candor-ts 0.5.2 → 0.5.3

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 +104 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
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
@@ -542,6 +542,60 @@ function rootsAtStdStream(expr) {
542
542
  }
543
543
  }
544
544
 
545
+ // ---- the implicit/desugared-call surface (the silent-pure holes the AST walk misses) -------------
546
+ // CLASSIFIER §1 says resolve, don't pattern-match — but the walk only sees CallExpression/
547
+ // NewExpression (+ accessor access). Effects reached through a DESUGARED call (a `for-of` lowering to
548
+ // `it[Symbol.iterator]().next()`, a `using` to `r[Symbol.dispose]()`, a tagged template to `tag(...)`)
549
+ // were invisible: reported concrete-PURE (omitted), not even Unknown. We model the desugaring exactly
550
+ // as the spec demands — resolve the implicit target via the compiler API and edge to it when LOCAL.
551
+ // A resolved-but-unseen target follows the existing external/κ posture (OPAQUE + ledger), and a
552
+ // BUILT-IN iterator/disposer (es-lib/@types/node — a plain array's iterator, a stdlib disposable)
553
+ // resolves to a non-local declaration and edges nothing, so it correctly stays pure.
554
+
555
+ // The member symbol for a WELL-KNOWN symbol (`Symbol.iterator`, `Symbol.dispose`, …) on a type. The
556
+ // checker mangles these to an escaped name `__@iterator@<globalId>`; match by the `__@<name>@` prefix
557
+ // (the trailing id is the unique Symbol's identity, not part of the name). `prefixes` is tried in
558
+ // order so a sync site prefers the sync method and an async site its async twin (falling back to sync).
559
+ function wellKnownSymbolMember(type, prefixes) {
560
+ if (!type || !type.getProperties) return null;
561
+ for (const p of type.getProperties()) {
562
+ const n = p.getName();
563
+ for (const pre of prefixes) if (n === pre || n.startsWith(pre + "@")) return p;
564
+ }
565
+ return null;
566
+ }
567
+ function declOfSym(sym) { return sym && (sym.valueDeclaration ?? sym.declarations?.[0]); }
568
+ const declIsLocal = (decl) => decl && projectFiles.has(path.resolve(decl.getSourceFile().fileName));
569
+
570
+ // The LOCAL units an ITERATION over `expr` implicitly calls: the iterable's `[Symbol.iterator]` (or
571
+ // `[Symbol.asyncIterator]` for `for await`) method AND the produced iterator's `next()`. The generator
572
+ // case rolls `next`'s body into the iterator-method unit (lexical attribution), and the self-iterator
573
+ // case (`[Symbol.iterator]() { return this }` + a separate effectful `next()`) needs the `next` edge —
574
+ // so we edge to BOTH whenever each is a LOCAL unit. A built-in iterable (plain array/string/Map: the
575
+ // es-lib/@types iterator) resolves non-local → no edge → stays pure (the precision invariant).
576
+ function iterationTargets(expr, isAsync) {
577
+ const t = checker.getTypeAtLocation(expr);
578
+ const iterPrefixes = isAsync ? ["__@asyncIterator", "__@iterator"] : ["__@iterator"];
579
+ const iterDecl = declOfSym(wellKnownSymbolMember(t, iterPrefixes));
580
+ if (!iterDecl) return [];
581
+ const out = [];
582
+ if (declIsLocal(iterDecl)) out.push(iterDecl);
583
+ // the iterator's next(): the return type of the [Symbol.iterator] method
584
+ try {
585
+ const sig = checker.getSignatureFromDeclaration(iterDecl);
586
+ const ret = sig && checker.getReturnTypeOfSignature(sig);
587
+ const nextDecl = declOfSym(ret && ret.getProperties().find((p) => p.getName() === "next"));
588
+ if (nextDecl && declIsLocal(nextDecl) && !out.includes(nextDecl)) out.push(nextDecl);
589
+ } catch { /* unresolved iterator shape — the iterator-method edge already covers the common case */ }
590
+ return out;
591
+ }
592
+ // Edge `rec` to each LOCAL desugared target that is a minted unit. Local-only by design: an external
593
+ // iterable/disposer is OPAQUE (the curated-κ caveat — same as an unmatched external call), never a
594
+ // fabricated edge; the existing call machinery + κ ledger already cover any EXPLICIT calls into it.
595
+ function edgeToTargets(rec, decls) {
596
+ for (const d of decls) { const t = nodeName.get(d); if (t) rec.edges.add(t); }
597
+ }
598
+
545
599
  // ---- pass 2: per call site, the (CLASSIFY)/(EDGE)/(UNKNOWN) resolution of SEMANTICS §4 ------------
546
600
  function visitCalls(node) {
547
601
  if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
@@ -820,6 +874,56 @@ function visitCalls(node) {
820
874
  }
821
875
  }
822
876
  }
877
+ // ITERATION desugaring (HIGH): `for (const x of bag)`, `for await (…)`, `[...bag]`, `const [a]=bag`,
878
+ // `Array.from(bag)` all lower to `bag[Symbol.iterator]().next()`. Edge the enclosing fn to the
879
+ // iterable's local `[Symbol.iterator]`/`[Symbol.asyncIterator]` method (and the produced iterator's
880
+ // local `next`). A built-in iterable (array/string/Map) resolves non-local → no edge → stays pure.
881
+ {
882
+ let iterExpr = null, iterAsync = false;
883
+ if (ts.isForOfStatement(node)) { iterExpr = node.expression; iterAsync = !!node.awaitModifier; }
884
+ else if (ts.isSpreadElement(node)) iterExpr = node.expression; // [...bag] / f(...bag)
885
+ else if (ts.isSpreadAssignment(node)) iterExpr = node.expression; // {...bag} — object spread is NOT
886
+ // iteration (it copies own enumerable props, no [Symbol.iterator]); wellKnownSymbolMember simply
887
+ // finds none and edges nothing. Listed for clarity; the resolution self-guards.
888
+ else if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer)
889
+ iterExpr = node.initializer; // const [a] = bag
890
+ else if (ts.isCallExpression(node) && node.arguments?.[0]
891
+ && node.expression.getText() === "Array.from")
892
+ iterExpr = node.arguments[0]; // Array.from(bag) — the iterable form (arg0 is iterated)
893
+ if (iterExpr) {
894
+ const owner = enclosing(node);
895
+ if (owner) edgeToTargets(fns.get(owner), iterationTargets(iterExpr, iterAsync));
896
+ }
897
+ }
898
+ // `using r = expr` / `await using r = expr` (MED): the scope-exit guarantees `r[Symbol.dispose]()` /
899
+ // `r[Symbol.asyncDispose]()`. Edge the enclosing fn to the resolved LOCAL dispose method.
900
+ if (ts.isVariableStatement(node)) {
901
+ const fl = node.declarationList.flags;
902
+ const isUsing = (fl & ts.NodeFlags.Using) || (fl & ts.NodeFlags.AwaitUsing);
903
+ if (isUsing) {
904
+ const isAwait = !!(fl & ts.NodeFlags.AwaitUsing);
905
+ const prefixes = isAwait ? ["__@asyncDispose", "__@dispose"] : ["__@dispose"];
906
+ const owner = enclosing(node);
907
+ for (const d of node.declarationList.declarations) {
908
+ if (!d.initializer || !owner) continue;
909
+ const t = checker.getTypeAtLocation(d.initializer);
910
+ const disposeDecl = declOfSym(wellKnownSymbolMember(t, prefixes));
911
+ if (disposeDecl && declIsLocal(disposeDecl)) edgeToTargets(fns.get(owner), [disposeDecl]);
912
+ }
913
+ }
914
+ }
915
+ // TAGGED TEMPLATE (LOW): `` tag`…` `` calls `tag(strings, ...subs)`. getResolvedSignature resolves
916
+ // the TaggedTemplateExpression to the tag fn cleanly — a node form the CallExpression walk never
917
+ // visits. Edge to the tag when LOCAL; a built-in/external tag (`String.raw`) resolves non-local and
918
+ // edges nothing (pure), matching the external-call posture.
919
+ if (ts.isTaggedTemplateExpression(node)) {
920
+ const owner = enclosing(node);
921
+ if (owner) {
922
+ const sig = checker.getResolvedSignature(node);
923
+ const decl = sig && sig.declaration;
924
+ if (decl && declIsLocal(decl)) edgeToTargets(fns.get(owner), [decl]);
925
+ }
926
+ }
823
927
  ts.forEachChild(node, visitCalls);
824
928
  }
825
929
  for (const sf of sources) visitCalls(sf);