Sfoglia il codice sorgente

feat(extraction): Erlang gen_server registered-name dispatch targets (#635, #648) (#1167)

gen_server:call/cast/send_request now connects to the TARGET module's
handle_call/handle_cast for every statically-named target, not just self:
a bare atom reaches the module of that name (OTP's {local, ?MODULE}
convention names a server after its module), and a macro defined as a bare
atom (-define(STORE, kv_store)) resolves the same way, alongside the
existing ?MODULE / -define(SERVER, ?MODULE) self paths. A registered name
that matches no module emits a qualified ref that never resolves — silent,
never guessed. Pid, var, and tuple targets ({global, Name}, {Name, Node})
stay unlinked.

Validated on emqx: 53 new edges, 53/53 precise (each source line is a real
registered-name gen_server request; each target module self-registers under
that name, macro-indirected registrations included). Nearly all are
test-suite → handler links — production code goes through API wrappers the
self path already covers — which is exactly the tests-exercising-this-
handler linkage blast-radius and test-gap reporting consume. ejabberd
yields zero (it always wraps): no false positives invented.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Colby Mchenry 2 mesi fa
parent
commit
7e3d44fa96
3 ha cambiato i file con 76 aggiunte e 40 eliminazioni
  1. 1 1
      CHANGELOG.md
  2. 24 3
      __tests__/extraction.test.ts
  3. 51 36
      src/extraction/tree-sitter.ts

+ 1 - 1
CHANGELOG.md

@@ -12,7 +12,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 ### New Features
 
 - Erlang behaviour dispatch is now followed through the graph: a framework call through a variable module — cowboy's `Handler:init`/`Middleware:execute` folds, a plugin manager's `Mod:callback(...)` — links to the repo's implementations of the behaviour that declares that callback, so flow traces and impact cross the OTP callback boundary instead of stopping at it. The links are precision-gated: the callback arity must match, exactly one behaviour may own that callback shape (a collision stays unlinked rather than guessed), the implementer must actually export the callback, and the fan-out is bounded — a behaviour with hundreds of implementers stays a visibly dynamic boundary. Every bridged hop is labeled as dynamic dispatch with its wiring site, never shown as a plain static call.
-- CodeGraph now indexes **Erlang** (`.erl`, `.hrl`) — functions, with clauses and arities of the same name grouped as one symbol spanning all of them, plus records with their fields, `-type`/`-opaque` aliases, `-define` macros, and `-spec` signatures attached to every function. Cross-module `mod:fn(...)` calls resolve to the target module's function, `fun name/arity` values are captured as references (so callback registrations like `lists:foreach(fun submit/1, ...)` link up), `-include`/`-include_lib` connect to the header files they pull in, `-behaviour` declarations link a callback module to its behaviour (and only ever to a module — a same-named macro or function elsewhere in the repo is never mistaken for one), and `-export` lists (plus `-compile(export_all)`) drive each function's public/private flag. OTP's indirection idioms are followed where the target is static: `spawn`/`apply`/`proc_lib`/`timer`/`rpc` calls that name their target as `(Module, Function, Args)` arguments produce call edges, and a module's public API wrappers connect to its own `handle_call`/`handle_cast` when `gen_server:call`/`cast` targets `?MODULE` (including the `-define(SERVER, ?MODULE)` idiom). Truly dynamic dispatch (`Mod:handle(...)`, message sends, var-module spawns) is deliberately left unlinked rather than guessed. `codegraph_explore` also understands Erlang-native symbol spelling in queries — `mod:fn/3` and `init/2` find the symbols they name. (#635, #648)
+- CodeGraph now indexes **Erlang** (`.erl`, `.hrl`) — functions, with clauses and arities of the same name grouped as one symbol spanning all of them, plus records with their fields, `-type`/`-opaque` aliases, `-define` macros, and `-spec` signatures attached to every function. Cross-module `mod:fn(...)` calls resolve to the target module's function, `fun name/arity` values are captured as references (so callback registrations like `lists:foreach(fun submit/1, ...)` link up), `-include`/`-include_lib` connect to the header files they pull in, `-behaviour` declarations link a callback module to its behaviour (and only ever to a module — a same-named macro or function elsewhere in the repo is never mistaken for one), and `-export` lists (plus `-compile(export_all)`) drive each function's public/private flag. OTP's indirection idioms are followed where the target is static: `spawn`/`apply`/`proc_lib`/`timer`/`rpc` calls that name their target as `(Module, Function, Args)` arguments produce call edges, and `gen_server:call`/`cast` connects to the target module's `handle_call`/`handle_cast` — its own when targeting `?MODULE` (including the `-define(SERVER, ?MODULE)` idiom), and the named module when a registered name follows OTP's name-the-server-after-its-module convention (`gen_server:call(other_mod, ...)`, directly or through a `-define(STORE, other_mod)` macro); a registered name that matches no module stays unlinked. Truly dynamic dispatch (`Mod:handle(...)`, message sends, var-module spawns) is deliberately left unlinked rather than guessed. `codegraph_explore` also understands Erlang-native symbol spelling in queries — `mod:fn/3` and `init/2` find the symbols they name. (#635, #648)
 - CodeGraph now indexes **Visual Basic .NET** (`.vb`) — classes, Modules, interfaces, structures, enums, properties, events, `MustOverride` abstract members, and `Declare` P/Invoke signatures, with `Inherits`/`Implements` hierarchy edges, call edges (resolved through VB's ambiguous call-vs-index parentheses), and `New`/`As New` instantiation links. Real-world VB styles parse cleanly: WinForms designer files, interpolated and multi-line strings, XML literals (embedded `<%= %>` expressions included), single-line and multi-line LINQ queries, multi-line lambdas, `Handles`/`WithEvents` event wiring, Custom Events, date literals, classic type-character identifiers (`i%`, `name$`), and non-English (Unicode) identifiers. (#648, #639, #170)
 - CodeGraph now indexes **COBOL** (`.cbl`, `.cob`, `.cpy`) — programs, sections and paragraphs with `PERFORM`/`GO TO` call edges, `CALL` cross-program calls, `COPY` copybook imports (standalone copybooks included), and DATA DIVISION records with 88-level condition names, in both fixed and free source format. Impact queries work on data items: every `MOVE`/`ADD`/`COMPUTE`/`SUBTRACT` write-site links back to the field it changes, so "what touches this copybook field" answers across programs. CICS flows connect too: `EXEC CICS LINK`/`XCTL` program targets, `EXEC SQL INCLUDE` copybooks, and pseudo-conversational `RETURN TRANSID(...)` hops resolve to the program owning the transaction id. (#590, #648)
 - CodeGraph now indexes **CFML** (`.cfc`, `.cfm`, `.cfs`) — both the classic tag-based style (`<cfcomponent>`/`<cffunction>`) and modern bare-script `component { ... }` syntax, including `extends`/`implements`, embedded `<cfscript>` blocks (at any nesting depth, including inside `<cfif>`/`<cfloop>`/`<cftry>`), call edges, and calls embedded in `#hash#` expressions inside `<cfquery>` SQL bodies. Files saved with a UTF-8 byte-order mark and tags with unquoted attribute values — both common in long-lived CFML codebases — are handled too. Thanks @ghedwards. (#1118)

+ 24 - 3
__tests__/extraction.test.ts

@@ -8991,14 +8991,35 @@ handle_cast({put, K, V}, S) -> {noreply, maps:put(K, V, S)}.
       expect(calls).toContain('kv_store::handle_cast');
     });
 
-    it('should not connect gen_server calls to other processes', () => {
+    it('should connect gen_server calls to a registered-name module, directly or via an atom macro', () => {
+      const code = `-module(kv_client).
+-export([fetch/1, evict/1]).
+
+-define(STORE, kv_store).
+
+fetch(Key) ->
+    gen_server:call(kv_store, {get, Key}).
+
+evict(Key) ->
+    gen_server:cast(?STORE, {evict, Key}).
+`;
+      const result = extractFromSource('src/kv_client.erl', code);
+      const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
+      // 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');
+    });
+
+    it('should not connect gen_server calls with dynamic targets', () => {
       const code = `-module(m).
 -export([go/2]).
 
 go(Pid, Msg) ->
     gen_server:call(Pid, Msg),
-    gen_server:call(other_registered_name, Msg),
-    gen_server:cast({global, some_name}, Msg).
+    gen_server:cast({global, some_name}, Msg),
+    gen_server:call({some_name, node()}, Msg).
 `;
       const result = extractFromSource('src/m.erl', code);
       const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);

+ 51 - 36
src/extraction/tree-sitter.ts

@@ -3509,28 +3509,39 @@ export class TreeSitterExtractor {
    * Extract a function call
    */
   /**
-   * Whether an Erlang gen_server target expression statically refers to the
-   * module it appears in: `?MODULE`, a macro the file defines as `?MODULE`
-   * (`-define(SERVER, ?MODULE)` — the standard idiom), or the module's own
-   * name as an atom. The self-macro set is memoized per file (single entry —
-   * extraction is file-sequential).
+   * The module an Erlang gen_server target expression statically names, or
+   * null when it's dynamic (pid/var/tuple form). Static shapes:
+   *   - a bare atom — either this module or another one; OTP's dominant
+   *     registration convention (`{local, ?MODULE}`) names a server process
+   *     after its module, so `gen_server:call(other_mod, …)` reaches
+   *     `other_mod`'s handlers. A registered name that matches no module
+   *     resolves to nothing downstream (the qualified ref just drops).
+   *   - `?MODULE`, or a macro the file defines as `?MODULE`
+   *     (`-define(SERVER, ?MODULE)` — the standard self idiom)
+   *   - a macro the file defines as a bare atom
+   *     (`-define(STORE, kv_store)` — the cross-module variant)
+   * The macro tables are memoized per file (single entry — extraction is
+   * file-sequential).
    */
-  private erlangSelfMacroFile = '';
+  private erlangServerMacroFile = '';
   private erlangSelfMacros = new Set<string>();
+  private erlangAtomMacros = new Map<string, string>();
 
-  private isErlangSelfReference(target: SyntaxNode): boolean {
+  private resolveErlangGenServerTarget(target: SyntaxNode): string | null {
     const ownModule = (this.filePath.split('/').pop() ?? '').replace(/\.erl$/, '');
     if (target.type === 'atom') {
-      return getNodeText(target, this.source) === ownModule;
+      const name = getNodeText(target, this.source).replace(/^'([\s\S]*)'$/, '$1');
+      return name || null;
     }
-    if (target.type !== 'macro_call_expr') return false;
+    if (target.type !== 'macro_call_expr') return null;
     const nameNode = getChildByField(target, 'name');
-    if (!nameNode) return false;
+    if (!nameNode) return null;
     const macroName = getNodeText(nameNode, this.source);
-    if (macroName === 'MODULE') return true;
-    if (this.erlangSelfMacroFile !== this.filePath) {
-      this.erlangSelfMacroFile = this.filePath;
+    if (macroName === 'MODULE') return ownModule || null;
+    if (this.erlangServerMacroFile !== this.filePath) {
+      this.erlangServerMacroFile = this.filePath;
       this.erlangSelfMacros = new Set<string>();
+      this.erlangAtomMacros = new Map<string, string>();
       let root: SyntaxNode = target;
       while (root.parent) root = root.parent;
       for (let i = 0; i < root.namedChildCount; i++) {
@@ -3539,17 +3550,23 @@ export class TreeSitterExtractor {
         const lhs = getChildByField(form, 'lhs');
         const defName = lhs ? getChildByField(lhs, 'name') : null;
         const replacement = getChildByField(form, 'replacement');
+        if (!defName || !replacement) continue;
         if (
-          defName &&
-          replacement?.type === 'macro_call_expr' &&
+          replacement.type === 'macro_call_expr' &&
           getChildByField(replacement, 'name') &&
           getNodeText(getChildByField(replacement, 'name')!, this.source) === 'MODULE'
         ) {
           this.erlangSelfMacros.add(getNodeText(defName, this.source));
+        } else if (replacement.type === 'atom') {
+          this.erlangAtomMacros.set(
+            getNodeText(defName, this.source),
+            getNodeText(replacement, this.source).replace(/^'([\s\S]*)'$/, '$1'),
+          );
         }
       }
     }
-    return this.erlangSelfMacros.has(macroName);
+    if (this.erlangSelfMacros.has(macroName)) return ownModule || null;
+    return this.erlangAtomMacros.get(macroName) ?? null;
   }
 
   private extractCall(node: SyntaxNode): void {
@@ -3652,15 +3669,15 @@ export class TreeSitterExtractor {
             line,
             column,
           });
-          // gen_server self-dispatch: `gen_server:call(?SERVER, Msg)` /
-          // `gen_server:cast(?MODULE, Msg)` — the OTP API-wrapper idiom (a
-          // module's public functions wrap gen_server requests to itself, and
-          // the real work happens in its own handle_call/handle_cast). The
-          // target is static when the first argument is ?MODULE, a macro the
-          // file defines as ?MODULE (the standard `-define(SERVER, ?MODULE)`),
-          // or the module's own name as an atom — emit the qualified callback
-          // ref so the module's public API connects to its handlers. Any other
-          // target (pid/var/registered name of another process) stays silent.
+          // gen_server dispatch: `gen_server:call(?SERVER, Msg)` /
+          // `gen_server:cast(other_mod, Msg)` — a request routes to the TARGET
+          // module's handle_call/handle_cast. The target is static when the
+          // first argument names a module: ?MODULE or a ?MODULE-defined macro
+          // (the self API-wrapper idiom), a bare atom (OTP's `{local, ?MODULE}`
+          // convention names a server after its module, so a cross-module
+          // registered name reaches that module's handlers — and a registered
+          // name matching no module resolves to nothing), or a macro defined
+          // as a bare atom. Pid/var/tuple targets stay silent.
           if (
             moduleExpr?.type === 'atom' &&
             erlAtom(moduleExpr) === 'gen_server' &&
@@ -3668,17 +3685,15 @@ export class TreeSitterExtractor {
           ) {
             const argsNode = getChildByField(node, 'args');
             const target = argsNode?.namedChild(0) ?? null;
-            if (target && this.isErlangSelfReference(target)) {
-              const ownModule = (this.filePath.split('/').pop() ?? '').replace(/\.erl$/, '');
-              if (ownModule) {
-                this.unresolvedReferences.push({
-                  fromNodeId: callerId,
-                  referenceName: `${ownModule}::${fnBare === 'cast' ? 'handle_cast' : 'handle_call'}`,
-                  referenceKind: 'calls',
-                  line,
-                  column,
-                });
-              }
+            const targetModule = target ? this.resolveErlangGenServerTarget(target) : null;
+            if (targetModule) {
+              this.unresolvedReferences.push({
+                fromNodeId: callerId,
+                referenceName: `${targetModule}::${fnBare === 'cast' ? 'handle_cast' : 'handle_call'}`,
+                referenceKind: 'calls',
+                line,
+                column,
+              });
             }
           }
           // MFA-in-argument dispatch: the spawn/apply family names its real