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
@@ -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,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "prism"
4
+ require_relative "coupling/report"
5
+ require_relative "smells/report"
4
6
 
5
7
  class Hashira::Pipeline
6
- ANALYZERS = %i[coupling complexity duplication].freeze
8
+ ANALYZERS = %i[coupling complexity duplication smells].freeze
7
9
 
8
- RULES = [Hashira::Analysis::CycleFindings, Hashira::Analysis::SdpViolationFindings].freeze
10
+ STRUCTURAL = Hashira::Coupling::Report::RULES.map { it::KIND }.freeze
11
+
12
+ SMELLS = Hashira::Smells::Report::CHECKS.map(&:kind).sort.freeze
9
13
 
10
14
  def initialize(project, enabled: ANALYZERS, packaging: :auto)
11
15
  @project = project
12
16
  @enabled = enabled
13
17
  @trees = project.files.to_h { [it, parse(it)] }
14
- @graph = Hashira::Analysis::Graph.new(project, @trees, census(settle(packaging)))
18
+ @coupling = Hashira::Coupling::Report.new(project, @trees, packaging: settle(packaging))
15
19
  end
16
20
 
17
- attr_reader :project, :graph
21
+ attr_reader :project
22
+
23
+ def graph = @coupling.graph
18
24
 
19
25
  def complexity
20
- @complexity ||= Hashira::Complexity::Analyzer.new(@project, @trees) if enabled?(:complexity)
26
+ @complexity ||= Hashira::Complexity::Scores.new(@project, @trees) if enabled?(:complexity)
21
27
  end
22
28
 
23
29
  def duplication
24
- @duplication ||= Hashira::Duplication::Analyzer.new(@project, @trees, churn) if enabled?(:duplication)
30
+ @duplication ||= Hashira::Duplication::Clones.new(@project, @trees, churn) if enabled?(:duplication)
31
+ end
32
+
33
+ def smells
34
+ @smells ||= Hashira::Smells::Report.new(@project, @trees) if enabled?(:smells)
25
35
  end
26
36
 
27
37
  def hotspots
@@ -32,18 +42,16 @@ class Hashira::Pipeline
32
42
 
33
43
  def enabled?(analyzer) = @enabled.include?(analyzer)
34
44
 
35
- def findings = structural + listed(complexity) + listed(duplication)
45
+ def findings = structural + listed(complexity) + listed(duplication) + listed(smells)
36
46
 
37
47
  private
38
48
 
39
- def census(packaging) = Hashira::Analysis::Census.new(@project, @trees, packaging:)
40
-
41
49
  def settle(packaging)
42
50
  return packaging unless packaging == :auto
43
51
  @project.rails? ? :namespace : :folder
44
52
  end
45
53
 
46
- def structural = enabled?(:coupling) ? RULES.flat_map { it.new(@project, @graph).list } : []
54
+ def structural = enabled?(:coupling) ? @coupling.findings : []
47
55
 
48
56
  def listed(analyzer) = analyzer ? analyzer.findings : []
49
57
 
@@ -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
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Smells::Check
4
+ def self.kind = name.split("::").last.gsub(/(?<=[a-z])(?=[A-Z])/, "_").downcase
5
+
6
+ def self.judge? = false
7
+
8
+ def initialize(subject)
9
+ @subject = subject
10
+ end
11
+
12
+ def finding
13
+ return unless smelly?
14
+ Hashira::Analysis::Finding.new(kind:, package: label, detail:, evidence:)
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :subject
20
+
21
+ def kind = self.class.kind
22
+
23
+ def label = subject.subject
24
+
25
+ def site = subject.site
26
+
27
+ def detail = { site: }
28
+
29
+ def spots(nodes) = "#{subject.file}:#{nodes.map { it.location.start_line }.uniq.join(", ")}"
30
+
31
+ def evidence = []
32
+
33
+ def tally(name, lines) = "#{name} (line#{"s" if lines.size > 1} #{lines.join(", ")})"
34
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Hashira::Smells::Conditions
6
+ TESTED = [Prism::IfNode, Prism::UnlessNode, Prism::CaseNode, Prism::AndNode, Prism::OrNode].freeze
7
+
8
+ module_function
9
+
10
+ def tested?(node) = TESTED.include?(node.class)
11
+
12
+ def fence?(node) = Hashira::Smells::Scope::FENCES.include?(node.class)
13
+
14
+ def couple?(node) = node.is_a?(Prism::AndNode) || node.is_a?(Prism::OrNode)
15
+
16
+ def condition(node)
17
+ case node
18
+ when Prism::IfNode, Prism::UnlessNode, Prism::CaseNode then node.predicate
19
+ when Prism::AndNode, Prism::OrNode then node.left
20
+ end
21
+ end
22
+
23
+ def branches(node)
24
+ return fork(node) if node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode)
25
+ return node.conditions + [node.consequent] if node.is_a?(Prism::CaseNode)
26
+ couple?(node) ? [node.right] : [node.body]
27
+ end
28
+
29
+ def fork(node)
30
+ [node.statements, node.is_a?(Prism::IfNode) ? node.subsequent : node.else_clause]
31
+ end
32
+
33
+ def nested(roots) = roots.compact.flat_map { seek(it) }
34
+
35
+ def seek(node)
36
+ return [node] if tested?(node)
37
+ fence?(node) ? [] : node.compact_child_nodes.flat_map { seek(it) }
38
+ end
39
+
40
+ def plain(node)
41
+ return [] if tested?(node) || fence?(node)
42
+ [node] + node.compact_child_nodes.flat_map { plain(it) }
43
+ end
44
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Hashira
6
+ module Smells
7
+ module Parameters
8
+ NAMELESS = [Prism::ForwardingParameterNode, Prism::ImplicitRestNode, Prism::NoKeywordsParameterNode].freeze
9
+
10
+ module_function
11
+
12
+ def names(def_node) = parts(def_node).flat_map { expand(it) }
13
+
14
+ def arguments(def_node) = parts(def_node).grep_v(Prism::BlockParameterNode).flat_map { expand(it) }
15
+
16
+ def parts(def_node)
17
+ node = def_node.parameters
18
+ return [] unless node
19
+ node.requireds + node.optionals + node.posts + node.keywords +
20
+ [node.rest, node.keyword_rest, node.block].compact
21
+ end
22
+
23
+ def expand(part)
24
+ case part
25
+ when Prism::MultiTargetNode then pieces(part).flat_map { expand(it) }
26
+ when *NAMELESS then []
27
+ else [part.name].compact
28
+ end
29
+ end
30
+
31
+ def pieces(part) = part.lefts + Array(part.rest&.expression) + part.rights
32
+ end
33
+
34
+ MethodContext =
35
+ Data.define(:owner, :node, :file, :section) do
36
+ def subject = "#{owner}#{singleton? ? "." : "#"}#{node.name}"
37
+
38
+ def line = node.location.start_line
39
+
40
+ def singleton? = node.receiver.is_a?(Prism::Node) || section == :singleton
41
+
42
+ def mixin? = section == :module_function
43
+
44
+ def public? = section == :public && !singleton?
45
+
46
+ def parameters = Parameters.names(node)
47
+
48
+ def arguments = Parameters.arguments(node)
49
+
50
+ def site = "#{file}:#{line}"
51
+ end
52
+
53
+ TypeContext =
54
+ Data.define(:name, :node, :kind, :file, :defs) do
55
+ def line = node.location.start_line
56
+
57
+ def subject = name
58
+
59
+ def owned = defs.reject(&:singleton?)
60
+
61
+ def site = "#{file}:#{line}"
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Smells::ControlParameter < Hashira::Smells::Check
4
+ private
5
+
6
+ def smelly? = culprits.any?
7
+
8
+ def culprits = @culprits ||= subject.parameters.filter_map { |name| culprit(name) }
9
+
10
+ def culprit(name)
11
+ lines = spots(name).uniq
12
+ [name, lines] unless lines.empty?
13
+ end
14
+
15
+ def spots(name)
16
+ Hashira::Smells::ParamCheck.new(subject.node, name).matches.map { it.location.start_line }
17
+ end
18
+
19
+ def detail = { site:, names: culprits.map { |name, _lines| name } }
20
+
21
+ def evidence = culprits.map { |name, lines| tally(name, lines) }
22
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Smells::DataClump < Hashira::Smells::Check
4
+ MAX_COPIES = 2
5
+
6
+ MIN_SIZE = 2
7
+
8
+ def self.judge? = true
9
+
10
+ private
11
+
12
+ def smelly? = clumps.any?
13
+
14
+ def candidates = @candidates ||= subject.owned.map { [it, it.arguments.sort] }
15
+
16
+ def clumps = @clumps ||= shared.map { |clump| [clump, holders(clump)] }
17
+
18
+ def shared = candidates.combination(MAX_COPIES + 1).filter_map { |group| clump(group) }.uniq
19
+
20
+ def clump(group)
21
+ names = group.map(&:last).inject(:&)
22
+ names if names.size >= MIN_SIZE
23
+ end
24
+
25
+ def holders(clump) = candidates.filter_map { |method, names| method if (clump - names).empty? }
26
+
27
+ def evidence = clumps.map { |clump, holders| row(clump, holders) }
28
+
29
+ def row(clump, holders)
30
+ "(#{clump.join(", ")}) → #{holders.size} methods: #{holders.map { |holder| holder.node.name }.join(", ")}"
31
+ end
32
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ class Hashira::Smells::DuplicateMethodCall < Hashira::Smells::Check
6
+ LIMIT = 1
7
+
8
+ private
9
+
10
+ def smelly? = repeats.any?
11
+
12
+ def calls = @calls ||= Hashira::Smells::Scope.inside(subject.node).grep(Prism::CallNode)
13
+
14
+ def plain?(node)
15
+ !node.receiver && !node.arguments && !node.block.is_a?(Prism::BlockArgumentNode)
16
+ end
17
+
18
+ def repeats
19
+ @repeats ||= usual.reject { |_handle, nodes| whole.value?(nodes) }.merge(whole)
20
+ end
21
+
22
+ def usual
23
+ calls.reject { plain?(it) || it.name == :new }
24
+ .group_by { handle(it) }.select { |_handle, group| group.size > LIMIT }
25
+ end
26
+
27
+ def whole
28
+ @whole ||=
29
+ calls.select { it.block.is_a?(Prism::BlockNode) }
30
+ .group_by { it.slice.gsub(/\s+/, " ") }.select { |_handle, group| group.size > LIMIT }
31
+ end
32
+
33
+ def handle(node) = "#{title(node)}#{signature(node)}"
34
+
35
+ def title(node)
36
+ [node.receiver&.slice, node.name].compact.join(node.safe_navigation? ? "&." : ".")
37
+ end
38
+
39
+ def signature(node)
40
+ wrap(([node.arguments&.slice] + pass(node.block)).compact)
41
+ end
42
+
43
+ def pass(block) = block.is_a?(Prism::BlockArgumentNode) ? [block.slice] : []
44
+
45
+ def wrap(parts) = parts.empty? ? "" : "(#{parts.join(", ")})"
46
+
47
+ def evidence
48
+ repeats.map { |handle, nodes| "#{handle} × #{nodes.size} (#{stamp(lines(nodes))})" }
49
+ end
50
+
51
+ def lines(nodes) = nodes.map { it.location.start_line }.uniq
52
+
53
+ def stamp(lines) = "line#{"s" if lines.size > 1} #{lines.join(", ")}"
54
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Smells::FeatureEnvy < Hashira::Smells::Check
4
+ private
5
+
6
+ def smelly?
7
+ !subject.singleton? && !subject.mixin? && refs.ego.positive? && envied.any?
8
+ end
9
+
10
+ def refs = @refs ||= Hashira::Smells::Refs.new(subject.node)
11
+
12
+ def envied = @envied ||= refs.envious
13
+
14
+ def detail = { site:, names: envied }
15
+
16
+ def evidence = envied.map { |name| tally(name, refs.lines(name).uniq) }
17
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ class Hashira::Smells::InstanceVariableAssumption < Hashira::Smells::Check
6
+ SETTERS = [
7
+ Prism::InstanceVariableWriteNode, Prism::InstanceVariableOrWriteNode,
8
+ Prism::InstanceVariableAndWriteNode, Prism::InstanceVariableOperatorWriteNode,
9
+ Prism::InstanceVariableTargetNode
10
+ ].freeze
11
+
12
+ MAYBES = [
13
+ Prism::LocalVariableOrWriteNode, Prism::InstanceVariableOrWriteNode,
14
+ Prism::ClassVariableOrWriteNode, Prism::GlobalVariableOrWriteNode,
15
+ Prism::ConstantOrWriteNode, Prism::ConstantPathOrWriteNode,
16
+ Prism::CallOrWriteNode, Prism::IndexOrWriteNode
17
+ ].freeze
18
+
19
+ module Harvest
20
+ module_function
21
+
22
+ def reads(node)
23
+ return [] if MAYBES.include?(node.class)
24
+ below = node.compact_child_nodes.flat_map { reads(it) }
25
+ node.is_a?(Prism::InstanceVariableReadNode) ? below + [node.name] : below
26
+ end
27
+ end
28
+
29
+ def self.judge? = true
30
+
31
+ private
32
+
33
+ def smelly? = subject.kind == :class && assumed.any?
34
+
35
+ def assumed = @assumed ||= (read - prepared).uniq.sort
36
+
37
+ def read = subject.owned.flat_map { Harvest.reads(it.node) }
38
+
39
+ def prepared
40
+ starters.flat_map { Hashira::Smells::Scope.inside(it.node) }.select { SETTERS.include?(it.class) }.map(&:name)
41
+ end
42
+
43
+ def starters = subject.owned.select { it.node.name == :initialize }
44
+
45
+ def evidence = assumed.map(&:to_s)
46
+ end