Skip to content
Merged
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
2 changes: 1 addition & 1 deletion DEPENDENCIES
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
vendorpull https://github.com/sourcemeta/vendorpull 1dcbac42809cf87cb5b045106b863e17ad84ba02
core https://github.com/sourcemeta/core 367f6ef45c8e8f05269d65d98dab155505f39ccb
jsonbinpack https://github.com/sourcemeta/jsonbinpack 183f333e0e032ebf0a397b5d66ed504c44a0a59f
blaze https://github.com/sourcemeta/blaze 18618eccba9609f8beb5563ce57b32ec551f39bf
blaze https://github.com/sourcemeta/blaze 9d13de453c340797d3214dc12e7d9382f7f3fb47
ctrf https://github.com/ctrf-io/ctrf 93ea827d951390190171d37443bff169cf47c808
5 changes: 4 additions & 1 deletion completion/jsonschema.bash
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ _jsonschema() {
if [ "${command}" = "upgrade" ]
then
COMPREPLY=( $(compgen -W "draft4 draft6 draft7 2019-09 2020-12" -- "${current}") )
elif [ "${command}" = "lint" ]
then
COMPREPLY=( $(compgen -f -X '!*.json' -X '!*.yaml' -X '!*.yml' -- "${current}") )
else
COMPREPLY=( $(compgen -W "typescript" -- "${current}") )
fi
Expand Down Expand Up @@ -142,7 +145,7 @@ _jsonschema() {
fi
;;
lint)
local options="--fix -f --extension -e --ignore -i --exclude -x --only -o --list -l --indentation -n"
local options="--fix -f --extension -e --ignore -i --exclude -x --only -o --list -l --indentation -n --rule -a --top-level-rule -t"

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: After jsonschema lint -t , Bash completion offers typescript, which is not a valid top-level rule argument. The new alias needs a lint-specific -t argument completion before the existing codegen/upgrade handling.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At completion/jsonschema.bash, line 145:

<comment>After `jsonschema lint -t `, Bash completion offers `typescript`, which is not a valid top-level rule argument. The new alias needs a `lint`-specific `-t` argument completion before the existing codegen/upgrade handling.</comment>

<file context>
@@ -142,7 +142,7 @@ _jsonschema() {
       ;;
     lint)
-      local options="--fix -f --extension -e --ignore -i --exclude -x --only -o --list -l --indentation -n"
+      local options="--fix -f --extension -e --ignore -i --exclude -x --only -o --list -l --indentation -n --rule -a --top-level-rule -t"
       if [[ ${current} == -* ]]
       then
</file context>

if [[ ${current} == -* ]]
then
COMPREPLY=( $(compgen -W "${options} ${global_options}" -- "${current}") )
Expand Down
2 changes: 2 additions & 0 deletions completion/jsonschema.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ _jsonschema() {
'(--only -o)'{--only,-o}'[Only run specific rule]:rule name:' \
'(--list -l)'{--list,-l}'[List all enabled rules]' \
'(--indentation -n)'{--indentation,-n}'[Specify indentation spaces]:spaces:(2 4 8)' \
'(--rule -a)'{--rule,-a}'[Add a custom lint rule]:rule schema:_files -g "*.json *.yaml *.yml"' \
'(--top-level-rule -t)'{--top-level-rule,-t}'[Add a custom lint rule that only runs against the document root]:rule schema:_files -g "*.json *.yaml *.yml"' \
'*:schema file:_files -g "*.json *.yaml *.yml"'
;;
bundle)
Expand Down
5 changes: 3 additions & 2 deletions docs/configuration.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ The `jsonschema.json` file format looks like this:
],
"lint": {
"rules": [
"./rules/require_type.json"
"./rules/require_type.json",
{ "path": "./rules/require_id.json", "topLevel": true }
]
}
}
Expand All @@ -61,7 +62,7 @@ describes the available configuration options:
| `resolve` | Object | A mapping of URIs to local file paths or other URIs for schema resolution remapping. Lookups are non-transitive: the value of a matching entry is the final target and is not itself looked up in `resolve` again | `{}` |
| `dependencies` | Object | A mapping of URIs to relative file paths for external schema dependencies to install (see [`jsonschema install`](./install.markdown)) | `{}` |
| `lint` | Object | Lint configuration | `{}` |
| `lint.rules` | String[] | Paths to custom lint rule schemas relative to `jsonschema.json` (see [lint](./lint.markdown)) | `[]` |
| `lint.rules` | String[] / Object[] | Paths to custom lint rule schemas relative to `jsonschema.json` (see [lint](./lint.markdown)). An entry may also be an object with a required `path` string property and an optional `topLevel` boolean property (defaults to `false`) that makes the rule only run against the document root | `[]` |

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: The new type notation String[] / Object[] incorrectly suggests lint.rules must be homogeneous (all strings or all objects), but the implementation allows mixing both types in the same array — each entry can independently be a string or an object with a path/topLevel shape. Consider reverting to the previous (String / Object)[] notation, which more accurately describes (String | Object)[] — an array whose individual elements can be either type.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/configuration.markdown, line 65:

<comment>The new type notation `String[] / Object[]` incorrectly suggests `lint.rules` must be homogeneous (all strings **or** all objects), but the implementation allows mixing both types in the same array — each entry can independently be a string or an object with a `path`/`topLevel` shape. Consider reverting to the previous `(String / Object)[]` notation, which more accurately describes `(String | Object)[]` — an array whose individual elements can be either type.</comment>

<file context>
@@ -62,7 +62,7 @@ describes the available configuration options:
 | `dependencies` | Object | A mapping of URIs to relative file paths for external schema dependencies to install (see [`jsonschema install`](./install.markdown)) | `{}` |
 | `lint` | Object | Lint configuration | `{}` |
-| `lint.rules` | (String / Object)[] | Paths to custom lint rule schemas relative to `jsonschema.json` (see [lint](./lint.markdown)). An entry may also be an object with a required `path` string property and an optional `topLevel` boolean property (defaults to `false`) that makes the rule only run against the document root | `[]` |
+| `lint.rules` | String[] / Object[] | Paths to custom lint rule schemas relative to `jsonschema.json` (see [lint](./lint.markdown)). An entry may also be an object with a required `path` string property and an optional `topLevel` boolean property (defaults to `false`) that makes the rule only run against the document root | `[]` |
 
 Lookup Algorithm
</file context>
Suggested change
| `lint.rules` | String[] / Object[] | Paths to custom lint rule schemas relative to `jsonschema.json` (see [lint](./lint.markdown)). An entry may also be an object with a required `path` string property and an optional `topLevel` boolean property (defaults to `false`) that makes the rule only run against the document root | `[]` |
| `lint.rules` | (String / Object)[] | Paths to custom lint rule schemas relative to `jsonschema.json` (see [lint](./lint.markdown)). An entry may also be an object with a required `path` string property and an optional `topLevel` boolean property (defaults to `false`) that makes the rule only run against the document root | `[]` |


Lookup Algorithm
----------------
Expand Down
32 changes: 30 additions & 2 deletions docs/lint.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ jsonschema lint [schemas-or-directories...] [--http/-h] [--fix/-f]
[--resolve/-r <schemas-or-directories> ...]
[--extension/-e <extension>] [--ignore/-i <schemas-or-directories>]
[--exclude/-x <rule-name>] [--only/-o <rule-name>] [--list/-l]
[--rule/-a <rule-schema>] [--format-assertion/-F]
[--rule/-a <rule-schema>] [--top-level-rule/-t <rule-schema>]
[--format-assertion/-F]
[--default-dialect/-d <uri>] [--indentation/-n <spaces>]
```

Expand Down Expand Up @@ -127,8 +128,29 @@ You can pass multiple custom rules:
jsonschema lint --rule rule1.json --rule rule2.json path/to/my/schema.json
```

If a custom rule only makes sense against the document root, register it with
the `--top-level-rule/-t` option instead. The rule will not run against any
other subschema. For example, create a rule that requires the document root to
declare an `$id`:

```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "require_id",
"description": "The root schema must declare an $id",
"required": [ "$id" ]
}
```

Then run:

```sh
jsonschema lint --top-level-rule require_id.json path/to/my/schema.json
```

Custom rules can also be declared in the
[`jsonschema.json`](./configuration.markdown) configuration file.
[`jsonschema.json`](./configuration.markdown) configuration file, where an
object entry with `topLevel` set to `true` marks the rule as top-level only.

Examples
--------
Expand Down Expand Up @@ -249,6 +271,12 @@ jsonschema lint --rule path/to/my/rule.json path/to/my/schema.json
jsonschema lint --rule rule1.json --rule rule2.json path/to/my/schema.json
```

### Lint with a custom rule that only runs against the document root

```sh
jsonschema lint --top-level-rule path/to/my/rule.json path/to/my/schema.json
```

### Lint with custom rules forcing every `format` to assert

The `--format-assertion`/`-F` option forces every `format` keyword in custom
Expand Down
61 changes: 41 additions & 20 deletions src/command_lint.cc
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,8 @@ static auto load_rule(sourcemeta::blaze::SchemaTransformer &bundle,
const std::filesystem::path &rule_path,
const std::string_view dialect,
const sourcemeta::blaze::SchemaResolver &custom_resolver,
const std::optional<sourcemeta::blaze::Tweaks> &tweaks)
const std::optional<sourcemeta::blaze::Tweaks> &tweaks,
const sourcemeta::blaze::SchemaRule::Scope scope)
-> void {
auto rule_schema{sourcemeta::core::read_yaml_or_json(rule_path)};
if (!rule_schema.defines("description")) {
Expand All @@ -169,7 +170,7 @@ static auto load_rule(sourcemeta::blaze::SchemaTransformer &bundle,
try {
bundle.add<sourcemeta::blaze::SchemaRule>(
rule_schema, sourcemeta::blaze::schema_walker, custom_resolver,
sourcemeta::blaze::default_schema_compiler, dialect, tweaks);
sourcemeta::blaze::default_schema_compiler, dialect, tweaks, scope);
} catch (const sourcemeta::blaze::SchemaRuleMissingNameError &error) {
throw sourcemeta::core::FileError<
sourcemeta::blaze::SchemaRuleMissingNameError>(rule_path, error);
Expand Down Expand Up @@ -199,6 +200,31 @@ static auto load_rule(sourcemeta::blaze::SchemaTransformer &bundle,
}
}

static auto
load_rules_from_options(sourcemeta::blaze::SchemaTransformer &bundle,
std::unordered_set<std::string> &rule_names,
const sourcemeta::core::Options &options,
const std::string_view option_name,
const sourcemeta::blaze::SchemaRule::Scope scope)
-> void {
for (const auto &rule_path_string : options.at(option_name)) {
const std::filesystem::path rule_path{
std::filesystem::weakly_canonical(rule_path_string)};
sourcemeta::jsonschema::LOG_VERBOSE(options)
<< "Loading custom rule: " << rule_path.string() << "\n";
const auto configuration_path{
sourcemeta::jsonschema::find_configuration(rule_path)};
const auto &configuration{sourcemeta::jsonschema::read_configuration(
options, configuration_path, rule_path)};
const auto dialect{
sourcemeta::jsonschema::default_dialect(options, configuration)};
const auto &custom_resolver{sourcemeta::jsonschema::resolver(
options, options.contains("http"), dialect, configuration)};
load_rule(bundle, rule_names, rule_path, dialect, custom_resolver,
sourcemeta::jsonschema::format_assertion_tweaks(options), scope);
}
}

auto sourcemeta::jsonschema::lint(const sourcemeta::core::Options &options)
-> void {
validate_http_headers(options);
Expand Down Expand Up @@ -249,29 +275,24 @@ auto sourcemeta::jsonschema::lint(const sourcemeta::core::Options &options)
const auto dialect{default_dialect(options, configuration)};
const auto &custom_resolver{
resolver(options, options.contains("http"), dialect, configuration)};
for (const auto &rule_path : configuration.value().lint.rules) {
for (const auto &rule : configuration.value().lint.rules) {
LOG_VERBOSE(options) << "Loading custom rule from configuration: "
<< rule_path.string() << "\n";
load_rule(bundle, rule_names, rule_path, dialect, custom_resolver,
sourcemeta::jsonschema::format_assertion_tweaks(options));
<< rule.path.string() << "\n";
load_rule(bundle, rule_names, rule.path, dialect, custom_resolver,
sourcemeta::jsonschema::format_assertion_tweaks(options),
rule.top_level ? sourcemeta::blaze::SchemaRule::Scope::TopLevel
: sourcemeta::blaze::SchemaRule::Scope::All);
}
}

if (options.contains("rule")) {
for (const auto &rule_path_string : options.at("rule")) {
const std::filesystem::path rule_path{
std::filesystem::weakly_canonical(rule_path_string)};
LOG_VERBOSE(options) << "Loading custom rule: " << rule_path.string()
<< "\n";
const auto configuration_path{find_configuration(rule_path)};
const auto &configuration{
read_configuration(options, configuration_path, rule_path)};
const auto dialect{default_dialect(options, configuration)};
const auto &custom_resolver{
resolver(options, options.contains("http"), dialect, configuration)};
load_rule(bundle, rule_names, rule_path, dialect, custom_resolver,
sourcemeta::jsonschema::format_assertion_tweaks(options));
}
load_rules_from_options(bundle, rule_names, options, "rule",
sourcemeta::blaze::SchemaRule::Scope::All);
}

if (options.contains("top-level-rule")) {
load_rules_from_options(bundle, rule_names, options, "top-level-rule",
sourcemeta::blaze::SchemaRule::Scope::TopLevel);
}

if (options.contains("only")) {
Expand Down
6 changes: 5 additions & 1 deletion src/main.cc
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ Global Options:
[--keep-ordering/-k] [--extension/-e <extension>]
[--ignore/-i <schemas-or-directories>] [--exclude/-x <rule-name>]
[--only/-o <rule-name>] [--list/-l] [--indentation/-n <spaces>]
[--rule/-a <rule-schema>] [--format-assertion/-F]
[--rule/-a <rule-schema>] [--top-level-rule/-t <rule-schema>]
[--format-assertion/-F]

Lint the input schemas and potentially fix the reported issues.
The --fix/-f option is not supported when passing YAML schemas.
Expand All @@ -97,6 +98,8 @@ Global Options:
Use --keep-ordering/-k with --format to preserve key order.
Use --list/-l to print a summary of all enabled rules.
Use --rule/-a to add a custom lint rule defined as a JSON Schema.
Use --top-level-rule/-t to add a custom lint rule that only runs
against the document root.

upgrade <schema.json|.yaml>
[--to/-t draft4|draft6|draft7|2019-09|2020-12] [--meta/-m]
Expand Down Expand Up @@ -189,6 +192,7 @@ auto jsonschema_main(const std::string &program, const std::string &command,
app.option("ignore", {"i"});
app.option("indentation", {"n"});
app.option("rule", {"a"});
app.option("top-level-rule", {"t"});
app.parse(argc, argv, {.skip = 1});
sourcemeta::jsonschema::lint(app);
return EXIT_SUCCESS;
Expand Down
15 changes: 15 additions & 0 deletions test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,14 @@ add_jsonschema_test_unix(lint/fail_lint_rule_unresolvable)
add_jsonschema_test_unix(lint/fail_lint_rule_invalid_yaml)
add_jsonschema_test_unix(lint/fail_lint_rule_property_names)
add_jsonschema_test_unix(lint/fail_lint_rule_property_names_json)
add_jsonschema_test_unix(lint/pass_lint_top_level_rule_no_violation)
add_jsonschema_test_unix(lint/pass_lint_top_level_rule_embedded_resource)
add_jsonschema_test_unix(lint/pass_lint_top_level_rule_yaml)
add_jsonschema_test_unix(lint/pass_lint_top_level_rule_exclude)
add_jsonschema_test_unix(lint/pass_lint_top_level_rule_verbose)
add_jsonschema_test_unix(lint/fail_lint_top_level_rule_violation)
add_jsonschema_test_unix(lint/fail_lint_top_level_rule_duplicate)
add_jsonschema_test_unix(lint/fail_lint_top_level_rule_fix)
add_jsonschema_test_unix(lint/pass_lint_config_rule_no_violation)
add_jsonschema_test_unix(lint/pass_lint_config_rule_multiple)
add_jsonschema_test_unix(lint/pass_lint_config_rule_verbose)
Expand All @@ -810,6 +818,13 @@ add_jsonschema_test_unix(lint/fail_lint_config_rule_missing_title)
add_jsonschema_test_unix(lint/pass_lint_config_rule_other_directory)
add_jsonschema_test_unix(lint/fail_lint_config_rule_other_directory)
add_jsonschema_test_unix(lint/fail_lint_config_rule_extension_mismatch)
add_jsonschema_test_unix(lint/pass_lint_config_rule_top_level)
add_jsonschema_test_unix(lint/pass_lint_config_rule_mixed_entries)
add_jsonschema_test_unix(lint/fail_lint_config_rule_top_level_violation)
add_jsonschema_test_unix(lint/fail_lint_config_rule_object_entry)
add_jsonschema_test_unix(lint/fail_lint_config_rule_invalid_entry)
add_jsonschema_test_unix(lint/fail_lint_config_rule_object_missing_path)
add_jsonschema_test_unix(lint/fail_lint_config_rule_object_invalid_top_level)
add_jsonschema_test_unix(lint/pass_lint_config_extension_directory)
add_jsonschema_test_unix(lint/pass_lint_config_extension_multi_directory)
add_jsonschema_test_unix(lint/pass_lint_config_ignore)
Expand Down
2 changes: 2 additions & 0 deletions test/autocomplete_bash.sh
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ test_completion "jsonschema validate --" "--fast" "Validate includes --fast"

test_completion "jsonschema lint --" "--fix" "Lint includes --fix"
test_completion "jsonschema lint --" "--list" "Lint includes --list"
test_completion "jsonschema lint --" "--rule" "Lint includes --rule"
test_completion "jsonschema lint --" "--top-level-rule" "Lint includes --top-level-rule"

test_completion "jsonschema bundle --" "--without-id" "Bundle includes --without-id"

Expand Down
53 changes: 53 additions & 0 deletions test/lint/fail_lint_config_rule_invalid_entry.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/bin/sh

set -o errexit
set -o nounset

TMP="$(mktemp -d)"
clean() { rm -rf "$TMP"; }
trap clean EXIT

cat << 'EOF' > "$TMP/jsonschema.json"
{
"lint": {
"rules": [ 1 ]
}
}
EOF

cat << 'EOF' > "$TMP/schema.json"
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "string"
}
EOF

cd "$TMP"
"$1" lint "$TMP/schema.json" \
> "$TMP/output.txt" 2>&1 && EXIT_CODE="$?" || EXIT_CODE="$?"
# Other input error
test "$EXIT_CODE" = "6"

cat << EOF > "$TMP/expected.txt"
error: The values in the lint rules array must be strings or objects
at file path $(realpath "$TMP")/jsonschema.json
at location "/lint/rules/0"
EOF

diff "$TMP/output.txt" "$TMP/expected.txt"

cd "$TMP"
"$1" lint --json "$TMP/schema.json" \
> "$TMP/output_json.txt" 2>&1 && EXIT_CODE="$?" || EXIT_CODE="$?"
# Other input error
test "$EXIT_CODE" = "6"

cat << EOF > "$TMP/expected_json.txt"
{
"error": "The values in the lint rules array must be strings or objects",
"filePath": "$(realpath "$TMP")/jsonschema.json",
"location": "/lint/rules/0"
}
EOF

diff "$TMP/output_json.txt" "$TMP/expected_json.txt"
78 changes: 78 additions & 0 deletions test/lint/fail_lint_config_rule_object_entry.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/bin/sh

set -o errexit
set -o nounset

TMP="$(mktemp -d)"
clean() { rm -rf "$TMP"; }
trap clean EXIT

cat << 'EOF' > "$TMP/rule.json"
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "require_id",
"description": "The root schema must declare an $id",
"required": [ "$id" ]
}
EOF

cat << 'EOF' > "$TMP/jsonschema.json"
{
"lint": {
"rules": [
{ "path": "./rule.json" }
]
}
}
EOF

cat << 'EOF' > "$TMP/schema.json"
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/schema",
"type": "object",
"properties": {
"foo": { "type": "string" }
}
}
EOF

cd "$TMP"
"$1" lint --only require_id "$TMP/schema.json" \
> "$TMP/output.txt" 2>&1 && EXIT_CODE="$?" || EXIT_CODE="$?"
# Lint violation
test "$EXIT_CODE" = "2"

cat << 'EOF' > "$TMP/expected.txt"
schema.json:6:5:
The root schema must declare an $id (require_id)
at location "/properties/foo"
The object value was expected to define the property "$id"
EOF

diff "$TMP/output.txt" "$TMP/expected.txt"

cd "$TMP"
"$1" lint --only require_id --json "$TMP/schema.json" \
> "$TMP/output_json.txt" 2>&1 && EXIT_CODE="$?" || EXIT_CODE="$?"
# Lint violation
test "$EXIT_CODE" = "2"

cat << EOF > "$TMP/expected_json.txt"
{
"valid": false,
"health": 50,
"errors": [
{
"path": "$(realpath "$TMP")/schema.json",
"id": "require_id",
"message": "The root schema must declare an \$id",
"description": "The object value was expected to define the property \"\$id\"",
"schemaLocation": [ "properties", "foo" ],
"position": [ 6, 5, 6, 31 ]
}
]
}
EOF

diff "$TMP/output_json.txt" "$TMP/expected_json.txt"
Loading
Loading