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-5256 backstop: `packages/engine/src/__tests__/reliability-interactions/dependency-cycle-reconcile.test.ts` covers persisted dependency-cycle detection via `reconcileDependencyCycles`, bounded umbrella-back-edge auto-repair, ambiguous-cycle observe-only behavior, composition ordering with `reconcileSelfDefeatingDependencies`, and the post-sweep write-time guard invariant. Core write-boundary regressions (FN-5240/5241/5242 signature, indirect cycle, umbrella back-edge rejection) live in `packages/core/src/__tests__/store-dependency-cycle.test.ts`.
|
||||||
- FN-5223 backstop: `packages/engine/src/__tests__/reliability-interactions/engine-active-since-floor.test.ts` covers engine-activation floor + grace composition across startup, pause/unpause, global-pause gating, and StuckTaskDetector lifecycle interactions.
|
- FN-5223 backstop: `packages/engine/src/__tests__/reliability-interactions/engine-active-since-floor.test.ts` covers engine-activation floor + grace composition across startup, pause/unpause, global-pause gating, and StuckTaskDetector lifecycle interactions.
|
||||||
|
|
||||||
|
**Unified squash gates (FN-9050).** `landOneRepo` validates every approved clean-room squash before advancing an integration ref, for both single-repository lands and each workspace sub-repository land. Workspace checks use that repository's File Scope subset; a violation leaves the integration ref unchanged. The diff-volume guard emits `merge:diff-volume-blocked` with task/repository IDs, commit IDs, finding count, and paths only.
|
||||||
|
|
||||||
The auto-recovery dispatcher at `packages/engine/src/auto-recovery.ts` (FN-4533) composes on top of existing layers (FN-4500 fast-path, FN-4508 deterministic branch-conflict, FN-4499 bootstrap-misbinding, FN-4428 contamination, `mergeAuditAutoRecovery` Stages 1–5, self-healing) to handle six residual classes: file-scope violation at squash, branch misbinding / ghost worktree, verification-fix scope leak, contamination, `branch-conflict-unrecoverable` residuals, and room-post/message-send failures. Invocation is additive — no existing layer's behavior changes.
|
The auto-recovery dispatcher at `packages/engine/src/auto-recovery.ts` (FN-4533) composes on top of existing layers (FN-4500 fast-path, FN-4508 deterministic branch-conflict, FN-4499 bootstrap-misbinding, FN-4428 contamination, `mergeAuditAutoRecovery` Stages 1–5, self-healing) to handle six residual classes: file-scope violation at squash, branch misbinding / ghost worktree, verification-fix scope leak, contamination, `branch-conflict-unrecoverable` residuals, and room-post/message-send failures. Invocation is additive — no existing layer's behavior changes.
|
||||||
|
|
||||||
### Concurrent soft-delete heartbeat races (FN-8004)
|
### 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:
|
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):
|
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" }));
|
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 { resolveTaskWorkingBranch } from "../worktree/worktree-names.js";
|
||||||
import { resolveIntegrationBranch } from "./integration-branch.js";
|
import { resolveIntegrationBranch } from "./integration-branch.js";
|
||||||
import { advanceIntegrationBranchRef } from "./merger-ref-update-advance.js";
|
import { advanceIntegrationBranchRef } from "./merger-ref-update-advance.js";
|
||||||
|
import { enforceAiMergeSquashGates } from "./merger-ai-squash-gates.js";
|
||||||
import {
|
import {
|
||||||
assertMergeGenerationOwned,
|
assertMergeGenerationOwned,
|
||||||
createMergeWriteFence,
|
createMergeWriteFence,
|
||||||
@@ -259,6 +260,7 @@ function listAiMergeWorktreeCandidates(taskId: string, projectRootDir: string, s
|
|||||||
|
|
||||||
async function recoverApprovedPreexistingAiMergeWorktree(
|
async function recoverApprovedPreexistingAiMergeWorktree(
|
||||||
repoRootDir: string,
|
repoRootDir: string,
|
||||||
|
branch: string,
|
||||||
integrationBranch: string,
|
integrationBranch: string,
|
||||||
ctx: LandRepoContext,
|
ctx: LandRepoContext,
|
||||||
): Promise<LandOneRepoResult | null> {
|
): Promise<LandOneRepoResult | null> {
|
||||||
@@ -307,6 +309,8 @@ async function recoverApprovedPreexistingAiMergeWorktree(
|
|||||||
const selected = recoverableCandidates[0];
|
const selected = recoverableCandidates[0];
|
||||||
throwIfAborted(signal, taskId);
|
throwIfAborted(signal, taskId);
|
||||||
if (!selected.alreadyLanded) {
|
if (!selected.alreadyLanded) {
|
||||||
|
if (!task) throw new Error(`AI merge task ${taskId} disappeared before recovery squash gates`);
|
||||||
|
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({
|
const land = await landSquash({
|
||||||
projectRootDir: repoRootDir,
|
projectRootDir: repoRootDir,
|
||||||
mergeRoot: selected.mergeRoot,
|
mergeRoot: selected.mergeRoot,
|
||||||
@@ -806,6 +810,9 @@ export interface LandRepoContext {
|
|||||||
off, preserving the documented hard-fail for the single-repo land path.
|
off, preserving the documented hard-fail for the single-repo land path.
|
||||||
*/
|
*/
|
||||||
nonFatalDependencySync?: boolean;
|
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:
|
FNXC:MergeNoCommits 2026-07-17-12:00:
|
||||||
When true, the task is expected to produce no code changes (audit, documentation, decision-only).
|
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
|
// 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
|
// landing/finalization, land that commit before the normal pre-merge prune can
|
||||||
// delete the only easy reference to it.
|
// 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;
|
if (recovered) return recovered;
|
||||||
|
|
||||||
// Pre-merge prune is rooted at THIS sub-repo (KTD1): N per-repo clean rooms for
|
// 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 };
|
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
|
// 4 + 5. Land the squash on the target branch and sync the user's
|
||||||
// checkout (AI reconciles a conflicting restore).
|
// checkout (AI reconciles a conflicting restore).
|
||||||
await setStatus("landing");
|
await setStatus("landing");
|
||||||
@@ -2072,6 +2089,8 @@ export async function landWorkspaceTask(
|
|||||||
nonFatalDependencySync: true,
|
nonFatalDependencySync: true,
|
||||||
// FNXC:MergeNoCommits 2026-07-17-12:00: no-commits tasks skip dependency sync in the clean room
|
// FNXC:MergeNoCommits 2026-07-17-12:00: no-commits tasks skip dependency sync in the clean room
|
||||||
noCommitsExpected: task.noCommitsExpected === true,
|
noCommitsExpected: task.noCommitsExpected === true,
|
||||||
|
repoRel,
|
||||||
|
repoKeys,
|
||||||
store,
|
store,
|
||||||
});
|
});
|
||||||
if (landResult.outcome === "landed") {
|
if (landResult.outcome === "landed") {
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ interface CheckDiffVolumeParams {
|
|||||||
threshold: number;
|
threshold: number;
|
||||||
allowlistGlobs: readonly string[];
|
allowlistGlobs: readonly string[];
|
||||||
taskId?: 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 {
|
function buildMessage(findings: readonly DiffVolumeRegressionFinding[]): string {
|
||||||
@@ -71,6 +73,7 @@ export async function checkDiffVolume({
|
|||||||
minLines,
|
minLines,
|
||||||
threshold,
|
threshold,
|
||||||
allowlistGlobs,
|
allowlistGlobs,
|
||||||
|
squashRange,
|
||||||
}: CheckDiffVolumeParams): Promise<void> {
|
}: CheckDiffVolumeParams): Promise<void> {
|
||||||
const base = (await execGit(rootDir, ["merge-base", integrationTargetSha, branch])).trim();
|
const base = (await execGit(rootDir, ["merge-base", integrationTargetSha, branch])).trim();
|
||||||
const touchedFilesOutput = await execGit(rootDir, ["diff", "--name-only", `${base}...${branch}`]);
|
const touchedFilesOutput = await execGit(rootDir, ["diff", "--name-only", `${base}...${branch}`]);
|
||||||
@@ -89,9 +92,14 @@ export async function checkDiffVolume({
|
|||||||
);
|
);
|
||||||
if (branchNet <= minLines) continue;
|
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;
|
const ratio = branchNet === 0 ? 1 : staged / branchNet;
|
||||||
if (ratio < threshold) {
|
if (ratio < threshold) {
|
||||||
findings.push({ file, branchNet, staged, ratio });
|
findings.push({ file, branchNet, staged, ratio });
|
||||||
|
|||||||
@@ -141,6 +141,18 @@ export class FileScopeViolationError extends Error {
|
|||||||
|
|
||||||
export type StagedFilesReader = (cwd: string) => Promise<string[]>;
|
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[]> {
|
async function readStagedFileNames(cwd: string): Promise<string[]> {
|
||||||
const { stdout } = await execAsync("git diff --cached --name-only", {
|
const { stdout } = await execAsync("git diff --cached --name-only", {
|
||||||
cwd,
|
cwd,
|
||||||
@@ -162,6 +174,15 @@ export async function assertSquashOverlapsFileScope(params: {
|
|||||||
* declared scope. `scopeOverride` is a documented no-op only under
|
* declared scope. `scopeOverride` is a documented no-op only under
|
||||||
* `fileScope: "off"` (handled by the caller, which skips this assert). */
|
* `fileScope: "off"` (handled by the caller, which skips this assert). */
|
||||||
customScopeRules?: string[];
|
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> {
|
}): Promise<void> {
|
||||||
const { store, taskId, rootDir, task, customScopeRules, stagedFilesReader = readStagedFileNames } = params;
|
const { store, taskId, rootDir, task, customScopeRules, stagedFilesReader = readStagedFileNames } = params;
|
||||||
const hasCustomRules = Array.isArray(customScopeRules) && customScopeRules.length > 0;
|
const hasCustomRules = Array.isArray(customScopeRules) && customScopeRules.length > 0;
|
||||||
@@ -190,14 +211,18 @@ export async function assertSquashOverlapsFileScope(params: {
|
|||||||
}
|
}
|
||||||
declaredScope = await store.parseFileScopeFromPrompt(taskId);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stagedFiles = await stagedFilesReader(rootDir);
|
const stagedFiles = await stagedFilesReader(rootDir);
|
||||||
const hasOverlap = stagedFiles.some((file) => matchesScope(file, declaredScope));
|
const hasOverlap = stagedFiles.some((file) => matchesScope(file, declaredScope));
|
||||||
if (!hasOverlap) {
|
if (forcedViolation || !hasOverlap) {
|
||||||
throw new FileScopeViolationError(taskId, stagedFiles, declaredScope);
|
throw new FileScopeViolationError(taskId, stagedFiles, forcedViolation ? resolvedScope : declaredScope);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +245,8 @@ export async function enforceSquashFileScopeInvariant(params: {
|
|||||||
resetLabel: string;
|
resetLabel: string;
|
||||||
stagedFilesReader?: StagedFilesReader;
|
stagedFilesReader?: StagedFilesReader;
|
||||||
auditor?: RunAuditor;
|
auditor?: RunAuditor;
|
||||||
|
scopeTransform?: (scope: string[]) => string[];
|
||||||
|
forceViolation?: (resolvedScope: string[]) => boolean;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
// U7 (R10): resolve the file-scope enforcement mode from the merge trait
|
// 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
|
// (flag ON) or settings (back-compat). The lost-work guard trio is NOT gated
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ export type GitMutationType =
|
|||||||
| "merge:resolve"
|
| "merge:resolve"
|
||||||
| "merge:file-scope-violation"
|
| "merge:file-scope-violation"
|
||||||
| "merge:file-scope-enforcement-disabled"
|
| "merge:file-scope-enforcement-disabled"
|
||||||
|
| "merge:diff-volume-blocked"
|
||||||
// FNXC:MergerUnification 2026-08-09-12:04: Legacy-only audit events emitted
|
// FNXC:MergerUnification 2026-08-09-12:04: Legacy-only audit events emitted
|
||||||
// exclusively by soft-deprecated aiMergeTask, never by production runAiMerge.
|
// exclusively by soft-deprecated aiMergeTask, never by production runAiMerge.
|
||||||
| "merge:auto-prerebase:applied"
|
| "merge:auto-prerebase:applied"
|
||||||
|
|||||||
Reference in New Issue
Block a user