feat(merge): remove the pre-commit diff-volume gate

The gate blocked approved clean-room squashes on per-file shrinkage with no
override path ("AI merge diff-volume gate blocked the approved squash").
Removed by operator decision: delete checkDiffVolume/DiffVolumeRegressionError,
the merge:diff-volume-blocked audit event, the runDiffVolumeGate call sites in
every legacy squash finalizer, the AI-merge pre-land check, and the
mergeDiffVolume* settings. File scope remains the pre-land guard; the
post-squash audit policy remains the shrinkage backstop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-15 22:42:39 -07:00
parent 866d0280c8
commit 87e673baf7
18 changed files with 31 additions and 793 deletions

View File

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

View File

@@ -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. 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. 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. 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. 7. **Smart-prefer-main overlap guard.** Recent overlapping main commits can flip to prefer-branch.
8. **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. **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.
10. **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 ### Gitignored-path guard on squash merges

View File

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

View File

@@ -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). | | `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). | | `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. | | `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. | | `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). | | `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. | | `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. |

View File

@@ -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. * 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 }, merger: { mode: "ai", maxReviewPasses: 3, allowDirtyLocalCheckoutSync: true },
mergeDiffVolumeMinLines: undefined,
mergeDiffVolumeThreshold: undefined,
mergeDiffVolumeAllowlist: undefined,
requiredChecks: undefined, requiredChecks: undefined,
mergeStrategyOverlapBehavior: "flip-to-prefer-branch", mergeStrategyOverlapBehavior: "flip-to-prefer-branch",
postMergeAuditMode: "warn", postMergeAuditMode: "warn",

View File

@@ -1684,12 +1684,6 @@ export interface ProjectSettings {
* When mode is "ai" (default), the standalone AI merge path is used and the * When mode is "ai" (default), the standalone AI merge path is used and the
* legacy merge settings above/below it do not apply. */ * legacy merge settings above/below it do not apply. */
merger?: MergerSettings; 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: * FNXC:PrMergeRequiredChecks 2026-08-09-06:39:
* Fusion honors these names independently of GitHub's isRequired flag. Empty preserves * Fusion honors these names independently of GitHub's isRequired flag. Empty preserves

View File

@@ -501,9 +501,6 @@ const NOT_SURFACED_ALLOWLIST: Record<string, string> = {
commitMsgHookEnabled: "not yet exposed as a distinct Settings field", commitMsgHookEnabled: "not yet exposed as a distinct Settings field",
autoResolveReviewComments: "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", 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", mergeAuditAutoRecovery: "not yet exposed as a distinct Settings field",
autoRecovery: "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", buildTimeoutMs: "not yet exposed as a distinct Settings field",

View File

@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
BranchConflictError, BranchConflictError,
DiffVolumeRegressionError,
MergeAbortedError, MergeAbortedError,
SquashAuditError, SquashAuditError,
type SquashAuditFindings, type SquashAuditFindings,
@@ -37,10 +36,6 @@ describe("engine public api barrel", () => {
recommendedAction: "Rebase the task branch.", recommendedAction: "Rebase the task branch.",
}), }),
}, },
{
name: "DiffVolumeRegressionError",
ctor: () => new DiffVolumeRegressionError([]),
},
{ {
name: "MergeAbortedError", name: "MergeAbortedError",
ctor: () => new MergeAbortedError("merge aborted"), ctor: () => new MergeAbortedError("merge aborted"),

View File

@@ -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, The capacity model is exactly two CONFIGURABLE numbers per project (total agents,
maxWorktrees) plus this one FIXED invariant. Merge is where the irreversible work maxWorktrees) plus this one FIXED invariant. Merge is where the irreversible work
happens — main advances, branches are deleted, worktrees are torn down — so every happens — main advances, branches are deleted, worktrees are torn down — so every
merge-safety guard in the repo (file-scope overlap, diff-volume shrinkage, merge-safety guard in the repo (file-scope overlap, post-squash audit,
post-squash audit, contamination auto-recovery) is written against the assumption contamination auto-recovery) is written against the assumption
that exactly one merge is in flight per project at a time. None of them are 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. concurrency-safe against a second merge advancing main underneath them.

View File

@@ -4,7 +4,6 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { FileScopeViolationError } from "../merge/merger-file-scope.js"; 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"; import { resolveRepoDeclaredScopeTransform } from "../merge/merger-ai-squash-gates.js";
const policy = vi.hoisted(() => vi.fn()); 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"); 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 () => { it("resets a recovered strict scope violation so a retry does not select it again", async () => {
setPolicy(); setPolicy();
const dir = createRepo((root) => writeFileSync(join(root, "outside.txt"), "outside\n")); 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"); })).rejects.toThrow("normal merge invoked");
expect(normalMerge).toHaveBeenCalledOnce(); 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);
});
}); });

View File

@@ -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<string>();
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<string>();
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<string>(),
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<string>(["README.md"]),
createMockStore(),
)).rejects.toBeInstanceOf(DiffVolumeRegressionError);
expect(git(dir, "git rev-parse HEAD")).toBe(preAttemptHeadSha);
expect(git(dir, "git status --short")).toBe("");
});
});

View File

@@ -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<Awaited<ReturnType<typeof makeReliabilityFixture>>> = [];
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();
});
});

View File

@@ -360,7 +360,6 @@ export {
dropAutostashBySha, dropAutostashBySha,
getAutostashDiff, getAutostashDiff,
notifyAutostashOrphans, notifyAutostashOrphans,
DiffVolumeRegressionError,
MergeAbortedError, MergeAbortedError,
SquashAuditError, SquashAuditError,
type MergerOptions, type MergerOptions,

View File

@@ -1,8 +1,7 @@
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { promisify } from "node:util"; 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 { 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 { createCommitRangeFilesReader, enforceSquashFileScopeInvariant, FileScopeViolationError } from "./merger-file-scope.js";
import type { RunAuditor } from "../util/run-audit.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<void>; repoRel?: string; repoKeys?: readonly string[] }): Promise<void> { 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<void>; repoRel?: string; repoKeys?: readonly string[] }): Promise<void> {
const resolver = params.repoRel ? resolveRepoDeclaredScopeTransform({ repoRel: params.repoRel, repoKeys: params.repoKeys ?? [] }) : undefined; const resolver = params.repoRel ? resolveRepoDeclaredScopeTransform({ repoRel: params.repoRel, repoKeys: params.repoKeys ?? [] }) : undefined;
const transform = resolver ? (scope: string[]) => resolver.transform(scope) : undefined; const transform = resolver ? (scope: string[]) => resolver.transform(scope) : undefined;
try { try {
@@ -53,14 +59,4 @@ export async function enforceAiMergeSquashGates(params: { store: TaskStore; task
await execFileAsync("git", ["clean", "-fd"], { cwd: params.mergeRoot }); await execFileAsync("git", ["clean", "-fd"], { cwd: params.mergeRoot });
throw error; 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;
}
} }

View File

@@ -317,7 +317,7 @@ async function recoverApprovedPreexistingAiMergeWorktree(
throwIfAborted(signal, taskId); throwIfAborted(signal, taskId);
if (!selected.alreadyLanded) { if (!selected.alreadyLanded) {
if (!task) throw new Error(`AI merge task ${taskId} disappeared before recovery squash gates`); 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({ const land = await landSquash({
projectRootDir: repoRootDir, projectRootDir: repoRootDir,
mergeRoot: selected.mergeRoot, mergeRoot: selected.mergeRoot,
@@ -1113,7 +1113,7 @@ export async function landOneRepo(
*/ */
const freshTask = await store.getTask(taskId); const freshTask = await store.getTask(taskId);
if (!freshTask) throw new Error(`AI merge task ${taskId} disappeared before squash gates`); 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 // 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. // move. A later reconciler can then settle an interrupted remote advance without re-squashing.

View File

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

View File

@@ -289,16 +289,6 @@ import {
type SquashAuditFindings, type SquashAuditFindings,
} from "./merge/merger-squash-audit.js"; } from "./merge/merger-squash-audit.js";
import { detectMergeOverlap, restoreBranchWinsFiles } from "./merge/merger-overlap-guard.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 { detectAlreadyLandedOnMain, type AlreadyMergedDetectionStrategy } from "./merge/already-merged-detector.js";
import { decideAutoPrerebase, probeDivergence, runAutoPrerebase } from "./merge/merger-auto-prerebase.js"; import { decideAutoPrerebase, probeDivergence, runAutoPrerebase } from "./merge/merger-auto-prerebase.js";
import { import {
@@ -318,7 +308,6 @@ import { advanceIntegrationBranchRef, IntegrationBranchConcurrentAdvanceError }
import { syncWorktreeToHead, type SyncWorktreeResult } from "./worktree/worktree-ref-sync.js"; import { syncWorktreeToHead, type SyncWorktreeResult } from "./worktree/worktree-ref-sync.js";
import { appendAutoWidenedScopeToPrompt, evaluateScopeAutoWiden } from "./merge/merger-scope-auto-widen.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"; export { IntegrationBranchConcurrentAdvanceError } from "./merge/merger-ref-update-advance.js";
/* /*
@@ -634,53 +623,6 @@ const MERGE_USER_COMMENTS_MAX_CHARS = 4000;
export const summarizeVerificationOutputLocal = summarizeVerificationOutput; export const summarizeVerificationOutputLocal = summarizeVerificationOutput;
async function resetToIntegrationTarget(rootDir: string, integrationTargetSha: string): Promise<void> {
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<void> {
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<string[]> { export async function getStagedFiles(cwd: string): Promise<string[]> {
try { try {
const { stdout } = await execAsync("git diff --cached --name-only", { const { stdout } = await execAsync("git diff --cached --name-only", {
@@ -3774,8 +3716,8 @@ export async function commitOrAmendMergeWithFixes(
preAttemptHeadSha: string, preAttemptHeadSha: string,
authorArg: string, authorArg: string,
diffStat?: string, diffStat?: string,
settings?: Settings, _settings?: Settings,
signal?: AbortSignal, _signal?: AbortSignal,
aiSummary?: string | null, aiSummary?: string | null,
aiBody?: string | null, aiBody?: string | null,
aiSubject?: string | null, aiSubject?: string | null,
@@ -4166,14 +4108,6 @@ export async function commitOrAmendMergeWithFixes(
auditor, auditor,
}); });
} }
await runDiffVolumeGate({
rootDir,
branch,
integrationTargetSha: preAttemptHeadSha,
taskId,
settings,
store,
});
await execAsync( await execAsync(
`git commit ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`, `git commit ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`,
{ cwd: rootDir, env: mergerCommitEnv() }, { cwd: rootDir, env: mergerCommitEnv() },
@@ -4198,14 +4132,6 @@ export async function commitOrAmendMergeWithFixes(
auditor, auditor,
}); });
} }
await runDiffVolumeGate({
rootDir,
branch,
integrationTargetSha: preAttemptHeadSha,
taskId,
settings,
store,
});
await execAsync( await execAsync(
`git commit --amend ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`, `git commit --amend ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`,
{ cwd: rootDir, env: mergerCommitEnv() }, { cwd: rootDir, env: mergerCommitEnv() },
@@ -4216,7 +4142,7 @@ export async function commitOrAmendMergeWithFixes(
mergerLog.log(`${taskId}: amended merge commit with verification fixes (deterministic message)`); mergerLog.log(`${taskId}: amended merge commit with verification fixes (deterministic message)`);
return { ok: true, reason: "committed" }; return { ok: true, reason: "committed" };
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof DiffVolumeRegressionError || err instanceof FileScopeViolationError) { if (err instanceof FileScopeViolationError) {
throw err; throw err;
} }
const errorMessage = err instanceof Error ? err.message : String(err); const errorMessage = err instanceof Error ? err.message : String(err);
@@ -8898,11 +8824,7 @@ export async function aiMergeTask(
throw error; throw error;
} }
if ( if (error?.name === "FileScopeViolationError") {
error instanceof DiffVolumeRegressionError
|| error?.name === "DiffVolumeRegressionError"
|| error?.name === "FileScopeViolationError"
) {
throw error; throw error;
} }
@@ -10451,14 +10373,6 @@ export async function executeMergeAttempt(
resetLabel: "file-scope invariant violation", resetLabel: "file-scope invariant violation",
auditor: params.auditor, auditor: params.auditor,
}); });
await runDiffVolumeGate({
rootDir,
branch,
integrationTargetSha: params.preAttemptHeadSha || "HEAD",
taskId,
settings,
store,
});
await execAsync( await execAsync(
`git commit ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`, `git commit ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`,
{ cwd: rootDir, env: mergerCommitEnv() }, { cwd: rootDir, env: mergerCommitEnv() },
@@ -10738,7 +10652,6 @@ export async function executeMergeAttempt(
// verification failure anyway — there are no conflicts to resolve. // verification failure anyway — there are no conflicts to resolve.
if ( if (
error?.name === "VerificationError" error?.name === "VerificationError"
|| error?.name === "DiffVolumeRegressionError"
|| error?.name === "FileScopeViolationError" || error?.name === "FileScopeViolationError"
) { ) {
throw error; throw error;
@@ -10788,7 +10701,7 @@ export async function attemptWithSideStrategy(
return finalizeSideStrategyAttempt(params, side, aiTracker); return finalizeSideStrategyAttempt(params, side, aiTracker);
} catch (error) { } 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; throw error;
} }
mergerLog.error(`${taskId}: -X ${side} merge failed: ${error}`); mergerLog.error(`${taskId}: -X ${side} merge failed: ${error}`);
@@ -10829,7 +10742,7 @@ async function attemptWithMixedSideStrategy(
return finalizeSideStrategyAttempt(params, strategy.defaultSide, aiTracker); return finalizeSideStrategyAttempt(params, strategy.defaultSide, aiTracker);
} catch (error) { } 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; throw error;
} }
mergerLog.error(`${taskId}: overlap-aware merge failed: ${error}`); mergerLog.error(`${taskId}: overlap-aware merge failed: ${error}`);
@@ -10898,14 +10811,6 @@ async function finalizeSideStrategyAttempt(
resetLabel: "file-scope invariant violation", resetLabel: "file-scope invariant violation",
auditor: params.auditor, auditor: params.auditor,
}); });
await runDiffVolumeGate({
rootDir,
branch,
integrationTargetSha: params.preAttemptHeadSha || "HEAD",
taskId,
settings,
store,
});
await execAsync( await execAsync(
`git commit ${subjectArg} ${bodyArg}${issueRefBodyArg}${trailerArg}${authorArg}`, `git commit ${subjectArg} ${bodyArg}${issueRefBodyArg}${trailerArg}${authorArg}`,
{ cwd: rootDir, env: mergerCommitEnv() }, { cwd: rootDir, env: mergerCommitEnv() },
@@ -11005,7 +10910,6 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
testCommand, testCommand,
buildCommand, buildCommand,
preMergeRebaseFallthrough, preMergeRebaseFallthrough,
preAttemptHeadSha,
} = params; } = params;
// Merge per-task effective workflow settings (U3, KTD-3) — this worker re-fetches // 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, aiBody: aiBody?.trim().length ? aiBody : safeBody,
aiSubject, aiSubject,
}); });
await runDiffVolumeGate({
rootDir,
branch,
integrationTargetSha: preAttemptHeadSha || "HEAD",
taskId,
settings,
store,
});
await execAsync( await execAsync(
`git commit ${subjectArg} ${bodyArg}${issueRefBodyArg}${trailerArg}${authorArg}`, `git commit ${subjectArg} ${bodyArg}${issueRefBodyArg}${trailerArg}${authorArg}`,
{ cwd: rootDir, env: mergerCommitEnv() }, { cwd: rootDir, env: mergerCommitEnv() },

View File

@@ -190,7 +190,6 @@ export type GitMutationType =
| "merge:resolve" | "merge:resolve"
| "merge:file-scope-violation" | "merge:file-scope-violation"
| "merge:file-scope-enforcement-disabled" | "merge:file-scope-enforcement-disabled"
| "merge:diff-volume-blocked"
// FNXC:MergerUnification 2026-08-09-12:04: Legacy-only audit events emitted // FNXC:MergerUnification 2026-08-09-12:04: Legacy-only audit events emitted
// exclusively by soft-deprecated aiMergeTask, never by production runAiMerge. // exclusively by soft-deprecated aiMergeTask, never by production runAiMerge.
| "merge:auto-prerebase:applied" | "merge:auto-prerebase:applied"