feat(FN-4067): add squash audit gate to merger with integration tests
Adds a squash-merge audit gate that verifies duplicate-cherry-pick commits and file-overlap losses after any squash merge into main, restoring the post-squash audit step to the merge workflow with test coverage for both the audit logic and the broader merger lifecycle. Fusion-Task-Id: FN-4067 Fusion-Task-Lineage: 593aa917-5640-495b-b1ae-80c3eaa09d01
This commit is contained in:
5
.changeset/fn-4067-post-squash-audit.md
Normal file
5
.changeset/fn-4067-post-squash-audit.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Block auto-resolved squash merge completion when the post-squash audit flags duplicate-subject or touched-file overlap risks, while keeping the audit script available for manual follow-up.
|
||||
@@ -219,13 +219,15 @@ Two rules, learned the hard way (FN-2370 silently reverted three commits' work):
|
||||
|
||||
2. **Prefer rebase-and-merge over squash for branches spanning multiple feature commits.** Squash collapses authorship and makes per-commit reverts impossible. Rebase-and-merge preserves the commit boundary so a regression can be reverted cleanly without losing the rest of the branch.
|
||||
|
||||
After any squash that auto-resolved conflicts, the merging agent MUST run:
|
||||
After any squash that auto-resolved conflicts, the merger now runs the post-squash audit as a blocking gate before auto-completing the task. Flagged merges stay in `in-review` for inspection, and only a clean audit proceeds to `done`.
|
||||
|
||||
For manual follow-up, standalone auditing, or post-incident inspection, the script remains available:
|
||||
|
||||
```
|
||||
node scripts/audit-squash-merge.mjs <squash-sha>
|
||||
```
|
||||
|
||||
The agent then reviews each flagged item itself (no human handoff): for every duplicate-cherry-pick subject, diff the matching main commit against HEAD and confirm its net contribution survived; for every touched-file overlap, confirm the recent main commits' changes still appear in HEAD. If anything was silently dropped, restore it as a follow-up commit on the same branch before reporting the merge complete. Only if the audit is clean (or all losses have been restored) is the merge done.
|
||||
Review every flagged item yourself (no human handoff): for every duplicate-cherry-pick subject, diff the matching main commit against HEAD and confirm its net contribution survived; for every touched-file overlap, confirm the recent main commits' changes still appear in HEAD. If anything was silently dropped, restore it as a follow-up commit on the same branch before reporting the merge complete. Only if the audit is clean (or all losses have been restored) is the merge done.
|
||||
|
||||
## Node Dashboard
|
||||
|
||||
|
||||
@@ -663,6 +663,67 @@ describe("TaskStore", () => {
|
||||
expect(respec.recoveryRetryCount).toBeUndefined();
|
||||
expect(respec.nextRecoveryAt).toBeUndefined();
|
||||
});
|
||||
|
||||
// FN-3964 regression: reopen transitions must clear paused state
|
||||
it("clears paused and pausedByAgentId when reopening in-progress+paused task to todo", async () => {
|
||||
const task = await store.createTask({ description: "test reopen clears paused" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
|
||||
// Simulate a paused task (e.g., from a stuck/retry flow)
|
||||
await store.updateTask(task.id, {
|
||||
paused: true,
|
||||
pausedByAgentId: "agent-test-001",
|
||||
status: "failed",
|
||||
error: "Something went wrong",
|
||||
});
|
||||
|
||||
const moved = await store.moveTask(task.id, "todo");
|
||||
expect(moved.column).toBe("todo");
|
||||
expect(moved.paused).toBeUndefined();
|
||||
expect(moved.pausedByAgentId).toBeUndefined();
|
||||
expect(moved.status).toBeUndefined();
|
||||
expect(moved.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears paused and pausedByAgentId when reopening done+paused task to triage", async () => {
|
||||
const task = await store.createTask({ description: "test reopen done to triage clears paused" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
|
||||
// Simulate stale paused state on a done task
|
||||
await store.updateTask(task.id, {
|
||||
paused: true,
|
||||
pausedByAgentId: "agent-test-002",
|
||||
});
|
||||
|
||||
const reopened = await store.moveTask(task.id, "triage");
|
||||
expect(reopened.column).toBe("triage");
|
||||
expect(reopened.paused).toBeUndefined();
|
||||
expect(reopened.pausedByAgentId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears paused when reopening in-review task to todo", async () => {
|
||||
const task = await store.createTask({ description: "test reopen in-review to todo clears paused" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
|
||||
// Simulate paused state during review
|
||||
await store.updateTask(task.id, {
|
||||
paused: true,
|
||||
pausedByAgentId: "agent-test-003",
|
||||
status: "failed",
|
||||
error: "Review failed",
|
||||
});
|
||||
|
||||
const retried = await store.moveTask(task.id, "todo");
|
||||
expect(retried.column).toBe("todo");
|
||||
expect(retried.paused).toBeUndefined();
|
||||
expect(retried.pausedByAgentId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ describe("TaskExecutor enginePaused soft pause (no agent termination)", () => {
|
||||
"Task marked complete with summary. All steps done. Moving to in-review.",
|
||||
);
|
||||
expect(watchdogSpy).toHaveBeenCalledWith("FN-001", "fn_task_done");
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { paused: false, status: null });
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: null });
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||||
});
|
||||
|
||||
|
||||
@@ -121,6 +121,23 @@ vi.mock("../context-limit-detector.js", () => ({
|
||||
isContextLimitError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../merger-squash-audit.js", () => ({
|
||||
auditSquashMerge: vi.fn(async () => ({
|
||||
squashSha: "mergedcommit123",
|
||||
parentSha: "parent123",
|
||||
squashSubject: "feat: squash merge",
|
||||
lookback: 30,
|
||||
branchSubjects: [],
|
||||
recentMainSubjects: [],
|
||||
duplicateSubjects: [],
|
||||
touchedFiles: [],
|
||||
touchedFileOverlaps: [],
|
||||
findings: [],
|
||||
issueCount: 0,
|
||||
clean: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
import {
|
||||
aiMergeTask,
|
||||
pushToRemoteAfterMerge,
|
||||
@@ -149,11 +166,13 @@ import {
|
||||
} from "../merger.js";
|
||||
import { mergerLog } from "../logger.js";
|
||||
import { createFnAgent } from "../pi.js";
|
||||
import { auditSquashMerge } from "../merger-squash-audit.js";
|
||||
import { execSync, exec } from "node:child_process";
|
||||
import * as core from "@fusion/core";
|
||||
import { type TaskStore, type Task, type MergeResult, DEFAULT_SETTINGS } from "@fusion/core";
|
||||
|
||||
const mockedCreateFnAgent = vi.mocked(createFnAgent);
|
||||
const mockedAuditSquashMerge = vi.mocked(auditSquashMerge);
|
||||
const mockedExecSync = vi.mocked(execSync);
|
||||
const mockedExec = vi.mocked(exec);
|
||||
const { existsSync: mockedExistsSyncRaw, readFileSync: mockedReadFileSyncRaw } = await import("node:fs");
|
||||
@@ -2116,4 +2135,167 @@ describe("aiMergeTask — reset cleanup failure diagnostics", () => {
|
||||
|
||||
// ── New Smart Conflict Resolution API Tests ────────────────────────────
|
||||
|
||||
describe("aiMergeTask post-squash audit gate", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
mockedAuditSquashMerge.mockResolvedValue({
|
||||
squashSha: "mergedcommit123",
|
||||
parentSha: "parent123",
|
||||
squashSubject: "feat: squash merge",
|
||||
lookback: 30,
|
||||
branchSubjects: [],
|
||||
recentMainSubjects: [],
|
||||
duplicateSubjects: [],
|
||||
touchedFiles: [],
|
||||
touchedFileOverlaps: [],
|
||||
findings: [],
|
||||
issueCount: 0,
|
||||
clean: true,
|
||||
});
|
||||
});
|
||||
|
||||
function setupAutoResolvedMergeExecSync() {
|
||||
let squashCalls = 0;
|
||||
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")) {
|
||||
squashCalls += 1;
|
||||
if (squashCalls === 2) {
|
||||
const error = new Error("merge conflict");
|
||||
(error as Error & { stdout?: string; stderr?: string }).stdout = "";
|
||||
(error as Error & { stdout?: string; stderr?: string }).stderr = "CONFLICT";
|
||||
throw error;
|
||||
}
|
||||
return Buffer.from("");
|
||||
}
|
||||
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "pnpm-lock.yaml\n" as any;
|
||||
if (cmdStr.includes("checkout --ours --") || cmdStr.includes("git add -- pnpm-lock.yaml")) return Buffer.from("");
|
||||
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")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
}
|
||||
|
||||
function createAuditStore() {
|
||||
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],
|
||||
);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
testCommand: "pnpm test",
|
||||
mergeConflictStrategy: "ai-only",
|
||||
worktreeRebaseBeforeMerge: false,
|
||||
worktreeRebaseLocalBase: false,
|
||||
});
|
||||
return store;
|
||||
}
|
||||
|
||||
it("moves the task to done when the post-squash audit is clean", async () => {
|
||||
setupAutoResolvedMergeExecSync();
|
||||
const store = createAuditStore();
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
expect(result.merged).toBe(true);
|
||||
expect(mockedAuditSquashMerge).toHaveBeenCalledWith({
|
||||
rootDir: "/tmp/root",
|
||||
squashSha: "mergedcommit123",
|
||||
});
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-050", "post-squash audit clean", "text", undefined, "merger");
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
|
||||
});
|
||||
|
||||
it("blocks completion and logs duplicate-subject findings", async () => {
|
||||
setupAutoResolvedMergeExecSync();
|
||||
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: ["src/example.ts"],
|
||||
touchedFileOverlaps: [],
|
||||
findings: [{ type: "duplicate-subject", subject: "feat: duplicate subject" }],
|
||||
issueCount: 1,
|
||||
clean: false,
|
||||
});
|
||||
const store = createAuditStore();
|
||||
|
||||
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
|
||||
"FN-050: post-squash audit blocked auto-completion for mergedco",
|
||||
);
|
||||
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
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"),
|
||||
"merger",
|
||||
);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-050", { status: null });
|
||||
});
|
||||
|
||||
it("blocks completion and logs touched-file-overlap findings", async () => {
|
||||
setupAutoResolvedMergeExecSync();
|
||||
mockedAuditSquashMerge.mockResolvedValue({
|
||||
squashSha: "mergedcommit123",
|
||||
parentSha: "parent123",
|
||||
squashSubject: "feat: squash merge",
|
||||
lookback: 30,
|
||||
branchSubjects: ["feat: branch change"],
|
||||
recentMainSubjects: [],
|
||||
duplicateSubjects: [],
|
||||
touchedFiles: ["src/shared.ts"],
|
||||
touchedFileOverlaps: [{
|
||||
type: "touched-file-overlap",
|
||||
file: "src/shared.ts",
|
||||
recentMainCommits: [{ sha: "abc1234", subject: "fix: recent main change" }],
|
||||
}],
|
||||
findings: [{
|
||||
type: "touched-file-overlap",
|
||||
file: "src/shared.ts",
|
||||
recentMainCommits: [{ sha: "abc1234", subject: "fix: recent main change" }],
|
||||
}],
|
||||
issueCount: 1,
|
||||
clean: false,
|
||||
});
|
||||
const store = createAuditStore();
|
||||
|
||||
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
|
||||
"FN-050: post-squash audit blocked auto-completion for mergedco",
|
||||
);
|
||||
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||
"FN-050",
|
||||
expect.stringContaining("post-squash audit blocked auto-completion"),
|
||||
"tool_error",
|
||||
expect.stringContaining("Touched-file overlap risks:\n- src/shared.ts\n - abc1234 fix: recent main change"),
|
||||
"merger",
|
||||
);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-050", { status: null });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
126
packages/engine/src/__tests__/merger-squash-audit.test.ts
Normal file
126
packages/engine/src/__tests__/merger-squash-audit.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { execSync, spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { auditSquashMerge } from "../merger-squash-audit.js";
|
||||
|
||||
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
|
||||
const describeIfGit = hasGit ? describe : describe.skip;
|
||||
|
||||
function git(repo: string, command: string): string {
|
||||
return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
||||
}
|
||||
|
||||
function write(repo: string, relativePath: string, content: string): void {
|
||||
writeFileSync(join(repo, relativePath), content, "utf-8");
|
||||
}
|
||||
|
||||
describeIfGit("auditSquashMerge", () => {
|
||||
const repos: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const repo of repos.splice(0)) {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function setupRepo(): string {
|
||||
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-squash-audit-"));
|
||||
repos.push(repo);
|
||||
git(repo, "git init -b main");
|
||||
git(repo, 'git config user.email "test@example.com"');
|
||||
git(repo, 'git config user.name "Test User"');
|
||||
write(repo, "README.md", "init\n");
|
||||
git(repo, "git add README.md && git commit -m 'init'");
|
||||
return repo;
|
||||
}
|
||||
|
||||
function createSquashCommit(repo: string, bodyLines: string[]): string {
|
||||
const body = bodyLines.map((line) => `- ${line}`).join("\n");
|
||||
git(repo, `git commit -m ${JSON.stringify("feat: squash merge")} -m ${JSON.stringify(body)}`);
|
||||
return git(repo, "git rev-parse HEAD");
|
||||
}
|
||||
|
||||
it("returns a clean result when no duplicate subjects or recent-main file overlaps exist", async () => {
|
||||
const repo = setupRepo();
|
||||
|
||||
git(repo, "git checkout -b feature/clean");
|
||||
write(repo, "feature.txt", "branch-only\n");
|
||||
git(repo, "git add feature.txt && git commit -m 'feat: branch clean change'");
|
||||
|
||||
git(repo, "git checkout main");
|
||||
write(repo, "main-only.txt", "recent main\n");
|
||||
git(repo, "git add main-only.txt && git commit -m 'chore: recent main touch'");
|
||||
|
||||
git(repo, "git merge --squash feature/clean");
|
||||
const squashSha = createSquashCommit(repo, ["feat: branch clean change"]);
|
||||
|
||||
const findings = await auditSquashMerge({ rootDir: repo, squashSha, lookback: 10 });
|
||||
|
||||
expect(findings.clean).toBe(true);
|
||||
expect(findings.issueCount).toBe(0);
|
||||
expect(findings.duplicateSubjects).toEqual([]);
|
||||
expect(findings.touchedFileOverlaps).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports duplicate branch subjects that match recent main commits", async () => {
|
||||
const repo = setupRepo();
|
||||
|
||||
git(repo, "git checkout -b feature/dupe");
|
||||
write(repo, "branch.txt", "feature\n");
|
||||
git(repo, "git add branch.txt && git commit -m 'feat: duplicate subject'");
|
||||
|
||||
git(repo, "git checkout main");
|
||||
write(repo, "main.txt", "main\n");
|
||||
git(repo, "git add main.txt && git commit -m 'feat: duplicate subject'");
|
||||
|
||||
git(repo, "git merge --squash feature/dupe");
|
||||
const squashSha = createSquashCommit(repo, ["feat: duplicate subject"]);
|
||||
|
||||
const findings = await auditSquashMerge({ rootDir: repo, squashSha, lookback: 10 });
|
||||
|
||||
expect(findings.clean).toBe(false);
|
||||
expect(findings.duplicateSubjects).toEqual([
|
||||
{ type: "duplicate-subject", subject: "feat: duplicate subject" },
|
||||
]);
|
||||
expect(findings.issueCount).toBe(1);
|
||||
});
|
||||
|
||||
it("reports touched-file overlaps with recent main commits", 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/overlap");
|
||||
write(repo, "shared.txt", "alpha-branch\nbeta\ngamma\n");
|
||||
git(repo, "git add shared.txt && git commit -m 'feat: branch edits shared file'");
|
||||
|
||||
git(repo, "git checkout main");
|
||||
write(repo, "shared.txt", "alpha\nbeta\ngamma-main\n");
|
||||
git(repo, "git add shared.txt && git commit -m 'fix: main edits shared file'");
|
||||
|
||||
git(repo, "git merge --squash feature/overlap");
|
||||
const squashSha = createSquashCommit(repo, ["feat: branch edits shared file"]);
|
||||
|
||||
const findings = await auditSquashMerge({ rootDir: repo, squashSha, lookback: 10 });
|
||||
|
||||
expect(findings.clean).toBe(false);
|
||||
expect(findings.duplicateSubjects).toEqual([]);
|
||||
expect(findings.touchedFileOverlaps).toHaveLength(1);
|
||||
expect(findings.touchedFileOverlaps[0]).toMatchObject({
|
||||
type: "touched-file-overlap",
|
||||
file: "shared.txt",
|
||||
});
|
||||
expect(findings.touchedFileOverlaps[0].recentMainCommits).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
sha: expect.any(String),
|
||||
subject: "fix: main edits shared file",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(findings.issueCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,15 @@ export {
|
||||
type MergerOptions,
|
||||
type AutostashOrphanRecord,
|
||||
} from "./merger.js";
|
||||
export {
|
||||
auditSquashMerge,
|
||||
formatSquashAuditReport,
|
||||
type SquashAuditFindings,
|
||||
type SquashAuditFinding,
|
||||
type SquashAuditDuplicateSubjectFinding,
|
||||
type SquashAuditTouchedFileOverlapFinding,
|
||||
type SquashAuditRecentMainCommit,
|
||||
} from "./merger-squash-audit.js";
|
||||
export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./reviewer.js";
|
||||
export { createFnAgent, promptWithFallback, describeModel, setHostExtensionPaths, getHostExtensionPaths, type AgentOptions, type AgentResult } from "./pi.js";
|
||||
|
||||
|
||||
187
packages/engine/src/merger-squash-audit.ts
Normal file
187
packages/engine/src/merger-squash-audit.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const DEFAULT_LOOKBACK = 30;
|
||||
const GIT_OUTPUT_MAX_BUFFER = 10 * 1024 * 1024;
|
||||
|
||||
export interface SquashAuditRecentMainCommit {
|
||||
sha: string;
|
||||
subject: string;
|
||||
}
|
||||
|
||||
export interface SquashAuditDuplicateSubjectFinding {
|
||||
type: "duplicate-subject";
|
||||
subject: string;
|
||||
}
|
||||
|
||||
export interface SquashAuditTouchedFileOverlapFinding {
|
||||
type: "touched-file-overlap";
|
||||
file: string;
|
||||
recentMainCommits: SquashAuditRecentMainCommit[];
|
||||
}
|
||||
|
||||
export type SquashAuditFinding = SquashAuditDuplicateSubjectFinding | SquashAuditTouchedFileOverlapFinding;
|
||||
|
||||
export interface SquashAuditFindings {
|
||||
squashSha: string;
|
||||
parentSha: string;
|
||||
squashSubject: string;
|
||||
lookback: number;
|
||||
branchSubjects: string[];
|
||||
recentMainSubjects: string[];
|
||||
duplicateSubjects: SquashAuditDuplicateSubjectFinding[];
|
||||
touchedFiles: string[];
|
||||
touchedFileOverlaps: SquashAuditTouchedFileOverlapFinding[];
|
||||
findings: SquashAuditFinding[];
|
||||
issueCount: number;
|
||||
clean: boolean;
|
||||
}
|
||||
|
||||
export async function auditSquashMerge({
|
||||
rootDir,
|
||||
squashSha,
|
||||
lookback = DEFAULT_LOOKBACK,
|
||||
}: {
|
||||
rootDir: string;
|
||||
squashSha: string;
|
||||
lookback?: number;
|
||||
}): Promise<SquashAuditFindings> {
|
||||
const normalizedLookback = normalizeLookback(lookback);
|
||||
const parentSha = await git(rootDir, ["rev-parse", `${squashSha}^`]);
|
||||
const squashSubject = await git(rootDir, ["log", "-1", "--format=%s", squashSha]);
|
||||
const branchSubjects = normalizeLines(await git(rootDir, ["log", "-1", "--format=%b", squashSha]))
|
||||
.map((line) => line.replace(/^- /, "").trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const recentMainCommits = await listRecentMainCommits(rootDir, parentSha, normalizedLookback);
|
||||
const recentMainSubjects = recentMainCommits.map((entry) => entry.subject);
|
||||
|
||||
const duplicateSubjects = branchSubjects
|
||||
.filter((subject) => recentMainSubjects.includes(subject))
|
||||
.map((subject) => ({ type: "duplicate-subject", subject }) satisfies SquashAuditDuplicateSubjectFinding);
|
||||
|
||||
const touchedFiles = normalizeLines(await git(rootDir, ["diff", "--name-only", parentSha, squashSha]));
|
||||
const touchedFileOverlaps: SquashAuditTouchedFileOverlapFinding[] = [];
|
||||
|
||||
for (const file of touchedFiles) {
|
||||
const overlappingCommits: SquashAuditRecentMainCommit[] = [];
|
||||
for (const commit of recentMainCommits) {
|
||||
const touchedInCommit = await git(rootDir, ["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha, "--", file]);
|
||||
if (normalizeLines(touchedInCommit).includes(file)) {
|
||||
overlappingCommits.push({ sha: commit.shortSha, subject: commit.subject });
|
||||
}
|
||||
}
|
||||
|
||||
if (overlappingCommits.length > 0) {
|
||||
touchedFileOverlaps.push({
|
||||
type: "touched-file-overlap",
|
||||
file,
|
||||
recentMainCommits: overlappingCommits,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const findings: SquashAuditFinding[] = [...duplicateSubjects, ...touchedFileOverlaps];
|
||||
|
||||
return {
|
||||
squashSha,
|
||||
parentSha,
|
||||
squashSubject,
|
||||
lookback: normalizedLookback,
|
||||
branchSubjects,
|
||||
recentMainSubjects,
|
||||
duplicateSubjects,
|
||||
touchedFiles,
|
||||
touchedFileOverlaps,
|
||||
findings,
|
||||
issueCount: findings.length,
|
||||
clean: findings.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatSquashAuditReport(findings: SquashAuditFindings): string {
|
||||
const lines: string[] = [
|
||||
`Auditing squash: ${findings.squashSha} — ${findings.squashSubject}`,
|
||||
`Parent (main before squash): ${findings.parentSha}`,
|
||||
`Lookback window on main: ${findings.lookback} commits`,
|
||||
"",
|
||||
"=== Duplicate-cherry-pick risk ===",
|
||||
];
|
||||
|
||||
if (findings.duplicateSubjects.length === 0) {
|
||||
lines.push("(none — no branch commit subjects match recent main commits)", "");
|
||||
} else {
|
||||
lines.push(
|
||||
"WARN: branch contains commits whose subjects match recent main commits.",
|
||||
"Auto-resolve may have picked the older side, dropping refinements.",
|
||||
"Action: diff each main commit below against HEAD and confirm its",
|
||||
"net contribution survived. Restore anything dropped as a follow-up.",
|
||||
"",
|
||||
...findings.duplicateSubjects.map((entry) => ` - ${entry.subject}`),
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(`=== Touched-file overlap (${findings.touchedFiles.length} files in squash) ===`);
|
||||
if (findings.touchedFileOverlaps.length === 0) {
|
||||
lines.push("(none — squash touches files no recent main commit touched)", "");
|
||||
} else {
|
||||
lines.push(
|
||||
"Files the squash touched that also have recent main activity.",
|
||||
"Action: for each commit below, verify its changes still appear",
|
||||
"in HEAD. Reapply any silently dropped changes on the same branch.",
|
||||
"",
|
||||
);
|
||||
for (const overlap of findings.touchedFileOverlaps) {
|
||||
lines.push(` ${overlap.file}`);
|
||||
for (const commit of overlap.recentMainCommits) {
|
||||
lines.push(` - ${commit.sha} ${commit.subject}`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push(`Audit complete. ${findings.issueCount} item(s) for the calling agent to review.`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function git(rootDir: string, args: string[]): Promise<string> {
|
||||
const { stdout } = await execFileAsync("git", args, {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
maxBuffer: GIT_OUTPUT_MAX_BUFFER,
|
||||
});
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
function normalizeLines(value: string): string[] {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return [];
|
||||
return trimmed.split("\n").map((line) => line.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
async function listRecentMainCommits(rootDir: string, parentSha: string, lookback: number): Promise<Array<{ sha: string; shortSha: string; subject: string }>> {
|
||||
const entries = normalizeLines(await git(rootDir, ["log", `--format=%H~%h~%s`, `-n`, String(lookback), parentSha]));
|
||||
return entries
|
||||
.map((entry) => {
|
||||
const [sha, shortSha, ...subjectParts] = entry.split("~");
|
||||
const subject = subjectParts.join("~").trim();
|
||||
if (!sha?.trim() || !shortSha?.trim() || !subject) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
sha: sha.trim(),
|
||||
shortSha: shortSha.trim(),
|
||||
subject,
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is { sha: string; shortSha: string; subject: string } => entry !== null);
|
||||
}
|
||||
|
||||
function normalizeLookback(value: number | undefined): number {
|
||||
if (!Number.isFinite(value) || !value || value < 1) {
|
||||
return DEFAULT_LOOKBACK;
|
||||
}
|
||||
return Math.trunc(value);
|
||||
}
|
||||
@@ -70,6 +70,7 @@ import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import { createRunAuditor, generateSyntheticRunId, type EngineRunContext } from "./run-audit.js";
|
||||
import { createWebFetchTool } from "./agent-tools.js";
|
||||
import { auditSquashMerge, type SquashAuditFindings } from "./merger-squash-audit.js";
|
||||
|
||||
/** Conflict type classification for merge conflict resolution */
|
||||
export type ConflictType =
|
||||
@@ -475,6 +476,17 @@ export class MergeAbortedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class SquashAuditError extends Error {
|
||||
constructor(
|
||||
taskId: string,
|
||||
public readonly squashSha: string,
|
||||
public readonly findings: SquashAuditFindings,
|
||||
) {
|
||||
super(buildSquashAuditBlockingMessage(taskId, squashSha, findings));
|
||||
this.name = "SquashAuditError";
|
||||
}
|
||||
}
|
||||
|
||||
export function throwIfAborted(signal: AbortSignal | undefined, taskId: string): void {
|
||||
if (!signal?.aborted) return;
|
||||
throw new MergeAbortedError(`Merge aborted for ${taskId}: engine shutdown requested`);
|
||||
@@ -4143,6 +4155,46 @@ function quoteArg(value: string): string {
|
||||
return `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
|
||||
}
|
||||
|
||||
function shouldRunPostSquashAudit(result: MergeResult, mergeWasEmpty: boolean, isEmptyCommit: boolean, commitSha?: string): boolean {
|
||||
if (mergeWasEmpty || isEmptyCommit || !commitSha) {
|
||||
return false;
|
||||
}
|
||||
return (result.autoResolvedCount ?? 0) > 0 || result.attemptsMade === 3;
|
||||
}
|
||||
|
||||
function buildSquashAuditBlockingMessage(taskId: string, squashSha: string, findings: SquashAuditFindings): string {
|
||||
const riskParts: string[] = [];
|
||||
if (findings.duplicateSubjects.length > 0) {
|
||||
riskParts.push(`${findings.duplicateSubjects.length} duplicate-subject risk${findings.duplicateSubjects.length === 1 ? "" : "s"}`);
|
||||
}
|
||||
if (findings.touchedFileOverlaps.length > 0) {
|
||||
riskParts.push(`${findings.touchedFileOverlaps.length} touched-file overlap risk${findings.touchedFileOverlaps.length === 1 ? "" : "s"}`);
|
||||
}
|
||||
const summary = riskParts.length > 0 ? riskParts.join(", ") : `${findings.issueCount} audit finding(s)`;
|
||||
return `${taskId}: post-squash audit blocked auto-completion for ${squashSha.slice(0, 8)} (${summary})`;
|
||||
}
|
||||
|
||||
function formatSquashAuditAgentLog(findings: SquashAuditFindings): string {
|
||||
const lines: string[] = [];
|
||||
if (findings.duplicateSubjects.length > 0) {
|
||||
lines.push("Duplicate-subject risks:");
|
||||
for (const duplicate of findings.duplicateSubjects) {
|
||||
lines.push(`- ${duplicate.subject}`);
|
||||
}
|
||||
}
|
||||
if (findings.touchedFileOverlaps.length > 0) {
|
||||
if (lines.length > 0) lines.push("");
|
||||
lines.push("Touched-file overlap risks:");
|
||||
for (const overlap of findings.touchedFileOverlaps) {
|
||||
lines.push(`- ${overlap.file}`);
|
||||
for (const commit of overlap.recentMainCommits) {
|
||||
lines.push(` - ${commit.sha} ${commit.subject}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a non-empty commit body for fallback merge commits. Used by sites
|
||||
* that would otherwise emit `-m ""` when the branch's commit log is empty
|
||||
@@ -6065,6 +6117,27 @@ export async function aiMergeTask(
|
||||
// attemptWithSideStrategy return true without committing when nothing
|
||||
// was staged. The recorded HEAD then has nothing to do with this task.
|
||||
const recordedSha = (isEmptyCommit || mergeWasEmpty) ? undefined : commitSha;
|
||||
|
||||
const auditSha = recordedSha;
|
||||
if (auditSha && shouldRunPostSquashAudit(result, mergeWasEmpty, isEmptyCommit, auditSha)) {
|
||||
const auditFindings = await auditSquashMerge({
|
||||
rootDir,
|
||||
squashSha: auditSha,
|
||||
});
|
||||
if (!auditFindings.clean) {
|
||||
const auditError = new SquashAuditError(taskId, auditSha, auditFindings);
|
||||
await store.appendAgentLog(
|
||||
taskId,
|
||||
auditError.message,
|
||||
"tool_error",
|
||||
formatSquashAuditAgentLog(auditFindings),
|
||||
"merger",
|
||||
);
|
||||
await store.updateTask(taskId, { status: null });
|
||||
throw auditError;
|
||||
}
|
||||
await store.appendAgentLog(taskId, "post-squash audit clean", "text", undefined, "merger");
|
||||
}
|
||||
if (isEmptyCommit) {
|
||||
mergerLog.warn(
|
||||
`${taskId}: local squash produced an empty commit (${commitSha?.slice(0, 8)}) — branch likely contained dupes of main. Skipping commitSha; recovery will backfill when real commit lands.`,
|
||||
@@ -6138,6 +6211,9 @@ export async function aiMergeTask(
|
||||
"merger",
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (err instanceof SquashAuditError || err?.name === "SquashAuditError") {
|
||||
throw err;
|
||||
}
|
||||
mergerLog.warn(`${taskId}: failed to collect/store merge details: ${err.message}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
// Example:
|
||||
// node scripts/audit-squash-merge.mjs 7c1a1c36c
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { tsImport } from "tsx/esm/api";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const squashSha = args.find((a) => !a.startsWith("--"));
|
||||
@@ -36,74 +36,18 @@ if (!squashSha) {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const sh = (cmd) => execSync(cmd, { encoding: "utf8" }).trim();
|
||||
const moduleUrl = new globalThis.URL("../packages/engine/src/merger-squash-audit.ts", import.meta.url).href;
|
||||
const { auditSquashMerge, formatSquashAuditReport } = await tsImport(moduleUrl, import.meta.url);
|
||||
|
||||
const parent = sh(`git rev-parse ${squashSha}^`);
|
||||
const subject = sh(`git log -1 --format=%s ${squashSha}`);
|
||||
const branchSubjects = sh(`git log -1 --format=%b ${squashSha}`)
|
||||
.split("\n")
|
||||
.map((l) => l.replace(/^- /, "").trim())
|
||||
.filter(Boolean);
|
||||
|
||||
console.log(`Auditing squash: ${squashSha} — ${subject}`);
|
||||
console.log(`Parent (main before squash): ${parent}`);
|
||||
console.log(`Lookback window on main: ${lookback} commits\n`);
|
||||
|
||||
// --- 1. Duplicate-cherry-pick detection ---
|
||||
const recentMainSubjects = sh(
|
||||
`git log --format=%s ${parent}~${lookback}..${parent}`,
|
||||
).split("\n");
|
||||
|
||||
const dupes = branchSubjects.filter((s) => recentMainSubjects.includes(s));
|
||||
|
||||
console.log("=== Duplicate-cherry-pick risk ===");
|
||||
if (dupes.length === 0) {
|
||||
console.log("(none — no branch commit subjects match recent main commits)\n");
|
||||
} else {
|
||||
console.log("WARN: branch contains commits whose subjects match recent main commits.");
|
||||
console.log("Auto-resolve may have picked the older side, dropping refinements.");
|
||||
console.log("Action: diff each main commit below against HEAD and confirm its");
|
||||
console.log("net contribution survived. Restore anything dropped as a follow-up.\n");
|
||||
for (const s of dupes) {
|
||||
console.log(` - ${s}`);
|
||||
}
|
||||
console.log();
|
||||
try {
|
||||
const findings = await auditSquashMerge({
|
||||
rootDir: process.cwd(),
|
||||
squashSha,
|
||||
lookback,
|
||||
});
|
||||
console.log(formatSquashAuditReport(findings));
|
||||
process.exit(findings.clean ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- 2. Touched-file overlap ---
|
||||
const touched = sh(`git diff --name-only ${parent} ${squashSha}`)
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
|
||||
console.log(`=== Touched-file overlap (${touched.length} files in squash) ===`);
|
||||
const overlaps = [];
|
||||
for (const file of touched) {
|
||||
const recent = sh(
|
||||
`git log --format=%h~%s ${parent}~${lookback}..${parent} -- ${JSON.stringify(file)}`,
|
||||
)
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
if (recent.length > 0) {
|
||||
overlaps.push({ file, recent });
|
||||
}
|
||||
}
|
||||
|
||||
if (overlaps.length === 0) {
|
||||
console.log("(none — squash touches files no recent main commit touched)\n");
|
||||
} else {
|
||||
console.log("Files the squash touched that also have recent main activity.");
|
||||
console.log("Action: for each commit below, verify its changes still appear");
|
||||
console.log("in HEAD. Reapply any silently dropped changes on the same branch.\n");
|
||||
for (const { file, recent } of overlaps) {
|
||||
console.log(` ${file}`);
|
||||
for (const entry of recent) {
|
||||
const [sha, ...subj] = entry.split("~");
|
||||
console.log(` - ${sha} ${subj.join("~")}`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
const issues = dupes.length + overlaps.length;
|
||||
console.log(`Audit complete. ${issues} item(s) for the calling agent to review.`);
|
||||
process.exit(issues === 0 ? 0 : 1);
|
||||
|
||||
Reference in New Issue
Block a user