From b7da0356f6903610b85dfcaf195e744b4cbe9b8a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 02:18:04 +0000 Subject: [PATCH 1/9] feat(heft-zod-schema-plugin): new plugin to generate *.schema.json from zod validators Agent-Logs-Url: https://github.com/microsoft/rushstack/sessions/f6417dd3-99b5-4eb5-9343-ecf5de6c37c4 Co-authored-by: iclanton <5010588+iclanton@users.noreply.github.com> --- .../subspaces/default/common-versions.json | 6 + .../config/subspaces/default/pnpm-lock.yaml | 30 ++ .../config/subspaces/default/repo-state.json | 2 +- .../heft-zod-schema-plugin/CHANGELOG.json | 4 + .../heft-zod-schema-plugin/CHANGELOG.md | 3 + heft-plugins/heft-zod-schema-plugin/LICENSE | 24 ++ heft-plugins/heft-zod-schema-plugin/README.md | 145 ++++++++ .../heft-zod-schema-plugin/config/heft.json | 27 ++ .../config/jest.config.json | 3 + .../heft-zod-schema-plugin/config/rig.json | 5 + .../heft-zod-schema-plugin/eslint.config.js | 18 + .../heft-zod-schema-plugin/heft-plugin.json | 11 + .../heft-zod-schema-plugin/package.json | 52 +++ .../src/HeftZodSchemaPlugin.ts | 141 ++++++++ .../src/SchemaMetaHelpers.ts | 102 ++++++ .../src/ZodSchemaGenerator.ts | 314 ++++++++++++++++++ .../heft-zod-schema-plugin.schema.json | 35 ++ .../src/test/SchemaMetaHelpers.test.ts | 39 +++ .../src/test/ZodSchemaGenerator.test.ts | 109 ++++++ .../ZodSchemaGenerator.test.ts.snap | 44 +++ .../src/test/fixtures/basic.zod.ts | 14 + .../src/test/fixtures/named-exports.zod.ts | 12 + .../src/test/fixtures/with-tsdoc-tag.zod.ts | 21 ++ .../heft-zod-schema-plugin/tsconfig.json | 3 + rush.json | 6 + 25 files changed, 1169 insertions(+), 1 deletion(-) create mode 100644 heft-plugins/heft-zod-schema-plugin/CHANGELOG.json create mode 100644 heft-plugins/heft-zod-schema-plugin/CHANGELOG.md create mode 100644 heft-plugins/heft-zod-schema-plugin/LICENSE create mode 100644 heft-plugins/heft-zod-schema-plugin/README.md create mode 100644 heft-plugins/heft-zod-schema-plugin/config/heft.json create mode 100644 heft-plugins/heft-zod-schema-plugin/config/jest.config.json create mode 100644 heft-plugins/heft-zod-schema-plugin/config/rig.json create mode 100644 heft-plugins/heft-zod-schema-plugin/eslint.config.js create mode 100644 heft-plugins/heft-zod-schema-plugin/heft-plugin.json create mode 100644 heft-plugins/heft-zod-schema-plugin/package.json create mode 100644 heft-plugins/heft-zod-schema-plugin/src/HeftZodSchemaPlugin.ts create mode 100644 heft-plugins/heft-zod-schema-plugin/src/SchemaMetaHelpers.ts create mode 100644 heft-plugins/heft-zod-schema-plugin/src/ZodSchemaGenerator.ts create mode 100644 heft-plugins/heft-zod-schema-plugin/src/schemas/heft-zod-schema-plugin.schema.json create mode 100644 heft-plugins/heft-zod-schema-plugin/src/test/SchemaMetaHelpers.test.ts create mode 100644 heft-plugins/heft-zod-schema-plugin/src/test/ZodSchemaGenerator.test.ts create mode 100644 heft-plugins/heft-zod-schema-plugin/src/test/__snapshots__/ZodSchemaGenerator.test.ts.snap create mode 100644 heft-plugins/heft-zod-schema-plugin/src/test/fixtures/basic.zod.ts create mode 100644 heft-plugins/heft-zod-schema-plugin/src/test/fixtures/named-exports.zod.ts create mode 100644 heft-plugins/heft-zod-schema-plugin/src/test/fixtures/with-tsdoc-tag.zod.ts create mode 100644 heft-plugins/heft-zod-schema-plugin/tsconfig.json 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..9e022725b04 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': @@ -19108,6 +19133,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 +38519,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/heft-plugins/heft-zod-schema-plugin/CHANGELOG.json b/heft-plugins/heft-zod-schema-plugin/CHANGELOG.json new file mode 100644 index 00000000000..10643509716 --- /dev/null +++ b/heft-plugins/heft-zod-schema-plugin/CHANGELOG.json @@ -0,0 +1,4 @@ +{ + "name": "@rushstack/heft-zod-schema-plugin", + "entries": [] +} diff --git a/heft-plugins/heft-zod-schema-plugin/CHANGELOG.md b/heft-plugins/heft-zod-schema-plugin/CHANGELOG.md new file mode 100644 index 00000000000..dc4346b7f76 --- /dev/null +++ b/heft-plugins/heft-zod-schema-plugin/CHANGELOG.md @@ -0,0 +1,3 @@ +# Change Log - @rushstack/heft-zod-schema-plugin + +This log was last generated on Sat, 19 Apr 2026 00:00:00 GMT and should not be manually modified. diff --git a/heft-plugins/heft-zod-schema-plugin/LICENSE b/heft-plugins/heft-zod-schema-plugin/LICENSE new file mode 100644 index 00000000000..4c95bdfe909 --- /dev/null +++ b/heft-plugins/heft-zod-schema-plugin/LICENSE @@ -0,0 +1,24 @@ +@rushstack/heft-json-schema-typings-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..1168312550f --- /dev/null +++ b/heft-plugins/heft-zod-schema-plugin/src/ZodSchemaGenerator.ts @@ -0,0 +1,314 @@ +// 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 } 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. All `inputGlobs` and `outputFolder` paths are resolved + * relative to this folder. + */ + buildFolderPath: string; + + /** + * Globs (relative to `buildFolderPath`) identifying the compiled JavaScript modules + * that export zod schemas. + */ + inputGlobs: string[]; + + /** + * Folder (relative to `buildFolderPath`) where the generated `*.schema.json` files + * will be written. + */ + 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'); + const matches: string[] = await glob(this._options.inputGlobs, { + 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.join( + 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..48965294e4f --- /dev/null +++ b/heft-plugins/heft-zod-schema-plugin/src/test/ZodSchemaGenerator.test.ts @@ -0,0 +1,109 @@ +// 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, PackageJsonLookup } from '@rushstack/node-core-library'; + +import { ZodSchemaGenerator, type IGeneratedSchema } from '../ZodSchemaGenerator'; + +const projectFolder: string = PackageJsonLookup.instance.tryGetPackageFolderFor(__dirname)!; +const compiledFixturesFolder: string = path.join(__dirname, 'fixtures'); +const outputFolder: string = path.join(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: [path.relative(projectFolder, path.join(compiledFixturesFolder, 'basic.zod.js'))], + outputFolder: path.relative(projectFolder, 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: [ + path.relative(projectFolder, path.join(compiledFixturesFolder, 'with-tsdoc-tag.zod.js')) + ], + outputFolder: path.relative(projectFolder, 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: [ + path.relative(projectFolder, path.join(compiledFixturesFolder, 'named-exports.zod.js')) + ], + outputFolder: path.relative(projectFolder, outputFolder), + exportName: '*', + indent: 2 + }); + + const results: IGeneratedSchema[] = await generator.generateAsync(); + expect(results).toHaveLength(2); + const fileNames: string[] = results.map((r) => path.basename(r.outputFilePath)).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: [path.relative(projectFolder, path.join(compiledFixturesFolder, 'basic.zod.js'))], + outputFolder: path.relative(projectFolder, 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: [path.relative(projectFolder, path.join(compiledFixturesFolder, 'basic.zod.js'))], + outputFolder: path.relative(projectFolder, 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/rush.json b/rush.json index 471d9676db5..cacfe3ee19f 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", From a500f8ffe3f1d7ba75e88e8a7d4f7d6519365d3c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 02:31:11 +0000 Subject: [PATCH 2/9] feat(rush-lib): pilot heft-zod-schema-plugin with experiments.zod.ts Agent-Logs-Url: https://github.com/microsoft/rushstack/sessions/f6417dd3-99b5-4eb5-9343-ecf5de6c37c4 Co-authored-by: iclanton <5010588+iclanton@users.noreply.github.com> --- .../rush-lib/zod-schema-pilot_2026-04-19.json | 10 ++ .../zod-schema-plugin_2026-04-19.json | 10 ++ .../rush/browser-approved-packages.json | 4 + .../build-tests-subspace/pnpm-lock.yaml | 10 +- .../build-tests-subspace/repo-state.json | 4 +- .../config/subspaces/default/pnpm-lock.yaml | 6 + libraries/rush-lib/config/heft.json | 17 ++ libraries/rush-lib/package.json | 4 +- .../rush-lib/src/schemas/experiments.zod.ts | 148 ++++++++++++++++++ 9 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 common/changes/@microsoft/rush-lib/zod-schema-pilot_2026-04-19.json create mode 100644 common/changes/@rushstack/heft-zod-schema-plugin/zod-schema-plugin_2026-04-19.json create mode 100644 libraries/rush-lib/src/schemas/experiments.zod.ts 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..18c9de63aef --- /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": "Pilot for @rushstack/heft-zod-schema-plugin: introduce experiments.zod.ts as a parallel zod-based source of truth for experiments.json. The hand-authored IExperimentsJson interface and experiments.schema.json remain unchanged; a compile-time assertion now guarantees the zod schema stays in sync with the interface.", + "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/config/rush/browser-approved-packages.json b/common/config/rush/browser-approved-packages.json index 1a87efdf7fd..583ff9264e7 100644 --- a/common/config/rush/browser-approved-packages.json +++ b/common/config/rush/browser-approved-packages.json @@ -38,6 +38,10 @@ "name": "@reduxjs/toolkit", "allowedCategories": [ "libraries", "vscode-extensions" ] }, + { + "name": "@rushstack/heft-zod-schema-plugin", + "allowedCategories": [ "libraries" ] + }, { "name": "@rushstack/problem-matcher", "allowedCategories": [ "libraries" ] diff --git a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml index 18ef85bc98c..5f8af2df6c7 100644 --- a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml +++ b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml @@ -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,17 @@ 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@file:../../../apps/heft': resolution: {directory: ../../../apps/heft, type: directory} diff --git a/common/config/subspaces/build-tests-subspace/repo-state.json b/common/config/subspaces/build-tests-subspace/repo-state.json index 51c6c27e844..982e319db26 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": "4497f5b169c39c6d45b2f351e4484d4879102c71", "preferredVersionsHash": "550b4cee0bef4e97db6c6aad726df5149d20e7d9", - "packageJsonInjectedDependenciesHash": "9c068bf4931bd84aa82934f391073bf027e52b69" + "packageJsonInjectedDependenciesHash": "e7087a8987565f709ad2f841d39cd7078a57d8ad" } diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 9e022725b04..9193f2eeee2 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -4189,6 +4189,9 @@ importers: '@rushstack/heft-webpack5-plugin': specifier: workspace:* version: link:../../heft-plugins/heft-webpack5-plugin + '@rushstack/heft-zod-schema-plugin': + specifier: workspace:* + version: link:../../heft-plugins/heft-zod-schema-plugin '@rushstack/operation-graph': specifier: workspace:* version: link:../operation-graph @@ -4231,6 +4234,9 @@ importers: webpack: specifier: ~5.105.2 version: 5.105.4 + zod: + specifier: ~4.3.6 + version: 4.3.6 ../../../libraries/rush-pnpm-kit-v10: dependencies: diff --git a/libraries/rush-lib/config/heft.json b/libraries/rush-lib/config/heft.json index d361c6eb55b..757daf9ff3d 100644 --- a/libraries/rush-lib/config/heft.json +++ b/libraries/rush-lib/config/heft.json @@ -97,6 +97,23 @@ ] } } + }, + + // PILOT (heft-zod-schema-plugin): generate the experiments.json + // JSON Schema from the colocated experiments.zod.ts source file. + // Writes to temp/zod-schemas to avoid colliding with the legacy + // hand-authored schema in src/schemas/. The generated file is for + // review during the pilot; the runtime still uses the legacy schema. + "zod-schemas": { + "taskDependencies": ["typescript"], + "taskPlugin": { + "pluginPackage": "@rushstack/heft-zod-schema-plugin", + "pluginName": "zod-schema-plugin", + "options": { + "inputGlobs": ["lib-intermediate-commonjs/schemas/*.zod.js"], + "outputFolder": "temp/zod-schemas" + } + } } } } diff --git a/libraries/rush-lib/package.json b/libraries/rush-lib/package.json index 3b1da56425a..1abd39f939b 100644 --- a/libraries/rush-lib/package.json +++ b/libraries/rush-lib/package.json @@ -84,6 +84,7 @@ "devDependencies": { "@pnpm/lockfile.types-900": "npm:@pnpm/lockfile.types@~900.0.0", "@rushstack/heft-webpack5-plugin": "workspace:*", + "@rushstack/heft-zod-schema-plugin": "workspace:*", "@rushstack/heft": "workspace:*", "@rushstack/operation-graph": "workspace:*", "@rushstack/webpack-deep-imports-plugin": "workspace:*", @@ -98,7 +99,8 @@ "@types/webpack-env": "1.18.8", "eslint": "~9.37.0", "local-node-rig": "workspace:*", - "webpack": "~5.105.2" + "webpack": "~5.105.2", + "zod": "~4.3.6" }, "publishOnlyDependencies": { "@rushstack/rush-amazon-s3-build-cache-plugin": "workspace:*", diff --git a/libraries/rush-lib/src/schemas/experiments.zod.ts b/libraries/rush-lib/src/schemas/experiments.zod.ts new file mode 100644 index 00000000000..5d23f2b7ccd --- /dev/null +++ b/libraries/rush-lib/src/schemas/experiments.zod.ts @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// PILOT: zod-based source-of-truth for experiments.json. +// +// This module is the long-term replacement for the hand-authored +// `experiments.schema.json` that lives next to it. The companion legacy schema +// is intentionally kept in place during the pilot so reviewers can diff the +// generated output against it. See the parent PR description for context. +// +// To preserve the existing rush-lib public API surface during the pilot, the +// `IExperimentsJson` interface in `ExperimentsConfiguration.ts` is left +// unchanged. The compile-time assertion at the bottom of this file guarantees +// that the zod schema stays structurally equivalent to that interface; if they +// ever drift, the build fails. +// +// At build time, `@rushstack/heft-zod-schema-plugin` reads the compiled form +// of this module and emits a generated `experiments.schema.json` for review. + +import { z } from 'zod'; + +import { withSchemaMeta } from '@rushstack/heft-zod-schema-plugin/lib/SchemaMetaHelpers'; + +import type { IExperimentsJson } from '../api/ExperimentsConfiguration'; + +const booleanFlag = (description: string): z.ZodOptional => + z.boolean().describe(description).optional(); + +/** + * The zod schema describing the structure of `experiments.json`. + * + * @internal + */ +// 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." + ), + 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." + ), + 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, 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.' + ), + 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( + 'When using cobuilds, this experiment allows uncacheable operations to benefit from cobuild orchestration without using the build cache.' + ), + 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; + +/** + * Compile-time assertion that the zod schema is structurally equivalent to the + * hand-authored `IExperimentsJson` interface in `ExperimentsConfiguration.ts`. + * If the two ever drift (for example, a new experiment is added in only one + * place), this will fail the build. + * + * @internal + */ +export type _ExperimentsJsonZodMatches = _Simplify> extends IExperimentsJson + ? IExperimentsJson extends _Simplify> + ? true + : { error: 'IExperimentsJson is missing properties present on z.infer' } + : { error: 'z.infer is missing properties present on IExperimentsJson' }; + +const _typeCheck: _ExperimentsJsonZodMatches = true; +// Reference the unused binding so the linter is happy. +void _typeCheck; + +// 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; From 1b4df78d8d18510f7d45ea4cbe5e878ad1e81b3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 02:34:48 +0000 Subject: [PATCH 3/9] fix: correct package name in heft-zod-schema-plugin LICENSE header Agent-Logs-Url: https://github.com/microsoft/rushstack/sessions/f6417dd3-99b5-4eb5-9343-ecf5de6c37c4 Co-authored-by: iclanton <5010588+iclanton@users.noreply.github.com> --- heft-plugins/heft-zod-schema-plugin/LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/heft-plugins/heft-zod-schema-plugin/LICENSE b/heft-plugins/heft-zod-schema-plugin/LICENSE index 4c95bdfe909..5958435f59f 100644 --- a/heft-plugins/heft-zod-schema-plugin/LICENSE +++ b/heft-plugins/heft-zod-schema-plugin/LICENSE @@ -1,4 +1,4 @@ -@rushstack/heft-json-schema-typings-plugin +@rushstack/heft-zod-schema-plugin Copyright (c) Microsoft Corporation. All rights reserved. From 1003aaebce3dc57cc3c309a8c96dce4a0af16eff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 03:49:19 +0000 Subject: [PATCH 4/9] (chore) rush update for new @rushstack/rush-schemas package Agent-Logs-Url: https://github.com/microsoft/rushstack/sessions/b7297c08-8212-4a0e-b5fa-f4d4ff39a8db Co-authored-by: iclanton <5010588+iclanton@users.noreply.github.com> --- .../rush-lib/zod-schema-pilot_2026-04-19.json | 2 +- .../rush-schemas-init_2026-04-19.json | 10 + .../rush/browser-approved-packages.json | 4 + .../build-tests-subspace/pnpm-lock.yaml | 39 ++- .../build-tests-subspace/repo-state.json | 4 +- .../config/subspaces/default/pnpm-lock.yaml | 28 +- common/reviews/api/rush-lib.api.md | 24 +- libraries/rush-lib/config/heft.json | 17 -- libraries/rush-lib/package.json | 5 +- .../src/api/ExperimentsConfiguration.ts | 140 +--------- libraries/rush-schemas/CHANGELOG.json | 4 + libraries/rush-schemas/CHANGELOG.md | 3 + libraries/rush-schemas/LICENSE | 24 ++ libraries/rush-schemas/README.md | 82 ++++++ libraries/rush-schemas/config/heft.json | 30 ++ libraries/rush-schemas/config/rig.json | 7 + libraries/rush-schemas/eslint.config.js | 20 ++ libraries/rush-schemas/package.json | 51 ++++ libraries/rush-schemas/src/build-cache.zod.ts | 264 ++++++++++++++++++ libraries/rush-schemas/src/cobuild.zod.ts | 54 ++++ .../src}/experiments.zod.ts | 173 ++++++++++-- libraries/rush-schemas/src/index.ts | 30 ++ libraries/rush-schemas/src/repo-state.zod.ts | 72 +++++ libraries/rush-schemas/tsconfig.json | 7 + rush.json | 6 + 25 files changed, 887 insertions(+), 213 deletions(-) create mode 100644 common/changes/@rushstack/rush-schemas/rush-schemas-init_2026-04-19.json create mode 100644 libraries/rush-schemas/CHANGELOG.json create mode 100644 libraries/rush-schemas/CHANGELOG.md create mode 100644 libraries/rush-schemas/LICENSE create mode 100644 libraries/rush-schemas/README.md create mode 100644 libraries/rush-schemas/config/heft.json create mode 100644 libraries/rush-schemas/config/rig.json create mode 100644 libraries/rush-schemas/eslint.config.js create mode 100644 libraries/rush-schemas/package.json create mode 100644 libraries/rush-schemas/src/build-cache.zod.ts create mode 100644 libraries/rush-schemas/src/cobuild.zod.ts rename libraries/{rush-lib/src/schemas => rush-schemas/src}/experiments.zod.ts (54%) create mode 100644 libraries/rush-schemas/src/index.ts create mode 100644 libraries/rush-schemas/src/repo-state.zod.ts create mode 100644 libraries/rush-schemas/tsconfig.json 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 index 18c9de63aef..f34bb8ff021 100644 --- 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 @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@microsoft/rush-lib", - "comment": "Pilot for @rushstack/heft-zod-schema-plugin: introduce experiments.zod.ts as a parallel zod-based source of truth for experiments.json. The hand-authored IExperimentsJson interface and experiments.schema.json remain unchanged; a compile-time assertion now guarantees the zod schema stays in sync with the interface.", + "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" } ], 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/browser-approved-packages.json b/common/config/rush/browser-approved-packages.json index 583ff9264e7..f6b09c0e4bd 100644 --- a/common/config/rush/browser-approved-packages.json +++ b/common/config/rush/browser-approved-packages.json @@ -46,6 +46,10 @@ "name": "@rushstack/problem-matcher", "allowedCategories": [ "libraries" ] }, + { + "name": "@rushstack/rush-schemas", + "allowedCategories": [ "libraries" ] + }, { "name": "@rushstack/rush-themed-ui", "allowedCategories": [ "libraries" ] diff --git a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml index 5f8af2df6c7..1e84bce785a 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) @@ -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) @@ -947,6 +947,12 @@ packages: peerDependencies: '@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} engines: {node: '>=10.13.0'} @@ -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,6 +4953,14 @@ 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-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(@types/node@20.17.19)': dependencies: '@pnpm/lockfile.types-900': '@pnpm/lockfile.types@900.0.0' @@ -8332,3 +8363,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 982e319db26..b5b2f7aa183 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": "4497f5b169c39c6d45b2f351e4484d4879102c71", + "pnpmShrinkwrapHash": "0d3fa0f98a02504bf1f36a2cea15fadf76e1f0b9", "preferredVersionsHash": "550b4cee0bef4e97db6c6aad726df5149d20e7d9", - "packageJsonInjectedDependenciesHash": "e7087a8987565f709ad2f841d39cd7078a57d8ad" + "packageJsonInjectedDependenciesHash": "c4fba4181349178d4b8b8aa1fee6cfe76e3639c0" } diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 9193f2eeee2..b04d8da0cb4 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -4113,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 @@ -4189,9 +4192,6 @@ importers: '@rushstack/heft-webpack5-plugin': specifier: workspace:* version: link:../../heft-plugins/heft-webpack5-plugin - '@rushstack/heft-zod-schema-plugin': - specifier: workspace:* - version: link:../../heft-plugins/heft-zod-schema-plugin '@rushstack/operation-graph': specifier: workspace:* version: link:../operation-graph @@ -4234,9 +4234,6 @@ importers: webpack: specifier: ~5.105.2 version: 5.105.4 - zod: - specifier: ~4.3.6 - version: 4.3.6 ../../../libraries/rush-pnpm-kit-v10: dependencies: @@ -4295,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': diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 706fca89ba0..583ce8d4afe 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -17,6 +17,7 @@ import { CredentialCache } from '@rushstack/credential-cache'; import { HookMap } from 'tapable'; import { ICredentialCacheEntry } from '@rushstack/credential-cache'; import { ICredentialCacheOptions } from '@rushstack/credential-cache'; +import type { IExperimentsJson } from '@rushstack/rush-schemas/lib/experiments.zod'; import { IFileDiffStatus } from '@rushstack/package-deps-hash'; import { IPackageJson } from '@rushstack/node-core-library'; import { IPrefixMatch } from '@rushstack/lookup-by-path'; @@ -466,28 +467,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/libraries/rush-lib/config/heft.json b/libraries/rush-lib/config/heft.json index 757daf9ff3d..d361c6eb55b 100644 --- a/libraries/rush-lib/config/heft.json +++ b/libraries/rush-lib/config/heft.json @@ -97,23 +97,6 @@ ] } } - }, - - // PILOT (heft-zod-schema-plugin): generate the experiments.json - // JSON Schema from the colocated experiments.zod.ts source file. - // Writes to temp/zod-schemas to avoid colliding with the legacy - // hand-authored schema in src/schemas/. The generated file is for - // review during the pilot; the runtime still uses the legacy schema. - "zod-schemas": { - "taskDependencies": ["typescript"], - "taskPlugin": { - "pluginPackage": "@rushstack/heft-zod-schema-plugin", - "pluginName": "zod-schema-plugin", - "options": { - "inputGlobs": ["lib-intermediate-commonjs/schemas/*.zod.js"], - "outputFolder": "temp/zod-schemas" - } - } } } } diff --git a/libraries/rush-lib/package.json b/libraries/rush-lib/package.json index 1abd39f939b..3accd3999b5 100644 --- a/libraries/rush-lib/package.json +++ b/libraries/rush-lib/package.json @@ -74,6 +74,7 @@ "pnpm-sync-lib": "0.3.3", "read-package-tree": "~5.1.5", "rxjs": "~6.6.7", + "@rushstack/rush-schemas": "workspace:*", "semver": "~7.7.4", "ssri": "~8.0.0", "strict-uri-encode": "~2.0.0", @@ -84,7 +85,6 @@ "devDependencies": { "@pnpm/lockfile.types-900": "npm:@pnpm/lockfile.types@~900.0.0", "@rushstack/heft-webpack5-plugin": "workspace:*", - "@rushstack/heft-zod-schema-plugin": "workspace:*", "@rushstack/heft": "workspace:*", "@rushstack/operation-graph": "workspace:*", "@rushstack/webpack-deep-imports-plugin": "workspace:*", @@ -99,8 +99,7 @@ "@types/webpack-env": "1.18.8", "eslint": "~9.37.0", "local-node-rig": "workspace:*", - "webpack": "~5.105.2", - "zod": "~4.3.6" + "webpack": "~5.105.2" }, "publishOnlyDependencies": { "@rushstack/rush-amazon-s3-build-cache-plugin": "workspace:*", diff --git a/libraries/rush-lib/src/api/ExperimentsConfiguration.ts b/libraries/rush-lib/src/api/ExperimentsConfiguration.ts index 3d2c9416dc6..5cc9c0f8b19 100644 --- a/libraries/rush-lib/src/api/ExperimentsConfiguration.ts +++ b/libraries/rush-lib/src/api/ExperimentsConfiguration.ts @@ -2,149 +2,15 @@ // See LICENSE in the project root for license information. import { JsonFile, JsonSchema, FileSystem } from '@rushstack/node-core-library'; +import type { IExperimentsJson } from '@rushstack/rush-schemas/lib/experiments.zod'; import { Colorize } from '@rushstack/terminal'; import schemaJson from '../schemas/experiments.schema.json'; -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; +export type { IExperimentsJson }; - /** - * 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; +const GRADUATED_EXPERIMENTS: Set = new Set(['phasedCommands']); - /** - * 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); diff --git a/libraries/rush-schemas/CHANGELOG.json b/libraries/rush-schemas/CHANGELOG.json new file mode 100644 index 00000000000..1d23905ccf8 --- /dev/null +++ b/libraries/rush-schemas/CHANGELOG.json @@ -0,0 +1,4 @@ +{ + "name": "@rushstack/rush-schemas", + "entries": [] +} diff --git a/libraries/rush-schemas/CHANGELOG.md b/libraries/rush-schemas/CHANGELOG.md new file mode 100644 index 00000000000..270832760dc --- /dev/null +++ b/libraries/rush-schemas/CHANGELOG.md @@ -0,0 +1,3 @@ +# Change Log - @rushstack/rush-schemas + +This log was last generated on Sat, 19 Apr 2026 00:00:00 GMT and should not be manually modified. 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..d73dce52829 --- /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 + +Two reasonable ways to derive the TypeScript types from a zod schema are in use +in this package, picked per-schema based on what the type is for: + +| Strategy | When to pick | +| ----------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `export type X = z.infer` | The interface is internal, or its TSDoc fidelity is not part of the published API surface. | +| Hand-authored `interface X` + drift check | The interface is part of the public Rush API surface and per-property TSDoc must be preserved. | + +The drift-check pattern (compile-time bidirectional `extends` assertion against +the hand-authored interface) keeps the schema and the interface from diverging +without forcing all callers to materialize `import { z } from 'zod'` in their +emitted `.d.ts` files. See `experiments.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..f0ee1b67d96 --- /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 shold 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..5111f213ace --- /dev/null +++ b/libraries/rush-schemas/src/cobuild.zod.ts @@ -0,0 +1,54 @@ +// 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. + * + * @remarks + * For tiny `@beta` interfaces like this, the `z.infer` form is the source of + * truth and the published `.d.ts` will reference zod's type computation. For + * marquee public interfaces such as {@link IExperimentsJson}, prefer the + * hand-authored interface + drift-check pattern instead so that per-property + * TSDoc is preserved. + * + * @beta + */ +export type ICobuildJson = Omit, '$schema'>; + +export default cobuildSchema; diff --git a/libraries/rush-lib/src/schemas/experiments.zod.ts b/libraries/rush-schemas/src/experiments.zod.ts similarity index 54% rename from libraries/rush-lib/src/schemas/experiments.zod.ts rename to libraries/rush-schemas/src/experiments.zod.ts index 5d23f2b7ccd..cab31ceb3df 100644 --- a/libraries/rush-lib/src/schemas/experiments.zod.ts +++ b/libraries/rush-schemas/src/experiments.zod.ts @@ -1,35 +1,165 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -// PILOT: zod-based source-of-truth for experiments.json. -// -// This module is the long-term replacement for the hand-authored -// `experiments.schema.json` that lives next to it. The companion legacy schema -// is intentionally kept in place during the pilot so reviewers can diff the -// generated output against it. See the parent PR description for context. -// -// To preserve the existing rush-lib public API surface during the pilot, the -// `IExperimentsJson` interface in `ExperimentsConfiguration.ts` is left -// unchanged. The compile-time assertion at the bottom of this file guarantees -// that the zod schema stays structurally equivalent to that interface; if they -// ever drift, the build fails. -// -// At build time, `@rushstack/heft-zod-schema-plugin` reads the compiled form -// of this module and emits a generated `experiments.schema.json` for review. - import { z } from 'zod'; import { withSchemaMeta } from '@rushstack/heft-zod-schema-plugin/lib/SchemaMetaHelpers'; -import type { IExperimentsJson } from '../api/ExperimentsConfiguration'; +/** + * This interface represents the raw experiments.json file which allows repo + * maintainers to enable and disable experimental Rush features. + * + * @remarks + * This interface is the hand-authored "source of truth" for the public Rush API + * surface. The compile-time assertion at the bottom of this file verifies that + * `experimentsSchema` stays structurally equivalent to it, so the schema and + * the type cannot drift. + * + * @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 booleanFlag = (description: string): z.ZodOptional => z.boolean().describe(description).optional(); /** - * The zod schema describing the structure of `experiments.json`. + * The zod schema describing the structure of `experiments.json`. Use this to + * validate raw config input. The corresponding TypeScript shape is + * {@link IExperimentsJson}; the two are kept in sync by a compile-time + * assertion at the bottom of the source module. * - * @internal + * @beta */ // eslint-disable-next-line @typescript-eslint/typedef export const experimentsSchema = withSchemaMeta( @@ -126,9 +256,8 @@ type _Simplify = T extends infer U ? { [K in keyof U]: U[K] } : never; /** * Compile-time assertion that the zod schema is structurally equivalent to the - * hand-authored `IExperimentsJson` interface in `ExperimentsConfiguration.ts`. - * If the two ever drift (for example, a new experiment is added in only one - * place), this will fail the build. + * hand-authored {@link IExperimentsJson} interface above. If the two ever drift + * (for example, a new experiment is added in only one place), this fails the build. * * @internal */ diff --git a/libraries/rush-schemas/src/index.ts b/libraries/rush-schemas/src/index.ts new file mode 100644 index 00000000000..20b4b9ed648 --- /dev/null +++ b/libraries/rush-schemas/src/index.ts @@ -0,0 +1,30 @@ +// 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, + type IRepoStateJson, + 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..51ec505d905 --- /dev/null +++ b/libraries/rush-schemas/src/repo-state.zod.ts @@ -0,0 +1,72 @@ +// 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' + } +); + +/** + * Raw shape of `repo-state.json`. This file is internal state managed by Rush + * and is not part of the public API surface, so the `z.infer` form is used + * directly as the source of truth. + * + * @internal + */ +export type IRepoStateJson = Omit, '$schema'>; + +export default repoStateSchema; diff --git a/libraries/rush-schemas/tsconfig.json b/libraries/rush-schemas/tsconfig.json new file mode 100644 index 00000000000..9a79fa4af11 --- /dev/null +++ b/libraries/rush-schemas/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json", + + "compilerOptions": { + "target": "ES2019" + } +} diff --git a/rush.json b/rush.json index cacfe3ee19f..55e0c77335e 100644 --- a/rush.json +++ b/rush.json @@ -1308,6 +1308,12 @@ "reviewCategory": "libraries", "versionPolicyName": "rush" }, + { + "packageName": "@rushstack/rush-schemas", + "projectFolder": "libraries/rush-schemas", + "reviewCategory": "libraries", + "shouldPublish": true + }, { "packageName": "@rushstack/rush-sdk", "projectFolder": "libraries/rush-sdk", From 01833066a01053dc89079bbc6f0bb1aa44cdbcf8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 03:54:30 +0000 Subject: [PATCH 5/9] =?UTF-8?q?address=20code-review=20feedback:=20harmoni?= =?UTF-8?q?ze=20schema=20descriptions=20with=20TSDoc;=20fix=20shold?= =?UTF-8?q?=E2=86=92should=20typo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/microsoft/rushstack/sessions/b7297c08-8212-4a0e-b5fa-f4d4ff39a8db Co-authored-by: iclanton <5010588+iclanton@users.noreply.github.com> --- libraries/rush-schemas/src/build-cache.zod.ts | 2 +- libraries/rush-schemas/src/experiments.zod.ts | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/libraries/rush-schemas/src/build-cache.zod.ts b/libraries/rush-schemas/src/build-cache.zod.ts index f0ee1b67d96..ee0e011b122 100644 --- a/libraries/rush-schemas/src/build-cache.zod.ts +++ b/libraries/rush-schemas/src/build-cache.zod.ts @@ -126,7 +126,7 @@ const amazonS3Configuration: z.ZodObject<{ .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 shold not include any path, use the s3Prefix to set the path.' + 'This should not include any path, use the s3Prefix to set the path.' ) .optional(), s3Region: z diff --git a/libraries/rush-schemas/src/experiments.zod.ts b/libraries/rush-schemas/src/experiments.zod.ts index cab31ceb3df..db4464081c2 100644 --- a/libraries/rush-schemas/src/experiments.zod.ts +++ b/libraries/rush-schemas/src/experiments.zod.ts @@ -175,19 +175,20 @@ export const experimentsSchema = withSchemaMeta( usePnpmFrozenLockfileForRushInstall: booleanFlag( "By default, 'rush install' passes --no-prefer-frozen-lockfile to 'pnpm install'. " + - "Set this option to true to pass '--frozen-lockfile' instead." + "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." + "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, 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.' + "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.' @@ -220,7 +221,9 @@ export const experimentsSchema = withSchemaMeta( '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( - 'When using cobuilds, this experiment allows uncacheable operations to benefit from cobuild orchestration without using the build cache.' + '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." From 60d69b00e0e71935a17b8655d5b3c420ad123cbb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:55:46 +0000 Subject: [PATCH 6/9] address review feedback: cleanup, alphabetical deps, drop unused IRepoStateJson, package entrypoint imports Agent-Logs-Url: https://github.com/microsoft/rushstack/sessions/83bb9e28-6062-4265-851a-ce82c95398ec Co-authored-by: iclanton <5010588+iclanton@users.noreply.github.com> --- .../rush/browser-approved-packages.json | 8 ----- .../rush/nonbrowser-approved-packages.json | 8 +++++ .../build-tests-subspace/repo-state.json | 2 +- common/reviews/api/rush-lib.api.md | 2 +- .../heft-zod-schema-plugin/CHANGELOG.json | 4 --- .../heft-zod-schema-plugin/CHANGELOG.md | 3 -- .../src/ZodSchemaGenerator.ts | 23 +++++++------ .../src/test/ZodSchemaGenerator.test.ts | 32 ++++++++----------- libraries/rush-lib/package.json | 2 +- .../src/api/ExperimentsConfiguration.ts | 2 +- libraries/rush-schemas/CHANGELOG.json | 4 --- libraries/rush-schemas/CHANGELOG.md | 3 -- libraries/rush-schemas/src/index.ts | 6 +--- libraries/rush-schemas/src/repo-state.zod.ts | 9 ------ libraries/rush-schemas/tsconfig.json | 6 +--- rush.json | 2 +- 16 files changed, 42 insertions(+), 74 deletions(-) delete mode 100644 heft-plugins/heft-zod-schema-plugin/CHANGELOG.json delete mode 100644 heft-plugins/heft-zod-schema-plugin/CHANGELOG.md delete mode 100644 libraries/rush-schemas/CHANGELOG.json delete mode 100644 libraries/rush-schemas/CHANGELOG.md diff --git a/common/config/rush/browser-approved-packages.json b/common/config/rush/browser-approved-packages.json index f6b09c0e4bd..1a87efdf7fd 100644 --- a/common/config/rush/browser-approved-packages.json +++ b/common/config/rush/browser-approved-packages.json @@ -38,18 +38,10 @@ "name": "@reduxjs/toolkit", "allowedCategories": [ "libraries", "vscode-extensions" ] }, - { - "name": "@rushstack/heft-zod-schema-plugin", - "allowedCategories": [ "libraries" ] - }, { "name": "@rushstack/problem-matcher", "allowedCategories": [ "libraries" ] }, - { - "name": "@rushstack/rush-schemas", - "allowedCategories": [ "libraries" ] - }, { "name": "@rushstack/rush-themed-ui", "allowedCategories": [ "libraries" ] 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/repo-state.json b/common/config/subspaces/build-tests-subspace/repo-state.json index b5b2f7aa183..2fc871c3dea 100644 --- a/common/config/subspaces/build-tests-subspace/repo-state.json +++ b/common/config/subspaces/build-tests-subspace/repo-state.json @@ -2,5 +2,5 @@ { "pnpmShrinkwrapHash": "0d3fa0f98a02504bf1f36a2cea15fadf76e1f0b9", "preferredVersionsHash": "550b4cee0bef4e97db6c6aad726df5149d20e7d9", - "packageJsonInjectedDependenciesHash": "c4fba4181349178d4b8b8aa1fee6cfe76e3639c0" + "packageJsonInjectedDependenciesHash": "cf6202674eee7a6015522eac2f64eaee1666ce39" } diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 583ce8d4afe..fea2cdbf41b 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -17,7 +17,7 @@ import { CredentialCache } from '@rushstack/credential-cache'; import { HookMap } from 'tapable'; import { ICredentialCacheEntry } from '@rushstack/credential-cache'; import { ICredentialCacheOptions } from '@rushstack/credential-cache'; -import type { IExperimentsJson } from '@rushstack/rush-schemas/lib/experiments.zod'; +import type { 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'; diff --git a/heft-plugins/heft-zod-schema-plugin/CHANGELOG.json b/heft-plugins/heft-zod-schema-plugin/CHANGELOG.json deleted file mode 100644 index 10643509716..00000000000 --- a/heft-plugins/heft-zod-schema-plugin/CHANGELOG.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "@rushstack/heft-zod-schema-plugin", - "entries": [] -} diff --git a/heft-plugins/heft-zod-schema-plugin/CHANGELOG.md b/heft-plugins/heft-zod-schema-plugin/CHANGELOG.md deleted file mode 100644 index dc4346b7f76..00000000000 --- a/heft-plugins/heft-zod-schema-plugin/CHANGELOG.md +++ /dev/null @@ -1,3 +0,0 @@ -# Change Log - @rushstack/heft-zod-schema-plugin - -This log was last generated on Sat, 19 Apr 2026 00:00:00 GMT and should not be manually modified. diff --git a/heft-plugins/heft-zod-schema-plugin/src/ZodSchemaGenerator.ts b/heft-plugins/heft-zod-schema-plugin/src/ZodSchemaGenerator.ts index 1168312550f..0b3a4d6b01e 100644 --- a/heft-plugins/heft-zod-schema-plugin/src/ZodSchemaGenerator.ts +++ b/heft-plugins/heft-zod-schema-plugin/src/ZodSchemaGenerator.ts @@ -3,7 +3,7 @@ import * as path from 'node:path'; -import { FileSystem, NewlineKind } from '@rushstack/node-core-library'; +import { FileSystem, NewlineKind, Path } from '@rushstack/node-core-library'; import type { ITerminal } from '@rushstack/terminal'; import { @@ -20,20 +20,20 @@ import { */ export interface IZodSchemaGeneratorOptions { /** - * The project root folder. All `inputGlobs` and `outputFolder` paths are resolved - * relative to this folder. + * The project root folder. Relative `inputGlobs` and `outputFolder` paths are resolved + * relative to this folder; absolute paths are accepted as-is. */ buildFolderPath: string; /** - * Globs (relative to `buildFolderPath`) identifying the compiled JavaScript modules - * that export zod schemas. + * Globs identifying the compiled JavaScript modules that export zod schemas. + * May be relative to `buildFolderPath` or absolute. */ inputGlobs: string[]; /** - * Folder (relative to `buildFolderPath`) where the generated `*.schema.json` files - * will be written. + * Folder where the generated `*.schema.json` files will be written. May be + * relative to `buildFolderPath` or absolute. */ outputFolder: string; @@ -108,7 +108,12 @@ export class ZodSchemaGenerator { // 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'); - const matches: string[] = await glob(this._options.inputGlobs, { + // 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 @@ -170,7 +175,7 @@ export class ZodSchemaGenerator { exportName === 'default' ? `${baseName}${SCHEMA_FILE_EXTENSION}` : `${baseName}.${exportName}${SCHEMA_FILE_EXTENSION}`; - const outputFilePath: string = path.join( + const outputFilePath: string = path.resolve( this._options.buildFolderPath, this._options.outputFolder, outputFileName 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 index 48965294e4f..844430dc33d 100644 --- a/heft-plugins/heft-zod-schema-plugin/src/test/ZodSchemaGenerator.test.ts +++ b/heft-plugins/heft-zod-schema-plugin/src/test/ZodSchemaGenerator.test.ts @@ -1,15 +1,13 @@ // 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, PackageJsonLookup } from '@rushstack/node-core-library'; import { ZodSchemaGenerator, type IGeneratedSchema } from '../ZodSchemaGenerator'; const projectFolder: string = PackageJsonLookup.instance.tryGetPackageFolderFor(__dirname)!; -const compiledFixturesFolder: string = path.join(__dirname, 'fixtures'); -const outputFolder: string = path.join(projectFolder, 'temp/test-zod-schema-output'); +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); @@ -24,8 +22,8 @@ describe(ZodSchemaGenerator.name, () => { it('emits a JSON schema for a basic zod default export', async () => { const generator: ZodSchemaGenerator = new ZodSchemaGenerator({ buildFolderPath: projectFolder, - inputGlobs: [path.relative(projectFolder, path.join(compiledFixturesFolder, 'basic.zod.js'))], - outputFolder: path.relative(projectFolder, outputFolder), + inputGlobs: [`${compiledFixturesFolder}/basic.zod.js`], + outputFolder, exportName: 'default', indent: 2 }); @@ -41,10 +39,8 @@ describe(ZodSchemaGenerator.name, () => { it('applies withSchemaMeta() metadata, including the TSDoc release tag', async () => { const generator: ZodSchemaGenerator = new ZodSchemaGenerator({ buildFolderPath: projectFolder, - inputGlobs: [ - path.relative(projectFolder, path.join(compiledFixturesFolder, 'with-tsdoc-tag.zod.js')) - ], - outputFolder: path.relative(projectFolder, outputFolder), + inputGlobs: [`${compiledFixturesFolder}/with-tsdoc-tag.zod.js`], + outputFolder, exportName: 'default', indent: 2 }); @@ -64,25 +60,23 @@ describe(ZodSchemaGenerator.name, () => { it('emits one schema file per named ZodType export when exportName is "*"', async () => { const generator: ZodSchemaGenerator = new ZodSchemaGenerator({ buildFolderPath: projectFolder, - inputGlobs: [ - path.relative(projectFolder, path.join(compiledFixturesFolder, 'named-exports.zod.js')) - ], - outputFolder: path.relative(projectFolder, outputFolder), + 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) => path.basename(r.outputFilePath)).sort(); + 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: [path.relative(projectFolder, path.join(compiledFixturesFolder, 'basic.zod.js'))], - outputFolder: path.relative(projectFolder, outputFolder), + inputGlobs: [`${compiledFixturesFolder}/basic.zod.js`], + outputFolder, exportName: 'default', indent: 2 }); @@ -98,8 +92,8 @@ describe(ZodSchemaGenerator.name, () => { it('throws a clear error when the requested export is not a zod schema', async () => { const generator: ZodSchemaGenerator = new ZodSchemaGenerator({ buildFolderPath: projectFolder, - inputGlobs: [path.relative(projectFolder, path.join(compiledFixturesFolder, 'basic.zod.js'))], - outputFolder: path.relative(projectFolder, outputFolder), + inputGlobs: [`${compiledFixturesFolder}/basic.zod.js`], + outputFolder, exportName: 'doesNotExist', indent: 2 }); diff --git a/libraries/rush-lib/package.json b/libraries/rush-lib/package.json index 3accd3999b5..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:*", @@ -74,7 +75,6 @@ "pnpm-sync-lib": "0.3.3", "read-package-tree": "~5.1.5", "rxjs": "~6.6.7", - "@rushstack/rush-schemas": "workspace:*", "semver": "~7.7.4", "ssri": "~8.0.0", "strict-uri-encode": "~2.0.0", diff --git a/libraries/rush-lib/src/api/ExperimentsConfiguration.ts b/libraries/rush-lib/src/api/ExperimentsConfiguration.ts index 5cc9c0f8b19..31bf27576ec 100644 --- a/libraries/rush-lib/src/api/ExperimentsConfiguration.ts +++ b/libraries/rush-lib/src/api/ExperimentsConfiguration.ts @@ -2,7 +2,7 @@ // See LICENSE in the project root for license information. import { JsonFile, JsonSchema, FileSystem } from '@rushstack/node-core-library'; -import type { IExperimentsJson } from '@rushstack/rush-schemas/lib/experiments.zod'; +import type { IExperimentsJson } from '@rushstack/rush-schemas'; import { Colorize } from '@rushstack/terminal'; import schemaJson from '../schemas/experiments.schema.json'; diff --git a/libraries/rush-schemas/CHANGELOG.json b/libraries/rush-schemas/CHANGELOG.json deleted file mode 100644 index 1d23905ccf8..00000000000 --- a/libraries/rush-schemas/CHANGELOG.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "@rushstack/rush-schemas", - "entries": [] -} diff --git a/libraries/rush-schemas/CHANGELOG.md b/libraries/rush-schemas/CHANGELOG.md deleted file mode 100644 index 270832760dc..00000000000 --- a/libraries/rush-schemas/CHANGELOG.md +++ /dev/null @@ -1,3 +0,0 @@ -# Change Log - @rushstack/rush-schemas - -This log was last generated on Sat, 19 Apr 2026 00:00:00 GMT and should not be manually modified. diff --git a/libraries/rush-schemas/src/index.ts b/libraries/rush-schemas/src/index.ts index 20b4b9ed648..347b5f26c75 100644 --- a/libraries/rush-schemas/src/index.ts +++ b/libraries/rush-schemas/src/index.ts @@ -21,10 +21,6 @@ export { export { cobuildSchema, type ICobuildJson, default as defaultCobuildSchema } from './cobuild.zod'; -export { - repoStateSchema, - type IRepoStateJson, - default as defaultRepoStateSchema -} from './repo-state.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 index 51ec505d905..7157412b326 100644 --- a/libraries/rush-schemas/src/repo-state.zod.ts +++ b/libraries/rush-schemas/src/repo-state.zod.ts @@ -60,13 +60,4 @@ export const repoStateSchema = withSchemaMeta( } ); -/** - * Raw shape of `repo-state.json`. This file is internal state managed by Rush - * and is not part of the public API surface, so the `z.infer` form is used - * directly as the source of truth. - * - * @internal - */ -export type IRepoStateJson = Omit, '$schema'>; - export default repoStateSchema; diff --git a/libraries/rush-schemas/tsconfig.json b/libraries/rush-schemas/tsconfig.json index 9a79fa4af11..dac21d04081 100644 --- a/libraries/rush-schemas/tsconfig.json +++ b/libraries/rush-schemas/tsconfig.json @@ -1,7 +1,3 @@ { - "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json", - - "compilerOptions": { - "target": "ES2019" - } + "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json" } diff --git a/rush.json b/rush.json index 55e0c77335e..daef1f4c83e 100644 --- a/rush.json +++ b/rush.json @@ -1312,7 +1312,7 @@ "packageName": "@rushstack/rush-schemas", "projectFolder": "libraries/rush-schemas", "reviewCategory": "libraries", - "shouldPublish": true + "versionPolicyName": "rush" }, { "packageName": "@rushstack/rush-sdk", From 08e3937341740894af929c99f08685f043f5c777 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:58:43 +0000 Subject: [PATCH 7/9] add @rushstack/rush-schemas as a rush-sdk dependency so the IExperimentsJson re-export resolves Agent-Logs-Url: https://github.com/microsoft/rushstack/sessions/83bb9e28-6062-4265-851a-ce82c95398ec Co-authored-by: iclanton <5010588+iclanton@users.noreply.github.com> --- common/config/subspaces/build-tests-subspace/pnpm-lock.yaml | 6 ++++-- .../config/subspaces/build-tests-subspace/repo-state.json | 4 ++-- common/config/subspaces/default/pnpm-lock.yaml | 3 +++ libraries/rush-sdk/package.json | 1 + 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml index 1e84bce785a..47d5eace8fc 100644 --- a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml +++ b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml @@ -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 @@ -4961,16 +4961,18 @@ snapshots: - '@rushstack/heft' - '@types/node' - '@rushstack/rush-sdk@file:../../../libraries/rush-sdk(@types/node@20.17.19)': + '@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)': diff --git a/common/config/subspaces/build-tests-subspace/repo-state.json b/common/config/subspaces/build-tests-subspace/repo-state.json index 2fc871c3dea..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": "0d3fa0f98a02504bf1f36a2cea15fadf76e1f0b9", + "pnpmShrinkwrapHash": "560bb998f1afda8ac0aa2d2087111c88f23bc845", "preferredVersionsHash": "550b4cee0bef4e97db6c6aad726df5149d20e7d9", - "packageJsonInjectedDependenciesHash": "cf6202674eee7a6015522eac2f64eaee1666ce39" + "packageJsonInjectedDependenciesHash": "3f7b5f1960e61c2a3d039187d38b51c9211f6b17" } diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index b04d8da0cb4..3531164fec6 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -4328,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 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" }, From 88fc2b6f9b1ef1a562117003fe701e9e9d95a73f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 02:39:55 +0000 Subject: [PATCH 8/9] feat(rush-lib): validate experiments/cobuild/repo-state via zod schemas; add JsonFile.loadAndParse to node-core-library Agent-Logs-Url: https://github.com/microsoft/rushstack/sessions/8313d468-a395-4e21-9308-46ee2c100ee0 Co-authored-by: iclanton <5010588+iclanton@users.noreply.github.com> --- common/reviews/api/node-core-library.api.md | 7 ++ common/reviews/api/rush-lib.api.md | 11 +-- libraries/node-core-library/src/JsonFile.ts | 58 +++++++++++ libraries/node-core-library/src/index.ts | 1 + .../rush-lib/src/api/CobuildConfiguration.ts | 16 +--- .../src/api/ExperimentsConfiguration.ts | 11 +-- libraries/rush-lib/src/logic/RepoStateFile.ts | 8 +- .../rush-lib/src/schemas/cobuild.schema.json | 35 ------- .../src/schemas/experiments.schema.json | 95 ------------------- .../src/schemas/repo-state.schema.json | 30 ------ 10 files changed, 79 insertions(+), 193 deletions(-) delete mode 100644 libraries/rush-lib/src/schemas/cobuild.schema.json delete mode 100644 libraries/rush-lib/src/schemas/experiments.schema.json delete mode 100644 libraries/rush-lib/src/schemas/repo-state.schema.json 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 fea2cdbf41b..dced889f74a 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -15,9 +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 type { IExperimentsJson } from '@rushstack/rush-schemas'; +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'; @@ -376,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 { 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/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 31bf27576ec..3f6670ded69 100644 --- a/libraries/rush-lib/src/api/ExperimentsConfiguration.ts +++ b/libraries/rush-lib/src/api/ExperimentsConfiguration.ts @@ -1,19 +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 type { IExperimentsJson } from '@rushstack/rush-schemas'; +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']); - -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. @@ -31,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) as IExperimentsJson; } 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 -} From 429889d8d4ba5c5d4448604efb715555ddd3706f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:20:47 +0000 Subject: [PATCH 9/9] refactor(rush-schemas): derive IExperimentsJson from experimentsSchema via z.infer (drop hand-authored interface + drift check) Agent-Logs-Url: https://github.com/microsoft/rushstack/sessions/df3800f0-fee3-4aeb-a16a-04079be1662c Co-authored-by: iclanton <5010588+iclanton@users.noreply.github.com> --- .../src/api/ExperimentsConfiguration.ts | 2 +- libraries/rush-schemas/README.md | 24 +-- libraries/rush-schemas/src/cobuild.zod.ts | 7 - libraries/rush-schemas/src/experiments.zod.ts | 169 +----------------- 4 files changed, 21 insertions(+), 181 deletions(-) diff --git a/libraries/rush-lib/src/api/ExperimentsConfiguration.ts b/libraries/rush-lib/src/api/ExperimentsConfiguration.ts index 3f6670ded69..9f4c46ed3d9 100644 --- a/libraries/rush-lib/src/api/ExperimentsConfiguration.ts +++ b/libraries/rush-lib/src/api/ExperimentsConfiguration.ts @@ -26,7 +26,7 @@ export class ExperimentsConfiguration { */ public constructor(jsonFilePath: string) { try { - this.configuration = JsonFile.loadAndParse(jsonFilePath, experimentsSchema) as IExperimentsJson; + this.configuration = JsonFile.loadAndParse(jsonFilePath, experimentsSchema); } catch (e) { if (FileSystem.isNotExistError(e)) { this.configuration = {}; diff --git a/libraries/rush-schemas/README.md b/libraries/rush-schemas/README.md index d73dce52829..443ded8bcd6 100644 --- a/libraries/rush-schemas/README.md +++ b/libraries/rush-schemas/README.md @@ -35,18 +35,18 @@ in one place. ## Per-schema authoring strategy -Two reasonable ways to derive the TypeScript types from a zod schema are in use -in this package, picked per-schema based on what the type is for: - -| Strategy | When to pick | -| ----------------------------------------- | ---------------------------------------------------------------------------------------------- | -| `export type X = z.infer` | The interface is internal, or its TSDoc fidelity is not part of the published API surface. | -| Hand-authored `interface X` + drift check | The interface is part of the public Rush API surface and per-property TSDoc must be preserved. | - -The drift-check pattern (compile-time bidirectional `extends` assertion against -the hand-authored interface) keeps the schema and the interface from diverging -without forcing all callers to materialize `import { z } from 'zod'` in their -emitted `.d.ts` files. See `experiments.zod.ts` for the canonical example. +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 diff --git a/libraries/rush-schemas/src/cobuild.zod.ts b/libraries/rush-schemas/src/cobuild.zod.ts index 5111f213ace..c093c39771d 100644 --- a/libraries/rush-schemas/src/cobuild.zod.ts +++ b/libraries/rush-schemas/src/cobuild.zod.ts @@ -40,13 +40,6 @@ export const cobuildSchema = withSchemaMeta( * pointer. The shape is derived from {@link cobuildSchema} via `z.infer` to * keep the schema and the type from drifting. * - * @remarks - * For tiny `@beta` interfaces like this, the `z.infer` form is the source of - * truth and the published `.d.ts` will reference zod's type computation. For - * marquee public interfaces such as {@link IExperimentsJson}, prefer the - * hand-authored interface + drift-check pattern instead so that per-property - * TSDoc is preserved. - * * @beta */ export type ICobuildJson = Omit, '$schema'>; diff --git a/libraries/rush-schemas/src/experiments.zod.ts b/libraries/rush-schemas/src/experiments.zod.ts index db4464081c2..5728c6b6160 100644 --- a/libraries/rush-schemas/src/experiments.zod.ts +++ b/libraries/rush-schemas/src/experiments.zod.ts @@ -5,159 +5,13 @@ import { z } from 'zod'; import { withSchemaMeta } from '@rushstack/heft-zod-schema-plugin/lib/SchemaMetaHelpers'; -/** - * This interface represents the raw experiments.json file which allows repo - * maintainers to enable and disable experimental Rush features. - * - * @remarks - * This interface is the hand-authored "source of truth" for the public Rush API - * surface. The compile-time assertion at the bottom of this file verifies that - * `experimentsSchema` stays structurally equivalent to it, so the schema and - * the type cannot drift. - * - * @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 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 is - * {@link IExperimentsJson}; the two are kept in sync by a compile-time - * assertion at the bottom of the source module. + * validate raw config input. The corresponding TypeScript shape + * {@link IExperimentsJson} is derived from this schema via `z.infer`. * * @beta */ @@ -258,21 +112,14 @@ export const experimentsSchema = withSchemaMeta( type _Simplify = T extends infer U ? { [K in keyof U]: U[K] } : never; /** - * Compile-time assertion that the zod schema is structurally equivalent to the - * hand-authored {@link IExperimentsJson} interface above. If the two ever drift - * (for example, a new experiment is added in only one place), this fails the build. + * 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. * - * @internal + * @beta */ -export type _ExperimentsJsonZodMatches = _Simplify> extends IExperimentsJson - ? IExperimentsJson extends _Simplify> - ? true - : { error: 'IExperimentsJson is missing properties present on z.infer' } - : { error: 'z.infer is missing properties present on IExperimentsJson' }; - -const _typeCheck: _ExperimentsJsonZodMatches = true; -// Reference the unused binding so the linter is happy. -void _typeCheck; +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`