supercov 0.0.43 → 0.0.44

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.
@@ -8,6 +8,17 @@ import { relative as pathRelative, resolve } from "node:path";
8
8
  import { analysisPath } from "./compiler.js";
9
9
  import { createFrontend, type CompilerFrontend } from "./frontend.js";
10
10
  import { assertionWitnessIssue, collectPragmas } from "./pragmas.js";
11
+ import { awaitedObservationSource } from "./awaited-observations.js";
12
+ import {
13
+ analyzeMockCounts,
14
+ analyzeFirstTestOmission,
15
+ analyzeCountSensitivity,
16
+ analyzePayloadSensitivity,
17
+ analyzeDirectReturnSensitivity,
18
+ analyzeCompletionSensitivity,
19
+ sourceTestRows,
20
+ type MockCountEvidence,
21
+ } from "./mock-counts.js";
11
22
  import type { AnalyzeOptions, Site } from "./types.js";
12
23
  export type { AnalyzeOptions, Site } from "./types.js";
13
24
 
@@ -85,6 +96,7 @@ export function analyzeWithFrontend(
85
96
  /** supercov runs carry no test line: the leaf title and the lines of the test's assertion phases link it instead */
86
97
  title?: string;
87
98
  phaseLines?: number[];
99
+ runner?: string;
88
100
  }
89
101
  const runtimeTests = (
90
102
  JSON.parse(readEvidence("cov/index.json")) as RuntimeTest[]
@@ -489,6 +501,76 @@ export function analyzeWithFrontend(
489
501
  /** the assertion pins the sink's whole call list (a call count, or `mock.calls` compared as a whole or
490
502
  * through a projection): it witnesses both that the pinned calls happened and that no other call did */
491
503
  callList?: boolean;
504
+ /** Source projection of a Node mock, not proof of production-site dependence. */
505
+ mock?: MockProjection;
506
+ /** Relation between both operands; strength alone cannot establish a value check. */
507
+ comparison?: Comparison;
508
+ /** Resolver source facts are not a verified producer/consumer instance link. */
509
+ processExit?: ProcessExitEvidence;
510
+ }
511
+ interface ProcessExitEvidence {
512
+ model: "node-child-exit-source-v1";
513
+ status: "unresolved";
514
+ reason: string;
515
+ operand: string;
516
+ helperCalls: string[];
517
+ promise?: string;
518
+ spawn?: string;
519
+ event?: { source: string; name: string };
520
+ resolution?: {
521
+ status: "source-checked";
522
+ source: string;
523
+ field?: string;
524
+ eventArgument: "code" | "signal";
525
+ };
526
+ consumer?: {
527
+ status: "source-checked" | "unresolved";
528
+ reason?: string;
529
+ bindings: string[];
530
+ read: string;
531
+ blockedAt?: string;
532
+ };
533
+ }
534
+ interface ComparisonOperand {
535
+ source: string;
536
+ binding?: string;
537
+ value?: SourcePrimitive;
538
+ /** Shared input provenance, not equality of the evaluated operands. */
539
+ input?: ComparisonInput;
540
+ }
541
+ interface ComparisonInput {
542
+ binding: string;
543
+ awaits: string[];
544
+ }
545
+ interface Comparison {
546
+ predicate: string;
547
+ actual: ComparisonOperand;
548
+ expected: ComparisonOperand;
549
+ relation:
550
+ "same-immutable-binding" | "shared-input-through-await" | "unresolved";
551
+ }
552
+ type SourcePrimitive =
553
+ | { kind: "number" | "string"; value: string }
554
+ | { kind: "boolean"; value: boolean }
555
+ | { kind: "null" };
556
+ interface PrimitiveDecision {
557
+ model: "js-primitive-decision-v1";
558
+ source: string;
559
+ whenTrue: SourcePrimitive;
560
+ whenFalse: SourcePrimitive;
561
+ checks: {
562
+ test: string;
563
+ assertionSource: string;
564
+ predicate: string;
565
+ expected: SourcePrimitive;
566
+ originalOutcome: boolean;
567
+ }[];
568
+ }
569
+ interface MockProjection {
570
+ target: string;
571
+ kind: "call-count" | "call-arguments" | "call-history" | "projection";
572
+ path: string[];
573
+ countEvidence?: MockCountEvidence;
492
574
  }
493
575
 
494
576
  /** Does a log site's message template (constant parts in order, placeholders as wildcards) fit an asserted literal? */
@@ -1336,6 +1418,8 @@ export function analyzeWithFrontend(
1336
1418
  pending: PendingOperand[];
1337
1419
  }
1338
1420
  interface PendingOperand {
1421
+ assertionSource: string;
1422
+ assertionMethod: string;
1339
1423
  /** "file:line:column" of the statements that compute the operand (its own statement and the
1340
1424
  * declarations/assignments of the variables it reads) */
1341
1425
  statements: string[];
@@ -1352,14 +1436,12 @@ export function analyzeWithFrontend(
1352
1436
  // A path into such an object that ends at a mock is a sink: `sink:mocks.b.c`.
1353
1437
  // ---------------------------------------------------------------------------
1354
1438
  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
- )
1439
+ if (!(
1440
+ ts.isArrowFunction(fn) ||
1441
+ ts.isFunctionExpression(fn) ||
1442
+ ts.isMethodDeclaration(fn) ||
1443
+ ts.isFunctionDeclaration(fn)
1444
+ ))
1363
1445
  return undefined;
1364
1446
  const body = fn.body;
1365
1447
  if (!body) return undefined;
@@ -1770,12 +1852,10 @@ export function analyzeWithFrontend(
1770
1852
  const mocks =
1771
1853
  moduleMocksByFile.get(relative(root, id.getSourceFile().fileName)) ?? [];
1772
1854
  for (const m of mocks) {
1773
- if (
1774
- !(
1775
- (m.resolved && imp.resolved && m.resolved === imp.resolved) ||
1776
- m.spec === imp.spec
1777
- )
1778
- )
1855
+ if (!(
1856
+ (m.resolved && imp.resolved && m.resolved === imp.resolved) ||
1857
+ m.spec === imp.spec
1858
+ ))
1779
1859
  continue;
1780
1860
  for (const b of m.exports)
1781
1861
  if (b.sink && b.path.length === 1 && b.path[0] === imp.importedName)
@@ -2007,6 +2087,7 @@ export function analyzeWithFrontend(
2007
2087
  doesNotReject: "presence",
2008
2088
  };
2009
2089
  const staticTests: StaticTest[] = [];
2090
+ const mockBodies = new Map<StaticTest, ts.Node>();
2010
2091
  const pragmaCollector = collectPragmas(
2011
2092
  ts,
2012
2093
  allFiles.filter(isTestFile),
@@ -2016,6 +2097,778 @@ export function analyzeWithFrontend(
2016
2097
  const staticTestKey = (file: string, line: number, name: string) =>
2017
2098
  JSON.stringify([file, line, name]);
2018
2099
 
2100
+ // Unlike origin tracing, comparison identity must NOT erase await, calls,
2101
+ // getters or transformations. Only syntax with no runtime operation is peeled.
2102
+ function comparisonExpression(e: ts.Expression): ts.Expression {
2103
+ while (
2104
+ ts.isParenthesizedExpression(e) ||
2105
+ ts.isNonNullExpression(e) ||
2106
+ ts.isAsExpression(e) ||
2107
+ ts.isTypeAssertionExpression(e) ||
2108
+ ts.isSatisfiesExpression(e)
2109
+ )
2110
+ e = e.expression;
2111
+ return e;
2112
+ }
2113
+
2114
+ /** Native import identity, not the spelling of a local helper. Bare native
2115
+ * assert and imported ok are the same truthiness operation in the recorder.
2116
+ * Keep this canonicalization at discovery: the witness checker must not equate
2117
+ * arbitrary operations named assert and ok or weaken its source/status check. */
2118
+ function nativeAssertionIdentity(
2119
+ call: ts.CallExpression,
2120
+ ): { module: string; method: string } | undefined {
2121
+ type Binding = {
2122
+ module: string;
2123
+ kind: "callable" | "namespace" | "method";
2124
+ method?: string;
2125
+ };
2126
+ function binding(raw: ts.Expression): Binding | undefined {
2127
+ const e = comparisonExpression(raw);
2128
+ if (ts.isIdentifier(e)) {
2129
+ const d = checker.getSymbolAtLocation(e)?.declarations?.[0];
2130
+ if (!d) return;
2131
+ let imported: ts.ImportDeclaration | ts.JSDocImportTag;
2132
+ let kind: Binding["kind"];
2133
+ let name: string | undefined;
2134
+ if (ts.isImportClause(d) && !d.isTypeOnly) {
2135
+ imported = d.parent;
2136
+ kind = "callable";
2137
+ } else if (ts.isNamespaceImport(d) && !d.parent.isTypeOnly) {
2138
+ imported = d.parent.parent;
2139
+ kind = "namespace";
2140
+ } else if (
2141
+ ts.isImportSpecifier(d) &&
2142
+ !d.isTypeOnly &&
2143
+ !d.parent.parent.isTypeOnly
2144
+ ) {
2145
+ imported = d.parent.parent.parent;
2146
+ name = (d.propertyName ?? d.name).text;
2147
+ kind =
2148
+ name === "default" || name === "strict" ? "callable" : "method";
2149
+ } else return;
2150
+ if (
2151
+ !ts.isImportDeclaration(imported) ||
2152
+ !ts.isStringLiteralLike(imported.moduleSpecifier)
2153
+ )
2154
+ return;
2155
+ const specifier = imported.moduleSpecifier.text;
2156
+ if (
2157
+ ![
2158
+ "node:assert",
2159
+ "node:assert/strict",
2160
+ "assert",
2161
+ "assert/strict",
2162
+ ].includes(specifier)
2163
+ )
2164
+ return;
2165
+ let module = specifier.startsWith("node:")
2166
+ ? specifier
2167
+ : `node:${specifier}`;
2168
+ if (name === "strict") module = "node:assert/strict";
2169
+ if (
2170
+ kind === "method" &&
2171
+ (!name || name === "assert" || !Object.hasOwn(ASSERT_STRENGTH, name))
2172
+ )
2173
+ return;
2174
+ return { module, kind, method: kind === "method" ? name : undefined };
2175
+ }
2176
+ if (!ts.isPropertyAccessExpression(e)) return;
2177
+ const receiver = binding(e.expression);
2178
+ if (!receiver || receiver.kind === "method") return;
2179
+ if (e.name.text === "strict")
2180
+ return { module: "node:assert/strict", kind: "callable" };
2181
+ if (e.name.text === "default" && receiver.kind === "namespace")
2182
+ return { module: receiver.module, kind: "callable" };
2183
+ if (
2184
+ e.name.text === "assert" ||
2185
+ !Object.hasOwn(ASSERT_STRENGTH, e.name.text)
2186
+ )
2187
+ return;
2188
+ return { module: receiver.module, kind: "method", method: e.name.text };
2189
+ }
2190
+ const resolved = binding(call.expression);
2191
+ if (!resolved || resolved.kind === "namespace") return;
2192
+ return { module: resolved.module, method: resolved.method ?? "ok" };
2193
+ }
2194
+
2195
+ function comparisonLocation(n: ts.Node): string {
2196
+ const sf = n.getSourceFile();
2197
+ return `${rel(sf)}:${n.getStart(sf)}:${n.getEnd()}`;
2198
+ }
2199
+
2200
+ function immutableBinding(
2201
+ expr: ts.Expression,
2202
+ seen = new Set<ts.Node>(),
2203
+ ): string | undefined {
2204
+ const e = comparisonExpression(expr);
2205
+ if (!ts.isIdentifier(e) || seen.size >= 32) return undefined;
2206
+ const d = declOf(e);
2207
+ if (
2208
+ !d ||
2209
+ !ts.isVariableDeclaration(d) ||
2210
+ !ts.isIdentifier(d.name) ||
2211
+ !d.initializer ||
2212
+ !ts.isVariableDeclarationList(d.parent) ||
2213
+ !(d.parent.flags & ts.NodeFlags.Const) ||
2214
+ seen.has(d)
2215
+ )
2216
+ return undefined;
2217
+ seen.add(d);
2218
+ // A copy of a mutable binding has its own identity, not a live alias.
2219
+ return immutableBinding(d.initializer, seen) ?? comparisonLocation(d);
2220
+ }
2221
+
2222
+ // Trace only const aliases and await. Unlike immutableBinding, this describes
2223
+ // an input dependency: awaiting a Promise or thenable need not preserve its
2224
+ // value, nor do two awaits necessarily return the same result.
2225
+ function comparisonInput(
2226
+ expr: ts.Expression,
2227
+ seen = new Set<ts.Node>(),
2228
+ ): ComparisonInput | undefined {
2229
+ const e = comparisonExpression(expr);
2230
+ if (seen.size >= 32 || seen.has(e)) return undefined;
2231
+ seen.add(e);
2232
+ if (ts.isAwaitExpression(e)) {
2233
+ const input = comparisonInput(e.expression, seen);
2234
+ return (
2235
+ input && {
2236
+ binding: input.binding,
2237
+ awaits: [comparisonLocation(e), ...input.awaits],
2238
+ }
2239
+ );
2240
+ }
2241
+ if (!ts.isIdentifier(e)) return undefined;
2242
+ const d = declOf(e);
2243
+ if (
2244
+ !d ||
2245
+ !ts.isVariableDeclaration(d) ||
2246
+ !ts.isIdentifier(d.name) ||
2247
+ !d.initializer ||
2248
+ !ts.isVariableDeclarationList(d.parent) ||
2249
+ !(d.parent.flags & ts.NodeFlags.Const) ||
2250
+ seen.has(d)
2251
+ )
2252
+ return undefined;
2253
+ seen.add(d);
2254
+ // A copied mutable binding, call or property read anchors a fresh const
2255
+ // input. Do not equate repeated calls/getters or follow mutable aliases.
2256
+ return (
2257
+ comparisonInput(d.initializer, seen) ?? {
2258
+ binding: comparisonLocation(d),
2259
+ awaits: [],
2260
+ }
2261
+ );
2262
+ }
2263
+
2264
+ /** Literal syntax only: do not resolve globals, call code or lose signed zero. */
2265
+ function sourcePrimitive(raw: ts.Expression): SourcePrimitive | undefined {
2266
+ const e = comparisonExpression(raw);
2267
+ if (ts.isStringLiteralLike(e)) {
2268
+ // Rust strings cannot represent lone UTF-16 surrogates. Leave those and
2269
+ // large literals unsupported instead of changing or truncating their value.
2270
+ if (e.text.length > 4096) return;
2271
+ for (const point of e.text)
2272
+ if (
2273
+ point.length === 1 &&
2274
+ point.charCodeAt(0) >= 0xd800 &&
2275
+ point.charCodeAt(0) <= 0xdfff
2276
+ )
2277
+ return;
2278
+ return { kind: "string", value: e.text };
2279
+ }
2280
+ if (e.kind === ts.SyntaxKind.TrueKeyword)
2281
+ return { kind: "boolean", value: true };
2282
+ if (e.kind === ts.SyntaxKind.FalseKeyword)
2283
+ return { kind: "boolean", value: false };
2284
+ if (e.kind === ts.SyntaxKind.NullKeyword) return { kind: "null" };
2285
+ let number: number;
2286
+ if (ts.isNumericLiteral(e)) number = Number(e.text);
2287
+ else if (
2288
+ ts.isPrefixUnaryExpression(e) &&
2289
+ [ts.SyntaxKind.MinusToken, ts.SyntaxKind.PlusToken].includes(
2290
+ e.operator,
2291
+ ) &&
2292
+ ts.isNumericLiteral(e.operand)
2293
+ )
2294
+ number =
2295
+ Number(e.operand.text) *
2296
+ (e.operator === ts.SyntaxKind.MinusToken ? -1 : 1);
2297
+ else return;
2298
+ if (!Number.isFinite(number)) return;
2299
+ return {
2300
+ kind: "number",
2301
+ value: Object.is(number, -0) ? "-0" : String(number),
2302
+ };
2303
+ }
2304
+
2305
+ function nativeComparison(call: ts.CallExpression): Comparison | undefined {
2306
+ const callee = comparisonExpression(call.expression);
2307
+ if (!ts.isPropertyAccessExpression(callee) || call.arguments.length < 2)
2308
+ return undefined;
2309
+ const receiver = comparisonExpression(callee.expression);
2310
+ if (!ts.isIdentifier(receiver)) return undefined;
2311
+ // Read the import declaration itself, before resolving its module alias.
2312
+ // A helper named `assert` is not the native assertion implementation.
2313
+ const declaration =
2314
+ checker.getSymbolAtLocation(receiver)?.declarations?.[0];
2315
+ if (
2316
+ !declaration ||
2317
+ !(ts.isImportClause(declaration) || ts.isNamespaceImport(declaration))
2318
+ )
2319
+ return undefined;
2320
+ const imported = ts.isImportClause(declaration)
2321
+ ? declaration.parent
2322
+ : declaration.parent.parent;
2323
+ if (!ts.isStringLiteralLike(imported.moduleSpecifier)) return undefined;
2324
+ const module = imported.moduleSpecifier.text;
2325
+ if (
2326
+ ![
2327
+ "node:assert",
2328
+ "node:assert/strict",
2329
+ "assert",
2330
+ "assert/strict",
2331
+ ].includes(module)
2332
+ )
2333
+ return undefined;
2334
+ const strict = module.endsWith("/strict");
2335
+ const predicates: Record<string, string> = {
2336
+ equal: strict ? "node-same-value" : "node-loose-equality",
2337
+ strictEqual: "node-same-value",
2338
+ notEqual: strict ? "node-not-same-value" : "node-not-loose-equality",
2339
+ notStrictEqual: "node-not-same-value",
2340
+ deepEqual: strict ? "node-deep-strict-equality" : "node-deep-equality",
2341
+ deepStrictEqual: "node-deep-strict-equality",
2342
+ };
2343
+ const predicate = predicates[callee.name.text];
2344
+ if (!predicate) return undefined;
2345
+ const operand = (arg: ts.Expression): ComparisonOperand => ({
2346
+ source: comparisonLocation(arg),
2347
+ binding: immutableBinding(arg),
2348
+ value: sourcePrimitive(arg),
2349
+ });
2350
+ const actual = operand(call.arguments[0]);
2351
+ const expected = operand(call.arguments[1]);
2352
+ const actualInput = comparisonInput(call.arguments[0]);
2353
+ const expectedInput = comparisonInput(call.arguments[1]);
2354
+ const sharedAwaitedInput =
2355
+ actualInput &&
2356
+ expectedInput &&
2357
+ actualInput.binding === expectedInput.binding &&
2358
+ actualInput.awaits.length + expectedInput.awaits.length > 0;
2359
+ const sameBinding = actual.binding && actual.binding === expected.binding;
2360
+ if (!sameBinding && sharedAwaitedInput) {
2361
+ actual.input = actualInput;
2362
+ expected.input = expectedInput;
2363
+ }
2364
+ return {
2365
+ predicate,
2366
+ actual,
2367
+ expected,
2368
+ relation: sameBinding
2369
+ ? "same-immutable-binding"
2370
+ : sharedAwaitedInput
2371
+ ? "shared-input-through-await"
2372
+ : "unresolved",
2373
+ };
2374
+ }
2375
+
2376
+ function nativeContextMock(call: ts.CallExpression): boolean {
2377
+ const callee = unwrap(call.expression);
2378
+ if (!ts.isPropertyAccessExpression(callee)) return false;
2379
+ const tracker = unwrap(callee.expression);
2380
+ if (!ts.isPropertyAccessExpression(tracker) || tracker.name.text !== "mock")
2381
+ return false;
2382
+ const context = declOf(unwrap(tracker.expression));
2383
+ if (!context || !ts.isParameter(context)) return false;
2384
+ const callback = context.parent;
2385
+ if (
2386
+ !(ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) ||
2387
+ callback.parameters[0] !== context ||
2388
+ !ts.isCallExpression(callback.parent)
2389
+ )
2390
+ return false;
2391
+ return nativeTestRegistration(callback.parent);
2392
+ }
2393
+
2394
+ function nativeTestRegistration(call: ts.CallExpression): boolean {
2395
+ const registration = comparisonExpression(call.expression);
2396
+ if (!ts.isIdentifier(registration)) return false;
2397
+ const declaration =
2398
+ checker.getSymbolAtLocation(registration)?.declarations?.[0];
2399
+ if (
2400
+ !declaration ||
2401
+ !(ts.isImportSpecifier(declaration) || ts.isImportClause(declaration))
2402
+ )
2403
+ return false;
2404
+ if (
2405
+ ts.isImportSpecifier(declaration) &&
2406
+ !["test", "it"].includes(
2407
+ (declaration.propertyName ?? declaration.name).text,
2408
+ )
2409
+ )
2410
+ return false;
2411
+ const imported = ts.isImportSpecifier(declaration)
2412
+ ? declaration.parent.parent.parent
2413
+ : declaration.parent;
2414
+ return (
2415
+ ts.isStringLiteralLike(imported.moduleSpecifier) &&
2416
+ imported.moduleSpecifier.text === "node:test"
2417
+ );
2418
+ }
2419
+
2420
+ function mockProjection(o: Origin): MockProjection | undefined {
2421
+ if (!o.kind.startsWith("mock:console.")) return undefined;
2422
+ const path = o.path;
2423
+ const joined = path.join(".");
2424
+ const kind =
2425
+ joined === "mock.callCount()" ||
2426
+ (path[0] === "mock" &&
2427
+ path[1] === "calls" &&
2428
+ path[path.length - 1] === "length" &&
2429
+ path.slice(2, -1).every((step) => /^slice\([\s\S]*\)$/.test(step)))
2430
+ ? "call-count"
2431
+ : joined === "mock.calls"
2432
+ ? "call-history"
2433
+ : /(?:^|\.)arguments(?:\.\[\d+\])?$/.test(joined)
2434
+ ? "call-arguments"
2435
+ : "projection";
2436
+ return { target: o.kind.slice(5), kind, path };
2437
+ }
2438
+
2439
+ /** Follow source projections to a Promise, then check only its resolver mapping.
2440
+ * This does not prove that the resolved object was not subsequently mutated,
2441
+ * or that the selected child is the producer of every covered exit site. */
2442
+ function childExitSource(
2443
+ expr: ts.Expression,
2444
+ ): ProcessExitEvidence | undefined {
2445
+ const info: ProcessExitEvidence = {
2446
+ model: "node-child-exit-source-v1",
2447
+ status: "unresolved",
2448
+ reason: "unsupported-promise-source",
2449
+ operand: comparisonLocation(expr),
2450
+ helperCalls: [],
2451
+ };
2452
+ const seen = new Set<ts.Node>();
2453
+ const carriers = new Map<ts.VariableDeclaration, Set<ts.Node>>();
2454
+ const helperReturns = new Map<ts.CallExpression, ts.Expression>();
2455
+ let openCarrier = false;
2456
+ const fail = (reason: string) => ({ ...info, reason });
2457
+ const nativeSpawn = (call: ts.CallExpression) => {
2458
+ const callee = comparisonExpression(call.expression);
2459
+ if (!ts.isIdentifier(callee)) return false;
2460
+ const raw = checker.getSymbolAtLocation(callee)?.declarations?.[0];
2461
+ if (!raw || !ts.isImportSpecifier(raw)) return false;
2462
+ const imported = raw.parent.parent.parent;
2463
+ return (
2464
+ (raw.propertyName ?? raw.name).text === "spawn" &&
2465
+ ts.isStringLiteralLike(imported.moduleSpecifier) &&
2466
+ (imported.moduleSpecifier.text === "node:child_process" ||
2467
+ imported.moduleSpecifier.text === "child_process")
2468
+ );
2469
+ };
2470
+ const property = (object: ts.ObjectLiteralExpression, key: string) => {
2471
+ // No spreads, getters, computed keys, duplicates or inherited properties.
2472
+ const entries = new Map<string, ts.Expression>();
2473
+ for (const p of object.properties) {
2474
+ if (
2475
+ !(
2476
+ ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)
2477
+ ) ||
2478
+ !(ts.isIdentifier(p.name) || ts.isStringLiteralLike(p.name))
2479
+ )
2480
+ return;
2481
+ const name = p.name.text;
2482
+ if (name === "__proto__" || name === "then" || entries.has(name))
2483
+ return;
2484
+ entries.set(name, ts.isPropertyAssignment(p) ? p.initializer : p.name);
2485
+ }
2486
+ return entries.get(key);
2487
+ };
2488
+ const trace = (
2489
+ raw: ts.Expression,
2490
+ path: string[],
2491
+ awaitedPath?: string[],
2492
+ scope = enclosingFunction(expr),
2493
+ ): ProcessExitEvidence | undefined => {
2494
+ const e = comparisonExpression(raw);
2495
+ if (seen.size >= 48 || seen.has(e)) return;
2496
+ seen.add(e);
2497
+ if (ts.isAwaitExpression(e))
2498
+ return trace(e.expression, path, [...path], scope);
2499
+ if (ts.isPropertyAccessExpression(e))
2500
+ return trace(e.expression, [e.name.text, ...path], awaitedPath, scope);
2501
+ if (ts.isIdentifier(e)) {
2502
+ const d = declOf(e);
2503
+ if (
2504
+ d &&
2505
+ ts.isVariableDeclaration(d) &&
2506
+ ts.isIdentifier(d.name) &&
2507
+ d.initializer &&
2508
+ ts.isVariableDeclarationList(d.parent) &&
2509
+ d.parent.flags & ts.NodeFlags.Const
2510
+ ) {
2511
+ if (!scope || enclosingFunction(d) !== scope) openCarrier = true;
2512
+ const uses = carriers.get(d) ?? new Set<ts.Node>();
2513
+ uses.add(e);
2514
+ carriers.set(d, uses);
2515
+ return trace(d.initializer, path, awaitedPath, scope);
2516
+ }
2517
+ return;
2518
+ }
2519
+ if (ts.isObjectLiteralExpression(e)) {
2520
+ const next = path.length ? property(e, path[0]) : undefined;
2521
+ return next
2522
+ ? trace(next, path.slice(1), awaitedPath, scope)
2523
+ : undefined;
2524
+ }
2525
+ if (
2526
+ ts.isCallExpression(e) &&
2527
+ ts.isIdentifier(comparisonExpression(e.expression))
2528
+ ) {
2529
+ const d = declOf(comparisonExpression(e.expression));
2530
+ const fn = d && ts.isFunctionDeclaration(d) ? d : undefined;
2531
+ if (
2532
+ !fn?.body ||
2533
+ fn.getSourceFile().isDeclarationFile ||
2534
+ seen.has(fn) ||
2535
+ fn.asteriskToken ||
2536
+ fn.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)
2537
+ )
2538
+ return;
2539
+ seen.add(fn);
2540
+ // Only one direct top-level return. Do not select the first branch or a
2541
+ // nested callback's return, and do not treat a helper name as a contract.
2542
+ const returns: ts.ReturnStatement[] = [];
2543
+ let budget = 4096;
2544
+ const scan = (n: ts.Node) => {
2545
+ if (--budget < 0) return;
2546
+ if (ts.isReturnStatement(n)) returns.push(n);
2547
+ else if (!ts.isFunctionLike(n)) ts.forEachChild(n, scan);
2548
+ };
2549
+ scan(fn.body);
2550
+ const [ret] = returns;
2551
+ if (
2552
+ budget < 0 ||
2553
+ returns.length !== 1 ||
2554
+ ret.parent !== fn.body ||
2555
+ !ret.expression
2556
+ )
2557
+ return;
2558
+ info.helperCalls.push(comparisonLocation(e));
2559
+ helperReturns.set(e, ret.expression);
2560
+ return trace(ret.expression, path, awaitedPath, fn);
2561
+ }
2562
+ if (
2563
+ !ts.isNewExpression(e) ||
2564
+ !ts.isIdentifier(e.expression) ||
2565
+ e.expression.text !== "Promise"
2566
+ )
2567
+ return;
2568
+ // Discovery is structural only. A Promise with no exit/close listener is
2569
+ // not an exit observation, even if a comment contains listener-shaped text.
2570
+ let hasEvent = false,
2571
+ discoveryBudget = 4096;
2572
+ const discover = (n: ts.Node) => {
2573
+ if (--discoveryBudget < 0 || hasEvent) return;
2574
+ if (
2575
+ ts.isCallExpression(n) &&
2576
+ ts.isPropertyAccessExpression(n.expression) &&
2577
+ ["on", "once"].includes(n.expression.name.text) &&
2578
+ n.arguments[0] &&
2579
+ ts.isStringLiteralLike(n.arguments[0]) &&
2580
+ ["exit", "close"].includes(n.arguments[0].text)
2581
+ )
2582
+ hasEvent = true;
2583
+ ts.forEachChild(n, discover);
2584
+ };
2585
+ if (e.arguments?.[0]) discover(e.arguments[0]);
2586
+ if (!hasEvent) return;
2587
+ info.promise = comparisonLocation(e);
2588
+ const promiseBinding = declOf(e.expression);
2589
+ // Compiler sessions may omit standard libraries. As with the native mock
2590
+ // model, global built-ins are assumed unmodified; a project declaration
2591
+ // with this name is never accepted as that global.
2592
+ if (promiseBinding && !promiseBinding.getSourceFile().isDeclarationFile)
2593
+ return fail("native-promise-binding-unverified");
2594
+ if (JSON.stringify(awaitedPath) !== JSON.stringify(path))
2595
+ return fail("promise-result-not-awaited-before-projection");
2596
+ const executor = e.arguments?.[0] && comparisonExpression(e.arguments[0]);
2597
+ if (
2598
+ e.arguments?.length !== 1 ||
2599
+ !executor ||
2600
+ !(ts.isArrowFunction(executor) || ts.isFunctionExpression(executor)) ||
2601
+ !ts.isBlock(executor.body) ||
2602
+ executor.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)
2603
+ )
2604
+ return fail("unsupported-promise-executor");
2605
+ const [resolveParam, rejectParam] = executor.parameters;
2606
+ if (
2607
+ !resolveParam ||
2608
+ executor.parameters.length > 2 ||
2609
+ executor.parameters.some(
2610
+ (p) => !ts.isIdentifier(p.name) || p.initializer || p.dotDotDotToken,
2611
+ )
2612
+ )
2613
+ return fail("unsupported-resolver-binding");
2614
+ let eventCall: ts.CallExpression | undefined;
2615
+ for (const statement of executor.body.statements) {
2616
+ if (
2617
+ !ts.isExpressionStatement(statement) ||
2618
+ !ts.isCallExpression(statement.expression)
2619
+ )
2620
+ return fail("unsupported-executor-statement");
2621
+ const call = statement.expression;
2622
+ if (
2623
+ !ts.isPropertyAccessExpression(call.expression) ||
2624
+ !["on", "once"].includes(call.expression.name.text) ||
2625
+ call.arguments.length !== 2 ||
2626
+ !ts.isStringLiteralLike(call.arguments[0])
2627
+ )
2628
+ return fail("competing-or-unsupported-settlement");
2629
+ const event = call.arguments[0].text;
2630
+ if (
2631
+ event === "error" &&
2632
+ rejectParam &&
2633
+ declOf(comparisonExpression(call.arguments[1])) === rejectParam
2634
+ )
2635
+ continue;
2636
+ if (!["exit", "close"].includes(event) || eventCall)
2637
+ return fail("competing-or-unsupported-settlement");
2638
+ eventCall = call;
2639
+ }
2640
+ if (!eventCall) return fail("missing-exit-listener");
2641
+ const member = eventCall.expression as ts.PropertyAccessExpression;
2642
+ const child = declOf(comparisonExpression(member.expression));
2643
+ if (
2644
+ !child ||
2645
+ !ts.isVariableDeclaration(child) ||
2646
+ !child.initializer ||
2647
+ !ts.isVariableDeclarationList(child.parent) ||
2648
+ !(child.parent.flags & ts.NodeFlags.Const)
2649
+ )
2650
+ return fail("child-binding-unverified");
2651
+ const spawn = comparisonExpression(child.initializer);
2652
+ if (!ts.isCallExpression(spawn) || !nativeSpawn(spawn))
2653
+ return fail("native-child-spawn-unverified");
2654
+ info.spawn = comparisonLocation(spawn);
2655
+ info.event = {
2656
+ source: comparisonLocation(eventCall),
2657
+ name: (eventCall.arguments[0] as ts.StringLiteralLike).text,
2658
+ };
2659
+ const callback = comparisonExpression(eventCall.arguments[1]);
2660
+ if (declOf(callback) === resolveParam) {
2661
+ if (path.length) return fail("unsupported-exit-result-projection");
2662
+ info.resolution = {
2663
+ status: "source-checked",
2664
+ source: comparisonLocation(callback),
2665
+ eventArgument: "code",
2666
+ };
2667
+ return fail("producer-instance-link-unverified");
2668
+ }
2669
+ if (
2670
+ !(ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) ||
2671
+ callback.modifiers?.some(
2672
+ (m) => m.kind === ts.SyntaxKind.AsyncKeyword,
2673
+ ) ||
2674
+ callback.parameters.length > 2 ||
2675
+ callback.parameters.some(
2676
+ (p) => !ts.isIdentifier(p.name) || p.initializer || p.dotDotDotToken,
2677
+ )
2678
+ )
2679
+ return fail("unsupported-exit-callback");
2680
+ let body: ts.Expression | undefined;
2681
+ if (!ts.isBlock(callback.body)) body = callback.body;
2682
+ else if (callback.body.statements.length === 1) {
2683
+ const statement = callback.body.statements[0];
2684
+ if (ts.isExpressionStatement(statement)) body = statement.expression;
2685
+ else if (ts.isReturnStatement(statement)) body = statement.expression;
2686
+ }
2687
+ const call = body && comparisonExpression(body);
2688
+ if (
2689
+ !call ||
2690
+ !ts.isCallExpression(call) ||
2691
+ declOf(comparisonExpression(call.expression)) !== resolveParam ||
2692
+ call.arguments.length !== 1
2693
+ )
2694
+ return fail("unsupported-exit-resolver");
2695
+ let value = comparisonExpression(call.arguments[0]);
2696
+ let field: string | undefined;
2697
+ if (path.length) {
2698
+ if (path.length !== 1 || !ts.isObjectLiteralExpression(value))
2699
+ return fail("unsupported-exit-result-projection");
2700
+ field = path[0];
2701
+ for (const member of value.properties) {
2702
+ if (!(
2703
+ ts.isPropertyAssignment(member) ||
2704
+ ts.isShorthandPropertyAssignment(member)
2705
+ ))
2706
+ return fail("unsupported-resolved-object");
2707
+ const item = comparisonExpression(
2708
+ ts.isPropertyAssignment(member) ? member.initializer : member.name,
2709
+ );
2710
+ if (
2711
+ !(
2712
+ ts.isIdentifier(item) &&
2713
+ callback.parameters.some((p) => declOf(item) === p)
2714
+ ) &&
2715
+ !ts.isStringLiteralLike(item) &&
2716
+ !ts.isNumericLiteral(item) &&
2717
+ ![
2718
+ ts.SyntaxKind.NullKeyword,
2719
+ ts.SyntaxKind.TrueKeyword,
2720
+ ts.SyntaxKind.FalseKeyword,
2721
+ ].includes(item.kind)
2722
+ )
2723
+ return fail("transformed-or-constant-event-value");
2724
+ }
2725
+ const projected = property(value, field);
2726
+ if (!projected) return fail("unsupported-resolved-object");
2727
+ value = comparisonExpression(projected);
2728
+ }
2729
+ if (!ts.isIdentifier(value))
2730
+ return fail("transformed-or-constant-event-value");
2731
+ const index = callback.parameters.findIndex(
2732
+ (p) =>
2733
+ ts.isIdentifier(p.name) &&
2734
+ !p.initializer &&
2735
+ !p.dotDotDotToken &&
2736
+ declOf(value) === p,
2737
+ );
2738
+ if (index !== 0 && index !== 1)
2739
+ return fail("resolved-value-is-not-event-argument");
2740
+ info.resolution = {
2741
+ status: "source-checked",
2742
+ source: comparisonLocation(call),
2743
+ ...(field ? { field } : {}),
2744
+ eventArgument: index === 0 ? "code" : "signal",
2745
+ };
2746
+ return fail("producer-instance-link-unverified");
2747
+ };
2748
+ const result = trace(expr, []);
2749
+ if (!result?.resolution) return result;
2750
+
2751
+ // The resolver's fresh object cannot change in transit if its Promise and
2752
+ // every followed carrier stay closed to the selected read. Inspect all uses
2753
+ // of each local binding, including closures, not just preceding statements.
2754
+ // This proves preservation of the event argument, not its producer identity.
2755
+ const consumer = {
2756
+ status: "unresolved" as "source-checked" | "unresolved",
2757
+ reason: "result-carrier-escapes-or-is-reused" as string | undefined,
2758
+ bindings: [...carriers.keys()].map(comparisonLocation),
2759
+ read: comparisonLocation(expr),
2760
+ blockedAt: undefined as string | undefined,
2761
+ };
2762
+ result.consumer = consumer;
2763
+ if (openCarrier) {
2764
+ consumer.reason = "nonlocal-result-carrier";
2765
+ return result;
2766
+ }
2767
+ const outer = (n: ts.Expression): ts.Expression => {
2768
+ let p = n.parent;
2769
+ while (
2770
+ p &&
2771
+ ts.isExpression(p) &&
2772
+ comparisonExpression(p) === comparisonExpression(n)
2773
+ ) {
2774
+ n = p;
2775
+ p = n.parent;
2776
+ }
2777
+ return n;
2778
+ };
2779
+ const discardedAwait = (n: ts.Expression) => {
2780
+ const p = outer(n).parent;
2781
+ return (
2782
+ ts.isAwaitExpression(p) && ts.isExpressionStatement(outer(p).parent)
2783
+ );
2784
+ };
2785
+ const discardedRace = (n: ts.Identifier) => {
2786
+ const array = n.parent;
2787
+ if (!ts.isArrayLiteralExpression(array)) return false;
2788
+ const call = array.parent;
2789
+ if (
2790
+ !ts.isCallExpression(call) ||
2791
+ call.arguments.length !== 1 ||
2792
+ !ts.isPropertyAccessExpression(call.expression) ||
2793
+ call.expression.name.text !== "race"
2794
+ )
2795
+ return false;
2796
+ const ctor = call.expression.expression;
2797
+ const binding = declOf(ctor);
2798
+ return (
2799
+ ts.isIdentifier(ctor) &&
2800
+ ctor.text === "Promise" &&
2801
+ (!binding || binding.getSourceFile().isDeclarationFile) &&
2802
+ discardedAwait(call)
2803
+ );
2804
+ };
2805
+ const siblingArrowRead = (n: ts.Identifier, d: ts.VariableDeclaration) => {
2806
+ // A fresh helper return may also expose independent arrow readers, e.g.
2807
+ // buffered stdout. Arrows cannot receive the carrier as their `this`.
2808
+ // Any captured use of the result Promise is audited separately below.
2809
+ const access = n.parent;
2810
+ if (!ts.isPropertyAccessExpression(access) || access.expression !== n)
2811
+ return false;
2812
+ const call = access.parent;
2813
+ if (
2814
+ !ts.isCallExpression(call) ||
2815
+ call.expression !== access ||
2816
+ call.arguments.length
2817
+ )
2818
+ return false;
2819
+ const init = d.initializer && comparisonExpression(d.initializer);
2820
+ const value =
2821
+ init && ts.isCallExpression(init) ? helperReturns.get(init) : init;
2822
+ const object = value && comparisonExpression(value);
2823
+ if (!object || !ts.isObjectLiteralExpression(object)) return false;
2824
+ const member = property(object, access.name.text);
2825
+ return !!member && ts.isArrowFunction(comparisonExpression(member));
2826
+ };
2827
+ let budget = 16384;
2828
+ for (const [d, uses] of carriers) {
2829
+ const scope = enclosingFunction(d);
2830
+ if (!scope) return result;
2831
+ let closed = true;
2832
+ const scan = (n: ts.Node) => {
2833
+ if (--budget < 0 || !closed) return;
2834
+ const callee = ts.isCallExpression(n)
2835
+ ? comparisonExpression(n.expression)
2836
+ : undefined;
2837
+ if (
2838
+ ts.isWithStatement(n) ||
2839
+ (callee && ts.isIdentifier(callee) && callee.text === "eval")
2840
+ ) {
2841
+ closed = false;
2842
+ consumer.reason = "reflective-carrier-access";
2843
+ consumer.blockedAt = comparisonLocation(n);
2844
+ return;
2845
+ }
2846
+ if (
2847
+ ts.isIdentifier(n) &&
2848
+ n !== d.name &&
2849
+ declOf(n) === d &&
2850
+ !uses.has(n) &&
2851
+ !discardedAwait(n) &&
2852
+ !discardedRace(n) &&
2853
+ !siblingArrowRead(n, d)
2854
+ ) {
2855
+ closed = false;
2856
+ consumer.blockedAt = comparisonLocation(n);
2857
+ return;
2858
+ }
2859
+ ts.forEachChild(n, scan);
2860
+ };
2861
+ scan(scope);
2862
+ if (!closed || budget < 0) {
2863
+ if (budget < 0) consumer.reason = "consumer-scan-budget";
2864
+ return result;
2865
+ }
2866
+ }
2867
+ consumer.status = "source-checked";
2868
+ delete consumer.reason;
2869
+ return result;
2870
+ }
2871
+
2019
2872
  function originOf(expr: ts.Expression, depth = 0): Origin | undefined {
2020
2873
  // each property-access segment costs one level: `admin.rest.resources.Article.find.mock.calls.map(...)` read
2021
2874
  // through a local helper is 10 deep; runaway recursion through helpers is bounded by paramBindings instead
@@ -2048,11 +2901,23 @@ export function analyzeWithFrontend(
2048
2901
  callee.expression.getText().endsWith(".mock") &&
2049
2902
  e.arguments.length >= 2 &&
2050
2903
  ts.isStringLiteralLike(e.arguments[1])
2051
- )
2904
+ ) {
2905
+ // Keep a same-named user helper out of the native mock model. Unsupported
2906
+ // tracker/receiver aliases remain unknown rather than acquiring stream credit.
2907
+ const receiver = unwrap(e.arguments[0]);
2908
+ const declaration = declOf(receiver);
2909
+ if (
2910
+ !nativeContextMock(e) ||
2911
+ !ts.isIdentifier(receiver) ||
2912
+ receiver.text !== "console" ||
2913
+ (declaration && !declaration.getSourceFile().isDeclarationFile)
2914
+ )
2915
+ return undefined;
2052
2916
  return {
2053
2917
  kind: `mock:${e.arguments[0].getText()}.${e.arguments[1].text}`,
2054
2918
  path: [],
2055
2919
  };
2920
+ }
2056
2921
  if (ts.isIdentifier(callee)) {
2057
2922
  // Resolve the declaration below. A familiar helper name is not a
2058
2923
  // contract, nor does it identify the particular process/socket observed.
@@ -2095,10 +2960,14 @@ export function analyzeWithFrontend(
2095
2960
  visit(e.arguments[0]);
2096
2961
  if (hook) return hook;
2097
2962
  }
2098
- if (["String", "Number", "Boolean"].includes(callee.text))
2099
- return e.arguments[0]
2963
+ if (["String", "Number", "Boolean"].includes(callee.text)) {
2964
+ const base = e.arguments[0]
2100
2965
  ? originOf(e.arguments[0], depth + 1)
2101
2966
  : undefined;
2967
+ return base?.kind.startsWith("mock:")
2968
+ ? { ...base, path: [...base.path, `${callee.text}()`] }
2969
+ : base;
2970
+ }
2102
2971
  const d = declOf(callee);
2103
2972
  if (d && isProdFile(d.getSourceFile()))
2104
2973
  return { kind: "prod:" + declaredName(d, callee.text), path: [] };
@@ -2145,10 +3014,14 @@ export function analyzeWithFrontend(
2145
3014
  if (ts.isPropertyAccessExpression(callee)) {
2146
3015
  const name = callee.name.text;
2147
3016
  const objText = callee.expression.getText();
2148
- if (["JSON", "Object", "Array", "Promise"].includes(objText))
2149
- return e.arguments[0]
3017
+ if (["JSON", "Object", "Array", "Promise"].includes(objText)) {
3018
+ const base = e.arguments[0]
2150
3019
  ? originOf(e.arguments[0], depth + 1)
2151
3020
  : undefined;
3021
+ return base?.kind.startsWith("mock:")
3022
+ ? { ...base, path: [...base.path, `${objText}.${name}()`] }
3023
+ : base;
3024
+ }
2152
3025
  // vi.mocked(x) is x; vi.importActual('~/x') is the real module
2153
3026
  if (
2154
3027
  (objText === "vi" || objText === "jest") &&
@@ -2165,7 +3038,10 @@ export function analyzeWithFrontend(
2165
3038
  return moduleOrigin(e.arguments[0].text, e.getSourceFile());
2166
3039
  const base = originOf(callee.expression, depth + 1);
2167
3040
  if (!base) return undefined;
2168
- const o: Origin = { ...base, path: [...base.path, name + "()"] };
3041
+ const step = base.kind.startsWith("mock:")
3042
+ ? `${name}(${e.arguments.map((arg) => arg.getText()).join(", ")})`
3043
+ : name + "()";
3044
+ const o: Origin = { ...base, path: [...base.path, step] };
2169
3045
  // promise.catch(e => e) carries the rejection; promise.then(onOk, onErr) carries either
2170
3046
  if (name === "catch" && e.arguments[0]) o.thrown = "only";
2171
3047
  else if (name === "then" && e.arguments.length >= 2) o.thrown = "also";
@@ -2235,7 +3111,10 @@ export function analyzeWithFrontend(
2235
3111
  }
2236
3112
  if (ts.isElementAccessExpression(e)) {
2237
3113
  const b = originOf(e.expression, depth + 1);
2238
- return b ? { ...b, path: [...b.path, "[]"] } : undefined;
3114
+ const step = b?.kind.startsWith("mock:")
3115
+ ? `[${e.argumentExpression.getText()}]`
3116
+ : "[]";
3117
+ return b ? { ...b, path: [...b.path, step] } : undefined;
2239
3118
  }
2240
3119
  if (ts.isConditionalExpression(e)) return undefined; // selected branch needs value-flow evidence
2241
3120
  if (ts.isBinaryExpression(e)) {
@@ -2249,7 +3128,12 @@ export function analyzeWithFrontend(
2249
3128
  // from the first operand whose name can be resolved.
2250
3129
  return undefined;
2251
3130
  }
2252
- if (ts.isPrefixUnaryExpression(e)) return originOf(e.operand, depth + 1);
3131
+ if (ts.isPrefixUnaryExpression(e)) {
3132
+ const base = originOf(e.operand, depth + 1);
3133
+ return base?.kind.startsWith("mock:")
3134
+ ? { ...base, path: [...base.path, `unary:${e.operator}`] }
3135
+ : base;
3136
+ }
2253
3137
  if (ts.isIdentifier(e)) {
2254
3138
  // imports first, by their import declaration: alias resolution can fail on deep re-exports
2255
3139
  const raw = checker.getSymbolAtLocation(e)?.declarations?.[0];
@@ -2351,13 +3235,6 @@ export function analyzeWithFrontend(
2351
3235
  scan(scope);
2352
3236
  if (fed) return { kind: fed, path: [] };
2353
3237
  }
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
3238
  // Mutable scalar aliases need reaching definitions, not the initializer
2362
3239
  // or first assignment anywhere in the file. Container/stream cases above
2363
3240
  // retain their separate models.
@@ -2491,8 +3368,6 @@ export function analyzeWithFrontend(
2491
3368
  return [{ boundary: "stdout" }];
2492
3369
  case "proc-stderr":
2493
3370
  return [{ boundary: "stderr" }];
2494
- case "proc-exit":
2495
- return [{ boundary: "exit" }];
2496
3371
  case "sdk-client":
2497
3372
  if (has("sessionId"))
2498
3373
  return [{ boundary: "client-header", facet: "mcp-session-id" }];
@@ -2546,7 +3421,10 @@ export function analyzeWithFrontend(
2546
3421
  // t.mock.method(console, 'log'): a test-owned replacement of a stream sink
2547
3422
  if (o.kind.startsWith("mock:console."))
2548
3423
  return [
2549
- { boundary: o.kind.endsWith(".error") ? "stderr" : "stdout" },
3424
+ {
3425
+ boundary: /\.(error|warn)$/.test(o.kind) ? "stderr" : "stdout",
3426
+ facet: o.kind.slice(5),
3427
+ },
2550
3428
  ];
2551
3429
  return [];
2552
3430
  }
@@ -2849,6 +3727,99 @@ export function analyzeWithFrontend(
2849
3727
  unrecognized.set(key, (unrecognized.get(key) ?? 0) + 1);
2850
3728
  }
2851
3729
 
3730
+ const nativeImportedMethod = (
3731
+ call: ts.CallExpression,
3732
+ module: string,
3733
+ methods: string[],
3734
+ ) => {
3735
+ const callee = comparisonExpression(call.expression);
3736
+ if (
3737
+ !ts.isPropertyAccessExpression(callee) ||
3738
+ !methods.includes(callee.name.text)
3739
+ )
3740
+ return false;
3741
+ const receiver = comparisonExpression(callee.expression);
3742
+ const d = checker.getSymbolAtLocation(receiver)?.declarations?.[0];
3743
+ const imported =
3744
+ d &&
3745
+ (ts.isImportClause(d)
3746
+ ? d.parent
3747
+ : ts.isNamespaceImport(d)
3748
+ ? d.parent.parent
3749
+ : undefined);
3750
+ return (
3751
+ !!imported &&
3752
+ ts.isStringLiteralLike(imported.moduleSpecifier) &&
3753
+ imported.moduleSpecifier.text === module
3754
+ );
3755
+ };
3756
+ const nativeGlobalValue = (expr: ts.Expression, name: string) => {
3757
+ const e = comparisonExpression(expr),
3758
+ d = declOf(e);
3759
+ return (
3760
+ ts.isIdentifier(e) &&
3761
+ e.text === name &&
3762
+ (!d || d.getSourceFile().isDeclarationFile)
3763
+ );
3764
+ };
3765
+ const mockModel = {
3766
+ declaration: declOf,
3767
+ location: comparisonLocation,
3768
+ nativeMock: nativeContextMock,
3769
+ nativePredicate: (call: ts.CallExpression) =>
3770
+ nativeComparison(call)?.predicate ??
3771
+ (nativeImportedMethod(call, "node:assert/strict", ["match"])
3772
+ ? "node-literal-regexp"
3773
+ : undefined),
3774
+ nativeException: (call: ts.CallExpression) => {
3775
+ const method = nativeAssertionIdentity(call)?.method;
3776
+ return method === "throws" || method === "doesNotThrow"
3777
+ ? method
3778
+ : undefined;
3779
+ },
3780
+ globalError: (expr: ts.Expression) => nativeGlobalValue(expr, "Error"),
3781
+ nativeTest: nativeTestRegistration,
3782
+ nativeInspect: (call: ts.CallExpression) =>
3783
+ nativeImportedMethod(call, "node:util", ["inspect"]),
3784
+ nativeTty: (expr: ts.Expression) =>
3785
+ ts.isPropertyAccessExpression(expr) &&
3786
+ expr.name.text === "isTTY" &&
3787
+ ts.isPropertyAccessExpression(expr.expression) &&
3788
+ expr.expression.name.text === "stderr" &&
3789
+ nativeGlobalValue(expr.expression.expression, "process"),
3790
+ nativeAssertion: (call: ts.CallExpression) =>
3791
+ nativeImportedMethod(call, "node:assert/strict", [
3792
+ "equal",
3793
+ "strictEqual",
3794
+ "deepEqual",
3795
+ "deepStrictEqual",
3796
+ "match",
3797
+ ]),
3798
+ globalString: (expr: ts.Expression) => nativeGlobalValue(expr, "String"),
3799
+ globalConsole: (expr: ts.Expression) => {
3800
+ const e = comparisonExpression(expr);
3801
+ const d = declOf(e);
3802
+ return (
3803
+ ts.isIdentifier(e) &&
3804
+ e.text === "console" &&
3805
+ (!d || d.getSourceFile().isDeclarationFile)
3806
+ );
3807
+ },
3808
+ production: (node: ts.Node) => isProdFile(node.getSourceFile()),
3809
+ site: (call: ts.CallExpression) =>
3810
+ smallestSiteContaining(
3811
+ rel(call.getSourceFile()),
3812
+ call.getStart(),
3813
+ call.getEnd(),
3814
+ )?.id,
3815
+ };
3816
+ function countModel(
3817
+ fn: ts.Node,
3818
+ row?: Parameters<typeof analyzeMockCounts>[3],
3819
+ ) {
3820
+ return analyzeMockCounts(ts, fn, mockModel, row);
3821
+ }
3822
+
2852
3823
  function analyzeTestBody(
2853
3824
  fn: ts.Node,
2854
3825
  file: string,
@@ -2856,6 +3827,7 @@ export function analyzeWithFrontend(
2856
3827
  name: string,
2857
3828
  inert = false,
2858
3829
  ): StaticTest {
3830
+ const mockCounts = countModel(fn);
2859
3831
  const observations: Observation[] = [];
2860
3832
  const sinks: SinkBinding[] = [];
2861
3833
  const rendered = new Set<string>();
@@ -2953,7 +3925,9 @@ export function analyzeWithFrontend(
2953
3925
  let expected: ts.Expression | undefined;
2954
3926
  let negative = false;
2955
3927
  let rejectsChain = false;
2956
- if (
3928
+ const nativeAssertion = nativeAssertionIdentity(node);
3929
+ if (nativeAssertion) method = nativeAssertion.method;
3930
+ else if (
2957
3931
  ts.isPropertyAccessExpression(callee) &&
2958
3932
  callee.expression.getText() === "assert"
2959
3933
  )
@@ -2962,8 +3936,19 @@ export function analyzeWithFrontend(
2962
3936
  method = "assert";
2963
3937
  if (method && ASSERT_STRENGTH[method]) {
2964
3938
  strength = ASSERT_STRENGTH[method];
2965
- actuals = node.arguments.slice(0, 2);
2966
- expected = node.arguments[1];
3939
+ // On a passing native ok/doesNotThrow/doesNotReject call, only the
3940
+ // first argument participates in the success predicate. The no-error
3941
+ // methods return before inspecting their optional matcher/message.
3942
+ // Argument evaluation can still have effects or throw. Do not apply
3943
+ // this rule to throws/rejects: their matcher and string-ambiguity
3944
+ // checks can inspect the second value even when an error occurred.
3945
+ const firstOperandOnly =
3946
+ nativeAssertion !== undefined &&
3947
+ ["ok", "doesNotThrow", "doesNotReject"].includes(
3948
+ nativeAssertion.method,
3949
+ );
3950
+ actuals = node.arguments.slice(0, firstOperandOnly ? 1 : 2);
3951
+ expected = firstOperandOnly ? undefined : node.arguments[1];
2967
3952
  negative = method === "doesNotMatch";
2968
3953
  } else if (ts.isPropertyAccessExpression(callee)) {
2969
3954
  // vitest/jest style: expect(actual)[.not][.resolves|.rejects].matcher(expected)
@@ -3005,6 +3990,7 @@ export function analyzeWithFrontend(
3005
3990
  )
3006
3991
  strength = "value";
3007
3992
  if (method && strength) {
3993
+ const comparison = nativeComparison(node);
3008
3994
  pragmaCollector.register(
3009
3995
  node,
3010
3996
  method,
@@ -3049,9 +4035,10 @@ export function analyzeWithFrontend(
3049
4035
  rejectsChain ||
3050
4036
  ["rejects", "throws", "toThrow", "toThrowError"].includes(method);
3051
4037
  for (const arg of actuals) {
3052
- const o = originOf(arg);
3053
4038
  const unresolvedOperand = (shape: string) =>
3054
4039
  pending.push({
4040
+ assertionSource: `${relative(root, sf.fileName)}:${sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1}:${sf.getLineAndCharacterOfPosition(node.getStart(sf)).character + 1}`,
4041
+ assertionMethod: method,
3055
4042
  statements: definingStatements(arg),
3056
4043
  strength: RANK[s] > RANK.value ? "value" : s,
3057
4044
  negative: !!negative,
@@ -3059,6 +4046,40 @@ export function analyzeWithFrontend(
3059
4046
  where: where(node, method),
3060
4047
  shape: `${arg.getText().replace(/\s+/g, " ").slice(0, 40)} [${shape}]`,
3061
4048
  });
4049
+ if (
4050
+ nativeAssertion &&
4051
+ arg === node.arguments[0] &&
4052
+ ["throws", "rejects", "doesNotThrow", "doesNotReject"].includes(
4053
+ nativeAssertion.method,
4054
+ )
4055
+ ) {
4056
+ // The operand supplies a callback or promise, not the value a
4057
+ // normal value assertion reads. `doesNotThrow(fn)` ignores fn's
4058
+ // result; `throws(factory())` catches the returned callback's
4059
+ // exception, not an exception thrown while evaluating factory().
4060
+ // Async variants additionally distinguish synchronous invocation,
4061
+ // promise validation and settlement. The ordinary origin/owner
4062
+ // model cannot establish those invocation and completion paths.
4063
+ // Retain the passing assertion as a limit, including when its
4064
+ // callable's source is recognizable; never invent return credit
4065
+ // or convert the operand producer's return into a caught throw.
4066
+ const completion =
4067
+ nativeAssertion.method === "throws"
4068
+ ? "synchronous throw"
4069
+ : nativeAssertion.method === "doesNotThrow"
4070
+ ? "synchronous normal"
4071
+ : nativeAssertion.method === "rejects"
4072
+ ? "asynchronous rejection"
4073
+ : "asynchronous fulfillment";
4074
+ unresolvedOperand(
4075
+ `${completion} completion; callback/promise producer and invocation path unresolved`,
4076
+ );
4077
+ continue;
4078
+ }
4079
+ const exitSource = childExitSource(arg);
4080
+ const o: Origin | undefined = exitSource
4081
+ ? { kind: "process-exit-source", path: [] }
4082
+ : originOf(arg);
3062
4083
  if (!o) {
3063
4084
  noteUnrecognized(arg, "no origin");
3064
4085
  unresolvedOperand("no origin");
@@ -3070,13 +4091,26 @@ export function analyzeWithFrontend(
3070
4091
  const facetPattern = o.facet?.startsWith("pattern:")
3071
4092
  ? o.facet.slice(8)
3072
4093
  : undefined;
3073
- const bs = boundariesOf(o);
4094
+ const bs = exitSource ? [{ boundary: "exit" }] : boundariesOf(o);
4095
+ const mock = mockProjection(o);
4096
+ if (mock?.kind === "call-count")
4097
+ mock.countEvidence = mockCounts.checks.get(node) ?? {
4098
+ model: "node-sync-console-count-v2",
4099
+ status: "unresolved",
4100
+ reason: mockCounts.limitation ?? "unsupported-count-projection",
4101
+ };
4102
+ if (mock)
4103
+ unresolvedOperand(
4104
+ `mock ${mock.kind}; production call identity and projection dependence unresolved`,
4105
+ );
3074
4106
  if (!bs.length && o.kind !== "literal") {
3075
4107
  noteUnrecognized(arg, o.kind);
3076
4108
  unresolvedOperand(o.kind);
3077
4109
  }
3078
- // rejects/throws: the function's throw sites are observed as well as (or instead of) its return
3079
- if (observesThrow)
4110
+ // Native first-operand completion was handled above. Its second
4111
+ // operand supplies an expectation/diagnostic, not a caught operation.
4112
+ // Non-native inference remains separate from the native model.
4113
+ if (observesThrow && !nativeAssertion)
3080
4114
  for (const b of [...bs])
3081
4115
  if (b.boundary.startsWith("return:"))
3082
4116
  bs.push({ boundary: "throw:" + b.boundary.slice(7) });
@@ -3096,8 +4130,21 @@ export function analyzeWithFrontend(
3096
4130
  where: where(node, method),
3097
4131
  assertionSource: `${relative(root, sf.fileName)}:${sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1}:${sf.getLineAndCharacterOfPosition(node.getStart(sf)).character + 1}`,
3098
4132
  assertionMethod: method,
4133
+ ...(mock ? { mock } : {}),
4134
+ ...(comparison ? { comparison } : {}),
4135
+ ...(b.boundary === "exit"
4136
+ ? {
4137
+ processExit: exitSource ?? {
4138
+ model: "node-child-exit-source-v1" as const,
4139
+ status: "unresolved" as const,
4140
+ reason: "unverified-event-channel",
4141
+ operand: comparisonLocation(arg),
4142
+ helperCalls: [],
4143
+ },
4144
+ }
4145
+ : {}),
3099
4146
  };
3100
- if (callList && b.boundary.startsWith("sink:"))
4147
+ if (!mock && callList && b.boundary.startsWith("sink:"))
3101
4148
  ob.callList = true;
3102
4149
  if (pattern) ob.pattern = pattern;
3103
4150
  else if (facetPattern) ob.pattern = facetPattern;
@@ -3112,6 +4159,29 @@ export function analyzeWithFrontend(
3112
4159
  }
3113
4160
  // implicit oracles: awaited reads that throw or time out
3114
4161
  if (ts.isAwaitExpression(node.parent)) {
4162
+ if (!(method && strength) && pragmaCollector.hasHint(node)) {
4163
+ const source = awaitedObservationSource(
4164
+ {
4165
+ syntax: ts,
4166
+ declaration: declOf,
4167
+ rawDeclaration: (n) => {
4168
+ const symbol = checker.getSymbolAtLocation(n);
4169
+ return symbol?.valueDeclaration ?? symbol?.declarations?.[0];
4170
+ },
4171
+ relativeFile: rel,
4172
+ },
4173
+ fn,
4174
+ node,
4175
+ );
4176
+ if (source && ts.isPropertyAccessExpression(node.expression))
4177
+ pragmaCollector.register(
4178
+ node,
4179
+ node.expression.name.text,
4180
+ staticTestKey(file, line, name),
4181
+ inert,
4182
+ source,
4183
+ );
4184
+ }
3115
4185
  const o = originOf(node);
3116
4186
  if (o) {
3117
4187
  const bs = boundariesOf(o);
@@ -3336,6 +4406,7 @@ export function analyzeWithFrontend(
3336
4406
  ? arg0.text
3337
4407
  : arg0.getText(sf).replace(/^[`'"]|[`'"]$/g, "");
3338
4408
  staticTests.push(st);
4409
+ mockBodies.set(st, decl.body);
3339
4410
  }
3340
4411
  ts.forEachChild(node, visit);
3341
4412
  };
@@ -3401,7 +4472,10 @@ export function analyzeWithFrontend(
3401
4472
  runtimeLines.every((l) => statics.some((s) => s.line === l))
3402
4473
  )
3403
4474
  runtimeLines.forEach((l) =>
3404
- staticLink.set(`${file}:${l}`, statics.find((s) => s.line === l)!),
4475
+ staticLink.set(
4476
+ `${file}:${l}`,
4477
+ statics.find((s) => s.line === l)!,
4478
+ ),
3405
4479
  );
3406
4480
  else if (runtimeLines.length === statics.length)
3407
4481
  runtimeLines.forEach((l, i) =>
@@ -3423,6 +4497,91 @@ export function analyzeWithFrontend(
3423
4497
  const staticFor = (rt: RuntimeTest) =>
3424
4498
  staticById.get(rt.id) ?? staticLink.get(`${rt.file}:${rt.line}`);
3425
4499
 
4500
+ // A custom registrar's callback can be identified by actual assertion call
4501
+ // points without proving the registrar or its captured row. Keep that weaker
4502
+ // relationship explicit. In particular, a wrapper may change the title, call
4503
+ // this same body with other inputs, catch failures, or run other callbacks.
4504
+ // Do this AFTER legacy linking: recovered bodies must never be candidates for
4505
+ // another attempt's title/rank/nearest-line fallback.
4506
+ const witnessedBodies = new Map<
4507
+ ts.Node,
4508
+ {
4509
+ call: ts.CallExpression;
4510
+ operations: Map<string, string>;
4511
+ st?: StaticTest;
4512
+ }
4513
+ >();
4514
+ for (const sf of allFiles) {
4515
+ if (!isTestFile(sf)) continue;
4516
+ const visit = (node: ts.Node) => {
4517
+ if (
4518
+ ts.isCallExpression(node) &&
4519
+ node.arguments.length >= 2 &&
4520
+ !enclosingFunction(node) &&
4521
+ !testDeclaration(node)
4522
+ ) {
4523
+ const body = node.arguments[node.arguments.length - 1];
4524
+ if (ts.isArrowFunction(body) || ts.isFunctionExpression(body)) {
4525
+ const operations = new Map<string, string>();
4526
+ const assertion = (n: ts.Node) => {
4527
+ if (n !== body && ts.isFunctionLike(n)) return;
4528
+ if (ts.isCallExpression(n)) {
4529
+ const nativeAssertion = nativeAssertionIdentity(n);
4530
+ if (nativeAssertion) {
4531
+ const p = sf.getLineAndCharacterOfPosition(n.getStart(sf));
4532
+ operations.set(
4533
+ `${rel(sf)}:${p.line + 1}:${p.character + 1}`,
4534
+ `${nativeAssertion.module}.${nativeAssertion.method}`,
4535
+ );
4536
+ }
4537
+ }
4538
+ ts.forEachChild(n, assertion);
4539
+ };
4540
+ assertion(body);
4541
+ if (operations.size)
4542
+ witnessedBodies.set(body, { call: node, operations });
4543
+ }
4544
+ }
4545
+ ts.forEachChild(node, visit);
4546
+ };
4547
+ visit(sf);
4548
+ }
4549
+ const witnessedBodyLinks = new Map<
4550
+ string,
4551
+ { body: string; assertions: string[] }
4552
+ >();
4553
+ for (const rt of runtimeTests) {
4554
+ if (staticFor(rt) || rt.runner !== "node:test") continue;
4555
+ const phases = runtimePhases.get(rt.id);
4556
+ if (!phases?.length || phases.some((p) => p.status !== "passed")) continue;
4557
+ const matches = [...witnessedBodies].filter(
4558
+ ([body, candidate]) =>
4559
+ rel(body.getSourceFile()) === rt.file &&
4560
+ phases.every((p) => candidate.operations.get(p.source) === p.op),
4561
+ );
4562
+ if (matches.length !== 1) continue;
4563
+ const [body, candidate] = matches[0];
4564
+ const sf = body.getSourceFile();
4565
+ if (!candidate.st) {
4566
+ const call = candidate.call;
4567
+ candidate.st = analyzeTestBody(
4568
+ body,
4569
+ rel(sf),
4570
+ sf.getLineAndCharacterOfPosition(call.getStart(sf)).line + 1,
4571
+ call.arguments[0].getText(sf).slice(0, 60),
4572
+ );
4573
+ candidate.st.endLine =
4574
+ sf.getLineAndCharacterOfPosition(call.getEnd()).line + 1;
4575
+ staticTests.push(candidate.st);
4576
+ mockBodies.set(candidate.st, body);
4577
+ }
4578
+ staticById.set(rt.id, candidate.st);
4579
+ witnessedBodyLinks.set(rt.id, {
4580
+ body: comparisonLocation(body),
4581
+ assertions: [...new Set(phases.map((p) => p.source))],
4582
+ });
4583
+ }
4584
+
3426
4585
  interface DecisionFacts {
3427
4586
  carrier?: string;
3428
4587
  /** value-position expression not inside a site: the sites its value flows to */
@@ -3623,19 +4782,35 @@ export function analyzeWithFrontend(
3623
4782
  }
3624
4783
 
3625
4784
  /**
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.
4785
+ * Unknown operand dependence is a limit, not proof of an absent assertion.
4786
+ * Synchronous statement attribution can identify a possible relationship but
4787
+ * cannot exclude one across an await, pipe capture, or another async boundary.
4788
+ * A passing unmodeled assertion therefore leaves dependence unresolved for
4789
+ * sites covered by that test. This only changes the reason for an unresolved
4790
+ * candidate: it never supplies an observation, strength, or positive test link.
3630
4791
  */
3631
4792
  function unmodelledOperands(s: Site, covering: RuntimeTest[]): string[] {
3632
4793
  buildFunctionIndex();
3633
4794
  const shapes = new Set<string>();
3634
4795
  for (const rt of covering) {
3635
4796
  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)
4797
+ const st = staticFor(rt);
4798
+ if (!st) continue;
4799
+ const phases = runtimePhases.get(rt.id);
4800
+ for (const p of st.pending) {
4801
+ if (phases) {
4802
+ if (
4803
+ !assertionWitnessIssue(phases, p.assertionSource, p.assertionMethod)
4804
+ )
4805
+ shapes.add(
4806
+ `${p.where}: ${p.shape} (passing assertion in covering test ${rt.id}; operand dependence unresolved)`,
4807
+ );
4808
+ // A known failed, mixed, incomplete or unexecuted call cannot supply
4809
+ // this passing-operand limit. Missing witness transport is separately
4810
+ // reported by witnessIssues. Legacy statement evidence remains below.
4811
+ continue;
4812
+ }
4813
+ if (!byStatement) continue;
3639
4814
  for (const pos of p.statements) {
3640
4815
  const attribution = byStatement[pos];
3641
4816
  if (!attribution) continue;
@@ -3646,6 +4821,7 @@ export function analyzeWithFrontend(
3646
4821
  (s.kind === "decision" && attribution.decs.some((d) => d === s.id));
3647
4822
  if (entered) shapes.add(p.shape);
3648
4823
  }
4824
+ }
3649
4825
  }
3650
4826
  return [...shapes];
3651
4827
  }
@@ -4380,6 +5556,9 @@ export function analyzeWithFrontend(
4380
5556
  assertionMethod: ob.assertionMethod,
4381
5557
  negative: ob.negative,
4382
5558
  callList: ob.callList,
5559
+ mock: ob.mock,
5560
+ comparison: ob.comparison,
5561
+ processExit: ob.processExit,
4383
5562
  weak: ob.weak,
4384
5563
  implicit: ob.implicit,
4385
5564
  runtime: ob.runtime,
@@ -4391,42 +5570,141 @@ export function analyzeWithFrontend(
4391
5570
  .length > 1
4392
5571
  : undefined,
4393
5572
  });
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
- ];
5573
+ const rowPlans = new Map<ts.Node, ReturnType<typeof sourceTestRows>>();
5574
+ const runtimeRowTitles = new Map<string, number>();
5575
+ for (const rt of runtimeTests) {
5576
+ const key = JSON.stringify([rt.file, rt.title]);
5577
+ runtimeRowTitles.set(key, (runtimeRowTitles.get(key) ?? 0) + 1);
5578
+ }
5579
+ function runtimeCountObservations(
5580
+ st: StaticTest,
5581
+ rt: RuntimeTest,
5582
+ ): Observation[] {
5583
+ const body = mockBodies.get(st);
5584
+ if (!body || !st.observations.some((ob) => ob.mock?.kind === "call-count"))
5585
+ return st.observations;
5586
+ if (!rowPlans.has(body))
5587
+ rowPlans.set(
5588
+ body,
5589
+ sourceTestRows(ts, body, {
5590
+ declaration: declOf,
5591
+ location: comparisonLocation,
5592
+ nativeTest: nativeTestRegistration,
5593
+ }),
5594
+ );
5595
+ const plan = rowPlans.get(body);
5596
+ if (!plan) return st.observations;
5597
+ const row =
5598
+ rt.title === undefined
5599
+ ? undefined
5600
+ : plan.rows.find((r) => r.evidence.title === rt.title);
5601
+ const duplicate =
5602
+ (runtimeRowTitles.get(JSON.stringify([rt.file, rt.title])) ?? 0) > 1;
5603
+ const reason =
5604
+ plan.reason ??
5605
+ (rt.runner !== "node:test"
5606
+ ? "unsupported-row-runtime-runner"
5607
+ : !row
5608
+ ? "runtime-title-does-not-identify-source-row"
5609
+ : duplicate
5610
+ ? "ambiguous-runtime-row-title"
5611
+ : undefined);
5612
+ const result = !reason && row ? countModel(body, row) : undefined;
5613
+ const checks = new Map<string, MockCountEvidence>();
5614
+ for (const [node, evidence] of result?.checks ?? []) {
5615
+ const sf = node.getSourceFile(),
5616
+ position = sf.getLineAndCharacterOfPosition(node.getStart(sf));
5617
+ checks.set(
5618
+ `${rel(sf)}:${position.line + 1}:${position.character + 1}`,
5619
+ evidence,
5620
+ );
5621
+ }
5622
+ return st.observations.map((ob) =>
5623
+ ob.mock?.kind !== "call-count"
5624
+ ? ob
5625
+ : {
5626
+ ...ob,
5627
+ mock: {
5628
+ ...ob.mock,
5629
+ countEvidence: checks.get(ob.assertionSource ?? "") ?? {
5630
+ model: "node-sync-console-count-v2",
5631
+ status: "unresolved",
5632
+ reason:
5633
+ reason ??
5634
+ result?.limitation ??
5635
+ "unsupported-count-projection",
5636
+ rowBinding: reason
5637
+ ? {
5638
+ model: "node-test-for-of-v1",
5639
+ status: "unresolved",
5640
+ reason,
5641
+ }
5642
+ : row!.evidence,
5643
+ },
5644
+ },
5645
+ },
5646
+ );
5647
+ }
5648
+ const unlinkedTests = runtimeTests
5649
+ .filter((rt) => !staticFor(rt))
5650
+ .map((rt) => ({
5651
+ id: rt.id,
5652
+ file: rt.file,
5653
+ title: rt.title ?? rt.name,
5654
+ reason: "test-source-unlinked" as const,
5655
+ }));
5656
+ const factTests = runtimeTests.map((rt) => {
5657
+ const st = staticFor(rt);
5658
+ // A passed runtime attempt stays in the inventory even when source
5659
+ // registration discovery failed. It is NOT an assertion-free test.
5660
+ if (!st)
4420
5661
  return {
4421
5662
  id: rt.id,
4422
- file: st.file,
4423
- observations,
4424
- ...(witnessIssues.length ? { witnessIssues } : {}),
4425
- sinks: st.sinks,
4426
- rendered: [...st.rendered],
5663
+ file: rt.file,
5664
+ observations: [] as ReturnType<typeof factObservation>[],
5665
+ sinks: [] as SinkBinding[],
5666
+ rendered: [] as string[],
5667
+ witnessIssues: [
5668
+ { kind: "test-source-unlinked" as const },
5669
+ ...(!runtimePhases.has(rt.id)
5670
+ ? [{ kind: "capture-unavailable" as const }]
5671
+ : []),
5672
+ ],
4427
5673
  };
4428
- })
4429
- .filter((t) => t !== undefined);
5674
+ const checked = runtimeCountObservations(st, rt).map((ob) => ({
5675
+ ob,
5676
+ kind: witnessIssue(rt.id, ob),
5677
+ }));
5678
+ const observations = checked
5679
+ .filter(({ kind }) => !kind)
5680
+ .map(({ ob }) => factObservation(ob));
5681
+ // Missing transport applies to the whole test, even when no operand could
5682
+ // be modeled. An empty phase file is different from no phase file.
5683
+ const witnessIssues = [
5684
+ ...(witnessedBodyLinks.has(rt.id)
5685
+ ? [{ kind: "test-registration-scope-unverified" as const }]
5686
+ : []),
5687
+ ...(!runtimePhases.has(rt.id)
5688
+ ? [{ kind: "capture-unavailable" as const }]
5689
+ : []),
5690
+ ...checked
5691
+ .filter(({ kind }) => kind && kind !== "capture-unavailable")
5692
+ .map(({ ob, kind }) => ({
5693
+ kind: kind!,
5694
+ source: ob.assertionSource,
5695
+ operation: ob.assertionMethod,
5696
+ observation: factObservation(ob),
5697
+ })),
5698
+ ];
5699
+ return {
5700
+ id: rt.id,
5701
+ file: st.file,
5702
+ observations,
5703
+ ...(witnessIssues.length ? { witnessIssues } : {}),
5704
+ sinks: st.sinks,
5705
+ rendered: [...st.rendered],
5706
+ };
5707
+ });
4430
5708
  // vi.mock boundaries depend on the test file, not the test: one entry per (file, site) pair that has any
4431
5709
  const mocksByTestFile: Record<string, Record<string, Boundary[]>> = {};
4432
5710
  for (const file of new Set(factTests.map((t) => t.file))) {
@@ -4437,6 +5715,198 @@ export function analyzeWithFrontend(
4437
5715
  }
4438
5716
  if (Object.keys(perSite).length) mocksByTestFile[file] = perSite;
4439
5717
  }
5718
+ const primitiveModules = new Map<ts.SourceFile, boolean>();
5719
+ function primitiveDecision(s: Site): PrimitiveDecision | undefined {
5720
+ const condition = siteNodes.get(s.id);
5721
+ if (
5722
+ !condition ||
5723
+ !ts.isIdentifier(condition) ||
5724
+ !ts.isIfStatement(condition.parent) ||
5725
+ condition.parent.expression !== condition
5726
+ )
5727
+ return;
5728
+ const branch = condition.parent,
5729
+ fn = enclosingFunction(branch);
5730
+ if (
5731
+ !fn ||
5732
+ !ts.isFunctionDeclaration(fn) ||
5733
+ !fn.name ||
5734
+ !fn.body ||
5735
+ fn.asteriskToken ||
5736
+ fn.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ||
5737
+ fn.body.statements.length !== 1 ||
5738
+ fn.body.statements[0] !== branch ||
5739
+ fn.parameters.length !== 1
5740
+ )
5741
+ return;
5742
+ const parameter = fn.parameters[0];
5743
+ if (
5744
+ !ts.isIdentifier(parameter.name) ||
5745
+ parameter.initializer ||
5746
+ parameter.dotDotDotToken ||
5747
+ declOf(condition) !== parameter
5748
+ )
5749
+ return;
5750
+ const literalReturn = (statement: ts.Statement | undefined) => {
5751
+ if (
5752
+ !statement ||
5753
+ !ts.isBlock(statement) ||
5754
+ statement.statements.length !== 1
5755
+ )
5756
+ return;
5757
+ const ret = statement.statements[0];
5758
+ return ts.isReturnStatement(ret) && ret.expression
5759
+ ? sourcePrimitive(ret.expression)
5760
+ : undefined;
5761
+ };
5762
+ const whenTrue = literalReturn(branch.thenStatement),
5763
+ whenFalse = literalReturn(branch.elseStatement);
5764
+ if (!whenTrue || !whenFalse) return;
5765
+ const sf = fn.getSourceFile();
5766
+ if (fn.parent !== sf) return;
5767
+ // A module of declarations only, with no binding writes or dynamic eval.
5768
+ // Do not assume that an exported function declaration can never be replaced.
5769
+ if (!primitiveModules.has(sf)) {
5770
+ let safe = sf.statements.every(
5771
+ (n) =>
5772
+ ts.isFunctionDeclaration(n) ||
5773
+ ts.isInterfaceDeclaration(n) ||
5774
+ ts.isTypeAliasDeclaration(n) ||
5775
+ ts.isEmptyStatement(n),
5776
+ );
5777
+ let budget = 16384;
5778
+ const scan = (n: ts.Node) => {
5779
+ if (!safe) return;
5780
+ if (
5781
+ --budget < 0 ||
5782
+ (ts.isIdentifier(n) && n.text === "eval") ||
5783
+ (ts.isBinaryExpression(n) &&
5784
+ n.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
5785
+ n.operatorToken.kind <= ts.SyntaxKind.LastAssignment) ||
5786
+ ((ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) &&
5787
+ [
5788
+ ts.SyntaxKind.PlusPlusToken,
5789
+ ts.SyntaxKind.MinusMinusToken,
5790
+ ].includes(n.operator))
5791
+ ) {
5792
+ safe = false;
5793
+ return;
5794
+ }
5795
+ ts.forEachChild(n, scan);
5796
+ };
5797
+ scan(sf);
5798
+ primitiveModules.set(sf, safe);
5799
+ }
5800
+ if (!primitiveModules.get(sf)) return;
5801
+ const covered = runtimeTests.filter((rt) => covers(rt.id, s));
5802
+ const trueTests = testsWithOutcome(s, true),
5803
+ falseTests = testsWithOutcome(s, false);
5804
+ if (!covered.length || !trueTests || !falseTests) return;
5805
+ const checks: PrimitiveDecision["checks"] = [];
5806
+ for (const rt of covered) {
5807
+ const st = staticFor(rt),
5808
+ body = st && mockBodies.get(st);
5809
+ if (
5810
+ !st ||
5811
+ !body ||
5812
+ !ts.isArrowFunction(body) ||
5813
+ !ts.isBlock(body.body) ||
5814
+ body.parameters.length ||
5815
+ body.modifiers?.length ||
5816
+ body.body.statements.length !== 1 ||
5817
+ !ts.isCallExpression(body.parent) ||
5818
+ !nativeTestRegistration(body.parent) ||
5819
+ body.parent.arguments.length !== 2 ||
5820
+ body.parent.arguments[1] !== body ||
5821
+ !ts.isStringLiteralLike(body.parent.arguments[0]) ||
5822
+ !ts.isExpressionStatement(body.parent.parent) ||
5823
+ !ts.isSourceFile(body.parent.parent.parent) ||
5824
+ rt.runner !== "node:test" ||
5825
+ rt.title !== body.parent.arguments[0].text ||
5826
+ (runtimeRowTitles.get(JSON.stringify([rt.file, rt.title])) ?? 0) !== 1
5827
+ )
5828
+ return;
5829
+ // A top-level hook/setup call can replace imports or assertions before the
5830
+ // callback. Such test modules require a separate environment model.
5831
+ if (
5832
+ !body
5833
+ .getSourceFile()
5834
+ .statements.every(
5835
+ (statement) =>
5836
+ ts.isImportDeclaration(statement) ||
5837
+ ts.isEmptyStatement(statement) ||
5838
+ (ts.isExpressionStatement(statement) &&
5839
+ ts.isCallExpression(statement.expression) &&
5840
+ nativeTestRegistration(statement.expression) &&
5841
+ statement.expression.arguments.length === 2),
5842
+ )
5843
+ )
5844
+ return;
5845
+ const statement = body.body.statements[0];
5846
+ if (
5847
+ !ts.isExpressionStatement(statement) ||
5848
+ !ts.isCallExpression(statement.expression)
5849
+ )
5850
+ return;
5851
+ const assertion = statement.expression;
5852
+ if (assertion.arguments.length !== 2) return;
5853
+ const comparison = nativeComparison(assertion);
5854
+ if (
5855
+ !comparison ||
5856
+ !["node-same-value", "node-not-same-value"].includes(
5857
+ comparison.predicate,
5858
+ )
5859
+ )
5860
+ return;
5861
+ const actual = comparisonExpression(assertion.arguments[0]),
5862
+ expected = sourcePrimitive(assertion.arguments[1]);
5863
+ if (
5864
+ !expected ||
5865
+ !ts.isCallExpression(actual) ||
5866
+ actual.questionDotToken ||
5867
+ !ts.isIdentifier(actual.expression) ||
5868
+ declOf(actual.expression) !== fn ||
5869
+ actual.arguments.length !== 1
5870
+ )
5871
+ return;
5872
+ const input = sourcePrimitive(actual.arguments[0]);
5873
+ if (
5874
+ !input ||
5875
+ input.kind !== "boolean" ||
5876
+ trueTests.has(rt.id) !== input.value ||
5877
+ falseTests.has(rt.id) !== !input.value
5878
+ )
5879
+ return;
5880
+ const pos = assertion
5881
+ .getSourceFile()
5882
+ .getLineAndCharacterOfPosition(assertion.getStart());
5883
+ const assertionSource = `${rel(assertion.getSourceFile())}:${pos.line + 1}:${pos.character + 1}`;
5884
+ const test = factTests.find((t) => t.id === rt.id);
5885
+ if (
5886
+ !test ||
5887
+ test.witnessIssues?.length ||
5888
+ test.observations.length !== 1 ||
5889
+ test.observations[0].assertionSource !== assertionSource ||
5890
+ test.observations[0].boundary !== `return:${fn.name.text}` ||
5891
+ test.observations[0].comparison?.predicate !== comparison.predicate
5892
+ )
5893
+ return;
5894
+ checks.push({
5895
+ test: rt.id,
5896
+ assertionSource,
5897
+ predicate: comparison.predicate,
5898
+ expected,
5899
+ originalOutcome: input.value,
5900
+ });
5901
+ }
5902
+ return {
5903
+ model: "js-primitive-decision-v1",
5904
+ source: comparisonLocation(branch),
5905
+ whenTrue,
5906
+ whenFalse,
5907
+ checks,
5908
+ };
5909
+ }
4440
5910
  const factSites = sites.map((s) => {
4441
5911
  const { bounds, reached } = allBoundaries(s);
4442
5912
  const tTrue = testsWithOutcome(s, true);
@@ -4480,6 +5950,7 @@ export function analyzeWithFrontend(
4480
5950
  ? {
4481
5951
  decision: {
4482
5952
  ...(decisionFacts.get(s.id) ?? {}),
5953
+ primitive: primitiveDecision(s),
4483
5954
  outcomes:
4484
5955
  tTrue && tFalse
4485
5956
  ? { true: [...tTrue], false: [...tFalse] }
@@ -4492,21 +5963,186 @@ export function analyzeWithFrontend(
4492
5963
  };
4493
5964
  });
4494
5965
 
5966
+ const pragmas = pragmaCollector.finish(
5967
+ runtimeTests.flatMap((rt) => {
5968
+ const st = staticFor(rt);
5969
+ return st
5970
+ ? [
5971
+ {
5972
+ testKey: staticTestKey(st.file, st.line, st.name),
5973
+ id: rt.id,
5974
+ phases: runtimePhases.get(rt.id),
5975
+ },
5976
+ ]
5977
+ : [];
5978
+ }),
5979
+ );
5980
+ for (const hint of pragmas) {
5981
+ if (
5982
+ !hint.check ||
5983
+ hint.issue ||
5984
+ hint.witness !== "passed" ||
5985
+ hint.candidateSites.length !== 1
5986
+ )
5987
+ continue;
5988
+ const rt = runtimeTests.find((t) => t.id === hint.test);
5989
+ const st = rt && staticFor(rt),
5990
+ body = st && mockBodies.get(st);
5991
+ const target = siteNodes.get(hint.candidateSites[0]);
5992
+ if (
5993
+ hint.check === "count" ||
5994
+ hint.check === "value" ||
5995
+ hint.check === "completion"
5996
+ ) {
5997
+ const limit = (reason: string) => {
5998
+ if (hint.check === "completion")
5999
+ hint.completionSensitivity = {
6000
+ model: "node-first-test-completion-v1",
6001
+ status: "unresolved",
6002
+ reason,
6003
+ };
6004
+ else if (hint.check === "value")
6005
+ hint.payloadSensitivity = {
6006
+ model: "node-closed-payload-sensitivity-v2",
6007
+ status: "unresolved",
6008
+ reason,
6009
+ };
6010
+ else
6011
+ hint.countSensitivity = {
6012
+ model: "node-closed-count-sensitivity-v1",
6013
+ status: "unresolved",
6014
+ reason,
6015
+ };
6016
+ };
6017
+ limit("sensitivity-source-or-runtime-unavailable");
6018
+ if (
6019
+ !rt ||
6020
+ rt.runner !== "node:test" ||
6021
+ !body ||
6022
+ !target ||
6023
+ (runtimeRowTitles.get(JSON.stringify([rt.file, rt.title])) ?? 0) !== 1
6024
+ )
6025
+ continue;
6026
+ const location = (n: ts.Node) => {
6027
+ const sf = n.getSourceFile(),
6028
+ p = sf.getLineAndCharacterOfPosition(n.getStart(sf));
6029
+ return `${rel(sf)}:${p.line + 1}:${p.character + 1}`;
6030
+ };
6031
+ let assertion: ts.CallExpression | undefined;
6032
+ const find = (n: ts.Node) => {
6033
+ if (ts.isCallExpression(n) && location(n) === hint.assertionSource)
6034
+ assertion = n;
6035
+ ts.forEachChild(n, find);
6036
+ };
6037
+ find(body);
6038
+ if (!assertion) continue;
6039
+ if (hint.check === "completion") {
6040
+ hint.completionSensitivity = analyzeCompletionSensitivity(
6041
+ ts,
6042
+ body,
6043
+ assertion,
6044
+ target,
6045
+ { ...mockModel, location },
6046
+ );
6047
+ continue;
6048
+ }
6049
+ const plan = sourceTestRows(ts, body, mockModel),
6050
+ row = plan?.rows.find((r) => r.evidence.title === rt.title);
6051
+ if (plan && (!row || plan.reason)) {
6052
+ limit(plan.reason ?? "sensitivity-runtime-row-unavailable");
6053
+ continue;
6054
+ }
6055
+ if (hint.check === "value") {
6056
+ hint.payloadSensitivity = analyzePayloadSensitivity(
6057
+ ts,
6058
+ body,
6059
+ assertion,
6060
+ target,
6061
+ { ...mockModel, location },
6062
+ row,
6063
+ );
6064
+ if (hint.payloadSensitivity.status !== "source-checked" && !row) {
6065
+ hint.directReturnSensitivity = analyzeDirectReturnSensitivity(
6066
+ ts,
6067
+ body,
6068
+ assertion,
6069
+ target,
6070
+ { ...mockModel, location },
6071
+ );
6072
+ if (hint.directReturnSensitivity.status === "source-checked")
6073
+ delete hint.payloadSensitivity;
6074
+ }
6075
+ } else
6076
+ hint.countSensitivity = analyzeCountSensitivity(
6077
+ ts,
6078
+ body,
6079
+ assertion,
6080
+ target,
6081
+ { ...mockModel, location },
6082
+ row,
6083
+ );
6084
+ continue;
6085
+ }
6086
+ hint.callOmission = {
6087
+ model: "node-first-test-call-omission-v1",
6088
+ status: "unresolved",
6089
+ reason: "omission-source-or-runtime-unavailable",
6090
+ };
6091
+ if (!rt || rt.runner !== "node:test" || !body) {
6092
+ hint.callOmission.reason = !rt
6093
+ ? "omission-runtime-test-unavailable"
6094
+ : rt.runner !== "node:test"
6095
+ ? "omission-unsupported-runner"
6096
+ : "omission-static-body-unavailable";
6097
+ continue;
6098
+ }
6099
+ if (!target || !ts.isCallExpression(target)) {
6100
+ hint.callOmission.reason = "omission-target-not-call-expression";
6101
+ continue;
6102
+ }
6103
+ if (
6104
+ (runtimeRowTitles.get(JSON.stringify([rt.file, rt.title])) ?? 0) !== 1
6105
+ ) {
6106
+ hint.callOmission.reason = "omission-ambiguous-runtime-title";
6107
+ continue;
6108
+ }
6109
+ let assertion: ts.CallExpression | undefined;
6110
+ const originalLocation = (n: ts.Node) => {
6111
+ const sf = n.getSourceFile(),
6112
+ p = sf.getLineAndCharacterOfPosition(n.getStart(sf));
6113
+ return `${rel(sf)}:${p.line + 1}:${p.character + 1}`;
6114
+ };
6115
+ const visit = (n: ts.Node) => {
6116
+ if (
6117
+ ts.isCallExpression(n) &&
6118
+ originalLocation(n) === hint.assertionSource
6119
+ )
6120
+ assertion = n;
6121
+ ts.forEachChild(n, visit);
6122
+ };
6123
+ visit(body);
6124
+ if (!assertion) {
6125
+ hint.callOmission.reason = "omission-assertion-source-unavailable";
6126
+ continue;
6127
+ }
6128
+ const plan = sourceTestRows(ts, body, mockModel);
6129
+ const row = plan?.rows.find((r) => r.evidence.title === rt.title);
6130
+ if (plan && (!row || plan.reason)) {
6131
+ hint.callOmission.reason =
6132
+ plan.reason ?? "omission-runtime-row-unavailable";
6133
+ continue;
6134
+ }
6135
+ hint.callOmission = analyzeFirstTestOmission(
6136
+ ts,
6137
+ body,
6138
+ assertion,
6139
+ target,
6140
+ { ...mockModel, location: originalLocation },
6141
+ row,
6142
+ );
6143
+ }
4495
6144
  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
- ),
6145
+ pragmas,
4510
6146
  facts: {
4511
6147
  schema: 1,
4512
6148
  root,
@@ -4519,7 +6155,13 @@ export function analyzeWithFrontend(
4519
6155
  observationPolicy:
4520
6156
  "source-linked-v3: exact successful call witness; rejected witnesses retain typed provenance, not value credit",
4521
6157
  runtimeTests: runtimeTests.length,
4522
- linkedTests: factTests.length,
6158
+ linkedTests: runtimeTests.length - unlinkedTests.length,
6159
+ unlinkedTests,
6160
+ witnessedBodyLinks: [...witnessedBodyLinks].map(([id, link]) => ({
6161
+ id,
6162
+ ...link,
6163
+ reason: "test-registration-scope-unverified" as const,
6164
+ })),
4523
6165
  staticTests: staticTests.length,
4524
6166
  linkedByAssertionLines: linkedByPhases,
4525
6167
  linkedByTitle,