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:
7
.changeset/remove-diff-volume-gate.md
Normal file
7
.changeset/remove-diff-volume-gate.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -360,7 +360,6 @@ export {
|
|||||||
dropAutostashBySha,
|
dropAutostashBySha,
|
||||||
getAutostashDiff,
|
getAutostashDiff,
|
||||||
notifyAutostashOrphans,
|
notifyAutostashOrphans,
|
||||||
DiffVolumeRegressionError,
|
|
||||||
MergeAbortedError,
|
MergeAbortedError,
|
||||||
SquashAuditError,
|
SquashAuditError,
|
||||||
type MergerOptions,
|
type MergerOptions,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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() },
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user