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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 53480df33daebb25ecac87a601cad36cc6a3d8657a5ccb3cd094c807e1be2c15
4
- data.tar.gz: 6034a5afdfa48254a48b989405aed85e8e1d6c4f82dea35649d714edd8ed38ee
3
+ metadata.gz: 137dafce03e707a097cf684620fbd9c9efc8989347e77b88c13da103102f5da5
4
+ data.tar.gz: e0ab59d2e1909042c991764b5384217601aaf42b036bd6916042efc2107e0200
5
5
  SHA512:
6
- metadata.gz: 915f8ea2649037c29a4971d4ebc3df94d38c6a976fd4f0792e3e2c80e3c54d23b776a3a511c0dd5fa7552cbbd6463282e9fcd31ba5e510c2a87d82aafbfb6310
7
- data.tar.gz: 9b17e6bbc1625f7ed54d02eeb7ba8f345451c931e24cf39bd057bc8a06e92d11167d35a0bd1b883b75750aa533fdb39a1513dcb4359929c86d2cb0e21e285cdd
6
+ metadata.gz: 2c4895c6d045e05e610fda4c51ce763e0ef876049d933dbd4a3d9f99ac24c8648583a4a3f39e5c2baadfae263736914ba2a428781024ead0410b2d968585b8b8
7
+ data.tar.gz: dec6a978383a76ad57fdf34c98b5ea4b571775d44baafc0f5c02c3bbe75b7a56e274609c1586fc77af94e93f65f501579a5b6fa09bfd4db3952bb3cb412c153b
data/CHANGELOG.md CHANGED
@@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.8.0] - 2026-08-15
9
+
10
+ ### Added
11
+
12
+ - **`--only PATHS` narrows the findings to the files you name.** Meant for
13
+ hooks: after a formatter, a refactor, or an agent's edit, ask whether *these*
14
+ files got worse — `hashira --only "$CHANGED" --ratchet`. The whole project is
15
+ still parsed, because half of what hashira knows is cross-file (which
16
+ constants are yours, which methods reach into a neighbour, which fragments
17
+ are clones); reading one file alone would answer differently. A focused
18
+ ratchet stays quiet about package edges, which belong to no single file, and
19
+ about findings that disappeared, which only a whole-project run can confirm —
20
+ it reports what your files introduced or made worse. `--only` refuses
21
+ `--update-baseline` and the diagram formats, and ignores paths outside the
22
+ analyzed directories so a hook can hand it every changed file.
23
+
24
+ ### Changed
25
+
26
+ - **Memoization stops reading as state.** An instance variable named `@_thing`
27
+ is a cache, not a responsibility: `instance_variable_assumption` no longer
28
+ reports lazy presence as an assumption, and `too_many_instance_variables`
29
+ no longer counts derived values against the class. Codebases that memoize
30
+ behind the `@_` convention will see both smells quieten; codebases that
31
+ don't are unaffected.
32
+ - **Every object is built the way this tool says to build one.** Constructors
33
+ only assign, class-method logic dissolves into instances (`Project.detect`
34
+ and `CLI.run` are plain constructors — `exe/hashira` now calls
35
+ `CLI.new(argv).status`), factories are named `build`, and hashes acting as
36
+ objects got names (`SdpViolationFindings::Imbalance`,
37
+ `DuplicationFinding::Overlap`, `MethodFinding::Effort`). Internal
38
+ throughout: the command line, the reports, the JSON, and the baseline
39
+ format are unchanged. Only embedders calling the Ruby API directly are
40
+ affected.
41
+ - Coupling reads its rule list from `Rule.subclasses`, the way the smells
42
+ report already read `Check.subclasses`, and parameters stop carrying their
43
+ node type (`def_node` → `definition`). hashira's own baseline is down to
44
+ zero findings and one accepted boundary.
45
+
8
46
  ## [0.7.0] - 2026-08-10
9
47
 
10
48
  ### Added
data/README.md CHANGED
@@ -476,6 +476,35 @@ record the decision: update the baseline, or accept it with a reason.
476
476
  Improvements fail the build too, and say so cheerfully — an unrecorded gain is one
477
477
  the next commit can quietly undo. Re-run `--update-baseline` to lock it in.
478
478
 
479
+ ### Hooks: ratchet the files you just touched
480
+
481
+ `--only` narrows the findings to the files you name, so an editor hook, a
482
+ pre-commit hook, or an agent finishing an edit can ask the one question that
483
+ matters there: *did these files get worse?*
484
+
485
+ ```sh
486
+ hashira --only lib/billing/refund.rb,lib/orders/checkout.rb --ratchet
487
+ hashira --only "$(git diff --cached --name-only -- '*.rb' | paste -sd, -)" --ratchet
488
+ ```
489
+
490
+ The whole project is still parsed — that is the point. Half of what hashira knows
491
+ is cross-file (which constants are yours, which methods pick apart a neighbour's
492
+ internals, which fragments are clones of each other), so a file read alone would
493
+ answer differently. `--only` narrows the *report*, never the analysis. On this
494
+ repository a full run is under a second for 129 files; smells alone, a quarter of
495
+ that.
496
+
497
+ Two things a focused run cannot judge, and so stays quiet about: package edges,
498
+ which belong to no single file, and findings that *disappeared*, which you would
499
+ have to read the whole project to be sure of. It reports what your files
500
+ introduced or made worse, and nothing else. Run the unfocused `--ratchet` in CI —
501
+ that is where removals get celebrated and the baseline gets relocked.
502
+
503
+ `--only` refuses to combine with `--update-baseline` (which would record a
504
+ baseline missing everything you did not name) or with the diagram formats (which
505
+ draw the graph, not the findings). Paths outside the analyzed directories are
506
+ ignored, so a hook can hand it every changed file without filtering first.
507
+
479
508
  ### Exit codes
480
509
 
481
510
  A build step should be able to tell a regression from a typo without grepping
data/exe/hashira CHANGED
@@ -6,4 +6,4 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
6
6
 
7
7
  require "hashira"
8
8
 
9
- exit Hashira::CLI.run(ARGV)
9
+ exit Hashira::CLI.new(ARGV).status
@@ -14,7 +14,7 @@ module Hashira
14
14
 
15
15
  def identity = digest || package
16
16
 
17
- def to_h = super.compact
17
+ def to_h = super.merge(detail: detail&.to_h).compact
18
18
  end
19
19
  end
20
20
  end
@@ -17,10 +17,10 @@ module Hashira
17
17
 
18
18
  def label(node) = [node.name.to_s]
19
19
 
20
- def cbase?(node)
20
+ def rooted?(node)
21
21
  return false unless node.is_a?(Prism::ConstantPathNode)
22
22
  parent = node.parent
23
- !parent || cbase?(parent)
23
+ !parent || rooted?(parent)
24
24
  end
25
25
 
26
26
  def anchor(stack, segments, roots)
@@ -33,12 +33,12 @@ module Hashira
33
33
  roots.include?(segments.first(1)) ? segments : (stack.last || []) + segments
34
34
  end
35
35
 
36
- def direct(type_node) = statements(type_node).grep(Prism::DefNode)
36
+ def direct(type) = statements(type).grep(Prism::DefNode)
37
37
 
38
- def constants(type_node) = statements(type_node).grep(Prism::ConstantWriteNode)
38
+ def constants(type) = statements(type).grep(Prism::ConstantWriteNode)
39
39
 
40
- def statements(type_node)
41
- body = type_node.body
40
+ def statements(type)
41
+ body = type.body
42
42
  body.is_a?(Prism::StatementsNode) ? body.body : [body]
43
43
  end
44
44
  end
data/lib/hashira/churn.rb CHANGED
@@ -1,18 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::Churn
4
- LOG = %w[log --no-renames --name-only --format=].freeze
5
4
  SITES_THAT_DRIFT_APART = 2
6
5
 
7
- def self.scan(directory) = new(tally(read(directory)))
8
-
9
- def self.read(directory)
10
- IO.popen(["git", "-C", directory, *LOG], err: File::NULL, &:read)
11
- rescue SystemCallError
12
- ""
13
- end
14
-
15
- def self.tally(output) = output.split("\n").map(&:strip).reject(&:empty?).tally
6
+ def self.build(directory) = new(Hashira::GitLog.new(directory).counts)
16
7
 
17
8
  def initialize(counts)
18
9
  @counts = counts
@@ -7,7 +7,7 @@ class Hashira::CI::Accepted
7
7
 
8
8
  Entry =
9
9
  Data.define(:kind, :package, :digest, :reason) do
10
- def self.from(hash)
10
+ def self.build(hash)
11
11
  new(kind: hash["kind"], package: hash["package"], digest: hash["digest"], reason: hash["reason"])
12
12
  end
13
13
 
@@ -17,16 +17,16 @@ class Hashira::CI::Accepted
17
17
 
18
18
  def label = reason || "accepted (no reason recorded)"
19
19
 
20
- def to_h = { kind:, package:, digest:, reason: }.compact
20
+ def to_h = super.compact
21
21
  end
22
22
 
23
- def self.load(path) = new(Hashira::CI::Baseline.read(path).fetch("accepted", []))
23
+ def self.build(path) = new(Hashira::CI::Baseline.new(path).accepted)
24
24
 
25
- def initialize(entries)
26
- @entries = entries.map { Entry.from(it) }
25
+ def initialize(list)
26
+ @list = list
27
27
  end
28
28
 
29
- def entries = @entries.map(&:to_h)
29
+ def entries = records.map(&:to_h)
30
30
 
31
31
  def screen(findings)
32
32
  accepted, live = findings.map { [it, reason(it)] }.partition(&:last)
@@ -35,7 +35,9 @@ class Hashira::CI::Accepted
35
35
 
36
36
  private
37
37
 
38
+ def records = @_records ||= @list.map { Entry.build(it) }
39
+
38
40
  def reason(finding)
39
- @entries.find { it.matches?(finding) }&.label
41
+ records.find { it.matches?(finding) }&.label
40
42
  end
41
43
  end
@@ -5,47 +5,37 @@ require "json"
5
5
  class Hashira::CI::Baseline
6
6
  SCHEMA_VERSION = 4
7
7
 
8
- def self.load(path, analyzers: [], targets: [])
9
- new(path, read(path), Hashira::CI::Scope.new(analyzers:, targets:))
10
- end
11
-
12
- def self.read(path)
13
- return {} unless path && File.exist?(path)
14
- recorded = JSON.parse(File.read(path))
15
- recorded.is_a?(Hash) ? recorded : raise(JSON::ParserError, "its top level is a list, not an object")
16
- end
17
-
18
- def self.trouble(path)
19
- read(path) && nil
20
- rescue JSON::ParserError, SystemCallError => error
21
- "#{path} is not a usable baseline — #{error.message.lines.first.strip}. Re-record it with --update-baseline"
22
- end
23
-
24
- def initialize(path, recorded, scope = Hashira::CI::Scope.none)
8
+ def initialize(path, analyzers: [], targets: [])
25
9
  @path = path
26
- @recorded = recorded
27
- @scope = scope
10
+ @analyzers = analyzers
11
+ @targets = targets
28
12
  end
29
13
 
30
14
  attr_reader :path
31
15
 
32
16
  def exist? = File.exist?(@path)
33
17
 
34
- def edges = @recorded.fetch("edges", [])
18
+ def trouble
19
+ recorded && nil
20
+ rescue JSON::ParserError, SystemCallError => error
21
+ "#{@path} is not a usable baseline — #{error.message.lines.first.strip}. Re-record it with --update-baseline"
22
+ end
23
+
24
+ def edges = recorded.fetch("edges", [])
35
25
 
36
- def findings = self.class.scored(@recorded.fetch("findings", {}))
26
+ def findings = keyed(recorded.fetch("findings", {}))
37
27
 
38
- def self.scored(recorded) = recorded.is_a?(Array) ? recorded.to_h { [it, nil] } : recorded
28
+ def findings? = recorded.key?("findings")
39
29
 
40
- def findings? = @recorded.key?("findings")
30
+ def accepted = recorded.fetch("accepted", [])
41
31
 
42
- def packaging = @recorded.fetch("packaging", "folder")
32
+ def packaging = recorded.fetch("packaging", "folder")
43
33
 
44
- def analyzers = @recorded.fetch("analyzers", wanted[:analyzers])
34
+ def analyzers = recorded.fetch("analyzers", wanted[:analyzers])
45
35
 
46
- def targets = @recorded.fetch("targets", wanted[:targets])
36
+ def targets = recorded.fetch("targets", wanted[:targets])
47
37
 
48
- def wanted = @scope.to_h
38
+ def wanted = scope.to_h
49
39
 
50
40
  def write(edges, findings, packaging:)
51
41
  File.write(@path, JSON.pretty_generate(payload(edges, findings, packaging)) << "\n")
@@ -53,9 +43,23 @@ class Hashira::CI::Baseline
53
43
 
54
44
  private
55
45
 
46
+ def recorded
47
+ @_recorded ||= read
48
+ end
49
+
50
+ def read
51
+ return {} unless @path && File.exist?(@path)
52
+ stored = JSON.parse(File.read(@path))
53
+ stored.is_a?(Hash) ? stored : raise(JSON::ParserError, "its top level is a list, not an object")
54
+ end
55
+
56
+ def keyed(findings) = findings.is_a?(Array) ? findings.to_h { [it, nil] } : findings
57
+
58
+ def scope = Hashira::CI::Scope.new(analyzers: @analyzers, targets: @targets)
59
+
56
60
  def payload(edges, findings, packaging)
57
- base = { version: SCHEMA_VERSION, packaging:, **@scope.to_h, edges:, findings: }
58
- accepted = Hashira::CI::Accepted.new(@recorded.fetch("accepted", [])).entries
59
- accepted.empty? ? base : base.merge(accepted:)
61
+ base = { version: SCHEMA_VERSION, packaging: }.merge(scope.to_h).merge(edges:, findings:)
62
+ kept = Hashira::CI::Accepted.new(accepted).entries
63
+ kept.empty? ? base : base.merge(accepted: kept)
60
64
  end
61
65
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::CI::Comparison
4
+ def initialize(current, recorded)
5
+ @current = current
6
+ @recorded = recorded
7
+ end
8
+
9
+ def diff
10
+ current = @current.keys
11
+ recorded = @recorded.keys
12
+ Hashira::CI::Diff.new(added: current - recorded, removed: recorded - current, worsened:)
13
+ end
14
+
15
+ private
16
+
17
+ def worsened
18
+ (@current.keys & @recorded.keys).filter_map { entry(it, @recorded[it], @current[it]) }
19
+ end
20
+
21
+ def entry(key, before, after)
22
+ [key, before, after] if before.is_a?(Integer) && after.is_a?(Integer) && after > before
23
+ end
24
+ end
@@ -4,21 +4,6 @@ module Hashira
4
4
  module CI
5
5
  Diff =
6
6
  Data.define(:added, :removed, :worsened) do
7
- def self.between(current, recorded)
8
- new(
9
- added: current.keys - recorded.keys, removed: recorded.keys - current.keys,
10
- worsened: grown(current, recorded)
11
- )
12
- end
13
-
14
- def self.grown(current, recorded)
15
- (current.keys & recorded.keys).filter_map { entry(it, recorded[it], current[it]) }
16
- end
17
-
18
- def self.entry(key, before, after)
19
- [key, before, after] if before.is_a?(Integer) && after.is_a?(Integer) && after > before
20
- end
21
-
22
7
  def initialize(worsened: [], **) = super
23
8
  def empty? = added.empty? && removed.empty? && worsened.empty?
24
9
  def worse? = !added.empty? || !worsened.empty?
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::CI::Gate
4
- def initialize(findings, kinds, io: $stdout, err: $stderr)
4
+ def initialize(findings, kinds, io: $stdout, error: $stderr)
5
5
  @findings = findings
6
6
  @kinds = kinds
7
7
  @io = io
8
- @err = err
8
+ @error = error
9
9
  end
10
10
 
11
11
  def check
@@ -19,7 +19,7 @@ class Hashira::CI::Gate
19
19
  offending.each { Hashira::Report::FindingLines.new(it, io: @io).print }
20
20
  verdict = "Gate FAILED: #{offending.size} finding(s) — #{tally(offending)}."
21
21
  @io.puts("\n#{verdict}")
22
- @err.puts(verdict)
22
+ @error.puts(verdict)
23
23
  1
24
24
  end
25
25
 
@@ -14,7 +14,11 @@ class Hashira::CI::Ratchet
14
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?
@@ -56,15 +60,17 @@ class Hashira::CI::Ratchet
56
60
 
57
61
  def edges = @graph.edges.map(&:to_s)
58
62
 
63
+ def tally(compared) = compared.counts("#{@graph.edges.size} edges", "#{@findings.size} findings").join(", ")
64
+
59
65
  def scored
60
- @scored ||= @findings.group_by(&:signature).sort.to_h { |key, group| [key, group.filter_map(&:magnitude).max] }
66
+ @_scored ||= @findings.group_by(&:signature).sort.to_h { |key, group| [key, group.filter_map(&:magnitude).max] }
61
67
  end
62
68
 
63
69
  def drift = Hashira::CI::Diff.new(added: fresh, removed: @baseline.edges - edges)
64
70
 
65
71
  def fresh = @graph.edges.reject { @baseline.edges.include?(it.to_s) }
66
72
 
67
- def delta
68
- Hashira::CI::Diff.between(scored, @baseline.findings) if @baseline.findings?
73
+ def delta(compared)
74
+ compared.findings(scored) if @baseline.findings?
69
75
  end
70
76
  end
@@ -7,8 +7,8 @@ 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
14
  verdict(edges, findings)
@@ -18,8 +18,8 @@ class Hashira::CI::RatchetReport
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.")
21
+ def unchanged(tally)
22
+ @io.puts("Ratchet OK: #{tally}, unchanged.")
23
23
  Hashira::CI::Status::CLEAN
24
24
  end
25
25
 
@@ -4,7 +4,6 @@ module Hashira
4
4
  module CI
5
5
  Scope =
6
6
  Data.define(:analyzers, :targets) do
7
- def self.none = new(analyzers: [], targets: [])
8
7
  def to_h = { analyzers: analyzers.map(&:to_s).sort, targets: targets.sort }
9
8
  end
10
9
  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,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,28 +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)
11
+ _flag, value = argv.slice!(position, 2)
12
12
  vet(flag, value)
13
13
  end
14
14
 
15
15
  def vet(flag, value)
16
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)
17
+ raise(Hashira::Error, "#{flag} given more than once") if argv.include?(flag)
18
18
  value
19
19
  end
20
20
 
21
- def delete(flag) = @argv.delete(flag)
21
+ def delete(flag) = argv.delete(flag)
22
22
 
23
23
  def rest
24
- stray = @argv.find { it.start_with?("-") }
24
+ stray = argv.find { it.start_with?("-") }
25
25
  raise(Hashira::Error, "unknown option #{stray}") if stray
26
- @argv
26
+ argv
27
27
  end
28
+
29
+ private
30
+
31
+ def argv = @_argv ||= @given.dup
28
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,12 +5,12 @@ 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] }
13
- }.freeze
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
14
 
15
15
  OWNERS = {
16
16
  **Hashira::Pipeline::STRUCTURAL.to_h { [it, :coupling] },
@@ -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,6 +3,7 @@
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"
8
9
  require_relative "top"
@@ -31,6 +32,15 @@ class Hashira::CLI
31
32
  name: "--skip", arg: "ANALYZERS", field: :skip, parse: Skip,
32
33
  text: ["drop an analyzer; comma-separated: coupling,", "complexity, duplication, smells"]
33
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
+ ),
34
44
  Flag.new(
35
45
  name: "--top", arg: "N", field: :top, parse: Top,
36
46
  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
@@ -12,10 +12,21 @@ module Hashira::CLI::Needs
12
12
  skip = options.skip
13
13
  drawing(mode, skip)
14
14
  gate(options.fail_on, skip)
15
- shaping(mode) if options.compact
15
+ shaping(options, mode)
16
16
  end
17
17
 
18
- def shaping(mode)
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)
19
30
  raise(Hashira::Error, "--compact shapes JSON, but this run emits #{mode}") unless mode == :json
20
31
  end
21
32
 
@@ -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