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:
Fusion
2026-05-12 05:22:14 -07:00
committed by gsxdsm
parent 50c8ab35d7
commit fc863f6e96
10 changed files with 665 additions and 73 deletions

View 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.

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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