FN-9050: enforce AI squash scope and diff-volume gates

Validate approved AI squashes before any integration ref advances.

- Enforce task file scope and diff-volume checks for single-repo and workspace lands.
- Reset rejected clean rooms to the integration tip so invalid approved squashes cannot be recovered on retry.
- Add audit events, documentation, a changeset, and regression coverage for land gates.

Files changed:
 .changeset/fn-9050-ai-merge-scope-gates.md         |   7 +
 docs/architecture.md                               |   2 +
 docs/workflow-steps.md                             |   4 +-
 .../src/__tests__/merger-ai-squash-gates.test.ts   | 188 +++++++++++++++++++++
 .../__tests__/merger-file-scope-invariant.test.ts  |  29 ++++
 .../__tests__/workspace-merger-scope-gates.test.ts | 118 +++++++++++++
 .../engine/src/merge/merger-ai-squash-gates.ts     |  66 ++++++++
 packages/engine/src/merge/merger-ai.ts             |  21 ++-
 .../engine/src/merge/merger-diff-volume-gate.ts    |  14 +-
 packages/engine/src/merge/merger-file-scope.ts     |  33 +++-
 packages/engine/src/util/run-audit.ts              |   1 +
 11 files changed, 474 insertions(+), 9 deletions(-)

Fusion-Task-Id: FN-9050

Fusion-Task-Lineage: 9ecd8b8c-5c00-45ea-98e9-2aa8a8040c53

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-14 23:42:52 -07:00
parent 6896f1d7f3
commit 4bc0a32d60
11 changed files with 474 additions and 9 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Block unsafe AI squash merges before they reach integration branches.
category: fix
dev: Enforces file-scope and diff-volume guards at the unified landOneRepo seam.

View File

@@ -2357,6 +2357,8 @@ Reliability-layer changes are in scope. Interaction regression backstops live in
- FN-5256 backstop: `packages/engine/src/__tests__/reliability-interactions/dependency-cycle-reconcile.test.ts` covers persisted dependency-cycle detection via `reconcileDependencyCycles`, bounded umbrella-back-edge auto-repair, ambiguous-cycle observe-only behavior, composition ordering with `reconcileSelfDefeatingDependencies`, and the post-sweep write-time guard invariant. Core write-boundary regressions (FN-5240/5241/5242 signature, indirect cycle, umbrella back-edge rejection) live in `packages/core/src/__tests__/store-dependency-cycle.test.ts`.
- FN-5223 backstop: `packages/engine/src/__tests__/reliability-interactions/engine-active-since-floor.test.ts` covers engine-activation floor + grace composition across startup, pause/unpause, global-pause gating, and StuckTaskDetector lifecycle interactions.
**Unified squash gates (FN-9050).** `landOneRepo` validates every approved clean-room squash before advancing an integration ref, for both single-repository lands and each workspace sub-repository land. Workspace checks use that repository's File Scope subset; a violation leaves the integration ref unchanged. The diff-volume guard emits `merge:diff-volume-blocked` with task/repository IDs, commit IDs, finding count, and paths only.
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.
### Concurrent soft-delete heartbeat races (FN-8004)

View File

@@ -687,10 +687,10 @@ By default this split-and-fork behavior is enabled through the project setting `
<!--
FNXC:WorkflowScopeLeak 2026-06-26-15:00:
KNOWN FOLLOW-UP: the FN-4343 per-step end-of-step scope-leak invariant (and its `workflowStepScopeEnforcement` setting) has NOT yet been replicated on the graph-native optional-group execution path. The setting is still declared and round-trips, but the graph executor does not run the per-step invariant. Merge-time File Scope enforcement (`FileScopeViolationError`, squash overlap) is a separate gate and is unaffected.
KNOWN FOLLOW-UP: the FN-4343 per-step end-of-step scope-leak invariant (and its `workflowStepScopeEnforcement` setting) has NOT yet been replicated on the graph-native optional-group execution path. The setting is still declared and round-trips, but the graph executor does not run the per-step invariant. Merge-time File Scope enforcement is a separate gate in the unified AI-merge `landOneRepo` seam; it evaluates the approved clean-room squash before the integration ref advances.
-->
> **Known follow-up (not yet on the graph path):** The original FN-4343 per-step invariant ran after each successful prompt-mode pre-merge workflow step under the legacy `runWorkflowSteps` loop. That loop was deleted in the graph-native cutover, and this per-step invariant has **not yet been replicated** on the optional-group graph path. The `workflowStepScopeEnforcement` setting (`"block"` / `"warn"` / `"off"`, default `"block"`) is still declared and round-trips, but the graph executor does not currently enforce it per step. Merge-time File Scope enforcement (`FileScopeViolationError` and squash/file-scope overlap) is a separate gate and is **unaffected** — off-scope writes are still caught at merge.
> **Known follow-up (not yet on the graph path):** The original FN-4343 per-step invariant ran after each successful prompt-mode pre-merge workflow step under the legacy `runWorkflowSteps` loop. That loop was deleted in the graph-native cutover, and this per-step invariant has **not yet been replicated** on the optional-group graph path. The `workflowStepScopeEnforcement` setting (`"block"` / `"warn"` / `"off"`, default `"block"`) is still declared and round-trips, but the graph executor does not currently enforce it per step. Merge-time File Scope enforcement (`FileScopeViolationError` and squash/file-scope overlap) runs at unified AI-merge `landOneRepo` before landing, so off-scope writes are still caught at merge.
The original (legacy) invariant evaluated files newly touched by a prompt-mode pre-merge step (committed delta plus uncommitted working-tree edits):

View File

@@ -0,0 +1,188 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { execSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { FileScopeViolationError } from "../merge/merger-file-scope.js";
import { DiffVolumeRegressionError } from "../merge/merger-diff-volume-gate.js";
import { resolveRepoDeclaredScopeTransform } from "../merge/merger-ai-squash-gates.js";
const policy = vi.hoisted(() => vi.fn());
vi.mock("../merge/merge-trait.js", () => ({ resolveMergePolicy: policy }));
import { resolveAiMergeRoot, runAiMerge } from "../merge/merger-ai.js";
const dirs: string[] = [];
afterEach(() => {
policy.mockReset();
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
function git(cwd: string, args: string): string {
return execSync(`git ${args}`, { cwd, encoding: "utf8" }).trim();
}
function createRepo(change: (dir: string) => void): string {
const dir = mkdtempSync(join(tmpdir(), "fusion-ai-squash-gates-"));
dirs.push(dir);
git(dir, "init -q -b main");
git(dir, "config user.email test@example.com");
git(dir, "config user.name Test");
writeFileSync(join(dir, "base.txt"), "base\n");
git(dir, "add -A && git commit -q -m base");
git(dir, "checkout -q -b fusion/fn-9050");
change(dir);
git(dir, "add -A && git commit -q -m feature");
git(dir, "checkout -q main");
return dir;
}
function makeStore(scope: string[], overrides: Record<string, unknown> = {}) {
const task: any = {
id: "FN-9050", title: "squash gates", column: "in-review", branch: "fusion/fn-9050",
comments: [], steeringComments: [], steps: [], log: [], ...overrides,
};
const store: any = {
getTask: vi.fn(async () => task),
getSettings: vi.fn(async () => ({ merger: { mode: "ai", maxReviewPasses: 0 } })),
parseFileScopeFromPrompt: vi.fn(async () => scope),
updateTask: vi.fn(async (_id: string, patch: object) => Object.assign(task, patch)),
moveTask: vi.fn(async (_id: string, column: string) => Object.assign(task, { column })),
appendAgentLog: vi.fn(async () => undefined),
logEntry: vi.fn(async () => undefined),
emit: vi.fn(),
recordRunAuditEvent: vi.fn(async () => undefined),
upsertTaskCommitAssociation: vi.fn(async () => undefined),
accumulateTokenUsage: vi.fn(async () => undefined),
};
return { store, task };
}
function squashAgent(branch: string, mutate?: (cwd: string) => void) {
return async (cwd: string): Promise<void> => {
git(cwd, `merge --squash ${branch}`);
mutate?.(cwd);
git(cwd, "add -A && git commit -q -m squash");
};
}
const approve = async () => "REVIEW_VERDICT: approve";
function setPolicy(mode: "strict" | "warn" | "off" = "strict"): void {
policy.mockResolvedValue({ fileScope: mode, fileScopeRules: [] });
}
describe("resolveRepoDeclaredScopeTransform", () => {
it("derives repo-local paths and keeps unprefixed scopes as a fallback", () => {
const scoped = resolveRepoDeclaredScopeTransform({ repoRel: "apps/web", repoKeys: ["apps", "apps/web", "api"] });
expect(scoped.transform(["./apps/web/src/**", "api/src/**"])).toEqual(["src/**"]);
expect(scoped.describe(["./apps/web/src/**", "api/src/**"])).toBe("repo-subset");
expect(scoped.transform(["src/**"])).toEqual(["src/**"]);
expect(scoped.describe(["src/**"])).toBe("unprefixed-fallback");
});
it("identifies declarations owned solely by another repository", () => {
const transform = resolveRepoDeclaredScopeTransform({ repoRel: "repo-b", repoKeys: ["repo-a", "repo-b"] });
expect(transform.transform(["repo-a/src/**"])).toEqual([]);
expect(transform.describe(["repo-a/src/**"])).toBe("foreign-repo-only");
});
});
describe("runAiMerge approved-squash gates", () => {
it("blocks a strict out-of-scope squash before main advances and records the violation", async () => {
setPolicy();
const dir = createRepo((root) => writeFileSync(join(root, "outside.txt"), "outside\n"));
const before = git(dir, "rev-parse main");
const { store } = makeStore(["allowed/**"]);
await expect(runAiMerge(store, dir, "FN-9050", { manual: true }, {
mergeAgent: squashAgent("fusion/fn-9050"), reviewAgent: approve,
})).rejects.toBeInstanceOf(FileScopeViolationError);
expect(git(dir, "rev-parse main")).toBe(before);
expect(store.recordRunAuditEvent.mock.calls.some(([event]: any[]) => event.mutationType === "merge:file-scope-violation")).toBe(true);
});
it.each([
["warn", false, "merge:file-scope-violation"],
["off", false, "merge:file-scope-enforcement-disabled"],
["strict", true, "merge:ai-landed"],
] as const)("preserves %s and scopeOverride file-scope behavior", async (mode, scopeOverride, auditType) => {
setPolicy(mode);
const dir = createRepo((root) => writeFileSync(join(root, "outside.txt"), "outside\n"));
const { store } = makeStore(["allowed/**"], scopeOverride ? { scopeOverride: true } : {});
const result = await runAiMerge(store, dir, "FN-9050", { manual: true }, {
mergeAgent: squashAgent("fusion/fn-9050"), reviewAgent: approve,
});
expect(result.merged).toBe(true);
expect(store.recordRunAuditEvent.mock.calls.some(([event]: any[]) => event.mutationType === auditType)).toBe(true);
if (scopeOverride) expect(store.appendAgentLog).toHaveBeenCalledWith("FN-9050", expect.stringContaining("scopeOverride"), "status", undefined, "merger");
});
it("blocks a committed shrinkage range before main advances and emits the diff-volume audit", async () => {
setPolicy();
const dir = createRepo((root) => writeFileSync(join(root, "large.ts"), Array.from({ length: 100 }, (_, i) => `line-${i}`).join("\n") + "\n"));
const before = git(dir, "rev-parse main");
const { store } = makeStore(["large.ts"]);
await expect(runAiMerge(store, dir, "FN-9050", { manual: true }, {
mergeAgent: squashAgent("fusion/fn-9050", (cwd) => writeFileSync(join(cwd, "large.ts"), "kept\n")),
reviewAgent: approve,
})).rejects.toBeInstanceOf(DiffVolumeRegressionError);
expect(git(dir, "rev-parse main")).toBe(before);
expect(store.recordRunAuditEvent.mock.calls.some(([event]: any[]) => event.mutationType === "merge:diff-volume-blocked")).toBe(true);
});
it("resets a recovered strict scope violation so a retry does not select it again", async () => {
setPolicy();
const dir = createRepo((root) => writeFileSync(join(root, "outside.txt"), "outside\n"));
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");
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(["allowed/**"]);
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(FileScopeViolationError);
expect(git(cleanRoom, "rev-parse HEAD")).toBe(before);
task.status = null;
const normalMerge = vi.fn(async () => { throw new Error("normal merge invoked"); });
await expect(runAiMerge(store, dir, "FN-9050", { manual: true }, {
mergeAgent: normalMerge, reviewAgent: approve,
})).rejects.toThrow("normal merge invoked");
expect(normalMerge).toHaveBeenCalledOnce();
});
it("uses the task branch, not clean-room HEAD, when recovery gates a shrinkage squash", async () => {
setPolicy();
const dir = createRepo((root) => writeFileSync(join(root, "large.ts"), Array.from({ length: 100 }, (_, i) => `line-${i}`).join("\n") + "\n"));
const before = git(dir, "rev-parse main");
const cleanRoomParent = resolveAiMergeRoot(dir);
mkdirSync(cleanRoomParent, { recursive: true });
const cleanRoom = mkdtempSync(join(cleanRoomParent, "fusion-ai-merge-fn-9050-"));
git(dir, `worktree add --detach ${cleanRoom} ${before}`);
git(cleanRoom, "merge --squash fusion/fn-9050");
writeFileSync(join(cleanRoom, "large.ts"), "kept\n");
git(cleanRoom, "add -A && git commit -q -m squash -m 'Fusion-Task-Id: FN-9050'");
const squashSha = git(cleanRoom, "rev-parse HEAD");
const { store, task } = makeStore(["large.ts"]);
task.log = [{ action: `AI merge review (pass 1): approved squash ${squashSha}`, timestamp: new Date().toISOString() }];
await expect(runAiMerge(store, dir, "FN-9050", { manual: true }, {
mergeAgent: async () => { throw new Error("recovery should not re-merge"); }, reviewAgent: approve,
})).rejects.toBeInstanceOf(DiffVolumeRegressionError);
expect(git(dir, "rev-parse main")).toBe(before);
expect(store.recordRunAuditEvent.mock.calls.some(([event]: any[]) => event.mutationType === "merge:diff-volume-blocked")).toBe(true);
});
});

View File

@@ -507,3 +507,32 @@ describe("file-scope invariant wiring", () => {
expect(mockedExecSync).not.toHaveBeenCalledWith("git reset --merge", expect.objectContaining({ cwd: "/tmp/root" }));
});
});
describe("scope transform seam", () => {
it("evaluates the transformed workspace-local scope after resolving prompt scope", async () => {
const store = createInvariantStore(["repo-a/src/**"]);
mockStagedFiles(["src/index.ts"]);
await expect(assertSquashOverlapsFileScope({
store: store as never,
taskId: "FN-9050",
rootDir: "/tmp/root",
stagedFilesReader,
task: await (store as any).getTask("FN-4073"),
scopeTransform: (scope) => scope.map((entry) => entry.replace("repo-a/", "")),
})).resolves.toBeUndefined();
});
it("does not invoke the transform when scopeOverride bypasses enforcement", async () => {
const store = createInvariantStore(["repo-a/src/**"], { scopeOverride: true });
const transform = vi.fn((scope: string[]) => scope);
await expect(assertSquashOverlapsFileScope({
store: store as never,
taskId: "FN-9050",
rootDir: "/tmp/root",
stagedFilesReader,
task: await (store as any).getTask("FN-4073"),
scopeTransform: transform,
})).resolves.toBeUndefined();
expect(transform).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,118 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import { execSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { Task, TaskStore } from "@fusion/core";
import { landWorkspaceTask } from "../merge/merger-ai.js";
import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js";
const policy = vi.hoisted(() => vi.fn());
vi.mock("../merge/merge-trait.js", () => ({ resolveMergePolicy: policy }));
const describeIfGit = hasGit ? describe : describe.skip;
const TASK_ID = "FN-9050";
const BRANCH = "fusion/fn-9050";
function addBranch(fx: WorkspaceFixture, repo: string, file = "feature.txt"): void {
const root = fx.repoPath(repo);
const worktree = join(root, ".fn-9050-scope");
fx.git(repo, `git worktree add -b ${BRANCH} ${worktree} HEAD`);
execSync('git config user.email "test@example.com" && git config user.name Test', { cwd: worktree, stdio: "pipe" });
mkdirSync(join(worktree, file, ".."), { recursive: true });
writeFileSync(join(worktree, file), `${repo}\n`);
execSync("git add -A && git commit -q -m feature", { cwd: worktree, stdio: "pipe" });
fx.git(repo, `git worktree remove --force ${worktree}`);
}
function storeFor(task: Task, scope: string[]): TaskStore & { updates: Array<Record<string, unknown>>; audit: any[] } {
const emitter = new EventEmitter();
const updates: Array<Record<string, unknown>> = [];
const audit: any[] = [];
return Object.assign(emitter, {
updates, audit,
getTask: vi.fn(async () => task),
getSettings: vi.fn(async () => ({ autoMerge: false, merger: { mode: "ai", maxReviewPasses: 0 } })),
parseFileScopeFromPrompt: vi.fn(async () => scope),
updateTask: vi.fn(async (_id: string, patch: Record<string, unknown>) => { updates.push(patch); Object.assign(task, patch); return task; }),
appendAgentLog: vi.fn(async () => undefined),
logEntry: vi.fn(async () => undefined),
moveTask: vi.fn(async () => task),
upsertTaskCommitAssociation: vi.fn(async () => undefined),
accumulateTokenUsage: vi.fn(async () => undefined),
recordRunAuditEvent: vi.fn(async (event: unknown) => { audit.push(event); }),
}) as unknown as TaskStore & { updates: Array<Record<string, unknown>>; audit: any[] };
}
function squashAgent(branch: string) {
return async (cwd: string): Promise<void> => {
execSync(`git merge --squash ${branch}`, { cwd, stdio: "pipe" });
execSync("git add -A && git commit -q -m squash", { cwd, stdio: "pipe" });
};
}
/**
* FNXC:AIMerge 2026-08-15-05:36:
* Workspace lands must check each clean-room range against that repository's
* local File Scope subset, so a sibling repository cannot consume its scope.
*/
describeIfGit("landWorkspaceTask file-scope gates", () => {
let fx: WorkspaceFixture;
afterEach(() => {
policy.mockReset();
fx?.cleanup();
});
it("lands the declared repo then blocks the foreign-only repo without advancing its integration ref", async () => {
policy.mockResolvedValue({ fileScope: "strict", fileScopeRules: [] });
fx = await createWorkspaceFixture(["repo-a", "repo-b"]);
addBranch(fx, "repo-a");
/*
* FNXC:AIMerge 2026-08-15-05:50:
* A local sibling-prefixed name proves repo-b cannot borrow repo-a's
* declaration through the normal repo-local path matcher.
*/
addBranch(fx, "repo-b", "repo-a/feature.txt");
const task = {
id: TASK_ID, title: "workspace scope", description: "", column: "in-review", branch: BRANCH,
comments: [], steeringComments: [], dependencies: [], steps: [], log: [], currentStep: 0,
workspaceWorktrees: {
"repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH },
"repo-b": { worktreePath: fx.repoPath("repo-b"), branch: BRANCH },
},
} as Task;
const store = storeFor(task, ["repo-a/feature.txt"]);
const beforeA = fx.git("repo-a", "git rev-parse main");
const beforeB = fx.git("repo-b", "git rev-parse main");
const result = await landWorkspaceTask(store, task, fx.rootDir, {}, {
mergeAgent: squashAgent(BRANCH), reviewAgent: async () => "REVIEW_VERDICT: approve",
});
expect(result.allLanded).toBe(false);
expect(result.repos.find((repo) => repo.repo === "repo-a")?.status).toBe("landed");
expect(result.repos.find((repo) => repo.repo === "repo-b")?.status).toBe("failed");
expect(fx.git("repo-a", "git rev-parse main")).not.toBe(beforeA);
expect(fx.git("repo-b", "git rev-parse main")).toBe(beforeB);
expect(store.audit.some((event) => event.mutationType === "merge:file-scope-violation")).toBe(true);
});
it("uses unprefixed scope as a repo-local fallback instead of blocking every workspace repo", async () => {
policy.mockResolvedValue({ fileScope: "strict", fileScopeRules: [] });
fx = await createWorkspaceFixture(["repo-a"]);
addBranch(fx, "repo-a");
const task = {
id: TASK_ID, title: "workspace scope", description: "", column: "in-review", branch: BRANCH,
comments: [], steeringComments: [], dependencies: [], steps: [], log: [], currentStep: 0,
workspaceWorktrees: { "repo-a": { worktreePath: fx.repoPath("repo-a"), branch: BRANCH } },
} as Task;
const store = storeFor(task, ["feature.txt"]);
const result = await landWorkspaceTask(store, task, fx.rootDir, {}, {
mergeAgent: squashAgent(BRANCH), reviewAgent: async () => "REVIEW_VERDICT: approve",
});
expect(result.allLanded).toBe(true);
expect(result.repos[0]?.status).toBe("landed");
});
});

View File

@@ -0,0 +1,66 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { Settings, Task, TaskStore } from "@fusion/core";
import { deriveRepoForPath, deriveRepoScopeSubset, UNSCOPED_REPO } from "../worktree/workspace-paths.js";
import { checkDiffVolume, DiffVolumeRegressionError, formatDiffVolumeFindings, resolveDiffVolumeGateSettings } from "./merger-diff-volume-gate.js";
import { createCommitRangeFilesReader, enforceSquashFileScopeInvariant, FileScopeViolationError } from "./merger-file-scope.js";
import type { RunAuditor } from "../util/run-audit.js";
const execFileAsync = promisify(execFile);
export function resolveRepoDeclaredScopeTransform({ repoRel, repoKeys }: { repoRel: string; repoKeys: readonly string[] }) {
return {
transform(scope: string[]): string[] {
const subset = deriveRepoScopeSubset(scope, repoRel);
if (subset.length) return subset;
return scope.some((entry) => deriveRepoForPath(entry, repoKeys) !== UNSCOPED_REPO) ? [] : scope;
},
describe(scope: string[]): "repo-subset" | "unprefixed-fallback" | "foreign-repo-only" {
if (deriveRepoScopeSubset(scope, repoRel).length) return "repo-subset";
return scope.some((entry) => deriveRepoForPath(entry, repoKeys) !== UNSCOPED_REPO) ? "foreign-repo-only" : "unprefixed-fallback";
},
};
}
/** 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> {
const resolver = params.repoRel ? resolveRepoDeclaredScopeTransform({ repoRel: params.repoRel, repoKeys: params.repoKeys ?? [] }) : undefined;
const transform = resolver ? (scope: string[]) => resolver.transform(scope) : undefined;
try {
await enforceSquashFileScopeInvariant({
store: params.store,
taskId: params.taskId,
rootDir: params.mergeRoot,
task: params.task,
resetLabel: "ai-merge file-scope invariant violation",
auditor: params.audit,
stagedFilesReader: createCommitRangeFilesReader(params.tipSha, params.squashSha),
scopeTransform: transform,
// FNXC:AIMerge 2026-08-15-05:50:
// A foreign-only workspace declaration is an invariant violation, not an
// empty scope: repo-b/`repo-a/feature.txt` must not borrow repo-a's scope.
forceViolation: resolver ? (scope) => resolver.describe(scope) === "foreign-repo-only" : undefined,
});
} catch (error) {
if (!(error instanceof FileScopeViolationError)) throw error;
/*
FNXC:AIMergeRecovery 2026-08-15-06:37:
A rejected approved squash must reset its clean room to the integration tip.
Preexisting-clean-room recovery discovers candidates by HEAD, so leaving the
rejected commit in place would make each retry select and reject it again.
*/
await execFileAsync("git", ["reset", "--hard", params.tipSha], { cwd: params.mergeRoot });
await execFileAsync("git", ["clean", "-fd"], { cwd: params.mergeRoot });
throw error;
}
try {
await checkDiffVolume({ rootDir: params.mergeRoot, branch: params.branch, integrationTargetSha: params.tipSha, squashRange: { fromSha: params.tipSha, toSha: params.squashSha }, ...resolveDiffVolumeGateSettings(params.settings) });
} catch (error) {
if (!(error instanceof DiffVolumeRegressionError)) throw error;
await execFileAsync("git", ["reset", "--hard", params.tipSha], { cwd: params.mergeRoot });
await execFileAsync("git", ["clean", "-fd"], { cwd: params.mergeRoot });
await params.store.appendAgentLog(params.taskId, "AI merge diff-volume gate blocked the approved squash", "tool_error", formatDiffVolumeFindings(error.findings), "merger");
await params.audit.git({ type: "merge:diff-volume-blocked", target: params.taskId, metadata: { taskId: params.taskId, repo: params.repoRel, tipSha: params.tipSha, squashSha: params.squashSha, findingCount: error.findings.length, files: error.findings.map((finding) => finding.file) } });
throw error;
}
}

View File

@@ -66,6 +66,7 @@ import { selectUserCommentsForAgentContext } from "../agents/agent-user-comments
import { resolveTaskWorkingBranch } from "../worktree/worktree-names.js";
import { resolveIntegrationBranch } from "./integration-branch.js";
import { advanceIntegrationBranchRef } from "./merger-ref-update-advance.js";
import { enforceAiMergeSquashGates } from "./merger-ai-squash-gates.js";
import {
assertMergeGenerationOwned,
createMergeWriteFence,
@@ -259,6 +260,7 @@ function listAiMergeWorktreeCandidates(taskId: string, projectRootDir: string, s
async function recoverApprovedPreexistingAiMergeWorktree(
repoRootDir: string,
branch: string,
integrationBranch: string,
ctx: LandRepoContext,
): Promise<LandOneRepoResult | null> {
@@ -307,6 +309,8 @@ async function recoverApprovedPreexistingAiMergeWorktree(
const selected = recoverableCandidates[0];
throwIfAborted(signal, taskId);
if (!selected.alreadyLanded) {
if (!task) throw new Error(`AI merge task ${taskId} disappeared before recovery squash gates`);
await enforceAiMergeSquashGates({ store, task, taskId, mergeRoot: selected.mergeRoot, branch, tipSha: selected.tipSha, squashSha: selected.squashSha, settings, audit, log, repoRel: ctx.repoRel, repoKeys: ctx.repoKeys });
const land = await landSquash({
projectRootDir: repoRootDir,
mergeRoot: selected.mergeRoot,
@@ -806,6 +810,9 @@ export interface LandRepoContext {
off, preserving the documented hard-fail for the single-repo land path.
*/
nonFatalDependencySync?: boolean;
/** Workspace repo-local File Scope context; omitted for single-repo lands. */
repoRel?: string;
repoKeys?: readonly string[];
/*
FNXC:MergeNoCommits 2026-07-17-12:00:
When true, the task is expected to produce no code changes (audit, documentation, decision-only).
@@ -859,7 +866,7 @@ export async function landOneRepo(
// If a prior merger died after the clean-room squash was approved but before
// landing/finalization, land that commit before the normal pre-merge prune can
// delete the only easy reference to it.
const recovered = await recoverApprovedPreexistingAiMergeWorktree(repoRootDir, integrationBranch, ctx);
const recovered = await recoverApprovedPreexistingAiMergeWorktree(repoRootDir, branch, integrationBranch, ctx);
if (recovered) return recovered;
// Pre-merge prune is rooted at THIS sub-repo (KTD1): N per-repo clean rooms for
@@ -1065,6 +1072,16 @@ export async function landOneRepo(
return { outcome: "empty", tipSha, integrationBranch };
}
/*
* FNXC:AIMerge 2026-08-19-00:00:
* This is the sole production pre-land seam: the reviewer approved the
* clean-room squash but the integration ref has not advanced, so scope and
* shrinkage violations can still leave every integration branch untouched.
*/
const freshTask = await store.getTask(taskId);
if (!freshTask) throw new Error(`AI merge task ${taskId} disappeared before squash gates`);
await enforceAiMergeSquashGates({ store, task: freshTask, taskId, mergeRoot, branch, tipSha, squashSha, settings, audit, log, repoRel: ctx.repoRel, repoKeys: ctx.repoKeys });
// 4 + 5. Land the squash on the target branch and sync the user's
// checkout (AI reconciles a conflicting restore).
await setStatus("landing");
@@ -2072,6 +2089,8 @@ export async function landWorkspaceTask(
nonFatalDependencySync: true,
// FNXC:MergeNoCommits 2026-07-17-12:00: no-commits tasks skip dependency sync in the clean room
noCommitsExpected: task.noCommitsExpected === true,
repoRel,
repoKeys,
store,
});
if (landResult.outcome === "landed") {

View File

@@ -28,6 +28,8 @@ interface CheckDiffVolumeParams {
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 {
@@ -71,6 +73,7 @@ export async function checkDiffVolume({
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}`]);
@@ -89,9 +92,14 @@ export async function checkDiffVolume({
);
if (branchNet <= minLines) continue;
const staged = parseNumstatTotal(
await execGit(rootDir, ["diff", "--cached", "--numstat", "--", file]),
);
/*
* FNXC:AIMerge 2026-08-19-00:00:
* 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 });

View File

@@ -141,6 +141,18 @@ export class FileScopeViolationError extends Error {
export type StagedFilesReader = (cwd: string) => Promise<string[]>;
/**
* FNXC:AIMerge 2026-08-19-00:00:
* Unified AI merges approve a committed clean-room squash, so their file list
* must be read from the approved commit range rather than the empty index.
*/
export function createCommitRangeFilesReader(fromSha: string, toSha: string): StagedFilesReader {
return async (cwd) => {
const { stdout } = await execAsync(`git diff --name-only ${fromSha}..${toSha}`, { cwd, encoding: "utf-8" });
return stdout.split("\n").map((line) => line.trim()).filter(Boolean);
};
}
async function readStagedFileNames(cwd: string): Promise<string[]> {
const { stdout } = await execAsync("git diff --cached --name-only", {
cwd,
@@ -162,6 +174,15 @@ export async function assertSquashOverlapsFileScope(params: {
* declared scope. `scopeOverride` is a documented no-op only under
* `fileScope: "off"` (handled by the caller, which skips this assert). */
customScopeRules?: string[];
/** Optional production transform for workspace repo-local scope evaluation. */
scopeTransform?: (scope: string[]) => string[];
/**
* FNXC:AIMerge 2026-08-15-05:50:
* A workspace repo with declarations owned only by sibling repos must fail even
* when a local file repeats a sibling-prefixed name. Preserve the original
* declared scope in the violation so operators can correct the task contract.
*/
forceViolation?: (resolvedScope: string[]) => boolean;
}): Promise<void> {
const { store, taskId, rootDir, task, customScopeRules, stagedFilesReader = readStagedFileNames } = params;
const hasCustomRules = Array.isArray(customScopeRules) && customScopeRules.length > 0;
@@ -190,14 +211,18 @@ export async function assertSquashOverlapsFileScope(params: {
}
declaredScope = await store.parseFileScopeFromPrompt(taskId);
}
if (declaredScope.length === 0) {
// Apply only after the override/custom-scope resolution preserves legacy semantics.
const resolvedScope = declaredScope;
declaredScope = params.scopeTransform ? params.scopeTransform(resolvedScope) : resolvedScope;
const forcedViolation = params.forceViolation?.(resolvedScope) === true;
if (declaredScope.length === 0 && !forcedViolation) {
return;
}
const stagedFiles = await stagedFilesReader(rootDir);
const hasOverlap = stagedFiles.some((file) => matchesScope(file, declaredScope));
if (!hasOverlap) {
throw new FileScopeViolationError(taskId, stagedFiles, declaredScope);
if (forcedViolation || !hasOverlap) {
throw new FileScopeViolationError(taskId, stagedFiles, forcedViolation ? resolvedScope : declaredScope);
}
}
@@ -220,6 +245,8 @@ export async function enforceSquashFileScopeInvariant(params: {
resetLabel: string;
stagedFilesReader?: StagedFilesReader;
auditor?: RunAuditor;
scopeTransform?: (scope: string[]) => string[];
forceViolation?: (resolvedScope: string[]) => boolean;
}): Promise<void> {
// U7 (R10): resolve the file-scope enforcement mode from the merge trait
// (flag ON) or settings (back-compat). The lost-work guard trio is NOT gated

View File

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