From 22b5a40bbcdcf46904c991dc4c38639c2f8f2768 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Wed, 15 Jul 2026 17:42:23 -0300 Subject: [PATCH 1/4] WIP Signed-off-by: Juan Cruz Viotti --- DEPENDENCIES | 2 +- vendor/blaze/DEPENDENCIES | 2 +- .../v1/2019-09/dialect.json | 40 ++++ .../v1/2019-09/vocabulary.json | 82 ++++++++ .../v1/2020-12/dialect.json | 41 ++++ .../v1/2020-12/vocabulary.json | 82 ++++++++ .../sourcemeta-extension/v1/README.markdown | 192 ++++++++++++++++++ .../include/sourcemeta/blaze/alterschema.h | 28 ++- vendor/blaze/src/alterschema/schema_rule.cc | 27 ++- vendor/blaze/src/compiler/compile.cc | 57 ++++++ vendor/blaze/src/compiler/compile_helpers.h | 15 -- vendor/blaze/src/compiler/default_compiler.cc | 3 +- .../src/compiler/default_compiler_2019_09.h | 16 +- .../src/compiler/default_compiler_2020_12.h | 6 +- .../src/compiler/default_compiler_draft3.h | 2 +- .../src/compiler/default_compiler_draft4.h | 4 +- .../include/sourcemeta/blaze/compiler.h | 7 +- .../include/sourcemeta/blaze/configuration.h | 7 +- vendor/blaze/src/configuration/json.cc | 12 +- vendor/blaze/src/configuration/parse.cc | 30 ++- .../blaze/src/evaluator/evaluator_describe.cc | 105 ++++++++++ .../blaze/foundation_vocabularies.h | 6 +- vendor/blaze/src/foundation/vocabularies.cc | 4 +- 23 files changed, 705 insertions(+), 65 deletions(-) create mode 100644 vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/dialect.json create mode 100644 vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json create mode 100644 vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/dialect.json create mode 100644 vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json create mode 100644 vendor/blaze/schemas/sourcemeta-extension/v1/README.markdown diff --git a/DEPENDENCIES b/DEPENDENCIES index 4ab598f16..706eaee74 100644 --- a/DEPENDENCIES +++ b/DEPENDENCIES @@ -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 diff --git a/vendor/blaze/DEPENDENCIES b/vendor/blaze/DEPENDENCIES index ae0e10b48..4f98c23bb 100644 --- a/vendor/blaze/DEPENDENCIES +++ b/vendor/blaze/DEPENDENCIES @@ -1,5 +1,5 @@ vendorpull https://github.com/sourcemeta/vendorpull 1dcbac42809cf87cb5b045106b863e17ad84ba02 -core https://github.com/sourcemeta/core 367f6ef45c8e8f05269d65d98dab155505f39ccb +core https://github.com/sourcemeta/core 2060a4f64a51bd277c4da802321f20e06089c145 jsonschema-test-suite https://github.com/json-schema-org/JSON-Schema-Test-Suite 1acd90e53554fa24d2529b49fd7d50bab18f8b7e jsonschema-2020-12 https://github.com/json-schema-org/json-schema-spec 769daad75a9553562333a8937a187741cb708c72 jsonschema-2019-09 https://github.com/json-schema-org/json-schema-spec 41014ea723120ce70b314d72f863c6929d9f3cfd diff --git a/vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/dialect.json b/vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/dialect.json new file mode 100644 index 000000000..33f13901e --- /dev/null +++ b/vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/dialect.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$vocabulary": { + "https://json-schema.org/draft/2019-09/vocab/core": true, + "https://json-schema.org/draft/2019-09/vocab/applicator": true, + "https://json-schema.org/draft/2019-09/vocab/validation": true, + "https://json-schema.org/draft/2019-09/vocab/meta-data": true, + "https://json-schema.org/draft/2019-09/vocab/format": false, + "https://json-schema.org/draft/2019-09/vocab/content": true, + "tag:sourcemeta.com,2026:extension/v1": true + }, + "$recursiveAnchor": true, + "title": "Sourcemeta dialect", + "description": "JSON Schema 2019-09 dialect that extends the official dialect with the Sourcemeta Extension vocabulary", + "examples": [ + true, + { + "type": "string", + "x-jsonld-id": "https://schema.org/name" + }, + { + "type": "object", + "x-jsonld-type": "https://schema.org/Product", + "properties": { + "name": { + "type": "string", + "x-jsonld-id": "https://schema.org/name" + } + } + } + ], + "allOf": [ + { + "$ref": "https://json-schema.org/draft/2019-09/schema" + }, + { + "$ref": "vocabulary.json" + } + ] +} diff --git a/vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json b/vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json new file mode 100644 index 000000000..60e0aad59 --- /dev/null +++ b/vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$vocabulary": { + "tag:sourcemeta.com,2026:extension/v1": true + }, + "$recursiveAnchor": true, + "title": "Sourcemeta Extension vocabulary", + "description": "Meta-schema that describes the keywords of the Sourcemeta Extension vocabulary", + "examples": [ + true, + { + "x-jsonld-id": "https://schema.org/name" + }, + { + "x-jsonld-type": "https://schema.org/Product", + "x-jsonld-graph": true + }, + { + "format": "uri", + "x-format-assertion": true + } + ], + "type": [ "object", "boolean" ], + "properties": { + "x-format-assertion": { + "type": "boolean" + }, + "x-jsonld-container": { + "enum": [ "@list", "@set", "@language", "@index" ] + }, + "x-jsonld-datatype": { + "x-format-assertion": true, + "type": "string", + "format": "iri" + }, + "x-jsonld-direction": { + "enum": [ "ltr", "rtl" ] + }, + "x-jsonld-graph": { + "type": "boolean" + }, + "x-jsonld-id": { + "x-format-assertion": true, + "type": "string", + "format": "iri" + }, + "x-jsonld-json": { + "type": "boolean" + }, + "x-jsonld-language": { + "type": "string", + "pattern": "^(?:[Ee][Nn]-[Gg][Bb]-[Oo][Ee][Dd]|[Ii]-(?:[Aa][Mm][Ii]|[Bb][Nn][Nn]|[Dd][Ee][Ff][Aa][Uu][Ll][Tt]|[Ee][Nn][Oo][Cc][Hh][Ii][Aa][Nn]|[Hh][Aa][Kk]|[Kk][Ll][Ii][Nn][Gg][Oo][Nn]|[Ll][Uu][Xx]|[Mm][Ii][Nn][Gg][Oo]|[Nn][Aa][Vv][Aa][Jj][Oo]|[Pp][Ww][Nn]|[Tt][Aa][Oo]|[Tt][Aa][Yy]|[Tt][Ss][Uu])|[Ss][Gg][Nn]-(?:[Bb][Ee]-[Ff][Rr]|[Bb][Ee]-[Nn][Ll]|[Cc][Hh]-[Dd][Ee])|(?:(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{4,8})(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|[0-9]{3}))?(?:-(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(?:-[0-9A-WY-Za-wy-z](?:-[A-Za-z0-9]{2,8})+)*(?:-[Xx](?:-[A-Za-z0-9]{1,8})+)?)|[Xx](?:-[A-Za-z0-9]{1,8})+)$" + }, + "x-jsonld-reverse": { + "x-format-assertion": true, + "type": "string", + "format": "iri" + }, + "x-jsonld-self": { + "x-format-assertion": true, + "type": "string", + "format": "uri-template" + }, + "x-jsonld-type": { + "anyOf": [ + { + "x-format-assertion": true, + "type": "string", + "format": "iri" + }, + { + "type": "array", + "items": { + "x-format-assertion": true, + "type": "string", + "format": "iri" + } + } + ] + } + } +} diff --git a/vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/dialect.json b/vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/dialect.json new file mode 100644 index 000000000..9701ca5aa --- /dev/null +++ b/vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/dialect.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, + "https://json-schema.org/draft/2020-12/vocab/validation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": true, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, + "https://json-schema.org/draft/2020-12/vocab/content": true, + "tag:sourcemeta.com,2026:extension/v1": true + }, + "$dynamicAnchor": "meta", + "title": "Sourcemeta dialect", + "description": "JSON Schema 2020-12 dialect that extends the official dialect with the Sourcemeta Extension vocabulary", + "examples": [ + true, + { + "type": "string", + "x-jsonld-id": "https://schema.org/name" + }, + { + "type": "object", + "x-jsonld-type": "https://schema.org/Product", + "properties": { + "name": { + "type": "string", + "x-jsonld-id": "https://schema.org/name" + } + } + } + ], + "allOf": [ + { + "$ref": "https://json-schema.org/draft/2020-12/schema" + }, + { + "$ref": "vocabulary.json" + } + ] +} diff --git a/vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json b/vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json new file mode 100644 index 000000000..607faeb42 --- /dev/null +++ b/vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$vocabulary": { + "tag:sourcemeta.com,2026:extension/v1": true + }, + "$dynamicAnchor": "meta", + "title": "Sourcemeta Extension vocabulary", + "description": "Meta-schema that describes the keywords of the Sourcemeta Extension vocabulary", + "examples": [ + true, + { + "x-jsonld-id": "https://schema.org/name" + }, + { + "x-jsonld-type": "https://schema.org/Product", + "x-jsonld-graph": true + }, + { + "format": "uri", + "x-format-assertion": true + } + ], + "type": [ "object", "boolean" ], + "properties": { + "x-format-assertion": { + "type": "boolean" + }, + "x-jsonld-container": { + "enum": [ "@list", "@set", "@language", "@index" ] + }, + "x-jsonld-datatype": { + "x-format-assertion": true, + "type": "string", + "format": "iri" + }, + "x-jsonld-direction": { + "enum": [ "ltr", "rtl" ] + }, + "x-jsonld-graph": { + "type": "boolean" + }, + "x-jsonld-id": { + "x-format-assertion": true, + "type": "string", + "format": "iri" + }, + "x-jsonld-json": { + "type": "boolean" + }, + "x-jsonld-language": { + "type": "string", + "pattern": "^(?:[Ee][Nn]-[Gg][Bb]-[Oo][Ee][Dd]|[Ii]-(?:[Aa][Mm][Ii]|[Bb][Nn][Nn]|[Dd][Ee][Ff][Aa][Uu][Ll][Tt]|[Ee][Nn][Oo][Cc][Hh][Ii][Aa][Nn]|[Hh][Aa][Kk]|[Kk][Ll][Ii][Nn][Gg][Oo][Nn]|[Ll][Uu][Xx]|[Mm][Ii][Nn][Gg][Oo]|[Nn][Aa][Vv][Aa][Jj][Oo]|[Pp][Ww][Nn]|[Tt][Aa][Oo]|[Tt][Aa][Yy]|[Tt][Ss][Uu])|[Ss][Gg][Nn]-(?:[Bb][Ee]-[Ff][Rr]|[Bb][Ee]-[Nn][Ll]|[Cc][Hh]-[Dd][Ee])|(?:(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{4,8})(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|[0-9]{3}))?(?:-(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(?:-[0-9A-WY-Za-wy-z](?:-[A-Za-z0-9]{2,8})+)*(?:-[Xx](?:-[A-Za-z0-9]{1,8})+)?)|[Xx](?:-[A-Za-z0-9]{1,8})+)$" + }, + "x-jsonld-reverse": { + "x-format-assertion": true, + "type": "string", + "format": "iri" + }, + "x-jsonld-self": { + "x-format-assertion": true, + "type": "string", + "format": "uri-template" + }, + "x-jsonld-type": { + "anyOf": [ + { + "x-format-assertion": true, + "type": "string", + "format": "iri" + }, + { + "type": "array", + "items": { + "x-format-assertion": true, + "type": "string", + "format": "iri" + } + } + ] + } + } +} diff --git a/vendor/blaze/schemas/sourcemeta-extension/v1/README.markdown b/vendor/blaze/schemas/sourcemeta-extension/v1/README.markdown new file mode 100644 index 000000000..0e4f53607 --- /dev/null +++ b/vendor/blaze/schemas/sourcemeta-extension/v1/README.markdown @@ -0,0 +1,192 @@ +Sourcemeta Extension Vocabulary +=============================== + +1. Introduction +--------------- + +The Sourcemeta Extension vocabulary, identified by the URI +`tag:sourcemeta.com,2026:extension/v1`, defines JSON Schema keywords that +annotate schemas with evaluation behavior and with the information required to +promote valid instances to JSON-LD [JSONLD11], the JSON-based serialization of +RDF [RDF11]. Every keyword in this vocabulary is an annotation. No keyword +directly asserts constraints on the instance, though `x-format-assertion` +changes how a sibling keyword is evaluated. + +2. Versioning +------------- + +The vocabulary URI identifies major version 1 of this vocabulary. Backwards +compatible changes, such as the introduction of new keywords, MAY be made to +this vocabulary without changing its URI. A breaking change MUST result in a +new vocabulary published under a new major version with a new URI. + +3. Keywords +----------- + +| Keyword | Value | Applies To | +|---------|-------|------------| +| [`x-format-assertion`](#31-x-format-assertion) | A boolean | Any subschema | +| [`x-jsonld-id`](#321-x-jsonld-id) | An absolute IRI | Property subschemas | +| [`x-jsonld-type`](#322-x-jsonld-type) | An absolute IRI or array of absolute IRIs | Object or reference subschemas | +| [`x-jsonld-reverse`](#323-x-jsonld-reverse) | An absolute IRI | Property subschemas | +| [`x-jsonld-datatype`](#324-x-jsonld-datatype) | An absolute IRI | Scalar subschemas | +| [`x-jsonld-language`](#325-x-jsonld-language) | A language tag | String subschemas | +| [`x-jsonld-direction`](#326-x-jsonld-direction) | `ltr` or `rtl` | String subschemas | +| [`x-jsonld-json`](#327-x-jsonld-json) | A boolean | Any subschema | +| [`x-jsonld-graph`](#328-x-jsonld-graph) | A boolean | Object subschemas | +| [`x-jsonld-container`](#329-x-jsonld-container) | `@list`, `@set`, `@language`, or `@index` | Array or object property subschemas | +| [`x-jsonld-self`](#3210-x-jsonld-self) | A URI Template | Scalar or object subschemas | + +### 3.1. `x-format-assertion` + +The value of this keyword MUST be a boolean. + +When the value is `true`, a `format` keyword in the same schema object MUST be +evaluated as an assertion rather than as an annotation. When the value is +`false` or the keyword is absent, the evaluation of `format` is unchanged. +This keyword has no effect on any other keyword. + +### 3.2. JSON-LD + +The keywords in this section map the instance locations that their subschemas +successfully evaluate to nodes, literals, and edges of a JSON-LD document. A +keyword takes effect at every instance location its subschema evaluates. When +several subschemas annotate the same instance location, their annotations are +merged, and a keyword that takes a single value MUST NOT be assigned two +different values for the same location. + +#### 3.2.1. `x-jsonld-id` + +The value of this keyword MUST be a string representing an absolute IRI +[RFC3987]. + +This keyword declares the predicate IRI that connects the annotated instance +location, as the object, to its enclosing node. It MUST NOT be applied to the +document root, to an array element, or to a member of a location annotated +with `x-jsonld-container`, as such locations have no enclosing node to attach +a predicate to. + +#### 3.2.2. `x-jsonld-type` + +The value of this keyword MUST be a string representing an absolute IRI +[RFC3987], or an array of such strings. + +This keyword declares the `@type` of the node that the annotated instance +location materializes as. Duplicate entries are collapsed, and an empty array +declares no types. It MUST be applied to a location whose value is an object, +unless the location is promoted to an identified node with `x-jsonld-self`. + +#### 3.2.3. `x-jsonld-reverse` + +The value of this keyword MUST be a string representing an absolute IRI +[RFC3987]. + +This keyword declares a reverse predicate IRI, asserting the edge with the +annotated instance location as the subject and the enclosing node as the +object, emitted through the JSON-LD `@reverse` keyword. The annotated location +MUST materialize as a node or as an array of nodes, as a literal cannot be the +subject of an edge. The placement requirements of `x-jsonld-id` also apply to +this keyword. + +#### 3.2.4. `x-jsonld-datatype` + +The value of this keyword MUST be a string representing an absolute IRI +[RFC3987]. + +This keyword declares the datatype IRI of the typed literal that the annotated +instance location materializes as, such as +`http://www.w3.org/2001/XMLSchema#date`. It MUST be applied to a location +whose value is a scalar, and it MUST NOT be combined with `x-jsonld-language` +or `x-jsonld-direction` at the same location. + +#### 3.2.5. `x-jsonld-language` + +The value of this keyword MUST be a string representing a well-formed language +tag [RFC5646]. + +This keyword declares the language of the language-tagged literal that the +annotated instance location materializes as. It MUST be applied to a location +whose value is a string. + +#### 3.2.6. `x-jsonld-direction` + +The value of this keyword MUST be the string `ltr` or the string `rtl`. + +This keyword declares the base direction of the internationalised literal that +the annotated instance location materializes as. It MUST be applied to a +location whose value is a string. + +#### 3.2.7. `x-jsonld-json` + +The value of this keyword MUST be a boolean. + +When the value is `true`, the annotated instance location is preserved +verbatim as an opaque JSON literal through the JSON-LD `@json` datatype, and +the keyword MUST NOT be combined with any keyword of this vocabulary other +than `x-jsonld-id` at the same location. When the value is `false`, the +keyword has no effect. + +#### 3.2.8. `x-jsonld-graph` + +The value of this keyword MUST be a boolean. + +When the value is `true`, the edges of the node that the annotated instance +location materializes as are asserted inside a named `@graph` that the node +identifier denotes. The location value MUST be an object. When the value is +`false`, the keyword has no effect. + +#### 3.2.9. `x-jsonld-container` + +The value of this keyword MUST be the string `@list`, `@set`, `@language`, or +`@index`. + +This keyword declares the container semantics of the annotated instance +location. The `@list` and `@set` containers range over an array value, +asserting an ordered RDF list and an unordered set respectively. The +`@language` container ranges over an object value, asserting language-tagged +literals. Its keys MUST be well-formed language tags [RFC5646], except for the +reserved key `@none` which asserts a literal with no language, and its members +MUST be strings, arrays of strings, or `null`, where `null` entries are +ignored. The `@index` container ranges over an object value whose keys carry +no RDF meaning. This keyword MUST NOT be combined with any keyword of this +vocabulary other than `x-jsonld-id` at the same location, and the members of a +`@language` container MUST NOT carry annotations of this vocabulary. + +#### 3.2.10. `x-jsonld-self` + +The value of this keyword MUST be a string representing a URI Template +[RFC6570]. + +This keyword mints the identifier of the node that the annotated instance +location materializes as, giving an object its `@id` or promoting a scalar to +an identified node. An object binds each template variable to the member of +that name, and a scalar binds the reserved variable `this` to its own value. +Every binding MUST be a non-empty string, a number, or a boolean, and the +expanded template MUST be an absolute IRI [RFC3987]. The location value MUST +NOT be an array, and the keyword MUST NOT be combined with +`x-jsonld-datatype`, `x-jsonld-language`, or `x-jsonld-direction` at the same +location. + +4. References +------------- + +### 4.1. Normative + +- [RFC3987] Duerst, M. and M. Suignard, ["Internationalized Resource + Identifiers (IRIs)"](https://www.rfc-editor.org/info/rfc3987), RFC 3987, + January 2005 +- [RFC5646] Phillips, A. and M. Davis, ["Tags for Identifying + Languages"](https://www.rfc-editor.org/info/rfc5646), BCP 47, RFC 5646, + September 2009 +- [RFC6570] Gregorio, J., Fielding, R., Hadley, M., Nottingham, M., and D. + Orchard, ["URI Template"](https://www.rfc-editor.org/info/rfc6570), RFC + 6570, March 2012 +- [JSONLD11] Sporny, M., Longley, D., Kellogg, G., Lanthaler, M., Champin, + P., and N. Lindström, ["JSON-LD 1.1"](https://www.w3.org/TR/json-ld11/), + W3C Recommendation, July 2020 + +### 4.2. Informative + +- [RDF11] Cyganiak, R., Wood, D., and M. Lanthaler, ["RDF 1.1 Concepts and + Abstract Syntax"](https://www.w3.org/TR/rdf11-concepts/), W3C + Recommendation, February 2014 diff --git a/vendor/blaze/src/alterschema/include/sourcemeta/blaze/alterschema.h b/vendor/blaze/src/alterschema/include/sourcemeta/blaze/alterschema.h index 993b0c2c7..8a1776bbd 100644 --- a/vendor/blaze/src/alterschema/include/sourcemeta/blaze/alterschema.h +++ b/vendor/blaze/src/alterschema/include/sourcemeta/blaze/alterschema.h @@ -88,16 +88,26 @@ auto add(SchemaTransformer &bundle, const AlterSchemaMode mode) -> void; /// @ingroup alterschema /// -/// A linter rule driven by a JSON Schema. Every subschema in the document -/// under inspection is validated as a JSON instance against the provided -/// rule schema. When a subschema does not conform, the rule fires and -/// reports the validation errors. The rule name is extracted from the -/// `title` keyword of the rule schema, and the rule description from the -/// `description` keyword. The title must consist only of lowercase ASCII -/// letters, digits, underscores, or slashes. +/// A linter rule driven by a JSON Schema. By default, every subschema in +/// the document under inspection is validated as a JSON instance against +/// the provided rule schema, though a rule may be scoped to only run +/// against the document root. When a subschema does not conform, the rule +/// fires and reports the validation errors. The rule name is extracted +/// from the `title` keyword of the rule schema, and the rule description +/// from the `description` keyword. The title must consist only of +/// lowercase ASCII letters, digits, underscores, or slashes. class SOURCEMETA_BLAZE_ALTERSCHEMA_EXPORT SchemaRule final : public SchemaTransformRule { public: + /// The locations of the schema that the rule applies to + enum class Scope : std::uint8_t { + /// Every subschema of the document under inspection + All, + + /// Only the document root + TopLevel + }; + using mutates = std::false_type; using reframe_after_transform = std::false_type; SchemaRule(const sourcemeta::core::JSON &schema, @@ -105,7 +115,8 @@ class SOURCEMETA_BLAZE_ALTERSCHEMA_EXPORT SchemaRule final const sourcemeta::blaze::SchemaResolver &resolver, const Compiler &compiler, const std::string_view default_dialect = "", - const std::optional &tweaks = std::nullopt); + const std::optional &tweaks = std::nullopt, + const Scope scope = Scope::All); [[nodiscard]] auto condition(const sourcemeta::core::JSON &, const sourcemeta::core::JSON &, const sourcemeta::blaze::Vocabularies &, @@ -117,6 +128,7 @@ class SOURCEMETA_BLAZE_ALTERSCHEMA_EXPORT SchemaRule final private: Template template_; + Scope scope_; }; #if defined(_MSC_VER) diff --git a/vendor/blaze/src/alterschema/schema_rule.cc b/vendor/blaze/src/alterschema/schema_rule.cc index 74072ac61..1610e82b8 100644 --- a/vendor/blaze/src/alterschema/schema_rule.cc +++ b/vendor/blaze/src/alterschema/schema_rule.cc @@ -68,19 +68,24 @@ SchemaRule::SchemaRule(const sourcemeta::core::JSON &schema, const sourcemeta::blaze::SchemaResolver &resolver, const Compiler &compiler, const std::string_view default_dialect, - const std::optional &tweaks) + const std::optional &tweaks, const Scope scope) : SchemaTransformRule{extract_title(schema), extract_description(schema)}, template_{compile(schema, walker, resolver, compiler, Mode::Exhaustive, - default_dialect, "", "", tweaks)} {}; - -auto SchemaRule::condition(const sourcemeta::core::JSON &schema, - const sourcemeta::core::JSON &, - const sourcemeta::blaze::Vocabularies &, - const sourcemeta::blaze::SchemaFrame &, - const sourcemeta::blaze::SchemaFrame::Location &, - const sourcemeta::blaze::SchemaWalker &, - const sourcemeta::blaze::SchemaResolver &, - const bool) const -> SchemaTransformRule::Result { + default_dialect, "", "", tweaks)}, + scope_{scope} {}; + +auto SchemaRule::condition( + const sourcemeta::core::JSON &schema, const sourcemeta::core::JSON &, + const sourcemeta::blaze::Vocabularies &, + const sourcemeta::blaze::SchemaFrame &, + const sourcemeta::blaze::SchemaFrame::Location &location, + const sourcemeta::blaze::SchemaWalker &, + const sourcemeta::blaze::SchemaResolver &, const bool) const + -> SchemaTransformRule::Result { + if (this->scope_ == Scope::TopLevel && !location.pointer.empty()) { + return false; + } + SimpleOutput output{schema}; Evaluator evaluator; const auto result{ diff --git a/vendor/blaze/src/compiler/compile.cc b/vendor/blaze/src/compiler/compile.cc index 65fcbdcfb..e3b12af0e 100644 --- a/vendor/blaze/src/compiler/compile.cc +++ b/vendor/blaze/src/compiler/compile.cc @@ -11,6 +11,7 @@ #include // std::set #include // std::string_view #include // std::unordered_map +#include // std::unordered_set #include // std::move, std::pair #include // std::vector @@ -73,6 +74,53 @@ auto compile_subschema(const sourcemeta::blaze::Context &context, return steps; } +auto defines_any_whitelisted_keyword( + const sourcemeta::core::JSON &schema, + const sourcemeta::blaze::SchemaFrame &frame, + const sourcemeta::blaze::SchemaWalker &walker, + const sourcemeta::blaze::SchemaResolver &resolver, + const sourcemeta::blaze::SchemaFrame::Location &entrypoint_location, + const std::unordered_set &keywords) + -> bool { + std::vector> + hashed_keywords; + hashed_keywords.reserve(keywords.size()); + for (const auto &keyword : keywords) { + hashed_keywords.emplace_back(keyword, + sourcemeta::core::JSON::Object::hash(keyword)); + } + + for (const auto &entry : frame.locations()) { + if (entry.second.type == + sourcemeta::blaze::SchemaFrame::LocationType::Pointer || + entry.second.type == + sourcemeta::blaze::SchemaFrame::LocationType::Anchor) { + continue; + } + + const auto &subschema{sourcemeta::core::get(schema, entry.second.pointer)}; + if (!subschema.is_object()) { + continue; + } + + bool defines_keyword{false}; + for (const auto &[keyword, keyword_hash] : hashed_keywords) { + if (subschema.defines(keyword, keyword_hash)) { + defines_keyword = true; + break; + } + } + + if (defines_keyword && frame.is_reachable(entrypoint_location, entry.second, + walker, resolver)) { + return true; + } + } + + return false; +} + // TODO: Somehow move this logic up to `SchemaFrame` auto schema_frame_populate_target_types( const sourcemeta::blaze::SchemaFrame &frame, @@ -174,6 +222,14 @@ auto compile(const sourcemeta::core::JSON &schema, entrypoint, "The given entry point URI is not a valid subschema"}; } + const bool collects_annotations{ + mode == Mode::Exhaustive || + (effective_tweaks.annotations.has_value() && + !effective_tweaks.annotations.value().empty() && + defines_any_whitelisted_keyword(schema, frame, walker, resolver, + entrypoint_location, + effective_tweaks.annotations.value()))}; + /////////////////////////////////////////////////////////////////// // (1) Determine all the schema resources in the schema /////////////////////////////////////////////////////////////////// @@ -307,6 +363,7 @@ auto compile(const sourcemeta::core::JSON &schema, .compiler = compiler, .mode = mode, .uses_dynamic_scopes = uses_dynamic_scopes, + .collects_annotations = collects_annotations, .unevaluated = std::move(unevaluated), .tweaks = effective_tweaks, .targets = std::move(targets_map), diff --git a/vendor/blaze/src/compiler/compile_helpers.h b/vendor/blaze/src/compiler/compile_helpers.h index e8859110c..0844fa97c 100644 --- a/vendor/blaze/src/compiler/compile_helpers.h +++ b/vendor/blaze/src/compiler/compile_helpers.h @@ -347,21 +347,6 @@ inline auto annotations_enabled(const Context &context, return context.mode == Mode::Exhaustive; } -// Whether any annotation may be collected anywhere in this compilation. Drives -// the "do not short-circuit / keep iterating" behavior of applicators like -// `contains` so that nested annotations are reached on every instance location, -// independently of whether the applicator's own annotation was whitelisted. -// Exhaustive mode always keeps iterating for complete error reporting, -// regardless of whether annotations are disabled via an empty whitelist. -inline auto annotations_collected(const Context &context) -> bool { - if (context.mode == Mode::Exhaustive) { - return true; - } - - return context.tweaks.annotations.has_value() && - !context.tweaks.annotations.value().empty(); -} - // TODO: Elevate to Core and test inline auto diff --git a/vendor/blaze/src/compiler/default_compiler.cc b/vendor/blaze/src/compiler/default_compiler.cc index 192b6a174..31f18b224 100644 --- a/vendor/blaze/src/compiler/default_compiler.cc +++ b/vendor/blaze/src/compiler/default_compiler.cc @@ -46,7 +46,8 @@ auto sourcemeta::blaze::default_schema_compiler( Known::JSON_Schema_Draft_3, Known::JSON_Schema_Draft_3_Hyper, Known::OpenAPI_3_1_Base, - Known::OpenAPI_3_2_Base}; + Known::OpenAPI_3_2_Base, + Known::Sourcemeta_Extension_V1}; schema_context.vocabularies.throw_if_any_unsupported( SUPPORTED_VOCABULARIES, "Cannot compile unsupported vocabulary"); diff --git a/vendor/blaze/src/compiler/default_compiler_2019_09.h b/vendor/blaze/src/compiler/default_compiler_2019_09.h index 6a7c6be3b..0a4d7017b 100644 --- a/vendor/blaze/src/compiler/default_compiler_2019_09.h +++ b/vendor/blaze/src/compiler/default_compiler_2019_09.h @@ -195,7 +195,7 @@ auto compiler_2019_09_applicator_contains(const Context &context, -> Instructions { return compiler_2019_09_applicator_contains_with_options( context, schema_context, dynamic_context, current, - annotations_collected(context), false); + context.collects_annotations, false); } auto compiler_2019_09_applicator_additionalproperties( @@ -204,7 +204,7 @@ auto compiler_2019_09_applicator_additionalproperties( -> Instructions { return compiler_draft3_applicator_additionalproperties_with_options( - context, schema_context, dynamic_context, annotations_collected(context), + context, schema_context, dynamic_context, context.collects_annotations, requires_evaluation(context, schema_context)); } @@ -221,12 +221,12 @@ auto compiler_2019_09_applicator_items(const Context &context, if (schema_context.schema.at(dynamic_context.keyword).is_array()) { return compiler_draft3_applicator_items_with_options( - context, schema_context, dynamic_context, - annotations_collected(context), track); + context, schema_context, dynamic_context, context.collects_annotations, + track); } return compiler_draft3_applicator_items_with_options( - context, schema_context, dynamic_context, annotations_collected(context), + context, schema_context, dynamic_context, context.collects_annotations, track && !schema_context.schema.defines("unevaluatedItems")); } @@ -242,7 +242,7 @@ auto compiler_2019_09_applicator_additionalitems( })}; return compiler_draft3_applicator_additionalitems_with_options( - context, schema_context, dynamic_context, annotations_collected(context), + context, schema_context, dynamic_context, context.collects_annotations, track && !schema_context.schema.defines("unevaluatedItems")); } @@ -432,7 +432,7 @@ auto compiler_2019_09_applicator_properties( -> Instructions { return compiler_draft3_applicator_properties_with_options( context, schema_context, dynamic_context, current, - annotations_collected(context), + context.collects_annotations, requires_evaluation(context, schema_context)); } @@ -441,7 +441,7 @@ auto compiler_2019_09_applicator_patternproperties( const DynamicContext &dynamic_context, const Instructions &) -> Instructions { return compiler_draft3_applicator_patternproperties_with_options( - context, schema_context, dynamic_context, annotations_collected(context), + context, schema_context, dynamic_context, context.collects_annotations, requires_evaluation(context, schema_context)); } diff --git a/vendor/blaze/src/compiler/default_compiler_2020_12.h b/vendor/blaze/src/compiler/default_compiler_2020_12.h index 9b02ab843..6b6245ff3 100644 --- a/vendor/blaze/src/compiler/default_compiler_2020_12.h +++ b/vendor/blaze/src/compiler/default_compiler_2020_12.h @@ -22,7 +22,7 @@ auto compiler_2020_12_applicator_prefixitems( })}; return compiler_draft3_applicator_items_array( - context, schema_context, dynamic_context, annotations_collected(context), + context, schema_context, dynamic_context, context.collects_annotations, track); } @@ -44,7 +44,7 @@ auto compiler_2020_12_applicator_items(const Context &context, return compiler_draft3_applicator_additionalitems_from_cursor( context, schema_context, dynamic_context, cursor, - annotations_collected(context), + context.collects_annotations, track && !schema_context.schema.defines("unevaluatedItems")); } @@ -62,7 +62,7 @@ auto compiler_2020_12_applicator_contains(const Context &context, return compiler_2019_09_applicator_contains_with_options( context, schema_context, dynamic_context, current, - annotations_collected(context), track); + context.collects_annotations, track); } auto compiler_2020_12_core_dynamicref(const Context &context, diff --git a/vendor/blaze/src/compiler/default_compiler_draft3.h b/vendor/blaze/src/compiler/default_compiler_draft3.h index ae717352e..51d8df52d 100644 --- a/vendor/blaze/src/compiler/default_compiler_draft3.h +++ b/vendor/blaze/src/compiler/default_compiler_draft3.h @@ -922,7 +922,7 @@ auto compiler_draft3_applicator_properties_with_options( if (context.mode == Mode::FastValidation) { if (fusion_possible && !fusion_entries.empty() && - !annotations_collected(context)) { + !context.collects_annotations) { for (const auto &req : required) { const auto &req_name{req.first}; bool already_tracked{false}; diff --git a/vendor/blaze/src/compiler/default_compiler_draft4.h b/vendor/blaze/src/compiler/default_compiler_draft4.h index b974a7d59..8960ab6bf 100644 --- a/vendor/blaze/src/compiler/default_compiler_draft4.h +++ b/vendor/blaze/src/compiler/default_compiler_draft4.h @@ -141,7 +141,7 @@ auto compiler_draft4_applicator_anyof(const Context &context, } const auto requires_exhaustive{context.mode == Mode::Exhaustive || - annotations_collected(context) || + context.collects_annotations || requires_evaluation(context, schema_context)}; return {make(sourcemeta::blaze::InstructionIndex::LogicalOr, context, @@ -172,7 +172,7 @@ auto compiler_draft4_applicator_oneof(const Context &context, } const auto requires_exhaustive{context.mode == Mode::Exhaustive || - annotations_collected(context) || + context.collects_annotations || requires_evaluation(context, schema_context)}; return {make(sourcemeta::blaze::InstructionIndex::LogicalXor, context, diff --git a/vendor/blaze/src/compiler/include/sourcemeta/blaze/compiler.h b/vendor/blaze/src/compiler/include/sourcemeta/blaze/compiler.h index 8fcf6c897..bebc4f252 100644 --- a/vendor/blaze/src/compiler/include/sourcemeta/blaze/compiler.h +++ b/vendor/blaze/src/compiler/include/sourcemeta/blaze/compiler.h @@ -99,8 +99,9 @@ struct Tweaks { std::size_t target_inline_threshold{50}; /// When set, force `format` to be compiled as an assertion bool format_assertion{false}; - /// Select which keywords emit annotations in exhaustive mode. When not set, - /// every annotation keyword is emitted + /// Select which keywords emit annotations, regardless of the compilation + /// mode. When not set, every annotation keyword is emitted in exhaustive + /// mode and none in fast mode std::optional> annotations{}; }; @@ -127,6 +128,8 @@ struct Context { const Mode mode; /// Whether the schema makes use of dynamic scoping const bool uses_dynamic_scopes; + /// Whether evaluating the schema may collect at least one annotation + const bool collects_annotations; /// The list of unevaluated entries and their dependencies const SchemaUnevaluatedEntries unevaluated; /// The set of tweaks for the compiler diff --git a/vendor/blaze/src/configuration/include/sourcemeta/blaze/configuration.h b/vendor/blaze/src/configuration/include/sourcemeta/blaze/configuration.h index 4c772494a..db3f0fb9c 100644 --- a/vendor/blaze/src/configuration/include/sourcemeta/blaze/configuration.h +++ b/vendor/blaze/src/configuration/include/sourcemeta/blaze/configuration.h @@ -69,7 +69,12 @@ struct SOURCEMETA_BLAZE_CONFIGURATION_EXPORT Configuration { std::vector ignore; struct Lint { - std::vector rules; + struct Rule { + std::filesystem::path path; + bool top_level{false}; + }; + + std::vector rules; }; Lint lint; diff --git a/vendor/blaze/src/configuration/json.cc b/vendor/blaze/src/configuration/json.cc index 532af7ecc..c995697ba 100644 --- a/vendor/blaze/src/configuration/json.cc +++ b/vendor/blaze/src/configuration/json.cc @@ -105,8 +105,16 @@ auto Configuration::to_json() const -> sourcemeta::core::JSON { auto lint_object{sourcemeta::core::JSON::make_object()}; auto rules_array{sourcemeta::core::JSON::make_array()}; for (const auto &rule : this->lint.rules) { - rules_array.push_back( - sourcemeta::core::JSON{relative_display_path(rule, this->base_path)}); + if (rule.top_level) { + auto rule_object{sourcemeta::core::JSON::make_object()}; + rule_object.assign("path", sourcemeta::core::JSON{relative_display_path( + rule.path, this->base_path)}); + rule_object.assign("topLevel", sourcemeta::core::JSON{true}); + rules_array.push_back(std::move(rule_object)); + } else { + rules_array.push_back(sourcemeta::core::JSON{ + relative_display_path(rule.path, this->base_path)}); + } } lint_object.assign("rules", std::move(rules_array)); diff --git a/vendor/blaze/src/configuration/parse.cc b/vendor/blaze/src/configuration/parse.cc index 70b879b88..370272fb4 100644 --- a/vendor/blaze/src/configuration/parse.cc +++ b/vendor/blaze/src/configuration/parse.cc @@ -200,15 +200,33 @@ auto Configuration::from_json(const sourcemeta::core::JSON &value, std::size_t index{0}; for (const auto &element : lint_value.at("rules").as_array()) { CONFIGURATION_ENSURE( - element.is_string(), - "The values in the lint rules array must be strings", + element.is_string() || element.is_object(), + "The values in the lint rules array must be strings or objects", sourcemeta::core::Pointer({"lint", "rules", index})); - const std::filesystem::path path{element.to_string()}; + bool top_level{false}; + if (element.is_object()) { + CONFIGURATION_ENSURE( + element.defines("path") && element.at("path").is_string(), + "The lint rule path property must be a string", + sourcemeta::core::Pointer({"lint", "rules", index, "path"})); + CONFIGURATION_ENSURE( + !element.defines("topLevel") || + element.at("topLevel").is_boolean(), + "The lint rule topLevel property must be a boolean", + sourcemeta::core::Pointer({"lint", "rules", index, "topLevel"})); + top_level = element.defines("topLevel") && + element.at("topLevel").to_boolean(); + } + + const std::filesystem::path path{element.is_string() + ? element.to_string() + : element.at("path").to_string()}; result.lint.rules.push_back( - path.is_absolute() - ? sourcemeta::core::weakly_canonical(path) - : sourcemeta::core::weakly_canonical(base_path / path)); + {.path = path.is_absolute() + ? sourcemeta::core::weakly_canonical(path) + : sourcemeta::core::weakly_canonical(base_path / path), + .top_level = top_level}); index += 1; } } diff --git a/vendor/blaze/src/evaluator/evaluator_describe.cc b/vendor/blaze/src/evaluator/evaluator_describe.cc index 615462918..384c0b644 100644 --- a/vendor/blaze/src/evaluator/evaluator_describe.cc +++ b/vendor/blaze/src/evaluator/evaluator_describe.cc @@ -703,6 +703,111 @@ auto describe(const bool valid, const Instruction &step, return message.str(); } + if (keyword == "x-format-assertion" && annotation.is_boolean()) { + if (annotation.to_boolean()) { + return "A sibling `format` keyword was expected to be enforced as " + "an assertion"; + } + + return "A sibling `format` keyword was expected to be collected as " + "an annotation"; + } + + if (keyword == "x-jsonld-id" && annotation.is_string()) { + std::ostringstream message; + message << "The JSON-LD predicate was " + << escape_string(annotation.to_string()); + return message.str(); + } + + if (keyword == "x-jsonld-reverse" && annotation.is_string()) { + std::ostringstream message; + message << "The reverse JSON-LD predicate was " + << escape_string(annotation.to_string()); + return message.str(); + } + + if (keyword == "x-jsonld-type" && annotation.is_string()) { + std::ostringstream message; + message << "The JSON-LD type was " + << escape_string(annotation.to_string()); + return message.str(); + } + + if (keyword == "x-jsonld-type" && annotation.is_array()) { + if (annotation.empty()) { + return "No JSON-LD types were assigned"; + } + + std::ostringstream message; + message << "The JSON-LD types were "; + const auto &elements{annotation.as_array()}; + for (auto iterator = elements.cbegin(); iterator != elements.cend(); + ++iterator) { + if (iterator != elements.cbegin()) { + message << (std::next(iterator) == elements.cend() ? ", and " : ", "); + } + + describe_stringify(*iterator, message); + } + + return message.str(); + } + + if (keyword == "x-jsonld-datatype" && annotation.is_string()) { + std::ostringstream message; + message << "The JSON-LD datatype was " + << escape_string(annotation.to_string()); + return message.str(); + } + + if (keyword == "x-jsonld-language" && annotation.is_string()) { + std::ostringstream message; + message << "The natural language was " + << escape_string(annotation.to_string()); + return message.str(); + } + + if (keyword == "x-jsonld-direction" && annotation.is_string()) { + std::ostringstream message; + message << "The base direction was " + << escape_string(annotation.to_string()); + return message.str(); + } + + if (keyword == "x-jsonld-json" && annotation.is_boolean()) { + if (annotation.to_boolean()) { + return "The value was expected to be treated as an opaque JSON " + "literal"; + } + + return "The value was not expected to be treated as an opaque JSON " + "literal"; + } + + if (keyword == "x-jsonld-graph" && annotation.is_boolean()) { + if (annotation.to_boolean()) { + return "The value was expected to be wrapped in a JSON-LD named graph"; + } + + return "The value was not expected to be wrapped in a JSON-LD named " + "graph"; + } + + if (keyword == "x-jsonld-container" && annotation.is_string()) { + std::ostringstream message; + message << "The JSON-LD container was " + << escape_string(annotation.to_string()); + return message.str(); + } + + if (keyword == "x-jsonld-self" && annotation.is_string()) { + std::ostringstream message; + message << "The JSON-LD identifier template was " + << escape_string(annotation.to_string()); + return message.str(); + } + std::ostringstream message; message << "The unrecognized keyword " << escape_string(keyword) << " was collected as the annotation "; diff --git a/vendor/blaze/src/foundation/include/sourcemeta/blaze/foundation_vocabularies.h b/vendor/blaze/src/foundation/include/sourcemeta/blaze/foundation_vocabularies.h index 9139299aa..0e6d94f50 100644 --- a/vendor/blaze/src/foundation/include/sourcemeta/blaze/foundation_vocabularies.h +++ b/vendor/blaze/src/foundation/include/sourcemeta/blaze/foundation_vocabularies.h @@ -65,11 +65,13 @@ struct SOURCEMETA_BLAZE_FOUNDATION_EXPORT Vocabularies { // https://spec.openapis.org/oas/v3.1.0.html#fixed-fields-19 OpenAPI_3_1_Base = 29, // https://spec.openapis.org/oas/v3.2.0.html#base-vocabulary - OpenAPI_3_2_Base = 30 + OpenAPI_3_2_Base = 30, + // Sourcemeta + Sourcemeta_Extension_V1 = 31 }; // NOTE: Must be kept in sync with the Known enum above - static constexpr std::size_t KNOWN_VOCABULARY_COUNT = 31; + static constexpr std::size_t KNOWN_VOCABULARY_COUNT = 32; /// A vocabulary URI type that can be either a known vocabulary enum or a /// custom string URI diff --git a/vendor/blaze/src/foundation/vocabularies.cc b/vendor/blaze/src/foundation/vocabularies.cc index 081d1d50c..65b74530b 100644 --- a/vendor/blaze/src/foundation/vocabularies.cc +++ b/vendor/blaze/src/foundation/vocabularies.cc @@ -68,7 +68,9 @@ "http://json-schema.org/draft-00/hyper-schema#") \ /* OpenAPI vocabularies */ \ X(OpenAPI_3_1_Base, "https://spec.openapis.org/oas/3.1/vocab/base") \ - X(OpenAPI_3_2_Base, "https://spec.openapis.org/oas/3.2/vocab/base") + X(OpenAPI_3_2_Base, "https://spec.openapis.org/oas/3.2/vocab/base") \ + /* Sourcemeta vocabularies */ \ + X(Sourcemeta_Extension_V1, "tag:sourcemeta.com,2026:extension/v1") namespace { auto uri_to_known_vocabulary(const std::string_view uri) From dabbb070dbaa885943234cab1b0652915bbc4e4d Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Wed, 15 Jul 2026 17:58:00 -0300 Subject: [PATCH 2/4] Support registering top-level custom linter rules Signed-off-by: Juan Cruz Viotti --- completion/jsonschema.bash | 2 +- completion/jsonschema.zsh | 2 + docs/configuration.markdown | 5 +- docs/lint.markdown | 32 +++++++- src/command_lint.cc | 61 ++++++++++----- src/main.cc | 6 +- test/CMakeLists.txt | 15 ++++ .../fail_lint_config_rule_invalid_entry.sh | 53 +++++++++++++ .../fail_lint_config_rule_object_entry.sh | 78 +++++++++++++++++++ ...nt_config_rule_object_invalid_top_level.sh | 64 +++++++++++++++ ...il_lint_config_rule_object_missing_path.sh | 55 +++++++++++++ ...il_lint_config_rule_top_level_violation.sh | 73 +++++++++++++++++ .../fail_lint_top_level_rule_duplicate.sh | 61 +++++++++++++++ test/lint/fail_lint_top_level_rule_fix.sh | 46 +++++++++++ .../fail_lint_top_level_rule_violation.sh | 63 +++++++++++++++ .../pass_lint_config_rule_mixed_entries.sh | 57 ++++++++++++++ test/lint/pass_lint_config_rule_top_level.sh | 47 +++++++++++ ...s_lint_top_level_rule_embedded_resource.sh | 39 ++++++++++ test/lint/pass_lint_top_level_rule_exclude.sh | 32 ++++++++ .../pass_lint_top_level_rule_no_violation.sh | 36 +++++++++ test/lint/pass_lint_top_level_rule_verbose.sh | 36 +++++++++ test/lint/pass_lint_top_level_rule_yaml.sh | 35 +++++++++ 22 files changed, 872 insertions(+), 26 deletions(-) create mode 100755 test/lint/fail_lint_config_rule_invalid_entry.sh create mode 100755 test/lint/fail_lint_config_rule_object_entry.sh create mode 100755 test/lint/fail_lint_config_rule_object_invalid_top_level.sh create mode 100755 test/lint/fail_lint_config_rule_object_missing_path.sh create mode 100755 test/lint/fail_lint_config_rule_top_level_violation.sh create mode 100755 test/lint/fail_lint_top_level_rule_duplicate.sh create mode 100755 test/lint/fail_lint_top_level_rule_fix.sh create mode 100755 test/lint/fail_lint_top_level_rule_violation.sh create mode 100755 test/lint/pass_lint_config_rule_mixed_entries.sh create mode 100755 test/lint/pass_lint_config_rule_top_level.sh create mode 100755 test/lint/pass_lint_top_level_rule_embedded_resource.sh create mode 100755 test/lint/pass_lint_top_level_rule_exclude.sh create mode 100755 test/lint/pass_lint_top_level_rule_no_violation.sh create mode 100755 test/lint/pass_lint_top_level_rule_verbose.sh create mode 100755 test/lint/pass_lint_top_level_rule_yaml.sh diff --git a/completion/jsonschema.bash b/completion/jsonschema.bash index 6aa230fbc..8d63c74c9 100644 --- a/completion/jsonschema.bash +++ b/completion/jsonschema.bash @@ -142,7 +142,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" if [[ ${current} == -* ]] then COMPREPLY=( $(compgen -W "${options} ${global_options}" -- "${current}") ) diff --git a/completion/jsonschema.zsh b/completion/jsonschema.zsh index d44305d68..43548624b 100644 --- a/completion/jsonschema.zsh +++ b/completion/jsonschema.zsh @@ -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) diff --git a/docs/configuration.markdown b/docs/configuration.markdown index 20d07522a..bbfa30f3d 100644 --- a/docs/configuration.markdown +++ b/docs/configuration.markdown @@ -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 } ] } } @@ -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 | `[]` | Lookup Algorithm ---------------- diff --git a/docs/lint.markdown b/docs/lint.markdown index 9b4bdbf0e..7e349f99b 100644 --- a/docs/lint.markdown +++ b/docs/lint.markdown @@ -8,7 +8,8 @@ jsonschema lint [schemas-or-directories...] [--http/-h] [--fix/-f] [--resolve/-r ...] [--extension/-e ] [--ignore/-i ] [--exclude/-x ] [--only/-o ] [--list/-l] - [--rule/-a ] [--format-assertion/-F] + [--rule/-a ] [--top-level-rule/-t ] + [--format-assertion/-F] [--default-dialect/-d ] [--indentation/-n ] ``` @@ -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 -------- @@ -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 diff --git a/src/command_lint.cc b/src/command_lint.cc index 64dfe7208..07cd0a666 100644 --- a/src/command_lint.cc +++ b/src/command_lint.cc @@ -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 &tweaks) + const std::optional &tweaks, + const sourcemeta::blaze::SchemaRule::Scope scope) -> void { auto rule_schema{sourcemeta::core::read_yaml_or_json(rule_path)}; if (!rule_schema.defines("description")) { @@ -169,7 +170,7 @@ static auto load_rule(sourcemeta::blaze::SchemaTransformer &bundle, try { bundle.add( 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); @@ -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 &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); @@ -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")) { diff --git a/src/main.cc b/src/main.cc index 9a016de75..a6fd6f533 100644 --- a/src/main.cc +++ b/src/main.cc @@ -88,7 +88,8 @@ Global Options: [--keep-ordering/-k] [--extension/-e ] [--ignore/-i ] [--exclude/-x ] [--only/-o ] [--list/-l] [--indentation/-n ] - [--rule/-a ] [--format-assertion/-F] + [--rule/-a ] [--top-level-rule/-t ] + [--format-assertion/-F] Lint the input schemas and potentially fix the reported issues. The --fix/-f option is not supported when passing YAML schemas. @@ -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 [--to/-t draft4|draft6|draft7|2019-09|2020-12] [--meta/-m] @@ -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; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 6f7405043..b77bce0be 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -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) @@ -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) diff --git a/test/lint/fail_lint_config_rule_invalid_entry.sh b/test/lint/fail_lint_config_rule_invalid_entry.sh new file mode 100755 index 000000000..e23f9233f --- /dev/null +++ b/test/lint/fail_lint_config_rule_invalid_entry.sh @@ -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" diff --git a/test/lint/fail_lint_config_rule_object_entry.sh b/test/lint/fail_lint_config_rule_object_entry.sh new file mode 100755 index 000000000..9e0af5513 --- /dev/null +++ b/test/lint/fail_lint_config_rule_object_entry.sh @@ -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" diff --git a/test/lint/fail_lint_config_rule_object_invalid_top_level.sh b/test/lint/fail_lint_config_rule_object_invalid_top_level.sh new file mode 100755 index 000000000..80381f12f --- /dev/null +++ b/test/lint/fail_lint_config_rule_object_invalid_top_level.sh @@ -0,0 +1,64 @@ +#!/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", "topLevel": 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 lint rule topLevel property must be a boolean + at file path $(realpath "$TMP")/jsonschema.json + at location "/lint/rules/0/topLevel" +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 lint rule topLevel property must be a boolean", + "filePath": "$(realpath "$TMP")/jsonschema.json", + "location": "/lint/rules/0/topLevel" +} +EOF + +diff "$TMP/output_json.txt" "$TMP/expected_json.txt" diff --git a/test/lint/fail_lint_config_rule_object_missing_path.sh b/test/lint/fail_lint_config_rule_object_missing_path.sh new file mode 100755 index 000000000..07d18c37c --- /dev/null +++ b/test/lint/fail_lint_config_rule_object_missing_path.sh @@ -0,0 +1,55 @@ +#!/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": [ + { "topLevel": true } + ] + } +} +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 lint rule path property must be a string + at file path $(realpath "$TMP")/jsonschema.json + at location "/lint/rules/0/path" +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 lint rule path property must be a string", + "filePath": "$(realpath "$TMP")/jsonschema.json", + "location": "/lint/rules/0/path" +} +EOF + +diff "$TMP/output_json.txt" "$TMP/expected_json.txt" diff --git a/test/lint/fail_lint_config_rule_top_level_violation.sh b/test/lint/fail_lint_config_rule_top_level_violation.sh new file mode 100755 index 000000000..a84ad59c7 --- /dev/null +++ b/test/lint/fail_lint_config_rule_top_level_violation.sh @@ -0,0 +1,73 @@ +#!/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", "topLevel": true } + ] + } +} +EOF + +cat << 'EOF' > "$TMP/schema.json" +{ + "$schema": "https://json-schema.org/draft/2020-12/schema" +} +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:1:1: + The root schema must declare an $id (require_id) + at location "" + 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": 0, + "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": [], + "position": [ 1, 1, 3, 1 ] + } + ] +} +EOF + +diff "$TMP/output_json.txt" "$TMP/expected_json.txt" diff --git a/test/lint/fail_lint_top_level_rule_duplicate.sh b/test/lint/fail_lint_top_level_rule_duplicate.sh new file mode 100755 index 000000000..03441dc18 --- /dev/null +++ b/test/lint/fail_lint_top_level_rule_duplicate.sh @@ -0,0 +1,61 @@ +#!/bin/sh + +set -o errexit +set -o nounset + +TMP="$(mktemp -d)" +clean() { rm -rf "$TMP"; } +trap clean EXIT + +cat << 'EOF' > "$TMP/rule1.json" +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "my_rule", + "description": "First rule", + "required": [ "type" ] +} +EOF + +cat << 'EOF' > "$TMP/rule2.json" +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "my_rule", + "description": "Second rule", + "required": [ "description" ] +} +EOF + +cat << 'EOF' > "$TMP/schema.json" +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string" +} +EOF + +"$1" lint --rule "$TMP/rule1.json" --top-level-rule "$TMP/rule2.json" "$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: A lint rule with this name already exists + at file path $(realpath "$TMP")/rule2.json + at rule my_rule +EOF + +diff "$TMP/output.txt" "$TMP/expected.txt" + +"$1" lint --rule "$TMP/rule1.json" --top-level-rule "$TMP/rule2.json" --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": "A lint rule with this name already exists", + "filePath": "$(realpath "$TMP")/rule2.json", + "rule": "my_rule" +} +EOF + +diff "$TMP/output_json.txt" "$TMP/expected_json.txt" diff --git a/test/lint/fail_lint_top_level_rule_fix.sh b/test/lint/fail_lint_top_level_rule_fix.sh new file mode 100755 index 000000000..9689dbf6e --- /dev/null +++ b/test/lint/fail_lint_top_level_rule_fix.sh @@ -0,0 +1,46 @@ +#!/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/schema.json" +{ + "$schema": "https://json-schema.org/draft/2020-12/schema" +} +EOF + +cd "$TMP" +"$1" lint --top-level-rule "$TMP/rule.json" --only require_id --fix "$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:1:1: + The root schema must declare an $id (require_id) + at location "" + The object value was expected to define the property "$id" +EOF + +diff "$TMP/output.txt" "$TMP/expected.txt" + +cat << 'EOF' > "$TMP/expected_schema.json" +{ + "$schema": "https://json-schema.org/draft/2020-12/schema" +} +EOF + +diff "$TMP/schema.json" "$TMP/expected_schema.json" diff --git a/test/lint/fail_lint_top_level_rule_violation.sh b/test/lint/fail_lint_top_level_rule_violation.sh new file mode 100755 index 000000000..4d0b41373 --- /dev/null +++ b/test/lint/fail_lint_top_level_rule_violation.sh @@ -0,0 +1,63 @@ +#!/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/schema.json" +{ + "$schema": "https://json-schema.org/draft/2020-12/schema" +} +EOF + +cd "$TMP" +"$1" lint --top-level-rule "$TMP/rule.json" --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:1:1: + The root schema must declare an $id (require_id) + at location "" + The object value was expected to define the property "$id" +EOF + +diff "$TMP/output.txt" "$TMP/expected.txt" + +cd "$TMP" +"$1" lint --top-level-rule "$TMP/rule.json" --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": 0, + "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": [], + "position": [ 1, 1, 3, 1 ] + } + ] +} +EOF + +diff "$TMP/output_json.txt" "$TMP/expected_json.txt" diff --git a/test/lint/pass_lint_config_rule_mixed_entries.sh b/test/lint/pass_lint_config_rule_mixed_entries.sh new file mode 100755 index 000000000..722906685 --- /dev/null +++ b/test/lint/pass_lint_config_rule_mixed_entries.sh @@ -0,0 +1,57 @@ +#!/bin/sh + +set -o errexit +set -o nounset + +TMP="$(mktemp -d)" +clean() { rm -rf "$TMP"; } +trap clean EXIT + +cat << 'EOF' > "$TMP/rule_type.json" +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "require_type", + "description": "Every subschema must declare the type keyword", + "required": [ "type" ] +} +EOF + +cat << 'EOF' > "$TMP/rule_id.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": [ + "./rule_type.json", + { "path": "./rule_id.json", "topLevel": true } + ] + } +} +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_type --only require_id "$TMP/schema.json" \ + > "$TMP/output.txt" 2>&1 + +cat << 'EOF' > "$TMP/expected.txt" +EOF + +diff "$TMP/output.txt" "$TMP/expected.txt" diff --git a/test/lint/pass_lint_config_rule_top_level.sh b/test/lint/pass_lint_config_rule_top_level.sh new file mode 100755 index 000000000..e7922788f --- /dev/null +++ b/test/lint/pass_lint_config_rule_top_level.sh @@ -0,0 +1,47 @@ +#!/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", "topLevel": true } + ] + } +} +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 + +cat << 'EOF' > "$TMP/expected.txt" +EOF + +diff "$TMP/output.txt" "$TMP/expected.txt" diff --git a/test/lint/pass_lint_top_level_rule_embedded_resource.sh b/test/lint/pass_lint_top_level_rule_embedded_resource.sh new file mode 100755 index 000000000..66b502e68 --- /dev/null +++ b/test/lint/pass_lint_top_level_rule_embedded_resource.sh @@ -0,0 +1,39 @@ +#!/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_type", + "description": "Every subschema must declare the type keyword", + "required": [ "type" ] +} +EOF + +cat << 'EOF' > "$TMP/schema.json" +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/main", + "type": "object", + "$defs": { + "other": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/other" + } + } +} +EOF + +"$1" lint --top-level-rule "$TMP/rule.json" --only require_type "$TMP/schema.json" \ + > "$TMP/output.txt" 2>&1 + +cat << 'EOF' > "$TMP/expected.txt" +EOF + +diff "$TMP/output.txt" "$TMP/expected.txt" diff --git a/test/lint/pass_lint_top_level_rule_exclude.sh b/test/lint/pass_lint_top_level_rule_exclude.sh new file mode 100755 index 000000000..6a9a3d888 --- /dev/null +++ b/test/lint/pass_lint_top_level_rule_exclude.sh @@ -0,0 +1,32 @@ +#!/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/schema.json" +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "x-lint-exclude": "require_id" +} +EOF + +"$1" lint --top-level-rule "$TMP/rule.json" --only require_id "$TMP/schema.json" \ + > "$TMP/output.txt" 2>&1 + +cat << 'EOF' > "$TMP/expected.txt" +EOF + +diff "$TMP/output.txt" "$TMP/expected.txt" diff --git a/test/lint/pass_lint_top_level_rule_no_violation.sh b/test/lint/pass_lint_top_level_rule_no_violation.sh new file mode 100755 index 000000000..8b2d78a09 --- /dev/null +++ b/test/lint/pass_lint_top_level_rule_no_violation.sh @@ -0,0 +1,36 @@ +#!/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/schema.json" +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schema", + "type": "object", + "properties": { + "foo": { "type": "string" } + } +} +EOF + +"$1" lint --top-level-rule "$TMP/rule.json" --only require_id "$TMP/schema.json" \ + > "$TMP/output.txt" 2>&1 + +cat << 'EOF' > "$TMP/expected.txt" +EOF + +diff "$TMP/output.txt" "$TMP/expected.txt" diff --git a/test/lint/pass_lint_top_level_rule_verbose.sh b/test/lint/pass_lint_top_level_rule_verbose.sh new file mode 100755 index 000000000..bfa501c57 --- /dev/null +++ b/test/lint/pass_lint_top_level_rule_verbose.sh @@ -0,0 +1,36 @@ +#!/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/schema.json" +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schema", + "type": "string" +} +EOF + +"$1" lint --top-level-rule "$TMP/rule.json" --only require_id --verbose "$TMP/schema.json" \ + > "$TMP/output.txt" 2>&1 + +cat << EOF > "$TMP/expected.txt" +Loading custom rule: $(realpath "$TMP")/rule.json +Only enabling rule: require_id +Linting: $(realpath "$TMP")/schema.json +EOF + +diff "$TMP/output.txt" "$TMP/expected.txt" diff --git a/test/lint/pass_lint_top_level_rule_yaml.sh b/test/lint/pass_lint_top_level_rule_yaml.sh new file mode 100755 index 000000000..dc9c65f77 --- /dev/null +++ b/test/lint/pass_lint_top_level_rule_yaml.sh @@ -0,0 +1,35 @@ +#!/bin/sh + +set -o errexit +set -o nounset + +TMP="$(mktemp -d)" +clean() { rm -rf "$TMP"; } +trap clean EXIT + +cat << 'EOF' > "$TMP/rule.yaml" +$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/schema.json" +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schema", + "type": "object", + "properties": { + "foo": { "type": "string" } + } +} +EOF + +"$1" lint --top-level-rule "$TMP/rule.yaml" --only require_id "$TMP/schema.json" \ + > "$TMP/output.txt" 2>&1 + +cat << 'EOF' > "$TMP/expected.txt" +EOF + +diff "$TMP/output.txt" "$TMP/expected.txt" From 2e708f519b9d000c39fffe634ca4314846514575 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Wed, 15 Jul 2026 18:17:24 -0300 Subject: [PATCH 3/4] Fix Signed-off-by: Juan Cruz Viotti --- docs/configuration.markdown | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration.markdown b/docs/configuration.markdown index bbfa30f3d..b5b6ff8d4 100644 --- a/docs/configuration.markdown +++ b/docs/configuration.markdown @@ -62,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 / 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 ---------------- From 7155e815563b31a963ec23ab1a54edc69f5c8192 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Wed, 15 Jul 2026 18:35:11 -0300 Subject: [PATCH 4/4] Fix Signed-off-by: Juan Cruz Viotti --- completion/jsonschema.bash | 3 +++ test/autocomplete_bash.sh | 2 ++ test/lint/fail_lint_top_level_rule_fix.sh | 26 +++++++++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/completion/jsonschema.bash b/completion/jsonschema.bash index 8d63c74c9..bfe9e883e 100644 --- a/completion/jsonschema.bash +++ b/completion/jsonschema.bash @@ -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 diff --git a/test/autocomplete_bash.sh b/test/autocomplete_bash.sh index b913a17db..539e6a641 100755 --- a/test/autocomplete_bash.sh +++ b/test/autocomplete_bash.sh @@ -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" diff --git a/test/lint/fail_lint_top_level_rule_fix.sh b/test/lint/fail_lint_top_level_rule_fix.sh index 9689dbf6e..1a9b3f763 100755 --- a/test/lint/fail_lint_top_level_rule_fix.sh +++ b/test/lint/fail_lint_top_level_rule_fix.sh @@ -44,3 +44,29 @@ cat << 'EOF' > "$TMP/expected_schema.json" EOF diff "$TMP/schema.json" "$TMP/expected_schema.json" + +"$1" lint --top-level-rule "$TMP/rule.json" --only require_id --fix --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": 0, + "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": [], + "position": [ 1, 1, 3, 1 ] + } + ] +} +EOF + +diff "$TMP/output_json.txt" "$TMP/expected_json.txt" + +diff "$TMP/schema.json" "$TMP/expected_schema.json"