Code duplication detector powered by Elixir AST analysis
Elixir
116
106 commits
updated Aug 11, 2026
Code duplication detector for Elixir, inspired by jscpd but built on Elixir's native AST instead of token matching.
Because ExDNA understands code structure β not just text β
fn(a, b) -> a + b end and fn(x, y) -> x + y end are recognized as the
same code. It also tells you how to fix each clone: extract a function, a
macro, or a behaviour callback.
def/defp clauses with the same
name/arity are analyzed as a single unit, catching duplicated pattern-matching
functions that individual clauses are too small to flagdef foo(x), do: foo(x, []) followed by
def foo(x, opts) are grouped as one unit, catching duplicated wrapper+body
pairs across modules@callbackbuild_changeset, contact_step) instead of
extracted_functionx |> f() and f(x) match as the same code%User{name: x, age: y} and
%User{age: y, name: x} match in Type-II modewhen is_binary(x) and when is_atom(x) match
in Type-II mode (covers Kernel guards, defguard, library guards)&&/||/! match and/or/not~w(foo bar)a matches [:foo, :bar]actions/ β tools/ (6 clones, 298 nodes)
instead of listing each pairMix.Task.Compiler β reuses complete results when sources are unchangedDuplicatedCode, reuses
Credo's parsed ASTs--max-clones for a clone budgetdef deps do
[{:ex_dna, "~> 1.5", only: [:dev, :test], runtime: false}]
end
mix ex_dna # scan lib/
mix ex_dna lib/accounts lib/admin # specific paths
mix ex_dna --literal-mode abstract # enable literal abstraction for Type-II
mix ex_dna --min-similarity 0.85 # enable Type-III (near-miss)
mix ex_dna --min-fuzzy-mass 80 # require larger Type-III candidates
mix ex_dna --min-mass 50 # fewer, larger clones
mix ex_dna --max-clones 10 # fail only above budget
mix ex_dna --format json # machine-readable
mix ex_dna --cache --format json # reuse unchanged analysis results
mix ex_dna --format html # browsable report
mix ex_dna --format sarif # GitHub Code Scanning
Deep-dive into a specific clone:
mix ex_dna.explain 3
mix ex_dna.explain 3 lib/accounts --min-mass 20
Shows the full anti-unification breakdown β common structure, divergence
points, and the suggested extraction with call sites. Pass the same paths and
detection flags you used for mix ex_dna to keep clone numbering aligned.
mix ex_dna --min-mass 40
Start with exact and renamed-variable clones, then opt into broader matching as needed:
mix ex_dna --literal-mode abstract # include changed literals (Type II)
mix ex_dna --min-similarity 0.85 # include near-miss clones (Type III)
mix ex_dna --normalize-pipes # compare pipe chains and nested calls
For noisy brownfield projects, raise --min-mass or --min-fuzzy-mass, and use
--max-clones as a clone budget while paying down duplication.
report = ExDNA.analyze("lib/")
report = ExDNA.analyze(["lib/", "test/"])
report = ExDNA.analyze(paths: ["lib/"], min_mass: 20, literal_mode: :abstract)
report.clones #=> [%ExDNA.Detection.Clone{}, ...]
report.stats #=> %{files_analyzed: 42, total_clones: 3, ...}
Options are layered: defaults β .ex_dna.exs β CLI flags.
Create .ex_dna.exs in your project root:
%{
min_mass: 25,
min_occurrences: 3,
ignore: ["lib/my_app_web/templates/**"],
excluded_macros: [:schema, :pipe_through, :plug],
normalize_pipes: true
}
| Option | CLI flag | Default | Description |
|---|---|---|---|
min_mass | --min-mass | 30 | Minimum AST nodes for a fragment |
min_occurrences | --min-occurrences | 2 | Minimum number of code occurrences to label a clone |
min_similarity | --min-similarity | 1.0 | Threshold for Type-III (set < 1.0 to enable) |
min_fuzzy_mass | --min-fuzzy-mass | min_mass * 2 | Minimum AST nodes for Type-III candidates |
literal_mode | --literal-mode | keep | keep = exact + renamed-variable clones, abstract = also changed-literal clones |
normalize_pipes | --normalize-pipes | false | Treat x |> f() same as f(x) |
excluded_macros | --exclude-macro | [] | Macro calls to skip entirely |
ignored_attributes | --ignore-attribute | (see below) | Module attribute names to skip |
max_module_forms | --max-module-forms | 200 | Max top-level forms eligible for sibling-window detection |
parse_timeout | β | 5000 | Max ms per file (kills hung parses) |
ignore | --ignore | [] | Glob patterns to exclude |
| β | --max-clones | β | Clone budget (exit 1 only above this) |
| β | --format | console | console, json, html, or sarif |
output_file | --output | format default | Output path for HTML/SARIF reports |
Default ignored attributes: all of Elixir's
reserved attributes
(moduledoc, doc, spec, type, impl, behaviour, derive, etc.).
Custom module attributes like @extensions, @timeout, or @fields are
fingerprinted and will be reported as duplicates when they appear with the
same value in multiple modules.
Use Credo-style comments when a duplicate is intentional:
defmodule MyApp.Validator do
# ex_dna:disable-for-next-line
def validate(params) do
# intentional duplication, won't be flagged
end
end
Supported comments:
# ex_dna:disable-for-this-file
# ex_dna:disable-for-next-line
# ex_dna:disable-for-previous-line
# ex_dna:disable-for-lines:3
min_occurrences β only report clone groups appearing in 3+ locationsmax_clones β return non-zero exit if more than 10 reportable clone groups remainNote that max_clones applies after report filters like min_occurrences so
clones that were not reported due to min_occurrences are not counted towards the max_clones budget.
Add ExDNA as a compiler for automatic detection on mix compile:
def project do
[compilers: Mix.compilers() ++ [:ex_dna]]
end
Source digests validate the cached result. When no source changed, ExDNA returns
the previous clone groups without rewriting the cache or rerunning detection.
When a source changes, ExDNA reruns normal analysis and replaces the result.
The cache is stored in .ex_dna_cache (add it to .gitignore).
The same cache is available to standalone checks while preserving all output formats and explicit paths:
mix ex_dna lib/ test/ --cache --format json
ExDNA ships an LSP server that pushes warnings inline on every save. It runs alongside your primary Elixir LSP.
mix ex_dna.lsp
vim.lsp.config('ex_dna', {
cmd = { 'mix', 'ex_dna.lsp' },
root_markers = { 'mix.exs' },
filetypes = { 'elixir' },
})
ExDNA ships a Credo check that replaces the built-in DuplicatedCode with
full Type-I/II/III detection and refactoring suggestions. It reuses Credo's
already-parsed ASTs β no double parsing.
Use as a Credo plugin (recommended) β automatically registers the check and
disables the built-in DuplicatedCode:
# .credo.exs
%{
configs: [
%{
name: "default",
plugins: [{ExDNA.Credo, []}]
}
]
}
Or add directly to the :enabled checks list:
{ExDNA.Credo, []}
And disable the built-in check:
{Credo.Check.Design.DuplicatedCode, false}
All ExDNA options are available as check/plugin params. By default the Credo check uses the same path scope as mix ex_dna (lib/); pass paths: ["lib/", "test/"] if you want Credo to include test files too.
{ExDNA.Credo, [
paths: ["lib/", "test/"],
min_mass: 40,
literal_mode: :abstract,
excluded_macros: [:schema, :pipe_through],
normalize_pipes: true,
min_similarity: 0.85
]}
Code.string_to_quoted/2 on every .ex/.exs file (parallel,
with per-file timeout)$0, $1) β optionally abstract literals β optionally
flatten pipes β sort struct/map fieldsmin_mass nodes, hash with
BLAKE2b; also generate sliding windows over module-level sibling sequences
and compute structural sub-hashes for fuzzy candidate pruningExDNA finds duplicated Elixir code by structure and computes the canonical extraction for each clone family.
It is one building block of a larger stack β tools that make AI-generated software checkable: structural search, dependence analysis, duplication and slop detection, session replay, and ecosystem-wide code search. See the Elixir Vibe organization for the rest, and Building Blocks for the Future Web for the thesis, architecture, and roadmap that tie them together.
Elixir
99.0%
Code duplication detector powered by Elixir AST analysis
Elixir
116
106 commits
updated Aug 11, 2026
Code duplication detector for Elixir, inspired by jscpd but built on Elixir's native AST instead of token matching.
Because ExDNA understands code structure β not just text β
fn(a, b) -> a + b end and fn(x, y) -> x + y end are recognized as the
same code. It also tells you how to fix each clone: extract a function, a
macro, or a behaviour callback.
def/defp clauses with the same
name/arity are analyzed as a single unit, catching duplicated pattern-matching
functions that individual clauses are too small to flagdef foo(x), do: foo(x, []) followed by
def foo(x, opts) are grouped as one unit, catching duplicated wrapper+body
pairs across modules@callbackbuild_changeset, contact_step) instead of
extracted_functionx |> f() and f(x) match as the same code%User{name: x, age: y} and
%User{age: y, name: x} match in Type-II modewhen is_binary(x) and when is_atom(x) match
in Type-II mode (covers Kernel guards, defguard, library guards)&&/||/! match and/or/not~w(foo bar)a matches [:foo, :bar]actions/ β tools/ (6 clones, 298 nodes)
instead of listing each pairMix.Task.Compiler β reuses complete results when sources are unchangedDuplicatedCode, reuses
Credo's parsed ASTs--max-clones for a clone budgetdef deps do
[{:ex_dna, "~> 1.5", only: [:dev, :test], runtime: false}]
end
mix ex_dna # scan lib/
mix ex_dna lib/accounts lib/admin # specific paths
mix ex_dna --literal-mode abstract # enable literal abstraction for Type-II
mix ex_dna --min-similarity 0.85 # enable Type-III (near-miss)
mix ex_dna --min-fuzzy-mass 80 # require larger Type-III candidates
mix ex_dna --min-mass 50 # fewer, larger clones
mix ex_dna --max-clones 10 # fail only above budget
mix ex_dna --format json # machine-readable
mix ex_dna --cache --format json # reuse unchanged analysis results
mix ex_dna --format html # browsable report
mix ex_dna --format sarif # GitHub Code Scanning
Deep-dive into a specific clone:
mix ex_dna.explain 3
mix ex_dna.explain 3 lib/accounts --min-mass 20
Shows the full anti-unification breakdown β common structure, divergence
points, and the suggested extraction with call sites. Pass the same paths and
detection flags you used for mix ex_dna to keep clone numbering aligned.
mix ex_dna --min-mass 40
Start with exact and renamed-variable clones, then opt into broader matching as needed:
mix ex_dna --literal-mode abstract # include changed literals (Type II)
mix ex_dna --min-similarity 0.85 # include near-miss clones (Type III)
mix ex_dna --normalize-pipes # compare pipe chains and nested calls
For noisy brownfield projects, raise --min-mass or --min-fuzzy-mass, and use
--max-clones as a clone budget while paying down duplication.
report = ExDNA.analyze("lib/")
report = ExDNA.analyze(["lib/", "test/"])
report = ExDNA.analyze(paths: ["lib/"], min_mass: 20, literal_mode: :abstract)
report.clones #=> [%ExDNA.Detection.Clone{}, ...]
report.stats #=> %{files_analyzed: 42, total_clones: 3, ...}
Options are layered: defaults β .ex_dna.exs β CLI flags.
Create .ex_dna.exs in your project root:
%{
min_mass: 25,
min_occurrences: 3,
ignore: ["lib/my_app_web/templates/**"],
excluded_macros: [:schema, :pipe_through, :plug],
normalize_pipes: true
}
| Option | CLI flag | Default | Description |
|---|---|---|---|
min_mass | --min-mass | 30 | Minimum AST nodes for a fragment |
min_occurrences | --min-occurrences | 2 | Minimum number of code occurrences to label a clone |
min_similarity | --min-similarity | 1.0 | Threshold for Type-III (set < 1.0 to enable) |
min_fuzzy_mass | --min-fuzzy-mass | min_mass * 2 | Minimum AST nodes for Type-III candidates |
literal_mode | --literal-mode | keep | keep = exact + renamed-variable clones, abstract = also changed-literal clones |
normalize_pipes | --normalize-pipes | false | Treat x |> f() same as f(x) |
excluded_macros | --exclude-macro | [] | Macro calls to skip entirely |
ignored_attributes | --ignore-attribute | (see below) | Module attribute names to skip |
max_module_forms | --max-module-forms | 200 | Max top-level forms eligible for sibling-window detection |
parse_timeout | β | 5000 | Max ms per file (kills hung parses) |
ignore | --ignore | [] | Glob patterns to exclude |
| β | --max-clones | β | Clone budget (exit 1 only above this) |
| β | --format | console | console, json, html, or sarif |
output_file | --output | format default | Output path for HTML/SARIF reports |
Default ignored attributes: all of Elixir's
reserved attributes
(moduledoc, doc, spec, type, impl, behaviour, derive, etc.).
Custom module attributes like @extensions, @timeout, or @fields are
fingerprinted and will be reported as duplicates when they appear with the
same value in multiple modules.
Use Credo-style comments when a duplicate is intentional:
defmodule MyApp.Validator do
# ex_dna:disable-for-next-line
def validate(params) do
# intentional duplication, won't be flagged
end
end
Supported comments:
# ex_dna:disable-for-this-file
# ex_dna:disable-for-next-line
# ex_dna:disable-for-previous-line
# ex_dna:disable-for-lines:3
min_occurrences β only report clone groups appearing in 3+ locationsmax_clones β return non-zero exit if more than 10 reportable clone groups remainNote that max_clones applies after report filters like min_occurrences so
clones that were not reported due to min_occurrences are not counted towards the max_clones budget.
Add ExDNA as a compiler for automatic detection on mix compile:
def project do
[compilers: Mix.compilers() ++ [:ex_dna]]
end
Source digests validate the cached result. When no source changed, ExDNA returns
the previous clone groups without rewriting the cache or rerunning detection.
When a source changes, ExDNA reruns normal analysis and replaces the result.
The cache is stored in .ex_dna_cache (add it to .gitignore).
The same cache is available to standalone checks while preserving all output formats and explicit paths:
mix ex_dna lib/ test/ --cache --format json
ExDNA ships an LSP server that pushes warnings inline on every save. It runs alongside your primary Elixir LSP.
mix ex_dna.lsp
vim.lsp.config('ex_dna', {
cmd = { 'mix', 'ex_dna.lsp' },
root_markers = { 'mix.exs' },
filetypes = { 'elixir' },
})
ExDNA ships a Credo check that replaces the built-in DuplicatedCode with
full Type-I/II/III detection and refactoring suggestions. It reuses Credo's
already-parsed ASTs β no double parsing.
Use as a Credo plugin (recommended) β automatically registers the check and
disables the built-in DuplicatedCode:
# .credo.exs
%{
configs: [
%{
name: "default",
plugins: [{ExDNA.Credo, []}]
}
]
}
Or add directly to the :enabled checks list:
{ExDNA.Credo, []}
And disable the built-in check:
{Credo.Check.Design.DuplicatedCode, false}
All ExDNA options are available as check/plugin params. By default the Credo check uses the same path scope as mix ex_dna (lib/); pass paths: ["lib/", "test/"] if you want Credo to include test files too.
{ExDNA.Credo, [
paths: ["lib/", "test/"],
min_mass: 40,
literal_mode: :abstract,
excluded_macros: [:schema, :pipe_through],
normalize_pipes: true,
min_similarity: 0.85
]}
Code.string_to_quoted/2 on every .ex/.exs file (parallel,
with per-file timeout)$0, $1) β optionally abstract literals β optionally
flatten pipes β sort struct/map fieldsmin_mass nodes, hash with
BLAKE2b; also generate sliding windows over module-level sibling sequences
and compute structural sub-hashes for fuzzy candidate pruningExDNA finds duplicated Elixir code by structure and computes the canonical extraction for each clone family.
It is one building block of a larger stack β tools that make AI-generated software checkable: structural search, dependence analysis, duplication and slop detection, session replay, and ecosystem-wide code search. See the Elixir Vibe organization for the rest, and Building Blocks for the Future Web for the thesis, architecture, and roadmap that tie them together.
Elixir
99.0%