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
This commit is contained in:
committed by
gsxdsm
parent
bc959c6ca4
commit
f6f38676b4
@@ -0,0 +1,286 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user