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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
2596
|
-
|
|
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
|
-
//
|
|
2692
|
-
|
|
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
|
-
*
|
|
3170
|
-
*
|
|
3171
|
-
*
|
|
3172
|
-
*
|
|
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 =
|
|
3180
|
-
if (!
|
|
4094
|
+
const st = staticFor(rt);
|
|
4095
|
+
if (!st)
|
|
3181
4096
|
continue;
|
|
3182
|
-
|
|
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
|
|
3838
|
-
|
|
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
|
|
3842
|
-
|
|
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
|
|
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:
|
|
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,
|