hashira 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +141 -0
  3. data/README.md +53 -5
  4. data/lib/hashira/analysis/catalog.rb +27 -0
  5. data/lib/hashira/analysis/census.rb +42 -35
  6. data/lib/hashira/analysis/constant_registry.rb +43 -0
  7. data/lib/hashira/analysis/cycle_findings.rb +34 -29
  8. data/lib/hashira/analysis/cycle_search.rb +28 -35
  9. data/lib/hashira/analysis/cycles.rb +14 -0
  10. data/lib/hashira/analysis/definition.rb +22 -0
  11. data/lib/hashira/analysis/definitions.rb +22 -18
  12. data/lib/hashira/analysis/edge.rb +4 -3
  13. data/lib/hashira/analysis/edge_map.rb +23 -26
  14. data/lib/hashira/analysis/finding.rb +7 -6
  15. data/lib/hashira/analysis/folder_placement.rb +17 -0
  16. data/lib/hashira/analysis/folding.rb +54 -0
  17. data/lib/hashira/analysis/graph.rb +35 -38
  18. data/lib/hashira/analysis/metric.rb +8 -7
  19. data/lib/hashira/analysis/namespace_placement.rb +28 -0
  20. data/lib/hashira/analysis/namespace_prefix.rb +32 -0
  21. data/lib/hashira/analysis/naming.rb +14 -0
  22. data/lib/hashira/analysis/no_folding.rb +13 -0
  23. data/lib/hashira/analysis/node_walk.rb +3 -3
  24. data/lib/hashira/analysis/placement.rb +19 -0
  25. data/lib/hashira/analysis/references.rb +14 -13
  26. data/lib/hashira/analysis/resolver.rb +28 -0
  27. data/lib/hashira/analysis/roster.rb +25 -0
  28. data/lib/hashira/analysis/rule.rb +10 -14
  29. data/lib/hashira/analysis/sdp_check.rb +8 -12
  30. data/lib/hashira/analysis/sdp_violation_findings.rb +20 -18
  31. data/lib/hashira/analysis/syntax.rb +21 -6
  32. data/lib/hashira/analysis/type_walk.rb +7 -9
  33. data/lib/hashira/churn.rb +11 -13
  34. data/lib/hashira/ci/accepted.rb +27 -31
  35. data/lib/hashira/ci/baseline.rb +22 -24
  36. data/lib/hashira/ci/diff.rb +8 -7
  37. data/lib/hashira/ci/edge_diff_report.rb +13 -17
  38. data/lib/hashira/ci/finding_diff_report.rb +14 -18
  39. data/lib/hashira/ci/gate.rb +19 -23
  40. data/lib/hashira/ci/improvement.rb +9 -14
  41. data/lib/hashira/ci/ratchet.rb +36 -27
  42. data/lib/hashira/ci/ratchet_report.rb +26 -32
  43. data/lib/hashira/cli/arguments.rb +23 -0
  44. data/lib/hashira/cli/command_line.rb +55 -71
  45. data/lib/hashira/cli/fail_on.rb +15 -20
  46. data/lib/hashira/cli/options.rb +9 -4
  47. data/lib/hashira/cli/package_by.rb +14 -0
  48. data/lib/hashira/cli/run.rb +29 -27
  49. data/lib/hashira/cli/skip.rb +14 -20
  50. data/lib/hashira/cli/usage.rb +41 -41
  51. data/lib/hashira/cli.rb +17 -19
  52. data/lib/hashira/complexity/analyzer.rb +30 -32
  53. data/lib/hashira/complexity/boolean_run.rb +11 -15
  54. data/lib/hashira/complexity/cognitive_score.rb +61 -66
  55. data/lib/hashira/complexity/if_chain.rb +33 -38
  56. data/lib/hashira/complexity/method_finding.rb +43 -48
  57. data/lib/hashira/complexity/method_score.rb +9 -7
  58. data/lib/hashira/complexity/rescue_scan.rb +16 -21
  59. data/lib/hashira/complexity/rollup.rb +13 -15
  60. data/lib/hashira/diagram/dot.rb +8 -11
  61. data/lib/hashira/diagram/mermaid.rb +11 -16
  62. data/lib/hashira/diagram/renderer.rb +10 -15
  63. data/lib/hashira/duplication/analyzer.rb +10 -14
  64. data/lib/hashira/duplication/cluster.rb +15 -12
  65. data/lib/hashira/duplication/clusterer.rb +31 -29
  66. data/lib/hashira/duplication/delta.rb +29 -30
  67. data/lib/hashira/duplication/duplication_finding.rb +19 -21
  68. data/lib/hashira/duplication/extractor.rb +15 -19
  69. data/lib/hashira/duplication/fragment.rb +19 -23
  70. data/lib/hashira/duplication/grouping.rb +12 -16
  71. data/lib/hashira/duplication/index.rb +20 -24
  72. data/lib/hashira/duplication/maximal.rb +11 -15
  73. data/lib/hashira/duplication/near_miss.rb +19 -22
  74. data/lib/hashira/duplication/sequence.rb +20 -22
  75. data/lib/hashira/duplication/similarity.rb +28 -35
  76. data/lib/hashira/duplication/union_find.rb +10 -14
  77. data/lib/hashira/duplication/variance.rb +36 -44
  78. data/lib/hashira/error.rb +4 -2
  79. data/lib/hashira/hotspots/file_cost.rb +8 -7
  80. data/lib/hashira/hotspots/rollup.rb +19 -21
  81. data/lib/hashira/pipeline.rb +36 -31
  82. data/lib/hashira/project.rb +53 -32
  83. data/lib/hashira/report/complexity_table.rb +27 -31
  84. data/lib/hashira/report/dependency_map.rb +17 -22
  85. data/lib/hashira/report/finding_lines.rb +15 -19
  86. data/lib/hashira/report/graph_payload.rb +14 -20
  87. data/lib/hashira/report/hotspot_table.rb +32 -37
  88. data/lib/hashira/report/json.rb +30 -32
  89. data/lib/hashira/report/metrics_table.rb +46 -33
  90. data/lib/hashira/report/text.rb +59 -52
  91. data/lib/hashira/version.rb +1 -1
  92. data/lib/hashira.rb +92 -55
  93. metadata +16 -2
  94. data/lib/hashira/analysis/root_namespace.rb +0 -14
@@ -2,71 +2,66 @@
2
2
 
3
3
  require "prism"
4
4
 
5
- module Hashira
6
- module Complexity
7
- class CognitiveScore
8
- HANDLERS = {
9
- Prism::IfNode => :on_if,
10
- Prism::UnlessNode => :on_nester,
11
- Prism::WhileNode => :on_nester,
12
- Prism::UntilNode => :on_nester,
13
- Prism::ForNode => :on_nester,
14
- Prism::CaseNode => :on_nester,
15
- Prism::CaseMatchNode => :on_nester,
16
- Prism::BeginNode => :on_begin,
17
- Prism::AndNode => :on_boolean,
18
- Prism::OrNode => :on_boolean,
19
- Prism::BlockNode => :on_block,
20
- Prism::CallNode => :on_call
21
- }.freeze
22
-
23
- LABELS = {
24
- Prism::UnlessNode => "unless", Prism::WhileNode => "while",
25
- Prism::UntilNode => "until", Prism::ForNode => "for",
26
- Prism::CaseNode => "case", Prism::CaseMatchNode => "case"
27
- }.freeze
28
-
29
- def initialize(def_node)
30
- @increments = []
31
- @calls = 0
32
- visit(def_node.body, 0)
33
- end
34
-
35
- attr_reader :increments, :calls
36
-
37
- def total = @increments.sum(&:cost)
38
-
39
- def visit(node, nesting)
40
- return unless node
41
-
42
- send(HANDLERS.fetch(node.class, :descend), node, nesting)
43
- end
44
-
45
- def add(node, cost, label)
46
- @increments << Increment.new(line: node.location.start_line, cost:, label:)
47
- end
48
-
49
- private
50
-
51
- def descend(node, nesting) = node.compact_child_nodes.each { visit(it, nesting) }
52
-
53
- def on_call(node, nesting)
54
- @calls += 1
55
- descend(node, nesting)
56
- end
57
-
58
- def on_block(node, nesting) = node.compact_child_nodes.each { visit(it, nesting + 1) }
59
-
60
- def on_nester(node, nesting)
61
- add(node, 1 + nesting, LABELS.fetch(node.class))
62
- node.compact_child_nodes.each { visit(it, nesting + 1) }
63
- end
64
-
65
- def on_if(node, nesting) = IfChain.new(self).apply(node, nesting)
66
-
67
- def on_begin(node, nesting) = RescueScan.new(self).apply(node, nesting)
68
-
69
- def on_boolean(node, nesting) = BooleanRun.new(self).apply(node, nesting)
70
- end
5
+ class Hashira::Complexity::CognitiveScore
6
+ HANDLERS = {
7
+ Prism::IfNode => :on_if,
8
+ Prism::UnlessNode => :on_nester,
9
+ Prism::WhileNode => :on_nester,
10
+ Prism::UntilNode => :on_nester,
11
+ Prism::ForNode => :on_nester,
12
+ Prism::CaseNode => :on_nester,
13
+ Prism::CaseMatchNode => :on_nester,
14
+ Prism::BeginNode => :on_begin,
15
+ Prism::AndNode => :on_boolean,
16
+ Prism::OrNode => :on_boolean,
17
+ Prism::BlockNode => :on_block,
18
+ Prism::CallNode => :on_call
19
+ }.freeze
20
+
21
+ LABELS = {
22
+ Prism::UnlessNode => "unless", Prism::WhileNode => "while",
23
+ Prism::UntilNode => "until", Prism::ForNode => "for",
24
+ Prism::CaseNode => "case", Prism::CaseMatchNode => "case"
25
+ }.freeze
26
+
27
+ def initialize(def_node)
28
+ @increments = []
29
+ @calls = 0
30
+ visit(def_node.body, 0)
71
31
  end
32
+
33
+ attr_reader :increments, :calls
34
+
35
+ def total = @increments.sum(&:cost)
36
+
37
+ def visit(node, nesting)
38
+ return unless node
39
+ __send__(HANDLERS.fetch(node.class, :descend), node, nesting)
40
+ end
41
+
42
+ def add(node, cost, label)
43
+ @increments << Hashira::Complexity::Increment.new(line: node.location.start_line, cost:, label:)
44
+ end
45
+
46
+ private
47
+
48
+ def descend(node, nesting) = node.compact_child_nodes.each { visit(it, nesting) }
49
+
50
+ def on_call(node, nesting)
51
+ @calls += 1
52
+ descend(node, nesting)
53
+ end
54
+
55
+ def on_block(node, nesting) = node.compact_child_nodes.each { visit(it, nesting + 1) }
56
+
57
+ def on_nester(node, nesting)
58
+ add(node, 1 + nesting, LABELS.fetch(node.class))
59
+ node.compact_child_nodes.each { visit(it, nesting + 1) }
60
+ end
61
+
62
+ def on_if(node, nesting) = Hashira::Complexity::IfChain.new(self).apply(node, nesting)
63
+
64
+ def on_begin(node, nesting) = Hashira::Complexity::RescueScan.new(self).apply(node, nesting)
65
+
66
+ def on_boolean(node, nesting) = Hashira::Complexity::BooleanRun.new(self).apply(node, nesting)
72
67
  end
@@ -2,44 +2,39 @@
2
2
 
3
3
  require "prism"
4
4
 
5
- module Hashira
6
- module Complexity
7
- class IfChain
8
- def initialize(scorer)
9
- @scorer = scorer
10
- end
11
-
12
- def apply(node, nesting)
13
- return ternary(node, nesting) unless node.if_keyword
14
-
15
- branch(node, 1 + nesting, nesting, "if")
16
- end
17
-
18
- private
19
-
20
- def branch(node, cost, nesting, label)
21
- @scorer.add(node, cost, label)
22
- @scorer.visit(node.predicate, nesting)
23
- @scorer.visit(node.statements, nesting + 1)
24
- tail(node.subsequent, nesting)
25
- end
26
-
27
- def tail(node, nesting)
28
- case node
29
- when Prism::IfNode then branch(node, 1, nesting, "elsif")
30
- when Prism::ElseNode then otherwise(node, nesting)
31
- end
32
- end
33
-
34
- def otherwise(node, nesting)
35
- @scorer.add(node, 1, "else")
36
- @scorer.visit(node.statements, nesting + 1)
37
- end
38
-
39
- def ternary(node, nesting)
40
- @scorer.add(node, 1, "ternary")
41
- node.compact_child_nodes.each { @scorer.visit(it, nesting) }
42
- end
5
+ class Hashira::Complexity::IfChain
6
+ def initialize(scorer)
7
+ @scorer = scorer
8
+ end
9
+
10
+ def apply(node, nesting)
11
+ return ternary(node, nesting) unless node.if_keyword
12
+ branch(node, 1 + nesting, nesting, "if")
13
+ end
14
+
15
+ private
16
+
17
+ def branch(node, cost, nesting, label)
18
+ @scorer.add(node, cost, label)
19
+ @scorer.visit(node.predicate, nesting)
20
+ @scorer.visit(node.statements, nesting + 1)
21
+ tail(node.subsequent, nesting)
22
+ end
23
+
24
+ def tail(node, nesting)
25
+ case node
26
+ when Prism::IfNode then branch(node, 1, nesting, "elsif")
27
+ when Prism::ElseNode then otherwise(node, nesting)
43
28
  end
44
29
  end
30
+
31
+ def otherwise(node, nesting)
32
+ @scorer.add(node, 1, "else")
33
+ @scorer.visit(node.statements, nesting + 1)
34
+ end
35
+
36
+ def ternary(node, nesting)
37
+ @scorer.add(node, 1, "ternary")
38
+ node.compact_child_nodes.each { @scorer.visit(it, nesting) }
39
+ end
45
40
  end
@@ -1,52 +1,47 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- module Complexity
5
- class MethodFinding
6
- ADVICE = {
7
- "if" => "flatten the branching — guard clauses, early returns, or polymorphism.",
8
- "elsif" => "replace the elsif ladder with a lookup or polymorphic dispatch.",
9
- "else" => "flatten the branching guard clauses, early returns, or polymorphism.",
10
- "case" => "a case this size often wants polymorphism or a dispatch table.",
11
- "boolean" => "name the compound condition in a predicate method.",
12
- "rescue" => "narrow the rescue, or lift error handling to the caller.",
13
- "while" => "extract the loop body into its own method.",
14
- "until" => "extract the loop body into its own method.",
15
- "for" => "extract the loop body into its own method.",
16
- "unless" => "invert to a guard clause or a named predicate.",
17
- "ternary" => "extract the nested ternary into a named method."
18
- }.freeze
19
-
20
- def initialize(score)
21
- @score = score
22
- end
23
-
24
- def to_finding
25
- Analysis::Finding.new(kind: "complexity", package: @score.subject, cycle: nil,
26
- message:, evidence:)
27
- end
28
-
29
- private
30
-
31
- def message
32
- "#{@score.subject} — cognitive #{@score.cognitive}, #{@score.calls} calls " \
33
- "(#{@score.file}:#{@score.line}). #{advice}"
34
- end
35
-
36
- def evidence
37
- @score.increments.group_by(&:label).map { |label, incs| line_summary(label, incs) }
38
- end
39
-
40
- def line_summary(label, incs)
41
- lines = incs.map(&:line).uniq
42
- "#{label} +#{incs.sum(&:cost)} (line#{"s" if lines.size > 1} #{lines.join(", ")})"
43
- end
44
-
45
- def advice = ADVICE.fetch(dominant)
46
-
47
- def dominant
48
- @score.increments.group_by(&:label).transform_values { it.sum(&:cost) }.max_by(&:last).first
49
- end
50
- end
3
+ class Hashira::Complexity::MethodFinding
4
+ ADVICE = {
5
+ "if" => "flatten the branching — guard clauses, early returns, or polymorphism.",
6
+ "elsif" => "replace the elsif ladder with a lookup or polymorphic dispatch.",
7
+ "else" => "flatten the branching — guard clauses, early returns, or polymorphism.",
8
+ "case" => "a case this size often wants polymorphism or a dispatch table.",
9
+ "boolean" => "name the compound condition in a predicate method.",
10
+ "rescue" => "narrow the rescue, or lift error handling to the caller.",
11
+ "while" => "extract the loop body into its own method.",
12
+ "until" => "extract the loop body into its own method.",
13
+ "for" => "extract the loop body into its own method.",
14
+ "unless" => "invert to a guard clause or a named predicate.",
15
+ "ternary" => "extract the nested ternary into a named method."
16
+ }.freeze
17
+
18
+ def initialize(score)
19
+ @score = score
20
+ end
21
+
22
+ def to_finding
23
+ Hashira::Analysis::Finding.new(kind: "complexity", package: @score.subject, cycle: nil, message:, evidence:)
24
+ end
25
+
26
+ private
27
+
28
+ def message
29
+ "#{@score.subject} — cognitive #{@score.cognitive}, #{@score.calls} calls " \
30
+ "(#{@score.file}:#{@score.line}). #{advice}"
31
+ end
32
+
33
+ def evidence
34
+ @score.increments.group_by(&:label).map { |label, incs| lines(label, incs) }
35
+ end
36
+
37
+ def lines(label, incs)
38
+ lines = incs.map(&:line).uniq
39
+ "#{label} +#{incs.sum(&:cost)} (line#{"s" if lines.size > 1} #{lines.join(", ")})"
40
+ end
41
+
42
+ def advice = ADVICE.fetch(dominant)
43
+
44
+ def dominant
45
+ @score.increments.group_by(&:label).transform_values { it.sum(&:cost) }.max_by(&:last).first
51
46
  end
52
47
  end
@@ -4,14 +4,16 @@ module Hashira
4
4
  module Complexity
5
5
  Increment = Data.define(:line, :cost, :label)
6
6
 
7
- MethodScore = Data.define(:subject, :file, :line, :cognitive, :calls, :increments) do
8
- def to_h = { subject:, file:, line:, cognitive:, calls: }
7
+ MethodScore =
8
+ Data.define(:subject, :file, :line, :cognitive, :calls, :increments) do
9
+ def to_h = { subject:, file:, line:, cognitive:, calls: }
9
10
 
10
- def cells = [subject, cognitive, calls, "#{file}:#{line}"]
11
- end
11
+ def cells = [subject, cognitive, calls, "#{file}:#{line}"]
12
+ end
12
13
 
13
- ClassScore = Data.define(:name, :cognitive, :method_count, :peak) do
14
- def cells = [name, cognitive, method_count, peak]
15
- end
14
+ ClassScore =
15
+ Data.define(:name, :cognitive, :method_count, :peak) do
16
+ def cells = [name, cognitive, method_count, peak]
17
+ end
16
18
  end
17
19
  end
@@ -1,28 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- module Complexity
5
- class RescueScan
6
- def initialize(scorer)
7
- @scorer = scorer
8
- end
9
-
10
- def apply(node, nesting)
11
- @scorer.visit(node.statements, nesting)
12
- clauses(node.rescue_clause, nesting)
13
- @scorer.visit(node.else_clause, nesting)
14
- @scorer.visit(node.ensure_clause, nesting)
15
- end
3
+ class Hashira::Complexity::RescueScan
4
+ def initialize(scorer)
5
+ @scorer = scorer
6
+ end
16
7
 
17
- private
8
+ def apply(node, nesting)
9
+ @scorer.visit(node.statements, nesting)
10
+ clauses(node.rescue_clause, nesting)
11
+ @scorer.visit(node.else_clause, nesting)
12
+ @scorer.visit(node.ensure_clause, nesting)
13
+ end
18
14
 
19
- def clauses(node, nesting)
20
- return unless node
15
+ private
21
16
 
22
- @scorer.add(node, 1 + nesting, "rescue")
23
- @scorer.visit(node.statements, nesting + 1)
24
- clauses(node.subsequent, nesting)
25
- end
26
- end
17
+ def clauses(node, nesting)
18
+ return unless node
19
+ @scorer.add(node, 1 + nesting, "rescue")
20
+ @scorer.visit(node.statements, nesting + 1)
21
+ clauses(node.subsequent, nesting)
27
22
  end
28
23
  end
@@ -1,22 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- module Complexity
5
- class Rollup
6
- def initialize(scores)
7
- @scores = scores
8
- end
9
-
10
- def classes = @scores.group_by { class_name(it.subject) }.map { |name, group| score(name, group) }
3
+ class Hashira::Complexity::Rollup
4
+ def initialize(scores)
5
+ @scores = scores
6
+ end
11
7
 
12
- private
8
+ def classes = @scores.group_by { owner(it.subject) }.map { |name, group| score(name, group) }
13
9
 
14
- def score(name, group)
15
- ClassScore.new(name:, cognitive: group.sum(&:cognitive), method_count: group.size,
16
- peak: group.map(&:cognitive).max)
17
- end
10
+ private
18
11
 
19
- def class_name(subject) = subject.split(/[#.]/, 2).first
20
- end
12
+ def score(name, group)
13
+ Hashira::Complexity::ClassScore.new(
14
+ name:, cognitive: group.sum(&:cognitive), method_count: group.size,
15
+ peak: group.map(&:cognitive).max
16
+ )
21
17
  end
18
+
19
+ def owner(subject) = subject.split(/[#.]/, 2).first
22
20
  end
@@ -1,16 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- module Diagram
5
- class Dot
6
- def initialize(edges)
7
- @edges = edges
8
- end
3
+ class Hashira::Diagram::Dot
4
+ def initialize(edges)
5
+ @edges = edges
6
+ end
9
7
 
10
- def source
11
- lines = @edges.map { |from, to, weight| %( "#{from}" -> "#{to}" [label="#{weight}"];) }
12
- "digraph hashira {\n rankdir=LR;\n#{lines.join("\n")}\n}"
13
- end
14
- end
8
+ def source
9
+ "digraph hashira {\n rankdir=LR;\n#{@edges.map do |from, to, weight|
10
+ %( "#{from}" -> "#{to}" [label="#{weight}"];)
11
+ end.join("\n")}\n}"
15
12
  end
16
13
  end
@@ -1,23 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- module Diagram
5
- class Mermaid
6
- def initialize(edges)
7
- @edges = edges
8
- @nodes = {}
9
- end
3
+ class Hashira::Diagram::Mermaid
4
+ def initialize(edges)
5
+ @edges = edges
6
+ @nodes = {}
7
+ end
10
8
 
11
- def source
12
- lines = @edges.map { |from, to, weight| " #{node(from)} -->|#{weight}| #{node(to)}" }
13
- "graph LR\n#{lines.join("\n")}"
14
- end
9
+ def source
10
+ "graph LR\n#{@edges.map { |from, to, weight| " #{node(from)} -->|#{weight}| #{node(to)}" }.join("\n")}"
11
+ end
15
12
 
16
- private
13
+ private
17
14
 
18
- def node(package)
19
- @nodes[package] ||= "#{package.gsub(/\W/, "_")}[\"#{package}\"]"
20
- end
21
- end
15
+ def node(package)
16
+ @nodes[package] ||= "#{package.gsub(/\W/, "_")}[\"#{package}\"]"
22
17
  end
23
18
  end
@@ -1,20 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- module Diagram
5
- class Renderer
6
- def initialize(graph, format, io: $stdout)
7
- @graph = graph
8
- @format = format
9
- @io = io
10
- end
3
+ class Hashira::Diagram::Renderer
4
+ def initialize(graph, format, io: $stdout)
5
+ @graph = graph
6
+ @format = format
7
+ @io = io
8
+ end
11
9
 
12
- def display
13
- edges = @graph.weighted_edges
14
- renderer = @format == :dot ? Dot.new(edges) : Mermaid.new(edges)
15
- @io.puts renderer.source
16
- 0
17
- end
18
- end
10
+ def display
11
+ edges = @graph.weighted
12
+ @io.puts((@format == :dot ? Hashira::Diagram::Dot.new(edges) : Hashira::Diagram::Mermaid.new(edges)).source)
13
+ 0
19
14
  end
20
15
  end
@@ -1,21 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- module Duplication
5
- class Analyzer
6
- def initialize(project, trees, churn)
7
- @project = project
8
- @trees = trees
9
- @churn = churn
10
- end
3
+ class Hashira::Duplication::Analyzer
4
+ def initialize(project, trees, churn)
5
+ @project = project
6
+ @trees = trees
7
+ @churn = churn
8
+ end
11
9
 
12
- def clusters = @clusters ||= Clusterer.new(fragments).clusters.sort_by { -it.mass }
10
+ def clusters = @clusters ||= Hashira::Duplication::Clusterer.new(fragments).clusters.sort_by { -it.mass }
13
11
 
14
- def findings = clusters.map { |cluster| DuplicationFinding.new(cluster, @churn).to_finding }
12
+ def findings = clusters.map { |cluster| Hashira::Duplication::DuplicationFinding.new(cluster, @churn).to_finding }
15
13
 
16
- private
14
+ private
17
15
 
18
- def fragments = Extractor.new(@project, @trees).fragments
19
- end
20
- end
16
+ def fragments = Hashira::Duplication::Extractor.new(@project, @trees).fragments
21
17
  end
@@ -2,23 +2,26 @@
2
2
 
3
3
  module Hashira
4
4
  module Duplication
5
- Cluster = Data.define(:sites) do
6
- def canonical = sites.max_by { |site| [shape_count(site), site.mass] }
5
+ Cluster =
6
+ Data.define(:sites) do
7
+ def canonical = sites.max_by { |site| [shapes(site), site.mass] }
7
8
 
8
- def shape_count(site) = sites.count { |other| other.types == site.types }
9
+ def shapes(site) = sites.count { |other| other.types == site.types }
9
10
 
10
- def others
11
- chosen = canonical
12
- sites.reject { |site| site.equal?(chosen) }
13
- end
11
+ def others
12
+ chosen = canonical
13
+ sites.reject { |site| site.equal?(chosen) }
14
+ end
15
+
16
+ def identical = sites.select { it.types == canonical.types }
14
17
 
15
- def mass = canonical.mass
18
+ def mass = canonical.mass
16
19
 
17
- def size = sites.size
20
+ def size = sites.size
18
21
 
19
- def site_masses = sites.map { [it.file, mass] }
22
+ def masses = sites.map { [it.file, mass] }
20
23
 
21
- def shape_only? = others.all? { Variance.new(canonical, it).shape_only? }
22
- end
24
+ def structural? = others.all? { Variance.new(canonical, it).structural? }
25
+ end
23
26
  end
24
27
  end
@@ -1,44 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- module Duplication
5
- class Clusterer
6
- PREFILTER = 12
7
- BASE_MASS = 16
8
- NEAR_MASS = 40
9
- PAIR = 2
10
- PENALTY_PER_RECURRENCE = 2
3
+ class Hashira::Duplication::Clusterer
4
+ PREFILTER = 12
5
+ BASE_MASS = 16
6
+ NEAR_MASS = 40
7
+ PAIR = 2
8
+ PENALTY_PER_RECURRENCE = 2
9
+
10
+ def initialize(fragments)
11
+ @fragments = fragments.select { |fragment| fragment.mass >= PREFILTER }
12
+ @sets = Hashira::Duplication::UnionFind.new
13
+ end
11
14
 
12
- def initialize(fragments)
13
- @fragments = fragments.select { |fragment| fragment.mass >= PREFILTER }
14
- @sets = UnionFind.new
15
- end
15
+ def clusters
16
+ @fragments.group_by(&:types).each_value { |group| chain(group) }
17
+ Hashira::Duplication::NearMiss.new(@fragments).pairs.each { |left, right| @sets.union(left, right) }
18
+ Hashira::Duplication::Maximal.new(sized).reduced
19
+ end
16
20
 
17
- def clusters
18
- @fragments.group_by(&:types).each_value { |group| chain(group) }
19
- NearMiss.new(@fragments).pairs.each { |left, right| @sets.union(left, right) }
20
- Maximal.new(sized).reduced
21
- end
21
+ private
22
22
 
23
- private
23
+ def chain(group) = group.each_cons(2) { |left, right| @sets.union(left, right) }
24
24
 
25
- def chain(group) = group.each_cons(2) { |left, right| @sets.union(left, right) }
25
+ def sized = built.filter_map { admitted(it) }
26
26
 
27
- def sized = built.select { |cluster| cluster.mass >= floor(cluster) }
27
+ def admitted(cluster) = [cluster, core(cluster)].compact.find { fits?(it) }
28
28
 
29
- def built = @sets.clusters.filter_map { |group| Grouping.new(group).cluster }
29
+ def fits?(cluster) = cluster.mass >= floor(cluster)
30
30
 
31
- def floor(cluster) = base(cluster) + idiom_penalty(cluster)
31
+ def core(cluster) = Hashira::Duplication::Grouping.new(cluster.identical).cluster
32
32
 
33
- def base(cluster) = thin_evidence?(cluster) ? NEAR_MASS : BASE_MASS
33
+ def built = @sets.clusters.filter_map { |group| Hashira::Duplication::Grouping.new(group).cluster }
34
34
 
35
- def thin_evidence?(cluster) = !one_shape?(cluster) || cluster.shape_only?
35
+ def floor(cluster) = base(cluster) + penalty(cluster)
36
36
 
37
- def idiom_penalty(cluster) = recurrences(cluster) * PENALTY_PER_RECURRENCE
37
+ def base(cluster) = thin?(cluster) ? NEAR_MASS : BASE_MASS
38
38
 
39
- def recurrences(cluster) = [cluster.size - PAIR, 0].max
39
+ def thin?(cluster) = !uniform?(cluster) || cluster.structural?
40
40
 
41
- def one_shape?(cluster) = cluster.sites.map(&:types).uniq.size == 1
42
- end
43
- end
41
+ def penalty(cluster) = recurrences(cluster) * PENALTY_PER_RECURRENCE
42
+
43
+ def recurrences(cluster) = [cluster.size - PAIR, 0].max
44
+
45
+ def uniform?(cluster) = cluster.sites.map(&:types).uniq.size == 1
44
46
  end