hashira 0.1.0 → 0.2.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/README.md +327 -32
  4. data/lib/hashira/analysis/census.rb +0 -2
  5. data/lib/hashira/analysis/cycle_findings.rb +1 -1
  6. data/lib/hashira/analysis/cycle_search.rb +1 -1
  7. data/lib/hashira/analysis/definitions.rb +1 -1
  8. data/lib/hashira/analysis/finding.rb +6 -2
  9. data/lib/hashira/analysis/graph.rb +3 -3
  10. data/lib/hashira/analysis/node_walk.rb +7 -1
  11. data/lib/hashira/analysis/references.rb +2 -2
  12. data/lib/hashira/analysis/sdp_check.rb +1 -1
  13. data/lib/hashira/analysis/type_walk.rb +1 -1
  14. data/lib/hashira/churn.rb +22 -0
  15. data/lib/hashira/ci/accepted.rb +11 -7
  16. data/lib/hashira/ci/baseline.rb +40 -0
  17. data/lib/hashira/ci/diff.rb +15 -0
  18. data/lib/hashira/ci/edge_diff_report.rb +4 -27
  19. data/lib/hashira/ci/finding_diff_report.rb +26 -0
  20. data/lib/hashira/ci/gate.rb +7 -5
  21. data/lib/hashira/ci/improvement.rb +19 -0
  22. data/lib/hashira/ci/ratchet.rb +16 -20
  23. data/lib/hashira/ci/ratchet_report.rb +42 -0
  24. data/lib/hashira/cli/command_line.rb +12 -6
  25. data/lib/hashira/cli/fail_on.rb +4 -2
  26. data/lib/hashira/cli/options.rb +1 -1
  27. data/lib/hashira/cli/run.rb +9 -3
  28. data/lib/hashira/cli/skip.rb +27 -0
  29. data/lib/hashira/cli/usage.rb +10 -5
  30. data/lib/hashira/cli.rb +1 -1
  31. data/lib/hashira/complexity/analyzer.rb +45 -0
  32. data/lib/hashira/complexity/boolean_run.rb +22 -0
  33. data/lib/hashira/complexity/cognitive_score.rb +72 -0
  34. data/lib/hashira/complexity/if_chain.rb +45 -0
  35. data/lib/hashira/complexity/method_finding.rb +52 -0
  36. data/lib/hashira/complexity/method_score.rb +17 -0
  37. data/lib/hashira/complexity/rescue_scan.rb +28 -0
  38. data/lib/hashira/complexity/rollup.rb +22 -0
  39. data/lib/hashira/duplication/analyzer.rb +21 -0
  40. data/lib/hashira/duplication/cluster.rb +24 -0
  41. data/lib/hashira/duplication/clusterer.rb +44 -0
  42. data/lib/hashira/duplication/delta.rb +39 -0
  43. data/lib/hashira/duplication/duplication_finding.rb +30 -0
  44. data/lib/hashira/duplication/extractor.rb +31 -0
  45. data/lib/hashira/duplication/fragment.rb +42 -0
  46. data/lib/hashira/duplication/grouping.rb +24 -0
  47. data/lib/hashira/duplication/index.rb +35 -0
  48. data/lib/hashira/duplication/maximal.rb +21 -0
  49. data/lib/hashira/duplication/near_miss.rb +33 -0
  50. data/lib/hashira/duplication/sequence.rb +34 -0
  51. data/lib/hashira/duplication/similarity.rb +50 -0
  52. data/lib/hashira/duplication/union_find.rb +21 -0
  53. data/lib/hashira/duplication/variance.rb +57 -0
  54. data/lib/hashira/hotspots/file_cost.rb +19 -0
  55. data/lib/hashira/hotspots/rollup.rb +33 -0
  56. data/lib/hashira/pipeline.rb +28 -6
  57. data/lib/hashira/project.rb +10 -7
  58. data/lib/hashira/report/complexity_table.rb +40 -0
  59. data/lib/hashira/report/dependency_map.rb +8 -5
  60. data/lib/hashira/report/finding_lines.rb +1 -1
  61. data/lib/hashira/report/graph_payload.rb +29 -0
  62. data/lib/hashira/report/hotspot_table.rb +41 -0
  63. data/lib/hashira/report/json.rb +24 -17
  64. data/lib/hashira/report/metrics_table.rb +1 -2
  65. data/lib/hashira/report/text.rb +24 -14
  66. data/lib/hashira/report/view.rb +7 -0
  67. data/lib/hashira/version.rb +1 -1
  68. data/lib/hashira.rb +36 -0
  69. metadata +44 -6
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
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
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
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
51
+ end
52
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Complexity
5
+ Increment = Data.define(:line, :cost, :label)
6
+
7
+ MethodScore = Data.define(:subject, :file, :line, :cognitive, :calls, :increments) do
8
+ def to_h = { subject:, file:, line:, cognitive:, calls: }
9
+
10
+ def cells = [subject, cognitive, calls, "#{file}:#{line}"]
11
+ end
12
+
13
+ ClassScore = Data.define(:name, :cognitive, :method_count, :peak) do
14
+ def cells = [name, cognitive, method_count, peak]
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
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
16
+
17
+ private
18
+
19
+ def clauses(node, nesting)
20
+ return unless node
21
+
22
+ @scorer.add(node, 1 + nesting, "rescue")
23
+ @scorer.visit(node.statements, nesting + 1)
24
+ clauses(node.subsequent, nesting)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
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) }
11
+
12
+ private
13
+
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
18
+
19
+ def class_name(subject) = subject.split(/[#.]/, 2).first
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
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
11
+
12
+ def clusters = @clusters ||= Clusterer.new(fragments).clusters.sort_by { -it.mass }
13
+
14
+ def findings = clusters.map { |cluster| DuplicationFinding.new(cluster, @churn).to_finding }
15
+
16
+ private
17
+
18
+ def fragments = Extractor.new(@project, @trees).fragments
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Duplication
5
+ Cluster = Data.define(:sites) do
6
+ def canonical = sites.max_by { |site| [shape_count(site), site.mass] }
7
+
8
+ def shape_count(site) = sites.count { |other| other.types == site.types }
9
+
10
+ def others
11
+ chosen = canonical
12
+ sites.reject { |site| site.equal?(chosen) }
13
+ end
14
+
15
+ def mass = canonical.mass
16
+
17
+ def size = sites.size
18
+
19
+ def site_masses = sites.map { [it.file, mass] }
20
+
21
+ def shape_only? = others.all? { Variance.new(canonical, it).shape_only? }
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
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
11
+
12
+ def initialize(fragments)
13
+ @fragments = fragments.select { |fragment| fragment.mass >= PREFILTER }
14
+ @sets = UnionFind.new
15
+ end
16
+
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
22
+
23
+ private
24
+
25
+ def chain(group) = group.each_cons(2) { |left, right| @sets.union(left, right) }
26
+
27
+ def sized = built.select { |cluster| cluster.mass >= floor(cluster) }
28
+
29
+ def built = @sets.clusters.filter_map { |group| Grouping.new(group).cluster }
30
+
31
+ def floor(cluster) = base(cluster) + idiom_penalty(cluster)
32
+
33
+ def base(cluster) = thin_evidence?(cluster) ? NEAR_MASS : BASE_MASS
34
+
35
+ def thin_evidence?(cluster) = !one_shape?(cluster) || cluster.shape_only?
36
+
37
+ def idiom_penalty(cluster) = recurrences(cluster) * PENALTY_PER_RECURRENCE
38
+
39
+ def recurrences(cluster) = [cluster.size - PAIR, 0].max
40
+
41
+ def one_shape?(cluster) = cluster.sites.map(&:types).uniq.size == 1
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Duplication
5
+ class Delta
6
+ ADVICE = {
7
+ identical: "byte-for-byte identical — extract a shared method and call it from each site.",
8
+ literal: "differs only in literal values — extract a method, pass them as arguments.",
9
+ message: "differs only in the receiver or message — extract a method taking the receiver.",
10
+ constant: "differs only in a constant — extract a method and parameterize it.",
11
+ structure: "the control flow differs — extract the common core, but verify by hand (lower confidence).",
12
+ mixed: "extract the shared shape and pass what differs as parameters."
13
+ }.freeze
14
+
15
+ def initialize(cluster)
16
+ @cluster = cluster
17
+ end
18
+
19
+ def summary = ADVICE.fetch(kind)
20
+
21
+ def kind
22
+ tags = kinds
23
+ return :identical if tags.empty?
24
+ return :structure if tags.include?(:structure)
25
+
26
+ tags.size == 1 ? tags.first : :mixed
27
+ end
28
+
29
+ def to_h
30
+ { mass: @cluster.mass, sites: @cluster.size, kind:,
31
+ locations: @cluster.sites.sort_by(&:sort_key).map(&:range) }
32
+ end
33
+
34
+ private
35
+
36
+ def kinds = @cluster.others.flat_map { |other| Variance.new(@cluster.canonical, other).kinds }.uniq
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Duplication
5
+ class DuplicationFinding
6
+ def initialize(cluster, churn)
7
+ @cluster = cluster
8
+ @churn = churn
9
+ end
10
+
11
+ def to_finding
12
+ site = @cluster.canonical
13
+ Analysis::Finding.new(kind: "duplication", package: site.location, digest: site.digest,
14
+ cycle: nil, message:, evidence:)
15
+ end
16
+
17
+ private
18
+
19
+ def message
20
+ "#{@cluster.size} similar fragments (mass #{@cluster.mass}) — #{Delta.new(@cluster).summary}#{note}"
21
+ end
22
+
23
+ def evidence = @cluster.sites.sort_by(&:sort_key).map(&:range)
24
+
25
+ def note
26
+ @churn.hot?(@cluster.sites) ? " Both sites change often — fix one, miss the other." : ""
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Hashira
6
+ module Duplication
7
+ class Extractor
8
+ WHOLE = [Prism::DefNode, Prism::WhenNode, Prism::RescueNode].freeze
9
+
10
+ def initialize(project, trees)
11
+ @project = project
12
+ @fragments = trees.flat_map { |path, tree| from_tree(@project.relative(path), tree) }
13
+ end
14
+
15
+ attr_reader :fragments
16
+
17
+ private
18
+
19
+ def from_tree(rel, tree)
20
+ nodes = Analysis::NodeWalk.collect(tree)
21
+ windows(rel, nodes) + wholes(nodes).map { Fragment.new(rel, [it]) }
22
+ end
23
+
24
+ def windows(rel, nodes) = runs(nodes).flat_map { Sequence.new(rel, it).fragments }
25
+
26
+ def runs(nodes) = nodes.filter_map { it.body if it.is_a?(Prism::StatementsNode) }
27
+
28
+ def wholes(nodes) = nodes.select { WHOLE.include?(it.class) }
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Hashira
6
+ module Duplication
7
+ class Fragment
8
+ DIGEST_LENGTH = 12
9
+
10
+ def initialize(file, roots)
11
+ @file = file
12
+ @roots = roots
13
+ end
14
+
15
+ attr_reader :file
16
+
17
+ def types = @types ||= nodes.map(&:type)
18
+
19
+ def digest = Digest::SHA256.hexdigest(shape).slice(0, DIGEST_LENGTH)
20
+
21
+ def shape = types.join(",")
22
+
23
+ def mass = types.size
24
+
25
+ def line = @roots.first.location.start_line
26
+
27
+ def finish = @roots.last.location.end_line
28
+
29
+ def location = "#{file}:#{line}"
30
+
31
+ def range = "#{file}:#{line}-#{finish}"
32
+
33
+ def sort_key = [file, line]
34
+
35
+ def overlaps?(other) = file == other.file && line <= other.finish && other.line <= finish
36
+
37
+ def overlaps_any?(others) = others.any? { overlaps?(it) }
38
+
39
+ def nodes = @nodes ||= @roots.flat_map { Analysis::NodeWalk.collect(it) }
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Duplication
5
+ class Grouping
6
+ def initialize(group)
7
+ @group = group
8
+ end
9
+
10
+ def cluster
11
+ sites = distinct
12
+ Cluster.new(sites) if sites.size >= 2
13
+ end
14
+
15
+ private
16
+
17
+ def distinct
18
+ @group.sort_by { -it.mass }.each_with_object([]) do |fragment, kept|
19
+ kept << fragment unless fragment.overlaps_any?(kept)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Duplication
5
+ class Index
6
+ RARE = 2
7
+ MAX_BUCKET = 60
8
+
9
+ def initialize(fragments)
10
+ @fragments = fragments
11
+ @document_frequency = frequencies(fragments)
12
+ end
13
+
14
+ def buckets = grouped.values.select { |bucket| bucket.size.between?(2, MAX_BUCKET) }
15
+
16
+ private
17
+
18
+ def frequencies(fragments)
19
+ fragments.each_with_object(Hash.new(0)) { |fragment, counts| tally(counts, fragment) }
20
+ end
21
+
22
+ def tally(counts, fragment) = fragment.types.uniq.each { |type| counts[type] += 1 }
23
+
24
+ def grouped
25
+ index = Hash.new { |hash, type| hash[type] = [] }
26
+ @fragments.each { |fragment| file(index, fragment) }
27
+ index
28
+ end
29
+
30
+ def file(index, fragment) = rarest(fragment).each { |type| index[type] << fragment }
31
+
32
+ def rarest(fragment) = fragment.types.uniq.min_by(RARE) { @document_frequency[it] }
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Duplication
5
+ class Maximal
6
+ def initialize(clusters)
7
+ @clusters = clusters
8
+ end
9
+
10
+ def reduced
11
+ @clusters.sort_by { -it.mass }.each_with_object([]) do |cluster, kept|
12
+ kept << cluster unless shadowed_by?(cluster, kept.flat_map(&:sites))
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def shadowed_by?(cluster, bigger) = cluster.sites.all? { it.overlaps_any?(bigger) }
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Duplication
5
+ class NearMiss
6
+ THRESHOLD = 0.8
7
+ MASS_RATIO = 1.5
8
+
9
+ def initialize(fragments)
10
+ @fragments = fragments
11
+ end
12
+
13
+ def pairs = Index.new(@fragments).buckets.flat_map { |bucket| verified(bucket) }.uniq
14
+
15
+ private
16
+
17
+ def verified(bucket) = bucket.combination(2).select { |left, right| near?(left, right) }
18
+
19
+ def near?(left, right)
20
+ return false unless comparable?(left, right) && !left.overlaps?(right)
21
+
22
+ drifted?(left.types, right.types)
23
+ end
24
+
25
+ def drifted?(first, second) = first != second && Similarity.new(first, second).at_least?(THRESHOLD)
26
+
27
+ def comparable?(left, right)
28
+ masses = [left.mass, right.mass]
29
+ masses.max <= masses.min * MASS_RATIO
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Duplication
5
+ class Sequence
6
+ MIN_STATEMENTS = 1
7
+ MAX_STATEMENTS = 12
8
+ LIST_RUN = 3
9
+
10
+ def initialize(file, statements)
11
+ @file = file
12
+ @statements = statements
13
+ end
14
+
15
+ def fragments
16
+ return [] if listing?
17
+
18
+ lengths.flat_map { |length| slide(length) }
19
+ end
20
+
21
+ private
22
+
23
+ def listing? = @statements.size >= LIST_RUN && shapes.uniq.size == 1
24
+
25
+ def shapes = @statements.map { fragment([it]).types }
26
+
27
+ def lengths = MIN_STATEMENTS..[@statements.size, MAX_STATEMENTS].min
28
+
29
+ def slide(length) = (0..(@statements.size - length)).map { fragment(@statements[it, length]) }
30
+
31
+ def fragment(roots) = Fragment.new(@file, roots)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Duplication
5
+ class Similarity
6
+ def initialize(left, right)
7
+ @left = left
8
+ @right = right
9
+ end
10
+
11
+ def ratio
12
+ return 0.0 if @left.empty? || @right.empty?
13
+
14
+ normalized(lcs)
15
+ end
16
+
17
+ def at_least?(threshold) = upper_bound >= threshold && ratio >= threshold
18
+
19
+ private
20
+
21
+ def upper_bound = normalized(tokens_in_common)
22
+
23
+ def normalized(length) = (2.0 * length) / (@left.size + @right.size)
24
+
25
+ def tokens_in_common
26
+ counts = @right.tally
27
+ @left.count { taken?(counts, it) }
28
+ end
29
+
30
+ def taken?(counts, token)
31
+ return false unless counts.fetch(token, 0).positive?
32
+
33
+ counts[token] -= 1
34
+ true
35
+ end
36
+
37
+ def lcs = @left.reduce(blank) { |prev, token| next_row(prev, token) }.last
38
+
39
+ def blank = Array.new(@right.size + 1, 0)
40
+
41
+ def next_row(prev, token)
42
+ @right.each_index.reduce([0]) { |row, index| row << cell(prev, row, token, index) }
43
+ end
44
+
45
+ def cell(prev, row, token, index)
46
+ @right[index] == token ? prev[index] + 1 : [prev[index + 1], row[index]].max
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Duplication
5
+ class UnionFind
6
+ def initialize
7
+ @parent = {}
8
+ end
9
+
10
+ def union(left, right) = @parent[root(left)] = root(right)
11
+
12
+ def clusters = @parent.keys.group_by { root(it) }.values
13
+
14
+ def root(node)
15
+ @parent[node] = node unless @parent.key?(node)
16
+ found = @parent[node]
17
+ found == node ? node : (@parent[node] = root(found))
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Duplication
5
+ class Variance
6
+ LITERALS = %i[integer_node float_node string_node symbol_node].freeze
7
+ VALUED = %i[integer_node float_node].freeze
8
+ NAMED = %i[call_node constant_read_node constant_path_node
9
+ local_variable_read_node local_variable_write_node
10
+ instance_variable_read_node instance_variable_write_node].freeze
11
+ CONSTANTS = %i[constant_read_node constant_path_node].freeze
12
+
13
+ def initialize(canonical, other)
14
+ @canonical = canonical
15
+ @other = other
16
+ end
17
+
18
+ def kinds
19
+ return [:structure] if @canonical.types != @other.types
20
+
21
+ differing.map { |node| category(node) }.uniq
22
+ end
23
+
24
+ def shape_only?
25
+ return false unless @canonical.types == @other.types
26
+
27
+ named.any? && named.all? { |left, right| left.name != right.name }
28
+ end
29
+
30
+ private
31
+
32
+ def pairs = @canonical.nodes.zip(@other.nodes)
33
+
34
+ def named = @named ||= pairs.select { |left, _| NAMED.include?(left.type) }
35
+
36
+ def differing = pairs.select { |pair| varies?(*pair) }.map(&:first)
37
+
38
+ def varies?(left, right) = signature(left) != signature(right)
39
+
40
+ def category(node)
41
+ type = node.type
42
+ return :literal if LITERALS.include?(type)
43
+
44
+ CONSTANTS.include?(type) ? :constant : :message
45
+ end
46
+
47
+ def signature(node)
48
+ type = node.type
49
+ return literal(node) if LITERALS.include?(type)
50
+
51
+ node.name if NAMED.include?(type)
52
+ end
53
+
54
+ def literal(node) = VALUED.include?(node.type) ? node.value : node.unescaped
55
+ end
56
+ end
57
+ end