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,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[]>;
|