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:
Fusion (runfusion.ai)
2026-05-21 16:29:47 -07:00
committed by gsxdsm
parent bc959c6ca4
commit f6f38676b4
21 changed files with 917 additions and 50 deletions

View 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);
});
});

View File

@@ -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);
});
});

View File

@@ -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"],
});
});
});

View 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;
}

View File

@@ -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,
});

View File

@@ -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"