Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ 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 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)

- When no project can be resolved at all, the MCP server now says so instead of starting silently: a startup log line names the directory it searched, and tool calls list the indexed sub-projects it can see nearby so you can pass one as `projectPath`. Previously the server looked healthy from the outside while every tool quietly had no project to answer from. Thanks @nakisen. (#1607)
Expand Down
147 changes: 147 additions & 0 deletions __tests__/erlang-arity-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* Erlang arity-aware resolution (#1610).
*
* Arity is part of a function's identity: `f/1` and `f/2` are unrelated
* definitions. Extraction gives each arity its own node (`mod::f/1`) and
* stamps refs with the call-site arity; resolution must land each ref on the
* def of exactly that arity — the everyday `header/2 -> header/3` delegation
* must be a real edge, never a self-loop — and refuse to guess a sibling
* arity when the named one doesn't exist.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';

describe('erlang arity-aware resolution', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'erlang-arity-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });

async function callEdges(d: string): Promise<Array<{ sq: string; tq: string }>> {
const cg = await CodeGraph.init(d, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const rows = db
.prepare(
`SELECT s.qualified_name sq, t.qualified_name tq
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE e.kind IN ('calls','references') AND s.kind = 'function'`
)
.all();
cg.destroy();
return rows;
}

it('resolves the f/N -> f/N+1 delegation to a real edge, not a self-loop', async () => {
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'deleg.erl'),
`-module(deleg).
-export([header/2]).

header(Name, Req) ->
header(Name, Req, undefined).

-spec header(binary(), map(), any()) -> any().
header(Name, Headers, Default) ->
maps:get(Name, Headers, Default).
`
);
const edges = await callEdges(dir);
expect(edges).toContainEqual({ sq: 'deleg::header/2', tq: 'deleg::header/3' });
// No self-loop in either direction.
expect(edges.some((e) => e.sq === e.tq && e.sq.startsWith('deleg::header'))).toBe(false);
});

it('resolves remote calls to the called arity and refuses a sibling arity', async () => {
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'store.erl'),
`-module(store).
-export([get/1, get/2]).

get(K) -> get(K, undefined).
get(K, Default) -> {K, Default}.
`
);
fs.writeFileSync(
path.join(dir, 'src', 'client.erl'),
`-module(client).
-export([fetch/1, broken/1]).

fetch(K) ->
store:get(K, nil).

broken(K) ->
store:get(K, nil, extra).
`
);
const edges = await callEdges(dir);
expect(edges).toContainEqual({ sq: 'client::fetch/1', tq: 'store::get/2' });
// store:get/3 doesn't exist — the ref must resolve to NOTHING, not /1 or /2.
expect(edges.some((e) => e.sq === 'client::broken/1' && e.tq.startsWith('store::get'))).toBe(false);
});

it('resolves an arity-less dynamic MFA ref only when exactly one arity exists', async () => {
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'single.erl'),
`-module(single).
-export([work/1]).

work(X) -> X.
`
);
fs.writeFileSync(
path.join(dir, 'src', 'multi.erl'),
`-module(multi).
-export([job/1, job/2]).

job(X) -> X.
job(X, Y) -> {X, Y}.
`
);
fs.writeFileSync(
path.join(dir, 'src', 'spawner.erl'),
`-module(spawner).
-export([go/1]).

go(Args) ->
erlang:spawn(single, work, Args),
erlang:spawn(multi, job, Args).
`
);
const edges = await callEdges(dir);
// `Args` is dynamic, so both refs are arity-less. single:work has exactly
// one arity — it resolves; multi:job has two — silent beats wrong.
expect(edges).toContainEqual({ sq: 'spawner::go/1', tq: 'single::work/1' });
expect(edges.some((e) => e.sq === 'spawner::go/1' && e.tq.startsWith('multi::job'))).toBe(false);
});

it('lands `fun mod:f/1` references on the written arity', async () => {
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'lib_m.erl'),
`-module(lib_m).
-export([bump/1, bump/2]).

bump(X) -> X + 1.
bump(X, N) -> X + N.
`
);
fs.writeFileSync(
path.join(dir, 'src', 'user_m.erl'),
`-module(user_m).
-export([run/1]).

run(L) ->
lists:map(fun lib_m:bump/1, L).
`
);
const edges = await callEdges(dir);
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);
});
});
41 changes: 41 additions & 0 deletions __tests__/erlang-behaviour-synthesizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,4 +187,45 @@ on_event(Ev) -> {seen, Ev}.
const rows = await synthEdges(dir);
expect(rows.map((r) => path.basename(r.tf))).toEqual(['public_impl.erl']);
});

it('counts dispatch-site arity across <<binary>> literals (#1358)', async () => {
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'codec_behaviour.erl'),
`-module(codec_behaviour).

-callback decode(binary(), list()) -> term().

-export([run/3]).

run(Mod, Bin, Opts) ->
Mod:decode(Bin, Opts).
`
);
fs.writeFileSync(
path.join(dir, 'src', 'json_codec.erl'),
`-module(json_codec).
-behaviour(codec_behaviour).
-export([decode/2]).

decode(Bin, _Opts) -> Bin.
`
);
// The dispatch site passes a binary literal whose commas previously
// inflated the computed arity (4 instead of 2), so the edge was dropped.
fs.writeFileSync(
path.join(dir, 'src', 'probe.erl'),
`-module(probe).
-export([go/1]).

go(Mod) ->
Mod:decode(<<1,2,3>>, []).
`
);

const rows = await synthEdges(dir);
const fromProbe = rows.filter((r) => r.source === 'go').map((r) => `${path.basename(r.tf)}:${r.target}`);
expect(fromProbe).toEqual(['json_codec.erl:decode']);
expect(rows.every((r) => r.via === 'codec_behaviour:decode/2' || r.source !== 'go')).toBe(true);
});
});
Loading