Files
fusion/packages/engine/src/__tests__/reliability-interactions/scope-auto-widen.real-git.test.ts
Fusion (runfusion.ai) f6f38676b4 feat(FN-5226): add scope auto-widen layer 2.5 to merger for attribution-bas
Merges the Layer 2.5 scope-auto-widen feature (FN-5226) into the merger: a new evaluator module that automatically widens a task's declared file scope based on git attribution prior to the existing scope partition gate, wired into `merger.ts` with full audit taxonomy, persisted task metadata, and re

Fusion-Task-Id: FN-5226
2026-05-21 16:30:31 -07:00

287 lines
11 KiB
TypeScript

import { afterEach, describe, expect, it } from "vitest";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { execSync, spawnSync } from "node:child_process";
import { TaskStore } from "@fusion/core";
import { applyLayer3ConflictScopePartition, getConflictedFiles } from "../../merger.js";
import { checkDiffVolume, DiffVolumeRegressionError } from "../../merger-diff-volume-gate.js";
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
const describeIfGit = hasGit ? describe : describe.skip;
function git(cwd: string, cmd: string): string {
return execSync(cmd, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
}
function promptWithScope(scope: string[]): string {
return `# Task\n\n## File Scope\n${scope.map((entry) => `- \`${entry}\``).join("\n")}\n\n## Steps\n- x\n`;
}
async function writeText(rootDir: string, file: string, content: string) {
const absolute = join(rootDir, file);
await mkdir(dirname(absolute), { recursive: true });
await writeFile(absolute, content, "utf-8");
}
async function setupScenario(options: {
taskId?: string;
targetFile: string;
declaredScope?: string[];
branchCommitMessages: Array<{ subject: string; body?: string; content: string }>;
mainContent: string;
scopeOverride?: boolean;
otherTaskScope?: string[];
gitignore?: string;
baseContent?: string;
}) {
const taskId = options.taskId ?? "FN-5226";
const rootDir = await mkdtemp(join(tmpdir(), "fn-5226-ri-"));
git(rootDir, "git init -b main");
git(rootDir, 'git config user.email "test@example.com"');
git(rootDir, 'git config user.name "Test User"');
await writeText(rootDir, "packages/desktop/src/foo.ts", "export const declared = 'base';\n");
await writeText(rootDir, options.targetFile, options.baseContent ?? "base\n");
git(rootDir, "git add .");
git(rootDir, "git commit -m 'chore: base'");
if (options.gitignore) {
await writeText(rootDir, ".gitignore", `${options.gitignore}\n`);
git(rootDir, "git add .gitignore");
git(rootDir, "git commit -m 'chore: ignore generated artifacts'");
}
await mkdir(join(rootDir, ".fusion"), { recursive: true });
const store = new TaskStore(rootDir, undefined, { inMemoryDb: true });
await store.init();
const createdTask = await store.createTask({
title: "scope auto widen",
description: taskId,
column: "in-review",
branch: `fusion/${taskId.toLowerCase()}`,
baseBranch: "main",
scopeOverride: options.scopeOverride,
prompt: promptWithScope(options.declaredScope ?? ["packages/desktop/src/**"]),
steps: [],
} as any);
const actualTaskId = createdTask.id;
const branchName = `fusion/${actualTaskId.toLowerCase()}`;
await store.updateTask(actualTaskId, { branch: branchName, baseBranch: "main" });
await writeFile(join(rootDir, ".fusion", "tasks", actualTaskId, "PROMPT.md"), promptWithScope(options.declaredScope ?? ["packages/desktop/src/**"]), "utf-8");
if (options.otherTaskScope) {
const otherTask = await store.createTask({
title: "other task",
description: "other task",
column: "todo",
prompt: promptWithScope(options.otherTaskScope),
steps: [],
} as any);
await writeFile(join(rootDir, ".fusion", "tasks", otherTask.id, "PROMPT.md"), promptWithScope(options.otherTaskScope), "utf-8");
}
git(rootDir, `git checkout -b ${branchName}`);
const stageTargetFile = options.gitignore
? `git add -u -- ${JSON.stringify(options.targetFile)}`
: `git add ${JSON.stringify(options.targetFile)}`;
for (const commit of options.branchCommitMessages) {
await writeText(rootDir, options.targetFile, commit.content);
git(rootDir, stageTargetFile);
const subject = commit.subject.replaceAll(taskId, actualTaskId);
const body = commit.body?.replaceAll(taskId, actualTaskId);
const trailer = body ? ` -m ${JSON.stringify(body)}` : "";
git(rootDir, `git commit -m ${JSON.stringify(subject)}${trailer}`);
}
git(rootDir, "git checkout main");
await writeText(rootDir, options.targetFile, options.mainContent);
git(rootDir, stageTargetFile);
git(rootDir, "git commit -m 'feat: main edit'");
git(rootDir, `git merge --squash ${branchName} || true`);
const auditEvents: Array<{ type: string; metadata: any }> = [];
const refreshedTask = await store.getTask(actualTaskId);
const conflicted = await getConflictedFiles(rootDir);
return {
rootDir,
store,
task: refreshedTask,
taskId: actualTaskId,
branchName,
conflicted,
auditEvents,
partition: async () => applyLayer3ConflictScopePartition({
store,
task: refreshedTask,
taskId: actualTaskId,
rootDir,
branch: branchName,
mergeTargetBranch: "main",
conflictFiles: conflicted,
auditor: {
git: async (event: any) => {
auditEvents.push({ type: event.type, metadata: event.metadata });
},
} as any,
}),
cleanup: async () => {
store.close();
await rm(rootDir, { recursive: true, force: true });
},
};
}
describeIfGit("reliability interaction: scope auto-widen", () => {
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
while (cleanups.length > 0) {
await cleanups.pop()!();
}
});
it("clean widen keeps the file in scope, updates prompt, and emits widen audit", async () => {
const fixture = await setupScenario({
targetFile: "AGENTS.md",
branchCommitMessages: [{ subject: "feat(FN-5226): touch foreign file", content: "branch\n" }],
mainContent: "main\n",
});
cleanups.push(fixture.cleanup);
const result = await fixture.partition();
const prompt = await readFile(join(fixture.rootDir, ".fusion", "tasks", fixture.taskId, "PROMPT.md"), "utf-8");
expect(result.inScopeConflicts).toEqual(["AGENTS.md"]);
expect(result.skippedFiles).toEqual([]);
expect(prompt).toContain(`scopeAutoWiden ${fixture.taskId}`);
expect(fixture.auditEvents.some((event) => (
event.type === "merge:scope:auto-widen" &&
event.metadata.file === "AGENTS.md" &&
event.metadata.attribution === "subject-prefix" &&
Array.isArray(event.metadata.commits) &&
event.metadata.commits.length > 0
))).toBe(true);
expect(fixture.auditEvents.some((event) => event.type === "merge:layer3:foreign-file-skipped" && event.metadata.skippedFiles.includes("AGENTS.md"))).toBe(false);
});
it("rejects widening when a foreign-attributed commit touched the file", async () => {
const fixture = await setupScenario({
targetFile: "AGENTS.md",
branchCommitMessages: [
{ subject: "feat(FN-5226): touch foreign file", content: "branch-1\n" },
{ subject: "feat(FN-7000): foreign touch", body: "Fusion-Task-Id: FN-7000", content: "branch-2\n" },
],
mainContent: "main\n",
});
cleanups.push(fixture.cleanup);
const result = await fixture.partition();
expect(result.skippedFiles).toEqual(["AGENTS.md"]);
expect(fixture.auditEvents.some((event) => event.type === "merge:scope:auto-widen")).toBe(false);
expect(fixture.auditEvents.some((event) => event.type === "merge:layer3:foreign-file-skipped" && event.metadata.skippedFiles.includes("AGENTS.md"))).toBe(true);
});
it("rejects widening when another active task already claims the file", async () => {
const fixture = await setupScenario({
targetFile: "AGENTS.md",
branchCommitMessages: [{ subject: "feat(FN-5226): touch foreign file", content: "branch\n" }],
mainContent: "main\n",
otherTaskScope: ["AGENTS.md"],
});
cleanups.push(fixture.cleanup);
const result = await fixture.partition();
expect(result.skippedFiles).toEqual(["AGENTS.md"]);
expect(fixture.auditEvents.some((event) => event.type === "merge:scope:auto-widen")).toBe(false);
});
it("rejects widening for ignored-path guard files", async () => {
const fixture = await setupScenario({
targetFile: ".fusion/tmp.txt",
branchCommitMessages: [{ subject: "feat(FN-5226): touch scratch file", content: "branch\n" }],
mainContent: "main\n",
baseContent: "base\n",
});
cleanups.push(fixture.cleanup);
const result = await applyLayer3ConflictScopePartition({
store: fixture.store,
task: fixture.task,
taskId: fixture.taskId,
rootDir: fixture.rootDir,
branch: fixture.branchName,
mergeTargetBranch: "main",
conflictFiles: [".fusion/tmp.txt"],
auditor: {
git: async (event: any) => {
fixture.auditEvents.push({ type: event.type, metadata: event.metadata });
},
} as any,
});
expect(result.skippedFiles).toEqual([".fusion/tmp.txt"]);
expect(fixture.auditEvents.some((event) => event.type === "merge:scope:auto-widen")).toBe(false);
expect(fixture.auditEvents.some((event) => event.type === "merge:layer3:foreign-file-skipped")).toBe(true);
});
it("preserves scopeOverride short-circuit", async () => {
const fixture = await setupScenario({
targetFile: "AGENTS.md",
branchCommitMessages: [{ subject: "feat(FN-5226): touch foreign file", content: "branch\n" }],
mainContent: "main\n",
scopeOverride: true,
});
cleanups.push(fixture.cleanup);
const result = await fixture.partition();
expect(result.viaScopeOverride).toBe(true);
expect(fixture.auditEvents.some((event) => event.type === "merge:scope:auto-widen")).toBe(false);
expect(fixture.auditEvents.some((event) => event.type === "merge:layer3:scope-override-bypass")).toBe(true);
});
it("composes with the diff-volume gate once the widened file is staged", async () => {
const branchContent = Array.from({ length: 70 }, (_, index) => `branch-${index}`).join("\n") + "\n";
const mainContent = Array.from({ length: 3 }, (_, index) => `main-${index}`).join("\n") + "\n";
const fixture = await setupScenario({
targetFile: "AGENTS.md",
branchCommitMessages: [{ subject: "feat(FN-5226): touch foreign file", content: branchContent }],
mainContent,
baseContent: "base\n",
});
cleanups.push(fixture.cleanup);
const result = await fixture.partition();
expect(result.inScopeConflicts).toEqual(["AGENTS.md"]);
await writeFile(join(fixture.rootDir, "AGENTS.md"), branchContent, "utf-8");
git(fixture.rootDir, "git add AGENTS.md");
await expect(checkDiffVolume({
rootDir: fixture.rootDir,
branch: fixture.branchName,
integrationTargetSha: "main",
minLines: 10,
threshold: 0.5,
allowlistGlobs: [],
taskId: fixture.taskId,
})).resolves.toBeUndefined();
await writeFile(join(fixture.rootDir, "AGENTS.md"), mainContent, "utf-8");
git(fixture.rootDir, "git add AGENTS.md");
await expect(checkDiffVolume({
rootDir: fixture.rootDir,
branch: fixture.branchName,
integrationTargetSha: "main",
minLines: 10,
threshold: 0.5,
allowlistGlobs: [],
taskId: fixture.taskId,
})).rejects.toBeInstanceOf(DiffVolumeRegressionError);
});
});