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,634 @@
1
+ import { codePointLabel, conceals } from "./conceal.js";
2
+ import { BEYOND_INTENT_MARK, buildReportModel, EMPTY_LENS_COPY, LENSES, TIER_GLYPH, TIER_MEANING, TIER_ORDER, TIER_WORD, } from "./model.js";
3
+ /**
4
+ * The HTML surface, a walker over the report model. Every sentence, tier,
5
+ * glyph, lens routing, ordering, and disclosure rendered here is decided by
6
+ * `buildReportModel` — what this file owns is format mechanics (entity
7
+ * escaping, markup, the stylesheet, the tab script) plus the fixed,
8
+ * data-free copy only this surface shows: the lens blurbs, the empty-lens
9
+ * wording, the provenance tail, the legend framing. Model text arrives with
10
+ * concealment already applied — structurally, as `ConcealSegment` arrays
11
+ * this walker wraps in its own markup, or as labelled strings for
12
+ * identifier-shaped fields — so nothing here re-derives concealment for
13
+ * model content. The one scoped exception, recorded in the design spec's
14
+ * addendum, is the API-surface symbol table: symbols are changeset data the
15
+ * model omits by design, so that table reads the changeset directly and
16
+ * applies the concealment defense renderer-side through `visible`.
17
+ */
18
+ const ESCAPES = {
19
+ "&": "&",
20
+ "<": "&lt;",
21
+ ">": "&gt;",
22
+ '"': "&quot;",
23
+ "'": "&#39;",
24
+ };
25
+ /**
26
+ * Markup escaping, and nothing else. Quotes and apostrophes go too, not just
27
+ * the angle brackets, so the result cannot break out of an attribute value.
28
+ * Everything the model hands this walker is untrusted — a signature
29
+ * containing `Array<T>` and a claim containing a literal `<script>` are the
30
+ * same problem, and only one of them is hostile — so format escaping is
31
+ * applied to ALL model-provided text uniformly. Three contexts, one function
32
+ * each:
33
+ *
34
+ * - a segmented content field → `seg` (or `prose`, which builds on it)
35
+ * - the symbol table's changeset data → `visible`
36
+ * - a labelled model string, or a string literal written in this file → `esc`
37
+ *
38
+ * Both wrappers call this one, so the escaping below is the floor under all
39
+ * three contexts, not an alternative to them.
40
+ *
41
+ * Nothing untrusted is ever interpolated into the inline script or the
42
+ * stylesheet, where escaping would not help — the script is a fixed string
43
+ * that reads its data from the DOM. See `test/report/html.test.ts`, "puts
44
+ * no report data inside the inline script".
45
+ */
46
+ function esc(text) {
47
+ return text.replace(/[&<>"']/g, (c) => ESCAPES[c]);
48
+ }
49
+ /**
50
+ * A segmented model field as HTML: text runs entity-escaped, each concealed
51
+ * segment's bare code-point label wrapped in the `.ctrl` span that keeps it
52
+ * visible (the table and its rationale live in `./conceal.ts`). The
53
+ * distinction arrives structurally from the model, so no label is ever
54
+ * parsed back out of flattened text — source code that literally spells
55
+ * `[U+202E]` stays ordinary text here, and only a real concealed character
56
+ * gets the span.
57
+ */
58
+ function seg(segments) {
59
+ return segments
60
+ .map((s) => s.kind === "concealed"
61
+ ? `<span class="ctrl" title="concealing character">${s.text}</span>`
62
+ : esc(s.text))
63
+ .join("");
64
+ }
65
+ /**
66
+ * Escaped, and with every concealing character replaced by a visible label
67
+ * of its own code point. Only for the API-surface symbol table — the one
68
+ * place this walker renders text that does not come from the model, the
69
+ * scoped exception described in this file's header — so the concealment
70
+ * defense is applied here, renderer-side, exactly where model coverage ends.
71
+ *
72
+ * The raw character is dropped rather than kept beside the label, matching
73
+ * what the model does to its own fields: copying a symbol name out of this
74
+ * report should not carry an invisible payload with it.
75
+ */
76
+ function visible(text) {
77
+ let out = "";
78
+ for (const ch of esc(text)) {
79
+ const code = ch.codePointAt(0) ?? 0;
80
+ out += conceals(code)
81
+ ? `<span class="ctrl" title="concealing character">${codePointLabel(code)}</span>`
82
+ : ch;
83
+ }
84
+ return out;
85
+ }
86
+ /**
87
+ * A segmented paragraph with `backticked` spans set as inline code. `seg`
88
+ * runs first and introduces no backtick of its own, so the pairs this
89
+ * matches are the author's; the text inside a span is already neutralised by
90
+ * the time the `code` tags go around it. Analyzer bodies and model prose
91
+ * both quote identifiers this way constantly, and a wall of
92
+ * undifferentiated prose is the thing that stops a reader partway down a
93
+ * finding.
94
+ */
95
+ function prose(segments) {
96
+ return seg(segments).replace(/`([^`\n]+)`/g, "<code>$1</code>");
97
+ }
98
+ /**
99
+ * The only way model-authored text reaches the page. The model carries the
100
+ * prose, the attribution, and the trust caveat in one `ModelNoteView` — see
101
+ * `./model.js`, where their inseparability is argued — and this block
102
+ * renders all three from that one object, so there is no code path that
103
+ * emits one without the others. See `test/report/html.test.ts`, "never
104
+ * renders model prose outside an attributed block".
105
+ *
106
+ * Contains no nested `div` on purpose: the test above matches from a
107
+ * `model-block` to the next `</div>`, so a nested one would end the match
108
+ * early and let the prose after it escape the check.
109
+ */
110
+ function modelBlock(note) {
111
+ return [
112
+ `<div class="model-block">`,
113
+ `<span class="model-tag">unverified · ${esc(note.model)}</span>`,
114
+ `<span class="model-text">${prose(note.text)}</span>`,
115
+ `<span class="model-note">${esc(note.caution)}</span>`,
116
+ `</div>`,
117
+ ].join("");
118
+ }
119
+ /**
120
+ * `path:line`, with before-side lines marked — see `EvidenceView.side` in
121
+ * `./model.js` for why the reader is owed that marker. No link: the numbers
122
+ * on a before-side ref count in a revision that need not exist in the
123
+ * working tree, and this report has no repository path to resolve an
124
+ * after-side ref against either, so every location here is text to read or
125
+ * copy rather than something to click. The file path arrives from the model
126
+ * as a labelled string, so only the entity layer is applied.
127
+ */
128
+ function location(ref) {
129
+ const marker = ref.side === "before"
130
+ ? ` <span class="chip chip-before" title="This line number counts in the before revision. It may point somewhere unrelated in the working tree.">before</span>`
131
+ : "";
132
+ return `<span class="loc">${esc(ref.file)}:${ref.line}</span>${marker}`;
133
+ }
134
+ function plural(n, word) {
135
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
136
+ }
137
+ /**
138
+ * Sentence-leading count. Splicing the raw number in leaves a sentence with
139
+ * a numeral for a subject and, in the singular, a verb that does not agree
140
+ * with it. The narrative is prose a person reads, so the subject is spelled
141
+ * out and each verb is inflected at its own call site.
142
+ */
143
+ function countWord(n) {
144
+ return n === 1 ? "One" : String(n);
145
+ }
146
+ function evidenceHtml(finding) {
147
+ if (finding.evidence.length === 0) {
148
+ return `<p class="no-evidence">No evidence. Nothing mechanical points at this — read the code before believing it.</p>`;
149
+ }
150
+ // Every ref the model carries, uncapped: this is the surface a reader
151
+ // opens when the terminal's summary was not enough.
152
+ const items = finding.evidence
153
+ .map((e) => `<li>${location(e)}<pre class="excerpt"><code>${seg(e.excerpt)}</code></pre></li>`)
154
+ .join("");
155
+ return `<h4>Evidence</h4><ol class="evidence">${items}</ol>`;
156
+ }
157
+ function reachHtml(finding) {
158
+ const reach = finding.reach;
159
+ if (!reach)
160
+ return "";
161
+ const items = reach.sites.map((s) => `<li>${location(s)}</li>`).join("");
162
+ // The site list is a sample even before the model's cap — the analyzer
163
+ // bounds what it collects while counting every reference — so the two
164
+ // numbers are reported apart rather than as "N of M". The cap and the
165
+ // overflow count are the model's (see `ReachView`); this walker only
166
+ // phrases them.
167
+ const more = reach.overflow > 0 ? `<li class="muted">… ${reach.overflow} more collected</li>` : "";
168
+ return `<h4>Referenced from <span class="muted">(${plural(reach.references, "reference")} in total)</span></h4><ul class="sites">${items}${more}</ul>`;
169
+ }
170
+ function findingCard(finding, lens, rank, numbered) {
171
+ const tier = finding.tier;
172
+ const badge = `<span class="badge badge-${tier}">${finding.glyph} ${esc(TIER_WORD[tier])}${tier === "model" && finding.modelNote ? ` · ${esc(finding.modelNote.model)}` : ""}</span>`;
173
+ // Two rows, not one, so the location cannot push the title around: on one
174
+ // row a `src/interpret/schema.ts:58` and a `b.ts:5` start their titles
175
+ // half a column apart, and file paths vary far more in width than
176
+ // anything else in the headline. Titles still shift by tier, since the
177
+ // badge ahead of them is wider for a model-tier finding that carries the
178
+ // model's name — that one is left alone deliberately: pinning the badge to
179
+ // a fixed width would either truncate a model name or pad every other
180
+ // headline to fit the longest one. The location's side marker is the
181
+ // model's (`FindingView.side`, taken from the anchoring evidence ref).
182
+ const head = [
183
+ `<span class="head-row">`,
184
+ `<span class="rank">${numbered ? rank : ""}</span>`,
185
+ `<span class="chev" aria-hidden="true">▸</span>`,
186
+ badge,
187
+ finding.beyondIntent
188
+ ? `<span class="badge badge-intent">${esc(finding.beyondIntent)}</span>`
189
+ : "",
190
+ `<span class="finding-title">${prose(finding.title)}</span>`,
191
+ `</span>`,
192
+ `<span class="head-loc">`,
193
+ location({ file: finding.file, line: finding.line, side: finding.side }),
194
+ `</span>`,
195
+ ].join("");
196
+ // One walk covers every tier: a model-tier finding arrives with no body
197
+ // paragraphs and its whole prose in `modelNote`, an inferred finding keeps
198
+ // its analyzer paragraphs and carries the claim's reasoning in `modelNote`
199
+ // beside them, and a claim-free finding has no note at all. Unlike the
200
+ // terminal, the block is not gated on a recorded model name: a model-tier
201
+ // finding's whole body is model prose, and suppressing it would leave a
202
+ // headline with nothing under it, so the model's UNNAMED_MODEL fallback
203
+ // attribution shows instead — visibly incomplete rather than absent.
204
+ const paragraphs = finding.body.map((p) => `<p class="body">${prose(p)}</p>`).join("");
205
+ const note = finding.modelNote ? modelBlock(finding.modelNote) : "";
206
+ return [
207
+ `<li><details class="card card-${tier}" id="${esc(lens)}-f${rank}">`,
208
+ `<summary>${head}</summary>`,
209
+ `<div class="card-body">${paragraphs}${note}${evidenceHtml(finding)}${reachHtml(finding)}</div>`,
210
+ `</details></li>`,
211
+ ].join("");
212
+ }
213
+ /**
214
+ * A ranked list of cards. `numbered` is off for the filtered lenses: a
215
+ * number beside a finding reads as its rank, and in a lens showing three of
216
+ * eleven findings any number is wrong — the position in the filtered list
217
+ * is not the rank, and the rank is not the position. The cards stay in the
218
+ * model's rank order either way — a pane filters, never reorders. The empty
219
+ * rank cell is kept so the headline and the location under it line up with
220
+ * the numbered lens.
221
+ */
222
+ function findingList(findings, lens, numbered = false) {
223
+ const items = findings.map((f, i) => findingCard(f, lens, i + 1, numbered)).join("");
224
+ return `<ol class="findings">${items}</ol>`;
225
+ }
226
+ function empty(message) {
227
+ return `<p class="empty">${esc(message)}</p>`;
228
+ }
229
+ function lead(m) {
230
+ const sentences = [
231
+ `This range touches ${plural(m.fileCount, "file")} and ${plural(m.lineCount, "changed line")}.`,
232
+ ];
233
+ if (m.findings.length === 0) {
234
+ sentences.push("Nothing in it tripped an analyzer, and no claim stands alone.");
235
+ }
236
+ else {
237
+ sentences.push(m.findings.length === 1
238
+ ? "One finding."
239
+ : `${m.findings.length} findings, ranked by what each is likely to cost.`);
240
+ if (m.counts.verified > 0) {
241
+ const n = m.counts.verified;
242
+ sentences.push(`${countWord(n)} rest${n === 1 ? "s" : ""} on something an analyzer can point at in the code.`);
243
+ }
244
+ if (m.counts.inferred > 0) {
245
+ const n = m.counts.inferred;
246
+ sentences.push(`${countWord(n)} pair${n === 1 ? "s" : ""} an analyzer's finding with the model's explanation of why it matters.`);
247
+ }
248
+ if (m.counts.model > 0) {
249
+ const n = m.counts.model;
250
+ sentences.push(`${countWord(n)} come${n === 1 ? "s" : ""} from the model alone — a lead to check, not a result.`);
251
+ }
252
+ }
253
+ return `<p class="lead">${esc(sentences.join(" "))}</p>`;
254
+ }
255
+ function narrativeLens(m) {
256
+ const body = m.findings.length === 0
257
+ ? empty("No findings to narrate.")
258
+ : findingList(m.findings, "narrative", true);
259
+ return lead(m) + body;
260
+ }
261
+ function section(heading, blurb, findings, lens) {
262
+ if (findings.length === 0)
263
+ return "";
264
+ return `<h3>${esc(heading)}</h3><p class="blurb">${esc(blurb)}</p>${findingList(findings, lens)}`;
265
+ }
266
+ function effectsLens(findings) {
267
+ // The classification is the model's (`FindingView.subject`, recovered from
268
+ // the finding's id exactly once, in `buildReportModel`); these filters
269
+ // only gather what it decided, in the order it decided.
270
+ const effects = findings.filter((f) => f.subject === "effect");
271
+ const guards = findings.filter((f) => f.subject === "guard");
272
+ const contracts = findings.filter((f) => f.subject === "surface");
273
+ const parts = [
274
+ section("Effects", "What this change makes the program do to the world outside itself.", effects, "effects"),
275
+ section("Guards", "Checks that ran before and do not run now.", guards, "guards"),
276
+ section("Contracts", "Promises other code was compiled against.", contracts, "contracts"),
277
+ ].filter((p) => p !== "");
278
+ // Names all three kinds of finding this lens does not show. It used to name
279
+ // only the first, while the model classifies a standalone reach finding
280
+ // under a subject no section filters on — so a reader was told the
281
+ // narrative held nothing extra except model claims, and it held that too.
282
+ // A citation finding is the third, for the same reason and with the same
283
+ // cost if it goes unnamed. Its clause has to cover every path that can
284
+ // produce one: citations are extracted from TypeScript comments as well as
285
+ // from prose, and the baseline-less path deliberately claims nothing about
286
+ // what the pointer used to do — so the clause names both sources and states
287
+ // only that the pointer does not hold now.
288
+ const note = `<p class="blurb">Built from what the analyzers proved, and not the whole list. A model-only claim has no analyzer behind it to classify. A standalone reach finding — a changed export with callers, and nothing else known about it — reports cost rather than a problem, and belongs to none of these three. A citation finding — a line of prose or a comment in this repository whose pointer into the code does not hold at this revision — belongs to none of them either. All three appear in the narrative.</p>`;
289
+ if (parts.length === 0) {
290
+ // Describes the filter, not the change. A lens is a view over findings
291
+ // the model classified by id prefix, and if that classification ever
292
+ // stops matching what the analyzers emit, this pane is what a user sees
293
+ // — so it must not be able to say "nothing crossed a boundary and no
294
+ // promise moved" while a removed guard sits ranked first in the
295
+ // narrative. An honest empty state degrades to a shrug; the other
296
+ // wording degrades to the tool asserting something false in its own
297
+ // voice, in the one place the tier badges do not reach.
298
+ return note + empty(`${EMPTY_LENS_COPY} The narrative has the full list.`);
299
+ }
300
+ return note + parts.join("");
301
+ }
302
+ const SYMBOL_CHANGE_MARK = {
303
+ added: "+",
304
+ modified: "~",
305
+ removed: "−",
306
+ };
307
+ function surfaceLens(changeset, findings) {
308
+ // The symbol table is the scoped exception named in this file's header:
309
+ // it reads the changeset, not the model, so its text goes through
310
+ // `visible` rather than arriving pre-labelled.
311
+ const rows = [];
312
+ for (const file of changeset.files) {
313
+ for (const sym of file.symbols) {
314
+ if (!sym.exported)
315
+ continue;
316
+ rows.push([
317
+ `<tr class="sym-${sym.change}">`,
318
+ // The mark carries the colour and the word carries the meaning, in
319
+ // one cell: as two columns they said the same thing twice.
320
+ `<td class="change"><span aria-hidden="true">${SYMBOL_CHANGE_MARK[sym.change]}</span> ${esc(sym.change)}</td>`,
321
+ `<td class="mono">${visible(sym.qualifiedName)}</td>`,
322
+ `<td class="muted">${visible(sym.kind)}</td>`,
323
+ `<td class="mono muted">${visible(file.path)}</td>`,
324
+ `</tr>`,
325
+ ].join(""));
326
+ }
327
+ }
328
+ const surfaceFindings = findings.filter((f) => f.subject === "surface");
329
+ if (rows.length === 0 && surfaceFindings.length === 0) {
330
+ return empty("Nothing in this range matched this view: no exported declaration appears in the symbol map, and no finding is about the public surface.");
331
+ }
332
+ const table = rows.length === 0
333
+ ? empty("No exported declaration appears in this range's symbol map, though the findings below are about the public surface.")
334
+ : [
335
+ // What the symbol map actually records, named rather than promised
336
+ // as "every exported declaration". `mapSymbols` (in
337
+ // `../extract/symbols.ts`) reads function, class, interface, type
338
+ // alias, enum, variable, method, and accessor declarations, and this
339
+ // table shows the ones that are module exports — a class member
340
+ // never is.
341
+ //
342
+ // The list of exclusions is checked, not assumed. An enum's
343
+ // individual members are never recorded — a changed member shows
344
+ // as its enum's one row — and neither is a re-export declaration;
345
+ // saying so is what keeps that silence from reading as "nothing
346
+ // changed". Namespaces are a different case and were described
347
+ // wrongly before: their members *are* recorded, qualified (`N.x`),
348
+ // and are not module exports — an importer reaches them through the
349
+ // namespace — so they do not appear in this table either, but for a
350
+ // stated reason rather than because nothing looked.
351
+ `<p class="blurb">Module-level exported functions, classes, interfaces, type aliases, enums, and variables this range touched, read from the symbol map rather than from the findings — so a symbol appears here whether or not anything flagged it. Not an inventory of the whole public surface: enum members and re-export declarations are not recorded at all, so a changed enum member shows only as its enum&#39;s row; a declaration exported by a separate <code>export { … }</code> statement rather than by an <code>export</code> modifier is recorded but not counted as an export; a namespace&#39;s members are recorded under the namespace rather than as exports of the file; and a deleted file contributes nothing.</p>`,
352
+ `<div class="table-scroll"><table class="surface">`,
353
+ `<thead><tr><th>Change</th><th>Symbol</th><th>Kind</th><th>File</th></tr></thead>`,
354
+ `<tbody>${rows.join("")}</tbody></table></div>`,
355
+ ].join("");
356
+ const flagged = surfaceFindings.length === 0
357
+ ? `<p class="blurb">No finding in this review is about the public surface.</p>`
358
+ : `<h3>What changed contractually</h3>${findingList(surfaceFindings, "surface")}`;
359
+ return table + flagged;
360
+ }
361
+ function headerHtml(m) {
362
+ // The same numbers as `ReportModel.scope`, phrased this surface's way —
363
+ // dot separators where the terminal's line uses a comma — which is why the
364
+ // model carries the pieces as well as its own composed line.
365
+ const scope = `${plural(m.fileCount, "file")} · ${plural(m.lineCount, "line")} changed · ${m.rangeLabel}`;
366
+ const chips = TIER_ORDER.map((tier) => `<span class="chip chip-${tier}" title="${esc(TIER_MEANING[tier])}">${TIER_GLYPH[tier]} ${m.counts[tier]} ${esc(TIER_WORD[tier])}</span>`).join("");
367
+ // Every reason this run fell short of its full pipeline, in one banner
368
+ // above the findings, gated on the model's `notes` exactly: non-empty
369
+ // means the review is partial and this surface must say so. A reader who
370
+ // does not know an analyzer died, or that the model was never asked,
371
+ // reads a short list as good news.
372
+ const banner = m.notes.length === 0
373
+ ? ""
374
+ : [
375
+ `<div class="banner">`,
376
+ `<strong>This review is partial.</strong>`,
377
+ `<ul>${m.notes.map((n) => `<li>${esc(n)}</li>`).join("")}</ul>`,
378
+ `</div>`,
379
+ ].join("");
380
+ // Separate from the banner deliberately, which is why the model carries it
381
+ // as `filterNote` rather than a `notes` entry: the filter ran as designed,
382
+ // so its disclosure must not read as the review falling short. See
383
+ // `test/report/html.test.ts`, "discloses the standalone-reach filter with
384
+ // the same sentence the terminal prints".
385
+ const filterNote = m.filterNote ? `<p class="muted">${esc(m.filterNote)}</p>` : "";
386
+ // The gate — a model name AND a model-derived tier below — is the model's;
387
+ // `provenance` is simply absent otherwise. The fixed tail after it is this
388
+ // surface's own data-free copy. Model prose itself is never gated this way
389
+ // — `modelBlock` names an unnamed model instead of going silent, because a
390
+ // model-tier finding's whole body is model prose and suppressing it would
391
+ // leave a headline with nothing under it.
392
+ const provenance = m.provenance
393
+ ? `<p class="provenance">${esc(m.provenance)} Everything it wrote is marked.</p>`
394
+ : "";
395
+ // Its own line, deliberately outside the banner — the model carries this
396
+ // as `coverageNote`, apart from `notes`, for exactly this placement:
397
+ // deleting a TypeScript file is routine, and "This review is partial."
398
+ // fired on every diff that did. A banner that a normal change trips is a
399
+ // banner a reader learns to skip, and the things it exists for — a dead
400
+ // analyzer, a model never asked — are the ones that would go unread.
401
+ const coverage = m.coverageNote ? `<p class="coverage">${esc(m.coverageNote)}</p>` : "";
402
+ const legend = TIER_ORDER.map((tier) => `<li><span class="badge badge-${tier}">${TIER_GLYPH[tier]} ${esc(TIER_WORD[tier])}</span> ${esc(TIER_MEANING[tier])}</li>`).join("");
403
+ // Its own item under the same legend, in the same shape as the tier items.
404
+ // The badge here is the model's word, escaped like every other model string.
405
+ const intentLegend = m.beyondIntentLegend
406
+ ? `<li><span class="badge badge-intent">${esc(BEYOND_INTENT_MARK)}</span> ${esc(m.beyondIntentLegend)}</li>`
407
+ : "";
408
+ return [
409
+ `<header>`,
410
+ `<h1>urtext</h1>`,
411
+ `<p class="scope">${esc(scope)}</p>`,
412
+ `<div class="chips">${chips}</div>`,
413
+ provenance,
414
+ coverage,
415
+ banner,
416
+ filterNote,
417
+ `<details class="legend"><summary>What the three tiers mean</summary><ul>${legend}${intentLegend}</ul></details>`,
418
+ `</header>`,
419
+ ].join("");
420
+ }
421
+ const STYLE = `
422
+ :root {
423
+ color-scheme: light dark;
424
+ --bg: #fbfaf8;
425
+ --panel: #ffffff;
426
+ --ink: #1a1a1c;
427
+ --muted: #5f6068;
428
+ --rule: #e2dfd9;
429
+ --verified: #12674c;
430
+ --inferred: #9a6206;
431
+ --model: #97325c;
432
+ --verified-bg: #e7f2ed;
433
+ --inferred-bg: #f7eedd;
434
+ --model-bg: #f8eaf0;
435
+ --code-bg: #f4f2ee;
436
+ }
437
+ @media (prefers-color-scheme: dark) {
438
+ :root {
439
+ --bg: #131316;
440
+ --panel: #1b1c1f;
441
+ --ink: #e8e7e4;
442
+ --muted: #9a9da4;
443
+ --rule: #2c2d32;
444
+ --verified: #63c9a4;
445
+ --inferred: #e0ac52;
446
+ --model: #e987b0;
447
+ --verified-bg: #12291f;
448
+ --inferred-bg: #2b2313;
449
+ --model-bg: #2c1420;
450
+ --code-bg: #212227;
451
+ }
452
+ }
453
+ * { box-sizing: border-box; }
454
+ body {
455
+ margin: 0;
456
+ background: var(--bg);
457
+ color: var(--ink);
458
+ font: 16px/1.55 ui-sans-serif, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
459
+ overflow-wrap: break-word;
460
+ }
461
+ .page { max-width: 58rem; margin: 0 auto; padding: 2.5rem 1.25rem 5rem; }
462
+ h1 { font-size: 1.05rem; letter-spacing: .18em; text-transform: uppercase; margin: 0 0 .35rem; }
463
+ h3 { font-size: .95rem; margin: 2rem 0 .25rem; }
464
+ h4 { font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin: 1.1rem 0 .35rem; font-weight: 600; }
465
+ header { border-bottom: 1px solid var(--rule); padding-bottom: 1.25rem; }
466
+ .scope { margin: 0 0 .9rem; color: var(--muted); font-size: .93rem; }
467
+ .chips { display: flex; flex-wrap: wrap; gap: .4rem; }
468
+ .chip {
469
+ font-size: .8rem; padding: .12rem .5rem; border-radius: 999px;
470
+ border: 1px solid var(--rule); background: var(--panel); color: var(--muted);
471
+ }
472
+ .chip-verified { color: var(--verified); background: var(--verified-bg); border-color: transparent; }
473
+ .chip-inferred { color: var(--inferred); background: var(--inferred-bg); border-color: transparent; }
474
+ .chip-model { color: var(--model); background: var(--model-bg); border-color: transparent; }
475
+ .chip-before { font-size: .7rem; text-transform: uppercase; letter-spacing: .06em; color: var(--model); background: var(--model-bg); border-color: transparent; }
476
+ .provenance, .coverage { font-size: .85rem; color: var(--muted); margin: .8rem 0 0; }
477
+ .banner {
478
+ margin: 1rem 0 0; padding: .7rem .9rem; border-radius: .4rem;
479
+ background: var(--inferred-bg); border: 1px solid var(--inferred); font-size: .9rem;
480
+ }
481
+ .banner ul { margin: .35rem 0 0; padding-left: 1.1rem; }
482
+ .legend { margin-top: 1rem; font-size: .85rem; color: var(--muted); }
483
+ .legend summary { cursor: pointer; }
484
+ .legend ul { list-style: none; margin: .6rem 0 0; padding: 0; }
485
+ .legend li { margin: .3rem 0; }
486
+ .tabs { display: none; gap: .35rem; margin: 1.5rem 0 .5rem; flex-wrap: wrap; }
487
+ .has-js .tabs { display: flex; }
488
+ .tabs button {
489
+ font: inherit; font-size: .88rem; padding: .35rem .8rem; cursor: pointer;
490
+ color: var(--muted); background: transparent;
491
+ border: 1px solid var(--rule); border-radius: 999px;
492
+ }
493
+ .tabs button[aria-selected="true"] { color: var(--bg); background: var(--ink); border-color: var(--ink); }
494
+ .lens { padding-top: .5rem; }
495
+ .has-js .lens { display: none; }
496
+ .has-js .lens.active { display: block; }
497
+ .lead { font-size: 1.02rem; margin: .75rem 0 1.25rem; }
498
+ .blurb, .empty { color: var(--muted); font-size: .9rem; }
499
+ .empty { padding: 1rem 0; }
500
+ .findings { list-style: none; margin: 0; padding: 0; }
501
+ .card {
502
+ background: var(--panel); border: 1px solid var(--rule);
503
+ border-left: 4px solid var(--rule); border-radius: .4rem;
504
+ margin: .5rem 0; padding: .55rem .8rem;
505
+ }
506
+ .card-verified { border-left-color: var(--verified); }
507
+ .card-inferred { border-left-color: var(--inferred); }
508
+ .card-model { border-left-color: var(--model); border-style: dashed; border-left-style: solid; background: transparent; }
509
+ .card summary { cursor: pointer; display: block; list-style: none; }
510
+ .card summary::-webkit-details-marker { display: none; }
511
+ .head-row { display: flex; flex-wrap: wrap; align-items: baseline; gap: .45rem; }
512
+ .head-loc { display: block; margin: .15rem 0 0 2.35rem; }
513
+ .chev { color: var(--muted); font-size: .75rem; transition: transform .12s ease; display: inline-block; }
514
+ details[open] > summary .chev { transform: rotate(90deg); }
515
+ .rank { color: var(--muted); font-variant-numeric: tabular-nums; font-size: .82rem; min-width: 1.1rem; }
516
+ .badge {
517
+ font-size: .74rem; letter-spacing: .04em; padding: .1rem .45rem; border-radius: .25rem;
518
+ white-space: nowrap;
519
+ }
520
+ .badge-verified { color: var(--verified); background: var(--verified-bg); }
521
+ .badge-inferred { color: var(--inferred); background: var(--inferred-bg); }
522
+ .badge-model { color: var(--model); background: var(--model-bg); }
523
+ .badge-intent { color: var(--model); background: transparent; border: 1px solid var(--model); }
524
+ .loc, .mono { font-family: ui-monospace, "Cascadia Mono", Consolas, "Liberation Mono", monospace; font-size: .84rem; }
525
+ .loc { color: var(--muted); }
526
+ .finding-title { flex: 1 1 16rem; min-width: 0; }
527
+ .card-body { padding: .35rem 0 .5rem 2.35rem; }
528
+ .body { margin: .5rem 0; }
529
+ .model-block {
530
+ display: block; margin: .7rem 0; padding: .55rem .75rem;
531
+ border: 1px dashed var(--model); border-radius: .35rem; background: var(--model-bg);
532
+ }
533
+ .model-tag {
534
+ display: inline-block; font-size: .7rem; text-transform: uppercase; letter-spacing: .07em;
535
+ color: var(--model); font-weight: 700; margin-right: .4rem;
536
+ }
537
+ .model-text { display: inline; }
538
+ .model-note { display: block; margin-top: .4rem; font-size: .8rem; color: var(--muted); }
539
+ .no-evidence { font-size: .9rem; color: var(--model); margin: .6rem 0; }
540
+ .evidence, .sites { margin: 0; padding-left: 1.2rem; }
541
+ .evidence li, .sites li { margin: .35rem 0; }
542
+ .excerpt {
543
+ margin: .2rem 0 .6rem; padding: .45rem .6rem; border-radius: .3rem;
544
+ background: var(--code-bg); overflow-x: auto; max-width: 100%;
545
+ font-family: ui-monospace, "Cascadia Mono", Consolas, "Liberation Mono", monospace;
546
+ font-size: .82rem; line-height: 1.45;
547
+ }
548
+ .muted { color: var(--muted); }
549
+ .table-scroll { overflow-x: auto; max-width: 100%; margin: .75rem 0; }
550
+ table.surface { border-collapse: collapse; font-size: .88rem; min-width: 34rem; }
551
+ table.surface th { text-align: left; font-size: .74rem; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); font-weight: 600; border-bottom: 1px solid var(--rule); padding: .3rem .6rem .3rem 0; }
552
+ table.surface td { padding: .22rem .6rem .22rem 0; border-bottom: 1px solid var(--rule); vertical-align: top; }
553
+ td.change { white-space: nowrap; }
554
+ .sym-added td.change { color: var(--verified); }
555
+ .sym-removed td.change { color: var(--model); }
556
+ .sym-modified td.change { color: var(--inferred); }
557
+ code { font-family: ui-monospace, "Cascadia Mono", Consolas, "Liberation Mono", monospace; font-size: .88em; background: var(--code-bg); padding: 0 .2em; border-radius: .2em; }
558
+ .excerpt code { background: none; padding: 0; font-size: inherit; }
559
+ .ctrl {
560
+ font-family: ui-monospace, "Cascadia Mono", Consolas, "Liberation Mono", monospace;
561
+ font-size: .72em; white-space: nowrap; vertical-align: baseline;
562
+ padding: 0 .25em; margin: 0 .1em; border-radius: .2em;
563
+ color: var(--model); background: var(--model-bg); border: 1px solid var(--model);
564
+ }
565
+ `;
566
+ /**
567
+ * Runs before the panes are parsed so a lens switch never flashes three
568
+ * stacked lenses. Everything it does is undone by its own absence: without
569
+ * scripting the class is never set, the tab bar stays hidden, and all three
570
+ * lenses render one after another rather than one of them being unreachable.
571
+ */
572
+ const HEAD_SCRIPT = `document.documentElement.className = "has-js";`;
573
+ const TAB_SCRIPT = `
574
+ (function () {
575
+ var tabs = document.querySelector(".tabs");
576
+ if (!tabs) return;
577
+ var buttons = Array.prototype.slice.call(tabs.querySelectorAll("button"));
578
+ var panes = Array.prototype.slice.call(document.querySelectorAll(".lens"));
579
+ function show(key) {
580
+ panes.forEach(function (p) { p.classList.toggle("active", p.getAttribute("data-lens") === key); });
581
+ buttons.forEach(function (b) {
582
+ var on = b.getAttribute("data-lens") === key;
583
+ b.setAttribute("aria-selected", on ? "true" : "false");
584
+ b.tabIndex = on ? 0 : -1;
585
+ });
586
+ }
587
+ buttons.forEach(function (b, i) {
588
+ b.addEventListener("click", function () { show(b.getAttribute("data-lens")); });
589
+ b.addEventListener("keydown", function (e) {
590
+ var step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
591
+ if (!step) return;
592
+ e.preventDefault();
593
+ var next = buttons[(i + step + buttons.length) % buttons.length];
594
+ show(next.getAttribute("data-lens"));
595
+ next.focus();
596
+ });
597
+ });
598
+ show(buttons[0].getAttribute("data-lens"));
599
+ })();
600
+ `;
601
+ export function renderHtml(changeset, findings, meta) {
602
+ const m = buildReportModel(changeset, findings, meta);
603
+ const panes = {
604
+ narrative: narrativeLens(m),
605
+ effects: effectsLens(m.findings),
606
+ surface: surfaceLens(changeset, m.findings),
607
+ };
608
+ const tabs = LENSES.map((l, i) => `<button type="button" role="tab" data-lens="${l.key}" aria-controls="lens-${l.key}" aria-selected="${i === 0 ? "true" : "false"}">${esc(l.label)}</button>`).join("");
609
+ const sections = LENSES.map((l, i) => `<section class="lens${i === 0 ? " active" : ""}" data-lens="${l.key}" id="lens-${l.key}" role="tabpanel" aria-label="${esc(l.label)}">${panes[l.key]}</section>`).join("");
610
+ return [
611
+ `<!doctype html>`,
612
+ `<html lang="en">`,
613
+ `<head>`,
614
+ `<meta charset="utf-8">`,
615
+ `<meta name="viewport" content="width=device-width, initial-scale=1">`,
616
+ // `<title>` is RCDATA, where markup would show literally — the model's
617
+ // labelled range label plus the entity layer keeps it both inert and
618
+ // free of concealing characters, like every attribute value here.
619
+ `<title>urtext — ${esc(m.rangeLabel)}</title>`,
620
+ `<style>${STYLE}</style>`,
621
+ `<script>${HEAD_SCRIPT}</script>`,
622
+ `</head>`,
623
+ `<body>`,
624
+ `<div class="page">`,
625
+ headerHtml(m),
626
+ `<nav class="tabs" role="tablist" aria-label="Lens">${tabs}</nav>`,
627
+ sections,
628
+ `</div>`,
629
+ `<script>${TAB_SCRIPT}</script>`,
630
+ `</body>`,
631
+ `</html>`,
632
+ ``,
633
+ ].join("\n");
634
+ }
@@ -0,0 +1,2 @@
1
+ import { type ReportModel } from "./model.js";
2
+ export declare function renderMarkdown(model: ReportModel): string;