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 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 a0adc753617..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 @@ -29,16 +29,30 @@ async function getFolderItemsAsync( } describe('json-schema-typings-plugin', () => { - it('should generate typings for JSON Schemas', async () => { - const rootFolder: string | undefined = PackageJsonLookup.instance.tryGetPackageFolderFor(__dirname); - if (!rootFolder) { + 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`, '.' ); 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/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 +} +", +} +`; 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..f0158d77039 --- /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-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" + } + ], + "packageName": "@rushstack/heft-json-schema-typings-plugin" +} \ No newline at end of file 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/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..4a54ddf83de --- /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": "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" + } + ], + "packageName": "@rushstack/node-core-library" +} \ No newline at end of file diff --git a/common/reviews/api/node-core-library.api.md b/common/reviews/api/node-core-library.api.md index cc01c92c873..1d377c3b3f3 100644 --- a/common/reviews/api/node-core-library.api.md +++ b/common/reviews/api/node-core-library.api.md @@ -462,6 +462,8 @@ export type IJsonSchemaFromObjectOptions = IJsonSchemaLoadOptions; export interface IJsonSchemaLoadOptions { customFormats?: Record | IJsonSchemaCustomFormat>; dependentSchemas?: JsonSchema[]; + // @beta + rejectVendorExtensionKeywords?: boolean; schemaVersion?: JsonSchemaVersion; } 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. 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/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 8b0cadc7e23..deb30f2742b 100644 --- a/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.ts +++ b/heft-plugins/heft-json-schema-typings-plugin/src/JsonSchemaTypingsGenerator.ts @@ -1,28 +1,74 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -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'; -interface IJsonSchemaTypingsGeneratorBaseOptions extends ITypingsGeneratorBaseOptions {} +import { + _addTsDocReleaseTagToExports, + _validateTsDocReleaseTag, + X_TSDOC_RELEASE_TAG_KEY +} from './TsDocReleaseTagHelpers'; + +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'; + +type Json4Schema = Parameters[0]; +interface IExtendedJson4Schema extends Json4Schema { + [X_TSDOC_RELEASE_TAG_KEY]?: string; +} export class JsonSchemaTypingsGenerator extends TypingsGenerator { public constructor(options: IJsonSchemaTypingsGeneratorBaseOptions) { + const { formatWithPrettier = false, ...otherOptions } = options; super({ - ...options, - fileExtensions: ['.schema.json'], - // Don't bother reading the file contents, compileFromFile will read the file - readFile: () => '', + ...otherOptions, + fileExtensions: [SCHEMA_FILE_EXTENSION], // eslint-disable-next-line @typescript-eslint/naming-convention - parseAndGenerateTypings: async (fileContents: string, filePath: string): Promise => - await compileFromFile(filePath, { + parseAndGenerateTypings: async ( + fileContents: string, + filePath: string, + relativePath: string + ): Promise => { + const parsedFileContents: IExtendedJson4Schema = JSON.parse(fileContents); + const { [X_TSDOC_RELEASE_TAG_KEY]: tsdocReleaseTag, ...jsonSchemaWithoutReleaseTag } = + 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(jsonSchemaWithoutReleaseTag, filenameWithoutExtension, { // The typings generator adds its own banner comment bannerComment: '', - cwd: path.dirname(filePath) - }) + cwd: dirname, + format: formatWithPrettier + }); + + // 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 (tsdocReleaseTag) { + _validateTsDocReleaseTag(tsdocReleaseTag, relativePath); + typings = _addTsDocReleaseTagToExports(typings, tsdocReleaseTag); + } + + return typings; + } }); } } 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/TsDocReleaseTagHelpers.ts b/heft-plugins/heft-json-schema-typings-plugin/src/TsDocReleaseTagHelpers.ts new file mode 100644 index 00000000000..3c736a094e8 --- /dev/null +++ b/heft-plugins/heft-json-schema-typings-plugin/src/TsDocReleaseTagHelpers.ts @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +export const X_TSDOC_RELEASE_TAG_KEY: 'x-tsdoc-release-tag' = 'x-tsdoc-release-tag'; +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_KEY} 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. + * + * `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. + */ +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'); + + // 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(/(? { + 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-release-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/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/__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 new file mode 100644 index 00000000000..4c40e324a60 --- /dev/null +++ b/heft-plugins/heft-json-schema-typings-plugin/src/test/__snapshots__/TsDocReleaseTagHelpers.test.ts.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`_addTsDocReleaseTagToExports creates a new JSDoc block for an export without a preceding comment 1`] = ` +"/** + * @public + */ +export type Foo = {};" +`; + +exports[`_addTsDocReleaseTagToExports does not double-tag an export that already has a JSDoc block 1`] = ` +"/** + * Already documented. + * + * @public + */ +export interface IConfig { + name: string; +}" +`; + +exports[`_addTsDocReleaseTagToExports does not modify non-export lines 1`] = ` +"// A leading comment +const internal = 1; + +/** + * @beta + */ +export type Foo = {};" +`; + +exports[`_addTsDocReleaseTagToExports handles multiple exports with and without JSDoc comments 1`] = ` +"/** + * First type. + * + * @beta + */ +export type Foo = {}; + +/** + * @beta + */ +export type Bar = {};" +`; + +exports[`_addTsDocReleaseTagToExports injects tag into an existing JSDoc comment before an export 1`] = ` +"/** + * A description. + * + * @beta + */ +export type Foo = {};" +`; + +exports[`_addTsDocReleaseTagToExports normalizes CRLF line endings to LF 1`] = ` +"/** + * A description. + * + * @beta + */ +export type Foo = {};" +`; 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..14511d830b7 --- /dev/null +++ b/heft-plugins/heft-json-schema-typings-plugin/src/test/schemas/with-tsdoc-tag.schema.json @@ -0,0 +1,12 @@ +{ + "x-tsdoc-release-tag": "@public", + "title": "Public Config", + "type": "object", + "properties": { + "value": { + "description": "A value.", + "type": "string" + } + }, + "additionalProperties": false +} diff --git a/libraries/node-core-library/src/JsonSchema.ts b/libraries/node-core-library/src/JsonSchema.ts index 2ca3f227190..a1ffdf13dfb 100644 --- a/libraries/node-core-library/src/JsonSchema.ts +++ b/libraries/node-core-library/src/JsonSchema.ts @@ -11,6 +11,27 @@ import addFormats from 'ajv-formats'; import { JsonFile, type JsonObject } from './JsonFile'; import { FileSystem } from './FileSystem'; +/** + * 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-[a-z0-9]+-[a-z0-9]+(-[a-z0-9]+)*$/; + +/** + * 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 (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); + } + } + } +} + interface ISchemaWithId { // draft-04 uses "id" id: string | undefined; @@ -119,6 +140,25 @@ export interface IJsonSchemaLoadOptions { * for example define generic numeric formats (e.g. uint8) or domain-specific formats. */ customFormats?: Record | IJsonSchemaCustomFormat>; + + /** + * 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-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 + * 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 + */ + rejectVendorExtensionKeywords?: boolean; } /** @@ -169,6 +209,7 @@ export class JsonSchema { private _customFormats: | Record | IJsonSchemaCustomFormat> | undefined = undefined; + private _rejectVendorExtensionKeywords: boolean = false; private constructor() {} @@ -192,6 +233,7 @@ export class JsonSchema { schema._dependentSchemas = options.dependentSchemas || []; schema._schemaVersion = options.schemaVersion; schema._customFormats = options.customFormats; + schema._rejectVendorExtensionKeywords = options.rejectVendorExtensionKeywords ?? false; } return schema; @@ -211,6 +253,7 @@ export class JsonSchema { schema._dependentSchemas = options.dependentSchemas || []; schema._schemaVersion = options.schemaVersion; schema._customFormats = options.customFormats; + schema._rejectVendorExtensionKeywords = options.rejectVendorExtensionKeywords ?? false; } return schema; @@ -350,6 +393,20 @@ export class JsonSchema { JsonSchema._collectDependentSchemas(collectedSchemas, this._dependentSchemas, seenObjects, seenIds); + // Unless explicitly rejected, scan the top-level keys of each schema for vendor + // extension keys matching the x-- pattern and register them with + // AJV so that strict mode does not reject them as unknown keywords. + if (!this._rejectVendorExtensionKeywords) { + const vendorKeywords: Set = new Set(); + _collectVendorExtensionKeywords(this._schemaObject, vendorKeywords); + for (const collectedSchema of collectedSchemas) { + _collectVendorExtensionKeywords(collectedSchema._schemaObject, vendorKeywords); + } + for (const keyword of vendorKeywords) { + validator.addKeyword(keyword); + } + } + // 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/test/JsonSchema.test.ts b/libraries/node-core-library/src/test/JsonSchema.test.ts index c5f9ffa9df3..44a482d9398 100644 --- a/libraries/node-core-library/src/test/JsonSchema.test.ts +++ b/libraries/node-core-library/src/test/JsonSchema.test.ts @@ -120,6 +120,94 @@ describe(JsonSchema.name, () => { }); }); + test('accepts vendor extension keywords by default', () => { + const schemaWithVendorExtensions: JsonSchema = JsonSchema.fromLoadedObject( + { + title: 'Test vendor extensions', + 'x-tsdoc-release-tag': '@beta', + 'x-myvendor-html-description': 'bold', + type: 'object', + properties: { + name: { type: 'string' } + }, + additionalProperties: false, + required: ['name'] + }, + { schemaVersion: 'draft-07' } + ); + expect(() => schemaWithVendorExtensions.validateObject({ name: 'hello' }, '')).not.toThrow(); + }); + + test('rejects vendor extension keywords when rejectVendorExtensionKeywords is enabled', () => { + const schemaWithVendorExtensions: JsonSchema = JsonSchema.fromLoadedObject( + { + title: 'Test vendor extensions rejected', + 'x-tsdoc-release-tag': '@beta', + type: 'object', + properties: { + name: { type: 'string' } + }, + additionalProperties: false, + required: ['name'] + }, + { schemaVersion: 'draft-07', rejectVendorExtensionKeywords: true } + ); + 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( {