Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .claude/skills/agent-eval/corpus.json
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,29 @@
"question": "How does a PUBLISH packet from an MQTT client reach the sessions of matching subscribers? Trace the flow from the connection/channel layer through the broker's routing to session delivery."
}
],
"Elixir": [
{
"name": "plug",
"repo": "https://github.com/elixir-plug/plug",
"size": "Small",
"files": "~80",
"question": "How does Plug.Builder compile a plug pipeline so a Conn reaches each plug's call/2 in order? Trace from use Plug.Builder through the compiled call/2."
},
{
"name": "phoenix",
"repo": "https://github.com/phoenixframework/phoenix",
"size": "Medium",
"files": "~240",
"question": "How does an HTTP request arriving at Phoenix.Endpoint reach a controller action through the router and the controller pipeline?"
},
{
"name": "blockscout",
"repo": "https://github.com/blockscout/blockscout",
"size": "Large",
"files": "~2500",
"question": "How does BlockScout fetch a newly imported block from the Ethereum JSON-RPC client and persist it so the block page can serve it? Trace from the indexer fetcher through the import pipeline to the schema."
}
],
"Solidity": [
{
"name": "solmate",
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### New Features

- CodeGraph now indexes **Elixir** (`.ex`, `.exs`) — modules, functions and macros (with clauses and arities of the same name grouped as one symbol), `defp` visibility, `@spec` signatures, `@doc` docs, structs with their fields, `@type`/`@opaque` aliases, protocols and implementations, plus `alias`/`import`/`require`/`use`. Cross-module `Mod.fun(...)` calls resolve through aliases, `|>` pipes and `&fun/arity` captures are followed, `apply`/`spawn` that name a module and function as arguments link to that function, `GenServer.call/cast` on this module reaches `handle_call`/`handle_cast`, and `@behaviour` names the callback module. Aliases apply in source order (including inside a function), a local call resolves to the enclosing module when a file holds more than one, an explicit `import Mod, only: [...]` no longer also binds the bare name, and each `defimpl` is its own scope so two implementations in one file no longer share callback names. Re-index Elixir projects after upgrading.

- **A busy screen's picture is laid out by the parts of the screen.** A screen is a set of handlers with no order between them, so on a hub screen the old rows-by-distance collapsed into one enormous row — the main screen of one app put 89 boxes side by side on a canvas over 28,000px wide, every line a near-horizontal sweep across all of it. The Steps tab now groups a screen's picture by region — the component that owns each handler, named in a small caption over its boxes — with each region a column where a step sits above what it sets in motion, tiled in the screen's own source order. At rest the picture hides only two things: the screen's own fan-out — one line into each region stands in for it — and lines that point back up; every other line draws where it leads, between two regions included, and selecting a step brings out its whole story in the side panel, link by link. A box nothing points at is the screen's own doing — run on render or mount, or from a binding written inline — the key says so, and selecting it lights its line from the screen with what fires it. The same app's widest screen now lays out under 3,500px with every line local, and the whole picture fits on screen when it opens. Endpoints, handlers and the in-order reading are untouched, and nothing needs a re-index: the regions come from the same walk that draws the steps.

- **Where the code chooses, the picture says so once.** A helper that ends `return (await hasSeenWelcome(id)) ? '/home/' : '/welcome/'` sends the app to one of two screens, but the Steps picture drew that as two separate arrows, each carrying the whole condition with one of them negated and both cut off at the same forty characters — and before you clicked anything, neither arrow was labelled at all, so nothing said it was a choice. Now sibling arrows out of one box that are the arms of one `if`, `switch` or ternary are drawn as the choice they are: the condition is written once under the box that decides it, and each arrow out says only which way it is — `yes`, `no`, or a case's own value. They are the only arrows labelled before you select anything, so the picture reads at a glance without becoming a wall of text. A one-sided guard — an early exit, an `if` with only one side drawn — still carries its condition on the arrow, and an arrow that is reached whether or not the condition holds never claims a side. Nothing needs a re-index: the decision is read from the source at request time.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ Every language below gets the same treatment — full structural extraction and
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/cobol.svg?v=1" width="104" height="104" alt="COBOL" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/vbnet.svg?v=1" width="104" height="104" alt="Visual Basic .NET" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/erlang.svg?v=1" width="104" height="104" alt="Erlang" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/elixir.svg?v=1" width="104" height="104" alt="Elixir" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/solidity.svg?v=1" width="104" height="104" alt="Solidity" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/terraform.svg?v=1" width="104" height="104" alt="Terraform / OpenTofu" />
<img src="https://raw.githubusercontent.com/colbymchenry/codegraph/main/assets/languages/nix.svg?v=1" width="104" height="104" alt="Nix" />
Expand Down Expand Up @@ -289,7 +290,7 @@ CodeGraph's parsing engine is a **native Rust kernel**: 20 languages — TypeScr
| **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 |
| **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes |
| **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config |
| **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
| **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, Elixir, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
| **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 17 frameworks |
| **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules |
| **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only |
Expand Down Expand Up @@ -896,6 +897,7 @@ is written):
| COBOL | `.cbl`, `.cob`, `.cpy` | Full support (programs, sections/paragraphs with PERFORM/GO TO call edges, CALL 'literal' cross-program calls, COPY copybook imports — including standalone `.cpy` files — DATA DIVISION records/fields/88-levels, EXEC CICS LINK/XCTL and EXEC SQL INCLUDE targets; fixed and free format) |
| Visual Basic .NET | `.vb` | Full support (classes, Modules, interfaces, structures, enums, properties, events, `Declare` P/Invoke, `Handles`/`WithEvents`, `Inherits`/`Implements` edges, call edges through VB's call/index paren ambiguity, `As New` instantiation, interpolated strings, LINQ, Unicode identifiers) |
| Erlang | `.erl`, `.hrl`, `.escript`, `.app.src`, `.app` | Full support (functions with multi-clause/multi-arity grouping, `-spec` signatures, records with fields, `-type`/`-opaque` aliases, `-define` macros, `-include`/`-include_lib`/`-import` edges, local and `mod:fn` remote call edges, `fun name/arity` references, `spawn`/`apply`/`proc_lib`/`timer`/`rpc` MFA-argument call edges, `gen_server:call/cast(?MODULE)` → own `handle_call`/`handle_cast` links, `-behaviour` links, `-export`-based visibility) |
| Elixir | `.ex`, `.exs` | Full support (modules with nested `defmodule`, functions and macros with multi-clause/multi-arity grouping, `defp` visibility, `@spec` signatures and `@doc`/`@moduledoc`, `defstruct` fields, `@type`/`@typep`/`@opaque` aliases, `defprotocol`/`defimpl`, `alias`/`import`/`require`/`use` edges with alias expansion on calls, local and `Mod.fun` remote call edges including `|>` pipes and `&fun/arity` captures, `apply`/`spawn` MFA-argument call edges, `GenServer.call/cast(__MODULE__)` → own `handle_call`/`handle_cast` links, `@behaviour` links, `defdelegate`) |
| Solidity | `.sol` | Full support (contracts, libraries, interfaces, structs, enums, modifiers, events, errors, state variables, `import`/`using` directives, `emit`/`revert` calls) |
| Terraform / OpenTofu | `.tf`, `.tfvars`, `.tofu` | Full support (resources, data sources, modules, variables, outputs, providers incl. aliases, `locals`; `var.`/`local.`/`module.`/resource references with Terraform's per-directory scoping enforced; module calls bridged across the boundary — inputs to the child module's variables, `module.M.out` to the child's output, `source` to the module's files; cloudposse/atmos `remote-state` cross-component wiring when the component is statically named; `provider = aws.east` selections resolved up the module tree; `moved`/`import`/`removed`/`check` block references; `.tfvars` assignments linked to the variables they set) |
| Nix | `.nix` | Full support (functions with simple/destructured/curried params, `let`/attrset bindings, `inherit`, `import ./path` file edges — `./dir` resolving through `default.nix` — plus NixOS module `imports = [ ./x.nix ]` lists and `callPackage ./pkg.nix` file edges; call edges; module-system option wiring — a config write like `launchd.user.agents.x = { ... }` links to the module declaring `options.launchd.user.agents`, so option flows trace across modules) |
Expand Down
161 changes: 161 additions & 0 deletions __tests__/elixir-arity-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/**
* Elixir arity-aware resolution.
*
* Arity is part of a function's identity: `f/1` and `f/2` are unrelated
* definitions. Extraction gives each arity its own node (`Mod::f/1`) and
* stamps refs with the call-site arity; resolution must land each ref on the
* def of exactly that arity — except default-args (`def foo(a, b \\ 1)`),
* which index as the max arity and accept a shorter call when that file
* defines exactly one function of the name.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';

describe('elixir arity-aware resolution', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'elixir-arity-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });

async function callEdges(d: string): Promise<Array<{ sq: string; tq: string }>> {
const cg = await CodeGraph.init(d, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const rows = db
.prepare(
`SELECT s.qualified_name sq, t.qualified_name tq
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE e.kind IN ('calls','references') AND s.kind = 'function'`
)
.all();
cg.destroy();
return rows;
}

it('resolves the f/N -> f/N+1 delegation to a real edge, not a self-loop', async () => {
fs.mkdirSync(path.join(dir, 'lib'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'lib', 'deleg.ex'),
`defmodule Deleg do
def header(name, req) do
header(name, req, nil)
end

def header(name, headers, default) do
Map.get(headers, name, default)
end
end
`
);
const edges = await callEdges(dir);
expect(edges).toContainEqual({ sq: 'Deleg::header/2', tq: 'Deleg::header/3' });
expect(edges.some((e) => e.sq === e.tq && e.sq.startsWith('Deleg::header'))).toBe(false);
});

it('resolves remote calls to the called arity and refuses a sibling arity', async () => {
fs.mkdirSync(path.join(dir, 'lib'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'lib', 'store.ex'),
`defmodule Store do
def get(k), do: get(k, nil)
def get(k, default), do: {k, default}
end
`
);
fs.writeFileSync(
path.join(dir, 'lib', 'client.ex'),
`defmodule Client do
def fetch(k), do: Store.get(k, nil)
def broken(k), do: Store.get(k, nil, :extra)
end
`
);
const edges = await callEdges(dir);
expect(edges).toContainEqual({ sq: 'Client::fetch/1', tq: 'Store::get/2' });
expect(edges.some((e) => e.sq === 'Client::broken/1' && e.tq.startsWith('Store::get'))).toBe(false);
});

it('resolves a shorter call onto a default-args def when it is the only arity', async () => {
fs.mkdirSync(path.join(dir, 'lib'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'lib', 'opts.ex'),
`defmodule Opts do
def get(id, opts \\\\ []) do
{id, opts}
end

def run(id), do: get(id)
end
`
);
const edges = await callEdges(dir);
expect(edges).toContainEqual({ sq: 'Opts::run/1', tq: 'Opts::get/2' });
});

it('resolves a local call to the enclosing module when the file has two modules', async () => {
fs.mkdirSync(path.join(dir, 'lib'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'lib', 'pair.ex'),
`defmodule First do
def work(x), do: {:first, x}
def run(x), do: work(x)
end

defmodule Second do
def work(x), do: {:second, x}
def run(x), do: work(x)
end
`
);
const edges = await callEdges(dir);
expect(edges).toContainEqual({ sq: 'First::run/1', tq: 'First::work/1' });
expect(edges).toContainEqual({ sq: 'Second::run/1', tq: 'Second::work/1' });
expect(edges.some((e) => e.sq === 'Second::run/1' && e.tq === 'First::work/1')).toBe(false);
expect(edges.some((e) => e.sq === 'First::run/1' && e.tq === 'Second::work/1')).toBe(false);
});

it('does not resolve an explicit import to an unrelated same-named function', async () => {
fs.mkdirSync(path.join(dir, 'lib'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'lib', 'other.ex'),
`defmodule Other do
def work(x), do: {:other, x}
end
`
);
fs.writeFileSync(
path.join(dir, 'lib', 'client.ex'),
`defmodule Client do
import Foo, only: [work: 1]
def go(x), do: work(x)
end
`
);
const edges = await callEdges(dir);
expect(edges.some((e) => e.sq === 'Client::go/1' && e.tq === 'Other::work/1')).toBe(false);
});

it('resolves aliased remote calls across files', async () => {
fs.mkdirSync(path.join(dir, 'lib'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'lib', 'repo.ex'),
`defmodule MyApp.Repo do
def get(schema, id), do: {schema, id}
end
`
);
fs.writeFileSync(
path.join(dir, 'lib', 'accounts.ex'),
`defmodule MyApp.Accounts do
alias MyApp.Repo

def get_user(id), do: Repo.get(User, id)
end
`
);
const edges = await callEdges(dir);
expect(edges).toContainEqual({ sq: 'MyApp.Accounts::get_user/1', tq: 'MyApp.Repo::get/2' });
});
});
Loading