This repository has been archived on 2026-07-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
ci-agent-runner/app/src/adapters/git/diff.ts
T

248 lines
8.1 KiB
TypeScript

const maximumDiffBytes = 500_000;
const maximumFindings = 100;
export interface DiffAnchor {
path: string;
side: "old" | "new";
line: number;
}
export interface UnifiedDiffLine {
text: string;
anchors: DiffAnchor[];
}
export interface ParsedUnifiedDiff {
lines: UnifiedDiffLine[];
anchors: DiffAnchor[];
}
export interface StructuredDiffFinding extends DiffAnchor {
body: string;
}
export interface PullReviewCommentInput {
path: string;
body: string;
old_position: number;
new_position: number;
}
interface HunkState {
path: string;
oldLine: number;
newLine: number;
oldEnd: number;
newEnd: number;
}
export function parseUnifiedDiff(diff: string): ParsedUnifiedDiff {
if (Buffer.byteLength(diff) > maximumDiffBytes)
throw new Error(`Unified diff exceeds ${maximumDiffBytes} bytes`);
const lines: UnifiedDiffLine[] = [];
const anchors: DiffAnchor[] = [];
const keys = new Set<string>();
let oldPath: string | undefined;
let path: string | undefined;
let hunk: HunkState | undefined;
for (const text of diff.split("\n")) {
const lineAnchors: DiffAnchor[] = [];
if (hunk && text === "\\ No newline at end of file") {
lines.push({ text, anchors: lineAnchors });
continue;
}
if (hunk && /^[- +]/.test(text)) {
consumeHunkLine(hunk, text, lineAnchors);
for (const anchor of lineAnchors) {
const key = anchorKey(anchor);
if (keys.has(key))
throw new Error(`Unified diff repeats anchor ${key}`);
keys.add(key);
anchors.push(anchor);
}
lines.push({ text, anchors: lineAnchors });
continue;
}
finishHunk(hunk);
hunk = undefined;
if (text.startsWith("diff --git ")) {
oldPath = undefined;
path = undefined;
} else if (text.startsWith("--- ")) {
oldPath = parseHeaderPath(text.slice(4), "a/");
} else if (text.startsWith("+++ ")) {
const newPath = parseHeaderPath(text.slice(4), "b/");
path = newPath === "/dev/null" ? oldPath : newPath;
if (!path || path === "/dev/null")
throw new Error("Unified diff has no usable file path");
} else if (text.startsWith("@@")) {
if (!path) throw new Error("Unified diff hunk has no file path");
hunk = parseHunk(text, path);
}
lines.push({ text, anchors: lineAnchors });
}
finishHunk(hunk);
return { lines, anchors };
}
export function renderUnifiedDiff(value: string | ParsedUnifiedDiff): string {
const parsed = typeof value === "string" ? parseUnifiedDiff(value) : value;
const rendered = parsed.lines
.map((line) => {
if (!line.anchors.length) return line.text;
const refs = line.anchors.map(renderDiffAnchor).join(" ");
return `${refs} ${line.text}`;
})
.join("\n");
if (Buffer.byteLength(rendered) > maximumDiffBytes * 2)
throw new Error("Rendered unified diff exceeds the output limit");
return rendered;
}
export function renderDiffAnchor(anchor: DiffAnchor): string {
return `[${anchor.side}:${anchor.line}:${JSON.stringify(anchor.path)}]`;
}
export function isValidDiffAnchor(
parsed: ParsedUnifiedDiff,
anchor: DiffAnchor,
): boolean {
const expected = anchorKey(anchor);
return parsed.anchors.some(
(candidate) => anchorKey(candidate) === expected,
);
}
export function validateStructuredFindings(
value: unknown,
diff: string | ParsedUnifiedDiff,
): StructuredDiffFinding[] {
if (!Array.isArray(value) || value.length > maximumFindings)
throw new Error("Review findings must be a bounded array");
const parsed = typeof diff === "string" ? parseUnifiedDiff(diff) : diff;
let bodyBytes = 0;
return value.map((item, index) => {
if (!item || typeof item !== "object" || Array.isArray(item))
throw new Error(`Review finding ${index} must be an object`);
const finding = item as Partial<StructuredDiffFinding>;
if (
typeof finding.path !== "string" ||
!safePath(finding.path) ||
(finding.side !== "old" && finding.side !== "new") ||
!Number.isSafeInteger(finding.line) ||
Number(finding.line) <= 0
) {
throw new Error(`Review finding ${index} has a malformed anchor`);
}
const anchor = {
path: finding.path,
side: finding.side,
line: Number(finding.line),
};
if (!isValidDiffAnchor(parsed, anchor))
throw new Error(
`Review finding ${index} is not anchored in the diff`,
);
if (typeof finding.body !== "string" || !finding.body.trim())
throw new Error(`Review finding ${index} has no body`);
const body = finding.body.trim();
bodyBytes += Buffer.byteLength(body);
if (body.length > 10_000 || bodyBytes > maximumDiffBytes)
throw new Error("Review findings exceed the output limit");
return { ...anchor, body };
});
}
export function renderPullReviewComments(
findings: StructuredDiffFinding[],
): PullReviewCommentInput[] {
return findings.map((finding) => ({
path: finding.path,
body: finding.body,
old_position: finding.side === "old" ? finding.line : 0,
new_position: finding.side === "new" ? finding.line : 0,
}));
}
function parseHunk(text: string, path: string): HunkState {
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(text);
if (!match) throw new Error(`Malformed unified diff hunk: ${text}`);
const oldLine = Number(match[1]);
const oldCount = Number(match[2] ?? 1);
const newLine = Number(match[3]);
const newCount = Number(match[4] ?? 1);
if (
![oldLine, oldCount, newLine, newCount].every(Number.isSafeInteger) ||
(oldCount > 0 && oldLine < 1) ||
(newCount > 0 && newLine < 1)
)
throw new Error(`Malformed unified diff hunk: ${text}`);
return {
path,
oldLine,
newLine,
oldEnd: oldLine + oldCount,
newEnd: newLine + newCount,
};
}
function consumeHunkLine(
hunk: HunkState,
text: string,
anchors: DiffAnchor[],
): void {
const prefix = text[0];
if (prefix === " " || prefix === "-") {
if (hunk.oldLine >= hunk.oldEnd)
throw new Error("Unified diff exceeds its old-line hunk range");
anchors.push({ path: hunk.path, side: "old", line: hunk.oldLine++ });
}
if (prefix === " " || prefix === "+") {
if (hunk.newLine >= hunk.newEnd)
throw new Error("Unified diff exceeds its new-line hunk range");
anchors.push({ path: hunk.path, side: "new", line: hunk.newLine++ });
}
}
function finishHunk(hunk: HunkState | undefined): void {
if (hunk && (hunk.oldLine !== hunk.oldEnd || hunk.newLine !== hunk.newEnd))
throw new Error(
"Unified diff hunk ended before its declared line counts",
);
}
function parseHeaderPath(value: string, prefix: string): string {
const decoded = value.startsWith('"') ? decodeQuotedPath(value) : value;
if (decoded === "/dev/null") return decoded;
const path = decoded.startsWith(prefix)
? decoded.slice(prefix.length)
: decoded;
if (!safePath(path))
throw new Error("Unified diff contains an unsafe path");
return path;
}
function decodeQuotedPath(value: string): string {
try {
return JSON.parse(value) as string;
} catch {
throw new Error("Unified diff contains a malformed quoted path");
}
}
function safePath(path: string): boolean {
return Boolean(
path &&
path.length <= 1_000 &&
!path.startsWith("/") &&
!path.includes("\0") &&
!path.includes("\n") &&
!path.split("/").includes(".."),
);
}
function anchorKey(anchor: DiffAnchor): string {
return JSON.stringify([anchor.path, anchor.side, anchor.line]);
}