diff --git a/tools/src/main/js/bundler/README.md b/tools/src/main/js/bundler/README.md index 750ae43b3..f1a7544c7 100644 --- a/tools/src/main/js/bundler/README.md +++ b/tools/src/main/js/bundler/README.md @@ -12,7 +12,13 @@ into `$defs` instead of via embedded resources. ## Requirements * Node.js `>= 24.2` (uses `import.meta.main`, `RegExp.escape`, `fs.glob`) -* No runtime dependencies +* [`ajv`](https://ajv.js.org/) `^8.20` — used to validate the bundle against the JSON Schema meta schema + +Install the dependencies once: + +```shell +npm ci +``` ## Usage @@ -59,8 +65,11 @@ The bundler is split into stages (one module each under `src/`): * The root `$id` is rewritten to point at the bundled file name. 2. **Tree-shake** (`src/cleanup.js`) — remove definitions that are not reachable from the root. *Not implemented yet; currently a no-op.* -3. **Sanity check** (`src/sanity.js`) — verifies every local JSON-Pointer ref resolves in the bundle. - Unresolvable refs are errors; external refs and anchor-based refs are reported as warnings only. +3. **Sanity check** (`src/sanity.js`) — validates the bundled result: + * every local JSON-Pointer ref must resolve in the bundle. + Unresolvable refs are errors; external refs and anchor-based refs are reported as warnings only. + * the bundle must be valid against the JSON Schema draft 2020-12 meta schema (checked via `ajv`). + Every meta-schema violation is an error. 4. **Write** (`cli.js`) — emits the pretty bundle, then strips `$comment` from all subschemas (property *names* called `$comment` are preserved) and emits the minified bundle. diff --git a/tools/src/main/js/bundler/package-lock.json b/tools/src/main/js/bundler/package-lock.json index 864344cc7..ac662c074 100644 --- a/tools/src/main/js/bundler/package-lock.json +++ b/tools/src/main/js/bundler/package-lock.json @@ -8,12 +8,68 @@ "name": "@cyclonedx/schema-bundler", "version": "2.0.0", "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.20" + }, "bin": { "bundle-schemas": "cli.js" }, "engines": { "node": ">=24.2" } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } } } } diff --git a/tools/src/main/js/bundler/package.json b/tools/src/main/js/bundler/package.json index c70735e4b..c4afc6ed6 100644 --- a/tools/src/main/js/bundler/package.json +++ b/tools/src/main/js/bundler/package.json @@ -10,6 +10,7 @@ "bundle-schemas": "./cli.js" }, "dependencies": { + "ajv": "^8.20" }, "scripts": { "cli": "node cli.js", diff --git a/tools/src/main/js/bundler/src/bundle.js b/tools/src/main/js/bundler/src/bundle.js index b1142593d..d9366f129 100644 --- a/tools/src/main/js/bundler/src/bundle.js +++ b/tools/src/main/js/bundler/src/bundle.js @@ -212,21 +212,20 @@ export const bundle = async (entryFile, includeFiles, targetFile) => { return { schema, - external: Array.from(externals), + external: Array.from(externals).sort(), embedded: Array.from(embeddedMap, ([f, d]) => { const {path, frag} = refSplit(f); return [ refJoin({path: posixPath(relative(entryDir, path)), frag}), makeLocalRef(d)]; - }), + }).sort(([a], [b]) => a.localeCompare(b)), rewired: Array.from(rewiredMap, ([f, t]) => { const {path, frag} = refSplit(f); return [ refJoin({path: posixPath(relative(entryDir, path)), frag}), t]; - } - ) + }).sort(([a], [b]) => a.localeCompare(b)), }; }; diff --git a/tools/src/main/js/bundler/src/helpers/json-schema-spec.js b/tools/src/main/js/bundler/src/helpers/json-schema-spec.js index 6481fce24..672ac0908 100644 --- a/tools/src/main/js/bundler/src/helpers/json-schema-spec.js +++ b/tools/src/main/js/bundler/src/helpers/json-schema-spec.js @@ -81,7 +81,7 @@ export const subschemas = function* (s) { continue; } if (SCHEMA_MAP.has(k)) { - if (!isSchemaObject(v)) continue; + if (typeof v !== 'object' || v === null) continue; for (const [mk, mv] of Object.entries(v)) { if (isSchemaObject(mv)) yield [mv, [k, mk]]; } diff --git a/tools/src/main/js/bundler/src/sanity.js b/tools/src/main/js/bundler/src/sanity.js index 127542e82..87c213e31 100644 --- a/tools/src/main/js/bundler/src/sanity.js +++ b/tools/src/main/js/bundler/src/sanity.js @@ -1,4 +1,6 @@ -import {objectGetOwnPath, PATH_NOT_FOUND} from "./helpers/common.js"; +import Ajv2020 from 'ajv/dist/2020.js'; + +import {objectGetOwnPath, PATH_NOT_FOUND} from './helpers/common.js'; import { isSchemaObject, makeLocalRef, @@ -86,18 +88,40 @@ const validateRefs = (schema) => { }; }; +/** + * @param {Readonly} schema + * @returns {ValidationResult} + */ +const validateAgainstMetaSchema = (schema) => { + const warnings = new Set(); + const errors = new Set(); + const ajv = new Ajv2020({allErrors: true}); + const valid = ajv.validateSchema(schema); + if (!valid) { + ajv.errorsText(ajv.errors, {separator: '\n'}) + .split('\n').forEach(e => errors.add(e)); + } + return { + warnings: Array.from(warnings), + errors: Array.from(errors), + }; +}; + /** * @param {Readonly} schema * @returns {ValidationResult} */ export const validateSchema = (schema) => { - const refs = validateRefs(schema) + const refs = validateRefs(schema); + const meta = validateAgainstMetaSchema(schema); return { warnings: [ ...refs.warnings, + ...meta.warnings, ], errors: [ ...refs.errors, + ...meta.errors, ], } }; diff --git a/tools/src/main/js/bundler/test/README.md b/tools/src/main/js/bundler/test/README.md index 03d7af090..d693bd59d 100644 --- a/tools/src/main/js/bundler/test/README.md +++ b/tools/src/main/js/bundler/test/README.md @@ -21,6 +21,10 @@ The runner copies `fixtures/` to a temporary directory, runs the CLI's `main()` `main.schema.json` as the entry and `modules/` as the modules directory, writes the returned report to `report.json`, and compares all three generated files byte-for-byte against `snapshot/`. +The bundler validates its output against the JSON Schema draft 2020-12 meta schema and fails on +any violation, so every fixture (and the bundled result of it) must be valid draft 2020-12 — +boolean subschemas (`true`/`false`) included. + Each module file name states the edge case it covers; `externals/not-bundled` covers a file outside the modules directory, the `remote` property in `used-whole` covers an absolute `https://` ref, and `inArray` in `used-whole` covers refs nested inside an array (`allOf`). @@ -28,7 +32,10 @@ the modules directory, the `remote` property in `used-whole` covers an absolute The modules exercise the tree-shaker as follows: - `used-whole` — referenced as a whole from the entry; the module body survives untouched, but - its unreferenced `$defs/unusedButKept` is dropped nevertheless. + its unreferenced `$defs/unused` is dropped nevertheless. +- `used-whole-data` — referenced as a whole; its reached schemas carry data-bearing keywords + (`required`, `examples`, `default`, `enum`, `const`, `meta:enum`) and boolean-schema properties. + All of that must pass the sweep untouched; only `$defs/unusedDef` is dropped. - `used-subdefs-only` — only individual `$defs` are referenced; the module body (`type`, `properties`) is dropped, the module is hollowed, and `$defs/dropped` is removed. A ref from the dropped body to `unused` must *not* make `unused` reachable. @@ -38,12 +45,13 @@ The modules exercise the tree-shaker as follows: (`address`, `geo`, the module itself) are hollowed; unreferenced siblings (`city`, `lon`, `unusedTopLevel`) and `$defs/unusedDef` are dropped. - `used-array-items` — refs point *into* schema arrays (`#/properties/combined/allOf/1`, - `#/properties/choice/oneOf/1`) and to a *non-schema* value (`#/properties/status/enum`). - Non-reached array siblings (`allOf/0`, `allOf/2`, `oneOf/0`) must be replaced by a void - `{"not": …}` placeholder — never removed, so indices and pointers stay stable, and never - serialised as `null`. The non-schema target is kept as a whole and its parent `status` is - hollowed. `$defs/keptViaArray` is kept transitively via `allOf/1`; `$defs/droppedDef` is removed. - A ref from the void `allOf/2` to `unused` must *not* make `unused` reachable. + `#/properties/combined/allOf/3`, `#/properties/choice/oneOf/1`). `allOf/3` is the boolean + schema `true`. Non-reached array items *before* the last reached one (`allOf/0`, `oneOf/0`) must be + replaced by a void `{"not": …}` placeholder — never removed, so indices and pointers stay stable, + and never serialised as `null`; boolean items (`allOf/2` = `false`) stay as they are. Non-reached + items *after* the last reached one (`allOf/4`) may be truncated. The unreferenced `examples` of + `choice` are removed. `$defs/keptViaArray` is kept transitively via `allOf/1`; `$defs/droppedDef` + and the nested `oneOf/*/$defs/droppedDef` are removed. - `unused` — nothing reachable refers to it; the whole module is removed. - `dynamic-anchor` — declares a `$dynamicAnchor`; the tree-shaker cannot follow anchors and keeps it as is. @@ -56,9 +64,11 @@ The entry schema additionally covers: `$defs/mainUnused` is dropped from that container. - an inline object property (`properties.composed`) whose own properties reference *properties* of a module rather than its `$defs` (see `used-properties-only` above). -- refs into *array items* and to a *non-schema value* of a module - (`properties.middle`, `properties.lastChoice`, `properties.statusValues`; +- refs into *array items* of a module (`properties.middle`, `properties.lastChoice`; see `used-array-items` above). +- refs to an empty `{}`, a `$comment`-only and an annotation-only schema + (`properties.anyValue`, `properties.todoValue`, `properties.annotatedValue`) — reached, so + they must survive and must *not* be hollowed. Inside the fixtures, the annotation keywords have distinct roles: @@ -72,8 +82,9 @@ Inside the fixtures, the annotation keywords have distinct roles: `report.json` pins the bundler's bookkeeping: which files were embedded and under which `$defs` key, which refs were left external, every rewired ref (source → target), the tree-shake result (every `reached`, `hollowed` and `removed` JSON pointer), and the -validation warnings. Paths in the report are relative to the entry schema's directory, -so the snapshot is independent of the temporary work directory. +validation warnings. Paths in the report are relative to the entry schema's directory and +the `embedded`/`external`/`rewired` lists are sorted, so the snapshot is independent of the +temporary work directory and of file-system iteration order. ## Run diff --git a/tools/src/main/js/bundler/test/fixtures/main.schema.json b/tools/src/main/js/bundler/test/fixtures/main.schema.json index b668fef21..3e10d406b 100644 --- a/tools/src/main/js/bundler/test/fixtures/main.schema.json +++ b/tools/src/main/js/bundler/test/fixtures/main.schema.json @@ -9,7 +9,9 @@ "whole" ], "examples": [ - {"whole": {}} + { + "whole": {} + } ], "$comment": "FIXTURE: root schema; entry point of the bundle. This $comment is the only one that survives minification. The root title/description must survive in both outputs. The root `required`/`examples` are data arrays inside the (reached) root schema and must survive the tree-shaker untouched.", "properties": { @@ -83,10 +85,17 @@ } }, "middle": { - "$comment": "Ref INTO a schema array (allOf/1) of a module -> siblings allOf/0 and allOf/2 must become void placeholders, not null; the index must stay 1.", + "$comment": "Ref INTO a schema array (allOf/1 and allOf/3) of a module -> siblings allOf/0 and allOf/2 must become void placeholders or stay boolean, not null; the indexes must stay; the rest can be safely removed.", "title": "Middle", "description": "The middle part of the module's combined schema.", - "$ref": "modules/used-array-items.schema.json#/properties/combined/allOf/1" + "anyOf": [ + { + "$ref": "modules/used-array-items.schema.json#/properties/combined/allOf/1" + }, + { + "$ref": "modules/used-array-items.schema.json#/properties/combined/allOf/3" + } + ] }, "lastChoice": { "$comment": "Ref INTO the last item of a oneOf -> the preceding item must become a void placeholder.", diff --git a/tools/src/main/js/bundler/test/fixtures/modules/used-array-items.schema.json b/tools/src/main/js/bundler/test/fixtures/modules/used-array-items.schema.json index a07590bed..83ba63612 100644 --- a/tools/src/main/js/bundler/test/fixtures/modules/used-array-items.schema.json +++ b/tools/src/main/js/bundler/test/fixtures/modules/used-array-items.schema.json @@ -7,7 +7,7 @@ "type": "object", "properties": { "combined": { - "$comment": "Only allOf/1 is referenced from main. allOf/0 and allOf/2 are not reached -> they must become void placeholders, never JSON null, so that the index of allOf/1 stays stable. 'combined' itself is not reached -> hollowed.", + "$comment": "Only allOf/1 and allOf/3 are referenced from main. allOf/0 and allOf/2 are not reached -> they must become void placeholders or stay boolean, never JSON null, so that the index of allOf/3 stays stable; the rest can be truncated. 'combined' itself is not reached -> hollowed.", "title": "Combined", "description": "A composition of which only the middle part is referenced.", "allOf": [ @@ -37,11 +37,24 @@ } } }, + false, + true, { - "$comment": "allOf/2: NOT referenced -> void placeholder. Its ref must NOT make unused.schema.json reachable.", - "title": "Combined part 2", - "description": "The last part.", - "$ref": "unused.schema.json" + "$comment": "allOf/4: NOT referenced and nothing used after it -> removed.", + "title": "Combined part 6", + "description": "The end part.", + "type": "object", + "properties": { + "a": { + "title": "A", + "description": "An unreferenced value.", + "type": "string" + } + } + }, + { + "$comment": "allOf/5: NOT referenced and nothing used after it -> removed.", + "$ref": "#/$defs/droppedDef" } ] }, diff --git a/tools/src/main/js/bundler/test/snapshot/main-bundled.min.schema.json b/tools/src/main/js/bundler/test/snapshot/main-bundled.min.schema.json index afb8f585d..cd6514af3 100644 --- a/tools/src/main/js/bundler/test/snapshot/main-bundled.min.schema.json +++ b/tools/src/main/js/bundler/test/snapshot/main-bundled.min.schema.json @@ -1 +1 @@ -{"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"http://example.com/test/main-bundled.min.schema.json","$comment":"FIXTURE: root schema; entry point of the bundle. This $comment is the only one that survives minification. The root title/description must survive in both outputs. The root `required`/`examples` are data arrays inside the (reached) root schema and must survive the tree-shaker untouched.","title":"Main","description":"Root document of the bundler test fixtures.","type":"object","required":["whole"],"examples":[{"whole":{}}],"properties":{"$comment":{"type":"string","title":"Comment"},"whole":{"title":"Whole","description":"A module referenced in its entirety.","$ref":"#/$defs/used-whole"},"wholeData":{"title":"Whole data","description":"A data-heavy module referenced in its entirety.","$ref":"#/$defs/used-whole-data"},"sub":{"title":"Sub","description":"A single definition of another module.","$ref":"#/$defs/used-subdefs-only/$defs/kept"},"local":{"title":"Local","description":"A definition of this very schema.","$ref":"#/$defs/main/$defs/mainLocal"},"composed":{"title":"Composed","description":"An object assembled from parts of another module.","type":"object","$defs":{"rootNestedDropped":{"title":"Root nested dropped","description":"An unreferenced nested definition of the root.","type":"null"}},"properties":{"street":{"title":"Street","description":"The street, taken from the module's address.","$ref":"#/$defs/used-properties-only/properties/address/properties/street"},"latitude":{"title":"Latitude","description":"The latitude, taken from the module's address.geo.","$ref":"#/$defs/used-properties-only/properties/address/properties/geo/properties/lat"},"contact":{"title":"Contact","description":"The contact block of the module, as is.","$ref":"#/$defs/used-properties-only/properties/contact"},"nested":{"title":"Nested","description":"A value defined in a property-level $defs.","$ref":"#/$defs/used-whole/properties/withDefs/$defs/nestedKept"}}},"middle":{"title":"Middle","description":"The middle part of the module's combined schema.","$ref":"#/$defs/used-array-items/properties/combined/allOf/1"},"lastChoice":{"title":"Last choice","description":"The last alternative of the module's choice.","$ref":"#/$defs/used-array-items/properties/choice/oneOf/1"},"anyValue":{"title":"Any value","description":"Accepts anything.","$ref":"#/$defs/used-subdefs-only/$defs/keptEmpty"},"todoValue":{"title":"Todo value","description":"A placeholder.","$ref":"#/$defs/used-subdefs-only/$defs/keptCommentOnly"},"annotatedValue":{"title":"Annotated value","description":"An annotation-only schema.","$ref":"#/$defs/used-subdefs-only/$defs/keptAnnotationsOnly"}},"$defs":{"dynamic-anchor":{"$dynamicAnchor":"node","title":"Dynamic anchor","description":"A recursive tree node, addressable via a dynamic anchor.","type":"object","properties":{"children":{"title":"Children","description":"The child nodes of this node.","type":"array","items":{"title":"Child","description":"A nested node.","$ref":"#/$defs/dynamic-anchor"}}}},"main":{"$defs":{"mainLocal":{"title":"Main local","description":"A local string definition of the root schema.","type":"string"},"mainUnused":{"title":"Main unused","description":"A definition nothing refers to.","type":"null"}},"not":{}},"unused":{"title":"Unused","description":"A module that nothing reachable refers to.","type":"object","properties":{"pullsNothingIn":{"title":"Pulls nothing in","description":"A flag defined by another module.","$ref":"#/$defs/used-whole/$defs/unused"}}},"used-array-items":{"title":"Used array items","description":"A module of which only individual array items and a non-schema value are referenced.","type":"object","properties":{"combined":{"title":"Combined","description":"A composition of which only the middle part is referenced.","allOf":[{"title":"Combined part 0","description":"The first part.","type":"object","properties":{"a":{"title":"A","description":"An unreferenced value.","type":"string"}}},{"title":"Combined part 1","description":"The middle part.","type":"object","properties":{"b":{"title":"B","description":"A value defined in this module.","$ref":"#/$defs/used-array-items/$defs/keptViaArray"}}},{"title":"Combined part 2","description":"The last part.","$ref":"#/$defs/unused"}]},"choice":{"title":"Choice","description":"A choice of which only the last alternative is referenced.","oneOf":[{"title":"Choice 0","description":"The first alternative.","type":"null","$defs":{"droppedDef":{"title":"Dropped def","description":"A definition nothing reachable refers to.","type":"null"}}},{"title":"Choice 1","description":"The second alternative.","type":"integer","$defs":{"droppedDef":{"title":"Dropped def","description":"A definition nothing reachable refers to.","type":"null"}}}],"examples":[null,1,2,3]}},"$defs":{"keptViaArray":{"title":"Kept via array","description":"A boolean value.","type":"boolean"},"droppedDef":{"title":"Dropped def","description":"A definition nothing reachable refers to.","type":"null"}}},"used-properties-only":{"title":"Used properties only","description":"A module of which only nested properties are referenced.","type":"object","properties":{"address":{"title":"Address","description":"A postal address.","type":"object","properties":{"street":{"title":"Street","description":"Street name and number.","type":"string"},"city":{"title":"City","description":"Name of the city.","type":"string"},"geo":{"title":"Geo","description":"Geographic coordinates.","type":"object","properties":{"lat":{"title":"Latitude","description":"Latitude in degrees.","$ref":"#/$defs/used-properties-only/$defs/degrees"},"lon":{"title":"Longitude","description":"Longitude in degrees.","$ref":"#/$defs/used-properties-only/$defs/degrees"}}}}},"contact":{"title":"Contact","description":"Contact details.","type":"object","properties":{"email":{"title":"E-mail","description":"An e-mail address.","type":"string","format":"email"},"phone":{"title":"Phone","description":"A phone number.","type":"string"}}},"unusedTopLevel":{"title":"Unused top level","description":"A property nothing refers to.","$ref":"#/$defs/unused"}},"$defs":{"degrees":{"title":"Degrees","description":"An angle in degrees.","type":"number","minimum":-180,"maximum":180},"unusedDef":{"title":"Unused def","description":"A definition nothing reachable refers to.","type":"null"}}},"used-subdefs-only":{"title":"Used sub-defs only","description":"A module of which only individual definitions are referenced.","type":"object","properties":{"body":{"title":"Body","description":"The body of this module.","$ref":"#/$defs/unused"}},"$defs":{"kept":{"title":"Kept","description":"A definition that is reached directly from the root schema.","type":"object","properties":{"shared":{"title":"Shared","description":"A value defined by another module.","$ref":"#/$defs/used-whole/$defs/sharedByOther"},"fromMain":{"title":"From main","description":"A value defined by the root schema.","$ref":"#/$defs/main/$defs/mainLocal"},"chain":{"title":"Chain","description":"A value defined further down in this module.","$ref":"#/$defs/used-subdefs-only/$defs/keptTransitively"}}},"keptEmpty":{},"keptCommentOnly":{},"keptAnnotationsOnly":{"title":"Kept annotations only","description":"A schema that is just an annotation."},"keptTransitively":{"title":"Kept transitively","description":"A string value.","type":"string"},"dropped":{"title":"Dropped","description":"A definition nothing reachable refers to.","type":"null"}}},"used-whole":{"title":"Used whole","description":"A module that is referenced in its entirety.","type":"object","properties":{"name":{"title":"Name","description":"A display name.","type":"string","$defs":{"nestedDropped":{"title":"Nested dropped","description":"An unreferenced nested definition.","type":"null"}}},"withDefs":{"title":"With defs","description":"A property that hosts its own definitions.","anyOf":[{"title":"Is string","type":"string"},{"title":"Non-zero number","type":"number","if":{"const":0,"$defs":{"unused":{"title":"Unused","type":"null"}}},"then":false,"else":true,"$defs":{"unusedPositive":{"title":"Number > 0","type":"number","exclusiveMinimum":0},"unusedNegative":{"title":"Number < 0","type":"number","exclusiveMaximum":0}}}],"$defs":{"nestedKept":{"title":"Nested kept","description":"A referenced nested definition.","type":"integer"},"nestedDropped":{"title":"Nested dropped","description":"An unreferenced nested definition.","type":"null"}}},"inner":{"title":"Inner","description":"Sibling annotations next to a $ref: title/description are kept, while the sibling $comment is removed in the minified output.","$ref":"#/$defs/used-whole/$defs/inner"},"license":{"title":"License","description":"The license of this item.","$ref":"externals/not-bundled.schema.json#/$defs/licenseId"},"remote":{"title":"Remote","description":"A thing defined by a remote schema.","$ref":"https://example.com/remote.schema.json#/$defs/thing"},"inArray":{"title":"In array","description":"A composition of several referenced schemas.","allOf":[{"$ref":"#/$defs/used-whole/$defs/inner"},{"$ref":"#/$defs/used-subdefs-only/$defs/keptTransitively"},{"$ref":"externals/not-bundled.schema.json#/$defs/licenseId"}]}},"$defs":{"inner":{"title":"Inner","description":"An integer value.","type":"integer"},"unused":{"title":"Unused","description":"A flag.","type":"boolean"},"sharedByOther":{"title":"Shared by other","description":"A numeric value shared across modules.","type":"number"}}},"used-whole-data":{"title":"Used whole data","description":"A module referenced in its entirety that carries data-bearing keywords inside reached schemas.","type":"object","required":["status","level"],"examples":[{"status":"on","level":1},{"status":"off","level":2}],"default":{"status":"on","level":1},"properties":{"status":{"title":"Status","description":"A status value.","type":"string","enum":["on","off"],"meta:enum":{"on":"It is on.","off":"It is off."}},"level":{"title":"Level","description":"A fixed level.","type":"integer","const":1,"examples":[1],"default":1},"flags":{"title":"Flags","description":"An object with boolean-schema properties.","type":"object","required":["anything"],"properties":{"anything":true,"nothing":false},"additionalProperties":false},"tags":{"title":"Tags","description":"A list of tags.","type":"array","uniqueItems":true,"minItems":0,"items":{"title":"Tag","description":"A single tag.","type":"string"}}},"$defs":{"unusedDef":{"title":"Unused def","description":"A definition nothing reachable refers to.","type":"null"}}}}} \ No newline at end of file +{"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"http://example.com/test/main-bundled.min.schema.json","$comment":"FIXTURE: root schema; entry point of the bundle. This $comment is the only one that survives minification. The root title/description must survive in both outputs. The root `required`/`examples` are data arrays inside the (reached) root schema and must survive the tree-shaker untouched.","title":"Main","description":"Root document of the bundler test fixtures.","type":"object","required":["whole"],"examples":[{"whole":{}}],"properties":{"$comment":{"type":"string","title":"Comment"},"whole":{"title":"Whole","description":"A module referenced in its entirety.","$ref":"#/$defs/used-whole"},"wholeData":{"title":"Whole data","description":"A data-heavy module referenced in its entirety.","$ref":"#/$defs/used-whole-data"},"sub":{"title":"Sub","description":"A single definition of another module.","$ref":"#/$defs/used-subdefs-only/$defs/kept"},"local":{"title":"Local","description":"A definition of this very schema.","$ref":"#/$defs/main/$defs/mainLocal"},"composed":{"title":"Composed","description":"An object assembled from parts of another module.","type":"object","$defs":{"rootNestedDropped":{"title":"Root nested dropped","description":"An unreferenced nested definition of the root.","type":"null"}},"properties":{"street":{"title":"Street","description":"The street, taken from the module's address.","$ref":"#/$defs/used-properties-only/properties/address/properties/street"},"latitude":{"title":"Latitude","description":"The latitude, taken from the module's address.geo.","$ref":"#/$defs/used-properties-only/properties/address/properties/geo/properties/lat"},"contact":{"title":"Contact","description":"The contact block of the module, as is.","$ref":"#/$defs/used-properties-only/properties/contact"},"nested":{"title":"Nested","description":"A value defined in a property-level $defs.","$ref":"#/$defs/used-whole/properties/withDefs/$defs/nestedKept"}}},"middle":{"title":"Middle","description":"The middle part of the module's combined schema.","anyOf":[{"$ref":"#/$defs/used-array-items/properties/combined/allOf/1"},{"$ref":"#/$defs/used-array-items/properties/combined/allOf/3"}]},"lastChoice":{"title":"Last choice","description":"The last alternative of the module's choice.","$ref":"#/$defs/used-array-items/properties/choice/oneOf/1"},"anyValue":{"title":"Any value","description":"Accepts anything.","$ref":"#/$defs/used-subdefs-only/$defs/keptEmpty"},"todoValue":{"title":"Todo value","description":"A placeholder.","$ref":"#/$defs/used-subdefs-only/$defs/keptCommentOnly"},"annotatedValue":{"title":"Annotated value","description":"An annotation-only schema.","$ref":"#/$defs/used-subdefs-only/$defs/keptAnnotationsOnly"}},"$defs":{"dynamic-anchor":{"$dynamicAnchor":"node","title":"Dynamic anchor","description":"A recursive tree node, addressable via a dynamic anchor.","type":"object","properties":{"children":{"title":"Children","description":"The child nodes of this node.","type":"array","items":{"title":"Child","description":"A nested node.","$ref":"#/$defs/dynamic-anchor"}}}},"main":{"$defs":{"mainLocal":{"title":"Main local","description":"A local string definition of the root schema.","type":"string"},"mainUnused":{"title":"Main unused","description":"A definition nothing refers to.","type":"null"}},"not":{}},"unused":{"title":"Unused","description":"A module that nothing reachable refers to.","type":"object","properties":{"pullsNothingIn":{"title":"Pulls nothing in","description":"A flag defined by another module.","$ref":"#/$defs/used-whole/$defs/unused"}}},"used-array-items":{"title":"Used array items","description":"A module of which only individual array items and a non-schema value are referenced.","type":"object","properties":{"combined":{"title":"Combined","description":"A composition of which only the middle part is referenced.","allOf":[{"title":"Combined part 0","description":"The first part.","type":"object","properties":{"a":{"title":"A","description":"An unreferenced value.","type":"string"}}},{"title":"Combined part 1","description":"The middle part.","type":"object","properties":{"b":{"title":"B","description":"A value defined in this module.","$ref":"#/$defs/used-array-items/$defs/keptViaArray"}}},false,true,{"title":"Combined part 6","description":"The end part.","type":"object","properties":{"a":{"title":"A","description":"An unreferenced value.","type":"string"}}},{"$ref":"#/$defs/used-array-items/$defs/droppedDef"}]},"choice":{"title":"Choice","description":"A choice of which only the last alternative is referenced.","oneOf":[{"title":"Choice 0","description":"The first alternative.","type":"null","$defs":{"droppedDef":{"title":"Dropped def","description":"A definition nothing reachable refers to.","type":"null"}}},{"title":"Choice 1","description":"The second alternative.","type":"integer","$defs":{"droppedDef":{"title":"Dropped def","description":"A definition nothing reachable refers to.","type":"null"}}}],"examples":[null,1,2,3]}},"$defs":{"keptViaArray":{"title":"Kept via array","description":"A boolean value.","type":"boolean"},"droppedDef":{"title":"Dropped def","description":"A definition nothing reachable refers to.","type":"null"}}},"used-properties-only":{"title":"Used properties only","description":"A module of which only nested properties are referenced.","type":"object","properties":{"address":{"title":"Address","description":"A postal address.","type":"object","properties":{"street":{"title":"Street","description":"Street name and number.","type":"string"},"city":{"title":"City","description":"Name of the city.","type":"string"},"geo":{"title":"Geo","description":"Geographic coordinates.","type":"object","properties":{"lat":{"title":"Latitude","description":"Latitude in degrees.","$ref":"#/$defs/used-properties-only/$defs/degrees"},"lon":{"title":"Longitude","description":"Longitude in degrees.","$ref":"#/$defs/used-properties-only/$defs/degrees"}}}}},"contact":{"title":"Contact","description":"Contact details.","type":"object","properties":{"email":{"title":"E-mail","description":"An e-mail address.","type":"string","format":"email"},"phone":{"title":"Phone","description":"A phone number.","type":"string"}}},"unusedTopLevel":{"title":"Unused top level","description":"A property nothing refers to.","$ref":"#/$defs/unused"}},"$defs":{"degrees":{"title":"Degrees","description":"An angle in degrees.","type":"number","minimum":-180,"maximum":180},"unusedDef":{"title":"Unused def","description":"A definition nothing reachable refers to.","type":"null"}}},"used-subdefs-only":{"title":"Used sub-defs only","description":"A module of which only individual definitions are referenced.","type":"object","properties":{"body":{"title":"Body","description":"The body of this module.","$ref":"#/$defs/unused"}},"$defs":{"kept":{"title":"Kept","description":"A definition that is reached directly from the root schema.","type":"object","properties":{"shared":{"title":"Shared","description":"A value defined by another module.","$ref":"#/$defs/used-whole/$defs/sharedByOther"},"fromMain":{"title":"From main","description":"A value defined by the root schema.","$ref":"#/$defs/main/$defs/mainLocal"},"chain":{"title":"Chain","description":"A value defined further down in this module.","$ref":"#/$defs/used-subdefs-only/$defs/keptTransitively"}}},"keptEmpty":{},"keptCommentOnly":{},"keptAnnotationsOnly":{"title":"Kept annotations only","description":"A schema that is just an annotation."},"keptTransitively":{"title":"Kept transitively","description":"A string value.","type":"string"},"dropped":{"title":"Dropped","description":"A definition nothing reachable refers to.","type":"null"}}},"used-whole":{"title":"Used whole","description":"A module that is referenced in its entirety.","type":"object","properties":{"name":{"title":"Name","description":"A display name.","type":"string","$defs":{"nestedDropped":{"title":"Nested dropped","description":"An unreferenced nested definition.","type":"null"}}},"withDefs":{"title":"With defs","description":"A property that hosts its own definitions.","anyOf":[{"title":"Is string","type":"string"},{"title":"Non-zero number","type":"number","if":{"const":0,"$defs":{"unused":{"title":"Unused","type":"null"}}},"then":false,"else":true,"$defs":{"unusedPositive":{"title":"Number > 0","type":"number","exclusiveMinimum":0},"unusedNegative":{"title":"Number < 0","type":"number","exclusiveMaximum":0}}}],"$defs":{"nestedKept":{"title":"Nested kept","description":"A referenced nested definition.","type":"integer"},"nestedDropped":{"title":"Nested dropped","description":"An unreferenced nested definition.","type":"null"}}},"inner":{"title":"Inner","description":"Sibling annotations next to a $ref: title/description are kept, while the sibling $comment is removed in the minified output.","$ref":"#/$defs/used-whole/$defs/inner"},"license":{"title":"License","description":"The license of this item.","$ref":"externals/not-bundled.schema.json#/$defs/licenseId"},"remote":{"title":"Remote","description":"A thing defined by a remote schema.","$ref":"https://example.com/remote.schema.json#/$defs/thing"},"inArray":{"title":"In array","description":"A composition of several referenced schemas.","allOf":[{"$ref":"#/$defs/used-whole/$defs/inner"},{"$ref":"#/$defs/used-subdefs-only/$defs/keptTransitively"},{"$ref":"externals/not-bundled.schema.json#/$defs/licenseId"}]}},"$defs":{"inner":{"title":"Inner","description":"An integer value.","type":"integer"},"unused":{"title":"Unused","description":"A flag.","type":"boolean"},"sharedByOther":{"title":"Shared by other","description":"A numeric value shared across modules.","type":"number"}}},"used-whole-data":{"title":"Used whole data","description":"A module referenced in its entirety that carries data-bearing keywords inside reached schemas.","type":"object","required":["status","level"],"examples":[{"status":"on","level":1},{"status":"off","level":2}],"default":{"status":"on","level":1},"properties":{"status":{"title":"Status","description":"A status value.","type":"string","enum":["on","off"],"meta:enum":{"on":"It is on.","off":"It is off."}},"level":{"title":"Level","description":"A fixed level.","type":"integer","const":1,"examples":[1],"default":1},"flags":{"title":"Flags","description":"An object with boolean-schema properties.","type":"object","required":["anything"],"properties":{"anything":true,"nothing":false},"additionalProperties":false},"tags":{"title":"Tags","description":"A list of tags.","type":"array","uniqueItems":true,"minItems":0,"items":{"title":"Tag","description":"A single tag.","type":"string"}}},"$defs":{"unusedDef":{"title":"Unused def","description":"A definition nothing reachable refers to.","type":"null"}}}}} \ No newline at end of file diff --git a/tools/src/main/js/bundler/test/snapshot/main-bundled.schema.json b/tools/src/main/js/bundler/test/snapshot/main-bundled.schema.json index c39871a59..5b2b34c3c 100644 --- a/tools/src/main/js/bundler/test/snapshot/main-bundled.schema.json +++ b/tools/src/main/js/bundler/test/snapshot/main-bundled.schema.json @@ -84,10 +84,17 @@ } }, "middle": { - "$comment": "Ref INTO a schema array (allOf/1) of a module -> siblings allOf/0 and allOf/2 must become void placeholders, not null; the index must stay 1.", + "$comment": "Ref INTO a schema array (allOf/1 and allOf/3) of a module -> siblings allOf/0 and allOf/2 must become void placeholders or stay boolean, not null; the indexes must stay; the rest can be safely removed.", "title": "Middle", "description": "The middle part of the module's combined schema.", - "$ref": "#/$defs/used-array-items/properties/combined/allOf/1" + "anyOf": [ + { + "$ref": "#/$defs/used-array-items/properties/combined/allOf/1" + }, + { + "$ref": "#/$defs/used-array-items/properties/combined/allOf/3" + } + ] }, "lastChoice": { "$comment": "Ref INTO the last item of a oneOf -> the preceding item must become a void placeholder.", @@ -172,7 +179,7 @@ "type": "object", "properties": { "combined": { - "$comment": "Only allOf/1 is referenced from main. allOf/0 and allOf/2 are not reached -> they must become void placeholders, never JSON null, so that the index of allOf/1 stays stable. 'combined' itself is not reached -> hollowed.", + "$comment": "Only allOf/1 and allOf/3 are referenced from main. allOf/0 and allOf/2 are not reached -> they must become void placeholders or stay boolean, never JSON null, so that the index of allOf/3 stays stable; the rest can be truncated. 'combined' itself is not reached -> hollowed.", "title": "Combined", "description": "A composition of which only the middle part is referenced.", "allOf": [ @@ -202,11 +209,24 @@ } } }, + false, + true, + { + "$comment": "allOf/4: NOT referenced and nothing used after it -> removed.", + "title": "Combined part 6", + "description": "The end part.", + "type": "object", + "properties": { + "a": { + "title": "A", + "description": "An unreferenced value.", + "type": "string" + } + } + }, { - "$comment": "allOf/2: NOT referenced -> void placeholder. Its ref must NOT make unused.schema.json reachable.", - "title": "Combined part 2", - "description": "The last part.", - "$ref": "#/$defs/unused" + "$comment": "allOf/5: NOT referenced and nothing used after it -> removed.", + "$ref": "#/$defs/used-array-items/$defs/droppedDef" } ] }, diff --git a/tools/src/main/js/bundler/test/snapshot/report.json b/tools/src/main/js/bundler/test/snapshot/report.json index 3a5e3dad3..e07b8b94a 100644 --- a/tools/src/main/js/bundler/test/snapshot/report.json +++ b/tools/src/main/js/bundler/test/snapshot/report.json @@ -40,92 +40,100 @@ ], "rewired": [ [ - "modules/used-whole.schema.json", - "#/$defs/used-whole" - ], - [ - "modules/used-whole-data.schema.json", - "#/$defs/used-whole-data" - ], - [ - "modules/used-subdefs-only.schema.json#/$defs/kept", - "#/$defs/used-subdefs-only/$defs/kept" + "externals/not-bundled.schema.json#/$defs/licenseId", + "externals/not-bundled.schema.json#/$defs/licenseId" ], [ "main.schema.json#/$defs/mainLocal", "#/$defs/main/$defs/mainLocal" ], [ - "modules/used-properties-only.schema.json#/properties/address/properties/street", - "#/$defs/used-properties-only/properties/address/properties/street" - ], - [ - "modules/used-properties-only.schema.json#/properties/address/properties/geo/properties/lat", - "#/$defs/used-properties-only/properties/address/properties/geo/properties/lat" + "modules/dynamic-anchor.schema.json", + "#/$defs/dynamic-anchor" ], [ - "modules/used-properties-only.schema.json#/properties/contact", - "#/$defs/used-properties-only/properties/contact" + "modules/unused.schema.json", + "#/$defs/unused" ], [ - "modules/used-whole.schema.json#/properties/withDefs/$defs/nestedKept", - "#/$defs/used-whole/properties/withDefs/$defs/nestedKept" + "modules/used-array-items.schema.json#/$defs/droppedDef", + "#/$defs/used-array-items/$defs/droppedDef" ], [ - "modules/used-array-items.schema.json#/properties/combined/allOf/1", - "#/$defs/used-array-items/properties/combined/allOf/1" + "modules/used-array-items.schema.json#/$defs/keptViaArray", + "#/$defs/used-array-items/$defs/keptViaArray" ], [ "modules/used-array-items.schema.json#/properties/choice/oneOf/1", "#/$defs/used-array-items/properties/choice/oneOf/1" ], [ - "modules/used-subdefs-only.schema.json#/$defs/keptEmpty", - "#/$defs/used-subdefs-only/$defs/keptEmpty" + "modules/used-array-items.schema.json#/properties/combined/allOf/1", + "#/$defs/used-array-items/properties/combined/allOf/1" ], [ - "modules/used-subdefs-only.schema.json#/$defs/keptCommentOnly", - "#/$defs/used-subdefs-only/$defs/keptCommentOnly" + "modules/used-array-items.schema.json#/properties/combined/allOf/3", + "#/$defs/used-array-items/properties/combined/allOf/3" ], [ - "modules/used-subdefs-only.schema.json#/$defs/keptAnnotationsOnly", - "#/$defs/used-subdefs-only/$defs/keptAnnotationsOnly" + "modules/used-properties-only.schema.json#/$defs/degrees", + "#/$defs/used-properties-only/$defs/degrees" ], [ - "modules/dynamic-anchor.schema.json", - "#/$defs/dynamic-anchor" + "modules/used-properties-only.schema.json#/properties/address/properties/geo/properties/lat", + "#/$defs/used-properties-only/properties/address/properties/geo/properties/lat" ], [ - "modules/used-whole.schema.json#/$defs/unused", - "#/$defs/used-whole/$defs/unused" + "modules/used-properties-only.schema.json#/properties/address/properties/street", + "#/$defs/used-properties-only/properties/address/properties/street" ], [ - "modules/used-array-items.schema.json#/$defs/keptViaArray", - "#/$defs/used-array-items/$defs/keptViaArray" + "modules/used-properties-only.schema.json#/properties/contact", + "#/$defs/used-properties-only/properties/contact" ], [ - "modules/unused.schema.json", - "#/$defs/unused" + "modules/used-subdefs-only.schema.json#/$defs/kept", + "#/$defs/used-subdefs-only/$defs/kept" ], [ - "modules/used-properties-only.schema.json#/$defs/degrees", - "#/$defs/used-properties-only/$defs/degrees" + "modules/used-subdefs-only.schema.json#/$defs/keptAnnotationsOnly", + "#/$defs/used-subdefs-only/$defs/keptAnnotationsOnly" ], [ - "modules/used-whole.schema.json#/$defs/sharedByOther", - "#/$defs/used-whole/$defs/sharedByOther" + "modules/used-subdefs-only.schema.json#/$defs/keptCommentOnly", + "#/$defs/used-subdefs-only/$defs/keptCommentOnly" + ], + [ + "modules/used-subdefs-only.schema.json#/$defs/keptEmpty", + "#/$defs/used-subdefs-only/$defs/keptEmpty" ], [ "modules/used-subdefs-only.schema.json#/$defs/keptTransitively", "#/$defs/used-subdefs-only/$defs/keptTransitively" ], + [ + "modules/used-whole-data.schema.json", + "#/$defs/used-whole-data" + ], + [ + "modules/used-whole.schema.json", + "#/$defs/used-whole" + ], [ "modules/used-whole.schema.json#/$defs/inner", "#/$defs/used-whole/$defs/inner" ], [ - "externals/not-bundled.schema.json#/$defs/licenseId", - "externals/not-bundled.schema.json#/$defs/licenseId" + "modules/used-whole.schema.json#/$defs/sharedByOther", + "#/$defs/used-whole/$defs/sharedByOther" + ], + [ + "modules/used-whole.schema.json#/$defs/unused", + "#/$defs/used-whole/$defs/unused" + ], + [ + "modules/used-whole.schema.json#/properties/withDefs/$defs/nestedKept", + "#/$defs/used-whole/properties/withDefs/$defs/nestedKept" ] ] }, diff --git a/tools/src/test/js/schema-v2/json-schema-validate-tests.js b/tools/src/test/js/schema-v2/json-schema-validate-tests.js index aa6f8ee02..d7b3d09ab 100644 --- a/tools/src/test/js/schema-v2/json-schema-validate-tests.js +++ b/tools/src/test/js/schema-v2/json-schema-validate-tests.js @@ -84,6 +84,7 @@ const [spdxSchema, cryptoDefsSchema, behaviorTaxonomySchema, schemas, schemaModu function getAjv(bundled) { // see https://ajv.js.org/options.html const ajv = new Ajv2020({ + allErrors: true, verbose: true, addUsedSchema: false, keywords: ["meta:enum"], @@ -121,17 +122,17 @@ let errCnt = 0 for (const [schemaFile, schema] of schemas) { console.log('\n> SchemaFile: ', schemaFile); - const ajv = getAjv(schemaFilesBundleMatcher.test(basename(schemaFile))) - - console.group(`> compile schema, log warnings ...`) + console.group(`> validate & compile schema, log warnings ...`) try { + const ajv = getAjv(schemaFilesBundleMatcher.test(basename(schemaFile))) + // compile goes beyond validation - it also checs references, and such ajv.compile(schema) } catch (err) { ++errCnt - console.groupEnd() console.error( `!!! SCHEMA ERROR:`, String(err), '\n in file:', schemaFile) + console.groupEnd() continue } console.groupEnd()