From 504b45279a0fc9f5d917240caedb64124dd45b90 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:45:09 +0000 Subject: [PATCH 01/16] Add Heft precompilation task and configuration loader option Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --- apps/heft/heft-plugin.json | 5 + .../plugins/PrecompileJsonSchemasPlugin.ts | 49 ++++++ ...recompile-json-schemas-options.schema.json | 19 +++ .../src/ConfigurationFileBase.ts | 34 +++- libraries/heft-config-file/src/index.ts | 1 + libraries/node-core-library/src/JsonSchema.ts | 148 +++++++++++------- .../src/test/JsonSchema.test.ts | 69 ++++++++ .../test-schema-standalone.schema.json | 9 ++ 8 files changed, 274 insertions(+), 60 deletions(-) create mode 100644 apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts create mode 100644 apps/heft/src/schemas/precompile-json-schemas-options.schema.json create mode 100644 libraries/node-core-library/src/test/test-data/test-schemas/test-schema-standalone.schema.json diff --git a/apps/heft/heft-plugin.json b/apps/heft/heft-plugin.json index 417896dc263..f2a7b5817a3 100644 --- a/apps/heft/heft-plugin.json +++ b/apps/heft/heft-plugin.json @@ -26,6 +26,11 @@ } ] }, + { + "pluginName": "precompile-json-schemas-plugin", + "entryPoint": "./lib-commonjs/plugins/PrecompileJsonSchemasPlugin", + "optionsSchema": "./lib-commonjs/schemas/precompile-json-schemas-options.schema.json" + }, { "pluginName": "run-script-plugin", "entryPoint": "./lib-commonjs/plugins/RunScriptPlugin", diff --git a/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts b/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts new file mode 100644 index 00000000000..df8780bb520 --- /dev/null +++ b/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { FileSystem, JsonSchema } from '@rushstack/node-core-library'; + +import type { HeftConfiguration } from '../configuration/HeftConfiguration'; +import type { IHeftTaskPlugin } from '../pluginFramework/IHeftPlugin'; +import type { IHeftTaskSession, IHeftTaskRunHookOptions } from '../pluginFramework/HeftTaskSession'; + +interface IPrecompileJsonSchemasPluginOptions { + sourceFolder: string; + destinationFolders: string[]; +} + +const PLUGIN_NAME: 'precompile-json-schemas-plugin' = 'precompile-json-schemas-plugin'; + +export default class PrecompileJsonSchemasPlugin + implements IHeftTaskPlugin +{ + public apply( + taskSession: IHeftTaskSession, + heftConfiguration: HeftConfiguration, + options: IPrecompileJsonSchemasPluginOptions + ): void { + taskSession.hooks.run.tapPromise(PLUGIN_NAME, async (runOptions: IHeftTaskRunHookOptions) => { + const sourceFolder: string = path.resolve(heftConfiguration.buildFolderPath, options.sourceFolder); + const schemaPaths: string[] = await runOptions.globAsync('**/*.schema.json', { + cwd: sourceFolder, + absolute: true + }); + + for (const schemaPath of schemaPaths) { + const validatorCode: string = JsonSchema.compileStandaloneCodeFromFile(schemaPath); + const relativePath: string = path.relative(sourceFolder, schemaPath).replace(/\.schema\.json$/, '.validator.cjs'); + for (const destinationFolder of options.destinationFolders) { + const destinationPath: string = path.resolve( + heftConfiguration.buildFolderPath, + destinationFolder, + relativePath + ); + await FileSystem.writeFileAsync(destinationPath, validatorCode, { ensureFolderExists: true }); + } + } + taskSession.logger.terminal.writeLine(`Precompiled ${schemaPaths.length} JSON schemas.`); + }); + } +} diff --git a/apps/heft/src/schemas/precompile-json-schemas-options.schema.json b/apps/heft/src/schemas/precompile-json-schemas-options.schema.json new file mode 100644 index 00000000000..f63e658a5fd --- /dev/null +++ b/apps/heft/src/schemas/precompile-json-schemas-options.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Precompile JSON Schemas Plugin Options", + "type": "object", + "additionalProperties": false, + "required": ["sourceFolder", "destinationFolders"], + "properties": { + "sourceFolder": { + "type": "string", + "description": "Folder containing JSON schema files, relative to the project root." + }, + "destinationFolders": { + "type": "array", + "minItems": 1, + "items": { "type": "string" }, + "description": "Folders for generated CommonJS validator modules, relative to the project root." + } + } +} diff --git a/libraries/heft-config-file/src/ConfigurationFileBase.ts b/libraries/heft-config-file/src/ConfigurationFileBase.ts index 0382fe2099d..f244ef9ea90 100644 --- a/libraries/heft-config-file/src/ConfigurationFileBase.ts +++ b/libraries/heft-config-file/src/ConfigurationFileBase.ts @@ -5,7 +5,13 @@ import * as nodeJsPath from 'node:path'; import { JSONPath } from 'jsonpath-plus'; -import { JsonSchema, JsonFile, Import, FileSystem } from '@rushstack/node-core-library'; +import { + JsonSchema, + JsonFile, + Import, + FileSystem, + type IJsonSchemaCompiledValidator +} from '@rushstack/node-core-library'; import type { ITerminal } from '@rushstack/terminal'; interface IConfigurationJson { @@ -367,6 +373,7 @@ export type IConfigurationFileOptionsWithJsonSchemaFilePath< */ jsonSchemaPath: string; jsonSchemaObject?: never; + jsonSchemaValidator?: never; }; /** @@ -382,6 +389,23 @@ export type IConfigurationFileOptionsWithJsonSchemaObject< */ jsonSchemaObject: object; jsonSchemaPath?: never; + jsonSchemaValidator?: never; + }; + +/** + * @beta + */ +export type IConfigurationFileOptionsWithJsonSchemaValidator< + TConfigurationFile, + TExtraOptions extends {} +> = IConfigurationFileOptionsBase & + TExtraOptions & { + /** + * A precompiled JSON schema validator generated during the build. + */ + jsonSchemaValidator: IJsonSchemaCompiledValidator; + jsonSchemaPath?: never; + jsonSchemaObject?: never; }; /** @@ -389,7 +413,8 @@ export type IConfigurationFileOptionsWithJsonSchemaObject< */ export type IConfigurationFileOptions = | IConfigurationFileOptionsWithJsonSchemaFilePath - | IConfigurationFileOptionsWithJsonSchemaObject; + | IConfigurationFileOptionsWithJsonSchemaObject + | IConfigurationFileOptionsWithJsonSchemaValidator; interface IJsonPathCallbackObject { path: string; @@ -450,12 +475,15 @@ export abstract class ConfigurationFileBase JsonSchema.fromCompiledValidator(jsonSchemaValidator); + } else if (jsonSchemaObject) { this.#getSchema = () => JsonSchema.fromLoadedObject(jsonSchemaObject); } else { this.#getSchema = () => JsonSchema.fromFile(jsonSchemaPath); diff --git a/libraries/heft-config-file/src/index.ts b/libraries/heft-config-file/src/index.ts index bd912a1ea15..6b0bd2cc352 100644 --- a/libraries/heft-config-file/src/index.ts +++ b/libraries/heft-config-file/src/index.ts @@ -14,6 +14,7 @@ export { type IConfigurationFileOptionsBase, type IConfigurationFileOptionsWithJsonSchemaFilePath, type IConfigurationFileOptionsWithJsonSchemaObject, + type IConfigurationFileOptionsWithJsonSchemaValidator, type IConfigurationFileOptions, type ICustomJsonPathMetadata, type ICustomPropertyInheritance, diff --git a/libraries/node-core-library/src/JsonSchema.ts b/libraries/node-core-library/src/JsonSchema.ts index 8864740ef41..e91aadc3cdb 100644 --- a/libraries/node-core-library/src/JsonSchema.ts +++ b/libraries/node-core-library/src/JsonSchema.ts @@ -5,6 +5,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import Ajv, { type Options as AjvOptions, type ErrorObject, type ValidateFunction } from 'ajv'; +import standaloneCode from 'ajv/dist/standalone'; import AjvDraft04 from 'ajv-draft-04'; import addFormats from 'ajv-formats'; @@ -203,6 +204,7 @@ function _inferJsonSchemaVersion({ $schema }: JsonObject): JsonSchemaVersion | u export class JsonSchema { private _dependentSchemas: JsonSchema[] = []; private _filename: string = ''; + private _shortName: string | undefined = undefined; private _validator: ValidateFunction | undefined = undefined; private _schemaObject: JsonObject | undefined = undefined; private _schemaVersion: JsonSchemaVersion | undefined = undefined; @@ -259,6 +261,31 @@ export class JsonSchema { return schema; } + /** + * Wraps an already compiled AJV-compatible validator without loading or compiling a schema at runtime. + * The validator must expose AJV's `errors` property after a failed validation. + */ + public static fromCompiledValidator(validator: ValidateFunction, shortName?: string): JsonSchema { + const schema: JsonSchema = new JsonSchema(); + schema._validator = validator; + schema._shortName = shortName; + return schema; + } + + /** + * Compiles a schema file into CommonJS standalone AJV code. + * @remarks + * The generated code requires the `ajv` runtime package in the consuming project. + */ + public static compileStandaloneCodeFromFile( + filename: string, + options?: IJsonSchemaFromFileOptions + ): string { + const schema: JsonSchema = JsonSchema.fromFile(filename, options); + const { ajv, validator } = schema._compileValidator({ code: { source: true } }); + return standaloneCode(ajv, validator); + } + /** * Returns a short name for this schema, for use in error messages. * @remarks @@ -266,6 +293,9 @@ export class JsonSchema { * field is used if available. */ public get shortName(): string { + if (this._shortName !== undefined) { + return this._shortName; + } if (!this._filename) { if (this._schemaObject) { const schemaWithId: ISchemaWithId = this._schemaObject as ISchemaWithId; @@ -287,76 +317,80 @@ export class JsonSchema { * Any dependencies will be compiled as well. */ public ensureCompiled(): void { - this._ensureLoaded(); - if (!this._validator) { - const targetSchemaVersion: JsonSchemaVersion | undefined = - this._schemaVersion ?? _inferJsonSchemaVersion(this._schemaObject); - const validatorOptions: AjvOptions = { - strictSchema: true, - allowUnionTypes: true - }; - - let validator: Ajv; - // Keep legacy support for older draft-04 schema - switch (targetSchemaVersion) { - case 'draft-04': { - validator = new AjvDraft04(validatorOptions); - break; - } + this._validator = this._compileValidator({}).validator; + } + } - case 'draft-07': - default: { - validator = new Ajv(validatorOptions); - break; - } + private _compileValidator(options: AjvOptions): { ajv: Ajv; validator: ValidateFunction } { + this._ensureLoaded(); + const targetSchemaVersion: JsonSchemaVersion | undefined = + this._schemaVersion ?? _inferJsonSchemaVersion(this._schemaObject!); + const validatorOptions: AjvOptions = { + strictSchema: true, + allowUnionTypes: true, + ...options + }; + + let validator: Ajv; + // Keep legacy support for older draft-04 schema + switch (targetSchemaVersion) { + case 'draft-04': { + validator = new AjvDraft04(validatorOptions); + break; } - // Enable json-schema format validation - // https://ajv.js.org/packages/ajv-formats.html - addFormats(validator); - if (this._customFormats) { - for (const [name, format] of Object.entries(this._customFormats)) { - validator.addFormat(name, { ...format, async: false }); - } + case 'draft-07': + default: { + validator = new Ajv(validatorOptions); + break; } + } - const collectedSchemas: JsonSchema[] = []; - const seenObjects: Set = new Set(); - const seenIds: Set = new Set(); + // Enable json-schema format validation + // https://ajv.js.org/packages/ajv-formats.html + addFormats(validator); + if (this._customFormats) { + for (const [name, format] of Object.entries(this._customFormats)) { + validator.addFormat(name, { ...format, async: false }); + } + } - this._collectDependentSchemas(collectedSchemas, this._dependentSchemas, seenObjects, seenIds); + const collectedSchemas: JsonSchema[] = []; + const seenObjects: Set = new Set(); + const seenIds: Set = new Set(); - // Unless explicitly rejected, scan the top-level keys of each schema for vendor - // extension keys matching the x-- pattern and register them with - // AJV so that strict mode does not reject them as unknown keywords. - if (!this._rejectVendorExtensionKeywords) { - const vendorKeywords: Set = new Set(); - _collectVendorExtensionKeywords(this._schemaObject, vendorKeywords); - for (const collectedSchema of collectedSchemas) { - _collectVendorExtensionKeywords(collectedSchema._schemaObject, vendorKeywords); - } - for (const keyword of vendorKeywords) { - validator.addKeyword(keyword); - } - } + this._collectDependentSchemas(collectedSchemas, this._dependentSchemas, seenObjects, seenIds); - // Validate each schema in order. We specifically do not supply them all together, because we want - // to make sure that circular references will fail to validate. + // Unless explicitly rejected, scan the top-level keys of each schema for vendor + // extension keys matching the x-- pattern and register them with + // AJV so that strict mode does not reject them as unknown keywords. + if (!this._rejectVendorExtensionKeywords) { + const vendorKeywords: Set = new Set(); + _collectVendorExtensionKeywords(this._schemaObject, vendorKeywords); for (const collectedSchema of collectedSchemas) { - validator.validateSchema(collectedSchema._schemaObject) as boolean; - if (validator.errors && validator.errors.length > 0) { - throw new Error( - `Failed to validate schema "${collectedSchema.shortName}":` + - os.EOL + - _formatErrorDetails(validator.errors) - ); - } - validator.addSchema(collectedSchema._schemaObject); + _collectVendorExtensionKeywords(collectedSchema._schemaObject, vendorKeywords); } + for (const keyword of vendorKeywords) { + validator.addKeyword(keyword); + } + } - this._validator = validator.compile(this._schemaObject); + // Validate each schema in order. We specifically do not supply them all together, because we want + // to make sure that circular references will fail to validate. + for (const collectedSchema of collectedSchemas) { + validator.validateSchema(collectedSchema._schemaObject) as boolean; + if (validator.errors && validator.errors.length > 0) { + throw new Error( + `Failed to validate schema "${collectedSchema.shortName}":` + + os.EOL + + _formatErrorDetails(validator.errors) + ); + } + validator.addSchema(collectedSchema._schemaObject); } + + return { ajv: validator, validator: validator.compile(this._schemaObject) }; } /** diff --git a/libraries/node-core-library/src/test/JsonSchema.test.ts b/libraries/node-core-library/src/test/JsonSchema.test.ts index 44a482d9398..a3d8cbb9584 100644 --- a/libraries/node-core-library/src/test/JsonSchema.test.ts +++ b/libraries/node-core-library/src/test/JsonSchema.test.ts @@ -1,6 +1,10 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import { runInNewContext } from 'node:vm'; + +import Ajv, { type ValidateFunction } from 'ajv'; + import { JsonFile, type JsonObject } from '../JsonFile'; import { JsonSchema, type IJsonSchemaErrorInfo } from '../JsonSchema'; @@ -105,6 +109,71 @@ describe(JsonSchema.name, () => { errorDetails.push(errorInfo.details); }); + test('wraps a compiled validator without loading a schema and preserves error formatting', () => { + const validator: ValidateFunction = new Ajv().compile({ + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + }); + const compiledSchema: JsonSchema = JsonSchema.fromCompiledValidator(validator, 'compiled schema'); + const loadSpy = jest.spyOn(JsonFile, 'load'); + try { + expect(compiledSchema.shortName).toBe('compiled schema'); + expect(() => compiledSchema.ensureCompiled()).not.toThrow(); + expect(() => compiledSchema.validateObject({ name: 'valid' }, 'input.json')).not.toThrow(); + expect(() => compiledSchema.validateObject({}, 'input.json')).toThrow( + /JSON validation failed:\s+input\.json\s+Error: #\s+must have required property 'name'/ + ); + expect(loadSpy).not.toHaveBeenCalled(); + } finally { + loadSpy.mockRestore(); + } + expect(JsonSchema.fromCompiledValidator(validator).shortName).toBe('(anonymous schema)'); + }); + + describe(JsonSchema.compileStandaloneCodeFromFile.name, () => { + function loadStandaloneSchema(filename: string, options?: Parameters[1]): JsonSchema { + const code: string = JsonSchema.compileStandaloneCodeFromFile(filename, options); + const generatedModule: { exports?: ValidateFunction } = {}; + runInNewContext(code, { module: generatedModule, require }); + expect(typeof generatedModule.exports).toBe('function'); + return JsonSchema.fromCompiledValidator(generatedModule.exports!); + } + + test.each([DRAFT_04_SCHEMA_PATH, DRAFT_07_SCHEMA_PATH])( + 'validates formats and inferred draft version for %s', + (filename) => { + const standaloneSchema: JsonSchema = loadStandaloneSchema(filename); + expect(() => + standaloneSchema.validateObject( + { exampleString: 'hello', exampleArray: [], exampleLink: 'https://example.com' }, + 'input.json' + ) + ).not.toThrow(); + expect(() => + standaloneSchema.validateObject( + { exampleString: 'hello', exampleArray: [], exampleLink: 'not a URI' }, + 'input.json' + ) + ).toThrow(/must match format "uri"/); + } + ); + + test('resolves dependent $ref schemas and accepts vendor keywords', () => { + const child: JsonSchema = JsonSchema.fromFile( + `${__dirname}/test-data/test-schemas/test-schema-nested-child.schema.json` + ); + const standaloneSchema: JsonSchema = loadStandaloneSchema( + `${__dirname}/test-data/test-schemas/test-schema-standalone.schema.json`, + { dependentSchemas: [child] } + ); + expect(() => standaloneSchema.validateObject({ item: { field1: 'valid' } }, 'input.json')).not.toThrow(); + expect(() => standaloneSchema.validateObject({ item: {} }, 'input.json')).toThrow( + /must have required property 'field1'/ + ); + }); + }); + expect(errorDetails).toMatchSnapshot(); }); test('successfully reports a compound validation error for format errors', () => { diff --git a/libraries/node-core-library/src/test/test-data/test-schemas/test-schema-standalone.schema.json b/libraries/node-core-library/src/test/test-data/test-schemas/test-schema-standalone.schema.json new file mode 100644 index 00000000000..6c3916d7baa --- /dev/null +++ b/libraries/node-core-library/src/test/test-data/test-schemas/test-schema-standalone.schema.json @@ -0,0 +1,9 @@ +{ + "$id": "http://example.com/schemas/test-schema-standalone.schema.json", + "x-myvendor-description": "Standalone schema", + "type": "object", + "properties": { + "item": { "$ref": "test-schema-nested-child.schema.json#/definitions/type1" } + }, + "required": ["item"] +} From 301bf40f53fd1e8c2c8faf64c05106bfa86afaea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:51:31 +0000 Subject: [PATCH 02/16] Complete isolated standalone AJV support and tests Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --- .../src/configuration/HeftPluginDefinition.ts | 17 +- .../plugins/PrecompileJsonSchemasPlugin.ts | 4 +- libraries/heft-config-file/README.md | 16 +- .../src/test/PrecompiledValidator.test.ts | 47 +++++ libraries/node-core-library/src/JsonSchema.ts | 34 +++- libraries/node-core-library/src/index.ts | 1 + .../src/test/JsonSchema.test.ts | 182 +++++++++++------- .../test-schema-standalone.schema.json | 11 +- 8 files changed, 236 insertions(+), 76 deletions(-) create mode 100644 libraries/heft-config-file/src/test/PrecompiledValidator.test.ts diff --git a/apps/heft/src/configuration/HeftPluginDefinition.ts b/apps/heft/src/configuration/HeftPluginDefinition.ts index 822ef3453e3..87216a305f1 100644 --- a/apps/heft/src/configuration/HeftPluginDefinition.ts +++ b/apps/heft/src/configuration/HeftPluginDefinition.ts @@ -1,9 +1,15 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import { createRequire } from 'node:module'; import * as path from 'node:path'; -import { InternalError, JsonSchema } from '@rushstack/node-core-library'; +import { + FileSystem, + InternalError, + JsonSchema, + type IJsonSchemaCompiledValidator +} from '@rushstack/node-core-library'; import type { IHeftPlugin } from '../pluginFramework/IHeftPlugin'; import type { IScopedLogger } from '../pluginFramework/logging/ScopedLogger'; @@ -222,7 +228,14 @@ export abstract class HeftPluginDefinitionBase { options.packageRoot, options.heftPluginDefinitionJson.optionsSchema ); - this.#optionsSchema = JsonSchema.fromFile(resolvedSchemaPath); + const compiledValidatorPath: string = resolvedSchemaPath.replace(/\.schema\.json$/, '.validator.cjs'); + this.#optionsSchema = + compiledValidatorPath !== resolvedSchemaPath && FileSystem.exists(compiledValidatorPath) + ? JsonSchema.fromCompiledValidator( + createRequire(resolvedSchemaPath)(compiledValidatorPath) as IJsonSchemaCompiledValidator, + path.basename(resolvedSchemaPath) + ) + : JsonSchema.fromFile(resolvedSchemaPath); } } diff --git a/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts b/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts index df8780bb520..0d6ac5d454b 100644 --- a/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts +++ b/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts @@ -33,7 +33,9 @@ export default class PrecompileJsonSchemasPlugin for (const schemaPath of schemaPaths) { const validatorCode: string = JsonSchema.compileStandaloneCodeFromFile(schemaPath); - const relativePath: string = path.relative(sourceFolder, schemaPath).replace(/\.schema\.json$/, '.validator.cjs'); + const relativePath: string = path + .relative(sourceFolder, schemaPath) + .replace(/\.schema\.json$/, '.validator.cjs'); for (const destinationFolder of options.destinationFolders) { const destinationPath: string = path.resolve( heftConfiguration.buildFolderPath, diff --git a/libraries/heft-config-file/README.md b/libraries/heft-config-file/README.md index c51a808d73a..3be578d081e 100644 --- a/libraries/heft-config-file/README.md +++ b/libraries/heft-config-file/README.md @@ -142,7 +142,7 @@ const loader = new ProjectConfigurationFile({ // Path relative to the project root projectRelativeFilePath: 'config/my-plugin.json', - // Provide either jsonSchemaPath or jsonSchemaObject + // Provide jsonSchemaPath, jsonSchemaObject, or jsonSchemaValidator jsonSchemaPath: require.resolve('./schemas/my-plugin.schema.json'), // Configure how properties merge when a config file uses "extends" @@ -200,7 +200,7 @@ const config = loader.loadConfigurationFile(terminal, '/absolute/path/to/config. ### JSON schema -Supply either a file path or an inline object: +Supply a file path, an inline object, or a precompiled validator: ```typescript // From a file path @@ -208,8 +208,20 @@ Supply either a file path or an inline object: // Inline { jsonSchemaObject: { type: 'object', properties: { ... } } } + +// Generated by Heft's precompile-json-schemas-plugin +{ jsonSchemaValidator: require('./schemas/my-plugin.validator.cjs') } ``` +To generate validators, add a build task using `@rushstack/heft`'s +`precompile-json-schemas-plugin` with `sourceFolder` pointing at the folder containing +`*.schema.json` files and `destinationFolders` pointing at the published output +folders. The plugin emits a `.validator.cjs` module beside each published schema +(for example, `my-plugin.schema.json` becomes `my-plugin.validator.cjs`). +Publish both the JSON schema (for editors and tooling) and its generated validator, +and reference the latter through `jsonSchemaValidator` at runtime. Generated +validators depend on the AJV runtime supplied by `@rushstack/node-core-library`. + Schema validation runs **after** all inheritance merging, so the schema describes the shape of the final merged result. diff --git a/libraries/heft-config-file/src/test/PrecompiledValidator.test.ts b/libraries/heft-config-file/src/test/PrecompiledValidator.test.ts new file mode 100644 index 00000000000..19b21cd6624 --- /dev/null +++ b/libraries/heft-config-file/src/test/PrecompiledValidator.test.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IJsonSchemaCompiledValidator } from '@rushstack/node-core-library'; +import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; + +import { ProjectConfigurationFile } from '../ProjectConfigurationFile'; + +describe('precompiled schema validation', () => { + const projectFolder: string = __dirname; + const terminal: Terminal = new Terminal(new StringBufferTerminalProvider(false)); + + it('uses the precompiled validator to load project configuration', () => { + const validator = jest.fn((data: unknown): boolean => (data as { thing?: string }).thing === 'A'); + const loader: ProjectConfigurationFile<{ thing: string }> = new ProjectConfigurationFile({ + projectRelativeFilePath: 'simplestConfigFile/simplestConfigFile.json', + jsonSchemaValidator: validator + }); + + expect(loader.loadConfigurationFileForProject(terminal, projectFolder).thing).toBe('A'); + expect(validator).toHaveBeenCalledTimes(1); + expect((validator.mock.calls[0][0] as { thing: string }).thing).toBe('A'); + }); + + it('reports validation errors from the precompiled validator', () => { + const validator: IJsonSchemaCompiledValidator = Object.assign( + (_data: unknown): boolean => false, + { + errors: [ + { + instancePath: '/thing', + message: 'must be a string', + keyword: 'type', + schemaPath: '#/properties/thing/type', + params: { type: 'string' } + } + ] + } + ); + const loader: ProjectConfigurationFile<{ thing: string }> = new ProjectConfigurationFile({ + projectRelativeFilePath: 'simplestConfigFile/simplestConfigFile.json', + jsonSchemaValidator: validator + }); + + expect(() => loader.loadConfigurationFileForProject(terminal, projectFolder)).toThrow(/must be a string/); + }); +}); diff --git a/libraries/node-core-library/src/JsonSchema.ts b/libraries/node-core-library/src/JsonSchema.ts index e91aadc3cdb..05252db3ecf 100644 --- a/libraries/node-core-library/src/JsonSchema.ts +++ b/libraries/node-core-library/src/JsonSchema.ts @@ -65,6 +65,15 @@ export interface IJsonSchemaCustomFormat { validate: (data: T) => boolean; } +/** + * A compiled AJV-compatible validator, such as one exported by AJV standalone code. + * @public + */ +export interface IJsonSchemaCompiledValidator { + (data: unknown): boolean; + errors?: ErrorObject[] | null; +} + /** * Callback function arguments for {@link JsonSchema.validateObjectWithCallback} * @public @@ -205,7 +214,7 @@ export class JsonSchema { private _dependentSchemas: JsonSchema[] = []; private _filename: string = ''; private _shortName: string | undefined = undefined; - private _validator: ValidateFunction | undefined = undefined; + private _validator: IJsonSchemaCompiledValidator | undefined = undefined; private _schemaObject: JsonObject | undefined = undefined; private _schemaVersion: JsonSchemaVersion | undefined = undefined; private _customFormats: @@ -265,7 +274,7 @@ export class JsonSchema { * Wraps an already compiled AJV-compatible validator without loading or compiling a schema at runtime. * The validator must expose AJV's `errors` property after a failed validation. */ - public static fromCompiledValidator(validator: ValidateFunction, shortName?: string): JsonSchema { + public static fromCompiledValidator(validator: IJsonSchemaCompiledValidator, shortName?: string): JsonSchema { const schema: JsonSchema = new JsonSchema(); schema._validator = validator; schema._shortName = shortName; @@ -275,15 +284,32 @@ export class JsonSchema { /** * Compiles a schema file into CommonJS standalone AJV code. * @remarks - * The generated code requires the `ajv` runtime package in the consuming project. + * The generated code resolves `ajv` and `ajv-formats` through this package's dependencies, + * so the consuming project does not need to declare them directly. + * Custom format validator functions cannot be serialized into standalone code. */ public static compileStandaloneCodeFromFile( filename: string, options?: IJsonSchemaFromFileOptions ): string { + if (options?.customFormats && Object.keys(options.customFormats).length > 0) { + throw new Error('Standalone schema compilation does not support customFormats validation functions'); + } const schema: JsonSchema = JsonSchema.fromFile(filename, options); const { ajv, validator } = schema._compileValidator({ code: { source: true } }); - return standaloneCode(ajv, validator); + const code: string = standaloneCode(ajv, validator); + const runtimeImportPattern: RegExp = /\brequire\((['"])((?:ajv|ajv-formats)\/[^'"]+)\1\)/g; + if (!runtimeImportPattern.test(code)) { + return code; + } + + // AJV emits bare imports for its runtime helpers and ajv-formats. Resolve them relative to + // this package so isolated consumers do not need their own direct dependencies on those packages. + return ( + "'use strict';\nconst __rushstackAjvRuntimeRequire = require('node:module').createRequire(" + + "require.resolve('@rushstack/node-core-library/package.json'));\n" + + code.replace(runtimeImportPattern, '__rushstackAjvRuntimeRequire($1$2$1)') + ); } /** diff --git a/libraries/node-core-library/src/index.ts b/libraries/node-core-library/src/index.ts index 3f4592cf6b1..d3b8338b47d 100644 --- a/libraries/node-core-library/src/index.ts +++ b/libraries/node-core-library/src/index.ts @@ -109,6 +109,7 @@ export { } from './JsonFile'; export { + type IJsonSchemaCompiledValidator, type IJsonSchemaErrorInfo, type IJsonSchemaCustomFormat, type IJsonSchemaFromFileOptions, diff --git a/libraries/node-core-library/src/test/JsonSchema.test.ts b/libraries/node-core-library/src/test/JsonSchema.test.ts index a3d8cbb9584..c125e37bc2a 100644 --- a/libraries/node-core-library/src/test/JsonSchema.test.ts +++ b/libraries/node-core-library/src/test/JsonSchema.test.ts @@ -6,7 +6,11 @@ import { runInNewContext } from 'node:vm'; import Ajv, { type ValidateFunction } from 'ajv'; import { JsonFile, type JsonObject } from '../JsonFile'; -import { JsonSchema, type IJsonSchemaErrorInfo } from '../JsonSchema'; +import { + JsonSchema, + type IJsonSchemaCompiledValidator, + type IJsonSchemaErrorInfo +} from '../JsonSchema'; const SCHEMA_PATH: string = `${__dirname}/test-data/test-schemas/test-schema.schema.json`; const DRAFT_04_SCHEMA_PATH: string = `${__dirname}/test-data/test-schemas/test-schema-draft-04.schema.json`; @@ -109,71 +113,6 @@ describe(JsonSchema.name, () => { errorDetails.push(errorInfo.details); }); - test('wraps a compiled validator without loading a schema and preserves error formatting', () => { - const validator: ValidateFunction = new Ajv().compile({ - type: 'object', - properties: { name: { type: 'string' } }, - required: ['name'] - }); - const compiledSchema: JsonSchema = JsonSchema.fromCompiledValidator(validator, 'compiled schema'); - const loadSpy = jest.spyOn(JsonFile, 'load'); - try { - expect(compiledSchema.shortName).toBe('compiled schema'); - expect(() => compiledSchema.ensureCompiled()).not.toThrow(); - expect(() => compiledSchema.validateObject({ name: 'valid' }, 'input.json')).not.toThrow(); - expect(() => compiledSchema.validateObject({}, 'input.json')).toThrow( - /JSON validation failed:\s+input\.json\s+Error: #\s+must have required property 'name'/ - ); - expect(loadSpy).not.toHaveBeenCalled(); - } finally { - loadSpy.mockRestore(); - } - expect(JsonSchema.fromCompiledValidator(validator).shortName).toBe('(anonymous schema)'); - }); - - describe(JsonSchema.compileStandaloneCodeFromFile.name, () => { - function loadStandaloneSchema(filename: string, options?: Parameters[1]): JsonSchema { - const code: string = JsonSchema.compileStandaloneCodeFromFile(filename, options); - const generatedModule: { exports?: ValidateFunction } = {}; - runInNewContext(code, { module: generatedModule, require }); - expect(typeof generatedModule.exports).toBe('function'); - return JsonSchema.fromCompiledValidator(generatedModule.exports!); - } - - test.each([DRAFT_04_SCHEMA_PATH, DRAFT_07_SCHEMA_PATH])( - 'validates formats and inferred draft version for %s', - (filename) => { - const standaloneSchema: JsonSchema = loadStandaloneSchema(filename); - expect(() => - standaloneSchema.validateObject( - { exampleString: 'hello', exampleArray: [], exampleLink: 'https://example.com' }, - 'input.json' - ) - ).not.toThrow(); - expect(() => - standaloneSchema.validateObject( - { exampleString: 'hello', exampleArray: [], exampleLink: 'not a URI' }, - 'input.json' - ) - ).toThrow(/must match format "uri"/); - } - ); - - test('resolves dependent $ref schemas and accepts vendor keywords', () => { - const child: JsonSchema = JsonSchema.fromFile( - `${__dirname}/test-data/test-schemas/test-schema-nested-child.schema.json` - ); - const standaloneSchema: JsonSchema = loadStandaloneSchema( - `${__dirname}/test-data/test-schemas/test-schema-standalone.schema.json`, - { dependentSchemas: [child] } - ); - expect(() => standaloneSchema.validateObject({ item: { field1: 'valid' } }, 'input.json')).not.toThrow(); - expect(() => standaloneSchema.validateObject({ item: {} }, 'input.json')).toThrow( - /must have required property 'field1'/ - ); - }); - }); - expect(errorDetails).toMatchSnapshot(); }); test('successfully reports a compound validation error for format errors', () => { @@ -189,6 +128,117 @@ describe(JsonSchema.name, () => { }); }); + test('wraps a compiled validator without loading a schema and preserves error formatting', () => { + const validator: ValidateFunction = new Ajv().compile({ + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + }); + const compiledValidator: IJsonSchemaCompiledValidator = validator; + const compiledSchema: JsonSchema = JsonSchema.fromCompiledValidator(compiledValidator, 'compiled schema'); + const loadSpy = jest.spyOn(JsonFile, 'load'); + try { + expect(compiledSchema.shortName).toBe('compiled schema'); + expect(() => compiledSchema.ensureCompiled()).not.toThrow(); + expect(() => compiledSchema.validateObject({ name: 'valid' }, 'input.json')).not.toThrow(); + expect(() => compiledSchema.validateObject({}, 'input.json')).toThrow( + /JSON validation failed:\s+input\.json\s+Error: #\s+must have required property 'name'/ + ); + expect(loadSpy).not.toHaveBeenCalled(); + } finally { + loadSpy.mockRestore(); + } + expect(JsonSchema.fromCompiledValidator(validator).shortName).toBe('(anonymous schema)'); + }); + + describe(JsonSchema.compileStandaloneCodeFromFile.name, () => { + function loadStandaloneSchema( + filename: string, + options?: Parameters[1] + ): JsonSchema { + const code: string = JsonSchema.compileStandaloneCodeFromFile(filename, options); + const generatedModule: { exports?: IJsonSchemaCompiledValidator } = {}; + const isolatedRequire = Object.assign( + (specifier: string) => { + if (specifier !== 'node:module') { + throw new Error(`Unexpected direct dependency in generated code: ${specifier}`); + } + return require(specifier); + }, + { + resolve: (specifier: string): string => { + if (specifier !== '@rushstack/node-core-library/package.json') { + throw new Error(`Unexpected direct resolution in generated code: ${specifier}`); + } + return require.resolve('../../package.json'); + } + } + ); + runInNewContext(code, { module: generatedModule, require: isolatedRequire }); + expect(typeof generatedModule.exports).toBe('function'); + return JsonSchema.fromCompiledValidator(generatedModule.exports!); + } + + test.each([DRAFT_04_SCHEMA_PATH, DRAFT_07_SCHEMA_PATH])( + 'validates formats and inferred draft version for %s', + (filename) => { + const standaloneSchema: JsonSchema = loadStandaloneSchema(filename); + expect(() => + standaloneSchema.validateObject( + { exampleString: 'hello', exampleArray: [], exampleLink: 'https://example.com' }, + 'input.json' + ) + ).not.toThrow(); + expect(() => + standaloneSchema.validateObject( + { exampleString: 'hello', exampleArray: [], exampleLink: 'not a URI' }, + 'input.json' + ) + ).toThrow(/must match format "uri"/); + } + ); + + test('resolves local $ref schemas and accepts vendor keywords', () => { + const standaloneSchema: JsonSchema = loadStandaloneSchema( + `${__dirname}/test-data/test-schemas/test-schema-standalone.schema.json` + ); + expect(() => standaloneSchema.validateObject({ item: { field1: 'valid' } }, 'input.json')).not.toThrow(); + expect(() => standaloneSchema.validateObject({ item: {} }, 'input.json')).toThrow( + /must have required property 'field1'/ + ); + }); + + test('resolves external $ref schemas supplied as dependentSchemas', () => { + const childSchema: JsonSchema = JsonSchema.fromFile( + `${__dirname}/test-data/test-schemas/test-schema-nested-child.schema.json` + ); + const standaloneSchema: JsonSchema = loadStandaloneSchema( + `${__dirname}/test-data/test-schemas/test-schema-nested.schema.json`, + { dependentSchemas: [childSchema] } + ); + expect(() => + standaloneSchema.validateObject( + { exampleString: 'valid', exampleArray: [], exampleUniqueObjectArray: [{ field2: 'a', field3: 'b' }] }, + 'input.json' + ) + ).not.toThrow(); + expect(() => + standaloneSchema.validateObject( + { exampleString: 'invalid', exampleArray: [], exampleUniqueObjectArray: [{ field2: 'a' }] }, + 'input.json' + ) + ).toThrow(/must have required property 'field3'/); + }); + + test('rejects custom format validation functions, which cannot be serialized', () => { + expect(() => + JsonSchema.compileStandaloneCodeFromFile(DRAFT_07_SCHEMA_PATH, { + customFormats: { custom: { type: 'string', validate: (value) => value.length > 0 } } + }) + ).toThrow(/does not support customFormats validation functions/); + }); + }); + test('accepts vendor extension keywords by default', () => { const schemaWithVendorExtensions: JsonSchema = JsonSchema.fromLoadedObject( { diff --git a/libraries/node-core-library/src/test/test-data/test-schemas/test-schema-standalone.schema.json b/libraries/node-core-library/src/test/test-data/test-schemas/test-schema-standalone.schema.json index 6c3916d7baa..c3fabf11e28 100644 --- a/libraries/node-core-library/src/test/test-data/test-schemas/test-schema-standalone.schema.json +++ b/libraries/node-core-library/src/test/test-data/test-schemas/test-schema-standalone.schema.json @@ -2,8 +2,17 @@ "$id": "http://example.com/schemas/test-schema-standalone.schema.json", "x-myvendor-description": "Standalone schema", "type": "object", + "definitions": { + "type1": { + "type": "object", + "properties": { + "field1": { "type": "string" } + }, + "required": ["field1"] + } + }, "properties": { - "item": { "$ref": "test-schema-nested-child.schema.json#/definitions/type1" } + "item": { "$ref": "#/definitions/type1" } }, "required": ["item"] } From d2525dbbbe2ddbd26c84c73af49621e74d001300 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:20:21 +0000 Subject: [PATCH 03/16] Use generated validators for Heft core schemas Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --- apps/heft/config/heft.json | 15 +++++ .../configuration/HeftPluginConfiguration.ts | 13 +++- .../test/PrecompiledSchemas.test.ts | 44 ++++++++++++++ .../plugins/PrecompileJsonSchemasPlugin.ts | 59 ++++++++++++------- apps/heft/src/utilities/CoreConfigFiles.ts | 26 +++++--- libraries/node-core-library/src/JsonSchema.ts | 2 +- 6 files changed, 127 insertions(+), 32 deletions(-) create mode 100644 apps/heft/src/configuration/test/PrecompiledSchemas.test.ts diff --git a/apps/heft/config/heft.json b/apps/heft/config/heft.json index 76eac0e3d31..eb23883aae8 100644 --- a/apps/heft/config/heft.json +++ b/apps/heft/config/heft.json @@ -22,6 +22,21 @@ } }, + "precompile-json-schemas": { + "taskDependencies": ["typescript"], + "taskPlugin": { + "pluginPackage": "@rushstack/heft", + "pluginName": "run-script-plugin", + "options": { + "scriptPath": "lib-commonjs/plugins/PrecompileJsonSchemasPlugin.js", + "scriptOptions": { + "sourceFolder": "src/schemas", + "destinationFolders": ["lib-commonjs/schemas", "lib-esm/schemas"] + } + } + } + }, + "copy-legacy-compatibility-start-js": { "taskDependencies": ["typescript"], "taskPlugin": { diff --git a/apps/heft/src/configuration/HeftPluginConfiguration.ts b/apps/heft/src/configuration/HeftPluginConfiguration.ts index ac2ab9334c4..82ee07dfbb8 100644 --- a/apps/heft/src/configuration/HeftPluginConfiguration.ts +++ b/apps/heft/src/configuration/HeftPluginConfiguration.ts @@ -1,7 +1,10 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { JsonFile, JsonSchema } from '@rushstack/node-core-library'; +import { createRequire } from 'node:module'; +import * as path from 'node:path'; + +import { JsonFile, JsonSchema, type IJsonSchemaCompiledValidator } from '@rushstack/node-core-library'; import { HeftLifecyclePluginDefinition, @@ -11,7 +14,6 @@ import { type IHeftTaskPluginDefinitionJson } from './HeftPluginDefinition'; import type { IHeftConfigurationJsonPluginSpecifier } from '../utilities/CoreConfigFiles'; -import heftPluginSchema from '../schemas/heft-plugin.schema.json'; export interface IHeftPluginConfigurationJson { lifecyclePlugins?: IHeftLifecyclePluginDefinitionJson[]; @@ -20,7 +22,12 @@ export interface IHeftPluginConfigurationJson { const HEFT_PLUGIN_CONFIGURATION_FILENAME: 'heft-plugin.json' = 'heft-plugin.json'; -const _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(heftPluginSchema); +const _jsonSchema: JsonSchema = JsonSchema.fromCompiledValidator( + createRequire(path.join(__dirname, '../schemas/heft-plugin.schema.json'))( + path.join(__dirname, '../schemas/heft-plugin.validator.cjs') + ) as IJsonSchemaCompiledValidator, + 'heft-plugin.schema.json' +); const _pluginConfigurationPromises: Map> = new Map(); /** diff --git a/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts b/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts new file mode 100644 index 00000000000..07d963654a7 --- /dev/null +++ b/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import { createRequire } from 'node:module'; +import * as path from 'node:path'; + +import { JsonSchema, type IJsonSchemaCompiledValidator } from '@rushstack/node-core-library'; + +describe('Heft built-in schemas', () => { + const schemaFolder: string = path.resolve(__dirname, '../../schemas'); + + it('publishes a precompiled validator for every JSON schema', () => { + const schemaFiles: string[] = fs + .readdirSync(schemaFolder) + .filter((fileName: string) => fileName.endsWith('.schema.json')); + expect(schemaFiles.length).toBeGreaterThan(0); + + for (const schemaFile of schemaFiles) { + const schemaPath: string = path.join(schemaFolder, schemaFile); + const validatorPath: string = schemaPath.replace(/\.schema\.json$/, '.validator.cjs'); + const validator: IJsonSchemaCompiledValidator = createRequire(schemaPath)( + validatorPath + ) as IJsonSchemaCompiledValidator; + expect(typeof validator).toBe('function'); + + const compiledSchema: JsonSchema = JsonSchema.fromCompiledValidator(validator, schemaFile); + const sourceSchema: JsonSchema = JsonSchema.fromFile(schemaPath); + for (const example of [{}, { invalid: true }, { taskPlugins: [] }]) { + let sourceValid: boolean = true; + try { + sourceSchema.validateObject(example, 'test.json'); + } catch { + sourceValid = false; + } + if (sourceValid) { + expect(() => compiledSchema.validateObject(example, 'test.json')).not.toThrow(); + } else { + expect(() => compiledSchema.validateObject(example, 'test.json')).toThrow(); + } + } + } + }); +}); diff --git a/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts b/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts index 0d6ac5d454b..16e01823b83 100644 --- a/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts +++ b/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts @@ -8,6 +8,7 @@ import { FileSystem, JsonSchema } from '@rushstack/node-core-library'; import type { HeftConfiguration } from '../configuration/HeftConfiguration'; import type { IHeftTaskPlugin } from '../pluginFramework/IHeftPlugin'; import type { IHeftTaskSession, IHeftTaskRunHookOptions } from '../pluginFramework/HeftTaskSession'; +import type { IRunScriptOptions } from './RunScriptPlugin'; interface IPrecompileJsonSchemasPluginOptions { sourceFolder: string; @@ -16,6 +17,41 @@ interface IPrecompileJsonSchemasPluginOptions { const PLUGIN_NAME: 'precompile-json-schemas-plugin' = 'precompile-json-schemas-plugin'; +async function precompileSchemasAsync( + buildFolderPath: string, + options: IPrecompileJsonSchemasPluginOptions, + runOptions: IHeftTaskRunHookOptions +): Promise { + const sourceFolder: string = path.resolve(buildFolderPath, options.sourceFolder); + const schemaPaths: string[] = await runOptions.globAsync('**/*.schema.json', { + cwd: sourceFolder, + absolute: true + }); + + for (const schemaPath of schemaPaths) { + const validatorCode: string = JsonSchema.compileStandaloneCodeFromFile(schemaPath); + const relativePath: string = path + .relative(sourceFolder, schemaPath) + .replace(/\.schema\.json$/, '.validator.cjs'); + for (const destinationFolder of options.destinationFolders) { + const destinationPath: string = path.resolve(buildFolderPath, destinationFolder, relativePath); + await FileSystem.writeFileAsync(destinationPath, validatorCode, { ensureFolderExists: true }); + } + } + return schemaPaths.length; +} + +// The Heft package builds itself using the previously published version of Heft. Until that +// version includes this plugin, its run-script-plugin invokes this entry point after TypeScript emits it. +export async function runAsync(options: IRunScriptOptions): Promise { + const count: number = await precompileSchemasAsync( + options.heftConfiguration.buildFolderPath, + options.scriptOptions as unknown as IPrecompileJsonSchemasPluginOptions, + options.runOptions + ); + options.heftTaskSession.logger.terminal.writeLine(`Precompiled ${count} JSON schemas.`); +} + export default class PrecompileJsonSchemasPlugin implements IHeftTaskPlugin { @@ -25,27 +61,8 @@ export default class PrecompileJsonSchemasPlugin options: IPrecompileJsonSchemasPluginOptions ): void { taskSession.hooks.run.tapPromise(PLUGIN_NAME, async (runOptions: IHeftTaskRunHookOptions) => { - const sourceFolder: string = path.resolve(heftConfiguration.buildFolderPath, options.sourceFolder); - const schemaPaths: string[] = await runOptions.globAsync('**/*.schema.json', { - cwd: sourceFolder, - absolute: true - }); - - for (const schemaPath of schemaPaths) { - const validatorCode: string = JsonSchema.compileStandaloneCodeFromFile(schemaPath); - const relativePath: string = path - .relative(sourceFolder, schemaPath) - .replace(/\.schema\.json$/, '.validator.cjs'); - for (const destinationFolder of options.destinationFolders) { - const destinationPath: string = path.resolve( - heftConfiguration.buildFolderPath, - destinationFolder, - relativePath - ); - await FileSystem.writeFileAsync(destinationPath, validatorCode, { ensureFolderExists: true }); - } - } - taskSession.logger.terminal.writeLine(`Precompiled ${schemaPaths.length} JSON schemas.`); + const count: number = await precompileSchemasAsync(heftConfiguration.buildFolderPath, options, runOptions); + taskSession.logger.terminal.writeLine(`Precompiled ${count} JSON schemas.`); }); } } diff --git a/apps/heft/src/utilities/CoreConfigFiles.ts b/apps/heft/src/utilities/CoreConfigFiles.ts index ff221359a7d..006bbe9add7 100644 --- a/apps/heft/src/utilities/CoreConfigFiles.ts +++ b/apps/heft/src/utilities/CoreConfigFiles.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import { createRequire } from 'node:module'; import * as path from 'node:path'; import { @@ -9,7 +10,12 @@ import { PathResolutionMethod, type IJsonPathMetadataResolverOptions } from '@rushstack/heft-config-file'; -import { Import, PackageJsonLookup, InternalError } from '@rushstack/node-core-library'; +import { + Import, + PackageJsonLookup, + InternalError, + type IJsonSchemaCompiledValidator +} from '@rushstack/node-core-library'; import type { ITerminal } from '@rushstack/terminal'; import type { IRigConfig } from '@rushstack/rig-package'; @@ -108,11 +114,13 @@ export class CoreConfigFiles { } }; - const schemaObject: object = await import('../schemas/heft.schema.json'); + const schemaValidator: IJsonSchemaCompiledValidator = createRequire( + path.resolve(__dirname, '../schemas/heft.schema.json') + )(path.resolve(__dirname, '../schemas/heft.validator.cjs')) as IJsonSchemaCompiledValidator; // eslint-disable-next-line require-atomic-updates _heftConfigFileLoader = new ProjectConfigurationFile({ projectRelativeFilePath: CoreConfigFiles.heftConfigurationProjectRelativeFilePath, - jsonSchemaObject: schemaObject, + jsonSchemaValidator: schemaValidator, propertyInheritanceDefaults: { array: { inheritanceType: InheritanceType.append }, object: { inheritanceType: InheritanceType.merge } @@ -156,11 +164,13 @@ export class CoreConfigFiles { // match the legacy schema. We don't need to worry about the resulting object, we just // want to see if it parses. We will use the ConfigurationFile class to load it to ensure // that we follow the "extends" chain for the entire config file. - const legacySchemaObject: object = await import('../schemas/heft-legacy.schema.json'); + const legacySchemaValidator: IJsonSchemaCompiledValidator = createRequire( + path.resolve(__dirname, '../schemas/heft-legacy.schema.json') + )(path.resolve(__dirname, '../schemas/heft-legacy.validator.cjs')) as IJsonSchemaCompiledValidator; const legacyConfigFileLoader: ProjectConfigurationFile = new ProjectConfigurationFile({ projectRelativeFilePath: CoreConfigFiles.heftConfigurationProjectRelativeFilePath, - jsonSchemaObject: legacySchemaObject + jsonSchemaValidator: legacySchemaValidator }); await legacyConfigFileLoader.loadConfigurationFileForProjectAsync(terminal, projectPath, rigConfig); } catch (e2) { @@ -230,11 +240,13 @@ export class CoreConfigFiles { rigConfig?: IRigConfig | undefined ): Promise { if (!_nodeServiceConfigurationLoader) { - const schemaObject: object = await import('../schemas/node-service.schema.json'); + const schemaValidator: IJsonSchemaCompiledValidator = createRequire( + path.resolve(__dirname, '../schemas/node-service.schema.json') + )(path.resolve(__dirname, '../schemas/node-service.validator.cjs')) as IJsonSchemaCompiledValidator; // eslint-disable-next-line require-atomic-updates _nodeServiceConfigurationLoader = new ProjectConfigurationFile({ projectRelativeFilePath: CoreConfigFiles.nodeServiceConfigurationProjectRelativeFilePath, - jsonSchemaObject: schemaObject + jsonSchemaValidator: schemaValidator }); } diff --git a/libraries/node-core-library/src/JsonSchema.ts b/libraries/node-core-library/src/JsonSchema.ts index 05252db3ecf..0f7615d79ec 100644 --- a/libraries/node-core-library/src/JsonSchema.ts +++ b/libraries/node-core-library/src/JsonSchema.ts @@ -464,7 +464,7 @@ export class JsonSchema { } if (this._validator && !this._validator(jsonObject)) { - const errorDetails: string = _formatErrorDetails(this._validator.errors!); + const errorDetails: string = _formatErrorDetails(this._validator.errors ?? []); const args: IJsonSchemaErrorInfo = { details: errorDetails From 4f7161fe19c1bba14472d1db78c382eaac1c416c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:22:29 +0000 Subject: [PATCH 04/16] Test Heft's packaged precompiled validators Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --- .../test/PrecompiledSchemas.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts b/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts index 07d963654a7..05a0042ba19 100644 --- a/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts +++ b/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts @@ -7,8 +7,11 @@ import * as path from 'node:path'; import { JsonSchema, type IJsonSchemaCompiledValidator } from '@rushstack/node-core-library'; +import { HeftPluginConfiguration } from '../HeftPluginConfiguration'; + describe('Heft built-in schemas', () => { const schemaFolder: string = path.resolve(__dirname, '../../schemas'); + const packageRoot: string = path.resolve(__dirname, '../../..'); it('publishes a precompiled validator for every JSON schema', () => { const schemaFiles: string[] = fs @@ -41,4 +44,18 @@ describe('Heft built-in schemas', () => { } } }); + + it('loads every built-in plugin option schema without compiling a JSON schema', async () => { + const fromFileSpy = jest.spyOn(JsonSchema, 'fromFile'); + try { + const plugins: HeftPluginConfiguration = await HeftPluginConfiguration.loadFromPackageAsync( + packageRoot, + '@rushstack/heft' + ); + expect(plugins.tryGetTaskPluginDefinitionByName('copy-files-plugin')).toBeDefined(); + expect(fromFileSpy).not.toHaveBeenCalled(); + } finally { + fromFileSpy.mockRestore(); + } + }); }); From f81eb04bfca9607a19a32d06f221268c1c4d2235 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:48:42 +0000 Subject: [PATCH 05/16] Use static validator imports in Heft Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --- apps/heft/config/heft.json | 3 +- apps/heft/package.json | 2 + .../configuration/HeftPluginConfiguration.ts | 10 +-- .../src/configuration/HeftPluginDefinition.ts | 36 ++++++---- .../test/PrecompiledSchemas.test.ts | 30 ++++++-- .../plugins/PrecompileJsonSchemasPlugin.ts | 14 +++- ...recompile-json-schemas-options.schema.json | 5 ++ apps/heft/src/schemas/validator.d.ts | 4 ++ apps/heft/src/utilities/CoreConfigFiles.ts | 20 ++---- .../config/subspaces/default/pnpm-lock.yaml | 6 ++ libraries/heft-config-file/README.md | 13 ++-- libraries/node-core-library/src/JsonSchema.ts | 59 ++++++++++++---- libraries/node-core-library/src/index.ts | 1 + .../src/test/JsonSchema.test.ts | 69 ++++++++++++++----- 14 files changed, 193 insertions(+), 79 deletions(-) create mode 100644 apps/heft/src/schemas/validator.d.ts diff --git a/apps/heft/config/heft.json b/apps/heft/config/heft.json index eb23883aae8..081495e01aa 100644 --- a/apps/heft/config/heft.json +++ b/apps/heft/config/heft.json @@ -31,7 +31,8 @@ "scriptPath": "lib-commonjs/plugins/PrecompileJsonSchemasPlugin.js", "scriptOptions": { "sourceFolder": "src/schemas", - "destinationFolders": ["lib-commonjs/schemas", "lib-esm/schemas"] + "destinationFolders": ["lib-commonjs/schemas"], + "esmDestinationFolders": ["lib-esm/schemas"] } } } diff --git a/apps/heft/package.json b/apps/heft/package.json index 6ea0e7a32bb..542f2244dff 100644 --- a/apps/heft/package.json +++ b/apps/heft/package.json @@ -67,6 +67,8 @@ "@rushstack/terminal": "workspace:*", "@rushstack/ts-command-line": "workspace:*", "@types/tapable": "1.0.6", + "ajv": "~8.20.0", + "ajv-formats": "~3.0.1", "fast-glob": "~3.3.1", "git-repo-info": "~2.1.0", "ignore": "~5.1.6", diff --git a/apps/heft/src/configuration/HeftPluginConfiguration.ts b/apps/heft/src/configuration/HeftPluginConfiguration.ts index 82ee07dfbb8..db89e8bc5fc 100644 --- a/apps/heft/src/configuration/HeftPluginConfiguration.ts +++ b/apps/heft/src/configuration/HeftPluginConfiguration.ts @@ -1,10 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { createRequire } from 'node:module'; -import * as path from 'node:path'; - -import { JsonFile, JsonSchema, type IJsonSchemaCompiledValidator } from '@rushstack/node-core-library'; +import { JsonFile, JsonSchema } from '@rushstack/node-core-library'; import { HeftLifecyclePluginDefinition, @@ -14,6 +11,7 @@ import { type IHeftTaskPluginDefinitionJson } from './HeftPluginDefinition'; import type { IHeftConfigurationJsonPluginSpecifier } from '../utilities/CoreConfigFiles'; +import heftPluginSchemaValidator from '../schemas/heft-plugin.validator.js'; export interface IHeftPluginConfigurationJson { lifecyclePlugins?: IHeftLifecyclePluginDefinitionJson[]; @@ -23,9 +21,7 @@ export interface IHeftPluginConfigurationJson { const HEFT_PLUGIN_CONFIGURATION_FILENAME: 'heft-plugin.json' = 'heft-plugin.json'; const _jsonSchema: JsonSchema = JsonSchema.fromCompiledValidator( - createRequire(path.join(__dirname, '../schemas/heft-plugin.schema.json'))( - path.join(__dirname, '../schemas/heft-plugin.validator.cjs') - ) as IJsonSchemaCompiledValidator, + heftPluginSchemaValidator, 'heft-plugin.schema.json' ); const _pluginConfigurationPromises: Map> = new Map(); diff --git a/apps/heft/src/configuration/HeftPluginDefinition.ts b/apps/heft/src/configuration/HeftPluginDefinition.ts index 87216a305f1..57fb76c9bfc 100644 --- a/apps/heft/src/configuration/HeftPluginDefinition.ts +++ b/apps/heft/src/configuration/HeftPluginDefinition.ts @@ -1,16 +1,15 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { createRequire } from 'node:module'; import * as path from 'node:path'; -import { - FileSystem, - InternalError, - JsonSchema, - type IJsonSchemaCompiledValidator -} from '@rushstack/node-core-library'; +import { InternalError, JsonSchema, type IJsonSchemaCompiledValidator } from '@rushstack/node-core-library'; +import copyFilesValidator from '../schemas/copy-files-options.validator.js'; +import deleteFilesValidator from '../schemas/delete-files-options.validator.js'; +import precompileSchemasValidator from '../schemas/precompile-json-schemas-options.validator.js'; +import runScriptValidator from '../schemas/run-script-options.validator.js'; +import setEnvironmentVariablesValidator from '../schemas/set-environment-variables-plugin.validator.js'; import type { IHeftPlugin } from '../pluginFramework/IHeftPlugin'; import type { IScopedLogger } from '../pluginFramework/logging/ScopedLogger'; import type { HeftLifecycleSession } from '../pluginFramework/HeftLifecycleSession'; @@ -198,6 +197,14 @@ export interface IHeftPluginDefinitionOptions { packageRoot: string; } +const BUILT_IN_OPTIONS_VALIDATORS: ReadonlyMap = new Map([ + ['copy-files-plugin', copyFilesValidator], + ['delete-files-plugin', deleteFilesValidator], + ['precompile-json-schemas-plugin', precompileSchemasValidator], + ['run-script-plugin', runScriptValidator], + ['set-environment-variables-plugin', setEnvironmentVariablesValidator] +]); + export abstract class HeftPluginDefinitionBase { #heftPluginDefinitionJson: IHeftPluginDefinitionJson; #pluginPackageName: string; @@ -228,14 +235,13 @@ export abstract class HeftPluginDefinitionBase { options.packageRoot, options.heftPluginDefinitionJson.optionsSchema ); - const compiledValidatorPath: string = resolvedSchemaPath.replace(/\.schema\.json$/, '.validator.cjs'); - this.#optionsSchema = - compiledValidatorPath !== resolvedSchemaPath && FileSystem.exists(compiledValidatorPath) - ? JsonSchema.fromCompiledValidator( - createRequire(resolvedSchemaPath)(compiledValidatorPath) as IJsonSchemaCompiledValidator, - path.basename(resolvedSchemaPath) - ) - : JsonSchema.fromFile(resolvedSchemaPath); + const builtInValidator: IJsonSchemaCompiledValidator | undefined = + options.packageName === '@rushstack/heft' + ? BUILT_IN_OPTIONS_VALIDATORS.get(this.pluginName) + : undefined; + this.#optionsSchema = builtInValidator + ? JsonSchema.fromCompiledValidator(builtInValidator, path.basename(resolvedSchemaPath)) + : JsonSchema.fromFile(resolvedSchemaPath); } } diff --git a/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts b/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts index 05a0042ba19..658301b411a 100644 --- a/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts +++ b/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts @@ -2,16 +2,37 @@ // See LICENSE in the project root for license information. import * as fs from 'node:fs'; -import { createRequire } from 'node:module'; import * as path from 'node:path'; import { JsonSchema, type IJsonSchemaCompiledValidator } from '@rushstack/node-core-library'; +import anythingValidator from '../../schemas/anything.validator.js'; +import copyFilesValidator from '../../schemas/copy-files-options.validator.js'; +import deleteFilesValidator from '../../schemas/delete-files-options.validator.js'; +import legacyValidator from '../../schemas/heft-legacy.validator.js'; +import heftPluginValidator from '../../schemas/heft-plugin.validator.js'; +import heftValidator from '../../schemas/heft.validator.js'; +import nodeServiceValidator from '../../schemas/node-service.validator.js'; +import precompileValidator from '../../schemas/precompile-json-schemas-options.validator.js'; +import runScriptValidator from '../../schemas/run-script-options.validator.js'; +import setEnvironmentValidator from '../../schemas/set-environment-variables-plugin.validator.js'; import { HeftPluginConfiguration } from '../HeftPluginConfiguration'; describe('Heft built-in schemas', () => { const schemaFolder: string = path.resolve(__dirname, '../../schemas'); const packageRoot: string = path.resolve(__dirname, '../../..'); + const validators: ReadonlyMap = new Map([ + ['anything.schema.json', anythingValidator], + ['copy-files-options.schema.json', copyFilesValidator], + ['delete-files-options.schema.json', deleteFilesValidator], + ['heft-legacy.schema.json', legacyValidator], + ['heft-plugin.schema.json', heftPluginValidator], + ['heft.schema.json', heftValidator], + ['node-service.schema.json', nodeServiceValidator], + ['precompile-json-schemas-options.schema.json', precompileValidator], + ['run-script-options.schema.json', runScriptValidator], + ['set-environment-variables-plugin.schema.json', setEnvironmentValidator] + ]); it('publishes a precompiled validator for every JSON schema', () => { const schemaFiles: string[] = fs @@ -21,10 +42,9 @@ describe('Heft built-in schemas', () => { for (const schemaFile of schemaFiles) { const schemaPath: string = path.join(schemaFolder, schemaFile); - const validatorPath: string = schemaPath.replace(/\.schema\.json$/, '.validator.cjs'); - const validator: IJsonSchemaCompiledValidator = createRequire(schemaPath)( - validatorPath - ) as IJsonSchemaCompiledValidator; + const validatorPath: string = schemaPath.replace(/\.schema\.json$/, '.validator.js'); + expect(fs.existsSync(validatorPath)).toBe(true); + const validator: IJsonSchemaCompiledValidator = validators.get(schemaFile)!; expect(typeof validator).toBe('function'); const compiledSchema: JsonSchema = JsonSchema.fromCompiledValidator(validator, schemaFile); diff --git a/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts b/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts index 16e01823b83..a956101fdfb 100644 --- a/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts +++ b/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts @@ -13,6 +13,7 @@ import type { IRunScriptOptions } from './RunScriptPlugin'; interface IPrecompileJsonSchemasPluginOptions { sourceFolder: string; destinationFolders: string[]; + esmDestinationFolders?: string[]; } const PLUGIN_NAME: 'precompile-json-schemas-plugin' = 'precompile-json-schemas-plugin'; @@ -29,14 +30,23 @@ async function precompileSchemasAsync( }); for (const schemaPath of schemaPaths) { - const validatorCode: string = JsonSchema.compileStandaloneCodeFromFile(schemaPath); const relativePath: string = path .relative(sourceFolder, schemaPath) - .replace(/\.schema\.json$/, '.validator.cjs'); + .replace(/\.schema\.json$/, '.validator.js'); + const validatorCode: string = JsonSchema.compileStandaloneCodeFromFile(schemaPath); for (const destinationFolder of options.destinationFolders) { const destinationPath: string = path.resolve(buildFolderPath, destinationFolder, relativePath); await FileSystem.writeFileAsync(destinationPath, validatorCode, { ensureFolderExists: true }); } + if (options.esmDestinationFolders?.length) { + const esmCode: string = JsonSchema.compileStandaloneCodeFromFile(schemaPath, undefined, { + moduleFormat: 'esm' + }); + for (const destinationFolder of options.esmDestinationFolders) { + const destinationPath: string = path.resolve(buildFolderPath, destinationFolder, relativePath); + await FileSystem.writeFileAsync(destinationPath, esmCode, { ensureFolderExists: true }); + } + } } return schemaPaths.length; } diff --git a/apps/heft/src/schemas/precompile-json-schemas-options.schema.json b/apps/heft/src/schemas/precompile-json-schemas-options.schema.json index f63e658a5fd..25b9137465f 100644 --- a/apps/heft/src/schemas/precompile-json-schemas-options.schema.json +++ b/apps/heft/src/schemas/precompile-json-schemas-options.schema.json @@ -14,6 +14,11 @@ "minItems": 1, "items": { "type": "string" }, "description": "Folders for generated CommonJS validator modules, relative to the project root." + }, + "esmDestinationFolders": { + "type": "array", + "items": { "type": "string" }, + "description": "Folders for generated ESM validator modules, relative to the project root." } } } diff --git a/apps/heft/src/schemas/validator.d.ts b/apps/heft/src/schemas/validator.d.ts new file mode 100644 index 00000000000..09a766cd001 --- /dev/null +++ b/apps/heft/src/schemas/validator.d.ts @@ -0,0 +1,4 @@ +declare module '*.validator.js' { + const validator: import('@rushstack/node-core-library').IJsonSchemaCompiledValidator; + export default validator; +} diff --git a/apps/heft/src/utilities/CoreConfigFiles.ts b/apps/heft/src/utilities/CoreConfigFiles.ts index 006bbe9add7..5ec54bb57c2 100644 --- a/apps/heft/src/utilities/CoreConfigFiles.ts +++ b/apps/heft/src/utilities/CoreConfigFiles.ts @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { createRequire } from 'node:module'; import * as path from 'node:path'; import { @@ -13,14 +12,16 @@ import { import { Import, PackageJsonLookup, - InternalError, - type IJsonSchemaCompiledValidator + InternalError } from '@rushstack/node-core-library'; import type { ITerminal } from '@rushstack/terminal'; import type { IRigConfig } from '@rushstack/rig-package'; import type { IDeleteOperation } from '../plugins/DeleteFilesPlugin'; import type { INodeServicePluginConfiguration } from '../plugins/NodeServicePlugin'; +import heftSchemaValidator from '../schemas/heft.validator.js'; +import legacySchemaValidator from '../schemas/heft-legacy.validator.js'; +import nodeServiceSchemaValidator from '../schemas/node-service.validator.js'; import { Constants } from './Constants'; export interface IHeftConfigurationJsonActionReference { @@ -114,13 +115,10 @@ export class CoreConfigFiles { } }; - const schemaValidator: IJsonSchemaCompiledValidator = createRequire( - path.resolve(__dirname, '../schemas/heft.schema.json') - )(path.resolve(__dirname, '../schemas/heft.validator.cjs')) as IJsonSchemaCompiledValidator; // eslint-disable-next-line require-atomic-updates _heftConfigFileLoader = new ProjectConfigurationFile({ projectRelativeFilePath: CoreConfigFiles.heftConfigurationProjectRelativeFilePath, - jsonSchemaValidator: schemaValidator, + jsonSchemaValidator: heftSchemaValidator, propertyInheritanceDefaults: { array: { inheritanceType: InheritanceType.append }, object: { inheritanceType: InheritanceType.merge } @@ -164,9 +162,6 @@ export class CoreConfigFiles { // match the legacy schema. We don't need to worry about the resulting object, we just // want to see if it parses. We will use the ConfigurationFile class to load it to ensure // that we follow the "extends" chain for the entire config file. - const legacySchemaValidator: IJsonSchemaCompiledValidator = createRequire( - path.resolve(__dirname, '../schemas/heft-legacy.schema.json') - )(path.resolve(__dirname, '../schemas/heft-legacy.validator.cjs')) as IJsonSchemaCompiledValidator; const legacyConfigFileLoader: ProjectConfigurationFile = new ProjectConfigurationFile({ projectRelativeFilePath: CoreConfigFiles.heftConfigurationProjectRelativeFilePath, @@ -240,13 +235,10 @@ export class CoreConfigFiles { rigConfig?: IRigConfig | undefined ): Promise { if (!_nodeServiceConfigurationLoader) { - const schemaValidator: IJsonSchemaCompiledValidator = createRequire( - path.resolve(__dirname, '../schemas/node-service.schema.json') - )(path.resolve(__dirname, '../schemas/node-service.validator.cjs')) as IJsonSchemaCompiledValidator; // eslint-disable-next-line require-atomic-updates _nodeServiceConfigurationLoader = new ProjectConfigurationFile({ projectRelativeFilePath: CoreConfigFiles.nodeServiceConfigurationProjectRelativeFilePath, - jsonSchemaValidator: schemaValidator + jsonSchemaValidator: nodeServiceSchemaValidator }); } diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 2d4e19dd39e..6cafe99b840 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -162,6 +162,12 @@ importers: '@types/tapable': specifier: 1.0.6 version: 1.0.6 + ajv: + specifier: ~8.20.0 + version: 8.20.0 + ajv-formats: + specifier: ~3.0.1 + version: 3.0.1(ajv@8.20.0) fast-glob: specifier: ~3.3.1 version: 3.3.3 diff --git a/libraries/heft-config-file/README.md b/libraries/heft-config-file/README.md index 3be578d081e..67e4415bcd4 100644 --- a/libraries/heft-config-file/README.md +++ b/libraries/heft-config-file/README.md @@ -210,17 +210,20 @@ Supply a file path, an inline object, or a precompiled validator: { jsonSchemaObject: { type: 'object', properties: { ... } } } // Generated by Heft's precompile-json-schemas-plugin -{ jsonSchemaValidator: require('./schemas/my-plugin.validator.cjs') } +import myPluginValidator from './schemas/my-plugin.validator.js'; +{ jsonSchemaValidator: myPluginValidator } ``` To generate validators, add a build task using `@rushstack/heft`'s `precompile-json-schemas-plugin` with `sourceFolder` pointing at the folder containing -`*.schema.json` files and `destinationFolders` pointing at the published output -folders. The plugin emits a `.validator.cjs` module beside each published schema -(for example, `my-plugin.schema.json` becomes `my-plugin.validator.cjs`). +`*.schema.json` files, `destinationFolders` pointing at the CommonJS output folders, +and `esmDestinationFolders` pointing at the ESM output folders. The plugin emits +`.validator.js` modules beside each published schema (for example, +`my-plugin.schema.json` becomes `my-plugin.validator.js`). Publish both the JSON schema (for editors and tooling) and its generated validator, and reference the latter through `jsonSchemaValidator` at runtime. Generated -validators depend on the AJV runtime supplied by `@rushstack/node-core-library`. +validators depend on `ajv` and `ajv-formats`; publishing projects should declare +them as dependencies. Schema validation runs **after** all inheritance merging, so the schema describes the shape of the final merged result. diff --git a/libraries/node-core-library/src/JsonSchema.ts b/libraries/node-core-library/src/JsonSchema.ts index 0f7615d79ec..a121073d8db 100644 --- a/libraries/node-core-library/src/JsonSchema.ts +++ b/libraries/node-core-library/src/JsonSchema.ts @@ -183,6 +183,17 @@ export type IJsonSchemaFromFileOptions = IJsonSchemaLoadOptions; */ export type IJsonSchemaFromObjectOptions = IJsonSchemaLoadOptions; +/** + * Options for {@link JsonSchema.compileStandaloneCodeFromFile}. + * @public + */ +export interface IJsonSchemaStandaloneCodeOptions { + /** + * The output module format. Defaults to CommonJS. + */ + moduleFormat?: 'commonjs' | 'esm'; +} + const JSON_SCHEMA_URL_PREFIX_BY_JSON_SCHEMA_VERSION: Map = new Map([ ['draft-04', 'http://json-schema.org/draft-04/schema'], ['draft-07', 'http://json-schema.org/draft-07/schema'] @@ -282,15 +293,17 @@ export class JsonSchema { } /** - * Compiles a schema file into CommonJS standalone AJV code. + * Compiles a schema file into standalone AJV code. * @remarks - * The generated code resolves `ajv` and `ajv-formats` through this package's dependencies, - * so the consuming project does not need to declare them directly. + * CommonJS output (the default) uses literal `require` calls; ESM output uses + * static imports. The consuming project must have `ajv` and `ajv-formats` + * resolvable from the location of the generated module. * Custom format validator functions cannot be serialized into standalone code. */ public static compileStandaloneCodeFromFile( filename: string, - options?: IJsonSchemaFromFileOptions + options?: IJsonSchemaFromFileOptions, + codeOptions?: IJsonSchemaStandaloneCodeOptions ): string { if (options?.customFormats && Object.keys(options.customFormats).length > 0) { throw new Error('Standalone schema compilation does not support customFormats validation functions'); @@ -299,17 +312,35 @@ export class JsonSchema { const { ajv, validator } = schema._compileValidator({ code: { source: true } }); const code: string = standaloneCode(ajv, validator); const runtimeImportPattern: RegExp = /\brequire\((['"])((?:ajv|ajv-formats)\/[^'"]+)\1\)/g; - if (!runtimeImportPattern.test(code)) { - return code; - } + if (codeOptions?.moduleFormat === 'esm') { + const validatorExport: RegExpMatchArray | null = code.match(/\bmodule\.exports\s*=\s*(validate\d+);/); + if (!validatorExport) { + throw new Error('Unexpected AJV standalone output: missing validator export'); + } - // AJV emits bare imports for its runtime helpers and ajv-formats. Resolve them relative to - // this package so isolated consumers do not need their own direct dependencies on those packages. - return ( - "'use strict';\nconst __rushstackAjvRuntimeRequire = require('node:module').createRequire(" + - "require.resolve('@rushstack/node-core-library/package.json'));\n" + - code.replace(runtimeImportPattern, '__rushstackAjvRuntimeRequire($1$2$1)') - ); + const imports: Map = new Map(); + const esmCode: string = code + .replace(/\bmodule\.exports(?:\.default)?\s*=\s*validate\d+;/g, '') + .replace(runtimeImportPattern, (_match: string, _quote: string, specifier: string) => { + let importedName: string | undefined = imports.get(specifier); + if (!importedName) { + importedName = `__rushstackAjvRuntime${imports.size}`; + imports.set(specifier, importedName); + } + return importedName; + }); + if (/\brequire\s*\(|\bmodule\.exports\b/.test(esmCode)) { + throw new Error('Unexpected AJV standalone output: unsupported CommonJS dependency or export'); + } + const importStatements: string = [...imports] + .map(([specifier, importedName]) => { + const esmSpecifier: string = path.extname(specifier) ? specifier : `${specifier}.js`; + return `import ${importedName} from ${JSON.stringify(esmSpecifier)};`; + }) + .join('\n'); + return `${importStatements}\n${esmCode}\nexport default ${validatorExport[1]};\n`; + } + return code; } /** diff --git a/libraries/node-core-library/src/index.ts b/libraries/node-core-library/src/index.ts index d3b8338b47d..99d0f421f0f 100644 --- a/libraries/node-core-library/src/index.ts +++ b/libraries/node-core-library/src/index.ts @@ -115,6 +115,7 @@ export { type IJsonSchemaFromFileOptions, type IJsonSchemaFromObjectOptions, type IJsonSchemaLoadOptions, + type IJsonSchemaStandaloneCodeOptions, type IJsonSchemaValidateOptions, type IJsonSchemaValidateObjectWithOptions, JsonSchema, diff --git a/libraries/node-core-library/src/test/JsonSchema.test.ts b/libraries/node-core-library/src/test/JsonSchema.test.ts index c125e37bc2a..fb5cf9d45d0 100644 --- a/libraries/node-core-library/src/test/JsonSchema.test.ts +++ b/libraries/node-core-library/src/test/JsonSchema.test.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import { execFileSync } from 'node:child_process'; +import * as path from 'node:path'; import { runInNewContext } from 'node:vm'; import Ajv, { type ValidateFunction } from 'ajv'; @@ -152,29 +154,33 @@ describe(JsonSchema.name, () => { }); describe(JsonSchema.compileStandaloneCodeFromFile.name, () => { + test('defaults to CommonJS output', () => { + const defaultCode: string = JsonSchema.compileStandaloneCodeFromFile(DRAFT_07_SCHEMA_PATH); + expect( + JsonSchema.compileStandaloneCodeFromFile(DRAFT_07_SCHEMA_PATH, undefined, { + moduleFormat: 'commonjs' + }) + ).toBe(defaultCode); + expect(defaultCode).toContain('module.exports'); + expect(defaultCode).toMatch(/require\(["']ajv(?:-formats)?\/dist\/[^"']+["']\)/); + expect(defaultCode).not.toContain('createRequire'); + }); + function loadStandaloneSchema( filename: string, options?: Parameters[1] ): JsonSchema { const code: string = JsonSchema.compileStandaloneCodeFromFile(filename, options); + expect(code).not.toContain('createRequire'); + expect(code).not.toContain('__rushstackAjvRuntimeRequire'); const generatedModule: { exports?: IJsonSchemaCompiledValidator } = {}; - const isolatedRequire = Object.assign( - (specifier: string) => { - if (specifier !== 'node:module') { - throw new Error(`Unexpected direct dependency in generated code: ${specifier}`); - } - return require(specifier); - }, - { - resolve: (specifier: string): string => { - if (specifier !== '@rushstack/node-core-library/package.json') { - throw new Error(`Unexpected direct resolution in generated code: ${specifier}`); - } - return require.resolve('../../package.json'); - } + const standaloneRequire = (specifier: string): unknown => { + if (!/^ajv(?:-formats)?\/dist\//.test(specifier)) { + throw new Error(`Unexpected dependency in generated code: ${specifier}`); } - ); - runInNewContext(code, { module: generatedModule, require: isolatedRequire }); + return require(specifier); + }; + runInNewContext(code, { module: generatedModule, require: standaloneRequire }); expect(typeof generatedModule.exports).toBe('function'); return JsonSchema.fromCompiledValidator(generatedModule.exports!); } @@ -198,6 +204,37 @@ describe(JsonSchema.name, () => { } ); + test.each([DRAFT_04_SCHEMA_PATH, DRAFT_07_SCHEMA_PATH])( + 'emits executable ESM with static AJV imports for %s', + (filename) => { + const code: string = JsonSchema.compileStandaloneCodeFromFile(filename, undefined, { + moduleFormat: 'esm' + }); + expect(code).toMatch(/^import __rushstackAjvRuntime\d+ from "ajv(?:-formats)?\/dist\/[^"]+\.js";/m); + expect(code).toMatch(/export default validate\d+;/); + expect(code).not.toMatch(/\brequire\s*\(|\bmodule\.exports\b|createRequire/); + + const validatorName: string = code.match(/export default (validate\d+);/)![1]; + const result: string = execFileSync( + process.execPath, + [ + '--input-type=module', + '--eval', + `${code} +const valid = { exampleString: 'hello', exampleArray: [], exampleLink: 'https://example.com' }; +const invalid = { ...valid, exampleLink: 'not a URI' }; +if (!${validatorName}(valid)) throw new Error('Valid input rejected'); +if (${validatorName}(invalid)) throw new Error('Invalid URI accepted'); +if (!${validatorName}.errors?.some(error => error.keyword === 'format')) { + throw new Error('Missing format error'); +}` + ], + { cwd: path.resolve(__dirname, '../..'), encoding: 'utf8' } + ); + expect(result).toBe(''); + } + ); + test('resolves local $ref schemas and accepts vendor keywords', () => { const standaloneSchema: JsonSchema = loadStandaloneSchema( `${__dirname}/test-data/test-schemas/test-schema-standalone.schema.json` From 69fc5a5e62f5f5e03188feec1dc06e039f0adff2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:49:29 +0000 Subject: [PATCH 06/16] Cover static validator imports and ESM outputs Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --- .../test/PrecompiledSchemas.test.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts b/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts index 658301b411a..5da7d9e4118 100644 --- a/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts +++ b/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts @@ -39,11 +39,20 @@ describe('Heft built-in schemas', () => { .readdirSync(schemaFolder) .filter((fileName: string) => fileName.endsWith('.schema.json')); expect(schemaFiles.length).toBeGreaterThan(0); + expect(schemaFiles.length).toBe(validators.size); + let esmImportCount: number = 0; for (const schemaFile of schemaFiles) { const schemaPath: string = path.join(schemaFolder, schemaFile); const validatorPath: string = schemaPath.replace(/\.schema\.json$/, '.validator.js'); expect(fs.existsSync(validatorPath)).toBe(true); + const esmValidatorPath: string = validatorPath.replace('lib-commonjs', 'lib-esm'); + const esmCode: string = fs.readFileSync(esmValidatorPath, 'utf8'); + if (/import .* from "ajv(?:-formats)?\/dist\//.test(esmCode)) { + esmImportCount++; + } + expect(esmCode).toMatch(/export default validate\d+;/); + expect(esmCode).not.toContain('require('); const validator: IJsonSchemaCompiledValidator = validators.get(schemaFile)!; expect(typeof validator).toBe('function'); @@ -62,6 +71,7 @@ describe('Heft built-in schemas', () => { expect(() => compiledSchema.validateObject(example, 'test.json')).toThrow(); } } + expect(esmImportCount).toBeGreaterThan(0); } }); @@ -72,7 +82,12 @@ describe('Heft built-in schemas', () => { packageRoot, '@rushstack/heft' ); - expect(plugins.tryGetTaskPluginDefinitionByName('copy-files-plugin')).toBeDefined(); + const manifest: { taskPlugins: { pluginName: string }[] } = JSON.parse( + fs.readFileSync(path.join(packageRoot, 'heft-plugin.json'), 'utf8') + ); + for (const plugin of manifest.taskPlugins) { + expect(plugins.tryGetTaskPluginDefinitionByName(plugin.pluginName)).toBeDefined(); + } expect(fromFileSpy).not.toHaveBeenCalled(); } finally { fromFileSpy.mockRestore(); From 33047b60434e7573600b4a59b0ae20c5111224a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:50:21 +0000 Subject: [PATCH 07/16] Address static validator review findings Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --- .../configuration/test/PrecompiledSchemas.test.ts | 2 +- .../heft/src/plugins/PrecompileJsonSchemasPlugin.ts | 13 ++++++++++++- libraries/node-core-library/src/JsonSchema.ts | 4 +++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts b/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts index 5da7d9e4118..020219a763a 100644 --- a/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts +++ b/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts @@ -71,8 +71,8 @@ describe('Heft built-in schemas', () => { expect(() => compiledSchema.validateObject(example, 'test.json')).toThrow(); } } - expect(esmImportCount).toBeGreaterThan(0); } + expect(esmImportCount).toBeGreaterThan(0); }); it('loads every built-in plugin option schema without compiling a JSON schema', async () => { diff --git a/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts b/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts index a956101fdfb..d6d3dd0a288 100644 --- a/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts +++ b/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts @@ -54,9 +54,20 @@ async function precompileSchemasAsync( // The Heft package builds itself using the previously published version of Heft. Until that // version includes this plugin, its run-script-plugin invokes this entry point after TypeScript emits it. export async function runAsync(options: IRunScriptOptions): Promise { + const { sourceFolder, destinationFolders, esmDestinationFolders } = options.scriptOptions; + if ( + typeof sourceFolder !== 'string' || + !Array.isArray(destinationFolders) || + !destinationFolders.every((folder: unknown) => typeof folder === 'string') || + (esmDestinationFolders !== undefined && + (!Array.isArray(esmDestinationFolders) || + !esmDestinationFolders.every((folder: unknown) => typeof folder === 'string'))) + ) { + throw new Error('Invalid schema precompilation script options'); + } const count: number = await precompileSchemasAsync( options.heftConfiguration.buildFolderPath, - options.scriptOptions as unknown as IPrecompileJsonSchemasPluginOptions, + { sourceFolder, destinationFolders, esmDestinationFolders }, options.runOptions ); options.heftTaskSession.logger.terminal.writeLine(`Precompiled ${count} JSON schemas.`); diff --git a/libraries/node-core-library/src/JsonSchema.ts b/libraries/node-core-library/src/JsonSchema.ts index a121073d8db..77f32cca452 100644 --- a/libraries/node-core-library/src/JsonSchema.ts +++ b/libraries/node-core-library/src/JsonSchema.ts @@ -284,6 +284,7 @@ export class JsonSchema { /** * Wraps an already compiled AJV-compatible validator without loading or compiling a schema at runtime. * The validator must expose AJV's `errors` property after a failed validation. + * @public */ public static fromCompiledValidator(validator: IJsonSchemaCompiledValidator, shortName?: string): JsonSchema { const schema: JsonSchema = new JsonSchema(); @@ -299,6 +300,7 @@ export class JsonSchema { * static imports. The consuming project must have `ajv` and `ajv-formats` * resolvable from the location of the generated module. * Custom format validator functions cannot be serialized into standalone code. + * @public */ public static compileStandaloneCodeFromFile( filename: string, @@ -334,7 +336,7 @@ export class JsonSchema { } const importStatements: string = [...imports] .map(([specifier, importedName]) => { - const esmSpecifier: string = path.extname(specifier) ? specifier : `${specifier}.js`; + const esmSpecifier: string = specifier.endsWith('.js') ? specifier : `${specifier}.js`; return `import ${importedName} from ${JSON.stringify(esmSpecifier)};`; }) .join('\n'); From d5c258e50a6cce54ff43f7dd2f00e2144cf1232d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:51:41 +0000 Subject: [PATCH 08/16] Preserve schema names and support ESM-only generation Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --- .../heft/src/plugins/PrecompileJsonSchemasPlugin.ts | 12 +++++++----- .../precompile-json-schemas-options.schema.json | 7 ++++++- apps/heft/src/utilities/CoreConfigFiles.ts | 13 ++++++------- libraries/heft-config-file/README.md | 2 ++ .../heft-config-file/src/ConfigurationFileBase.ts | 9 ++++++++- 5 files changed, 29 insertions(+), 14 deletions(-) diff --git a/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts b/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts index d6d3dd0a288..c77232ee61f 100644 --- a/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts +++ b/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts @@ -12,7 +12,7 @@ import type { IRunScriptOptions } from './RunScriptPlugin'; interface IPrecompileJsonSchemasPluginOptions { sourceFolder: string; - destinationFolders: string[]; + destinationFolders?: string[]; esmDestinationFolders?: string[]; } @@ -34,7 +34,7 @@ async function precompileSchemasAsync( .relative(sourceFolder, schemaPath) .replace(/\.schema\.json$/, '.validator.js'); const validatorCode: string = JsonSchema.compileStandaloneCodeFromFile(schemaPath); - for (const destinationFolder of options.destinationFolders) { + for (const destinationFolder of options.destinationFolders ?? []) { const destinationPath: string = path.resolve(buildFolderPath, destinationFolder, relativePath); await FileSystem.writeFileAsync(destinationPath, validatorCode, { ensureFolderExists: true }); } @@ -57,11 +57,13 @@ export async function runAsync(options: IRunScriptOptions): Promise { const { sourceFolder, destinationFolders, esmDestinationFolders } = options.scriptOptions; if ( typeof sourceFolder !== 'string' || - !Array.isArray(destinationFolders) || - !destinationFolders.every((folder: unknown) => typeof folder === 'string') || + (destinationFolders !== undefined && + (!Array.isArray(destinationFolders) || + !destinationFolders.every((folder: unknown) => typeof folder === 'string'))) || (esmDestinationFolders !== undefined && (!Array.isArray(esmDestinationFolders) || - !esmDestinationFolders.every((folder: unknown) => typeof folder === 'string'))) + !esmDestinationFolders.every((folder: unknown) => typeof folder === 'string'))) || + !(destinationFolders?.length || esmDestinationFolders?.length) ) { throw new Error('Invalid schema precompilation script options'); } diff --git a/apps/heft/src/schemas/precompile-json-schemas-options.schema.json b/apps/heft/src/schemas/precompile-json-schemas-options.schema.json index 25b9137465f..a2a49f31842 100644 --- a/apps/heft/src/schemas/precompile-json-schemas-options.schema.json +++ b/apps/heft/src/schemas/precompile-json-schemas-options.schema.json @@ -3,7 +3,11 @@ "title": "Precompile JSON Schemas Plugin Options", "type": "object", "additionalProperties": false, - "required": ["sourceFolder", "destinationFolders"], + "required": ["sourceFolder"], + "anyOf": [ + { "required": ["destinationFolders"] }, + { "required": ["esmDestinationFolders"] } + ], "properties": { "sourceFolder": { "type": "string", @@ -17,6 +21,7 @@ }, "esmDestinationFolders": { "type": "array", + "minItems": 1, "items": { "type": "string" }, "description": "Folders for generated ESM validator modules, relative to the project root." } diff --git a/apps/heft/src/utilities/CoreConfigFiles.ts b/apps/heft/src/utilities/CoreConfigFiles.ts index 5ec54bb57c2..cd1f4f3d4fa 100644 --- a/apps/heft/src/utilities/CoreConfigFiles.ts +++ b/apps/heft/src/utilities/CoreConfigFiles.ts @@ -9,11 +9,7 @@ import { PathResolutionMethod, type IJsonPathMetadataResolverOptions } from '@rushstack/heft-config-file'; -import { - Import, - PackageJsonLookup, - InternalError -} from '@rushstack/node-core-library'; +import { Import, PackageJsonLookup, InternalError } from '@rushstack/node-core-library'; import type { ITerminal } from '@rushstack/terminal'; import type { IRigConfig } from '@rushstack/rig-package'; @@ -119,6 +115,7 @@ export class CoreConfigFiles { _heftConfigFileLoader = new ProjectConfigurationFile({ projectRelativeFilePath: CoreConfigFiles.heftConfigurationProjectRelativeFilePath, jsonSchemaValidator: heftSchemaValidator, + jsonSchemaValidatorName: 'heft.schema.json', propertyInheritanceDefaults: { array: { inheritanceType: InheritanceType.append }, object: { inheritanceType: InheritanceType.merge } @@ -165,7 +162,8 @@ export class CoreConfigFiles { const legacyConfigFileLoader: ProjectConfigurationFile = new ProjectConfigurationFile({ projectRelativeFilePath: CoreConfigFiles.heftConfigurationProjectRelativeFilePath, - jsonSchemaValidator: legacySchemaValidator + jsonSchemaValidator: legacySchemaValidator, + jsonSchemaValidatorName: 'heft-legacy.schema.json' }); await legacyConfigFileLoader.loadConfigurationFileForProjectAsync(terminal, projectPath, rigConfig); } catch (e2) { @@ -238,7 +236,8 @@ export class CoreConfigFiles { // eslint-disable-next-line require-atomic-updates _nodeServiceConfigurationLoader = new ProjectConfigurationFile({ projectRelativeFilePath: CoreConfigFiles.nodeServiceConfigurationProjectRelativeFilePath, - jsonSchemaValidator: nodeServiceSchemaValidator + jsonSchemaValidator: nodeServiceSchemaValidator, + jsonSchemaValidatorName: 'node-service.schema.json' }); } diff --git a/libraries/heft-config-file/README.md b/libraries/heft-config-file/README.md index 67e4415bcd4..66ebfff414b 100644 --- a/libraries/heft-config-file/README.md +++ b/libraries/heft-config-file/README.md @@ -220,6 +220,8 @@ To generate validators, add a build task using `@rushstack/heft`'s and `esmDestinationFolders` pointing at the ESM output folders. The plugin emits `.validator.js` modules beside each published schema (for example, `my-plugin.schema.json` becomes `my-plugin.validator.js`). +At least one destination list is required. Each schema is compiled independently; +references must resolve within the same schema file rather than to another file. Publish both the JSON schema (for editors and tooling) and its generated validator, and reference the latter through `jsonSchemaValidator` at runtime. Generated validators depend on `ajv` and `ajv-formats`; publishing projects should declare diff --git a/libraries/heft-config-file/src/ConfigurationFileBase.ts b/libraries/heft-config-file/src/ConfigurationFileBase.ts index f244ef9ea90..b07e44fad4e 100644 --- a/libraries/heft-config-file/src/ConfigurationFileBase.ts +++ b/libraries/heft-config-file/src/ConfigurationFileBase.ts @@ -374,6 +374,7 @@ export type IConfigurationFileOptionsWithJsonSchemaFilePath< jsonSchemaPath: string; jsonSchemaObject?: never; jsonSchemaValidator?: never; + jsonSchemaValidatorName?: never; }; /** @@ -390,6 +391,7 @@ export type IConfigurationFileOptionsWithJsonSchemaObject< jsonSchemaObject: object; jsonSchemaPath?: never; jsonSchemaValidator?: never; + jsonSchemaValidatorName?: never; }; /** @@ -404,6 +406,10 @@ export type IConfigurationFileOptionsWithJsonSchemaValidator< * A precompiled JSON schema validator generated during the build. */ jsonSchemaValidator: IJsonSchemaCompiledValidator; + /** + * The schema filename to use in diagnostics for this validator. + */ + jsonSchemaValidatorName?: string; jsonSchemaPath?: never; jsonSchemaObject?: never; }; @@ -476,13 +482,14 @@ export abstract class ConfigurationFileBase JsonSchema.fromCompiledValidator(jsonSchemaValidator); + this.#getSchema = () => JsonSchema.fromCompiledValidator(jsonSchemaValidator, jsonSchemaValidatorName); } else if (jsonSchemaObject) { this.#getSchema = () => JsonSchema.fromLoadedObject(jsonSchemaObject); } else { From c23e215657639eb796a1f8c7f4f0275f66e32376 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:52:27 +0000 Subject: [PATCH 09/16] Skip unused validator formats during precompilation Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --- apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts b/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts index c77232ee61f..fe2a643d331 100644 --- a/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts +++ b/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts @@ -33,10 +33,12 @@ async function precompileSchemasAsync( const relativePath: string = path .relative(sourceFolder, schemaPath) .replace(/\.schema\.json$/, '.validator.js'); - const validatorCode: string = JsonSchema.compileStandaloneCodeFromFile(schemaPath); - for (const destinationFolder of options.destinationFolders ?? []) { - const destinationPath: string = path.resolve(buildFolderPath, destinationFolder, relativePath); - await FileSystem.writeFileAsync(destinationPath, validatorCode, { ensureFolderExists: true }); + if (options.destinationFolders?.length) { + const validatorCode: string = JsonSchema.compileStandaloneCodeFromFile(schemaPath); + for (const destinationFolder of options.destinationFolders) { + const destinationPath: string = path.resolve(buildFolderPath, destinationFolder, relativePath); + await FileSystem.writeFileAsync(destinationPath, validatorCode, { ensureFolderExists: true }); + } } if (options.esmDestinationFolders?.length) { const esmCode: string = JsonSchema.compileStandaloneCodeFromFile(schemaPath, undefined, { From 37ca26621e345169402e9addcd1904b3789c94cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:53:22 +0000 Subject: [PATCH 10/16] Tighten ESM output and schema build diagnostics Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --- .../test/PrecompiledSchemas.test.ts | 4 +- .../plugins/PrecompileJsonSchemasPlugin.ts | 40 +++++++++++-------- libraries/node-core-library/src/JsonSchema.ts | 1 + 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts b/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts index 020219a763a..2828101c697 100644 --- a/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts +++ b/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts @@ -19,8 +19,8 @@ import setEnvironmentValidator from '../../schemas/set-environment-variables-plu import { HeftPluginConfiguration } from '../HeftPluginConfiguration'; describe('Heft built-in schemas', () => { - const schemaFolder: string = path.resolve(__dirname, '../../schemas'); const packageRoot: string = path.resolve(__dirname, '../../..'); + const schemaFolder: string = path.join(packageRoot, 'lib-commonjs/schemas'); const validators: ReadonlyMap = new Map([ ['anything.schema.json', anythingValidator], ['copy-files-options.schema.json', copyFilesValidator], @@ -46,7 +46,7 @@ describe('Heft built-in schemas', () => { const schemaPath: string = path.join(schemaFolder, schemaFile); const validatorPath: string = schemaPath.replace(/\.schema\.json$/, '.validator.js'); expect(fs.existsSync(validatorPath)).toBe(true); - const esmValidatorPath: string = validatorPath.replace('lib-commonjs', 'lib-esm'); + const esmValidatorPath: string = path.join(packageRoot, 'lib-esm/schemas', path.basename(validatorPath)); const esmCode: string = fs.readFileSync(esmValidatorPath, 'utf8'); if (/import .* from "ajv(?:-formats)?\/dist\//.test(esmCode)) { esmImportCount++; diff --git a/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts b/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts index fe2a643d331..9cf0128cc1b 100644 --- a/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts +++ b/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts @@ -30,24 +30,32 @@ async function precompileSchemasAsync( }); for (const schemaPath of schemaPaths) { - const relativePath: string = path - .relative(sourceFolder, schemaPath) - .replace(/\.schema\.json$/, '.validator.js'); - if (options.destinationFolders?.length) { - const validatorCode: string = JsonSchema.compileStandaloneCodeFromFile(schemaPath); - for (const destinationFolder of options.destinationFolders) { - const destinationPath: string = path.resolve(buildFolderPath, destinationFolder, relativePath); - await FileSystem.writeFileAsync(destinationPath, validatorCode, { ensureFolderExists: true }); + try { + const relativePath: string = path + .relative(sourceFolder, schemaPath) + .replace(/\.schema\.json$/, '.validator.js'); + if (options.destinationFolders?.length) { + const validatorCode: string = JsonSchema.compileStandaloneCodeFromFile(schemaPath); + for (const destinationFolder of options.destinationFolders) { + const destinationPath: string = path.resolve(buildFolderPath, destinationFolder, relativePath); + await FileSystem.writeFileAsync(destinationPath, validatorCode, { ensureFolderExists: true }); + } } - } - if (options.esmDestinationFolders?.length) { - const esmCode: string = JsonSchema.compileStandaloneCodeFromFile(schemaPath, undefined, { - moduleFormat: 'esm' - }); - for (const destinationFolder of options.esmDestinationFolders) { - const destinationPath: string = path.resolve(buildFolderPath, destinationFolder, relativePath); - await FileSystem.writeFileAsync(destinationPath, esmCode, { ensureFolderExists: true }); + if (options.esmDestinationFolders?.length) { + const esmCode: string = JsonSchema.compileStandaloneCodeFromFile(schemaPath, undefined, { + moduleFormat: 'esm' + }); + for (const destinationFolder of options.esmDestinationFolders) { + const destinationPath: string = path.resolve(buildFolderPath, destinationFolder, relativePath); + await FileSystem.writeFileAsync(destinationPath, esmCode, { ensureFolderExists: true }); + } } + } catch (error) { + throw new Error( + `Failed to precompile "${schemaPath}". External $ref dependencies must be supplied explicitly ` + + 'and are not supported by this plugin.', + { cause: error } + ); } } return schemaPaths.length; diff --git a/libraries/node-core-library/src/JsonSchema.ts b/libraries/node-core-library/src/JsonSchema.ts index 77f32cca452..4aa18a98fa6 100644 --- a/libraries/node-core-library/src/JsonSchema.ts +++ b/libraries/node-core-library/src/JsonSchema.ts @@ -322,6 +322,7 @@ export class JsonSchema { const imports: Map = new Map(); const esmCode: string = code + .replace(/^(['"])use strict\1;?/, '') .replace(/\bmodule\.exports(?:\.default)?\s*=\s*validate\d+;/g, '') .replace(runtimeImportPattern, (_match: string, _quote: string, specifier: string) => { let importedName: string | undefined = imports.get(specifier); From 53861176382943bfd5ac40255498f4e0cfeb4af0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:54:13 +0000 Subject: [PATCH 11/16] Keep underlying schema build errors visible Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --- apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts b/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts index 9cf0128cc1b..85e56f33785 100644 --- a/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts +++ b/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts @@ -52,8 +52,8 @@ async function precompileSchemasAsync( } } catch (error) { throw new Error( - `Failed to precompile "${schemaPath}". External $ref dependencies must be supplied explicitly ` + - 'and are not supported by this plugin.', + `Failed to precompile "${schemaPath}": ${error instanceof Error ? error.message : String(error)}. ` + + 'If this is an unresolved $ref, external references are not supported by this plugin.', { cause: error } ); } From e69e974023a2a683bcf825bd3dff19a344281c96 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:55:26 +0000 Subject: [PATCH 12/16] Exercise AJV ESM helper interop and contextual errors Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --- .../plugins/PrecompileJsonSchemasPlugin.ts | 7 ++++-- .../src/test/JsonSchema.test.ts | 22 ++++++++++++++----- .../test-schema-string-length.schema.json | 8 +++++++ 3 files changed, 30 insertions(+), 7 deletions(-) create mode 100644 libraries/node-core-library/src/test/test-data/test-schemas/test-schema-string-length.schema.json diff --git a/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts b/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts index 85e56f33785..0f9ab1d4c6c 100644 --- a/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts +++ b/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts @@ -51,9 +51,12 @@ async function precompileSchemasAsync( } } } catch (error) { + const message: string = error instanceof Error ? error.message : String(error); + const referenceHint: string = /can't resolve reference|missing ref/i.test(message) + ? ' External $ref dependencies are not supported by this plugin.' + : ''; throw new Error( - `Failed to precompile "${schemaPath}": ${error instanceof Error ? error.message : String(error)}. ` + - 'If this is an unresolved $ref, external references are not supported by this plugin.', + `Failed to precompile "${schemaPath}": ${message}${referenceHint}`, { cause: error } ); } diff --git a/libraries/node-core-library/src/test/JsonSchema.test.ts b/libraries/node-core-library/src/test/JsonSchema.test.ts index fb5cf9d45d0..53d8d2e3541 100644 --- a/libraries/node-core-library/src/test/JsonSchema.test.ts +++ b/libraries/node-core-library/src/test/JsonSchema.test.ts @@ -204,7 +204,11 @@ describe(JsonSchema.name, () => { } ); - test.each([DRAFT_04_SCHEMA_PATH, DRAFT_07_SCHEMA_PATH])( + test.each([ + DRAFT_04_SCHEMA_PATH, + DRAFT_07_SCHEMA_PATH, + `${__dirname}/test-data/test-schemas/test-schema-string-length.schema.json` + ])( 'emits executable ESM with static AJV imports for %s', (filename) => { const code: string = JsonSchema.compileStandaloneCodeFromFile(filename, undefined, { @@ -223,10 +227,18 @@ describe(JsonSchema.name, () => { `${code} const valid = { exampleString: 'hello', exampleArray: [], exampleLink: 'https://example.com' }; const invalid = { ...valid, exampleLink: 'not a URI' }; -if (!${validatorName}(valid)) throw new Error('Valid input rejected'); -if (${validatorName}(invalid)) throw new Error('Invalid URI accepted'); -if (!${validatorName}.errors?.some(error => error.keyword === 'format')) { - throw new Error('Missing format error'); +const sample = ${JSON.stringify('test-schema-string-length.schema.json')}; +if (${JSON.stringify(filename)}.endsWith(sample)) { + if (!${validatorName}({ value: 'ab' })) throw new Error('Valid string rejected'); + if (${validatorName}({ value: 'a' })) throw new Error('Short string accepted'); + if (!${validatorName}.errors?.some(error => error.keyword === 'minLength')) throw new Error('Missing length error'); +} else { + if (!${validatorName}(valid)) throw new Error('Valid input rejected'); + if (${validatorName}(invalid)) throw new Error('Invalid URI accepted'); + if (!${validatorName}.errors?.some(error => error.keyword === 'format')) throw new Error('Missing format error'); + if (${validatorName}({ ...valid, exampleUniqueObjectArray: [{ field2: 'a' }, { field2: 'a' }] })) { + throw new Error('Duplicate objects accepted'); + } }` ], { cwd: path.resolve(__dirname, '../..'), encoding: 'utf8' } diff --git a/libraries/node-core-library/src/test/test-data/test-schemas/test-schema-string-length.schema.json b/libraries/node-core-library/src/test/test-data/test-schemas/test-schema-string-length.schema.json new file mode 100644 index 00000000000..340b66d23e5 --- /dev/null +++ b/libraries/node-core-library/src/test/test-data/test-schemas/test-schema-string-length.schema.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "value": { "type": "string", "minLength": 2 } + }, + "required": ["value"] +} From 73d84b715f8dc7d330d72409408fc68fc474c2b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:56:13 +0000 Subject: [PATCH 13/16] Check generated export consistency and script options Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --- apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts | 5 ++++- libraries/node-core-library/src/JsonSchema.ts | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts b/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts index 0f9ab1d4c6c..bdf3422104a 100644 --- a/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts +++ b/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts @@ -78,7 +78,10 @@ export async function runAsync(options: IRunScriptOptions): Promise { !esmDestinationFolders.every((folder: unknown) => typeof folder === 'string'))) || !(destinationFolders?.length || esmDestinationFolders?.length) ) { - throw new Error('Invalid schema precompilation script options'); + throw new Error( + 'Schema precompilation requires a string sourceFolder and at least one non-empty ' + + 'destinationFolders or esmDestinationFolders string array.' + ); } const count: number = await precompileSchemasAsync( options.heftConfiguration.buildFolderPath, diff --git a/libraries/node-core-library/src/JsonSchema.ts b/libraries/node-core-library/src/JsonSchema.ts index 4aa18a98fa6..6d9642aeae6 100644 --- a/libraries/node-core-library/src/JsonSchema.ts +++ b/libraries/node-core-library/src/JsonSchema.ts @@ -319,6 +319,12 @@ export class JsonSchema { if (!validatorExport) { throw new Error('Unexpected AJV standalone output: missing validator export'); } + const exportedValidators: string[] = [ + ...code.matchAll(/\bmodule\.exports(?:\.default)?\s*=\s*(validate\d+);/g) + ].map((match: RegExpMatchArray) => match[1]); + if (exportedValidators.some((exportName: string) => exportName !== validatorExport[1])) { + throw new Error('Unexpected AJV standalone output: inconsistent validator exports'); + } const imports: Map = new Map(); const esmCode: string = code From f43c0f60bc2b338e132cabfc249e03c0ca51d85c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:57:06 +0000 Subject: [PATCH 14/16] Match bundled validators by schema path Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --- .../src/configuration/HeftPluginDefinition.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/heft/src/configuration/HeftPluginDefinition.ts b/apps/heft/src/configuration/HeftPluginDefinition.ts index 57fb76c9bfc..2b28562d6a9 100644 --- a/apps/heft/src/configuration/HeftPluginDefinition.ts +++ b/apps/heft/src/configuration/HeftPluginDefinition.ts @@ -198,11 +198,11 @@ export interface IHeftPluginDefinitionOptions { } const BUILT_IN_OPTIONS_VALIDATORS: ReadonlyMap = new Map([ - ['copy-files-plugin', copyFilesValidator], - ['delete-files-plugin', deleteFilesValidator], - ['precompile-json-schemas-plugin', precompileSchemasValidator], - ['run-script-plugin', runScriptValidator], - ['set-environment-variables-plugin', setEnvironmentVariablesValidator] + ['copy-files-options.schema.json', copyFilesValidator], + ['delete-files-options.schema.json', deleteFilesValidator], + ['precompile-json-schemas-options.schema.json', precompileSchemasValidator], + ['run-script-options.schema.json', runScriptValidator], + ['set-environment-variables-plugin.schema.json', setEnvironmentVariablesValidator] ]); export abstract class HeftPluginDefinitionBase { @@ -236,8 +236,9 @@ export abstract class HeftPluginDefinitionBase { options.heftPluginDefinitionJson.optionsSchema ); const builtInValidator: IJsonSchemaCompiledValidator | undefined = - options.packageName === '@rushstack/heft' - ? BUILT_IN_OPTIONS_VALIDATORS.get(this.pluginName) + options.packageName === '@rushstack/heft' && + path.dirname(resolvedSchemaPath) === path.resolve(options.packageRoot, 'lib-commonjs/schemas') + ? BUILT_IN_OPTIONS_VALIDATORS.get(path.basename(resolvedSchemaPath)) : undefined; this.#optionsSchema = builtInValidator ? JsonSchema.fromCompiledValidator(builtInValidator, path.basename(resolvedSchemaPath)) From fb38fd461e019635ae125c65368f72f4b6cccab5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:19:56 +0000 Subject: [PATCH 15/16] Track deep AJV runtime integration Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --- apps/heft/package.json | 2 - .../test/PrecompiledSchemas.test.ts | 2 +- .../config/subspaces/default/pnpm-lock.yaml | 6 -- libraries/heft-config-file/README.md | 5 +- libraries/node-core-library/src/JsonSchema.ts | 63 ++++++++++++------- .../src/JsonSchemaRuntime.ts | 29 +++++++++ .../src/test/JsonSchema.test.ts | 19 +++++- 7 files changed, 89 insertions(+), 37 deletions(-) create mode 100644 libraries/node-core-library/src/JsonSchemaRuntime.ts diff --git a/apps/heft/package.json b/apps/heft/package.json index 542f2244dff..6ea0e7a32bb 100644 --- a/apps/heft/package.json +++ b/apps/heft/package.json @@ -67,8 +67,6 @@ "@rushstack/terminal": "workspace:*", "@rushstack/ts-command-line": "workspace:*", "@types/tapable": "1.0.6", - "ajv": "~8.20.0", - "ajv-formats": "~3.0.1", "fast-glob": "~3.3.1", "git-repo-info": "~2.1.0", "ignore": "~5.1.6", diff --git a/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts b/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts index 2828101c697..000fd0bb0b0 100644 --- a/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts +++ b/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts @@ -48,7 +48,7 @@ describe('Heft built-in schemas', () => { expect(fs.existsSync(validatorPath)).toBe(true); const esmValidatorPath: string = path.join(packageRoot, 'lib-esm/schemas', path.basename(validatorPath)); const esmCode: string = fs.readFileSync(esmValidatorPath, 'utf8'); - if (/import .* from "ajv(?:-formats)?\/dist\//.test(esmCode)) { + if (/import .* from "@rushstack\/node-core-library\/lib\/JsonSchemaRuntime"/.test(esmCode)) { esmImportCount++; } expect(esmCode).toMatch(/export default validate\d+;/); diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 6cafe99b840..2d4e19dd39e 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -162,12 +162,6 @@ importers: '@types/tapable': specifier: 1.0.6 version: 1.0.6 - ajv: - specifier: ~8.20.0 - version: 8.20.0 - ajv-formats: - specifier: ~3.0.1 - version: 3.0.1(ajv@8.20.0) fast-glob: specifier: ~3.3.1 version: 3.3.3 diff --git a/libraries/heft-config-file/README.md b/libraries/heft-config-file/README.md index 66ebfff414b..ef0dd793e62 100644 --- a/libraries/heft-config-file/README.md +++ b/libraries/heft-config-file/README.md @@ -224,8 +224,9 @@ At least one destination list is required. Each schema is compiled independently references must resolve within the same schema file rather than to another file. Publish both the JSON schema (for editors and tooling) and its generated validator, and reference the latter through `jsonSchemaValidator` at runtime. Generated -validators depend on `ajv` and `ajv-formats`; publishing projects should declare -them as dependencies. +validators import `@rushstack/node-core-library/lib/JsonSchemaRuntime` for +AJV runtime helpers, so publishing projects only need their existing dependency +on `@rushstack/node-core-library`. Schema validation runs **after** all inheritance merging, so the schema describes the shape of the final merged result. diff --git a/libraries/node-core-library/src/JsonSchema.ts b/libraries/node-core-library/src/JsonSchema.ts index 6d9642aeae6..7ff3e5fa946 100644 --- a/libraries/node-core-library/src/JsonSchema.ts +++ b/libraries/node-core-library/src/JsonSchema.ts @@ -199,6 +199,19 @@ const JSON_SCHEMA_URL_PREFIX_BY_JSON_SCHEMA_VERSION: Map = new Map([ + ['ajv/dist/runtime/equal', 'equal'], + ['ajv/dist/runtime/parseJson', 'parseJson'], + ['ajv/dist/runtime/quote', 'quote'], + ['ajv/dist/runtime/timestamp', 'timestamp'], + ['ajv/dist/runtime/ucs2length', 'ucs2length'], + ['ajv/dist/runtime/uri', 'uri'], + ['ajv/dist/runtime/validation_error', 'validationError'], + ['ajv-formats/dist/formats', 'formats'], + ['ajv-formats/dist/limit', 'limit'] +]); + /** * Helper function to determine the json-schema version to target for validation. */ @@ -296,9 +309,9 @@ export class JsonSchema { /** * Compiles a schema file into standalone AJV code. * @remarks - * CommonJS output (the default) uses literal `require` calls; ESM output uses - * static imports. The consuming project must have `ajv` and `ajv-formats` - * resolvable from the location of the generated module. + * CommonJS output (the default) uses a literal `require` call; ESM output uses + * a static import from `@rushstack/node-core-library/lib/JsonSchemaRuntime`. + * The consuming project only needs a dependency on `@rushstack/node-core-library`. * Custom format validator functions cannot be serialized into standalone code. * @public */ @@ -314,42 +327,46 @@ export class JsonSchema { const { ajv, validator } = schema._compileValidator({ code: { source: true } }); const code: string = standaloneCode(ajv, validator); const runtimeImportPattern: RegExp = /\brequire\((['"])((?:ajv|ajv-formats)\/[^'"]+)\1\)/g; + const runtimeExports: Set = new Set(); + const rewrittenCode: string = code.replace( + runtimeImportPattern, + (_match: string, _quote: string, specifier: string) => { + const exportName: string | undefined = STANDALONE_RUNTIME_EXPORTS.get(specifier); + if (!exportName) { + throw new Error(`Unsupported AJV standalone runtime dependency: ${specifier}`); + } + runtimeExports.add(exportName); + return codeOptions?.moduleFormat === 'esm' + ? exportName + : `require(${JSON.stringify(STANDALONE_RUNTIME_MODULE)}).${exportName}`; + } + ); if (codeOptions?.moduleFormat === 'esm') { - const validatorExport: RegExpMatchArray | null = code.match(/\bmodule\.exports\s*=\s*(validate\d+);/); + const validatorExport: RegExpMatchArray | null = rewrittenCode.match( + /\bmodule\.exports\s*=\s*(validate\d+);/ + ); if (!validatorExport) { throw new Error('Unexpected AJV standalone output: missing validator export'); } const exportedValidators: string[] = [ - ...code.matchAll(/\bmodule\.exports(?:\.default)?\s*=\s*(validate\d+);/g) + ...rewrittenCode.matchAll(/\bmodule\.exports(?:\.default)?\s*=\s*(validate\d+);/g) ].map((match: RegExpMatchArray) => match[1]); if (exportedValidators.some((exportName: string) => exportName !== validatorExport[1])) { throw new Error('Unexpected AJV standalone output: inconsistent validator exports'); } - const imports: Map = new Map(); - const esmCode: string = code + const esmCode: string = rewrittenCode .replace(/^(['"])use strict\1;?/, '') - .replace(/\bmodule\.exports(?:\.default)?\s*=\s*validate\d+;/g, '') - .replace(runtimeImportPattern, (_match: string, _quote: string, specifier: string) => { - let importedName: string | undefined = imports.get(specifier); - if (!importedName) { - importedName = `__rushstackAjvRuntime${imports.size}`; - imports.set(specifier, importedName); - } - return importedName; - }); + .replace(/\bmodule\.exports(?:\.default)?\s*=\s*validate\d+;/g, ''); if (/\brequire\s*\(|\bmodule\.exports\b/.test(esmCode)) { throw new Error('Unexpected AJV standalone output: unsupported CommonJS dependency or export'); } - const importStatements: string = [...imports] - .map(([specifier, importedName]) => { - const esmSpecifier: string = specifier.endsWith('.js') ? specifier : `${specifier}.js`; - return `import ${importedName} from ${JSON.stringify(esmSpecifier)};`; - }) - .join('\n'); + const importStatements: string = runtimeExports.size + ? `import { ${[...runtimeExports].join(', ')} } from ${JSON.stringify(STANDALONE_RUNTIME_MODULE)};` + : ''; return `${importStatements}\n${esmCode}\nexport default ${validatorExport[1]};\n`; } - return code; + return rewrittenCode; } /** diff --git a/libraries/node-core-library/src/JsonSchemaRuntime.ts b/libraries/node-core-library/src/JsonSchemaRuntime.ts new file mode 100644 index 00000000000..ba1a1a49f41 --- /dev/null +++ b/libraries/node-core-library/src/JsonSchemaRuntime.ts @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as equalModule from 'ajv/dist/runtime/equal'; +import * as parseJsonModule from 'ajv/dist/runtime/parseJson'; +import * as quoteModule from 'ajv/dist/runtime/quote'; +import * as timestampModule from 'ajv/dist/runtime/timestamp'; +import * as ucs2lengthModule from 'ajv/dist/runtime/ucs2length'; +import * as uriModule from 'ajv/dist/runtime/uri'; +import * as validationErrorModule from 'ajv/dist/runtime/validation_error'; +import * as formatsModule from 'ajv-formats/dist/formats'; +import * as limitModule from 'ajv-formats/dist/limit'; + +// Node's ESM import of a CommonJS module wraps its exports under `default`. +// Normalize that wrapper so generated standalone code sees the same shape as require(). +function _asCommonJsExports(imported: T): T { + const defaultExport: unknown = (imported as unknown as { default?: unknown }).default; + return defaultExport && typeof defaultExport === 'object' ? (defaultExport as T) : imported; +} + +export const equal: typeof equalModule = _asCommonJsExports(equalModule); +export const parseJson: typeof parseJsonModule = _asCommonJsExports(parseJsonModule); +export const quote: typeof quoteModule = _asCommonJsExports(quoteModule); +export const timestamp: typeof timestampModule = _asCommonJsExports(timestampModule); +export const ucs2length: typeof ucs2lengthModule = _asCommonJsExports(ucs2lengthModule); +export const uri: typeof uriModule = _asCommonJsExports(uriModule); +export const validationError: typeof validationErrorModule = _asCommonJsExports(validationErrorModule); +export const formats: typeof formatsModule = _asCommonJsExports(formatsModule); +export const limit: typeof limitModule = _asCommonJsExports(limitModule); diff --git a/libraries/node-core-library/src/test/JsonSchema.test.ts b/libraries/node-core-library/src/test/JsonSchema.test.ts index 53d8d2e3541..c9683b7415f 100644 --- a/libraries/node-core-library/src/test/JsonSchema.test.ts +++ b/libraries/node-core-library/src/test/JsonSchema.test.ts @@ -13,6 +13,7 @@ import { type IJsonSchemaCompiledValidator, type IJsonSchemaErrorInfo } from '../JsonSchema'; +import * as JsonSchemaRuntime from '../JsonSchemaRuntime'; const SCHEMA_PATH: string = `${__dirname}/test-data/test-schemas/test-schema.schema.json`; const DRAFT_04_SCHEMA_PATH: string = `${__dirname}/test-data/test-schemas/test-schema-draft-04.schema.json`; @@ -154,6 +155,12 @@ describe(JsonSchema.name, () => { }); describe(JsonSchema.compileStandaloneCodeFromFile.name, () => { + test('deep runtime exports match the AJV helpers used by standalone code', () => { + expect(JsonSchemaRuntime.equal.default).toBe(require('ajv/dist/runtime/equal').default); + expect(JsonSchemaRuntime.ucs2length.default).toBe(require('ajv/dist/runtime/ucs2length').default); + expect(JsonSchemaRuntime.formats.fullFormats).toBe(require('ajv-formats/dist/formats').fullFormats); + }); + test('defaults to CommonJS output', () => { const defaultCode: string = JsonSchema.compileStandaloneCodeFromFile(DRAFT_07_SCHEMA_PATH); expect( @@ -162,7 +169,10 @@ describe(JsonSchema.name, () => { }) ).toBe(defaultCode); expect(defaultCode).toContain('module.exports'); - expect(defaultCode).toMatch(/require\(["']ajv(?:-formats)?\/dist\/[^"']+["']\)/); + expect(defaultCode).toMatch( + /require\(["']@rushstack\/node-core-library\/lib\/JsonSchemaRuntime["']\)/ + ); + expect(defaultCode).not.toMatch(/require\(["']ajv(?:-formats)?\//); expect(defaultCode).not.toContain('createRequire'); }); @@ -175,7 +185,7 @@ describe(JsonSchema.name, () => { expect(code).not.toContain('__rushstackAjvRuntimeRequire'); const generatedModule: { exports?: IJsonSchemaCompiledValidator } = {}; const standaloneRequire = (specifier: string): unknown => { - if (!/^ajv(?:-formats)?\/dist\//.test(specifier)) { + if (specifier !== '@rushstack/node-core-library/lib/JsonSchemaRuntime') { throw new Error(`Unexpected dependency in generated code: ${specifier}`); } return require(specifier); @@ -214,7 +224,10 @@ describe(JsonSchema.name, () => { const code: string = JsonSchema.compileStandaloneCodeFromFile(filename, undefined, { moduleFormat: 'esm' }); - expect(code).toMatch(/^import __rushstackAjvRuntime\d+ from "ajv(?:-formats)?\/dist\/[^"]+\.js";/m); + expect(code).toMatch( + /^import \{ [^}]+ \} from "@rushstack\/node-core-library\/lib\/JsonSchemaRuntime";/m + ); + expect(code).not.toMatch(/from "ajv(?:-formats)?\//); expect(code).toMatch(/export default validate\d+;/); expect(code).not.toMatch(/\brequire\s*\(|\bmodule\.exports\b|createRequire/); From d3d00d3558c9c0deb1e65a26207ae3808514ea8b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:21:39 +0000 Subject: [PATCH 16/16] Preserve AJV URI helper default export Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --- libraries/node-core-library/src/JsonSchemaRuntime.ts | 7 ++++++- libraries/node-core-library/src/test/JsonSchema.test.ts | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/libraries/node-core-library/src/JsonSchemaRuntime.ts b/libraries/node-core-library/src/JsonSchemaRuntime.ts index ba1a1a49f41..5a833bd47a9 100644 --- a/libraries/node-core-library/src/JsonSchemaRuntime.ts +++ b/libraries/node-core-library/src/JsonSchemaRuntime.ts @@ -15,7 +15,12 @@ import * as limitModule from 'ajv-formats/dist/limit'; // Normalize that wrapper so generated standalone code sees the same shape as require(). function _asCommonJsExports(imported: T): T { const defaultExport: unknown = (imported as unknown as { default?: unknown }).default; - return defaultExport && typeof defaultExport === 'object' ? (defaultExport as T) : imported; + return defaultExport && + typeof defaultExport === 'object' && + '__esModule' in defaultExport && + defaultExport.__esModule === true + ? (defaultExport as T) + : imported; } export const equal: typeof equalModule = _asCommonJsExports(equalModule); diff --git a/libraries/node-core-library/src/test/JsonSchema.test.ts b/libraries/node-core-library/src/test/JsonSchema.test.ts index c9683b7415f..3cbf201867d 100644 --- a/libraries/node-core-library/src/test/JsonSchema.test.ts +++ b/libraries/node-core-library/src/test/JsonSchema.test.ts @@ -158,6 +158,7 @@ describe(JsonSchema.name, () => { test('deep runtime exports match the AJV helpers used by standalone code', () => { expect(JsonSchemaRuntime.equal.default).toBe(require('ajv/dist/runtime/equal').default); expect(JsonSchemaRuntime.ucs2length.default).toBe(require('ajv/dist/runtime/ucs2length').default); + expect(JsonSchemaRuntime.uri.default).toBe(require('ajv/dist/runtime/uri').default); expect(JsonSchemaRuntime.formats.fullFormats).toBe(require('ajv-formats/dist/formats').fullFormats); });