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,572 @@
1
+ import { ANONYMOUS_OWNER, GETTER_FRAME_PREFIX, LOCAL_SCOPE, MODULE_OWNER, SETTER_FRAME_PREFIX, } from "../extract/scope.js";
2
+ import { SIGNATURE_TRUNCATION_MARKER } from "../analyze/surface.js";
3
+ import { typeUnresolvedNoteFor, foldReach, groupAddedExports, groupSignatureChanges, reachKey, } from "./reach.js";
4
+ /**
5
+ * Tunable in one place on purpose: these weights will need adjusting once
6
+ * they have been run against real diffs.
7
+ */
8
+ export const WEIGHTS = {
9
+ factKind: {
10
+ // A vanished check is the best mechanical proxy for a correctness or
11
+ // security regression, so it outranks everything else.
12
+ guard_removed: 90,
13
+ signature_changed: 75,
14
+ export_removed: 70,
15
+ effect_added: 60,
16
+ // Deliberately far below the ceiling the log curve is clamped to (see
17
+ // `effect_added`, above): this is the *base* the log-scaled formula in
18
+ // `scoreFact` multiplies, not the score a blast-radius fact typically
19
+ // gets. At a base of 40 the curve saturated at three references, so 4
20
+ // references and 24 scored identically and every blast-radius finding
21
+ // in a real run tied at the ceiling and sorted by file path — the
22
+ // ranking carried no information at all. The base was lowered to the
23
+ // value below so the curve instead spans one reference up to the
24
+ // ceiling at roughly a thousand, which covers every count a real
25
+ // repository produces. See `scoreFact`.
26
+ blast_radius: 15,
27
+ export_added: 25,
28
+ effect_removed: 15,
29
+ // A rotted citation is a defect in the repository's account of itself, not
30
+ // in its behavior: nothing a reader merges is broken by it. So it sits
31
+ // above the kinds that report cost rather than a problem, and below the
32
+ // kinds that report new public surface or a regression. See
33
+ // `test/score/index.test.ts`.
34
+ citation_rot: 18,
35
+ },
36
+ effect: {
37
+ network: 1.0,
38
+ database: 1.0,
39
+ process: 0.9,
40
+ filesystem: 0.8,
41
+ env: 0.6,
42
+ timing: 0.4,
43
+ },
44
+ };
45
+ function effectOf(fact) {
46
+ const e = fact.detail.effect;
47
+ return typeof e === "string" && Object.hasOwn(WEIGHTS.effect, e)
48
+ ? e
49
+ : "timing";
50
+ }
51
+ export function scoreFact(fact) {
52
+ const base = WEIGHTS.factKind[fact.kind];
53
+ if (fact.kind === "effect_added" || fact.kind === "effect_removed") {
54
+ return base * WEIGHTS.effect[effectOf(fact)];
55
+ }
56
+ if (fact.kind === "blast_radius") {
57
+ // Log-scaled: three callers and forty are meaningfully different, forty
58
+ // and eighty are not. The base must stay low enough that the curve does
59
+ // not hit the ceiling below the reference counts real repositories
60
+ // produce — a saturated curve ranks nothing (see `WEIGHTS.factKind.blast_radius`).
61
+ const refs = typeof fact.detail.references === "number" ? fact.detail.references : 1;
62
+ const logScaled = base * (1 + Math.log10(Math.max(refs, 1)));
63
+ // A blast-radius fact reports reach, not a defect: "this changed and a
64
+ // lot of code uses it" names no problem by itself, so no reference count
65
+ // may push it above a fact that does. `effect_added` is a deliberately
66
+ // chosen ceiling, set below guard_removed / signature_changed /
67
+ // export_removed — the kinds that most directly report a regression —
68
+ // so a widely-used export can never bury one of those under sheer reach.
69
+ // Do not raise this to "fix" a large-repo score; raise it only by
70
+ // deciding blast radius should outrank a removed guard, which it should
71
+ // not.
72
+ return Math.min(logScaled, WEIGHTS.factKind.effect_added);
73
+ }
74
+ return base;
75
+ }
76
+ const FACT_KINDS = Object.keys(WEIGHTS.factKind);
77
+ const EFFECT_KINDS = Object.keys(WEIGHTS.effect);
78
+ function syntheticFact(kind, detail) {
79
+ return { id: "synthetic", kind, file: "synthetic.ts", line: 1, detail, evidence: [] };
80
+ }
81
+ /**
82
+ * The lowest score any real analyzer fact can produce, across every fact
83
+ * kind and — for the two effect kinds, whose score also depends on which
84
+ * effect fired — every effect. Computed by actually calling `scoreFact` on
85
+ * a synthetic fact of each shape, not by reading `WEIGHTS.factKind` and
86
+ * taking its minimum directly: that minimum (shared by `blast_radius` and
87
+ * `effect_removed`) is a real, producible score — `effect_removed` ×
88
+ * `network` or `database` reaches it exactly, and so does `blast_radius` at
89
+ * one reference — but it is not the *lowest* producible score. `scoreFact`
90
+ * also multiplies `effect_removed`'s (and `effect_added`'s) base by an
91
+ * effect weight that can sit below `network`/`database`'s (`timing` is the
92
+ * lowest), which takes `effect_removed` down further still — a score
93
+ * `WEIGHTS.factKind` has no entry for,
94
+ * because it only lists each kind's base, not what the formula built on top
95
+ * of that base can still produce. Calling `scoreFact` directly means this
96
+ * tracks the real formula (the effect multiplier, the blast_radius log
97
+ * curve) instead of a hand-copied table that cannot see below its own
98
+ * bases. `reconcile.ts` derives `MODEL_CEILING` from this, so a claim can
99
+ * never be scored above the weakest thing an analyzer can find.
100
+ */
101
+ export function minPossibleAnalyzerScore() {
102
+ const scores = FACT_KINDS.flatMap((kind) => {
103
+ if (kind === "effect_added" || kind === "effect_removed") {
104
+ return EFFECT_KINDS.map((effect) => scoreFact(syntheticFact(kind, { effect, sites: 1 })));
105
+ }
106
+ if (kind === "blast_radius") {
107
+ // references: one is the floor — the analyzer that emits blast_radius
108
+ // facts never emits one for zero references (see the early return in
109
+ // src/analyze/blast-radius.ts, `if (refs.length === 0) continue;`),
110
+ // so nothing weaker exists.
111
+ return [scoreFact(syntheticFact(kind, { references: 1 }))];
112
+ }
113
+ return [scoreFact(syntheticFact(kind, {}))];
114
+ });
115
+ return Math.min(...scores);
116
+ }
117
+ /**
118
+ * The evidence tier for a finding, from what produced it.
119
+ *
120
+ * - `verified` — an analyzer found it and can point at the code.
121
+ * - `inferred` — the model explained something an analyzer found. The fact
122
+ * is still true; the *explanation* is the model's, so the finding is only
123
+ * as good as that explanation.
124
+ * - `model` — the model alone. Nothing mechanical corroborates it.
125
+ *
126
+ * A fact always beats a claim: if there is a fact, the tier can never be
127
+ * `model`, because something machine-checked is underneath it.
128
+ */
129
+ export function tierFor(fact, claim) {
130
+ if (fact && claim?.correspondsTo === fact.id)
131
+ return "inferred";
132
+ if (fact)
133
+ return "verified";
134
+ return "model";
135
+ }
136
+ /** "a network" but "an env" — the first words a user reads. */
137
+ function article(word) {
138
+ return /^[aeiou]/i.test(word) ? "an" : "a";
139
+ }
140
+ function str(v, fallback) {
141
+ return typeof v === "string" && v.length > 0 ? v : fallback;
142
+ }
143
+ function num(v, fallback) {
144
+ return typeof v === "number" ? v : fallback;
145
+ }
146
+ function isNonEmptyString(v) {
147
+ return typeof v === "string" && v.length > 0;
148
+ }
149
+ /**
150
+ * Longest signature text, in code points, that a finding body renders
151
+ * verbatim on either side of a was→now sentence. Deliberately far below
152
+ * `MAX_SIGNATURE_LENGTH` (the storage cap in `../analyze/surface.ts`): that
153
+ * one bounds what a fact *carries*, this one bounds what a sentence *shows*.
154
+ * The first dogfood run printed a JWT-sized string literal verbatim into a
155
+ * body, which both drowned the sentence and republished a secret-looking
156
+ * literal in a report — the middle-truncated rendering is readability plus
157
+ * soft redaction. Nothing is hidden from a reader who wants the full text:
158
+ * it remains in the diff itself, at the declaration every such finding
159
+ * anchors its evidence to. `test/comment-contract.test.ts` derives part of
160
+ * its forbidden set from this, so comments name it rather than restating
161
+ * its value.
162
+ */
163
+ export const MAX_RENDERED_SIGNATURE = 120;
164
+ /**
165
+ * How much of the tail survives the middle cut. A tail is kept at all —
166
+ * rather than cutting at the end the way `truncateSignature` in
167
+ * `../analyze/surface.ts` does — because the end of a signature is often
168
+ * the discriminating part (a return type, a literal's final characters),
169
+ * and a head-only cut of two long literals sharing a prefix would render
170
+ * identically.
171
+ */
172
+ const RENDERED_SIGNATURE_TAIL = 12;
173
+ /**
174
+ * Middle-truncates a signature that would overflow `MAX_RENDERED_SIGNATURE`,
175
+ * stating the original length so the cut is visible rather than mistakable
176
+ * for the whole text. Counted and cut by code point, not UTF-16 unit, for
177
+ * the same reason `excerpt` in `../report/terminal.ts` is: `String#slice`
178
+ * counts units, so an astral character straddling either cut would leave a
179
+ * lone surrogate that renders as U+FFFD. Applied here, in the one place
180
+ * was→now sentences are composed, so the terminal and HTML renderers cannot
181
+ * disagree about what they show.
182
+ *
183
+ * `trueChars` is the length stated in the marker when given: the length of
184
+ * the signature before any upstream cap cut it, not of whatever text
185
+ * survived to this layer — a marker measuring the surviving text asserted
186
+ * a false size whenever the storage cap had already run.
187
+ */
188
+ function truncateRenderedSignature(text, trueChars) {
189
+ const points = [...text];
190
+ if (points.length <= MAX_RENDERED_SIGNATURE)
191
+ return text;
192
+ const head = points.slice(0, MAX_RENDERED_SIGNATURE - RENDERED_SIGNATURE_TAIL - 1).join("");
193
+ const tail = points.slice(points.length - RENDERED_SIGNATURE_TAIL).join("");
194
+ return `${head}…${tail} (${trueChars ?? points.length} chars)`;
195
+ }
196
+ /**
197
+ * One side of a was→now sentence, from what the fact stored. `trueChars`
198
+ * is the fact's `beforeChars`/`afterChars` detail — the code-point length
199
+ * the surface analyzer measured before `MAX_SIGNATURE_LENGTH` capped the
200
+ * stored text. When it is present, a trailing storage marker is dropped
201
+ * ahead of the middle cut: the render marker states the true length now,
202
+ * and keeping both left the storage marker's tail fragment sitting inside
203
+ * the rendered tail. When it is absent — an older fact, or one built by
204
+ * hand — the stored text renders exactly as before, marker included, with
205
+ * the marker length falling back to the stored text's own count.
206
+ */
207
+ function renderStoredSignature(stored, trueChars) {
208
+ const chars = typeof trueChars === "number" ? trueChars : undefined;
209
+ const bare = chars === undefined ? stored : stored.replace(SIGNATURE_TRUNCATION_MARKER, "");
210
+ return truncateRenderedSignature(bare, chars);
211
+ }
212
+ /**
213
+ * The signature_changed prose, shared between `toFinding` (one fact, one
214
+ * finding) and the member details `rankWithAbsorption` hands to
215
+ * `groupSignatureChanges` — composed once so the grouped and ungrouped
216
+ * renderings of the same fact cannot drift apart.
217
+ *
218
+ * On narrowing: `hasName` (an aliased condition, a stored boolean) carries
219
+ * a type-guard's narrowing only for a const variable, not for a property
220
+ * access — narrowing `fact.detail.export` through it would not compile —
221
+ * and the local is reused for `leadingName`, which capitalises only the
222
+ * fallback so a real export keeps its own casing (see `capitalize`'s doc
223
+ * comment).
224
+ */
225
+ function describeSignatureChange(fact) {
226
+ const rawName = fact.detail.export;
227
+ const hasName = isNonEmptyString(rawName);
228
+ const name = hasName ? rawName : "an export";
229
+ const rawBefore = str(fact.detail.before, "its previous type");
230
+ const rawAfter = str(fact.detail.after, "a new type");
231
+ // Detected on the raw strings, ahead of truncation — though `any` is
232
+ // short enough that the order could never matter — and asymmetric on
233
+ // purpose: an export that was already `any` and stayed `any` never
234
+ // emits a fact, and one whose *before* side reads `any` genuinely
235
+ // narrowed, which needs no hedge. The hedge itself is
236
+ // `typeUnresolvedNoteFor`, whose doc comment (in `./reach.js`) argues why
237
+ // it keys on the rendered text.
238
+ const typeUnresolved = rawAfter === "any" && rawBefore !== "any";
239
+ const leadingName = hasName ? name : capitalize(name);
240
+ const sentence = `${leadingName} was ${renderStoredSignature(rawBefore, fact.detail.beforeChars)} and is now ${renderStoredSignature(rawAfter, fact.detail.afterChars)}.`;
241
+ return { name, sentence, typeUnresolved };
242
+ }
243
+ /**
244
+ * Bodies are sentences and must start capitalised — unlike titles, which are
245
+ * deliberately lowercase-led because the renderer prefixes them with
246
+ * `file:line — `. Only ever applied to a fallback string (see call sites): a
247
+ * real symbol name keeps whatever casing the source code gave it, since
248
+ * forcing that would misspell the identifier the user is reading about.
249
+ */
250
+ function capitalize(s) {
251
+ return s.length > 0 ? `${s[0].toUpperCase()}${s.slice(1)}` : s;
252
+ }
253
+ /**
254
+ * Plain English for every scope sentinel a qualified owner path can contain,
255
+ * keyed on the constants themselves. `guardOwnerLabel` translates what is in
256
+ * this table plus the accessor frames (`accessorSegmentLabel`, below) and
257
+ * nothing else, so a sentinel is either listed here or printed raw to a
258
+ * reader. `SCOPE_SENTINELS` is the list this has to cover, and
259
+ * `test/score/index.test.ts` walks that list rather than this table — so a new
260
+ * sentinel with no entry here fails a test instead of reaching a report.
261
+ */
262
+ const SEGMENT_LABEL = {
263
+ [MODULE_OWNER]: "the top level of this file",
264
+ [ANONYMOUS_OWNER]: "an anonymous function",
265
+ [LOCAL_SCOPE]: "an unnamed block",
266
+ };
267
+ /**
268
+ * "the value getter" for an accessor's `get value` frame — the second family
269
+ * of path segments that is not source text a reader can search for, beside
270
+ * the sentinels above. Parameterised where the sentinels are fixed, so it is
271
+ * a function rather than more table rows; `test/score/index.test.ts` pins
272
+ * both accessor kinds at every path position the sentinel walk covers.
273
+ */
274
+ function accessorSegmentLabel(segment) {
275
+ if (segment.startsWith(GETTER_FRAME_PREFIX)) {
276
+ return `the ${segment.slice(GETTER_FRAME_PREFIX.length)} getter`;
277
+ }
278
+ if (segment.startsWith(SETTER_FRAME_PREFIX)) {
279
+ return `the ${segment.slice(SETTER_FRAME_PREFIX.length)} setter`;
280
+ }
281
+ return undefined;
282
+ }
283
+ /**
284
+ * `guard_removed`'s `symbol` is the qualified owner path `collectGuards`
285
+ * attributed the guard to — `Worker.run`, which reads fine as it stands. What
286
+ * does not read fine are the sentinels that path can contain.
287
+ *
288
+ * A sentinel can sit at *any* position, and handling only "the whole path" and
289
+ * "the last segment" printed a raw one to the reader — `<anonymous>.inner` for a
290
+ * method of an object literal passed straight to a call, `<local>.run` for an
291
+ * arrow declared in a top-level block, `Registry.<local>.helper` for a local in
292
+ * a static initializer block. So the path is read as segments: each run of
293
+ * real names stays dotted, each sentinel becomes its phrase, and the pieces are
294
+ * joined innermost first — "inner in an anonymous function", "an anonymous
295
+ * function in Worker.run", "run in an unnamed block". A path of real names comes
296
+ * back exactly as it went in, and a lone `MODULE_OWNER` comes back as the one
297
+ * phrase it has always had.
298
+ */
299
+ function guardOwnerLabel(v) {
300
+ const s = str(v, "this code");
301
+ const parts = [];
302
+ let named = [];
303
+ const flush = () => {
304
+ if (named.length > 0)
305
+ parts.push(named.join("."));
306
+ named = [];
307
+ };
308
+ for (const segment of s.split(".")) {
309
+ const label = SEGMENT_LABEL[segment] ?? accessorSegmentLabel(segment);
310
+ if (label) {
311
+ flush();
312
+ parts.push(label);
313
+ }
314
+ else {
315
+ named.push(segment);
316
+ }
317
+ }
318
+ flush();
319
+ return parts.reverse().join(" in ");
320
+ }
321
+ export function toFinding(fact) {
322
+ let title;
323
+ let body;
324
+ switch (fact.kind) {
325
+ case "effect_added":
326
+ case "effect_removed": {
327
+ // No file path here: the renderer prefixes every finding with
328
+ // `file:line — `, and the JSON carries `file` as its own field. Naming
329
+ // it again produced "svc.ts:2 — svc.ts introduces a network effect".
330
+ const effect = effectOf(fact);
331
+ const sites = num(fact.detail.sites, 1);
332
+ const an = article(effect);
333
+ const where = sites === 1 ? "at one site" : `at ${sites} sites`;
334
+ title =
335
+ fact.kind === "effect_added"
336
+ ? `introduces ${an} ${effect} effect`
337
+ : `no longer has ${an} ${effect} effect`;
338
+ body =
339
+ fact.kind === "effect_added"
340
+ ? `This file previously had no ${effect} effect. It now does, ${where}.`
341
+ : `This file previously had ${an} ${effect} effect ${where}. It no longer does.`;
342
+ break;
343
+ }
344
+ case "guard_removed": {
345
+ const owner = guardOwnerLabel(fact.detail.symbol);
346
+ const guard = str(fact.detail.guard, "check");
347
+ const an = article(guard);
348
+ title = `${an} ${guard} guard was removed from ${owner}`;
349
+ body = `${an[0].toUpperCase()}${an.slice(1)} ${guard} guard that previously ran in ${owner} is no longer present. Removed checks are where correctness and security regressions usually hide, so confirm the condition is genuinely unreachable now.`;
350
+ break;
351
+ }
352
+ case "export_added": {
353
+ const name = str(fact.detail.export, "an export");
354
+ title = `${name} is newly exported`;
355
+ body = `This file did not export ${name} before. New public surface is worth a look, but it cannot break an existing caller.`;
356
+ break;
357
+ }
358
+ case "export_removed": {
359
+ const name = str(fact.detail.export, "an export");
360
+ title = `${name} is no longer exported`;
361
+ body = `This file previously exported ${name}. Anything importing it will fail to resolve.`;
362
+ break;
363
+ }
364
+ case "signature_changed": {
365
+ const d = describeSignatureChange(fact);
366
+ title = `${d.name} changed its signature`;
367
+ body = `${d.sentence} A changed contract can break callers without breaking the build at this file, so check the call sites.${d.typeUnresolved ? ` ${typeUnresolvedNoteFor([d.name])}` : ""}`;
368
+ break;
369
+ }
370
+ case "blast_radius": {
371
+ const rawSymbol = fact.detail.symbol;
372
+ const hasSymbol = isNonEmptyString(rawSymbol);
373
+ const symbol = hasSymbol ? rawSymbol : "this export";
374
+ const refs = num(fact.detail.references, 0);
375
+ const places = refs === 1 ? "one place" : `${refs} places`;
376
+ const verb = refs === 1 ? "references" : "reference";
377
+ title = `${symbol} changed and is referenced in ${places}`;
378
+ const leadingSymbol = hasSymbol ? symbol : capitalize(symbol);
379
+ body = `${leadingSymbol} was modified, and ${places} in this repository ${verb} it. The wider the reach, the more a subtle change costs.`;
380
+ break;
381
+ }
382
+ case "citation_rot": {
383
+ const cited = str(fact.detail.citedFile, "the cited file");
384
+ const start = num(fact.detail.citedLine, 0);
385
+ const end = num(fact.detail.citedEndLine, 0);
386
+ // The citation as the prose wrote it: a line, a range, or — for the
387
+ // quoted form — no line at all.
388
+ const at = start === 0 ? "" : end === 0 ? `:${start}` : `:${start}-${end}`;
389
+ // True only when the citation reaches more than one line. A citation
390
+ // the prose wrote as a degenerate range — `X:2-2` — stays a range in
391
+ // the title, which echoes what was written, and is one line everywhere
392
+ // a sentence has to agree with it.
393
+ const spanned = end !== 0 && end !== start;
394
+ const hash = fact.detail.baseline;
395
+ const dated = isNonEmptyString(hash);
396
+ const when = dated ? `when this line was last written (${hash})` : "";
397
+ // Only `missing_file` has a branch for the undated case, and that is
398
+ // not an oversight: the analyzer runs the other three tests solely
399
+ // against a baseline file it has already read, so a fact of those
400
+ // kinds carrying no commit cannot be produced. See
401
+ // `src/analyze/citations.ts`, "With no baseline — blame failed, or the
402
+ // historical-read budget is spent — only the first test runs, ungated,
403
+ // against the reviewed revision."
404
+ switch (fact.detail.rot) {
405
+ case "missing_file":
406
+ if (!dated) {
407
+ // No baseline means no commit to name and no proof the path was
408
+ // ever there, so this branch claims neither.
409
+ title = `cites \`${cited}\`, which is not in this repository at this revision`;
410
+ body = `This line cites \`${cited}${at}\`. That path is not present at this revision. urtext could not read this line's history here, so it does not know whether the citation ever resolved.`;
411
+ break;
412
+ }
413
+ title = `cites \`${cited}\`, which is not in this repository any more`;
414
+ body = `This line cites \`${cited}${at}\`. That file existed ${when} and is not present at this revision, so the citation does not resolve. What it was meant to point at is not something urtext can recover.`;
415
+ break;
416
+ case "line_out_of_range": {
417
+ const count = num(fact.detail.lineCount, 0);
418
+ title = `cites \`${cited}${at}\`, which is past the end of that file`;
419
+ body = `\`${cited}\` has ${count} line${count === 1 ? "" : "s"} at this revision, so ${spanned ? `lines ${start}-${end} are not all in it` : `line ${start} is not in it`}. The citation resolved ${when}; it resolves to nothing now.`;
420
+ break;
421
+ }
422
+ case "quote_absent": {
423
+ const phrase = str(fact.detail.quote, "the quoted phrase");
424
+ title = `cites \`${cited}\` for a quoted phrase that is not in it`;
425
+ body = `This line cites \`${cited}\` and quotes “${phrase}”. That text was in \`${cited}\` ${when} and is not in it at this revision. urtext compares the quoted text against the file's contents; it does not know whether the text moved, was reworded, or was deliberately dropped.`;
426
+ break;
427
+ }
428
+ default: {
429
+ // A line urtext read as empty is something it knows, not something
430
+ // it is missing. `str`'s fallback exists for a detail that is absent
431
+ // or is not a string; an empty line is neither, and routing it
432
+ // through the fallback printed "something else" — a phrase whose
433
+ // whole meaning is "urtext cannot say" — in place of a fact urtext
434
+ // could state exactly, under a verified badge. Both sides are said
435
+ // as blankness instead, in the same voice as the reading they
436
+ // replace. A detail genuinely absent still takes the fallback, which
437
+ // is the case that phrase was written for.
438
+ const isBlank = (v) => typeof v === "string" && v.trim() === "";
439
+ const read = isBlank(fact.detail.was)
440
+ ? "was blank"
441
+ : `read \`${str(fact.detail.was, "something else")}\``;
442
+ const readsNow = isBlank(fact.detail.now)
443
+ ? "It is now blank."
444
+ : `It now reads \`${str(fact.detail.now, "something else")}\`.`;
445
+ // On a drift `citedLine` is the line whose content differs, which is
446
+ // where the evidence points — not what the prose wrote. The citation
447
+ // as written is carried separately and is what these sentences name:
448
+ // it is the string a reader searches their own document for and the
449
+ // text they will edit, and a title naming the differing line alone
450
+ // would be a string they could not find. The two are read from
451
+ // separate fields and never combined, so no span the prose did not
452
+ // write can be composed here.
453
+ const wroteStart = num(fact.detail.writtenLine, start);
454
+ const wroteEnd = num(fact.detail.writtenEndLine, 0);
455
+ const wrote = wroteStart === 0 ? "" : wroteEnd === 0 ? `:${wroteStart}` : `:${wroteStart}-${wroteEnd}`;
456
+ // Which line inside the citation moved. Said only where the citation
457
+ // reaches more than one line: on a single-line citation it would
458
+ // restate the line just named.
459
+ const inside = wroteEnd !== 0 && wroteEnd !== wroteStart && start !== 0 ? `line ${start} of ` : "";
460
+ title = `cites \`${cited}${wrote}\`, which no longer reads the same`;
461
+ // Membership of the cited path in the changed set is proven, so the
462
+ // body states exactly that and nothing more. Attributing the
463
+ // movement to the reviewed change would be a causal claim under a
464
+ // verified badge that the evidence does not carry: the baseline is
465
+ // the commit that last wrote the CITING line, which can predate the
466
+ // reviewed range by any number of commits.
467
+ const touched = fact.detail.citedTouched === true ? ` This change touched \`${cited}\`.` : "";
468
+ body = `When this line was last written (${hash}), ${inside}\`${cited}${wrote}\` ${read}. ${readsNow} The citation still resolves to a line; it no longer resolves to the same content. urtext does not know whether the new line is what this sentence meant.${touched}`;
469
+ }
470
+ }
471
+ break;
472
+ }
473
+ }
474
+ return {
475
+ id: fact.id,
476
+ tier: tierFor(fact, undefined),
477
+ file: fact.file,
478
+ line: fact.line,
479
+ title,
480
+ body,
481
+ score: scoreFact(fact),
482
+ evidence: fact.evidence,
483
+ };
484
+ }
485
+ /**
486
+ * `rank` plus the map a model claim needs to find a fact that no longer has
487
+ * a finding of its own: `absorbedBy` maps a folded/grouped fact's id to the
488
+ * id of the finding that now speaks for it (see `foldReach`,
489
+ * `groupAddedExports`, and `groupSignatureChanges` — the three places facts
490
+ * disappear this way). Chained here because a fact can be absorbed twice in
491
+ * a row — a blast_radius fact folded into an `export_added` or
492
+ * `signature_changed` sibling whose own finding is then itself collapsed
493
+ * into its file's group — and only this function, which runs the fold and
494
+ * both grouping passes itself, sees every step. `reconcile.ts` is the only
495
+ * caller that needs this; everything else calls `rank`, the one-line
496
+ * delegate below, which just discards it.
497
+ */
498
+ export function rankWithAbsorption(facts) {
499
+ const { facts: kept, reach, absorbedBy: radiusAbsorbedBy } = foldReach(facts, (fact) => WEIGHTS.factKind[fact.kind]);
500
+ const findings = kept.map((fact) => {
501
+ const finding = toFinding(fact);
502
+ // A blast_radius fact never looks up its own reach entry: that entry
503
+ // is *its own* reference count, so without this guard a lonely
504
+ // blast_radius fact (one with no sibling, kept above) amplifies and
505
+ // restates itself. `toFinding` already wrote the reference count into
506
+ // this finding's body; there is nothing here to add.
507
+ const r = fact.kind !== "blast_radius" && fact.qualifiedSymbol
508
+ ? reach.get(reachKey(fact.file, fact.qualifiedSymbol))
509
+ : undefined;
510
+ if (!r)
511
+ return finding;
512
+ // Computed once and reused below, matching the pattern `groupAddedExports`
513
+ // (`../score/reach.ts`) and `toFinding`'s blast_radius branch (above in
514
+ // this file) both use: the subject is `places`, not the symbol, so the
515
+ // verb has to agree with it — "One place ... references"; "N places ...
516
+ // reference" — rather than being re-derived (or, as this site used to,
517
+ // fixed to the plural form regardless of count).
518
+ const places = r.references === 1 ? "One place" : `${r.references} places`;
519
+ const verb = r.references === 1 ? "references" : "reference";
520
+ return {
521
+ ...finding,
522
+ reach: r,
523
+ // A bounded multiplier, at most 1.5x, applied on top of `scoreFact`'s
524
+ // own ceiling — not a separate score. That means an amplified finding
525
+ // *can* end up above an unamplified higher-weight one (a
526
+ // signature_changed finding reaching 300 references is multiplied by
527
+ // 1.5x its base score, which can put it above an unamplified
528
+ // guard_removed finding) — correctly: a contract change with 300 call
529
+ // sites can matter more than one removed guard.
530
+ // Any guard_removed with the same reach amplifies by the same
531
+ // factor, so comparable findings keep their relative order. What
532
+ // this never does is let a fact amplify *itself*: see the guard on
533
+ // `r`, above.
534
+ score: finding.score * (1 + Math.min(Math.log10(Math.max(r.references, 1)), 1) * 0.5),
535
+ body: `${finding.body} ${places} in this repository ${verb} it.`,
536
+ };
537
+ });
538
+ // Derived from the same facts the findings were built from, so the group
539
+ // listing's sentences and the ungrouped bodies come out of one composer
540
+ // (`describeSignatureChange`) and cannot drift apart. Keyed by fact id,
541
+ // which is also the finding id for an ungrouped fact.
542
+ const signatureDetails = new Map();
543
+ for (const fact of kept) {
544
+ if (fact.kind !== "signature_changed")
545
+ continue;
546
+ const d = describeSignatureChange(fact);
547
+ signatureDetails.set(fact.id, { name: d.name, sentence: d.sentence, typeUnresolved: d.typeUnresolved });
548
+ }
549
+ const { findings: signatureGrouped, absorbedBy: signatureAbsorbedBy } = groupSignatureChanges(findings, signatureDetails);
550
+ const { findings: grouped, absorbedBy: exportAbsorbedBy } = groupAddedExports(signatureGrouped);
551
+ // The two grouping passes touch disjoint id prefixes, so one merged map
552
+ // can answer for both when the chain below resolves a sibling.
553
+ const groupAbsorbedBy = new Map([...signatureAbsorbedBy, ...exportAbsorbedBy]);
554
+ // A blast_radius fact's sibling can itself have been grouped away, so its
555
+ // id no longer names a finding either — resolve through the grouping
556
+ // absorption maps too, falling back to the sibling's own id when it was
557
+ // not grouped (an ungrouped sibling's finding id is just its fact id).
558
+ const absorbedBy = new Map();
559
+ for (const [factId, siblingId] of radiusAbsorbedBy) {
560
+ absorbedBy.set(factId, groupAbsorbedBy.get(siblingId) ?? siblingId);
561
+ }
562
+ for (const [factId, groupId] of groupAbsorbedBy) {
563
+ absorbedBy.set(factId, groupId);
564
+ }
565
+ return {
566
+ findings: grouped.sort((a, b) => b.score - a.score || a.file.localeCompare(b.file) || a.line - b.line),
567
+ absorbedBy,
568
+ };
569
+ }
570
+ export function rank(facts) {
571
+ return rankWithAbsorption(facts).findings;
572
+ }