Skip to content

feat: Adds Elixir Language Support#320

Closed
tgroshon wants to merge 1 commit into
abhigyanpatwari:mainfrom
tgroshon:feat/add-elixir-lang
Closed

feat: Adds Elixir Language Support#320
tgroshon wants to merge 1 commit into
abhigyanpatwari:mainfrom
tgroshon:feat/add-elixir-lang

Conversation

@tgroshon

@tgroshon tgroshon commented Mar 17, 2026

Copy link
Copy Markdown

Core Registration & Parsing:

  • supported-languages.ts: Added Elixir = 'elixir' enum value
  • parser-loader.ts + parse-worker.ts: Registered tree-sitter-elixir as optional dependency with try/catch import
  • package.json: Moved tree-sitter-elixir to optionalDependencies
  • utils.ts: Added .ex/.exs extension mapping, Elixir built-in names, isElixirFunctionDef() helper for enclosing function detection
  • tree-sitter-queries.ts: Added ELIXIR_QUERIES with #eq? predicates for defmodule, def/defp, defmacro/defmacrop, defstruct, defprotocol, defimpl, alias/import/require/use imports, qualified calls, and heritage (use/@behaviour)
  • export-detection.ts: Added elixirExportChecker — walks up to enclosing call target to check def (public) vs defp (private)
  • call-routing.ts: Extended HeritageItem to support 'use'/'behaviour' heritage kinds; Elixir uses noRouting since queries handle all dispatch
  • type-extractors/elixir.ts (new): Stub type config with @SPEC return type extraction and struct construction scanning
  • resolvers/elixir.ts (new): CamelCase-to-snake_case module path resolution with multi-alias expansion

Import Resolution & Call Routing:

  • import-processor.ts: Added Elixir branch calling resolveElixirImport()
  • resolvers/utils.ts: Added .ex/.exs to EXTENSIONS
  • call-processor.ts + type-env.ts: Added isElixirFunctionDef() checks for enclosing function/scope detection (Elixir's def/defp are call nodes, not in FUNCTION_NODE_TYPES)

Type Extraction:

  • Basic @spec return type extraction from preceding unary_operator nodes
  • Struct construction inference (%User{} patterns)

Phoenix Framework Detection:

  • Path-based: *_controller.ex, *_live.ex, *_channel.ex, router.ex, endpoint.ex, application.ex, mix.exs
  • AST-based: use Phoenix.Router/Controller/LiveView/Channel, use GenServer/Supervisor/Agent

Tests:

  • simple.ex fixture: Module, def/defp, defmacro, defstruct, defprotocol, defimpl
  • 6 resolution fixtures: alias, multi-alias, pipe, phoenix, behaviour, visibility
  • 58 tests all passing (38 language + 20 Elixir resolver tests)
  • No regressions in existing tests (1425 unit tests pass, all other resolver tests pass)

@vercel

vercel Bot commented Mar 17, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the NexusCore Team on Vercel.

A member of the Team first needs to authorize it.

@xkonjin xkonjin left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: Elixir Language Support

Thorough, well-structured addition. The approach mirrors the existing Ruby/Swift patterns nicely. A few items worth addressing:

Potential Issues

  1. tree-sitter-swift accidentally removed from package-lock.json (line 39 diff) — the -"tree-sitter-swift": "^0.6.0", removal from the top-level dependencies block looks unintentional. It's still in optionalDependencies in package.json, but the lockfile change could cause issues on clean installs. Please verify this was deliberate.

  2. @ladybugdb/core reordering — the package.json diff moves this dependency from one position to another (alphabetical sort fix). Harmless but confirm it wasn't an accidental addition or version bump.

  3. toSnakeCase edge case — the regex in elixir.ts resolver handles HTTPClienthttp_client correctly, but Elixir modules with numbers (e.g. OAuth2) would produce o_auth2 which may not match filesystem conventions. Consider whether number-adjacent casing matters for your indexed repos.

  4. Heritage query fragility — the @behaviour heritage query assumes it's a direct child of the do_block:

    (do_block (unary_operator (call target: (identifier) @_attr ...)))
    

    If there are intervening nodes (e.g., @moduledoc before @behaviour), this pattern may not match. The query should use wildcard/sibling matching rather than requiring direct child position. Worth verifying with a fixture that has other attributes between defmodule and @behaviour.

Test Coverage

  1. Missing test for toSnakeCase — the resolver has a non-trivial regex-based snake_case converter but no unit test for edge cases (single-char modules, all-caps like HTTP, mixed like OAuth2Client).

  2. Pipe operator test asserts e.source === 'process' — this string comparison against function names could be brittle if the graph stores qualified names (e.g., Pipeline.process). Other language tests seem to use .includes() or partial matching.

Nits

  • The RubyHeritageItem deprecation alias is a nice touch for backward compat.
  • Good decision making tree-sitter-elixir an optionalDependency with graceful fallback.
  • The test fixtures are comprehensive — alias, multi-alias, pipe, Phoenix, behaviour, and visibility all covered.

Overall this is solid work. The heritage query fragility (#4) is the most likely source of production issues.

@magyargergo

Copy link
Copy Markdown
Collaborator

@claude use ultra-thinking and review this PR if it fits into our architecture and is correctly implementing type resolution for supporting member based CALLS edges. We only accept a PR if it is fully supports the given langauge features and handles type resolutions properly!

@claude

claude Bot commented Mar 17, 2026

Copy link
Copy Markdown

Claude finished @magyargergo's task in 4m 32s —— View job


PR Review: Elixir Language Support — Architecture & Type Resolution Analysis

  • Gather context and understand request
  • Read core architecture files (how other languages handle type resolution & CALLS edges)
  • Review Elixir type extractor (type-extractors/elixir.ts)
  • Review Elixir resolver (resolvers/elixir.ts)
  • Review tree-sitter queries for CALLS edge support
  • Review call-routing, call-processor, export-detection changes
  • Review test coverage for member-based CALLS
  • Synthesize findings and write comprehensive review

Architecture Fitness

The PR correctly mirrors the Ruby/Swift patterns and integrates cleanly into the ingestion pipeline: it registers in LANGUAGE_QUERIES, callRouters, typeConfigs, resolvers/index, and type-extractors/index. The optional-dependency pattern with try/catch is correct. The structural plumbing is sound.

However, there is a critical gap in how member-based CALLS edges work for Elixir — the most important ask in this review.


🚨 Critical Issue: Member-Based CALLS Edges Are Broken

This is the most significant finding and a blocker for acceptance.

Root Cause

In Elixir, qualified calls like Repo.insert(user) are represented in tree-sitter-elixir as:

(call
  target: (dot
    left: (alias "Repo")        ← receiver
    right: (identifier "insert") ← @call.name
  ))

The call-processor uses inferCallForm(callNode, nameNode) to classify calls. Tracing through utils.ts:824:

  1. nameNode.parent = dot node — dot is not in MEMBER_ACCESS_NODE_TYPES (member_expression, attribute, field_expression, selector_expression, etc. — no dot)
  2. callNode.type === 'call' with no receiver field (tree-sitter-elixir uses target: (dot ...), not a receiver field like Ruby's grammar)
  3. Falls through to step 6: nameParent?.parent === callNodereturns 'free'

Result: All Module.func() calls are classified as free calls. receiverName is undefined. The full machinery in resolveCallTarget for receiver-type filtering (D1–D4 in call-processor.ts:476–503) is bypassed entirely.

What Works vs. What Doesn't

Call Pattern Classification CALLS edge produced?
step_one(data) (same file, pipe) free ✅ Yes (import-scoped or same-file)
Repo.insert(user) (qualified) free (wrong — should be member) ⚠️ Only if unique global match
user.save() (instance method) N/A (not idiomatic Elixir)

What Needs to Be Fixed

Fix 1utils.ts MEMBER_ACCESS_NODE_TYPES: add 'dot':

const MEMBER_ACCESS_NODE_TYPES = new Set([
  // ... existing entries ...
  'dot',  // Elixir: Module.func() — nameNode sits inside dot node
]);

Fix 2utils.ts extractReceiverName: add dot handling (the left child carries the module name):

// Elixir dot node: (dot left: (alias "Module") right: (identifier "func"))
if (!receiver && parent.type === 'dot') {
  receiver = parent.childForFieldName('left');
}

Without both fixes, @spec return types and struct construction bindings collected by the Elixir type extractor can never be used for receiver-type disambiguation on cross-module calls — the infrastructure is in place but unreachable.

Related Structural Gap: No HAS_METHOD Edges → No ownerId Filtering

Even after fixing inferCallForm, the D4 ownerId-based filtering won't work because CLASS_CONTAINER_TYPES in utils.ts:273 doesn't include Elixir's call node wrapping defmodule. As a result:

  • findEnclosingClassId always returns null for Elixir functions
  • No HAS_METHOD edge from MyApp.Repoinsert
  • ownerId is not set on Elixir function SymbolDefinitions

D3 (file-based narrowing) would still work if callForm is fixed — it can disambiguate Repo.insert from Accounts.insert via file path when the receiver type resolves. But D4 ownerId matching, which is the fallback when there are multiple methods in the same file, is unavailable.


⚠️ Secondary Issues

__MODULE__ not handled as self-reference

In the behaviour fixture, GenServer.start_link(__MODULE__, opts) passes __MODULE__ which is Elixir's equivalent of self/this. The lookupInEnv in type-env.ts:57 handles 'self', 'this', '$this', but not '__MODULE__'. Calls on __MODULE__ would miss receiver-type resolution.

Multi-alias expansion only returns first match

resolvers/elixir.ts:40:

for (const name of names) {
  const resolved = resolveElixirSingleImport(...);
  if (resolved) return resolved;  // ← returns on first hit
}

alias MyApp.{User, Admin} should produce two IMPORTS edges. The current resolver finds one and stops. resolveElixirImport would need to return string[] to support multi-alias properly (or the import-processor would need to iterate the aliases individually). The test (serviceImports.length >= 1) accepts partial resolution, masking this.

Heritage query requires alias — misses atom/dynamic patterns

The @behaviour and use heritage queries require (alias) as the argument:

(arguments (alias) @heritage.extends)

This misses use MyApp.Web, :controller (with keyword options — (arguments (alias) (keywords ...))) and @behaviour :gen_server (atom, not alias). Phoenix's use MyAppWeb, :controller is a common pattern in real apps.

@spec walks backward but misses doc attributes

extractReturnType in type-extractors/elixir.ts:155:

} else if (sibling.isNamed && sibling.type !== 'comment') {
  break; // Stop at non-comment, non-attribute nodes
}

This stops at the first non-comment named node before def. In practice, @doc "..." and @impl true appear between @spec and def, and these are unary_operator nodes (for @doc) or call nodes (for @impl true). The @impl true case is a call node → the walk would break before finding @spec. So @spec + @impl in sequence won't have return types extracted.

Test coverage gap: no test for cross-module member CALLS edges

The alias fixture explicitly softens expectations:

// The elixir-alias fixture has mostly qualified calls (Repo.insert) which
// may not produce CALLS edges due to symbol resolution filtering.

This is a direct acknowledgment that member-based CALLS (the core requirement of this review) are untested and likely broken. A fixture like elixir-member-calls with a test asserting that MyApp.Repo.insert call resolves to the insert function in repo.ex is needed.


✅ What Works Well

  • Symbol discovery: def/defp/defmodule/defprotocol/defimpl/defmacro all captured correctly
  • Visibility: defisExported: true, defpisExported: false — correctly implemented via elixirExportChecker
  • Scope detection: isElixirFunctionDef() correctly finds enclosing def/defp for scope keying in TypeEnv and call enclosure lookup
  • Import resolution: Module path → snake_case file path conversion is correct for standard Phoenix/OTP layouts
  • Framework detection: Phoenix controllers, LiveViews, channels, routers detected both by path and AST
  • Heritage: use GenServer and @behaviour Serializable correctly produce EXTENDS edges
  • Pipe operator: same-file pipe chains (data |> step_one() |> step_two()) correctly produce CALLS edges
  • Struct binding: user = %User{} pattern scanning is correctly wired into the TypeEnv scan
  • Optional dep pattern: tree-sitter-elixir as optional with graceful fallback is correct

Verdict

Not ready to merge as-is for the stated goal of supporting member-based CALLS edges. The critical gap is that Module.func() calls are mis-classified as free calls, bypassing the entire receiver-type resolution system. The @spec return type infrastructure and struct construction binding are correctly implemented but unreachable via the call pipeline.

The fixes to MEMBER_ACCESS_NODE_TYPES and extractReceiverName in utils.ts are targeted and low-risk (they only activate for Elixir's dot node type, which no other language uses). These should be added along with:

  1. A test fixture (elixir-member-calls) asserting Repo.insert resolves to repo.ex:insert
  2. Fix for multi-alias returning only first match
  3. Fix for @spec extraction breaking on @impl intervening nodes
  4. (Stretch) __MODULE__ as self-reference in type-env

@github-actions

Copy link
Copy Markdown
Contributor

CI Report

Some checks failed

Pipeline Status

Stage Status Details
✅ Typecheck success tsc --noEmit
✅ Unit Tests success 3 platforms
❌ Integration failure 3 OS x 4 groups = 12 jobs

Test Results

Suite Tests Passed Failed Skipped Duration
Unit 1425 1425 0 0 8s
Integration 1016 997 1 17 65s
Total 2441 2422 1 17 73s

1 failed / 2422 passed

17 test(s) skipped — expand for details

Integration:

  • Swift constructor-inferred type resolution > detects User and Repo classes, both with save methods
  • Swift constructor-inferred type resolution > resolves user.save() to Models/User.swift via constructor-inferred type
  • Swift constructor-inferred type resolution > resolves repo.save() to Models/Repo.swift via constructor-inferred type
  • Swift constructor-inferred type resolution > emits exactly 2 save() CALLS edges (one per receiver type)
  • Swift self resolution > detects User and Repo classes, each with a save function
  • Swift self resolution > resolves self.save() inside User.process to User.save, not Repo.save
  • Swift parent resolution > detects BaseModel and User classes plus Serializable protocol
  • Swift parent resolution > emits EXTENDS edge: User → BaseModel
  • Swift parent resolution > emits IMPLEMENTS edge: User → Serializable (protocol conformance)
  • Swift cross-file User.init() inference > resolves user.save() via User.init(name:) inference
  • Swift cross-file User.init() inference > resolves user.greet() via User.init(name:) inference
  • Swift return type inference > detects User class and getUser function
  • Swift return type inference > detects save function on User (Swift class methods are Function nodes)
  • Swift return type inference > resolves user.save() to User#save via return type of getUser() -> User
  • Swift return-type inference via function return type > resolves user.save() to User#save via return type of getUser()
  • Swift return-type inference via function return type > user.save() does NOT resolve to Repo#save
  • Swift return-type inference via function return type > resolves repo.save() to Repo#save via return type of getRepo()

Code Coverage

Combined (Unit + Integration)

Metric Coverage Covered Base Delta Status
Statements 50.14% 5034/10038 43.56% 📈 +6.6 🟢 ██████████░░░░░░░░░░
Branches 43.48% 3240/7450 39.17% 📈 +4.3 🟢 ████████░░░░░░░░░░░░
Functions 50.31% 480/954 43.37% 📈 +6.9 🟢 ██████████░░░░░░░░░░
Lines 51.66% 4550/8807 44.8% 📈 +6.9 🟢 ██████████░░░░░░░░░░
Coverage breakdown by test suite

Unit Tests

Metric Coverage Covered Base Delta Status
Statements 40.42% 4058/10038 43.56% 📉 -3.1 🔴 ████████░░░░░░░░░░░░
Branches 36.13% 2692/7450 39.17% 📉 -3.0 🔴 ███████░░░░░░░░░░░░░
Functions 39.3% 375/954 43.37% 📉 -4.1 🔴 ███████░░░░░░░░░░░░░
Lines 41.53% 3658/8807 44.8% 📉 -3.3 🔴 ████████░░░░░░░░░░░░

Integration Tests

Metric Coverage Covered Base Delta Status
Statements 23.37% 2346/10038 43.56% 📉 -20.2 🔴 ████░░░░░░░░░░░░░░░░
Branches 17.57% 1309/7450 39.17% 📉 -21.6 🔴 ███░░░░░░░░░░░░░░░░░
Functions 26.51% 253/954 43.37% 📉 -16.9 🔴 █████░░░░░░░░░░░░░░░
Lines 24.71% 2177/8807 44.8% 📉 -20.1 🔴 ████░░░░░░░░░░░░░░░░

📋 View full run · Generated by CI

@magyargergo

Copy link
Copy Markdown
Collaborator

@claude review the written integration tests for each cases for the given language we required comprehensive integration tests in our fixtures datasets.

@claude

claude Bot commented Mar 17, 2026

Copy link
Copy Markdown

Claude finished @magyargergo's task in 3m 20s —— View job


Integration Test Review: Elixir Language Support

  • Gather context and understand request
  • Read integration test file (elixir.test.ts)
  • Read all fixture files (alias, multi-alias, pipe, phoenix, behaviour, visibility)
  • Compare against other language integration tests for coverage baseline
  • Synthesize findings and write comprehensive review

Assessment: Tests Are Smoke-Level Only — Not Comprehensive

Compared against the Ruby integration tests (the gold standard in this repo — 12 describe blocks, 40+ assertions covering member calls, disambiguation, type inference, self/parent resolution, return type inference, chained calls), the Elixir tests are in an early "does it crash" state. They verify symbol discovery and basic IMPORTS edges, but leave the core pipeline features entirely untested.


🚨 Critical: The Primary Feature Has No Tests

Cross-module member CALLS edges (Module.func()) are completely absent from the test suite.

The elixir-alias fixture is the only place where this pattern exists (Repo.insert(attrs) in user.ex), and the test explicitly avoids asserting on it:

// elixir.test.ts:58-65
it('processes call edges without errors', () => {
  // The elixir-alias fixture has mostly qualified calls (Repo.insert) which
  // may not produce CALLS edges due to symbol resolution filtering.
  const calls = getRelationships(result, 'CALLS');
  expect(Array.isArray(calls)).toBe(true);  // ← this always passes, proves nothing
});

expect(Array.isArray(calls)).toBe(true) is a no-op assertion. An empty array also passes. There is no test verifying that createinsert produces a CALLS edge. This is the exact capability gap flagged in the previous review, and the test suite confirms it doesn't work by intentionally sidestepping the assertion.

A required missing fixture:

test/fixtures/lang-resolution/elixir-member-calls/
  lib/my_app/repo.ex       → defmodule MyApp.Repo; def insert(attrs)
  lib/my_app/service.ex    → defmodule MyApp.Service; alias MyApp.Repo; def save(attrs) do Repo.insert(attrs) end

With tests asserting:

  • saveinsert CALLS edge exists
  • targetFilePath contains repo.ex
  • rel.reason is set

🔴 Gap 1: Multi-Alias Expansion Not Validated End-to-End

Fixture: service.ex does alias MyApp.Accounts.{User, Admin} — two aliases.
Test (elixir.test.ts:97):

expect(serviceImports.length).toBeGreaterThanOrEqual(1);  // only requires ONE

The comment says "At least one of User or Admin should resolve." This accepts partial failure. The feature being tested is {User, Admin} expansion — it should produce two IMPORTS edges. The test masks the known bug where only the first match is returned (flagged in previous review, resolvers/elixir.ts:40).

Required: assert serviceImports.length equals exactly 2, with both user.ex and admin.ex as targets.


🔴 Gap 2: Phoenix Framework Detection Not Tested

The phoenix fixture has use Phoenix.Controller, use Phoenix.LiveView, use Phoenix.Router. The tests only verify:

  1. Module names are detected ✓
  2. Function names are detected ✓
  3. One IMPORTS edge from controller → accounts ✓

Nothing tests EXTENDS/IMPLEMENTS edges from use Phoenix.*, which is the core output of framework detection. Given that use heritage is one of the explicitly advertised features, there should be:

it('emits EXTENDS edge from use Phoenix.Controller', () => {
  const extends_ = getRelationships(result, 'EXTENDS');
  const controllerEdge = extends_.find(e =>
    e.source === 'MyAppWeb.UserController' && e.target.includes('Phoenix.Controller')
  );
  expect(controllerEdge).toBeDefined();
});

Similarly, the LiveView use Phoenix.LiveView edge is untested.

Also missing: no test that Accounts.list_users() and Accounts.get_user(id) in the controller produce CALLS edges to accounts.ex.


🔴 Gap 3: defprotocol / defimpl Edges Not Tested

The simple.ex sample fixture contains both defprotocol Printable and defimpl Printable, for: MyApp.Accounts.User, but there is no integration test suite for these constructs. The PR description lists defprotocol/defimpl as supported. Required:

  • A fixture with a protocol definition and an implementation
  • Test that defprotocol emits a node (what label? Interface/Protocol?)
  • Test that defimpl emits an IMPLEMENTS edge from the implementing module to the protocol
  • Test that @callback definitions in a protocol are captured

🔴 Gap 4: defstruct as Properties Not Tested

The multi-alias fixture has defstruct [:name, :email] and defstruct [:name, :role], but no test checks that these emit Property nodes or that the module emits HAS_PROPERTY/HAS_FIELD edges. Compare Ruby's treatment of attr_accessor (tested at ruby.test.ts:115-137).


🔴 Gap 5: @spec Return Type Inference Not Tested

The PR adds @spec return type extraction specifically to enable type-driven CALLS resolution. There is no fixture or test that validates:

  1. A function with @spec get_user() :: User.t() has its return type extracted
  2. A variable assigned from that function call is typed as User
  3. A subsequent method call on that variable produces a CALLS edge to User's method

Without this test, the @spec infrastructure is shipped but unverified.


🟡 Weak Assertions Throughout

Location Current assertion Should be
elixir.test.ts:55 userToRepo.length >= 1 exact count + verify targetFilePath basename
elixir.test.ts:97 serviceImports.length >= 1 === 2 + both user.ex and admin.ex present
elixir.test.ts:131 e.source === 'process' check qualified name or e.sourceFilePath.includes('pipeline.ex')
elixir.test.ts:208-213 targets.toContain(...) check source module name + verify edge points to real graph node

No test anywhere checks rel.reason on EXTENDS/IMPORTS edges (Ruby tests verify 'include', 'extend', 'prepend', 'import-resolved'). No test checks sourceLabel/targetLabel on CALLS edges.


🟡 describe.skipIf Pattern Not Used

The Elixir tests use an ad-hoc if (skipIfNoElixir()) return; inside each describe block. The Swift tests use the cleaner describe.skipIf(!swiftAvailable)(...) pattern, which integrates with the test reporter and marks tests as properly skipped rather than silently empty. Fix this →


🟡 simple.ex Sample Fixture Is Unused by Integration Tests

test/fixtures/sample-code/simple.ex covers defmodule, @behaviour, alias, use, defstruct, defprotocol, defimpl, @spec, def/defp, defmacro — a comprehensive fixture. But it's only consumed by the tree-sitter-languages.test.ts unit test for parsing. The integration resolver tests have no equivalent that exercises all these constructs together end-to-end through the full pipeline.


Required Fixtures to Add

Fixture What it tests
elixir-member-calls Module.func() → CALLS edge (the critical gap)
elixir-struct-type-inference %User{} struct construction → type binding → CALLS
elixir-spec-type @spec func() :: User.t() return type → type binding
elixir-defprotocol defprotocol/defimpl → IMPLEMENTS edges
elixir-defstruct-props defstruct → Property nodes / HAS_FIELD edges

The pipe and visibility fixtures are the only ones where the tests are definitive and actually validate what they claim to.


@tgroshon

Copy link
Copy Markdown
Author

Thanks for the reviews! I'll make appropriate updates to the PR today. Let me know if you have any other guidance or insights.

@tgroshon tgroshon force-pushed the feat/add-elixir-lang branch 2 times, most recently from 1863f12 to 4ebff21 Compare March 17, 2026 21:56
@tgroshon tgroshon force-pushed the feat/add-elixir-lang branch from 4ebff21 to 3ceee02 Compare March 18, 2026 17:30
Repository owner deleted a comment from tgroshon Mar 19, 2026
Repository owner deleted a comment from claude Bot Mar 19, 2026
@magyargergo

Copy link
Copy Markdown
Collaborator

@claude ultra-think while identifying Elixir-language gaps. Perfom this analysis on this pull request and verify those findings. Act as a senior compiler font-end engineer and expert in static analysis tools and review this change from that perpective and review the architectural fit. Also check out previous comments and reason with their requests. You must also cross-reference it with our type-resolution-system.md.

@github-actions

github-actions Bot commented Mar 19, 2026

Copy link
Copy Markdown
Contributor

Claude finished @magyargergo's task in 6m 6s —— View job


Deep Elixir PR Analysis — Compiler Front-End & Static Analysis Perspective

  • Gather context: read type-resolution-system.md and key architecture docs
  • Read all changed files: utils.ts, call-processor.ts, call-routing.ts, type-env.ts, tree-sitter-queries.ts
  • Read Elixir-specific new files: type-extractors/elixir.ts, resolvers/elixir.ts
  • Read integration tests and all fixture files
  • Compare against Ruby/Swift implementations as baseline
  • Cross-reference findings with previous reviews and type-resolution-system.md
  • Synthesize and post comprehensive expert analysis

Executive Summary

This review is conducted against the two-commit version of the PR (after the second commit 59452ae). I verified findings from the two previous reviews against the actual current source. Most critical issues have been addressed. However, several structural gaps remain that affect CALLS edge accuracy and feature completeness.


Resolved Issues (Verified in Current Code)

The second commit resolved the central blocker identified in the prior review. I confirmed all fixes in the actual source:

Prior Issue Status Evidence
MEMBER_ACCESS_NODE_TYPES missing 'dot' Fixed utils.ts:878'dot' present
extractReceiverName not handling Elixir dot node Fixed utils.ts:1047-1049parent.childForFieldName('left')
Multi-alias returning only first match Fixed resolvers/elixir.ts:44-61resolveElixirImports returns string[]
import-processor calling singular resolver Fixed import-processor.ts:241 — calls resolveElixirImports (plural)
__MODULE__ not treated as self-reference Fixed type-env.ts:129 — added to lookupInEnv special-case
@spec walk breaking on @impl node Fixed type-extractors/elixir.ts:155-166 — keeps walking past unary_operator and @attr-style call nodes
Tests using describe.skipIf pattern Fixed elixir.test.ts:20 — uses describe.skipIf(!elixirAvailable)
Multi-alias test asserting >= 1 Fixed elixir.test.ts:86 — now asserts toBe(2) with both file targets checked
Member-calls fixture/test missing Fixed elixir-member-calls/ fixture + elixir.test.ts:286-322 added

The core CALLS pipeline now correctly classifies Repo.insert(attrs) as a member call, extracts receiver Repo from the dot node's left field, and the receiver appears in SIMPLE_RECEIVER_TYPES as 'alias' (utils.ts:972).


🔴 Critical Remaining Issue: __MODULE__ Self-Resolution is Silently Broken

lookupInEnv in type-env.ts:129 now correctly special-cases __MODULE__, calling findEnclosingClassName. However, findEnclosingClassName (type-env.ts:176-187) only walks the AST looking for nodes in CLASS_CONTAINER_TYPES (utils.ts:273-287).

The Elixir defmodule construct is a call node — it is not in CLASS_CONTAINER_TYPES. This means findEnclosingClassName will walk past defmodule without extracting the module name, returning undefined.

// type-env.ts:176-187
const findEnclosingClassName = (node: SyntaxNode): string | undefined => {
  let current = node.parent;
  while (current) {
    if (CLASS_CONTAINER_TYPES.has(current.type)) {  // 'call' is NOT here
      ...
    }
    current = current.parent;
  }
  return undefined;  // ← always reached for Elixir
};
// CLASS_CONTAINER_TYPES — utils.ts:273
'class_declaration', 'interface_declaration', 'class', 'module', ...  // no 'call'

Consequence: GenServer.start_link(__MODULE__, opts) in my_server.ex__MODULE__ is passed as an argument, which is fine. But if any Elixir code calls a method on __MODULE__ (e.g. __MODULE__.child_spec(opts)), the receiver-type lookup silently returns undefined and CALLS disambiguation falls back to name-only matching. The fix for lookupInEnv is thus only nominally applied — the underlying walker it calls does not work for Elixir.

Fix this →


🔴 Structural Gap: defimpl Produces a Symbol But No IMPLEMENTS Edge

The tree-sitter query for defimpl:

(call target: (identifier) @_kw
  (arguments (alias) @name)
  (#eq? @_kw "defimpl")) @definition.impl

This captures @name = Printable (the protocol name), but does not capture the for: clause (MyApp.User). There is no heritage query connecting the implementing module to the protocol.

In the pipeline, @definition.impl produces a graph node with label Impl and name Printable. But:

  • No HAS_METHOD edges connecting protocol functions to the implementation
  • No IMPLEMENTS edge from MyApp.UserPrintable
  • The for: module name is never captured in any query

This is architecturally incomplete for a feature advertised as "defprotocol/defimpl" support. Compare with how the Ruby include/prepend heritage produces EXTENDS edges, or Swift protocol conformance produces EXTENDS edges.

A proper fix requires an additional heritage query:

; defimpl Protocol, for: Module → IMPLEMENTS edge
(call target: (identifier) @_kw
  (arguments (alias) @heritage.extends)
  (arguments (keywords (pair key: (atom) @_for value: (alias) @heritage.class)))
  (#eq? @_kw "defimpl")
  (#eq? @_for ":for")) @heritage

This gap means the "defprotocol / defimpl" feature listed in the PR description is half-implemented: symbols are discovered, but relationships between protocol, implementor, and callback functions are absent.


🔴 Heritage Query: @behaviour Atom Form Not Handled

The @behaviour heritage query requires an (alias) as the argument:

(unary_operator
  operand: (call target: (identifier) @_attr
    (arguments (alias) @heritage.extends)
    (#eq? @_attr "behaviour")))

@behaviour :gen_server uses an (atom) node, not (alias). OTP behaviours often use atom form (especially from :gen_server, :gen_statem). The query will silently miss these. The behaviour fixture happens to use Serializable (an alias), so it passes, but real OTP code is missed.


🟡 Compiler-Perspective: Tree-Sitter Query Precision Issues

1. @spec Return Type t() Is Noise

extractElixirTypeName in type-extractors/elixir.ts:47-51:

const simpleMatch = trimmed.match(/^(\w+)\(\)$/);
if (simpleMatch) {
  const name = simpleMatch[1];
  if (ELIXIR_BUILTINS.has(name)) return undefined;
  return name;  // ← 't' is returned for 't()'
}

't' is not in ELIXIR_BUILTINS. So @spec get_user() :: t() would produce return type "t" — a common Elixir convention for the opaque self-type — which will not match any symbol in the graph. This is conservative (no wrong binding), but it wastes a spec annotation that could have been resolved to the enclosing module.

The correct handling: when the return type is t() (no qualifier), resolve it to the enclosing module's name. This requires the same findEnclosingClassName fix described above.

2. Qualified Call Query Has Ambiguous @call Node

The qualified call query in ELIXIR_QUERIES:

; Qualified calls: Module.func()
(call target: (dot right: (identifier) @call.name)) @call

The @call anchor here is the outer call node (the full Repo.insert(attrs) call). The @call.name is insert. In inferCallForm(callNode, nameNode):

  • callNode.type === 'call'
  • nameNode.parent.type === 'dot'
  • 'dot' ∈ MEMBER_ACCESS_NODE_TYPES → correctly returns 'member'

This is correct. But there's a subtle issue: the free-call query also matches call nodes:

(call target: (identifier) @call.name ...) @call

And the first line of routeElixirCall filters out definition keywords:

if (ELIXIR_DEF_KEYWORDS.has(calledName)) return SKIP_RESULT;

For qualified calls, calledName would be insert (from @call.name), not a def keyword. So no issue there. But could alias, import, require, use appear as @call.name for qualified calls? Yes: MyModule.alias(...) would be captured with calledName = 'alias'. But since routeElixirCall doesn't filter alias (only ELIXIR_DEF_KEYWORDS), this would produce a CALL edge to alias. This is an edge case but worth noting.

3. defmodule Heritage Query: Structural Fragility

The defmodule-level heritage query matches by requiring the use/@behaviour to appear inside do_block as a direct child:

(call ... (do_block (call target: (identifier) @_use ...)))

In tree-sitter query semantics, (do_block (call ...)) matches call as a DIRECT child of do_block. This means if the use statement is nested (e.g., inside an if or quote block), it won't be captured. While this covers the common case, it's worth documenting this limitation.

4. defguard / defguardp Pattern Matching

The defguard query:

(call target: (identifier) @_kw
  (arguments (call target: (identifier) @name))
  (#eq? @_kw "defguard")) @definition.macro

In tree-sitter-elixir, defguard is_user(term) when is_map(term) may parse as:

(call target: "defguard"
  (arguments (binary_operator left: (call target: "is_user") right: ...)))

The when clause produces a binary_operator, not a bare call. The query matches (arguments (call target: (identifier) @name)) — this would match the INNER call is_user, but only if defguard's arguments directly contain a call node. With the when guard, the structure becomes (binary_operator ...) as the argument. This query may fail to capture guard function names when the when clause is present. This needs verification with tree-sitter-elixir directly.


🟡 Type Resolution System: Elixir Coverage vs. type-resolution-system.md

Cross-referencing with type-resolution-system.md's Language Feature Matrix:

Feature Elixir Implementation Quality
Declarations extractDeclaration is a no-op ⚠️ None (parameters have no inline types — by design)
Parameters extractParameter is a no-op ⚠️ None (correct — @SPEC handles this)
Initializer / constructor inference scanConstructorBinding handles %User{}
Constructor binding scan Present via scanConstructorBinding
For-loop element types Not implemented 🔴 Missing
Pattern binding Not implemented (function head %User{} = u) 🟡 Missing
Assignment chains Inherited from base engine
Comment-based types Not implemented (@doc not parsed for types) ⚠️ Elixir uses @spec not doc comments
Return type extraction extractReturnType implemented

The absence of for-loop element types is notable since Elixir's Enum.each(users, fn user -> user.name end) is common. The anonymous function parameter user has no inline type — it would need @spec inference from the Enum.each context, which is outside the scope of the current tier system. This is an acceptable limitation but should be documented.

Pattern binding in function heads is a real gap for Elixir compared to other OO languages. Elixir's primary dispatch mechanism is pattern-matched function heads:

def process(%User{} = user), do: user.name

The variable user is type-bound to User via pattern match, but the current scanConstructorBinding only handles user = %User{} (RHS struct literal), not LHS destructuring patterns.


🟡 CLASS_CONTAINER_TYPES Gap: No ownerId on Elixir Symbols

findEnclosingClassId in utils.ts:310 walks up the AST looking for CLASS_CONTAINER_TYPES. Since Elixir defmodule is a call node (not in this set), all Elixir function SymbolDefinitions have ownerId = null.

This means the D4 ownerId-based filtering in call-processor.ts:502 will never activate for Elixir. The system falls back to D3 (file-based narrowing), which is adequate for the common case where one file has one module. However, multi-module files (two defmodule blocks in the same .ex file) would experience CALLS edge ambiguity between methods of the two modules, since file-based narrowing alone cannot distinguish them.


🟢 What's Well Done

Plumbing is clean: The registration chain (supported-languages → parser-loader → parse-worker → LANGUAGE_QUERIES → callRouters → typeConfigs → resolvers/index) is complete and follows the existing pattern exactly.

isElixirFunctionDef (utils.ts:376-394) — well-considered: it correctly identifies call nodes as function definitions by inspecting the target identifier. Used correctly in both call-processor.ts (enclosing function lookup) and type-env.ts (scope key generation). The scope key format (funcName@startIndex) is consistent with the shared format documented in type-resolution-system.md.

routeElixirCall — correctly returns SKIP_RESULT for definition keywords and CALL_RESULT for everything else. The noRouting approach (queries handle all import/heritage dispatch instead of runtime routing) is architecturally consistent.

toSnakeCase — the double-regex approach (([A-Z]+)([A-Z][a-z]) then ([a-z\d])([A-Z])) is correct for standard CamelCase → snake_case conversion and handles HTTPClient → http_client properly.

Optional dependency patterntry { ... } catch { ... } in both parser-loader.ts and parse-worker.ts is the correct pattern, matching how tree-sitter-swift is handled.

countCallArguments pipe support (utils.ts:853-859) — correctly adds 1 for the piped value when a call is the RHS of |>. This is a proper treatment of the implicit first argument in pipe chains.

@spec backward-walk — the corrected extractReturnType (from commit 2) properly handles @doc, @moduledoc, @deprecated, and @impl interleaved between @spec and def, maintaining the conservative "stop at real def/other call" logic.


Final Verdict

The second commit brought this PR close to the bar for member-based CALLS edge support. The core pipeline — query capture → inferCallForm('member') → receiver extraction (alias node as receiver) → D3 file-based narrowing — is now correctly wired. The elixir-alias and elixir-member-calls fixtures demonstrate this.

Remaining blockers before merge:

  1. 🔴 __MODULE__ self-resolution broken due to CLASS_CONTAINER_TYPES not including defmodule call nodes — lookupInEnv special-case is registered but the underlying findEnclosingClassName walker cannot reach it
  2. 🔴 defimpl ... for: Module produces zero IMPLEMENTS edges — protocol implementation relationships are not captured
  3. 🔴 @behaviour :gen_server atom-form not matched by heritage query

Acceptable for merge with documented limitations:

  1. 🟡 ownerId always null on Elixir functions (D4 filtering inactive; D3 covers most cases)
  2. 🟡 t() return type resolves to string "t" instead of enclosing module name
  3. 🟡 Pattern binding in function heads (%User{} = user params) not supported
  4. 🟡 defguard with when clause may not be captured (needs grammar verification)
  5. 🟡 No defstruct → Property node emission

Core Registration & Parsing:

- supported-languages.ts: Added Elixir = 'elixir' enum value
- parser-loader.ts + parse-worker.ts: Registered tree-sitter-elixir as optional dependency with try/catch import
- package.json: Moved tree-sitter-elixir to optionalDependencies
- utils.ts: Added .ex/.exs extension mapping, Elixir built-in names, isElixirFunctionDef() helper for enclosing
function detection
- tree-sitter-queries.ts: Added ELIXIR_QUERIES with #eq? predicates for defmodule, def/defp, defmacro/defmacrop,
defstruct, defprotocol, defimpl, alias/import/require/use imports, qualified calls, and heritage (use/@behaviour)
- export-detection.ts: Added elixirExportChecker — walks up to enclosing call target to check def (public) vs defp
(private)
- call-routing.ts: Extended HeritageItem to support 'use'/'behaviour' heritage kinds; Elixir uses noRouting since
queries handle all dispatch
- type-extractors/elixir.ts (new): Stub type config with @SPEC return type extraction and struct construction
scanning
- resolvers/elixir.ts (new): CamelCase-to-snake_case module path resolution with multi-alias expansion

Import Resolution & Call Routing:

- import-processor.ts: Added Elixir branch calling resolveElixirImport()
- resolvers/utils.ts: Added .ex/.exs to EXTENSIONS
- call-processor.ts + type-env.ts: Added isElixirFunctionDef() checks for enclosing function/scope detection
(Elixir's def/defp are call nodes, not in FUNCTION_NODE_TYPES)

Type Extraction:

- Basic @SPEC return type extraction from preceding unary_operator nodes
- Struct construction inference (%User{} patterns)

Phoenix Framework Detection:

- Path-based: *_controller.ex, *_live.ex, *_channel.ex, router.ex, endpoint.ex, application.ex, mix.exs
- AST-based: use Phoenix.Router/Controller/LiveView/Channel, use GenServer/Supervisor/Agent

Tests:

- simple.ex fixture: Module, def/defp, defmacro, defstruct, defprotocol, defimpl
- 6 resolution fixtures: alias, multi-alias, pipe, phoenix, behaviour, visibility
- 58 tests all passing (38 language + 20 Elixir resolver tests)
- No regressions in existing tests (1425 unit tests pass, all other resolver tests pass)

Fix quote rewriting

Upgrades for additional PR comments
@tgroshon tgroshon force-pushed the feat/add-elixir-lang branch from 58f5965 to 072f928 Compare March 20, 2026 03:38
@zander-raycraft

Copy link
Copy Markdown
Collaborator

@claude looking at the latest commit to address previous issues in this PR, can you ultra-think while identifying Elixir-language gaps. Perfom this analysis on this pull request and verify those findings. Act as a senior compiler font-end engineer and expert in static analysis tools and review this change from that perpective and review the architectural fit. Also check out previous comments and reason with their requests. You must also cross-reference it with our type-resolution-system.md.

@github-actions

github-actions Bot commented Mar 21, 2026

Copy link
Copy Markdown
Contributor

Claude finished @zander-raycraft's task in 10m 24s —— View job


Deep Elixir PR Analysis — Compiler Front-End & Static Analysis Perspective

  • Gather context: read type-resolution-system.md and key architecture docs
  • Read all Elixir-specific new/modified files
  • Read integration tests and all fixture files
  • Compare against Ruby/Swift implementations as baseline
  • Cross-reference findings with previous reviews and type-resolution-system.md
  • Synthesize and post comprehensive expert analysis

Verification of Previous Findings

All critical issues from the previous two reviews are now confirmed resolved in the current commit (072f928). I verified each directly against the source:

Previously Flagged Issue Status Location
MEMBER_ACCESS_NODE_TYPES missing 'dot' ✅ Fixed utils.ts:906
extractReceiverName not handling dot node ✅ Fixed utils.ts:1074-1077
SIMPLE_RECEIVER_TYPES missing 'alias' ✅ Fixed utils.ts:1000
Multi-alias returning only first match ✅ Fixed resolvers/elixir.ts:44-65
__MODULE__ not in lookupInEnv special-case ✅ Fixed type-env.ts:119
findEnclosingClassName not handling defmodule ✅ Fixed type-env.ts:169-177
findEnclosingClassId not handling defmodule (HAS_METHOD) ✅ Fixed utils.ts:349-358
@spec walk breaking on @impl interleaved nodes ✅ Fixed type-extractors/elixir.ts:156-167
defguard with when clause not captured ✅ Fixed tree-sitter-queries.ts:962-978
@behaviour :gen_server atom-form not matched ✅ Fixed tree-sitter-queries.ts:1079-1089
defimpl Protocol, for: Module producing no IMPLEMENTS edge ✅ Fixed tree-sitter-queries.ts:1091-1101
describe.skipIf pattern not used ✅ Fixed elixir.test.ts:20
Multi-alias test accepting >= 1 (masking bug) ✅ Fixed elixir.test.ts:86 — asserts exactly 2
Member-calls fixture and test missing ✅ Added elixir-member-calls/ + elixir.test.ts:296-332
HAS_METHOD edges untested ✅ Added elixir.test.ts:338-355
defimpl IMPLEMENTS edge untested ✅ Added elixir.test.ts:361-379

🔬 New Deep Analysis — Compiler Front-End Perspective

✅ Core Pipeline: Member CALLS Fully Wired

The complete call resolution path for Repo.insert(attrs) is now correctly threaded:

tree-sitter: (call target: (dot left: (alias "Repo") right: (identifier "insert")))
                          ↓
inferCallForm(): nameNode.parent.type === 'dot' ∈ MEMBER_ACCESS_NODE_TYPES → 'member'
                          ↓  
extractReceiverName(): parent.childForFieldName('left') → alias node "Repo"
SIMPLE_RECEIVER_TYPES.has('alias') → receiverName = "Repo"
                          ↓
TypeEnv.lookup("Repo") → undefined (not a variable — it's a module alias)
                          ↓
call-processor D3: file-path narrowing via IMPORTS edges from alias resolution

The conservative fallthrough (receiver not in TypeEnv → D3 path) is architecturally sound. alias MyApp.Repo creates an IMPORTS edge, which D3 uses to narrow candidates by file. This is the correct design for Elixir's module-dispatch model.

__MODULE__ Resolution — Confirmed Working

findEnclosingClassName in type-env.ts:166-186 correctly handles the Elixir defmodule call pattern before checking CLASS_CONTAINER_TYPES. The critical fix (lines 170-176):

if (current.type === 'call') {
  const target = current.childForFieldName('target');
  if (target?.type === 'identifier' && target.text === 'defmodule') {
    const args = current.children?.find((c: any) => c.type === 'arguments');
    const aliasNode = args?.children?.find((c: any) => c.type === 'alias');
    if (aliasNode) return aliasNode.text;   // ← "MyServer"
  }
}

__MODULE__ calls like GenServer.start_link(__MODULE__, opts) now correctly resolve __MODULE__ to the enclosing module name, enabling type-env lookup to produce the correct receiver type.

defimpl Heritage — Structurally Sound

The defimpl query (lines 1091-1101) correctly produces:

  • heritage.class = "MyServer" (from for: MyServer)
  • heritage.implements = "Printable" (the protocol name)

Parse-worker pushes kind: 'implements'processHeritageFromExtracted creates IMPLEMENTS(MyServer → Printable). Since both symbols are in the same file, ctx.resolve() finds the actual node IDs. The edge connects real graph nodes. ✅

@behaviour :atom_form — Edge Target Analysis

For @behaviour :gen_server, captureMap['heritage.implements'].text = ':gen_server' (atom text includes the colon in tree-sitter-elixir). resolveHeritageId(':gen_server', ..., 'Interface')ctx.resolve() returns null (OTP, not in project) → falls back to generateId('Interface', ':gen_server') = "Interface::gen_server". Since generateId is a simple concatenation (not a hash), the target ID is human-readable. The test assertion e.rel.targetId.includes('gen_server') is "Interface::gen_server".includes('gen_server') = true. ✅


🟡 Remaining Gaps (Acceptable / Non-Blocking)

1. No extractPendingAssignment — Call-Result Chains Unsupported

user = get_user(); user.save() won't produce a type binding for user. The typeConfig for Elixir doesn't export extractPendingAssignment. Cross-referencing with type-resolution-system.md:

Feature Ruby Swift Elixir (this PR)
Assignment chains No Yes No
Call-result variable binding Yes¶ No No
Method-call-result binding Yes¶ No No

Elixir's omission here is consistent with Ruby's partial support. Elixir's immutable binding model (x = f() is pattern match, not assignment) makes this architecturally different from OO languages. Acceptable for initial support.

2. No ForLoopExtractorEnum.each Lambda Vars Untyped

Enum.each(users, fn user -> user.name end)user's type is not inferred. Ruby has a ForLoopExtractor (for users.each do |user|). Elixir's idiom (anonymous function argument) is structurally different from Ruby's block parameter — the variable is inside a stab_clause (->) which is not in FUNCTION_NODE_TYPES, so findEnclosingScopeKey correctly bubbles up to the enclosing def. But the variable user has no scope binding. This would need a new ForLoopExtractor that scans Enum.* call arguments and binds the anonymous function's parameter to the element type.

Fix this →

3. use MyAppWeb, :controller Macro Indirection

The use heritage query captures the alias child of arguments, which works for use Phoenix.Controller. For use MyAppWeb, :controller, @heritage.extends = "MyAppWeb" — producing an EXTENDS edge to MyAppWeb rather than Phoenix.Controller. This is a fundamental macro-expansion gap: without evaluating __using__/1, the actual behaviour injection is invisible to static analysis. The fixtures use the expanded use Phoenix.Controller form to sidestep this. This limitation should be documented in the fixture but is otherwise architecturally unavoidable without macro evaluation.

4. Duplicate Node IDs for Same-Name Functions in defprotocol + defimpl

In my_server.ex, both defprotocol Printable do def print(data) end and defimpl Printable, for: MyServer do def print(server) end define a function print in the same file. Both get nodeId = generateId('Function', 'path/my_server.ex:print') = the same ID. The second registration overwrites the first in the symbol table.

This is a pre-existing limitation of the name-based ID scheme (no module/class qualifier in the node ID), which exists across all languages. Ruby with two classes in the same file would hit the same issue. Acceptable for now but worth documenting.

5. Pattern Binding in Function Heads Not Supported

def process(%User{} = user), do: user.nameuser is type-bound via pattern match in the function head, but scanConstructorBinding only handles RHS patterns (user = %User{}). This is consistent with the matrix (Python/PHP/Ruby/Swift all show "No" for pattern binding). Acceptable.


🔍 Compiler-Perspective: Query Correctness Deep Dive

isElixirFunctionDef and Function Arity Ambiguity

// utils.ts:415-421
const firstArg = args.children?.find((c: any) => c.type === 'call');
if (firstArg) {
  const funcTarget = firstArg.childForFieldName?.('target')
    ?? firstArg.children?.find((c: any) => c.type === 'identifier');
  if (funcTarget?.type === 'identifier') return funcTarget.text;
}
return null;

Issue: This finds the FIRST call child of arguments, which is the function signature. But def foo, do: bar (no-arg function using , syntax instead of do ... end) doesn't wrap the name in arguments as a call — it would be (identifier "foo"). In tree-sitter-elixir, def foo without parens parses as (call target: (identifier "def") (arguments (identifier "foo"))). The firstArg search finds c.type === 'call' but the arg is an identifier, not a call. So isElixirFunctionDef would return null for zero-arity functions defined without parens.

This means def init, do: :ok would NOT be recognized as a function definition by isElixirFunctionDef, and scope keying / call-processor enclosure detection would miss it. The tree-sitter query @definition.function DOES capture it (matching (call target: "def" (arguments (identifier) @name))... wait, actually the definition query only matches (arguments (call target: (identifier) @name)) — a call inside arguments. Zero-arity def foo has (identifier "foo") not a (call ...) in arguments.

Actually I need to re-check the definition query:

(call
  target: (identifier) @_kw
  (arguments
    (call
      target: (identifier) @name))
  (#eq? @_kw "def")) @definition.function

This requires (arguments (call ...)). For def foo (no parens), arguments would contain (identifier "foo"), not a call. So def foo is NOT captured by the definition query.

Verify and fix this →

Heritage Query: (do_block (call ...)) Direct-Child Semantics

The heritage queries like:

(call ... (do_block (call target: (identifier) @_use (arguments (alias) @heritage.extends) ...)) ...)

In tree-sitter queries, (parent (child)) matches when child is a direct child of parent. This means if use is nested inside a with, if, or quote block within the do_block, the query won't match. In practice Elixir use is always at module body level, but this structural constraint is worth noting.

More importantly, the query requires the call to be a direct child of do_block. In tree-sitter-elixir's grammar, do_block children can include call, unary_operator, binary_operator — all as direct children. The use GenServer IS a direct child of the module's do_block. So this works for the common case.

However, if there are multiple use statements, each creates a separate query match (since the query anchors to defmodule and descends into do_block looking for any matching use child). Each match correctly produces a separate EXTENDS edge. ✅

defdelegate CALLS Gap

defdelegate foo(arg), to: OtherModule creates a @definition.function node for foo but does not produce a CALLS edge to OtherModule.foo. The to: clause is invisible to call resolution. Users relying on delegate call graphs would see foo defined but no outbound edges. This is an acceptable limitation.


✅ What's Architecturally Sound

  • Pipeline registration is complete: supported-languages → parser-loader → parse-worker → LANGUAGE_QUERIES → callRouters → typeConfigs → resolvers/index → type-extractors/index — all wired.
  • Optional dependency pattern with try/catch fallback is correctly applied in both parser-loader.ts and parse-worker.ts.
  • routeElixirCall correctly returns SKIP_RESULT for def keywords (no spurious CALLS edges on def/defp/defmodule/defimpl/etc.).
  • countCallArguments pipe support (utils.ts:853-859) correctly adds 1 for the implicit piped argument — matches Elixir's formal semantics where x |> f() desugars to f(x).
  • toSnakeCase double-regex correctly handles HTTPClient → http_client, UserService → user_service, multi-char caps sequences. The edge case OAuth2 → o_auth2 is a minor deviation from oauth2 but unlikely to cause file resolution failures in practice.
  • Scope key format (funcName@startIndex) is consistent with the shared format documented in type-resolution-system.md, ensuring constructor-binding verification works correctly.
  • isElixirFunctionDef in type-env.ts:847-848 correctly gates scope key generation: Elixir function scopes are call nodes (not in FUNCTION_NODE_TYPES), so without this special case, all Elixir code would run at file scope.

Verdict

Ready to merge with the following caveats documented:

The previous critical blockers are all resolved. The current implementation correctly handles:

  • Member CALLS edges (Repo.insertinsert in repo.ex) ✅
  • HAS_METHOD edges from defmodule
  • IMPLEMENTS edges from @behaviour (both alias and atom forms) ✅
  • IMPLEMENTS edges from defimpl ... for: Module
  • EXTENDS edges from use Module
  • Heritage item for @behaviour :gen_server (OTP atom form) ✅
  • __MODULE__ self-reference ✅

One newly-identified correctness issue worth fixing before merge:

🟡 Zero-arity def foo without parens: The definition query and isElixirFunctionDef both require the function signature to be wrapped in a call node inside arguments. def init, do: :ok (zero-arity, no parens) won't be captured as a function definition or recognized as a scope boundary. This is rare in production Elixir (parens are strongly preferred by mix format) but does appear in OTP-style code. Worth adding a fallback query pattern.

All other remaining gaps are consistent with the language feature matrix in type-resolution-system.md and are acceptable for initial Elixir support.
· PR Branch

@zander-raycraft zander-raycraft added the enhancement New feature or request label Mar 21, 2026
@magyargergo

Copy link
Copy Markdown
Collaborator

⚠️ Upcoming Prettier formatting — rebase instructions

PR #563 adds Prettier as the code formatter for the repo. When it merges, the bulk format commit will touch ~350 files (style-only: whitespace, quotes, trailing commas). Your branch will likely conflict.

After #563 merges, rebase your branch:

git fetch origin
git checkout <your-branch>
git rebase origin/main

# Conflicts will be formatting-only — accept your version:
git checkout --theirs .
git add .
git rebase --continue

# Then re-format your branch to match the new style:
npx prettier --write .
git add -A
git commit -m "style: apply prettier formatting"
git push --force-with-lease

New setup step: Run npm install at the repo root (not just in gitnexus/) to get prettier + activate the pre-commit hook. The hook auto-formats staged files on every commit going forward.

@tgroshon

tgroshon commented Apr 6, 2026

Copy link
Copy Markdown
Author

I'll revisit this in a few weeks, probably on a fresh branch after the prettier changes land.

@tgroshon tgroshon closed this Apr 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants