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.
This commit is contained in:
Evan Wang 2026-05-01 03:51:58 +08:00
parent b5316c2df1
commit 705e3c733a
39 changed files with 2367 additions and 86 deletions

View file

@ -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'] {

View file

@ -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.
});

View file

@ -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;
}

View file

@ -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';
}

View file

@ -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;
}

View file

@ -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<ReturnType<typeof getGoParser>['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<string, Capture> = {};
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;
}

View file

@ -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<ScopeId, Map<string, BindingRef[]>>;
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<string, BindingRef[]>();
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 ?? '';
}

View file

@ -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!) } : {}),
},
];
}

View file

@ -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<string>,
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>): 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<string>, 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<string>;
readonly goModule?: GoModuleConfig;
}

View file

@ -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';

View file

@ -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<string, string[]> {
// 1. Collect interface defs → method names (from scope.ownedDefs)
const interfaceMethods = new Map<string, Set<string>>();
const interfaceDefsById = new Map<string, SymbolDefinition>();
// 2. Collect struct defs → method names
const structMethods = new Map<string, Set<string>>();
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<string>();
// 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<string>();
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<string, string[]>();
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<string>, subset: Set<string>): boolean {
for (const item of subset) {
if (!superset.has(item)) return false;
}
return true;
}

View file

@ -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;
}

View file

@ -0,0 +1,27 @@
import type { BindingRef } from 'gitnexus-shared';
const TIER: Record<BindingRef['origin'], number> = {
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<string>();
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;
});
}

View file

@ -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<string, string>(); // 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;
}
}
}
}
}

View file

@ -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<string, string> },
): 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<string, string>();
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<string, { filePath: string; defs: SymbolDefinition[] }[]>();
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<ScopeId, Map<string, BindingRef[]>>;
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, Map<string, BindingRef[]>>,
scopeId: ScopeId,
name: string,
): BindingRef[] {
let scopeBindings = augmentations.get(scopeId);
if (scopeBindings === undefined) {
scopeBindings = new Map<string, BindingRef[]>();
augmentations.set(scopeId, scopeBindings);
}
let bucketArr = scopeBindings.get(name);
if (bucketArr === undefined) {
bucketArr = [];
scopeBindings.set(name, bucketArr);
}
return bucketArr;
}

View file

@ -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<Parser['setLanguage']>[0]);
}
return _parser;
}
export function getGoScopeQuery(): Parser.Query {
if (_query === null) {
_query = new Parser.Query(Go as Parameters<Parser['setLanguage']>[0], GO_SCOPE_QUERY);
}
return _query;
}

View file

@ -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<string, string> },
): 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<string, TypeRef>;
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<string, Scope>,
): Scope | null {
// V1 simplified: return null, use module scope as fallback
return null;
}

View file

@ -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),
};
}

View file

@ -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,
};

View file

@ -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;
}

View file

@ -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<string, string>();
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;
}

View file

@ -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 } : {}),
};
}

View file

@ -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<string>,
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<interface_DefId, implementing_struct_DefId[]>.
* Default: undefined (no structural interface detection).
*/
readonly detectInterfaceImplementations?: (
parsedFiles: readonly ParsedFile[],
indexes: ScopeResolutionIndexes,
model: SemanticModel,
) => Map<string, string[]>;
/**
* 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<string, string> },
) => void;
}

View file

@ -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

View file

@ -36,6 +36,7 @@ export function emitFreeCallFallback(
handledSites: Set<string>,
model: SemanticModel,
workspaceIndex: WorkspaceResolutionIndex,
options: { readonly allowGlobalFallback?: boolean } = {},
): number {
let emitted = 0;
const seen = new Set<string>();
@ -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<string>();
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<string>();
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

View file

@ -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<string, string[]>();
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<string, TypeRef>).set(name, terminal);
}
}
}
}
// Final pass: chain-follow non-module scopes (function-local
// typeBindings). Module scopes were already followed inside the
// SCC loop above.

View file

@ -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<string, string>();
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);
}

View file

@ -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,

View file

@ -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<SupportedLanguages, ScopeResolver> = n
[SupportedLanguages.Python, pythonScopeResolver],
[SupportedLanguages.CSharp, csharpScopeResolver],
[SupportedLanguages.TypeScript, typescriptScopeResolver],
[SupportedLanguages.Go, goScopeResolver],
]);

View file

@ -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<string, string> | undefined;
const getFileContents = (): Map<string, string> => {
if (fileContents === undefined) {
fileContents = new Map<string, string>();
for (const f of files) fileContents.set(f.path, f.content);
}
return fileContents;
};
if (provider.populateNamespaceSiblings !== undefined) {
const fileContents = new Map<string, string>();
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,

View file

@ -38,8 +38,8 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe
export function collectNamespaceTargets(
parsed: ParsedFile,
scopes: ScopeResolutionIndexes,
): Map<string, string> {
const out = new Map<string, string>();
): Map<string, string[]> {
const out = new Map<string, string[]>();
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;
}

View file

@ -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;
}

View file

@ -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<string, unknown>[]) =>
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');
});
});

View file

@ -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> = {}): 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();
});
});

View file

@ -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']);
});
});

View file

@ -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);
});
});

View file

@ -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: [],
};
}

View file

@ -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);
});
});

View file

@ -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');
});
});