diff --git a/common/changes/@microsoft/rush-lib/zod-schema-pilot_2026-04-19.json b/common/changes/@microsoft/rush-lib/zod-schema-pilot_2026-04-19.json new file mode 100644 index 00000000000..f34bb8ff021 --- /dev/null +++ b/common/changes/@microsoft/rush-lib/zod-schema-pilot_2026-04-19.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush-lib", + "comment": "Move the IExperimentsJson interface declaration to the new @rushstack/rush-schemas package. rush-lib re-exports the interface so existing imports remain stable.", + "type": "none" + } + ], + "packageName": "@microsoft/rush-lib" +} diff --git a/common/changes/@rushstack/heft-zod-schema-plugin/zod-schema-plugin_2026-04-19.json b/common/changes/@rushstack/heft-zod-schema-plugin/zod-schema-plugin_2026-04-19.json new file mode 100644 index 00000000000..a0f30ab849b --- /dev/null +++ b/common/changes/@rushstack/heft-zod-schema-plugin/zod-schema-plugin_2026-04-19.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft-zod-schema-plugin", + "comment": "Initial release. A Heft task plugin that converts zod validators into *.schema.json build outputs using zod 4's built-in z.toJSONSchema(). Includes a withSchemaMeta() helper for attaching $schema/title/description/x-tsdoc-release-tag metadata to a zod schema.", + "type": "minor" + } + ], + "packageName": "@rushstack/heft-zod-schema-plugin" +} diff --git a/common/changes/@rushstack/rush-schemas/rush-schemas-init_2026-04-19.json b/common/changes/@rushstack/rush-schemas/rush-schemas-init_2026-04-19.json new file mode 100644 index 00000000000..7b7d95da5d7 --- /dev/null +++ b/common/changes/@rushstack/rush-schemas/rush-schemas-init_2026-04-19.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-schemas", + "comment": "Initial release. Pilots a dedicated home for zod-authored Rush configuration schemas (experiments, cobuild, repo-state, build-cache) that emits both runtime validators and *.schema.json artifacts.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-schemas" +} diff --git a/common/config/rush/nonbrowser-approved-packages.json b/common/config/rush/nonbrowser-approved-packages.json index de6d664412f..ff7c5135339 100644 --- a/common/config/rush/nonbrowser-approved-packages.json +++ b/common/config/rush/nonbrowser-approved-packages.json @@ -290,6 +290,10 @@ "name": "@rushstack/heft-webpack5-plugin", "allowedCategories": [ "libraries", "tests", "vscode-extensions" ] }, + { + "name": "@rushstack/heft-zod-schema-plugin", + "allowedCategories": [ "libraries" ] + }, { "name": "@rushstack/localization-utilities", "allowedCategories": [ "libraries" ] @@ -378,6 +382,10 @@ "name": "@rushstack/rush-resolver-cache-plugin", "allowedCategories": [ "libraries" ] }, + { + "name": "@rushstack/rush-schemas", + "allowedCategories": [ "libraries" ] + }, { "name": "@rushstack/rush-sdk", "allowedCategories": [ "libraries", "tests", "vscode-extensions" ] diff --git a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml index 18ef85bc98c..47d5eace8fc 100644 --- a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml +++ b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml @@ -24,7 +24,7 @@ importers: dependencies: '@microsoft/rush-lib': specifier: file:../../libraries/rush-lib - version: file:../../../libraries/rush-lib(@types/node@20.17.19) + version: file:../../../libraries/rush-lib(@rushstack/heft@file:../../../apps/heft(@types/node@20.17.19))(@types/node@20.17.19) '@rushstack/terminal': specifier: file:../../libraries/terminal version: file:../../../libraries/terminal(@types/node@20.17.19) @@ -55,7 +55,7 @@ importers: dependencies: '@rushstack/rush-sdk': specifier: file:../../libraries/rush-sdk - version: file:../../../libraries/rush-sdk(@types/node@20.17.19) + version: file:../../../libraries/rush-sdk(@rushstack/heft@file:../../../apps/heft(@types/node@20.17.19))(@types/node@20.17.19) dependenciesMeta: '@microsoft/rush-lib': injected: true @@ -68,7 +68,7 @@ importers: devDependencies: '@microsoft/rush-lib': specifier: file:../../libraries/rush-lib - version: file:../../../libraries/rush-lib(@types/node@20.17.19) + version: file:../../../libraries/rush-lib(@rushstack/heft@file:../../../apps/heft(@types/node@20.17.19))(@types/node@20.17.19) '@rushstack/heft': specifier: file:../../apps/heft version: file:../../../apps/heft(@types/node@20.17.19) @@ -911,7 +911,7 @@ packages: '@rushstack/heft-api-extractor-plugin@file:../../../heft-plugins/heft-api-extractor-plugin': resolution: {directory: ../../../heft-plugins/heft-api-extractor-plugin, type: directory} peerDependencies: - '@rushstack/heft': 1.2.14 + '@rushstack/heft': 1.2.15 '@rushstack/heft-config-file@file:../../../libraries/heft-config-file': resolution: {directory: ../../../libraries/heft-config-file, type: directory} @@ -920,7 +920,7 @@ packages: '@rushstack/heft-jest-plugin@file:../../../heft-plugins/heft-jest-plugin': resolution: {directory: ../../../heft-plugins/heft-jest-plugin, type: directory} peerDependencies: - '@rushstack/heft': ^1.2.14 + '@rushstack/heft': ^1.2.15 '@types/jest': ^30.0.0 jest-environment-jsdom: ^30.3.0 jest-environment-node: ^30.3.0 @@ -935,17 +935,23 @@ packages: '@rushstack/heft-lint-plugin@file:../../../heft-plugins/heft-lint-plugin': resolution: {directory: ../../../heft-plugins/heft-lint-plugin, type: directory} peerDependencies: - '@rushstack/heft': 1.2.14 + '@rushstack/heft': 1.2.15 '@rushstack/heft-node-rig@file:../../../rigs/heft-node-rig': resolution: {directory: ../../../rigs/heft-node-rig, type: directory} peerDependencies: - '@rushstack/heft': ^1.2.14 + '@rushstack/heft': ^1.2.15 '@rushstack/heft-typescript-plugin@file:../../../heft-plugins/heft-typescript-plugin': resolution: {directory: ../../../heft-plugins/heft-typescript-plugin, type: directory} peerDependencies: - '@rushstack/heft': 1.2.14 + '@rushstack/heft': 1.2.15 + + '@rushstack/heft-zod-schema-plugin@file:../../../heft-plugins/heft-zod-schema-plugin': + resolution: {directory: ../../../heft-plugins/heft-zod-schema-plugin, type: directory} + peerDependencies: + '@rushstack/heft': 1.2.15 + zod: '>=4.0.0' '@rushstack/heft@file:../../../apps/heft': resolution: {directory: ../../../apps/heft, type: directory} @@ -1005,6 +1011,9 @@ packages: '@rushstack/rush-pnpm-kit-v9@file:../../../libraries/rush-pnpm-kit-v9': resolution: {directory: ../../../libraries/rush-pnpm-kit-v9, type: directory} + '@rushstack/rush-schemas@file:../../../libraries/rush-schemas': + resolution: {directory: ../../../libraries/rush-schemas, type: directory} + '@rushstack/rush-sdk@file:../../../libraries/rush-sdk': resolution: {directory: ../../../libraries/rush-sdk, type: directory} @@ -3675,6 +3684,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + snapshots: '@babel/code-frame@7.29.0': @@ -4300,7 +4312,7 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@microsoft/rush-lib@file:../../../libraries/rush-lib(@types/node@20.17.19)': + '@microsoft/rush-lib@file:../../../libraries/rush-lib(@rushstack/heft@file:../../../apps/heft(@types/node@20.17.19))(@types/node@20.17.19)': dependencies: '@inquirer/checkbox': 5.1.3(@types/node@20.17.19) '@inquirer/confirm': 6.0.11(@types/node@20.17.19) @@ -4319,6 +4331,7 @@ snapshots: '@rushstack/rush-pnpm-kit-v10': file:../../../libraries/rush-pnpm-kit-v10 '@rushstack/rush-pnpm-kit-v8': file:../../../libraries/rush-pnpm-kit-v8 '@rushstack/rush-pnpm-kit-v9': file:../../../libraries/rush-pnpm-kit-v9 + '@rushstack/rush-schemas': file:../../../libraries/rush-schemas(@rushstack/heft@file:../../../apps/heft(@types/node@20.17.19))(@types/node@20.17.19) '@rushstack/stream-collator': file:../../../libraries/stream-collator(@types/node@20.17.19) '@rushstack/terminal': file:../../../libraries/terminal(@types/node@20.17.19) '@rushstack/ts-command-line': file:../../../libraries/ts-command-line(@types/node@20.17.19) @@ -4342,6 +4355,7 @@ snapshots: tar: 7.5.13 true-case-path: 2.2.1 transitivePeerDependencies: + - '@rushstack/heft' - '@types/node' - supports-color @@ -4835,6 +4849,15 @@ snapshots: transitivePeerDependencies: - '@types/node' + '@rushstack/heft-zod-schema-plugin@file:../../../heft-plugins/heft-zod-schema-plugin(@rushstack/heft@file:../../../apps/heft(@types/node@20.17.19))(@types/node@20.17.19)(zod@4.3.6)': + dependencies: + '@rushstack/heft': file:../../../apps/heft(@types/node@20.17.19) + '@rushstack/node-core-library': file:../../../libraries/node-core-library(@types/node@20.17.19) + fast-glob: 3.3.3 + zod: 4.3.6 + transitivePeerDependencies: + - '@types/node' + '@rushstack/heft@file:../../../apps/heft(@types/node@20.17.19)': dependencies: '@rushstack/heft-config-file': file:../../../libraries/heft-config-file(@types/node@20.17.19) @@ -4930,16 +4953,26 @@ snapshots: '@pnpm/lockfile.fs-pnpm-lock-v9': '@pnpm/lockfile.fs@1001.1.32(@pnpm/logger@1001.0.1)' '@pnpm/logger': 1001.0.1 - '@rushstack/rush-sdk@file:../../../libraries/rush-sdk(@types/node@20.17.19)': + '@rushstack/rush-schemas@file:../../../libraries/rush-schemas(@rushstack/heft@file:../../../apps/heft(@types/node@20.17.19))(@types/node@20.17.19)': + dependencies: + '@rushstack/heft-zod-schema-plugin': file:../../../heft-plugins/heft-zod-schema-plugin(@rushstack/heft@file:../../../apps/heft(@types/node@20.17.19))(@types/node@20.17.19)(zod@4.3.6) + zod: 4.3.6 + transitivePeerDependencies: + - '@rushstack/heft' + - '@types/node' + + '@rushstack/rush-sdk@file:../../../libraries/rush-sdk(@rushstack/heft@file:../../../apps/heft(@types/node@20.17.19))(@types/node@20.17.19)': dependencies: '@pnpm/lockfile.types-900': '@pnpm/lockfile.types@900.0.0' '@rushstack/credential-cache': file:../../../libraries/credential-cache(@types/node@20.17.19) '@rushstack/lookup-by-path': file:../../../libraries/lookup-by-path(@types/node@20.17.19) '@rushstack/node-core-library': file:../../../libraries/node-core-library(@types/node@20.17.19) '@rushstack/package-deps-hash': file:../../../libraries/package-deps-hash(@types/node@20.17.19) + '@rushstack/rush-schemas': file:../../../libraries/rush-schemas(@rushstack/heft@file:../../../apps/heft(@types/node@20.17.19))(@types/node@20.17.19) '@rushstack/terminal': file:../../../libraries/terminal(@types/node@20.17.19) tapable: 2.2.1 transitivePeerDependencies: + - '@rushstack/heft' - '@types/node' '@rushstack/stream-collator@file:../../../libraries/stream-collator(@types/node@20.17.19)': @@ -8332,3 +8365,5 @@ snapshots: yaml@2.4.1: {} yocto-queue@0.1.0: {} + + zod@4.3.6: {} diff --git a/common/config/subspaces/build-tests-subspace/repo-state.json b/common/config/subspaces/build-tests-subspace/repo-state.json index 51c6c27e844..647515d1a38 100644 --- a/common/config/subspaces/build-tests-subspace/repo-state.json +++ b/common/config/subspaces/build-tests-subspace/repo-state.json @@ -1,6 +1,6 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "1266218fdf9ed4d67e625f96e8c1cc4bae29dc68", + "pnpmShrinkwrapHash": "560bb998f1afda8ac0aa2d2087111c88f23bc845", "preferredVersionsHash": "550b4cee0bef4e97db6c6aad726df5149d20e7d9", - "packageJsonInjectedDependenciesHash": "9c068bf4931bd84aa82934f391073bf027e52b69" + "packageJsonInjectedDependenciesHash": "3f7b5f1960e61c2a3d039187d38b51c9211f6b17" } diff --git a/common/config/subspaces/default/common-versions.json b/common/config/subspaces/default/common-versions.json index 40fc6f8597a..9ed739d85c9 100644 --- a/common/config/subspaces/default/common-versions.json +++ b/common/config/subspaces/default/common-versions.json @@ -117,6 +117,12 @@ "2.2.1", "1.1.3" // heft plugin is using an older version of tapable ], + "zod": [ + // rush-mcp-server pins to zod 3 to remain compatible with @modelcontextprotocol/sdk; + // heft-zod-schema-plugin and the rush-lib pilot use zod 4 for its built-in + // z.toJSONSchema() API. + "~3.25.76" + ], // --- For Webpack 4 projects ---- "css-loader": ["~5.2.7"], "html-webpack-plugin": ["~4.5.2"], diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 165c3afa6e8..3531164fec6 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -3654,6 +3654,31 @@ importers: specifier: ~5.105.2 version: 5.105.4 + ../../../heft-plugins/heft-zod-schema-plugin: + dependencies: + '@rushstack/node-core-library': + specifier: workspace:* + version: link:../../libraries/node-core-library + fast-glob: + specifier: ~3.3.1 + version: 3.3.3 + devDependencies: + '@rushstack/heft': + specifier: workspace:* + version: link:../../apps/heft + '@rushstack/terminal': + specifier: workspace:* + version: link:../../libraries/terminal + eslint: + specifier: ~9.37.0 + version: 9.37.0 + local-node-rig: + specifier: workspace:* + version: link:../../rigs/local-node-rig + zod: + specifier: ~4.3.6 + version: 4.3.6 + ../../../libraries/api-extractor-model: dependencies: '@microsoft/tsdoc': @@ -4088,6 +4113,9 @@ importers: '@rushstack/rush-pnpm-kit-v9': specifier: workspace:* version: link:../rush-pnpm-kit-v9 + '@rushstack/rush-schemas': + specifier: workspace:* + version: link:../rush-schemas '@rushstack/stream-collator': specifier: workspace:* version: link:../stream-collator @@ -4264,6 +4292,25 @@ importers: specifier: workspace:* version: link:../../rigs/local-node-rig + ../../../libraries/rush-schemas: + dependencies: + '@rushstack/heft-zod-schema-plugin': + specifier: workspace:* + version: link:../../heft-plugins/heft-zod-schema-plugin + zod: + specifier: ~4.3.6 + version: 4.3.6 + devDependencies: + '@rushstack/heft': + specifier: workspace:* + version: link:../../apps/heft + eslint: + specifier: ~9.37.0 + version: 9.37.0 + local-node-rig: + specifier: workspace:* + version: link:../../rigs/local-node-rig + ../../../libraries/rush-sdk: dependencies: '@pnpm/lockfile.types-900': @@ -4281,6 +4328,9 @@ importers: '@rushstack/package-deps-hash': specifier: workspace:* version: link:../package-deps-hash + '@rushstack/rush-schemas': + specifier: workspace:* + version: link:../rush-schemas '@rushstack/terminal': specifier: workspace:* version: link:../terminal @@ -19108,6 +19158,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zwitch@1.0.5: resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==} @@ -38491,4 +38544,6 @@ snapshots: zod@3.25.76: {} + zod@4.3.6: {} + zwitch@1.0.5: {} diff --git a/common/config/subspaces/default/repo-state.json b/common/config/subspaces/default/repo-state.json index e3196053836..04046d25efe 100644 --- a/common/config/subspaces/default/repo-state.json +++ b/common/config/subspaces/default/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "b649b4390090c37d5feb374b0c04bf100edc8047", + "pnpmShrinkwrapHash": "a2c8f7f19f774ed72d75aa83c6639ac8e034c8ba", "preferredVersionsHash": "029c99bd6e65c5e1f25e2848340509811ff9753c" } diff --git a/common/reviews/api/node-core-library.api.md b/common/reviews/api/node-core-library.api.md index 7b787350514..aa9a2996772 100644 --- a/common/reviews/api/node-core-library.api.md +++ b/common/reviews/api/node-core-library.api.md @@ -457,6 +457,11 @@ export interface IJsonFileStringifyOptions extends IJsonFileParseOptions { prettyFormatting?: boolean; } +// @public +export interface IJsonFileTypeValidator { + parse(input: unknown): T; +} + // @public export interface IJsonSchemaCustomFormat { type: T extends string ? 'string' : T extends number ? 'number' : never; @@ -732,6 +737,8 @@ export class JsonFile { // @internal (undocumented) static _formatPathForError: (path: string) => string; static load(jsonFilename: string, options?: IJsonFileParseOptions): JsonObject; + static loadAndParse(jsonFilename: string, validator: IJsonFileTypeValidator, options?: IJsonFileParseOptions): T; + static loadAndParseAsync(jsonFilename: string, validator: IJsonFileTypeValidator, options?: IJsonFileParseOptions): Promise; static loadAndValidate(jsonFilename: string, jsonSchema: JsonSchema, options?: IJsonFileLoadAndValidateOptions): JsonObject; static loadAndValidateAsync(jsonFilename: string, jsonSchema: JsonSchema, options?: IJsonFileLoadAndValidateOptions): Promise; static loadAndValidateWithCallback(jsonFilename: string, jsonSchema: JsonSchema, errorCallback: (errorInfo: IJsonSchemaErrorInfo) => void, options?: IJsonFileLoadAndValidateOptions): JsonObject; diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 706fca89ba0..dced889f74a 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -15,8 +15,10 @@ import type { CommandLineParameter } from '@rushstack/ts-command-line'; import { CommandLineParameterKind } from '@rushstack/ts-command-line'; import { CredentialCache } from '@rushstack/credential-cache'; import { HookMap } from 'tapable'; +import { ICobuildJson } from '@rushstack/rush-schemas'; import { ICredentialCacheEntry } from '@rushstack/credential-cache'; import { ICredentialCacheOptions } from '@rushstack/credential-cache'; +import { IExperimentsJson } from '@rushstack/rush-schemas'; import { IFileDiffStatus } from '@rushstack/package-deps-hash'; import { IPackageJson } from '@rushstack/node-core-library'; import { IPrefixMatch } from '@rushstack/lookup-by-path'; @@ -375,13 +377,7 @@ export interface ICobuildContext { runnerId: string; } -// @beta (undocumented) -export interface ICobuildJson { - // (undocumented) - cobuildFeatureEnabled: boolean; - // (undocumented) - cobuildLockProvider: string; -} +export { ICobuildJson } // @beta (undocumented) export interface ICobuildLockProvider { @@ -466,28 +462,7 @@ export interface IExecutionResult { readonly status: OperationStatus; } -// @beta -export interface IExperimentsJson { - allowCobuildWithoutCache?: boolean; - buildCacheWithAllowWarningsInSuccessfulBuild?: boolean; - buildSkipWithAllowWarningsInSuccessfulBuild?: boolean; - cleanInstallAfterNpmrcChanges?: boolean; - enableSubpathScan?: boolean; - exemptDecoupledDependenciesBetweenSubspaces?: boolean; - forbidPhantomResolvableNodeModulesFolders?: boolean; - generateProjectImpactGraphDuringRushUpdate?: boolean; - noChmodFieldInTarHeaderNormalization?: boolean; - omitAppleDoubleFilesFromBuildCache?: boolean; - omitImportersFromPreventManualShrinkwrapChanges?: boolean; - printEventHooksOutputToConsole?: boolean; - rushAlerts?: boolean; - strictChangefileValidation?: boolean; - useIPCScriptsInWatchMode?: boolean; - usePnpmFrozenLockfileForRushInstall?: boolean; - usePnpmLockfileOnlyThenFrozenLockfileForRushUpdate?: boolean; - usePnpmPreferFrozenLockfileForRushUpdate?: boolean; - usePnpmSyncForInjectedDependencies?: boolean; -} +export { IExperimentsJson } // @beta export interface IFileSystemBuildCacheProviderOptions { diff --git a/heft-plugins/heft-zod-schema-plugin/LICENSE b/heft-plugins/heft-zod-schema-plugin/LICENSE new file mode 100644 index 00000000000..5958435f59f --- /dev/null +++ b/heft-plugins/heft-zod-schema-plugin/LICENSE @@ -0,0 +1,24 @@ +@rushstack/heft-zod-schema-plugin + +Copyright (c) Microsoft Corporation. All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/heft-plugins/heft-zod-schema-plugin/README.md b/heft-plugins/heft-zod-schema-plugin/README.md new file mode 100644 index 00000000000..24eee141aec --- /dev/null +++ b/heft-plugins/heft-zod-schema-plugin/README.md @@ -0,0 +1,145 @@ +# @rushstack/heft-zod-schema-plugin + +A Heft task plugin that generates JSON Schema files (`*.schema.json`) from +[zod](https://zod.dev/) validators at build time. It is the inverse of +[`@rushstack/heft-json-schema-typings-plugin`](https://www.npmjs.com/package/@rushstack/heft-json-schema-typings-plugin), +and is intended for projects that prefer to keep a single source of truth (the +zod schema) and have both the runtime validator and the published JSON Schema +generated from it. + +## How it works + +1. You author one TypeScript module per schema, e.g. `src/schemas/foo.zod.ts`, + that exports a zod schema (typically as the default export). +2. The TypeScript compiler emits `lib/schemas/foo.zod.js`. +3. This plugin loads that compiled module, calls zod's built-in + [`z.toJSONSchema()`](https://zod.dev/json-schema), and writes a + `lib/schemas/foo.schema.json` file as a build artifact. +4. The companion TypeScript interface is obtained from the same source via + `z.infer` — no second source of truth and no extra + codegen step. + +## Setup + +1. Add the plugin and zod (4.0 or later) as dependencies of your project: + + ```bash + rush add -p @rushstack/heft-zod-schema-plugin --dev + rush add -p zod + ``` + +2. Load the plugin in your project's **heft.json**. Because the plugin reads + compiled JavaScript, declare it as a task that runs **after** the + `typescript` task: + + ```jsonc + { + "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft.schema.json", + "phasesByName": { + "build": { + "tasksByName": { + "zod-schema": { + "taskDependencies": ["typescript"], + "taskPlugin": { + "pluginPackage": "@rushstack/heft-zod-schema-plugin", + "options": { + // (Optional) Defaults shown below + // "inputGlobs": ["lib/schemas/*.zod.js"], + // "outputFolder": "lib/schemas", + // "exportName": "default", + // "indent": 2 + } + } + } + } + } + } + } + ``` + +3. Author your schema modules: + + ```ts + // src/schemas/my-config.zod.ts + import { z } from 'zod'; + + const myConfigSchema = z.object({ + name: z.string().describe('The name of the item.'), + count: z.number().int().optional() + }); + + export type IMyConfig = z.infer; + export default myConfigSchema; + ``` + + Each build will (re-)generate `lib/schemas/my-config.schema.json` from the + compiled `lib/schemas/my-config.zod.js`. + +## Plugin options + +| Option | Type | Default | Description | +| -------------- | ---------- | ----------------------------- | -------------------------------------------------------------------------------------------------------- | +| `inputGlobs` | `string[]` | `["lib/schemas/*.zod.js"]` | Globs (relative to the project folder) identifying compiled zod modules. | +| `outputFolder` | `string` | `"lib/schemas"` | Folder for the generated `*.schema.json` files. | +| `exportName` | `string` | `"default"` | Export to read from each module. Use `"*"` to emit one schema per named `ZodType` export of the module. | +| `indent` | `integer` | `2` | Number of spaces used to pretty-print the JSON output. | + +## Authoring metadata: `withSchemaMeta()` + +Top-level metadata such as `$schema`, `$id`, `title`, and a TSDoc release tag +can be attached without depending on zod-internal APIs: + +```ts +import { z } from 'zod'; +import { withSchemaMeta } from '@rushstack/heft-zod-schema-plugin/lib/SchemaMetaHelpers'; + +const myConfigSchema = withSchemaMeta( + z.object({ + name: z.string() + }), + { + $schema: 'https://developer.microsoft.com/json-schemas/my-product/v1/my-config.schema.json', + title: 'My Config', + releaseTag: '@public' + } +); + +export type IMyConfig = z.infer; +export default myConfigSchema; +``` + +The `releaseTag` field is emitted as the `x-tsdoc-release-tag` vendor extension, +which is the same convention recognised by +[`@rushstack/heft-json-schema-typings-plugin`](https://www.npmjs.com/package/@rushstack/heft-json-schema-typings-plugin), +so you can chain the two plugins to produce both a `.schema.json` and a tagged +`.d.ts` from the same zod source. The tag value must be a single lowercase +word starting with `@` (for example `@public` or `@beta`); invalid values cause +a build error. + +## Generating TypeScript interfaces + +The recommended pattern is to use zod's own `z.infer`: + +```ts +export type IMyConfig = z.infer; +``` + +This works without any additional build step and stays in sync with the schema +automatically. + +If your project needs a named, fully-expanded `interface` declaration in a +generated `.d.ts` file (for example to control the public API surface of an +API-Extractor-processed package), you can chain +[`@rushstack/heft-json-schema-typings-plugin`](https://www.npmjs.com/package/@rushstack/heft-json-schema-typings-plugin) +after this plugin and point its `srcFolder` option at the +`outputFolder` of this plugin. + +## Links + +- [CHANGELOG.md]( + https://github.com/microsoft/rushstack/blob/main/heft-plugins/heft-zod-schema-plugin/CHANGELOG.md) - Find + 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. +- [zod](https://zod.dev/) - TypeScript-first schema validation with static type inference. + +Heft is part of the [Rush Stack](https://rushstack.io/) family of projects. diff --git a/heft-plugins/heft-zod-schema-plugin/config/heft.json b/heft-plugins/heft-zod-schema-plugin/config/heft.json new file mode 100644 index 00000000000..0e52387039a --- /dev/null +++ b/heft-plugins/heft-zod-schema-plugin/config/heft.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft.schema.json", + "extends": "local-node-rig/profiles/default/config/heft.json", + + "phasesByName": { + "build": { + "tasksByName": { + "copy-json-schemas": { + "taskPlugin": { + "pluginPackage": "@rushstack/heft", + "pluginName": "copy-files-plugin", + "options": { + "copyOperations": [ + { + "sourcePath": "src/schemas", + "destinationFolders": ["temp/json-schemas/heft/v1"], + "fileExtensions": [".schema.json"], + "hardlink": true + } + ] + } + } + } + } + } + } +} diff --git a/heft-plugins/heft-zod-schema-plugin/config/jest.config.json b/heft-plugins/heft-zod-schema-plugin/config/jest.config.json new file mode 100644 index 00000000000..d1749681d90 --- /dev/null +++ b/heft-plugins/heft-zod-schema-plugin/config/jest.config.json @@ -0,0 +1,3 @@ +{ + "extends": "local-node-rig/profiles/default/config/jest.config.json" +} diff --git a/heft-plugins/heft-zod-schema-plugin/config/rig.json b/heft-plugins/heft-zod-schema-plugin/config/rig.json new file mode 100644 index 00000000000..9d412b88354 --- /dev/null +++ b/heft-plugins/heft-zod-schema-plugin/config/rig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + + "rigPackageName": "local-node-rig" +} diff --git a/heft-plugins/heft-zod-schema-plugin/eslint.config.js b/heft-plugins/heft-zod-schema-plugin/eslint.config.js new file mode 100644 index 00000000000..c15e6077310 --- /dev/null +++ b/heft-plugins/heft-zod-schema-plugin/eslint.config.js @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const nodeTrustedToolProfile = require('local-node-rig/profiles/default/includes/eslint/flat/profile/node-trusted-tool'); +const friendlyLocalsMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/friendly-locals'); + +module.exports = [ + ...nodeTrustedToolProfile, + ...friendlyLocalsMixin, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + tsconfigRootDir: __dirname + } + } + } +]; diff --git a/heft-plugins/heft-zod-schema-plugin/heft-plugin.json b/heft-plugins/heft-zod-schema-plugin/heft-plugin.json new file mode 100644 index 00000000000..26592fe2dea --- /dev/null +++ b/heft-plugins/heft-zod-schema-plugin/heft-plugin.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft-plugin.schema.json", + + "taskPlugins": [ + { + "pluginName": "zod-schema-plugin", + "entryPoint": "./lib-commonjs/HeftZodSchemaPlugin", + "optionsSchema": "./lib-commonjs/schemas/heft-zod-schema-plugin.schema.json" + } + ] +} diff --git a/heft-plugins/heft-zod-schema-plugin/package.json b/heft-plugins/heft-zod-schema-plugin/package.json new file mode 100644 index 00000000000..40acfe30617 --- /dev/null +++ b/heft-plugins/heft-zod-schema-plugin/package.json @@ -0,0 +1,52 @@ +{ + "name": "@rushstack/heft-zod-schema-plugin", + "version": "0.1.0", + "description": "A Heft plugin for generating JSON Schema files (*.schema.json) from zod validators.", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/rushstack.git", + "directory": "heft-plugins/heft-zod-schema-plugin" + }, + "homepage": "https://rushstack.io/pages/heft/overview/", + "license": "MIT", + "scripts": { + "build": "heft test --clean", + "start": "heft build-watch", + "_phase:build": "heft run --only build -- --clean", + "_phase:test": "heft run --only test -- --clean" + }, + "peerDependencies": { + "@rushstack/heft": "1.2.15", + "zod": ">=4.0.0" + }, + "dependencies": { + "@rushstack/node-core-library": "workspace:*", + "fast-glob": "~3.3.1" + }, + "devDependencies": { + "@rushstack/heft": "workspace:*", + "@rushstack/terminal": "workspace:*", + "eslint": "~9.37.0", + "local-node-rig": "workspace:*", + "zod": "~4.3.6" + }, + "exports": { + "./lib/*.schema.json": "./lib-commonjs/*.schema.json", + "./lib/*": { + "types": "./lib-dts/*.d.ts", + "node": "./lib-commonjs/*.js", + "import": "./lib-esm/*.js", + "require": "./lib-commonjs/*.js" + }, + "./heft-plugin.json": "./heft-plugin.json", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "lib/*": [ + "lib-dts/*" + ] + } + }, + "sideEffects": false +} diff --git a/heft-plugins/heft-zod-schema-plugin/src/HeftZodSchemaPlugin.ts b/heft-plugins/heft-zod-schema-plugin/src/HeftZodSchemaPlugin.ts new file mode 100644 index 00000000000..390027a7923 --- /dev/null +++ b/heft-plugins/heft-zod-schema-plugin/src/HeftZodSchemaPlugin.ts @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import type { + HeftConfiguration, + IHeftTaskSession, + IHeftTaskPlugin, + IHeftTaskRunIncrementalHookOptions, + IWatchedFileState +} from '@rushstack/heft'; +import type { ITerminal } from '@rushstack/terminal'; + +import { ZodSchemaGenerator, type IGeneratedSchema } from './ZodSchemaGenerator'; + +const PLUGIN_NAME: 'zod-schema-plugin' = 'zod-schema-plugin'; + +const DEFAULT_INPUT_GLOBS: readonly string[] = ['lib/schemas/*.zod.js']; +const DEFAULT_OUTPUT_FOLDER: 'lib/schemas' = 'lib/schemas'; +const DEFAULT_EXPORT_NAME: 'default' = 'default'; +const DEFAULT_INDENT: 2 = 2; + +/** + * Options for `@rushstack/heft-zod-schema-plugin`. + * + * @public + */ +export interface IHeftZodSchemaPluginOptions { + /** + * Globs (relative to the project folder) identifying compiled JavaScript modules + * that export zod schemas. Defaults to `["lib/schemas/*.zod.js"]`. + */ + inputGlobs?: string[]; + + /** + * Folder (relative to the project folder) where the generated `*.schema.json` + * files will be written. Defaults to `"lib/schemas"`. + */ + outputFolder?: string; + + /** + * The name of the export to read from each module. Use `"default"` (the default) + * to read the default export, or `"*"` to emit one schema file per named + * `ZodType` export. + */ + exportName?: string; + + /** + * Number of spaces to indent the generated JSON. Defaults to `2`. + */ + indent?: number; +} + +/** + * A Heft task plugin that converts zod validators into `*.schema.json` build + * outputs. See `README.md` for usage details. + * + * @public + */ +export default class HeftZodSchemaPlugin implements IHeftTaskPlugin { + public apply( + taskSession: IHeftTaskSession, + heftConfiguration: HeftConfiguration, + options: IHeftZodSchemaPluginOptions + ): void { + const { + logger: { terminal }, + hooks: { run, runIncremental } + } = taskSession; + const { buildFolderPath } = heftConfiguration; + + const inputGlobs: string[] = + options.inputGlobs && options.inputGlobs.length > 0 + ? options.inputGlobs + : [...DEFAULT_INPUT_GLOBS]; + const outputFolder: string = options.outputFolder ?? DEFAULT_OUTPUT_FOLDER; + const exportName: string = options.exportName ?? DEFAULT_EXPORT_NAME; + const indent: number = options.indent ?? DEFAULT_INDENT; + + const generator: ZodSchemaGenerator = new ZodSchemaGenerator({ + buildFolderPath, + inputGlobs, + outputFolder, + exportName, + indent, + terminal + }); + + run.tapPromise(PLUGIN_NAME, async () => { + await this._runGeneratorAsync(generator, terminal); + }); + + runIncremental.tapPromise( + PLUGIN_NAME, + async (runIncrementalOptions: IHeftTaskRunIncrementalHookOptions) => { + const matched: Map = await runIncrementalOptions.watchGlobAsync( + inputGlobs, + { + cwd: buildFolderPath, + absolute: false + } + ); + let anyChanged: boolean = false; + for (const [, { changed }] of matched) { + if (changed) { + anyChanged = true; + break; + } + } + if (!anyChanged) { + return; + } + await this._runGeneratorAsync(generator, terminal); + } + ); + } + + private async _runGeneratorAsync( + generator: ZodSchemaGenerator, + terminal: ITerminal + ): Promise { + terminal.writeLine('Generating JSON schemas from zod validators...'); + const results: IGeneratedSchema[] = await generator.generateAsync(); + if (results.length === 0) { + terminal.writeWarningLine('No zod schema modules matched the configured input globs.'); + return; + } + let writtenCount: number = 0; + for (const result of results) { + if (result.wasWritten) { + writtenCount++; + terminal.writeVerboseLine(`Wrote ${path.relative(process.cwd(), result.outputFilePath)}`); + } + } + terminal.writeLine( + `Generated ${results.length} schema(s) (${writtenCount} written, ` + + `${results.length - writtenCount} unchanged).` + ); + } +} diff --git a/heft-plugins/heft-zod-schema-plugin/src/SchemaMetaHelpers.ts b/heft-plugins/heft-zod-schema-plugin/src/SchemaMetaHelpers.ts new file mode 100644 index 00000000000..1d73ada30bc --- /dev/null +++ b/heft-plugins/heft-zod-schema-plugin/src/SchemaMetaHelpers.ts @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * The vendor-extension property name used to embed a TSDoc release tag in a generated + * JSON Schema. Mirrors the `x-tsdoc-release-tag` extension recognised by + * `@rushstack/heft-json-schema-typings-plugin` so that the same convention works in + * both directions. + */ +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`). + * + * @internal + */ +export function _validateTsDocReleaseTag(value: string, sourceDescription: string): void { + if (!RELEASE_TAG_PATTERN.test(value)) { + throw new Error( + `Invalid ${X_TSDOC_RELEASE_TAG_KEY} value ${JSON.stringify(value)} in ${sourceDescription}. ` + + 'Expected a single lowercase word starting with "@" (e.g. "@public", "@beta").' + ); + } +} + +/** + * Top-level metadata that authors may attach to a zod schema for inclusion in the + * generated `*.schema.json` output. + * + * @public + */ +export interface ISchemaMeta { + /** + * The JSON Schema dialect URL that the generated file should declare in its + * top-level `$schema` property. If unset, the value emitted by `z.toJSONSchema()` + * (or none) is used. + */ + $schema?: string; + + /** + * Optional `$id` to embed in the generated schema. + */ + $id?: string; + + /** + * Optional `title` for the generated schema. If not provided, an existing title + * from the zod schema (e.g. via `.meta({ title })`) is preserved. + */ + title?: string; + + /** + * Optional human-readable description for the schema. If not provided, an existing + * description from the zod schema is preserved. + */ + description?: string; + + /** + * A TSDoc release tag (e.g. `@public`, `@beta`, `@alpha`, `@internal`) to embed in + * the generated schema as a vendor extension (`x-tsdoc-release-tag`). + * + * @remarks + * The companion `@rushstack/heft-json-schema-typings-plugin` uses the same vendor + * extension to inject release tags into generated `.d.ts` files, which keeps the + * convention consistent across the two plugins. + */ + releaseTag?: string; +} + +const _schemaMetaMap: WeakMap = new WeakMap(); + +/** + * Attaches schema-emission metadata (such as `$schema`, `title`, and a TSDoc + * release tag) to a zod schema. The metadata is read by + * `@rushstack/heft-zod-schema-plugin` when generating the corresponding + * `*.schema.json` file. + * + * @remarks + * The metadata is stored out-of-band in a `WeakMap` keyed on the schema instance + * so that this helper does not depend on any particular zod version. The schema + * itself is returned unchanged, which keeps `z.infer` results identical. + * + * @public + */ +export function withSchemaMeta(schema: TSchema, meta: ISchemaMeta): TSchema { + if (meta.releaseTag !== undefined) { + _validateTsDocReleaseTag(meta.releaseTag, 'withSchemaMeta()'); + } + _schemaMetaMap.set(schema, { ...meta }); + return schema; +} + +/** + * Looks up metadata previously attached to a schema with `withSchemaMeta`. + * + * @internal + */ +export function _getSchemaMeta(schema: object): ISchemaMeta | undefined { + return _schemaMetaMap.get(schema); +} diff --git a/heft-plugins/heft-zod-schema-plugin/src/ZodSchemaGenerator.ts b/heft-plugins/heft-zod-schema-plugin/src/ZodSchemaGenerator.ts new file mode 100644 index 00000000000..0b3a4d6b01e --- /dev/null +++ b/heft-plugins/heft-zod-schema-plugin/src/ZodSchemaGenerator.ts @@ -0,0 +1,319 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { FileSystem, NewlineKind, Path } from '@rushstack/node-core-library'; +import type { ITerminal } from '@rushstack/terminal'; + +import { + _getSchemaMeta, + _validateTsDocReleaseTag, + X_TSDOC_RELEASE_TAG_KEY, + type ISchemaMeta +} from './SchemaMetaHelpers'; + +/** + * Options for {@link ZodSchemaGenerator}. + * + * @internal + */ +export interface IZodSchemaGeneratorOptions { + /** + * The project root folder. Relative `inputGlobs` and `outputFolder` paths are resolved + * relative to this folder; absolute paths are accepted as-is. + */ + buildFolderPath: string; + + /** + * Globs identifying the compiled JavaScript modules that export zod schemas. + * May be relative to `buildFolderPath` or absolute. + */ + inputGlobs: string[]; + + /** + * Folder where the generated `*.schema.json` files will be written. May be + * relative to `buildFolderPath` or absolute. + */ + outputFolder: string; + + /** + * The name of the export to read from each module. Use `"default"` for the default + * export, or `"*"` to emit one schema file per named `ZodType` export. + */ + exportName: string; + + /** + * Number of spaces to indent the generated JSON. + */ + indent: number; + + /** + * Optional terminal to write progress messages to. + */ + terminal?: ITerminal; +} + +const SCHEMA_FILE_EXTENSION: '.schema.json' = '.schema.json'; +const ZOD_FILE_SUFFIX: '.zod.js' = '.zod.js'; + +/** + * Result of generating one schema file. + * + * @internal + */ +export interface IGeneratedSchema { + /** Absolute path of the source module. */ + sourceModulePath: string; + /** Absolute path of the emitted `*.schema.json` file. */ + outputFilePath: string; + /** The pretty-printed JSON contents that were written. */ + contents: string; + /** `true` if the file was actually rewritten (false if contents were unchanged). */ + wasWritten: boolean; +} + +/** + * Loads compiled JavaScript modules that export zod schemas, converts each schema + * to a JSON Schema document via zod's built-in `z.toJSONSchema()`, and writes the + * results to `/.schema.json` (or, for named exports, + * `..schema.json`). + * + * @internal + */ +export class ZodSchemaGenerator { + private readonly _options: IZodSchemaGeneratorOptions; + + public constructor(options: IZodSchemaGeneratorOptions) { + this._options = options; + } + + /** + * Find all source modules matching `inputGlobs`, generate their schemas, and + * write them to disk. + * + * @returns the list of generated schema results + */ + public async generateAsync(): Promise { + const sourceModules: string[] = await this._findSourceModulesAsync(); + const results: IGeneratedSchema[] = []; + for (const sourceModulePath of sourceModules) { + const moduleResults: IGeneratedSchema[] = await this._processModuleAsync(sourceModulePath); + results.push(...moduleResults); + } + return results; + } + + private async _findSourceModulesAsync(): Promise { + // Defer requiring fast-glob until use to keep startup cheap when the plugin + // is loaded but no work is needed. + const glob: typeof import('fast-glob') = require('fast-glob'); + // fast-glob requires forward-slash patterns; convert any platform-specific + // separators (Windows backslashes from `__dirname`-rooted patterns, etc.). + const normalizedGlobs: string[] = this._options.inputGlobs.map((pattern) => + Path.convertToSlashes(pattern) + ); + const matches: string[] = await glob(normalizedGlobs, { + cwd: this._options.buildFolderPath, + absolute: true, + onlyFiles: true + }); + matches.sort(); + return matches; + } + + private async _processModuleAsync(sourceModulePath: string): Promise { + // Force a fresh load so that incremental builds always see the latest compiled + // output. + delete require.cache[require.resolve(sourceModulePath)]; + let loadedModule: Record; + try { + loadedModule = require(sourceModulePath) as Record; + } catch (error) { + throw new Error( + `Failed to load zod schema module "${sourceModulePath}": ${(error as Error).message}` + ); + } + + const exportsToProcess: { exportName: string; schema: object }[] = []; + if (this._options.exportName === '*') { + for (const [name, value] of Object.entries(loadedModule)) { + if (name === 'default' || !_isZodSchema(value)) { + continue; + } + exportsToProcess.push({ exportName: name, schema: value }); + } + // Always include default export when present + const defaultExport: unknown = loadedModule.default; + if (_isZodSchema(defaultExport)) { + exportsToProcess.push({ exportName: 'default', schema: defaultExport }); + } + } else { + const exportValue: unknown = loadedModule[this._options.exportName]; + if (!_isZodSchema(exportValue)) { + throw new Error( + `Module "${sourceModulePath}" does not export a zod schema as ` + + `"${this._options.exportName}". ` + + 'Expected a value with a "_def" property and a "parse" method.' + ); + } + exportsToProcess.push({ exportName: this._options.exportName, schema: exportValue }); + } + + if (exportsToProcess.length === 0) { + throw new Error( + `Module "${sourceModulePath}" did not export any zod schemas matching ` + + `exportName "${this._options.exportName}".` + ); + } + + const baseName: string = _getBaseName(sourceModulePath); + + const results: IGeneratedSchema[] = []; + for (const { exportName, schema } of exportsToProcess) { + const outputFileName: string = + exportName === 'default' + ? `${baseName}${SCHEMA_FILE_EXTENSION}` + : `${baseName}.${exportName}${SCHEMA_FILE_EXTENSION}`; + const outputFilePath: string = path.resolve( + this._options.buildFolderPath, + this._options.outputFolder, + outputFileName + ); + + const contents: string = this._convertSchemaToJson(schema, sourceModulePath); + const wasWritten: boolean = await _writeIfChangedAsync(outputFilePath, contents); + results.push({ sourceModulePath, outputFilePath, contents, wasWritten }); + } + + return results; + } + + private _convertSchemaToJson(schema: object, sourceModulePath: string): string { + // Locate `z.toJSONSchema` from zod 4+ on the schema's own prototype chain when + // possible to avoid loading multiple zod copies. Fall back to require('zod'). + const zod: { toJSONSchema: (schema: object) => Record } = require('zod'); + if (typeof zod.toJSONSchema !== 'function') { + throw new Error( + 'The installed version of "zod" does not provide z.toJSONSchema(). ' + + 'heft-zod-schema-plugin requires zod 4.0.0 or later.' + ); + } + + const jsonSchema: Record = zod.toJSONSchema(schema); + + // Apply user-supplied metadata (withSchemaMeta) to the top of the document. + const meta: ISchemaMeta | undefined = _getSchemaMeta(schema); + if (meta) { + if (meta.releaseTag !== undefined) { + _validateTsDocReleaseTag(meta.releaseTag, sourceModulePath); + } + _applyMetaToTopLevel(jsonSchema, meta); + } + + return JSON.stringify(jsonSchema, undefined, this._options.indent) + '\n'; + } +} + +/** + * Duck-types a value as a zod schema instance. We deliberately avoid an + * `instanceof` check because the plugin and its consumer might end up with + * different copies of zod resolved at runtime. + */ +function _isZodSchema(value: unknown): value is object { + if (value === null || typeof value !== 'object') { + return false; + } + const candidate: { _def?: unknown; parse?: unknown } = value as { + _def?: unknown; + parse?: unknown; + }; + return candidate._def !== undefined && typeof candidate.parse === 'function'; +} + +/** + * Derives the base name for a generated schema from a source module path, + * stripping the `.zod.js` suffix if present, otherwise just the extension. + */ +function _getBaseName(sourceModulePath: string): string { + const fileName: string = path.basename(sourceModulePath); + if (fileName.endsWith(ZOD_FILE_SUFFIX)) { + return fileName.slice(0, -ZOD_FILE_SUFFIX.length); + } + const ext: string = path.extname(fileName); + return ext ? fileName.slice(0, -ext.length) : fileName; +} + +/** + * Inserts `$schema`, `$id`, `title`, `description`, and `x-tsdoc-release-tag` + * properties at the top of the JSON Schema document, preserving deterministic + * ordering: `$schema`, then `$id`, then `title`, then `description`, then the + * extension key, then any other keys produced by `z.toJSONSchema`. + */ +function _applyMetaToTopLevel(jsonSchema: Record, meta: ISchemaMeta): void { + const ordered: Record = {}; + if (meta.$schema !== undefined) { + ordered.$schema = meta.$schema; + } else if (jsonSchema.$schema !== undefined) { + ordered.$schema = jsonSchema.$schema; + } + if (meta.$id !== undefined) { + ordered.$id = meta.$id; + } else if (jsonSchema.$id !== undefined) { + ordered.$id = jsonSchema.$id; + } + if (meta.title !== undefined) { + ordered.title = meta.title; + } else if (jsonSchema.title !== undefined) { + ordered.title = jsonSchema.title; + } + if (meta.description !== undefined) { + ordered.description = meta.description; + } else if (jsonSchema.description !== undefined) { + ordered.description = jsonSchema.description; + } + if (meta.releaseTag !== undefined) { + ordered[X_TSDOC_RELEASE_TAG_KEY] = meta.releaseTag; + } + + // Copy remaining keys from the original document, skipping ones we've already + // placed at the front. + for (const [key, value] of Object.entries(jsonSchema)) { + if (key in ordered) { + continue; + } + ordered[key] = value; + } + + // Mutate jsonSchema in place to reflect the new ordering by deleting and + // reinserting each key. + for (const key of Object.keys(jsonSchema)) { + delete jsonSchema[key]; + } + for (const [key, value] of Object.entries(ordered)) { + jsonSchema[key] = value; + } +} + +/** + * Writes the file only if its current contents differ from `contents`. Returns + * `true` if the file was rewritten. + */ +async function _writeIfChangedAsync(outputFilePath: string, contents: string): Promise { + let existing: string | undefined; + try { + existing = await FileSystem.readFileAsync(outputFilePath); + } catch (error) { + if (!FileSystem.isNotExistError(error)) { + throw error; + } + } + if (existing === contents) { + return false; + } + await FileSystem.writeFileAsync(outputFilePath, contents, { + ensureFolderExists: true, + convertLineEndings: NewlineKind.Lf + }); + return true; +} diff --git a/heft-plugins/heft-zod-schema-plugin/src/schemas/heft-zod-schema-plugin.schema.json b/heft-plugins/heft-zod-schema-plugin/src/schemas/heft-zod-schema-plugin.schema.json new file mode 100644 index 00000000000..75bfe6b2a27 --- /dev/null +++ b/heft-plugins/heft-zod-schema-plugin/src/schemas/heft-zod-schema-plugin.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + + "type": "object", + "additionalProperties": false, + + "properties": { + "inputGlobs": { + "type": "array", + "description": "Globs (relative to the project folder) identifying compiled JavaScript modules that export zod schemas. Defaults to [\"lib/schemas/*.zod.js\"].", + "minItems": 1, + "items": { + "type": "string" + } + }, + + "outputFolder": { + "type": "string", + "description": "Folder (relative to the project folder) where the generated *.schema.json files will be written. Defaults to \"lib/schemas\".", + "pattern": "[^\\\\]" + }, + + "exportName": { + "type": "string", + "description": "The name of the export to read from each module. Use \"default\" to read the default export, or \"*\" to emit one schema file per named ZodType export. Defaults to \"default\"." + }, + + "indent": { + "type": "integer", + "description": "Number of spaces to indent the generated JSON. Defaults to 2.", + "minimum": 0, + "maximum": 10 + } + } +} diff --git a/heft-plugins/heft-zod-schema-plugin/src/test/SchemaMetaHelpers.test.ts b/heft-plugins/heft-zod-schema-plugin/src/test/SchemaMetaHelpers.test.ts new file mode 100644 index 00000000000..916654376e1 --- /dev/null +++ b/heft-plugins/heft-zod-schema-plugin/src/test/SchemaMetaHelpers.test.ts @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { _validateTsDocReleaseTag, withSchemaMeta, _getSchemaMeta } from '../SchemaMetaHelpers'; + +describe(_validateTsDocReleaseTag.name, () => { + test('accepts valid release tags', () => { + expect(() => _validateTsDocReleaseTag('@public', 'src')).not.toThrow(); + expect(() => _validateTsDocReleaseTag('@beta', 'src')).not.toThrow(); + expect(() => _validateTsDocReleaseTag('@alpha', 'src')).not.toThrow(); + expect(() => _validateTsDocReleaseTag('@internal', 'src')).not.toThrow(); + }); + + test('rejects invalid release tags', () => { + expect(() => _validateTsDocReleaseTag('public', 'src')).toThrow(/Invalid x-tsdoc-release-tag/); + expect(() => _validateTsDocReleaseTag('@Public', 'src')).toThrow(/Invalid x-tsdoc-release-tag/); + expect(() => _validateTsDocReleaseTag('@two words', 'src')).toThrow(/Invalid x-tsdoc-release-tag/); + expect(() => _validateTsDocReleaseTag('', 'src')).toThrow(/Invalid x-tsdoc-release-tag/); + }); +}); + +describe(withSchemaMeta.name, () => { + test('returns the schema unchanged and stores metadata', () => { + const schema: { _def: object; parse: () => void } = { + _def: {}, + parse: () => undefined + }; + const result: object = withSchemaMeta(schema, { title: 'Hello', releaseTag: '@public' }); + expect(result).toBe(schema); + expect(_getSchemaMeta(schema)).toEqual({ title: 'Hello', releaseTag: '@public' }); + }); + + test('validates the release tag immediately', () => { + const schema: object = { _def: {}, parse: () => undefined }; + expect(() => withSchemaMeta(schema, { releaseTag: 'public' })).toThrow( + /Invalid x-tsdoc-release-tag/ + ); + }); +}); diff --git a/heft-plugins/heft-zod-schema-plugin/src/test/ZodSchemaGenerator.test.ts b/heft-plugins/heft-zod-schema-plugin/src/test/ZodSchemaGenerator.test.ts new file mode 100644 index 00000000000..844430dc33d --- /dev/null +++ b/heft-plugins/heft-zod-schema-plugin/src/test/ZodSchemaGenerator.test.ts @@ -0,0 +1,103 @@ +// 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 { ZodSchemaGenerator, type IGeneratedSchema } from '../ZodSchemaGenerator'; + +const projectFolder: string = PackageJsonLookup.instance.tryGetPackageFolderFor(__dirname)!; +const compiledFixturesFolder: string = `${__dirname}/fixtures`; +const outputFolder: string = `${projectFolder}/temp/test-zod-schema-output`; + +async function readJsonAsync(absolutePath: string): Promise { + const text: string = await FileSystem.readFileAsync(absolutePath); + return JSON.parse(text); +} + +describe(ZodSchemaGenerator.name, () => { + beforeEach(async () => { + await FileSystem.ensureEmptyFolderAsync(outputFolder); + }); + + it('emits a JSON schema for a basic zod default export', async () => { + const generator: ZodSchemaGenerator = new ZodSchemaGenerator({ + buildFolderPath: projectFolder, + inputGlobs: [`${compiledFixturesFolder}/basic.zod.js`], + outputFolder, + exportName: 'default', + indent: 2 + }); + + const results: IGeneratedSchema[] = await generator.generateAsync(); + expect(results).toHaveLength(1); + expect(results[0].outputFilePath.endsWith('basic.schema.json')).toBe(true); + + const written: unknown = await readJsonAsync(results[0].outputFilePath); + expect(written).toMatchSnapshot(); + }); + + it('applies withSchemaMeta() metadata, including the TSDoc release tag', async () => { + const generator: ZodSchemaGenerator = new ZodSchemaGenerator({ + buildFolderPath: projectFolder, + inputGlobs: [`${compiledFixturesFolder}/with-tsdoc-tag.zod.js`], + outputFolder, + exportName: 'default', + indent: 2 + }); + + const results: IGeneratedSchema[] = await generator.generateAsync(); + expect(results).toHaveLength(1); + + const written: Record = (await readJsonAsync( + results[0].outputFilePath + )) as Record; + expect(written).toMatchSnapshot(); + expect(written['x-tsdoc-release-tag']).toBe('@public'); + expect(written.title).toBe('Public Config'); + expect(written.$schema).toBe('http://json-schema.org/draft-07/schema#'); + }); + + it('emits one schema file per named ZodType export when exportName is "*"', async () => { + const generator: ZodSchemaGenerator = new ZodSchemaGenerator({ + buildFolderPath: projectFolder, + inputGlobs: [`${compiledFixturesFolder}/named-exports.zod.js`], + outputFolder, + exportName: '*', + indent: 2 + }); + + const results: IGeneratedSchema[] = await generator.generateAsync(); + expect(results).toHaveLength(2); + const fileNames: string[] = results.map((r) => r.outputFilePath.split(/[\\/]/).pop()!).sort(); + expect(fileNames).toEqual(['named-exports.alphaSchema.schema.json', 'named-exports.betaSchema.schema.json']); + }); + + it('produces deterministic output and skips writes when contents are unchanged', async () => { + const generator: ZodSchemaGenerator = new ZodSchemaGenerator({ + buildFolderPath: projectFolder, + inputGlobs: [`${compiledFixturesFolder}/basic.zod.js`], + outputFolder, + exportName: 'default', + indent: 2 + }); + + const first: IGeneratedSchema[] = await generator.generateAsync(); + expect(first[0].wasWritten).toBe(true); + + const second: IGeneratedSchema[] = await generator.generateAsync(); + expect(second[0].wasWritten).toBe(false); + expect(second[0].contents).toEqual(first[0].contents); + }); + + it('throws a clear error when the requested export is not a zod schema', async () => { + const generator: ZodSchemaGenerator = new ZodSchemaGenerator({ + buildFolderPath: projectFolder, + inputGlobs: [`${compiledFixturesFolder}/basic.zod.js`], + outputFolder, + exportName: 'doesNotExist', + indent: 2 + }); + + await expect(generator.generateAsync()).rejects.toThrow(/does not export a zod schema/); + }); +}); diff --git a/heft-plugins/heft-zod-schema-plugin/src/test/__snapshots__/ZodSchemaGenerator.test.ts.snap b/heft-plugins/heft-zod-schema-plugin/src/test/__snapshots__/ZodSchemaGenerator.test.ts.snap new file mode 100644 index 00000000000..1b21cd3e788 --- /dev/null +++ b/heft-plugins/heft-zod-schema-plugin/src/test/__snapshots__/ZodSchemaGenerator.test.ts.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`ZodSchemaGenerator applies withSchemaMeta() metadata, including the TSDoc release tag 1`] = ` +Object { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": Object { + "value": Object { + "description": "A value.", + "type": "string", + }, + }, + "title": "Public Config", + "type": "object", + "x-tsdoc-release-tag": "@public", +} +`; + +exports[`ZodSchemaGenerator emits a JSON schema for a basic zod default export 1`] = ` +Object { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": Object { + "count": Object { + "description": "The number of items.", + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer", + }, + "enabled": Object { + "description": "Whether the feature is enabled.", + "type": "boolean", + }, + "name": Object { + "description": "The name of the item.", + "type": "string", + }, + }, + "required": Array [ + "name", + ], + "type": "object", +} +`; diff --git a/heft-plugins/heft-zod-schema-plugin/src/test/fixtures/basic.zod.ts b/heft-plugins/heft-zod-schema-plugin/src/test/fixtures/basic.zod.ts new file mode 100644 index 00000000000..f609f60dc6e --- /dev/null +++ b/heft-plugins/heft-zod-schema-plugin/src/test/fixtures/basic.zod.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { z } from 'zod'; + +const basicSchema = z.object({ + name: z.string().describe('The name of the item.'), + count: z.number().int().describe('The number of items.').optional(), + enabled: z.boolean().describe('Whether the feature is enabled.').optional() +}); + +export type IBasicConfig = z.infer; + +export default basicSchema; diff --git a/heft-plugins/heft-zod-schema-plugin/src/test/fixtures/named-exports.zod.ts b/heft-plugins/heft-zod-schema-plugin/src/test/fixtures/named-exports.zod.ts new file mode 100644 index 00000000000..973fded95b0 --- /dev/null +++ b/heft-plugins/heft-zod-schema-plugin/src/test/fixtures/named-exports.zod.ts @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { z } from 'zod'; + +export const alphaSchema = z.object({ + alpha: z.string() +}); + +export const betaSchema = z.object({ + beta: z.number() +}); diff --git a/heft-plugins/heft-zod-schema-plugin/src/test/fixtures/with-tsdoc-tag.zod.ts b/heft-plugins/heft-zod-schema-plugin/src/test/fixtures/with-tsdoc-tag.zod.ts new file mode 100644 index 00000000000..97a513ddb58 --- /dev/null +++ b/heft-plugins/heft-zod-schema-plugin/src/test/fixtures/with-tsdoc-tag.zod.ts @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { z } from 'zod'; + +import { withSchemaMeta } from '../../SchemaMetaHelpers'; + +const publicSchema = withSchemaMeta( + z.object({ + value: z.string().describe('A value.').optional() + }), + { + $schema: 'http://json-schema.org/draft-07/schema#', + title: 'Public Config', + releaseTag: '@public' + } +); + +export type IPublicConfig = z.infer; + +export default publicSchema; diff --git a/heft-plugins/heft-zod-schema-plugin/tsconfig.json b/heft-plugins/heft-zod-schema-plugin/tsconfig.json new file mode 100644 index 00000000000..dac21d04081 --- /dev/null +++ b/heft-plugins/heft-zod-schema-plugin/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json" +} diff --git a/libraries/node-core-library/src/JsonFile.ts b/libraries/node-core-library/src/JsonFile.ts index 21f55b6fc2c..c82f1a4e15b 100644 --- a/libraries/node-core-library/src/JsonFile.ts +++ b/libraries/node-core-library/src/JsonFile.ts @@ -124,6 +124,25 @@ export interface IJsonFileParseOptions { */ export interface IJsonFileLoadAndValidateOptions extends IJsonFileParseOptions, IJsonSchemaValidateOptions {} +/** + * A structural validator interface that matches the parse/safeParse contract of + * popular schema libraries such as zod. + * + * @remarks + * `JsonFile.loadAndParse()` accepts any object whose `parse()` method takes an + * `unknown` input and returns a typed value (throwing on validation failure). + * Using a structural type here avoids forcing `node-core-library` to take a + * runtime dependency on a specific schema-validation library or major version. + * + * @public + */ +export interface IJsonFileTypeValidator { + /** + * Validate `input` and return it as the validated type, or throw if validation fails. + */ + parse(input: unknown): T; +} + /** * Options for {@link JsonFile.stringify} * @@ -319,6 +338,45 @@ export class JsonFile { return jsonObject; } + /** + * Loads a JSON file and validates it using a structural validator (such as a + * zod schema), returning the strongly-typed result. + * + * @remarks + * `validator` is any object exposing a `parse(input: unknown): T` method. + * The validator is responsible for both runtime validation and the resulting + * TypeScript type. This indirection lets `node-core-library` accept zod + * schemas without taking a runtime dependency on zod. + * + * @example + * ```ts + * import { z } from 'zod'; + * const schema = z.object({ name: z.string() }); + * const data = JsonFile.loadAndParse('config.json', schema); + * // data is typed as { name: string } + * ``` + */ + public static loadAndParse( + jsonFilename: string, + validator: IJsonFileTypeValidator, + options?: IJsonFileParseOptions + ): T { + const jsonObject: JsonObject = JsonFile.load(jsonFilename, options); + return validator.parse(jsonObject); + } + + /** + * An async version of {@link JsonFile.loadAndParse}. + */ + public static async loadAndParseAsync( + jsonFilename: string, + validator: IJsonFileTypeValidator, + options?: IJsonFileParseOptions + ): Promise { + const jsonObject: JsonObject = await JsonFile.loadAsync(jsonFilename, options); + return validator.parse(jsonObject); + } + /** * Serializes the specified JSON object to a string buffer. * @param jsonObject - the object to be serialized diff --git a/libraries/node-core-library/src/index.ts b/libraries/node-core-library/src/index.ts index 3f4592cf6b1..87ba4da39bd 100644 --- a/libraries/node-core-library/src/index.ts +++ b/libraries/node-core-library/src/index.ts @@ -105,6 +105,7 @@ export { type IJsonFileLoadAndValidateOptions, type IJsonFileStringifyOptions, type IJsonFileSaveOptions, + type IJsonFileTypeValidator, JsonFile } from './JsonFile'; diff --git a/libraries/rush-lib/package.json b/libraries/rush-lib/package.json index 3b1da56425a..faeeab22fb8 100644 --- a/libraries/rush-lib/package.json +++ b/libraries/rush-lib/package.json @@ -53,6 +53,7 @@ "@rushstack/rush-pnpm-kit-v10": "workspace:*", "@rushstack/rush-pnpm-kit-v8": "workspace:*", "@rushstack/rush-pnpm-kit-v9": "workspace:*", + "@rushstack/rush-schemas": "workspace:*", "@rushstack/stream-collator": "workspace:*", "@rushstack/terminal": "workspace:*", "@rushstack/ts-command-line": "workspace:*", diff --git a/libraries/rush-lib/src/api/CobuildConfiguration.ts b/libraries/rush-lib/src/api/CobuildConfiguration.ts index d96cfc96dc2..65d70bc4252 100644 --- a/libraries/rush-lib/src/api/CobuildConfiguration.ts +++ b/libraries/rush-lib/src/api/CobuildConfiguration.ts @@ -3,7 +3,8 @@ import { randomUUID } from 'node:crypto'; -import { FileSystem, JsonFile, JsonSchema } from '@rushstack/node-core-library'; +import { FileSystem, JsonFile } from '@rushstack/node-core-library'; +import { type ICobuildJson, cobuildSchema } from '@rushstack/rush-schemas'; import type { ITerminal } from '@rushstack/terminal'; import { EnvironmentConfiguration } from './EnvironmentConfiguration'; @@ -11,15 +12,8 @@ import type { CobuildLockProviderFactory, RushSession } from '../pluginFramework import { RushConstants } from '../logic/RushConstants'; import type { ICobuildLockProvider } from '../logic/cobuild/ICobuildLockProvider'; import type { RushConfiguration } from './RushConfiguration'; -import schemaJson from '../schemas/cobuild.schema.json'; -/** - * @beta - */ -export interface ICobuildJson { - cobuildFeatureEnabled: boolean; - cobuildLockProvider: string; -} +export type { ICobuildJson }; /** * @beta @@ -37,8 +31,6 @@ export interface ICobuildConfigurationOptions { * @beta */ export class CobuildConfiguration { - private static _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); - /** * Indicates whether the cobuild feature is enabled. * Typically it is enabled in the cobuild.json config file. @@ -126,7 +118,7 @@ export class CobuildConfiguration { ): Promise { let cobuildJson: ICobuildJson | undefined; try { - cobuildJson = await JsonFile.loadAndValidateAsync(jsonFilePath, CobuildConfiguration._jsonSchema); + cobuildJson = await JsonFile.loadAndParseAsync(jsonFilePath, cobuildSchema); } catch (e) { if (FileSystem.isNotExistError(e)) { return undefined; diff --git a/libraries/rush-lib/src/api/ExperimentsConfiguration.ts b/libraries/rush-lib/src/api/ExperimentsConfiguration.ts index 3d2c9416dc6..9f4c46ed3d9 100644 --- a/libraries/rush-lib/src/api/ExperimentsConfiguration.ts +++ b/libraries/rush-lib/src/api/ExperimentsConfiguration.ts @@ -1,153 +1,14 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { JsonFile, JsonSchema, FileSystem } from '@rushstack/node-core-library'; +import { JsonFile, FileSystem } from '@rushstack/node-core-library'; +import { type IExperimentsJson, experimentsSchema } from '@rushstack/rush-schemas'; import { Colorize } from '@rushstack/terminal'; -import schemaJson from '../schemas/experiments.schema.json'; +export type { IExperimentsJson }; const GRADUATED_EXPERIMENTS: Set = new Set(['phasedCommands']); -/** - * This interface represents the raw experiments.json file which allows repo - * maintainers to enable and disable experimental Rush features. - * @beta - */ -export interface IExperimentsJson { - /** - * By default, 'rush install' passes --no-prefer-frozen-lockfile to 'pnpm install'. - * Set this option to true to pass '--frozen-lockfile' instead for faster installs. - */ - usePnpmFrozenLockfileForRushInstall?: boolean; - - /** - * By default, 'rush update' passes --no-prefer-frozen-lockfile to 'pnpm install'. - * Set this option to true to pass '--prefer-frozen-lockfile' instead to minimize shrinkwrap changes. - */ - usePnpmPreferFrozenLockfileForRushUpdate?: boolean; - - /** - * By default, 'rush update' runs as a single operation. - * Set this option to true to instead update the lockfile with `--lockfile-only`, then perform a `--frozen-lockfile` install. - * Necessary when using the `afterAllResolved` hook in .pnpmfile.cjs. - */ - usePnpmLockfileOnlyThenFrozenLockfileForRushUpdate?: boolean; - - /** - * If using the 'preventManualShrinkwrapChanges' option, restricts the hash to only include the layout of external dependencies. - * Used to allow links between workspace projects or the addition/removal of references to existing dependency versions to not - * cause hash changes. - */ - omitImportersFromPreventManualShrinkwrapChanges?: boolean; - - /** - * If true, the chmod field in temporary project tar headers will not be normalized. - * This normalization can help ensure consistent tarball integrity across platforms. - */ - noChmodFieldInTarHeaderNormalization?: boolean; - - /** - * If true, build caching will respect the allowWarningsInSuccessfulBuild flag and cache builds with warnings. - * This will not replay warnings from the cached build. - */ - buildCacheWithAllowWarningsInSuccessfulBuild?: boolean; - - /** - * If true, build skipping will respect the allowWarningsInSuccessfulBuild flag and skip builds with warnings. - * This will not replay warnings from the skipped build. - */ - buildSkipWithAllowWarningsInSuccessfulBuild?: boolean; - - /** - * If true, perform a clean install after when running `rush install` or `rush update` if the - * `.npmrc` file has changed since the last install. - */ - cleanInstallAfterNpmrcChanges?: boolean; - - /** - * If true, print the outputs of shell commands defined in event hooks to the console. - */ - printEventHooksOutputToConsole?: boolean; - - /** - * If true, Rush will not allow node_modules in the repo folder or in parent folders. - */ - forbidPhantomResolvableNodeModulesFolders?: boolean; - - /** - * (UNDER DEVELOPMENT) For certain installation problems involving peer dependencies, PNPM cannot - * correctly satisfy versioning requirements without installing duplicate copies of a package inside the - * node_modules folder. This poses a problem for "workspace:*" dependencies, as they are normally - * installed by making a symlink to the local project source folder. PNPM's "injected dependencies" - * feature provides a model for copying the local project folder into node_modules, however copying - * must occur AFTER the dependency project is built and BEFORE the consuming project starts to build. - * The "pnpm-sync" tool manages this operation; see its documentation for details. - * Enable this experiment if you want "rush" and "rushx" commands to resync injected dependencies - * by invoking "pnpm-sync" during the build. - */ - usePnpmSyncForInjectedDependencies?: boolean; - - /** - * If set to true, Rush will generate a `project-impact-graph.yaml` file in the repository root during `rush update`. - */ - generateProjectImpactGraphDuringRushUpdate?: boolean; - - /** - * If true, when running in watch mode, Rush will check for phase scripts named `_phase::ipc` and run them instead - * of `_phase:` if they exist. The created child process will be provided with an IPC channel and expected to persist - * across invocations. - */ - useIPCScriptsInWatchMode?: boolean; - - /** - * (UNDER DEVELOPMENT) The Rush alerts feature provides a way to send announcements to engineers - * working in the monorepo, by printing directly in the user's shell window when they invoke Rush commands. - * This ensures that important notices will be seen by anyone doing active development, since people often - * ignore normal discussion group messages or don't know to subscribe. - */ - rushAlerts?: boolean; - - /** - * Allow cobuilds without using the build cache to store previous execution info. When setting up - * distributed builds, Rush will allow uncacheable projects to still leverage the cobuild feature. - * This is useful when you want to speed up operations that can't (or shouldn't) be cached. - */ - allowCobuildWithoutCache?: boolean; - - /** - * By default, rush perform a full scan of the entire repository. For example, Rush runs `git status` to check for local file changes. - * When this toggle is enabled, Rush will only scan specific paths, significantly speeding up Git operations. - */ - enableSubpathScan?: boolean; - - /** - * Rush has a policy that normally requires Rush projects to specify `workspace:*` in package.json when depending - * on other projects in the workspace, unless they are explicitly declared as `decoupledLocalDependencies` - * in rush.json. Enabling this experiment will remove that requirement for dependencies belonging to a different - * subspace. This is useful for large product groups who work in separate subspaces and generally prefer to consume - * each other's packages via the NPM registry. - */ - exemptDecoupledDependenciesBetweenSubspaces?: boolean; - - /** - * If true, when running on macOS, Rush will omit AppleDouble files (`._*`) from build cache archives - * when a companion file exists in the same directory. AppleDouble files are automatically created by - * macOS to store extended attributes on filesystems that don't support them, and should generally not - * be included in the shared build cache. - */ - omitAppleDoubleFilesFromBuildCache?: boolean; - - /** - * If true, `rush change --verify` will perform additional validation of change files. Specifically, - * it will report errors if change files reference projects that do not exist in the Rush configuration, - * or if change files target a project that belongs to a lockstepped version policy but is not the - * policy's main project. - */ - strictChangefileValidation?: boolean; -} - -const _EXPERIMENTS_JSON_SCHEMA: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); - /** * Use this class to load the "common/config/rush/experiments.json" config file. * This file allows repo maintainers to enable and disable experimental Rush features. @@ -165,7 +26,7 @@ export class ExperimentsConfiguration { */ public constructor(jsonFilePath: string) { try { - this.configuration = JsonFile.loadAndValidate(jsonFilePath, _EXPERIMENTS_JSON_SCHEMA); + this.configuration = JsonFile.loadAndParse(jsonFilePath, experimentsSchema); } catch (e) { if (FileSystem.isNotExistError(e)) { this.configuration = {}; diff --git a/libraries/rush-lib/src/logic/RepoStateFile.ts b/libraries/rush-lib/src/logic/RepoStateFile.ts index 0b7d907c7fd..72ba445bd85 100644 --- a/libraries/rush-lib/src/logic/RepoStateFile.ts +++ b/libraries/rush-lib/src/logic/RepoStateFile.ts @@ -1,12 +1,12 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { FileSystem, JsonFile, JsonSchema, NewlineKind } from '@rushstack/node-core-library'; +import { FileSystem, JsonFile, NewlineKind } from '@rushstack/node-core-library'; +import { repoStateSchema } from '@rushstack/rush-schemas'; import type { RushConfiguration } from '../api/RushConfiguration'; import { PnpmShrinkwrapFile } from './pnpm/PnpmShrinkwrapFile'; import type { CommonVersionsConfiguration } from '../api/CommonVersionsConfiguration'; -import schemaJson from '../schemas/repo-state.schema.json'; import type { Subspace } from '../api/Subspace'; /** @@ -45,8 +45,6 @@ interface IRepoStateJson { * @public */ export class RepoStateFile { - private static _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); - private _pnpmShrinkwrapHash: string | undefined; private _preferredVersionsHash: string | undefined; private _packageJsonInjectedDependenciesHash: string | undefined; @@ -148,7 +146,7 @@ export class RepoStateFile { } if (repoStateJson) { - this._jsonSchema.validateObject(repoStateJson, jsonFilename); + repoStateSchema.parse(repoStateJson); } } diff --git a/libraries/rush-lib/src/schemas/cobuild.schema.json b/libraries/rush-lib/src/schemas/cobuild.schema.json deleted file mode 100644 index 6fe630b89d8..00000000000 --- a/libraries/rush-lib/src/schemas/cobuild.schema.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "title": "Configuration for Rush's cobuild.", - "description": "For use with the Rush tool, this file provides configuration options for cobuild feature. See http://rushjs.io for details.", - "definitions": { - "anything": { - "type": ["array", "boolean", "integer", "number", "object", "string"], - "items": { - "$ref": "#/definitions/anything" - } - } - }, - "type": "object", - "allOf": [ - { - "type": "object", - "additionalProperties": false, - "required": ["cobuildFeatureEnabled", "cobuildLockProvider"], - "properties": { - "$schema": { - "description": "Part of the JSON Schema standard, this optional keyword declares the URL of the schema that the file conforms to. Editors may download the schema and use it to perform syntax highlighting.", - "type": "string" - }, - "cobuildFeatureEnabled": { - "description": "Set this to true to enable the cobuild feature.", - "type": "boolean" - }, - "cobuildLockProvider": { - "description": "Specify the cobuild lock provider to use", - "type": "string" - } - } - } - ] -} diff --git a/libraries/rush-lib/src/schemas/experiments.schema.json b/libraries/rush-lib/src/schemas/experiments.schema.json deleted file mode 100644 index dd508fcc1db..00000000000 --- a/libraries/rush-lib/src/schemas/experiments.schema.json +++ /dev/null @@ -1,95 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "title": "Rush experiments.json config file", - "description": "For use with the Rush tool, this file allows repo maintainers to enable and disable experimental Rush features.", - - "type": "object", - "properties": { - "$schema": { - "description": "Part of the JSON Schema standard, this optional keyword declares the URL of the schema that the file conforms to. Editors may download the schema and use it to perform syntax highlighting.", - "type": "string" - }, - - "usePnpmFrozenLockfileForRushInstall": { - "description": "By default, 'rush install' passes --no-prefer-frozen-lockfile to 'pnpm install'. Set this option to true to pass '--frozen-lockfile' instead.", - "type": "boolean" - }, - "usePnpmPreferFrozenLockfileForRushUpdate": { - "description": "By default, 'rush update' passes --no-prefer-frozen-lockfile to 'pnpm install'. Set this option to true to pass '--prefer-frozen-lockfile' instead.", - "type": "boolean" - }, - "usePnpmLockfileOnlyThenFrozenLockfileForRushUpdate": { - "description": "By default, 'rush update' runs as a single operation. Set this option to true to instead update the lockfile with `--lockfile-only`, then perform a `--frozen-lockfile` install. Necessary when using the `afterAllResolved` hook in .pnpmfile.cjs.", - "type": "boolean" - }, - "omitImportersFromPreventManualShrinkwrapChanges": { - "description": "If using the 'preventManualShrinkwrapChanges' option, only prevent manual changes to the total set of external dependencies referenced by the repository, not which projects reference which dependencies. This offers a balance between lockfile integrity and merge conflicts.", - "type": "boolean" - }, - "noChmodFieldInTarHeaderNormalization": { - "description": "If true, the chmod field in temporary project tar headers will not be normalized. This normalization can help ensure consistent tarball integrity across platforms.", - "type": "boolean" - }, - "buildCacheWithAllowWarningsInSuccessfulBuild": { - "description": "If true, build caching will respect the allowWarningsInSuccessfulBuild flag and cache builds with warnings. This will not replay warnings from the cached build.", - "type": "boolean" - }, - "buildSkipWithAllowWarningsInSuccessfulBuild": { - "description": "If true, build skipping will respect the allowWarningsInSuccessfulBuild flag and skip builds with warnings. This will not replay warnings from the skipped build.", - "type": "boolean" - }, - "phasedCommands": { - "description": "THIS EXPERIMENT HAS BEEN GRADUATED TO A STANDARD FEATURE. THIS PROPERTY SHOULD BE REMOVED.", - "type": "boolean" - }, - "cleanInstallAfterNpmrcChanges": { - "description": "If true, perform a clean install after when running `rush install` or `rush update` if the `.npmrc` file has changed since the last install.", - "type": "boolean" - }, - "printEventHooksOutputToConsole": { - "description": "If true, print the outputs of shell commands defined in event hooks to the console.", - "type": "boolean" - }, - "forbidPhantomResolvableNodeModulesFolders": { - "description": "If true, Rush will not allow node_modules in the repo folder or in parent folders.", - "type": "boolean" - }, - "usePnpmSyncForInjectedDependencies": { - "description": "(UNDER DEVELOPMENT) For certain installation problems involving peer dependencies, PNPM cannot correctly satisfy versioning requirements without installing duplicate copies of a package inside the node_modules folder. This poses a problem for 'workspace:*' dependencies, as they are normally installed by making a symlink to the local project source folder. PNPM's 'injected dependencies' feature provides a model for copying the local project folder into node_modules, however copying must occur AFTER the dependency project is built and BEFORE the consuming project starts to build. The 'pnpm-sync' tool manages this operation; see its documentation for details. Enable this experiment if you want 'rush' and 'rushx' commands to resync injected dependencies by invoking 'pnpm-sync' during the build.", - "type": "boolean" - }, - "generateProjectImpactGraphDuringRushUpdate": { - "description": "If set to true, Rush will generate a `project-impact-graph.yaml` file in the repository root during `rush update`.", - "type": "boolean" - }, - "useIPCScriptsInWatchMode": { - "description": "If true, when running in watch mode, Rush will check for phase scripts named `_phase::ipc` and run them instead of `_phase:` if they exist. The created child process will be provided with an IPC channel and expected to persist across invocations.", - "type": "boolean" - }, - "allowCobuildWithoutCache": { - "description": "When using cobuilds, this experiment allows uncacheable operations to benefit from cobuild orchestration without using the build cache.", - "type": "boolean" - }, - "rushAlerts": { - "description": "(UNDER DEVELOPMENT) The Rush alerts feature provides a way to send announcements to engineers working in the monorepo, by printing directly in the user's shell window when they invoke Rush commands. This ensures that important notices will be seen by anyone doing active development, since people often ignore normal discussion group messages or don't know to subscribe.", - "type": "boolean" - }, - "enableSubpathScan": { - "description": "By default, rush perform a full scan of the entire repository. For example, Rush runs `git status` to check for local file changes. When this toggle is enabled, Rush will only scan specific paths, significantly speeding up Git operations.", - "type": "boolean" - }, - "exemptDecoupledDependenciesBetweenSubspaces": { - "description": "Rush has a policy that normally requires Rush projects to specify `workspace:*` in package.json when depending on other projects in the workspace, unless they are explicitly declared as `decoupledLocalDependencies in rush.json. Enabling this experiment will remove that requirement for dependencies belonging to a different subspace. This is useful for large product groups who work in separate subspaces and generally prefer to consume each other's packages via the NPM registry.", - "type": "boolean" - }, - "omitAppleDoubleFilesFromBuildCache": { - "description": "If true, when running on macOS, Rush will omit AppleDouble files (._*) from build cache archives when a companion file exists in the same directory. AppleDouble files are automatically created by macOS to store extended attributes on filesystems that don't support them, and should generally not be included in the shared build cache.", - "type": "boolean" - }, - "strictChangefileValidation": { - "description": "If true, `rush change --verify` will report errors if change files reference projects that do not exist in the Rush configuration, or if change files target a project that belongs to a lockstepped version policy but is not the policy's main project.", - "type": "boolean" - } - }, - "additionalProperties": false -} diff --git a/libraries/rush-lib/src/schemas/repo-state.schema.json b/libraries/rush-lib/src/schemas/repo-state.schema.json deleted file mode 100644 index d14c1de3ac4..00000000000 --- a/libraries/rush-lib/src/schemas/repo-state.schema.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "title": "Rush repo-state.json file", - "description": "For use with the Rush tool, this file tracks the state of various features in the Rush repo. See http://rushjs.io for details.", - - "type": "object", - "properties": { - "$schema": { - "description": "Part of the JSON Schema standard, this optional keyword declares the URL of the schema that the file conforms to. Editors may download the schema and use it to perform syntax highlighting.", - "type": "string" - }, - "pnpmShrinkwrapHash": { - "description": "A hash of the contents of the PNPM shrinkwrap file for the repository. This hash is used to determine whether or not the shrinkwrap has been modified prior to install.", - "type": "string" - }, - "preferredVersionsHash": { - "description": "A hash of \"preferred versions\" for the repository. This hash is used to determine whether or not preferred versions have been modified prior to install.", - "type": "string" - }, - "packageJsonInjectedDependenciesHash": { - "description": "A hash of the injected dependencies in related package.json. This hash is used to determine whether or not the shrinkwrap needs to updated prior to install.", - "type": "string" - }, - "pnpmCatalogsHash": { - "description": "A hash of the PNPM catalog definitions for the repository. This hash is used to determine whether or not the catalog has been modified prior to install.", - "type": "string" - } - }, - "additionalProperties": false -} diff --git a/libraries/rush-schemas/LICENSE b/libraries/rush-schemas/LICENSE new file mode 100644 index 00000000000..7114e0acefe --- /dev/null +++ b/libraries/rush-schemas/LICENSE @@ -0,0 +1,24 @@ +@rushstack/rush-schemas + +Copyright (c) Microsoft Corporation. All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/libraries/rush-schemas/README.md b/libraries/rush-schemas/README.md new file mode 100644 index 00000000000..443ded8bcd6 --- /dev/null +++ b/libraries/rush-schemas/README.md @@ -0,0 +1,82 @@ +# @rushstack/rush-schemas + +[![npm version](https://badge.fury.io/js/%40rushstack%2Frush-schemas.svg)](https://badge.fury.io/js/%40rushstack%2Frush-schemas) + +JSON Schema validators for [Rush](https://rushjs.io/) configuration files, +authored as [zod](https://zod.dev/) schemas. + +This package is the source-of-truth for the structure of files such as +`experiments.json`, `cobuild.json`, `repo-state.json`, and `build-cache.json`. +Each schema is authored once as a `*.zod.ts` module and the build emits three +artifacts: + +1. **Runtime validator** — `lib/.zod.js` exporting the zod schema + instance, usable at runtime to `parse()` user-provided JSON. +2. **TypeScript types** — `lib/.zod.d.ts` exporting the inferred + (or hand-authored) interface for the configuration shape. +3. **JSON Schema** — `lib/.schema.json`, generated by + [`@rushstack/heft-zod-schema-plugin`](../../heft-plugins/heft-zod-schema-plugin/). + +## Why a dedicated package? + +The schemas describe a published contract that a number of tools depend on: + +- `@microsoft/rush` and `@microsoft/rush-lib` consume them at runtime to + validate user JSON. +- Editors load the corresponding `*.schema.json` files from + `developer.microsoft.com/json-schemas/...` for IntelliSense and validation. +- Third-party Rush plugins, CI tooling, and the `@rushstack/rush-sdk` consume + the same shapes. + +Co-locating the validators here means consumers can `import { experimentsSchema } +from '@rushstack/rush-schemas/lib/experiments.zod'` without pulling in the rest +of `rush-lib`, and Heft's `*.zod.ts` build pipeline only needs to be configured +in one place. + +## Per-schema authoring strategy + +The TypeScript types for each schema are derived from the zod schema rather +than hand-authored, to keep the runtime validator and the static type from +drifting: + +```ts +export type IMyConfigJson = Omit, '$schema'>; +``` + +For schemas whose JSON shape (e.g. a discriminated `oneOf` on `provider`) +intentionally differs from the runtime TypeScript shape consumed inside +`rush-lib`, omit the `z.infer` alias entirely and let `rush-lib` define its +own runtime interface; see `build-cache.zod.ts` for the canonical example. + +## Authoring a new schema + +```ts +import { z } from 'zod'; +import { withSchemaMeta } from '@rushstack/heft-zod-schema-plugin/lib/SchemaMetaHelpers'; + +// eslint-disable-next-line @typescript-eslint/typedef +export const myConfigSchema = withSchemaMeta( + z + .object({ + name: z.string().describe('The name of the item.') + }) + .strict(), + { + $schema: 'http://json-schema.org/draft-04/schema#', + title: 'My Config', + releaseTag: '@public' + } +); + +export type IMyConfigJson = z.infer; + +export default myConfigSchema; +``` + +The default export is what `@rushstack/heft-zod-schema-plugin` reads when +emitting `.schema.json`. + +## Status + +This package is a pilot. Only a small, structurally diverse subset of the Rush +schemas have been ported so far. See the parent PR for context. diff --git a/libraries/rush-schemas/config/heft.json b/libraries/rush-schemas/config/heft.json new file mode 100644 index 00000000000..7f801709aa6 --- /dev/null +++ b/libraries/rush-schemas/config/heft.json @@ -0,0 +1,30 @@ +/** + * Defines configuration used by core Heft. + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft.schema.json", + + "extends": "local-node-rig/profiles/default/config/heft.json", + + "phasesByName": { + "build": { + "tasksByName": { + // Generate one *.schema.json file per *.zod.ts module by reading the + // compiled JavaScript form. Schemas are written alongside their .js so + // they can be consumed via the package's `./lib/.schema.json` + // exports map. + "zod-schemas": { + "taskDependencies": ["typescript"], + "taskPlugin": { + "pluginPackage": "@rushstack/heft-zod-schema-plugin", + "pluginName": "zod-schema-plugin", + "options": { + "inputGlobs": ["lib-commonjs/*.zod.js"], + "outputFolder": "lib-commonjs" + } + } + } + } + } + } +} diff --git a/libraries/rush-schemas/config/rig.json b/libraries/rush-schemas/config/rig.json new file mode 100644 index 00000000000..165ffb001f5 --- /dev/null +++ b/libraries/rush-schemas/config/rig.json @@ -0,0 +1,7 @@ +{ + // The "rig.json" file directs tools to look for their config files in an external package. + // Documentation for this system: https://www.npmjs.com/package/@rushstack/rig-package + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + + "rigPackageName": "local-node-rig" +} diff --git a/libraries/rush-schemas/eslint.config.js b/libraries/rush-schemas/eslint.config.js new file mode 100644 index 00000000000..87132f43292 --- /dev/null +++ b/libraries/rush-schemas/eslint.config.js @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const nodeProfile = require('local-node-rig/profiles/default/includes/eslint/flat/profile/node'); +const friendlyLocalsMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/friendly-locals'); +const tsdocMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/tsdoc'); + +module.exports = [ + ...nodeProfile, + ...friendlyLocalsMixin, + ...tsdocMixin, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + tsconfigRootDir: __dirname + } + } + } +]; diff --git a/libraries/rush-schemas/package.json b/libraries/rush-schemas/package.json new file mode 100644 index 00000000000..14a867358bb --- /dev/null +++ b/libraries/rush-schemas/package.json @@ -0,0 +1,51 @@ +{ + "name": "@rushstack/rush-schemas", + "version": "0.1.0", + "description": "JSON Schema validators for Rush configuration files, authored as zod schemas. Emits both runtime validators and *.schema.json files.", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/rushstack.git", + "directory": "libraries/rush-schemas" + }, + "homepage": "https://rushjs.io", + "license": "MIT", + "scripts": { + "build": "heft build --clean", + "test": "heft test --clean", + "_phase:build": "heft run --only build -- --clean", + "_phase:test": "heft run --only test -- --clean" + }, + "main": "./lib-commonjs/index.js", + "types": "./lib-dts/index.d.ts", + "exports": { + ".": { + "types": "./lib-dts/index.d.ts", + "node": "./lib-commonjs/index.js", + "require": "./lib-commonjs/index.js" + }, + "./lib/*.schema.json": "./lib-commonjs/*.schema.json", + "./lib/*": { + "types": "./lib-dts/*.d.ts", + "node": "./lib-commonjs/*.js", + "require": "./lib-commonjs/*.js" + }, + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "lib/*": [ + "lib-dts/*" + ] + } + }, + "dependencies": { + "@rushstack/heft-zod-schema-plugin": "workspace:*", + "zod": "~4.3.6" + }, + "devDependencies": { + "@rushstack/heft": "workspace:*", + "eslint": "~9.37.0", + "local-node-rig": "workspace:*" + }, + "sideEffects": false +} diff --git a/libraries/rush-schemas/src/build-cache.zod.ts b/libraries/rush-schemas/src/build-cache.zod.ts new file mode 100644 index 00000000000..ee0e011b122 --- /dev/null +++ b/libraries/rush-schemas/src/build-cache.zod.ts @@ -0,0 +1,264 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { z } from 'zod'; + +import { withSchemaMeta } from '@rushstack/heft-zod-schema-plugin/lib/SchemaMetaHelpers'; + +// NOTE: this port intentionally does NOT include a `z.infer` alias or a drift +// check against rush-lib's existing `IBuildCacheJson` type. That type uses an +// open `[otherConfigKey: string]: JsonObject` index signature so that +// third-party cache provider plugins can add their own config blocks at +// runtime, while the JSON Schema describes a fixed set of first-party +// providers via a discriminated `oneOf`. The two shapes serve different +// purposes (runtime extensibility vs. editor-time validation) and cannot be +// reconciled by a structural assertion. This is the central pitfall of +// porting `oneOf`-style schemas: validators and TypeScript types diverge by +// design, and the schema package has to acknowledge that rather than try to +// pretend otherwise. + +const entraLoginFlow: z.ZodEnum<{ + AdoCodespacesAuth: 'AdoCodespacesAuth'; + InteractiveBrowser: 'InteractiveBrowser'; + DeviceCode: 'DeviceCode'; + VisualStudioCode: 'VisualStudioCode'; + AzureCli: 'AzureCli'; + AzureDeveloperCli: 'AzureDeveloperCli'; + AzurePowerShell: 'AzurePowerShell'; +}> = z.enum([ + 'AdoCodespacesAuth', + 'InteractiveBrowser', + 'DeviceCode', + 'VisualStudioCode', + 'AzureCli', + 'AzureDeveloperCli', + 'AzurePowerShell' +]); + +type EntraLoginFlowName = z.infer; + +const entraLoginFlowKeys: readonly EntraLoginFlowName[] = entraLoginFlow.options; + +/** + * Builds the loginFlowFailover sub-object: each known provider key maps to a + * fallback flow that is not equal to the key itself (matching the original + * JSON Schema's `not: { enum: [] }` constraint). + */ +function buildLoginFlowFailoverShape(): z.ZodObject< + Record>>> +> { + const shape: Record>>> = {}; + for (const key of entraLoginFlowKeys) { + const others: EntraLoginFlowName[] = entraLoginFlowKeys.filter((other) => other !== key); + // Reconstruct an enum without the self-fallback option. + const optionsRecord: Record = {}; + for (const value of others) { + optionsRecord[value] = value; + } + shape[key] = z.enum(optionsRecord).optional(); + } + return z.object(shape) as z.ZodObject< + Record>>> + >; +} + +const azureBlobStorageConfiguration: z.ZodObject<{ + storageAccountName: z.ZodString; + storageContainerName: z.ZodString; + azureEnvironment: z.ZodOptional< + z.ZodEnum<{ + AzurePublicCloud: 'AzurePublicCloud'; + AzureChina: 'AzureChina'; + AzureGermany: 'AzureGermany'; + AzureGovernment: 'AzureGovernment'; + }> + >; + loginFlow: z.ZodOptional; + loginFlowFailover: z.ZodOptional>; + blobPrefix: z.ZodOptional; + isCacheWriteAllowed: z.ZodOptional; + readRequiresAuthentication: z.ZodOptional; +}> = z.object({ + storageAccountName: z + .string() + .describe('(Required) The name of the the Azure storage account to use for build cache.'), + storageContainerName: z + .string() + .describe('(Required) The name of the container in the Azure storage account to use for build cache.'), + azureEnvironment: z + .enum(['AzurePublicCloud', 'AzureChina', 'AzureGermany', 'AzureGovernment']) + .describe('The Azure environment the storage account exists in. Defaults to AzurePublicCloud.') + .optional(), + loginFlow: entraLoginFlow.optional(), + loginFlowFailover: buildLoginFlowFailoverShape() + .describe( + 'Optional configuration for a fallback login flow if the primary login flow fails. ' + + 'If not defined, the default order is: AdoCodespacesAuth -> VisualStudioCode -> AzureCli -> ' + + 'AzureDeveloperCli -> AzurePowerShell -> InteractiveBrowser -> DeviceCode.' + ) + .optional(), + blobPrefix: z.string().describe('An optional prefix for cache item blob names.').optional(), + isCacheWriteAllowed: z + .boolean() + .describe('If set to true, allow writing to the cache. Defaults to false.') + .optional(), + readRequiresAuthentication: z + .boolean() + .describe('If set to true, reading the cache requires authentication. Defaults to false.') + .optional() +}); + +const amazonS3Configuration: z.ZodObject<{ + s3Bucket: z.ZodOptional; + s3Endpoint: z.ZodOptional; + s3Region: z.ZodString; + s3Prefix: z.ZodOptional; + isCacheWriteAllowed: z.ZodOptional; +}> = z.object({ + s3Bucket: z + .string() + .describe( + '(Required unless s3Endpoint is specified) The name of the bucket to use for build cache (e.g. "my-bucket").' + ) + .optional(), + s3Endpoint: z + .string() + .describe( + '(Required unless s3Bucket is specified) The Amazon S3 endpoint of the bucket to use for build cache ' + + '(e.g. "my-bucket.s3.us-east-2.amazonaws.com" or "http://localhost:9000").\n' + + 'This should not include any path, use the s3Prefix to set the path.' + ) + .optional(), + s3Region: z + .string() + .describe('(Required) The Amazon S3 region of the bucket to use for build cache (e.g. "us-east-1").'), + s3Prefix: z + .string() + .describe('An optional prefix ("folder") for cache items. Should not start with /') + .optional(), + isCacheWriteAllowed: z + .boolean() + .describe('If set to true, allow writing to the cache. Defaults to false.') + .optional() +}); + +const tokenHandler: z.ZodObject<{ exec: z.ZodString; args: z.ZodOptional> }> = + z.object({ + exec: z.string().describe('(Required) The command or script to execute.'), + args: z.array(z.string()).describe('(Optional) Arguments to pass to the command or script.').optional() + }); + +const httpConfiguration: z.ZodObject<{ + url: z.ZodString; + uploadMethod: z.ZodOptional< + z.ZodEnum<{ PUT: 'PUT'; POST: 'POST'; PATCH: 'PATCH' }> + >; + headers: z.ZodOptional>; + tokenHandler: z.ZodOptional; + cacheKeyPrefix: z.ZodOptional; + isCacheWriteAllowed: z.ZodOptional; +}> = z.object({ + url: z + .string() + .url() + .describe('(Required) The URL of the server that stores the caches (e.g. "https://build-caches.example.com").'), + uploadMethod: z + .enum(['PUT', 'POST', 'PATCH']) + .describe('(Optional) The HTTP method to use when writing to the cache (defaults to PUT).') + .optional(), + headers: z + .record(z.string(), z.string()) + .describe('(Optional) HTTP headers to pass to the cache server') + .optional(), + tokenHandler: tokenHandler + .describe( + '(Optional) Shell command that prints the authorization token needed to communicate with the HTTPS ' + + 'server and exits with code 0. This command will be executed from the root of the monorepo.' + ) + .optional(), + cacheKeyPrefix: z.string().describe('(Optional) prefix for cache keys.').optional(), + isCacheWriteAllowed: z + .boolean() + .describe('(Optional) If set to true, allow writing to the cache. Defaults to false.') + .optional() +}); + +const baseProperties: z.ZodObject<{ + $schema: z.ZodOptional; + buildCacheEnabled: z.ZodBoolean; + cacheProvider: z.ZodString; + cacheEntryNamePattern: z.ZodOptional; + cacheHashSalt: z.ZodOptional; + azureBlobStorageConfiguration: z.ZodOptional; + amazonS3Configuration: z.ZodOptional; + httpConfiguration: z.ZodOptional; +}> = z.object({ + $schema: z + .string() + .describe( + 'Part of the JSON Schema standard, this optional keyword declares the URL of the schema that the file ' + + 'conforms to. Editors may download the schema and use it to perform syntax highlighting.' + ) + .optional(), + buildCacheEnabled: z.boolean().describe('Set this to true to enable the build cache feature.'), + cacheProvider: z.string().describe('Specify the cache provider to use'), + cacheEntryNamePattern: z + .string() + .describe( + 'Setting this property overrides the cache entry ID. If this property is set, it must contain a [hash] ' + + 'token. It may also contain one of the following tokens: [projectName], [projectName:normalize], ' + + '[phaseName], [phaseName:normalize], [phaseName:trimPrefix], [os], and [arch].' + ) + .optional(), + cacheHashSalt: z + .string() + .describe( + 'An optional salt to inject during calculation of the cache key. This can be used to invalidate the ' + + 'cache for all projects when the salt changes.' + ) + .optional(), + azureBlobStorageConfiguration: azureBlobStorageConfiguration.optional(), + amazonS3Configuration: amazonS3Configuration.optional(), + httpConfiguration: httpConfiguration.optional() +}); + +/** + * The zod schema describing the structure of `build-cache.json`. + * + * @remarks + * The schema mirrors the original `build-cache.schema.json` discriminated + * `oneOf` over the `cacheProvider` field. Provider-specific configuration + * blocks (for example, `amazonS3Configuration`) are validated only when the + * matching provider is selected. + * + * @beta + */ +// eslint-disable-next-line @typescript-eslint/typedef +export const buildCacheSchema = withSchemaMeta( + baseProperties.and( + z.discriminatedUnion('cacheProvider', [ + z.object({ cacheProvider: z.literal('local-only') }), + z.object({ + cacheProvider: z.literal('azure-blob-storage'), + azureBlobStorageConfiguration: azureBlobStorageConfiguration + }), + z.object({ + cacheProvider: z.literal('amazon-s3'), + amazonS3Configuration: amazonS3Configuration + }), + z.object({ + cacheProvider: z.literal('http'), + httpConfiguration: httpConfiguration + }) + ]) + ), + { + $schema: 'http://json-schema.org/draft-04/schema#', + title: "Configuration for Rush's build cache.", + description: + "For use with the Rush tool, this file provides configuration options for cached project build output. See http://rushjs.io for details.", + releaseTag: '@beta' + } +); + +export default buildCacheSchema; diff --git a/libraries/rush-schemas/src/cobuild.zod.ts b/libraries/rush-schemas/src/cobuild.zod.ts new file mode 100644 index 00000000000..c093c39771d --- /dev/null +++ b/libraries/rush-schemas/src/cobuild.zod.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { z } from 'zod'; + +import { withSchemaMeta } from '@rushstack/heft-zod-schema-plugin/lib/SchemaMetaHelpers'; + +/** + * The zod schema describing the structure of `cobuild.json`. Use this to + * validate raw config input. + * + * @beta + */ +// eslint-disable-next-line @typescript-eslint/typedef +export const cobuildSchema = withSchemaMeta( + z + .object({ + $schema: z + .string() + .describe( + 'Part of the JSON Schema standard, this optional keyword declares the URL of the schema that the file conforms to. ' + + 'Editors may download the schema and use it to perform syntax highlighting.' + ) + .optional(), + cobuildFeatureEnabled: z.boolean().describe('Set this to true to enable the cobuild feature.'), + cobuildLockProvider: z.string().describe('Specify the cobuild lock provider to use') + }) + .strict(), + { + $schema: 'http://json-schema.org/draft-04/schema#', + title: "Configuration for Rush's cobuild.", + description: + "For use with the Rush tool, this file provides configuration options for cobuild feature. See http://rushjs.io for details.", + releaseTag: '@beta' + } +); + +/** + * Raw shape of `cobuild.json`, excluding the optional top-level `$schema` + * pointer. The shape is derived from {@link cobuildSchema} via `z.infer` to + * keep the schema and the type from drifting. + * + * @beta + */ +export type ICobuildJson = Omit, '$schema'>; + +export default cobuildSchema; diff --git a/libraries/rush-schemas/src/experiments.zod.ts b/libraries/rush-schemas/src/experiments.zod.ts new file mode 100644 index 00000000000..5728c6b6160 --- /dev/null +++ b/libraries/rush-schemas/src/experiments.zod.ts @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { z } from 'zod'; + +import { withSchemaMeta } from '@rushstack/heft-zod-schema-plugin/lib/SchemaMetaHelpers'; + +const booleanFlag = (description: string): z.ZodOptional => + z.boolean().describe(description).optional(); + +/** + * The zod schema describing the structure of `experiments.json`. Use this to + * validate raw config input. The corresponding TypeScript shape + * {@link IExperimentsJson} is derived from this schema via `z.infer`. + * + * @beta + */ +// eslint-disable-next-line @typescript-eslint/typedef +export const experimentsSchema = withSchemaMeta( + z + .object({ + $schema: z + .string() + .describe( + 'Part of the JSON Schema standard, this optional keyword declares the URL of the schema that the file conforms to. ' + + 'Editors may download the schema and use it to perform syntax highlighting.' + ) + .optional(), + + usePnpmFrozenLockfileForRushInstall: booleanFlag( + "By default, 'rush install' passes --no-prefer-frozen-lockfile to 'pnpm install'. " + + "Set this option to true to pass '--frozen-lockfile' instead for faster installs." + ), + usePnpmPreferFrozenLockfileForRushUpdate: booleanFlag( + "By default, 'rush update' passes --no-prefer-frozen-lockfile to 'pnpm install'. " + + "Set this option to true to pass '--prefer-frozen-lockfile' instead to minimize shrinkwrap changes." + ), + usePnpmLockfileOnlyThenFrozenLockfileForRushUpdate: booleanFlag( + "By default, 'rush update' runs as a single operation. Set this option to true to instead update the lockfile with `--lockfile-only`, then perform a `--frozen-lockfile` install. " + + 'Necessary when using the `afterAllResolved` hook in .pnpmfile.cjs.' + ), + omitImportersFromPreventManualShrinkwrapChanges: booleanFlag( + "If using the 'preventManualShrinkwrapChanges' option, restricts the hash to only include the layout of " + + 'external dependencies. Used to allow links between workspace projects or the addition/removal of ' + + 'references to existing dependency versions to not cause hash changes.' + ), + noChmodFieldInTarHeaderNormalization: booleanFlag( + 'If true, the chmod field in temporary project tar headers will not be normalized. This normalization can help ensure consistent tarball integrity across platforms.' + ), + buildCacheWithAllowWarningsInSuccessfulBuild: booleanFlag( + 'If true, build caching will respect the allowWarningsInSuccessfulBuild flag and cache builds with warnings. This will not replay warnings from the cached build.' + ), + buildSkipWithAllowWarningsInSuccessfulBuild: booleanFlag( + 'If true, build skipping will respect the allowWarningsInSuccessfulBuild flag and skip builds with warnings. This will not replay warnings from the skipped build.' + ), + phasedCommands: booleanFlag( + 'THIS EXPERIMENT HAS BEEN GRADUATED TO A STANDARD FEATURE. THIS PROPERTY SHOULD BE REMOVED.' + ), + cleanInstallAfterNpmrcChanges: booleanFlag( + 'If true, perform a clean install after when running `rush install` or `rush update` if the `.npmrc` file has changed since the last install.' + ), + printEventHooksOutputToConsole: booleanFlag( + 'If true, print the outputs of shell commands defined in event hooks to the console.' + ), + forbidPhantomResolvableNodeModulesFolders: booleanFlag( + 'If true, Rush will not allow node_modules in the repo folder or in parent folders.' + ), + usePnpmSyncForInjectedDependencies: booleanFlag( + "(UNDER DEVELOPMENT) For certain installation problems involving peer dependencies, PNPM cannot correctly satisfy versioning requirements without installing duplicate copies of a package inside the node_modules folder. This poses a problem for 'workspace:*' dependencies, as they are normally installed by making a symlink to the local project source folder. PNPM's 'injected dependencies' feature provides a model for copying the local project folder into node_modules, however copying must occur AFTER the dependency project is built and BEFORE the consuming project starts to build. The 'pnpm-sync' tool manages this operation; see its documentation for details. Enable this experiment if you want 'rush' and 'rushx' commands to resync injected dependencies by invoking 'pnpm-sync' during the build." + ), + generateProjectImpactGraphDuringRushUpdate: booleanFlag( + 'If set to true, Rush will generate a `project-impact-graph.yaml` file in the repository root during `rush update`.' + ), + useIPCScriptsInWatchMode: booleanFlag( + 'If true, when running in watch mode, Rush will check for phase scripts named `_phase::ipc` and run them instead of `_phase:` if they exist. The created child process will be provided with an IPC channel and expected to persist across invocations.' + ), + allowCobuildWithoutCache: booleanFlag( + 'Allow cobuilds without using the build cache to store previous execution info. When setting up ' + + 'distributed builds, Rush will allow uncacheable projects to still leverage the cobuild feature. ' + + "This is useful when you want to speed up operations that can't (or shouldn't) be cached." + ), + rushAlerts: booleanFlag( + "(UNDER DEVELOPMENT) The Rush alerts feature provides a way to send announcements to engineers working in the monorepo, by printing directly in the user's shell window when they invoke Rush commands. This ensures that important notices will be seen by anyone doing active development, since people often ignore normal discussion group messages or don't know to subscribe." + ), + enableSubpathScan: booleanFlag( + 'By default, rush perform a full scan of the entire repository. For example, Rush runs `git status` to check for local file changes. When this toggle is enabled, Rush will only scan specific paths, significantly speeding up Git operations.' + ), + exemptDecoupledDependenciesBetweenSubspaces: booleanFlag( + 'Rush has a policy that normally requires Rush projects to specify `workspace:*` in package.json when depending on other projects in the workspace, unless they are explicitly declared as `decoupledLocalDependencies in rush.json. Enabling this experiment will remove that requirement for dependencies belonging to a different subspace. This is useful for large product groups who work in separate subspaces and generally prefer to consume each other\'s packages via the NPM registry.' + ), + omitAppleDoubleFilesFromBuildCache: booleanFlag( + 'If true, when running on macOS, Rush will omit AppleDouble files (._*) from build cache archives when a companion file exists in the same directory. AppleDouble files are automatically created by macOS to store extended attributes on filesystems that don\'t support them, and should generally not be included in the shared build cache.' + ), + strictChangefileValidation: booleanFlag( + 'If true, `rush change --verify` will report errors if change files reference projects that do not exist in the Rush configuration, or if change files target a project that belongs to a lockstepped version policy but is not the policy\'s main project.' + ) + }) + .strict(), + { + $schema: 'http://json-schema.org/draft-04/schema#', + title: 'Rush experiments.json config file', + description: + 'For use with the Rush tool, this file allows repo maintainers to enable and disable experimental Rush features.', + releaseTag: '@beta' + } +); + +/** + * Helper that maps over the keys of `T` to coerce TypeScript into rendering the + * fully-expanded shape of an inferred type. + */ +type _Simplify = T extends infer U ? { [K in keyof U]: U[K] } : never; + +/** + * Raw shape of `experiments.json`, excluding the optional top-level `$schema` + * pointer. The shape is derived from {@link experimentsSchema} via `z.infer` to + * keep the schema and the type from drifting; per-property documentation is + * carried by the schema's `.describe()` annotations. + * + * @beta + */ +export type IExperimentsJson = _Simplify, '$schema'>>; + +// Default export so the heft-zod-schema-plugin emits this as +// `experiments.schema.json` (rather than `experiments.experimentsSchema.schema.json` +// when configured with `exportName: "*"`). +export default experimentsSchema; diff --git a/libraries/rush-schemas/src/index.ts b/libraries/rush-schemas/src/index.ts new file mode 100644 index 00000000000..347b5f26c75 --- /dev/null +++ b/libraries/rush-schemas/src/index.ts @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * JSON Schema validators for Rush configuration files, authored as zod schemas. + * + * @remarks + * Each schema module exports a default zod validator and the corresponding + * TypeScript shape. The build also emits a `.schema.json` JSON Schema + * file alongside the compiled JavaScript module, accessible via the package's + * `./lib/.schema.json` exports map entry. + * + * @packageDocumentation + */ + +export { + experimentsSchema, + type IExperimentsJson, + default as defaultExperimentsSchema +} from './experiments.zod'; + +export { cobuildSchema, type ICobuildJson, default as defaultCobuildSchema } from './cobuild.zod'; + +export { repoStateSchema, default as defaultRepoStateSchema } from './repo-state.zod'; + +export { buildCacheSchema, default as defaultBuildCacheSchema } from './build-cache.zod'; diff --git a/libraries/rush-schemas/src/repo-state.zod.ts b/libraries/rush-schemas/src/repo-state.zod.ts new file mode 100644 index 00000000000..7157412b326 --- /dev/null +++ b/libraries/rush-schemas/src/repo-state.zod.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 { z } from 'zod'; + +import { withSchemaMeta } from '@rushstack/heft-zod-schema-plugin/lib/SchemaMetaHelpers'; + +/** + * The zod schema describing the structure of `repo-state.json`. + * + * @beta + */ +// eslint-disable-next-line @typescript-eslint/typedef +export const repoStateSchema = withSchemaMeta( + z + .object({ + $schema: z + .string() + .describe( + 'Part of the JSON Schema standard, this optional keyword declares the URL of the schema that the file conforms to. ' + + 'Editors may download the schema and use it to perform syntax highlighting.' + ) + .optional(), + pnpmShrinkwrapHash: z + .string() + .describe( + 'A hash of the contents of the PNPM shrinkwrap file for the repository. ' + + 'This hash is used to determine whether or not the shrinkwrap has been modified prior to install.' + ) + .optional(), + preferredVersionsHash: z + .string() + .describe( + 'A hash of "preferred versions" for the repository. ' + + 'This hash is used to determine whether or not preferred versions have been modified prior to install.' + ) + .optional(), + packageJsonInjectedDependenciesHash: z + .string() + .describe( + 'A hash of the injected dependencies in related package.json. ' + + 'This hash is used to determine whether or not the shrinkwrap needs to updated prior to install.' + ) + .optional(), + pnpmCatalogsHash: z + .string() + .describe( + 'A hash of the PNPM catalog definitions for the repository. ' + + 'This hash is used to determine whether or not the catalog has been modified prior to install.' + ) + .optional() + }) + .strict(), + { + $schema: 'http://json-schema.org/draft-04/schema#', + title: 'Rush repo-state.json file', + description: + 'For use with the Rush tool, this file tracks the state of various features in the Rush repo. See http://rushjs.io for details.', + releaseTag: '@internal' + } +); + +export default repoStateSchema; diff --git a/libraries/rush-schemas/tsconfig.json b/libraries/rush-schemas/tsconfig.json new file mode 100644 index 00000000000..dac21d04081 --- /dev/null +++ b/libraries/rush-schemas/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json" +} diff --git a/libraries/rush-sdk/package.json b/libraries/rush-sdk/package.json index 149dc5d331a..fd3a7a00269 100644 --- a/libraries/rush-sdk/package.json +++ b/libraries/rush-sdk/package.json @@ -47,6 +47,7 @@ "@rushstack/lookup-by-path": "workspace:*", "@rushstack/node-core-library": "workspace:*", "@rushstack/package-deps-hash": "workspace:*", + "@rushstack/rush-schemas": "workspace:*", "@rushstack/terminal": "workspace:*", "tapable": "2.2.1" }, diff --git a/rush.json b/rush.json index 471d9676db5..daef1f4c83e 100644 --- a/rush.json +++ b/rush.json @@ -1120,6 +1120,12 @@ "reviewCategory": "libraries", "shouldPublish": true }, + { + "packageName": "@rushstack/heft-zod-schema-plugin", + "projectFolder": "heft-plugins/heft-zod-schema-plugin", + "reviewCategory": "libraries", + "shouldPublish": true + }, { "packageName": "@rushstack/heft-lint-plugin", "projectFolder": "heft-plugins/heft-lint-plugin", @@ -1302,6 +1308,12 @@ "reviewCategory": "libraries", "versionPolicyName": "rush" }, + { + "packageName": "@rushstack/rush-schemas", + "projectFolder": "libraries/rush-schemas", + "reviewCategory": "libraries", + "versionPolicyName": "rush" + }, { "packageName": "@rushstack/rush-sdk", "projectFolder": "libraries/rush-sdk",