hashira 0.7.0 → 0.9.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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -0
  3. data/README.md +57 -3
  4. data/exe/hashira +1 -1
  5. data/lib/hashira/analysis/finding.rb +11 -1
  6. data/lib/hashira/analysis/syntax.rb +6 -6
  7. data/lib/hashira/analysis/type_walk.rb +4 -0
  8. data/lib/hashira/churn.rb +1 -10
  9. data/lib/hashira/ci/accepted.rb +9 -7
  10. data/lib/hashira/ci/baseline.rb +53 -33
  11. data/lib/hashira/ci/comparison.rb +33 -0
  12. data/lib/hashira/ci/diff.rb +0 -15
  13. data/lib/hashira/ci/gate.rb +3 -3
  14. data/lib/hashira/ci/mark.rb +7 -0
  15. data/lib/hashira/ci/ratchet.rb +12 -4
  16. data/lib/hashira/ci/ratchet_report.rb +4 -4
  17. data/lib/hashira/ci/scope.rb +0 -1
  18. data/lib/hashira/ci/slice.rb +21 -0
  19. data/lib/hashira/ci/sweep.rb +13 -0
  20. data/lib/hashira/cli/arguments.rb +12 -8
  21. data/lib/hashira/cli/choice.rb +9 -0
  22. data/lib/hashira/cli/command_line.rb +8 -5
  23. data/lib/hashira/cli/fail_on.rb +6 -6
  24. data/lib/hashira/cli/flag.rb +3 -1
  25. data/lib/hashira/cli/flags.rb +10 -0
  26. data/lib/hashira/cli/format.rb +1 -1
  27. data/lib/hashira/cli/needs.rb +13 -2
  28. data/lib/hashira/cli/only.rb +16 -0
  29. data/lib/hashira/cli/options.rb +8 -10
  30. data/lib/hashira/cli/package_by.rb +1 -1
  31. data/lib/hashira/cli/run.rb +9 -8
  32. data/lib/hashira/cli.rb +22 -21
  33. data/lib/hashira/complexity/cognitive_score.rb +44 -33
  34. data/lib/hashira/complexity/method_finding.rb +7 -5
  35. data/lib/hashira/complexity/method_score.rb +1 -1
  36. data/lib/hashira/complexity/scores.rb +11 -9
  37. data/lib/hashira/coupling/audiences.rb +3 -3
  38. data/lib/hashira/coupling/catalog.rb +8 -6
  39. data/lib/hashira/coupling/census.rb +42 -22
  40. data/lib/hashira/coupling/cycle_search.rb +11 -7
  41. data/lib/hashira/coupling/definitions.rb +1 -7
  42. data/lib/hashira/coupling/edge_map.rb +9 -8
  43. data/lib/hashira/coupling/folding.rb +5 -5
  44. data/lib/hashira/coupling/graph.rb +16 -9
  45. data/lib/hashira/coupling/metric.rb +3 -1
  46. data/lib/hashira/coupling/naming.rb +4 -4
  47. data/lib/hashira/coupling/references.rb +12 -12
  48. data/lib/hashira/coupling/report.rb +7 -9
  49. data/lib/hashira/coupling/roll_call.rb +1 -1
  50. data/lib/hashira/coupling/roll_call_findings.rb +1 -1
  51. data/lib/hashira/coupling/roster.rb +37 -12
  52. data/lib/hashira/coupling/rule.rb +1 -1
  53. data/lib/hashira/coupling/sdp_violation_findings.rb +3 -1
  54. data/lib/hashira/duplication/clones.rb +1 -1
  55. data/lib/hashira/duplication/clusters.rb +10 -7
  56. data/lib/hashira/duplication/delta.rb +1 -4
  57. data/lib/hashira/duplication/duplication_finding.rb +4 -2
  58. data/lib/hashira/duplication/fragment.rb +20 -2
  59. data/lib/hashira/duplication/harvest.rb +7 -5
  60. data/lib/hashira/duplication/index.rb +3 -2
  61. data/lib/hashira/duplication/literal.rb +25 -0
  62. data/lib/hashira/duplication/similarity.rb +2 -2
  63. data/lib/hashira/duplication/variance.rb +7 -1
  64. data/lib/hashira/error.rb +0 -3
  65. data/lib/hashira/focus.rb +29 -0
  66. data/lib/hashira/git_log.rb +19 -0
  67. data/lib/hashira/hotspots/file_cost.rb +1 -1
  68. data/lib/hashira/hotspots/rollup.rb +2 -2
  69. data/lib/hashira/pipeline.rb +22 -14
  70. data/lib/hashira/project.rb +35 -34
  71. data/lib/hashira/report/columns.rb +14 -8
  72. data/lib/hashira/report/graph_payload.rb +1 -1
  73. data/lib/hashira/report/hotspot_table.rb +1 -1
  74. data/lib/hashira/report/json.rb +2 -1
  75. data/lib/hashira/report/metrics_table.rb +3 -3
  76. data/lib/hashira/report/phrases.rb +23 -18
  77. data/lib/hashira/report/smell_phrases.rb +2 -2
  78. data/lib/hashira/smells/branches.rb +62 -0
  79. data/lib/hashira/smells/census.rb +11 -6
  80. data/lib/hashira/smells/check.rb +1 -5
  81. data/lib/hashira/smells/contexts.rb +7 -5
  82. data/lib/hashira/smells/control_parameter.rb +1 -1
  83. data/lib/hashira/smells/data_clump.rb +2 -4
  84. data/lib/hashira/smells/duplicate_method_call.rb +25 -5
  85. data/lib/hashira/smells/feature_envy.rb +3 -3
  86. data/lib/hashira/smells/foreign.rb +14 -14
  87. data/lib/hashira/smells/instance_variable_assumption.rb +5 -15
  88. data/lib/hashira/smells/kind.rb +9 -0
  89. data/lib/hashira/smells/lineage.rb +92 -0
  90. data/lib/hashira/smells/manual_dispatch.rb +1 -1
  91. data/lib/hashira/smells/module_initialize.rb +0 -2
  92. data/lib/hashira/smells/nil_check.rb +1 -1
  93. data/lib/hashira/smells/ownership.rb +19 -7
  94. data/lib/hashira/smells/param_check.rb +2 -2
  95. data/lib/hashira/smells/refs.rb +13 -7
  96. data/lib/hashira/smells/repeated_conditional.rb +1 -3
  97. data/lib/hashira/smells/report.rb +16 -7
  98. data/lib/hashira/smells/too_many_instance_variables.rb +3 -3
  99. data/lib/hashira/smells/visibility.rb +18 -11
  100. data/lib/hashira/trees.rb +8 -4
  101. data/lib/hashira/version.rb +1 -1
  102. data/lib/hashira.rb +12 -0
  103. metadata +13 -1
@@ -7,19 +7,21 @@ class Hashira::Duplication::Harvest
7
7
 
8
8
  def initialize(project, trees)
9
9
  @project = project
10
- @fragments = trees.flat_map { |path, tree| scan(@project.relative(path), tree) }
10
+ @trees = trees
11
11
  end
12
12
 
13
- attr_reader :fragments
13
+ def fragments = @_fragments ||= @trees.flat_map { |path, tree| scan(@project.relative(path), tree) }
14
14
 
15
15
  private
16
16
 
17
- def scan(rel, tree)
17
+ def scan(relative, tree)
18
18
  nodes = Hashira::Analysis::NodeWalk.collect(tree)
19
- windows(rel, nodes) + wholes(nodes).map { Hashira::Duplication::Fragment.new(rel, [it]) }
19
+ windows(relative, nodes) + wholes(nodes).map { Hashira::Duplication::Fragment.new(relative, [it]) }
20
20
  end
21
21
 
22
- def windows(rel, nodes) = runs(nodes).flat_map { Hashira::Duplication::Sequence.new(rel, it).fragments }
22
+ def windows(relative, nodes)
23
+ runs(nodes).flat_map { Hashira::Duplication::Sequence.new(relative, it).fragments }
24
+ end
23
25
 
24
26
  def runs(nodes) = nodes.filter_map { it.body if it.is_a?(Prism::StatementsNode) }
25
27
 
@@ -6,13 +6,14 @@ class Hashira::Duplication::Index
6
6
 
7
7
  def initialize(fragments)
8
8
  @fragments = fragments
9
- @frequency = frequencies(fragments)
10
9
  end
11
10
 
12
11
  def buckets = grouped.values.select { |bucket| bucket.size.between?(2, MAX_BUCKET) }
13
12
 
14
13
  private
15
14
 
15
+ def frequency = @_frequency ||= frequencies(@fragments)
16
+
16
17
  def frequencies(fragments)
17
18
  fragments.each_with_object(Hash.new(0)) { |fragment, counts| tally(counts, fragment) }
18
19
  end
@@ -27,5 +28,5 @@ class Hashira::Duplication::Index
27
28
 
28
29
  def file(index, fragment) = rarest(fragment).each { |type| index[type] << fragment }
29
30
 
30
- def rarest(fragment) = fragment.types.uniq.min_by(RARE) { @frequency[it] }
31
+ def rarest(fragment) = fragment.types.uniq.min_by(RARE) { frequency[it] }
31
32
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ class Hashira::Duplication::Literal
6
+ TYPES = [
7
+ Prism::ArrayNode, Prism::AssocNode, Prism::FalseNode, Prism::FloatNode,
8
+ Prism::HashNode, Prism::IntegerNode, Prism::KeywordHashNode, Prism::NilNode,
9
+ Prism::StringNode, Prism::SymbolNode, Prism::TrueNode
10
+ ].freeze
11
+
12
+ def initialize(node)
13
+ @node = node
14
+ end
15
+
16
+ def literal? = TYPES.include?(@node.class) && parts.all?(&:literal?)
17
+
18
+ def literals? = parts.all?(&:literal?)
19
+
20
+ private
21
+
22
+ def parts = children.map { Hashira::Duplication::Literal.new(it) }
23
+
24
+ def children = Array(@node&.compact_child_nodes)
25
+ end
@@ -8,7 +8,7 @@ class Hashira::Duplication::Similarity
8
8
 
9
9
  def ratio
10
10
  return 0.0 if @left.empty? || @right.empty?
11
- normalized(lcs)
11
+ normalized(subsequence)
12
12
  end
13
13
 
14
14
  def meets?(threshold) = ceiling >= threshold && ratio >= threshold
@@ -29,7 +29,7 @@ class Hashira::Duplication::Similarity
29
29
  true
30
30
  end
31
31
 
32
- def lcs = @left.reduce(blank) { |prev, token| advance(prev, token) }.last
32
+ def subsequence = @left.reduce(blank) { |prev, token| advance(prev, token) }.last
33
33
 
34
34
  def blank = Array.new(@right.size + 1, 0)
35
35
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "prism"
4
+
3
5
  class Hashira::Duplication::Variance
4
6
  LITERALS = %i[integer_node float_node string_node symbol_node].freeze
5
7
  VALUED = %i[integer_node float_node].freeze
@@ -27,7 +29,11 @@ class Hashira::Duplication::Variance
27
29
 
28
30
  def pairs = @canonical.nodes.zip(@other.nodes)
29
31
 
30
- def named = @named ||= pairs.select { |left, _| NAMED.include?(left.type) }
32
+ def named = @_named ||= pairs.select { |left, _| NAMED.include?(left.type) && !inner?(left) }
33
+
34
+ def inner?(node) = nested.any? { it.equal?(node) }
35
+
36
+ def nested = @_nested ||= @canonical.nodes.grep(Prism::ConstantPathNode).filter_map(&:parent)
31
37
 
32
38
  def differing = pairs.select { |pair| varies?(*pair) }.map(&:first)
33
39
 
data/lib/hashira/error.rb CHANGED
@@ -1,7 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::Error < StandardError
4
- def self.unknown(flag, value, choices)
5
- new("unknown #{flag} #{value.inspect} (use: #{choices.join(", ")})")
6
- end
7
4
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Focus
4
+ SITE = /\A(?<path>[^\s:]+\.rb):/
5
+
6
+ def initialize(project, paths)
7
+ @project = project
8
+ @paths = paths
9
+ end
10
+
11
+ def narrowing? = !@paths.empty?
12
+
13
+ def narrow(findings)
14
+ return findings unless narrowing?
15
+ findings.select { named?(it) }
16
+ end
17
+
18
+ private
19
+
20
+ def wanted = @_wanted ||= @paths.select { covered?(it) }.map { @project.relative(it) }
21
+
22
+ def covered?(path) = @project.directories.any? { path.start_with?("#{it}/") }
23
+
24
+ def named?(finding) = sites(finding).any? { wanted.include?(it) }
25
+
26
+ def sites(finding) = quotes(finding).filter_map { SITE.match(it)&.[](:path) }
27
+
28
+ def quotes(finding) = [finding.package, finding.detail.to_h[:site], *finding.evidence].compact
29
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::GitLog
4
+ LOG = %w[log --no-renames --name-only --format=].freeze
5
+
6
+ def initialize(directory)
7
+ @directory = directory
8
+ end
9
+
10
+ def counts = output.split("\n").map(&:strip).reject(&:empty?).tally
11
+
12
+ private
13
+
14
+ def output
15
+ IO.popen(["git", "-C", @directory, *LOG], err: File::NULL, &:read)
16
+ rescue SystemCallError
17
+ ""
18
+ end
19
+ end
@@ -12,7 +12,7 @@ module Hashira
12
12
 
13
13
  def heat = [churn, CHURN_FLOOR].max
14
14
 
15
- def to_h = { file:, cognitive:, duplication:, churn:, cost:, rank: }
15
+ def to_h = super.merge(cost:, rank:)
16
16
 
17
17
  def cells = [file, cognitive, duplication, churn, rank]
18
18
  end
@@ -23,9 +23,9 @@ class Hashira::Hotspots::Rollup
23
23
 
24
24
  def clusters = @duplication ? @duplication.clusters : []
25
25
 
26
- def cognitive = @cognitive ||= bucketed(scores.map { [it.file, it.cognitive] })
26
+ def cognitive = @_cognitive ||= bucketed(scores.map { [it.file, it.cognitive] })
27
27
 
28
- def duplicated = @duplicated ||= bucketed(clusters.flat_map(&:masses))
28
+ def duplicated = @_duplicated ||= bucketed(clusters.flat_map(&:masses))
29
29
 
30
30
  def bucketed(charges) = charges.each_with_object(Hash.new(0)) { |(file, cost), total| total[file] += cost }
31
31
  end
@@ -10,50 +10,58 @@ class Hashira::Pipeline
10
10
  STRUCTURAL = Hashira::Coupling::Report::RULES.map { it::KIND }.freeze
11
11
 
12
12
  SMELLS = (
13
- Hashira::Smells::Report::CHECKS.map(&:kind) + [Hashira::Smells::BoundarySprawl::KIND]
13
+ Hashira::Smells::Report::CHECKS.map { Hashira::Smells::Kind.new(it).to_s } + [Hashira::Smells::BoundarySprawl::KIND]
14
14
  ).sort.freeze
15
15
 
16
- def initialize(project, enabled: ANALYZERS, packaging: :auto)
16
+ def initialize(project, enabled: ANALYZERS, packaging: :auto, only: [])
17
17
  @project = project
18
18
  @enabled = enabled
19
- @parsed = Hashira::Trees.new(project)
20
- @coupling = Hashira::Coupling::Report.new(project, @parsed.all, packaging: settle(packaging))
19
+ @packaging = packaging
20
+ @only = only
21
21
  end
22
22
 
23
23
  attr_reader :project
24
24
 
25
- def unparsed = @parsed.unparsed
25
+ def unparsed = parsed.unparsed
26
26
 
27
- def graph = @coupling.graph
27
+ def graph = coupling.graph
28
28
 
29
- def complexity = @complexity ||= analyzed(:complexity, Hashira::Complexity::Scores)
29
+ def complexity = @_complexity ||= analyzed(:complexity, Hashira::Complexity::Scores)
30
30
 
31
- def duplication = @duplication ||= analyzed(:duplication, Hashira::Duplication::Clones, churn)
31
+ def duplication = @_duplication ||= analyzed(:duplication, Hashira::Duplication::Clones, churn)
32
32
 
33
- def smells = @smells ||= analyzed(:smells, Hashira::Smells::Report)
33
+ def smells = @_smells ||= analyzed(:smells, Hashira::Smells::Report)
34
34
 
35
35
  def hotspots
36
- @hotspots ||= Hashira::Hotspots::Rollup.new(complexity, duplication, churn) if complexity || duplication
36
+ @_hotspots ||= Hashira::Hotspots::Rollup.new(complexity, duplication, churn) if complexity || duplication
37
37
  end
38
38
 
39
- def churn = @churn ||= Hashira::Churn.scan(@project.directories.first)
39
+ def churn = @_churn ||= Hashira::Churn.build(@project.directories.first)
40
40
 
41
41
  def enabled?(analyzer) = @enabled.include?(analyzer)
42
42
 
43
43
  def analyzed(name, kind, *extra)
44
- kind.new(@project, @parsed.all, *extra) if enabled?(name)
44
+ kind.new(@project, parsed.all, *extra) if enabled?(name)
45
45
  end
46
46
 
47
- def findings = structural + listed(complexity) + listed(duplication) + listed(smells)
47
+ def findings = focus.narrow(structural + listed(complexity) + listed(duplication) + listed(smells))
48
+
49
+ def focus = @_focus ||= Hashira::Focus.new(@project, @only)
48
50
 
49
51
  private
50
52
 
53
+ def parsed = @_parsed ||= Hashira::Trees.new(@project)
54
+
55
+ def coupling
56
+ @_coupling ||= Hashira::Coupling::Report.new(@project, parsed.all, packaging: settle(@packaging))
57
+ end
58
+
51
59
  def settle(packaging)
52
60
  return packaging unless packaging == :auto
53
61
  @project.rails? ? :namespace : :folder
54
62
  end
55
63
 
56
- def structural = enabled?(:coupling) ? @coupling.findings : []
64
+ def structural = enabled?(:coupling) ? coupling.findings : []
57
65
 
58
66
  def listed(analyzer) = analyzer ? analyzer.findings : []
59
67
  end
@@ -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
@@ -33,8 +33,8 @@ module Hashira::Report::Phrases
33
33
  end
34
34
 
35
35
  def on_instance_variable_assumption(finding)
36
- "#{finding.package} reads instance variables never set in initialize (#{finding.detail[:site]}). " \
37
- "Assign them in initialize, or pass the data explicitly."
36
+ "#{finding.package} reads instance variables nothing in the class assigns (#{finding.detail[:site]}). " \
37
+ "Assign them where the object is built, or pass the data explicitly."
38
38
  end
39
39
 
40
40
  def on_manual_dispatch(finding)
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ class Hashira::Smells::Branches
6
+ def initialize(root)
7
+ @root = root
8
+ end
9
+
10
+ def together?(nodes) = nodes.combination(2).any? { |left, right| meet?(trail(left), trail(right)) }
11
+
12
+ private
13
+
14
+ def meet?(left, right) = shared(left, right).none? { parted?(*it) }
15
+
16
+ def shared(left, right) = left.zip(right).select(&:last)
17
+
18
+ def parted?(here, there) = here.first.equal?(there.first) && here.last != there.last
19
+
20
+ def trail(node) = trails.fetch(node)
21
+
22
+ def trails
23
+ return @_trails if @_trails
24
+ @_trails = {}.compare_by_identity
25
+ chart(@root, [])
26
+ @_trails
27
+ end
28
+
29
+ def chart(node, trail)
30
+ @_trails[node] = trail
31
+ node.compact_child_nodes.each { chart(it, trail + taken(node, it)) }
32
+ end
33
+
34
+ def taken(node, child)
35
+ return arm_for(node, child) if node.is_a?(Prism::BeginNode)
36
+ arm = arms(node).index { it.equal?(child) }
37
+ arm ? [[node, arm]] : []
38
+ end
39
+
40
+ def arm_for(node, child)
41
+ rescued = node.rescue_clause
42
+ return [] unless [node.statements, rescued, node.else_clause].any? { it.equal?(child) }
43
+ [[node, rescued.equal?(child) ? 1 : 0]]
44
+ end
45
+
46
+ def arms(node) = (clauses(node) || conditions(node)).compact
47
+
48
+ def clauses(node)
49
+ case node
50
+ when Prism::IfNode, Prism::RescueNode then [node.statements, node.subsequent]
51
+ when Prism::UnlessNode then [node.statements, node.else_clause]
52
+ when Prism::RescueModifierNode then [node.expression, node.rescue_expression]
53
+ end
54
+ end
55
+
56
+ def conditions(node)
57
+ case node
58
+ when Prism::CaseNode, Prism::CaseMatchNode then node.conditions + [node.else_clause]
59
+ else []
60
+ end
61
+ end
62
+ end