candor-ts 0.5.21 → 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 +131 -0
package/package.json
CHANGED
package/scan.mjs
CHANGED
|
@@ -962,6 +962,71 @@ function iterationTargets(expr, isAsync) {
|
|
|
962
962
|
function edgeToTargets(rec, decls) {
|
|
963
963
|
for (const d of decls) { const t = nodeName.get(d); if (t) rec.edges.add(t); }
|
|
964
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
|
+
}
|
|
965
1030
|
// Does iterating `expr` FORCE an OPAQUE (caller-supplied) iterable? Forcing an iterable runs its
|
|
966
1031
|
// `[Symbol.iterator]`/`next` body, which — when the iterable is a PARAMETER / `any` / a bare type-
|
|
967
1032
|
// parameter — is caller-chosen code that can perform arbitrary I/O. This is epistemically identical to
|
|
@@ -1708,6 +1773,72 @@ function visitCalls(node) {
|
|
|
1708
1773
|
}
|
|
1709
1774
|
}
|
|
1710
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
|
+
}
|
|
1711
1842
|
// TAGGED TEMPLATE (LOW): `` tag`…` `` calls `tag(strings, ...subs)`. getResolvedSignature resolves
|
|
1712
1843
|
// the TaggedTemplateExpression to the tag fn cleanly — a node form the CallExpression walk never
|
|
1713
1844
|
// visits. Edge to the tag when LOCAL; a built-in/external tag (`String.raw`) resolves non-local and
|