supercov 0.0.42 → 0.0.43
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/README.md +23 -3
- package/analyzers/typescript/README.md +55 -0
- package/analyzers/typescript/bin/compiler-identity.mjs +78 -0
- package/analyzers/typescript/bin/identity.mjs +67 -0
- package/analyzers/typescript/bin/query.mjs +29 -0
- package/analyzers/typescript/dist/analyze.js +3972 -0
- package/analyzers/typescript/dist/archive.js +309 -0
- package/analyzers/typescript/dist/build-identity.json +1 -0
- package/analyzers/typescript/dist/compiler.js +32 -0
- package/analyzers/typescript/dist/frontend.js +75 -0
- package/analyzers/typescript/dist/native-frontend.js +271 -0
- package/analyzers/typescript/dist/pragmas.js +143 -0
- package/analyzers/typescript/dist/types.js +1 -0
- package/analyzers/typescript/package.json +27 -0
- package/analyzers/typescript/src/analyze.ts +4538 -0
- package/analyzers/typescript/src/archive.ts +438 -0
- package/analyzers/typescript/src/compiler.ts +49 -0
- package/analyzers/typescript/src/frontend.ts +136 -0
- package/analyzers/typescript/src/native-frontend.ts +315 -0
- package/analyzers/typescript/src/pragmas.ts +218 -0
- package/analyzers/typescript/src/types.ts +45 -0
- package/analyzers/typescript/tsconfig.json +12 -0
- package/docs/agent-loop.md +13 -0
- package/docs/assertion-evidence.md +135 -0
- package/docs/cli.md +10 -0
- package/docs/code-verification.md +182 -0
- package/docs/supported-suites.md +40 -9
- package/docs/verification.md +12 -0
- package/package.json +33 -15
- package/runtime/javascript/jest.cjs +134 -0
- package/runtime/javascript/jest.config.mjs +39 -0
- package/runtime/javascript/jestReporter.mjs +77 -0
- package/runtime/javascript/register.mjs +22 -4
- package/runtime/javascript/runtime.mjs +51 -13
|
@@ -0,0 +1,3972 @@
|
|
|
1
|
+
import { relative as pathRelative } from "node:path";
|
|
2
|
+
import { analysisPath } from "./compiler.js";
|
|
3
|
+
import { createFrontend } from "./frontend.js";
|
|
4
|
+
import { assertionWitnessIssue, collectPragmas } from "./pragmas.js";
|
|
5
|
+
// Archive/site/test identities use forward slashes on every host.
|
|
6
|
+
const relative = (from, to) => pathRelative(from, to).replaceAll("\\", "/");
|
|
7
|
+
export function analyze(options) {
|
|
8
|
+
if (!options ||
|
|
9
|
+
typeof options.projectRoot !== "string" ||
|
|
10
|
+
!options.evidenceFiles)
|
|
11
|
+
throw new Error("projectRoot and in-memory evidenceFiles are required");
|
|
12
|
+
const frontend = createFrontend(options.projectRoot, options.typescript);
|
|
13
|
+
try {
|
|
14
|
+
return analyzeWithFrontend(options, frontend);
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
frontend.close();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Internal archive entry: one compiler session, closed by the archive caller. */
|
|
21
|
+
export function analyzeWithFrontend(options, frontend) {
|
|
22
|
+
const root = analysisPath(options.projectRoot);
|
|
23
|
+
const ts = frontend.syntax;
|
|
24
|
+
const srcDir = (options.sourceDir ?? "src").replace(/\/$/, "");
|
|
25
|
+
const testDir = (options.testDir ?? "tests").replace(/\/$/, "");
|
|
26
|
+
const evidence = options.evidenceFiles;
|
|
27
|
+
const readEvidence = (p) => {
|
|
28
|
+
const value = evidence[p];
|
|
29
|
+
if (value === undefined)
|
|
30
|
+
throw new Error(`Missing archive evidence input: ${p}`);
|
|
31
|
+
return value;
|
|
32
|
+
};
|
|
33
|
+
const hasEvidence = (p) => Object.hasOwn(evidence, p);
|
|
34
|
+
const inventory = JSON.parse(readEvidence("inventory.json"));
|
|
35
|
+
if (!Array.isArray(inventory.sites))
|
|
36
|
+
throw new Error("inventory.json must contain a sites array");
|
|
37
|
+
const sites = inventory.sites;
|
|
38
|
+
const RANK = { presence: 1, value: 2, total: 3 };
|
|
39
|
+
const stronger = (a, b) => (!a ? b : !b ? a : RANK[a] >= RANK[b] ? a : b);
|
|
40
|
+
const effectSites = sites.filter((s) => s.kind === "effect");
|
|
41
|
+
const siteById = new Map(sites.map((s) => [s.id, s]));
|
|
42
|
+
const runtimeTests = JSON.parse(readEvidence("cov/index.json")).filter((t) => t.ok);
|
|
43
|
+
const coverage = new Map();
|
|
44
|
+
for (const t of runtimeTests) {
|
|
45
|
+
const perFile = new Map();
|
|
46
|
+
let current;
|
|
47
|
+
let currentFile = "";
|
|
48
|
+
for (const line of readEvidence(`cov/${t.id}.lcov`).split("\n")) {
|
|
49
|
+
if (line.startsWith("SF:")) {
|
|
50
|
+
const f = line.slice(3).trim();
|
|
51
|
+
currentFile = f.startsWith("/") ? relative(root, f) : f;
|
|
52
|
+
current = new Set();
|
|
53
|
+
perFile.set(currentFile, current);
|
|
54
|
+
}
|
|
55
|
+
else if (line.startsWith("DA:") && current) {
|
|
56
|
+
const [ln, hits] = line.slice(3).split(",").map(Number);
|
|
57
|
+
if (hits <= 0)
|
|
58
|
+
continue;
|
|
59
|
+
current.add(ln);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
coverage.set(t.id, perFile);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Optional per-test condition outcomes: cov/<test>.outcomes.json maps "<file>:<line>:<column>" of a
|
|
66
|
+
* condition atom to [timesTrue, timesFalse] within that test. supercov's MC/DC runtime provides this;
|
|
67
|
+
* without the file, branch outcomes are inferred from line coverage as before.
|
|
68
|
+
*/
|
|
69
|
+
const outcomes = new Map();
|
|
70
|
+
for (const t of runtimeTests) {
|
|
71
|
+
const p = `cov/${t.id}.outcomes.json`;
|
|
72
|
+
if (!hasEvidence(p))
|
|
73
|
+
continue;
|
|
74
|
+
const raw = JSON.parse(readEvidence(p));
|
|
75
|
+
outcomes.set(t.id, new Map(Object.entries(raw)));
|
|
76
|
+
}
|
|
77
|
+
/** supercov input: statement/function points rather than executed lines (see covers()). */
|
|
78
|
+
const statementGranular = outcomes.size > 0;
|
|
79
|
+
const runtimePhases = new Map();
|
|
80
|
+
const runtimeStatements = new Map();
|
|
81
|
+
for (const t of runtimeTests) {
|
|
82
|
+
const p = `cov/${t.id}.phases.json`;
|
|
83
|
+
if (hasEvidence(p))
|
|
84
|
+
runtimePhases.set(t.id, JSON.parse(readEvidence(p)));
|
|
85
|
+
const sp = `cov/${t.id}.statements.json`;
|
|
86
|
+
if (hasEvidence(sp)) {
|
|
87
|
+
const parsed = JSON.parse(readEvidence(sp));
|
|
88
|
+
if (Object.keys(parsed).length)
|
|
89
|
+
runtimeStatements.set(t.id, parsed);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Outcome key of a decision atom: "<file>:<line>:<column>#<index>", where the position is the start
|
|
94
|
+
* of the whole condition expression of the if/ternary/loop and the index is the atom's place among
|
|
95
|
+
* the expression's leaf conditions in source order (supercov's `conditions[]` order). Value-position
|
|
96
|
+
* logical expressions have no decision outcome record and yield undefined.
|
|
97
|
+
*/
|
|
98
|
+
const outcomeKeyCache = new Map();
|
|
99
|
+
function outcomeKeyOf(s) {
|
|
100
|
+
const cached = outcomeKeyCache.get(s.id);
|
|
101
|
+
if (cached !== undefined)
|
|
102
|
+
return cached ?? undefined;
|
|
103
|
+
const compute = () => {
|
|
104
|
+
const atom = siteNodes.get(s.id);
|
|
105
|
+
if (!atom)
|
|
106
|
+
return undefined;
|
|
107
|
+
const sf = atom.getSourceFile();
|
|
108
|
+
const isLogicalBinary = (x) => ts.isBinaryExpression(x) &&
|
|
109
|
+
[
|
|
110
|
+
ts.SyntaxKind.AmpersandAmpersandToken,
|
|
111
|
+
ts.SyntaxKind.BarBarToken,
|
|
112
|
+
ts.SyntaxKind.QuestionQuestionToken,
|
|
113
|
+
].includes(x.operatorToken.kind);
|
|
114
|
+
let top = atom;
|
|
115
|
+
while (top.parent &&
|
|
116
|
+
(isLogicalBinary(top.parent) ||
|
|
117
|
+
ts.isParenthesizedExpression(top.parent)))
|
|
118
|
+
top = top.parent;
|
|
119
|
+
const ctx = top.parent;
|
|
120
|
+
const isCondition = ((ts.isIfStatement(ctx) ||
|
|
121
|
+
ts.isWhileStatement(ctx) ||
|
|
122
|
+
ts.isDoStatement(ctx)) &&
|
|
123
|
+
ctx.expression === top) ||
|
|
124
|
+
(ts.isConditionalExpression(ctx) && ctx.condition === top) ||
|
|
125
|
+
(ts.isForStatement(ctx) && ctx.condition === top);
|
|
126
|
+
if (!isCondition) {
|
|
127
|
+
// value-position `a && b` / `a || b` / `a ?? b`: supercov records per logical-value branch which
|
|
128
|
+
// side was selected and whether the result was truthy, from which the converter derives both
|
|
129
|
+
// operands' outcomes. The branch is the innermost logical expression whose direct operand is the
|
|
130
|
+
// atom; the key carries its start and end so nested chains (`a && b && c`) stay distinct.
|
|
131
|
+
let b = atom.parent;
|
|
132
|
+
while (b && ts.isParenthesizedExpression(b))
|
|
133
|
+
b = b.parent;
|
|
134
|
+
if (!b || !isLogicalBinary(b))
|
|
135
|
+
return undefined;
|
|
136
|
+
const same = (x) => unwrap(x).getStart(sf) === atom.getStart(sf) &&
|
|
137
|
+
unwrap(x).getEnd() === atom.getEnd();
|
|
138
|
+
const index = same(b.left) ? 0 : same(b.right) ? 1 : -1;
|
|
139
|
+
if (index < 0)
|
|
140
|
+
return undefined;
|
|
141
|
+
const start = sf.getLineAndCharacterOfPosition(b.getStart(sf));
|
|
142
|
+
const end = sf.getLineAndCharacterOfPosition(b.getEnd());
|
|
143
|
+
return `${s.file}:${start.line + 1}:${start.character + 1}~${end.line + 1}:${end.character + 1}#${index}|2`;
|
|
144
|
+
}
|
|
145
|
+
const leaves = [];
|
|
146
|
+
const collect = (x) => {
|
|
147
|
+
if (ts.isParenthesizedExpression(x))
|
|
148
|
+
return collect(x.expression);
|
|
149
|
+
if (isLogicalBinary(x)) {
|
|
150
|
+
collect(x.left);
|
|
151
|
+
collect(x.right);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
leaves.push(x);
|
|
155
|
+
};
|
|
156
|
+
collect(top);
|
|
157
|
+
const index = leaves.findIndex((l) => l.getStart(sf) === atom.getStart(sf) && l.getEnd() === atom.getEnd());
|
|
158
|
+
if (index < 0)
|
|
159
|
+
return undefined;
|
|
160
|
+
const { line, character } = sf.getLineAndCharacterOfPosition(top.getStart(sf));
|
|
161
|
+
return `${s.file}:${line + 1}:${character + 1}#${index}|${leaves.length}`;
|
|
162
|
+
};
|
|
163
|
+
const key = compute();
|
|
164
|
+
outcomeKeyCache.set(s.id, key ?? null);
|
|
165
|
+
return key;
|
|
166
|
+
}
|
|
167
|
+
/** Tests in which the condition at `s` took `outcome`; undefined when no test carries outcome data for it. */
|
|
168
|
+
/**
|
|
169
|
+
* Value-position operand (`a || b`, `a && b`, `a ?? b`): the tests in which this operand's value was the
|
|
170
|
+
* value of the whole expression. A mutation of the operand's value is observable only there, which is
|
|
171
|
+
* the question for a value-position atom (the MC/DC stuck-outcome question is the one for conditions).
|
|
172
|
+
*/
|
|
173
|
+
function testsWhereSelected(s) {
|
|
174
|
+
const full = outcomeKeyOf(s);
|
|
175
|
+
if (!full || !full.includes("~"))
|
|
176
|
+
return undefined;
|
|
177
|
+
const [key] = full.split("|");
|
|
178
|
+
const selKey = key.replace(/#(\d+)$/, "#s$1");
|
|
179
|
+
let any = false;
|
|
180
|
+
const res = new Set();
|
|
181
|
+
for (const [tid, m] of outcomes) {
|
|
182
|
+
const o = m.get(selKey);
|
|
183
|
+
if (!o)
|
|
184
|
+
continue;
|
|
185
|
+
any = true;
|
|
186
|
+
if (o[0] > 0)
|
|
187
|
+
res.add(tid);
|
|
188
|
+
}
|
|
189
|
+
return any ? res : undefined;
|
|
190
|
+
}
|
|
191
|
+
function testsWithOutcome(s, outcome) {
|
|
192
|
+
const full = outcomeKeyOf(s);
|
|
193
|
+
if (!full)
|
|
194
|
+
return undefined;
|
|
195
|
+
const [key, leafCount] = full.split("|");
|
|
196
|
+
const decisionKey = key.slice(0, key.lastIndexOf("#"));
|
|
197
|
+
let any = false;
|
|
198
|
+
const res = new Set();
|
|
199
|
+
for (const [tid, m] of outcomes) {
|
|
200
|
+
const o = m.get(key);
|
|
201
|
+
if (!o)
|
|
202
|
+
continue;
|
|
203
|
+
// the atom split must agree with supercov's condition list, else the index means nothing
|
|
204
|
+
let n = 0;
|
|
205
|
+
while (m.has(`${decisionKey}#${n}`))
|
|
206
|
+
n++;
|
|
207
|
+
if (n !== Number(leafCount))
|
|
208
|
+
return undefined;
|
|
209
|
+
any = true;
|
|
210
|
+
if ((outcome ? o[0] : o[1]) > 0)
|
|
211
|
+
res.add(tid);
|
|
212
|
+
}
|
|
213
|
+
return any ? res : undefined;
|
|
214
|
+
}
|
|
215
|
+
/** Module-setup coverage (imports, module-level statements) of the test's file: counts for module-level sites only. */
|
|
216
|
+
const setupCoverage = new Map();
|
|
217
|
+
for (const t of runtimeTests) {
|
|
218
|
+
const p = `cov/${t.id}.setup.lcov`;
|
|
219
|
+
if (!hasEvidence(p))
|
|
220
|
+
continue;
|
|
221
|
+
const perFile = new Map();
|
|
222
|
+
let current;
|
|
223
|
+
for (const line of readEvidence(p).split("\n")) {
|
|
224
|
+
if (line.startsWith("SF:")) {
|
|
225
|
+
current = new Set();
|
|
226
|
+
perFile.set(line.slice(3).trim(), current);
|
|
227
|
+
}
|
|
228
|
+
else if (line.startsWith("DA:") && current)
|
|
229
|
+
current.add(Number(line.slice(3).split(",")[0]));
|
|
230
|
+
}
|
|
231
|
+
setupCoverage.set(t.id, perFile);
|
|
232
|
+
}
|
|
233
|
+
function covers(testId, s) {
|
|
234
|
+
const lines = coverage.get(testId)?.get(s.file);
|
|
235
|
+
if (s.owner === "<module>") {
|
|
236
|
+
const setup = setupCoverage.get(testId)?.get(s.file);
|
|
237
|
+
if (setup)
|
|
238
|
+
for (let l = s.start.line; l <= s.end.line; l++)
|
|
239
|
+
if (setup.has(l))
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
if (!lines)
|
|
243
|
+
return false;
|
|
244
|
+
for (let l = s.start.line; l <= s.end.line; l++)
|
|
245
|
+
if (lines.has(l))
|
|
246
|
+
return true;
|
|
247
|
+
// Statement-granular coverage (supercov marks a statement's first line only): an expression site
|
|
248
|
+
// inside a multi-line statement is covered when its own statement is. A site that is itself a
|
|
249
|
+
// statement (return, throw, expression statement) has its own line marked, so no fallback; the climb
|
|
250
|
+
// stops at the first statement and never crosses a block or a function boundary, so a branch that
|
|
251
|
+
// did not run is not credited with its parent `if`. Line-granular data (v8 lcov) marks every line of
|
|
252
|
+
// an executed statement and the definition line of every function, so the fallback is wrong there.
|
|
253
|
+
if (!statementGranular)
|
|
254
|
+
return false;
|
|
255
|
+
const node = siteNodes.get(s.id);
|
|
256
|
+
if (!node || ts.isStatement(node) || ts.isBlock(node))
|
|
257
|
+
return false;
|
|
258
|
+
const sf = node.getSourceFile();
|
|
259
|
+
const lineOf = (x) => sf.getLineAndCharacterOfPosition(x.getStart(sf)).line + 1;
|
|
260
|
+
let n = node.parent;
|
|
261
|
+
while (n && !ts.isStatement(n) && !ts.isSourceFile(n)) {
|
|
262
|
+
// an expression-bodied arrow (`x => ({ label })`): its entry is a function point on the arrow's line
|
|
263
|
+
if (ts.isFunctionLike(n))
|
|
264
|
+
return lines.has(lineOf(n));
|
|
265
|
+
if (ts.isBlock(n))
|
|
266
|
+
return false;
|
|
267
|
+
n = n.parent;
|
|
268
|
+
}
|
|
269
|
+
if (!n || ts.isSourceFile(n) || ts.isBlock(n))
|
|
270
|
+
return false;
|
|
271
|
+
return lines.has(lineOf(n));
|
|
272
|
+
}
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// Program over src + tests
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
const program = frontend.openProgram(options);
|
|
277
|
+
const checker = program.checker;
|
|
278
|
+
const allFiles = program.files.filter((f) => !f.isDeclarationFile && !f.fileName.includes("/node_modules/"));
|
|
279
|
+
const rel = (sf) => relative(root, sf.fileName);
|
|
280
|
+
const isProdFile = (sf) => options.sourceFiles
|
|
281
|
+
? options.sourceFiles.includes(rel(sf))
|
|
282
|
+
: rel(sf).startsWith(srcDir + "/");
|
|
283
|
+
const isTestFile = (sf) => options.testFiles
|
|
284
|
+
? options.testFiles.includes(rel(sf))
|
|
285
|
+
: rel(sf).startsWith(testDir + "/") &&
|
|
286
|
+
/\.(test|spec)\.(ts|tsx|mts)$/.test(sf.fileName);
|
|
287
|
+
const srcByFile = new Map(allFiles.filter(isProdFile).map((sf) => [rel(sf), sf]));
|
|
288
|
+
const isExternalDecl = (d) => d.getSourceFile().fileName.includes("/node_modules/") ||
|
|
289
|
+
d.getSourceFile().isDeclarationFile;
|
|
290
|
+
function unwrap(e) {
|
|
291
|
+
let cur = e;
|
|
292
|
+
for (;;) {
|
|
293
|
+
if (ts.isParenthesizedExpression(cur) ||
|
|
294
|
+
ts.isAwaitExpression(cur) ||
|
|
295
|
+
ts.isNonNullExpression(cur) ||
|
|
296
|
+
ts.isAsExpression(cur) ||
|
|
297
|
+
ts.isTypeAssertionExpression(cur)) {
|
|
298
|
+
cur = cur.expression;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
return cur;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function symbolOf(id) {
|
|
305
|
+
// `{ logger }` shorthand: the name resolves to the property; we want the value it refers to
|
|
306
|
+
let s = ts.isIdentifier(id) &&
|
|
307
|
+
ts.isShorthandPropertyAssignment(id.parent) &&
|
|
308
|
+
id.parent.name === id
|
|
309
|
+
? checker.getShorthandAssignmentValueSymbol(id.parent)
|
|
310
|
+
: checker.getSymbolAtLocation(id);
|
|
311
|
+
if (s && s.flags & ts.SymbolFlags.Alias) {
|
|
312
|
+
try {
|
|
313
|
+
s = checker.getAliasedSymbol(s);
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
/* unresolvable */
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return s;
|
|
320
|
+
}
|
|
321
|
+
function declOf(id) {
|
|
322
|
+
const s = symbolOf(id);
|
|
323
|
+
return s?.valueDeclaration ?? s?.declarations?.[0];
|
|
324
|
+
}
|
|
325
|
+
function enclosingFunction(node) {
|
|
326
|
+
let n = node.parent;
|
|
327
|
+
while (n) {
|
|
328
|
+
if (ts.isFunctionLike(n))
|
|
329
|
+
return n;
|
|
330
|
+
n = n.parent;
|
|
331
|
+
}
|
|
332
|
+
return undefined;
|
|
333
|
+
}
|
|
334
|
+
function nameOfFunction(fn) {
|
|
335
|
+
if (!fn)
|
|
336
|
+
return undefined;
|
|
337
|
+
if (ts.isFunctionDeclaration(fn) && fn.name)
|
|
338
|
+
return fn.name.text;
|
|
339
|
+
if ((ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) &&
|
|
340
|
+
ts.isVariableDeclaration(fn.parent) &&
|
|
341
|
+
ts.isIdentifier(fn.parent.name))
|
|
342
|
+
return fn.parent.name.text;
|
|
343
|
+
if (ts.isClassDeclaration(fn) && fn.name)
|
|
344
|
+
return fn.name.text;
|
|
345
|
+
if (ts.isMethodDeclaration(fn) &&
|
|
346
|
+
ts.isIdentifier(fn.name) &&
|
|
347
|
+
ts.isClassDeclaration(fn.parent) &&
|
|
348
|
+
fn.parent.name)
|
|
349
|
+
return `${fn.parent.name.text}.${fn.name.text}`;
|
|
350
|
+
if (ts.isConstructorDeclaration(fn) &&
|
|
351
|
+
ts.isClassDeclaration(fn.parent) &&
|
|
352
|
+
fn.parent.name)
|
|
353
|
+
return `${fn.parent.name.text}.constructor`;
|
|
354
|
+
return undefined;
|
|
355
|
+
}
|
|
356
|
+
/** Nearest named function (skipping anonymous callbacks), matching inventory's `owner`. */
|
|
357
|
+
function ownerOf(node) {
|
|
358
|
+
let fn = enclosingFunction(node);
|
|
359
|
+
while (fn && !nameOfFunction(fn))
|
|
360
|
+
fn = enclosingFunction(fn);
|
|
361
|
+
return fn;
|
|
362
|
+
}
|
|
363
|
+
function siteNode(s) {
|
|
364
|
+
const sf = srcByFile.get(s.file);
|
|
365
|
+
if (!sf)
|
|
366
|
+
return undefined;
|
|
367
|
+
let found;
|
|
368
|
+
const visit = (n) => {
|
|
369
|
+
// prefer the deepest node with this exact range (a statement and its call expression share it)
|
|
370
|
+
if (n.getStart(sf) === s.pos && n.getEnd() === s.endPos)
|
|
371
|
+
found = n;
|
|
372
|
+
if (n.getStart(sf) <= s.pos && n.getEnd() >= s.endPos)
|
|
373
|
+
ts.forEachChild(n, visit);
|
|
374
|
+
};
|
|
375
|
+
visit(sf);
|
|
376
|
+
return found;
|
|
377
|
+
}
|
|
378
|
+
const siteNodes = new Map();
|
|
379
|
+
for (const s of sites) {
|
|
380
|
+
const n = siteNode(s);
|
|
381
|
+
if (n)
|
|
382
|
+
siteNodes.set(s.id, n);
|
|
383
|
+
}
|
|
384
|
+
function smallestSiteContaining(file, start, end, onlyEffects = true) {
|
|
385
|
+
return (onlyEffects ? effectSites : sites)
|
|
386
|
+
.filter((s) => s.file === file && s.pos <= start && s.endPos >= end)
|
|
387
|
+
.sort((a, b) => a.endPos - a.pos - (b.endPos - b.pos))[0];
|
|
388
|
+
}
|
|
389
|
+
/** Does a log site's message template (constant parts in order, placeholders as wildcards) fit an asserted literal? */
|
|
390
|
+
function templateMatches(s, literal) {
|
|
391
|
+
const raw = (s.arg0 ?? "").replace(/^[`'"]|[`'"]$/g, "");
|
|
392
|
+
const parts = raw
|
|
393
|
+
.split(/\$\{[^}]*\}/)
|
|
394
|
+
.map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
395
|
+
try {
|
|
396
|
+
return new RegExp(parts.join(".*")).test(literal);
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/** An observation pins a log site when its pattern, full literal and/or fragment fit the site's message. */
|
|
403
|
+
function messageFits(ob, s) {
|
|
404
|
+
if (ob.pattern && !patternMatchesSite(ob.pattern, s))
|
|
405
|
+
return false;
|
|
406
|
+
if (ob.literal && !templateMatches(s, ob.literal))
|
|
407
|
+
return false;
|
|
408
|
+
// a fragment may be a prefix/substring of the template's constant text, or a whole message with values filled in
|
|
409
|
+
if (ob.fragment &&
|
|
410
|
+
!patternMatchesSite(ob.fragment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), s) &&
|
|
411
|
+
!templateMatches(s, ob.fragment))
|
|
412
|
+
return false;
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
const LOG_TEMPLATE_CACHE = new Map();
|
|
416
|
+
/** Constant text variants of a log call's first argument for pattern matching. */
|
|
417
|
+
function logTexts(s) {
|
|
418
|
+
const key = s.id;
|
|
419
|
+
let v = LOG_TEMPLATE_CACHE.get(key);
|
|
420
|
+
if (!v) {
|
|
421
|
+
const raw = (s.arg0 ?? "").replace(/^[`'"]|[`'"]$/g, "");
|
|
422
|
+
const variants = [
|
|
423
|
+
raw.replace(/\$\{[^}]*\}/g, ""),
|
|
424
|
+
raw.replace(/\$\{[^}]*\}/g, "0"),
|
|
425
|
+
raw.replace(/\$\{[^}]*\}/g, "x"),
|
|
426
|
+
];
|
|
427
|
+
// The logger implementation prefixes every line; a pattern may target the prefix.
|
|
428
|
+
v = [...variants, ...variants.map((t) => `[supergateway] ${t}`)];
|
|
429
|
+
LOG_TEMPLATE_CACHE.set(key, v);
|
|
430
|
+
}
|
|
431
|
+
return v;
|
|
432
|
+
}
|
|
433
|
+
function patternMatchesSite(pattern, s) {
|
|
434
|
+
let re;
|
|
435
|
+
try {
|
|
436
|
+
re = new RegExp(pattern);
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
return logTexts(s).some((t) => re.test(t));
|
|
442
|
+
}
|
|
443
|
+
/** Production side: which boundaries does an effect site emit into (directly). */
|
|
444
|
+
function directBoundaries(s) {
|
|
445
|
+
const chain = s.chain ?? [];
|
|
446
|
+
const m = s.method;
|
|
447
|
+
switch (s.category) {
|
|
448
|
+
case "io-call": {
|
|
449
|
+
if (m === "status" || m === "writeHead" || m === "sendStatus")
|
|
450
|
+
return [{ boundary: "client-status" }];
|
|
451
|
+
if (m === "setHeader")
|
|
452
|
+
return [
|
|
453
|
+
{
|
|
454
|
+
boundary: "client-header",
|
|
455
|
+
facet: /^['"`]/.test(s.arg0 ?? "")
|
|
456
|
+
? s.arg0.slice(1, -1).toLowerCase()
|
|
457
|
+
: "*",
|
|
458
|
+
},
|
|
459
|
+
];
|
|
460
|
+
if (m === "handleRequest")
|
|
461
|
+
return [
|
|
462
|
+
{ boundary: "client-status" },
|
|
463
|
+
{ boundary: "client-header", facet: "*" },
|
|
464
|
+
{ boundary: "client-message" },
|
|
465
|
+
];
|
|
466
|
+
if (m === "write" && chain.includes("stdin"))
|
|
467
|
+
return [{ boundary: "child-stdin" }];
|
|
468
|
+
if (m === "write" && chain.includes("stdout"))
|
|
469
|
+
return [{ boundary: "client-message" }, { boundary: "stdout" }];
|
|
470
|
+
if (m === "kill" || m === "spawn")
|
|
471
|
+
return [{ boundary: "child-lifecycle" }];
|
|
472
|
+
if (m === "exit")
|
|
473
|
+
return [{ boundary: "exit" }];
|
|
474
|
+
if (m === "request")
|
|
475
|
+
return [{ boundary: "upstream" }];
|
|
476
|
+
if (m === "json" || m === "send" || m === "end" || m === "write")
|
|
477
|
+
return [{ boundary: "client-message" }];
|
|
478
|
+
if (m === "close" || m === "terminate" || m === "destroy")
|
|
479
|
+
return [{ boundary: "client-lifecycle" }];
|
|
480
|
+
return [{ boundary: "lifecycle" }];
|
|
481
|
+
}
|
|
482
|
+
case "log":
|
|
483
|
+
return [
|
|
484
|
+
{ boundary: m === "error" ? "stderr" : "stdout", facet: "log" },
|
|
485
|
+
];
|
|
486
|
+
case "schedule":
|
|
487
|
+
return [{ boundary: "timing" }];
|
|
488
|
+
case "return":
|
|
489
|
+
case "callback-return":
|
|
490
|
+
return [{ boundary: "return:" + s.owner }];
|
|
491
|
+
case "throw":
|
|
492
|
+
return [{ boundary: "throw:" + s.owner }];
|
|
493
|
+
case "state-write": {
|
|
494
|
+
// globalThis.prisma = ...: a global a test can read back directly
|
|
495
|
+
const node = siteNodes.get(s.id);
|
|
496
|
+
const target = node && ts.isBinaryExpression(node)
|
|
497
|
+
? unwrap(node.left)
|
|
498
|
+
: node && ts.isDeleteExpression(node)
|
|
499
|
+
? unwrap(node.expression)
|
|
500
|
+
: undefined;
|
|
501
|
+
if (target &&
|
|
502
|
+
ts.isPropertyAccessExpression(target) &&
|
|
503
|
+
ts.isIdentifier(target.expression) &&
|
|
504
|
+
GLOBAL_ROOTS.has(target.expression.text))
|
|
505
|
+
return [{ boundary: "global:" + target.name.text }];
|
|
506
|
+
return [{ boundary: "internal" }];
|
|
507
|
+
}
|
|
508
|
+
case "external-call": {
|
|
509
|
+
// this.getSessionTable().upsert(...): a getter returning an injected field (`this.prisma[this.tableName]`)
|
|
510
|
+
const viaGetter = injectedFieldReceiver(s);
|
|
511
|
+
if (viaGetter)
|
|
512
|
+
return [viaGetter];
|
|
513
|
+
// prisma.article.count(...) on a parameter: boundary callback:prisma, facet article.count
|
|
514
|
+
if (s.note === "this-callback" || s.note === "param")
|
|
515
|
+
return [
|
|
516
|
+
{
|
|
517
|
+
boundary: "callback:" + (chain[0] === "this" ? chain[1] : chain[0]),
|
|
518
|
+
facet: chain.slice(chain[0] === "this" ? 2 : 1).join(".") || undefined,
|
|
519
|
+
},
|
|
520
|
+
];
|
|
521
|
+
return [{ boundary: "internal" }];
|
|
522
|
+
}
|
|
523
|
+
default:
|
|
524
|
+
return [{ boundary: "internal" }];
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* `this.getX().method(...)` where `getX()` returns `this.field`, `this.field.y` or `this.field[key]`: the call
|
|
529
|
+
* writes into whatever was injected as `field` (a constructor parameter of the same name, or a parameter
|
|
530
|
+
* property). Boundary callback:<field>, facet the member path with `*` for a computed key.
|
|
531
|
+
*/
|
|
532
|
+
function injectedFieldReceiver(s) {
|
|
533
|
+
const node = siteNodes.get(s.id);
|
|
534
|
+
if (!node || !ts.isCallExpression(node))
|
|
535
|
+
return undefined;
|
|
536
|
+
const callee = unwrap(node.expression);
|
|
537
|
+
if (!ts.isPropertyAccessExpression(callee))
|
|
538
|
+
return undefined;
|
|
539
|
+
const recv = unwrap(callee.expression);
|
|
540
|
+
if (!ts.isCallExpression(recv) || recv.arguments.length)
|
|
541
|
+
return undefined;
|
|
542
|
+
const getter = unwrap(recv.expression);
|
|
543
|
+
if (!ts.isPropertyAccessExpression(getter) ||
|
|
544
|
+
getter.expression.kind !== ts.SyntaxKind.ThisKeyword)
|
|
545
|
+
return undefined;
|
|
546
|
+
const m = checker.getSymbolAtLocation(getter.name)?.valueDeclaration;
|
|
547
|
+
if (!m || !ts.isMethodDeclaration(m) || !m.body)
|
|
548
|
+
return undefined;
|
|
549
|
+
let ret;
|
|
550
|
+
const visit = (n) => {
|
|
551
|
+
if (ret)
|
|
552
|
+
return;
|
|
553
|
+
if (ts.isReturnStatement(n) && n.expression)
|
|
554
|
+
ret = n.expression;
|
|
555
|
+
else if (!ts.isFunctionLike(n))
|
|
556
|
+
ts.forEachChild(n, visit);
|
|
557
|
+
};
|
|
558
|
+
visit(m.body);
|
|
559
|
+
if (!ret)
|
|
560
|
+
return undefined;
|
|
561
|
+
// peel `(this.prisma as any)[this.tableName]` down to the field and the member path
|
|
562
|
+
const facet = [];
|
|
563
|
+
let cur = unwrap(ret);
|
|
564
|
+
for (let i = 0; i < 6; i++) {
|
|
565
|
+
if (ts.isElementAccessExpression(cur)) {
|
|
566
|
+
facet.unshift("*");
|
|
567
|
+
cur = unwrap(cur.expression);
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
if (ts.isPropertyAccessExpression(cur) &&
|
|
571
|
+
cur.expression.kind !== ts.SyntaxKind.ThisKeyword) {
|
|
572
|
+
facet.unshift(cur.name.text);
|
|
573
|
+
cur = unwrap(cur.expression);
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
if (!ts.isPropertyAccessExpression(cur) ||
|
|
579
|
+
cur.expression.kind !== ts.SyntaxKind.ThisKeyword)
|
|
580
|
+
return undefined;
|
|
581
|
+
return {
|
|
582
|
+
boundary: "callback:" + cur.name.text,
|
|
583
|
+
facet: [...facet, callee.name.text].join("."),
|
|
584
|
+
via: `${getter.name.text}()`,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
/** Third-party calls whose effect we model rather than analyze. */
|
|
588
|
+
function externalSink(calleeName, path) {
|
|
589
|
+
if (calleeName === "cors")
|
|
590
|
+
return [
|
|
591
|
+
{ boundary: "client-header", facet: "access-control-allow-origin" },
|
|
592
|
+
{ boundary: "client-header", facet: "access-control-expose-headers" },
|
|
593
|
+
];
|
|
594
|
+
if (calleeName === "Server") {
|
|
595
|
+
if (path[0] === "version")
|
|
596
|
+
return [{ boundary: "client-message", facet: "serverInfo.version" }];
|
|
597
|
+
if (path[0] === "name")
|
|
598
|
+
return [{ boundary: "client-message", facet: "serverInfo.name" }];
|
|
599
|
+
}
|
|
600
|
+
return [];
|
|
601
|
+
}
|
|
602
|
+
const ITERATORS = new Set([
|
|
603
|
+
"forEach",
|
|
604
|
+
"map",
|
|
605
|
+
"filter",
|
|
606
|
+
"some",
|
|
607
|
+
"every",
|
|
608
|
+
"find",
|
|
609
|
+
"flatMap",
|
|
610
|
+
"reduce",
|
|
611
|
+
]);
|
|
612
|
+
const flowCache = new Map();
|
|
613
|
+
function fnKey(fn) {
|
|
614
|
+
return `${rel(fn.getSourceFile())}:${fn.getStart()}`;
|
|
615
|
+
}
|
|
616
|
+
/** Project function/class declaration for a callee identifier, if any. */
|
|
617
|
+
function projectCallee(callee) {
|
|
618
|
+
const c = unwrap(callee);
|
|
619
|
+
// this.sessionToRow(...) / Klass.helper(...): a method of a project class
|
|
620
|
+
if (ts.isPropertyAccessExpression(c)) {
|
|
621
|
+
const m = checker.getSymbolAtLocation(c.name)?.valueDeclaration;
|
|
622
|
+
return m && ts.isMethodDeclaration(m) && isProdFile(m.getSourceFile())
|
|
623
|
+
? m
|
|
624
|
+
: undefined;
|
|
625
|
+
}
|
|
626
|
+
if (!ts.isIdentifier(c))
|
|
627
|
+
return undefined;
|
|
628
|
+
const d = declOf(c);
|
|
629
|
+
if (!d || !isProdFile(d.getSourceFile()))
|
|
630
|
+
return undefined;
|
|
631
|
+
if (ts.isFunctionDeclaration(d) || ts.isClassDeclaration(d))
|
|
632
|
+
return d;
|
|
633
|
+
if (ts.isVariableDeclaration(d) &&
|
|
634
|
+
d.initializer &&
|
|
635
|
+
(ts.isArrowFunction(unwrap(d.initializer)) ||
|
|
636
|
+
ts.isFunctionExpression(unwrap(d.initializer))))
|
|
637
|
+
return unwrap(d.initializer);
|
|
638
|
+
return undefined;
|
|
639
|
+
}
|
|
640
|
+
function paramsOf(fn) {
|
|
641
|
+
if (ts.isClassDeclaration(fn)) {
|
|
642
|
+
const ctor = fn.members.find(ts.isConstructorDeclaration);
|
|
643
|
+
return ctor?.parameters ?? [];
|
|
644
|
+
}
|
|
645
|
+
return fn.parameters;
|
|
646
|
+
}
|
|
647
|
+
function bodyOf(fn) {
|
|
648
|
+
return fn;
|
|
649
|
+
}
|
|
650
|
+
/** Resolve (function, param index, object path) to the binding declaration that receives the value. */
|
|
651
|
+
function bindingFor(fn, index, path) {
|
|
652
|
+
const p = paramsOf(fn)[index];
|
|
653
|
+
if (!p)
|
|
654
|
+
return undefined;
|
|
655
|
+
if (!path.length)
|
|
656
|
+
return p;
|
|
657
|
+
if (ts.isObjectBindingPattern(p.name)) {
|
|
658
|
+
const el = p.name.elements.find((e) => (e.propertyName ?? e.name).getText() === path[0]);
|
|
659
|
+
return el ?? p;
|
|
660
|
+
}
|
|
661
|
+
if (ts.isIdentifier(p.name)) {
|
|
662
|
+
// const { x } = param inside the body
|
|
663
|
+
let found;
|
|
664
|
+
const visit = (n) => {
|
|
665
|
+
if (found)
|
|
666
|
+
return;
|
|
667
|
+
if (ts.isVariableDeclaration(n) &&
|
|
668
|
+
n.initializer &&
|
|
669
|
+
ts.isIdentifier(unwrap(n.initializer)) &&
|
|
670
|
+
declOf(unwrap(n.initializer)) === p &&
|
|
671
|
+
ts.isObjectBindingPattern(n.name)) {
|
|
672
|
+
found = n.name.elements.find((e) => (e.propertyName ?? e.name).getText() === path[0]);
|
|
673
|
+
}
|
|
674
|
+
ts.forEachChild(n, visit);
|
|
675
|
+
};
|
|
676
|
+
visit(bodyOf(fn));
|
|
677
|
+
return found ?? p;
|
|
678
|
+
}
|
|
679
|
+
return p;
|
|
680
|
+
}
|
|
681
|
+
/** Follow a value node to boundaries and effect sites. */
|
|
682
|
+
function propagateValue(value, acc, visited, depth) {
|
|
683
|
+
if (depth > 12)
|
|
684
|
+
return;
|
|
685
|
+
const sf = value.getSourceFile();
|
|
686
|
+
const file = rel(sf);
|
|
687
|
+
const key = `${file}:${value.getStart(sf)}:${value.getEnd()}`;
|
|
688
|
+
if (visited.has(key))
|
|
689
|
+
return;
|
|
690
|
+
visited.add(key);
|
|
691
|
+
// is the value inside an effect site? then that site carries it
|
|
692
|
+
const site = smallestSiteContaining(file, value.getStart(sf), value.getEnd());
|
|
693
|
+
if (site &&
|
|
694
|
+
site.category !== "return" &&
|
|
695
|
+
site.category !== "callback-return") {
|
|
696
|
+
acc.sites.add(site.id);
|
|
697
|
+
// keep climbing too: the site's own value may flow further (e.g. `res.status(x).json(y)` is fine as a site)
|
|
698
|
+
}
|
|
699
|
+
// climb to find how the value is consumed
|
|
700
|
+
let path = [];
|
|
701
|
+
let n = value;
|
|
702
|
+
while (n.parent) {
|
|
703
|
+
const p = n.parent;
|
|
704
|
+
if (ts.isPropertyAssignment(p) && p.initializer === n) {
|
|
705
|
+
path = [p.name.getText(), ...path];
|
|
706
|
+
n = p;
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
if (ts.isShorthandPropertyAssignment(p)) {
|
|
710
|
+
path = [p.name.getText(), ...path];
|
|
711
|
+
n = p;
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
if (ts.isObjectLiteralExpression(p) ||
|
|
715
|
+
ts.isParenthesizedExpression(p) ||
|
|
716
|
+
ts.isAwaitExpression(p) ||
|
|
717
|
+
ts.isNonNullExpression(p) ||
|
|
718
|
+
ts.isAsExpression(p) ||
|
|
719
|
+
ts.isSpreadAssignment(p) ||
|
|
720
|
+
ts.isConditionalExpression(p) ||
|
|
721
|
+
ts.isTemplateSpan(p) ||
|
|
722
|
+
ts.isTemplateExpression(p) ||
|
|
723
|
+
(ts.isBinaryExpression(p) &&
|
|
724
|
+
!(p.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
|
|
725
|
+
p.operatorToken.kind <= ts.SyntaxKind.LastAssignment))) {
|
|
726
|
+
n = p;
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
if (ts.isPropertyAccessExpression(p) && p.expression === n) {
|
|
730
|
+
n = p;
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
if (ts.isElementAccessExpression(p) && p.expression === n) {
|
|
734
|
+
n = p;
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
if (ts.isVariableDeclaration(p) && p.initializer === n) {
|
|
738
|
+
if (ts.isIdentifier(p.name))
|
|
739
|
+
propagateBinding(p, acc, visited, depth + 1);
|
|
740
|
+
else if (ts.isObjectBindingPattern(p.name))
|
|
741
|
+
for (const el of p.name.elements)
|
|
742
|
+
propagateBinding(el, acc, visited, depth + 1);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
if (ts.isBinaryExpression(p) &&
|
|
746
|
+
p.right === n &&
|
|
747
|
+
p.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
748
|
+
ts.isIdentifier(p.left)) {
|
|
749
|
+
const d = declOf(p.left);
|
|
750
|
+
if (d)
|
|
751
|
+
propagateBinding(d, acc, visited, depth + 1);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
// sessionParams.accountOwner = value: the value becomes part of a local object, which flows on wherever the
|
|
755
|
+
// object does (returned, handed to a constructor or factory, ...). Writes into `this`/parameters are sites.
|
|
756
|
+
if (ts.isBinaryExpression(p) &&
|
|
757
|
+
p.right === n &&
|
|
758
|
+
p.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
759
|
+
(ts.isPropertyAccessExpression(p.left) ||
|
|
760
|
+
ts.isElementAccessExpression(p.left))) {
|
|
761
|
+
const root = rootOfExpr(p.left);
|
|
762
|
+
const d = ts.isIdentifier(root) ? declOf(root) : undefined;
|
|
763
|
+
if (d && ts.isVariableDeclaration(d) && enclosingFunction(d))
|
|
764
|
+
propagateBinding(d, acc, visited, depth + 1);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
if (ts.isReturnStatement(p) || (ts.isArrowFunction(p) && p.body === n)) {
|
|
768
|
+
// the return site itself carries the value: a test that reads the function's return observes it
|
|
769
|
+
// (this is what makes a branch's assignment evident through `expect(fn()).toEqual(...)`)
|
|
770
|
+
const target = ts.isArrowFunction(p) ? n : p;
|
|
771
|
+
const rs = smallestSiteContaining(file, target.getStart(sf), target.getEnd());
|
|
772
|
+
if (rs &&
|
|
773
|
+
(rs.category === "return" || rs.category === "callback-return"))
|
|
774
|
+
acc.sites.add(rs.id);
|
|
775
|
+
// value becomes the return of the enclosing function -> flows to that function's callers
|
|
776
|
+
const fn = ts.isArrowFunction(p) ? p : enclosingFunction(p);
|
|
777
|
+
if (fn) {
|
|
778
|
+
// callback return (map/forEach callback): value flows to the call's result
|
|
779
|
+
if (ts.isCallExpression(fn.parent) &&
|
|
780
|
+
fn.parent.arguments.includes(fn) &&
|
|
781
|
+
ts.isPropertyAccessExpression(fn.parent.expression) &&
|
|
782
|
+
ITERATORS.has(fn.parent.expression.name.text)) {
|
|
783
|
+
propagateValue(fn.parent, acc, visited, depth + 1);
|
|
784
|
+
}
|
|
785
|
+
else {
|
|
786
|
+
const name = nameOfFunction(fn);
|
|
787
|
+
if (name)
|
|
788
|
+
mergeFlow(acc, flowFromReturn(fn, depth + 1), `return of ${name}`);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (ts.isSpreadElement(p)) {
|
|
794
|
+
n = p;
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
if ((ts.isCallExpression(p) || ts.isNewExpression(p)) &&
|
|
798
|
+
p.arguments?.includes(n)) {
|
|
799
|
+
// pass-through builtins: the call's result still carries the value
|
|
800
|
+
const calleeText = p.expression.getText();
|
|
801
|
+
if (/^(Object\.(entries|keys|values|assign|fromEntries)|Array\.from|JSON\.(stringify|parse)|String|Number|Boolean|structuredClone)$/.test(calleeText)) {
|
|
802
|
+
n = p;
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
// the call itself may be an effect site (console.log(...value...)): it carries the value
|
|
806
|
+
const callSite = effectSites.find((e) => e.file === file &&
|
|
807
|
+
e.pos === p.getStart(sf) &&
|
|
808
|
+
e.endPos === p.getEnd());
|
|
809
|
+
if (callSite)
|
|
810
|
+
acc.sites.add(callSite.id);
|
|
811
|
+
const index = p.arguments.indexOf(n);
|
|
812
|
+
const target = projectCallee(p.expression);
|
|
813
|
+
if (target) {
|
|
814
|
+
const b = bindingFor(target, index, path);
|
|
815
|
+
if (b)
|
|
816
|
+
propagateBinding(b, acc, visited, depth + 1);
|
|
817
|
+
}
|
|
818
|
+
else {
|
|
819
|
+
const c = unwrap(p.expression);
|
|
820
|
+
const calleeName = ts.isIdentifier(c)
|
|
821
|
+
? c.text
|
|
822
|
+
: ts.isPropertyAccessExpression(c)
|
|
823
|
+
? c.name.text
|
|
824
|
+
: "";
|
|
825
|
+
for (const b of externalSink(calleeName, path))
|
|
826
|
+
acc.boundaries.push({ ...b, via: `${calleeName}(...)` });
|
|
827
|
+
// items.push(value) on a local array: the value becomes part of the array
|
|
828
|
+
if (ts.isPropertyAccessExpression(c) &&
|
|
829
|
+
MUTATORS.has(c.name.text) &&
|
|
830
|
+
ts.isCallExpression(p)) {
|
|
831
|
+
const root = rootOfExpr(c.expression);
|
|
832
|
+
const d = ts.isIdentifier(root) ? declOf(root) : undefined;
|
|
833
|
+
if (d && ts.isVariableDeclaration(d) && enclosingFunction(d)) {
|
|
834
|
+
propagateBinding(d, acc, visited, depth + 1);
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
// a library computation (`last(xs)`, `Session.fromPropertyArray(entries)`, `new Map(entries)`): its
|
|
839
|
+
// result derives from its arguments, so the value flows on through the call. Not for calls a test
|
|
840
|
+
// controls: sinks injected as parameters or fields, installed globals, mocked modules, I/O, timers, logs.
|
|
841
|
+
if (carriesArguments(p, callSite)) {
|
|
842
|
+
path = [];
|
|
843
|
+
n = p;
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (ts.isCallExpression(p) && p.expression === n) {
|
|
850
|
+
// value is called: e.g. formatArgs(args) -> result flows onward
|
|
851
|
+
n = p;
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
if (ts.isCallExpression(p) &&
|
|
855
|
+
ts.isPropertyAccessExpression(p.expression) &&
|
|
856
|
+
p.expression.expression === n) {
|
|
857
|
+
// value is the receiver of a method call: iterators pass elements to callbacks; other methods yield derived values
|
|
858
|
+
if (ITERATORS.has(p.expression.name.text)) {
|
|
859
|
+
for (const arg of p.arguments)
|
|
860
|
+
if (ts.isArrowFunction(arg) || ts.isFunctionExpression(arg))
|
|
861
|
+
for (const prm of arg.parameters)
|
|
862
|
+
propagateBinding(prm, acc, visited, depth + 1);
|
|
863
|
+
if (p.expression.name.text !== "forEach") {
|
|
864
|
+
n = p;
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
n = p;
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
// `!value`, `typeof value`: still the value, seen through an operator
|
|
873
|
+
if ((ts.isPrefixUnaryExpression(p) &&
|
|
874
|
+
p.operator === ts.SyntaxKind.ExclamationToken) ||
|
|
875
|
+
ts.isTypeOfExpression(p)) {
|
|
876
|
+
n = p;
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
// the value is a condition: it selects which exit (return/throw) the enclosing function takes, so a test
|
|
880
|
+
// that reads the function's result observes the value's truthiness (`if (!prompt) return null`,
|
|
881
|
+
// `if (a && await blogExists(...)) return x`)
|
|
882
|
+
if ((ts.isIfStatement(p) && p.expression === n) ||
|
|
883
|
+
((ts.isWhileStatement(p) || ts.isDoStatement(p)) &&
|
|
884
|
+
p.expression === n) ||
|
|
885
|
+
(ts.isForStatement(p) && p.condition === n) ||
|
|
886
|
+
(ts.isSwitchStatement(p) && p.expression === n)) {
|
|
887
|
+
const exits = exitSitesSelectedBy(p, sf);
|
|
888
|
+
for (const e of exits)
|
|
889
|
+
acc.sites.add(e.id);
|
|
890
|
+
if (exits.some((e) => e.category !== "throw")) {
|
|
891
|
+
const fn = enclosingFunction(p);
|
|
892
|
+
const name = nameOfFunction(fn);
|
|
893
|
+
if (fn && name)
|
|
894
|
+
mergeFlow(acc, flowFromReturn(fn, depth + 1), `selects the return of ${name}`);
|
|
895
|
+
}
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
if (ts.isExpressionStatement(p) ||
|
|
899
|
+
ts.isBlock(p) ||
|
|
900
|
+
ts.isIfStatement(p) ||
|
|
901
|
+
ts.isArrowFunction(p) ||
|
|
902
|
+
ts.isFunctionDeclaration(p) ||
|
|
903
|
+
ts.isSourceFile(p))
|
|
904
|
+
return;
|
|
905
|
+
n = p;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
/** The return/throw sites a condition chooses between: those in the branches it controls and, when the
|
|
909
|
+
* controlled branch leaves the block early, those in the rest of the block. */
|
|
910
|
+
function exitSitesSelectedBy(ctrl, sf) {
|
|
911
|
+
const ranges = [];
|
|
912
|
+
if (ts.isIfStatement(ctrl)) {
|
|
913
|
+
ranges.push([
|
|
914
|
+
ctrl.thenStatement.getStart(sf),
|
|
915
|
+
ctrl.thenStatement.getEnd(),
|
|
916
|
+
]);
|
|
917
|
+
if (ctrl.elseStatement)
|
|
918
|
+
ranges.push([
|
|
919
|
+
ctrl.elseStatement.getStart(sf),
|
|
920
|
+
ctrl.elseStatement.getEnd(),
|
|
921
|
+
]);
|
|
922
|
+
else if (terminates(ctrl.thenStatement) && ts.isBlock(ctrl.parent))
|
|
923
|
+
ranges.push([ctrl.getEnd(), ctrl.parent.getEnd()]);
|
|
924
|
+
}
|
|
925
|
+
else if (ts.isWhileStatement(ctrl) ||
|
|
926
|
+
ts.isDoStatement(ctrl) ||
|
|
927
|
+
ts.isForStatement(ctrl)) {
|
|
928
|
+
ranges.push([ctrl.statement.getStart(sf), ctrl.statement.getEnd()]);
|
|
929
|
+
if (ts.isBlock(ctrl.parent))
|
|
930
|
+
ranges.push([ctrl.getEnd(), ctrl.parent.getEnd()]);
|
|
931
|
+
}
|
|
932
|
+
else if (ts.isSwitchStatement(ctrl)) {
|
|
933
|
+
ranges.push([ctrl.caseBlock.getStart(sf), ctrl.caseBlock.getEnd()]);
|
|
934
|
+
}
|
|
935
|
+
const file = rel(sf);
|
|
936
|
+
return effectSites.filter((e) => e.file === file &&
|
|
937
|
+
(e.category === "return" ||
|
|
938
|
+
e.category === "throw" ||
|
|
939
|
+
e.category === "callback-return") &&
|
|
940
|
+
ranges.some(([a, b]) => e.pos >= a && e.endPos <= b));
|
|
941
|
+
}
|
|
942
|
+
/** Does the result of this non-project call derive from its arguments (a library computation such as
|
|
943
|
+
* `last(xs)` or `Session.fromPropertyArray(entries)`) rather than from something the test controls (a sink
|
|
944
|
+
* injected as a parameter or field, an installed global, a mocked module, I/O, a timer, a log)? */
|
|
945
|
+
function carriesArguments(call, site) {
|
|
946
|
+
if (site && !(site.category === "external-call" && site.note === "import"))
|
|
947
|
+
return false;
|
|
948
|
+
const root = rootOfExpr(call.expression);
|
|
949
|
+
if (!ts.isIdentifier(root))
|
|
950
|
+
return false;
|
|
951
|
+
const d = declOf(root);
|
|
952
|
+
// a parameter, or a global a test file installed: test-controlled
|
|
953
|
+
if (d && (ts.isParameter(d) || ts.isBindingElement(d)))
|
|
954
|
+
return false;
|
|
955
|
+
if (!site) {
|
|
956
|
+
if (d && !d.getSourceFile().isDeclarationFile)
|
|
957
|
+
return true;
|
|
958
|
+
for (const sinks of globalSinksByFile.values())
|
|
959
|
+
if (sinks.has(root.text))
|
|
960
|
+
return false;
|
|
961
|
+
return true;
|
|
962
|
+
}
|
|
963
|
+
const imp = importOf(root);
|
|
964
|
+
return !!imp && !mockedAnywhere(imp);
|
|
965
|
+
}
|
|
966
|
+
/** Is this import replaced by `vi.mock` in some test file? Its results are then test-controlled. */
|
|
967
|
+
function mockedAnywhere(imp) {
|
|
968
|
+
for (const mocks of moduleMocksByFile.values())
|
|
969
|
+
for (const m of mocks)
|
|
970
|
+
if ((m.resolved && imp.resolved && m.resolved === imp.resolved) ||
|
|
971
|
+
m.spec === imp.spec)
|
|
972
|
+
return true;
|
|
973
|
+
return false;
|
|
974
|
+
}
|
|
975
|
+
function propagateBinding(decl, acc, visited, depth) {
|
|
976
|
+
if (depth > 12)
|
|
977
|
+
return;
|
|
978
|
+
const sf = decl.getSourceFile();
|
|
979
|
+
const key = `B:${rel(sf)}:${decl.getStart(sf)}`;
|
|
980
|
+
if (visited.has(key))
|
|
981
|
+
return;
|
|
982
|
+
visited.add(key);
|
|
983
|
+
// parameter declared with a binding pattern: follow each element
|
|
984
|
+
if (ts.isParameter(decl) && ts.isObjectBindingPattern(decl.name)) {
|
|
985
|
+
for (const el of decl.name.elements)
|
|
986
|
+
propagateBinding(el, acc, visited, depth);
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
const sym = symbolOf(decl.name ?? decl);
|
|
990
|
+
if (!sym)
|
|
991
|
+
return;
|
|
992
|
+
const scope = enclosingFunction(decl) ?? sf;
|
|
993
|
+
const visit = (n) => {
|
|
994
|
+
if (ts.isIdentifier(n) &&
|
|
995
|
+
n !== decl.name &&
|
|
996
|
+
symbolOf(n) === sym)
|
|
997
|
+
propagateValue(n, acc, visited, depth + 1);
|
|
998
|
+
ts.forEachChild(n, visit);
|
|
999
|
+
};
|
|
1000
|
+
visit(scope);
|
|
1001
|
+
}
|
|
1002
|
+
function mergeFlow(acc, other, via) {
|
|
1003
|
+
for (const b of other.boundaries)
|
|
1004
|
+
acc.boundaries.push({ ...b, via: b.via ? `${via} → ${b.via}` : via });
|
|
1005
|
+
for (const s of other.sites)
|
|
1006
|
+
acc.sites.add(s);
|
|
1007
|
+
}
|
|
1008
|
+
/** Boundaries and sites reached by the return value of a project function. */
|
|
1009
|
+
function flowFromReturn(fn, depth = 0) {
|
|
1010
|
+
// depth cut-off must not poison the cache with an empty result
|
|
1011
|
+
if (depth > 8)
|
|
1012
|
+
return { boundaries: [], sites: new Set() };
|
|
1013
|
+
const key = fnKey(fn);
|
|
1014
|
+
const cached = flowCache.get(key);
|
|
1015
|
+
if (cached)
|
|
1016
|
+
return cached;
|
|
1017
|
+
const acc = { boundaries: [], sites: new Set() };
|
|
1018
|
+
flowCache.set(key, acc); // cycle guard
|
|
1019
|
+
const visited = new Set();
|
|
1020
|
+
for (const sf of allFiles) {
|
|
1021
|
+
if (!isProdFile(sf))
|
|
1022
|
+
continue;
|
|
1023
|
+
const visit = (n) => {
|
|
1024
|
+
if ((ts.isCallExpression(n) || ts.isNewExpression(n)) &&
|
|
1025
|
+
projectCallee(n.expression) === fn)
|
|
1026
|
+
propagateValue(n, acc, visited, depth + 1);
|
|
1027
|
+
// higher-order use: the function is passed as a value (e.g. `log({ formatArgs: debugFormatArgs })`)
|
|
1028
|
+
// and called through a parameter later; its return flows out of those calls
|
|
1029
|
+
else if (ts.isIdentifier(n) &&
|
|
1030
|
+
!(ts.isCallExpression(n.parent) && n.parent.expression === n) &&
|
|
1031
|
+
!ts.isPropertyAccessExpression(n.parent) &&
|
|
1032
|
+
functionOfIdentifier(n) === fn) {
|
|
1033
|
+
for (const call of callsThroughParameter(n))
|
|
1034
|
+
propagateValue(call, acc, visited, depth + 1);
|
|
1035
|
+
}
|
|
1036
|
+
ts.forEachChild(n, visit);
|
|
1037
|
+
};
|
|
1038
|
+
visit(sf);
|
|
1039
|
+
}
|
|
1040
|
+
return acc;
|
|
1041
|
+
}
|
|
1042
|
+
function functionOfIdentifier(id) {
|
|
1043
|
+
const d = declOf(id);
|
|
1044
|
+
if (!d)
|
|
1045
|
+
return undefined;
|
|
1046
|
+
if (ts.isFunctionDeclaration(d))
|
|
1047
|
+
return d;
|
|
1048
|
+
if (ts.isVariableDeclaration(d) &&
|
|
1049
|
+
d.initializer &&
|
|
1050
|
+
(ts.isArrowFunction(unwrap(d.initializer)) ||
|
|
1051
|
+
ts.isFunctionExpression(unwrap(d.initializer))))
|
|
1052
|
+
return unwrap(d.initializer);
|
|
1053
|
+
return undefined;
|
|
1054
|
+
}
|
|
1055
|
+
/** For a function passed as an argument, the calls made through the receiving parameter. */
|
|
1056
|
+
function callsThroughParameter(valueRef) {
|
|
1057
|
+
let path = [];
|
|
1058
|
+
let n = valueRef;
|
|
1059
|
+
while (n.parent &&
|
|
1060
|
+
(ts.isPropertyAssignment(n.parent) ||
|
|
1061
|
+
ts.isShorthandPropertyAssignment(n.parent) ||
|
|
1062
|
+
ts.isObjectLiteralExpression(n.parent) ||
|
|
1063
|
+
ts.isParenthesizedExpression(n.parent))) {
|
|
1064
|
+
if (ts.isPropertyAssignment(n.parent) ||
|
|
1065
|
+
ts.isShorthandPropertyAssignment(n.parent))
|
|
1066
|
+
path = [n.parent.name.getText(), ...path];
|
|
1067
|
+
n = n.parent;
|
|
1068
|
+
}
|
|
1069
|
+
const p = n.parent;
|
|
1070
|
+
if (!p ||
|
|
1071
|
+
!(ts.isCallExpression(p) || ts.isNewExpression(p)) ||
|
|
1072
|
+
!p.arguments?.includes(n))
|
|
1073
|
+
return [];
|
|
1074
|
+
const target = projectCallee(p.expression);
|
|
1075
|
+
if (!target)
|
|
1076
|
+
return [];
|
|
1077
|
+
const binding = bindingFor(target, p.arguments.indexOf(n), path);
|
|
1078
|
+
if (!binding)
|
|
1079
|
+
return [];
|
|
1080
|
+
const sym = symbolOf(binding.name ?? binding);
|
|
1081
|
+
if (!sym)
|
|
1082
|
+
return [];
|
|
1083
|
+
const calls = [];
|
|
1084
|
+
const scope = ts.isClassDeclaration(target) ? target : target;
|
|
1085
|
+
const visit = (x) => {
|
|
1086
|
+
if (ts.isCallExpression(x)) {
|
|
1087
|
+
const c = unwrap(x.expression);
|
|
1088
|
+
if (ts.isIdentifier(c) && symbolOf(c) === sym)
|
|
1089
|
+
calls.push(x);
|
|
1090
|
+
}
|
|
1091
|
+
ts.forEachChild(x, visit);
|
|
1092
|
+
};
|
|
1093
|
+
visit(scope);
|
|
1094
|
+
return calls;
|
|
1095
|
+
}
|
|
1096
|
+
function functionByName(name, file) {
|
|
1097
|
+
const sf = srcByFile.get(file);
|
|
1098
|
+
if (!sf)
|
|
1099
|
+
return undefined;
|
|
1100
|
+
let found;
|
|
1101
|
+
const visit = (n) => {
|
|
1102
|
+
if (found)
|
|
1103
|
+
return;
|
|
1104
|
+
if (ts.isFunctionLike(n) && nameOfFunction(n) === name) {
|
|
1105
|
+
found = n;
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
ts.forEachChild(n, visit);
|
|
1109
|
+
};
|
|
1110
|
+
visit(sf);
|
|
1111
|
+
return found;
|
|
1112
|
+
}
|
|
1113
|
+
// ---------------------------------------------------------------------------
|
|
1114
|
+
// Adapter: objects of mocks (`const mocks = vi.hoisted(() => ({ a: vi.fn(), b: { c: vi.fn() } }))`)
|
|
1115
|
+
// A path into such an object that ends at a mock is a sink: `sink:mocks.b.c`.
|
|
1116
|
+
// ---------------------------------------------------------------------------
|
|
1117
|
+
function returnedObject(fn) {
|
|
1118
|
+
if (!(ts.isArrowFunction(fn) ||
|
|
1119
|
+
ts.isFunctionExpression(fn) ||
|
|
1120
|
+
ts.isMethodDeclaration(fn) ||
|
|
1121
|
+
ts.isFunctionDeclaration(fn)))
|
|
1122
|
+
return undefined;
|
|
1123
|
+
const body = fn.body;
|
|
1124
|
+
if (!body)
|
|
1125
|
+
return undefined;
|
|
1126
|
+
if (!ts.isBlock(body)) {
|
|
1127
|
+
const b = unwrap(body);
|
|
1128
|
+
return ts.isObjectLiteralExpression(b) ? b : undefined;
|
|
1129
|
+
}
|
|
1130
|
+
let r;
|
|
1131
|
+
const visit = (n) => {
|
|
1132
|
+
if (r)
|
|
1133
|
+
return;
|
|
1134
|
+
if (ts.isReturnStatement(n) &&
|
|
1135
|
+
n.expression &&
|
|
1136
|
+
ts.isObjectLiteralExpression(unwrap(n.expression)))
|
|
1137
|
+
r = unwrap(n.expression);
|
|
1138
|
+
ts.forEachChild(n, visit);
|
|
1139
|
+
};
|
|
1140
|
+
visit(body);
|
|
1141
|
+
return r;
|
|
1142
|
+
}
|
|
1143
|
+
/** The object literal behind `const x = {...}`, `const x = vi.hoisted(() => ({...}))` or `const x = makeMocks()` (a local factory). */
|
|
1144
|
+
function hoistedObject(init) {
|
|
1145
|
+
const u = unwrap(init);
|
|
1146
|
+
if (ts.isObjectLiteralExpression(u))
|
|
1147
|
+
return u;
|
|
1148
|
+
if (ts.isCallExpression(u) &&
|
|
1149
|
+
/^(vi|jest)\.hoisted$/.test(u.expression.getText()) &&
|
|
1150
|
+
u.arguments[0])
|
|
1151
|
+
return returnedObject(unwrap(u.arguments[0]));
|
|
1152
|
+
if (ts.isCallExpression(u) && ts.isIdentifier(u.expression)) {
|
|
1153
|
+
const fn = localFunctionNode(u.expression.text, u.getSourceFile());
|
|
1154
|
+
if (fn)
|
|
1155
|
+
return returnedObject(fn);
|
|
1156
|
+
}
|
|
1157
|
+
return undefined;
|
|
1158
|
+
}
|
|
1159
|
+
/** The value a variable holds: its initializer, or for `let x` the first `x = ...` assignment in the file (beforeEach setup). */
|
|
1160
|
+
function initOf(d) {
|
|
1161
|
+
if (d.initializer)
|
|
1162
|
+
return d.initializer;
|
|
1163
|
+
if (!ts.isIdentifier(d.name))
|
|
1164
|
+
return undefined;
|
|
1165
|
+
const sym = symbolOf(d.name);
|
|
1166
|
+
let found;
|
|
1167
|
+
const visit = (n) => {
|
|
1168
|
+
if (found)
|
|
1169
|
+
return;
|
|
1170
|
+
if (ts.isBinaryExpression(n) &&
|
|
1171
|
+
n.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
1172
|
+
ts.isIdentifier(n.left) &&
|
|
1173
|
+
symbolOf(n.left) === sym) {
|
|
1174
|
+
found = n.right;
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
ts.forEachChild(n, visit);
|
|
1178
|
+
};
|
|
1179
|
+
visit(d.getSourceFile());
|
|
1180
|
+
return found;
|
|
1181
|
+
}
|
|
1182
|
+
const GLOBAL_ROOTS = new Set(["globalThis", "window", "global", "self"]);
|
|
1183
|
+
/** An object of mocks or a single mock, following identifiers to their (possibly later-assigned) value. */
|
|
1184
|
+
function mockValue(v, depth = 0) {
|
|
1185
|
+
if (depth > 4)
|
|
1186
|
+
return undefined;
|
|
1187
|
+
const u = unwrap(v);
|
|
1188
|
+
if (isMockFactory(u))
|
|
1189
|
+
return u;
|
|
1190
|
+
const obj = hoistedObject(u);
|
|
1191
|
+
if (obj)
|
|
1192
|
+
return containsMock(obj) ? obj : undefined;
|
|
1193
|
+
if (ts.isIdentifier(u)) {
|
|
1194
|
+
const d = declOf(u);
|
|
1195
|
+
const init = d && ts.isVariableDeclaration(d) ? initOf(d) : undefined;
|
|
1196
|
+
return init ? mockValue(init, depth + 1) : undefined;
|
|
1197
|
+
}
|
|
1198
|
+
return undefined;
|
|
1199
|
+
}
|
|
1200
|
+
function containsMock(obj) {
|
|
1201
|
+
let found = false;
|
|
1202
|
+
const visit = (n) => {
|
|
1203
|
+
if (found)
|
|
1204
|
+
return;
|
|
1205
|
+
if (ts.isCallExpression(n) && isMockFactory(n))
|
|
1206
|
+
found = true;
|
|
1207
|
+
else
|
|
1208
|
+
ts.forEachChild(n, visit);
|
|
1209
|
+
};
|
|
1210
|
+
visit(obj);
|
|
1211
|
+
return found;
|
|
1212
|
+
}
|
|
1213
|
+
/** Walk `path` through an object literal; the segments up to (and including) the first mock leaf, or undefined. */
|
|
1214
|
+
function mockLeafPath(obj, path) {
|
|
1215
|
+
let cur = obj;
|
|
1216
|
+
const walked = [];
|
|
1217
|
+
// a property of an object literal, or of any object-literal argument of Object.assign(target, ...sources)
|
|
1218
|
+
const propertyOf = (obj, seg) => {
|
|
1219
|
+
if (ts.isCallExpression(obj) &&
|
|
1220
|
+
obj.expression.getText() === "Object.assign") {
|
|
1221
|
+
for (const a of [...obj.arguments].reverse()) {
|
|
1222
|
+
const found = propertyOf(unwrap(a), seg);
|
|
1223
|
+
if (found)
|
|
1224
|
+
return found;
|
|
1225
|
+
}
|
|
1226
|
+
return undefined;
|
|
1227
|
+
}
|
|
1228
|
+
if (!ts.isObjectLiteralExpression(obj))
|
|
1229
|
+
return undefined;
|
|
1230
|
+
const p = obj.properties.find((x) => x.name?.getText() === seg);
|
|
1231
|
+
return p && ts.isPropertyAssignment(p)
|
|
1232
|
+
? unwrap(p.initializer)
|
|
1233
|
+
: p && ts.isShorthandPropertyAssignment(p)
|
|
1234
|
+
? p.name
|
|
1235
|
+
: undefined;
|
|
1236
|
+
};
|
|
1237
|
+
for (const seg of path) {
|
|
1238
|
+
let v = propertyOf(cur, seg);
|
|
1239
|
+
if (!v)
|
|
1240
|
+
return undefined;
|
|
1241
|
+
// `{ modalShow }` where `const modalShow = vi.fn()` was declared just above
|
|
1242
|
+
if (ts.isIdentifier(v)) {
|
|
1243
|
+
const d = declOf(v);
|
|
1244
|
+
const init = d && ts.isVariableDeclaration(d) ? initOf(d) : undefined;
|
|
1245
|
+
if (init)
|
|
1246
|
+
v = unwrap(init);
|
|
1247
|
+
}
|
|
1248
|
+
walked.push(seg);
|
|
1249
|
+
if (isMockFactory(v))
|
|
1250
|
+
return walked;
|
|
1251
|
+
// `Article: articleResource()`: a nested local factory
|
|
1252
|
+
if (ts.isCallExpression(v)) {
|
|
1253
|
+
const nested = hoistedObject(v);
|
|
1254
|
+
if (nested)
|
|
1255
|
+
v = nested;
|
|
1256
|
+
}
|
|
1257
|
+
cur = v;
|
|
1258
|
+
}
|
|
1259
|
+
return undefined;
|
|
1260
|
+
}
|
|
1261
|
+
/** `mocks.fetcher.submit` → the sink object, its name and the path inside it. */
|
|
1262
|
+
function sinkObjOrigin(e) {
|
|
1263
|
+
const root = rootOfExpr(e);
|
|
1264
|
+
if (!ts.isIdentifier(root))
|
|
1265
|
+
return undefined;
|
|
1266
|
+
const d = declOf(root);
|
|
1267
|
+
const init = d && ts.isVariableDeclaration(d) ? initOf(d) : undefined;
|
|
1268
|
+
if (!init)
|
|
1269
|
+
return undefined;
|
|
1270
|
+
const obj = hoistedObject(init);
|
|
1271
|
+
if (!obj || !containsMock(obj))
|
|
1272
|
+
return undefined;
|
|
1273
|
+
return { objName: root.text, obj, path: chainOf(e).slice(1) };
|
|
1274
|
+
}
|
|
1275
|
+
const moduleMocksByFile = new Map();
|
|
1276
|
+
const globalSinksByFile = new Map();
|
|
1277
|
+
function collectGlobalSinks(sf) {
|
|
1278
|
+
const out = new Map();
|
|
1279
|
+
const set = (name, v) => {
|
|
1280
|
+
const mv = mockValue(v);
|
|
1281
|
+
if (!mv || out.has(name))
|
|
1282
|
+
return;
|
|
1283
|
+
const u = unwrap(v);
|
|
1284
|
+
const viaVariable = ts.isIdentifier(u) &&
|
|
1285
|
+
(() => {
|
|
1286
|
+
const d = declOf(u);
|
|
1287
|
+
return !!d && ts.isVariableDeclaration(d);
|
|
1288
|
+
})();
|
|
1289
|
+
out.set(name, {
|
|
1290
|
+
value: mv,
|
|
1291
|
+
name: viaVariable ? u.text : name,
|
|
1292
|
+
});
|
|
1293
|
+
};
|
|
1294
|
+
const visit = (n) => {
|
|
1295
|
+
if (ts.isCallExpression(n) &&
|
|
1296
|
+
/^Object\.assign$/.test(n.expression.getText()) &&
|
|
1297
|
+
n.arguments.length >= 2 &&
|
|
1298
|
+
GLOBAL_ROOTS.has(n.arguments[0].getText())) {
|
|
1299
|
+
const obj = unwrap(n.arguments[1]);
|
|
1300
|
+
if (ts.isObjectLiteralExpression(obj))
|
|
1301
|
+
for (const p of obj.properties) {
|
|
1302
|
+
if (ts.isPropertyAssignment(p) && p.name)
|
|
1303
|
+
set(p.name.getText(), p.initializer);
|
|
1304
|
+
else if (ts.isShorthandPropertyAssignment(p))
|
|
1305
|
+
set(p.name.text, p.name);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
if (ts.isCallExpression(n) &&
|
|
1309
|
+
/^(vi|jest)\.stubGlobal$/.test(n.expression.getText()) &&
|
|
1310
|
+
n.arguments.length >= 2 &&
|
|
1311
|
+
ts.isStringLiteralLike(n.arguments[0]))
|
|
1312
|
+
set(n.arguments[0].text, n.arguments[1]);
|
|
1313
|
+
if (ts.isBinaryExpression(n) &&
|
|
1314
|
+
n.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
1315
|
+
ts.isPropertyAccessExpression(n.left) &&
|
|
1316
|
+
GLOBAL_ROOTS.has(n.left.expression.getText()))
|
|
1317
|
+
set(n.left.name.text, n.right);
|
|
1318
|
+
ts.forEachChild(n, visit);
|
|
1319
|
+
};
|
|
1320
|
+
visit(sf);
|
|
1321
|
+
return out;
|
|
1322
|
+
}
|
|
1323
|
+
/** Origin of `name` (or `window.name`) when the test file installed it as a mock or an object of mocks. */
|
|
1324
|
+
function globalSinkOrigin(name, sf) {
|
|
1325
|
+
const g = globalSinksByFile.get(relative(root, sf.fileName))?.get(name);
|
|
1326
|
+
if (!g)
|
|
1327
|
+
return undefined;
|
|
1328
|
+
return ts.isObjectLiteralExpression(g.value)
|
|
1329
|
+
? { kind: "sinkobj:" + g.name, path: [], obj: g.value }
|
|
1330
|
+
: { kind: "sink:" + g.name, path: [] };
|
|
1331
|
+
}
|
|
1332
|
+
/** Boundary a production call `shopify.toast.show(...)` / `window.Beacon(...)` writes into, for one test file's installed globals. */
|
|
1333
|
+
function globalSinkBoundary(name, rest, testFile) {
|
|
1334
|
+
const g = globalSinksByFile.get(testFile)?.get(name);
|
|
1335
|
+
if (!g)
|
|
1336
|
+
return undefined;
|
|
1337
|
+
if (ts.isObjectLiteralExpression(g.value)) {
|
|
1338
|
+
const leaf = mockLeafPath(g.value, rest);
|
|
1339
|
+
return leaf
|
|
1340
|
+
? {
|
|
1341
|
+
boundary: `sink:${g.name}.${leaf.join(".")}`,
|
|
1342
|
+
via: "test-installed global",
|
|
1343
|
+
}
|
|
1344
|
+
: undefined;
|
|
1345
|
+
}
|
|
1346
|
+
return rest.length === 0
|
|
1347
|
+
? { boundary: "sink:" + g.name, via: "test-installed global" }
|
|
1348
|
+
: undefined;
|
|
1349
|
+
}
|
|
1350
|
+
function resolveSpec(spec, fromFile) {
|
|
1351
|
+
return program.resolveModule(spec, fromFile);
|
|
1352
|
+
}
|
|
1353
|
+
function collectModuleMocks(sf) {
|
|
1354
|
+
const mocks = [];
|
|
1355
|
+
const visit = (n) => {
|
|
1356
|
+
if (ts.isCallExpression(n) &&
|
|
1357
|
+
/^(vi|jest)\.(mock|doMock)$/.test(n.expression.getText()) &&
|
|
1358
|
+
n.arguments[0] &&
|
|
1359
|
+
ts.isStringLiteralLike(n.arguments[0])) {
|
|
1360
|
+
const spec = n.arguments[0].text;
|
|
1361
|
+
const factory = n.arguments[1] ? unwrap(n.arguments[1]) : undefined;
|
|
1362
|
+
const exportsObj = factory ? returnedObject(factory) : undefined;
|
|
1363
|
+
const exportsList = [];
|
|
1364
|
+
const walk = (obj, path) => {
|
|
1365
|
+
for (const p of obj.properties) {
|
|
1366
|
+
if (ts.isSpreadAssignment(p) || !p.name)
|
|
1367
|
+
continue;
|
|
1368
|
+
const full = [...path, p.name.getText()];
|
|
1369
|
+
const v = ts.isPropertyAssignment(p)
|
|
1370
|
+
? unwrap(p.initializer)
|
|
1371
|
+
: ts.isShorthandPropertyAssignment(p)
|
|
1372
|
+
? p.name
|
|
1373
|
+
: ts.isMethodDeclaration(p)
|
|
1374
|
+
? p
|
|
1375
|
+
: undefined;
|
|
1376
|
+
if (!v)
|
|
1377
|
+
continue;
|
|
1378
|
+
if (ts.isObjectLiteralExpression(v)) {
|
|
1379
|
+
walk(v, full);
|
|
1380
|
+
continue;
|
|
1381
|
+
}
|
|
1382
|
+
if (ts.isIdentifier(v) || ts.isPropertyAccessExpression(v)) {
|
|
1383
|
+
const so = sinkObjOrigin(v);
|
|
1384
|
+
const leaf = so ? mockLeafPath(so.obj, so.path) : undefined;
|
|
1385
|
+
if (so && leaf) {
|
|
1386
|
+
exportsList.push({
|
|
1387
|
+
path: full,
|
|
1388
|
+
sink: `sink:${so.objName}.${leaf.join(".")}`,
|
|
1389
|
+
});
|
|
1390
|
+
continue;
|
|
1391
|
+
}
|
|
1392
|
+
// `{ prisma }` where `prisma` is a (hoisted) object of mocks: the export is the whole sink object
|
|
1393
|
+
if (so) {
|
|
1394
|
+
exportsList.push({ path: full, sinkObj: so });
|
|
1395
|
+
continue;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
// `$setBlocksType: vi.fn()` right in the factory: a sink named after the module and export path
|
|
1399
|
+
if (ts.isCallExpression(v) && isMockFactory(v)) {
|
|
1400
|
+
exportsList.push({
|
|
1401
|
+
path: full,
|
|
1402
|
+
sink: `sink:${spec}#${full.join(".")}`,
|
|
1403
|
+
});
|
|
1404
|
+
continue;
|
|
1405
|
+
}
|
|
1406
|
+
if (ts.isArrowFunction(v) ||
|
|
1407
|
+
ts.isFunctionExpression(v) ||
|
|
1408
|
+
ts.isMethodDeclaration(v)) {
|
|
1409
|
+
let ret;
|
|
1410
|
+
if (v.body && !ts.isBlock(v.body))
|
|
1411
|
+
ret = unwrap(v.body);
|
|
1412
|
+
else if (v.body) {
|
|
1413
|
+
const vr = (x) => {
|
|
1414
|
+
if (ret)
|
|
1415
|
+
return;
|
|
1416
|
+
if (ts.isReturnStatement(x) && x.expression)
|
|
1417
|
+
ret = unwrap(x.expression);
|
|
1418
|
+
ts.forEachChild(x, vr);
|
|
1419
|
+
};
|
|
1420
|
+
vr(v.body);
|
|
1421
|
+
}
|
|
1422
|
+
const rso = ret &&
|
|
1423
|
+
(ts.isIdentifier(ret) || ts.isPropertyAccessExpression(ret))
|
|
1424
|
+
? sinkObjOrigin(ret)
|
|
1425
|
+
: undefined;
|
|
1426
|
+
exportsList.push(rso ? { path: full, returns: rso } : { path: full });
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
};
|
|
1430
|
+
if (exportsObj)
|
|
1431
|
+
walk(exportsObj, []);
|
|
1432
|
+
mocks.push({
|
|
1433
|
+
spec,
|
|
1434
|
+
resolved: resolveSpec(spec, sf.fileName),
|
|
1435
|
+
exports: exportsList,
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
ts.forEachChild(n, visit);
|
|
1439
|
+
};
|
|
1440
|
+
visit(sf);
|
|
1441
|
+
return mocks;
|
|
1442
|
+
}
|
|
1443
|
+
function importOf(id) {
|
|
1444
|
+
const d = checker.getSymbolAtLocation(id)?.declarations?.[0];
|
|
1445
|
+
if (!d)
|
|
1446
|
+
return undefined;
|
|
1447
|
+
let spec;
|
|
1448
|
+
let importedName = id.text;
|
|
1449
|
+
if (ts.isImportSpecifier(d)) {
|
|
1450
|
+
importedName = (d.propertyName ?? d.name).text;
|
|
1451
|
+
spec = d.parent.parent.parent.moduleSpecifier
|
|
1452
|
+
.getText()
|
|
1453
|
+
.slice(1, -1);
|
|
1454
|
+
}
|
|
1455
|
+
else if (ts.isImportClause(d)) {
|
|
1456
|
+
importedName = "default";
|
|
1457
|
+
spec = d.parent.moduleSpecifier
|
|
1458
|
+
.getText()
|
|
1459
|
+
.slice(1, -1);
|
|
1460
|
+
}
|
|
1461
|
+
else if (ts.isNamespaceImport(d)) {
|
|
1462
|
+
importedName = "*";
|
|
1463
|
+
spec = d.parent.parent.moduleSpecifier
|
|
1464
|
+
.getText()
|
|
1465
|
+
.slice(1, -1);
|
|
1466
|
+
}
|
|
1467
|
+
if (!spec)
|
|
1468
|
+
return undefined;
|
|
1469
|
+
return {
|
|
1470
|
+
spec,
|
|
1471
|
+
resolved: resolveSpec(spec, id.getSourceFile().fileName),
|
|
1472
|
+
importedName,
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
/** The declared name behind an import alias (`import { action as bulkAction }` → action). */
|
|
1476
|
+
function declaredName(d, fallback) {
|
|
1477
|
+
const n = d.name;
|
|
1478
|
+
return n && ts.isIdentifier(n) ? n.text : fallback;
|
|
1479
|
+
}
|
|
1480
|
+
/** `import { x } from '~/m'` in a test file that `vi.mock`s '~/m' with `x: vi.fn()` (or `x: mocks.y`): the sink. */
|
|
1481
|
+
function mockedImportOrigin(id) {
|
|
1482
|
+
const imp = importOf(id);
|
|
1483
|
+
if (!imp)
|
|
1484
|
+
return undefined;
|
|
1485
|
+
const mocks = moduleMocksByFile.get(relative(root, id.getSourceFile().fileName)) ?? [];
|
|
1486
|
+
for (const m of mocks) {
|
|
1487
|
+
if (!((m.resolved && imp.resolved && m.resolved === imp.resolved) ||
|
|
1488
|
+
m.spec === imp.spec))
|
|
1489
|
+
continue;
|
|
1490
|
+
for (const b of m.exports)
|
|
1491
|
+
if (b.sink && b.path.length === 1 && b.path[0] === imp.importedName)
|
|
1492
|
+
return { kind: b.sink, path: [] };
|
|
1493
|
+
}
|
|
1494
|
+
return undefined;
|
|
1495
|
+
}
|
|
1496
|
+
/** `await import('~/lib/x')` / `vi.importActual('~/lib/x')`: the production module itself (kind carries the file). */
|
|
1497
|
+
function moduleOrigin(spec, from) {
|
|
1498
|
+
const resolved = resolveSpec(spec, from.fileName);
|
|
1499
|
+
const relPath = resolved ? relative(root, resolved) : "";
|
|
1500
|
+
return {
|
|
1501
|
+
kind: "module:" + (relPath.startsWith(srcDir + "/") ? relPath : ""),
|
|
1502
|
+
path: [],
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
const mmCache = new Map();
|
|
1506
|
+
/** Sinks a production call site writes into, given the module mocks of one test file. */
|
|
1507
|
+
function moduleMockBoundaries(s, testFile) {
|
|
1508
|
+
const key = `${s.id}|${testFile}`;
|
|
1509
|
+
const cached = mmCache.get(key);
|
|
1510
|
+
if (cached)
|
|
1511
|
+
return cached;
|
|
1512
|
+
const out = [];
|
|
1513
|
+
const mocks = moduleMocksByFile.get(testFile) ?? [];
|
|
1514
|
+
const node = siteNodes.get(s.id);
|
|
1515
|
+
if (mocks.length && node && ts.isCallExpression(node)) {
|
|
1516
|
+
const callee = unwrap(node.expression);
|
|
1517
|
+
const chain = chainOf(callee);
|
|
1518
|
+
const root = rootOfExpr(callee);
|
|
1519
|
+
const same = (m, imp) => (m.resolved && imp.resolved && m.resolved === imp.resolved) ||
|
|
1520
|
+
m.spec === imp.spec;
|
|
1521
|
+
const exportPath = (imp, rest) => imp.importedName === "default" || imp.importedName === "*"
|
|
1522
|
+
? rest
|
|
1523
|
+
: [imp.importedName, ...rest];
|
|
1524
|
+
if (ts.isIdentifier(root)) {
|
|
1525
|
+
// shopify.toast.show(...) / window.Beacon(...) on a global the test installed (the app only has an ambient declaration for it)
|
|
1526
|
+
const rd = declOf(root);
|
|
1527
|
+
if (!rd || rd.getSourceFile().isDeclarationFile) {
|
|
1528
|
+
const gb = GLOBAL_ROOTS.has(root.text) && chain[1]
|
|
1529
|
+
? globalSinkBoundary(chain[1], chain.slice(2), testFile)
|
|
1530
|
+
: globalSinkBoundary(root.text, chain.slice(1), testFile);
|
|
1531
|
+
if (gb)
|
|
1532
|
+
out.push(gb);
|
|
1533
|
+
}
|
|
1534
|
+
const imp = importOf(root);
|
|
1535
|
+
if (imp) {
|
|
1536
|
+
// authenticate.admin(request): the import itself is mocked
|
|
1537
|
+
const cp = exportPath(imp, chain.slice(1));
|
|
1538
|
+
for (const m of mocks)
|
|
1539
|
+
if (same(m, imp))
|
|
1540
|
+
for (const b of m.exports) {
|
|
1541
|
+
if (b.sink &&
|
|
1542
|
+
b.path.length === cp.length &&
|
|
1543
|
+
b.path.every((x, i) => x === cp[i]))
|
|
1544
|
+
out.push({ boundary: b.sink, via: `vi.mock('${m.spec}')` });
|
|
1545
|
+
// prisma.article.update(...) through `vi.mock('~/lib/prisma.server', () => ({ prisma }))`:
|
|
1546
|
+
// walk the rest of the call path inside the exported object of mocks
|
|
1547
|
+
if (b.sinkObj &&
|
|
1548
|
+
b.path.length < cp.length &&
|
|
1549
|
+
b.path.every((x, i) => x === cp[i])) {
|
|
1550
|
+
const leaf = mockLeafPath(b.sinkObj.obj, [
|
|
1551
|
+
...b.sinkObj.path,
|
|
1552
|
+
...cp.slice(b.path.length),
|
|
1553
|
+
]);
|
|
1554
|
+
if (leaf)
|
|
1555
|
+
out.push({
|
|
1556
|
+
boundary: `sink:${b.sinkObj.objName}.${leaf.join(".")}`,
|
|
1557
|
+
via: `vi.mock('${m.spec}') ${b.path.join(".")}`,
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
else {
|
|
1563
|
+
// const fetcher = useFetcher(); fetcher.submit(...): the import returns an object of mocks
|
|
1564
|
+
const d = declOf(root);
|
|
1565
|
+
const decl = d && ts.isVariableDeclaration(d)
|
|
1566
|
+
? d
|
|
1567
|
+
: d &&
|
|
1568
|
+
ts.isBindingElement(d) &&
|
|
1569
|
+
ts.isVariableDeclaration(d.parent.parent)
|
|
1570
|
+
? d.parent.parent
|
|
1571
|
+
: undefined;
|
|
1572
|
+
const prefix = d && ts.isBindingElement(d)
|
|
1573
|
+
? [(d.propertyName ?? d.name).getText()]
|
|
1574
|
+
: [];
|
|
1575
|
+
const init = decl?.initializer ? unwrap(decl.initializer) : undefined;
|
|
1576
|
+
if (init && ts.isCallExpression(init)) {
|
|
1577
|
+
const ic = unwrap(init.expression);
|
|
1578
|
+
const ir = rootOfExpr(ic);
|
|
1579
|
+
const imp2 = ts.isIdentifier(ir) ? importOf(ir) : undefined;
|
|
1580
|
+
if (imp2) {
|
|
1581
|
+
const callPath = exportPath(imp2, chainOf(ic).slice(1));
|
|
1582
|
+
for (const m of mocks)
|
|
1583
|
+
if (same(m, imp2))
|
|
1584
|
+
for (const b of m.exports) {
|
|
1585
|
+
if (!b.returns ||
|
|
1586
|
+
b.path.length !== callPath.length ||
|
|
1587
|
+
!b.path.every((x, i) => x === callPath[i]))
|
|
1588
|
+
continue;
|
|
1589
|
+
const leaf = mockLeafPath(b.returns.obj, [
|
|
1590
|
+
...b.returns.path,
|
|
1591
|
+
...prefix,
|
|
1592
|
+
...chain.slice(1),
|
|
1593
|
+
]);
|
|
1594
|
+
if (leaf)
|
|
1595
|
+
out.push({
|
|
1596
|
+
boundary: `sink:${b.returns.objName}.${leaf.join(".")}`,
|
|
1597
|
+
via: `vi.mock('${m.spec}') ${b.path.join(".")}()`,
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
mmCache.set(key, out);
|
|
1606
|
+
return out;
|
|
1607
|
+
}
|
|
1608
|
+
const EXPECT_STRENGTH = {
|
|
1609
|
+
toEqual: "total",
|
|
1610
|
+
toStrictEqual: "total",
|
|
1611
|
+
toMatchObject: "value",
|
|
1612
|
+
toMatchSnapshot: "total",
|
|
1613
|
+
toMatchInlineSnapshot: "total",
|
|
1614
|
+
toHaveBeenCalledWith: "total",
|
|
1615
|
+
toHaveBeenLastCalledWith: "total",
|
|
1616
|
+
toHaveBeenNthCalledWith: "total",
|
|
1617
|
+
toBe: "value",
|
|
1618
|
+
toBeCloseTo: "value",
|
|
1619
|
+
toHaveLength: "value",
|
|
1620
|
+
toHaveProperty: "value",
|
|
1621
|
+
toBeInstanceOf: "value",
|
|
1622
|
+
toHaveBeenCalledTimes: "value",
|
|
1623
|
+
toHaveBeenCalledOnce: "value",
|
|
1624
|
+
toBeGreaterThan: "value",
|
|
1625
|
+
toBeGreaterThanOrEqual: "value",
|
|
1626
|
+
toBeLessThan: "value",
|
|
1627
|
+
toBeLessThanOrEqual: "value",
|
|
1628
|
+
toContain: "value",
|
|
1629
|
+
toContainEqual: "value",
|
|
1630
|
+
toMatch: "value",
|
|
1631
|
+
toThrow: "value",
|
|
1632
|
+
toThrowError: "value",
|
|
1633
|
+
toHaveTextContent: "value",
|
|
1634
|
+
toHaveAttribute: "value",
|
|
1635
|
+
toHaveValue: "value",
|
|
1636
|
+
toBeTruthy: "presence",
|
|
1637
|
+
toBeFalsy: "presence",
|
|
1638
|
+
toBeDefined: "presence",
|
|
1639
|
+
// toBeNull / toBeUndefined compare with one specific value: a `return null` mutated away fails them
|
|
1640
|
+
toBeUndefined: "value",
|
|
1641
|
+
toBeNull: "value",
|
|
1642
|
+
toBeNaN: "presence",
|
|
1643
|
+
toHaveBeenCalled: "presence",
|
|
1644
|
+
toBeInTheDocument: "presence",
|
|
1645
|
+
toBeVisible: "presence",
|
|
1646
|
+
toBeDisabled: "presence",
|
|
1647
|
+
toBeEnabled: "presence",
|
|
1648
|
+
toBeChecked: "presence",
|
|
1649
|
+
toHaveFocus: "presence",
|
|
1650
|
+
toBeEmptyDOMElement: "presence",
|
|
1651
|
+
toHaveClass: "value",
|
|
1652
|
+
toHaveStyle: "value",
|
|
1653
|
+
toHaveDisplayValue: "value",
|
|
1654
|
+
toHaveAccessibleName: "value",
|
|
1655
|
+
toEqualTypeOf: "presence",
|
|
1656
|
+
// Playwright locator and page matchers
|
|
1657
|
+
toHaveCount: "value",
|
|
1658
|
+
toHaveText: "value",
|
|
1659
|
+
toContainText: "value",
|
|
1660
|
+
toHaveURL: "value",
|
|
1661
|
+
toHaveTitle: "value",
|
|
1662
|
+
toHaveId: "value",
|
|
1663
|
+
toHaveCSS: "value",
|
|
1664
|
+
toHaveJSProperty: "value",
|
|
1665
|
+
toHaveValues: "value",
|
|
1666
|
+
toBeHidden: "presence",
|
|
1667
|
+
toBeAttached: "presence",
|
|
1668
|
+
toBeEditable: "presence",
|
|
1669
|
+
toBeFocused: "presence",
|
|
1670
|
+
toBeInViewport: "presence",
|
|
1671
|
+
toBeOK: "presence",
|
|
1672
|
+
toHaveScreenshot: "presence",
|
|
1673
|
+
};
|
|
1674
|
+
/** vi.fn(), jest.fn(), mock.fn(), vi.spyOn(...): a test-owned function sink, possibly with a chained mock setup. */
|
|
1675
|
+
function isMockFactory(e) {
|
|
1676
|
+
let cur = unwrap(e);
|
|
1677
|
+
for (let i = 0; i < 12; i++) {
|
|
1678
|
+
if (ts.isCallExpression(cur)) {
|
|
1679
|
+
const c = unwrap(cur.expression);
|
|
1680
|
+
if (ts.isPropertyAccessExpression(c) &&
|
|
1681
|
+
/^(vi|jest|mock|sinon)$/.test(c.expression.getText()) &&
|
|
1682
|
+
/^(fn|spyOn|stub|spy|mock)$/.test(c.name.text))
|
|
1683
|
+
return true;
|
|
1684
|
+
cur = c;
|
|
1685
|
+
}
|
|
1686
|
+
else if (ts.isPropertyAccessExpression(cur))
|
|
1687
|
+
cur = unwrap(cur.expression);
|
|
1688
|
+
else
|
|
1689
|
+
return false;
|
|
1690
|
+
}
|
|
1691
|
+
return false;
|
|
1692
|
+
}
|
|
1693
|
+
const ASSERT_STRENGTH = {
|
|
1694
|
+
deepEqual: "total",
|
|
1695
|
+
deepStrictEqual: "total",
|
|
1696
|
+
notDeepEqual: "total",
|
|
1697
|
+
notDeepStrictEqual: "total",
|
|
1698
|
+
equal: "value",
|
|
1699
|
+
strictEqual: "value",
|
|
1700
|
+
notEqual: "value",
|
|
1701
|
+
notStrictEqual: "value",
|
|
1702
|
+
match: "value",
|
|
1703
|
+
doesNotMatch: "value",
|
|
1704
|
+
ok: "presence",
|
|
1705
|
+
assert: "presence",
|
|
1706
|
+
rejects: "presence",
|
|
1707
|
+
throws: "presence",
|
|
1708
|
+
doesNotThrow: "presence",
|
|
1709
|
+
doesNotReject: "presence",
|
|
1710
|
+
};
|
|
1711
|
+
const staticTests = [];
|
|
1712
|
+
const pragmaCollector = collectPragmas(ts, allFiles.filter(isTestFile), rel, sites);
|
|
1713
|
+
const staticTestKey = (file, line, name) => JSON.stringify([file, line, name]);
|
|
1714
|
+
function originOf(expr, depth = 0) {
|
|
1715
|
+
// each property-access segment costs one level: `admin.rest.resources.Article.find.mock.calls.map(...)` read
|
|
1716
|
+
// through a local helper is 10 deep; runaway recursion through helpers is bounded by paramBindings instead
|
|
1717
|
+
if (depth > 16)
|
|
1718
|
+
return undefined;
|
|
1719
|
+
const e = unwrap(expr);
|
|
1720
|
+
if (ts.isStringLiteralLike(e) ||
|
|
1721
|
+
ts.isNumericLiteral(e) ||
|
|
1722
|
+
e.kind === ts.SyntaxKind.TrueKeyword ||
|
|
1723
|
+
e.kind === ts.SyntaxKind.FalseKeyword ||
|
|
1724
|
+
e.kind === ts.SyntaxKind.NullKeyword ||
|
|
1725
|
+
ts.isRegularExpressionLiteral(e) ||
|
|
1726
|
+
ts.isTemplateExpression(e) ||
|
|
1727
|
+
ts.isArrayLiteralExpression(e) ||
|
|
1728
|
+
ts.isObjectLiteralExpression(e))
|
|
1729
|
+
return { kind: "literal", path: [] };
|
|
1730
|
+
if (ts.isNewExpression(e))
|
|
1731
|
+
return {
|
|
1732
|
+
kind: /Client|Transport|WebSocket/.test(e.expression.getText())
|
|
1733
|
+
? "sdk-client"
|
|
1734
|
+
: "new:" + e.expression.getText(),
|
|
1735
|
+
path: [],
|
|
1736
|
+
};
|
|
1737
|
+
if (ts.isCallExpression(e)) {
|
|
1738
|
+
const callee = unwrap(e.expression);
|
|
1739
|
+
if (ts.isPropertyAccessExpression(callee) &&
|
|
1740
|
+
callee.name.text === "method" &&
|
|
1741
|
+
callee.expression.getText().endsWith(".mock") &&
|
|
1742
|
+
e.arguments.length >= 2 &&
|
|
1743
|
+
ts.isStringLiteralLike(e.arguments[1]))
|
|
1744
|
+
return {
|
|
1745
|
+
kind: `mock:${e.arguments[0].getText()}.${e.arguments[1].text}`,
|
|
1746
|
+
path: [],
|
|
1747
|
+
};
|
|
1748
|
+
if (ts.isIdentifier(callee)) {
|
|
1749
|
+
// Resolve the declaration below. A familiar helper name is not a
|
|
1750
|
+
// contract, nor does it identify the particular process/socket observed.
|
|
1751
|
+
// testing-library: render(...)/within(...) results read the DOM; renderHook(() => useX()) reads useX's return
|
|
1752
|
+
const rawCallee = checker.getSymbolAtLocation(callee)?.declarations?.[0];
|
|
1753
|
+
const importedFromTestingLibrary = !!rawCallee &&
|
|
1754
|
+
(ts.isImportSpecifier(rawCallee) || ts.isImportClause(rawCallee)) &&
|
|
1755
|
+
/^@testing-library\//.test((ts.isImportSpecifier(rawCallee)
|
|
1756
|
+
? rawCallee.parent.parent.parent
|
|
1757
|
+
: rawCallee.parent).moduleSpecifier
|
|
1758
|
+
.getText()
|
|
1759
|
+
.slice(1, -1));
|
|
1760
|
+
if ((callee.text === "render" || callee.text === "within") &&
|
|
1761
|
+
importedFromTestingLibrary)
|
|
1762
|
+
return { kind: "dom", path: [] };
|
|
1763
|
+
if (callee.text === "renderHook" && e.arguments[0]) {
|
|
1764
|
+
let hook;
|
|
1765
|
+
const visit = (n) => {
|
|
1766
|
+
if (hook)
|
|
1767
|
+
return;
|
|
1768
|
+
if (ts.isCallExpression(n) &&
|
|
1769
|
+
ts.isIdentifier(unwrap(n.expression))) {
|
|
1770
|
+
const d = declOf(unwrap(n.expression));
|
|
1771
|
+
if (d && isProdFile(d.getSourceFile()))
|
|
1772
|
+
hook = {
|
|
1773
|
+
kind: "prod:" + unwrap(n.expression).text,
|
|
1774
|
+
path: [],
|
|
1775
|
+
};
|
|
1776
|
+
}
|
|
1777
|
+
ts.forEachChild(n, visit);
|
|
1778
|
+
};
|
|
1779
|
+
visit(e.arguments[0]);
|
|
1780
|
+
if (hook)
|
|
1781
|
+
return hook;
|
|
1782
|
+
}
|
|
1783
|
+
if (["String", "Number", "Boolean"].includes(callee.text))
|
|
1784
|
+
return e.arguments[0]
|
|
1785
|
+
? originOf(e.arguments[0], depth + 1)
|
|
1786
|
+
: undefined;
|
|
1787
|
+
const d = declOf(callee);
|
|
1788
|
+
if (d && isProdFile(d.getSourceFile()))
|
|
1789
|
+
return { kind: "prod:" + declaredName(d, callee.text), path: [] };
|
|
1790
|
+
if (d && isExternalDecl(d)) {
|
|
1791
|
+
// an import the test file mocked: calling it returns nothing observable, but the mock itself is a sink
|
|
1792
|
+
const mocked = mockedImportOrigin(callee);
|
|
1793
|
+
return mocked ?? { kind: "import:" + callee.text, path: [] };
|
|
1794
|
+
}
|
|
1795
|
+
// withEditor(run => ...) { return run(editor) }: the parameter is bound to the caller's callback
|
|
1796
|
+
if (d && ts.isParameter(d)) {
|
|
1797
|
+
const bound = boundArgument(d);
|
|
1798
|
+
const bf = bound ? unwrap(bound) : undefined;
|
|
1799
|
+
if (bf && (ts.isArrowFunction(bf) || ts.isFunctionExpression(bf))) {
|
|
1800
|
+
const viaCb = localFnCallOrigin(e, bf, depth + 1);
|
|
1801
|
+
if (viaCb)
|
|
1802
|
+
return viaCb;
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
// const generate = await load(); generate(...): a variable holding a production function (or an it.each cell)
|
|
1806
|
+
if (d &&
|
|
1807
|
+
(ts.isVariableDeclaration(d) ||
|
|
1808
|
+
ts.isBindingElement(d) ||
|
|
1809
|
+
ts.isParameter(d))) {
|
|
1810
|
+
const held = originOf(callee, depth + 1);
|
|
1811
|
+
if (held && held.kind.startsWith("prod:") && !held.path.length)
|
|
1812
|
+
return held;
|
|
1813
|
+
// const { getImage } = renderX(); getImage(): a local closure, follow its return expression
|
|
1814
|
+
const fn = heldFunction(d);
|
|
1815
|
+
if (fn) {
|
|
1816
|
+
const viaHeld = localFnCallOrigin(e, fn, depth + 1);
|
|
1817
|
+
if (viaHeld)
|
|
1818
|
+
return viaHeld;
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
// a local helper that returns a wrapped value: follow its return expression, substituting
|
|
1822
|
+
// parameters with the call's arguments (`readLoaderJson(response)` → origin of `response`)
|
|
1823
|
+
const local = localFunctionNode(callee.text, e.getSourceFile());
|
|
1824
|
+
if (local) {
|
|
1825
|
+
const viaReturn = localFnCallOrigin(e, local, depth + 1);
|
|
1826
|
+
if (viaReturn)
|
|
1827
|
+
return viaReturn;
|
|
1828
|
+
}
|
|
1829
|
+
return { kind: "localfn:" + callee.text, path: [] };
|
|
1830
|
+
}
|
|
1831
|
+
if (ts.isPropertyAccessExpression(callee)) {
|
|
1832
|
+
const name = callee.name.text;
|
|
1833
|
+
const objText = callee.expression.getText();
|
|
1834
|
+
if (["JSON", "Object", "Array", "Promise"].includes(objText))
|
|
1835
|
+
return e.arguments[0]
|
|
1836
|
+
? originOf(e.arguments[0], depth + 1)
|
|
1837
|
+
: undefined;
|
|
1838
|
+
// vi.mocked(x) is x; vi.importActual('~/x') is the real module
|
|
1839
|
+
if ((objText === "vi" || objText === "jest") &&
|
|
1840
|
+
name === "mocked" &&
|
|
1841
|
+
e.arguments[0])
|
|
1842
|
+
return originOf(e.arguments[0], depth + 1);
|
|
1843
|
+
if ((objText === "vi" || objText === "jest") &&
|
|
1844
|
+
(name === "importActual" || name === "requireActual") &&
|
|
1845
|
+
e.arguments[0] &&
|
|
1846
|
+
ts.isStringLiteralLike(e.arguments[0]))
|
|
1847
|
+
return moduleOrigin(e.arguments[0].text, e.getSourceFile());
|
|
1848
|
+
const base = originOf(callee.expression, depth + 1);
|
|
1849
|
+
if (!base)
|
|
1850
|
+
return undefined;
|
|
1851
|
+
const o = { ...base, path: [...base.path, name + "()"] };
|
|
1852
|
+
// promise.catch(e => e) carries the rejection; promise.then(onOk, onErr) carries either
|
|
1853
|
+
if (name === "catch" && e.arguments[0])
|
|
1854
|
+
o.thrown = "only";
|
|
1855
|
+
else if (name === "then" && e.arguments.length >= 2)
|
|
1856
|
+
o.thrown = "also";
|
|
1857
|
+
if (name === "get" &&
|
|
1858
|
+
e.arguments[0] &&
|
|
1859
|
+
ts.isStringLiteralLike(e.arguments[0]))
|
|
1860
|
+
o.facet = e.arguments[0].text.toLowerCase();
|
|
1861
|
+
if (name === "includes" &&
|
|
1862
|
+
e.arguments[0] &&
|
|
1863
|
+
ts.isStringLiteralLike(e.arguments[0]))
|
|
1864
|
+
o.facet = "includes:" + e.arguments[0].text;
|
|
1865
|
+
// `.filter(line => line.startsWith('X'))` / `.filter(line => /re/.test(line))`: a total assertion on the
|
|
1866
|
+
// filtered subset pins only the messages the predicate selects
|
|
1867
|
+
if ((name === "filter" || name === "find" || name === "some") &&
|
|
1868
|
+
e.arguments[0]) {
|
|
1869
|
+
const src = e.arguments[0].getText();
|
|
1870
|
+
const lit = /(?:startsWith|includes|endsWith)\((['"`])(.*?)\1\)/.exec(src);
|
|
1871
|
+
const re = /(\/(?:[^/\\]|\\.)+\/[a-z]*)\.test\(/.exec(src);
|
|
1872
|
+
if (lit)
|
|
1873
|
+
o.facet = "includes:" + lit[2];
|
|
1874
|
+
else if (re)
|
|
1875
|
+
o.facet = "pattern:" + re[1].replace(/^\/|\/[a-z]*$/g, "");
|
|
1876
|
+
}
|
|
1877
|
+
return o;
|
|
1878
|
+
}
|
|
1879
|
+
// await import('~/lib/x'): the real module
|
|
1880
|
+
if (e.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
|
1881
|
+
e.arguments[0] &&
|
|
1882
|
+
ts.isStringLiteralLike(e.arguments[0]))
|
|
1883
|
+
return moduleOrigin(e.arguments[0].text, e.getSourceFile());
|
|
1884
|
+
return undefined;
|
|
1885
|
+
}
|
|
1886
|
+
if (ts.isPropertyAccessExpression(e)) {
|
|
1887
|
+
// window.Beacon / globalThis.prisma: a test-installed mock, else production global state read back
|
|
1888
|
+
if (ts.isIdentifier(e.expression) && GLOBAL_ROOTS.has(e.expression.text))
|
|
1889
|
+
return (globalSinkOrigin(e.name.text, e.getSourceFile()) ?? {
|
|
1890
|
+
kind: "global",
|
|
1891
|
+
path: [e.name.text],
|
|
1892
|
+
});
|
|
1893
|
+
const b = originOf(e.expression, depth + 1);
|
|
1894
|
+
if (b?.kind.startsWith("localfn:") && !b.path.length)
|
|
1895
|
+
return (localHelperProperty(b.kind.slice(8), e.name.text, e.getSourceFile(), depth + 1) ?? { ...b, path: [e.name.text] });
|
|
1896
|
+
// (await import('~/x')).fn: an export of a production module
|
|
1897
|
+
if (b?.kind.startsWith("module:") && !b.path.length)
|
|
1898
|
+
return b.kind.slice(7)
|
|
1899
|
+
? { kind: "prod:" + e.name.text, path: [] }
|
|
1900
|
+
: undefined;
|
|
1901
|
+
return b ? { ...b, path: [...b.path, e.name.text] } : undefined;
|
|
1902
|
+
}
|
|
1903
|
+
if (ts.isElementAccessExpression(e)) {
|
|
1904
|
+
const b = originOf(e.expression, depth + 1);
|
|
1905
|
+
return b ? { ...b, path: [...b.path, "[]"] } : undefined;
|
|
1906
|
+
}
|
|
1907
|
+
if (ts.isConditionalExpression(e))
|
|
1908
|
+
return undefined; // selected branch needs value-flow evidence
|
|
1909
|
+
if (ts.isBinaryExpression(e)) {
|
|
1910
|
+
// Comma and simple assignment evaluate both operands but return only the right.
|
|
1911
|
+
if (e.operatorToken.kind === ts.SyntaxKind.CommaToken ||
|
|
1912
|
+
e.operatorToken.kind === ts.SyntaxKind.EqualsToken)
|
|
1913
|
+
return originOf(e.right, depth + 1);
|
|
1914
|
+
// Other operators transform or select values; do not guess their origin
|
|
1915
|
+
// from the first operand whose name can be resolved.
|
|
1916
|
+
return undefined;
|
|
1917
|
+
}
|
|
1918
|
+
if (ts.isPrefixUnaryExpression(e))
|
|
1919
|
+
return originOf(e.operand, depth + 1);
|
|
1920
|
+
if (ts.isIdentifier(e)) {
|
|
1921
|
+
// imports first, by their import declaration: alias resolution can fail on deep re-exports
|
|
1922
|
+
const raw = checker.getSymbolAtLocation(e)?.declarations?.[0];
|
|
1923
|
+
if (raw &&
|
|
1924
|
+
(ts.isImportSpecifier(raw) ||
|
|
1925
|
+
ts.isImportClause(raw) ||
|
|
1926
|
+
ts.isNamespaceImport(raw))) {
|
|
1927
|
+
const spec = (raw.getSourceFile() &&
|
|
1928
|
+
(ts.isImportSpecifier(raw)
|
|
1929
|
+
? raw.parent.parent.parent
|
|
1930
|
+
: ts.isImportClause(raw)
|
|
1931
|
+
? raw.parent
|
|
1932
|
+
: raw.parent.parent)).moduleSpecifier
|
|
1933
|
+
.getText()
|
|
1934
|
+
.slice(1, -1);
|
|
1935
|
+
if (/^@testing-library\//.test(spec) &&
|
|
1936
|
+
["screen", "within", "render"].includes(e.text))
|
|
1937
|
+
return { kind: "dom", path: [] };
|
|
1938
|
+
// expect($setBlocksType).toHaveBeenCalledWith(...): an import the test file mocked is a sink
|
|
1939
|
+
const mocked = mockedImportOrigin(e);
|
|
1940
|
+
if (mocked)
|
|
1941
|
+
return mocked;
|
|
1942
|
+
const target = declOf(e);
|
|
1943
|
+
if (target && isProdFile(target.getSourceFile()))
|
|
1944
|
+
return { kind: "prod:" + declaredName(target, e.text), path: [] };
|
|
1945
|
+
if (!target || isExternalDecl(target))
|
|
1946
|
+
return { kind: "import:" + e.text, path: [] };
|
|
1947
|
+
}
|
|
1948
|
+
const d = declOf(e);
|
|
1949
|
+
// a parameter of a local helper whose call we are following: use the call's argument
|
|
1950
|
+
if (d && ts.isParameter(d)) {
|
|
1951
|
+
const bound = boundArgument(d);
|
|
1952
|
+
if (bound)
|
|
1953
|
+
return originOf(bound, depth + 1);
|
|
1954
|
+
const cell = eachTableOrigin(d, depth);
|
|
1955
|
+
if (cell)
|
|
1956
|
+
return cell;
|
|
1957
|
+
}
|
|
1958
|
+
if (!d || d.getSourceFile().isDeclarationFile) {
|
|
1959
|
+
// an undeclared (or ambient) identifier the test file installed as a global object of mocks
|
|
1960
|
+
const g = globalSinkOrigin(e.text, e.getSourceFile());
|
|
1961
|
+
if (g)
|
|
1962
|
+
return g;
|
|
1963
|
+
if (e.text === "console")
|
|
1964
|
+
return { kind: "console", path: [] };
|
|
1965
|
+
if (!d)
|
|
1966
|
+
return undefined;
|
|
1967
|
+
}
|
|
1968
|
+
if (ts.isVariableDeclaration(d)) {
|
|
1969
|
+
const initializer = initOf(d);
|
|
1970
|
+
if (!initializer) {
|
|
1971
|
+
const forOf = d.parent.parent;
|
|
1972
|
+
if (ts.isForOfStatement(forOf)) {
|
|
1973
|
+
const b = originOf(forOf.expression, depth + 1);
|
|
1974
|
+
return b ? { ...b, path: [...b.path, "[]"] } : undefined;
|
|
1975
|
+
}
|
|
1976
|
+
return undefined;
|
|
1977
|
+
}
|
|
1978
|
+
const init = unwrap(initializer);
|
|
1979
|
+
// an object of mocks (plain or vi.hoisted): paths into it are sinks
|
|
1980
|
+
const hoisted = hoistedObject(init);
|
|
1981
|
+
if (hoisted && containsMock(hoisted))
|
|
1982
|
+
return { kind: "sinkobj:" + e.text, path: [], obj: hoisted };
|
|
1983
|
+
if (ts.isArrayLiteralExpression(init) ||
|
|
1984
|
+
ts.isObjectLiteralExpression(init) ||
|
|
1985
|
+
isMockFactory(init))
|
|
1986
|
+
return { kind: "sink:" + e.text, path: [] };
|
|
1987
|
+
// test-owned accumulator fed from a child process stream: let output = ''; proc.stdout.on('data', c => output += c)
|
|
1988
|
+
if (ts.isStringLiteralLike(init) && init.text === "") {
|
|
1989
|
+
const sym = symbolOf(d.name);
|
|
1990
|
+
const scope = enclosingFunction(d) ?? d.getSourceFile();
|
|
1991
|
+
let fed;
|
|
1992
|
+
const scan = (n) => {
|
|
1993
|
+
if (fed)
|
|
1994
|
+
return;
|
|
1995
|
+
if (ts.isBinaryExpression(n) &&
|
|
1996
|
+
n.operatorToken.kind === ts.SyntaxKind.PlusEqualsToken &&
|
|
1997
|
+
ts.isIdentifier(n.left) &&
|
|
1998
|
+
symbolOf(n.left) === sym) {
|
|
1999
|
+
let p = n;
|
|
2000
|
+
while (p && !fed) {
|
|
2001
|
+
if (ts.isCallExpression(p) &&
|
|
2002
|
+
ts.isPropertyAccessExpression(p.expression) &&
|
|
2003
|
+
(p.expression.name.text === "on" ||
|
|
2004
|
+
p.expression.name.text === "once")) {
|
|
2005
|
+
const recv = p.expression.expression.getText();
|
|
2006
|
+
fed = /stderr/.test(recv) ? "proc-stderr" : "proc-stdout";
|
|
2007
|
+
}
|
|
2008
|
+
p = p.parent;
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
ts.forEachChild(n, scan);
|
|
2012
|
+
};
|
|
2013
|
+
scan(scope);
|
|
2014
|
+
if (fed)
|
|
2015
|
+
return { kind: fed, path: [] };
|
|
2016
|
+
}
|
|
2017
|
+
// promise resolved from a child process 'close'/'exit' event carries the exit code
|
|
2018
|
+
if (ts.isNewExpression(init) &&
|
|
2019
|
+
init.expression.getText() === "Promise" &&
|
|
2020
|
+
/\.(once|on)\(\s*['"](close|exit)['"]/.test(init.getText()))
|
|
2021
|
+
return { kind: "proc-exit", path: [] };
|
|
2022
|
+
// Mutable scalar aliases need reaching definitions, not the initializer
|
|
2023
|
+
// or first assignment anywhere in the file. Container/stream cases above
|
|
2024
|
+
// retain their separate models.
|
|
2025
|
+
if (!(d.parent.flags & ts.NodeFlags.Const))
|
|
2026
|
+
return undefined;
|
|
2027
|
+
return originOf(initializer, depth + 1);
|
|
2028
|
+
}
|
|
2029
|
+
if (ts.isBindingElement(d)) {
|
|
2030
|
+
const pattern = d.parent;
|
|
2031
|
+
const decl = pattern.parent;
|
|
2032
|
+
// ({ action, url }) => ... of a describe.each table
|
|
2033
|
+
if (ts.isParameter(decl)) {
|
|
2034
|
+
const cell = eachTableOrigin(d, depth);
|
|
2035
|
+
if (cell)
|
|
2036
|
+
return cell;
|
|
2037
|
+
}
|
|
2038
|
+
if (ts.isVariableDeclaration(decl) && decl.initializer) {
|
|
2039
|
+
const b = originOf(decl.initializer, depth + 1);
|
|
2040
|
+
const prop = ts.isObjectBindingPattern(pattern)
|
|
2041
|
+
? (d.propertyName ?? d.name).getText()
|
|
2042
|
+
: "[]";
|
|
2043
|
+
// destructured from a local test helper: follow the helper's returned object property
|
|
2044
|
+
if (b?.kind.startsWith("localfn:") && prop !== "[]")
|
|
2045
|
+
return (localHelperProperty(b.kind.slice(8), prop, d.getSourceFile(), depth + 1) ?? { ...b, path: [...b.path, prop] });
|
|
2046
|
+
return b ? { ...b, path: [...b.path, prop] } : undefined;
|
|
2047
|
+
}
|
|
2048
|
+
return { kind: "literal", path: [] };
|
|
2049
|
+
}
|
|
2050
|
+
if (ts.isParameter(d)) {
|
|
2051
|
+
const fn = d.parent;
|
|
2052
|
+
if ((ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) &&
|
|
2053
|
+
ts.isCallExpression(fn.parent) &&
|
|
2054
|
+
ts.isPropertyAccessExpression(fn.parent.expression) &&
|
|
2055
|
+
ITERATORS.has(fn.parent.expression.name.text)) {
|
|
2056
|
+
const b = originOf(fn.parent.expression.expression, depth + 1);
|
|
2057
|
+
return b ? { ...b, path: [...b.path, "[]"] } : undefined;
|
|
2058
|
+
}
|
|
2059
|
+
return undefined;
|
|
2060
|
+
}
|
|
2061
|
+
if (isExternalDecl(d))
|
|
2062
|
+
return e.text === "screen"
|
|
2063
|
+
? { kind: "dom", path: [] }
|
|
2064
|
+
: { kind: "import:" + e.text, path: [] };
|
|
2065
|
+
if (isProdFile(d.getSourceFile()))
|
|
2066
|
+
return { kind: "prod:" + e.text, path: [] };
|
|
2067
|
+
return undefined;
|
|
2068
|
+
}
|
|
2069
|
+
return undefined;
|
|
2070
|
+
}
|
|
2071
|
+
function boundariesOf(o) {
|
|
2072
|
+
if (o.alts?.length) {
|
|
2073
|
+
// every row of an it.each table: the same path read off each alternative root
|
|
2074
|
+
const all = [
|
|
2075
|
+
...boundariesOf({ ...o, alts: undefined }),
|
|
2076
|
+
...o.alts.flatMap((a) => boundariesOf({
|
|
2077
|
+
...a,
|
|
2078
|
+
path: [...a.path, ...o.path],
|
|
2079
|
+
facet: o.facet ?? a.facet,
|
|
2080
|
+
thrown: o.thrown,
|
|
2081
|
+
alts: undefined,
|
|
2082
|
+
})),
|
|
2083
|
+
];
|
|
2084
|
+
const seen = new Set();
|
|
2085
|
+
return all.filter((b) => {
|
|
2086
|
+
const k = `${b.boundary}|${b.facet ?? ""}`;
|
|
2087
|
+
if (seen.has(k))
|
|
2088
|
+
return false;
|
|
2089
|
+
seen.add(k);
|
|
2090
|
+
return true;
|
|
2091
|
+
});
|
|
2092
|
+
}
|
|
2093
|
+
const p = o.path;
|
|
2094
|
+
const has = (...names) => names.some((n) => p.includes(n));
|
|
2095
|
+
const facetPath = (from) => p
|
|
2096
|
+
.slice(from)
|
|
2097
|
+
.filter((x) => !x.endsWith("()") && x !== "[]")
|
|
2098
|
+
.join(".");
|
|
2099
|
+
switch (o.kind) {
|
|
2100
|
+
case "helper:rpc":
|
|
2101
|
+
if (p[0] === "response") {
|
|
2102
|
+
if (has("status", "ok"))
|
|
2103
|
+
return [{ boundary: "client-status" }];
|
|
2104
|
+
if (has("headers"))
|
|
2105
|
+
return [{ boundary: "client-header", facet: o.facet ?? "*" }];
|
|
2106
|
+
return [{ boundary: "client-status" }];
|
|
2107
|
+
}
|
|
2108
|
+
if (p[0] === "messages")
|
|
2109
|
+
return [{ boundary: "client-message", facet: facetPath(1) }];
|
|
2110
|
+
return [{ boundary: "client-status" }, { boundary: "client-message" }];
|
|
2111
|
+
case "helper:fetch":
|
|
2112
|
+
if (has("status", "ok"))
|
|
2113
|
+
return [{ boundary: "client-status" }];
|
|
2114
|
+
if (has("headers"))
|
|
2115
|
+
return [{ boundary: "client-header", facet: o.facet ?? "*" }];
|
|
2116
|
+
if (has("text()", "json()"))
|
|
2117
|
+
return [{ boundary: "client-message" }];
|
|
2118
|
+
return [{ boundary: "client-status" }];
|
|
2119
|
+
case "helper:pendingRpc":
|
|
2120
|
+
if (has("status"))
|
|
2121
|
+
return [{ boundary: "client-status" }];
|
|
2122
|
+
if (has("text"))
|
|
2123
|
+
return [{ boundary: "client-message" }];
|
|
2124
|
+
return [{ boundary: "client-status" }];
|
|
2125
|
+
case "helper:launchGateway":
|
|
2126
|
+
if (has("errors()"))
|
|
2127
|
+
return [{ boundary: "stderr" }];
|
|
2128
|
+
if (has("output()"))
|
|
2129
|
+
return [{ boundary: "stdout" }];
|
|
2130
|
+
if (has("exited"))
|
|
2131
|
+
return [{ boundary: "exit" }];
|
|
2132
|
+
if (has("ready()", "waitFor()"))
|
|
2133
|
+
return [{ boundary: "stdout" }, { boundary: "stderr" }];
|
|
2134
|
+
return [];
|
|
2135
|
+
case "helper:stdioRpc":
|
|
2136
|
+
return [{ boundary: "client-message", facet: facetPath(0) }];
|
|
2137
|
+
case "helper:once":
|
|
2138
|
+
return [
|
|
2139
|
+
{
|
|
2140
|
+
boundary: o.facet === "message" ? "client-message" : "client-lifecycle",
|
|
2141
|
+
},
|
|
2142
|
+
];
|
|
2143
|
+
case "helper:lifecycleControl":
|
|
2144
|
+
return has("started") ? [{ boundary: "child-stdin" }] : [];
|
|
2145
|
+
case "helper:wireUpstream":
|
|
2146
|
+
return [{ boundary: "upstream" }];
|
|
2147
|
+
case "helper:recordingPeer":
|
|
2148
|
+
return has("received()") ? [{ boundary: "child-stdin" }] : [];
|
|
2149
|
+
case "proc-stdout":
|
|
2150
|
+
return [{ boundary: "stdout" }];
|
|
2151
|
+
case "proc-stderr":
|
|
2152
|
+
return [{ boundary: "stderr" }];
|
|
2153
|
+
case "proc-exit":
|
|
2154
|
+
return [{ boundary: "exit" }];
|
|
2155
|
+
case "sdk-client":
|
|
2156
|
+
if (has("sessionId"))
|
|
2157
|
+
return [{ boundary: "client-header", facet: "mcp-session-id" }];
|
|
2158
|
+
return [{ boundary: "client-message", facet: facetPath(0) }];
|
|
2159
|
+
case "dom":
|
|
2160
|
+
return [{ boundary: "dom" }];
|
|
2161
|
+
case "console":
|
|
2162
|
+
// expect(console.error).toHaveBeenCalled…: a spied stream; the facet keeps console.dir from pinning console.log sites
|
|
2163
|
+
return p[0]
|
|
2164
|
+
? [
|
|
2165
|
+
{
|
|
2166
|
+
boundary: p[0] === "error" || p[0] === "warn" ? "stderr" : "stdout",
|
|
2167
|
+
facet: "console." + p[0],
|
|
2168
|
+
},
|
|
2169
|
+
]
|
|
2170
|
+
: [];
|
|
2171
|
+
case "global":
|
|
2172
|
+
// expect(globalThis.prisma).toBe(...): production global state read back
|
|
2173
|
+
return p[0] ? [{ boundary: "global:" + p[0] }] : [];
|
|
2174
|
+
default:
|
|
2175
|
+
if (o.kind.startsWith("prod:")) {
|
|
2176
|
+
const fn = o.kind.slice(5);
|
|
2177
|
+
const ret = {
|
|
2178
|
+
boundary: "return:" + fn,
|
|
2179
|
+
facet: facetPath(0),
|
|
2180
|
+
};
|
|
2181
|
+
if (o.thrown === "only")
|
|
2182
|
+
return [{ boundary: "throw:" + fn }];
|
|
2183
|
+
if (o.thrown === "also")
|
|
2184
|
+
return [{ boundary: "throw:" + fn }, ret];
|
|
2185
|
+
return [ret];
|
|
2186
|
+
}
|
|
2187
|
+
// new PrismaSessionStorage(...).storeSession(...): the method's return
|
|
2188
|
+
if (o.kind.startsWith("new:") && p[0]?.endsWith("()"))
|
|
2189
|
+
return [
|
|
2190
|
+
{
|
|
2191
|
+
boundary: `return:${o.kind.slice(4)}.${p[0].slice(0, -2)}`,
|
|
2192
|
+
facet: facetPath(1),
|
|
2193
|
+
},
|
|
2194
|
+
];
|
|
2195
|
+
if (o.kind.startsWith("sink:"))
|
|
2196
|
+
return [{ boundary: o.kind }];
|
|
2197
|
+
if (o.kind.startsWith("sinkobj:") && o.obj) {
|
|
2198
|
+
// mocks.fetcher.submit.mock.calls → sink:mocks.fetcher.submit; mocks.fetcher.state → test input, not a sink
|
|
2199
|
+
const leaf = mockLeafPath(o.obj, p.filter((x) => !x.endsWith("()") && x !== "[]"));
|
|
2200
|
+
return leaf
|
|
2201
|
+
? [{ boundary: `sink:${o.kind.slice(8)}.${leaf.join(".")}` }]
|
|
2202
|
+
: [];
|
|
2203
|
+
}
|
|
2204
|
+
// t.mock.method(console, 'log'): a test-owned replacement of a stream sink
|
|
2205
|
+
if (o.kind.startsWith("mock:console."))
|
|
2206
|
+
return [
|
|
2207
|
+
{ boundary: o.kind.endsWith(".error") ? "stderr" : "stdout" },
|
|
2208
|
+
];
|
|
2209
|
+
return [];
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
/** A local test helper `function name() { ...; return { prop: value } }`: the origin of `value`. */
|
|
2213
|
+
const localFnCache = new Map();
|
|
2214
|
+
function localFunctionNode(name, sf) {
|
|
2215
|
+
const key = `${sf.fileName}|${name}`;
|
|
2216
|
+
const cached = localFnCache.get(key);
|
|
2217
|
+
if (cached !== undefined)
|
|
2218
|
+
return cached ?? undefined;
|
|
2219
|
+
let found;
|
|
2220
|
+
const visit = (n) => {
|
|
2221
|
+
if (found)
|
|
2222
|
+
return;
|
|
2223
|
+
if (ts.isFunctionDeclaration(n) && n.name?.text === name)
|
|
2224
|
+
found = n;
|
|
2225
|
+
else if (ts.isVariableDeclaration(n) &&
|
|
2226
|
+
ts.isIdentifier(n.name) &&
|
|
2227
|
+
n.name.text === name &&
|
|
2228
|
+
n.initializer &&
|
|
2229
|
+
(ts.isArrowFunction(unwrap(n.initializer)) ||
|
|
2230
|
+
ts.isFunctionExpression(unwrap(n.initializer))))
|
|
2231
|
+
found = unwrap(n.initializer);
|
|
2232
|
+
ts.forEachChild(n, visit);
|
|
2233
|
+
};
|
|
2234
|
+
visit(sf);
|
|
2235
|
+
localFnCache.set(key, found ?? null);
|
|
2236
|
+
return found;
|
|
2237
|
+
}
|
|
2238
|
+
/** The expression a local helper returns under property `prop` of its returned object literal. */
|
|
2239
|
+
function localHelperPropertyNode(name, prop, sf) {
|
|
2240
|
+
const fn = localFunctionNode(name, sf);
|
|
2241
|
+
if (!fn)
|
|
2242
|
+
return undefined;
|
|
2243
|
+
let result;
|
|
2244
|
+
const fromObject = (obj) => {
|
|
2245
|
+
const p = obj.properties.find((x) => x.name?.getText() === prop);
|
|
2246
|
+
if (p && ts.isPropertyAssignment(p))
|
|
2247
|
+
result = p.initializer;
|
|
2248
|
+
else if (p && ts.isShorthandPropertyAssignment(p))
|
|
2249
|
+
result = p.name;
|
|
2250
|
+
};
|
|
2251
|
+
if (ts.isArrowFunction(fn) &&
|
|
2252
|
+
!ts.isBlock(fn.body) &&
|
|
2253
|
+
ts.isObjectLiteralExpression(unwrap(fn.body)))
|
|
2254
|
+
fromObject(unwrap(fn.body));
|
|
2255
|
+
const visit = (n) => {
|
|
2256
|
+
if (result)
|
|
2257
|
+
return;
|
|
2258
|
+
if (ts.isReturnStatement(n) &&
|
|
2259
|
+
n.expression &&
|
|
2260
|
+
ts.isObjectLiteralExpression(unwrap(n.expression)))
|
|
2261
|
+
fromObject(unwrap(n.expression));
|
|
2262
|
+
ts.forEachChild(n, visit);
|
|
2263
|
+
};
|
|
2264
|
+
visit(fn);
|
|
2265
|
+
return result;
|
|
2266
|
+
}
|
|
2267
|
+
/** While following a local helper call, its parameters stand for the call's arguments. */
|
|
2268
|
+
const paramBindings = [];
|
|
2269
|
+
function boundArgument(d) {
|
|
2270
|
+
for (let i = paramBindings.length - 1; i >= 0; i--) {
|
|
2271
|
+
const bound = paramBindings[i].get(d);
|
|
2272
|
+
if (bound)
|
|
2273
|
+
return bound;
|
|
2274
|
+
}
|
|
2275
|
+
return undefined;
|
|
2276
|
+
}
|
|
2277
|
+
/**
|
|
2278
|
+
* `describe.each(rows)('%s', (a, b) => ...)` / `it.each(rows)('%s', ({ action }) => ...)`: a callback parameter
|
|
2279
|
+
* stands for one cell per row. Returns the row cells the parameter (or destructured property) can hold.
|
|
2280
|
+
*/
|
|
2281
|
+
function eachTableCells(d) {
|
|
2282
|
+
let param;
|
|
2283
|
+
let prop;
|
|
2284
|
+
if (ts.isParameter(d))
|
|
2285
|
+
param = d;
|
|
2286
|
+
else if (ts.isBindingElement(d) &&
|
|
2287
|
+
ts.isObjectBindingPattern(d.parent) &&
|
|
2288
|
+
ts.isParameter(d.parent.parent)) {
|
|
2289
|
+
param = d.parent.parent;
|
|
2290
|
+
prop = (d.propertyName ?? d.name).getText();
|
|
2291
|
+
}
|
|
2292
|
+
if (!param)
|
|
2293
|
+
return undefined;
|
|
2294
|
+
const cb = param.parent;
|
|
2295
|
+
if (!(ts.isArrowFunction(cb) || ts.isFunctionExpression(cb)) ||
|
|
2296
|
+
!ts.isCallExpression(cb.parent))
|
|
2297
|
+
return undefined;
|
|
2298
|
+
const inner = unwrap(cb.parent.expression);
|
|
2299
|
+
if (!ts.isCallExpression(inner) ||
|
|
2300
|
+
!/^(describe|it|test)(\.\w+)*\.each$/.test(inner.expression.getText()) ||
|
|
2301
|
+
!inner.arguments[0])
|
|
2302
|
+
return undefined;
|
|
2303
|
+
let table = unwrap(inner.arguments[0]);
|
|
2304
|
+
// const endpoints = [...]; describe.each(endpoints)(...)
|
|
2305
|
+
if (ts.isIdentifier(table)) {
|
|
2306
|
+
const td = declOf(table);
|
|
2307
|
+
const init = td && ts.isVariableDeclaration(td) ? initOf(td) : undefined;
|
|
2308
|
+
if (init)
|
|
2309
|
+
table = unwrap(init);
|
|
2310
|
+
}
|
|
2311
|
+
if (!ts.isArrayLiteralExpression(table))
|
|
2312
|
+
return undefined;
|
|
2313
|
+
const index = cb.parameters.indexOf(param);
|
|
2314
|
+
const cells = [];
|
|
2315
|
+
for (const row of table.elements.map(unwrap)) {
|
|
2316
|
+
if (ts.isArrayLiteralExpression(row)) {
|
|
2317
|
+
if (row.elements[index])
|
|
2318
|
+
cells.push(row.elements[index]);
|
|
2319
|
+
continue;
|
|
2320
|
+
}
|
|
2321
|
+
if (index !== 0)
|
|
2322
|
+
continue;
|
|
2323
|
+
if (!prop) {
|
|
2324
|
+
cells.push(row);
|
|
2325
|
+
continue;
|
|
2326
|
+
}
|
|
2327
|
+
if (ts.isObjectLiteralExpression(row)) {
|
|
2328
|
+
const p = row.properties.find((x) => x.name?.getText() === prop);
|
|
2329
|
+
if (p && ts.isPropertyAssignment(p))
|
|
2330
|
+
cells.push(p.initializer);
|
|
2331
|
+
else if (p && ts.isShorthandPropertyAssignment(p))
|
|
2332
|
+
cells.push(p.name);
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
return cells.length ? cells : undefined;
|
|
2336
|
+
}
|
|
2337
|
+
/** Origin of a table-driven parameter: the first row's cell, with the other rows as alternatives. */
|
|
2338
|
+
function eachTableOrigin(d, depth) {
|
|
2339
|
+
const cells = eachTableCells(d);
|
|
2340
|
+
if (!cells)
|
|
2341
|
+
return undefined;
|
|
2342
|
+
const origins = cells
|
|
2343
|
+
.map((c) => originOf(c, depth + 1))
|
|
2344
|
+
.filter((o) => !!o);
|
|
2345
|
+
if (!origins.length)
|
|
2346
|
+
return undefined;
|
|
2347
|
+
const [first, ...rest] = origins;
|
|
2348
|
+
return rest.length ? { ...first, alts: rest } : first;
|
|
2349
|
+
}
|
|
2350
|
+
/** The function a variable holds: `const run = () => ...`, or `const { getImage } = helper()` where helper returns `{ getImage: () => ... }`. */
|
|
2351
|
+
function heldFunction(d) {
|
|
2352
|
+
let v;
|
|
2353
|
+
if (ts.isVariableDeclaration(d))
|
|
2354
|
+
v = initOf(d);
|
|
2355
|
+
else if (ts.isBindingElement(d) &&
|
|
2356
|
+
ts.isObjectBindingPattern(d.parent) &&
|
|
2357
|
+
ts.isVariableDeclaration(d.parent.parent) &&
|
|
2358
|
+
d.parent.parent.initializer) {
|
|
2359
|
+
const init = unwrap(d.parent.parent.initializer);
|
|
2360
|
+
if (ts.isCallExpression(init) && ts.isIdentifier(init.expression))
|
|
2361
|
+
v = localHelperPropertyNode(init.expression.text, (d.propertyName ?? d.name).getText(), d.getSourceFile());
|
|
2362
|
+
}
|
|
2363
|
+
const u = v ? unwrap(v) : undefined;
|
|
2364
|
+
return u && (ts.isArrowFunction(u) || ts.isFunctionExpression(u))
|
|
2365
|
+
? u
|
|
2366
|
+
: undefined;
|
|
2367
|
+
}
|
|
2368
|
+
/** Origin of a local helper call through its return expression (not an object literal). */
|
|
2369
|
+
function localFnCallOrigin(call, fn, depth) {
|
|
2370
|
+
if (depth > 16 || paramBindings.length > 4)
|
|
2371
|
+
return undefined;
|
|
2372
|
+
let ret;
|
|
2373
|
+
const body = fn.body;
|
|
2374
|
+
if (!body)
|
|
2375
|
+
return undefined;
|
|
2376
|
+
if (!ts.isBlock(body))
|
|
2377
|
+
ret = unwrap(body);
|
|
2378
|
+
else {
|
|
2379
|
+
const visit = (n) => {
|
|
2380
|
+
if (ret)
|
|
2381
|
+
return;
|
|
2382
|
+
if (ts.isReturnStatement(n) && n.expression)
|
|
2383
|
+
ret = unwrap(n.expression);
|
|
2384
|
+
else if (!ts.isFunctionLike(n))
|
|
2385
|
+
ts.forEachChild(n, visit);
|
|
2386
|
+
};
|
|
2387
|
+
visit(body);
|
|
2388
|
+
}
|
|
2389
|
+
if (!ret || ts.isObjectLiteralExpression(ret))
|
|
2390
|
+
return undefined;
|
|
2391
|
+
const bindings = new Map();
|
|
2392
|
+
fn.parameters.forEach((p, i) => {
|
|
2393
|
+
if (call.arguments[i])
|
|
2394
|
+
bindings.set(p, call.arguments[i]);
|
|
2395
|
+
});
|
|
2396
|
+
paramBindings.push(bindings);
|
|
2397
|
+
try {
|
|
2398
|
+
return originOf(ret, depth + 1);
|
|
2399
|
+
}
|
|
2400
|
+
finally {
|
|
2401
|
+
paramBindings.pop();
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
function localHelperProperty(name, prop, sf, depth) {
|
|
2405
|
+
const node = localHelperPropertyNode(name, prop, sf);
|
|
2406
|
+
return node ? originOf(node, depth) : undefined;
|
|
2407
|
+
}
|
|
2408
|
+
/** Regex source of a pattern argument: a literal, or new RegExp(string | template) with placeholders as wildcards. */
|
|
2409
|
+
function regexSource(arg) {
|
|
2410
|
+
const a = unwrap(arg);
|
|
2411
|
+
if (ts.isRegularExpressionLiteral(a))
|
|
2412
|
+
return a.getText().replace(/^\/|\/[a-z]*$/g, "");
|
|
2413
|
+
if (ts.isNewExpression(a) &&
|
|
2414
|
+
a.expression.getText() === "RegExp" &&
|
|
2415
|
+
a.arguments?.[0]) {
|
|
2416
|
+
const p = unwrap(a.arguments[0]);
|
|
2417
|
+
if (ts.isStringLiteralLike(p))
|
|
2418
|
+
return p.text;
|
|
2419
|
+
if (ts.isTemplateExpression(p))
|
|
2420
|
+
return (p.head.text +
|
|
2421
|
+
p.templateSpans.map((s) => ".*" + s.literal.text).join(""));
|
|
2422
|
+
}
|
|
2423
|
+
if (ts.isConditionalExpression(a)) {
|
|
2424
|
+
const l = regexSource(a.whenTrue);
|
|
2425
|
+
const r = regexSource(a.whenFalse);
|
|
2426
|
+
return l && r ? `${l}|${r}` : (l ?? r);
|
|
2427
|
+
}
|
|
2428
|
+
return undefined;
|
|
2429
|
+
}
|
|
2430
|
+
/** The production component behind a JSX tag, by declared name (matches inventory `owner`). */
|
|
2431
|
+
function componentOfTag(tag) {
|
|
2432
|
+
const root = ts.isIdentifier(tag)
|
|
2433
|
+
? tag
|
|
2434
|
+
: ts.isPropertyAccessExpression(tag)
|
|
2435
|
+
? rootOfExpr(tag)
|
|
2436
|
+
: undefined;
|
|
2437
|
+
if (!root || !ts.isIdentifier(root) || /^[a-z]/.test(root.text))
|
|
2438
|
+
return undefined;
|
|
2439
|
+
const d = declOf(root);
|
|
2440
|
+
if (!d || !isProdFile(d.getSourceFile()))
|
|
2441
|
+
return undefined;
|
|
2442
|
+
if (ts.isFunctionDeclaration(d))
|
|
2443
|
+
return d.name?.text;
|
|
2444
|
+
if (ts.isVariableDeclaration(d) && ts.isIdentifier(d.name))
|
|
2445
|
+
return d.name.text;
|
|
2446
|
+
return undefined;
|
|
2447
|
+
}
|
|
2448
|
+
function jsxComponentsIn(node, acc) {
|
|
2449
|
+
const visit = (n) => {
|
|
2450
|
+
if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) {
|
|
2451
|
+
const c = componentOfTag(n.tagName);
|
|
2452
|
+
if (c)
|
|
2453
|
+
acc.add(c);
|
|
2454
|
+
}
|
|
2455
|
+
ts.forEachChild(n, visit);
|
|
2456
|
+
};
|
|
2457
|
+
visit(node);
|
|
2458
|
+
}
|
|
2459
|
+
/** Components rendered by a test, plus the production components their JSX renders (two levels). */
|
|
2460
|
+
function renderedComponents(roots) {
|
|
2461
|
+
const all = new Set(roots);
|
|
2462
|
+
let frontier = [...roots];
|
|
2463
|
+
for (let depth = 0; depth < 2 && frontier.length; depth++) {
|
|
2464
|
+
const next = [];
|
|
2465
|
+
for (const name of frontier)
|
|
2466
|
+
for (const sf of allFiles) {
|
|
2467
|
+
if (!isProdFile(sf))
|
|
2468
|
+
continue;
|
|
2469
|
+
const fn = functionByName(name, rel(sf));
|
|
2470
|
+
if (!fn)
|
|
2471
|
+
continue;
|
|
2472
|
+
const inner = new Set();
|
|
2473
|
+
jsxComponentsIn(fn, inner);
|
|
2474
|
+
for (const c of inner)
|
|
2475
|
+
if (!all.has(c)) {
|
|
2476
|
+
all.add(c);
|
|
2477
|
+
next.push(c);
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
frontier = next;
|
|
2481
|
+
}
|
|
2482
|
+
return all;
|
|
2483
|
+
}
|
|
2484
|
+
/** Diagnostic: assertion operands the origin model could not map, grouped by shape. */
|
|
2485
|
+
const unrecognized = new Map();
|
|
2486
|
+
function noteUnrecognized(arg, why) {
|
|
2487
|
+
const shape = arg
|
|
2488
|
+
.getText()
|
|
2489
|
+
.replace(/\s+/g, " ")
|
|
2490
|
+
.replace(/(['"`]).*?\1/g, "…")
|
|
2491
|
+
.replace(/\b\d+\b/g, "N")
|
|
2492
|
+
.slice(0, 45);
|
|
2493
|
+
const key = `${shape} [${why}]`;
|
|
2494
|
+
unrecognized.set(key, (unrecognized.get(key) ?? 0) + 1);
|
|
2495
|
+
}
|
|
2496
|
+
function analyzeTestBody(fn, file, line, name, inert = false) {
|
|
2497
|
+
const observations = [];
|
|
2498
|
+
const sinks = [];
|
|
2499
|
+
const rendered = new Set();
|
|
2500
|
+
const pending = [];
|
|
2501
|
+
/** the statements that compute `arg`: its own statement plus the declaring / assigning statements of the local variables it reads */
|
|
2502
|
+
const definingStatements = (arg) => {
|
|
2503
|
+
const sf = arg.getSourceFile();
|
|
2504
|
+
const key = (n) => {
|
|
2505
|
+
const { line, character } = sf.getLineAndCharacterOfPosition(n.getStart(sf));
|
|
2506
|
+
return `${relative(root, sf.fileName)}:${line + 1}:${character + 1}`;
|
|
2507
|
+
};
|
|
2508
|
+
const statementOf = (n) => {
|
|
2509
|
+
let cur = n;
|
|
2510
|
+
while (cur && !ts.isSourceFile(cur)) {
|
|
2511
|
+
if (ts.isStatement(cur) &&
|
|
2512
|
+
!ts.isBlock(cur) &&
|
|
2513
|
+
cur.parent &&
|
|
2514
|
+
(ts.isBlock(cur.parent) ||
|
|
2515
|
+
ts.isSourceFile(cur.parent) ||
|
|
2516
|
+
ts.isCaseClause(cur.parent) ||
|
|
2517
|
+
ts.isDefaultClause(cur.parent) ||
|
|
2518
|
+
ts.isModuleBlock(cur.parent)))
|
|
2519
|
+
return cur;
|
|
2520
|
+
cur = cur.parent;
|
|
2521
|
+
}
|
|
2522
|
+
return undefined;
|
|
2523
|
+
};
|
|
2524
|
+
const keys = new Set();
|
|
2525
|
+
const own = statementOf(arg);
|
|
2526
|
+
if (own)
|
|
2527
|
+
keys.add(key(own));
|
|
2528
|
+
const seen = new Set();
|
|
2529
|
+
const follow = (e, depth) => {
|
|
2530
|
+
if (depth > 3)
|
|
2531
|
+
return;
|
|
2532
|
+
const visit = (n) => {
|
|
2533
|
+
if (ts.isIdentifier(n)) {
|
|
2534
|
+
const d = declOf(n);
|
|
2535
|
+
if (d &&
|
|
2536
|
+
!seen.has(d) &&
|
|
2537
|
+
d.getSourceFile() === sf &&
|
|
2538
|
+
(ts.isVariableDeclaration(d) || ts.isBindingElement(d))) {
|
|
2539
|
+
seen.add(d);
|
|
2540
|
+
const decl = ts.isBindingElement(d) ? d.parent.parent : d;
|
|
2541
|
+
const st = statementOf(decl);
|
|
2542
|
+
if (st)
|
|
2543
|
+
keys.add(key(st));
|
|
2544
|
+
const init = ts.isVariableDeclaration(decl)
|
|
2545
|
+
? decl.initializer
|
|
2546
|
+
: undefined;
|
|
2547
|
+
if (init)
|
|
2548
|
+
follow(init, depth + 1);
|
|
2549
|
+
// `let x; ... x = compute()`: every assignment statement to the variable
|
|
2550
|
+
if (ts.isVariableDeclaration(decl) &&
|
|
2551
|
+
ts.isIdentifier(decl.name)) {
|
|
2552
|
+
const sym = symbolOf(decl.name);
|
|
2553
|
+
const scan = (m) => {
|
|
2554
|
+
if (ts.isBinaryExpression(m) &&
|
|
2555
|
+
m.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
2556
|
+
ts.isIdentifier(m.left) &&
|
|
2557
|
+
symbolOf(m.left) === sym) {
|
|
2558
|
+
const st2 = statementOf(m);
|
|
2559
|
+
if (st2)
|
|
2560
|
+
keys.add(key(st2));
|
|
2561
|
+
follow(m.right, depth + 1);
|
|
2562
|
+
}
|
|
2563
|
+
ts.forEachChild(m, scan);
|
|
2564
|
+
};
|
|
2565
|
+
scan(sf);
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
ts.forEachChild(n, visit);
|
|
2570
|
+
};
|
|
2571
|
+
visit(e);
|
|
2572
|
+
};
|
|
2573
|
+
follow(arg, 0);
|
|
2574
|
+
return [...keys];
|
|
2575
|
+
};
|
|
2576
|
+
const sf = fn.getSourceFile();
|
|
2577
|
+
const where = (n, label) => `${relative(root, sf.fileName)}:${sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1} ${label}`;
|
|
2578
|
+
const visit = (node) => {
|
|
2579
|
+
if (ts.isCallExpression(node)) {
|
|
2580
|
+
const callee = unwrap(node.expression);
|
|
2581
|
+
// node:assert style: assert.method(actual, expected)
|
|
2582
|
+
let method;
|
|
2583
|
+
let strength;
|
|
2584
|
+
let actuals = [];
|
|
2585
|
+
let expected;
|
|
2586
|
+
let negative = false;
|
|
2587
|
+
let rejectsChain = false;
|
|
2588
|
+
if (ts.isPropertyAccessExpression(callee) &&
|
|
2589
|
+
callee.expression.getText() === "assert")
|
|
2590
|
+
method = callee.name.text;
|
|
2591
|
+
else if (ts.isIdentifier(callee) && callee.text === "assert")
|
|
2592
|
+
method = "assert";
|
|
2593
|
+
if (method && ASSERT_STRENGTH[method]) {
|
|
2594
|
+
strength = ASSERT_STRENGTH[method];
|
|
2595
|
+
actuals = node.arguments.slice(0, 2);
|
|
2596
|
+
expected = node.arguments[1];
|
|
2597
|
+
negative = method === "doesNotMatch";
|
|
2598
|
+
}
|
|
2599
|
+
else if (ts.isPropertyAccessExpression(callee)) {
|
|
2600
|
+
// vitest/jest style: expect(actual)[.not][.resolves|.rejects].matcher(expected)
|
|
2601
|
+
method = undefined;
|
|
2602
|
+
let e = unwrap(callee.expression);
|
|
2603
|
+
while (ts.isPropertyAccessExpression(e) &&
|
|
2604
|
+
["not", "resolves", "rejects"].includes(e.name.text)) {
|
|
2605
|
+
if (e.name.text === "not")
|
|
2606
|
+
negative = true;
|
|
2607
|
+
if (e.name.text === "rejects")
|
|
2608
|
+
rejectsChain = true;
|
|
2609
|
+
e = unwrap(e.expression);
|
|
2610
|
+
}
|
|
2611
|
+
if (ts.isCallExpression(e) &&
|
|
2612
|
+
ts.isIdentifier(unwrap(e.expression)) &&
|
|
2613
|
+
unwrap(e.expression).text === "expect" &&
|
|
2614
|
+
e.arguments[0] &&
|
|
2615
|
+
EXPECT_STRENGTH[callee.name.text]) {
|
|
2616
|
+
method = callee.name.text;
|
|
2617
|
+
strength = EXPECT_STRENGTH[method];
|
|
2618
|
+
if ((method === "toThrow" || method === "toThrowError") &&
|
|
2619
|
+
!node.arguments[0])
|
|
2620
|
+
strength = "presence";
|
|
2621
|
+
actuals = [e.arguments[0]];
|
|
2622
|
+
expected = node.arguments[0];
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
// expect.objectContaining / expect.any / expect.anything inside the expected value: a subset match, not total
|
|
2626
|
+
if (strength === "total" &&
|
|
2627
|
+
expected &&
|
|
2628
|
+
/\bexpect\.(objectContaining|arrayContaining|anything|any|stringContaining|stringMatching|closeTo)\s*\(/.test(expected.getText()))
|
|
2629
|
+
strength = "value";
|
|
2630
|
+
if (method && strength) {
|
|
2631
|
+
pragmaCollector.register(node, method, staticTestKey(file, line, name), inert);
|
|
2632
|
+
const s = strength;
|
|
2633
|
+
const pattern = expected &&
|
|
2634
|
+
[
|
|
2635
|
+
"match",
|
|
2636
|
+
"doesNotMatch",
|
|
2637
|
+
"toMatch",
|
|
2638
|
+
"toThrow",
|
|
2639
|
+
"toThrowError",
|
|
2640
|
+
].includes(method)
|
|
2641
|
+
? regexSource(expected)
|
|
2642
|
+
: undefined;
|
|
2643
|
+
const expectedLiteral = expected &&
|
|
2644
|
+
[
|
|
2645
|
+
"equal",
|
|
2646
|
+
"strictEqual",
|
|
2647
|
+
"notEqual",
|
|
2648
|
+
"notStrictEqual",
|
|
2649
|
+
"toBe",
|
|
2650
|
+
"toEqual",
|
|
2651
|
+
"toStrictEqual",
|
|
2652
|
+
"toThrow",
|
|
2653
|
+
"toThrowError",
|
|
2654
|
+
].includes(method) &&
|
|
2655
|
+
ts.isStringLiteralLike(unwrap(expected))
|
|
2656
|
+
? unwrap(expected).text
|
|
2657
|
+
: undefined;
|
|
2658
|
+
const expectedFragment = expected &&
|
|
2659
|
+
["toContain", "toContainEqual", "toMatch"].includes(method) &&
|
|
2660
|
+
ts.isStringLiteralLike(unwrap(expected))
|
|
2661
|
+
? unwrap(expected).text
|
|
2662
|
+
: undefined;
|
|
2663
|
+
const observesThrow = rejectsChain ||
|
|
2664
|
+
["rejects", "throws", "toThrow", "toThrowError"].includes(method);
|
|
2665
|
+
for (const arg of actuals) {
|
|
2666
|
+
const o = originOf(arg);
|
|
2667
|
+
const unresolvedOperand = (shape) => pending.push({
|
|
2668
|
+
statements: definingStatements(arg),
|
|
2669
|
+
strength: RANK[s] > RANK.value ? "value" : s,
|
|
2670
|
+
negative: !!negative,
|
|
2671
|
+
rejects: observesThrow,
|
|
2672
|
+
where: where(node, method),
|
|
2673
|
+
shape: `${arg.getText().replace(/\s+/g, " ").slice(0, 40)} [${shape}]`,
|
|
2674
|
+
});
|
|
2675
|
+
if (!o) {
|
|
2676
|
+
noteUnrecognized(arg, "no origin");
|
|
2677
|
+
unresolvedOperand("no origin");
|
|
2678
|
+
continue;
|
|
2679
|
+
}
|
|
2680
|
+
const includes = o.facet?.startsWith("includes:")
|
|
2681
|
+
? o.facet.slice(9)
|
|
2682
|
+
: undefined;
|
|
2683
|
+
const facetPattern = o.facet?.startsWith("pattern:")
|
|
2684
|
+
? o.facet.slice(8)
|
|
2685
|
+
: undefined;
|
|
2686
|
+
const bs = boundariesOf(o);
|
|
2687
|
+
if (!bs.length && o.kind !== "literal") {
|
|
2688
|
+
noteUnrecognized(arg, o.kind);
|
|
2689
|
+
unresolvedOperand(o.kind);
|
|
2690
|
+
}
|
|
2691
|
+
// rejects/throws: the function's throw sites are observed as well as (or instead of) its return
|
|
2692
|
+
if (observesThrow)
|
|
2693
|
+
for (const b of [...bs])
|
|
2694
|
+
if (b.boundary.startsWith("return:"))
|
|
2695
|
+
bs.push({ boundary: "throw:" + b.boundary.slice(7) });
|
|
2696
|
+
// the whole call list of a sink: a call count, or `mock.calls` read as a whole or through a projection
|
|
2697
|
+
// (`calls.map(([p]) => p.blog_id)`, `calls.length`), as opposed to one call (`calls[0]`)
|
|
2698
|
+
const callsAt = o.path.indexOf("calls");
|
|
2699
|
+
const callList = method === "toHaveBeenCalledTimes" ||
|
|
2700
|
+
method === "toHaveBeenCalledOnce" ||
|
|
2701
|
+
(callsAt > 0 &&
|
|
2702
|
+
o.path[callsAt - 1] === "mock" &&
|
|
2703
|
+
!o.path.slice(callsAt + 1).includes("[]"));
|
|
2704
|
+
for (const b of bs) {
|
|
2705
|
+
const ob = {
|
|
2706
|
+
...b,
|
|
2707
|
+
strength: s,
|
|
2708
|
+
where: where(node, method),
|
|
2709
|
+
assertionSource: `${relative(root, sf.fileName)}:${sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1}:${sf.getLineAndCharacterOfPosition(node.getStart(sf)).character + 1}`,
|
|
2710
|
+
assertionMethod: method,
|
|
2711
|
+
};
|
|
2712
|
+
if (callList && b.boundary.startsWith("sink:"))
|
|
2713
|
+
ob.callList = true;
|
|
2714
|
+
if (pattern)
|
|
2715
|
+
ob.pattern = pattern;
|
|
2716
|
+
else if (facetPattern)
|
|
2717
|
+
ob.pattern = facetPattern;
|
|
2718
|
+
if (includes)
|
|
2719
|
+
ob.fragment = includes; // a substring or prefix: pins every message that can contain it
|
|
2720
|
+
else if (expectedFragment)
|
|
2721
|
+
ob.fragment = expectedFragment;
|
|
2722
|
+
else if (expectedLiteral)
|
|
2723
|
+
ob.literal = expectedLiteral; // a whole message: pins the template that can produce it
|
|
2724
|
+
if (negative)
|
|
2725
|
+
ob.negative = true;
|
|
2726
|
+
observations.push(ob);
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
// implicit oracles: awaited reads that throw or time out
|
|
2731
|
+
if (ts.isAwaitExpression(node.parent)) {
|
|
2732
|
+
const o = originOf(node);
|
|
2733
|
+
if (o) {
|
|
2734
|
+
const bs = boundariesOf(o);
|
|
2735
|
+
let pattern;
|
|
2736
|
+
if (o.kind === "helper:launchGateway" && o.path.includes("ready()"))
|
|
2737
|
+
pattern = "Listening on port|Stdio server listening";
|
|
2738
|
+
if (o.kind === "helper:launchGateway" &&
|
|
2739
|
+
o.path.includes("waitFor()")) {
|
|
2740
|
+
const lit = node.arguments[0]
|
|
2741
|
+
?.getText()
|
|
2742
|
+
.match(/includes\((['"`])(.*?)\1\)/);
|
|
2743
|
+
pattern = lit
|
|
2744
|
+
? lit[2].replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
2745
|
+
: undefined;
|
|
2746
|
+
}
|
|
2747
|
+
for (const b of bs)
|
|
2748
|
+
observations.push({
|
|
2749
|
+
...b,
|
|
2750
|
+
strength: "presence",
|
|
2751
|
+
where: where(node, "await"),
|
|
2752
|
+
implicit: true,
|
|
2753
|
+
pattern,
|
|
2754
|
+
});
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
if (ts.isNewExpression(node) || ts.isCallExpression(node)) {
|
|
2759
|
+
const callee = unwrap(node.expression);
|
|
2760
|
+
if (ts.isIdentifier(callee)) {
|
|
2761
|
+
const d = declOf(callee);
|
|
2762
|
+
if (d && isProdFile(d.getSourceFile())) {
|
|
2763
|
+
const sig = checker.getResolvedSignature(node);
|
|
2764
|
+
const params = sig?.getParameters() ?? [];
|
|
2765
|
+
node.arguments?.forEach((arg, i) => {
|
|
2766
|
+
const paramName = params[i]?.name;
|
|
2767
|
+
if (!paramName)
|
|
2768
|
+
return;
|
|
2769
|
+
const paramDecl = params[i]?.valueDeclaration;
|
|
2770
|
+
// a destructured parameter `({ argv, logger })`: the property name is the binding the code uses
|
|
2771
|
+
const destructured = !!paramDecl &&
|
|
2772
|
+
ts.isParameter(paramDecl) &&
|
|
2773
|
+
ts.isObjectBindingPattern(paramDecl.name);
|
|
2774
|
+
// one sink may be wired through several members (logger.info and logger.error both push to `logs`)
|
|
2775
|
+
const found = new Map();
|
|
2776
|
+
const record = (name, path) => {
|
|
2777
|
+
const param = destructured ? path[0] : paramName;
|
|
2778
|
+
const member = (destructured ? path.slice(1) : path).join(".") || undefined;
|
|
2779
|
+
if (param)
|
|
2780
|
+
found.set(`${name}|${param}|${member ?? ""}`, {
|
|
2781
|
+
name,
|
|
2782
|
+
param,
|
|
2783
|
+
member,
|
|
2784
|
+
});
|
|
2785
|
+
};
|
|
2786
|
+
const scan = (n, path, depth) => {
|
|
2787
|
+
if (depth > 5)
|
|
2788
|
+
return;
|
|
2789
|
+
if (ts.isIdentifier(n) &&
|
|
2790
|
+
!(ts.isPropertyAssignment(n.parent) && n.parent.name === n)) {
|
|
2791
|
+
const dd = declOf(n);
|
|
2792
|
+
const ddInit = dd && ts.isVariableDeclaration(dd) ? initOf(dd) : undefined;
|
|
2793
|
+
if (dd && ts.isVariableDeclaration(dd) && ddInit) {
|
|
2794
|
+
const init = unwrap(ddInit);
|
|
2795
|
+
if (ts.isArrayLiteralExpression(init) ||
|
|
2796
|
+
ts.isObjectLiteralExpression(init) ||
|
|
2797
|
+
isMockFactory(init)) {
|
|
2798
|
+
record(n.text, path);
|
|
2799
|
+
if (ts.isObjectLiteralExpression(init))
|
|
2800
|
+
scan(init, path, depth + 1); // a logger object wiring other sinks
|
|
2801
|
+
return;
|
|
2802
|
+
}
|
|
2803
|
+
if (ts.isCallExpression(init) &&
|
|
2804
|
+
ts.isIdentifier(unwrap(init.expression))) {
|
|
2805
|
+
const h = localFunctionNode(unwrap(init.expression).text, sf);
|
|
2806
|
+
// `table = createTable()` where the factory returns an object of mocks: the variable is the sink
|
|
2807
|
+
const made = h ? returnedObject(h) : undefined;
|
|
2808
|
+
if (made && containsMock(made)) {
|
|
2809
|
+
record(n.text, path);
|
|
2810
|
+
return;
|
|
2811
|
+
}
|
|
2812
|
+
if (h)
|
|
2813
|
+
scan(h, path, depth + 1);
|
|
2814
|
+
return;
|
|
2815
|
+
}
|
|
2816
|
+
scan(ddInit, path, depth + 1);
|
|
2817
|
+
return;
|
|
2818
|
+
}
|
|
2819
|
+
if (dd && ts.isBindingElement(dd)) {
|
|
2820
|
+
// destructured from a local helper's returned object: scan what the helper put there
|
|
2821
|
+
const decl = dd.parent.parent;
|
|
2822
|
+
if (ts.isVariableDeclaration(decl) &&
|
|
2823
|
+
decl.initializer &&
|
|
2824
|
+
ts.isCallExpression(unwrap(decl.initializer))) {
|
|
2825
|
+
const c = unwrap(unwrap(decl.initializer)
|
|
2826
|
+
.expression);
|
|
2827
|
+
const prop = (dd.propertyName ?? dd.name).getText();
|
|
2828
|
+
if (ts.isIdentifier(c)) {
|
|
2829
|
+
const expr = localHelperPropertyNode(c.text, prop, sf);
|
|
2830
|
+
if (expr)
|
|
2831
|
+
scan(expr, path, depth + 1);
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
return;
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
if (ts.isPropertyAssignment(n) || ts.isMethodDeclaration(n)) {
|
|
2838
|
+
const m = n.name.getText();
|
|
2839
|
+
ts.forEachChild(n, (c) => scan(c, [...path, m], depth));
|
|
2840
|
+
return;
|
|
2841
|
+
}
|
|
2842
|
+
if (ts.isShorthandPropertyAssignment(n)) {
|
|
2843
|
+
scan(n.name, [...path, n.name.text], depth);
|
|
2844
|
+
return;
|
|
2845
|
+
}
|
|
2846
|
+
ts.forEachChild(n, (c) => scan(c, path, depth));
|
|
2847
|
+
};
|
|
2848
|
+
scan(arg, [], 0);
|
|
2849
|
+
for (const b of found.values())
|
|
2850
|
+
sinks.push({
|
|
2851
|
+
sink: "sink:" + b.name,
|
|
2852
|
+
prodName: callee.text,
|
|
2853
|
+
param: b.param,
|
|
2854
|
+
member: b.member,
|
|
2855
|
+
});
|
|
2856
|
+
});
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
ts.forEachChild(node, visit);
|
|
2861
|
+
};
|
|
2862
|
+
visit(fn);
|
|
2863
|
+
// local helper functions called from the body may create sinks or wire them into production code
|
|
2864
|
+
const helpers = new Set();
|
|
2865
|
+
const collect = (n) => {
|
|
2866
|
+
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) {
|
|
2867
|
+
const h = localFunctionNode(n.expression.text, sf);
|
|
2868
|
+
if (h && h !== fn)
|
|
2869
|
+
helpers.add(h);
|
|
2870
|
+
}
|
|
2871
|
+
ts.forEachChild(n, collect);
|
|
2872
|
+
};
|
|
2873
|
+
collect(fn);
|
|
2874
|
+
for (const h of helpers)
|
|
2875
|
+
visit(h);
|
|
2876
|
+
jsxComponentsIn(fn, rendered);
|
|
2877
|
+
for (const h of helpers)
|
|
2878
|
+
jsxComponentsIn(h, rendered);
|
|
2879
|
+
// `it.fails` / skipped tests are not oracles: keep them for linking, drop their observations
|
|
2880
|
+
return {
|
|
2881
|
+
file,
|
|
2882
|
+
line,
|
|
2883
|
+
name,
|
|
2884
|
+
observations: inert ? [] : observations,
|
|
2885
|
+
sinks,
|
|
2886
|
+
rendered: renderedComponents(rendered),
|
|
2887
|
+
pending: inert ? [] : pending,
|
|
2888
|
+
};
|
|
2889
|
+
}
|
|
2890
|
+
/** node:test `test(...)`, vitest/jest `it(...)`/`test(...)`, `it.each(table)(name, fn)`; `.fails`/`.skip`/`.todo` are not oracles. */
|
|
2891
|
+
function testDeclaration(node) {
|
|
2892
|
+
if (!ts.isCallExpression(node) || node.arguments.length < 2)
|
|
2893
|
+
return undefined;
|
|
2894
|
+
const body = node.arguments[node.arguments.length - 1];
|
|
2895
|
+
if (!(ts.isArrowFunction(body) || ts.isFunctionExpression(body)))
|
|
2896
|
+
return undefined;
|
|
2897
|
+
let callee = unwrap(node.expression);
|
|
2898
|
+
if (ts.isCallExpression(callee))
|
|
2899
|
+
callee = unwrap(callee.expression); // it.each(table)(...)
|
|
2900
|
+
const names = ts.isIdentifier(callee)
|
|
2901
|
+
? [callee.text]
|
|
2902
|
+
: ts.isPropertyAccessExpression(callee)
|
|
2903
|
+
? [callee.expression.getText(), callee.name.text]
|
|
2904
|
+
: [];
|
|
2905
|
+
if (!["test", "it"].includes(names[0]))
|
|
2906
|
+
return undefined;
|
|
2907
|
+
if (names[1] &&
|
|
2908
|
+
!["each", "only", "concurrent", "fails", "skip", "todo"].includes(names[1]))
|
|
2909
|
+
return undefined;
|
|
2910
|
+
return {
|
|
2911
|
+
body,
|
|
2912
|
+
name: node.arguments[0].getText().slice(0, 60),
|
|
2913
|
+
inert: ["fails", "skip", "todo"].includes(names[1] ?? ""),
|
|
2914
|
+
};
|
|
2915
|
+
}
|
|
2916
|
+
for (const sf of allFiles) {
|
|
2917
|
+
if (!isTestFile(sf))
|
|
2918
|
+
continue;
|
|
2919
|
+
moduleMocksByFile.set(rel(sf), collectModuleMocks(sf));
|
|
2920
|
+
globalSinksByFile.set(rel(sf), collectGlobalSinks(sf));
|
|
2921
|
+
const visit = (node) => {
|
|
2922
|
+
const decl = testDeclaration(node);
|
|
2923
|
+
if (decl) {
|
|
2924
|
+
const st = analyzeTestBody(decl.body, rel(sf), sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1, decl.name, decl.inert);
|
|
2925
|
+
st.endLine = sf.getLineAndCharacterOfPosition(node.getEnd()).line + 1;
|
|
2926
|
+
const arg0 = node.arguments[0];
|
|
2927
|
+
st.title = ts.isStringLiteralLike(arg0)
|
|
2928
|
+
? arg0.text
|
|
2929
|
+
: arg0.getText(sf).replace(/^[`'"]|[`'"]$/g, "");
|
|
2930
|
+
staticTests.push(st);
|
|
2931
|
+
}
|
|
2932
|
+
ts.forEachChild(node, visit);
|
|
2933
|
+
};
|
|
2934
|
+
visit(sf);
|
|
2935
|
+
}
|
|
2936
|
+
// Runtime line numbers come from ts-node's transpiled output (no source maps), so they
|
|
2937
|
+
// drift below the TypeScript lines. Order is preserved, so pair runtime call sites with
|
|
2938
|
+
// static test() calls by rank within each file; fall back to nearest-line when counts differ.
|
|
2939
|
+
const staticLink = new Map();
|
|
2940
|
+
const linkWarnings = [];
|
|
2941
|
+
// Runners that report no test line (supercov): link by the lines of the test's assertion phases,
|
|
2942
|
+
// which lie inside exactly one test declaration, else by the leaf title.
|
|
2943
|
+
const staticById = new Map();
|
|
2944
|
+
let linkedByPhases = 0;
|
|
2945
|
+
let linkedByTitle = 0;
|
|
2946
|
+
for (const rt of runtimeTests) {
|
|
2947
|
+
if (rt.line !== 0)
|
|
2948
|
+
continue;
|
|
2949
|
+
const statics = staticTests.filter((t) => t.file === rt.file);
|
|
2950
|
+
const lines = rt.phaseLines ?? [];
|
|
2951
|
+
const byPhases = lines.length
|
|
2952
|
+
? statics
|
|
2953
|
+
.filter((t) => t.line <= lines[0] &&
|
|
2954
|
+
(t.endLine ?? t.line) >= lines[lines.length - 1])
|
|
2955
|
+
.sort((a, b) => b.line - a.line)[0]
|
|
2956
|
+
: undefined;
|
|
2957
|
+
if (byPhases) {
|
|
2958
|
+
staticById.set(rt.id, byPhases);
|
|
2959
|
+
linkedByPhases++;
|
|
2960
|
+
continue;
|
|
2961
|
+
}
|
|
2962
|
+
const title = rt.title ?? rt.name;
|
|
2963
|
+
const byTitle = statics.find((t) => t.title !== undefined &&
|
|
2964
|
+
(t.title === title ||
|
|
2965
|
+
(/[$%]/.test(t.title) &&
|
|
2966
|
+
title.startsWith(t.title.split(/[$%]/)[0].trimEnd()))));
|
|
2967
|
+
if (byTitle) {
|
|
2968
|
+
staticById.set(rt.id, byTitle);
|
|
2969
|
+
linkedByTitle++;
|
|
2970
|
+
}
|
|
2971
|
+
else
|
|
2972
|
+
linkWarnings.push(`${rt.file}: no static test for "${title}"`);
|
|
2973
|
+
}
|
|
2974
|
+
for (const file of new Set(runtimeTests.filter((t) => t.line !== 0).map((t) => t.file))) {
|
|
2975
|
+
const runtimeLines = [
|
|
2976
|
+
...new Set(runtimeTests
|
|
2977
|
+
.filter((t) => t.file === file && t.line !== 0)
|
|
2978
|
+
.map((t) => t.line)),
|
|
2979
|
+
].sort((a, b) => a - b);
|
|
2980
|
+
const statics = staticTests
|
|
2981
|
+
.filter((t) => t.file === file)
|
|
2982
|
+
.sort((a, b) => a.line - b.line);
|
|
2983
|
+
// exact source lines (a runner that maps locations) win; otherwise pair by rank
|
|
2984
|
+
if (runtimeLines.length &&
|
|
2985
|
+
runtimeLines.every((l) => statics.some((s) => s.line === l)))
|
|
2986
|
+
runtimeLines.forEach((l) => staticLink.set(`${file}:${l}`, statics.find((s) => s.line === l)));
|
|
2987
|
+
else if (runtimeLines.length === statics.length)
|
|
2988
|
+
runtimeLines.forEach((l, i) => staticLink.set(`${file}:${l}`, statics[i]));
|
|
2989
|
+
else {
|
|
2990
|
+
linkWarnings.push(`${file}: ${runtimeLines.length} runtime call sites vs ${statics.length} static tests; using nearest-line fallback`);
|
|
2991
|
+
for (const l of runtimeLines) {
|
|
2992
|
+
const st = statics
|
|
2993
|
+
.filter((t) => t.line <= l)
|
|
2994
|
+
.sort((a, b) => b.line - a.line)[0] ?? statics[0];
|
|
2995
|
+
if (st)
|
|
2996
|
+
staticLink.set(`${file}:${l}`, st);
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
}
|
|
3000
|
+
const staticFor = (rt) => staticById.get(rt.id) ?? staticLink.get(`${rt.file}:${rt.line}`);
|
|
3001
|
+
const decisionFacts = new Map();
|
|
3002
|
+
function classByName(name, file) {
|
|
3003
|
+
const sf = srcByFile.get(file);
|
|
3004
|
+
let found;
|
|
3005
|
+
const visit = (n) => {
|
|
3006
|
+
if (found)
|
|
3007
|
+
return;
|
|
3008
|
+
if (ts.isClassDeclaration(n) && n.name?.text === name) {
|
|
3009
|
+
found = n;
|
|
3010
|
+
return;
|
|
3011
|
+
}
|
|
3012
|
+
ts.forEachChild(n, visit);
|
|
3013
|
+
};
|
|
3014
|
+
if (sf)
|
|
3015
|
+
visit(sf);
|
|
3016
|
+
return found;
|
|
3017
|
+
}
|
|
3018
|
+
/** Functions passed (directly or as an object property) for parameter `paramName` at call sites of `fn`. */
|
|
3019
|
+
function callbackTargets(fn, paramName) {
|
|
3020
|
+
const params = paramsOf(fn);
|
|
3021
|
+
let index = -1;
|
|
3022
|
+
let path = [];
|
|
3023
|
+
params.forEach((p, i) => {
|
|
3024
|
+
if (ts.isIdentifier(p.name) && p.name.text === paramName)
|
|
3025
|
+
index = i;
|
|
3026
|
+
else if (ts.isObjectBindingPattern(p.name)) {
|
|
3027
|
+
const el = p.name.elements.find((e) => e.name.getText() === paramName);
|
|
3028
|
+
if (el) {
|
|
3029
|
+
index = i;
|
|
3030
|
+
path = [(el.propertyName ?? el.name).getText()];
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
});
|
|
3034
|
+
if (index < 0)
|
|
3035
|
+
return [];
|
|
3036
|
+
const targets = [];
|
|
3037
|
+
const asFunction = (arg) => {
|
|
3038
|
+
if (!arg)
|
|
3039
|
+
return;
|
|
3040
|
+
const u = unwrap(arg);
|
|
3041
|
+
if (ts.isArrowFunction(u) || ts.isFunctionExpression(u))
|
|
3042
|
+
targets.push(u);
|
|
3043
|
+
else if (ts.isIdentifier(u)) {
|
|
3044
|
+
const d = declOf(u);
|
|
3045
|
+
if (d && ts.isFunctionDeclaration(d))
|
|
3046
|
+
targets.push(d);
|
|
3047
|
+
else if (d &&
|
|
3048
|
+
ts.isVariableDeclaration(d) &&
|
|
3049
|
+
d.initializer &&
|
|
3050
|
+
(ts.isArrowFunction(unwrap(d.initializer)) ||
|
|
3051
|
+
ts.isFunctionExpression(unwrap(d.initializer))))
|
|
3052
|
+
targets.push(unwrap(d.initializer));
|
|
3053
|
+
}
|
|
3054
|
+
};
|
|
3055
|
+
for (const sf of allFiles) {
|
|
3056
|
+
if (!isProdFile(sf))
|
|
3057
|
+
continue;
|
|
3058
|
+
const visit = (n) => {
|
|
3059
|
+
if ((ts.isCallExpression(n) || ts.isNewExpression(n)) &&
|
|
3060
|
+
projectCallee(n.expression) === fn) {
|
|
3061
|
+
let arg = n.arguments?.[index];
|
|
3062
|
+
if (arg && path.length && ts.isObjectLiteralExpression(unwrap(arg))) {
|
|
3063
|
+
const prop = unwrap(arg).properties.find((p) => p.name?.getText() === path[0]);
|
|
3064
|
+
arg =
|
|
3065
|
+
prop && ts.isPropertyAssignment(prop)
|
|
3066
|
+
? prop.initializer
|
|
3067
|
+
: prop && ts.isShorthandPropertyAssignment(prop)
|
|
3068
|
+
? prop.name
|
|
3069
|
+
: undefined;
|
|
3070
|
+
}
|
|
3071
|
+
asFunction(arg);
|
|
3072
|
+
}
|
|
3073
|
+
ts.forEachChild(n, visit);
|
|
3074
|
+
};
|
|
3075
|
+
visit(sf);
|
|
3076
|
+
}
|
|
3077
|
+
return targets;
|
|
3078
|
+
}
|
|
3079
|
+
/** All boundaries an effect site is observable through: direct, plus inter-procedural flow. */
|
|
3080
|
+
function allBoundaries(s) {
|
|
3081
|
+
const bounds = directBoundaries(s);
|
|
3082
|
+
const reached = [];
|
|
3083
|
+
if (s.category === "return" || s.category === "callback-return") {
|
|
3084
|
+
const fn = functionByName(s.owner, s.file);
|
|
3085
|
+
if (fn) {
|
|
3086
|
+
const flow = flowFromReturn(fn);
|
|
3087
|
+
bounds.push(...flow.boundaries);
|
|
3088
|
+
for (const id of flow.sites) {
|
|
3089
|
+
const r = sites.find((x) => x.id === id);
|
|
3090
|
+
if (r && r.id !== s.id)
|
|
3091
|
+
reached.push(r);
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
// a throw escapes to the callers that do not catch it: `rejects.toBe(err)` on the public function observes
|
|
3096
|
+
// a rethrow deep inside a helper
|
|
3097
|
+
if (s.category === "throw") {
|
|
3098
|
+
const fn = functionByName(s.owner, s.file);
|
|
3099
|
+
if (fn)
|
|
3100
|
+
bounds.push(...throwsThrough(fn, 0, new Set()));
|
|
3101
|
+
}
|
|
3102
|
+
// calling a callback parameter runs whatever function the caller passed: its sites are reached
|
|
3103
|
+
if (s.category === "external-call" &&
|
|
3104
|
+
(s.note === "param" || s.note === "this-callback") &&
|
|
3105
|
+
s.chain) {
|
|
3106
|
+
const paramName = s.chain[0] === "this" ? s.chain[1] : s.chain[0];
|
|
3107
|
+
const holder = s.note === "this-callback"
|
|
3108
|
+
? classByName(s.owner.split(".")[0], s.file)
|
|
3109
|
+
: functionByName(s.owner, s.file);
|
|
3110
|
+
if (holder && paramName) {
|
|
3111
|
+
for (const t of callbackTargets(holder, paramName)) {
|
|
3112
|
+
const tf = rel(t.getSourceFile());
|
|
3113
|
+
for (const e of effectSites)
|
|
3114
|
+
if (e.file === tf &&
|
|
3115
|
+
e.pos >= t.getStart() &&
|
|
3116
|
+
e.endPos <= t.getEnd())
|
|
3117
|
+
reached.push(e);
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3120
|
+
}
|
|
3121
|
+
return { bounds, reached };
|
|
3122
|
+
}
|
|
3123
|
+
/**
|
|
3124
|
+
* `throw:<caller>` for each project caller an exception escapes to: the call sits outside any try block, is not
|
|
3125
|
+
* chained with `.catch`/`.then`, and (for an async callee) is awaited or returned so the rejection propagates.
|
|
3126
|
+
* Follows the escape upward through the named owners of those calls.
|
|
3127
|
+
*/
|
|
3128
|
+
function throwsThrough(fn, depth, seen) {
|
|
3129
|
+
const key = fnKey(fn);
|
|
3130
|
+
if (depth > 4 || seen.has(key))
|
|
3131
|
+
return [];
|
|
3132
|
+
seen.add(key);
|
|
3133
|
+
const out = [];
|
|
3134
|
+
const isAsync = !!(ts.canHaveModifiers(fn) &&
|
|
3135
|
+
ts.getModifiers(fn)?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword));
|
|
3136
|
+
for (const c of callSitesOf(fn)) {
|
|
3137
|
+
let guarded = false;
|
|
3138
|
+
let awaited = false;
|
|
3139
|
+
let returned = false;
|
|
3140
|
+
let node = c;
|
|
3141
|
+
while (node.parent && !ts.isFunctionLike(node.parent)) {
|
|
3142
|
+
const p = node.parent;
|
|
3143
|
+
if (ts.isTryStatement(p) && p.tryBlock === node)
|
|
3144
|
+
guarded = true;
|
|
3145
|
+
if (ts.isAwaitExpression(p))
|
|
3146
|
+
awaited = true;
|
|
3147
|
+
if (ts.isReturnStatement(p))
|
|
3148
|
+
returned = true;
|
|
3149
|
+
if (ts.isPropertyAccessExpression(p) &&
|
|
3150
|
+
p.expression === node &&
|
|
3151
|
+
(p.name.text === "catch" || p.name.text === "then"))
|
|
3152
|
+
guarded = true;
|
|
3153
|
+
node = p;
|
|
3154
|
+
}
|
|
3155
|
+
if (ts.isArrowFunction(node.parent) && node.parent.body === node)
|
|
3156
|
+
returned = true;
|
|
3157
|
+
if (guarded || (isAsync && !awaited && !returned))
|
|
3158
|
+
continue;
|
|
3159
|
+
const owner = ownerOf(c);
|
|
3160
|
+
const name = nameOfFunction(owner);
|
|
3161
|
+
if (!owner || !name)
|
|
3162
|
+
continue;
|
|
3163
|
+
out.push({ boundary: "throw:" + name, via: `escapes ${name}` });
|
|
3164
|
+
out.push(...throwsThrough(owner, depth + 1, seen));
|
|
3165
|
+
}
|
|
3166
|
+
return out;
|
|
3167
|
+
}
|
|
3168
|
+
/**
|
|
3169
|
+
* Was this site plausibly asserted through an operand whose shape the analysis could not trace? Statement
|
|
3170
|
+
* attribution answers it: the operand's defining statements record which production functions were entered
|
|
3171
|
+
* while they ran, so an operand that entered this site's owner may well read it. Returns the shapes to
|
|
3172
|
+
* report, so "unresolved" can say "limit" instead of sending an agent to write a test that already exists.
|
|
3173
|
+
*/
|
|
3174
|
+
function unmodelledOperands(s, covering) {
|
|
3175
|
+
buildFunctionIndex();
|
|
3176
|
+
const shapes = new Set();
|
|
3177
|
+
for (const rt of covering) {
|
|
3178
|
+
const byStatement = runtimeStatements.get(rt.id);
|
|
3179
|
+
const st = byStatement ? staticFor(rt) : undefined;
|
|
3180
|
+
if (!byStatement || !st)
|
|
3181
|
+
continue;
|
|
3182
|
+
for (const p of st.pending)
|
|
3183
|
+
for (const pos of p.statements) {
|
|
3184
|
+
const attribution = byStatement[pos];
|
|
3185
|
+
if (!attribution)
|
|
3186
|
+
continue;
|
|
3187
|
+
const entered = attribution.fns.some((key) => functionIndex.get(key)?.name === s.owner) ||
|
|
3188
|
+
(s.kind === "decision" && attribution.decs.some((d) => d === s.id));
|
|
3189
|
+
if (entered)
|
|
3190
|
+
shapes.add(p.shape);
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
return [...shapes];
|
|
3194
|
+
}
|
|
3195
|
+
function isObjectValuedReturn(s) {
|
|
3196
|
+
const node = siteNodes.get(s.id);
|
|
3197
|
+
if (!node || !ts.isReturnStatement(node) || !node.expression)
|
|
3198
|
+
return false;
|
|
3199
|
+
const objectish = (e) => {
|
|
3200
|
+
const u = unwrap(e);
|
|
3201
|
+
if (ts.isConditionalExpression(u))
|
|
3202
|
+
return objectish(u.whenTrue) && objectish(u.whenFalse);
|
|
3203
|
+
if (!ts.isIdentifier(u))
|
|
3204
|
+
return false;
|
|
3205
|
+
const d = declOf(u);
|
|
3206
|
+
if (!d || !ts.isVariableDeclaration(d) || !d.initializer)
|
|
3207
|
+
return false;
|
|
3208
|
+
const init = unwrap(d.initializer);
|
|
3209
|
+
return (ts.isObjectLiteralExpression(init) ||
|
|
3210
|
+
ts.isArrowFunction(init) ||
|
|
3211
|
+
ts.isFunctionExpression(init));
|
|
3212
|
+
};
|
|
3213
|
+
return objectish(node.expression);
|
|
3214
|
+
}
|
|
3215
|
+
function terminates(stmt) {
|
|
3216
|
+
if (ts.isReturnStatement(stmt) ||
|
|
3217
|
+
ts.isThrowStatement(stmt) ||
|
|
3218
|
+
ts.isContinueStatement(stmt) ||
|
|
3219
|
+
ts.isBreakStatement(stmt))
|
|
3220
|
+
return true;
|
|
3221
|
+
if (ts.isBlock(stmt) && stmt.statements.length)
|
|
3222
|
+
return terminates(stmt.statements[stmt.statements.length - 1]);
|
|
3223
|
+
return false;
|
|
3224
|
+
}
|
|
3225
|
+
// ---------------------------------------------------------------------------
|
|
3226
|
+
// Execution records locate functions but do not establish returned-value or
|
|
3227
|
+
// thrown-value dependence. Keep that evidence separate from value observations.
|
|
3228
|
+
// ---------------------------------------------------------------------------
|
|
3229
|
+
const functionIndex = new Map();
|
|
3230
|
+
let functionIndexBuilt = false;
|
|
3231
|
+
function buildFunctionIndex() {
|
|
3232
|
+
if (functionIndexBuilt)
|
|
3233
|
+
return;
|
|
3234
|
+
functionIndexBuilt = true;
|
|
3235
|
+
for (const sf of allFiles) {
|
|
3236
|
+
if (!isProdFile(sf))
|
|
3237
|
+
continue;
|
|
3238
|
+
const file = rel(sf);
|
|
3239
|
+
const visit = (n) => {
|
|
3240
|
+
if (ts.isFunctionLike(n) &&
|
|
3241
|
+
!ts.isMethodSignature(n) &&
|
|
3242
|
+
!ts.isFunctionTypeNode(n)) {
|
|
3243
|
+
const name = nameOfFunction(n);
|
|
3244
|
+
if (name) {
|
|
3245
|
+
const { line, character } = sf.getLineAndCharacterOfPosition(n.getStart(sf));
|
|
3246
|
+
functionIndex.set(`${file}:${line + 1}:${character + 1}`, {
|
|
3247
|
+
name,
|
|
3248
|
+
node: n,
|
|
3249
|
+
});
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
3252
|
+
ts.forEachChild(n, visit);
|
|
3253
|
+
};
|
|
3254
|
+
visit(sf);
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
/** Production call sites of a function (direct calls only, the same lookup flowFromReturn uses). */
|
|
3258
|
+
const callSitesCache = new Map();
|
|
3259
|
+
function callSitesOf(fn) {
|
|
3260
|
+
const key = fnKey(fn);
|
|
3261
|
+
const cached = callSitesCache.get(key);
|
|
3262
|
+
if (cached)
|
|
3263
|
+
return cached;
|
|
3264
|
+
const calls = [];
|
|
3265
|
+
for (const sf of allFiles) {
|
|
3266
|
+
if (!isProdFile(sf))
|
|
3267
|
+
continue;
|
|
3268
|
+
const visit = (x) => {
|
|
3269
|
+
if (ts.isCallExpression(x) && projectCallee(x.expression) === fn)
|
|
3270
|
+
calls.push(x);
|
|
3271
|
+
ts.forEachChild(x, visit);
|
|
3272
|
+
};
|
|
3273
|
+
visit(sf);
|
|
3274
|
+
}
|
|
3275
|
+
callSitesCache.set(key, calls);
|
|
3276
|
+
return calls;
|
|
3277
|
+
}
|
|
3278
|
+
/** `return …`, `throw …`, or a block that ends in one: the branch leaves the function. */
|
|
3279
|
+
function exitsEarly(stmt) {
|
|
3280
|
+
if (ts.isReturnStatement(stmt) || ts.isThrowStatement(stmt))
|
|
3281
|
+
return true;
|
|
3282
|
+
if (ts.isBlock(stmt)) {
|
|
3283
|
+
const last = stmt.statements[stmt.statements.length - 1];
|
|
3284
|
+
return (!!last && (ts.isReturnStatement(last) || ts.isThrowStatement(last)));
|
|
3285
|
+
}
|
|
3286
|
+
return false;
|
|
3287
|
+
}
|
|
3288
|
+
function sitesInRange(file, range) {
|
|
3289
|
+
return effectSites
|
|
3290
|
+
.filter((e) => e.file === file && e.pos >= range[0] && e.endPos <= range[1])
|
|
3291
|
+
.map((e) => e.id);
|
|
3292
|
+
}
|
|
3293
|
+
/** Effect sites that a branch reaches through values it assigns or returns (local flow). */
|
|
3294
|
+
function carriersOfRange(sf, range) {
|
|
3295
|
+
const acc = { boundaries: [], sites: new Set() };
|
|
3296
|
+
const visited = new Set();
|
|
3297
|
+
const visit = (n) => {
|
|
3298
|
+
if (n.getStart(sf) >= range[0] && n.getEnd() <= range[1]) {
|
|
3299
|
+
if (ts.isBinaryExpression(n) &&
|
|
3300
|
+
n.operatorToken.kind === ts.SyntaxKind.EqualsToken)
|
|
3301
|
+
propagateValue(n.right, acc, visited, 3);
|
|
3302
|
+
if (ts.isReturnStatement(n) && n.expression)
|
|
3303
|
+
propagateValue(n.expression, acc, visited, 3);
|
|
3304
|
+
if (ts.isVariableDeclaration(n) && n.initializer)
|
|
3305
|
+
propagateValue(n.initializer, acc, visited, 3);
|
|
3306
|
+
}
|
|
3307
|
+
ts.forEachChild(n, visit);
|
|
3308
|
+
};
|
|
3309
|
+
visit(sf);
|
|
3310
|
+
// boundaries reached without a site (external sinks) count as a pseudo-site: represent by owner return
|
|
3311
|
+
return acc.sites;
|
|
3312
|
+
}
|
|
3313
|
+
const MUTATORS = new Set([
|
|
3314
|
+
"set",
|
|
3315
|
+
"delete",
|
|
3316
|
+
"clear",
|
|
3317
|
+
"push",
|
|
3318
|
+
"pop",
|
|
3319
|
+
"shift",
|
|
3320
|
+
"unshift",
|
|
3321
|
+
"splice",
|
|
3322
|
+
"add",
|
|
3323
|
+
]);
|
|
3324
|
+
function rootOfExpr(e) {
|
|
3325
|
+
let cur = unwrap(e);
|
|
3326
|
+
for (;;) {
|
|
3327
|
+
if (ts.isPropertyAccessExpression(cur) ||
|
|
3328
|
+
ts.isElementAccessExpression(cur) ||
|
|
3329
|
+
ts.isCallExpression(cur)) {
|
|
3330
|
+
cur = unwrap(cur.expression);
|
|
3331
|
+
continue;
|
|
3332
|
+
}
|
|
3333
|
+
return cur;
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
function chainOf(e) {
|
|
3337
|
+
const names = [];
|
|
3338
|
+
let cur = unwrap(e);
|
|
3339
|
+
for (;;) {
|
|
3340
|
+
if (ts.isPropertyAccessExpression(cur)) {
|
|
3341
|
+
names.unshift(cur.name.text);
|
|
3342
|
+
cur = unwrap(cur.expression);
|
|
3343
|
+
continue;
|
|
3344
|
+
}
|
|
3345
|
+
if (ts.isElementAccessExpression(cur) || ts.isCallExpression(cur)) {
|
|
3346
|
+
cur = unwrap(cur.expression);
|
|
3347
|
+
continue;
|
|
3348
|
+
}
|
|
3349
|
+
break;
|
|
3350
|
+
}
|
|
3351
|
+
if (ts.isIdentifier(cur))
|
|
3352
|
+
names.unshift(cur.text);
|
|
3353
|
+
else if (cur.kind === ts.SyntaxKind.ThisKeyword)
|
|
3354
|
+
names.unshift("this");
|
|
3355
|
+
return names;
|
|
3356
|
+
}
|
|
3357
|
+
function isWriteTarget(n) {
|
|
3358
|
+
const p = n.parent;
|
|
3359
|
+
if (!p)
|
|
3360
|
+
return false;
|
|
3361
|
+
if (ts.isBinaryExpression(p) &&
|
|
3362
|
+
p.left === n &&
|
|
3363
|
+
p.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
|
|
3364
|
+
p.operatorToken.kind <= ts.SyntaxKind.LastAssignment)
|
|
3365
|
+
return true;
|
|
3366
|
+
if ((ts.isPrefixUnaryExpression(p) || ts.isPostfixUnaryExpression(p)) &&
|
|
3367
|
+
p.operand === n)
|
|
3368
|
+
return true;
|
|
3369
|
+
if (ts.isDeleteExpression(p))
|
|
3370
|
+
return true;
|
|
3371
|
+
if (ts.isElementAccessExpression(p) && p.expression === n)
|
|
3372
|
+
return isWriteTarget(p);
|
|
3373
|
+
if (ts.isPropertyAccessExpression(p) && p.expression === n) {
|
|
3374
|
+
if (ts.isCallExpression(p.parent) &&
|
|
3375
|
+
p.parent.expression === p &&
|
|
3376
|
+
MUTATORS.has(p.name.text))
|
|
3377
|
+
return true;
|
|
3378
|
+
return isWriteTarget(p);
|
|
3379
|
+
}
|
|
3380
|
+
return false;
|
|
3381
|
+
}
|
|
3382
|
+
function stateKey(s, node) {
|
|
3383
|
+
let target;
|
|
3384
|
+
if (ts.isBinaryExpression(node))
|
|
3385
|
+
target = node.left;
|
|
3386
|
+
else if (ts.isPrefixUnaryExpression(node) ||
|
|
3387
|
+
ts.isPostfixUnaryExpression(node))
|
|
3388
|
+
target = node.operand;
|
|
3389
|
+
else if (ts.isDeleteExpression(node))
|
|
3390
|
+
target = node.expression;
|
|
3391
|
+
else if (ts.isCallExpression(node) &&
|
|
3392
|
+
ts.isPropertyAccessExpression(unwrap(node.expression)))
|
|
3393
|
+
target = unwrap(node.expression)
|
|
3394
|
+
.expression;
|
|
3395
|
+
if (!target)
|
|
3396
|
+
return undefined;
|
|
3397
|
+
const t = unwrap(target);
|
|
3398
|
+
const root = rootOfExpr(t);
|
|
3399
|
+
if (root.kind === ts.SyntaxKind.ThisKeyword) {
|
|
3400
|
+
const names = chainOf(t);
|
|
3401
|
+
return names[1] ? { kind: "field", name: names[1] } : undefined;
|
|
3402
|
+
}
|
|
3403
|
+
if (ts.isIdentifier(root)) {
|
|
3404
|
+
if (t === root)
|
|
3405
|
+
return { kind: "var", name: root.text, sym: symbolOf(root) };
|
|
3406
|
+
if (/alias/.test(s.note ?? "") && ts.isPropertyAccessExpression(t))
|
|
3407
|
+
return { kind: "prop", name: t.name.text };
|
|
3408
|
+
return { kind: "var", name: root.text, sym: symbolOf(root) };
|
|
3409
|
+
}
|
|
3410
|
+
return undefined;
|
|
3411
|
+
}
|
|
3412
|
+
function readsOf(key, node, sf) {
|
|
3413
|
+
let scope = sf;
|
|
3414
|
+
if (key.kind === "field" || key.kind === "prop") {
|
|
3415
|
+
let c = node;
|
|
3416
|
+
while (c && !ts.isClassDeclaration(c))
|
|
3417
|
+
c = c.parent;
|
|
3418
|
+
scope = c ?? ownerOf(node) ?? sf;
|
|
3419
|
+
}
|
|
3420
|
+
else if (key.sym) {
|
|
3421
|
+
const d = key.sym.valueDeclaration ?? key.sym.declarations?.[0];
|
|
3422
|
+
scope = d ? (enclosingFunction(d) ?? sf) : sf;
|
|
3423
|
+
}
|
|
3424
|
+
const reads = [];
|
|
3425
|
+
const inside = (n) => n.getStart(sf) >= node.getStart(sf) && n.getEnd() <= node.getEnd();
|
|
3426
|
+
const visit = (n) => {
|
|
3427
|
+
if (!inside(n)) {
|
|
3428
|
+
if (key.kind === "field" &&
|
|
3429
|
+
ts.isPropertyAccessExpression(n) &&
|
|
3430
|
+
n.expression.kind === ts.SyntaxKind.ThisKeyword &&
|
|
3431
|
+
n.name.text === key.name &&
|
|
3432
|
+
!isWriteTarget(n))
|
|
3433
|
+
reads.push(n);
|
|
3434
|
+
else if (key.kind === "prop" &&
|
|
3435
|
+
ts.isPropertyAccessExpression(n) &&
|
|
3436
|
+
n.name.text === key.name &&
|
|
3437
|
+
!isWriteTarget(n))
|
|
3438
|
+
reads.push(n);
|
|
3439
|
+
else if (key.kind === "var" &&
|
|
3440
|
+
ts.isIdentifier(n) &&
|
|
3441
|
+
key.sym &&
|
|
3442
|
+
symbolOf(n) === key.sym &&
|
|
3443
|
+
!isWriteTarget(n) &&
|
|
3444
|
+
!(ts.isVariableDeclaration(n.parent) && n.parent.name === n) &&
|
|
3445
|
+
!(ts.isParameter(n.parent) && n.parent.name === n))
|
|
3446
|
+
reads.push(n);
|
|
3447
|
+
}
|
|
3448
|
+
ts.forEachChild(n, visit);
|
|
3449
|
+
};
|
|
3450
|
+
visit(scope);
|
|
3451
|
+
return reads;
|
|
3452
|
+
}
|
|
3453
|
+
function dependentsOfRead(r, file, sf, depth = 0) {
|
|
3454
|
+
const out = [];
|
|
3455
|
+
const start = r.getStart(sf);
|
|
3456
|
+
const end = r.getEnd();
|
|
3457
|
+
const atom = sites
|
|
3458
|
+
.filter((x) => x.kind === "decision" &&
|
|
3459
|
+
x.file === file &&
|
|
3460
|
+
x.pos <= start &&
|
|
3461
|
+
x.endPos >= end)
|
|
3462
|
+
.sort((a, b) => a.endPos - a.pos - (b.endPos - b.pos))[0];
|
|
3463
|
+
if (atom)
|
|
3464
|
+
out.push(atom);
|
|
3465
|
+
const eff = smallestSiteContaining(file, start, end);
|
|
3466
|
+
if (eff)
|
|
3467
|
+
out.push(eff);
|
|
3468
|
+
if (depth < 1) {
|
|
3469
|
+
let p = r.parent;
|
|
3470
|
+
while (p &&
|
|
3471
|
+
!ts.isStatement(p) &&
|
|
3472
|
+
!ts.isVariableDeclaration(p) &&
|
|
3473
|
+
!ts.isFunctionLike(p))
|
|
3474
|
+
p = p.parent;
|
|
3475
|
+
if (p && ts.isVariableDeclaration(p) && ts.isIdentifier(p.name)) {
|
|
3476
|
+
const decl = p;
|
|
3477
|
+
const sym = symbolOf(decl.name);
|
|
3478
|
+
const scope = enclosingFunction(decl) ?? sf;
|
|
3479
|
+
const visit = (n) => {
|
|
3480
|
+
if (ts.isIdentifier(n) &&
|
|
3481
|
+
n !== decl.name &&
|
|
3482
|
+
sym &&
|
|
3483
|
+
symbolOf(n) === sym)
|
|
3484
|
+
out.push(...dependentsOfRead(n, file, sf, depth + 1));
|
|
3485
|
+
ts.forEachChild(n, visit);
|
|
3486
|
+
};
|
|
3487
|
+
visit(scope);
|
|
3488
|
+
}
|
|
3489
|
+
}
|
|
3490
|
+
return out;
|
|
3491
|
+
}
|
|
3492
|
+
function callbackSitesOf(call, file, sf) {
|
|
3493
|
+
const cb = call.arguments.find((a) => ts.isArrowFunction(a) || ts.isFunctionExpression(a));
|
|
3494
|
+
return cb
|
|
3495
|
+
? effectSites.filter((e) => e.file === file &&
|
|
3496
|
+
e.pos >= cb.getStart(sf) &&
|
|
3497
|
+
e.endPos <= cb.getEnd())
|
|
3498
|
+
: [];
|
|
3499
|
+
}
|
|
3500
|
+
/** The sites that depend on reads of the state a state-write site writes (undefined when the key is unknown). */
|
|
3501
|
+
function stateWriteDeps(s) {
|
|
3502
|
+
const node = siteNodes.get(s.id);
|
|
3503
|
+
const sf = srcByFile.get(s.file);
|
|
3504
|
+
if (!node || !sf)
|
|
3505
|
+
return undefined;
|
|
3506
|
+
const key = stateKey(s, node);
|
|
3507
|
+
if (!key)
|
|
3508
|
+
return undefined;
|
|
3509
|
+
const deps = [];
|
|
3510
|
+
for (const r of readsOf(key, node, sf))
|
|
3511
|
+
for (const d of dependentsOfRead(r, s.file, sf))
|
|
3512
|
+
deps.push({
|
|
3513
|
+
site: d,
|
|
3514
|
+
label: `read ${r.getText(sf).replace(/\s+/g, " ").slice(0, 30)}`,
|
|
3515
|
+
});
|
|
3516
|
+
return deps;
|
|
3517
|
+
}
|
|
3518
|
+
/**
|
|
3519
|
+
* The sites through which an internal effect becomes observable: a timer through its callback's sites, a
|
|
3520
|
+
* cancelled timer through a callback with a total sink, a project method call through the method's sites,
|
|
3521
|
+
* a state write through the sites that depend on reads of that state. Undefined when the site's state key
|
|
3522
|
+
* cannot be determined, which is the honest "no derivation possible".
|
|
3523
|
+
*/
|
|
3524
|
+
function deriveDeps(s) {
|
|
3525
|
+
const node = siteNodes.get(s.id);
|
|
3526
|
+
const sf = srcByFile.get(s.file);
|
|
3527
|
+
if (!node || !sf)
|
|
3528
|
+
return undefined;
|
|
3529
|
+
const deps = [];
|
|
3530
|
+
if (s.category === "schedule" && ts.isCallExpression(node)) {
|
|
3531
|
+
if (s.method === "setTimeout" || s.method === "setInterval") {
|
|
3532
|
+
for (const e of callbackSitesOf(node, s.file, sf))
|
|
3533
|
+
deps.push({ site: e, label: "timer callback" });
|
|
3534
|
+
}
|
|
3535
|
+
else {
|
|
3536
|
+
// cancelling a timer is observable only as the callback not running: needs a total sink assertion on that callback
|
|
3537
|
+
for (const e of effectSites) {
|
|
3538
|
+
if (e.file !== s.file ||
|
|
3539
|
+
e.category !== "schedule" ||
|
|
3540
|
+
e.method !== "setTimeout" ||
|
|
3541
|
+
e.owner.split(".")[0] !== s.owner.split(".")[0])
|
|
3542
|
+
continue;
|
|
3543
|
+
const en = siteNodes.get(e.id);
|
|
3544
|
+
if (!en || !ts.isCallExpression(en))
|
|
3545
|
+
continue;
|
|
3546
|
+
// A dependency candidate, not a verdict. The engine checks whether any
|
|
3547
|
+
// of these callback sites has total evidence at the derivation stage.
|
|
3548
|
+
deps.push({
|
|
3549
|
+
site: e,
|
|
3550
|
+
label: "cancelled timer with total sink",
|
|
3551
|
+
strength: "value",
|
|
3552
|
+
requiresTotal: callbackSitesOf(en, s.file, sf).map((c) => c.id),
|
|
3553
|
+
});
|
|
3554
|
+
}
|
|
3555
|
+
}
|
|
3556
|
+
}
|
|
3557
|
+
else if (s.category === "state-call" && ts.isCallExpression(node)) {
|
|
3558
|
+
const callee = unwrap(node.expression);
|
|
3559
|
+
if (ts.isPropertyAccessExpression(callee)) {
|
|
3560
|
+
const d = declOf(callee.name);
|
|
3561
|
+
if (d && ts.isMethodDeclaration(d)) {
|
|
3562
|
+
const mf = rel(d.getSourceFile());
|
|
3563
|
+
for (const e of sites)
|
|
3564
|
+
if (e.file === mf &&
|
|
3565
|
+
e.pos >= d.getStart() &&
|
|
3566
|
+
e.endPos <= d.getEnd())
|
|
3567
|
+
deps.push({ site: e, label: `method ${callee.name.text}` });
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
else if (s.category === "state-write") {
|
|
3572
|
+
const sd = stateWriteDeps(s);
|
|
3573
|
+
if (!sd)
|
|
3574
|
+
return undefined;
|
|
3575
|
+
deps.push(...sd);
|
|
3576
|
+
}
|
|
3577
|
+
return deps;
|
|
3578
|
+
}
|
|
3579
|
+
/** Pure syntax/flow facts. Whether a witness is needed and sufficient belongs to the Rust join. */
|
|
3580
|
+
function analyzeDecision(s) {
|
|
3581
|
+
const sf = srcByFile.get(s.file);
|
|
3582
|
+
const atom = siteNodes.get(s.id);
|
|
3583
|
+
let n = atom;
|
|
3584
|
+
const context = (x) => ts.isIfStatement(x) ||
|
|
3585
|
+
ts.isConditionalExpression(x) ||
|
|
3586
|
+
ts.isWhileStatement(x) ||
|
|
3587
|
+
ts.isDoStatement(x) ||
|
|
3588
|
+
ts.isForStatement(x);
|
|
3589
|
+
const logical = (x) => ts.isBinaryExpression(x) &&
|
|
3590
|
+
["&&", "||", "??"].includes(x.operatorToken.getText(sf));
|
|
3591
|
+
const logicalTop = (x) => logical(x) &&
|
|
3592
|
+
!logical(x.parent) &&
|
|
3593
|
+
!(context(x.parent) &&
|
|
3594
|
+
(ts.isIfStatement(x.parent) ||
|
|
3595
|
+
ts.isWhileStatement(x.parent) ||
|
|
3596
|
+
ts.isDoStatement(x.parent)
|
|
3597
|
+
? x.parent.expression === x
|
|
3598
|
+
: ts.isConditionalExpression(x.parent) || ts.isForStatement(x.parent)
|
|
3599
|
+
? x.parent.condition === x
|
|
3600
|
+
: false));
|
|
3601
|
+
while (n && !(context(n) || logicalTop(n)))
|
|
3602
|
+
n = n.parent;
|
|
3603
|
+
if (!n)
|
|
3604
|
+
return {};
|
|
3605
|
+
const facts = {};
|
|
3606
|
+
let thenRange;
|
|
3607
|
+
let elseRange;
|
|
3608
|
+
let carrier;
|
|
3609
|
+
if (ts.isIfStatement(n)) {
|
|
3610
|
+
thenRange = [n.thenStatement.getStart(sf), n.thenStatement.getEnd()];
|
|
3611
|
+
if (n.elseStatement)
|
|
3612
|
+
elseRange = [n.elseStatement.getStart(sf), n.elseStatement.getEnd()];
|
|
3613
|
+
else if (terminates(n.thenStatement) && ts.isBlock(n.parent))
|
|
3614
|
+
elseRange = [n.getEnd(), n.parent.getEnd()];
|
|
3615
|
+
}
|
|
3616
|
+
else if (ts.isWhileStatement(n) ||
|
|
3617
|
+
ts.isDoStatement(n) ||
|
|
3618
|
+
ts.isForStatement(n)) {
|
|
3619
|
+
thenRange = [n.statement.getStart(sf), n.statement.getEnd()];
|
|
3620
|
+
if (ts.isBlock(n.parent))
|
|
3621
|
+
elseRange = [n.getEnd(), n.parent.getEnd()];
|
|
3622
|
+
}
|
|
3623
|
+
else
|
|
3624
|
+
carrier = smallestSiteContaining(s.file, n.getStart(sf), n.getEnd());
|
|
3625
|
+
const objectValued = ts.isConditionalExpression(n) &&
|
|
3626
|
+
[n.whenTrue, n.whenFalse].every((b) => {
|
|
3627
|
+
const u = unwrap(b);
|
|
3628
|
+
if (!ts.isIdentifier(u))
|
|
3629
|
+
return false;
|
|
3630
|
+
const d = declOf(u);
|
|
3631
|
+
return (!!d &&
|
|
3632
|
+
ts.isVariableDeclaration(d) &&
|
|
3633
|
+
!!d.initializer &&
|
|
3634
|
+
(ts.isObjectLiteralExpression(unwrap(d.initializer)) ||
|
|
3635
|
+
ts.isArrowFunction(unwrap(d.initializer)) ||
|
|
3636
|
+
ts.isFunctionExpression(unwrap(d.initializer)) ||
|
|
3637
|
+
ts.isCallExpression(unwrap(d.initializer))));
|
|
3638
|
+
});
|
|
3639
|
+
if (objectValued && ts.isConditionalExpression(n)) {
|
|
3640
|
+
const branchSites = (b) => {
|
|
3641
|
+
const ids = new Set();
|
|
3642
|
+
const d = declOf(unwrap(b));
|
|
3643
|
+
const init = d && ts.isVariableDeclaration(d) ? d.initializer : undefined;
|
|
3644
|
+
if (!init)
|
|
3645
|
+
return ids;
|
|
3646
|
+
const add = (from, to) => {
|
|
3647
|
+
for (const e of effectSites)
|
|
3648
|
+
if (e.file === s.file && e.pos >= from && e.endPos <= to)
|
|
3649
|
+
ids.add(e.id);
|
|
3650
|
+
};
|
|
3651
|
+
add(init.getStart(sf), init.getEnd());
|
|
3652
|
+
const visit = (x) => {
|
|
3653
|
+
if (ts.isCallExpression(x) && ts.isIdentifier(x.expression)) {
|
|
3654
|
+
const f = localFunctionNode(x.expression.text, sf);
|
|
3655
|
+
if (f)
|
|
3656
|
+
add(f.getStart(sf), f.getEnd());
|
|
3657
|
+
}
|
|
3658
|
+
ts.forEachChild(x, visit);
|
|
3659
|
+
};
|
|
3660
|
+
visit(init);
|
|
3661
|
+
return ids;
|
|
3662
|
+
};
|
|
3663
|
+
const a = branchSites(n.whenTrue), b = branchSites(n.whenFalse);
|
|
3664
|
+
facts.objectValued = {
|
|
3665
|
+
onlyA: [...a].filter((x) => !b.has(x)),
|
|
3666
|
+
onlyB: [...b].filter((x) => !a.has(x)),
|
|
3667
|
+
};
|
|
3668
|
+
return facts;
|
|
3669
|
+
}
|
|
3670
|
+
if (carrier) {
|
|
3671
|
+
facts.carrier = carrier.id;
|
|
3672
|
+
return facts;
|
|
3673
|
+
}
|
|
3674
|
+
if (!thenRange) {
|
|
3675
|
+
const acc = { boundaries: [], sites: new Set() };
|
|
3676
|
+
propagateValue(n, acc, new Set(), 3);
|
|
3677
|
+
facts.valueFlow = [...acc.sites];
|
|
3678
|
+
return facts;
|
|
3679
|
+
}
|
|
3680
|
+
const thenIds = new Set(sitesInRange(s.file, thenRange));
|
|
3681
|
+
for (const id of carriersOfRange(sf, thenRange))
|
|
3682
|
+
thenIds.add(id);
|
|
3683
|
+
facts.then = [...thenIds];
|
|
3684
|
+
facts.else = null;
|
|
3685
|
+
if (elseRange) {
|
|
3686
|
+
const ids = new Set(sitesInRange(s.file, elseRange));
|
|
3687
|
+
for (const id of carriersOfRange(sf, elseRange))
|
|
3688
|
+
ids.add(id);
|
|
3689
|
+
facts.else = [...ids];
|
|
3690
|
+
}
|
|
3691
|
+
const haveOutcomes = testsWithOutcome(s, true) !== undefined &&
|
|
3692
|
+
testsWithOutcome(s, false) !== undefined;
|
|
3693
|
+
if (haveOutcomes && ts.isIfStatement(n) && exitsEarly(n.thenStatement)) {
|
|
3694
|
+
const fnNode = atom ? enclosingFunction(atom) : undefined;
|
|
3695
|
+
const fnEnd = fnNode ? fnNode.getEnd() : sf.getEnd();
|
|
3696
|
+
const downstream = effectSites.filter((d) => d.file === s.file &&
|
|
3697
|
+
d.pos >= thenRange[1] &&
|
|
3698
|
+
d.endPos <= fnEnd &&
|
|
3699
|
+
d.category !== "log");
|
|
3700
|
+
if (fnNode)
|
|
3701
|
+
for (const c of callSitesOf(fnNode)) {
|
|
3702
|
+
const cf = rel(c.getSourceFile());
|
|
3703
|
+
const callerFn = enclosingFunction(c);
|
|
3704
|
+
const callerEnd = callerFn
|
|
3705
|
+
? callerFn.getEnd()
|
|
3706
|
+
: c.getSourceFile().getEnd();
|
|
3707
|
+
for (const d of effectSites)
|
|
3708
|
+
if (d.file === cf &&
|
|
3709
|
+
d.pos >= c.getEnd() &&
|
|
3710
|
+
d.endPos <= callerEnd &&
|
|
3711
|
+
d.category !== "log")
|
|
3712
|
+
downstream.push(d);
|
|
3713
|
+
}
|
|
3714
|
+
// The prototype omitted this when a branch was already strong. Retain the
|
|
3715
|
+
// source fact here; the engine already makes that verdict-dependent choice.
|
|
3716
|
+
facts.earlyExitDownstream = downstream.map((d) => d.id);
|
|
3717
|
+
}
|
|
3718
|
+
const loopControl = (st) => ts.isContinueStatement(st) ||
|
|
3719
|
+
ts.isBreakStatement(st) ||
|
|
3720
|
+
(ts.isBlock(st) &&
|
|
3721
|
+
st.statements.length > 0 &&
|
|
3722
|
+
loopControl(st.statements[st.statements.length - 1]));
|
|
3723
|
+
if (haveOutcomes && ts.isIfStatement(n) && loopControl(n.thenStatement)) {
|
|
3724
|
+
let loop = n.parent;
|
|
3725
|
+
while (loop &&
|
|
3726
|
+
!ts.isIterationStatement(loop, false) &&
|
|
3727
|
+
!ts.isFunctionLike(loop) &&
|
|
3728
|
+
!ts.isSwitchStatement(loop))
|
|
3729
|
+
loop = loop.parent;
|
|
3730
|
+
if (loop && ts.isIterationStatement(loop, false)) {
|
|
3731
|
+
const body = loop.statement;
|
|
3732
|
+
const last = (st) => ts.isBlock(st) && st.statements.length
|
|
3733
|
+
? last(st.statements[st.statements.length - 1])
|
|
3734
|
+
: st;
|
|
3735
|
+
const from = ts.isBreakStatement(last(n.thenStatement))
|
|
3736
|
+
? body.getStart(sf)
|
|
3737
|
+
: n.getEnd();
|
|
3738
|
+
const bodySites = effectSites.filter((d) => d.file === s.file &&
|
|
3739
|
+
d.pos >= from &&
|
|
3740
|
+
d.endPos <= body.getEnd() &&
|
|
3741
|
+
d.category !== "log");
|
|
3742
|
+
const calleeSites = (node) => {
|
|
3743
|
+
const found = [];
|
|
3744
|
+
const visit = (x) => {
|
|
3745
|
+
if (ts.isCallExpression(x)) {
|
|
3746
|
+
const target = projectCallee(x.expression);
|
|
3747
|
+
if (target && !ts.isClassDeclaration(target)) {
|
|
3748
|
+
const tf = rel(target.getSourceFile());
|
|
3749
|
+
for (const d of effectSites)
|
|
3750
|
+
if (d.file === tf &&
|
|
3751
|
+
d.pos >= target.getStart() &&
|
|
3752
|
+
d.endPos <= target.getEnd() &&
|
|
3753
|
+
d.category !== "log")
|
|
3754
|
+
found.push(d);
|
|
3755
|
+
}
|
|
3756
|
+
}
|
|
3757
|
+
ts.forEachChild(x, visit);
|
|
3758
|
+
};
|
|
3759
|
+
visit(node);
|
|
3760
|
+
return found;
|
|
3761
|
+
};
|
|
3762
|
+
const visit = (x) => {
|
|
3763
|
+
if (ts.isCallExpression(x) &&
|
|
3764
|
+
x.getStart(sf) >= from &&
|
|
3765
|
+
x.getEnd() <= body.getEnd())
|
|
3766
|
+
bodySites.push(...calleeSites(x));
|
|
3767
|
+
else
|
|
3768
|
+
ts.forEachChild(x, visit);
|
|
3769
|
+
};
|
|
3770
|
+
visit(body);
|
|
3771
|
+
facts.loopBody = bodySites.map((d) => d.id);
|
|
3772
|
+
}
|
|
3773
|
+
}
|
|
3774
|
+
if (!elseRange) {
|
|
3775
|
+
facts.defaultKept = [];
|
|
3776
|
+
for (const id of thenIds) {
|
|
3777
|
+
const site = siteById.get(id);
|
|
3778
|
+
if (!site || site.category !== "state-write")
|
|
3779
|
+
continue;
|
|
3780
|
+
const deps = stateWriteDeps(site) ?? [];
|
|
3781
|
+
if (deps.length)
|
|
3782
|
+
facts.defaultKept.push({
|
|
3783
|
+
write: id,
|
|
3784
|
+
dependents: deps.map((d) => ({ site: d.site.id, label: d.label })),
|
|
3785
|
+
});
|
|
3786
|
+
}
|
|
3787
|
+
}
|
|
3788
|
+
return facts;
|
|
3789
|
+
}
|
|
3790
|
+
// Preserve the reference traversal order. The bounded recursive flow cache is
|
|
3791
|
+
// populated effect-first in the prototype; warming it in decision order can
|
|
3792
|
+
// change the recorded paths even when the syntax is identical.
|
|
3793
|
+
for (const s of effectSites)
|
|
3794
|
+
allBoundaries(s);
|
|
3795
|
+
for (const s of sites)
|
|
3796
|
+
if (s.kind === "decision")
|
|
3797
|
+
decisionFacts.set(s.id, analyzeDecision(s));
|
|
3798
|
+
const logEffectSites = effectSites.filter((e) => e.category === "log");
|
|
3799
|
+
/** Log sites an observation's message constraints admit; only the analyzer knows the template syntax. */
|
|
3800
|
+
const admittedLogSites = (ob) => {
|
|
3801
|
+
if (!ob.pattern && !ob.literal && !ob.fragment)
|
|
3802
|
+
return undefined;
|
|
3803
|
+
return logEffectSites.filter((e) => messageFits(ob, e)).map((e) => e.id);
|
|
3804
|
+
};
|
|
3805
|
+
const suppressedObservations = [];
|
|
3806
|
+
function witnessIssue(test, ob) {
|
|
3807
|
+
const reason = assertionWitnessIssue(runtimePhases.get(test), ob.assertionSource, ob.assertionMethod);
|
|
3808
|
+
if (!reason)
|
|
3809
|
+
return undefined;
|
|
3810
|
+
suppressedObservations.push({
|
|
3811
|
+
test,
|
|
3812
|
+
where: ob.where,
|
|
3813
|
+
reason,
|
|
3814
|
+
});
|
|
3815
|
+
return reason;
|
|
3816
|
+
}
|
|
3817
|
+
const factObservation = (ob) => ({
|
|
3818
|
+
boundary: ob.boundary,
|
|
3819
|
+
facet: ob.facet,
|
|
3820
|
+
strength: ob.strength,
|
|
3821
|
+
where: ob.where,
|
|
3822
|
+
assertionSource: ob.assertionSource,
|
|
3823
|
+
assertionMethod: ob.assertionMethod,
|
|
3824
|
+
negative: ob.negative,
|
|
3825
|
+
callList: ob.callList,
|
|
3826
|
+
weak: ob.weak,
|
|
3827
|
+
implicit: ob.implicit,
|
|
3828
|
+
runtime: ob.runtime,
|
|
3829
|
+
logSites: admittedLogSites(ob),
|
|
3830
|
+
// a regex several log sites can satisfy pins none of them individually; a whole literal or a
|
|
3831
|
+
// fragment does not carry that ambiguity, so the rule is about patterns only
|
|
3832
|
+
patternShared: ob.pattern
|
|
3833
|
+
? logEffectSites.filter((e) => patternMatchesSite(ob.pattern, e))
|
|
3834
|
+
.length > 1
|
|
3835
|
+
: undefined,
|
|
3836
|
+
});
|
|
3837
|
+
const factTests = runtimeTests
|
|
3838
|
+
.map((rt) => {
|
|
3839
|
+
const st = staticFor(rt);
|
|
3840
|
+
if (!st)
|
|
3841
|
+
return undefined;
|
|
3842
|
+
const checked = st.observations.map((ob) => ({
|
|
3843
|
+
ob,
|
|
3844
|
+
kind: witnessIssue(rt.id, ob),
|
|
3845
|
+
}));
|
|
3846
|
+
const observations = checked
|
|
3847
|
+
.filter(({ kind }) => !kind)
|
|
3848
|
+
.map(({ ob }) => factObservation(ob));
|
|
3849
|
+
// Missing transport applies to the whole test, even when no operand could
|
|
3850
|
+
// be modeled. An empty phase file is different from no phase file.
|
|
3851
|
+
const witnessIssues = [
|
|
3852
|
+
...(!runtimePhases.has(rt.id)
|
|
3853
|
+
? [{ kind: "capture-unavailable" }]
|
|
3854
|
+
: []),
|
|
3855
|
+
...checked
|
|
3856
|
+
.filter(({ kind }) => kind && kind !== "capture-unavailable")
|
|
3857
|
+
.map(({ ob, kind }) => ({
|
|
3858
|
+
kind: kind,
|
|
3859
|
+
source: ob.assertionSource,
|
|
3860
|
+
operation: ob.assertionMethod,
|
|
3861
|
+
observation: factObservation(ob),
|
|
3862
|
+
})),
|
|
3863
|
+
];
|
|
3864
|
+
return {
|
|
3865
|
+
id: rt.id,
|
|
3866
|
+
file: st.file,
|
|
3867
|
+
observations,
|
|
3868
|
+
...(witnessIssues.length ? { witnessIssues } : {}),
|
|
3869
|
+
sinks: st.sinks,
|
|
3870
|
+
rendered: [...st.rendered],
|
|
3871
|
+
};
|
|
3872
|
+
})
|
|
3873
|
+
.filter((t) => t !== undefined);
|
|
3874
|
+
// vi.mock boundaries depend on the test file, not the test: one entry per (file, site) pair that has any
|
|
3875
|
+
const mocksByTestFile = {};
|
|
3876
|
+
for (const file of new Set(factTests.map((t) => t.file))) {
|
|
3877
|
+
const perSite = {};
|
|
3878
|
+
for (const s of sites) {
|
|
3879
|
+
const bs = moduleMockBoundaries(s, file);
|
|
3880
|
+
if (bs.length)
|
|
3881
|
+
perSite[s.id] = bs;
|
|
3882
|
+
}
|
|
3883
|
+
if (Object.keys(perSite).length)
|
|
3884
|
+
mocksByTestFile[file] = perSite;
|
|
3885
|
+
}
|
|
3886
|
+
const factSites = sites.map((s) => {
|
|
3887
|
+
const { bounds, reached } = allBoundaries(s);
|
|
3888
|
+
const tTrue = testsWithOutcome(s, true);
|
|
3889
|
+
const tFalse = testsWithOutcome(s, false);
|
|
3890
|
+
const selected = testsWhereSelected(s);
|
|
3891
|
+
const covered = runtimeTests
|
|
3892
|
+
.filter((t) => covers(t.id, s))
|
|
3893
|
+
.map((t) => t.id);
|
|
3894
|
+
const derive = s.kind === "effect"
|
|
3895
|
+
? (deriveDeps(s) ?? []).map((d) => ({
|
|
3896
|
+
site: d.site.id,
|
|
3897
|
+
label: d.label,
|
|
3898
|
+
strength: d.strength,
|
|
3899
|
+
requiresTotal: d.requiresTotal,
|
|
3900
|
+
}))
|
|
3901
|
+
: [];
|
|
3902
|
+
return {
|
|
3903
|
+
id: s.id,
|
|
3904
|
+
file: s.file,
|
|
3905
|
+
line: s.start.line,
|
|
3906
|
+
kind: s.kind,
|
|
3907
|
+
category: s.category,
|
|
3908
|
+
classification: s.classification,
|
|
3909
|
+
owner: s.owner,
|
|
3910
|
+
method: s.method,
|
|
3911
|
+
bounds,
|
|
3912
|
+
// A site reached by another site's value is read at its own boundary only: the flow that carried
|
|
3913
|
+
// the value there does not carry it onward, so `bounds` (which includes that flow) is too wide.
|
|
3914
|
+
directBounds: directBoundaries(s),
|
|
3915
|
+
reached: reached.map((r) => r.id),
|
|
3916
|
+
coveredBy: covered,
|
|
3917
|
+
objectValuedReturn: (s.category === "return" || s.category === "callback-return") &&
|
|
3918
|
+
isObjectValuedReturn(s),
|
|
3919
|
+
unmodelledShapes: unmodelledOperands(s, runtimeTests.filter((t) => covers(t.id, s))),
|
|
3920
|
+
...(s.kind === "decision"
|
|
3921
|
+
? {
|
|
3922
|
+
decision: {
|
|
3923
|
+
...(decisionFacts.get(s.id) ?? {}),
|
|
3924
|
+
outcomes: tTrue && tFalse
|
|
3925
|
+
? { true: [...tTrue], false: [...tFalse] }
|
|
3926
|
+
: undefined,
|
|
3927
|
+
selected: selected ? [...selected] : undefined,
|
|
3928
|
+
},
|
|
3929
|
+
}
|
|
3930
|
+
: {}),
|
|
3931
|
+
...(derive.length ? { derive } : {}),
|
|
3932
|
+
};
|
|
3933
|
+
});
|
|
3934
|
+
return {
|
|
3935
|
+
pragmas: pragmaCollector.finish(runtimeTests.flatMap((rt) => {
|
|
3936
|
+
const st = staticFor(rt);
|
|
3937
|
+
return st
|
|
3938
|
+
? [
|
|
3939
|
+
{
|
|
3940
|
+
testKey: staticTestKey(st.file, st.line, st.name),
|
|
3941
|
+
id: rt.id,
|
|
3942
|
+
phases: runtimePhases.get(rt.id),
|
|
3943
|
+
},
|
|
3944
|
+
]
|
|
3945
|
+
: [];
|
|
3946
|
+
})),
|
|
3947
|
+
facts: {
|
|
3948
|
+
schema: 1,
|
|
3949
|
+
root,
|
|
3950
|
+
sites: factSites,
|
|
3951
|
+
tests: factTests,
|
|
3952
|
+
mocksByTestFile,
|
|
3953
|
+
},
|
|
3954
|
+
diagnostics: {
|
|
3955
|
+
suppressedObservations,
|
|
3956
|
+
observationPolicy: "source-linked-v3: exact successful call witness; rejected witnesses retain typed provenance, not value credit",
|
|
3957
|
+
runtimeTests: runtimeTests.length,
|
|
3958
|
+
linkedTests: factTests.length,
|
|
3959
|
+
staticTests: staticTests.length,
|
|
3960
|
+
linkedByAssertionLines: linkedByPhases,
|
|
3961
|
+
linkedByTitle,
|
|
3962
|
+
linkWarnings,
|
|
3963
|
+
unrecognizedOperands: [...unrecognized].map(([shape, count]) => ({
|
|
3964
|
+
shape,
|
|
3965
|
+
count,
|
|
3966
|
+
})),
|
|
3967
|
+
compilerVersion: frontend.version,
|
|
3968
|
+
compilerFrontend: frontend.kind,
|
|
3969
|
+
compilerLimitations: [...frontend.limitations].sort(),
|
|
3970
|
+
},
|
|
3971
|
+
};
|
|
3972
|
+
}
|