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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/scan.mjs +131 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.5.21",
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
@@ -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