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:
+5
-2
@@ -145,8 +145,11 @@ interface UnresolvedRefRow {
|
||||
* refs against newly-added node names.
|
||||
*/
|
||||
function referenceNameTail(referenceName: string): string {
|
||||
const idx = Math.max(referenceName.lastIndexOf('.'), referenceName.lastIndexOf(':'));
|
||||
return idx >= 0 ? referenceName.slice(idx + 1) : referenceName;
|
||||
// Erlang refs carry a written arity (`f/1`, `mod::fn/2` — #1610); the tail a
|
||||
// new symbol's plain name could match is the arity-less function name.
|
||||
const base = referenceName.replace(/\/\d{1,3}$/, '') || referenceName;
|
||||
const idx = Math.max(base.lastIndexOf('.'), base.lastIndexOf(':'));
|
||||
return idx >= 0 ? base.slice(idx + 1) : base;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,8 +9,11 @@ import type { LanguageExtractor, ExtractorContext } from '../tree-sitter-types';
|
||||
// extractor, so every symbol-bearing top-level form is dispatched through the
|
||||
// visitNode hook below instead:
|
||||
// - a function's name lives on its CLAUSE, not the fun_decl, and the grammar
|
||||
// emits one fun_decl PER CLAUSE — consecutive same-name fun_decl forms are
|
||||
// merged into a single function node here;
|
||||
// emits one fun_decl PER CLAUSE — consecutive same-name same-ARITY
|
||||
// fun_decl forms (clauses of one function) are merged into a single
|
||||
// function node here. Arity is part of an Erlang function's identity
|
||||
// (`f/1` and `f/2` are unrelated definitions — #1610), so each arity gets
|
||||
// its own node, qualified `mod::f/1` / `mod::f/2`;
|
||||
// - type-position expressions (-spec/-type/-callback bodies, record field
|
||||
// types) parse as `call` nodes, so descending into them would mint bogus
|
||||
// call refs to type names (`pid()`, `term()`); the hook consumes those
|
||||
@@ -19,9 +22,10 @@ import type { LanguageExtractor, ExtractorContext } from '../tree-sitter-types';
|
||||
// the generic extractStruct would skip as a forward declaration.
|
||||
// Calls (local `f(X)`, remote `mod:f(X)`, `fun f/1` references, and record
|
||||
// usages) are handled by the erlang branch in extractCall — remote calls are
|
||||
// emitted as `mod::f`, which matches the qualifiedName the module namespace
|
||||
// produces (see packageTypes below), so cross-module resolution rides the
|
||||
// standard qualified-name matcher.
|
||||
// emitted as `mod::f/2` (arity counted at the call site), byte-identical to
|
||||
// the qualifiedName above, so cross-module resolution rides the standard
|
||||
// qualified-name matcher; local calls are emitted `f/2` and resolved by the
|
||||
// erlang arity step in matchReference.
|
||||
|
||||
/** Text of an atom with quoted-atom quotes stripped (`'EXIT'` → `EXIT`). */
|
||||
function atomText(node: SyntaxNode, source: string): string {
|
||||
@@ -35,19 +39,27 @@ function collapseWs(text: string): string {
|
||||
// --- Per-file memos. Extraction is file-sequential within a worker, so a
|
||||
// single-entry memo keyed by filePath is safe (and resets naturally). ---
|
||||
|
||||
/** Exported function names for the current file ('all' for -compile(export_all)). */
|
||||
/**
|
||||
* Exported `name/arity` keys for the current file ('all' for
|
||||
* -compile(export_all)). Keyed by arity because `-export([f/1])` exports
|
||||
* exactly f/1 — f/2 in the same module stays private (#1610). A malformed
|
||||
* `fa` with no arity node falls back to the bare name key.
|
||||
*/
|
||||
let exportsFile = '';
|
||||
let exportsMemo: Set<string> | 'all' = new Set();
|
||||
|
||||
/**
|
||||
* Clause-merge state: the previous fun_decl's name and node id. A fun_decl
|
||||
* whose clause repeats that name is a continuation clause (or a same-name
|
||||
* different-arity definition — deliberately grouped under one node, the way
|
||||
* overloads are elsewhere) and attaches to the existing node instead of
|
||||
* creating a duplicate.
|
||||
* Clause-merge state: the previous fun_decl's name, arity, and node id. A
|
||||
* fun_decl whose clause repeats that (name, arity) is a continuation clause of
|
||||
* the SAME function and attaches to the existing node instead of creating a
|
||||
* duplicate. A same-name DIFFERENT-arity fun_decl is an unrelated function
|
||||
* (Erlang identity is `name/arity`) and gets its own node (#1610). Keying on
|
||||
* adjacency stays safe: clauses of one function must be adjacent in Erlang —
|
||||
* a non-adjacent redefinition of the same name/arity is a compile error.
|
||||
*/
|
||||
let lastFnFile = '';
|
||||
let lastFnName = '';
|
||||
let lastFnArity = -1;
|
||||
let lastFnId = '';
|
||||
|
||||
function moduleExports(node: SyntaxNode, source: string, filePath: string): Set<string> | 'all' {
|
||||
@@ -69,7 +81,12 @@ function moduleExports(node: SyntaxNode, source: string, filePath: string): Set<
|
||||
for (const fa of form.namedChildren) {
|
||||
if (fa.type !== 'fa') continue;
|
||||
const fun = getChildByField(fa, 'fun');
|
||||
if (fun) result.add(atomText(fun, source));
|
||||
if (!fun) continue;
|
||||
const name = atomText(fun, source);
|
||||
const arityNode = getChildByField(fa, 'arity');
|
||||
const arityValue = arityNode ? getChildByField(arityNode, 'value') : null;
|
||||
const arity = arityValue ? getNodeText(arityValue, source) : null;
|
||||
result.add(arity !== null ? `${name}/${arity}` : name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,13 +95,27 @@ function moduleExports(node: SyntaxNode, source: string, filePath: string): Set<
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The -spec directly above a function (comments may sit between), if it names it. */
|
||||
function precedingSpec(node: SyntaxNode, name: string, source: string): SyntaxNode | null {
|
||||
/** Argument count of a clause/sig: the `args` (expr_args) field's named-child count. */
|
||||
function nodeArity(withArgs: SyntaxNode): number {
|
||||
const args = getChildByField(withArgs, 'args');
|
||||
return args ? args.namedChildCount : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The -spec directly above a function (comments may sit between), if it names
|
||||
* it AND matches its arity — the spec for `header/3` sitting between the
|
||||
* `header/2` and `header/3` definitions must attach to /3 only (#1610). A
|
||||
* spec whose sigs can't be read (defensive) is accepted on the name alone.
|
||||
*/
|
||||
function precedingSpec(node: SyntaxNode, name: string, arity: number, source: string): SyntaxNode | null {
|
||||
let prev = node.previousNamedSibling;
|
||||
while (prev && prev.type === 'comment') prev = prev.previousNamedSibling;
|
||||
if (prev?.type === 'spec') {
|
||||
const specFun = getChildByField(prev, 'fun');
|
||||
if (specFun && atomText(specFun, source) === name) return prev;
|
||||
if (specFun && atomText(specFun, source) === name) {
|
||||
const sigs = prev.namedChildren.filter((c) => c.type === 'type_sig');
|
||||
if (sigs.length === 0 || sigs.some((sig) => nodeArity(sig) === arity)) return prev;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -104,10 +135,11 @@ function handleFunDecl(node: SyntaxNode, ctx: ExtractorContext): boolean {
|
||||
if (!nameNode) return true;
|
||||
const name = atomText(nameNode, ctx.source);
|
||||
if (!name) return true;
|
||||
const arity = nodeArity(first);
|
||||
|
||||
// Continuation clause: extend the existing node's span and attribute this
|
||||
// clause's calls to it.
|
||||
if (ctx.filePath === lastFnFile && name === lastFnName && lastFnId) {
|
||||
// Continuation clause of the SAME function (same name AND arity): extend the
|
||||
// existing node's span and attribute this clause's calls to it.
|
||||
if (ctx.filePath === lastFnFile && name === lastFnName && arity === lastFnArity && lastFnId) {
|
||||
for (let i = ctx.nodes.length - 1; i >= 0; i--) {
|
||||
const n = ctx.nodes[i];
|
||||
if (n && n.id === lastFnId) {
|
||||
@@ -121,16 +153,20 @@ function handleFunDecl(node: SyntaxNode, ctx: ExtractorContext): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
const spec = precedingSpec(node, name, ctx.source);
|
||||
const spec = precedingSpec(node, name, arity, ctx.source);
|
||||
const exports = moduleExports(node, ctx.source, ctx.filePath);
|
||||
const fn = ctx.createNode('function', name, node, {
|
||||
docstring: getPrecedingDocstring(spec ?? node, ctx.source),
|
||||
signature: spec
|
||||
? collapseWs(getNodeText(spec, ctx.source)).slice(0, 300)
|
||||
: clauseHeader(first, ctx.source),
|
||||
isExported: exports === 'all' || exports.has(name),
|
||||
isExported: exports === 'all' || exports.has(`${name}/${arity}`) || exports.has(name),
|
||||
});
|
||||
if (!fn) return true;
|
||||
// Arity is part of the function's identity — carry it on the qualified name
|
||||
// (`mod::f/2`), the canonical Erlang spelling and the only persisted slot.
|
||||
// The node NAME stays bare so name search and bare-name matching still work.
|
||||
fn.qualifiedName = `${fn.qualifiedName}/${arity}`;
|
||||
ctx.pushScope(fn.id);
|
||||
// The whole clause is walked (not just the body) so record patterns in the
|
||||
// arguments and guard calls contribute references too.
|
||||
@@ -138,6 +174,7 @@ function handleFunDecl(node: SyntaxNode, ctx: ExtractorContext): boolean {
|
||||
ctx.popScope();
|
||||
lastFnFile = ctx.filePath;
|
||||
lastFnName = name;
|
||||
lastFnArity = arity;
|
||||
lastFnId = fn.id;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3760,15 +3760,18 @@ export class TreeSitterExtractor {
|
||||
|
||||
// Erlang: a local call is `call(expr: atom, args)`; a remote call nests it
|
||||
// under `remote(module: remote_module, fun: call)` — the module qualifier
|
||||
// lives on the PARENT. Remote calls are emitted as `mod::fn`, which is
|
||||
// byte-identical to the qualifiedName the module namespace gives every
|
||||
// function (see packageTypes in languages/erlang.ts), so they resolve via
|
||||
// matchByQualifiedName. A var/macro callee or module (`F(X)`, `?M(X)`,
|
||||
// `Mod:handle(X)`) has no static target — except `?MODULE:fn(X)`, which the
|
||||
// bare name + same-file preference resolves correctly. `fun name/1` /
|
||||
// `fun mod:name/1` values are function REFERENCES (callback registration),
|
||||
// and record construction/update/index/field-access are `references` to the
|
||||
// record's struct node.
|
||||
// lives on the PARENT. Arity is part of a function's identity (#1610), so
|
||||
// refs carry the call-site arity: remote calls are emitted as `mod::fn/2`,
|
||||
// byte-identical to the qualifiedName the module namespace + arity suffix
|
||||
// gives every function (see languages/erlang.ts), so they resolve via
|
||||
// matchByQualifiedName; local calls are emitted `fn/2` and resolved by the
|
||||
// erlang arity step in matchReference (same-file first). A var/macro callee
|
||||
// or module (`F(X)`, `?M(X)`, `Mod:handle(X)`) has no static target —
|
||||
// except `?MODULE:fn(X)`, which the bare-name-with-arity + same-file
|
||||
// preference resolves correctly. `fun name/1` / `fun mod:name/1` values
|
||||
// are function REFERENCES (callback registration) carrying their own
|
||||
// written arity, and record construction/update/index/field-access are
|
||||
// `references` to the record's struct node.
|
||||
if (this.language === 'erlang') {
|
||||
const line = node.startPosition.row + 1;
|
||||
const column = node.startPosition.column;
|
||||
@@ -3800,9 +3803,13 @@ export class TreeSitterExtractor {
|
||||
moduleExpr.type === 'macro_call_expr' ? getChildByField(moduleExpr, 'name') : null;
|
||||
if (!macroName || getNodeText(macroName, this.source) !== 'MODULE') return;
|
||||
}
|
||||
// Arity from the call site's own argument list — part of the callee's
|
||||
// identity, and what disambiguates `f/1` from `f/2` (#1610).
|
||||
const callArgsNode = getChildByField(node, 'args');
|
||||
const callArity = callArgsNode ? callArgsNode.namedChildCount : 0;
|
||||
this.unresolvedReferences.push({
|
||||
fromNodeId: callerId,
|
||||
referenceName: calleeName,
|
||||
referenceName: `${calleeName}/${callArity}`,
|
||||
referenceKind: 'calls',
|
||||
line,
|
||||
column,
|
||||
@@ -3825,9 +3832,10 @@ export class TreeSitterExtractor {
|
||||
const target = argsNode?.namedChild(0) ?? null;
|
||||
const targetModule = target ? this.resolveErlangGenServerTarget(target) : null;
|
||||
if (targetModule) {
|
||||
// OTP fixes the handler arities: handle_call/3, handle_cast/2.
|
||||
this.unresolvedReferences.push({
|
||||
fromNodeId: callerId,
|
||||
referenceName: `${targetModule}::${fnBare === 'cast' ? 'handle_cast' : 'handle_call'}`,
|
||||
referenceName: `${targetModule}::${fnBare === 'cast' ? 'handle_cast/2' : 'handle_call/3'}`,
|
||||
referenceKind: 'calls',
|
||||
line,
|
||||
column,
|
||||
@@ -3858,9 +3866,17 @@ export class TreeSitterExtractor {
|
||||
getChildByField(m, 'name') !== null &&
|
||||
getNodeText(getChildByField(m, 'name')!, this.source) === 'MODULE';
|
||||
if (m.type !== 'atom' && !isLocalModule) continue;
|
||||
// Arity of the spawned/applied function = the length of the
|
||||
// static args-list literal directly after the (M, F) pair, when
|
||||
// present (`spawn_link(?MODULE, request_process, [Req, Env])` →
|
||||
// /2). A var/absent list leaves the ref arity-less; the
|
||||
// qualified matcher then resolves it only when the module
|
||||
// defines exactly one arity of that name.
|
||||
const mfaList = argExprs[i + 2];
|
||||
const arityTail = mfaList?.type === 'list' ? `/${mfaList.namedChildCount}` : '';
|
||||
this.unresolvedReferences.push({
|
||||
fromNodeId: callerId,
|
||||
referenceName: isLocalModule ? erlAtom(f) : `${erlAtom(m)}::${erlAtom(f)}`,
|
||||
referenceName: (isLocalModule ? erlAtom(f) : `${erlAtom(m)}::${erlAtom(f)}`) + arityTail,
|
||||
referenceKind: 'calls',
|
||||
line: f.startPosition.row + 1,
|
||||
column: f.startPosition.column,
|
||||
@@ -3881,6 +3897,11 @@ export class TreeSitterExtractor {
|
||||
if (moduleAtom?.type !== 'atom') return;
|
||||
refName = `${erlAtom(moduleAtom)}::${refName}`;
|
||||
}
|
||||
// `fun f/1` writes its arity — carry it so the ref lands on the
|
||||
// matching arity's node (#1610).
|
||||
const funArityNode = getChildByField(node, 'arity');
|
||||
const funArityValue = funArityNode ? getChildByField(funArityNode, 'value') : null;
|
||||
if (funArityValue) refName = `${refName}/${getNodeText(funArityValue, this.source)}`;
|
||||
this.unresolvedReferences.push({
|
||||
fromNodeId: callerId,
|
||||
referenceName: refName,
|
||||
|
||||
+20
-2
@@ -122,9 +122,14 @@ const CONTAINER_NODE_KINDS = new Set<NodeKind>([
|
||||
'class', 'struct', 'union', 'interface', 'trait', 'protocol', 'enum', 'namespace', 'module',
|
||||
]);
|
||||
|
||||
/** Last `::` / `.` / `/`-separated segment of a qualified symbol. */
|
||||
/**
|
||||
* Last `::` / `.` / `/`-separated segment of a qualified symbol. An Erlang
|
||||
* arity tail (`mod::fn/3`, `fn/3`) is stripped first — the useful last segment
|
||||
* is the function name, never the digits (#1610).
|
||||
*/
|
||||
function lastQualifierPart(symbol: string): string {
|
||||
const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
|
||||
const noArity = symbol.replace(/\/\d{1,3}$/, '') || symbol;
|
||||
const parts = noArity.split(/::|[./]/).filter((p) => p.length > 0);
|
||||
return parts[parts.length - 1] ?? symbol;
|
||||
}
|
||||
|
||||
@@ -6723,6 +6728,19 @@ export class ToolHandler {
|
||||
* Python — `stage_apply::run` matches a `run` in `stage_apply.rs`)
|
||||
*/
|
||||
private matchesSymbol(node: Node, symbol: string): boolean {
|
||||
// Erlang arity spelling (`fn/3`, `mod:fn/3` → normalized `mod.fn/3`): when
|
||||
// the node's qualifiedName carries an arity (`mod::fn/3`, #1610), the
|
||||
// written arity must match it exactly; the remaining comparison then runs
|
||||
// on the arity-less spelling. A node with no arity in its qualifiedName
|
||||
// keeps the original symbol (a `/` there means a path-ish name instead).
|
||||
const aritySpelling = /^(.+)\/(\d{1,3})$/.exec(symbol);
|
||||
if (aritySpelling) {
|
||||
const nodeArity = /\/(\d{1,3})$/.exec(node.qualifiedName ?? '')?.[1];
|
||||
if (nodeArity !== undefined) {
|
||||
if (nodeArity !== aritySpelling[2]) return false;
|
||||
symbol = aritySpelling[1]!;
|
||||
}
|
||||
}
|
||||
// Simple name match
|
||||
if (node.name === symbol) return true;
|
||||
// File basename match (e.g., "product-card" matches "product-card.liquid")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user