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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-11 14:54:27 -07:00
parent 778292b484
commit f2c729bf77
4 changed files with 249 additions and 5 deletions

View File

@@ -48,7 +48,9 @@ pnpm verify:workspace # deep opt-in verification: lint -> test:full -> build (N
<!-- FNXC:TestInfrastructure 2026-06-25-00:00: verify:fast is the opt-in test-free verification path. docs/testing.md observes the broad test gate caught no recalled real bugs while consuming ~70% of shipping time in flake triage; typecheck+build+boot-smoke gives deterministic, flake-free signal without running tests. It changes no default — pnpm test, the merge gate, and CI are untouched; the full suite stays available and runs non-blocking. -->
<!-- FNXC:TestInfrastructure 2026-06-26-00:49: verify:fast must bootstrap missing workspace dist artifacts and build @runfusion/fusion even when the CLI package is not in the changed-package set because package builds and the boot smoke invoke source-checkout wrappers that require dist outputs in fresh worktrees. -->
<!-- FNXC:TestInfrastructure 2026-07-22-12:00: Cheap deterministic policy gates must fail before verify:fast's expensive work. Read canonical package.json pretest commands and invoke their validator entry points directly so test-free verification and the merge gate cannot drift. -->
`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.
<!-- FNXC:WorkspaceBuild 2026-06-30-00:00: FN-7290 keeps root pnpm build operator-facing while allowing unchanged plugin workspaces to skip their package build only when required dist outputs exist and a git-backed content hash matches the last successful plugin build cache entry. Missing dist, absent entries, changed plugin, declared local workspace-dependency, or root build config/tooling inputs, unavailable git hashes, or cache-version changes must rebuild rather than trust mtimes. -->
`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.

View File

@@ -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": [

View File

@@ -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);
});
});

View File

@@ -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();