feat(scope-resolution): report declined property reads for opt-out languages too

Generalizing R3-1 rather than waiting for it to be re-reported in the other
direction. The reported case was a JavaScript read whose only anchor was
TypeScript; the mirror — a TypeScript read anchored only in JavaScript — was
still silent, because a language that sets `fieldFallbackOnMethodLookup: false`
had the whole pass skipped, and skipping emission also skipped REPORTING.

Detection is not inference. Counting what could not be linked asserts nothing
about what it means, so `reportOnly` runs the pass for its facts while emitting
no edge, and the opt-out keeps protecting exactly what it protected before.

Two things this turned up that a single-instance fix would have missed:

The cross-language fixture could NOT prove `reportOnly` is load-bearing — the
per-language candidate filter already blocks those edges, so the assertion
passed with the flag forced off. The case that discriminates is a SAME-language
TypeScript read that name inference could legitimately link and the opt-out
forbids; forcing the flag off there emits `readsTsOnly -> tsOnlyBudget`, which
is the violation.

Getting to that case surfaced a sibling gap, recorded but NOT fixed here: the
object-literal `Property` rule is JavaScript-only, so `const CONFIG = { ... }`
in a `.ts` file mints no node and its keys are invisible. The first draft of
this fixture used exactly that shape and could not discriminate for that reason.
It is the TypeScript half of R2-1a and wants its own change, not a rider on
this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ReidenXerx 2026-08-07 02:13:59 +03:00
parent 5143a11b3c
commit 0c5a4f64bf
4 changed files with 87 additions and 19 deletions

View file

@ -320,6 +320,17 @@ export function emitUniqueNamePropertyAccesses(
* omitted (tests / isolated calls).
*/
prebuiltPropertyNameIndex?: PropertyNameIndex,
/**
* DETECT WITHOUT EMITTING. A language that sets
* `fieldFallbackOnMethodLookup: false` (TypeScript) opts out of name
* inference because a real type system should answer precisely that opt-out
* is right and stays. But skipping the pass wholesale also skipped its
* REPORTING, so a TypeScript read whose only anchor is JavaScript got the
* same silent empty answer R3-1 exists to remove, just in the other
* direction. Detection is not inference: counting what could not be linked
* asserts nothing about what it means.
*/
reportOnly = false,
): UniqueNamePropertyStats {
const byName = prebuiltPropertyNameIndex ?? buildPropertyNameIndex(graph);
const ownFilePaths = new Set(parsedFiles.map((p) => p.filePath));
@ -404,6 +415,7 @@ export function emitUniqueNamePropertyAccesses(
if (seen.has(dedupKey)) continue;
seen.add(dedupKey);
if (reportOnly) continue;
// `addRelationship` is first-write-wins, so a precise edge already
// emitted for this exact id keeps ownership over the inference.
graph.addRelationship({

View file

@ -1047,25 +1047,32 @@ export function runScopeResolution(
uniqueNameSkipSites,
);
const uniqueNameProperties =
callableFlowOnly || provider.fieldFallbackOnMethodLookup === false
? {
emitted: 0,
ambiguous: 0,
narrowed: 0,
ambiguousNames: [],
crossLanguageOnly: 0,
crossLanguageOnlyNames: [],
}
: emitUniqueNamePropertyAccesses(
graph,
indexes,
emitParsedFiles,
postHeritageNodeLookup,
uniqueNameSkipSites,
finalized,
input.prebuiltPropertyNameIndex,
);
// A language that opts out of name fallback still gets DETECTION. Skipping
// the pass outright also skipped its reporting, so a TypeScript read whose
// only anchor is JavaScript answered the same silent empty as the JS-read /
// TS-anchor case R3-1 was filed about — the identical defect, mirrored.
// `reportOnly` counts without emitting: no edge, no inference, no change to
// what the opt-out protects.
const nameFallbackDisabled = provider.fieldFallbackOnMethodLookup === false;
const uniqueNameProperties = callableFlowOnly
? {
emitted: 0,
ambiguous: 0,
narrowed: 0,
ambiguousNames: [],
crossLanguageOnly: 0,
crossLanguageOnlyNames: [],
}
: emitUniqueNamePropertyAccesses(
graph,
indexes,
emitParsedFiles,
postHeritageNodeLookup,
uniqueNameSkipSites,
finalized,
input.prebuiltPropertyNameIndex,
nameFallbackDisabled,
);
// value-ref registrations (#2437): USES edges at the registration sites
// plus field-based dispatch — synthesized CALLS from member-call sites to

View file

@ -0,0 +1,24 @@
// The MIRROR of the Java/JS case. TypeScript sets
// `fieldFallbackOnMethodLookup: false`, so name inference does not run for it
// at all — correctly, since a type system should answer precisely. But that
// opt-out also skipped REPORTING, so this read answered the same silent empty
// as the case round 3 was filed about, in the other direction.
export function renderJsOnly(bag: { [k: string]: number }): number {
return bag.jsOnlyThreshold;
}
// The case that makes `reportOnly` load-bearing: a TypeScript property and a
// TypeScript read of it through an untyped receiver. Name inference COULD link
// these — same language, unique name — which is precisely what
// `fieldFallbackOnMethodLookup: false` forbids. Running the pass for reporting
// must not quietly re-enable it.
// An INTERFACE member, not an object literal: the object-literal Property rule
// is JavaScript-only, so a `const X = { ... }` in a .ts file mints no node and
// there would be nothing for inference to link either way.
export interface TsBudget {
tsOnlyBudget: number;
}
export function readsTsOnly(bag: { [k: string]: number }): number {
return bag.tsOnlyBudget;
}

View file

@ -75,5 +75,30 @@ describe('cross-language property inference (RV-5)', () => {
it('does not count a cross-language decline as an ambiguity', () => {
expect(inference().ambiguousNames).not.toContain('loyaltyPointsBalance');
});
// The MIRROR, found by asking what else shares this shape rather than
// waiting for it to be reported. TypeScript opts out of name inference
// (`fieldFallbackOnMethodLookup: false`) because a type system should
// answer precisely — that stays. But skipping the pass wholesale also
// skipped its reporting, so a TypeScript read anchored only in JavaScript
// gave the identical silent empty this whole item is about.
//
// Detection is not inference: counting what could not be linked asserts
// nothing about what it means, so the opt-out loses nothing.
it('reports the same fact for a language that opts OUT of name inference', () => {
const hit = inference().crossLanguageNames.find((e) => e.name === 'jsOnlyThreshold');
expect(hit).toBeDefined();
expect(hit?.languages).toContain('javascript');
});
// The assertion that makes `reportOnly` load-bearing. The cross-language
// case cannot show it — the language filter blocks those edges anyway — so
// this is a SAME-language TypeScript read that name inference could link,
// and which `fieldFallbackOnMethodLookup: false` forbids linking. Running
// the pass for reporting must not quietly re-enable inference.
it('still emits no edge for the opted-out language', () => {
expect(readersOf('tsOnlyBudget')).not.toContain('readsTsOnly');
expect(readersOf('jsOnlyThreshold')).not.toContain('renderJsOnly');
});
});
});