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
This commit is contained in:
103
packages/engine/src/merger-diff-volume-gate.ts
Normal file
103
packages/engine/src/merger-diff-volume-gate.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user