hashira 0.3.0 → 0.5.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 (140) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +155 -0
  3. data/README.md +127 -25
  4. data/lib/hashira/analysis/finding.rb +7 -6
  5. data/lib/hashira/analysis/node_walk.rb +3 -3
  6. data/lib/hashira/analysis/syntax.rb +21 -6
  7. data/lib/hashira/analysis/type_walk.rb +7 -9
  8. data/lib/hashira/churn.rb +11 -13
  9. data/lib/hashira/ci/accepted.rb +27 -31
  10. data/lib/hashira/ci/baseline.rb +22 -24
  11. data/lib/hashira/ci/diff.rb +8 -7
  12. data/lib/hashira/ci/edge_diff_report.rb +13 -17
  13. data/lib/hashira/ci/finding_diff_report.rb +14 -18
  14. data/lib/hashira/ci/gate.rb +19 -23
  15. data/lib/hashira/ci/improvement.rb +9 -14
  16. data/lib/hashira/ci/ratchet.rb +32 -27
  17. data/lib/hashira/ci/ratchet_report.rb +26 -32
  18. data/lib/hashira/cli/arguments.rb +23 -0
  19. data/lib/hashira/cli/command_line.rb +55 -71
  20. data/lib/hashira/cli/fail_on.rb +24 -19
  21. data/lib/hashira/cli/options.rb +9 -4
  22. data/lib/hashira/cli/package_by.rb +14 -0
  23. data/lib/hashira/cli/run.rb +33 -27
  24. data/lib/hashira/cli/skip.rb +14 -20
  25. data/lib/hashira/cli/usage.rb +44 -41
  26. data/lib/hashira/cli.rb +17 -19
  27. data/lib/hashira/complexity/boolean_run.rb +13 -15
  28. data/lib/hashira/complexity/cognitive_score.rb +68 -66
  29. data/lib/hashira/complexity/if_chain.rb +33 -38
  30. data/lib/hashira/complexity/method_finding.rb +20 -42
  31. data/lib/hashira/complexity/method_score.rb +9 -7
  32. data/lib/hashira/complexity/rescue_scan.rb +16 -21
  33. data/lib/hashira/complexity/rollup.rb +13 -15
  34. data/lib/hashira/complexity/scores.rb +44 -0
  35. data/lib/hashira/coupling/audiences.rb +49 -0
  36. data/lib/hashira/coupling/catalog.rb +27 -0
  37. data/lib/hashira/coupling/census.rb +55 -0
  38. data/lib/hashira/coupling/constant_registry.rb +47 -0
  39. data/lib/hashira/coupling/cycle_findings.rb +31 -0
  40. data/lib/hashira/coupling/cycle_search.rb +37 -0
  41. data/lib/hashira/coupling/cycles.rb +14 -0
  42. data/lib/hashira/coupling/definition.rb +22 -0
  43. data/lib/hashira/coupling/definitions.rb +31 -0
  44. data/lib/hashira/coupling/edge.rb +10 -0
  45. data/lib/hashira/coupling/edge_map.rb +33 -0
  46. data/lib/hashira/coupling/folder_placement.rb +17 -0
  47. data/lib/hashira/coupling/folding.rb +56 -0
  48. data/lib/hashira/coupling/graph.rb +63 -0
  49. data/lib/hashira/coupling/metric.rb +15 -0
  50. data/lib/hashira/coupling/mixed_audience_findings.rb +32 -0
  51. data/lib/hashira/coupling/namespace_placement.rb +28 -0
  52. data/lib/hashira/{analysis → coupling}/namespace_prefix.rb +11 -11
  53. data/lib/hashira/coupling/naming.rb +14 -0
  54. data/lib/hashira/coupling/no_folding.rb +13 -0
  55. data/lib/hashira/coupling/placement.rb +19 -0
  56. data/lib/hashira/coupling/references.rb +55 -0
  57. data/lib/hashira/coupling/report.rb +28 -0
  58. data/lib/hashira/coupling/roll_call.rb +45 -0
  59. data/lib/hashira/coupling/roll_call_findings.rb +30 -0
  60. data/lib/hashira/coupling/roster.rb +25 -0
  61. data/lib/hashira/coupling/rule.rb +18 -0
  62. data/lib/hashira/coupling/scope.rb +28 -0
  63. data/lib/hashira/coupling/sdp_check.rb +14 -0
  64. data/lib/hashira/coupling/sdp_violation_findings.rb +25 -0
  65. data/lib/hashira/coupling/wide_edge_findings.rb +25 -0
  66. data/lib/hashira/coupling/words.rb +29 -0
  67. data/lib/hashira/diagram/dot.rb +8 -11
  68. data/lib/hashira/diagram/mermaid.rb +11 -16
  69. data/lib/hashira/diagram/source.rb +15 -0
  70. data/lib/hashira/duplication/clones.rb +17 -0
  71. data/lib/hashira/duplication/cluster.rb +14 -13
  72. data/lib/hashira/duplication/clusters.rb +46 -0
  73. data/lib/hashira/duplication/delta.rb +21 -30
  74. data/lib/hashira/duplication/duplication_finding.rb +17 -23
  75. data/lib/hashira/duplication/fragment.rb +19 -23
  76. data/lib/hashira/duplication/grouping.rb +12 -16
  77. data/lib/hashira/duplication/harvest.rb +27 -0
  78. data/lib/hashira/duplication/index.rb +20 -24
  79. data/lib/hashira/duplication/maximal.rb +11 -15
  80. data/lib/hashira/duplication/near_miss.rb +19 -22
  81. data/lib/hashira/duplication/sequence.rb +20 -22
  82. data/lib/hashira/duplication/similarity.rb +31 -34
  83. data/lib/hashira/duplication/union_find.rb +10 -14
  84. data/lib/hashira/duplication/variance.rb +35 -43
  85. data/lib/hashira/error.rb +4 -2
  86. data/lib/hashira/hotspots/file_cost.rb +8 -7
  87. data/lib/hashira/hotspots/rollup.rb +19 -21
  88. data/lib/hashira/pipeline.rb +41 -31
  89. data/lib/hashira/project.rb +50 -49
  90. data/lib/hashira/report/complexity_table.rb +27 -31
  91. data/lib/hashira/report/dependency_map.rb +17 -22
  92. data/lib/hashira/report/finding_lines.rb +15 -19
  93. data/lib/hashira/report/graph_payload.rb +14 -20
  94. data/lib/hashira/report/hotspot_table.rb +32 -37
  95. data/lib/hashira/report/json.rb +32 -32
  96. data/lib/hashira/report/metrics_table.rb +46 -33
  97. data/lib/hashira/report/phrases.rb +98 -0
  98. data/lib/hashira/report/smell_phrases.rb +68 -0
  99. data/lib/hashira/report/text.rb +59 -52
  100. data/lib/hashira/smells/census.rb +32 -0
  101. data/lib/hashira/smells/check.rb +30 -0
  102. data/lib/hashira/smells/conditions.rb +44 -0
  103. data/lib/hashira/smells/contexts.rb +64 -0
  104. data/lib/hashira/smells/control_parameter.rb +22 -0
  105. data/lib/hashira/smells/data_clump.rb +30 -0
  106. data/lib/hashira/smells/duplicate_method_call.rb +54 -0
  107. data/lib/hashira/smells/feature_envy.rb +17 -0
  108. data/lib/hashira/smells/instance_variable_assumption.rb +44 -0
  109. data/lib/hashira/smells/manual_dispatch.rb +16 -0
  110. data/lib/hashira/smells/module_initialize.rb +9 -0
  111. data/lib/hashira/smells/nil_check.rb +32 -0
  112. data/lib/hashira/smells/param_check.rb +51 -0
  113. data/lib/hashira/smells/refs.rb +53 -0
  114. data/lib/hashira/smells/repeated_conditional.rb +29 -0
  115. data/lib/hashira/smells/report.rb +31 -0
  116. data/lib/hashira/smells/scope.rb +29 -0
  117. data/lib/hashira/smells/too_many_instance_variables.rb +24 -0
  118. data/lib/hashira/smells/utility_function.rb +13 -0
  119. data/lib/hashira/smells/visibility.rb +64 -0
  120. data/lib/hashira/version.rb +1 -1
  121. data/lib/hashira.rb +131 -60
  122. metadata +62 -20
  123. data/lib/hashira/analysis/census.rb +0 -39
  124. data/lib/hashira/analysis/constant_registry.rb +0 -40
  125. data/lib/hashira/analysis/cycle_findings.rb +0 -33
  126. data/lib/hashira/analysis/cycle_search.rb +0 -44
  127. data/lib/hashira/analysis/definitions.rb +0 -27
  128. data/lib/hashira/analysis/edge.rb +0 -9
  129. data/lib/hashira/analysis/edge_map.rb +0 -36
  130. data/lib/hashira/analysis/graph.rb +0 -56
  131. data/lib/hashira/analysis/metric.rb +0 -14
  132. data/lib/hashira/analysis/references.rb +0 -39
  133. data/lib/hashira/analysis/rule.rb +0 -22
  134. data/lib/hashira/analysis/sdp_check.rb +0 -18
  135. data/lib/hashira/analysis/sdp_violation_findings.rb +0 -27
  136. data/lib/hashira/complexity/analyzer.rb +0 -45
  137. data/lib/hashira/diagram/renderer.rb +0 -20
  138. data/lib/hashira/duplication/analyzer.rb +0 -21
  139. data/lib/hashira/duplication/clusterer.rb +0 -50
  140. data/lib/hashira/duplication/extractor.rb +0 -31
@@ -1,30 +1,24 @@
1
1
  # frozen_string_literal: true
2
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
3
+ class Hashira::Duplication::DuplicationFinding
4
+ def initialize(cluster, churn)
5
+ @cluster = cluster
6
+ @churn = churn
7
+ end
18
8
 
19
- def message
20
- "#{@cluster.size} similar fragments (mass #{@cluster.mass}) — #{Delta.new(@cluster).summary}#{note}"
21
- end
9
+ def to_finding
10
+ site = @cluster.canonical
11
+ Hashira::Analysis::Finding.new(kind: "duplication", package: site.location, digest: site.digest, detail:, evidence:)
12
+ end
22
13
 
23
- def evidence = @cluster.sites.sort_by(&:sort_key).map(&:range)
14
+ private
24
15
 
25
- def note
26
- @churn.hot?(@cluster.sites) ? " Both sites change often — fix one, miss the other." : ""
27
- end
28
- end
16
+ def detail
17
+ {
18
+ size: @cluster.size, mass: @cluster.mass,
19
+ kind: Hashira::Duplication::Delta.new(@cluster).kind, hot: @churn.hot?(@cluster.sites)
20
+ }
29
21
  end
22
+
23
+ def evidence = @cluster.sites.sort_by(&:rank).map(&:range)
30
24
  end
@@ -2,41 +2,37 @@
2
2
 
3
3
  require "digest"
4
4
 
5
- module Hashira
6
- module Duplication
7
- class Fragment
8
- DIGEST_LENGTH = 12
5
+ class Hashira::Duplication::Fragment
6
+ DIGEST_LENGTH = 12
9
7
 
10
- def initialize(file, roots)
11
- @file = file
12
- @roots = roots
13
- end
8
+ def initialize(file, roots)
9
+ @file = file
10
+ @roots = roots
11
+ end
14
12
 
15
- attr_reader :file
13
+ attr_reader :file
16
14
 
17
- def types = @types ||= nodes.map(&:type)
15
+ def types = @types ||= nodes.map(&:type)
18
16
 
19
- def digest = Digest::SHA256.hexdigest(shape).slice(0, DIGEST_LENGTH)
17
+ def digest = Digest::SHA256.hexdigest(shape).slice(0, DIGEST_LENGTH)
20
18
 
21
- def shape = types.join(",")
19
+ def shape = types.join(",")
22
20
 
23
- def mass = types.size
21
+ def mass = types.size
24
22
 
25
- def line = @roots.first.location.start_line
23
+ def line = @roots.first.location.start_line
26
24
 
27
- def finish = @roots.last.location.end_line
25
+ def finish = @roots.last.location.end_line
28
26
 
29
- def location = "#{file}:#{line}"
27
+ def location = "#{file}:#{line}"
30
28
 
31
- def range = "#{file}:#{line}-#{finish}"
29
+ def range = "#{file}:#{line}-#{finish}"
32
30
 
33
- def sort_key = [file, line]
31
+ def rank = [file, line]
34
32
 
35
- def overlaps?(other) = file == other.file && line <= other.finish && other.line <= finish
33
+ def overlaps?(other) = file == other.file && line <= other.finish && other.line <= finish
36
34
 
37
- def overlaps_any?(others) = others.any? { overlaps?(it) }
35
+ def touches?(others) = others.any? { overlaps?(it) }
38
36
 
39
- def nodes = @nodes ||= @roots.flat_map { Analysis::NodeWalk.collect(it) }
40
- end
41
- end
37
+ def nodes = @nodes ||= @roots.flat_map { Hashira::Analysis::NodeWalk.collect(it) }
42
38
  end
@@ -1,24 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- module Duplication
5
- class Grouping
6
- def initialize(group)
7
- @group = group
8
- end
3
+ class Hashira::Duplication::Grouping
4
+ def initialize(group)
5
+ @group = group
6
+ end
9
7
 
10
- def cluster
11
- sites = distinct
12
- Cluster.new(sites) if sites.size >= 2
13
- end
8
+ def cluster
9
+ sites = distinct
10
+ Hashira::Duplication::Cluster.new(sites) if sites.size >= 2
11
+ end
14
12
 
15
- private
13
+ private
16
14
 
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
15
+ def distinct
16
+ @group.sort_by { -it.mass }.each_with_object([]) do |fragment, kept|
17
+ kept << fragment unless fragment.touches?(kept)
22
18
  end
23
19
  end
24
20
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ class Hashira::Duplication::Harvest
6
+ WHOLE = [Prism::DefNode, Prism::WhenNode, Prism::RescueNode].freeze
7
+
8
+ def initialize(project, trees)
9
+ @project = project
10
+ @fragments = trees.flat_map { |path, tree| scan(@project.relative(path), tree) }
11
+ end
12
+
13
+ attr_reader :fragments
14
+
15
+ private
16
+
17
+ def scan(rel, tree)
18
+ nodes = Hashira::Analysis::NodeWalk.collect(tree)
19
+ windows(rel, nodes) + wholes(nodes).map { Hashira::Duplication::Fragment.new(rel, [it]) }
20
+ end
21
+
22
+ def windows(rel, nodes) = runs(nodes).flat_map { Hashira::Duplication::Sequence.new(rel, it).fragments }
23
+
24
+ def runs(nodes) = nodes.filter_map { it.body if it.is_a?(Prism::StatementsNode) }
25
+
26
+ def wholes(nodes) = nodes.select { WHOLE.include?(it.class) }
27
+ end
@@ -1,35 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- module Duplication
5
- class Index
6
- RARE = 2
7
- MAX_BUCKET = 60
3
+ class Hashira::Duplication::Index
4
+ RARE = 2
5
+ MAX_BUCKET = 60
8
6
 
9
- def initialize(fragments)
10
- @fragments = fragments
11
- @document_frequency = frequencies(fragments)
12
- end
7
+ def initialize(fragments)
8
+ @fragments = fragments
9
+ @frequency = frequencies(fragments)
10
+ end
13
11
 
14
- def buckets = grouped.values.select { |bucket| bucket.size.between?(2, MAX_BUCKET) }
12
+ def buckets = grouped.values.select { |bucket| bucket.size.between?(2, MAX_BUCKET) }
15
13
 
16
- private
14
+ private
17
15
 
18
- def frequencies(fragments)
19
- fragments.each_with_object(Hash.new(0)) { |fragment, counts| tally(counts, fragment) }
20
- end
16
+ def frequencies(fragments)
17
+ fragments.each_with_object(Hash.new(0)) { |fragment, counts| tally(counts, fragment) }
18
+ end
21
19
 
22
- def tally(counts, fragment) = fragment.types.uniq.each { |type| counts[type] += 1 }
20
+ def tally(counts, fragment) = fragment.types.uniq.each { |type| counts[type] += 1 }
23
21
 
24
- def grouped
25
- index = Hash.new { |hash, type| hash[type] = [] }
26
- @fragments.each { |fragment| file(index, fragment) }
27
- index
28
- end
22
+ def grouped
23
+ index = Hash.new { |hash, type| hash[type] = [] }
24
+ @fragments.each { |fragment| file(index, fragment) }
25
+ index
26
+ end
29
27
 
30
- def file(index, fragment) = rarest(fragment).each { |type| index[type] << fragment }
28
+ def file(index, fragment) = rarest(fragment).each { |type| index[type] << fragment }
31
29
 
32
- def rarest(fragment) = fragment.types.uniq.min_by(RARE) { @document_frequency[it] }
33
- end
34
- end
30
+ def rarest(fragment) = fragment.types.uniq.min_by(RARE) { @frequency[it] }
35
31
  end
@@ -1,21 +1,17 @@
1
1
  # frozen_string_literal: true
2
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
3
+ class Hashira::Duplication::Maximal
4
+ def initialize(clusters)
5
+ @clusters = clusters
6
+ end
17
7
 
18
- def shadowed_by?(cluster, bigger) = cluster.sites.all? { it.overlaps_any?(bigger) }
8
+ def reduced
9
+ @clusters.sort_by { -it.mass }.each_with_object([]) do |cluster, kept|
10
+ kept << cluster unless within?(cluster, kept.flat_map(&:sites))
19
11
  end
20
12
  end
13
+
14
+ private
15
+
16
+ def within?(cluster, bigger) = cluster.sites.all? { it.touches?(bigger) }
21
17
  end
@@ -1,33 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- module Duplication
5
- class NearMiss
6
- THRESHOLD = 0.8
7
- MASS_RATIO = 1.5
3
+ class Hashira::Duplication::NearMiss
4
+ THRESHOLD = 0.8
5
+ MASS_RATIO = 1.5
8
6
 
9
- def initialize(fragments)
10
- @fragments = fragments
11
- end
12
-
13
- def pairs = Index.new(@fragments).buckets.flat_map { |bucket| verified(bucket) }.uniq
7
+ def initialize(fragments)
8
+ @fragments = fragments
9
+ end
14
10
 
15
- private
11
+ def pairs = Hashira::Duplication::Index.new(@fragments).buckets.flat_map { |bucket| verified(bucket) }.uniq
16
12
 
17
- def verified(bucket) = bucket.combination(2).select { |left, right| near?(left, right) }
13
+ private
18
14
 
19
- def near?(left, right)
20
- return false unless comparable?(left, right) && !left.overlaps?(right)
15
+ def verified(bucket) = bucket.combination(2).select { |left, right| near?(left, right) }
21
16
 
22
- drifted?(left.types, right.types)
23
- end
17
+ def near?(left, right)
18
+ return false unless comparable?(left, right) && !left.overlaps?(right)
19
+ drifted?(left.types, right.types)
20
+ end
24
21
 
25
- def drifted?(first, second) = first != second && Similarity.new(first, second).at_least?(THRESHOLD)
22
+ def drifted?(first, second)
23
+ first != second && Hashira::Duplication::Similarity.new(first, second).meets?(THRESHOLD)
24
+ end
26
25
 
27
- def comparable?(left, right)
28
- masses = [left.mass, right.mass]
29
- masses.max <= masses.min * MASS_RATIO
30
- end
31
- end
26
+ def comparable?(left, right)
27
+ masses = [left.mass, right.mass]
28
+ masses.max <= masses.min * MASS_RATIO
32
29
  end
33
30
  end
@@ -1,36 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- module Duplication
5
- class Sequence
6
- MIN_STATEMENTS = 1
7
- MAX_STATEMENTS = 12
8
- LIST_RUN = 3
3
+ class Hashira::Duplication::Sequence
4
+ MIN_STATEMENTS = 1
5
+ MAX_STATEMENTS = 12
6
+ LIST_RUN = 3
7
+
8
+ def initialize(file, statements)
9
+ @file = file
10
+ @statements = statements
11
+ end
9
12
 
10
- def initialize(file, statements)
11
- @file = file
12
- @statements = statements
13
- end
13
+ def fragments = segments.flat_map { windows(it) }
14
14
 
15
- def fragments = segments.flat_map { windows(it) }
15
+ private
16
16
 
17
- private
17
+ def segments = runs.chunk { listing?(it) }.filter_map { |listed, group| group.flatten(1) unless listed }
18
18
 
19
- def segments = runs.chunk { listing?(it) }.filter_map { |listed, group| group.flatten(1) unless listed }
19
+ def listing?(run) = run.size >= LIST_RUN
20
20
 
21
- def listing?(run) = run.size >= LIST_RUN
21
+ def runs = shaped.slice_when { |left, right| left.last != right.last }.map { it.map(&:first) }
22
22
 
23
- def runs = shaped.slice_when { |left, right| left.last != right.last }.map { it.map(&:first) }
23
+ def shaped = @statements.map { [it, fragment([it]).types] }
24
24
 
25
- def shaped = @statements.map { [it, fragment([it]).types] }
25
+ def windows(segment) = lengths(segment).flat_map { |length| slide(segment, length) }
26
26
 
27
- def windows(segment) = lengths(segment).flat_map { |length| slide(segment, length) }
27
+ def lengths(segment) = MIN_STATEMENTS..[segment.size, MAX_STATEMENTS].min
28
28
 
29
- def lengths(segment) = MIN_STATEMENTS..[segment.size, MAX_STATEMENTS].min
29
+ def slide(segment, length) = spans(segment, length).map { fragment(segment[it, length]) }
30
30
 
31
- def slide(segment, length) = (0..(segment.size - length)).map { fragment(segment[it, length]) }
31
+ def spans(segment, length) = 0..(segment.size - length)
32
32
 
33
- def fragment(roots) = Fragment.new(@file, roots)
34
- end
35
- end
33
+ def fragment(roots) = Hashira::Duplication::Fragment.new(@file, roots)
36
34
  end
@@ -1,50 +1,47 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- module Duplication
5
- class Similarity
6
- def initialize(left, right)
7
- @left = left
8
- @right = right
9
- end
3
+ class Hashira::Duplication::Similarity
4
+ def initialize(left, right)
5
+ @left = left
6
+ @right = right
7
+ end
10
8
 
11
- def ratio
12
- return 0.0 if @left.empty? || @right.empty?
9
+ def ratio
10
+ return 0.0 if @left.empty? || @right.empty?
11
+ normalized(lcs)
12
+ end
13
13
 
14
- normalized(lcs)
15
- end
14
+ def meets?(threshold) = ceiling >= threshold && ratio >= threshold
16
15
 
17
- def at_least?(threshold) = upper_bound >= threshold && ratio >= threshold
16
+ private
18
17
 
19
- private
18
+ def ceiling = normalized(overlap)
20
19
 
21
- def upper_bound = normalized(tokens_in_common)
20
+ def normalized(length) = (2.0 * length) / (@left.size + @right.size)
22
21
 
23
- def normalized(length) = (2.0 * length) / (@left.size + @right.size)
22
+ def overlap
23
+ @left.count { taken?(@right.tally, it) }
24
+ end
24
25
 
25
- def tokens_in_common
26
- counts = @right.tally
27
- @left.count { taken?(counts, it) }
28
- end
26
+ def taken?(counts, token)
27
+ return false unless counts.fetch(token, 0).positive?
28
+ counts[token] -= 1
29
+ true
30
+ end
29
31
 
30
- def taken?(counts, token)
31
- return false unless counts.fetch(token, 0).positive?
32
+ def lcs = @left.reduce(blank) { |prev, token| advance(prev, token) }.last
32
33
 
33
- counts[token] -= 1
34
- true
35
- end
34
+ def blank = Array.new(@right.size + 1, 0)
36
35
 
37
- def lcs = @left.reduce(blank) { |prev, token| next_row(prev, token) }.last
36
+ def advance(prev, token)
37
+ @right.each_index.reduce([0]) { |row, index| row << cell(prev, row, token, index) }
38
+ end
38
39
 
39
- def blank = Array.new(@right.size + 1, 0)
40
+ def cell(prev, row, token, index)
41
+ match?(token, index) ? prev[index] + 1 : carry(prev, row, index)
42
+ end
40
43
 
41
- def next_row(prev, token)
42
- @right.each_index.reduce([0]) { |row, index| row << cell(prev, row, token, index) }
43
- end
44
+ def match?(token, index) = @right[index] == token
44
45
 
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
46
+ def carry(prev, row, index) = [prev[index + 1], row[index]].max
50
47
  end
@@ -1,21 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- module Duplication
5
- class UnionFind
6
- def initialize
7
- @parent = {}
8
- end
3
+ class Hashira::Duplication::UnionFind
4
+ def initialize
5
+ @parent = {}
6
+ end
9
7
 
10
- def union(left, right) = @parent[root(left)] = root(right)
8
+ def union(left, right) = @parent[root(left)] = root(right)
11
9
 
12
- def clusters = @parent.keys.group_by { root(it) }.values
10
+ def clusters = @parent.keys.group_by { root(it) }.values
13
11
 
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
12
+ def root(node)
13
+ @parent[node] = node unless @parent.key?(node)
14
+ found = @parent[node]
15
+ found == node ? node : (@parent[node] = root(found))
20
16
  end
21
17
  end
@@ -1,57 +1,49 @@
1
1
  # frozen_string_literal: true
2
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
3
+ class Hashira::Duplication::Variance
4
+ LITERALS = %i[integer_node float_node string_node symbol_node].freeze
5
+ VALUED = %i[integer_node float_node].freeze
6
+ NAMED = %i[call_node constant_read_node constant_path_node
7
+ local_variable_read_node local_variable_write_node
8
+ instance_variable_read_node instance_variable_write_node].freeze
9
+ CONSTANTS = %i[constant_read_node constant_path_node].freeze
10
+
11
+ def initialize(canonical, other)
12
+ @canonical = canonical
13
+ @other = other
14
+ end
26
15
 
27
- named.any? && named.all? { |left, right| left.name != right.name }
28
- end
16
+ def kinds
17
+ return [:structure] if @canonical.types != @other.types
18
+ differing.map { |node| category(node) }.uniq
19
+ end
29
20
 
30
- private
21
+ def structural?
22
+ return false unless @canonical.types == @other.types
23
+ named.any? && named.all? { |left, right| left.name != right.name }
24
+ end
31
25
 
32
- def pairs = @canonical.nodes.zip(@other.nodes)
26
+ private
33
27
 
34
- def named = @named ||= pairs.select { |left, _| NAMED.include?(left.type) }
28
+ def pairs = @canonical.nodes.zip(@other.nodes)
35
29
 
36
- def differing = pairs.select { |pair| varies?(*pair) }.map(&:first)
30
+ def named = @named ||= pairs.select { |left, _| NAMED.include?(left.type) }
37
31
 
38
- def varies?(left, right) = signature(left) != signature(right)
32
+ def differing = pairs.select { |pair| varies?(*pair) }.map(&:first)
39
33
 
40
- def category(node)
41
- type = node.type
42
- return :literal if LITERALS.include?(type)
34
+ def varies?(left, right) = signature(left) != signature(right)
43
35
 
44
- CONSTANTS.include?(type) ? :constant : :message
45
- end
36
+ def category(node)
37
+ type = node.type
38
+ return :literal if LITERALS.include?(type)
39
+ CONSTANTS.include?(type) ? :constant : :message
40
+ end
46
41
 
47
- def signature(node)
48
- type = node.type
49
- return literal(node) if LITERALS.include?(type)
42
+ def signature(node)
43
+ LITERALS.include?(node.type) ? literal(node) : label(node)
44
+ end
50
45
 
51
- node.name if NAMED.include?(type)
52
- end
46
+ def label(node) = (node.name if NAMED.include?(node.type))
53
47
 
54
- def literal(node) = VALUED.include?(node.type) ? node.value : node.unescaped
55
- end
56
- end
48
+ def literal(node) = VALUED.include?(node.type) ? node.value : node.unescaped
57
49
  end
data/lib/hashira/error.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- class Error < StandardError; end
3
+ class Hashira::Error < StandardError
4
+ def self.unknown(flag, value, choices)
5
+ new("unknown #{flag} #{value.inspect} (use: #{choices.join(", ")})")
6
+ end
5
7
  end
@@ -4,16 +4,17 @@ module Hashira
4
4
  module Hotspots
5
5
  CHURN_FLOOR = 1
6
6
 
7
- FileCost = Data.define(:file, :cognitive, :duplication, :churn) do
8
- def cost = cognitive + duplication
7
+ FileCost =
8
+ Data.define(:file, :cognitive, :duplication, :churn) do
9
+ def cost = cognitive + duplication
9
10
 
10
- def rank = cost * effective_churn
11
+ def rank = cost * heat
11
12
 
12
- def effective_churn = [churn, CHURN_FLOOR].max
13
+ def heat = [churn, CHURN_FLOOR].max
13
14
 
14
- def to_h = { file:, cognitive:, duplication:, churn:, cost:, rank: }
15
+ def to_h = { file:, cognitive:, duplication:, churn:, cost:, rank: }
15
16
 
16
- def cells = [file, cognitive, duplication, churn, rank]
17
- end
17
+ def cells = [file, cognitive, duplication, churn, rank]
18
+ end
18
19
  end
19
20
  end