Skip to content

Commit bfd9200

Browse files
committed
fix(census): resolve a call site by how the call is written, not by where the name was declared
`opensCallableScope` is the predicate a callable's name is RECORDED under -- a method's name in its class body, a function declaration's in the block or file. The lookup site used that same chain for every callee shape, so a bare `getEngine()` was resolved through the enclosing class body, which the language never does: a method lives on the prototype, not in lexical scope. A `class C { getEngine(): Map<…> }` therefore shadowed a file-level `function getEngine(): IDataEngine` at a call written `getEngine()` inside `C` -- a real engine write subtracted under the DEFENDED `platform-type` arm, which prints nothing and is counted nowhere. `calleeScopeChain` picks the chain by callee shape: a bare identifier walks the lexical scopes only, `this.m()` walks the class chain, and `x.m()` walks neither and reads the file-wide floor, which is exactly what it resolved through before. Recording is unchanged. Four pins cover the three shapes: a bare call in both declaration orders (the floor disagrees between them), `this.m()`, and `this.x.m()` from inside a class with its own same-named method. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
1 parent bee92af commit bfd9200

1 file changed

Lines changed: 94 additions & 7 deletions

File tree

‎scripts/tenant-audit-census.mjs‎

Lines changed: 94 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -541,19 +541,62 @@ function opensClassScope(node) {
541541
}
542542

543543
/**
544-
* Where a CALLABLE'S OWN NAME is declared: the lexical scopes, plus the class.
544+
* Where a CALLABLE'S OWN NAME is declared -- a RECORDING predicate, ⛔ not a
545+
* lookup one: the lexical scopes, plus the class.
545546
*
546547
* A method's name belongs to its class body, a function declaration's and a
547548
* `const fn = () => …`'s to the block or file that declares it -- so this is
548549
* {@link opensScope} with the class added, and not either one alone. ⛔ Not the
549550
* same predicate as {@link opensClassScope}: a method named `getEngine` is
550551
* class-scoped, while a `function getEngine()` two lines above the class is not,
551552
* and a chain that saw only classes would lose the second one.
553+
*
554+
* ⭐ That reasoning answers WHERE A NAME IS DECLARED, and it is the whole
555+
* question only while a declaration is being recorded. A CALL SITE asks a
556+
* different question -- which declaration THIS call reaches -- and the answer
557+
* there is keyed on how the call is WRITTEN. ⛔ Reading a bare `getEngine()`
558+
* through this union is how a method came to shadow a file-level function that
559+
* the language would never let it shadow. {@link calleeScopeChain} is the
560+
* lookup-side predicate; the two are deliberately not the same function.
552561
*/
553562
function opensCallableScope(node) {
554563
return opensScope(node) || opensClassScope(node);
555564
}
556565

566+
/**
567+
* ⭐ The scope chain a CALL SITE resolves its callee through -- by how the call
568+
* is WRITTEN, which is not the question {@link opensCallableScope} answers.
569+
*
570+
* One map records every callable's name at the scope that declares it, and that
571+
* map is read from three different call shapes, each of which the language
572+
* resolves differently:
573+
*
574+
* • `f()` -- a BARE IDENTIFIER is a lexical name and nothing else. A
575+
* method lives on the prototype, ⛔ never in lexical scope,
576+
* so the enclosing class body is not on this chain: a
577+
* `class C { getEngine() {…} }` does not shadow a
578+
* `function getEngine()` for a call written `getEngine()`
579+
* inside `C`. Reading it through the class subtracted a real
580+
* engine write under the DEFENDED `platform-type` arm, which
581+
* prints nothing and is counted nowhere.
582+
* • `this.m()` -- a method name, which belongs to the enclosing class exactly
583+
* as a property does, and to no lexical scope.
584+
* • `x.m()` -- a member of whatever `x` is, and this module has no index of
585+
* class members to read that off (interfaces and type-literal
586+
* aliases only, via {@link memberTypeOfShapes}, tried before
587+
* this chain). `null` ⇒ the file-wide FLOOR alone, which is
588+
* exactly what this receiver resolved through before there
589+
* were any scopes at all. ⛔ Not a placement this module can
590+
* justify -- it is the one it can defend as unchanged.
591+
*/
592+
function calleeScopeChain(callee) {
593+
if (ts.isIdentifier(callee)) return opensScope;
594+
if (ts.isPropertyAccessExpression(callee) && callee.expression.kind === ts.SyntaxKind.ThisKeyword) {
595+
return opensClassScope;
596+
}
597+
return null;
598+
}
599+
557600
/**
558601
* The scopes enclosing a node, innermost first, under `opens`.
559602
*
@@ -617,10 +660,18 @@ function scopedNames(opens = opensScope) {
617660
scopeMap(declNode).set(name, entry);
618661
flat.set(name, entry);
619662
},
620-
/** The declaration `name` refers to AT `node` -- lexical tier, then the floor. */
621-
lookup(name, node) {
622-
if (node) {
623-
for (const scope of scopeChainOf(node, opens)) {
663+
/**
664+
* The declaration `name` refers to AT `node` -- lexical tier, then the floor.
665+
*
666+
* `chain` defaults to the predicate this map RECORDS under, which is the
667+
* right answer wherever the name is looked up the same way it is declared.
668+
* A callable's name is not: it is recorded where it is declared and read by
669+
* how the call is written, so {@link resolveReceiver} passes the chain from
670+
* {@link calleeScopeChain} instead. `null` asks for the FLOOR alone.
671+
*/
672+
lookup(name, node, chain = opens) {
673+
if (node && chain) {
674+
for (const scope of scopeChainOf(node, chain)) {
624675
const hit = byScope.get(scope)?.get(name);
625676
if (hit) return hit;
626677
}
@@ -851,7 +902,7 @@ export function resolveReceiver(recvNode, sf, decls, index, depth = 0) {
851902
return inlineEngineDoorOrOther(mt, `${fname}() return`, sf);
852903
}
853904
}
854-
const entry = fname ? decls.fnReturns.lookup(fname, callee) : null;
905+
const entry = fname ? decls.fnReturns.lookup(fname, callee, calleeScopeChain(callee)) : null;
855906
if (entry) return fromEntry(entry, `${fname}()`);
856907
return { kind: 'unresolved', how: 'call', detail: receiverKey(r, sf) };
857908
}
@@ -2481,6 +2532,39 @@ export function selfTest() {
24812532
+ `export function w(d: Deps) {\n d.getEngine().${WRITE};\n}\n`, ['IProbeEngine']),
24822533
'engine/IProbeEngine');
24832534

2535+
// ── ⭐⭐ ONE MAP, THREE CALL SHAPES ────────────────────────────────
2536+
// The cases above all read the callable map the way its name was DECLARED.
2537+
// These four read it the way the call is WRITTEN, which is the other question
2538+
// and the one the lookup site actually asks. A single chain answering both is
2539+
// wrong for one of them: reading a BARE `getEngine()` through the enclosing
2540+
// class body lets a method shadow a file-level function that the language
2541+
// would never let it shadow -- and in this instrument that is the quiet
2542+
// direction again, a real engine write subtracted under `platform-type`, an
2543+
// arm that DEFENDS the subtraction and so prints nothing and is counted
2544+
// nowhere. ⛔ Pinned in BOTH declaration orders, because the floor decides the
2545+
// one the lexical tier does not reach and the two orders disagree there.
2546+
t('⭐⭐ a BARE call resolves lexically -- a method never shadows a file-level function of the same name',
2547+
verdictsIn('function getEngine(): IProbeEngine { return null as never; }\n'
2548+
+ 'class C {\n getEngine(): Map<string, number> { return new Map(); }\n'
2549+
+ ` w() { getEngine().${WRITE}; }\n}\n`, ['IProbeEngine']),
2550+
'engine/IProbeEngine');
2551+
t('⭐⭐ …and in the other declaration order, where the file-wide floor would have answered the method',
2552+
verdictsIn('class C {\n getEngine(): Map<string, number> { return new Map(); }\n'
2553+
+ ` w() { getEngine().${WRITE}; }\n}\n`
2554+
+ 'function getEngine(): IProbeEngine { return null as never; }\n', ['IProbeEngine']),
2555+
'engine/IProbeEngine');
2556+
t('⭐ a `this.<method>()` call DOES read the enclosing class, so its own method wins over a same-named function',
2557+
verdictsIn('function getEngine(): IProbeEngine { return null as never; }\n'
2558+
+ 'class C {\n getEngine(): Map<string, number> { return new Map(); }\n'
2559+
+ " w() { this.getEngine().delete('k'); }\n}\n", ['IProbeEngine']),
2560+
'other/platform-type');
2561+
t('⛔ FLOOR: an `x.<method>()` call is not the enclosing class\'s method -- the site\'s own class does not capture it',
2562+
verdictsIn('class A {\n getEngine(): IProbeEngine { return null as never; }\n}\n'
2563+
+ 'class B {\n constructor(private readonly x: A) {}\n'
2564+
+ ' getEngine(): Map<string, number> { return new Map(); }\n'
2565+
+ ` w() { this.x.getEngine().${WRITE}; }\n}\n`, ['IProbeEngine']),
2566+
'engine/IProbeEngine');
2567+
24842568
// The same conflation decided two OTHER questions, and both are verdicts the
24852569
// artefacts carry: WHICH object a site writes, and whether it is elevated.
24862570
/** Every write call's object-name verdict, in source order. */
@@ -2554,7 +2638,10 @@ export function selfTest() {
25542638
+ 'two places a name is NOT lexical: two classes sharing a property name are two properties at both '
25552639
+ 'storage sites and through a `this.<base>.<member>` base, two same-named local functions are two '
25562640
+ 'callables, each in both declaration orders, while a `this.<prop>` no enclosing class declares and '
2557-
+ 'a method called from outside its class both still resolve file-wide).',
2641+
+ 'a method called from outside its class both still resolve file-wide -- and the callable map read '
2642+
+ 'by how the CALL is written rather than by where the name was declared: a bare `f()` resolves '
2643+
+ 'lexically and is never shadowed by a same-named method of the enclosing class, in both '
2644+
+ 'declaration orders, while `this.m()` does read that class and `x.m()` reads neither).',
25582645
);
25592646
return 0;
25602647
}

0 commit comments

Comments
 (0)