add diff coverage preflight for PR review submissions (#544)

* add one-time diff coverage preflight for PR reviews

track diff read coverage from agent tool-use events and run a one-time pre-flight before review submission, with explicit coverage skip reasons for low-value files like lockfiles.

Made-with: Cursor

* add manual dispatch fallback for preview deploy workflow

allow preview repo and preview sync jobs to be run via workflow_dispatch with explicit PR number and branch inputs, so preview provisioning can be retriggered when pull_request events fail to fire.

Made-with: Cursor

* fix manual preview dispatch PR input wiring

use normalized PR number and branch env values for comment creation and script env wiring so workflow_dispatch preview runs can create and update PR-specific preview resources.

Made-with: Cursor

* remove obsolete snapshots invalidated by checkout instructions change

* fix diff coverage read offset handling and add local sanity-check guidance

normalize read offset semantics for diff coverage tracking, reuse shared range counting in review preflight, add focused diff coverage unit tests, and document the local play.ts testing workflow in AGENTS.md.

Made-with: Cursor

* add regenerated mcp test snapshots

capture snapshot files generated by the review comment and checkout formatting tests during pre-push validation so the branch remains clean and reproducible.

Made-with: Cursor

* add diff coverage preflight instrumentation logs

log diff coverage initialization in checkout_pr and emit preflight state/breakdown diagnostics in create_pull_request_review to debug missing coverage enforcement in preview e2e runs.

Made-with: Cursor

* add env override to force local cli execution in action runtime

support explicit local-cli execution via PULLFROG_FORCE_LOCAL_CLI so preview workflows can run branch action code instead of the npm fallback package during e2e debugging.

Made-with: Cursor

* add preview e2e debugging learnings for action runtime validation

capture the preview execution-path gotchas and one-time preflight verification pattern in AGENTS.md so future investigations validate the real runtime and avoid npm fallback confusion.

Made-with: Cursor

* reduce diff coverage log noise while preserving failure visibility

downgrade verbose diff coverage lifecycle diagnostics to debug, keep a concise info-level pre-flight failure signal, and document preview runtime debugging learnings in AGENTS.md.

Made-with: Cursor

* WIP

* tune sync.md: ff override + softer overlap verification

Made-with: Cursor

* chore: bump models snapshot for claude-opus-4-7

Made-with: Cursor

* rip out coverage_skips waiver from diff coverage pre-flight

Made-with: Cursor

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
This commit is contained in:
Colin McDonnell
2026-04-16 21:51:44 +00:00
committed by pullfrog[bot]
parent 5e6ff67623
commit 56a5d29598
11 changed files with 653 additions and 5 deletions
+14 -1
View File
@@ -178,6 +178,7 @@ type RunParams = {
cwd: string;
env: Record<string, string | undefined>;
todoTracker?: TodoTracker | undefined;
onToolUse?: ((event: { toolName: string; input: unknown }) => void) | undefined;
};
type ClaudeRunResult = AgentResult & { sessionId?: string | undefined };
@@ -221,6 +222,12 @@ async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
finalOutput = message;
} else if (block.type === "tool_use") {
const toolName = block.name || "unknown";
if (params.onToolUse) {
params.onToolUse({
toolName,
input: block.input,
});
}
thinkingTimer.markToolCall();
log.toolCall({ toolName, input: block.input || {} });
@@ -605,7 +612,13 @@ export const claude = agent({
log.debug(`» starting Pullfrog (Claude Code): node ${baseArgs.join(" ")}`);
log.debug(`» working directory: ${repoDir}`);
const runParams = { label: "Pullfrog", cwd: repoDir, env, todoTracker: ctx.todoTracker };
const runParams = {
label: "Pullfrog",
cwd: repoDir,
env,
todoTracker: ctx.todoTracker,
onToolUse: ctx.onToolUse,
};
let result = await runClaude({
...runParams,
+9
View File
@@ -257,6 +257,7 @@ type RunParams = {
cwd: string;
env: Record<string, string | undefined>;
todoTracker?: TodoTracker | undefined;
onToolUse?: ((event: { toolName: string; input: unknown }) => void) | undefined;
};
async function runOpenCode(params: RunParams): Promise<AgentResult> {
@@ -357,6 +358,13 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
stepHistory[stepHistory.length - 1]!.toolCalls.push(toolName);
}
if (params.onToolUse) {
params.onToolUse({
toolName,
input: event.part?.state?.input,
});
}
thinkingTimer.markToolCall();
log.toolCall({ toolName, input: event.part?.state?.input || {} });
@@ -659,6 +667,7 @@ export const opencode = agent({
cwd: repoDir,
env,
todoTracker: ctx.todoTracker,
onToolUse: ctx.onToolUse,
};
let result = await runOpenCode({
+6
View File
@@ -45,6 +45,11 @@ export interface AgentUsage {
costUsd?: number | undefined;
}
export interface AgentToolUseEvent {
toolName: string;
input: unknown;
}
/**
* Result returned by agent execution
*/
@@ -66,6 +71,7 @@ export interface AgentRunContext {
tmpdir: string;
instructions: ResolvedInstructions;
todoTracker?: TodoTracker | undefined;
onToolUse?: ((event: AgentToolUseEvent) => void) | undefined;
}
export interface Agent {
+14
View File
@@ -23,6 +23,7 @@ import { apiFetch } from "./utils/apiFetch.ts";
import { validateAgentApiKey } from "./utils/apiKeys.ts";
import { resolveBody } from "./utils/body.ts";
import { formatUsageSummary, log, writeSummary } from "./utils/cli.ts";
import { recordDiffReadFromToolUse } from "./utils/diffCoverage.ts";
import { reportErrorToComment } from "./utils/errorReport.ts";
import { onExitSignal } from "./utils/exitHandler.ts";
import { resolveGit, setGitAuthServer } from "./utils/gitAuth.ts";
@@ -419,6 +420,19 @@ export async function main(): Promise<MainResult> {
tmpdir,
instructions,
todoTracker,
onToolUse: (event) => {
const wasTracked = recordDiffReadFromToolUse({
state: toolState.diffCoverage,
toolName: event.toolName,
input: event.input,
cwd: process.cwd(),
});
if (!wasTracked) return;
const trackedRanges = toolState.diffCoverage?.coveredRanges ?? [];
log.debug(
`» diff coverage tracked from tool ${event.toolName} (${trackedRanges.length} merged range${trackedRanges.length === 1 ? "" : "s"})`
);
},
});
// timeout enforcement: default is 1 hour, but can be overridden via flags in the prompt:
+12 -2
View File
@@ -3,6 +3,7 @@ import { join } from "node:path";
import type { Octokit, RestEndpointMethodTypes } from "@octokit/rest";
import { type } from "arktype";
import { log } from "../utils/cli.ts";
import { countLines, createDiffCoverageState } from "../utils/diffCoverage.ts";
import { $git } from "../utils/gitAuth.ts";
import { executeLifecycleHook } from "../utils/lifecycle.ts";
import { computeIncrementalDiff } from "../utils/rangeDiff.ts";
@@ -504,6 +505,14 @@ export function CheckoutPrTool(ctx: ToolContext) {
const diffPath = join(tempDir, `pr-${pull_number}-${headShort}.diff`);
writeFileSync(diffPath, formatResult.content);
log.debug(`wrote diff to ${diffPath} (${formatResult.content.length} bytes)`);
ctx.toolState.diffCoverage = createDiffCoverageState({
diffPath,
totalLines: countLines({ content: formatResult.content }),
toc: formatResult.toc,
});
log.debug(
`» diff coverage initialized: diffPath=${diffPath}, totalLines=${ctx.toolState.diffCoverage.totalLines}, tocEntries=${ctx.toolState.diffCoverage.tocEntries.length}`
);
const incrementalInstructions = incrementalDiffPath
? ` IMPORTANT: incrementalDiffPath contains ONLY the changes since the last reviewed version ` +
@@ -527,9 +536,10 @@ export function CheckoutPrTool(ctx: ToolContext) {
toc: formatResult.toc,
instructions:
`the diff file at diffPath contains a table of contents (TOC) at the top listing every changed file with its line range. ` +
`use the line ranges to read specific files from the diff instead of reading the entire file. ` +
`use the TOC line ranges as your checklist and read specific files from the diff instead of reading the entire file. ` +
`for example, if the TOC says "src/foo.ts → lines 5-42", read lines 5-42 from diffPath to see that file's changes. ` +
`review files selectively based on relevance rather than reading everything sequentially. ` +
`before your review is submitted, a one-time coverage pre-flight may error listing unread TOC regions. ` +
`retry the same create_pull_request_review call to proceed — optionally after reading the listed ranges. the pre-flight will not block again this session. ` +
`the local branch is 'localBranch' (pr-{number}), not the remote branch name. ` +
`when pushing, omit branchName to use the current branch. do not use remoteBranch as a local branch name.` +
incrementalInstructions,
+64
View File
@@ -4,6 +4,11 @@ import { formatMcpToolRef } from "../external.ts";
import { getApiUrl } from "../utils/apiUrl.ts";
import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts";
import { log } from "../utils/cli.ts";
import {
countLinesInRanges,
getDiffCoverageBreakdown,
renderDiffCoverageBreakdown,
} from "../utils/diffCoverage.ts";
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.ts";
import type { ToolContext } from "./server.ts";
@@ -75,6 +80,7 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
"IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " +
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts. " +
"Use 'suggestion' to propose replacement code - MUST preserve exact indentation of original code. " +
"The first submission may error once with a one-time diff-coverage nudge listing unread TOC regions — retry with the same arguments and the pre-flight will not block again. " +
"Example replacing lines 42-44 (3 lines) with 5 lines: " +
`{ path: 'src/api.ts', start_line: 42, line: 44, suggestion: ' const result = await fetch(url);\\n if (!result.ok) {\\n log.error(result.status);\\n throw new Error("request failed");\\n }' }` +
" CONSTRAINT: Inline comments can ONLY target files and lines that appear in the PR diff." +
@@ -131,6 +137,9 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
);
}
}
runDiffCoveragePreflight({ ctx });
type ReviewComment = NonNullable<typeof params.comments>[number];
const reviewComments = comments.map((comment) => {
let commentBody = fixDoubleEscapedString(comment.body || "");
@@ -248,6 +257,61 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
});
}
function runDiffCoveragePreflight(params: { ctx: ToolContext }): void {
const coverageState = params.ctx.toolState.diffCoverage;
if (!coverageState) {
log.debug("diff coverage pre-flight skipped: no diffCoverage state present in toolState");
return;
}
if (coverageState.coveragePreflightRan) {
log.debug("diff coverage pre-flight skipped: already ran in this session");
return;
}
coverageState.coveragePreflightRan = true;
log.debug(
`diff coverage pre-flight start: diffPath=${coverageState.diffPath}, totalLines=${coverageState.totalLines}, tocEntries=${coverageState.tocEntries.length}, coveredRanges=${coverageState.coveredRanges.length}`
);
const breakdown = getDiffCoverageBreakdown({ state: coverageState });
const unread: Array<{ path: string; ranges: string; unreadLines: number }> = [];
let unreadLines = 0;
for (const file of breakdown.files) {
if (file.unreadRanges.length === 0) continue;
const rangesText = file.unreadRanges
.map((range) => `${range.startLine}-${range.endLine}`)
.join(", ");
const fileUnreadLines = countLinesInRanges({ ranges: file.unreadRanges });
unread.push({ path: file.filename, ranges: rangesText, unreadLines: fileUnreadLines });
unreadLines += fileUnreadLines;
}
coverageState.lastBreakdown = renderDiffCoverageBreakdown({
diffPath: coverageState.diffPath,
breakdown,
});
log.debug(
`diff coverage pre-flight breakdown: coveredLines=${breakdown.coveredLines}, unreadLines=${unreadLines}`
);
if (unreadLines === 0) {
log.debug("diff coverage pre-flight passed: no unread regions");
return;
}
log.info(
`diff coverage pre-flight nudge: unread lines=${unreadLines}, unread files=${unread.length}`
);
const unreadText = unread
.map((entry) => `- ${entry.path} (${entry.unreadLines} lines, ${entry.ranges})`)
.join("\n");
throw new Error(
`diff coverage pre-flight: some TOC regions were not read before review submission. ` +
`this is a one-time nudge — optionally read the ranges below from ${coverageState.diffPath}, then call create_pull_request_review again with the same arguments. ` +
`this pre-flight will not block again in this review session.\n\n` +
`unread TOC regions:\n${unreadText}\n\n` +
`${coverageState.lastBreakdown}`
);
}
type FooterOpts = { body: string; approved: boolean; hasComments: boolean };
async function createAndSubmitWithFooter(
+2
View File
@@ -9,6 +9,7 @@ import type { Mode } from "../modes.ts";
import type { PrepResult } from "../prep/index.ts";
import { closeBrowserDaemon } from "../utils/browser.ts";
import { log } from "../utils/cli.ts";
import type { DiffCoverageState } from "../utils/diffCoverage.ts";
import type { OctokitWithPlugins } from "../utils/github.ts";
import type { ResolvedPayload } from "../utils/payload.ts";
import type { RunContextData } from "../utils/runContextData.ts";
@@ -107,6 +108,7 @@ export interface ToolState {
usageEntries: AgentUsage[];
model?: string | undefined;
todoTracker?: TodoTracker | undefined;
diffCoverage?: DiffCoverageState | undefined;
}
interface InitToolStateParams {
+4 -2
View File
@@ -130,7 +130,7 @@ ${learningsStep(t, 6)}`,
"Review code, PRs, or implementations; provide feedback or suggestions; identify issues; or check code quality, style, and correctness",
prompt: `### Checklist
1. Checkout the PR via \`${t("checkout_pr")}\` — this returns PR metadata and a \`diffPath\`. Read the diff to identify the major areas of change.
1. Checkout the PR via \`${t("checkout_pr")}\` — this returns PR metadata and a \`diffPath\`. read the diff TOC first and treat its file line ranges as your coverage checklist.
2. For each area of change:
- read the diff and trace data flow, check boundaries, and verify assumptions
@@ -147,6 +147,7 @@ ${learningsStep(t, 6)}`,
4. Submit — ALWAYS submit exactly one review via \`${t("create_pull_request_review")}\`.
Do NOT call \`report_progress\` — the review is the final record and the progress
comment will be cleaned up automatically.
note: the first create_pull_request_review submission may error with a one-time diff-coverage nudge listing unread TOC regions. retry the same call to proceed — optionally after reading the listed ranges. the pre-flight will not block again this session.
- **critical issues** (blocks merge — bugs, security, data loss):
\`approved: false\`. Body begins with a GitHub alert blockquote, e.g.:
@@ -165,7 +166,7 @@ ${learningsStep(t, 6)}`,
"Re-review a PR after new commits are pushed; focus on new changes since the last review",
prompt: `### Checklist
1. Checkout the PR via \`${t("checkout_pr")}\` — this returns PR metadata, \`diffPath\` (full diff), and \`incrementalDiffPath\` (changes since last reviewed version, if available).
1. Checkout the PR via \`${t("checkout_pr")}\` — this returns PR metadata, \`diffPath\` (full diff), and \`incrementalDiffPath\` (changes since last reviewed version, if available). read the diff TOC first and use its line ranges as your coverage checklist.
2. If \`incrementalDiffPath\` is present, read it to see what changed since the last review. This is a range-diff that isolates the net changes, filtering out base branch noise. If not present, fall back to reviewing the full PR diff.
@@ -189,6 +190,7 @@ ${learningsStep(t, 6)}`,
- in some cases you may receive a complete diff for the whole pull request instead of an incremental one. when this happens, you will need to determine what changes have happened since Pullfrog's most recent review.
7. Submit — Do NOT call \`report_progress\` or \`create_issue_comment\` — the review is the final record and the progress comment will be cleaned up automatically. the review body always includes the reviewed changes from step 6a. append \`Prior review feedback:\\n\` with the checklist from step 6b only if any prior comments were addressed. Follow these rules:
- note: the first create_pull_request_review submission may error with a one-time diff-coverage nudge listing unread TOC regions. retry the same call to proceed — optionally after reading the listed ranges. the pre-flight will not block again this session.
- IF NO NEW ISSUES, NON-SUBSTANTIVE CHANGES ONLY (trivial formatting, import reordering, comment tweaks): do NOT submit a review. Do NOT call \`report_progress\`. Exit — the progress comment will be cleaned up automatically.
- ELSE IF NEW CRITICAL ISSUES (blocks merge): call \`${t("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. body opens with a GitHub alert blockquote (e.g. \`> [!CAUTION]\\n> This PR introduces ...\`), then the reviewed changes summary and prior feedback (if any).
- ELSE IF NEW RECOMMENDED CHANGES (non-critical): call \`${t("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. body opens with \`> [!IMPORTANT]\\n> ...\` alert, then the reviewed changes summary and prior feedback (if any).
+5
View File
@@ -77,6 +77,11 @@ function runLocalCli(context: RuntimeContext, cliArgs: string[]): void {
}
function runPullfrogCliInner(context: RuntimeContext, cliArgs: string[]): void {
if (process.env.PULLFROG_FORCE_LOCAL_CLI === "1") {
runLocalCli(context, cliArgs);
return;
}
if (context.actionRef === "main" && context.actionRepository === "pullfrog/pullfrog") {
runLocalCli(context, cliArgs);
return;
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import {
createDiffCoverageState,
getDiffCoverageBreakdown,
recordDiffReadFromToolUse,
} from "./diffCoverage.ts";
const diffPath = "/tmp/pr-1.diff";
const toc = `## Files (2)
- src/a.ts → lines 5-10
- yarn.lock → lines 12-20
---
`;
describe("diff coverage line checker", () => {
it("treats Read offsets as zero based", () => {
const state = createDiffCoverageState({
diffPath,
totalLines: 30,
toc,
});
const tracked = recordDiffReadFromToolUse({
state,
toolName: "Read",
input: {
filePath: diffPath,
offset: 0,
limit: 3,
},
cwd: "/",
});
expect(tracked).toBe(true);
const breakdown = getDiffCoverageBreakdown({ state });
expect(breakdown.coveredRanges).toEqual([{ startLine: 1, endLine: 3 }]);
});
it("treats ReadFile offsets as one based", () => {
const state = createDiffCoverageState({
diffPath,
totalLines: 30,
toc,
});
const tracked = recordDiffReadFromToolUse({
state,
toolName: "ReadFile",
input: {
path: diffPath,
offset: 1,
limit: 2,
},
cwd: "/",
});
expect(tracked).toBe(true);
const breakdown = getDiffCoverageBreakdown({ state });
expect(breakdown.coveredRanges).toEqual([{ startLine: 1, endLine: 2 }]);
});
it("supports negative offsets from file end", () => {
const state = createDiffCoverageState({
diffPath,
totalLines: 30,
toc,
});
const tracked = recordDiffReadFromToolUse({
state,
toolName: "Read",
input: {
path: diffPath,
offset: -2,
limit: 2,
},
cwd: "/",
});
expect(tracked).toBe(true);
const breakdown = getDiffCoverageBreakdown({ state });
expect(breakdown.coveredRanges).toEqual([{ startLine: 29, endLine: 30 }]);
});
it("computes per-file unread ranges from tracked reads", () => {
const state = createDiffCoverageState({
diffPath,
totalLines: 30,
toc,
});
recordDiffReadFromToolUse({
state,
toolName: "Read",
input: {
path: diffPath,
start_line: 5,
end_line: 6,
},
cwd: "/",
});
recordDiffReadFromToolUse({
state,
toolName: "Read",
input: {
path: diffPath,
start_line: 12,
end_line: 14,
},
cwd: "/",
});
const breakdown = getDiffCoverageBreakdown({ state });
const firstFile = breakdown.files[0];
const secondFile = breakdown.files[1];
expect(firstFile.filename).toBe("src/a.ts");
expect(firstFile.coveredRanges).toEqual([{ startLine: 5, endLine: 6 }]);
expect(firstFile.unreadRanges).toEqual([{ startLine: 7, endLine: 10 }]);
expect(secondFile.filename).toBe("yarn.lock");
expect(secondFile.coveredRanges).toEqual([{ startLine: 12, endLine: 14 }]);
expect(secondFile.unreadRanges).toEqual([{ startLine: 15, endLine: 20 }]);
});
});
+396
View File
@@ -0,0 +1,396 @@
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;
lastBreakdown?: string | undefined;
};
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[] = [];
for (const line of lines) {
const match = line.match(/^- (.+) (?:→|->) lines (\d+)-(\d+)$/);
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;
}
export function createDiffCoverageState(params: {
diffPath: string;
totalLines: number;
toc: string;
}): DiffCoverageState {
return {
diffPath: params.diffPath,
totalLines: params.totalLines,
tocEntries: parseDiffTocEntries({ toc: params.toc }),
coveredRanges: [],
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,
};
}
export function renderDiffCoverageBreakdown(params: {
diffPath: string;
breakdown: DiffCoverageBreakdown;
}): string {
const breakdown = params.breakdown;
const lines: string[] = [];
lines.push(`diff coverage report for \`${params.diffPath}\``);
lines.push(
`overall: ${breakdown.coveredLines}/${breakdown.totalLines} lines read (${breakdown.coveragePercent}%), unread: ${breakdown.unreadLines}`
);
lines.push(`covered ranges: ${formatRanges({ ranges: breakdown.coveredRanges })}`);
lines.push(`unread ranges: ${formatRanges({ ranges: breakdown.unreadRanges })}`);
lines.push("");
lines.push("per-file TOC coverage:");
for (const file of breakdown.files) {
const filePercent = file.totalLines
? Number(((file.coveredLines / file.totalLines) * 100).toFixed(2))
: 100;
lines.push(
`- ${file.filename} (toc lines ${file.startLine}-${file.endLine}): ${file.coveredLines}/${file.totalLines} lines read (${filePercent}%)`
);
lines.push(` read: ${formatRanges({ ranges: file.coveredRanges })}`);
lines.push(` unread: ${formatRanges({ ranges: file.unreadRanges })}`);
}
return lines.join("\n");
}
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 formatRanges(params: { ranges: DiffLineRange[] }): string {
if (params.ranges.length === 0) return "none";
return params.ranges.map((range) => `${range.startLine}-${range.endLine}`).join(", ");
}
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;
}