utils/diffCoverage.ts
Rama main · réplica de lectura del repositorio original.
import { isAbsolute, normalize, resolve } from "node:path";
export type DiffLineRange = {
startLine: number;
endLine: number;
};
export type DiffTocEntry = {
filename: string;
startLine: number;
endLine: number;
};
export type DiffCoverageFileBreakdown = {
filename: string;
startLine: number;
endLine: number;
totalLines: number;
coveredLines: number;
coveredRanges: DiffLineRange[];
unreadRanges: DiffLineRange[];
};
export type DiffCoverageBreakdown = {
totalLines: number;
coveredLines: number;
unreadLines: number;
coveragePercent: number;
coveredRanges: DiffLineRange[];
unreadRanges: DiffLineRange[];
files: DiffCoverageFileBreakdown[];
};
export type DiffCoverageState = {
diffPath: string;
totalLines: number;
tocEntries: DiffTocEntry[];
coveredRanges: DiffLineRange[];
coveragePreflightRan: boolean;
};
type ReadTarget = {
path: string;
offset?: number | undefined;
limit?: number | undefined;
startLine?: number | undefined;
endLine?: number | undefined;
};
type OffsetBase = "zero" | "one";
export function countLines(params: { content: string }): number {
const content = params.content;
if (content.length === 0) return 0;
return content.split("\n").length;
}
export function parseDiffTocEntries(params: { toc: string }): DiffTocEntry[] {
const lines = params.toc.split("\n");
const entries: DiffTocEntry[] = [];
// production TOC lines (see formatFilesWithLineNumbers in checkout.ts) append
// ` · diff-<sha256>` so the agent has the GitHub "Files Changed" anchor
// precomputed. accept that suffix optionally so we also parse the shorter
// shape used in tests and in reviewComments.
for (const line of lines) {
const match = line.match(
/^- (.+) (?:→|->) lines (\d+)-(\d+)(?: · diff-[0-9a-f]+)?(?: · generated)?$/
);
if (!match) continue;
const startLine = Number.parseInt(match[2], 10);
const endLine = Number.parseInt(match[3], 10);
if (!Number.isFinite(startLine) || !Number.isFinite(endLine)) continue;
entries.push({ filename: match[1], startLine, endLine });
}
return entries;
}
/**
* one entry per section of an incremental (range-diff) diff, which opens on an
* `@@ <file>[: <function>]` hunk header or a `## <file>[ (new)] ##` file header,
* either of which may be the only line naming its file.
*/
export function parseIncrementalDiffEntries(params: { content: string }): DiffTocEntry[] {
const lines = params.content.split("\n");
const entries: DiffTocEntry[] = [];
for (const [index, line] of lines.entries()) {
const match = line.match(/^@@ (.+)$|^[ +-]## (.+) ##$/);
if (!match) continue;
const previous = entries.at(-1);
if (previous) previous.endLine = index;
const filename = (match[1] ?? match[2]).replace(/(: | \().*$/, "");
entries.push({ filename, startLine: index + 1, endLine: lines.length });
}
return entries;
}
export function createDiffCoverageState(params: {
diffPath: string;
totalLines: number;
tocEntries: DiffTocEntry[];
previous?: DiffCoverageState | undefined;
}): DiffCoverageState {
return {
diffPath: params.diffPath,
totalLines: params.totalLines,
tocEntries: params.tocEntries,
coveredRanges: [],
// carry forward across checkout_pr refreshes so the nudge stays "once per
// review session". coveredRanges are intentionally not carried because
// line numbers are tied to the previous diff's content.
coveragePreflightRan: params.previous?.coveragePreflightRan ?? false,
};
}
export function recordDiffReadFromToolUse(params: {
state: DiffCoverageState | undefined;
toolName: string;
input: unknown;
cwd: string;
}): boolean {
const state = params.state;
if (!state) return false;
if (!isReadTool(params.toolName)) return false;
const readTarget = extractReadTarget({ input: params.input });
if (!readTarget) return false;
const normalizedReadPath = normalizePath({ path: readTarget.path, cwd: params.cwd });
const normalizedDiffPath = normalize(state.diffPath);
if (normalizedReadPath !== normalizedDiffPath) return false;
const range = resolveReadRange({
totalLines: state.totalLines,
offset: readTarget.offset,
limit: readTarget.limit,
startLine: readTarget.startLine,
endLine: readTarget.endLine,
offsetBase: resolveOffsetBase({ toolName: params.toolName }),
});
if (!range) return false;
state.coveredRanges = mergeRanges({ ranges: state.coveredRanges, nextRange: range });
return true;
}
export function getDiffCoverageBreakdown(params: {
state: DiffCoverageState;
}): DiffCoverageBreakdown {
const state = params.state;
const coveredRanges = mergeRangesList({ ranges: state.coveredRanges });
const unreadRanges = invertRanges({ totalLines: state.totalLines, coveredRanges });
const coveredLines = countLinesInRanges({ ranges: coveredRanges });
const unreadLines = Math.max(0, state.totalLines - coveredLines);
const coveragePercent = state.totalLines
? Number(((coveredLines / state.totalLines) * 100).toFixed(2))
: 100;
const files: DiffCoverageFileBreakdown[] = [];
for (const entry of state.tocEntries) {
const fileRange: DiffLineRange = { startLine: entry.startLine, endLine: entry.endLine };
const coveredInFile = intersectRangesWithRange({ ranges: coveredRanges, target: fileRange });
const unreadInFile = intersectRangesWithRange({ ranges: unreadRanges, target: fileRange });
const totalFileLines = Math.max(0, entry.endLine - entry.startLine + 1);
const fileCoveredLines = countLinesInRanges({ ranges: coveredInFile });
files.push({
filename: entry.filename,
startLine: entry.startLine,
endLine: entry.endLine,
totalLines: totalFileLines,
coveredLines: fileCoveredLines,
coveredRanges: coveredInFile,
unreadRanges: unreadInFile,
});
}
return {
totalLines: state.totalLines,
coveredLines,
unreadLines,
coveragePercent,
coveredRanges,
unreadRanges,
files,
};
}
function resolveOffsetBase(params: { toolName: string }): OffsetBase {
const lower = params.toolName.toLowerCase();
if (lower === "readfile" || lower.endsWith(".readfile")) {
return "one";
}
return "zero";
}
function isReadTool(toolName: string): boolean {
const lower = toolName.toLowerCase();
if (lower === "read" || lower === "readfile") return true;
if (lower.endsWith(".read") || lower.endsWith(".readfile")) return true;
return false;
}
function extractReadTarget(params: { input: unknown }): ReadTarget | null {
const inputRecord = asRecord(params.input);
if (!inputRecord) return null;
const direct = extractReadTargetFromRecord({ record: inputRecord });
if (direct) return direct;
const nestedCandidates = [inputRecord.args, inputRecord.params, inputRecord.input];
for (const candidate of nestedCandidates) {
const nestedRecord = asRecord(candidate);
if (!nestedRecord) continue;
const nested = extractReadTargetFromRecord({ record: nestedRecord });
if (nested) return nested;
}
return null;
}
function extractReadTargetFromRecord(params: {
record: Record<string, unknown>;
}): ReadTarget | null {
const record = params.record;
const pathValue =
readString({ value: record.path }) ??
readString({ value: record.file_path }) ??
readString({ value: record.filePath }) ??
readString({ value: record.filepath }) ??
readString({ value: record.file }) ??
readString({ value: record.target_file });
if (!pathValue) return null;
const offset = readNumber({ value: record.offset });
const limit = readNumber({ value: record.limit });
const startLine =
readNumber({ value: record.start_line }) ??
readNumber({ value: record.startLine }) ??
readNumber({ value: record.line_start });
const endLine =
readNumber({ value: record.end_line }) ??
readNumber({ value: record.endLine }) ??
readNumber({ value: record.line_end });
return { path: pathValue, offset, limit, startLine, endLine };
}
function resolveReadRange(params: {
totalLines: number;
offset?: number | undefined;
limit?: number | undefined;
startLine?: number | undefined;
endLine?: number | undefined;
offsetBase: OffsetBase;
}): DiffLineRange | null {
const totalLines = params.totalLines;
if (totalLines <= 0) return null;
if (params.startLine !== undefined || params.endLine !== undefined) {
const rawStart = params.startLine ?? 1;
const rawEnd = params.endLine ?? totalLines;
const startLine = clampLine({ value: rawStart, totalLines });
const endLine = clampLine({ value: rawEnd, totalLines });
if (endLine < startLine) return null;
return { startLine, endLine };
}
let startLine = 1;
if (params.offset !== undefined) {
if (params.offset >= 0) {
const normalizedOffset =
params.offsetBase === "zero" ? params.offset + 1 : params.offset === 0 ? 1 : params.offset;
startLine = clampLine({ value: normalizedOffset, totalLines });
} else {
startLine = clampLine({ value: totalLines + params.offset + 1, totalLines });
}
}
let endLine = totalLines;
if (params.limit !== undefined) {
if (params.limit <= 0) return null;
endLine = clampLine({ value: startLine + params.limit - 1, totalLines });
}
if (endLine < startLine) return null;
return { startLine, endLine };
}
function normalizePath(params: { path: string; cwd: string }): string {
if (isAbsolute(params.path)) return normalize(params.path);
return normalize(resolve(params.cwd, params.path));
}
function mergeRanges(params: {
ranges: DiffLineRange[];
nextRange: DiffLineRange;
}): DiffLineRange[] {
return mergeRangesList({ ranges: [...params.ranges, params.nextRange] });
}
function mergeRangesList(params: { ranges: DiffLineRange[] }): DiffLineRange[] {
if (params.ranges.length === 0) return [];
const sorted = [...params.ranges].sort((a, b) => a.startLine - b.startLine);
const merged: DiffLineRange[] = [];
for (const range of sorted) {
const last = merged[merged.length - 1];
if (!last) {
merged.push({ startLine: range.startLine, endLine: range.endLine });
continue;
}
if (range.startLine <= last.endLine + 1) {
if (range.endLine > last.endLine) {
last.endLine = range.endLine;
}
continue;
}
merged.push({ startLine: range.startLine, endLine: range.endLine });
}
return merged;
}
function invertRanges(params: {
totalLines: number;
coveredRanges: DiffLineRange[];
}): DiffLineRange[] {
if (params.totalLines <= 0) return [];
if (params.coveredRanges.length === 0) {
return [{ startLine: 1, endLine: params.totalLines }];
}
const unread: DiffLineRange[] = [];
let cursor = 1;
for (const range of params.coveredRanges) {
if (cursor < range.startLine) {
unread.push({ startLine: cursor, endLine: range.startLine - 1 });
}
cursor = Math.max(cursor, range.endLine + 1);
}
if (cursor <= params.totalLines) {
unread.push({ startLine: cursor, endLine: params.totalLines });
}
return unread;
}
function intersectRangesWithRange(params: {
ranges: DiffLineRange[];
target: DiffLineRange;
}): DiffLineRange[] {
const intersections: DiffLineRange[] = [];
for (const range of params.ranges) {
if (range.endLine < params.target.startLine) continue;
if (range.startLine > params.target.endLine) continue;
const startLine = Math.max(range.startLine, params.target.startLine);
const endLine = Math.min(range.endLine, params.target.endLine);
if (endLine >= startLine) {
intersections.push({ startLine, endLine });
}
}
return intersections;
}
export function countLinesInRanges(params: { ranges: DiffLineRange[] }): number {
let total = 0;
for (const range of params.ranges) {
total += range.endLine - range.startLine + 1;
}
return total;
}
function clampLine(params: { value: number; totalLines: number }): number {
if (params.value < 1) return 1;
if (params.value > params.totalLines) return params.totalLines;
return params.value;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return Object.fromEntries(Object.entries(value));
}
function readString(params: { value: unknown }): string | undefined {
if (typeof params.value === "string") return params.value;
return undefined;
}
function readNumber(params: { value: unknown }): number | undefined {
if (typeof params.value === "number" && Number.isFinite(params.value)) return params.value;
if (typeof params.value === "string") {
const parsed = Number.parseInt(params.value, 10);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}