urtext 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/dist/analyze/blast-radius.d.ts +28 -0
- package/dist/analyze/blast-radius.js +163 -0
- package/dist/analyze/canonical.d.ts +27 -0
- package/dist/analyze/canonical.js +74 -0
- package/dist/analyze/citations.d.ts +256 -0
- package/dist/analyze/citations.js +945 -0
- package/dist/analyze/effects.d.ts +15 -0
- package/dist/analyze/effects.js +255 -0
- package/dist/analyze/fact.d.ts +42 -0
- package/dist/analyze/fact.js +46 -0
- package/dist/analyze/guards.d.ts +70 -0
- package/dist/analyze/guards.js +211 -0
- package/dist/analyze/index.d.ts +26 -0
- package/dist/analyze/index.js +52 -0
- package/dist/analyze/program.d.ts +15 -0
- package/dist/analyze/program.js +229 -0
- package/dist/analyze/surface.d.ts +48 -0
- package/dist/analyze/surface.js +396 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +12 -0
- package/dist/cli.d.ts +110 -0
- package/dist/cli.js +502 -0
- package/dist/extract/diff.d.ts +35 -0
- package/dist/extract/diff.js +116 -0
- package/dist/extract/git.d.ts +12 -0
- package/dist/extract/git.js +247 -0
- package/dist/extract/index.d.ts +4 -0
- package/dist/extract/index.js +57 -0
- package/dist/extract/intent.d.ts +64 -0
- package/dist/extract/intent.js +238 -0
- package/dist/extract/scope.d.ts +160 -0
- package/dist/extract/scope.js +284 -0
- package/dist/extract/symbols.d.ts +24 -0
- package/dist/extract/symbols.js +230 -0
- package/dist/interpret/client.d.ts +27 -0
- package/dist/interpret/client.js +80 -0
- package/dist/interpret/index.d.ts +41 -0
- package/dist/interpret/index.js +86 -0
- package/dist/interpret/prompt.d.ts +23 -0
- package/dist/interpret/prompt.js +128 -0
- package/dist/interpret/schema.d.ts +74 -0
- package/dist/interpret/schema.js +103 -0
- package/dist/report/conceal.d.ts +63 -0
- package/dist/report/conceal.js +129 -0
- package/dist/report/coverage.d.ts +43 -0
- package/dist/report/coverage.js +56 -0
- package/dist/report/html.d.ts +4 -0
- package/dist/report/html.js +634 -0
- package/dist/report/markdown.d.ts +2 -0
- package/dist/report/markdown.js +168 -0
- package/dist/report/model.d.ts +303 -0
- package/dist/report/model.js +289 -0
- package/dist/report/pdf.d.ts +2 -0
- package/dist/report/pdf.js +217 -0
- package/dist/report/terminal.d.ts +2 -0
- package/dist/report/terminal.js +206 -0
- package/dist/report/write.d.ts +105 -0
- package/dist/report/write.js +160 -0
- package/dist/score/index.d.ts +94 -0
- package/dist/score/index.js +572 -0
- package/dist/score/reach.d.ts +126 -0
- package/dist/score/reach.js +320 -0
- package/dist/score/reconcile.d.ts +52 -0
- package/dist/score/reconcile.js +208 -0
- package/dist/types.d.ts +221 -0
- package/dist/types.js +10 -0
- package/fonts/DejaVuSans-Bold.ttf +0 -0
- package/fonts/DejaVuSans-Oblique.ttf +0 -0
- package/fonts/DejaVuSans.ttf +0 -0
- package/fonts/DejaVuSansMono.ttf +0 -0
- package/fonts/LICENSE +187 -0
- package/package.json +44 -0
|
@@ -0,0 +1,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
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>;
|