diff --git a/.changeset/remove-diff-volume-gate.md b/.changeset/remove-diff-volume-gate.md new file mode 100644 index 0000000000..d9863b978c --- /dev/null +++ b/.changeset/remove-diff-volume-gate.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Remove the pre-commit diff-volume merge gate; approved squashes are no longer blocked on per-file shrinkage. +category: feature +dev: Deletes `checkDiffVolume`/`DiffVolumeRegressionError`, the `merge:diff-volume-blocked` audit event, and the `mergeDiffVolumeMinLines`/`mergeDiffVolumeThreshold`/`mergeDiffVolumeAllowlist` settings. File scope remains the pre-land guard; the post-squash audit policy remains the shrinkage backstop. diff --git a/AGENTS.md b/AGENTS.md index 846e4f7009..dffd5fd891 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -250,10 +250,9 @@ Use `superviseSpawn(...)` from `@fusion/core` for managed child processes; do no 4. **Already-on-main classifier applies.** Allow finalize/self-healing recovery when lineage is landed. 5. **Contamination auto-recovery is bounded.** First pass can auto-drop upstream foreign commits; repeated/ambiguous cases escalate. 6. **Run post-squash audit policy.** Respect `postMergeAuditMode` (`warn`/`block`/`off`) and auto-recovery stages. -7. **Enforce pre-commit diff-volume gate.** Block suspicious shrinkage before squash commit. -8. **Smart-prefer-main overlap guard.** Recent overlapping main commits can flip to prefer-branch. -9. **Layer-3 scope partition.** Out-of-scope conflicts resolve to main before AI arbitration unless `task.scopeOverride=true`. -10. **Legacy auto-prerebase is inert.** It belonged to the soft-deprecated `aiMergeTask` pipeline; unified `runAiMerge` does not use it. +7. **Smart-prefer-main overlap guard.** Recent overlapping main commits can flip to prefer-branch. +8. **Layer-3 scope partition.** Out-of-scope conflicts resolve to main before AI arbitration unless `task.scopeOverride=true`. +9. **Legacy auto-prerebase is inert.** It belonged to the soft-deprecated `aiMergeTask` pipeline; unified `runAiMerge` does not use it. ### Gitignored-path guard on squash merges diff --git a/docs/architecture.md b/docs/architecture.md index 3a76c80126..f2f8f7345c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2361,7 +2361,7 @@ Reliability-layer changes are in scope. Interaction regression backstops live in - FN-5256 backstop: `packages/engine/src/__tests__/reliability-interactions/dependency-cycle-reconcile.test.ts` covers persisted dependency-cycle detection via `reconcileDependencyCycles`, bounded umbrella-back-edge auto-repair, ambiguous-cycle observe-only behavior, composition ordering with `reconcileSelfDefeatingDependencies`, and the post-sweep write-time guard invariant. Core write-boundary regressions (FN-5240/5241/5242 signature, indirect cycle, umbrella back-edge rejection) live in `packages/core/src/__tests__/store-dependency-cycle.test.ts`. - FN-5223 backstop: `packages/engine/src/__tests__/reliability-interactions/engine-active-since-floor.test.ts` covers engine-activation floor + grace composition across startup, pause/unpause, global-pause gating, and StuckTaskDetector lifecycle interactions. -**Unified squash gates (FN-9050).** `landOneRepo` validates every approved clean-room squash before advancing an integration ref, for both single-repository lands and each workspace sub-repository land. Workspace checks use that repository's File Scope subset; a violation leaves the integration ref unchanged. The diff-volume guard emits `merge:diff-volume-blocked` with task/repository IDs, commit IDs, finding count, and paths only. +**Unified squash gates (FN-9050).** `landOneRepo` validates every approved clean-room squash before advancing an integration ref, for both single-repository lands and each workspace sub-repository land. Workspace checks use that repository's File Scope subset; a violation leaves the integration ref unchanged. (The former diff-volume shrinkage guard and its `merge:diff-volume-blocked` audit event were removed by operator decision on 2026-08-16 — it blocked approved squashes.) The auto-recovery dispatcher at `packages/engine/src/auto-recovery.ts` (FN-4533) composes on top of existing layers (FN-4500 fast-path, FN-4508 deterministic branch-conflict, FN-4499 bootstrap-misbinding, FN-4428 contamination, `mergeAuditAutoRecovery` Stages 1–5, self-healing) to handle six residual classes: file-scope violation at squash, branch misbinding / ghost worktree, verification-fix scope leak, contamination, `branch-conflict-unrecoverable` residuals, and room-post/message-send failures. Invocation is additive — no existing layer's behavior changes. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index c31adad8bd..4d43ae2254 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -520,9 +520,6 @@ Security-sensitive file-browser escape hatches are project-only. `allowAbsoluteF | `worktreeRebaseRemote` | `string` | `""` | Optional remote-name override for worktree rebase and integration-remote resolution; blank auto-resolves a remote. See [Worktree and pre-merge rebase settings](#worktree-and-pre-merge-rebase-settings). | | `worktreeRebaseLocalBase` | `boolean` | `true` | Enables the legacy local-base Stage 2 rebase only; that pipeline has no production caller at HEAD. See [Worktree and pre-merge rebase settings](#worktree-and-pre-merge-rebase-settings). | | `mergeConflictStrategy` | `"smart-prefer-main" \| "smart-prefer-branch" \| "ai-only" \| "abort"` | `"smart-prefer-main"` | Controls the merger's conflict-resolution cascade. `smart-prefer-main` fast-forwards local main from `origin` when possible, then tries AI resolution, then auto-resolve heuristics, then a final `-X ours` fallback that prefers main unless the overlap guard below says otherwise. `smart-prefer-branch` uses the same cascade but ends with `-X theirs` so the task branch wins. `ai-only` never silently picks a side, and `abort` stops after the first AI attempt. Legacy `smart` / `prefer-main` values are normalized automatically. | -| `mergeDiffVolumeMinLines` | `number` | `20` | Minimum branch-net line volume before Fusion compares a file's staged squash delta against the branch's net delta. Applied at merge time and clamped to `>= 1`. | -| `mergeDiffVolumeThreshold` | `number` | `0.2` | Minimum staged-to-branch-net ratio allowed for a non-allowlisted file during auto-resolved squash finalization. Applied at merge time and clamped to `0..1`. | -| `mergeDiffVolumeAllowlist` | `string[]` | `[]` | Additional glob patterns skipped by the pre-commit diff-volume gate, beyond the built-in generated-file and lockfile allowlists. | | `mergeStrategyOverlapBehavior` | `"flip-to-prefer-branch" \| "warn-only" \| "ignore"` | `"flip-to-prefer-branch"` | Safety control for `mergeConflictStrategy="smart-prefer-main"`. Before the Attempt 3 `-X ours` fallback, Fusion checks whether the task branch and recent `main` history overlap on the same files (30-commit lookback, matching the squash audit heuristics). `flip-to-prefer-branch` makes overlapping files prefer the task branch so hardening is not silently discarded (the FN-3936 class of regression). `warn-only` logs the overlap but keeps the legacy main-wins fallback. `ignore` disables the overlap guard and preserves legacy behavior exactly. | | `postMergeAuditMode` | `"block" \| "warn" \| "off"` | `"warn"` | Controls the post-merge audit gate. **Warn** (default) logs findings and continues to auto-complete merges. **Block** is the stricter opt-in mode: it refuses auto-completion on duplicate-subject or touched-file overlap findings when you want maximum FN-3936-class drop protection. **Off** skips the audit entirely. Regardless of mode, rebase-strategy overlap-only findings are auto-cleared when deterministic merge verification has already proven the tree (FN-4333). | | `mergeAuditAutoRecovery` | `"deterministic-only" \| "programmatic" \| "ai-assisted" \| "off"` | `"ai-assisted"` | Controls how the engine recovers when the post-merge audit finds risks. **Deterministic only** keeps just the verified-rebase short-circuit. **Programmatic** also diffs each flagged main commit against HEAD and passes when every contribution survives. **AI-assisted** additionally lets the merger write a single restoration commit when programmatic checks find real drops, and bounces the task back to in-progress before parking. **Off** disables all recovery — failed audits park the task immediately. | diff --git a/packages/core/src/config/settings-schema.ts b/packages/core/src/config/settings-schema.ts index 440840ef17..d69b7cf3ea 100644 --- a/packages/core/src/config/settings-schema.ts +++ b/packages/core/src/config/settings-schema.ts @@ -614,9 +614,6 @@ export const DEFAULT_PROJECT_SETTINGS = { * New and unconfigured projects default AI merge to sync a dirty checked-out integration branch, restoring the legacy stash → fast-forward → restore landing behavior. Explicit persisted merger.allowDirtyLocalCheckoutSync values still win, and no existing-project migration stamps this default into storage. */ merger: { mode: "ai", maxReviewPasses: 3, allowDirtyLocalCheckoutSync: true }, - mergeDiffVolumeMinLines: undefined, - mergeDiffVolumeThreshold: undefined, - mergeDiffVolumeAllowlist: undefined, requiredChecks: undefined, mergeStrategyOverlapBehavior: "flip-to-prefer-branch", postMergeAuditMode: "warn", diff --git a/packages/core/src/types/settings/settings-scope.ts b/packages/core/src/types/settings/settings-scope.ts index 425254c2b6..8d884e273d 100644 --- a/packages/core/src/types/settings/settings-scope.ts +++ b/packages/core/src/types/settings/settings-scope.ts @@ -1684,12 +1684,6 @@ export interface ProjectSettings { * When mode is "ai" (default), the standalone AI merge path is used and the * legacy merge settings above/below it do not apply. */ merger?: MergerSettings; - /** Minimum branch net line volume before the pre-commit diff-volume gate evaluates a file. Default applied at read site: 20. */ - mergeDiffVolumeMinLines?: number; - /** Minimum staged/branch-net ratio required by the pre-commit diff-volume gate. Default applied at read site: 0.2. */ - mergeDiffVolumeThreshold?: number; - /** Additional file globs allowlisted by the pre-commit diff-volume gate on top of generated/lockfile patterns. Default applied at read site: []. */ - mergeDiffVolumeAllowlist?: string[]; /** * FNXC:PrMergeRequiredChecks 2026-08-09-06:39: * Fusion honors these names independently of GitHub's isRequired flag. Empty preserves diff --git a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx index 6042b97513..5fedb0a868 100644 --- a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx +++ b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx @@ -501,9 +501,6 @@ const NOT_SURFACED_ALLOWLIST: Record = { commitMsgHookEnabled: "not yet exposed as a distinct Settings field", autoResolveReviewComments: "not yet exposed as a distinct Settings field", mergeRequestContractShadowEnabled: "internal shadow-diagnostic flag, not a Settings field", - mergeDiffVolumeMinLines: "not yet exposed as a distinct Settings field", - mergeDiffVolumeThreshold: "not yet exposed as a distinct Settings field", - mergeDiffVolumeAllowlist: "not yet exposed as a distinct Settings field", mergeAuditAutoRecovery: "not yet exposed as a distinct Settings field", autoRecovery: "not yet exposed as a distinct Settings field", buildTimeoutMs: "not yet exposed as a distinct Settings field", diff --git a/packages/engine/src/__tests__/engine-public-api.test.ts b/packages/engine/src/__tests__/engine-public-api.test.ts index e2f16ab56a..3c1cc1bccd 100644 --- a/packages/engine/src/__tests__/engine-public-api.test.ts +++ b/packages/engine/src/__tests__/engine-public-api.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { BranchConflictError, - DiffVolumeRegressionError, MergeAbortedError, SquashAuditError, type SquashAuditFindings, @@ -37,10 +36,6 @@ describe("engine public api barrel", () => { recommendedAction: "Rebase the task branch.", }), }, - { - name: "DiffVolumeRegressionError", - ctor: () => new DiffVolumeRegressionError([]), - }, { name: "MergeAbortedError", ctor: () => new MergeAbortedError("merge aborted"), diff --git a/packages/engine/src/__tests__/merge-single-flight-invariant.test.ts b/packages/engine/src/__tests__/merge-single-flight-invariant.test.ts index a64e55335e..d9cc823978 100644 --- a/packages/engine/src/__tests__/merge-single-flight-invariant.test.ts +++ b/packages/engine/src/__tests__/merge-single-flight-invariant.test.ts @@ -8,8 +8,8 @@ MERGE CONCURRENCY IS FIXED AT 1 AND IS NOT A SETTING. The capacity model is exactly two CONFIGURABLE numbers per project (total agents, maxWorktrees) plus this one FIXED invariant. Merge is where the irreversible work happens — main advances, branches are deleted, worktrees are torn down — so every -merge-safety guard in the repo (file-scope overlap, diff-volume shrinkage, -post-squash audit, contamination auto-recovery) is written against the assumption +merge-safety guard in the repo (file-scope overlap, post-squash audit, +contamination auto-recovery) is written against the assumption that exactly one merge is in flight per project at a time. None of them are concurrency-safe against a second merge advancing main underneath them. diff --git a/packages/engine/src/__tests__/merger-ai-squash-gates.test.ts b/packages/engine/src/__tests__/merger-ai-squash-gates.test.ts index 12a71ffd6a..c370a03429 100644 --- a/packages/engine/src/__tests__/merger-ai-squash-gates.test.ts +++ b/packages/engine/src/__tests__/merger-ai-squash-gates.test.ts @@ -4,7 +4,6 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { FileScopeViolationError } from "../merge/merger-file-scope.js"; -import { DiffVolumeRegressionError } from "../merge/merger-diff-volume-gate.js"; import { resolveRepoDeclaredScopeTransform } from "../merge/merger-ai-squash-gates.js"; const policy = vi.hoisted(() => vi.fn()); @@ -121,21 +120,6 @@ describe("runAiMerge approved-squash gates", () => { if (scopeOverride) expect(store.appendAgentLog).toHaveBeenCalledWith("FN-9050", expect.stringContaining("scopeOverride"), "status", undefined, "merger"); }); - it("blocks a committed shrinkage range before main advances and emits the diff-volume audit", async () => { - setPolicy(); - const dir = createRepo((root) => writeFileSync(join(root, "large.ts"), Array.from({ length: 100 }, (_, i) => `line-${i}`).join("\n") + "\n")); - const before = git(dir, "rev-parse main"); - const { store } = makeStore(["large.ts"]); - - await expect(runAiMerge(store, dir, "FN-9050", { manual: true }, { - mergeAgent: squashAgent("fusion/fn-9050", (cwd) => writeFileSync(join(cwd, "large.ts"), "kept\n")), - reviewAgent: approve, - })).rejects.toBeInstanceOf(DiffVolumeRegressionError); - - expect(git(dir, "rev-parse main")).toBe(before); - expect(store.recordRunAuditEvent.mock.calls.some(([event]: any[]) => event.mutationType === "merge:diff-volume-blocked")).toBe(true); - }); - it("resets a recovered strict scope violation so a retry does not select it again", async () => { setPolicy(); const dir = createRepo((root) => writeFileSync(join(root, "outside.txt"), "outside\n")); @@ -162,27 +146,4 @@ describe("runAiMerge approved-squash gates", () => { })).rejects.toThrow("normal merge invoked"); expect(normalMerge).toHaveBeenCalledOnce(); }); - - it("uses the task branch, not clean-room HEAD, when recovery gates a shrinkage squash", async () => { - setPolicy(); - const dir = createRepo((root) => writeFileSync(join(root, "large.ts"), Array.from({ length: 100 }, (_, i) => `line-${i}`).join("\n") + "\n")); - const before = git(dir, "rev-parse main"); - const cleanRoomParent = resolveAiMergeRoot(dir); - mkdirSync(cleanRoomParent, { recursive: true }); - const cleanRoom = mkdtempSync(join(cleanRoomParent, "fusion-ai-merge-fn-9050-")); - git(dir, `worktree add --detach ${cleanRoom} ${before}`); - git(cleanRoom, "merge --squash fusion/fn-9050"); - writeFileSync(join(cleanRoom, "large.ts"), "kept\n"); - git(cleanRoom, "add -A && git commit -q -m squash -m 'Fusion-Task-Id: FN-9050'"); - const squashSha = git(cleanRoom, "rev-parse HEAD"); - const { store, task } = makeStore(["large.ts"]); - task.log = [{ action: `AI merge review (pass 1): approved squash ${squashSha}`, timestamp: new Date().toISOString() }]; - - await expect(runAiMerge(store, dir, "FN-9050", { manual: true }, { - mergeAgent: async () => { throw new Error("recovery should not re-merge"); }, reviewAgent: approve, - })).rejects.toBeInstanceOf(DiffVolumeRegressionError); - - expect(git(dir, "rev-parse main")).toBe(before); - expect(store.recordRunAuditEvent.mock.calls.some(([event]: any[]) => event.mutationType === "merge:diff-volume-blocked")).toBe(true); - }); }); diff --git a/packages/engine/src/__tests__/merger-diff-volume-gate.slow.test.ts b/packages/engine/src/__tests__/merger-diff-volume-gate.slow.test.ts deleted file mode 100644 index 297e4e76b8..0000000000 --- a/packages/engine/src/__tests__/merger-diff-volume-gate.slow.test.ts +++ /dev/null @@ -1,410 +0,0 @@ -// Real-git wallclock under parallel CI load; do not lower per-test timeouts -// without re-measuring under pnpm test:full. (FN-4839) -import { afterEach, describe, expect, it, vi } from "vitest"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { join, resolve } from "node:path"; -import { tmpdir } from "node:os"; -import { execSync } from "node:child_process"; -import { DEFAULT_SETTINGS } from "@fusion/core"; -import { checkDiffVolume, DiffVolumeRegressionError } from "../merge/merger-diff-volume-gate.js"; -import { attemptWithSideStrategy, commitOrAmendMergeWithFixes, executeMergeAttempt } from "../merger.js"; - -function git(dir: string, command: string): string { - return execSync(command, { cwd: dir, stdio: "pipe" }).toString().trim(); -} - -function testTempParent(): string { - return process.env.FUSION_TEST_WORKER_ROOT ?? tmpdir(); -} - -function assertIsolatedWorkspace(dir: string): void { - const repoRoot = process.env.FUSION_TEST_REAL_ROOT; - if (!repoRoot) return; - expect(resolve(dir).startsWith(resolve(repoRoot))).toBe(false); -} - -function initRepo(dir: string): void { - git(dir, "git init -b main"); - git(dir, 'git config user.email "test@example.com"'); - git(dir, 'git config user.name "Test"'); - git(dir, 'git config commit.gpgsign false'); - writeFileSync(join(dir, "README.md"), "# repo\n"); - git(dir, "git add README.md"); - git(dir, 'git commit -m "chore: initial commit"'); -} - -function writeRepeatedLines(dir: string, file: string, count: number, prefix = "line"): void { - mkdirSync(join(dir, file, ".."), { recursive: true }); - writeFileSync(join(dir, file), Array.from({ length: count }, (_, index) => `${prefix} ${index + 1}`).join("\n") + "\n"); -} - -function discardStagedFile(dir: string, file: string): void { - git(dir, `git reset HEAD -- ${file}`); - const absolute = join(dir, file); - if (existsSync(absolute)) { - rmSync(absolute, { force: true }); - } -} - -function createBranchCommit(dir: string, branch: string, file: string, lineCount: number, prefix?: string): { preAttemptHeadSha: string } { - const preAttemptHeadSha = git(dir, "git rev-parse HEAD"); - git(dir, `git checkout -b ${branch}`); - writeRepeatedLines(dir, file, lineCount, prefix ?? branch); - git(dir, `git add ${file}`); - git(dir, `git commit -m "feat: update ${file}"`); - git(dir, "git checkout main"); - return { preAttemptHeadSha }; -} - -function stageSquash(dir: string, branch: string): void { - git(dir, `git merge --squash ${branch}`); -} - -function createMockStore() { - return { - appendAgentLog: vi.fn().mockResolvedValue(undefined), - logEntry: vi.fn().mockResolvedValue(undefined), - getTask: vi.fn().mockResolvedValue({ id: "FN-4072", column: "in-review", prompt: "# test" }), - upsertTaskCommitAssociation: vi.fn().mockResolvedValue(undefined), - getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS, - mergeIntegrationWorktree: "cwd-main" as const, commitAuthorEnabled: false }), - } as any; -} - -function mergeAttemptParams(dir: string, branch: string, preAttemptHeadSha: string, store = createMockStore()) { - return { - store, - rootDir: dir, - taskId: "FN-4072", - branch, - commitLog: `- feat: ${branch}`, - diffStat: "1 file changed", - aiSummary: null, - aiSubject: null, - includeTaskId: false, - smartConflictResolution: true, - mergeConflictStrategy: "smart-prefer-main", - attemptNum: 3, - options: {}, - result: {}, - settings: { ...DEFAULT_SETTINGS, - mergeIntegrationWorktree: "cwd-main" as const, commitAuthorEnabled: false }, - preAttemptHeadSha, - } as any; -} - -describe("checkDiffVolume", () => { - const createdDirs = new Set(); - - afterEach(() => { - for (const dir of createdDirs) { - rmSync(dir, { recursive: true, force: true }); - createdDirs.delete(dir); - } - }); - - it("blocks when a large branch contribution is dropped from staged content", async () => { - const dir = mkdtempSync(join(testTempParent(), "fusion-test-diff-volume-")); - createdDirs.add(dir); - assertIsolatedWorkspace(dir); - initRepo(dir); - const { preAttemptHeadSha } = createBranchCommit(dir, "feat/drop", "packages/core/src/store.ts", 60, "drop"); - stageSquash(dir, "feat/drop"); - discardStagedFile(dir, "packages/core/src/store.ts"); - - await expect(checkDiffVolume({ - rootDir: dir, - branch: "feat/drop", - integrationTargetSha: preAttemptHeadSha, - minLines: 20, - threshold: 0.2, - allowlistGlobs: [], - taskId: "FN-4072", - })).rejects.toMatchObject({ - name: "DiffVolumeRegressionError", - findings: [expect.objectContaining({ file: "packages/core/src/store.ts", branchNet: 60, staged: 0 })], - }); - }, 15_000); - - it("ignores dropped files below minLines", async () => { - const dir = mkdtempSync(join(testTempParent(), "fusion-test-diff-volume-")); - createdDirs.add(dir); - initRepo(dir); - const { preAttemptHeadSha } = createBranchCommit(dir, "feat/small", "src/small.ts", 5, "small"); - stageSquash(dir, "feat/small"); - discardStagedFile(dir, "src/small.ts"); - - await expect(checkDiffVolume({ - rootDir: dir, - branch: "feat/small", - integrationTargetSha: preAttemptHeadSha, - minLines: 20, - threshold: 0.2, - allowlistGlobs: [], - taskId: "FN-4072", - })).resolves.toBeUndefined(); - }); - - it("skips dropped lockfiles", async () => { - const dir = mkdtempSync(join(testTempParent(), "fusion-test-diff-volume-")); - createdDirs.add(dir); - initRepo(dir); - const { preAttemptHeadSha } = createBranchCommit(dir, "feat/lock", "pnpm-lock.yaml", 60, "lock"); - stageSquash(dir, "feat/lock"); - discardStagedFile(dir, "pnpm-lock.yaml"); - - await expect(checkDiffVolume({ - rootDir: dir, - branch: "feat/lock", - integrationTargetSha: preAttemptHeadSha, - minLines: 20, - threshold: 0.2, - allowlistGlobs: [], - taskId: "FN-4072", - })).resolves.toBeUndefined(); - }); - - it("honors caller-supplied allowlist globs", async () => { - const dir = mkdtempSync(join(testTempParent(), "fusion-test-diff-volume-")); - createdDirs.add(dir); - initRepo(dir); - const { preAttemptHeadSha } = createBranchCommit(dir, "feat/allow", "fixtures/generated.snapshot", 60, "snapshot"); - stageSquash(dir, "feat/allow"); - discardStagedFile(dir, "fixtures/generated.snapshot"); - - await expect(checkDiffVolume({ - rootDir: dir, - branch: "feat/allow", - integrationTargetSha: preAttemptHeadSha, - minLines: 20, - threshold: 0.2, - allowlistGlobs: ["fixtures/*.snapshot"], - taskId: "FN-4072", - })).resolves.toBeUndefined(); - }); - - it("treats binary numstat entries as zero without crashing", async () => { - const dir = mkdtempSync(join(testTempParent(), "fusion-test-diff-volume-")); - createdDirs.add(dir); - initRepo(dir); - const preAttemptHeadSha = git(dir, "git rev-parse HEAD"); - git(dir, "git checkout -b feat/binary"); - writeFileSync(join(dir, "image.bin"), Buffer.from([0, 1, 2, 3, 4, 5])); - git(dir, "git add image.bin"); - git(dir, 'git commit -m "feat: add binary"'); - git(dir, "git checkout main"); - stageSquash(dir, "feat/binary"); - discardStagedFile(dir, "image.bin"); - - await expect(checkDiffVolume({ - rootDir: dir, - branch: "feat/binary", - integrationTargetSha: preAttemptHeadSha, - minLines: 1, - threshold: 0.2, - allowlistGlobs: [], - taskId: "FN-4072", - })).resolves.toBeUndefined(); - }); - - it("exposes a structured error message", async () => { - const dir = mkdtempSync(join(testTempParent(), "fusion-test-diff-volume-")); - createdDirs.add(dir); - initRepo(dir); - const { preAttemptHeadSha } = createBranchCommit(dir, "feat/msg", "src/important.ts", 60, "important"); - stageSquash(dir, "feat/msg"); - discardStagedFile(dir, "src/important.ts"); - - await expect(checkDiffVolume({ - rootDir: dir, - branch: "feat/msg", - integrationTargetSha: preAttemptHeadSha, - minLines: 20, - threshold: 0.2, - allowlistGlobs: [], - taskId: "FN-4072", - })).rejects.toSatisfy((error: unknown) => error instanceof DiffVolumeRegressionError && error.message.includes("branch_net=60") && error.message.includes("ratio=0.000")); - }); -}); - -describe("diff-volume gate merger integration", () => { - const createdDirs = new Set(); - - afterEach(() => { - for (const dir of createdDirs) { - rmSync(dir, { recursive: true, force: true }); - createdDirs.delete(dir); - } - }); - - it("blocks the FN-3936 replay in attemptWithSideStrategy and leaves the worktree clean", async () => { - const dir = mkdtempSync(join(testTempParent(), "fusion-test-diff-volume-merge-")); - createdDirs.add(dir); - initRepo(dir); - writeRepeatedLines(dir, "packages/core/src/store.ts", 1, "base"); - git(dir, "git add packages/core/src/store.ts"); - git(dir, 'git commit -m "chore: add store"'); - - git(dir, "git checkout -b feat/fn-3936"); - writeRepeatedLines(dir, "packages/core/src/store.ts", 60, "branch"); - writeRepeatedLines(dir, "docs/kept.md", 5, "kept"); - git(dir, "git add packages/core/src/store.ts docs/kept.md"); - git(dir, 'git commit -m "feat: branch store hardening"'); - git(dir, "git checkout main"); - - writeRepeatedLines(dir, "packages/core/src/store.ts", 1, "main"); - git(dir, "git add packages/core/src/store.ts"); - git(dir, 'git commit -m "fix: main store edit"'); - const mainHeadBeforeMerge = git(dir, "git rev-parse HEAD"); - - const store = createMockStore(); - await expect(attemptWithSideStrategy(mergeAttemptParams(dir, "feat/fn-3936", mainHeadBeforeMerge, store), "ours")).rejects.toMatchObject({ - name: "DiffVolumeRegressionError", - findings: [expect.objectContaining({ file: "packages/core/src/store.ts", branchNet: 61, staged: 0 })], - }); - - expect(git(dir, "git rev-parse HEAD")).toBe(mainHeadBeforeMerge); - expect(git(dir, "git status --short")).toBe(""); - expect(store.appendAgentLog).toHaveBeenCalledWith( - "FN-4072", - "Diff-volume gate blocked auto-resolved squash before commit", - "tool_error", - expect.stringContaining("packages/core/src/store.ts"), - "merger", - ); - }); - - it("allows a healthy attempt 2 auto-resolution path when staged volume matches the branch", async () => { - const dir = mkdtempSync(join(testTempParent(), "fusion-test-diff-volume-merge-")); - createdDirs.add(dir); - initRepo(dir); - writeRepeatedLines(dir, "src/data.gen.ts", 1, "base"); - git(dir, "git add src/data.gen.ts"); - git(dir, 'git commit -m "chore: add generated file"'); - const preAttemptHeadSha = git(dir, "git rev-parse HEAD"); - - git(dir, "git checkout -b feat/generated"); - writeRepeatedLines(dir, "src/data.gen.ts", 60, "branch-generated"); - git(dir, "git add src/data.gen.ts"); - git(dir, 'git commit -m "feat: regenerate data"'); - git(dir, "git checkout main"); - - writeRepeatedLines(dir, "src/data.gen.ts", 2, "main-generated"); - git(dir, "git add src/data.gen.ts"); - git(dir, 'git commit -m "chore: main regen"'); - const store = createMockStore(); - - const success = await executeMergeAttempt({ - ...mergeAttemptParams(dir, "feat/generated", preAttemptHeadSha, store), - attemptNum: 2, - diffStat: " src/data.gen.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++", - }, {} as any); - - expect(success).toBe(true); - expect(git(dir, "git rev-parse HEAD")).not.toBe(preAttemptHeadSha); - expect(git(dir, "git show --format= --name-only HEAD").split("\n")).toContain("src/data.gen.ts"); - }, 15_000); - - it("allows dropped lockfile-only content in attemptWithSideStrategy", async () => { - const dir = mkdtempSync(join(testTempParent(), "fusion-test-diff-volume-merge-")); - createdDirs.add(dir); - initRepo(dir); - writeRepeatedLines(dir, "pnpm-lock.yaml", 1, "base-lock"); - git(dir, "git add pnpm-lock.yaml"); - git(dir, 'git commit -m "chore: add lockfile"'); - - git(dir, "git checkout -b feat/lock-drop"); - writeRepeatedLines(dir, "pnpm-lock.yaml", 60, "branch-lock"); - writeRepeatedLines(dir, "src/kept.ts", 5, "kept-lock"); - git(dir, "git add pnpm-lock.yaml src/kept.ts"); - git(dir, 'git commit -m "feat: lock update"'); - git(dir, "git checkout main"); - - writeRepeatedLines(dir, "pnpm-lock.yaml", 1, "main-lock"); - git(dir, "git add pnpm-lock.yaml"); - git(dir, 'git commit -m "chore: main lock change"'); - const mainHeadBeforeMerge = git(dir, "git rev-parse HEAD"); - - const merged = await attemptWithSideStrategy(mergeAttemptParams(dir, "feat/lock-drop", mainHeadBeforeMerge), "ours"); - expect(merged).toBe(true); - expect(git(dir, "git rev-parse HEAD")).not.toBe(mainHeadBeforeMerge); - }); - - it("blocks commitOrAmendMergeWithFixes before a fresh finalize commit when staged branch volume was dropped", async () => { - const dir = mkdtempSync(join(testTempParent(), "fusion-test-diff-volume-merge-")); - createdDirs.add(dir); - initRepo(dir); - const preAttemptHeadSha = git(dir, "git rev-parse HEAD"); - git(dir, "git checkout -b feat/finalize-fresh"); - writeRepeatedLines(dir, "src/finalize.ts", 60, "fresh"); - writeRepeatedLines(dir, "src/kept.ts", 5, "kept-fresh"); - git(dir, "git add src/finalize.ts src/kept.ts"); - git(dir, 'git commit -m "feat: finalize fresh"'); - git(dir, "git checkout main"); - stageSquash(dir, "feat/finalize-fresh"); - discardStagedFile(dir, "src/finalize.ts"); - - await expect(commitOrAmendMergeWithFixes( - dir, - "FN-4072", - "feat/finalize-fresh", - "- feat: finalize fresh", - false, - preAttemptHeadSha, - "", - "1 file changed", - { ...DEFAULT_SETTINGS, - mergeIntegrationWorktree: "cwd-main" as const, commitAuthorEnabled: false }, - undefined, - null, - null, - null, - new Set(), - createMockStore(), - )).rejects.toBeInstanceOf(DiffVolumeRegressionError); - - expect(git(dir, "git rev-parse HEAD")).toBe(preAttemptHeadSha); - expect(git(dir, "git status --short")).toBe(""); - }); - - it("blocks commitOrAmendMergeWithFixes before an amend finalize when staged branch volume was dropped", async () => { - const dir = mkdtempSync(join(testTempParent(), "fusion-test-diff-volume-merge-")); - createdDirs.add(dir); - initRepo(dir); - const preAttemptHeadSha = git(dir, "git rev-parse HEAD"); - git(dir, "git checkout -b feat/finalize-amend"); - writeRepeatedLines(dir, "src/amend.ts", 60, "amend"); - writeRepeatedLines(dir, "src/kept-amend.ts", 5, "kept-amend"); - git(dir, "git add src/amend.ts src/kept-amend.ts"); - git(dir, 'git commit -m "feat: finalize amend"'); - git(dir, "git checkout main"); - stageSquash(dir, "feat/finalize-amend"); - git(dir, 'git commit -m "feat: ai commit"'); - writeRepeatedLines(dir, "README.md", 1, "dirty"); - git(dir, "git add README.md"); - git(dir, "git reset HEAD -- src/amend.ts"); - rmSync(join(dir, "src/amend.ts"), { force: true }); - - await expect(commitOrAmendMergeWithFixes( - dir, - "FN-4072", - "feat/finalize-amend", - "- feat: finalize amend", - false, - preAttemptHeadSha, - "", - "1 file changed", - { ...DEFAULT_SETTINGS, - mergeIntegrationWorktree: "cwd-main" as const, commitAuthorEnabled: false }, - undefined, - null, - null, - null, - new Set(["README.md"]), - createMockStore(), - )).rejects.toBeInstanceOf(DiffVolumeRegressionError); - - expect(git(dir, "git rev-parse HEAD")).toBe(preAttemptHeadSha); - expect(git(dir, "git status --short")).toBe(""); - }); -}); diff --git a/packages/engine/src/__tests__/reliability-interactions/merge-strategy-and-overlap.test.ts b/packages/engine/src/__tests__/reliability-interactions/merge-strategy-and-overlap.test.ts deleted file mode 100644 index d258dd3c5f..0000000000 --- a/packages/engine/src/__tests__/reliability-interactions/merge-strategy-and-overlap.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Real-git wallclock under parallel CI load; do not lower per-test timeouts -// without re-measuring under pnpm test:full. (FN-4839) -import { afterEach, describe, expect, it } from "vitest"; -import { checkDiffVolume } from "../../merge/merger-diff-volume-gate.js"; -// FNXC:SqliteRemoval 2026-07-14: hasPg guard added — makeReliabilityFixture requires PG after SQLite removal (VAL-REMOVAL-005). -import { makeReliabilityFixture, hasGit, hasPg, git } from "./_helpers.js"; - -const describeIfGit = hasGit && hasPg ? describe : describe.skip; - -describeIfGit("reliability interactions: merge strategy + overlap", () => { - const fixtures: Array>> = []; - afterEach(async () => { while (fixtures.length) await fixtures.pop()!.cleanup(); }); - - // Case 6 (auto-strategy multi-commit history) is covered by src/__tests__/merger-commit-strategy.real-git.test.ts: auto-routes multi-substantive branches to history-preserving direct merge. - - it("Case 7: diff-volume gate detects dropped branch contribution", async () => { - const fx = await makeReliabilityFixture({ taskId: "FN-4361-C7" }); - fixtures.push(fx); - await fx.createBranch("fusion/fn-4361-c7"); - await fx.writeAndCommit("packages/core/src/drop.ts", Array.from({ length: 50 }, (_, i) => `line ${i}`).join("\n") + "\n", "feat: branch volume"); - await fx.checkout("main"); - const base = git(fx.rootDir, "git rev-parse HEAD"); - git(fx.rootDir, "git merge --squash fusion/fn-4361-c7"); - git(fx.rootDir, "git reset HEAD -- packages/core/src/drop.ts"); - - await expect(checkDiffVolume({ - rootDir: fx.rootDir, - branch: "fusion/fn-4361-c7", - integrationTargetSha: base, - minLines: 20, - threshold: 0.2, - allowlistGlobs: [], - taskId: fx.task.id, - })).rejects.toMatchObject({ name: "DiffVolumeRegressionError" }); - }); - - it("Additional: diff-volume gate runs before later invariant checks on empty staged set", async () => { - const fx = await makeReliabilityFixture({ taskId: "FN-4361-MX" }); - fixtures.push(fx); - await fx.createBranch("fusion/fn-4361-mx"); - await fx.writeAndCommit("src/mx.txt", Array.from({ length: 40 }, (_, i) => `x${i}`).join("\n") + "\n", "feat: mx"); - await fx.checkout("main"); - const base = git(fx.rootDir, "git rev-parse HEAD"); - git(fx.rootDir, "git merge --squash fusion/fn-4361-mx"); - git(fx.rootDir, "git reset HEAD -- src/mx.txt"); - await expect(checkDiffVolume({ rootDir: fx.rootDir, branch: "fusion/fn-4361-mx", integrationTargetSha: base, minLines: 20, threshold: 0.2, allowlistGlobs: [], taskId: fx.task.id })).rejects.toBeTruthy(); - }); -}); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index fb09d7459a..c417dc971e 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -360,7 +360,6 @@ export { dropAutostashBySha, getAutostashDiff, notifyAutostashOrphans, - DiffVolumeRegressionError, MergeAbortedError, SquashAuditError, type MergerOptions, diff --git a/packages/engine/src/merge/merger-ai-squash-gates.ts b/packages/engine/src/merge/merger-ai-squash-gates.ts index ac65e80ca7..83fd49993b 100644 --- a/packages/engine/src/merge/merger-ai-squash-gates.ts +++ b/packages/engine/src/merge/merger-ai-squash-gates.ts @@ -1,8 +1,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import type { Settings, Task, TaskStore } from "@fusion/core"; +import type { Task, TaskStore } from "@fusion/core"; import { deriveRepoForPath, deriveRepoScopeSubset, UNSCOPED_REPO } from "../worktree/workspace-paths.js"; -import { checkDiffVolume, DiffVolumeRegressionError, formatDiffVolumeFindings, resolveDiffVolumeGateSettings } from "./merger-diff-volume-gate.js"; import { createCommitRangeFilesReader, enforceSquashFileScopeInvariant, FileScopeViolationError } from "./merger-file-scope.js"; import type { RunAuditor } from "../util/run-audit.js"; @@ -22,8 +21,15 @@ export function resolveRepoDeclaredScopeTransform({ repoRel, repoKeys }: { repoR }; } -/** Apply both pre-land guards to the approved clean-room squash. */ -export async function enforceAiMergeSquashGates(params: { store: TaskStore; task: Task; taskId: string; mergeRoot: string; branch: string; tipSha: string; squashSha: string; settings: Settings; audit: RunAuditor; log: (message: string) => Promise; repoRel?: string; repoKeys?: readonly string[] }): Promise { +/* +FNXC:AIMerge 2026-08-16-05:28: +The pre-land diff-volume shrinkage gate (checkDiffVolume + merge:diff-volume-blocked audit) +was removed by operator decision: it blocked approved clean-room squashes whose review had +already accepted the diff, with no override path. File scope is the sole pre-land guard now; +the post-squash audit policy remains the shrinkage backstop. +*/ +/** Apply the file-scope pre-land guard to the approved clean-room squash. */ +export async function enforceAiMergeSquashGates(params: { store: TaskStore; task: Task; taskId: string; mergeRoot: string; branch: string; tipSha: string; squashSha: string; audit: RunAuditor; log: (message: string) => Promise; repoRel?: string; repoKeys?: readonly string[] }): Promise { const resolver = params.repoRel ? resolveRepoDeclaredScopeTransform({ repoRel: params.repoRel, repoKeys: params.repoKeys ?? [] }) : undefined; const transform = resolver ? (scope: string[]) => resolver.transform(scope) : undefined; try { @@ -53,14 +59,4 @@ export async function enforceAiMergeSquashGates(params: { store: TaskStore; task await execFileAsync("git", ["clean", "-fd"], { cwd: params.mergeRoot }); throw error; } - try { - await checkDiffVolume({ rootDir: params.mergeRoot, branch: params.branch, integrationTargetSha: params.tipSha, squashRange: { fromSha: params.tipSha, toSha: params.squashSha }, ...resolveDiffVolumeGateSettings(params.settings) }); - } catch (error) { - if (!(error instanceof DiffVolumeRegressionError)) throw error; - await execFileAsync("git", ["reset", "--hard", params.tipSha], { cwd: params.mergeRoot }); - await execFileAsync("git", ["clean", "-fd"], { cwd: params.mergeRoot }); - await params.store.appendAgentLog(params.taskId, "AI merge diff-volume gate blocked the approved squash", "tool_error", formatDiffVolumeFindings(error.findings), "merger"); - await params.audit.git({ type: "merge:diff-volume-blocked", target: params.taskId, metadata: { taskId: params.taskId, repo: params.repoRel, tipSha: params.tipSha, squashSha: params.squashSha, findingCount: error.findings.length, files: error.findings.map((finding) => finding.file) } }); - throw error; - } } diff --git a/packages/engine/src/merge/merger-ai.ts b/packages/engine/src/merge/merger-ai.ts index 1130641169..a4f62a33a0 100644 --- a/packages/engine/src/merge/merger-ai.ts +++ b/packages/engine/src/merge/merger-ai.ts @@ -317,7 +317,7 @@ async function recoverApprovedPreexistingAiMergeWorktree( throwIfAborted(signal, taskId); if (!selected.alreadyLanded) { if (!task) throw new Error(`AI merge task ${taskId} disappeared before recovery squash gates`); - await enforceAiMergeSquashGates({ store, task, taskId, mergeRoot: selected.mergeRoot, branch, tipSha: selected.tipSha, squashSha: selected.squashSha, settings, audit, log, repoRel: ctx.repoRel, repoKeys: ctx.repoKeys }); + await enforceAiMergeSquashGates({ store, task, taskId, mergeRoot: selected.mergeRoot, branch, tipSha: selected.tipSha, squashSha: selected.squashSha, audit, log, repoRel: ctx.repoRel, repoKeys: ctx.repoKeys }); const land = await landSquash({ projectRootDir: repoRootDir, mergeRoot: selected.mergeRoot, @@ -1113,7 +1113,7 @@ export async function landOneRepo( */ const freshTask = await store.getTask(taskId); if (!freshTask) throw new Error(`AI merge task ${taskId} disappeared before squash gates`); - await enforceAiMergeSquashGates({ store, task: freshTask, taskId, mergeRoot, branch, tipSha, squashSha, settings, audit, log, repoRel: ctx.repoRel, repoKeys: ctx.repoKeys }); + await enforceAiMergeSquashGates({ store, task: freshTask, taskId, mergeRoot, branch, tipSha, squashSha, audit, log, repoRel: ctx.repoRel, repoKeys: ctx.repoKeys }); // FNXC:Workspace 2026-08-15-08:36: Persist the recovery intent before the shared ref can // move. A later reconciler can then settle an interrupted remote advance without re-squashing. diff --git a/packages/engine/src/merge/merger-diff-volume-gate.ts b/packages/engine/src/merge/merger-diff-volume-gate.ts deleted file mode 100644 index bf2b3f0df8..0000000000 --- a/packages/engine/src/merge/merger-diff-volume-gate.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -import type { Settings } from "@fusion/core"; -import { GENERATED_PATTERNS, LOCKFILE_PATTERNS, matchGlob } from "../merger.js"; - -const execFileAsync = promisify(execFile); - -export interface DiffVolumeRegressionFinding { - file: string; - branchNet: number; - staged: number; - ratio: number; -} - -export class DiffVolumeRegressionError extends Error { - override name = "DiffVolumeRegressionError"; - - constructor(public readonly findings: DiffVolumeRegressionFinding[]) { - super(buildMessage(findings)); - } -} - -interface CheckDiffVolumeParams { - rootDir: string; - branch: string; - integrationTargetSha: string; - minLines: number; - threshold: number; - allowlistGlobs: readonly string[]; - taskId?: string; - /** Approved clean-room commit range; legacy callers continue reading the index. */ - squashRange?: { fromSha: string; toSha: string }; -} - -function buildMessage(findings: readonly DiffVolumeRegressionFinding[]): string { - const details = findings - .map((finding) => `${finding.file} (branch_net=${finding.branchNet}, staged=${finding.staged}, ratio=${finding.ratio.toFixed(3)})`) - .join(", "); - return `Per-file diff-volume regression detected: ${details}`; -} - -function parseNumstatTotal(output: string): number { - const line = output - .split("\n") - .map((entry) => entry.trim()) - .find(Boolean); - if (!line) return 0; - const [addedRaw, deletedRaw] = line.split("\t"); - if (!addedRaw || !deletedRaw) return 0; - if (addedRaw === "-" || deletedRaw === "-") return 0; - const added = Number.parseInt(addedRaw, 10); - const deleted = Number.parseInt(deletedRaw, 10); - return (Number.isFinite(added) ? added : 0) + (Number.isFinite(deleted) ? deleted : 0); -} - -function isAllowlisted(file: string, allowlistGlobs: readonly string[]): boolean { - return [...LOCKFILE_PATTERNS, ...GENERATED_PATTERNS, ...allowlistGlobs].some((pattern) => matchGlob(file, pattern)); -} - -async function execGit(rootDir: string, args: string[]): Promise { - const { stdout } = await execFileAsync("git", args, { - cwd: rootDir, - encoding: "utf-8", - maxBuffer: 10 * 1024 * 1024, - }); - return stdout; -} - -export async function checkDiffVolume({ - rootDir, - branch, - integrationTargetSha, - minLines, - threshold, - allowlistGlobs, - squashRange, -}: CheckDiffVolumeParams): Promise { - const base = (await execGit(rootDir, ["merge-base", integrationTargetSha, branch])).trim(); - const touchedFilesOutput = await execGit(rootDir, ["diff", "--name-only", `${base}...${branch}`]); - const touchedFiles = touchedFilesOutput - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - - const findings: DiffVolumeRegressionFinding[] = []; - - for (const file of touchedFiles) { - if (isAllowlisted(file, allowlistGlobs)) continue; - - const branchNet = parseNumstatTotal( - await execGit(rootDir, ["diff", "--numstat", `${base}...${branch}`, "--", file]), - ); - if (branchNet <= minLines) continue; - - /* - * FNXC:AIMerge 2026-08-15-22:55: - * The unified land has an approved commit, not staged changes; preserve the - * legacy index read unless its clean-room range is supplied. - */ - const staged = parseNumstatTotal(await execGit(rootDir, squashRange - ? ["diff", "--numstat", `${squashRange.fromSha}..${squashRange.toSha}`, "--", file] - : ["diff", "--cached", "--numstat", "--", file])); - const ratio = branchNet === 0 ? 1 : staged / branchNet; - if (ratio < threshold) { - findings.push({ file, branchNet, staged, ratio }); - } - } - - if (findings.length > 0) { - throw new DiffVolumeRegressionError(findings); - } -} - -export interface DiffVolumeGateSettings { - minLines: number; - threshold: number; - allowlistGlobs: string[]; -} - -/* - * FNXC:CodeOrganization 2026-07-17-12:00: - * Diff-volume settings normalization and finding formatting peeled from merger.ts. - */ -export function resolveDiffVolumeGateSettings(settings?: Settings): DiffVolumeGateSettings { - const minLinesRaw = settings?.mergeDiffVolumeMinLines ?? 20; - const thresholdRaw = settings?.mergeDiffVolumeThreshold ?? 0.2; - return { - minLines: Math.max(1, Math.trunc(Number.isFinite(minLinesRaw) ? minLinesRaw : 20)), - threshold: Math.min(1, Math.max(0, Number.isFinite(thresholdRaw) ? thresholdRaw : 0.2)), - allowlistGlobs: Array.isArray(settings?.mergeDiffVolumeAllowlist) - ? settings.mergeDiffVolumeAllowlist.filter((glob): glob is string => typeof glob === "string" && glob.trim().length > 0) - : [], - }; -} - -export function formatDiffVolumeFindings(findings: ReadonlyArray<{ file: string; branchNet: number; staged: number; ratio: number }>): string { - return findings - .map((finding) => `${finding.file} (branchNet=${finding.branchNet}, staged=${finding.staged}, ratio=${finding.ratio.toFixed(3)})`) - .join("\n"); -} - diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index d093ced08c..ac13b0da5a 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -289,16 +289,6 @@ import { type SquashAuditFindings, } from "./merge/merger-squash-audit.js"; import { detectMergeOverlap, restoreBranchWinsFiles } from "./merge/merger-overlap-guard.js"; -import { - checkDiffVolume, - DiffVolumeRegressionError, - resolveDiffVolumeGateSettings, - formatDiffVolumeFindings, -} from "./merge/merger-diff-volume-gate.js"; -export { - resolveDiffVolumeGateSettings, - formatDiffVolumeFindings, -} from "./merge/merger-diff-volume-gate.js"; import { detectAlreadyLandedOnMain, type AlreadyMergedDetectionStrategy } from "./merge/already-merged-detector.js"; import { decideAutoPrerebase, probeDivergence, runAutoPrerebase } from "./merge/merger-auto-prerebase.js"; import { @@ -318,7 +308,6 @@ import { advanceIntegrationBranchRef, IntegrationBranchConcurrentAdvanceError } import { syncWorktreeToHead, type SyncWorktreeResult } from "./worktree/worktree-ref-sync.js"; import { appendAutoWidenedScopeToPrompt, evaluateScopeAutoWiden } from "./merge/merger-scope-auto-widen.js"; -export { DiffVolumeRegressionError } from "./merge/merger-diff-volume-gate.js"; export { IntegrationBranchConcurrentAdvanceError } from "./merge/merger-ref-update-advance.js"; /* @@ -634,53 +623,6 @@ const MERGE_USER_COMMENTS_MAX_CHARS = 4000; export const summarizeVerificationOutputLocal = summarizeVerificationOutput; -async function resetToIntegrationTarget(rootDir: string, integrationTargetSha: string): Promise { - await execAsync(`git reset --hard ${quoteArg(integrationTargetSha)}`, { - cwd: rootDir, - encoding: "utf-8", - }); - await execAsync("git clean -fd", { - cwd: rootDir, - encoding: "utf-8", - }); -} - -async function runDiffVolumeGate(params: { - rootDir: string; - branch: string; - integrationTargetSha: string; - taskId: string; - settings?: Settings; - store?: TaskStore; -}): Promise { - try { - const gateSettings = resolveDiffVolumeGateSettings(params.settings); - await checkDiffVolume({ - rootDir: params.rootDir, - branch: params.branch, - integrationTargetSha: params.integrationTargetSha, - minLines: gateSettings.minLines, - threshold: gateSettings.threshold, - allowlistGlobs: gateSettings.allowlistGlobs, - taskId: params.taskId, - }); - } catch (error: unknown) { - if (!(error instanceof DiffVolumeRegressionError)) throw error; - await resetToIntegrationTarget(params.rootDir, params.integrationTargetSha); - const details = formatDiffVolumeFindings(error.findings); - if (params.store) { - await params.store.appendAgentLog( - params.taskId, - `Diff-volume gate blocked auto-resolved squash before commit`, - "tool_error", - details, - "merger", - ); - } - throw error; - } -} - export async function getStagedFiles(cwd: string): Promise { try { const { stdout } = await execAsync("git diff --cached --name-only", { @@ -3774,8 +3716,8 @@ export async function commitOrAmendMergeWithFixes( preAttemptHeadSha: string, authorArg: string, diffStat?: string, - settings?: Settings, - signal?: AbortSignal, + _settings?: Settings, + _signal?: AbortSignal, aiSummary?: string | null, aiBody?: string | null, aiSubject?: string | null, @@ -4166,14 +4108,6 @@ export async function commitOrAmendMergeWithFixes( auditor, }); } - await runDiffVolumeGate({ - rootDir, - branch, - integrationTargetSha: preAttemptHeadSha, - taskId, - settings, - store, - }); await execAsync( `git commit ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`, { cwd: rootDir, env: mergerCommitEnv() }, @@ -4198,14 +4132,6 @@ export async function commitOrAmendMergeWithFixes( auditor, }); } - await runDiffVolumeGate({ - rootDir, - branch, - integrationTargetSha: preAttemptHeadSha, - taskId, - settings, - store, - }); await execAsync( `git commit --amend ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`, { cwd: rootDir, env: mergerCommitEnv() }, @@ -4216,7 +4142,7 @@ export async function commitOrAmendMergeWithFixes( mergerLog.log(`${taskId}: amended merge commit with verification fixes (deterministic message)`); return { ok: true, reason: "committed" }; } catch (err: unknown) { - if (err instanceof DiffVolumeRegressionError || err instanceof FileScopeViolationError) { + if (err instanceof FileScopeViolationError) { throw err; } const errorMessage = err instanceof Error ? err.message : String(err); @@ -8898,11 +8824,7 @@ export async function aiMergeTask( throw error; } - if ( - error instanceof DiffVolumeRegressionError - || error?.name === "DiffVolumeRegressionError" - || error?.name === "FileScopeViolationError" - ) { + if (error?.name === "FileScopeViolationError") { throw error; } @@ -10451,14 +10373,6 @@ export async function executeMergeAttempt( resetLabel: "file-scope invariant violation", auditor: params.auditor, }); - await runDiffVolumeGate({ - rootDir, - branch, - integrationTargetSha: params.preAttemptHeadSha || "HEAD", - taskId, - settings, - store, - }); await execAsync( `git commit ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`, { cwd: rootDir, env: mergerCommitEnv() }, @@ -10738,7 +10652,6 @@ export async function executeMergeAttempt( // verification failure anyway — there are no conflicts to resolve. if ( error?.name === "VerificationError" - || error?.name === "DiffVolumeRegressionError" || error?.name === "FileScopeViolationError" ) { throw error; @@ -10788,7 +10701,7 @@ export async function attemptWithSideStrategy( return finalizeSideStrategyAttempt(params, side, aiTracker); } catch (error) { - if (error instanceof Error && (error.name === "MergeAbortedError" || error.name === "DiffVolumeRegressionError" || error.name === "FileScopeViolationError")) { + if (error instanceof Error && (error.name === "MergeAbortedError" || error.name === "FileScopeViolationError")) { throw error; } mergerLog.error(`${taskId}: -X ${side} merge failed: ${error}`); @@ -10829,7 +10742,7 @@ async function attemptWithMixedSideStrategy( return finalizeSideStrategyAttempt(params, strategy.defaultSide, aiTracker); } catch (error) { - if (error instanceof Error && (error.name === "MergeAbortedError" || error.name === "DiffVolumeRegressionError" || error.name === "FileScopeViolationError")) { + if (error instanceof Error && (error.name === "MergeAbortedError" || error.name === "FileScopeViolationError")) { throw error; } mergerLog.error(`${taskId}: overlap-aware merge failed: ${error}`); @@ -10898,14 +10811,6 @@ async function finalizeSideStrategyAttempt( resetLabel: "file-scope invariant violation", auditor: params.auditor, }); - await runDiffVolumeGate({ - rootDir, - branch, - integrationTargetSha: params.preAttemptHeadSha || "HEAD", - taskId, - settings, - store, - }); await execAsync( `git commit ${subjectArg} ${bodyArg}${issueRefBodyArg}${trailerArg}${authorArg}`, { cwd: rootDir, env: mergerCommitEnv() }, @@ -11005,7 +10910,6 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo testCommand, buildCommand, preMergeRebaseFallthrough, - preAttemptHeadSha, } = params; // Merge per-task effective workflow settings (U3, KTD-3) — this worker re-fetches @@ -11332,14 +11236,6 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo aiBody: aiBody?.trim().length ? aiBody : safeBody, aiSubject, }); - await runDiffVolumeGate({ - rootDir, - branch, - integrationTargetSha: preAttemptHeadSha || "HEAD", - taskId, - settings, - store, - }); await execAsync( `git commit ${subjectArg} ${bodyArg}${issueRefBodyArg}${trailerArg}${authorArg}`, { cwd: rootDir, env: mergerCommitEnv() }, diff --git a/packages/engine/src/util/run-audit.ts b/packages/engine/src/util/run-audit.ts index 8455547d09..214c2a5447 100644 --- a/packages/engine/src/util/run-audit.ts +++ b/packages/engine/src/util/run-audit.ts @@ -190,7 +190,6 @@ export type GitMutationType = | "merge:resolve" | "merge:file-scope-violation" | "merge:file-scope-enforcement-disabled" - | "merge:diff-volume-blocked" // FNXC:MergerUnification 2026-08-09-12:04: Legacy-only audit events emitted // exclusively by soft-deprecated aiMergeTask, never by production runAiMerge. | "merge:auto-prerebase:applied"