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.
@@ -2,6 +2,8 @@ import { relative as pathRelative } from "node:path";
2
2
  import { analysisPath } from "./compiler.js";
3
3
  import { createFrontend } from "./frontend.js";
4
4
  import { assertionWitnessIssue, collectPragmas } from "./pragmas.js";
5
+ import { awaitedObservationSource } from "./awaited-observations.js";
6
+ import { analyzeMockCounts, analyzeFirstTestOmission, analyzeCountSensitivity, analyzePayloadSensitivity, analyzeDirectReturnSensitivity, analyzeCompletionSensitivity, sourceTestRows, } from "./mock-counts.js";
5
7
  // Archive/site/test identities use forward slashes on every host.
6
8
  const relative = (from, to) => pathRelative(from, to).replaceAll("\\", "/");
7
9
  export function analyze(options) {
@@ -1709,8 +1711,684 @@ export function analyzeWithFrontend(options, frontend) {
1709
1711
  doesNotReject: "presence",
1710
1712
  };
1711
1713
  const staticTests = [];
1714
+ const mockBodies = new Map();
1712
1715
  const pragmaCollector = collectPragmas(ts, allFiles.filter(isTestFile), rel, sites);
1713
1716
  const staticTestKey = (file, line, name) => JSON.stringify([file, line, name]);
1717
+ // Unlike origin tracing, comparison identity must NOT erase await, calls,
1718
+ // getters or transformations. Only syntax with no runtime operation is peeled.
1719
+ function comparisonExpression(e) {
1720
+ while (ts.isParenthesizedExpression(e) ||
1721
+ ts.isNonNullExpression(e) ||
1722
+ ts.isAsExpression(e) ||
1723
+ ts.isTypeAssertionExpression(e) ||
1724
+ ts.isSatisfiesExpression(e))
1725
+ e = e.expression;
1726
+ return e;
1727
+ }
1728
+ /** Native import identity, not the spelling of a local helper. Bare native
1729
+ * assert and imported ok are the same truthiness operation in the recorder.
1730
+ * Keep this canonicalization at discovery: the witness checker must not equate
1731
+ * arbitrary operations named assert and ok or weaken its source/status check. */
1732
+ function nativeAssertionIdentity(call) {
1733
+ function binding(raw) {
1734
+ const e = comparisonExpression(raw);
1735
+ if (ts.isIdentifier(e)) {
1736
+ const d = checker.getSymbolAtLocation(e)?.declarations?.[0];
1737
+ if (!d)
1738
+ return;
1739
+ let imported;
1740
+ let kind;
1741
+ let name;
1742
+ if (ts.isImportClause(d) && !d.isTypeOnly) {
1743
+ imported = d.parent;
1744
+ kind = "callable";
1745
+ }
1746
+ else if (ts.isNamespaceImport(d) && !d.parent.isTypeOnly) {
1747
+ imported = d.parent.parent;
1748
+ kind = "namespace";
1749
+ }
1750
+ else if (ts.isImportSpecifier(d) &&
1751
+ !d.isTypeOnly &&
1752
+ !d.parent.parent.isTypeOnly) {
1753
+ imported = d.parent.parent.parent;
1754
+ name = (d.propertyName ?? d.name).text;
1755
+ kind =
1756
+ name === "default" || name === "strict" ? "callable" : "method";
1757
+ }
1758
+ else
1759
+ return;
1760
+ if (!ts.isImportDeclaration(imported) ||
1761
+ !ts.isStringLiteralLike(imported.moduleSpecifier))
1762
+ return;
1763
+ const specifier = imported.moduleSpecifier.text;
1764
+ if (![
1765
+ "node:assert",
1766
+ "node:assert/strict",
1767
+ "assert",
1768
+ "assert/strict",
1769
+ ].includes(specifier))
1770
+ return;
1771
+ let module = specifier.startsWith("node:")
1772
+ ? specifier
1773
+ : `node:${specifier}`;
1774
+ if (name === "strict")
1775
+ module = "node:assert/strict";
1776
+ if (kind === "method" &&
1777
+ (!name || name === "assert" || !Object.hasOwn(ASSERT_STRENGTH, name)))
1778
+ return;
1779
+ return { module, kind, method: kind === "method" ? name : undefined };
1780
+ }
1781
+ if (!ts.isPropertyAccessExpression(e))
1782
+ return;
1783
+ const receiver = binding(e.expression);
1784
+ if (!receiver || receiver.kind === "method")
1785
+ return;
1786
+ if (e.name.text === "strict")
1787
+ return { module: "node:assert/strict", kind: "callable" };
1788
+ if (e.name.text === "default" && receiver.kind === "namespace")
1789
+ return { module: receiver.module, kind: "callable" };
1790
+ if (e.name.text === "assert" ||
1791
+ !Object.hasOwn(ASSERT_STRENGTH, e.name.text))
1792
+ return;
1793
+ return { module: receiver.module, kind: "method", method: e.name.text };
1794
+ }
1795
+ const resolved = binding(call.expression);
1796
+ if (!resolved || resolved.kind === "namespace")
1797
+ return;
1798
+ return { module: resolved.module, method: resolved.method ?? "ok" };
1799
+ }
1800
+ function comparisonLocation(n) {
1801
+ const sf = n.getSourceFile();
1802
+ return `${rel(sf)}:${n.getStart(sf)}:${n.getEnd()}`;
1803
+ }
1804
+ function immutableBinding(expr, seen = new Set()) {
1805
+ const e = comparisonExpression(expr);
1806
+ if (!ts.isIdentifier(e) || seen.size >= 32)
1807
+ return undefined;
1808
+ const d = declOf(e);
1809
+ if (!d ||
1810
+ !ts.isVariableDeclaration(d) ||
1811
+ !ts.isIdentifier(d.name) ||
1812
+ !d.initializer ||
1813
+ !ts.isVariableDeclarationList(d.parent) ||
1814
+ !(d.parent.flags & ts.NodeFlags.Const) ||
1815
+ seen.has(d))
1816
+ return undefined;
1817
+ seen.add(d);
1818
+ // A copy of a mutable binding has its own identity, not a live alias.
1819
+ return immutableBinding(d.initializer, seen) ?? comparisonLocation(d);
1820
+ }
1821
+ // Trace only const aliases and await. Unlike immutableBinding, this describes
1822
+ // an input dependency: awaiting a Promise or thenable need not preserve its
1823
+ // value, nor do two awaits necessarily return the same result.
1824
+ function comparisonInput(expr, seen = new Set()) {
1825
+ const e = comparisonExpression(expr);
1826
+ if (seen.size >= 32 || seen.has(e))
1827
+ return undefined;
1828
+ seen.add(e);
1829
+ if (ts.isAwaitExpression(e)) {
1830
+ const input = comparisonInput(e.expression, seen);
1831
+ return (input && {
1832
+ binding: input.binding,
1833
+ awaits: [comparisonLocation(e), ...input.awaits],
1834
+ });
1835
+ }
1836
+ if (!ts.isIdentifier(e))
1837
+ return undefined;
1838
+ const d = declOf(e);
1839
+ if (!d ||
1840
+ !ts.isVariableDeclaration(d) ||
1841
+ !ts.isIdentifier(d.name) ||
1842
+ !d.initializer ||
1843
+ !ts.isVariableDeclarationList(d.parent) ||
1844
+ !(d.parent.flags & ts.NodeFlags.Const) ||
1845
+ seen.has(d))
1846
+ return undefined;
1847
+ seen.add(d);
1848
+ // A copied mutable binding, call or property read anchors a fresh const
1849
+ // input. Do not equate repeated calls/getters or follow mutable aliases.
1850
+ return (comparisonInput(d.initializer, seen) ?? {
1851
+ binding: comparisonLocation(d),
1852
+ awaits: [],
1853
+ });
1854
+ }
1855
+ /** Literal syntax only: do not resolve globals, call code or lose signed zero. */
1856
+ function sourcePrimitive(raw) {
1857
+ const e = comparisonExpression(raw);
1858
+ if (ts.isStringLiteralLike(e)) {
1859
+ // Rust strings cannot represent lone UTF-16 surrogates. Leave those and
1860
+ // large literals unsupported instead of changing or truncating their value.
1861
+ if (e.text.length > 4096)
1862
+ return;
1863
+ for (const point of e.text)
1864
+ if (point.length === 1 &&
1865
+ point.charCodeAt(0) >= 0xd800 &&
1866
+ point.charCodeAt(0) <= 0xdfff)
1867
+ return;
1868
+ return { kind: "string", value: e.text };
1869
+ }
1870
+ if (e.kind === ts.SyntaxKind.TrueKeyword)
1871
+ return { kind: "boolean", value: true };
1872
+ if (e.kind === ts.SyntaxKind.FalseKeyword)
1873
+ return { kind: "boolean", value: false };
1874
+ if (e.kind === ts.SyntaxKind.NullKeyword)
1875
+ return { kind: "null" };
1876
+ let number;
1877
+ if (ts.isNumericLiteral(e))
1878
+ number = Number(e.text);
1879
+ else if (ts.isPrefixUnaryExpression(e) &&
1880
+ [ts.SyntaxKind.MinusToken, ts.SyntaxKind.PlusToken].includes(e.operator) &&
1881
+ ts.isNumericLiteral(e.operand))
1882
+ number =
1883
+ Number(e.operand.text) *
1884
+ (e.operator === ts.SyntaxKind.MinusToken ? -1 : 1);
1885
+ else
1886
+ return;
1887
+ if (!Number.isFinite(number))
1888
+ return;
1889
+ return {
1890
+ kind: "number",
1891
+ value: Object.is(number, -0) ? "-0" : String(number),
1892
+ };
1893
+ }
1894
+ function nativeComparison(call) {
1895
+ const callee = comparisonExpression(call.expression);
1896
+ if (!ts.isPropertyAccessExpression(callee) || call.arguments.length < 2)
1897
+ return undefined;
1898
+ const receiver = comparisonExpression(callee.expression);
1899
+ if (!ts.isIdentifier(receiver))
1900
+ return undefined;
1901
+ // Read the import declaration itself, before resolving its module alias.
1902
+ // A helper named `assert` is not the native assertion implementation.
1903
+ const declaration = checker.getSymbolAtLocation(receiver)?.declarations?.[0];
1904
+ if (!declaration ||
1905
+ !(ts.isImportClause(declaration) || ts.isNamespaceImport(declaration)))
1906
+ return undefined;
1907
+ const imported = ts.isImportClause(declaration)
1908
+ ? declaration.parent
1909
+ : declaration.parent.parent;
1910
+ if (!ts.isStringLiteralLike(imported.moduleSpecifier))
1911
+ return undefined;
1912
+ const module = imported.moduleSpecifier.text;
1913
+ if (![
1914
+ "node:assert",
1915
+ "node:assert/strict",
1916
+ "assert",
1917
+ "assert/strict",
1918
+ ].includes(module))
1919
+ return undefined;
1920
+ const strict = module.endsWith("/strict");
1921
+ const predicates = {
1922
+ equal: strict ? "node-same-value" : "node-loose-equality",
1923
+ strictEqual: "node-same-value",
1924
+ notEqual: strict ? "node-not-same-value" : "node-not-loose-equality",
1925
+ notStrictEqual: "node-not-same-value",
1926
+ deepEqual: strict ? "node-deep-strict-equality" : "node-deep-equality",
1927
+ deepStrictEqual: "node-deep-strict-equality",
1928
+ };
1929
+ const predicate = predicates[callee.name.text];
1930
+ if (!predicate)
1931
+ return undefined;
1932
+ const operand = (arg) => ({
1933
+ source: comparisonLocation(arg),
1934
+ binding: immutableBinding(arg),
1935
+ value: sourcePrimitive(arg),
1936
+ });
1937
+ const actual = operand(call.arguments[0]);
1938
+ const expected = operand(call.arguments[1]);
1939
+ const actualInput = comparisonInput(call.arguments[0]);
1940
+ const expectedInput = comparisonInput(call.arguments[1]);
1941
+ const sharedAwaitedInput = actualInput &&
1942
+ expectedInput &&
1943
+ actualInput.binding === expectedInput.binding &&
1944
+ actualInput.awaits.length + expectedInput.awaits.length > 0;
1945
+ const sameBinding = actual.binding && actual.binding === expected.binding;
1946
+ if (!sameBinding && sharedAwaitedInput) {
1947
+ actual.input = actualInput;
1948
+ expected.input = expectedInput;
1949
+ }
1950
+ return {
1951
+ predicate,
1952
+ actual,
1953
+ expected,
1954
+ relation: sameBinding
1955
+ ? "same-immutable-binding"
1956
+ : sharedAwaitedInput
1957
+ ? "shared-input-through-await"
1958
+ : "unresolved",
1959
+ };
1960
+ }
1961
+ function nativeContextMock(call) {
1962
+ const callee = unwrap(call.expression);
1963
+ if (!ts.isPropertyAccessExpression(callee))
1964
+ return false;
1965
+ const tracker = unwrap(callee.expression);
1966
+ if (!ts.isPropertyAccessExpression(tracker) || tracker.name.text !== "mock")
1967
+ return false;
1968
+ const context = declOf(unwrap(tracker.expression));
1969
+ if (!context || !ts.isParameter(context))
1970
+ return false;
1971
+ const callback = context.parent;
1972
+ if (!(ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) ||
1973
+ callback.parameters[0] !== context ||
1974
+ !ts.isCallExpression(callback.parent))
1975
+ return false;
1976
+ return nativeTestRegistration(callback.parent);
1977
+ }
1978
+ function nativeTestRegistration(call) {
1979
+ const registration = comparisonExpression(call.expression);
1980
+ if (!ts.isIdentifier(registration))
1981
+ return false;
1982
+ const declaration = checker.getSymbolAtLocation(registration)?.declarations?.[0];
1983
+ if (!declaration ||
1984
+ !(ts.isImportSpecifier(declaration) || ts.isImportClause(declaration)))
1985
+ return false;
1986
+ if (ts.isImportSpecifier(declaration) &&
1987
+ !["test", "it"].includes((declaration.propertyName ?? declaration.name).text))
1988
+ return false;
1989
+ const imported = ts.isImportSpecifier(declaration)
1990
+ ? declaration.parent.parent.parent
1991
+ : declaration.parent;
1992
+ return (ts.isStringLiteralLike(imported.moduleSpecifier) &&
1993
+ imported.moduleSpecifier.text === "node:test");
1994
+ }
1995
+ function mockProjection(o) {
1996
+ if (!o.kind.startsWith("mock:console."))
1997
+ return undefined;
1998
+ const path = o.path;
1999
+ const joined = path.join(".");
2000
+ const kind = joined === "mock.callCount()" ||
2001
+ (path[0] === "mock" &&
2002
+ path[1] === "calls" &&
2003
+ path[path.length - 1] === "length" &&
2004
+ path.slice(2, -1).every((step) => /^slice\([\s\S]*\)$/.test(step)))
2005
+ ? "call-count"
2006
+ : joined === "mock.calls"
2007
+ ? "call-history"
2008
+ : /(?:^|\.)arguments(?:\.\[\d+\])?$/.test(joined)
2009
+ ? "call-arguments"
2010
+ : "projection";
2011
+ return { target: o.kind.slice(5), kind, path };
2012
+ }
2013
+ /** Follow source projections to a Promise, then check only its resolver mapping.
2014
+ * This does not prove that the resolved object was not subsequently mutated,
2015
+ * or that the selected child is the producer of every covered exit site. */
2016
+ function childExitSource(expr) {
2017
+ const info = {
2018
+ model: "node-child-exit-source-v1",
2019
+ status: "unresolved",
2020
+ reason: "unsupported-promise-source",
2021
+ operand: comparisonLocation(expr),
2022
+ helperCalls: [],
2023
+ };
2024
+ const seen = new Set();
2025
+ const carriers = new Map();
2026
+ const helperReturns = new Map();
2027
+ let openCarrier = false;
2028
+ const fail = (reason) => ({ ...info, reason });
2029
+ const nativeSpawn = (call) => {
2030
+ const callee = comparisonExpression(call.expression);
2031
+ if (!ts.isIdentifier(callee))
2032
+ return false;
2033
+ const raw = checker.getSymbolAtLocation(callee)?.declarations?.[0];
2034
+ if (!raw || !ts.isImportSpecifier(raw))
2035
+ return false;
2036
+ const imported = raw.parent.parent.parent;
2037
+ return ((raw.propertyName ?? raw.name).text === "spawn" &&
2038
+ ts.isStringLiteralLike(imported.moduleSpecifier) &&
2039
+ (imported.moduleSpecifier.text === "node:child_process" ||
2040
+ imported.moduleSpecifier.text === "child_process"));
2041
+ };
2042
+ const property = (object, key) => {
2043
+ // No spreads, getters, computed keys, duplicates or inherited properties.
2044
+ const entries = new Map();
2045
+ for (const p of object.properties) {
2046
+ if (!(ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) ||
2047
+ !(ts.isIdentifier(p.name) || ts.isStringLiteralLike(p.name)))
2048
+ return;
2049
+ const name = p.name.text;
2050
+ if (name === "__proto__" || name === "then" || entries.has(name))
2051
+ return;
2052
+ entries.set(name, ts.isPropertyAssignment(p) ? p.initializer : p.name);
2053
+ }
2054
+ return entries.get(key);
2055
+ };
2056
+ const trace = (raw, path, awaitedPath, scope = enclosingFunction(expr)) => {
2057
+ const e = comparisonExpression(raw);
2058
+ if (seen.size >= 48 || seen.has(e))
2059
+ return;
2060
+ seen.add(e);
2061
+ if (ts.isAwaitExpression(e))
2062
+ return trace(e.expression, path, [...path], scope);
2063
+ if (ts.isPropertyAccessExpression(e))
2064
+ return trace(e.expression, [e.name.text, ...path], awaitedPath, scope);
2065
+ if (ts.isIdentifier(e)) {
2066
+ const d = declOf(e);
2067
+ if (d &&
2068
+ ts.isVariableDeclaration(d) &&
2069
+ ts.isIdentifier(d.name) &&
2070
+ d.initializer &&
2071
+ ts.isVariableDeclarationList(d.parent) &&
2072
+ d.parent.flags & ts.NodeFlags.Const) {
2073
+ if (!scope || enclosingFunction(d) !== scope)
2074
+ openCarrier = true;
2075
+ const uses = carriers.get(d) ?? new Set();
2076
+ uses.add(e);
2077
+ carriers.set(d, uses);
2078
+ return trace(d.initializer, path, awaitedPath, scope);
2079
+ }
2080
+ return;
2081
+ }
2082
+ if (ts.isObjectLiteralExpression(e)) {
2083
+ const next = path.length ? property(e, path[0]) : undefined;
2084
+ return next
2085
+ ? trace(next, path.slice(1), awaitedPath, scope)
2086
+ : undefined;
2087
+ }
2088
+ if (ts.isCallExpression(e) &&
2089
+ ts.isIdentifier(comparisonExpression(e.expression))) {
2090
+ const d = declOf(comparisonExpression(e.expression));
2091
+ const fn = d && ts.isFunctionDeclaration(d) ? d : undefined;
2092
+ if (!fn?.body ||
2093
+ fn.getSourceFile().isDeclarationFile ||
2094
+ seen.has(fn) ||
2095
+ fn.asteriskToken ||
2096
+ fn.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword))
2097
+ return;
2098
+ seen.add(fn);
2099
+ // Only one direct top-level return. Do not select the first branch or a
2100
+ // nested callback's return, and do not treat a helper name as a contract.
2101
+ const returns = [];
2102
+ let budget = 4096;
2103
+ const scan = (n) => {
2104
+ if (--budget < 0)
2105
+ return;
2106
+ if (ts.isReturnStatement(n))
2107
+ returns.push(n);
2108
+ else if (!ts.isFunctionLike(n))
2109
+ ts.forEachChild(n, scan);
2110
+ };
2111
+ scan(fn.body);
2112
+ const [ret] = returns;
2113
+ if (budget < 0 ||
2114
+ returns.length !== 1 ||
2115
+ ret.parent !== fn.body ||
2116
+ !ret.expression)
2117
+ return;
2118
+ info.helperCalls.push(comparisonLocation(e));
2119
+ helperReturns.set(e, ret.expression);
2120
+ return trace(ret.expression, path, awaitedPath, fn);
2121
+ }
2122
+ if (!ts.isNewExpression(e) ||
2123
+ !ts.isIdentifier(e.expression) ||
2124
+ e.expression.text !== "Promise")
2125
+ return;
2126
+ // Discovery is structural only. A Promise with no exit/close listener is
2127
+ // not an exit observation, even if a comment contains listener-shaped text.
2128
+ let hasEvent = false, discoveryBudget = 4096;
2129
+ const discover = (n) => {
2130
+ if (--discoveryBudget < 0 || hasEvent)
2131
+ return;
2132
+ if (ts.isCallExpression(n) &&
2133
+ ts.isPropertyAccessExpression(n.expression) &&
2134
+ ["on", "once"].includes(n.expression.name.text) &&
2135
+ n.arguments[0] &&
2136
+ ts.isStringLiteralLike(n.arguments[0]) &&
2137
+ ["exit", "close"].includes(n.arguments[0].text))
2138
+ hasEvent = true;
2139
+ ts.forEachChild(n, discover);
2140
+ };
2141
+ if (e.arguments?.[0])
2142
+ discover(e.arguments[0]);
2143
+ if (!hasEvent)
2144
+ return;
2145
+ info.promise = comparisonLocation(e);
2146
+ const promiseBinding = declOf(e.expression);
2147
+ // Compiler sessions may omit standard libraries. As with the native mock
2148
+ // model, global built-ins are assumed unmodified; a project declaration
2149
+ // with this name is never accepted as that global.
2150
+ if (promiseBinding && !promiseBinding.getSourceFile().isDeclarationFile)
2151
+ return fail("native-promise-binding-unverified");
2152
+ if (JSON.stringify(awaitedPath) !== JSON.stringify(path))
2153
+ return fail("promise-result-not-awaited-before-projection");
2154
+ const executor = e.arguments?.[0] && comparisonExpression(e.arguments[0]);
2155
+ if (e.arguments?.length !== 1 ||
2156
+ !executor ||
2157
+ !(ts.isArrowFunction(executor) || ts.isFunctionExpression(executor)) ||
2158
+ !ts.isBlock(executor.body) ||
2159
+ executor.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword))
2160
+ return fail("unsupported-promise-executor");
2161
+ const [resolveParam, rejectParam] = executor.parameters;
2162
+ if (!resolveParam ||
2163
+ executor.parameters.length > 2 ||
2164
+ executor.parameters.some((p) => !ts.isIdentifier(p.name) || p.initializer || p.dotDotDotToken))
2165
+ return fail("unsupported-resolver-binding");
2166
+ let eventCall;
2167
+ for (const statement of executor.body.statements) {
2168
+ if (!ts.isExpressionStatement(statement) ||
2169
+ !ts.isCallExpression(statement.expression))
2170
+ return fail("unsupported-executor-statement");
2171
+ const call = statement.expression;
2172
+ if (!ts.isPropertyAccessExpression(call.expression) ||
2173
+ !["on", "once"].includes(call.expression.name.text) ||
2174
+ call.arguments.length !== 2 ||
2175
+ !ts.isStringLiteralLike(call.arguments[0]))
2176
+ return fail("competing-or-unsupported-settlement");
2177
+ const event = call.arguments[0].text;
2178
+ if (event === "error" &&
2179
+ rejectParam &&
2180
+ declOf(comparisonExpression(call.arguments[1])) === rejectParam)
2181
+ continue;
2182
+ if (!["exit", "close"].includes(event) || eventCall)
2183
+ return fail("competing-or-unsupported-settlement");
2184
+ eventCall = call;
2185
+ }
2186
+ if (!eventCall)
2187
+ return fail("missing-exit-listener");
2188
+ const member = eventCall.expression;
2189
+ const child = declOf(comparisonExpression(member.expression));
2190
+ if (!child ||
2191
+ !ts.isVariableDeclaration(child) ||
2192
+ !child.initializer ||
2193
+ !ts.isVariableDeclarationList(child.parent) ||
2194
+ !(child.parent.flags & ts.NodeFlags.Const))
2195
+ return fail("child-binding-unverified");
2196
+ const spawn = comparisonExpression(child.initializer);
2197
+ if (!ts.isCallExpression(spawn) || !nativeSpawn(spawn))
2198
+ return fail("native-child-spawn-unverified");
2199
+ info.spawn = comparisonLocation(spawn);
2200
+ info.event = {
2201
+ source: comparisonLocation(eventCall),
2202
+ name: eventCall.arguments[0].text,
2203
+ };
2204
+ const callback = comparisonExpression(eventCall.arguments[1]);
2205
+ if (declOf(callback) === resolveParam) {
2206
+ if (path.length)
2207
+ return fail("unsupported-exit-result-projection");
2208
+ info.resolution = {
2209
+ status: "source-checked",
2210
+ source: comparisonLocation(callback),
2211
+ eventArgument: "code",
2212
+ };
2213
+ return fail("producer-instance-link-unverified");
2214
+ }
2215
+ if (!(ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) ||
2216
+ callback.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ||
2217
+ callback.parameters.length > 2 ||
2218
+ callback.parameters.some((p) => !ts.isIdentifier(p.name) || p.initializer || p.dotDotDotToken))
2219
+ return fail("unsupported-exit-callback");
2220
+ let body;
2221
+ if (!ts.isBlock(callback.body))
2222
+ body = callback.body;
2223
+ else if (callback.body.statements.length === 1) {
2224
+ const statement = callback.body.statements[0];
2225
+ if (ts.isExpressionStatement(statement))
2226
+ body = statement.expression;
2227
+ else if (ts.isReturnStatement(statement))
2228
+ body = statement.expression;
2229
+ }
2230
+ const call = body && comparisonExpression(body);
2231
+ if (!call ||
2232
+ !ts.isCallExpression(call) ||
2233
+ declOf(comparisonExpression(call.expression)) !== resolveParam ||
2234
+ call.arguments.length !== 1)
2235
+ return fail("unsupported-exit-resolver");
2236
+ let value = comparisonExpression(call.arguments[0]);
2237
+ let field;
2238
+ if (path.length) {
2239
+ if (path.length !== 1 || !ts.isObjectLiteralExpression(value))
2240
+ return fail("unsupported-exit-result-projection");
2241
+ field = path[0];
2242
+ for (const member of value.properties) {
2243
+ if (!(ts.isPropertyAssignment(member) ||
2244
+ ts.isShorthandPropertyAssignment(member)))
2245
+ return fail("unsupported-resolved-object");
2246
+ const item = comparisonExpression(ts.isPropertyAssignment(member) ? member.initializer : member.name);
2247
+ if (!(ts.isIdentifier(item) &&
2248
+ callback.parameters.some((p) => declOf(item) === p)) &&
2249
+ !ts.isStringLiteralLike(item) &&
2250
+ !ts.isNumericLiteral(item) &&
2251
+ ![
2252
+ ts.SyntaxKind.NullKeyword,
2253
+ ts.SyntaxKind.TrueKeyword,
2254
+ ts.SyntaxKind.FalseKeyword,
2255
+ ].includes(item.kind))
2256
+ return fail("transformed-or-constant-event-value");
2257
+ }
2258
+ const projected = property(value, field);
2259
+ if (!projected)
2260
+ return fail("unsupported-resolved-object");
2261
+ value = comparisonExpression(projected);
2262
+ }
2263
+ if (!ts.isIdentifier(value))
2264
+ return fail("transformed-or-constant-event-value");
2265
+ const index = callback.parameters.findIndex((p) => ts.isIdentifier(p.name) &&
2266
+ !p.initializer &&
2267
+ !p.dotDotDotToken &&
2268
+ declOf(value) === p);
2269
+ if (index !== 0 && index !== 1)
2270
+ return fail("resolved-value-is-not-event-argument");
2271
+ info.resolution = {
2272
+ status: "source-checked",
2273
+ source: comparisonLocation(call),
2274
+ ...(field ? { field } : {}),
2275
+ eventArgument: index === 0 ? "code" : "signal",
2276
+ };
2277
+ return fail("producer-instance-link-unverified");
2278
+ };
2279
+ const result = trace(expr, []);
2280
+ if (!result?.resolution)
2281
+ return result;
2282
+ // The resolver's fresh object cannot change in transit if its Promise and
2283
+ // every followed carrier stay closed to the selected read. Inspect all uses
2284
+ // of each local binding, including closures, not just preceding statements.
2285
+ // This proves preservation of the event argument, not its producer identity.
2286
+ const consumer = {
2287
+ status: "unresolved",
2288
+ reason: "result-carrier-escapes-or-is-reused",
2289
+ bindings: [...carriers.keys()].map(comparisonLocation),
2290
+ read: comparisonLocation(expr),
2291
+ blockedAt: undefined,
2292
+ };
2293
+ result.consumer = consumer;
2294
+ if (openCarrier) {
2295
+ consumer.reason = "nonlocal-result-carrier";
2296
+ return result;
2297
+ }
2298
+ const outer = (n) => {
2299
+ let p = n.parent;
2300
+ while (p &&
2301
+ ts.isExpression(p) &&
2302
+ comparisonExpression(p) === comparisonExpression(n)) {
2303
+ n = p;
2304
+ p = n.parent;
2305
+ }
2306
+ return n;
2307
+ };
2308
+ const discardedAwait = (n) => {
2309
+ const p = outer(n).parent;
2310
+ return (ts.isAwaitExpression(p) && ts.isExpressionStatement(outer(p).parent));
2311
+ };
2312
+ const discardedRace = (n) => {
2313
+ const array = n.parent;
2314
+ if (!ts.isArrayLiteralExpression(array))
2315
+ return false;
2316
+ const call = array.parent;
2317
+ if (!ts.isCallExpression(call) ||
2318
+ call.arguments.length !== 1 ||
2319
+ !ts.isPropertyAccessExpression(call.expression) ||
2320
+ call.expression.name.text !== "race")
2321
+ return false;
2322
+ const ctor = call.expression.expression;
2323
+ const binding = declOf(ctor);
2324
+ return (ts.isIdentifier(ctor) &&
2325
+ ctor.text === "Promise" &&
2326
+ (!binding || binding.getSourceFile().isDeclarationFile) &&
2327
+ discardedAwait(call));
2328
+ };
2329
+ const siblingArrowRead = (n, d) => {
2330
+ // A fresh helper return may also expose independent arrow readers, e.g.
2331
+ // buffered stdout. Arrows cannot receive the carrier as their `this`.
2332
+ // Any captured use of the result Promise is audited separately below.
2333
+ const access = n.parent;
2334
+ if (!ts.isPropertyAccessExpression(access) || access.expression !== n)
2335
+ return false;
2336
+ const call = access.parent;
2337
+ if (!ts.isCallExpression(call) ||
2338
+ call.expression !== access ||
2339
+ call.arguments.length)
2340
+ return false;
2341
+ const init = d.initializer && comparisonExpression(d.initializer);
2342
+ const value = init && ts.isCallExpression(init) ? helperReturns.get(init) : init;
2343
+ const object = value && comparisonExpression(value);
2344
+ if (!object || !ts.isObjectLiteralExpression(object))
2345
+ return false;
2346
+ const member = property(object, access.name.text);
2347
+ return !!member && ts.isArrowFunction(comparisonExpression(member));
2348
+ };
2349
+ let budget = 16384;
2350
+ for (const [d, uses] of carriers) {
2351
+ const scope = enclosingFunction(d);
2352
+ if (!scope)
2353
+ return result;
2354
+ let closed = true;
2355
+ const scan = (n) => {
2356
+ if (--budget < 0 || !closed)
2357
+ return;
2358
+ const callee = ts.isCallExpression(n)
2359
+ ? comparisonExpression(n.expression)
2360
+ : undefined;
2361
+ if (ts.isWithStatement(n) ||
2362
+ (callee && ts.isIdentifier(callee) && callee.text === "eval")) {
2363
+ closed = false;
2364
+ consumer.reason = "reflective-carrier-access";
2365
+ consumer.blockedAt = comparisonLocation(n);
2366
+ return;
2367
+ }
2368
+ if (ts.isIdentifier(n) &&
2369
+ n !== d.name &&
2370
+ declOf(n) === d &&
2371
+ !uses.has(n) &&
2372
+ !discardedAwait(n) &&
2373
+ !discardedRace(n) &&
2374
+ !siblingArrowRead(n, d)) {
2375
+ closed = false;
2376
+ consumer.blockedAt = comparisonLocation(n);
2377
+ return;
2378
+ }
2379
+ ts.forEachChild(n, scan);
2380
+ };
2381
+ scan(scope);
2382
+ if (!closed || budget < 0) {
2383
+ if (budget < 0)
2384
+ consumer.reason = "consumer-scan-budget";
2385
+ return result;
2386
+ }
2387
+ }
2388
+ consumer.status = "source-checked";
2389
+ delete consumer.reason;
2390
+ return result;
2391
+ }
1714
2392
  function originOf(expr, depth = 0) {
1715
2393
  // each property-access segment costs one level: `admin.rest.resources.Article.find.mock.calls.map(...)` read
1716
2394
  // through a local helper is 10 deep; runaway recursion through helpers is bounded by paramBindings instead
@@ -1740,11 +2418,21 @@ export function analyzeWithFrontend(options, frontend) {
1740
2418
  callee.name.text === "method" &&
1741
2419
  callee.expression.getText().endsWith(".mock") &&
1742
2420
  e.arguments.length >= 2 &&
1743
- ts.isStringLiteralLike(e.arguments[1]))
2421
+ ts.isStringLiteralLike(e.arguments[1])) {
2422
+ // Keep a same-named user helper out of the native mock model. Unsupported
2423
+ // tracker/receiver aliases remain unknown rather than acquiring stream credit.
2424
+ const receiver = unwrap(e.arguments[0]);
2425
+ const declaration = declOf(receiver);
2426
+ if (!nativeContextMock(e) ||
2427
+ !ts.isIdentifier(receiver) ||
2428
+ receiver.text !== "console" ||
2429
+ (declaration && !declaration.getSourceFile().isDeclarationFile))
2430
+ return undefined;
1744
2431
  return {
1745
2432
  kind: `mock:${e.arguments[0].getText()}.${e.arguments[1].text}`,
1746
2433
  path: [],
1747
2434
  };
2435
+ }
1748
2436
  if (ts.isIdentifier(callee)) {
1749
2437
  // Resolve the declaration below. A familiar helper name is not a
1750
2438
  // contract, nor does it identify the particular process/socket observed.
@@ -1780,10 +2468,14 @@ export function analyzeWithFrontend(options, frontend) {
1780
2468
  if (hook)
1781
2469
  return hook;
1782
2470
  }
1783
- if (["String", "Number", "Boolean"].includes(callee.text))
1784
- return e.arguments[0]
2471
+ if (["String", "Number", "Boolean"].includes(callee.text)) {
2472
+ const base = e.arguments[0]
1785
2473
  ? originOf(e.arguments[0], depth + 1)
1786
2474
  : undefined;
2475
+ return base?.kind.startsWith("mock:")
2476
+ ? { ...base, path: [...base.path, `${callee.text}()`] }
2477
+ : base;
2478
+ }
1787
2479
  const d = declOf(callee);
1788
2480
  if (d && isProdFile(d.getSourceFile()))
1789
2481
  return { kind: "prod:" + declaredName(d, callee.text), path: [] };
@@ -1831,10 +2523,14 @@ export function analyzeWithFrontend(options, frontend) {
1831
2523
  if (ts.isPropertyAccessExpression(callee)) {
1832
2524
  const name = callee.name.text;
1833
2525
  const objText = callee.expression.getText();
1834
- if (["JSON", "Object", "Array", "Promise"].includes(objText))
1835
- return e.arguments[0]
2526
+ if (["JSON", "Object", "Array", "Promise"].includes(objText)) {
2527
+ const base = e.arguments[0]
1836
2528
  ? originOf(e.arguments[0], depth + 1)
1837
2529
  : undefined;
2530
+ return base?.kind.startsWith("mock:")
2531
+ ? { ...base, path: [...base.path, `${objText}.${name}()`] }
2532
+ : base;
2533
+ }
1838
2534
  // vi.mocked(x) is x; vi.importActual('~/x') is the real module
1839
2535
  if ((objText === "vi" || objText === "jest") &&
1840
2536
  name === "mocked" &&
@@ -1848,7 +2544,10 @@ export function analyzeWithFrontend(options, frontend) {
1848
2544
  const base = originOf(callee.expression, depth + 1);
1849
2545
  if (!base)
1850
2546
  return undefined;
1851
- const o = { ...base, path: [...base.path, name + "()"] };
2547
+ const step = base.kind.startsWith("mock:")
2548
+ ? `${name}(${e.arguments.map((arg) => arg.getText()).join(", ")})`
2549
+ : name + "()";
2550
+ const o = { ...base, path: [...base.path, step] };
1852
2551
  // promise.catch(e => e) carries the rejection; promise.then(onOk, onErr) carries either
1853
2552
  if (name === "catch" && e.arguments[0])
1854
2553
  o.thrown = "only";
@@ -1902,7 +2601,10 @@ export function analyzeWithFrontend(options, frontend) {
1902
2601
  }
1903
2602
  if (ts.isElementAccessExpression(e)) {
1904
2603
  const b = originOf(e.expression, depth + 1);
1905
- return b ? { ...b, path: [...b.path, "[]"] } : undefined;
2604
+ const step = b?.kind.startsWith("mock:")
2605
+ ? `[${e.argumentExpression.getText()}]`
2606
+ : "[]";
2607
+ return b ? { ...b, path: [...b.path, step] } : undefined;
1906
2608
  }
1907
2609
  if (ts.isConditionalExpression(e))
1908
2610
  return undefined; // selected branch needs value-flow evidence
@@ -1915,8 +2617,12 @@ export function analyzeWithFrontend(options, frontend) {
1915
2617
  // from the first operand whose name can be resolved.
1916
2618
  return undefined;
1917
2619
  }
1918
- if (ts.isPrefixUnaryExpression(e))
1919
- return originOf(e.operand, depth + 1);
2620
+ if (ts.isPrefixUnaryExpression(e)) {
2621
+ const base = originOf(e.operand, depth + 1);
2622
+ return base?.kind.startsWith("mock:")
2623
+ ? { ...base, path: [...base.path, `unary:${e.operator}`] }
2624
+ : base;
2625
+ }
1920
2626
  if (ts.isIdentifier(e)) {
1921
2627
  // imports first, by their import declaration: alias resolution can fail on deep re-exports
1922
2628
  const raw = checker.getSymbolAtLocation(e)?.declarations?.[0];
@@ -2014,11 +2720,6 @@ export function analyzeWithFrontend(options, frontend) {
2014
2720
  if (fed)
2015
2721
  return { kind: fed, path: [] };
2016
2722
  }
2017
- // promise resolved from a child process 'close'/'exit' event carries the exit code
2018
- if (ts.isNewExpression(init) &&
2019
- init.expression.getText() === "Promise" &&
2020
- /\.(once|on)\(\s*['"](close|exit)['"]/.test(init.getText()))
2021
- return { kind: "proc-exit", path: [] };
2022
2723
  // Mutable scalar aliases need reaching definitions, not the initializer
2023
2724
  // or first assignment anywhere in the file. Container/stream cases above
2024
2725
  // retain their separate models.
@@ -2150,8 +2851,6 @@ export function analyzeWithFrontend(options, frontend) {
2150
2851
  return [{ boundary: "stdout" }];
2151
2852
  case "proc-stderr":
2152
2853
  return [{ boundary: "stderr" }];
2153
- case "proc-exit":
2154
- return [{ boundary: "exit" }];
2155
2854
  case "sdk-client":
2156
2855
  if (has("sessionId"))
2157
2856
  return [{ boundary: "client-header", facet: "mcp-session-id" }];
@@ -2204,7 +2903,10 @@ export function analyzeWithFrontend(options, frontend) {
2204
2903
  // t.mock.method(console, 'log'): a test-owned replacement of a stream sink
2205
2904
  if (o.kind.startsWith("mock:console."))
2206
2905
  return [
2207
- { boundary: o.kind.endsWith(".error") ? "stderr" : "stdout" },
2906
+ {
2907
+ boundary: /\.(error|warn)$/.test(o.kind) ? "stderr" : "stdout",
2908
+ facet: o.kind.slice(5),
2909
+ },
2208
2910
  ];
2209
2911
  return [];
2210
2912
  }
@@ -2493,7 +3195,74 @@ export function analyzeWithFrontend(options, frontend) {
2493
3195
  const key = `${shape} [${why}]`;
2494
3196
  unrecognized.set(key, (unrecognized.get(key) ?? 0) + 1);
2495
3197
  }
3198
+ const nativeImportedMethod = (call, module, methods) => {
3199
+ const callee = comparisonExpression(call.expression);
3200
+ if (!ts.isPropertyAccessExpression(callee) ||
3201
+ !methods.includes(callee.name.text))
3202
+ return false;
3203
+ const receiver = comparisonExpression(callee.expression);
3204
+ const d = checker.getSymbolAtLocation(receiver)?.declarations?.[0];
3205
+ const imported = d &&
3206
+ (ts.isImportClause(d)
3207
+ ? d.parent
3208
+ : ts.isNamespaceImport(d)
3209
+ ? d.parent.parent
3210
+ : undefined);
3211
+ return (!!imported &&
3212
+ ts.isStringLiteralLike(imported.moduleSpecifier) &&
3213
+ imported.moduleSpecifier.text === module);
3214
+ };
3215
+ const nativeGlobalValue = (expr, name) => {
3216
+ const e = comparisonExpression(expr), d = declOf(e);
3217
+ return (ts.isIdentifier(e) &&
3218
+ e.text === name &&
3219
+ (!d || d.getSourceFile().isDeclarationFile));
3220
+ };
3221
+ const mockModel = {
3222
+ declaration: declOf,
3223
+ location: comparisonLocation,
3224
+ nativeMock: nativeContextMock,
3225
+ nativePredicate: (call) => nativeComparison(call)?.predicate ??
3226
+ (nativeImportedMethod(call, "node:assert/strict", ["match"])
3227
+ ? "node-literal-regexp"
3228
+ : undefined),
3229
+ nativeException: (call) => {
3230
+ const method = nativeAssertionIdentity(call)?.method;
3231
+ return method === "throws" || method === "doesNotThrow"
3232
+ ? method
3233
+ : undefined;
3234
+ },
3235
+ globalError: (expr) => nativeGlobalValue(expr, "Error"),
3236
+ nativeTest: nativeTestRegistration,
3237
+ nativeInspect: (call) => nativeImportedMethod(call, "node:util", ["inspect"]),
3238
+ nativeTty: (expr) => ts.isPropertyAccessExpression(expr) &&
3239
+ expr.name.text === "isTTY" &&
3240
+ ts.isPropertyAccessExpression(expr.expression) &&
3241
+ expr.expression.name.text === "stderr" &&
3242
+ nativeGlobalValue(expr.expression.expression, "process"),
3243
+ nativeAssertion: (call) => nativeImportedMethod(call, "node:assert/strict", [
3244
+ "equal",
3245
+ "strictEqual",
3246
+ "deepEqual",
3247
+ "deepStrictEqual",
3248
+ "match",
3249
+ ]),
3250
+ globalString: (expr) => nativeGlobalValue(expr, "String"),
3251
+ globalConsole: (expr) => {
3252
+ const e = comparisonExpression(expr);
3253
+ const d = declOf(e);
3254
+ return (ts.isIdentifier(e) &&
3255
+ e.text === "console" &&
3256
+ (!d || d.getSourceFile().isDeclarationFile));
3257
+ },
3258
+ production: (node) => isProdFile(node.getSourceFile()),
3259
+ site: (call) => smallestSiteContaining(rel(call.getSourceFile()), call.getStart(), call.getEnd())?.id,
3260
+ };
3261
+ function countModel(fn, row) {
3262
+ return analyzeMockCounts(ts, fn, mockModel, row);
3263
+ }
2496
3264
  function analyzeTestBody(fn, file, line, name, inert = false) {
3265
+ const mockCounts = countModel(fn);
2497
3266
  const observations = [];
2498
3267
  const sinks = [];
2499
3268
  const rendered = new Set();
@@ -2585,15 +3354,26 @@ export function analyzeWithFrontend(options, frontend) {
2585
3354
  let expected;
2586
3355
  let negative = false;
2587
3356
  let rejectsChain = false;
2588
- if (ts.isPropertyAccessExpression(callee) &&
3357
+ const nativeAssertion = nativeAssertionIdentity(node);
3358
+ if (nativeAssertion)
3359
+ method = nativeAssertion.method;
3360
+ else if (ts.isPropertyAccessExpression(callee) &&
2589
3361
  callee.expression.getText() === "assert")
2590
3362
  method = callee.name.text;
2591
3363
  else if (ts.isIdentifier(callee) && callee.text === "assert")
2592
3364
  method = "assert";
2593
3365
  if (method && ASSERT_STRENGTH[method]) {
2594
3366
  strength = ASSERT_STRENGTH[method];
2595
- actuals = node.arguments.slice(0, 2);
2596
- expected = node.arguments[1];
3367
+ // On a passing native ok/doesNotThrow/doesNotReject call, only the
3368
+ // first argument participates in the success predicate. The no-error
3369
+ // methods return before inspecting their optional matcher/message.
3370
+ // Argument evaluation can still have effects or throw. Do not apply
3371
+ // this rule to throws/rejects: their matcher and string-ambiguity
3372
+ // checks can inspect the second value even when an error occurred.
3373
+ const firstOperandOnly = nativeAssertion !== undefined &&
3374
+ ["ok", "doesNotThrow", "doesNotReject"].includes(nativeAssertion.method);
3375
+ actuals = node.arguments.slice(0, firstOperandOnly ? 1 : 2);
3376
+ expected = firstOperandOnly ? undefined : node.arguments[1];
2597
3377
  negative = method === "doesNotMatch";
2598
3378
  }
2599
3379
  else if (ts.isPropertyAccessExpression(callee)) {
@@ -2628,6 +3408,7 @@ export function analyzeWithFrontend(options, frontend) {
2628
3408
  /\bexpect\.(objectContaining|arrayContaining|anything|any|stringContaining|stringMatching|closeTo)\s*\(/.test(expected.getText()))
2629
3409
  strength = "value";
2630
3410
  if (method && strength) {
3411
+ const comparison = nativeComparison(node);
2631
3412
  pragmaCollector.register(node, method, staticTestKey(file, line, name), inert);
2632
3413
  const s = strength;
2633
3414
  const pattern = expected &&
@@ -2663,8 +3444,9 @@ export function analyzeWithFrontend(options, frontend) {
2663
3444
  const observesThrow = rejectsChain ||
2664
3445
  ["rejects", "throws", "toThrow", "toThrowError"].includes(method);
2665
3446
  for (const arg of actuals) {
2666
- const o = originOf(arg);
2667
3447
  const unresolvedOperand = (shape) => pending.push({
3448
+ assertionSource: `${relative(root, sf.fileName)}:${sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1}:${sf.getLineAndCharacterOfPosition(node.getStart(sf)).character + 1}`,
3449
+ assertionMethod: method,
2668
3450
  statements: definingStatements(arg),
2669
3451
  strength: RANK[s] > RANK.value ? "value" : s,
2670
3452
  negative: !!negative,
@@ -2672,6 +3454,33 @@ export function analyzeWithFrontend(options, frontend) {
2672
3454
  where: where(node, method),
2673
3455
  shape: `${arg.getText().replace(/\s+/g, " ").slice(0, 40)} [${shape}]`,
2674
3456
  });
3457
+ if (nativeAssertion &&
3458
+ arg === node.arguments[0] &&
3459
+ ["throws", "rejects", "doesNotThrow", "doesNotReject"].includes(nativeAssertion.method)) {
3460
+ // The operand supplies a callback or promise, not the value a
3461
+ // normal value assertion reads. `doesNotThrow(fn)` ignores fn's
3462
+ // result; `throws(factory())` catches the returned callback's
3463
+ // exception, not an exception thrown while evaluating factory().
3464
+ // Async variants additionally distinguish synchronous invocation,
3465
+ // promise validation and settlement. The ordinary origin/owner
3466
+ // model cannot establish those invocation and completion paths.
3467
+ // Retain the passing assertion as a limit, including when its
3468
+ // callable's source is recognizable; never invent return credit
3469
+ // or convert the operand producer's return into a caught throw.
3470
+ const completion = nativeAssertion.method === "throws"
3471
+ ? "synchronous throw"
3472
+ : nativeAssertion.method === "doesNotThrow"
3473
+ ? "synchronous normal"
3474
+ : nativeAssertion.method === "rejects"
3475
+ ? "asynchronous rejection"
3476
+ : "asynchronous fulfillment";
3477
+ unresolvedOperand(`${completion} completion; callback/promise producer and invocation path unresolved`);
3478
+ continue;
3479
+ }
3480
+ const exitSource = childExitSource(arg);
3481
+ const o = exitSource
3482
+ ? { kind: "process-exit-source", path: [] }
3483
+ : originOf(arg);
2675
3484
  if (!o) {
2676
3485
  noteUnrecognized(arg, "no origin");
2677
3486
  unresolvedOperand("no origin");
@@ -2683,13 +3492,24 @@ export function analyzeWithFrontend(options, frontend) {
2683
3492
  const facetPattern = o.facet?.startsWith("pattern:")
2684
3493
  ? o.facet.slice(8)
2685
3494
  : undefined;
2686
- const bs = boundariesOf(o);
3495
+ const bs = exitSource ? [{ boundary: "exit" }] : boundariesOf(o);
3496
+ const mock = mockProjection(o);
3497
+ if (mock?.kind === "call-count")
3498
+ mock.countEvidence = mockCounts.checks.get(node) ?? {
3499
+ model: "node-sync-console-count-v2",
3500
+ status: "unresolved",
3501
+ reason: mockCounts.limitation ?? "unsupported-count-projection",
3502
+ };
3503
+ if (mock)
3504
+ unresolvedOperand(`mock ${mock.kind}; production call identity and projection dependence unresolved`);
2687
3505
  if (!bs.length && o.kind !== "literal") {
2688
3506
  noteUnrecognized(arg, o.kind);
2689
3507
  unresolvedOperand(o.kind);
2690
3508
  }
2691
- // rejects/throws: the function's throw sites are observed as well as (or instead of) its return
2692
- if (observesThrow)
3509
+ // Native first-operand completion was handled above. Its second
3510
+ // operand supplies an expectation/diagnostic, not a caught operation.
3511
+ // Non-native inference remains separate from the native model.
3512
+ if (observesThrow && !nativeAssertion)
2693
3513
  for (const b of [...bs])
2694
3514
  if (b.boundary.startsWith("return:"))
2695
3515
  bs.push({ boundary: "throw:" + b.boundary.slice(7) });
@@ -2708,8 +3528,21 @@ export function analyzeWithFrontend(options, frontend) {
2708
3528
  where: where(node, method),
2709
3529
  assertionSource: `${relative(root, sf.fileName)}:${sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1}:${sf.getLineAndCharacterOfPosition(node.getStart(sf)).character + 1}`,
2710
3530
  assertionMethod: method,
3531
+ ...(mock ? { mock } : {}),
3532
+ ...(comparison ? { comparison } : {}),
3533
+ ...(b.boundary === "exit"
3534
+ ? {
3535
+ processExit: exitSource ?? {
3536
+ model: "node-child-exit-source-v1",
3537
+ status: "unresolved",
3538
+ reason: "unverified-event-channel",
3539
+ operand: comparisonLocation(arg),
3540
+ helperCalls: [],
3541
+ },
3542
+ }
3543
+ : {}),
2711
3544
  };
2712
- if (callList && b.boundary.startsWith("sink:"))
3545
+ if (!mock && callList && b.boundary.startsWith("sink:"))
2713
3546
  ob.callList = true;
2714
3547
  if (pattern)
2715
3548
  ob.pattern = pattern;
@@ -2729,6 +3562,19 @@ export function analyzeWithFrontend(options, frontend) {
2729
3562
  }
2730
3563
  // implicit oracles: awaited reads that throw or time out
2731
3564
  if (ts.isAwaitExpression(node.parent)) {
3565
+ if (!(method && strength) && pragmaCollector.hasHint(node)) {
3566
+ const source = awaitedObservationSource({
3567
+ syntax: ts,
3568
+ declaration: declOf,
3569
+ rawDeclaration: (n) => {
3570
+ const symbol = checker.getSymbolAtLocation(n);
3571
+ return symbol?.valueDeclaration ?? symbol?.declarations?.[0];
3572
+ },
3573
+ relativeFile: rel,
3574
+ }, fn, node);
3575
+ if (source && ts.isPropertyAccessExpression(node.expression))
3576
+ pragmaCollector.register(node, node.expression.name.text, staticTestKey(file, line, name), inert, source);
3577
+ }
2732
3578
  const o = originOf(node);
2733
3579
  if (o) {
2734
3580
  const bs = boundariesOf(o);
@@ -2928,6 +3774,7 @@ export function analyzeWithFrontend(options, frontend) {
2928
3774
  ? arg0.text
2929
3775
  : arg0.getText(sf).replace(/^[`'"]|[`'"]$/g, "");
2930
3776
  staticTests.push(st);
3777
+ mockBodies.set(st, decl.body);
2931
3778
  }
2932
3779
  ts.forEachChild(node, visit);
2933
3780
  };
@@ -2998,6 +3845,72 @@ export function analyzeWithFrontend(options, frontend) {
2998
3845
  }
2999
3846
  }
3000
3847
  const staticFor = (rt) => staticById.get(rt.id) ?? staticLink.get(`${rt.file}:${rt.line}`);
3848
+ // A custom registrar's callback can be identified by actual assertion call
3849
+ // points without proving the registrar or its captured row. Keep that weaker
3850
+ // relationship explicit. In particular, a wrapper may change the title, call
3851
+ // this same body with other inputs, catch failures, or run other callbacks.
3852
+ // Do this AFTER legacy linking: recovered bodies must never be candidates for
3853
+ // another attempt's title/rank/nearest-line fallback.
3854
+ const witnessedBodies = new Map();
3855
+ for (const sf of allFiles) {
3856
+ if (!isTestFile(sf))
3857
+ continue;
3858
+ const visit = (node) => {
3859
+ if (ts.isCallExpression(node) &&
3860
+ node.arguments.length >= 2 &&
3861
+ !enclosingFunction(node) &&
3862
+ !testDeclaration(node)) {
3863
+ const body = node.arguments[node.arguments.length - 1];
3864
+ if (ts.isArrowFunction(body) || ts.isFunctionExpression(body)) {
3865
+ const operations = new Map();
3866
+ const assertion = (n) => {
3867
+ if (n !== body && ts.isFunctionLike(n))
3868
+ return;
3869
+ if (ts.isCallExpression(n)) {
3870
+ const nativeAssertion = nativeAssertionIdentity(n);
3871
+ if (nativeAssertion) {
3872
+ const p = sf.getLineAndCharacterOfPosition(n.getStart(sf));
3873
+ operations.set(`${rel(sf)}:${p.line + 1}:${p.character + 1}`, `${nativeAssertion.module}.${nativeAssertion.method}`);
3874
+ }
3875
+ }
3876
+ ts.forEachChild(n, assertion);
3877
+ };
3878
+ assertion(body);
3879
+ if (operations.size)
3880
+ witnessedBodies.set(body, { call: node, operations });
3881
+ }
3882
+ }
3883
+ ts.forEachChild(node, visit);
3884
+ };
3885
+ visit(sf);
3886
+ }
3887
+ const witnessedBodyLinks = new Map();
3888
+ for (const rt of runtimeTests) {
3889
+ if (staticFor(rt) || rt.runner !== "node:test")
3890
+ continue;
3891
+ const phases = runtimePhases.get(rt.id);
3892
+ if (!phases?.length || phases.some((p) => p.status !== "passed"))
3893
+ continue;
3894
+ const matches = [...witnessedBodies].filter(([body, candidate]) => rel(body.getSourceFile()) === rt.file &&
3895
+ phases.every((p) => candidate.operations.get(p.source) === p.op));
3896
+ if (matches.length !== 1)
3897
+ continue;
3898
+ const [body, candidate] = matches[0];
3899
+ const sf = body.getSourceFile();
3900
+ if (!candidate.st) {
3901
+ const call = candidate.call;
3902
+ candidate.st = analyzeTestBody(body, rel(sf), sf.getLineAndCharacterOfPosition(call.getStart(sf)).line + 1, call.arguments[0].getText(sf).slice(0, 60));
3903
+ candidate.st.endLine =
3904
+ sf.getLineAndCharacterOfPosition(call.getEnd()).line + 1;
3905
+ staticTests.push(candidate.st);
3906
+ mockBodies.set(candidate.st, body);
3907
+ }
3908
+ staticById.set(rt.id, candidate.st);
3909
+ witnessedBodyLinks.set(rt.id, {
3910
+ body: comparisonLocation(body),
3911
+ assertions: [...new Set(phases.map((p) => p.source))],
3912
+ });
3913
+ }
3001
3914
  const decisionFacts = new Map();
3002
3915
  function classByName(name, file) {
3003
3916
  const sf = srcByFile.get(file);
@@ -3166,20 +4079,33 @@ export function analyzeWithFrontend(options, frontend) {
3166
4079
  return out;
3167
4080
  }
3168
4081
  /**
3169
- * Was this site plausibly asserted through an operand whose shape the analysis could not trace? Statement
3170
- * attribution answers it: the operand's defining statements record which production functions were entered
3171
- * while they ran, so an operand that entered this site's owner may well read it. Returns the shapes to
3172
- * report, so "unresolved" can say "limit" instead of sending an agent to write a test that already exists.
4082
+ * Unknown operand dependence is a limit, not proof of an absent assertion.
4083
+ * Synchronous statement attribution can identify a possible relationship but
4084
+ * cannot exclude one across an await, pipe capture, or another async boundary.
4085
+ * A passing unmodeled assertion therefore leaves dependence unresolved for
4086
+ * sites covered by that test. This only changes the reason for an unresolved
4087
+ * candidate: it never supplies an observation, strength, or positive test link.
3173
4088
  */
3174
4089
  function unmodelledOperands(s, covering) {
3175
4090
  buildFunctionIndex();
3176
4091
  const shapes = new Set();
3177
4092
  for (const rt of covering) {
3178
4093
  const byStatement = runtimeStatements.get(rt.id);
3179
- const st = byStatement ? staticFor(rt) : undefined;
3180
- if (!byStatement || !st)
4094
+ const st = staticFor(rt);
4095
+ if (!st)
3181
4096
  continue;
3182
- for (const p of st.pending)
4097
+ const phases = runtimePhases.get(rt.id);
4098
+ for (const p of st.pending) {
4099
+ if (phases) {
4100
+ if (!assertionWitnessIssue(phases, p.assertionSource, p.assertionMethod))
4101
+ shapes.add(`${p.where}: ${p.shape} (passing assertion in covering test ${rt.id}; operand dependence unresolved)`);
4102
+ // A known failed, mixed, incomplete or unexecuted call cannot supply
4103
+ // this passing-operand limit. Missing witness transport is separately
4104
+ // reported by witnessIssues. Legacy statement evidence remains below.
4105
+ continue;
4106
+ }
4107
+ if (!byStatement)
4108
+ continue;
3183
4109
  for (const pos of p.statements) {
3184
4110
  const attribution = byStatement[pos];
3185
4111
  if (!attribution)
@@ -3189,6 +4115,7 @@ export function analyzeWithFrontend(options, frontend) {
3189
4115
  if (entered)
3190
4116
  shapes.add(p.shape);
3191
4117
  }
4118
+ }
3192
4119
  }
3193
4120
  return [...shapes];
3194
4121
  }
@@ -3823,6 +4750,9 @@ export function analyzeWithFrontend(options, frontend) {
3823
4750
  assertionMethod: ob.assertionMethod,
3824
4751
  negative: ob.negative,
3825
4752
  callList: ob.callList,
4753
+ mock: ob.mock,
4754
+ comparison: ob.comparison,
4755
+ processExit: ob.processExit,
3826
4756
  weak: ob.weak,
3827
4757
  implicit: ob.implicit,
3828
4758
  runtime: ob.runtime,
@@ -3834,12 +4764,93 @@ export function analyzeWithFrontend(options, frontend) {
3834
4764
  .length > 1
3835
4765
  : undefined,
3836
4766
  });
3837
- const factTests = runtimeTests
3838
- .map((rt) => {
4767
+ const rowPlans = new Map();
4768
+ const runtimeRowTitles = new Map();
4769
+ for (const rt of runtimeTests) {
4770
+ const key = JSON.stringify([rt.file, rt.title]);
4771
+ runtimeRowTitles.set(key, (runtimeRowTitles.get(key) ?? 0) + 1);
4772
+ }
4773
+ function runtimeCountObservations(st, rt) {
4774
+ const body = mockBodies.get(st);
4775
+ if (!body || !st.observations.some((ob) => ob.mock?.kind === "call-count"))
4776
+ return st.observations;
4777
+ if (!rowPlans.has(body))
4778
+ rowPlans.set(body, sourceTestRows(ts, body, {
4779
+ declaration: declOf,
4780
+ location: comparisonLocation,
4781
+ nativeTest: nativeTestRegistration,
4782
+ }));
4783
+ const plan = rowPlans.get(body);
4784
+ if (!plan)
4785
+ return st.observations;
4786
+ const row = rt.title === undefined
4787
+ ? undefined
4788
+ : plan.rows.find((r) => r.evidence.title === rt.title);
4789
+ const duplicate = (runtimeRowTitles.get(JSON.stringify([rt.file, rt.title])) ?? 0) > 1;
4790
+ const reason = plan.reason ??
4791
+ (rt.runner !== "node:test"
4792
+ ? "unsupported-row-runtime-runner"
4793
+ : !row
4794
+ ? "runtime-title-does-not-identify-source-row"
4795
+ : duplicate
4796
+ ? "ambiguous-runtime-row-title"
4797
+ : undefined);
4798
+ const result = !reason && row ? countModel(body, row) : undefined;
4799
+ const checks = new Map();
4800
+ for (const [node, evidence] of result?.checks ?? []) {
4801
+ const sf = node.getSourceFile(), position = sf.getLineAndCharacterOfPosition(node.getStart(sf));
4802
+ checks.set(`${rel(sf)}:${position.line + 1}:${position.character + 1}`, evidence);
4803
+ }
4804
+ return st.observations.map((ob) => ob.mock?.kind !== "call-count"
4805
+ ? ob
4806
+ : {
4807
+ ...ob,
4808
+ mock: {
4809
+ ...ob.mock,
4810
+ countEvidence: checks.get(ob.assertionSource ?? "") ?? {
4811
+ model: "node-sync-console-count-v2",
4812
+ status: "unresolved",
4813
+ reason: reason ??
4814
+ result?.limitation ??
4815
+ "unsupported-count-projection",
4816
+ rowBinding: reason
4817
+ ? {
4818
+ model: "node-test-for-of-v1",
4819
+ status: "unresolved",
4820
+ reason,
4821
+ }
4822
+ : row.evidence,
4823
+ },
4824
+ },
4825
+ });
4826
+ }
4827
+ const unlinkedTests = runtimeTests
4828
+ .filter((rt) => !staticFor(rt))
4829
+ .map((rt) => ({
4830
+ id: rt.id,
4831
+ file: rt.file,
4832
+ title: rt.title ?? rt.name,
4833
+ reason: "test-source-unlinked",
4834
+ }));
4835
+ const factTests = runtimeTests.map((rt) => {
3839
4836
  const st = staticFor(rt);
4837
+ // A passed runtime attempt stays in the inventory even when source
4838
+ // registration discovery failed. It is NOT an assertion-free test.
3840
4839
  if (!st)
3841
- return undefined;
3842
- const checked = st.observations.map((ob) => ({
4840
+ return {
4841
+ id: rt.id,
4842
+ file: rt.file,
4843
+ observations: [],
4844
+ sinks: [],
4845
+ rendered: [],
4846
+ witnessIssues: [
4847
+ { kind: "test-source-unlinked" },
4848
+ ...(!runtimePhases.has(rt.id)
4849
+ ? [{ kind: "capture-unavailable" }]
4850
+ : []),
4851
+ ],
4852
+ };
4853
+ const checked = runtimeCountObservations(st, rt).map((ob) => ({
3843
4854
  ob,
3844
4855
  kind: witnessIssue(rt.id, ob),
3845
4856
  }));
@@ -3849,6 +4860,9 @@ export function analyzeWithFrontend(options, frontend) {
3849
4860
  // Missing transport applies to the whole test, even when no operand could
3850
4861
  // be modeled. An empty phase file is different from no phase file.
3851
4862
  const witnessIssues = [
4863
+ ...(witnessedBodyLinks.has(rt.id)
4864
+ ? [{ kind: "test-registration-scope-unverified" }]
4865
+ : []),
3852
4866
  ...(!runtimePhases.has(rt.id)
3853
4867
  ? [{ kind: "capture-unavailable" }]
3854
4868
  : []),
@@ -3869,8 +4883,7 @@ export function analyzeWithFrontend(options, frontend) {
3869
4883
  sinks: st.sinks,
3870
4884
  rendered: [...st.rendered],
3871
4885
  };
3872
- })
3873
- .filter((t) => t !== undefined);
4886
+ });
3874
4887
  // vi.mock boundaries depend on the test file, not the test: one entry per (file, site) pair that has any
3875
4888
  const mocksByTestFile = {};
3876
4889
  for (const file of new Set(factTests.map((t) => t.file))) {
@@ -3883,6 +4896,167 @@ export function analyzeWithFrontend(options, frontend) {
3883
4896
  if (Object.keys(perSite).length)
3884
4897
  mocksByTestFile[file] = perSite;
3885
4898
  }
4899
+ const primitiveModules = new Map();
4900
+ function primitiveDecision(s) {
4901
+ const condition = siteNodes.get(s.id);
4902
+ if (!condition ||
4903
+ !ts.isIdentifier(condition) ||
4904
+ !ts.isIfStatement(condition.parent) ||
4905
+ condition.parent.expression !== condition)
4906
+ return;
4907
+ const branch = condition.parent, fn = enclosingFunction(branch);
4908
+ if (!fn ||
4909
+ !ts.isFunctionDeclaration(fn) ||
4910
+ !fn.name ||
4911
+ !fn.body ||
4912
+ fn.asteriskToken ||
4913
+ fn.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ||
4914
+ fn.body.statements.length !== 1 ||
4915
+ fn.body.statements[0] !== branch ||
4916
+ fn.parameters.length !== 1)
4917
+ return;
4918
+ const parameter = fn.parameters[0];
4919
+ if (!ts.isIdentifier(parameter.name) ||
4920
+ parameter.initializer ||
4921
+ parameter.dotDotDotToken ||
4922
+ declOf(condition) !== parameter)
4923
+ return;
4924
+ const literalReturn = (statement) => {
4925
+ if (!statement ||
4926
+ !ts.isBlock(statement) ||
4927
+ statement.statements.length !== 1)
4928
+ return;
4929
+ const ret = statement.statements[0];
4930
+ return ts.isReturnStatement(ret) && ret.expression
4931
+ ? sourcePrimitive(ret.expression)
4932
+ : undefined;
4933
+ };
4934
+ const whenTrue = literalReturn(branch.thenStatement), whenFalse = literalReturn(branch.elseStatement);
4935
+ if (!whenTrue || !whenFalse)
4936
+ return;
4937
+ const sf = fn.getSourceFile();
4938
+ if (fn.parent !== sf)
4939
+ return;
4940
+ // A module of declarations only, with no binding writes or dynamic eval.
4941
+ // Do not assume that an exported function declaration can never be replaced.
4942
+ if (!primitiveModules.has(sf)) {
4943
+ let safe = sf.statements.every((n) => ts.isFunctionDeclaration(n) ||
4944
+ ts.isInterfaceDeclaration(n) ||
4945
+ ts.isTypeAliasDeclaration(n) ||
4946
+ ts.isEmptyStatement(n));
4947
+ let budget = 16384;
4948
+ const scan = (n) => {
4949
+ if (!safe)
4950
+ return;
4951
+ if (--budget < 0 ||
4952
+ (ts.isIdentifier(n) && n.text === "eval") ||
4953
+ (ts.isBinaryExpression(n) &&
4954
+ n.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
4955
+ n.operatorToken.kind <= ts.SyntaxKind.LastAssignment) ||
4956
+ ((ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) &&
4957
+ [
4958
+ ts.SyntaxKind.PlusPlusToken,
4959
+ ts.SyntaxKind.MinusMinusToken,
4960
+ ].includes(n.operator))) {
4961
+ safe = false;
4962
+ return;
4963
+ }
4964
+ ts.forEachChild(n, scan);
4965
+ };
4966
+ scan(sf);
4967
+ primitiveModules.set(sf, safe);
4968
+ }
4969
+ if (!primitiveModules.get(sf))
4970
+ return;
4971
+ const covered = runtimeTests.filter((rt) => covers(rt.id, s));
4972
+ const trueTests = testsWithOutcome(s, true), falseTests = testsWithOutcome(s, false);
4973
+ if (!covered.length || !trueTests || !falseTests)
4974
+ return;
4975
+ const checks = [];
4976
+ for (const rt of covered) {
4977
+ const st = staticFor(rt), body = st && mockBodies.get(st);
4978
+ if (!st ||
4979
+ !body ||
4980
+ !ts.isArrowFunction(body) ||
4981
+ !ts.isBlock(body.body) ||
4982
+ body.parameters.length ||
4983
+ body.modifiers?.length ||
4984
+ body.body.statements.length !== 1 ||
4985
+ !ts.isCallExpression(body.parent) ||
4986
+ !nativeTestRegistration(body.parent) ||
4987
+ body.parent.arguments.length !== 2 ||
4988
+ body.parent.arguments[1] !== body ||
4989
+ !ts.isStringLiteralLike(body.parent.arguments[0]) ||
4990
+ !ts.isExpressionStatement(body.parent.parent) ||
4991
+ !ts.isSourceFile(body.parent.parent.parent) ||
4992
+ rt.runner !== "node:test" ||
4993
+ rt.title !== body.parent.arguments[0].text ||
4994
+ (runtimeRowTitles.get(JSON.stringify([rt.file, rt.title])) ?? 0) !== 1)
4995
+ return;
4996
+ // A top-level hook/setup call can replace imports or assertions before the
4997
+ // callback. Such test modules require a separate environment model.
4998
+ if (!body
4999
+ .getSourceFile()
5000
+ .statements.every((statement) => ts.isImportDeclaration(statement) ||
5001
+ ts.isEmptyStatement(statement) ||
5002
+ (ts.isExpressionStatement(statement) &&
5003
+ ts.isCallExpression(statement.expression) &&
5004
+ nativeTestRegistration(statement.expression) &&
5005
+ statement.expression.arguments.length === 2)))
5006
+ return;
5007
+ const statement = body.body.statements[0];
5008
+ if (!ts.isExpressionStatement(statement) ||
5009
+ !ts.isCallExpression(statement.expression))
5010
+ return;
5011
+ const assertion = statement.expression;
5012
+ if (assertion.arguments.length !== 2)
5013
+ return;
5014
+ const comparison = nativeComparison(assertion);
5015
+ if (!comparison ||
5016
+ !["node-same-value", "node-not-same-value"].includes(comparison.predicate))
5017
+ return;
5018
+ const actual = comparisonExpression(assertion.arguments[0]), expected = sourcePrimitive(assertion.arguments[1]);
5019
+ if (!expected ||
5020
+ !ts.isCallExpression(actual) ||
5021
+ actual.questionDotToken ||
5022
+ !ts.isIdentifier(actual.expression) ||
5023
+ declOf(actual.expression) !== fn ||
5024
+ actual.arguments.length !== 1)
5025
+ return;
5026
+ const input = sourcePrimitive(actual.arguments[0]);
5027
+ if (!input ||
5028
+ input.kind !== "boolean" ||
5029
+ trueTests.has(rt.id) !== input.value ||
5030
+ falseTests.has(rt.id) !== !input.value)
5031
+ return;
5032
+ const pos = assertion
5033
+ .getSourceFile()
5034
+ .getLineAndCharacterOfPosition(assertion.getStart());
5035
+ const assertionSource = `${rel(assertion.getSourceFile())}:${pos.line + 1}:${pos.character + 1}`;
5036
+ const test = factTests.find((t) => t.id === rt.id);
5037
+ if (!test ||
5038
+ test.witnessIssues?.length ||
5039
+ test.observations.length !== 1 ||
5040
+ test.observations[0].assertionSource !== assertionSource ||
5041
+ test.observations[0].boundary !== `return:${fn.name.text}` ||
5042
+ test.observations[0].comparison?.predicate !== comparison.predicate)
5043
+ return;
5044
+ checks.push({
5045
+ test: rt.id,
5046
+ assertionSource,
5047
+ predicate: comparison.predicate,
5048
+ expected,
5049
+ originalOutcome: input.value,
5050
+ });
5051
+ }
5052
+ return {
5053
+ model: "js-primitive-decision-v1",
5054
+ source: comparisonLocation(branch),
5055
+ whenTrue,
5056
+ whenFalse,
5057
+ checks,
5058
+ };
5059
+ }
3886
5060
  const factSites = sites.map((s) => {
3887
5061
  const { bounds, reached } = allBoundaries(s);
3888
5062
  const tTrue = testsWithOutcome(s, true);
@@ -3921,6 +5095,7 @@ export function analyzeWithFrontend(options, frontend) {
3921
5095
  ? {
3922
5096
  decision: {
3923
5097
  ...(decisionFacts.get(s.id) ?? {}),
5098
+ primitive: primitiveDecision(s),
3924
5099
  outcomes: tTrue && tFalse
3925
5100
  ? { true: [...tTrue], false: [...tFalse] }
3926
5101
  : undefined,
@@ -3931,19 +5106,139 @@ export function analyzeWithFrontend(options, frontend) {
3931
5106
  ...(derive.length ? { derive } : {}),
3932
5107
  };
3933
5108
  });
5109
+ const pragmas = pragmaCollector.finish(runtimeTests.flatMap((rt) => {
5110
+ const st = staticFor(rt);
5111
+ return st
5112
+ ? [
5113
+ {
5114
+ testKey: staticTestKey(st.file, st.line, st.name),
5115
+ id: rt.id,
5116
+ phases: runtimePhases.get(rt.id),
5117
+ },
5118
+ ]
5119
+ : [];
5120
+ }));
5121
+ for (const hint of pragmas) {
5122
+ if (!hint.check ||
5123
+ hint.issue ||
5124
+ hint.witness !== "passed" ||
5125
+ hint.candidateSites.length !== 1)
5126
+ continue;
5127
+ const rt = runtimeTests.find((t) => t.id === hint.test);
5128
+ const st = rt && staticFor(rt), body = st && mockBodies.get(st);
5129
+ const target = siteNodes.get(hint.candidateSites[0]);
5130
+ if (hint.check === "count" ||
5131
+ hint.check === "value" ||
5132
+ hint.check === "completion") {
5133
+ const limit = (reason) => {
5134
+ if (hint.check === "completion")
5135
+ hint.completionSensitivity = {
5136
+ model: "node-first-test-completion-v1",
5137
+ status: "unresolved",
5138
+ reason,
5139
+ };
5140
+ else if (hint.check === "value")
5141
+ hint.payloadSensitivity = {
5142
+ model: "node-closed-payload-sensitivity-v2",
5143
+ status: "unresolved",
5144
+ reason,
5145
+ };
5146
+ else
5147
+ hint.countSensitivity = {
5148
+ model: "node-closed-count-sensitivity-v1",
5149
+ status: "unresolved",
5150
+ reason,
5151
+ };
5152
+ };
5153
+ limit("sensitivity-source-or-runtime-unavailable");
5154
+ if (!rt ||
5155
+ rt.runner !== "node:test" ||
5156
+ !body ||
5157
+ !target ||
5158
+ (runtimeRowTitles.get(JSON.stringify([rt.file, rt.title])) ?? 0) !== 1)
5159
+ continue;
5160
+ const location = (n) => {
5161
+ const sf = n.getSourceFile(), p = sf.getLineAndCharacterOfPosition(n.getStart(sf));
5162
+ return `${rel(sf)}:${p.line + 1}:${p.character + 1}`;
5163
+ };
5164
+ let assertion;
5165
+ const find = (n) => {
5166
+ if (ts.isCallExpression(n) && location(n) === hint.assertionSource)
5167
+ assertion = n;
5168
+ ts.forEachChild(n, find);
5169
+ };
5170
+ find(body);
5171
+ if (!assertion)
5172
+ continue;
5173
+ if (hint.check === "completion") {
5174
+ hint.completionSensitivity = analyzeCompletionSensitivity(ts, body, assertion, target, { ...mockModel, location });
5175
+ continue;
5176
+ }
5177
+ const plan = sourceTestRows(ts, body, mockModel), row = plan?.rows.find((r) => r.evidence.title === rt.title);
5178
+ if (plan && (!row || plan.reason)) {
5179
+ limit(plan.reason ?? "sensitivity-runtime-row-unavailable");
5180
+ continue;
5181
+ }
5182
+ if (hint.check === "value") {
5183
+ hint.payloadSensitivity = analyzePayloadSensitivity(ts, body, assertion, target, { ...mockModel, location }, row);
5184
+ if (hint.payloadSensitivity.status !== "source-checked" && !row) {
5185
+ hint.directReturnSensitivity = analyzeDirectReturnSensitivity(ts, body, assertion, target, { ...mockModel, location });
5186
+ if (hint.directReturnSensitivity.status === "source-checked")
5187
+ delete hint.payloadSensitivity;
5188
+ }
5189
+ }
5190
+ else
5191
+ hint.countSensitivity = analyzeCountSensitivity(ts, body, assertion, target, { ...mockModel, location }, row);
5192
+ continue;
5193
+ }
5194
+ hint.callOmission = {
5195
+ model: "node-first-test-call-omission-v1",
5196
+ status: "unresolved",
5197
+ reason: "omission-source-or-runtime-unavailable",
5198
+ };
5199
+ if (!rt || rt.runner !== "node:test" || !body) {
5200
+ hint.callOmission.reason = !rt
5201
+ ? "omission-runtime-test-unavailable"
5202
+ : rt.runner !== "node:test"
5203
+ ? "omission-unsupported-runner"
5204
+ : "omission-static-body-unavailable";
5205
+ continue;
5206
+ }
5207
+ if (!target || !ts.isCallExpression(target)) {
5208
+ hint.callOmission.reason = "omission-target-not-call-expression";
5209
+ continue;
5210
+ }
5211
+ if ((runtimeRowTitles.get(JSON.stringify([rt.file, rt.title])) ?? 0) !== 1) {
5212
+ hint.callOmission.reason = "omission-ambiguous-runtime-title";
5213
+ continue;
5214
+ }
5215
+ let assertion;
5216
+ const originalLocation = (n) => {
5217
+ const sf = n.getSourceFile(), p = sf.getLineAndCharacterOfPosition(n.getStart(sf));
5218
+ return `${rel(sf)}:${p.line + 1}:${p.character + 1}`;
5219
+ };
5220
+ const visit = (n) => {
5221
+ if (ts.isCallExpression(n) &&
5222
+ originalLocation(n) === hint.assertionSource)
5223
+ assertion = n;
5224
+ ts.forEachChild(n, visit);
5225
+ };
5226
+ visit(body);
5227
+ if (!assertion) {
5228
+ hint.callOmission.reason = "omission-assertion-source-unavailable";
5229
+ continue;
5230
+ }
5231
+ const plan = sourceTestRows(ts, body, mockModel);
5232
+ const row = plan?.rows.find((r) => r.evidence.title === rt.title);
5233
+ if (plan && (!row || plan.reason)) {
5234
+ hint.callOmission.reason =
5235
+ plan.reason ?? "omission-runtime-row-unavailable";
5236
+ continue;
5237
+ }
5238
+ hint.callOmission = analyzeFirstTestOmission(ts, body, assertion, target, { ...mockModel, location: originalLocation }, row);
5239
+ }
3934
5240
  return {
3935
- pragmas: pragmaCollector.finish(runtimeTests.flatMap((rt) => {
3936
- const st = staticFor(rt);
3937
- return st
3938
- ? [
3939
- {
3940
- testKey: staticTestKey(st.file, st.line, st.name),
3941
- id: rt.id,
3942
- phases: runtimePhases.get(rt.id),
3943
- },
3944
- ]
3945
- : [];
3946
- })),
5241
+ pragmas,
3947
5242
  facts: {
3948
5243
  schema: 1,
3949
5244
  root,
@@ -3955,7 +5250,13 @@ export function analyzeWithFrontend(options, frontend) {
3955
5250
  suppressedObservations,
3956
5251
  observationPolicy: "source-linked-v3: exact successful call witness; rejected witnesses retain typed provenance, not value credit",
3957
5252
  runtimeTests: runtimeTests.length,
3958
- linkedTests: factTests.length,
5253
+ linkedTests: runtimeTests.length - unlinkedTests.length,
5254
+ unlinkedTests,
5255
+ witnessedBodyLinks: [...witnessedBodyLinks].map(([id, link]) => ({
5256
+ id,
5257
+ ...link,
5258
+ reason: "test-registration-scope-unverified",
5259
+ })),
3959
5260
  staticTests: staticTests.length,
3960
5261
  linkedByAssertionLines: linkedByPhases,
3961
5262
  linkedByTitle,