diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eda4e513..ed723edc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes -- Erlang functions that share a name but differ in arity are now separate symbols with the language's own `module:fun/arity` identity, so the everyday `f/1` delegating to `f/2` shows as a real call edge instead of a self-loop, each arity keeps its own `-spec` and source span, `-export([f/1])` marks exactly that arity as public, and asking `codegraph_explore` for a symbol the way Erlang spells it — `cowboy_req:header/3` — returns that definition. Re-index Erlang projects after upgrading. Thanks @Dshuishui. (#1610) (Erlang) +- Erlang functions that share a name but differ in arity are now separate symbols with the language's own `module:fun/arity` identity, so the everyday `f/1` delegating to `f/2` shows as a real call edge instead of a self-loop, each arity keeps its own `-spec` and source span, `-export([f/1])` marks exactly that arity as public, and asking `codegraph_explore` for a symbol the way Erlang spells it — `cowboy_req:header/3` — returns that definition. Selective `-import` calls now resolve only to the named module, while built-in and otherwise out-of-scope calls stay unresolved instead of linking to an unrelated project function with the same name. Re-index Erlang projects after upgrading. Thanks @Dshuishui. (#1610) (Erlang) - Erlang behaviour dispatch no longer miscounts a call site's arity when an argument is a binary literal like `<<1,2,3>>` — the commas inside were counted as argument separators, which silently dropped (or could mislink) the dispatch edge to the behaviour callback. (#1358) (Erlang) - The MCP server now finds your project when it's launched from a workspace folder above it: if the launch directory has no index of its own but exactly one indexed project sits below it (a repo container, an agent workspace, a monorepo root), that project becomes the session's default — live file watching and the shared daemon included — instead of every tool call failing until a `projectPath` or `--path` is supplied. Thanks @nakisen. (#1606) diff --git a/__tests__/erlang-arity-resolution.test.ts b/__tests__/erlang-arity-resolution.test.ts index 9351f81b3..ed9fca3a2 100644 --- a/__tests__/erlang-arity-resolution.test.ts +++ b/__tests__/erlang-arity-resolution.test.ts @@ -144,4 +144,60 @@ run(L) -> expect(edges).toContainEqual({ sq: 'user_m::run/1', tq: 'lib_m::bump/1' }); expect(edges.some((e) => e.sq === 'user_m::run/1' && e.tq === 'lib_m::bump/2')).toBe(false); }); + + it('resolves a selective import to its named module, not a nearer same-named function', async () => { + fs.mkdirSync(path.join(dir, 'deps'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'app'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'deps', 'imported.erl'), + `-module(imported). +-export([pick/1]). + +pick(X) -> {imported, X}. +` + ); + fs.writeFileSync( + path.join(dir, 'app', 'wrong.erl'), + `-module(wrong). +-export([pick/1]). + +pick(X) -> {wrong, X}. +` + ); + fs.writeFileSync( + path.join(dir, 'app', 'client.erl'), + `-module(client). +-import(imported, [ + pick/1 % imported selectively +]). +-export([run/1]). + +run(X) -> pick(X). +` + ); + const edges = await callEdges(dir); + expect(edges).toContainEqual({ sq: 'client::run/1', tq: 'imported::pick/1' }); + expect(edges).not.toContainEqual({ sq: 'client::run/1', tq: 'wrong::pick/1' }); + }); + + it('does not bind an auto-imported BIF to a same-named project function', async () => { + fs.writeFileSync( + path.join(dir, 'other.erl'), + `-module(other). +-export([length/1]). + +length(X) -> X. +` + ); + fs.writeFileSync( + path.join(dir, 'client.erl'), + `-module(client). +-export([run/1]). + +run(X) -> length(X). +` + ); + const edges = await callEdges(dir); + expect(edges).not.toContainEqual({ sq: 'client::run/1', tq: 'other::length/1' }); + }); }); diff --git a/src/extraction/extraction-version.ts b/src/extraction/extraction-version.ts index 07ccbb964..8ddab13b8 100644 --- a/src/extraction/extraction-version.ts +++ b/src/extraction/extraction-version.ts @@ -21,4 +21,4 @@ * turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty * in the product is load-bearing"). */ -export const EXTRACTION_VERSION = 25; +export const EXTRACTION_VERSION = 26; diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 60c7b3008..b015eba66 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -788,11 +788,75 @@ export function extractImportMappings( mappings.push(...extractPHPImports(content)); } else if (language === 'c' || language === 'cpp') { mappings.push(...extractCppImports(content)); + } else if (language === 'erlang') { + mappings.push(...extractErlangImports(content)); } return mappings; } +/** + * Extract Erlang's selective imports: `-import(module, [f/1, g/2]).` + * + * The arity stays in the local/exported name because it is part of an Erlang + * function's identity. The module name is an atom, not a filesystem path; the + * Erlang branch in resolveViaImport uses it to form the qualified name. + */ +function extractErlangImports(content: string): ImportMapping[] { + const mappings: ImportMapping[] = []; + const atom = String.raw`(?:'(?:\\.|[^'])*'|[a-z][A-Za-z0-9_@]*)`; + const importRe = new RegExp( + String.raw`^\s*-import\s*\(\s*(${atom})\s*,\s*\[([\s\S]*?)\]\s*\)\s*\.`, + 'gm', + ); + const bindingRe = new RegExp(String.raw`(${atom})\s*\/\s*(\d{1,3})`, 'g'); + const unquoteAtom = (value: string): string => value.replace(/^'([\s\S]*)'$/, '$1'); + + let importMatch: RegExpExecArray | null; + while ((importMatch = importRe.exec(content)) !== null) { + const source = unquoteAtom(importMatch[1]!); + const bindings = stripErlangLineComments(importMatch[2]!); + bindingRe.lastIndex = 0; + let bindingMatch: RegExpExecArray | null; + while ((bindingMatch = bindingRe.exec(bindings)) !== null) { + const name = `${unquoteAtom(bindingMatch[1]!)}/${bindingMatch[2]}`; + mappings.push({ + localName: name, + exportedName: name, + source, + isDefault: false, + isNamespace: false, + }); + } + } + + return mappings; +} + +/** Strip `%` comments without treating a percent inside a quoted atom/string as a comment. */ +function stripErlangLineComments(value: string): string { + let result = ''; + let quote: "'" | '"' | null = null; + let escaped = false; + for (let i = 0; i < value.length; i++) { + const ch = value[i]!; + if (quote) { + result += ch; + if (escaped) escaped = false; + else if (ch === '\\') escaped = true; + else if (ch === quote) quote = null; + } else if (ch === "'" || ch === '"') { + quote = ch; + result += ch; + } else if (ch === '%') { + while (i + 1 < value.length && value[i + 1] !== '\n') i++; + } else { + result += ch; + } + } + return result; +} + /** * Extract JS/TS import mappings */ @@ -1433,6 +1497,31 @@ export function resolveViaImport( return null; } + // Erlang selective imports name a module rather than a filesystem path, and + // the imported binding includes its arity (`-import(a, [f/1])`). Resolve the + // exact module::function/arity identity before the generic path-based import + // logic. Ambiguous duplicate module definitions are left unresolved. + if (ref.language === 'erlang' && /^.+\/\d{1,3}$/.test(ref.referenceName)) { + const imp = imports.find((candidate) => candidate.localName === ref.referenceName); + if (imp) { + const candidates = context + .getNodesByQualifiedName(`${imp.source}::${imp.exportedName}`) + .filter( + (node) => + node.language === 'erlang' && node.kind === 'function' && node.isExported, + ); + if (candidates.length === 1) { + return { + original: ref, + targetNodeId: candidates[0]!.id, + confidence: 0.95, + resolvedBy: 'import', + }; + } + } + return null; + } + // Go cross-package calls: `pkga.FuncX(...)` extracts to referenceName // `pkga.FuncX` and the import `github.com/example/myproject/pkga` // maps to a *package directory* containing one or more .go files. diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index c74d8f272..1ebf33c0a 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -2551,11 +2551,10 @@ export function matchReference( // Erlang call/fun refs carry the call-site arity (`f/1` — #1610) because // arity is part of the function's identity and every erlang function's // qualifiedName carries it (`mod::f/1`). Resolve ONLY to a definition of - // that exact arity: the call site's own file first (a local call targets its - // own module by language semantics; `-import`ed functions ride the - // cross-file branch), and when no definition of that arity exists anywhere, - // resolve to NOTHING rather than a sibling arity — the real target may be - // macro-generated or out of repo, and a wrong-arity edge is worse than none. + // that exact arity in the call site's own module. Explicit `-import`s are + // handled by resolveViaImport before this matcher. A bare call can otherwise + // only be a local function or an auto-imported BIF, so it must never fall + // through to a same-named function in another module. if ( ref.language === 'erlang' && !ref.referenceName.includes('::') && @@ -2565,30 +2564,17 @@ export function matchReference( if (am) { // endsWith is length-anchored, so `/1` cannot match `…/11`. const arityTail = `/${am[2]}`; - const candidates = context - .getNodesByName(am[1]!) - .filter( + const sameFile = context + .getNodesInFile(ref.filePath) + .find( (n) => - n.language === 'erlang' && n.kind === 'function' && n.qualifiedName.endsWith(arityTail), + n.language === 'erlang' && + n.kind === 'function' && + n.name === am[1] && + n.qualifiedName.endsWith(arityTail), ); - if (candidates.length > 0) { - const sameFile = candidates.find((n) => n.filePath === ref.filePath); - if (sameFile) { - return { original: ref, targetNodeId: sameFile.id, confidence: 0.95, resolvedBy: 'exact-match' }; - } - if (candidates.length === 1) { - return { original: ref, targetNodeId: candidates[0]!.id, confidence: 0.8, resolvedBy: 'exact-match' }; - } - const best = findBestMatch(ref, candidates, context); - if (best) { - const proximity = computePathProximity(ref.filePath, best.filePath); - return { - original: ref, - targetNodeId: best.id, - confidence: proximity >= 30 ? 0.7 : 0.4, - resolvedBy: 'exact-match', - }; - } + if (sameFile) { + return { original: ref, targetNodeId: sameFile.id, confidence: 0.95, resolvedBy: 'exact-match' }; } return null; }