hashira 0.4.0 → 0.5.1

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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -0
  3. data/README.md +90 -30
  4. data/lib/hashira/analysis/finding.rb +2 -2
  5. data/lib/hashira/analysis/syntax.rb +6 -2
  6. data/lib/hashira/churn.rb +1 -1
  7. data/lib/hashira/ci/ratchet.rb +5 -9
  8. data/lib/hashira/cli/command_line.rb +10 -49
  9. data/lib/hashira/cli/fail_on.rb +9 -5
  10. data/lib/hashira/cli/flag.rb +29 -0
  11. data/lib/hashira/cli/flags.rb +50 -0
  12. data/lib/hashira/cli/format.rb +13 -0
  13. data/lib/hashira/cli/options.rb +2 -0
  14. data/lib/hashira/cli/run.rb +8 -4
  15. data/lib/hashira/cli/usage.rb +20 -21
  16. data/lib/hashira/cli.rb +1 -1
  17. data/lib/hashira/complexity/boolean_run.rb +5 -3
  18. data/lib/hashira/complexity/cognitive_score.rb +21 -14
  19. data/lib/hashira/complexity/if_chain.rb +14 -14
  20. data/lib/hashira/complexity/method_finding.rb +4 -19
  21. data/lib/hashira/complexity/rescue_scan.rb +9 -9
  22. data/lib/hashira/complexity/{analyzer.rb → scores.rb} +6 -5
  23. data/lib/hashira/coupling/audiences.rb +49 -0
  24. data/lib/hashira/{analysis → coupling}/catalog.rb +3 -3
  25. data/lib/hashira/{analysis → coupling}/census.rb +17 -8
  26. data/lib/hashira/{analysis → coupling}/constant_registry.rb +11 -6
  27. data/lib/hashira/coupling/cycle_findings.rb +31 -0
  28. data/lib/hashira/{analysis → coupling}/cycle_search.rb +1 -1
  29. data/lib/hashira/{analysis → coupling}/cycles.rb +2 -2
  30. data/lib/hashira/{analysis → coupling}/definition.rb +6 -2
  31. data/lib/hashira/{analysis → coupling}/definitions.rb +5 -2
  32. data/lib/hashira/{analysis → coupling}/edge.rb +1 -1
  33. data/lib/hashira/{analysis → coupling}/edge_map.rb +10 -10
  34. data/lib/hashira/{analysis → coupling}/folder_placement.rb +2 -2
  35. data/lib/hashira/{analysis → coupling}/folding.rb +4 -2
  36. data/lib/hashira/{analysis → coupling}/graph.rb +18 -8
  37. data/lib/hashira/{analysis → coupling}/metric.rb +1 -1
  38. data/lib/hashira/coupling/mixed_audience_findings.rb +32 -0
  39. data/lib/hashira/{analysis → coupling}/namespace_placement.rb +3 -3
  40. data/lib/hashira/{analysis → coupling}/namespace_prefix.rb +1 -1
  41. data/lib/hashira/{analysis → coupling}/naming.rb +2 -2
  42. data/lib/hashira/{analysis → coupling}/no_folding.rb +1 -1
  43. data/lib/hashira/{analysis → coupling}/placement.rb +3 -3
  44. data/lib/hashira/coupling/references.rb +55 -0
  45. data/lib/hashira/coupling/report.rb +28 -0
  46. data/lib/hashira/coupling/roll_call.rb +45 -0
  47. data/lib/hashira/coupling/roll_call_findings.rb +30 -0
  48. data/lib/hashira/{analysis → coupling}/roster.rb +6 -2
  49. data/lib/hashira/{analysis → coupling}/rule.rb +1 -1
  50. data/lib/hashira/{analysis/resolver.rb → coupling/scope.rb} +7 -3
  51. data/lib/hashira/{analysis → coupling}/sdp_check.rb +1 -1
  52. data/lib/hashira/coupling/sdp_violation_findings.rb +25 -0
  53. data/lib/hashira/coupling/wide_edge_findings.rb +25 -0
  54. data/lib/hashira/coupling/words.rb +29 -0
  55. data/lib/hashira/diagram/{renderer.rb → source.rb} +2 -2
  56. data/lib/hashira/duplication/{analyzer.rb → clones.rb} +3 -3
  57. data/lib/hashira/duplication/{clusterer.rb → clusters.rb} +3 -3
  58. data/lib/hashira/duplication/delta.rb +6 -14
  59. data/lib/hashira/duplication/duplication_finding.rb +8 -10
  60. data/lib/hashira/duplication/{extractor.rb → harvest.rb} +1 -1
  61. data/lib/hashira/duplication/sequence.rb +3 -1
  62. data/lib/hashira/duplication/similarity.rb +5 -1
  63. data/lib/hashira/duplication/variance.rb +3 -3
  64. data/lib/hashira/hotspots/rollup.rb +1 -1
  65. data/lib/hashira/pipeline.rb +18 -10
  66. data/lib/hashira/report/complexity_table.rb +1 -1
  67. data/lib/hashira/report/finding_lines.rb +1 -1
  68. data/lib/hashira/report/json.rb +5 -3
  69. data/lib/hashira/report/phrases.rb +98 -0
  70. data/lib/hashira/report/smell_phrases.rb +68 -0
  71. data/lib/hashira/smells/census.rb +32 -0
  72. data/lib/hashira/smells/check.rb +34 -0
  73. data/lib/hashira/smells/conditions.rb +44 -0
  74. data/lib/hashira/smells/contexts.rb +64 -0
  75. data/lib/hashira/smells/control_parameter.rb +22 -0
  76. data/lib/hashira/smells/data_clump.rb +32 -0
  77. data/lib/hashira/smells/duplicate_method_call.rb +54 -0
  78. data/lib/hashira/smells/feature_envy.rb +17 -0
  79. data/lib/hashira/smells/instance_variable_assumption.rb +46 -0
  80. data/lib/hashira/smells/manual_dispatch.rb +16 -0
  81. data/lib/hashira/smells/module_initialize.rb +11 -0
  82. data/lib/hashira/smells/nil_check.rb +32 -0
  83. data/lib/hashira/smells/param_check.rb +51 -0
  84. data/lib/hashira/smells/refs.rb +53 -0
  85. data/lib/hashira/smells/repeated_conditional.rb +31 -0
  86. data/lib/hashira/smells/report.rb +34 -0
  87. data/lib/hashira/smells/scope.rb +29 -0
  88. data/lib/hashira/smells/too_many_instance_variables.rb +26 -0
  89. data/lib/hashira/smells/utility_function.rb +13 -0
  90. data/lib/hashira/smells/visibility.rb +64 -0
  91. data/lib/hashira/version.rb +1 -1
  92. data/lib/hashira.rb +68 -30
  93. metadata +63 -31
  94. data/lib/hashira/analysis/cycle_findings.rb +0 -38
  95. data/lib/hashira/analysis/references.rb +0 -40
  96. data/lib/hashira/analysis/sdp_violation_findings.rb +0 -29
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "prism"
4
4
 
5
- class Hashira::Analysis::Folding
5
+ class Hashira::Coupling::Folding
6
6
  SUFFIXES = %w[Resource Serializer Policy Decorator].freeze
7
7
 
8
8
  def initialize(definitions, census, suffixes: [])
@@ -32,10 +32,12 @@ class Hashira::Analysis::Folding
32
32
  def pair(name, stem) = ([name, stem] if stem && @census.packages.include?(stem))
33
33
 
34
34
  def crop(name)
35
- suffix = @suffixes.find { name.end_with?(it) && name != it }
35
+ suffix = @suffixes.find { trims?(name, it) }
36
36
  suffix && name.delete_suffix(suffix)
37
37
  end
38
38
 
39
+ def trims?(name, suffix) = name.end_with?(suffix) && name != suffix
40
+
39
41
  def singles = @singles ||= lone.group_by(&:name).transform_values { it.find(&:superclass) || it.last }
40
42
 
41
43
  def lone = @definitions.select(&:singular?).reject { anchored.include?(it.name) }
@@ -1,14 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Hashira::Analysis::Graph
3
+ class Hashira::Coupling::Graph
4
4
  def initialize(project, trees, census)
5
5
  @census = census
6
- @map = Hashira::Analysis::EdgeMap.new(project, census)
7
- trees.each { |file, tree| @map.record(file, tree) }
8
- @cycles = Hashira::Analysis::Cycles.new(@map.dependencies, self)
6
+ @trees = trees
7
+ @map = Hashira::Coupling::EdgeMap.new(census)
8
+ trees.each { |file, tree| @map.record(project.relative(file), file, tree) }
9
+ @cycles = Hashira::Coupling::Cycles.new(@map.dependencies, self)
9
10
  end
10
11
 
11
- attr_reader :cycles
12
+ attr_reader :cycles, :trees
13
+
14
+ def charge(file) = @census.charge(file, [])
12
15
 
13
16
  def packages = (@census.packages | @map.dependencies.keys)
14
17
 
@@ -21,7 +24,7 @@ class Hashira::Analysis::Graph
21
24
  def incoming(package) = packages.select { dependencies[it].include?(package) }.sort
22
25
 
23
26
  def edges
24
- dependencies.sort.flat_map { |from, tos| tos.sort.map { Hashira::Analysis::Edge.new(from:, to: it) } }
27
+ dependencies.sort.flat_map { |from, tos| tos.sort.map { Hashira::Coupling::Edge.new(from:, to: it) } }
25
28
  end
26
29
 
27
30
  def weighted
@@ -33,8 +36,15 @@ class Hashira::Analysis::Graph
33
36
 
34
37
  def evidence(from, to) = @map.evidence[[from, to]]
35
38
 
39
+ def usage(package) = incoming(package).to_h { [it, @map.usage[[it, package]]] }
40
+
41
+ def constants(edge)
42
+ from, to = edge.deconstruct
43
+ @map.usage[[from, to]].sort
44
+ end
45
+
36
46
  def metric(package)
37
- Hashira::Analysis::Metric.new(
47
+ Hashira::Coupling::Metric.new(
38
48
  types: @census.types[package],
39
49
  afferent: incoming(package).size,
40
50
  efferent: dependencies[package].size
@@ -43,7 +53,7 @@ class Hashira::Analysis::Graph
43
53
 
44
54
  def metrics = packages.to_h { [it, metric(it)] }
45
55
 
46
- def violations = Hashira::Analysis::SdpCheck.new(dependencies, metrics).violations
56
+ def violations = Hashira::Coupling::SdpCheck.new(dependencies, metrics).violations
47
57
 
48
58
  def weight(from, to) = evidence(from, to).size
49
59
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hashira
4
- module Analysis
4
+ module Coupling
5
5
  Metric =
6
6
  Data.define(:types, :afferent, :efferent) do
7
7
  def instability
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rule"
4
+
5
+ class Hashira::Coupling::MixedAudienceFindings < Hashira::Coupling::Rule
6
+ KIND = "mixed_audience"
7
+
8
+ def list
9
+ graph.packages.sort.filter_map { verdict(it) }
10
+ end
11
+
12
+ private
13
+
14
+ def verdict(package)
15
+ audiences = Hashira::Coupling::Audiences.new(graph.usage(package))
16
+ detail(package, audiences.parts) if audiences.split?
17
+ end
18
+
19
+ def detail(package, parts)
20
+ finding(
21
+ package:, evidence: parts.flat_map do
22
+ sightings(package, it).first(2)
23
+ end, detail: { parts: parts.map(&:to_h) }
24
+ )
25
+ end
26
+
27
+ def sightings(package, part)
28
+ part.users.flat_map { |client| graph.evidence(client, package).to_a.select { mentions?(it, part.constants) } }
29
+ end
30
+
31
+ def mentions?(line, constants) = constants.any? { line.end_with?(": #{it}", "::#{it}") }
32
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "placement"
4
4
 
5
- class Hashira::Analysis::NamespacePlacement < Hashira::Analysis::Placement
5
+ class Hashira::Coupling::NamespacePlacement < Hashira::Coupling::Placement
6
6
  RAILS_BASES =
7
7
  %w[ApplicationRecord ApplicationController ApplicationJob ApplicationMailer
8
8
  ApplicationHelper ApplicationCable ApplicationResource ApplicationSerializer
@@ -20,9 +20,9 @@ class Hashira::Analysis::NamespacePlacement < Hashira::Analysis::Placement
20
20
 
21
21
  def skip?(segments) = project.rails? && RAILS_BASES.include?(segments.first)
22
22
 
23
- def folding(census) = Hashira::Analysis::Folding.new(catalog, census, suffixes: suffixes)
23
+ def folding(census) = Hashira::Coupling::Folding.new(catalog, census, suffixes: suffixes)
24
24
 
25
25
  private
26
26
 
27
- def suffixes = project.rails? ? Hashira::Analysis::Folding::SUFFIXES : []
27
+ def suffixes = project.rails? ? Hashira::Coupling::Folding::SUFFIXES : []
28
28
  end
@@ -1,7 +1,7 @@
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
 
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Hashira::Analysis::Naming
3
+ class Hashira::Coupling::Naming
4
4
  def initialize(definitions)
5
- @segments = Hashira::Analysis::NamespacePrefix.infer(definitions)
5
+ @segments = Hashira::Coupling::NamespacePrefix.infer(definitions)
6
6
  end
7
7
 
8
8
  attr_reader :segments
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hashira
4
- module Analysis
4
+ module Coupling
5
5
  module NoFolding
6
6
  module_function
7
7
 
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Hashira::Analysis::Placement
3
+ class Hashira::Coupling::Placement
4
4
  def self.build(packaging, project, catalog)
5
5
  {
6
- folder: Hashira::Analysis::FolderPlacement,
7
- namespace: Hashira::Analysis::NamespacePlacement
6
+ folder: Hashira::Coupling::FolderPlacement,
7
+ namespace: Hashira::Coupling::NamespacePlacement
8
8
  }.fetch(packaging).new(project, catalog)
9
9
  end
10
10
 
@@ -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
@@ -1,9 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Hashira::Analysis::Roster
3
+ class Hashira::Coupling::Roster
4
4
  def initialize(placed)
5
- @registry = Hashira::Analysis::ConstantRegistry.new
5
+ @registry = Hashira::Coupling::ConstantRegistry.new
6
6
  @types = Hash.new(0)
7
+ @typed = Set.new
7
8
  counted = Set.new
8
9
  placed.each { |definition, package| admit(definition, package, counted) }
9
10
  end
@@ -12,6 +13,8 @@ class Hashira::Analysis::Roster
12
13
 
13
14
  def origins = @registry.origins
14
15
 
16
+ def type?(path) = @typed.include?(path)
17
+
15
18
  def packages = @types.keys | @registry.packages
16
19
 
17
20
  private
@@ -20,6 +23,7 @@ class Hashira::Analysis::Roster
20
23
  return unless package
21
24
  path = definition.path
22
25
  @registry.register(path, package)
26
+ @typed << path if definition.type?
23
27
  @types[package] += 1 if definition.counted? && counted.add?(path)
24
28
  end
25
29
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Hashira::Analysis::Rule
3
+ class Hashira::Coupling::Rule
4
4
  def initialize(project, graph)
5
5
  @project = project
6
6
  @graph = graph
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Hashira::Analysis::Resolver
3
+ class Hashira::Coupling::Scope
4
+ CORE = Object.constants.select { Object.const_source_location(it) == [] }.to_set(&:to_s).freeze
5
+
4
6
  def initialize(registry, catalog, placement)
5
7
  @registry = registry
6
8
  @catalog = catalog
@@ -10,7 +12,7 @@ class Hashira::Analysis::Resolver
10
12
  def resolve(segments, nesting)
11
13
  return if @placement.skip?(segments)
12
14
  scoped = nesting.reverse_each.filter_map { descend(it, segments) }.first
13
- scoped ? qualify(scoped) : @registry.package(@catalog.strip(segments))
15
+ scoped ? qualify(scoped) : outward(@catalog.strip(segments))
14
16
  end
15
17
 
16
18
  def pinpoint(segments)
@@ -20,9 +22,11 @@ class Hashira::Analysis::Resolver
20
22
 
21
23
  private
22
24
 
25
+ def outward(path) = CORE.include?(path.first) ? @registry.rooted(path) : @registry.package(path)
26
+
23
27
  def descend(entry, segments)
24
28
  segments.length.downto(1).filter_map { @registry.exact(@catalog.strip(entry + segments.first(it))) }.first
25
29
  end
26
30
 
27
- def qualify(found) = (found unless found == Hashira::Analysis::ConstantRegistry::AMBIGUOUS)
31
+ def qualify(found) = (found unless found == Hashira::Coupling::ConstantRegistry::AMBIGUOUS)
28
32
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Hashira::Analysis::SdpCheck
3
+ class Hashira::Coupling::SdpCheck
4
4
  def initialize(dependencies, metrics)
5
5
  @dependencies = dependencies
6
6
  @metrics = metrics
@@ -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,13 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Hashira::Diagram::Renderer
3
+ class Hashira::Diagram::Source
4
4
  def initialize(graph, format, io: $stdout)
5
5
  @graph = graph
6
6
  @format = format
7
7
  @io = io
8
8
  end
9
9
 
10
- def display
10
+ def print
11
11
  edges = @graph.weighted
12
12
  @io.puts((@format == :dot ? Hashira::Diagram::Dot.new(edges) : Hashira::Diagram::Mermaid.new(edges)).source)
13
13
  0
@@ -1,17 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Hashira::Duplication::Analyzer
3
+ class Hashira::Duplication::Clones
4
4
  def initialize(project, trees, churn)
5
5
  @project = project
6
6
  @trees = trees
7
7
  @churn = churn
8
8
  end
9
9
 
10
- def clusters = @clusters ||= Hashira::Duplication::Clusterer.new(fragments).clusters.sort_by { -it.mass }
10
+ def clusters = @clusters ||= Hashira::Duplication::Clusters.new(fragments).sorted
11
11
 
12
12
  def findings = clusters.map { |cluster| Hashira::Duplication::DuplicationFinding.new(cluster, @churn).to_finding }
13
13
 
14
14
  private
15
15
 
16
- def fragments = Hashira::Duplication::Extractor.new(@project, @trees).fragments
16
+ def fragments = Hashira::Duplication::Harvest.new(@project, @trees).fragments
17
17
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Hashira::Duplication::Clusterer
3
+ class Hashira::Duplication::Clusters
4
4
  PREFILTER = 12
5
5
  BASE_MASS = 16
6
6
  NEAR_MASS = 40
@@ -12,10 +12,10 @@ class Hashira::Duplication::Clusterer
12
12
  @sets = Hashira::Duplication::UnionFind.new
13
13
  end
14
14
 
15
- def clusters
15
+ def sorted
16
16
  @fragments.group_by(&:types).each_value { |group| chain(group) }
17
17
  Hashira::Duplication::NearMiss.new(@fragments).pairs.each { |left, right| @sets.union(left, right) }
18
- Hashira::Duplication::Maximal.new(sized).reduced
18
+ Hashira::Duplication::Maximal.new(sized).reduced.sort_by { -it.mass }
19
19
  end
20
20
 
21
21
  private
@@ -1,26 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::Duplication::Delta
4
- ADVICE = {
5
- identical: "byte-for-byte identical — extract a shared method and call it from each site.",
6
- literal: "differs only in literal values — extract a method, pass them as arguments.",
7
- message: "differs only in the receiver or message — extract a method taking the receiver.",
8
- constant: "differs only in a constant — extract a method and parameterize it.",
9
- structure: "the control flow differs — extract the common core, but verify by hand (lower confidence).",
10
- mixed: "extract the shared shape and pass what differs as parameters."
11
- }.freeze
12
-
13
4
  def initialize(cluster)
14
5
  @cluster = cluster
15
6
  end
16
7
 
17
- def summary = ADVICE.fetch(kind)
18
-
19
8
  def kind
20
9
  tags = kinds
21
- return :identical if tags.empty?
22
- return :structure if tags.include?(:structure)
23
- tags.size == 1 ? tags.first : :mixed
10
+ tags.empty? ? :identical : label(tags)
24
11
  end
25
12
 
26
13
  def to_h
@@ -32,6 +19,11 @@ class Hashira::Duplication::Delta
32
19
 
33
20
  private
34
21
 
22
+ def label(tags)
23
+ return :structure if tags.include?(:structure)
24
+ tags.size == 1 ? tags.first : :mixed
25
+ end
26
+
35
27
  def kinds
36
28
  @cluster.others.flat_map { |other| Hashira::Duplication::Variance.new(@cluster.canonical, other).kinds }.uniq
37
29
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::Duplication::DuplicationFinding
4
+ KIND = "duplication"
5
+
4
6
  def initialize(cluster, churn)
5
7
  @cluster = cluster
6
8
  @churn = churn
@@ -8,21 +10,17 @@ class Hashira::Duplication::DuplicationFinding
8
10
 
9
11
  def to_finding
10
12
  site = @cluster.canonical
11
- Hashira::Analysis::Finding.new(
12
- kind: "duplication", package: site.location, digest: site.digest,
13
- cycle: nil, message:, evidence:
14
- )
13
+ Hashira::Analysis::Finding.new(kind: KIND, package: site.location, digest: site.digest, detail:, evidence:)
15
14
  end
16
15
 
17
16
  private
18
17
 
19
- def message
20
- "#{@cluster.size} similar fragments (mass #{@cluster.mass}) — #{Hashira::Duplication::Delta.new(@cluster).summary}#{note}"
18
+ def detail
19
+ {
20
+ size: @cluster.size, mass: @cluster.mass,
21
+ kind: Hashira::Duplication::Delta.new(@cluster).kind, hot: @churn.hot?(@cluster.sites)
22
+ }
21
23
  end
22
24
 
23
25
  def evidence = @cluster.sites.sort_by(&:rank).map(&:range)
24
-
25
- def note
26
- @churn.hot?(@cluster.sites) ? " Both sites change often — fix one, miss the other." : ""
27
- end
28
26
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "prism"
4
4
 
5
- class Hashira::Duplication::Extractor
5
+ class Hashira::Duplication::Harvest
6
6
  WHOLE = [Prism::DefNode, Prism::WhenNode, Prism::RescueNode].freeze
7
7
 
8
8
  def initialize(project, trees)
@@ -26,7 +26,9 @@ class Hashira::Duplication::Sequence
26
26
 
27
27
  def lengths(segment) = MIN_STATEMENTS..[segment.size, MAX_STATEMENTS].min
28
28
 
29
- def slide(segment, length) = (0..(segment.size - length)).map { fragment(segment[it, length]) }
29
+ def slide(segment, length) = spans(segment, length).map { fragment(segment[it, length]) }
30
+
31
+ def spans(segment, length) = 0..(segment.size - length)
30
32
 
31
33
  def fragment(roots) = Hashira::Duplication::Fragment.new(@file, roots)
32
34
  end