fix(scope-resolution): rank production anchors above test fixtures

Found by testing R3-4 on the reporting repo instead of on its fixtures. Anchoring
returned literals took `wickRatio` from 6 definitions to 13 — and backend reads
still resolved to nothing, because SEVEN of the new JavaScript anchors compete
and four of them are in `tests/`. A test constructs throwaway shapes carrying
production field names; a read in shipped code cannot mean one of them.

Applied before the declared/return-shape split, because "is this the shipped
program" is the stronger signal — a declaration inside a test fixture is still a
test fixture. Skipped when the READER is itself a test, since a read there
legitimately means the test's own shape.

The first version of this test was vacuous and the mutation check caught it: the
reader sat in the same file as the production anchor, so the same-file tier
resolved it whether or not this tier existed. The reader now lives in a file
that imports neither anchor, which leaves production-vs-test as the only thing
that can decide.

Honest about what this does NOT do: it narrows `wickRatio` from seven candidates
to three, and three functions in different files each returning that field is
GENUINELY ambiguous — refusing is correct, and the ambiguity is now counted and
named rather than silent. The reported question ("who reads wickRatio?") is
answerable only where one producer exists; where several do, the honest answer
is the list of producers, which R3-4 made nameable for the first time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ReidenXerx 2026-08-07 03:58:48 +03:00
parent af5eec5c05
commit c764847a90
5 changed files with 60 additions and 0 deletions

View file

@ -69,6 +69,7 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
import { resolveCallerGraphId } from '../graph-bridge/ids.js';
import { callableFlowSiteKey } from './callable-value-flow.js';
import { isTestFile } from '../../entry-point-scoring.js';
import { getLanguageFromFilename } from 'gitnexus-shared';
/** Language a definition lives in, for reporting which anchor a reader cannot reach. */
@ -310,6 +311,21 @@ function narrowToSingleCandidate(
// what guarantees R3-4 cannot change an answer that already resolved before
// return shapes were indexed at all.
let candidates = candidatesIn;
// PRODUCTION CODE FIRST. A test constructs throwaway shapes with the same
// field names as the thing it exercises — measured on the reporting repo,
// four of the seven JavaScript anchors for `wickRatio` are in `tests/` — and
// a read in production code cannot mean any of them. Applied before the
// declared/return-shape split because "is this the shipped program" is the
// stronger signal: a declaration in a test fixture is still a test fixture.
//
// Only when the READER is production. A read inside a test legitimately means
// the test's own shape, so this must not fire there.
if (!isTestFile(readingFile)) {
const production = candidates.filter((c) => !isTestFile(c.filePath));
if (production.length > 0) candidates = production;
}
const declared = candidates.filter((c) => !c.fromReturnShape);
const ranked = declared.length > 0 ? declared : candidates;

View file

@ -0,0 +1,9 @@
// A TEST file that constructs a throwaway shape with a production field name.
// Measured on the reporting repo: four of seven JavaScript anchors for one
// field lived in `tests/`, competing with the three real ones and making every
// production read ambiguous. A read in production cannot mean any of these.
export function buildTestFixture() {
return {
productionAndTestField: 'fixture',
};
}

View file

@ -0,0 +1,8 @@
// The reader lives in its OWN file and imports neither anchor, so no same-file
// or direct-import tier can decide this. What is left is production-vs-test,
// which is exactly the tier under test — with a reader beside the production
// anchor, the same-file tier resolves it either way and the assertion proves
// nothing.
export function readsProductionShape(bag) {
return bag.productionAndTestField;
}

View file

@ -45,3 +45,11 @@ export const anonHolder = [
return { anonReturnKey: row.x };
},
];
// The production anchor for a name a test fixture also constructs. A read here
// must resolve to THIS one, not to the fixture's.
export function buildProductionShape(row) {
return {
productionAndTestField: row.real,
};
}

View file

@ -203,6 +203,25 @@ describe('JavaScript plain-object property access (A1/A5)', () => {
expect(ids.some((id) => id.includes('formatSummary.summaryOnlyField'))).toBe(true);
});
// Production code outranks test fixtures. Measured on the reporting repo:
// four of the seven JavaScript anchors for one field were in `tests/`,
// competing with the three real ones and making every production read
// ambiguous. A test builds throwaway shapes with production field names; a
// read in shipped code cannot mean one.
it('does not let a test fixture compete with the production anchor', () => {
expect(readersOf('productionAndTestField')).toContain('readsProductionShape');
const ids = Array.from(
(result as unknown as { graph: { iterNodes(): Iterable<PropNode> } }).graph.iterNodes(),
)
.filter((n) => n.label === 'Property')
.map((n) => String(n.id));
// Both anchors exist — it is the RANKING that differs, not the indexing.
expect(ids.some((id) => id.includes('buildProductionShape.productionAndTestField'))).toBe(
true,
);
expect(ids.some((id) => id.includes('buildTestFixture.productionAndTestField'))).toBe(true);
});
// THE GUARANTEE that reconciles this with R2-1b. `sharedWithDeclared` is
// both a named-object key and a return-shape key; a read must still resolve
// to the DECLARED one, or indexing return shapes would silently move