From 9b13a1a31698b18102437d4d85ed7826146981dd Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Mon, 16 Feb 2026 19:21:00 -0800 Subject: [PATCH 01/14] Add pattern to ignore tmpclaude working directory files --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 93d80cd12cf..6f360c85f74 100644 --- a/.gitignore +++ b/.gitignore @@ -136,4 +136,4 @@ test-results/ # Claude Code local configuration .claude/*.local.json - +**/tmpclaude-*-cwd From 2c9e6cb1273d77daec79af90638259a278e1866b Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 18 Feb 2026 08:56:57 -0800 Subject: [PATCH 02/14] Add x-tsdoc-tag support to the JSON schema typings plugin and node-core-library. The plugin now reads the `x-tsdoc-tag` property from schema JSON files and injects the specified TSDoc release tag into generated `.d.ts` declarations. `JsonSchema` registers `x-tsdoc-tag` as a known AJV keyword so that strict mode validation does not reject schema files containing this property. --- ...e-json-schema-plugin_2026-02-18-16-54.json | 10 +++++ ...e-json-schema-plugin_2026-02-18-16-54.json | 10 +++++ .../src/JsonSchemaTypingsGenerator.ts | 45 +++++++++++++++++-- libraries/node-core-library/src/JsonSchema.ts | 4 ++ 4 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 common/changes/@rushstack/heft-json-schema-typings-plugin/use-json-schema-plugin_2026-02-18-16-54.json create mode 100644 common/changes/@rushstack/node-core-library/use-json-schema-plugin_2026-02-18-16-54.json diff --git a/common/changes/@rushstack/heft-json-schema-typings-plugin/use-json-schema-plugin_2026-02-18-16-54.json b/common/changes/@rushstack/heft-json-schema-typings-plugin/use-json-schema-plugin_2026-02-18-16-54.json new file mode 100644 index 00000000000..a49387134f5 --- /dev/null +++ b/common/changes/@rushstack/heft-json-schema-typings-plugin/use-json-schema-plugin_2026-02-18-16-54.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft-json-schema-typings-plugin", + "comment": "Add support for the `x-tsdoc-tag` custom property in JSON schema files. When present (e.g. `\"x-tsdoc-tag\": \"@beta\"`), the specified TSDoc release tag is injected into the generated `.d.ts` declarations, allowing API Extractor to apply the correct release level when these types are re-exported from package entry points.", + "type": "minor" + } + ], + "packageName": "@rushstack/heft-json-schema-typings-plugin" +} \ No newline at end of file diff --git a/common/changes/@rushstack/node-core-library/use-json-schema-plugin_2026-02-18-16-54.json b/common/changes/@rushstack/node-core-library/use-json-schema-plugin_2026-02-18-16-54.json new file mode 100644 index 00000000000..5fcc37744cc --- /dev/null +++ b/common/changes/@rushstack/node-core-library/use-json-schema-plugin_2026-02-18-16-54.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/node-core-library", + "comment": "Register `x-tsdoc-tag` as a known keyword in `JsonSchema` so that AJV strict mode does not reject JSON schema files that include this custom metadata property.", + "type": "minor" + } + ], + "packageName": "@rushstack/node-core-library" +} \ No newline at end of file diff --git a/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.ts b/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.ts index 8b0cadc7e23..507003876e3 100644 --- a/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.ts +++ b/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.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 { readFile } from 'node:fs/promises'; import path from 'node:path'; import { compileFromFile } from 'json-schema-to-typescript'; @@ -9,6 +10,31 @@ import { type ITypingsGeneratorBaseOptions, TypingsGenerator } from '@rushstack/ interface IJsonSchemaTypingsGeneratorBaseOptions extends ITypingsGeneratorBaseOptions {} +/** + * Adds a TSDoc release tag (e.g. `@public`, `@beta`) to all exported declarations + * in generated typings. + * + * `json-schema-to-typescript` does not emit release tags, so this function + * post-processes the output to ensure API Extractor treats these types with the + * correct release tag when they are re-exported from package entry points. + */ +function _addTsDocTagToExports(typingsData: string, tag: string): string { + // Normalize line endings for consistent regex matching. + // The TypingsGenerator base class applies NewlineKind.OsDefault when writing. + const normalized: string = typingsData.replace(/\r\n/g, '\n'); + + // Pass 1: For exports preceded by an existing JSDoc comment, insert + // the tag before the closing "*/". + let result: string = normalized.replace(/ \*\/\n(export )/g, ` *\n * ${tag}\n */\n$1`); + + // Pass 2: For exports NOT preceded by a JSDoc comment, insert a new + // JSDoc block. The negative lookbehind ensures Pass 1 + // results are not double-matched. + result = result.replace(/(? '', // eslint-disable-next-line @typescript-eslint/naming-convention - parseAndGenerateTypings: async (fileContents: string, filePath: string): Promise => - await compileFromFile(filePath, { + parseAndGenerateTypings: async (fileContents: string, filePath: string): Promise => { + const typings: string = await compileFromFile(filePath, { // The typings generator adds its own banner comment bannerComment: '', cwd: path.dirname(filePath) - }) + }); + + // Check for an "x-tsdoc-tag" property in the schema (e.g. "@public" or "@beta"). + // If present, inject the tag into JSDoc comments for all exported declarations. + const schemaContent: string = await readFile(filePath, 'utf-8'); + const schemaJson: { 'x-tsdoc-tag'?: string } = JSON.parse(schemaContent); + const tsdocTag: string | undefined = schemaJson['x-tsdoc-tag']; + + if (tsdocTag) { + return _addTsDocTagToExports(typings, tsdocTag); + } + + return typings; + } }); } } diff --git a/libraries/node-core-library/src/JsonSchema.ts b/libraries/node-core-library/src/JsonSchema.ts index 2ca3f227190..ce3c5bff65f 100644 --- a/libraries/node-core-library/src/JsonSchema.ts +++ b/libraries/node-core-library/src/JsonSchema.ts @@ -335,6 +335,10 @@ export class JsonSchema { } } + // Register the "x-tsdoc-tag" custom keyword used by @rushstack/heft-json-schema-typings-plugin + // so that AJV's strict mode does not reject it as an unknown keyword. + validator.addKeyword('x-tsdoc-tag'); + // Enable json-schema format validation // https://ajv.js.org/packages/ajv-formats.html addFormats(validator); From e451d48d7aac812ecc6ac18aa2054b6272018edb Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 18 Feb 2026 10:10:09 -0800 Subject: [PATCH 03/14] Add unit tests for x-tsdoc-tag support in node-core-library and heft-json-schema-typings-plugin. --- .../package.json | 3 +- .../src/JsonSchemaTypingsGenerator.ts | 27 +------- .../src/TsDocTagHelpers.ts | 27 ++++++++ .../TsDocTagHelpers.test.ts.snap | 61 +++++++++++++++++++ .../src/test/JsonSchema.test.ts | 17 ++++++ 5 files changed, 109 insertions(+), 26 deletions(-) create mode 100644 heft-plugins/heft-json-schema-typings-plugin/src/TsDocTagHelpers.ts create mode 100644 heft-plugins/heft-json-schema-typings-plugin/src/test/__snapshots__/TsDocTagHelpers.test.ts.snap diff --git a/heft-plugins/heft-json-schema-typings-plugin/package.json b/heft-plugins/heft-json-schema-typings-plugin/package.json index 2a555c3fb77..880e0daf3b7 100644 --- a/heft-plugins/heft-json-schema-typings-plugin/package.json +++ b/heft-plugins/heft-json-schema-typings-plugin/package.json @@ -12,7 +12,8 @@ "scripts": { "build": "heft test --clean", "start": "heft build-watch", - "_phase:build": "heft run --only build -- --clean" + "_phase:build": "heft run --only build -- --clean", + "_phase:test": "heft run --only test -- --clean" }, "peerDependencies": { "@rushstack/heft": "1.1.14" diff --git a/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.ts b/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.ts index 507003876e3..20424f57935 100644 --- a/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.ts +++ b/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.ts @@ -8,32 +8,9 @@ import { compileFromFile } from 'json-schema-to-typescript'; import { type ITypingsGeneratorBaseOptions, TypingsGenerator } from '@rushstack/typings-generator'; -interface IJsonSchemaTypingsGeneratorBaseOptions extends ITypingsGeneratorBaseOptions {} - -/** - * Adds a TSDoc release tag (e.g. `@public`, `@beta`) to all exported declarations - * in generated typings. - * - * `json-schema-to-typescript` does not emit release tags, so this function - * post-processes the output to ensure API Extractor treats these types with the - * correct release tag when they are re-exported from package entry points. - */ -function _addTsDocTagToExports(typingsData: string, tag: string): string { - // Normalize line endings for consistent regex matching. - // The TypingsGenerator base class applies NewlineKind.OsDefault when writing. - const normalized: string = typingsData.replace(/\r\n/g, '\n'); - - // Pass 1: For exports preceded by an existing JSDoc comment, insert - // the tag before the closing "*/". - let result: string = normalized.replace(/ \*\/\n(export )/g, ` *\n * ${tag}\n */\n$1`); +import { _addTsDocTagToExports } from './TsDocTagHelpers'; - // Pass 2: For exports NOT preceded by a JSDoc comment, insert a new - // JSDoc block. The negative lookbehind ensures Pass 1 - // results are not double-matched. - result = result.replace(/(? { }); }); + test('accepts a schema containing the x-tsdoc-tag custom keyword', () => { + const schemaWithTsDocTag: JsonSchema = JsonSchema.fromLoadedObject( + { + title: 'Test x-tsdoc-tag', + 'x-tsdoc-tag': '@beta', + type: 'object', + properties: { + name: { type: 'string' } + }, + additionalProperties: false, + required: ['name'] + }, + { schemaVersion: 'draft-07' } + ); + expect(() => schemaWithTsDocTag.validateObject({ name: 'hello' }, '')).not.toThrow(); + }); + test('successfully applies custom formats', () => { const schemaWithCustomFormat = JsonSchema.fromLoadedObject( { From 67d6f7093001bafe5e01fa6fbf465a49deaea74f Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 18 Feb 2026 10:38:06 -0800 Subject: [PATCH 04/14] fixup! Add unit tests for x-tsdoc-tag support in node-core-library and heft-json-schema-typings-plugin. --- .../src/test/TsDocTagHelpers.test.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 heft-plugins/heft-json-schema-typings-plugin/src/test/TsDocTagHelpers.test.ts diff --git a/heft-plugins/heft-json-schema-typings-plugin/src/test/TsDocTagHelpers.test.ts b/heft-plugins/heft-json-schema-typings-plugin/src/test/TsDocTagHelpers.test.ts new file mode 100644 index 00000000000..4204b5ced53 --- /dev/null +++ b/heft-plugins/heft-json-schema-typings-plugin/src/test/TsDocTagHelpers.test.ts @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { _addTsDocTagToExports } from '../TsDocTagHelpers'; + +describe(_addTsDocTagToExports.name, () => { + test('injects tag into an existing JSDoc comment before an export', () => { + const input: string = ['/**', ' * A description.', ' */', 'export type Foo = {};'].join('\n'); + + expect(_addTsDocTagToExports(input, '@beta')).toMatchSnapshot(); + }); + + test('creates a new JSDoc block for an export without a preceding comment', () => { + const input: string = 'export type Foo = {};'; + + expect(_addTsDocTagToExports(input, '@public')).toMatchSnapshot(); + }); + + test('handles multiple exports with and without JSDoc comments', () => { + const input: string = [ + '/**', + ' * First type.', + ' */', + 'export type Foo = {};', + '', + 'export type Bar = {};' + ].join('\n'); + + expect(_addTsDocTagToExports(input, '@beta')).toMatchSnapshot(); + }); + + test('normalizes CRLF line endings to LF', () => { + const input: string = '/**\r\n * A description.\r\n */\r\nexport type Foo = {};'; + + expect(_addTsDocTagToExports(input, '@beta')).toMatchSnapshot(); + }); + + test('does not double-tag an export that already has a JSDoc block', () => { + const input: string = [ + '/**', + ' * Already documented.', + ' */', + 'export interface IConfig {', + ' name: string;', + '}' + ].join('\n'); + + const result: string = _addTsDocTagToExports(input, '@public'); + + // The tag should appear exactly once + const tagOccurrences: number = (result.match(/@public/g) || []).length; + expect(tagOccurrences).toBe(1); + expect(result).toMatchSnapshot(); + }); + + test('does not modify non-export lines', () => { + const input: string = ['// A leading comment', 'const internal = 1;', '', 'export type Foo = {};'].join( + '\n' + ); + + expect(_addTsDocTagToExports(input, '@beta')).toMatchSnapshot(); + }); +}); From 55f71880bb1c2301e5548433441e247111e94f14 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 18 Feb 2026 10:40:07 -0800 Subject: [PATCH 05/14] [heft-json-schema-typings-plugin] Add snapshot unit tests for JsonSchemaTypingsGenerator - Replace compileFromFile with compile (reads file via TypingsGenerator base class) - Parse and strip x-tsdoc-tag from schema before passing to compile() - Pass format: false to skip prettier (avoids dynamic import issue in Jest) - Add jest.config.json with moduleNameMapper to stub prettier at load time - Add 3 snapshot tests: basic schema, x-tsdoc-tag injection, cross-file $ref - Add test fixture schemas in src/test/schemas/ --- .../config/jest.config.json | 6 ++ .../config/jestMocks/prettier.js | 6 ++ .../src/JsonSchemaTypingsGenerator.ts | 46 +++++++--- .../test/JsonSchemaTypingsGenerator.test.ts | 63 ++++++++++++++ .../JsonSchemaTypingsGenerator.test.ts.snap | 85 +++++++++++++++++++ .../src/test/schemas/basic.schema.json | 20 +++++ .../src/test/schemas/child.schema.json | 17 ++++ .../src/test/schemas/parent.schema.json | 22 +++++ .../test/schemas/with-tsdoc-tag.schema.json | 12 +++ 9 files changed, 263 insertions(+), 14 deletions(-) create mode 100644 heft-plugins/heft-json-schema-typings-plugin/config/jest.config.json create mode 100644 heft-plugins/heft-json-schema-typings-plugin/config/jestMocks/prettier.js create mode 100644 heft-plugins/heft-json-schema-typings-plugin/src/test/JsonSchemaTypingsGenerator.test.ts create mode 100644 heft-plugins/heft-json-schema-typings-plugin/src/test/__snapshots__/JsonSchemaTypingsGenerator.test.ts.snap create mode 100644 heft-plugins/heft-json-schema-typings-plugin/src/test/schemas/basic.schema.json create mode 100644 heft-plugins/heft-json-schema-typings-plugin/src/test/schemas/child.schema.json create mode 100644 heft-plugins/heft-json-schema-typings-plugin/src/test/schemas/parent.schema.json create mode 100644 heft-plugins/heft-json-schema-typings-plugin/src/test/schemas/with-tsdoc-tag.schema.json diff --git a/heft-plugins/heft-json-schema-typings-plugin/config/jest.config.json b/heft-plugins/heft-json-schema-typings-plugin/config/jest.config.json new file mode 100644 index 00000000000..7b2eb73199f --- /dev/null +++ b/heft-plugins/heft-json-schema-typings-plugin/config/jest.config.json @@ -0,0 +1,6 @@ +{ + "extends": "local-node-rig/profiles/default/config/jest.config.json", + "moduleNameMapper": { + "^prettier$": "/jestMocks/prettier.js" + } +} diff --git a/heft-plugins/heft-json-schema-typings-plugin/config/jestMocks/prettier.js b/heft-plugins/heft-json-schema-typings-plugin/config/jestMocks/prettier.js new file mode 100644 index 00000000000..c5d39cf2978 --- /dev/null +++ b/heft-plugins/heft-json-schema-typings-plugin/config/jestMocks/prettier.js @@ -0,0 +1,6 @@ +// Stub for prettier. json-schema-to-typescript eagerly require('prettier') at +// module load time. Prettier v3's CJS entry does a top-level dynamic import() +// which crashes inside Jest's VM sandbox on Node 22+. Since compile() is called +// with format: false, prettier is never invoked — this stub just prevents the +// module-load crash. +module.exports = {}; diff --git a/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.ts b/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.ts index 20424f57935..f6d1c572aa5 100644 --- a/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.ts +++ b/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.ts @@ -1,10 +1,9 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { readFile } from 'node:fs/promises'; -import path from 'node:path'; +import * as path from 'node:path'; -import { compileFromFile } from 'json-schema-to-typescript'; +import { compile } from 'json-schema-to-typescript'; import { type ITypingsGeneratorBaseOptions, TypingsGenerator } from '@rushstack/typings-generator'; @@ -12,29 +11,48 @@ import { _addTsDocTagToExports } from './TsDocTagHelpers'; interface IJsonSchemaTypingsGeneratorBaseOptions extends ITypingsGeneratorBaseOptions {} +const SCHEMA_FILE_EXTENSION: '.schema.json' = '.schema.json'; +const X_TSDOC_TAG_KEY: 'x-tsdoc-tag' = 'x-tsdoc-tag'; + +type Json4Schema = Parameters[0]; +interface IExtendedJson4Schema extends Json4Schema { + [X_TSDOC_TAG_KEY]?: string; +} + export class JsonSchemaTypingsGenerator extends TypingsGenerator { public constructor(options: IJsonSchemaTypingsGeneratorBaseOptions) { super({ ...options, - fileExtensions: ['.schema.json'], - // Don't bother reading the file contents, compileFromFile will read the file - readFile: () => '', + fileExtensions: [SCHEMA_FILE_EXTENSION], // eslint-disable-next-line @typescript-eslint/naming-convention - parseAndGenerateTypings: async (fileContents: string, filePath: string): Promise => { - const typings: string = await compileFromFile(filePath, { + parseAndGenerateTypings: async ( + fileContents: string, + filePath: string, + relativePath: string + ): Promise => { + const parsedFileContents: IExtendedJson4Schema = JSON.parse(fileContents); + const { [X_TSDOC_TAG_KEY]: tsdocTag, ...jsonSchemaWithoutTsDocTag } = parsedFileContents; + + // Use the absolute directory of the schema file so that cross-file $ref + // (e.g. { "$ref": "./other.schema.json" }) resolves correctly. + const dirname: string = path.dirname(filePath); + const filenameWithoutExtension: string = filePath.slice( + dirname.length + 1, + -SCHEMA_FILE_EXTENSION.length + ); + let typings: string = await compile(jsonSchemaWithoutTsDocTag, filenameWithoutExtension, { // The typings generator adds its own banner comment bannerComment: '', - cwd: path.dirname(filePath) + cwd: dirname, + // The generated typings are machine-produced .d.ts files that do not need + // prettier formatting. + format: false }); // Check for an "x-tsdoc-tag" property in the schema (e.g. "@public" or "@beta"). // If present, inject the tag into JSDoc comments for all exported declarations. - const schemaContent: string = await readFile(filePath, 'utf-8'); - const schemaJson: { 'x-tsdoc-tag'?: string } = JSON.parse(schemaContent); - const tsdocTag: string | undefined = schemaJson['x-tsdoc-tag']; - if (tsdocTag) { - return _addTsDocTagToExports(typings, tsdocTag); + typings = _addTsDocTagToExports(typings, tsdocTag); } return typings; diff --git a/heft-plugins/heft-json-schema-typings-plugin/src/test/JsonSchemaTypingsGenerator.test.ts b/heft-plugins/heft-json-schema-typings-plugin/src/test/JsonSchemaTypingsGenerator.test.ts new file mode 100644 index 00000000000..2283f7f7e52 --- /dev/null +++ b/heft-plugins/heft-json-schema-typings-plugin/src/test/JsonSchemaTypingsGenerator.test.ts @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { FileSystem, PackageJsonLookup } from '@rushstack/node-core-library'; + +import { JsonSchemaTypingsGenerator } from '../JsonSchemaTypingsGenerator'; + +const projectFolder: string = PackageJsonLookup.instance.tryGetPackageFolderFor(__dirname)!; +const schemasFolder: string = `${__dirname}/schemas`; +const outputFolder: string = `${projectFolder}/temp/test-typings-output`; + +async function readGeneratedTypings(schemaRelativePath: string): Promise { + const outputPath: string = `${outputFolder}/${schemaRelativePath}.d.ts`; + return await FileSystem.readFileAsync(outputPath); +} + +describe('JsonSchemaTypingsGenerator', () => { + beforeEach(async () => { + await FileSystem.ensureEmptyFolderAsync(outputFolder); + }); + + it('generates typings for a basic object schema', async () => { + const generator = new JsonSchemaTypingsGenerator({ + srcFolder: schemasFolder, + generatedTsFolder: outputFolder + }); + + await generator.generateTypingsAsync(['basic.schema.json']); + const typings: string = await readGeneratedTypings('basic.schema.json'); + expect(typings).toMatchSnapshot(); + }); + + it('injects x-tsdoc-tag into exported declarations', async () => { + const generator = new JsonSchemaTypingsGenerator({ + srcFolder: schemasFolder, + generatedTsFolder: outputFolder + }); + + await generator.generateTypingsAsync(['with-tsdoc-tag.schema.json']); + const typings: string = await readGeneratedTypings('with-tsdoc-tag.schema.json'); + expect(typings).toMatchSnapshot(); + expect(typings).toContain('@public'); + }); + + it('resolves cross-file $ref between schema files', async () => { + const generator = new JsonSchemaTypingsGenerator({ + srcFolder: schemasFolder, + generatedTsFolder: outputFolder + }); + + await generator.generateTypingsAsync(['child.schema.json', 'parent.schema.json']); + const [parentTypings, childTypings]: string[] = await Promise.all([ + readGeneratedTypings('parent.schema.json'), + readGeneratedTypings('child.schema.json') + ]); + + expect(childTypings).toMatchSnapshot('child output'); + expect(parentTypings).toMatchSnapshot('parent output'); + + // The parent typings should reference the child type + expect(parentTypings).toContain('ChildType'); + }); +}); diff --git a/heft-plugins/heft-json-schema-typings-plugin/src/test/__snapshots__/JsonSchemaTypingsGenerator.test.ts.snap b/heft-plugins/heft-json-schema-typings-plugin/src/test/__snapshots__/JsonSchemaTypingsGenerator.test.ts.snap new file mode 100644 index 00000000000..d786082a1c2 --- /dev/null +++ b/heft-plugins/heft-json-schema-typings-plugin/src/test/__snapshots__/JsonSchemaTypingsGenerator.test.ts.snap @@ -0,0 +1,85 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`JsonSchemaTypingsGenerator generates typings for a basic object schema 1`] = ` +"// This file was generated by a tool. Modifying it will produce unexpected behavior + +export interface BasicConfig { +/** + * The name of the item. + */ +name: string +/** + * The number of items. + */ +count?: number +/** + * Whether the feature is enabled. + */ +enabled?: boolean +} +" +`; + +exports[`JsonSchemaTypingsGenerator injects x-tsdoc-tag into exported declarations 1`] = ` +"// This file was generated by a tool. Modifying it will produce unexpected behavior + +/** + * @public + */ +export interface PublicConfig { +/** + * A value. + */ +value?: string +} +" +`; + +exports[`JsonSchemaTypingsGenerator resolves cross-file $ref between schema files: child output 1`] = ` +"// This file was generated by a tool. Modifying it will produce unexpected behavior + +/** + * A reusable child type. + */ +export interface ChildType { +/** + * The name of the child. + */ +childName: string +/** + * The value of the child. + */ +childValue?: number +} +" +`; + +exports[`JsonSchemaTypingsGenerator resolves cross-file $ref between schema files: parent output 1`] = ` +"// This file was generated by a tool. Modifying it will produce unexpected behavior + +export interface ParentConfig { +/** + * A label for the parent. + */ +label: string +child: ChildType +/** + * A list of children. + */ +children?: ChildType[] +} +/** + * A reusable child type. + */ +export interface ChildType { +/** + * The name of the child. + */ +childName: string +/** + * The value of the child. + */ +childValue?: number +} +" +`; diff --git a/heft-plugins/heft-json-schema-typings-plugin/src/test/schemas/basic.schema.json b/heft-plugins/heft-json-schema-typings-plugin/src/test/schemas/basic.schema.json new file mode 100644 index 00000000000..05db112bce5 --- /dev/null +++ b/heft-plugins/heft-json-schema-typings-plugin/src/test/schemas/basic.schema.json @@ -0,0 +1,20 @@ +{ + "title": "Basic Config", + "type": "object", + "properties": { + "name": { + "description": "The name of the item.", + "type": "string" + }, + "count": { + "description": "The number of items.", + "type": "integer" + }, + "enabled": { + "description": "Whether the feature is enabled.", + "type": "boolean" + } + }, + "additionalProperties": false, + "required": ["name"] +} diff --git a/heft-plugins/heft-json-schema-typings-plugin/src/test/schemas/child.schema.json b/heft-plugins/heft-json-schema-typings-plugin/src/test/schemas/child.schema.json new file mode 100644 index 00000000000..dedcfaafabf --- /dev/null +++ b/heft-plugins/heft-json-schema-typings-plugin/src/test/schemas/child.schema.json @@ -0,0 +1,17 @@ +{ + "title": "Child Type", + "description": "A reusable child type.", + "type": "object", + "properties": { + "childName": { + "description": "The name of the child.", + "type": "string" + }, + "childValue": { + "description": "The value of the child.", + "type": "number" + } + }, + "additionalProperties": false, + "required": ["childName"] +} diff --git a/heft-plugins/heft-json-schema-typings-plugin/src/test/schemas/parent.schema.json b/heft-plugins/heft-json-schema-typings-plugin/src/test/schemas/parent.schema.json new file mode 100644 index 00000000000..63cd9884e19 --- /dev/null +++ b/heft-plugins/heft-json-schema-typings-plugin/src/test/schemas/parent.schema.json @@ -0,0 +1,22 @@ +{ + "title": "Parent Config", + "type": "object", + "properties": { + "label": { + "description": "A label for the parent.", + "type": "string" + }, + "child": { + "$ref": "./child.schema.json" + }, + "children": { + "description": "A list of children.", + "type": "array", + "items": { + "$ref": "./child.schema.json" + } + } + }, + "additionalProperties": false, + "required": ["label", "child"] +} diff --git a/heft-plugins/heft-json-schema-typings-plugin/src/test/schemas/with-tsdoc-tag.schema.json b/heft-plugins/heft-json-schema-typings-plugin/src/test/schemas/with-tsdoc-tag.schema.json new file mode 100644 index 00000000000..d3e195844e7 --- /dev/null +++ b/heft-plugins/heft-json-schema-typings-plugin/src/test/schemas/with-tsdoc-tag.schema.json @@ -0,0 +1,12 @@ +{ + "x-tsdoc-tag": "@public", + "title": "Public Config", + "type": "object", + "properties": { + "value": { + "description": "A value.", + "type": "string" + } + }, + "additionalProperties": false +} From aba62a34a63881a46e09af5c1b9915ed544e887f Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 18 Feb 2026 10:54:36 -0800 Subject: [PATCH 06/14] [heft-json-schema-typings-plugin] Make formatWithPrettier configurable - Add formatWithPrettier option to JsonSchemaTypingsGenerator constructor - Expose formatWithPrettier in the Heft plugin options and JSON schema - Defaults to false (skip prettier formatting) --- .../x-tsdoc-tag_2026-02-18-17-00.json | 10 ++++++++++ .../src/JsonSchemaTypingsGenerator.ts | 17 ++++++++++++----- .../src/JsonSchemaTypingsPlugin.ts | 6 ++++-- .../heft-json-schema-typings-plugin.schema.json | 5 +++++ 4 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 common/changes/@rushstack/heft-json-schema-typings-plugin/x-tsdoc-tag_2026-02-18-17-00.json diff --git a/common/changes/@rushstack/heft-json-schema-typings-plugin/x-tsdoc-tag_2026-02-18-17-00.json b/common/changes/@rushstack/heft-json-schema-typings-plugin/x-tsdoc-tag_2026-02-18-17-00.json new file mode 100644 index 00000000000..58a617e46c6 --- /dev/null +++ b/common/changes/@rushstack/heft-json-schema-typings-plugin/x-tsdoc-tag_2026-02-18-17-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft-json-schema-typings-plugin", + "comment": "Add a `formatWithPrettier` option (defaults to `false`) to skip prettier formatting of generated typings.", + "type": "minor" + } + ], + "packageName": "@rushstack/heft-json-schema-typings-plugin" +} diff --git a/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.ts b/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.ts index f6d1c572aa5..837c9599e32 100644 --- a/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.ts +++ b/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.ts @@ -9,7 +9,15 @@ import { type ITypingsGeneratorBaseOptions, TypingsGenerator } from '@rushstack/ import { _addTsDocTagToExports } from './TsDocTagHelpers'; -interface IJsonSchemaTypingsGeneratorBaseOptions extends ITypingsGeneratorBaseOptions {} +interface IJsonSchemaTypingsGeneratorBaseOptions extends ITypingsGeneratorBaseOptions { + /** + * If true, format generated typings with prettier. Defaults to false. + * + * @remarks + * Enabling this requires the `prettier` package to be installed as a dependency. + */ + formatWithPrettier?: boolean; +} const SCHEMA_FILE_EXTENSION: '.schema.json' = '.schema.json'; const X_TSDOC_TAG_KEY: 'x-tsdoc-tag' = 'x-tsdoc-tag'; @@ -21,8 +29,9 @@ interface IExtendedJson4Schema extends Json4Schema { export class JsonSchemaTypingsGenerator extends TypingsGenerator { public constructor(options: IJsonSchemaTypingsGeneratorBaseOptions) { + const { formatWithPrettier = false, ...otherOptions } = options; super({ - ...options, + ...otherOptions, fileExtensions: [SCHEMA_FILE_EXTENSION], // eslint-disable-next-line @typescript-eslint/naming-convention parseAndGenerateTypings: async ( @@ -44,9 +53,7 @@ export class JsonSchemaTypingsGenerator extends TypingsGenerator { // The typings generator adds its own banner comment bannerComment: '', cwd: dirname, - // The generated typings are machine-produced .d.ts files that do not need - // prettier formatting. - format: false + format: formatWithPrettier }); // Check for an "x-tsdoc-tag" property in the schema (e.g. "@public" or "@beta"). diff --git a/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsPlugin.ts b/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsPlugin.ts index 573e8cf5ce9..877cd041b26 100644 --- a/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsPlugin.ts +++ b/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsPlugin.ts @@ -18,6 +18,7 @@ const PLUGIN_NAME: 'json-schema-typings-plugin' = 'json-schema-typings-plugin'; export interface IJsonSchemaTypingsPluginOptions { srcFolder?: string; generatedTsFolders?: string[]; + formatWithPrettier?: boolean; } export default class JsonSchemaTypingsPlugin implements IHeftTaskPlugin { @@ -34,7 +35,7 @@ export default class JsonSchemaTypingsPlugin implements IHeftTaskPlugin { diff --git a/heft-plugins/heft-json-schema-typings-plugin/src/schemas/heft-json-schema-typings-plugin.schema.json b/heft-plugins/heft-json-schema-typings-plugin/src/schemas/heft-json-schema-typings-plugin.schema.json index 27716523997..4b54bc5464a 100644 --- a/heft-plugins/heft-json-schema-typings-plugin/src/schemas/heft-json-schema-typings-plugin.schema.json +++ b/heft-plugins/heft-json-schema-typings-plugin/src/schemas/heft-json-schema-typings-plugin.schema.json @@ -18,6 +18,11 @@ "items": { "type": "string" } + }, + + "formatWithPrettier": { + "type": "boolean", + "description": "If true, format generated typings with prettier. Defaults to false." } } } From 3b0985eb0d15bfbc52c1a25a7347d71b57933fcc Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 18 Feb 2026 11:25:38 -0800 Subject: [PATCH 07/14] Rename x-tsdoc-tag to x-tsdoc-release-tag and add validation - Rename x-tsdoc-tag to x-tsdoc-release-tag across all files - Add X_TSDOC_RELEASE_TAG_KEYWORD constant exported from node-core-library - Move validation into TsDocReleaseTagHelpers.ts (renamed from TsDocTagHelpers.ts) - Validate that x-tsdoc-release-tag value is a single lowercase word starting with @ - Add unit tests for _validateTsDocReleaseTag - Update changelog messages --- ...e-json-schema-plugin_2026-02-18-16-54.json | 2 +- ...e-json-schema-plugin_2026-02-18-16-54.json | 2 +- common/reviews/api/node-core-library.api.md | 3 + .../src/JsonSchemaTypingsGenerator.ts | 18 ++-- ...agHelpers.ts => TsDocReleaseTagHelpers.ts} | 19 ++++- .../test/JsonSchemaTypingsGenerator.test.ts | 2 +- .../src/test/TsDocReleaseTagHelpers.test.ts | 85 +++++++++++++++++++ .../src/test/TsDocTagHelpers.test.ts | 63 -------------- .../JsonSchemaTypingsGenerator.test.ts.snap | 85 ------------------- ...ap => TsDocReleaseTagHelpers.test.ts.snap} | 0 .../test/schemas/with-tsdoc-tag.schema.json | 2 +- libraries/node-core-library/src/JsonSchema.ts | 16 +++- libraries/node-core-library/src/index.ts | 3 +- .../src/test/JsonSchema.test.ts | 6 +- 14 files changed, 139 insertions(+), 167 deletions(-) rename heft-plugins/heft-json-schema-typings-plugin/src/{TsDocTagHelpers.ts => TsDocReleaseTagHelpers.ts} (62%) create mode 100644 heft-plugins/heft-json-schema-typings-plugin/src/test/TsDocReleaseTagHelpers.test.ts delete mode 100644 heft-plugins/heft-json-schema-typings-plugin/src/test/TsDocTagHelpers.test.ts delete mode 100644 heft-plugins/heft-json-schema-typings-plugin/src/test/__snapshots__/JsonSchemaTypingsGenerator.test.ts.snap rename heft-plugins/heft-json-schema-typings-plugin/src/test/__snapshots__/{TsDocTagHelpers.test.ts.snap => TsDocReleaseTagHelpers.test.ts.snap} (100%) diff --git a/common/changes/@rushstack/heft-json-schema-typings-plugin/use-json-schema-plugin_2026-02-18-16-54.json b/common/changes/@rushstack/heft-json-schema-typings-plugin/use-json-schema-plugin_2026-02-18-16-54.json index a49387134f5..f0158d77039 100644 --- a/common/changes/@rushstack/heft-json-schema-typings-plugin/use-json-schema-plugin_2026-02-18-16-54.json +++ b/common/changes/@rushstack/heft-json-schema-typings-plugin/use-json-schema-plugin_2026-02-18-16-54.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@rushstack/heft-json-schema-typings-plugin", - "comment": "Add support for the `x-tsdoc-tag` custom property in JSON schema files. When present (e.g. `\"x-tsdoc-tag\": \"@beta\"`), the specified TSDoc release tag is injected into the generated `.d.ts` declarations, allowing API Extractor to apply the correct release level when these types are re-exported from package entry points.", + "comment": "Add support for the `x-tsdoc-release-tag` custom property in JSON schema files. When present (e.g. `\"x-tsdoc-release-tag\": \"@beta\"`), the specified TSDoc release tag is injected into the generated `.d.ts` declarations, allowing API Extractor to apply the correct release level when these types are re-exported from package entry points.", "type": "minor" } ], diff --git a/common/changes/@rushstack/node-core-library/use-json-schema-plugin_2026-02-18-16-54.json b/common/changes/@rushstack/node-core-library/use-json-schema-plugin_2026-02-18-16-54.json index 5fcc37744cc..e37a1870ce3 100644 --- a/common/changes/@rushstack/node-core-library/use-json-schema-plugin_2026-02-18-16-54.json +++ b/common/changes/@rushstack/node-core-library/use-json-schema-plugin_2026-02-18-16-54.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@rushstack/node-core-library", - "comment": "Register `x-tsdoc-tag` as a known keyword in `JsonSchema` so that AJV strict mode does not reject JSON schema files that include this custom metadata property.", + "comment": "Register `x-tsdoc-release-tag` as a known keyword in `JsonSchema` so that AJV strict mode does not reject JSON schema files that include this custom metadata property.", "type": "minor" } ], diff --git a/common/reviews/api/node-core-library.api.md b/common/reviews/api/node-core-library.api.md index cc01c92c873..a2467a5572a 100644 --- a/common/reviews/api/node-core-library.api.md +++ b/common/reviews/api/node-core-library.api.md @@ -962,4 +962,7 @@ declare namespace User { } export { User } +// @beta +export const X_TSDOC_RELEASE_TAG_KEYWORD: 'x-tsdoc-release-tag'; + ``` diff --git a/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.ts b/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.ts index 837c9599e32..510b36de64b 100644 --- a/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.ts +++ b/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.ts @@ -6,8 +6,9 @@ import * as path from 'node:path'; import { compile } from 'json-schema-to-typescript'; import { type ITypingsGeneratorBaseOptions, TypingsGenerator } from '@rushstack/typings-generator'; +import { X_TSDOC_RELEASE_TAG_KEYWORD } from '@rushstack/node-core-library'; -import { _addTsDocTagToExports } from './TsDocTagHelpers'; +import { _addTsDocReleaseTagToExports, _validateTsDocReleaseTag } from './TsDocReleaseTagHelpers'; interface IJsonSchemaTypingsGeneratorBaseOptions extends ITypingsGeneratorBaseOptions { /** @@ -20,11 +21,10 @@ interface IJsonSchemaTypingsGeneratorBaseOptions extends ITypingsGeneratorBaseOp } const SCHEMA_FILE_EXTENSION: '.schema.json' = '.schema.json'; -const X_TSDOC_TAG_KEY: 'x-tsdoc-tag' = 'x-tsdoc-tag'; type Json4Schema = Parameters[0]; interface IExtendedJson4Schema extends Json4Schema { - [X_TSDOC_TAG_KEY]?: string; + [X_TSDOC_RELEASE_TAG_KEYWORD]?: string; } export class JsonSchemaTypingsGenerator extends TypingsGenerator { @@ -40,7 +40,8 @@ export class JsonSchemaTypingsGenerator extends TypingsGenerator { relativePath: string ): Promise => { const parsedFileContents: IExtendedJson4Schema = JSON.parse(fileContents); - const { [X_TSDOC_TAG_KEY]: tsdocTag, ...jsonSchemaWithoutTsDocTag } = parsedFileContents; + const { [X_TSDOC_RELEASE_TAG_KEYWORD]: tsdocReleaseTag, ...jsonSchemaWithoutReleaseTag } = + parsedFileContents; // Use the absolute directory of the schema file so that cross-file $ref // (e.g. { "$ref": "./other.schema.json" }) resolves correctly. @@ -49,17 +50,18 @@ export class JsonSchemaTypingsGenerator extends TypingsGenerator { dirname.length + 1, -SCHEMA_FILE_EXTENSION.length ); - let typings: string = await compile(jsonSchemaWithoutTsDocTag, filenameWithoutExtension, { + let typings: string = await compile(jsonSchemaWithoutReleaseTag, filenameWithoutExtension, { // The typings generator adds its own banner comment bannerComment: '', cwd: dirname, format: formatWithPrettier }); - // Check for an "x-tsdoc-tag" property in the schema (e.g. "@public" or "@beta"). + // Check for an "x-tsdoc-release-tag" property in the schema (e.g. "@public" or "@beta"). // If present, inject the tag into JSDoc comments for all exported declarations. - if (tsdocTag) { - typings = _addTsDocTagToExports(typings, tsdocTag); + if (tsdocReleaseTag) { + _validateTsDocReleaseTag(tsdocReleaseTag, relativePath); + typings = _addTsDocReleaseTagToExports(typings, tsdocReleaseTag); } return typings; diff --git a/heft-plugins/heft-json-schema-typings-plugin/src/TsDocTagHelpers.ts b/heft-plugins/heft-json-schema-typings-plugin/src/TsDocReleaseTagHelpers.ts similarity index 62% rename from heft-plugins/heft-json-schema-typings-plugin/src/TsDocTagHelpers.ts rename to heft-plugins/heft-json-schema-typings-plugin/src/TsDocReleaseTagHelpers.ts index be11902b49d..a5a3879fbe5 100644 --- a/heft-plugins/heft-json-schema-typings-plugin/src/TsDocTagHelpers.ts +++ b/heft-plugins/heft-json-schema-typings-plugin/src/TsDocReleaseTagHelpers.ts @@ -1,6 +1,23 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import { X_TSDOC_RELEASE_TAG_KEYWORD } from '@rushstack/node-core-library'; + +const RELEASE_TAG_PATTERN: RegExp = /^@[a-z]+$/; + +/** + * Validates that a string looks like a TSDoc release tag — a single lowercase + * word starting with `@` (e.g. `@public`, `@beta`, `@internal`). + */ +export function _validateTsDocReleaseTag(value: string, schemaPath: string): void { + if (!RELEASE_TAG_PATTERN.test(value)) { + throw new Error( + `Invalid ${X_TSDOC_RELEASE_TAG_KEYWORD} value ${JSON.stringify(value)} in ${schemaPath}. ` + + 'Expected a single lowercase word starting with "@" (e.g. "@public", "@beta").' + ); + } +} + /** * Adds a TSDoc release tag (e.g. `@public`, `@beta`) to all exported declarations * in generated typings. @@ -9,7 +26,7 @@ * post-processes the output to ensure API Extractor treats these types with the * correct release tag when they are re-exported from package entry points. */ -export function _addTsDocTagToExports(typingsData: string, tag: string): string { +export function _addTsDocReleaseTagToExports(typingsData: string, tag: string): string { // Normalize line endings for consistent regex matching. // The TypingsGenerator base class applies NewlineKind.OsDefault when writing. const normalized: string = typingsData.replace(/\r\n/g, '\n'); diff --git a/heft-plugins/heft-json-schema-typings-plugin/src/test/JsonSchemaTypingsGenerator.test.ts b/heft-plugins/heft-json-schema-typings-plugin/src/test/JsonSchemaTypingsGenerator.test.ts index 2283f7f7e52..219b514284b 100644 --- a/heft-plugins/heft-json-schema-typings-plugin/src/test/JsonSchemaTypingsGenerator.test.ts +++ b/heft-plugins/heft-json-schema-typings-plugin/src/test/JsonSchemaTypingsGenerator.test.ts @@ -30,7 +30,7 @@ describe('JsonSchemaTypingsGenerator', () => { expect(typings).toMatchSnapshot(); }); - it('injects x-tsdoc-tag into exported declarations', async () => { + it('injects x-tsdoc-release-tag into exported declarations', async () => { const generator = new JsonSchemaTypingsGenerator({ srcFolder: schemasFolder, generatedTsFolder: outputFolder diff --git a/heft-plugins/heft-json-schema-typings-plugin/src/test/TsDocReleaseTagHelpers.test.ts b/heft-plugins/heft-json-schema-typings-plugin/src/test/TsDocReleaseTagHelpers.test.ts new file mode 100644 index 00000000000..ee9a7e55d14 --- /dev/null +++ b/heft-plugins/heft-json-schema-typings-plugin/src/test/TsDocReleaseTagHelpers.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { _addTsDocReleaseTagToExports, _validateTsDocReleaseTag } from '../TsDocReleaseTagHelpers'; + +describe(_addTsDocReleaseTagToExports.name, () => { + test('injects tag into an existing JSDoc comment before an export', () => { + const input: string = ['/**', ' * A description.', ' */', 'export type Foo = {};'].join('\n'); + + expect(_addTsDocReleaseTagToExports(input, '@beta')).toMatchSnapshot(); + }); + + test('creates a new JSDoc block for an export without a preceding comment', () => { + const input: string = 'export type Foo = {};'; + + expect(_addTsDocReleaseTagToExports(input, '@public')).toMatchSnapshot(); + }); + + test('handles multiple exports with and without JSDoc comments', () => { + const input: string = [ + '/**', + ' * First type.', + ' */', + 'export type Foo = {};', + '', + 'export type Bar = {};' + ].join('\n'); + + expect(_addTsDocReleaseTagToExports(input, '@beta')).toMatchSnapshot(); + }); + + test('normalizes CRLF line endings to LF', () => { + const input: string = '/**\r\n * A description.\r\n */\r\nexport type Foo = {};'; + + expect(_addTsDocReleaseTagToExports(input, '@beta')).toMatchSnapshot(); + }); + + test('does not double-tag an export that already has a JSDoc block', () => { + const input: string = [ + '/**', + ' * Already documented.', + ' */', + 'export interface IConfig {', + ' name: string;', + '}' + ].join('\n'); + + const result: string = _addTsDocReleaseTagToExports(input, '@public'); + + // The tag should appear exactly once + const tagOccurrences: number = (result.match(/@public/g) || []).length; + expect(tagOccurrences).toBe(1); + expect(result).toMatchSnapshot(); + }); + + test('does not modify non-export lines', () => { + const input: string = ['// A leading comment', 'const internal = 1;', '', 'export type Foo = {};'].join( + '\n' + ); + + expect(_addTsDocReleaseTagToExports(input, '@beta')).toMatchSnapshot(); + }); +}); + +describe(_validateTsDocReleaseTag.name, () => { + test('accepts valid release tags', () => { + expect(() => _validateTsDocReleaseTag('@public', 'test.schema.json')).not.toThrow(); + expect(() => _validateTsDocReleaseTag('@beta', 'test.schema.json')).not.toThrow(); + expect(() => _validateTsDocReleaseTag('@alpha', 'test.schema.json')).not.toThrow(); + expect(() => _validateTsDocReleaseTag('@internal', 'test.schema.json')).not.toThrow(); + }); + + test('rejects invalid release tags', () => { + expect(() => _validateTsDocReleaseTag('public', 'test.schema.json')).toThrow( + /Invalid x-tsdoc-release-tag/ + ); + expect(() => _validateTsDocReleaseTag('@Public', 'test.schema.json')).toThrow( + /Invalid x-tsdoc-release-tag/ + ); + expect(() => _validateTsDocReleaseTag('@two words', 'test.schema.json')).toThrow( + /Invalid x-tsdoc-release-tag/ + ); + expect(() => _validateTsDocReleaseTag('', 'test.schema.json')).toThrow(/Invalid x-tsdoc-release-tag/); + }); +}); diff --git a/heft-plugins/heft-json-schema-typings-plugin/src/test/TsDocTagHelpers.test.ts b/heft-plugins/heft-json-schema-typings-plugin/src/test/TsDocTagHelpers.test.ts deleted file mode 100644 index 4204b5ced53..00000000000 --- a/heft-plugins/heft-json-schema-typings-plugin/src/test/TsDocTagHelpers.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. -// See LICENSE in the project root for license information. - -import { _addTsDocTagToExports } from '../TsDocTagHelpers'; - -describe(_addTsDocTagToExports.name, () => { - test('injects tag into an existing JSDoc comment before an export', () => { - const input: string = ['/**', ' * A description.', ' */', 'export type Foo = {};'].join('\n'); - - expect(_addTsDocTagToExports(input, '@beta')).toMatchSnapshot(); - }); - - test('creates a new JSDoc block for an export without a preceding comment', () => { - const input: string = 'export type Foo = {};'; - - expect(_addTsDocTagToExports(input, '@public')).toMatchSnapshot(); - }); - - test('handles multiple exports with and without JSDoc comments', () => { - const input: string = [ - '/**', - ' * First type.', - ' */', - 'export type Foo = {};', - '', - 'export type Bar = {};' - ].join('\n'); - - expect(_addTsDocTagToExports(input, '@beta')).toMatchSnapshot(); - }); - - test('normalizes CRLF line endings to LF', () => { - const input: string = '/**\r\n * A description.\r\n */\r\nexport type Foo = {};'; - - expect(_addTsDocTagToExports(input, '@beta')).toMatchSnapshot(); - }); - - test('does not double-tag an export that already has a JSDoc block', () => { - const input: string = [ - '/**', - ' * Already documented.', - ' */', - 'export interface IConfig {', - ' name: string;', - '}' - ].join('\n'); - - const result: string = _addTsDocTagToExports(input, '@public'); - - // The tag should appear exactly once - const tagOccurrences: number = (result.match(/@public/g) || []).length; - expect(tagOccurrences).toBe(1); - expect(result).toMatchSnapshot(); - }); - - test('does not modify non-export lines', () => { - const input: string = ['// A leading comment', 'const internal = 1;', '', 'export type Foo = {};'].join( - '\n' - ); - - expect(_addTsDocTagToExports(input, '@beta')).toMatchSnapshot(); - }); -}); diff --git a/heft-plugins/heft-json-schema-typings-plugin/src/test/__snapshots__/JsonSchemaTypingsGenerator.test.ts.snap b/heft-plugins/heft-json-schema-typings-plugin/src/test/__snapshots__/JsonSchemaTypingsGenerator.test.ts.snap deleted file mode 100644 index d786082a1c2..00000000000 --- a/heft-plugins/heft-json-schema-typings-plugin/src/test/__snapshots__/JsonSchemaTypingsGenerator.test.ts.snap +++ /dev/null @@ -1,85 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`JsonSchemaTypingsGenerator generates typings for a basic object schema 1`] = ` -"// This file was generated by a tool. Modifying it will produce unexpected behavior - -export interface BasicConfig { -/** - * The name of the item. - */ -name: string -/** - * The number of items. - */ -count?: number -/** - * Whether the feature is enabled. - */ -enabled?: boolean -} -" -`; - -exports[`JsonSchemaTypingsGenerator injects x-tsdoc-tag into exported declarations 1`] = ` -"// This file was generated by a tool. Modifying it will produce unexpected behavior - -/** - * @public - */ -export interface PublicConfig { -/** - * A value. - */ -value?: string -} -" -`; - -exports[`JsonSchemaTypingsGenerator resolves cross-file $ref between schema files: child output 1`] = ` -"// This file was generated by a tool. Modifying it will produce unexpected behavior - -/** - * A reusable child type. - */ -export interface ChildType { -/** - * The name of the child. - */ -childName: string -/** - * The value of the child. - */ -childValue?: number -} -" -`; - -exports[`JsonSchemaTypingsGenerator resolves cross-file $ref between schema files: parent output 1`] = ` -"// This file was generated by a tool. Modifying it will produce unexpected behavior - -export interface ParentConfig { -/** - * A label for the parent. - */ -label: string -child: ChildType -/** - * A list of children. - */ -children?: ChildType[] -} -/** - * A reusable child type. - */ -export interface ChildType { -/** - * The name of the child. - */ -childName: string -/** - * The value of the child. - */ -childValue?: number -} -" -`; diff --git a/heft-plugins/heft-json-schema-typings-plugin/src/test/__snapshots__/TsDocTagHelpers.test.ts.snap b/heft-plugins/heft-json-schema-typings-plugin/src/test/__snapshots__/TsDocReleaseTagHelpers.test.ts.snap similarity index 100% rename from heft-plugins/heft-json-schema-typings-plugin/src/test/__snapshots__/TsDocTagHelpers.test.ts.snap rename to heft-plugins/heft-json-schema-typings-plugin/src/test/__snapshots__/TsDocReleaseTagHelpers.test.ts.snap diff --git a/heft-plugins/heft-json-schema-typings-plugin/src/test/schemas/with-tsdoc-tag.schema.json b/heft-plugins/heft-json-schema-typings-plugin/src/test/schemas/with-tsdoc-tag.schema.json index d3e195844e7..14511d830b7 100644 --- a/heft-plugins/heft-json-schema-typings-plugin/src/test/schemas/with-tsdoc-tag.schema.json +++ b/heft-plugins/heft-json-schema-typings-plugin/src/test/schemas/with-tsdoc-tag.schema.json @@ -1,5 +1,5 @@ { - "x-tsdoc-tag": "@public", + "x-tsdoc-release-tag": "@public", "title": "Public Config", "type": "object", "properties": { diff --git a/libraries/node-core-library/src/JsonSchema.ts b/libraries/node-core-library/src/JsonSchema.ts index ce3c5bff65f..d635fa7da81 100644 --- a/libraries/node-core-library/src/JsonSchema.ts +++ b/libraries/node-core-library/src/JsonSchema.ts @@ -11,6 +11,18 @@ import addFormats from 'ajv-formats'; import { JsonFile, type JsonObject } from './JsonFile'; import { FileSystem } from './FileSystem'; +/** + * The JSON schema keyword `x-tsdoc-release-tag` is used by + * `@rushstack/heft-json-schema-typings-plugin` to annotate generated `.d.ts` + * declarations with a TSDoc release tag such as `@public` or `@beta`. + * + * This constant is also registered with AJV so that strict mode does not reject + * schema files that include it. + * + * @beta + */ +export const X_TSDOC_RELEASE_TAG_KEYWORD: 'x-tsdoc-release-tag' = 'x-tsdoc-release-tag'; + interface ISchemaWithId { // draft-04 uses "id" id: string | undefined; @@ -335,9 +347,9 @@ export class JsonSchema { } } - // Register the "x-tsdoc-tag" custom keyword used by @rushstack/heft-json-schema-typings-plugin + // Register the "x-tsdoc-release-tag" custom keyword used by @rushstack/heft-json-schema-typings-plugin // so that AJV's strict mode does not reject it as an unknown keyword. - validator.addKeyword('x-tsdoc-tag'); + validator.addKeyword(X_TSDOC_RELEASE_TAG_KEYWORD); // Enable json-schema format validation // https://ajv.js.org/packages/ajv-formats.html diff --git a/libraries/node-core-library/src/index.ts b/libraries/node-core-library/src/index.ts index c48a9c950a3..92605b3a832 100644 --- a/libraries/node-core-library/src/index.ts +++ b/libraries/node-core-library/src/index.ts @@ -113,7 +113,8 @@ export { type IJsonSchemaValidateOptions, type IJsonSchemaValidateObjectWithOptions, JsonSchema, - type JsonSchemaVersion + type JsonSchemaVersion, + X_TSDOC_RELEASE_TAG_KEYWORD } from './JsonSchema'; export { LegacyAdapters, type LegacyCallback } from './LegacyAdapters'; diff --git a/libraries/node-core-library/src/test/JsonSchema.test.ts b/libraries/node-core-library/src/test/JsonSchema.test.ts index 5565cf570d1..2264451083a 100644 --- a/libraries/node-core-library/src/test/JsonSchema.test.ts +++ b/libraries/node-core-library/src/test/JsonSchema.test.ts @@ -120,11 +120,11 @@ describe(JsonSchema.name, () => { }); }); - test('accepts a schema containing the x-tsdoc-tag custom keyword', () => { + test('accepts a schema containing the x-tsdoc-release-tag custom keyword', () => { const schemaWithTsDocTag: JsonSchema = JsonSchema.fromLoadedObject( { - title: 'Test x-tsdoc-tag', - 'x-tsdoc-tag': '@beta', + title: 'Test x-tsdoc-release-tag', + 'x-tsdoc-release-tag': '@beta', type: 'object', properties: { name: { type: 'string' } From b80e83f1ae8a648ca1fdc6689cf8e991074b8dfd Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 18 Feb 2026 11:45:54 -0800 Subject: [PATCH 08/14] Replace hardcoded x-tsdoc-release-tag with configurable vendor extension support - Remove X_TSDOC_RELEASE_TAG_KEYWORD constant from node-core-library exports - Add allowVendorExtensionKeywords option (@beta) to IJsonSchemaLoadOptions - When enabled, recursively scans schema tree for x-- keys and registers them with AJV so strict mode does not reject them - Restore local X_TSDOC_RELEASE_TAG_KEY constant in heft-json-schema-typings-plugin - Update node-core-library changelog to describe the new option - Add tests for both enabled and disabled vendor extension behavior --- .../test/JsonSchemaTypingsGenerator.test.ts | 18 +++-- ...e-json-schema-plugin_2026-02-18-16-54.json | 2 +- common/reviews/api/node-core-library.api.md | 5 +- .../src/JsonSchemaTypingsGenerator.ts | 11 +-- .../src/TsDocReleaseTagHelpers.ts | 5 +- libraries/node-core-library/src/JsonSchema.ts | 70 +++++++++++++++---- libraries/node-core-library/src/index.ts | 3 +- .../src/test/JsonSchema.test.ts | 26 +++++-- 8 files changed, 105 insertions(+), 35 deletions(-) diff --git a/build-tests/heft-json-schema-typings-plugin-test/src/test/JsonSchemaTypingsGenerator.test.ts b/build-tests/heft-json-schema-typings-plugin-test/src/test/JsonSchemaTypingsGenerator.test.ts index a0adc753617..b1e1bd61161 100644 --- a/build-tests/heft-json-schema-typings-plugin-test/src/test/JsonSchemaTypingsGenerator.test.ts +++ b/build-tests/heft-json-schema-typings-plugin-test/src/test/JsonSchemaTypingsGenerator.test.ts @@ -28,17 +28,25 @@ async function getFolderItemsAsync( return Object.fromEntries(results); } +const rootFolder: string | undefined = PackageJsonLookup.instance.tryGetPackageFolderFor(__dirname); +if (!rootFolder) { + throw new Error('Could not find root folder for the test'); +} + describe('json-schema-typings-plugin', () => { it('should generate typings for JSON Schemas', async () => { - const rootFolder: string | undefined = PackageJsonLookup.instance.tryGetPackageFolderFor(__dirname); - if (!rootFolder) { - throw new Error('Could not find root folder for the test'); - } - const folderItems: Record = await getFolderItemsAsync( `${rootFolder}/temp/schema-dts`, '.' ); expect(folderItems).toMatchSnapshot(); }); + + it('should generate formatted typings for JSON Schemas', async () => { + const folderItems: Record = await getFolderItemsAsync( + `${rootFolder}/temp/schema-dts-formatted`, + '.' + ); + expect(folderItems).toMatchSnapshot(); + }); }); diff --git a/common/changes/@rushstack/node-core-library/use-json-schema-plugin_2026-02-18-16-54.json b/common/changes/@rushstack/node-core-library/use-json-schema-plugin_2026-02-18-16-54.json index e37a1870ce3..b20427c08a5 100644 --- a/common/changes/@rushstack/node-core-library/use-json-schema-plugin_2026-02-18-16-54.json +++ b/common/changes/@rushstack/node-core-library/use-json-schema-plugin_2026-02-18-16-54.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@rushstack/node-core-library", - "comment": "Register `x-tsdoc-release-tag` as a known keyword in `JsonSchema` so that AJV strict mode does not reject JSON schema files that include this custom metadata property.", + "comment": "Add an `allowVendorExtensionKeywords` option to `JsonSchema` that automatically registers `x--` properties with the AJV validator so that strict mode does not reject JSON schema files containing vendor extensions.", "type": "minor" } ], diff --git a/common/reviews/api/node-core-library.api.md b/common/reviews/api/node-core-library.api.md index a2467a5572a..8c7e76a6fcf 100644 --- a/common/reviews/api/node-core-library.api.md +++ b/common/reviews/api/node-core-library.api.md @@ -460,6 +460,8 @@ export type IJsonSchemaFromObjectOptions = IJsonSchemaLoadOptions; // @public export interface IJsonSchemaLoadOptions { + // @beta + allowVendorExtensionKeywords?: boolean; customFormats?: Record | IJsonSchemaCustomFormat>; dependentSchemas?: JsonSchema[]; schemaVersion?: JsonSchemaVersion; @@ -962,7 +964,4 @@ declare namespace User { } export { User } -// @beta -export const X_TSDOC_RELEASE_TAG_KEYWORD: 'x-tsdoc-release-tag'; - ``` diff --git a/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.ts b/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.ts index 510b36de64b..deb30f2742b 100644 --- a/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.ts +++ b/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.ts @@ -6,9 +6,12 @@ import * as path from 'node:path'; import { compile } from 'json-schema-to-typescript'; import { type ITypingsGeneratorBaseOptions, TypingsGenerator } from '@rushstack/typings-generator'; -import { X_TSDOC_RELEASE_TAG_KEYWORD } from '@rushstack/node-core-library'; -import { _addTsDocReleaseTagToExports, _validateTsDocReleaseTag } from './TsDocReleaseTagHelpers'; +import { + _addTsDocReleaseTagToExports, + _validateTsDocReleaseTag, + X_TSDOC_RELEASE_TAG_KEY +} from './TsDocReleaseTagHelpers'; interface IJsonSchemaTypingsGeneratorBaseOptions extends ITypingsGeneratorBaseOptions { /** @@ -24,7 +27,7 @@ const SCHEMA_FILE_EXTENSION: '.schema.json' = '.schema.json'; type Json4Schema = Parameters[0]; interface IExtendedJson4Schema extends Json4Schema { - [X_TSDOC_RELEASE_TAG_KEYWORD]?: string; + [X_TSDOC_RELEASE_TAG_KEY]?: string; } export class JsonSchemaTypingsGenerator extends TypingsGenerator { @@ -40,7 +43,7 @@ export class JsonSchemaTypingsGenerator extends TypingsGenerator { relativePath: string ): Promise => { const parsedFileContents: IExtendedJson4Schema = JSON.parse(fileContents); - const { [X_TSDOC_RELEASE_TAG_KEYWORD]: tsdocReleaseTag, ...jsonSchemaWithoutReleaseTag } = + const { [X_TSDOC_RELEASE_TAG_KEY]: tsdocReleaseTag, ...jsonSchemaWithoutReleaseTag } = parsedFileContents; // Use the absolute directory of the schema file so that cross-file $ref diff --git a/heft-plugins/heft-json-schema-typings-plugin/src/TsDocReleaseTagHelpers.ts b/heft-plugins/heft-json-schema-typings-plugin/src/TsDocReleaseTagHelpers.ts index a5a3879fbe5..3c736a094e8 100644 --- a/heft-plugins/heft-json-schema-typings-plugin/src/TsDocReleaseTagHelpers.ts +++ b/heft-plugins/heft-json-schema-typings-plugin/src/TsDocReleaseTagHelpers.ts @@ -1,8 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { X_TSDOC_RELEASE_TAG_KEYWORD } from '@rushstack/node-core-library'; - +export const X_TSDOC_RELEASE_TAG_KEY: 'x-tsdoc-release-tag' = 'x-tsdoc-release-tag'; const RELEASE_TAG_PATTERN: RegExp = /^@[a-z]+$/; /** @@ -12,7 +11,7 @@ const RELEASE_TAG_PATTERN: RegExp = /^@[a-z]+$/; export function _validateTsDocReleaseTag(value: string, schemaPath: string): void { if (!RELEASE_TAG_PATTERN.test(value)) { throw new Error( - `Invalid ${X_TSDOC_RELEASE_TAG_KEYWORD} value ${JSON.stringify(value)} in ${schemaPath}. ` + + `Invalid ${X_TSDOC_RELEASE_TAG_KEY} value ${JSON.stringify(value)} in ${schemaPath}. ` + 'Expected a single lowercase word starting with "@" (e.g. "@public", "@beta").' ); } diff --git a/libraries/node-core-library/src/JsonSchema.ts b/libraries/node-core-library/src/JsonSchema.ts index d635fa7da81..c84a43280a3 100644 --- a/libraries/node-core-library/src/JsonSchema.ts +++ b/libraries/node-core-library/src/JsonSchema.ts @@ -12,16 +12,29 @@ import { JsonFile, type JsonObject } from './JsonFile'; import { FileSystem } from './FileSystem'; /** - * The JSON schema keyword `x-tsdoc-release-tag` is used by - * `@rushstack/heft-json-schema-typings-plugin` to annotate generated `.d.ts` - * declarations with a TSDoc release tag such as `@public` or `@beta`. - * - * This constant is also registered with AJV so that strict mode does not reject - * schema files that include it. - * - * @beta + * Pattern matching JSON Schema vendor extension keywords in the form `x--`. + * @example `x-tsdoc-release-tag`, `x-intellij-html-description` */ -export const X_TSDOC_RELEASE_TAG_KEYWORD: 'x-tsdoc-release-tag' = 'x-tsdoc-release-tag'; +const VENDOR_EXTENSION_KEY_PATTERN: RegExp = /^x-.+-/; + +/** + * Recursively scans a JSON object (typically a JSON Schema) and collects all property + * keys matching the vendor extension pattern `x--`. + */ +function _collectVendorExtensionKeywords(obj: unknown, keywords: Set): void { + if (Array.isArray(obj)) { + for (const item of obj) { + _collectVendorExtensionKeywords(item, keywords); + } + } else if (typeof obj === 'object' && obj !== null) { + for (const [key, value] of Object.entries(obj as Record)) { + if (VENDOR_EXTENSION_KEY_PATTERN.test(key)) { + keywords.add(key); + } + _collectVendorExtensionKeywords(value, keywords); + } + } +} interface ISchemaWithId { // draft-04 uses "id" @@ -131,6 +144,24 @@ export interface IJsonSchemaLoadOptions { * for example define generic numeric formats (e.g. uint8) or domain-specific formats. */ customFormats?: Record | IJsonSchemaCustomFormat>; + + /** + * If true, JSON Schema vendor extension keywords matching the pattern `x--` + * are automatically registered with the AJV validator so that strict mode does not reject them. + * + * @remarks + * The JSON Schema specification allows vendor-specific extensions using the `x-` prefix. + * For example, `x-tsdoc-release-tag` is used by `@rushstack/heft-json-schema-typings-plugin`. + * Other tools may define their own extensions such as `x-intellij-html-description`. + * + * By default, AJV's strict mode rejects unknown keywords. Enabling this option causes the + * schema tree to be scanned for any keys matching the `x--` pattern, and those + * keys are registered as custom keywords so that validation succeeds. + * + * @defaultValue false + * @beta + */ + allowVendorExtensionKeywords?: boolean; } /** @@ -181,6 +212,7 @@ export class JsonSchema { private _customFormats: | Record | IJsonSchemaCustomFormat> | undefined = undefined; + private _allowVendorExtensionKeywords: boolean = false; private constructor() {} @@ -204,6 +236,7 @@ export class JsonSchema { schema._dependentSchemas = options.dependentSchemas || []; schema._schemaVersion = options.schemaVersion; schema._customFormats = options.customFormats; + schema._allowVendorExtensionKeywords = options.allowVendorExtensionKeywords ?? false; } return schema; @@ -223,6 +256,7 @@ export class JsonSchema { schema._dependentSchemas = options.dependentSchemas || []; schema._schemaVersion = options.schemaVersion; schema._customFormats = options.customFormats; + schema._allowVendorExtensionKeywords = options.allowVendorExtensionKeywords ?? false; } return schema; @@ -347,10 +381,6 @@ export class JsonSchema { } } - // Register the "x-tsdoc-release-tag" custom keyword used by @rushstack/heft-json-schema-typings-plugin - // so that AJV's strict mode does not reject it as an unknown keyword. - validator.addKeyword(X_TSDOC_RELEASE_TAG_KEYWORD); - // Enable json-schema format validation // https://ajv.js.org/packages/ajv-formats.html addFormats(validator); @@ -366,6 +396,20 @@ export class JsonSchema { JsonSchema._collectDependentSchemas(collectedSchemas, this._dependentSchemas, seenObjects, seenIds); + // If vendor extension keywords are allowed, scan the schema tree for keys + // matching the x-- pattern and register them with AJV so + // that strict mode does not reject them as unknown keywords. + if (this._allowVendorExtensionKeywords) { + 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); + } + } + // 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) { diff --git a/libraries/node-core-library/src/index.ts b/libraries/node-core-library/src/index.ts index 92605b3a832..c48a9c950a3 100644 --- a/libraries/node-core-library/src/index.ts +++ b/libraries/node-core-library/src/index.ts @@ -113,8 +113,7 @@ export { type IJsonSchemaValidateOptions, type IJsonSchemaValidateObjectWithOptions, JsonSchema, - type JsonSchemaVersion, - X_TSDOC_RELEASE_TAG_KEYWORD + type JsonSchemaVersion } from './JsonSchema'; export { LegacyAdapters, type LegacyCallback } from './LegacyAdapters'; diff --git a/libraries/node-core-library/src/test/JsonSchema.test.ts b/libraries/node-core-library/src/test/JsonSchema.test.ts index 2264451083a..0d07b9cd4d3 100644 --- a/libraries/node-core-library/src/test/JsonSchema.test.ts +++ b/libraries/node-core-library/src/test/JsonSchema.test.ts @@ -120,10 +120,28 @@ describe(JsonSchema.name, () => { }); }); - test('accepts a schema containing the x-tsdoc-release-tag custom keyword', () => { - const schemaWithTsDocTag: JsonSchema = JsonSchema.fromLoadedObject( + test('accepts vendor extension keywords when allowVendorExtensionKeywords is enabled', () => { + const schemaWithVendorExtensions: JsonSchema = JsonSchema.fromLoadedObject( { - title: 'Test x-tsdoc-release-tag', + title: 'Test vendor extensions', + 'x-tsdoc-release-tag': '@beta', + 'x-intellij-html-description': 'bold', + type: 'object', + properties: { + name: { type: 'string' } + }, + additionalProperties: false, + required: ['name'] + }, + { schemaVersion: 'draft-07', allowVendorExtensionKeywords: true } + ); + expect(() => schemaWithVendorExtensions.validateObject({ name: 'hello' }, '')).not.toThrow(); + }); + + test('rejects vendor extension keywords when allowVendorExtensionKeywords is not enabled', () => { + const schemaWithVendorExtensions: JsonSchema = JsonSchema.fromLoadedObject( + { + title: 'Test vendor extensions rejected', 'x-tsdoc-release-tag': '@beta', type: 'object', properties: { @@ -134,7 +152,7 @@ describe(JsonSchema.name, () => { }, { schemaVersion: 'draft-07' } ); - expect(() => schemaWithTsDocTag.validateObject({ name: 'hello' }, '')).not.toThrow(); + expect(() => schemaWithVendorExtensions.validateObject({ name: 'hello' }, '')).toThrow(); }); test('successfully applies custom formats', () => { From 3b23b2997c3ac47423743c8e2a0ae9e5a135db6d Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 18 Feb 2026 11:51:01 -0800 Subject: [PATCH 09/14] fixup! Rename x-tsdoc-tag to x-tsdoc-release-tag and add validation --- .../JsonSchemaTypingsGenerator.test.ts.snap | 85 +++++++++++++++++++ .../TsDocReleaseTagHelpers.test.ts.snap | 12 +-- 2 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 heft-plugins/heft-json-schema-typings-plugin/src/test/__snapshots__/JsonSchemaTypingsGenerator.test.ts.snap diff --git a/heft-plugins/heft-json-schema-typings-plugin/src/test/__snapshots__/JsonSchemaTypingsGenerator.test.ts.snap b/heft-plugins/heft-json-schema-typings-plugin/src/test/__snapshots__/JsonSchemaTypingsGenerator.test.ts.snap new file mode 100644 index 00000000000..bb41d510e81 --- /dev/null +++ b/heft-plugins/heft-json-schema-typings-plugin/src/test/__snapshots__/JsonSchemaTypingsGenerator.test.ts.snap @@ -0,0 +1,85 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`JsonSchemaTypingsGenerator generates typings for a basic object schema 1`] = ` +"// This file was generated by a tool. Modifying it will produce unexpected behavior + +export interface BasicConfig { +/** + * The name of the item. + */ +name: string +/** + * The number of items. + */ +count?: number +/** + * Whether the feature is enabled. + */ +enabled?: boolean +} +" +`; + +exports[`JsonSchemaTypingsGenerator injects x-tsdoc-release-tag into exported declarations 1`] = ` +"// This file was generated by a tool. Modifying it will produce unexpected behavior + +/** + * @public + */ +export interface PublicConfig { +/** + * A value. + */ +value?: string +} +" +`; + +exports[`JsonSchemaTypingsGenerator resolves cross-file $ref between schema files: child output 1`] = ` +"// This file was generated by a tool. Modifying it will produce unexpected behavior + +/** + * A reusable child type. + */ +export interface ChildType { +/** + * The name of the child. + */ +childName: string +/** + * The value of the child. + */ +childValue?: number +} +" +`; + +exports[`JsonSchemaTypingsGenerator resolves cross-file $ref between schema files: parent output 1`] = ` +"// This file was generated by a tool. Modifying it will produce unexpected behavior + +export interface ParentConfig { +/** + * A label for the parent. + */ +label: string +child: ChildType +/** + * A list of children. + */ +children?: ChildType[] +} +/** + * A reusable child type. + */ +export interface ChildType { +/** + * The name of the child. + */ +childName: string +/** + * The value of the child. + */ +childValue?: number +} +" +`; diff --git a/heft-plugins/heft-json-schema-typings-plugin/src/test/__snapshots__/TsDocReleaseTagHelpers.test.ts.snap b/heft-plugins/heft-json-schema-typings-plugin/src/test/__snapshots__/TsDocReleaseTagHelpers.test.ts.snap index 4717114b0d9..4c40e324a60 100644 --- a/heft-plugins/heft-json-schema-typings-plugin/src/test/__snapshots__/TsDocReleaseTagHelpers.test.ts.snap +++ b/heft-plugins/heft-json-schema-typings-plugin/src/test/__snapshots__/TsDocReleaseTagHelpers.test.ts.snap @@ -1,13 +1,13 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`_addTsDocTagToExports creates a new JSDoc block for an export without a preceding comment 1`] = ` +exports[`_addTsDocReleaseTagToExports creates a new JSDoc block for an export without a preceding comment 1`] = ` "/** * @public */ export type Foo = {};" `; -exports[`_addTsDocTagToExports does not double-tag an export that already has a JSDoc block 1`] = ` +exports[`_addTsDocReleaseTagToExports does not double-tag an export that already has a JSDoc block 1`] = ` "/** * Already documented. * @@ -18,7 +18,7 @@ export interface IConfig { }" `; -exports[`_addTsDocTagToExports does not modify non-export lines 1`] = ` +exports[`_addTsDocReleaseTagToExports does not modify non-export lines 1`] = ` "// A leading comment const internal = 1; @@ -28,7 +28,7 @@ const internal = 1; export type Foo = {};" `; -exports[`_addTsDocTagToExports handles multiple exports with and without JSDoc comments 1`] = ` +exports[`_addTsDocReleaseTagToExports handles multiple exports with and without JSDoc comments 1`] = ` "/** * First type. * @@ -42,7 +42,7 @@ export type Foo = {}; export type Bar = {};" `; -exports[`_addTsDocTagToExports injects tag into an existing JSDoc comment before an export 1`] = ` +exports[`_addTsDocReleaseTagToExports injects tag into an existing JSDoc comment before an export 1`] = ` "/** * A description. * @@ -51,7 +51,7 @@ exports[`_addTsDocTagToExports injects tag into an existing JSDoc comment before export type Foo = {};" `; -exports[`_addTsDocTagToExports normalizes CRLF line endings to LF 1`] = ` +exports[`_addTsDocReleaseTagToExports normalizes CRLF line endings to LF 1`] = ` "/** * A description. * From f0cbbbc830e83d9f2005f2d31afada5f0d7aee62 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 18 Feb 2026 11:51:49 -0800 Subject: [PATCH 10/14] Add tests for unformatted typings. --- .../config/heft.json | 12 ++ .../test/JsonSchemaTypingsGenerator.test.ts | 16 +- .../JsonSchemaTypingsGenerator.test.ts.snap | 186 +++++++++++++++++- 3 files changed, 208 insertions(+), 6 deletions(-) diff --git a/build-tests/heft-json-schema-typings-plugin-test/config/heft.json b/build-tests/heft-json-schema-typings-plugin-test/config/heft.json index 9ca23a6775b..f74df35f8af 100644 --- a/build-tests/heft-json-schema-typings-plugin-test/config/heft.json +++ b/build-tests/heft-json-schema-typings-plugin-test/config/heft.json @@ -17,6 +17,18 @@ "generatedTsFolders": ["temp/schema-dts"] } } + }, + + "json-schema-typings-formatted": { + "taskPlugin": { + "pluginPackage": "@rushstack/heft-json-schema-typings-plugin", + "pluginName": "json-schema-typings-plugin", + "options": { + "srcFolder": "node_modules/@rushstack/node-core-library/src/test/test-data/test-schemas", + "generatedTsFolders": ["temp/schema-dts-formatted"], + "formatWithPrettier": true + } + } } } } diff --git a/build-tests/heft-json-schema-typings-plugin-test/src/test/JsonSchemaTypingsGenerator.test.ts b/build-tests/heft-json-schema-typings-plugin-test/src/test/JsonSchemaTypingsGenerator.test.ts index b1e1bd61161..20a46ffc077 100644 --- a/build-tests/heft-json-schema-typings-plugin-test/src/test/JsonSchemaTypingsGenerator.test.ts +++ b/build-tests/heft-json-schema-typings-plugin-test/src/test/JsonSchemaTypingsGenerator.test.ts @@ -28,12 +28,18 @@ async function getFolderItemsAsync( return Object.fromEntries(results); } -const rootFolder: string | undefined = PackageJsonLookup.instance.tryGetPackageFolderFor(__dirname); -if (!rootFolder) { - throw new Error('Could not find root folder for the test'); -} - describe('json-schema-typings-plugin', () => { + let rootFolder: string; + + beforeAll(() => { + const foundRootFolder: string | undefined = PackageJsonLookup.instance.tryGetPackageFolderFor(__dirname); + if (!foundRootFolder) { + throw new Error('Could not find root folder for the test'); + } + + rootFolder = foundRootFolder; + }); + it('should generate typings for JSON Schemas', async () => { const folderItems: Record = await getFolderItemsAsync( `${rootFolder}/temp/schema-dts`, diff --git a/build-tests/heft-json-schema-typings-plugin-test/src/test/__snapshots__/JsonSchemaTypingsGenerator.test.ts.snap b/build-tests/heft-json-schema-typings-plugin-test/src/test/__snapshots__/JsonSchemaTypingsGenerator.test.ts.snap index 42c458407d8..e65a7d9d88a 100644 --- a/build-tests/heft-json-schema-typings-plugin-test/src/test/__snapshots__/JsonSchemaTypingsGenerator.test.ts.snap +++ b/build-tests/heft-json-schema-typings-plugin-test/src/test/__snapshots__/JsonSchemaTypingsGenerator.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`json-schema-typings-plugin should generate typings for JSON Schemas 1`] = ` +exports[`json-schema-typings-plugin should generate formatted typings for JSON Schemas 1`] = ` Object { "./test-invalid-additional.schema.json.d.ts": "// This file was generated by a tool. Modifying it will produce unexpected behavior @@ -183,3 +183,187 @@ export interface TestValid { ", } `; + +exports[`json-schema-typings-plugin should generate typings for JSON Schemas 1`] = ` +Object { + "./test-invalid-additional.schema.json.d.ts": "// This file was generated by a tool. Modifying it will produce unexpected behavior + +export interface TestInvalidAdditional { +[k: string]: unknown +} +", + "./test-invalid-format.schema.json.d.ts": "// This file was generated by a tool. Modifying it will produce unexpected behavior + +export interface TestInvalidFormat { +[k: string]: unknown +} +", + "./test-schema-draft-04.schema.json.d.ts": "// This file was generated by a tool. Modifying it will produce unexpected behavior + +export interface TestSchemaFile { +exampleString: string +exampleLink?: string +exampleArray: string[] +/** + * Description for exampleOneOf - this is a very long description to show in an error message + */ +exampleOneOf?: (Type1 | Type2) +exampleUniqueObjectArray?: { +field2?: string +field3?: string +}[] +} +/** + * Description for type1 + */ +export interface Type1 { +/** + * Description for field1 + */ +field1: string +} +/** + * Description for type2 + */ +export interface Type2 { +/** + * Description for field2 + */ +field2: string +/** + * Description for field3 + */ +field3: string +} +", + "./test-schema-draft-07.schema.json.d.ts": "// This file was generated by a tool. Modifying it will produce unexpected behavior + +export interface TestSchemaFile { +exampleString: string +exampleLink?: string +exampleArray: string[] +/** + * Description for exampleOneOf - this is a very long description to show in an error message + */ +exampleOneOf?: (Type1 | Type2) +exampleUniqueObjectArray?: { +field2?: string +field3?: string +}[] +} +/** + * Description for type1 + */ +export interface Type1 { +/** + * Description for field1 + */ +field1: string +} +/** + * Description for type2 + */ +export interface Type2 { +/** + * Description for field2 + */ +field2: string +/** + * Description for field3 + */ +field3: string +} +", + "./test-schema-invalid.schema.json.d.ts": "// This file was generated by a tool. Modifying it will produce unexpected behavior + +export interface HttpExampleComSchemasTestSchemaNestedChildSchemaJson { +[k: string]: unknown +} +", + "./test-schema-nested-child.schema.json.d.ts": "// This file was generated by a tool. Modifying it will produce unexpected behavior + +export interface HttpExampleComSchemasTestSchemaNestedChildSchemaJson { +[k: string]: unknown +} +", + "./test-schema-nested.schema.json.d.ts": "// This file was generated by a tool. Modifying it will produce unexpected behavior + +export interface TestSchemaFile { +exampleString: string +exampleLink?: string +exampleArray: string[] +/** + * Description for exampleOneOf - this is a very long description to show in an error message + */ +exampleOneOf?: (Type1 | Type2) +exampleUniqueObjectArray?: Type2[] +} +/** + * Description for type1 + */ +export interface Type1 { +/** + * Description for field1 + */ +field1: string +} +/** + * Description for type2 + */ +export interface Type2 { +/** + * Description for field2 + */ +field2: string +/** + * Description for field3 + */ +field3: string +} +", + "./test-schema.schema.json.d.ts": "// This file was generated by a tool. Modifying it will produce unexpected behavior + +export interface TestSchemaFile { +exampleString: string +exampleLink?: string +exampleArray: string[] +/** + * Description for exampleOneOf - this is a very long description to show in an error message + */ +exampleOneOf?: (Type1 | Type2) +exampleUniqueObjectArray?: { +field2?: string +field3?: string +}[] +} +/** + * Description for type1 + */ +export interface Type1 { +/** + * Description for field1 + */ +field1: string +} +/** + * Description for type2 + */ +export interface Type2 { +/** + * Description for field2 + */ +field2: string +/** + * Description for field3 + */ +field3: string +} +", + "./test-valid.schema.json.d.ts": "// This file was generated by a tool. Modifying it will produce unexpected behavior + +export interface TestValid { +[k: string]: unknown +} +", +} +`; From b59a4c92052c1a1ad235bd1439b19ce8b4a74232 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 18 Feb 2026 11:55:38 -0800 Subject: [PATCH 11/14] Default to allowing vendor extension keywords in JsonSchema - Rename allowVendorExtensionKeywords to rejectVendorExtensionKeywords - Vendor extensions (x--) are now accepted by default - Set rejectVendorExtensionKeywords: true to restore strict behavior - Update tests and changelog accordingly --- ...e-json-schema-plugin_2026-02-18-16-54.json | 2 +- common/reviews/api/node-core-library.api.md | 4 ++-- libraries/node-core-library/src/JsonSchema.ts | 23 ++++++++++--------- .../src/test/JsonSchema.test.ts | 8 +++---- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/common/changes/@rushstack/node-core-library/use-json-schema-plugin_2026-02-18-16-54.json b/common/changes/@rushstack/node-core-library/use-json-schema-plugin_2026-02-18-16-54.json index b20427c08a5..4a54ddf83de 100644 --- a/common/changes/@rushstack/node-core-library/use-json-schema-plugin_2026-02-18-16-54.json +++ b/common/changes/@rushstack/node-core-library/use-json-schema-plugin_2026-02-18-16-54.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@rushstack/node-core-library", - "comment": "Add an `allowVendorExtensionKeywords` option to `JsonSchema` that automatically registers `x--` properties with the AJV validator so that strict mode does not reject JSON schema files containing vendor extensions.", + "comment": "Add a property to the `JsonSchema` validator to control the handling of vendor extension keywords. By default, vendor extension keywords matching the `x--` pattern are accepted. Set the new `rejectVendorExtensionKeywords` option to `true` to restore the previous strict behavior.", "type": "minor" } ], diff --git a/common/reviews/api/node-core-library.api.md b/common/reviews/api/node-core-library.api.md index 8c7e76a6fcf..1d377c3b3f3 100644 --- a/common/reviews/api/node-core-library.api.md +++ b/common/reviews/api/node-core-library.api.md @@ -460,10 +460,10 @@ export type IJsonSchemaFromObjectOptions = IJsonSchemaLoadOptions; // @public export interface IJsonSchemaLoadOptions { - // @beta - allowVendorExtensionKeywords?: boolean; customFormats?: Record | IJsonSchemaCustomFormat>; dependentSchemas?: JsonSchema[]; + // @beta + rejectVendorExtensionKeywords?: boolean; schemaVersion?: JsonSchemaVersion; } diff --git a/libraries/node-core-library/src/JsonSchema.ts b/libraries/node-core-library/src/JsonSchema.ts index c84a43280a3..cd3c60d79ce 100644 --- a/libraries/node-core-library/src/JsonSchema.ts +++ b/libraries/node-core-library/src/JsonSchema.ts @@ -146,22 +146,23 @@ export interface IJsonSchemaLoadOptions { customFormats?: Record | IJsonSchemaCustomFormat>; /** - * If true, JSON Schema vendor extension keywords matching the pattern `x--` - * are automatically registered with the AJV validator so that strict mode does not reject them. + * If true, the AJV validator will reject JSON Schema vendor extension keywords + * matching the pattern `x--` as unknown keywords. * * @remarks * The JSON Schema specification allows vendor-specific extensions using the `x-` prefix. * For example, `x-tsdoc-release-tag` is used by `@rushstack/heft-json-schema-typings-plugin`. * Other tools may define their own extensions such as `x-intellij-html-description`. * - * By default, AJV's strict mode rejects unknown keywords. Enabling this option causes the - * schema tree to be scanned for any keys matching the `x--` pattern, and those - * keys are registered as custom keywords so that validation succeeds. + * By default, the schema tree is scanned for any keys matching the `x--` + * pattern, and those keys are registered as custom AJV keywords so that strict mode validation + * succeeds. Set this option to `true` to disable this behavior and treat vendor extension + * keywords as unknown (which causes AJV strict mode to reject them). * * @defaultValue false * @beta */ - allowVendorExtensionKeywords?: boolean; + rejectVendorExtensionKeywords?: boolean; } /** @@ -212,7 +213,7 @@ export class JsonSchema { private _customFormats: | Record | IJsonSchemaCustomFormat> | undefined = undefined; - private _allowVendorExtensionKeywords: boolean = false; + private _rejectVendorExtensionKeywords: boolean = false; private constructor() {} @@ -236,7 +237,7 @@ export class JsonSchema { schema._dependentSchemas = options.dependentSchemas || []; schema._schemaVersion = options.schemaVersion; schema._customFormats = options.customFormats; - schema._allowVendorExtensionKeywords = options.allowVendorExtensionKeywords ?? false; + schema._rejectVendorExtensionKeywords = options.rejectVendorExtensionKeywords ?? false; } return schema; @@ -256,7 +257,7 @@ export class JsonSchema { schema._dependentSchemas = options.dependentSchemas || []; schema._schemaVersion = options.schemaVersion; schema._customFormats = options.customFormats; - schema._allowVendorExtensionKeywords = options.allowVendorExtensionKeywords ?? false; + schema._rejectVendorExtensionKeywords = options.rejectVendorExtensionKeywords ?? false; } return schema; @@ -396,10 +397,10 @@ export class JsonSchema { JsonSchema._collectDependentSchemas(collectedSchemas, this._dependentSchemas, seenObjects, seenIds); - // If vendor extension keywords are allowed, scan the schema tree for keys + // Unless explicitly rejected, scan the schema tree 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._allowVendorExtensionKeywords) { + if (!this._rejectVendorExtensionKeywords) { const vendorKeywords: Set = new Set(); _collectVendorExtensionKeywords(this._schemaObject, vendorKeywords); for (const collectedSchema of collectedSchemas) { diff --git a/libraries/node-core-library/src/test/JsonSchema.test.ts b/libraries/node-core-library/src/test/JsonSchema.test.ts index 0d07b9cd4d3..37f1cb12144 100644 --- a/libraries/node-core-library/src/test/JsonSchema.test.ts +++ b/libraries/node-core-library/src/test/JsonSchema.test.ts @@ -120,7 +120,7 @@ describe(JsonSchema.name, () => { }); }); - test('accepts vendor extension keywords when allowVendorExtensionKeywords is enabled', () => { + test('accepts vendor extension keywords by default', () => { const schemaWithVendorExtensions: JsonSchema = JsonSchema.fromLoadedObject( { title: 'Test vendor extensions', @@ -133,12 +133,12 @@ describe(JsonSchema.name, () => { additionalProperties: false, required: ['name'] }, - { schemaVersion: 'draft-07', allowVendorExtensionKeywords: true } + { schemaVersion: 'draft-07' } ); expect(() => schemaWithVendorExtensions.validateObject({ name: 'hello' }, '')).not.toThrow(); }); - test('rejects vendor extension keywords when allowVendorExtensionKeywords is not enabled', () => { + test('rejects vendor extension keywords when rejectVendorExtensionKeywords is enabled', () => { const schemaWithVendorExtensions: JsonSchema = JsonSchema.fromLoadedObject( { title: 'Test vendor extensions rejected', @@ -150,7 +150,7 @@ describe(JsonSchema.name, () => { additionalProperties: false, required: ['name'] }, - { schemaVersion: 'draft-07' } + { schemaVersion: 'draft-07', rejectVendorExtensionKeywords: true } ); expect(() => schemaWithVendorExtensions.validateObject({ name: 'hello' }, '')).toThrow(); }); From 08b68e0901e0eebc49ff12377774af75ead2d774 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 18 Feb 2026 12:02:16 -0800 Subject: [PATCH 12/14] fixup! Replace hardcoded x-tsdoc-release-tag with configurable vendor extension support --- libraries/node-core-library/src/JsonSchema.ts | 28 ++++++++----------- .../src/test/JsonSchema.test.ts | 2 +- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/libraries/node-core-library/src/JsonSchema.ts b/libraries/node-core-library/src/JsonSchema.ts index cd3c60d79ce..a1ffdf13dfb 100644 --- a/libraries/node-core-library/src/JsonSchema.ts +++ b/libraries/node-core-library/src/JsonSchema.ts @@ -12,26 +12,22 @@ import { JsonFile, type JsonObject } from './JsonFile'; import { FileSystem } from './FileSystem'; /** - * Pattern matching JSON Schema vendor extension keywords in the form `x--`. - * @example `x-tsdoc-release-tag`, `x-intellij-html-description` + * Pattern matching JSON Schema vendor extension keywords in the form `x--`, + * where `` is alphanumeric and `` is kebab-case alphanumeric. + * @example `x-tsdoc-release-tag`, `x-myvendor-description` */ -const VENDOR_EXTENSION_KEY_PATTERN: RegExp = /^x-.+-/; +const VENDOR_EXTENSION_KEY_PATTERN: RegExp = /^x-[a-z0-9]+-[a-z0-9]+(-[a-z0-9]+)*$/; /** - * Recursively scans a JSON object (typically a JSON Schema) and collects all property - * keys matching the vendor extension pattern `x--`. + * Collects top-level property keys from a JSON object that match the vendor extension + * pattern `x--`. Only root-level keys are inspected for performance. */ function _collectVendorExtensionKeywords(obj: unknown, keywords: Set): void { - if (Array.isArray(obj)) { - for (const item of obj) { - _collectVendorExtensionKeywords(item, keywords); - } - } else if (typeof obj === 'object' && obj !== null) { - for (const [key, value] of Object.entries(obj as Record)) { + if (typeof obj === 'object' && obj !== null && !Array.isArray(obj)) { + for (const key of Object.keys(obj as Record)) { if (VENDOR_EXTENSION_KEY_PATTERN.test(key)) { keywords.add(key); } - _collectVendorExtensionKeywords(value, keywords); } } } @@ -152,7 +148,7 @@ export interface IJsonSchemaLoadOptions { * @remarks * The JSON Schema specification allows vendor-specific extensions using the `x-` prefix. * For example, `x-tsdoc-release-tag` is used by `@rushstack/heft-json-schema-typings-plugin`. - * Other tools may define their own extensions such as `x-intellij-html-description`. + * Other tools may define their own extensions such as `x-myvendor-html-description`. * * By default, the schema tree is scanned for any keys matching the `x--` * pattern, and those keys are registered as custom AJV keywords so that strict mode validation @@ -397,9 +393,9 @@ export class JsonSchema { JsonSchema._collectDependentSchemas(collectedSchemas, this._dependentSchemas, seenObjects, seenIds); - // Unless explicitly rejected, scan the schema tree for vendor extension keys - // matching the x-- pattern and register them with AJV so - // that strict mode does not reject them as unknown keywords. + // 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); diff --git a/libraries/node-core-library/src/test/JsonSchema.test.ts b/libraries/node-core-library/src/test/JsonSchema.test.ts index 37f1cb12144..5a2f46526a3 100644 --- a/libraries/node-core-library/src/test/JsonSchema.test.ts +++ b/libraries/node-core-library/src/test/JsonSchema.test.ts @@ -125,7 +125,7 @@ describe(JsonSchema.name, () => { { title: 'Test vendor extensions', 'x-tsdoc-release-tag': '@beta', - 'x-intellij-html-description': 'bold', + 'x-myvendor-html-description': 'bold', type: 'object', properties: { name: { type: 'string' } From 876c3a2628b2939e6cd00c5bf26cdd6f1c23132d Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 18 Feb 2026 12:06:34 -0800 Subject: [PATCH 13/14] Improve heft-json-schema-typings-plugin README - Add setup instructions with heft.json configuration example - Document all plugin options (srcFolder, generatedTsFolders, formatWithPrettier) - Document the x-tsdoc-release-tag vendor extension with usage example - Note that JsonSchema accepts vendor extensions by default --- .../heft-json-schema-typings-plugin/README.md | 98 ++++++++++++++++++- 1 file changed, 96 insertions(+), 2 deletions(-) diff --git a/heft-plugins/heft-json-schema-typings-plugin/README.md b/heft-plugins/heft-json-schema-typings-plugin/README.md index e6fc6cd0be1..69af8891ba2 100644 --- a/heft-plugins/heft-json-schema-typings-plugin/README.md +++ b/heft-plugins/heft-json-schema-typings-plugin/README.md @@ -1,12 +1,106 @@ # @rushstack/heft-json-schema-typings-plugin -This is a Heft plugin for generating TypeScript typings from JSON schema files. +This is a Heft plugin that generates TypeScript `.d.ts` typings from JSON Schema files +(files matching `*.schema.json`). It uses the +[json-schema-to-typescript](https://www.npmjs.com/package/json-schema-to-typescript) library +to produce type declarations that can be imported alongside the schema at build time. + +## Setup + +1. Add the plugin as a `devDependency` of your project: + + ```bash + rush add -p @rushstack/heft-json-schema-typings-plugin --dev + ``` + +2. Load the plugin in your project's **heft.json** configuration: + + ```jsonc + { + "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft.schema.json", + "phasesByName": { + "build": { + "tasksByName": { + "json-schema-typings": { + "taskPlugin": { + "pluginPackage": "@rushstack/heft-json-schema-typings-plugin", + "options": { + // (Optional) Defaults shown below + // "srcFolder": "src", + // "generatedTsFolders": ["temp/schemas-ts"], + // "formatWithPrettier": false + } + } + } + } + } + } + } + ``` + +3. Place your `*.schema.json` files under the source folder (default: `src/`). + The plugin will generate a corresponding `.d.ts` file for each schema. + +## Plugin options + +| Option | Type | Default | Description | +| -------------------- | ---------- | -------------------- | -------------------------------------------------------------------------------- | +| `srcFolder` | `string` | `"src"` | Source directory to scan for `*.schema.json` files. | +| `generatedTsFolders` | `string[]` | `["temp/schemas-ts"]`| Output directories for the generated `.d.ts` files. | +| `formatWithPrettier` | `boolean` | `false` | When `true`, format generated typings with [prettier](https://prettier.io/). Requires `prettier` as an installed dependency. | + +## Vendor extension: `x-tsdoc-release-tag` + +The plugin recognises a custom vendor extension property called **`x-tsdoc-release-tag`** in +your JSON Schema files. When present at the top level of a schema, its value (a +[TSDoc release tag](https://tsdoc.org/pages/spec/tag_kinds/#release-tags) such as `@public`, +`@beta`, `@alpha`, or `@internal`) is injected into JSDoc comments of every exported +declaration in the generated `.d.ts` file. + +This is useful when the generated types are re-exported from a package entry point that is +processed by [API Extractor](https://api-extractor.com/), which uses release tags to +determine the API surface visibility. + +### Example + +**my-config.schema.json** + +```json +{ + "x-tsdoc-release-tag": "@public", + "title": "My Config", + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "additionalProperties": false +} +``` + +**Generated output (my-config.schema.json.d.ts)** + +```ts +/** + * @public + */ +export interface MyConfig { + name?: string; +} +``` + +The `x-tsdoc-release-tag` property is stripped from the schema before type generation, so it +does not affect the shape of the emitted types. The value must be a single lowercase word +starting with `@` (for example `@public` or `@beta`); invalid values cause a build error. + +> **Note:** `@rushstack/node-core-library`'s `JsonSchema` class accepts vendor extension +> keywords matching the `x--` pattern by default, so schema files containing +> `x-tsdoc-release-tag` will validate without any additional configuration. ## Links - [CHANGELOG.md]( https://github.com/microsoft/rushstack/blob/main/heft-plugins/heft-json-schema-typings-plugin/CHANGELOG.md) - Find -out what's new in the latest version + out what's new in the latest version - [@rushstack/heft](https://www.npmjs.com/package/@rushstack/heft) - Heft is a config-driven toolchain that invokes popular tools such as TypeScript, ESLint, Jest, Webpack, and API Extractor. Heft is part of the [Rush Stack](https://rushstack.io/) family of projects. From d805fca6906b68b0bcfcb4d40854c344af939288 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 18 Feb 2026 12:06:41 -0800 Subject: [PATCH 14/14] Add tests for vendor extension keyword edge cases - Test that non-root vendor extension keywords are rejected - Test that malformed vendor tags (missing vendor segment, uppercase) are rejected --- .../src/test/JsonSchema.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/libraries/node-core-library/src/test/JsonSchema.test.ts b/libraries/node-core-library/src/test/JsonSchema.test.ts index 5a2f46526a3..44a482d9398 100644 --- a/libraries/node-core-library/src/test/JsonSchema.test.ts +++ b/libraries/node-core-library/src/test/JsonSchema.test.ts @@ -155,6 +155,59 @@ describe(JsonSchema.name, () => { expect(() => schemaWithVendorExtensions.validateObject({ name: 'hello' }, '')).toThrow(); }); + test('rejects vendor extension keywords that are not at the schema root level', () => { + const schemaWithNestedVendorExtension: JsonSchema = JsonSchema.fromLoadedObject( + { + title: 'Test nested vendor extension', + type: 'object', + properties: { + name: { + type: 'string', + 'x-myvendor-display-name': 'Name field' + } + }, + additionalProperties: false, + required: ['name'] + }, + { schemaVersion: 'draft-07' } + ); + expect(() => schemaWithNestedVendorExtension.validateObject({ name: 'hello' }, '')).toThrow(); + }); + + test('rejects malformed vendor extension keywords that do not match x--', () => { + // Missing vendor segment: "x-tag" has no second hyphen-separated part + const schemaWithMalformedTag: JsonSchema = JsonSchema.fromLoadedObject( + { + title: 'Test malformed vendor extension', + 'x-tag': '@beta', + type: 'object', + properties: { + name: { type: 'string' } + }, + additionalProperties: false, + required: ['name'] + }, + { schemaVersion: 'draft-07' } + ); + expect(() => schemaWithMalformedTag.validateObject({ name: 'hello' }, '')).toThrow(); + + // Uppercase characters in vendor segment + const schemaWithUppercaseTag: JsonSchema = JsonSchema.fromLoadedObject( + { + title: 'Test uppercase vendor extension', + 'x-MyVendor-tag': 'value', + type: 'object', + properties: { + name: { type: 'string' } + }, + additionalProperties: false, + required: ['name'] + }, + { schemaVersion: 'draft-07' } + ); + expect(() => schemaWithUppercaseTag.validateObject({ name: 'hello' }, '')).toThrow(); + }); + test('successfully applies custom formats', () => { const schemaWithCustomFormat = JsonSchema.fromLoadedObject( {