Skip to content
Merged
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ Create `.ex_dna.exs` in your project root:
```elixir
%{
min_mass: 25,
min_occurrences: 3,
ignore: ["lib/my_app_web/templates/**"],
excluded_macros: [:schema, :pipe_through, :plug],
normalize_pipes: true
Expand All @@ -104,6 +105,7 @@ Create `.ex_dna.exs` in your project root:
| 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) |
| `literal_mode` | `--literal-mode` | `keep` | `keep` = Type-I only, `abstract` = also Type-II |
| `normalize_pipes` | `--normalize-pipes` | `false` | Treat `x \|> f()` same as `f(x)` |
Expand Down Expand Up @@ -133,6 +135,14 @@ def validate(params) do
end
```

## max_clones / min_occurrences

* `min_occurrences` → only report clone groups appearing in 3+ locations
* `max_clones` → return non-zero exit if more than 10 reportable clone groups remain

Note 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.

## Incremental detection

Add ExDNA as a compiler for automatic detection on `mix compile`:
Expand Down
2 changes: 2 additions & 0 deletions lib/ex_dna.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ defmodule ExDNA do

%{
min_mass: 30,
min_occurrences: 3,
min_similarity: 0.85,
paths: ["lib/"],
ignore: ["lib/my_app_web/templates/**"]
Expand All @@ -46,6 +47,7 @@ defmodule ExDNA do

* `:paths` — list of file/directory paths to scan (default: `["lib/"]`)
* `:min_mass` — minimum AST node count for a fragment to be considered (default: `#{Config.default(:min_mass)}`)
* `:min_occurrences` - minimum number of code occurrences to label a clone (default: `#{Config.default(:min_occurrences)}`)
* `:min_similarity` — similarity threshold 0.0–1.0 (default: `#{Config.default(:min_similarity)}`)
* `:ignore` — list of glob patterns to exclude
* `:reporters` — list of reporter modules (default: `[ExDNA.Reporter.Console]`)
Expand Down
8 changes: 8 additions & 0 deletions lib/ex_dna/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ defmodule ExDNA.Config do

%{
min_mass: 25,
min_occurrences: 3,
ignore: ["lib/my_app_web/templates/**"],
excluded_macros: [:schema, :pipe_through, :plug],
normalize_pipes: true
Expand All @@ -25,6 +26,7 @@ defmodule ExDNA.Config do
@defaults %{
paths: ["lib/"],
min_mass: 30,
min_occurrences: 2,
min_similarity: 1.0,
ignore: [],
reporters: [ExDNA.Reporter.Console],
Expand Down Expand Up @@ -67,6 +69,7 @@ defmodule ExDNA.Config do
@type t :: %__MODULE__{
paths: [String.t()],
min_mass: pos_integer(),
min_occurrences: pos_integer(),
min_similarity: float(),
ignore: [String.t()],
reporters: [module()],
Expand Down Expand Up @@ -99,6 +102,11 @@ defmodule ExDNA.Config do
raise ArgumentError, "min_mass must be a positive integer, got: #{inspect(config.min_mass)}"
end

unless is_integer(config.min_occurrences) and config.min_occurrences > 1 do
raise ArgumentError,
"min_occurrences must be an integer greater than 1, got: #{inspect(config.min_occurrences)}"
end

unless is_float(config.min_similarity) and config.min_similarity >= 0.0 and
config.min_similarity <= 1.0 do
raise ArgumentError,
Expand Down
1 change: 1 addition & 0 deletions lib/ex_dna/detection/detector.ex
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ defmodule ExDNA.Detection.Detector do
type_iii_clones = find_fuzzy_clones(fragments, exact_clones, config)

(exact_clones ++ type_iii_clones)
|> Enum.filter(&(length(&1.fragments) >= config.min_occurrences))
|> Enum.map(&Pipeline.attach_suggestion/1)
|> BehaviourSuggestion.analyze(Map.new(file_ast_pairs))
|> Enum.sort_by(& &1.mass, :desc)
Expand Down
4 changes: 4 additions & 0 deletions lib/ex_dna/integrations/credo.ex
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ if Code.ensure_loaded?(Credo.Check) do

{ExDNA.Credo, [
min_mass: 40,
min_occurrences: 3,
literal_mode: :abstract,
excluded_macros: [:schema, :pipe_through],
normalize_pipes: true,
Expand All @@ -51,6 +52,7 @@ if Code.ensure_loaded?(Credo.Check) do
tags: [:ex_dna],
param_defaults: [
min_mass: 30,
min_occurrences: 2,
literal_mode: :keep,
excluded_macros: [],
normalize_pipes: false,
Expand All @@ -76,6 +78,7 @@ if Code.ensure_loaded?(Credo.Check) do
""",
params: [
min_mass: "Minimum AST node count for a code fragment to be considered.",
min_occurrences: "Minimum number of code occurrences to label a clone",
literal_mode:
"`keep` for exact clones only (Type-I), `abstract` to also detect renamed-variable clones (Type-II).",
excluded_macros:
Expand Down Expand Up @@ -183,6 +186,7 @@ if Code.ensure_loaded?(Credo.Check) do
paths: [],
reporters: [],
min_mass: Params.get(params, :min_mass, __MODULE__),
min_occurrences: Params.get(params, :min_occurrences, __MODULE__),
literal_mode: Params.get(params, :literal_mode, __MODULE__),
excluded_macros: Params.get(params, :excluded_macros, __MODULE__),
normalize_pipes: Params.get(params, :normalize_pipes, __MODULE__),
Expand Down
5 changes: 4 additions & 1 deletion lib/mix/tasks/ex_dna.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ defmodule Mix.Tasks.ExDna do
## Command-line options

* `--min-mass` — minimum AST node count (default: 30)
* `--min-occurrences` — minimum number of code occurrences to report a clone (default: 2)
* `--min-similarity` — similarity threshold 0.0–1.0 (default: 1.0).
Values below 1.0 enable Type-III near-miss detection.
* `--literal-mode` — `keep` (Type-I only) or `abstract` (also Type-II). Default: `keep`
Expand Down Expand Up @@ -38,6 +39,7 @@ defmodule Mix.Tasks.ExDna do
OptionParser.parse(argv,
strict: [
min_mass: :integer,
min_occurrences: :integer,
min_similarity: :float,
literal_mode: :string,
normalize_pipes: :boolean,
Expand All @@ -47,7 +49,7 @@ defmodule Mix.Tasks.ExDna do
format: :string,
max_clones: :integer
],
aliases: [m: :min_mass, s: :min_similarity, i: :ignore, f: :format]
aliases: [m: :min_mass, o: :min_occurrences, s: :min_similarity, i: :ignore, f: :format]
)

config_opts = build_config(opts, paths)
Expand Down Expand Up @@ -120,6 +122,7 @@ defmodule Mix.Tasks.ExDna do
]
|> Options.maybe_put(:ignore, ignored_paths)
|> Options.maybe_put(:min_mass, Keyword.get(opts, :min_mass))
|> Options.maybe_put(:min_occurrences, Keyword.get(opts, :min_occurrences))
|> Options.maybe_put(:min_similarity, Keyword.get(opts, :min_similarity))
|> Options.maybe_put(:excluded_macros, excluded_macros)
|> Options.maybe_put(:ignored_attributes, ignored_attributes)
Expand Down
4 changes: 3 additions & 1 deletion lib/mix/tasks/ex_dna.explain.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ defmodule Mix.Tasks.ExDna.Explain do
OptionParser.parse(argv,
strict: [
min_mass: :integer,
min_occurrences: :integer,
min_similarity: :float,
literal_mode: :string,
normalize_pipes: :boolean,
ignore: :keep
],
aliases: [m: :min_mass, s: :min_similarity, i: :ignore]
aliases: [m: :min_mass, o: :min_occurrences, s: :min_similarity, i: :ignore]
)

clone_index =
Expand All @@ -50,6 +51,7 @@ defmodule Mix.Tasks.ExDna.Explain do
]
|> Options.maybe_put(:ignore, Options.optional_values(opts, :ignore))
|> Options.maybe_put(:min_mass, Keyword.get(opts, :min_mass))
|> Options.maybe_put(:min_occurrences, Keyword.get(opts, :min_occurrences))
|> Options.maybe_put(:min_similarity, Keyword.get(opts, :min_similarity))

report = ExDNA.analyze(config_opts)
Expand Down
1 change: 1 addition & 0 deletions test/ex_dna/config_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ defmodule ExDNA.ConfigTest do
test "applies defaults" do
config = Config.new([])
assert config.min_mass == 30
assert config.min_occurrences == 2
assert config.min_similarity == 1.0
assert config.paths == ["lib/"]
assert config.excluded_macros == []
Expand Down
56 changes: 56 additions & 0 deletions test/ex_dna/detection/detector_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,62 @@ defmodule ExDNA.Detection.DetectorTest do
assert clones == []
end

test "respects min_occurrences", %{dir: dir} do
write_fixture(dir, "a.ex", """
defmodule A do
def process(data) do
data
|> Enum.map(fn x -> x * 2 end)
|> Enum.filter(fn x -> x > 10 end)
|> Enum.sort()
|> Enum.take(5)
end

def cloned_only_twice(data) do
data
|> Enum.map(fn x -> x * 4 end)
|> Enum.sort()
end
end
""")

write_fixture(dir, "b.ex", """
defmodule B do
def process(data) do
data
|> Enum.map(fn x -> x * 2 end)
|> Enum.filter(fn x -> x > 10 end)
|> Enum.sort()
|> Enum.take(5)
end

def cloned_only_twice(data) do
data
|> Enum.map(fn x -> x * 4 end)
|> Enum.sort()
end
end
""")

write_fixture(dir, "c.ex", """
defmodule C do
def process(data) do
data
|> Enum.map(fn x -> x * 2 end)
|> Enum.filter(fn x -> x > 10 end)
|> Enum.sort()
|> Enum.take(5)
end
end
""")

config = Config.new(paths: [dir], min_mass: 5, reporters: [], min_occurrences: 3)
{clones, _} = Detector.run(config)

fragments = Enum.map(clones, &length(&1.fragments))
assert fragments == [3, 3, 3], "should only detect clones with 3 repetitions or more"
end

test "reports real-world controller clones with differing callees without suggesting extraction",
%{
dir: dir
Expand Down
Loading