fix(FN-5291): downgrade file-scope invariant failures

This commit is contained in:
gsxdsm
2026-05-19 23:00:11 -07:00
parent 98033bc869
commit e908cbcb42
3 changed files with 34 additions and 23 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Downgrade merge-time file-scope invariant violations from task-failing errors to warning-only logs so merges can continue while still recording audit telemetry.

View File

@@ -232,13 +232,12 @@ describe("enforceSquashFileScopeInvariant audit emission", () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("emits run_audit event on file-scope violation", async () => { it("emits run_audit event on file-scope violation but continues", async () => {
const store = createInvariantStore(["packages/engine/src/merger.ts"]); const store = createInvariantStore(["packages/engine/src/merger.ts"]);
const auditor = { git: vi.fn().mockResolvedValue(undefined) }; const auditor = { git: vi.fn().mockResolvedValue(undefined) };
mockedExecSync.mockImplementation((cmd: any) => { mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd); const cmdStr = String(cmd);
if (cmdStr === "git diff --cached --name-only") return "packages/core/src/store.ts"; if (cmdStr === "git diff --cached --name-only") return "packages/core/src/store.ts";
if (cmdStr === "git reset --merge") return "";
return ""; return "";
}); });
@@ -249,8 +248,15 @@ describe("enforceSquashFileScopeInvariant audit emission", () => {
task: await (store as any).getTask("FN-4073"), task: await (store as any).getTask("FN-4073"),
resetLabel: "file-scope invariant violation", resetLabel: "file-scope invariant violation",
auditor: auditor as any, auditor: auditor as any,
})).rejects.toBeInstanceOf(FileScopeViolationError); })).resolves.toBeUndefined();
expect(store.appendAgentLog).toHaveBeenCalledWith(
"FN-4073",
expect.stringContaining("Warning only — continuing merge."),
"text",
expect.stringContaining("declaredScope:"),
"merger",
);
expect(auditor.git).toHaveBeenCalledTimes(1); expect(auditor.git).toHaveBeenCalledTimes(1);
expect(auditor.git).toHaveBeenCalledWith({ expect(auditor.git).toHaveBeenCalledWith({
type: "merge:file-scope-violation", type: "merge:file-scope-violation",
@@ -261,6 +267,7 @@ describe("enforceSquashFileScopeInvariant audit emission", () => {
declaredScope: ["packages/engine/src/merger.ts"], declaredScope: ["packages/engine/src/merger.ts"],
stagedFileCount: 1, stagedFileCount: 1,
declaredScopeCount: 1, declaredScopeCount: 1,
warningOnly: true,
}, },
}); });
}); });
@@ -282,13 +289,12 @@ describe("enforceSquashFileScopeInvariant audit emission", () => {
expect(auditor.git).not.toHaveBeenCalled(); expect(auditor.git).not.toHaveBeenCalled();
}); });
it("does not mask original violation when audit emission fails", async () => { it("does not fail when audit emission fails", async () => {
const store = createInvariantStore(["packages/engine/src/merger.ts"]); const store = createInvariantStore(["packages/engine/src/merger.ts"]);
const auditor = { git: vi.fn().mockRejectedValue(new Error("audit boom")) }; const auditor = { git: vi.fn().mockRejectedValue(new Error("audit boom")) };
mockedExecSync.mockImplementation((cmd: any) => { mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd); const cmdStr = String(cmd);
if (cmdStr === "git diff --cached --name-only") return "packages/core/src/store.ts"; if (cmdStr === "git diff --cached --name-only") return "packages/core/src/store.ts";
if (cmdStr === "git reset --merge") return "";
return ""; return "";
}); });
@@ -299,12 +305,12 @@ describe("enforceSquashFileScopeInvariant audit emission", () => {
task: await (store as any).getTask("FN-4073"), task: await (store as any).getTask("FN-4073"),
resetLabel: "file-scope invariant violation", resetLabel: "file-scope invariant violation",
auditor: auditor as any, auditor: auditor as any,
})).rejects.toBeInstanceOf(FileScopeViolationError); })).resolves.toBeUndefined();
expect(store.appendAgentLog).toHaveBeenCalledWith( expect(store.appendAgentLog).toHaveBeenCalledWith(
"FN-4073", "FN-4073",
expect.stringContaining("File-scope invariant violation"), expect.stringContaining("File-scope invariant violation"),
"tool_error", "text",
expect.stringContaining("declaredScope:"), expect.stringContaining("declaredScope:"),
"merger", "merger",
); );
@@ -315,7 +321,6 @@ describe("enforceSquashFileScopeInvariant audit emission", () => {
mockedExecSync.mockImplementation((cmd: any) => { mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd); const cmdStr = String(cmd);
if (cmdStr === "git diff --cached --name-only") return "packages/core/src/store.ts"; if (cmdStr === "git diff --cached --name-only") return "packages/core/src/store.ts";
if (cmdStr === "git reset --merge") return "";
return ""; return "";
}); });
@@ -325,7 +330,7 @@ describe("enforceSquashFileScopeInvariant audit emission", () => {
rootDir: "/tmp/root", rootDir: "/tmp/root",
task: await (store as any).getTask("FN-4073"), task: await (store as any).getTask("FN-4073"),
resetLabel: "file-scope invariant violation", resetLabel: "file-scope invariant violation",
})).rejects.toBeInstanceOf(FileScopeViolationError); })).resolves.toBeUndefined();
}); });
}); });
@@ -334,7 +339,7 @@ describe("file-scope invariant wiring", () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("blocks the standard merge AI path before the commit when staged files are out of scope", async () => { it("warns but allows the standard merge AI path when staged files are out of scope", async () => {
const store = createInvariantStore(["packages/engine/src/merger.ts"]); const store = createInvariantStore(["packages/engine/src/merger.ts"]);
mockedExecSync.mockImplementation((cmd: any) => { mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd); const cmdStr = String(cmd);
@@ -358,16 +363,16 @@ describe("file-scope invariant wiring", () => {
options: {}, options: {},
result, result,
settings: { ...DEFAULT_SETTINGS }, settings: { ...DEFAULT_SETTINGS },
}, { aiWasInvoked: false })).rejects.toBeInstanceOf(FileScopeViolationError); }, { aiWasInvoked: false })).resolves.toEqual(expect.any(Boolean));
expect(store.appendAgentLog).toHaveBeenCalledWith( expect(store.appendAgentLog).toHaveBeenCalledWith(
"FN-4073", "FN-4073",
expect.stringContaining("File-scope invariant violation"), expect.stringContaining("Warning only — continuing merge."),
"tool_error", "text",
expect.stringContaining("declaredScope:"), expect.stringContaining("declaredScope:"),
"merger", "merger",
); );
expect(mockedExecSync).toHaveBeenCalledWith("git reset --merge", expect.objectContaining({ cwd: "/tmp/root" })); expect(mockedExecSync).not.toHaveBeenCalledWith("git reset --merge", expect.objectContaining({ cwd: "/tmp/root" }));
expect(store.moveTask).not.toHaveBeenCalled(); expect(store.moveTask).not.toHaveBeenCalled();
}); });
@@ -449,7 +454,7 @@ describe("file-scope invariant wiring", () => {
); );
}); });
it("blocks verification-fix finalization when staged files are out of scope", async () => { it("warns but allows verification-fix finalization when staged files are out of scope", async () => {
const store = createInvariantStore(["packages/engine/src/merger.ts"]); const store = createInvariantStore(["packages/engine/src/merger.ts"]);
mockedExecSync.mockImplementation((cmd: any) => { mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd); const cmdStr = String(cmd);
@@ -477,15 +482,15 @@ describe("file-scope invariant wiring", () => {
undefined, undefined,
new Set(), new Set(),
store as never, store as never,
)).rejects.toBeInstanceOf(FileScopeViolationError); )).resolves.toMatchObject({ ok: true, reason: "committed" });
expect(store.appendAgentLog).toHaveBeenCalledWith( expect(store.appendAgentLog).toHaveBeenCalledWith(
"FN-4073", "FN-4073",
expect.stringContaining("File-scope invariant violation"), expect.stringContaining("Warning only — continuing merge."),
"tool_error", "text",
expect.stringContaining("stagedFiles:"), expect.stringContaining("stagedFiles:"),
"merger", "merger",
); );
expect(mockedExecSync).toHaveBeenCalledWith("git reset --merge", expect.objectContaining({ cwd: "/tmp/root" })); expect(mockedExecSync).not.toHaveBeenCalledWith("git reset --merge", expect.objectContaining({ cwd: "/tmp/root" }));
}); });
}); });

View File

@@ -4171,13 +4171,15 @@ export async function enforceSquashFileScopeInvariant(params: {
if (!(error instanceof FileScopeViolationError)) { if (!(error instanceof FileScopeViolationError)) {
throw error; throw error;
} }
const warningMessage = `${error.message} Warning only — continuing merge.`;
await params.store.appendAgentLog( await params.store.appendAgentLog(
params.taskId, params.taskId,
error.message, warningMessage,
"tool_error", "text",
formatFileScopeViolationAgentLog(error), formatFileScopeViolationAgentLog(error),
"merger", "merger",
); );
mergerLog.warn(`${params.taskId}: ${warningMessage}`);
if (params.auditor) { if (params.auditor) {
try { try {
await params.auditor.git({ await params.auditor.git({
@@ -4189,14 +4191,13 @@ export async function enforceSquashFileScopeInvariant(params: {
declaredScope: error.declaredScope, declaredScope: error.declaredScope,
stagedFileCount: error.stagedFiles.length, stagedFileCount: error.stagedFiles.length,
declaredScopeCount: error.declaredScope.length, declaredScopeCount: error.declaredScope.length,
warningOnly: true,
}, },
}); });
} catch (auditErr) { } catch (auditErr) {
mergerLog.warn(`${params.taskId}: failed to emit run_audit event for FileScopeViolationError: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`); mergerLog.warn(`${params.taskId}: failed to emit run_audit event for FileScopeViolationError: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`);
} }
} }
resetMergeWithWarn(params.rootDir, params.taskId, params.resetLabel);
throw error;
} }
} }