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.
Files changed (34) hide show
  1. package/README.md +23 -3
  2. package/analyzers/typescript/README.md +55 -0
  3. package/analyzers/typescript/bin/compiler-identity.mjs +78 -0
  4. package/analyzers/typescript/bin/identity.mjs +67 -0
  5. package/analyzers/typescript/bin/query.mjs +29 -0
  6. package/analyzers/typescript/dist/analyze.js +3972 -0
  7. package/analyzers/typescript/dist/archive.js +309 -0
  8. package/analyzers/typescript/dist/build-identity.json +1 -0
  9. package/analyzers/typescript/dist/compiler.js +32 -0
  10. package/analyzers/typescript/dist/frontend.js +75 -0
  11. package/analyzers/typescript/dist/native-frontend.js +271 -0
  12. package/analyzers/typescript/dist/pragmas.js +143 -0
  13. package/analyzers/typescript/dist/types.js +1 -0
  14. package/analyzers/typescript/package.json +27 -0
  15. package/analyzers/typescript/src/analyze.ts +4538 -0
  16. package/analyzers/typescript/src/archive.ts +438 -0
  17. package/analyzers/typescript/src/compiler.ts +49 -0
  18. package/analyzers/typescript/src/frontend.ts +136 -0
  19. package/analyzers/typescript/src/native-frontend.ts +315 -0
  20. package/analyzers/typescript/src/pragmas.ts +218 -0
  21. package/analyzers/typescript/src/types.ts +45 -0
  22. package/analyzers/typescript/tsconfig.json +12 -0
  23. package/docs/agent-loop.md +13 -0
  24. package/docs/assertion-evidence.md +135 -0
  25. package/docs/cli.md +10 -0
  26. package/docs/code-verification.md +182 -0
  27. package/docs/supported-suites.md +40 -9
  28. package/docs/verification.md +12 -0
  29. package/package.json +33 -15
  30. package/runtime/javascript/jest.cjs +134 -0
  31. package/runtime/javascript/jest.config.mjs +39 -0
  32. package/runtime/javascript/jestReporter.mjs +77 -0
  33. package/runtime/javascript/register.mjs +22 -4
  34. 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
+ }