From 149a24796b7995e4c60cf4e71c3ce8210faff1e2 Mon Sep 17 00:00:00 2001 From: sheo13666q Date: Fri, 16 Oct 2026 14:28:49 +0000 Subject: [PATCH 1/3] feat: precompile a schema into a check for `validate` Compiling a schema at runtime loads `ajv`, about 150ms for the first schema of a process and a few to tens of milliseconds for every other one, which every loader and plugin validating its options pays on every start. `schema-utils precompile ` compiles a schema ahead of time into `.check.js`, and `validate` takes it as its fourth argument. A valid value is only checked, `ajv` is loaded to report the errors of an invalid one, so they stay the same. `--check` fails when a check is missing or out of date, for CI. The keywords `validate` compiles with a function (`instanceof`, `absolutePath`, `undefinedAsNull`) get code twins with the same semantics, the generated module loads the `ajv` runtime helpers it needs through `schema-utils`, which the package depends on, and the paths of the schemas are relative so the output is the same on every machine. Every case of the validation tests runs the precompiled check too and has to agree with it. --- .changeset/precompiled-schemas.md | 5 + README.md | 31 +++ bin/schema-utils.js | 9 + declarations/cli.d.ts | 23 ++ declarations/index.d.ts | 10 + declarations/precompile.d.ts | 26 ++ declarations/runtime.d.ts | 31 +++ declarations/validate.d.ts | 13 + eslint.config.mjs | 10 + package.json | 4 + src/cli.js | 133 +++++++++++ src/index.js | 10 + src/precompile.js | 314 ++++++++++++++++++++++++ src/runtime.js | 40 ++++ src/validate.js | 23 +- test/helpers/precompiled.js | 78 ++++++ test/index.test.js | 24 ++ test/precompile.test.js | 381 ++++++++++++++++++++++++++++++ 18 files changed, 1164 insertions(+), 1 deletion(-) create mode 100644 .changeset/precompiled-schemas.md create mode 100755 bin/schema-utils.js create mode 100644 declarations/cli.d.ts create mode 100644 declarations/precompile.d.ts create mode 100644 declarations/runtime.d.ts create mode 100644 src/cli.js create mode 100644 src/precompile.js create mode 100644 src/runtime.js create mode 100644 test/helpers/precompiled.js create mode 100644 test/precompile.test.js diff --git a/.changeset/precompiled-schemas.md b/.changeset/precompiled-schemas.md new file mode 100644 index 0000000..80d69da --- /dev/null +++ b/.changeset/precompiled-schemas.md @@ -0,0 +1,5 @@ +--- +"schema-utils": minor +--- + +perf: add `schema-utils precompile` to compile a schema ahead of time into a check that `validate` takes as its fourth argument, so valid options are validated without loading `ajv` diff --git a/README.md b/README.md index ce30457..2964750 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,37 @@ class Plugin { export default Plugin; ``` +### Precompiled schemas + +Compiling a schema at runtime loads `ajv`, which costs about 150ms for the first schema of a +process and a few to tens of milliseconds for every other one. A loader or a plugin can compile its +schema ahead of time instead, into a check that `validate` takes as its fourth argument: + +```console +npx schema-utils precompile src/options.json +``` + +This writes `src/options.check.js` next to the schema. Commit it, and publish it with the package: + +```js +import { validate } from "schema-utils"; +import check from "./options.check.js"; +import schema from "./options.json"; + +validate(schema, options, { name: "Loader Name" }, check); +``` + +When the options are valid, the check is all that runs and `ajv` is never loaded. When they are +not, `validate` runs the full validation to report what is wrong, so the errors are the same as +without a check. + +Run `schema-utils precompile --check src/options.json` in CI to fail when the check is missing or +out of date with its schema. The generated module needs `schema-utils` as a dependency of the +package and is CommonJS. + +A relative `$ref` to another file resolves against the schema. To compile a schema from code, use +`precompile(schema, { loadSchema })`, it returns the source of the module. + ### Allow to disable and enable validation (the `validate` function do nothing) This can be useful when you don't want to do validation for `production` builds. diff --git a/bin/schema-utils.js b/bin/schema-utils.js new file mode 100755 index 0000000..5e57341 --- /dev/null +++ b/bin/schema-utils.js @@ -0,0 +1,9 @@ +#!/usr/bin/env node + +/* eslint-disable no-console */ + +require("../dist/cli") + .default(process.argv.slice(2), { log: console.log, error: console.error }) + .then((exitCode) => { + process.exitCode = exitCode; + }); diff --git a/declarations/cli.d.ts b/declarations/cli.d.ts new file mode 100644 index 0000000..134b899 --- /dev/null +++ b/declarations/cli.d.ts @@ -0,0 +1,23 @@ +export default cli; +export type CliIO = { + /** + * prints a message + */ + log: (message: string) => void; + /** + * prints an error + */ + error: (message: string) => void; +}; +/** + * @typedef {object} CliIO + * @property {(message: string) => void} log prints a message + * @property {(message: string) => void} error prints an error + */ +/** + * Runs the command line interface. + * @param {string[]} args arguments without `node` and the script + * @param {CliIO} io where the output goes + * @returns {Promise} exit code + */ +declare function cli(args: string[], io: CliIO): Promise; diff --git a/declarations/index.d.ts b/declarations/index.d.ts index 8202ab6..038be27 100644 --- a/declarations/index.d.ts +++ b/declarations/index.d.ts @@ -5,10 +5,20 @@ export type JSONSchema7 = import("./validate").JSONSchema7; export type ExtendedSchema = import("./validate").ExtendedSchema; export type ValidationErrorConfiguration = import("./validate").ValidationErrorConfiguration; +export type PrecompiledCheck = import("./validate").PrecompiledCheck; +export type PrecompileOptions = import("./precompile").PrecompileOptions; import { ValidationError } from "./validate"; import { disableValidation } from "./validate"; import { enableValidation } from "./validate"; import { needValidate } from "./validate"; +/** + * @param {Parameters} args arguments + * @returns {ReturnType} source of the module + */ +export function precompile( + schema: import("./validate").Schema, + options?: import("./precompile").PrecompileOptions | undefined, +): ReturnType; import { validate } from "./validate"; export { ValidationError, diff --git a/declarations/precompile.d.ts b/declarations/precompile.d.ts new file mode 100644 index 0000000..7b6f8d2 --- /dev/null +++ b/declarations/precompile.d.ts @@ -0,0 +1,26 @@ +export default precompile; +export type PrecompileOptions = { + /** + * loads a schema an external `$ref` points to + */ + loadSchema?: ((uri: string) => Promise) | undefined; +}; +export type Ajv = import("ajv").default; +export type AnySchemaObject = import("ajv").AnySchemaObject; +export type KeywordCxt = import("ajv").KeywordCxt; +export type Schema = import("./validate").Schema; +export type EXPECTED_ANY = any; +/** + * @typedef {object} PrecompileOptions + * @property {((uri: string) => Promise)=} loadSchema loads a schema an external `$ref` points to + */ +/** + * Compiles a schema into the source of a CommonJS module that exports a check for it. + * @param {Schema} schema schema + * @param {PrecompileOptions=} options options + * @returns {Promise} source of the module + */ +declare function precompile( + schema: Schema, + options?: PrecompileOptions | undefined, +): Promise; diff --git a/declarations/runtime.d.ts b/declarations/runtime.d.ts new file mode 100644 index 0000000..f11aa93 --- /dev/null +++ b/declarations/runtime.d.ts @@ -0,0 +1,31 @@ +export = runtime; +/** + * @typedef {object} Runtime + * @property {(a: any, b: any) => boolean} equal deep equality for `const`, `enum` and `uniqueItems` + * @property {(str: string) => number} ucs2length string length in code points for `minLength` and `maxLength` + * @property {any} uri URI parser for `$ref` to an `$id` + * @property {Record} formats formats of `ajv-formats` for `format` + */ +/** @type {Runtime} */ +declare const runtime: Runtime; +declare namespace runtime { + export { Runtime }; +} +type Runtime = { + /** + * deep equality for `const`, `enum` and `uniqueItems` + */ + equal: (a: any, b: any) => boolean; + /** + * string length in code points for `minLength` and `maxLength` + */ + ucs2length: (str: string) => number; + /** + * URI parser for `$ref` to an `$id` + */ + uri: any; + /** + * formats of `ajv-formats` for `format` + */ + formats: Record; +}; diff --git a/declarations/validate.d.ts b/declarations/validate.d.ts index 4e069fa..0122c49 100644 --- a/declarations/validate.d.ts +++ b/declarations/validate.d.ts @@ -3,6 +3,7 @@ export type JSONSchema4 = import("json-schema").JSONSchema4; export type JSONSchema6 = import("json-schema").JSONSchema6; export type JSONSchema7 = import("json-schema").JSONSchema7; export type ErrorObject = import("ajv").ErrorObject; +export type EXPECTED_ANY = any; export type ExtendedSchema = { /** * format minimum @@ -74,6 +75,10 @@ export type ErrorPathNode = { */ size: number; }; +/** + * A check precompiled from a schema with `precompile`, it only says whether the value is valid. + */ +export type PrecompiledCheck = (value: EXPECTED_ANY) => boolean; /** * Whether validation is skipped, shared by every `schema-utils` in the process. */ @@ -95,14 +100,22 @@ export function enableValidation(): void; * @returns {boolean} true when need validate, otherwise false */ export function needValidate(): boolean; +/** + * A check precompiled from a schema with `precompile`, it only says whether the value is valid. + * @callback PrecompiledCheck + * @param {EXPECTED_ANY} value value + * @returns {boolean} true when valid, otherwise false + */ /** * @param {Schema} schema schema * @param {object[] | object} options options * @param {ValidationErrorConfiguration=} configuration configuration + * @param {PrecompiledCheck=} check precompiled check of the schema, so a valid value never compiles the schema * @returns {void} */ export function validate( schema: Schema, options: object[] | object, configuration?: ValidationErrorConfiguration | undefined, + check?: PrecompiledCheck | undefined, ): void; diff --git a/eslint.config.mjs b/eslint.config.mjs index cfe7b09..fb585be 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -8,4 +8,14 @@ export default defineConfig([ "n/prefer-node-protocol": "off", }, }, + { + files: ["bin/**/*.js"], + rules: { + // Node.js has always skipped the hashbang of the file it runs + "n/no-unsupported-features/es-syntax": [ + "error", + { ignores: ["hashbang"] }, + ], + }, + }, ]); diff --git a/package.json b/package.json index 47e5937..c648c3e 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,11 @@ "author": "webpack Contrib (https://github.com/webpack-contrib)", "main": "dist/index.js", "types": "declarations/index.d.ts", + "bin": { + "schema-utils": "bin/schema-utils.js" + }, "files": [ + "bin", "dist", "declarations" ], diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..17ab45f --- /dev/null +++ b/src/cli.js @@ -0,0 +1,133 @@ +const USAGE = `Usage: schema-utils precompile [--check] ... + +Compiles every schema into a check next to it, \`options.json\` into \`options.check.js\`, +for the fourth argument of \`validate\`: + + validate(schema, options, configuration, require("./options.check.js")); + +Options: + --check write nothing, fail when a check is missing or out of date`; + +/** + * @param {string} schemaPath path of the schema + * @returns {string} path of its check + */ +function getCheckPath(schemaPath) { + return `${schemaPath.replace(/\.json$/i, "")}.check.js`; +} + +/** + * @param {string} content content + * @returns {string} content with LF line endings and without trailing whitespace + */ +function normalize(content) { + return content.replace(/\r\n?/g, "\n").trim(); +} + +/** + * @typedef {object} CliIO + * @property {(message: string) => void} log prints a message + * @property {(message: string) => void} error prints an error + */ + +/** + * Runs the command line interface. + * @param {string[]} args arguments without `node` and the script + * @param {CliIO} io where the output goes + * @returns {Promise} exit code + */ +async function cli(args, io) { + const [command, ...rest] = args; + + if (command === "--help" || command === "-h") { + io.log(USAGE); + + return 0; + } + + if (command !== "precompile") { + io.error(command ? `Unknown command "${command}"\n\n${USAGE}` : USAGE); + + return 1; + } + + const check = rest.includes("--check"); + const files = rest.filter((arg) => arg !== "--check"); + const unknown = files.find((arg) => arg.startsWith("-")); + + if (unknown) { + io.error(`Unknown option "${unknown}"\n\n${USAGE}`); + + return 1; + } + + if (files.length === 0) { + io.error(`No schema given\n\n${USAGE}`); + + return 1; + } + + const fs = require("fs"); + const path = require("path"); + const { pathToFileURL } = require("url"); + const precompile = require("./precompile").default; + + let exitCode = 0; + + for (const file of files) { + const schemaPath = path.resolve(file); + const checkPath = getCheckPath(schemaPath); + const relativeCheckPath = path.relative(process.cwd(), checkPath); + + let code; + + try { + const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8")); + + // A relative `$ref` to another file resolves against the schema itself + if (typeof schema.$id === "undefined") { + schema.$id = pathToFileURL(schemaPath).href; + } + + code = await precompile(schema); + } catch (error) { + io.error( + `Cannot precompile "${file}": ${/** @type {Error} */ (error).message}`, + ); + exitCode = 1; + continue; + } + + let current; + + try { + current = fs.readFileSync(checkPath, "utf8"); + } catch (_err) { + // Not generated yet + } + + if ( + typeof current !== "undefined" && + normalize(current) === normalize(code) + ) { + continue; + } + + if (check) { + io.error( + `"${relativeCheckPath}" is ${ + typeof current === "undefined" ? "missing" : "out of date" + }, run "schema-utils precompile ${file}"`, + ); + exitCode = 1; + continue; + } + + fs.writeFileSync(checkPath, `${code}\n`); + io.log(`"${relativeCheckPath}" updated`); + } + + return exitCode; +} + +export default cli; diff --git a/src/index.js b/src/index.js index 08dc9dc..ff937a8 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,8 @@ /** @typedef {import("./validate").JSONSchema7} JSONSchema7 */ /** @typedef {import("./validate").ExtendedSchema} ExtendedSchema */ /** @typedef {import("./validate").ValidationErrorConfiguration} ValidationErrorConfiguration */ +/** @typedef {import("./validate").PrecompiledCheck} PrecompiledCheck */ +/** @typedef {import("./precompile").PrecompileOptions} PrecompileOptions */ const { ValidationError, @@ -13,10 +15,18 @@ const { validate, } = require("./validate"); +/** + * @param {Parameters} args arguments + * @returns {ReturnType} source of the module + */ +// Loaded on demand, a precompiled schema is generated at build time, never at runtime +const precompile = (...args) => require("./precompile").default(...args); + module.exports = { ValidationError, disableValidation, enableValidation, needValidate, + precompile, validate, }; diff --git a/src/precompile.js b/src/precompile.js new file mode 100644 index 0000000..2eda84a --- /dev/null +++ b/src/precompile.js @@ -0,0 +1,314 @@ +/** @typedef {import("ajv").default} Ajv */ +/** @typedef {import("ajv").AnySchemaObject} AnySchemaObject */ +/** @typedef {import("ajv").KeywordCxt} KeywordCxt */ +/** @typedef {import("./validate").Schema} Schema */ + +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + +// Compiles a schema into a standalone check, a module exporting `(value) => boolean` that +// `validate` takes as its fourth argument, so a valid value never loads `ajv`. +// +// WHY: compiling a schema with `ajv` at runtime costs about 150ms for the first schema of a process +// (loading `ajv` itself) and a few up to tens of milliseconds for every other one, which every +// webpack plugin and loader pays on every start. The check only answers whether the value is +// valid, the full validation still runs to build the error message when it is not. + +const RUNTIME = "schema-utils/dist/runtime"; + +/** + * @param {Ajv} ajv ajv + * @returns {void} + */ +function addKeywords(ajv) { + const { Name, _ } = require("ajv"); + + // The same constructors `ajv-keywords` accepts + const CONSTRUCTORS = [ + "Object", + "Array", + "Function", + "Number", + "String", + "Date", + "RegExp", + "Buffer", + "Promise", + ]; + + ajv.addKeyword({ + keyword: "instanceof", + schemaType: ["string", "array"], + /** + * @param {KeywordCxt} cxt context + */ + code(cxt) { + const { data, schema } = cxt; + const names = Array.isArray(schema) ? schema : [schema]; + + for (const name of names) { + if (!CONSTRUCTORS.includes(name)) { + throw new Error(`invalid "instanceof" keyword value ${name}`); + } + } + + cxt.fail( + names + .map((name) => _`!(${data} instanceof ${new Name(name)})`) + .reduce((a, b) => _`${a} && ${b}`), + ); + }, + }); + + ajv.addKeyword({ + keyword: "absolutePath", + type: "string", + schemaType: "boolean", + /** + * @param {KeywordCxt} cxt context + */ + code(cxt) { + const { data, schema } = cxt; + + // Keep in sync with `keywords/absolutePath.js` + cxt.fail( + _`${data}.includes("!") || /^(?:file:(?=\\/))?(?:[A-Za-z]:(\\\\|\\/)|\\\\\\\\|\\/)/i.test(${data}) !== ${schema}`, + ); + }, + }); + + ajv.addKeyword({ + keyword: "undefinedAsNull", + before: "enum", + modifying: true, + /** + * @param {KeywordCxt} cxt context + */ + code(cxt) { + const { gen, data, schema, parentSchema, it } = cxt; + + // Keep in sync with `keywords/undefinedAsNull.js` - the value is set to `null` in its parent + // the way the full validation sets it, so a passing check leaves the same value behind + if ( + !schema || + typeof parentSchema.enum === "undefined" || + it.parentData === undefined || + it.parentDataProperty === undefined + ) { + return; + } + + // The value itself has no parent to set when the check is called without one + const condition = + it.dataLevel === 0 + ? _`${data} === undefined && ${it.parentData} !== undefined` + : _`${data} === undefined`; + + gen.if(condition, () => { + gen.assign( + _`${/** @type {import("ajv").Name} */ (it.parentData)}[${it.parentDataProperty}]`, + null, + ); + gen.assign(data, null); + }); + }, + }); +} + +/** + * @param {string} code generated code + * @returns {string} code loading the runtime helpers from `schema-utils` + */ +function useRuntime(code) { + return code.replace( + /require\("ajv\/dist\/runtime\/(\w+)"\)\.default/g, + (match, name) => { + if (!["equal", "ucs2length", "uri"].includes(name)) { + throw new Error(`Unsupported "ajv" runtime helper "${name}"`); + } + + return `require(${JSON.stringify(RUNTIME)}).${name}`; + }, + ); +} + +// Keywords whose value is a schema, an array of schemas or an object of schemas +const SCHEMA_KEYWORDS = [ + "additionalItems", + "additionalProperties", + "contains", + "else", + "if", + "items", + "not", + "propertyNames", + "then", +]; +const SCHEMA_ARRAY_KEYWORDS = ["allOf", "anyOf", "items", "oneOf"]; +const SCHEMA_MAP_KEYWORDS = [ + "$defs", + "definitions", + "dependencies", + "patternProperties", + "properties", +]; +// Keywords that are not validation keywords but have to stay +const STRUCTURE_KEYWORDS = ["$id", "$schema", "$defs", "definitions"]; + +/** + * Removes every keyword that does not validate, i.e. `title`, `description` and `link`. + * + * WHY: the generated code embeds the parts of the schema its error objects point to, so the + * annotations would only make the module bigger and slower to load. + * @param {EXPECTED_ANY} schema schema + * @param {Set} keywords validation keywords + * @returns {EXPECTED_ANY} schema without the annotations + */ +function stripAnnotations(schema, keywords) { + if (!schema || typeof schema !== "object" || Array.isArray(schema)) { + return schema; + } + + /** @type {Record} */ + const result = {}; + + for (const key of Object.keys(schema)) { + if (!keywords.has(key)) continue; + + const value = schema[key]; + + if (SCHEMA_ARRAY_KEYWORDS.includes(key) && Array.isArray(value)) { + result[key] = value.map((item) => stripAnnotations(item, keywords)); + } else if (SCHEMA_KEYWORDS.includes(key)) { + result[key] = stripAnnotations(value, keywords); + } else if ( + SCHEMA_MAP_KEYWORDS.includes(key) && + value && + typeof value === "object" + ) { + /** @type {Record} */ + const map = {}; + + for (const name of Object.keys(value)) { + // A `dependencies` entry is either a schema or an array of property names + map[name] = Array.isArray(value[name]) + ? value[name] + : stripAnnotations(value[name], keywords); + } + + result[key] = map; + } else { + result[key] = value; + } + } + + return result; +} + +/** + * Loads the schema an external `$ref` points to, when it points to a file. + * @param {string} uri absolute URI of the schema + * @returns {Promise} schema + */ +async function loadFileSchema(uri) { + if (!uri.startsWith("file:")) { + throw new Error( + `Cannot load the schema "${uri}", pass a \`loadSchema\` option to load it`, + ); + } + + const { readFileSync } = require("fs"); + const { fileURLToPath } = require("url"); + + return JSON.parse(readFileSync(fileURLToPath(uri), "utf8")); +} + +/** + * @typedef {object} PrecompileOptions + * @property {((uri: string) => Promise)=} loadSchema loads a schema an external `$ref` points to + */ + +/** + * Compiles a schema into the source of a CommonJS module that exports a check for it. + * @param {Schema} schema schema + * @param {PrecompileOptions=} options options + * @returns {Promise} source of the module + */ +async function precompile(schema, options = {}) { + const Ajv = require("ajv").default; + const standaloneCode = require("ajv/dist/standalone").default; + const ajvKeywords = require("ajv-keywords").default; + const addFormats = require("ajv-formats").default; + const { _ } = require("ajv"); + + // The same options as the full validation in `validate.js`, without the ones that only shape + // the errors - a check only says whether the value is valid + const ajv = new Ajv({ + strict: false, + $data: true, + messages: false, + code: { + source: true, + optimize: true, + formats: _`require(${RUNTIME}).formats`, + }, + }); + + ajvKeywords(ajv, ["patternRequired"]); + addFormats(ajv, { keywords: false }); + + const addLimitKeyword = require("./keywords/limit").default; + + addLimitKeyword(ajv); + addKeywords(ajv); + + const keywords = new Set([ + ...Object.keys(ajv.RULES.all), + ...STRUCTURE_KEYWORDS, + ]); + const loadSchema = options.loadSchema || loadFileSchema; + /** @type {string[]} */ + const loaded = []; + + ajv.opts.loadSchema = async (uri) => { + loaded.push(uri); + + return stripAnnotations(await loadSchema(uri), keywords); + }; + + const validate = await ajv.compileAsync(stripAnnotations(schema, keywords)); + + let code = useRuntime(standaloneCode(ajv, validate)); + + // The `$id` of a schema read from a file is its absolute URL, which ends up in the code, so the + // URLs of the schemas are made relative to the schema for the generated file to be the same on + // every machine - only those, a value in the schema can be a `file:` URL too + const { $id } = /** @type {AnySchemaObject} */ (schema); + + if (typeof $id === "string" && $id.startsWith("file:")) { + const { fileURLToPath } = require("url"); + const path = require("path"); + + const dir = path.dirname(fileURLToPath($id)); + const urls = [$id, ...loaded] + .filter((url) => url.startsWith("file:")) + // A longer URL first, it can start with a shorter one + .sort((a, b) => b.length - a.length); + + for (const url of urls) { + code = code + .split(url) + .join(path.relative(dir, fileURLToPath(url)).replace(/\\/g, "/")); + } + } + + return `/* eslint-disable */ +// @ts-nocheck +/* + * This file was automatically generated by "schema-utils". + * DO NOT MODIFY BY HAND. + */ +${code}`; +} + +export default precompile; diff --git a/src/runtime.js b/src/runtime.js new file mode 100644 index 0000000..ea9880b --- /dev/null +++ b/src/runtime.js @@ -0,0 +1,40 @@ +// The helpers a precompiled check (see `precompile.js`) loads at runtime. +// +// WHY: the generated module requires `schema-utils/dist/runtime` instead of `ajv/dist/runtime/*` +// because the packages shipping a check depend on `schema-utils`, not on `ajv`, which is not +// guaranteed to be resolvable from their files. Every helper is a getter, so a check only loads +// the ones its schema needs. + +/* eslint-disable jsdoc/reject-any-type */ + +/** + * @typedef {object} Runtime + * @property {(a: any, b: any) => boolean} equal deep equality for `const`, `enum` and `uniqueItems` + * @property {(str: string) => number} ucs2length string length in code points for `minLength` and `maxLength` + * @property {any} uri URI parser for `$ref` to an `$id` + * @property {Record} formats formats of `ajv-formats` for `format` + */ + +/** @type {Runtime} */ +const runtime = /** @type {any} */ ({}); + +Object.defineProperties(runtime, { + equal: { + enumerable: true, + get: () => require("ajv/dist/runtime/equal").default, + }, + ucs2length: { + enumerable: true, + get: () => require("ajv/dist/runtime/ucs2length").default, + }, + uri: { + enumerable: true, + get: () => require("ajv/dist/runtime/uri").default, + }, + formats: { + enumerable: true, + get: () => require("ajv-formats/dist/formats").fullFormats, + }, +}); + +module.exports = runtime; diff --git a/src/validate.js b/src/validate.js index 8b6e563..a18ccf7 100644 --- a/src/validate.js +++ b/src/validate.js @@ -47,6 +47,9 @@ const getAjv = memoize(() => { /** @typedef {import("json-schema").JSONSchema7} JSONSchema7 */ /** @typedef {import("ajv").ErrorObject} ErrorObject */ +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + /** * @typedef {object} ExtendedSchema * @property {(string | number)=} formatMinimum format minimum @@ -447,17 +450,35 @@ function validateObject(schema, options) { return compiledSchema.errors ? filterErrors(compiledSchema.errors) : []; } +/** + * A check precompiled from a schema with `precompile`, it only says whether the value is valid. + * @callback PrecompiledCheck + * @param {EXPECTED_ANY} value value + * @returns {boolean} true when valid, otherwise false + */ + /** * @param {Schema} schema schema * @param {object[] | object} options options * @param {ValidationErrorConfiguration=} configuration configuration + * @param {PrecompiledCheck=} check precompiled check of the schema, so a valid value never compiles the schema * @returns {void} */ -function validate(schema, options, configuration) { +function validate(schema, options, configuration, check) { if (!needValidate()) { return; } + // Only an invalid value needs `ajv`, to tell what is wrong with it + if ( + check && + (Array.isArray(options) + ? options.every((item) => check(item)) + : check(options)) + ) { + return; + } + let errors = []; if (Array.isArray(options)) { diff --git a/test/helpers/precompiled.js b/test/helpers/precompiled.js new file mode 100644 index 0000000..46c58e4 --- /dev/null +++ b/test/helpers/precompiled.js @@ -0,0 +1,78 @@ +import precompile from "../../src/precompile"; + +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + +/** @type {WeakMap boolean>>} */ +const cache = new WeakMap(); + +/** + * Loads the generated module the way a package shipping it would, with its runtime helpers from + * the sources instead of the published `schema-utils`. + * @param {string} code source of the module + * @returns {(value: EXPECTED_ANY) => boolean} check + */ +function load(code) { + const module = { exports: {} }; + /** + * @param {string} request request + * @returns {EXPECTED_ANY} module + */ + const fakeRequire = (request) => { + if (request === "schema-utils/dist/runtime") { + return require("../../src/runtime"); + } + + throw new Error(`The precompiled check requires "${request}"`); + }; + + // eslint-disable-next-line no-new-func + new Function("module", "exports", "require", code)( + module, + module.exports, + fakeRequire, + ); + + return /** @type {(value: EXPECTED_ANY) => boolean} */ (module.exports); +} + +/** + * @param {EXPECTED_ANY} schema schema + * @returns {Promise<(value: EXPECTED_ANY) => boolean>} precompiled check of the schema + */ +export function getCheck(schema) { + let check = cache.get(schema); + + if (!check) { + check = precompile(schema).then(load); + cache.set(schema, check); + } + + return check; +} + +/** + * Copies plain objects and arrays, keeping every other value as is, so a check and the full + * validation each get their own value to modify. + * @template T + * @param {T} value value + * @returns {T} copy + */ +export function copy(value) { + if (Array.isArray(value)) { + return /** @type {T} */ (value.map(copy)); + } + + if (value && Object.getPrototypeOf(value) === Object.prototype) { + /** @type {Record} */ + const result = {}; + + for (const key of Object.keys(value)) { + result[key] = copy(/** @type {EXPECTED_ANY} */ (value)[key]); + } + + return /** @type {T} */ (result); + } + + return value; +} diff --git a/test/index.test.js b/test/index.test.js index 60d83ae..6798734 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -3,6 +3,7 @@ import webpackSchema from "webpack/schemas/WebpackOptions.json"; import { validate } from "../src"; import schema from "./fixtures/schema.json"; +import { copy, getCheck } from "./helpers/precompiled"; // eslint-disable-next-line jsdoc/reject-any-type /** @typedef {any} EXPECTED_ANY */ @@ -37,6 +38,19 @@ describe("validation", () => { expect(error).toBeUndefined(); }); + + it(`should pass the precompiled check for ${name}`, async () => { + const check = await getCheck(testSchema); + const checked = copy(config); + const validated = copy(config); + + expect(check(checked)).toBe(true); + + validate(testSchema, validated, options.name); + + // `undefinedAsNull` sets the value the same way in both + expect(checked).toEqual(validated); + }); } /** @@ -67,6 +81,12 @@ describe("validation", () => { throw new Error("Validation didn't fail"); }); + + it(`should fail the precompiled check for ${name}`, async () => { + const check = await getCheck(schema); + + expect(check(copy(config))).toBe(false); + }); } createSuccessTestCase("empty array", { @@ -3300,6 +3320,10 @@ describe("validation", () => { (msg) => expect(msg).toMatchSnapshot(), ); + createSuccessTestCase("array with enum and undefinedAsNull and undefined", { + arrayStringAndEnum: ["foo", undefined], + }); + createFailedTestCase( "array with enum and undefinedAsNull #2", { diff --git a/test/precompile.test.js b/test/precompile.test.js new file mode 100644 index 0000000..5a4c09a --- /dev/null +++ b/test/precompile.test.js @@ -0,0 +1,381 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { pathToFileURL } from "url"; + +import { precompile, validate } from "../src"; +import cli from "../src/cli"; + +import { getCheck } from "./helpers/precompiled"; + +const runtime = require("../src/runtime"); + +/** + * `fs.rmSync` needs Node.js 14.14.0 + * @param {string} target file or directory to remove + */ +function remove(target) { + if (fs.lstatSync(target).isDirectory()) { + for (const name of fs.readdirSync(target)) { + remove(path.join(target, name)); + } + + fs.rmdirSync(target); + } else { + fs.unlinkSync(target); + } +} + +describe("precompile", () => { + /** @type {string} */ + let dir; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "schema-utils-")); + }); + + afterEach(() => { + remove(dir); + }); + + /** + * @param {string} name file name + * @param {object} content JSON content + * @returns {string} path of the file + */ + function writeJson(name, content) { + const file = path.join(dir, name); + + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(content)); + + return file; + } + + /** + * @param {string[]} args arguments + * @returns {Promise<{ exitCode: number, log: string[], error: string[] }>} result + */ + async function runCli(args) { + /** @type {string[]} */ + const log = []; + /** @type {string[]} */ + const error = []; + const exitCode = await cli(args, { + log: (message) => log.push(message), + error: (message) => error.push(message), + }); + + return { exitCode, log, error }; + } + + describe("validate with a check", () => { + const schema = { + type: "object", + properties: { name: { type: "string" } }, + additionalProperties: false, + }; + + it("should not run the full validation when the check passes", () => { + const check = jest.fn(() => true); + + // The value is invalid, a passing check is trusted + expect(() => + validate(schema, { name: 1 }, { name: "Plugin" }, check), + ).not.toThrow(); + expect(check).toHaveBeenCalledTimes(1); + }); + + it("should run the full validation when the check fails", () => { + expect(() => + validate(schema, { name: 1 }, { name: "Plugin" }, () => false), + ).toThrow(/^Invalid configuration object\. Plugin has been initialized/); + }); + + it("should not throw when only the check fails", () => { + expect(() => + validate(schema, { name: "a" }, { name: "Plugin" }, () => false), + ).not.toThrow(); + }); + + it("should check every item of an array", () => { + const check = jest.fn( + (value) => + typeof (/** @type {{ name: unknown }} */ (value).name) === "string", + ); + + expect(() => + validate(schema, [{ name: "a" }, { name: 1 }], {}, check), + ).toThrow(/configuration\[1\]\.name should be a string/); + expect(check).toHaveBeenCalledTimes(2); + }); + + it("should validate with a precompiled check", async () => { + const check = await getCheck(schema); + + expect(() => validate(schema, { name: "a" }, {}, check)).not.toThrow(); + expect(() => validate(schema, { name: 1 }, {}, check)).toThrow( + /configuration\.name should be a string/, + ); + }); + }); + + it("should generate a module with the banner", async () => { + const code = await precompile({ type: "string" }); + + expect(code).toMatch( + /^\/\* eslint-disable \*\/\n\/\/ @ts-nocheck\n\/\*\n \* This file was automatically generated by "schema-utils"\./, + ); + }); + + it("should not keep annotations in the generated code", async () => { + const code = await precompile({ + title: "Plugin options", + description: "The options", + type: "object", + properties: { + // A property named like an annotation is kept + description: { + description: "The description of the thing", + link: "https://example.com/description", + enum: ["a", "b"], + }, + }, + definitions: { + Unused: { description: "Never referenced", type: "string" }, + }, + }); + + expect(code).not.toMatch(/Plugin options|The options|example\.com/); + expect(code).toMatch(/"description"/); + }); + + it("should load the runtime helpers from schema-utils", async () => { + const code = await precompile({ + type: "object", + properties: { + name: { type: "string", minLength: 2 }, + date: { type: "string", format: "date" }, + mode: { enum: [{ a: 1 }] }, + }, + }); + + expect(code).not.toMatch(/require\("ajv/); + expect(code).toMatch( + /require\("schema-utils\/dist\/runtime"\)\.ucs2length/, + ); + expect(code).toMatch(/require\("schema-utils\/dist\/runtime"\)\.formats/); + expect(code).toMatch(/require\("schema-utils\/dist\/runtime"\)\.equal/); + }); + + it("should expose every runtime helper", () => { + expect(runtime.equal({ a: [1] }, { a: [1] })).toBe(true); + expect(runtime.ucs2length("😀")).toBe(1); + expect(typeof runtime.uri.parse).toBe("function"); + expect(typeof runtime.formats.date).toBe("object"); + }); + + it("should check instanceof", async () => { + const check = await getCheck({ + type: "object", + properties: { + fn: { instanceof: "Function" }, + test: { instanceof: ["RegExp", "Function"] }, + }, + }); + + expect(check({ fn: () => {}, test: /a/ })).toBe(true); + expect(check({ fn: () => {}, test: () => {} })).toBe(true); + expect(check({ fn: "a" })).toBe(false); + expect(check({ test: "a" })).toBe(false); + }); + + it("should reject an unknown instanceof constructor", async () => { + await expect(precompile({ instanceof: "Map" })).rejects.toThrow( + 'invalid "instanceof" keyword value Map', + ); + }); + + it("should check absolutePath", async () => { + const check = await getCheck({ + type: "object", + properties: { + absolute: { type: "string", absolutePath: true }, + relative: { type: "string", absolutePath: false }, + }, + }); + + expect(check({ absolute: "/a", relative: "a" })).toBe(true); + expect(check({ absolute: "C:\\a" })).toBe(true); + expect(check({ absolute: "file:///a" })).toBe(true); + expect(check({ absolute: "a" })).toBe(false); + expect(check({ absolute: "/a!b" })).toBe(false); + expect(check({ relative: "/a" })).toBe(false); + }); + + it("should resolve an external $ref to a file", async () => { + writeJson("definitions.json", { + definitions: { + Name: { description: "A name", type: "string" }, + }, + }); + + const file = writeJson("options.json", { + type: "object", + properties: { name: { $ref: "./definitions.json#/definitions/Name" } }, + }); + const schema = JSON.parse(fs.readFileSync(file, "utf8")); + + schema.$id = pathToFileURL(file).href; + + const code = await precompile(schema); + + // Every path is relative to the schema, so the code is the same on every machine + expect(code).not.toMatch(/file:/); + expect(code).not.toContain(dir); + expect(code).not.toMatch(/A name/); + }); + + it("should keep a file URL that is a value", async () => { + const file = writeJson("options.json", {}); + const url = pathToFileURL(path.join(dir, "assets/logo.png")).href; + const check = await getCheck({ + $id: pathToFileURL(file).href, + enum: [url], + }); + + expect(check(url)).toBe(true); + expect(check("assets/logo.png")).toBe(false); + }); + + it("should not set undefinedAsNull on a value without a parent", async () => { + const check = await getCheck({ undefinedAsNull: true, enum: [null] }); + + expect(check(undefined)).toBe(false); + expect(check(null)).toBe(true); + expect( + check(undefined, { parentData: [undefined], parentDataProperty: 0 }), + ).toBe(true); + }); + + it("should load an external $ref with loadSchema", async () => { + const loadSchema = jest.fn(async () => ({ type: "string" })); + + await precompile( + { + $id: "https://example.com/options.json", + $ref: "https://example.com/name.json", + }, + { loadSchema }, + ); + + expect(loadSchema).toHaveBeenCalledWith("https://example.com/name.json"); + }); + + it("should not load an external $ref that is not a file", async () => { + await expect( + precompile({ + $id: "https://example.com/options.json", + $ref: "https://example.com/name.json", + }), + ).rejects.toThrow( + 'Cannot load the schema "https://example.com/name.json", pass a `loadSchema` option to load it', + ); + }); + + describe("cli", () => { + it("should print the usage", async () => { + const result = await runCli(["--help"]); + + expect(result.exitCode).toBe(0); + expect(result.log[0]).toMatch(/^Usage: schema-utils precompile/); + }); + + it("should fail without a command", async () => { + const result = await runCli([]); + + expect(result.exitCode).toBe(1); + expect(result.error[0]).toMatch(/^Usage: schema-utils precompile/); + }); + + it("should fail with an unknown command", async () => { + const result = await runCli(["compile"]); + + expect(result.exitCode).toBe(1); + expect(result.error[0]).toMatch(/^Unknown command "compile"/); + }); + + it("should fail with an unknown option", async () => { + const result = await runCli(["precompile", "--write", "options.json"]); + + expect(result.exitCode).toBe(1); + expect(result.error[0]).toMatch(/^Unknown option "--write"/); + }); + + it("should fail without a schema", async () => { + const result = await runCli(["precompile", "--check"]); + + expect(result.exitCode).toBe(1); + expect(result.error[0]).toMatch(/^No schema given/); + }); + + it("should write the check next to the schema and keep it up to date", async () => { + writeJson("definitions.json", { + definitions: { Name: { type: "string" } }, + }); + + const file = writeJson("src/options.json", { + type: "object", + properties: { + name: { $ref: "../definitions.json#/definitions/Name" }, + }, + }); + const checkFile = path.join(dir, "src/options.check.js"); + + let result = await runCli(["precompile", "--check", file]); + + expect(result.exitCode).toBe(1); + expect(result.error[0]).toMatch(/options\.check\.js" is missing/); + expect(fs.existsSync(checkFile)).toBe(false); + + result = await runCli(["precompile", file]); + + expect(result.exitCode).toBe(0); + expect(result.log[0]).toMatch(/options\.check\.js" updated/); + + const code = fs.readFileSync(checkFile, "utf8"); + + expect(code).not.toContain(dir); + + result = await runCli(["precompile", "--check", file]); + + expect(result).toEqual({ exitCode: 0, log: [], error: [] }); + + // Nothing to write when it is up to date + result = await runCli(["precompile", file]); + + expect(result).toEqual({ exitCode: 0, log: [], error: [] }); + + writeJson("src/options.json", { type: "string" }); + + result = await runCli(["precompile", "--check", file]); + + expect(result.exitCode).toBe(1); + expect(result.error[0]).toMatch(/options\.check\.js" is out of date/); + expect(fs.readFileSync(checkFile, "utf8")).toBe(code); + }); + + it("should report a schema it cannot precompile and go on", async () => { + const broken = path.join(dir, "broken.json"); + + fs.writeFileSync(broken, "{"); + + const file = writeJson("options.json", { type: "string" }); + const result = await runCli(["precompile", broken, file]); + + expect(result.exitCode).toBe(1); + expect(result.error[0]).toMatch(/^Cannot precompile ".+broken\.json": /); + expect(fs.existsSync(path.join(dir, "options.check.js"))).toBe(true); + }); + }); +}); From 5ddcefb5f38c6fa06f97a03b5de216071c7cfb68 Mon Sep 17 00:00:00 2001 From: sheo13666q Date: Fri, 16 Oct 2026 14:35:37 +0000 Subject: [PATCH 2/3] fix: exit with 1 when `schema-utils precompile` throws Before Node.js 15 an unhandled rejection only warns and the process exits with 0, so a check that could not be written passed in CI. --- bin/schema-utils.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/bin/schema-utils.js b/bin/schema-utils.js index 5e57341..3c0603c 100755 --- a/bin/schema-utils.js +++ b/bin/schema-utils.js @@ -4,6 +4,13 @@ require("../dist/cli") .default(process.argv.slice(2), { log: console.log, error: console.error }) - .then((exitCode) => { - process.exitCode = exitCode; - }); + .then( + (exitCode) => { + process.exitCode = exitCode; + }, + (error) => { + // Before Node.js 15 an unhandled rejection exits with 0 + console.error(error); + process.exitCode = 1; + }, + ); From 5cfcea27c27f0b97691098232688852c892f1728 Mon Sep 17 00:00:00 2001 From: sheo13666q Date: Sat, 17 Oct 2026 11:31:23 +0000 Subject: [PATCH 3/3] chore(deps): bump fast-uri to 3.1.8 Fixes GHSA-hrr3-gc8f-f4qj, which fails `npm audit` in the lint job. --- package-lock.json | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index c858b07..adf69ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "schema-utils", - "version": "4.3.3", + "version": "4.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "schema-utils", - "version": "4.3.3", + "version": "4.5.0", "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.15", @@ -14,6 +14,9 @@ "ajv-formats": "^3.0.1", "ajv-keywords": "^5.1.0" }, + "bin": { + "schema-utils": "bin/schema-utils.js" + }, "devDependencies": { "@babel/cli": "^8.0.4", "@babel/core": "^8.0.1", @@ -7728,7 +7731,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.7", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github",