Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/format.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,9 @@ jsonschema fmt path/to/my/schema.json --check
```sh
jsonschema fmt path/to/my/openapi.json
```

### Format the configuration file in-place

```sh
jsonschema fmt jsonschema.json
```
112 changes: 79 additions & 33 deletions src/command_fmt.cc
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
#include <sourcemeta/core/json.h>
#include <sourcemeta/core/jsonschema.h>

#include <iostream> // std::cerr, std::cout
#include <sstream> // std::ostringstream
#include <string> // std::string
#include <string_view> // std::string_view
#include <utility> // std::move, std::unreachable
#include <filesystem> // std::filesystem
#include <iostream> // std::cerr, std::cout
#include <sstream> // std::ostringstream
#include <string> // std::string
#include <string_view> // std::string_view
#include <system_error> // std::error_code
#include <utility> // std::move, std::unreachable

#include "command.h"
#include "error.h"
Expand Down Expand Up @@ -152,6 +154,28 @@ auto formatted_copy(const sourcemeta::core::JSON &document,
return copy;
}

// A configuration file read through a path of its own, such as a symbolic link
// or a spelling that the filesystem matches without regard to case, is still
// the very file the lookup settled on. What tells the two apart is therefore
// the identity of the file rather than the path that happens to name it
auto same_file(const std::filesystem::path &left,
const std::filesystem::path &right) -> bool {
if (left == right) {
return true;
}

std::error_code error;
const auto result{std::filesystem::equivalent(left, right, error)};
return !error && result;
}

auto configuration_copy(const sourcemeta::core::JSON &document)
-> sourcemeta::core::JSON {
auto copy{document};
sourcemeta::jsonschema::format_configuration(copy);
return copy;
}

} // namespace

auto sourcemeta::jsonschema::fmt(const sourcemeta::core::Options &options)
Expand Down Expand Up @@ -281,13 +305,22 @@ auto sourcemeta::jsonschema::fmt(const sourcemeta::core::Options &options)
entry.resolution_base};
}

if (!entry.second.is_object() && !entry.second.is_boolean()) {
throw NotSchemaError{entry.resolution_base};
}
const auto configuration_path{
find_configuration(options, entry.resolution_base)};
// The configuration file in effect for its own location is the one this
// goes on to order by the configuration format rather than by any dialect,
// so none of what tells a schema from the rest has a say over it
const auto is_configuration{
configuration_path.has_value() &&
same_file(configuration_path.value(), entry.resolution_base)};

if (!is_configuration) {
if (!entry.second.is_object() && !entry.second.is_boolean()) {
throw NotSchemaError{entry.resolution_base};
}

reject_unsupported_openapi(entry.second, entry.resolution_base);
const auto is_openapi{
sourcemeta::core::openapi_version(entry.second).has_value()};
reject_unsupported_openapi(entry.second, entry.resolution_base);
}

if (options.contains("check")) {
LOG_VERBOSE(options) << "Checking: " << entry.first << "\n";
Expand All @@ -296,30 +329,43 @@ auto sourcemeta::jsonschema::fmt(const sourcemeta::core::Options &options)
}

try {
const auto configuration_path{
find_configuration(options, entry.resolution_base)};
const auto &configuration{read_configuration(options, configuration_path,
entry.resolution_base)};
const auto dialect{default_dialect(options, configuration)};
const auto is_test_document =
dialect.empty() && looks_like_test_document(entry.second);
const auto effective_dialect =
is_test_document ? TEST_DOCUMENT_DEFAULT_DIALECT : dialect;
if (is_test_document) {
std::cerr << "Interpreting as a test file: " << entry.first << "\n";
}
const auto &custom_resolver{resolver(options, options.contains("http"),
effective_dialect, configuration)};

std::ostringstream expected;
if (options.contains("keep-ordering")) {
sourcemeta::jsonschema::write_schema(entry.second, expected,
indentation, entry.roundtrip);
if (is_configuration) {

@augmentcode augmentcode Bot Oct 10, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you check the new branch at src/command_fmt.cc:316 with "ignore": ["./jsonschema.json"], a pattern already used in test/format/pass_cwd_config_no_path.clitest? for_each_json filters the explicitly requested configuration out at src/input.h:624–629 before this branch runs, so fmt jsonschema.json leaves it unchanged and fmt jsonschema.json --check reports success even when it is unformatted.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

std::cerr << "Interpreting as a configuration file: " << entry.first

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This message prints unconditionally, so a passing jsonschema fmt --check jsonschema.json in CI now always emits a line on stderr even without --verbose, polluting otherwise-silent check output. Guard it with LOG_VERBOSE(options) like the surrounding check/format progress logs, or emit it only in the non-check path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/command_fmt.cc, line 317:

<comment>This message prints unconditionally, so a passing `jsonschema fmt --check jsonschema.json` in CI now always emits a line on stderr even without `--verbose`, polluting otherwise-silent check output. Guard it with `LOG_VERBOSE(options)` like the surrounding check/format progress logs, or emit it only in the non-check path.</comment>

<file context>
@@ -296,30 +312,43 @@ auto sourcemeta::jsonschema::fmt(const sourcemeta::core::Options &options)
-        sourcemeta::jsonschema::write_schema(entry.second, expected,
-                                             indentation, entry.roundtrip);
+      if (is_configuration) {
+        std::cerr << "Interpreting as a configuration file: " << entry.first
+                  << "\n";
+        if (options.contains("keep-ordering")) {
</file context>
Suggested change
std::cerr << "Interpreting as a configuration file: " << entry.first
LOG_VERBOSE(options) << "Interpreting as a configuration file: "
<< entry.first << "\n";

<< "\n";
if (options.contains("keep-ordering")) {
sourcemeta::jsonschema::write_schema(entry.second, expected,
indentation, entry.roundtrip);
} else {
sourcemeta::jsonschema::write_schema(configuration_copy(entry.second),
expected, indentation,
entry.roundtrip);
}
} else {
sourcemeta::jsonschema::write_schema(
formatted_copy(entry.second, custom_resolver, effective_dialect,
openapi_default_id(entry), is_openapi),
expected, indentation, entry.roundtrip);
const auto &configuration{read_configuration(
options, configuration_path, entry.resolution_base)};
const auto dialect{default_dialect(options, configuration)};
const auto is_test_document =
dialect.empty() && looks_like_test_document(entry.second);
const auto effective_dialect =
is_test_document ? TEST_DOCUMENT_DEFAULT_DIALECT : dialect;
if (is_test_document) {
std::cerr << "Interpreting as a test file: " << entry.first << "\n";
}
const auto &custom_resolver{resolver(options, options.contains("http"),
effective_dialect, configuration)};
const auto is_openapi{
sourcemeta::core::openapi_version(entry.second).has_value()};

if (options.contains("keep-ordering")) {
sourcemeta::jsonschema::write_schema(entry.second, expected,
indentation, entry.roundtrip);
} else {
sourcemeta::jsonschema::write_schema(
formatted_copy(entry.second, custom_resolver, effective_dialect,
openapi_default_id(entry), is_openapi),
expected, indentation, entry.roundtrip);
}
}

const auto current{
Expand Down
118 changes: 116 additions & 2 deletions src/utils.h
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,19 @@
#include "error.h"
#include "input.h"

#include <algorithm> // std::max, std::ranges::all_of
#include <cctype> // std::isdigit
#include <algorithm> // std::max, std::ranges::all_of, std::ranges::find, std::sort
#include <array> // std::array, std::to_array
#include <cctype> // std::isdigit
#include <cstddef> // std::size_t
#include <cstdint> // std::uint16_t
#include <filesystem> // std::filesystem::path
#include <iterator> // std::ranges::distance
#include <limits> // std::numeric_limits
#include <memory> // std::make_shared
#include <optional> // std::optional
#include <ostream> // std::ostream
#include <set> // std::set
#include <span> // std::span
#include <stdexcept> // std::out_of_range
#include <string> // std::string, std::stoull
#include <string_view> // std::string_view
Expand Down Expand Up @@ -143,6 +148,115 @@ inline auto looks_like_test_document(const sourcemeta::core::JSON &document)
document.defines("tests") && document.at("tests").is_array();
}

// The order in which properties are meant to appear within a configuration
// file, where the position of an entry is the rank of the property it names.
// This is the order upstream spells when it serializes a configuration of its
// own, so a configuration this writes is one upstream would have written
constexpr auto CONFIGURATION_PROPERTIES{std::to_array<std::string_view>(
{"title", "description", "email", "github", "website", "path", "baseUri",
"defaultDialect", "extension", "resolve", "dependencies", "ignore",
"lint"})};

constexpr auto CONFIGURATION_LINT_PROPERTIES{
std::to_array<std::string_view>({"rules", "exclude"})};

inline auto
configuration_property_rank(const std::span<const std::string_view> properties,
const sourcemeta::core::JSON::String &property)
-> std::uint16_t {
constexpr auto UNRECOGNISED{std::numeric_limits<std::uint16_t>::max()};
const auto match{std::ranges::find(properties, property)};
if (match == properties.end()) {
return UNRECOGNISED;
}

return static_cast<std::uint16_t>(
std::ranges::distance(properties.begin(), match));
}

// A property the configuration format does not name goes last, and goes in
// alphabetical order among the rest of its kind, so that what this writes does
// not depend on the order the properties were read in
inline auto configuration_property_compare(
const std::span<const std::string_view> properties,
const sourcemeta::core::JSON::String &left,
const sourcemeta::core::JSON::String &right) -> bool {
const auto left_rank{configuration_property_rank(properties, left)};
const auto right_rank{configuration_property_rank(properties, right)};
if (left_rank == right_rank) {
return left < right;
}

return left_rank < right_rank;
}

// A configuration property whose value stands for a set rather than a sequence,
// where the order the entries were written in carries no meaning of its own
inline auto sort_configuration_set(sourcemeta::core::JSON &value) -> void {
if (!value.is_array()) {
return;
}

std::sort(value.as_array().begin(), value.as_array().end());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Formatting a configuration file no longer validates it. Previously fmt always ran read_configuration -> blaze Configuration::from_json on the entry, so a malformed jsonschema.json (non-object root, or extension/ignore/exclude with non-string members) was rejected with a positioned ConfigurationParseError. The new is_configuration branch in command_fmt.cc skips that path entirely and just runs format_configuration plus write_schema, so such documents are silently reordered/rewritten (and --check passes on them). As a secondary risk, this std::sort compares heterogeneous JSON values via JSON::operator< (sourcemeta::core::JSON::operator<, vendor/core json_value.h), which core only defines for comparable values and which can throw, crashing fmt on arrays mixing types. Validate the document as a configuration (e.g., call read_configuration and surface ConfigurationParseError) before formatting it as one, or at minimum bail out of sorting when the array contains non-string elements.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/utils.h, line 200:

<comment>Formatting a configuration file no longer validates it. Previously `fmt` always ran `read_configuration` -> blaze `Configuration::from_json` on the entry, so a malformed `jsonschema.json` (non-object root, or `extension`/`ignore`/`exclude` with non-string members) was rejected with a positioned `ConfigurationParseError`. The new `is_configuration` branch in `command_fmt.cc` skips that path entirely and just runs `format_configuration` plus `write_schema`, so such documents are silently reordered/rewritten (and `--check` passes on them). As a secondary risk, this `std::sort` compares heterogeneous `JSON` values via `JSON::operator<` (`sourcemeta::core::JSON::operator<`, vendor/core `json_value.h`), which core only defines for comparable values and which can throw, crashing `fmt` on arrays mixing types. Validate the document as a configuration (e.g., call `read_configuration` and surface `ConfigurationParseError`) before formatting it as one, or at minimum bail out of sorting when the array contains non-string elements.</comment>

<file context>
@@ -143,6 +148,115 @@ inline auto looks_like_test_document(const sourcemeta::core::JSON &document)
+    return;
+  }
+
+  std::sort(value.as_array().begin(), value.as_array().end());
+}
+
</file context>

}

inline auto sort_configuration_map(sourcemeta::core::JSON &value) -> void {
if (!value.is_object()) {
return;
}

value.reorder(
[](const auto &left, const auto &right) { return left < right; });
}

// A configuration file is not a schema, so what orders it is the configuration
// format rather than any dialect. Every property the file spells is kept as it
// was written, as the only thing at stake here is the order they come in
inline auto format_configuration(sourcemeta::core::JSON &configuration)
-> void {
if (!configuration.is_object()) {
return;
}

auto *extension{configuration.try_at("extension")};
if (extension != nullptr) {
sort_configuration_set(*extension);
}

auto *ignore{configuration.try_at("ignore")};
if (ignore != nullptr) {
sort_configuration_set(*ignore);
}

auto *resolve{configuration.try_at("resolve")};
if (resolve != nullptr) {
sort_configuration_map(*resolve);
}

auto *dependencies{configuration.try_at("dependencies")};
if (dependencies != nullptr) {
sort_configuration_map(*dependencies);
}

auto *lint{configuration.try_at("lint")};
if (lint != nullptr && lint->is_object()) {
auto *exclude{lint->try_at("exclude")};
if (exclude != nullptr) {
sort_configuration_set(*exclude);
}

lint->reorder([](const auto &left, const auto &right) {
return configuration_property_compare(CONFIGURATION_LINT_PROPERTIES, left,
right);
});
}

configuration.reorder([](const auto &left, const auto &right) {
return configuration_property_compare(CONFIGURATION_PROPERTIES, left,
right);
});
}

// Whether a document holds an OpenAPI Description at all, whichever revision it
// declares. The `openapi` field is what the specification identifies one by,
// and one we cannot read is a description all the same, so this is what tells a
Expand Down
14 changes: 14 additions & 0 deletions test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,20 @@ add_jsonschema_test(format/pass_test_document_with_comment)
add_jsonschema_test(format/pass_test_document_explicit_dialect)
add_jsonschema_test(format/pass_test_document_directory)
add_jsonschema_test(format/pass_test_document_schema_keyword)
add_jsonschema_test(format/pass_configuration_file)
add_jsonschema_test(format/pass_configuration_file_unformatted)
add_jsonschema_test(format/pass_check_configuration_file)
add_jsonschema_test(format/fail_check_configuration_file)
add_jsonschema_test(format/fail_check_configuration_file_json)
add_jsonschema_test(format/pass_configuration_file_keep_ordering)
add_jsonschema_test(format/pass_configuration_file_sets)
add_jsonschema_test(format/pass_configuration_file_resolve)
add_jsonschema_test(format/pass_configuration_file_unknown_properties)
add_jsonschema_test(format/pass_configuration_file_dependencies)
add_jsonschema_test(format/pass_configuration_file_without_dialect)
add_jsonschema_test(format/pass_configuration_file_custom_name)
add_jsonschema_test(format/pass_configuration_file_nested)
add_jsonschema_test_unix(format/pass_configuration_file_symlink)
add_jsonschema_test(format/pass_bundled_metaschema)
add_jsonschema_test(format/pass_check_color_always_verbose)
add_jsonschema_test(format/pass_check_color_never_verbose)
Expand Down
33 changes: 33 additions & 0 deletions test/format/fail_check_configuration_file.clitest
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
WRITE jsonschema.json UNTIL EOF
{
"baseUri": "https://example.com",
"title": "My project"
}
EOF

COPY jsonschema.json TO original.json

// Validation failure
RUN fmt jsonschema.json --check STDIN /dev/null IN . INTO result.txt EXPECTING 2
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

REPLACE $CWD WITH '[CWD]' IN result.txt

WRITE expected.txt UNTIL EOF
2> Interpreting as a configuration file: [CWD]/jsonschema.json
2> fail: [CWD]/jsonschema.json
2> --- current
2> +++ expected
2> @@ -1,4 +1,4 @@
2> {
2> - "baseUri": "https://example.com",
2> - "title": "My project"
2> + "title": "My project",
2> + "baseUri": "https://example.com"
2> }
2>
2> Run the `fmt` command without `--check/-c` to fix the formatting
EOF

COMPARE result.txt AGAINST expected.txt

COMPARE jsonschema.json AGAINST original.json
53 changes: 53 additions & 0 deletions test/format/fail_check_configuration_file_json.clitest
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
WRITE jsonschema.json UNTIL EOF
{
"baseUri": "https://example.com",
"title": "My project"
}
EOF

COPY jsonschema.json TO original.json

// Validation failure
RUN fmt jsonschema.json --check --json STDIN /dev/null IN . INTO result.txt EXPECTING 2

REPLACE $CWD WITH '[CWD]' IN result.txt

WRITE expected.txt UNTIL EOF
1> {
1> "valid": false,
1> "errors": [
1> {
1> "path": "[CWD]/jsonschema.json",
1> "diff": [
1> {
1> "type": "equal",
1> "lines": [ "{" ]
1> },
1> {
1> "type": "delete",
1> "lines": [
1> " \"baseUri\": \"https://example.com\",",
1> " \"title\": \"My project\""
1> ]
1> },
1> {
1> "type": "insert",
1> "lines": [
1> " \"title\": \"My project\",",
1> " \"baseUri\": \"https://example.com\""
1> ]
1> },
1> {
1> "type": "equal",
1> "lines": [ "}" ]
1> }
1> ]
1> }
1> ]
1> }
2> Interpreting as a configuration file: [CWD]/jsonschema.json
EOF

COMPARE result.txt AGAINST expected.txt

COMPARE jsonschema.json AGAINST original.json
Loading
Loading