candor-ts 0.5.3 → 0.5.4

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 +88 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
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
@@ -322,6 +322,7 @@ const nodeName = new WeakMap(); // declaration node -> qualified name
322
322
  // `@Entity()` (naming-strategy-dependent) contributes nothing — never a guess.
323
323
  const entityTables = new Map(); // ClassDeclaration node -> table name
324
324
  const interfaceImpls = new Map(); // InterfaceDeclaration node -> implementing ClassDeclarations (CHA universe)
325
+ const classOverrides = new Map(); // base-method MemberDeclaration node -> overriding subclass member nodes (class-CHA)
325
326
  function moduleOf(sf) {
326
327
  const rel = path.relative(rootDir, path.resolve(sf.fileName)).replace(/\.[mc]?[tj]sx?$/, "");
327
328
  return rel.split(path.sep).join(".");
@@ -453,6 +454,63 @@ for (const sf of sources) {
453
454
  })(sf);
454
455
  }
455
456
 
457
+ // Class-CHA universe (the override half of the Rust engine's local-trait / bounded-CHA move): a
458
+ // method call on a BASE-class-typed receiver resolves statically to the base method, but a SUBCLASS
459
+ // may override it with an effectful body — `class Dog extends Animal { speak(){ fs.readFileSync() } }`.
460
+ // Without fanning out to the override, `a.speak()` on an `Animal`-typed `a` comes back concrete-PURE
461
+ // (a silent-pure soundness hole, strictly worse than Unknown). We index, for every LOCAL base-class
462
+ // member, the overriding members in its LOCAL subclasses (walking the full `extends` chain so a
463
+ // grand-subclass override is attributed to the right ancestor declaration). The dispatch site (below)
464
+ // edges to the base PLUS these overrides, bounded by the same ≤12 family limit the interface path
465
+ // uses, with the same allResolved honesty gate. Local subclasses only (an external base/override
466
+ // surface stays OPAQUE, never fabricated). Mirrors interfaceImpls' merged-decl posture.
467
+ {
468
+ const memberName = (m) => (ts.isMethodDeclaration(m) || ts.isGetAccessorDeclaration(m)
469
+ || ts.isSetAccessorDeclaration(m) || ts.isPropertyDeclaration(m)) && m.name?.getText?.();
470
+ // Resolve `extends X` to X's LOCAL ClassDeclaration (through an import alias), or null.
471
+ const baseClassOf = (cls) => {
472
+ for (const h of cls.heritageClauses ?? []) {
473
+ if (h.token !== ts.SyntaxKind.ExtendsKeyword) continue;
474
+ const t = h.types?.[0];
475
+ if (!t) continue;
476
+ let sym = checker.getSymbolAtLocation(t.expression);
477
+ if (sym && sym.flags & ts.SymbolFlags.Alias) { try { sym = checker.getAliasedSymbol(sym); } catch { /* keep */ } }
478
+ const bd = (sym?.declarations ?? []).find((d) => ts.isClassDeclaration(d));
479
+ if (bd && projectFiles.has(path.resolve(bd.getSourceFile().fileName))) return bd;
480
+ }
481
+ return null;
482
+ };
483
+ for (const sf of sources) {
484
+ (function scan(node) {
485
+ if (ts.isClassDeclaration(node)) {
486
+ for (const m of node.members ?? []) {
487
+ const name = memberName(m);
488
+ if (!name) continue;
489
+ // Walk the base chain; register this subclass member as an override of the NEAREST
490
+ // ancestor member of the same name (one edge per (name) — TS forbids two declarations of
491
+ // one accessor-kind/method on a class, so the first match up the chain is the override
492
+ // target). Stop after the first ancestor declares the name: that is the unit a base-typed
493
+ // dispatch lands on; higher ancestors are reached transitively via their own override edges.
494
+ let base = baseClassOf(node), guard = 0;
495
+ while (base && guard++ < 64) {
496
+ const ancestor = (base.members ?? []).find((x) => memberName(x) === name
497
+ && (ts.isMethodDeclaration(x) === ts.isMethodDeclaration(m))
498
+ && (ts.isGetAccessorDeclaration(x) === ts.isGetAccessorDeclaration(m))
499
+ && (ts.isSetAccessorDeclaration(x) === ts.isSetAccessorDeclaration(m)));
500
+ if (ancestor) {
501
+ if (!classOverrides.has(ancestor)) classOverrides.set(ancestor, []);
502
+ classOverrides.get(ancestor).push(m);
503
+ break;
504
+ }
505
+ base = baseClassOf(base);
506
+ }
507
+ }
508
+ }
509
+ ts.forEachChild(node, scan);
510
+ })(sf);
511
+ }
512
+ }
513
+
456
514
  // callback-flow bookkeeping (the Rust engine's callback_named move, ported): for every call that
457
515
  // edges to a LOCAL unit, record what each argument position received — a NAMED local unit (a
458
516
  // resolvable callback target), or an opaque value (an inline closure stays attributed to the
@@ -629,6 +687,36 @@ function visitCalls(node) {
629
687
  const targetName = nodeName.get(decl);
630
688
  if (targetName) {
631
689
  rec.edges.add(targetName); // (EDGE) — cross-FILE edges resolve the same way
690
+ // Class-CHA fan-out: resolution landed on a base-class member that LOCAL subclasses
691
+ // override. A base-typed receiver (`a: Animal`, or a branch-merged `Animal|Dog`) could be
692
+ // any subclass at runtime, so the override bodies' effects must propagate — else the caller
693
+ // reads concrete-PURE while a `Dog.speak` does I/O (the silent-pure base-dispatch hole). We
694
+ // edge to the overrides too, bounded by the same ≤12 family limit the interface path uses,
695
+ // with the same honesty gate: if any override isn't a resolvable unit (not minted), or the
696
+ // family is too large, fall to Unknown rather than silently dropping it. A monomorphic
697
+ // receiver already resolved to the leaf (`new Dog()` -> Dog.speak, no overrides) so this is
698
+ // inert there — no double-count. A base method NO subclass overrides has no entry: today's
699
+ // behavior (just the base) is preserved exactly.
700
+ const overrides = classOverrides.get(decl);
701
+ if (overrides && overrides.length > 0) {
702
+ if (overrides.length <= 12) {
703
+ let allResolved = true;
704
+ const oTargets = [];
705
+ for (const om of overrides) {
706
+ const ot = nodeName.get(om);
707
+ if (ot) oTargets.push(ot);
708
+ else allResolved = false;
709
+ }
710
+ for (const ot of oTargets) rec.edges.add(ot);
711
+ if (!allResolved) {
712
+ rec.direct.add("Unknown");
713
+ rec.why.add(`override:${decl.name?.getText?.() ?? "member"}`);
714
+ }
715
+ } else {
716
+ rec.direct.add("Unknown"); // override family too wide to enumerate soundly
717
+ rec.why.add(`override:${decl.name?.getText?.() ?? "member"}`);
718
+ }
719
+ }
632
720
  // record what each argument position received (callback-flow, see callbackArgs)
633
721
  (node.arguments ?? []).forEach((a, i) => {
634
722
  const slot = (callbackArgs.get(targetName) ?? callbackArgs.set(targetName, new Map()).get(targetName));