Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
56 changes: 56 additions & 0 deletions __tests__/erlang-arity-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
});
});
2 changes: 1 addition & 1 deletion src/extraction/extraction-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
89 changes: 89 additions & 0 deletions src/resolution/import-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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.
Expand Down
40 changes: 13 additions & 27 deletions src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('::') &&
Expand All @@ -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;
}
Expand Down