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,945 @@
1
+ import ts from "typescript";
2
+ import { git } from "../extract/git.js";
3
+ import { isTypeScriptFile } from "../extract/symbols.js";
4
+ import { REPORT_DIR, WORKTREE, } from "../types.js";
5
+ import { makeFact, MAX_EVIDENCE } from "./fact.js";
6
+ /**
7
+ * A repository-relative path followed by a line, or a line range. The path
8
+ * must contain at least one directory separator — see
9
+ * CITATION_GUARD_SEPARATOR in the design's false-positive guards for why a
10
+ * bare `Something.js:14` is not treated as a citation at all.
11
+ *
12
+ * Left to right: a lookbehind rejecting a preceding path character, so a
13
+ * match cannot start in the middle of a longer path; one or more `segment/`
14
+ * groups, which is what makes the separator mandatory; a final segment with
15
+ * a dot and an alphanumeric extension; a colon; a line number with no
16
+ * leading zero; optionally a hyphen and an end line; and a lookahead
17
+ * rejecting a trailing digit, letter, underscore, slash, or hyphen, so a
18
+ * citation is not matched inside a longer line number or a longer path.
19
+ * Capture groups: path, start line, end line or undefined.
20
+ *
21
+ * A trailing period is not rejected, so a citation ending a sentence is
22
+ * extracted like any other — see `test/analyze/citations.test.ts`,
23
+ * "extracts a citation a sentence's closing period touches, in both forms".
24
+ */
25
+ export const LINE_CITATION = /(?<![A-Za-z0-9_@./-])((?:[A-Za-z0-9_@.-]+\/)+[A-Za-z0-9_@.-]+\.[A-Za-z][A-Za-z0-9]*):([1-9][0-9]*)(?:-([1-9][0-9]*))?(?![0-9A-Za-z_/-])/g;
26
+ /**
27
+ * A backticked repository-relative path, then a quoted phrase — urtext's own
28
+ * comment-contract form, and the most checkable citation form in the
29
+ * repository, because the quoted text either appears in the named file or it
30
+ * does not. The same mandatory-separator path, inside backticks; an optional
31
+ * comma, semicolon, or colon; a short run of whitespace; then a straight or
32
+ * left curly double quote, the phrase, and a straight or right curly close.
33
+ * Capture groups: path, phrase.
34
+ */
35
+ export const QUOTED_CITATION = /`((?:[A-Za-z0-9_@.-]+\/)+[A-Za-z0-9_@.-]+\.[A-Za-z][A-Za-z0-9]*)`[,;:]?\s{0,3}["“]([^"”]+)["”]/g;
36
+ /**
37
+ * The most code points a quoted phrase, or a stored was/now line, carries.
38
+ * Longer than this is a block quotation rather than a pointer.
39
+ */
40
+ export const MAX_QUOTE_CHARS = 240;
41
+ /** Appended to a was/now line the cap cut, so no line merely appears to end. */
42
+ export const CITATION_TRUNCATION_MARKER = "… [line truncated]";
43
+ /** Tracked files scanned as raw text, after masking. */
44
+ export const PROSE_EXTENSIONS = [".md", ".markdown", ".txt"];
45
+ /**
46
+ * The pathspecs the candidate-file queries in this file's scan half pass to
47
+ * git. Narrower than `isTypeScriptFile` accepts — it also takes the
48
+ * module-explicit extensions, which no pathspec here names — so a citation
49
+ * written in one of those files is not checked at all. An under-report, the
50
+ * direction every approximation in this feature leans.
51
+ */
52
+ export const CITATION_PATHSPECS = ["*.md", "*.markdown", "*.txt", "*.ts", "*.tsx"];
53
+ /**
54
+ * Every whitespace run, newlines included, collapsed to a single space, then
55
+ * trimmed. Matching and comparison both run on normalized text, and the
56
+ * cited file is normalized identically before a containment test. Without
57
+ * this, every comment-contract citation in `src/` would fail: they wrap
58
+ * across continuation lines, so the phrase as written carries newlines and
59
+ * asterisks the cited file never had.
60
+ */
61
+ export function normalizeText(text) {
62
+ return text.replace(/\s+/g, " ").trim();
63
+ }
64
+ /**
65
+ * Replaces every character of a span with a space, keeping newlines. Length
66
+ * is preserved exactly, which is the whole point: every offset computed
67
+ * after a mask still names the same character of the original text, so a
68
+ * masked fence cannot move a later citation's reported line. See
69
+ * `test/analyze/citations.test.ts`, "keeps every offset, so masking never
70
+ * moves a later citation's line".
71
+ */
72
+ function blankSpan(text) {
73
+ return text.replace(/[^\n]/g, " ");
74
+ }
75
+ const FENCE_LINE = /^(?: {0,3}>[ \t]?)* {0,3}(`{3,}|~{3,})/;
76
+ /**
77
+ * CITATION_GUARD_FENCE. Spans from a line opening a fence through the next
78
+ * line closing that fence — same character, at least as long a run —
79
+ * inclusive, are blanked. A path and line inside a fence is sample output,
80
+ * and treating sample output as an assertion about the repository is the
81
+ * most common false positive available. An unclosed fence blanks to the end
82
+ * of the text: silence about a run of prose costs less than a finding built
83
+ * on a code block nobody closed. Indented blocks are deliberately not
84
+ * masked — they are indistinguishable from list continuations in this
85
+ * repository's prose, and the baseline gate already covers the illustrative
86
+ * ones. A blockquote prefix on the fence line is tolerated: a fence inside a
87
+ * quotation is still a fence, and the indent allowance alone cannot see past
88
+ * the marker. See `test/analyze/citations.test.ts`, "CITATION_GUARD_FENCE: a
89
+ * citation inside a fenced block is not one, and the same text outside it
90
+ * is" and "CITATION_GUARD_FENCE: a fenced block inside a blockquote is masked
91
+ * like any other".
92
+ */
93
+ export function maskFences(text) {
94
+ const lines = text.split("\n");
95
+ let open;
96
+ for (let i = 0; i < lines.length; i++) {
97
+ const fence = FENCE_LINE.exec(lines[i]);
98
+ if (open === undefined) {
99
+ if (fence) {
100
+ open = fence[1];
101
+ lines[i] = blankSpan(lines[i]);
102
+ }
103
+ continue;
104
+ }
105
+ const closes = fence !== null && fence[1][0] === open[0] && fence[1].length >= open.length;
106
+ lines[i] = blankSpan(lines[i]);
107
+ if (closes)
108
+ open = undefined;
109
+ }
110
+ return lines.join("\n");
111
+ }
112
+ const URL_SPAN = /[A-Za-z][A-Za-z0-9+.-]*:\/\/\S+/g;
113
+ const LINK_DESTINATION = /\]\([^)]*\)/g;
114
+ /**
115
+ * CITATION_GUARD_URL. A link to another host is a link to another host, and
116
+ * its path-and-line tail says nothing about this repository. The regexes'
117
+ * lookbehind is a second line of defense, not a substitute: masking is what
118
+ * makes the intent explicit and testable.
119
+ */
120
+ export function maskUrls(text) {
121
+ return text.replace(URL_SPAN, blankSpan).replace(LINK_DESTINATION, blankSpan);
122
+ }
123
+ export function isProseFile(path) {
124
+ return PROSE_EXTENSIONS.some((ext) => path.toLowerCase().endsWith(ext));
125
+ }
126
+ /** Absolute offset of the start of every line, for offset-to-line lookup. */
127
+ function lineStartsOf(source) {
128
+ const starts = [0];
129
+ for (let i = 0; i < source.length; i++) {
130
+ if (source[i] === "\n")
131
+ starts.push(i + 1);
132
+ }
133
+ return starts;
134
+ }
135
+ function lineOfOffset(starts, offset) {
136
+ let low = 0;
137
+ let high = starts.length - 1;
138
+ while (low < high) {
139
+ const mid = (low + high + 1) >> 1;
140
+ if (starts[mid] <= offset)
141
+ low = mid;
142
+ else
143
+ high = mid - 1;
144
+ }
145
+ return low + 1;
146
+ }
147
+ function textOfLine(source, starts, line) {
148
+ const start = starts[line - 1];
149
+ const end = starts[line] === undefined ? source.length : starts[line] - 1;
150
+ return source.slice(start, end).replace(/\r$/, "").trim();
151
+ }
152
+ /**
153
+ * Runs both forms over `haystack` and reports each hit against `source`,
154
+ * which is the file as written. `toSource` maps a haystack index back to an
155
+ * absolute source offset: it is the identity for prose, where masking
156
+ * preserves length, and the unwrap's offset map for comments, where it is
157
+ * not.
158
+ *
159
+ * The two post-match rules for Form B live here rather than in the pattern
160
+ * so their reasons stay readable. A parsed line number that is not a safe
161
+ * integer is discarded rather than checked, because `Number` would silently
162
+ * round a numeral too long to be a line into one that looks checkable.
163
+ */
164
+ function matchCitations(haystack, toSource, source) {
165
+ const starts = lineStartsOf(source);
166
+ const out = [];
167
+ const push = (index, parts) => {
168
+ const citingLine = lineOfOffset(starts, toSource(index));
169
+ out.push({ ...parts, citingLine, citingText: textOfLine(source, starts, citingLine) });
170
+ };
171
+ for (const m of haystack.matchAll(LINE_CITATION)) {
172
+ const line = Number.parseInt(m[2], 10);
173
+ const endLine = m[3] === undefined ? undefined : Number.parseInt(m[3], 10);
174
+ if (!Number.isSafeInteger(line))
175
+ continue;
176
+ if (endLine !== undefined && !Number.isSafeInteger(endLine))
177
+ continue;
178
+ push(m.index, endLine === undefined
179
+ ? { form: "line", path: m[1], line }
180
+ : { form: "line", path: m[1], line, endLine });
181
+ }
182
+ for (const m of haystack.matchAll(QUOTED_CITATION)) {
183
+ const quote = normalizeText(m[2]);
184
+ // The phrase must contain whitespace: a single quoted word is prose
185
+ // emphasis far more often than a citation, and one word is too weak a
186
+ // needle to conclude anything from. Counted with the spread for the
187
+ // reason `truncateSignature` in `./surface.ts` documents.
188
+ if (!/\s/.test(quote))
189
+ continue;
190
+ if ([...quote].length > MAX_QUOTE_CHARS)
191
+ continue;
192
+ push(m.index, { form: "quote", path: m[1], quote });
193
+ }
194
+ return out.sort((a, b) => a.citingLine - b.citingLine || a.path.localeCompare(b.path) || a.form.localeCompare(b.form));
195
+ }
196
+ export function citationsInProse(text) {
197
+ return matchCitations(maskUrls(maskFences(text)), (index) => index, text);
198
+ }
199
+ /**
200
+ * Every comment in a file, walked off the parsed AST's leaf tokens.
201
+ *
202
+ * This is a deliberate second copy of the walk `test/comment-contract.test.ts`
203
+ * documents at length, and the design names the duplication so a reviewer
204
+ * does not treat it as an oversight: a plan that wants one copy should hoist
205
+ * it into a shared module and update both call sites in the same change, not
206
+ * import test code into `src/`. The reasons the walk has this shape are
207
+ * recorded there in full; the short version is that a raw scanner loop
208
+ * desynchronizes on the first template interpolation, and this codebase's
209
+ * comments sit beside plenty of those, while a leading-only pass silently
210
+ * misses every comment on the same line as the token before it. Two
211
+ * different leaves can share a position — a zero-width node ends exactly
212
+ * where the next token starts — so hits are deduplicated by range start.
213
+ */
214
+ function commentSpans(source, fileName) {
215
+ const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true);
216
+ const spans = [];
217
+ const seen = new Set();
218
+ const record = (ranges) => {
219
+ for (const range of ranges ?? []) {
220
+ if (seen.has(range.pos))
221
+ continue;
222
+ seen.add(range.pos);
223
+ spans.push({ start: range.pos, text: source.slice(range.pos, range.end) });
224
+ }
225
+ };
226
+ const visit = (node) => {
227
+ const children = node.getChildren(sourceFile);
228
+ if (children.length === 0) {
229
+ record(ts.getLeadingCommentRanges(source, node.getFullStart()));
230
+ record(ts.getTrailingCommentRanges(source, node.getEnd()));
231
+ return;
232
+ }
233
+ children.forEach(visit);
234
+ };
235
+ visit(sourceFile);
236
+ return spans.sort((a, b) => a.start - b.start);
237
+ }
238
+ /**
239
+ * Strips a comment's opening, closing, and continuation decorations and
240
+ * joins its lines with single spaces, carrying an offset map alongside.
241
+ * The citing line reported in evidence is derived from that map, so a
242
+ * quoted citation whose phrase wraps across several comment lines is
243
+ * reported at the line its path actually sits on — see
244
+ * `test/analyze/citations.test.ts`, "reports the line the path sits on for a
245
+ * quote that wrapped across comment lines". A naive "line of the comment's
246
+ * start" would misreport every wrapped citation in `src/`, which are most of
247
+ * them.
248
+ */
249
+ function unwrapComment(span) {
250
+ const chars = [];
251
+ const offsets = [];
252
+ let lineStart = span.start;
253
+ span.text.split("\n").forEach((line, index) => {
254
+ let from = 0;
255
+ let to = line.length;
256
+ if (to > from && line[to - 1] === "\r")
257
+ to--;
258
+ while (from < to && (line[from] === " " || line[from] === "\t"))
259
+ from++;
260
+ // The close is stripped before the opens, so a line that is nothing but
261
+ // a closing decoration contributes no stray character.
262
+ if (to - from >= 2 && line.slice(to - 2, to) === "*/")
263
+ to -= 2;
264
+ if (line.startsWith("//", from))
265
+ from += 2;
266
+ else if (line.startsWith("/*", from)) {
267
+ from += 2;
268
+ if (line[from] === "*")
269
+ from++;
270
+ }
271
+ else if (line[from] === "*")
272
+ from++;
273
+ if (index > 0) {
274
+ chars.push(" ");
275
+ offsets.push(lineStart + Math.min(from, to));
276
+ }
277
+ for (let k = from; k < to; k++) {
278
+ chars.push(line[k]);
279
+ offsets.push(lineStart + k);
280
+ }
281
+ lineStart += line.length + 1;
282
+ });
283
+ return { text: chars.join(""), offsets };
284
+ }
285
+ /**
286
+ * Comments only. A path and line inside a string literal is usually a test
287
+ * fixture's expected output, and inside code it is not prose making a claim.
288
+ * Fences are not masked here — a fence is a prose construct — but URLs are,
289
+ * for the same reason they are in prose.
290
+ */
291
+ export function citationsInComments(source, fileName) {
292
+ const out = [];
293
+ for (const span of commentSpans(source, fileName)) {
294
+ const { text, offsets } = unwrapComment(span);
295
+ out.push(...matchCitations(maskUrls(text), (index) => offsets[index] ?? span.start, source));
296
+ }
297
+ return out.sort((a, b) => a.citingLine - b.citingLine || a.path.localeCompare(b.path));
298
+ }
299
+ /** Every citation in one file, scanned the way that file's kind is scanned. */
300
+ export function citationsIn(path, text) {
301
+ if (isProseFile(path))
302
+ return citationsInProse(text);
303
+ if (isTypeScriptFile(path))
304
+ return citationsInComments(text, path);
305
+ return [];
306
+ }
307
+ /**
308
+ * The most citations one run checks, across both modes. Bounds a sweep of a
309
+ * repository whose prose cites code everywhere; the default mode is already
310
+ * bounded by the change and reaches this only on a very large diff. A cap
311
+ * that bites is disclosed — see `citationsCappedNote`.
312
+ */
313
+ export const MAX_CITATIONS_CHECKED = 2000;
314
+ /**
315
+ * The most citing files one run opens. Bounds the blame calls, which are the
316
+ * expensive per-file work; the citation cap above bounds the per-citation
317
+ * work. Files are taken in path order so a capped run is deterministic.
318
+ */
319
+ export const MAX_CITING_FILES = 320;
320
+ /**
321
+ * The most distinct (revision, cited file) pairs one run reads historically.
322
+ * A repository whose prose cites a hundred files from a hundred different
323
+ * baseline commits would otherwise pay a `git show` per pair with no bound
324
+ * at all; every citation past this is checked existence-only and said so.
325
+ */
326
+ export const MAX_BASELINE_READS = 480;
327
+ /**
328
+ * The most basenames passed to one `git grep` invocation. Terms are chunked
329
+ * at this width and the results unioned, so unlike the caps above this one
330
+ * loses nothing and discloses nothing.
331
+ */
332
+ export const MAX_GREP_TERMS = 96;
333
+ /**
334
+ * Pluralized inline in the style `review` in `../cli.ts` already uses, and
335
+ * phrased as reasons so they read alongside the existing warnings. They land
336
+ * in `warnings`, which becomes `ReportModel.notes`, which trips the "This
337
+ * review is partial." banner — correctly. A capped run genuinely did not
338
+ * check everything it was asked to.
339
+ */
340
+ export function citingFilesCappedNote(scanned, found) {
341
+ const left = found - scanned;
342
+ // The leftover count carries its own noun rather than trailing a bare
343
+ // numeral. A leftover of exactly one is reachable — the cap bites the
344
+ // moment one more candidate exists than it scans — and a numeral with
345
+ // nothing to agree with is this sentence saying less than it means to.
346
+ //
347
+ // Where the scan stopped is named because the selection is a prefix, not a
348
+ // sample: `sweepCandidates` sorts by path and this cap takes the front of
349
+ // that list, so a bitten cap cuts mid-directory. Measured on a large
350
+ // repository, the earlier wording reported a fraction that read as though
351
+ // it were spread across the tree, while the run had in fact covered nearly
352
+ // every file under one leading directory and a small minority of the
353
+ // source. A true count that leaves a false impression costs a reader
354
+ // exactly what a false count would, so the sentence says which files it
355
+ // means. See `test/analyze/citations-rot.test.ts`, "discloses a bitten
356
+ // citing-file cap with counts that add up, over the prefix of the path
357
+ // order it really scanned".
358
+ return `citation checking scanned the first ${scanned} of ${found} candidate files in path order, so citations in the other ${left} file${left === 1 ? "" : "s"} were not checked; the scan stops at that point in the path order rather than spreading across this repository`;
359
+ }
360
+ export function citationsCappedNote(checked, found) {
361
+ const left = found - checked;
362
+ // The same prefix the file cap takes, one level down: `pending` is built
363
+ // over the scanned files in path order and, inside each file, in citing-line
364
+ // order, and this cap keeps the front of that list. A bitten cap therefore
365
+ // stops partway along the path order — and may stop partway through a single
366
+ // file — rather than sampling the repository. Said out loud for the reason
367
+ // `citingFilesCappedNote` above says it, and because two cap notes that
368
+ // answer the same question differently are worse than either alone: a reader
369
+ // who learns what the first sentence means reads the second as meaning it
370
+ // too. See `test/analyze/citations-rot.test.ts`, "names the prefix the
371
+ // citation cap took, and enumerates citations in exactly that order".
372
+ return `citation checking stopped after the first ${checked} of ${found} citations in path order, so ${left} further citation${left === 1 ? "" : "s"} in this repository ${left === 1 ? "was" : "were"} not checked; the check stops at that point in the path order rather than spreading across this repository`;
373
+ }
374
+ /**
375
+ * No order clause, unlike the two caps above, and the difference is in what
376
+ * this sentence claims rather than in how its budget is spent. The budget is
377
+ * spent front to back like theirs — a citation is refused a historical read
378
+ * only once the distinct-pair allowance is gone, so the ones that degrade are
379
+ * the later ones in the same path order, minus any whose baseline pair had
380
+ * already been read. But this note states no fraction and no share of the
381
+ * repository: every citation it counts was checked, and the sentence says
382
+ * exactly how far that check went. There is no coverage claim here for an
383
+ * order to qualify, so the clause the caps carry would attach to a sentence
384
+ * that never said it had covered anything.
385
+ */
386
+ export function baselineReadsCappedNote(unchecked) {
387
+ return `citation checking stopped reading historical file contents, so ${unchecked} citation${unchecked === 1 ? "" : "s"} ${unchecked === 1 ? "was" : "were"} checked only for whether the cited file exists`;
388
+ }
389
+ /**
390
+ * Copy for a shallow repository, where blame answers but cannot be believed.
391
+ * Phrased as a skip rather than a partial check, because that is what it is.
392
+ */
393
+ export function shallowRepositoryNote() {
394
+ return "citation checking was skipped: this repository is a shallow clone, so the commit that last wrote each citing line cannot be known";
395
+ }
396
+ /** Copy for citations whose history could not be read. */
397
+ export function blameUnavailableNote(count, reason) {
398
+ return `${count} citation${count === 1 ? "" : "s"} could not be dated (git blame failed: ${reason}), so ${count === 1 ? "it was" : "they were"} checked only for whether the cited file exists`;
399
+ }
400
+ /**
401
+ * A blame line's porcelain header: the commit, the line in the original
402
+ * file, the line in the final file, and — only on the first line of a run —
403
+ * how many lines the run covers. The parse keeps one thing, a map from final
404
+ * line to commit; every other header and the tab-prefixed content are
405
+ * skipped. See `test/analyze/citations-rot.test.ts`, "keeps one commit per
406
+ * final line from --line-porcelain output".
407
+ */
408
+ const BLAME_HEADER = /^([0-9a-f]{40}) \d+ (\d+)(?: \d+)?$/;
409
+ export function parseBlame(out) {
410
+ const map = new Map();
411
+ for (const line of out.split("\n")) {
412
+ const m = BLAME_HEADER.exec(line);
413
+ if (m)
414
+ map.set(Number.parseInt(m[2], 10), m[1]);
415
+ }
416
+ return map;
417
+ }
418
+ /** An uncommitted line blames to the all-zeros commit. */
419
+ const UNCOMMITTED = /^0+$/;
420
+ /**
421
+ * `git grep` exits one when nothing matches. That rejection is an absence,
422
+ * not a failure — it is read as "no candidate files", exactly as `readAt`
423
+ * reads git's absence wording as null — and it is told apart from a real
424
+ * failure by the pair of things git guarantees for it: the exit status, and
425
+ * an empty stderr. Anything else travels the degradation path.
426
+ */
427
+ function isNoMatch(err) {
428
+ if (!(err instanceof Error))
429
+ return false;
430
+ const code = err.code;
431
+ const stderr = err.stderr;
432
+ return code === 1 && typeof stderr === "string" && stderr.trim() === "";
433
+ }
434
+ /**
435
+ * The reason git printed, not the command that failed. `git()` rejects with
436
+ * execFile's error, whose `message` is a command dump — the whole argument
437
+ * list, revision hashes and all — and whose `stderr` carries what git
438
+ * actually said. This reason is composed into a user-facing partial-review
439
+ * note, so the stderr line wins; the message is the fallback for a rejection
440
+ * that carries no stderr at all.
441
+ */
442
+ function reasonOf(err) {
443
+ const stderr = err instanceof Error ? err.stderr : undefined;
444
+ const text = typeof stderr === "string" && stderr.trim() !== ""
445
+ ? stderr
446
+ : err instanceof Error
447
+ ? err.message
448
+ : String(err);
449
+ return text.trim().split("\n")[0].trim();
450
+ }
451
+ /**
452
+ * A revision and a path as one memo key. Joined by a unit separator rather
453
+ * than concatenated, so no pair can spell the same key as a different pair.
454
+ */
455
+ function revPathKey(rev, path) {
456
+ return `${rev}\u001F${path}`;
457
+ }
458
+ function linesOf(text) {
459
+ const lines = text.split("\n");
460
+ // A file ending in a newline splits to a trailing empty element that is
461
+ // not a line of the file; counting it would put every last-line citation
462
+ // one short of the end.
463
+ if (lines.length > 0 && lines[lines.length - 1] === "")
464
+ lines.pop();
465
+ return lines.map((line) => line.replace(/\r$/, ""));
466
+ }
467
+ /**
468
+ * Both sides of a was/now pair are truncated for storage and rendering, with
469
+ * CITATION_TRUNCATION_MARKER appended when the cut runs, so no line merely
470
+ * appears to end. Counted by code point for the reason `truncateSignature`
471
+ * in `./surface.ts` documents.
472
+ */
473
+ function truncateLine(text) {
474
+ const points = [...text];
475
+ if (points.length <= MAX_QUOTE_CHARS)
476
+ return text;
477
+ return points.slice(0, MAX_QUOTE_CHARS).join("") + CITATION_TRUNCATION_MARKER;
478
+ }
479
+ function normalizePath(path) {
480
+ const parts = [];
481
+ for (const part of path.split("/")) {
482
+ if (part === "" || part === ".")
483
+ continue;
484
+ if (part === "..")
485
+ parts.pop();
486
+ else
487
+ parts.push(part);
488
+ }
489
+ return parts.join("/");
490
+ }
491
+ function dirOf(path) {
492
+ const slash = path.lastIndexOf("/");
493
+ return slash < 0 ? "" : path.slice(0, slash);
494
+ }
495
+ export async function findCitationRot(changeset, ctx, options = {}) {
496
+ const now = changeset.range.to;
497
+ const cwd = ctx.cwd;
498
+ const note = options.onNote;
499
+ // A shallow clone is the one repository shape where blame is worse than
500
+ // unavailable: `--root` is exactly what suppresses git's boundary marker,
501
+ // so a line older than the graft is attributed to the graft commit rather
502
+ // than reported as unknown. At depth one that makes the baseline and the
503
+ // reviewed revision the same commit, every gate compares a thing to
504
+ // itself, and citation checking becomes a silent no-op; deeper, a finding
505
+ // states a boundary commit as "when this line was last written", which is
506
+ // a historical claim the repository does not carry. A disclosed skip is
507
+ // the only honest answer, and it is the whole check rather than a
508
+ // degradation of it.
509
+ if ((await git(["rev-parse", "--is-shallow-repository"], cwd)).trim() === "true") {
510
+ note?.(shallowRepositoryNote());
511
+ return [];
512
+ }
513
+ // Every path the range touched, both sides, so a rename is covered on
514
+ // both. This is the default mode's whole filter, and it is what bounds
515
+ // cost by the change rather than by the repository.
516
+ const touched = new Set();
517
+ for (const file of changeset.files) {
518
+ touched.add(file.path);
519
+ if (file.previousPath)
520
+ touched.add(file.previousPath);
521
+ }
522
+ // One read per revision-and-path pair. `readAt` is a `git show` per call
523
+ // and is not memoized upstream; memoizing here is what turns many
524
+ // citations into one file at one baseline commit into one read.
525
+ const reads = new Map();
526
+ const historical = new Set();
527
+ let baselineReadsRefused = 0;
528
+ const readAt = (rev, path) => {
529
+ const key = revPathKey(rev, path);
530
+ let read = reads.get(key);
531
+ if (!read) {
532
+ read = ctx.readAt(rev, path);
533
+ read.catch(() => reads.delete(key));
534
+ reads.set(key, read);
535
+ }
536
+ return read;
537
+ };
538
+ /** A historical read, refused once the distinct-pair budget is spent. */
539
+ const readHistorical = (rev, path) => {
540
+ const key = revPathKey(rev, path);
541
+ if (!historical.has(key)) {
542
+ if (historical.size >= MAX_BASELINE_READS)
543
+ return undefined;
544
+ historical.add(key);
545
+ }
546
+ return readAt(rev, path);
547
+ };
548
+ // One blame per citing file, never per citation. A file with forty
549
+ // citations costs one blame. A resolved null is a blame that failed, and
550
+ // its reason travels beside it.
551
+ const blames = new Map();
552
+ let blameReason;
553
+ const blameOf = (path) => {
554
+ let blame = blames.get(path);
555
+ if (!blame) {
556
+ const args = ["blame", "--line-porcelain", "--root"];
557
+ if (now !== WORKTREE)
558
+ args.push(now);
559
+ blame = git([...args, "--", path], cwd)
560
+ .then(parseBlame)
561
+ .catch((err) => {
562
+ blameReason ??= reasonOf(err);
563
+ return null;
564
+ });
565
+ blames.set(path, blame);
566
+ }
567
+ return blame;
568
+ };
569
+ const candidates = options.sweep
570
+ ? await sweepCandidates(cwd)
571
+ : await touchedCandidates(cwd, now, touched);
572
+ if (candidates.length === 0)
573
+ return [];
574
+ const scanned = candidates.slice(0, MAX_CITING_FILES);
575
+ if (scanned.length < candidates.length) {
576
+ note?.(citingFilesCappedNote(scanned.length, candidates.length));
577
+ }
578
+ // Extraction first, across every scanned file, because the citation cap's
579
+ // sentence states how many were found as well as how many were checked —
580
+ // and extraction costs no git at all.
581
+ const pending = [];
582
+ for (const file of scanned) {
583
+ const text = await readAt(now, file);
584
+ if (text === null)
585
+ continue;
586
+ for (const citation of citationsIn(file, text))
587
+ pending.push({ file, citation });
588
+ }
589
+ const checking = pending.slice(0, MAX_CITATIONS_CHECKED);
590
+ if (checking.length < pending.length) {
591
+ note?.(citationsCappedNote(checking.length, pending.length));
592
+ }
593
+ const rots = [];
594
+ let undated = 0;
595
+ for (const { file, citation } of checking) {
596
+ const blame = await blameOf(file);
597
+ const baseline = blame?.get(citation.citingLine);
598
+ // An uncommitted citing line is as new as the change under review:
599
+ // there is no earlier state to compare against, all four tests are
600
+ // skipped, and nothing is disclosed, because nothing was lost.
601
+ if (blame !== null && (baseline === undefined || UNCOMMITTED.test(baseline)))
602
+ continue;
603
+ if (blame === null)
604
+ undated++;
605
+ const rot = await checkCitation({
606
+ file,
607
+ citation,
608
+ baseline: blame === null ? undefined : baseline,
609
+ now,
610
+ touched,
611
+ sweep: options.sweep === true,
612
+ readAt,
613
+ readHistorical,
614
+ onRefusedBaseline: () => {
615
+ baselineReadsRefused++;
616
+ },
617
+ });
618
+ if (rot)
619
+ rots.push(rot);
620
+ }
621
+ if (undated > 0)
622
+ note?.(blameUnavailableNote(undated, blameReason ?? "unknown reason"));
623
+ if (baselineReadsRefused > 0)
624
+ note?.(baselineReadsCappedNote(baselineReadsRefused));
625
+ return rots;
626
+ }
627
+ /**
628
+ * Sweep candidates: every tracked file the pathspecs admit, minus
629
+ * REPORT_DIR. urtext's own reports quote source lines by path and line by
630
+ * construction; scanning them would make every review generate citations
631
+ * that the next review reports as rotted. Sorted, so a capped run takes the
632
+ * same files twice.
633
+ *
634
+ * `ls-files` lists the index rather than a revision, which is safe because
635
+ * every candidate is then read through `readAt` at the reviewed revision: a
636
+ * file absent there yields null and is skipped.
637
+ */
638
+ async function sweepCandidates(cwd) {
639
+ const out = await git(["ls-files", "-z", "--", ...CITATION_PATHSPECS], cwd);
640
+ return out
641
+ .split("\0")
642
+ .filter((path) => path !== "" && !path.startsWith(`${REPORT_DIR}/`))
643
+ .sort();
644
+ }
645
+ /**
646
+ * Default-mode candidates: files git can prove mention one of the changed
647
+ * files' basenames. Basenames rather than full paths, because prose cites a
648
+ * file by many spellings and the resolution rules decide what a path means;
649
+ * the full-path filter is applied after extraction, on resolved paths, where
650
+ * it is exact. Terms are chunked at MAX_GREP_TERMS per invocation and the
651
+ * results unioned — chunking loses nothing, so unlike the caps it needs no
652
+ * disclosure.
653
+ */
654
+ async function touchedCandidates(cwd, now, touched) {
655
+ const basenames = [...new Set([...touched].map((path) => path.split("/").pop() ?? path))].sort();
656
+ if (basenames.length === 0)
657
+ return [];
658
+ const found = new Set();
659
+ for (let i = 0; i < basenames.length; i += MAX_GREP_TERMS) {
660
+ const terms = basenames.slice(i, i + MAX_GREP_TERMS).flatMap((name) => ["-e", name]);
661
+ const args = ["grep", "-I", "-l", "-F", ...terms];
662
+ if (now !== WORKTREE)
663
+ args.push(now);
664
+ let out;
665
+ try {
666
+ out = await git([...args, "--", ...CITATION_PATHSPECS], cwd);
667
+ }
668
+ catch (err) {
669
+ if (isNoMatch(err))
670
+ continue;
671
+ throw err;
672
+ }
673
+ for (const line of out.split("\n")) {
674
+ // With a revision argument git prefixes each path with `<rev>:`.
675
+ const path = now === WORKTREE ? line : line.slice(line.indexOf(":") + 1);
676
+ if (path !== "" && !path.startsWith(`${REPORT_DIR}/`))
677
+ found.add(path);
678
+ }
679
+ }
680
+ return [...found].sort();
681
+ }
682
+ /**
683
+ * The four tests, in order, first one wins. A missing file has no lines to
684
+ * be out of range and no content to have drifted, so emitting more than one
685
+ * fact for one citation would be one finding said four ways.
686
+ *
687
+ * Which tests a citation is eligible for follows from its form and no other
688
+ * rule: missing_file applies to both forms; line_out_of_range and
689
+ * content_drift to the path-and-line form only, since they need a line
690
+ * number; quote_absent to the quoted form only, since it needs a phrase. A
691
+ * form is never checked by a test it has no input for.
692
+ *
693
+ * With no baseline — blame failed, or the historical-read budget is spent —
694
+ * only the first test runs, ungated, against the reviewed revision. That is
695
+ * the one place a false positive is reachable, and it is disclosed in the
696
+ * same breath by `blameUnavailableNote` or `baselineReadsCappedNote`.
697
+ */
698
+ async function checkCitation(args) {
699
+ const { file, citation, baseline, now, touched, sweep, readAt, readHistorical } = args;
700
+ const isProse = isProseFile(file);
701
+ // Repository-root-relative first, which is how every path in this codebase
702
+ // is spelled and how `git show <rev>:<path>` resolves; then relative to
703
+ // the citing file's own directory, for prose only. A comment in `src/x.ts`
704
+ // that names `report/model.ts` means the repository path, and resolving it
705
+ // against `src/` would invent a file.
706
+ const spellings = [normalizePath(citation.path)];
707
+ if (isProse && dirOf(file) !== "") {
708
+ spellings.push(normalizePath(`${dirOf(file)}/${citation.path}`));
709
+ }
710
+ const optional = {
711
+ ...(citation.line === undefined ? {} : { citedLine: citation.line }),
712
+ ...(citation.endLine === undefined ? {} : { citedEndLine: citation.endLine }),
713
+ ...(citation.quote === undefined ? {} : { quote: citation.quote }),
714
+ };
715
+ let citedFile;
716
+ let baselineText = null;
717
+ let refused = false;
718
+ if (baseline !== undefined) {
719
+ for (const spelling of spellings) {
720
+ const read = readHistorical(baseline, spelling);
721
+ if (read === undefined) {
722
+ refused = true;
723
+ break;
724
+ }
725
+ const text = await read;
726
+ if (text !== null) {
727
+ citedFile = spelling;
728
+ baselineText = text;
729
+ break;
730
+ }
731
+ }
732
+ }
733
+ if (baseline === undefined || refused) {
734
+ if (refused)
735
+ args.onRefusedBaseline();
736
+ // Existence-only: resolve against the reviewed revision, since there is
737
+ // no baseline to resolve against, and carry no commit, since none was
738
+ // read. The path reported is the root-relative spelling, which is the
739
+ // one a reader will look for.
740
+ for (const spelling of spellings) {
741
+ if ((await readAt(now, spelling)) !== null)
742
+ return undefined;
743
+ }
744
+ const absent = spellings[0];
745
+ const absentTouched = touched.has(absent);
746
+ // The default mode's filter still binds when history does not: a
747
+ // shallow clone is the common case for it, and a review that named
748
+ // files the change never touched would be answering a question nobody
749
+ // asked, under a verified badge.
750
+ if (!sweep && !absentTouched)
751
+ return undefined;
752
+ return {
753
+ rot: "missing_file",
754
+ citingFile: file,
755
+ citingLine: citation.citingLine,
756
+ citingText: citation.citingText,
757
+ citedFile: absent,
758
+ ...optional,
759
+ citedTouched: absentTouched,
760
+ };
761
+ }
762
+ // Resolved neither way at the baseline: the citation never resolved, and
763
+ // urtext cannot tell a typo from an illustration from a plan for a file
764
+ // that does not exist yet. It says nothing.
765
+ if (citedFile === undefined || baselineText === null)
766
+ return undefined;
767
+ // The default mode's exact filter, applied on the resolved path.
768
+ const citedTouched = touched.has(citedFile);
769
+ if (!sweep && !citedTouched)
770
+ return undefined;
771
+ const common = {
772
+ citingFile: file,
773
+ citingLine: citation.citingLine,
774
+ citingText: citation.citingText,
775
+ citedFile,
776
+ baseline,
777
+ citedTouched,
778
+ };
779
+ const nowText = await readAt(now, citedFile);
780
+ if (nowText === null) {
781
+ return { rot: "missing_file", ...common, ...optional };
782
+ }
783
+ const baseLines = linesOf(baselineText);
784
+ const nowLines = linesOf(nowText);
785
+ if (citation.line !== undefined) {
786
+ const last = citation.endLine ?? citation.line;
787
+ // Gate: a citation to a line the file never had is a typo, not rot.
788
+ if (citation.line > baseLines.length || last > baseLines.length)
789
+ return undefined;
790
+ if (citation.line > nowLines.length || last > nowLines.length) {
791
+ return {
792
+ rot: "line_out_of_range",
793
+ ...common,
794
+ citedLine: citation.line,
795
+ ...(citation.endLine === undefined ? {} : { citedEndLine: citation.endLine }),
796
+ lineCount: nowLines.length,
797
+ };
798
+ }
799
+ }
800
+ if (citation.quote !== undefined) {
801
+ // Containment, not equality, and normalization on both sides, so
802
+ // re-wrapping a source comment does not fire this and a genuine
803
+ // rewording does.
804
+ if (!normalizeText(baselineText).includes(citation.quote))
805
+ return undefined;
806
+ if (!normalizeText(nowText).includes(citation.quote)) {
807
+ return { rot: "quote_absent", ...common, quote: citation.quote };
808
+ }
809
+ return undefined;
810
+ }
811
+ if (citation.line !== undefined) {
812
+ const last = citation.endLine ?? citation.line;
813
+ for (let n = citation.line; n <= last; n++) {
814
+ // Compared with leading and trailing whitespace stripped per line: a
815
+ // pure re-indent moves no content, and reporting it would be noise a
816
+ // reader cannot act on. For a range, the first differing line is the
817
+ // one reported.
818
+ const was = baseLines[n - 1] ?? "";
819
+ const current = nowLines[n - 1] ?? "";
820
+ if (was.trim() === current.trim())
821
+ continue;
822
+ return {
823
+ rot: "content_drift",
824
+ ...common,
825
+ // The line that differs, not the range's first line. `was`, `now`,
826
+ // and `citedText` are all this line's text, and a fact whose line
827
+ // number and quoted text disagreed would send a reader to a line
828
+ // that reads nothing like the one the finding shows it. See
829
+ // `citedEndLine` for why no range is carried alongside.
830
+ citedLine: n,
831
+ // And the citation as the prose wrote it, whole and unmixed, so the
832
+ // finding can name the string the reader will search for without
833
+ // that string ever being assembled out of two different lines.
834
+ writtenLine: citation.line,
835
+ ...(citation.endLine === undefined ? {} : { writtenEndLine: citation.endLine }),
836
+ was: truncateLine(was.trim()),
837
+ now: truncateLine(current.trim()),
838
+ // The cited line as it currently stands, taken from text already in
839
+ // hand so the analyzer composing `evidence[1]` never reads it again.
840
+ citedText: current.trim(),
841
+ };
842
+ }
843
+ }
844
+ return undefined;
845
+ }
846
+ /**
847
+ * Leading characters of a commit object name kept for display. The finding
848
+ * body names this commit to a reader, and a full object name there is noise
849
+ * beside a sentence; git's own short form is the length a reader recognizes.
850
+ */
851
+ const ABBREVIATED_HASH = 7;
852
+ function abbreviate(hash) {
853
+ return hash.slice(0, ABBREVIATED_HASH);
854
+ }
855
+ /**
856
+ * Returns its analyzer under a name it states outright, and that is
857
+ * load-bearing: `runAnalyzers` reports a failed analyzer by
858
+ * `analyzers[i].name` in a warning a user reads, and an arrow returned
859
+ * directly from a factory has no name at all, so a citation analyzer that
860
+ * threw would be disclosed as a numbered anonymous one.
861
+ *
862
+ * The other analyzers get their names for free, from NamedEvaluation of the
863
+ * variable declaration they are assigned to. That mechanism is not enough
864
+ * here. This binding sits one scope in and shadows the module-level
865
+ * singleton below, and a transform that renames shadowed symbols to keep
866
+ * every binding unique — esbuild, which is what runs this repository's tests
867
+ * — rewrites the binding, and the inferred name goes with it, turning the
868
+ * disclosed name into a near-miss of itself. So the name is written down
869
+ * rather than derived. See `test/analyze/citations-rot.test.ts`, "names
870
+ * itself when it throws, so the disclosure never says analyzer #N".
871
+ */
872
+ export function makeCitationsAnalyzer(options = {}) {
873
+ const citationsAnalyzer = async (changeset, ctx) => {
874
+ const rots = await findCitationRot(changeset, ctx, options);
875
+ return rots.map((rot) => {
876
+ // evidence[0] is the citing line, so Fact.file/Fact.line land on the
877
+ // prose the reader has to fix. evidence[1], when the cited file and
878
+ // line exist now, is the cited location as it currently stands — the
879
+ // "now" half of a drift, shown rather than asserted. The baseline
880
+ // content is deliberately never an EvidenceRef: `side` distinguishes
881
+ // the before and after sides of the reviewed range, and the baseline
882
+ // is some other commit entirely. It lives in `detail.was` and in the
883
+ // finding body, where the commit that produced it is named beside it.
884
+ const evidence = [
885
+ {
886
+ file: rot.citingFile,
887
+ line: rot.citingLine,
888
+ excerpt: rot.citingText,
889
+ side: "after",
890
+ },
891
+ ];
892
+ // A drift onto an empty line has a cited location and nothing to quote
893
+ // at it. The ref is dropped rather than pushed with an empty excerpt:
894
+ // every surface renders a ref as its location followed by its text, so
895
+ // an empty one is a row that shows the reader nothing and reads as a
896
+ // renderer that failed rather than as the blank line it is. The
897
+ // blankness is not lost — `toFinding` states it in words, from
898
+ // `detail.now`, which carries it exactly. See `src/score/index.ts`,
899
+ // "A line urtext read as empty is something it knows".
900
+ if (rot.citedLine !== undefined && rot.citedText !== undefined && rot.citedText !== "") {
901
+ evidence.push({
902
+ file: rot.citedFile,
903
+ line: rot.citedLine,
904
+ excerpt: rot.citedText,
905
+ side: "after",
906
+ });
907
+ }
908
+ return makeFact({
909
+ // The kind and a colon, which is the convention `subjectOf` in
910
+ // `../report/model.ts` recovers the lens from. The citing location
911
+ // plus the rot kind is the identity: one citing line can carry two
912
+ // citations, and both may rot. `qualifiedSymbol` is omitted — a
913
+ // citation is about a file and a line, not a symbol.
914
+ id: `citation_rot:${rot.citingFile}:${rot.citingLine}:${rot.rot}`,
915
+ kind: "citation_rot",
916
+ detail: {
917
+ rot: rot.rot,
918
+ citedFile: rot.citedFile,
919
+ ...(rot.citedLine === undefined ? {} : { citedLine: rot.citedLine }),
920
+ ...(rot.citedEndLine === undefined ? {} : { citedEndLine: rot.citedEndLine }),
921
+ ...(rot.writtenLine === undefined ? {} : { writtenLine: rot.writtenLine }),
922
+ ...(rot.writtenEndLine === undefined ? {} : { writtenEndLine: rot.writtenEndLine }),
923
+ ...(rot.quote === undefined ? {} : { quote: rot.quote }),
924
+ ...(rot.was === undefined ? {} : { was: rot.was }),
925
+ ...(rot.now === undefined ? {} : { now: rot.now }),
926
+ ...(rot.baseline === undefined ? {} : { baseline: abbreviate(rot.baseline) }),
927
+ ...(rot.lineCount === undefined ? {} : { lineCount: rot.lineCount }),
928
+ citedTouched: rot.citedTouched,
929
+ },
930
+ // Shared with the analyzers that sample evidence, so the cap cannot
931
+ // drift between them, though a citation fact never has more than two
932
+ // refs today.
933
+ evidence: evidence.slice(0, MAX_EVIDENCE),
934
+ });
935
+ });
936
+ };
937
+ Object.defineProperty(citationsAnalyzer, "name", { value: "citationsAnalyzer" });
938
+ return citationsAnalyzer;
939
+ }
940
+ /**
941
+ * The default-mode instance, and the member of ANALYZERS. Also the identity
942
+ * `review` matches on when it swaps in a configured instance, so it must stay
943
+ * a single shared value rather than being reconstructed per call.
944
+ */
945
+ export const citationsAnalyzer = makeCitationsAnalyzer();