Elixir extractor
codegraph-elixir 0.2.0 — the Elixir extractor on the compiler’s own parser. A Mix project in extractors/elixir/, built into an escript and released as one binary that carries its runtime. It is the parser as a library: every *.ex and *.exs under the roots is parsed with Code.string_to_quoted/2, names resolve through a lexical scope and a table of what the BEAM ships, nothing is compiled, no deps/ is read, and a file the parser rejects is skipped and counted. It emits model.jsonl and nothing else: all trait, profile and validation logic lives in @codegraph/core.
brew install defsquare/tap/codegraph-elixir # Erlang and Elixir inside; Apple silicon, or the release's codegraph-elixir-linux-x64
codegraph-elixir --src <dir> --out model.jsonl
# from a clone — the escript runs wherever Erlang/OTP 27+ is on PATH
./build.sh --elixir # mix escript.build → extractors/elixir/dist/codegraph-elixir
./bin/codegraph-elixir --src <dir> --out model.jsonlThe escript is one file with Elixir embedded; the machine that runs it needs Erlang/OTP, as a jar needs a JDK. The released binary is the escript plus its own runtime in one file, so it needs nothing. Building needs Elixir 1.18+ on OTP 27+: build.sh looks for elixir on PATH, then under ~/.local/share/beam, then in asdf and mise shims; extractors/elixir/.tool-versions pins what CI builds with. Installing any extractor from the tap also installs the metrics pass.
Synopsis
codegraph-elixir [--src <dir>]… [--out <file>] [--progress auto|plain|none] [--no-progress]
[--deps <dir>] [--trace <file>] [--explain-dropped]
[--repo-remote <url>] [--repo-commit <sha>] [--repo-root <path>] [--repo-provider <p>]
[--version] [--help]Options
| Option | Meaning | Default |
|---|---|---|
--src <dir> | source root; repeatable. With several roots, anchors are relative to their deepest common ancestor, which becomes the model’s root. _build, deps, .git, node_modules and .elixir_ls are skipped: build output and dependencies are never sources | the current directory |
--out <file> | where to write the model | <current-dir>-codegraph.jsonl |
--progress <m> | auto (one line per phase when stderr is a terminal, nothing when piped), plain, none | auto |
--no-progress | same as --progress none | — |
--deps <dir> | parse dependency sources under <dir> for their exports only (names and arities); keys never change, only counts do | none, even when deps/ exists beside the roots |
--trace <file> | merge the compiler trace mix codegraph.trace wrote inside the project: calls the parser cannot see (macro-injected) become generated edges; keys never change | — |
--explain-dropped | list every dropped site on stderr (reason, file:line, name/arity); the model is unchanged | — |
--version | print the extractor version | — |
--help | print the help and exit | — |
Repository provenance
Copied verbatim into the header; the extractor runs no git — whoever invokes it supplies the facts, as snapshots does.
| Option | Meaning |
|---|---|
--repo-remote <url> | normalized https clone URL, no .git suffix |
--repo-commit <sha> | the sha this tree is at — a permalink, not a branch |
--repo-root <path> | the analyzed root RELATIVE to the repository root (default: empty — they are the same directory) |
--repo-provider <p> | github | gitlab, only when the hostname does not say |
Exit codes: 0 success · 1 failure · 2 bad usage · 3 an extraction pass not implemented yet.
How it resolves without a build
A lexical scope, as the compiler keeps it.
alias,import,requireanduseas written, the auto-alias of a nesteddefmodule,__MODULE__; a form written inside a function body applies to that body alone. A local call resolves to a definition of the enclosing module with that name and arity, else through an explicitimportthat exports it, else toKernel— and aKernelbinding is the language: resolved, counted, never an edge.An OTP table, embedded at build time. Every module and export of the Elixir and Erlang/OTP the extractor was built with —
Enumand:etsside by side, one VM — the way the Java image carries the API distilled fromct.sym. A module in the table is a stub below<otp>; a module nothing declares is a stub below<deps>; a module declared under the roots is corpus. Membership is never decided by a name prefix. The header names the versions the table was made from:{"name":"codegraph-elixir","version":"0.2.0","elixir":"1.18.4","otp":"27"}What it cannot see is code that exists only after macro expansion: what
use Ecto.Schemainjects, a Phoenix router’s routes, theassertofuse ExUnit.Case. A local call in a module thatuses a foreign module is counted aslocal_injectedand never guessed; the--traceenrichment recovers those from the compiler itself.
Streams
stdout carries nothing but --help and --version. Progress and the resolution summary go to stderr. This is the reference corpus:
✓ parse 23 files, 1 unparsed 0.1s
✓ deps 0 modules' exports 0.0s
✓ entities 181 entities 0.0s
✓ whitelist 28 modules 0.0s
✓ edges 174 edges 0.0s
✓ trace none 0.0s
✓ stubs 29 stubs 0.0s
✓ write 409 records 0.0s
unparsed (skipped): lib/acme_order/legacy/broken.ex: line 4: syntax error before: 'end'
RESOLUTION SUMMARY
references : 220
resolved : 176 (external: 54) + kernel: 25 (the language, never an edge)
dropped : 33 (dynamic_dispatch 5, field_unbound 5, local_injected 14, map_access 8, throw_dynamic 1)
resolution rate : 91.4%
imports : 31 (alias 19, import 4, require 1, use 7; external: 11)
files : 23 (.ex 19, .exs 4; unparsed: 1)
entities : 210 (stubs: 27 — <otp> 20, <deps> 7)
edges : 174 (access 26, import 29, interfaceImplementation 7, invocation 61, reference 47, throws 4); self-edges dropped: 2, dynamic module names dropped: 0
duplicates : 0 same-keyed declarations re-keyed, 0 module names declared in more than one file (the first in file order is the import target)
unclosable : 0 edges dropped (an endpoint no entity declares)
wrote model.jsonlreferences are the sites the walker read. resolved are the ones that became an edge — to a corpus entity, or to a stub (
external); a call to the language itself (length/1,+/2,case) iskernel, resolved and never an edge.dropped is everything else, under its reason — each a stated ceiling of the baseline:
Reason What it is local_injecteda local call in a module that uses a foreign module —field :sku, :stringunderuse Ecto.Schema,assertunderuse ExUnit.Case: the name was injected by a macro the parser cannot see.--tracerecovers theselocal_unbounda local call that binds to nothing at all ambiguous_importtwo foreign imports could each provide the name remote_unboundM.f/non a corpus module that declares nof/n— injected, or a typootp_unknown/deps_unknowna call the OTP table, or the --depsexports, say does not existdynamic_dispatchapply/3,mod.f()through a variable, aGenServer.call(pid, …)map_accessm.fieldon a variable — a runtime map access, not a struct fieldfield_unbound/field_external%M{k: …}where M declares no fieldk(Ecto-injected), or M is a stubprefix_aliasan aliasof a namespace prefix no module declareshandler_unbounda GenServer.call(M, …)on a corpus M that defines no handlerthrow_dynamica reraiseorraiseof a value whose module the source does not nameimports by the form written; entities with the stubs by reserved module; edges by kind, then the self-edges and the dynamic module names dropped at the source.
unparsed names every file the parser rejected: its
filerecord is still written, with nothing below it. duplicates names every same-keyed declaration re-keyed and every module name declared in more than one file. unclosable must be zero: a non-zero count names an id-scheme gap, and the edge was dropped rather than written dangling.
--explain-dropped adds one line per drop, in source order, and changes no byte of the model:
dropped dynamic_dispatch: lib/acme_order/dynamic.ex:4 apply(mod, fun, args)
dropped dynamic_dispatch: lib/acme_order/dynamic.ex:6 pricing.price(line, :web)
dropped throw_dynamic: lib/acme_order/errors.ex:16 e
dropped map_access: lib/acme_order/money.ex:13 a.amount()--deps <dir>
codegraph-elixir --src lib --deps deps --out model.jsonlreads every deps/*/lib/**/*.ex for its exports only: import Ecto.Query then binds from/2 as a fact, a call into a dependency that exports the function counts as resolved, and one it does not export is dropped as deps_unknown. No entity is emitted from deps/ and keys never change — a dependency stays a stub below <deps>.
--trace <file>: the compiler as the second reader
Everything above is read from source, before macro expansion. What a used macro injects — a Phoenix router’s routes, an Ecto schema’s fields, a __struct__/1 — exists only after the compiler expands it. When the project compiles, the compiler can be the second reader:
cd ~/src/some-app
mix codegraph.trace --out codegraph-trace.jsonl # mix compile --force under a tracer
codegraph-elixir --src . --trace codegraph-trace.jsonl --out model.jsonlmix codegraph.trace needs the task on the code path: add {:codegraph_elixir, "~> 0.2", only: :dev, runtime: false} to the project’s deps, or point the VM at a compiled checkout without touching mix.exs:
ERL_FLAGS="-pa <codegraph>/extractors/elixir/_build/dev/lib/codegraph_elixir/ebin" mix codegraph.traceThe trace is JSONL: a header naming the Elixir version and the project root, then one event per resolved call or struct expansion — file, line, the caller, the callee (module, name, arity). The extractor merges it after its own pass: an event whose site the baseline already wrote is the same fact (known); a new one becomes an edge with provenance generated, resolved through the same rules (a stub for an external callee); a call to a function the module does not declare in source (__struct__/1, an injected greet/1) has no entity to land on and is counted as injected; a call bound to Kernel is kernel. Keys never come from the trace, so a model with and without it has the same entities, and every declared edge stays exactly as it was. On Phoenix:
trace : 16797 events — 5896 generated edges added, 4140 already in the model, 6174 kernel, 36 to injected definitions (no entity), 64 outside the corpus or its declarations, 487 unresolvableThe Elixir profile
The mapping the extractor must respect, rendered from codegraph profiles --lang ex. required(kind) ⊆ traits ⊆ required(kind) ∪ optional(kind).
| Kind | Required traits | Optional traits |
|---|---|---|
attribute | TNamed, TStructural, TChildOf, TSourceAnchor | TWithValue, TWithAccesses, TWithInvocations, TComment |
callback | TNamed, TInvocable, TWithChildren, TWithParameters, TChildOf, TSourceAnchor | TComment |
field | TNamed, TStructural, TChildOf, TSourceAnchor | TWithValue, TComment |
file | TNamed, TModule, TWithChildren | TSourceAnchor, TComment, TMetrics, TWithInvocations, TWithAccesses |
function | TNamed, TInvocable, TWithChildren, TWithParameters, TWithInvocations, TWithAccesses, TChildOf, TSourceAnchor | TWithLocalVariables, TComment, TMetrics |
macro | TNamed, TInvocable, TWithChildren, TWithParameters, TWithInvocations, TWithAccesses, TChildOf, TSourceAnchor | TWithLocalVariables, TComment, TMetrics |
module | TNamed, TType, TWithChildren, TChildOf, TSourceAnchor | TWithImplements, TAttachedTo, TComment, TMetrics, TWithInvocations, TWithAccesses |
parameter | TStructural, TChildOf | TNamed, TSourceAnchor, TWithValue |
protocol | TNamed, TType, TWithChildren, TChildOf, TSourceAnchor | TComment, TMetrics |
Edge kinds emitted: import · interfaceImplementation · invocation · access · reference · throws — no inheritance, no embedding: Elixir has neither.
| Elixir construct | Kind |
|---|---|
a source file, .ex or .exs | file — the module is the file, keyed by its root-relative path; mix.exs and config/*.exs call at top level, so a file may carry TWithInvocations and TWithAccesses |
defmodule | module — a type, child of its file; a nested defmodule Inner is a second child of the file, Outer.Inner, aliased inside Outer |
defprotocol | protocol; its defs are callback children |
defimpl P, for: T | module named as the compiler names it (P.T), attachedTo T; an interfaceImplementation edge T → P at the block |
def, defp, defdelegate | function — every clause of one name/arity folded into one entity; defp carries private: true |
defmacro, defmacrop, defguard | macro |
@callback, @macrocallback, a protocol’s def | callback — no body, no invocations |
a defstruct or defexception key | field, its default as a value |
@name value, a non-reserved attribute | attribute — the module constant; @moduledoc, @doc, @spec, @impl are metadata, never entities |
| a parameter position of the folded function | parameter, named from the first clause that names it |
Arity is identity. f/1 and f/2 are unrelated functions, so a function, macro or callback always carries its arity as the disambiguator (total#1); def f(a, b \\ 1) declares f/1 and f/2 and is one entity, f#2, with defaults: 1. Key components are percent-encoded: /, # and % in every path segment and name, and . inside a module atom (AcmeOrder%2EOrder), because . separates nesting inside a symbol; the entity’s name is the atom as written.
file ex:lib%2Facme_order%2Forder.ex the path relative to the root, escaped
module ex:lib%2Facme_order%2Forder.ex/AcmeOrder%2EOrder the atom as written, its dots escaped
function, macro …/AcmeOrder%2EOrder.total#1 name, then arity — always present
defimpl module ex:lib%2Facme_order%2Fmoney.ex/String%2EChars%2EMoney the compiler's name for it
field, attribute …/AcmeOrder%2EOrder.lines …/AcmeOrder%2EOrder.@max_lines
parameter …/AcmeOrder%2EOrder.create#2#param:opts an unnamed position: by ordinal
stubs ex:<otp>/Enum ex:<otp>/ets ex:<deps>/Ecto%2EChangesetStub discipline
Corpus membership is a whitelist of the defmodules under the roots, built in a first pass; never a name-prefix test.
- A module in the embedded OTP table is a stub below
<otp>, the BEAM’s own:Enum,:ets,GenServer,Mix.Project, and the built-in protocol targets (Any,BitString,Tuple…). - A module the roots do not declare and the table does not know is a stub below
<deps>— a dependency, whichever one: with nodeps/read, the model cannot say which, and says so rather than guessing. - A stub has no members. A call to
Enum.map/2is an invocation of the stub module<otp>/Enum; a field of an external struct is never an entity. - An
aliasof a namespace prefix no module declares is dropped and counted asprefix_alias, never stubbed; a local call that binds to nothing is dropped and counted, never stubbed either. - Dynamic dispatch is reported, never guessed. A call to a corpus protocol function is an
invocationwith provenancedynamic-candidatewhose candidates are every corpusdefimpl;GenServer.call(__MODULE__, msg)is adynamic-candidateinvocation of the module’s ownhandle_call/3. Any other first argument,apply/3,mod.f(),Module.concat,send/2: dropped and counted.
Edges to stubs are kept; the internal-only view is the analyzer-side filter. alias, import, require and use fold to one import edge kind, file → file or stub module, so an external import reads as a dependency on <otp> or <deps>. See Stubs and Extracting without compiling.
Measures emitted
Carried in the TMetrics map, computed with the compiler’s own tokenizer. See Measures and literals.
| Key | On | Definition |
|---|---|---|
sloc | the file, every module and every invocable | lines of its own span that are neither blank nor comment-only, a comment being what the compiler’s parser reports as one: every non-blank line a heredoc, a sigil or a multi-line string spans is code, a # inside one included |
cyclomatic | invocables | 1 + clause heads beyond the first + case/cond/with/receive/try clauses beyond the first + if/unless + and/or/&&/|| + rescue/catch clauses |
A nested fn contributes to its enclosing invocable: an anonymous function is not an entity. A module’s complexity is the sum over its functions, which the consumer computes with sum:cyclomatic.
The metrics pass adds cognitive complexity, Halstead, ABC and the rest on top. big-code-analysis opens one space per def clause, so a function of several clauses is joined by containment and carries the charged keys only (cognitive, nexits, abc.*) — no lloc, Halstead or mi, which do not add up over clauses. defprotocol and defimpl bodies, defguard and defdelegate have no space and carry none of the pass’s keys.
Values and annotations emitted
| Fact | Carried by | Notes |
|---|---|---|
a defstruct key’s default | TWithValue on the field | |
a \\ default | TWithValue on the parameter | [], "EUR" |
@name value | TWithValue on the attribute | a literal as a value; an atom or an expression as unevaluated with its text; an array of either. The value’s own calls and reads are edges from the attribute |
@derive P | an interfaceImplementation edge, provenance generated | the language itself defines the expansion (Protocol.derive/3) |
@behaviour B, defimpl P, for: T | an interfaceImplementation edge, declared | |
use X | an import edge and a declared invocation of X.__using__#1 | what __using__ injects is absent until --trace |
@moduledoc, @doc, the # block above a definition | comments |
Profile notes
The documented blind spots, verbatim from the profile.
- The module is the file (the TypeScript rule):
TModule.definedInhas exactly one entry, and every entity’s module is the file it is written in. Adefmoduleis amoduleentity, child of its file, carrying TType — the module IS the type in Elixir (a struct is%Mod{}, a behaviour and a protocol are modules,@spec f :: Mod.t()). Nesting (defmodule Innerinsidedefmodule Outer) definesOuter.Inneras a second child of the file and aliasesInnerinsideOuter: it is an alias, not a containment. - Key components are percent-encoded:
/,#and%in every path segment and name, and.inside a module atom (Acme%2EOrder), because.is the nesting separator inside a symbol (Acme%2EOrder.total). Relying on the rule that alias segments start uppercase while function names do not would make raw dots injective fordefmodule A.Band fail fordefmodule :"a.b", which is legal; the encoding is injective by construction, and the entity’snameis the atom as written. - Arity is identity:
f/1andf/2are unrelated functions, so afunction,macroorcallbackalways carries its arity as the disambiguator (total#1).def f(a, b \\ 1)declaresf/1andf/2and is ONE entity,f#2, to which a callf(x)resolves; every clause of onef/2is folded into that entity, anchored from the first clause’s line to the last clause’send.signatureisname/arity. - No
inheritanceedge kind and noembedding: Elixir has neither class inheritance nor embedding.interfaceImplementationis emitteddeclaredfor@behaviour Band fordefimpl P, for: T(an edge T -> P anchored at the block), andgeneratedfor@derive P, whose expansion the language itself defines (Protocol.derive/3). defimpl P, for: Tis a named module on the BEAM (P.T, the compiler’s own name): it is amoduleentity keyed by that name in the file where the block is written,attachedToT — the containment-versus-attachment split of the ClojureimplBlock, with the name Elixir gives it.defimplwritten insidedefmodule Twith nofor:implements for T.- Corpus membership is a whitelist of the
defmodules under the roots, never a name-prefix test. Every other module is a stubmoduleentity below one of two reserved stub modules:<otp>for a module the BEAM ships (Elixir’s standard library and Erlang/OTP, decided by a module and export table embedded in the extractor at build time from the building toolchain —Enumand:etsside by side, one VM; the built-in protocol targetsAny,BitString,Function,PID,Port,Reference,Tupleincluded), and<deps>for a module the roots do not declare and the table does not know (a dependency, whichever one: with nodeps/read, the model cannot say which). Keys therefore never depend on what is checked out beside the corpus. A stub has no members (the C# fold): a call toEnum.map/2is aninvocationof the stub module<otp>/Enum, and a field of an external struct is never an entity. Analiasof a namespace PREFIX no module declares (alias Plausible.Stats.SQLthenSQL.Expression) is dropped and counted asprefix_alias, never stubbed. alias,import,requireanduseare oneimportedge kind, file -> the target: another corpus file when the target is declared under the roots, else the stubmodulebelow<otp>or<deps>. Folded to the module layer by the analyzer, an external import reads as a dependency on the reserved module — the honest statement that the model does not know which dependency provides it. The four forms are counted separately on stderr; the edge itself does not say which was written. A form written inside a function body applies to that body alone and is still an edge from the file.use Xis defined by the language asrequire Xfollowed byX.__using__(opts)at compile time: it yields theimportedge AND adeclaredinvocation ofX.__using__#1. What__using__injects — a@behaviour, imports, definitions — does not exist before expansion: it is absent from the baseline and every local call it would have bound is dropped and counted aslocal-unboundon stderr (a Phoenix router’s routes, an Ecto schema’s fields, aplugpipeline). The--traceenrichment recovers those asgenerated.- A local call
f(a, b)resolves in this order: adef/defp/defmacroof the enclosing module with that name and arity (defaults folded); an explicitimport Mthat exports it (a corpus M, the OTP table, or the--depsexports), most recent import first; Kernel / Kernel.SpecialForms unlessimport Kernel, except:removed it — and a Kernel binding is the LANGUAGE, resolved but never an edge (length/1,+/2, theto_string/1of a string interpolation), counted askernelon stderr; the SOLE foreignimport Mwhose exports are unknown (a stub below<deps>, counted) — unless the moduleuses a foreign module whose__using__may have injected the name, when nothing honest can be said and the site is dropped aslocal_injected; two or more candidates:ambiguous_import; none:local_unbound. A remote callM.f/non a corpus M that declares nof/n(an injected definition, a typo) is dropped asremote_unbound. - Dynamic dispatch is reported, never guessed. A call to a corpus protocol function
P.f(x)is aninvocationwith provenancedynamic-candidatewhose candidates are every corpusdefimplof P’sf;GenServer.call(Server, msg)/GenServer.cast(Server, msg)withServera statically named corpus module (__MODULE__or an alias) is, beside the invocation of<otp>/GenServer, adynamic-candidateinvocation of that module’s clause-foldedhandle_call/3/handle_cast/2— the one message-passing form that is statically honest, because the server is named in the source; a server the module does not define a handler for is dropped ashandler_unbound. Any other first argument (a pid, a registered name, a variable),apply/3,mod.f()through a variable,Module.concat,Code.eval_*andsend/2are dropped and counted asdynamic-dispatch. - Higher-order use (
&M.f/2,&f/2,&M.f(&1, x)) yields areferenceedge to the function, never aninvocation: the call happens elsewhere. A module atom in argument position (children = [MyWorker, {Registry, keys: :unique}]) is areferenceto the module — how a supervision tree surfaces.%M{…}in an expression or a pattern is areferenceto M (struct expansion) and each named key anaccesstoM.<key>(isReadin a pattern,isWritein a construction or update) — the only static field access Elixir has;s.nameis a runtime map access, dropped and counted.@attrread in a body is anaccessto theattribute. raise M,raise M, msgandreraise M, …arethrowsedges to M (RuntimeErrorbelow<otp>forraise "text");throwandexitare not exceptions and yield nothing.@spec,@type,@typepand@opaqueyieldreferenceedges to every named remote type’s module;@typeis not an entity.@moduledocand@docare the entity’scomments, as is the#comment block written directly above a definition.- A
defdelegate f(a), to: M, as: :gis afunctionwith onedeclaredinvocation ofM.g#1: the delegation is written.defpanddefare one kind — the metamodel has no visibility — andprivate: truerides as a pass-through key the container contract tolerates;defaults: nsays how many lower arities a default-carrying head also declares. - A module body is compile-time code:
use X,plug :x,schema "t" do field … end, anif Mix.env() == :test do def … end— its calls and reads are edges FROM themoduleentity (hence the optionalTWithInvocations/TWithAccesseson the kind), the calls injected macros would bind being counted rather than guessed. A module attribute’s value owns the edges it writes (the narrowest declared owner):@table :ets.new(…)invokes from theattribute,@names [Acme.A, Acme.B]references from it,@limit @max * 2reads from it. A reserved attribute (@moduledoc,@doc,@spec,@impl,@enforce_keys, …) is metadata, never an entity; a redefined attribute keeps its first definition (accumulating attributes are written many times) and the rest are counted. - Locals are deferred:
TWithLocalVariablesis licensed on invocables and not emitted in M15. Every match binds, so a local per=would multiply entities by ten for no edge the analyzer reads today; the trait stays licensed so adding them needs no profile change. - Blind spots, stated: definitions a
used macro injects and the locals that call them; callback implementations as edges (handle_call/3under@behaviour GenServer— the analyzer recovers “implements callback” frominterfaceImplementationplus name/arity, as it does for Java overrides); message passing beyond the__MODULE__form; process topology at runtime (Registry,:viatuples, dynamic supervisors);.erlfiles in a mixed corpus (an Erlang profile is its own phase). A fileCode.string_to_quoted/2rejects (a syntax error, invalid UTF-8) is skipped and counted asunparsed; itsfilerecord is still written, with no entities. - The
--traceenrichment:mix codegraph.tracerecompiles a project that COMPILES under a compilation tracer and writes every call the compiler resolved after macro expansion (remote, local, imported, macros, struct expansions) with its site and caller; the extractor merges the file after its own pass. An event the baseline already wrote at the same site is the same fact; a new one is an edge with provenancegenerated, resolved through the same rules (an external callee lands on its stub module); a call to a function the module does not declare in source (__struct__/1, an injectedgreet/1) has no entity to land on and is counted asinjected, never invented; a Kernel binding iskernel. Keys never come from the trace — a model with and without it has the same entities, and everydeclarededge stays as it was. Phoenix (205 files): 16 797 events, 5 895 generated edges added to 14 314 declared ones. - Kernel.SpecialForms are variadic (
for,try,with,casetake any number of arguments) and are matched by name alone;x :: size(8)inside a binary is a specifier, not a call. A repeated name in one head (def f(mode, mode)pins the second) names one position only; the other is keyed by ordinal. - Audit (M15c, every model
validate-clean and byte-identical to core’s encoder, nodeps/read): elixir-lang/elixirlib(557 files, 27 061 entities / 78 564 edges, 17 s) resolves 80.8 % of 164 123 sites (52 140 Kernel), the rest counted — 30 629local_injected(its own tests’assert/testunderuse ExUnit.Case,use MixTest.Case), 2 659 map accesses, 736 dynamic dispatches, 460 remote calls to injected definitions; phoenixframework/phoenix (205 files, 5 828 / 14 314, 2.3 s) 76.7 % (6 616 injected, 1 900 map accesses); plausible/analytics (1 256 files, 16 369 / 43 661, 5 s) 58.2 % (46 793 injected —use PlausibleWeb, :controller, Ecto schemas, ExUnit; 14 401 map accesses). The injected share IS the macro ceiling the trace exists for: on Phoenix,mix xref graph(148 file-level compile and runtime edges) is witnessed 147 times by the baseline alone (the miss is a module reached only throughCode.ensure_loaded?), and the trace adds 5 895generatededges. - Measures (TMetrics, METAMODEL.md §3.8):
slocon the file, every module and every invocable — lines of its own span that are neither blank nor comment-only, a comment being what the compiler’s own parser reports as one: every non-blank line a heredoc, a sigil or a multi-line string spans is code, a#inside one included;cyclomaticon invocables: 1 + clause heads beyond the first +case/cond/with/receive/tryclauses beyond the first +if/unless+and/or/&&/||+rescue/catchclauses. A nestedfncontributes to its enclosing invocable: an anonymous function is not an entity. - The metrics pass (
codegraph-metrics, PLAN.md §18): big-code-analysis opens one space perdefclause, so a function of several clauses is joined by containment and carries the charged keys only (cognitive, exits, ABC) — nolloc, Halstead ormi, which do not add up over clauses.defprotocolanddefimplbodies,defguardanddefdelegatehave no space and carry none of the pass’s keys. The join’s witness (sloc= big-code-analysis’sploc) cannot hold over a heredoc: big-code-analysis leaves a heredoc’s text lines out ofploc, which METAMODEL’ssloccounts (Phoenix’smix_app_path:sloc19,ploc13).
The reference corpus
fixtures/elixir/ is a small order-management corpus (23 files, ~260 lines) shaped like a Mix project — mix.exs, config/, lib/, test/ — and the acceptance corpus for the extractor: the snapshot in fixtures/elixir/expected/ is produced from that tree, and ./test.sh --elixir checks that the escript reproduces it byte for byte.
./bin/codegraph-elixir --src fixtures/elixir/src --out /tmp/m.jsonl --progress none \
&& cmp /tmp/m.jsonl fixtures/elixir/expected/model.jsonl && echo sameRun it from the repository root with that relative --src: the typed path is the header’s root, so an absolute one differs from the snapshot in its first line. A file’s key is then lib%2Facme_order%2Forder.ex, and its name the path as written.
It does not compile, on purpose. mix compile would fail at the first use Ecto.Schema: ecto_sql and jason are declared in mix.exs and never fetched — the ordinary legacy case. Every module those dependencies would provide is a stub below <deps>; everything the BEAM ships is a stub below <otp>. And lib/acme_order/legacy/broken.ex does not parse: the extractor skips it, counts it, and still writes its file record.
| File | Pins |
|---|---|
mix.exs | a .exs walked like an .ex; use Mix.Project → an import of <otp>/Mix.Project and an invocation of its __using__/1; defp deps carries private: true |
config/config.exs | a file with no defmodule: import Config and the top-level config/2,3 calls are edges from the file |
lib/acme_order/order.ex | arity as identity: create/2 with a default is one entity create#2; two total/1 clauses are one entity spanning both; defdelegate as a function with one invocation; @max_lines read from a guard; %__MODULE__{…} writing fields; &Money.add/2 as a reference; raise TooManyLines as a throws; pricing.price(…) dropped as dynamic_dispatch |
lib/acme_order/money.ex | defimpl inside the module → a named module String.Chars.AcmeOrder.Money, attachedTo Money; @derive Jason.Encoder → generated |
lib/acme_order/pricing.ex, pricing/standard.ex, premium.ex | a behaviour’s @callbacks as callback entities; @behaviour → interfaceImplementation to the declared module; a call through a pipe |
lib/acme_order/priceable.ex | defprotocol → protocol; three defimpl … for: blocks, one of them for: Any → <otp>/Any |
lib/acme_order/reporting.ex, stock.ex | dynamic dispatch, honestly: a protocol call with the three corpus impls as candidates; GenServer.call(AcmeOrder.Stock, :peek) → a dynamic-candidate invocation of handle_call#3; GenServer.call(pid, …) dropped |
lib/acme_order/repo.ex, schema/line.ex | use Ecto.Repo, use Ecto.Schema, import Ecto.Changeset → stubs below <deps>; field, schema, cast dropped as local_injected — the ceiling --deps and --trace lift |
lib/acme_order/macros.ex | defmacro and defguard → macro; a quote block’s calls are not edges of this module |
lib/acme_order/notifier.ex | nested defmodules as children of the file; import Kernel, except: [length: 1] → the module’s own length/1; a function-scoped alias … as: |
lib/acme_order/errors.ex | a nested defexception with fields; raise "text" → <otp>/RuntimeError; reraise e, __STACKTRACE__ dropped |
lib/acme_order/dynamic.ex | apply/3, mod.f(), GenServer.cast(pid, …) dropped and counted; Module.concat as an invocation of <otp>/Module |
lib/acme_order/application.ex | the supervision tree as references: a module atom in argument position is a value |
lib/acme_order/legacy/broken.ex | a syntax error: skipped, counted, the file record still written |
lib/acme_order/legacy/atom_module.ex | defmodule :"legacy.mod": the dot escaped in the key (legacy%2Emod), kept in the name |
lib/acme_order/two_modules.ex | two defmodules in one file; an attribute whose value is an array of unevaluated atoms |
lib/acme_order/promo#2024.ex | a # in a file name → promo%232024.ex in the key; a cross-module struct update reading and writing fields |
test/acme_order/order_test.exs | use ExUnit.Case → <otp>; a test block’s calls are invocations from the test module |
Gotcha
Point --src at the project root, not at lib/ alone, when mix.exs, config/ and test/ matter: the module is the file, so the city draws one building per module in a district per directory, and an umbrella’s apps/*/lib trees fall out as districts with no extra setup. _build, deps, .git, node_modules and .elixir_ls are skipped on their own.
| Symptom | Cause | Fix |
|---|---|---|
a large local_injected count on a Phoenix or Ecto application | the names come from macros the parser cannot expand | mix codegraph.trace inside the project, then --trace; --deps deps resolves the imports’ exports |
calls into dependencies land on <deps> with no function | a stub has no members, and no deps/ was read | expected; --deps deps changes the counts, never the keys |
erl or escript not found | the escript needs Erlang/OTP 27+ on PATH | install OTP, or use the released binary, which carries its own |
.erl files contribute nothing | an Erlang profile is its own phase | — |
the header’s root is absolute | --src was typed absolute | type it relative to the directory you run from |