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
219
packages/engine/src/__tests__/merger-scope-auto-widen.test.ts
Normal file
219
packages/engine/src/__tests__/merger-scope-auto-widen.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { appendAutoWidenedScopeToPrompt, evaluateScopeAutoWiden, ScopeAutoWidenPersistError } from "../merger-scope-auto-widen.js";
|
||||
|
||||
describe("merger-scope-auto-widen", () => {
|
||||
const roots: string[] = [];
|
||||
|
||||
async function makeRoot() {
|
||||
const root = await mkdtemp(join(tmpdir(), "fn-5226-"));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
while (roots.length > 0) {
|
||||
await rm(roots.pop()!, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("widens a clean own-attributed candidate", async () => {
|
||||
const store = {
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
parseFileScopeFromPrompt: vi.fn(),
|
||||
} as any;
|
||||
const exec = vi.fn()
|
||||
.mockRejectedValueOnce(new Error("not ignored"))
|
||||
.mockResolvedValueOnce({ stdout: "sha1\x00feat(FN-5226): update\x00\x1e" });
|
||||
|
||||
const result = await evaluateScopeAutoWiden({
|
||||
store,
|
||||
task: { id: "FN-5226" } as any,
|
||||
taskId: "FN-5226",
|
||||
rootDir: "/tmp",
|
||||
branch: "fusion/fn-5226",
|
||||
baseRef: "main",
|
||||
candidateFiles: ["packages/engine/src/merger.ts"],
|
||||
execAsyncImpl: exec as any,
|
||||
});
|
||||
|
||||
expect(result.widened).toEqual([{
|
||||
file: "packages/engine/src/merger.ts",
|
||||
attribution: "subject-prefix",
|
||||
commits: ["sha1"],
|
||||
}]);
|
||||
expect(result.refused).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses on foreign-attributed commits", async () => {
|
||||
const store = {
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
parseFileScopeFromPrompt: vi.fn(),
|
||||
} as any;
|
||||
const exec = vi.fn()
|
||||
.mockRejectedValueOnce(new Error("not ignored"))
|
||||
.mockResolvedValueOnce({ stdout: "sha1\x00feat(FN-9999): foreign\x00\x1e" })
|
||||
.mockRejectedValueOnce(new Error("not ignored"))
|
||||
.mockResolvedValueOnce({ stdout: "sha2\x00no token\x00\x1e" });
|
||||
|
||||
const result = await evaluateScopeAutoWiden({
|
||||
store,
|
||||
task: { id: "FN-5226" } as any,
|
||||
taskId: "FN-5226",
|
||||
rootDir: "/tmp",
|
||||
branch: "fusion/fn-5226",
|
||||
baseRef: "main",
|
||||
candidateFiles: ["foreign.ts", "none.ts"],
|
||||
execAsyncImpl: exec as any,
|
||||
});
|
||||
|
||||
expect(result.widened).toEqual([]);
|
||||
expect(result.refused).toEqual([
|
||||
{ file: "foreign.ts", reason: "foreign-commit" },
|
||||
{ file: "none.ts", reason: "foreign-commit" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses when git log has no branch-side attribution evidence", async () => {
|
||||
const store = {
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
parseFileScopeFromPrompt: vi.fn(),
|
||||
} as any;
|
||||
const exec = vi.fn()
|
||||
.mockRejectedValueOnce(new Error("not ignored"))
|
||||
.mockResolvedValueOnce({ stdout: "" });
|
||||
|
||||
const result = await evaluateScopeAutoWiden({
|
||||
store,
|
||||
task: { id: "FN-5226" } as any,
|
||||
taskId: "FN-5226",
|
||||
rootDir: "/tmp",
|
||||
branch: "fusion/fn-5226",
|
||||
baseRef: "main",
|
||||
candidateFiles: ["no-log.ts"],
|
||||
execAsyncImpl: exec as any,
|
||||
});
|
||||
|
||||
expect(result.widened).toEqual([]);
|
||||
expect(result.refused).toEqual([{ file: "no-log.ts", reason: "no-attribution" }]);
|
||||
});
|
||||
|
||||
it("refuses .fusion and gitignored paths", async () => {
|
||||
const store = {
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
parseFileScopeFromPrompt: vi.fn(),
|
||||
} as any;
|
||||
const exec = vi.fn().mockResolvedValue({ stdout: "" });
|
||||
|
||||
const result = await evaluateScopeAutoWiden({
|
||||
store,
|
||||
task: { id: "FN-5226" } as any,
|
||||
taskId: "FN-5226",
|
||||
rootDir: "/tmp",
|
||||
branch: "fusion/fn-5226",
|
||||
baseRef: "main",
|
||||
candidateFiles: [".fusion/tasks/FN-1/notes.txt", "ignored.log"],
|
||||
execAsyncImpl: exec as any,
|
||||
});
|
||||
|
||||
expect(result.widened).toEqual([]);
|
||||
expect(result.refused).toEqual([
|
||||
{ file: ".fusion/tasks/FN-1/notes.txt", reason: "ignored-path" },
|
||||
{ file: "ignored.log", reason: "ignored-path" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses when another active task claims the path (including glob scopes) and ignores done/archived tasks", async () => {
|
||||
const store = {
|
||||
listTasks: vi.fn().mockResolvedValue([
|
||||
{ id: "FN-100", column: "in-progress", deletedAt: null },
|
||||
{ id: "FN-101", column: "done", deletedAt: null },
|
||||
{ id: "FN-102", column: "archived", deletedAt: null },
|
||||
]),
|
||||
parseFileScopeFromPrompt: vi.fn(async (taskId: string) => {
|
||||
if (taskId === "FN-100") return ["claimed.ts", "packages/engine/src/**/*.ts"];
|
||||
return ["other.ts"];
|
||||
}),
|
||||
} as any;
|
||||
const exec = vi.fn().mockRejectedValue(new Error("not ignored"));
|
||||
|
||||
const result = await evaluateScopeAutoWiden({
|
||||
store,
|
||||
task: { id: "FN-5226" } as any,
|
||||
taskId: "FN-5226",
|
||||
rootDir: "/tmp",
|
||||
branch: "fusion/fn-5226",
|
||||
baseRef: "main",
|
||||
candidateFiles: ["packages/engine/src/utils/foo.ts"],
|
||||
execAsyncImpl: exec as any,
|
||||
});
|
||||
|
||||
expect(result.widened).toEqual([]);
|
||||
expect(result.refused).toEqual([{ file: "packages/engine/src/utils/foo.ts", reason: "claimed-by-other-task" }]);
|
||||
});
|
||||
|
||||
it("appends scope markers idempotently and parseFileScopeFromPrompt round-trips", async () => {
|
||||
const root = await makeRoot();
|
||||
const taskId = "FN-5226";
|
||||
const taskDir = join(root, ".fusion", "tasks", taskId);
|
||||
await mkdir(taskDir, { recursive: true });
|
||||
await writeFile(join(taskDir, "PROMPT.md"), `# Prompt\n\n## File Scope\n\n<!-- scopeOverride manual -->\n- \`packages/engine/src/merger.ts\`\n\n## Steps\n- one\n`, "utf-8");
|
||||
|
||||
const fakeStore = { getTaskDir: (id: string) => join(root, ".fusion", "tasks", id) } as any;
|
||||
const addedFirst = await appendAutoWidenedScopeToPrompt({ store: fakeStore, taskId, files: ["AGENTS.md", "packages/engine/src/merger.ts"] });
|
||||
const addedSecond = await appendAutoWidenedScopeToPrompt({ store: fakeStore, taskId, files: ["AGENTS.md"] });
|
||||
|
||||
expect(addedFirst).toEqual(["AGENTS.md"]);
|
||||
expect(addedSecond).toEqual([]);
|
||||
|
||||
const prompt = await readFile(join(taskDir, "PROMPT.md"), "utf-8");
|
||||
expect(prompt).toContain("<!-- scopeOverride manual -->");
|
||||
expect(prompt).toContain("- `AGENTS.md` <!-- scopeAutoWiden FN-5226 -->");
|
||||
expect(prompt.match(/scopeAutoWiden FN-5226/g)?.length ?? 0).toBe(1);
|
||||
|
||||
const store = new TaskStore(root, join(root, ".fusion-global-settings"), { inMemoryDb: true });
|
||||
const parsed = await store.parseFileScopeFromPrompt(taskId);
|
||||
expect(parsed).toContain("AGENTS.md");
|
||||
expect(parsed).toContain("packages/engine/src/merger.ts");
|
||||
});
|
||||
|
||||
it("accepts trailer attribution with multi-line commit body", async () => {
|
||||
const store = {
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
parseFileScopeFromPrompt: vi.fn(),
|
||||
} as any;
|
||||
const exec = vi.fn()
|
||||
.mockRejectedValueOnce(new Error("not ignored"))
|
||||
.mockResolvedValueOnce({ stdout: "sha3\x00chore: detailed message\x00Body line\n\nFusion-Task-Id: FN-5226\x1e" });
|
||||
|
||||
const result = await evaluateScopeAutoWiden({
|
||||
store,
|
||||
task: { id: "FN-5226" } as any,
|
||||
taskId: "FN-5226",
|
||||
rootDir: "/tmp",
|
||||
branch: "fusion/fn-5226",
|
||||
baseRef: "main",
|
||||
candidateFiles: ["multiline.ts"],
|
||||
execAsyncImpl: exec as any,
|
||||
});
|
||||
|
||||
expect(result.widened).toEqual([{ file: "multiline.ts", attribution: "trailer", commits: ["sha3"] }]);
|
||||
expect(result.refused).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws when File Scope section is missing", async () => {
|
||||
const root = await makeRoot();
|
||||
const taskId = "FN-5226";
|
||||
const taskDir = join(root, ".fusion", "tasks", taskId);
|
||||
await mkdir(taskDir, { recursive: true });
|
||||
await writeFile(join(taskDir, "PROMPT.md"), "# Prompt\n\n## Steps\n- one\n", "utf-8");
|
||||
|
||||
const fakeStore = { getTaskDir: (id: string) => join(root, ".fusion", "tasks", id) } as any;
|
||||
await expect(appendAutoWidenedScopeToPrompt({ store: fakeStore, taskId, files: ["AGENTS.md"] })).rejects.toBeInstanceOf(ScopeAutoWidenPersistError);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -87,4 +87,29 @@ describe("run-audit provisioning mutation types", () => {
|
||||
"merge:integration-ref-advance",
|
||||
]);
|
||||
});
|
||||
|
||||
it("records merge:scope:auto-widen git events", async () => {
|
||||
const store = new AuditStoreStub();
|
||||
const auditor = createRunAuditor(store as unknown as TaskStore, { runId: "r1", agentId: "a1", taskId: "FN-5226" });
|
||||
|
||||
await auditor.git({
|
||||
type: "merge:scope:auto-widen",
|
||||
target: "fusion/fn-5226",
|
||||
metadata: {
|
||||
taskId: "FN-5226",
|
||||
file: "AGENTS.md",
|
||||
attribution: "subject-prefix",
|
||||
commits: ["abc123"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.events).toHaveLength(1);
|
||||
expect(store.events[0]?.mutationType).toBe("merge:scope:auto-widen");
|
||||
expect(store.events[0]?.metadata).toEqual({
|
||||
taskId: "FN-5226",
|
||||
file: "AGENTS.md",
|
||||
attribution: "subject-prefix",
|
||||
commits: ["abc123"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
239
packages/engine/src/merger-scope-auto-widen.ts
Normal file
239
packages/engine/src/merger-scope-auto-widen.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import type { Task, TaskStore } from "@fusion/core";
|
||||
|
||||
import { toTaskToken } from "./merger.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const GIT_MAX_BUFFER = 10 * 1024 * 1024;
|
||||
|
||||
type Attribution = "subject-prefix" | "bracketed-prefix" | "trailer";
|
||||
export type ScopeAutoWidenRefusalReason = "foreign-commit" | "claimed-by-other-task" | "ignored-path" | "no-attribution";
|
||||
|
||||
export interface ScopeAutoWidenAccepted {
|
||||
file: string;
|
||||
attribution: Attribution;
|
||||
commits: string[];
|
||||
}
|
||||
|
||||
export interface ScopeAutoWidenRefused {
|
||||
file: string;
|
||||
reason: ScopeAutoWidenRefusalReason;
|
||||
}
|
||||
|
||||
export interface ScopeAutoWidenResult {
|
||||
widened: ScopeAutoWidenAccepted[];
|
||||
refused: ScopeAutoWidenRefused[];
|
||||
}
|
||||
|
||||
export interface EvaluateScopeAutoWidenParams {
|
||||
store: Pick<TaskStore, "parseFileScopeFromPrompt"> & Partial<Pick<TaskStore, "listTasks">>;
|
||||
task: Task;
|
||||
taskId: string;
|
||||
rootDir: string;
|
||||
branch: string;
|
||||
baseRef: string;
|
||||
candidateFiles: string[];
|
||||
execAsyncImpl?: typeof execAsync;
|
||||
}
|
||||
|
||||
export class ScopeAutoWidenPersistError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ScopeAutoWidenPersistError";
|
||||
}
|
||||
}
|
||||
|
||||
function quoteArg(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function attributedBySubjectPrefix(subject: string, taskToken: string): boolean {
|
||||
const conventional = /^(?:feat|fix|test|chore|docs|refactor|perf|build|ci|style|revert)\s*\(([A-Z]+-\d+)\)!?:/i.exec(subject);
|
||||
if (conventional?.[1] && toTaskToken(conventional[1]) === taskToken) return true;
|
||||
const legacyColon = /^\s*([A-Z]+-\d+):/i.exec(subject);
|
||||
return !!legacyColon?.[1] && toTaskToken(legacyColon[1]) === taskToken;
|
||||
}
|
||||
|
||||
function attributedByBracketedPrefix(subject: string, taskToken: string): boolean {
|
||||
const bracketed = /^\s*\[([A-Z]+-\d+)\]/i.exec(subject);
|
||||
return !!bracketed?.[1] && toTaskToken(bracketed[1]) === taskToken;
|
||||
}
|
||||
|
||||
function attributedByTrailer(body: string, taskToken: string): boolean {
|
||||
const trailerPattern = /(?:^|\n)(?:Fusion-Task-Id|Task-Id):\s*(\S+)\s*(?:\n|$)/gim;
|
||||
let match: RegExpExecArray | null = null;
|
||||
let last: RegExpExecArray | null = null;
|
||||
while (true) {
|
||||
match = trailerPattern.exec(body);
|
||||
if (!match) break;
|
||||
last = match;
|
||||
}
|
||||
return !!last?.[1] && toTaskToken(last[1]) === taskToken;
|
||||
}
|
||||
|
||||
function classifyCommitAttribution(subject: string, body: string, taskToken: string): Attribution | null {
|
||||
if (attributedByTrailer(body, taskToken)) return "trailer";
|
||||
if (attributedBySubjectPrefix(subject, taskToken)) return "subject-prefix";
|
||||
if (attributedByBracketedPrefix(subject, taskToken)) return "bracketed-prefix";
|
||||
return null;
|
||||
}
|
||||
|
||||
async function isGitIgnored(file: string, rootDir: string, execImpl: typeof execAsync): Promise<boolean> {
|
||||
try {
|
||||
await execImpl(`git check-ignore -- ${quoteArg(file)}`, {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
maxBuffer: GIT_MAX_BUFFER,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function matchGlob(path: string, glob: string): boolean {
|
||||
const escaped = glob
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*\*/g, "::DOUBLESTAR::")
|
||||
.replace(/\*/g, "[^/]*")
|
||||
.replace(/::DOUBLESTAR::/g, ".*");
|
||||
return new RegExp(`^${escaped}$`).test(path);
|
||||
}
|
||||
|
||||
function scopeContainsPath(scope: string[], file: string): boolean {
|
||||
return scope.some((entry) => entry === file || (entry.includes("*") && matchGlob(file, entry)));
|
||||
}
|
||||
|
||||
export async function evaluateScopeAutoWiden(params: EvaluateScopeAutoWidenParams): Promise<ScopeAutoWidenResult> {
|
||||
const { store, task, taskId, rootDir, branch, baseRef, candidateFiles } = params;
|
||||
const execImpl = params.execAsyncImpl ?? execAsync;
|
||||
const widened: ScopeAutoWidenAccepted[] = [];
|
||||
const refused: ScopeAutoWidenRefused[] = [];
|
||||
const taskToken = toTaskToken(task.id || taskId);
|
||||
|
||||
const allTasks = typeof store.listTasks === "function"
|
||||
? await store.listTasks({ slim: true, includeArchived: false })
|
||||
: [];
|
||||
const activeOtherTasks = allTasks.filter((other) => (
|
||||
other.id !== taskId &&
|
||||
other.deletedAt == null &&
|
||||
other.column !== "done" &&
|
||||
other.column !== "archived"
|
||||
));
|
||||
|
||||
for (const file of candidateFiles) {
|
||||
if (file === ".fusion" || file.startsWith(".fusion/")) {
|
||||
refused.push({ file, reason: "ignored-path" });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await isGitIgnored(file, rootDir, execImpl)) {
|
||||
refused.push({ file, reason: "ignored-path" });
|
||||
continue;
|
||||
}
|
||||
|
||||
let claimed = false;
|
||||
for (const otherTask of activeOtherTasks) {
|
||||
try {
|
||||
const otherScope = await store.parseFileScopeFromPrompt(otherTask.id);
|
||||
if (scopeContainsPath(otherScope, file)) {
|
||||
refused.push({ file, reason: "claimed-by-other-task" });
|
||||
claimed = true;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// fail-open per-task parse errors for peer prompts
|
||||
}
|
||||
}
|
||||
if (claimed) continue;
|
||||
|
||||
const { stdout } = await execImpl(
|
||||
`git log ${quoteArg(`${baseRef}..${branch}`)} --format=%H%x00%s%x00%B%x1e -- ${quoteArg(file)}`,
|
||||
{
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
maxBuffer: GIT_MAX_BUFFER,
|
||||
},
|
||||
);
|
||||
|
||||
const records = stdout.split("\x1e").map((entry) => entry.trim()).filter(Boolean);
|
||||
if (records.length === 0) {
|
||||
refused.push({ file, reason: "no-attribution" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const commits: string[] = [];
|
||||
const attributions: Attribution[] = [];
|
||||
let isForeign = false;
|
||||
for (const record of records) {
|
||||
const [sha = "", subject = "", ...bodyParts] = record.split("\x00");
|
||||
const body = bodyParts.join("\x00");
|
||||
const attribution = classifyCommitAttribution(subject, body, taskToken);
|
||||
if (!attribution) {
|
||||
isForeign = true;
|
||||
break;
|
||||
}
|
||||
commits.push(sha);
|
||||
attributions.push(attribution);
|
||||
}
|
||||
|
||||
if (isForeign) {
|
||||
refused.push({ file, reason: "foreign-commit" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const attribution = attributions.every((value) => value === attributions[0]) ? attributions[0]! : "trailer";
|
||||
widened.push({ file, attribution, commits });
|
||||
}
|
||||
|
||||
return { widened, refused };
|
||||
}
|
||||
|
||||
function splitPromptSections(prompt: string): { before: string; section: string; after: string } {
|
||||
const headingMatch = prompt.match(/^##\s+File Scope\s*$/m);
|
||||
if (!headingMatch || headingMatch.index == null) {
|
||||
throw new ScopeAutoWidenPersistError("PROMPT.md missing ## File Scope section");
|
||||
}
|
||||
|
||||
const sectionStart = headingMatch.index + headingMatch[0].length;
|
||||
const rest = prompt.slice(sectionStart);
|
||||
const nextHeadingIndex = rest.search(/\n##?\s/);
|
||||
const sectionEnd = nextHeadingIndex === -1 ? prompt.length : sectionStart + nextHeadingIndex;
|
||||
|
||||
return {
|
||||
before: prompt.slice(0, sectionStart),
|
||||
section: prompt.slice(sectionStart, sectionEnd),
|
||||
after: prompt.slice(sectionEnd),
|
||||
};
|
||||
}
|
||||
|
||||
function parseScopeEntries(section: string): Set<string> {
|
||||
const tokens = section.match(/`([^`]+)`/g) ?? [];
|
||||
return new Set(tokens.map((token) => token.slice(1, -1)));
|
||||
}
|
||||
|
||||
export async function appendAutoWidenedScopeToPrompt(params: {
|
||||
store: Pick<TaskStore, "getTaskDir">;
|
||||
taskId: string;
|
||||
files: string[];
|
||||
}): Promise<string[]> {
|
||||
const { store, taskId, files } = params;
|
||||
if (files.length === 0) return [];
|
||||
|
||||
const promptPath = join(store.getTaskDir(taskId), "PROMPT.md");
|
||||
const prompt = await readFile(promptPath, "utf-8");
|
||||
const { before, section, after } = splitPromptSections(prompt);
|
||||
const existing = parseScopeEntries(section);
|
||||
const toAdd = files.filter((file) => !existing.has(file));
|
||||
if (toAdd.length === 0) return [];
|
||||
|
||||
const insertion = toAdd.map((file) => `- \`${file}\` <!-- scopeAutoWiden ${taskId} -->`).join("\n");
|
||||
const sectionTrimmed = section.trimEnd();
|
||||
const normalizedSection = sectionTrimmed.length === 0 ? `\n\n${insertion}\n` : `${sectionTrimmed}\n${insertion}\n`;
|
||||
await writeFile(promptPath, `${before}${normalizedSection}${after}`, "utf-8");
|
||||
return toAdd;
|
||||
}
|
||||
@@ -111,6 +111,7 @@ import {
|
||||
import { acquireTaskWorktree } from "./worktree-acquisition.js";
|
||||
import { resolveIntegrationBranch } from "./integration-branch.js";
|
||||
import { advanceIntegrationBranchRef, IntegrationBranchConcurrentAdvanceError } from "./merger-ref-update-advance.js";
|
||||
import { appendAutoWidenedScopeToPrompt, evaluateScopeAutoWiden } from "./merger-scope-auto-widen.js";
|
||||
|
||||
export { DiffVolumeRegressionError } from "./merger-diff-volume-gate.js";
|
||||
export { IntegrationBranchConcurrentAdvanceError } from "./merger-ref-update-advance.js";
|
||||
@@ -534,7 +535,7 @@ async function findOwnedLandedCommitForTask(rootDir: string, task: Task): Promis
|
||||
return null;
|
||||
}
|
||||
|
||||
function toTaskToken(value: string): string {
|
||||
export function toTaskToken(value: string): string {
|
||||
return value.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||
}
|
||||
|
||||
@@ -2652,6 +2653,7 @@ async function tryRecoverHardFailApply(params: {
|
||||
taskId,
|
||||
rootDir,
|
||||
branch: task.branch || canonicalFusionBranchName(taskId),
|
||||
mergeTargetBranch: task.baseBranch || "main",
|
||||
conflictFiles: threeWayConflicted,
|
||||
auditor: undefined,
|
||||
});
|
||||
@@ -3081,6 +3083,7 @@ async function restoreUnrelatedRootDirChanges(
|
||||
taskId,
|
||||
rootDir,
|
||||
branch: task.branch || canonicalFusionBranchName(taskId),
|
||||
mergeTargetBranch: task.baseBranch || "main",
|
||||
conflictFiles: conflictedFiles,
|
||||
auditor: undefined,
|
||||
});
|
||||
@@ -4080,10 +4083,11 @@ export async function applyLayer3ConflictScopePartition(params: {
|
||||
taskId: string;
|
||||
rootDir: string;
|
||||
branch: string;
|
||||
mergeTargetBranch?: string;
|
||||
conflictFiles: string[];
|
||||
auditor?: RunAuditor;
|
||||
}): Promise<{ inScopeConflicts: string[]; skippedFiles: string[]; declaredScope: string[]; viaScopeOverride: boolean }> {
|
||||
const { store, task, taskId, rootDir, branch, conflictFiles, auditor } = params;
|
||||
const { store, task, taskId, rootDir, branch, mergeTargetBranch = "main", conflictFiles, auditor } = params;
|
||||
if (conflictFiles.length === 0 || typeof (store as Partial<TaskStore>).parseFileScopeFromPrompt !== "function") {
|
||||
return { inScopeConflicts: conflictFiles, skippedFiles: [], declaredScope: [], viaScopeOverride: false };
|
||||
}
|
||||
@@ -4114,7 +4118,62 @@ export async function applyLayer3ConflictScopePartition(params: {
|
||||
return { inScopeConflicts: conflictFiles, skippedFiles: [], declaredScope, viaScopeOverride: false };
|
||||
}
|
||||
|
||||
const { inScope, outOfScope } = partitionConflictsByFileScope({ conflictFiles, declaredScope });
|
||||
let effectiveDeclaredScope = [...declaredScope];
|
||||
let outOfScope = conflictFiles.filter((file) => !matchesScope(file, effectiveDeclaredScope));
|
||||
if (outOfScope.length > 0) {
|
||||
const scopeAutoWiden = await evaluateScopeAutoWiden({
|
||||
store,
|
||||
task,
|
||||
taskId,
|
||||
rootDir,
|
||||
branch,
|
||||
baseRef: mergeTargetBranch,
|
||||
candidateFiles: outOfScope,
|
||||
});
|
||||
|
||||
if (scopeAutoWiden.widened.length > 0) {
|
||||
try {
|
||||
const widenedFiles = await appendAutoWidenedScopeToPrompt({
|
||||
store,
|
||||
taskId,
|
||||
files: scopeAutoWiden.widened.map((entry) => entry.file),
|
||||
});
|
||||
if (widenedFiles.length > 0) {
|
||||
effectiveDeclaredScope = await store.parseFileScopeFromPrompt(taskId);
|
||||
const widenedSet = new Set(widenedFiles);
|
||||
for (const widened of scopeAutoWiden.widened.filter((entry) => widenedSet.has(entry.file))) {
|
||||
if (auditor) {
|
||||
await auditor.git({
|
||||
type: "merge:scope:auto-widen",
|
||||
target: branch,
|
||||
metadata: {
|
||||
taskId,
|
||||
file: widened.file,
|
||||
attribution: widened.attribution,
|
||||
commits: widened.commits,
|
||||
},
|
||||
}).catch((error: unknown) => {
|
||||
mergerLog.warn(`${taskId}: failed to emit merge:scope:auto-widen run_audit event: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
await store.appendAgentLog(
|
||||
taskId,
|
||||
`Layer 2.5 auto-widened File Scope: ${widenedFiles.join(", ")}`,
|
||||
"text",
|
||||
undefined,
|
||||
"merger",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
mergerLog.warn(`${taskId}: failed to persist Layer 2.5 auto-widened scope, continuing with strip path: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
outOfScope = outOfScope.filter((file) => !matchesScope(file, effectiveDeclaredScope));
|
||||
}
|
||||
|
||||
const { inScope } = partitionConflictsByFileScope({ conflictFiles, declaredScope: effectiveDeclaredScope });
|
||||
for (const file of outOfScope) {
|
||||
// In merge and rebase conflict contexts, `--ours` resolves to the
|
||||
// integration-target side (main bytes), which we keep for out-of-scope files.
|
||||
@@ -4142,7 +4201,7 @@ export async function applyLayer3ConflictScopePartition(params: {
|
||||
metadata: {
|
||||
taskId,
|
||||
skippedFiles: outOfScope,
|
||||
declaredScope,
|
||||
declaredScope: effectiveDeclaredScope,
|
||||
inScopeCount: inScope.length,
|
||||
viaScopeOverride: false,
|
||||
},
|
||||
@@ -4152,7 +4211,7 @@ export async function applyLayer3ConflictScopePartition(params: {
|
||||
}
|
||||
}
|
||||
|
||||
return { inScopeConflicts: inScope, skippedFiles: outOfScope, declaredScope, viaScopeOverride: false };
|
||||
return { inScopeConflicts: inScope, skippedFiles: outOfScope, declaredScope: effectiveDeclaredScope, viaScopeOverride: false };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -8180,6 +8239,7 @@ export async function aiMergeTask(
|
||||
options,
|
||||
result,
|
||||
settings,
|
||||
mergeTargetBranch: mergeTarget.branch,
|
||||
testCommand: effectiveTestCommand,
|
||||
buildCommand: effectiveBuildCommand,
|
||||
testSource: effectiveTestSource,
|
||||
@@ -9442,6 +9502,7 @@ interface MergeAttemptParams {
|
||||
options: MergerOptions;
|
||||
result: MergeResult;
|
||||
settings: Settings;
|
||||
mergeTargetBranch?: string;
|
||||
testCommand?: string;
|
||||
buildCommand?: string;
|
||||
/** Source of the test command: 'explicit' from settings or 'inferred' from project files */
|
||||
@@ -9568,6 +9629,7 @@ export async function executeMergeAttempt(
|
||||
taskId,
|
||||
rootDir,
|
||||
branch,
|
||||
mergeTargetBranch: params.mergeTargetBranch ?? "main",
|
||||
conflictFiles: conflictedFiles,
|
||||
auditor: params.auditor,
|
||||
});
|
||||
@@ -9760,6 +9822,7 @@ export async function executeMergeAttempt(
|
||||
taskId,
|
||||
rootDir,
|
||||
branch,
|
||||
mergeTargetBranch: params.mergeTargetBranch ?? "main",
|
||||
conflictFiles: conflictedFiles,
|
||||
auditor: params.auditor,
|
||||
});
|
||||
|
||||
@@ -156,6 +156,7 @@ export type GitMutationType =
|
||||
| "merge:auto-prerebase:failed"
|
||||
| "merge:layer3:foreign-file-skipped"
|
||||
| "merge:layer3:scope-override-bypass"
|
||||
| "merge:scope:auto-widen"
|
||||
| "merge:reuse-handoff-acquired"
|
||||
| "merge:reuse-handoff-refused"
|
||||
| "merge:reuse-handoff-released"
|
||||
|
||||
Reference in New Issue
Block a user