From 70d7dcaa6c00f90ba8ca15609b171a6df61edf96 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 30 Jun 2026 11:08:55 -0700 Subject: [PATCH] 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) --- .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(-) create mode 100644 .changeset/fn-7290-plugin-build-cache.md create mode 100644 scripts/__tests__/build-workspace.test.mjs create mode 100644 scripts/build-workspace.mjs diff --git a/.changeset/fn-7290-plugin-build-cache.md b/.changeset/fn-7290-plugin-build-cache.md new file mode 100644 index 0000000000..2f3c60327c --- /dev/null +++ b/.changeset/fn-7290-plugin-build-cache.md @@ -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. diff --git a/docs/testing.md b/docs/testing.md index a5006cf2b7..e19205157f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 `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). + +`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=` only for targeted package-level investigation. diff --git a/package.json b/package.json index 3583d95319..1f236d1248 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/__tests__/build-workspace.test.mjs b/scripts/__tests__/build-workspace.test.mjs new file mode 100644 index 0000000000..86bfc5cd5d --- /dev/null +++ b/scripts/__tests__/build-workspace.test.mjs @@ -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"); +}); diff --git a/scripts/build-workspace.mjs b/scripts/build-workspace.mjs new file mode 100644 index 0000000000..01cb0854c3 --- /dev/null +++ b/scripts/build-workspace.mjs @@ -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 }} + */ +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 }} 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} 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} [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 }} options.cache + * @param {(p: string) => boolean} [options.existsFn] + * @param {(args: string[], cwd: string) => string|null} [options.gitFn] + * @param {ReturnType} [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} [options.cache] + * @param {(p: string) => boolean} [options.existsFn] + * @param {(args: string[], cwd: string) => string|null} [options.gitFn] + * @param {ReturnType} [options.snapshot] + * @returns {{ plannedPackages: object[], skippedPlugins: object[], excludedPackages: object[], pluginEvaluations: Map }} + */ +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} 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()); +}