Fixes #1610. Also fixes #1358 (the `<<binary>>` arity miscount in behaviour dispatch, reported separately and hit by the same code path). ## Problem Arity is part of an Erlang function's identity — `f/1` and `f/2` are unrelated top-level definitions — but the extractor merged consecutive same-name `fun_decl`s regardless of arity. Reproduced on main exactly as reported: - adjacent `f(X) -> …. f(X, Y) -> ….` → **one** node spanning both, with the first definition's signature; - interleaved `f/1, g/0, f/2` → two nodes with **identical** `qualified_name`; - `cowboy_req`'s `header(Name, Req) -> header(Name, Req, undefined).` → a **self-loop** `header → header`, with the `-spec` for `/3` swallowed by the merged span; - `-export([f/1])` marked every arity exported. ## Fix - **One node per (name, arity).** Clauses of the same name+arity still merge (that part of the old behavior was correct); a different arity starts a new node. `qualifiedName` carries the canonical spelling — `mod::f/1` — while the node **name stays bare** so search and bare-name matching are unchanged. - **`-export` and `-spec` are per-arity.** `-export([f/1])` exports exactly `f/1`; a spec sitting between two arities attaches to the arity its signature names. - **Refs carry the call-site arity** wherever it's statically known: local `f/1`, remote `mod::f/2`, `fun f/1` / `fun mod:f/1` values, `gen_server` dispatch (`handle_call/3`, `handle_cast/2`), and spawn/apply MFA lists (`spawn_link(?MODULE, work, [A, B])` → `work/2`). - **The matcher resolves only to the named arity** — same file first (a local call targets its own module) — and when no definition of that arity exists it resolves to **nothing** rather than a sibling arity: silent beats wrong. An arity-less dynamic-MFA ref resolves only when the module defines exactly one arity of that name. - **Behaviour dispatch** selects the implementer node of the site's arity, and the arity counter now skips `<<1,2,3>>` binary-literal commas per its own docstring (#1358) — `Mod:decode(<<1,2,3>>, Opts)` counts 2, not 4. - **`codegraph_explore` / `codegraph_node`** accept the written `mod:fn/3` spelling against the new arity-qualified names (the issue's measured `cowboy_stream_h:request_process/3` shape). ## Validation Minimal fixtures (all three reported shapes) now index as `gap::f/1` + `gap::f/2`, distinct `inter::f/1`/`inter::f/2`, and a real `deleg::header/2 → deleg::header/3` edge with no self-loop. Cowboy (fresh `--depth 1` clone, this build vs unmodified main build): | | main | this PR | |---|---|---| | nodes | 3,668 | 3,748 (+80 — the arity splits; no explosion) | | erlang function nodes | 2,850 | 2,930 | | behaviour dispatch edges | 38 | **44** | | `cowboy_req::header` | one node, span 420–425, /3's spec lost | `header/2` (420–421, its own spec) + `header/3` (424–425, its spec) | | delegation | self-loop | `header/2 → header/3` | `calls` edges drop 6,059 → 5,656: a sample of every removed pair shows the false-positive class the issue predicted — out-of-repo/BIF calls (`length/1`, `error/1`, `quicer:*`) that previously name-matched onto unrelated same-named in-repo functions now stay unresolved. Tests: new arity coverage in extraction + a new arity-resolution integration suite + a #1358 binary-literal behaviour test; updated existing Erlang expectations to the arity-carrying spellings. Full suite: **3,018 passed, 0 failed**. No migration: an existing Erlang index picks the new shape up on its next re-index (`codegraph sync` / re-`init`). Erlang is wasm-only (not in the native kernel), so there is no kernel-parity surface. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01LxZj6W6Y1SHXwvpT3uwJpK
232 lines
6.7 KiB
TypeScript
232 lines
6.7 KiB
TypeScript
/**
|
|
* Erlang behaviour-callback dispatch bridge.
|
|
*
|
|
* A behaviour module declares `-callback fn/N`, implementers declare
|
|
* `-behaviour(B)` and export the callbacks, and the framework dispatches
|
|
* through a variable module (`Handler:init(...)`, `Mod:handle_thing(...)`) — a
|
|
* dynamic hop extraction deliberately leaves silent. This bridges each
|
|
* `Var:fn(args)` site to every in-repo implementer of the ONE behaviour that
|
|
* declares (fn, site-arity), and proves the precision gates: a same-named
|
|
* function in a non-implementer module contributes no edge, an arity mismatch
|
|
* contributes no edge, and a (fn, arity) declared by TWO behaviours bails
|
|
* entirely.
|
|
*/
|
|
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-behaviour synthesizer', () => {
|
|
let dir: string;
|
|
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'erlang-behaviour-')); });
|
|
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
|
|
|
|
async function synthEdges(d: string): Promise<any[]> {
|
|
const cg = await CodeGraph.init(d, { silent: true });
|
|
await cg.indexAll();
|
|
const db = (cg as any).db.db;
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT s.name source, s.file_path sf, t.name target, t.file_path tf,
|
|
json_extract(e.metadata,'$.via') via
|
|
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
|
|
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'erlang-behaviour'`
|
|
)
|
|
.all();
|
|
cg.destroy();
|
|
return rows;
|
|
}
|
|
|
|
it('bridges Var:fn(...) dispatch to every implementer, gated on behaviour + export + arity', async () => {
|
|
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src', 'worker_behaviour.erl'),
|
|
`-module(worker_behaviour).
|
|
|
|
-callback handle_thing(Arg :: term()) -> ok | {error, term()}.
|
|
-callback init(list()) -> {ok, term()}.
|
|
|
|
-export([dispatch/2]).
|
|
|
|
dispatch(Mod, Arg) ->
|
|
Mod:handle_thing(Arg).
|
|
`
|
|
);
|
|
// Two real implementers, exporting the callback.
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src', 'worker_a.erl'),
|
|
`-module(worker_a).
|
|
-behaviour(worker_behaviour).
|
|
-export([handle_thing/1, init/1]).
|
|
|
|
handle_thing(X) -> {ok, X}.
|
|
init(_) -> {ok, state}.
|
|
`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src', 'worker_b.erl'),
|
|
`-module(worker_b).
|
|
-behaviour(worker_behaviour).
|
|
-export([handle_thing/1, init/1]).
|
|
|
|
handle_thing(X) -> {done, X}.
|
|
init(_) -> {ok, state}.
|
|
`
|
|
);
|
|
// Defines + exports the same function name but does NOT implement the behaviour.
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src', 'freeloader.erl'),
|
|
`-module(freeloader).
|
|
-export([handle_thing/1]).
|
|
|
|
handle_thing(X) -> X.
|
|
`
|
|
);
|
|
// A second dispatcher in another module, plus an arity-mismatched site and a
|
|
// macro-module site — neither of the latter two may produce edges.
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src', 'runner.erl'),
|
|
`-module(runner).
|
|
-export([run/2, wrong/2, self_call/1]).
|
|
|
|
run(Mod, Arg) ->
|
|
Mod:handle_thing(Arg).
|
|
|
|
wrong(Mod, Arg) ->
|
|
Mod:handle_thing(Arg, extra).
|
|
|
|
self_call(X) ->
|
|
?MODULE:handle_thing(X).
|
|
`
|
|
);
|
|
|
|
const rows = await synthEdges(dir);
|
|
const targets = (src: string) =>
|
|
rows.filter((r) => r.source === src).map((r) => `${path.basename(r.tf)}:${r.target}`).sort();
|
|
|
|
// Both dispatch sites link both implementers — and only them (no freeloader).
|
|
expect(targets('dispatch')).toEqual(['worker_a.erl:handle_thing', 'worker_b.erl:handle_thing']);
|
|
expect(targets('run')).toEqual(['worker_a.erl:handle_thing', 'worker_b.erl:handle_thing']);
|
|
// Arity mismatch (handle_thing/2 undeclared) and ?MODULE sites: nothing.
|
|
expect(targets('wrong')).toEqual([]);
|
|
expect(targets('self_call')).toEqual([]);
|
|
// Provenance metadata names the contract.
|
|
expect(rows.every((r) => r.via === 'worker_behaviour:handle_thing/1')).toBe(true);
|
|
});
|
|
|
|
it('bails when two behaviours declare the same callback name and arity', async () => {
|
|
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
for (const b of ['left_behaviour', 'right_behaviour']) {
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src', `${b}.erl`),
|
|
`-module(${b}).
|
|
|
|
-callback common_cb(term()) -> ok.
|
|
`
|
|
);
|
|
}
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src', 'impl_left.erl'),
|
|
`-module(impl_left).
|
|
-behaviour(left_behaviour).
|
|
-export([common_cb/1]).
|
|
|
|
common_cb(X) -> X.
|
|
`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src', 'caller.erl'),
|
|
`-module(caller).
|
|
-export([go/2]).
|
|
|
|
go(Mod, X) ->
|
|
Mod:common_cb(X).
|
|
`
|
|
);
|
|
|
|
const rows = await synthEdges(dir);
|
|
expect(rows).toEqual([]);
|
|
});
|
|
|
|
it('does not link an implementer whose callback is not exported', async () => {
|
|
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src', 'hook_behaviour.erl'),
|
|
`-module(hook_behaviour).
|
|
|
|
-callback on_event(term()) -> ok.
|
|
|
|
-export([fire/2]).
|
|
|
|
fire(Mod, Ev) ->
|
|
Mod:on_event(Ev).
|
|
`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src', 'private_impl.erl'),
|
|
`-module(private_impl).
|
|
-behaviour(hook_behaviour).
|
|
-export([start/0]).
|
|
|
|
start() -> ok.
|
|
|
|
on_event(_Ev) -> ok.
|
|
`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src', 'public_impl.erl'),
|
|
`-module(public_impl).
|
|
-behaviour(hook_behaviour).
|
|
-export([on_event/1]).
|
|
|
|
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);
|
|
});
|
|
});
|