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,31 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hashira
4
- module Analysis
4
+ module Coupling
5
5
  module NamespacePrefix
6
6
  module_function
7
7
 
8
8
  def infer(definitions)
9
- build(deepest_definition_per_package(definitions))
9
+ build(definitions.map { |_node, full, _package| full }.uniq)
10
10
  end
11
11
 
12
12
  def build(fulls, prefix = [])
13
13
  depth = prefix.length
14
- wrapper = shared_wrapper(fulls, depth)
14
+ wrapper = wrapper(fulls, depth)
15
15
  return prefix unless wrapper
16
-
17
16
  build(fulls.select { it[depth] == wrapper }, prefix + [wrapper])
18
17
  end
19
18
 
20
- def deepest_definition_per_package(definitions)
21
- definitions.map { |_node, full, package| [package, full] }
22
- .sort_by { |_package, full| full.length }
23
- .to_h.values
19
+ def wrapper(fulls, depth)
20
+ wrapper, count = fulls.filter_map { it[depth] if it[depth + 1] }.tally.max_by { |_wrapper, tally| tally }
21
+ wrapper if wrapper && count == peers(fulls, depth, wrapper)
24
22
  end
25
23
 
26
- def shared_wrapper(fulls, depth)
27
- wrapper, count = fulls.filter_map { it[depth] if it[depth + 1] }.tally.max_by { |_wrapper, tally| tally }
28
- wrapper if wrapper && count > fulls.size / 2
24
+ def peers(fulls, depth, wrapper)
25
+ fulls.count do |full|
26
+ size = full.length
27
+ size > depth && !(size == depth + 1 && full[depth] == wrapper)
28
+ end
29
29
  end
30
30
  end
31
31
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Coupling::Naming
4
+ def initialize(definitions)
5
+ @segments = Hashira::Coupling::NamespacePrefix.infer(definitions)
6
+ end
7
+
8
+ attr_reader :segments
9
+
10
+ def strip(path)
11
+ return [] if @segments.first(path.length) == path
12
+ path.drop(@segments.length.downto(0).find { path.first(it) == @segments.last(it) })
13
+ end
14
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Coupling
5
+ module NoFolding
6
+ module_function
7
+
8
+ def map = {}
9
+
10
+ def disclosed = []
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Coupling::Placement
4
+ def self.build(packaging, project, catalog)
5
+ {
6
+ folder: Hashira::Coupling::FolderPlacement,
7
+ namespace: Hashira::Coupling::NamespacePlacement
8
+ }.fetch(packaging).new(project, catalog)
9
+ end
10
+
11
+ def initialize(project, catalog)
12
+ @project = project
13
+ @catalog = catalog
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :project, :catalog
19
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ class Hashira::Coupling::References
6
+ def self.list(tree) = new.sightings(tree).map(&:first)
7
+
8
+ def self.sightings(tree, roots = nil) = new(roots).sightings(tree)
9
+
10
+ def initialize(roots = nil)
11
+ @roots = roots
12
+ @found = []
13
+ @scopes = [[]]
14
+ end
15
+
16
+ def sightings(tree)
17
+ collect(tree)
18
+ @found
19
+ end
20
+
21
+ private
22
+
23
+ def nesting = @scopes.last
24
+
25
+ def collect(node, home = nesting)
26
+ return unless node
27
+ return @found << sighting(node, home) if constant?(node)
28
+ return enter(node) if definition?(node)
29
+ node.compact_child_nodes.each { collect(it, home) }
30
+ end
31
+
32
+ def sighting(node, home)
33
+ [syntax.segments(node), node.location.start_line, syntax.cbase?(node) ? nil : nesting, home]
34
+ end
35
+
36
+ def syntax = Hashira::Analysis::Syntax
37
+
38
+ def constant?(node) = node.is_a?(Prism::ConstantPathNode) || node.is_a?(Prism::ConstantReadNode)
39
+
40
+ def definition?(node) = node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
41
+
42
+ def enter(node)
43
+ opened = nesting + [anchor(node)]
44
+ collect(node.superclass, opened) if node.is_a?(Prism::ClassNode)
45
+ inside(opened) { collect(node.body) }
46
+ end
47
+
48
+ def anchor(node) = syntax.anchor(nesting, syntax.segments(node.constant_path), @roots)
49
+
50
+ def inside(scope)
51
+ @scopes << scope
52
+ yield
53
+ @scopes.pop
54
+ end
55
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cycle_findings"
4
+ require_relative "mixed_audience_findings"
5
+ require_relative "roll_call_findings"
6
+ require_relative "sdp_violation_findings"
7
+ require_relative "wide_edge_findings"
8
+
9
+ class Hashira::Coupling::Report
10
+ RULES = [
11
+ Hashira::Coupling::CycleFindings, Hashira::Coupling::SdpViolationFindings,
12
+ Hashira::Coupling::MixedAudienceFindings, Hashira::Coupling::WideEdgeFindings,
13
+ Hashira::Coupling::RollCallFindings
14
+ ].freeze
15
+
16
+ def initialize(project, trees, packaging:)
17
+ @project = project
18
+ @graph = Hashira::Coupling::Graph.new(project, trees, census(trees, packaging))
19
+ end
20
+
21
+ attr_reader :graph
22
+
23
+ def findings = RULES.flat_map { it.new(@project, @graph).list }
24
+
25
+ private
26
+
27
+ def census(trees, packaging) = Hashira::Coupling::Census.new(@project, trees, packaging:)
28
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Coupling::RollCall
4
+ MIN_WORDS = 3
5
+
6
+ MIN_FILES = 3
7
+
8
+ MIN_PACKAGES = 2
9
+
10
+ Roll = Data.define(:words, :files, :packages)
11
+
12
+ def initialize(lists, homes)
13
+ @lists = lists
14
+ @homes = homes
15
+ end
16
+
17
+ def rolls = maximal(candidates.filter_map { entry(it) }).sort_by(&:words)
18
+
19
+ private
20
+
21
+ def files = @files ||= @lists.keys.sort
22
+
23
+ def candidates
24
+ files.combination(2).map { |one, two| @lists[one] & @lists[two] }.select { it.size >= MIN_WORDS }.uniq
25
+ end
26
+
27
+ def entry(words)
28
+ holders = files.select { @lists[it] >= words }
29
+ spread = holders.map { @homes[it] }.uniq.sort
30
+ return unless holders.size >= MIN_FILES && spread.size >= MIN_PACKAGES
31
+ Roll.new(words: words.sort, files: holders, packages: spread)
32
+ end
33
+
34
+ def maximal(rolls)
35
+ rolls.reject { |roll| swallowed?(roll, rolls) }
36
+ end
37
+
38
+ def swallowed?(roll, rolls) = rolls.any? { swallows?(it, roll) }
39
+
40
+ def swallows?(big, small)
41
+ mine = big.words
42
+ theirs = small.words
43
+ mine != theirs && (theirs - mine).empty?
44
+ end
45
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rule"
4
+
5
+ class Hashira::Coupling::RollCallFindings < Hashira::Coupling::Rule
6
+ KIND = "roll_call"
7
+
8
+ def list = rolls.map { entry(it) }
9
+
10
+ private
11
+
12
+ def rolls = Hashira::Coupling::RollCall.new(lists, homes).rolls
13
+
14
+ def sightings
15
+ @sightings ||=
16
+ graph.trees.filter_map do |file, tree|
17
+ words = Hashira::Coupling::Words.list(tree)
18
+ [project.relative(file), graph.charge(file), words] unless words.empty?
19
+ end
20
+ end
21
+
22
+ def lists = sightings.to_h { |file, _home, words| [file, words] }
23
+
24
+ def homes = sightings.to_h { |file, home, _words| [file, home] }
25
+
26
+ def entry(roll)
27
+ words, files, packages = roll.deconstruct
28
+ finding(package: packages.first, digest: words.join(","), evidence: files, detail: { words:, files:, packages: })
29
+ end
30
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Coupling::Roster
4
+ def initialize(placed)
5
+ @registry = Hashira::Coupling::ConstantRegistry.new
6
+ @types = Hash.new(0)
7
+ counted = Set.new
8
+ placed.each { |definition, package| admit(definition, package, counted) }
9
+ end
10
+
11
+ attr_reader :registry, :types
12
+
13
+ def origins = @registry.origins
14
+
15
+ def packages = @types.keys | @registry.packages
16
+
17
+ private
18
+
19
+ def admit(definition, package, counted)
20
+ return unless package
21
+ path = definition.path
22
+ @registry.register(path, package)
23
+ @types[package] += 1 if definition.counted? && counted.add?(path)
24
+ end
25
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Coupling::Rule
4
+ def initialize(project, graph)
5
+ @project = project
6
+ @graph = graph
7
+ end
8
+
9
+ private
10
+
11
+ attr_reader :project, :graph
12
+
13
+ def metrics = @metrics ||= graph.metrics
14
+
15
+ def finding(**attributes)
16
+ Hashira::Analysis::Finding.new(kind: self.class::KIND, cycle: nil, **attributes)
17
+ end
18
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Coupling::Scope
4
+ def initialize(registry, catalog, placement)
5
+ @registry = registry
6
+ @catalog = catalog
7
+ @placement = placement
8
+ end
9
+
10
+ def resolve(segments, nesting)
11
+ return if @placement.skip?(segments)
12
+ scoped = nesting.reverse_each.filter_map { descend(it, segments) }.first
13
+ scoped ? qualify(scoped) : @registry.package(@catalog.strip(segments))
14
+ end
15
+
16
+ def pinpoint(segments)
17
+ return if @placement.skip?(segments)
18
+ qualify(descend([], segments))
19
+ end
20
+
21
+ private
22
+
23
+ def descend(entry, segments)
24
+ segments.length.downto(1).filter_map { @registry.exact(@catalog.strip(entry + segments.first(it))) }.first
25
+ end
26
+
27
+ def qualify(found) = (found unless found == Hashira::Coupling::ConstantRegistry::AMBIGUOUS)
28
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Coupling::SdpCheck
4
+ def initialize(dependencies, metrics)
5
+ @dependencies = dependencies
6
+ @metrics = metrics
7
+ end
8
+
9
+ def violations
10
+ @dependencies.flat_map do |from, tos|
11
+ tos.select { @metrics[it].instability > @metrics[from].instability }.map { [from, it] }
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rule"
4
+
5
+ class Hashira::Coupling::SdpViolationFindings < Hashira::Coupling::Rule
6
+ KIND = "sdp_violation"
7
+
8
+ def list
9
+ ranked.map { |from, to| violation(from, to) }
10
+ end
11
+
12
+ private
13
+
14
+ def ranked = graph.violations.sort_by { |from, to| instability(from) - instability(to) }
15
+
16
+ def violation(from, to)
17
+ finding(package: from, evidence: graph.evidence(from, to).to_a.first(5), detail: detail(from, to))
18
+ end
19
+
20
+ def detail(from, to)
21
+ { from:, to:, from_instability: instability(from), to_instability: instability(to) }
22
+ end
23
+
24
+ def instability(package) = metrics[package].instability
25
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rule"
4
+
5
+ class Hashira::Coupling::WideEdgeFindings < Hashira::Coupling::Rule
6
+ KIND = "wide_edge"
7
+
8
+ WIDTH = 5
9
+
10
+ def list
11
+ graph.edges.select { wide?(it) }.map { entry(it) }
12
+ end
13
+
14
+ private
15
+
16
+ def wide?(edge) = graph.constants(edge).size >= WIDTH
17
+
18
+ def entry(edge)
19
+ from, to = edge.deconstruct
20
+ finding(
21
+ package: from, digest: "#{from} -> #{to}", evidence: graph.evidence(from, to).to_a.first(4),
22
+ detail: { from:, to:, constants: graph.constants(edge) }
23
+ )
24
+ end
25
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Hashira::Coupling::Words
6
+ PATTERN = /\A[a-z][a-z_]*\z/
7
+
8
+ module_function
9
+
10
+ def list(tree)
11
+ Hashira::Analysis::NodeWalk.collect(tree).flat_map { entries(it) }.compact.grep(PATTERN).to_set
12
+ end
13
+
14
+ def entries(node)
15
+ case node
16
+ when Prism::ArrayNode then node.elements.map { word(it) }
17
+ when Prism::HashNode then keys(node)
18
+ else []
19
+ end
20
+ end
21
+
22
+ def keys(node) = node.elements.grep(Prism::AssocNode).map { word(it.key) }
23
+
24
+ def word(node)
25
+ case node
26
+ when Prism::SymbolNode, Prism::StringNode then node.unescaped
27
+ end
28
+ end
29
+ 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
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Diagram::Source
4
+ def initialize(graph, format, io: $stdout)
5
+ @graph = graph
6
+ @format = format
7
+ @io = io
8
+ end
9
+
10
+ def print
11
+ edges = @graph.weighted
12
+ @io.puts((@format == :dot ? Hashira::Diagram::Dot.new(edges) : Hashira::Diagram::Mermaid.new(edges)).source)
13
+ 0
14
+ end
15
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Duplication::Clones
4
+ def initialize(project, trees, churn)
5
+ @project = project
6
+ @trees = trees
7
+ @churn = churn
8
+ end
9
+
10
+ def clusters = @clusters ||= Hashira::Duplication::Clusters.new(fragments).sorted
11
+
12
+ def findings = clusters.map { |cluster| Hashira::Duplication::DuplicationFinding.new(cluster, @churn).to_finding }
13
+
14
+ private
15
+
16
+ def fragments = Hashira::Duplication::Harvest.new(@project, @trees).fragments
17
+ end
@@ -2,25 +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
14
15
 
15
- def exact_sites = sites.select { it.types == canonical.types }
16
+ def identical = sites.select { it.types == canonical.types }
16
17
 
17
- def mass = canonical.mass
18
+ def mass = canonical.mass
18
19
 
19
- def size = sites.size
20
+ def size = sites.size
20
21
 
21
- def site_masses = sites.map { [it.file, mass] }
22
+ def masses = sites.map { [it.file, mass] }
22
23
 
23
- def shape_only? = others.all? { Variance.new(canonical, it).shape_only? }
24
- end
24
+ def structural? = others.all? { Variance.new(canonical, it).structural? }
25
+ end
25
26
  end
26
27
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Duplication::Clusters
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
14
+
15
+ def sorted
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.sort_by { -it.mass }
19
+ end
20
+
21
+ private
22
+
23
+ def chain(group) = group.each_cons(2) { |left, right| @sets.union(left, right) }
24
+
25
+ def sized = built.filter_map { admitted(it) }
26
+
27
+ def admitted(cluster) = [cluster, core(cluster)].compact.find { fits?(it) }
28
+
29
+ def fits?(cluster) = cluster.mass >= floor(cluster)
30
+
31
+ def core(cluster) = Hashira::Duplication::Grouping.new(cluster.identical).cluster
32
+
33
+ def built = @sets.clusters.filter_map { |group| Hashira::Duplication::Grouping.new(group).cluster }
34
+
35
+ def floor(cluster) = base(cluster) + penalty(cluster)
36
+
37
+ def base(cluster) = thin?(cluster) ? NEAR_MASS : BASE_MASS
38
+
39
+ def thin?(cluster) = !uniform?(cluster) || cluster.structural?
40
+
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
46
+ end
@@ -1,39 +1,30 @@
1
1
  # frozen_string_literal: true
2
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)
3
+ class Hashira::Duplication::Delta
4
+ def initialize(cluster)
5
+ @cluster = cluster
6
+ end
20
7
 
21
- def kind
22
- tags = kinds
23
- return :identical if tags.empty?
24
- return :structure if tags.include?(:structure)
8
+ def kind
9
+ tags = kinds
10
+ tags.empty? ? :identical : label(tags)
11
+ end
25
12
 
26
- tags.size == 1 ? tags.first : :mixed
27
- end
13
+ def to_h
14
+ {
15
+ mass: @cluster.mass, sites: @cluster.size, kind:,
16
+ locations: @cluster.sites.sort_by(&:rank).map(&:range)
17
+ }
18
+ end
28
19
 
29
- def to_h
30
- { mass: @cluster.mass, sites: @cluster.size, kind:,
31
- locations: @cluster.sites.sort_by(&:sort_key).map(&:range) }
32
- end
20
+ private
33
21
 
34
- private
22
+ def label(tags)
23
+ return :structure if tags.include?(:structure)
24
+ tags.size == 1 ? tags.first : :mixed
25
+ end
35
26
 
36
- def kinds = @cluster.others.flat_map { |other| Variance.new(@cluster.canonical, other).kinds }.uniq
37
- end
27
+ def kinds
28
+ @cluster.others.flat_map { |other| Hashira::Duplication::Variance.new(@cluster.canonical, other).kinds }.uniq
38
29
  end
39
30
  end