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.
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/dist/analyze/blast-radius.d.ts +28 -0
- package/dist/analyze/blast-radius.js +163 -0
- package/dist/analyze/canonical.d.ts +27 -0
- package/dist/analyze/canonical.js +74 -0
- package/dist/analyze/citations.d.ts +256 -0
- package/dist/analyze/citations.js +945 -0
- package/dist/analyze/effects.d.ts +15 -0
- package/dist/analyze/effects.js +255 -0
- package/dist/analyze/fact.d.ts +42 -0
- package/dist/analyze/fact.js +46 -0
- package/dist/analyze/guards.d.ts +70 -0
- package/dist/analyze/guards.js +211 -0
- package/dist/analyze/index.d.ts +26 -0
- package/dist/analyze/index.js +52 -0
- package/dist/analyze/program.d.ts +15 -0
- package/dist/analyze/program.js +229 -0
- package/dist/analyze/surface.d.ts +48 -0
- package/dist/analyze/surface.js +396 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +12 -0
- package/dist/cli.d.ts +110 -0
- package/dist/cli.js +502 -0
- package/dist/extract/diff.d.ts +35 -0
- package/dist/extract/diff.js +116 -0
- package/dist/extract/git.d.ts +12 -0
- package/dist/extract/git.js +247 -0
- package/dist/extract/index.d.ts +4 -0
- package/dist/extract/index.js +57 -0
- package/dist/extract/intent.d.ts +64 -0
- package/dist/extract/intent.js +238 -0
- package/dist/extract/scope.d.ts +160 -0
- package/dist/extract/scope.js +284 -0
- package/dist/extract/symbols.d.ts +24 -0
- package/dist/extract/symbols.js +230 -0
- package/dist/interpret/client.d.ts +27 -0
- package/dist/interpret/client.js +80 -0
- package/dist/interpret/index.d.ts +41 -0
- package/dist/interpret/index.js +86 -0
- package/dist/interpret/prompt.d.ts +23 -0
- package/dist/interpret/prompt.js +128 -0
- package/dist/interpret/schema.d.ts +74 -0
- package/dist/interpret/schema.js +103 -0
- package/dist/report/conceal.d.ts +63 -0
- package/dist/report/conceal.js +129 -0
- package/dist/report/coverage.d.ts +43 -0
- package/dist/report/coverage.js +56 -0
- package/dist/report/html.d.ts +4 -0
- package/dist/report/html.js +634 -0
- package/dist/report/markdown.d.ts +2 -0
- package/dist/report/markdown.js +168 -0
- package/dist/report/model.d.ts +303 -0
- package/dist/report/model.js +289 -0
- package/dist/report/pdf.d.ts +2 -0
- package/dist/report/pdf.js +217 -0
- package/dist/report/terminal.d.ts +2 -0
- package/dist/report/terminal.js +206 -0
- package/dist/report/write.d.ts +105 -0
- package/dist/report/write.js +160 -0
- package/dist/score/index.d.ts +94 -0
- package/dist/score/index.js +572 -0
- package/dist/score/reach.d.ts +126 -0
- package/dist/score/reach.js +320 -0
- package/dist/score/reconcile.d.ts +52 -0
- package/dist/score/reconcile.js +208 -0
- package/dist/types.d.ts +221 -0
- package/dist/types.js +10 -0
- package/fonts/DejaVuSans-Bold.ttf +0 -0
- package/fonts/DejaVuSans-Oblique.ttf +0 -0
- package/fonts/DejaVuSans.ttf +0 -0
- package/fonts/DejaVuSansMono.ttf +0 -0
- package/fonts/LICENSE +187 -0
- 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,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
|
+
}
|