hashira 0.6.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -0
  3. data/README.md +148 -53
  4. data/exe/hashira +1 -1
  5. data/lib/hashira/analysis/finding.rb +5 -1
  6. data/lib/hashira/analysis/syntax.rb +6 -6
  7. data/lib/hashira/churn.rb +3 -4
  8. data/lib/hashira/ci/accepted.rb +9 -10
  9. data/lib/hashira/ci/baseline.rb +39 -12
  10. data/lib/hashira/ci/comparison.rb +24 -0
  11. data/lib/hashira/ci/diff.rb +4 -8
  12. data/lib/hashira/ci/finding_diff_report.rb +10 -0
  13. data/lib/hashira/ci/gate.rb +10 -2
  14. data/lib/hashira/ci/improvement.rb +1 -1
  15. data/lib/hashira/ci/ratchet.rb +41 -10
  16. data/lib/hashira/ci/ratchet_report.rb +8 -6
  17. data/lib/hashira/ci/scope.rb +10 -0
  18. data/lib/hashira/ci/slice.rb +17 -0
  19. data/lib/hashira/ci/status.rb +7 -0
  20. data/lib/hashira/ci/sweep.rb +13 -0
  21. data/lib/hashira/cli/arguments.rb +17 -8
  22. data/lib/hashira/cli/choice.rb +9 -0
  23. data/lib/hashira/cli/command_line.rb +8 -5
  24. data/lib/hashira/cli/fail_on.rb +17 -7
  25. data/lib/hashira/cli/flag.rb +3 -1
  26. data/lib/hashira/cli/flags.rb +23 -0
  27. data/lib/hashira/cli/format.rb +1 -1
  28. data/lib/hashira/cli/needs.rb +45 -0
  29. data/lib/hashira/cli/only.rb +16 -0
  30. data/lib/hashira/cli/options.rb +12 -3
  31. data/lib/hashira/cli/package_by.rb +1 -1
  32. data/lib/hashira/cli/run.rb +36 -8
  33. data/lib/hashira/cli/top.rb +13 -0
  34. data/lib/hashira/cli/usage.rb +5 -1
  35. data/lib/hashira/cli.rb +31 -11
  36. data/lib/hashira/complexity/cognitive_score.rb +44 -33
  37. data/lib/hashira/complexity/method_finding.rb +7 -5
  38. data/lib/hashira/complexity/method_score.rb +1 -1
  39. data/lib/hashira/complexity/scores.rb +11 -9
  40. data/lib/hashira/coupling/audiences.rb +3 -3
  41. data/lib/hashira/coupling/catalog.rb +8 -6
  42. data/lib/hashira/coupling/census.rb +42 -22
  43. data/lib/hashira/coupling/cycle_search.rb +11 -7
  44. data/lib/hashira/coupling/definitions.rb +1 -1
  45. data/lib/hashira/coupling/edge_map.rb +9 -8
  46. data/lib/hashira/coupling/folding.rb +5 -5
  47. data/lib/hashira/coupling/graph.rb +16 -9
  48. data/lib/hashira/coupling/metric.rb +9 -1
  49. data/lib/hashira/coupling/naming.rb +4 -4
  50. data/lib/hashira/coupling/references.rb +12 -12
  51. data/lib/hashira/coupling/report.rb +7 -9
  52. data/lib/hashira/coupling/roll_call.rb +1 -1
  53. data/lib/hashira/coupling/roll_call_findings.rb +1 -1
  54. data/lib/hashira/coupling/roster.rb +37 -12
  55. data/lib/hashira/coupling/rule.rb +1 -1
  56. data/lib/hashira/coupling/sdp_violation_findings.rb +3 -1
  57. data/lib/hashira/diagram/dot.rb +9 -6
  58. data/lib/hashira/diagram/mermaid.rb +11 -8
  59. data/lib/hashira/diagram/source.rb +5 -2
  60. data/lib/hashira/duplication/clones.rb +1 -1
  61. data/lib/hashira/duplication/clusters.rb +10 -7
  62. data/lib/hashira/duplication/delta.rb +1 -4
  63. data/lib/hashira/duplication/duplication_finding.rb +4 -2
  64. data/lib/hashira/duplication/fragment.rb +2 -2
  65. data/lib/hashira/duplication/harvest.rb +7 -5
  66. data/lib/hashira/duplication/index.rb +3 -2
  67. data/lib/hashira/duplication/similarity.rb +2 -2
  68. data/lib/hashira/duplication/variance.rb +1 -1
  69. data/lib/hashira/error.rb +0 -3
  70. data/lib/hashira/focus.rb +29 -0
  71. data/lib/hashira/git_log.rb +19 -0
  72. data/lib/hashira/hotspots/file_cost.rb +1 -1
  73. data/lib/hashira/hotspots/rollup.rb +2 -2
  74. data/lib/hashira/pipeline.rb +26 -24
  75. data/lib/hashira/project.rb +47 -32
  76. data/lib/hashira/report/columns.rb +54 -0
  77. data/lib/hashira/report/complexity_table.rb +12 -16
  78. data/lib/hashira/report/dependency_map.rb +1 -0
  79. data/lib/hashira/report/graph_payload.rb +1 -1
  80. data/lib/hashira/report/hotspot_table.rb +6 -14
  81. data/lib/hashira/report/json.rb +10 -2
  82. data/lib/hashira/report/metrics_table.rb +19 -20
  83. data/lib/hashira/report/notices.rb +37 -0
  84. data/lib/hashira/report/phrases.rb +25 -18
  85. data/lib/hashira/report/text.rb +24 -9
  86. data/lib/hashira/report/view.rb +4 -1
  87. data/lib/hashira/smells/census.rb +3 -4
  88. data/lib/hashira/smells/check.rb +1 -5
  89. data/lib/hashira/smells/contexts.rb +4 -4
  90. data/lib/hashira/smells/control_parameter.rb +1 -1
  91. data/lib/hashira/smells/data_clump.rb +2 -4
  92. data/lib/hashira/smells/duplicate_method_call.rb +3 -3
  93. data/lib/hashira/smells/feature_envy.rb +3 -3
  94. data/lib/hashira/smells/foreign.rb +14 -14
  95. data/lib/hashira/smells/instance_variable_assumption.rb +3 -3
  96. data/lib/hashira/smells/kind.rb +9 -0
  97. data/lib/hashira/smells/manual_dispatch.rb +1 -1
  98. data/lib/hashira/smells/module_initialize.rb +0 -2
  99. data/lib/hashira/smells/nil_check.rb +1 -1
  100. data/lib/hashira/smells/ownership.rb +19 -7
  101. data/lib/hashira/smells/param_check.rb +2 -2
  102. data/lib/hashira/smells/refs.rb +13 -7
  103. data/lib/hashira/smells/repeated_conditional.rb +1 -3
  104. data/lib/hashira/smells/report.rb +16 -7
  105. data/lib/hashira/smells/too_many_instance_variables.rb +3 -3
  106. data/lib/hashira/smells/visibility.rb +18 -11
  107. data/lib/hashira/trees.rb +26 -0
  108. data/lib/hashira/version.rb +1 -1
  109. data/lib/hashira.rb +14 -0
  110. metadata +16 -1
@@ -3,54 +3,69 @@
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
- raise(Hashira::Error, "no lib/ directory here — pass the source directory explicitly") if Dir["lib/*/"].empty?
12
- ["lib"]
10
+ def directories = @_directories ||= resolved
11
+
12
+ def rails? = directories.any? { config?(File.expand_path(it)) }
13
+
14
+ def files = directories.flat_map { Dir["#{it}/**/*.rb"] }.sort
15
+
16
+ def package(path)
17
+ first, rest = relative(path).delete_suffix(".rb").split("/", 2)
18
+ return ROOT_PACKAGE unless rest || folder?(path, first)
19
+ contested.include?(first) ? "#{parent(path)}/#{first}" : first
20
+ end
21
+
22
+ def relative(path) = path.delete_prefix("#{parent(path)}/")
23
+
24
+ def label = directories.join(", ")
25
+
26
+ def root = ROOT_PACKAGE
27
+
28
+ private
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
13
36
  end
14
- private_class_method :defaults
15
37
 
16
- def self.descend(directory)
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)
17
46
  child = sole(directory)
18
47
  return directory unless child && Dir["#{child}/*/"].any? && (Dir["#{directory}/*.rb"] - ["#{child}.rb"]).empty?
19
48
  descend(child)
20
49
  end
21
50
 
22
- def self.sole(directory)
51
+ def sole(directory)
23
52
  subdirectories = Dir["#{directory}/*/"]
24
53
  subdirectories.size == 1 ? subdirectories.first.delete_suffix("/") : nil
25
54
  end
26
- private_class_method :descend, :sole
27
55
 
28
- def initialize(directories)
56
+ def vet(directories)
57
+ file = directories.find { File.file?(it) }
58
+ raise(Hashira::Error, "#{file} is a file — hashira takes directories (try: hashira #{File.dirname(file)})") if file
29
59
  missing = directories.reject { Dir.exist?(it) }
30
60
  raise(Hashira::Error, "no such directory: #{missing.join(", ")}") unless missing.empty?
31
- @directories = directories.map { it.delete_suffix("/") }
32
- @rails = @directories.any? { config?(File.expand_path(it)) }
33
61
  end
34
62
 
35
- attr_reader :directories
36
-
37
- def rails? = @rails
38
-
39
- def files = @directories.flat_map { Dir["#{it}/**/*.rb"] }.sort
40
-
41
- def package(path)
42
- first, rest = relative(path).delete_suffix(".rb").split("/", 2)
43
- return ROOT_PACKAGE unless rest || folder?(path, first)
44
- contested.include?(first) ? "#{parent(path)}/#{first}" : first
63
+ def distinct(directories)
64
+ pairs = directories.map { [it, File.realpath(it)] }.uniq(&:last)
65
+ pairs.filter_map { |dir, path| dir unless nested?(path, pairs) }
45
66
  end
46
67
 
47
- def relative(path) = path.delete_prefix("#{parent(path)}/")
48
-
49
- def label = @directories.join(", ")
50
-
51
- def root = ROOT_PACKAGE
52
-
53
- private
68
+ def nested?(path, pairs) = pairs.any? { path.start_with?("#{it.last}/") }
54
69
 
55
70
  def config?(directory)
56
71
  ["config/application.rb", "../config/application.rb"].any? { File.exist?(File.expand_path(it, directory)) }
@@ -59,13 +74,13 @@ class Hashira::Project
59
74
  def folder?(path, name) = Dir.exist?("#{parent(path)}/#{name}")
60
75
 
61
76
  def contested
62
- @contested ||=
63
- @directories.flat_map { |directory| Dir["#{directory}/*/"].map { File.basename(it) } }
77
+ @_contested ||=
78
+ directories.flat_map { |directory| Dir["#{directory}/*/"].map { File.basename(it) } }
64
79
  .tally.filter_map { |name, count| name if count > 1 }
65
80
  end
66
81
 
67
82
  def parent(path)
68
- @directories.find { path.start_with?("#{it}/") } ||
83
+ directories.find { path.start_with?("#{it}/") } ||
69
84
  raise(Hashira::Error, "#{path} is outside the analyzed directories")
70
85
  end
71
86
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Report::Columns
4
+ CAP = 48
5
+ HEAD = 23
6
+ GAP = " "
7
+ NUMBER = /\A-?\d+(?:\.\d+)?\z/
8
+
9
+ def initialize(headers, rows, io: $stdout)
10
+ @raw = [headers, *rows]
11
+ @io = io
12
+ end
13
+
14
+ def print
15
+ @io.puts(header)
16
+ @io.puts(rule)
17
+ body
18
+ end
19
+
20
+ def body = rows.each { @io.puts(line(it)) }
21
+
22
+ def header = line(headers)
23
+
24
+ def rule = "-" * [header, *rows.map { line(it) }].map(&:length).max
25
+
26
+ private
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
+
34
+ def line(cells) = cells.map.with_index { |cell, column| pad(cell, column) }.join(GAP).rstrip
35
+
36
+ def pad(cell, column)
37
+ width = widths[column]
38
+ numeric[column] ? cell.rjust(width) : cell.ljust(width)
39
+ end
40
+
41
+ def widths = @_widths ||= headers.each_index.map { |column| down(column).map(&:length).max }
42
+
43
+ def numeric = @_numeric ||= headers.each_index.map { |column| rows.any? && counted?(column) }
44
+
45
+ def counted?(column) = rows.all? { NUMBER.match?(it[column]) }
46
+
47
+ def down(column) = [headers[column], *rows.map { it[column] }]
48
+
49
+ def clip(cell)
50
+ text = cell.to_s
51
+ return text if text.length <= CAP
52
+ "#{text[0, HEAD]}…#{text[(HEAD + 1 - CAP)..]}"
53
+ end
54
+ end
@@ -2,35 +2,31 @@
2
2
 
3
3
  class Hashira::Report::ComplexityTable
4
4
  TOP = 10
5
- METHOD_ROW = "%-44s %4s %6s %s"
6
- CLASS_ROW = "%-32s %5s %8s %6s"
5
+ METHOD_HEADERS = %w[method Cog Calls Loc].freeze
6
+ CLASS_HEADERS = %w[class Cog Methods Peak].freeze
7
7
  METHOD_TITLE = "Cognitive complexity — worst methods (Cog = how hard to read, Calls = message sends)"
8
8
  CLASS_TITLE = "Per-class rollup (Cog total survives extract-method; Peak is the worst method it hides)"
9
9
 
10
- def initialize(complexity, io: $stdout)
10
+ def initialize(complexity, top: TOP, io: $stdout)
11
11
  @complexity = complexity
12
+ @top = top
12
13
  @io = io
13
14
  end
14
15
 
15
16
  def print
16
- section(@complexity.ranked, METHOD_ROW, %w[method Cog Calls Loc], METHOD_TITLE)
17
- section(@complexity.classes, CLASS_ROW, %w[class Cog Methods Peak], CLASS_TITLE)
17
+ section(@complexity.ranked, METHOD_HEADERS, METHOD_TITLE)
18
+ section(@complexity.classes, CLASS_HEADERS, CLASS_TITLE)
18
19
  end
19
20
 
20
21
  private
21
22
 
22
- def section(scores, row_format, columns, title)
23
- heading(row_format, columns, title)
24
- ranked(scores).each { @io.puts(format(row_format, *it.cells)) }
25
- @io.puts
26
- end
27
-
28
- def heading(row_format, columns, title)
29
- header = format(row_format, *columns)
23
+ def section(scores, headers, title)
24
+ rows = ranked(scores)
25
+ return if rows.empty?
30
26
  @io.puts("#{title}:\n\n")
31
- @io.puts(header)
32
- @io.puts("-" * header.length)
27
+ Hashira::Report::Columns.new(headers, rows.map(&:cells), io: @io).print
28
+ @io.puts
33
29
  end
34
30
 
35
- def ranked(scores) = scores.select { it.cognitive.positive? }.first(TOP)
31
+ def ranked(scores) = scores.select { it.cognitive.positive? }.first(@top)
36
32
  end
@@ -9,6 +9,7 @@ class Hashira::Report::DependencyMap
9
9
  def print
10
10
  @io.puts("Dependencies (DependsUpon(refs) -> | <- UsedBy):")
11
11
  @graph.packages.sort.each { @io.puts(row(it)) }
12
+ @io.puts
12
13
  end
13
14
 
14
15
  private
@@ -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
@@ -2,32 +2,24 @@
2
2
 
3
3
  class Hashira::Report::HotspotTable
4
4
  TOP = 10
5
- ROW = "%-46s %5s %5s %6s %7s"
5
+ HEADERS = %w[file Cog Dup Churn Rank].freeze
6
6
 
7
- def initialize(hotspots, io: $stdout)
7
+ def initialize(hotspots, top: TOP, io: $stdout)
8
8
  @hotspots = hotspots
9
+ @top = top
9
10
  @io = io
10
11
  end
11
12
 
12
13
  def print
13
14
  return if ranked.empty?
14
- heading
15
- rows
15
+ @io.puts("Hotspots — cost × churn (where refactoring pays the most):\n\n")
16
+ Hashira::Report::Columns.new(HEADERS, ranked.map(&:cells), io: @io).print
16
17
  legend
17
18
  end
18
19
 
19
20
  private
20
21
 
21
- def ranked = @ranked ||= @hotspots.files.first(TOP)
22
-
23
- def rows = ranked.each { @io.puts(format(ROW, *it.cells)) }
24
-
25
- def heading
26
- @io.puts("Hotspots — cost × churn (where refactoring pays the most):\n\n")
27
- header = format(ROW, *%w[file Cog Dup Churn Rank])
28
- @io.puts(header)
29
- @io.puts("-" * header.length)
30
- end
22
+ def ranked = @_ranked ||= @hotspots.files.first(@top)
31
23
 
32
24
  def legend
33
25
  @io.puts("\nLegend: Cog cognitive complexity, Dup mass of the clones the file carries,")
@@ -8,14 +8,22 @@ class Hashira::Report::Json
8
8
  @io = io
9
9
  end
10
10
 
11
+ SCHEMA = 1
12
+
11
13
  def print
12
- @io.puts(JSON.pretty_generate(payload))
14
+ @io.puts(@view.compact ? JSON.generate(payload) : JSON.pretty_generate(payload))
13
15
  0
14
16
  end
15
17
 
16
18
  private
17
19
 
18
- def payload = base.merge(coupling).merge(sections.compact)
20
+ def payload = about.merge(base).merge(coupling).merge(sections.compact)
21
+
22
+ def about
23
+ project = @view.project
24
+ { version: SCHEMA, packaging: @view.graph&.packaging, targets: project.directories }
25
+ .merge(files: project.files.size)
26
+ end
19
27
 
20
28
  def base = { findings: @view.findings.all.map { rendered(it) }, accepted: accepted }
21
29
 
@@ -1,47 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::Report::MetricsTable
4
- def initialize(graph, io: $stdout)
4
+ TOP = 25
5
+
6
+ def initialize(graph, top: TOP, io: $stdout)
5
7
  @graph = graph
8
+ @top = top
6
9
  @io = io
7
10
  end
8
11
 
9
- LIMIT = 25
12
+ HEADERS = %w[package TC Ca Ce I Cyc].freeze
10
13
 
11
14
  def print
12
- heading
13
- kept.each { |package, metric| @io.puts(row(package, metric)) }
15
+ Hashira::Report::Columns.new(HEADERS, kept.map { |package, metric| row(package, metric) }, io: @io).print
14
16
  note unless spared.empty?
17
+ more unless over.zero?
15
18
  legend
16
19
  end
17
20
 
18
21
  private
19
22
 
20
- def ranked = @graph.metrics.sort_by { |_package, metric| metric.instability }
23
+ def ranked = @graph.metrics.sort_by { |_package, metric| metric.order }
21
24
 
22
- def split = @split ||= (ranked.size > LIMIT ? ranked.partition { |_package, metric| !leaf?(metric) } : [ranked, []])
25
+ def split = @_split ||= (ranked.size > @top ? ranked.partition { |_package, metric| !leaf?(metric) } : [ranked, []])
23
26
 
24
- def kept = split.first
27
+ def kept = split.first.first(@top)
25
28
 
26
29
  def spared = split.last
27
30
 
31
+ def over = split.first.size - kept.size
32
+
28
33
  def leaf?(metric) = metric.types <= 1 && metric.efferent.zero? && metric.afferent <= 1
29
34
 
30
- def note
31
- @io.puts(" + #{spared.size} single-type leaf packages (TC ≤ 1, Ca ≤ 1, Ce = 0) — hidden for brevity")
32
- end
35
+ def note = @io.puts(" + #{leaves} (TC ≤ 1, Ca ≤ 1, Ce = 0) — hidden for brevity")
33
36
 
34
- def heading
35
- @io.puts(format("%-12s %3s %3s %3s %5s %-3s", *%w[package TC Ca Ce I Cyc]))
36
- @io.puts("-" * 40)
37
- end
37
+ def leaves = Hashira::Report::Phrases.count(spared.size, "single-type leaf package")
38
38
 
39
- def row(package, metric)
40
- format(
41
- "%-12s %3d %3d %3d %5.2f %-3s", package, *metric.to_h.values,
42
- (@graph.cycles.through?(package) ? "YES" : "-")
43
- )
44
- end
39
+ def more = @io.puts(" … and #{over} more — raise the cap with --top, or read them all with --json")
40
+
41
+ def row(package, metric) = [package, *metric.cells, cycle(package)]
42
+
43
+ def cycle(package) = @graph.cycles.through?(package) ? "YES" : "-"
45
44
 
46
45
  def legend
47
46
  @io.puts("\nLegend: TC total types, Ca afferent (incoming), Ce efferent (outgoing),")
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Report::Notices
4
+ ISSUES = "https://github.com/giacope/hashira/issues"
5
+
6
+ def initialize(io: $stderr)
7
+ @io = io
8
+ end
9
+
10
+ def scanning(files) = interactive { @io.puts("hashira: reading #{files} files…") }
11
+
12
+ def finished(files, seconds) = interactive { @io.puts("hashira: #{files} files in #{seconds}s") }
13
+
14
+ def rails
15
+ @io.puts("hashira: this looks like a Rails root — `hashira app` reads the application, not just lib/")
16
+ end
17
+
18
+ def churn(label)
19
+ @io.puts("hashira: no git history for #{label} — hotspots are ranked by cost alone")
20
+ end
21
+
22
+ def crashed(error)
23
+ @io.puts("hashira: internal error — #{error.class}: #{error.message}")
24
+ @io.puts(" at #{error.backtrace.first}")
25
+ @io.puts(" this is a bug in hashira — please report it at #{ISSUES}")
26
+ end
27
+
28
+ def unparsed(count, sample)
29
+ @io.puts("hashira: #{count} of the files did not parse — #{sample}")
30
+ end
31
+
32
+ private
33
+
34
+ def interactive
35
+ yield if @io.tty?
36
+ end
37
+ end
@@ -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,18 +74,20 @@ 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)
83
88
 
89
+ def count(number, noun) = "#{number} #{number == 1 ? noun : "#{noun}s"}"
90
+
84
91
  def clause(part) = "#{part[:users].join(", ")} #{verb(part)} #{part[:constants].join(", ")}"
85
92
 
86
93
  def verb(part)
@@ -93,6 +100,6 @@ module Hashira::Report::Phrases
93
100
  end
94
101
 
95
102
  def footnote(detail)
96
- detail[:hot] ? " Both sites change often — fix one, miss the other." : ""
103
+ detail.hot ? " Both sites change often — fix one, miss the other." : ""
97
104
  end
98
105
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::Report::Text
4
+ FINDINGS = 25
5
+
4
6
  def initialize(view, io: $stdout)
5
7
  @view = view
6
8
  @io = io
@@ -19,31 +21,37 @@ class Hashira::Report::Text
19
21
  def coupling
20
22
  graph = @view.graph
21
23
  header(graph)
22
- Hashira::Report::MetricsTable.new(graph, io: @io).print
24
+ table(Hashira::Report::MetricsTable, graph)
23
25
  Hashira::Report::DependencyMap.new(graph, io: @io).print
24
26
  folded(graph.folds)
25
27
  end
26
28
 
27
29
  def folded(folds)
28
30
  return if folds.empty?
29
- @io.puts("\nFolded (single-type classes joined to their base or domain):")
31
+ @io.puts("Folded (single-type classes joined to their base or domain):")
30
32
  folds.each { @io.puts(" #{it[:from]} -> #{it[:to]} (#{it[:via]})") }
33
+ @io.puts
31
34
  end
32
35
 
33
- def complexity = Hashira::Report::ComplexityTable.new(@view.complexity, io: @io).print
36
+ def table(kind, subject) = kind.new(subject, top: @view.top || kind::TOP, io: @io).print
37
+
38
+ def complexity = table(Hashira::Report::ComplexityTable, @view.complexity)
34
39
 
35
- def hotspots = Hashira::Report::HotspotTable.new(@view.hotspots, io: @io).print
40
+ def hotspots = table(Hashira::Report::HotspotTable, @view.hotspots)
36
41
 
37
42
  def header(graph)
38
43
  packages = graph.packages.size
39
- @io.puts(banner(packages))
44
+ @io.puts(banner(graph.packaging, packages))
40
45
  caveat if packages == 1
41
46
  end
42
47
 
43
- def banner(packages)
44
- "Package (layer) metrics for #{@view.project.label} (#{packages} packages, #{total} files)\n\n"
48
+ def banner(grouping, packages)
49
+ "Package (#{grouping}) metrics for #{@view.project.label} " \
50
+ "(#{count(packages, "package")}, #{count(total, "file")})\n\n"
45
51
  end
46
52
 
53
+ def count(number, noun) = Hashira::Report::Phrases.count(number, noun)
54
+
47
55
  def total = @view.project.files.size
48
56
 
49
57
  def caveat
@@ -55,7 +63,7 @@ class Hashira::Report::Text
55
63
 
56
64
  def findings
57
65
  all = @view.findings.all
58
- @io.puts("\nFindings (#{all.size}):")
66
+ @io.puts("Findings (#{all.size}):")
59
67
  list(all)
60
68
  accepted
61
69
  @io.puts("\n Full evidence + machine format: hashira --json") unless all.empty?
@@ -63,7 +71,14 @@ class Hashira::Report::Text
63
71
 
64
72
  def list(all)
65
73
  return @io.puts(" none ✓ — structure is healthy") if all.empty?
66
- all.each { Hashira::Report::FindingLines.new(it, indent: " ", io: @io).emit }
74
+ shown = all.first(@view.top || FINDINGS)
75
+ shown.each { Hashira::Report::FindingLines.new(it, indent: " ", io: @io).emit }
76
+ elided(all.size - shown.size)
77
+ end
78
+
79
+ def elided(rest)
80
+ return if rest.zero?
81
+ @io.puts(" … and #{rest} more — raise the cap with --top, or read them all with --json")
67
82
  end
68
83
 
69
84
  def accepted
@@ -2,6 +2,9 @@
2
2
 
3
3
  module Hashira
4
4
  module Report
5
- View = Data.define(:project, :graph, :complexity, :duplication, :hotspots, :findings)
5
+ View =
6
+ Data.define(:project, :graph, :complexity, :duplication, :hotspots, :findings, :top, :compact) do
7
+ def initialize(top: nil, compact: nil, **) = super
8
+ end
6
9
  end
7
10
  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