From 4bc0a32d6099e829a8238da960d3967b0c8b0304 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 14 Aug 2026 23:42:52 -0700 Subject: [PATCH] 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) --- .changeset/fn-9050-ai-merge-scope-gates.md | 7 + docs/architecture.md | 2 + docs/workflow-steps.md | 4 +- .../__tests__/merger-ai-squash-gates.test.ts | 188 ++++++++++++++++++ .../merger-file-scope-invariant.test.ts | 29 +++ .../workspace-merger-scope-gates.test.ts | 118 +++++++++++ .../src/merge/merger-ai-squash-gates.ts | 66 ++++++ packages/engine/src/merge/merger-ai.ts | 21 +- .../src/merge/merger-diff-volume-gate.ts | 14 +- .../engine/src/merge/merger-file-scope.ts | 33 ++- packages/engine/src/util/run-audit.ts | 1 + 11 files changed, 474 insertions(+), 9 deletions(-) create mode 100644 .changeset/fn-9050-ai-merge-scope-gates.md create mode 100644 packages/engine/src/__tests__/merger-ai-squash-gates.test.ts create mode 100644 packages/engine/src/__tests__/workspace-merger-scope-gates.test.ts create mode 100644 packages/engine/src/merge/merger-ai-squash-gates.ts diff --git a/.changeset/fn-9050-ai-merge-scope-gates.md b/.changeset/fn-9050-ai-merge-scope-gates.md new file mode 100644 index 0000000000..8834911326 --- /dev/null +++ b/.changeset/fn-9050-ai-merge-scope-gates.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index a59f238bf9..cc118ab9f9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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) diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index c290273d40..c1b63f8db2 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -687,10 +687,10 @@ By default this split-and-fork behavior is enabled through the project setting ` -> **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): diff --git a/packages/engine/src/__tests__/merger-ai-squash-gates.test.ts b/packages/engine/src/__tests__/merger-ai-squash-gates.test.ts new file mode 100644 index 0000000000..12a71ffd6a --- /dev/null +++ b/packages/engine/src/__tests__/merger-ai-squash-gates.test.ts @@ -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 = {}) { + 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 => { + 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); + }); +}); diff --git a/packages/engine/src/__tests__/merger-file-scope-invariant.test.ts b/packages/engine/src/__tests__/merger-file-scope-invariant.test.ts index 7946ab2945..b60b1ee43b 100644 --- a/packages/engine/src/__tests__/merger-file-scope-invariant.test.ts +++ b/packages/engine/src/__tests__/merger-file-scope-invariant.test.ts @@ -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(); + }); +}); diff --git a/packages/engine/src/__tests__/workspace-merger-scope-gates.test.ts b/packages/engine/src/__tests__/workspace-merger-scope-gates.test.ts new file mode 100644 index 0000000000..bc94a51311 --- /dev/null +++ b/packages/engine/src/__tests__/workspace-merger-scope-gates.test.ts @@ -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>; audit: any[] } { + const emitter = new EventEmitter(); + const updates: Array> = []; + 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) => { 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>; audit: any[] }; +} + +function squashAgent(branch: string) { + return async (cwd: string): Promise => { + 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"); + }); +}); diff --git a/packages/engine/src/merge/merger-ai-squash-gates.ts b/packages/engine/src/merge/merger-ai-squash-gates.ts new file mode 100644 index 0000000000..ac65e80ca7 --- /dev/null +++ b/packages/engine/src/merge/merger-ai-squash-gates.ts @@ -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; repoRel?: string; repoKeys?: readonly string[] }): Promise { + const resolver = params.repoRel ? resolveRepoDeclaredScopeTransform({ repoRel: params.repoRel, repoKeys: params.repoKeys ?? [] }) : undefined; + const transform = resolver ? (scope: string[]) => resolver.transform(scope) : undefined; + try { + 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; + } +} diff --git a/packages/engine/src/merge/merger-ai.ts b/packages/engine/src/merge/merger-ai.ts index 226e32d14c..47aecb06dd 100644 --- a/packages/engine/src/merge/merger-ai.ts +++ b/packages/engine/src/merge/merger-ai.ts @@ -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 { @@ -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") { diff --git a/packages/engine/src/merge/merger-diff-volume-gate.ts b/packages/engine/src/merge/merger-diff-volume-gate.ts index 5cce9461b9..68df2276a7 100644 --- a/packages/engine/src/merge/merger-diff-volume-gate.ts +++ b/packages/engine/src/merge/merger-diff-volume-gate.ts @@ -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 { 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 }); diff --git a/packages/engine/src/merge/merger-file-scope.ts b/packages/engine/src/merge/merger-file-scope.ts index 314e97bb0e..3546271ce7 100644 --- a/packages/engine/src/merge/merger-file-scope.ts +++ b/packages/engine/src/merge/merger-file-scope.ts @@ -141,6 +141,18 @@ export class FileScopeViolationError extends Error { export type StagedFilesReader = (cwd: string) => Promise; +/** + * 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 { 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 { 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 { // 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 diff --git a/packages/engine/src/util/run-audit.ts b/packages/engine/src/util/run-audit.ts index 252e6b4d5c..cdd5386400 100644 --- a/packages/engine/src/util/run-audit.ts +++ b/packages/engine/src/util/run-audit.ts @@ -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"