candor-ts 0.5.19 → 0.5.21

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 +132 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.5.19",
3
+ "version": "0.5.21",
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,63 @@ 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
+ // Does iterating `expr` FORCE an OPAQUE (caller-supplied) iterable? Forcing an iterable runs its
966
+ // `[Symbol.iterator]`/`next` body, which — when the iterable is a PARAMETER / `any` / a bare type-
967
+ // parameter — is caller-chosen code that can perform arbitrary I/O. This is epistemically identical to
968
+ // invoking an opaque callback parameter (the `call:param` → Unknown posture, scan.mjs ~1145-1158): a
969
+ // silent-pure verdict would be the cardinal sin. Mirror that decision exactly. Returns a `why` string
970
+ // (→ record Unknown) or null. By design this fires ONLY for genuinely caller-supplied iterables; a
971
+ // CONCRETE built-in (array/string/Map/Set: the value resolves to a concrete type, not a param) stays
972
+ // PURE, and a LOCAL iterable/generator (a local call result, a local class instance) is handled by the
973
+ // existing local-edge path (iterationTargets / the call machinery) — neither is flagged here.
974
+ // Opaque iterable INTERFACE names: a value whose TYPE is literally one of these is caller-supplied
975
+ // iterator code (its `next` body is unknowable). A CONCRETE built-in (Array/Set/Map/String) has its own
976
+ // symbol (`Array`/…) and runs a built-in iterator (no user code) → NOT opaque; a LOCAL class implementing
977
+ // Iterable is handled by the local-edge path. So we key on the type's SYMBOL NAME, NOT on "is a parameter"
978
+ // (the earlier param-identity check fabricated Unknown when iterating a concrete-typed array PARAM — the
979
+ // conformance `loop_elem` regression: `(items: T[]) => { for (const c of items) … }`).
980
+ const OPAQUE_ITERABLE_TYPES = new Set([
981
+ "Iterable", "Iterator", "IterableIterator", "Generator",
982
+ "AsyncIterable", "AsyncIterator", "AsyncIterableIterator", "AsyncGenerator",
983
+ ]);
984
+ function opaqueIterableWhy(expr) {
985
+ const t = checker.getTypeAtLocation(expr);
986
+ if (!t) return null;
987
+ // (a) `any` or a bare TYPE PARAMETER (`<T extends Iterable<…>>(x: T)`): the concrete iterable is
988
+ // indeterminate, so its iterator body is unknowable — never silently pure.
989
+ if (t.flags & ts.TypeFlags.Any) return "iterate:any";
990
+ if (t.flags & ts.TypeFlags.TypeParameter) return "iterate:typeparam";
991
+ // (b) the type IS an opaque iterable INTERFACE *and* the value is CALLER-SUPPLIED (a parameter / binding
992
+ // element): `collect(source: Iterable<T>)`, `nexts(it: Iterator<T>)`, `drain(g: Generator<T>)`. The
993
+ // caller chooses the concrete iterator (arbitrary I/O), identical to invoking an opaque callback. BOTH
994
+ // conditions are required: a LOCAL generator/iterable CALL result (`for (const x of gen())`) also has a
995
+ // Generator/IterableIterator type but is NOT a param → excluded, so the local-edge path edges its real
996
+ // effect (no spurious Unknown); a concrete Array/Set/Map/String PARAM has its own symbol (not in the
997
+ // set) → excluded → PURE (built-in iterator, no user code; the conformance `loop_elem` case).
998
+ const sym = t.getSymbol && t.getSymbol();
999
+ if (sym && OPAQUE_ITERABLE_TYPES.has(sym.name) && ts.isIdentifier(expr)) {
1000
+ const d = realDecl(checker.getSymbolAtLocation(expr));
1001
+ if (d && (ts.isParameter(d) || ts.isBindingElement(d))) {
1002
+ const idx = ts.isParameter(d) && d.parent ? d.parent.parameters.indexOf(d) : -1;
1003
+ return idx >= 0 ? `iterate:param#${idx}` : "iterate:param";
1004
+ }
1005
+ }
1006
+ return null;
1007
+ }
1008
+ // Record an opaque-iterable force as Unknown on the enclosing fn, UNLESS iteration already resolved a
1009
+ // LOCAL desugar target (then the real effect is edged — no Unknown needed). `localResolved` = the
1010
+ // non-empty result of iterationTargets (a local `[Symbol.iterator]`/`next` unit was found).
1011
+ function noteOpaqueIteration(node, iterExpr, localResolved) {
1012
+ if (localResolved) return;
1013
+ const owner = enclosing(node);
1014
+ if (!owner) return;
1015
+ const why = opaqueIterableWhy(iterExpr);
1016
+ if (!why) return;
1017
+ const rec = fns.get(owner);
1018
+ if (!rec) return;
1019
+ rec.direct.add("Unknown");
1020
+ rec.why.add(why);
1021
+ }
937
1022
 
938
1023
  // Callee names that INVOKE a function/method argument (so a fn-reference passed to one is reachable
939
1024
  // through it). Array/iterable HOFs, the timer/microtask schedulers, and Promise continuations. A
@@ -1028,6 +1113,23 @@ function visitCalls(node) {
1028
1113
  : ts.isIdentifier(node.expression) ? node.expression.text : null;
1029
1114
  if (mod !== "<local>" && calleeName && HOF_INVOKERS.has(calleeName)) {
1030
1115
  for (const a of node.arguments ?? []) {
1116
+ // A `<ref>.bind(…)` partial-application is a CallExpression (skipped by the id/property-access
1117
+ // gate below) but the INVOKING HOF calls the bound fn → its effects are reachable. Unwrap the
1118
+ // `.bind` chain to the root receiver and resolve it like a bare ref (`resolveFnRefUnit` follows
1119
+ // local aliases too). A `.bind` whose receiver can't be pinned to a fn unit (`getCallback().bind`,
1120
+ // a param/`any` holder) still INVOKES whatever it wraps — disclose Unknown, never silent-pure.
1121
+ const bound = unwrapBind(a);
1122
+ if (bound) {
1123
+ const bref = bound.ref;
1124
+ const d3 = bref && realDecl(checker.getSymbolAtLocation(bref));
1125
+ const tb = (d3 && nodeName.get(d3)) || (bref && resolveFnRefUnit(bref));
1126
+ if (tb) rec.edges.add(tb);
1127
+ else {
1128
+ rec.direct.add("Unknown");
1129
+ rec.why.add(`bind:${(bref ?? a).getText().replace(/\s+/g, "").slice(0, 40)}`);
1130
+ }
1131
+ continue;
1132
+ }
1031
1133
  if (!ts.isIdentifier(a) && !ts.isPropertyAccessExpression(a)) continue;
1032
1134
  const d2 = realDecl(checker.getSymbolAtLocation(a));
1033
1135
  const t = d2 && nodeName.get(d2);
@@ -1068,6 +1170,28 @@ function visitCalls(node) {
1068
1170
  rec.why.add(`call:${recvText.slice(0, 40)}.${m}`);
1069
1171
  }
1070
1172
  }
1173
+ // EXPLICIT iterator force: `it.next()` / `it.return()` / `it.throw()` on an OPAQUE iterator
1174
+ // (a parameter / `any` / type-parameter typed as the `Iterator`/`Generator` protocol) runs
1175
+ // caller-supplied iterator code — epistemically identical to forcing a for-of over an opaque
1176
+ // iterable, and to invoking an opaque callback. The method resolves to the non-local es-lib
1177
+ // `Iterator.next` signature, so the desugar above never sees it and the call lands here pure.
1178
+ // Disclose Unknown (cardinal-sin guard). Gated on the iterator-protocol type symbol so an
1179
+ // unrelated `.next()` on some other opaque param is not flagged; a LOCAL iterator's `next`
1180
+ // resolves `<local>` (edged below), never reaching this non-local arm.
1181
+ if ((m === "next" || m === "return" || m === "throw")) {
1182
+ const why = opaqueIterableWhy(recv);
1183
+ const rt = checker.getTypeAtLocation(recv);
1184
+ const sn = rt?.getSymbol?.()?.getName?.()
1185
+ ?? (rt?.flags & ts.TypeFlags.TypeParameter ? checker.getBaseConstraintOfType(rt)?.getSymbol?.()?.getName?.() : undefined);
1186
+ const ITER_PROTO = new Set([
1187
+ "Iterator", "AsyncIterator", "Iterable", "AsyncIterable",
1188
+ "IterableIterator", "AsyncIterableIterator", "Generator", "AsyncGenerator",
1189
+ ]);
1190
+ if (why && sn && ITER_PROTO.has(sn)) {
1191
+ rec.direct.add("Unknown");
1192
+ rec.why.add(why); // `iterate:param#i` / `iterate:any` / `iterate:typeparam`
1193
+ }
1194
+ }
1071
1195
  }
1072
1196
  if (mod === "<local>") {
1073
1197
  const targetName = nodeName.get(decl);
@@ -1557,7 +1681,14 @@ function visitCalls(node) {
1557
1681
  iterExpr = node.arguments[0]; // Array.from(bag) — the iterable form (arg0 is iterated)
1558
1682
  if (iterExpr) {
1559
1683
  const owner = enclosing(node);
1560
- if (owner) edgeToTargets(fns.get(owner), iterationTargets(iterExpr, iterAsync));
1684
+ if (owner) {
1685
+ const targets = iterationTargets(iterExpr, iterAsync);
1686
+ edgeToTargets(fns.get(owner), targets);
1687
+ // Opaque-iterable honesty: a param/`any`/type-parameter iterable runs caller-supplied iterator
1688
+ // code — disclose Unknown, mirroring the opaque-callback `call:param` posture (cardinal-sin
1689
+ // guard). Skipped when iteration already resolved a LOCAL unit (real effect already edged).
1690
+ noteOpaqueIteration(node, iterExpr, targets.length > 0);
1691
+ }
1561
1692
  }
1562
1693
  }
1563
1694
  // `using r = expr` / `await using r = expr` (MED): the scope-exit guarantees `r[Symbol.dispose]()` /