diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 28a080eb26..6465a9782c 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -14,6 +14,7 @@ import { isVerboseIngestionEnabled } from './utils/verbose.js'; import { getDefinitionNodeFromCaptures, findEnclosingClassInfo, + findObjectLiteralBindingInfo, getLabelFromCaptures, CLASS_CONTAINER_TYPES, type SyntaxNode, @@ -531,6 +532,10 @@ const processParsingSequential = async ( ) : null; const enclosingClassId = enclosingClassInfo?.classId ?? null; + const objectLiteralOwnerInfo = + !enclosingClassId && nodeLabel === 'Method' && definitionNode + ? findObjectLiteralBindingInfo(definitionNode, file.path) + : null; // Qualify method/property IDs with enclosing class name to avoid collisions // e.g. "Method:animal.dart:Animal.speak" vs "Method:animal.dart:Dog.speak" @@ -785,7 +790,7 @@ const processParsingSequential = async ( returnType: methodProps.returnType as string | undefined, declaredType, templateArguments: classTemplateArguments, - ownerId: enclosingClassId ?? undefined, + ownerId: enclosingClassId ?? objectLiteralOwnerInfo?.ownerId ?? undefined, qualifiedName: qualifiedTypeName, }); @@ -805,15 +810,18 @@ const processParsingSequential = async ( graph.addRelationship(relationship); // ── HAS_METHOD / HAS_PROPERTY: link member to enclosing class ── - if (enclosingClassId) { + const ownerIdForMemberEdge = enclosingClassId ?? objectLiteralOwnerInfo?.ownerId ?? null; + if (ownerIdForMemberEdge) { const memberEdgeType = nodeLabel === 'Property' ? 'HAS_PROPERTY' : 'HAS_METHOD'; graph.addRelationship({ - id: generateId(memberEdgeType, `${enclosingClassId}->${nodeId}`), - sourceId: enclosingClassId, + id: generateId(memberEdgeType, `${ownerIdForMemberEdge}->${nodeId}`), + sourceId: ownerIdForMemberEdge, targetId: nodeId, type: memberEdgeType, confidence: 1.0, - reason: '', + reason: objectLiteralOwnerInfo + ? 'object literal method belongs to exported object binding' + : '', }); } }); diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 746b9d6948..09080d2c6c 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -693,8 +693,14 @@ function normalizeNodeLabel(kindStr: string): SymbolDefinition['type'] | undefin case 'property': return 'Property'; case 'variable': - case 'const': return 'Variable'; + // `const` / `let` declarations align with the legacy DAG parse phase, + // which emits `Const` graph nodes via `@definition.const` capture for + // `lexical_declaration`. Returning `'Const'` here lets resolveDefGraphId's + // qualified-key path succeed for value receivers without relying on the + // simple-key fallback (PR #1718 review Finding 1 / 2026-05-21-002 U4). + case 'const': + return 'Const'; case 'typealias': case 'type_alias': return 'TypeAlias'; diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/edges.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/edges.ts index 080972691b..2562868e44 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/edges.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/edges.ts @@ -98,3 +98,54 @@ export function tryEmitEdge( }); return true; } + +/** + * Variant of `tryEmitEdge` that takes a pre-resolved target graph id + * instead of resolving it from a `SymbolDefinition`. Used by the + * value-receiver-owner bridge (`receiver-bound-calls.ts` Case 5) where + * the picked owner-indexed method def carries no `qualifiedName` (object + * literals have no class owner to seed it) and therefore cannot + * round-trip through `resolveDefGraphId`. The def's `nodeId` IS the + * canonical graph node id (written by the parse phase), so the caller + * passes it directly. + * + * All other invariants of `tryEmitEdge` apply: dedup key shape, collapse + * flag honoring, edge-type mapping, caller-id resolution. + */ +export function tryEmitEdgeWithExplicitTargetId( + graph: KnowledgeGraph, + scopes: ScopeResolutionIndexes, + nodeLookup: GraphNodeLookup, + site: { + readonly inScope: ScopeId; + readonly atRange: { startLine: number; startCol: number }; + readonly kind: string; + }, + targetGraphId: string, + reason: string, + seen: Set, + confidence = 0.85, + collapseByCallerTarget = false, +): boolean { + const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup); + const edgeType = mapReferenceKindToEdgeType(site.kind as Reference['kind']); + if (callerGraphId === undefined) return false; + if (edgeType === undefined) return false; + + const useCollapsed = collapseByCallerTarget && edgeType === 'CALLS'; + const dedupKey = useCollapsed + ? `${edgeType}:${callerGraphId}->${targetGraphId}` + : `${edgeType}:${callerGraphId}->${targetGraphId}:${site.atRange.startLine}:${site.atRange.startCol}`; + if (seen.has(dedupKey)) return false; + seen.add(dedupKey); + + graph.addRelationship({ + id: `rel:${dedupKey}`, + sourceId: callerGraphId, + targetId: targetGraphId, + type: edgeType, + confidence, + reason, + }); + return true; +} diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts index fd8c3cf235..8c29f8f2c6 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts @@ -159,6 +159,12 @@ export function isLinkableLabel(label: NodeLabel): boolean { // ACCESSES edges target field nodes (e.g. `user.name = "x"` → // ACCESSES edge to User's `name` Variable/Property node). label === 'Variable' || - label === 'Property' + label === 'Property' || + // Const is linkable so the value-receiver-owner bridge in + // `receiver-bound-calls.ts` Case 5 can translate the scope-resolution + // `Variable` def for `export const fooService = {...}` to the canonical + // `Const:filePath:name` graph node id, against which object-literal + // method symbols register their `ownerId` (PR #1718 / issue #1358). + label === 'Const' ); } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 430d83c2df..6a9469693c 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -21,6 +21,11 @@ * but not a namespace prefix → compound resolver * 7. **Case 4 (simple typeBinding)** — `typeRef.rawName` has no dot → * MRO walk + `findOwnedMember` + * 8. **Case 5 (value-receiver bridge)** — receiver is a `Const`/`Variable` + * whose `nodeId` is referenced as an `ownerId` in `model.methods` + * (object-literal services). Last-resort fallback for lowercase + * receivers with no class-like or type-binding match. Mirrors + * the legacy DAG bridge in `call-processor.ts`. * * Reordering or merging cases changes resolution semantics. * @@ -46,9 +51,10 @@ import { findExportedDef, findOwnedMember, findReceiverTypeBinding, + findValueBindingInScope, isClassLike, } from '../scope/walkers.js'; -import { tryEmitEdge } from '../graph-bridge/edges.js'; +import { tryEmitEdge, tryEmitEdgeWithExplicitTargetId } from '../graph-bridge/edges.js'; import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js'; import { resolveDefGraphId } from '../graph-bridge/ids.js'; import { @@ -706,6 +712,61 @@ export function emitReceiverBoundCalls( } } } + + // ── Case 5: value-receiver bridge (object-literal services) ── + // When prior cases couldn't resolve the receiver as a class or + // type binding, fall back to value-binding resolution. Covers: + // + // export const fooService = { getUser(id) {...} }; + // import { fooService } from './service'; + // fooService.getUser(id); // ← resolve here + // + // `fooService` is a `Const`/`Variable` (not class-like, no typeBinding + // for unannotated literals), so Cases 2-4 skip it. Scope-resolution + // defs for non-class values carry a synthetic id, so we translate to + // the canonical graph node ID via `resolveDefGraphId` before owner- + // indexed lookup — the parser writes the graph node ID as `ownerId` + // on the method symbol-table entry to match. + // + // Object-literal methods do not carry a `qualifiedName` (no class + // owner to seed it), so the picked def cannot round-trip through + // `tryEmitEdge` → `resolveDefGraphId`. We use + // `tryEmitEdgeWithExplicitTargetId` instead, passing `picked.nodeId` + // directly — same dedup-key shape, collapse-flag honoring, and + // caller resolution as `tryEmitEdge`. + const valueDef = findValueBindingInScope(site.inScope, receiverName, scopes); + if (valueDef !== undefined) { + const ownerGraphId = + resolveDefGraphId(valueDef.filePath, valueDef, nodeLookup) ?? valueDef.nodeId; + const picked = pickOverload(ownerGraphId, memberName, site, model, provider); + if (picked === OVERLOAD_AMBIGUOUS) { + handledSites.add(siteKey); + continue; + } + if (picked !== undefined) { + const reason = + site.kind === 'write' || site.kind === 'read' + ? site.kind + : picked.filePath !== parsed.filePath + ? 'import-resolved' + : 'global'; + const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85; + const ok = tryEmitEdgeWithExplicitTargetId( + graph, + scopes, + nodeLookup, + site, + picked.nodeId, + reason, + seen, + confidence, + collapse, + ); + if (ok) emitted++; + handledSites.add(siteKey); + continue; + } + } } } diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index 6e087d4f72..a9bb188d2d 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -165,28 +165,9 @@ export function findClassBindingInScope( receiverName: string, scopes: ScopeResolutionIndexes, ): SymbolDefinition | undefined { - let currentId: ScopeId | null = startScope; - const visited = new Set(); - while (currentId !== null) { - if (visited.has(currentId)) return undefined; - visited.add(currentId); - const scope = scopes.scopeTree.getScope(currentId); - if (scope === undefined) return undefined; - - const localBindings = scope.bindings.get(receiverName); - if (localBindings !== undefined) { - for (const b of localBindings) { - if (isClassLike(b.def.type)) return b.def; - } - } + const local = walkScopeChain(startScope, receiverName, scopes, (def) => isClassLike(def.type)); + if (local !== undefined) return local; - const importedBindings = lookupBindingsAt(currentId, receiverName, scopes); - for (const b of importedBindings) { - if (isClassLike(b.def.type)) return b.def; - } - - currentId = scope.parent; - } // Fallback for languages (Go) where namespace-style imports don't // create scope bindings: resolve via QualifiedNameIndex. Only fires // when the scope-chain walk found nothing; single-match wins. @@ -211,6 +192,89 @@ export function findClassBindingInScope( return undefined; } +/** + * Predicate for value-receiver bridge: the labels for which + * `reconcileOwnership` registers methods/fields under the def's + * `nodeId` as the `ownerId`. Explicit allowlist so future NodeLabel + * additions (Module, Namespace, TypeAlias, EnumMember, etc.) do NOT + * silently widen the bridge — adding a new ownerable label requires + * touching both this predicate and `reconcileOwnership`. + * + * See: `scope-resolution/pipeline/reconcile-ownership.ts` Property / + * Variable / Const / Static registration block. + */ +export function isOwnableValueLabel(t: string): boolean { + return t === 'Const' || t === 'Variable' || t === 'Property' || t === 'Static'; +} + +/** + * Look up a value-binding (Const/Variable/Property/Static) by name in + * the given scope's chain. Used by the value-receiver-owner bridge + * for object-literal services such as: + * + * export const fooService = { getUser(id) {...} }; + * + * where `fooService` is a `Const`/`Variable` whose `nodeId` is the + * `ownerId` of the member method. Neither `findClassBindingInScope` + * (rejects non-class-like) nor `findReceiverTypeBinding` (no typeBinding + * for an unannotated literal) finds it. + * + * Mirrors `findClassBindingInScope` exactly; only the accepted def-type + * predicate differs. + */ +export function findValueBindingInScope( + startScope: ScopeId, + receiverName: string, + scopes: ScopeResolutionIndexes, +): SymbolDefinition | undefined { + return walkScopeChain(startScope, receiverName, scopes, (def) => isOwnableValueLabel(def.type)); +} + +/** + * Generic scope-chain walker. Walks from `startScope` toward the root, + * consulting both the local `scope.bindings` channel and the dual-source + * `lookupBindingsAt` view (finalized + augmented). At each scope, local + * bindings are exhausted BEFORE imported/augmented bindings — preserves + * JavaScript-style lexical scoping where a local `const x` shadows an + * imported `x` of the same name. + * + * Returns the first binding `def` matching `predicate`. Cycles in the + * scope graph terminate the walk (defensive — should not occur in + * well-formed inputs). + */ +function walkScopeChain( + startScope: ScopeId, + name: string, + scopes: ScopeResolutionIndexes, + predicate: (def: SymbolDefinition) => boolean, +): SymbolDefinition | undefined { + let currentId: ScopeId | null = startScope; + const visited = new Set(); + while (currentId !== null) { + if (visited.has(currentId)) return undefined; + visited.add(currentId); + const scope = scopes.scopeTree.getScope(currentId); + if (scope === undefined) return undefined; + + // Local first: a `const x` in this scope shadows any imported `x`. + const localBindings = scope.bindings.get(name); + if (localBindings !== undefined) { + for (const b of localBindings) { + if (predicate(b.def)) return b.def; + } + } + + // Then imported/augmented bindings — only consulted when no local match. + const importedBindings = lookupBindingsAt(currentId, name, scopes); + for (const b of importedBindings) { + if (predicate(b.def)) return b.def; + } + + currentId = scope.parent; + } + return undefined; +} + /** * Look up a callable (Function/Method/Constructor) by name in the * given scope's chain. Uses the dual-source pattern (scope.bindings + diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 351cfdedbd..98e795686a 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -411,6 +411,123 @@ export const findEnclosingClassInfo = ( return null; }; +/** Object literal binding info for TS/JS shorthand methods. */ +export interface ObjectLiteralBindingInfo { + ownerId: string; +} + +/** + * Block-statement AST types that disqualify an object-literal binding from + * carrying a HAS_METHOD edge. A `const` declared inside one of these is block- + * scoped and cannot be imported, so attributing methods to it would create + * false-positive cross-file edges. + */ +const BLOCK_SCOPE_BOUNDARY_TYPES = new Set([ + 'statement_block', + 'if_statement', + 'else_clause', + 'for_statement', + 'for_in_statement', + 'for_of_statement', + 'while_statement', + 'do_statement', + 'try_statement', + 'catch_clause', + 'finally_clause', + 'switch_statement', + 'switch_case', + 'switch_default', + 'with_statement', +]); + +/** + * Find the file-scope variable that owns an object literal method definition. + * + * Covers TypeScript/JavaScript shorthand object methods such as: + * + * export const service = { async load() {} }; + * + * tree-sitter represents `load` as a `method_definition` inside an `object`, + * not inside a class container. Without this fallback, ingestion emits a + * top-level `Method` node but no edge from the exported `service` value to + * that method, so impact queries cannot discover `service.load`. + * + * Two-phase walk: + * Phase A walks up from `node` tracking how many `object` ancestors we + * cross. The first `variable_declarator` reached with `objectDepth >= 1` + * is the candidate owner — unless `objectDepth > 1` (the method belongs + * to a nested object literal; we return null rather than misattribute + * to the outer binding). Hitting a function/class container before the + * declarator returns null (catches IIFE-wrapped literals). + * Phase B walks the declarator's own ancestors. Any function or class + * ancestor before reaching `program`/`export_statement` returns null + * (catches `const` declared inside a function body). Any block-statement + * ancestor also returns null (catches block-scoped declarations inside + * top-level `if`/`for`/`try`/etc., which cannot be imported). + */ +export const findObjectLiteralBindingInfo = ( + node: SyntaxNode, + filePath: string, +): ObjectLiteralBindingInfo | null => { + // ── Phase A: walk up from node, count `object` ancestors, find declarator + let current: SyntaxNode | null = node; + let objectDepth = 0; + let declarator: SyntaxNode | null = null; + + while (current) { + if (current.type === 'object') { + objectDepth += 1; + } + + if (current.type === 'variable_declarator' && objectDepth >= 1) { + if (objectDepth > 1) { + // Method belongs to a nested object literal; safe under-approximation. + return null; + } + declarator = current; + break; + } + + if ( + current !== node && + (FUNCTION_NODE_TYPES.has(current.type) || CLASS_CONTAINER_TYPES.has(current.type)) + ) { + // Function/class container encountered before owning declarator + // (e.g. IIFE-wrapped object literal). Bail out. + return null; + } + + current = current.parent; + } + + if (!declarator) return null; + + // ── Phase B: declarator must live at file scope (program / export_statement) + // with no function, class, or block-statement ancestor in between. + let anc: SyntaxNode | null = declarator.parent; + while (anc) { + if (anc.type === 'program' || anc.type === 'export_statement') { + break; + } + if (FUNCTION_NODE_TYPES.has(anc.type) || CLASS_CONTAINER_TYPES.has(anc.type)) { + return null; + } + if (BLOCK_SCOPE_BOUNDARY_TYPES.has(anc.type)) { + return null; + } + anc = anc.parent; + } + + const nameNode = declarator.childForFieldName?.('name'); + if (!nameNode || nameNode.type !== 'identifier') return null; + + const declaration = declarator.parent; + const ownerLabel = declaration?.type === 'variable_declaration' ? 'Variable' : 'Const'; + return { + ownerId: generateId(ownerLabel, `${filePath}:${nameNode.text}`), + }; +}; + /** Convenience wrapper: returns just the class ID string (backward compat). */ export const findEnclosingClassId = (node: SyntaxNode, filePath: string): string | null => { return findEnclosingClassInfo(node, filePath)?.classId ?? null; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index eee9593a16..ccb88e0de4 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -50,6 +50,7 @@ import { FUNCTION_NODE_TYPES, getDefinitionNodeFromCaptures, findEnclosingClassInfo, + findObjectLiteralBindingInfo, type EnclosingClassInfo, getLabelFromCaptures, findDescendant, @@ -2068,6 +2069,10 @@ const processFileGroup = ( ) : null; const enclosingClassId = enclosingClassInfo?.classId ?? null; + const objectLiteralOwnerInfo = + !enclosingClassId && nodeLabel === 'Method' && definitionNode + ? findObjectLiteralBindingInfo(definitionNode, file.path) + : null; // Qualify method/property IDs with enclosing class name to avoid collisions const qualifiedName = enclosingClassInfo @@ -2306,6 +2311,7 @@ const processFileGroup = ( }); // enclosingClassId already computed above (before nodeId generation) + const ownerId = enclosingClassId ?? objectLiteralOwnerInfo?.ownerId; result.symbols.push({ filePath: file.path, @@ -2322,7 +2328,7 @@ const processFileGroup = ( ...(classTemplateArguments !== undefined && classTemplateArguments.length > 0 ? { templateArguments: classTemplateArguments } : {}), - ...(enclosingClassId ? { ownerId: enclosingClassId } : {}), + ...(ownerId !== undefined ? { ownerId } : {}), visibility: methodProps.visibility as string | undefined, isStatic: methodProps.isStatic as boolean | undefined, isReadonly: methodProps.isReadonly as boolean | undefined, @@ -2355,15 +2361,17 @@ const processFileGroup = ( }); // ── HAS_METHOD / HAS_PROPERTY: link member to enclosing class ── - if (enclosingClassId) { + if (ownerId !== undefined) { const memberEdgeType = nodeLabel === 'Property' ? 'HAS_PROPERTY' : 'HAS_METHOD'; result.relationships.push({ - id: generateId(memberEdgeType, `${enclosingClassId}->${nodeId}`), - sourceId: enclosingClassId, + id: generateId(memberEdgeType, `${ownerId}->${nodeId}`), + sourceId: ownerId, targetId: nodeId, type: memberEdgeType, confidence: 1.0, - reason: '', + reason: objectLiteralOwnerInfo + ? 'object literal method belongs to exported object binding' + : '', }); } } diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-class-instance-singleton/src/consumer.js b/gitnexus/test/fixtures/lang-resolution/javascript-class-instance-singleton/src/consumer.js new file mode 100644 index 0000000000..9c51372f7b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-class-instance-singleton/src/consumer.js @@ -0,0 +1,9 @@ +import { fooService } from './service.js'; + +/** + * @param {string} id + * @returns {string} + */ +export function caller(id) { + return fooService.getUser(id); +} diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-class-instance-singleton/src/service.js b/gitnexus/test/fixtures/lang-resolution/javascript-class-instance-singleton/src/service.js new file mode 100644 index 0000000000..079e643f05 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-class-instance-singleton/src/service.js @@ -0,0 +1,11 @@ +export class FooService { + /** + * @param {string} id + * @returns {string} + */ + getUser(id) { + return id; + } +} + +export const fooService = new FooService(); diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-factory-singleton/src/consumer.js b/gitnexus/test/fixtures/lang-resolution/javascript-factory-singleton/src/consumer.js new file mode 100644 index 0000000000..9c51372f7b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-factory-singleton/src/consumer.js @@ -0,0 +1,9 @@ +import { fooService } from './service.js'; + +/** + * @param {string} id + * @returns {string} + */ +export function caller(id) { + return fooService.getUser(id); +} diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-factory-singleton/src/service.js b/gitnexus/test/fixtures/lang-resolution/javascript-factory-singleton/src/service.js new file mode 100644 index 0000000000..c0472b3965 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-factory-singleton/src/service.js @@ -0,0 +1,18 @@ +export class FooService { + /** + * @param {string} id + * @returns {string} + */ + getUser(id) { + return id; + } +} + +/** + * @returns {FooService} + */ +export function makeFooService() { + return new FooService(); +} + +export const fooService = makeFooService(); diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-class-instance-singleton/src/consumer.ts b/gitnexus/test/fixtures/lang-resolution/typescript-class-instance-singleton/src/consumer.ts new file mode 100644 index 0000000000..7d1298ec7a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-class-instance-singleton/src/consumer.ts @@ -0,0 +1,5 @@ +import { fooService } from './service'; + +export function caller(id: string) { + return fooService.getUser(id); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-class-instance-singleton/src/service.ts b/gitnexus/test/fixtures/lang-resolution/typescript-class-instance-singleton/src/service.ts new file mode 100644 index 0000000000..e8a729ac98 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-class-instance-singleton/src/service.ts @@ -0,0 +1,7 @@ +export class FooService { + getUser(id: string) { + return id; + } +} + +export const fooService = new FooService(); diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-factory-singleton/src/consumer.ts b/gitnexus/test/fixtures/lang-resolution/typescript-factory-singleton/src/consumer.ts new file mode 100644 index 0000000000..7d1298ec7a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-factory-singleton/src/consumer.ts @@ -0,0 +1,5 @@ +import { fooService } from './service'; + +export function caller(id: string) { + return fooService.getUser(id); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-factory-singleton/src/service.ts b/gitnexus/test/fixtures/lang-resolution/typescript-factory-singleton/src/service.ts new file mode 100644 index 0000000000..89a70d5538 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-factory-singleton/src/service.ts @@ -0,0 +1,11 @@ +export class FooService { + getUser(id: string) { + return id; + } +} + +export function makeFooService(): FooService { + return new FooService(); +} + +export const fooService = makeFooService(); diff --git a/gitnexus/test/integration/ast-helpers-object-literal-binding.test.ts b/gitnexus/test/integration/ast-helpers-object-literal-binding.test.ts new file mode 100644 index 0000000000..99f3700aa0 --- /dev/null +++ b/gitnexus/test/integration/ast-helpers-object-literal-binding.test.ts @@ -0,0 +1,181 @@ +/** + * Integration tests for `findObjectLiteralBindingInfo`. + * + * Drives the helper against real tree-sitter ASTs (TypeScript) and pins the + * Phase A / Phase B boundary semantics from the PR #1718 production-readiness + * review (U1): + * - happy path: file-scope export const / const / export var → returns binding + * - local-inside-function / arrow / class-constructor → null + * - nested object literal → null (safe under-approximation) + * - block-scoped declaration (if / for body) → null + * - IIFE-wrapped object literal → null + * - assignment without declarator → null (no throw) + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import Parser from 'tree-sitter'; +import { loadParser, loadLanguage } from '../../src/core/tree-sitter/parser-loader.js'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; +import { findObjectLiteralBindingInfo } from '../../src/core/ingestion/utils/ast-helpers.js'; +import { generateId } from '../../src/lib/utils.js'; + +let parser: Parser; + +beforeAll(async () => { + parser = await loadParser(); + await loadLanguage(SupportedLanguages.TypeScript, 'fixture.ts'); +}); + +/** Locate every method_definition AST node by name. */ +function findMethodNodes(root: Parser.SyntaxNode, methodName: string): Parser.SyntaxNode[] { + const out: Parser.SyntaxNode[] = []; + const visit = (node: Parser.SyntaxNode) => { + if (node.type === 'method_definition') { + const name = node.childForFieldName('name'); + if (name?.text === methodName) out.push(node); + } + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) visit(child); + } + }; + visit(root); + return out; +} + +function parseTs(code: string): Parser.Tree { + return parser.parse(code); +} + +describe('findObjectLiteralBindingInfo — happy paths', () => { + it('exported const + shorthand method → owner binding', () => { + const tree = parseTs(`export const fooService = { async getUser(id: string) { return id; } };`); + const [methodNode] = findMethodNodes(tree.rootNode, 'getUser'); + expect(methodNode).toBeDefined(); + const result = findObjectLiteralBindingInfo(methodNode, 'src/foo.ts'); + expect(result).toEqual({ ownerId: generateId('Const', 'src/foo.ts:fooService') }); + }); + + it('bare file-scope const → owner binding', () => { + const tree = parseTs(`const fooService = { getUser(id: string) { return id; } };`); + const [methodNode] = findMethodNodes(tree.rootNode, 'getUser'); + const result = findObjectLiteralBindingInfo(methodNode, 'src/foo.ts'); + expect(result).toEqual({ ownerId: generateId('Const', 'src/foo.ts:fooService') }); + }); + + it('exported var (variable_declaration) → Variable label', () => { + const tree = parseTs(`export var legacyService = { run() {} };`); + const [methodNode] = findMethodNodes(tree.rootNode, 'run'); + const result = findObjectLiteralBindingInfo(methodNode, 'src/legacy.ts'); + expect(result).toEqual({ ownerId: generateId('Variable', 'src/legacy.ts:legacyService') }); + }); +}); + +describe('findObjectLiteralBindingInfo — negative: container boundaries', () => { + it('local const inside exported function → null', () => { + const tree = parseTs(` + export function processAll() { + const handler = { run(x: string) { return x; } }; + return handler; + } + `); + const [methodNode] = findMethodNodes(tree.rootNode, 'run'); + expect(findObjectLiteralBindingInfo(methodNode, 'src/p.ts')).toBe(null); + }); + + it('local const inside exported arrow function → null', () => { + const tree = parseTs(` + export const make = () => { + const h = { run() {} }; + return h; + }; + `); + const [methodNode] = findMethodNodes(tree.rootNode, 'run'); + expect(findObjectLiteralBindingInfo(methodNode, 'src/p.ts')).toBe(null); + }); + + it('local const inside class constructor → null', () => { + const tree = parseTs(` + export class C { + constructor() { + const h = { run() {} }; + void h; + } + } + `); + const [methodNode] = findMethodNodes(tree.rootNode, 'run'); + expect(findObjectLiteralBindingInfo(methodNode, 'src/c.ts')).toBe(null); + }); +}); + +describe('findObjectLiteralBindingInfo — negative: nested literals', () => { + it('inner method of nested literal → null (safe under-approximation)', () => { + const tree = parseTs(`export const s = { nested: { method() {} } };`); + const [methodNode] = findMethodNodes(tree.rootNode, 'method'); + expect(findObjectLiteralBindingInfo(methodNode, 'src/s.ts')).toBe(null); + }); + + it('top-level method alongside nested literal still binds to outer', () => { + const tree = parseTs(`export const s = { nested: { inner() {} }, outer() {} };`); + const [outerNode] = findMethodNodes(tree.rootNode, 'outer'); + expect(findObjectLiteralBindingInfo(outerNode, 'src/s.ts')).toEqual({ + ownerId: generateId('Const', 'src/s.ts:s'), + }); + const [innerNode] = findMethodNodes(tree.rootNode, 'inner'); + expect(findObjectLiteralBindingInfo(innerNode, 'src/s.ts')).toBe(null); + }); +}); + +describe('findObjectLiteralBindingInfo — negative: block scope', () => { + it('declared inside top-level if-block → null', () => { + const tree = parseTs(` + const cond = true; + if (cond) { + const handler = { run() {} }; + void handler; + } + `); + const [methodNode] = findMethodNodes(tree.rootNode, 'run'); + expect(findObjectLiteralBindingInfo(methodNode, 'src/p.ts')).toBe(null); + }); + + it('declared inside for-of body → null', () => { + const tree = parseTs(` + const arr = [1, 2]; + for (const _i of arr) { + const h = { run() {} }; + void h; + } + `); + const [methodNode] = findMethodNodes(tree.rootNode, 'run'); + expect(findObjectLiteralBindingInfo(methodNode, 'src/p.ts')).toBe(null); + }); + + it('declared inside try-block → null', () => { + const tree = parseTs(` + try { + const h = { run() {} }; + void h; + } catch {} + `); + const [methodNode] = findMethodNodes(tree.rootNode, 'run'); + expect(findObjectLiteralBindingInfo(methodNode, 'src/p.ts')).toBe(null); + }); +}); + +describe('findObjectLiteralBindingInfo — negative: IIFE and assignment', () => { + it('IIFE-wrapped object literal → null', () => { + const tree = parseTs(`export const x = (() => ({ m() {} }))();`); + const [methodNode] = findMethodNodes(tree.rootNode, 'm'); + expect(findObjectLiteralBindingInfo(methodNode, 'src/x.ts')).toBe(null); + }); + + it('assignment expression (no variable_declarator) → null without throwing', () => { + const tree = parseTs(` + let y: any; + y = { m() {} }; + `); + const [methodNode] = findMethodNodes(tree.rootNode, 'm'); + expect(() => findObjectLiteralBindingInfo(methodNode, 'src/y.ts')).not.toThrow(); + expect(findObjectLiteralBindingInfo(methodNode, 'src/y.ts')).toBe(null); + }); +}); diff --git a/gitnexus/test/integration/object-literal-owner-resolution.test.ts b/gitnexus/test/integration/object-literal-owner-resolution.test.ts new file mode 100644 index 0000000000..b0641d7f09 --- /dev/null +++ b/gitnexus/test/integration/object-literal-owner-resolution.test.ts @@ -0,0 +1,279 @@ +/** + * Integration tests for PR #1718 production-readiness review (U4). + * + * Proves the bug fix for issue #1358 end-to-end: + * + * export const fooService = { getUser(id: string) { return id; } }; + * // consumer.ts + * import { fooService } from './service'; + * export function caller(id: string) { return fooService.getUser(id); } + * + * After this PR, the full ingestion pipeline must emit: + * - `Const:fooService` ── HAS_METHOD ─► `Method:getUser` + * - `Function:caller` ── CALLS ─► `Method:getUser` + * + * The CALLS edge is the canonical proof: `gitnexus_impact` upstream traversal + * is a graph walk over CALLS, so if the edge exists, impact returns the + * caller. Asserting the edge directly avoids wiring an entire `withTestLbugDB` + * fixture for what is effectively a graph-shape assertion. + * + * Test set: + * - Test A: sequential pipeline produces both edges with the right `ownerId` + * - Test B: worker-mode pipeline produces identical edge sets (skipped when + * `dist/parse-worker.js` is missing; CI builds it before running tests) + * - Test C: local-scoped object literal inside a function emits no false- + * positive HAS_METHOD (proves U1 boundary guard is load-bearing) + * - Test D: nested object literal binds neither method to outer (safe + * under-approximation proof) + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + getRelationships, + getNodesByLabel, + runPipelineFromRepo, + type PipelineResult, +} from './resolvers/helpers.js'; +import { generateId } from '../../src/lib/utils.js'; + +const DIST_WORKER = path.resolve( + __dirname, + '..', + '..', + 'dist', + 'core', + 'ingestion', + 'workers', + 'parse-worker.js', +); +const hasDistWorker = fs.existsSync(DIST_WORKER); + +// CI tripwire: worker-parity test (Test B below) silently skips when +// `dist/parse-worker.js` is missing. That's fine locally — devs may not +// have run `npm run build` — but on CI a missing dist would mean U3 +// (worker-path ownerId emission) is unverified. Fail hard so a missing +// dist surfaces as a red build, not a green test with a silent skip. +// Locally, run `npm run build` before this suite to exercise worker mode. +if (!hasDistWorker && process.env.CI) { + throw new Error( + 'dist/parse-worker.js missing on CI — worker-parity test would silently skip. ' + + 'Ensure the build runs before this suite.', + ); +} + +/** Materialise a tiny fixture repo on disk. Returns the absolute repo root. */ +function writeFixture(files: Record): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gnx-objlit-')); + for (const [rel, content] of Object.entries(files)) { + const full = path.join(root, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + return root; +} + +function removeFixture(root: string): void { + fs.rmSync(root, { recursive: true, force: true }); +} + +const SERVICE_TS = `export const fooService = { + getUser(id: string) { return id; }, + saveUser(id: string) { return id; }, +}; +`; + +const CONSUMER_TS = `import { fooService } from './service'; + +export function caller(id: string) { + return fooService.getUser(id); +} +`; + +// ── Test A: sequential pipeline ────────────────────────────────────────────── + +describe('object-literal owner resolution — sequential pipeline (PR #1718)', () => { + let repoRoot: string; + let result: PipelineResult; + + beforeAll(async () => { + repoRoot = writeFixture({ + 'src/service.ts': SERVICE_TS, + 'src/consumer.ts': CONSUMER_TS, + }); + result = await runPipelineFromRepo(repoRoot, () => undefined, { + skipGraphPhases: true, + skipWorkers: true, + }); + }, 60000); + + afterAll(() => removeFixture(repoRoot)); + + it('emits Const:fooService, Method:getUser, Function:caller exactly once', () => { + expect(getNodesByLabel(result, 'Const').filter((n) => n === 'fooService').length).toBe(1); + expect(getNodesByLabel(result, 'Method').filter((n) => n === 'getUser').length).toBe(1); + expect(getNodesByLabel(result, 'Function').filter((n) => n === 'caller').length).toBe(1); + }); + + it('emits exactly the expected HAS_METHOD edges from fooService', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const fromFoo = hasMethod + .filter((e) => e.source === 'fooService') + .map((e) => e.target) + .sort(); + expect(fromFoo).toEqual(['getUser', 'saveUser']); + }); + + it('the fooService Const node uses the expected graph node ID', () => { + const expectedNodeId = generateId('Const', 'src/service.ts:fooService'); + let fooServiceNode: { id: string; label: string } | undefined; + result.graph.forEachNode((n) => { + if (n.label === 'Const' && n.properties.name === 'fooService') { + fooServiceNode = { id: n.id, label: n.label }; + } + }); + expect(fooServiceNode).toBeDefined(); + expect(fooServiceNode!.id).toBe(expectedNodeId); + }); + + it('emits a CALLS edge from caller to getUser with the expected target/confidence/reason (issue #1358 fix)', () => { + const calls = getRelationships(result, 'CALLS'); + const callerToGetUser = calls + .filter((e) => e.source === 'caller' && e.target === 'getUser') + .map((e) => ({ + targetId: e.rel.targetId, + confidence: e.rel.confidence, + reason: e.rel.reason, + })); + + // The Method node id encodes arity disambiguation (#1 = one-arity overload). + // Pin the canonical id so a regression that targets a phantom node fails. + const expectedTargetId = generateId('Method', 'src/service.ts:getUser#1'); + expect(callerToGetUser).toEqual([ + { + targetId: expectedTargetId, + confidence: 0.85, + reason: 'import-resolved', + }, + ]); + }); +}); + +// ── Test B: worker-mode parity ─────────────────────────────────────────────── + +describe.skipIf(!hasDistWorker)('object-literal owner resolution — worker parity', () => { + let repoRoot: string; + let sequentialResult: PipelineResult; + let workerResult: PipelineResult; + + beforeAll(async () => { + repoRoot = writeFixture({ + 'src/service.ts': SERVICE_TS, + 'src/consumer.ts': CONSUMER_TS, + }); + sequentialResult = await runPipelineFromRepo(repoRoot, () => undefined, { + skipGraphPhases: true, + skipWorkers: true, + }); + workerResult = await runPipelineFromRepo(repoRoot, () => undefined, { + skipGraphPhases: true, + skipWorkers: false, + workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, + }); + }, 90000); + + afterAll(() => removeFixture(repoRoot)); + + it('produces the same HAS_METHOD edge set as sequential', () => { + const seqEdges = getRelationships(sequentialResult, 'HAS_METHOD') + .map((e) => `${e.source}->${e.target}`) + .sort(); + const workerEdges = getRelationships(workerResult, 'HAS_METHOD') + .map((e) => `${e.source}->${e.target}`) + .sort(); + expect(workerEdges).toEqual(seqEdges); + }); + + it('produces the same CALLS edge set as sequential', () => { + const seqEdges = getRelationships(sequentialResult, 'CALLS') + .map((e) => `${e.source}->${e.target}`) + .sort(); + const workerEdges = getRelationships(workerResult, 'CALLS') + .map((e) => `${e.source}->${e.target}`) + .sort(); + expect(workerEdges).toEqual(seqEdges); + }); +}); + +// ── Test C: negative — local object literal inside a function body ────────── + +describe('object-literal owner resolution — negative (local literal)', () => { + let repoRoot: string; + let result: PipelineResult; + + beforeAll(async () => { + repoRoot = writeFixture({ + 'src/p.ts': `export function processAll() { + const handler = { run(id: string) { return id; } }; + return handler; +} +`, + }); + result = await runPipelineFromRepo(repoRoot, () => undefined, { + skipGraphPhases: true, + skipWorkers: true, + }); + }, 60000); + + afterAll(() => removeFixture(repoRoot)); + + it('emits no HAS_METHOD edge targeting `run` (no false-positive owner attribution)', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const targetingRun = hasMethod.filter((e) => e.target === 'run'); + expect(targetingRun.length).toBe(0); + }); + + it('the run method node carries no ownerId property', () => { + let runNode: { properties: { name: string; ownerId?: string }; label: string } | undefined; + result.graph.forEachNode((n) => { + if (n.label === 'Method' && n.properties.name === 'run') { + runNode = n as typeof runNode; + } + }); + expect(runNode).toBeDefined(); + expect(runNode!.properties.ownerId).toBe(undefined); + }); +}); + +// ── Test D: negative — nested object literal ───────────────────────────────── + +describe('object-literal owner resolution — negative (nested literal)', () => { + let repoRoot: string; + let result: PipelineResult; + + beforeAll(async () => { + repoRoot = writeFixture({ + 'src/n.ts': `export const s = { + nested: { method(id: string) { return id; } }, + outer(id: string) { return id; }, +}; +`, + }); + result = await runPipelineFromRepo(repoRoot, () => undefined, { + skipGraphPhases: true, + skipWorkers: true, + }); + }, 60000); + + afterAll(() => removeFixture(repoRoot)); + + it('binds the top-level outer method to s but does NOT bind the nested method', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const fromS = hasMethod + .filter((e) => e.source === 's') + .map((e) => e.target) + .sort(); + expect(fromS).toEqual(['outer']); + }); +}); diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 296f3e4581..c7dff09475 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -87,6 +87,33 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly required (2>1) on candidate with default param emits NO edge post-fix', 'variadic candidate, argCount < required (1<2) emits NO edge', ]), + typescript: new Set([ + // Issue #1358 sub-cases: class-instance singleton (`export const foo = new Foo()`) + // and factory-pattern singleton (`export const foo = makeFoo()`) cross-file + // CALLS resolution. The scope-resolution path resolves these via + // `@type-binding.constructor` capture (TS query) + + // `propagateImportedReturnTypes` mirror + receiver-bound Case 4 simple + // typeBinding lookup. The legacy DAG's typeEnv does not propagate + // `new Foo()` constructor inference across module boundaries — verified + // by `scope-parity / typescript parity` CI job failure. Node-existence + // and HAS_METHOD edge assertions pass under legacy DAG (parser-level + // emission is intact); only the cross-file CALLS edge resolution + // requires the scope-resolution chain. Scope-resolver-only correctness + // wins; backporting requires constructor-typeBinding cross-file + // propagation in the legacy DAG. + 'resolves caller.fooService.getUser() to FooService.getUser via constructor-inferred typeBinding', + 'resolves caller.fooService.getUser() through the factory chain to FooService.getUser', + ]), + javascript: new Set([ + // Mirrors the TypeScript class-instance and factory-pattern singleton + // resolution gates above. JavaScript fails on the same 2 CALLS-edge + // resolution tests under `REGISTRY_PRIMARY_JAVASCRIPT=0` for the same + // reason — no cross-file constructor-typeBinding propagation in the + // legacy DAG path. Verified by `scope-parity / javascript parity` CI + // job failure on the bare singleton tests before this exclusion landed. + 'resolves caller.fooService.getUser() to FooService.getUser via constructor-inferred typeBinding', + 'resolves caller.fooService.getUser() through the factory chain to FooService.getUser', + ]), python: new Set([ // Suffix-fallback lex tiebreak depends on the registry-primary // resolver's deterministic sort. The legacy resolver returns the diff --git a/gitnexus/test/integration/resolvers/javascript.test.ts b/gitnexus/test/integration/resolvers/javascript.test.ts index ccb4df6186..25316ac6dc 100644 --- a/gitnexus/test/integration/resolvers/javascript.test.ts +++ b/gitnexus/test/integration/resolvers/javascript.test.ts @@ -1,11 +1,12 @@ /** * JavaScript: self/this resolution, parent resolution, super resolution */ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, expect, beforeAll } from 'vitest'; import path from 'path'; import { FIXTURES, CROSS_FILE_FIXTURES, + createResolverParityIt, getRelationships, getNodesByLabel, getNodesByLabelFull, @@ -14,6 +15,13 @@ import { type PipelineResult, } from './helpers.js'; +// Shadow vitest's `it` with the parity-gated runner so tests listed in +// `LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.javascript` (helpers.ts) skip +// under `REGISTRY_PRIMARY_JAVASCRIPT=0` (legacy DAG mode) and run normally +// under the default registry-primary path. The scope-parity CI gate +// requires this for the issue #1358 singleton describes below. +const it = createResolverParityIt('javascript'); + // --------------------------------------------------------------------------- // skipGraphPhases: verify pipeline works correctly when graph phases are skipped // --------------------------------------------------------------------------- @@ -541,3 +549,101 @@ describe('JavaScript Child extends Parent — inherited method resolution (SM-9) expect(parentMethodCall!.source).toBe('run'); }); }); + +// --------------------------------------------------------------------------- +// Issue #1358: class-instance singleton (`export const x = new C()`) +// PR #1718 closed the object-literal-shorthand sub-case; this fixture covers +// the class-instance sub-case for JavaScript. Same resolution chain as TS but +// the receiver type comes from the `new ClassName()` initializer (no JSDoc +// annotation needed — the @type-binding.constructor capture handles it). +// --------------------------------------------------------------------------- + +describe('JavaScript class-instance singleton resolution (issue #1358 sub-case)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'javascript-class-instance-singleton'), + () => {}, + { skipGraphPhases: true }, + ); + }, 60000); + + it('detects FooService class, getUser method, caller function, fooService Const', () => { + expect(getNodesByLabel(result, 'Class')).toContain('FooService'); + expect(getNodesByLabel(result, 'Method')).toContain('getUser'); + expect(getNodesByLabel(result, 'Function')).toContain('caller'); + expect(getNodesByLabel(result, 'Const')).toContain('fooService'); + }); + + it('emits HAS_METHOD edge from FooService to getUser', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const fromClass = hasMethod.filter((e) => e.source === 'FooService').map((e) => e.target); + expect(fromClass).toEqual(['getUser']); + }); + + it('resolves caller.fooService.getUser() to FooService.getUser via constructor-inferred typeBinding', () => { + const calls = getRelationships(result, 'CALLS'); + const projected = calls + .filter((e) => e.source === 'caller' && e.target === 'getUser') + .map((e) => ({ + targetFilePath: e.targetFilePath, + reason: e.rel.reason, + confidence: e.rel.confidence, + })); + + expect(projected).toEqual([ + { + targetFilePath: 'src/service.js', + reason: 'import-resolved', + confidence: 0.85, + }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Issue #1358: factory-pattern singleton (`export const x = makeC()`) +// Tests the @type-binding.alias chain-follow for JS — fooService aliases the +// return of makeFooService(), whose JSDoc @returns {FooService} ties the chain +// back to the class. Resolution propagates cross-file via +// propagateImportedReturnTypes followChainPostFinalize. +// --------------------------------------------------------------------------- + +describe('JavaScript factory-pattern singleton resolution (issue #1358 sub-case)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'javascript-factory-singleton'), + () => {}, + { skipGraphPhases: true }, + ); + }, 60000); + + it('detects FooService class, makeFooService function, fooService Const, caller function', () => { + expect(getNodesByLabel(result, 'Class')).toContain('FooService'); + expect(getNodesByLabel(result, 'Function')).toContain('makeFooService'); + expect(getNodesByLabel(result, 'Function')).toContain('caller'); + expect(getNodesByLabel(result, 'Const')).toContain('fooService'); + }); + + it('resolves caller.fooService.getUser() through the factory chain to FooService.getUser', () => { + const calls = getRelationships(result, 'CALLS'); + const projected = calls + .filter((e) => e.source === 'caller' && e.target === 'getUser') + .map((e) => ({ + targetFilePath: e.targetFilePath, + reason: e.rel.reason, + confidence: e.rel.confidence, + })); + + expect(projected).toEqual([ + { + targetFilePath: 'src/service.js', + reason: 'import-resolved', + confidence: 0.85, + }, + ]); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index 8b6e50d20e..88dceda832 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -1,12 +1,13 @@ /** * TypeScript: heritage resolution + ambiguous symbol disambiguation */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, expect, beforeAll, afterAll } from 'vitest'; import path from 'path'; import fs from 'node:fs'; import os from 'node:os'; import { FIXTURES, + createResolverParityIt, getRelationships, getNodesByLabel, getNodesByLabelFull, @@ -15,6 +16,13 @@ import { type PipelineResult, } from './helpers.js'; +// Shadow vitest's `it` with the parity-gated runner so tests listed in +// `LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.typescript` (helpers.ts) skip +// under `REGISTRY_PRIMARY_TYPESCRIPT=0` (legacy DAG mode) and run normally +// under the default registry-primary path. The scope-parity CI gate +// requires this for the issue #1358 singleton describes below. +const it = createResolverParityIt('typescript'); + function writeFixtureRepo(root: string, files: Record): void { for (const [relPath, content] of Object.entries(files)) { const fullPath = path.join(root, relPath); @@ -2936,3 +2944,100 @@ export function createUtf8User(): void { } }); }); + +// --------------------------------------------------------------------------- +// Issue #1358: class-instance singleton (`export const x = new C()`) +// PR #1718 closed the object-literal-shorthand sub-case; this fixture covers +// the class-instance sub-case. Resolution chain: @type-binding.constructor +// (TS query) → propagateImportedReturnTypes (cross-file mirror) → +// receiver-bound Case 4 (simple typeBinding) → MRO walk. +// --------------------------------------------------------------------------- + +describe('TypeScript class-instance singleton resolution (issue #1358 sub-case)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-class-instance-singleton'), + () => {}, + { skipGraphPhases: true }, + ); + }, 60000); + + it('detects FooService class, getUser method, caller function, fooService Const', () => { + expect(getNodesByLabel(result, 'Class')).toContain('FooService'); + expect(getNodesByLabel(result, 'Method')).toContain('getUser'); + expect(getNodesByLabel(result, 'Function')).toContain('caller'); + expect(getNodesByLabel(result, 'Const')).toContain('fooService'); + }); + + it('emits HAS_METHOD edge from FooService to getUser', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const fromClass = hasMethod.filter((e) => e.source === 'FooService').map((e) => e.target); + expect(fromClass).toEqual(['getUser']); + }); + + it('resolves caller.fooService.getUser() to FooService.getUser via constructor-inferred typeBinding', () => { + const calls = getRelationships(result, 'CALLS'); + const projected = calls + .filter((e) => e.source === 'caller' && e.target === 'getUser') + .map((e) => ({ + targetFilePath: e.targetFilePath, + reason: e.rel.reason, + confidence: e.rel.confidence, + })); + + expect(projected).toEqual([ + { + targetFilePath: 'src/service.ts', + reason: 'import-resolved', + confidence: 0.85, + }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Issue #1358: factory-pattern singleton (`export const x = makeC()`) +// Tests the @type-binding.alias chain-follow path through +// propagateImportedReturnTypes (followChainPostFinalize) — fooService aliases +// makeFooService's return type, which the constructor seeds as FooService. +// --------------------------------------------------------------------------- + +describe('TypeScript factory-pattern singleton resolution (issue #1358 sub-case)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-factory-singleton'), + () => {}, + { skipGraphPhases: true }, + ); + }, 60000); + + it('detects FooService class, makeFooService function, fooService Const, caller function', () => { + expect(getNodesByLabel(result, 'Class')).toContain('FooService'); + expect(getNodesByLabel(result, 'Function')).toContain('makeFooService'); + expect(getNodesByLabel(result, 'Function')).toContain('caller'); + expect(getNodesByLabel(result, 'Const')).toContain('fooService'); + }); + + it('resolves caller.fooService.getUser() through the factory chain to FooService.getUser', () => { + const calls = getRelationships(result, 'CALLS'); + const projected = calls + .filter((e) => e.source === 'caller' && e.target === 'getUser') + .map((e) => ({ + targetFilePath: e.targetFilePath, + reason: e.rel.reason, + confidence: e.rel.confidence, + })); + + expect(projected).toEqual([ + { + targetFilePath: 'src/service.ts', + reason: 'import-resolved', + confidence: 0.85, + }, + ]); + }); +}); diff --git a/gitnexus/test/unit/parsing-worker-fallback.test.ts b/gitnexus/test/unit/parsing-worker-fallback.test.ts index ec90b64b13..04aa413c24 100644 --- a/gitnexus/test/unit/parsing-worker-fallback.test.ts +++ b/gitnexus/test/unit/parsing-worker-fallback.test.ts @@ -143,3 +143,47 @@ describe('processParsing — worker-pool error propagation (U20)', () => { expect(progressDetails).toContain('1 worker-quarantined file(s) skipped'); }); }); + +describe('TypeScript object literal method exports', () => { + it('links exported object literal shorthand methods back to the exported object', async () => { + const graph = createKnowledgeGraph(); + + await processParsing( + graph, + [ + { + path: 'src/foo.ts', + content: `export const fooService = { + async getUser(id: string) { + return findUser(id); + }, + saveUser(user: User) { + return persist(user); + }, +}; +`, + }, + ], + createSymbolTable(), + createASTCache(), + createASTCache(), + ); + + const service = graph.nodes.find( + (node) => node.label === 'Const' && node.properties.name === 'fooService', + ); + expect(service, 'exported object literal should be captured as a Const').toBeDefined(); + + const methodNames = new Set( + graph.nodes.filter((node) => node.label === 'Method').map((node) => node.properties.name), + ); + expect(methodNames).toEqual(new Set(['getUser', 'saveUser'])); + + const linkedMethodNames = graph.relationships + .filter((rel) => rel.type === 'HAS_METHOD' && rel.sourceId === service!.id) + .map((rel) => graph.getNode(rel.targetId)?.properties.name) + .sort(); + + expect(linkedMethodNames).toEqual(['getUser', 'saveUser']); + }); +});