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.
- package/README.md +2 -2
- package/analyzers/typescript/README.md +4 -0
- package/analyzers/typescript/bin/identity.mjs +4 -0
- package/analyzers/typescript/dist/analyze.js +1352 -51
- package/analyzers/typescript/dist/archive.js +31 -3
- package/analyzers/typescript/dist/awaited-observations.js +376 -0
- package/analyzers/typescript/dist/build-identity.json +1 -1
- package/analyzers/typescript/dist/mock-counts.js +2517 -0
- package/analyzers/typescript/dist/pragmas.js +59 -16
- package/analyzers/typescript/src/analyze.ts +1738 -96
- package/analyzers/typescript/src/archive.ts +36 -3
- package/analyzers/typescript/src/awaited-observations.ts +561 -0
- package/analyzers/typescript/src/mock-counts.ts +3219 -0
- package/analyzers/typescript/src/pragmas.ts +90 -24
- package/docs/agent-loop.md +116 -31
- package/docs/assertion-evidence.md +560 -1
- package/docs/cli.md +15 -15
- package/docs/code-verification.md +3 -181
- package/docs/coverage-model.md +6 -6
- package/docs/evidence.md +6 -6
- package/docs/getting-started.md +60 -72
- package/docs/performance.md +4 -4
- package/docs/troubleshooting.md +8 -8
- package/docs/verification.md +2 -2
- package/docs/workspace-isolation.md +1 -1
- package/package.json +11 -9
- package/runtime/javascript/nodeAssertAdapter.mjs +32 -8
- package/runtime/javascript/nodeTest.mjs +13 -5
- package/runtime/javascript/runnerEvidence.mjs +33 -11
- package/runtime/javascript/runtime.mjs +22 -3
|
@@ -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
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
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
|
-
|
|
1776
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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))
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
2966
|
-
|
|
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
|
-
//
|
|
3079
|
-
|
|
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(
|
|
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
|
-
*
|
|
3627
|
-
*
|
|
3628
|
-
*
|
|
3629
|
-
*
|
|
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 =
|
|
3637
|
-
if (!
|
|
3638
|
-
|
|
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
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
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:
|
|
4423
|
-
observations,
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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,
|