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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/scan.mjs +176 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.5.20",
3
+ "version": "0.5.22",
4
4
  "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.5)",
5
5
  "type": "module",
6
6
  "dependencies": {
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