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
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+125
-30
@@ -10366,7 +10366,81 @@ helper() -> ok.
|
||||
const ns = result.nodes.find((n) => n.kind === 'namespace');
|
||||
expect(ns?.name).toBe('my_server');
|
||||
const start = result.nodes.find((n) => n.kind === 'function' && n.name === 'start');
|
||||
expect(start?.qualifiedName).toBe('my_server::start');
|
||||
// Arity is part of an Erlang function's identity — qualifiedName carries it (#1610).
|
||||
expect(start?.qualifiedName).toBe('my_server::start/0');
|
||||
});
|
||||
|
||||
it('should give same-name different-arity functions separate arity-qualified nodes (#1610)', () => {
|
||||
const code = `-module(gap).
|
||||
-export([f/1, f/2]).
|
||||
|
||||
f(X) -> X + 1.
|
||||
f(X, Y) -> X + Y.
|
||||
`;
|
||||
const result = extractFromSource('src/gap.erl', code);
|
||||
const fns = result.nodes.filter((n) => n.kind === 'function' && n.name === 'f');
|
||||
expect(fns).toHaveLength(2);
|
||||
expect(fns.map((n) => n.qualifiedName).sort()).toEqual(['gap::f/1', 'gap::f/2']);
|
||||
const f1 = fns.find((n) => n.qualifiedName === 'gap::f/1')!;
|
||||
const f2 = fns.find((n) => n.qualifiedName === 'gap::f/2')!;
|
||||
expect([f1.startLine, f1.endLine]).toEqual([4, 4]);
|
||||
expect([f2.startLine, f2.endLine]).toEqual([5, 5]);
|
||||
expect(f1.signature).toBe('f(X)');
|
||||
expect(f2.signature).toBe('f(X, Y)');
|
||||
});
|
||||
|
||||
it('should split interleaved same-name defs by arity with distinct qualified names', () => {
|
||||
const code = `-module(inter).
|
||||
|
||||
f(X) -> X + 1;
|
||||
f(Y) -> Y.
|
||||
g() -> ok.
|
||||
f(X, Y) -> X + Y.
|
||||
`;
|
||||
const result = extractFromSource('src/inter.erl', code);
|
||||
const fs = result.nodes.filter((n) => n.kind === 'function' && n.name === 'f');
|
||||
expect(fs).toHaveLength(2);
|
||||
expect(fs.map((n) => n.qualifiedName).sort()).toEqual(['inter::f/1', 'inter::f/2']);
|
||||
// Clauses of the same arity still merge into one span.
|
||||
const f1 = fs.find((n) => n.qualifiedName === 'inter::f/1')!;
|
||||
expect([f1.startLine, f1.endLine]).toEqual([3, 4]);
|
||||
});
|
||||
|
||||
it('should flag exported per arity (#1610)', () => {
|
||||
const code = `-module(m).
|
||||
-export([f/1]).
|
||||
|
||||
f(X) -> X.
|
||||
f(X, Y) -> {X, Y}.
|
||||
`;
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
expect(result.nodes.find((n) => n.qualifiedName === 'm::f/1')?.isExported).toBe(true);
|
||||
expect(result.nodes.find((n) => n.qualifiedName === 'm::f/2')?.isExported).toBe(false);
|
||||
});
|
||||
|
||||
it('should attach a -spec sitting between two arities to the arity it names (#1610)', () => {
|
||||
const code = `-module(deleg).
|
||||
-export([header/2, header/3]).
|
||||
|
||||
header(Name, Req) ->
|
||||
header(Name, Req, undefined).
|
||||
|
||||
-spec header(binary(), map(), any()) -> any().
|
||||
header(Name, Headers, Default) ->
|
||||
maps:get(Name, Headers, Default).
|
||||
`;
|
||||
const result = extractFromSource('src/deleg.erl', code);
|
||||
const h2 = result.nodes.find((n) => n.qualifiedName === 'deleg::header/2')!;
|
||||
const h3 = result.nodes.find((n) => n.qualifiedName === 'deleg::header/3')!;
|
||||
expect(h2.signature).toBe('header(Name, Req)');
|
||||
expect(h3.signature).toBe('-spec header(binary(), map(), any()) -> any().');
|
||||
expect([h2.startLine, h2.endLine]).toEqual([4, 5]);
|
||||
expect(h3.startLine).toBe(8);
|
||||
// The delegation call carries the callee's arity — no more self-loop.
|
||||
const calls = result.unresolvedReferences
|
||||
.filter((r) => r.referenceKind === 'calls')
|
||||
.map((r) => r.referenceName);
|
||||
expect(calls).toContain('header/3');
|
||||
});
|
||||
|
||||
it('should flag exported functions and honor -compile(export_all)', () => {
|
||||
@@ -10501,11 +10575,31 @@ prepare(X) -> X.
|
||||
`;
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
|
||||
expect(calls).toContain('prepare');
|
||||
// `mod:fn(...)` is emitted as `mod::fn` — the same shape the module
|
||||
// namespace gives every function's qualifiedName, so it resolves via
|
||||
// the qualified-name matcher.
|
||||
expect(calls).toContain('other_mod::process');
|
||||
expect(calls).toContain('prepare/1');
|
||||
// `mod:fn(...)` is emitted as `mod::fn/arity` — the same shape the
|
||||
// module namespace + arity suffix gives every function's qualifiedName,
|
||||
// so it resolves via the qualified-name matcher (#1610).
|
||||
expect(calls).toContain('other_mod::process/1');
|
||||
});
|
||||
|
||||
it('should carry written arity on fun references and static MFA lists (#1610)', () => {
|
||||
const code = `-module(m).
|
||||
-export([go/0]).
|
||||
|
||||
go() ->
|
||||
lists:map(fun bump/1, [1]),
|
||||
Prod = fun other_mod:produce/2,
|
||||
proc_lib:spawn_link(?MODULE, work, [a, b]),
|
||||
Prod.
|
||||
|
||||
bump(X) -> X + 1.
|
||||
work(_A, _B) -> ok.
|
||||
`;
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const refs = result.unresolvedReferences;
|
||||
expect(refs.some((r) => r.referenceKind === 'references' && r.referenceName === 'bump/1')).toBe(true);
|
||||
expect(refs.some((r) => r.referenceKind === 'references' && r.referenceName === 'other_mod::produce/2')).toBe(true);
|
||||
expect(refs.some((r) => r.referenceKind === 'calls' && r.referenceName === 'work/2')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not emit calls for dynamic dispatch (var module / var fun)', () => {
|
||||
@@ -10518,9 +10612,9 @@ run(Mod, F) ->
|
||||
`;
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
|
||||
expect(calls).not.toContain('handle');
|
||||
expect(calls).not.toContain('Mod::handle');
|
||||
expect(calls).not.toContain('F');
|
||||
expect(calls.some((c) => c.startsWith('handle'))).toBe(false);
|
||||
expect(calls.some((c) => c.startsWith('Mod::'))).toBe(false);
|
||||
expect(calls.some((c) => c === 'F' || c.startsWith('F/'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should connect gen_server self-calls to the module handlers', () => {
|
||||
@@ -10548,9 +10642,10 @@ handle_cast({put, K, V}, S) -> {noreply, maps:put(K, V, S)}.
|
||||
const result = extractFromSource('src/kv_store.erl', code);
|
||||
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
|
||||
// ?SERVER (defined as ?MODULE), ?MODULE, and the module's own atom all
|
||||
// count as self — public API wrappers connect to their handlers.
|
||||
expect(calls.filter((c) => c === 'kv_store::handle_call')).toHaveLength(2);
|
||||
expect(calls).toContain('kv_store::handle_cast');
|
||||
// count as self — public API wrappers connect to their handlers, at
|
||||
// OTP's fixed handler arities (#1610).
|
||||
expect(calls.filter((c) => c === 'kv_store::handle_call/3')).toHaveLength(2);
|
||||
expect(calls).toContain('kv_store::handle_cast/2');
|
||||
});
|
||||
|
||||
it('should connect gen_server calls to a registered-name module, directly or via an atom macro', () => {
|
||||
@@ -10570,8 +10665,8 @@ evict(Key) ->
|
||||
// OTP's {local, ?MODULE} convention names a server after its module —
|
||||
// a cross-module registered name targets that module's handlers. A name
|
||||
// matching no module simply never resolves downstream.
|
||||
expect(calls).toContain('kv_store::handle_call');
|
||||
expect(calls).toContain('kv_store::handle_cast');
|
||||
expect(calls).toContain('kv_store::handle_call/3');
|
||||
expect(calls).toContain('kv_store::handle_cast/2');
|
||||
});
|
||||
|
||||
it('should not connect gen_server calls with dynamic targets', () => {
|
||||
@@ -10604,10 +10699,10 @@ monitor_loop(_P) -> ok.
|
||||
`;
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
|
||||
expect(calls).toContain('request_process'); // ?MODULE → bare, same-file resolution
|
||||
expect(calls).toContain('monitor_loop');
|
||||
expect(calls).toContain('other_mod::handle');
|
||||
expect(calls).toContain('other_mod::tick');
|
||||
expect(calls).toContain('request_process/2'); // ?MODULE → bare-with-arity, same-file resolution
|
||||
expect(calls).toContain('monitor_loop/1');
|
||||
expect(calls).toContain('other_mod::handle/1');
|
||||
expect(calls).toContain('other_mod::tick/0');
|
||||
});
|
||||
|
||||
it('should stay silent on dynamic spawn/apply (var module, fun value, or plain fun)', () => {
|
||||
@@ -10624,8 +10719,8 @@ helper() -> ok.
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
|
||||
// The fun body's call is still walked; no phantom MFA targets appear.
|
||||
expect(calls).toContain('helper');
|
||||
expect(calls.filter((c) => c !== 'spawn' && c !== 'apply' && c !== 'helper')).toHaveLength(0);
|
||||
expect(calls).toContain('helper/0');
|
||||
expect(calls.filter((c) => !['spawn/3', 'spawn/1', 'apply/3', 'helper/0'].includes(c))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should treat ?MODULE:fn calls as local calls', () => {
|
||||
@@ -10639,7 +10734,7 @@ work() -> ok.
|
||||
`;
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
|
||||
expect(calls).toContain('work');
|
||||
expect(calls).toContain('work/0');
|
||||
});
|
||||
|
||||
it('should capture fun name/arity values as function references', () => {
|
||||
@@ -10654,8 +10749,8 @@ notify(_P) -> ok.
|
||||
`;
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'references').map((r) => r.referenceName);
|
||||
expect(refs).toContain('notify');
|
||||
expect(refs).toContain('m::notify');
|
||||
expect(refs).toContain('notify/1');
|
||||
expect(refs).toContain('m::notify/1');
|
||||
});
|
||||
|
||||
it('should reference records used in bodies and argument patterns', () => {
|
||||
@@ -10691,8 +10786,8 @@ second(X) -> X.
|
||||
const calls = result.unresolvedReferences.filter(
|
||||
(r) => r.referenceKind === 'calls' && r.fromNodeId === handle?.id
|
||||
).map((r) => r.referenceName);
|
||||
expect(calls).toContain('first');
|
||||
expect(calls).toContain('second');
|
||||
expect(calls).toContain('first/1');
|
||||
expect(calls).toContain('second/1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10714,8 +10809,8 @@ analyze(Path) ->
|
||||
expect(fns).toContain('main');
|
||||
expect(fns).toContain('analyze');
|
||||
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
|
||||
expect(calls).toContain('analyze');
|
||||
expect(calls).toContain('io::format');
|
||||
expect(calls).toContain('analyze/1');
|
||||
expect(calls).toContain('io::format/2');
|
||||
});
|
||||
|
||||
it('should link an app resource file to its callback module and dependency apps', () => {
|
||||
@@ -10761,7 +10856,7 @@ do_thing(X) ->
|
||||
const refsFrom = (id?: string) =>
|
||||
result.unresolvedReferences.filter((r) => r.fromNodeId === id).map((r) => `${r.referenceKind}:${r.referenceName}`);
|
||||
// The body's remote call belongs to the macro node — true exactly once.
|
||||
expect(refsFrom(macro?.id)).toContain('calls:audit_logger::log');
|
||||
expect(refsFrom(macro?.id)).toContain('calls:audit_logger::log/2');
|
||||
// The use site joins the call chain: do_thing -calls→ LOG_AUDIT.
|
||||
expect(refsFrom(doThing?.id)).toContain('calls:LOG_AUDIT');
|
||||
});
|
||||
@@ -10794,7 +10889,7 @@ prepare() -> ok.
|
||||
const result = extractFromSource('src/m.erl', code);
|
||||
const refs = result.unresolvedReferences.map((r) => r.referenceName);
|
||||
// The nested call inside the macro's arguments still attributes to check/0.
|
||||
expect(refs).toContain('prepare');
|
||||
expect(refs).toContain('prepare/0');
|
||||
// ?assertEqual (an OTP header macro) is emitted and simply never resolves…
|
||||
expect(refs).toContain('assertEqual');
|
||||
// …but predefined macros have no definition to link.
|
||||
@@ -10814,7 +10909,7 @@ prepare() -> ok.
|
||||
const alias = result.nodes.find((n) => n.kind === 'constant' && n.name === 'ALIAS');
|
||||
const refsFrom = (id?: string) =>
|
||||
result.unresolvedReferences.filter((r) => r.fromNodeId === id).map((r) => `${r.referenceKind}:${r.referenceName}`);
|
||||
expect(refsFrom(target?.id)).toContain('calls:target_fn');
|
||||
expect(refsFrom(target?.id)).toContain('calls:target_fn/0');
|
||||
expect(refsFrom(alias?.id)).toContain('references:TARGET');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user