FN-7290: skip unchanged plugin builds
Add a git-backed plugin build cache to make root workspace builds skip safe unchanged plugin packages. - Route root pnpm build through a workspace build planner that always builds non-plugin packages and selectively skips cached plugin packages. - Hash plugin inputs with declared local workspace dependencies plus root build tooling/config and require dist outputs before skipping. - Cover cache planning and invalidation behavior with script tests and document the operator-facing build behavior. Files changed: .changeset/fn-7290-plugin-build-cache.md | 7 + docs/testing.md | 5 +- package.json | 2 +- scripts/__tests__/build-workspace.test.mjs | 253 +++++++++++++++++ scripts/build-workspace.mjs | 427 +++++++++++++++++++++++++++++ 5 files changed, 692 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-7290 Fusion-Task-Lineage: a91861c7-385d-4e9c-bc43-11ed0ff188c8 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7290-plugin-build-cache.md
Normal file
7
.changeset/fn-7290-plugin-build-cache.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Skip unchanged plugin builds during workspace builds.
|
||||
category: performance
|
||||
dev: Root pnpm build now uses a git content-hash plugin build cache that includes local workspace dependency and root build config/tooling inputs.
|
||||
@@ -35,7 +35,7 @@ pnpm smoke:boot # boot smoke: CLI --help + real serve /api/health
|
||||
pnpm verify:fast # TEST-FREE verification: artifact bootstrap + scoped typecheck/build + CLI build + boot smoke
|
||||
pnpm test:full # full workspace suite — explicit opt-in only
|
||||
pnpm lint # lint all packages
|
||||
pnpm build # build workspace packages (excludes desktop/mobile)
|
||||
pnpm build # build workspace packages (excludes desktop/mobile; skips unchanged plugins safely)
|
||||
pnpm verify:workspace # deep opt-in verification: lint -> test:full -> build (NOT the merge gate)
|
||||
```
|
||||
|
||||
@@ -43,6 +43,9 @@ pnpm verify:workspace # deep opt-in verification: lint -> test:full -> build (N
|
||||
<!-- 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. -->
|
||||
`pnpm verify:fast` (`scripts/verify-fast.mjs`) is the recommended **test-free verification** command: it 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, then runs the existing **boot smoke** once — and runs **no test suite**. 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 only the artifact bootstrap, 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).
|
||||
|
||||
<!-- 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.
|
||||
|
||||
`pnpm test:full` runs each package's default test script with capped worker fanout (`FUSION_TEST_TOTAL_WORKERS=4 FUSION_TEST_CONCURRENCY=2 pnpm -r --workspace-concurrency=2 test`). Do not casually raise worker counts; dashboard/jsdom and integration-heavy packages destabilize when oversubscribed. Use `VITEST_MAX_WORKERS=<n>` only for targeted package-level investigation.
|
||||
|
||||
<!-- FNXC:CustomWorkflowReliability 2026-06-19-00:00: FN-6694 adds an executable custom-workflow reliability release-check lane for QA signoff, but it must stay out of the merge gate so reliability evidence does not inflate every PR's wall-time. -->
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"lint": "eslint .",
|
||||
"sync:fusion-skill": "node scripts/sync-fusion-skill-tools.mjs",
|
||||
"sync:fusion-skill:check": "node scripts/sync-fusion-skill-tools.mjs --check",
|
||||
"build": "pnpm -r --filter=!@fusion/desktop --filter=!@fusion/mobile build",
|
||||
"build": "node scripts/build-workspace.mjs",
|
||||
"build:all": "pnpm -r build",
|
||||
"verify:workspace": "pnpm lint && pnpm test:full && pnpm build",
|
||||
"build:exe": "pnpm build && pnpm --filter @runfusion/fusion build:exe",
|
||||
|
||||
253
scripts/__tests__/build-workspace.test.mjs
Normal file
253
scripts/__tests__/build-workspace.test.mjs
Normal file
@@ -0,0 +1,253 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import {
|
||||
BUILD_CACHE_VERSION,
|
||||
PLUGIN_BUILD_GLOBAL_INPUT_PATHS,
|
||||
computePluginSourceHash,
|
||||
discoverWorkspacePackages,
|
||||
planWorkspaceBuild,
|
||||
readPluginBuildCache,
|
||||
requiredPluginOutputs,
|
||||
} from "../build-workspace.mjs";
|
||||
|
||||
function createWorkspace() {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "fn-7290-build-workspace-"));
|
||||
writeFileSync(path.join(root, "pnpm-workspace.yaml"), "packages:\n - 'packages/*'\n - 'plugins/*'\n");
|
||||
writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "workspace-root", private: true }, null, 2));
|
||||
writeFileSync(path.join(root, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
|
||||
writeFileSync(path.join(root, "tsconfig.json"), JSON.stringify({ extends: "./tsconfig.base.json" }, null, 2));
|
||||
writeFileSync(path.join(root, "tsconfig.base.json"), JSON.stringify({ compilerOptions: { strict: true } }, null, 2));
|
||||
mkdirSync(path.join(root, "plugins"), { recursive: true });
|
||||
writeFileSync(path.join(root, "plugins", "tsconfig.base.json"), JSON.stringify({ extends: "../tsconfig.base.json" }, null, 2));
|
||||
mkdirSync(path.join(root, "scripts", "lib"), { recursive: true });
|
||||
writeFileSync(path.join(root, "scripts", "build-workspace.mjs"), "export {};\n");
|
||||
writeFileSync(path.join(root, "scripts", "lib", "content-hash.mjs"), "export {};\n");
|
||||
writePackage(root, "packages/core", {
|
||||
name: "@fusion/core",
|
||||
scripts: { build: "tsc" },
|
||||
});
|
||||
mkdirSync(path.join(root, "packages/core", "src"), { recursive: true });
|
||||
writeFileSync(path.join(root, "packages/core", "src", "index.ts"), "export const core = 1;\n");
|
||||
writePackage(root, "packages/desktop", {
|
||||
name: "@fusion/desktop",
|
||||
scripts: { build: "tsc" },
|
||||
});
|
||||
writePackage(root, "plugins/fusion-plugin-alpha", {
|
||||
name: "@fusion-plugin-examples/alpha",
|
||||
scripts: { build: "tsc" },
|
||||
dependencies: { "@fusion/core": "workspace:*" },
|
||||
exports: { ".": { types: "./src/index.ts", import: "./dist/index.js" } },
|
||||
});
|
||||
mkdirSync(path.join(root, "plugins/fusion-plugin-alpha", "src"), { recursive: true });
|
||||
writeFileSync(path.join(root, "plugins/fusion-plugin-alpha", "src", "index.ts"), "export const alpha = 1;\n");
|
||||
return root;
|
||||
}
|
||||
|
||||
function writePackage(root, dir, manifest) {
|
||||
mkdirSync(path.join(root, dir), { recursive: true });
|
||||
writeFileSync(path.join(root, dir, "package.json"), JSON.stringify(manifest, null, 2));
|
||||
}
|
||||
|
||||
function withWorkspace(fn) {
|
||||
const root = createWorkspace();
|
||||
try {
|
||||
return fn(root);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function initGit(root) {
|
||||
spawnSync("git", ["init"], { cwd: root, stdio: "ignore" });
|
||||
spawnSync("git", ["add", "."], { cwd: root, stdio: "ignore" });
|
||||
}
|
||||
|
||||
function packageByName(packages, name) {
|
||||
return packages.find((pkg) => pkg.name === name);
|
||||
}
|
||||
|
||||
function writePluginDist(root, dir = "plugins/fusion-plugin-alpha") {
|
||||
mkdirSync(path.join(root, dir, "dist"), { recursive: true });
|
||||
writeFileSync(path.join(root, dir, "dist", "index.js"), "export const alpha = 1;\n");
|
||||
}
|
||||
|
||||
test("discovers workspace packages and classifies plugin directories", () => {
|
||||
withWorkspace((root) => {
|
||||
const packages = discoverWorkspacePackages(root);
|
||||
const plugin = packageByName(packages, "@fusion-plugin-examples/alpha");
|
||||
const core = packageByName(packages, "@fusion/core");
|
||||
|
||||
assert.equal(plugin.isPlugin, true);
|
||||
assert.equal(core.isPlugin, false);
|
||||
assert.deepEqual(plugin.requiredOutputs, ["plugins/fusion-plugin-alpha/dist/index.js"]);
|
||||
assert.deepEqual(plugin.inputPaths, [
|
||||
...PLUGIN_BUILD_GLOBAL_INPUT_PATHS,
|
||||
"packages/core",
|
||||
"plugins/fusion-plugin-alpha",
|
||||
].sort((a, b) => a.localeCompare(b)));
|
||||
});
|
||||
});
|
||||
|
||||
test("non-plugin build packages stay planned while unchanged plugins with outputs and cache are skipped", () => {
|
||||
withWorkspace((root) => {
|
||||
writePluginDist(root);
|
||||
initGit(root);
|
||||
const packages = discoverWorkspacePackages(root);
|
||||
const plugin = packageByName(packages, "@fusion-plugin-examples/alpha");
|
||||
const hash = computePluginSourceHash(plugin, root);
|
||||
const cache = { version: BUILD_CACHE_VERSION, entries: { [plugin.name]: { sourceHash: hash } } };
|
||||
|
||||
const plan = planWorkspaceBuild({ rootDir: root, packages, cache });
|
||||
|
||||
assert.deepEqual(plan.plannedPackages.map((pkg) => pkg.name), ["@fusion/core"]);
|
||||
assert.deepEqual(plan.skippedPlugins.map((pkg) => pkg.name), ["@fusion-plugin-examples/alpha"]);
|
||||
assert.deepEqual(plan.excludedPackages.map((pkg) => pkg.name), ["@fusion/desktop"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("plugin packages build when required outputs are missing even with a matching cache", () => {
|
||||
withWorkspace((root) => {
|
||||
initGit(root);
|
||||
const packages = discoverWorkspacePackages(root);
|
||||
const plugin = packageByName(packages, "@fusion-plugin-examples/alpha");
|
||||
const hash = computePluginSourceHash(plugin, root);
|
||||
const cache = { version: BUILD_CACHE_VERSION, entries: { [plugin.name]: { sourceHash: hash } } };
|
||||
|
||||
const plan = planWorkspaceBuild({ rootDir: root, packages, cache });
|
||||
|
||||
const plannedPlugin = packageByName(plan.plannedPackages, "@fusion-plugin-examples/alpha");
|
||||
assert.equal(plannedPlugin.buildReason, "missing-output");
|
||||
});
|
||||
});
|
||||
|
||||
test("plugin packages build when no successful-build cache entry exists", () => {
|
||||
withWorkspace((root) => {
|
||||
writePluginDist(root);
|
||||
initGit(root);
|
||||
const packages = discoverWorkspacePackages(root);
|
||||
|
||||
const plan = planWorkspaceBuild({ rootDir: root, packages, cache: { version: BUILD_CACHE_VERSION, entries: {} } });
|
||||
|
||||
const plannedPlugin = packageByName(plan.plannedPackages, "@fusion-plugin-examples/alpha");
|
||||
assert.equal(plannedPlugin.buildReason, "no-cache");
|
||||
});
|
||||
});
|
||||
|
||||
test("plugin packages build when tracked source files change", () => {
|
||||
withWorkspace((root) => {
|
||||
writePluginDist(root);
|
||||
initGit(root);
|
||||
const packages = discoverWorkspacePackages(root);
|
||||
const plugin = packageByName(packages, "@fusion-plugin-examples/alpha");
|
||||
const originalHash = computePluginSourceHash(plugin, root);
|
||||
writeFileSync(path.join(root, plugin.dir, "src", "index.ts"), "export const alpha = 2;\n");
|
||||
|
||||
const plan = planWorkspaceBuild({
|
||||
rootDir: root,
|
||||
packages,
|
||||
cache: { version: BUILD_CACHE_VERSION, entries: { [plugin.name]: { sourceHash: originalHash } } },
|
||||
});
|
||||
|
||||
const plannedPlugin = packageByName(plan.plannedPackages, "@fusion-plugin-examples/alpha");
|
||||
assert.equal(plannedPlugin.buildReason, "changed-inputs");
|
||||
});
|
||||
});
|
||||
|
||||
test("plugin packages build when untracked plugin source files are present", () => {
|
||||
withWorkspace((root) => {
|
||||
writePluginDist(root);
|
||||
initGit(root);
|
||||
const packages = discoverWorkspacePackages(root);
|
||||
const plugin = packageByName(packages, "@fusion-plugin-examples/alpha");
|
||||
const originalHash = computePluginSourceHash(plugin, root);
|
||||
writeFileSync(path.join(root, plugin.dir, "src", "extra.ts"), "export const extra = true;\n");
|
||||
|
||||
const plan = planWorkspaceBuild({
|
||||
rootDir: root,
|
||||
packages,
|
||||
cache: { version: BUILD_CACHE_VERSION, entries: { [plugin.name]: { sourceHash: originalHash } } },
|
||||
});
|
||||
|
||||
const plannedPlugin = packageByName(plan.plannedPackages, "@fusion-plugin-examples/alpha");
|
||||
assert.equal(plannedPlugin.buildReason, "changed-inputs");
|
||||
});
|
||||
});
|
||||
|
||||
test("plugin packages build when declared workspace dependency files change", () => {
|
||||
withWorkspace((root) => {
|
||||
writePluginDist(root);
|
||||
initGit(root);
|
||||
const packages = discoverWorkspacePackages(root);
|
||||
const plugin = packageByName(packages, "@fusion-plugin-examples/alpha");
|
||||
const originalHash = computePluginSourceHash(plugin, root);
|
||||
writeFileSync(path.join(root, "packages/core", "src", "index.ts"), "export const core = 2;\n");
|
||||
|
||||
const plan = planWorkspaceBuild({
|
||||
rootDir: root,
|
||||
packages,
|
||||
cache: { version: BUILD_CACHE_VERSION, entries: { [plugin.name]: { sourceHash: originalHash } } },
|
||||
});
|
||||
|
||||
const plannedPlugin = packageByName(plan.plannedPackages, "@fusion-plugin-examples/alpha");
|
||||
assert.equal(plannedPlugin.buildReason, "changed-inputs");
|
||||
});
|
||||
});
|
||||
|
||||
test("plugin packages build when root TypeScript/build-tooling inputs change", () => {
|
||||
withWorkspace((root) => {
|
||||
writePluginDist(root);
|
||||
initGit(root);
|
||||
const packages = discoverWorkspacePackages(root);
|
||||
const plugin = packageByName(packages, "@fusion-plugin-examples/alpha");
|
||||
const originalHash = computePluginSourceHash(plugin, root);
|
||||
writeFileSync(path.join(root, "tsconfig.base.json"), JSON.stringify({ compilerOptions: { strict: false } }, null, 2));
|
||||
|
||||
const plan = planWorkspaceBuild({
|
||||
rootDir: root,
|
||||
packages,
|
||||
cache: { version: BUILD_CACHE_VERSION, entries: { [plugin.name]: { sourceHash: originalHash } } },
|
||||
});
|
||||
|
||||
const plannedPlugin = packageByName(plan.plannedPackages, "@fusion-plugin-examples/alpha");
|
||||
assert.equal(plannedPlugin.buildReason, "changed-inputs");
|
||||
});
|
||||
});
|
||||
|
||||
test("invalid cache versions are ignored so plugins rebuild once", () => {
|
||||
withWorkspace((root) => {
|
||||
mkdirSync(path.join(root, ".fusion", "cache"), { recursive: true });
|
||||
writeFileSync(path.join(root, ".fusion", "cache", "plugin-build-cache.json"), JSON.stringify({ version: -1, entries: { stale: {} } }));
|
||||
|
||||
assert.deepEqual(readPluginBuildCache(root), { version: BUILD_CACHE_VERSION, entries: {} });
|
||||
});
|
||||
});
|
||||
|
||||
test("required outputs include source-export dist counterparts", () => {
|
||||
withWorkspace((root) => {
|
||||
mkdirSync(path.join(root, "plugins/fusion-plugin-beta", "src", "nested"), { recursive: true });
|
||||
writeFileSync(path.join(root, "plugins/fusion-plugin-beta", "src", "index.ts"), "export {};\n");
|
||||
writeFileSync(path.join(root, "plugins/fusion-plugin-beta", "src", "nested", "view.tsx"), "export {};\n");
|
||||
const outputs = requiredPluginOutputs(root, "plugins/fusion-plugin-beta", {
|
||||
exports: {
|
||||
".": { types: "./src/index.d.ts", import: "./src/index.ts" },
|
||||
"./view": { types: "./src/nested/view.d.ts", import: "./src/nested/view.tsx" },
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(outputs, [
|
||||
"plugins/fusion-plugin-beta/dist/index.js",
|
||||
"plugins/fusion-plugin-beta/dist/nested/view.js",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test("root package build script points at the workspace build wrapper", () => {
|
||||
const rootPackage = JSON.parse(readFileSync(path.resolve("package.json"), "utf8"));
|
||||
|
||||
assert.equal(rootPackage.scripts.build, "node scripts/build-workspace.mjs");
|
||||
});
|
||||
427
scripts/build-workspace.mjs
Normal file
427
scripts/build-workspace.mjs
Normal file
@@ -0,0 +1,427 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
FNXC:WorkspaceBuild 2026-06-30-00:00:
|
||||
Root builds may skip unchanged plugin workspaces to keep local and CI feedback fast, but only after required dist outputs exist and a content hash proves plugin package inputs match the last successful plugin build. Non-plugin packages still build every run so the root command preserves the pre-existing recursive build contract outside plugins.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import fg from "fast-glob";
|
||||
import YAML from "yaml";
|
||||
import {
|
||||
computeContentHash,
|
||||
createRepoContentSnapshot,
|
||||
defaultGitRunner,
|
||||
readJsonCache,
|
||||
} from "./lib/content-hash.mjs";
|
||||
|
||||
export const BUILD_CACHE_VERSION = 1;
|
||||
export const BUILD_CACHE_FILE = "plugin-build-cache.json";
|
||||
export const ROOT_BUILD_EXCLUDED_PACKAGES = new Set(["@fusion/desktop", "@fusion/mobile"]);
|
||||
export const PLUGIN_BUILD_GLOBAL_INPUT_PATHS = [
|
||||
"package.json",
|
||||
"pnpm-lock.yaml",
|
||||
"pnpm-workspace.yaml",
|
||||
"tsconfig.json",
|
||||
"tsconfig.base.json",
|
||||
"plugins/tsconfig.base.json",
|
||||
"scripts/build-workspace.mjs",
|
||||
"scripts/lib/content-hash.mjs",
|
||||
];
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, "..");
|
||||
|
||||
/**
|
||||
* Resolve the plugin build cache under .fusion/cache as a repo-local build
|
||||
* artifact. The cache is only an optimization: missing, unreadable, or stale
|
||||
* entries force a plugin build rather than allowing a skip.
|
||||
*
|
||||
* @param {string} rootDir
|
||||
* @returns {string}
|
||||
*/
|
||||
export function pluginBuildCachePath(rootDir) {
|
||||
return path.join(rootDir, ".fusion", "cache", BUILD_CACHE_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the plugin build cache, normalizing invalid or older formats to an empty
|
||||
* cache so a format bump rebuilds plugins once and then records fresh hashes.
|
||||
*
|
||||
* @param {string} rootDir
|
||||
* @returns {{ version: number, entries: Record<string, { sourceHash?: string, builtAt?: string }> }}
|
||||
*/
|
||||
export function readPluginBuildCache(rootDir) {
|
||||
const cache = readJsonCache(pluginBuildCachePath(rootDir), null);
|
||||
if (!cache || cache.version !== BUILD_CACHE_VERSION || typeof cache.entries !== "object") {
|
||||
return { version: BUILD_CACHE_VERSION, entries: {} };
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort write for the plugin build cache. A successful package build must
|
||||
* not become a failed root build just because the local optimization cache is
|
||||
* not writable.
|
||||
*
|
||||
* @param {string} rootDir
|
||||
* @param {{ version: number, entries: Record<string, { sourceHash?: string, builtAt?: string }> }} cache
|
||||
*/
|
||||
export function writePluginBuildCache(rootDir, cache) {
|
||||
try {
|
||||
const cachePath = pluginBuildCachePath(rootDir);
|
||||
mkdirSync(path.dirname(cachePath), { recursive: true });
|
||||
writeFileSync(cachePath, JSON.stringify(cache, null, 2));
|
||||
} catch {
|
||||
// Best-effort optimization cache; the next run will rebuild missing entries.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse pnpm-workspace.yaml and return workspace package globs.
|
||||
*
|
||||
* @param {string} rootDir
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function readWorkspacePackagePatterns(rootDir) {
|
||||
const workspacePath = path.join(rootDir, "pnpm-workspace.yaml");
|
||||
const parsed = YAML.parse(readFileSync(workspacePath, "utf8"));
|
||||
return Array.isArray(parsed?.packages) ? parsed.packages.filter((entry) => typeof entry === "string") : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover workspace package manifests from pnpm workspace patterns instead of
|
||||
* hard-coding the current plugin list.
|
||||
*
|
||||
* @param {string} rootDir
|
||||
* @param {string[]} [patterns]
|
||||
* @returns {{ name: string, dir: string, manifest: object, hasBuild: boolean, isPlugin: boolean, requiredOutputs: string[], inputPaths: string[] }[]}
|
||||
*/
|
||||
export function discoverWorkspacePackages(rootDir, patterns = readWorkspacePackagePatterns(rootDir)) {
|
||||
const manifestPatterns = patterns.map((pattern) => `${pattern.replace(/\/$/, "")}/package.json`);
|
||||
const manifestPaths = fg.sync(manifestPatterns, {
|
||||
cwd: rootDir,
|
||||
onlyFiles: true,
|
||||
unique: true,
|
||||
dot: false,
|
||||
ignore: ["**/node_modules/**"],
|
||||
}).sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const packages = [];
|
||||
for (const manifestPath of manifestPaths) {
|
||||
const manifest = JSON.parse(readFileSync(path.join(rootDir, manifestPath), "utf8"));
|
||||
if (typeof manifest.name !== "string" || !manifest.name) continue;
|
||||
const dir = path.dirname(manifestPath).replaceAll(path.sep, "/");
|
||||
packages.push({
|
||||
name: manifest.name,
|
||||
dir,
|
||||
manifest,
|
||||
hasBuild: typeof manifest.scripts?.build === "string",
|
||||
isPlugin: isPluginPackageDir(dir),
|
||||
requiredOutputs: requiredPluginOutputs(rootDir, dir, manifest),
|
||||
inputPaths: [dir],
|
||||
});
|
||||
}
|
||||
|
||||
const packagesByName = new Map(packages.map((pkg) => [pkg.name, pkg]));
|
||||
for (const pkg of packages) {
|
||||
if (!pkg.isPlugin) continue;
|
||||
pkg.inputPaths = collectPluginHashInputPaths(pkg, packagesByName);
|
||||
}
|
||||
|
||||
return packages;
|
||||
}
|
||||
|
||||
const DEPENDENCY_FIELDS = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];
|
||||
|
||||
function declaredDependencyNames(manifest) {
|
||||
return DEPENDENCY_FIELDS.flatMap((field) => Object.keys(manifest?.[field] ?? {}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a plugin's content-hash input directories. Include local workspace
|
||||
* dependency directories and root build config/tooling files as invalidators so
|
||||
* skipping a plugin cannot hide a compile break against changed shared package
|
||||
* types, TypeScript settings, pnpm resolution, or build wrapper behavior.
|
||||
*
|
||||
* FNXC:WorkspaceBuild 2026-06-30-00:00:
|
||||
* Plugin skip decisions must include declared local workspace dependencies and
|
||||
* root build config/tooling in the content hash, not just the plugin package
|
||||
* directory, because root pnpm builds previously recompiled plugins after shared
|
||||
* package API/type changes and root TypeScript/build-tooling changes.
|
||||
*
|
||||
* @param {object} pkg
|
||||
* @param {Map<string, object>} packagesByName
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function collectPluginHashInputPaths(pkg, packagesByName) {
|
||||
const inputPaths = new Set([...PLUGIN_BUILD_GLOBAL_INPUT_PATHS, pkg.dir]);
|
||||
const seen = new Set();
|
||||
const visit = (current) => {
|
||||
if (seen.has(current.name)) return;
|
||||
seen.add(current.name);
|
||||
for (const dependencyName of declaredDependencyNames(current.manifest)) {
|
||||
const dependency = packagesByName.get(dependencyName);
|
||||
if (!dependency) continue;
|
||||
inputPaths.add(dependency.dir);
|
||||
visit(dependency);
|
||||
}
|
||||
};
|
||||
visit(pkg);
|
||||
return [...inputPaths].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin workspaces live under plugins/ (including plugins/examples/). This
|
||||
* directory classification keeps future plugin packages covered by the skip
|
||||
* cache without requiring a code edit for each new package name.
|
||||
*
|
||||
* @param {string} dir
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isPluginPackageDir(dir) {
|
||||
return dir === "plugins" || dir.startsWith("plugins/");
|
||||
}
|
||||
|
||||
function distPathFromExportValue(value) {
|
||||
if (typeof value !== "string") return null;
|
||||
if (value.startsWith("./dist/")) return value.slice(2);
|
||||
if (!value.startsWith("./src/")) return null;
|
||||
const withoutPrefix = value.slice("./src/".length);
|
||||
if (/\.d\.[cm]?ts$/.test(withoutPrefix)) return null;
|
||||
if (!/\.[cm]?[tj]sx?$/.test(withoutPrefix)) return null;
|
||||
return path.posix.join("dist", withoutPrefix.replace(/\.[cm]?[tj]sx?$/, ".js"));
|
||||
}
|
||||
|
||||
function collectDistExports(exportsField, outputPaths = new Set()) {
|
||||
if (typeof exportsField === "string") {
|
||||
const output = distPathFromExportValue(exportsField);
|
||||
if (output) outputPaths.add(output);
|
||||
return outputPaths;
|
||||
}
|
||||
if (!exportsField || typeof exportsField !== "object") return outputPaths;
|
||||
for (const value of Object.values(exportsField)) {
|
||||
if (typeof value === "string") {
|
||||
const output = distPathFromExportValue(value);
|
||||
if (output) outputPaths.add(output);
|
||||
} else {
|
||||
collectDistExports(value, outputPaths);
|
||||
}
|
||||
}
|
||||
return outputPaths;
|
||||
}
|
||||
|
||||
function collectDistEntrypoints(manifest, outputPaths = new Set()) {
|
||||
for (const key of ["main", "module", "types"] ) {
|
||||
const output = distPathFromExportValue(manifest[key]);
|
||||
if (output) outputPaths.add(output);
|
||||
}
|
||||
if (typeof manifest.bin === "string") {
|
||||
const output = distPathFromExportValue(manifest.bin);
|
||||
if (output) outputPaths.add(output);
|
||||
} else if (manifest.bin && typeof manifest.bin === "object") {
|
||||
for (const value of Object.values(manifest.bin)) {
|
||||
const output = distPathFromExportValue(value);
|
||||
if (output) outputPaths.add(output);
|
||||
}
|
||||
}
|
||||
return outputPaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer the required plugin build outputs. Exported dist paths are required as
|
||||
* declared, and source entrypoints are mapped to their dist JS counterparts so
|
||||
* packages that export source during development still cannot be skipped when
|
||||
* their tsc output is absent.
|
||||
*
|
||||
* @param {string} rootDir
|
||||
* @param {string} dir
|
||||
* @param {object} manifest
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function requiredPluginOutputs(rootDir, dir, manifest) {
|
||||
const outputs = collectDistEntrypoints(manifest, collectDistExports(manifest.exports));
|
||||
const sourceFiles = fg.sync(["src/**/*.{ts,tsx,mts,cts}"], {
|
||||
cwd: path.join(rootDir, dir),
|
||||
onlyFiles: true,
|
||||
unique: true,
|
||||
ignore: ["**/*.d.ts", "**/*.test.*", "**/__tests__/**", "**/node_modules/**", "**/dist/**"],
|
||||
});
|
||||
for (const sourceFile of sourceFiles) {
|
||||
outputs.add(sourceFile.replace(/^src\//, "dist/").replace(/\.[cm]?[tj]sx?$/, ".js"));
|
||||
}
|
||||
if (typeof manifest.scripts?.build === "string" && manifest.scripts.build.includes("copy-css")) {
|
||||
const cssFiles = fg.sync(["src/**/*.css"], {
|
||||
cwd: path.join(rootDir, dir),
|
||||
onlyFiles: true,
|
||||
unique: true,
|
||||
ignore: ["**/node_modules/**", "**/dist/**"],
|
||||
});
|
||||
for (const cssFile of cssFiles) {
|
||||
outputs.add(cssFile.replace(/^src\//, "dist/"));
|
||||
}
|
||||
}
|
||||
if (outputs.size === 0) outputs.add("dist/index.js");
|
||||
return [...outputs].sort((a, b) => a.localeCompare(b)).map((output) => path.posix.join(dir, output));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a plugin package input hash using the shared git-backed content hash.
|
||||
* Returns null when git is unavailable; callers must build rather than skip in
|
||||
* that case.
|
||||
*
|
||||
* @param {object} pkg
|
||||
* @param {string} rootDir
|
||||
* @param {object} [options]
|
||||
* @param {(args: string[], cwd: string) => string|null} [options.gitFn]
|
||||
* @param {ReturnType<typeof createRepoContentSnapshot>} [options.snapshot]
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function computePluginSourceHash(pkg, rootDir, { gitFn = defaultGitRunner, snapshot } = {}) {
|
||||
const probe = gitFn(["rev-parse", "--is-inside-work-tree"], rootDir);
|
||||
if (probe !== "true") return null;
|
||||
return computeContentHash({
|
||||
rootDir,
|
||||
inputPaths: pkg.inputPaths?.length ? pkg.inputPaths : [pkg.dir],
|
||||
versionPrefix: `plugin-build-v${BUILD_CACHE_VERSION}`,
|
||||
gitFn,
|
||||
snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Explain whether a plugin package must be built. A skip requires every required
|
||||
* output to exist plus a matching successful-build source hash.
|
||||
*
|
||||
* @param {object} pkg
|
||||
* @param {object} options
|
||||
* @param {string} options.rootDir
|
||||
* @param {{ entries?: Record<string, { sourceHash?: string }> }} options.cache
|
||||
* @param {(p: string) => boolean} [options.existsFn]
|
||||
* @param {(args: string[], cwd: string) => string|null} [options.gitFn]
|
||||
* @param {ReturnType<typeof createRepoContentSnapshot>} [options.snapshot]
|
||||
* @returns {{ shouldBuild: boolean, reason: string, sourceHash: string|null, missingOutputs: string[] }}
|
||||
*/
|
||||
export function evaluatePluginBuild(pkg, { rootDir, cache, existsFn = existsSync, gitFn = defaultGitRunner, snapshot } = {}) {
|
||||
const missingOutputs = pkg.requiredOutputs.filter((output) => !existsFn(path.join(rootDir, output)));
|
||||
const sourceHash = computePluginSourceHash(pkg, rootDir, { gitFn, snapshot });
|
||||
if (missingOutputs.length > 0) return { shouldBuild: true, reason: "missing-output", sourceHash, missingOutputs };
|
||||
if (sourceHash === null) return { shouldBuild: true, reason: "no-git-hash", sourceHash, missingOutputs };
|
||||
const entry = cache?.entries?.[pkg.name];
|
||||
if (!entry?.sourceHash) return { shouldBuild: true, reason: "no-cache", sourceHash, missingOutputs };
|
||||
if (entry.sourceHash !== sourceHash) return { shouldBuild: true, reason: "changed-inputs", sourceHash, missingOutputs };
|
||||
return { shouldBuild: false, reason: "unchanged", sourceHash, missingOutputs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan the root build. Non-plugin build packages are always planned; plugin
|
||||
* packages are planned only when the safe content-hash cache says they changed
|
||||
* or their required outputs/cache entry are missing.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} [options.rootDir]
|
||||
* @param {object[]} [options.packages]
|
||||
* @param {ReturnType<typeof readPluginBuildCache>} [options.cache]
|
||||
* @param {(p: string) => boolean} [options.existsFn]
|
||||
* @param {(args: string[], cwd: string) => string|null} [options.gitFn]
|
||||
* @param {ReturnType<typeof createRepoContentSnapshot>} [options.snapshot]
|
||||
* @returns {{ plannedPackages: object[], skippedPlugins: object[], excludedPackages: object[], pluginEvaluations: Map<string, object> }}
|
||||
*/
|
||||
export function planWorkspaceBuild({ rootDir = repoRoot, packages = discoverWorkspacePackages(rootDir), cache = readPluginBuildCache(rootDir), existsFn = existsSync, gitFn = defaultGitRunner, snapshot } = {}) {
|
||||
const plannedPackages = [];
|
||||
const skippedPlugins = [];
|
||||
const excludedPackages = [];
|
||||
const pluginEvaluations = new Map();
|
||||
|
||||
for (const pkg of packages) {
|
||||
if (!pkg.hasBuild) continue;
|
||||
if (ROOT_BUILD_EXCLUDED_PACKAGES.has(pkg.name)) {
|
||||
excludedPackages.push(pkg);
|
||||
continue;
|
||||
}
|
||||
if (!pkg.isPlugin) {
|
||||
plannedPackages.push({ ...pkg, buildReason: "non-plugin" });
|
||||
continue;
|
||||
}
|
||||
const evaluation = evaluatePluginBuild(pkg, { rootDir, cache, existsFn, gitFn, snapshot });
|
||||
pluginEvaluations.set(pkg.name, evaluation);
|
||||
if (evaluation.shouldBuild) {
|
||||
plannedPackages.push({ ...pkg, buildReason: evaluation.reason, sourceHash: evaluation.sourceHash });
|
||||
} else {
|
||||
skippedPlugins.push({ ...pkg, buildReason: evaluation.reason, sourceHash: evaluation.sourceHash });
|
||||
}
|
||||
}
|
||||
|
||||
return { plannedPackages, skippedPlugins, excludedPackages, pluginEvaluations };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build all planned packages through pnpm filters so each package's existing
|
||||
* build script and workspace dependency behavior remain intact.
|
||||
*
|
||||
* @param {object[]} plannedPackages
|
||||
* @param {string} rootDir
|
||||
* @param {(command: string, args: string[], options: object) => { status: number|null }} [spawnFn]
|
||||
* @returns {{ status: number, packageNames: string[] }}
|
||||
*/
|
||||
export function runPlannedBuilds(plannedPackages, rootDir, spawnFn = spawnSync) {
|
||||
if (plannedPackages.length === 0) return { status: 0, packageNames: [] };
|
||||
const packageNames = plannedPackages.map((pkg) => pkg.name);
|
||||
const args = [...packageNames.flatMap((name) => ["--filter", name]), "build"];
|
||||
const result = spawnFn("pnpm", args, { cwd: rootDir, stdio: "inherit" });
|
||||
return { status: result.status ?? 1, packageNames };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record hashes for plugins that built successfully.
|
||||
*
|
||||
* @param {object[]} builtPackages
|
||||
* @param {object} options
|
||||
* @param {string} options.rootDir
|
||||
* @param {ReturnType<typeof readPluginBuildCache>} options.cache
|
||||
* @param {(args: string[], cwd: string) => string|null} [options.gitFn]
|
||||
*/
|
||||
export function recordSuccessfulPluginBuilds(builtPackages, { rootDir, cache, gitFn = defaultGitRunner } = {}) {
|
||||
const nextCache = { version: BUILD_CACHE_VERSION, entries: { ...(cache?.entries ?? {}) } };
|
||||
let changed = false;
|
||||
const snapshot = createRepoContentSnapshot({ rootDir, gitFn });
|
||||
for (const pkg of builtPackages.filter((entry) => entry.isPlugin)) {
|
||||
const sourceHash = computePluginSourceHash(pkg, rootDir, { gitFn, snapshot });
|
||||
if (sourceHash === null) continue;
|
||||
nextCache.entries[pkg.name] = { sourceHash, builtAt: new Date().toISOString() };
|
||||
changed = true;
|
||||
}
|
||||
if (changed) writePluginBuildCache(rootDir, nextCache);
|
||||
}
|
||||
|
||||
function formatPlanLine(pkg) {
|
||||
return `${pkg.name} (${pkg.buildReason})`;
|
||||
}
|
||||
|
||||
export function main({ rootDir = repoRoot, spawnFn = spawnSync, gitFn = defaultGitRunner } = {}) {
|
||||
const cache = readPluginBuildCache(rootDir);
|
||||
const snapshot = createRepoContentSnapshot({ rootDir, gitFn });
|
||||
const plan = planWorkspaceBuild({ rootDir, cache, gitFn, snapshot });
|
||||
const plannedNames = plan.plannedPackages.map(formatPlanLine);
|
||||
const skippedNames = plan.skippedPlugins.map((pkg) => pkg.name);
|
||||
|
||||
console.log(`[build-workspace] planned builds: ${plannedNames.join(", ") || "(none)"}`);
|
||||
if (skippedNames.length > 0) {
|
||||
console.log(`[build-workspace] skipped unchanged plugins: ${skippedNames.join(", ")}`);
|
||||
}
|
||||
|
||||
const result = runPlannedBuilds(plan.plannedPackages, rootDir, spawnFn);
|
||||
if (result.status !== 0) {
|
||||
process.stderr.write(`[build-workspace] FAILED packages: ${result.packageNames.join(", ") || "(none)"}\n`);
|
||||
return result.status;
|
||||
}
|
||||
|
||||
recordSuccessfulPluginBuilds(plan.plannedPackages, { rootDir, cache, gitFn });
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
process.exit(main());
|
||||
}
|
||||
Reference in New Issue
Block a user