candor-ts 0.5.2 → 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.
- package/package.json +1 -1
- package/scan.mjs +192 -0
package/package.json
CHANGED
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
|
|
@@ -542,6 +600,60 @@ function rootsAtStdStream(expr) {
|
|
|
542
600
|
}
|
|
543
601
|
}
|
|
544
602
|
|
|
603
|
+
// ---- the implicit/desugared-call surface (the silent-pure holes the AST walk misses) -------------
|
|
604
|
+
// CLASSIFIER §1 says resolve, don't pattern-match — but the walk only sees CallExpression/
|
|
605
|
+
// NewExpression (+ accessor access). Effects reached through a DESUGARED call (a `for-of` lowering to
|
|
606
|
+
// `it[Symbol.iterator]().next()`, a `using` to `r[Symbol.dispose]()`, a tagged template to `tag(...)`)
|
|
607
|
+
// were invisible: reported concrete-PURE (omitted), not even Unknown. We model the desugaring exactly
|
|
608
|
+
// as the spec demands — resolve the implicit target via the compiler API and edge to it when LOCAL.
|
|
609
|
+
// A resolved-but-unseen target follows the existing external/κ posture (OPAQUE + ledger), and a
|
|
610
|
+
// BUILT-IN iterator/disposer (es-lib/@types/node — a plain array's iterator, a stdlib disposable)
|
|
611
|
+
// resolves to a non-local declaration and edges nothing, so it correctly stays pure.
|
|
612
|
+
|
|
613
|
+
// The member symbol for a WELL-KNOWN symbol (`Symbol.iterator`, `Symbol.dispose`, …) on a type. The
|
|
614
|
+
// checker mangles these to an escaped name `__@iterator@<globalId>`; match by the `__@<name>@` prefix
|
|
615
|
+
// (the trailing id is the unique Symbol's identity, not part of the name). `prefixes` is tried in
|
|
616
|
+
// order so a sync site prefers the sync method and an async site its async twin (falling back to sync).
|
|
617
|
+
function wellKnownSymbolMember(type, prefixes) {
|
|
618
|
+
if (!type || !type.getProperties) return null;
|
|
619
|
+
for (const p of type.getProperties()) {
|
|
620
|
+
const n = p.getName();
|
|
621
|
+
for (const pre of prefixes) if (n === pre || n.startsWith(pre + "@")) return p;
|
|
622
|
+
}
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
function declOfSym(sym) { return sym && (sym.valueDeclaration ?? sym.declarations?.[0]); }
|
|
626
|
+
const declIsLocal = (decl) => decl && projectFiles.has(path.resolve(decl.getSourceFile().fileName));
|
|
627
|
+
|
|
628
|
+
// The LOCAL units an ITERATION over `expr` implicitly calls: the iterable's `[Symbol.iterator]` (or
|
|
629
|
+
// `[Symbol.asyncIterator]` for `for await`) method AND the produced iterator's `next()`. The generator
|
|
630
|
+
// case rolls `next`'s body into the iterator-method unit (lexical attribution), and the self-iterator
|
|
631
|
+
// case (`[Symbol.iterator]() { return this }` + a separate effectful `next()`) needs the `next` edge —
|
|
632
|
+
// so we edge to BOTH whenever each is a LOCAL unit. A built-in iterable (plain array/string/Map: the
|
|
633
|
+
// es-lib/@types iterator) resolves non-local → no edge → stays pure (the precision invariant).
|
|
634
|
+
function iterationTargets(expr, isAsync) {
|
|
635
|
+
const t = checker.getTypeAtLocation(expr);
|
|
636
|
+
const iterPrefixes = isAsync ? ["__@asyncIterator", "__@iterator"] : ["__@iterator"];
|
|
637
|
+
const iterDecl = declOfSym(wellKnownSymbolMember(t, iterPrefixes));
|
|
638
|
+
if (!iterDecl) return [];
|
|
639
|
+
const out = [];
|
|
640
|
+
if (declIsLocal(iterDecl)) out.push(iterDecl);
|
|
641
|
+
// the iterator's next(): the return type of the [Symbol.iterator] method
|
|
642
|
+
try {
|
|
643
|
+
const sig = checker.getSignatureFromDeclaration(iterDecl);
|
|
644
|
+
const ret = sig && checker.getReturnTypeOfSignature(sig);
|
|
645
|
+
const nextDecl = declOfSym(ret && ret.getProperties().find((p) => p.getName() === "next"));
|
|
646
|
+
if (nextDecl && declIsLocal(nextDecl) && !out.includes(nextDecl)) out.push(nextDecl);
|
|
647
|
+
} catch { /* unresolved iterator shape — the iterator-method edge already covers the common case */ }
|
|
648
|
+
return out;
|
|
649
|
+
}
|
|
650
|
+
// Edge `rec` to each LOCAL desugared target that is a minted unit. Local-only by design: an external
|
|
651
|
+
// iterable/disposer is OPAQUE (the curated-κ caveat — same as an unmatched external call), never a
|
|
652
|
+
// fabricated edge; the existing call machinery + κ ledger already cover any EXPLICIT calls into it.
|
|
653
|
+
function edgeToTargets(rec, decls) {
|
|
654
|
+
for (const d of decls) { const t = nodeName.get(d); if (t) rec.edges.add(t); }
|
|
655
|
+
}
|
|
656
|
+
|
|
545
657
|
// ---- pass 2: per call site, the (CLASSIFY)/(EDGE)/(UNKNOWN) resolution of SEMANTICS §4 ------------
|
|
546
658
|
function visitCalls(node) {
|
|
547
659
|
if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
|
|
@@ -575,6 +687,36 @@ function visitCalls(node) {
|
|
|
575
687
|
const targetName = nodeName.get(decl);
|
|
576
688
|
if (targetName) {
|
|
577
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
|
+
}
|
|
578
720
|
// record what each argument position received (callback-flow, see callbackArgs)
|
|
579
721
|
(node.arguments ?? []).forEach((a, i) => {
|
|
580
722
|
const slot = (callbackArgs.get(targetName) ?? callbackArgs.set(targetName, new Map()).get(targetName));
|
|
@@ -820,6 +962,56 @@ function visitCalls(node) {
|
|
|
820
962
|
}
|
|
821
963
|
}
|
|
822
964
|
}
|
|
965
|
+
// ITERATION desugaring (HIGH): `for (const x of bag)`, `for await (…)`, `[...bag]`, `const [a]=bag`,
|
|
966
|
+
// `Array.from(bag)` all lower to `bag[Symbol.iterator]().next()`. Edge the enclosing fn to the
|
|
967
|
+
// iterable's local `[Symbol.iterator]`/`[Symbol.asyncIterator]` method (and the produced iterator's
|
|
968
|
+
// local `next`). A built-in iterable (array/string/Map) resolves non-local → no edge → stays pure.
|
|
969
|
+
{
|
|
970
|
+
let iterExpr = null, iterAsync = false;
|
|
971
|
+
if (ts.isForOfStatement(node)) { iterExpr = node.expression; iterAsync = !!node.awaitModifier; }
|
|
972
|
+
else if (ts.isSpreadElement(node)) iterExpr = node.expression; // [...bag] / f(...bag)
|
|
973
|
+
else if (ts.isSpreadAssignment(node)) iterExpr = node.expression; // {...bag} — object spread is NOT
|
|
974
|
+
// iteration (it copies own enumerable props, no [Symbol.iterator]); wellKnownSymbolMember simply
|
|
975
|
+
// finds none and edges nothing. Listed for clarity; the resolution self-guards.
|
|
976
|
+
else if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer)
|
|
977
|
+
iterExpr = node.initializer; // const [a] = bag
|
|
978
|
+
else if (ts.isCallExpression(node) && node.arguments?.[0]
|
|
979
|
+
&& node.expression.getText() === "Array.from")
|
|
980
|
+
iterExpr = node.arguments[0]; // Array.from(bag) — the iterable form (arg0 is iterated)
|
|
981
|
+
if (iterExpr) {
|
|
982
|
+
const owner = enclosing(node);
|
|
983
|
+
if (owner) edgeToTargets(fns.get(owner), iterationTargets(iterExpr, iterAsync));
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
// `using r = expr` / `await using r = expr` (MED): the scope-exit guarantees `r[Symbol.dispose]()` /
|
|
987
|
+
// `r[Symbol.asyncDispose]()`. Edge the enclosing fn to the resolved LOCAL dispose method.
|
|
988
|
+
if (ts.isVariableStatement(node)) {
|
|
989
|
+
const fl = node.declarationList.flags;
|
|
990
|
+
const isUsing = (fl & ts.NodeFlags.Using) || (fl & ts.NodeFlags.AwaitUsing);
|
|
991
|
+
if (isUsing) {
|
|
992
|
+
const isAwait = !!(fl & ts.NodeFlags.AwaitUsing);
|
|
993
|
+
const prefixes = isAwait ? ["__@asyncDispose", "__@dispose"] : ["__@dispose"];
|
|
994
|
+
const owner = enclosing(node);
|
|
995
|
+
for (const d of node.declarationList.declarations) {
|
|
996
|
+
if (!d.initializer || !owner) continue;
|
|
997
|
+
const t = checker.getTypeAtLocation(d.initializer);
|
|
998
|
+
const disposeDecl = declOfSym(wellKnownSymbolMember(t, prefixes));
|
|
999
|
+
if (disposeDecl && declIsLocal(disposeDecl)) edgeToTargets(fns.get(owner), [disposeDecl]);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
// TAGGED TEMPLATE (LOW): `` tag`…` `` calls `tag(strings, ...subs)`. getResolvedSignature resolves
|
|
1004
|
+
// the TaggedTemplateExpression to the tag fn cleanly — a node form the CallExpression walk never
|
|
1005
|
+
// visits. Edge to the tag when LOCAL; a built-in/external tag (`String.raw`) resolves non-local and
|
|
1006
|
+
// edges nothing (pure), matching the external-call posture.
|
|
1007
|
+
if (ts.isTaggedTemplateExpression(node)) {
|
|
1008
|
+
const owner = enclosing(node);
|
|
1009
|
+
if (owner) {
|
|
1010
|
+
const sig = checker.getResolvedSignature(node);
|
|
1011
|
+
const decl = sig && sig.declaration;
|
|
1012
|
+
if (decl && declIsLocal(decl)) edgeToTargets(fns.get(owner), [decl]);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
823
1015
|
ts.forEachChild(node, visitCalls);
|
|
824
1016
|
}
|
|
825
1017
|
for (const sf of sources) visitCalls(sf);
|