mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-04 02:31:36 +00:00
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:
parent
b5316c2df1
commit
705e3c733a
39 changed files with 2367 additions and 86 deletions
|
|
@ -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'] {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
});
|
||||
|
|
|
|||
46
gitnexus/src/core/ingestion/languages/go/arity-metadata.ts
Normal file
46
gitnexus/src/core/ingestion/languages/go/arity-metadata.ts
Normal 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;
|
||||
}
|
||||
16
gitnexus/src/core/ingestion/languages/go/arity.ts
Normal file
16
gitnexus/src/core/ingestion/languages/go/arity.ts
Normal 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';
|
||||
}
|
||||
18
gitnexus/src/core/ingestion/languages/go/cache-stats.ts
Normal file
18
gitnexus/src/core/ingestion/languages/go/cache-stats.ts
Normal 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;
|
||||
}
|
||||
174
gitnexus/src/core/ingestion/languages/go/captures.ts
Normal file
174
gitnexus/src/core/ingestion/languages/go/captures.ts
Normal 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;
|
||||
}
|
||||
97
gitnexus/src/core/ingestion/languages/go/expand-wildcards.ts
Normal file
97
gitnexus/src/core/ingestion/languages/go/expand-wildcards.ts
Normal 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 ?? '';
|
||||
}
|
||||
|
|
@ -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!) } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
81
gitnexus/src/core/ingestion/languages/go/import-target.ts
Normal file
81
gitnexus/src/core/ingestion/languages/go/import-target.ts
Normal 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;
|
||||
}
|
||||
16
gitnexus/src/core/ingestion/languages/go/index.ts
Normal file
16
gitnexus/src/core/ingestion/languages/go/index.ts
Normal 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';
|
||||
87
gitnexus/src/core/ingestion/languages/go/interface-impls.ts
Normal file
87
gitnexus/src/core/ingestion/languages/go/interface-impls.ts
Normal 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;
|
||||
}
|
||||
124
gitnexus/src/core/ingestion/languages/go/interpret.ts
Normal file
124
gitnexus/src/core/ingestion/languages/go/interpret.ts
Normal 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;
|
||||
}
|
||||
27
gitnexus/src/core/ingestion/languages/go/merge-bindings.ts
Normal file
27
gitnexus/src/core/ingestion/languages/go/merge-bindings.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
73
gitnexus/src/core/ingestion/languages/go/method-owners.ts
Normal file
73
gitnexus/src/core/ingestion/languages/go/method-owners.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
91
gitnexus/src/core/ingestion/languages/go/package-siblings.ts
Normal file
91
gitnexus/src/core/ingestion/languages/go/package-siblings.ts
Normal 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;
|
||||
}
|
||||
212
gitnexus/src/core/ingestion/languages/go/query.ts
Normal file
212
gitnexus/src/core/ingestion/languages/go/query.ts
Normal 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;
|
||||
}
|
||||
101
gitnexus/src/core/ingestion/languages/go/range-binding.ts
Normal file
101
gitnexus/src/core/ingestion/languages/go/range-binding.ts
Normal 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;
|
||||
}
|
||||
21
gitnexus/src/core/ingestion/languages/go/receiver-binding.ts
Normal file
21
gitnexus/src/core/ingestion/languages/go/receiver-binding.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
50
gitnexus/src/core/ingestion/languages/go/scope-resolver.ts
Normal file
50
gitnexus/src/core/ingestion/languages/go/scope-resolver.ts
Normal 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,
|
||||
};
|
||||
38
gitnexus/src/core/ingestion/languages/go/simple-hooks.ts
Normal file
38
gitnexus/src/core/ingestion/languages/go/simple-hooks.ts
Normal 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;
|
||||
}
|
||||
250
gitnexus/src/core/ingestion/languages/go/type-binding.ts
Normal file
250
gitnexus/src/core/ingestion/languages/go/type-binding.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
130
gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts
Normal file
130
gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
114
gitnexus/test/unit/scope-resolution/go/go-imports.test.ts
Normal file
114
gitnexus/test/unit/scope-resolution/go/go-imports.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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: [],
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
101
gitnexus/test/unit/scope-resolution/go/go-type-binding.test.ts
Normal file
101
gitnexus/test/unit/scope-resolution/go/go-type-binding.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue