fix(go): resolve registry primary edge cases

This commit is contained in:
Evan Wang 2026-05-03 18:29:53 +08:00
parent ec65a3cfbd
commit bf42cf5e3e
24 changed files with 320 additions and 117 deletions

View file

@ -1,5 +1,10 @@
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js';
import {
findNodeAtRange,
nodeToCapture,
syntheticCapture,
type SyntaxNode,
} 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';
@ -8,37 +13,6 @@ import { synthesizeGoReceiverBinding } from './receiver-binding.js';
import { synthesizeGoTypeBindings } from './type-binding.js';
import { getTreeSitterBufferSize } from '../../constants.js';
/** Go builtin types that must not be qualified with a package prefix. */
const GO_BUILTIN_TYPES = new Set([
'bool',
'byte',
'comparable',
'complex128',
'complex64',
'error',
'float32',
'float64',
'int',
'int16',
'int32',
'int64',
'int8',
'rune',
'string',
'uint',
'uint16',
'uint32',
'uint64',
'uint8',
'uintptr',
'any',
]);
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,
@ -56,7 +30,6 @@ export function emitGoScopeCaptures(
const rawMatches = getGoScopeQuery().matches(tree.rootNode);
const out: CaptureMatch[] = [];
const pkgName = inferPackageName(sourceText);
for (const m of rawMatches) {
const grouped: Record<string, Capture> = {};
@ -89,6 +62,8 @@ export function emitGoScopeCaptures(
}
}
if (isRawMultiAssignTypeBinding(tree.rootNode, grouped)) continue;
const declAnchor = grouped['@declaration.function'] ?? grouped['@declaration.method'];
if (declAnchor !== undefined) {
const fnNode =
@ -167,38 +142,27 @@ export function emitGoScopeCaptures(
});
}
// 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();
// Strip chan prefix so element type gets qualified:
// chan Event → pkg.Event (correct for cross-receiver dispatch)
// chan int → excluded by GO_BUILTIN_TYPES below (no phantom pkg.int)
if (raw.startsWith('chan ')) raw = raw.slice(5).trim();
if (raw.includes('.') || raw.startsWith('func(') || raw.startsWith('map[')) continue;
if (GO_BUILTIN_TYPES.has(raw)) 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;
}
function isRawMultiAssignTypeBinding(
rootNode: SyntaxNode,
grouped: Record<string, Capture>,
): boolean {
const anchor =
grouped['@type-binding.constructor'] ??
grouped['@type-binding.call-return'] ??
grouped['@type-binding.assertion'];
if (anchor === undefined) return false;
const node = findNodeAtRange(rootNode, anchor.range, 'short_var_declaration');
if (node === null) return false;
const lhs = node.childForFieldName('left');
const rhs = node.childForFieldName('right');
if (lhs === null) return false;
if (rhs === null) return false;
return (
lhs.namedChildren.filter((c) => c.type === 'identifier').length >= 2 &&
rhs.namedChildren.length >= 2
);
}

View file

@ -10,7 +10,7 @@ export function interpretGoImport(captures: CaptureMatch): ParsedImport | 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 };
return { kind: 'namespace', localName: alias, importedName: name, targetRaw: source };
}
if (kind === 'namespace') {
if (name === undefined) return null;

View file

@ -19,16 +19,38 @@ export function populateGoOwners(parsed: ParsedFile): void {
// struct/interface declarations, this handles struct field ownership.
populateClassOwnedMembers(parsed);
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
if (moduleScope === undefined) return;
populateGoOwnersInPackage([parsed]);
}
export function populateGoWorkspaceOwners(
parsedFiles: readonly ParsedFile[],
ctx: { readonly fileContents: ReadonlyMap<string, string> },
): void {
const filesByPackage = new Map<string, ParsedFile[]>();
for (const parsed of parsedFiles) {
const pkgName = inferPackageName(ctx.fileContents.get(parsed.filePath) ?? '');
if (pkgName === null) continue;
const key = `${packageDir(parsed.filePath)}\0${pkgName}`;
const bucket = filesByPackage.get(key) ?? [];
bucket.push(parsed);
filesByPackage.set(key, bucket);
}
for (const bucket of filesByPackage.values()) {
populateGoOwnersInPackage(bucket);
}
}
function populateGoOwnersInPackage(parsedFiles: readonly ParsedFile[]): void {
// Build struct name → def map from ALL scopes' ownedDefs (struct defs
// live in Class scopes now, not Module scope).
const structByQualifiedName = new Map<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);
for (const parsed of parsedFiles) {
for (const scope of parsed.scopes) {
for (const def of scope.ownedDefs) {
if (isClassLike(def.type) && def.qualifiedName) {
structByQualifiedName.set(def.qualifiedName, def.nodeId);
}
}
}
}
@ -37,37 +59,50 @@ export function populateGoOwners(parsed: ParsedFile): void {
// 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;
for (const parsed of parsedFiles) {
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;
// 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 (ownerId !== undefined) {
for (const def of methodDefs) {
(def as { ownerId?: string }).ownerId = ownerId;
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;
}
}
}
}
}
}
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);
}

View file

@ -59,7 +59,6 @@ const GO_SCOPE_QUERY = `
left: (expression_list (identifier) @declaration.name)) @declaration.variable
;; Imports
(import_declaration) @import.statement
(import_spec) @import.statement
;; Type bindings — parameter annotations

View file

@ -1,7 +1,7 @@
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 { populateGoOwners, populateGoWorkspaceOwners } 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';
@ -36,6 +36,7 @@ export const goScopeResolver: ScopeResolver = {
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
populateOwners: (parsed: ParsedFile) => populateGoOwners(parsed),
populateWorkspaceOwners: (parsedFiles, ctx) => populateGoWorkspaceOwners(parsedFiles, ctx),
isSuperReceiver: () => false,

View file

@ -11,18 +11,18 @@ export function synthesizeGoTypeBindings(rootNode: SyntaxNode): CaptureMatch[] {
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.
// expressions. The tree-sitter query produces all LHS/RHS
// combinations; emit only the positions whose RHS carries an
// inferable type.
const lhsIds = lhs.namedChildren.filter((c) => c.type === 'identifier');
const rhsExprs = right.namedChildren.filter(
(c) => c.type === 'composite_literal' || c.type === 'call_expression',
);
const rhsExprs = right.namedChildren;
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;
if (typeNode === null) continue;
const typeName = extractSimpleTypeNameText(typeNode);
out.push({
'@type-binding.multi-assign': syntheticCapture(
'@type-binding.multi-assign',
@ -239,12 +239,46 @@ function extractTypeNode(expr: SyntaxNode): SyntaxNode | null {
null
);
}
if (expr.type === 'unary_expression') {
const operand = expr.childForFieldName('operand');
return operand === null ? null : extractTypeNode(operand);
}
if (expr.type === 'call_expression') {
const fn = expr.childForFieldName('function');
if (fn?.type === 'identifier' && fn.text === 'new') {
const args = expr.childForFieldName('arguments');
return (
args?.namedChildren.find((c) =>
['type_identifier', 'qualified_type', 'pointer_type'].includes(c.type),
) ?? null
);
}
if (fn?.type === 'identifier' && fn.text === 'make') {
const args = expr.childForFieldName('arguments');
const container = args?.namedChildren.find((c) =>
['slice_type', 'map_type'].includes(c.type),
);
if (container?.type === 'slice_type') {
return (
container.namedChildren.find((c) =>
['type_identifier', 'qualified_type'].includes(c.type),
) ?? null
);
}
if (container?.type === 'map_type') {
const typeChildren = container.namedChildren.filter((c) =>
['type_identifier', 'qualified_type'].includes(c.type),
);
return typeChildren[1] ?? typeChildren[0] ?? null;
}
}
if (fn?.type === 'identifier') return fn;
if (fn?.type === 'selector_expression') {
return fn.childForFieldName('field') ?? fn;
}
}
if (expr.type === 'type_assertion_expression') {
return expr.childForFieldName('type');
}
return null;
}

View file

@ -395,6 +395,18 @@ export interface ScopeResolver {
*/
populateOwners(parsed: ParsedFile): void;
/**
* Optional workspace-wide ownership reconciliation for languages whose
* member owner can be declared in a different file from the owner type.
* Runs after every file has had `populateOwners(parsed)` applied, but
* still before `reconcileOwnership`, so stamped ownerIds are copied into
* the semantic model registries.
*/
readonly populateWorkspaceOwners?: (
parsedFiles: readonly ParsedFile[],
ctx: { readonly fileContents: ReadonlyMap<string, string> },
) => void;
/**
* Recognize a `super(...)`-style receiver text. Python returns
* `/^super\s*\(/.test(t)`. Java returns `t === 'super'`. C++ may

View file

@ -90,6 +90,14 @@ export function runScopeResolution(
const onWarn = input.onWarn ?? (() => {});
const PROF = process.env.PROF_SCOPE_RESOLUTION === '1';
const tStart = PROF ? process.hrtime.bigint() : 0n;
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;
};
// ── Phase 1: extract each file → ParsedFile ────────────────────────────
const parsedFiles: ParsedFile[] = [];
@ -111,6 +119,7 @@ export function runScopeResolution(
provider.populateOwners(parsed);
parsedFiles.push(parsed);
}
provider.populateWorkspaceOwners?.(parsedFiles, { fileContents: getFileContents() });
// Reconcile scope-resolution's ownership view into the SemanticModel.
// See `reconcile-ownership.ts` for the full rationale (Contract
@ -179,15 +188,6 @@ 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) {
provider.populateNamespaceSiblings(parsedFiles, indexes, {
fileContents: getFileContents(),

View file

@ -0,0 +1,3 @@
module example.com/aliasimport
go 1.21

View file

@ -0,0 +1,3 @@
package util
func Log() {}

View file

@ -0,0 +1,7 @@
package main
import util "example.com/aliasimport/internal/util"
func main() {
util.Log()
}

View file

@ -0,0 +1,3 @@
module example.com/samefactory
go 1.21

View file

@ -0,0 +1,10 @@
package main
func NewUser() *User {
return &User{}
}
func processUser() {
user := NewUser()
user.Save()
}

View file

@ -0,0 +1,7 @@
package main
type Repo struct{}
func (r *Repo) Save() bool {
return true
}

View file

@ -0,0 +1,7 @@
package main
type User struct{}
func (u *User) Save() bool {
return true
}

View file

@ -0,0 +1,3 @@
module example.com/splitowner
go 1.21

View file

@ -0,0 +1,6 @@
package main
func process() {
user := User{}
user.Save()
}

View file

@ -0,0 +1,7 @@
package main
type Repo struct{}
func (r *Repo) Save() bool {
return true
}

View file

@ -0,0 +1,5 @@
package main
func (u *User) Save() bool {
return true
}

View file

@ -0,0 +1,3 @@
package main
type User struct{}

View file

@ -610,6 +610,30 @@ describe('Go return type inference via explicit function return type', () => {
});
});
describe('Go same-package factory return type inference', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'go-same-package-factory'), () => {});
}, 60000);
it('resolves user.Save() through same-package NewUser() return type', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(
(c) => c.target === 'Save' && c.source === 'processUser' && c.targetFilePath === 'user.go',
);
expect(userSave).toBeDefined();
});
it('does not resolve user.Save() to Repo.Save', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(
(c) => c.target === 'Save' && c.source === 'processUser' && c.targetFilePath === 'repo.go',
);
expect(repoSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Go multi-return factory inference: user, err := NewUser("alice"); user.Save()
// ---------------------------------------------------------------------------
@ -1261,6 +1285,47 @@ describe('Go cross-file binding propagation', () => {
});
});
describe('Go aliased package selector resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'go-aliased-package-import'), () => {});
}, 60000);
it('resolves util.Log() through an aliased package import', () => {
const calls = getRelationships(result, 'CALLS');
const logCall = calls.find(
(c) =>
c.target === 'Log' && c.source === 'main' && c.targetFilePath === 'internal/util/log.go',
);
expect(logCall).toBeDefined();
});
});
describe('Go method owner resolution across package files', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'go-split-method-owner'), () => {});
}, 60000);
it('resolves user.Save() to the method whose receiver type is declared in another package file', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(
(c) => c.target === 'Save' && c.source === 'process' && c.targetFilePath === 'save.go',
);
expect(userSave).toBeDefined();
});
it('does not resolve user.Save() to Repo.Save', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(
(c) => c.target === 'Save' && c.source === 'process' && c.targetFilePath === 'repo.go',
);
expect(repoSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Go cmd/ helper files should NOT get entry-point multiplier (P0-1 fix)
// Only main.go files should get the 3.0 entry-point boost, not arbitrary

View file

@ -5,6 +5,23 @@ const tagNames = (matches: readonly Record<string, unknown>[]) =>
matches.flatMap((m) => Object.keys(m));
describe('Go scope captures — smoke', () => {
it('emits grouped imports once per import spec', () => {
const src = `
package main
import (
"fmt"
"os"
)
`;
const matches = emitGoScopeCaptures(src, 'main.go');
const imports = matches
.filter((m) => m['@import.source'] !== undefined)
.map((m) => m['@import.source']!.text);
expect(imports).toEqual(['fmt', 'os']);
});
it('emits module, struct, interface, function, method, import, call, read, write captures', () => {
const src = `
package main

View file

@ -82,10 +82,9 @@ describe('Go import interpretation', () => {
'@import.source': capt('@import.source', 'example.com/pkg/util'),
});
expect(result).toEqual({
kind: 'alias',
kind: 'namespace',
localName: 'util',
importedName: 'util',
alias: 'util',
targetRaw: 'example.com/pkg/util',
});
});

View file

@ -67,6 +67,19 @@ describe('Go type binding synthesis — 7 patterns', () => {
expect(qMatch).toBeDefined();
});
it('keeps multi-assignment constructor bindings aligned with RHS positions', () => {
const src = 'package main\nfunc main() {\n a, b := 42, X{}\n}';
const bindings = emitGoScopeCaptures(src, 'main.go')
.filter((m) => m['@type-binding.name'] !== undefined)
.map((m) => ({
name: m['@type-binding.name']!.text,
type: m['@type-binding.type']!.text,
}));
expect(bindings).toContainEqual({ name: 'b', type: 'X' });
expect(bindings).not.toContainEqual({ name: 'a', type: 'X' });
});
it('interprets assertion type binding', () => {
const result = interpretGoTypeBinding({
'@type-binding.assertion': {