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
@@ -8,6 +8,7 @@ class Hashira::CI::FindingDiffReport
8
8
 
9
9
  def print(diff)
10
10
  introduced(diff.added).each { emit(it) }
11
+ aggravated(diff.worsened).each { |finding, before, after| regressed(finding, before, after) }
11
12
  Hashira::CI::Improvement.new("Findings resolved", io: @io).print(diff.removed)
12
13
  end
13
14
 
@@ -15,6 +16,15 @@ class Hashira::CI::FindingDiffReport
15
16
 
16
17
  def introduced(added) = added.filter_map { |signature| @findings.find { it.signature == signature } }
17
18
 
19
+ def aggravated(worsened)
20
+ worsened.map { |signature, before, after| [@findings.find { it.signature == signature }, before, after] }
21
+ end
22
+
23
+ def regressed(finding, before, after)
24
+ @io.puts("WORSE FINDING (was #{before}, now #{after}):")
25
+ Hashira::Report::FindingLines.new(finding, indent: " ", io: @io).emit
26
+ end
27
+
18
28
  def emit(finding)
19
29
  @io.puts("NEW FINDING:")
20
30
  Hashira::Report::FindingLines.new(finding, indent: " ", io: @io).emit
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::CI::Gate
4
- def initialize(findings, kinds, io: $stdout)
4
+ def initialize(findings, kinds, io: $stdout, error: $stderr)
5
5
  @findings = findings
6
6
  @kinds = kinds
7
7
  @io = io
8
+ @error = error
8
9
  end
9
10
 
10
11
  def check
@@ -16,10 +17,17 @@ class Hashira::CI::Gate
16
17
 
17
18
  def failure(offending)
18
19
  offending.each { Hashira::Report::FindingLines.new(it, io: @io).print }
19
- @io.puts("\nGate FAILED: #{offending.size} finding(s) of kind #{@kinds.join(", ")}.")
20
+ verdict = "Gate FAILED: #{offending.size} finding(s) #{tally(offending)}."
21
+ @io.puts("\n#{verdict}")
22
+ @error.puts(verdict)
20
23
  1
21
24
  end
22
25
 
26
+ def tally(offending)
27
+ offending.group_by(&:kind).map { |kind, list| [kind, list.size] }
28
+ .sort_by { |kind, size| [-size, kind] }.map { |kind, size| "#{kind} #{size}" }.join(", ")
29
+ end
30
+
23
31
  def clean
24
32
  @io.puts("Gate OK: no findings of kind #{@kinds.join(", ")}.")
25
33
  0
@@ -9,6 +9,6 @@ class Hashira::CI::Improvement
9
9
  def print(removed)
10
10
  return if removed.empty?
11
11
  @io.puts("#{@label} (improvement!): #{removed.join(", ")}")
12
- @io.puts("Lock it in: hashira --update-baseline")
12
+ @io.puts("Lock it in: re-run this command with --update-baseline")
13
13
  end
14
14
  end
@@ -1,30 +1,57 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::CI::Ratchet
4
- def initialize(graph, findings, baseline_path, io: $stdout)
4
+ def initialize(graph, findings, baseline, io: $stdout)
5
5
  @graph = graph
6
6
  @findings = findings
7
- @baseline = Hashira::CI::Baseline.load(baseline_path)
7
+ @baseline = baseline
8
8
  @io = io
9
9
  end
10
10
 
11
11
  def update
12
- @baseline.write(edges, digests, packaging:)
13
- @io.puts("Baseline updated: #{edges.size} edges, #{digests.size} findings.")
14
- 0
12
+ @baseline.write(edges, scored, packaging:)
13
+ @io.puts("Baseline updated: #{edges.size} edges, #{recorded}.")
14
+ Hashira::CI::Status::CLEAN
15
15
  end
16
16
 
17
- def check = Hashira::CI::RatchetReport.new(@graph, @findings, io: @io).print(drift, delta)
17
+ def check(focus)
18
+ compared = focus.narrowing? ? Hashira::CI::Slice.new(@baseline) : Hashira::CI::Sweep.new(@baseline)
19
+ Hashira::CI::RatchetReport.new(@graph, @findings, io: @io)
20
+ .print(compared.edges(drift), delta(compared), tally(compared))
21
+ end
18
22
 
19
23
  def blocker
20
24
  return "no baseline at #{@baseline.path} — run --update-baseline first" unless @baseline.exist?
21
- mismatch unless @baseline.packaging == packaging
25
+ drifted.first
22
26
  end
23
27
 
24
28
  private
25
29
 
30
+ def drifted = [(mismatch unless @baseline.packaging == packaging), *narrowed(@baseline.wanted)].compact
31
+
32
+ def narrowed(wanted)
33
+ [
34
+ widened("the analyzers", @baseline.analyzers, wanted[:analyzers]),
35
+ widened("the directories", @baseline.targets, wanted[:targets])
36
+ ]
37
+ end
38
+
39
+ def widened(flag, was, now)
40
+ return if was == now
41
+ phrase(flag, was, now)
42
+ end
43
+
44
+ def phrase(flag, was, now)
45
+ "baseline #{@baseline.path} was recorded with #{flag} #{was.join(", ")}, but this run " \
46
+ "uses #{now.join(", ")} — rerun the recorded way, or refresh it with --update-baseline"
47
+ end
48
+
26
49
  def packaging = @graph.packaging.to_s
27
50
 
51
+ def recorded = collapsed(@findings.size, scored.size)
52
+
53
+ def collapsed(total, keys) = keys == total ? "#{total} findings" : "#{total} findings (#{keys} distinct)"
54
+
28
55
  def mismatch
29
56
  recorded = @baseline.packaging
30
57
  "baseline #{@baseline.path} was recorded with --package-by #{recorded}, but this run " \
@@ -33,13 +60,17 @@ class Hashira::CI::Ratchet
33
60
 
34
61
  def edges = @graph.edges.map(&:to_s)
35
62
 
36
- def digests = @findings.map(&:signature).uniq.sort
63
+ def tally(compared) = compared.counts("#{@graph.edges.size} edges", "#{@findings.size} findings").join(", ")
64
+
65
+ def scored
66
+ @_scored ||= @findings.group_by(&:signature).sort.to_h { |key, group| [key, group.filter_map(&:magnitude).max] }
67
+ end
37
68
 
38
69
  def drift = Hashira::CI::Diff.new(added: fresh, removed: @baseline.edges - edges)
39
70
 
40
71
  def fresh = @graph.edges.reject { @baseline.edges.include?(it.to_s) }
41
72
 
42
- def delta
43
- Hashira::CI::Diff.between(digests, @baseline.findings) if @baseline.findings?
73
+ def delta(compared)
74
+ compared.findings(scored) if @baseline.findings?
44
75
  end
45
76
  end
@@ -7,22 +7,24 @@ class Hashira::CI::RatchetReport
7
7
  @io = io
8
8
  end
9
9
 
10
- def print(edges, findings)
11
- return unchanged if quiet?(edges, findings)
10
+ def print(edges, findings, tally)
11
+ return unchanged(tally) if quiet?(edges, findings)
12
12
  details(edges, findings)
13
13
  advice(edges, findings)
14
- 1
14
+ verdict(edges, findings)
15
15
  end
16
16
 
17
17
  private
18
18
 
19
19
  def quiet?(*diffs) = diffs.compact.all?(&:empty?)
20
20
 
21
- def unchanged
22
- @io.puts("Ratchet OK: #{@graph.edges.size} edges, #{@findings.size} findings, unchanged.")
23
- 0
21
+ def unchanged(tally)
22
+ @io.puts("Ratchet OK: #{tally}, unchanged.")
23
+ Hashira::CI::Status::CLEAN
24
24
  end
25
25
 
26
+ def verdict(*diffs) = diffs.compact.any?(&:worse?) ? Hashira::CI::Status::WORSE : Hashira::CI::Status::BETTER
27
+
26
28
  def details(edges, findings)
27
29
  Hashira::CI::EdgeDiffReport.new(@graph, io: @io).print(edges)
28
30
  Hashira::CI::FindingDiffReport.new(@findings, io: @io).print(findings) if findings
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module CI
5
+ Scope =
6
+ Data.define(:analyzers, :targets) do
7
+ def to_h = { analyzers: analyzers.map(&:to_s).sort, targets: targets.sort }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::CI::Slice
4
+ SETTLED = Hashira::CI::Diff.new(added: [], removed: [])
5
+
6
+ def initialize(baseline)
7
+ @baseline = baseline
8
+ end
9
+
10
+ def edges(_diff) = SETTLED
11
+
12
+ def findings(scored) = Hashira::CI::Comparison.new(scored, seen(scored)).diff
13
+
14
+ def seen(scored) = @baseline.findings.slice(*scored.keys)
15
+
16
+ def counts(_edges, findings) = [findings]
17
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira::CI::Status
4
+ CLEAN = 0
5
+ WORSE = 1
6
+ BETTER = 3
7
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::CI::Sweep
4
+ def initialize(baseline)
5
+ @baseline = baseline
6
+ end
7
+
8
+ def edges(diff) = diff
9
+
10
+ def findings(scored) = Hashira::CI::Comparison.new(scored, @baseline.findings).diff
11
+
12
+ def counts(edges, findings) = [edges, findings]
13
+ end
@@ -1,23 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::CLI::Arguments
4
- def initialize(argv)
5
- @argv = argv.dup
4
+ def initialize(given)
5
+ @given = given
6
6
  end
7
7
 
8
8
  def take(flag, default)
9
- position = @argv.index(flag)
9
+ position = argv.index(flag)
10
10
  return default unless position
11
- _flag, value = @argv.slice!(position, 2)
12
- raise(Hashira::Error, "#{flag} needs a value") unless value
11
+ _flag, value = argv.slice!(position, 2)
12
+ vet(flag, value)
13
+ end
14
+
15
+ def vet(flag, value)
16
+ raise(Hashira::Error, "#{flag} needs a value") if value.to_s.strip.empty?
17
+ raise(Hashira::Error, "#{flag} given more than once") if argv.include?(flag)
13
18
  value
14
19
  end
15
20
 
16
- def delete(flag) = @argv.delete(flag)
21
+ def delete(flag) = argv.delete(flag)
17
22
 
18
23
  def rest
19
- stray = @argv.find { it.start_with?("-") }
24
+ stray = argv.find { it.start_with?("-") }
20
25
  raise(Hashira::Error, "unknown option #{stray}") if stray
21
- @argv
26
+ argv
22
27
  end
28
+
29
+ private
30
+
31
+ def argv = @_argv ||= @given.dup
23
32
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira::CLI::Choice
4
+ module_function
5
+
6
+ def unknown(flag, value, choices)
7
+ Hashira::Error.new("unknown #{flag} #{value.inspect} (use: #{choices.join(", ")})")
8
+ end
9
+ end
@@ -2,23 +2,26 @@
2
2
 
3
3
  class Hashira::CLI::CommandLine
4
4
  def initialize(argv)
5
- @arguments = Hashira::CLI::Arguments.new(argv)
5
+ @argv = argv
6
6
  end
7
7
 
8
8
  def options = page || parsed
9
9
 
10
10
  private
11
11
 
12
+ def arguments = @_arguments ||= Hashira::CLI::Arguments.new(@argv)
13
+
12
14
  def page
13
15
  flag = Hashira::CLI::FLAGS.select(&:page?).find { seen?(it) }
14
- Hashira::CLI::Options.page(flag.mode) if flag
16
+ Hashira::CLI::Options.build(flag.mode) if flag
15
17
  end
16
18
 
17
- def seen?(flag) = flag.names.any? { @arguments.delete(it) }
19
+ def seen?(flag) = flag.names.any? { arguments.delete(it) }
18
20
 
19
21
  def parsed
20
- parses = Hashira::CLI::FLAGS.reject(&:page?).to_h { [it, it.read(@arguments)] }
21
- Hashira::CLI::Options.new(directories: @arguments.rest, mode: mode(parses), **fields(parses))
22
+ parses = Hashira::CLI::FLAGS.reject(&:page?).to_h { [it, it.read(arguments)] }
23
+ Hashira::CLI::Options.new(directories: arguments.rest, mode: mode(parses), **fields(parses))
24
+ .tap { Hashira::CLI::Needs.check(it) }
22
25
  end
23
26
 
24
27
  def fields(parses) = parses.to_h { |flag, value| [flag.field, value] }.except(nil)
@@ -5,20 +5,30 @@ require_relative "../pipeline"
5
5
  module Hashira::CLI::FailOn
6
6
  MEASURES = (Hashira::Pipeline::ANALYZERS - %i[coupling smells]).map(&:to_s).freeze
7
7
 
8
- KINDS = {
9
- "cycles" => "cycle", "sdp" => "sdp_violation", "dupe" => "duplication",
10
- **Hashira::Pipeline::STRUCTURAL.to_h { [it, it] },
11
- **MEASURES.to_h { [it, it] },
12
- "smells" => Hashira::Pipeline::SMELLS, **Hashira::Pipeline::SMELLS.to_h { [it, it] }
8
+ KINDS = { "cycles" => "cycle", "sdp" => "sdp_violation", "dupe" => "duplication" }
9
+ .merge(Hashira::Pipeline::STRUCTURAL.to_h { [it, it] })
10
+ .merge(MEASURES.to_h { [it, it] })
11
+ .merge("smells" => Hashira::Pipeline::SMELLS)
12
+ .merge(Hashira::Pipeline::SMELLS.to_h { [it, it] })
13
+ .freeze
14
+
15
+ OWNERS = {
16
+ **Hashira::Pipeline::STRUCTURAL.to_h { [it, :coupling] },
17
+ **MEASURES.to_h { [it, it.to_sym] },
18
+ **Hashira::Pipeline::SMELLS.to_h { [it, :smells] }
13
19
  }.freeze
14
20
 
15
21
  module_function
16
22
 
17
23
  def parse(list)
18
- return [] unless list
19
- list.split(",").flat_map { Array(kind(it.strip)) }.uniq
24
+ return [] if list.to_s.empty?
25
+ kinds = list.split(",").flat_map { Array(kind(it.strip)) }.uniq
26
+ raise(Hashira::Error, "--fail-on needs at least one kind") if kinds.empty?
27
+ kinds
20
28
  end
21
29
 
30
+ def owner(kind) = OWNERS.fetch(kind)
31
+
22
32
  def kind(name)
23
33
  KINDS.fetch(name) do
24
34
  raise(Hashira::Error, "unknown --fail-on kind #{name.inspect} (use: #{KINDS.keys.join(", ")})")
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::CLI
4
+ BLANK = %i[arg field parse mode].to_h { [it, nil] }.freeze
5
+
4
6
  Flag =
5
7
  Data.define(:name, :arg, :default, :field, :parse, :mode, :text) do
6
- def initialize(**attributes) = super(arg: nil, default: "", field: nil, parse: nil, mode: nil, **attributes)
8
+ def initialize(**attributes) = super(**BLANK, default: "", **attributes)
7
9
 
8
10
  def read(arguments) = arg ? take(arguments) : arguments.delete(name)
9
11
 
@@ -3,8 +3,10 @@
3
3
  require_relative "fail_on"
4
4
  require_relative "flag"
5
5
  require_relative "format"
6
+ require_relative "only"
6
7
  require_relative "package_by"
7
8
  require_relative "skip"
9
+ require_relative "top"
8
10
 
9
11
  class Hashira::CLI
10
12
  FLAGS = [
@@ -13,6 +15,10 @@ class Hashira::CLI
13
15
  text: ["text (default), json, dot, or mermaid"]
14
16
  ),
15
17
  Flag.new(name: "--json", mode: :json, text: ["shorthand for --format json"]),
18
+ Flag.new(
19
+ name: "--compact", field: :compact,
20
+ text: ["emit JSON on one line, without the indentation", "meant for reading"]
21
+ ),
16
22
  Flag.new(
17
23
  name: "--fail-on", arg: "KINDS", field: :fail_on, parse: FailOn, mode: :fail_on,
18
24
  text: [
@@ -26,6 +32,23 @@ class Hashira::CLI
26
32
  name: "--skip", arg: "ANALYZERS", field: :skip, parse: Skip,
27
33
  text: ["drop an analyzer; comma-separated: coupling,", "complexity, duplication, smells"]
28
34
  ),
35
+ Flag.new(
36
+ name: "--only", arg: "PATHS", field: :only, parse: Only,
37
+ text: [
38
+ "keep only the findings that name these files;",
39
+ "comma-separated. The whole project is still read,",
40
+ "so cross-file signals stay right. Meant for hooks:",
41
+ "hashira --only $CHANGED --ratchet"
42
+ ]
43
+ ),
44
+ Flag.new(
45
+ name: "--top", arg: "N", field: :top, parse: Top,
46
+ text: [
47
+ "show at most N rows in each table and N",
48
+ "findings (default: 25 packages and findings,",
49
+ "10 methods and files)"
50
+ ]
51
+ ),
29
52
  Flag.new(
30
53
  name: "--package-by", arg: "WHAT", field: :packaging, parse: PackageBy,
31
54
  text: [
@@ -8,6 +8,6 @@ module Hashira::CLI::Format
8
8
  def parse(value)
9
9
  return if value.empty?
10
10
  return value.to_sym if CHOICES.include?(value)
11
- raise(Hashira::Error.unknown("--format", value, CHOICES))
11
+ raise(Hashira::CLI::Choice.unknown("--format", value, CHOICES))
12
12
  end
13
13
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "fail_on"
4
+
5
+ module Hashira::CLI::Needs
6
+ DIAGRAMS = %i[dot mermaid].freeze
7
+
8
+ module_function
9
+
10
+ def check(options)
11
+ mode = options.mode
12
+ skip = options.skip
13
+ drawing(mode, skip)
14
+ gate(options.fail_on, skip)
15
+ shaping(options, mode)
16
+ end
17
+
18
+ def shaping(options, mode)
19
+ compacting(mode) if options.compact
20
+ focusing(mode) unless options.only.empty?
21
+ end
22
+
23
+ def focusing(mode)
24
+ raise(Hashira::Error, "--only narrows the findings, but --update-baseline records them all") if mode == :update
25
+ return unless DIAGRAMS.include?(mode)
26
+ raise(Hashira::Error, "--format #{mode} draws the coupling graph, which --only cannot narrow")
27
+ end
28
+
29
+ def compacting(mode)
30
+ raise(Hashira::Error, "--compact shapes JSON, but this run emits #{mode}") unless mode == :json
31
+ end
32
+
33
+ def drawing(mode, skip)
34
+ return unless DIAGRAMS.include?(mode) && skip.include?(:coupling)
35
+ raise(Hashira::Error, "--format #{mode} draws the coupling graph, but --skip coupling drops it")
36
+ end
37
+
38
+ def gate(fail_on, skip)
39
+ blind = fail_on.find { skip.include?(owner(it)) }
40
+ return unless blind
41
+ raise(Hashira::Error, "--fail-on #{blind} needs the #{owner(blind)} analyzer, but --skip drops it")
42
+ end
43
+
44
+ def owner(kind) = Hashira::CLI::FailOn.owner(kind)
45
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira::CLI::Only
4
+ module_function
5
+
6
+ def parse(value)
7
+ return [] if value.empty?
8
+ value.split(",").map { vet(it.strip) }
9
+ end
10
+
11
+ def vet(path)
12
+ here = path.delete_prefix("#{Dir.pwd}/").delete_prefix("./")
13
+ raise(Hashira::Error, "--only #{path.inspect} is not a file here") unless File.file?(here)
14
+ here
15
+ end
16
+ end
@@ -2,13 +2,22 @@
2
2
 
3
3
  class Hashira::CLI
4
4
  Options =
5
- Data.define(:directories, :mode, :baseline, :fail_on, :skip, :packaging) do
5
+ Data.define(:directories, :mode, :baseline, :fail_on, :skip, :only, :packaging, :top, :compact) do
6
6
  def self.parse(argv) = CommandLine.new(argv).options
7
7
 
8
- def self.page(mode) = new(directories: [], mode:, baseline: "", fail_on: [], skip: [], packaging: :auto)
8
+ def self.build(mode)
9
+ new(
10
+ directories: [], baseline: "", fail_on: [], skip: [], only: [],
11
+ packaging: :auto, top: nil, compact: nil, mode:
12
+ )
13
+ end
9
14
 
10
15
  def pipeline
11
- Hashira::Pipeline.new(Hashira::Project.detect(directories), enabled: analyzers, packaging:)
16
+ chosen = Hashira::Project.new(directories)
17
+ told = Hashira::Report::Notices.new
18
+ told.scanning(chosen.files.size)
19
+ told.rails if directories.empty? && File.exist?("config/application.rb")
20
+ Hashira::Pipeline.new(chosen, enabled: analyzers, packaging:, only:)
12
21
  end
13
22
 
14
23
  def analyzers = Hashira::Pipeline::ANALYZERS - skip
@@ -9,6 +9,6 @@ module Hashira::CLI::PackageBy
9
9
  return :auto if value.empty?
10
10
  choice = value.to_sym
11
11
  return choice if CHOICES.include?(choice)
12
- raise(Hashira::Error.unknown("--package-by", value, CHOICES))
12
+ raise(Hashira::CLI::Choice.unknown("--package-by", value, CHOICES))
13
13
  end
14
14
  end
@@ -1,27 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::CLI::Run
4
- MODES = { update: :update, ratchet: :check, fail_on: :guard, json: :json, dot: :diagram, mermaid: :diagram }.freeze
5
-
6
4
  def initialize(pipeline, options)
7
5
  @pipeline = pipeline
8
6
  @options = options
9
7
  end
10
8
 
9
+ MODES = { update: :update, ratchet: :check, fail_on: :guard }
10
+ .merge(json: :json, dot: :diagram, mermaid: :diagram).freeze
11
+
11
12
  def status = __send__(MODES.fetch(@options.mode, :text))
12
13
 
13
14
  private
14
15
 
15
16
  def graph = @pipeline.graph
16
17
 
17
- def findings = @findings ||= Hashira::CI::Accepted.load(@options.baseline).screen(@pipeline.findings)
18
+ def findings = @_findings ||= accepted.screen(@pipeline.findings)
19
+
20
+ def accepted
21
+ path = @options.baseline
22
+ stop = Hashira::CI::Baseline.new(path).trouble
23
+ raise(Hashira::Error, stop) if stop
24
+ Hashira::CI::Accepted.build(path)
25
+ end
18
26
 
19
- def update = ratchet.update
27
+ def update
28
+ ratchet.update
29
+ rescue SystemCallError => error
30
+ raise(Hashira::Error, "cannot write #{@options.baseline} (#{error.message})")
31
+ end
20
32
 
21
33
  def check
22
34
  stop = ratchet.blocker
23
35
  raise(Hashira::Error, stop) if stop
24
- ratchet.check
36
+ ratchet.check(@pipeline.focus)
25
37
  end
26
38
 
27
39
  def guard = gate.check
@@ -32,9 +44,25 @@ class Hashira::CLI::Run
32
44
 
33
45
  def text = report(Hashira::Report::Text)
34
46
 
35
- def report(kind) = kind.new(view).print
47
+ def report(kind)
48
+ notices
49
+ kind.new(view).print
50
+ end
51
+
52
+ def notices
53
+ told = Hashira::Report::Notices.new
54
+ told.churn(@pipeline.project.label) unless @pipeline.churn.history?
55
+ broken = @pipeline.unparsed
56
+ told.unparsed(broken.size, broken.first(3).join(", ")) unless broken.empty?
57
+ end
58
+
59
+ def ratchet = @_ratchet ||= Hashira::CI::Ratchet.new(graph, findings.all, baseline)
60
+
61
+ def baseline
62
+ Hashira::CI::Baseline.new(@options.baseline, analyzers: @options.analyzers, targets:)
63
+ end
36
64
 
37
- def ratchet = @ratchet ||= Hashira::CI::Ratchet.new(graph, findings.all, @options.baseline)
65
+ def targets = @pipeline.project.directories
38
66
 
39
67
  def gate = Hashira::CI::Gate.new(findings, @options.fail_on)
40
68
 
@@ -44,7 +72,7 @@ class Hashira::CLI::Run
44
72
  Hashira::Report::View.new(
45
73
  project: @pipeline.project, graph: (graph if @pipeline.enabled?(:coupling)),
46
74
  complexity: @pipeline.complexity, duplication: @pipeline.duplication,
47
- hotspots: @pipeline.hotspots, findings:
75
+ hotspots: @pipeline.hotspots, findings:, top: @options.top, compact: @options.compact
48
76
  )
49
77
  end
50
78
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira::CLI::Top
4
+ WHOLE = /\A[1-9]\d*\z/
5
+
6
+ module_function
7
+
8
+ def parse(value)
9
+ return if value.empty?
10
+ return Integer(value, 10) if value.match?(WHOLE)
11
+ raise(Hashira::Error, "--top #{value.inspect} is not a positive whole number")
12
+ end
13
+ end
@@ -17,6 +17,10 @@ module Hashira::CLI::Usage
17
17
 
18
18
  FOOTER = <<~TEXT
19
19
 
20
+ Exit codes: 0 clean · 1 findings or a regression · 2 misuse
21
+ (bad flags, missing directory, unusable baseline) · 3 an
22
+ improvement the baseline has not recorded · 70 internal error.
23
+
20
24
  Findings accepted by design can be recorded in the baseline as
21
25
  "accepted": [{"kind": "...", "package": "...", "reason": "..."}]
22
26
  — they leave reports and gates, keeping a one-line reminder each.
@@ -38,7 +42,7 @@ module Hashira::CLI::Usage
38
42
  end
39
43
 
40
44
  def emit(output)
41
- puts(output)
45
+ $stdout.puts(output)
42
46
  0
43
47
  end
44
48
  end