Files
fusion/scripts/ensure-test-artifacts.mjs
gsxdsm 211c0fd557 perf(test): cut inner-loop fixed overhead to sub-second on cache-fresh runs
- skill-sync check conditioned on content hash of its inputs (skips ~0.3s spawn)
- ensure-test-artifacts: git-blob content-hash staleness; branch switches no longer trigger spurious ~2.6s tsc rebuilds (mtime fallback when dirty)
- isolation guard: cheap --before-fast reusing prior post-run baseline (~2.1s -> ~0.07s); detection proven preserved via injected-leak failure test
- vitest-setup: CI skips 4040-4045 discovery probe unless FUSION_RESERVED_PORTS set; kill-guard wrapper untouched, asymmetry pinned by port-probe-policy tests
- cache-fresh fast path skips sync/artifacts/HOME-prune entirely (mode line: fast-path=cache-fresh)
2026-06-03 17:57:29 -07:00

386 lines
14 KiB
JavaScript

#!/usr/bin/env node
import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import {
computeContentHash,
defaultGitRunner,
fusionCacheDir,
readJsonCache,
} from "./lib/content-hash.mjs";
export const REQUIRED_BUILD_PACKAGES = [
{ name: "@fusion/core", requiredArtifacts: ["packages/core/dist/index.js"] },
{ name: "@fusion/dashboard", requiredArtifacts: ["packages/dashboard/dist/index.js"] },
{
name: "@fusion/engine",
requiredArtifacts: ["packages/engine/dist/index.js"],
staleAgainstGlobs: [{ sourcePath: "packages/engine/src" }],
},
{ name: "@fusion/plugin-sdk", requiredArtifacts: ["packages/plugin-sdk/dist/index.js"] },
{
name: "@fusion-plugin-examples/dependency-graph",
requiredArtifacts: [
"plugins/fusion-plugin-dependency-graph/dist/index.js",
"plugins/fusion-plugin-dependency-graph/dist/dashboard-view.js",
],
staleAgainstGlobs: [{ sourcePath: "plugins/fusion-plugin-dependency-graph/src" }],
},
{
name: "@fusion-plugin-examples/hermes-runtime",
requiredArtifacts: [
"plugins/fusion-plugin-hermes-runtime/dist/index.js",
"plugins/fusion-plugin-hermes-runtime/dist/cli-spawn.js",
],
staleAgainstGlobs: [{ sourcePath: "plugins/fusion-plugin-hermes-runtime/src" }],
},
{
name: "@fusion-plugin-examples/openclaw-runtime",
requiredArtifacts: [
"plugins/fusion-plugin-openclaw-runtime/dist/index.js",
"plugins/fusion-plugin-openclaw-runtime/dist/runtime-adapter.js",
"plugins/fusion-plugin-openclaw-runtime/dist/pi-module.js",
"plugins/fusion-plugin-openclaw-runtime/dist/probe.js",
],
staleAgainstGlobs: [{ sourcePath: "plugins/fusion-plugin-openclaw-runtime/src" }],
},
{
name: "@fusion-plugin-examples/paperclip-runtime",
requiredArtifacts: ["plugins/fusion-plugin-paperclip-runtime/dist/index.js"],
staleAgainstGlobs: [{ sourcePath: "plugins/fusion-plugin-paperclip-runtime/src" }],
},
];
// ---------------------------------------------------------------------------
// U3: content-hash artifact cache.
//
// The mtime-based staleness check (collectNewestSourceMtimeMs vs the dist
// artifact mtime) fires spuriously on branch switches: `git checkout` rewrites
// source-file mtimes to "now" even when content is identical, so dist looks
// stale and we pay a needless `tsc` rebuild inside the inner-loop budget.
//
// The fix: after a successful build, cache a git-blob content hash of the
// package's source inputs. On the next run, if the current content hash matches
// the cached one, the dist is up to date regardless of mtimes — skip the
// rebuild. A real source edit changes the hash and rebuilds.
//
// Correctness over speed:
// - computeContentHash hashes working-tree bytes for dirty/untracked files,
// so an unstaged source edit busts the hash (git's index blob SHA would be
// stale). See scripts/lib/content-hash.mjs.
// - If git is unavailable, or the cache has no entry yet, we FALL BACK to the
// mtime comparison — we never silently skip a needed rebuild.
// ---------------------------------------------------------------------------
const ARTIFACT_CACHE_VERSION = 1;
function artifactCachePath(rootDir) {
return path.join(fusionCacheDir(rootDir), "artifact-cache.json");
}
function readArtifactCache(rootDir) {
const cache = readJsonCache(artifactCachePath(rootDir), null);
if (!cache || cache.version !== ARTIFACT_CACHE_VERSION || typeof cache.entries !== "object") {
return { version: ARTIFACT_CACHE_VERSION, entries: {} };
}
return cache;
}
/**
* Compute the source content hash for a package entry, or null when it has no
* source globs (missing-only packages don't use the staleness cache) or git is
* unavailable so we must fall back to mtime.
*/
function computeArtifactSourceHash(pkgEntry, rootDir, gitFn = defaultGitRunner) {
if (!pkgEntry?.staleAgainstGlobs?.length) return null;
// Probe git availability once; computeContentHash also tolerates null but we
// want an explicit "fall back to mtime" signal when not in a git work tree.
const probe = gitFn(["rev-parse", "--is-inside-work-tree"], rootDir);
if (probe !== "true") return null;
const inputPaths = pkgEntry.staleAgainstGlobs.map((glob) => glob.sourcePath);
return computeContentHash({
rootDir,
inputPaths,
versionPrefix: `artifact-v${ARTIFACT_CACHE_VERSION}`,
gitFn,
});
}
/**
* Persist the source content hash for each freshly-built package so the next
* run can skip the rebuild when content is unchanged.
*/
export function recordArtifactBuild(pkgEntries, rootDir, gitFn = defaultGitRunner) {
try {
const cache = readArtifactCache(rootDir);
let wrote = false;
for (const pkgEntry of pkgEntries) {
const hash = computeArtifactSourceHash(pkgEntry, rootDir, gitFn);
if (hash === null) continue;
cache.entries[pkgEntry.name] = { sourceHash: hash, builtAt: new Date().toISOString() };
wrote = true;
}
if (!wrote) return;
mkdirSync(fusionCacheDir(rootDir), { recursive: true });
writeFileSync(artifactCachePath(rootDir), JSON.stringify(cache, null, 2));
} catch {
// Cache write is best-effort; a failure just means we mtime-check next time.
}
}
function collectNewestSourceMtimeMs(sourceDir, statFn, readdirFn) {
let newest = 0;
const stack = [sourceDir];
while (stack.length > 0) {
const current = stack.pop();
let entries = [];
try {
entries = readdirFn(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.name.startsWith(".")) continue;
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name === "dist") continue;
stack.push(fullPath);
continue;
}
let stats;
try {
stats = statFn(fullPath);
} catch {
continue;
}
newest = Math.max(newest, stats.mtimeMs);
}
}
return newest;
}
export function isStale(
pkgEntry,
rootDir = process.cwd(),
statFn = statSync,
readdirFn = readdirSync,
existsFn = existsSync,
cacheOptions = {},
) {
if (!pkgEntry?.staleAgainstGlobs?.length) return false;
// U3: content-hash short-circuit. If the package's source content hash matches
// the hash captured at the last successful build, dist is up to date even
// when mtimes say otherwise (branch-switch churn). Only trust this when the
// cache opts in AND a hash is computable (git available, not dirty-fallback).
const { artifactCache, gitFn } = cacheOptions;
if (artifactCache) {
const entry = artifactCache.entries?.[pkgEntry.name];
if (entry?.sourceHash) {
const currentHash = computeArtifactSourceHash(pkgEntry, rootDir, gitFn);
if (currentHash !== null && currentHash === entry.sourceHash) {
return false; // Content unchanged since last build — not stale.
}
// Hash mismatch or unavailable → fall through to the mtime check below,
// which never under-reports staleness.
}
}
let minArtifactMtimeMs = Number.POSITIVE_INFINITY;
for (const artifactPath of pkgEntry.requiredArtifacts) {
const fullPath = path.join(rootDir, artifactPath);
if (!existsFn(fullPath)) continue;
let stats;
try {
stats = statFn(fullPath);
} catch {
continue;
}
minArtifactMtimeMs = Math.min(minArtifactMtimeMs, stats.mtimeMs);
}
if (!Number.isFinite(minArtifactMtimeMs)) return false;
let maxSourceMtimeMs = 0;
for (const { sourcePath } of pkgEntry.staleAgainstGlobs) {
const sourceDir = path.join(rootDir, sourcePath);
maxSourceMtimeMs = Math.max(maxSourceMtimeMs, collectNewestSourceMtimeMs(sourceDir, statFn, readdirFn));
}
return maxSourceMtimeMs > minArtifactMtimeMs;
}
export function detectMissingOrStaleArtifacts(
rootDir = process.cwd(),
existsFn = existsSync,
statFn = statSync,
readdirFn = readdirSync,
cacheOptions = {},
) {
return REQUIRED_BUILD_PACKAGES.filter((pkg) => {
const missing = pkg.requiredArtifacts.some((artifactPath) => !existsFn(path.join(rootDir, artifactPath)));
if (missing) return true;
return isStale(pkg, rootDir, statFn, readdirFn, existsFn, cacheOptions);
});
}
export function detectMissingArtifacts(rootDir = process.cwd(), existsFn = existsSync, statFn = statSync, readdirFn = readdirSync) {
return detectMissingOrStaleArtifacts(rootDir, existsFn, statFn, readdirFn);
}
function classifyArtifactIssues(pkgEntry, rootDir, existsFn, statFn, readdirFn) {
const missingPaths = pkgEntry.requiredArtifacts.filter((artifactPath) => !existsFn(path.join(rootDir, artifactPath)));
if (missingPaths.length > 0) {
return { missingPaths, stalePaths: [] };
}
if (isStale(pkgEntry, rootDir, statFn, readdirFn, existsFn)) {
return { missingPaths: [], stalePaths: [...pkgEntry.requiredArtifacts] };
}
return { missingPaths: [], stalePaths: [] };
}
function writeRemediation(stderrWrite, pkgEntries, filterCommand, rootDir, existsFn = existsSync, statFn = statSync, readdirFn = readdirSync) {
stderrWrite("\n[test-bootstrap] FAILED: workspace dist artifact rebuild did not complete.\n");
stderrWrite(`[test-bootstrap] command: ${filterCommand}\n`);
stderrWrite(`[test-bootstrap] affected packages: ${pkgEntries.map((pkg) => pkg.name).join(", ")}\n`);
for (const pkgEntry of pkgEntries) {
const { missingPaths, stalePaths } = classifyArtifactIssues(pkgEntry, rootDir, existsFn, statFn, readdirFn);
for (const missingPath of missingPaths) {
stderrWrite(`[test-bootstrap] missing: ${missingPath}\n`);
}
for (const stalePath of stalePaths) {
stderrWrite(`[test-bootstrap] stale (src newer than dist): ${stalePath}\n`);
}
}
stderrWrite("[test-bootstrap] next steps:\n");
stderrWrite(" 1) pnpm install --frozen-lockfile\n");
stderrWrite(" 2) pnpm --filter <pkg> build\n");
stderrWrite(" 3) delete <plugin>/dist and re-run pnpm test\n");
stderrWrite("[test-bootstrap] reference: FN-4232, FN-4605\n\n");
}
function run(
command,
args,
cwd,
{
exitFn = process.exit,
stderrWrite = process.stderr.write.bind(process.stderr),
spawnFn = spawnSync,
pkgEntries = [],
existsFn = existsSync,
statFn = statSync,
readdirFn = readdirSync,
} = {},
) {
const result = spawnFn(command, args, { cwd, stdio: "inherit" });
if (result.status !== 0) {
const filterCommand = `${command} ${args.join(" ")}`;
const packageNames = args.filter((entry, index) => args[index - 1] === "--filter");
const packagesToReport = pkgEntries.length > 0
? pkgEntries
: REQUIRED_BUILD_PACKAGES.filter((pkg) => packageNames.includes(pkg.name));
writeRemediation(stderrWrite, packagesToReport, filterCommand, cwd, existsFn, statFn, readdirFn);
exitFn(result.status ?? 1);
}
}
function resolveWorkspaceRoot(explicitRootDir) {
if (process.env.FUSION_PROJECT_DIR) {
return path.resolve(process.env.FUSION_PROJECT_DIR);
}
if (explicitRootDir) {
return path.resolve(explicitRootDir);
}
let current = path.resolve(process.cwd());
while (true) {
if (existsSync(path.join(current, "pnpm-workspace.yaml"))) {
return current;
}
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return path.resolve(process.cwd());
}
export function ensureTestArtifacts(
rootDir,
runFn = run,
existsFn = existsSync,
statFn = statSync,
readdirFn = readdirSync,
runOptions = {},
) {
const resolvedRootDir = resolveWorkspaceRoot(rootDir);
// U3: load the content-hash cache so branch-switch mtime churn doesn't force a
// rebuild. The default-runner (real CLI) path uses it; injected test runners
// can opt in via runOptions.artifactCache / runOptions.gitFn but default to
// disabled so existing mtime-based tests keep exercising the mtime path.
const useContentCache = runFn === run || runOptions.artifactCache !== undefined;
const cacheOptions = useContentCache
? {
artifactCache: runOptions.artifactCache ?? readArtifactCache(resolvedRootDir),
gitFn: runOptions.gitFn ?? defaultGitRunner,
}
: {};
const missingOrStale = detectMissingOrStaleArtifacts(resolvedRootDir, existsFn, statFn, readdirFn, cacheOptions);
// U3: seed the content-hash cache for packages whose dist is already fresh
// (by mtime or a prior build) but have no cache entry yet. This "adopts" the
// current source content as the built baseline so the NEXT run — e.g. after a
// branch switch rewrites mtimes to "now" without changing content — gets a
// content-hash hit instead of a spurious tsc rebuild. We never seed a package
// that is currently missing/stale (those still build below and record then).
if (useContentCache) {
const staleNames = new Set(missingOrStale.map((pkg) => pkg.name));
const cache = cacheOptions.artifactCache;
const toSeed = REQUIRED_BUILD_PACKAGES.filter(
(pkg) =>
pkg.staleAgainstGlobs?.length &&
!staleNames.has(pkg.name) &&
!cache?.entries?.[pkg.name]?.sourceHash,
);
if (toSeed.length > 0) {
recordArtifactBuild(toSeed, resolvedRootDir, cacheOptions.gitFn ?? defaultGitRunner);
}
}
if (missingOrStale.length === 0) return [];
const names = missingOrStale.map((pkg) => pkg.name);
console.log(`[test-bootstrap] rebuilding workspace dist artifacts (missing or stale): ${names.join(", ")}`);
if (runFn === run) {
runFn("pnpm", [...names.flatMap((name) => ["--filter", name]), "build"], resolvedRootDir, {
...runOptions,
pkgEntries: missingOrStale,
existsFn,
statFn,
readdirFn,
});
} else {
runFn("pnpm", [...names.flatMap((name) => ["--filter", name]), "build"], resolvedRootDir);
}
// Build succeeded (the real runner exits the process on failure, so reaching
// here means a clean build). Record content hashes for the packages we built
// so the next run can skip the rebuild on unchanged content.
if (useContentCache) {
recordArtifactBuild(missingOrStale, resolvedRootDir, cacheOptions.gitFn ?? defaultGitRunner);
}
return names;
}
if (import.meta.url === `file://${process.argv[1]}`) {
ensureTestArtifacts();
}