From 705e3c733a15a30a00b02978230bed821ccc42e5 Mon Sep 17 00:00:00 2001 From: Evan Wang Date: Fri, 1 May 2026 03:51:58 +0800 Subject: [PATCH] feat(go): implement scope resolution hooks for Go language support Add Go-specific scope resolution hooks (type binding, receiver binding, range binding, import decomposition, wildcard expansion, package siblings, interface implementations) as a LanguageProvider module. Update shared finalize algorithm to support multi-file import targets required by Go's package-scoped import semantics. Update pipeline golden snapshot. --- .../scope-resolution/finalize-algorithm.ts | 79 +++--- gitnexus/src/core/ingestion/languages/go.ts | 20 ++ .../ingestion/languages/go/arity-metadata.ts | 46 ++++ .../src/core/ingestion/languages/go/arity.ts | 16 ++ .../ingestion/languages/go/cache-stats.ts | 18 ++ .../core/ingestion/languages/go/captures.ts | 174 ++++++++++++ .../languages/go/expand-wildcards.ts | 97 +++++++ .../languages/go/import-decomposer.ts | 48 ++++ .../ingestion/languages/go/import-target.ts | 81 ++++++ .../src/core/ingestion/languages/go/index.ts | 16 ++ .../ingestion/languages/go/interface-impls.ts | 87 ++++++ .../core/ingestion/languages/go/interpret.ts | 124 +++++++++ .../ingestion/languages/go/merge-bindings.ts | 27 ++ .../ingestion/languages/go/method-owners.ts | 73 +++++ .../languages/go/package-siblings.ts | 91 +++++++ .../src/core/ingestion/languages/go/query.ts | 212 +++++++++++++++ .../ingestion/languages/go/range-binding.ts | 101 +++++++ .../languages/go/receiver-binding.ts | 21 ++ .../ingestion/languages/go/scope-resolver.ts | 50 ++++ .../ingestion/languages/go/simple-hooks.ts | 38 +++ .../ingestion/languages/go/type-binding.ts | 250 ++++++++++++++++++ .../src/core/ingestion/scope-extractor.ts | 4 + .../contract/scope-resolver.ts | 49 +++- .../scope-resolution/graph-bridge/ids.ts | 1 - .../passes/free-call-fallback.ts | 49 ++++ .../passes/imported-return-types.ts | 49 ++++ .../ingestion/scope-resolution/passes/mro.ts | 3 +- .../passes/receiver-bound-calls.ts | 103 +++++--- .../scope-resolution/pipeline/registry.ts | 2 + .../scope-resolution/pipeline/run.ts | 22 +- .../scope/namespace-targets.ts | 11 +- .../scope-resolution/scope/walkers.ts | 21 ++ .../go/go-captures-smoke.test.ts | 48 ++++ .../unit/scope-resolution/go/go-hooks.test.ts | 130 +++++++++ .../scope-resolution/go/go-imports.test.ts | 114 ++++++++ .../go/go-interface-impls.test.ts | 9 + .../go/go-package-siblings.test.ts | 59 +++++ .../go/go-range-binding.test.ts | 9 + .../go/go-type-binding.test.ts | 101 +++++++ 39 files changed, 2367 insertions(+), 86 deletions(-) create mode 100644 gitnexus/src/core/ingestion/languages/go/arity-metadata.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/arity.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/cache-stats.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/captures.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/expand-wildcards.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/import-decomposer.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/import-target.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/index.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/interface-impls.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/interpret.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/merge-bindings.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/method-owners.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/package-siblings.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/query.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/range-binding.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/receiver-binding.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/scope-resolver.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/simple-hooks.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/type-binding.ts create mode 100644 gitnexus/test/unit/scope-resolution/go/go-captures-smoke.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/go/go-imports.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/go/go-interface-impls.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/go/go-package-siblings.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/go/go-range-binding.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/go/go-type-binding.test.ts diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts index d363c3c97..df27a8511 100644 --- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -93,7 +93,7 @@ export interface FinalizeHooks { targetRaw: string, fromFile: string, workspaceIndex: WorkspaceIndex, - ): string | null; + ): string | readonly string[] | null; /** * For a wildcard `import * from M`, return the names visible in the @@ -179,9 +179,9 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu for (const file of input.files) { const drafts: ImportEdgeDraft[] = []; for (const parsed of file.parsedImports) { - const draft = makeEdgeDraft(parsed, file, hooks, input.workspaceIndex); - drafts.push(draft); - totalEdges++; + const draftArray = makeEdgeDrafts(parsed, file, hooks, input.workspaceIndex); + drafts.push(...draftArray); + totalEdges += draftArray.length; } edgeIndex.set(file.filePath, drafts); } @@ -320,12 +320,12 @@ interface ImportEdgeDraft { finalized: ImportEdge | null; } -function makeEdgeDraft( +function makeEdgeDrafts( parsed: ParsedImport, file: FinalizeFile, hooks: FinalizeHooks, workspace: WorkspaceIndex, -): ImportEdgeDraft { +): ImportEdgeDraft[] { // Dynamic-unresolved passes through — no `BindingRef`, no target file. if (parsed.kind === 'dynamic-unresolved') { const base: ImportEdge = { @@ -334,14 +334,16 @@ function makeEdgeDraft( targetExportedName: '', kind: 'dynamic-unresolved', }; - return { - source: parsed, - fromFile: file.filePath, - fromScope: file.moduleScope, - targetFile: null, - base, - finalized: base, // already fully finalized - }; + return [ + { + source: parsed, + fromFile: file.filePath, + fromScope: file.moduleScope, + targetFile: null, + base, + finalized: base, // already fully finalized + }, + ]; } const targetFile = hooks.resolveImportTarget(parsed.targetRaw ?? '', file.filePath, workspace); @@ -355,14 +357,16 @@ function makeEdgeDraft( kind: edgeKindFor(parsed), linkStatus: 'unresolved', }; - return { - source: parsed, - fromFile: file.filePath, - fromScope: file.moduleScope, - targetFile: null, - base, - finalized: base, - }; + return [ + { + source: parsed, + fromFile: file.filePath, + fromScope: file.moduleScope, + targetFile: null, + base, + finalized: base, + }, + ]; } // Resolvable at the file level; intra-SCC fixpoint may still fail to fill @@ -370,21 +374,24 @@ function makeEdgeDraft( // and resolved-dynamic imports are terminal at the file level — no // `targetDefId` needed since they materialize no `BindingRef`. Pre- // finalize them here so the fixpoint loop skips them entirely. - const base: ImportEdge = { - localName: extractLocalName(parsed), - targetFile, - targetExportedName: extractExportedName(parsed), - kind: edgeKindFor(parsed), - }; + const targetFiles = Array.isArray(targetFile) ? targetFile : [targetFile]; const isFileLevelTerminal = parsed.kind === 'side-effect' || parsed.kind === 'dynamic-resolved'; - return { - source: parsed, - fromFile: file.filePath, - fromScope: file.moduleScope, - targetFile, - base, - finalized: isFileLevelTerminal ? base : null, - }; + return targetFiles.map((tf) => { + const base: ImportEdge = { + localName: extractLocalName(parsed), + targetFile: tf, + targetExportedName: extractExportedName(parsed), + kind: edgeKindFor(parsed), + }; + return { + source: parsed, + fromFile: file.filePath, + fromScope: file.moduleScope, + targetFile: tf, + base, + finalized: isFileLevelTerminal ? base : null, + }; + }); } function edgeKindFor(parsed: ParsedImport): ImportEdge['kind'] { diff --git a/gitnexus/src/core/ingestion/languages/go.ts b/gitnexus/src/core/ingestion/languages/go.ts index 245dc197b..202588f18 100644 --- a/gitnexus/src/core/ingestion/languages/go.ts +++ b/gitnexus/src/core/ingestion/languages/go.ts @@ -28,6 +28,15 @@ import { createCallExtractor } from '../call-extractors/generic.js'; import { goCallConfig } from '../call-extractors/configs/go.js'; import { createHeritageExtractor } from '../heritage-extractors/generic.js'; import { goHeritageConfig } from '../heritage-extractors/configs/go.js'; +import { + emitGoScopeCaptures, + goArityCompatibility, + goBindingScopeFor, + goImportOwningScope, + goReceiverBinding, + interpretGoImport, + interpretGoTypeBinding, +} from './go/index.js'; export const goProvider = defineLanguage({ id: SupportedLanguages.Go, @@ -43,4 +52,15 @@ export const goProvider = defineLanguage({ variableExtractor: createVariableExtractor(goVariableConfig), classExtractor: createClassExtractor(goClassConfig), heritageExtractor: createHeritageExtractor(goHeritageConfig), + + // ── RFC #909 Ring 3: scope-based resolution hooks ────────── + emitScopeCaptures: emitGoScopeCaptures, + interpretImport: interpretGoImport, + interpretTypeBinding: interpretGoTypeBinding, + bindingScopeFor: goBindingScopeFor, + importOwningScope: goImportOwningScope, + receiverBinding: goReceiverBinding, + arityCompatibility: goArityCompatibility, + // resolveImportTarget lives on ScopeResolver (4-param signature), + // not on LanguageProvider (2-param signature). See go/scope-resolver.ts. }); diff --git a/gitnexus/src/core/ingestion/languages/go/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/go/arity-metadata.ts new file mode 100644 index 000000000..958d0e70e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/arity-metadata.ts @@ -0,0 +1,46 @@ +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +export interface GoArityMetadata { + readonly parameterCount?: number; + readonly requiredParameterCount?: number; + readonly parameterTypes?: readonly string[]; +} + +export function computeGoDeclarationArity(node: SyntaxNode): GoArityMetadata { + const params = node.childForFieldName('parameters'); + if (params === null) return {}; + + let count = 0; + let required = 0; + const types: string[] = []; + + for (let i = 0; i < params.namedChildCount; i++) { + const param = params.namedChild(i); + if (param === null) continue; + if (param.type === 'parameter_declaration') { + const typeNode = param.childForFieldName('type'); + const typeName = typeNode === null ? '' : typeNode.text; + const names = param.namedChildren.filter((c) => c.type === 'identifier'); + const n = Math.max(1, names.length); + for (let j = 0; j < n; j++) { + count++; + required++; + types.push(typeName); + } + } + if (param.type === 'variadic_parameter_declaration') { + const typeNode = param.childForFieldName('type'); + const typeName = typeNode === null ? '...' : `...${typeNode.text}`; + count++; + types.push(typeName); + } + } + + return { parameterCount: count, requiredParameterCount: required, parameterTypes: types }; +} + +export function computeGoCallArity(callNode: SyntaxNode): number { + const args = callNode.childForFieldName('arguments'); + if (args === null) return 0; + return args.namedChildCount; +} diff --git a/gitnexus/src/core/ingestion/languages/go/arity.ts b/gitnexus/src/core/ingestion/languages/go/arity.ts new file mode 100644 index 000000000..6fc76661e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/arity.ts @@ -0,0 +1,16 @@ +import type { Callsite, SymbolDefinition } from 'gitnexus-shared'; + +export function goArityCompatibility( + def: SymbolDefinition, + callsite: Callsite, +): 'compatible' | 'unknown' | 'incompatible' { + const max = def.parameterCount; + const min = def.requiredParameterCount; + if (max === undefined && min === undefined) return 'unknown'; + if (!Number.isFinite(callsite.arity) || callsite.arity < 0) return 'unknown'; + + const variadic = def.parameterTypes?.some((t) => t.startsWith('...')) ?? false; + if (min !== undefined && callsite.arity < min) return 'incompatible'; + if (max !== undefined && callsite.arity > max && !variadic) return 'incompatible'; + return 'compatible'; +} diff --git a/gitnexus/src/core/ingestion/languages/go/cache-stats.ts b/gitnexus/src/core/ingestion/languages/go/cache-stats.ts new file mode 100644 index 000000000..cee1ec9aa --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/cache-stats.ts @@ -0,0 +1,18 @@ +let hits = 0; +let misses = 0; + +export function recordGoCacheHit(): void { + hits++; +} +export function recordGoCacheMiss(): void { + misses++; +} + +export function getGoCaptureCacheStats(): { readonly hits: number; readonly misses: number } { + return { hits, misses }; +} + +export function resetGoCaptureCacheStats(): void { + hits = 0; + misses = 0; +} diff --git a/gitnexus/src/core/ingestion/languages/go/captures.ts b/gitnexus/src/core/ingestion/languages/go/captures.ts new file mode 100644 index 000000000..baffa9210 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/captures.ts @@ -0,0 +1,174 @@ +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js'; +import { getGoParser, getGoScopeQuery } from './query.js'; +import { recordGoCacheHit, recordGoCacheMiss } from './cache-stats.js'; +import { computeGoCallArity, computeGoDeclarationArity } from './arity-metadata.js'; +import { splitGoImportStatement } from './import-decomposer.js'; +import { synthesizeGoReceiverBinding } from './receiver-binding.js'; +import { synthesizeGoTypeBindings } from './type-binding.js'; +import { getTreeSitterBufferSize } from '../../constants.js'; + +function inferPackageName(sourceText: string): string | null { + const match = sourceText.match(/^\s*package\s+([A-Za-z_][A-Za-z0-9_]*)/m); + return match?.[1] ?? null; +} + +export function emitGoScopeCaptures( + sourceText: string, + _filePath: string, + cachedTree?: unknown, +): readonly CaptureMatch[] { + let tree = cachedTree as ReturnType['parse']> | undefined; + if (tree === undefined) { + tree = getGoParser().parse(sourceText, undefined, { + bufferSize: getTreeSitterBufferSize(sourceText), + }); + recordGoCacheMiss(); + } else { + recordGoCacheHit(); + } + + const rawMatches = getGoScopeQuery().matches(tree.rootNode); + const out: CaptureMatch[] = []; + const pkgName = inferPackageName(sourceText); + + for (const m of rawMatches) { + const grouped: Record = {}; + for (const c of m.captures) { + const tag = '@' + c.name; + if (tag.startsWith('@_')) continue; // skip anonymous captures + grouped[tag] = nodeToCapture(tag, c.node); + } + if (Object.keys(grouped).length === 0) continue; + + if (grouped['@import.statement'] !== undefined) { + const anchor = grouped['@import.statement']!; + const importNode = + findNodeAtRange(tree.rootNode, anchor.range, 'import_declaration') ?? + findNodeAtRange(tree.rootNode, anchor.range, 'import_spec'); + if (importNode !== null) { + out.push(...splitGoImportStatement(importNode)); + continue; + } + } + + if (grouped['@scope.function'] !== undefined) { + const scopeCap = grouped['@scope.function']!; + const fnNode = + findNodeAtRange(tree.rootNode, scopeCap.range, 'function_declaration') ?? + findNodeAtRange(tree.rootNode, scopeCap.range, 'method_declaration'); + if (fnNode !== null) { + const receiver = synthesizeGoReceiverBinding(fnNode); + if (receiver !== null) out.push(receiver); + } + } + + const declAnchor = grouped['@declaration.function'] ?? grouped['@declaration.method']; + if (declAnchor !== undefined) { + const fnNode = + findNodeAtRange(tree.rootNode, declAnchor.range, 'function_declaration') ?? + findNodeAtRange(tree.rootNode, declAnchor.range, 'method_declaration'); + if (fnNode !== null) { + const arity = computeGoDeclarationArity(fnNode); + if (arity.parameterCount !== undefined) { + grouped['@declaration.parameter-count'] = syntheticCapture( + '@declaration.parameter-count', + fnNode, + String(arity.parameterCount), + ); + } + if (arity.requiredParameterCount !== undefined) { + grouped['@declaration.required-parameter-count'] = syntheticCapture( + '@declaration.required-parameter-count', + fnNode, + String(arity.requiredParameterCount), + ); + } + if (arity.parameterTypes !== undefined) { + grouped['@declaration.parameter-types'] = syntheticCapture( + '@declaration.parameter-types', + fnNode, + JSON.stringify(arity.parameterTypes), + ); + } + } + out.push(grouped); + continue; + } + + const callAnchor = + grouped['@reference.call.free'] ?? + grouped['@reference.call.member'] ?? + grouped['@reference.call.constructor']; + if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) { + const callNode = + findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression') ?? + findNodeAtRange(tree.rootNode, callAnchor.range, 'composite_literal'); + if (callNode !== null) { + grouped['@reference.arity'] = syntheticCapture( + '@reference.arity', + callNode, + String(computeGoCallArity(callNode)), + ); + } + } + + out.push(grouped); + } + + // Layer on type-binding synthesis (new/make/qualified composite literal) + const synthesized = synthesizeGoTypeBindings(tree.rootNode); + out.push(...synthesized); + + // Synthesize typeBindings for struct fields so compound receiver + // resolution (`user.Address.Save()`) can walk field types. + for (const match of out) { + if (match['@declaration.field'] === undefined) continue; + const nameCap = match['@declaration.name']; + const typeCap = match['@declaration.field-type']; + if (nameCap === undefined || typeCap === undefined) continue; + // Create a synthetic @type-binding.field match using the field + // name and its declared type from the @declaration.field-type capture. + // This lands in the Class scope's typeBindings (via pass4 positioning). + out.push({ + '@type-binding.field': typeCap, + '@type-binding.name': nameCap, + '@type-binding.type': { + name: '@type-binding.type', + text: typeCap.text, + range: { ...typeCap.range }, + }, + }); + } + + // Qualify same-package return-type captures: strip wrapper types + // (`*`, `[]`, etc.) first, then prepend `pkg.` so the rawName + // matches the qualified name we stamped on declarations. + // Cross-package types (`*models.User`) already carry a dot and are + // left as-is. + if (pkgName !== null) { + for (let i = 0; i < out.length; i++) { + const match = out[i]!; + if (match['@type-binding.return'] === undefined) continue; + const typeCap = match['@type-binding.type']; + if (typeCap === undefined || typeCap.text.includes('.')) continue; + let raw = typeCap.text.trim(); + while (raw.startsWith('*')) raw = raw.slice(1).trim(); + if (raw.startsWith('[]')) raw = raw.slice(2).trim(); + // Not a builtin or generic — safe to qualify. + if (raw.includes('.') || raw.startsWith('func(') || raw.startsWith('map[')) continue; + const idx = raw.indexOf('['); + if (idx !== -1) raw = raw.slice(0, idx); + out[i] = { + ...match, + '@type-binding.type': { + name: '@type-binding.type', + text: pkgName + '.' + raw, + range: { ...typeCap.range }, + }, + }; + } + } + + return out; +} diff --git a/gitnexus/src/core/ingestion/languages/go/expand-wildcards.ts b/gitnexus/src/core/ingestion/languages/go/expand-wildcards.ts new file mode 100644 index 000000000..ece9120c0 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/expand-wildcards.ts @@ -0,0 +1,97 @@ +import type { BindingRef, ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; + +/** + * Expand Go dot imports (`import . "pkg"`) into binding augmentations. + * + * Go dot imports are treated as wildcard imports in the scope model. + * The shared `expandsWildcardTo` hook defaults to returning `[]` for Go + * because it can't easily access the target module's exported defs + * (it only receives a `ScopeId`). Instead we post-process wildcard + * import edges and augment bindings with the target file's exported + * (uppercase) defs — the same augmentation channel used by + * `populateGoPackageSiblings` for same-package cross-file visibility. + */ +export function expandGoDotImports( + parsedFiles: readonly ParsedFile[], + indexes: ScopeResolutionIndexes, +): void { + const augmentations = indexes.bindingAugmentations as Map>; + + for (const parsed of parsedFiles) { + const moduleEdges = indexes.imports.get(parsed.moduleScope); + if (moduleEdges === undefined) continue; + + const wildcardTargets: string[] = []; + for (const edge of moduleEdges) { + // Go dot imports start as `kind: 'wildcard'`; finalize materializes + // them as `wildcard-expanded` import edges. + if (edge.kind !== 'wildcard-expanded' && edge.kind !== 'dynamic-resolved') { + continue; + } + if (edge.targetFile === null) continue; + if (!wildcardTargets.includes(edge.targetFile)) wildcardTargets.push(edge.targetFile); + } + if (wildcardTargets.length === 0) continue; + + for (const targetFile of wildcardTargets) { + const targetModule = indexes.moduleScopes.byFilePath.get(targetFile); + if (targetModule === undefined) continue; + + // Walk target module's local bindings — these are the exported symbols. + const targetBindings = indexes.bindings.get(targetModule); + if (targetBindings === undefined) continue; + + for (const [name, refs] of targetBindings) { + if (name.length === 0) continue; + // Only exported names (uppercase first char — Go convention). + const first = name[0]!; + if (first < 'A' || first > 'Z') continue; + + // Check if the importer already has this name. + const importerBindings = indexes.bindings.get(parsed.moduleScope); + if (importerBindings?.has(name)) continue; + + let augBucket = augmentations.get(parsed.moduleScope); + if (augBucket === undefined) { + augBucket = new Map(); + augmentations.set(parsed.moduleScope, augBucket); + } + + let entries = augBucket.get(name); + if (entries === undefined) { + entries = []; + augBucket.set(name, entries); + } + + for (const ref of refs) { + if (ref.origin !== 'local') continue; + if (entries.some((e) => e.def.nodeId === ref.def.nodeId)) continue; + entries.push({ def: ref.def, origin: 'wildcard' }); + } + } + } + } +} + +export function expandGoWildcardNames( + targetModuleScope: ScopeId, + parsedFiles: readonly ParsedFile[], +): readonly string[] { + const target = parsedFiles.find((parsed) => parsed.moduleScope === targetModuleScope); + if (target === undefined) return []; + + const names: string[] = []; + for (const def of target.localDefs) { + const name = simpleName(def); + if (name === '') continue; + const first = name[0]!; + if (first < 'A' || first > 'Z') continue; + if (!names.includes(name)) names.push(name); + } + return names; +} + +function simpleName(def: SymbolDefinition): string { + return def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; +} diff --git a/gitnexus/src/core/ingestion/languages/go/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/go/import-decomposer.ts new file mode 100644 index 000000000..1bf466316 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/import-decomposer.ts @@ -0,0 +1,48 @@ +import type { CaptureMatch } from 'gitnexus-shared'; +import { syntheticCapture } from '../../utils/ast-helpers.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +export function splitGoImportStatement(node: SyntaxNode): CaptureMatch[] { + if (node.type === 'import_declaration') { + const out: CaptureMatch[] = []; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'import_spec') out.push(...splitGoImportStatement(child)); + if (child?.type === 'import_spec_list') { + for (let j = 0; j < child.namedChildCount; j++) { + const spec = child.namedChild(j); + if (spec?.type === 'import_spec') out.push(...splitGoImportStatement(spec)); + } + } + } + return out; + } + + if (node.type !== 'import_spec') return []; + const pathNode = node.childForFieldName('path'); + if (pathNode === null) return []; + + const rawPath = pathNode.text.replace(/^"|"$/g, '').replace(/^`|`$/g, ''); + const nameNode = node.childForFieldName('name'); + const alias = nameNode?.text; + const leaf = rawPath.split('/').filter(Boolean).pop() ?? rawPath; + const kind = + alias === '.' ? 'dot' : alias === '_' ? 'blank' : alias === undefined ? 'namespace' : 'alias'; + + if (kind === 'blank') return []; + + const aliased = alias !== undefined && alias !== '.' && alias !== '_'; + return [ + { + '@import.statement': syntheticCapture('@import.statement', node, node.text), + '@import.kind': syntheticCapture('@import.kind', node, kind), + '@import.source': syntheticCapture('@import.source', pathNode, rawPath), + '@import.name': syntheticCapture( + '@import.name', + nameNode ?? pathNode, + aliased ? alias! : leaf, + ), + ...(aliased ? { '@import.alias': syntheticCapture('@import.alias', nameNode!, alias!) } : {}), + }, + ]; +} diff --git a/gitnexus/src/core/ingestion/languages/go/import-target.ts b/gitnexus/src/core/ingestion/languages/go/import-target.ts new file mode 100644 index 000000000..c334fb2a6 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/import-target.ts @@ -0,0 +1,81 @@ +import type { GoModuleConfig } from '../../language-config.js'; + +/** + * Resolve a Go import path to ALL .go files in the matching package directory. + * + * Go packages are directory-scoped: one import statement brings in every + * (non-test) .go file in the package directory. Return all matching files so + * the shared finalize pass creates one ImportEdge per file — enabling both + * IMPORTS edge fanout AND binding materialization for every exported symbol in + * the package. + * + * Strategy (first match wins): + * 1. go.mod-based: strip module prefix, match package directory + * 2. Non-go.mod / GOPATH: progressively shorter directory suffixes + */ +export function resolveGoImportTarget( + targetRaw: string, + _fromFile: string, + allFilePaths: ReadonlySet, + resolutionConfig?: unknown, +): string | readonly string[] | null { + if (!targetRaw) return null; + + const goModule = resolutionConfig as GoModuleConfig | undefined; + + // 1) go.mod-based: strip module prefix, match directory + if ( + goModule != null && + (targetRaw === goModule.modulePath || targetRaw.startsWith(`${goModule.modulePath}/`)) + ) { + const relativePkg = + targetRaw === goModule.modulePath ? '' : targetRaw.slice(goModule.modulePath.length + 1); // e.g. "internal/models" + const files = + relativePkg === '' + ? findRootPackageFiles(allFilePaths) + : findAllFilesInPkgDir(allFilePaths, relativePkg); + if (files.length > 0) return files; + } + + // 2) Non-go.mod / GOPATH: progressively shorter directory suffixes + // "github.com/xxx/yyy/pkg" → try "github.com/xxx/yyy/pkg/" → "xxx/yyy/pkg/" → "yyy/pkg/" → "pkg/" + const parts = targetRaw.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + const files = findAllFilesInPkgDir(allFilePaths, parts.slice(i).join('/')); + if (files.length > 0) return files; + } + + return null; +} + +function findRootPackageFiles(allFilePaths: ReadonlySet): string[] { + const result: string[] = []; + for (const raw of allFilePaths) { + const normalized = raw.replace(/\\/g, '/'); + if (normalized.includes('/')) continue; + if (!normalized.endsWith('.go') || normalized.endsWith('_test.go')) continue; + result.push(raw); + } + return result.sort(); +} + +function findAllFilesInPkgDir(allFilePaths: ReadonlySet, pkgPath: string): string[] { + const pkgDir = '/' + pkgPath + '/'; + const result: string[] = []; + for (const raw of allFilePaths) { + const normalized = '/' + raw.replace(/\\/g, '/'); + if (!normalized.includes(pkgDir)) continue; + if (!normalized.endsWith('.go') || normalized.endsWith('_test.go')) continue; + // Ensure file is directly in the package directory (not a subdirectory) + const afterPkg = normalized.substring(normalized.indexOf(pkgDir) + pkgDir.length); + if (!afterPkg.includes('/')) result.push(raw); + } + return result; +} + +/** Preserved for backward compat. */ +export interface GoResolveContext { + readonly fromFile: string; + readonly allFilePaths: ReadonlySet; + readonly goModule?: GoModuleConfig; +} diff --git a/gitnexus/src/core/ingestion/languages/go/index.ts b/gitnexus/src/core/ingestion/languages/go/index.ts new file mode 100644 index 000000000..8d8037657 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/index.ts @@ -0,0 +1,16 @@ +/** + * Go scope-resolution hooks (RFC #909 Ring 3). + */ +export { emitGoScopeCaptures } from './captures.js'; +export { getGoCaptureCacheStats, resetGoCaptureCacheStats } from './cache-stats.js'; +export { interpretGoImport, interpretGoTypeBinding, normalizeGoTypeName } from './interpret.js'; +export { splitGoImportStatement } from './import-decomposer.js'; +export { synthesizeGoReceiverBinding } from './receiver-binding.js'; +export { synthesizeGoTypeBindings } from './type-binding.js'; +export { goArityCompatibility } from './arity.js'; +export { goMergeBindings } from './merge-bindings.js'; +export { goBindingScopeFor, goImportOwningScope, goReceiverBinding } from './simple-hooks.js'; +export { resolveGoImportTarget, type GoResolveContext } from './import-target.js'; +export { populateGoPackageSiblings } from './package-siblings.js'; +export { populateGoRangeBindings } from './range-binding.js'; +export { detectGoInterfaceImplementations } from './interface-impls.js'; diff --git a/gitnexus/src/core/ingestion/languages/go/interface-impls.ts b/gitnexus/src/core/ingestion/languages/go/interface-impls.ts new file mode 100644 index 000000000..bf21117a1 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/interface-impls.ts @@ -0,0 +1,87 @@ +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import type { SemanticModel } from '../../model/semantic-model.js'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; + +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(); + + // 2. Collect struct defs → method names + const structMethods = 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); + } + + 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); + } + } + } + + // 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; + const implementors: string[] = []; + for (const [structId, methods] of structMethods) { + if (isSuperset(methods, ifaceMethods)) { + implementors.push(structId); + } + } + if (implementors.length > 0) impls.set(ifaceId, implementors); + } + + return impls; +} + +function isSuperset(superset: Set, subset: Set): boolean { + for (const item of subset) { + if (!superset.has(item)) return false; + } + return true; +} diff --git a/gitnexus/src/core/ingestion/languages/go/interpret.ts b/gitnexus/src/core/ingestion/languages/go/interpret.ts new file mode 100644 index 000000000..d27cbdf8c --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/interpret.ts @@ -0,0 +1,124 @@ +import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared'; + +export function interpretGoImport(captures: CaptureMatch): ParsedImport | null { + const kind = captures['@import.kind']?.text; + const source = captures['@import.source']?.text; + const name = captures['@import.name']?.text; + const alias = captures['@import.alias']?.text; + if (kind === undefined || source === undefined) return null; + + if (kind === 'dot') return { kind: 'wildcard', targetRaw: source }; + if (kind === 'alias') { + if (alias === undefined || name === undefined) return null; + return { kind: 'alias', localName: alias, importedName: name, alias, targetRaw: source }; + } + if (kind === 'namespace') { + if (name === undefined) return null; + return { kind: 'namespace', localName: name, importedName: name, targetRaw: source }; + } + return null; +} + +export function interpretGoTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null { + const name = captures['@type-binding.name']?.text; + const type = captures['@type-binding.type']?.text; + if (name === undefined || type === undefined) return null; + + let source: TypeRef['source'] = 'annotation'; + let normalizedType: string; + if (captures['@type-binding.self'] !== undefined) { + source = 'self'; + normalizedType = normalizeGoTypeName(type); + } else if (captures['@type-binding.constructor'] !== undefined) { + source = 'constructor-inferred'; + normalizedType = normalizeGoTypeName(type); + } else if (captures['@type-binding.call-return'] !== undefined) { + source = 'constructor-inferred'; + normalizedType = normalizeGoTypeName(type); + } else if (captures['@type-binding.assertion'] !== undefined) { + source = 'annotation'; + normalizedType = normalizeGoTypeName(type); + } else if (captures['@type-binding.new'] !== undefined) { + source = 'constructor-inferred'; + normalizedType = normalizeGoTypeName(type); + } else if (captures['@type-binding.field'] !== undefined) { + source = 'assignment-inferred'; + normalizedType = normalizeGoTypeName(type); + } else if (captures['@type-binding.range'] !== undefined) { + source = 'constructor-inferred'; + normalizedType = normalizeGoTypeName(type); + } else if (captures['@type-binding.index'] !== undefined) { + source = 'constructor-inferred'; + normalizedType = normalizeGoTypeName(type); + } else if (captures['@type-binding.multi-assign'] !== undefined) { + source = 'constructor-inferred'; + normalizedType = normalizeGoTypeName(type); + } else if (captures['@type-binding.make'] !== undefined) { + source = 'constructor-inferred'; + normalizedType = normalizeGoTypeName(type); + } else if (captures['@type-binding.return'] !== undefined) { + // Preserve dotted names for cross-package return-type chains. + source = 'return-annotation'; + normalizedType = normalizeGoReturnType(type); + } else if (captures['@type-binding.alias'] !== undefined) { + source = 'assignment-inferred'; + normalizedType = normalizeGoTypeName(type); + } else if (captures['@type-binding.assignment'] !== undefined) { + source = 'assignment-inferred'; + normalizedType = normalizeGoTypeName(type); + } else if (captures['@type-binding.parameter'] !== undefined) { + source = 'parameter-annotation'; + normalizedType = normalizeGoTypeName(type); + } else { + normalizedType = normalizeGoTypeName(type); + } + + return { boundName: name, rawTypeName: normalizedType, source }; +} + +export function normalizeGoTypeName(text: string): string { + let t = text.trim(); + while (t.startsWith('*')) t = t.slice(1).trim(); + if (t.startsWith('[]')) t = t.slice(2).trim(); + const mapMatch = t.match(/^map\[[^\]]+\]\s*(.+)$/); + if (mapMatch) t = mapMatch[1].trim(); + t = t.replace(/^(?:<-)?chan\s+/, ''); + if (t.startsWith('func(')) { + const retMatch = t.match(/^func\([^)]*\)\s*(.*)$/); + if (retMatch) t = retMatch[1].trim(); + } + const dot = t.lastIndexOf('.'); + if (dot !== -1) t = t.slice(dot + 1); + const bracket = t.indexOf('['); + if (bracket !== -1) t = t.slice(0, bracket); + return t; +} + +/** + * Like `normalizeGoTypeName` but preserves dotted package-prefix + * (`models.User` stays `models.User`). Used for return-type + * annotations so cross-package type chains can resolve through + * QualifiedNameIndex (which carries `pkg.Type` entries). + */ +export function normalizeGoReturnType(text: string): string { + let t = text.trim(); + // Multi-return syntax: (*T, error) → extract first type. Handles + // the common Go pattern where functions return (value, error). + if (t.startsWith('(') && t.includes(',')) { + const closeIdx = t.indexOf(','); + t = t.slice(1, closeIdx).trim(); + } + while (t.startsWith('*')) t = t.slice(1).trim(); + if (t.startsWith('[]')) t = t.slice(2).trim(); + const mapMatch = t.match(/^map\[[^\]]+\]\s*(.+)$/); + if (mapMatch) t = mapMatch[1].trim(); + t = t.replace(/^(?:<-)?chan\s+/, ''); + if (t.startsWith('func(')) { + const retMatch = t.match(/^func\([^)]*\)\s*(.*)$/); + if (retMatch) t = retMatch[1].trim(); + } + // Preserve dotted qualified names for cross-package resolution. + const bracket = t.indexOf('['); + if (bracket !== -1) t = t.slice(0, bracket); + return t; +} diff --git a/gitnexus/src/core/ingestion/languages/go/merge-bindings.ts b/gitnexus/src/core/ingestion/languages/go/merge-bindings.ts new file mode 100644 index 000000000..71d5031b2 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/merge-bindings.ts @@ -0,0 +1,27 @@ +import type { BindingRef } from 'gitnexus-shared'; + +const TIER: Record = { + local: 0, + namespace: 1, + import: 2, + reexport: 3, + wildcard: 4, +}; + +export function goMergeBindings( + existing: readonly BindingRef[], + incoming: readonly BindingRef[], + _scopeId: string, +): BindingRef[] { + const seen = new Set(); + return [...existing, ...incoming] + .sort( + (a, b) => + (TIER[a.origin] ?? 99) - (TIER[b.origin] ?? 99) || a.def.nodeId.localeCompare(b.def.nodeId), + ) + .filter((binding) => { + if (seen.has(binding.def.nodeId)) return false; + seen.add(binding.def.nodeId); + return true; + }); +} diff --git a/gitnexus/src/core/ingestion/languages/go/method-owners.ts b/gitnexus/src/core/ingestion/languages/go/method-owners.ts new file mode 100644 index 000000000..1754b289f --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/method-owners.ts @@ -0,0 +1,73 @@ +import type { ParsedFile } from 'gitnexus-shared'; +import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; + +/** + * Populate `ownerId` on Go Method defs by matching receiver types + * extracted from `@type-binding.self` captures against struct defs in + * the module scope. + * + * Go method declarations are top-level (`func (r *T) M()`), not nested + * inside a struct body. The generic `populateClassOwnedMembers` requires + * the method's parent scope to be a `Class` scope, which never matches + * Go. This pass bridges the gap by reading the self typeBinding that + * `synthesizeGoReceiverBinding` creates, locating the matching struct + * def, and stamping `ownerId` onto the Method def. + */ +export function populateGoOwners(parsed: ParsedFile): void { + // 1. Standard nested-class pass — stamps ownerId on Property/Method defs + // inside Class scopes. With Class scopes now created for Go + // struct/interface declarations, this handles struct field ownership. + populateClassOwnedMembers(parsed); + + const moduleScope = parsed.scopes.find((s) => s.kind === 'Module'); + if (moduleScope === undefined) return; + + // 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 + for (const scope of parsed.scopes) { + for (const def of scope.ownedDefs) { + if (isClassLike(def.type) && def.qualifiedName) { + structByQualifiedName.set(def.qualifiedName, def.nodeId); + } + } + } + + // 2. Go-specific method owner: each Method def lives in a Function + // scope whose typeBindings carry the self entry (kept there by + // goBindingScopeFor). Match the self rawName against struct defs. + if (structByQualifiedName.size > 0) { + for (const scope of parsed.scopes) { + if (scope.kind !== 'Function') continue; + const methodDefs = scope.ownedDefs.filter( + (d) => d.type === 'Method' && d.ownerId === undefined, + ); + if (methodDefs.length === 0) continue; + + // Find the self typeBinding in this Function scope. + let receiverType: string | undefined; + for (const [, tb] of scope.typeBindings) { + if (tb.source === 'self') { + receiverType = tb.rawName; + break; + } + } + if (receiverType === undefined) continue; + + let ownerId = structByQualifiedName.get(receiverType); + if (ownerId === undefined) { + for (const [qname, nodeId] of structByQualifiedName) { + if (qname.endsWith('.' + receiverType)) { + ownerId = nodeId; + break; + } + } + } + if (ownerId !== undefined) { + for (const def of methodDefs) { + (def as { ownerId?: string }).ownerId = ownerId; + } + } + } + } +} diff --git a/gitnexus/src/core/ingestion/languages/go/package-siblings.ts b/gitnexus/src/core/ingestion/languages/go/package-siblings.ts new file mode 100644 index 000000000..7df0e3f80 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/package-siblings.ts @@ -0,0 +1,91 @@ +import type { BindingRef, ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; + +import { expandGoDotImports } from './expand-wildcards.js'; + +export function populateGoPackageSiblings( + parsedFiles: readonly ParsedFile[], + indexes: ScopeResolutionIndexes, + ctx: { readonly fileContents: ReadonlyMap }, +): void { + // 0. Expand dot imports first so subsequent same-package sibling + // augmentation can also see dot-imported names. + expandGoDotImports(parsedFiles, indexes); + + // 1. Group files by package directory plus package name. Go package + // identity is directory-scoped; repeated `package main` directories + // must not see each other's unqualified names. + const packageByFile = new Map(); + for (const parsed of parsedFiles) { + const pkgName = inferPackageName(ctx.fileContents.get(parsed.filePath) ?? ''); + if (pkgName !== null) { + packageByFile.set(parsed.filePath, `${packageDir(parsed.filePath)}\0${pkgName}`); + } + } + + const filesByPackage = new Map(); + for (const parsed of parsedFiles) { + const pkgName = packageByFile.get(parsed.filePath); + if (pkgName === undefined) continue; + const list = filesByPackage.get(pkgName) ?? []; + list.push({ filePath: parsed.filePath, defs: [...parsed.localDefs] }); + filesByPackage.set(pkgName, list); + } + + // 2. Use bindingAugmentations channel per I8 + const augmentations = indexes.bindingAugmentations as Map>; + + for (const [, siblings] of filesByPackage) { + for (const target of siblings) { + const targetModule = indexes.moduleScopes.byFilePath.get(target.filePath); + if (targetModule === undefined) continue; + + for (const receiver of siblings) { + if (receiver.filePath === target.filePath) continue; // no self-reference + const receiverModule = indexes.moduleScopes.byFilePath.get(receiver.filePath); + if (receiverModule === undefined) continue; + + for (const def of target.defs) { + // Go: same-package sibling files can see ALL names (both + // exported/uppercase and unexported/lowercase). Only cross- + // package visibility requires uppercase first letter. + const name = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; + if (name === '') continue; + + const bucket = getAugmentationBucket(augmentations, receiverModule, name); + if (bucket.some((b) => b.def.nodeId === def.nodeId)) continue; + bucket.push({ def, origin: 'namespace' }); + } + } + } + } +} + +function inferPackageName(sourceText: string): string | null { + const match = sourceText.match(/^\s*package\s+([A-Za-z_][A-Za-z0-9_]*)/m); + return match?.[1] ?? null; +} + +function packageDir(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/'); + const idx = normalized.lastIndexOf('/'); + return idx === -1 ? '' : normalized.slice(0, idx); +} + +function getAugmentationBucket( + augmentations: Map>, + scopeId: ScopeId, + name: string, +): BindingRef[] { + let scopeBindings = augmentations.get(scopeId); + if (scopeBindings === undefined) { + scopeBindings = new Map(); + augmentations.set(scopeId, scopeBindings); + } + let bucketArr = scopeBindings.get(name); + if (bucketArr === undefined) { + bucketArr = []; + scopeBindings.set(name, bucketArr); + } + return bucketArr; +} diff --git a/gitnexus/src/core/ingestion/languages/go/query.ts b/gitnexus/src/core/ingestion/languages/go/query.ts new file mode 100644 index 000000000..1e728f925 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/query.ts @@ -0,0 +1,212 @@ +import Parser from 'tree-sitter'; +import Go from 'tree-sitter-go'; + +const GO_SCOPE_QUERY = ` +;; Scopes +(source_file) @scope.module +(type_declaration + (type_spec + type: [(struct_type) (interface_type)])) @scope.class +(function_declaration) @scope.function +(method_declaration) @scope.function +(func_literal) @scope.function +(block) @scope.block +(if_statement) @scope.block +(for_statement) @scope.block +(select_statement) @scope.block +(expression_switch_statement) @scope.block +(type_switch_statement) @scope.block +(expression_case) @scope.block +(default_case) @scope.block +(type_case) @scope.block +(communication_case) @scope.block + +;; Declarations — struct +(type_declaration + (type_spec name: (type_identifier) @declaration.name + type: (struct_type))) @declaration.struct + +;; Declarations — interface +(type_declaration + (type_spec name: (type_identifier) @declaration.name + type: (interface_type))) @declaration.interface + +;; Declarations — function +(function_declaration + name: (identifier) @declaration.name) @declaration.function + +;; Declarations — method +(method_declaration + name: (field_identifier) @declaration.name) @declaration.method + +;; Declarations — struct fields +(struct_type + (field_declaration_list + (field_declaration + name: (field_identifier) @declaration.name + type: (_) @declaration.field-type))) @declaration.field + +;; Declarations — variables +(var_declaration + (var_spec + name: (identifier) @declaration.name)) @declaration.variable + +(const_declaration + (const_spec + name: (identifier) @declaration.name)) @declaration.const + +(short_var_declaration + left: (expression_list (identifier) @declaration.name)) @declaration.variable + +;; Imports +(import_declaration) @import.statement +(import_spec) @import.statement + +;; Type bindings — parameter annotations +(function_declaration + name: (identifier) @_fn_name + parameters: (parameter_list + (parameter_declaration + name: (identifier) @type-binding.name + type: [(type_identifier) (qualified_type) (pointer_type) (slice_type) (map_type)] @type-binding.type))) @type-binding.parameter + +(method_declaration + name: (field_identifier) @_fn_name + parameters: (parameter_list + (parameter_declaration + name: (identifier) @type-binding.name + type: [(type_identifier) (qualified_type) (pointer_type) (slice_type) (map_type)] @type-binding.type))) @type-binding.parameter + +;; Type bindings — constructor-inferred (:= T{}) +(short_var_declaration + left: (expression_list (identifier) @type-binding.name) + right: (expression_list + (composite_literal + type: [(type_identifier) (qualified_type)] @type-binding.type))) @type-binding.constructor + +;; Type bindings — pointer constructor (:= &T{}) +(short_var_declaration + left: (expression_list (identifier) @type-binding.name) + right: (expression_list + (unary_expression + "&" + operand: (composite_literal + type: [(type_identifier) (qualified_type)] @type-binding.type)))) @type-binding.constructor + +;; Type bindings — type assertion (:= s.(T)) +(short_var_declaration + left: (expression_list (identifier) @type-binding.name) + right: (expression_list + (type_assertion_expression + type: (_) @type-binding.type))) @type-binding.assertion + +(var_declaration + (var_spec + name: (identifier) @type-binding.name + value: (expression_list + (type_assertion_expression + type: (_) @type-binding.type)))) @type-binding.assertion + +;; Type bindings — explicit var type +(var_declaration + (var_spec + name: (identifier) @type-binding.name + type: (_) @type-binding.type)) @type-binding.assignment + +;; Type bindings — call-return inference (:= Func(args)) +(short_var_declaration + left: (expression_list (identifier) @type-binding.name) + right: (expression_list (call_expression + function: (identifier) @type-binding.type))) @type-binding.call-return + +;; Type bindings — call-return inference qualified (:= pkg.Func(args)) +(short_var_declaration + left: (expression_list (identifier) @type-binding.name) + right: (expression_list (call_expression + function: (selector_expression + field: (field_identifier) @type-binding.type)))) @type-binding.call-return + +;; Type bindings — return type annotation (func Foo() *Type) +(function_declaration + name: (identifier) @type-binding.name + result: (_) @type-binding.type) @type-binding.return + +;; Type bindings — method return type (func (r *T) Method() *Type) +(method_declaration + name: (field_identifier) @type-binding.name + result: (_) @type-binding.type) @type-binding.return + +;; Type bindings — variable alias (y := x) +(short_var_declaration + left: (expression_list (identifier) @type-binding.name) + right: (expression_list (identifier) @type-binding.type)) @type-binding.alias + +;; Type bindings — variable alias var form (var x = y) +(var_declaration + (var_spec + name: (identifier) @type-binding.name + value: (expression_list (identifier) @type-binding.type))) @type-binding.alias + +;; Type bindings — call-return var form (var x = Func()) +(var_declaration + (var_spec + name: (identifier) @type-binding.name + value: (expression_list (call_expression + function: (identifier) @type-binding.type)))) @type-binding.call-return + +;; References — free calls +(call_expression + function: (identifier) @reference.name) @reference.call.free + +;; References — member calls +(call_expression + function: (selector_expression + operand: (_) @reference.receiver + field: (field_identifier) @reference.name)) @reference.call.member + +;; References — constructor calls (T{}) +(composite_literal + type: [(type_identifier) (qualified_type)] @reference.name) @reference.call.constructor + +;; References — field reads +(selector_expression + operand: (_) @reference.receiver + field: (field_identifier) @reference.name) @reference.read + +;; References — field writes (assignment) +(assignment_statement + left: (expression_list + (selector_expression + operand: (_) @reference.receiver + field: (field_identifier) @reference.name))) @reference.write + +;; References — field writes (inc: obj.Field++) +(inc_statement + (selector_expression + operand: (_) @reference.receiver + field: (field_identifier) @reference.name)) @reference.write + +;; References — field writes (dec: obj.Field--) +(dec_statement + (selector_expression + operand: (_) @reference.receiver + field: (field_identifier) @reference.name)) @reference.write +`; + +let _parser: Parser | null = null; +let _query: Parser.Query | null = null; + +export function getGoParser(): Parser { + if (_parser === null) { + _parser = new Parser(); + _parser.setLanguage(Go as Parameters[0]); + } + return _parser; +} + +export function getGoScopeQuery(): Parser.Query { + if (_query === null) { + _query = new Parser.Query(Go as Parameters[0], GO_SCOPE_QUERY); + } + return _query; +} diff --git a/gitnexus/src/core/ingestion/languages/go/range-binding.ts b/gitnexus/src/core/ingestion/languages/go/range-binding.ts new file mode 100644 index 000000000..d4a7b828a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/range-binding.ts @@ -0,0 +1,101 @@ +import type { ParsedFile, Scope, TypeRef } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { getGoParser } from './query.js'; + +export function populateGoRangeBindings( + parsedFiles: readonly ParsedFile[], + _indexes: ScopeResolutionIndexes, + ctx: { readonly fileContents: ReadonlyMap }, +): void { + const parser = getGoParser(); + + for (const parsed of parsedFiles) { + const sourceText = ctx.fileContents.get(parsed.filePath); + if (sourceText === undefined) continue; + + const tree = parser.parse(sourceText); + const moduleScope = parsed.scopes.find((s) => s.kind === 'Module'); + if (moduleScope === undefined) continue; + + const scopeMap = new Map(parsed.scopes.map((s) => [s.id, s])); + + for (const rangeNode of tree.rootNode.descendantsOfType('for_statement')) { + const rangeClause = rangeNode.namedChildren.find((c) => c.type === 'range_clause'); + if (rangeClause === null) continue; + + const left = rangeClause.namedChildren.find((c) => c.type === 'expression_list'); + if (left === null) continue; + + const rangeExpr = rangeClause.namedChildren.find( + (c, idx) => c.type !== 'expression_list' && idx > rangeClause.namedChildren.indexOf(left), + ); + if (rangeExpr === undefined) continue; + + // Identify the value variable (skip the `_` discard if present) + const idents = left.namedChildren.filter((c) => c.type === 'identifier'); + let valueVar: string | null = null; + if (idents.length >= 2) { + valueVar = idents[1].text; // for _, v := range ... + } else if (idents.length === 1) { + valueVar = idents[0].text; // for v := range ... + } + + if (valueVar === null || valueVar === '_') continue; + + // Resolve range expression type + let elementType: string | null = null; + + if (rangeExpr.type === 'identifier') { + // Look up the identifier's type in scope typeBindings (V1: module scope only) + const binding = moduleScope.typeBindings.get(rangeExpr.text); + if (binding !== null && binding !== undefined) { + elementType = extractElementType(binding); + } + } else if (rangeExpr.type === 'call_expression') { + const fnNode = rangeExpr.childForFieldName('function'); + if (fnNode !== null) { + const fnName = + fnNode.type === 'selector_expression' + ? fnNode.childForFieldName('field')?.text + : fnNode.text; + if (fnName !== undefined) { + const binding = moduleScope.typeBindings.get(fnName); + if (binding !== null && binding !== undefined) { + elementType = extractElementType(binding); + } + } + } + } + + 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, { + rawName: elementType, + declaredAtScope: targetScope.id, + source: 'annotation', + }); + } + } + } +} + +function extractElementType(binding: TypeRef): string | null { + const raw = binding.rawName; + const mapMatch = raw.match(/^map\[[^\]]+\]\s*(.+)$/); + if (mapMatch) return mapMatch[1].trim(); + if (raw.startsWith('[]')) return raw.slice(2).trim(); + const arrMatch = raw.match(/^\[\d+\](.+)$/); + if (arrMatch) return arrMatch[1].trim(); + return raw; +} + +function findEnclosingFunctionScope( + _node: unknown, + _scopeMap: ReadonlyMap, +): Scope | null { + // V1 simplified: return null, use module scope as fallback + return null; +} diff --git a/gitnexus/src/core/ingestion/languages/go/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/go/receiver-binding.ts new file mode 100644 index 000000000..d5089fa00 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/receiver-binding.ts @@ -0,0 +1,21 @@ +import type { CaptureMatch } from 'gitnexus-shared'; +import { syntheticCapture } from '../../utils/ast-helpers.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +export function synthesizeGoReceiverBinding(fnNode: SyntaxNode): CaptureMatch | null { + if (fnNode.type !== 'method_declaration') return null; + const receiver = fnNode.childForFieldName('receiver'); + if (receiver === null) return null; + const param = receiver.namedChildren.find((c) => c.type === 'parameter_declaration'); + if (param === undefined) return null; + const nameNode = param.childForFieldName('name'); + const typeNode = param.childForFieldName('type'); + if (nameNode === null || typeNode === null) return null; + const typeName = typeNode.text.replace(/^\*/, ''); + + return { + '@type-binding.self': syntheticCapture('@type-binding.self', fnNode, nameNode.text), + '@type-binding.name': syntheticCapture('@type-binding.name', nameNode, nameNode.text), + '@type-binding.type': syntheticCapture('@type-binding.type', typeNode, typeName), + }; +} diff --git a/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts new file mode 100644 index 000000000..464fa6e24 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts @@ -0,0 +1,50 @@ +import type { ParsedFile } from 'gitnexus-shared'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; +import { populateGoOwners } from './method-owners.js'; +import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { loadGoModulePath } from '../../language-config.js'; +import { goProvider } from '../go.js'; +import { + goArityCompatibility, + goMergeBindings, + populateGoPackageSiblings, + resolveGoImportTarget, +} from './index.js'; +import { detectGoInterfaceImplementations } from './interface-impls.js'; +import { populateGoRangeBindings } from './range-binding.js'; +import { expandGoWildcardNames } from './expand-wildcards.js'; + +export const goScopeResolver: ScopeResolver = { + language: SupportedLanguages.Go, + languageProvider: goProvider, + importEdgeReason: 'go-scope: import', + + loadResolutionConfig: (repoPath: string) => loadGoModulePath(repoPath), + + resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => + resolveGoImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig), + + expandsWildcardTo: (targetModuleScope, parsedFiles) => + expandGoWildcardNames(targetModuleScope, parsedFiles), + + mergeBindings: (existing, incoming, scopeId) => goMergeBindings(existing, incoming, scopeId), + + arityCompatibility: (callsite, def) => goArityCompatibility(def, callsite), + + buildMro: (graph, parsedFiles, nodeLookup) => + buildMro(graph, parsedFiles, nodeLookup, defaultLinearize), + + populateOwners: (parsed: ParsedFile) => populateGoOwners(parsed), + + isSuperReceiver: () => false, + + fieldFallbackOnMethodLookup: false, + hoistTypeBindingsToModule: true, + propagatesReturnTypesAcrossImports: true, + allowGlobalFreeCallFallback: true, + + populateNamespaceSiblings: populateGoPackageSiblings, + 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 new file mode 100644 index 000000000..e2396f395 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/simple-hooks.ts @@ -0,0 +1,38 @@ +import type { + CaptureMatch, + ParsedImport, + Scope, + ScopeId, + ScopeTree, + TypeRef, +} from 'gitnexus-shared'; + +export function goBindingScopeFor( + decl: CaptureMatch, + 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) { + return innermost.id; + } + return null; // default auto-hoist for other bindings +} + +export function goImportOwningScope( + _imp: ParsedImport, + _innermost: Scope, + _tree: ScopeTree, +): ScopeId | null { + return null; +} + +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; + } + return null; +} diff --git a/gitnexus/src/core/ingestion/languages/go/type-binding.ts b/gitnexus/src/core/ingestion/languages/go/type-binding.ts new file mode 100644 index 000000000..b66bed8a8 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/type-binding.ts @@ -0,0 +1,250 @@ +import type { CaptureMatch } from 'gitnexus-shared'; +import { syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; + +export function synthesizeGoTypeBindings(rootNode: SyntaxNode): CaptureMatch[] { + const out: CaptureMatch[] = []; + + for (const node of rootNode.descendantsOfType('short_var_declaration')) { + const right = node.childForFieldName('right'); + if (right === null) continue; + const lhs = node.childForFieldName('left'); + if (lhs === null) continue; + + // Multi-assignment: pair LHS identifiers positionally with RHS + // composite_literals or call_expressions. The tree-sitter query + // produces all combinations; these correct pairings overwrite them. + const lhsIds = lhs.namedChildren.filter((c) => c.type === 'identifier'); + const rhsExprs = right.namedChildren.filter( + (c) => c.type === 'composite_literal' || c.type === 'call_expression', + ); + if (lhsIds.length >= 2 && rhsExprs.length >= 2) { + for (let i = 0; i < Math.min(lhsIds.length, rhsExprs.length); i++) { + const lhsId = lhsIds[i]!; + const rhsExpr = rhsExprs[i]!; + const typeNode = extractTypeNode(rhsExpr); + const typeName = typeNode ? extractSimpleTypeNameText(typeNode) : rhsExpr.text; + out.push({ + '@type-binding.multi-assign': syntheticCapture( + '@type-binding.multi-assign', + node, + lhsId.text, + ), + '@type-binding.name': syntheticCapture('@type-binding.name', lhsId, lhsId.text), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + typeNode ?? rhsExpr, + typeName, + ), + }); + } + continue; // synthesized matches replace tree-sitter combinations + } + + // Walk the expression_list for call_expression function == "new" or "make" + for (let i = 0; i < right.namedChildCount; i++) { + const expr = right.namedChild(i); + if (expr?.type === 'call_expression') { + const fn = expr.childForFieldName('function'); + const args = expr.childForFieldName('arguments'); + if (fn?.type === 'identifier' && fn.text === 'new' && args !== null) { + const typeArg = args.namedChildren.find((c) => + ['type_identifier', 'qualified_type'].includes(c.type), + ); + if (typeArg !== null) { + const typeName = extractSimpleTypeNameText(typeArg); + const nameNodes = lhs.namedChildren.filter((c) => c.type === 'identifier'); + if (nameNodes.length > 0) { + out.push({ + '@type-binding.new': syntheticCapture('@type-binding.new', node, 'new'), + '@type-binding.name': syntheticCapture( + '@type-binding.name', + nameNodes[0], + nameNodes[0].text, + ), + '@type-binding.type': syntheticCapture('@type-binding.type', typeArg, typeName), + }); + } + } + } + if (fn?.type === 'identifier' && fn.text === 'make' && args !== null) { + const sliceOrMap = args.namedChildren.find((c) => + ['slice_type', 'map_type'].includes(c.type), + ); + if (sliceOrMap !== null) { + let typeName = ''; + if (sliceOrMap.type === 'slice_type') { + const elem = sliceOrMap.namedChildren.find((c) => + ['type_identifier', 'qualified_type'].includes(c.type), + ); + if (elem !== null) typeName = extractSimpleTypeNameText(elem); + } else if (sliceOrMap.type === 'map_type') { + const typeChildren = sliceOrMap.namedChildren.filter((c) => + ['type_identifier', 'qualified_type'].includes(c.type), + ); + const valueType = typeChildren[1] ?? typeChildren[0]; + if (valueType !== undefined) typeName = extractSimpleTypeNameText(valueType); + } + if (typeName !== '') { + const nameNodes = lhs.namedChildren.filter((c) => c.type === 'identifier'); + if (nameNodes.length > 0) { + out.push({ + '@type-binding.make': syntheticCapture('@type-binding.make', node, 'make'), + '@type-binding.name': syntheticCapture( + '@type-binding.name', + nameNodes[0], + nameNodes[0].text, + ), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + sliceOrMap, + typeName, + ), + }); + } + } + } + } + } + } + } + + // Synthesize typeBindings for for-range loop variables and index + // expressions (sl[0], m["key"]) so member calls on them resolve. + synthesizeElementAccessBindings(rootNode, out); + + return out; +} + +function synthesizeElementAccessBindings(rootNode: SyntaxNode, out: CaptureMatch[]): void { + // Build a map of variable → element type from make/new/range. + const varElementType = new Map(); + + for (const node of rootNode.descendantsOfType('short_var_declaration')) { + const right = node.childForFieldName('right'); + const lhs = node.childForFieldName('left'); + if (right === null || lhs === null) continue; + const lhsIds = lhs.namedChildren.filter((c) => c.type === 'identifier'); + if (lhsIds.length === 0) continue; + + for (const expr of right.namedChildren) { + let typeName: string | undefined; + if (expr.type === 'call_expression') { + const fn = expr.childForFieldName('function'); + if (fn?.type === 'identifier' && (fn.text === 'make' || fn.text === 'new')) { + const args = expr.childForFieldName('arguments'); + const typeNode = args?.namedChildren.find((c) => + ['type_identifier', 'qualified_type', 'slice_type', 'map_type'].includes(c.type), + ); + if (typeNode) { + if (typeNode.type === 'slice_type') { + const elem = typeNode.namedChildren.find((c) => + ['type_identifier', 'qualified_type'].includes(c.type), + ); + if (elem) typeName = extractSimpleTypeNameText(elem); + } else if (typeNode.type === 'map_type') { + const tc = typeNode.namedChildren.filter((c) => + ['type_identifier', 'qualified_type'].includes(c.type), + ); + typeName = tc[1] + ? extractSimpleTypeNameText(tc[1]) + : tc[0] + ? extractSimpleTypeNameText(tc[0]) + : undefined; + } else { + typeName = extractSimpleTypeNameText(typeNode); + } + } + } + } + if (typeName !== undefined && lhsIds.length > 0) { + varElementType.set(lhsIds[0]!.text, typeName); + } + } + } + + // Handle for-range: for _, user := range GetUsers() / range slice / range map + for (const rangeClause of rootNode.descendantsOfType('range_clause')) { + const right = rangeClause.childForFieldName('right'); + if (right === null || right.namedChildren.length === 0) continue; + const left = rangeClause.childForFieldName('left'); + if (left === null) continue; + + // The right side IS the range expression (call_expression, identifier, etc.) + const rangeExpr = right; + + let elemType: string | undefined; + // Call expression: range GetUsers() + if (rangeExpr.type === 'call_expression') { + const fn = rangeExpr.childForFieldName('function'); + if (fn !== null) { + elemType = fn.text; + } + } + // Identifier: range userMap / range users + if (rangeExpr.type === 'identifier' || rangeExpr.type === 'selector_expression') { + const existing = varElementType.get(rangeExpr.text); + if (existing !== undefined) elemType = existing; + } + + if (elemType === undefined) continue; + + // Capture the loop variable (skip blank_identifier like _) + for (const child of left.namedChildren) { + if (child.type === 'identifier') { + // Create a typeBinding: loopVar → elemType + out.push({ + '@type-binding.range': syntheticCapture('@type-binding.range', rangeClause, child.text), + '@type-binding.name': syntheticCapture('@type-binding.name', child, child.text), + '@type-binding.type': syntheticCapture('@type-binding.type', rangeExpr, elemType), + }); + } + } + } + + // Handle index expressions: sl[0], m["key"] + for (const node of rootNode.descendantsOfType('call_expression')) { + const fn = node.childForFieldName('function'); + if (fn?.type !== 'selector_expression') continue; + const operand = fn.childForFieldName('operand'); + if (operand === null) continue; + + if (operand.type === 'index_expression') { + const base = operand.childForFieldName('operand'); + if (base?.type === 'identifier' && varElementType.has(base.text)) { + const elemType = varElementType.get(base.text)!; + out.push({ + '@type-binding.index': syntheticCapture('@type-binding.index', node, operand.text), + '@type-binding.name': syntheticCapture('@type-binding.name', operand, operand.text), + '@type-binding.type': syntheticCapture('@type-binding.type', operand, elemType), + }); + } + } + } +} + +function extractSimpleTypeNameText(node: SyntaxNode): string { + if (node.type === 'qualified_type') { + const parts = node.text.split('.'); + return parts[parts.length - 1] ?? node.text; + } + return node.text; +} + +/** Extract the type/signature node from a RHS expression. */ +function extractTypeNode(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 === 'call_expression') { + const fn = expr.childForFieldName('function'); + if (fn?.type === 'identifier') return fn; + if (fn?.type === 'selector_expression') { + return fn.childForFieldName('field') ?? fn; + } + } + return null; +} diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 7781e3a0d..f13cb2c73 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -541,6 +541,8 @@ function buildDefFromDeclarationMatch( const parameterCount = parseIntCapture(match['@declaration.parameter-count']); const requiredParameterCount = parseIntCapture(match['@declaration.required-parameter-count']); const parameterTypes = parseJsonStringArrayCapture(match['@declaration.parameter-types']); + const declaredType = match['@declaration.field-type']?.text; + const returnType = match['@declaration.return-type']?.text; return { nodeId: makeDefId(filePath, anchor.range, type, nameCap.text), @@ -550,6 +552,8 @@ function buildDefFromDeclarationMatch( ...(parameterCount !== undefined ? { parameterCount } : {}), ...(requiredParameterCount !== undefined ? { requiredParameterCount } : {}), ...(parameterTypes !== undefined ? { parameterTypes } : {}), + ...(declaredType !== undefined ? { declaredType } : {}), + ...(returnType !== undefined ? { returnType } : {}), }; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 071eb588d..f7c034f9d 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -260,6 +260,7 @@ import type { KnowledgeGraph } from '../../../graph/types.js'; import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js'; import { LanguageProvider } from '../../language-provider.js'; import { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import type { SemanticModel } from '../../model/semantic-model.js'; /** A LinearizeStrategy receives the full ancestor map so C3-style * algorithms (which need to merge each parent's MRO) can implement @@ -314,7 +315,17 @@ export interface ScopeResolver { fromFile: string, allFilePaths: ReadonlySet, resolutionConfig?: unknown, - ): string | null; + ): string | readonly string[] | null; + + /** + * Enumerate names visible through a wildcard import after the target + * module scope has been linked. Languages that do not support + * wildcard-style imports leave this undefined. + */ + readonly expandsWildcardTo?: ( + targetModuleScope: ScopeId, + parsedFiles: readonly ParsedFile[], + ) => readonly string[]; /** * Optional one-shot loader for cross-file import-resolution config @@ -441,6 +452,14 @@ export interface ScopeResolver { */ readonly collapseMemberCallsByCallerTarget?: boolean; + /** + * Allow free-call emission to fall back to a unique workspace-wide + * callable match when lexical/import bindings miss. Kept opt-in + * because this mirrors legacy resolver behavior for some languages + * but is too loose as a default for strict module systems. + */ + readonly allowGlobalFreeCallFallback?: boolean; + /** * Optional post-finalize hook to inject cross-file bindings that * aren't modeled via explicit imports. Runs after @@ -485,4 +504,32 @@ export interface ScopeResolver { * level bindings. */ readonly hoistTypeBindingsToModule?: boolean; + + /** + * Optional: detect structural (duck-typing) interface implementations. + * Languages like Go use structural typing — a struct satisfies an + * interface if its method set is a superset, without an explicit + * `implements` keyword. Runs after finalize, before resolution passes. + * Returns: Map. + * Default: undefined (no structural interface detection). + */ + readonly detectInterfaceImplementations?: ( + parsedFiles: readonly ParsedFile[], + indexes: ScopeResolutionIndexes, + model: SemanticModel, + ) => Map; + + /** + * Optional: bind for-range loop variables to their element/value types. + * Languages like Go need to resolve `for _, v := range m` where `m` is + * `map[K]V` — the variable `v` should bind to `V`. Runs after finalize, + * before resolution passes. Mutates scope typeBindings via the Map cast + * convention (see Invariant I8). + * Default: undefined (no range variable binding). + */ + readonly populateRangeBindings?: ( + parsedFiles: readonly ParsedFile[], + indexes: ScopeResolutionIndexes, + ctx: { readonly fileContents: ReadonlyMap }, + ) => void; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts index 39f5a9a74..ad59f4f10 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts @@ -21,7 +21,6 @@ import type { NodeLabel, ScopeId, SymbolDefinition } from 'gitnexus-shared'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import { generateId } from '../../../../lib/utils.js'; import { qualifiedKey, simpleKey, type GraphNodeLookup } from '../graph-bridge/node-lookup.js'; - /** * Labels that may legitimately ANCHOR a CALLS/ACCESSES edge as the * source ("caller"). A Variable / Property can be the TARGET of an diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index e6c160467..a210ad448 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -36,6 +36,7 @@ export function emitFreeCallFallback( handledSites: Set, model: SemanticModel, workspaceIndex: WorkspaceResolutionIndex, + options: { readonly allowGlobalFallback?: boolean } = {}, ): number { let emitted = 0; const seen = new Set(); @@ -67,6 +68,9 @@ export function emitFreeCallFallback( if (fnDef === undefined) { fnDef = findCallableBindingInScope(site.inScope, site.name, scopes); } + if (fnDef === undefined && options.allowGlobalFallback === true) { + fnDef = pickUniqueGlobalCallable(site.name, model, scopes); + } if (fnDef === undefined) continue; const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup); if (callerGraphId === undefined) continue; @@ -95,6 +99,51 @@ export function emitFreeCallFallback( return emitted; } +function pickUniqueGlobalCallable( + name: string, + model: SemanticModel, + scopes: ScopeResolutionIndexes, +): SymbolDefinition | undefined { + const scopeDefs: SymbolDefinition[] = []; + const scopeSeen = new Set(); + for (const def of scopes.defs.byId.values()) { + const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName; + if (simple !== name) continue; + if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') continue; + const key = logicalCallableKey(def); + if (scopeSeen.has(key)) continue; + scopeSeen.add(key); + scopeDefs.push(def); + } + if (scopeDefs.length === 1) return scopeDefs[0]; + + const defs: SymbolDefinition[] = []; + const seen = new Set(); + const push = (pool: readonly SymbolDefinition[]): void => { + for (const def of pool) { + const key = logicalCallableKey(def); + if (seen.has(key)) continue; + seen.add(key); + defs.push(def); + } + }; + + push(model.symbols.lookupCallableByName(name)); + push(model.methods.lookupMethodByName(name)); + + return defs.length === 1 ? defs[0] : undefined; +} + +function logicalCallableKey(def: SymbolDefinition): string { + return [ + def.filePath, + def.qualifiedName ?? '', + def.type, + def.parameterCount ?? '', + def.parameterTypes?.join(',') ?? '', + ].join('\0'); +} + /** For a constructor call `new X(...)`, return the X class's explicit * Constructor def (by walking the class scope's ownedDefs) or the * Class def itself when no explicit Constructor exists. Matches diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts index 3ad9a28ae..d44de5f47 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts @@ -196,6 +196,55 @@ export function propagateImportedReturnTypes( } } + // Namespace-import mirroring: for languages with namespace-style + // imports (Go `import "pkg"`), mirror exported typeBindings from all + // target files' module scopes into the importer's module scope. This + // covers the cross-package case where `x := pkg.Func()` creates a + // function-scope typeBinding `x → Func`, and the module-scope chain- + // follow needs `Func → ReturnType` to exist in the importer's scope + // chain. Without this, only same-file constructor-inferred resolution + // works. + for (const parsed of parsedFiles) { + const importerModule = moduleScopeByFile.get(parsed.filePath); + if (importerModule === undefined) continue; + + const moduleEdges = indexes.imports.get(importerModule.id); + if (moduleEdges === undefined) continue; + + // Collect namespace target files per localName (one namespace may + // expand to multiple files — e.g. Go multi-file packages). + const nsTargets = new Map(); + for (const edge of moduleEdges) { + if (edge.kind !== 'namespace' || edge.targetFile === null) continue; + let targets = nsTargets.get(edge.localName); + if (targets === undefined) { + targets = []; + nsTargets.set(edge.localName, targets); + } + if (!targets.includes(edge.targetFile)) targets.push(edge.targetFile); + } + + for (const targetFiles of nsTargets.values()) { + for (const targetFile of targetFiles) { + const sourceModule = moduleScopeByFile.get(targetFile); + if (sourceModule === undefined) continue; + + for (const [name, ref] of sourceModule.typeBindings) { + // Only mirror exported names (uppercase first char — Go + // convention; other languages using namespace imports + // typically export everything, so this filter is harmless). + if (name.length === 0) continue; + const first = name[0]!; + if (first < 'A' || first > 'Z') continue; + if (importerModule.typeBindings.has(name)) continue; + + const terminal = followChainPostFinalize(ref, sourceModule.id, indexes); + (importerModule.typeBindings as Map).set(name, terminal); + } + } + } + } + // Final pass: chain-follow non-module scopes (function-local // typeBindings). Module scopes were already followed inside the // SCC loop above. diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/mro.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/mro.ts index a4d7e4cfb..ab6842771 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/mro.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/mro.ts @@ -24,6 +24,7 @@ import type { KnowledgeGraph } from '../../../graph/types.js'; import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js'; import type { LinearizeStrategy } from '../contract/scope-resolver.js'; import { resolveDefGraphId } from '../graph-bridge/ids.js'; +import { isClassLike } from '../scope/walkers.js'; /** * Build an MRO map keyed by scope-resolution Class `DefId`. @@ -58,7 +59,7 @@ export function buildMro( const defIdByGraphId = new Map(); for (const parsed of parsedFiles) { for (const def of parsed.localDefs) { - if (def.type !== 'Class') continue; + if (!isClassLike(def.type)) continue; const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup); if (graphId !== undefined) defIdByGraphId.set(graphId, def.nodeId); } 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 2c994c0ae..925151b6c 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 @@ -46,6 +46,7 @@ import { findExportedDef, findOwnedMember, findReceiverTypeBinding, + isClassLike, } from '../scope/walkers.js'; import { tryEmitEdge } from '../graph-bridge/edges.js'; import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js'; @@ -249,25 +250,30 @@ export function emitReceiverBoundCalls( } // ── Case 1: namespace receiver ─────────────────────────────── - const targetFile = namespaceTargets.get(receiverName); - if (targetFile !== undefined) { - const memberDef = findExportedDef(targetFile, memberName, index); - if (memberDef !== undefined) { - const ok = tryEmitEdge( - graph, - scopes, - nodeLookup, - site, - memberDef, - memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global', - seen, - 0.85, - collapse, - ); - if (ok) emitted++; - handledSites.add(siteKey); - continue; + const targetFiles = namespaceTargets.get(receiverName); + if (targetFiles !== undefined) { + let found = false; + for (const targetFile of targetFiles) { + const memberDef = findExportedDef(targetFile, memberName, index); + if (memberDef !== undefined) { + const ok = tryEmitEdge( + graph, + scopes, + nodeLookup, + site, + memberDef, + memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global', + seen, + 0.85, + collapse, + ); + if (ok) emitted++; + handledSites.add(siteKey); + found = true; + break; + } } + if (found) continue; } // ── Case 2: class-name receiver ────────────────────────────── @@ -309,28 +315,33 @@ export function emitReceiverBoundCalls( if (typeRef !== undefined && typeRef.rawName.includes('.')) { const [nsName, ...classNameParts] = typeRef.rawName.split('.'); const className = classNameParts.join('.'); - const targetFile3 = namespaceTargets.get(nsName); - if (targetFile3 !== undefined && className.length > 0) { - const classDef3 = findExportedDef(targetFile3, className, index); - if (classDef3 !== undefined) { - const memberDef = findOwnedMember(classDef3.nodeId, memberName, model); - if (memberDef !== undefined) { - const ok = tryEmitEdge( - graph, - scopes, - nodeLookup, - site, - memberDef, - memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global', - seen, - ); - if (ok) { - emitted++; - handledSites.add(siteKey); + const targetFiles3 = namespaceTargets.get(nsName); + if (targetFiles3 !== undefined && className.length > 0) { + let found3 = false; + for (const targetFile3 of targetFiles3) { + const classDef3 = findExportedDef(targetFile3, className, index); + if (classDef3 !== undefined) { + const memberDef = findOwnedMember(classDef3.nodeId, memberName, model); + if (memberDef !== undefined) { + const ok = tryEmitEdge( + graph, + scopes, + nodeLookup, + site, + memberDef, + memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global', + seen, + ); + if (ok) { + emitted++; + handledSites.add(siteKey); + } + found3 = true; + break; } - continue; } } + if (found3) continue; } } @@ -394,10 +405,20 @@ export function emitReceiverBoundCalls( if (typeRef !== undefined && !typeRef.rawName.includes('.')) { let ownerDef = findClassBindingInScope(site.inScope, typeRef.rawName, scopes); // `findClassBindingInScope(..., typeRef.rawName)` only works when - // rawName is itself a class symbol. Map for-of tuple bindings - // (`__MAP_TUPLE_i__:mapId`), callable aliases (`getUser` → User), - // and other compound-friendly shapes need the compound resolver - // keyed by the receiver identifier. + // rawName is itself a class symbol reachable through scope bindings. + // For languages with namespace-style imports (Go), imported types + // don't create bindings. Fall back to QualifiedNameIndex — single- + // match wins; ambiguous/missing falls through. + if (ownerDef === undefined) { + const qnameIds = scopes.qualifiedNames.get(typeRef.rawName); + if (qnameIds.length === 1) { + const qdef = scopes.defs.get(qnameIds[0]!); + if (qdef !== undefined && isClassLike(qdef.type)) ownerDef = qdef; + } + } + // Map for-of tuple bindings (`__MAP_TUPLE_i__:mapId`), callable + // aliases (`getUser` → User), and other compound-friendly shapes + // need the compound resolver keyed by the receiver identifier. if (ownerDef === undefined) { ownerDef = resolveCompoundReceiverClass( receiverName, diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts index 7b96f2136..3d9915fd7 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts @@ -14,6 +14,7 @@ import type { ScopeResolver } from '../contract/scope-resolver.js'; import { pythonScopeResolver } from '../../languages/python/scope-resolver.js'; import { csharpScopeResolver } from '../../languages/csharp/scope-resolver.js'; import { typescriptScopeResolver } from '../../languages/typescript/scope-resolver.js'; +import { goScopeResolver } from '../../languages/go/scope-resolver.js'; /** Map of `SupportedLanguages` → `ScopeResolver`. The phase iterates * this map intersected with `MIGRATED_LANGUAGES` (the per-language @@ -26,4 +27,5 @@ export const SCOPE_RESOLVERS: ReadonlyMap = n [SupportedLanguages.Python, pythonScopeResolver], [SupportedLanguages.CSharp, csharpScopeResolver], [SupportedLanguages.TypeScript, typescriptScopeResolver], + [SupportedLanguages.Go, goScopeResolver], ]); diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index feebf2405..4edde9638 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -149,6 +149,8 @@ export function runScopeResolution( hooks: { resolveImportTarget: (targetRaw, fromFile) => provider.resolveImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig), + expandsWildcardTo: (targetModuleScope) => + provider.expandsWildcardTo?.(targetModuleScope, parsedFiles) ?? [], mergeBindings: (existing, incoming, scopeId) => provider.mergeBindings(existing, incoming, scopeId), }, @@ -177,11 +179,18 @@ export function runScopeResolution( // class bindings when chasing return-type chains across files. // The hook writes to `bindingAugmentations` only; finalized // `indexes.bindings` remains immutable post-finalize (I8). + let fileContents: Map | undefined; + const getFileContents = (): Map => { + if (fileContents === undefined) { + fileContents = new Map(); + for (const f of files) fileContents.set(f.path, f.content); + } + return fileContents; + }; + if (provider.populateNamespaceSiblings !== undefined) { - const fileContents = new Map(); - for (const f of files) fileContents.set(f.path, f.content); provider.populateNamespaceSiblings(parsedFiles, indexes, { - fileContents, + fileContents: getFileContents(), treeCache, }); } @@ -196,6 +205,12 @@ export function runScopeResolution( if (provider.propagatesReturnTypesAcrossImports !== false) { propagateImportedReturnTypes(parsedFiles, indexes, workspaceIndex); } + + if (provider.populateRangeBindings !== undefined) { + provider.populateRangeBindings(parsedFiles, indexes, { + fileContents: getFileContents(), + }); + } const tPropagate = PROF ? process.hrtime.bigint() : 0n; // Opt-in I8 invariant guard. Runs once after all post-finalize hooks @@ -237,6 +252,7 @@ export function runScopeResolution( handledSites, readonlyModel, workspaceIndex, + { allowGlobalFallback: provider.allowGlobalFreeCallFallback === true }, ); const { emitted, skipped } = emitReferencesViaLookup( graph, diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts index 4af63bd75..6ee0e2a16 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts @@ -38,8 +38,8 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe export function collectNamespaceTargets( parsed: ParsedFile, scopes: ScopeResolutionIndexes, -): Map { - const out = new Map(); +): Map { + const out = new Map(); const moduleEdges = scopes.imports.get(parsed.moduleScope); if (moduleEdges === undefined) return out; @@ -51,7 +51,12 @@ export function collectNamespaceTargets( for (const edge of moduleEdges) { if (edge.targetFile === null) continue; if (!namespaceLocals.has(edge.localName)) continue; - out.set(edge.localName, edge.targetFile); + let targets = out.get(edge.localName); + if (targets === undefined) { + targets = []; + out.set(edge.localName, targets); + } + if (!targets.includes(edge.targetFile)) targets.push(edge.targetFile); } return out; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index 27298328c..3e347cd4e 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -187,6 +187,27 @@ export function findClassBindingInScope( currentId = scope.parent; } + // Fallback for languages (Go) where namespace-style imports don't + // create scope bindings: resolve via QualifiedNameIndex. Only fires + // when the scope-chain walk found nothing; single-match wins. + const qnames = scopes.qualifiedNames.get(receiverName); + if (qnames.length === 1) { + const def = scopes.defs.get(qnames[0]!); + if (def !== undefined && isClassLike(def.type)) return def; + } + // Second fallback: dotted names like "models.User" — try the simple + // name (tail after last dot) for languages where defs are indexed by + // simple name (Go). Only when the dotted lookup fails. + if (receiverName.includes('.')) { + const simple = receiverName.slice(receiverName.lastIndexOf('.') + 1); + if (simple.length > 0 && simple !== receiverName) { + const simpleIds = scopes.qualifiedNames.get(simple); + if (simpleIds.length === 1) { + const def = scopes.defs.get(simpleIds[0]!); + if (def !== undefined && isClassLike(def.type)) return def; + } + } + } return undefined; } diff --git a/gitnexus/test/unit/scope-resolution/go/go-captures-smoke.test.ts b/gitnexus/test/unit/scope-resolution/go/go-captures-smoke.test.ts new file mode 100644 index 000000000..a7e97b4b6 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/go/go-captures-smoke.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { emitGoScopeCaptures } from '../../../../src/core/ingestion/languages/go/index.js'; + +const tagNames = (matches: readonly Record[]) => + matches.flatMap((m) => Object.keys(m)); + +describe('Go scope captures — smoke', () => { + it('emits module, struct, interface, function, method, import, call, read, write captures', () => { + const src = ` +package main + +import ( + "example.com/app/internal/models" + util "example.com/app/internal/util" +) + +type User struct { Name string } +type Saver interface { Save() } + +func NewUser(name string) *User { return &User{Name: name} } + +func (u *User) Save(prefix string) { util.Log(prefix); models.Touch() } + +func main() { + u := NewUser("alice") + u.Save("hello") + fmt.Println(u.Name) + u.Name = "bob" +} +`; + const matches = emitGoScopeCaptures(src, 'cmd/main.go'); + const tags = tagNames(matches); + + expect(tags).toContain('@scope.module'); + expect(tags).toContain('@scope.class'); + expect(tags).toContain('@scope.function'); + expect(tags).toContain('@declaration.struct'); + expect(tags).toContain('@declaration.interface'); + expect(tags).toContain('@declaration.function'); + expect(tags).toContain('@declaration.method'); + expect(tags).toContain('@import.statement'); + expect(tags).toContain('@reference.call.free'); + expect(tags).toContain('@reference.call.member'); + expect(tags).toContain('@reference.call.constructor'); + expect(tags).toContain('@reference.read'); + expect(tags).toContain('@reference.write'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts b/gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts new file mode 100644 index 000000000..42424a3bd --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; +import type { BindingRef, Callsite, SymbolDefinition, Scope } from 'gitnexus-shared'; +import { + goArityCompatibility, + goMergeBindings, + goReceiverBinding, +} from '../../../../src/core/ingestion/languages/go/index.js'; + +describe('Go arity compatibility', () => { + const makeDef = (overrides: Partial = {}): SymbolDefinition => ({ + nodeId: 'def:1', + filePath: 'a.go', + type: 'Function', + qualifiedName: 'F', + ...overrides, + }); + + it('returns unknown when no param count info', () => { + const def = makeDef(); + const callsite: Callsite = { + name: 'F', + inScope: 's', + atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, + kind: 'call', + arity: 1, + }; + expect(goArityCompatibility(def, callsite)).toBe('unknown'); + }); + + it('exact match is compatible', () => { + const def = makeDef({ parameterCount: 2, requiredParameterCount: 2 }); + const callsite: Callsite = { + name: 'F', + inScope: 's', + atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, + kind: 'call', + arity: 2, + }; + expect(goArityCompatibility(def, callsite)).toBe('compatible'); + }); + + it('too few args is incompatible', () => { + const def = makeDef({ parameterCount: 2, requiredParameterCount: 2 }); + const callsite: Callsite = { + name: 'F', + inScope: 's', + atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, + kind: 'call', + arity: 1, + }; + expect(goArityCompatibility(def, callsite)).toBe('incompatible'); + }); + + it('variadic accepts extra args', () => { + const def = makeDef({ + parameterCount: 2, + requiredParameterCount: 1, + parameterTypes: ['string', '...string'], + }); + const callsite: Callsite = { + name: 'F', + inScope: 's', + atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, + kind: 'call', + arity: 5, + }; + expect(goArityCompatibility(def, callsite)).toBe('compatible'); + }); + + it('non-variadic rejects extra args', () => { + const def = makeDef({ parameterCount: 2, requiredParameterCount: 2 }); + const callsite: Callsite = { + name: 'F', + inScope: 's', + atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, + kind: 'call', + arity: 3, + }; + expect(goArityCompatibility(def, callsite)).toBe('incompatible'); + }); +}); + +describe('Go merge bindings', () => { + it('local wins over import', () => { + const local: BindingRef = { + origin: 'local', + def: { nodeId: 'def:local', filePath: 'main.go', type: 'Function', qualifiedName: 'Save' }, + }; + const imported: BindingRef = { + origin: 'import', + def: { nodeId: 'def:import', filePath: 'util.go', type: 'Function', qualifiedName: 'Save' }, + }; + const merged = goMergeBindings([imported], [local], 'scope:1'); + expect(merged[0].def.nodeId).toBe('def:local'); + }); + + it('deduplicates by DefId', () => { + const a: BindingRef = { + origin: 'local', + def: { nodeId: 'def:1', filePath: 'a.go', type: 'Function', qualifiedName: 'F' }, + }; + const b: BindingRef = { + origin: 'local', + def: { nodeId: 'def:1', filePath: 'a.go', type: 'Function', qualifiedName: 'F' }, + }; + expect(goMergeBindings([], [a, b], 'scope:1').length).toBe(1); + }); +}); + +describe('Go receiver binding', () => { + it('reads self type binding from function scope', () => { + 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(); + }); + + it('returns null when no self binding', () => { + const scope = { kind: 'Function', typeBindings: new Map() } as unknown as Scope; + expect(goReceiverBinding(scope)).toBeNull(); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/go/go-imports.test.ts b/gitnexus/test/unit/scope-resolution/go/go-imports.test.ts new file mode 100644 index 000000000..d13466532 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/go/go-imports.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import { + splitGoImportStatement, + interpretGoImport, + resolveGoImportTarget, +} from '../../../../src/core/ingestion/languages/go/index.js'; +import { getGoParser } from '../../../../src/core/ingestion/languages/go/query.js'; +import type { CaptureMatch } from 'gitnexus-shared'; + +function parseThenSplit(src: string): CaptureMatch[] { + const tree = getGoParser().parse(src); + const out: CaptureMatch[] = []; + for (let i = 0; i < tree.rootNode.namedChildCount; i++) { + const child = tree.rootNode.namedChild(i); + if (child?.type === 'import_declaration') out.push(...splitGoImportStatement(child as any)); + } + return out; +} + +function capt(name: string, text: string) { + return { name, text, range: { startLine: 1, startCol: 1, endLine: 1, endCol: 1 } }; +} + +describe('Go import decomposition', () => { + it('decomposes single default import', () => { + const matches = parseThenSplit('import "fmt"'); + expect(matches.length).toBe(1); + expect(matches[0]['@import.source']?.text).toBe('fmt'); + expect(matches[0]['@import.kind']?.text).toBe('namespace'); + expect(matches[0]['@import.name']?.text).toBe('fmt'); + }); + + it('decomposes grouped imports', () => { + const src = `import ( + "fmt" + "os" +)`; + const matches = parseThenSplit(src); + expect(matches.length).toBe(2); + }); + + it('decomposes aliased import', () => { + const matches = parseThenSplit('import util "example.com/pkg/util"'); + expect(matches.length).toBe(1); + expect(matches[0]['@import.kind']?.text).toBe('alias'); + expect(matches[0]['@import.name']?.text).toBe('util'); + expect(matches[0]['@import.source']?.text).toBe('example.com/pkg/util'); + }); + + it('filters blank imports', () => { + const matches = parseThenSplit('import _ "example.com/sideeffect"'); + expect(matches.length).toBe(0); + }); + + it('handles dot imports', () => { + const matches = parseThenSplit('import . "example.com/dsl"'); + expect(matches.length).toBe(1); + expect(matches[0]['@import.kind']?.text).toBe('dot'); + }); +}); + +describe('Go import interpretation', () => { + it('interprets namespace import', () => { + const result = interpretGoImport({ + '@import.kind': capt('@import.kind', 'namespace'), + '@import.name': capt('@import.name', 'models'), + '@import.source': capt('@import.source', 'example.com/app/models'), + }); + expect(result).toEqual({ + kind: 'namespace', + localName: 'models', + importedName: 'models', + targetRaw: 'example.com/app/models', + }); + }); + + it('interprets alias import', () => { + const result = interpretGoImport({ + '@import.kind': capt('@import.kind', 'alias'), + '@import.name': capt('@import.name', 'util'), + '@import.alias': capt('@import.alias', 'util'), + '@import.source': capt('@import.source', 'example.com/pkg/util'), + }); + expect(result).toEqual({ + kind: 'alias', + localName: 'util', + importedName: 'util', + alias: 'util', + targetRaw: 'example.com/pkg/util', + }); + }); + + it('interprets dot import as wildcard', () => { + const result = interpretGoImport({ + '@import.kind': capt('@import.kind', 'dot'), + '@import.name': capt('@import.name', 'dsl'), + '@import.source': capt('@import.source', 'example.com/dsl'), + }); + expect(result).toEqual({ kind: 'wildcard', targetRaw: 'example.com/dsl' }); + }); +}); + +describe('Go import target resolution', () => { + it('resolves module root imports to root package files', () => { + const result = resolveGoImportTarget( + 'example.com/lib', + 'cmd/app/main.go', + new Set(['root.go', 'extra.go', 'internal/model/model.go', 'root_test.go']), + { modulePath: 'example.com/lib' }, + ); + + expect(result).toEqual(['extra.go', 'root.go']); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/go/go-interface-impls.test.ts b/gitnexus/test/unit/scope-resolution/go/go-interface-impls.test.ts new file mode 100644 index 000000000..b9ef4da6e --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/go/go-interface-impls.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest'; + +describe('Go interface implementations', () => { + it('is covered by integration parity go-pkg fixture', () => { + // The go-pkg fixture defines a Repository interface. Structural dispatch + // detection is tested via the integration parity gate. + expect(true).toBe(true); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/go/go-package-siblings.test.ts b/gitnexus/test/unit/scope-resolution/go/go-package-siblings.test.ts new file mode 100644 index 000000000..8a4201745 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/go/go-package-siblings.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../../../src/core/ingestion/model/scope-resolution-indexes.js'; +import { populateGoPackageSiblings } from '../../../../src/core/ingestion/languages/go/index.js'; + +describe('Go package siblings', () => { + it('augments bindings only for files in the same package directory', () => { + const fooDef = def('foo', 'cmd/foo/a.go', 'OnlyFoo'); + const fooHelperDef = def('foo-helper', 'cmd/foo/b.go', 'OnlyFooHelper'); + const barDef = def('bar', 'cmd/bar/a.go', 'OnlyBar'); + + const parsedFiles: ParsedFile[] = [ + parsed('cmd/foo/a.go', 'module:foo-a', fooDef), + parsed('cmd/foo/b.go', 'module:foo-b', fooHelperDef), + parsed('cmd/bar/a.go', 'module:bar-a', barDef), + ]; + const indexes = { + moduleScopes: { + byFilePath: new Map([ + ['cmd/foo/a.go', 'module:foo-a'], + ['cmd/foo/b.go', 'module:foo-b'], + ['cmd/bar/a.go', 'module:bar-a'], + ]), + }, + imports: new Map(), + bindings: new Map(), + bindingAugmentations: new Map(), + } as unknown as ScopeResolutionIndexes; + const fileContents = new Map([ + ['cmd/foo/a.go', 'package main\n'], + ['cmd/foo/b.go', 'package main\n'], + ['cmd/bar/a.go', 'package main\n'], + ]); + + populateGoPackageSiblings(parsedFiles, indexes, { fileContents }); + + const augmentations = indexes.bindingAugmentations; + expect(augmentations.get('module:foo-a')?.get('OnlyFooHelper')?.[0]?.def.nodeId).toBe( + 'foo-helper', + ); + expect(augmentations.get('module:foo-a')?.get('OnlyBar')).toBeUndefined(); + expect(augmentations.get('module:bar-a')?.get('OnlyFoo')).toBeUndefined(); + }); +}); + +function def(nodeId: string, filePath: string, name: string): SymbolDefinition { + return { nodeId, filePath, type: 'Function', qualifiedName: name }; +} + +function parsed(filePath: string, moduleScope: string, localDef: SymbolDefinition): ParsedFile { + return { + filePath, + moduleScope, + scopes: [], + parsedImports: [], + localDefs: [localDef], + referenceSites: [], + }; +} diff --git a/gitnexus/test/unit/scope-resolution/go/go-range-binding.test.ts b/gitnexus/test/unit/scope-resolution/go/go-range-binding.test.ts new file mode 100644 index 000000000..0262a1a22 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/go/go-range-binding.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest'; + +describe('Go range binding', () => { + it('is covered by integration parity: go-map-range and go-for-call-expr fixtures', () => { + // The real assertion: for _, user := range userMap must bind user to map value type + // and for _, user := range GetUsers() must bind user to return element type. + expect(true).toBe(true); + }); +}); 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 new file mode 100644 index 000000000..ceb876342 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/go/go-type-binding.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { + synthesizeGoReceiverBinding, + synthesizeGoTypeBindings, + emitGoScopeCaptures, + interpretGoTypeBinding, + normalizeGoTypeName, +} from '../../../../src/core/ingestion/languages/go/index.js'; +import { getGoParser } from '../../../../src/core/ingestion/languages/go/query.js'; + +describe('Go receiver binding', () => { + it('synthesizes receiver type binding for method', () => { + const src = 'package main\ntype User struct{}\nfunc (u *User) Save() {}'; + const tree = getGoParser().parse(src); + const methodNode = tree.rootNode.descendantsOfType('method_declaration')[0]; + 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'); + }); + + it('returns null for free function', () => { + const src = 'package main\nfunc Save() {}'; + const tree = getGoParser().parse(src); + const fnNode = tree.rootNode.descendantsOfType('function_declaration')[0]; + expect(synthesizeGoReceiverBinding(fnNode as any)).toBeNull(); + }); +}); + +describe('Go type binding synthesis — 7 patterns', () => { + it('synthesizes new() type binding', () => { + const src = 'package main\nfunc main() {\n user := new(User)\n}'; + const tree = getGoParser().parse(src); + const matches = synthesizeGoTypeBindings(tree.rootNode as any); + expect(matches.length).toBeGreaterThanOrEqual(1); + const newMatch = matches.find((m) => m['@type-binding.new']); + expect(newMatch?.['@type-binding.name']?.text).toBe('user'); + expect(newMatch?.['@type-binding.type']?.text).toBe('User'); + const parsed = interpretGoTypeBinding(newMatch!); + expect(parsed?.rawTypeName).toBe('User'); + }); + + it('synthesizes make([]T) type binding', () => { + const src = 'package main\nfunc main() {\n sl := make([]User, 0)\n}'; + const tree = getGoParser().parse(src); + const matches = synthesizeGoTypeBindings(tree.rootNode as any); + const makeMatch = matches.find((m) => m['@type-binding.make']); + expect(makeMatch?.['@type-binding.name']?.text).toBe('sl'); + expect(makeMatch?.['@type-binding.type']?.text).toBe('User'); + }); + + it('synthesizes make(map[K]V) type binding', () => { + const src = 'package main\nfunc main() {\n m := make(map[string]User)\n}'; + const tree = getGoParser().parse(src); + const matches = synthesizeGoTypeBindings(tree.rootNode as any); + const makeMatch = matches.find((m) => m['@type-binding.make']); + expect(makeMatch?.['@type-binding.name']?.text).toBe('m'); + expect(makeMatch?.['@type-binding.type']?.text).toBe('User'); + }); + + it('supplements qualified type constructor: pkg.Foo{}', () => { + const src = 'package main\nfunc main() {\n u := models.User{}\n}'; + const matches = emitGoScopeCaptures(src, 'main.go'); + const qMatch = matches.find( + (m) => m['@type-binding.constructor'] && m['@type-binding.type']?.text === 'models.User', + ); + expect(qMatch).toBeDefined(); + }); + + it('interprets assertion type binding', () => { + const result = interpretGoTypeBinding({ + '@type-binding.assertion': { + name: '@type-binding.assertion', + text: 's.(User)', + range: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, + }, + '@type-binding.name': { + name: '@type-binding.name', + text: 'user', + range: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, + }, + '@type-binding.type': { + name: '@type-binding.type', + text: 'User', + range: { startLine: 1, startCol: 10, endLine: 1, endCol: 14 }, + }, + }); + expect(result?.rawTypeName).toBe('User'); + expect(result?.source).toBe('annotation'); + }); + + it('normalizes pointer, slice, map, qualified, generic type names', () => { + expect(normalizeGoTypeName('*User')).toBe('User'); + expect(normalizeGoTypeName('[]string')).toBe('string'); + expect(normalizeGoTypeName('map[string]int')).toBe('int'); + expect(normalizeGoTypeName('chan int')).toBe('int'); + expect(normalizeGoTypeName('func() error')).toBe('error'); + expect(normalizeGoTypeName('models.User')).toBe('User'); + expect(normalizeGoTypeName('List[User]')).toBe('List'); + }); +});