hashira 0.7.0 → 0.8.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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +29 -0
  4. data/exe/hashira +1 -1
  5. data/lib/hashira/analysis/finding.rb +1 -1
  6. data/lib/hashira/analysis/syntax.rb +6 -6
  7. data/lib/hashira/churn.rb +1 -10
  8. data/lib/hashira/ci/accepted.rb +9 -7
  9. data/lib/hashira/ci/baseline.rb +34 -30
  10. data/lib/hashira/ci/comparison.rb +24 -0
  11. data/lib/hashira/ci/diff.rb +0 -15
  12. data/lib/hashira/ci/gate.rb +3 -3
  13. data/lib/hashira/ci/ratchet.rb +10 -4
  14. data/lib/hashira/ci/ratchet_report.rb +4 -4
  15. data/lib/hashira/ci/scope.rb +0 -1
  16. data/lib/hashira/ci/slice.rb +17 -0
  17. data/lib/hashira/ci/sweep.rb +13 -0
  18. data/lib/hashira/cli/arguments.rb +12 -8
  19. data/lib/hashira/cli/choice.rb +9 -0
  20. data/lib/hashira/cli/command_line.rb +8 -5
  21. data/lib/hashira/cli/fail_on.rb +6 -6
  22. data/lib/hashira/cli/flag.rb +3 -1
  23. data/lib/hashira/cli/flags.rb +10 -0
  24. data/lib/hashira/cli/format.rb +1 -1
  25. data/lib/hashira/cli/needs.rb +13 -2
  26. data/lib/hashira/cli/only.rb +16 -0
  27. data/lib/hashira/cli/options.rb +8 -10
  28. data/lib/hashira/cli/package_by.rb +1 -1
  29. data/lib/hashira/cli/run.rb +9 -8
  30. data/lib/hashira/cli.rb +22 -21
  31. data/lib/hashira/complexity/cognitive_score.rb +44 -33
  32. data/lib/hashira/complexity/method_finding.rb +7 -5
  33. data/lib/hashira/complexity/method_score.rb +1 -1
  34. data/lib/hashira/complexity/scores.rb +11 -9
  35. data/lib/hashira/coupling/audiences.rb +3 -3
  36. data/lib/hashira/coupling/catalog.rb +8 -6
  37. data/lib/hashira/coupling/census.rb +42 -22
  38. data/lib/hashira/coupling/cycle_search.rb +11 -7
  39. data/lib/hashira/coupling/definitions.rb +1 -1
  40. data/lib/hashira/coupling/edge_map.rb +9 -8
  41. data/lib/hashira/coupling/folding.rb +5 -5
  42. data/lib/hashira/coupling/graph.rb +16 -9
  43. data/lib/hashira/coupling/metric.rb +3 -1
  44. data/lib/hashira/coupling/naming.rb +4 -4
  45. data/lib/hashira/coupling/references.rb +12 -12
  46. data/lib/hashira/coupling/report.rb +7 -9
  47. data/lib/hashira/coupling/roll_call.rb +1 -1
  48. data/lib/hashira/coupling/roll_call_findings.rb +1 -1
  49. data/lib/hashira/coupling/roster.rb +37 -12
  50. data/lib/hashira/coupling/rule.rb +1 -1
  51. data/lib/hashira/coupling/sdp_violation_findings.rb +3 -1
  52. data/lib/hashira/duplication/clones.rb +1 -1
  53. data/lib/hashira/duplication/clusters.rb +10 -7
  54. data/lib/hashira/duplication/delta.rb +1 -4
  55. data/lib/hashira/duplication/duplication_finding.rb +4 -2
  56. data/lib/hashira/duplication/fragment.rb +2 -2
  57. data/lib/hashira/duplication/harvest.rb +7 -5
  58. data/lib/hashira/duplication/index.rb +3 -2
  59. data/lib/hashira/duplication/similarity.rb +2 -2
  60. data/lib/hashira/duplication/variance.rb +1 -1
  61. data/lib/hashira/error.rb +0 -3
  62. data/lib/hashira/focus.rb +29 -0
  63. data/lib/hashira/git_log.rb +19 -0
  64. data/lib/hashira/hotspots/file_cost.rb +1 -1
  65. data/lib/hashira/hotspots/rollup.rb +2 -2
  66. data/lib/hashira/pipeline.rb +22 -14
  67. data/lib/hashira/project.rb +35 -34
  68. data/lib/hashira/report/columns.rb +14 -8
  69. data/lib/hashira/report/graph_payload.rb +1 -1
  70. data/lib/hashira/report/hotspot_table.rb +1 -1
  71. data/lib/hashira/report/json.rb +2 -1
  72. data/lib/hashira/report/metrics_table.rb +3 -3
  73. data/lib/hashira/report/phrases.rb +23 -18
  74. data/lib/hashira/smells/census.rb +3 -4
  75. data/lib/hashira/smells/check.rb +1 -5
  76. data/lib/hashira/smells/contexts.rb +4 -4
  77. data/lib/hashira/smells/control_parameter.rb +1 -1
  78. data/lib/hashira/smells/data_clump.rb +2 -4
  79. data/lib/hashira/smells/duplicate_method_call.rb +3 -3
  80. data/lib/hashira/smells/feature_envy.rb +3 -3
  81. data/lib/hashira/smells/foreign.rb +14 -14
  82. data/lib/hashira/smells/instance_variable_assumption.rb +3 -3
  83. data/lib/hashira/smells/kind.rb +9 -0
  84. data/lib/hashira/smells/manual_dispatch.rb +1 -1
  85. data/lib/hashira/smells/module_initialize.rb +0 -2
  86. data/lib/hashira/smells/nil_check.rb +1 -1
  87. data/lib/hashira/smells/ownership.rb +19 -7
  88. data/lib/hashira/smells/param_check.rb +2 -2
  89. data/lib/hashira/smells/refs.rb +13 -7
  90. data/lib/hashira/smells/repeated_conditional.rb +1 -3
  91. data/lib/hashira/smells/report.rb +16 -7
  92. data/lib/hashira/smells/too_many_instance_variables.rb +3 -3
  93. data/lib/hashira/smells/visibility.rb +18 -11
  94. data/lib/hashira/trees.rb +8 -4
  95. data/lib/hashira/version.rb +1 -1
  96. data/lib/hashira.rb +8 -0
  97. metadata +9 -1
@@ -3,40 +3,15 @@
3
3
  class Hashira::Project
4
4
  ROOT_PACKAGE = "(root)"
5
5
 
6
- def self.detect(directories)
7
- new((directories.empty? ? defaults : directories).map { descend(it.delete_suffix("/")) })
6
+ def initialize(requested)
7
+ @requested = requested
8
8
  end
9
9
 
10
- def self.defaults
11
- return ["lib"] if Dir["lib/*/"].any? || Dir["lib/*.rb"].any?
12
- raise(Hashira::Error, "no lib/ directory here — pass the source directory explicitly")
13
- end
14
- private_class_method :defaults
15
-
16
- def self.descend(directory)
17
- child = sole(directory)
18
- return directory unless child && Dir["#{child}/*/"].any? && (Dir["#{directory}/*.rb"] - ["#{child}.rb"]).empty?
19
- descend(child)
20
- end
10
+ def directories = @_directories ||= resolved
21
11
 
22
- def self.sole(directory)
23
- subdirectories = Dir["#{directory}/*/"]
24
- subdirectories.size == 1 ? subdirectories.first.delete_suffix("/") : nil
25
- end
26
- private_class_method :descend, :sole
27
-
28
- def initialize(directories)
29
- vet(directories)
30
- @directories = distinct(directories.map { it.delete_suffix("/") })
31
- raise(Hashira::Error, "no Ruby files under #{label}") if files.empty?
32
- @rails = @directories.any? { config?(File.expand_path(it)) }
33
- end
34
-
35
- attr_reader :directories
12
+ def rails? = directories.any? { config?(File.expand_path(it)) }
36
13
 
37
- def rails? = @rails
38
-
39
- def files = @directories.flat_map { Dir["#{it}/**/*.rb"] }.sort
14
+ def files = directories.flat_map { Dir["#{it}/**/*.rb"] }.sort
40
15
 
41
16
  def package(path)
42
17
  first, rest = relative(path).delete_suffix(".rb").split("/", 2)
@@ -46,12 +21,38 @@ class Hashira::Project
46
21
 
47
22
  def relative(path) = path.delete_prefix("#{parent(path)}/")
48
23
 
49
- def label = @directories.join(", ")
24
+ def label = directories.join(", ")
50
25
 
51
26
  def root = ROOT_PACKAGE
52
27
 
53
28
  private
54
29
 
30
+ def resolved
31
+ chosen = (@requested.empty? ? defaults : @requested).map { descend(it.delete_suffix("/")) }
32
+ vet(chosen)
33
+ survivors = distinct(chosen)
34
+ raise(Hashira::Error, "no Ruby files under #{survivors.join(", ")}") if bare?(survivors)
35
+ survivors
36
+ end
37
+
38
+ def bare?(survivors) = survivors.flat_map { Dir["#{it}/**/*.rb"] }.empty?
39
+
40
+ def defaults
41
+ return ["lib"] if Dir["lib/*/"].any? || Dir["lib/*.rb"].any?
42
+ raise(Hashira::Error, "no lib/ directory here — pass the source directory explicitly")
43
+ end
44
+
45
+ def descend(directory)
46
+ child = sole(directory)
47
+ return directory unless child && Dir["#{child}/*/"].any? && (Dir["#{directory}/*.rb"] - ["#{child}.rb"]).empty?
48
+ descend(child)
49
+ end
50
+
51
+ def sole(directory)
52
+ subdirectories = Dir["#{directory}/*/"]
53
+ subdirectories.size == 1 ? subdirectories.first.delete_suffix("/") : nil
54
+ end
55
+
55
56
  def vet(directories)
56
57
  file = directories.find { File.file?(it) }
57
58
  raise(Hashira::Error, "#{file} is a file — hashira takes directories (try: hashira #{File.dirname(file)})") if file
@@ -73,13 +74,13 @@ class Hashira::Project
73
74
  def folder?(path, name) = Dir.exist?("#{parent(path)}/#{name}")
74
75
 
75
76
  def contested
76
- @contested ||=
77
- @directories.flat_map { |directory| Dir["#{directory}/*/"].map { File.basename(it) } }
77
+ @_contested ||=
78
+ directories.flat_map { |directory| Dir["#{directory}/*/"].map { File.basename(it) } }
78
79
  .tally.filter_map { |name, count| name if count > 1 }
79
80
  end
80
81
 
81
82
  def parent(path)
82
- @directories.find { path.start_with?("#{it}/") } ||
83
+ directories.find { path.start_with?("#{it}/") } ||
83
84
  raise(Hashira::Error, "#{path} is outside the analyzed directories")
84
85
  end
85
86
  end
@@ -7,7 +7,7 @@ class Hashira::Report::Columns
7
7
  NUMBER = /\A-?\d+(?:\.\d+)?\z/
8
8
 
9
9
  def initialize(headers, rows, io: $stdout)
10
- @headers, *@rows = [headers, *rows].map { |cells| cells.map { clip(it) } }
10
+ @raw = [headers, *rows]
11
11
  @io = io
12
12
  end
13
13
 
@@ -17,14 +17,20 @@ class Hashira::Report::Columns
17
17
  body
18
18
  end
19
19
 
20
- def body = @rows.each { @io.puts(line(it)) }
20
+ def body = rows.each { @io.puts(line(it)) }
21
21
 
22
- def header = line(@headers)
22
+ def header = line(headers)
23
23
 
24
- def rule = "-" * [header, *@rows.map { line(it) }].map(&:length).max
24
+ def rule = "-" * [header, *rows.map { line(it) }].map(&:length).max
25
25
 
26
26
  private
27
27
 
28
+ def clipped = @_clipped ||= @raw.map { |cells| cells.map { clip(it) } }
29
+
30
+ def headers = clipped.first
31
+
32
+ def rows = clipped.drop(1)
33
+
28
34
  def line(cells) = cells.map.with_index { |cell, column| pad(cell, column) }.join(GAP).rstrip
29
35
 
30
36
  def pad(cell, column)
@@ -32,13 +38,13 @@ class Hashira::Report::Columns
32
38
  numeric[column] ? cell.rjust(width) : cell.ljust(width)
33
39
  end
34
40
 
35
- def widths = @widths ||= @headers.each_index.map { |column| down(column).map(&:length).max }
41
+ def widths = @_widths ||= headers.each_index.map { |column| down(column).map(&:length).max }
36
42
 
37
- def numeric = @numeric ||= @headers.each_index.map { |column| @rows.any? && counted?(column) }
43
+ def numeric = @_numeric ||= headers.each_index.map { |column| rows.any? && counted?(column) }
38
44
 
39
- def counted?(column) = @rows.all? { NUMBER.match?(it[column]) }
45
+ def counted?(column) = rows.all? { NUMBER.match?(it[column]) }
40
46
 
41
- def down(column) = [@headers[column], *@rows.map { it[column] }]
47
+ def down(column) = [headers[column], *rows.map { it[column] }]
42
48
 
43
49
  def clip(cell)
44
50
  text = cell.to_s
@@ -17,7 +17,7 @@ class Hashira::Report::GraphPayload
17
17
 
18
18
  def edges
19
19
  @graph.weighted.map do |from, to, weight|
20
- { from:, to:, weight:, refs: @graph.evidence(from, to).to_a.sort }
20
+ { from:, to:, weight: }.merge(refs: @graph.evidence(from, to).to_a.sort)
21
21
  end
22
22
  end
23
23
  end
@@ -19,7 +19,7 @@ class Hashira::Report::HotspotTable
19
19
 
20
20
  private
21
21
 
22
- def ranked = @ranked ||= @hotspots.files.first(@top)
22
+ def ranked = @_ranked ||= @hotspots.files.first(@top)
23
23
 
24
24
  def legend
25
25
  @io.puts("\nLegend: Cog cognitive complexity, Dup mass of the clones the file carries,")
@@ -21,7 +21,8 @@ class Hashira::Report::Json
21
21
 
22
22
  def about
23
23
  project = @view.project
24
- { version: SCHEMA, packaging: @view.graph&.packaging, targets: project.directories, files: project.files.size }
24
+ { version: SCHEMA, packaging: @view.graph&.packaging, targets: project.directories }
25
+ .merge(files: project.files.size)
25
26
  end
26
27
 
27
28
  def base = { findings: @view.findings.all.map { rendered(it) }, accepted: accepted }
@@ -22,7 +22,7 @@ class Hashira::Report::MetricsTable
22
22
 
23
23
  def ranked = @graph.metrics.sort_by { |_package, metric| metric.order }
24
24
 
25
- def split = @split ||= (ranked.size > @top ? ranked.partition { |_package, metric| !leaf?(metric) } : [ranked, []])
25
+ def split = @_split ||= (ranked.size > @top ? ranked.partition { |_package, metric| !leaf?(metric) } : [ranked, []])
26
26
 
27
27
  def kept = split.first.first(@top)
28
28
 
@@ -38,9 +38,9 @@ class Hashira::Report::MetricsTable
38
38
 
39
39
  def more = @io.puts(" … and #{over} more — raise the cap with --top, or read them all with --json")
40
40
 
41
- def row(package, metric) = [package, *metric.cells, cyc(package)]
41
+ def row(package, metric) = [package, *metric.cells, cycle(package)]
42
42
 
43
- def cyc(package) = @graph.cycles.through?(package) ? "YES" : "-"
43
+ def cycle(package) = @graph.cycles.through?(package) ? "YES" : "-"
44
44
 
45
45
  def legend
46
46
  @io.puts("\nLegend: TC total types, Ca afferent (incoming), Ce efferent (outgoing),")
@@ -1,28 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hashira::Report::Phrases
4
+ FLATTEN = "flatten the branching — guard clauses, early returns, or polymorphism."
5
+
6
+ LOOP_BODY = "extract the loop body into its own method."
7
+
4
8
  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.",
9
+ "if" => FLATTEN, "else" => FLATTEN,
10
+ "elsif" => "replace the elsif ladder with a lookup or polymorphic dispatch."
11
+ }.merge(
8
12
  "case" => "a case this size often wants polymorphism or a dispatch table.",
9
13
  "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
+ "rescue" => "narrow the rescue, or lift error handling to the caller."
15
+ ).merge(
16
+ "while" => LOOP_BODY, "until" => LOOP_BODY, "for" => LOOP_BODY
17
+ ).merge(
14
18
  "unless" => "invert to a guard clause or a named predicate.",
15
19
  "ternary" => "extract the nested ternary into a named method."
16
- }.freeze
20
+ ).freeze
17
21
 
18
22
  DUPLICATION_ADVICE = {
19
23
  identical: "byte-for-byte identical — extract a shared method and call it from each site.",
20
24
  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.",
25
+ message: "differs only in the receiver or message — extract a method taking the receiver."
26
+ }.merge(
22
27
  constant: "differs only in a constant — extract a method and parameterize it.",
23
28
  structure: "the control flow differs — extract the common core, but verify by hand (lower confidence).",
24
29
  mixed: "extract the shared shape and pass what differs as parameters."
25
- }.freeze
30
+ ).freeze
26
31
 
27
32
  module_function
28
33
 
@@ -38,9 +43,9 @@ module Hashira::Report::Phrases
38
43
 
39
44
  def on_sdp_violation(finding)
40
45
  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}. " \
46
+ from, to = detail.deconstruct
47
+ "#{from} (I=#{score(detail.from_instability)}) depends on the LESS stable #{to} " \
48
+ "(I=#{score(detail.to_instability)}) — churn in #{to} will force churn in #{from}. " \
44
49
  "Invert the edge or extract the stable part of #{to} that #{from} needs."
45
50
  end
46
51
 
@@ -69,14 +74,14 @@ module Hashira::Report::Phrases
69
74
 
70
75
  def on_complexity(finding)
71
76
  detail = finding.detail
72
- "#{finding.package} — cognitive #{detail[:cognitive]}, #{detail[:calls]} calls " \
73
- "(#{detail[:site]}). #{COMPLEXITY_ADVICE.fetch(detail[:dominant])}"
77
+ "#{finding.package} — cognitive #{detail.cognitive}, #{detail.calls} calls " \
78
+ "(#{detail.site}). #{COMPLEXITY_ADVICE.fetch(detail.dominant)}"
74
79
  end
75
80
 
76
81
  def on_duplication(finding)
77
82
  detail = finding.detail
78
- "#{detail[:size]} similar fragments (mass #{detail[:mass]}) — " \
79
- "#{DUPLICATION_ADVICE.fetch(detail[:kind])}#{footnote(detail)}"
83
+ "#{detail.size} similar fragments (mass #{detail.mass}) — " \
84
+ "#{DUPLICATION_ADVICE.fetch(detail.kind)}#{footnote(detail)}"
80
85
  end
81
86
 
82
87
  def score(value) = format("%.2f", value)
@@ -95,6 +100,6 @@ module Hashira::Report::Phrases
95
100
  end
96
101
 
97
102
  def footnote(detail)
98
- detail[:hot] ? " Both sites change often — fix one, miss the other." : ""
103
+ detail.hot ? " Both sites change often — fix one, miss the other." : ""
99
104
  end
100
105
  end
@@ -6,10 +6,9 @@ class Hashira::Smells::Census
6
6
  def initialize(project, trees)
7
7
  @project = project
8
8
  @trees = trees
9
- @ownership = Hashira::Smells::Ownership.new(trees.values)
10
9
  end
11
10
 
12
- attr_reader :ownership
11
+ def ownership = @_ownership ||= Hashira::Smells::Ownership.new(@trees.values)
13
12
 
14
13
  def types = @trees.flat_map { |path, tree| harvest(@project.relative(path), tree) }
15
14
 
@@ -28,8 +27,8 @@ class Hashira::Smells::Census
28
27
  def kind(node) = node.is_a?(Prism::ModuleNode) ? :module : :class
29
28
 
30
29
  def defs(name, node, file)
31
- Hashira::Smells::Visibility.new(node).entries.map do |def_node, section|
32
- Hashira::Smells::MethodContext.new(owner: name, node: def_node, file:, section:, ownership: @ownership)
30
+ Hashira::Smells::Visibility.new(node).entries.map do |definition, section|
31
+ Hashira::Smells::MethodContext.new(owner: name, node: definition, file:, section:, ownership:)
33
32
  end
34
33
  end
35
34
  end
@@ -1,10 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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
4
  def initialize(subject)
9
5
  @subject = subject
10
6
  end
@@ -18,7 +14,7 @@ class Hashira::Smells::Check
18
14
 
19
15
  attr_reader :subject
20
16
 
21
- def kind = self.class.kind
17
+ def kind = Hashira::Smells::Kind.new(self.class).to_s
22
18
 
23
19
  def label = subject.subject
24
20
 
@@ -9,12 +9,12 @@ module Hashira
9
9
 
10
10
  module_function
11
11
 
12
- def names(def_node) = parts(def_node).flat_map { expand(it) }
12
+ def names(definition) = parts(definition).flat_map { expand(it) }
13
13
 
14
- def arguments(def_node) = parts(def_node).grep_v(Prism::BlockParameterNode).flat_map { expand(it) }
14
+ def arguments(definition) = parts(definition).grep_v(Prism::BlockParameterNode).flat_map { expand(it) }
15
15
 
16
- def parts(def_node)
17
- node = def_node.parameters
16
+ def parts(definition)
17
+ node = definition.parameters
18
18
  return [] unless node
19
19
  node.requireds + node.optionals + node.posts + node.keywords +
20
20
  [node.rest, node.keyword_rest, node.block].compact
@@ -5,7 +5,7 @@ class Hashira::Smells::ControlParameter < Hashira::Smells::Check
5
5
 
6
6
  def smelly? = culprits.any?
7
7
 
8
- def culprits = @culprits ||= subject.parameters.filter_map { |name| culprit(name) }
8
+ def culprits = @_culprits ||= subject.parameters.filter_map { |name| culprit(name) }
9
9
 
10
10
  def culprit(name)
11
11
  lines = spots(name).uniq
@@ -5,15 +5,13 @@ class Hashira::Smells::DataClump < Hashira::Smells::Check
5
5
 
6
6
  MIN_SIZE = 2
7
7
 
8
- def self.judge? = true
9
-
10
8
  private
11
9
 
12
10
  def smelly? = clumps.any?
13
11
 
14
- def candidates = @candidates ||= subject.owned.map { [it, it.arguments.sort] }
12
+ def candidates = @_candidates ||= subject.owned.map { [it, it.arguments.sort] }
15
13
 
16
- def clumps = @clumps ||= shared.map { |clump| [clump, holders(clump)] }
14
+ def clumps = @_clumps ||= shared.map { |clump| [clump, holders(clump)] }
17
15
 
18
16
  def shared = candidates.combination(MAX_COPIES + 1).filter_map { |group| clump(group) }.uniq
19
17
 
@@ -9,14 +9,14 @@ class Hashira::Smells::DuplicateMethodCall < Hashira::Smells::Check
9
9
 
10
10
  def smelly? = repeats.any?
11
11
 
12
- def calls = @calls ||= Hashira::Smells::Scope.inside(subject.node).grep(Prism::CallNode)
12
+ def calls = @_calls ||= Hashira::Smells::Scope.inside(subject.node).grep(Prism::CallNode)
13
13
 
14
14
  def plain?(node)
15
15
  !node.receiver && !node.arguments && !node.block.is_a?(Prism::BlockArgumentNode)
16
16
  end
17
17
 
18
18
  def repeats
19
- @repeats ||= usual.reject { |_handle, nodes| whole.value?(nodes) }.merge(whole)
19
+ @_repeats ||= usual.reject { |_handle, nodes| whole.value?(nodes) }.merge(whole)
20
20
  end
21
21
 
22
22
  def usual
@@ -25,7 +25,7 @@ class Hashira::Smells::DuplicateMethodCall < Hashira::Smells::Check
25
25
  end
26
26
 
27
27
  def whole
28
- @whole ||=
28
+ @_whole ||=
29
29
  calls.select { it.block.is_a?(Prism::BlockNode) }
30
30
  .group_by { it.slice.gsub(/\s+/, " ") }.select { |_handle, group| group.size > LIMIT }
31
31
  end
@@ -7,11 +7,11 @@ class Hashira::Smells::FeatureEnvy < Hashira::Smells::Check
7
7
  !subject.singleton? && !subject.mixin? && refs.ego.positive? && envied.any?
8
8
  end
9
9
 
10
- def refs = @refs ||= Hashira::Smells::Refs.new(subject.node)
10
+ def refs = @_refs ||= Hashira::Smells::Refs.new(subject.node)
11
11
 
12
- def envied = @envied ||= refs.envious.reject { foreign.dismiss?(it) }
12
+ def envied = @_envied ||= refs.envious.reject { foreign.dismiss?(it) }
13
13
 
14
- def foreign = @foreign ||= Hashira::Smells::Foreign.new(subject, subject.ownership)
14
+ def foreign = @_foreign ||= Hashira::Smells::Foreign.new(subject, subject.ownership)
15
15
 
16
16
  def detail = { site:, names: envied }
17
17
 
@@ -18,9 +18,7 @@ class Hashira::Smells::Foreign
18
18
  ].freeze
19
19
 
20
20
  def initialize(subject, ownership)
21
- node = subject.node
22
- @body = Hashira::Smells::Scope.inside(node)
23
- @tail = tail(node)
21
+ @subject = subject
24
22
  @ownership = ownership
25
23
  end
26
24
 
@@ -35,12 +33,16 @@ class Hashira::Smells::Foreign
35
33
 
36
34
  private
37
35
 
36
+ def body = @_body ||= Hashira::Smells::Scope.inside(@subject.node)
37
+
38
+ def tail = @_tail ||= Hashira::Analysis::Syntax.statements(@subject.node).compact.last
39
+
38
40
  def convert?
39
- @tail.is_a?(Prism::CallNode) && @tail.name == :new &&
40
- Hashira::Analysis::Syntax.segments(@tail.receiver).any? && stateless?
41
+ tail.is_a?(Prism::CallNode) && tail.name == :new &&
42
+ Hashira::Analysis::Syntax.segments(tail.receiver).any? && stateless?
41
43
  end
42
44
 
43
- def stateless? = @body.none? { STATE.include?(it.class) }
45
+ def stateless? = body.none? { STATE.include?(it.class) }
44
46
 
45
47
  def fenced?(name)
46
48
  tested = tests { it == name }
@@ -48,7 +50,7 @@ class Hashira::Smells::Foreign
48
50
  end
49
51
 
50
52
  def wire?(name)
51
- calls = @body.grep(Prism::CallNode).select { |call| local?(call.receiver) { it == name } }
53
+ calls = body.grep(Prism::CallNode).select { |call| local?(call.receiver) { it == name } }
52
54
  calls.any? && calls.all? { keyed?(it) } && reassignments(name).none?
53
55
  end
54
56
 
@@ -75,11 +77,11 @@ class Hashira::Smells::Foreign
75
77
  def reassignments(name) = among(Prism::LocalVariableOperatorWriteNode, name)
76
78
 
77
79
  def among(kind, name)
78
- @body.grep(kind).select { it.name == name }
80
+ body.grep(kind).select { it.name == name }
79
81
  end
80
82
 
81
83
  def snares(name)
82
- @body.grep(Prism::RescueNode).select { it.reference&.name == name }
84
+ body.grep(Prism::RescueNode).select { it.reference&.name == name }
83
85
  end
84
86
 
85
87
  def alien?(exceptions)
@@ -107,15 +109,15 @@ class Hashira::Smells::Foreign
107
109
  end
108
110
 
109
111
  def probes(&)
110
- @body.grep(Prism::CallNode).select { TYPE_TESTS.include?(it.name) && local?(it.receiver, &) }.filter_map { key(it) }
112
+ body.grep(Prism::CallNode).select { TYPE_TESTS.include?(it.name) && local?(it.receiver, &) }.filter_map { key(it) }
111
113
  end
112
114
 
113
115
  def arms(&)
114
- @body.grep(Prism::CaseNode).select { local?(it.predicate, &) }.flat_map(&:conditions).flat_map(&:conditions)
116
+ body.grep(Prism::CaseNode).select { local?(it.predicate, &) }.flat_map(&:conditions).flat_map(&:conditions)
115
117
  end
116
118
 
117
119
  def lookups(&)
118
- @body.grep(Prism::CallNode).select { it.name == :[] && sorts?(key(it), &) }.flat_map { @ownership.keys(Hashira::Analysis::Syntax.segments(it.receiver)) }
120
+ body.grep(Prism::CallNode).select { it.name == :[] && sorts?(key(it), &) }.flat_map { @ownership.keys(Hashira::Analysis::Syntax.segments(it.receiver)) }
119
121
  end
120
122
 
121
123
  def sorts?(argument, &)
@@ -126,6 +128,4 @@ class Hashira::Smells::Foreign
126
128
  def local?(node, &) = node.is_a?(Prism::LocalVariableReadNode) && yield(node.name)
127
129
 
128
130
  def key(call) = call.arguments&.arguments&.first
129
-
130
- def tail(def_node) = Hashira::Analysis::Syntax.statements(def_node).compact.last
131
131
  end
@@ -26,13 +26,13 @@ class Hashira::Smells::InstanceVariableAssumption < Hashira::Smells::Check
26
26
  end
27
27
  end
28
28
 
29
- def self.judge? = true
30
-
31
29
  private
32
30
 
33
31
  def smelly? = subject.kind == :class && assumed.any?
34
32
 
35
- def assumed = @assumed ||= (read - prepared).uniq.sort
33
+ def assumed = @_assumed ||= (read - prepared).uniq.sort.reject { cache?(it) }
34
+
35
+ def cache?(name) = name.start_with?("@_")
36
36
 
37
37
  def read = subject.owned.flat_map { Harvest.reads(it.node) }
38
38
 
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Smells::Kind
4
+ def initialize(check)
5
+ @check = check
6
+ end
7
+
8
+ def to_s = @check.name.split("::").last.gsub(/(?<=[a-z])(?=[A-Z])/, "_").downcase
9
+ end
@@ -8,7 +8,7 @@ class Hashira::Smells::ManualDispatch < Hashira::Smells::Check
8
8
  def smelly? = sightings.any?
9
9
 
10
10
  def sightings
11
- @sightings ||= Hashira::Smells::Scope.inside(subject.node)
11
+ @_sightings ||= Hashira::Smells::Scope.inside(subject.node)
12
12
  .select { it.is_a?(Prism::CallNode) && it.name == :respond_to? }
13
13
  end
14
14
 
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::Smells::ModuleInitialize < Hashira::Smells::Check
4
- def self.judge? = true
5
-
6
4
  private
7
5
 
8
6
  def smelly? = subject.kind == :module && flagged
@@ -9,7 +9,7 @@ class Hashira::Smells::NilCheck < Hashira::Smells::Check
9
9
 
10
10
  def smelly? = checks.any?
11
11
 
12
- def checks = @checks ||= Hashira::Smells::Scope.inside(subject.node).select { check?(it) }
12
+ def checks = @_checks ||= Hashira::Smells::Scope.inside(subject.node).select { check?(it) }
13
13
 
14
14
  def check?(node)
15
15
  case node
@@ -2,17 +2,29 @@
2
2
 
3
3
  class Hashira::Smells::Ownership
4
4
  def initialize(trees)
5
- @suffixes = Set.new
6
- @tables = {}
7
- trees.each { survey(it) }
5
+ @trees = trees
8
6
  end
9
7
 
10
- def owned?(segments) = @suffixes.include?(segments.join("::"))
8
+ def owned?(segments)
9
+ surveyed
10
+ @_suffixes.include?(segments.join("::"))
11
+ end
11
12
 
12
- def keys(segments) = @tables.fetch(segments.join("::"), [])
13
+ def keys(segments)
14
+ surveyed
15
+ @_tables.fetch(segments.join("::"), [])
16
+ end
13
17
 
14
18
  private
15
19
 
20
+ def surveyed
21
+ return if @_surveyed
22
+ @_surveyed = true
23
+ @_suffixes = Set.new
24
+ @_tables = {}
25
+ @trees.each { survey(it) }
26
+ end
27
+
16
28
  def survey(tree)
17
29
  Hashira::Analysis::TypeWalk.each(tree) do |node, full|
18
30
  absorb(full)
@@ -27,14 +39,14 @@ class Hashira::Smells::Ownership
27
39
  end
28
40
 
29
41
  def absorb(path)
30
- @suffixes.merge(suffixes(path))
42
+ @_suffixes.merge(suffixes(path))
31
43
  end
32
44
 
33
45
  def chart(path, value)
34
46
  return unless value.is_a?(Prism::HashNode)
35
47
  keys = value.elements.map { spine(it) }
36
48
  return if keys.empty? || keys.any?(&:nil?)
37
- suffixes(path).each { @tables[it] = keys }
49
+ suffixes(path).each { @_tables[it] = keys }
38
50
  end
39
51
 
40
52
  def thaw(value)
@@ -22,13 +22,13 @@ class Hashira::Smells::ParamCheck
22
22
  private
23
23
 
24
24
  def nested
25
- @nested ||= Hashira::Smells::Conditions.nested(branches).map { self.class.new(it, @name) }
25
+ @_nested ||= Hashira::Smells::Conditions.nested(branches).map { self.class.new(it, @name) }
26
26
  end
27
27
 
28
28
  def branches = Hashira::Smells::Conditions.branches(@node)
29
29
 
30
30
  def predicate
31
- @predicate ||= spread(Hashira::Smells::Conditions.condition(@node))
31
+ @_predicate ||= spread(Hashira::Smells::Conditions.condition(@node))
32
32
  end
33
33
 
34
34
  def spread(condition) = condition ? Hashira::Analysis::NodeWalk.collect(condition) : []