diff --git a/review-enrichment/src/analyzers/coverage-delta.ts b/review-enrichment/src/analyzers/coverage-delta.ts new file mode 100644 index 0000000000..9cb1b40380 --- /dev/null +++ b/review-enrichment/src/analyzers/coverage-delta.ts @@ -0,0 +1,346 @@ +// Coverage-delta analyzer (#1516). Finds added/changed lines in the PR that are not covered by the +// project's own CI test suite. Uses the GitHub Actions artifact API to download the most recent +// successful run's coverage report (lcov / Istanbul JSON / Cobertura XML), parses line-hit counts, +// and correlates them against the PR patch hunks — all without a repo checkout. +// ZIP extraction uses Node.js built-in zlib (DEFLATE) so no extra dependency is needed. +// Fail-safe: returns [] on any network error, non-ok response, or unparseable artifact. +import type { EnrichRequest, CoverageDeltaFinding } from "../types.js"; +import { inflateRawSync } from "node:zlib"; + +const MAX_ARTIFACT_BYTES = 5 * 1024 * 1024; // 5 MB cap on artifact ZIP (skip oversized ones) +const MAX_COVERAGE_BYTES = 2 * 1024 * 1024; // 2 MB cap on any single uncompressed file in the ZIP +const MAX_RUNS_TO_CHECK = 5; // successful runs to search before giving up +const MAX_FILES_REPORTED = 15; +const MAX_LINES_PER_FILE = 20; + +// Artifact names that are likely coverage reports. Case-insensitive match against artifact.name. +const COVERAGE_ARTIFACT_RE = /coverage|lcov|cov[-_]report|test[-_]cov|codecoverage/i; + +// Map of normalized file path → Set of 1-indexed uncovered line numbers. +type CoverageMap = Map>; + +interface WorkflowRun { + id: number; + conclusion: string | null; + created_at: string; +} + +interface Artifact { + id: number; + name: string; + size_in_bytes: number; +} + +/** ZIP entry returned by readZipEntries. */ +export interface ZipEntry { + name: string; + data: Buffer; +} + +// ── Patch parsing ───────────────────────────────────────────────────────────── + +/** Extract 1-indexed line numbers (in the NEW file) for all added lines from a unified diff patch. */ +export function extractChangedLines(patch: string): Set { + const lines = new Set(); + let newLine = 0; + for (const line of patch.split("\n")) { + if (line.startsWith("+++") || line.startsWith("---")) continue; + const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line); + if (hunk) { newLine = Number(hunk[1]!); continue; } + if (line.startsWith("+")) { lines.add(newLine); newLine++; } + else if (!line.startsWith("-")) { newLine++; } + } + return lines; +} + +// ── Coverage format parsers ─────────────────────────────────────────────────── + +/** Parse an lcov report into a map of file → uncovered line numbers (DA:line,0 entries). */ +export function parseLcov(content: string): CoverageMap { + const map: CoverageMap = new Map(); + let currentFile = ""; + for (const rawLine of content.split("\n")) { + const line = rawLine.trim(); + if (line.startsWith("SF:")) { + currentFile = line.slice(3); + if (!map.has(currentFile)) map.set(currentFile, new Set()); + } else if (line.startsWith("DA:") && currentFile) { + const parts = line.slice(3).split(","); + const lineNum = Number(parts[0]); + const hits = Number(parts[1]); + if (Number.isFinite(lineNum) && Number.isFinite(hits) && hits === 0) { + map.get(currentFile)!.add(lineNum); + } + } else if (line === "end_of_record") { + currentFile = ""; + } + } + return map; +} + +/** Parse an Istanbul/NYC coverage-final.json into a map of file → uncovered line numbers. + * Each entry maps statement keys to hit counts; uncovered = s[key] === 0. */ +export function parseIstanbulJson(content: string): CoverageMap { + const map: CoverageMap = new Map(); + let data: Record; + try { + data = JSON.parse(content) as Record; + } catch { + return map; + } + if (typeof data !== "object" || data === null || Array.isArray(data)) return map; + + for (const [filePath, fileCov] of Object.entries(data)) { + if (typeof fileCov !== "object" || fileCov === null) continue; + const fc = fileCov as { + s?: Record; + statementMap?: Record; + }; + if (!fc.s || !fc.statementMap) continue; + const uncovered = new Set(); + for (const [key, hits] of Object.entries(fc.s)) { + if (hits === 0) { + const stmt = fc.statementMap[key]; + if (stmt) uncovered.add(stmt.start.line); + } + } + if (uncovered.size > 0) map.set(filePath, uncovered); + } + return map; +} + +/** Parse a Cobertura XML coverage report into a map of file → uncovered line numbers. + * Uses line-by-line scanning to avoid backtracking `[\s\S]*?` patterns (ReDoS risk on XML). */ +export function parseCoberturaXml(content: string): CoverageMap { + const map: CoverageMap = new Map(); + let currentFile = ""; + for (const rawLine of content.split("\n")) { + if (rawLine.includes("")) { + currentFile = ""; + } else if (rawLine.includes("= Math.max(0, buf.length - 65558); i--) { + if (buf.readUInt32LE(i) === 0x06054b50) { eocdAt = i; break; } + } + if (eocdAt < 0) return entries; + + const cdSize = buf.readUInt32LE(eocdAt + 12); + const cdStart = buf.readUInt32LE(eocdAt + 16); + if (cdStart + cdSize > buf.length) return entries; + + let pos = cdStart; + while (pos + 46 <= cdStart + cdSize) { + if (buf.readUInt32LE(pos) !== 0x02014b50) break; // central directory entry signature + const method = buf.readUInt16LE(pos + 10); + const compressedSize = buf.readUInt32LE(pos + 20); + const nameLen = buf.readUInt16LE(pos + 28); + const extraLen = buf.readUInt16LE(pos + 30); + const commentLen = buf.readUInt16LE(pos + 32); + const localOffset = buf.readUInt32LE(pos + 42); + const name = buf.slice(pos + 46, pos + 46 + nameLen).toString("utf-8"); + + const entrySize = 46 + nameLen + extraLen + commentLen; + if (pos + entrySize > cdStart + cdSize) break; + pos += entrySize; + + // Read local file header to find the actual data offset (local extra can differ from central). + if (localOffset + 30 > buf.length) continue; + const localNameLen = buf.readUInt16LE(localOffset + 26); + const localExtraLen = buf.readUInt16LE(localOffset + 28); + const dataStart = localOffset + 30 + localNameLen + localExtraLen; + if (dataStart + compressedSize > buf.length) continue; + const compData = buf.slice(dataStart, dataStart + compressedSize); + + let data: Buffer; + if (method === 0) { + if (compData.length > MAX_COVERAGE_BYTES) continue; // stored entry too large + data = compData; + } else if (method === 8) { + try { data = inflateRawSync(compData, { maxOutputLength: MAX_COVERAGE_BYTES }); } + catch { continue; } // RangeError from maxOutputLength or corrupt data → skip entry + } else { + continue; // unsupported compression method + } + + entries.push({ name, data }); + } + return entries; +} + +// ── Coverage file identification and parsing dispatch ───────────────────────── + +function coverageFileKind(name: string): "lcov" | "istanbul" | "cobertura" | null { + const base = name.split("/").pop()?.toLowerCase() ?? ""; + if (base === "lcov.info" || base.endsWith(".lcov")) return "lcov"; + if (base === "coverage-final.json") return "istanbul"; + if (base === "coverage.xml" || base === "cobertura.xml") return "cobertura"; + return null; +} + +function parseCoverage(kind: "lcov" | "istanbul" | "cobertura", content: string): CoverageMap { + if (kind === "lcov") return parseLcov(content); + if (kind === "istanbul") return parseIstanbulJson(content); + return parseCoberturaXml(content); +} + +/** True when covPath equals prFile or ends with / (handles absolute workspace-prefixed paths). + * Suffix matching can produce false positives when two distinct paths share a trailing component + * (e.g. `lib/utils.ts` in coverage matching PR file `utils.ts`); acceptable given the heuristic nature. */ +function pathMatches(covPath: string, prFile: string): boolean { + const c = covPath.replace(/\\/g, "/"); + const p = prFile.replace(/\\/g, "/"); + return c === p || c.endsWith("/" + p); +} + +// ── Analyzer entrypoint ─────────────────────────────────────────────────────── + +/** Analyzer entrypoint: find added/changed lines with zero test coverage using the repo's own CI run. */ +export async function scanCoverageDelta( + req: EnrichRequest, + fetchFn: typeof fetch, + opts?: { signal?: AbortSignal }, +): Promise { + const { repoFullName, headSha, githubToken, files = [] } = req; + if (!githubToken || !headSha) return []; + + const parts = repoFullName.split("/"); + const owner = parts[0]; + const repo = parts[1]; + if (!owner || !repo) return []; + const eOwner = encodeURIComponent(owner); + const eRepo = encodeURIComponent(repo); + + // Build the changed-line index from the PR patch before touching the network. + const changedLines = new Map>(); + for (const file of files) { + if (!file.patch) continue; + const lines = extractChangedLines(file.patch); + if (lines.size > 0) changedLines.set(file.path, lines); + } + if (changedLines.size === 0) return []; + + const headers: Record = { + Authorization: `Bearer ${githubToken}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }; + + // Fetch recent successful workflow runs for this head SHA. + let runs: WorkflowRun[]; + try { + const runsResp = await fetchFn( + `https://api.github.com/repos/${eOwner}/${eRepo}/actions/runs?head_sha=${encodeURIComponent(headSha)}&per_page=10`, + { headers, signal: opts?.signal }, + ); + if (!runsResp.ok) return []; + const runsJson = (await runsResp.json()) as { workflow_runs?: WorkflowRun[] }; + runs = (runsJson.workflow_runs ?? []) + .filter((r) => r.conclusion === "success") + .sort((a, b) => b.created_at.localeCompare(a.created_at)) + .slice(0, MAX_RUNS_TO_CHECK); + } catch { + return []; + } + + if (runs.length === 0) return []; + + // Walk runs most-recent-first and stop at the first one that has a coverage artifact. + let coverageArtifact: Artifact | null = null; + + for (const run of runs) { + let artifacts: Artifact[]; + try { + const artResp = await fetchFn( + `https://api.github.com/repos/${eOwner}/${eRepo}/actions/runs/${run.id}/artifacts`, + { headers, signal: opts?.signal }, + ); + if (!artResp.ok) continue; + const artJson = (await artResp.json()) as { artifacts?: Artifact[] }; + artifacts = artJson.artifacts ?? []; + } catch { + continue; + } + + const found = artifacts + .filter((a) => COVERAGE_ARTIFACT_RE.test(a.name) && a.size_in_bytes <= MAX_ARTIFACT_BYTES) + .sort((a, b) => a.size_in_bytes - b.size_in_bytes)[0]; + + if (found) { coverageArtifact = found; break; } + } + + if (!coverageArtifact) return []; + + // Download the artifact ZIP (GitHub responds with a redirect to a signed S3 URL; fetch follows it). + let zipBuffer: Buffer; + try { + const zipResp = await fetchFn( + `https://api.github.com/repos/${eOwner}/${eRepo}/actions/artifacts/${coverageArtifact.id}/zip`, + { headers, signal: opts?.signal }, + ); + if (!zipResp.ok) return []; + zipBuffer = Buffer.from(await zipResp.arrayBuffer()); + } catch { + return []; + } + + // Parse the first recognised coverage file inside the ZIP. + const zipEntries = readZipEntries(zipBuffer); + let coverageMap: CoverageMap | null = null; + for (const entry of zipEntries) { + const kind = coverageFileKind(entry.name); + if (!kind) continue; + const parsed = parseCoverage(kind, entry.data.toString("utf-8")); + if (parsed.size > 0) { coverageMap = parsed; break; } + } + if (!coverageMap) return []; + + // Correlate changed lines with uncovered lines. + const findings: CoverageDeltaFinding[] = []; + for (const [prFile, prLines] of changedLines) { + if (findings.length >= MAX_FILES_REPORTED) break; + + let uncoveredForFile: Set | null = null; + for (const [covPath, uncovered] of coverageMap) { + if (pathMatches(covPath, prFile)) { uncoveredForFile = uncovered; break; } + } + if (!uncoveredForFile) continue; + + const uncoveredChanged: number[] = []; + for (const line of prLines) { + if (uncoveredForFile.has(line)) uncoveredChanged.push(line); + if (uncoveredChanged.length >= MAX_LINES_PER_FILE) break; + } + if (uncoveredChanged.length === 0) continue; + + uncoveredChanged.sort((a, b) => a - b); + findings.push({ file: prFile, uncoveredLines: uncoveredChanged }); + } + + return findings; +} diff --git a/review-enrichment/src/brief.ts b/review-enrichment/src/brief.ts index ccb30f107b..5bed3693a3 100644 --- a/review-enrichment/src/brief.ts +++ b/review-enrichment/src/brief.ts @@ -14,6 +14,7 @@ import { scanInstallScripts } from "./analyzers/install-scripts.js"; import { scanActionPins } from "./analyzers/actions-pin.js"; import { scanEol } from "./analyzers/eol-check.js"; import { scanRedos } from "./analyzers/redos.js"; +import { scanCoverageDelta } from "./analyzers/coverage-delta.js"; import { scanCodeowners } from "./analyzers/codeowners.js"; import { scanSecretLog } from "./analyzers/secret-log.js"; import { renderBrief } from "./render.js"; @@ -29,6 +30,7 @@ const ANALYZERS: Record = { actionPin: (req) => scanActionPins(req), eol: (req) => scanEol(req), redos: (req) => scanRedos(req), + coverageDelta: (req, signal) => scanCoverageDelta(req, fetch, { signal }), codeowners: (req, signal) => scanCodeowners(req, fetch, { signal }), secretLog: (req, signal) => scanSecretLog(req, signal), }; diff --git a/review-enrichment/src/render.ts b/review-enrichment/src/render.ts index 5270f795e0..2ac68ebd26 100644 --- a/review-enrichment/src/render.ts +++ b/review-enrichment/src/render.ts @@ -129,6 +129,19 @@ export function renderBrief( } } + const coverageDeltas = findings.coverageDelta ?? []; + if (coverageDeltas.length) { + lines.push( + "### Changed lines not covered by tests (CI coverage artifact)", + ); + for (const item of coverageDeltas) { + const lineList = item.uncoveredLines.join(", "); + lines.push( + `- ${safeCodeSpan(item.file)} — uncovered changed lines: ${lineList}`, + ); + } + } + const codeownersViolations = findings.codeowners ?? []; if (codeownersViolations.length) { const allOwners = new Set(codeownersViolations.flatMap((f) => f.owners)); diff --git a/review-enrichment/src/types.ts b/review-enrichment/src/types.ts index 0136d320af..c387cc04fd 100644 --- a/review-enrichment/src/types.ts +++ b/review-enrichment/src/types.ts @@ -93,6 +93,13 @@ export interface RedosFinding { pattern: string; } +/** Changed lines in the PR that are not covered by the project's own CI coverage report (#1516). + * Sourced from GitHub Actions artifacts (lcov / Istanbul JSON / Cobertura XML). */ +export interface CoverageDeltaFinding { + file: string; + uncoveredLines: number[]; // sorted, 1-indexed new-file line numbers with zero hits +} + /** A changed file governed by a CODEOWNERS rule where the PR author is not listed as an owner (#1515). * The blast radius (distinct ownership domains crossed) is derived at render time from the full findings set. */ export interface CodeownersFinding { @@ -118,6 +125,7 @@ export interface BriefFindings { installScript?: InstallScriptFinding[]; eol?: EolFinding[]; redos?: RedosFinding[]; + coverageDelta?: CoverageDeltaFinding[]; codeowners?: CodeownersFinding[]; secretLog?: SecretLogFinding[]; } diff --git a/review-enrichment/test/enrichment.test.ts b/review-enrichment/test/enrichment.test.ts index 94c475f4d4..a51ec25bc7 100644 --- a/review-enrichment/test/enrichment.test.ts +++ b/review-enrichment/test/enrichment.test.ts @@ -1,5 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { deflateRawSync } from "node:zlib"; import { extractDependencyChanges, queryOsv, @@ -21,6 +22,14 @@ import { scanPatchForRedos, scanRedos, } from "../dist/analyzers/redos.js"; +import { + extractChangedLines, + parseLcov, + parseIstanbulJson, + parseCoberturaXml, + readZipEntries, + scanCoverageDelta, +} from "../dist/analyzers/coverage-delta.js"; import { findOwners, parseCodeowners, @@ -1034,6 +1043,486 @@ test("buildBrief: eol analyzer runs (real now, 2023 cycle is past)", async () => } }); +// ── coverage-delta helpers ──────────────────────────────────────────────────── + +/** Build a minimal stored-only (method=0) ZIP buffer from an array of {name, data} entries. */ +function makeStoredZip(entries) { + const localParts = []; + const cdParts = []; + let offset = 0; + + for (const { name, data } of entries) { + const nameBytes = Buffer.from(name, "utf-8"); + const dataBytes = Buffer.isBuffer(data) ? data : Buffer.from(data, "utf-8"); + + const lh = Buffer.alloc(30); + lh.writeUInt32LE(0x04034b50, 0); + lh.writeUInt16LE(0, 4); lh.writeUInt16LE(0, 6); lh.writeUInt16LE(0, 8); + lh.writeUInt16LE(0, 10); lh.writeUInt16LE(0, 12); lh.writeUInt32LE(0, 14); + lh.writeUInt32LE(dataBytes.length, 18); + lh.writeUInt32LE(dataBytes.length, 22); + lh.writeUInt16LE(nameBytes.length, 26); lh.writeUInt16LE(0, 28); + + const cd = Buffer.alloc(46); + cd.writeUInt32LE(0x02014b50, 0); + cd.writeUInt16LE(0, 4); cd.writeUInt16LE(0, 6); cd.writeUInt16LE(0, 8); + cd.writeUInt16LE(0, 10); cd.writeUInt16LE(0, 12); cd.writeUInt16LE(0, 14); + cd.writeUInt32LE(0, 16); + cd.writeUInt32LE(dataBytes.length, 20); + cd.writeUInt32LE(dataBytes.length, 24); + cd.writeUInt16LE(nameBytes.length, 28); cd.writeUInt16LE(0, 30); + cd.writeUInt16LE(0, 32); cd.writeUInt16LE(0, 34); cd.writeUInt16LE(0, 36); + cd.writeUInt32LE(0, 38); cd.writeUInt32LE(offset, 42); + + localParts.push(lh, nameBytes, dataBytes); + cdParts.push(cd, nameBytes); + offset += 30 + nameBytes.length + dataBytes.length; + } + + const cd = Buffer.concat(cdParts); + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(0, 4); eocd.writeUInt16LE(0, 6); + eocd.writeUInt16LE(entries.length, 8); eocd.writeUInt16LE(entries.length, 10); + eocd.writeUInt32LE(cd.length, 12); eocd.writeUInt32LE(offset, 16); + eocd.writeUInt16LE(0, 20); + return Buffer.concat([...localParts, cd, eocd]); +} + +const PATCH_WITH_ADDS = [ + "@@ -1,3 +1,5 @@", + " context", + "+added line 2", + " context", + "+added line 4", + "-removed", + " context", +].join("\n"); + +const LCOV_CONTENT = [ + "SF:src/foo.ts", + "DA:2,0", // uncovered + "DA:3,1", // covered + "DA:4,0", // uncovered + "end_of_record", + "SF:src/bar.ts", + "DA:1,1", + "end_of_record", +].join("\n"); + +const ISTANBUL_CONTENT = JSON.stringify({ + "/workspace/src/foo.ts": { + s: { "0": 0, "1": 1, "2": 0 }, + statementMap: { + "0": { start: { line: 2, column: 0 }, end: { line: 2, column: 10 } }, + "1": { start: { line: 3, column: 0 }, end: { line: 3, column: 10 } }, + "2": { start: { line: 4, column: 0 }, end: { line: 4, column: 10 } }, + }, + }, +}); + +const COBERTURA_CONTENT = [ + '', + "", + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + "", +].join("\n"); + +const COV_REQ = (overrides = {}) => ({ + repoFullName: "owner/repo", + prNumber: 1, + headSha: "abc123", + githubToken: "tok", + files: [{ path: "src/foo.ts", patch: PATCH_WITH_ADDS }], + ...overrides, +}); + +const RUNS_RESP = { workflow_runs: [{ id: 42, conclusion: "success", created_at: "2026-06-01T00:00:00Z" }] }; +const ARTIFACTS_RESP = { artifacts: [{ id: 7, name: "coverage", size_in_bytes: 1000 }] }; + +/** Slice a Buffer into a standalone ArrayBuffer (avoids Node pool byteOffset issues). */ +function toArrayBuffer(buf) { + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); +} + +function makeCovFetch(lcovContent = LCOV_CONTENT) { + const zip = makeStoredZip([{ name: "lcov.info", data: lcovContent }]); + const zipAb = toArrayBuffer(zip); + return async (url) => { + const u = String(url); + if (u.includes("/actions/runs?")) return { ok: true, json: async () => RUNS_RESP }; + if (u.includes("/actions/runs/42/artifacts")) return { ok: true, json: async () => ARTIFACTS_RESP }; + if (u.includes("/actions/artifacts/7/zip")) + return { ok: true, arrayBuffer: async () => zipAb }; + return { ok: false, json: async () => ({}) }; + }; +} + +// ── extractChangedLines ─────────────────────────────────────────────────────── + +test("extractChangedLines: tracks added lines by 1-indexed new-file position, skips removed/context", () => { + const lines = extractChangedLines(PATCH_WITH_ADDS); + assert.ok(lines.has(2), "line 2 added"); + assert.ok(lines.has(4), "line 4 added"); + assert.ok(!lines.has(1), "line 1 is context"); + assert.ok(!lines.has(3), "line 3 is context"); + assert.equal(lines.size, 2); +}); + +test("extractChangedLines: handles multiple hunks with correct line offsets", () => { + const patch = "@@ -1,0 +1,2 @@\n+a\n+b\n@@ -5,0 +7,1 @@\n+c"; + const lines = extractChangedLines(patch); + assert.ok(lines.has(1)); + assert.ok(lines.has(2)); + assert.ok(lines.has(7)); + assert.equal(lines.size, 3); +}); + +// ── parseLcov ───────────────────────────────────────────────────────────────── + +test("parseLcov: extracts uncovered lines (DA:line,0) per file, ignores covered", () => { + const map = parseLcov(LCOV_CONTENT); + const fooUncovered = map.get("src/foo.ts")!; + assert.ok(fooUncovered.has(2)); + assert.ok(fooUncovered.has(4)); + assert.ok(!fooUncovered.has(3)); + assert.equal(map.get("src/bar.ts")?.size ?? 0, 0, "bar.ts has no uncovered lines"); +}); + +test("parseLcov: returns empty map for empty content", () => { + assert.equal(parseLcov("").size, 0); +}); + +// ── parseIstanbulJson ───────────────────────────────────────────────────────── + +test("parseIstanbulJson: extracts uncovered statements by line; path preserved as key", () => { + const map = parseIstanbulJson(ISTANBUL_CONTENT); + const fooUncovered = map.get("/workspace/src/foo.ts")!; + assert.ok(fooUncovered.has(2)); + assert.ok(fooUncovered.has(4)); + assert.ok(!fooUncovered.has(3)); +}); + +test("parseIstanbulJson: returns empty map for invalid JSON", () => { + assert.equal(parseIstanbulJson("not json").size, 0); +}); + +test("parseIstanbulJson: returns empty map for non-object root", () => { + assert.equal(parseIstanbulJson("[]").size, 0); +}); + +test("parseIstanbulJson: skips file entries missing s or statementMap", () => { + const content = JSON.stringify({ "a.ts": { path: "a.ts" } }); + assert.equal(parseIstanbulJson(content).size, 0); +}); + +// ── parseCoberturaXml ───────────────────────────────────────────────────────── + +test("parseCoberturaXml: extracts zero-hit lines per class filename", () => { + const map = parseCoberturaXml(COBERTURA_CONTENT); + const fooUncovered = map.get("src/foo.ts")!; + assert.ok(fooUncovered.has(2)); + assert.ok(fooUncovered.has(4)); + assert.ok(!fooUncovered.has(3)); +}); + +test("parseCoberturaXml: returns empty set for file with no zero-hit lines", () => { + const xml = ''; + const map = parseCoberturaXml(xml); + assert.equal(map.get("a.ts")?.size ?? 0, 0); +}); + +// ── readZipEntries ──────────────────────────────────────────────────────────── + +test("readZipEntries: extracts stored entries by name and data", () => { + const zip = makeStoredZip([ + { name: "lcov.info", data: "SF:a\nDA:1,0\nend_of_record" }, + { name: "other.txt", data: "hello" }, + ]); + const entries = readZipEntries(zip); + assert.equal(entries.length, 2); + assert.equal(entries[0].name, "lcov.info"); + assert.ok(entries[0].data.toString().includes("DA:1,0")); + assert.equal(entries[1].name, "other.txt"); +}); + +test("readZipEntries: returns [] for a buffer that is not a ZIP", () => { + assert.deepEqual(readZipEntries(Buffer.from("not a zip")), []); +}); + +test("readZipEntries: returns [] for an empty buffer", () => { + assert.deepEqual(readZipEntries(Buffer.alloc(0)), []); +}); + +test("readZipEntries: skips DEFLATE entry whose decompressed size exceeds MAX_COVERAGE_BYTES (decompression bomb guard)", () => { + // 2 MB + 1 byte of repetitive data — compresses to a tiny payload but exceeds the cap on decompression. + const MAX_COVERAGE_BYTES = 2 * 1024 * 1024; + const uncompressed = Buffer.alloc(MAX_COVERAGE_BYTES + 1, 0x41); + const compressed = deflateRawSync(uncompressed); + + // Build a minimal single-entry ZIP with compression method 8 (DEFLATE). + const name = Buffer.from("bomb.txt"); + const lh = Buffer.alloc(30); + lh.writeUInt32LE(0x04034b50, 0); // local file header signature + lh.writeUInt16LE(0, 4); lh.writeUInt16LE(0, 6); + lh.writeUInt16LE(8, 8); // compression method = DEFLATE + lh.writeUInt16LE(0, 10); lh.writeUInt16LE(0, 12); lh.writeUInt32LE(0, 14); + lh.writeUInt32LE(compressed.length, 18); + lh.writeUInt32LE(uncompressed.length, 22); + lh.writeUInt16LE(name.length, 26); lh.writeUInt16LE(0, 28); + + const cdStart = 30 + name.length + compressed.length; + const cd = Buffer.alloc(46); + cd.writeUInt32LE(0x02014b50, 0); // central directory entry signature + cd.writeUInt16LE(0, 4); // version made by + cd.writeUInt16LE(0, 6); // version needed + cd.writeUInt16LE(0, 8); // general purpose bit flag + cd.writeUInt16LE(8, 10); // compression method = DEFLATE + cd.writeUInt16LE(0, 12); cd.writeUInt16LE(0, 14); + cd.writeUInt32LE(0, 16); + cd.writeUInt32LE(compressed.length, 20); + cd.writeUInt32LE(uncompressed.length, 24); + cd.writeUInt16LE(name.length, 28); cd.writeUInt16LE(0, 30); cd.writeUInt16LE(0, 32); + cd.writeUInt16LE(0, 34); cd.writeUInt16LE(0, 36); cd.writeUInt32LE(0, 38); + cd.writeUInt32LE(0, 42); // local header offset = 0 + + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(0, 4); eocd.writeUInt16LE(0, 6); + eocd.writeUInt16LE(1, 8); eocd.writeUInt16LE(1, 10); + eocd.writeUInt32LE(46 + name.length, 12); // central directory size + eocd.writeUInt32LE(cdStart, 16); // central directory offset + eocd.writeUInt16LE(0, 20); + + const zip = toArrayBuffer(Buffer.concat([lh, name, compressed, cd, name, eocd])); + const entries = readZipEntries(Buffer.from(zip)); + assert.deepEqual(entries, [], "entry exceeding MAX_COVERAGE_BYTES must be skipped, not returned"); +}); + +// ── scanCoverageDelta ───────────────────────────────────────────────────────── + +test("scanCoverageDelta: returns [] when githubToken is absent", async () => { + const findings = await scanCoverageDelta( + COV_REQ({ githubToken: undefined }), + async () => { throw new Error("no fetch"); }, + ); + assert.deepEqual(findings, []); +}); + +test("scanCoverageDelta: returns [] when headSha is absent", async () => { + const findings = await scanCoverageDelta( + COV_REQ({ headSha: undefined }), + async () => { throw new Error("no fetch"); }, + ); + assert.deepEqual(findings, []); +}); + +test("scanCoverageDelta: returns [] when no files have patches", async () => { + const findings = await scanCoverageDelta( + COV_REQ({ files: [{ path: "src/foo.ts" }] }), + async () => { throw new Error("no fetch"); }, + ); + assert.deepEqual(findings, []); +}); + +test("scanCoverageDelta: returns [] on runs API non-ok response", async () => { + const findings = await scanCoverageDelta( + COV_REQ(), + async () => ({ ok: false, json: async () => ({}) }), + ); + assert.deepEqual(findings, []); +}); + +test("scanCoverageDelta: returns [] on runs API network error (fail-safe)", async () => { + const findings = await scanCoverageDelta(COV_REQ(), async () => { throw new Error("down"); }); + assert.deepEqual(findings, []); +}); + +test("scanCoverageDelta: returns [] when no successful runs are found", async () => { + const findings = await scanCoverageDelta( + COV_REQ(), + async () => ({ ok: true, json: async () => ({ workflow_runs: [{ id: 1, conclusion: "failure", created_at: "2026-01-01" }] }) }), + ); + assert.deepEqual(findings, []); +}); + +test("scanCoverageDelta: returns [] when artifacts API fails for all runs", async () => { + const findings = await scanCoverageDelta(COV_REQ(), async (url) => { + const u = String(url); + if (u.includes("/actions/runs?")) return { ok: true, json: async () => RUNS_RESP }; + return { ok: false, json: async () => ({}) }; + }); + assert.deepEqual(findings, []); +}); + +test("scanCoverageDelta: returns [] when no coverage artifact is found in the run", async () => { + const findings = await scanCoverageDelta(COV_REQ(), async (url) => { + const u = String(url); + if (u.includes("/actions/runs?")) return { ok: true, json: async () => RUNS_RESP }; + if (u.includes("/actions/runs/42/artifacts")) + return { ok: true, json: async () => ({ artifacts: [{ id: 9, name: "build-output", size_in_bytes: 500 }] }) }; + return { ok: false, json: async () => ({}) }; + }); + assert.deepEqual(findings, []); +}); + +test("scanCoverageDelta: returns [] when artifact ZIP download fails", async () => { + const findings = await scanCoverageDelta(COV_REQ(), async (url) => { + const u = String(url); + if (u.includes("/actions/runs?")) return { ok: true, json: async () => RUNS_RESP }; + if (u.includes("/actions/runs/42/artifacts")) return { ok: true, json: async () => ARTIFACTS_RESP }; + return { ok: false, json: async () => ({}) }; + }); + assert.deepEqual(findings, []); +}); + +test("scanCoverageDelta: returns [] when ZIP contains no recognised coverage file", async () => { + const zip = makeStoredZip([{ name: "readme.txt", data: "no coverage here" }]); + const zipAb = toArrayBuffer(zip); + const findings = await scanCoverageDelta(COV_REQ(), async (url) => { + const u = String(url); + if (u.includes("/actions/runs?")) return { ok: true, json: async () => RUNS_RESP }; + if (u.includes("/actions/runs/42/artifacts")) return { ok: true, json: async () => ARTIFACTS_RESP }; + if (u.includes("/actions/artifacts/7/zip")) + return { ok: true, arrayBuffer: async () => zipAb }; + return { ok: false }; + }); + assert.deepEqual(findings, []); +}); + +test("scanCoverageDelta: detects uncovered changed lines via lcov artifact", async () => { + const findings = await scanCoverageDelta(COV_REQ(), makeCovFetch()); + assert.equal(findings.length, 1); + assert.equal(findings[0].file, "src/foo.ts"); + // patch adds lines 2 and 4; lcov marks both as uncovered + assert.deepEqual(findings[0].uncoveredLines, [2, 4]); +}); + +test("scanCoverageDelta: detects uncovered changed lines via Istanbul JSON artifact", async () => { + const zip = makeStoredZip([{ name: "coverage-final.json", data: ISTANBUL_CONTENT }]); + const zipAb = toArrayBuffer(zip); + const findings = await scanCoverageDelta(COV_REQ(), async (url) => { + const u = String(url); + if (u.includes("/actions/runs?")) return { ok: true, json: async () => RUNS_RESP }; + if (u.includes("/actions/runs/42/artifacts")) return { ok: true, json: async () => ARTIFACTS_RESP }; + if (u.includes("/actions/artifacts/7/zip")) + return { ok: true, arrayBuffer: async () => zipAb }; + return { ok: false }; + }); + assert.equal(findings.length, 1); + assert.equal(findings[0].file, "src/foo.ts"); + assert.deepEqual(findings[0].uncoveredLines, [2, 4]); +}); + +test("scanCoverageDelta: detects uncovered changed lines via Cobertura XML artifact", async () => { + const zip = makeStoredZip([{ name: "coverage.xml", data: COBERTURA_CONTENT }]); + const zipAb = toArrayBuffer(zip); + const findings = await scanCoverageDelta(COV_REQ(), async (url) => { + const u = String(url); + if (u.includes("/actions/runs?")) return { ok: true, json: async () => RUNS_RESP }; + if (u.includes("/actions/runs/42/artifacts")) return { ok: true, json: async () => ARTIFACTS_RESP }; + if (u.includes("/actions/artifacts/7/zip")) + return { ok: true, arrayBuffer: async () => zipAb }; + return { ok: false }; + }); + assert.equal(findings.length, 1); + assert.deepEqual(findings[0].uncoveredLines, [2, 4]); +}); + +test("scanCoverageDelta: returns [] when all changed lines are covered", async () => { + // lcov reports all lines covered (hits > 0) + const lcov = "SF:src/foo.ts\nDA:2,5\nDA:4,3\nend_of_record\n"; + const findings = await scanCoverageDelta(COV_REQ(), makeCovFetch(lcov)); + assert.deepEqual(findings, []); +}); + +test("scanCoverageDelta: skips files not present in coverage report", async () => { + // PR adds lines to a different file than what's in the lcov + const lcov = "SF:src/other.ts\nDA:1,0\nend_of_record\n"; + const findings = await scanCoverageDelta(COV_REQ(), makeCovFetch(lcov)); + assert.deepEqual(findings, []); +}); + +test("scanCoverageDelta: forwards abort signal to fetch calls", async () => { + const seenSignals: AbortSignal[] = []; + const controller = new AbortController(); + await scanCoverageDelta(COV_REQ(), async (_url, init) => { + seenSignals.push(init.signal); + return { ok: true, json: async () => RUNS_RESP }; + }, { signal: controller.signal }); + assert.ok(seenSignals.length >= 1); + assert.ok(seenSignals.every((s) => s instanceof AbortSignal)); +}); + +test("scanCoverageDelta: percent-encodes owner, repo, and headSha in API URLs", async () => { + const urls: string[] = []; + await scanCoverageDelta( + COV_REQ({ repoFullName: "owner name/repo name", headSha: "sha with spaces" }), + async (url) => { + urls.push(String(url)); + return { ok: true, json: async () => ({ workflow_runs: [] }) }; + }, + ); + assert.ok(urls.length >= 1, "at least one fetch should be made"); + assert.ok( + urls[0].includes("owner%20name/repo%20name"), + `owner/repo segments should be encoded; got ${urls[0]}`, + ); + assert.ok( + urls[0].includes("head_sha=sha%20with%20spaces"), + `headSha should be encoded; got ${urls[0]}`, + ); +}); + +// ── renderBrief: coverage-delta ─────────────────────────────────────────────── + +test("renderBrief: renders the coverage-delta block with file and line list", () => { + const r = renderBrief({ + coverageDelta: [ + { file: "src/foo.ts", uncoveredLines: [5, 12, 23] }, + ], + }); + assert.match(r.promptSection, /Changed lines not covered by tests/); + assert.match(r.promptSection, /`src\/foo\.ts`/); + assert.match(r.promptSection, /5, 12, 23/); +}); + +test("renderBrief: sanitizes file paths in coverage-delta block", () => { + const r = renderBrief({ + coverageDelta: [ + { file: "src/evil`\nnext", uncoveredLines: [1] }, + ], + }); + // The raw newline in the filename must be replaced (not injected as a real newline into the output). + assert.ok(!r.promptSection.includes("src/evil\nnext"), "raw newline must be escaped"); + assert.match(r.promptSection, /`src\/evil/); +}); + +test("buildBrief: coverageDelta analyzer is wired into the orchestrator", async () => { + const realFetch = globalThis.fetch; + globalThis.fetch = makeCovFetch(); + try { + const brief = await buildBrief({ + repoFullName: "owner/repo", + prNumber: 1, + headSha: "abc123", + githubToken: "tok", + files: [{ path: "src/foo.ts", patch: PATCH_WITH_ADDS }], + }); + assert.equal(brief.analyzerStatus.coverageDelta, "ok"); + assert.equal(brief.findings.coverageDelta.length, 1); + assert.match(brief.promptSection, /Changed lines not covered/); + } finally { + globalThis.fetch = realFetch; + } +}); + test("codeOnly: blanks string messages, keeps ${...} interpolation bodies", () => { assert.equal(codeOnly('"a secret here"'), " "); assert.equal(codeOnly("'plain'"), " ");