From 1e451bb8a41d9c3f04c09dd5e42673adf2cafc2c Mon Sep 17 00:00:00 2001 From: ReidenXerx Date: Thu, 6 Aug 2026 01:24:58 +0300 Subject: [PATCH] feat(scope-resolution): resolve plain-object property access by unique name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../passes/unique-name-properties.ts | 146 ++++++++++++++++++ .../scope-resolution/pipeline/run.ts | 49 ++++++ .../javascript-object-properties.test.ts | 27 +++- 3 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 gitnexus/src/core/ingestion/scope-resolution/passes/unique-name-properties.ts diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/unique-name-properties.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/unique-name-properties.ts new file mode 100644 index 000000000..e365a3e05 --- /dev/null +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/unique-name-properties.ts @@ -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 { + const byName = new Map(); + 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, +): UniqueNamePropertyStats { + const byName = indexUniquePropertyNodes(graph); + if (byName.size === 0) return { emitted: 0, ambiguous: 0 }; + + let emitted = 0; + let ambiguous = 0; + const seen = new Set(); + + 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 }; +} diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 72f1b1168..f85ea6b89 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -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, diff --git a/gitnexus/test/integration/resolvers/javascript-object-properties.test.ts b/gitnexus/test/integration/resolvers/javascript-object-properties.test.ts index c431d4875..23be3652a 100644 --- a/gitnexus/test/integration/resolvers/javascript-object-properties.test.ts +++ b/gitnexus/test/integration/resolvers/javascript-object-properties.test.ts @@ -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 {