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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions tools/src/main/js/bundler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
56 changes: 56 additions & 0 deletions tools/src/main/js/bundler/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions tools/src/main/js/bundler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"bundle-schemas": "./cli.js"
},
"dependencies": {
"ajv": "^8.20"
},
"scripts": {
"cli": "node cli.js",
Expand Down
7 changes: 3 additions & 4 deletions tools/src/main/js/bundler/src/bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -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]];
}
Expand Down
28 changes: 26 additions & 2 deletions tools/src/main/js/bundler/src/sanity.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -86,18 +88,40 @@ const validateRefs = (schema) => {
};
};

/**
* @param {Readonly<Schema>} 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>} 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,
],
}
};
33 changes: 22 additions & 11 deletions tools/src/main/js/bundler/test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,21 @@ 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`).

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.
Expand All @@ -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.

Expand All @@ -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:

Expand All @@ -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

Expand Down
15 changes: 12 additions & 3 deletions tools/src/main/js/bundler/test/fixtures/main.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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"
}
]
},
Expand Down
Loading
Loading