From 0b2a2b3d908d666ac6b7180f0d8164650339d6cf Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 29 Jul 2026 11:47:47 +0000 Subject: [PATCH] test(repo): enforce architecture and compatibility gates --- package.json | 13 +- packages/api/vite.config.ts | 19 +- packages/core/package.json | 1 - packages/core/vite.config.ts | 29 +- packages/deslop-js/package.json | 1 - .../find-strongly-connected-components.ts | 80 +++ .../eslint-plugin-react-doctor/vite.config.ts | 15 +- packages/language-server/vite.config.ts | 39 +- .../scripts/generate-rule-registry.mjs | 19 - .../oxlint-plugin-react-doctor/vite.config.ts | 17 +- .../tests/node-support-metadata.test.ts | 5 +- packages/react-doctor/vite.config.ts | 59 +- pnpm-lock.yaml | 446 +++++++------ scripts/build/constants.ts | 34 + scripts/build/read-package-version.test.ts | 36 + scripts/check-published-deps.ts | 28 +- scripts/check-skills.mjs | 369 +++++++++++ scripts/check-skills.test.mjs | 126 ++++ scripts/check-source-architecture.test.ts | 209 ++++++ scripts/check-source-architecture.ts | 625 ++++++++++++++++++ scripts/compatibility/approved-deltas.json | 4 + .../compatibility/check-approved-deltas.ts | 79 +++ .../compatibility/check-public-packages.ts | 226 +++++++ scripts/compatibility/cli-help-invocations.ts | 35 + scripts/compatibility/cli-help.test.ts | 43 ++ scripts/compatibility/normalize-cli-help.ts | 20 + .../parse-help-command-aliases.ts | 13 + .../public-package-snapshot-types.ts | 27 + scripts/compatibility/snapshots/cli-help.json | 142 ++++ .../snapshots/packed-public-entry-points.json | 208 ++++++ .../snapshots/public-packages.json | 129 ++++ scripts/convert-node-imports.mjs | 131 ---- .../broken-asset/skills/broken/SKILL.md | 8 + .../broken-frontmatter/skills/broken/SKILL.md | 5 + .../broken-link/skills/broken/SKILL.md | 8 + .../skills/groups/nested-skill/SKILL.md | 18 + .../groups/nested-skill/assets/prompt.txt | 1 + .../groups/nested-skill/references/details.md | 3 + .../groups/nested-skill/scripts/check.mjs | 1 + scripts/performance/constants.ts | 1 + scripts/smoke-packed-cli-install.ts | 370 ++++++++++- scripts/sync-react-doctor-skill.mjs | 106 +++ .../utils/find-published-package-manifests.ts | 46 ++ scripts/utils/read-package-export-value.ts | 9 + scripts/utils/read-package-version.ts | 18 + 45 files changed, 3302 insertions(+), 519 deletions(-) create mode 100644 packages/deslop-js/src/utils/find-strongly-connected-components.ts create mode 100644 scripts/build/constants.ts create mode 100644 scripts/build/read-package-version.test.ts create mode 100644 scripts/check-skills.mjs create mode 100644 scripts/check-skills.test.mjs create mode 100644 scripts/check-source-architecture.test.ts create mode 100644 scripts/check-source-architecture.ts create mode 100644 scripts/compatibility/approved-deltas.json create mode 100644 scripts/compatibility/check-approved-deltas.ts create mode 100644 scripts/compatibility/check-public-packages.ts create mode 100644 scripts/compatibility/cli-help-invocations.ts create mode 100644 scripts/compatibility/cli-help.test.ts create mode 100644 scripts/compatibility/normalize-cli-help.ts create mode 100644 scripts/compatibility/parse-help-command-aliases.ts create mode 100644 scripts/compatibility/public-package-snapshot-types.ts create mode 100644 scripts/compatibility/snapshots/cli-help.json create mode 100644 scripts/compatibility/snapshots/packed-public-entry-points.json create mode 100644 scripts/compatibility/snapshots/public-packages.json delete mode 100644 scripts/convert-node-imports.mjs create mode 100644 scripts/fixtures/check-skills/broken-asset/skills/broken/SKILL.md create mode 100644 scripts/fixtures/check-skills/broken-frontmatter/skills/broken/SKILL.md create mode 100644 scripts/fixtures/check-skills/broken-link/skills/broken/SKILL.md create mode 100644 scripts/fixtures/check-skills/valid-nested/skills/groups/nested-skill/SKILL.md create mode 100644 scripts/fixtures/check-skills/valid-nested/skills/groups/nested-skill/assets/prompt.txt create mode 100644 scripts/fixtures/check-skills/valid-nested/skills/groups/nested-skill/references/details.md create mode 100644 scripts/fixtures/check-skills/valid-nested/skills/groups/nested-skill/scripts/check.mjs create mode 100644 scripts/sync-react-doctor-skill.mjs create mode 100644 scripts/utils/find-published-package-manifests.ts create mode 100644 scripts/utils/read-package-export-value.ts create mode 100644 scripts/utils/read-package-version.ts diff --git a/package.json b/package.json index dcfca4d64c..e863bc12da 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,17 @@ "lint:fix": "vp lint --fix", "format": "vp fmt", "format:check": "vp fmt --check", - "check": "vp check", + "check": "pnpm run skills:check && tsx scripts/check-source-architecture.ts && node --experimental-strip-types --no-warnings scripts/compatibility/check-public-packages.ts && node --experimental-strip-types --no-warnings scripts/compatibility/check-approved-deltas.ts && node --experimental-strip-types --no-warnings --test scripts/compatibility/cli-help.test.ts && tsx --test scripts/build/read-package-version.test.ts && vp check", + "architecture:check": "tsx scripts/check-source-architecture.ts", + "test:architecture": "tsx --test scripts/check-source-architecture.test.ts", + "compatibility:check": "node --experimental-strip-types --no-warnings scripts/compatibility/check-public-packages.ts && node --experimental-strip-types --no-warnings scripts/compatibility/check-approved-deltas.ts", + "compatibility:update": "node --experimental-strip-types --no-warnings scripts/compatibility/check-public-packages.ts --update", + "compatibility:packed:update": "node --experimental-strip-types --no-warnings scripts/smoke-packed-cli-install.ts --update-snapshots", + "test:compatibility": "node --experimental-strip-types --no-warnings --test scripts/compatibility/cli-help.test.ts", + "test:build-policy": "tsx --test scripts/build/read-package-version.test.ts", + "test:skills": "node --test scripts/check-skills.test.mjs .agents/skills/run-parity/scripts/compare-parity.test.mjs .agents/skills/run-parity/scripts/validate-parity-input.test.mjs", + "skills:sync": "node scripts/sync-react-doctor-skill.mjs", + "skills:check": "node scripts/sync-react-doctor-skill.mjs --check && node scripts/check-skills.mjs && pnpm run test:skills", "changeset": "changeset", "version": "changeset version", "release": "pnpm build && pnpm check:published-deps && node scripts/sentry-sourcemaps.mjs && changeset publish", @@ -46,6 +56,7 @@ "@voidzero-dev/vite-plus-core": "^0.1.15", "commander": "^14.0.3", "cross-env": "^10.1.0", + "oxc-parser": "^0.141.0", "simple-statistics": "^7.9.3", "tsx": "^4.22.4", "turbo": "^2.9.7", diff --git a/packages/api/vite.config.ts b/packages/api/vite.config.ts index 85e2007f47..317a96d850 100644 --- a/packages/api/vite.config.ts +++ b/packages/api/vite.config.ts @@ -1,27 +1,24 @@ import { defineConfig } from "vite-plus"; +import { + DEFAULT_TEST_TIMEOUT_MS, + ENGINE_RUNTIME_EXTERNALS, + NODE_PACK_TARGET, +} from "../../scripts/build/constants.js"; export default defineConfig({ pack: [ { entry: { index: "./src/index.ts" }, deps: { - neverBundle: [ - "deslop-js", - "effect", - "oxc-parser", - "oxc-resolver", - "oxlint", - "oxlint-plugin-react-doctor", - "typescript", - ], + neverBundle: ENGINE_RUNTIME_EXTERNALS, }, dts: true, - target: "node20", + target: NODE_PACK_TARGET, platform: "node", fixedExtension: false, }, ], test: { - testTimeout: 30_000, + testTimeout: DEFAULT_TEST_TIMEOUT_MS, }, }); diff --git a/packages/core/package.json b/packages/core/package.json index f910d33100..bec2b3e45a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -37,7 +37,6 @@ "typescript": ">=5.0.4 <7" }, "devDependencies": { - "@effect/vitest": "4.0.0-beta.70", "@types/node": "^25.6.0", "@types/picomatch": "^4.0.3", "@types/semver": "^7.7.1" diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index f98c4ecd44..483e268d67 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -1,36 +1,29 @@ -import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { defineConfig } from "vite-plus"; +import { + DEFAULT_TEST_TIMEOUT_MS, + ENGINE_RUNTIME_EXTERNALS, + NODE_PACK_TARGET, +} from "../../scripts/build/constants.js"; +import { readPackageVersion } from "../../scripts/utils/read-package-version.js"; const packageRoot = path.dirname(fileURLToPath(import.meta.url)); - -const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")) as { - version: string; -}; +const packageVersion = readPackageVersion(import.meta.url); export default defineConfig({ pack: [ { entry: { index: "./src/index.ts", schemas: "./src/schemas.ts" }, deps: { - neverBundle: [ - "@effect/platform-node-shared", - "deslop-js", - "effect", - "oxc-parser", - "oxc-resolver", - "oxlint", - "oxlint-plugin-react-doctor", - "typescript", - ], + neverBundle: ["@effect/platform-node-shared", ...ENGINE_RUNTIME_EXTERNALS], }, dts: true, - target: "node20", + target: NODE_PACK_TARGET, platform: "node", fixedExtension: false, env: { - REACT_DOCTOR_CORE_VERSION: packageJson.version, + REACT_DOCTOR_CORE_VERSION: packageVersion, }, }, ], @@ -49,6 +42,6 @@ export default defineConfig({ replacement: path.join(packageRoot, "../oxlint-plugin-react-doctor/src/index.ts"), }, ], - testTimeout: 30_000, + testTimeout: DEFAULT_TEST_TIMEOUT_MS, }, }); diff --git a/packages/deslop-js/package.json b/packages/deslop-js/package.json index 443b81bd64..8994b4ae51 100644 --- a/packages/deslop-js/package.json +++ b/packages/deslop-js/package.json @@ -77,7 +77,6 @@ "typescript": ">=5.0.4 <6" }, "devDependencies": { - "@types/minimatch": "^5.1.2", "@types/node": "^25.6.0", "tsx": "^4.21.0" } diff --git a/packages/deslop-js/src/utils/find-strongly-connected-components.ts b/packages/deslop-js/src/utils/find-strongly-connected-components.ts new file mode 100644 index 0000000000..304ee4bbf1 --- /dev/null +++ b/packages/deslop-js/src/utils/find-strongly-connected-components.ts @@ -0,0 +1,80 @@ +interface StronglyConnectedComponentFrame { + nodeIndex: number; + successorIndex: number; +} + +export const findStronglyConnectedComponents = ( + adjacencyList: ReadonlyArray>, +): number[][] => { + const nodeIndices: Array = new Array(adjacencyList.length); + const lowLinks: number[] = new Array(adjacencyList.length).fill(0); + const nodesOnStack: boolean[] = new Array(adjacencyList.length).fill(false); + const componentStack: number[] = []; + const components: number[][] = []; + let nextNodeIndex = 0; + + for (let startNodeIndex = 0; startNodeIndex < adjacencyList.length; startNodeIndex++) { + if (nodeIndices[startNodeIndex] !== undefined) continue; + + nodeIndices[startNodeIndex] = nextNodeIndex; + lowLinks[startNodeIndex] = nextNodeIndex; + nextNodeIndex++; + nodesOnStack[startNodeIndex] = true; + componentStack.push(startNodeIndex); + + const traversalStack: StronglyConnectedComponentFrame[] = [ + { nodeIndex: startNodeIndex, successorIndex: 0 }, + ]; + + while (traversalStack.length > 0) { + const frame = traversalStack[traversalStack.length - 1]; + const successors = adjacencyList[frame.nodeIndex]; + + if (frame.successorIndex < successors.length) { + const successorNodeIndex = successors[frame.successorIndex]; + frame.successorIndex++; + const successorTraversalIndex = nodeIndices[successorNodeIndex]; + + if (successorTraversalIndex === undefined) { + nodeIndices[successorNodeIndex] = nextNodeIndex; + lowLinks[successorNodeIndex] = nextNodeIndex; + nextNodeIndex++; + nodesOnStack[successorNodeIndex] = true; + componentStack.push(successorNodeIndex); + traversalStack.push({ nodeIndex: successorNodeIndex, successorIndex: 0 }); + } else if (nodesOnStack[successorNodeIndex]) { + lowLinks[frame.nodeIndex] = Math.min(lowLinks[frame.nodeIndex], successorTraversalIndex); + } + continue; + } + + const currentNodeIndex = frame.nodeIndex; + const currentTraversalIndex = nodeIndices[currentNodeIndex]; + traversalStack.pop(); + + if (traversalStack.length > 0) { + const parentFrame = traversalStack[traversalStack.length - 1]; + lowLinks[parentFrame.nodeIndex] = Math.min( + lowLinks[parentFrame.nodeIndex], + lowLinks[currentNodeIndex], + ); + } + + if (currentTraversalIndex !== lowLinks[currentNodeIndex]) continue; + + const component: number[] = []; + let componentNodeIndex: number | undefined; + do { + componentNodeIndex = componentStack.pop(); + if (componentNodeIndex === undefined) { + throw new Error("Strongly connected component stack was unexpectedly empty."); + } + nodesOnStack[componentNodeIndex] = false; + component.push(componentNodeIndex); + } while (componentNodeIndex !== currentNodeIndex); + components.push(component); + } + } + + return components; +}; diff --git a/packages/eslint-plugin-react-doctor/vite.config.ts b/packages/eslint-plugin-react-doctor/vite.config.ts index 6437ed7872..1994541f8d 100644 --- a/packages/eslint-plugin-react-doctor/vite.config.ts +++ b/packages/eslint-plugin-react-doctor/vite.config.ts @@ -1,13 +1,8 @@ -import * as fs from "node:fs"; -import * as path from "node:path"; -import { fileURLToPath } from "node:url"; import { defineConfig } from "vite-plus"; +import { NODE_PACK_TARGET } from "../../scripts/build/constants.js"; +import { readPackageVersion } from "../../scripts/utils/read-package-version.js"; -const packageRoot = path.dirname(fileURLToPath(import.meta.url)); - -const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")) as { - version: string; -}; +const packageVersion = readPackageVersion(import.meta.url); export default defineConfig({ pack: [ @@ -15,11 +10,11 @@ export default defineConfig({ entry: { index: "./src/index.ts" }, deps: { neverBundle: ["oxlint-plugin-react-doctor"] }, dts: true, - target: "node20", + target: NODE_PACK_TARGET, platform: "node", fixedExtension: false, env: { - VERSION: process.env.VERSION ?? packageJson.version, + VERSION: process.env.VERSION ?? packageVersion, }, }, ], diff --git a/packages/language-server/vite.config.ts b/packages/language-server/vite.config.ts index 831a183e9d..15a9e17dce 100644 --- a/packages/language-server/vite.config.ts +++ b/packages/language-server/vite.config.ts @@ -1,54 +1,41 @@ -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; import { defineConfig } from "vite-plus"; +import { + DEFAULT_TEST_TIMEOUT_MS, + ENGINE_RUNTIME_EXTERNALS, + LSP_RUNTIME_EXTERNALS, + NODE_PACK_TARGET, +} from "../../scripts/build/constants.js"; +import { readPackageVersion } from "../../scripts/utils/read-package-version.js"; -const packageRoot = path.dirname(fileURLToPath(import.meta.url)); -const { version } = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")) as { - version: string; -}; +const packageVersion = readPackageVersion(import.meta.url); export default defineConfig({ pack: [ { entry: { index: "./src/index.ts" }, env: { - VERSION: process.env.VERSION ?? version, + VERSION: process.env.VERSION ?? packageVersion, }, deps: { // Keep the heavy engine + LSP transport external so the // language-server dist stays lean and runnable standalone via // its own node_modules. The react-doctor CLI re-bundles this // dist and decides which of these to inline at publish time. - neverBundle: [ - "@react-doctor/core", - "deslop-js", - "effect", - "oxc-parser", - "oxc-resolver", - "oxlint", - "oxlint-plugin-react-doctor", - "typescript", - "vscode-languageserver", - "vscode-languageserver-protocol", - "vscode-languageserver-textdocument", - "vscode-jsonrpc", - "vscode-uri", - ], + neverBundle: ["@react-doctor/core", ...ENGINE_RUNTIME_EXTERNALS, ...LSP_RUNTIME_EXTERNALS], }, dts: true, - target: "node20", + target: NODE_PACK_TARGET, platform: "node", fixedExtension: false, }, ], test: { - testTimeout: 30_000, + testTimeout: DEFAULT_TEST_TIMEOUT_MS, // The integration suite boots a real LSP server subprocess and waits up // to 20s for it to publish diagnostics inside `beforeAll`. The default // 10s hook timeout is shorter than that wait, so a slow cold start on // macOS / Windows CI runners trips the hook before the server is ready. // Match it to `testTimeout` so the hook gets the same budget as the tests. - hookTimeout: 30_000, + hookTimeout: DEFAULT_TEST_TIMEOUT_MS, }, }); diff --git a/packages/oxlint-plugin-react-doctor/scripts/generate-rule-registry.mjs b/packages/oxlint-plugin-react-doctor/scripts/generate-rule-registry.mjs index 8cd77bd230..13fe8ca769 100644 --- a/packages/oxlint-plugin-react-doctor/scripts/generate-rule-registry.mjs +++ b/packages/oxlint-plugin-react-doctor/scripts/generate-rule-registry.mjs @@ -162,24 +162,6 @@ const RULES_NOT_PORTED_FROM_EXTERNAL = new Set([ "role-button-requires-complete-keyboard-activation", ]); -// Rule ids whose source files are kept on disk but intentionally NOT -// registered. Use sparingly — the canonical way to retire a rule is to -// delete its file (and its tests, fixture references, etc.). This -// skiplist exists for rules we want to stop shipping right away while -// preserving their implementation, tests, and regression fixtures so -// re-enabling is a one-line change. Add a brief justification next to -// every entry. -const RULE_IDS_TO_SKIP_REGISTRATION = new Set([ - // The React-Compiler memoization premise didn't hold: the three - // canonical hooks it targeted (`useRouter`, `useSearchParams`, - // `useNavigation`) all return stable references, so destructuring - // their methods produces no measurable compiler win — and on Pages - // Router (`next/router`) destructuring `push` captures a stale - // reference. Implementation + regression suite + fixture lines kept - // in place; remove this entry to re-enable. - "react-compiler-destructure-method", -]); - // Fine-grained category → the clear, user-facing bucket the scan output // groups & labels by. Rules (and the buckets below) declare a detailed // category for intent; the reporter only ever shows these five outcome @@ -300,7 +282,6 @@ for (const bucket of fs.readdirSync(PLUGIN_RULES_ROOT, { withFileTypes: true })) process.exit(1); } const ruleId = idMatch[1]; - if (RULE_IDS_TO_SKIP_REGISTRATION.has(ruleId)) continue; const category = toBucket(categoryMatch ? categoryMatch[1] : defaultCategory); const severity = severityMatch[1]; // Force POSIX separators — `path.relative()` returns backslashes on diff --git a/packages/oxlint-plugin-react-doctor/vite.config.ts b/packages/oxlint-plugin-react-doctor/vite.config.ts index 7329ddfec9..642f720601 100644 --- a/packages/oxlint-plugin-react-doctor/vite.config.ts +++ b/packages/oxlint-plugin-react-doctor/vite.config.ts @@ -1,13 +1,8 @@ -import * as fs from "node:fs"; -import * as path from "node:path"; -import { fileURLToPath } from "node:url"; import { defineConfig } from "vite-plus"; +import { DEFAULT_TEST_TIMEOUT_MS, NODE_PACK_TARGET } from "../../scripts/build/constants.js"; +import { readPackageVersion } from "../../scripts/utils/read-package-version.js"; -const packageRoot = path.dirname(fileURLToPath(import.meta.url)); - -const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")) as { - version: string; -}; +const packageVersion = readPackageVersion(import.meta.url); export default defineConfig({ pack: [ @@ -29,15 +24,15 @@ export default defineConfig({ neverBundle: ["oxc-parser"], }, dts: true, - target: "node20", + target: NODE_PACK_TARGET, platform: "node", fixedExtension: false, env: { - VERSION: process.env.VERSION ?? packageJson.version, + VERSION: process.env.VERSION ?? packageVersion, }, }, ], test: { - testTimeout: 30_000, + testTimeout: DEFAULT_TEST_TIMEOUT_MS, }, }); diff --git a/packages/react-doctor/tests/node-support-metadata.test.ts b/packages/react-doctor/tests/node-support-metadata.test.ts index ee9426b664..01c0965747 100644 --- a/packages/react-doctor/tests/node-support-metadata.test.ts +++ b/packages/react-doctor/tests/node-support-metadata.test.ts @@ -1,6 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { describe, expect, it } from "vite-plus/test"; +import { NODE_PACK_TARGET } from "../../../scripts/build/constants.js"; interface PackageJson { readonly engines?: { @@ -61,6 +62,7 @@ const packageBuildConfigs = [ "packages/api/vite.config.ts", "packages/core/vite.config.ts", "packages/eslint-plugin-react-doctor/vite.config.ts", + "packages/language-server/vite.config.ts", "packages/oxlint-plugin-react-doctor/vite.config.ts", "packages/react-doctor/vite.config.ts", ]; @@ -98,9 +100,10 @@ describe("Node support metadata", () => { }); it("keeps published package builds targeting Node 20", () => { + expect(NODE_PACK_TARGET).toBe("node20"); for (const configPath of packageBuildConfigs) { const config = readText(configPath); - expect(config, configPath).toContain('target: "node20"'); + expect(config, configPath).toContain("target: NODE_PACK_TARGET"); expect(config, configPath).not.toContain('target: "node22"'); } }); diff --git a/packages/react-doctor/vite.config.ts b/packages/react-doctor/vite.config.ts index f4f017318d..9918f72358 100644 --- a/packages/react-doctor/vite.config.ts +++ b/packages/react-doctor/vite.config.ts @@ -2,14 +2,17 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { defineConfig } from "vite-plus"; +import { + DEFAULT_TEST_TIMEOUT_MS, + LSP_RUNTIME_EXTERNALS, + NATIVE_ANALYZER_EXTERNALS, + NATIVE_ANALYZER_RUNTIME_EXTERNALS, + NODE_PACK_TARGET, +} from "../../scripts/build/constants.js"; +import { readPackageVersion } from "../../scripts/utils/read-package-version.js"; const packageRoot = path.dirname(fileURLToPath(import.meta.url)); - -const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")) as { - version: string; -}; - -const TEST_TIMEOUT_MS = 30_000; +const packageVersion = readPackageVersion(import.meta.url); // HACK: agent-install's parseSkillManifest silently returns `null` when // frontmatter is missing or invalid `name:` / `description:` fields, @@ -32,8 +35,7 @@ const assertSkillManifestParseable = (manifestPath: string): void => { } }; -// Ship every skill directory under `skills/` (the `react-doctor` skill and -// its `references/` today) so `react-doctor install` can install them all. +// Ship repository-root `skills/` as the canonical distributed sources. // Each is validated at build time so a broken SKILL.md is caught here, not // at install time. const copySkillsToDist = () => { @@ -94,11 +96,7 @@ export default defineConfig({ // (the server would start and exit immediately). They're // declared as runtime dependencies so the published tarball // resolves them. - "vscode-languageserver", - "vscode-languageserver-protocol", - "vscode-languageserver-textdocument", - "vscode-jsonrpc", - "vscode-uri", + ...LSP_RUNTIME_EXTERNALS, // HACK: deslop-js wraps oxc-parser / oxc-resolver, both of // which load platform-specific NAPI bindings via require(). // Rollup happily inlines the JS loader chain but rewrites @@ -109,11 +107,7 @@ export default defineConfig({ // siblings) external so the loaders run untouched and Node // resolves the bindings from the deslop-js node_modules // tree on install — see issue #404. - "deslop-js", - "oxc-parser", - "oxc-resolver", - "oxlint", - "oxlint-plugin-react-doctor", + ...NATIVE_ANALYZER_RUNTIME_EXTERNALS, "prompts", "typescript", // The interactive Ink report (lazy-loaded for `experimental-tui`). @@ -129,7 +123,7 @@ export default defineConfig({ ], }, dts: true, - target: "node20", + target: NODE_PACK_TARGET, platform: "node", // Emit source maps so the release pipeline (scripts/sentry-sourcemaps.mjs) // can inject Sentry Debug IDs and upload them for readable, de-minified @@ -138,7 +132,7 @@ export default defineConfig({ // the Debug IDs injected into the published `dist/cli.js`. sourcemap: true, env: { - VERSION: process.env.VERSION ?? packageJson.version, + VERSION: process.env.VERSION ?? packageVersion, }, // HACK: no shebang on dist/cli.js — the published `bin` entry is // bin/react-doctor.js, which owns the `#!/usr/bin/env node` line @@ -164,17 +158,13 @@ export default defineConfig({ "confbox", "jiti", "magicast", - "deslop-js", - "oxc-parser", - "oxc-resolver", - "oxlint", - "oxlint-plugin-react-doctor", + ...NATIVE_ANALYZER_RUNTIME_EXTERNALS, "prompts", "typescript", ], }, dts: true, - target: "node20", + target: NODE_PACK_TARGET, platform: "node", fixedExtension: false, }, @@ -190,27 +180,18 @@ export default defineConfig({ // same reason as the CLI pack (it resolves its own OTel/native deps // via require() at runtime). "@sentry/node", - "deslop-js", - "oxc-parser", - "oxc-resolver", - "oxlint", - "oxlint-plugin-react-doctor", - "typescript", - "vscode-languageserver", - "vscode-languageserver-protocol", - "vscode-languageserver-textdocument", - "vscode-jsonrpc", - "vscode-uri", + ...NATIVE_ANALYZER_EXTERNALS, + ...LSP_RUNTIME_EXTERNALS, ], }, dts: false, - target: "node20", + target: NODE_PACK_TARGET, platform: "node", fixedExtension: false, }, ], test: { - testTimeout: TEST_TIMEOUT_MS, + testTimeout: DEFAULT_TEST_TIMEOUT_MS, // NOTE: do NOT pin Windows onto a single serial fork // (`singleFork` / `maxWorkers: 1` / `fileParallelism: false`). // This suite drives the real `oxlint` binary and per-test deslop diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80662c766b..38a83a0534 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: cross-env: specifier: ^10.1.0 version: 10.1.0 + oxc-parser: + specifier: ^0.141.0 + version: 0.141.0 simple-statistics: specifier: ^7.9.3 version: 7.9.3 @@ -107,9 +110,6 @@ importers: specifier: '>=5.0.4 <7' version: 6.0.3 devDependencies: - '@effect/vitest': - specifier: 4.0.0-beta.70 - version: 4.0.0-beta.70(effect@4.0.0-beta.70)(vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(vite@7.3.1(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0))) '@types/node': specifier: ^25.6.0 version: 25.6.0 @@ -154,9 +154,6 @@ importers: specifier: '>=5.0.4 <6' version: 5.9.3 devDependencies: - '@types/minimatch': - specifier: ^5.1.2 - version: 5.1.2 '@types/node': specifier: ^25.6.0 version: 25.6.0 @@ -617,12 +614,6 @@ packages: peerDependencies: effect: ^4.0.0-beta.70 - '@effect/vitest@4.0.0-beta.70': - resolution: {integrity: sha512-XDteNN0xfOgoMauAVoN5iylxVgEjp7kFsGFq18tZ5XYjek0eOZa0nOoes5s7Bs71VvwjnCeCbFMD7IhxswEt8A==} - peerDependencies: - effect: ^4.0.0-beta.70 - vitest: ^3.0.0 || ^4.0.0 - '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -1499,6 +1490,12 @@ packages: cpu: [arm] os: [android] + '@oxc-parser/binding-android-arm-eabi@0.141.0': + resolution: {integrity: sha512-jk7086MFvR/T4DG9IY7MKBVt1PMxvSZoz/TvnifodvS0pjghVwJHRttnAExhlwdMOgHv1TmLdENnbNpYk2zjvA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + '@oxc-parser/binding-android-arm-eabi@0.142.0': resolution: {integrity: sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1511,6 +1508,12 @@ packages: cpu: [arm64] os: [android] + '@oxc-parser/binding-android-arm64@0.141.0': + resolution: {integrity: sha512-a4XDQ27ZT7e7zwAlxJDTiCA7IBGWDuy2+MhFq85Of7XlBSmpkfcBFml11q0Zx6f7RMuI0B4xCtt2ytBS4yOptg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@oxc-parser/binding-android-arm64@0.142.0': resolution: {integrity: sha512-WZkvGRLNQTz8lR9zP5nLjUdlroRCopBu3g9zF1p/laE6DzT1UbQo8Rdz5MWhaJUPYg/6gp+jo7HUgsyKaN1FtQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1523,6 +1526,12 @@ packages: cpu: [arm64] os: [darwin] + '@oxc-parser/binding-darwin-arm64@0.141.0': + resolution: {integrity: sha512-m/kVk6rzYmBeHYnz+1Y5fod00AVTTxMbC71azFfm/zjx1j9XxwKtA0+VfkKuVMC8rbghb9TtfevnuWZa9OuPEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@oxc-parser/binding-darwin-arm64@0.142.0': resolution: {integrity: sha512-l4khS8LQOOVYsGRVARo1gSaCT/aBSceUVXgtovWc2+drnxVuDr082WA3OCHVdVzIz5JIrP/y9CWsSKxBDNmYGg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1535,6 +1544,12 @@ packages: cpu: [x64] os: [darwin] + '@oxc-parser/binding-darwin-x64@0.141.0': + resolution: {integrity: sha512-o0X+6KZlfucWU/v5oKRQPwdFXsXAjW8jmpo/Gpw/qyKsbKtlfkHoeH9Bjp/m13TwjewvJnCkwF0DWzgpC4HjTQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@oxc-parser/binding-darwin-x64@0.142.0': resolution: {integrity: sha512-QBsNF3nqlXmcH2B1YOPqQYmCJoy4HuIjUxGbBO/k5JAJUl68ghU2psRY2zPk+RyBaWqKP/qfL4oaFgEMCdwskA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1547,6 +1562,12 @@ packages: cpu: [x64] os: [freebsd] + '@oxc-parser/binding-freebsd-x64@0.141.0': + resolution: {integrity: sha512-W5KbTnNkTMMMylqj6dYqnsXvkmESVPodPKYLJ5zdzIPdl9fUJtolkpUeSzYEbGGYB4a4A4avl3EePnZ/wLIdJg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@oxc-parser/binding-freebsd-x64@0.142.0': resolution: {integrity: sha512-b7Q7m4Cqc6XqNhri3R+QhU+GVy646Pn+bkdhrDdWym/Fdi0ZUa+d73H9dm5H91JtbtAQ/z1d8XKMW3oOV8a4tQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1559,6 +1580,12 @@ packages: cpu: [arm] os: [linux] + '@oxc-parser/binding-linux-arm-gnueabihf@0.141.0': + resolution: {integrity: sha512-g3dtbJa8zeOGK36Sr9cQavsdi5H/ie2hVjrSjIxsNAR1qZA40ZYVXnfdfoMAlq8CmB9qFL1yhsSCUHeNmdmt8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxc-parser/binding-linux-arm-gnueabihf@0.142.0': resolution: {integrity: sha512-3riVS5IhdH3uCZj1Y9ftDQlR0dvLsIlw/edrRqk8JhgNd5K0XSs+UBtgh50N13CAlW9/TXj6sVGXaKNBocd0Yg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1571,6 +1598,12 @@ packages: cpu: [arm] os: [linux] + '@oxc-parser/binding-linux-arm-musleabihf@0.141.0': + resolution: {integrity: sha512-e6hwQqd+3lvP13G2jxvFpoA7dzHcFLN+Mq47JCVMtdNHbbyBRo756JCtbbJH6ca8inTfyqZoqBmS3vhQlzAK2w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxc-parser/binding-linux-arm-musleabihf@0.142.0': resolution: {integrity: sha512-NmXUOpgpTSkhl795TiXmWppTwmSJ92RC1qvD6e4XOF+slgmo3e6Ah+kEu+6AN8s7NAOEwqGmir58MgSQSWmBSA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1584,6 +1617,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-arm64-gnu@0.141.0': + resolution: {integrity: sha512-vXz2BLAuypA+4MLyBg94pzEo6THVnzYnCtAjXoihIIQo0t2pnp/AmW+SH1EI+4VbuJnC//KplIJ5yyaCGua4jA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-arm64-gnu@0.142.0': resolution: {integrity: sha512-gc0EXsKtXgerujmU2Bql3u1L1HsSQ2774R83idq/FoNMPVV/RY/1ErFsvnit7KoiP/sLvzQixeUo4Ut0ic0wmw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1598,6 +1638,13 @@ packages: os: [linux] libc: [musl] + '@oxc-parser/binding-linux-arm64-musl@0.141.0': + resolution: {integrity: sha512-jMkS/EztNW34HKsXIaT/SoHcmtocq/vWhwFOVduF9kduuuRIVwfwQ6uxzIO+qPKSXdd2TXt54of0BJ2zFMXnmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@oxc-parser/binding-linux-arm64-musl@0.142.0': resolution: {integrity: sha512-F2XvmWSE0uWpie+jHKKIFgdVOe9ypGhkEZxKx5DuW215K6cbAC274yYaPkcM7EqY4Df3Weyhpcz3lsURyH2LVg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1612,6 +1659,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-ppc64-gnu@0.141.0': + resolution: {integrity: sha512-vo+MR+n3zQJ6Mq92hiP084NZcgDv5iJlVR02gMf28neMvVT1tKVm7VeiW/DxhdqOi3QLeaXIk9cUcLL1qrkngw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-ppc64-gnu@0.142.0': resolution: {integrity: sha512-wLMbT21U/QxknQsk+VvNF0b9D2/aGWhcaQQQ+VYlE8FwD5+GoWZIPPXNzyHmkYyhm0KB3itL+TBavjMatqNnYA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1626,6 +1680,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-riscv64-gnu@0.141.0': + resolution: {integrity: sha512-oh80w+7RuiO5gBp9Jnoa/H8Qlt3JsHL2MkW+0dwEdlDMdslVZX/YsekSK6EeyEenY66/mhCfypsNATQ7Ph3qlQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-riscv64-gnu@0.142.0': resolution: {integrity: sha512-+G8F/4ckwT7FCJV4H2bt09xEzJbjNCfuL4Sp1AYNaFtFMVtgIGMuJlteT82U+K0UIZ/DzAR/LDlMFnEuajG7Kw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1640,6 +1701,13 @@ packages: os: [linux] libc: [musl] + '@oxc-parser/binding-linux-riscv64-musl@0.141.0': + resolution: {integrity: sha512-LOyEmFA8sCnYbEXP1+iQvCC/P1YXHMA/t6x1Ksp0Y9VwhLFsiBJFzV1zIxrOIE2LKaGGhDjQ29xq9cbq6omDXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + '@oxc-parser/binding-linux-riscv64-musl@0.142.0': resolution: {integrity: sha512-hTsHtTLxMAfCo+rpF5K3qZJKW2NpPN/CHd4mYB3y7XlSdspHkd2gehDIofP64AacA9nWQw2tY3O7wR6UY8IVOA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1654,6 +1722,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-s390x-gnu@0.141.0': + resolution: {integrity: sha512-3wnwk/l1CvszVE5TJR1wSl/zSEfydRqrNhn6s7Vr9IzSJpUQIroqVsIoPARHRFA+FQwkxAFDAHDAasa7v8OobQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-s390x-gnu@0.142.0': resolution: {integrity: sha512-6y7qYY3TCUDYjqswImdTGl92y+KA/80twALegQPN27kfY+bG7Ib1+L3jbmrCZQx6wrVnai9IPsEZp07I0hx7JQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1668,6 +1743,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-x64-gnu@0.141.0': + resolution: {integrity: sha512-qtyQVAAebFq57B2tifTlel3TgGqUtsYNI/e+p6aya9rN9lOZVTDvr215fGYSA9XWooxzMxDiVxkBLk2jQHbsOQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-x64-gnu@0.142.0': resolution: {integrity: sha512-i69kAWU+2LgoH5bR+zWiiu+UzAw7Oxkwv7COeJTeY19pn4e70nKQcr9Pm6cL2Z0Z54d+gl9qADlK/0yyuCPiBA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1682,6 +1764,13 @@ packages: os: [linux] libc: [musl] + '@oxc-parser/binding-linux-x64-musl@0.141.0': + resolution: {integrity: sha512-SkGV1nKw40roEc94pv5EaaeH2ay14G6+roe8Q0wIUC1LcEKxzKW921h7+ZuZX0D3q2Mb/7aSFmxEVqnko3lPRw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@oxc-parser/binding-linux-x64-musl@0.142.0': resolution: {integrity: sha512-4SQs678MmjYVrmhAgCWD4o0vpaFszXw9xLX5p2Z9MMFcltxiLkA88wQjh80YHjPrXtpyZ2CWI5m+1yNKM0m2Pw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1695,6 +1784,12 @@ packages: cpu: [arm64] os: [openharmony] + '@oxc-parser/binding-openharmony-arm64@0.141.0': + resolution: {integrity: sha512-cVgDM7n8QziQqOaP5hNgUYfMG7S/ZeuPxFWXnnHRv7rh025COk0rfQ6eEdKG3j/GaUuyvNZN4ifF1J8KmuXLLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@oxc-parser/binding-openharmony-arm64@0.142.0': resolution: {integrity: sha512-YHpx9N7Ln3a++Tc8rv+H7mrK1zyJQOAwCFg8LZ3lTs1T5afGWeZrLPhPT9HLnIwSjCyJqPWVMIrMxbjcmBr2oQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1706,6 +1801,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] + '@oxc-parser/binding-wasm32-wasi@0.141.0': + resolution: {integrity: sha512-HggH++Fkn3OilBn+bs3jpgIFQa34oMAyUUHy0vpGum+gt1Eb5nyLc8dNU/RAPSw6lsLrx7ncKtHSZE+3Sp0l2g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + '@oxc-parser/binding-wasm32-wasi@0.142.0': resolution: {integrity: sha512-3pLDyY3+oogW73RM5uehNgAiR/Xfb7fvO2Q1Z1gIqZ2+50XDVQmBVlRkHXZTU4gKnQHpwETNsYQVsJ3joVB2iA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1717,6 +1817,12 @@ packages: cpu: [arm64] os: [win32] + '@oxc-parser/binding-win32-arm64-msvc@0.141.0': + resolution: {integrity: sha512-KLSEH9GwgbrqbJOjtGHt9STw96s+78yDzp7IDN8Lno+7Ut9sNBfZ4jYZIz4mD50qmWUjoOI7i9I6UENbhNbMZQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@oxc-parser/binding-win32-arm64-msvc@0.142.0': resolution: {integrity: sha512-Had/VeVY28Oyb0K+Q4FV8KCzoBycIh93oDK6pCbya9lkzdq+ikMHMgBubsdqqlybjJmQRawCQRrnBRHyQwYvcQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1729,6 +1835,12 @@ packages: cpu: [ia32] os: [win32] + '@oxc-parser/binding-win32-ia32-msvc@0.141.0': + resolution: {integrity: sha512-9UVWUOOCI/1YkiSSNjg2zyBJYM9E/t1A/8GNobd48JDn/fQ6mzxcVO3H08jb3rAaW/B1VBf8eCORTvSsO9T08g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + '@oxc-parser/binding-win32-ia32-msvc@0.142.0': resolution: {integrity: sha512-GGi3+YphVHavvgs6gum2UXoNCqzHAmPt/nXkn8ZQZstV2Q1qZD1Mn8fz/nWrDkefHQtrG/+1/XrbMxsBTo6Svw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1741,6 +1853,12 @@ packages: cpu: [x64] os: [win32] + '@oxc-parser/binding-win32-x64-msvc@0.141.0': + resolution: {integrity: sha512-HI/wsvbWT5RHHw5c37D0fEgeTd8/1Q4OJs5jUmEBc17VZFG6SsCIe4barq7NsAPPks/JW+3ayi3Rp+PQI5h4Kg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxc-parser/binding-win32-x64-msvc@0.142.0': resolution: {integrity: sha512-Ny/Wv4Us1LGC/ljwNTp+Hx3r/pH15EFfeDF0p+n898gt+TtRd6C9SccHcuUhDiNTb8s5tt7jdeAMDRQZ4Vq6hg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1757,6 +1875,9 @@ packages: '@oxc-project/types@0.132.0': resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==} + '@oxc-project/types@0.141.0': + resolution: {integrity: sha512-S4as7z0j0xQkXcJlyY5ehntwK8/wRkQb9Cyqw+J/N2rkWGQGK0SxD6X6DhQTc7qsxVTBxXbxZtBJh3mr3PtIzQ==} + '@oxc-project/types@0.142.0': resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} @@ -2488,9 +2609,6 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/minimatch@5.1.2': - resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} - '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} @@ -2519,35 +2637,6 @@ packages: resolution: {integrity: sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitest/expect@4.1.7': - resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} - - '@vitest/mocker@4.1.7': - resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} - peerDependencies: - msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/pretty-format@4.1.7': - resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==} - - '@vitest/runner@4.1.7': - resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==} - - '@vitest/snapshot@4.1.7': - resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==} - - '@vitest/spy@4.1.7': - resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==} - - '@vitest/utils@4.1.7': - resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} - '@voidzero-dev/vite-plus-core@0.1.20': resolution: {integrity: sha512-4KmzRfzwTeG3JuvDijrdqWusSgRvLMKDPrVsDdtbDVVjEMq0VnM8lSH+Nvepd6Pg+SuSVUP212OIfH/3Yn1bfA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2849,10 +2938,6 @@ packages: caniuse-lite@1.0.30001769: resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} - engines: {node: '>=18'} - chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3033,9 +3118,6 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-module-lexer@2.1.0: - resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} - es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -3131,9 +3213,6 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -3146,10 +3225,6 @@ packages: resolution: {integrity: sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==} engines: {node: '>=0.10.0'} - expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} - engines: {node: '>=12.0.0'} - extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} @@ -3601,9 +3676,6 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magicast@0.5.3: resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} @@ -3728,6 +3800,10 @@ packages: resolution: {integrity: sha512-+0LAPHaqtfQlvWdpaAa09SmOaZZgP8C552xosEkGJ4+ruEwP1Vgx+sqBgcBCNfR6KDCmagGOZTde8wmAvcI/Hg==} engines: {node: ^20.19.0 || >=22.12.0} + oxc-parser@0.141.0: + resolution: {integrity: sha512-uFkGGr1KMWd6aWv9UAqooYrN78trw8MWWmoPvgWokfBEUq1+eiIQ+qfj3wokhy0fxtZWZk+0dHoS7/yRTJtd6w==} + engines: {node: ^20.19.0 || >=22.12.0} + oxc-parser@0.142.0: resolution: {integrity: sha512-kKR+jPiRJYJDexVoziIg/FVGvr1fT1FZSSJOk6tVoMKKSlsf1Cso+cgGCJkOEDWOP174vRntCPFKg+AS7InWvw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3975,9 +4051,6 @@ packages: resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} engines: {node: '>= 0.4'} - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -4024,9 +4097,6 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - std-env@4.0.0: resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} @@ -4122,10 +4192,6 @@ packages: resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} engines: {node: ^20.0.0 || >=22.0.0} - tinyrainbow@3.1.0: - resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} - engines: {node: '>=14.0.0'} - to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -4247,47 +4313,6 @@ packages: yaml: optional: true - vitest@4.1.7: - resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@opentelemetry/api': ^1.9.0 - '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.7 - '@vitest/browser-preview': 4.1.7 - '@vitest/browser-webdriverio': 4.1.7 - '@vitest/coverage-istanbul': 4.1.7 - '@vitest/coverage-v8': 4.1.7 - '@vitest/ui': 4.1.7 - happy-dom: '*' - jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@opentelemetry/api': - optional: true - '@types/node': - optional: true - '@vitest/browser-playwright': - optional: true - '@vitest/browser-preview': - optional: true - '@vitest/browser-webdriverio': - optional: true - '@vitest/coverage-istanbul': - optional: true - '@vitest/coverage-v8': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - vscode-jsonrpc@8.2.0: resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} engines: {node: '>=14.0.0'} @@ -4326,11 +4351,6 @@ packages: engines: {node: '>= 8'} hasBin: true - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true - widest-line@6.0.0: resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} engines: {node: '>=20'} @@ -4917,11 +4937,6 @@ snapshots: - bufferutil - utf-8-validate - '@effect/vitest@4.0.0-beta.70(effect@4.0.0-beta.70)(vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(vite@7.3.1(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)))': - dependencies: - effect: 4.0.0-beta.70 - vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(vite@7.3.1(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)) - '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -5667,96 +5682,144 @@ snapshots: '@oxc-parser/binding-android-arm-eabi@0.132.0': optional: true + '@oxc-parser/binding-android-arm-eabi@0.141.0': + optional: true + '@oxc-parser/binding-android-arm-eabi@0.142.0': optional: true '@oxc-parser/binding-android-arm64@0.132.0': optional: true + '@oxc-parser/binding-android-arm64@0.141.0': + optional: true + '@oxc-parser/binding-android-arm64@0.142.0': optional: true '@oxc-parser/binding-darwin-arm64@0.132.0': optional: true + '@oxc-parser/binding-darwin-arm64@0.141.0': + optional: true + '@oxc-parser/binding-darwin-arm64@0.142.0': optional: true '@oxc-parser/binding-darwin-x64@0.132.0': optional: true + '@oxc-parser/binding-darwin-x64@0.141.0': + optional: true + '@oxc-parser/binding-darwin-x64@0.142.0': optional: true '@oxc-parser/binding-freebsd-x64@0.132.0': optional: true + '@oxc-parser/binding-freebsd-x64@0.141.0': + optional: true + '@oxc-parser/binding-freebsd-x64@0.142.0': optional: true '@oxc-parser/binding-linux-arm-gnueabihf@0.132.0': optional: true + '@oxc-parser/binding-linux-arm-gnueabihf@0.141.0': + optional: true + '@oxc-parser/binding-linux-arm-gnueabihf@0.142.0': optional: true '@oxc-parser/binding-linux-arm-musleabihf@0.132.0': optional: true + '@oxc-parser/binding-linux-arm-musleabihf@0.141.0': + optional: true + '@oxc-parser/binding-linux-arm-musleabihf@0.142.0': optional: true '@oxc-parser/binding-linux-arm64-gnu@0.132.0': optional: true + '@oxc-parser/binding-linux-arm64-gnu@0.141.0': + optional: true + '@oxc-parser/binding-linux-arm64-gnu@0.142.0': optional: true '@oxc-parser/binding-linux-arm64-musl@0.132.0': optional: true + '@oxc-parser/binding-linux-arm64-musl@0.141.0': + optional: true + '@oxc-parser/binding-linux-arm64-musl@0.142.0': optional: true '@oxc-parser/binding-linux-ppc64-gnu@0.132.0': optional: true + '@oxc-parser/binding-linux-ppc64-gnu@0.141.0': + optional: true + '@oxc-parser/binding-linux-ppc64-gnu@0.142.0': optional: true '@oxc-parser/binding-linux-riscv64-gnu@0.132.0': optional: true + '@oxc-parser/binding-linux-riscv64-gnu@0.141.0': + optional: true + '@oxc-parser/binding-linux-riscv64-gnu@0.142.0': optional: true '@oxc-parser/binding-linux-riscv64-musl@0.132.0': optional: true + '@oxc-parser/binding-linux-riscv64-musl@0.141.0': + optional: true + '@oxc-parser/binding-linux-riscv64-musl@0.142.0': optional: true '@oxc-parser/binding-linux-s390x-gnu@0.132.0': optional: true + '@oxc-parser/binding-linux-s390x-gnu@0.141.0': + optional: true + '@oxc-parser/binding-linux-s390x-gnu@0.142.0': optional: true '@oxc-parser/binding-linux-x64-gnu@0.132.0': optional: true + '@oxc-parser/binding-linux-x64-gnu@0.141.0': + optional: true + '@oxc-parser/binding-linux-x64-gnu@0.142.0': optional: true '@oxc-parser/binding-linux-x64-musl@0.132.0': optional: true + '@oxc-parser/binding-linux-x64-musl@0.141.0': + optional: true + '@oxc-parser/binding-linux-x64-musl@0.142.0': optional: true '@oxc-parser/binding-openharmony-arm64@0.132.0': optional: true + '@oxc-parser/binding-openharmony-arm64@0.141.0': + optional: true + '@oxc-parser/binding-openharmony-arm64@0.142.0': optional: true @@ -5767,6 +5830,13 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true + '@oxc-parser/binding-wasm32-wasi@0.141.0': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + '@oxc-parser/binding-wasm32-wasi@0.142.0': dependencies: '@emnapi/core': 1.11.2 @@ -5777,18 +5847,27 @@ snapshots: '@oxc-parser/binding-win32-arm64-msvc@0.132.0': optional: true + '@oxc-parser/binding-win32-arm64-msvc@0.141.0': + optional: true + '@oxc-parser/binding-win32-arm64-msvc@0.142.0': optional: true '@oxc-parser/binding-win32-ia32-msvc@0.132.0': optional: true + '@oxc-parser/binding-win32-ia32-msvc@0.141.0': + optional: true + '@oxc-parser/binding-win32-ia32-msvc@0.142.0': optional: true '@oxc-parser/binding-win32-x64-msvc@0.132.0': optional: true + '@oxc-parser/binding-win32-x64-msvc@0.141.0': + optional: true + '@oxc-parser/binding-win32-x64-msvc@0.142.0': optional: true @@ -5798,6 +5877,8 @@ snapshots: '@oxc-project/types@0.132.0': {} + '@oxc-project/types@0.141.0': {} + '@oxc-project/types@0.142.0': {} '@oxc-resolver/binding-android-arm-eabi@11.24.2': @@ -6252,8 +6333,6 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/minimatch@5.1.2': {} - '@types/node@12.20.55': {} '@types/node@25.6.0': @@ -6281,47 +6360,6 @@ snapshots: '@typescript-eslint/types@8.59.3': {} - '@vitest/expect@4.1.7': - dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@vitest/spy': 4.1.7 - '@vitest/utils': 4.1.7 - chai: 6.2.2 - tinyrainbow: 3.1.0 - - '@vitest/mocker@4.1.7(vite@7.3.1(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0))': - dependencies: - '@vitest/spy': 4.1.7 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.3.1(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0) - - '@vitest/pretty-format@4.1.7': - dependencies: - tinyrainbow: 3.1.0 - - '@vitest/runner@4.1.7': - dependencies: - '@vitest/utils': 4.1.7 - pathe: 2.0.3 - - '@vitest/snapshot@4.1.7': - dependencies: - '@vitest/pretty-format': 4.1.7 - '@vitest/utils': 4.1.7 - magic-string: 0.30.21 - pathe: 2.0.3 - - '@vitest/spy@4.1.7': {} - - '@vitest/utils@4.1.7': - dependencies: - '@vitest/pretty-format': 4.1.7 - convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 - '@voidzero-dev/vite-plus-core@0.1.20(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.0)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0)': dependencies: '@oxc-project/runtime': 0.127.0 @@ -6620,8 +6658,6 @@ snapshots: caniuse-lite@1.0.30001769: {} - chai@6.2.2: {} - chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -6780,8 +6816,6 @@ snapshots: es-module-lexer@1.7.0: {} - es-module-lexer@2.1.0: {} - es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -6976,10 +7010,6 @@ snapshots: estraverse@5.3.0: {} - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.8 - esutils@2.0.3: {} events@3.3.0: {} @@ -6988,8 +7018,6 @@ snapshots: dependencies: homedir-polyfill: 1.0.3 - expect-type@1.3.0: {} - extendable-error@0.1.7: {} fast-check@4.8.0: @@ -7384,10 +7412,6 @@ snapshots: dependencies: yallist: 3.1.1 - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - magicast@0.5.3: dependencies: '@babel/parser': 7.29.7 @@ -7529,6 +7553,31 @@ snapshots: '@oxc-parser/binding-win32-ia32-msvc': 0.132.0 '@oxc-parser/binding-win32-x64-msvc': 0.132.0 + oxc-parser@0.141.0: + dependencies: + '@oxc-project/types': 0.141.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.141.0 + '@oxc-parser/binding-android-arm64': 0.141.0 + '@oxc-parser/binding-darwin-arm64': 0.141.0 + '@oxc-parser/binding-darwin-x64': 0.141.0 + '@oxc-parser/binding-freebsd-x64': 0.141.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.141.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.141.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.141.0 + '@oxc-parser/binding-linux-arm64-musl': 0.141.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.141.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.141.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.141.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.141.0 + '@oxc-parser/binding-linux-x64-gnu': 0.141.0 + '@oxc-parser/binding-linux-x64-musl': 0.141.0 + '@oxc-parser/binding-openharmony-arm64': 0.141.0 + '@oxc-parser/binding-wasm32-wasi': 0.141.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.141.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.141.0 + '@oxc-parser/binding-win32-x64-msvc': 0.141.0 + oxc-parser@0.142.0: dependencies: '@oxc-project/types': 0.142.0 @@ -7862,8 +7911,6 @@ snapshots: shell-quote@1.10.0: {} - siginfo@2.0.0: {} - signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -7907,8 +7954,6 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 - stackback@0.0.2: {} - std-env@4.0.0: {} stdin-discarder@0.3.2: {} @@ -8000,8 +8045,6 @@ snapshots: tinypool@2.1.0: {} - tinyrainbow@3.1.0: {} - to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -8175,34 +8218,6 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 - vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(vite@7.3.1(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)): - dependencies: - '@vitest/expect': 4.1.7 - '@vitest/mocker': 4.1.7(vite@7.3.1(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.7 - '@vitest/runner': 4.1.7 - '@vitest/snapshot': 4.1.7 - '@vitest/spy': 4.1.7 - '@vitest/utils': 4.1.7 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.0.0 - tinybench: 2.9.0 - tinyexec: 1.1.1 - tinyglobby: 0.2.16 - tinyrainbow: 3.1.0 - vite: 7.3.1(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@opentelemetry/api': 1.9.1 - '@types/node': 25.6.0 - transitivePeerDependencies: - - msw - vscode-jsonrpc@8.2.0: {} vscode-languageclient@9.0.1: @@ -8239,11 +8254,6 @@ snapshots: dependencies: isexe: 2.0.0 - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 - widest-line@6.0.0: dependencies: string-width: 8.2.1 diff --git a/scripts/build/constants.ts b/scripts/build/constants.ts new file mode 100644 index 0000000000..d56e0dbe3c --- /dev/null +++ b/scripts/build/constants.ts @@ -0,0 +1,34 @@ +export const NODE_PACK_TARGET = "node20"; +export const DEFAULT_TEST_TIMEOUT_MS = 30_000; + +const OXC_RUNTIME_EXTERNALS: ReadonlyArray = [ + "oxc-parser", + "oxc-resolver", + "oxlint", + "oxlint-plugin-react-doctor", +]; + +export const ENGINE_RUNTIME_EXTERNALS: ReadonlyArray = [ + "deslop-js", + "effect", + ...OXC_RUNTIME_EXTERNALS, + "typescript", +]; + +export const NATIVE_ANALYZER_RUNTIME_EXTERNALS: ReadonlyArray = [ + "deslop-js", + ...OXC_RUNTIME_EXTERNALS, +]; + +export const NATIVE_ANALYZER_EXTERNALS: ReadonlyArray = [ + ...NATIVE_ANALYZER_RUNTIME_EXTERNALS, + "typescript", +]; + +export const LSP_RUNTIME_EXTERNALS: ReadonlyArray = [ + "vscode-languageserver", + "vscode-languageserver-protocol", + "vscode-languageserver-textdocument", + "vscode-jsonrpc", + "vscode-uri", +]; diff --git a/scripts/build/read-package-version.test.ts b/scripts/build/read-package-version.test.ts new file mode 100644 index 0000000000..0315c15639 --- /dev/null +++ b/scripts/build/read-package-version.test.ts @@ -0,0 +1,36 @@ +import * as assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { test } from "node:test"; +import { pathToFileURL } from "node:url"; +import { readPackageVersion } from "../utils/read-package-version.js"; + +interface TestPackageManifest { + readonly version: unknown; +} + +const withPackageManifest = ( + manifest: TestPackageManifest, + run: (moduleUrl: string) => void, +): void => { + const packageDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-build-policy-")); + try { + fs.writeFileSync(path.join(packageDirectory, "package.json"), JSON.stringify(manifest)); + run(pathToFileURL(path.join(packageDirectory, "vite.config.ts")).href); + } finally { + fs.rmSync(packageDirectory, { recursive: true, force: true }); + } +}; + +test("readPackageVersion reads the package adjacent to a module URL", () => { + withPackageManifest({ version: "1.2.3" }, (moduleUrl) => { + assert.equal(readPackageVersion(moduleUrl), "1.2.3"); + }); +}); + +test("readPackageVersion rejects manifests without a string version", () => { + withPackageManifest({ version: 123 }, (moduleUrl) => { + assert.throws(() => readPackageVersion(moduleUrl), /has no string version/); + }); +}); diff --git a/scripts/check-published-deps.ts b/scripts/check-published-deps.ts index a3c97cfb89..1835c04304 100644 --- a/scripts/check-published-deps.ts +++ b/scripts/check-published-deps.ts @@ -3,6 +3,11 @@ import { fileURLToPath } from "node:url"; import ts from "typescript"; import * as fs from "node:fs"; import * as path from "node:path"; +import { + findPublishedPackageManifests, + type PackageManifest, + readPackageManifest, +} from "./utils/find-published-package-manifests.ts"; const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url)); const REPOSITORY_ROOT = path.resolve(SCRIPT_DIRECTORY, ".."); @@ -14,14 +19,6 @@ const NODE_BUILTIN_MODULES: ReadonlySet = new Set([ ...builtinModules.map((moduleName) => `node:${moduleName}`), ]); -interface PackageManifest { - readonly name: string; - readonly private?: boolean; - readonly dependencies?: Record; - readonly peerDependencies?: Record; - readonly optionalDependencies?: Record; -} - interface PhantomDependency { readonly importedPackage: string; readonly importingFiles: readonly string[]; @@ -34,9 +31,6 @@ interface PackageAuditResult { readonly artifactFileCount: number; } -const readManifest = (packageDirectory: string): PackageManifest => - JSON.parse(fs.readFileSync(path.join(packageDirectory, "package.json"), "utf8")); - const toPackageName = (moduleSpecifier: string): string => { const segments = moduleSpecifier.split("/"); return moduleSpecifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0]; @@ -94,7 +88,7 @@ const collectModuleSpecifiers = (sourceText: string, fileName: string): Set { - const manifest = readManifest(packageDirectory); + const manifest = readPackageManifest(packageDirectory); const declaredDependencies = new Set([ ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.peerDependencies ?? {}), @@ -136,13 +130,9 @@ const auditPublishedPackage = (packageDirectory: string): PackageAuditResult => }; }; -const publishedPackageDirectories = fs - .readdirSync(PACKAGES_DIRECTORY, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(PACKAGES_DIRECTORY, entry.name)) - .filter((packageDirectory) => fs.existsSync(path.join(packageDirectory, "package.json"))) - .filter((packageDirectory) => readManifest(packageDirectory).private !== true) - .sort(); +const publishedPackageDirectories = findPublishedPackageManifests(PACKAGES_DIRECTORY).map( + ({ directory }) => directory, +); if (publishedPackageDirectories.length === 0) { console.error("No published packages found under packages/."); diff --git a/scripts/check-skills.mjs b/scripts/check-skills.mjs new file mode 100644 index 0000000000..e56e2bc5d6 --- /dev/null +++ b/scripts/check-skills.mjs @@ -0,0 +1,369 @@ +import * as childProcess from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const SCRIPT_FILE_PATH = fileURLToPath(import.meta.url); +const REPOSITORY_ROOT = path.resolve(path.dirname(SCRIPT_FILE_PATH), ".."); +const AGENT_GUIDANCE_PATHS = ["AGENTS.md"]; +const AGENT_REFERENCE_DIRECTORY = ".agents/references"; +const TRACKED_SKILL_PATHSPECS = [":(glob)skills/**/SKILL.md", ":(glob).agents/skills/**/SKILL.md"]; +const FRONTMATTER_PATTERN = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; +const MARKDOWN_LINK_PATTERN = /!?\[[^\]\r\n]*\]\((<[^>\r\n]+>|[^)\r\n]+)\)/g; +const MARKDOWN_REFERENCE_LINK_PATTERN = /^[ \t]{0,3}\[[^\]\r\n]+\]:[ \t]*(<[^>\r\n]+>|\S+)/gm; +const INLINE_CODE_PATTERN = /`([^`\r\n]+)`/g; +const NR_COMMAND_PATTERN = + /\bnr(?:[ \t]+--filter[ \t]+([A-Za-z0-9@/._-]+))?[ \t]+([A-Za-z0-9:_-]+)/g; +const REPOSITORY_PATH_PATTERN = + /^(?:AGENTS\.md|action\.yml|package\.json|pnpm-lock\.yaml|(?:\.agents|\.github|compatibility|docs|packages|scripts|skills)\/)/; +const SKILL_DIRECTORY_RESOURCE_PATTERN = + /(?:\$(?:\{SKILL_DIR\}|SKILL_DIR)|)\/((?:assets|scripts)\/[A-Za-z0-9_./-]+)/gi; +const REPOSITORY_RESOURCE_PATTERN = + /((?:\.agents\/skills|skills|packages)\/[A-Za-z0-9_./-]*\/(?:assets|scripts)\/[A-Za-z0-9_./-]+)/g; +const INVALID_PLAIN_SCALAR_PATTERN = /^(?:|null|~|true|false|[-+]?(?:\d+\.?\d*|\.\d+))$/i; + +const toPosixPath = (filePath) => filePath.split(path.sep).join("/"); + +const lineNumberAt = (sourceText, characterIndex) => + sourceText.slice(0, characterIndex).split(/\r?\n/).length; + +const isInsideDirectory = (directory, candidatePath) => { + const relativePath = path.relative(directory, candidatePath); + return ( + relativePath === "" || (!relativePath.startsWith(`..${path.sep}`) && relativePath !== "..") + ); +}; + +const createIssue = (manifestPath, line, message) => ({ + manifestPath, + line, + message, +}); + +const isNonEmptyStringScalar = (value) => { + const trimmedValue = value.trim(); + const firstCharacter = trimmedValue[0]; + if (firstCharacter === '"' || firstCharacter === "'") { + return trimmedValue.endsWith(firstCharacter) && trimmedValue.slice(1, -1).trim().length > 0; + } + return ( + !INVALID_PLAIN_SCALAR_PATTERN.test(trimmedValue) && + !["[", "{", "&", "*", "!"].includes(firstCharacter) + ); +}; + +const readBlockScalarValue = (frontmatter, fieldName) => { + const fieldPattern = new RegExp(`^${fieldName}[ \\t]*:[ \\t]*([>|][+-]?)$`, "m"); + const match = frontmatter.match(fieldPattern); + if (!match || match.index === undefined) return null; + + const followingLines = frontmatter + .slice(match.index + match[0].length) + .split(/\r?\n/) + .slice(1); + const valueLines = []; + for (const line of followingLines) { + if (line !== "" && !/^[ \t]/.test(line)) break; + valueLines.push(line.trim()); + } + return valueLines.filter(Boolean).join(" "); +}; + +const validateFrontmatter = (manifestPath, sourceText) => { + const match = sourceText.match(FRONTMATTER_PATTERN); + if (!match) return [createIssue(manifestPath, 1, "missing YAML frontmatter")]; + + const frontmatter = match[1] ?? ""; + return ["name", "description"].flatMap((fieldName) => { + const fieldPattern = new RegExp(`^${fieldName}[ \\t]*:[ \\t]*(.*)$`, "gm"); + const matches = [...frontmatter.matchAll(fieldPattern)]; + if (matches.length === 0) { + return [createIssue(manifestPath, 1, `missing frontmatter field "${fieldName}"`)]; + } + if (matches.length > 1) { + return [createIssue(manifestPath, 1, `duplicate frontmatter field "${fieldName}"`)]; + } + + const inlineValue = (matches[0][1] ?? "").trim(); + const isBlockScalar = /^[>|][+-]?$/.test(inlineValue); + const scalarValue = isBlockScalar ? readBlockScalarValue(frontmatter, fieldName) : inlineValue; + if ( + scalarValue !== null && + (isBlockScalar ? scalarValue.trim().length > 0 : isNonEmptyStringScalar(scalarValue)) + ) { + return []; + } + return [ + createIssue(manifestPath, 1, `frontmatter field "${fieldName}" must be a non-empty scalar`), + ]; + }); +}; + +const parseMarkdownDestination = (rawDestination) => { + const trimmedDestination = rawDestination.trim(); + if (trimmedDestination.startsWith("<")) { + const closingBracketIndex = trimmedDestination.indexOf(">"); + return closingBracketIndex === -1 + ? null + : trimmedDestination.slice(1, closingBracketIndex).trim(); + } + const destinationMatch = trimmedDestination.match(/^(?:\\.|[^\s])+/); + return destinationMatch === null ? null : destinationMatch[0]; +}; + +const normalizeRelativeDestination = (destination) => { + if ( + destination === "" || + /^[#/~$]/.test(destination) || + destination.includes("<") || + /^[A-Za-z][A-Za-z0-9+.-]*:/.test(destination) + ) { + return null; + } + + const [pathWithoutFragment = ""] = destination.split(/[?#]/, 1); + if (pathWithoutFragment === "") return null; + try { + return decodeURIComponent(pathWithoutFragment.replaceAll("\\ ", " ")); + } catch { + return null; + } +}; + +const validateMarkdownLinks = (repositoryRoot, manifestPath, sourceText) => { + const manifestDirectory = path.dirname(path.resolve(repositoryRoot, manifestPath)); + const linkMatches = [ + ...sourceText.matchAll(MARKDOWN_LINK_PATTERN), + ...sourceText.matchAll(MARKDOWN_REFERENCE_LINK_PATTERN), + ]; + return linkMatches.flatMap((match) => { + const destination = parseMarkdownDestination(match[1] ?? ""); + const relativeDestination = + destination === null ? null : normalizeRelativeDestination(destination); + if (relativeDestination === null) return []; + + const resolvedPath = path.resolve(manifestDirectory, relativeDestination); + if (!isInsideDirectory(repositoryRoot, resolvedPath) || fs.existsSync(resolvedPath)) return []; + return [ + createIssue( + manifestPath, + lineNumberAt(sourceText, match.index ?? 0), + `broken relative Markdown link: ${relativeDestination}`, + ), + ]; + }); +}; + +const validateLocalResources = (repositoryRoot, manifestPath, sourceText) => { + const manifestDirectory = path.dirname(path.resolve(repositoryRoot, manifestPath)); + const resourceReferences = [ + ...[...sourceText.matchAll(SKILL_DIRECTORY_RESOURCE_PATTERN)].map((match) => ({ + relativePath: match[1] ?? "", + resolvedPath: path.resolve(manifestDirectory, match[1] ?? ""), + index: match.index ?? 0, + })), + ...[...sourceText.matchAll(REPOSITORY_RESOURCE_PATTERN)].map((match) => ({ + relativePath: match[1] ?? "", + resolvedPath: path.resolve(repositoryRoot, match[1] ?? ""), + index: match.index ?? 0, + })), + ]; + const seenPaths = new Set(); + + return resourceReferences.flatMap((reference) => { + if ( + reference.relativePath === "" || + seenPaths.has(reference.resolvedPath) || + !isInsideDirectory(repositoryRoot, reference.resolvedPath) + ) { + return []; + } + seenPaths.add(reference.resolvedPath); + if (fs.existsSync(reference.resolvedPath)) return []; + return [ + createIssue( + manifestPath, + lineNumberAt(sourceText, reference.index), + `missing local resource: ${reference.relativePath}`, + ), + ]; + }); +}; + +const findAgentGuidancePaths = (repositoryRoot) => { + const referenceDirectory = path.join(repositoryRoot, AGENT_REFERENCE_DIRECTORY); + const referencePaths = fs.existsSync(referenceDirectory) + ? fs + .readdirSync(referenceDirectory, { withFileTypes: true }) + .filter((directoryEntry) => directoryEntry.isFile() && directoryEntry.name.endsWith(".md")) + .map((directoryEntry) => path.join(AGENT_REFERENCE_DIRECTORY, directoryEntry.name)) + : []; + return [...AGENT_GUIDANCE_PATHS, ...referencePaths].sort(); +}; + +const readPackageScripts = (packageJsonPath) => { + if (!fs.existsSync(packageJsonPath)) return null; + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + return packageJson.scripts && typeof packageJson.scripts === "object" + ? new Set(Object.keys(packageJson.scripts)) + : new Set(); +}; + +const findWorkspacePackageJsonPath = (repositoryRoot, workspaceName) => { + const packagesDirectory = path.join(repositoryRoot, "packages"); + if (!fs.existsSync(packagesDirectory)) return null; + + for (const directoryEntry of fs.readdirSync(packagesDirectory, { withFileTypes: true })) { + if (!directoryEntry.isDirectory()) continue; + const packageJsonPath = path.join(packagesDirectory, directoryEntry.name, "package.json"); + if (!fs.existsSync(packageJsonPath)) continue; + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + if (packageJson.name === workspaceName) return packageJsonPath; + } + return null; +}; + +const validateNrCommands = (repositoryRoot, documentPath, sourceText) => + [...sourceText.matchAll(NR_COMMAND_PATTERN)].flatMap((match) => { + const workspaceName = match[1]; + const scriptName = match[2] ?? ""; + if (scriptName === "SCRIPT_NAME" || scriptName === "script_name") return []; + + const packageJsonPath = + workspaceName === undefined + ? path.join(repositoryRoot, "package.json") + : findWorkspacePackageJsonPath(repositoryRoot, workspaceName); + if (packageJsonPath === null) { + return [ + createIssue( + documentPath, + lineNumberAt(sourceText, match.index ?? 0), + `documented nr workspace does not exist: ${workspaceName}`, + ), + ]; + } + + const scripts = readPackageScripts(packageJsonPath); + if (scripts?.has(scriptName)) return []; + const command = + workspaceName === undefined + ? `nr ${scriptName}` + : `nr --filter ${workspaceName} ${scriptName}`; + return [ + createIssue( + documentPath, + lineNumberAt(sourceText, match.index ?? 0), + `documented script does not exist: ${command}`, + ), + ]; + }); + +const validateCanonicalRepositoryPaths = (repositoryRoot, documentPath, sourceText) => + [...sourceText.matchAll(INLINE_CODE_PATTERN)].flatMap((match) => { + const repositoryPath = match[1] ?? ""; + if ( + !REPOSITORY_PATH_PATTERN.test(repositoryPath) || + /[\s*{}<>]/.test(repositoryPath) || + repositoryPath.includes("#") + ) { + return []; + } + + const pathWithoutTrailingSlash = repositoryPath.replace(/\/$/, ""); + if (fs.existsSync(path.resolve(repositoryRoot, pathWithoutTrailingSlash))) return []; + return [ + createIssue( + documentPath, + lineNumberAt(sourceText, match.index ?? 0), + `canonical repository path does not exist: ${repositoryPath}`, + ), + ]; + }); + +export const validateAgentGuidanceDocuments = ({ + repositoryRoot = REPOSITORY_ROOT, + guidancePaths = findAgentGuidancePaths(repositoryRoot), +} = {}) => + guidancePaths + .flatMap((guidancePath) => { + const documentPath = toPosixPath(guidancePath); + const absoluteDocumentPath = path.resolve(repositoryRoot, guidancePath); + if (!fs.existsSync(absoluteDocumentPath)) { + return [createIssue(documentPath, 1, "agent guidance document is missing")]; + } + + const sourceText = fs.readFileSync(absoluteDocumentPath, "utf8"); + return [ + ...validateMarkdownLinks(repositoryRoot, documentPath, sourceText), + ...validateNrCommands(repositoryRoot, documentPath, sourceText), + ...validateCanonicalRepositoryPaths(repositoryRoot, documentPath, sourceText), + ]; + }) + .sort( + (leftIssue, rightIssue) => + leftIssue.manifestPath.localeCompare(rightIssue.manifestPath) || + leftIssue.line - rightIssue.line || + leftIssue.message.localeCompare(rightIssue.message), + ); + +export const findTrackedSkillManifestPaths = (repositoryRoot = REPOSITORY_ROOT) => + childProcess + .execFileSync("git", ["ls-files", "-z", "--", ...TRACKED_SKILL_PATHSPECS], { + cwd: repositoryRoot, + encoding: "utf8", + }) + .split("\0") + .filter(Boolean) + .sort(); + +export const validateSkillDocuments = ({ + repositoryRoot = REPOSITORY_ROOT, + skillManifestPaths = findTrackedSkillManifestPaths(repositoryRoot), +} = {}) => + skillManifestPaths + .flatMap((skillManifestPath) => { + const manifestPath = toPosixPath(skillManifestPath); + const absoluteManifestPath = path.resolve(repositoryRoot, skillManifestPath); + if (!fs.existsSync(absoluteManifestPath)) { + return [createIssue(manifestPath, 1, "tracked skill manifest is missing")]; + } + + const sourceText = fs.readFileSync(absoluteManifestPath, "utf8"); + return [ + ...validateFrontmatter(manifestPath, sourceText), + ...validateMarkdownLinks(repositoryRoot, manifestPath, sourceText), + ...validateLocalResources(repositoryRoot, manifestPath, sourceText), + ]; + }) + .sort( + (leftIssue, rightIssue) => + leftIssue.manifestPath.localeCompare(rightIssue.manifestPath) || + leftIssue.line - rightIssue.line || + leftIssue.message.localeCompare(rightIssue.message), + ); + +export const formatSkillValidationIssues = (issues) => + issues.map((issue) => `${issue.manifestPath}:${issue.line} - ${issue.message}`).join("\n"); + +const runSkillCheck = () => { + const skillManifestPaths = findTrackedSkillManifestPaths(); + if (skillManifestPaths.length === 0) { + throw new Error("No tracked skills/**/SKILL.md or .agents/skills/**/SKILL.md files found."); + } + + const skillIssues = validateSkillDocuments({ skillManifestPaths }); + const guidancePaths = findAgentGuidancePaths(REPOSITORY_ROOT); + const guidanceIssues = validateAgentGuidanceDocuments({ guidancePaths }); + process.stdout.write( + `Skill document validation: ${skillManifestPaths.length} manifests, ${skillIssues.length} issues.\n`, + ); + process.stdout.write( + `Agent guidance validation: ${guidancePaths.length} documents, ${guidanceIssues.length} issues.\n`, + ); + const issues = [...skillIssues, ...guidanceIssues]; + if (issues.length === 0) return; + process.stderr.write(`${formatSkillValidationIssues(issues)}\n`); + process.exitCode = 1; +}; + +if (path.resolve(process.argv[1] ?? "") === SCRIPT_FILE_PATH) runSkillCheck(); diff --git a/scripts/check-skills.test.mjs b/scripts/check-skills.test.mjs new file mode 100644 index 0000000000..38c6a2acd0 --- /dev/null +++ b/scripts/check-skills.test.mjs @@ -0,0 +1,126 @@ +import * as assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { validateAgentGuidanceDocuments, validateSkillDocuments } from "./check-skills.mjs"; + +const FIXTURE_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "fixtures", + "check-skills", +); + +const validateFixture = (fixtureName, manifestPath) => + validateSkillDocuments({ + repositoryRoot: path.join(FIXTURE_ROOT, fixtureName), + skillManifestPaths: [manifestPath], + }); + +describe("skill document validation", () => { + it("accepts nested skills, local resources, external paths, and slash commands", () => { + assert.deepEqual(validateFixture("valid-nested", "skills/groups/nested-skill/SKILL.md"), []); + }); + + it("reports missing required frontmatter", () => { + assert.deepEqual(validateFixture("broken-frontmatter", "skills/broken/SKILL.md"), [ + { + manifestPath: "skills/broken/SKILL.md", + line: 1, + message: 'missing frontmatter field "description"', + }, + ]); + }); + + it("reports broken relative Markdown links", () => { + assert.deepEqual(validateFixture("broken-link", "skills/broken/SKILL.md"), [ + { + manifestPath: "skills/broken/SKILL.md", + line: 8, + message: "broken relative Markdown link: references/missing.md", + }, + ]); + }); + + it("reports missing explicitly skill-local assets and scripts", () => { + assert.deepEqual(validateFixture("broken-asset", "skills/broken/SKILL.md"), [ + { + manifestPath: "skills/broken/SKILL.md", + line: 8, + message: "missing local resource: assets/missing.json", + }, + ]); + }); +}); + +const withGuidanceFixture = (files, runTest) => { + const repositoryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-agent-guidance-")); + try { + for (const [relativePath, sourceText] of Object.entries(files)) { + const filePath = path.join(repositoryRoot, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, sourceText); + } + runTest(repositoryRoot); + } finally { + fs.rmSync(repositoryRoot, { recursive: true, force: true }); + } +}; + +describe("agent guidance validation", () => { + it("accepts existing links, canonical paths, and root or workspace scripts", () => { + withGuidanceFixture( + { + "package.json": '{"scripts":{"test":"node --test"}}', + "packages/example/package.json": + '{"name":"@fixture/example","scripts":{"gen:check":"node generate.mjs"}}', + "scripts/check.mjs": "", + ".agents/references/testing.md": "# Testing\n", + "AGENTS.md": + "[Testing](.agents/references/testing.md) uses `scripts/check.mjs`, `nr test`, and `nr --filter @fixture/example gen:check`.\n", + }, + (repositoryRoot) => { + assert.deepEqual(validateAgentGuidanceDocuments({ repositoryRoot }), []); + }, + ); + }); + + it("reports broken links, canonical paths, workspaces, and scripts", () => { + withGuidanceFixture( + { + "package.json": '{"scripts":{}}', + "AGENTS.md": + "[Missing](.agents/references/missing.md) uses `scripts/missing.mjs`, `nr missing`, and `nr --filter @fixture/missing test`.\n", + }, + (repositoryRoot) => { + assert.deepEqual(validateAgentGuidanceDocuments({ repositoryRoot }), [ + { + manifestPath: "AGENTS.md", + line: 1, + message: "broken relative Markdown link: .agents/references/missing.md", + }, + { + manifestPath: "AGENTS.md", + line: 1, + message: "canonical repository path does not exist: scripts/missing.mjs", + }, + { + manifestPath: "AGENTS.md", + line: 1, + message: "documented nr workspace does not exist: @fixture/missing", + }, + { + manifestPath: "AGENTS.md", + line: 1, + message: "documented script does not exist: nr missing", + }, + ]); + }, + ); + }); + + it("accepts the repository's complete agent guidance set", () => { + assert.deepEqual(validateAgentGuidanceDocuments(), []); + }); +}); diff --git a/scripts/check-source-architecture.test.ts b/scripts/check-source-architecture.test.ts new file mode 100644 index 0000000000..2c83f93ec8 --- /dev/null +++ b/scripts/check-source-architecture.test.ts @@ -0,0 +1,209 @@ +import * as assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, it } from "node:test"; +import { + analyzeSourceArchitecture, + formatSourceArchitectureFailures, +} from "./check-source-architecture.js"; + +const withSourceFixture = ( + files: Readonly>, + runTest: (rootDirectory: string) => void, +): void => { + const rootDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-architecture-")); + try { + for (const [relativePath, sourceText] of Object.entries(files)) { + const filePath = path.join(rootDirectory, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, sourceText); + } + runTest(rootDirectory); + } finally { + fs.rmSync(rootDirectory, { recursive: true, force: true }); + } +}; + +describe("source architecture", () => { + it("reports deterministic runtime import components and leaves acyclic files out", () => { + withSourceFixture( + { + "src/a.ts": 'import { valueB } from "./b";\nexport const valueA = valueB;\n', + "src/b.ts": 'export { valueA } from "./a";\nexport const valueB = 1;\n', + "src/leaf.ts": "export const leaf = true;\n", + }, + (rootDirectory) => { + const result = analyzeSourceArchitecture({ + rootDirectory, + sourceDirectories: ["src"], + forbiddenDependencyRules: [], + }); + assert.deepEqual( + result.runtimeImportComponents.map((component) => + component.map((filePath) => path.relative(rootDirectory, filePath)), + ), + [[path.join("src", "a.ts"), path.join("src", "b.ts")]], + ); + }, + ); + }); + + it("excludes type-only and dynamic imports from runtime components", () => { + withSourceFixture( + { + "src/type-a.ts": + 'import type { TypeB } from "./type-b";\nexport interface TypeA extends TypeB {}\n', + "src/type-b.ts": + 'import { typeValue } from "./type-a";\nexport interface TypeB {}\nexport const value = typeValue;\n', + "src/lazy-a.ts": 'export const load = () => import("./lazy-b");\n', + "src/lazy-b.ts": 'import { load } from "./lazy-a";\nexport const loaded = load;\n', + }, + (rootDirectory) => { + const result = analyzeSourceArchitecture({ + rootDirectory, + sourceDirectories: ["src"], + forbiddenDependencyRules: [], + }); + assert.deepEqual(result.runtimeImportComponents, []); + assert.equal( + result.dependencies.some((dependency) => dependency.isTypeOnly), + true, + ); + assert.equal( + result.dependencies.some((dependency) => dependency.isDynamic), + true, + ); + }, + ); + }); + + it("resolves extensionless indexes and JavaScript specifiers to TypeScript sources", () => { + withSourceFixture( + { + "src/a.ts": 'import { feature } from "./feature?raw";\nexport const value = feature;\n', + "src/feature/index.ts": 'import { value } from "../a.js";\nexport const feature = value;\n', + }, + (rootDirectory) => { + const result = analyzeSourceArchitecture({ + rootDirectory, + sourceDirectories: ["src"], + forbiddenDependencyRules: [], + }); + assert.deepEqual( + result.runtimeImportComponents.map((component) => + component.map((filePath) => path.relative(rootDirectory, filePath)), + ), + [[path.join("src", "a.ts"), path.join("src", "feature", "index.ts")]], + ); + }, + ); + }); + + it("includes workspace package imports in runtime components", () => { + withSourceFixture( + { + "packages/a/package.json": + '{"name":"@fixture/a","exports":{".":{"default":"./dist/public.js"}}}', + "packages/a/src/public.ts": + 'import { valueB } from "@fixture/b";\nexport const valueA = valueB;\n', + "packages/b/package.json": '{"name":"@fixture/b"}', + "packages/b/src/index.ts": + 'import { valueA } from "@fixture/a";\nexport const valueB = valueA;\n', + }, + (rootDirectory) => { + const result = analyzeSourceArchitecture({ rootDirectory }); + assert.deepEqual( + result.runtimeImportComponents.map((component) => + component.map((filePath) => path.relative(rootDirectory, filePath)), + ), + [ + [ + path.join("packages", "a", "src", "public.ts"), + path.join("packages", "b", "src", "index.ts"), + ], + ], + ); + assert.deepEqual( + result.dependencies.map((dependency) => dependency.specifier), + ["@fixture/b", "@fixture/a"], + ); + assert.match( + formatSourceArchitectureFailures(rootDirectory, result), + /Runtime import SCC \(2 files\)/, + ); + }, + ); + }); + + it("applies layer rules across workspace package subpaths", () => { + withSourceFixture( + { + "packages/contracts/package.json": '{"name":"@fixture/contracts"}', + "packages/contracts/src/contracts.ts": + 'import type { CliState } from "@fixture/runtime/cli/state";\nexport const loadCli = () => import("@fixture/runtime/cli");\nexport interface Contract extends CliState {}\n', + "packages/runtime/package.json": + '{"name":"@fixture/runtime","exports":{"./cli":{"default":"./dist/cli/index.js"},"./cli/state":{"types":"./dist/cli/state.d.ts"}}}', + "packages/runtime/src/cli/index.ts": "export const cli = true;\n", + "packages/runtime/src/cli/state.ts": "export interface CliState {}\n", + }, + (rootDirectory) => { + const result = analyzeSourceArchitecture({ rootDirectory }); + assert.deepEqual( + result.forbiddenDependencies.map((dependency) => ({ + specifier: dependency.specifier, + line: dependency.line, + typeOnly: dependency.isTypeOnly, + dynamic: dependency.isDynamic, + })), + [ + { + specifier: "@fixture/runtime/cli", + line: 2, + typeOnly: false, + dynamic: true, + }, + { + specifier: "@fixture/runtime/cli/state", + line: 1, + typeOnly: true, + dynamic: false, + }, + ], + ); + }, + ); + }); + + it("rejects type-only and dynamic backward edges with actionable locations", () => { + withSourceFixture( + { + "packages/example/src/contracts.ts": + 'import type { CliState } from "./cli/state";\nexport const loadCli = () => import("./cli/index");\nexport interface Contract extends CliState {}\n', + "packages/example/src/cli/index.ts": "export const cli = true;\n", + "packages/example/src/cli/state.ts": "export interface CliState {}\n", + }, + (rootDirectory) => { + const result = analyzeSourceArchitecture({ rootDirectory }); + assert.equal(result.forbiddenDependencies.length, 2); + assert.deepEqual( + result.forbiddenDependencies.map((dependency) => ({ + line: dependency.line, + typeOnly: dependency.isTypeOnly, + dynamic: dependency.isDynamic, + rule: dependency.ruleName, + })), + [ + { line: 2, typeOnly: false, dynamic: true, rule: "neutral-foundations" }, + { line: 1, typeOnly: true, dynamic: false, rule: "neutral-foundations" }, + ], + ); + + const output = formatSourceArchitectureFailures(rootDirectory, result); + assert.match(output, /packages\/example\/src\/contracts\.ts:1/); + assert.match(output, /packages\/example\/src\/cli\/state\.ts/); + assert.match(output, /Foundation types, schemas, and errors must remain independent/); + }, + ); + }); +}); diff --git a/scripts/check-source-architecture.ts b/scripts/check-source-architecture.ts new file mode 100644 index 0000000000..0092e224ac --- /dev/null +++ b/scripts/check-source-architecture.ts @@ -0,0 +1,625 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseSync } from "oxc-parser"; +import { findStronglyConnectedComponents } from "../packages/deslop-js/src/utils/find-strongly-connected-components.js"; + +interface SourceDependency { + sourcePath: string; + targetPath: string; + specifier: string; + line: number; + isTypeOnly: boolean; + isDynamic: boolean; +} + +interface SourceParseFailure { + filePath: string; + message: string; +} + +export interface ForbiddenDependencyRule { + name: string; + sourcePathPatterns: ReadonlyArray; + forbiddenTargetPathPatterns: ReadonlyArray; + reason: string; +} + +export interface ForbiddenSourceDependency extends SourceDependency { + ruleName: string; + reason: string; +} + +export interface SourceArchitectureOptions { + rootDirectory: string; + sourceDirectories?: ReadonlyArray; + forbiddenDependencyRules?: ReadonlyArray; +} + +export interface SourceArchitectureResult { + sourceFileCount: number; + runtimeImportComponents: ReadonlyArray>; + dependencies: ReadonlyArray; + forbiddenDependencies: ReadonlyArray; + parseFailures: ReadonlyArray; +} + +interface ParsedDependency { + specifier: string; + line: number; + isTypeOnly: boolean; + isDynamic: boolean; +} + +interface WorkspaceSourcePackage { + readonly name: string; + readonly sourcePathBySubpath: ReadonlyMap; +} + +const SOURCE_FILE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]; +const TEST_FILE_MARKERS = [".spec.", ".stories.", ".test."]; +const TEST_DIRECTORY_NAMES = new Set([ + "__fixtures__", + "__tests__", + "fixtures", + "test", + "tests", + "test-utils", +]); +const JAVASCRIPT_SOURCE_ALTERNATIVES: Readonly>> = { + ".js": [".ts", ".tsx", ".js", ".jsx"], + ".jsx": [".tsx", ".jsx"], + ".mjs": [".mts", ".mjs"], + ".cjs": [".cts", ".cjs"], +}; +const EXPORT_CONDITION_PRIORITY = ["default", "import", "require", "types"]; + +const RUNTIME_LAYER_PATH_PATTERNS = [ + /\/src\/cli\//, + /\/src\/services\//, + /\/src\/runners\//, + /\/src\/(?:diagnose|editor-scan|inspect|instrument|lsp-telemetry|observability|run-inspect|run-oxlint)\.[cm]?[jt]sx?$/, +]; + +export const DEFAULT_FORBIDDEN_DEPENDENCY_RULES: ReadonlyArray = [ + { + name: "neutral-foundations", + sourcePathPatterns: [ + /\/src\/types\//, + /\/src\/contracts(?:\/|\.[cm]?[jt]sx?$)/, + /\/src\/schemas\.[cm]?[jt]sx?$/, + /\/src\/errors\.[cm]?[jt]sx?$/, + ], + forbiddenTargetPathPatterns: RUNTIME_LAYER_PATH_PATTERNS, + reason: + "Foundation types, schemas, and errors must remain independent of CLI, telemetry, services, runners, and orchestrators.", + }, + { + name: "project-discovery", + sourcePathPatterns: [/\/src\/project-info\//], + forbiddenTargetPathPatterns: RUNTIME_LAYER_PATH_PATTERNS, + reason: "Project discovery must remain below runtime services and orchestration.", + }, + { + name: "leaf-utilities", + sourcePathPatterns: [/\/src\/utils\//], + forbiddenTargetPathPatterns: RUNTIME_LAYER_PATH_PATTERNS, + reason: "Leaf utilities must not depend on runtime services, CLI, telemetry, or orchestration.", + }, +]; + +const toPosixPath = (filePath: string): string => filePath.split(path.sep).join("/"); + +const compareText = (leftText: string, rightText: string): number => { + if (leftText < rightText) return -1; + if (leftText > rightText) return 1; + return 0; +}; + +const isProductionSourceFile = (filePath: string, sourceDirectory: string): boolean => { + const extension = path.extname(filePath); + if (!SOURCE_FILE_EXTENSIONS.includes(extension)) return false; + if (filePath.endsWith(".d.ts")) return false; + + const relativePath = path.relative(sourceDirectory, filePath); + const pathSegments = relativePath.split(path.sep); + if (pathSegments.some((pathSegment) => TEST_DIRECTORY_NAMES.has(pathSegment))) return false; + + const fileName = path.basename(filePath); + return !TEST_FILE_MARKERS.some((testMarker) => fileName.includes(testMarker)); +}; + +const listProductionSourceFiles = (sourceDirectory: string): string[] => { + if (!fs.existsSync(sourceDirectory)) return []; + + const sourceFiles: string[] = []; + const pendingDirectories = [sourceDirectory]; + while (pendingDirectories.length > 0) { + const currentDirectory = pendingDirectories.pop(); + if (currentDirectory === undefined) break; + + const directoryEntries = fs + .readdirSync(currentDirectory, { withFileTypes: true }) + .sort((leftEntry, rightEntry) => compareText(leftEntry.name, rightEntry.name)); + for (const directoryEntry of directoryEntries) { + const entryPath = path.join(currentDirectory, directoryEntry.name); + if (directoryEntry.isDirectory()) { + if (!TEST_DIRECTORY_NAMES.has(directoryEntry.name)) pendingDirectories.push(entryPath); + } else if (directoryEntry.isFile() && isProductionSourceFile(entryPath, sourceDirectory)) { + sourceFiles.push(path.resolve(entryPath)); + } + } + } + + return sourceFiles.sort(); +}; + +const discoverPackageSourceDirectories = (rootDirectory: string): string[] => { + const packagesDirectory = path.join(rootDirectory, "packages"); + if (!fs.existsSync(packagesDirectory)) return []; + + return fs + .readdirSync(packagesDirectory, { withFileTypes: true }) + .filter((directoryEntry) => directoryEntry.isDirectory()) + .map((directoryEntry) => path.join(packagesDirectory, directoryEntry.name, "src")) + .filter((sourceDirectory) => fs.existsSync(sourceDirectory)) + .sort(); +}; + +const getLineNumber = (sourceText: string, offset: number): number => + sourceText.slice(0, offset).split("\n").length; + +const collectDynamicImportDependencies = ( + sourceText: string, + program: unknown, +): ParsedDependency[] => { + const dependencies: ParsedDependency[] = []; + const visitedNodes = new WeakSet(); + const visitNode = (node: unknown): void => { + if (!node || typeof node !== "object" || visitedNodes.has(node)) return; + visitedNodes.add(node); + + if (Reflect.get(node, "type") === "ImportExpression") { + const sourceNode = Reflect.get(node, "source"); + if ( + sourceNode && + typeof sourceNode === "object" && + Reflect.get(sourceNode, "type") === "Literal" + ) { + const specifier = Reflect.get(sourceNode, "value"); + const startOffset = Reflect.get(node, "start"); + if (typeof specifier === "string" && typeof startOffset === "number") { + dependencies.push({ + specifier, + line: getLineNumber(sourceText, startOffset), + isTypeOnly: false, + isDynamic: true, + }); + } + } + } + + for (const childNode of Object.values(node)) { + if (Array.isArray(childNode)) { + for (const arrayChildNode of childNode) visitNode(arrayChildNode); + } else { + visitNode(childNode); + } + } + }; + + visitNode(program); + return dependencies; +}; + +const parseDependencies = ( + filePath: string, +): { dependencies: ParsedDependency[]; parseFailures: SourceParseFailure[] } => { + const sourceText = fs.readFileSync(filePath, "utf8"); + try { + const parseResult = parseSync(filePath, sourceText, { astType: "ts" }); + const parseFailures = parseResult.errors + .filter((parseError) => parseError.severity === "Error") + .map((parseError) => ({ + filePath, + message: parseError.message, + })); + const dependencies: ParsedDependency[] = parseResult.module.staticImports.map( + (staticImport) => ({ + specifier: staticImport.moduleRequest.value, + line: getLineNumber(sourceText, staticImport.start), + isTypeOnly: + staticImport.entries.length > 0 && + staticImport.entries.every((importEntry) => importEntry.isType), + isDynamic: false, + }), + ); + + const reExportDependencies = new Map(); + for (const staticExport of parseResult.module.staticExports) { + for (const exportEntry of staticExport.entries) { + const specifier = exportEntry.moduleRequest?.value; + if (specifier === undefined) continue; + + const existingDependency = reExportDependencies.get(specifier); + if (existingDependency === undefined) { + reExportDependencies.set(specifier, { + specifier, + line: getLineNumber(sourceText, exportEntry.start), + isTypeOnly: exportEntry.isType, + isDynamic: false, + }); + } else if (!exportEntry.isType) { + existingDependency.isTypeOnly = false; + } + } + } + + dependencies.push( + ...reExportDependencies.values(), + ...collectDynamicImportDependencies(sourceText, parseResult.program), + ); + return { dependencies, parseFailures }; + } catch (parseError) { + return { + dependencies: [], + parseFailures: [ + { + filePath, + message: parseError instanceof Error ? parseError.message : String(parseError), + }, + ], + }; + } +}; + +const resolveSourcePath = ( + unresolvedPath: string, + sourceFilePaths: ReadonlySet, +): string | undefined => { + const importedExtension = path.extname(unresolvedPath); + const candidatePaths: string[] = []; + if (importedExtension === "") { + for (const sourceExtension of SOURCE_FILE_EXTENSIONS) { + candidatePaths.push(`${unresolvedPath}${sourceExtension}`); + } + for (const sourceExtension of SOURCE_FILE_EXTENSIONS) { + candidatePaths.push(path.join(unresolvedPath, `index${sourceExtension}`)); + } + } else { + const sourceAlternatives = JAVASCRIPT_SOURCE_ALTERNATIVES[importedExtension]; + if (sourceAlternatives !== undefined) { + const pathWithoutExtension = unresolvedPath.slice(0, -importedExtension.length); + for (const sourceExtension of sourceAlternatives) { + candidatePaths.push(`${pathWithoutExtension}${sourceExtension}`); + } + } else { + candidatePaths.push(unresolvedPath); + } + } + + return candidatePaths.find((candidatePath) => sourceFilePaths.has(candidatePath)); +}; + +const resolveRelativeSourcePath = ( + sourceFilePath: string, + specifier: string, + sourceFilePaths: ReadonlySet, +): string | undefined => { + if (!specifier.startsWith("./") && !specifier.startsWith("../")) return undefined; + if (specifier.includes("*")) return undefined; + + const pathSpecifier = specifier.split(/[?#]/, 1)[0]; + return resolveSourcePath( + path.resolve(path.dirname(sourceFilePath), pathSpecifier), + sourceFilePaths, + ); +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const resolveExportTarget = (exportValue: unknown): string | undefined => { + if (typeof exportValue === "string") return exportValue; + if (!isRecord(exportValue)) return undefined; + + for (const condition of EXPORT_CONDITION_PRIORITY) { + const conditionTarget = resolveExportTarget(exportValue[condition]); + if (conditionTarget !== undefined) return conditionTarget; + } + return undefined; +}; + +const resolvePackageTargetSourcePath = ( + packageDirectory: string, + sourceDirectory: string, + target: string, + sourceFilePaths: ReadonlySet, +): string | undefined => { + if (target.startsWith("./src/")) { + return resolveSourcePath(path.resolve(packageDirectory, target), sourceFilePaths); + } + if (!target.startsWith("./dist/")) return undefined; + + const sourceSubpath = target + .slice("./dist/".length) + .replace(/\.d\.[cm]?ts$/, "") + .replace(/\.[cm]?[jt]sx?$/, ""); + return resolveSourcePath(path.join(sourceDirectory, sourceSubpath), sourceFilePaths); +}; + +const discoverWorkspaceSourcePackages = ( + sourceDirectories: ReadonlyArray, + sourceFilePaths: ReadonlySet, +): ReadonlyArray => + sourceDirectories + .flatMap((sourceDirectory) => { + const packageDirectory = path.dirname(sourceDirectory); + const packageManifestPath = path.join(packageDirectory, "package.json"); + if (!fs.existsSync(packageManifestPath)) return []; + + const manifest = JSON.parse(fs.readFileSync(packageManifestPath, "utf8")); + if (typeof manifest.name !== "string" || manifest.name.length === 0) return []; + + const sourcePathBySubpath = new Map(); + const exportEntries = isRecord(manifest.exports) + ? Object.entries(manifest.exports).filter( + ([exportKey]) => exportKey === "." || exportKey.startsWith("./"), + ) + : []; + if (exportEntries.length > 0) { + for (const [exportKey, exportValue] of exportEntries) { + const target = resolveExportTarget(exportValue); + if (target === undefined) continue; + const sourcePath = resolvePackageTargetSourcePath( + packageDirectory, + sourceDirectory, + target, + sourceFilePaths, + ); + if (sourcePath !== undefined) { + sourcePathBySubpath.set(exportKey === "." ? "" : exportKey.slice(2), sourcePath); + } + } + } else { + const rootTarget = resolveExportTarget(manifest.exports); + if (rootTarget !== undefined) { + const sourcePath = resolvePackageTargetSourcePath( + packageDirectory, + sourceDirectory, + rootTarget, + sourceFilePaths, + ); + if (sourcePath !== undefined) sourcePathBySubpath.set("", sourcePath); + } + } + + const fallbackRootSourcePath = resolveSourcePath( + path.join(sourceDirectory, "index"), + sourceFilePaths, + ); + if (fallbackRootSourcePath !== undefined && !sourcePathBySubpath.has("")) { + sourcePathBySubpath.set("", fallbackRootSourcePath); + } + return [{ name: manifest.name, sourcePathBySubpath }]; + }) + .sort((leftPackage, rightPackage) => compareText(leftPackage.name, rightPackage.name)); + +const resolveWorkspaceSourcePath = ( + specifier: string, + workspacePackages: ReadonlyArray, + sourceFilePaths: ReadonlySet, +): string | undefined => { + const workspacePackage = workspacePackages.find( + (candidatePackage) => + specifier === candidatePackage.name || specifier.startsWith(`${candidatePackage.name}/`), + ); + if (workspacePackage === undefined) return undefined; + + const packageSubpath = + specifier === workspacePackage.name ? "" : specifier.slice(workspacePackage.name.length + 1); + const exportedSourcePath = workspacePackage.sourcePathBySubpath.get(packageSubpath); + if (exportedSourcePath !== undefined) return exportedSourcePath; + + const packageRootSourcePath = workspacePackage.sourcePathBySubpath.get(""); + if (packageRootSourcePath === undefined || packageSubpath.length === 0) return undefined; + return resolveSourcePath( + path.join(path.dirname(packageRootSourcePath), packageSubpath), + sourceFilePaths, + ); +}; + +const matchesAnyPattern = (filePath: string, patterns: ReadonlyArray): boolean => + patterns.some((pattern) => pattern.test(toPosixPath(filePath))); + +const findForbiddenDependencies = ( + dependencies: ReadonlyArray, + rules: ReadonlyArray, +): ForbiddenSourceDependency[] => { + const forbiddenDependencies = new Map(); + for (const dependency of dependencies) { + for (const rule of rules) { + if (!matchesAnyPattern(dependency.sourcePath, rule.sourcePathPatterns)) continue; + if (!matchesAnyPattern(dependency.targetPath, rule.forbiddenTargetPathPatterns)) continue; + + const dependencyKey = `${rule.name}\0${dependency.sourcePath}\0${dependency.targetPath}`; + const existingDependency = forbiddenDependencies.get(dependencyKey); + if (existingDependency === undefined || dependency.line < existingDependency.line) { + forbiddenDependencies.set(dependencyKey, { + ...dependency, + ruleName: rule.name, + reason: rule.reason, + }); + } + } + } + + return [...forbiddenDependencies.values()].sort( + (leftDependency, rightDependency) => + compareText(leftDependency.sourcePath, rightDependency.sourcePath) || + compareText(leftDependency.targetPath, rightDependency.targetPath) || + compareText(leftDependency.ruleName, rightDependency.ruleName), + ); +}; + +const findRuntimeImportComponents = ( + sourceFilePaths: ReadonlyArray, + dependencies: ReadonlyArray, +): string[][] => { + const sourceFileIndexByPath = new Map( + sourceFilePaths.map((sourceFilePath, sourceFileIndex) => [sourceFilePath, sourceFileIndex]), + ); + const runtimeTargetSets = sourceFilePaths.map(() => new Set()); + for (const dependency of dependencies) { + if (dependency.isTypeOnly || dependency.isDynamic) continue; + const sourceFileIndex = sourceFileIndexByPath.get(dependency.sourcePath); + const targetFileIndex = sourceFileIndexByPath.get(dependency.targetPath); + if (sourceFileIndex === undefined || targetFileIndex === undefined) continue; + runtimeTargetSets[sourceFileIndex].add(targetFileIndex); + } + + const adjacencyList = runtimeTargetSets.map((targetSet) => + [...targetSet].sort((leftIndex, rightIndex) => leftIndex - rightIndex), + ); + return findStronglyConnectedComponents(adjacencyList) + .filter( + (component) => + component.length > 1 || + (component.length === 1 && adjacencyList[component[0]].includes(component[0])), + ) + .map((component) => component.map((sourceFileIndex) => sourceFilePaths[sourceFileIndex]).sort()) + .sort((leftComponent, rightComponent) => compareText(leftComponent[0], rightComponent[0])); +}; + +export const analyzeSourceArchitecture = ( + options: SourceArchitectureOptions, +): SourceArchitectureResult => { + const rootDirectory = path.resolve(options.rootDirectory); + const sourceDirectories = ( + options.sourceDirectories?.map((sourceDirectory) => + path.resolve(rootDirectory, sourceDirectory), + ) ?? discoverPackageSourceDirectories(rootDirectory) + ).sort(); + const sourceFilePaths = [...new Set(sourceDirectories.flatMap(listProductionSourceFiles))].sort(); + const sourceFilePathSet = new Set(sourceFilePaths); + const workspacePackages = discoverWorkspaceSourcePackages(sourceDirectories, sourceFilePathSet); + const dependencies: SourceDependency[] = []; + const parseFailures: SourceParseFailure[] = []; + + for (const sourceFilePath of sourceFilePaths) { + const parsedSource = parseDependencies(sourceFilePath); + parseFailures.push(...parsedSource.parseFailures); + for (const parsedDependency of parsedSource.dependencies) { + const targetFilePath = + resolveRelativeSourcePath(sourceFilePath, parsedDependency.specifier, sourceFilePathSet) ?? + resolveWorkspaceSourcePath( + parsedDependency.specifier, + workspacePackages, + sourceFilePathSet, + ); + if (targetFilePath === undefined) continue; + dependencies.push({ + sourcePath: sourceFilePath, + targetPath: targetFilePath, + specifier: parsedDependency.specifier, + line: parsedDependency.line, + isTypeOnly: parsedDependency.isTypeOnly, + isDynamic: parsedDependency.isDynamic, + }); + } + } + + dependencies.sort( + (leftDependency, rightDependency) => + compareText(leftDependency.sourcePath, rightDependency.sourcePath) || + leftDependency.line - rightDependency.line || + compareText(leftDependency.targetPath, rightDependency.targetPath), + ); + + return { + sourceFileCount: sourceFilePaths.length, + runtimeImportComponents: findRuntimeImportComponents(sourceFilePaths, dependencies), + dependencies, + forbiddenDependencies: findForbiddenDependencies( + dependencies, + options.forbiddenDependencyRules ?? DEFAULT_FORBIDDEN_DEPENDENCY_RULES, + ), + parseFailures: parseFailures.sort((leftFailure, rightFailure) => + compareText(leftFailure.filePath, rightFailure.filePath), + ), + }; +}; + +const formatRepositoryPath = (rootDirectory: string, filePath: string): string => + toPosixPath(path.relative(rootDirectory, filePath)); + +export const formatSourceArchitectureFailures = ( + rootDirectory: string, + result: SourceArchitectureResult, +): string => { + const failureGroups: string[] = []; + + for (const component of result.runtimeImportComponents) { + const componentPathSet = new Set(component); + const componentEdges = result.dependencies.filter( + (dependency) => + !dependency.isTypeOnly && + !dependency.isDynamic && + componentPathSet.has(dependency.sourcePath) && + componentPathSet.has(dependency.targetPath), + ); + failureGroups.push( + [ + `Runtime import SCC (${component.length} files):`, + ...component.map((filePath) => ` - ${formatRepositoryPath(rootDirectory, filePath)}`), + " Internal edges:", + ...componentEdges.map( + (dependency) => + ` - ${formatRepositoryPath(rootDirectory, dependency.sourcePath)}:${dependency.line} -> ${formatRepositoryPath(rootDirectory, dependency.targetPath)}`, + ), + " Break at least one runtime edge or move the shared dependency below the component.", + ].join("\n"), + ); + } + + for (const dependency of result.forbiddenDependencies) { + failureGroups.push( + [ + `Forbidden backward edge (${dependency.ruleName}):`, + ` ${formatRepositoryPath(rootDirectory, dependency.sourcePath)}:${dependency.line} -> ${formatRepositoryPath(rootDirectory, dependency.targetPath)}`, + ` ${dependency.reason}`, + ].join("\n"), + ); + } + + for (const parseFailure of result.parseFailures) { + failureGroups.push( + [ + "Unable to verify source architecture:", + ` ${formatRepositoryPath(rootDirectory, parseFailure.filePath)}`, + ` ${parseFailure.message}`, + ].join("\n"), + ); + } + + return failureGroups.join("\n\n"); +}; + +const SCRIPT_FILE_PATH = fileURLToPath(import.meta.url); +const runSourceArchitectureCheck = (): void => { + const rootDirectory = path.resolve(path.dirname(SCRIPT_FILE_PATH), ".."); + const result = analyzeSourceArchitecture({ rootDirectory }); + const failureCount = + result.runtimeImportComponents.length + + result.forbiddenDependencies.length + + result.parseFailures.length; + process.stdout.write( + `Source architecture: ${result.sourceFileCount} files, ${failureCount} violations.\n`, + ); + if (failureCount === 0) return; + + process.stderr.write(`${formatSourceArchitectureFailures(rootDirectory, result)}\n`); + process.exitCode = 1; +}; + +if (path.resolve(process.argv[1] ?? "") === SCRIPT_FILE_PATH) runSourceArchitectureCheck(); diff --git a/scripts/compatibility/approved-deltas.json b/scripts/compatibility/approved-deltas.json new file mode 100644 index 0000000000..556dd47939 --- /dev/null +++ b/scripts/compatibility/approved-deltas.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "deltas": [] +} diff --git a/scripts/compatibility/check-approved-deltas.ts b/scripts/compatibility/check-approved-deltas.ts new file mode 100644 index 0000000000..5e6513faa3 --- /dev/null +++ b/scripts/compatibility/check-approved-deltas.ts @@ -0,0 +1,79 @@ +import { fileURLToPath } from "node:url"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +interface CompatibilityDelta { + readonly id: string; + readonly owner: string; + readonly scope: string; + readonly rationale: string; + readonly observedDifference: string; + readonly expiryCondition: string; + readonly removalIssue: string; +} + +interface CompatibilityDeltaLedger { + readonly schemaVersion: number; + readonly deltas: ReadonlyArray; +} + +const COMPATIBILITY_DELTA_SCHEMA_VERSION = 1; +const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url)); +const REPOSITORY_ROOT = path.resolve(SCRIPT_DIRECTORY, "../.."); +const LEDGER_PATH = path.join(SCRIPT_DIRECTORY, "approved-deltas.json"); + +const isNonEmptyString = (value: unknown): value is string => + typeof value === "string" && value.trim().length > 0; + +const isCompatibilityDelta = (value: unknown): value is CompatibilityDelta => { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + + return ( + "id" in value && + isNonEmptyString(value.id) && + "owner" in value && + isNonEmptyString(value.owner) && + "scope" in value && + isNonEmptyString(value.scope) && + "rationale" in value && + isNonEmptyString(value.rationale) && + "observedDifference" in value && + isNonEmptyString(value.observedDifference) && + "expiryCondition" in value && + isNonEmptyString(value.expiryCondition) && + "removalIssue" in value && + isNonEmptyString(value.removalIssue) + ); +}; + +const isCompatibilityDeltaLedger = (value: unknown): value is CompatibilityDeltaLedger => { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + if (!("schemaVersion" in value) || value.schemaVersion !== COMPATIBILITY_DELTA_SCHEMA_VERSION) { + return false; + } + if (!("deltas" in value) || !Array.isArray(value.deltas)) return false; + return value.deltas.every(isCompatibilityDelta); +}; + +if (!fs.existsSync(LEDGER_PATH)) { + console.error(`Missing ${path.relative(REPOSITORY_ROOT, LEDGER_PATH)}.`); + process.exit(1); +} + +const ledger: unknown = JSON.parse(fs.readFileSync(LEDGER_PATH, "utf8")); +if (!isCompatibilityDeltaLedger(ledger)) { + console.error("Invalid compatibility delta ledger."); + process.exit(1); +} + +const duplicateIdentifiers = ledger.deltas + .map(({ id }) => id) + .filter((identifier, index, identifiers) => identifiers.indexOf(identifier) !== index); +if (duplicateIdentifiers.length > 0) { + console.error( + `Duplicate compatibility delta IDs: ${[...new Set(duplicateIdentifiers)].join(", ")}`, + ); + process.exit(1); +} + +console.log(`Compatibility delta ledger is valid with ${ledger.deltas.length} active deltas.`); diff --git a/scripts/compatibility/check-public-packages.ts b/scripts/compatibility/check-public-packages.ts new file mode 100644 index 0000000000..51eacacb58 --- /dev/null +++ b/scripts/compatibility/check-public-packages.ts @@ -0,0 +1,226 @@ +import { fileURLToPath } from "node:url"; +import { isDeepStrictEqual } from "node:util"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { + findPublishedPackageManifests, + type PackageManifest, +} from "../utils/find-published-package-manifests.ts"; +import { CLI_HELP_INVOCATIONS } from "./cli-help-invocations.ts"; +import type { + CliHelpSnapshotEntry, + PackedPublicEntryPointSnapshot, +} from "./public-package-snapshot-types.ts"; +import { readPackageExportValue } from "../utils/read-package-export-value.ts"; + +const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url)); +const REPOSITORY_ROOT = path.resolve(SCRIPT_DIRECTORY, "../.."); +const PACKAGES_DIRECTORY = path.join(REPOSITORY_ROOT, "packages"); +const SNAPSHOT_DIRECTORY = path.join(SCRIPT_DIRECTORY, "snapshots"); +const PUBLIC_PACKAGES_SNAPSHOT_PATH = path.join(SNAPSHOT_DIRECTORY, "public-packages.json"); +const CLI_HELP_SNAPSHOT_PATH = path.join(SNAPSHOT_DIRECTORY, "cli-help.json"); +const PACKED_ENTRY_SNAPSHOT_PATH = path.join(SNAPSHOT_DIRECTORY, "packed-public-entry-points.json"); + +interface PublicPackageSnapshot { + readonly name: string; + readonly directory: string; + readonly type: string | null; + readonly main: string | null; + readonly module: string | null; + readonly types: string | null; + readonly bin: PackageManifest["bin"] | null; + readonly exports: unknown; + readonly files: ReadonlyArray; + readonly sideEffects: PackageManifest["sideEffects"] | null; + readonly engines: PackageManifest["engines"] | null; + readonly peerDependencies: PackageManifest["peerDependencies"] | null; +} + +interface PackageExportsSnapshot { + readonly exports?: unknown; +} + +const buildPublicPackageSnapshots = (): ReadonlyArray => + findPublishedPackageManifests(PACKAGES_DIRECTORY).map(({ directory, manifest }) => ({ + name: manifest.name, + directory: path.relative(REPOSITORY_ROOT, directory).split(path.sep).join("/"), + type: manifest.type ?? null, + main: manifest.main ?? null, + module: manifest.module ?? null, + types: manifest.types ?? null, + bin: manifest.bin ?? null, + exports: manifest.exports ?? null, + files: manifest.files ?? [], + sideEffects: manifest.sideEffects ?? null, + engines: manifest.engines ?? null, + peerDependencies: manifest.peerDependencies ?? null, + })); + +const serializeSnapshots = (snapshots: ReadonlyArray): string => + `${JSON.stringify(snapshots, null, 2)}\n`; + +const readJson = (filePath: string): Value => JSON.parse(fs.readFileSync(filePath, "utf8")); + +const collectExportSubpaths = (manifest: PackageExportsSnapshot): string[] => { + if (manifest.exports === undefined || manifest.exports === null) return []; + if ( + typeof manifest.exports !== "object" || + manifest.exports === null || + Array.isArray(manifest.exports) + ) { + return ["."]; + } + + const exportKeys = Object.keys(manifest.exports); + const subpaths = exportKeys.filter((exportKey) => exportKey.startsWith(".")); + return subpaths.length > 0 ? subpaths.sort() : ["."]; +}; + +const collectRuntimeModes = (exportValue: unknown): Array<"import" | "require"> => { + if (typeof exportValue === "string") return ["import"]; + if (typeof exportValue !== "object" || exportValue === null || Array.isArray(exportValue)) { + return []; + } + + const runtimeModes = new Set<"import" | "require">(); + for (const [condition, conditionValue] of Object.entries(exportValue)) { + if (condition === "import") runtimeModes.add("import"); + else if (condition === "require") runtimeModes.add("require"); + else if (condition === "default") runtimeModes.add("import"); + else if (condition !== "types") { + for (const nestedRuntimeMode of collectRuntimeModes(conditionValue)) { + runtimeModes.add(nestedRuntimeMode); + } + } + } + return [...runtimeModes].sort(); +}; + +const validatePackedSnapshotCoverage = ( + packageSnapshots: ReadonlyArray, +): void => { + for (const snapshotPath of [CLI_HELP_SNAPSHOT_PATH, PACKED_ENTRY_SNAPSHOT_PATH]) { + if (!fs.existsSync(snapshotPath)) { + console.error(`Missing ${path.relative(REPOSITORY_ROOT, snapshotPath)}.`); + process.exit(1); + } + } + + const cliHelpSnapshot = readJson>(CLI_HELP_SNAPSHOT_PATH); + const expectedHelpInvocations = CLI_HELP_INVOCATIONS.map( + ({ name, arguments: argumentsList }) => ({ + name, + arguments: argumentsList, + }), + ); + const snapshotHelpInvocations = cliHelpSnapshot.map(({ name, arguments: argumentsList }) => ({ + name, + arguments: argumentsList, + })); + if (!isDeepStrictEqual(snapshotHelpInvocations, expectedHelpInvocations)) { + console.error("CLI help snapshot does not cover the reviewed command and alias inventory."); + process.exit(1); + } + + const packedSnapshot = readJson(PACKED_ENTRY_SNAPSHOT_PATH); + const expectedEntries = packageSnapshots + .flatMap((packageSnapshot) => + collectExportSubpaths(packageSnapshot).map((subpath) => ({ + packageName: packageSnapshot.name, + subpath, + })), + ) + .sort((leftEntry, rightEntry) => { + const leftKey = `${leftEntry.packageName}\0${leftEntry.subpath}`; + const rightKey = `${rightEntry.packageName}\0${rightEntry.subpath}`; + if (leftKey < rightKey) return -1; + if (leftKey > rightKey) return 1; + return 0; + }); + const snapshotEntries = packedSnapshot.entries + .map(({ packageName, subpath }) => ({ packageName, subpath })) + .sort((leftEntry, rightEntry) => { + const leftKey = `${leftEntry.packageName}\0${leftEntry.subpath}`; + const rightKey = `${rightEntry.packageName}\0${rightEntry.subpath}`; + if (leftKey < rightKey) return -1; + if (leftKey > rightKey) return 1; + return 0; + }); + if (!isDeepStrictEqual(snapshotEntries, expectedEntries)) { + console.error("Packed runtime entry baseline does not cover every published export subpath."); + process.exit(1); + } + const packageSnapshotByName = new Map( + packageSnapshots.map((packageSnapshot) => [packageSnapshot.name, packageSnapshot]), + ); + for (const entrySnapshot of packedSnapshot.entries) { + if (entrySnapshot.executionOnly === true) { + if (entrySnapshot.exportKeys !== undefined) { + console.error( + `Execution-only entry ${entrySnapshot.packageName}${entrySnapshot.subpath} must not pin runtime export keys.`, + ); + process.exit(1); + } + continue; + } + if (entrySnapshot.exportKeys === undefined) { + console.error( + `Library entry ${entrySnapshot.packageName}${entrySnapshot.subpath} is missing runtime export keys.`, + ); + process.exit(1); + } + const packageSnapshot = packageSnapshotByName.get(entrySnapshot.packageName); + const exportValue = readPackageExportValue(packageSnapshot?.exports, entrySnapshot.subpath); + const expectedRuntimeModes = collectRuntimeModes(exportValue); + const snapshotRuntimeModes = Object.keys(entrySnapshot.exportKeys).sort(); + if (!isDeepStrictEqual(snapshotRuntimeModes, expectedRuntimeModes)) { + console.error( + `Runtime export baseline modes do not match ${entrySnapshot.packageName}${entrySnapshot.subpath}.`, + ); + process.exit(1); + } + } + + const expectedPackageNames = packageSnapshots.map(({ name }) => name).sort(); + const policyPackageNames = packedSnapshot.filePolicies + .map(({ packageName }) => packageName) + .sort(); + if (!isDeepStrictEqual(policyPackageNames, expectedPackageNames)) { + console.error("Packed file policies do not cover every published package."); + process.exit(1); + } +}; + +const argumentsList = process.argv.slice(2); +const shouldUpdate = argumentsList.length === 1 && argumentsList[0] === "--update"; +if (argumentsList.length > 0 && !shouldUpdate) { + console.error("Usage: node scripts/compatibility/check-public-packages.ts [--update]"); + process.exit(1); +} + +const currentSnapshots = buildPublicPackageSnapshots(); + +if (shouldUpdate) { + fs.mkdirSync(path.dirname(PUBLIC_PACKAGES_SNAPSHOT_PATH), { recursive: true }); + fs.writeFileSync(PUBLIC_PACKAGES_SNAPSHOT_PATH, serializeSnapshots(currentSnapshots)); + console.log(`Updated ${path.relative(REPOSITORY_ROOT, PUBLIC_PACKAGES_SNAPSHOT_PATH)}.`); + process.exit(0); +} + +if (!fs.existsSync(PUBLIC_PACKAGES_SNAPSHOT_PATH)) { + console.error(`Missing ${path.relative(REPOSITORY_ROOT, PUBLIC_PACKAGES_SNAPSHOT_PATH)}.`); + console.error("Run `nr compatibility:update` after reviewing the public surface."); + process.exit(1); +} + +const expectedSnapshots = JSON.parse(fs.readFileSync(PUBLIC_PACKAGES_SNAPSHOT_PATH, "utf8")); +if (!isDeepStrictEqual(currentSnapshots, expectedSnapshots)) { + console.error("Published package compatibility snapshot drift detected."); + console.error( + "Review the package surface, then run `nr compatibility:update` if it is intentional.", + ); + process.exit(1); +} + +validatePackedSnapshotCoverage(currentSnapshots); +console.log(`Public package compatibility matches for ${currentSnapshots.length} packages.`); diff --git a/scripts/compatibility/cli-help-invocations.ts b/scripts/compatibility/cli-help-invocations.ts new file mode 100644 index 0000000000..46d004704d --- /dev/null +++ b/scripts/compatibility/cli-help-invocations.ts @@ -0,0 +1,35 @@ +export interface CliHelpInvocation { + readonly name: string; + readonly arguments: ReadonlyArray; +} + +export const CLI_HELP_INVOCATIONS: ReadonlyArray = [ + { name: "root", arguments: ["--help"] }, + { name: "design", arguments: ["design", "--help"] }, + { name: "why", arguments: ["why", "--help"] }, + { name: "install", arguments: ["install", "--help"] }, + { name: "setup-alias", arguments: ["setup", "--help"] }, + { name: "ci", arguments: ["ci", "--help"] }, + { name: "ci-install", arguments: ["ci", "install", "--help"] }, + { name: "ci-config", arguments: ["ci", "config", "--help"] }, + { name: "ci-upgrade", arguments: ["ci", "upgrade", "--help"] }, + { name: "ci-help-alias", arguments: ["ci", "help"] }, + { name: "version", arguments: ["version", "--help"] }, + { name: "rules", arguments: ["rules", "--help"] }, + { name: "rules-list", arguments: ["rules", "list", "--help"] }, + { name: "rules-explain", arguments: ["rules", "explain", "--help"] }, + { name: "rules-set", arguments: ["rules", "set", "--help"] }, + { name: "rules-enable", arguments: ["rules", "enable", "--help"] }, + { name: "rules-disable", arguments: ["rules", "disable", "--help"] }, + { name: "rules-category", arguments: ["rules", "category", "--help"] }, + { name: "rules-ignore-tag", arguments: ["rules", "ignore-tag", "--help"] }, + { name: "rules-unignore-tag", arguments: ["rules", "unignore-tag", "--help"] }, + { name: "rules-help-alias", arguments: ["rules", "help"] }, + { name: "experimental-lsp", arguments: ["experimental-lsp", "--help"] }, + { name: "experimental-tui", arguments: ["experimental-tui", "--help"] }, + { name: "help-alias", arguments: ["help"] }, + { name: "help-install-alias", arguments: ["help", "install"] }, + { name: "help-setup-alias", arguments: ["help", "setup"] }, + { name: "legacy-diff-flag", arguments: ["--diff=false", "--help"] }, + { name: "legacy-fail-on-flag", arguments: ["--fail-on=none", "--help"] }, +]; diff --git a/scripts/compatibility/cli-help.test.ts b/scripts/compatibility/cli-help.test.ts new file mode 100644 index 0000000000..d265e5d3be --- /dev/null +++ b/scripts/compatibility/cli-help.test.ts @@ -0,0 +1,43 @@ +import * as assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { normalizeCliHelp } from "./normalize-cli-help.ts"; +import { parseHelpCommandAliases } from "./parse-help-command-aliases.ts"; + +describe("CLI help compatibility", () => { + it("normalizes only ANSI, line endings, the working directory, and version", () => { + const output = + '\u001B[2mUsage:\u001B[22m tool 1.2.3\r\n --cwd working directory (default: "C:\\repo\\project")\r\n' + + " with wrapped text\r\n"; + + assert.equal( + normalizeCliHelp(output, "C:\\repo\\project", "1.2.3"), + 'Usage: tool \n --cwd working directory (default: "")\n' + + " with wrapped text\n", + ); + }); + + it("normalizes Windows wrapping around the dynamic working directory", () => { + const output = + ' -c, --cwd working directory (default:\r\n "C:\\Users\\runner\\AppData\\Local\\Temp")\r\n'; + + assert.equal( + normalizeCliHelp(output, "C:\\Users\\RUNNER~1\\AppData\\Local\\Temp"), + ' -c, --cwd working directory (default: "")\n', + ); + }); + + it("extracts canonical commands and aliases without wrapped descriptions", () => { + const output = `Usage: tool [command] + +Commands: + install|setup [options] Install the integration + with a wrapped description + rules Configure rules + +Examples: + tool rules +`; + + assert.deepEqual(parseHelpCommandAliases(output), ["install", "setup", "rules"]); + }); +}); diff --git a/scripts/compatibility/normalize-cli-help.ts b/scripts/compatibility/normalize-cli-help.ts new file mode 100644 index 0000000000..82438ffeeb --- /dev/null +++ b/scripts/compatibility/normalize-cli-help.ts @@ -0,0 +1,20 @@ +import { stripVTControlCharacters } from "node:util"; + +export const normalizeCliHelp = ( + output: string, + workingDirectory: string, + version?: string, +): string => { + const normalizedWorkingDirectory = workingDirectory.replaceAll("\\", "/"); + const normalizedOutput = stripVTControlCharacters(output) + .replace(/\r\n?/g, "\n") + .replaceAll(workingDirectory, "") + .replaceAll(normalizedWorkingDirectory, "") + .replace( + /working\s+directory\s+\(default:\s+"[^"]*"\)/g, + 'working directory (default: "")', + ); + return version === undefined + ? normalizedOutput + : normalizedOutput.replaceAll(version, ""); +}; diff --git a/scripts/compatibility/parse-help-command-aliases.ts b/scripts/compatibility/parse-help-command-aliases.ts new file mode 100644 index 0000000000..afd545b1fc --- /dev/null +++ b/scripts/compatibility/parse-help-command-aliases.ts @@ -0,0 +1,13 @@ +export const parseHelpCommandAliases = (helpOutput: string): string[] => { + const commandsSection = helpOutput.match(/(?:^|\n)Commands:\n([\s\S]*?)(?:\n\n|$)/); + if (commandsSection === null) return []; + + return commandsSection[1] + .split("\n") + .flatMap((line) => { + const commandSignature = line.match(/^ (\S+)/)?.[1]; + if (commandSignature === undefined) return []; + return commandSignature.split("|").map((commandName) => commandName.replace(/\[.*$/, "")); + }) + .filter(Boolean); +}; diff --git a/scripts/compatibility/public-package-snapshot-types.ts b/scripts/compatibility/public-package-snapshot-types.ts new file mode 100644 index 0000000000..aa87b78aeb --- /dev/null +++ b/scripts/compatibility/public-package-snapshot-types.ts @@ -0,0 +1,27 @@ +export interface CliHelpSnapshotEntry { + readonly name: string; + readonly arguments: ReadonlyArray; + readonly output: string; +} + +export interface PackedEntrySnapshot { + readonly packageName: string; + readonly subpath: string; + readonly executionOnly?: boolean; + readonly exportKeys?: { + readonly import?: ReadonlyArray; + readonly require?: ReadonlyArray; + }; +} + +export interface PackedFilePolicy { + readonly packageName: string; + readonly requiredFiles: ReadonlyArray; + readonly allowedPatterns: ReadonlyArray; + readonly deniedPatterns: ReadonlyArray; +} + +export interface PackedPublicEntryPointSnapshot { + readonly entries: ReadonlyArray; + readonly filePolicies: ReadonlyArray; +} diff --git a/scripts/compatibility/snapshots/cli-help.json b/scripts/compatibility/snapshots/cli-help.json new file mode 100644 index 0000000000..2274175567 --- /dev/null +++ b/scripts/compatibility/snapshots/cli-help.json @@ -0,0 +1,142 @@ +[ + { + "name": "root", + "arguments": ["--help"], + "output": "Usage: react-doctor [options] [command] [directory]\n\nDiagnose React codebase health\n\nArguments:\n directory project directory to scan (default: \".\")\n\nOptions:\n -v, --version display the version number\n --lint enable linting\n --no-lint skip linting\n --dead-code enable dead-code analysis (default)\n --no-dead-code skip dead-code analysis (unused files / exports\n / dependencies, circular imports)\n --supply-chain enable the dependency supply-chain scan\n (default)\n --no-supply-chain skip the dependency supply-chain scan\n (Socket.dev dependency health checks)\n --verbose show every rule and per-file details (default\n shows top 3 rules)\n --debug force a Sentry trace and print its id at the end\n (paste it into a bug report)\n --output-dir directory for the full diagnostics dump\n (default: a temp folder)\n --score output only the score\n --json output a single structured JSON report\n (suppresses other output)\n --json-compact with --json, emit compact JSON (no indentation)\n --json-out with --json, write the report to a file instead\n of stdout\n -y, --yes skip prompts, scan all workspace projects\n --no-parallel lint serially with one worker (default: parallel\n across CPU cores; set the worker count with\n REACT_DOCTOR_PARALLEL)\n --project select projects: workspace names or directory\n paths (comma-separated for multiple); overrides\n the `projects` config field\n --scope how much supported JS/TS and inline HTML script\n source to scan/report: full (default), files,\n changed (only new issues vs base), or lines\n (issues whose source spans touch changed lines)\n --base base git ref for files/changed/lines scope\n (auto-detected when omitted)\n --include-untracked with --scope files/changed/lines, also scan\n ordinary untracked files (respects .gitignore)\n --no-score skip the score API, the share URL, and crash\n reporting\n --category only show diagnostics in a category (repeatable;\n e.g. Security)\n --no-telemetry alias for --no-score (skip the score API, share\n URL, and crash reporting)\n --staged scan only staged (git index) files for\n pre-commit hooks\n --max-duration scan time budget for the whole run, shared\n across workspace projects: past it, remaining\n lint batches and dead-code are skipped and\n partial results are reported (skipped files are\n listed in the JSON report)\n --blocking severity that fails CI: error (default),\n warning, or none (advisory)\n --no-respect-inline-disables audit mode: neutralize inline lint suppressions\n before scanning\n --warnings show warning-severity diagnostics (default)\n --no-warnings hide warning-severity diagnostics (errors only)\n --color force colored output\n --no-color disable colored output (also honors NO_COLOR)\n -h, --help display help for command\n\nCommands:\n design [directory] Run only the focused UI design diagnostics\n why [options] Explain why a rule fired (or why a suppression\n didn't apply) at a file:line\n install|setup [options] Install the react-doctor skill into your coding\n agents and optional git hook\n ci Set up, upgrade, and configure React Doctor in\n your CI\n version [options] show the version with Node and platform info\n rules List, explain, and configure which React Doctor\n rules run\n experimental-lsp [experimental] run the React Doctor language\n server over stdio (for editors)\n\nExamples:\n $ react-doctor # scan the current project\n $ react-doctor ./apps/web # scan a specific directory\n $ react-doctor --scope changed --base main # scan only new issues vs. main\n $ react-doctor --project modules/a,modules/b # score each module separately (names or paths)\n $ react-doctor --staged # scan staged files (pre-commit hook)\n $ react-doctor design # run the focused UI design audit\n $ react-doctor --category Security # show only one diagnostic category\n $ react-doctor --blocking warning # fail CI on warnings too (default: error)\n $ react-doctor --json > report.json # write a machine-readable report\n $ react-doctor why src/App.tsx:42 # explain why a rule fired there\n $ react-doctor ci install # scan every pull request in CI\n $ react-doctor install # set up the agent skill and git hook\n\nConfiguration:\n Add a doctor.config.ts (or .js/.mjs/.json — or a \"reactDoctor\" key in your package.json) in the project root.\n Use react-doctor rules to list, explain, and configure rules. CLI flags always override config values.\n\nFeedback & bug reports:\n https://github.com/millionco/react-doctor/issues\n\nLearn more:\n https://github.com/millionco/react-doctor\n\n" + }, + { + "name": "design", + "arguments": ["design", "--help"], + "output": "Usage: react-doctor design [options] [directory]\n\nRun only the focused UI design diagnostics\n\nOptions:\n -h, --help display help for command\n\nExamples:\n $ react-doctor design # audit UI design in the current project\n $ react-doctor design ./apps/web # audit one application\n $ react-doctor design --verbose # show every design finding\n $ react-doctor design --json # write a design-only JSON report\n\nScope:\n Runs every rule tagged design; all design rules stay opt-in during a general health scan.\n Dead-code, supply-chain, external lint-config, custom-plugin, and health-score passes are skipped.\n Standard scan flags such as --scope, --project, --verbose, and --json still work.\n\n" + }, + { + "name": "why", + "arguments": ["why", "--help"], + "output": "Usage: react-doctor why [options] \n\nExplain why a rule fired (or why a suppression didn't apply) at a file:line\n\nOptions:\n --project select projects: workspace names or directory paths\n (comma-separated for multiple)\n -c, --cwd working directory (default: \"\")\n --color force colored output\n --no-color disable colored output (also honors NO_COLOR)\n -h, --help display help for command\n" + }, + { + "name": "install", + "arguments": ["install", "--help"], + "output": "Usage: react-doctor install|setup [options]\n\nInstall the react-doctor skill into your coding agents and optional git hook\n\nOptions:\n -y, --yes skip prompts, install for all detected agents\n --dry-run show what would be installed without writing files\n --agent-hooks install native non-blocking agent hooks for Claude Code and\n Cursor\n -c, --cwd working directory (default: \"\")\n --color force colored output\n --no-color disable colored output (also honors NO_COLOR)\n -h, --help display help for command\n\nExamples:\n $ react-doctor install # interactive setup\n $ react-doctor install --yes # non-interactive; all detected agents\n $ react-doctor install --dry-run # preview without writing files\n $ react-doctor install --agent-hooks # also install native agent hooks\n\nManaging CI:\n react-doctor ci install sets up, upgrades, and configures CI on its own.\n\nLearn more:\n https://github.com/millionco/react-doctor\n\n" + }, + { + "name": "setup-alias", + "arguments": ["setup", "--help"], + "output": "Usage: react-doctor install|setup [options]\n\nInstall the react-doctor skill into your coding agents and optional git hook\n\nOptions:\n -y, --yes skip prompts, install for all detected agents\n --dry-run show what would be installed without writing files\n --agent-hooks install native non-blocking agent hooks for Claude Code and\n Cursor\n -c, --cwd working directory (default: \"\")\n --color force colored output\n --no-color disable colored output (also honors NO_COLOR)\n -h, --help display help for command\n\nExamples:\n $ react-doctor install # interactive setup\n $ react-doctor install --yes # non-interactive; all detected agents\n $ react-doctor install --dry-run # preview without writing files\n $ react-doctor install --agent-hooks # also install native agent hooks\n\nManaging CI:\n react-doctor ci install sets up, upgrades, and configures CI on its own.\n\nLearn more:\n https://github.com/millionco/react-doctor\n\n" + }, + { + "name": "ci", + "arguments": ["ci", "--help"], + "output": "Usage: react-doctor ci [options] [command]\n\nSet up, upgrade, and configure React Doctor in your CI\n\nOptions:\n -h, --help display help for command\n\nCommands:\n install [options] Add a CI workflow that scans every pull request\n config [options] Change the gate, scan scope, and pull-request reporting\n upgrade [options] Upgrade the CI workflow to the action's current major\n help [command] display help for command\n" + }, + { + "name": "ci-install", + "arguments": ["ci", "install", "--help"], + "output": "Usage: react-doctor ci install [options]\n\nAdd a CI workflow that scans every pull request\n\nOptions:\n --provider ci provider: github-actions or gitlab-ci (auto-detected\n by default)\n --pr open a pull request with the change instead of writing\n it to the working tree\n --blocking gate level: none (advisory, default), warning, or error\n --scope what a pull-request scan reports: changed (default),\n files, lines, full\n --comment post a summary comment on each pull request\n --no-comment don't post a summary comment\n --review-comments add inline review comments on changed lines\n --no-review-comments don't add inline review comments\n --commit-status report a commit status with the health score\n --no-commit-status don't report a commit status\n -y, --yes skip prompts; use the defaults\n -c, --cwd working directory (default: \"\")\n --color force colored output\n --no-color disable colored output (also honors NO_COLOR)\n -h, --help display help for command\n\nExamples:\n $ react-doctor ci install # add a workflow that scans every pull request\n $ react-doctor ci install --blocking error # add it and fail the check on new errors\n $ react-doctor ci install --pr # open a pull request with the workflow\n $ react-doctor ci config # change the gate, scope, and reporting settings\n $ react-doctor ci config --scope full # scan the whole project on every pull request\n $ react-doctor ci upgrade # bump the action to its current major\n\nProviders:\n GitHub Actions is auto-detected and fully supported. GitLab CI is a gate-only\n scaffold. Pass --provider gitlab-ci to choose it explicitly.\n\nLearn more:\n https://react.doctor/ci\n\n" + }, + { + "name": "ci-config", + "arguments": ["ci", "config", "--help"], + "output": "Usage: react-doctor ci config [options]\n\nChange the gate, scan scope, and pull-request reporting\n\nOptions:\n --provider ci provider: github-actions or gitlab-ci (auto-detected\n by default)\n --blocking gate level: none (advisory), warning, or error\n --scope what a pull-request scan reports: changed, files, lines,\n or full\n --comment post a summary comment on each pull request\n --no-comment don't post a summary comment\n --review-comments add inline review comments on changed lines\n --no-review-comments don't add inline review comments\n --commit-status report a commit status with the health score\n --no-commit-status don't report a commit status\n -y, --yes skip prompts; print the current settings\n -c, --cwd working directory (default: \"\")\n --color force colored output\n --no-color disable colored output (also honors NO_COLOR)\n -h, --help display help for command\n" + }, + { + "name": "ci-upgrade", + "arguments": ["ci", "upgrade", "--help"], + "output": "Usage: react-doctor ci upgrade [options]\n\nUpgrade the CI workflow to the action's current major\n\nOptions:\n --provider ci provider: github-actions or gitlab-ci (auto-detected by\n default)\n --pr open a pull request with the change instead of writing it\n to the working tree\n -y, --yes skip prompts\n -c, --cwd working directory (default: \"\")\n --color force colored output\n --no-color disable colored output (also honors NO_COLOR)\n -h, --help display help for command\n" + }, + { + "name": "ci-help-alias", + "arguments": ["ci", "help"], + "output": "Usage: react-doctor ci [options] [command]\n\nSet up, upgrade, and configure React Doctor in your CI\n\nOptions:\n -h, --help display help for command\n\nCommands:\n install [options] Add a CI workflow that scans every pull request\n config [options] Change the gate, scan scope, and pull-request reporting\n upgrade [options] Upgrade the CI workflow to the action's current major\n help [command] display help for command\n" + }, + { + "name": "version", + "arguments": ["version", "--help"], + "output": "Usage: react-doctor version [options]\n\nshow the version with Node and platform info\n\nOptions:\n --color force colored output\n --no-color disable colored output (also honors NO_COLOR)\n -h, --help display help for command\n" + }, + { + "name": "rules", + "arguments": ["rules", "--help"], + "output": "Usage: react-doctor rules [options] [command]\n\nList, explain, and configure which React Doctor rules run\n\nOptions:\n -h, --help display help for command\n\nCommands:\n list [options] List rules and the severity they run at under your config\n explain [options] Explain why a rule matters, its current severity, and how to configure it\n set [options] Set a rule's severity: off, warn, or error\n enable [options] Enable a rule at its recommended severity (or pass --severity)\n disable [options] Disable a rule so it never runs\n category [options] Set the severity for a whole category (off, warn, error)\n ignore-tag [options] Skip a whole rule family by tag before linting (e.g. design)\n unignore-tag [options] Stop ignoring a tag previously skipped via ignore-tag\n help [command] display help for command\n" + }, + { + "name": "rules-list", + "arguments": ["rules", "list", "--help"], + "output": "Usage: react-doctor rules list [options]\n\nList rules and the severity they run at under your config\n\nOptions:\n --category only show rules in a category (e.g. Performance)\n --tag only show rules with a tag (e.g. design, test-noise)\n --framework only show rules for a framework (e.g. global, nextjs)\n --configured only show rules your config has changed from the default\n --json output a structured JSON array\n -c, --cwd working directory (default: \"\")\n -h, --help display help for command\n" + }, + { + "name": "rules-explain", + "arguments": ["rules", "explain", "--help"], + "output": "Usage: react-doctor rules explain [options] \n\nExplain why a rule matters, its current severity, and how to configure it\n\nOptions:\n --json output a structured JSON object\n -c, --cwd working directory (default: \"\")\n -h, --help display help for command\n" + }, + { + "name": "rules-set", + "arguments": ["rules", "set", "--help"], + "output": "Usage: react-doctor rules set [options] \n\nSet a rule's severity: off, warn, or error\n\nOptions:\n -c, --cwd working directory (default: \"\")\n -h, --help display help for command\n" + }, + { + "name": "rules-enable", + "arguments": ["rules", "enable", "--help"], + "output": "Usage: react-doctor rules enable [options] \n\nEnable a rule at its recommended severity (or pass --severity)\n\nOptions:\n --severity severity to enable at: warn or error\n -c, --cwd working directory (default: \"\")\n -h, --help display help for command\n" + }, + { + "name": "rules-disable", + "arguments": ["rules", "disable", "--help"], + "output": "Usage: react-doctor rules disable [options] \n\nDisable a rule so it never runs\n\nOptions:\n -c, --cwd working directory (default: \"\")\n -h, --help display help for command\n" + }, + { + "name": "rules-category", + "arguments": ["rules", "category", "--help"], + "output": "Usage: react-doctor rules category [options] \n\nSet the severity for a whole category (off, warn, error)\n\nOptions:\n -c, --cwd working directory (default: \"\")\n -h, --help display help for command\n" + }, + { + "name": "rules-ignore-tag", + "arguments": ["rules", "ignore-tag", "--help"], + "output": "Usage: react-doctor rules ignore-tag [options] \n\nSkip a whole rule family by tag before linting (e.g. design)\n\nOptions:\n -c, --cwd working directory (default: \"\")\n -h, --help display help for command\n" + }, + { + "name": "rules-unignore-tag", + "arguments": ["rules", "unignore-tag", "--help"], + "output": "Usage: react-doctor rules unignore-tag [options] \n\nStop ignoring a tag previously skipped via ignore-tag\n\nOptions:\n -c, --cwd working directory (default: \"\")\n -h, --help display help for command\n" + }, + { + "name": "rules-help-alias", + "arguments": ["rules", "help"], + "output": "Usage: react-doctor rules [options] [command]\n\nList, explain, and configure which React Doctor rules run\n\nOptions:\n -h, --help display help for command\n\nCommands:\n list [options] List rules and the severity they run at under your config\n explain [options] Explain why a rule matters, its current severity, and how to configure it\n set [options] Set a rule's severity: off, warn, or error\n enable [options] Enable a rule at its recommended severity (or pass --severity)\n disable [options] Disable a rule so it never runs\n category [options] Set the severity for a whole category (off, warn, error)\n ignore-tag [options] Skip a whole rule family by tag before linting (e.g. design)\n unignore-tag [options] Stop ignoring a tag previously skipped via ignore-tag\n help [command] display help for command\n" + }, + { + "name": "experimental-lsp", + "arguments": ["experimental-lsp", "--help"], + "output": "Usage: react-doctor experimental-lsp [options]\n\n[experimental] run the React Doctor language server over stdio (for editors)\n\nOptions:\n -h, --help display help for command\n" + }, + { + "name": "experimental-tui", + "arguments": ["experimental-tui", "--help"], + "output": "Usage: react-doctor experimental-tui [options] [directory]\n\n[experimental] interactive, scrollable scan report\n\nOptions:\n --blocking severity that fails CI: error (default), warning, or\n none (advisory)\n --color force colored output\n --no-color disable colored output (also honors NO_COLOR)\n --no-dead-code skip dead-code analysis\n --no-supply-chain skip the dependency supply-chain scan\n --no-score skip the score API, the share URL, and crash reporting\n -p, --project scan specific workspace projects (comma-separated, or\n *)\n -y, --yes skip the project prompt and scan every discovered\n project\n -h, --help display help for command\n" + }, + { + "name": "help-alias", + "arguments": ["help"], + "output": "Usage: react-doctor [options] [command] [directory]\n\nDiagnose React codebase health\n\nArguments:\n directory project directory to scan (default: \".\")\n\nOptions:\n -v, --version display the version number\n --lint enable linting\n --no-lint skip linting\n --dead-code enable dead-code analysis (default)\n --no-dead-code skip dead-code analysis (unused files / exports\n / dependencies, circular imports)\n --supply-chain enable the dependency supply-chain scan\n (default)\n --no-supply-chain skip the dependency supply-chain scan\n (Socket.dev dependency health checks)\n --verbose show every rule and per-file details (default\n shows top 3 rules)\n --debug force a Sentry trace and print its id at the end\n (paste it into a bug report)\n --output-dir directory for the full diagnostics dump\n (default: a temp folder)\n --score output only the score\n --json output a single structured JSON report\n (suppresses other output)\n --json-compact with --json, emit compact JSON (no indentation)\n --json-out with --json, write the report to a file instead\n of stdout\n -y, --yes skip prompts, scan all workspace projects\n --no-parallel lint serially with one worker (default: parallel\n across CPU cores; set the worker count with\n REACT_DOCTOR_PARALLEL)\n --project select projects: workspace names or directory\n paths (comma-separated for multiple); overrides\n the `projects` config field\n --scope how much supported JS/TS and inline HTML script\n source to scan/report: full (default), files,\n changed (only new issues vs base), or lines\n (issues whose source spans touch changed lines)\n --base base git ref for files/changed/lines scope\n (auto-detected when omitted)\n --include-untracked with --scope files/changed/lines, also scan\n ordinary untracked files (respects .gitignore)\n --no-score skip the score API, the share URL, and crash\n reporting\n --category only show diagnostics in a category (repeatable;\n e.g. Security)\n --no-telemetry alias for --no-score (skip the score API, share\n URL, and crash reporting)\n --staged scan only staged (git index) files for\n pre-commit hooks\n --max-duration scan time budget for the whole run, shared\n across workspace projects: past it, remaining\n lint batches and dead-code are skipped and\n partial results are reported (skipped files are\n listed in the JSON report)\n --blocking severity that fails CI: error (default),\n warning, or none (advisory)\n --no-respect-inline-disables audit mode: neutralize inline lint suppressions\n before scanning\n --warnings show warning-severity diagnostics (default)\n --no-warnings hide warning-severity diagnostics (errors only)\n --color force colored output\n --no-color disable colored output (also honors NO_COLOR)\n -h, --help display help for command\n\nCommands:\n design [directory] Run only the focused UI design diagnostics\n why [options] Explain why a rule fired (or why a suppression\n didn't apply) at a file:line\n install|setup [options] Install the react-doctor skill into your coding\n agents and optional git hook\n ci Set up, upgrade, and configure React Doctor in\n your CI\n version [options] show the version with Node and platform info\n rules List, explain, and configure which React Doctor\n rules run\n experimental-lsp [experimental] run the React Doctor language\n server over stdio (for editors)\n\nExamples:\n $ react-doctor # scan the current project\n $ react-doctor ./apps/web # scan a specific directory\n $ react-doctor --scope changed --base main # scan only new issues vs. main\n $ react-doctor --project modules/a,modules/b # score each module separately (names or paths)\n $ react-doctor --staged # scan staged files (pre-commit hook)\n $ react-doctor design # run the focused UI design audit\n $ react-doctor --category Security # show only one diagnostic category\n $ react-doctor --blocking warning # fail CI on warnings too (default: error)\n $ react-doctor --json > report.json # write a machine-readable report\n $ react-doctor why src/App.tsx:42 # explain why a rule fired there\n $ react-doctor ci install # scan every pull request in CI\n $ react-doctor install # set up the agent skill and git hook\n\nConfiguration:\n Add a doctor.config.ts (or .js/.mjs/.json — or a \"reactDoctor\" key in your package.json) in the project root.\n Use react-doctor rules to list, explain, and configure rules. CLI flags always override config values.\n\nFeedback & bug reports:\n https://github.com/millionco/react-doctor/issues\n\nLearn more:\n https://github.com/millionco/react-doctor\n\n" + }, + { + "name": "help-install-alias", + "arguments": ["help", "install"], + "output": "Usage: react-doctor install|setup [options]\n\nInstall the react-doctor skill into your coding agents and optional git hook\n\nOptions:\n -y, --yes skip prompts, install for all detected agents\n --dry-run show what would be installed without writing files\n --agent-hooks install native non-blocking agent hooks for Claude Code and\n Cursor\n -c, --cwd working directory (default: \"\")\n --color force colored output\n --no-color disable colored output (also honors NO_COLOR)\n -h, --help display help for command\n\nExamples:\n $ react-doctor install # interactive setup\n $ react-doctor install --yes # non-interactive; all detected agents\n $ react-doctor install --dry-run # preview without writing files\n $ react-doctor install --agent-hooks # also install native agent hooks\n\nManaging CI:\n react-doctor ci install sets up, upgrades, and configures CI on its own.\n\nLearn more:\n https://github.com/millionco/react-doctor\n\n" + }, + { + "name": "help-setup-alias", + "arguments": ["help", "setup"], + "output": "Usage: react-doctor install|setup [options]\n\nInstall the react-doctor skill into your coding agents and optional git hook\n\nOptions:\n -y, --yes skip prompts, install for all detected agents\n --dry-run show what would be installed without writing files\n --agent-hooks install native non-blocking agent hooks for Claude Code and\n Cursor\n -c, --cwd working directory (default: \"\")\n --color force colored output\n --no-color disable colored output (also honors NO_COLOR)\n -h, --help display help for command\n\nExamples:\n $ react-doctor install # interactive setup\n $ react-doctor install --yes # non-interactive; all detected agents\n $ react-doctor install --dry-run # preview without writing files\n $ react-doctor install --agent-hooks # also install native agent hooks\n\nManaging CI:\n react-doctor ci install sets up, upgrades, and configures CI on its own.\n\nLearn more:\n https://github.com/millionco/react-doctor\n\n" + }, + { + "name": "legacy-diff-flag", + "arguments": ["--diff=false", "--help"], + "output": "Usage: react-doctor [options] [command] [directory]\n\nDiagnose React codebase health\n\nArguments:\n directory project directory to scan (default: \".\")\n\nOptions:\n -v, --version display the version number\n --lint enable linting\n --no-lint skip linting\n --dead-code enable dead-code analysis (default)\n --no-dead-code skip dead-code analysis (unused files / exports\n / dependencies, circular imports)\n --supply-chain enable the dependency supply-chain scan\n (default)\n --no-supply-chain skip the dependency supply-chain scan\n (Socket.dev dependency health checks)\n --verbose show every rule and per-file details (default\n shows top 3 rules)\n --debug force a Sentry trace and print its id at the end\n (paste it into a bug report)\n --output-dir directory for the full diagnostics dump\n (default: a temp folder)\n --score output only the score\n --json output a single structured JSON report\n (suppresses other output)\n --json-compact with --json, emit compact JSON (no indentation)\n --json-out with --json, write the report to a file instead\n of stdout\n -y, --yes skip prompts, scan all workspace projects\n --no-parallel lint serially with one worker (default: parallel\n across CPU cores; set the worker count with\n REACT_DOCTOR_PARALLEL)\n --project select projects: workspace names or directory\n paths (comma-separated for multiple); overrides\n the `projects` config field\n --scope how much supported JS/TS and inline HTML script\n source to scan/report: full (default), files,\n changed (only new issues vs base), or lines\n (issues whose source spans touch changed lines)\n --base base git ref for files/changed/lines scope\n (auto-detected when omitted)\n --include-untracked with --scope files/changed/lines, also scan\n ordinary untracked files (respects .gitignore)\n --no-score skip the score API, the share URL, and crash\n reporting\n --category only show diagnostics in a category (repeatable;\n e.g. Security)\n --no-telemetry alias for --no-score (skip the score API, share\n URL, and crash reporting)\n --staged scan only staged (git index) files for\n pre-commit hooks\n --max-duration scan time budget for the whole run, shared\n across workspace projects: past it, remaining\n lint batches and dead-code are skipped and\n partial results are reported (skipped files are\n listed in the JSON report)\n --blocking severity that fails CI: error (default),\n warning, or none (advisory)\n --no-respect-inline-disables audit mode: neutralize inline lint suppressions\n before scanning\n --warnings show warning-severity diagnostics (default)\n --no-warnings hide warning-severity diagnostics (errors only)\n --color force colored output\n --no-color disable colored output (also honors NO_COLOR)\n -h, --help display help for command\n\nCommands:\n design [directory] Run only the focused UI design diagnostics\n why [options] Explain why a rule fired (or why a suppression\n didn't apply) at a file:line\n install|setup [options] Install the react-doctor skill into your coding\n agents and optional git hook\n ci Set up, upgrade, and configure React Doctor in\n your CI\n version [options] show the version with Node and platform info\n rules List, explain, and configure which React Doctor\n rules run\n experimental-lsp [experimental] run the React Doctor language\n server over stdio (for editors)\n\nExamples:\n $ react-doctor # scan the current project\n $ react-doctor ./apps/web # scan a specific directory\n $ react-doctor --scope changed --base main # scan only new issues vs. main\n $ react-doctor --project modules/a,modules/b # score each module separately (names or paths)\n $ react-doctor --staged # scan staged files (pre-commit hook)\n $ react-doctor design # run the focused UI design audit\n $ react-doctor --category Security # show only one diagnostic category\n $ react-doctor --blocking warning # fail CI on warnings too (default: error)\n $ react-doctor --json > report.json # write a machine-readable report\n $ react-doctor why src/App.tsx:42 # explain why a rule fired there\n $ react-doctor ci install # scan every pull request in CI\n $ react-doctor install # set up the agent skill and git hook\n\nConfiguration:\n Add a doctor.config.ts (or .js/.mjs/.json — or a \"reactDoctor\" key in your package.json) in the project root.\n Use react-doctor rules to list, explain, and configure rules. CLI flags always override config values.\n\nFeedback & bug reports:\n https://github.com/millionco/react-doctor/issues\n\nLearn more:\n https://github.com/millionco/react-doctor\n\n" + }, + { + "name": "legacy-fail-on-flag", + "arguments": ["--fail-on=none", "--help"], + "output": "Usage: react-doctor [options] [command] [directory]\n\nDiagnose React codebase health\n\nArguments:\n directory project directory to scan (default: \".\")\n\nOptions:\n -v, --version display the version number\n --lint enable linting\n --no-lint skip linting\n --dead-code enable dead-code analysis (default)\n --no-dead-code skip dead-code analysis (unused files / exports\n / dependencies, circular imports)\n --supply-chain enable the dependency supply-chain scan\n (default)\n --no-supply-chain skip the dependency supply-chain scan\n (Socket.dev dependency health checks)\n --verbose show every rule and per-file details (default\n shows top 3 rules)\n --debug force a Sentry trace and print its id at the end\n (paste it into a bug report)\n --output-dir directory for the full diagnostics dump\n (default: a temp folder)\n --score output only the score\n --json output a single structured JSON report\n (suppresses other output)\n --json-compact with --json, emit compact JSON (no indentation)\n --json-out with --json, write the report to a file instead\n of stdout\n -y, --yes skip prompts, scan all workspace projects\n --no-parallel lint serially with one worker (default: parallel\n across CPU cores; set the worker count with\n REACT_DOCTOR_PARALLEL)\n --project select projects: workspace names or directory\n paths (comma-separated for multiple); overrides\n the `projects` config field\n --scope how much supported JS/TS and inline HTML script\n source to scan/report: full (default), files,\n changed (only new issues vs base), or lines\n (issues whose source spans touch changed lines)\n --base base git ref for files/changed/lines scope\n (auto-detected when omitted)\n --include-untracked with --scope files/changed/lines, also scan\n ordinary untracked files (respects .gitignore)\n --no-score skip the score API, the share URL, and crash\n reporting\n --category only show diagnostics in a category (repeatable;\n e.g. Security)\n --no-telemetry alias for --no-score (skip the score API, share\n URL, and crash reporting)\n --staged scan only staged (git index) files for\n pre-commit hooks\n --max-duration scan time budget for the whole run, shared\n across workspace projects: past it, remaining\n lint batches and dead-code are skipped and\n partial results are reported (skipped files are\n listed in the JSON report)\n --blocking severity that fails CI: error (default),\n warning, or none (advisory)\n --no-respect-inline-disables audit mode: neutralize inline lint suppressions\n before scanning\n --warnings show warning-severity diagnostics (default)\n --no-warnings hide warning-severity diagnostics (errors only)\n --color force colored output\n --no-color disable colored output (also honors NO_COLOR)\n -h, --help display help for command\n\nCommands:\n design [directory] Run only the focused UI design diagnostics\n why [options] Explain why a rule fired (or why a suppression\n didn't apply) at a file:line\n install|setup [options] Install the react-doctor skill into your coding\n agents and optional git hook\n ci Set up, upgrade, and configure React Doctor in\n your CI\n version [options] show the version with Node and platform info\n rules List, explain, and configure which React Doctor\n rules run\n experimental-lsp [experimental] run the React Doctor language\n server over stdio (for editors)\n\nExamples:\n $ react-doctor # scan the current project\n $ react-doctor ./apps/web # scan a specific directory\n $ react-doctor --scope changed --base main # scan only new issues vs. main\n $ react-doctor --project modules/a,modules/b # score each module separately (names or paths)\n $ react-doctor --staged # scan staged files (pre-commit hook)\n $ react-doctor design # run the focused UI design audit\n $ react-doctor --category Security # show only one diagnostic category\n $ react-doctor --blocking warning # fail CI on warnings too (default: error)\n $ react-doctor --json > report.json # write a machine-readable report\n $ react-doctor why src/App.tsx:42 # explain why a rule fired there\n $ react-doctor ci install # scan every pull request in CI\n $ react-doctor install # set up the agent skill and git hook\n\nConfiguration:\n Add a doctor.config.ts (or .js/.mjs/.json — or a \"reactDoctor\" key in your package.json) in the project root.\n Use react-doctor rules to list, explain, and configure rules. CLI flags always override config values.\n\nFeedback & bug reports:\n https://github.com/millionco/react-doctor/issues\n\nLearn more:\n https://github.com/millionco/react-doctor\n\n" + } +] diff --git a/scripts/compatibility/snapshots/packed-public-entry-points.json b/scripts/compatibility/snapshots/packed-public-entry-points.json new file mode 100644 index 0000000000..9e9f67e902 --- /dev/null +++ b/scripts/compatibility/snapshots/packed-public-entry-points.json @@ -0,0 +1,208 @@ +{ + "entries": [ + { + "packageName": "deslop-js", + "subpath": ".", + "exportKeys": { + "import": ["analyze", "defineConfig"], + "require": ["analyze", "defineConfig"] + } + }, + { + "packageName": "deslop-js", + "subpath": "./analyzed-inputs", + "exportKeys": { + "import": ["ANALYZED_MANIFEST_FILENAMES", "DEFAULT_EXTENSIONS"], + "require": ["ANALYZED_MANIFEST_FILENAMES", "DEFAULT_EXTENSIONS"] + } + }, + { + "packageName": "deslop-js", + "subpath": "./package.json", + "exportKeys": { + "import": ["default"] + } + }, + { + "packageName": "eslint-plugin-react-doctor", + "subpath": ".", + "exportKeys": { + "import": ["default"] + } + }, + { + "packageName": "oxlint-plugin-react-doctor", + "subpath": ".", + "exportKeys": { + "import": [ + "ALL_REACT_DOCTOR_RULES", + "ALL_REACT_DOCTOR_RULE_KEYS", + "CROSS_FILE_DEPENDENCY_COLLECTORS", + "CROSS_FILE_RULE_IDS", + "EXTERNAL_RULES", + "FRAMEWORK_SPECIFIC_RULE_KEYS", + "FRAMEWORK_TOKENS", + "MOTION_LIBRARY_PACKAGES", + "NEXTJS_RULES", + "PREACT_RULES", + "REACT_COMPILER_RULES", + "REACT_DOCTOR_RULES", + "REACT_NATIVE_DEPENDENCY_NAMES", + "REACT_NATIVE_DEPENDENCY_PREFIXES", + "REACT_NATIVE_RULES", + "RECOMMENDED_RULES", + "RULES", + "TANSTACK_QUERY_RULES", + "TANSTACK_START_RULES", + "UNBOUNDED_CROSS_FILE_RULE_IDS", + "classifySecurityScanFile", + "collectCrossFileDependencyProbes", + "default", + "isReactNativeDependencyName", + "resetManifestCaches", + "shouldReadSecurityScanContent" + ] + } + }, + { + "packageName": "oxlint-plugin-react-doctor", + "subpath": "./contracts", + "exportKeys": { + "import": ["FRAMEWORK_TOKENS", "MOTION_LIBRARY_PACKAGES"] + } + }, + { + "packageName": "react-doctor", + "subpath": ".", + "executionOnly": true + }, + { + "packageName": "react-doctor", + "subpath": "./api", + "exportKeys": { + "import": [ + "AmbiguousProjectError", + "NoReactDependencyError", + "NotADirectoryError", + "PackageJsonNotFoundError", + "ProjectNotFoundError", + "ReactDoctorError", + "buildJsonReport", + "buildJsonReportError", + "clearCaches", + "defineConfig", + "diagnose", + "filterSourceFiles", + "getDiffInfo", + "hasReactRuntime", + "isProjectDiscoveryError", + "isReactDoctorError", + "summarizeDiagnostics", + "toJsonReport" + ] + } + } + ], + "filePolicies": [ + { + "packageName": "deslop-cli", + "requiredFiles": ["LICENSE", "README.md", "dist/cli.mjs", "package.json"], + "allowedPatterns": ["LICENSE", "README.md", "dist/**", "package.json"], + "deniedPatterns": [ + "scripts/**", + "src/**", + "tests/**", + "tsconfig.json", + "vite.config.ts", + "**/*.map" + ] + }, + { + "packageName": "deslop-js", + "requiredFiles": [ + "LICENSE", + "README.md", + "dist/analyzed-inputs.cjs", + "dist/analyzed-inputs.d.cts", + "dist/analyzed-inputs.d.mts", + "dist/analyzed-inputs.mjs", + "dist/index.cjs", + "dist/index.d.cts", + "dist/index.d.mts", + "dist/index.mjs", + "package.json" + ], + "allowedPatterns": ["LICENSE", "README.md", "dist/**", "package.json"], + "deniedPatterns": [ + "scripts/**", + "src/**", + "tests/**", + "tsconfig.json", + "vite.config.ts", + "**/*.map" + ] + }, + { + "packageName": "eslint-plugin-react-doctor", + "requiredFiles": ["LICENSE", "README.md", "dist/index.d.ts", "dist/index.js", "package.json"], + "allowedPatterns": ["LICENSE", "README.md", "dist/**", "package.json"], + "deniedPatterns": [ + "scripts/**", + "src/**", + "tests/**", + "tsconfig.json", + "vite.config.ts", + "**/*.map" + ] + }, + { + "packageName": "oxlint-plugin-react-doctor", + "requiredFiles": [ + "LICENSE", + "README.md", + "dist/contracts.d.ts", + "dist/contracts.js", + "dist/index.d.ts", + "dist/index.js", + "package.json" + ], + "allowedPatterns": ["LICENSE", "README.md", "dist/**", "package.json"], + "deniedPatterns": [ + "scripts/**", + "src/**", + "tests/**", + "tsconfig.json", + "vite.config.ts", + "**/*.map" + ] + }, + { + "packageName": "react-doctor", + "requiredFiles": [ + "LICENSE", + "README.md", + "bin/react-doctor.js", + "dist/cli.d.ts", + "dist/cli.js", + "dist/index.d.ts", + "dist/index.js", + "dist/lsp.js", + "dist/skills/improve-react/AUDIT.md", + "dist/skills/improve-react/PLAN-TEMPLATE.md", + "dist/skills/improve-react/SKILL.md", + "dist/skills/react-doctor/SKILL.md", + "dist/skills/react-doctor/references/explain.md", + "package.json" + ], + "allowedPatterns": ["LICENSE", "README.md", "bin/**", "dist/**", "package.json"], + "deniedPatterns": [ + "scripts/**", + "src/**", + "tests/**", + "tsconfig.json", + "vite.config.ts", + "**/*.map" + ] + } + ] +} diff --git a/scripts/compatibility/snapshots/public-packages.json b/scripts/compatibility/snapshots/public-packages.json new file mode 100644 index 0000000000..65a638829c --- /dev/null +++ b/scripts/compatibility/snapshots/public-packages.json @@ -0,0 +1,129 @@ +[ + { + "name": "deslop-cli", + "directory": "packages/deslop-cli", + "type": "module", + "main": null, + "module": null, + "types": null, + "bin": { + "deslop": "./dist/cli.mjs" + }, + "exports": null, + "files": ["dist", "package.json", "README.md", "LICENSE"], + "sideEffects": null, + "engines": { + "node": ">=22" + }, + "peerDependencies": null + }, + { + "name": "deslop-js", + "directory": "packages/deslop-js", + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.mjs", + "types": "dist/index.d.mts", + "bin": null, + "exports": { + "./package.json": "./package.json", + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./analyzed-inputs": { + "import": { + "types": "./dist/analyzed-inputs.d.mts", + "default": "./dist/analyzed-inputs.mjs" + }, + "require": { + "types": "./dist/analyzed-inputs.d.cts", + "default": "./dist/analyzed-inputs.cjs" + } + } + }, + "files": ["dist", "package.json", "README.md", "LICENSE"], + "sideEffects": null, + "engines": null, + "peerDependencies": null + }, + { + "name": "eslint-plugin-react-doctor", + "directory": "packages/eslint-plugin-react-doctor", + "type": "module", + "main": null, + "module": null, + "types": null, + "bin": null, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": ["dist/**/*.js", "dist/**/*.d.ts", "LICENSE"], + "sideEffects": false, + "engines": { + "node": "^20.19.0 || >=22.13.0" + }, + "peerDependencies": null + }, + { + "name": "oxlint-plugin-react-doctor", + "directory": "packages/oxlint-plugin-react-doctor", + "type": "module", + "main": null, + "module": null, + "types": null, + "bin": null, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./contracts": { + "types": "./dist/contracts.d.ts", + "default": "./dist/contracts.js" + } + }, + "files": ["dist/**/*.js", "dist/**/*.d.ts", "LICENSE"], + "sideEffects": false, + "engines": { + "node": "^20.19.0 || >=22.13.0" + }, + "peerDependencies": null + }, + { + "name": "react-doctor", + "directory": "packages/react-doctor", + "type": "module", + "main": null, + "module": null, + "types": null, + "bin": { + "react-doctor": "./bin/react-doctor.js" + }, + "exports": { + ".": { + "types": "./dist/cli.d.ts", + "default": "./dist/cli.js" + }, + "./api": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": ["bin/**", "dist/**/*.js", "dist/**/*.d.ts", "dist/skills/**", "LICENSE"], + "sideEffects": false, + "engines": { + "node": "^20.19.0 || >=22.13.0" + }, + "peerDependencies": null + } +] diff --git a/scripts/convert-node-imports.mjs b/scripts/convert-node-imports.mjs deleted file mode 100644 index 1a9be54088..0000000000 --- a/scripts/convert-node-imports.mjs +++ /dev/null @@ -1,131 +0,0 @@ -import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; -import { extname, join } from "node:path"; - -const ROOT = join(import.meta.dirname, ".."); -const EXTENSIONS = new Set([".ts", ".tsx", ".js", ".mjs", ".cjs"]); - -const collectFiles = (directory) => { - const entries = readdirSync(directory); - const files = []; - for (const entry of entries) { - if (entry === "node_modules" || entry === "dist" || entry === ".git") continue; - const fullPath = join(directory, entry); - const stats = statSync(fullPath); - if (stats.isDirectory()) { - files.push(...collectFiles(fullPath)); - continue; - } - if (EXTENSIONS.has(extname(fullPath))) files.push(fullPath); - } - return files; -}; - -const parseNamedImport = (source, moduleSpecifier) => { - const pattern = new RegExp( - `import\\s+\\{([^}]+)\\}\\s+from\\s+["']${moduleSpecifier.replace(":", "\\:")}["'];?`, - "g", - ); - const names = []; - let match; - while ((match = pattern.exec(source)) !== null) { - const imported = match[1] - .split(",") - .map((part) => part.trim()) - .filter(Boolean) - .map((part) => { - const aliasMatch = part.match(/^(\w+)\s+as\s+(\w+)$/); - if (aliasMatch) return { local: aliasMatch[2], imported: aliasMatch[1] }; - return { local: part, imported: part }; - }); - names.push(...imported); - } - return names; -}; - -const removeNamedImports = (source, moduleSpecifier) => { - const pattern = new RegExp( - `import\\s+\\{[^}]+\\}\\s+from\\s+["']${moduleSpecifier.replace(":", "\\:")}["'];?\\n?`, - "g", - ); - return source.replace(pattern, ""); -}; - -const hasNamespaceImport = (source, moduleSpecifier, alias) => { - const pattern = new RegExp( - `import\\s+\\*\\s+as\\s+${alias}\\s+from\\s+["']${moduleSpecifier.replace(":", "\\:")}["']`, - ); - return pattern.test(source); -}; - -const ensureNamespaceImport = (source, moduleSpecifier, alias) => { - if (hasNamespaceImport(source, moduleSpecifier, alias)) return source; - const importLine = `import * as ${alias} from "${moduleSpecifier}";\n`; - const importMatch = source.match(/^((?:import\s.+;\n)*)/); - if (importMatch) { - return source.replace(importMatch[1], `${importMatch[1]}${importLine}`); - } - return `${importLine}${source}`; -}; - -const prefixUsages = (source, names, alias) => { - let result = source; - for (const { local, imported } of names) { - const member = imported === local ? local : imported; - const replacement = `${alias}.${member}`; - const pattern = new RegExp(`(? { - let source = readFileSync(filePath, "utf8"); - const original = source; - - source = source.replace( - /import\s+fs\s+from\s+["']node:fs["'];?/g, - 'import * as fs from "node:fs";', - ); - source = source.replace( - /import\s+path\s+from\s+["']node:path["'];?/g, - 'import * as path from "node:path";', - ); - source = source.replace( - /import\s+\*\s+as\s+Path\s+from\s+["']node:path["'];?/g, - 'import * as path from "node:path";', - ); - source = source.replace(/\bPath\./g, "path."); - - const fsNames = parseNamedImport(source, "node:fs"); - const pathNames = parseNamedImport(source, "node:path"); - - if (fsNames.length > 0) { - source = removeNamedImports(source, "node:fs"); - source = ensureNamespaceImport(source, "node:fs", "fs"); - source = prefixUsages(source, fsNames, "fs"); - } - - if (pathNames.length > 0) { - source = removeNamedImports(source, "node:path"); - source = ensureNamespaceImport(source, "node:path", "path"); - source = prefixUsages(source, pathNames, "path"); - } - - source = source.replace(/\n{3,}/g, "\n\n"); - - if (source !== original) { - writeFileSync(filePath, source); - return true; - } - return false; -}; - -const files = collectFiles(ROOT).filter( - (filePath) => !filePath.includes("convert-node-imports.mjs"), -); -const changed = files.filter(convertFile); -console.log(`Updated ${changed.length} files`); diff --git a/scripts/fixtures/check-skills/broken-asset/skills/broken/SKILL.md b/scripts/fixtures/check-skills/broken-asset/skills/broken/SKILL.md new file mode 100644 index 0000000000..fc6dd928d7 --- /dev/null +++ b/scripts/fixtures/check-skills/broken-asset/skills/broken/SKILL.md @@ -0,0 +1,8 @@ +--- +name: broken-asset +description: Reference a missing skill-local asset. +--- + +# Broken asset + +Load `$SKILL_DIR/assets/missing.json`. diff --git a/scripts/fixtures/check-skills/broken-frontmatter/skills/broken/SKILL.md b/scripts/fixtures/check-skills/broken-frontmatter/skills/broken/SKILL.md new file mode 100644 index 0000000000..4cefb84a75 --- /dev/null +++ b/scripts/fixtures/check-skills/broken-frontmatter/skills/broken/SKILL.md @@ -0,0 +1,5 @@ +--- +name: broken +--- + +# Broken frontmatter diff --git a/scripts/fixtures/check-skills/broken-link/skills/broken/SKILL.md b/scripts/fixtures/check-skills/broken-link/skills/broken/SKILL.md new file mode 100644 index 0000000000..e7188ead1d --- /dev/null +++ b/scripts/fixtures/check-skills/broken-link/skills/broken/SKILL.md @@ -0,0 +1,8 @@ +--- +name: broken-link +description: Reference a missing local document. +--- + +# Broken link + +Read [the missing reference](references/missing.md). diff --git a/scripts/fixtures/check-skills/valid-nested/skills/groups/nested-skill/SKILL.md b/scripts/fixtures/check-skills/valid-nested/skills/groups/nested-skill/SKILL.md new file mode 100644 index 0000000000..024073da6d --- /dev/null +++ b/scripts/fixtures/check-skills/valid-nested/skills/groups/nested-skill/SKILL.md @@ -0,0 +1,18 @@ +--- +name: nested-skill +description: Validate a nested skill fixture without requiring external checkouts. +metadata: + model: inherited +--- + +# Nested skill + +Read the [nested reference](references/details.md#details), [local asset](assets/prompt.txt), +and [external documentation](https://example.com/skill). The [reference-style link][details] +resolves locally. + +Run `node $SKILL_DIR/scripts/check.mjs`. A user can still invoke `/deslop` or provide +`$HOME/Developer/project`, `/examples`, or a +[relative external checkout](../../../../../../external/SKILL.md). + +[details]: references/details.md diff --git a/scripts/fixtures/check-skills/valid-nested/skills/groups/nested-skill/assets/prompt.txt b/scripts/fixtures/check-skills/valid-nested/skills/groups/nested-skill/assets/prompt.txt new file mode 100644 index 0000000000..8975357285 --- /dev/null +++ b/scripts/fixtures/check-skills/valid-nested/skills/groups/nested-skill/assets/prompt.txt @@ -0,0 +1 @@ +fixture prompt diff --git a/scripts/fixtures/check-skills/valid-nested/skills/groups/nested-skill/references/details.md b/scripts/fixtures/check-skills/valid-nested/skills/groups/nested-skill/references/details.md new file mode 100644 index 0000000000..1b8ce2a3a1 --- /dev/null +++ b/scripts/fixtures/check-skills/valid-nested/skills/groups/nested-skill/references/details.md @@ -0,0 +1,3 @@ +# Details + +Fixture reference. diff --git a/scripts/fixtures/check-skills/valid-nested/skills/groups/nested-skill/scripts/check.mjs b/scripts/fixtures/check-skills/valid-nested/skills/groups/nested-skill/scripts/check.mjs new file mode 100644 index 0000000000..dc105b0147 --- /dev/null +++ b/scripts/fixtures/check-skills/valid-nested/skills/groups/nested-skill/scripts/check.mjs @@ -0,0 +1 @@ +process.stdout.write("fixture\n"); diff --git a/scripts/performance/constants.ts b/scripts/performance/constants.ts index af876aae2f..692de1f18f 100644 --- a/scripts/performance/constants.ts +++ b/scripts/performance/constants.ts @@ -22,6 +22,7 @@ export const STRESS_VALUE_MODULUS = 7; export const STRESS_BRANCH_MODULUS = 3; export const STRESS_SUPPORT_SOURCE_FILE_COUNT = 2; export const BENCHMARK_TIMEOUT_MS = 30 * 60 * 1_000; +export const PERFORMANCE_PROFILE_TEST_TIMEOUT_MS = 120_000; export const COMMAND_MAX_BUFFER_BYTES = 100_000_000; export const BYTES_PER_KIBIBYTE = 1_024; export const BYTES_PER_MEBIBYTE = BYTES_PER_KIBIBYTE * BYTES_PER_KIBIBYTE; diff --git a/scripts/smoke-packed-cli-install.ts b/scripts/smoke-packed-cli-install.ts index 5d424709a0..846dff9494 100644 --- a/scripts/smoke-packed-cli-install.ts +++ b/scripts/smoke-packed-cli-install.ts @@ -1,16 +1,28 @@ import { spawnSync } from "node:child_process"; +import { isDeepStrictEqual } from "node:util"; import { fileURLToPath } from "node:url"; import * as Schema from "effect/Schema"; import { JsonReport } from "@react-doctor/core/schemas"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { CLI_HELP_INVOCATIONS } from "./compatibility/cli-help-invocations.ts"; +import { normalizeCliHelp } from "./compatibility/normalize-cli-help.ts"; +import { parseHelpCommandAliases } from "./compatibility/parse-help-command-aliases.ts"; +import type { + CliHelpSnapshotEntry, + PackedEntrySnapshot, + PackedFilePolicy, + PackedPublicEntryPointSnapshot, +} from "./compatibility/public-package-snapshot-types.ts"; +import { readPackageExportValue } from "./utils/read-package-export-value.ts"; interface CommandInput { readonly command: string; readonly args: readonly string[]; readonly cwd: string; readonly allowedStatuses?: readonly number[]; + readonly environment?: NodeJS.ProcessEnv; readonly needsShell?: boolean; } @@ -21,12 +33,16 @@ interface StringRecord { const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url)); const REPOSITORY_ROOT = path.resolve(SCRIPT_DIRECTORY, ".."); const FIXTURE_DIRECTORY = path.resolve(REPOSITORY_ROOT, "packages/core/tests/fixtures/basic-react"); +const SNAPSHOT_DIRECTORY = path.join(SCRIPT_DIRECTORY, "compatibility", "snapshots"); +const CLI_HELP_SNAPSHOT_PATH = path.join(SNAPSHOT_DIRECTORY, "cli-help.json"); +const PACKED_ENTRY_SNAPSHOT_PATH = path.join(SNAPSHOT_DIRECTORY, "packed-public-entry-points.json"); const FORBIDDEN_INSTALLED_PACKAGES: readonly string[] = [ "ini", "effect", "@effect/platform-node-shared", ]; const COMMAND_OUTPUT_MAX_BYTES = 50 * 1024 * 1024; +const RUNTIME_EXPORT_MARKER = "REACT_DOCTOR_RUNTIME_EXPORTS="; const isRecord = (value: unknown): value is StringRecord => typeof value === "object" && value !== null && !Array.isArray(value); @@ -35,6 +51,7 @@ const runCommand = (input: CommandInput) => { const result = spawnSync(input.command, [...input.args], { cwd: input.cwd, encoding: "utf-8", + env: input.environment, maxBuffer: COMMAND_OUTPUT_MAX_BYTES, shell: input.needsShell === true, }); @@ -52,6 +69,261 @@ const runCommand = (input: CommandInput) => { return result; }; +const readJson = (filePath: string): Value => JSON.parse(fs.readFileSync(filePath, "utf8")); + +const writeJson = (filePath: string, value: unknown): void => { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +}; + +const toPosixPath = (filePath: string): string => filePath.split(path.sep).join("/"); + +const collectPackageFiles = (packageDirectory: string): string[] => { + const packageFiles: string[] = []; + const pendingDirectories = [packageDirectory]; + while (pendingDirectories.length > 0) { + const currentDirectory = pendingDirectories.pop(); + if (currentDirectory === undefined) break; + + for (const directoryEntry of fs.readdirSync(currentDirectory, { withFileTypes: true })) { + if (directoryEntry.name === "node_modules") continue; + const entryPath = path.join(currentDirectory, directoryEntry.name); + if (directoryEntry.isDirectory()) { + pendingDirectories.push(entryPath); + } else if (directoryEntry.isFile()) { + packageFiles.push(toPosixPath(path.relative(packageDirectory, entryPath))); + } + } + } + return packageFiles.sort(); +}; + +const matchesPackedFilePattern = (filePath: string, pattern: string): boolean => { + if (pattern.startsWith("**/*")) return filePath.endsWith(pattern.slice(4)); + if (!pattern.endsWith("/**")) return filePath === pattern; + const directoryPrefix = pattern.slice(0, -3); + return filePath === directoryPrefix || filePath.startsWith(`${directoryPrefix}/`); +}; + +const assertPackedFilePolicy = (packageDirectory: string, policy: PackedFilePolicy): void => { + const packageFiles = collectPackageFiles(packageDirectory); + const missingFiles = policy.requiredFiles.filter( + (requiredFile) => !packageFiles.includes(requiredFile), + ); + const disallowedFiles = packageFiles.filter( + (packageFile) => + !policy.allowedPatterns.some((allowedPattern) => + matchesPackedFilePattern(packageFile, allowedPattern), + ), + ); + const explicitlyDeniedFiles = packageFiles.filter((packageFile) => + policy.deniedPatterns.some((deniedPattern) => + matchesPackedFilePattern(packageFile, deniedPattern), + ), + ); + if ( + missingFiles.length === 0 && + disallowedFiles.length === 0 && + explicitlyDeniedFiles.length === 0 + ) { + return; + } + + console.error(`Packed file policy failed for ${policy.packageName}.`); + if (missingFiles.length > 0) console.error(`Missing: ${missingFiles.join(", ")}`); + if (disallowedFiles.length > 0) console.error(`Not allowed: ${disallowedFiles.join(", ")}`); + if (explicitlyDeniedFiles.length > 0) { + console.error(`Explicitly denied: ${explicitlyDeniedFiles.join(", ")}`); + } + process.exit(1); +}; + +const collectConditionalTargets = (exportValue: unknown, conditionName: string): string[] => { + if (!isRecord(exportValue)) return []; + const targets: string[] = []; + for (const [condition, conditionValue] of Object.entries(exportValue)) { + if (condition === conditionName && typeof conditionValue === "string") { + targets.push(conditionValue); + } else { + targets.push(...collectConditionalTargets(conditionValue, conditionName)); + } + } + return targets; +}; + +const collectRuntimeTargets = (exportValue: unknown): string[] => { + if (typeof exportValue === "string") return [exportValue]; + if (!isRecord(exportValue)) return []; + + return Object.entries(exportValue).flatMap(([condition, conditionValue]) => + condition === "types" ? [] : collectRuntimeTargets(conditionValue), + ); +}; + +const assertPackedEntryFiles = ( + installDirectory: string, + entrySnapshots: ReadonlyArray, +): void => { + for (const entrySnapshot of entrySnapshots) { + const packageDirectory = path.join(installDirectory, "node_modules", entrySnapshot.packageName); + const manifest = readJson(path.join(packageDirectory, "package.json")); + const exportValue = readPackageExportValue(manifest.exports, entrySnapshot.subpath); + const runtimeTargets = collectRuntimeTargets(exportValue); + const declarationTargets = collectConditionalTargets(exportValue, "types"); + + for (const target of [...runtimeTargets, ...declarationTargets]) { + const targetPath = path.resolve(packageDirectory, target); + if (!fs.existsSync(targetPath)) { + console.error( + `Packed entry ${entrySnapshot.packageName}${entrySnapshot.subpath} points to missing ${target}.`, + ); + process.exit(1); + } + } + + const hasJavaScriptRuntime = runtimeTargets.some((target) => + [".js", ".mjs", ".cjs"].includes(path.extname(target)), + ); + if (hasJavaScriptRuntime && declarationTargets.length === 0) { + console.error( + `Packed entry ${entrySnapshot.packageName}${entrySnapshot.subpath} has no declaration target.`, + ); + process.exit(1); + } + } +}; + +const assertPackedBins = (packageDirectory: string): void => { + const manifest = readJson(path.join(packageDirectory, "package.json")); + const binValue = manifest.bin; + let binTargets: string[] = []; + if (typeof binValue === "string") { + binTargets = [binValue]; + } else if (isRecord(binValue)) { + binTargets = Object.values(binValue).filter( + (target): target is string => typeof target === "string", + ); + } + for (const binTarget of binTargets) { + if (fs.existsSync(path.resolve(packageDirectory, binTarget))) continue; + console.error(`Packed bin target is missing: ${packageDirectory} -> ${binTarget}.`); + process.exit(1); + } +}; + +const toPackageSpecifier = (entrySnapshot: PackedEntrySnapshot): string => + entrySnapshot.subpath === "." + ? entrySnapshot.packageName + : `${entrySnapshot.packageName}/${entrySnapshot.subpath.slice(2)}`; + +const probeRuntimeExportKeys = ( + installDirectory: string, + entrySnapshot: PackedEntrySnapshot, + moduleMode: "import" | "require", +): string[] => { + const packageSpecifier = toPackageSpecifier(entrySnapshot); + const importAttributes = packageSpecifier.endsWith("/package.json") + ? ', { with: { type: "json" } }' + : ""; + let probeSource: string; + if (moduleMode === "import") { + probeSource = `const namespace = await import(${JSON.stringify(packageSpecifier)}${importAttributes}); +process.stdout.write(${JSON.stringify(RUNTIME_EXPORT_MARKER)} + JSON.stringify(Object.keys(namespace).sort()) + "\\n");`; + } else { + probeSource = `const { createRequire } = await import("node:module"); +const namespace = createRequire(import.meta.url)(${JSON.stringify(packageSpecifier)}); +process.stdout.write(${JSON.stringify(RUNTIME_EXPORT_MARKER)} + JSON.stringify(Object.keys(namespace).sort()) + "\\n");`; + } + const result = runCommand({ + command: process.execPath, + args: ["--input-type=module", "--eval", probeSource], + cwd: installDirectory, + environment: { + ...process.env, + NO_COLOR: "1", + REACT_DOCTOR_NO_TELEMETRY: "1", + }, + }); + const markerLine = result.stdout + .split(/\r?\n/) + .find((outputLine) => outputLine.startsWith(RUNTIME_EXPORT_MARKER)); + if (markerLine === undefined) { + console.error(`Runtime export probe produced no result for ${packageSpecifier}.`); + process.exit(1); + } + return JSON.parse(markerLine.slice(RUNTIME_EXPORT_MARKER.length)); +}; + +const captureRuntimeEntrySnapshots = ( + installDirectory: string, + entrySnapshots: ReadonlyArray, +): PackedEntrySnapshot[] => + entrySnapshots.map((entrySnapshot) => { + if (entrySnapshot.executionOnly === true) return entrySnapshot; + if (entrySnapshot.exportKeys === undefined) { + console.error( + `Library entry ${entrySnapshot.packageName}${entrySnapshot.subpath} is missing runtime export keys.`, + ); + process.exit(1); + } + const exportKeys: { import?: ReadonlyArray; require?: ReadonlyArray } = {}; + if (entrySnapshot.exportKeys.import !== undefined) { + exportKeys.import = probeRuntimeExportKeys(installDirectory, entrySnapshot, "import"); + } + if (entrySnapshot.exportKeys.require !== undefined) { + exportKeys.require = probeRuntimeExportKeys(installDirectory, entrySnapshot, "require"); + } + return { ...entrySnapshot, exportKeys }; + }); + +const captureCliHelpSnapshot = ( + cliModulePath: string, + version: string, +): { snapshot: CliHelpSnapshotEntry[]; rawOutputByName: ReadonlyMap } => { + const workingDirectory = os.tmpdir(); + const rawOutputByName = new Map(); + const snapshot = CLI_HELP_INVOCATIONS.map(({ name, arguments: argumentsList }) => { + const result = runCommand({ + command: process.execPath, + args: [cliModulePath, ...argumentsList], + cwd: workingDirectory, + environment: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" }, + }); + rawOutputByName.set(name, result.stdout); + return { + name, + arguments: argumentsList, + output: normalizeCliHelp(result.stdout, workingDirectory, version), + }; + }); + return { snapshot, rawOutputByName }; +}; + +const assertHelpCommandCoverage = (rawOutputByName: ReadonlyMap): void => { + const invocationPaths = new Set( + CLI_HELP_INVOCATIONS.map(({ arguments: argumentsList }) => + argumentsList.filter((argument) => argument !== "--help").join(" "), + ), + ); + for (const [parentName, parentPath] of [ + ["root", ""], + ["ci", "ci"], + ["rules", "rules"], + ]) { + const parentHelp = rawOutputByName.get(parentName); + if (parentHelp === undefined) { + console.error(`Missing captured help for ${parentName}.`); + process.exit(1); + } + for (const commandAlias of parseHelpCommandAliases(parentHelp)) { + const commandPath = [parentPath, commandAlias].filter(Boolean).join(" "); + if (!invocationPaths.has(commandPath)) { + console.error(`CLI help baseline is missing the supported command "${commandPath}".`); + process.exit(1); + } + } + } +}; + const readPackageName = (packageDirectory: string): string | null => { const packageJsonPath = path.join(packageDirectory, "package.json"); if (!fs.existsSync(packageJsonPath)) return null; @@ -99,6 +371,23 @@ const assertFixtureExists = (): void => { const main = (): void => { assertFixtureExists(); + const argumentsList = process.argv.slice(2); + const shouldUpdateSnapshots = + argumentsList.length === 1 && argumentsList[0] === "--update-snapshots"; + if (argumentsList.length > 0 && !shouldUpdateSnapshots) { + console.error("Usage: node scripts/smoke-packed-cli-install.ts [--update-snapshots]"); + process.exit(1); + } + if (!fs.existsSync(CLI_HELP_SNAPSHOT_PATH) && !shouldUpdateSnapshots) { + console.error(`Missing ${path.relative(REPOSITORY_ROOT, CLI_HELP_SNAPSHOT_PATH)}.`); + process.exit(1); + } + if (!fs.existsSync(PACKED_ENTRY_SNAPSHOT_PATH)) { + console.error(`Missing ${path.relative(REPOSITORY_ROOT, PACKED_ENTRY_SNAPSHOT_PATH)}.`); + process.exit(1); + } + + const packedSnapshot = readJson(PACKED_ENTRY_SNAPSHOT_PATH); const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-packed-cli-")); const packDirectory = path.join(temporaryDirectory, "pack"); @@ -112,7 +401,7 @@ const main = (): void => { `${JSON.stringify({ name: "react-doctor-packed-cli-smoke", private: true }, null, 2)}\n`, ); - // Pack the CLI together with its unbundled workspace dependencies: + // Pack the CLI together with every public package: // changesets version-bumps and publishes them as a pinned set, so // installing the tarballs mirrors what a release ships. The CLI keeps // `oxlint-plugin-react-doctor` and `deslop-js` external (neverBundle — @@ -123,12 +412,7 @@ const main = (): void => { runCommand({ command: "pnpm", args: [ - "--filter", - "react-doctor", - "--filter", - "oxlint-plugin-react-doctor", - "--filter", - "deslop-js", + ...packedSnapshot.filePolicies.flatMap(({ packageName }) => ["--filter", packageName]), "pack", "--pack-destination", packDirectory, @@ -138,9 +422,9 @@ const main = (): void => { }); const tarballs = fs.readdirSync(packDirectory).filter((fileName) => fileName.endsWith(".tgz")); - if (tarballs.length !== 3) { + if (tarballs.length !== packedSnapshot.filePolicies.length) { console.error( - `Expected exactly three packed tarballs in ${packDirectory}, found ${tarballs.length}.`, + `Expected ${packedSnapshot.filePolicies.length} packed tarballs in ${packDirectory}, found ${tarballs.length}.`, ); process.exit(1); } @@ -156,6 +440,15 @@ const main = (): void => { const installedPackages = collectInstalledPackageNames( path.join(installDirectory, "node_modules"), ); + for (const filePolicy of packedSnapshot.filePolicies) { + const packageDirectory = path.join(installDirectory, "node_modules", filePolicy.packageName); + if (!fs.existsSync(packageDirectory)) { + console.error(`Packed install is missing ${filePolicy.packageName}.`); + process.exit(1); + } + assertPackedFilePolicy(packageDirectory, filePolicy); + assertPackedBins(packageDirectory); + } const forbiddenPackages = FORBIDDEN_INSTALLED_PACKAGES.filter((packageName) => installedPackages.has(packageName), ); @@ -166,6 +459,23 @@ const main = (): void => { process.exit(1); } + assertPackedEntryFiles(installDirectory, packedSnapshot.entries); + const currentEntrySnapshots = captureRuntimeEntrySnapshots( + installDirectory, + packedSnapshot.entries, + ); + if (shouldUpdateSnapshots) { + writeJson(PACKED_ENTRY_SNAPSHOT_PATH, { + ...packedSnapshot, + entries: currentEntrySnapshots, + }); + } else if (!isDeepStrictEqual(currentEntrySnapshots, packedSnapshot.entries)) { + console.error("Packed runtime export key drift detected."); + console.error("Expected:", JSON.stringify(packedSnapshot.entries, null, 2)); + console.error("Received:", JSON.stringify(currentEntrySnapshots, null, 2)); + process.exit(1); + } + const binaryPath = path.join( installDirectory, "node_modules", @@ -184,6 +494,39 @@ const main = (): void => { process.exit(1); } + const deslopBinaryPath = path.join( + installDirectory, + "node_modules", + "deslop-cli", + "dist", + "cli.mjs", + ); + runCommand({ + command: process.execPath, + args: [deslopBinaryPath, "--help"], + cwd: installDirectory, + }); + + const cliModulePath = path.join( + installDirectory, + "node_modules", + "react-doctor", + "dist", + "cli.js", + ); + const cliHelp = captureCliHelpSnapshot(cliModulePath, version); + assertHelpCommandCoverage(cliHelp.rawOutputByName); + if (shouldUpdateSnapshots) { + writeJson(CLI_HELP_SNAPSHOT_PATH, cliHelp.snapshot); + } else { + const expectedCliHelp = readJson>(CLI_HELP_SNAPSHOT_PATH); + if (!isDeepStrictEqual(cliHelp.snapshot, expectedCliHelp)) { + console.error("Packed CLI help compatibility snapshot drift detected."); + console.error("Run `nr compatibility:packed:update` after reviewing the change."); + process.exit(1); + } + } + const scanResult = runCommand({ command: process.execPath, args: [ @@ -199,9 +542,10 @@ const main = (): void => { allowedStatuses: [0, 1], }); - let decoded: ReturnType>; + const decodeJsonReport = Schema.decodeUnknownSync(JsonReport); + let decoded: ReturnType; try { - decoded = Schema.decodeUnknownSync(JsonReport)(JSON.parse(scanResult.stdout)); + decoded = decodeJsonReport(JSON.parse(scanResult.stdout)); } catch (cause) { console.error("Installed CLI did not produce a schema-valid JsonReport."); console.error("stdout:", scanResult.stdout.slice(0, 2_000)); @@ -210,11 +554,11 @@ const main = (): void => { } console.log( - `Packed install smoke OK: version=${version} diagnostics=${decoded.diagnostics.length} forbiddenPackages=0`, + `Packed install smoke OK: version=${version} diagnostics=${decoded.diagnostics.length} entries=${currentEntrySnapshots.length} help=${cliHelp.snapshot.length} forbiddenPackages=0`, ); } finally { fs.rmSync(temporaryDirectory, { recursive: true, force: true }); } }; -main(); +if (path.resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) main(); diff --git a/scripts/sync-react-doctor-skill.mjs b/scripts/sync-react-doctor-skill.mjs new file mode 100644 index 0000000000..3fac0045fe --- /dev/null +++ b/scripts/sync-react-doctor-skill.mjs @@ -0,0 +1,106 @@ +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const SCRIPT_FILE_PATH = fileURLToPath(import.meta.url); +const REPOSITORY_ROOT = path.resolve(path.dirname(SCRIPT_FILE_PATH), ".."); + +export const CANONICAL_REACT_DOCTOR_SKILL_DIRECTORY = path.join( + REPOSITORY_ROOT, + "skills", + "react-doctor", +); +export const REACT_DOCTOR_SKILL_ADAPTER_DIRECTORY = path.join( + REPOSITORY_ROOT, + ".agents", + "skills", + "react-doctor", +); + +const hashFileContents = (filePath) => + crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); + +const readSkillTreeEntries = (rootDirectory) => { + if (!fs.existsSync(rootDirectory)) return new Map(); + + const entries = new Map(); + const visit = (directory) => { + const directoryEntries = fs.readdirSync(directory, { withFileTypes: true }); + for (const directoryEntry of directoryEntries) { + const absolutePath = path.join(directory, directoryEntry.name); + const relativePath = path.relative(rootDirectory, absolutePath).split(path.sep).join("/"); + if (directoryEntry.isDirectory()) { + entries.set(relativePath, "directory"); + visit(absolutePath); + } else if (directoryEntry.isFile()) { + entries.set(relativePath, `file:${hashFileContents(absolutePath)}`); + } else if (directoryEntry.isSymbolicLink()) { + entries.set(relativePath, `symlink:${fs.readlinkSync(absolutePath)}`); + } else { + entries.set(relativePath, "unsupported"); + } + } + }; + + visit(rootDirectory); + return entries; +}; + +export const findReactDoctorSkillTreeMismatches = ( + canonicalDirectory = CANONICAL_REACT_DOCTOR_SKILL_DIRECTORY, + adapterDirectory = REACT_DOCTOR_SKILL_ADAPTER_DIRECTORY, +) => { + const canonicalEntries = readSkillTreeEntries(canonicalDirectory); + const adapterEntries = readSkillTreeEntries(adapterDirectory); + const relativePaths = [...new Set([...canonicalEntries.keys(), ...adapterEntries.keys()])].sort(); + const missingCanonicalSkill = canonicalEntries.has("SKILL.md") + ? [] + : ["missing canonical entry: SKILL.md"]; + + return [ + ...missingCanonicalSkill, + ...relativePaths.flatMap((relativePath) => { + const canonicalEntry = canonicalEntries.get(relativePath); + const adapterEntry = adapterEntries.get(relativePath); + if (canonicalEntry === adapterEntry) return []; + if (canonicalEntry === undefined) return [`extra adapter entry: ${relativePath}`]; + if (adapterEntry === undefined) return [`missing adapter entry: ${relativePath}`]; + return [`changed adapter entry: ${relativePath}`]; + }), + ]; +}; + +export const synchronizeReactDoctorSkill = ( + canonicalDirectory = CANONICAL_REACT_DOCTOR_SKILL_DIRECTORY, + adapterDirectory = REACT_DOCTOR_SKILL_ADAPTER_DIRECTORY, +) => { + if (!fs.existsSync(path.join(canonicalDirectory, "SKILL.md"))) { + throw new Error(`Canonical React Doctor skill is missing: ${canonicalDirectory}`); + } + + fs.rmSync(adapterDirectory, { recursive: true, force: true }); + fs.mkdirSync(path.dirname(adapterDirectory), { recursive: true }); + fs.cpSync(canonicalDirectory, adapterDirectory, { recursive: true }); +}; + +const runSkillSynchronization = () => { + const argumentsList = process.argv.slice(2); + const shouldOnlyCheck = argumentsList.length === 1 && argumentsList[0] === "--check"; + if (argumentsList.length > 0 && !shouldOnlyCheck) { + throw new Error("Usage: node scripts/sync-react-doctor-skill.mjs [--check]"); + } + + if (!shouldOnlyCheck) synchronizeReactDoctorSkill(); + + const mismatches = findReactDoctorSkillTreeMismatches(); + process.stdout.write(`React Doctor skill adapter mismatch count: ${mismatches.length}\n`); + if (mismatches.length === 0) return; + + process.stderr.write( + `${mismatches.join("\n")}\nRun \`nr skills:sync\` from the repository root.\n`, + ); + process.exitCode = 1; +}; + +if (path.resolve(process.argv[1] ?? "") === SCRIPT_FILE_PATH) runSkillSynchronization(); diff --git a/scripts/utils/find-published-package-manifests.ts b/scripts/utils/find-published-package-manifests.ts new file mode 100644 index 0000000000..9eb6000cd1 --- /dev/null +++ b/scripts/utils/find-published-package-manifests.ts @@ -0,0 +1,46 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +export interface PackageManifest { + readonly name: string; + readonly private?: boolean; + readonly type?: string; + readonly main?: string; + readonly module?: string; + readonly types?: string; + readonly bin?: string | Readonly>; + readonly exports?: unknown; + readonly files?: ReadonlyArray; + readonly sideEffects?: boolean | ReadonlyArray; + readonly engines?: Readonly>; + readonly peerDependencies?: Readonly>; + readonly dependencies?: Readonly>; + readonly optionalDependencies?: Readonly>; +} + +export interface PublishedPackageManifest { + readonly directory: string; + readonly manifest: PackageManifest; +} + +export const readPackageManifest = (packageDirectory: string): PackageManifest => + JSON.parse(fs.readFileSync(path.join(packageDirectory, "package.json"), "utf8")); + +export const findPublishedPackageManifests = ( + packagesDirectory: string, +): ReadonlyArray => + fs + .readdirSync(packagesDirectory, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(packagesDirectory, entry.name)) + .filter((packageDirectory) => fs.existsSync(path.join(packageDirectory, "package.json"))) + .map((directory) => ({ + directory, + manifest: readPackageManifest(directory), + })) + .filter(({ manifest }) => manifest.private !== true) + .sort((leftPackage, rightPackage) => { + if (leftPackage.manifest.name < rightPackage.manifest.name) return -1; + if (leftPackage.manifest.name > rightPackage.manifest.name) return 1; + return 0; + }); diff --git a/scripts/utils/read-package-export-value.ts b/scripts/utils/read-package-export-value.ts new file mode 100644 index 0000000000..70b9032087 --- /dev/null +++ b/scripts/utils/read-package-export-value.ts @@ -0,0 +1,9 @@ +export const readPackageExportValue = (exportsValue: unknown, subpath: string): unknown => { + if (typeof exportsValue !== "object" || exportsValue === null || Array.isArray(exportsValue)) { + return subpath === "." ? exportsValue : undefined; + } + + const hasSubpathKeys = Object.keys(exportsValue).some((exportKey) => exportKey.startsWith(".")); + if (hasSubpathKeys) return Reflect.get(exportsValue, subpath); + return subpath === "." ? exportsValue : undefined; +}; diff --git a/scripts/utils/read-package-version.ts b/scripts/utils/read-package-version.ts new file mode 100644 index 0000000000..72c7110b34 --- /dev/null +++ b/scripts/utils/read-package-version.ts @@ -0,0 +1,18 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const readPackageVersion = (moduleUrl: string): string => { + const packageRoot = path.dirname(fileURLToPath(moduleUrl)); + const manifestPath = path.join(packageRoot, "package.json"); + const manifest: unknown = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + if ( + typeof manifest !== "object" || + manifest === null || + !("version" in manifest) || + typeof manifest.version !== "string" + ) { + throw new Error(`Package manifest at ${manifestPath} has no string version.`); + } + return manifest.version; +};