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,15 @@
1
+ import type { Analyzer, EffectKind } from "../types.js";
2
+ export interface EffectSite {
3
+ kind: EffectKind;
4
+ line: number;
5
+ excerpt: string;
6
+ }
7
+ /** Effect sites in a file, in source order. Syntactic — no type checker. */
8
+ export declare function detectEffects(path: string, text: string): EffectSite[];
9
+ /**
10
+ * Reports effect kinds that appear in a file's after-state but not its
11
+ * before-state, and vice versa. A file that already made network calls and
12
+ * makes different ones now is not a finding; a file that never did and now
13
+ * does, is.
14
+ */
15
+ export declare const effectsAnalyzer: Analyzer;
@@ -0,0 +1,255 @@
1
+ import ts from "typescript";
2
+ import { isTypeScriptFile } from "../extract/symbols.js";
3
+ import { makeFact, MAX_EVIDENCE } from "./fact.js";
4
+ /**
5
+ * Detection below is syntactic and keyed on identifier names, plus a pass
6
+ * that resolves import bindings against `MODULE_EFFECTS` by specifier. The
7
+ * identifier tables alone remain scope-blind: a shadowing local (`const db =
8
+ * new Map()`) still produces a false-positive `database` effect, because
9
+ * that is a name coincidence no amount of import resolution can rule out.
10
+ * But imports from a module in `MODULE_EFFECTS` are now resolved by
11
+ * specifier regardless of the local name they bind to — aliased
12
+ * (`readFile as rf`), namespace (`* as fsp`), and default imports all
13
+ * resolve correctly. What is still unresolved: an import from a module not
14
+ * listed in `MODULE_EFFECTS`, and a binding introduced by re-export
15
+ * (`export { readFile } from ...`) or dynamic `import()` rather than a
16
+ * static import declaration.
17
+ *
18
+ * This binding lookup is itself name-based and scope-blind, the same way
19
+ * the identifier tables are: it is a single flat map consulted against
20
+ * every identifier in the file, with no tracking of scope or redeclaration.
21
+ * A local declaration that reuses an imported binding's name — a function
22
+ * parameter named `rf` in a file that also imports `readFile as rf` — is
23
+ * misattributed as a filesystem effect in exactly the same way `db` is
24
+ * misattributed above. Resolving imports by specifier closes the
25
+ * false-negative gap (an aliased import used to be invisible); it does not
26
+ * close the false-positive gap (a shadowing name is still indistinguishable
27
+ * from the binding it shadows).
28
+ */
29
+ /** Bare global calls that are effectful. */
30
+ const GLOBAL_CALLS = {
31
+ fetch: "network",
32
+ };
33
+ /** `object.member` patterns, matched on the object name. */
34
+ const OBJECT_EFFECTS = {
35
+ fs: "filesystem",
36
+ fsPromises: "filesystem",
37
+ axios: "network",
38
+ http: "network",
39
+ https: "network",
40
+ child_process: "process",
41
+ db: "database",
42
+ prisma: "database",
43
+ knex: "database",
44
+ pool: "database",
45
+ };
46
+ /** Fully-qualified `object.member` patterns that beat the object-name table. */
47
+ const QUALIFIED_EFFECTS = {
48
+ "process.env": "env",
49
+ "process.exit": "process",
50
+ "Date.now": "timing",
51
+ "Math.random": "timing",
52
+ };
53
+ function qualifiedName(node) {
54
+ const left = node.expression;
55
+ if (!ts.isIdentifier(left))
56
+ return null;
57
+ return `${left.text}.${node.name.text}`;
58
+ }
59
+ /**
60
+ * Module specifiers whose imports carry an effect. Matched after stripping a
61
+ * `node:` prefix, so "node:fs/promises" and "fs/promises" are one entry.
62
+ * This is specifier-based and purely syntactic: it needs no type checker,
63
+ * and it closes the aliased-import blind spot the identifier tables have.
64
+ *
65
+ * The lookup is exact-match after stripping `node:`, not prefix-match —
66
+ * `fs/promises` needs its own entry precisely because `fs` would not
67
+ * cover it.
68
+ */
69
+ const MODULE_EFFECTS = {
70
+ fs: "filesystem",
71
+ "fs/promises": "filesystem",
72
+ http: "network",
73
+ https: "network",
74
+ http2: "network",
75
+ net: "network",
76
+ dns: "network",
77
+ undici: "network",
78
+ axios: "network",
79
+ "node-fetch": "network",
80
+ child_process: "process",
81
+ cluster: "process",
82
+ worker_threads: "process",
83
+ pg: "database",
84
+ mysql: "database",
85
+ mysql2: "database",
86
+ sqlite3: "database",
87
+ "better-sqlite3": "database",
88
+ mongodb: "database",
89
+ ioredis: "database",
90
+ redis: "database",
91
+ };
92
+ function moduleEffect(specifier) {
93
+ const s = specifier.replace(/^node:/, "");
94
+ return MODULE_EFFECTS[s];
95
+ }
96
+ /** Local identifier → effect, from this file's import declarations. */
97
+ function importBindings(sf) {
98
+ const out = new Map();
99
+ for (const stmt of sf.statements) {
100
+ if (!ts.isImportDeclaration(stmt))
101
+ continue;
102
+ if (!ts.isStringLiteral(stmt.moduleSpecifier))
103
+ continue;
104
+ const effect = moduleEffect(stmt.moduleSpecifier.text);
105
+ if (!effect)
106
+ continue;
107
+ const clause = stmt.importClause;
108
+ if (!clause || clause.isTypeOnly)
109
+ continue;
110
+ // `import http from "node:http"`
111
+ if (clause.name)
112
+ out.set(clause.name.text, effect);
113
+ const bindings = clause.namedBindings;
114
+ if (!bindings)
115
+ continue;
116
+ if (ts.isNamespaceImport(bindings)) {
117
+ // `import * as fsp from "node:fs/promises"`
118
+ out.set(bindings.name.text, effect);
119
+ }
120
+ else {
121
+ // `import { readFile as rf } from "fs/promises"` — the local name is
122
+ // `bindings.elements[i].name`, which is what appears at call sites.
123
+ // `import { type readFile } from ...` is inert in valid code (a
124
+ // type-only binding cannot appear in value position), so skip it too.
125
+ for (const el of bindings.elements) {
126
+ if (el.isTypeOnly)
127
+ continue;
128
+ out.set(el.name.text, effect);
129
+ }
130
+ }
131
+ }
132
+ return out;
133
+ }
134
+ /** Effect sites in a file, in source order. Syntactic — no type checker. */
135
+ export function detectEffects(path, text) {
136
+ if (!isTypeScriptFile(path))
137
+ return [];
138
+ const sf = ts.createSourceFile(path, text, ts.ScriptTarget.ES2022, true, path.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
139
+ const lines = text.split("\n");
140
+ const sites = [];
141
+ const bindings = importBindings(sf);
142
+ const push = (node, kind) => {
143
+ const line = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
144
+ sites.push({ kind, line, excerpt: (lines[line - 1] ?? "").trim() });
145
+ };
146
+ const visit = (node) => {
147
+ if (ts.isPropertyAccessExpression(node)) {
148
+ const q = qualifiedName(node);
149
+ if (q && QUALIFIED_EFFECTS[q]) {
150
+ push(node, QUALIFIED_EFFECTS[q]);
151
+ }
152
+ else if (ts.isIdentifier(node.expression)) {
153
+ const bound = bindings.get(node.expression.text);
154
+ if (bound) {
155
+ push(node, bound);
156
+ }
157
+ else if (OBJECT_EFFECTS[node.expression.text]) {
158
+ push(node, OBJECT_EFFECTS[node.expression.text]);
159
+ }
160
+ }
161
+ }
162
+ else if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
163
+ const bound = bindings.get(node.expression.text);
164
+ if (bound) {
165
+ push(node, bound);
166
+ }
167
+ else if (GLOBAL_CALLS[node.expression.text]) {
168
+ push(node, GLOBAL_CALLS[node.expression.text]);
169
+ }
170
+ }
171
+ ts.forEachChild(node, visit);
172
+ };
173
+ ts.forEachChild(sf, visit);
174
+ return sites;
175
+ }
176
+ function kindsOf(sites) {
177
+ return new Set(sites.map((s) => s.kind));
178
+ }
179
+ function toEvidence(path, sites, kind, side) {
180
+ return sites
181
+ .filter((s) => s.kind === kind)
182
+ .slice(0, MAX_EVIDENCE)
183
+ .map((s) => ({ file: path, line: s.line, excerpt: s.excerpt, side }));
184
+ }
185
+ /**
186
+ * Builds one Fact for a single (path, kind) pair, or null if there is
187
+ * nothing to show. `path` is the file the sites and evidence both come
188
+ * from — the after-path for additions, the before-path for removals — and
189
+ * `side` says which revision those line numbers count in. `Fact.file`/
190
+ * `Fact.line` are derived from `evidence[0]` by `makeFact`, so they agree
191
+ * with the evidence even for a renamed file.
192
+ */
193
+ function buildFact(factKind, path, effectKind, sites) {
194
+ const side = factKind === "effect_removed" ? "before" : "after";
195
+ const matching = sites.filter((s) => s.kind === effectKind);
196
+ const evidence = toEvidence(path, matching, effectKind, side);
197
+ // A fact that cannot show its evidence must not ship. Unreachable today
198
+ // because `evidence` is derived from the same non-empty `matching` list,
199
+ // but returning null rather than calling makeFact (which throws on empty
200
+ // evidence) keeps a future refactor's mistake a silent no-op here rather
201
+ // than a crashed review.
202
+ if (evidence.length === 0)
203
+ return null;
204
+ return makeFact({
205
+ id: `${factKind}:${path}:${effectKind}`,
206
+ kind: factKind,
207
+ // True total, not the (possibly capped) evidence count — this number is
208
+ // shown to users, so it must not understate how many sites there are.
209
+ detail: { effect: effectKind, sites: matching.length },
210
+ evidence,
211
+ });
212
+ }
213
+ /**
214
+ * Reports effect kinds that appear in a file's after-state but not its
215
+ * before-state, and vice versa. A file that already made network calls and
216
+ * makes different ones now is not a finding; a file that never did and now
217
+ * does, is.
218
+ */
219
+ export const effectsAnalyzer = async (changeset, ctx) => {
220
+ const facts = [];
221
+ for (const file of changeset.files) {
222
+ if (!isTypeScriptFile(file.path))
223
+ continue;
224
+ const beforePath = file.previousPath ?? file.path;
225
+ const beforeText = file.status === "added" ? null : await ctx.readAt(ctx.range.from, beforePath);
226
+ const afterText = file.status === "deleted" ? null : await ctx.readAt(ctx.range.to, file.path);
227
+ // A file that still exists but whose after-state could not be read tells
228
+ // us nothing: the content is missing, not the effects. Treating that as
229
+ // "the effects are gone" is how a wrong range or a wrong working
230
+ // directory turns into a confident `verified` claim that a guard was
231
+ // removed. Say nothing instead — silence is recoverable, a false
232
+ // verified finding is not.
233
+ if (afterText === null && file.status !== "deleted")
234
+ continue;
235
+ const beforeSites = beforeText ? detectEffects(beforePath, beforeText) : [];
236
+ const afterSites = afterText ? detectEffects(file.path, afterText) : [];
237
+ const before = kindsOf(beforeSites);
238
+ const after = kindsOf(afterSites);
239
+ for (const kind of after) {
240
+ if (before.has(kind))
241
+ continue;
242
+ const fact = buildFact("effect_added", file.path, kind, afterSites);
243
+ if (fact)
244
+ facts.push(fact);
245
+ }
246
+ for (const kind of before) {
247
+ if (after.has(kind))
248
+ continue;
249
+ const fact = buildFact("effect_removed", beforePath, kind, beforeSites);
250
+ if (fact)
251
+ facts.push(fact);
252
+ }
253
+ }
254
+ return facts;
255
+ };
@@ -0,0 +1,42 @@
1
+ import type { EvidenceRef, Fact, FactKind } from "../types.js";
2
+ /**
3
+ * How many EvidenceRefs a fact carries at most. Only the excerpted evidence
4
+ * list is capped — counted quantities (`detail.sites`,
5
+ * `detail.references`) stay exact, so the prose never understates a change.
6
+ * Shared by the analyzers that sample evidence (effects, blast-radius) so
7
+ * the cap cannot drift between them; `test/comment-contract.test.ts`
8
+ * derives part of its forbidden set from it, so comments name it rather
9
+ * than restating its value.
10
+ */
11
+ export declare const MAX_EVIDENCE = 5;
12
+ /**
13
+ * Everything a Fact needs that is not derivable from its evidence. `file`
14
+ * and `line` are deliberately absent: they are not inputs.
15
+ */
16
+ export interface FactInput {
17
+ id: string;
18
+ kind: FactKind;
19
+ qualifiedSymbol?: string;
20
+ detail: Record<string, unknown>;
21
+ evidence: EvidenceRef[];
22
+ }
23
+ /**
24
+ * The only way an *emitted* Fact is built: every analyzer constructs its
25
+ * facts through this function. (`minPossibleAnalyzerScore` in
26
+ * `../score/index.ts` builds throwaway synthetic Facts directly, but those
27
+ * are scored and discarded, never emitted.)
28
+ *
29
+ * Two rules the spec treats as load-bearing — every fact carries evidence,
30
+ * and `Fact.file`/`Fact.line` name the same place as `evidence[0]` — were
31
+ * previously defended by convention and per-analyzer review. They were
32
+ * broken three times anyway, each time producing a `verified` finding that
33
+ * pointed somewhere the reader could not check. Deriving the location from
34
+ * the evidence instead of accepting it as a parameter makes the broken
35
+ * version unrepresentable: there is no argument to get wrong.
36
+ *
37
+ * Empty evidence throws rather than returning null. An analyzer that
38
+ * reaches this point with nothing to show has a bug in the caller, and a
39
+ * silent drop would hide it; analyzers that legitimately have nothing to
40
+ * report must not call this at all.
41
+ */
42
+ export declare function makeFact(input: FactInput): Fact;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * How many EvidenceRefs a fact carries at most. Only the excerpted evidence
3
+ * list is capped — counted quantities (`detail.sites`,
4
+ * `detail.references`) stay exact, so the prose never understates a change.
5
+ * Shared by the analyzers that sample evidence (effects, blast-radius) so
6
+ * the cap cannot drift between them; `test/comment-contract.test.ts`
7
+ * derives part of its forbidden set from it, so comments name it rather
8
+ * than restating its value.
9
+ */
10
+ export const MAX_EVIDENCE = 5;
11
+ /**
12
+ * The only way an *emitted* Fact is built: every analyzer constructs its
13
+ * facts through this function. (`minPossibleAnalyzerScore` in
14
+ * `../score/index.ts` builds throwaway synthetic Facts directly, but those
15
+ * are scored and discarded, never emitted.)
16
+ *
17
+ * Two rules the spec treats as load-bearing — every fact carries evidence,
18
+ * and `Fact.file`/`Fact.line` name the same place as `evidence[0]` — were
19
+ * previously defended by convention and per-analyzer review. They were
20
+ * broken three times anyway, each time producing a `verified` finding that
21
+ * pointed somewhere the reader could not check. Deriving the location from
22
+ * the evidence instead of accepting it as a parameter makes the broken
23
+ * version unrepresentable: there is no argument to get wrong.
24
+ *
25
+ * Empty evidence throws rather than returning null. An analyzer that
26
+ * reaches this point with nothing to show has a bug in the caller, and a
27
+ * silent drop would hide it; analyzers that legitimately have nothing to
28
+ * report must not call this at all.
29
+ */
30
+ export function makeFact(input) {
31
+ const anchor = input.evidence[0];
32
+ if (!anchor) {
33
+ throw new Error(`makeFact(${input.kind}, id=${input.id}): a fact must carry at least one EvidenceRef`);
34
+ }
35
+ return {
36
+ id: input.id,
37
+ kind: input.kind,
38
+ file: anchor.file,
39
+ line: anchor.line,
40
+ ...(input.qualifiedSymbol === undefined
41
+ ? {}
42
+ : { qualifiedSymbol: input.qualifiedSymbol }),
43
+ detail: input.detail,
44
+ evidence: input.evidence,
45
+ };
46
+ }
@@ -0,0 +1,70 @@
1
+ import type { Analyzer } from "../types.js";
2
+ export interface GuardSite {
3
+ /**
4
+ * Dotted path to the scope the guard runs in — `Worker.run`, `handlers.run`,
5
+ * `N.check`, or a class for a guard in its static initializer block — or
6
+ * `MODULE_OWNER` for top-level code. Built by pushing `framesFor`, which is
7
+ * also how `mapSymbols` builds `ChangedSymbol.qualifiedName`: the same
8
+ * declaration has to come out with the same path from both, because
9
+ * `foldReach` matches facts across analyzers on it. When it did not — this
10
+ * side framed no object literal and rooted no unnamed scope — a guard removed
11
+ * from `handlers.run` was reported against an untouched top-level `run`, and
12
+ * a guard genuinely removed from that export was cancelled out by one added
13
+ * to the method and never reported at all. `test/extract/scope.test.ts` asks
14
+ * both sides about the same declaration and compares.
15
+ *
16
+ * Also not the innermost name alone: the whole file's guards are matched
17
+ * before against after on this string, and two classes in one file may each
18
+ * declare `render`. See `Fact.qualifiedSymbol`, which this becomes.
19
+ */
20
+ qualifiedOwner: string;
21
+ /** Kind plus normalised condition text — the identity used for matching. */
22
+ signature: string;
23
+ line: number;
24
+ excerpt: string;
25
+ }
26
+ /**
27
+ * Guard-shaped constructs, attributed to the symbol containing them.
28
+ * Matching is by (qualifiedOwner, signature), so moving a guard within a
29
+ * function is not a removal; what makes a removal reportable is decided by
30
+ * `guardsAnalyzer` below, which requires the *count* of that guard kind in
31
+ * that symbol to have gone down.
32
+ */
33
+ export declare function collectGuards(path: string, text: string): GuardSite[];
34
+ /**
35
+ * Reports guards that a symbol has genuinely lost.
36
+ *
37
+ * Two conditions, both required. First, the exact guard — kind plus
38
+ * normalised condition text — must be unmatched on the after side, matched
39
+ * as a multiset so that deleting one of two identical guards still leaves
40
+ * one unmatched. Second, the *count* of that guard kind in that symbol must
41
+ * have gone down.
42
+ *
43
+ * The count condition is what separates a removal from an edit. Guard
44
+ * identity includes the condition text, so rewording a condition, renaming
45
+ * a variable it reads, or narrowing an `else if` all make the old signature
46
+ * absent — and on identity alone each presented as a pure removal, with no
47
+ * compensating "added" fact and the highest weight in the report. Three of
48
+ * three guard findings on this branch's own diff were exactly that. A
49
+ * symbol that still runs as many `if` guards as it did before has not lost
50
+ * one, whatever the conditions now say.
51
+ *
52
+ * The cost is a real removal that coincides with a new guard of the same kind
53
+ * under the same owner path: the counts balance and nothing is reported. "The
54
+ * same symbol" understated it — an owner path is not always one declaration.
55
+ * Two anonymous functions in one scope share `<anonymous>`, and two bindings in
56
+ * different unnamed scopes share a `<local>` root (see `byQualifiedName` in
57
+ * `../extract/symbols.ts`, which records the same trade from the other end), so
58
+ * a guard *moved* between two sibling callbacks in one function cancels out and
59
+ * this analyzer says nothing. Common enough to matter: this codebase's own
60
+ * `runAnalyzers` and `parseClaims` each hold more than one guard under a
61
+ * single `<anonymous>` path.
62
+ *
63
+ * That is still the intended trade — silence is recoverable, a confident wrong
64
+ * `verified` finding is not — but it is paid more often than "the same symbol"
65
+ * suggests.
66
+ *
67
+ * A symbol that disappeared entirely is not reported either — its deletion
68
+ * is the finding, and the guards analyzer would only add noise.
69
+ */
70
+ export declare const guardsAnalyzer: Analyzer;
@@ -0,0 +1,211 @@
1
+ import ts from "typescript";
2
+ import { framesFor, MODULE_OWNER, qualifyOwner } from "../extract/scope.js";
3
+ import { isTypeScriptFile } from "../extract/symbols.js";
4
+ import { makeFact } from "./fact.js";
5
+ function normalise(text) {
6
+ return text.replace(/\s+/g, " ").trim();
7
+ }
8
+ /**
9
+ * Guard-shaped constructs, attributed to the symbol containing them.
10
+ * Matching is by (qualifiedOwner, signature), so moving a guard within a
11
+ * function is not a removal; what makes a removal reportable is decided by
12
+ * `guardsAnalyzer` below, which requires the *count* of that guard kind in
13
+ * that symbol to have gone down.
14
+ */
15
+ export function collectGuards(path, text) {
16
+ if (!isTypeScriptFile(path))
17
+ return [];
18
+ const sf = ts.createSourceFile(path, text, ts.ScriptTarget.ES2022, true, path.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
19
+ const lines = text.split("\n");
20
+ const out = [];
21
+ const owner = [];
22
+ const push = (node, signature) => {
23
+ const line = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
24
+ out.push({
25
+ qualifiedOwner: qualifyOwner(owner),
26
+ signature,
27
+ line,
28
+ excerpt: (lines[line - 1] ?? "").trim(),
29
+ });
30
+ };
31
+ const visit = (node) => {
32
+ const pushed = framesFor(node);
33
+ owner.push(...pushed);
34
+ if (ts.isIfStatement(node)) {
35
+ push(node, `if:${normalise(node.expression.getText(sf))}`);
36
+ }
37
+ else if (ts.isThrowStatement(node)) {
38
+ push(node, `throw:${normalise(node.expression.getText(sf))}`);
39
+ }
40
+ else if (ts.isReturnStatement(node) &&
41
+ node.parent &&
42
+ ts.isBlock(node.parent) &&
43
+ node.parent.parent &&
44
+ ts.isIfStatement(node.parent.parent)) {
45
+ // An early return inside a conditional — the classic guard clause.
46
+ push(node, `return:${normalise(node.getText(sf))}`);
47
+ }
48
+ ts.forEachChild(node, visit);
49
+ owner.length -= pushed.length;
50
+ };
51
+ ts.forEachChild(sf, visit);
52
+ return out;
53
+ }
54
+ /** Exact identity: this guard, with this condition text, in this symbol. */
55
+ function key(g) {
56
+ return `${g.qualifiedOwner}|${g.signature}`;
57
+ }
58
+ /** Coarse identity: an if / throw / return guard in this symbol, any text. */
59
+ function kindKey(g) {
60
+ return `${g.qualifiedOwner}|${g.signature.split(":")[0]}`;
61
+ }
62
+ function countBy(guards, of) {
63
+ const counts = new Map();
64
+ for (const g of guards)
65
+ counts.set(of(g), (counts.get(of(g)) ?? 0) + 1);
66
+ return counts;
67
+ }
68
+ /**
69
+ * Reports guards that a symbol has genuinely lost.
70
+ *
71
+ * Two conditions, both required. First, the exact guard — kind plus
72
+ * normalised condition text — must be unmatched on the after side, matched
73
+ * as a multiset so that deleting one of two identical guards still leaves
74
+ * one unmatched. Second, the *count* of that guard kind in that symbol must
75
+ * have gone down.
76
+ *
77
+ * The count condition is what separates a removal from an edit. Guard
78
+ * identity includes the condition text, so rewording a condition, renaming
79
+ * a variable it reads, or narrowing an `else if` all make the old signature
80
+ * absent — and on identity alone each presented as a pure removal, with no
81
+ * compensating "added" fact and the highest weight in the report. Three of
82
+ * three guard findings on this branch's own diff were exactly that. A
83
+ * symbol that still runs as many `if` guards as it did before has not lost
84
+ * one, whatever the conditions now say.
85
+ *
86
+ * The cost is a real removal that coincides with a new guard of the same kind
87
+ * under the same owner path: the counts balance and nothing is reported. "The
88
+ * same symbol" understated it — an owner path is not always one declaration.
89
+ * Two anonymous functions in one scope share `<anonymous>`, and two bindings in
90
+ * different unnamed scopes share a `<local>` root (see `byQualifiedName` in
91
+ * `../extract/symbols.ts`, which records the same trade from the other end), so
92
+ * a guard *moved* between two sibling callbacks in one function cancels out and
93
+ * this analyzer says nothing. Common enough to matter: this codebase's own
94
+ * `runAnalyzers` and `parseClaims` each hold more than one guard under a
95
+ * single `<anonymous>` path.
96
+ *
97
+ * That is still the intended trade — silence is recoverable, a confident wrong
98
+ * `verified` finding is not — but it is paid more often than "the same symbol"
99
+ * suggests.
100
+ *
101
+ * A symbol that disappeared entirely is not reported either — its deletion
102
+ * is the finding, and the guards analyzer would only add noise.
103
+ */
104
+ export const guardsAnalyzer = async (changeset, ctx) => {
105
+ const facts = [];
106
+ for (const file of changeset.files) {
107
+ if (!isTypeScriptFile(file.path))
108
+ continue;
109
+ if (file.status === "added" || file.status === "deleted")
110
+ continue;
111
+ const beforePath = file.previousPath ?? file.path;
112
+ const beforeText = await ctx.readAt(ctx.range.from, beforePath);
113
+ const afterText = await ctx.readAt(ctx.range.to, file.path);
114
+ // An unreadable side is an error, never evidence that a guard vanished.
115
+ if (beforeText === null || afterText === null)
116
+ continue;
117
+ const before = collectGuards(beforePath, beforeText);
118
+ const after = collectGuards(file.path, afterText);
119
+ // Consumed as the before-side guards are matched against it, so two
120
+ // identical before-guards cannot both be "matched" by a single
121
+ // surviving one.
122
+ const unmatched = countBy(after, key);
123
+ // How many removals of each (symbol, kind) the counts can justify.
124
+ const beforeKinds = countBy(before, kindKey);
125
+ const afterKinds = countBy(after, kindKey);
126
+ const budget = new Map();
127
+ for (const [k, n] of beforeKinds) {
128
+ budget.set(k, n - (afterKinds.get(k) ?? 0));
129
+ }
130
+ const survivingSymbols = new Set(after.map((g) => g.qualifiedOwner));
131
+ // Symbols with no guards at all after the change still count as
132
+ // surviving if they still exist in the file. This must recognise every
133
+ // owner path `frameNameOf` can ever attribute a guard to (including arrow
134
+ // functions and function expressions bound to a name), qualified exactly
135
+ // as `collectGuards` qualifies it — otherwise a symbol whose *last* guard
136
+ // was just removed looks, from here, like a symbol that was deleted
137
+ // outright, and the removal is wrongly suppressed as "vanished symbol"
138
+ // noise instead of reported.
139
+ for (const s of collectDeclaredOwners(file.path, afterText)) {
140
+ survivingSymbols.add(s);
141
+ }
142
+ // "<module>" is not a declaration that can be deleted — it stands for
143
+ // top-level code, which survives as long as the file itself does (both
144
+ // sides having been readable is already established above). Without
145
+ // this, a removed top-level guard would be wrongly treated as belonging
146
+ // to a vanished symbol and silently dropped.
147
+ survivingSymbols.add(MODULE_OWNER);
148
+ for (const g of before) {
149
+ // Matched against a surviving guard with the same condition text:
150
+ // consume it and move on. Multiset, not set — see `unmatched`.
151
+ const survivor = unmatched.get(key(g)) ?? 0;
152
+ if (survivor > 0) {
153
+ unmatched.set(key(g), survivor - 1);
154
+ continue;
155
+ }
156
+ if (!survivingSymbols.has(g.qualifiedOwner))
157
+ continue;
158
+ // Unmatched text, but the symbol runs as many guards of this kind as
159
+ // it did before: this is an edited condition, not a removed guard.
160
+ const left = budget.get(kindKey(g)) ?? 0;
161
+ if (left <= 0)
162
+ continue;
163
+ budget.set(kindKey(g), left - 1);
164
+ const evidence = [
165
+ // beforePath and the before-side line: the guard existed in the
166
+ // before-side content, and that line number counts in the before
167
+ // revision — `side` says so, so the renderer does not send a reader
168
+ // to whatever occupies that line now. Fact.file/Fact.line follow
169
+ // from this ref via makeFact, so they agree even for a renamed file.
170
+ { file: beforePath, line: g.line, excerpt: g.excerpt, side: "before" },
171
+ ];
172
+ facts.push(makeFact({
173
+ // The line is part of the id: with two identical guards in one
174
+ // symbol and only one deleted, symbol+signature alone would not
175
+ // distinguish the fact from a second one.
176
+ id: `guard_removed:${beforePath}:${g.line}:${g.qualifiedOwner}:${g.signature}`,
177
+ kind: "guard_removed",
178
+ qualifiedSymbol: g.qualifiedOwner,
179
+ detail: { guard: g.signature.split(":")[0], symbol: g.qualifiedOwner },
180
+ evidence,
181
+ }));
182
+ }
183
+ }
184
+ return facts;
185
+ };
186
+ /**
187
+ * Every owner path `frameNameOf` could attribute a guard to in this file — the
188
+ * same predicate *and the same owner stack* `collectGuards` uses. The stack
189
+ * is not optional here even though survival is a question about presence
190
+ * rather than nesting: these strings are compared against `GuardSite`'s
191
+ * qualified owners, so `Worker.run` present in the file must not read as
192
+ * `run` absent from it.
193
+ */
194
+ function collectDeclaredOwners(path, text) {
195
+ if (!isTypeScriptFile(path))
196
+ return [];
197
+ const sf = ts.createSourceFile(path, text, ts.ScriptTarget.ES2022, true, path.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
198
+ const owners = [];
199
+ const stack = [];
200
+ const visit = (node) => {
201
+ const pushed = framesFor(node);
202
+ if (pushed.length > 0) {
203
+ stack.push(...pushed);
204
+ owners.push(qualifyOwner(stack));
205
+ }
206
+ ts.forEachChild(node, visit);
207
+ stack.length -= pushed.length;
208
+ };
209
+ ts.forEachChild(sf, visit);
210
+ return owners;
211
+ }
@@ -0,0 +1,26 @@
1
+ import type { AnalysisContext, Analyzer, Changeset, Fact } from "../types.js";
2
+ export { detectEffects, effectsAnalyzer } from "./effects.js";
3
+ export { collectGuards, guardsAnalyzer } from "./guards.js";
4
+ export { exportedSignatures, surfaceAnalyzer } from "./surface.js";
5
+ export { countReferences, blastRadiusAnalyzer } from "./blast-radius.js";
6
+ export { citationsAnalyzer, makeCitationsAnalyzer } from "./citations.js";
7
+ export { createProgramAt, listTypeScriptFilesAt } from "./program.js";
8
+ export { makeFact } from "./fact.js";
9
+ export declare const ANALYZERS: Analyzer[];
10
+ /** One analyzer that threw, named so the user knows what is missing. */
11
+ export interface AnalyzerFailure {
12
+ analyzer: string;
13
+ message: string;
14
+ }
15
+ /**
16
+ * Runs every analyzer and returns the facts from the ones that succeeded.
17
+ *
18
+ * `Promise.allSettled`, not `Promise.all`: one compiler-API or git failure
19
+ * used to discard every analyzer's facts and exit non-zero, so a single
20
+ * unreadable revision turned a review with three real findings into no
21
+ * review at all. A degraded review beats no review — but only if the
22
+ * degradation is visible, which is what `onFailure` is for. Callers that
23
+ * pass no handler get the facts and no indication anything was lost, so
24
+ * anything user-facing should pass one.
25
+ */
26
+ export declare function runAnalyzers(changeset: Changeset, ctx: AnalysisContext, analyzers?: Analyzer[], onFailure?: (failure: AnalyzerFailure) => void): Promise<Fact[]>;