candor-ts 0.5.19 → 0.5.20

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 +87 -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.20",
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
@@ -934,6 +934,63 @@ function iterationTargets(expr, isAsync) {
934
934
  function edgeToTargets(rec, decls) {
935
935
  for (const d of decls) { const t = nodeName.get(d); if (t) rec.edges.add(t); }
936
936
  }
937
+ // Does iterating `expr` FORCE an OPAQUE (caller-supplied) iterable? Forcing an iterable runs its
938
+ // `[Symbol.iterator]`/`next` body, which — when the iterable is a PARAMETER / `any` / a bare type-
939
+ // parameter — is caller-chosen code that can perform arbitrary I/O. This is epistemically identical to
940
+ // invoking an opaque callback parameter (the `call:param` → Unknown posture, scan.mjs ~1145-1158): a
941
+ // silent-pure verdict would be the cardinal sin. Mirror that decision exactly. Returns a `why` string
942
+ // (→ record Unknown) or null. By design this fires ONLY for genuinely caller-supplied iterables; a
943
+ // CONCRETE built-in (array/string/Map/Set: the value resolves to a concrete type, not a param) stays
944
+ // PURE, and a LOCAL iterable/generator (a local call result, a local class instance) is handled by the
945
+ // existing local-edge path (iterationTargets / the call machinery) — neither is flagged here.
946
+ // Opaque iterable INTERFACE names: a value whose TYPE is literally one of these is caller-supplied
947
+ // iterator code (its `next` body is unknowable). A CONCRETE built-in (Array/Set/Map/String) has its own
948
+ // symbol (`Array`/…) and runs a built-in iterator (no user code) → NOT opaque; a LOCAL class implementing
949
+ // Iterable is handled by the local-edge path. So we key on the type's SYMBOL NAME, NOT on "is a parameter"
950
+ // (the earlier param-identity check fabricated Unknown when iterating a concrete-typed array PARAM — the
951
+ // conformance `loop_elem` regression: `(items: T[]) => { for (const c of items) … }`).
952
+ const OPAQUE_ITERABLE_TYPES = new Set([
953
+ "Iterable", "Iterator", "IterableIterator", "Generator",
954
+ "AsyncIterable", "AsyncIterator", "AsyncIterableIterator", "AsyncGenerator",
955
+ ]);
956
+ function opaqueIterableWhy(expr) {
957
+ const t = checker.getTypeAtLocation(expr);
958
+ if (!t) return null;
959
+ // (a) `any` or a bare TYPE PARAMETER (`<T extends Iterable<…>>(x: T)`): the concrete iterable is
960
+ // indeterminate, so its iterator body is unknowable — never silently pure.
961
+ if (t.flags & ts.TypeFlags.Any) return "iterate:any";
962
+ if (t.flags & ts.TypeFlags.TypeParameter) return "iterate:typeparam";
963
+ // (b) the type IS an opaque iterable INTERFACE *and* the value is CALLER-SUPPLIED (a parameter / binding
964
+ // element): `collect(source: Iterable<T>)`, `nexts(it: Iterator<T>)`, `drain(g: Generator<T>)`. The
965
+ // caller chooses the concrete iterator (arbitrary I/O), identical to invoking an opaque callback. BOTH
966
+ // conditions are required: a LOCAL generator/iterable CALL result (`for (const x of gen())`) also has a
967
+ // Generator/IterableIterator type but is NOT a param → excluded, so the local-edge path edges its real
968
+ // effect (no spurious Unknown); a concrete Array/Set/Map/String PARAM has its own symbol (not in the
969
+ // set) → excluded → PURE (built-in iterator, no user code; the conformance `loop_elem` case).
970
+ const sym = t.getSymbol && t.getSymbol();
971
+ if (sym && OPAQUE_ITERABLE_TYPES.has(sym.name) && ts.isIdentifier(expr)) {
972
+ const d = realDecl(checker.getSymbolAtLocation(expr));
973
+ if (d && (ts.isParameter(d) || ts.isBindingElement(d))) {
974
+ const idx = ts.isParameter(d) && d.parent ? d.parent.parameters.indexOf(d) : -1;
975
+ return idx >= 0 ? `iterate:param#${idx}` : "iterate:param";
976
+ }
977
+ }
978
+ return null;
979
+ }
980
+ // Record an opaque-iterable force as Unknown on the enclosing fn, UNLESS iteration already resolved a
981
+ // LOCAL desugar target (then the real effect is edged — no Unknown needed). `localResolved` = the
982
+ // non-empty result of iterationTargets (a local `[Symbol.iterator]`/`next` unit was found).
983
+ function noteOpaqueIteration(node, iterExpr, localResolved) {
984
+ if (localResolved) return;
985
+ const owner = enclosing(node);
986
+ if (!owner) return;
987
+ const why = opaqueIterableWhy(iterExpr);
988
+ if (!why) return;
989
+ const rec = fns.get(owner);
990
+ if (!rec) return;
991
+ rec.direct.add("Unknown");
992
+ rec.why.add(why);
993
+ }
937
994
 
938
995
  // Callee names that INVOKE a function/method argument (so a fn-reference passed to one is reachable
939
996
  // through it). Array/iterable HOFs, the timer/microtask schedulers, and Promise continuations. A
@@ -1068,6 +1125,28 @@ function visitCalls(node) {
1068
1125
  rec.why.add(`call:${recvText.slice(0, 40)}.${m}`);
1069
1126
  }
1070
1127
  }
1128
+ // EXPLICIT iterator force: `it.next()` / `it.return()` / `it.throw()` on an OPAQUE iterator
1129
+ // (a parameter / `any` / type-parameter typed as the `Iterator`/`Generator` protocol) runs
1130
+ // caller-supplied iterator code — epistemically identical to forcing a for-of over an opaque
1131
+ // iterable, and to invoking an opaque callback. The method resolves to the non-local es-lib
1132
+ // `Iterator.next` signature, so the desugar above never sees it and the call lands here pure.
1133
+ // Disclose Unknown (cardinal-sin guard). Gated on the iterator-protocol type symbol so an
1134
+ // unrelated `.next()` on some other opaque param is not flagged; a LOCAL iterator's `next`
1135
+ // resolves `<local>` (edged below), never reaching this non-local arm.
1136
+ if ((m === "next" || m === "return" || m === "throw")) {
1137
+ const why = opaqueIterableWhy(recv);
1138
+ const rt = checker.getTypeAtLocation(recv);
1139
+ const sn = rt?.getSymbol?.()?.getName?.()
1140
+ ?? (rt?.flags & ts.TypeFlags.TypeParameter ? checker.getBaseConstraintOfType(rt)?.getSymbol?.()?.getName?.() : undefined);
1141
+ const ITER_PROTO = new Set([
1142
+ "Iterator", "AsyncIterator", "Iterable", "AsyncIterable",
1143
+ "IterableIterator", "AsyncIterableIterator", "Generator", "AsyncGenerator",
1144
+ ]);
1145
+ if (why && sn && ITER_PROTO.has(sn)) {
1146
+ rec.direct.add("Unknown");
1147
+ rec.why.add(why); // `iterate:param#i` / `iterate:any` / `iterate:typeparam`
1148
+ }
1149
+ }
1071
1150
  }
1072
1151
  if (mod === "<local>") {
1073
1152
  const targetName = nodeName.get(decl);
@@ -1557,7 +1636,14 @@ function visitCalls(node) {
1557
1636
  iterExpr = node.arguments[0]; // Array.from(bag) — the iterable form (arg0 is iterated)
1558
1637
  if (iterExpr) {
1559
1638
  const owner = enclosing(node);
1560
- if (owner) edgeToTargets(fns.get(owner), iterationTargets(iterExpr, iterAsync));
1639
+ if (owner) {
1640
+ const targets = iterationTargets(iterExpr, iterAsync);
1641
+ edgeToTargets(fns.get(owner), targets);
1642
+ // Opaque-iterable honesty: a param/`any`/type-parameter iterable runs caller-supplied iterator
1643
+ // code — disclose Unknown, mirroring the opaque-callback `call:param` posture (cardinal-sin
1644
+ // guard). Skipped when iteration already resolved a LOCAL unit (real effect already edged).
1645
+ noteOpaqueIteration(node, iterExpr, targets.length > 0);
1646
+ }
1561
1647
  }
1562
1648
  }
1563
1649
  // `using r = expr` / `await using r = expr` (MED): the scope-exit guarantees `r[Symbol.dispose]()` /