From 9bf68eb84084b3ec0b5703ae37977866bf0b6259 Mon Sep 17 00:00:00 2001 From: Dshuishui Date: Wed, 26 Aug 2026 18:50:15 +0800 Subject: [PATCH] fix(erlang): don't count comments as arguments when computing arity tree-sitter reports `comment` as a named child, so a comment written at the top level of a parameter or argument list inflated the arity taken from `namedChildCount`: `plain( % note\n 1, 2)` read as plain/3 and the edge was dropped, and `f(X, % note\n Y) ->` was indexed as f/3. The MFA list in `spawn(?MODULE, f, [A, % note\n B])` had the same problem. Arity only became part of a function's identity in #1610, so the miscount was harmless before that. The three counts now share one `namedArgCount` helper so the invariant is stated once. --- __tests__/erlang-arity-resolution.test.ts | 33 +++++++++++++++++++++++ src/extraction/languages/erlang.ts | 12 ++++++--- src/extraction/tree-sitter-helpers.ts | 13 +++++++++ src/extraction/tree-sitter.ts | 13 ++++++--- 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/__tests__/erlang-arity-resolution.test.ts b/__tests__/erlang-arity-resolution.test.ts index 9351f81b3..8cad2f8f5 100644 --- a/__tests__/erlang-arity-resolution.test.ts +++ b/__tests__/erlang-arity-resolution.test.ts @@ -120,6 +120,39 @@ go(Args) -> expect(edges.some((e) => e.sq === 'spawner::go/1' && e.tq.startsWith('multi::job'))).toBe(false); }); + it('counts arity past comments written inside a parameter or argument list', async () => { + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'noted.erl'), + `-module(noted). +-export([run/0, plain/2, spaced/2]). + +plain(X, Y) -> X + Y. + +spaced(X, % why this one is special + Y) -> + X * Y. + +run() -> + A = plain( % leading note + 1, 2), + B = plain(1, % separating note + 2), + C = spaced(1, 2), + D = erlang:spawn(noted, plain, [1, % note inside the MFA list + 2]), + {A, B, C, D}. +` + ); + const edges = await callEdges(dir); + // A comment is a NAMED child, but it is not an argument: every call below + // is arity 2, and `spaced` is defined with arity 2. + expect(edges).toContainEqual({ sq: 'noted::run/0', tq: 'noted::plain/2' }); + expect(edges).toContainEqual({ sq: 'noted::run/0', tq: 'noted::spaced/2' }); + // Nothing resolved to a phantom arity the comments would have produced. + expect(edges.some((e) => /::(plain|spaced)\/[^2]$/.test(e.tq))).toBe(false); + }); + it('lands `fun mod:f/1` references on the written arity', async () => { fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); fs.writeFileSync( diff --git a/src/extraction/languages/erlang.ts b/src/extraction/languages/erlang.ts index 2f9f1b4bb..c63840438 100644 --- a/src/extraction/languages/erlang.ts +++ b/src/extraction/languages/erlang.ts @@ -1,5 +1,10 @@ import type { Node as SyntaxNode } from 'web-tree-sitter'; -import { getNodeText, getChildByField, getPrecedingDocstring } from '../tree-sitter-helpers'; +import { + getNodeText, + getChildByField, + getPrecedingDocstring, + namedArgCount, +} from '../tree-sitter-helpers'; import type { LanguageExtractor, ExtractorContext } from '../tree-sitter-types'; // Node names follow the vendored WhatsApp/tree-sitter-erlang grammar (0.19, @@ -95,10 +100,9 @@ function moduleExports(node: SyntaxNode, source: string, filePath: string): Set< return result; } -/** Argument count of a clause/sig: the `args` (expr_args) field's named-child count. */ +/** Argument count of a clause/sig: the `args` (expr_args) field's arguments. */ function nodeArity(withArgs: SyntaxNode): number { - const args = getChildByField(withArgs, 'args'); - return args ? args.namedChildCount : 0; + return namedArgCount(getChildByField(withArgs, 'args')); } /** diff --git a/src/extraction/tree-sitter-helpers.ts b/src/extraction/tree-sitter-helpers.ts index a6438e1b4..b9e35b43a 100644 --- a/src/extraction/tree-sitter-helpers.ts +++ b/src/extraction/tree-sitter-helpers.ts @@ -89,6 +89,19 @@ function cleanCommentMarkers(comment: string): string { .trim(); } +/** + * Number of ARGUMENTS in an `args` node — its named children minus comments. + * + * tree-sitter reports `comment` as a NAMED child, so a comment written at the + * top level of a parameter or argument list (`f(A, % why\n B)`) inflates a + * plain `namedChildCount`. Anywhere that count is an arity — and arity is part + * of an Erlang function's identity (#1610) — the comment must not be counted. + */ +export function namedArgCount(argsNode: SyntaxNode | null | undefined): number { + if (!argsNode) return 0; + return argsNode.namedChildren.filter((c) => c.type !== 'comment').length; +} + /** * Get the docstring/comment preceding a node */ diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index c34dc4716..5d5d6ea73 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -16,7 +16,13 @@ import { UnresolvedReference, } from '../types'; import { getParser, detectLanguage, isLanguageSupported, isFileLevelOnlyLanguage } from './grammars'; -import { generateNodeId, getNodeText, getChildByField, getPrecedingDocstring } from './tree-sitter-helpers'; +import { + generateNodeId, + getNodeText, + getChildByField, + getPrecedingDocstring, + namedArgCount, +} from './tree-sitter-helpers'; import { FN_REF_SPECS, captureFnRefCandidates, type FnRefSpec, type FnRefCandidate } from './function-ref'; import { isGeneratedFile } from './generated-detection'; import type { LanguageExtractor, ExtractorContext } from './tree-sitter-types'; @@ -3805,8 +3811,7 @@ export class TreeSitterExtractor { } // 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; + const callArity = namedArgCount(getChildByField(node, 'args')); this.unresolvedReferences.push({ fromNodeId: callerId, referenceName: `${calleeName}/${callArity}`, @@ -3873,7 +3878,7 @@ export class TreeSitterExtractor { // 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}` : ''; + const arityTail = mfaList?.type === 'list' ? `/${namedArgCount(mfaList)}` : ''; this.unresolvedReferences.push({ fromNodeId: callerId, referenceName: (isLocalModule ? erlAtom(f) : `${erlAtom(m)}::${erlAtom(f)}`) + arityTail,