From 535e20609909c7ac053d04c3175f693cf3374fba Mon Sep 17 00:00:00 2001 From: Neer Friedman Date: Fri, 1 May 2026 15:34:56 +0300 Subject: [PATCH 1/5] feature: allowed repetitions --- lib/ex_dna/config.ex | 1 + lib/ex_dna/detection/detector.ex | 1 + test/ex_dna/detection/detector_test.exs | 56 +++++++++++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/lib/ex_dna/config.ex b/lib/ex_dna/config.ex index 7a92936..005f3e1 100644 --- a/lib/ex_dna/config.ex +++ b/lib/ex_dna/config.ex @@ -31,6 +31,7 @@ defmodule ExDNA.Config do literal_mode: :keep, normalize_pipes: false, excluded_macros: [], + allowed_repetitions: 1, ignored_attributes: [ :moduledoc, :doc, diff --git a/lib/ex_dna/detection/detector.ex b/lib/ex_dna/detection/detector.ex index fd7a6ab..7b7f8a9 100644 --- a/lib/ex_dna/detection/detector.ex +++ b/lib/ex_dna/detection/detector.ex @@ -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.allowed_repetitions)) |> Enum.map(&Pipeline.attach_suggestion/1) |> BehaviourSuggestion.analyze(Map.new(file_ast_pairs)) |> Enum.sort_by(& &1.mass, :desc) diff --git a/test/ex_dna/detection/detector_test.exs b/test/ex_dna/detection/detector_test.exs index b200503..9227cbf 100644 --- a/test/ex_dna/detection/detector_test.exs +++ b/test/ex_dna/detection/detector_test.exs @@ -132,6 +132,62 @@ defmodule ExDNA.Detection.DetectorTest do assert clones == [] end + test "respects allowed_repetitions", %{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: [], allowed_repetitions: 2) + {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 From d30f4acc24c82b1ee184a02f13017e202f3f4816 Mon Sep 17 00:00:00 2001 From: Neer Friedman Date: Fri, 1 May 2026 15:47:00 +0300 Subject: [PATCH 2/5] fix formatting --- test/ex_dna/detection/detector_test.exs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/ex_dna/detection/detector_test.exs b/test/ex_dna/detection/detector_test.exs index 9227cbf..f631f6c 100644 --- a/test/ex_dna/detection/detector_test.exs +++ b/test/ex_dna/detection/detector_test.exs @@ -132,7 +132,7 @@ defmodule ExDNA.Detection.DetectorTest do assert clones == [] end - test "respects allowed_repetitions", %{dir: dir} do + test "respects allowed_repetitions", %{dir: dir} do write_fixture(dir, "a.ex", """ defmodule A do def process(data) do @@ -184,8 +184,8 @@ defmodule ExDNA.Detection.DetectorTest do config = Config.new(paths: [dir], min_mass: 5, reporters: [], allowed_repetitions: 2) {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" + 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", From bda3def8679b3ad65f474e1acbe437e37e7e3175 Mon Sep 17 00:00:00 2001 From: Neer Friedman Date: Fri, 1 May 2026 23:17:11 +0300 Subject: [PATCH 3/5] change opt name and add docs --- README.md | 2 ++ lib/ex_dna.ex | 2 ++ lib/ex_dna/config.ex | 9 ++++++++- lib/ex_dna/detection/detector.ex | 2 +- lib/ex_dna/integrations/credo.ex | 4 ++++ lib/mix/tasks/ex_dna.ex | 4 +++- lib/mix/tasks/ex_dna.explain.ex | 4 +++- test/ex_dna/config_test.exs | 1 + test/ex_dna/detection/detector_test.exs | 4 ++-- 9 files changed, 26 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 618e725..8fcf10b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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)` | diff --git a/lib/ex_dna.ex b/lib/ex_dna.ex index d86c246..b91f3f0 100644 --- a/lib/ex_dna.ex +++ b/lib/ex_dna.ex @@ -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/**"] @@ -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]`) diff --git a/lib/ex_dna/config.ex b/lib/ex_dna/config.ex index 005f3e1..615c86f 100644 --- a/lib/ex_dna/config.ex +++ b/lib/ex_dna/config.ex @@ -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 @@ -25,13 +26,13 @@ defmodule ExDNA.Config do @defaults %{ paths: ["lib/"], min_mass: 30, + min_occurrences: 2, min_similarity: 1.0, ignore: [], reporters: [ExDNA.Reporter.Console], literal_mode: :keep, normalize_pipes: false, excluded_macros: [], - allowed_repetitions: 1, ignored_attributes: [ :moduledoc, :doc, @@ -68,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()], @@ -100,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, diff --git a/lib/ex_dna/detection/detector.ex b/lib/ex_dna/detection/detector.ex index 7b7f8a9..c033a99 100644 --- a/lib/ex_dna/detection/detector.ex +++ b/lib/ex_dna/detection/detector.ex @@ -67,7 +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.allowed_repetitions)) + |> 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) diff --git a/lib/ex_dna/integrations/credo.ex b/lib/ex_dna/integrations/credo.ex index 0a9c7bd..0849110 100644 --- a/lib/ex_dna/integrations/credo.ex +++ b/lib/ex_dna/integrations/credo.ex @@ -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, @@ -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, @@ -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: @@ -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__), diff --git a/lib/mix/tasks/ex_dna.ex b/lib/mix/tasks/ex_dna.ex index 5daff6e..4861d34 100644 --- a/lib/mix/tasks/ex_dna.ex +++ b/lib/mix/tasks/ex_dna.ex @@ -38,6 +38,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, @@ -47,7 +48,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) @@ -120,6 +121,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) diff --git a/lib/mix/tasks/ex_dna.explain.ex b/lib/mix/tasks/ex_dna.explain.ex index d709ee6..0b0f7b8 100644 --- a/lib/mix/tasks/ex_dna.explain.ex +++ b/lib/mix/tasks/ex_dna.explain.ex @@ -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 = @@ -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) diff --git a/test/ex_dna/config_test.exs b/test/ex_dna/config_test.exs index 053197e..56c3aa2 100644 --- a/test/ex_dna/config_test.exs +++ b/test/ex_dna/config_test.exs @@ -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 == [] diff --git a/test/ex_dna/detection/detector_test.exs b/test/ex_dna/detection/detector_test.exs index f631f6c..9060630 100644 --- a/test/ex_dna/detection/detector_test.exs +++ b/test/ex_dna/detection/detector_test.exs @@ -132,7 +132,7 @@ defmodule ExDNA.Detection.DetectorTest do assert clones == [] end - test "respects allowed_repetitions", %{dir: dir} do + test "respects min_occurrences", %{dir: dir} do write_fixture(dir, "a.ex", """ defmodule A do def process(data) do @@ -181,7 +181,7 @@ defmodule ExDNA.Detection.DetectorTest do end """) - config = Config.new(paths: [dir], min_mass: 5, reporters: [], allowed_repetitions: 2) + config = Config.new(paths: [dir], min_mass: 5, reporters: [], min_occurrences: 3) {clones, _} = Detector.run(config) fragments = Enum.map(clones, &length(&1.fragments)) From c01987f89ffc9b639add5fac4bf3fef20ce929e8 Mon Sep 17 00:00:00 2001 From: Neer Friedman Date: Sat, 2 May 2026 00:51:06 +0300 Subject: [PATCH 4/5] add README section on max_clones and min_occurrences --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 8fcf10b..1cdceca 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,15 @@ 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. + +applies after report filters like min_occurrences ## Incremental detection Add ExDNA as a compiler for automatic detection on `mix compile`: From 79c8c8d6502444c037c83cfae77224ea34a1b870 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 2 May 2026 10:29:16 +0300 Subject: [PATCH 5/5] Clean up min_occurrences docs --- README.md | 3 +-- lib/mix/tasks/ex_dna.ex | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1cdceca..6fab9e0 100644 --- a/README.md +++ b/README.md @@ -105,7 +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_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)` | @@ -143,7 +143,6 @@ end 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. -applies after report filters like min_occurrences ## Incremental detection Add ExDNA as a compiler for automatic detection on `mix compile`: diff --git a/lib/mix/tasks/ex_dna.ex b/lib/mix/tasks/ex_dna.ex index 4861d34..37d2077 100644 --- a/lib/mix/tasks/ex_dna.ex +++ b/lib/mix/tasks/ex_dna.ex @@ -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`