Files
fusion/packages/engine/src/branch-attribution.ts
Fusion (runfusion.ai) c5e9dc43f1 fix(FN-5304): guard against silent no-op landed-file attribution mismatches
- Add a merger guard that verifies source fusion/FN branch attribution when rebase capture reports zero own commits
- Fail finalize with explicit no-op attribution mismatch handling instead of marking mergeConfirmed on ambiguous no-op ranges
- Type and emit dedicated audit events for mismatch and source-ref-unavailable skip diagnostics
- Expand branch attribution, merger, and reliability interaction tests to cover the full no-op guard matrix

Fusion-Task-Id: FN-5304
2026-05-20 08:23:40 -07:00

283 lines
9.6 KiB
TypeScript

import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
const GIT_TIMEOUT_MS = 30_000;
const GIT_MAX_BUFFER = 10 * 1024 * 1024;
export const CONVENTIONAL_COMMIT_TYPES = [
"feat",
"fix",
"test",
"chore",
"docs",
"refactor",
"perf",
"build",
"ci",
"style",
"revert",
] as const;
type AttributionSource = "trailer" | "subject-prefix" | "bracketed-prefix" | "none";
export interface AttributionResult {
files: string[];
foreignCommits: { sha: string; subject: string; attributedTaskId: string | null }[];
ownCommitCount: number;
ownCommitShas?: string[];
rawDiffFileCount: number;
commitAttributions: { sha: string; subject: string; source: AttributionSource; attributed: boolean; attributedTaskId: string | null }[];
}
export class BranchAttributionError extends Error {
readonly cause?: unknown;
constructor(message: string, cause?: unknown) {
super(message);
this.name = "BranchAttributionError";
this.cause = cause;
}
}
export class SilentNoOpAttributionMismatchError extends Error {
readonly taskId: string;
readonly recordedSha: string;
readonly rebaseMergeBaseSha: string;
readonly sourceBranchRef: string;
readonly sourceBranchOwnCommitCount: number;
readonly sourceBranchOwnCommitShas: string[];
constructor(params: {
taskId: string;
recordedSha: string;
rebaseMergeBaseSha: string;
sourceBranchRef: string;
sourceBranchOwnCommitCount: number;
sourceBranchOwnCommitShas: string[];
}) {
super(
`silent no-op attribution mismatch: ${params.sourceBranchRef} carries ${params.sourceBranchOwnCommitCount} attributable commit(s) for ${params.taskId} not present in recorded head ${params.recordedSha}`,
);
this.name = "SilentNoOpAttributionMismatchError";
this.taskId = params.taskId;
this.recordedSha = params.recordedSha;
this.rebaseMergeBaseSha = params.rebaseMergeBaseSha;
this.sourceBranchRef = params.sourceBranchRef;
this.sourceBranchOwnCommitCount = params.sourceBranchOwnCommitCount;
this.sourceBranchOwnCommitShas = params.sourceBranchOwnCommitShas;
}
}
export interface BranchAttributionOptions {
worktreePath: string;
baseRef: string;
taskId: string;
requireTrailer?: boolean;
execAsyncImpl?: typeof execAsync;
}
export interface BranchRangeAttributionOptions {
worktreePath: string;
rangeRef: string;
taskId: string;
execAsyncImpl?: typeof execAsync;
}
function quoteShellArg(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function extractAttributedTaskId(body: string): string | null {
const trailerPattern = /(?:^|\n)(?:Fusion-Task-Id|Task-Id):\s*(\S+)\s*(?:\n|$)/gim;
let match: RegExpExecArray | null = null;
let last: RegExpExecArray | null = null;
while (true) {
match = trailerPattern.exec(body);
if (!match) break;
last = match;
}
return last?.[1] ?? null;
}
/**
* FN-5083/FN-5060 hotfix: extract a task-id reference from a commit subject when the
* `Fusion-Task-Id` trailer is missing. Recognizes conventional commits
* (`feat(FN-123): ...`, `fix(FN-123): ...`, etc.), bracketed prefixes
* (`[FN-123] ...`) and legacy colon prefixes (`FN-123: ...`).
*
* Accepts any uppercase-letter task prefix (FN, KB, RF, PROJ, JIRA, ...) so this is
* project-agnostic. Returns the canonical `<PREFIX>-<digits>` string.
*/
function extractTaskIdFromSubject(subject: string): {
attributedTaskId: string | null;
source: Extract<AttributionSource, "subject-prefix" | "bracketed-prefix" | "none">;
} {
if (!subject) {
return { attributedTaskId: null, source: "none" };
}
// Conventional commit: feat(FN-123): ... or fix(FN-123)!: ... (case-insensitive)
const conventional =
/^(?:feat|fix|test|chore|docs|refactor|perf|build|ci|style|revert)\s*\(([A-Z]+-\d+)\)!?:/i.exec(subject);
if (conventional?.[1]) {
return { attributedTaskId: conventional[1].toUpperCase(), source: "subject-prefix" };
}
// Bracketed: [FN-123] ...
const bracketed = /^\s*\[([A-Z]+-\d+)\]/i.exec(subject);
if (bracketed?.[1]) {
return { attributedTaskId: bracketed[1].toUpperCase(), source: "bracketed-prefix" };
}
// Legacy colon: FN-123: ...
const colon = /^\s*([A-Z]+-\d+):/i.exec(subject);
if (colon?.[1]) {
return { attributedTaskId: colon[1].toUpperCase(), source: "subject-prefix" };
}
return { attributedTaskId: null, source: "none" };
}
function taskIdsMatch(a: string | null, b: string): boolean {
if (!a) return false;
return a.toUpperCase() === b.toUpperCase();
}
export async function collectOwnTaskCommitsForRange(opts: BranchRangeAttributionOptions): Promise<{ ownCommitCount: number; ownCommitShas: string[] }> {
const execImpl = opts.execAsyncImpl ?? execAsync;
let logOutput: string;
try {
const result = await execImpl(
`git log --format=%H%x00%s%x00%B%x1e ${quoteShellArg(opts.rangeRef)}`,
{
cwd: opts.worktreePath,
encoding: "utf-8",
timeout: GIT_TIMEOUT_MS,
maxBuffer: GIT_MAX_BUFFER,
},
);
logOutput = result.stdout;
} catch (error) {
const stderr =
typeof error === "object" && error && "stderr" in error && typeof error.stderr === "string"
? error.stderr.trim()
: String(error);
throw new BranchAttributionError(
`git command failed: git log --format=%H%x00%s%x00%B%x1e ${opts.rangeRef} (${stderr || "no stderr"})`,
error,
);
}
if (!logOutput.trim()) {
return { ownCommitCount: 0, ownCommitShas: [] };
}
const ownCommitShas: string[] = [];
const records = logOutput.split("\x1e").map((record) => record.trim()).filter(Boolean);
for (const record of records) {
const [sha = "", subject = "", ...bodyParts] = record.split("\x00");
if (!sha) {
throw new BranchAttributionError("malformed git log output: missing commit sha");
}
const body = bodyParts.join("\x00");
const trailerAttributedTaskId = extractAttributedTaskId(body);
const subjectAttribution = trailerAttributedTaskId
? { attributedTaskId: null, source: "none" as const }
: extractTaskIdFromSubject(subject);
const attributedTaskId = trailerAttributedTaskId ?? subjectAttribution.attributedTaskId;
if (taskIdsMatch(attributedTaskId, opts.taskId)) {
ownCommitShas.push(sha);
}
}
return { ownCommitCount: ownCommitShas.length, ownCommitShas };
}
export async function filterFilesToOwnTaskCommits(opts: BranchAttributionOptions): Promise<AttributionResult> {
const execImpl = opts.execAsyncImpl ?? execAsync;
const runGit = async (command: string): Promise<string> => {
try {
const { stdout } = await execImpl(command, {
cwd: opts.worktreePath,
encoding: "utf-8",
timeout: GIT_TIMEOUT_MS,
maxBuffer: GIT_MAX_BUFFER,
});
return stdout;
} catch (error) {
const stderr =
typeof error === "object" && error && "stderr" in error && typeof error.stderr === "string"
? error.stderr.trim()
: String(error);
throw new BranchAttributionError(`git command failed: ${command} (${stderr || "no stderr"})`, error);
}
};
const rawDiffOutput = await runGit(`git diff --name-only ${quoteShellArg(opts.baseRef)}..HEAD`);
const rawDiffFileCount = rawDiffOutput
.split("\n")
.map((line) => line.trim())
.filter(Boolean).length;
const logOutput = await runGit(
`git log --format=%H%x00%s%x00%B%x1e ${quoteShellArg(`${opts.baseRef}..HEAD`)}`,
);
if (!logOutput.trim()) {
return { files: [], foreignCommits: [], ownCommitCount: 0, ownCommitShas: [], rawDiffFileCount, commitAttributions: [] };
}
const fileSet = new Set<string>();
const foreignCommits: { sha: string; subject: string; attributedTaskId: string | null }[] = [];
const ownCommitShas: string[] = [];
const commitAttributions: AttributionResult["commitAttributions"] = [];
const records = logOutput.split("\x1e").map((record) => record.trim()).filter(Boolean);
for (const record of records) {
const [sha = "", subject = "", ...bodyParts] = record.split("\x00");
if (!sha) {
throw new BranchAttributionError("malformed git log output: missing commit sha");
}
const body = bodyParts.join("\x00");
const trailerAttributedTaskId = extractAttributedTaskId(body);
// FN-5083/FN-5060 hotfix: trailer is primary; fall back to subject parsing so
// commits without the `Fusion-Task-Id` trailer (the common case for agent-driven
// commits today) still attribute correctly by their conventional-commit subject.
const subjectAttribution = trailerAttributedTaskId
? { attributedTaskId: null, source: "none" as const }
: extractTaskIdFromSubject(subject);
const attributedTaskId = trailerAttributedTaskId ?? subjectAttribution.attributedTaskId;
const source: AttributionSource = trailerAttributedTaskId ? "trailer" : subjectAttribution.source;
commitAttributions.push({
sha,
subject,
source,
attributed: taskIdsMatch(attributedTaskId, opts.taskId),
attributedTaskId,
});
if (taskIdsMatch(attributedTaskId, opts.taskId)) {
ownCommitShas.push(sha);
continue;
}
foreignCommits.push({ sha, subject, attributedTaskId });
}
for (const sha of ownCommitShas) {
const diffTreeOutput = await runGit(`git diff-tree --no-commit-id --name-only -r ${quoteShellArg(sha)}`);
for (const file of diffTreeOutput
.split("\n")
.map((line) => line.trim())
.filter(Boolean)) {
fileSet.add(file);
}
}
return {
files: [...fileSet].sort((a, b) => a.localeCompare(b)),
foreignCommits,
ownCommitCount: ownCommitShas.length,
ownCommitShas,
rawDiffFileCount,
commitAttributions,
};
}