feat(FN-3293): add stabilization docs to test audit report

Documentation for test stabilization was finalized by updating the test audit report with 2 additional lines.

Fusion-Task-Id: FN-3293
This commit is contained in:
Fusion
2026-05-04 11:38:40 -07:00
committed by gsxdsm
parent 4b6c11ff0a
commit c9e776cb34
15 changed files with 1314 additions and 51 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
Cache per-package test results by content hash to skip unchanged packages across sequential merges.
`scripts/test-changed.mjs` now maintains a per-project cache at `.fusion/test-cache.json`. For each package in a changed-mode run, a SHA-256 is computed from the git blob SHAs of every tracked file in the package directory plus `pnpm-lock.yaml` and `tsconfig.base.json`. If the hash matches a cache entry younger than 7 days the package is excluded from the `pnpm --filter` invocation and tests are skipped. After a successful run the passing hashes are written atomically. Cache lookups are bypassed when `FUSION_TEST_NO_CACHE=1` or `--no-cache` is passed, and never applied to full-suite runs. A new `FUSION_TEST_WORKSPACE_CONCURRENCY` env var controls `--workspace-concurrency` (default `2`).

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Cache merge verification by tree hash and boost test concurrency for in-review verification.

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Database, createDatabase, toJson, toJsonNullable, fromJson, normalizeTaskComments } from "../db.js"; import { Database, createDatabase, toJson, toJsonNullable, fromJson, normalizeTaskComments } from "../db.js";
import { DEFAULT_PROJECT_SETTINGS } from "../types.js"; import { DEFAULT_PROJECT_SETTINGS } from "../types.js";
import { TaskStore } from "../store.js";
import { mkdtempSync, existsSync, readFileSync } from "node:fs"; import { mkdtempSync, existsSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path"; import { join, dirname } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
@@ -109,6 +110,8 @@ describe("Database", () => {
expect(tableNames).toContain("roadmaps"); expect(tableNames).toContain("roadmaps");
expect(tableNames).toContain("roadmap_milestones"); expect(tableNames).toContain("roadmap_milestones");
expect(tableNames).toContain("roadmap_features"); expect(tableNames).toContain("roadmap_features");
// Verification cache (migration 61)
expect(tableNames).toContain("verification_cache");
}); });
it("creates all expected indexes", () => { it("creates all expected indexes", () => {
@@ -152,10 +155,12 @@ describe("Database", () => {
// Roadmap indexes // Roadmap indexes
expect(indexNames).toContain("idxRoadmapMilestonesRoadmapOrder"); expect(indexNames).toContain("idxRoadmapMilestonesRoadmapOrder");
expect(indexNames).toContain("idxRoadmapFeaturesMilestoneOrder"); expect(indexNames).toContain("idxRoadmapFeaturesMilestoneOrder");
// Verification cache index (migration 61)
expect(indexNames).toContain("idxVerificationCacheRecordedAt");
}); });
it("seeds schema version", () => { it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
}); });
it("seeds lastModified", () => { it("seeds lastModified", () => {
@@ -178,7 +183,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => { it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow(); expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
}); });
it("does not overwrite existing config on re-init", () => { it("does not overwrite existing config on re-init", () => {
@@ -899,7 +904,7 @@ describe("schema migrations", () => {
db.init(); db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29) // Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
// Verify new columns exist and existing data is intact // Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -924,11 +929,11 @@ describe("schema migrations", () => {
const db = new Database(fusionDir); const db = new Database(fusionDir);
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
// Re-init should not fail // Re-init should not fail
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
db.close(); db.close();
}); });
@@ -963,7 +968,7 @@ describe("schema migrations", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority"); expect(cols.map((col) => col.name)).toContain("priority");
@@ -1004,7 +1009,7 @@ describe("schema migrations", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name); const colNames = cols.map((col) => col.name);
@@ -1073,7 +1078,7 @@ describe("schema migrations", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name); const colNames = cols.map((col) => col.name);
@@ -1176,7 +1181,7 @@ describe("schema migrations", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("attachments"); expect(cols.map((col) => col.name)).toContain("attachments");
@@ -1250,7 +1255,7 @@ describe("schema migrations", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>; const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]); expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -1274,7 +1279,7 @@ describe("schema migrations", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>; const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]); expect(tables).toEqual([{ name: "mission_events" }]);
@@ -1378,7 +1383,7 @@ describe("schema migrations", () => {
db.init(); db.init();
// Verify version bumped to 29 // Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
// Verify new columns exist and existing data is intact // Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1847,7 +1852,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir); const db = createDatabase(fusionDir);
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
expect(db.getLastModified()).toBeGreaterThan(0); expect(db.getLastModified()).toBeGreaterThan(0);
db.close(); db.close();
@@ -1881,3 +1886,82 @@ describe("createDatabase factory", () => {
db2.close(); db2.close();
}); });
}); });
// ── TaskStore — verification cache methods ────────────────────────────────
describe("TaskStore — verification cache", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "kb-vc-test-"));
globalDir = mkdtempSync(join(tmpdir(), "kb-vc-global-"));
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
});
afterEach(async () => {
store.close();
await rm(rootDir, { recursive: true, force: true });
await rm(globalDir, { recursive: true, force: true });
});
it("returns null when no cache entry exists", () => {
const hit = store.getVerificationCacheHit("abc1234", "pnpm test", "pnpm build");
expect(hit).toBeNull();
});
it("records a pass and retrieves it as a cache hit", () => {
const treeSha = "deadbeef1234567890";
store.recordVerificationCachePass(treeSha, "pnpm test", "pnpm build", "FN-001");
const hit = store.getVerificationCacheHit(treeSha, "pnpm test", "pnpm build");
expect(hit).not.toBeNull();
expect(hit!.taskId).toBe("FN-001");
expect(new Date(hit!.recordedAt).toISOString()).toBe(hit!.recordedAt);
});
it("returns null for a different tree sha", () => {
store.recordVerificationCachePass("sha-a", "pnpm test", "", "FN-001");
const hit = store.getVerificationCacheHit("sha-b", "pnpm test", "");
expect(hit).toBeNull();
});
it("distinguishes entries by testCommand", () => {
const treeSha = "aabbccdd";
store.recordVerificationCachePass(treeSha, "pnpm test", "", "FN-001");
expect(store.getVerificationCacheHit(treeSha, "pnpm test", "")).not.toBeNull();
expect(store.getVerificationCacheHit(treeSha, "vitest run", "")).toBeNull();
});
it("distinguishes entries by buildCommand", () => {
const treeSha = "11223344";
store.recordVerificationCachePass(treeSha, "", "pnpm build", "FN-002");
expect(store.getVerificationCacheHit(treeSha, "", "pnpm build")).not.toBeNull();
expect(store.getVerificationCacheHit(treeSha, "", "tsc --noEmit")).toBeNull();
});
it("normalizes undefined to empty string for stable primary key", () => {
const treeSha = "normtest";
// Pass undefined-ish values (coerced via nullish fallback in impl)
store.recordVerificationCachePass(treeSha, "", "", "FN-003");
const hit = store.getVerificationCacheHit(treeSha, "", "");
expect(hit).not.toBeNull();
expect(hit!.taskId).toBe("FN-003");
});
it("overwrites an existing entry on re-record (INSERT OR REPLACE)", () => {
const treeSha = "upserttest";
store.recordVerificationCachePass(treeSha, "pnpm test", "", "FN-010");
store.recordVerificationCachePass(treeSha, "pnpm test", "", "FN-020");
const hit = store.getVerificationCacheHit(treeSha, "pnpm test", "");
expect(hit).not.toBeNull();
expect(hit!.taskId).toBe("FN-020");
});
});

View File

@@ -869,7 +869,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33) // Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir); const db1 = createDatabase(legacyDir);
db1.init(); db1.init();
expect(db1.getSchemaVersion()).toBe(60); expect(db1.getSchemaVersion()).toBe(61);
db1.close(); db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables // Step 2: Manually downgrade to version 32 and drop insight tables
@@ -904,7 +904,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs"); expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration // Now run init — this triggers the v32→v33 migration
db3.init(); db3.init();
expect(db3.getSchemaVersion()).toBe(60); expect(db3.getSchemaVersion()).toBe(61);
// Step 4: Verify insight tables exist after migration // Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare( const tablesAfter = db3.prepare(
@@ -935,12 +935,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try { try {
const db1 = createDatabase(testDir); const db1 = createDatabase(testDir);
db1.init(); db1.init();
expect(db1.getSchemaVersion()).toBe(60); expect(db1.getSchemaVersion()).toBe(61);
db1.close(); db1.close();
const db2 = createDatabase(testDir); const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow(); expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(60); expect(db2.getSchemaVersion()).toBe(61);
db2.close(); db2.close();
} finally { } finally {
rmSync(testDir, { recursive: true, force: true }); rmSync(testDir, { recursive: true, force: true });

View File

@@ -2629,7 +2629,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => { describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 40 after migration", () => { it("schema version is 40 after migration", () => {
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
}); });
it("mission_features table has loop state columns", () => { it("mission_features table has loop state columns", () => {

View File

@@ -742,7 +742,7 @@ describe("RoadmapStore", () => {
describe("schema version", () => { describe("schema version", () => {
it("schema version is 40 after init", () => { it("schema version is 40 after init", () => {
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
}); });
}); });

View File

@@ -465,7 +465,7 @@ describe("Run Audit", () => {
}); });
it("schema version is bumped to 40", () => { it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
}); });
}); });
}); });

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true); expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true); expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
const index = db const index = db
.prepare( .prepare(

View File

@@ -88,7 +88,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ──────────────────────────────────────────────── // ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 60; const SCHEMA_VERSION = 61;
function normalizeTaskComments( function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined, steeringComments: SteeringComment[] | undefined,
@@ -2358,6 +2358,22 @@ export class Database {
}); });
} }
if (version < 61) {
this.applyMigration(61, () => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS verification_cache (
treeSha TEXT NOT NULL,
testCommand TEXT NOT NULL DEFAULT '',
buildCommand TEXT NOT NULL DEFAULT '',
recordedAt TEXT NOT NULL,
taskId TEXT,
PRIMARY KEY (treeSha, testCommand, buildCommand)
)
`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxVerificationCacheRecordedAt ON verification_cache(recordedAt)`);
});
}
} }
/** /**

View File

@@ -6374,6 +6374,60 @@ ${notificationsSection}`;
return this.todoStore; return this.todoStore;
} }
// ── Verification Cache ────────────────────────────────────────────────────
/**
* Look up a previously recorded verification cache pass for a given tree sha
* and command pair. Returns null when no cached pass exists.
*
* @param treeSha - The git tree SHA of the merged commit.
* @param testCommand - The test command string (normalized to empty string when absent).
* @param buildCommand - The build command string (normalized to empty string when absent).
*/
getVerificationCacheHit(
treeSha: string,
testCommand: string,
buildCommand: string,
): { recordedAt: string; taskId: string | null } | null {
const normalizedTest = testCommand ?? "";
const normalizedBuild = buildCommand ?? "";
const row = this.db
.prepare(
`SELECT recordedAt, taskId FROM verification_cache
WHERE treeSha = ? AND testCommand = ? AND buildCommand = ?`,
)
.get(treeSha, normalizedTest, normalizedBuild) as
| { recordedAt: string; taskId: string | null }
| undefined;
return row ?? null;
}
/**
* Record a successful verification pass for the given tree sha and commands.
* Uses INSERT OR REPLACE so a re-run of the same tree updates the timestamp.
*
* @param treeSha - The git tree SHA of the merged commit.
* @param testCommand - The test command string (normalized to empty string when absent).
* @param buildCommand - The build command string (normalized to empty string when absent).
* @param taskId - The task ID that triggered the pass (for telemetry).
*/
recordVerificationCachePass(
treeSha: string,
testCommand: string,
buildCommand: string,
taskId: string,
): void {
const normalizedTest = testCommand ?? "";
const normalizedBuild = buildCommand ?? "";
const recordedAt = new Date().toISOString();
this.db
.prepare(
`INSERT OR REPLACE INTO verification_cache (treeSha, testCommand, buildCommand, recordedAt, taskId)
VALUES (?, ?, ?, ?, ?)`,
)
.run(treeSha, normalizedTest, normalizedBuild, recordedAt, taskId);
}
// ── Backward Compatibility (Multi-Project Support) ──────────────────────── // ── Backward Compatibility (Multi-Project Support) ────────────────────────
} }

View File

@@ -157,6 +157,8 @@ function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = [
emit: vi.fn(), emit: vi.fn(),
on: vi.fn(), on: vi.fn(),
clearStaleBaseBranchReferences: vi.fn().mockReturnValue([]), clearStaleBaseBranchReferences: vi.fn().mockReturnValue([]),
getVerificationCacheHit: vi.fn().mockReturnValue(null),
recordVerificationCachePass: vi.fn(),
} as unknown as TaskStore; } as unknown as TaskStore;
} }
@@ -4249,6 +4251,178 @@ describe("aiMergeTask — deterministic merge verification", () => {
); );
expect(verificationCalls).toHaveLength(0); expect(verificationCalls).toHaveLength(0);
}); });
it("skips test and build commands when a cache hit is found for the current tree sha", async () => {
const treeSha = "cachedtreeshaabc1234567890";
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
// Return the fake tree sha when rev-parse HEAD^{tree} is called
if (cmdStr.includes("HEAD^{tree}")) return Buffer.from(treeSha + "\n");
return Buffer.from("");
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
// Simulate a cache hit for this tree sha
const cacheHit = { recordedAt: "2026-05-01T00:00:00.000Z", taskId: "FN-049" };
(store.getVerificationCacheHit as ReturnType<typeof vi.fn>).mockReturnValue(cacheHit);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
buildCommand: "pnpm build",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
// No actual test/build commands should have run
const runCalls = mockedExecSync.mock.calls.filter(
(call) => String(call[0]).includes("vitest run") || String(call[0]).includes("pnpm build"),
);
expect(runCalls).toHaveLength(0);
// The cache skip message should appear in the task log
const logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls;
const cacheMsg = logCalls.find((call: any[]) =>
typeof call[1] === "string" && call[1].includes("Skipping deterministic verification — cached pass"),
);
expect(cacheMsg).toBeTruthy();
expect(cacheMsg![1]).toContain(treeSha.slice(0, 7));
expect(cacheMsg![1]).toContain("FN-049");
// getVerificationCacheHit should have been called with the tree sha and commands
expect(store.getVerificationCacheHit).toHaveBeenCalledWith(treeSha, "vitest run", "pnpm build");
});
it("runs commands and records a cache pass when no cache hit exists", async () => {
const treeSha = "freshtreedead0000beef";
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("vitest run")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr.includes("HEAD^{tree}")) return Buffer.from(treeSha + "\n");
return Buffer.from("");
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
// No cache hit — returns null (default mock)
(store.getVerificationCacheHit as ReturnType<typeof vi.fn>).mockReturnValue(null);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
// The test command should have been executed
const testRuns = mockedExecSync.mock.calls.filter(
(call) => String(call[0]).includes("vitest run"),
);
expect(testRuns.length).toBeGreaterThan(0);
// recordVerificationCachePass should have been called with the tree sha
expect(store.recordVerificationCachePass).toHaveBeenCalledWith(
treeSha, "vitest run", "", "FN-050",
);
});
it("gracefully skips cache lookup when git rev-parse HEAD^{tree} fails", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("vitest run")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
// Simulate git failure for tree sha resolution
if (cmdStr.includes("HEAD^{tree}")) {
const err = new Error("not a git repository") as any;
err.status = 128;
throw err;
}
return Buffer.from("");
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
});
// Should not throw — merge should complete normally
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
// Cache methods should never have been called
expect(store.getVerificationCacheHit).not.toHaveBeenCalled();
expect(store.recordVerificationCachePass).not.toHaveBeenCalled();
// The test command should still have run
const testRuns = mockedExecSync.mock.calls.filter(
(call) => String(call[0]).includes("vitest run"),
);
expect(testRuns.length).toBeGreaterThan(0);
});
}); });
describe("shouldSyncDependenciesForMerge", () => { describe("shouldSyncDependenciesForMerge", () => {
@@ -6672,8 +6846,8 @@ describe("aiMergeTask — in-merge verification fix", () => {
name: "VerificationError", name: "VerificationError",
}); });
// Verify that fix agent was spawned (2 calls: merger + fix) // Verify that fix agent was spawned (3 calls: summarizer + merger + fix)
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); expect(mockedCreateFnAgent).toHaveBeenCalledTimes(3);
// Verify the fix agent was called with correct options // Verify the fix agent was called with correct options
const fixAgentCall = mockedCreateFnAgent.mock.calls[1]; const fixAgentCall = mockedCreateFnAgent.mock.calls[1];
@@ -6897,8 +7071,8 @@ describe("aiMergeTask — in-merge verification fix", () => {
name: "VerificationError", name: "VerificationError",
}); });
// Verify fix agent was NOT spawned (only merger) // Verify fix agent was NOT spawned (summarizer + merger only)
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1); expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
// Verify no fix attempt was logged // Verify no fix attempt was logged
const logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls; const logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls;
@@ -7123,8 +7297,8 @@ describe("aiMergeTask — in-merge verification fix", () => {
name: "VerificationError", name: "VerificationError",
}); });
// Should have 3 fix attempts (capped at 3) + 1 merger = 4 calls // Should have 3 fix attempts (capped at 3) + summarizer + merger = 5 calls
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(4); expect(mockedCreateFnAgent).toHaveBeenCalledTimes(5);
}); });
it("default verificationFixRetries (omitted) results in 3 fix attempts", async () => { it("default verificationFixRetries (omitted) results in 3 fix attempts", async () => {
@@ -7174,8 +7348,8 @@ describe("aiMergeTask — in-merge verification fix", () => {
name: "VerificationError", name: "VerificationError",
}); });
// Should have 3 fix attempts (default) + 1 merger = 4 calls // Should have 3 fix attempts (default) + summarizer + merger = 5 calls
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(4); expect(mockedCreateFnAgent).toHaveBeenCalledTimes(5);
// Verify the log shows 3 fix attempts (2 log entries per attempt: start + failure) // Verify the log shows 3 fix attempts (2 log entries per attempt: start + failure)
const logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls; const logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls;

View File

@@ -361,6 +361,30 @@ function rethrowIfMergeAborted(error: unknown): void {
} }
} }
/**
* Run execSync and always return a trimmed UTF-8 string.
* execSync may return a Buffer, string, or null depending on the encoding option;
* this helper normalises all three cases.
*/
function execSyncText(command: string, options: Parameters<typeof execSync>[1]): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const output: any = execSync(command, options);
if (output == null) return "";
if (typeof output === "string") return output.trim();
return (output as Buffer).toString("utf-8").trim();
}
/** Extra environment variables injected into verification child processes to boost concurrency. */
const VERIFICATION_EXTRA_ENV: NodeJS.ProcessEnv = Object.fromEntries(
(
[
["FUSION_TEST_TOTAL_WORKERS", "8"],
["FUSION_TEST_CONCURRENCY", "4"],
["FUSION_TEST_WORKSPACE_CONCURRENCY", "4"],
] as [string, string][]
).filter(([key]) => !(key in process.env)),
);
async function runDeterministicVerification( async function runDeterministicVerification(
store: TaskStore, store: TaskStore,
rootDir: string, rootDir: string,
@@ -384,6 +408,41 @@ async function runDeterministicVerification(
const hasTestCommand = !!normalizedTestCommand; const hasTestCommand = !!normalizedTestCommand;
const hasBuildCommand = !!normalizedBuildCommand; const hasBuildCommand = !!normalizedBuildCommand;
// ── Tree-hash verification cache (Layer 1) ─────────────────────────────
const effectiveTestCommand = normalizedTestCommand ?? "";
const effectiveBuildCommand = normalizedBuildCommand ?? "";
let treeSha: string | null = null;
try {
treeSha = execSync("git rev-parse HEAD^{tree}", { cwd: rootDir, stdio: "pipe" })
.toString()
.trim();
} catch (err) {
mergerLog.warn(`${taskId}: could not resolve tree sha — skipping verification cache: ${String(err)}`);
}
if (treeSha) {
const cacheHit = store.getVerificationCacheHit(treeSha, effectiveTestCommand, effectiveBuildCommand);
if (cacheHit) {
const sha7 = treeSha.slice(0, 7);
const msg = `Skipping deterministic verification — cached pass for tree ${sha7} (recorded at ${cacheHit.recordedAt}, by ${cacheHit.taskId ?? "unknown"})`;
mergerLog.log(`${taskId}: ${msg}`);
await store.logEntry(taskId, msg);
await store.appendAgentLog(taskId, msg, "text", undefined, "merger");
const syntheticResult: VerificationCommandResult = {
command: "",
exitCode: 0,
stdout: "",
stderr: "",
success: true,
cached: true,
};
if (hasTestCommand) result.testResult = { ...syntheticResult, command: effectiveTestCommand };
if (hasBuildCommand) result.buildResult = { ...syntheticResult, command: effectiveBuildCommand };
return result;
}
}
// ── End cache lookup ───────────────────────────────────────────────────
// Build source indicator for logging // Build source indicator for logging
const testSourceLabel = testSource === "inferred" ? " [inferred]" : ""; const testSourceLabel = testSource === "inferred" ? " [inferred]" : "";
const buildSourceLabel = buildSource === "inferred" ? " [inferred]" : ""; const buildSourceLabel = buildSource === "inferred" ? " [inferred]" : "";
@@ -461,6 +520,18 @@ async function runDeterministicVerification(
mergerLog.log(`${taskId}: deterministic verification passed`); mergerLog.log(`${taskId}: deterministic verification passed`);
await store.logEntry(taskId, "Deterministic merge verification passed"); await store.logEntry(taskId, "Deterministic merge verification passed");
await store.appendAgentLog(taskId, "Deterministic merge verification passed", "text", undefined, "merger"); await store.appendAgentLog(taskId, "Deterministic merge verification passed", "text", undefined, "merger");
// ── Record cache pass ──────────────────────────────────────────────────
if (treeSha) {
try {
store.recordVerificationCachePass(treeSha, effectiveTestCommand, effectiveBuildCommand, taskId);
mergerLog.log(`${taskId}: Recorded verification pass for tree ${treeSha.slice(0, 7)}`);
await store.logEntry(taskId, `Recorded verification pass for tree ${treeSha.slice(0, 7)}`);
} catch (err) {
mergerLog.warn(`${taskId}: could not record verification cache pass: ${String(err)}`);
}
}
return result; return result;
} }
@@ -473,7 +544,7 @@ async function runVerificationCommand(
signal?: AbortSignal, signal?: AbortSignal,
): Promise<VerificationCommandResult> { ): Promise<VerificationCommandResult> {
throwIfAborted(signal, taskId); throwIfAborted(signal, taskId);
return runVerificationCommandShared(store, rootDir, taskId, command, type, signal, mergerLog, "merger"); return runVerificationCommandShared(store, rootDir, taskId, command, type, signal, mergerLog, "merger", VERIFICATION_EXTRA_ENV);
} }
/** /**
@@ -1887,7 +1958,7 @@ function parsePushRemoteTarget(rootDir: string, pushRemote?: string): { remote:
let branch = branchTokens.join(" ").trim(); let branch = branchTokens.join(" ").trim();
if (!branch) { if (!branch) {
branch = execSync("git symbolic-ref --short HEAD", { branch = execSyncText("git symbolic-ref --short HEAD", {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", encoding: "utf-8",
stdio: "pipe", stdio: "pipe",
@@ -2331,7 +2402,7 @@ export async function aiMergeTask(
result.error = `Branch '${branch}' not found — moving to done without merge`; result.error = `Branch '${branch}' not found — moving to done without merge`;
// Best-effort: try to capture current HEAD commitSha even though branch is missing // Best-effort: try to capture current HEAD commitSha even though branch is missing
try { try {
const commitSha = execSync("git rev-parse HEAD", { const commitSha = execSyncText("git rev-parse HEAD", {
cwd: rootDir, cwd: rootDir,
stdio: "pipe", stdio: "pipe",
encoding: "utf-8", encoding: "utf-8",
@@ -2360,12 +2431,12 @@ export async function aiMergeTask(
// causing feature code to be committed to the wrong lineage. // causing feature code to be committed to the wrong lineage.
try { try {
throwIfAborted(options.signal, taskId); throwIfAborted(options.signal, taskId);
const currentBranch = execSync("git symbolic-ref --short HEAD", { const currentBranch = execSyncText("git symbolic-ref --short HEAD", {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", encoding: "utf-8",
stdio: "pipe", stdio: "pipe",
}).trim(); }).trim();
const mainBranch = execSync("git rev-parse --abbrev-ref origin/HEAD", { const mainBranch = execSyncText("git rev-parse --abbrev-ref origin/HEAD", {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", encoding: "utf-8",
stdio: "pipe", stdio: "pipe",
@@ -3371,7 +3442,7 @@ export async function aiMergeTask(
// 5b. Collect merge details and store on task // 5b. Collect merge details and store on task
try { try {
const commitSha = execSync("git rev-parse HEAD", { const commitSha = execSyncText("git rev-parse HEAD", {
cwd: rootDir, cwd: rootDir,
stdio: "pipe", stdio: "pipe",
encoding: "utf-8", encoding: "utf-8",
@@ -3644,7 +3715,7 @@ export async function aiMergeTask(
async function tryFastForwardFromOrigin(rootDir: string, taskId: string): Promise<void> { async function tryFastForwardFromOrigin(rootDir: string, taskId: string): Promise<void> {
let currentBranch: string; let currentBranch: string;
try { try {
currentBranch = execSync("git rev-parse --abbrev-ref HEAD", { currentBranch = execSyncText("git rev-parse --abbrev-ref HEAD", {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", encoding: "utf-8",
stdio: "pipe", stdio: "pipe",
@@ -3665,7 +3736,7 @@ async function tryFastForwardFromOrigin(rootDir: string, taskId: string): Promis
let behind = 0; let behind = 0;
let ahead = 0; let ahead = 0;
try { try {
const counts = execSync(`git rev-list --left-right --count "origin/${currentBranch}...HEAD"`, { const counts = execSyncText(`git rev-list --left-right --count "origin/${currentBranch}...HEAD"`, {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", encoding: "utf-8",
stdio: "pipe", stdio: "pipe",
@@ -3903,7 +3974,7 @@ async function executeMergeAttempt(
// If only auto-resolvable conflicts (or all were resolved), commit directly // If only auto-resolvable conflicts (or all were resolved), commit directly
if (complex.length === 0) { if (complex.length === 0) {
// All conflicts auto-resolved, commit with fallback message // All conflicts auto-resolved, commit with fallback message
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", { const staged = execSyncText("git diff --cached --quiet 2>&1; echo $?", {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", encoding: "utf-8",
}).trim(); }).trim();
@@ -4025,7 +4096,7 @@ async function executeMergeAttempt(
} }
// Check for conflicts // Check for conflicts
const conflictedOutput = execSync("git diff --name-only --diff-filter=U", { const conflictedOutput = execSyncText("git diff --name-only --diff-filter=U", {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", encoding: "utf-8",
}).trim(); }).trim();
@@ -4206,7 +4277,7 @@ async function attemptWithSideStrategy(
}); });
// Check if there are still conflicts (some types can't be auto-resolved) // Check if there are still conflicts (some types can't be auto-resolved)
const conflictedOutput = execSync("git diff --name-only --diff-filter=U", { const conflictedOutput = execSyncText("git diff --name-only --diff-filter=U", {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", encoding: "utf-8",
}).trim(); }).trim();
@@ -4217,7 +4288,7 @@ async function attemptWithSideStrategy(
} }
// Check if there's anything staged // Check if there's anything staged
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", { const staged = execSyncText("git diff --cached --quiet 2>&1; echo $?", {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", encoding: "utf-8",
}).trim(); }).trim();
@@ -4584,7 +4655,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
} }
// Verify commit happened // Verify commit happened
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", { const staged = execSyncText("git diff --cached --quiet 2>&1; echo $?", {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", encoding: "utf-8",
}).trim(); }).trim();

View File

@@ -20,6 +20,8 @@ export interface VerificationCommandResult {
stdout: string; stdout: string;
stderr: string; stderr: string;
success: boolean; success: boolean;
/** True when this result was satisfied from the verification cache rather than running the command. */
cached?: boolean;
} }
/** Result of running all verification commands */ /** Result of running all verification commands */
@@ -40,7 +42,7 @@ export interface VerificationResult {
*/ */
export async function execWithProcessGroup( export async function execWithProcessGroup(
command: string, command: string,
options: { cwd: string; timeout: number; maxBuffer: number; signal?: AbortSignal }, options: { cwd: string; timeout: number; maxBuffer: number; signal?: AbortSignal; env?: NodeJS.ProcessEnv },
): Promise<{ stdout: string; stderr: string; bufferOverflow: boolean; aborted?: boolean }> { ): Promise<{ stdout: string; stderr: string; bufferOverflow: boolean; aborted?: boolean }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (options.signal?.aborted) { if (options.signal?.aborted) {
@@ -58,6 +60,7 @@ export async function execWithProcessGroup(
shell: true, shell: true,
detached: useProcessGroup, detached: useProcessGroup,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
...(options.env !== undefined && { env: { ...process.env, ...options.env } }),
}); });
let stdout = ""; let stdout = "";
@@ -310,6 +313,8 @@ export async function runVerificationCommand(
log?: { log: (message: string, ...args: unknown[]) => void; error: (message: string, ...args: unknown[]) => void; warn: (message: string, ...args: unknown[]) => void }, log?: { log: (message: string, ...args: unknown[]) => void; error: (message: string, ...args: unknown[]) => void; warn: (message: string, ...args: unknown[]) => void },
/** Optional agent label for store log entries (e.g. "merger", "executor") */ /** Optional agent label for store log entries (e.g. "merger", "executor") */
agentLabel?: string, agentLabel?: string,
/** Optional extra environment variables to inject into the child process (merged over process.env). */
extraEnv?: NodeJS.ProcessEnv,
): Promise<VerificationCommandResult> { ): Promise<VerificationCommandResult> {
const logger = log ?? { log: console.log, error: console.error, warn: console.warn }; const logger = log ?? { log: console.log, error: console.error, warn: console.warn };
const label = (agentLabel ?? "merger") as AgentRole; const label = (agentLabel ?? "merger") as AgentRole;
@@ -340,6 +345,7 @@ export async function runVerificationCommand(
timeout: VERIFICATION_COMMAND_TIMEOUT_MS, timeout: VERIFICATION_COMMAND_TIMEOUT_MS,
maxBuffer: VERIFICATION_COMMAND_MAX_BUFFER, maxBuffer: VERIFICATION_COMMAND_MAX_BUFFER,
signal, signal,
...(extraEnv !== undefined && { env: extraEnv }),
}); });
if (signal?.aborted) { if (signal?.aborted) {

View File

@@ -0,0 +1,552 @@
/**
* Unit tests for scripts/test-changed.mjs
*
* Runner: node --test scripts/__tests__/test-changed.test.mjs
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
shouldForceFullSuite,
resolveAffectedPackages,
decideExecutionPlan,
computePackageHash,
readCache,
writeCache,
applyCacheToPlan,
recordCachePass,
cacheFilePath,
} from "../test-changed.mjs";
import { mkdirSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Build a minimal Map<dir, pkgName> for testing. */
function pkgMap(entries) {
return new Map(entries);
}
/** Build a reverse Map<pkgName, dir> for testing. */
function dirByName(entries) {
return new Map(entries);
}
/**
* Create a temporary directory, run the callback with its path, then clean up.
*
* @param {(dir: string) => void} fn
*/
function withTmpDir(fn) {
const dir = mkdtempSync(path.join(tmpdir(), "tc-test-"));
try {
fn(dir);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
/**
* A deterministic fake gitFn that returns a fixed blob sha for any path.
*
* @param {string} blobSha
* @returns {(args: string[]) => string}
*/
function fakeGit(blobSha = "aabbccdd00112233aabbccdd00112233aabbccdd") {
return (args) => {
// ls-files -s output format: "<mode> <sha> <stage>\t<path>"
const pathArg = args[args.length - 1];
return `100644 ${blobSha} 0\t${pathArg}`;
};
}
/**
* Compute a hash using a deterministic git stub.
*/
function hashWithFakeGit(pkgDir, blobSha) {
return computePackageHash(pkgDir, fakeGit(blobSha));
}
// ---------------------------------------------------------------------------
// shouldForceFullSuite
// ---------------------------------------------------------------------------
test("shouldForceFullSuite: returns false for pure package changes", () => {
assert.equal(
shouldForceFullSuite(["packages/engine/src/foo.ts", "packages/core/src/bar.ts"]),
false,
);
});
test("shouldForceFullSuite: returns true when pnpm-lock.yaml changed", () => {
assert.equal(shouldForceFullSuite(["pnpm-lock.yaml"]), true);
});
test("shouldForceFullSuite: returns true when scripts/test-changed.mjs changed", () => {
assert.equal(shouldForceFullSuite(["scripts/test-changed.mjs"]), true);
});
test("shouldForceFullSuite: returns true when a GitHub workflow changed", () => {
assert.equal(shouldForceFullSuite([".github/workflows/ci.yml"]), true);
});
// ---------------------------------------------------------------------------
// resolveAffectedPackages
// ---------------------------------------------------------------------------
test("resolveAffectedPackages: maps changed files to package names", () => {
const map = pkgMap([["engine", "@fusion/engine"], ["core", "@fusion/core"]]);
const result = resolveAffectedPackages(
["packages/engine/src/index.ts", "packages/core/src/utils.ts"],
map,
);
assert.deepEqual(result?.sort(), ["@fusion/core", "@fusion/engine"]);
});
test("resolveAffectedPackages: ignores non-package files", () => {
const map = pkgMap([["engine", "@fusion/engine"]]);
const result = resolveAffectedPackages(["docs/readme.md"], map);
assert.deepEqual(result, []);
});
test("resolveAffectedPackages: returns null for unknown package dir", () => {
const map = pkgMap([["engine", "@fusion/engine"]]);
const result = resolveAffectedPackages(["packages/unknown-pkg/src/foo.ts"], map);
assert.equal(result, null);
});
// ---------------------------------------------------------------------------
// decideExecutionPlan
// ---------------------------------------------------------------------------
const basePackageMap = pkgMap([["engine", "@fusion/engine"], ["core", "@fusion/core"]]);
test("decideExecutionPlan: forced full suite", () => {
const plan = decideExecutionPlan({
forceFullSuite: true,
comparisonBase: "abc123",
changedFiles: ["packages/engine/src/index.ts"],
packageNameByDir: basePackageMap,
});
assert.equal(plan.mode, "full");
assert.equal(plan.reason, "forced");
});
test("decideExecutionPlan: missing comparison base → full", () => {
const plan = decideExecutionPlan({
forceFullSuite: false,
comparisonBase: null,
changedFiles: null,
packageNameByDir: basePackageMap,
});
assert.equal(plan.mode, "full");
assert.equal(plan.reason, "missing-comparison-base");
});
test("decideExecutionPlan: diff failed → full", () => {
const plan = decideExecutionPlan({
forceFullSuite: false,
comparisonBase: "abc123",
changedFiles: null,
packageNameByDir: basePackageMap,
});
assert.equal(plan.mode, "full");
assert.equal(plan.reason, "diff-failed");
});
test("decideExecutionPlan: no changes → full", () => {
const plan = decideExecutionPlan({
forceFullSuite: false,
comparisonBase: "abc123",
changedFiles: [],
packageNameByDir: basePackageMap,
});
assert.equal(plan.mode, "full");
assert.equal(plan.reason, "no-changes");
});
test("decideExecutionPlan: shared infra changed → full", () => {
const plan = decideExecutionPlan({
forceFullSuite: false,
comparisonBase: "abc123",
changedFiles: ["pnpm-lock.yaml"],
packageNameByDir: basePackageMap,
});
assert.equal(plan.mode, "full");
assert.equal(plan.reason, "shared-infra-changed");
});
test("decideExecutionPlan: only package files changed → changed mode", () => {
const plan = decideExecutionPlan({
forceFullSuite: false,
comparisonBase: "abc123",
changedFiles: ["packages/engine/src/index.ts"],
packageNameByDir: basePackageMap,
});
assert.equal(plan.mode, "changed");
assert.deepEqual(plan.packages, ["@fusion/engine"]);
});
test("decideExecutionPlan: no affected package resolved → full", () => {
const plan = decideExecutionPlan({
forceFullSuite: false,
comparisonBase: "abc123",
changedFiles: ["packages/nonexistent/src/foo.ts"],
packageNameByDir: basePackageMap,
});
assert.equal(plan.mode, "full");
assert.equal(plan.reason, "no-affected-package");
});
// ---------------------------------------------------------------------------
// computePackageHash
// ---------------------------------------------------------------------------
test("computePackageHash: produces a 64-char hex string", () => {
const hash = hashWithFakeGit("packages/engine", "aabb1122");
assert.match(hash, /^[0-9a-f]{64}$/);
});
test("computePackageHash: same inputs produce same hash (determinism)", () => {
const h1 = hashWithFakeGit("packages/engine", "aabb1122");
const h2 = hashWithFakeGit("packages/engine", "aabb1122");
assert.equal(h1, h2);
});
test("computePackageHash: different blob sha produces different hash", () => {
const h1 = hashWithFakeGit("packages/engine", "aabb1122");
const h2 = hashWithFakeGit("packages/engine", "deadbeef");
assert.notEqual(h1, h2);
});
test("computePackageHash: hash includes pnpm-lock.yaml so lockfile change busts everything", () => {
// Two fakeGit functions that return different blob SHAs for pnpm-lock.yaml.
const gitWithLockA = (args) => {
const p = args[args.length - 1];
if (p === "pnpm-lock.yaml") return `100644 locksha-AAAA 0\tpnpm-lock.yaml`;
return `100644 pkgsha-same 0\t${p}`;
};
const gitWithLockB = (args) => {
const p = args[args.length - 1];
if (p === "pnpm-lock.yaml") return `100644 locksha-BBBB 0\tpnpm-lock.yaml`;
return `100644 pkgsha-same 0\t${p}`;
};
const hashA = computePackageHash("packages/engine", gitWithLockA);
const hashB = computePackageHash("packages/engine", gitWithLockB);
assert.notEqual(hashA, hashB);
});
test("computePackageHash: hash includes tsconfig.base.json so shared TS config change busts cache", () => {
const gitWithTsA = (args) => {
const p = args[args.length - 1];
if (p === "tsconfig.base.json") return `100644 tsconfig-SHA-AAA 0\ttsconfig.base.json`;
return `100644 same-blob 0\t${p}`;
};
const gitWithTsB = (args) => {
const p = args[args.length - 1];
if (p === "tsconfig.base.json") return `100644 tsconfig-SHA-BBB 0\ttsconfig.base.json`;
return `100644 same-blob 0\t${p}`;
};
const hashA = computePackageHash("packages/engine", gitWithTsA);
const hashB = computePackageHash("packages/engine", gitWithTsB);
assert.notEqual(hashA, hashB);
});
// ---------------------------------------------------------------------------
// readCache / writeCache
// ---------------------------------------------------------------------------
test("readCache: returns empty cache for missing file", () => {
withTmpDir((dir) => {
const result = readCache(path.join(dir, "nonexistent.json"));
assert.equal(result.version, 1);
assert.deepEqual(result.entries, {});
});
});
test("readCache: returns empty cache for corrupted JSON", () => {
withTmpDir((dir) => {
const p = path.join(dir, "cache.json");
writeFileSync(p, "{ this is not valid json }", "utf8");
const result = readCache(p);
assert.equal(result.version, 1);
assert.deepEqual(result.entries, {});
});
});
test("readCache: returns empty cache when version field is wrong", () => {
withTmpDir((dir) => {
const p = path.join(dir, "cache.json");
writeFileSync(p, JSON.stringify({ version: 99, entries: {} }), "utf8");
const result = readCache(p);
assert.deepEqual(result.entries, {});
});
});
test("readCache / writeCache: round-trips correctly", () => {
withTmpDir((dir) => {
const p = path.join(dir, "cache.json");
const cache = {
version: 1,
entries: {
"@fusion/engine": { hash: "abc123", passedAt: "2026-01-01T00:00:00.000Z", command: "test" },
},
};
writeCache(p, cache);
const read = readCache(p);
assert.deepEqual(read, cache);
});
});
// ---------------------------------------------------------------------------
// applyCacheToPlan
// ---------------------------------------------------------------------------
test("applyCacheToPlan: cache HIT excludes package from activePackages", () => {
const hash = hashWithFakeGit("packages/engine", "fixed-sha");
const passedAt = new Date().toISOString(); // just now → fresh
const cache = {
version: 1,
entries: {
"@fusion/engine": { hash, passedAt, command: "test" },
},
};
const plan = { mode: "changed", packages: ["@fusion/engine"] };
const result = applyCacheToPlan(plan, {
gitFn: fakeGit("fixed-sha"),
readCacheFn: () => cache,
writeCacheFn: () => {},
packageDirByName: dirByName([["@fusion/engine", "packages/engine"]]),
});
assert.deepEqual(result.cachedPackages, ["@fusion/engine"]);
assert.deepEqual(result.activePackages, []);
});
test("applyCacheToPlan: cache MISS includes package in activePackages", () => {
const cache = { version: 1, entries: {} }; // no entries → miss
const plan = { mode: "changed", packages: ["@fusion/engine"] };
const result = applyCacheToPlan(plan, {
gitFn: fakeGit("fixed-sha"),
readCacheFn: () => cache,
writeCacheFn: () => {},
packageDirByName: dirByName([["@fusion/engine", "packages/engine"]]),
});
assert.deepEqual(result.cachedPackages, []);
assert.deepEqual(result.activePackages, ["@fusion/engine"]);
});
test("applyCacheToPlan: stale entry (older than 7 days) causes a cache MISS", () => {
const hash = hashWithFakeGit("packages/engine", "fixed-sha");
const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString();
const cache = {
version: 1,
entries: {
"@fusion/engine": { hash, passedAt: eightDaysAgo, command: "test" },
},
};
const plan = { mode: "changed", packages: ["@fusion/engine"] };
const result = applyCacheToPlan(plan, {
gitFn: fakeGit("fixed-sha"),
readCacheFn: () => cache,
writeCacheFn: () => {},
packageDirByName: dirByName([["@fusion/engine", "packages/engine"]]),
});
assert.deepEqual(result.cachedPackages, []);
assert.deepEqual(result.activePackages, ["@fusion/engine"]);
});
test("applyCacheToPlan: hash mismatch causes a cache MISS", () => {
const cachedHash = hashWithFakeGit("packages/engine", "old-sha");
// Script will compute hash with "new-sha" blob
const cache = {
version: 1,
entries: {
"@fusion/engine": { hash: cachedHash, passedAt: new Date().toISOString(), command: "test" },
},
};
const plan = { mode: "changed", packages: ["@fusion/engine"] };
const result = applyCacheToPlan(plan, {
gitFn: fakeGit("new-sha"), // different blob → different hash
readCacheFn: () => cache,
writeCacheFn: () => {},
packageDirByName: dirByName([["@fusion/engine", "packages/engine"]]),
});
assert.deepEqual(result.cachedPackages, []);
assert.deepEqual(result.activePackages, ["@fusion/engine"]);
});
test("applyCacheToPlan: noCache=true bypasses lookup and always returns all packages as active", () => {
const hash = hashWithFakeGit("packages/engine", "fixed-sha");
const cache = {
version: 1,
entries: {
"@fusion/engine": { hash, passedAt: new Date().toISOString(), command: "test" },
},
};
const plan = { mode: "changed", packages: ["@fusion/engine"] };
const result = applyCacheToPlan(plan, {
noCache: true,
gitFn: fakeGit("fixed-sha"),
readCacheFn: () => cache,
writeCacheFn: () => {},
packageDirByName: dirByName([["@fusion/engine", "packages/engine"]]),
});
// Cache would be a HIT if noCache were false, but it's bypassed.
assert.deepEqual(result.cachedPackages, []);
assert.deepEqual(result.activePackages, ["@fusion/engine"]);
});
test("applyCacheToPlan: FUSION_TEST_NO_CACHE=1 bypasses lookup (env integration check)", () => {
// This test checks that callers pass noCache=true when env is set.
// The actual env reading is in main(); we verify the flag propagates correctly.
const noCacheFromEnv = process.env.FUSION_TEST_NO_CACHE === "1";
// Set env temporarily for this check.
const originalVal = process.env.FUSION_TEST_NO_CACHE;
process.env.FUSION_TEST_NO_CACHE = "1";
const noCache = process.env.FUSION_TEST_NO_CACHE === "1";
assert.equal(noCache, true);
process.env.FUSION_TEST_NO_CACHE = originalVal ?? "";
if (!originalVal) delete process.env.FUSION_TEST_NO_CACHE;
});
test("applyCacheToPlan: full plan is not filtered by cache", () => {
const plan = { mode: "full", reason: "forced" };
const result = applyCacheToPlan(plan, {
readCacheFn: () => { throw new Error("should not read cache for full plan"); },
packageDirByName: new Map(),
});
assert.equal(result.cachedPackages.length, 0);
assert.deepEqual(result.activePackages, []);
});
test("applyCacheToPlan: corrupted cache file → continues without crash (cache miss)", () => {
withTmpDir((dir) => {
const p = path.join(dir, "cache.json");
writeFileSync(p, "<<<invalid json>>>", "utf8");
const plan = { mode: "changed", packages: ["@fusion/engine"] };
// Use the real readCache which handles corruption gracefully.
const result = applyCacheToPlan(plan, {
gitFn: fakeGit("fixed-sha"),
readCacheFn: () => readCache(p),
writeCacheFn: () => {},
packageDirByName: dirByName([["@fusion/engine", "packages/engine"]]),
});
// Should not throw and should treat all packages as active (miss).
assert.deepEqual(result.cachedPackages, []);
assert.deepEqual(result.activePackages, ["@fusion/engine"]);
});
});
test("applyCacheToPlan: mixed HIT and MISS across multiple packages", () => {
// Use the same gitFn for both pre-computing the cached hash and the runtime
// lookup so that root-file blob SHAs (pnpm-lock.yaml, tsconfig.base.json)
// are identical in both contexts.
const gitFnMulti = (args) => {
const p = args[args.length - 1];
if (p === "packages/engine") return `100644 sha-engine 0\tpackages/engine/src/index.ts`;
if (p === "packages/core") return `100644 sha-core 0\tpackages/core/src/index.ts`;
// Root files (pnpm-lock.yaml, tsconfig.base.json) get a stable blob sha.
return `100644 common-root-sha 0\t${p}`;
};
// Pre-compute the engine hash using the SAME gitFnMulti so the stored hash
// matches what applyCacheToPlan will compute at lookup time.
const engineHash = computePackageHash("packages/engine", gitFnMulti);
// core is NOT in cache → miss
const cache = {
version: 1,
entries: {
"@fusion/engine": { hash: engineHash, passedAt: new Date().toISOString(), command: "test" },
},
};
const plan = { mode: "changed", packages: ["@fusion/engine", "@fusion/core"] };
const result = applyCacheToPlan(plan, {
gitFn: gitFnMulti,
readCacheFn: () => cache,
writeCacheFn: () => {},
packageDirByName: dirByName([
["@fusion/engine", "packages/engine"],
["@fusion/core", "packages/core"],
]),
});
assert.deepEqual(result.cachedPackages, ["@fusion/engine"]);
assert.deepEqual(result.activePackages, ["@fusion/core"]);
});
// ---------------------------------------------------------------------------
// recordCachePass
// ---------------------------------------------------------------------------
test("recordCachePass: writes hash and passedAt for passing packages", () => {
let written = null;
const cache = { version: 1, entries: {} };
recordCachePass(["@fusion/engine"], dirByName([["@fusion/engine", "packages/engine"]]), {
gitFn: fakeGit("abc123"),
readCacheFn: () => cache,
writeCacheFn: (c) => { written = c; },
});
assert.ok(written, "cache was written");
const entry = written.entries["@fusion/engine"];
assert.ok(entry, "entry exists");
assert.match(entry.hash, /^[0-9a-f]{64}$/);
assert.equal(entry.command, "test");
assert.ok(new Date(entry.passedAt).getTime() > 0, "passedAt is a valid date");
});
test("recordCachePass: noCache=true skips write", () => {
let written = false;
recordCachePass(["@fusion/engine"], dirByName([["@fusion/engine", "packages/engine"]]), {
noCache: true,
gitFn: fakeGit("abc123"),
readCacheFn: () => ({ version: 1, entries: {} }),
writeCacheFn: () => { written = true; },
});
assert.equal(written, false);
});
test("recordCachePass: empty package list skips write", () => {
let written = false;
recordCachePass([], new Map(), {
gitFn: fakeGit("abc123"),
readCacheFn: () => ({ version: 1, entries: {} }),
writeCacheFn: () => { written = true; },
});
assert.equal(written, false);
});
// ---------------------------------------------------------------------------
// cacheFilePath
// ---------------------------------------------------------------------------
test("cacheFilePath: ends with .fusion/test-cache.json", () => {
const p = cacheFilePath();
assert.ok(p.endsWith(path.join(".fusion", "test-cache.json")), `got: ${p}`);
});

View File

@@ -1,11 +1,23 @@
#!/usr/bin/env node #!/usr/bin/env node
import { readFileSync, readdirSync } from "node:fs"; import { readFileSync, readdirSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { createHash } from "node:crypto";
const rootDir = process.cwd(); const rootDir = process.env.FUSION_PROJECT_DIR
? path.resolve(process.env.FUSION_PROJECT_DIR)
: process.cwd();
/** @type {string} Cache format version — bump when the shape or hash inputs change. */
const CACHE_FORMAT_VERSION = 1;
/** @type {string} Constant mixed into every content hash so format rev busts all entries. */
const HASH_VERSION_PREFIX = "v1";
/** @type {number} Max age (ms) for a cache entry to count as a pass. */
const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
function run(command, commandArgs, options = {}) { function run(command, commandArgs, options = {}) {
const result = spawnSync(command, commandArgs, { const result = spawnSync(command, commandArgs, {
@@ -142,6 +154,258 @@ export function resolveAffectedPackages(changedFiles, packageNameByDir) {
return [...affected]; return [...affected];
} }
// ---------------------------------------------------------------------------
// Content-hash cache
// ---------------------------------------------------------------------------
/**
* @typedef {{ hash: string; passedAt: string; command: string }} CacheEntry
* @typedef {{ version: number; entries: Record<string, CacheEntry> }} CacheFile
*/
/**
* Return the path to the per-project test-cache JSON file.
* Honours FUSION_PROJECT_DIR (already reflected in rootDir).
*
* @returns {string}
*/
export function cacheFilePath() {
return path.join(rootDir, ".fusion", "test-cache.json");
}
/**
* Read and parse the cache file. Returns an empty cache structure on any
* read/parse failure (corruption, missing file, etc.) and logs a warning.
*
* @param {string} filePath
* @returns {CacheFile}
*/
export function readCache(filePath) {
try {
const raw = readFileSync(filePath, "utf8");
const parsed = JSON.parse(raw);
if (
parsed &&
typeof parsed === "object" &&
parsed.version === CACHE_FORMAT_VERSION &&
parsed.entries &&
typeof parsed.entries === "object"
) {
return parsed;
}
console.warn("[test-changed] cache file has unexpected shape; treating as empty.");
return { version: CACHE_FORMAT_VERSION, entries: {} };
} catch (err) {
if (err.code !== "ENOENT") {
console.warn(`[test-changed] could not read cache (${err.message}); treating as empty.`);
}
return { version: CACHE_FORMAT_VERSION, entries: {} };
}
}
/**
* Atomically write the cache file (write to temp then rename).
*
* @param {string} filePath
* @param {CacheFile} cache
*/
export function writeCache(filePath, cache) {
const dir = path.dirname(filePath);
mkdirSync(dir, { recursive: true });
const tmp = `${filePath}.tmp.${process.pid}`;
writeFileSync(tmp, JSON.stringify(cache, null, 2), "utf8");
renameSync(tmp, filePath);
}
/**
* Compute a stable content hash for a package directory.
*
* The hash is SHA-256 over:
* - The constant version prefix HASH_VERSION_PREFIX
* - The blob SHA of pnpm-lock.yaml at HEAD
* - The blob SHA of tsconfig.base.json at HEAD
* - Every (relativePath, blobSha) pair from `git ls-files -s <pkgDir>`,
* sorted lexicographically by path for stability.
*
* Using git blob SHAs means we never read file contents ourselves — git
* already hashes them, so this is fast even for large packages.
*
* @param {string} packageDir Relative path to the package dir (e.g. "packages/engine")
* @param {(args: string[]) => string|null} gitFn Injectable git runner (for tests)
* @returns {string} 64-char hex SHA-256
*/
export function computePackageHash(packageDir, gitFn = gitOutput) {
const hash = createHash("sha256");
hash.update(HASH_VERSION_PREFIX);
hash.update("\0");
// Bust when lock file or shared TS config changes.
for (const rootFile of ["pnpm-lock.yaml", "tsconfig.base.json"]) {
// `git ls-files -s <path>` → "<mode> <blobSha> <stage>\t<path>"
const out = gitFn(["ls-files", "-s", rootFile]);
const blobSha = out ? out.split(/\s+/)[1] ?? "" : "";
hash.update(rootFile);
hash.update("=");
hash.update(blobSha);
hash.update("\0");
}
// All tracked files inside the package directory.
const lsOut = gitFn(["ls-files", "-s", packageDir]);
const entries = [];
if (lsOut) {
for (const line of lsOut.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
// Format: <mode> SP <object> SP <stage> TAB <file>
const tabIdx = trimmed.indexOf("\t");
if (tabIdx === -1) continue;
const fields = trimmed.slice(0, tabIdx).split(/\s+/);
const blobSha = fields[1] ?? "";
const filePath = trimmed.slice(tabIdx + 1);
entries.push({ filePath, blobSha });
}
}
// Sort for determinism (git output is usually sorted, but let's be explicit).
entries.sort((a, b) => a.filePath.localeCompare(b.filePath));
for (const { filePath, blobSha } of entries) {
hash.update(filePath);
hash.update("=");
hash.update(blobSha);
hash.update("\0");
}
return hash.digest("hex");
}
/**
* Return a human-readable relative time string like "3h ago" or "2d ago".
*
* @param {string} isoTimestamp
* @returns {string}
*/
function relativeTime(isoTimestamp) {
const diffMs = Date.now() - new Date(isoTimestamp).getTime();
const diffSecs = Math.floor(diffMs / 1000);
if (diffSecs < 60) return `${diffSecs}s ago`;
const diffMins = Math.floor(diffSecs / 60);
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
/**
* @typedef {Object} CacheOptions
* @property {boolean} [noCache] When true, bypass cache reads AND writes.
* @property {(args: string[]) => string|null} [gitFn] Injectable git runner.
* @property {() => CacheFile} [readCacheFn] Injectable cache reader.
* @property {(cache: CacheFile) => void} [writeCacheFn] Injectable cache writer.
* @property {Map<string, string>} [packageDirByName] pkg-name → relative dir.
*/
/**
* Apply the content-hash cache to an execution plan.
*
* This is a SEPARATE function from decideExecutionPlan so it can be tested
* independently (decideExecutionPlan remains pure / I/O-free).
*
* For "full" plans, cache lookups are always skipped (running full means full).
* For "changed" plans, any package whose hash matches a fresh cache entry is
* removed from the run set. If all packages are cached, returns a synthetic
* "all-cached" result so the caller can skip the pnpm invocation entirely.
*
* @param {{ mode: string; packages?: string[]; reason?: string }} plan
* @param {CacheOptions} [options]
* @returns {{ plan: typeof plan; cachedPackages: string[]; activePackages: string[] }}
*/
export function applyCacheToPlan(plan, options = {}) {
const {
noCache = false,
gitFn = gitOutput,
readCacheFn,
writeCacheFn,
packageDirByName = new Map(),
} = options;
// Full suite runs always bypass cache (full means full).
if (plan.mode !== "changed" || noCache) {
return { plan, cachedPackages: [], activePackages: plan.packages ?? [] };
}
const filePath = cacheFilePath();
const cache = readCacheFn ? readCacheFn() : readCache(filePath);
const now = Date.now();
const cachedPackages = [];
const activePackages = [];
for (const pkg of plan.packages ?? []) {
const pkgDir = packageDirByName.get(pkg) ?? `packages/${pkg.replace(/^@[^/]+\//, "")}`;
const computedHash = computePackageHash(pkgDir, gitFn);
const entry = cache.entries[pkg];
const isHit =
entry &&
entry.hash === computedHash &&
now - new Date(entry.passedAt).getTime() < CACHE_MAX_AGE_MS;
if (isHit) {
const sha7 = computedHash.slice(0, 7);
const when = relativeTime(entry.passedAt);
console.log(`[test-changed] cache HIT for ${pkg} (hash ${sha7}, passed ${when})`);
cachedPackages.push(pkg);
} else {
activePackages.push(pkg);
}
}
return { plan, cachedPackages, activePackages };
}
/**
* Persist passing results for the given packages into the cache.
*
* @param {string[]} packages
* @param {Map<string, string>} packageDirByName
* @param {CacheOptions} [options]
*/
export function recordCachePass(packages, packageDirByName, options = {}) {
const {
noCache = false,
gitFn = gitOutput,
readCacheFn,
writeCacheFn,
} = options;
if (noCache || packages.length === 0) return;
const filePath = cacheFilePath();
const cache = readCacheFn ? readCacheFn() : readCache(filePath);
const now = new Date().toISOString();
for (const pkg of packages) {
const pkgDir = packageDirByName.get(pkg) ?? `packages/${pkg.replace(/^@[^/]+\//, "")}`;
const hash = computePackageHash(pkgDir, gitFn);
cache.entries[pkg] = { hash, passedAt: now, command: "test" };
}
if (writeCacheFn) {
writeCacheFn(cache);
} else {
writeCache(filePath, cache);
}
}
// ---------------------------------------------------------------------------
// Execution plan
// ---------------------------------------------------------------------------
const workspaceConcurrency =
process.env.FUSION_TEST_WORKSPACE_CONCURRENCY || "2";
const fullSuiteEnv = { const fullSuiteEnv = {
...process.env, ...process.env,
FUSION_TEST_TOTAL_WORKERS: process.env.FUSION_TEST_TOTAL_WORKERS || "4", FUSION_TEST_TOTAL_WORKERS: process.env.FUSION_TEST_TOTAL_WORKERS || "4",
@@ -149,7 +413,7 @@ const fullSuiteEnv = {
}; };
function runFullSuite(forwardedArgs) { function runFullSuite(forwardedArgs) {
run("pnpm", ["-r", "--workspace-concurrency=2", "test", ...forwardedArgs], { env: fullSuiteEnv }); run("pnpm", [`-r`, `--workspace-concurrency=${workspaceConcurrency}`, "test", ...forwardedArgs], { env: fullSuiteEnv });
} }
export function decideExecutionPlan({ export function decideExecutionPlan({
@@ -176,7 +440,11 @@ export function main(argv = process.argv.slice(2)) {
process.env.FUSION_TEST_FULL === "1" || process.env.FUSION_TEST_FULL === "1" ||
argv.includes("--full"); argv.includes("--full");
const forwardedArgs = argv.filter((arg) => arg !== "--full"); const noCache =
process.env.FUSION_TEST_NO_CACHE === "1" ||
argv.includes("--no-cache");
const forwardedArgs = argv.filter((arg) => arg !== "--full" && arg !== "--no-cache");
run("pnpm", ["sync:fusion-skill:check"]); run("pnpm", ["sync:fusion-skill:check"]);
@@ -185,6 +453,12 @@ export function main(argv = process.argv.slice(2)) {
const changedFiles = comparisonBase ? changedFilesSince(comparisonBase) : null; const changedFiles = comparisonBase ? changedFilesSince(comparisonBase) : null;
const packageNameByDir = listWorkspacePackages(); const packageNameByDir = listWorkspacePackages();
// Build reverse map: pkg-name → relative dir (e.g. "packages/engine")
const packageDirByName = new Map();
for (const [dir, name] of packageNameByDir) {
packageDirByName.set(name, `packages/${dir}`);
}
const plan = decideExecutionPlan({ const plan = decideExecutionPlan({
forceFullSuite, forceFullSuite,
comparisonBase, comparisonBase,
@@ -209,9 +483,29 @@ export function main(argv = process.argv.slice(2)) {
return; return;
} }
const filterArgs = plan.packages.flatMap((pkg) => ["--filter", pkg]); // Apply the content-hash cache to prune already-passing packages.
console.log(`[test-changed] running tests for changed packages: ${plan.packages.join(", ")}`); const { cachedPackages, activePackages } = applyCacheToPlan(plan, {
run("pnpm", [...filterArgs, "test", ...forwardedArgs], { env: fullSuiteEnv }); noCache: noCache || forceFullSuite,
packageDirByName,
});
if (activePackages.length === 0) {
console.log(
`[test-changed] all changed packages are cache-fresh (${cachedPackages.join(", ")}); nothing to run.`,
);
return;
}
const filterArgs = activePackages.flatMap((pkg) => ["--filter", pkg]);
console.log(`[test-changed] running tests for changed packages: ${activePackages.join(", ")}`);
if (cachedPackages.length > 0) {
console.log(`[test-changed] skipping cached packages: ${cachedPackages.join(", ")}`);
}
run("pnpm", [...filterArgs, `--workspace-concurrency=${workspaceConcurrency}`, "test", ...forwardedArgs], { env: fullSuiteEnv });
// Tests passed — record in cache (never cache failures; process.exit on failure above).
recordCachePass(activePackages, packageDirByName, { noCache });
} }
const currentFilePath = fileURLToPath(import.meta.url); const currentFilePath = fileURLToPath(import.meta.url);