From eb2ba3dcadc58833137fe3a4c6e6ef1b97eb7e89 Mon Sep 17 00:00:00 2001 From: evolution Date: Tue, 2 Jun 2026 16:27:44 +0800 Subject: [PATCH] feat(go): infer structural interface implementations (#1966) --- gitnexus/bench/scope-capture/baselines.json | 4 +- .../ingestion/languages/go/arity-metadata.ts | 31 +- .../core/ingestion/languages/go/captures.ts | 10 +- .../ingestion/languages/go/interface-impls.ts | 631 +++++++++-- .../core/ingestion/languages/go/interpret.ts | 5 +- .../ingestion/languages/go/method-owners.ts | 28 +- .../src/core/ingestion/languages/go/query.ts | 4 + .../ingestion/languages/go/range-binding.ts | 9 +- .../languages/go/receiver-binding.ts | 2 +- .../ingestion/languages/go/scope-resolver.ts | 4 +- .../ingestion/languages/go/simple-hooks.ts | 16 +- .../ingestion/languages/go/type-binding.ts | 36 + .../passes/receiver-bound-calls.ts | 2 +- .../scope-resolution/pipeline/run.ts | 86 +- .../scope-resolution/scope/walkers.ts | 16 + .../src/core/ingestion/tree-sitter-queries.ts | 1 + .../src/core/ingestion/utils/ast-helpers.ts | 5 + .../go-captures-golden/expected-captures.json | 158 +-- .../api/repository.go | 14 + .../cmd/main.go | 20 + .../contracts/read_closer.go | 8 + .../go.mod | 3 + .../impl/file.go | 17 + .../other/user.go | 5 + .../store/repository.go | 26 + .../repository.go | 102 ++ .../integration/go-pipeline-benchmark.test.ts | 267 ++++- .../test/integration/resolvers/go.test.ts | 150 +++ .../test/integration/resolvers/helpers.ts | 14 + .../go/go-captures-smoke.test.ts | 47 +- .../unit/scope-resolution/go/go-hooks.test.ts | 987 +++++++++++++++++- .../go/go-type-binding.test.ts | 23 +- 32 files changed, 2562 insertions(+), 169 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/api/repository.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/cmd/main.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/contracts/read_closer.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/go.mod create mode 100644 gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/impl/file.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/other/user.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/store/repository.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-structural-interface-dispatch/repository.go diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 113e42b0d..704e9381d 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -1,9 +1,9 @@ { "_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/-* fixture corpus + a 20-entity synthetic source (correctness gate; re-baseline intentionally on a legitimate capture change). scaling_budget = max allowed (t800/t250)/(800/250); ~1.0 is linear, ~3.2 is quadratic. The synthetic source is now HERITAGE-BEARING for every language (each Entity extends/implements/embeds/uses-trait/conforms-to a shared base) so the #1951 @reference.inherits synth is gated at scale, not just the base capture loop. All languages thread the tree-sitter captured node instead of re-deriving it with findNodeAtRange(tree.rootNode,...) per match, so all are linear (go #1915, python #1918, ruby/php/rust/csharp #1951, java #1956).", "go": { - "fingerprint": "976bfd17cee048db11e06a27298e48919b7d45d5277d11923faeb61b138737dd", + "fingerprint": "a909c197b07921f974de1a8a47cc997b9580153db2d400ef13d205b6c1de5865", "scaling_budget": 1.5, - "_rebaselined": "#1956 synth-widening: + go-qualified-base fixture; synthesizeGoInheritanceReferences now emits embeds for qualified_type (pkg.Base), generic_type (Box[T]), pointer, AND interface_type embeds (matching the #1940 legacy leg), reduced to bare names at parity. go-ambiguous gains an embed inherits capture. Linear (~1.01). (Earlier #1956: heritage-bearing scale source so the synth is gated at scale.)" + "_rebaselined": "#1966: Go structural interface implementation detection changes capture output (method_elem, return-type, pointer receiver raw form, struct_type/interface_type containers)." }, "cobol": { "fingerprint": "68ee0e95eb9f86f2d92ca35f730f4c2d4d83abc1b5241ae767ff3437780ec8d1", diff --git a/gitnexus/src/core/ingestion/languages/go/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/go/arity-metadata.ts index 958d0e70e..b8b546b0d 100644 --- a/gitnexus/src/core/ingestion/languages/go/arity-metadata.ts +++ b/gitnexus/src/core/ingestion/languages/go/arity-metadata.ts @@ -4,12 +4,21 @@ export interface GoArityMetadata { readonly parameterCount?: number; readonly requiredParameterCount?: number; readonly parameterTypes?: readonly string[]; + readonly returnType?: string; } export function computeGoDeclarationArity(node: SyntaxNode): GoArityMetadata { const params = node.childForFieldName('parameters'); - if (params === null) return {}; + const returnType = extractGoReturnType(node); + if (params === null) return returnType === undefined ? {} : { returnType }; + return { + ...computeGoParameterMetadata(params), + ...(returnType === undefined ? {} : { returnType }), + }; +} + +function computeGoParameterMetadata(params: SyntaxNode): GoArityMetadata { let count = 0; let required = 0; const types: string[] = []; @@ -44,3 +53,23 @@ export function computeGoCallArity(callNode: SyntaxNode): number { if (args === null) return 0; return args.namedChildCount; } + +function extractGoReturnType(node: SyntaxNode): string | undefined { + const result = node.childForFieldName('result'); + if (result === null) return undefined; + if (result.type !== 'parameter_list') return result.text; + + const returnTypes: string[] = []; + for (const child of result.namedChildren) { + if (child.type !== 'parameter_declaration') continue; + const typeNode = child.childForFieldName('type'); + if (typeNode === null) continue; + const names = child.namedChildren.filter((c) => c.type === 'identifier'); + const n = Math.max(1, names.length); + for (let i = 0; i < n; i++) { + returnTypes.push(typeNode.text); + } + } + if (returnTypes.length === 0) return undefined; + return returnTypes.length === 1 ? returnTypes[0] : `(${returnTypes.join(', ')})`; +} diff --git a/gitnexus/src/core/ingestion/languages/go/captures.ts b/gitnexus/src/core/ingestion/languages/go/captures.ts index f835bc79a..753a03ea9 100644 --- a/gitnexus/src/core/ingestion/languages/go/captures.ts +++ b/gitnexus/src/core/ingestion/languages/go/captures.ts @@ -88,7 +88,8 @@ export function emitGoScopeCaptures( // the function_declaration / method_declaration node. const fnNode = declAnchorNode.type === 'function_declaration' || - declAnchorNode.type === 'method_declaration' + declAnchorNode.type === 'method_declaration' || + declAnchorNode.type === 'method_elem' ? declAnchorNode : null; if (fnNode !== null) { @@ -114,6 +115,13 @@ export function emitGoScopeCaptures( JSON.stringify(arity.parameterTypes), ); } + if (arity.returnType !== undefined) { + grouped['@declaration.return-type'] = syntheticCapture( + '@declaration.return-type', + fnNode, + arity.returnType, + ); + } } out.push(grouped); continue; diff --git a/gitnexus/src/core/ingestion/languages/go/interface-impls.ts b/gitnexus/src/core/ingestion/languages/go/interface-impls.ts index bf21117a1..b8e26892b 100644 --- a/gitnexus/src/core/ingestion/languages/go/interface-impls.ts +++ b/gitnexus/src/core/ingestion/languages/go/interface-impls.ts @@ -1,87 +1,598 @@ -import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import type { ParsedFile, ReferenceSite, SymbolDefinition } from 'gitnexus-shared'; import type { SemanticModel } from '../../model/semantic-model.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { simpleQualifiedName } from '../../scope-resolution/graph-bridge/ids.js'; +import { resolveInheritanceBaseInScope } from '../../scope-resolution/scope/walkers.js'; + +type MethodSet = ReadonlyMap; +type MutableMethodSet = Map; +type MethodSetEntry = { + readonly overloads: readonly SymbolDefinition[]; + readonly depth: number; + readonly ambiguous: boolean; +}; +type MutableMethodSetEntries = Map; +type GoMethodDefinition = SymbolDefinition & { readonly goReceiverKind?: 'value' | 'pointer' }; +type SignatureContext = { + readonly packageQualifier: string | undefined; + readonly importQualifiers: ReadonlyMap; +}; +type DetectionIndexes = { + readonly interfaces: readonly SymbolDefinition[]; + readonly structsById: ReadonlyMap; + readonly methodsByOwner: ReadonlyMap; + readonly effectiveMethodsByStructId: ReadonlyMap; + readonly interfaceById: ReadonlyMap; + readonly interfaceOwnMethodsById: ReadonlyMap; + readonly embeddedSitesByInterfaceId: ReadonlyMap; + readonly parentStructIdsByStructId: ReadonlyMap; + readonly structIdsByMethodName: ReadonlyMap>; + readonly signatureContextByDefId: ReadonlyMap; + readonly scopeIndexes: ScopeResolutionIndexes; +}; export function detectGoInterfaceImplementations( parsedFiles: readonly ParsedFile[], _indexes: ScopeResolutionIndexes, _model: SemanticModel, ): Map { - // 1. Collect interface defs → method names (from scope.ownedDefs) - const interfaceMethods = new Map>(); - const interfaceDefsById = new Map(); + return detectGoInterfaceImplementationsFromIndexes(buildDetectionIndexes(parsedFiles, _indexes)); +} - // 2. Collect struct defs → method names - const structMethods = new Map>(); +function buildDetectionIndexes( + parsedFiles: readonly ParsedFile[], + indexes: ScopeResolutionIndexes, +): DetectionIndexes { + const interfaces: SymbolDefinition[] = []; + const structsById = new Map(); + const methodsByOwner = new Map>(); + const effectiveMethodsByStructId = new Map(); + const interfaceById = new Map(); + const interfaceOwnMethodsById = new Map(); + const embeddedSitesByInterfaceId = new Map(); + const parentStructIdsByStructId = new Map(); + const structIdsByMethodName = new Map>(); + const signatureContextByDefId = new Map(); + const interfaceIdByScopeId = new Map(); + const structIdByScopeId = new Map(); for (const parsed of parsedFiles) { - // Collect interface defs and their owned methods - for (const scope of parsed.scopes) { - if (scope.kind !== 'Class') continue; - - // Find the type def for this scope - const typeDef = scope.ownedDefs.find((d) => d.type === 'Interface' || d.type === 'Struct'); - if (typeDef === undefined) continue; - - if (typeDef.type === 'Interface') { - interfaceDefsById.set(typeDef.nodeId, typeDef); - const methodNames = new Set(); - // Methods are in child scopes (Function kind) or ownedDefs - for (const childScope of parsed.scopes) { - if (childScope.parent === scope.id && childScope.kind === 'Function') { - for (const def of childScope.ownedDefs) { - if (def.type === 'Method' || def.type === 'Function') { - methodNames.add(def.qualifiedName?.split('.').pop() ?? ''); - } - } - } - } - // Also check if methods have ownerId pointing to this interface - for (const def of parsed.localDefs) { - if ( - (def as { ownerId?: string }).ownerId === typeDef.nodeId && - (def.type === 'Method' || def.type === 'Function') - ) { - methodNames.add(def.qualifiedName?.split('.').pop() ?? ''); - } - } - interfaceMethods.set(typeDef.nodeId, methodNames); + const signatureContext = signatureContextForFile(parsed, indexes); + for (const def of parsed.localDefs) { + signatureContextByDefId.set(def.nodeId, signatureContext); + if (def.type === 'Interface') { + interfaces.push(def); + interfaceById.set(def.nodeId, def); + continue; } - - if (typeDef.type === 'Struct') { - const methodNames = new Set(); - for (const def of parsed.localDefs) { - if ( - (def as { ownerId?: string }).ownerId === typeDef.nodeId && - (def.type === 'Method' || def.type === 'Function') - ) { - methodNames.add(def.qualifiedName?.split('.').pop() ?? ''); - } - } - structMethods.set(typeDef.nodeId, methodNames); + if (def.type === 'Struct') { + structsById.set(def.nodeId, def); + continue; } + if (def.type !== 'Method' && def.type !== 'Function') continue; + if (def.ownerId === undefined) continue; + if (isPointerReceiverMethod(def)) continue; + const methodName = simpleQualifiedName(def); + if (methodName === undefined || methodName.length === 0) continue; + + addMethod(methodsByOwner, def.ownerId, methodName, def); } } - // 3. For each interface, find structs whose method set is a superset - const impls = new Map(); - for (const [ifaceId, ifaceMethods] of interfaceMethods) { - if (ifaceMethods.size === 0) continue; + for (const parsed of parsedFiles) { + for (const scope of parsed.scopes) { + const iface = scope.ownedDefs.find((def) => def.type === 'Interface'); + if (iface !== undefined) interfaceIdByScopeId.set(scope.id, iface.nodeId); + const struct = scope.ownedDefs.find((def) => def.type === 'Struct'); + if (struct !== undefined) structIdByScopeId.set(scope.id, struct.nodeId); + } + } + + for (const parsed of parsedFiles) { + const childScopesByParent = new Map(); + for (const scope of parsed.scopes) { + if (scope.parent === null) continue; + const children = childScopesByParent.get(scope.parent) ?? []; + children.push(scope); + childScopesByParent.set(scope.parent, children); + } + + for (const scope of parsed.scopes) { + const ifaceId = interfaceIdByScopeId.get(scope.id); + if (ifaceId === undefined) continue; + const methods = new Map(); + for (const childScope of childScopesByParent.get(scope.id) ?? []) { + for (const def of childScope.ownedDefs) { + if (def.type !== 'Method' && def.type !== 'Function') continue; + const methodName = simpleQualifiedName(def); + if (methodName === undefined || methodName.length === 0) continue; + addMethodOverload(methods, methodName, def); + } + } + interfaceOwnMethodsById.set(ifaceId, methods); + } + + for (const site of parsed.referenceSites) { + if (site.kind !== 'inherits') continue; + const ifaceId = interfaceIdByScopeId.get(site.inScope); + if (ifaceId !== undefined) { + const sites = embeddedSitesByInterfaceId.get(ifaceId) ?? []; + sites.push(site); + embeddedSitesByInterfaceId.set(ifaceId, sites); + continue; + } + + const structId = structIdByScopeId.get(site.inScope); + if (structId === undefined) continue; + const parent = resolveInheritanceBaseInScope(site.inScope, site.name, indexes); + if (parent === undefined || parent.type !== 'Struct') continue; + addParentStruct(parentStructIdsByStructId, structId, parent.nodeId); + } + } + + const structMethodSetCache = new Map(); + for (const structId of structsById.keys()) { + const effective = collectStructMethodSet( + structId, + { + parentStructIdsByStructId, + methodsByOwner, + }, + new Set(), + structMethodSetCache, + ); + if (effective === undefined) continue; + effectiveMethodsByStructId.set(structId, effective); + for (const methodName of effective.keys()) { + addStructMethodCandidate(structIdsByMethodName, methodName, structId); + } + } + + return { + interfaces, + structsById, + methodsByOwner, + effectiveMethodsByStructId, + interfaceById, + interfaceOwnMethodsById, + embeddedSitesByInterfaceId, + parentStructIdsByStructId, + structIdsByMethodName, + signatureContextByDefId, + scopeIndexes: indexes, + }; +} + +function detectGoInterfaceImplementationsFromIndexes( + indexes: DetectionIndexes, +): Map { + const implementations = new Map(); + const methodSetCache = new Map(); + for (const iface of indexes.interfaces) { + const required = collectInterfaceMethodSet(iface, indexes, new Set(), methodSetCache); + if (required === undefined || required.size === 0) continue; + if (!methodSetHasVerifiableSignatures(required)) continue; + const implementors: string[] = []; - for (const [structId, methods] of structMethods) { - if (isSuperset(methods, ifaceMethods)) { + for (const structId of candidateStructIdsFor(required, indexes)) { + const actual = indexes.effectiveMethodsByStructId.get(structId); + if (actual === undefined) continue; + if (methodSetSatisfies(actual, required, indexes.signatureContextByDefId)) { implementors.push(structId); } } - if (implementors.length > 0) impls.set(ifaceId, implementors); + if (implementors.length > 0) implementations.set(iface.nodeId, implementors); } - return impls; + return implementations; } -function isSuperset(superset: Set, subset: Set): boolean { - for (const item of subset) { - if (!superset.has(item)) return false; +function addMethod( + methodsByOwner: Map>, + ownerId: string, + methodName: string, + def: SymbolDefinition, +): void { + let methods = methodsByOwner.get(ownerId); + if (methods === undefined) { + methods = new Map(); + methodsByOwner.set(ownerId, methods); + } + addMethodOverload(methods, methodName, def); +} + +function addMethodOverload( + methods: Map, + methodName: string, + def: SymbolDefinition, +): void { + const overloads = methods.get(methodName) ?? []; + overloads.push(def); + methods.set(methodName, overloads); +} + +function addStructMethodCandidate( + structIdsByMethodName: Map>, + methodName: string, + structId: string, +): void { + const structIds = structIdsByMethodName.get(methodName) ?? new Set(); + structIds.add(structId); + structIdsByMethodName.set(methodName, structIds); +} + +function addParentStruct( + parentStructIdsByStructId: Map, + structId: string, + parentStructId: string, +): void { + const parents = parentStructIdsByStructId.get(structId) ?? []; + parents.push(parentStructId); + parentStructIdsByStructId.set(structId, parents); +} + +function collectStructMethodSet( + structId: string, + indexes: Pick, + visiting: Set, + cache: Map, +): MutableMethodSet | undefined { + const entries = collectStructMethodEntries(structId, indexes, visiting, cache); + return entries === undefined ? undefined : methodEntriesToMethodSet(entries); +} + +function collectStructMethodEntries( + structId: string, + indexes: Pick, + visiting: Set, + cache: Map, +): MutableMethodSetEntries | undefined { + const cached = cache.get(structId); + if (cached !== undefined) return cloneMethodEntries(cached); + if (visiting.has(structId)) return undefined; + visiting.add(structId); + + const merged = directMethodEntries(indexes.methodsByOwner.get(structId)); + + for (const parentStructId of indexes.parentStructIdsByStructId.get(structId) ?? []) { + const parentEntries = collectStructMethodEntries(parentStructId, indexes, visiting, cache); + if (parentEntries === undefined) { + visiting.delete(structId); + return undefined; + } + for (const [methodName, entry] of parentEntries) { + if (entry.ambiguous) continue; + mergePromotedMethodEntry(merged, methodName, { + overloads: entry.overloads, + depth: entry.depth + 1, + ambiguous: false, + }); + } + } + + visiting.delete(structId); + cache.set(structId, cloneMethodEntries(merged)); + return merged; +} + +function collectInterfaceMethodSet( + iface: SymbolDefinition, + indexes: DetectionIndexes, + visiting: Set, + cache: Map, +): MutableMethodSet | undefined { + const cached = cache.get(iface.nodeId); + if (cached !== undefined) return cloneMethodSet(cached); + if (visiting.has(iface.nodeId)) return undefined; + visiting.add(iface.nodeId); + + const ownMethods = + indexes.methodsByOwner.get(iface.nodeId) ?? indexes.interfaceOwnMethodsById.get(iface.nodeId); + const merged = cloneMethodSet(ownMethods); + + const embeddedInterfaces = embeddedInterfacesFor(iface, indexes); + if (embeddedInterfaces === undefined) { + visiting.delete(iface.nodeId); + return undefined; + } + + for (const embeddedIface of embeddedInterfaces) { + const embeddedMethods = collectInterfaceMethodSet(embeddedIface, indexes, visiting, cache); + if (embeddedMethods === undefined) { + visiting.delete(iface.nodeId); + return undefined; + } + mergeMethodSet(merged, embeddedMethods); + } + + visiting.delete(iface.nodeId); + cache.set(iface.nodeId, cloneMethodSet(merged)); + return merged; +} + +function embeddedInterfacesFor( + iface: SymbolDefinition, + indexes: DetectionIndexes, +): SymbolDefinition[] | undefined { + const embedded: SymbolDefinition[] = []; + for (const site of indexes.embeddedSitesByInterfaceId.get(iface.nodeId) ?? []) { + const resolved = resolveEmbeddedInterface(site, indexes); + if (resolved === undefined) return undefined; + embedded.push(resolved); + } + return embedded; +} + +function candidateStructIdsFor(required: MethodSet, indexes: DetectionIndexes): readonly string[] { + let best: ReadonlySet | undefined; + for (const name of required.keys()) { + const candidates = indexes.structIdsByMethodName.get(name); + if (candidates === undefined) return []; + if (best === undefined || candidates.size < best.size) best = candidates; + } + return best === undefined ? [...indexes.structsById.keys()] : [...best]; +} + +function resolveEmbeddedInterface( + site: ReferenceSite, + indexes: DetectionIndexes, +): SymbolDefinition | undefined { + const bound = resolveInheritanceBaseInScope(site.inScope, site.name, indexes.scopeIndexes); + if (bound !== undefined) return bound.type === 'Interface' ? bound : undefined; + + const simpleName = simpleTypeName(site.name); + const matches: SymbolDefinition[] = []; + for (const iface of indexes.interfaceById.values()) { + if (iface.qualifiedName === site.name || iface.qualifiedName === simpleName) { + matches.push(iface); + } + } + return matches.length === 1 ? matches[0] : undefined; +} + +function simpleTypeName(name: string): string { + const dot = name.lastIndexOf('.'); + return dot === -1 ? name : name.slice(dot + 1); +} + +function cloneMethodSet(methods: MethodSet | undefined): MutableMethodSet { + const clone = new Map(); + if (methods === undefined) return clone; + for (const [name, overloads] of methods) { + clone.set(name, [...overloads]); + } + return clone; +} + +function directMethodEntries(methods: MethodSet | undefined): MutableMethodSetEntries { + const entries = new Map(); + if (methods === undefined) return entries; + for (const [name, overloads] of methods) { + entries.set(name, { overloads: [...overloads], depth: 0, ambiguous: false }); + } + return entries; +} + +function cloneMethodEntries(entries: ReadonlyMap): MutableMethodSetEntries { + const clone = new Map(); + for (const [name, entry] of entries) { + clone.set(name, { ...entry, overloads: [...entry.overloads] }); + } + return clone; +} + +function methodEntriesToMethodSet(entries: ReadonlyMap): MutableMethodSet { + const methods = new Map(); + for (const [name, entry] of entries) { + if (entry.ambiguous) continue; + methods.set(name, [...entry.overloads]); + } + return methods; +} + +function mergePromotedMethodEntry( + target: MutableMethodSetEntries, + methodName: string, + candidate: MethodSetEntry, +): void { + const existing = target.get(methodName); + if (existing === undefined || candidate.depth < existing.depth) { + target.set(methodName, candidate); + return; + } + if (candidate.depth > existing.depth) return; + target.set(methodName, { overloads: [], depth: candidate.depth, ambiguous: true }); +} + +function mergeMethodSet(target: MutableMethodSet, source: MethodSet): void { + for (const [name, overloads] of source) { + const existing = target.get(name) ?? []; + existing.push(...overloads); + target.set(name, existing); + } +} + +function methodSetSatisfies( + actual: MethodSet, + required: MethodSet, + signatureContextByDefId: ReadonlyMap, +): boolean { + for (const [name, requiredOverloads] of required) { + const actualOverloads = actual.get(name); + if (actualOverloads === undefined) return false; + for (const requiredMethod of requiredOverloads) { + if (!hasCompatibleMethod(actualOverloads, requiredMethod, signatureContextByDefId)) { + return false; + } + } } return true; } + +function hasCompatibleMethod( + actualOverloads: readonly SymbolDefinition[], + requiredMethod: SymbolDefinition, + signatureContextByDefId: ReadonlyMap, +): boolean { + if (!hasVerifiableSignature(requiredMethod)) return false; + return actualOverloads.some((actualMethod) => + signaturesCompatible(actualMethod, requiredMethod, signatureContextByDefId), + ); +} + +function methodSetHasVerifiableSignatures(methods: MethodSet): boolean { + for (const overloads of methods.values()) { + if (!overloads.some(hasVerifiableSignature)) return false; + } + return true; +} + +function isPointerReceiverMethod(def: SymbolDefinition): boolean { + return (def as GoMethodDefinition).goReceiverKind === 'pointer'; +} + +function hasVerifiableSignature(def: SymbolDefinition): boolean { + return ( + def.parameterCount !== undefined || + def.requiredParameterCount !== undefined || + (def.parameterTypes !== undefined && def.parameterTypes.length > 0) || + def.returnType !== undefined + ); +} + +function signaturesCompatible( + actual: SymbolDefinition, + required: SymbolDefinition, + signatureContextByDefId: ReadonlyMap, +): boolean { + const actualContext = signatureContextByDefId.get(actual.nodeId); + const requiredContext = signatureContextByDefId.get(required.nodeId); + return ( + countsCompatible(actual.parameterCount, required.parameterCount) && + countsCompatible(actual.requiredParameterCount, required.requiredParameterCount) && + parameterTypesCompatible( + actual.parameterTypes, + required.parameterTypes, + actualContext, + requiredContext, + ) && + returnTypesCompatible(actual.returnType, required.returnType, actualContext, requiredContext) + ); +} + +function countsCompatible(actual: number | undefined, required: number | undefined): boolean { + return actual === undefined || required === undefined || actual === required; +} + +function parameterTypesCompatible( + actual: readonly string[] | undefined, + required: readonly string[] | undefined, + actualContext: SignatureContext | undefined, + requiredContext: SignatureContext | undefined, +): boolean { + if (actual === undefined || required === undefined) return true; + if (actual.length !== required.length) return false; + return actual.every((type, index) => { + const actualType = normalizeSignatureType(type, actualContext); + const requiredType = normalizeSignatureType(required[index]!, requiredContext); + return actualType !== undefined && requiredType !== undefined && actualType === requiredType; + }); +} + +function returnTypesCompatible( + actual: string | undefined, + required: string | undefined, + actualContext: SignatureContext | undefined, + requiredContext: SignatureContext | undefined, +): boolean { + if (required === undefined) return actual === undefined; + if (actual === undefined) return false; + const actualType = normalizeSignatureType(actual, actualContext); + const requiredType = normalizeSignatureType(required, requiredContext); + return actualType !== undefined && requiredType !== undefined && actualType === requiredType; +} + +function normalizeSignatureType(typeName: string, context?: SignatureContext): string | undefined { + // Go type identity includes pointer/slice/map/variadic shape and package + // qualifiers. Only erase whitespace and qualify bare local type names; stripping + // `*`, `[]`, `...`, or `pkg.` would make non-identical signatures compare equal. + const compact = typeName.replace(/\s+/g, ''); + if (context === undefined) return compact; + return qualifyGoSignatureTypes(compact, context); +} + +function qualifyGoSignatureTypes(typeName: string, context: SignatureContext): string | undefined { + let unresolvedQualifier = false; + const qualified = typeName.replace(/[A-Za-z_][A-Za-z0-9_]*/g, (token, offset, source) => { + if (GO_BUILTIN_TYPES.has(token)) return token; + if (hasPackageQualifierDot(source, offset)) return token; + if (source[offset + token.length] === '.') { + const qualifier = context.importQualifiers.get(token); + if (qualifier !== undefined) return qualifier; + unresolvedQualifier = true; + return token; + } + if (context.packageQualifier === undefined) return token; + return `${context.packageQualifier}.${token}`; + }); + return unresolvedQualifier ? undefined : qualified; +} + +function hasPackageQualifierDot(source: string, offset: number): boolean { + return source[offset - 1] === '.' && source[offset - 2] !== '.'; +} + +function signatureContextForFile( + parsed: ParsedFile, + indexes: ScopeResolutionIndexes, +): SignatureContext { + const importQualifiers = new Map(); + const importEdges = indexes.imports?.get(parsed.moduleScope) ?? []; + for (const edge of importEdges) { + if (edge.kind !== 'namespace' || edge.targetFile === null) continue; + const qualifier = packageQualifierForFile(edge.targetFile); + if (qualifier !== undefined) importQualifiers.set(edge.localName, qualifier); + } + return { + packageQualifier: packageQualifierForFile(parsed.filePath), + importQualifiers, + }; +} + +function packageQualifierForFile(filePath: string): string | undefined { + const normalized = filePath.replace(/\\/g, '/'); + const slash = normalized.lastIndexOf('/'); + if (slash === -1) return undefined; + const packageDir = normalized.slice(0, slash); + return packageDir.length === 0 ? undefined : packageDir; +} + +const GO_BUILTIN_TYPES = new Set([ + 'any', + 'bool', + 'byte', + 'comparable', + 'complex64', + 'complex128', + 'error', + 'float32', + 'float64', + 'func', + 'int', + 'int8', + 'int16', + 'int32', + 'int64', + 'interface', + 'map', + 'rune', + 'string', + 'struct', + 'uint', + 'uint8', + 'uint16', + 'uint32', + 'uint64', + 'uintptr', + 'chan', +]); diff --git a/gitnexus/src/core/ingestion/languages/go/interpret.ts b/gitnexus/src/core/ingestion/languages/go/interpret.ts index c0bd71f48..32989038c 100644 --- a/gitnexus/src/core/ingestion/languages/go/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/go/interpret.ts @@ -28,7 +28,10 @@ export function interpretGoTypeBinding(captures: CaptureMatch): ParsedTypeBindin let normalizedType: string; if (captures['@type-binding.self'] !== undefined) { source = 'self'; - normalizedType = normalizeGoTypeName(type); + // Preserve pointer shape on receiver self-bindings (`*T` vs `T`). + // Method-owner enrichment consumes that raw shape to model Go value and + // pointer receiver method sets conservatively. + normalizedType = type.trim(); } else if (captures['@type-binding.constructor'] !== undefined) { source = 'constructor-inferred'; normalizedType = normalizeGoTypeName(type); diff --git a/gitnexus/src/core/ingestion/languages/go/method-owners.ts b/gitnexus/src/core/ingestion/languages/go/method-owners.ts index 457f9f60a..53ea352b0 100644 --- a/gitnexus/src/core/ingestion/languages/go/method-owners.ts +++ b/gitnexus/src/core/ingestion/languages/go/method-owners.ts @@ -44,12 +44,15 @@ export function populateGoWorkspaceOwners( function populateGoOwnersInPackage(parsedFiles: readonly ParsedFile[]): void { // Build struct name → def map from ALL scopes' ownedDefs (struct defs // live in Class scopes now, not Module scope). - const structByQualifiedName = new Map(); // qname → nodeId + const structByQualifiedName = new Map(); for (const parsed of parsedFiles) { for (const scope of parsed.scopes) { for (const def of scope.ownedDefs) { if (isClassLike(def.type) && def.qualifiedName) { - structByQualifiedName.set(def.qualifiedName, def.nodeId); + structByQualifiedName.set(def.qualifiedName, { + nodeId: def.nodeId, + qualifiedName: def.qualifiedName, + }); } } } @@ -69,26 +72,35 @@ function populateGoOwnersInPackage(parsedFiles: readonly ParsedFile[]): void { // Find the self typeBinding in this Function scope. let receiverType: string | undefined; + let receiverKind: 'value' | 'pointer' = 'value'; for (const [, tb] of scope.typeBindings) { if (tb.source === 'self') { receiverType = tb.rawName; + receiverKind = tb.rawName.trim().startsWith('*') ? 'pointer' : 'value'; break; } } if (receiverType === undefined) continue; + receiverType = receiverType.replace(/^\*+/, '').trim(); - let ownerId = structByQualifiedName.get(receiverType); - if (ownerId === undefined) { - for (const [qname, nodeId] of structByQualifiedName) { + let owner = structByQualifiedName.get(receiverType); + if (owner === undefined) { + for (const [qname, candidate] of structByQualifiedName) { if (qname.endsWith('.' + receiverType)) { - ownerId = nodeId; + owner = candidate; break; } } } - if (ownerId !== undefined) { + if (owner !== undefined) { for (const def of methodDefs) { - (def as { ownerId?: string }).ownerId = ownerId; + (def as { ownerId?: string }).ownerId = owner.nodeId; + (def as { goReceiverKind?: 'value' | 'pointer' }).goReceiverKind = receiverKind; + const simpleName = def.qualifiedName?.split('.').pop() ?? def.qualifiedName; + if (simpleName !== undefined && !def.qualifiedName?.includes('.')) { + (def as { qualifiedName?: string }).qualifiedName = + `${owner.qualifiedName}.${simpleName}`; + } } } } diff --git a/gitnexus/src/core/ingestion/languages/go/query.ts b/gitnexus/src/core/ingestion/languages/go/query.ts index 21aae19ad..64fd860ee 100644 --- a/gitnexus/src/core/ingestion/languages/go/query.ts +++ b/gitnexus/src/core/ingestion/languages/go/query.ts @@ -39,6 +39,10 @@ const GO_SCOPE_QUERY = ` (method_declaration name: (field_identifier) @declaration.name) @declaration.method +;; Declarations — interface methods +(method_elem + name: (field_identifier) @declaration.name) @declaration.method + ;; Declarations — struct fields (struct_type (field_declaration_list diff --git a/gitnexus/src/core/ingestion/languages/go/range-binding.ts b/gitnexus/src/core/ingestion/languages/go/range-binding.ts index a8318361e..6841172ec 100644 --- a/gitnexus/src/core/ingestion/languages/go/range-binding.ts +++ b/gitnexus/src/core/ingestion/languages/go/range-binding.ts @@ -71,10 +71,12 @@ export function populateGoRangeBindings( // Resolve range expression type let elementType: string | null = null; + const functionScope = findEnclosingFunctionScope(rangeNode, scopeMap); if (rangeExpr.type === 'identifier') { - // Look up the identifier's type in scope typeBindings (V1: module scope only) - const binding = moduleScope.typeBindings.get(rangeExpr.text); + const binding = + functionScope?.typeBindings.get(rangeExpr.text) ?? + moduleScope.typeBindings.get(rangeExpr.text); if (binding !== null && binding !== undefined) { elementType = extractElementType(binding); } @@ -96,7 +98,6 @@ export function populateGoRangeBindings( if (elementType !== null && valueVar !== null) { // Inject type binding for the range variable onto the enclosing function scope - const functionScope = findEnclosingFunctionScope(rangeNode, scopeMap); const targetScope = functionScope ?? moduleScope; const mutable = targetScope.typeBindings as Map; mutable.set(valueVar, { @@ -137,7 +138,7 @@ function findEnclosingFunctionScope( for (const scope of scopeMap.values()) { if ( scope.kind === 'Function' && - scope.range.startLine === current.startPosition.row && + scope.range.startLine === current.startPosition.row + 1 && scope.range.startCol === current.startPosition.column ) { return scope; diff --git a/gitnexus/src/core/ingestion/languages/go/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/go/receiver-binding.ts index d5089fa00..a2c7542f8 100644 --- a/gitnexus/src/core/ingestion/languages/go/receiver-binding.ts +++ b/gitnexus/src/core/ingestion/languages/go/receiver-binding.ts @@ -11,7 +11,7 @@ export function synthesizeGoReceiverBinding(fnNode: SyntaxNode): CaptureMatch | const nameNode = param.childForFieldName('name'); const typeNode = param.childForFieldName('type'); if (nameNode === null || typeNode === null) return null; - const typeName = typeNode.text.replace(/^\*/, ''); + const typeName = typeNode.text; return { '@type-binding.self': syntheticCapture('@type-binding.self', fnNode, nameNode.text), diff --git a/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts index 15d345736..e55595a63 100644 --- a/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts @@ -48,8 +48,8 @@ export const goScopeResolver: ScopeResolver = { populateNamespaceSiblings: populateGoPackageSiblings, mirrorNamespaceTypeBindings: mirrorGoNamespaceTypeBindings, - // Staged/V2: registered but not yet wired in run.ts — method-name-only - // matching produces false IMPLEMENTS edges; awaits signature-level comparison. + // Go has structural interfaces: implementations are inferred by signature, + // then fed into generic MRO/interface-dispatch. detectInterfaceImplementations: detectGoInterfaceImplementations, populateRangeBindings: populateGoRangeBindings, }; diff --git a/gitnexus/src/core/ingestion/languages/go/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/go/simple-hooks.ts index e2396f395..e8e86cb27 100644 --- a/gitnexus/src/core/ingestion/languages/go/simple-hooks.ts +++ b/gitnexus/src/core/ingestion/languages/go/simple-hooks.ts @@ -12,10 +12,11 @@ export function goBindingScopeFor( innermost: Scope, _tree: ScopeTree, ): ScopeId | null { - // Keep self typeBindings in the method's Function scope (prevent - // auto-hoist to Module) so populateGoOwners can match Method defs - // to their receiver types by inspecting each Function scope. - if (decl['@type-binding.self'] !== undefined) { + // Keep receiver and parameter typeBindings in the function scope + // (prevent auto-hoist to Module). Parameters are local variables: + // hoisting `repo Repository` from one function to module scope can + // pollute another function's local receiver resolution. + if (decl['@type-binding.self'] !== undefined || decl['@type-binding.parameter'] !== undefined) { return innermost.id; } return null; // default auto-hoist for other bindings @@ -32,7 +33,12 @@ export function goImportOwningScope( export function goReceiverBinding(functionScope: Scope): TypeRef | null { if (functionScope.kind !== 'Function') return null; for (const binding of functionScope.typeBindings.values()) { - if (binding.source === 'self') return binding; + if (binding.source === 'self') return normalizeGoSelfTypeRef(binding); } return null; } + +function normalizeGoSelfTypeRef(binding: TypeRef): TypeRef { + const rawName = binding.rawName.replace(/^\*+/, '').trim(); + return rawName === binding.rawName ? binding : { ...binding, rawName }; +} diff --git a/gitnexus/src/core/ingestion/languages/go/type-binding.ts b/gitnexus/src/core/ingestion/languages/go/type-binding.ts index 2db737930..266558046 100644 --- a/gitnexus/src/core/ingestion/languages/go/type-binding.ts +++ b/gitnexus/src/core/ingestion/languages/go/type-binding.ts @@ -4,6 +4,27 @@ import { syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; export function synthesizeGoTypeBindings(rootNode: SyntaxNode): CaptureMatch[] { const out: CaptureMatch[] = []; + for (const node of rootNode.descendantsOfType('var_spec')) { + const name = node.childForFieldName('name'); + const value = node.childForFieldName('value'); + if (name?.type !== 'identifier' || value === null) continue; + + const rhsExpr = value.namedChildren[0]; + if (rhsExpr === undefined) continue; + const typeNode = extractCompositeLiteralTypeNode(rhsExpr); + if (typeNode === null) continue; + + out.push({ + '@type-binding.constructor': syntheticCapture('@type-binding.constructor', node, node.text), + '@type-binding.name': syntheticCapture('@type-binding.name', name, name.text), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + typeNode, + extractSimpleTypeNameText(typeNode), + ), + }); + } + for (const node of rootNode.descendantsOfType('short_var_declaration')) { const right = node.childForFieldName('right'); if (right === null) continue; @@ -232,6 +253,21 @@ function extractSimpleTypeNameText(node: SyntaxNode): string { } /** Extract the type/signature node from a RHS expression. */ +function extractCompositeLiteralTypeNode(expr: SyntaxNode): SyntaxNode | null { + if (expr.type === 'composite_literal') { + return ( + expr.childForFieldName('type') ?? + expr.namedChildren.find((c) => ['type_identifier', 'qualified_type'].includes(c.type)) ?? + null + ); + } + if (expr.type === 'unary_expression') { + const operand = expr.childForFieldName('operand'); + return operand === null ? null : extractCompositeLiteralTypeNode(operand); + } + return null; +} + function extractTypeNode(expr: SyntaxNode): SyntaxNode | null { if (expr.type === 'composite_literal') { return ( 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 a7f5c9f85..d5aa2f788 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 @@ -170,7 +170,7 @@ export function emitReceiverBoundCalls( const graphIdToClassDef = new Map(); for (const parsed of parsedFiles) { for (const def of parsed.localDefs) { - if (def.type !== 'Class' && def.type !== 'Interface') continue; + if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue; const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup); if (graphId !== undefined) graphIdToClassDef.set(graphId, def); } diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 2a2b7edf7..7d45f60cd 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -25,6 +25,7 @@ import type { ParsedFile, RegistryProviders } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../../../graph/types.js'; +import { generateId } from '../../../../lib/utils.js'; import { lookupOwnedMembersByOwner } from '../../model/owned-members-lookup.js'; import type { MutableSemanticModel, SemanticModel } from '../../model/semantic-model.js'; import { reconcileOwnership, validateOwnershipParity } from './reconcile-ownership.js'; @@ -41,11 +42,7 @@ import { emitFreeCallFallback } from '../passes/free-call-fallback.js'; import { emitReferencesViaLookup } from '../graph-bridge/references-to-edges.js'; import { emitImportEdges } from '../graph-bridge/imports-to-edges.js'; import type { ScopeResolver } from '../contract/scope-resolver.js'; -import { - findClassBindingInScope, - findEnclosingClassDef, - resolveAmbiguousInheritanceBaseViaImports, -} from '../scope/walkers.js'; +import { findEnclosingClassDef, resolveInheritanceBaseInScope } from '../scope/walkers.js'; import { buildWorkspaceResolutionIndex } from '../workspace-index.js'; import type { ResolutionOutcome, ResolutionOutcomeRecorder } from '../resolution-outcome.js'; @@ -134,14 +131,7 @@ function preEmitInheritanceEdges( handledSites.add(siteKey); } - const targetDef = - findClassBindingInScope(site.inScope, site.name, scopes) ?? - // Import-aware disambiguation fallback (#1951). Only engages when the - // scope-chain + single-match lookups above returned undefined because - // the simple name is ambiguous (multiple same-named class-like defs). - // Picks the candidate whose defining file is imported/included by the - // referencing file. Never changes behavior for single-match cases. - resolveAmbiguousInheritanceBaseViaImports(site.inScope, site.name, scopes); + const targetDef = resolveInheritanceBaseInScope(site.inScope, site.name, scopes); if (targetDef === undefined) continue; const callerClass = findEnclosingClassDef(site.inScope, scopes); @@ -166,6 +156,68 @@ function preEmitInheritanceEdges( return handledSites; } +/** + * Emit language-inferred structural interface implementations before MRO and + * interface dispatch are built. Languages such as Go do not declare + * `implements` explicitly, so their resolver can infer defId-level interface + * satisfaction from parsed files and this bridge converts those defIds to + * graph node ids. + * + * Existing explicit IMPLEMENTS edges win: the local `existing` set prevents + * duplicate structural edges and keeps this hook language-neutral. The reason + * string carries the provider language (`go-structural-implements`) so callers + * can distinguish inferred edges from source-declared heritage. + */ +function emitDetectedInterfaceImplementations( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: ReturnType, + provider: ScopeResolver, + indexes: ReturnType, + model: SemanticModel, +): number { + if (provider.detectInterfaceImplementations === undefined) return 0; + + const graphIdByDefId = new Map(); + for (const parsed of parsedFiles) { + for (const def of parsed.localDefs) { + if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue; + const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup); + if (graphId !== undefined) graphIdByDefId.set(def.nodeId, graphId); + } + } + + const existing = new Set(); + for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) { + existing.add(`${rel.sourceId}->${rel.targetId}`); + } + + let emitted = 0; + const detected = provider.detectInterfaceImplementations(parsedFiles, indexes, model); + for (const [interfaceDefId, implementorDefIds] of detected) { + const targetId = graphIdByDefId.get(interfaceDefId); + if (targetId === undefined) continue; + for (const implementorDefId of implementorDefIds) { + const sourceId = graphIdByDefId.get(implementorDefId); + if (sourceId === undefined) continue; + const edgeKey = `${sourceId}->${targetId}`; + if (existing.has(edgeKey)) continue; + existing.add(edgeKey); + graph.addRelationship({ + id: generateId('IMPLEMENTS', edgeKey), + sourceId, + targetId, + type: 'IMPLEMENTS', + confidence: 0.85, + reason: `${provider.language}-structural-implements`, + }); + emitted++; + } + } + + return emitted; +} + export type ScopeResolutionSubPhase = | 'extracting' | 'analyzing types' @@ -377,6 +429,14 @@ export function runScopeResolution( // heritage hook are invisible and ACCESSES edges silently fail to emit. const postHeritageNodeLookup = provider.emitHeritageEdges !== undefined ? buildGraphNodeLookup(graph) : nodeLookup; + emitDetectedInterfaceImplementations( + graph, + parsedFiles, + postHeritageNodeLookup, + provider, + finalized, + readonlyModel, + ); const mroByClassDefId = provider.buildMro(graph, parsedFiles, postHeritageNodeLookup); const extendsOnlyMroByClassDefId = provider.buildExtendsOnlyMro?.( graph, diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index e02a59552..f36de06b2 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -300,6 +300,22 @@ export function findClassBindingInScope( return undefined; } +/** + * Resolve a class-like inheritance target using the shared inheritance + * resolution chain. Keeps pre-emitted heritage edges and language-specific + * consumers of `inherits` sites aligned. + */ +export function resolveInheritanceBaseInScope( + startScope: ScopeId, + baseName: string, + scopes: ScopeResolutionIndexes, +): SymbolDefinition | undefined { + return ( + findClassBindingInScope(startScope, baseName, scopes) ?? + resolveAmbiguousInheritanceBaseViaImports(startScope, baseName, scopes) + ); +} + /** * Import/include-aware disambiguation for an *ambiguous* class-like base * name. Engages ONLY as a fallback after `findClassBindingInScope` has diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 8a825c8d6..b9f8f3c65 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -905,6 +905,7 @@ export const GO_QUERIES = ` ; Functions & Methods (function_declaration name: (identifier) @name) @definition.function (method_declaration name: (field_identifier) @name) @definition.method +(method_elem name: (field_identifier) @name) @definition.method ; Types (type_declaration (type_spec name: (type_identifier) @name type: (struct_type))) @definition.struct diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 3289bb8d3..85526721b 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -182,6 +182,9 @@ export const CLASS_CONTAINER_TYPES = new Set([ // Kotlin 'object_declaration', 'companion_object', + // Go + 'struct_type', + 'interface_type', ]); export const CONTAINER_TYPE_TO_LABEL: Record = { @@ -214,6 +217,8 @@ export const CONTAINER_TYPE_TO_LABEL: Record = { singleton_class: 'Class', // Ruby: class << self inherits enclosing class name object_declaration: 'Class', companion_object: 'Class', + struct_type: 'Struct', + interface_type: 'Interface', }; /** diff --git a/gitnexus/test/fixtures/go-captures-golden/expected-captures.json b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json index 223edbde6..1630caf85 100644 --- a/gitnexus/test/fixtures/go-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json @@ -9,35 +9,35 @@ }, "go-ambiguous/internal/models/handler.go": { "captureGroups": 9, - "digest": "619c516a5791095bc6380de62fa861364b3f9480f2ec87d4499c2c998f228713" + "digest": "737d7ca26cf03aebcae6b5df6a13267d400b42cd37799d72eda92db3485a1add" }, "go-ambiguous/internal/other/handler.go": { "captureGroups": 9, - "digest": "08a61721581c4f17741ef0c4ee1c8945235ee7f2aca7d88d2fe122071cd62e6f" + "digest": "645a526694111ba6066715515ef4055d84f5c328505f7fa92d00b0b253d0aeca" }, "go-ambiguous/internal/services/user.go": { "captureGroups": 9, - "digest": "642da0df644ebd5a789e2de2d473ced2c9a77e5f6f1b4a1b1d53cf04845b820c" + "digest": "0a988c48838a34eb7a6f29b324191c39c19087e728914fffa06bbb9221841d23" }, "go-assignment-chain/cmd/main.go": { "captureGroups": 50, - "digest": "47ba5fd2ea96ee202b3a3de5c0db75ea18d0889064ed2138d62c67594f7d22e9" + "digest": "570d1eb9d287345a00a5a05bde00724b609523b41953cb1c277f5c056980d7b5" }, "go-assignment-chain/models/repo.go": { "captureGroups": 8, - "digest": "1b3acbf48751105d056253488f34159266bdb0b229248c85065e927b72bf4801" + "digest": "63cb96d06478f4b2039d6eaeab1468a8af3c503ec45e5785ff3e4de5aabe9240" }, "go-assignment-chain/models/user.go": { "captureGroups": 8, - "digest": "457c76ebf1c86efac7a7d9f384a8e49acd5649c006e6cc8aa29a59c36f0b102a" + "digest": "fcee44ed373eda2ff00778ff951fc2fa1503c5187b9e7d43dea3ee8b63656907" }, "go-call-result-binding/cmd/main.go": { "captureGroups": 16, - "digest": "83d611dcee826ec848a0b3fd5a18a98103896d17c353d8d63cd15551039818e9" + "digest": "ecf35e5a0e148d55f98a0de636f743db026fb75ae6a609b3fa90bd285c322145" }, "go-call-result-binding/models/user.go": { "captureGroups": 10, - "digest": "5c31199e148340ee32dd48e32a00003e094be963b1287b9b40c6b4effca12175" + "digest": "4193f6356f75505415e36d4b090dc9f5265937efe72b4251c33e5b5f06aaa99d" }, "go-calls/cmd/main.go": { "captureGroups": 6, @@ -45,23 +45,23 @@ }, "go-calls/internal/onearg/log.go": { "captureGroups": 6, - "digest": "a101eafe9f08396bb176cf3cadd3960d752802048e47c0ac9e9ace07244a46fb" + "digest": "762e8143c4ab116272c38d46586106ec4d5e01153c7abe831ca434269da58d94" }, "go-calls/internal/zeroarg/log.go": { "captureGroups": 5, - "digest": "7b322767a38298de8c6ce99fa6b1d69dda8bbb79704053644bfaa7d2e4473fdb" + "digest": "eae0988b2f001f06b275487d4c8154ce64c222642fff2cbabaff8d51b389dea3" }, "go-chain-call/cmd/main.go": { "captureGroups": 20, - "digest": "5a8d7de8ae87887902d16f28cb70d31014c96ab5d2eb63cbd6c8be27650fa9a1" + "digest": "c60375f48bb2765060f068dc986abc9ed6ed4489c14115b6fa544ee3beb3d3d8" }, "go-chain-call/models/repo.go": { "captureGroups": 10, - "digest": "ac4799aae638d528c5c7c01c8b9734fc61e995596a12b12e790dcffab97b4029" + "digest": "94b6f138a062dfd96969dc7ec3d3eddecab591cb29d16fed4ea91da20ff0363b" }, "go-chain-call/models/user.go": { "captureGroups": 10, - "digest": "5c31199e148340ee32dd48e32a00003e094be963b1287b9b40c6b4effca12175" + "digest": "4193f6356f75505415e36d4b090dc9f5265937efe72b4251c33e5b5f06aaa99d" }, "go-child-extends-parent/models/child.go": { "captureGroups": 4, @@ -69,7 +69,7 @@ }, "go-child-extends-parent/models/parent.go": { "captureGroups": 8, - "digest": "454a724f571a5aede89d7657f76ed8e3c9b19bfc18d524141d1b646005f98e49" + "digest": "19a2b1696d45ea66cd00df562b65ea0b2e9fdbe60233cf31587a334221420d6e" }, "go-child-extends-parent/services/app.go": { "captureGroups": 10, @@ -77,7 +77,7 @@ }, "go-cmd-helper/cmd/server/internal/config/config.go": { "captureGroups": 5, - "digest": "b10874198d380b0a186fb1e1ee8cedac644eb3b6b624398110a660183e59a0b5" + "digest": "129af13eba5f2b52ea2fd2d24de49331ba542aa3dfab014e72baa0239685a0db" }, "go-cmd-helper/cmd/server/main.go": { "captureGroups": 7, @@ -89,11 +89,11 @@ }, "go-constructor-type-inference/models/repo.go": { "captureGroups": 8, - "digest": "1b3acbf48751105d056253488f34159266bdb0b229248c85065e927b72bf4801" + "digest": "63cb96d06478f4b2039d6eaeab1468a8af3c503ec45e5785ff3e4de5aabe9240" }, "go-constructor-type-inference/models/user.go": { "captureGroups": 8, - "digest": "457c76ebf1c86efac7a7d9f384a8e49acd5649c006e6cc8aa29a59c36f0b102a" + "digest": "fcee44ed373eda2ff00778ff951fc2fa1503c5187b9e7d43dea3ee8b63656907" }, "go-deep-field-chain/cmd/main.go": { "captureGroups": 13, @@ -101,7 +101,7 @@ }, "go-deep-field-chain/models/models.go": { "captureGroups": 33, - "digest": "0ad1df946f58e446a8dbd8ef13041b6e0177f53293946b955acf3c46684e4295" + "digest": "3941943919b0066138d8a759cceadd95784c8a89978e7a345ea056ef1ca78c9c" }, "go-field-types/cmd/main.go": { "captureGroups": 9, @@ -109,7 +109,7 @@ }, "go-field-types/models/models.go": { "captureGroups": 22, - "digest": "fbdbf74d927c0ed07f4820c190fc6f2039b74ddead8abb45fc238812dfa4d4ef" + "digest": "78de0f686b33993bd361a1d5366982afbe5378f4a62464c4248e3c9b65aaa9c3" }, "go-for-call-expr/cmd/main.go": { "captureGroups": 27, @@ -117,11 +117,11 @@ }, "go-for-call-expr/models/repo.go": { "captureGroups": 14, - "digest": "312e59c1402cb83a4ce51c27866fde6c597f7121098b57ff1d3bd4dc6363834a" + "digest": "33889573461301951f51786e94a60a1d20c87bd3d2cad12f8619e951b94ad7ed" }, "go-for-call-expr/models/user.go": { "captureGroups": 14, - "digest": "c442a26c4051c2b7426850a381137507a147c21d631d6535d71ef1035b1506fb" + "digest": "964cf1bd181cc7196726c3261a683884ac9c6fc3aee6170b47bd54427a6cb947" }, "go-inc-dec-write-access/main.go": { "captureGroups": 21, @@ -141,7 +141,7 @@ }, "go-make-builtin/models.go": { "captureGroups": 15, - "digest": "4d5628f66471f1ad1180a47b797195506d190ddc84f61f6c57f2e22afd754c1e" + "digest": "2af382fcd2e59cf01d9439bdb8709cba150bb239fae71b6f0d09c1fa7248fc5b" }, "go-map-range/main.go": { "captureGroups": 10, @@ -157,23 +157,23 @@ }, "go-member-calls/cmd/main.go": { "captureGroups": 11, - "digest": "e46f6d1dff39943f8f89c05e0d28f61f8471cdc729c91f01ca693a38a244b8ba" + "digest": "b65bbfd200e4be9a991403a44bc68fb0a0abfe0bbf764b2acbd472d3eaf6742e" }, "go-member-calls/models/user.go": { "captureGroups": 8, - "digest": "457c76ebf1c86efac7a7d9f384a8e49acd5649c006e6cc8aa29a59c36f0b102a" + "digest": "fcee44ed373eda2ff00778ff951fc2fa1503c5187b9e7d43dea3ee8b63656907" }, "go-method-chain-binding/cmd/main.go": { "captureGroups": 21, - "digest": "0fff0df5dd77e13e0b9e62dd7f38efc038d33ffd5d891f684289eccbc46ff583" + "digest": "884589d31ad9c4463d48323b7aa5f80c15a4d3eb1097210abd88c3d013d2fbef" }, "go-method-chain-binding/models/user.go": { "captureGroups": 24, - "digest": "504ea598176dd8e01d759cc54e012735c746f14ec3a660af2c362fc356326f65" + "digest": "d72468ce0567ce35393b570e7e3e86f0c004e37a9614dc5ee698aff6d3a4348a" }, "go-method-enrichment/animal.go": { - "captureGroups": 15, - "digest": "ac0933f59d4a88a25629d02f308c66847fd34ceba5831660fc8bff07109b7708" + "captureGroups": 16, + "digest": "7ef52b78d7c920a34961f3275ceb4a34d0172067459ab594287b7e4b7ec695a6" }, "go-method-enrichment/app.go": { "captureGroups": 15, @@ -185,7 +185,7 @@ }, "go-mixed-chain/models/models.go": { "captureGroups": 41, - "digest": "dc2cedaefcd73faf13a9f704a4be8f0bd4140c608cd86480e1f4400fec49dbd1" + "digest": "afb1eaf2869f534033c5cd844ca111a93b63df1c098cec9c9518c4480a47ddb0" }, "go-multi-assign/app.go": { "captureGroups": 16, @@ -193,19 +193,19 @@ }, "go-multi-assign/models.go": { "captureGroups": 19, - "digest": "a4d43cf2cd2f7bdbc750a7ee1f5e9c7611e5d1e25f1a46386d0eeb9f73bb7846" + "digest": "e071ebdd59b5bbebb42785ebf850a9c0ee7c2e3a8cc13fb0b17233480efa26ed" }, "go-multi-return-inference/cmd/main.go": { "captureGroups": 36, - "digest": "6229b69cfd15bf1465d770d97486522faac5a91cf8828c424c8c8c989b310224" + "digest": "179fca92694ce3e1f2a9b38cde0c283e4bab00f240316a6116931115e079057a" }, "go-multi-return-inference/models/repo.go": { "captureGroups": 10, - "digest": "ac4799aae638d528c5c7c01c8b9734fc61e995596a12b12e790dcffab97b4029" + "digest": "94b6f138a062dfd96969dc7ec3d3eddecab591cb29d16fed4ea91da20ff0363b" }, "go-multi-return-inference/models/user.go": { "captureGroups": 10, - "digest": "5c31199e148340ee32dd48e32a00003e094be963b1287b9b40c6b4effca12175" + "digest": "4193f6356f75505415e36d4b090dc9f5265937efe72b4251c33e5b5f06aaa99d" }, "go-new-builtin/main.go": { "captureGroups": 12, @@ -213,27 +213,27 @@ }, "go-new-builtin/models.go": { "captureGroups": 17, - "digest": "40c9f89942406caf2610f0d1954b66e905c39dd5d617d7359e572445d586c15d" + "digest": "d5ce8ba6ff38c6c78bb87e746a5382822745d0c9841a8092495ff386c7c4ee24" }, "go-nullable-receiver/cmd/main.go": { "captureGroups": 27, - "digest": "7a93b339f8c68ed3882802da21343c1d6b5231ae5cd8dd13eb4c05e2e7716320" + "digest": "7430c0c8f4877d1b41e62aee9017eadc66d2a8f01b70acf2836584a84f254335" }, "go-nullable-receiver/models/repo.go": { "captureGroups": 8, - "digest": "1b3acbf48751105d056253488f34159266bdb0b229248c85065e927b72bf4801" + "digest": "63cb96d06478f4b2039d6eaeab1468a8af3c503ec45e5785ff3e4de5aabe9240" }, "go-nullable-receiver/models/user.go": { "captureGroups": 8, - "digest": "457c76ebf1c86efac7a7d9f384a8e49acd5649c006e6cc8aa29a59c36f0b102a" + "digest": "fcee44ed373eda2ff00778ff951fc2fa1503c5187b9e7d43dea3ee8b63656907" }, "go-parent-resolution/models/base.go": { "captureGroups": 8, - "digest": "2afaeb50d544a55fe437ef20e2c0de92152d2ba62f2693c329255787bb3d0a02" + "digest": "a5138789f8e35111dced225841a4d0e209eabad582973a55f5d47f882cf777e8" }, "go-parent-resolution/models/user.go": { "captureGroups": 9, - "digest": "bc39ef8b54dcfcf975155ff69721bf2075ca170fb81bb081a1052dda72f64ecd" + "digest": "e1b57b0c5865d84b80518fe52e3807f813ae5589b4b558d0037e6ea136843f85" }, "go-pkg/cmd/main.go": { "captureGroups": 14, @@ -241,19 +241,19 @@ }, "go-pkg/internal/auth/service.go": { "captureGroups": 17, - "digest": "2204643b50f486423ee7a5877b2bab7d6334cbe62b14b4435fe4ba8a6465ce92" + "digest": "dc481fe7319f4e69d6f53d04a10029bb36b5f964b5ecd5a367d6e081c0158fec" }, "go-pkg/internal/models/admin.go": { "captureGroups": 14, - "digest": "85b2e848f29885082671f00d015bcce3b14ebd372a88acbc225e6ade1ee5449c" + "digest": "6c307b22f7ddd3c2c34a525a61e4ed36602e22b5845fc6533be15515ef7d78fb" }, "go-pkg/internal/models/repository.go": { - "captureGroups": 3, - "digest": "7de6e11a3cf9c89afa9d89fe37dab85b208699f20105fbade223e4f78a63b15c" + "captureGroups": 5, + "digest": "73a5c9bfcb59011f36c47e1b806edba89bad1ed32f81d46acbefdb8abdba67c7" }, "go-pkg/internal/models/user.go": { "captureGroups": 13, - "digest": "e56fcea1c473866ed06fc0262702e54c72016a9556cc6337330c03cc1f638fe1" + "digest": "a9eeed64d83ab471847db27f8ce5c4ba65b6ac2664f372da67ee4fc974016224" }, "go-pointer-constructor-inference/cmd/main.go": { "captureGroups": 15, @@ -261,19 +261,19 @@ }, "go-pointer-constructor-inference/models/repo.go": { "captureGroups": 10, - "digest": "ac4799aae638d528c5c7c01c8b9734fc61e995596a12b12e790dcffab97b4029" + "digest": "94b6f138a062dfd96969dc7ec3d3eddecab591cb29d16fed4ea91da20ff0363b" }, "go-pointer-constructor-inference/models/user.go": { "captureGroups": 10, - "digest": "5c31199e148340ee32dd48e32a00003e094be963b1287b9b40c6b4effca12175" + "digest": "4193f6356f75505415e36d4b090dc9f5265937efe72b4251c33e5b5f06aaa99d" }, "go-qualified-base/base/base.go": { - "captureGroups": 16, - "digest": "4c50e40140094556e8bfc540f0ea6627c55823a1e4fcc91ca5d74912df67d499" + "captureGroups": 17, + "digest": "c2f2241a4e31ad1b003c1649d7511437f3884815412aeac65754cf3cfb9b6dc1" }, "go-qualified-base/consumers/local.go": { - "captureGroups": 19, - "digest": "9097247f77ab93b742c715ff9d656038fc8ea0558e3fe7e4df64e6b1c6fcb64c" + "captureGroups": 20, + "digest": "923a41030b83b854118c12cb6f99ca02a8347b1db72ec8304904d3a99284f5f4" }, "go-qualified-base/consumers/qualified.go": { "captureGroups": 14, @@ -281,7 +281,7 @@ }, "go-receiver-method-free-call/example.go": { "captureGroups": 8, - "digest": "2a3c26672d3b997bdc39644361c550f8cf0749489f0945d210e2fb7f3bca9383" + "digest": "0b773eaede711e43ed8b8ae6aacf76c398dc362f54429b30a4f5eb7c71f863da" }, "go-receiver-method-free-call/util.go": { "captureGroups": 4, @@ -293,35 +293,35 @@ }, "go-receiver-resolution/models/repo.go": { "captureGroups": 8, - "digest": "1b3acbf48751105d056253488f34159266bdb0b229248c85065e927b72bf4801" + "digest": "63cb96d06478f4b2039d6eaeab1468a8af3c503ec45e5785ff3e4de5aabe9240" }, "go-receiver-resolution/models/user.go": { "captureGroups": 8, - "digest": "457c76ebf1c86efac7a7d9f384a8e49acd5649c006e6cc8aa29a59c36f0b102a" + "digest": "fcee44ed373eda2ff00778ff951fc2fa1503c5187b9e7d43dea3ee8b63656907" }, "go-return-type-inference/cmd/main.go": { "captureGroups": 39, - "digest": "ac8ca3dc1fb7fb4d7a1f947a77e93db890f04d328135dc514f36ba5cd04bc3e1" + "digest": "71b935bc70b194c2a8136f053319aaba86a4ba80a5d171ef39bc6e8d5c144865" }, "go-return-type-inference/models/repo.go": { "captureGroups": 16, - "digest": "8c500db82093b4622acbca734f3040e6176000a9d9a4843e2a81e3f2b3daee7c" + "digest": "0dde6336b070f5e9919189de576fdad96d69eb993f9b29a7a48875bc044f229b" }, "go-return-type-inference/models/user.go": { "captureGroups": 16, - "digest": "a70b19b9c46a02003f26d0b70fa5368983a791583ec3776e983c733a9283eefa" + "digest": "c133a3db65e1952277b7c986f0870db86d4bb762ff0043d2160ed772317e03ca" }, "go-same-package-factory/main.go": { "captureGroups": 14, - "digest": "505d4d279615f3c99457d8cb0f29fdf946b547c6b1fda6d43aa6d4302dd61809" + "digest": "4638aceb638f4cf9c11d9f51f43f50edf6e5358da69cde78d4bffee1f3dce3cc" }, "go-same-package-factory/repo.go": { "captureGroups": 8, - "digest": "f8bb213588f517e166b421f19d72cde981153464cc3f3168499769e778c4a22e" + "digest": "a5488a5ebe483c88d9f5bb2ccf0c283e397b3345798509c0e8e5ab65272be7fd" }, "go-same-package-factory/user.go": { "captureGroups": 8, - "digest": "4daaad60f35d4519a24cd067baf4cd4bdc421e3dd80b9110a8dfaa8a9e75d9eb" + "digest": "067c51c54c15b71f3ec45b8da94ea693e6b440d30fa26122d0a90a3b81f42bce" }, "go-split-method-owner/main.go": { "captureGroups": 9, @@ -329,11 +329,11 @@ }, "go-split-method-owner/repo.go": { "captureGroups": 8, - "digest": "f8bb213588f517e166b421f19d72cde981153464cc3f3168499769e778c4a22e" + "digest": "a5488a5ebe483c88d9f5bb2ccf0c283e397b3345798509c0e8e5ab65272be7fd" }, "go-split-method-owner/save.go": { "captureGroups": 6, - "digest": "6e0e1b5521a2fad1cd00252e771926d5caa7930440278968f7f8607821ad339c" + "digest": "5d3d7a31d42336c964302822a959c6519e31f09bc4c6ee744197205d7591f707" }, "go-split-method-owner/user.go": { "captureGroups": 3, @@ -345,15 +345,43 @@ }, "go-struct-literals/user.go": { "captureGroups": 10, - "digest": "89b791a9d150fe341924d54d3173d9f35f72be899d0e7ac03a35d32119b94f8f" + "digest": "16f0d425b25bdc7eaaf331bd47ce1ece6d476ae0e24e72110ce96c1f315bd314" + }, + "go-structural-interface-cross-package/api/repository.go": { + "captureGroups": 12, + "digest": "b5ef588c11391fa93237260baae4c4bb67ee4dfc381651444a9925c3f52adec3" + }, + "go-structural-interface-cross-package/cmd/main.go": { + "captureGroups": 27, + "digest": "b44adcfaa2b965479abe17b406c6c74ddd51bb54e7d129e8a99a26a2a3f2fc16" + }, + "go-structural-interface-cross-package/contracts/read_closer.go": { + "captureGroups": 6, + "digest": "0813deb9e72bfb1e07d0e9399c4d4537dc40181f0c5c43cff5580b91d4ed3605" + }, + "go-structural-interface-cross-package/impl/file.go": { + "captureGroups": 20, + "digest": "950002bd5dfc126d13355f317944a509253fe6dd81f12638af1d5f0fe37d04db" + }, + "go-structural-interface-cross-package/other/user.go": { + "captureGroups": 5, + "digest": "4d6298ea05addadfa17f6dd8a2eb0fa2ea63b73de69355015594504ae4e6c1b1" + }, + "go-structural-interface-cross-package/store/repository.go": { + "captureGroups": 33, + "digest": "9a497524610994e9a47555cd5874074b278d3e12850e3ee62a1afadb25fcabe8" + }, + "go-structural-interface-dispatch/repository.go": { + "captureGroups": 129, + "digest": "f31bc522991e59527834bb7dcd45b8738490c622c3d25181a8148cc85d27844b" }, "go-type-assertion/main.go": { "captureGroups": 11, "digest": "b8bd327d3965531802a93bc01e3c24969a027540397a1635119ef0b7da46c348" }, "go-type-assertion/models.go": { - "captureGroups": 17, - "digest": "3780f8f7c145a15f849ae6958e492db0044e825b81aa8dfd21b9873759fad65e" + "captureGroups": 18, + "digest": "018658521c83d757464357f9963cd5952e040be01a02d9f1d41304f3748fd1a5" }, "go-variadic-resolution/cmd/main.go": { "captureGroups": 6, @@ -369,6 +397,6 @@ }, "synthetic:dao-20": { "captureGroups": 481, - "digest": "1698b5dd78c8094f251b10ab8cacebfbf453f38eddf7233fbc7964e32f04ceeb" + "digest": "611e1f8147dff51e8857e493e06089aadf17a042cee6a522520433025b2bc0da" } } diff --git a/gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/api/repository.go b/gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/api/repository.go new file mode 100644 index 000000000..e2088118a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/api/repository.go @@ -0,0 +1,14 @@ +package api + +type User struct { + ID string +} + +type Saver interface { + Load(id string) User + Save(user User) error +} + +type Reader interface { + Read() error +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/cmd/main.go b/gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/cmd/main.go new file mode 100644 index 000000000..a5d465d49 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/cmd/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "github.com/example/gostruct/api" + "github.com/example/gostruct/contracts" + "github.com/example/gostruct/store" +) + +func precise(user api.User) { + var saver api.Saver = store.GoodStore{} + saver.Save(user) +} + +func fallback(saver api.Saver, user api.User) { + saver.Save(user) +} + +func fallbackReadCloser(rc contracts.ReadCloser) { + rc.Close() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/contracts/read_closer.go b/gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/contracts/read_closer.go new file mode 100644 index 000000000..a571f008b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/contracts/read_closer.go @@ -0,0 +1,8 @@ +package contracts + +import apix "github.com/example/gostruct/api" + +type ReadCloser interface { + apix.Reader + Close() error +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/go.mod b/gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/go.mod new file mode 100644 index 000000000..21dfd5784 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/go.mod @@ -0,0 +1,3 @@ +module github.com/example/gostruct + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/impl/file.go b/gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/impl/file.go new file mode 100644 index 000000000..eb82cc437 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/impl/file.go @@ -0,0 +1,17 @@ +package impl + +type File struct{} + +func (f File) Read() error { + return nil +} + +func (f File) Close() error { + return nil +} + +type CloseOnly struct{} + +func (c CloseOnly) Close() error { + return nil +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/other/user.go b/gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/other/user.go new file mode 100644 index 000000000..a1b199dce --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/other/user.go @@ -0,0 +1,5 @@ +package other + +type User struct { + ID string +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/store/repository.go b/gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/store/repository.go new file mode 100644 index 000000000..ed79d194a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-structural-interface-cross-package/store/repository.go @@ -0,0 +1,26 @@ +package store + +import ( + apix "github.com/example/gostruct/api" + "github.com/example/gostruct/other" +) + +type GoodStore struct{} + +func (s GoodStore) Load(id string) apix.User { + return apix.User{ID: id} +} + +func (s GoodStore) Save(user apix.User) error { + return nil +} + +type WrongStore struct{} + +func (s WrongStore) Load(id string) other.User { + return other.User{ID: id} +} + +func (s WrongStore) Save(user other.User) error { + return nil +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-structural-interface-dispatch/repository.go b/gitnexus/test/fixtures/lang-resolution/go-structural-interface-dispatch/repository.go new file mode 100644 index 000000000..4312e3755 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-structural-interface-dispatch/repository.go @@ -0,0 +1,102 @@ +package main + +type User struct { + Name string +} + +type Repository interface { + Find(id string) User + Save(user User) error +} + +type SqlRepository struct{} + +func (s SqlRepository) Find(id string) User { + return User{Name: id} +} + +func (s SqlRepository) Save(user User) error { + return nil +} + +type MemoryRepository struct{} + +func (m MemoryRepository) Find(id string) User { + return User{Name: id} +} + +func (m MemoryRepository) Save(user User) error { + return nil +} + +type BadRepository struct{} + +func (b BadRepository) Find(id string) User { + return User{Name: id} +} + +func (b BadRepository) Save(id string) error { + return nil +} + +func precise(user User) { + var repo Repository = SqlRepository{} + repo.Save(user) +} + +func fallback(repo Repository, user User) { + repo.Save(user) +} + +type Reader interface { + Read() error +} + +type ReadCloser interface { + Reader + Close() error +} + +type FileBase struct{} + +func (f FileBase) Read() error { + return nil +} + +type File struct { + FileBase +} + +func (f File) Close() error { + return nil +} + +type ShadowReadFile struct { + FileBase +} + +func (s ShadowReadFile) Read(path string) error { + return nil +} + +func (s ShadowReadFile) Close() error { + return nil +} + +type CloseOnly struct{} + +func (c CloseOnly) Close() error { + return nil +} + +func fallbackReadCloser(rc ReadCloser) { + rc.Close() +} + +type PointerOnly interface { + Touch() +} + +type PointerOnlyThing struct{} + +func (p *PointerOnlyThing) Touch() {} diff --git a/gitnexus/test/integration/go-pipeline-benchmark.test.ts b/gitnexus/test/integration/go-pipeline-benchmark.test.ts index 273424721..8980d4759 100644 --- a/gitnexus/test/integration/go-pipeline-benchmark.test.ts +++ b/gitnexus/test/integration/go-pipeline-benchmark.test.ts @@ -32,8 +32,12 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; -import { emitGoScopeCaptures } from '../../src/core/ingestion/languages/go/index.js'; +import { + emitGoScopeCaptures, + detectGoInterfaceImplementations, +} from '../../src/core/ingestion/languages/go/index.js'; const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1'; @@ -454,3 +458,264 @@ describe('Go scope-capture O(n^2) regression tripwire', () => { expect(elapsedMs).toBeLessThan(BUDGET_MS); }, 30_000); }); + +// --------------------------------------------------------------------------- +// Go structural interface detection benchmarks +// --------------------------------------------------------------------------- + +/** + * Build synthetic ParsedFile data for detectGoInterfaceImplementations. + * + * Creates `interfaceCount` interfaces (each with Find + Save methods) and + * `structCount` structs that implement all of them (matching signatures). + * Also generates a few BadStruct entries with mismatched signatures to + * verify they are correctly excluded (non-vacuous guard). + */ +function generateSyntheticInterfaceData(interfaceCount: number, structCount: number): ParsedFile[] { + const defs: SymbolDefinition[] = []; + const ifaceIds: string[] = []; + const structIds: string[] = []; + + // Interfaces + for (let i = 0; i < interfaceCount; i++) { + const ifaceId = `iface:Repo${i}`; + ifaceIds.push(ifaceId); + defs.push({ + nodeId: ifaceId, + filePath: 'repo.go', + type: 'Interface', + qualifiedName: `Repo${i}`, + }); + // Find(id string) User + defs.push({ + nodeId: `iface:Repo${i}.Find`, + filePath: 'repo.go', + type: 'Method', + qualifiedName: `Repo${i}.Find`, + ownerId: ifaceId, + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['string'], + returnType: 'User', + }); + // Save(user User) error + defs.push({ + nodeId: `iface:Repo${i}.Save`, + filePath: 'repo.go', + type: 'Method', + qualifiedName: `Repo${i}.Save`, + ownerId: ifaceId, + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['User'], + returnType: 'error', + }); + } + + // Structs — each implements all interfaces + for (let s = 0; s < structCount; s++) { + const structId = `struct:Impl${s}`; + structIds.push(structId); + defs.push({ + nodeId: structId, + filePath: 'repo.go', + type: 'Struct', + qualifiedName: `Impl${s}`, + }); + for (let i = 0; i < interfaceCount; i++) { + defs.push({ + nodeId: `struct:Impl${s}.Repo${i}.Find`, + filePath: 'repo.go', + type: 'Method', + qualifiedName: `Impl${s}.Find`, + ownerId: structId, + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['string'], + returnType: 'User', + }); + defs.push({ + nodeId: `struct:Impl${s}.Repo${i}.Save`, + filePath: 'repo.go', + type: 'Method', + qualifiedName: `Impl${s}.Save`, + ownerId: structId, + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['User'], + returnType: 'error', + }); + } + } + + // BadStructs — wrong Save signature (string instead of User), should NOT match + for (let b = 0; b < Math.min(5, structCount); b++) { + const badId = `struct:Bad${b}`; + defs.push({ + nodeId: badId, + filePath: 'repo.go', + type: 'Struct', + qualifiedName: `Bad${b}`, + }); + for (let i = 0; i < interfaceCount; i++) { + defs.push({ + nodeId: `struct:Bad${b}.Repo${i}.Find`, + filePath: 'repo.go', + type: 'Method', + qualifiedName: `Bad${b}.Find`, + ownerId: badId, + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['string'], + returnType: 'User', + }); + // Mismatched Save: string param instead of User + defs.push({ + nodeId: `struct:Bad${b}.Repo${i}.Save`, + filePath: 'repo.go', + type: 'Method', + qualifiedName: `Bad${b}.Save`, + ownerId: badId, + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['string'], + returnType: 'error', + }); + } + } + + return [ + { + filePath: 'repo.go', + language: 'go', + scopes: [], + imports: [], + localDefs: defs, + referenceSites: [], + }, + ] as ParsedFile[]; +} + +/** + * Ungated tripwire: runs in normal CI. Calls detectGoInterfaceImplementations + * directly on synthetic data to catch O(n²) regressions. The current indexed + * path (structIdsByMethodName intersection) keeps this well under budget. + */ +describe('Go structural interface detection O(n²) regression tripwire', () => { + it('detects implementations for 50 interfaces × 50 structs within budget', () => { + const IFACE_COUNT = 50; + const STRUCT_COUNT = 50; + const BUDGET_MS = 5_000; + + const parsed = generateSyntheticInterfaceData(IFACE_COUNT, STRUCT_COUNT); + const emptyIndexes = {} as any; + const emptyModel = {} as any; + + // Warm up + detectGoInterfaceImplementations( + generateSyntheticInterfaceData(5, 5), + emptyIndexes, + emptyModel, + ); + + const start = Date.now(); + const result = detectGoInterfaceImplementations(parsed, emptyIndexes, emptyModel); + const elapsedMs = Date.now() - start; + + // Sanity: each interface should be implemented by all STRUCT_COUNT structs + expect(result.size).toBe(IFACE_COUNT); + for (const [, impls] of result) { + expect(impls).toHaveLength(STRUCT_COUNT); + } + // Regression guard + expect(elapsedMs).toBeLessThan(BUDGET_MS); + + console.log( + ` interface-detection tripwire: ${IFACE_COUNT}×${STRUCT_COUNT} = ${IFACE_COUNT * STRUCT_COUNT} pairs, ${elapsedMs}ms`, + ); + }, 30_000); +}); + +/** + * Gated scaling benchmark: measures how detectGoInterfaceImplementations + * scales as interface and struct counts grow proportionally. + * + * Run: GITNEXUS_BENCH=1 npx vitest run test/integration/go-pipeline-benchmark.test.ts + */ +describe.skipIf(!BENCH_ENABLED)('Go structural interface detection benchmark', () => { + const scales = [50, 200, 800]; + const REPS = 3; + + it('scales linearly with interface × struct count', () => { + interface ScaleResult { + ifaceCount: number; + structCount: number; + totalPairs: number; + elapsedMs: number; + implEdges: number; + } + + const results: ScaleResult[] = []; + const emptyIndexes = {} as any; + const emptyModel = {} as any; + + for (const n of scales) { + // Warm up with small data once + detectGoInterfaceImplementations( + generateSyntheticInterfaceData(5, 5), + emptyIndexes, + emptyModel, + ); + + let bestMs = Infinity; + let implEdges = 0; + const parsed = generateSyntheticInterfaceData(n, n); + + for (let r = 0; r < REPS; r++) { + const start = Date.now(); + const result = detectGoInterfaceImplementations(parsed, emptyIndexes, emptyModel); + const elapsed = Date.now() - start; + if (elapsed < bestMs) { + bestMs = elapsed; + implEdges = 0; + for (const [, impls] of result) implEdges += impls.length; + } + } + + results.push({ + ifaceCount: n, + structCount: n, + totalPairs: n * n, + elapsedMs: bestMs, + implEdges, + }); + console.log(` ${n}×${n} = ${n * n} pairs: ${bestMs}ms (${implEdges} IMPLEMENTS edges)`); + } + + // Print scaling table + console.log('\nGo Interface Detection — Scaling'); + console.log('┌──────────┬──────────┬───────────┬───────────┐'); + console.log('│ Iface×St │ Pairs │ Time (ms) │ IMPL edges│'); + console.log('├──────────┼──────────┼───────────┼───────────┤'); + for (const r of results) { + console.log( + `│ ${String(`${r.ifaceCount}×${r.structCount}`).padStart(8)} │ ${String(r.totalPairs).padStart(8)} │ ${String(r.elapsedMs).padStart(9)} │ ${String(r.implEdges).padStart(9)} │`, + ); + } + console.log('└──────────┴──────────┴───────────┴───────────┘'); + + // Assert linear scaling + if (results.length >= 2) { + console.log('\nScaling ratios (time_ratio / size_ratio):'); + for (let i = 1; i < results.length; i++) { + const sizeRatio = results[i].totalPairs / results[i - 1].totalPairs; + const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs; + const scaling = timeRatio / sizeRatio; + console.log( + ` ${results[i - 1].totalPairs} → ${results[i].totalPairs}: ${scaling.toFixed(2)}x (${scaling < 1.5 ? 'linear' : scaling < 3 ? 'superlinear' : 'WARNING: quadratic'})`, + ); + expect(scaling).toBeLessThan(1.5); + } + } + }, 300_000); +}); diff --git a/gitnexus/test/integration/resolvers/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts index 8119800f3..5d2ba1938 100644 --- a/gitnexus/test/integration/resolvers/go.test.ts +++ b/gitnexus/test/integration/resolvers/go.test.ts @@ -340,6 +340,156 @@ describe('Go receiver-constrained resolution', () => { }); }); +describe('Go structural interface dispatch', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-structural-interface-dispatch'), + () => {}, + ); + }, 60000); + + function owningTypeName(methodId: string): string { + for (const rel of result.graph.iterRelationshipsByType('HAS_METHOD')) { + if (rel.targetId !== methodId) continue; + const owner = result.graph.getNode(rel.sourceId); + return (owner?.properties.name ?? rel.sourceId) as string; + } + return ''; + } + + it('emits signature-checked structural IMPLEMENTS edges only for valid implementors', () => { + const implementsEdges = getRelationships(result, 'IMPLEMENTS').filter( + (edge) => edge.rel.reason === 'go-structural-implements', + ); + expect(edgeSet(implementsEdges)).toEqual([ + 'File → ReadCloser', + 'File → Reader', + 'FileBase → Reader', + 'MemoryRepository → Repository', + 'SqlRepository → Repository', + ]); + expect(implementsEdges.every((edge) => edge.rel.confidence === 0.85)).toBe(true); + }); + + it('feeds structural IMPLEMENTS into METHOD_IMPLEMENTS edges', () => { + const methodEdges = getRelationships(result, 'METHOD_IMPLEMENTS').filter( + (edge) => edge.target === 'Save', + ); + const sourceOwners = methodEdges.map((edge) => owningTypeName(edge.rel.sourceId)).sort(); + expect(sourceOwners).toEqual(['MemoryRepository', 'SqlRepository']); + }); + + it('prefers the concrete local assignment over interface fan-out', () => { + const saveCalls = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'precise' && edge.target === 'Save', + ); + const targetOwners = saveCalls.map((edge) => owningTypeName(edge.rel.targetId)); + expect(targetOwners).toEqual(['SqlRepository']); + }); + + it('fans out interface-typed receiver calls to all known implementors', () => { + const saveCalls = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'fallback' && edge.target === 'Save', + ); + const dispatchTargets = saveCalls + .filter((edge) => edge.rel.reason === 'interface-dispatch') + .map((edge) => owningTypeName(edge.rel.targetId)) + .sort(); + expect(dispatchTargets).toEqual(['MemoryRepository', 'SqlRepository']); + }); + + it('includes embedded interface methods before emitting structural IMPLEMENTS edges', () => { + const implementsEdges = getRelationships(result, 'IMPLEMENTS'); + expect(edgeSet(implementsEdges)).toContain('File → ReadCloser'); + expect(edgeSet(implementsEdges)).not.toContain('CloseOnly → ReadCloser'); + }); + + it('includes promoted embedded struct methods before emitting structural IMPLEMENTS edges', () => { + const implementsEdges = getRelationships(result, 'IMPLEMENTS'); + expect(edgeSet(implementsEdges)).toContain('File → Reader'); + expect(edgeSet(implementsEdges)).toContain('File → ReadCloser'); + expect(edgeSet(implementsEdges)).not.toContain('ShadowReadFile → Reader'); + expect(edgeSet(implementsEdges)).not.toContain('ShadowReadFile → ReadCloser'); + }); + + it('does not emit value-type IMPLEMENTS for pointer-receiver-only methods', () => { + const implementsEdges = getRelationships(result, 'IMPLEMENTS'); + expect(edgeSet(implementsEdges)).not.toContain('PointerOnlyThing → PointerOnly'); + }); + + it('fans out embedded-interface receivers only to complete implementors', () => { + const closeCalls = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'fallbackReadCloser' && edge.target === 'Close', + ); + const dispatchTargets = closeCalls + .filter((edge) => edge.rel.reason === 'interface-dispatch') + .map((edge) => owningTypeName(edge.rel.targetId)) + .sort(); + expect(dispatchTargets).toEqual(['File']); + }); +}); + +describe('Go cross-package structural interface dispatch', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-structural-interface-cross-package'), + () => {}, + ); + }, 60000); + + function owningTypeName(methodId: string): string { + for (const rel of result.graph.iterRelationshipsByType('HAS_METHOD')) { + if (rel.targetId !== methodId) continue; + const owner = result.graph.getNode(rel.sourceId); + return (owner?.properties.name ?? rel.sourceId) as string; + } + return ''; + } + + it('matches local interface types against package-qualified implementation signatures', () => { + const implementsEdges = getRelationships(result, 'IMPLEMENTS').filter( + (edge) => edge.rel.reason === 'go-structural-implements', + ); + expect(edgeSet(implementsEdges)).toEqual([ + 'File → ReadCloser', + 'File → Reader', + 'GoodStore → Saver', + ]); + }); + + it('merges methods from package-qualified embedded interfaces before matching implementors', () => { + const implementsEdges = getRelationships(result, 'IMPLEMENTS'); + expect(edgeSet(implementsEdges)).toContain('File → ReadCloser'); + expect(edgeSet(implementsEdges)).not.toContain('CloseOnly → ReadCloser'); + }); + + it('fans out cross-package interface receivers only to valid implementors', () => { + const saveCalls = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'fallback' && edge.target === 'Save', + ); + const dispatchTargets = saveCalls + .filter((edge) => edge.rel.reason === 'interface-dispatch') + .map((edge) => owningTypeName(edge.rel.targetId)) + .sort(); + expect(dispatchTargets).toEqual(['GoodStore']); + }); + + it('dispatches package-qualified embedded-interface receivers only to complete implementors', () => { + const closeCalls = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'fallbackReadCloser' && edge.target === 'Close', + ); + const dispatchTargets = closeCalls + .filter((edge) => edge.rel.reason === 'interface-dispatch') + .map((edge) => owningTypeName(edge.rel.targetId)) + .sort(); + expect(dispatchTargets).toEqual(['File']); + }); +}); + // --------------------------------------------------------------------------- // Variadic resolution: ...interface{} doesn't get filtered by arity // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 3e9e5d25d..ddc4a96d3 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -43,6 +43,20 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly m['@scope.function']?.text.startsWith('func()'))).toBe(true); - // ...and the method's receiver self-binding is synthesized (name + pointer-stripped type)... + // ...and the method's receiver self-binding is synthesized with its raw pointer shape... const selves = matches.filter((m) => m['@type-binding.self'] !== undefined); expect(selves).toHaveLength(1); // exactly one — from the method, not the closure expect(selves[0]!['@type-binding.name']?.text).toBe('u'); - expect(selves[0]!['@type-binding.type']?.text).toBe('User'); + expect(selves[0]!['@type-binding.type']?.text).toBe('*User'); }); it('does not drop a var-form type assertion binding', () => { @@ -162,4 +162,47 @@ func Map[T any](x T) T { return x } expect(decl).toBeDefined(); expect(decl!['@declaration.name']?.text).toBe('Map'); }); + + it('expands grouped named return types into separate return values', () => { + const src = ` +package main + +type Pairer interface { + Pair() (a, b int) +} +`; + const matches = emitGoScopeCaptures(src, 'main.go'); + const pairDecl = matches.find( + (m) => m['@declaration.method'] !== undefined && m['@declaration.name']?.text === 'Pair', + ); + + expect(pairDecl?.['@declaration.return-type']?.text).toBe('(int, int)'); + }); + + it('captures interface method return signatures for structural matching', () => { + const src = ` +package main + +type Shapes interface { + Touch() + Close() error + Pair() (int, error) + NamedPair() (a, b int) +} +`; + const methods = emitGoScopeCaptures(src, 'main.go') + .filter((m) => m['@declaration.method'] !== undefined) + .map((m) => ({ + name: m['@declaration.name']?.text, + returns: m['@declaration.return-type']?.text, + })) + .sort((a, b) => String(a.name).localeCompare(String(b.name))); + + expect(methods).toEqual([ + { name: 'Close', returns: 'error' }, + { name: 'NamedPair', returns: '(int, int)' }, + { name: 'Pair', returns: '(int, error)' }, + { name: 'Touch', returns: undefined }, + ]); + }); }); diff --git a/gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts b/gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts index 42424a3bd..330cbb85e 100644 --- a/gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts +++ b/gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts @@ -1,10 +1,20 @@ import { describe, expect, it } from 'vitest'; -import type { BindingRef, Callsite, SymbolDefinition, Scope } from 'gitnexus-shared'; +import type { + BindingRef, + Callsite, + ImportEdge, + ReferenceSite, + Scope, + ScopeId, + SymbolDefinition, +} from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../../../src/core/ingestion/model/scope-resolution-indexes.js'; import { goArityCompatibility, goMergeBindings, goReceiverBinding, } from '../../../../src/core/ingestion/languages/go/index.js'; +import { detectGoInterfaceImplementations } from '../../../../src/core/ingestion/languages/go/interface-impls.js'; describe('Go arity compatibility', () => { const makeDef = (overrides: Partial = {}): SymbolDefinition => ({ @@ -118,6 +128,16 @@ describe('Go receiver binding', () => { expect(goReceiverBinding(scope)?.rawName).toBe('User'); }); + it('normalizes pointer self bindings for receiver lookup', () => { + const scope = { + kind: 'Function', + typeBindings: new Map([ + ['u', { rawName: '*User', declaredAtScope: 'scope:1', source: 'self' }], + ]), + } as unknown as Scope; + expect(goReceiverBinding(scope)?.rawName).toBe('User'); + }); + it('returns null for non-Function scope', () => { const scope = { kind: 'Module', typeBindings: new Map() } as unknown as Scope; expect(goReceiverBinding(scope)).toBeNull(); @@ -128,3 +148,968 @@ describe('Go receiver binding', () => { expect(goReceiverBinding(scope)).toBeNull(); }); }); + +function goDef( + nodeId: string, + type: SymbolDefinition['type'], + qualifiedName: string, + ownerId?: string, + metadata: Partial = {}, +): SymbolDefinition { + return { + nodeId, + filePath: 'repo.go', + type, + qualifiedName, + ...(ownerId === undefined ? {} : { ownerId }), + ...metadata, + }; +} + +function parsedGoDefs( + defs: readonly SymbolDefinition[], + options: { + readonly scopes?: readonly Scope[]; + readonly referenceSites?: readonly ReferenceSite[]; + } = {}, +) { + return [ + { + filePath: 'repo.go', + language: 'go', + scopes: options.scopes ?? [], + imports: [], + localDefs: [...defs], + referenceSites: options.referenceSites ?? [], + }, + ] as any; +} + +function parsedGoFile( + filePath: string, + defs: readonly SymbolDefinition[], + options: { + readonly scopes?: readonly Scope[]; + readonly referenceSites?: readonly ReferenceSite[]; + } = {}, +) { + return { + filePath, + language: 'go', + scopes: options.scopes ?? [], + imports: [], + localDefs: [...defs], + referenceSites: options.referenceSites ?? [], + } as any; +} + +function scopeIndexes( + defs: readonly SymbolDefinition[], + scopes: readonly Scope[] = [], + options: { + readonly bindingAugmentations?: ReadonlyMap< + ScopeId, + ReadonlyMap + >; + readonly imports?: ReadonlyMap; + } = {}, +): ScopeResolutionIndexes { + const defsById = new Map(defs.map((def) => [def.nodeId, def])); + const qualifiedNames = new Map(); + for (const def of defs) { + const ids = qualifiedNames.get(def.qualifiedName) ?? []; + ids.push(def.nodeId); + qualifiedNames.set(def.qualifiedName, ids); + } + const scopesById = new Map(scopes.map((s) => [s.id, s])); + return { + defs: { get: (id: string) => defsById.get(id) }, + qualifiedNames: { get: (name: string) => qualifiedNames.get(name) ?? [] }, + scopeTree: { getScope: (id: ScopeId) => scopesById.get(id) }, + bindings: new Map(), + bindingAugmentations: options.bindingAugmentations ?? new Map(), + imports: options.imports ?? new Map(), + workspaceFqnBindings: new Map(), + namespaceFqnBindings: new Map(), + accessibleNamespacesByScope: new Map(), + methodDispatch: {} as any, + moduleScopes: {} as any, + workspaceTypeBindings: new Map(), + namespaceTypeBindings: new Map(), + referenceSites: [], + sccs: [], + stats: {} as any, + } as ScopeResolutionIndexes; +} + +const emptyIndexes = scopeIndexes([]); + +function scope( + id: ScopeId, + kind: Scope['kind'], + ownedDefs: readonly SymbolDefinition[], + parent: ScopeId | null = null, +): Scope { + return { + id, + parent, + kind, + filePath: 'repo.go', + range: { startLine: 1, startCol: 0, endLine: 1, endCol: 1 }, + bindings: new Map(), + ownedDefs, + imports: [], + typeBindings: new Map(), + }; +} + +function inheritsSite(name: string, inScope: ScopeId): ReferenceSite { + return { + name, + inScope, + kind: 'inherits', + atRange: { startLine: 1, startCol: 0, endLine: 1, endCol: 1 }, + }; +} + +describe('Go structural interface detection', () => { + it('detects a struct implementing every interface method with matching signatures', () => { + const iface = goDef('iface:Repository', 'Interface', 'Repository'); + const struct = goDef('struct:SqlRepository', 'Struct', 'SqlRepository'); + const ifaceFind = goDef('iface:Repository.Find', 'Method', 'Repository.Find', iface.nodeId, { + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['string'], + returnType: 'User', + }); + const ifaceSave = goDef('iface:Repository.Save', 'Method', 'Repository.Save', iface.nodeId, { + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['User'], + returnType: 'error', + }); + const structFind = goDef( + 'struct:SqlRepository.Find', + 'Method', + 'SqlRepository.Find', + struct.nodeId, + { + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['string'], + returnType: 'User', + }, + ); + const structSave = goDef( + 'struct:SqlRepository.Save', + 'Method', + 'SqlRepository.Save', + struct.nodeId, + { + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['User'], + returnType: 'error', + }, + ); + + const result = detectGoInterfaceImplementations( + parsedGoDefs([iface, struct, ifaceFind, ifaceSave, structFind, structSave]), + emptyIndexes, + {} as any, + ); + + expect(result.get(iface.nodeId)).toEqual([struct.nodeId]); + }); + + it('does not treat pointer-receiver-only methods as value type implementations', () => { + const iface = goDef('iface:Closer', 'Interface', 'Closer'); + const struct = goDef('struct:PointerOnlyCloser', 'Struct', 'PointerOnlyCloser'); + const ifaceClose = goDef('iface:Closer.Close', 'Method', 'Closer.Close', iface.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + }); + const structClose = goDef( + 'struct:PointerOnlyCloser.Close', + 'Method', + 'PointerOnlyCloser.Close', + struct.nodeId, + { + parameterCount: 0, + requiredParameterCount: 0, + }, + ); + (structClose as SymbolDefinition & { goReceiverKind: 'pointer' }).goReceiverKind = 'pointer'; + + const result = detectGoInterfaceImplementations( + parsedGoDefs([iface, struct, ifaceClose, structClose]), + emptyIndexes, + {} as any, + ); + + expect(result.get(iface.nodeId)).toBeUndefined(); + }); + + it('rejects same-name methods with incompatible parameter types', () => { + const iface = goDef('iface:Repository', 'Interface', 'Repository'); + const struct = goDef('struct:BadRepository', 'Struct', 'BadRepository'); + const ifaceSave = goDef('iface:Repository.Save', 'Method', 'Repository.Save', iface.nodeId, { + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['User'], + returnType: 'error', + }); + const badSave = goDef( + 'struct:BadRepository.Save', + 'Method', + 'BadRepository.Save', + struct.nodeId, + { + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['string'], + returnType: 'error', + }, + ); + + const result = detectGoInterfaceImplementations( + parsedGoDefs([iface, struct, ifaceSave, badSave]), + emptyIndexes, + {} as any, + ); + + expect(result.get(iface.nodeId)).toBeUndefined(); + }); + + it('preserves Go parameter type shape when checking signatures', () => { + const iface = goDef('iface:Repository', 'Interface', 'Repository'); + const struct = goDef('struct:BadRepository', 'Struct', 'BadRepository'); + const ifaceSave = goDef('iface:Repository.Save', 'Method', 'Repository.Save', iface.nodeId, { + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['[]User'], + returnType: 'error', + }); + const badSave = goDef( + 'struct:BadRepository.Save', + 'Method', + 'BadRepository.Save', + struct.nodeId, + { + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['User'], + returnType: 'error', + }, + ); + + const result = detectGoInterfaceImplementations( + parsedGoDefs([iface, struct, ifaceSave, badSave]), + emptyIndexes, + {} as any, + ); + + expect(result.get(iface.nodeId)).toBeUndefined(); + }); + + it('does not conflate variadic and slice parameter types in interface signatures', () => { + const iface = goDef('iface:Repository', 'Interface', 'Repository'); + const struct = goDef('struct:BadRepository', 'Struct', 'BadRepository'); + const ifaceSave = goDef('iface:Repository.Save', 'Method', 'Repository.Save', iface.nodeId, { + parameterCount: 1, + requiredParameterCount: 0, + parameterTypes: ['...User'], + returnType: 'error', + }); + const badSave = goDef( + 'struct:BadRepository.Save', + 'Method', + 'BadRepository.Save', + struct.nodeId, + { + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['[]User'], + returnType: 'error', + }, + ); + + const result = detectGoInterfaceImplementations( + parsedGoDefs([iface, struct, ifaceSave, badSave]), + emptyIndexes, + {} as any, + ); + + expect(result.get(iface.nodeId)).toBeUndefined(); + }); + + it('preserves variadic element package identity when checking signatures', () => { + const iface = goDef('iface:Repository', 'Interface', 'Repository', undefined, { + filePath: 'api/repository.go', + }); + const struct = goDef('struct:BadRepository', 'Struct', 'BadRepository', undefined, { + filePath: 'store/repository.go', + }); + const ifaceSave = goDef('iface:Repository.Save', 'Method', 'Repository.Save', iface.nodeId, { + filePath: 'api/repository.go', + parameterCount: 1, + requiredParameterCount: 0, + parameterTypes: ['...User'], + returnType: 'error', + }); + const badSave = goDef( + 'struct:BadRepository.Save', + 'Method', + 'BadRepository.Save', + struct.nodeId, + { + filePath: 'store/repository.go', + parameterCount: 1, + requiredParameterCount: 0, + parameterTypes: ['...User'], + returnType: 'error', + }, + ); + const defs = [iface, struct, ifaceSave, badSave]; + + const result = detectGoInterfaceImplementations( + [ + parsedGoFile('api/repository.go', [iface, ifaceSave]), + parsedGoFile('store/repository.go', [struct, badSave]), + ], + scopeIndexes(defs), + {} as any, + ); + + expect(result.get(iface.nodeId)).toBeUndefined(); + }); + + it('requires methods inherited from embedded interfaces', () => { + const reader = goDef('iface:Reader', 'Interface', 'Reader'); + const readCloser = goDef('iface:ReadCloser', 'Interface', 'ReadCloser'); + const struct = goDef('struct:PartialFile', 'Struct', 'PartialFile'); + const readerRead = goDef('iface:Reader.Read', 'Method', 'Reader.Read', reader.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }); + const readCloserClose = goDef( + 'iface:ReadCloser.Close', + 'Method', + 'ReadCloser.Close', + readCloser.nodeId, + { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }, + ); + const structClose = goDef( + 'struct:PartialFile.Close', + 'Method', + 'PartialFile.Close', + struct.nodeId, + { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }, + ); + + const result = detectGoInterfaceImplementations( + parsedGoDefs([reader, readCloser, struct, readerRead, readCloserClose, structClose], { + scopes: [ + scope('scope:Reader', 'Class', [reader]), + scope('scope:ReadCloser', 'Class', [readCloser]), + ], + referenceSites: [inheritsSite('Reader', 'scope:ReadCloser')], + }), + emptyIndexes, + {} as any, + ); + + expect(result.get(readCloser.nodeId)).toBeUndefined(); + }); + + it('accepts structs implementing methods from embedded interfaces', () => { + const reader = goDef('iface:Reader', 'Interface', 'Reader'); + const readCloser = goDef('iface:ReadCloser', 'Interface', 'ReadCloser'); + const struct = goDef('struct:File', 'Struct', 'File'); + const readerRead = goDef('iface:Reader.Read', 'Method', 'Reader.Read', reader.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }); + const readCloserClose = goDef( + 'iface:ReadCloser.Close', + 'Method', + 'ReadCloser.Close', + readCloser.nodeId, + { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }, + ); + const structRead = goDef('struct:File.Read', 'Method', 'File.Read', struct.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }); + const structClose = goDef('struct:File.Close', 'Method', 'File.Close', struct.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }); + + const result = detectGoInterfaceImplementations( + parsedGoDefs( + [reader, readCloser, struct, readerRead, readCloserClose, structRead, structClose], + { + scopes: [ + scope('scope:Reader', 'Class', [reader]), + scope('scope:ReadCloser', 'Class', [readCloser]), + ], + referenceSites: [inheritsSite('Reader', 'scope:ReadCloser')], + }, + ), + emptyIndexes, + {} as any, + ); + + expect(result.get(readCloser.nodeId)).toEqual([struct.nodeId]); + }); + + it('accepts structs implementing interface methods through promoted embedded struct methods', () => { + const reader = goDef('iface:Reader', 'Interface', 'Reader'); + const base = goDef('struct:Base', 'Struct', 'Base'); + const file = goDef('struct:File', 'Struct', 'File'); + const readerRead = goDef('iface:Reader.Read', 'Method', 'Reader.Read', reader.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }); + const baseRead = goDef('struct:Base.Read', 'Method', 'Base.Read', base.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }); + const defs = [reader, base, file, readerRead, baseRead]; + const scopes = [scope('scope:Base', 'Class', [base]), scope('scope:File', 'Class', [file])]; + + const result = detectGoInterfaceImplementations( + parsedGoDefs(defs, { + scopes, + referenceSites: [inheritsSite('Base', 'scope:File')], + }), + scopeIndexes(defs, scopes), + {} as any, + ); + + expect(result.get(reader.nodeId)).toEqual([base.nodeId, file.nodeId]); + }); + + it('lets direct struct methods shadow promoted embedded struct methods', () => { + const reader = goDef('iface:Reader', 'Interface', 'Reader'); + const base = goDef('struct:Base', 'Struct', 'Base'); + const shadowFile = goDef('struct:ShadowFile', 'Struct', 'ShadowFile'); + const readerRead = goDef('iface:Reader.Read', 'Method', 'Reader.Read', reader.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }); + const baseRead = goDef('struct:Base.Read', 'Method', 'Base.Read', base.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }); + const shadowRead = goDef( + 'struct:ShadowFile.Read', + 'Method', + 'ShadowFile.Read', + shadowFile.nodeId, + { + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['string'], + returnType: 'error', + }, + ); + const defs = [reader, base, shadowFile, readerRead, baseRead, shadowRead]; + const scopes = [ + scope('scope:Base', 'Class', [base]), + scope('scope:ShadowFile', 'Class', [shadowFile]), + ]; + + const result = detectGoInterfaceImplementations( + parsedGoDefs(defs, { + scopes, + referenceSites: [inheritsSite('Base', 'scope:ShadowFile')], + }), + scopeIndexes(defs, scopes), + {} as any, + ); + + expect(result.get(reader.nodeId)).toEqual([base.nodeId]); + }); + + it('does not use ambiguous promoted embedded struct methods for interface matching', () => { + const reader = goDef('iface:Reader', 'Interface', 'Reader'); + const baseA = goDef('struct:BaseA', 'Struct', 'BaseA'); + const baseB = goDef('struct:BaseB', 'Struct', 'BaseB'); + const file = goDef('struct:File', 'Struct', 'File'); + const readerRead = goDef('iface:Reader.Read', 'Method', 'Reader.Read', reader.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }); + const baseARead = goDef('struct:BaseA.Read', 'Method', 'BaseA.Read', baseA.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }); + const baseBRead = goDef('struct:BaseB.Read', 'Method', 'BaseB.Read', baseB.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }); + const defs = [reader, baseA, baseB, file, readerRead, baseARead, baseBRead]; + const scopes = [ + scope('scope:BaseA', 'Class', [baseA]), + scope('scope:BaseB', 'Class', [baseB]), + scope('scope:File', 'Class', [file]), + ]; + + const result = detectGoInterfaceImplementations( + parsedGoDefs(defs, { + scopes, + referenceSites: [inheritsSite('BaseA', 'scope:File'), inheritsSite('BaseB', 'scope:File')], + }), + scopeIndexes(defs, scopes), + {} as any, + ); + + expect(result.get(reader.nodeId)).toEqual([baseA.nodeId, baseB.nodeId]); + }); + + it('uses the shallowest promoted embedded struct method when deeper methods share the name', () => { + const reader = goDef('iface:Reader', 'Interface', 'Reader'); + const shallow = goDef('struct:Shallow', 'Struct', 'Shallow'); + const deepBase = goDef('struct:DeepBase', 'Struct', 'DeepBase'); + const deepWrapper = goDef('struct:DeepWrapper', 'Struct', 'DeepWrapper'); + const file = goDef('struct:File', 'Struct', 'File'); + const readerRead = goDef('iface:Reader.Read', 'Method', 'Reader.Read', reader.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }); + const shallowRead = goDef('struct:Shallow.Read', 'Method', 'Shallow.Read', shallow.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }); + const deepRead = goDef('struct:DeepBase.Read', 'Method', 'DeepBase.Read', deepBase.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }); + const defs = [reader, shallow, deepBase, deepWrapper, file, readerRead, shallowRead, deepRead]; + const scopes = [ + scope('scope:Shallow', 'Class', [shallow]), + scope('scope:DeepBase', 'Class', [deepBase]), + scope('scope:DeepWrapper', 'Class', [deepWrapper]), + scope('scope:File', 'Class', [file]), + ]; + + const result = detectGoInterfaceImplementations( + parsedGoDefs(defs, { + scopes, + referenceSites: [ + inheritsSite('DeepBase', 'scope:DeepWrapper'), + inheritsSite('Shallow', 'scope:File'), + inheritsSite('DeepWrapper', 'scope:File'), + ], + }), + scopeIndexes(defs, scopes), + {} as any, + ); + + expect(result.get(reader.nodeId)).toEqual([ + shallow.nodeId, + deepBase.nodeId, + deepWrapper.nodeId, + file.nodeId, + ]); + }); + + it('resolves ambiguous embedded interfaces through imported scope context', () => { + const readerA = goDef('iface:a.Reader', 'Interface', 'Reader', undefined, { + filePath: 'a/reader.go', + }); + const readerB = goDef('iface:b.Reader', 'Interface', 'Reader', undefined, { + filePath: 'b/reader.go', + }); + const readCloser = goDef('iface:ReadCloser', 'Interface', 'ReadCloser', undefined, { + filePath: 'contracts/read_closer.go', + }); + const file = goDef('struct:File', 'Struct', 'File'); + const closeOnly = goDef('struct:CloseOnly', 'Struct', 'CloseOnly'); + const readerARead = goDef('iface:a.Reader.Read', 'Method', 'Reader.Read', readerA.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }); + const readerBReadWrong = goDef('iface:b.Reader.Read', 'Method', 'Reader.Read', readerB.nodeId, { + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['string'], + returnType: 'error', + }); + const readCloserClose = goDef( + 'iface:ReadCloser.Close', + 'Method', + 'ReadCloser.Close', + readCloser.nodeId, + { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }, + ); + const fileRead = goDef('struct:File.Read', 'Method', 'File.Read', file.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }); + const fileClose = goDef('struct:File.Close', 'Method', 'File.Close', file.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }); + const closeOnlyClose = goDef( + 'struct:CloseOnly.Close', + 'Method', + 'CloseOnly.Close', + closeOnly.nodeId, + { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }, + ); + const defs = [ + readerA, + readerB, + readCloser, + file, + closeOnly, + readerARead, + readerBReadWrong, + readCloserClose, + fileRead, + fileClose, + closeOnlyClose, + ]; + const scopes = [ + scope('scope:module', 'Module', []), + scope('scope:ReaderA', 'Class', [readerA], 'scope:module'), + scope('scope:ReaderB', 'Class', [readerB], 'scope:module'), + scope('scope:ReadCloser', 'Class', [readCloser], 'scope:module'), + ]; + + const result = detectGoInterfaceImplementations( + parsedGoDefs(defs, { + scopes, + referenceSites: [inheritsSite('Reader', 'scope:ReadCloser')], + }), + scopeIndexes(defs, scopes, { + imports: new Map([ + [ + 'scope:module', + [ + { + kind: 'namespace', + localName: 'a', + targetExportedName: 'a', + targetFile: 'a/reader.go', + }, + ], + ], + ]), + }), + {} as any, + ); + + expect(result.get(readCloser.nodeId)).toEqual([file.nodeId]); + }); + + it('does not emit implementations for cyclic embedded interfaces', () => { + const ifaceA = goDef('iface:A', 'Interface', 'A'); + const ifaceB = goDef('iface:B', 'Interface', 'B'); + const struct = goDef('struct:CycleImpl', 'Struct', 'CycleImpl'); + const ifaceAMethod = goDef('iface:A.A', 'Method', 'A.A', ifaceA.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + }); + const ifaceBMethod = goDef('iface:B.B', 'Method', 'B.B', ifaceB.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + }); + const structA = goDef('struct:CycleImpl.A', 'Method', 'CycleImpl.A', struct.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + }); + const structB = goDef('struct:CycleImpl.B', 'Method', 'CycleImpl.B', struct.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + }); + + const result = detectGoInterfaceImplementations( + parsedGoDefs([ifaceA, ifaceB, struct, ifaceAMethod, ifaceBMethod, structA, structB], { + scopes: [scope('scope:A', 'Class', [ifaceA]), scope('scope:B', 'Class', [ifaceB])], + referenceSites: [inheritsSite('B', 'scope:A'), inheritsSite('A', 'scope:B')], + }), + emptyIndexes, + {} as any, + ); + + expect(result.get(ifaceA.nodeId)).toBeUndefined(); + expect(result.get(ifaceB.nodeId)).toBeUndefined(); + }); + + it('allows one struct to satisfy multiple unrelated interfaces', () => { + const reader = goDef('iface:Reader', 'Interface', 'Reader'); + const closer = goDef('iface:Closer', 'Interface', 'Closer'); + const file = goDef('struct:File', 'Struct', 'File'); + const readerRead = goDef('iface:Reader.Read', 'Method', 'Reader.Read', reader.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }); + const closerClose = goDef('iface:Closer.Close', 'Method', 'Closer.Close', closer.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }); + const fileRead = goDef('struct:File.Read', 'Method', 'File.Read', file.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }); + const fileClose = goDef('struct:File.Close', 'Method', 'File.Close', file.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }); + + const result = detectGoInterfaceImplementations( + parsedGoDefs([reader, closer, file, readerRead, closerClose, fileRead, fileClose]), + emptyIndexes, + {} as any, + ); + + expect(result.get(reader.nodeId)).toEqual([file.nodeId]); + expect(result.get(closer.nodeId)).toEqual([file.nodeId]); + }); + + it('does not emit implementations when an embedded interface cannot be resolved', () => { + const readCloser = goDef('iface:ReadCloser', 'Interface', 'ReadCloser'); + const struct = goDef('struct:CloseOnly', 'Struct', 'CloseOnly'); + const readCloserClose = goDef( + 'iface:ReadCloser.Close', + 'Method', + 'ReadCloser.Close', + readCloser.nodeId, + { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }, + ); + const structClose = goDef( + 'struct:CloseOnly.Close', + 'Method', + 'CloseOnly.Close', + struct.nodeId, + { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }, + ); + + const result = detectGoInterfaceImplementations( + parsedGoDefs([readCloser, struct, readCloserClose, structClose], { + scopes: [scope('scope:ReadCloser', 'Class', [readCloser])], + referenceSites: [inheritsSite('io.Reader', 'scope:ReadCloser')], + }), + emptyIndexes, + {} as any, + ); + + expect(result.get(readCloser.nodeId)).toBeUndefined(); + }); + + it('allows embedded empty interfaces to contribute no required methods', () => { + const marker = goDef('iface:Marker', 'Interface', 'Marker'); + const iface = goDef('iface:MarkedSaver', 'Interface', 'MarkedSaver'); + const struct = goDef('struct:Repo', 'Struct', 'Repo'); + const ifaceSave = goDef('iface:MarkedSaver.Save', 'Method', 'MarkedSaver.Save', iface.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }); + const structSave = goDef('struct:Repo.Save', 'Method', 'Repo.Save', struct.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }); + + const result = detectGoInterfaceImplementations( + parsedGoDefs([marker, iface, struct, ifaceSave, structSave], { + scopes: [ + scope('scope:Marker', 'Class', [marker]), + scope('scope:MarkedSaver', 'Class', [iface]), + ], + referenceSites: [inheritsSite('Marker', 'scope:MarkedSaver')], + }), + emptyIndexes, + {} as any, + ); + + expect(result.get(iface.nodeId)).toEqual([struct.nodeId]); + }); + + it('preserves package qualifiers when checking signatures', () => { + const iface = goDef('iface:Saver', 'Interface', 'Saver'); + const struct = goDef('struct:Repo', 'Struct', 'Repo'); + const ifaceSave = goDef('iface:Saver.Save', 'Method', 'Saver.Save', iface.nodeId, { + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['a.User'], + returnType: 'error', + }); + const structSave = goDef('struct:Repo.Save', 'Method', 'Repo.Save', struct.nodeId, { + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['b.User'], + returnType: 'error', + }); + + const result = detectGoInterfaceImplementations( + parsedGoDefs([iface, struct, ifaceSave, structSave]), + emptyIndexes, + {} as any, + ); + + expect(result.get(iface.nodeId)).toBeUndefined(); + }); + + it('does not match signatures with unresolved import-qualified types', () => { + const iface = goDef('iface:Saver', 'Interface', 'Saver'); + const struct = goDef('struct:Repo', 'Struct', 'Repo'); + const ifaceSave = goDef('iface:Saver.Save', 'Method', 'Saver.Save', iface.nodeId, { + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['missing.User'], + returnType: 'error', + }); + const structSave = goDef('struct:Repo.Save', 'Method', 'Repo.Save', struct.nodeId, { + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['missing.User'], + returnType: 'error', + }); + + const result = detectGoInterfaceImplementations( + parsedGoDefs([iface, struct, ifaceSave, structSave]), + emptyIndexes, + {} as any, + ); + + expect(result.get(iface.nodeId)).toBeUndefined(); + }); + + it('rejects methods missing an interface-required return type', () => { + const iface = goDef('iface:Closer', 'Interface', 'Closer'); + const struct = goDef('struct:NoReturnCloser', 'Struct', 'NoReturnCloser'); + const ifaceClose = goDef('iface:Closer.Close', 'Method', 'Closer.Close', iface.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'error', + }); + const structClose = goDef( + 'struct:NoReturnCloser.Close', + 'Method', + 'NoReturnCloser.Close', + struct.nodeId, + { + parameterCount: 0, + requiredParameterCount: 0, + }, + ); + + const result = detectGoInterfaceImplementations( + parsedGoDefs([iface, struct, ifaceClose, structClose]), + emptyIndexes, + {} as any, + ); + + expect(result.get(iface.nodeId)).toBeUndefined(); + }); + + it('rejects methods with fewer grouped return values than the interface requires', () => { + const iface = goDef('iface:PairReader', 'Interface', 'PairReader'); + const struct = goDef('struct:SingleReader', 'Struct', 'SingleReader'); + const ifaceRead = goDef('iface:PairReader.Read', 'Method', 'PairReader.Read', iface.nodeId, { + parameterCount: 0, + requiredParameterCount: 0, + returnType: '(int, int)', + }); + const structRead = goDef( + 'struct:SingleReader.Read', + 'Method', + 'SingleReader.Read', + struct.nodeId, + { + parameterCount: 0, + requiredParameterCount: 0, + returnType: 'int', + }, + ); + + const result = detectGoInterfaceImplementations( + parsedGoDefs([iface, struct, ifaceRead, structRead]), + emptyIndexes, + {} as any, + ); + + expect(result.get(iface.nodeId)).toBeUndefined(); + }); + + it('rejects interface methods without enough signature metadata', () => { + const iface = goDef('iface:Repository', 'Interface', 'Repository'); + const struct = goDef('struct:SqlRepository', 'Struct', 'SqlRepository'); + const ifaceSave = goDef('iface:Repository.Save', 'Method', 'Repository.Save', iface.nodeId); + const structSave = goDef( + 'struct:SqlRepository.Save', + 'Method', + 'SqlRepository.Save', + struct.nodeId, + { + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['User'], + returnType: 'error', + }, + ); + + const result = detectGoInterfaceImplementations( + parsedGoDefs([iface, struct, ifaceSave, structSave]), + emptyIndexes, + {} as any, + ); + + expect(result.get(iface.nodeId)).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/go/go-type-binding.test.ts b/gitnexus/test/unit/scope-resolution/go/go-type-binding.test.ts index 96da250fb..2108fae41 100644 --- a/gitnexus/test/unit/scope-resolution/go/go-type-binding.test.ts +++ b/gitnexus/test/unit/scope-resolution/go/go-type-binding.test.ts @@ -16,7 +16,9 @@ describe('Go receiver binding', () => { const result = synthesizeGoReceiverBinding(methodNode as any)!; expect(result['@type-binding.self']).toBeDefined(); expect(result['@type-binding.name']!.text).toBe('u'); - expect(result['@type-binding.type']!.text).toBe('User'); + expect(result['@type-binding.type']!.text).toBe('*User'); + const parsed = interpretGoTypeBinding(result); + expect(parsed?.rawTypeName).toBe('*User'); }); it('returns null for free function', () => { @@ -67,6 +69,25 @@ describe('Go type binding synthesis — 7 patterns', () => { expect(qMatch).toBeDefined(); }); + it('prefers concrete RHS composite literal type for explicit interface var declarations', () => { + const src = `package main +type Repository interface{ Save(User) error } +type User struct{} +type SqlRepository struct{} +func main() { + var repo Repository = SqlRepository{} + _ = repo +}`; + const matches = emitGoScopeCaptures(src, 'main.go'); + const concreteMatch = matches.find( + (m) => + m['@type-binding.constructor'] !== undefined && + m['@type-binding.name']?.text === 'repo' && + m['@type-binding.type']?.text === 'SqlRepository', + ); + expect(concreteMatch).toBeDefined(); + }); + it('keeps multi-assignment constructor bindings aligned with RHS positions', () => { const src = 'package main\nfunc main() {\n a, b := 42, X{}\n}'; const bindings = emitGoScopeCaptures(src, 'main.go')