feat(FN-4097): add merger audit gate test coverage for combined audit findi
Adds comprehensive test coverage for the merger audit gate across two test files, extending lifecycle coverage to 183 lines and adding squash-audit coverage with 31 lines. Fusion-Task-Id: FN-4097
This commit is contained in:
@@ -2396,7 +2396,7 @@ describe("aiMergeTask post-squash audit gate", () => {
|
||||
});
|
||||
}
|
||||
|
||||
function createAuditStore() {
|
||||
function createAuditStore(overrides: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
@@ -2407,10 +2407,85 @@ describe("aiMergeTask post-squash audit gate", () => {
|
||||
mergeConflictStrategy: "ai-only",
|
||||
worktreeRebaseBeforeMerge: false,
|
||||
worktreeRebaseLocalBase: false,
|
||||
...overrides,
|
||||
});
|
||||
return store;
|
||||
}
|
||||
|
||||
function setupNoConflictMergeExecSync() {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("symbolic-ref --short HEAD")) return "main" as any;
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123" as any;
|
||||
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("rev-list --count")) return "1\n" as any;
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
|
||||
if (cmdStr.includes("merge --squash") && !cmdStr.includes("-X")) return Buffer.from("");
|
||||
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "" as any;
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
|
||||
if (cmdStr.includes("git commit ")) return Buffer.from("");
|
||||
if (cmdStr.includes("show --shortstat")) return "2 files changed, 4 insertions(+), 1 deletion(-)" as any;
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
}
|
||||
|
||||
function setupAttempt3FallbackMergeExecSync() {
|
||||
let hasConflicts = true;
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("symbolic-ref --short HEAD")) return "main" as any;
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123" as any;
|
||||
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("rev-list --count")) return "1\n" as any;
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
|
||||
if (cmdStr.includes("merge --squash") && !cmdStr.includes("-X")) {
|
||||
throw new Error("Merge conflict");
|
||||
}
|
||||
if (cmdStr.includes("merge -X ours --squash")) {
|
||||
hasConflicts = false;
|
||||
return Buffer.from("");
|
||||
}
|
||||
if (cmdStr.includes("diff --name-only --diff-filter=U")) {
|
||||
return hasConflicts ? "src/complex.ts\n" : "";
|
||||
}
|
||||
if (cmdStr.includes("diff-tree")) {
|
||||
const error = new Error("exit code 1") as any;
|
||||
error.stdout = "+const x = 2;\n-const x = 1;";
|
||||
throw error;
|
||||
}
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
|
||||
if (cmdStr.includes("git commit ")) return Buffer.from("");
|
||||
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D") || cmdStr.includes("worktree remove") || cmdStr.includes("reset --merge")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
}
|
||||
|
||||
function setupEmptySquashMergeExecSync() {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("symbolic-ref --short HEAD")) return "main" as any;
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123" as any;
|
||||
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("rev-list --count")) return "1\n" as any;
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "0 files changed" as any;
|
||||
if (cmdStr.includes("merge --squash") && !cmdStr.includes("-X")) return Buffer.from("");
|
||||
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "" as any;
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "0" as any;
|
||||
if (cmdStr.includes("show --shortstat")) return "0 files changed" as any;
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D") || cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
}
|
||||
|
||||
it("moves the task to done when the post-squash audit is clean", async () => {
|
||||
setupAutoResolvedMergeExecSync();
|
||||
const store = createAuditStore();
|
||||
@@ -2499,6 +2574,112 @@ describe("aiMergeTask post-squash audit gate", () => {
|
||||
);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-050", { status: null });
|
||||
});
|
||||
|
||||
it("blocks completion and logs combined duplicate-subject and touched-file findings", async () => {
|
||||
setupAutoResolvedMergeExecSync();
|
||||
mockedAuditSquashMerge.mockResolvedValue({
|
||||
squashSha: "mergedcommit123",
|
||||
parentSha: "parent123",
|
||||
squashSubject: "feat: squash merge",
|
||||
lookback: 30,
|
||||
branchSubjects: ["feat: duplicate subject", "feat: branch change"],
|
||||
recentMainSubjects: ["feat: duplicate subject"],
|
||||
duplicateSubjects: [{ type: "duplicate-subject", subject: "feat: duplicate subject" }],
|
||||
touchedFiles: ["src/shared.ts"],
|
||||
touchedFileOverlaps: [{
|
||||
type: "touched-file-overlap",
|
||||
file: "src/shared.ts",
|
||||
recentMainCommits: [{ sha: "abc1234", subject: "fix: recent main change" }],
|
||||
}],
|
||||
findings: [
|
||||
{ type: "duplicate-subject", subject: "feat: duplicate subject" },
|
||||
{
|
||||
type: "touched-file-overlap",
|
||||
file: "src/shared.ts",
|
||||
recentMainCommits: [{ sha: "abc1234", subject: "fix: recent main change" }],
|
||||
},
|
||||
],
|
||||
issueCount: 2,
|
||||
clean: false,
|
||||
});
|
||||
const store = createAuditStore();
|
||||
|
||||
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(/post-squash audit blocked auto-completion/);
|
||||
|
||||
expect(vi.mocked(store.moveTask).mock.calls.some(([, column]) => column === "done")).toBe(false);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-050", { status: null });
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||
"FN-050",
|
||||
expect.stringContaining("post-squash audit blocked auto-completion"),
|
||||
"tool_error",
|
||||
expect.stringContaining("Duplicate-subject risks:\n- feat: duplicate subject\n\nTouched-file overlap risks:\n- src/shared.ts\n - abc1234 fix: recent main change"),
|
||||
"merger",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips the post-squash audit when the squash succeeds without auto-resolution", async () => {
|
||||
setupNoConflictMergeExecSync();
|
||||
const store = createAuditStore();
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
expect(result.merged).toBe(true);
|
||||
expect(mockedAuditSquashMerge).not.toHaveBeenCalled();
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
|
||||
expect(
|
||||
vi.mocked(store.appendAgentLog).mock.calls.some(([, message]) => String(message).includes("post-squash audit clean") || String(message).includes("post-squash audit blocked auto-completion")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("runs the post-squash audit after the attempt 3 -X ours fallback path", async () => {
|
||||
setupAttempt3FallbackMergeExecSync();
|
||||
mockedAuditSquashMerge.mockResolvedValue({
|
||||
squashSha: "mergedcommit123",
|
||||
parentSha: "parent123",
|
||||
squashSubject: "feat: squash merge",
|
||||
lookback: 30,
|
||||
branchSubjects: ["feat: duplicate subject"],
|
||||
recentMainSubjects: ["feat: duplicate subject"],
|
||||
duplicateSubjects: [{ type: "duplicate-subject", subject: "feat: duplicate subject" }],
|
||||
touchedFiles: [],
|
||||
touchedFileOverlaps: [],
|
||||
findings: [{ type: "duplicate-subject", subject: "feat: duplicate subject" }],
|
||||
issueCount: 1,
|
||||
clean: false,
|
||||
});
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockRejectedValue(new Error("Agent failed")),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
const store = createAuditStore({
|
||||
mergeConflictStrategy: "smart-prefer-main",
|
||||
worktreeRebaseBeforeMerge: true,
|
||||
});
|
||||
|
||||
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(/post-squash audit blocked auto-completion/);
|
||||
|
||||
expect(mockedAuditSquashMerge).toHaveBeenCalledWith({
|
||||
rootDir: "/tmp/root",
|
||||
squashSha: "mergedcommit123",
|
||||
});
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-050", { status: null });
|
||||
});
|
||||
|
||||
it("skips the post-squash audit when the squash merge is empty", async () => {
|
||||
setupEmptySquashMergeExecSync();
|
||||
const store = createAuditStore();
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
expect(result.merged).toBe(true);
|
||||
expect(mockedAuditSquashMerge).not.toHaveBeenCalled();
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
|
||||
expect(
|
||||
vi.mocked(store.appendAgentLog).mock.calls.some(([, message]) => String(message).includes("post-squash audit blocked auto-completion")),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -123,4 +123,35 @@ describeIfGit("auditSquashMerge", () => {
|
||||
);
|
||||
expect(findings.issueCount).toBe(1);
|
||||
});
|
||||
|
||||
it("reports combined duplicate-subject and touched-file overlap findings", async () => {
|
||||
const repo = setupRepo();
|
||||
|
||||
write(repo, "shared.txt", "alpha\nbeta\ngamma\n");
|
||||
git(repo, "git add shared.txt && git commit -m 'chore: add shared file'");
|
||||
|
||||
git(repo, "git checkout -b feature/combined");
|
||||
write(repo, "shared.txt", "alpha-branch\nbeta\ngamma\n");
|
||||
git(repo, "git add shared.txt && git commit -m 'feat: duplicate and overlap'");
|
||||
|
||||
git(repo, "git checkout main");
|
||||
write(repo, "shared.txt", "alpha\nbeta\ngamma-main\n");
|
||||
git(repo, "git add shared.txt && git commit -m 'feat: duplicate and overlap'");
|
||||
|
||||
git(repo, "git merge --squash feature/combined");
|
||||
const squashSha = createSquashCommit(repo, ["feat: duplicate and overlap"]);
|
||||
|
||||
const findings = await auditSquashMerge({ rootDir: repo, squashSha, lookback: 10 });
|
||||
|
||||
expect(findings.clean).toBe(false);
|
||||
expect(findings.issueCount).toBe(2);
|
||||
expect(findings.duplicateSubjects).toEqual([
|
||||
{ type: "duplicate-subject", subject: "feat: duplicate and overlap" },
|
||||
]);
|
||||
expect(findings.touchedFileOverlaps).toHaveLength(1);
|
||||
expect(findings.touchedFileOverlaps[0]).toMatchObject({
|
||||
type: "touched-file-overlap",
|
||||
file: "shared.txt",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user