From 85583ea857e965411bb897134920a16d400327eb Mon Sep 17 00:00:00 2001 From: Ricardo Costeira Date: Thu, 29 Oct 2026 17:58:24 +0100 Subject: [PATCH 1/4] Measure how much of each hunk a Briefing's excerpts show --- src/briefing-coverage.ts | 69 +++++++++++++++++++++++ tests/briefing-coverage.test.ts | 98 +++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 src/briefing-coverage.ts create mode 100644 tests/briefing-coverage.test.ts diff --git a/src/briefing-coverage.ts b/src/briefing-coverage.ts new file mode 100644 index 0000000..d60d815 --- /dev/null +++ b/src/briefing-coverage.ts @@ -0,0 +1,69 @@ +import type { BriefingDocument, BriefingEvidence } from "./briefing.ts"; +import { lineRanges, type LineRange } from "./change-facts.ts"; +import { diffSourceIdentity } from "./diff-sources.ts"; +import type { GitComparison } from "./types.ts"; + +export type HunkCoverageState = "explained" | "partly-explained" | "not-explained"; +export interface HunkCoverage { path: string; hunk: number; header: string; state: HunkCoverageState; changedLines: number; cited: LineRange[]; expanded: LineRange[]; unexplained: LineRange[] } +export interface BriefingCoverage { state: "measured" | "unavailable"; reason?: string; comparisonId: string; documentId: string; hunks: HunkCoverage[]; totals: { hunks: number; explained: number; partlyExplained: number; notExplained: number; changedLines: number; cited: number; expanded: number; unexplained: number }; evidenceWithoutDiff: number } + +const emptyTotals = () => ({ hunks: 0, explained: 0, partlyExplained: 0, notExplained: 0, changedLines: 0, cited: 0, expanded: 0, unexplained: 0 }); + +function shownEvidenceIds(document: BriefingDocument): Set { + const ids = new Set(), diagrams = new Set(); + for (const block of document.sections.flatMap(s => s.blocks)) { + if (block.kind === "source") ids.add(block.evidenceId); + else if (block.kind === "diagram") diagrams.add(block.diagramId); + } + for (const diagram of document.diagrams) if (diagrams.has(diagram.id)) for (const element of diagram.elements) for (const id of element.evidenceIds) ids.add(id); + return ids; +} + +export function briefingCoverage(comparison: GitComparison, document: BriefingDocument): BriefingCoverage { + const comparisonId = diffSourceIdentity(comparison), documentId = document.id; + if (document.comparisonId !== comparisonId) return { state: "unavailable", reason: "The Briefing belongs to another captured comparison.", comparisonId, documentId, hunks: [], totals: emptyTotals(), evidenceWithoutDiff: 0 }; + const ids = shownEvidenceIds(document); + const shown = new Map>(), cited = new Map>(); + const mark = (map: Map>, key: string, line: number) => { let set = map.get(key); if (!set) map.set(key, set = new Set()); set.add(line); }; + let evidenceWithoutDiff = 0; + for (const evidence of document.evidence as BriefingEvidence[]) { + if (!ids.has(evidence.id) || !evidence.available || evidence.kind !== "code") continue; + if (evidence.diff === undefined) { evidenceWithoutDiff++; continue; } + const diff = evidence.diff; + if (!diff.available) continue; + const file = comparison.files.find(f => [f.path, f.oldPath, f.newPath].includes(evidence.path) && f.oldPath === diff.oldPath && f.newPath === diff.newPath); + if (!file) continue; + for (const row of diff.rows) { + const side = row.kind === "deletion" ? "old" : row.kind === "addition" ? "new" : undefined; + if (!side) continue; + const line = side === "old" ? row.oldLine : row.newLine; + if (line === null) continue; + mark(shown, `${file.path}\0${side}`, line); + if (side === evidence.side && line >= evidence.startLine && line <= evidence.endLine) mark(cited, `${file.path}\0${side}`, line); + } + } + const hunks: HunkCoverage[] = [], totals = emptyTotals(); + for (const file of comparison.files) { + file.hunks.forEach((hunk, index) => { + const lists = { cited: { old: [] as number[], new: [] as number[] }, expanded: { old: [] as number[], new: [] as number[] }, unexplained: { old: [] as number[], new: [] as number[] } }; + let changed = 0; + for (const row of hunk.rows) { + const side = row.kind === "deletion" ? "old" : row.kind === "addition" ? "new" : undefined; + if (!side) continue; + const line = side === "old" ? row.oldLine : row.newLine; + if (line === null) continue; + changed++; + const key = `${file.path}\0${side}`; + (cited.get(key)?.has(line) ? lists.cited : shown.get(key)?.has(line) ? lists.expanded : lists.unexplained)[side].push(line); + } + const ranges = (kind: keyof typeof lists) => [...lineRanges("old", lists[kind].old), ...lineRanges("new", lists[kind].new)]; + const count = (kind: keyof typeof lists) => lists[kind].old.length + lists[kind].new.length; + const unexplained = count("unexplained"); + const state: HunkCoverageState = unexplained === 0 ? "explained" : unexplained === changed ? "not-explained" : "partly-explained"; + hunks.push({ path: file.path, hunk: index, header: hunk.header, state, changedLines: changed, cited: ranges("cited"), expanded: ranges("expanded"), unexplained: ranges("unexplained") }); + totals.hunks++; totals.changedLines += changed; totals.cited += count("cited"); totals.expanded += count("expanded"); totals.unexplained += unexplained; + if (state === "explained") totals.explained++; else if (state === "not-explained") totals.notExplained++; else totals.partlyExplained++; + }); + } + return { state: "measured", comparisonId, documentId, hunks, totals, evidenceWithoutDiff }; +} diff --git a/tests/briefing-coverage.test.ts b/tests/briefing-coverage.test.ts new file mode 100644 index 0000000..ca6b1eb --- /dev/null +++ b/tests/briefing-coverage.test.ts @@ -0,0 +1,98 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { buildBriefing, type BriefingDocument } from "../src/briefing.ts"; +import { briefingCoverage, type HunkCoverage } from "../src/briefing-coverage.ts"; +import type { LineRange } from "../src/change-facts.ts"; +import type { GitComparison } from "../src/types.ts"; +import { capturedText, repositoryComparison } from "./comparison-fixtures.ts"; + +const outline = { title: "t", summary: "s", phases: [], flows: [], callFlows: [], findings: [] }; +const calcBase = ["// head", "const value = 10;", ...Array.from({ length: 40 }, (_, i) => `// context ${i}`)].join("\n") + "\n"; +const calcHead = ["// head", "const subtotal = 20;", "const value = subtotal * 0.9;", ...Array.from({ length: 40 }, (_, i) => `// context ${i}`)].join("\n") + "\n"; +const large = (name: string) => Array.from({ length: 200 }, (_, i) => `${name}(${i});`).join("\n") + "\n"; + +let cleanup: () => Promise, comparison: GitComparison; +beforeAll(async () => { + const fixture = await repositoryComparison( + { "calc.ts": calcBase, "log.ts": 'log("start");\nretry(1);\n', "large.ts": large("oldCall"), "other.ts": "const a = 1;\n" }, + { "calc.ts": calcHead, "log.ts": 'log("begin");\nretry(3);\n', "large.ts": large("newCall"), "other.ts": "const a = 2;\n" }, + ); + comparison = fixture.comparison; cleanup = fixture.cleanup; +}, 30_000); +afterAll(async () => { await cleanup?.(); }); + +async function quote(path: string, line: number) { return (await capturedText(comparison, "new", path)).split("\n")[line - 1]!; } +async function documentWith(cites: Array<[string, number]>, extra: Array<{ id: string; path: string; startLine: number; endLine: number; quote: string }> = []) { + const evidence = await Promise.all(cites.map(async ([path, line], i) => ({ id: `e${i}`, path, side: "new" as const, startLine: line, endLine: line, quote: await quote(path, line) }))); + return buildBriefing(comparison, outline, "", { + title: "t", sections: [{ id: "s", title: "s", blocks: evidence.map((e, i) => ({ id: `b${i}`, kind: "source" as const, evidenceId: e.id })) }], + evidence: [...evidence.map(e => ({ ...e })), ...extra.map(e => ({ ...e, side: "new" as const }))], + }); +} +const hunksOf = (coverage: ReturnType, path: string) => coverage.hunks.filter(h => h.path === path); +const size = (ranges: LineRange[]) => ranges.reduce((n, r) => n + r.endLine - r.startLine + 1, 0); +const lines = (ranges: LineRange[], side: "old" | "new") => ranges.filter(r => r.side === side).flatMap(r => Array.from({ length: r.endLine - r.startLine + 1 }, (_, i) => r.startLine + i)); + +test("a replacement cited on one side explains both sides and keeps cited apart from expanded", async () => { + const doc = await documentWith([["calc.ts", 3]]); + const [hunk] = hunksOf(briefingCoverage(comparison, doc), "calc.ts") as [HunkCoverage]; + expect(hunk.state).toBe("explained"); + expect(hunk.cited).toEqual([{ side: "new", startLine: 3, endLine: 3 }]); + expect(hunk.expanded).toEqual([{ side: "old", startLine: 2, endLine: 2 }, { side: "new", startLine: 2, endLine: 2 }]); + expect(hunk.unexplained).toEqual([]); +}, 30_000); + +test("lines expanded from a neighbouring citation are expanded, not cited", async () => { + const hunk = hunksOf(briefingCoverage(comparison, await documentWith([["log.ts", 1]])), "log.ts")[0]!; + expect(hunk.state).toBe("explained"); + expect(hunk.cited).toEqual([{ side: "new", startLine: 1, endLine: 1 }]); + expect(lines(hunk.cited, "new")).not.toContain(2); + expect(lines(hunk.expanded, "new")).toContain(2); + expect(lines(hunk.expanded, "old")).toContain(2); +}, 30_000); + +test("rows behind an omitted-lines marker stay unexplained", async () => { + const doc = await documentWith([["large.ts", 100]]); + const hunk = hunksOf(briefingCoverage(comparison, doc), "large.ts")[0]!; + expect(hunk.state).toBe("partly-explained"); + expect(hunk.unexplained.length).toBeGreaterThan(0); + const diff = doc.evidence[0]!.diff!; + if (!diff.available) throw Error(diff.reason); + const shownNew = new Set(diff.rows.map(r => r.newLine)), shownOld = new Set(diff.rows.map(r => r.oldLine)); + for (const line of lines(hunk.unexplained, "new")) expect(shownNew.has(line)).toBe(false); + for (const line of lines(hunk.unexplained, "old")) expect(shownOld.has(line)).toBe(false); +}, 30_000); + +test("an uncited file is not explained and every hunk partitions its changed lines", async () => { + const coverage = briefingCoverage(comparison, await documentWith([["calc.ts", 3], ["large.ts", 100]])); + const other = hunksOf(coverage, "other.ts")[0]!; + expect(other.state).toBe("not-explained"); + expect(other.unexplained).toEqual([{ side: "old", startLine: 1, endLine: 1 }, { side: "new", startLine: 1, endLine: 1 }]); + for (const hunk of coverage.hunks) { + const all = [...hunk.cited, ...hunk.expanded, ...hunk.unexplained].flatMap(r => lines([r], r.side).map(line => `${r.side}${line}`)); + expect(size(hunk.cited) + size(hunk.expanded) + size(hunk.unexplained)).toBe(hunk.changedLines); + expect(new Set(all).size).toBe(all.length); + } + expect(coverage.totals.hunks).toBe(coverage.hunks.length); + expect(coverage.totals.changedLines).toBe(coverage.hunks.reduce((n, h) => n + h.changedLines, 0)); +}, 30_000); + +test("another comparison makes the coverage unavailable", async () => { + const doc: BriefingDocument = { ...(await documentWith([["calc.ts", 3]])), comparisonId: "f".repeat(64) }; + const coverage = briefingCoverage(comparison, doc); + expect(coverage.state).toBe("unavailable"); + expect(coverage.hunks).toEqual([]); + expect(coverage.totals.hunks).toBe(0); +}, 30_000); + +test("evidence no block references counts as nothing until a diagram element shows it", async () => { + const extra = { id: "x1", path: "other.ts", startLine: 1, endLine: 1, quote: "const a = 2;" }; + const doc = await documentWith([["calc.ts", 3]], [extra]); + expect(doc.evidence.some(e => e.id === "x1" && e.available)).toBe(true); + expect(hunksOf(briefingCoverage(comparison, doc), "other.ts")[0]!.state).toBe("not-explained"); + const shown = structuredClone(doc); + shown.diagrams.push({ id: "d1", title: "d", family: "architecture", ir: {}, elements: [{ id: "x", label: "x", kind: "components", evidenceIds: ["x1"] }] }); + shown.sections[0]!.blocks.push({ id: "b-d1", kind: "diagram", diagramId: "d1" }); + const other = hunksOf(briefingCoverage(comparison, shown), "other.ts")[0]!; + expect(other.state).toBe("explained"); + expect(other.cited).toEqual([{ side: "new", startLine: 1, endLine: 1 }]); +}, 30_000); From 2d2d593f84a88aeb27595d650e8766ec63127068 Mon Sep 17 00:00:00 2001 From: Ricardo Costeira Date: Thu, 29 Oct 2026 18:05:06 +0100 Subject: [PATCH 2/4] Check Briefing coverage against the captured hunk rows and its totals --- src/briefing-coverage.ts | 28 +++++++++++--------- tests/briefing-coverage.test.ts | 46 +++++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/src/briefing-coverage.ts b/src/briefing-coverage.ts index d60d815..e94c9bc 100644 --- a/src/briefing-coverage.ts +++ b/src/briefing-coverage.ts @@ -1,7 +1,7 @@ -import type { BriefingDocument, BriefingEvidence } from "./briefing.ts"; -import { lineRanges, type LineRange } from "./change-facts.ts"; +import type { BriefingDocument } from "./briefing.ts"; +import { lineRanges, type LineRange, type LineSide } from "./change-facts.ts"; import { diffSourceIdentity } from "./diff-sources.ts"; -import type { GitComparison } from "./types.ts"; +import type { DiffRow, GitComparison } from "./types.ts"; export type HunkCoverageState = "explained" | "partly-explained" | "not-explained"; export interface HunkCoverage { path: string; hunk: number; header: string; state: HunkCoverageState; changedLines: number; cited: LineRange[]; expanded: LineRange[]; unexplained: LineRange[] } @@ -9,6 +9,12 @@ export interface BriefingCoverage { state: "measured" | "unavailable"; reason?: const emptyTotals = () => ({ hunks: 0, explained: 0, partlyExplained: 0, notExplained: 0, changedLines: 0, cited: 0, expanded: 0, unexplained: 0 }); +function changedLine(row: DiffRow): { side: LineSide; line: number } | undefined { + const side: LineSide | undefined = row.kind === "deletion" ? "old" : row.kind === "addition" ? "new" : undefined; + const line = side === "old" ? row.oldLine : side === "new" ? row.newLine : null; + return side && line !== null ? { side, line } : undefined; +} + function shownEvidenceIds(document: BriefingDocument): Set { const ids = new Set(), diagrams = new Set(); for (const block of document.sections.flatMap(s => s.blocks)) { @@ -26,7 +32,7 @@ export function briefingCoverage(comparison: GitComparison, document: BriefingDo const shown = new Map>(), cited = new Map>(); const mark = (map: Map>, key: string, line: number) => { let set = map.get(key); if (!set) map.set(key, set = new Set()); set.add(line); }; let evidenceWithoutDiff = 0; - for (const evidence of document.evidence as BriefingEvidence[]) { + for (const evidence of document.evidence) { if (!ids.has(evidence.id) || !evidence.available || evidence.kind !== "code") continue; if (evidence.diff === undefined) { evidenceWithoutDiff++; continue; } const diff = evidence.diff; @@ -34,10 +40,9 @@ export function briefingCoverage(comparison: GitComparison, document: BriefingDo const file = comparison.files.find(f => [f.path, f.oldPath, f.newPath].includes(evidence.path) && f.oldPath === diff.oldPath && f.newPath === diff.newPath); if (!file) continue; for (const row of diff.rows) { - const side = row.kind === "deletion" ? "old" : row.kind === "addition" ? "new" : undefined; - if (!side) continue; - const line = side === "old" ? row.oldLine : row.newLine; - if (line === null) continue; + const changed = changedLine(row); + if (!changed) continue; + const { side, line } = changed; mark(shown, `${file.path}\0${side}`, line); if (side === evidence.side && line >= evidence.startLine && line <= evidence.endLine) mark(cited, `${file.path}\0${side}`, line); } @@ -48,10 +53,9 @@ export function briefingCoverage(comparison: GitComparison, document: BriefingDo const lists = { cited: { old: [] as number[], new: [] as number[] }, expanded: { old: [] as number[], new: [] as number[] }, unexplained: { old: [] as number[], new: [] as number[] } }; let changed = 0; for (const row of hunk.rows) { - const side = row.kind === "deletion" ? "old" : row.kind === "addition" ? "new" : undefined; - if (!side) continue; - const line = side === "old" ? row.oldLine : row.newLine; - if (line === null) continue; + const at = changedLine(row); + if (!at) continue; + const { side, line } = at; changed++; const key = `${file.path}\0${side}`; (cited.get(key)?.has(line) ? lists.cited : shown.get(key)?.has(line) ? lists.expanded : lists.unexplained)[side].push(line); diff --git a/tests/briefing-coverage.test.ts b/tests/briefing-coverage.test.ts index ca6b1eb..221ef1d 100644 --- a/tests/briefing-coverage.test.ts +++ b/tests/briefing-coverage.test.ts @@ -57,9 +57,17 @@ test("rows behind an omitted-lines marker stay unexplained", async () => { expect(hunk.unexplained.length).toBeGreaterThan(0); const diff = doc.evidence[0]!.diff!; if (!diff.available) throw Error(diff.reason); + expect(diff.rows.some(r => r.kind === "meta")).toBe(true); const shownNew = new Set(diff.rows.map(r => r.newLine)), shownOld = new Set(diff.rows.map(r => r.oldLine)); - for (const line of lines(hunk.unexplained, "new")) expect(shownNew.has(line)).toBe(false); - for (const line of lines(hunk.unexplained, "old")) expect(shownOld.has(line)).toBe(false); + const captured = comparison.files.find(f => f.path === "large.ts")!.hunks[0]!.rows; + const hidden = (side: "old" | "new") => captured + .filter(r => r.kind === (side === "old" ? "deletion" : "addition")) + .map(r => (side === "old" ? r.oldLine : r.newLine)!) + .filter(line => !(side === "old" ? shownOld : shownNew).has(line)); + expect(hidden("new").length).toBeGreaterThan(0); + expect(hidden("old").length).toBeGreaterThan(0); + expect(lines(hunk.unexplained, "new")).toEqual(hidden("new")); + expect(lines(hunk.unexplained, "old")).toEqual(hidden("old")); }, 30_000); test("an uncited file is not explained and every hunk partitions its changed lines", async () => { @@ -71,9 +79,43 @@ test("an uncited file is not explained and every hunk partitions its changed lin const all = [...hunk.cited, ...hunk.expanded, ...hunk.unexplained].flatMap(r => lines([r], r.side).map(line => `${r.side}${line}`)); expect(size(hunk.cited) + size(hunk.expanded) + size(hunk.unexplained)).toBe(hunk.changedLines); expect(new Set(all).size).toBe(all.length); + const captured = comparison.files.find(f => f.path === hunk.path)!.hunks[hunk.hunk]!.rows; + expect(hunk.changedLines).toBe(captured.filter(r => r.kind === "addition" || r.kind === "deletion").length); } + const stateOf = (path: string) => hunksOf(coverage, path)[0]!.state; + expect([stateOf("calc.ts"), stateOf("large.ts"), stateOf("log.ts"), stateOf("other.ts")]).toEqual(["explained", "partly-explained", "not-explained", "not-explained"]); expect(coverage.totals.hunks).toBe(coverage.hunks.length); + expect(coverage.totals.explained).toBe(1); + expect(coverage.totals.partlyExplained).toBe(1); + expect(coverage.totals.notExplained).toBe(2); expect(coverage.totals.changedLines).toBe(coverage.hunks.reduce((n, h) => n + h.changedLines, 0)); + expect(coverage.totals.cited).toBe(coverage.hunks.reduce((n, h) => n + size(h.cited), 0)); + expect(coverage.totals.expanded).toBe(coverage.hunks.reduce((n, h) => n + size(h.expanded), 0)); + expect(coverage.totals.unexplained).toBe(coverage.hunks.reduce((n, h) => n + size(h.unexplained), 0)); +}, 30_000); + +test("shown code evidence without a diff is counted and explains nothing", async () => { + const doc = structuredClone(await documentWith([["calc.ts", 3]])); + delete doc.evidence[0]!.diff; + const coverage = briefingCoverage(comparison, doc); + expect(coverage.evidenceWithoutDiff).toBe(1); + expect(hunksOf(coverage, "calc.ts")[0]!.state).toBe("not-explained"); +}, 30_000); + +test("a diff that is unavailable explains nothing", async () => { + const doc = structuredClone(await documentWith([["calc.ts", 3]])); + doc.evidence[0]!.diff = { available: false, reason: "gone" }; + const coverage = briefingCoverage(comparison, doc); + expect(coverage.evidenceWithoutDiff).toBe(0); + expect(hunksOf(coverage, "calc.ts")[0]!.state).toBe("not-explained"); +}, 30_000); + +test("a diff whose paths do not match the comparison file explains nothing", async () => { + const doc = structuredClone(await documentWith([["calc.ts", 3]])); + const diff = doc.evidence[0]!.diff!; + if (!diff.available) throw Error(diff.reason); + diff.newPath = "elsewhere.ts"; + expect(hunksOf(briefingCoverage(comparison, doc), "calc.ts")[0]!.state).toBe("not-explained"); }, 30_000); test("another comparison makes the coverage unavailable", async () => { From aa7bb1c4bb6b6d9b300825d238b4bd580c0a906a Mon Sep 17 00:00:00 2001 From: Ricardo Costeira Date: Thu, 29 Oct 2026 18:09:23 +0100 Subject: [PATCH 3/4] Count document excerpts in Briefing coverage --- src/briefing-coverage.ts | 8 ++++- tests/briefing-coverage.test.ts | 52 ++++++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/briefing-coverage.ts b/src/briefing-coverage.ts index e94c9bc..1eae939 100644 --- a/src/briefing-coverage.ts +++ b/src/briefing-coverage.ts @@ -33,7 +33,13 @@ export function briefingCoverage(comparison: GitComparison, document: BriefingDo const mark = (map: Map>, key: string, line: number) => { let set = map.get(key); if (!set) map.set(key, set = new Set()); set.add(line); }; let evidenceWithoutDiff = 0; for (const evidence of document.evidence) { - if (!ids.has(evidence.id) || !evidence.available || evidence.kind !== "code") continue; + if (!ids.has(evidence.id) || !evidence.available) continue; + if (evidence.kind === "document") { + const file = comparison.files.find(f => (evidence.side === "new" ? f.newPath : f.oldPath) === evidence.path); + if (!file) continue; + for (let line = evidence.startLine; line <= evidence.endLine; line++) { mark(shown, `${file.path}\0${evidence.side}`, line); mark(cited, `${file.path}\0${evidence.side}`, line); } + continue; + } if (evidence.diff === undefined) { evidenceWithoutDiff++; continue; } const diff = evidence.diff; if (!diff.available) continue; diff --git a/tests/briefing-coverage.test.ts b/tests/briefing-coverage.test.ts index 221ef1d..5f2d6a2 100644 --- a/tests/briefing-coverage.test.ts +++ b/tests/briefing-coverage.test.ts @@ -3,7 +3,7 @@ import { buildBriefing, type BriefingDocument } from "../src/briefing.ts"; import { briefingCoverage, type HunkCoverage } from "../src/briefing-coverage.ts"; import type { LineRange } from "../src/change-facts.ts"; import type { GitComparison } from "../src/types.ts"; -import { capturedText, repositoryComparison } from "./comparison-fixtures.ts"; +import { capturedText, historicalComparison, repositoryComparison } from "./comparison-fixtures.ts"; const outline = { title: "t", summary: "s", phases: [], flows: [], callFlows: [], findings: [] }; const calcBase = ["// head", "const value = 10;", ...Array.from({ length: 40 }, (_, i) => `// context ${i}`)].join("\n") + "\n"; @@ -138,3 +138,53 @@ test("evidence no block references counts as nothing until a diagram element sho expect(other.state).toBe("explained"); expect(other.cited).toEqual([{ side: "new", startLine: 1, endLine: 1 }]); }, 30_000); + +test("a document excerpt cites exactly its lines and never expands", async () => { + const fixture = await repositoryComparison({ "notes.md": "# Notes\nend\n" }, { "notes.md": "# Notes\nbeta\ngamma\nend\n" }); + try { + const quoted = (await capturedText(fixture.comparison, "new", "notes.md")).split("\n").slice(1, 3).join("\n"); + const doc = await buildBriefing(fixture.comparison, outline, "", { + title: "t", sections: [{ id: "s", title: "s", blocks: [{ id: "b", kind: "source", evidenceId: "d1" }] }], + evidence: [{ id: "d1", path: "notes.md", side: "new", startLine: 2, endLine: 3, quote: quoted }], + }); + expect(doc.evidence[0]!.kind).toBe("document"); + expect(doc.evidence[0]!.available).toBe(true); + const hunk = briefingCoverage(fixture.comparison, doc).hunks[0]!; + expect(hunk.state).toBe("explained"); + expect(hunk.cited).toEqual([{ side: "new", startLine: 2, endLine: 3 }]); + expect(hunk.expanded).toEqual([]); + expect(hunk.unexplained).toEqual([]); + } finally { await fixture.cleanup(); } +}, 30_000); + +test("over 8f84166..740bada every changed line is counted exactly once", async () => { + const history = await historicalComparison("8f84166", "740bada"); + const rowsOf = (path: string) => history.files.find(f => f.path === path)!.hunks.flatMap(h => h.rows); + const revision = rowsOf("src/revision.ts").find(r => r.kind === "addition")!; + const gitRows = rowsOf("src/git.ts"); + const afterDeletion = gitRows.find((r, i) => r.kind === "addition" && gitRows[i - 1]?.kind === "deletion")!; + const cite = async (path: string, line: number, i: number) => ({ id: `e${i}`, path, side: "new" as const, startLine: line, endLine: line, quote: (await capturedText(history, "new", path)).split("\n")[line - 1]! }); + const evidence = [await cite("src/revision.ts", revision.newLine!, 0), await cite("src/git.ts", afterDeletion.newLine!, 1)]; + const doc = await buildBriefing(history, outline, "", { + title: "t", sections: [{ id: "s", title: "s", blocks: evidence.map((e, i) => ({ id: `b${i}`, kind: "source" as const, evidenceId: e.id })) }], evidence, + }); + const coverage = briefingCoverage(history, doc); + expect(coverage.state).toBe("measured"); + const changed = history.files.flatMap(f => f.hunks.flatMap(h => h.rows)).filter(r => r.kind === "addition" || r.kind === "deletion").length; + expect(coverage.totals.changedLines).toBe(changed); + for (const hunk of coverage.hunks) { + const all = [...hunk.cited, ...hunk.expanded, ...hunk.unexplained].flatMap(r => lines([r], r.side).map(line => `${r.side}${line}`)); + expect(new Set(all).size).toBe(all.length); + expect(size(hunk.cited) + size(hunk.expanded) + size(hunk.unexplained)).toBe(hunk.changedLines); + } + expect(coverage.hunks.filter(h => h.path === "src/revision.ts").some(h => h.state === "partly-explained")).toBe(true); +}, 30_000); + +test("over 57bf907..27b7e44 a Briefing holding no evidence explains nothing", async () => { + const history = await historicalComparison("57bf907", "27b7e44"); + const doc = await buildBriefing(history, outline, "", { title: "t", sections: [{ id: "s", title: "s", blocks: [{ id: "b", kind: "prose", text: "x", basis: "stated" }] }], evidence: [] }); + const coverage = briefingCoverage(history, doc); + expect(coverage.hunks.length).toBeGreaterThan(0); + expect(coverage.hunks.every(h => h.state === "not-explained")).toBe(true); + expect(coverage.totals.unexplained).toBe(coverage.totals.changedLines); +}, 30_000); From 3bb92ffbd93a9971a28c790f7eefb1965640ec4a Mon Sep 17 00:00:00 2001 From: Ricardo Costeira Date: Thu, 29 Oct 2026 18:22:12 +0100 Subject: [PATCH 4/4] Pin document excerpt coverage to the cited lines across sides and renames --- src/briefing-coverage.ts | 3 +- tests/briefing-coverage.test.ts | 53 +++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/briefing-coverage.ts b/src/briefing-coverage.ts index 1eae939..0961c3f 100644 --- a/src/briefing-coverage.ts +++ b/src/briefing-coverage.ts @@ -37,7 +37,8 @@ export function briefingCoverage(comparison: GitComparison, document: BriefingDo if (evidence.kind === "document") { const file = comparison.files.find(f => (evidence.side === "new" ? f.newPath : f.oldPath) === evidence.path); if (!file) continue; - for (let line = evidence.startLine; line <= evidence.endLine; line++) { mark(shown, `${file.path}\0${evidence.side}`, line); mark(cited, `${file.path}\0${evidence.side}`, line); } + const key = `${file.path}\0${evidence.side}`; + for (let line = evidence.startLine; line <= evidence.endLine; line++) { mark(shown, key, line); mark(cited, key, line); } continue; } if (evidence.diff === undefined) { evidenceWithoutDiff++; continue; } diff --git a/tests/briefing-coverage.test.ts b/tests/briefing-coverage.test.ts index 5f2d6a2..9c53546 100644 --- a/tests/briefing-coverage.test.ts +++ b/tests/briefing-coverage.test.ts @@ -157,6 +157,54 @@ test("a document excerpt cites exactly its lines and never expands", async () => } finally { await fixture.cleanup(); } }, 30_000); +test("a document excerpt in a replacement cites only its line and leaves the other side unexplained", async () => { + const fixture = await repositoryComparison({ "notes.md": "# Notes\nalpha\nend\n" }, { "notes.md": "# Notes\nbeta\ngamma\nend\n" }); + try { + const quoted = (await capturedText(fixture.comparison, "new", "notes.md")).split("\n")[1]!; + const doc = await buildBriefing(fixture.comparison, outline, "", { + title: "t", sections: [{ id: "s", title: "s", blocks: [{ id: "b", kind: "source", evidenceId: "d1" }] }], + evidence: [{ id: "d1", path: "notes.md", side: "new", startLine: 2, endLine: 2, quote: quoted }], + }); + expect(doc.evidence[0]!.kind).toBe("document"); + expect(doc.evidence[0]!.available).toBe(true); + const coverage = briefingCoverage(fixture.comparison, doc); + const hunk = coverage.hunks[0]!; + expect(hunk.state).toBe("partly-explained"); + expect(hunk.cited).toEqual([{ side: "new", startLine: 2, endLine: 2 }]); + expect(hunk.expanded).toEqual([]); + expect(hunk.unexplained).toEqual([{ side: "old", startLine: 2, endLine: 2 }, { side: "new", startLine: 3, endLine: 3 }]); + expect(coverage.evidenceWithoutDiff).toBe(0); + } finally { await fixture.cleanup(); } +}, 30_000); + +test("document excerpts on both sides of a renamed file explain its replacement by path", async () => { + const body = Array.from({ length: 20 }, (_, i) => `line ${i}`); + const before = ["# Old", ...body, "tail old", "end"].join("\n") + "\n"; + const after = ["# Old", ...body, "tail new", "end"].join("\n") + "\n"; + const fixture = await repositoryComparison({ "old-notes.md": before }, { "old-notes.md": null, "new-notes.md": after }); + try { + const file = fixture.comparison.files[0]!; + expect(fixture.comparison.files).toHaveLength(1); + expect(file.oldPath).toBe("old-notes.md"); + expect(file.newPath).toBe("new-notes.md"); + const oldLine = before.split("\n").indexOf("tail old") + 1, newLine = after.split("\n").indexOf("tail new") + 1; + const oldQuote = (await capturedText(fixture.comparison, "old", "old-notes.md")).split("\n")[oldLine - 1]!; + const newQuote = (await capturedText(fixture.comparison, "new", "new-notes.md")).split("\n")[newLine - 1]!; + const doc = await buildBriefing(fixture.comparison, outline, "", { + title: "t", sections: [{ id: "s", title: "s", blocks: [{ id: "b0", kind: "source", evidenceId: "d0" }, { id: "b1", kind: "source", evidenceId: "d1" }] }], + evidence: [ + { id: "d0", path: "old-notes.md", side: "old", startLine: oldLine, endLine: oldLine, quote: oldQuote }, + { id: "d1", path: "new-notes.md", side: "new", startLine: newLine, endLine: newLine, quote: newQuote }, + ], + }); + expect(doc.evidence.every(e => e.kind === "document" && e.available)).toBe(true); + const hunk = briefingCoverage(fixture.comparison, doc).hunks.find(h => h.path === file.path)!; + expect(hunk.state).toBe("explained"); + expect(hunk.cited).toEqual([{ side: "old", startLine: oldLine, endLine: oldLine }, { side: "new", startLine: newLine, endLine: newLine }]); + expect(hunk.expanded).toEqual([]); + } finally { await fixture.cleanup(); } +}, 30_000); + test("over 8f84166..740bada every changed line is counted exactly once", async () => { const history = await historicalComparison("8f84166", "740bada"); const rowsOf = (path: string) => history.files.find(f => f.path === path)!.hunks.flatMap(h => h.rows); @@ -168,8 +216,13 @@ test("over 8f84166..740bada every changed line is counted exactly once", async ( const doc = await buildBriefing(history, outline, "", { title: "t", sections: [{ id: "s", title: "s", blocks: evidence.map((e, i) => ({ id: `b${i}`, kind: "source" as const, evidenceId: e.id })) }], evidence, }); + expect(doc.evidence.every(e => e.available && e.kind === "code" && e.diff?.available)).toBe(true); const coverage = briefingCoverage(history, doc); expect(coverage.state).toBe("measured"); + const gitFile = history.files.find(f => f.path === "src/git.ts")!; + const gitHunk = coverage.hunks.find(h => h.path === "src/git.ts" && gitFile.hunks[h.hunk]!.rows.some(r => r.kind === "addition" && r.newLine === afterDeletion.newLine))!; + expect(lines(gitHunk.cited, "new")).toContain(afterDeletion.newLine!); + expect(gitHunk.expanded.length).toBeGreaterThan(0); const changed = history.files.flatMap(f => f.hunks.flatMap(h => h.rows)).filter(r => r.kind === "addition" || r.kind === "deletion").length; expect(coverage.totals.changedLines).toBe(changed); for (const hunk of coverage.hunks) {