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:
Colby Mchenry
2026-08-26 10:39:15 -05:00
committed by GitHub
parent c382225461
commit 41c10750e0
11 changed files with 536 additions and 75 deletions
+26 -8
View File
@@ -3008,6 +3008,10 @@ const ERLANG_BEHAVIOUR_FANOUT_CAP = 24;
*/
function erlangArityAt(src: string, openIdx: number): number {
let depth = 1;
// `<<1,2,3>>` binary literals: commas inside are element separators, not
// argument separators. Tracked separately from bracket depth because the
// single-char `<`/`>` comparison operators must stay inert (#1358).
let binDepth = 0;
let commas = 0;
let sawArg = false;
const limit = Math.min(src.length, openIdx + 4000);
@@ -3028,13 +3032,15 @@ function erlangArityAt(src: string, openIdx: number): number {
sawArg = true;
continue;
}
if (ch === '<' && src[i + 1] === '<') { binDepth++; i++; sawArg = true; continue; }
if (ch === '>' && src[i + 1] === '>' && binDepth > 0) { binDepth--; i++; continue; }
if (ch === '(' || ch === '[' || ch === '{') { depth++; sawArg = true; continue; }
if (ch === ')' || ch === ']' || ch === '}') {
depth--;
if (depth === 0) return sawArg ? commas + 1 : 0;
continue;
}
if (ch === ',' && depth === 1) { commas++; continue; }
if (ch === ',' && depth === 1 && binDepth === 0) { commas++; continue; }
if (!/\s/.test(ch)) sawArg = true;
}
return -1;
@@ -3265,12 +3271,18 @@ async function erlangBehaviourDispatchEdges(queries: QueryBuilder, ctx: Resoluti
}
if (declaringBehaviours.size === 0) return [];
// Implementer target lookup, lazy per (behaviour, fn): implementers come
// from the `implements` edges extraction resolved, and the target is the
// implementer module's own exported `fn` function node.
// Implementer target lookup, lazy per (behaviour, fn, arity): implementers
// come from the `implements` edges extraction resolved, and the target is
// the implementer module's own exported `fn` node OF THE SITE'S ARITY —
// function qualifiedNames carry arity (`mod::fn/2`, #1610), so the arity the
// dispatch site used selects among same-named definitions.
const targetCache = new Map<string, Node[]>();
const targetsOf = (behaviour: Node, fn: string): Node[] => {
const cacheKey = `${behaviour.id}#${fn}`;
const qnArity = (qn: string): number => {
const m = /\/(\d{1,3})$/.exec(qn);
return m ? Number(m[1]) : -1;
};
const targetsOf = (behaviour: Node, fn: string, arity: number): Node[] => {
const cacheKey = `${behaviour.id}#${fn}/${arity}`;
let targets = targetCache.get(cacheKey);
if (targets) return targets;
targets = [];
@@ -3279,7 +3291,13 @@ async function erlangBehaviourDispatchEdges(queries: QueryBuilder, ctx: Resoluti
if (!impl || impl.language !== 'erlang' || impl.kind !== 'namespace') continue;
const fnNode = ctx
.getNodesInFile(impl.filePath)
.find((n) => n.kind === 'function' && n.name === fn && n.isExported !== false);
.find(
(n) =>
n.kind === 'function' &&
n.name === fn &&
qnArity(n.qualifiedName) === arity &&
n.isExported !== false,
);
if (fnNode) targets.push(fnNode);
}
targetCache.set(cacheKey, targets);
@@ -3308,7 +3326,7 @@ async function erlangBehaviourDispatchEdges(queries: QueryBuilder, ctx: Resoluti
const behaviours = declaringBehaviours.get(`${fn}/${arity}`);
if (!behaviours || behaviours.length !== 1) continue; // unknown or ambiguous
const behaviour = behaviours[0]!;
const targets = targetsOf(behaviour, fn);
const targets = targetsOf(behaviour, fn, arity);
if (targets.length === 0 || targets.length > ERLANG_BEHAVIOUR_FANOUT_CAP) continue;
const line = safe.slice(0, m.index).split('\n').length;
const disp = enclosingFn(nodesInFile, line);
+4 -1
View File
@@ -886,10 +886,13 @@ export class ReferenceResolver {
// indexed under the bare name, so the existence check strips the dot.
// Nix static path imports (`import ./x.nix`) name a FILE, not a symbol —
// they bypass the symbol-existence check and resolve via resolveViaImport.
const existenceName =
let existenceName =
ref.language === 'arkts' && ref.referenceName.startsWith('.')
? ref.referenceName.slice(1)
: ref.referenceName;
// Erlang refs carry the call-site arity (`f/1`, `mod::f/2` — #1610); the
// name index stores bare names, so existence is checked arity-less.
if (ref.language === 'erlang') existenceName = existenceName.replace(/\/\d{1,3}$/, '');
const tPre = this.profileStages ? process.hrtime.bigint() : 0n;
const preFilterPass =
isNixPathImportRef(ref) ||
+75
View File
@@ -503,6 +503,35 @@ export function matchByQualifiedName(
}
}
// Erlang qualified refs (#1610): every erlang function's qualifiedName
// carries its arity (`mod::f/2`), and refs carry the call-site arity when it
// is statically known.
if (ref.language === 'erlang' && ref.referenceName.includes('::')) {
// A ref WITH arity that missed the exact lookup names an arity that isn't
// defined (or a module out of repo). Never fall through to the partial
// match — its "last segment" would be the arity digits — and never settle
// for a sibling arity: silent beats wrong.
if (/\/\d{1,3}$/.test(ref.referenceName)) return null;
// An arity-LESS qualified ref (dynamic MFA whose args list wasn't a
// static literal): resolve only when the module defines exactly ONE arity
// of that function; several arities with no signal is a guess.
const base = ref.referenceName.slice(ref.referenceName.lastIndexOf('::') + 2);
const prefix = `${ref.referenceName}/`;
const arityCands = keepForRef(context.getNodesByName(base)).filter(
(n) =>
n.qualifiedName.startsWith(prefix) && /^\d{1,3}$/.test(n.qualifiedName.slice(prefix.length)),
);
if (arityCands.length === 1) {
return {
original: ref,
targetNodeId: arityCands[0]!.id,
confidence: 0.85,
resolvedBy: 'qualified-name',
};
}
return null;
}
// Try partial qualified name match — again preferring the call site's own
// file when more than one symbol's qualifiedName ends with the reference.
const parts = ref.referenceName.split(/[:.]/);
@@ -2519,6 +2548,52 @@ 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.
if (
ref.language === 'erlang' &&
!ref.referenceName.includes('::') &&
(ref.referenceKind === 'calls' || ref.referenceKind === 'references')
) {
const am = /^(.+)\/(\d{1,3})$/.exec(ref.referenceName);
if (am) {
// endsWith is length-anchored, so `/1` cannot match `…/11`.
const arityTail = `/${am[2]}`;
const candidates = context
.getNodesByName(am[1]!)
.filter(
(n) =>
n.language === 'erlang' && n.kind === 'function' && 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',
};
}
}
return null;
}
}
// Try strategies in order of confidence
let result: ResolvedRef | null;