urtext 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +229 -0
  3. package/dist/analyze/blast-radius.d.ts +28 -0
  4. package/dist/analyze/blast-radius.js +163 -0
  5. package/dist/analyze/canonical.d.ts +27 -0
  6. package/dist/analyze/canonical.js +74 -0
  7. package/dist/analyze/citations.d.ts +256 -0
  8. package/dist/analyze/citations.js +945 -0
  9. package/dist/analyze/effects.d.ts +15 -0
  10. package/dist/analyze/effects.js +255 -0
  11. package/dist/analyze/fact.d.ts +42 -0
  12. package/dist/analyze/fact.js +46 -0
  13. package/dist/analyze/guards.d.ts +70 -0
  14. package/dist/analyze/guards.js +211 -0
  15. package/dist/analyze/index.d.ts +26 -0
  16. package/dist/analyze/index.js +52 -0
  17. package/dist/analyze/program.d.ts +15 -0
  18. package/dist/analyze/program.js +229 -0
  19. package/dist/analyze/surface.d.ts +48 -0
  20. package/dist/analyze/surface.js +396 -0
  21. package/dist/bin.d.ts +2 -0
  22. package/dist/bin.js +12 -0
  23. package/dist/cli.d.ts +110 -0
  24. package/dist/cli.js +502 -0
  25. package/dist/extract/diff.d.ts +35 -0
  26. package/dist/extract/diff.js +116 -0
  27. package/dist/extract/git.d.ts +12 -0
  28. package/dist/extract/git.js +247 -0
  29. package/dist/extract/index.d.ts +4 -0
  30. package/dist/extract/index.js +57 -0
  31. package/dist/extract/intent.d.ts +64 -0
  32. package/dist/extract/intent.js +238 -0
  33. package/dist/extract/scope.d.ts +160 -0
  34. package/dist/extract/scope.js +284 -0
  35. package/dist/extract/symbols.d.ts +24 -0
  36. package/dist/extract/symbols.js +230 -0
  37. package/dist/interpret/client.d.ts +27 -0
  38. package/dist/interpret/client.js +80 -0
  39. package/dist/interpret/index.d.ts +41 -0
  40. package/dist/interpret/index.js +86 -0
  41. package/dist/interpret/prompt.d.ts +23 -0
  42. package/dist/interpret/prompt.js +128 -0
  43. package/dist/interpret/schema.d.ts +74 -0
  44. package/dist/interpret/schema.js +103 -0
  45. package/dist/report/conceal.d.ts +63 -0
  46. package/dist/report/conceal.js +129 -0
  47. package/dist/report/coverage.d.ts +43 -0
  48. package/dist/report/coverage.js +56 -0
  49. package/dist/report/html.d.ts +4 -0
  50. package/dist/report/html.js +634 -0
  51. package/dist/report/markdown.d.ts +2 -0
  52. package/dist/report/markdown.js +168 -0
  53. package/dist/report/model.d.ts +303 -0
  54. package/dist/report/model.js +289 -0
  55. package/dist/report/pdf.d.ts +2 -0
  56. package/dist/report/pdf.js +217 -0
  57. package/dist/report/terminal.d.ts +2 -0
  58. package/dist/report/terminal.js +206 -0
  59. package/dist/report/write.d.ts +105 -0
  60. package/dist/report/write.js +160 -0
  61. package/dist/score/index.d.ts +94 -0
  62. package/dist/score/index.js +572 -0
  63. package/dist/score/reach.d.ts +126 -0
  64. package/dist/score/reach.js +320 -0
  65. package/dist/score/reconcile.d.ts +52 -0
  66. package/dist/score/reconcile.js +208 -0
  67. package/dist/types.d.ts +221 -0
  68. package/dist/types.js +10 -0
  69. package/fonts/DejaVuSans-Bold.ttf +0 -0
  70. package/fonts/DejaVuSans-Oblique.ttf +0 -0
  71. package/fonts/DejaVuSans.ttf +0 -0
  72. package/fonts/DejaVuSansMono.ttf +0 -0
  73. package/fonts/LICENSE +187 -0
  74. package/package.json +44 -0
@@ -0,0 +1,289 @@
1
+ import { labelConcealed, segmentConcealed } from "./conceal.js";
2
+ import { deletedFilesNote, deletedTypeScriptFiles, suppressionNote } from "./coverage.js";
3
+ // Flat surfaces (terminal, Markdown, PDF) join segmented fields through
4
+ // `plainText` and never re-derive concealment themselves; re-exported here
5
+ // so a walker needs only this module to walk the model.
6
+ export { plainText } from "./conceal.js";
7
+ /**
8
+ * The order every surface lists the tiers in: strongest evidence first.
9
+ * Owned here so no walker can shuffle its counts, chips, or legend into a
10
+ * sequence the other surfaces do not use.
11
+ */
12
+ export const TIER_ORDER = ["verified", "inferred", "model"];
13
+ /**
14
+ * The lens display order and headings, owned here for the same reason
15
+ * `TIER_ORDER` owns the tier sequence: the HTML's tab strip and the
16
+ * Markdown's section headings both read from this one constant, and two
17
+ * walkers with private copies of an order are two orders waiting to diverge.
18
+ */
19
+ export const LENSES = [
20
+ { key: "narrative", label: "Narrative" },
21
+ { key: "effects", label: "Effects & contracts" },
22
+ { key: "surface", label: "API surface" },
23
+ ];
24
+ /**
25
+ * What an empty lens says: a sentence about the filter, never about the
26
+ * code. A lens is a view over findings the model classified by id prefix,
27
+ * and if that classification ever stops matching what the analyzers emit,
28
+ * the empty pane is what a user sees — so it must not be able to claim
29
+ * nothing changed while a removed guard sits ranked first elsewhere in the
30
+ * report. Shared by the HTML's empty effects pane and the Markdown's empty
31
+ * sections; a surface may append its own pointer to where the findings are,
32
+ * but the filter-shaped sentence itself is single-sourced here.
33
+ */
34
+ export const EMPTY_LENS_COPY = "Nothing in this range matched this view.";
35
+ /**
36
+ * What a findings-free run says — one sentence about the analyzers, never
37
+ * about the code being fine. Shared by the surfaces that state it whole (the
38
+ * terminal and the PDF); the Markdown has no single no-findings line, its
39
+ * lens sections each carry EMPTY_LENS_COPY instead. Owned here for the same
40
+ * reason as EMPTY_LENS_COPY: two walkers with private copies of a sentence
41
+ * are two sentences waiting to diverge.
42
+ */
43
+ export const NO_FINDINGS_COPY = "No findings. Nothing in this change tripped an analyzer.";
44
+ /**
45
+ * `path:line`, or `path:line (before)` when the line number counts in the
46
+ * before revision rather than the working tree — see `EvidenceView.side`
47
+ * for why the reader is owed that marker. Shared by the flat surfaces
48
+ * (terminal, Markdown, PDF), which had verbatim private copies; the HTML
49
+ * composes its own marked-up location and is deliberately not a consumer.
50
+ */
51
+ export function location(ref) {
52
+ return `${ref.file}:${ref.line}${ref.side === "before" ? " (before)" : ""}`;
53
+ }
54
+ /** The badge every surface shows on a marked finding. Composed here, once. */
55
+ export const BEYOND_INTENT_MARK = "beyond stated intent";
56
+ /**
57
+ * What the badge means, stated once per report rather than once per finding.
58
+ * Names the commit messages as the source and says what the comparison is not,
59
+ * because the badge alone reads stronger than the evidence behind it.
60
+ */
61
+ export const BEYOND_INTENT_MEANING = "“beyond stated intent” means the commit messages in this range do not account for what the change does there. It compares the change against its own description, not against anything a person actually asked for.";
62
+ /** The marks both surfaces already print, kept identical so every surface reads as one tool. */
63
+ export const TIER_GLYPH = {
64
+ verified: "▲",
65
+ inferred: "●",
66
+ model: "○",
67
+ };
68
+ /** The tier's display word — "model-only", not the bare tier value, where a reader sees it. */
69
+ export const TIER_WORD = {
70
+ verified: "verified",
71
+ inferred: "inferred",
72
+ model: "model-only",
73
+ };
74
+ export const TIER_MEANING = {
75
+ verified: "an analyzer found this and can point at the code",
76
+ inferred: "an analyzer found it; the explanation is the model's",
77
+ model: "the model alone — nothing mechanical corroborates it",
78
+ };
79
+ /**
80
+ * The name attached to model prose when the run recorded none. A run that
81
+ * produced model-tier findings without a model name is a bug upstream, not a
82
+ * licence to show the prose bare: the fallback keeps the attribution present
83
+ * and visibly incomplete rather than absent.
84
+ */
85
+ export const UNNAMED_MODEL = "an unnamed model";
86
+ /** The caveat beside prose the model authored with no fact underneath it. */
87
+ export const MODEL_CAUTION_STANDALONE = "Nothing mechanical corroborates this. Treat it as a lead to check, not a result.";
88
+ /** The caveat beside a claim's explanation of an analyzer's finding. */
89
+ export const MODEL_CAUTION_CLAIM = "The finding above is an analyzer's. This explanation of why it matters is not.";
90
+ /** How many referencing sites a finding lists before it stops listing them. */
91
+ export const REACH_SITES_SHOWN = 5;
92
+ /** The id `groupAddedExports` gives the finding that replaces a file's added-export findings. */
93
+ const EXPORT_GROUP_PREFIX = "export_added_group";
94
+ /** The id `groupSignatureChanges` gives the finding that replaces a file's signature_changed findings. */
95
+ const SIGNATURE_GROUP_PREFIX = "signature_changed_group";
96
+ /**
97
+ * Total over `FactKind` — `satisfies` makes a new kind a compile error here
98
+ * rather than a finding that silently falls out of every lens, unnoticed
99
+ * because nothing names it.
100
+ */
101
+ const SUBJECT_OF_KIND = {
102
+ effect_added: "effect",
103
+ effect_removed: "effect",
104
+ guard_removed: "guard",
105
+ export_added: "surface",
106
+ export_removed: "surface",
107
+ signature_changed: "surface",
108
+ blast_radius: "reach",
109
+ citation_rot: "citation",
110
+ };
111
+ /**
112
+ * Recovered from the finding's id, not from its prose. Every fact id begins
113
+ * with its `FactKind` and a colon (see `makeFact`'s callers), and the
114
+ * grouping passes key on the same convention — classifying on a title's
115
+ * wording instead would tie every surface to sentences `toFinding` is free
116
+ * to rewrite.
117
+ *
118
+ * A standalone model claim has no subject: `reconcile` prefixes those ids
119
+ * with `claim:`, which matches no kind. That is the intended outcome, not a
120
+ * gap — the filtered lenses show what analyzers proved, and the model's own
121
+ * claims have nothing mechanical behind them to classify.
122
+ *
123
+ * The convention itself is pinned against real analyzer output, not against
124
+ * hand-written ids, by `test/report/html.test.ts`, "every id a real analyzer
125
+ * produces starts with its own fact kind" — a fixture written to match the
126
+ * code cannot notice the code changing.
127
+ */
128
+ function subjectOf(id) {
129
+ const colon = id.indexOf(":");
130
+ // An id with no colon has no kind prefix at all. Slicing to what `indexOf`
131
+ // returns when it finds nothing would instead drop the id's last character
132
+ // and hand back a near-miss that can still match a kind — the one case
133
+ // this function exists to reject. See `test/report/model.test.ts`, "sends
134
+ // an id with no colon to the narrative alone".
135
+ if (colon < 0)
136
+ return undefined;
137
+ const prefix = id.slice(0, colon);
138
+ if (prefix === EXPORT_GROUP_PREFIX || prefix === SIGNATURE_GROUP_PREFIX)
139
+ return "surface";
140
+ return Object.hasOwn(SUBJECT_OF_KIND, prefix)
141
+ ? SUBJECT_OF_KIND[prefix]
142
+ : undefined;
143
+ }
144
+ /**
145
+ * The lens a subject's findings are gathered under. Effects and guards share
146
+ * a pane (as two sections); surface findings have their own; a standalone
147
+ * reach finding and a citation finding each belong to no filtered pane and
148
+ * live in the narrative, which shows every finding regardless of lens. A
149
+ * rotted citation is not an effect, not a guard, and not a change to the
150
+ * public surface: it belongs to the account of what this change did, which
151
+ * is what the narrative is.
152
+ */
153
+ const LENS_OF_SUBJECT = {
154
+ effect: "effects",
155
+ guard: "effects",
156
+ surface: "surface",
157
+ reach: "narrative",
158
+ citation: "narrative",
159
+ };
160
+ function plural(n, word) {
161
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
162
+ }
163
+ function toEvidenceView(ref) {
164
+ const view = {
165
+ file: labelConcealed(ref.file),
166
+ line: ref.line,
167
+ excerpt: segmentConcealed(ref.excerpt),
168
+ };
169
+ if (ref.side)
170
+ view.side = ref.side;
171
+ return view;
172
+ }
173
+ function toFindingView(finding, modelName) {
174
+ const subject = subjectOf(finding.id);
175
+ const lens = subject ? LENS_OF_SUBJECT[subject] : "narrative";
176
+ // A fact-derived finding has its file:line derived from the first evidence
177
+ // ref (see `makeFact`), so that ref's side annotation applies to the
178
+ // headline too. A model-tier finding carries no evidence at all, so
179
+ // `side` stays undefined and no marker is composed — correctly, since
180
+ // there is no before-side line to warn about.
181
+ const side = finding.evidence[0]?.side;
182
+ const file = labelConcealed(finding.file);
183
+ const title = segmentConcealed(finding.title);
184
+ // Segmented from the RAW composition — not assembled out of the flattened
185
+ // `file` string — so a concealing character in the path stays structural
186
+ // inside the headline even though the standalone `file` field flattens it.
187
+ const headline = segmentConcealed(`${finding.file}:${finding.line}${side === "before" ? " (before)" : ""} — ${finding.title}`);
188
+ const attribution = modelName ?? UNNAMED_MODEL;
189
+ // All model-authored prose flows through `modelNote`, whose attribution is
190
+ // built into the same object — there is no field a walker can read model
191
+ // prose from without also holding the model's name and the caution. A
192
+ // model-tier finding's whole body is the model's; an inferred finding
193
+ // keeps its analyzer body and carries the claim's reasoning beside it.
194
+ const modelNote = finding.tier === "model"
195
+ ? {
196
+ model: attribution,
197
+ text: segmentConcealed(finding.body),
198
+ caution: MODEL_CAUTION_STANDALONE,
199
+ }
200
+ : finding.claim
201
+ ? {
202
+ model: attribution,
203
+ text: segmentConcealed(finding.claim.reasoning),
204
+ caution: MODEL_CAUTION_CLAIM,
205
+ }
206
+ : undefined;
207
+ const body = finding.tier === "model" ? [] : [segmentConcealed(finding.body)];
208
+ // Reach sites are a sample even before this cap — the analyzer bounds what
209
+ // it collects while counting every reference — so `references` and the
210
+ // site list are reported apart rather than as "N of M". The cap itself is
211
+ // pinned by `test/report/model.test.ts`, "caps reach sites as the HTML
212
+ // report does and counts the overflow".
213
+ const reach = finding.reach && finding.reach.sites.length > 0
214
+ ? {
215
+ references: finding.reach.references,
216
+ sites: finding.reach.sites.slice(0, REACH_SITES_SHOWN).map(toEvidenceView),
217
+ overflow: Math.max(finding.reach.sites.length - REACH_SITES_SHOWN, 0),
218
+ }
219
+ : undefined;
220
+ const view = {
221
+ id: finding.id,
222
+ tier: finding.tier,
223
+ glyph: TIER_GLYPH[finding.tier],
224
+ lens,
225
+ headline,
226
+ title,
227
+ file,
228
+ line: finding.line,
229
+ body,
230
+ evidence: finding.evidence.map(toEvidenceView),
231
+ };
232
+ if (subject)
233
+ view.subject = subject;
234
+ if (side)
235
+ view.side = side;
236
+ if (modelNote)
237
+ view.modelNote = modelNote;
238
+ if (reach)
239
+ view.reach = reach;
240
+ if (finding.beyondIntent)
241
+ view.beyondIntent = BEYOND_INTENT_MARK;
242
+ return view;
243
+ }
244
+ export function buildReportModel(changeset, findings, meta) {
245
+ const fileCount = changeset.files.length;
246
+ const lineCount = changeset.files.reduce((n, f) => n + f.hunks.reduce((m, h) => m + h.newLines + h.oldLines, 0), 0);
247
+ const rangeLabel = labelConcealed(changeset.range.label);
248
+ const scope = `${plural(fileCount, "file")}, ${plural(lineCount, "line")} changed · ${rangeLabel}`;
249
+ const counts = { verified: 0, inferred: 0, model: 0 };
250
+ for (const f of findings)
251
+ counts[f.tier]++;
252
+ // An empty model name is treated as no name: `InterpretResult.model` is
253
+ // empty when the stage was skipped, and naming an empty string would
254
+ // attribute a stage that never ran.
255
+ const modelName = meta.model ? labelConcealed(meta.model) : undefined;
256
+ const provenance = modelName && (counts.inferred > 0 || counts.model > 0)
257
+ ? `${modelName} interpreted this change.`
258
+ : undefined;
259
+ const notes = meta.warnings.map(labelConcealed);
260
+ const untracked = changeset.untrackedCount ?? 0;
261
+ if (untracked > 0) {
262
+ notes.push(`${plural(untracked, "untracked file")} not reviewed — git diff does not include them.`);
263
+ }
264
+ const deleted = deletedTypeScriptFiles(changeset);
265
+ const coverageNote = deleted.length > 0 ? labelConcealed(deletedFilesNote(deleted)) : undefined;
266
+ const suppressed = meta.suppressed ?? 0;
267
+ const filterNote = suppressed > 0 ? suppressionNote(suppressed) : undefined;
268
+ const model = {
269
+ scope,
270
+ fileCount,
271
+ lineCount,
272
+ rangeLabel,
273
+ counts,
274
+ notes,
275
+ findings: findings.map((f) => toFindingView(f, modelName)),
276
+ };
277
+ if (provenance)
278
+ model.provenance = provenance;
279
+ if (modelName)
280
+ model.modelName = modelName;
281
+ if (coverageNote)
282
+ model.coverageNote = coverageNote;
283
+ if (filterNote)
284
+ model.filterNote = filterNote;
285
+ if (model.findings.some((f) => f.beyondIntent)) {
286
+ model.beyondIntentLegend = BEYOND_INTENT_MEANING;
287
+ }
288
+ return model;
289
+ }
@@ -0,0 +1,2 @@
1
+ import { type ReportModel } from "./model.js";
2
+ export declare function renderPdf(model: ReportModel): Promise<Buffer>;
@@ -0,0 +1,217 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { LENSES, location, NO_FINDINGS_COPY, plainText, TIER_ORDER, TIER_WORD, } from "./model.js";
3
+ /**
4
+ * The PDF surface, a walker over the report model — the client-presentable
5
+ * document, set in the SOW house style this project ports from
6
+ * a sibling project's exporter. Every sentence, tier, glyph, ordering, and
7
+ * disclosure rendered here is decided by `buildReportModel`; what this file
8
+ * owns is typesetting — fonts, sizes, the rule, the footer, and where the
9
+ * gray goes. Concealment arrives from the model as segments; this flat
10
+ * surface joins them through `plainText` and renders the labels verbatim.
11
+ *
12
+ * pdfkit is loaded inside `renderPdf` with a dynamic `import()` and nowhere
13
+ * else, so a run that never asks for a PDF never loads it — pinned by
14
+ * `test/report/pdf.test.ts`, "imports pdfkit dynamically and nowhere
15
+ * statically", which reads this file's source.
16
+ *
17
+ * Unlike the HTML's lens tabs and the Markdown's lens sections, this surface
18
+ * is one numbered list in model order — a client reads a single ranked
19
+ * document, with each finding's lens shown as a small caption rather than a
20
+ * grouping. The model's walker rule allows this (order is preserved, nothing
21
+ * is dropped), and `test/report/pdf.test.ts`, "renders every finding across
22
+ * pages — none silently truncated", pins it across page breaks. `ReachView`
23
+ * site lists are this surface's one density cut, exactly as they are the
24
+ * terminal's and the Markdown's: a reach finding's body already states the
25
+ * reference count, and the full site list stays on the HTML surface.
26
+ */
27
+ /**
28
+ * The embedded DejaVu family, committed under `fonts/` and shipped in the
29
+ * npm package so the renderer works wherever the package is installed. The
30
+ * accepted coverage trade: DejaVu covers Latin, Cyrillic, and Greek broadly,
31
+ * but not CJK or other scripts — full-Unicode fonts cost tens of megabytes.
32
+ */
33
+ const FONT_FILES = {
34
+ sans: "DejaVuSans.ttf",
35
+ bold: "DejaVuSans-Bold.ttf",
36
+ oblique: "DejaVuSans-Oblique.ttf",
37
+ mono: "DejaVuSansMono.ttf",
38
+ };
39
+ /** Registered-font names, one per file in `FONT_FILES`. */
40
+ const SANS = "urtext-sans";
41
+ const BOLD = "urtext-sans-bold";
42
+ const OBLIQUE = "urtext-sans-oblique";
43
+ const MONO = "urtext-mono";
44
+ /**
45
+ * Resolved relative to this module, so the same path works from `src/report/`
46
+ * under tsx and from `dist/report/` in an installed package — both sit one
47
+ * directory tree beside `fonts/`.
48
+ */
49
+ function fontPath(file) {
50
+ return fileURLToPath(new URL(`../../fonts/${file}`, import.meta.url));
51
+ }
52
+ const MARGIN = 54;
53
+ const TITLE_SIZE = 20;
54
+ const HEADING_SIZE = 13;
55
+ const BODY_SIZE = 11;
56
+ const META_SIZE = 10;
57
+ const CAPTION_SIZE = 9;
58
+ const FOOTER_SIZE = 8;
59
+ /** Where the footer baseline sits, measured up from the page's bottom edge. */
60
+ const FOOTER_RISE = 36;
61
+ const GRAY = "#666666";
62
+ const BLACK = "#000000";
63
+ /** The one sentence every page's footer carries, beside the page number. */
64
+ const FOOTER_LINE = "Generated by urtext — every finding is labeled by its evidence tier";
65
+ const LENS_LABEL = new Map(LENSES.map((l) => [l.key, l.label]));
66
+ /** A bold-label/value meta line, the SOW house style's header idiom. */
67
+ function metaLine(doc, label, value) {
68
+ doc
69
+ .font(BOLD)
70
+ .fontSize(META_SIZE)
71
+ .text(`${label}: `, { continued: true })
72
+ .font(SANS)
73
+ .text(value);
74
+ }
75
+ /**
76
+ * A whole-line bold disclosure. The honesty-critical lines — "This review is
77
+ * partial.", every `notes` entry, the filter note — all pass through here,
78
+ * so no restyling can quiet one of them without quieting the style itself.
79
+ */
80
+ function strongLine(doc, text) {
81
+ doc.font(BOLD).fontSize(META_SIZE).text(text);
82
+ }
83
+ function rule(doc) {
84
+ const y = doc.y;
85
+ doc
86
+ .moveTo(MARGIN, y)
87
+ .lineTo(doc.page.width - MARGIN, y)
88
+ .stroke();
89
+ doc.moveDown();
90
+ }
91
+ /**
92
+ * The only way model-authored text reaches this document: the attribution
93
+ * line, then the prose, then the trust caveat, all from the one
94
+ * `ModelNoteView` (see `./model.js`, where their inseparability is argued).
95
+ * Like the HTML and the Markdown, not gated on a recorded model name: the
96
+ * model's UNNAMED_MODEL fallback attribution shows instead, visibly
97
+ * incomplete rather than absent.
98
+ */
99
+ function modelNoteBlock(doc, note) {
100
+ doc.font(OBLIQUE).fontSize(META_SIZE).text(`unverified · ${note.model}`);
101
+ doc.font(SANS).fontSize(BODY_SIZE).text(plainText(note.text));
102
+ doc.font(OBLIQUE).fontSize(META_SIZE).text(note.caution);
103
+ }
104
+ function evidenceBlock(doc, ref) {
105
+ doc.font(OBLIQUE).fontSize(CAPTION_SIZE).fillColor(GRAY).text(location(ref)).fillColor(BLACK);
106
+ doc.font(MONO).fontSize(CAPTION_SIZE).text(plainText(ref.excerpt));
107
+ doc.moveDown(0.5);
108
+ }
109
+ function findingSection(doc, finding, ordinal) {
110
+ doc
111
+ .font(BOLD)
112
+ .fontSize(HEADING_SIZE)
113
+ .text(`${ordinal}. ${finding.glyph} ${plainText(finding.headline)} [${finding.tier}]` +
114
+ (finding.beyondIntent ? ` (${finding.beyondIntent})` : ""));
115
+ doc
116
+ .font(SANS)
117
+ .fontSize(CAPTION_SIZE)
118
+ .fillColor(GRAY)
119
+ .text(LENS_LABEL.get(finding.lens) ?? finding.lens)
120
+ .fillColor(BLACK);
121
+ doc.moveDown(0.5);
122
+ for (const paragraph of finding.body) {
123
+ doc.font(SANS).fontSize(BODY_SIZE).text(plainText(paragraph));
124
+ doc.moveDown(0.5);
125
+ }
126
+ if (finding.modelNote) {
127
+ modelNoteBlock(doc, finding.modelNote);
128
+ doc.moveDown(0.5);
129
+ }
130
+ for (const ref of finding.evidence) {
131
+ evidenceBlock(doc, ref);
132
+ }
133
+ doc.moveDown();
134
+ }
135
+ /**
136
+ * Stamped onto every buffered page once the content is laid out — only then
137
+ * is the page count known. The bottom margin is zeroed for the duration of
138
+ * each stamp: pdfkit starts a fresh page for any text that lands inside the
139
+ * margin, even absolutely positioned text, and a footer that adds a page —
140
+ * itself footerless — would break "prints the footer on every page" in
141
+ * `test/report/pdf.test.ts`.
142
+ */
143
+ function stampFooters(doc) {
144
+ const range = doc.bufferedPageRange();
145
+ for (let i = range.start; i < range.start + range.count; i++) {
146
+ doc.switchToPage(i);
147
+ const savedBottom = doc.page.margins.bottom;
148
+ doc.page.margins.bottom = 0;
149
+ const y = doc.page.height - FOOTER_RISE;
150
+ doc.font(SANS).fontSize(FOOTER_SIZE).fillColor(GRAY);
151
+ doc.text(FOOTER_LINE, MARGIN, y, { lineBreak: false });
152
+ doc.text(String(i + 1), MARGIN, y, {
153
+ width: doc.page.width - MARGIN * 2,
154
+ align: "right",
155
+ lineBreak: false,
156
+ });
157
+ doc.fillColor(BLACK);
158
+ doc.page.margins.bottom = savedBottom;
159
+ }
160
+ }
161
+ export async function renderPdf(model) {
162
+ const { default: PDFDocument } = await import("pdfkit");
163
+ return new Promise((resolve, reject) => {
164
+ const doc = new PDFDocument({ margin: MARGIN, bufferPages: true });
165
+ const chunks = [];
166
+ doc.on("data", (chunk) => chunks.push(chunk));
167
+ doc.on("end", () => resolve(Buffer.concat(chunks)));
168
+ doc.on("error", (err) => reject(err));
169
+ doc.registerFont(SANS, fontPath(FONT_FILES.sans));
170
+ doc.registerFont(BOLD, fontPath(FONT_FILES.bold));
171
+ doc.registerFont(OBLIQUE, fontPath(FONT_FILES.oblique));
172
+ doc.registerFont(MONO, fontPath(FONT_FILES.mono));
173
+ doc.font(BOLD).fontSize(TITLE_SIZE).text("urtext review");
174
+ doc.moveDown();
175
+ metaLine(doc, "Generated", new Date().toISOString().slice(0, 10));
176
+ metaLine(doc, "Range", model.scope);
177
+ metaLine(doc, "Model", model.modelName ?? "not asked");
178
+ metaLine(doc, "Evidence", TIER_ORDER.map((tier) => `${model.counts[tier]} ${TIER_WORD[tier]}`).join(" · "));
179
+ // The disclosures, every one, before the rule and the list: a reader has
180
+ // to know what the review could not see before reading the findings and
181
+ // concluding nothing else was found. Order matches the other surfaces:
182
+ // provenance, the partial-review notes, coverage, then the filter note.
183
+ if (model.provenance) {
184
+ doc.font(SANS).fontSize(META_SIZE).text(model.provenance);
185
+ }
186
+ if (model.notes.length > 0) {
187
+ // Gated on the model's `notes` exactly: non-empty means the review is
188
+ // partial and this surface must say so, in the same sentence the
189
+ // HTML's banner leads with.
190
+ strongLine(doc, "This review is partial.");
191
+ for (const note of model.notes) {
192
+ strongLine(doc, note);
193
+ }
194
+ }
195
+ if (model.coverageNote) {
196
+ doc.font(SANS).fontSize(META_SIZE).text(model.coverageNote);
197
+ }
198
+ if (model.filterNote) {
199
+ strongLine(doc, model.filterNote);
200
+ }
201
+ // Whole-line bold, like every honesty-critical line on this surface:
202
+ // never restyled away.
203
+ if (model.beyondIntentLegend) {
204
+ strongLine(doc, model.beyondIntentLegend);
205
+ }
206
+ doc.moveDown();
207
+ rule(doc);
208
+ if (model.findings.length === 0) {
209
+ doc.font(SANS).fontSize(BODY_SIZE).text(NO_FINDINGS_COPY);
210
+ }
211
+ for (const [index, finding] of model.findings.entries()) {
212
+ findingSection(doc, finding, index + 1);
213
+ }
214
+ stampFooters(doc);
215
+ doc.end();
216
+ });
217
+ }
@@ -0,0 +1,2 @@
1
+ import type { Changeset, Finding } from "../types.js";
2
+ export declare function renderTerminal(changeset: Changeset, findings: Finding[], reportPath?: string, warnings?: string[], model?: string, suppressed?: number): string;