candor-ts 0.5.20 → 0.5.22
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/package.json +1 -1
- package/scan.mjs +176 -0
package/package.json
CHANGED
package/scan.mjs
CHANGED
|
@@ -759,6 +759,34 @@ function resolveFnRefUnit(refNode, depth = 0) {
|
|
|
759
759
|
return null;
|
|
760
760
|
}
|
|
761
761
|
|
|
762
|
+
// Unwrap a `<ref>.bind(…)` partial-application chain to the underlying function-reference RECEIVER.
|
|
763
|
+
// `setTimeout(this.flush.bind(this), 0)` / `effFs.bind(null)` / `cb.bind(null,a).bind(null,b)` schedule the
|
|
764
|
+
// BOUND function, but the argument node is a CallExpression (callee = PropertyAccessExpression `.bind`), so
|
|
765
|
+
// the HOF-ref arm — which only edges identifier / property-access args — dropped it (silent-pure: the
|
|
766
|
+
// cardinal sin). `.bind` is the third reflective-invoke member alongside `.call`/`.apply`. Given an arg
|
|
767
|
+
// node, returns:
|
|
768
|
+
// { ref } — it IS a `.bind` chain and the root receiver is a resolvable id/property-access ref
|
|
769
|
+
// (recursing through chained `.bind().bind()`); the caller resolves it to its fn unit.
|
|
770
|
+
// { ref:null }— it IS a `.bind` chain but the root receiver is NOT a plain ref (`getCallback().bind(null)`,
|
|
771
|
+
// a parenthesized/`any` holder) — still INVOKED by the HOF, so the caller discloses Unknown.
|
|
772
|
+
// null — not a `.bind` call at all; the caller's id/property-access path handles it.
|
|
773
|
+
// A `.bind` on a PURE fn resolves to a pure unit (no fabrication); the bind-unresolvable case never goes
|
|
774
|
+
// silent-pure.
|
|
775
|
+
function unwrapBind(node, depth = 0) {
|
|
776
|
+
if (!node || depth > 8) return null;
|
|
777
|
+
if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return null;
|
|
778
|
+
if (node.expression.name.text !== "bind") return null;
|
|
779
|
+
let recv = node.expression.expression;
|
|
780
|
+
while (ts.isParenthesizedExpression(recv)) recv = recv.expression;
|
|
781
|
+
// chained `.bind().bind()` — recurse only when the receiver is ITSELF a `.bind` call, else it's an
|
|
782
|
+
// arbitrary call (`getCallback().bind`) whose result we can't pin → unresolvable bind.
|
|
783
|
+
if (ts.isCallExpression(recv)) {
|
|
784
|
+
const inner = unwrapBind(recv, depth + 1);
|
|
785
|
+
return inner ?? { ref: null };
|
|
786
|
+
}
|
|
787
|
+
return { ref: (ts.isIdentifier(recv) || ts.isPropertyAccessExpression(recv)) ? recv : null };
|
|
788
|
+
}
|
|
789
|
+
|
|
762
790
|
// Accessor resolution (the silent-pure-accessor fix): a property READ (`x.raw`) or property
|
|
763
791
|
// ASSIGNMENT target (`x.path = v`) may resolve to a getter/setter whose body performs effects. We
|
|
764
792
|
// resolve the property-name symbol to its declarations and look for an accessor of the matching
|
|
@@ -934,6 +962,71 @@ function iterationTargets(expr, isAsync) {
|
|
|
934
962
|
function edgeToTargets(rec, decls) {
|
|
935
963
|
for (const d of decls) { const t = nodeName.get(d); if (t) rec.edges.add(t); }
|
|
936
964
|
}
|
|
965
|
+
|
|
966
|
+
// ---- implicit VALUE-COERCION desugaring (the silent-pure holes where the JS coercion protocol calls a
|
|
967
|
+
// user method the AST walk never visits) ----------------------------------------------------------
|
|
968
|
+
// JS coerces an object to a primitive by INVOKING a method on it: `a + b`/`` `${x}` ``/`String(x)` call
|
|
969
|
+
// `toString` (or `[Symbol.toPrimitive]`); `x + 1`/`-x`/`+x`/relational call `valueOf` (or
|
|
970
|
+
// `[Symbol.toPrimitive]`); `JSON.stringify(x)` calls `toJSON`. None of these surface as a
|
|
971
|
+
// CallExpression on the user method, so an effectful `toString`/`valueOf`/`toJSON`/`[Symbol.toPrimitive]`
|
|
972
|
+
// reached this way read SILENT-PURE (the cardinal sin). We model the desugar EXACTLY as the spec demands:
|
|
973
|
+
// resolve the operand's type's coercion member via the checker and edge to it ONLY when it is a LOCAL
|
|
974
|
+
// unit. A built-in/external member (lib.es `Object.prototype.toString`, a stdlib `toJSON`) resolves
|
|
975
|
+
// non-local → no edge → stays pure (the precision invariant); a PURE local member edges to a pure unit
|
|
976
|
+
// (contributes nothing). NEVER a fabricated edge: a non-object operand, or a type with no such member,
|
|
977
|
+
// resolves to nothing.
|
|
978
|
+
|
|
979
|
+
// Resolve coercion members of `expr`'s type to their LOCAL decls. `names` is the ordered set of plain
|
|
980
|
+
// member names to try; `withPrimitive` also consults the well-known `[Symbol.toPrimitive]` (which JS
|
|
981
|
+
// prefers over toString/valueOf when present). A union operand is widened to its constituents so a
|
|
982
|
+
// `A | B` value edges to whichever side declares a LOCAL coercion member. Returns LOCAL member decls.
|
|
983
|
+
function coercionTargets(expr, names, withPrimitive) {
|
|
984
|
+
const t = checker.getTypeAtLocation(expr);
|
|
985
|
+
if (!t) return [];
|
|
986
|
+
// Widen unions/intersections so each branch's coercion member is considered (a `Foo | string` operand
|
|
987
|
+
// can be a Foo at runtime → its local toString runs). A primitive/literal constituent has no LOCAL
|
|
988
|
+
// member and contributes nothing.
|
|
989
|
+
const parts = t.isUnionOrIntersection?.() ? t.types : [t];
|
|
990
|
+
const out = [];
|
|
991
|
+
for (const part of parts) {
|
|
992
|
+
if (!part || !part.getProperty) continue;
|
|
993
|
+
if (withPrimitive) {
|
|
994
|
+
const pd = declOfSym(wellKnownSymbolMember(part, ["__@toPrimitive"]));
|
|
995
|
+
if (pd && declIsLocal(pd) && !out.includes(pd)) out.push(pd);
|
|
996
|
+
}
|
|
997
|
+
for (const n of names) {
|
|
998
|
+
const md = declOfSym(part.getProperty(n));
|
|
999
|
+
// A METHOD (or function-valued property) member — not a getter/data field of an unrelated shape.
|
|
1000
|
+
if (md && declIsLocal(md)
|
|
1001
|
+
&& (ts.isMethodDeclaration(md) || ts.isMethodSignature(md)
|
|
1002
|
+
|| ts.isPropertyDeclaration(md) || ts.isPropertyAssignment(md)
|
|
1003
|
+
|| ts.isFunctionDeclaration(md) || ts.isFunctionExpression(md) || ts.isArrowFunction(md))
|
|
1004
|
+
&& !out.includes(md))
|
|
1005
|
+
out.push(md);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
return out;
|
|
1009
|
+
}
|
|
1010
|
+
// True when `expr`'s type is an OBJECT type that could carry a coercion method (so `a + b` may trigger
|
|
1011
|
+
// one). A pure primitive operand (string/number/boolean/bigint/null/undefined) never invokes
|
|
1012
|
+
// toString/valueOf in `+` (string+string concatenates, number+number adds — no method call), so we skip
|
|
1013
|
+
// it: edging there would be at best inert and the type-narrowing keeps `coercionTargets` from widening a
|
|
1014
|
+
// huge `string | Foo` into spurious work. An object/union-containing-object is a candidate.
|
|
1015
|
+
function mayCoerceObject(expr) {
|
|
1016
|
+
const t = checker.getTypeAtLocation(expr);
|
|
1017
|
+
if (!t) return false;
|
|
1018
|
+
const parts = t.isUnionOrIntersection?.() ? t.types : [t];
|
|
1019
|
+
const PRIM = ts.TypeFlags.StringLike | ts.TypeFlags.NumberLike | ts.TypeFlags.BigIntLike
|
|
1020
|
+
| ts.TypeFlags.BooleanLike | ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.VoidLike
|
|
1021
|
+
| ts.TypeFlags.ESSymbolLike;
|
|
1022
|
+
for (const part of parts) {
|
|
1023
|
+
if (!part) continue;
|
|
1024
|
+
if (part.flags & PRIM) continue; // a pure primitive branch never coerces via a method
|
|
1025
|
+
if (part.flags & ts.TypeFlags.Object) return true;
|
|
1026
|
+
if (part.isUnionOrIntersection?.()) return true; // nested — let coercionTargets sort it out
|
|
1027
|
+
}
|
|
1028
|
+
return false;
|
|
1029
|
+
}
|
|
937
1030
|
// Does iterating `expr` FORCE an OPAQUE (caller-supplied) iterable? Forcing an iterable runs its
|
|
938
1031
|
// `[Symbol.iterator]`/`next` body, which — when the iterable is a PARAMETER / `any` / a bare type-
|
|
939
1032
|
// parameter — is caller-chosen code that can perform arbitrary I/O. This is epistemically identical to
|
|
@@ -1085,6 +1178,23 @@ function visitCalls(node) {
|
|
|
1085
1178
|
: ts.isIdentifier(node.expression) ? node.expression.text : null;
|
|
1086
1179
|
if (mod !== "<local>" && calleeName && HOF_INVOKERS.has(calleeName)) {
|
|
1087
1180
|
for (const a of node.arguments ?? []) {
|
|
1181
|
+
// A `<ref>.bind(…)` partial-application is a CallExpression (skipped by the id/property-access
|
|
1182
|
+
// gate below) but the INVOKING HOF calls the bound fn → its effects are reachable. Unwrap the
|
|
1183
|
+
// `.bind` chain to the root receiver and resolve it like a bare ref (`resolveFnRefUnit` follows
|
|
1184
|
+
// local aliases too). A `.bind` whose receiver can't be pinned to a fn unit (`getCallback().bind`,
|
|
1185
|
+
// a param/`any` holder) still INVOKES whatever it wraps — disclose Unknown, never silent-pure.
|
|
1186
|
+
const bound = unwrapBind(a);
|
|
1187
|
+
if (bound) {
|
|
1188
|
+
const bref = bound.ref;
|
|
1189
|
+
const d3 = bref && realDecl(checker.getSymbolAtLocation(bref));
|
|
1190
|
+
const tb = (d3 && nodeName.get(d3)) || (bref && resolveFnRefUnit(bref));
|
|
1191
|
+
if (tb) rec.edges.add(tb);
|
|
1192
|
+
else {
|
|
1193
|
+
rec.direct.add("Unknown");
|
|
1194
|
+
rec.why.add(`bind:${(bref ?? a).getText().replace(/\s+/g, "").slice(0, 40)}`);
|
|
1195
|
+
}
|
|
1196
|
+
continue;
|
|
1197
|
+
}
|
|
1088
1198
|
if (!ts.isIdentifier(a) && !ts.isPropertyAccessExpression(a)) continue;
|
|
1089
1199
|
const d2 = realDecl(checker.getSymbolAtLocation(a));
|
|
1090
1200
|
const t = d2 && nodeName.get(d2);
|
|
@@ -1663,6 +1773,72 @@ function visitCalls(node) {
|
|
|
1663
1773
|
}
|
|
1664
1774
|
}
|
|
1665
1775
|
}
|
|
1776
|
+
// IMPLICIT VALUE-COERCION desugaring (HIGH): the JS coercion protocol invokes a user method the AST
|
|
1777
|
+
// walk never visits as a CallExpression. Resolve the operand's type's coercion member and edge to it
|
|
1778
|
+
// when LOCAL (a built-in/external member resolves non-local → no edge → stays pure). NEVER fabricate.
|
|
1779
|
+
{
|
|
1780
|
+
const owner = enclosing(node);
|
|
1781
|
+
const recOf = () => owner && fns.get(owner);
|
|
1782
|
+
// 1+2. BINARY operators. `+` with an OBJECT operand triggers toString/valueOf (string+string,
|
|
1783
|
+
// number+number have no coercion method — stay pure, gated by mayCoerceObject). Arithmetic
|
|
1784
|
+
// (`-`/`*`/`/`/`%`/`**`) and relational (`<`/`>`/`<=`/`>=`) coerce to a NUMBER → valueOf (then
|
|
1785
|
+
// toString). `[Symbol.toPrimitive]` is preferred by JS over both — always consulted.
|
|
1786
|
+
if (ts.isBinaryExpression(node) && owner) {
|
|
1787
|
+
const op = node.operatorToken.kind;
|
|
1788
|
+
const K = ts.SyntaxKind;
|
|
1789
|
+
const ARITH = new Set([K.MinusToken, K.AsteriskToken, K.SlashToken, K.PercentToken,
|
|
1790
|
+
K.AsteriskAsteriskToken, K.LessThanToken, K.GreaterThanToken, K.LessThanEqualsToken,
|
|
1791
|
+
K.GreaterThanEqualsToken, K.AmpersandToken, K.BarToken, K.CaretToken,
|
|
1792
|
+
K.LessThanLessThanToken, K.GreaterThanGreaterThanToken, K.GreaterThanGreaterThanGreaterThanToken]);
|
|
1793
|
+
const COMPOUND_ARITH = new Set([K.MinusEqualsToken, K.AsteriskEqualsToken, K.SlashEqualsToken,
|
|
1794
|
+
K.PercentEqualsToken, K.AsteriskAsteriskEqualsToken]);
|
|
1795
|
+
if (op === K.PlusToken || op === K.PlusEqualsToken) {
|
|
1796
|
+
// string concat / `+` arithmetic: an OBJECT operand is coerced via toString OR valueOf (the
|
|
1797
|
+
// order depends on the hint, but EITHER may run — edge to both when local). string+string and
|
|
1798
|
+
// number+number have only primitive operands → mayCoerceObject false → no edge (pure).
|
|
1799
|
+
for (const operand of [node.left, node.right]) {
|
|
1800
|
+
if (mayCoerceObject(operand))
|
|
1801
|
+
edgeToTargets(recOf(), coercionTargets(operand, ["valueOf", "toString"], true));
|
|
1802
|
+
}
|
|
1803
|
+
} else if (ARITH.has(op) || COMPOUND_ARITH.has(op)) {
|
|
1804
|
+
for (const operand of [node.left, node.right]) {
|
|
1805
|
+
if (mayCoerceObject(operand))
|
|
1806
|
+
edgeToTargets(recOf(), coercionTargets(operand, ["valueOf", "toString"], true));
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
// 2. UNARY arithmetic `-x` / `+x` / `~x` coerces the operand to a number → valueOf (then toString /
|
|
1811
|
+
// [Symbol.toPrimitive]). (`!x` is boolean coercion — no method call; excluded.)
|
|
1812
|
+
if (ts.isPrefixUnaryExpression(node) && owner
|
|
1813
|
+
&& (node.operator === ts.SyntaxKind.MinusToken || node.operator === ts.SyntaxKind.PlusToken
|
|
1814
|
+
|| node.operator === ts.SyntaxKind.TildeToken)
|
|
1815
|
+
&& mayCoerceObject(node.operand)) {
|
|
1816
|
+
edgeToTargets(recOf(), coercionTargets(node.operand, ["valueOf", "toString"], true));
|
|
1817
|
+
}
|
|
1818
|
+
// 1. TEMPLATE expression `` `${x}` ``: each interpolated substitution is string-coerced → toString
|
|
1819
|
+
// (then [Symbol.toPrimitive]/valueOf). (A TaggedTemplate is handled separately below — the tag fn
|
|
1820
|
+
// receives the raw substitution values, no per-sub coercion, so we exclude tagged templates here.)
|
|
1821
|
+
if (ts.isTemplateExpression(node) && owner && !ts.isTaggedTemplateExpression(node.parent)) {
|
|
1822
|
+
for (const span of node.templateSpans)
|
|
1823
|
+
if (mayCoerceObject(span.expression))
|
|
1824
|
+
edgeToTargets(recOf(), coercionTargets(span.expression, ["toString", "valueOf"], true));
|
|
1825
|
+
}
|
|
1826
|
+
// 1+4. CALL forms `String(x)` (→ toString) and `JSON.stringify(x)` (→ toJSON). These resolve to the
|
|
1827
|
+
// es-lib `StringConstructor`/`JSON.stringify` signature (not the user method), so the CallExpression
|
|
1828
|
+
// walk above never follows the coercion. Edge to the argument's LOCAL toString / toJSON.
|
|
1829
|
+
if (ts.isCallExpression(node) && owner && node.arguments?.[0]) {
|
|
1830
|
+
const callee = node.expression.getText().replace(/\s+/g, "");
|
|
1831
|
+
const arg0 = node.arguments[0];
|
|
1832
|
+
if (callee === "String" && ts.isIdentifier(node.expression) && mayCoerceObject(arg0))
|
|
1833
|
+
edgeToTargets(recOf(), coercionTargets(arg0, ["toString", "valueOf"], true));
|
|
1834
|
+
// `"" + x` is covered by the binary arm; `String(x)` is the explicit conversion form.
|
|
1835
|
+
else if (callee === "JSON.stringify")
|
|
1836
|
+
// toJSON is consulted regardless of operand shape (JSON.stringify checks for it on any value);
|
|
1837
|
+
// a plain object with no LOCAL toJSON resolves to nothing → pure (no fabrication). NO Symbol-
|
|
1838
|
+
// toPrimitive here — JSON.stringify uses toJSON only, not the primitive-coercion protocol.
|
|
1839
|
+
edgeToTargets(recOf(), coercionTargets(arg0, ["toJSON"], false));
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1666
1842
|
// TAGGED TEMPLATE (LOW): `` tag`…` `` calls `tag(strings, ...subs)`. getResolvedSignature resolves
|
|
1667
1843
|
// the TaggedTemplateExpression to the tag fn cleanly — a node form the CallExpression walk never
|
|
1668
1844
|
// visits. Edge to the tag when LOCAL; a built-in/external tag (`String.raw`) resolves non-local and
|