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:
7
.changeset/fn-9050-ai-merge-scope-gates.md
Normal file
7
.changeset/fn-9050-ai-merge-scope-gates.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
188
packages/engine/src/__tests__/merger-ai-squash-gates.test.ts
Normal file
188
packages/engine/src/__tests__/merger-ai-squash-gates.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
66
packages/engine/src/merge/merger-ai-squash-gates.ts
Normal file
66
packages/engine/src/merge/merger-ai-squash-gates.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user