candor-ts 0.5.3 → 0.5.5
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.
- package/package.json +1 -1
- package/scan.mjs +126 -0
package/package.json
CHANGED
package/scan.mjs
CHANGED
|
@@ -322,6 +322,33 @@ 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)
|
|
326
|
+
// Resolve `extends X` to X's LOCAL ClassDeclaration (through an import alias), or null. Module-level
|
|
327
|
+
// so both the class-CHA INDEX (below) and the dispatch site's RECEIVER-SUBTREE scoping share one
|
|
328
|
+
// definition of the local inheritance edge.
|
|
329
|
+
function localBaseClassOf(cls) {
|
|
330
|
+
for (const h of cls.heritageClauses ?? []) {
|
|
331
|
+
if (h.token !== ts.SyntaxKind.ExtendsKeyword) continue;
|
|
332
|
+
const t = h.types?.[0];
|
|
333
|
+
if (!t) continue;
|
|
334
|
+
let sym = checker.getSymbolAtLocation(t.expression);
|
|
335
|
+
if (sym && sym.flags & ts.SymbolFlags.Alias) { try { sym = checker.getAliasedSymbol(sym); } catch { /* keep */ } }
|
|
336
|
+
const bd = (sym?.declarations ?? []).find((d) => ts.isClassDeclaration(d));
|
|
337
|
+
if (bd && projectFiles.has(path.resolve(bd.getSourceFile().fileName))) return bd;
|
|
338
|
+
}
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
// Is `cls` in the subtree rooted at `root` (i.e. cls === root, or cls transitively `extends` root
|
|
342
|
+
// through LOCAL classes)? Used to scope a base-member override fan-out to the RECEIVER's static type
|
|
343
|
+
// — a sibling subclass's override lives OUTSIDE this subtree and must not contaminate the verdict.
|
|
344
|
+
function classInSubtree(cls, root) {
|
|
345
|
+
let cur = cls, guard = 0;
|
|
346
|
+
while (cur && guard++ < 64) {
|
|
347
|
+
if (cur === root) return true;
|
|
348
|
+
cur = localBaseClassOf(cur);
|
|
349
|
+
}
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
325
352
|
function moduleOf(sf) {
|
|
326
353
|
const rel = path.relative(rootDir, path.resolve(sf.fileName)).replace(/\.[mc]?[tj]sx?$/, "");
|
|
327
354
|
return rel.split(path.sep).join(".");
|
|
@@ -453,6 +480,51 @@ for (const sf of sources) {
|
|
|
453
480
|
})(sf);
|
|
454
481
|
}
|
|
455
482
|
|
|
483
|
+
// Class-CHA universe (the override half of the Rust engine's local-trait / bounded-CHA move): a
|
|
484
|
+
// method call on a BASE-class-typed receiver resolves statically to the base method, but a SUBCLASS
|
|
485
|
+
// may override it with an effectful body — `class Dog extends Animal { speak(){ fs.readFileSync() } }`.
|
|
486
|
+
// Without fanning out to the override, `a.speak()` on an `Animal`-typed `a` comes back concrete-PURE
|
|
487
|
+
// (a silent-pure soundness hole, strictly worse than Unknown). We index, for every LOCAL base-class
|
|
488
|
+
// member, the overriding members in its LOCAL subclasses (walking the full `extends` chain so a
|
|
489
|
+
// grand-subclass override is attributed to the right ancestor declaration). The dispatch site (below)
|
|
490
|
+
// edges to the base PLUS these overrides, bounded by the same ≤12 family limit the interface path
|
|
491
|
+
// uses, with the same allResolved honesty gate. Local subclasses only (an external base/override
|
|
492
|
+
// surface stays OPAQUE, never fabricated). Mirrors interfaceImpls' merged-decl posture.
|
|
493
|
+
{
|
|
494
|
+
const memberName = (m) => (ts.isMethodDeclaration(m) || ts.isGetAccessorDeclaration(m)
|
|
495
|
+
|| ts.isSetAccessorDeclaration(m) || ts.isPropertyDeclaration(m)) && m.name?.getText?.();
|
|
496
|
+
const baseClassOf = localBaseClassOf;
|
|
497
|
+
for (const sf of sources) {
|
|
498
|
+
(function scan(node) {
|
|
499
|
+
if (ts.isClassDeclaration(node)) {
|
|
500
|
+
for (const m of node.members ?? []) {
|
|
501
|
+
const name = memberName(m);
|
|
502
|
+
if (!name) continue;
|
|
503
|
+
// Walk the base chain; register this subclass member as an override of the NEAREST
|
|
504
|
+
// ancestor member of the same name (one edge per (name) — TS forbids two declarations of
|
|
505
|
+
// one accessor-kind/method on a class, so the first match up the chain is the override
|
|
506
|
+
// target). Stop after the first ancestor declares the name: that is the unit a base-typed
|
|
507
|
+
// dispatch lands on; higher ancestors are reached transitively via their own override edges.
|
|
508
|
+
let base = baseClassOf(node), guard = 0;
|
|
509
|
+
while (base && guard++ < 64) {
|
|
510
|
+
const ancestor = (base.members ?? []).find((x) => memberName(x) === name
|
|
511
|
+
&& (ts.isMethodDeclaration(x) === ts.isMethodDeclaration(m))
|
|
512
|
+
&& (ts.isGetAccessorDeclaration(x) === ts.isGetAccessorDeclaration(m))
|
|
513
|
+
&& (ts.isSetAccessorDeclaration(x) === ts.isSetAccessorDeclaration(m)));
|
|
514
|
+
if (ancestor) {
|
|
515
|
+
if (!classOverrides.has(ancestor)) classOverrides.set(ancestor, []);
|
|
516
|
+
classOverrides.get(ancestor).push(m);
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
519
|
+
base = baseClassOf(base);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
ts.forEachChild(node, scan);
|
|
524
|
+
})(sf);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
456
528
|
// callback-flow bookkeeping (the Rust engine's callback_named move, ported): for every call that
|
|
457
529
|
// edges to a LOCAL unit, record what each argument position received — a NAMED local unit (a
|
|
458
530
|
// resolvable callback target), or an opaque value (an inline closure stays attributed to the
|
|
@@ -629,6 +701,60 @@ function visitCalls(node) {
|
|
|
629
701
|
const targetName = nodeName.get(decl);
|
|
630
702
|
if (targetName) {
|
|
631
703
|
rec.edges.add(targetName); // (EDGE) — cross-FILE edges resolve the same way
|
|
704
|
+
// Class-CHA fan-out: resolution landed on a base-class member that LOCAL subclasses
|
|
705
|
+
// override. A base-typed receiver (`a: Animal`, or a branch-merged `Animal|Dog`) could be
|
|
706
|
+
// any subclass at runtime, so the override bodies' effects must propagate — else the caller
|
|
707
|
+
// reads concrete-PURE while a `Dog.speak` does I/O (the silent-pure base-dispatch hole). We
|
|
708
|
+
// edge to the overrides too, bounded by the same ≤12 family limit the interface path uses,
|
|
709
|
+
// with the same honesty gate: if any override isn't a resolvable unit (not minted), or the
|
|
710
|
+
// family is too large, fall to Unknown rather than silently dropping it. A monomorphic
|
|
711
|
+
// receiver already resolved to the leaf (`new Dog()` -> Dog.speak, no overrides) so this is
|
|
712
|
+
// inert there — no double-count. A base method NO subclass overrides has no entry: today's
|
|
713
|
+
// behavior (just the base) is preserved exactly.
|
|
714
|
+
const allOverrides = classOverrides.get(decl);
|
|
715
|
+
if (allOverrides && allOverrides.length > 0) {
|
|
716
|
+
// PRECISION: scope the fan-out to the RECEIVER's static-type subtree. A base-member
|
|
717
|
+
// dispatch on a receiver statically typed as subclass `Cat` can only ever bind to a
|
|
718
|
+
// `Cat`-subtree body — a SIBLING `Dog.speak` override is type-impossible on this path,
|
|
719
|
+
// so propagating its effect over-reports on an unreachable receiver (fabrication-
|
|
720
|
+
// adjacent). When we can pin the receiver's static class (a property/element access
|
|
721
|
+
// whose receiver-expression type is a LOCAL class), keep only overrides whose owning
|
|
722
|
+
// class lies in that class's subtree; `viaBase(a: Animal)` keeps Dog (Dog ∈ Animal-
|
|
723
|
+
// subtree, the soundness edge), `noOverride(c: Cat)` drops Dog (Dog ∉ Cat-subtree) and
|
|
724
|
+
// stays pure. SOUNDNESS-PRESERVING FALLBACK: if the receiver's static class can't be
|
|
725
|
+
// pinned to a LOCAL class (no property access, a union/interface/`any` receiver, an
|
|
726
|
+
// external/unresolved type), we do NOT narrow — the full override set is kept, exactly
|
|
727
|
+
// the pre-precision behavior, so we never silently drop an effect we can't rule out.
|
|
728
|
+
let overrides = allOverrides;
|
|
729
|
+
const recvExpr = (ts.isPropertyAccessExpression(node.expression)
|
|
730
|
+
|| ts.isElementAccessExpression(node.expression)) ? node.expression.expression : null;
|
|
731
|
+
if (recvExpr) {
|
|
732
|
+
const rt = checker.getTypeAtLocation(recvExpr);
|
|
733
|
+
const rootClass = (rt?.symbol?.declarations ?? []).find((d) =>
|
|
734
|
+
ts.isClassDeclaration(d) && projectFiles.has(path.resolve(d.getSourceFile().fileName)));
|
|
735
|
+
if (rootClass) overrides = allOverrides.filter((om) =>
|
|
736
|
+
ts.isClassDeclaration(om.parent) && classInSubtree(om.parent, rootClass));
|
|
737
|
+
}
|
|
738
|
+
if (overrides.length > 0) {
|
|
739
|
+
if (overrides.length <= 12) {
|
|
740
|
+
let allResolved = true;
|
|
741
|
+
const oTargets = [];
|
|
742
|
+
for (const om of overrides) {
|
|
743
|
+
const ot = nodeName.get(om);
|
|
744
|
+
if (ot) oTargets.push(ot);
|
|
745
|
+
else allResolved = false;
|
|
746
|
+
}
|
|
747
|
+
for (const ot of oTargets) rec.edges.add(ot);
|
|
748
|
+
if (!allResolved) {
|
|
749
|
+
rec.direct.add("Unknown");
|
|
750
|
+
rec.why.add(`override:${decl.name?.getText?.() ?? "member"}`);
|
|
751
|
+
}
|
|
752
|
+
} else {
|
|
753
|
+
rec.direct.add("Unknown"); // override family too wide to enumerate soundly
|
|
754
|
+
rec.why.add(`override:${decl.name?.getText?.() ?? "member"}`);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
632
758
|
// record what each argument position received (callback-flow, see callbackArgs)
|
|
633
759
|
(node.arguments ?? []).forEach((a, i) => {
|
|
634
760
|
const slot = (callbackArgs.get(targetName) ?? callbackArgs.set(targetName, new Map()).get(targetName));
|