hashira 0.4.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/README.md +86 -25
  4. data/lib/hashira/analysis/finding.rb +2 -2
  5. data/lib/hashira/ci/ratchet.rb +5 -9
  6. data/lib/hashira/cli/fail_on.rb +15 -5
  7. data/lib/hashira/cli/run.rb +8 -4
  8. data/lib/hashira/cli/usage.rb +8 -5
  9. data/lib/hashira/complexity/boolean_run.rb +5 -3
  10. data/lib/hashira/complexity/cognitive_score.rb +21 -14
  11. data/lib/hashira/complexity/if_chain.rb +14 -14
  12. data/lib/hashira/complexity/method_finding.rb +3 -20
  13. data/lib/hashira/complexity/rescue_scan.rb +9 -9
  14. data/lib/hashira/complexity/{analyzer.rb → scores.rb} +6 -5
  15. data/lib/hashira/coupling/audiences.rb +49 -0
  16. data/lib/hashira/{analysis → coupling}/catalog.rb +3 -3
  17. data/lib/hashira/{analysis → coupling}/census.rb +17 -8
  18. data/lib/hashira/{analysis → coupling}/constant_registry.rb +6 -2
  19. data/lib/hashira/coupling/cycle_findings.rb +31 -0
  20. data/lib/hashira/{analysis → coupling}/cycle_search.rb +1 -1
  21. data/lib/hashira/{analysis → coupling}/cycles.rb +2 -2
  22. data/lib/hashira/{analysis → coupling}/definition.rb +1 -1
  23. data/lib/hashira/{analysis → coupling}/definitions.rb +1 -1
  24. data/lib/hashira/{analysis → coupling}/edge.rb +1 -1
  25. data/lib/hashira/{analysis → coupling}/edge_map.rb +10 -10
  26. data/lib/hashira/{analysis → coupling}/folder_placement.rb +2 -2
  27. data/lib/hashira/{analysis → coupling}/folding.rb +4 -2
  28. data/lib/hashira/{analysis → coupling}/graph.rb +18 -8
  29. data/lib/hashira/{analysis → coupling}/metric.rb +1 -1
  30. data/lib/hashira/coupling/mixed_audience_findings.rb +32 -0
  31. data/lib/hashira/{analysis → coupling}/namespace_placement.rb +3 -3
  32. data/lib/hashira/{analysis → coupling}/namespace_prefix.rb +1 -1
  33. data/lib/hashira/{analysis → coupling}/naming.rb +2 -2
  34. data/lib/hashira/{analysis → coupling}/no_folding.rb +1 -1
  35. data/lib/hashira/{analysis → coupling}/placement.rb +3 -3
  36. data/lib/hashira/coupling/references.rb +55 -0
  37. data/lib/hashira/coupling/report.rb +28 -0
  38. data/lib/hashira/coupling/roll_call.rb +45 -0
  39. data/lib/hashira/coupling/roll_call_findings.rb +30 -0
  40. data/lib/hashira/{analysis → coupling}/roster.rb +2 -2
  41. data/lib/hashira/{analysis → coupling}/rule.rb +1 -1
  42. data/lib/hashira/{analysis/resolver.rb → coupling/scope.rb} +2 -2
  43. data/lib/hashira/{analysis → coupling}/sdp_check.rb +1 -1
  44. data/lib/hashira/coupling/sdp_violation_findings.rb +25 -0
  45. data/lib/hashira/coupling/wide_edge_findings.rb +25 -0
  46. data/lib/hashira/coupling/words.rb +29 -0
  47. data/lib/hashira/diagram/{renderer.rb → source.rb} +2 -2
  48. data/lib/hashira/duplication/{analyzer.rb → clones.rb} +3 -3
  49. data/lib/hashira/duplication/{clusterer.rb → clusters.rb} +3 -3
  50. data/lib/hashira/duplication/delta.rb +6 -14
  51. data/lib/hashira/duplication/duplication_finding.rb +6 -10
  52. data/lib/hashira/duplication/{extractor.rb → harvest.rb} +1 -1
  53. data/lib/hashira/duplication/sequence.rb +3 -1
  54. data/lib/hashira/duplication/similarity.rb +5 -1
  55. data/lib/hashira/duplication/variance.rb +3 -3
  56. data/lib/hashira/hotspots/rollup.rb +1 -1
  57. data/lib/hashira/pipeline.rb +15 -10
  58. data/lib/hashira/report/complexity_table.rb +1 -1
  59. data/lib/hashira/report/finding_lines.rb +1 -1
  60. data/lib/hashira/report/json.rb +5 -3
  61. data/lib/hashira/report/phrases.rb +98 -0
  62. data/lib/hashira/report/smell_phrases.rb +68 -0
  63. data/lib/hashira/smells/census.rb +32 -0
  64. data/lib/hashira/smells/check.rb +30 -0
  65. data/lib/hashira/smells/conditions.rb +44 -0
  66. data/lib/hashira/smells/contexts.rb +64 -0
  67. data/lib/hashira/smells/control_parameter.rb +22 -0
  68. data/lib/hashira/smells/data_clump.rb +30 -0
  69. data/lib/hashira/smells/duplicate_method_call.rb +54 -0
  70. data/lib/hashira/smells/feature_envy.rb +17 -0
  71. data/lib/hashira/smells/instance_variable_assumption.rb +44 -0
  72. data/lib/hashira/smells/manual_dispatch.rb +16 -0
  73. data/lib/hashira/smells/module_initialize.rb +9 -0
  74. data/lib/hashira/smells/nil_check.rb +32 -0
  75. data/lib/hashira/smells/param_check.rb +51 -0
  76. data/lib/hashira/smells/refs.rb +53 -0
  77. data/lib/hashira/smells/repeated_conditional.rb +29 -0
  78. data/lib/hashira/smells/report.rb +31 -0
  79. data/lib/hashira/smells/scope.rb +29 -0
  80. data/lib/hashira/smells/too_many_instance_variables.rb +24 -0
  81. data/lib/hashira/smells/utility_function.rb +13 -0
  82. data/lib/hashira/smells/visibility.rb +64 -0
  83. data/lib/hashira/version.rb +1 -1
  84. data/lib/hashira.rb +65 -30
  85. metadata +60 -31
  86. data/lib/hashira/analysis/cycle_findings.rb +0 -38
  87. data/lib/hashira/analysis/references.rb +0 -40
  88. data/lib/hashira/analysis/sdp_violation_findings.rb +0 -29
@@ -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,8 +1,8 @@
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
7
  counted = Set.new
8
8
  placed.each { |definition, package| admit(definition, package, counted) }
@@ -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,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Hashira::Analysis::Resolver
3
+ class Hashira::Coupling::Scope
4
4
  def initialize(registry, catalog, placement)
5
5
  @registry = registry
6
6
  @catalog = catalog
@@ -24,5 +24,5 @@ class Hashira::Analysis::Resolver
24
24
  segments.length.downto(1).filter_map { @registry.exact(@catalog.strip(entry + segments.first(it))) }.first
25
25
  end
26
26
 
27
- def qualify(found) = (found unless found == Hashira::Analysis::ConstantRegistry::AMBIGUOUS)
27
+ def qualify(found) = (found unless found == Hashira::Coupling::ConstantRegistry::AMBIGUOUS)
28
28
  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
@@ -8,21 +8,17 @@ class Hashira::Duplication::DuplicationFinding
8
8
 
9
9
  def to_finding
10
10
  site = @cluster.canonical
11
- Hashira::Analysis::Finding.new(
12
- kind: "duplication", package: site.location, digest: site.digest,
13
- cycle: nil, message:, evidence:
14
- )
11
+ Hashira::Analysis::Finding.new(kind: "duplication", package: site.location, digest: site.digest, detail:, evidence:)
15
12
  end
16
13
 
17
14
  private
18
15
 
19
- def message
20
- "#{@cluster.size} similar fragments (mass #{@cluster.mass}) — #{Hashira::Duplication::Delta.new(@cluster).summary}#{note}"
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
+ }
21
21
  end
22
22
 
23
23
  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
24
  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
@@ -38,6 +38,10 @@ class Hashira::Duplication::Similarity
38
38
  end
39
39
 
40
40
  def cell(prev, row, token, index)
41
- @right[index] == token ? prev[index] + 1 : [prev[index + 1], row[index]].max
41
+ match?(token, index) ? prev[index] + 1 : carry(prev, row, index)
42
42
  end
43
+
44
+ def match?(token, index) = @right[index] == token
45
+
46
+ def carry(prev, row, index) = [prev[index + 1], row[index]].max
43
47
  end
@@ -40,10 +40,10 @@ class Hashira::Duplication::Variance
40
40
  end
41
41
 
42
42
  def signature(node)
43
- type = node.type
44
- return literal(node) if LITERALS.include?(type)
45
- node.name if NAMED.include?(type)
43
+ LITERALS.include?(node.type) ? literal(node) : label(node)
46
44
  end
47
45
 
46
+ def label(node) = (node.name if NAMED.include?(node.type))
47
+
48
48
  def literal(node) = VALUED.include?(node.type) ? node.value : node.unescaped
49
49
  end
@@ -19,7 +19,7 @@ class Hashira::Hotspots::Rollup
19
19
  )
20
20
  end
21
21
 
22
- def scores = @complexity ? @complexity.methods : []
22
+ def scores = @complexity ? @complexity.ranked : []
23
23
 
24
24
  def clusters = @duplication ? @duplication.clusters : []
25
25
 
@@ -1,27 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "prism"
4
+ require_relative "coupling/report"
4
5
 
5
6
  class Hashira::Pipeline
6
- ANALYZERS = %i[coupling complexity duplication].freeze
7
+ ANALYZERS = %i[coupling complexity duplication smells].freeze
7
8
 
8
- RULES = [Hashira::Analysis::CycleFindings, Hashira::Analysis::SdpViolationFindings].freeze
9
+ STRUCTURAL = Hashira::Coupling::Report::RULES.map { it::KIND }.freeze
9
10
 
10
11
  def initialize(project, enabled: ANALYZERS, packaging: :auto)
11
12
  @project = project
12
13
  @enabled = enabled
13
14
  @trees = project.files.to_h { [it, parse(it)] }
14
- @graph = Hashira::Analysis::Graph.new(project, @trees, census(settle(packaging)))
15
+ @coupling = Hashira::Coupling::Report.new(project, @trees, packaging: settle(packaging))
15
16
  end
16
17
 
17
- attr_reader :project, :graph
18
+ attr_reader :project
19
+
20
+ def graph = @coupling.graph
18
21
 
19
22
  def complexity
20
- @complexity ||= Hashira::Complexity::Analyzer.new(@project, @trees) if enabled?(:complexity)
23
+ @complexity ||= Hashira::Complexity::Scores.new(@project, @trees) if enabled?(:complexity)
21
24
  end
22
25
 
23
26
  def duplication
24
- @duplication ||= Hashira::Duplication::Analyzer.new(@project, @trees, churn) if enabled?(:duplication)
27
+ @duplication ||= Hashira::Duplication::Clones.new(@project, @trees, churn) if enabled?(:duplication)
28
+ end
29
+
30
+ def smells
31
+ @smells ||= Hashira::Smells::Report.new(@project, @trees) if enabled?(:smells)
25
32
  end
26
33
 
27
34
  def hotspots
@@ -32,18 +39,16 @@ class Hashira::Pipeline
32
39
 
33
40
  def enabled?(analyzer) = @enabled.include?(analyzer)
34
41
 
35
- def findings = structural + listed(complexity) + listed(duplication)
42
+ def findings = structural + listed(complexity) + listed(duplication) + listed(smells)
36
43
 
37
44
  private
38
45
 
39
- def census(packaging) = Hashira::Analysis::Census.new(@project, @trees, packaging:)
40
-
41
46
  def settle(packaging)
42
47
  return packaging unless packaging == :auto
43
48
  @project.rails? ? :namespace : :folder
44
49
  end
45
50
 
46
- def structural = enabled?(:coupling) ? RULES.flat_map { it.new(@project, @graph).list } : []
51
+ def structural = enabled?(:coupling) ? @coupling.findings : []
47
52
 
48
53
  def listed(analyzer) = analyzer ? analyzer.findings : []
49
54
 
@@ -13,7 +13,7 @@ class Hashira::Report::ComplexityTable
13
13
  end
14
14
 
15
15
  def print
16
- section(@complexity.methods, METHOD_ROW, %w[method Cog Calls Loc], METHOD_TITLE)
16
+ section(@complexity.ranked, METHOD_ROW, %w[method Cog Calls Loc], METHOD_TITLE)
17
17
  section(@complexity.classes, CLASS_ROW, %w[class Cog Methods Peak], CLASS_TITLE)
18
18
  end
19
19
 
@@ -10,7 +10,7 @@ class Hashira::Report::FindingLines
10
10
  end
11
11
 
12
12
  def print
13
- @io.puts("#{@indent}#{@finding.kind}: #{@finding.message}")
13
+ @io.puts("#{@indent}#{@finding.kind}: #{Hashira::Report::Phrases.message(@finding)}")
14
14
  @finding.evidence.first(SAMPLE).each { @io.puts("#{@indent} · #{it}") }
15
15
  end
16
16
 
@@ -17,7 +17,9 @@ class Hashira::Report::Json
17
17
 
18
18
  def payload = base.merge(coupling).merge(sections.compact)
19
19
 
20
- def base = { findings: @view.findings.all.map(&:to_h), accepted: accepted }
20
+ def base = { findings: @view.findings.all.map { rendered(it) }, accepted: accepted }
21
+
22
+ def rendered(finding) = finding.to_h.merge(message: Hashira::Report::Phrases.message(finding))
21
23
 
22
24
  def coupling
23
25
  graph = @view.graph
@@ -33,12 +35,12 @@ class Hashira::Report::Json
33
35
  end
34
36
 
35
37
  def accepted
36
- @view.findings.accepted.map { |finding, reason| finding.to_h.merge(reason:) }
38
+ @view.findings.accepted.map { |finding, reason| rendered(finding).merge(reason:) }
37
39
  end
38
40
 
39
41
  def complexity = { methods: the_methods, classes: the_classes }
40
42
 
41
- def the_methods = @view.complexity.methods.map(&:to_h)
43
+ def the_methods = @view.complexity.ranked.map(&:to_h)
42
44
 
43
45
  def the_classes = @view.complexity.classes.map(&:to_h)
44
46
 
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira::Report::Phrases
4
+ COMPLEXITY_ADVICE = {
5
+ "if" => "flatten the branching — guard clauses, early returns, or polymorphism.",
6
+ "elsif" => "replace the elsif ladder with a lookup or polymorphic dispatch.",
7
+ "else" => "flatten the branching — guard clauses, early returns, or polymorphism.",
8
+ "case" => "a case this size often wants polymorphism or a dispatch table.",
9
+ "boolean" => "name the compound condition in a predicate method.",
10
+ "rescue" => "narrow the rescue, or lift error handling to the caller.",
11
+ "while" => "extract the loop body into its own method.",
12
+ "until" => "extract the loop body into its own method.",
13
+ "for" => "extract the loop body into its own method.",
14
+ "unless" => "invert to a guard clause or a named predicate.",
15
+ "ternary" => "extract the nested ternary into a named method."
16
+ }.freeze
17
+
18
+ DUPLICATION_ADVICE = {
19
+ identical: "byte-for-byte identical — extract a shared method and call it from each site.",
20
+ literal: "differs only in literal values — extract a method, pass them as arguments.",
21
+ message: "differs only in the receiver or message — extract a method taking the receiver.",
22
+ constant: "differs only in a constant — extract a method and parameterize it.",
23
+ structure: "the control flow differs — extract the common core, but verify by hand (lower confidence).",
24
+ mixed: "extract the shared shape and pass what differs as parameters."
25
+ }.freeze
26
+
27
+ module_function
28
+
29
+ def message(finding) = public_send("on_#{finding.kind}", finding)
30
+
31
+ def on_cycle(finding)
32
+ detail = finding.detail
33
+ from, to = detail[:weak]
34
+ weight = detail[:weight]
35
+ "#{finding.package} can reach itself: #{finding.cycle.join(" -> ")} — any change may ripple back " \
36
+ "around. The lightest edge on this cycle is #{from} -> #{to} (#{weight} ref#{"s" unless weight == 1})."
37
+ end
38
+
39
+ def on_sdp_violation(finding)
40
+ detail = finding.detail
41
+ from, to = detail.values_at(:from, :to)
42
+ "#{from} (I=#{score(detail[:from_instability])}) depends on the LESS stable #{to} " \
43
+ "(I=#{score(detail[:to_instability])}) — churn in #{to} will force churn in #{from}. " \
44
+ "Invert the edge or extract the stable part of #{to} that #{from} needs."
45
+ end
46
+
47
+ def on_mixed_audience(finding)
48
+ package = finding.package
49
+ parts = finding.detail[:parts]
50
+ "#{package} splits #{parts.size} ways: #{parts.map { clause(it) }.join("; ")} — " \
51
+ "parts with separate client bases are separate packages in disguise. " \
52
+ "Split #{package} along that seam#{addendum(parts)}."
53
+ end
54
+
55
+ def on_wide_edge(finding)
56
+ detail = finding.detail
57
+ from, to = detail.values_at(:from, :to)
58
+ names = detail[:constants]
59
+ "#{from} -> #{to} is #{names.size} constants wide (#{names.join(", ")}) — " \
60
+ "every one is a reason for #{from} to change. Front #{to} with one facade."
61
+ end
62
+
63
+ def on_roll_call(finding)
64
+ detail = finding.detail
65
+ "the words #{detail[:words].join(", ")} are listed together in #{detail[:files].join(", ")} — " \
66
+ "#{detail[:packages].size} packages keep one roll-call in sync by hand. " \
67
+ "Make the list data with a single owner."
68
+ end
69
+
70
+ def on_complexity(finding)
71
+ detail = finding.detail
72
+ "#{finding.package} — cognitive #{detail[:cognitive]}, #{detail[:calls]} calls " \
73
+ "(#{detail[:site]}). #{COMPLEXITY_ADVICE.fetch(detail[:dominant])}"
74
+ end
75
+
76
+ def on_duplication(finding)
77
+ detail = finding.detail
78
+ "#{detail[:size]} similar fragments (mass #{detail[:mass]}) — " \
79
+ "#{DUPLICATION_ADVICE.fetch(detail[:kind])}#{footnote(detail)}"
80
+ end
81
+
82
+ def score(value) = format("%.2f", value)
83
+
84
+ def clause(part) = "#{part[:users].join(", ")} #{verb(part)} #{part[:constants].join(", ")}"
85
+
86
+ def verb(part)
87
+ return "share" if part[:shared]
88
+ part[:users].size == 1 ? "alone uses" : "use"
89
+ end
90
+
91
+ def addendum(parts)
92
+ parts.any? { it[:shared] } ? ", keeping the shared constants as the base layer the rest builds on" : ""
93
+ end
94
+
95
+ def footnote(detail)
96
+ detail[:hot] ? " Both sites change often — fix one, miss the other." : ""
97
+ end
98
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira::Report::Phrases
4
+ module_function
5
+
6
+ def on_control_parameter(finding)
7
+ detail = finding.detail
8
+ "#{finding.package} is steered by #{quoted(detail[:names])} (#{detail[:site]}). " \
9
+ "Split the method, or pass a strategy instead of a flag."
10
+ end
11
+
12
+ def on_data_clump(finding)
13
+ "#{finding.package} passes the same parameters between methods (#{finding.detail[:site]}). " \
14
+ "Introduce a parameter object."
15
+ end
16
+
17
+ def on_duplicate_method_call(finding)
18
+ "#{finding.package} repeats identical calls (#{finding.detail[:site]}). " \
19
+ "Name the result in a local variable."
20
+ end
21
+
22
+ def on_feature_envy(finding)
23
+ detail = finding.detail
24
+ names = detail[:names]
25
+ "#{finding.package} refers to #{quoted(names)} more than to self (#{detail[:site]}). " \
26
+ "The behavior may belong on #{names.first}."
27
+ end
28
+
29
+ def on_instance_variable_assumption(finding)
30
+ "#{finding.package} reads instance variables never set in initialize (#{finding.detail[:site]}). " \
31
+ "Assign them in initialize, or pass the data explicitly."
32
+ end
33
+
34
+ def on_manual_dispatch(finding)
35
+ "#{finding.package} dispatches manually via respond_to? (#{finding.detail[:site]}). " \
36
+ "Trust the duck type, or split the callers into two adapters."
37
+ end
38
+
39
+ def on_module_initialize(finding)
40
+ "#{finding.package} defines initialize in a module (#{finding.detail[:site]}). " \
41
+ "Move construction into the including class."
42
+ end
43
+
44
+ def on_nil_check(finding)
45
+ "#{finding.package} checks for nil (#{finding.detail[:site]}). " \
46
+ "Prefer a default, a null object, or polymorphism."
47
+ end
48
+
49
+ def on_repeated_conditional(finding)
50
+ tally(finding, "branches on the same test %d times", "Replace the scattered checks with polymorphism.")
51
+ end
52
+
53
+ def on_too_many_instance_variables(finding)
54
+ tally(finding, "holds %d instance variables", "Split the class, or gather related fields into value objects.")
55
+ end
56
+
57
+ def on_utility_function(finding)
58
+ "#{finding.package} touches no instance state (#{finding.detail[:site]}). " \
59
+ "Move it onto the object it serves, or make it a module function."
60
+ end
61
+
62
+ def tally(finding, event, advice)
63
+ detail = finding.detail
64
+ "#{finding.package} #{format(event, detail[:count])} (#{detail[:site]}). #{advice}"
65
+ end
66
+
67
+ def quoted(names) = names.map { "'#{it}'" }.join(", ")
68
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ class Hashira::Smells::Census
6
+ def initialize(project, trees)
7
+ @project = project
8
+ @trees = trees
9
+ end
10
+
11
+ def types = @trees.flat_map { |path, tree| harvest(@project.relative(path), tree) }
12
+
13
+ private
14
+
15
+ def harvest(file, tree)
16
+ found = []
17
+ Hashira::Analysis::TypeWalk.each(tree) { |node, full| found << context(file, node, full.join("::")) }
18
+ found
19
+ end
20
+
21
+ def context(file, node, name)
22
+ Hashira::Smells::TypeContext.new(name:, node:, kind: kind(node), file:, defs: defs(name, node, file))
23
+ end
24
+
25
+ def kind(node) = node.is_a?(Prism::ModuleNode) ? :module : :class
26
+
27
+ def defs(name, node, file)
28
+ Hashira::Smells::Visibility.new(node).entries.map do |def_node, section|
29
+ Hashira::Smells::MethodContext.new(owner: name, node: def_node, file:, section:)
30
+ end
31
+ end
32
+ end