From f2c729bf77d8eb1b0ec902b21a1573ec11ecd759 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 11 Aug 2026 14:54:27 -0700 Subject: [PATCH] FN-8994: add workspace package graph validation Prevent cold workspace installs from failing on missing or unglobbed local packages. - Validate workspace-protocol dependencies and overrides against glob-covered packages. - Add static-gate coverage and regression tests for missing plugin packages. - Document the workspace package graph check. Files changed: docs/testing.md | 4 +- package.json | 9 +- scripts/__tests__/check-workspace-package-graph.test.mjs | 103 +++++++++++++++ scripts/check-workspace-package-graph.mjs | 138 +++++++++++++++++++++ 4 files changed, 249 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-8994 Fusion-Task-Lineage: 17dbe062-79aa-4417-ae38-43df881e9fa4 Co-authored-by: Fusion (runfusion.ai) --- docs/testing.md | 4 +- package.json | 9 +- .../check-workspace-package-graph.test.mjs | 103 +++++++++++++ scripts/check-workspace-package-graph.mjs | 138 ++++++++++++++++++ 4 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 scripts/__tests__/check-workspace-package-graph.test.mjs create mode 100644 scripts/check-workspace-package-graph.mjs diff --git a/docs/testing.md b/docs/testing.md index 7585dc46e7..fadf5662e3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -48,7 +48,9 @@ pnpm verify:workspace # deep opt-in verification: lint -> test:full -> build (N -`pnpm verify:fast` (`scripts/verify-fast.mjs`) is the recommended **test-free verification** command. It first runs the canonical, read-only static validators from root `pretest` — `check-no-nohup`, `check-no-kill-4040`, `check-no-getdatabase`, `check-prerebase-inert`, `check-no-node-only-core-imports-in-dashboard`, `check-pi-versions-pinned`, `check-no-test-timeout-appeasement`, `check-changeset-format`, `check-routes-modular`, and `check-runtime-skill-loader-drift` (which enforces the Claude/Grok runtime skill loaders' clean rename-diff) — then bootstraps missing/stale workspace dist artifacts, runs **typecheck + build scoped to the changed packages** (reusing the same git-diff / changed-package resolution as `pnpm test`), always builds the `@runfusion/fusion` CLI package required by the source-checkout boot smoke, and runs the existing **boot smoke** once. The static phase invokes each existing validator entry point without update flags, is bounded and fail-fast, and runs **no Vitest or test lane**. It gives deterministic, flake-free signal in seconds, so it is a sound project `testCommand`/verification command when you want non-test verification. With no affected package (root/docs-only diff) it runs static checks, artifact bootstrap, the CLI prerequisite build, and boot smoke. Each step is bounded by the shared `runWithWatchdog` (class `changed`) so a hang fails fast, and it exits nonzero on the first failing step. This is purely additive: it does not change `pnpm test`, the merge gate, or CI, and the full suite stays available (`pnpm test:full`, non-blocking on push to main). +`pnpm verify:fast` (`scripts/verify-fast.mjs`) is the recommended **test-free verification** command. It first runs the canonical, read-only static validators from root `pretest` — `check-no-nohup`, `check-no-kill-4040`, `check-no-getdatabase`, `check-prerebase-inert`, `check-no-node-only-core-imports-in-dashboard`, `check-pi-versions-pinned`, `check-workspace-package-graph`, `check-no-test-timeout-appeasement`, `check-changeset-format`, `check-routes-modular`, and `check-runtime-skill-loader-drift` (which enforces the Claude/Grok runtime skill loaders' clean rename-diff) — then bootstraps missing/stale workspace dist artifacts, runs **typecheck + build scoped to the changed packages** (reusing the same git-diff / changed-package resolution as `pnpm test`), always builds the `@runfusion/fusion` CLI package required by the source-checkout boot smoke, and runs the existing **boot smoke** once. The static phase invokes each existing validator entry point without update flags, is bounded and fail-fast, and runs **no Vitest or test lane**. It gives deterministic, flake-free signal in seconds, so it is a sound project `testCommand`/verification command when you want non-test verification. With no affected package (root/docs-only diff) it runs static checks, artifact bootstrap, the CLI prerequisite build, and boot smoke. Each step is bounded by the shared `runWithWatchdog` (class `changed`) so a hang fails fast, and it exits nonzero on the first failing step. This is purely additive: it does not change `pnpm test`, the merge gate, or CI, and the full suite stays available (`pnpm test:full`, non-blocking on push to main). + +`pnpm check:workspace-package-graph` verifies that every `workspace:` dependency or override in the root importer or a glob-matched workspace manifest resolves to a glob-covered workspace package, and that no package directory under `packages/` or `plugins/` falls outside `pnpm-workspace.yaml` package globs. `pnpm build` runs `scripts/build-workspace.mjs`: non-plugin workspace packages with build scripts still build on every run (excluding `@fusion/desktop` and `@fusion/mobile`), while plugin packages under `plugins/` and `plugins/examples/` can be skipped when `.fusion/cache/plugin-build-cache.json` records the same content hash as the current plugin package inputs plus declared local workspace-dependency inputs, root TypeScript/pnpm/build-tooling inputs, and all required `dist/` outputs are present. A plugin rebuild is forced for a missing or partial `dist/`, no successful-build cache entry, changed tracked or untracked plugin/dependency/root build inputs, unavailable git content hash, or build-cache version changes. The cache is an optimization only; cache writes are best-effort and a failed package build still makes `pnpm build` exit nonzero with the planned package names. diff --git a/package.json b/package.json index fe7fe794ee..269f93c04e 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "type": "module", "packageManager": "pnpm@10.33.0", "scripts": { - "pretest": "node scripts/check-no-nohup.mjs && node scripts/check-no-cwd-relative-dashboard-test-reads.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-prerebase-inert.mjs && node scripts/check-capacity-pool-id.mjs && node scripts/check-no-node-only-core-imports-in-dashboard.mjs && node scripts/check-pi-versions-pinned.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-routes-modular.mjs && node scripts/check-runtime-skill-loader-drift.mjs", - "pretest:full": "node scripts/check-no-nohup.mjs && node scripts/check-no-cwd-relative-dashboard-test-reads.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-prerebase-inert.mjs && node scripts/check-capacity-pool-id.mjs && node scripts/check-no-node-only-core-imports-in-dashboard.mjs && node scripts/check-pi-versions-pinned.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-routes-modular.mjs && node scripts/check-runtime-skill-loader-drift.mjs", + "pretest": "node scripts/check-no-nohup.mjs && node scripts/check-no-cwd-relative-dashboard-test-reads.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-prerebase-inert.mjs && node scripts/check-capacity-pool-id.mjs && node scripts/check-no-node-only-core-imports-in-dashboard.mjs && node scripts/check-pi-versions-pinned.mjs && node scripts/check-workspace-package-graph.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-routes-modular.mjs && node scripts/check-runtime-skill-loader-drift.mjs", + "pretest:full": "node scripts/check-no-nohup.mjs && node scripts/check-no-cwd-relative-dashboard-test-reads.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-prerebase-inert.mjs && node scripts/check-capacity-pool-id.mjs && node scripts/check-no-node-only-core-imports-in-dashboard.mjs && node scripts/check-pi-versions-pinned.mjs && node scripts/check-workspace-package-graph.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-routes-modular.mjs && node scripts/check-runtime-skill-loader-drift.mjs", "check:line-count": "node scripts/check-file-line-count.mjs", "check:routes-modular": "node scripts/check-routes-modular.mjs", "check:changesets": "node scripts/check-changeset-format.mjs", @@ -31,7 +31,7 @@ "census:lifecycle-columns": "node scripts/lifecycle-column-census.mjs", "check:quarantine-ledger": "node scripts/check-quarantine-ledger.mjs --strict", "check:mock-completeness": "node scripts/check-mock-completeness.mjs", - "test:gate:static": "node scripts/check-no-nohup.mjs && node scripts/check-no-cwd-relative-dashboard-test-reads.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-prerebase-inert.mjs && node scripts/check-capacity-pool-id.mjs && node scripts/check-no-node-only-core-imports-in-dashboard.mjs && node scripts/check-pi-versions-pinned.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-mock-completeness.mjs && node scripts/check-inert-sync-lane-conversions.mjs && node scripts/check-runtime-skill-loader-drift.mjs", + "test:gate:static": "node scripts/check-no-nohup.mjs && node scripts/check-no-cwd-relative-dashboard-test-reads.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-prerebase-inert.mjs && node scripts/check-capacity-pool-id.mjs && node scripts/check-no-node-only-core-imports-in-dashboard.mjs && node scripts/check-pi-versions-pinned.mjs && node scripts/check-workspace-package-graph.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-mock-completeness.mjs && node scripts/check-inert-sync-lane-conversions.mjs && node scripts/check-runtime-skill-loader-drift.mjs", "test:gate": "node scripts/run-static-gate-checks.mjs && sh -c 'pnpm --filter @fusion/engine test:core & engine_pid=$!; pnpm --filter @fusion/core test:pg-gate & pg_pid=$!; pnpm --filter @fusion/core test:unit-gate & unit_pid=$!; status=0; wait $engine_pid || status=1; wait $pg_pid || status=1; wait $unit_pid || status=1; exit $status' && pnpm --filter @runfusion/fusion test:ci-shape", "smoke:boot": "node scripts/boot-smoke.mjs", "local": "node scripts/start-local.mjs", @@ -92,7 +92,8 @@ "mobile:run:android": "bash scripts/mobile-run-android.sh", "ios:acceptance": "node scripts/ios-acceptance.mjs", "build:desktop": "pnpm --filter @fusion/desktop build", - "dist:desktop:win": "pnpm --filter @fusion/desktop build && pnpm --filter @fusion/desktop dist:win" + "dist:desktop:win": "pnpm --filter @fusion/desktop build && pnpm --filter @fusion/desktop dist:win", + "check:workspace-package-graph": "node scripts/check-workspace-package-graph.mjs" }, "pnpm": { "ignoredBuiltDependencies": [ diff --git a/scripts/__tests__/check-workspace-package-graph.test.mjs b/scripts/__tests__/check-workspace-package-graph.test.mjs new file mode 100644 index 0000000000..8297ed2003 --- /dev/null +++ b/scripts/__tests__/check-workspace-package-graph.test.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + matchesWorkspaceGlob, + readWorkspacePackageGraph, + scanWorkspacePackageGraph, + validateWorkspacePackageGraph, +} from "../check-workspace-package-graph.mjs"; +import { readStaticGateChecks } from "../run-static-gate-checks.mjs"; + +const GLOBS = ["packages/*", "plugins/fusion-plugin-*"]; +const VALID_PLUGIN = { + filePath: "plugins/fusion-plugin-claude-runtime/package.json", + manifest: { name: "@fusion-plugin-examples/claude-runtime" }, +}; + +function validate(manifests, workspaceGlobs = GLOBS) { + return validateWorkspacePackageGraph({ manifests, workspaceGlobs }); +} + +describe("check-workspace-package-graph", () => { + it("matches package globs without relying on node:path.matchesGlob", () => { + assert.equal(matchesWorkspaceGlob("packages/dashboard", "packages/*"), true); + assert.equal(matchesWorkspaceGlob("packages/dashboard/nested", "packages/*"), false); + assert.equal(matchesWorkspaceGlob("plugins/fusion-plugin-claude-runtime", "plugins/fusion-plugin-*"), true); + assert.equal(matchesWorkspaceGlob("plugins/examples/claude-runtime", "plugins/examples/*"), true); + }); + + it("passes against the live repository", () => { + assert.deepEqual(scanWorkspacePackageGraph(), []); + }); + + it("includes the root importer without treating it as an unglobbed package", () => { + const { manifests } = readWorkspacePackageGraph(); + assert.equal(manifests.some(({ filePath, isRoot }) => filePath === "package.json" && isRoot), true); + assert.deepEqual(validate([{ filePath: "package.json", isRoot: true, manifest: { name: "fusion-workspace" } }]), []); + }); + + it("rejects dangling workspace dependencies from the root importer", () => { + const violations = validate([{ + filePath: "package.json", + isRoot: true, + manifest: { dependencies: { "@fusion-plugin-examples/missing": "workspace:*" } }, + }]); + assert.equal(violations.length, 1); + assert.match(violations[0], /package\.json/); + assert.match(violations[0], /@fusion-plugin-examples\/missing/); + }); + + it("rejects dangling workspace-protocol overrides", () => { + const violations = validate([{ + filePath: "package.json", + isRoot: true, + manifest: { pnpm: { overrides: { "@fusion-plugin-examples/missing": "workspace:*" } } }, + }]); + assert.equal(violations.length, 1); + assert.match(violations[0], /overrides\.@fusion-plugin-examples\/missing/); + }); + + it("reconstructs the missing claude-runtime package symptom", () => { + const violations = validate([{ + filePath: "packages/dashboard/package.json", + manifest: { dependencies: { "@fusion-plugin-examples/claude-runtime": "workspace:*" } }, + }]); + assert.deepEqual(violations.length, 1); + assert.match(violations[0], /packages\/dashboard\/package\.json/); + assert.match(violations[0], /@fusion-plugin-examples\/claude-runtime/); + }); + + it("checks every dependency block and accepts published or non-workspace dependencies", () => { + for (const blockName of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) { + const violations = validate([{ filePath: "packages/dashboard/package.json", manifest: { [blockName]: { missing: "workspace:*" } } }]); + assert.equal(violations.length, 1, `${blockName} must be checked`); + } + assert.deepEqual(validate([ + VALID_PLUGIN, + { filePath: "packages/dashboard/package.json", manifest: { dependencies: { "@fusion-plugin-examples/claude-runtime": "workspace:*", external: "^1.2.3" } } }, + { filePath: "packages/empty/package.json", manifest: {} }, + { filePath: "packages/unnamed/package.json", manifest: { name: "" } }, + ]), []); + }); + + it("reports every dangling declaration across manifests", () => { + const violations = validate([ + { filePath: "packages/one/package.json", manifest: { dependencies: { one: "workspace:*" } } }, + { filePath: "packages/two/package.json", manifest: { optionalDependencies: { two: "workspace:*" } } }, + ]); + assert.equal(violations.length, 2); + }); + + it("rejects package directories outside workspace globs", () => { + const violations = validate([ + { filePath: "plugins/unlisted/package.json", manifest: { name: "unlisted" } }, + ], ["packages/*"]); + assert.equal(violations.length, 1); + assert.match(violations[0], /plugins\/unlisted\/package\.json/); + assert.match(violations[0], /not covered/); + }); + + it("is included in the blocking static gate inventory", () => { + assert.equal(readStaticGateChecks().includes("scripts/check-workspace-package-graph.mjs"), true); + }); +}); diff --git a/scripts/check-workspace-package-graph.mjs b/scripts/check-workspace-package-graph.mjs new file mode 100644 index 0000000000..e0ac474c87 --- /dev/null +++ b/scripts/check-workspace-package-graph.mjs @@ -0,0 +1,138 @@ +#!/usr/bin/env node +/* +FNXC:WorkspaceBootstrap 2026-08-11-21:28: +FN-8994 found that a dangling workspace:* dependency or an unglobbed workspace +package stays invisible until a cold pnpm install fails in an isolated worktree. +Scan the root manifest as an importer even though it matches no workspace glob, +while resolving targets only from glob-covered workspace packages. +*/ +import { globSync, readFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parse as parseYaml } from "yaml"; + +export const DEPENDENCY_BLOCKS = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]; +const PACKAGE_DIRECTORY_PREFIXES = ["packages/", "plugins/"]; +const EXCLUDED_PACKAGE_PATHS = ["**/node_modules/**", "**/dist/**"]; + +function isWorkspaceProtocol(value) { + return typeof value === "string" && value.startsWith("workspace:"); +} + +function isPackageDirectory(filePath) { + return PACKAGE_DIRECTORY_PREFIXES.some((prefix) => filePath.startsWith(prefix)); +} + +/* +FNXC:WorkspaceBootstrap 2026-08-11-21:41: +FN-8994's validator runs in pretest and the blocking gate, whose supported Node +floor is 22.4. Use this small package-glob matcher instead of node:path.matchesGlob, +which was added after that floor and would make a valid cold bootstrap fail. +*/ +export function matchesWorkspaceGlob(directory, workspaceGlob) { + const pattern = workspaceGlob + .replaceAll(/[|\\{}()[\]^$+?.]/g, "\\$&") + .replaceAll("**", "\u0000") + .replaceAll("*", "[^/]*") + .replaceAll("\u0000", ".*"); + return new RegExp(`^${pattern}$`).test(directory); +} + +function isGlobMatched(filePath, workspaceGlobs) { + const directory = dirname(filePath).replaceAll("\\", "/"); + return workspaceGlobs.some((workspaceGlob) => matchesWorkspaceGlob(directory, workspaceGlob)); +} + +function overrideEntries(manifest) { + return [manifest?.pnpm?.overrides, manifest?.overrides] + .filter((overrides) => overrides && typeof overrides === "object") + .flatMap((overrides) => Object.entries(overrides)); +} + +/** + * Validate in-memory manifests so the policy can be tested without repository I/O. + * Root manifests are consumers only: they are excluded from package-directory + * coverage and cannot publish a workspace resolution target. + */ +export function validateWorkspacePackageGraph({ manifests = [], workspaceGlobs = [] }) { + const violations = []; + const workspaceManifests = manifests.filter(({ filePath, isRoot }) => !isRoot && isGlobMatched(filePath, workspaceGlobs)); + const workspaceNames = new Set( + workspaceManifests.map(({ manifest }) => manifest?.name).filter((name) => typeof name === "string" && name.trim()), + ); + + for (const { filePath, manifest = {}, isRoot = false } of manifests) { + if (!isRoot && isPackageDirectory(filePath) && !isGlobMatched(filePath, workspaceGlobs)) { + violations.push(`${filePath}: package directory is not covered by any pnpm-workspace.yaml packages glob`); + } + + for (const blockName of DEPENDENCY_BLOCKS) { + const dependencies = manifest?.[blockName]; + if (!dependencies || typeof dependencies !== "object") continue; + for (const [packageName, version] of Object.entries(dependencies)) { + if (isWorkspaceProtocol(version) && !workspaceNames.has(packageName)) { + violations.push(`${filePath}: ${blockName}.${packageName} uses workspace: but no glob-covered workspace package publishes ${packageName}`); + } + } + } + + for (const [packageName, version] of overrideEntries(manifest)) { + if (isWorkspaceProtocol(version) && !workspaceNames.has(packageName)) { + violations.push(`${filePath}: overrides.${packageName} uses workspace: but no glob-covered workspace package publishes ${packageName}`); + } + } + } + + return violations; +} + +export function readWorkspacePackageGraph({ root = process.cwd(), readFile = readFileSync, glob = globSync } = {}) { + let workspaceConfig; + try { + workspaceConfig = parseYaml(readFile(`${root}/pnpm-workspace.yaml`, "utf8")); + } catch (error) { + return { manifests: [], workspaceGlobs: [], violations: [`pnpm-workspace.yaml: invalid YAML (${error instanceof Error ? error.message : String(error)})`] }; + } + const workspaceGlobs = Array.isArray(workspaceConfig?.packages) ? workspaceConfig.packages.filter((value) => typeof value === "string") : []; + const manifestPaths = new Set(["package.json"]); + for (const workspaceGlob of workspaceGlobs) { + for (const filePath of glob(`${workspaceGlob}/package.json`, { cwd: root, exclude: EXCLUDED_PACKAGE_PATHS })) manifestPaths.add(filePath); + } + for (const filePath of glob("{packages,plugins}/**/package.json", { cwd: root, exclude: EXCLUDED_PACKAGE_PATHS })) manifestPaths.add(filePath); + + const manifests = []; + const readErrors = []; + for (const filePath of [...manifestPaths].sort()) { + try { + manifests.push({ + filePath, + isRoot: filePath === "package.json", + manifest: JSON.parse(readFile(`${root}/${filePath}`, "utf8")), + }); + } catch (error) { + readErrors.push(`${filePath}: invalid JSON (${error instanceof Error ? error.message : String(error)})`); + } + } + return { manifests, workspaceGlobs, violations: [...readErrors, ...validateWorkspacePackageGraph({ manifests, workspaceGlobs })] }; +} + +export function scanWorkspacePackageGraph(options = {}) { + return readWorkspacePackageGraph(options).violations; +} + +export function formatFailureMessage(violations) { + return [ + "[check-workspace-package-graph] workspace: dependencies must resolve to glob-covered workspace packages.", + "Every package directory under packages/ or plugins/ must be included by pnpm-workspace.yaml.", + ...violations.map((violation) => `- ${violation}`), + ].join("\n"); +} + +export function main() { + const violations = scanWorkspacePackageGraph(); + if (!violations.length) return 0; + console.error(formatFailureMessage(violations)); + return 1; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) process.exitCode = main();