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,396 @@
1
+ import { join } from "node:path";
2
+ import ts from "typescript";
3
+ import { isTypeScriptFile } from "../extract/symbols.js";
4
+ import { canonicalSignature } from "./canonical.js";
5
+ import { makeFact } from "./fact.js";
6
+ import { relativePathOf } from "./program.js";
7
+ /**
8
+ * Interfaces and type aliases carry no runtime value, so
9
+ * `getTypeOfSymbolAtLocation` resolves them to `any` — every such export
10
+ * would otherwise print identically on both sides of any change, no matter
11
+ * what changed. Their shape has to come from `getDeclaredTypeOfSymbol`
12
+ * instead (see `structuralSignature`).
13
+ */
14
+ const TYPE_ONLY = ts.SymbolFlags.Interface | ts.SymbolFlags.TypeAlias;
15
+ /**
16
+ * A stored signature beyond this length is truncated when a fact records
17
+ * it, so that one enormous type — a huge object literal, or a union with
18
+ * many members — cannot flood a fact with a single string. The marker
19
+ * makes the cut visible: a reader must be able to tell the text was cut,
20
+ * not mistake it for the whole type. Exported for
21
+ * `test/comment-contract.test.ts`, which derives part of its forbidden set
22
+ * from it.
23
+ *
24
+ * Applied at the fact-emission boundary, not inside `exportedSignatures`:
25
+ * the true, uncut text has to exist long enough for its code-point length
26
+ * to be recorded beside the capped text (`beforeChars`/`afterChars` in the
27
+ * fact's detail), because the renderer's own length marker states that
28
+ * true length — a length measured after this cap asserted a false size
29
+ * for exactly the long-literal class the marker exists for.
30
+ */
31
+ export const MAX_SIGNATURE_LENGTH = 400;
32
+ /**
33
+ * Matches the marker `truncateSignature` appends. Exported for the render
34
+ * layer (`../score/index.ts`), which drops the marker before applying its
35
+ * own, shorter middle-truncation — the render marker states the true
36
+ * length, and keeping both would leave this one's tail fragment inside
37
+ * the rendered text.
38
+ */
39
+ export const SIGNATURE_TRUNCATION_MARKER = /… \[truncated, \d+ more chars\]$/;
40
+ /**
41
+ * Counted in code points throughout — the length check, the cut, and the
42
+ * omitted count in the marker. `String#slice` counts UTF-16 units, so an
43
+ * astral character straddling the cap stored a lone surrogate that every
44
+ * downstream layer then faithfully preserved: the render layer cuts
45
+ * safely, but cannot repair a half character it was handed.
46
+ */
47
+ function truncateSignature(text) {
48
+ const points = [...text];
49
+ if (points.length <= MAX_SIGNATURE_LENGTH)
50
+ return text;
51
+ const omitted = points.length - MAX_SIGNATURE_LENGTH;
52
+ return `${points.slice(0, MAX_SIGNATURE_LENGTH).join("")}… [truncated, ${omitted} more chars]`;
53
+ }
54
+ /** Code points, not UTF-16 units — the unit every truncation layer counts in. */
55
+ function codePointLength(text) {
56
+ return [...text].length;
57
+ }
58
+ /**
59
+ * Well-known-symbol members (`[Symbol.iterator]`, `[Symbol.unscopables]`,
60
+ * `[Symbol.asyncIterator]`) come back from `getPropertiesOfType` under a
61
+ * name that embeds the checker's *internal symbol id* — `__@iterator@11` in
62
+ * one program, `__@iterator@472` in another built from byte-identical
63
+ * source. Printing that id would report a contract change for source that
64
+ * provably did not change. The id is stripped; the member name is kept, so
65
+ * a genuinely retyped `[Symbol.iterator]` is still visible.
66
+ */
67
+ const WELL_KNOWN_MEMBER = /^(__@[A-Za-z]+)@\d+$/;
68
+ function memberName(name) {
69
+ return name.replace(WELL_KNOWN_MEMBER, "$1");
70
+ }
71
+ /**
72
+ * Whether a type-only symbol's shape was written out by hand at its
73
+ * declaration — an `interface X { ... }`, or a `type X = { ... }` whose
74
+ * right-hand side is an object type literal.
75
+ *
76
+ * Only those two get expanded member-by-member (see `structuralSignature`).
77
+ * The distinction is not cosmetic. `getPropertiesOfType` reports the
78
+ * *apparent* type's members, which for anything else is a library shape the
79
+ * user never wrote: `type Names = string[]` reports all 35 members of
80
+ * `Array<string>`, a tuple reports the same plus its indices, `Map` reports
81
+ * the Map API. Two of those members carry an unstable internal id (see
82
+ * `WELL_KNOWN_MEMBER`), so every array, tuple, `Map`, `Set`, or otherwise
83
+ * iterable alias reported a fabricated `signature_changed` on every run.
84
+ * Gating on `TypeFlags.Object` did not exclude them, because arrays and
85
+ * tuples *are* object types. Gating on the declaration does.
86
+ */
87
+ function expandsStructurally(sym) {
88
+ return (sym.declarations ?? []).some((d) => ts.isInterfaceDeclaration(d) ||
89
+ (ts.isTypeAliasDeclaration(d) && ts.isTypeLiteralNode(d.type)));
90
+ }
91
+ /** `(req: string): string` for each call signature, or "" if there are none. */
92
+ function callSignatures(checker, type) {
93
+ return checker
94
+ .getSignaturesOfType(type, ts.SignatureKind.Call)
95
+ .map((s) => checker.signatureToString(s, undefined, ts.TypeFormatFlags.NoTruncation))
96
+ .join("; ");
97
+ }
98
+ /**
99
+ * A printed signature for a type-only export, sensitive to the changes that
100
+ * break callers and stable across two separately-built `ts.Program`s.
101
+ *
102
+ * Hand-written shapes (see `expandsStructurally`) are expanded
103
+ * member-by-member: `name: type` per member, sorted by member name so that
104
+ * reordering members does not read as a contract change, plus any call
105
+ * signatures — `getPropertiesOfType` does not enumerate those, and an
106
+ * `interface H { (req: string): string }` would otherwise print as `{ }` on
107
+ * both sides of any change to its parameters. Index signatures are still
108
+ * not enumerated: a type whose only change is to one of those is invisible
109
+ * on this path (`test/analyze/surface.test.ts` pins what *is* seen).
110
+ *
111
+ * Everything else prints via `checker.typeToString`, which gives exactly
112
+ * the text a reader would write: `string[]`, `[string, number]`,
113
+ * `Map<string, number>`, `(req: string) => string`, `{ [x: string]:
114
+ * number; }`. Short, accurate, and — unlike the expansion of a library
115
+ * type's apparent members — identical across programs. The
116
+ * `TypeFormatFlags.InTypeAlias` flag is required: without it, a type
117
+ * carrying its own alias symbol (which `getDeclaredTypeOfSymbol` always
118
+ * returns for a type alias) prints back out as just the alias's own name
119
+ * (`"Kind"`, not `"function" | "class"`), identical on both sides of any
120
+ * change, which would make this path mute.
121
+ *
122
+ * How shape-sensitive that printing is depends on the shape:
123
+ *
124
+ * - A literal union (`"a" | "b"`) prints its members; adding or removing
125
+ * one changes the text. Same for a union of type *aliases*: `type AX = {
126
+ * p: string }; type AY = { q: number }; export type U = AX | AY` prints
127
+ * `{ p: string; } | { q: number; }`, fully shape-sensitive, because
128
+ * `InTypeAlias` expands each member's own alias.
129
+ * - A union or intersection of *interfaces* prints each member as its bare
130
+ * name — `IX | IY`, `IX & IY` — so a member added to `IX` is invisible
131
+ * through `U`'s signature. (Pinned in `surface.test.ts`.)
132
+ *
133
+ * That last gap is partially self-mitigating, but not because of where
134
+ * `IX` lives relative to `U`: `surfaceAnalyzer` below computes each changed
135
+ * file's own before/after export table independently, so co-location is
136
+ * irrelevant. What matters is whether `IX` is *itself* an export of a file
137
+ * in the changeset — if it is, and its signature changed, that file emits
138
+ * its own `signature_changed` finding for `IX` directly. It stays silent
139
+ * when `IX` did not change, when `IX`'s declaring file falls outside the
140
+ * range being reviewed, and — the case that surprises — when `IX` is not
141
+ * exported at all, since a non-exported interface never appears in any
142
+ * file's export table and so no file emits a compensating finding for it.
143
+ *
144
+ * A union's members are printed sorted, for the same reason the structural
145
+ * branch sorts properties: a union is a set, its declared order carries no
146
+ * meaning, and that order is not even guaranteed stable for byte-identical
147
+ * source text across two separately-built programs. Printing it as declared
148
+ * would report a "signature changed" finding for source that did not change.
149
+ */
150
+ function structuralSignature(checker, sym, fallback) {
151
+ const declared = checker.getDeclaredTypeOfSymbol(sym);
152
+ // NoTruncation everywhere a type is printed: the checker's own default
153
+ // cap cut long literal types silently, with a marker of its own, before
154
+ // either of this module's layers ever saw the text — so the recorded
155
+ // "true length" was the checker's cap, not the type's, and two literals
156
+ // differing only past that cap printed identically. The full text lives
157
+ // only until `surfaceAnalyzer` stores it through `truncateSignature`.
158
+ const FLAGS = ts.TypeFormatFlags.InTypeAlias | ts.TypeFormatFlags.NoTruncation;
159
+ if (!expandsStructurally(sym)) {
160
+ if (declared.isUnion()) {
161
+ const members = declared.types
162
+ .map((t) => checker.typeToString(t, undefined, FLAGS))
163
+ .sort();
164
+ return members.join(" | ");
165
+ }
166
+ return checker.typeToString(declared, undefined, FLAGS);
167
+ }
168
+ const props = [...checker.getPropertiesOfType(declared)]
169
+ .map((p) => ({ sym: p, name: memberName(p.getName()) }))
170
+ .sort((a, b) => a.name.localeCompare(b.name));
171
+ const parts = props.map((p) => {
172
+ const type = checker.getTypeOfSymbolAtLocation(p.sym, p.sym.valueDeclaration ?? fallback);
173
+ return `${p.name}: ${checker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation)}`;
174
+ });
175
+ const shape = `{ ${parts.join("; ")} }`;
176
+ const calls = callSignatures(checker, declared);
177
+ return calls ? `${shape} & { ${calls} }` : shape;
178
+ }
179
+ /**
180
+ * Exported name → printed type, for one file in a program. `undefined` when
181
+ * the program has no such source file: that is an unreadable file, not a
182
+ * file with no exports, and the two must not be conflated. Returning an
183
+ * empty map for a missing before-side file would make every export of the
184
+ * after-side file read as `export_added` — absence treated as evidence,
185
+ * one level up from the file-read rule the other analyzers follow.
186
+ *
187
+ * The printed text is the *full* signature, uncapped: `surfaceAnalyzer`
188
+ * measures its true length and applies `truncateSignature` when it stores
189
+ * the text on a fact, and truncating here instead would destroy the length
190
+ * before anything could record it.
191
+ */
192
+ export function exportedSignatures(program, root, path) {
193
+ const out = new Map();
194
+ const sf = program.getSourceFile(join(root, path));
195
+ if (!sf)
196
+ return undefined;
197
+ const checker = program.getTypeChecker();
198
+ const moduleSymbol = checker.getSymbolAtLocation(sf);
199
+ // A source file with no module symbol is a script, not a module: it has
200
+ // no exports, which is a fact about the file rather than a failure to
201
+ // read it.
202
+ if (!moduleSymbol)
203
+ return out;
204
+ for (const sym of checker.getExportsOfModule(moduleSymbol)) {
205
+ const signature = sym.flags & TYPE_ONLY
206
+ ? structuralSignature(checker, sym, sf)
207
+ : checker.typeToString(checker.getTypeOfSymbolAtLocation(sym, sf), undefined,
208
+ // See `structuralSignature`'s comment on NoTruncation: this is
209
+ // the path a long string-literal const takes, exactly the
210
+ // class whose true length the renderer's marker must state.
211
+ ts.TypeFormatFlags.NoTruncation);
212
+ out.set(sym.getName(), signature);
213
+ }
214
+ return out;
215
+ }
216
+ /**
217
+ * Line, excerpt, and repo-relative file for the declaration of an exported
218
+ * name — anchored to the file that actually *declares* the symbol, not the
219
+ * file whose export list it was found on. Both re-export forms need this:
220
+ *
221
+ * - `export * from "./other.js"` hands back the underlying declaration's own
222
+ * symbol directly, so `declarations[0]` already lives in `./other.js`.
223
+ * - `export { x } from "./other.js"` (a *named* re-export) hands back an
224
+ * `Alias`-flagged symbol whose own `declarations[0]` is the
225
+ * `ExportSpecifier` — a line in the re-exporting barrel, not in
226
+ * `./other.js`. Resolving through `getAliasedSymbol` first is required to
227
+ * reach the real declaration.
228
+ *
229
+ * Using the wrong side's source text for the line/excerpt would satisfy the
230
+ * Fact.file/evidence agreement check while pointing at a line that has
231
+ * nothing to do with the symbol (or, for the named-re-export case, at the
232
+ * barrel's `export { x } from ...` line rather than the actual definition)
233
+ * — evidence that looks valid and is not. Returns undefined, rather than
234
+ * evidence in the wrong file, when no declaration can be found at all.
235
+ */
236
+ function lineOfExport(program, root, path, name) {
237
+ const sf = program.getSourceFile(join(root, path));
238
+ if (!sf)
239
+ return undefined;
240
+ const checker = program.getTypeChecker();
241
+ const moduleSymbol = checker.getSymbolAtLocation(sf);
242
+ const sym = moduleSymbol
243
+ ? checker.getExportsOfModule(moduleSymbol).find((s) => s.getName() === name)
244
+ : undefined;
245
+ if (!sym)
246
+ return undefined;
247
+ // getAliasedSymbol throws on a symbol that isn't actually an alias, so it
248
+ // is only called behind the flag check. If resolving the alias yields no
249
+ // usable declaration (a shape the compiler API does not otherwise rule
250
+ // out), fall back to the unresolved symbol rather than losing the
251
+ // evidence entirely.
252
+ const resolved = sym.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(sym) : sym;
253
+ const decl = resolved.declarations?.[0] ?? sym.declarations?.[0];
254
+ if (!decl)
255
+ return undefined;
256
+ const declSf = decl.getSourceFile();
257
+ const line = declSf.getLineAndCharacterOfPosition(decl.getStart(declSf)).line + 1;
258
+ const excerpt = declSf.text.split("\n")[line - 1]?.trim() ?? "";
259
+ // `resolved`'s own name, not `name` (the caller's, which is the exported
260
+ // name — possibly an alias assigned by the re-exporting barrel, not the
261
+ // binding's own name). Two different bindings declared on the same line
262
+ // of the same file — `export const alpha = ..., beta = ...;` — can each
263
+ // be re-exported under the identical alias from two different barrels
264
+ // (`export { alpha as z }`, `export { beta as z }`); without the real
265
+ // declared name, (file, line, exported-name) alone cannot tell them
266
+ // apart. See `surfaceAnalyzer`'s dedup key, below.
267
+ return { file: relativePathOf(root, declSf), line, excerpt, declaredName: resolved.getName() };
268
+ }
269
+ /**
270
+ * Reports changes to a file's public contract: exports added, exports
271
+ * removed, and exports whose type signature changed. A changed signature is
272
+ * the class of change that breaks callers without breaking the build at the
273
+ * point of change, which is why it ranks above an added export.
274
+ */
275
+ export const surfaceAnalyzer = async (changeset, ctx) => {
276
+ const relevant = changeset.files.filter((f) => isTypeScriptFile(f.path) && f.status !== "deleted");
277
+ if (relevant.length === 0)
278
+ return [];
279
+ const [beforeProgram, afterProgram] = await Promise.all([
280
+ ctx.programAt(ctx.range.from),
281
+ ctx.programAt(ctx.range.to),
282
+ ]);
283
+ const facts = [];
284
+ // Shared across every file in `relevant`, not reset per iteration: a
285
+ // barrel re-export is discovered twice — once from the file that declares
286
+ // the symbol, once from the barrel that re-exports it — and both
287
+ // discoveries resolve to the same declaration site via `lineOfExport`'s
288
+ // `getAliasedSymbol` call. Keying on (kind, where.file, where.line, name,
289
+ // where.declaredName) rather than on file.path (the file this loop
290
+ // iteration happens to be examining) is what makes those two discoveries
291
+ // collide instead of producing two facts for one new — or one changed, or
292
+ // one removed — export. See `test/analyze/surface.test.ts`, "reports a
293
+ // symbol declared once and re-exported through two barrels as one fact,
294
+ // not one per barrel".
295
+ const seenDeclarations = new Set();
296
+ for (const file of relevant) {
297
+ const beforePath = file.previousPath ?? file.path;
298
+ const before = file.status === "added"
299
+ ? new Map()
300
+ : exportedSignatures(beforeProgram, ctx.cwd, beforePath);
301
+ const after = exportedSignatures(afterProgram, ctx.cwd, file.path);
302
+ // Either side missing from its program is a failure to read, not an
303
+ // empty contract. Diffing against a map that does not describe the file
304
+ // would invent an added or removed export for every name in it.
305
+ if (before === undefined || after === undefined)
306
+ continue;
307
+ const emit = (kind, name, detail, where, side) => {
308
+ // No resolvable declaration site, or nothing to excerpt: no evidence,
309
+ // no fact.
310
+ if (!where || !where.excerpt)
311
+ return;
312
+ // `name` (the exported name) plus `where.declaredName` (the real
313
+ // binding's own name): naming both, not just one. `name` alone
314
+ // collapses two different bindings that happen to share a line and an
315
+ // exported alias — see `lineOfExport`'s doc comment for the
316
+ // `alpha`/`beta`-as-`z` case this guards. `where.declaredName` alone
317
+ // would instead collapse the same binding re-exported under two
318
+ // genuinely different external names into one entry, which is a
319
+ // separate question this key does not need to answer either way.
320
+ const key = `${kind}:${where.file}:${where.line}:${name}:${where.declaredName}`;
321
+ if (seenDeclarations.has(key))
322
+ return;
323
+ seenDeclarations.add(key);
324
+ const evidence = [
325
+ { file: where.file, line: where.line, excerpt: where.excerpt, side },
326
+ ];
327
+ facts.push(makeFact({
328
+ // Anchored at the declaration (where.file, where.line), the same
329
+ // place `key` above dedupes on — not at file.path. One export has
330
+ // one id no matter how many files re-export it.
331
+ //
332
+ // Fact.file is not passed at all: makeFact derives it from
333
+ // evidence[0], which is where.file — evidence for a re-exported
334
+ // symbol, or for a removed export on a renamed file, lands in a
335
+ // different file than the one being analyzed here.
336
+ id: key,
337
+ kind,
338
+ // The declaration's own name, not the exported one. They differ
339
+ // only for a renaming re-export (`export { alpha as z }`), and
340
+ // there it is `alpha` that `blast-radius` will have named for the
341
+ // same declaration — `foldReach` matches the two on this field, so
342
+ // an alias here would leave a signature change and its own
343
+ // reference count looking like facts about two different symbols.
344
+ // The reader still sees `z`: the prose reads `detail.export`.
345
+ qualifiedSymbol: where.declaredName,
346
+ detail: { export: name, ...detail },
347
+ evidence,
348
+ }));
349
+ };
350
+ for (const [name, afterSig] of after) {
351
+ const beforeSig = before.get(name);
352
+ const storedAfter = truncateSignature(afterSig);
353
+ if (beforeSig === undefined) {
354
+ emit("export_added", name, { after: storedAfter }, lineOfExport(afterProgram, ctx.cwd, file.path, name), "after");
355
+ }
356
+ else if (canonicalSignature(beforeSig) !== canonicalSignature(afterSig)) {
357
+ // Compared CANONICALLY, because the raw text carries checker
358
+ // accidents: a union nested anywhere in a printed type serializes
359
+ // in type-interning order, which merely adding a module elsewhere
360
+ // in the range can flip (see `canonicalSignature`). The fact still
361
+ // stores the capped raw text — the reader sees what the checker
362
+ // printed; only the equality question goes through the canonical
363
+ // form.
364
+ //
365
+ // Compared on the FULL text, not the stored capped one, for two
366
+ // reasons that arrived together. A capped text can cut mid-token,
367
+ // and unparseable text canonicalizes to itself — so a long type
368
+ // with a flipped union inside the cap stayed a false positive (a
369
+ // real repository's 986-char interface reproduced this). And the
370
+ // old capped comparison silently ignored any real change past the
371
+ // cap — a `verified` tier that stops reading at an arbitrary
372
+ // length was masking, not caution. Both directions are pinned in
373
+ // `test/analyze/surface.test.ts`, "nested set-semantic reorders".
374
+ // The `beforeChars`/`afterChars` counts are the full text's
375
+ // code-point lengths, recorded before the cap so the renderer's
376
+ // length marker can state the type's real size rather than the
377
+ // cap's.
378
+ emit("signature_changed", name, {
379
+ before: truncateSignature(beforeSig),
380
+ beforeChars: codePointLength(beforeSig),
381
+ after: storedAfter,
382
+ afterChars: codePointLength(afterSig),
383
+ }, lineOfExport(afterProgram, ctx.cwd, file.path, name), "after");
384
+ }
385
+ }
386
+ for (const [name, beforeSig] of before) {
387
+ if (after.has(name))
388
+ continue;
389
+ emit("export_removed", name, { before: truncateSignature(beforeSig) },
390
+ // Before-side evidence: this line number counts in the before
391
+ // revision, and need not exist in the working tree at all.
392
+ lineOfExport(beforeProgram, ctx.cwd, beforePath, name), "before");
393
+ }
394
+ }
395
+ return facts;
396
+ };
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "./cli.js";
3
+ // The executable entry, and nothing else: it runs unconditionally. Its
4
+ // predecessor was an am-I-the-entry-module guard at the bottom of cli.ts
5
+ // comparing `process.argv[1]` against `import.meta.url`, and that comparison
6
+ // broke twice — once on the compiled filename, then under a symlinked global
7
+ // bin directory (fnm's, but any version manager or `npm link` does this),
8
+ // where Node resolves the real path for `import.meta.url` while `argv[1]`
9
+ // keeps the symlinked spelling. The guard failing means the CLI exits zero
10
+ // having printed nothing. Tests import cli.ts, which no longer self-runs;
11
+ // nothing imports this file.
12
+ void main();
package/dist/cli.d.ts ADDED
@@ -0,0 +1,110 @@
1
+ import { type ReportModel } from "./report/model.js";
2
+ import { type ExportFormat } from "./report/write.js";
3
+ import type { Analyzer } from "./types.js";
4
+ /**
5
+ * Every format `--stdout` can carry. One member today, and a union rather
6
+ * than a boolean for the same reason `IntentSource` is one: a second member
7
+ * is a compile error at every site that decides what stdout holds, instead
8
+ * of a boolean that quietly means "the one other thing". Lives here and not
9
+ * beside EXPORT_FORMATS in `./report/write.js`: that constant belongs to the
10
+ * writer because the writer owns the filenames, and nothing outside this
11
+ * file decides what a stream carries.
12
+ */
13
+ export declare const STDOUT_FORMATS: readonly ["md"];
14
+ export type StdoutFormat = (typeof STDOUT_FORMATS)[number];
15
+ export interface CliOptions {
16
+ command: string;
17
+ range?: string;
18
+ json: boolean;
19
+ noLlm: boolean;
20
+ /** Optional like `range`, not defaulted like the other flags: every
21
+ * pre-existing caller of `review` — this CLI's own tests included —
22
+ * predates `--open` and constructs a `CliOptions` literal without it. */
23
+ open?: boolean;
24
+ /**
25
+ * The formats `--export` asked for, deduplicated, in first-mention order.
26
+ * Optional for the same reason as `open`: pre-existing callers construct
27
+ * `CliOptions` literals without it. Undefined and empty mean the same
28
+ * thing — write no exports.
29
+ */
30
+ exportFormats?: ExportFormat[];
31
+ /**
32
+ * Model for the interpretation stage. Undefined means the flag was not
33
+ * given, which `requestClaims` reads as `DEFAULT_MODEL` — the default lives
34
+ * there, in the one place that talks to the API, rather than being copied
35
+ * into this parser as well.
36
+ */
37
+ model?: string;
38
+ /**
39
+ * The format `--stdout` asked for. Optional for the same reason as `open`
40
+ * and `exportFormats`: pre-existing callers construct `CliOptions`
41
+ * literals without it. Undefined means the terminal render owns stdout, as
42
+ * it always has.
43
+ */
44
+ stdout?: StdoutFormat;
45
+ /**
46
+ * Sweep every citation in the repository rather than only those pointing
47
+ * into changed files. Optional like `open` and `exportFormats`: every
48
+ * pre-existing caller constructs a `CliOptions` literal without it.
49
+ */
50
+ citations?: boolean;
51
+ help: boolean;
52
+ }
53
+ /** Exported so a test can check that it names the real default model. */
54
+ export declare const USAGE: string;
55
+ export declare function parseArgs(argv: string[]): CliOptions;
56
+ /**
57
+ * The renderers behind `--export`, one per format. `renderPdf` keeps its
58
+ * lazy pdfkit import internally, so carrying it in this default costs a run
59
+ * without `--export pdf` nothing.
60
+ */
61
+ export interface Exporters {
62
+ md: (model: ReportModel) => string;
63
+ pdf: (model: ReportModel) => Promise<Buffer>;
64
+ }
65
+ export declare function review(cwd: string, opts: CliOptions, analyzers?: Analyzer[], exporters?: Exporters): Promise<{
66
+ output: string;
67
+ exitCode: number;
68
+ reportPath: string | undefined;
69
+ /**
70
+ * The Markdown review, present exactly when `--stdout md` was given and the
71
+ * run produced one. `output` keeps its meaning — the human render and every
72
+ * path line — and `main` decides which stream each goes to. See
73
+ * `test/cli.test.ts`, "--stdout md puts the Markdown on stdout and every
74
+ * other line on stderr".
75
+ */
76
+ markdown?: string;
77
+ }>;
78
+ /**
79
+ * Acts on `--open`. `openReport` ignores an absent path, which is right for it
80
+ * and wrong as the whole behaviour: a user who asked for the report to be
81
+ * opened and gets no window is owed the reason. There are two — the review
82
+ * failed hard enough that no report is written, and the write itself failed —
83
+ * and the output above states whichever applies, so this points at that rather
84
+ * than guessing which one it was.
85
+ *
86
+ * Separate from `main` because `main` reads `process.argv` and writes to the
87
+ * real stderr, so neither branch could be reached from a test through it. See
88
+ * `test/cli.test.ts`, "--open".
89
+ */
90
+ export declare function openOrExplain(reportPath: string | undefined, onMessage: (message: string) => void, open?: (path: string) => void): void;
91
+ /**
92
+ * Which stream carries which document. Extracted from `main` for the reason
93
+ * `openOrExplain` was: `main` reads `process.argv` and writes to the real
94
+ * process streams, so neither branch is reachable from a test through it.
95
+ * Under `--stdout md` the Markdown owns stdout alone and the human render —
96
+ * notes, path lines, tip — moves to stderr; otherwise nothing moves. An
97
+ * absent `markdown` empties stdout rather than falling back to `output`: a
98
+ * review body sitting in a pipe looks like a successful review to anyone who
99
+ * only checks whether one arrived. See `test/cli.test.ts`, "--stdout md puts
100
+ * the Markdown on stdout and every other line on stderr" and "empties stdout
101
+ * entirely when the run produced no Markdown".
102
+ */
103
+ export declare function streamsFor(result: {
104
+ output: string;
105
+ markdown?: string;
106
+ }, opts: CliOptions): {
107
+ stdout: string;
108
+ stderr: string;
109
+ };
110
+ export declare function main(): Promise<void>;