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:
7
.changeset/test-cache-content-hash.md
Normal file
7
.changeset/test-cache-content-hash.md
Normal 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`).
|
||||||
5
.changeset/verification-cache-and-concurrency.md
Normal file
5
.changeset/verification-cache-and-concurrency.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Cache merge verification by tree hash and boost test concurrency for in-review verification.
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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) ────────────────────────
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
552
scripts/__tests__/test-changed.test.mjs
Normal file
552
scripts/__tests__/test-changed.test.mjs
Normal 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}`);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user