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