Files
fusion/packages/engine/src/merger-diff-volume-gate.ts
Fusion 8fa3e8bfd1 feat(FN-4072): add diff volume gate to merger
Added a diff volume gate to the merger that blocks or warns on large diffs before merge completes. The feature includes a new `merger-diff-volume-gate` module wired into the merger, configurable via project settings, with tests covering all gate paths. Also updated docs and the changeset for the `@r

Fusion-Task-Id: FN-4072
2026-05-12 18:26:58 -07:00

104 lines
3.1 KiB
TypeScript

import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { GENERATED_PATTERNS, LOCKFILE_PATTERNS, matchGlob } from "./merger.js";
const execFileAsync = promisify(execFile);
export interface DiffVolumeRegressionFinding {
file: string;
branchNet: number;
staged: number;
ratio: number;
}
export class DiffVolumeRegressionError extends Error {
override name = "DiffVolumeRegressionError";
constructor(public readonly findings: DiffVolumeRegressionFinding[]) {
super(buildMessage(findings));
}
}
interface CheckDiffVolumeParams {
rootDir: string;
branch: string;
integrationTargetSha: string;
minLines: number;
threshold: number;
allowlistGlobs: readonly string[];
taskId?: string;
}
function buildMessage(findings: readonly DiffVolumeRegressionFinding[]): string {
const details = findings
.map((finding) => `${finding.file} (branch_net=${finding.branchNet}, staged=${finding.staged}, ratio=${finding.ratio.toFixed(3)})`)
.join(", ");
return `Per-file diff-volume regression detected: ${details}`;
}
function parseNumstatTotal(output: string): number {
const line = output
.split("\n")
.map((entry) => entry.trim())
.find(Boolean);
if (!line) return 0;
const [addedRaw, deletedRaw] = line.split("\t");
if (!addedRaw || !deletedRaw) return 0;
if (addedRaw === "-" || deletedRaw === "-") return 0;
const added = Number.parseInt(addedRaw, 10);
const deleted = Number.parseInt(deletedRaw, 10);
return (Number.isFinite(added) ? added : 0) + (Number.isFinite(deleted) ? deleted : 0);
}
function isAllowlisted(file: string, allowlistGlobs: readonly string[]): boolean {
return [...LOCKFILE_PATTERNS, ...GENERATED_PATTERNS, ...allowlistGlobs].some((pattern) => matchGlob(file, pattern));
}
async function execGit(rootDir: string, args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", args, {
cwd: rootDir,
encoding: "utf-8",
maxBuffer: 10 * 1024 * 1024,
});
return stdout;
}
export async function checkDiffVolume({
rootDir,
branch,
integrationTargetSha,
minLines,
threshold,
allowlistGlobs,
}: CheckDiffVolumeParams): Promise<void> {
const base = (await execGit(rootDir, ["merge-base", integrationTargetSha, branch])).trim();
const touchedFilesOutput = await execGit(rootDir, ["diff", "--name-only", `${base}...${branch}`]);
const touchedFiles = touchedFilesOutput
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const findings: DiffVolumeRegressionFinding[] = [];
for (const file of touchedFiles) {
if (isAllowlisted(file, allowlistGlobs)) continue;
const branchNet = parseNumstatTotal(
await execGit(rootDir, ["diff", "--numstat", `${base}...${branch}`, "--", file]),
);
if (branchNet <= minLines) continue;
const staged = parseNumstatTotal(
await execGit(rootDir, ["diff", "--cached", "--numstat", "--", file]),
);
const ratio = branchNet === 0 ? 1 : staged / branchNet;
if (ratio < threshold) {
findings.push({ file, branchNet, staged, ratio });
}
}
if (findings.length > 0) {
throw new DiffVolumeRegressionError(findings);
}
}