Files
codegraph/__tests__/erlang-arity-resolution.test.ts
T
Colby MchenryandGitHub 41c10750e0 fix(erlang): give same-name different-arity functions separate arity-qualified nodes (#1610) (#1615)
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
2026-08-26 10:39:15 -05:00

148 lines
4.6 KiB
TypeScript

/**
* 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);
});
});