From 70dabcbbc9b37eb11c3cbf00ef4369f1da573510 Mon Sep 17 00:00:00 2001 From: Helweg Date: Sat, 29 Aug 2026 11:17:38 +0200 Subject: [PATCH 1/2] feat: resolve local Go package calls safely --- CHANGELOG.md | 1 + src/indexer/go-package-resolution.ts | 487 +++++++++++++++++++++++++ src/indexer/index.ts | 2 +- src/indexer/local-module-resolution.ts | 69 +++- tests/automatic-branch-index.test.ts | 2 +- tests/call-graph.test.ts | 166 ++++++++- tests/local-module-resolution.test.ts | 176 ++++++++- tests/pr-impact.test.ts | 2 +- 8 files changed, 897 insertions(+), 8 deletions(-) create mode 100644 src/indexer/go-package-resolution.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f4916de..92588fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Source-backed architecture context**: Added the portable `architecture_context` tool across OpenCode, MCP, and Pi. It produces deterministic, token-bounded repository maps with source-derived responsibility excerpts, cited community and boundary evidence, strict query and directory focus, graph-sparse source-directory fallback, uncertainty notes, precise follow-up tool calls, and optional Git-backed recent activity. Architecture planning evaluation now measures graded evidence relevance and actual response token cost. - **Local JavaScript module graph resolution**: TypeScript and JavaScript call edges now resolve through local relative ES module imports, aliases, default and namespace imports, named re-exports, and `export *` chains. Resolution remains conservative for ambiguous or missing modules and exports, existing indexes migrate without re-embedding unchanged chunks, and graph coverage diagnostics report branch-aware resolved and unresolved totals by language. +- **Local Go package call resolution**: Direct, unshadowed Go function calls now resolve conservatively to a unique function declared by another eligible indexed file in the same directory and package. Selector calls, build-constrained files, incompatible test files, different packages or directories, and ambiguous targets remain unresolved, while package membership and source changes refresh affected callers without re-embedding unchanged chunks. - **Workspace package call graph resolution**: TypeScript and JavaScript call edges now follow project-local package imports selected by root `package.json` workspace array or object patterns, including exclusions, ordered static ESM `node`, `import`, and `default` conditions, exact source-facing declarations, bounded safe single-wildcard entries, and safe package-relative subpaths. Discovery remains bounded to known project sources, exact and null or invalid declarations block broader fallbacks, wildcard precedence follows Node's deterministic package-export specificity, and malformed, encoded-traversal, `node_modules`, escaping, external, or duplicate mappings remain unresolved rather than being guessed. Root ownership, manifest changes, and the resolution-version migration refresh unchanged graph edges without re-embedding source chunks. ## [0.25.1] - 2026-08-23 diff --git a/src/indexer/go-package-resolution.ts b/src/indexer/go-package-resolution.ts new file mode 100644 index 0000000..e7902e7 --- /dev/null +++ b/src/indexer/go-package-resolution.ts @@ -0,0 +1,487 @@ +import type { CallSiteData, SymbolData } from "../native/types.js"; + +import * as path from "node:path"; + +const GO_SOURCE_EXTENSION = ".go"; + +function normalizeGoFilePath(filePath: string): string { + return path.posix.normalize(filePath.replaceAll("\\", "/")); +} + +export function isGoFilePath(filePath: string): boolean { + return path.posix.extname(normalizeGoFilePath(filePath)).toLowerCase() === GO_SOURCE_EXTENSION; +} + +// The native Go query intentionally exposes both `Target()` and `pkg.Target()` +// as Call sites. This lexer preserves that public contract while using the +// reported UTF-8 byte coordinates to admit only syntactically direct calls. +// It also recognizes local bindings conservatively so lexical shadowing never +// turns into a guessed package edge. +interface GoToken { + kind: "identifier" | "symbol"; + value: string; + line: number; + column: number; +} + +const GO_CALLABLE_SYMBOL_KINDS = new Set(["function_declaration", "method_declaration"]); +const GO_KEYWORDS = new Set([ + "break", + "default", + "func", + "interface", + "select", + "case", + "defer", + "go", + "map", + "struct", + "chan", + "else", + "goto", + "package", + "switch", + "const", + "fallthrough", + "if", + "range", + "type", + "continue", + "for", + "import", + "return", + "var", +]); +const GO_BUILD_OPERATING_SYSTEMS = new Set([ + "aix", + "android", + "darwin", + "dragonfly", + "freebsd", + "illumos", + "ios", + "js", + "linux", + "netbsd", + "openbsd", + "plan9", + "solaris", + "wasip1", + "windows", +]); +const GO_BUILD_ARCHITECTURES = new Set([ + "386", + "amd64", + "arm", + "arm64", + "loong64", + "mips", + "mips64", + "mips64le", + "mipsle", + "ppc64", + "ppc64le", + "riscv64", + "s390x", + "wasm", +]); + +function isGoIdentifierStart(character: string): boolean { + return character === "_" || /\p{L}/u.test(character); +} + +function isGoIdentifierContinue(character: string): boolean { + return isGoIdentifierStart(character) || /\p{Nd}/u.test(character); +} + +function tokenizeGoSource(content: string): GoToken[] { + const tokens: GoToken[] = []; + let cursor = 0; + let line = 1; + let column = 0; + + const currentCharacter = (): string => { + const codePoint = content.codePointAt(cursor); + return codePoint === undefined ? "" : String.fromCodePoint(codePoint); + }; + const advance = (): string => { + const character = currentCharacter(); + cursor += character.length; + if (character === "\n") { + tokens.push({ kind: "symbol", value: "\n", line, column }); + line += 1; + column = 0; + } else { + column += Buffer.byteLength(character, "utf8"); + } + return character; + }; + + while (cursor < content.length) { + const character = currentCharacter(); + const next = content[cursor + character.length] ?? ""; + + if (/\s/u.test(character)) { + advance(); + continue; + } + if (character === "/" && next === "/") { + advance(); + advance(); + while (cursor < content.length && currentCharacter() !== "\n") advance(); + continue; + } + if (character === "/" && next === "*") { + advance(); + advance(); + while (cursor < content.length) { + const commentCharacter = currentCharacter(); + const commentNext = content[cursor + commentCharacter.length] ?? ""; + advance(); + if (commentCharacter === "*" && commentNext === "/") { + advance(); + break; + } + } + continue; + } + if (character === '"' || character === "'" || character === "`") { + const delimiter = character; + advance(); + let escaped = false; + while (cursor < content.length) { + const stringCharacter = currentCharacter(); + advance(); + if (delimiter !== "`" && escaped) { + escaped = false; + continue; + } + if (delimiter !== "`" && stringCharacter === "\\") { + escaped = true; + continue; + } + if (stringCharacter === delimiter) break; + } + continue; + } + if (isGoIdentifierStart(character)) { + const startLine = line; + const startColumn = column; + let value = ""; + while (cursor < content.length && isGoIdentifierContinue(currentCharacter())) { + value += advance(); + } + tokens.push({ kind: "identifier", value, line: startLine, column: startColumn }); + continue; + } + + const startLine = line; + const startColumn = column; + if (character === ":" && next === "=") { + advance(); + advance(); + tokens.push({ kind: "symbol", value: ":=", line: startLine, column: startColumn }); + continue; + } + advance(); + tokens.push({ kind: "symbol", value: character, line: startLine, column: startColumn }); + } + + return tokens; +} + +function previousGoToken(tokens: readonly GoToken[], start: number): number { + let cursor = start; + while (cursor >= 0 && tokens[cursor].value === "\n") cursor -= 1; + return cursor; +} + +function nextGoToken(tokens: readonly GoToken[], start: number): number { + let cursor = start; + while (cursor < tokens.length && tokens[cursor].value === "\n") cursor += 1; + return cursor; +} + +function findMatchingGoDelimiter( + tokens: readonly GoToken[], + start: number, + open: string, + close: string, +): number | undefined { + let depth = 0; + for (let index = start; index < tokens.length; index += 1) { + if (tokens[index].value === open) depth += 1; + if (tokens[index].value === close) { + depth -= 1; + if (depth === 0) return index; + } + } + return undefined; +} + +function goPositionContains(symbol: SymbolData, site: CallSiteData): boolean { + const startsBefore = symbol.startLine < site.line + || (symbol.startLine === site.line && symbol.startCol <= site.column); + const endsAfter = symbol.endLine > site.line + || (symbol.endLine === site.line && symbol.endCol >= site.column); + return startsBefore && endsAfter; +} + +function findEnclosingGoCallableSymbol( + symbols: readonly SymbolData[], + site: CallSiteData, +): SymbolData | undefined { + return symbols + .filter((symbol) => GO_CALLABLE_SYMBOL_KINDS.has(symbol.kind) && goPositionContains(symbol, site)) + .sort((left, right) => + (left.endLine - left.startLine) - (right.endLine - right.startLine) + || left.startLine - right.startLine + || left.startCol - right.startCol + || left.id.localeCompare(right.id) + )[0]; +} + +function goTokenIsWithinSymbol(token: GoToken, symbol: SymbolData): boolean { + return token.line > symbol.startLine + || (token.line === symbol.startLine && token.column >= symbol.startCol); +} + +function goDeclarationContainsName( + tokens: readonly GoToken[], + declarationIndex: number, + callIndex: number, + name: string, +): boolean { + const declarationKind = tokens[declarationIndex].value; + let cursor = nextGoToken(tokens, declarationIndex + 1); + if (cursor >= callIndex) return false; + + if (tokens[cursor].value !== "(") { + if (tokens[cursor].kind === "identifier" && tokens[cursor].value === name) return true; + if (declarationKind === "type") return false; + while (cursor < callIndex) { + const comma = nextGoToken(tokens, cursor + 1); + if (tokens[comma]?.value !== ",") return false; + cursor = nextGoToken(tokens, comma + 1); + if (tokens[cursor]?.kind !== "identifier") return false; + if (tokens[cursor].value === name) return true; + } + return false; + } + + const end = findMatchingGoDelimiter(tokens, cursor, "(", ")"); + if (end === undefined) return false; + let atSpecStart = true; + let depth = 1; + for (let index = cursor + 1; index < Math.min(end, callIndex); index += 1) { + const token = tokens[index]; + if (token.value === "(") depth += 1; + if (token.value === ")") depth -= 1; + if (depth !== 1) continue; + if (token.value === "\n" || token.value === ";") { + atSpecStart = true; + continue; + } + if (!atSpecStart) continue; + if (token.kind === "identifier") { + if (token.value === name) return true; + let next = nextGoToken(tokens, index + 1); + while (tokens[next]?.value === ",") { + next = nextGoToken(tokens, next + 1); + if (tokens[next]?.kind !== "identifier") break; + if (tokens[next].value === name) return true; + next = nextGoToken(tokens, next + 1); + } + atSpecStart = false; + } + } + return false; +} + +function goFunctionHeaderContainsName( + tokens: readonly GoToken[], + functionIndex: number, + callIndex: number, + name: string, +): boolean { + let declarationNameIndex: number | undefined; + const cursor = nextGoToken(tokens, functionIndex + 1); + if (tokens[cursor]?.value === "(") { + const receiverEnd = findMatchingGoDelimiter(tokens, cursor, "(", ")"); + if (receiverEnd !== undefined) { + const possibleName = nextGoToken(tokens, receiverEnd + 1); + const possibleParameters = nextGoToken(tokens, possibleName + 1); + if (tokens[possibleName]?.kind === "identifier" && tokens[possibleParameters]?.value === "(") { + declarationNameIndex = possibleName; + } + } + } else if (tokens[cursor]?.kind === "identifier") { + declarationNameIndex = cursor; + } + + let parenthesisDepth = 0; + let bracketDepth = 0; + let anonymousTypeBraceDepth = 0; + for (let index = functionIndex + 1; index < callIndex; index += 1) { + const token = tokens[index]; + if (token.value === "(") parenthesisDepth += 1; + if (token.value === ")") parenthesisDepth -= 1; + if (token.value === "[") bracketDepth += 1; + if (token.value === "]") bracketDepth -= 1; + if (token.value === "{") { + if (parenthesisDepth === 0 && bracketDepth === 0 && anonymousTypeBraceDepth === 0) { + const previous = previousGoToken(tokens, index - 1); + if (tokens[previous]?.value === "interface" || tokens[previous]?.value === "struct") { + anonymousTypeBraceDepth = 1; + continue; + } + break; + } + if (anonymousTypeBraceDepth > 0) anonymousTypeBraceDepth += 1; + } + if (token.value === "}" && anonymousTypeBraceDepth > 0) anonymousTypeBraceDepth -= 1; + if (index !== declarationNameIndex && token.kind === "identifier" && token.value === name) { + return true; + } + } + return false; +} + +function hasGoLocalBindingBeforeCall( + tokens: readonly GoToken[], + scopeStart: number, + callIndex: number, + name: string, +): boolean { + for (let index = scopeStart; index < callIndex; index += 1) { + const token = tokens[index]; + if (token.value === "func" && goFunctionHeaderContainsName(tokens, index, callIndex, name)) { + return true; + } + if ( + (token.value === "var" || token.value === "const" || token.value === "type") + && goDeclarationContainsName(tokens, index, callIndex, name) + ) { + return true; + } + if (token.value === ":=") { + let binding = previousGoToken(tokens, index - 1); + while (binding >= scopeStart && tokens[binding]?.kind === "identifier") { + if (tokens[binding].value === name) return true; + const comma = previousGoToken(tokens, binding - 1); + if (tokens[comma]?.value !== ",") break; + binding = previousGoToken(tokens, comma - 1); + } + } + } + return false; +} + +function isDirectGoFunctionCallSite( + tokens: readonly GoToken[], + callIndex: number | undefined, + site: CallSiteData, + symbols: readonly SymbolData[], +): boolean { + if (site.callType !== "Call" || site.calleeName === "init" || callIndex === undefined) return false; + + const previous = previousGoToken(tokens, callIndex - 1); + const next = nextGoToken(tokens, callIndex + 1); + if (tokens[previous]?.value === "." || tokens[next]?.value !== "(") return false; + + const enclosingSymbol = findEnclosingGoCallableSymbol(symbols, site); + if (!enclosingSymbol) return false; + const scopeStart = tokens.findIndex((token) => goTokenIsWithinSymbol(token, enclosingSymbol)); + if (scopeStart === -1 || scopeStart >= callIndex) return false; + return !hasGoLocalBindingBeforeCall(tokens, scopeStart, callIndex, site.calleeName); +} + +export function createGoDirectCallClassifier( + content: string, + symbols: readonly SymbolData[], +): (site: CallSiteData) => boolean { + const tokens = tokenizeGoSource(content); + const tokenIndexes = new Map(); + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token.kind === "identifier") { + tokenIndexes.set(`${token.value}\0${token.line}\0${token.column}`, index); + } + } + return (site) => isDirectGoFunctionCallSite( + tokens, + tokenIndexes.get(`${site.calleeName}\0${site.line}\0${site.column}`), + site, + symbols, + ); +} + +function hasGoBuildDirective(content: string): boolean { + const packageOffset = content.search(/^[ \t]*package\s+[\p{L}_]/mu); + const header = packageOffset === -1 ? content : content.slice(0, packageOffset); + return /^[ \t]*\/\/(?:go:build\b|[ \t]*\+build\b)/mu.test(header); +} + +function hasGoFileNameBuildConstraint(filePath: string): boolean { + let stem = path.posix.basename(normalizeGoFilePath(filePath), GO_SOURCE_EXTENSION).toLowerCase(); + if (stem.endsWith("_test")) stem = stem.slice(0, -"_test".length); + const segments = stem.split("_"); + const last = segments.at(-1); + const secondLast = segments.at(-2); + return (last !== undefined && (GO_BUILD_OPERATING_SYSTEMS.has(last) || GO_BUILD_ARCHITECTURES.has(last))) + || (secondLast !== undefined && GO_BUILD_OPERATING_SYSTEMS.has(secondLast) + && last !== undefined && GO_BUILD_ARCHITECTURES.has(last)); +} + +function hasGoCgoImport(content: string): boolean { + return /\bimport(?:\s+(?:[._]|[\p{L}_][\p{L}\p{Nd}_]*))?\s+(?:"C"|`C`)/u.test(content) + || /\bimport\s*\([\s\S]*?(?:"C"|`C`)[\s\S]*?\)/u.test(content); +} + +// Cross-file resolution abstains whenever file membership depends on build +// context that the index does not model. This is intentionally narrower than +// the set of files the parser can index. +export function isGoPackageResolutionEligible(filePath: string, content: string): boolean { + const normalized = normalizeGoFilePath(filePath); + const baseName = path.posix.basename(normalized); + return path.posix.extname(normalized) === GO_SOURCE_EXTENSION + && !baseName.startsWith(".") + && !baseName.startsWith("_") + && !hasGoBuildDirective(content) + && !hasGoFileNameBuildConstraint(normalized) + && !hasGoCgoImport(content); +} + +export function isGoTestFilePath(filePath: string): boolean { + return normalizeGoFilePath(filePath).toLowerCase().endsWith("_test.go"); +} + +export function parseGoPackageName(content: string): string | undefined { + let cursor = content.charCodeAt(0) === 0xfeff ? 1 : 0; + + while (cursor < content.length) { + if (/\s/u.test(content[cursor])) { + cursor += 1; + continue; + } + if (content.startsWith("//", cursor)) { + const lineEnd = content.indexOf("\n", cursor + 2); + cursor = lineEnd === -1 ? content.length : lineEnd + 1; + continue; + } + if (content.startsWith("/*", cursor)) { + const commentEnd = content.indexOf("*/", cursor + 2); + if (commentEnd === -1) return undefined; + cursor = commentEnd + 2; + continue; + } + break; + } + + const packageName = content.slice(cursor).match( + /^package\s+([\p{L}_][\p{L}\p{Nd}_]*)(?=\s|;|$)/u, + )?.[1]; + return packageName && packageName !== "_" && !GO_KEYWORDS.has(packageName) + ? packageName + : undefined; +} diff --git a/src/indexer/index.ts b/src/indexer/index.ts index d75477d..2eb03e4 100644 --- a/src/indexer/index.ts +++ b/src/indexer/index.ts @@ -187,7 +187,7 @@ function resolveSameCommunityCandidateIds( .map((candidate) => candidate.id)); } // Existing indexes without this metadata are the implicit version 1. -const CALL_GRAPH_RESOLUTION_VERSION = "8"; +const CALL_GRAPH_RESOLUTION_VERSION = "9"; const PHP_FUNCTION_SYMBOL_CHUNK_TYPES = new Set([ "function_declaration", "function", diff --git a/src/indexer/local-module-resolution.ts b/src/indexer/local-module-resolution.ts index c62d57e..b62b9e9 100644 --- a/src/indexer/local-module-resolution.ts +++ b/src/indexer/local-module-resolution.ts @@ -2,6 +2,14 @@ import type { CallSiteData, SymbolData } from "../native/types.js"; import * as path from "node:path"; +import { + createGoDirectCallClassifier, + isGoFilePath, + isGoPackageResolutionEligible, + isGoTestFilePath, + parseGoPackageName, +} from "./go-package-resolution.js"; + const JAVASCRIPT_SOURCE_EXTENSIONS = [ ".ts", ".tsx", @@ -1354,9 +1362,11 @@ function namespaceQualifier(content: string, site: CallSiteData): string | undef export class LocalModuleCallResolver { private readonly modulePaths = new Set(); + private readonly goPackagePaths = new Set(); private readonly loadModule: LocalModuleResolverOptions["loadModule"]; private readonly moduleData = new Map>(); private readonly moduleRecords = new Map>(); + private readonly goCallClassifiers = new Map boolean>(); private readonly exportCache = new Map(); private readonly tsConfigPathAliases?: LocalModulePathAliases; private readonly pathAliasesForImporter?: LocalModuleResolverOptions["pathAliasesForImporter"]; @@ -1366,6 +1376,7 @@ export class LocalModuleCallResolver { for (const filePath of options.filePaths) { const normalized = normalizeFilePath(filePath); if (isJavaScriptFamilyFilePath(normalized)) this.modulePaths.add(normalized); + if (isGoFilePath(normalized)) this.goPackagePaths.add(normalized); } this.loadModule = options.loadModule; this.tsConfigPathAliases = options.tsConfigPathAliases; @@ -1378,9 +1389,14 @@ export class LocalModuleCallResolver { seedModule(filePath: string, data: LocalModuleData): void { const normalized = normalizeFilePath(filePath); - if (!this.modulePaths.has(normalized)) return; + if (!this.modulePaths.has(normalized) && !this.goPackagePaths.has(normalized)) return; this.moduleData.set(normalized, Promise.resolve(data)); - this.moduleRecords.set(normalized, Promise.resolve(parseModuleRecord(data.content))); + if (this.modulePaths.has(normalized)) { + this.moduleRecords.set(normalized, Promise.resolve(parseModuleRecord(data.content))); + } + if (this.goPackagePaths.has(normalized)) { + this.goCallClassifiers.set(normalized, createGoDirectCallClassifier(data.content, data.symbols)); + } for (const key of this.exportCache.keys()) { if (key.startsWith(`${normalized}\0`)) this.exportCache.delete(key); } @@ -1392,6 +1408,9 @@ export class LocalModuleCallResolver { site: CallSiteData, ): Promise { const importer = normalizeFilePath(importerFilePath); + if (this.goPackagePaths.has(importer)) { + return this.resolveGoPackageCallTarget(importer, importerContent, site); + } if (!this.modulePaths.has(importer)) return undefined; const record = await this.getModuleRecord(importer, importerContent); if (!record) return undefined; @@ -1417,6 +1436,52 @@ export class LocalModuleCallResolver { return unique.length === 1 ? unique[0] : undefined; } + private async resolveGoPackageCallTarget( + importerFilePath: string, + importerContent: string, + site: CallSiteData, + ): Promise { + const importerData = await this.getModuleData(importerFilePath); + if (!importerData || !isGoPackageResolutionEligible(importerFilePath, importerContent)) { + return undefined; + } + const classifyCall = this.goCallClassifiers.get(importerFilePath) + ?? createGoDirectCallClassifier(importerContent, importerData.symbols); + this.goCallClassifiers.set(importerFilePath, classifyCall); + if (!classifyCall(site)) return undefined; + + const packageName = parseGoPackageName(importerContent); + if (!packageName) return undefined; + + const importerDirectory = path.posix.dirname(importerFilePath); + const importerIsTest = isGoTestFilePath(importerFilePath); + const candidates: SymbolData[] = []; + for (const targetPath of this.goPackagePaths) { + if ( + targetPath === importerFilePath + || path.posix.dirname(targetPath) !== importerDirectory + || (!importerIsTest && isGoTestFilePath(targetPath)) + ) { + continue; + } + + const targetData = await this.getModuleData(targetPath); + if ( + !targetData + || !isGoPackageResolutionEligible(targetPath, targetData.content) + || parseGoPackageName(targetData.content) !== packageName + ) { + continue; + } + candidates.push(...targetData.symbols.filter((symbol) => + symbol.name === site.calleeName && symbol.kind === "function_declaration" + )); + } + + const unique = deduplicateSymbols(candidates); + return unique.length === 1 ? unique[0] : undefined; + } + private async getModuleData(filePath: string): Promise { const normalized = normalizeFilePath(filePath); const existing = this.moduleData.get(normalized); diff --git a/tests/automatic-branch-index.test.ts b/tests/automatic-branch-index.test.ts index 8e350f0..60bc3b3 100644 --- a/tests/automatic-branch-index.test.ts +++ b/tests/automatic-branch-index.test.ts @@ -318,7 +318,7 @@ function changed(): number { ), ).toBe(true); for (const [prefix, version] of [ - ["index.callGraphResolutionVersion", "8"], + ["index.callGraphResolutionVersion", "9"], [swiftPrefix, "1"], ["index.parser.metalVersion", "1"], ] as const) { diff --git a/tests/call-graph.test.ts b/tests/call-graph.test.ts index cfd8087..67e437a 100644 --- a/tests/call-graph.test.ts +++ b/tests/call-graph.test.ts @@ -199,6 +199,24 @@ function migrationMetadataKey(prefix: string, catalogIdentity = "default"): stri expect(callNames).toContain("fetchData"); }); + it("preserves Go direct and selector call extraction coordinates", () => { + const content = [ + "package worker", + "", + "func Run() {", + ' _ = "é"; Direct(); other.Direct()', + "}", + ].join("\n"); + const calls = extractCalls(content, "go").filter((call) => call.calleeName === "Direct"); + + expect(calls).toHaveLength(2); + expect(calls.map((call) => call.callType)).toEqual(["Call", "Call"]); + expect(calls.map((call) => call.column)).toEqual([ + Buffer.byteLength(' _ = "é"; ', "utf8"), + Buffer.byteLength(' _ = "é"; Direct(); other.', "utf8"), + ]); + }); + describe("php call extraction", () => { it("should extract direct function calls", () => { const content = fs.readFileSync(path.join(fixturesDir, "php-simple-calls.php"), "utf-8"); @@ -2507,7 +2525,7 @@ main() { }); }); - describe("local TypeScript and JavaScript module resolution", () => { + describe("local module resolution", () => { function mockEmbeddings(): ReturnType { return vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init?) => { const body = JSON.parse(String(init?.body ?? "{}")) as { input?: string | string[] }; @@ -3132,6 +3150,152 @@ main() { fetchSpy.mockRestore(); } }); + + it("refreshes conservative Go package edges through the public Indexer API", async () => { + const projectDir = path.join(tempDir, "go-package-resolution-project"); + const packageDir = path.join(projectDir, "worker"); + fs.mkdirSync(path.join(projectDir, "other"), { recursive: true }); + fs.mkdirSync(packageDir, { recursive: true }); + const callerContent = [ + "package worker", + "", + "func Run(Shadowed func()) {", + " Shared()", + " other.Shared()", + " Shadowed()", + " TestOnly()", + " Tagged()", + " Platform()", + " SameFile()", + " other.SameFile()", + "}", + "", + "func SameFile() {}", + ].join("\n"); + fs.writeFileSync(path.join(packageDir, "caller.go"), callerContent); + fs.writeFileSync(path.join(packageDir, "shadowed.go"), "package worker\n\nfunc Shadowed() {}\n"); + fs.writeFileSync(path.join(packageDir, "helpers_test.go"), "package worker\n\nfunc TestOnly() {}\n"); + fs.writeFileSync( + path.join(packageDir, "tagged.go"), + "//go:build custom\n\npackage worker\n\nfunc Tagged() {}\n", + ); + fs.writeFileSync(path.join(packageDir, "platform_linux.go"), "package worker\n\nfunc Platform() {}\n"); + fs.writeFileSync(path.join(packageDir, "different-package.go"), "package other\n\nfunc Shared() {}\n"); + fs.writeFileSync(path.join(projectDir, "other", "decoy.go"), "package worker\n\nfunc Shared() {}\n"); + const targetPath = path.join(packageDir, "target.go"); + const duplicatePath = path.join(packageDir, "duplicate.go"); + const fetchSpy = mockEmbeddings(); + const indexer = new Indexer(projectDir, createIndexerConfig(), "opencode"); + + const embeddedInputs = (): string[] => fetchSpy.mock.calls.flatMap(([, init]) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { input?: string | string[] }; + return Array.isArray(body.input) ? body.input : body.input ? [body.input] : []; + }); + const graphState = async (): Promise<{ + caller: SymbolData; + target: SymbolData | undefined; + sameFile: SymbolData; + edges: CallEdgeData[]; + }> => { + const symbols = await indexer.getSymbolsForBranch(); + const caller = symbols.find((symbol) => symbol.name === "Run"); + if (!caller) throw new Error("Missing Go caller symbol"); + const sameFile = symbols.find((symbol) => + symbol.name === "SameFile" && symbol.filePath.endsWith("worker/caller.go") + ); + if (!sameFile) throw new Error("Missing same-file Go target symbol"); + return { + caller, + target: symbols.find((symbol) => + symbol.name === "Shared" && symbol.filePath.endsWith("worker/target.go") + ), + sameFile, + edges: await indexer.getCallees(caller.id), + }; + }; + + try { + await indexer.index(); + let state = await graphState(); + expect(state.target).toBeUndefined(); + expect(state.edges.filter((edge) => edge.targetName === "Shared")).toMatchObject([ + { line: 4, isResolved: false }, + { line: 5, isResolved: false }, + ]); + for (const targetName of ["Shadowed", "TestOnly", "Tagged", "Platform"]) { + expect(state.edges.find((edge) => edge.targetName === targetName)).toMatchObject({ + isResolved: false, + }); + } + + expect(state.edges.find((edge) => edge.targetName === "SameFile" && edge.line === 10)).toMatchObject({ + isResolved: true, + toSymbolId: state.sameFile.id, + }); + expect(state.edges.find((edge) => edge.targetName === "SameFile" && edge.line === 11)).toMatchObject({ + isResolved: false, + }); + + fetchSpy.mockClear(); + fs.writeFileSync(targetPath, "// package worker is documented here.\npackage worker\n\nfunc Shared() {}\n"); + await indexer.index(); + state = await graphState(); + expect(state.target).toBeDefined(); + expect(state.edges.find((edge) => edge.targetName === "Shared" && edge.line === 4)).toMatchObject({ + isResolved: true, + toSymbolId: state.target!.id, + }); + expect(state.edges.find((edge) => edge.targetName === "Shared" && edge.line === 5)).toMatchObject({ + isResolved: false, + }); + await expect(indexer.getCallersForSymbol(state.target!.id, "Shared", false)).resolves.toMatchObject([ + { fromSymbolId: state.caller.id, line: 4, isResolved: true }, + ]); + expect(embeddedInputs().some((input) => input.includes("func Run"))).toBe(false); + + fetchSpy.mockClear(); + fs.writeFileSync(targetPath, "package other\n\nfunc Shared() {}\n"); + await indexer.index(); + state = await graphState(); + expect(state.edges.find((edge) => edge.targetName === "Shared" && edge.line === 4)).toMatchObject({ + isResolved: false, + }); + expect(embeddedInputs().some((input) => input.includes("func Run"))).toBe(false); + + fs.writeFileSync(targetPath, "package worker\n\nfunc Shared() {}\n"); + await indexer.index(); + state = await graphState(); + expect(state.edges.find((edge) => edge.targetName === "Shared" && edge.line === 4)?.isResolved).toBe(true); + + fetchSpy.mockClear(); + fs.writeFileSync(duplicatePath, "package worker\n\nfunc Shared() {}\n"); + await indexer.index(); + state = await graphState(); + expect(state.edges.find((edge) => edge.targetName === "Shared" && edge.line === 4)).toMatchObject({ + isResolved: false, + }); + expect(embeddedInputs().some((input) => input.includes("func Run"))).toBe(false); + + fetchSpy.mockClear(); + fs.unlinkSync(duplicatePath); + await indexer.index(); + state = await graphState(); + expect(state.edges.find((edge) => edge.targetName === "Shared" && edge.line === 4)?.isResolved).toBe(true); + expect(fetchSpy).not.toHaveBeenCalled(); + + fetchSpy.mockClear(); + fs.unlinkSync(targetPath); + await indexer.index(); + state = await graphState(); + expect(state.edges.find((edge) => edge.targetName === "Shared" && edge.line === 4)).toMatchObject({ + isResolved: false, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + await indexer.close(); + fetchSpy.mockRestore(); + } + }); }); describe("branch awareness", () => { diff --git a/tests/local-module-resolution.test.ts b/tests/local-module-resolution.test.ts index 0e5575d..cc21ceb 100644 --- a/tests/local-module-resolution.test.ts +++ b/tests/local-module-resolution.test.ts @@ -12,7 +12,7 @@ import { resolveTsConfigForModuleResolution, type LocalModuleData, } from "../src/indexer/local-module-resolution.js"; -import type { CallSiteData, SymbolData } from "../src/native/index.js"; +import { extractCalls, parseFiles, type CallSiteData, type SymbolData } from "../src/native/index.js"; describe("local workspace package manifest discovery", () => { const importerPaths = [ @@ -127,7 +127,9 @@ function symbol(id: string, filePath: string, name: string, kind = "function_dec startCol: 0, endLine: 3, endCol: 0, - language: filePath.endsWith(".js") ? "javascript" : "typescript", + language: filePath.endsWith(".go") + ? "go" + : filePath.endsWith(".js") ? "javascript" : "typescript", }; } @@ -135,6 +137,25 @@ function callSite(calleeName: string, line = 2, column = 2, callType: CallSiteDa return { calleeName, line, column, callType, confidence: "Direct" }; } +function goModule(filePath: string, content: string): LocalModuleData { + const parsed = parseFiles([{ path: filePath, content }])[0]; + return { + content, + symbols: parsed.symbols.map((parsedSymbol, index) => ({ + ...parsedSymbol, + id: `go_${index}_${parsedSymbol.name}`, + filePath, + })), + }; +} + +function goCall(content: string, calleeName: string, occurrence = 0): CallSiteData { + const sites = extractCalls(content, "go").filter((site) => site.calleeName === calleeName); + const site = sites[occurrence]; + if (!site) throw new Error(`Missing Go call ${calleeName} at occurrence ${occurrence}`); + return site; +} + function resolver( modules: Record, workspaceManifestTexts: Record = {}, @@ -150,6 +171,157 @@ function resolver( } describe("LocalModuleCallResolver", () => { + it("resolves only direct unshadowed Go function calls in the same directory and package", async () => { + const caller = [ + "package worker", + "", + "func Run(Shared func()) {", + " Shared()", + "}", + "", + "func Direct() {", + " Target()", + ' _ = "é"; other.Target()', + "}", + ].join("\n"); + const target = "package worker\n\nfunc Target() {}\nfunc Shared() {}\n"; + const targetSymbol = goModule("worker/target.go", target).symbols.find((entry) => entry.name === "Target"); + const instance = resolver({ + "worker/caller.go": goModule("worker/caller.go", caller), + "worker/target.go": goModule("worker/target.go", target), + "worker/other-package.go": goModule("worker/other-package.go", "package other\n\nfunc Target() {}\n"), + "other/decoy.go": goModule("other/decoy.go", "package worker\n\nfunc Target() {}\n"), + }); + + await expect(instance.resolveCallTarget("worker/caller.go", caller, goCall(caller, "Target", 0))) + .resolves.toEqual(targetSymbol); + await expect(instance.resolveCallTarget("worker/caller.go", caller, goCall(caller, "Target", 1))) + .resolves.toBeUndefined(); + await expect(instance.resolveCallTarget("worker/caller.go", caller, goCall(caller, "Shared"))) + .resolves.toBeUndefined(); + }); + + it("abstains for Go short declarations, reserved init calls, build constraints, and test-only targets", async () => { + const caller = [ + "package worker", + "", + "func Run() {", + " Shadowed := func() {}", + " Shadowed()", + " init()", + " Tagged()", + " Platform()", + " TestOnly()", + " CgoOnly()", + " HiddenOnly()", + " UppercaseOnly()", + "}", + ].join("\n"); + const modules = { + "worker/caller.go": goModule("worker/caller.go", caller), + "worker/shadowed.go": goModule("worker/shadowed.go", "package worker\n\nfunc Shadowed() {}\n"), + "worker/init.go": goModule("worker/init.go", "package worker\n\nfunc init() {}\n"), + "worker/tagged.go": goModule( + "worker/tagged.go", + "//go:build custom\n\npackage worker\n\nfunc Tagged() {}\n", + ), + "worker/platform_linux.go": goModule( + "worker/platform_linux.go", + "package worker\n\nfunc Platform() {}\n", + ), + "worker/helpers_test.go": goModule( + "worker/helpers_test.go", + "package worker\n\nfunc TestOnly() {}\n", + ), + "worker/cgo.go": goModule( + "worker/cgo.go", + 'package worker\n\nimport "C"\n\nfunc CgoOnly() {}\n', + ), + "worker/_hidden.go": goModule( + "worker/_hidden.go", + "package worker\n\nfunc HiddenOnly() {}\n", + ), + "worker/uppercase.GO": goModule( + "worker/uppercase.GO", + "package worker\n\nfunc UppercaseOnly() {}\n", + ), + }; + const instance = resolver(modules); + + for (const name of [ + "Shadowed", + "init", + "Tagged", + "Platform", + "TestOnly", + "CgoOnly", + "HiddenOnly", + "UppercaseOnly", + ]) { + await expect(instance.resolveCallTarget("worker/caller.go", caller, goCall(caller, name))) + .resolves.toBeUndefined(); + } + }); + + it("abstains for explicit Go local declarations and build-constrained importers", async () => { + const caller = [ + "package worker", + "", + "func Explicit() {", + " var (", + " ignored int", + " Bound = func() {}", + " )", + " Bound()", + "}", + "", + "func LocalType() {", + " type Converted func()", + " Converted()", + "}", + ].join("\n"); + const platformCaller = "package worker\n\nfunc PlatformRun() { Production() }\n"; + const taggedCaller = "// +build custom\n\npackage worker\n\nfunc TaggedRun() { Production() }\n"; + const production = goModule("worker/production.go", "package worker\n\nfunc Production() {}\n"); + const instance = resolver({ + "worker/caller.go": goModule("worker/caller.go", caller), + "worker/bound.go": goModule("worker/bound.go", "package worker\n\nfunc Bound() {}\nfunc Converted() {}\n"), + "worker/caller_linux.go": goModule("worker/caller_linux.go", platformCaller), + "worker/tagged-caller.go": goModule("worker/tagged-caller.go", taggedCaller), + "worker/production.go": production, + }); + + await expect(instance.resolveCallTarget("worker/caller.go", caller, goCall(caller, "Bound"))) + .resolves.toBeUndefined(); + await expect(instance.resolveCallTarget("worker/caller.go", caller, goCall(caller, "Converted"))) + .resolves.toBeUndefined(); + await expect(instance.resolveCallTarget( + "worker/caller_linux.go", + platformCaller, + goCall(platformCaller, "Production"), + )).resolves.toBeUndefined(); + await expect(instance.resolveCallTarget( + "worker/tagged-caller.go", + taggedCaller, + goCall(taggedCaller, "Production"), + )).resolves.toBeUndefined(); + }); + + it("allows same-package Go tests to call eligible production and test helpers", async () => { + const caller = "package worker\n\nfunc TestRun() { Production(); TestHelper() }\n"; + const production = goModule("worker/production.go", "package worker\n\nfunc Production() {}\n"); + const helper = goModule("worker/helper_test.go", "package worker\n\nfunc TestHelper() {}\n"); + const instance = resolver({ + "worker/caller_test.go": goModule("worker/caller_test.go", caller), + "worker/production.go": production, + "worker/helper_test.go": helper, + }); + + await expect(instance.resolveCallTarget("worker/caller_test.go", caller, goCall(caller, "Production"))) + .resolves.toEqual(production.symbols.find((entry) => entry.name === "Production")); + await expect(instance.resolveCallTarget("worker/caller_test.go", caller, goCall(caller, "TestHelper"))) + .resolves.toEqual(helper.symbols.find((entry) => entry.name === "TestHelper")); + }); it("resolves declared project-local workspace package roots and exact exports", async () => { const main = [ 'import { rootTarget } from "@scope/shared";', diff --git a/tests/pr-impact.test.ts b/tests/pr-impact.test.ts index 86e3d6f..bd9d117 100644 --- a/tests/pr-impact.test.ts +++ b/tests/pr-impact.test.ts @@ -28,7 +28,7 @@ function symbolExtractorMetadataKey(catalogIdentity: string): string { function setBranchMigrationMetadataCurrent(database: Database, catalogIdentity: string): void { const suffix = hashContent(catalogIdentity).slice(0, 24); - database.setMetadata(`index.callGraphResolutionVersion.${suffix}`, "8"); + database.setMetadata(`index.callGraphResolutionVersion.${suffix}`, "9"); database.setMetadata(`index.parser.swiftVersion.${suffix}`, "1"); database.setMetadata(`index.parser.metalVersion.${suffix}`, "1"); database.setMetadata(symbolExtractorMetadataKey(catalogIdentity), "1"); From 567f4062ac5e95db57b55a005458d97348a3e5fa Mon Sep 17 00:00:00 2001 From: Helweg Date: Sat, 29 Aug 2026 21:57:39 +0200 Subject: [PATCH 2/2] fix: restore Go call resolution after rebase --- src/indexer/index.ts | 57 +++++++++++++++++++++++++++++++++++----- tests/call-graph.test.ts | 12 +++++---- 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/src/indexer/index.ts b/src/indexer/index.ts index 2eb03e4..ec1c320 100644 --- a/src/indexer/index.ts +++ b/src/indexer/index.ts @@ -111,6 +111,7 @@ import { import { iterateOrderedFileBatches, type FileBatchLimits } from "./file-batches.js"; import { canonicalizePathForComparison } from "../utils/canonical-path.js"; import { summarizeCallGraphCoverage, type CallGraphCoverage } from "./call-graph-coverage.js"; +import { createGoDirectCallClassifier, isGoFilePath } from "./go-package-resolution.js"; import { getLocalWorkspacePackageManifestPaths, getLocalWorkspacePackages, @@ -4356,6 +4357,15 @@ export class Indexer { && isJavaScriptFamilyFilePath(filePath) && !currentStoredFilePaths.has(filePath) ); + const changedGoPackageDirectories = new Set( + Array.from(this.fileHashCache.keys()).flatMap((filePath) => + (!scopedRoots || this.isFileInCurrentScope(filePath, scopedRoots)) + && isGoFilePath(filePath) + && !currentStoredFilePaths.has(filePath) + ? [path.posix.dirname(filePath.split(path.sep).join("/"))] + : [] + ), + ); for (const file of files) { const storedPath = this.toStoredFilePath(file.path); @@ -4388,13 +4398,21 @@ export class Indexer { if (!cachedHashMatches && isJavaScriptFamilyFilePath(storedPath)) { javaScriptGraphSourcesChanged = true; } + if (!cachedHashMatches && isGoFilePath(storedPath)) { + changedGoPackageDirectories.add(path.posix.dirname(storedPath.split(path.sep).join("/"))); + } const needsCallGraphRefresh = cachedHashMatches - && (needsCallGraphResolutionMigration || localModuleResolutionConfigChanged) && ( - isJavaScriptFamilyFilePath(storedPath) - || database.getChunksByFile(storedPath).some((chunk) => - chunk.language === "php" || chunk.language === "c" || chunk.language === "cpp" + ( + (needsCallGraphResolutionMigration || localModuleResolutionConfigChanged) + && ( + isJavaScriptFamilyFilePath(storedPath) + || database.getChunksByFile(storedPath).some((chunk) => + chunk.language === "php" || chunk.language === "c" || chunk.language === "cpp" + ) + ) ) + || (needsCallGraphResolutionMigration && isGoFilePath(storedPath)) ); const requiresSwiftParserUpgrade = reparseCachedSwiftFiles && path.extname(storedPath).toLowerCase() === ".swift"; @@ -4427,6 +4445,22 @@ export class Indexer { } } + if (changedGoPackageDirectories.size > 0) { + for (const [storedPath, descriptor] of allFileDescriptors) { + const normalizedStoredPath = storedPath.split(path.sep).join("/"); + if ( + !isGoFilePath(normalizedStoredPath) + || !changedGoPackageDirectories.has(path.posix.dirname(normalizedStoredPath)) + || changedFilePathSet.has(storedPath) + ) { + continue; + } + unchangedFilePaths.delete(storedPath); + changedFileDescriptors.push(descriptor); + changedFilePathSet.add(storedPath); + } + } + for (const storedPath of allFileDescriptors.keys()) { if (changedFilePathSet.has(storedPath)) { this.logger.recordCacheMiss(); @@ -4751,14 +4785,23 @@ export class Indexer { symbolsByName.set(key, symbols); } - for (const site of extractCalls(loadedFile.content, fileLanguage)) { + const callSites = extractCalls(loadedFile.content, fileLanguage); + const classifyGoCall = fileLanguage === "go" + ? createGoDirectCallClassifier(loadedFile.content, fileSymbols) + : undefined; + for (const site of callSites) { const enclosingSymbol = findEnclosingSymbol(fileSymbols, site.line, site.column); if (!enclosingSymbol) { continue; } + const isSupportedGoCall = classifyGoCall?.(site) ?? false; let candidates = symbolsByName.get(normalizeSymbolKey(site.calleeName)); - if (fileLanguage === "php" && candidates) { + if (fileLanguage === "go") { + candidates = isSupportedGoCall + ? candidates?.filter((candidate) => candidate.kind === "function_declaration") + : undefined; + } else if (fileLanguage === "php" && candidates) { if (site.callType === "Constructor") { candidates = candidates.filter((candidate) => PHP_CLASS_SYMBOL_CHUNK_TYPES.has(candidate.kind)); } else if (site.callType === "Call") { @@ -4772,7 +4815,7 @@ export class Indexer { if ( !resolvedTarget && (!candidates || candidates.length === 0) - && isJavaScriptFamilyFilePath(parsed.path) + && (isJavaScriptFamilyFilePath(parsed.path) || isSupportedGoCall) ) { resolvedTarget = await localModuleResolver.resolveCallTarget( parsed.path, diff --git a/tests/call-graph.test.ts b/tests/call-graph.test.ts index 67e437a..d90f4c2 100644 --- a/tests/call-graph.test.ts +++ b/tests/call-graph.test.ts @@ -2870,7 +2870,7 @@ main() { col: resolvedEdge!.col, isResolved: false, }); - database.setMetadata(migrationMetadataKey("index.callGraphResolutionVersion"), "7"); + database.setMetadata(migrationMetadataKey("index.callGraphResolutionVersion"), "8"); database.close(); indexer = new Indexer(projectDir, createIndexerConfig(), "opencode"); @@ -2882,7 +2882,7 @@ main() { await indexer.close(); const migratedDatabase = new Database(path.join(projectDir, ".opencode", "index", "codebase.db")); - expect(migratedDatabase.getMetadata(migrationMetadataKey("index.callGraphResolutionVersion"))).toBe("8"); + expect(migratedDatabase.getMetadata(migrationMetadataKey("index.callGraphResolutionVersion"))).toBe("9"); migratedDatabase.close(); } finally { await indexer.close(); @@ -3232,9 +3232,11 @@ main() { isResolved: true, toSymbolId: state.sameFile.id, }); - expect(state.edges.find((edge) => edge.targetName === "SameFile" && edge.line === 11)).toMatchObject({ - isResolved: false, - }); + const qualifiedSameFileEdge = state.edges.find( + (edge) => edge.targetName === "SameFile" && edge.line === 11, + ); + expect(qualifiedSameFileEdge).toMatchObject({ isResolved: false }); + expect(qualifiedSameFileEdge?.toSymbolId).toBeUndefined(); fetchSpy.mockClear(); fs.writeFileSync(targetPath, "// package worker is documented here.\npackage worker\n\nfunc Shared() {}\n");