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 {