feat(scope-resolution): resolve plain-object property access by unique name

Idiomatic JS reads configuration off an object whose receiver cannot be
typed — an options bag passed as a parameter, a destructured handle, an
imported literal. No precise pass resolves those, so a field read and
written across a live code path produced no ACCESSES edge at all and "who
reads this setting?" answered a confident zero.

A last-resort pass runs after every precise pass and sees only what they
left behind. For each still-unresolved read/write site it asks whether
exactly ONE Property in the workspace carries that name. If so the read
almost certainly means it. If two or more do, nothing is emitted and the
site is COUNTED as ambiguous — a guess between them would be a coin flip,
and a wrong edge in the pre-edit safety gate is worse than a missing one.

Uniqueness is the right gate because it recovers exactly the names worth
recovering: distinctive domain fields (exitMinAtrMult, bookNotionalUsdt)
are unique in a repo and resolve, while generic keys (id, name, data) are
not and are skipped — which is where name matching would over-connect.

Bounded four ways:
- Confidence 0.5, the global tier, with the inference named in the reason,
  so a consumer can filter inferences without losing scope-resolved edges.
- Never second-guesses a precise result: sites already resolved are
  excluded, because first-write-wins stops a duplicate but NOT a second
  edge to a different target.
- Honors `fieldFallbackOnMethodLookup`. A statically-typed language opts
  out of name matching precisely because it over-connects; inferring an
  ACCESSES edge by name is the same claim and must obey the same opt-out.
- Requires an explicit receiver — a bare identifier is not a property
  access, and matching one by name would link a local to an unrelated key.

Indexes graph nodes rather than scope defs because an object-literal key
mints a Property NODE but no scope-resolution DEF: `localDefs` and
`scope.bindings` are both empty for exactly the population this serves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ReidenXerx 2026-08-06 01:24:58 +03:00
parent 5ebc060682
commit 1e451bb8a4
3 changed files with 216 additions and 6 deletions

View file

@ -0,0 +1,146 @@
/**
* Last-resort property resolution by UNIQUE NAME (A1/A5).
*
* Idiomatic JS reads configuration off a plain object whose receiver cannot be
* typed — an options bag passed as a parameter, a destructured handle, an
* imported literal. The precise passes resolve none of those, so a field read
* and written across a live code path produced no `ACCESSES` edge at all and
* "who reads this setting?" answered a confident zero.
*
* This pass runs AFTER every precise pass and only sees what they left behind.
* For each still-unresolved read/write site it asks one question: does exactly
* ONE `Property` node in the graph carry this name? If so the read almost
* certainly means it, and an edge is emitted at REDUCED CONFIDENCE with a
* reason that names the inference. If two or more carry the name, nothing is
* emitted — a guess between them would be a coin flip, and a wrong edge in the
* pre-edit safety gate is worse than a missing one.
*
* Why uniqueness is the right gate: the names this recovers are the ones worth
* recovering. Distinctive domain fields (`exitMinAtrMult`, `bookNotionalUsdt`)
* are unique in a repo and resolve; generic keys (`id`, `name`, `data`) are not
* and are skipped, which is exactly where name matching would over-connect.
* That is the `fieldFallbackOnMethodLookup` trade this codebase already accepts
* for dynamic languages, bounded so it cannot fire on the ambiguous majority.
*
* Confidence is 0.5 — the same tier the 3-tier import resolver assigns its
* global fallback, because this is the same kind of claim: a name matched
* workspace-wide with no scope evidence behind it.
*
* WHY GRAPH NODES, NOT SCOPE DEFS: an object-literal key mints a `Property`
* NODE (parse query) but no scope-resolution DEF, so `scope.bindings` and
* `localDefs` are both empty for exactly the population this pass exists to
* serve. Indexing the graph is therefore not a shortcut — it is the only place
* these symbols exist. It also means the pass emits straight to the graph
* rather than through `tryEmitEdge`, whose target side takes a def.
*/
import type { ParsedFile } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../../graph/types.js';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
import { resolveCallerGraphId } from '../graph-bridge/ids.js';
/**
* Confidence for a workspace-unique name match. Deliberately the global tier's
* 0.5 and not the 0.85 default: a consumer filtering on confidence must be able
* to drop these without dropping scope-resolved edges.
*/
const UNIQUE_NAME_CONFIDENCE = 0.5;
const EDGE_REASON = 'scope-resolution: unique-name property';
/** Sentinel for "more than one node carries this name" — never resolved. */
const AMBIGUOUS = null;
export interface UniqueNamePropertyStats {
/** Edges emitted from a workspace-unique name match. */
readonly emitted: number;
/**
* Sites skipped because two or more `Property` nodes share the name, so a
* unique-name match would have been a coin flip. Reported rather than
* silently dropped: this is the population a receiver-typing improvement
* would convert into precise edges.
*/
readonly ambiguous: number;
}
/**
* Index `Property` nodes by name, collapsing any name with two or more nodes
* to {@link AMBIGUOUS} immediately. Storing the sentinel instead of a list
* keeps a repo full of same-named keys (`id`, `type`, `value`) from
* materializing an array per key.
*/
function indexUniquePropertyNodes(graph: KnowledgeGraph): ReadonlyMap<string, string | null> {
const byName = new Map<string, string | null>();
for (const node of graph.iterNodes()) {
if (node.label !== 'Property') continue;
const name = node.properties.name;
if (typeof name !== 'string' || name.length === 0) continue;
const existing = byName.get(name);
if (existing === undefined) {
byName.set(name, node.id);
} else if (existing !== AMBIGUOUS && existing !== node.id) {
byName.set(name, AMBIGUOUS);
}
}
return byName;
}
export function emitUniqueNamePropertyAccesses(
graph: KnowledgeGraph,
indexes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
/** Sites a precise pass already owns — never second-guessed here. */
skipSites: ReadonlySet<string>,
): UniqueNamePropertyStats {
const byName = indexUniquePropertyNodes(graph);
if (byName.size === 0) return { emitted: 0, ambiguous: 0 };
let emitted = 0;
let ambiguous = 0;
const seen = new Set<string>();
for (const parsed of parsedFiles) {
for (const site of parsed.referenceSites) {
if (site.kind !== 'read' && site.kind !== 'write') continue;
// A bare identifier is not a property access — without a receiver there
// is no object whose member this could be, and matching one by name
// would link a local variable to an unrelated object's key.
if (site.explicitReceiver === undefined) continue;
const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`;
if (skipSites.has(siteKey)) continue;
const targetId = byName.get(site.name);
if (targetId === undefined) continue;
if (targetId === AMBIGUOUS) {
ambiguous++;
continue;
}
const callerGraphId = resolveCallerGraphId(site.inScope, indexes, nodeLookup, site.atRange);
if (callerGraphId === undefined) continue;
// A property reading itself is not a fact about anything.
if (callerGraphId === targetId) continue;
const dedupKey = `ACCESSES:${callerGraphId}->${targetId}:${site.atRange.startLine}:${site.atRange.startCol}`;
if (seen.has(dedupKey)) continue;
seen.add(dedupKey);
// `addRelationship` is first-write-wins, so a precise edge already
// emitted for this exact id keeps ownership over the inference.
graph.addRelationship({
id: `rel:${dedupKey}`,
sourceId: callerGraphId,
targetId,
type: 'ACCESSES',
confidence: UNIQUE_NAME_CONFIDENCE,
reason: `${EDGE_REASON}: ${site.kind}`,
evidence: [],
});
emitted++;
}
}
return { emitted, ambiguous };
}

View file

@ -76,6 +76,7 @@ import {
MAX_PROPERTY_DISPATCH_FANOUT,
} from '../passes/property-dispatch.js';
import { emitReferencesViaLookup } from '../graph-bridge/references-to-edges.js';
import { emitUniqueNamePropertyAccesses } from '../passes/unique-name-properties.js';
import {
createCalleeIdAccumulator,
type CalleeIdAccumulator,
@ -438,6 +439,17 @@ interface RunScopeResolutionStats {
* #2437 false-safe gap for exactly those keys (names are in the warn log).
*/
readonly propertyDispatchSkippedKeys: number;
/**
* ACCESSES edges recovered by workspace-unique property name (A1/A5) — the
* last-resort pass for receivers no precise pass could type.
*/
readonly uniqueNamePropertyEdges: number;
/**
* Read/write sites left unresolved because two or more `Property` defs share
* the name, so a unique-name match would have been a coin flip. This is the
* population a receiver-typing improvement would convert into precise edges.
*/
readonly uniqueNamePropertyAmbiguous: number;
readonly resolutionOutcomes: readonly ResolutionOutcome[];
/**
* Per-function taint summaries harvested in the pdg window (#2084 M4 U1).
@ -566,6 +578,8 @@ export function runScopeResolution(
referenceEdgesEmitted: 0,
referenceSkipped: 0,
propertyDispatchSkippedKeys: 0,
uniqueNamePropertyEdges: 0,
uniqueNamePropertyAmbiguous: 0,
resolutionOutcomes,
functionSummaries: [],
callSummaries: [],
@ -595,6 +609,8 @@ export function runScopeResolution(
referenceEdgesEmitted: 0,
referenceSkipped: 0,
propertyDispatchSkippedKeys: 0,
uniqueNamePropertyEdges: 0,
uniqueNamePropertyAmbiguous: 0,
resolutionOutcomes,
functionSummaries: [],
callSummaries: [],
@ -900,6 +916,37 @@ export function runScopeResolution(
referenceSkipSites,
calleeIdAccumulator,
);
// Last-resort property resolution by workspace-unique name (A1/A5). Runs
// after every precise pass and only sees what they left behind, so a
// scope-resolved target always wins. Sites the generic bridge already
// resolved are excluded explicitly: `graph.addRelationship` is
// first-write-wins per edge id, which stops a DUPLICATE but not a second
// edge to a DIFFERENT target, and second-guessing a resolved receiver is
// exactly the wrong-edge-in-the-safety-gate case this must not create.
const uniqueNameSkipSites = new Set(referenceSkipSites);
for (const [fromScope, refs] of referenceIndex.bySourceScope) {
const fromFilePath = indexes.scopeTree.getScope(fromScope)?.filePath;
if (fromFilePath === undefined) continue;
for (const ref of refs) {
uniqueNameSkipSites.add(`${fromFilePath}:${ref.atRange.startLine}:${ref.atRange.startCol}`);
}
}
// Gated on the language's own field-name-fallback policy. A statically-typed
// language sets `fieldFallbackOnMethodLookup: false` precisely because
// matching a member by name over-connects when a real type system could have
// answered exactly; inferring an ACCESSES edge by name is the same claim, so
// it must obey the same opt-out rather than route around it.
const uniqueNameProperties =
callableFlowOnly || provider.fieldFallbackOnMethodLookup === false
? { emitted: 0, ambiguous: 0 }
: emitUniqueNamePropertyAccesses(
graph,
indexes,
emitParsedFiles,
postHeritageNodeLookup,
uniqueNameSkipSites,
);
// value-ref registrations (#2437): USES edges at the registration sites
// plus field-based dispatch — synthesized CALLS from member-call sites to
// functions registered under the same property key. This runs after the
@ -1378,6 +1425,8 @@ export function runScopeResolution(
propertyDispatch.callsEmitted,
referenceSkipped: skipped,
propertyDispatchSkippedKeys: propertyDispatch.skippedKeys,
uniqueNamePropertyEdges: uniqueNameProperties.emitted,
uniqueNamePropertyAmbiguous: uniqueNameProperties.ambiguous,
resolutionOutcomes,
functionSummaries: harvestedSummaries,
callSummaries: harvestedCallSummaries,

View file

@ -78,13 +78,28 @@ describe('JavaScript plain-object property access (A1/A5)', () => {
expect(new Set(props).size).toBe(2);
});
// Blocked on receiver resolution — see STATUS above. `readersOf` is the
// assertion these become once the receiver can be typed to the literal.
it.todo('emits ACCESSES for a read through the holding variable');
it.todo('emits ACCESSES for the property WRITE (A5)');
it.todo('emits ACCESSES for a read through an untyped param (option bag)');
it('emits ACCESSES for a read through the holding variable', () => {
expect(readersOf('exitMinAtrMult')).toContain('readViaVariable');
});
void readersOf;
it('emits ACCESSES for the property WRITE (A5)', () => {
const writes = getRelationships(result, 'ACCESSES').filter(
(e) => e.target === 'exitMinAtrMult' && (e.rel.reason ?? '').includes('write'),
);
expect(writes.map((e) => e.source)).toContain('tightenExit');
});
it('emits ACCESSES for a read through an untyped param (option bag)', () => {
expect(readersOf('exitMinAtrMult')).toContain('applyRules');
});
it('marks a name-inferred edge at reduced confidence, not as precise', () => {
const inferred = getRelationships(result, 'ACCESSES').filter(
(e) => e.target === 'exitMinAtrMult' && (e.rel.reason ?? '').includes('unique-name'),
);
expect(inferred.length).toBeGreaterThan(0);
for (const e of inferred) expect(e.rel.confidence).toBeLessThan(0.85);
});
});
interface PropNode {