hashira 0.6.0 → 0.7.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/README.md +119 -53
  4. data/lib/hashira/analysis/finding.rb +4 -0
  5. data/lib/hashira/churn.rb +10 -2
  6. data/lib/hashira/ci/accepted.rb +1 -4
  7. data/lib/hashira/ci/baseline.rb +28 -5
  8. data/lib/hashira/ci/diff.rb +15 -4
  9. data/lib/hashira/ci/finding_diff_report.rb +10 -0
  10. data/lib/hashira/ci/gate.rb +10 -2
  11. data/lib/hashira/ci/improvement.rb +1 -1
  12. data/lib/hashira/ci/ratchet.rb +33 -8
  13. data/lib/hashira/ci/ratchet_report.rb +4 -2
  14. data/lib/hashira/ci/scope.rb +11 -0
  15. data/lib/hashira/ci/status.rb +7 -0
  16. data/lib/hashira/cli/arguments.rb +6 -1
  17. data/lib/hashira/cli/fail_on.rb +12 -2
  18. data/lib/hashira/cli/flags.rb +13 -0
  19. data/lib/hashira/cli/needs.rb +34 -0
  20. data/lib/hashira/cli/options.rb +14 -3
  21. data/lib/hashira/cli/run.rb +32 -5
  22. data/lib/hashira/cli/top.rb +13 -0
  23. data/lib/hashira/cli/usage.rb +5 -1
  24. data/lib/hashira/cli.rb +24 -5
  25. data/lib/hashira/coupling/metric.rb +7 -1
  26. data/lib/hashira/diagram/dot.rb +9 -6
  27. data/lib/hashira/diagram/mermaid.rb +11 -8
  28. data/lib/hashira/diagram/source.rb +5 -2
  29. data/lib/hashira/pipeline.rb +12 -18
  30. data/lib/hashira/project.rb +19 -5
  31. data/lib/hashira/report/columns.rb +48 -0
  32. data/lib/hashira/report/complexity_table.rb +12 -16
  33. data/lib/hashira/report/dependency_map.rb +1 -0
  34. data/lib/hashira/report/hotspot_table.rb +6 -14
  35. data/lib/hashira/report/json.rb +9 -2
  36. data/lib/hashira/report/metrics_table.rb +19 -20
  37. data/lib/hashira/report/notices.rb +37 -0
  38. data/lib/hashira/report/phrases.rb +2 -0
  39. data/lib/hashira/report/text.rb +24 -9
  40. data/lib/hashira/report/view.rb +4 -1
  41. data/lib/hashira/trees.rb +22 -0
  42. data/lib/hashira/version.rb +1 -1
  43. data/lib/hashira.rb +6 -0
  44. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a0b871ab3e0bf096a2752a48a4c3776d2dbe2ee0cae33b2df8af89649d0952ca
4
- data.tar.gz: b1058810a8d965e1010044691d1b11571f21b077581f5e435d40b0d263f133b9
3
+ metadata.gz: 53480df33daebb25ecac87a601cad36cc6a3d8657a5ccb3cd094c807e1be2c15
4
+ data.tar.gz: 6034a5afdfa48254a48b989405aed85e8e1d6c4f82dea35649d714edd8ed38ee
5
5
  SHA512:
6
- metadata.gz: 24d9aae54c58e39a54799efdf9749f501000162c8ea8f9a38e02523fb6ee3122a98daf6591cfaf7816f3a2cd66f20218c08ff27579bc1b58a269e4ce7bddda92
7
- data.tar.gz: 158bf3af24e081df3483e3d124833f6943404cfdab6ca1f2f7235115a400cfb1c48f885fb16b290fc529ebc1556c8eb5ed97119f635c9869ae881b44be51c599
6
+ metadata.gz: 915f8ea2649037c29a4971d4ebc3df94d38c6a976fd4f0792e3e2c80e3c54d23b776a3a511c0dd5fa7552cbbd6463282e9fcd31ba5e510c2a87d82aafbfb6310
7
+ data.tar.gz: 9b17e6bbc1625f7ed54d02eeb7ba8f345451c931e24cf39bd057bc8a06e92d11167d35a0bd1b883b75750aa533fdb39a1513dcb4359929c86d2cb0e21e285cdd
data/CHANGELOG.md CHANGED
@@ -5,6 +5,74 @@ 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.7.0] - 2026-08-10
9
+
10
+ ### Added
11
+
12
+ - **The ratchet compares magnitudes, not just identities.** The baseline now
13
+ records a value beside each finding's signature — cognitive complexity,
14
+ clone-cluster mass — so a baselined method that gets measurably worse fails
15
+ the build instead of hiding behind set membership. On a legacy codebase this
16
+ is the case the ratchet exists for: everything hot is already baselined on
17
+ day one.
18
+ - **Exit codes stop meaning six different things.** 0 clean, 1 findings or a
19
+ caught regression, 2 misuse, 3 an improvement the baseline has not recorded,
20
+ 70 internal error. Failing on 1 while treating 3 as a nudge blocks
21
+ regressions without blocking progress. Unexpected exceptions now print the
22
+ class, message, origin frame, and where to report — not a backtrace.
23
+ - **The report says what produced it.** The heading names the packaging mode,
24
+ files Ruby itself rejects are counted and named on stderr instead of
25
+ contributing half-parsed trees silently, and a terminal run shows a sign of
26
+ life before the parse and a timing line after (never when stderr is not a
27
+ tty — stdout stays byte-identical).
28
+ - `--top N` caps every list at once; the package table and findings list gain
29
+ a default cap of 25 with a note saying what was withheld. `--json` is never
30
+ capped.
31
+ - `--compact` emits `--json` on one line instead of pretty-printed
32
+ indentation, and `--json` now opens with schema version, packaging, targets,
33
+ and file count.
34
+
35
+ ### Fixed
36
+
37
+ - **Green no longer means unchecked.** `--fail-on ""` armed nothing and
38
+ passed; `--fail-on cycles --skip coupling` switched off the only analyzer
39
+ that finds cycles and announced there were none; a directory with no Ruby
40
+ files was congratulated on its healthy structure. All three now fail
41
+ loudly.
42
+ - **The baseline guards the whole scope it was recorded under.** A baseline
43
+ recorded over four analyzers, compared against a run with `--skip smells`,
44
+ reported every smell finding as an improvement and suggested locking it in.
45
+ Schema 4 records analyzers and target directories, and the ratchet refuses
46
+ a mismatched run the same way the packaging guard already did.
47
+ - Churn runs `git -C <directory>` instead of reading the working directory,
48
+ so analyzing a repo from anywhere else no longer zeroes every count and
49
+ silently reorders the hotspot queue. A run with no history says so; an
50
+ unreadable baseline is a one-line error, not nine frames of Ruby.
51
+ - Five CLI misreadings: a file argument is named as a file (with the
52
+ directory to try), duplicate directories are deduplicated by realpath, a
53
+ gem whose lib holds only loose files is accepted, a value flag given twice
54
+ is not "unknown", and a diagram whose analyzer is skipped is refused
55
+ instead of drawn anyway.
56
+ - Diagrams stop losing packages: mermaid/dot ids are generated so `my-pkg`
57
+ and `my_pkg` no longer merge (and a package named `end` no longer breaks
58
+ the grammar), and isolated packages appear instead of vanishing.
59
+ - `Gate FAILED` names the kinds that actually fired, worst first, mirrored to
60
+ stderr; `--update-baseline` and `--ratchet` no longer report contradictory
61
+ totals for the same run.
62
+ - Tables size their columns to their contents: long names clip in the middle
63
+ instead of pushing rows into ribbons, numeric columns right-align, and
64
+ trailing whitespace is gone.
65
+ - An edgeless package prints "—" and sorts last instead of claiming I=0.00
66
+ beside genuine foundations.
67
+
68
+ ### Changed
69
+
70
+ - The four house cops and shared style defaults moved to the published
71
+ `rubocop-kata` gem; `.rubocop.yml` keeps only project-specific config.
72
+ - Docs: the `--fail-on` shorthands (`cycles`, `sdp`, `dupe`) are documented,
73
+ and a bare `hashira` in a Rails root notes once on stderr that `hashira app`
74
+ reads the application.
75
+
8
76
  ## [0.6.0] - 2026-08-05
9
77
 
10
78
  ### Changed
data/README.md CHANGED
@@ -21,10 +21,12 @@ the cycle and to the cheapest edge to cut:
21
21
 
22
22
  ```console
23
23
  $ hashira app
24
- package TC Ca Ce I Cyc
25
- ----------------------------------------
26
- billing 1 1 1 0.50 YES
27
- shipping 1 1 1 0.50 YES
24
+ Package (folder) metrics for app (2 packages, 2 files)
25
+
26
+ package TC Ca Ce I Cyc
27
+ -------------------------------
28
+ billing 1 1 1 0.50 YES
29
+ shipping 1 1 1 0.50 YES
28
30
 
29
31
  Findings (1):
30
32
  cycle: billing can reach itself: billing -> shipping -> billing — any change may ripple back around. The lightest edge on this cycle is billing -> shipping (1 ref).
@@ -72,66 +74,77 @@ Single-folder wrapper chains are descended automatically, so `hashira`,
72
74
  hashira # auto-detects lib/<gem>
73
75
  hashira lib/myapp # or point it at a directory
74
76
  hashira app lib # or several — one shared graph
75
- hashira --skip complexity,duplication # coupling only
76
- hashira --skip coupling # complexity + duplication
77
+ hashira --skip complexity,duplication # coupling + smells only
78
+ hashira --skip coupling # complexity + duplication + smells
79
+ hashira --top 50 # longer tables and findings list
77
80
  ```
78
81
 
79
82
  The full text report is the coupling tables, the complexity tables, the hotspot
80
- rollup, and the findings (which include any duplication clusters). Here it is on
81
- hashira's own source:
83
+ rollup, and the findings (which include any duplication clusters). It is capped
84
+ so a large codebase stays readable — 25 packages and findings, 10 methods and
85
+ files — and every list says how many rows it withheld. `--top N` moves all of
86
+ them at once; `--json` is never capped.
87
+
88
+ The heading names the packaging that ran (`folder` or `namespace`), since the
89
+ baseline is recorded per mode. Anything hashira had to work around goes to
90
+ stderr, never stdout: a directory with no git history (churn reads as zero, so
91
+ hotspots rank by cost alone), and files Prism could not parse. On a terminal
92
+ you also get a progress line before the parse and a timing line after; piped or
93
+ in CI, stdout is byte-identical either way. Here it is on hashira's own source:
82
94
 
83
95
  ```console
84
96
  $ hashira
85
- Package (layer) metrics for lib/hashira (11 packages, 111 files)
86
-
87
- package TC Ca Ce I Cyc
88
- ----------------------------------------
89
- analysis 3 4 0 0.00 -
90
- diagram 3 1 0 0.00 -
91
- hotspots 1 1 0 0.00 -
92
- duplication 14 2 1 0.33 -
93
- report 8 2 1 0.33 -
94
- ci 8 1 1 0.50 -
95
- complexity 7 1 1 0.50 -
96
- coupling 24 1 1 0.50 -
97
- smells 21 1 1 0.50 -
98
- (root) 4 1 5 0.83 -
99
- cli 8 0 4 1.00 -
97
+ Package (folder) metrics for lib/hashira (11 packages, 129 files)
98
+
99
+ package TC Ca Ce I Cyc
100
+ ----------------------------------
101
+ diagram 3 1 0 0.00 -
102
+ hotspots 1 1 0 0.00 -
103
+ analysis 3 4 0 0.00 -
104
+ report 11 2 1 0.33 -
105
+ duplication 14 2 1 0.33 -
106
+ smells 24 1 1 0.50 -
107
+ ci 8 1 1 0.50 -
108
+ complexity 7 1 1 0.50 -
109
+ coupling 29 1 1 0.50 -
110
+ (root) 5 1 5 0.83 -
111
+ cli 11 0 4 1.00 -
100
112
 
101
113
  Legend: TC total types, Ca afferent (incoming), Ce efferent (outgoing),
102
114
  I=Ce/(Ce+Ca) instability (0=maximally stable, 1=maximally unstable)
103
115
 
104
116
  Dependencies (DependsUpon(refs) -> | <- UsedBy):
105
- (root) -> complexity(1), coupling(5), duplication(1), hotspots(1), smells(1) <- cli
117
+ (root) -> complexity(1), coupling(2), duplication(1), hotspots(1), smells(3) <- cli
106
118
  analysis -> (none) <- complexity, coupling, duplication, smells
107
119
  duplication -> analysis(3) <- (root), report
108
120
  ...
109
121
 
110
122
  Cognitive complexity — worst methods (Cog = how hard to read, Calls = message sends):
111
123
 
112
- method Cog Calls Loc
113
- -------------------------------------------------------------
114
- Hashira::Coupling::NamespacePrefix#wrapper 4 8 coupling/namespace_prefix.rb:19
115
- Hashira::Analysis::Syntax#anchor 4 12 analysis/syntax.rb:26
116
- Hashira::Smells::Conditions#branches 4 10 smells/conditions.rb:23
117
- Hashira::Coupling::Roster#admit 3 4 coupling/roster.rb:19
124
+ method Cog Calls Loc
125
+ ---------------------------------------------------------------------------------------
126
+ Hashira::Coupling::NamespacePrefix#wrapper 4 8 coupling/namespace_prefix.rb:19
127
+ Hashira::Analysis::Syntax#anchor 4 12 analysis/syntax.rb:26
128
+ Hashira::Smells::Conditions#branches 4 10 smells/conditions.rb:23
129
+ Hashira::Coupling::Roster#admit 4 6 coupling/roster.rb:22
118
130
  ...
119
131
 
120
132
  Per-class rollup (Cog total survives extract-method; Peak is the worst method it hides):
121
133
 
122
- class Cog Methods Peak
123
- ------------------------------------------------------
124
- Hashira::Project 12 15 3
125
- Hashira::CLI::CommandLine 12 15 3
134
+ class Cog Methods Peak
135
+ --------------------------------------------------
136
+ Hashira::Project 13 15 3
137
+ Hashira::Smells::Conditions 11 9 4
138
+ Hashira::Smells::Foreign 11 27 1
126
139
  ...
127
140
 
128
141
  Hotspots — cost × churn (where refactoring pays the most):
129
142
 
130
- file Cog Dup Churn Rank
131
- -------------------------------------------------------------------------
132
- pipeline.rb 10 0 6 60
133
- cli/command_line.rb 12 0 4 48
134
- project.rb 12 0 4 48
143
+ file Cog Dup Churn Rank
144
+ ----------------------------------------------
145
+ pipeline.rb 10 0 11 110
146
+ project.rb 13 0 5 65
147
+ report/text.rb 8 0 4 32
135
148
  ...
136
149
 
137
150
  Findings (0):
@@ -185,11 +198,13 @@ reach into `Ci`?
185
198
 
186
199
  ```console
187
200
  $ hashira app
188
- package TC Ca Ce I Cyc
189
- ----------------------------------------
190
- Account 26 21 18 0.46 YES
191
- Billing 116 12 11 0.48 YES
192
- Ci 107 9 16 0.64 YES
201
+ Package (namespace) metrics for app (442 packages, 3222 files)
202
+
203
+ package TC Ca Ce I Cyc
204
+ -------------------------------
205
+ Account 26 21 18 0.46 YES
206
+ Billing 116 12 11 0.48 YES
207
+ Ci 107 9 16 0.64 YES
193
208
  ...
194
209
  cycle: Account can reach itself: Account -> User -> Account — any change
195
210
  may ripple back around. The lightest edge on this cycle is Account -> User (1 ref).
@@ -355,13 +370,13 @@ changes — because cost you never pay isn't worth paying down:
355
370
  ```console
356
371
  Hotspots — cost × churn (where refactoring pays the most):
357
372
 
358
- file Cog Dup Churn Rank
359
- -------------------------------------------------------------------------
360
- controllers/orders/refunds_controller.rb 0 67 4 268
361
- controllers/orders/returns_controller.rb 0 67 4 268
362
- models/invoice.rb 8 34 3 126
363
- models/shipping/label.rb 9 100 1 109
364
- controllers/orders_controller.rb 8 0 7 56
373
+ file Cog Dup Churn Rank
374
+ ---------------------------------------------------------------
375
+ controllers/orders/refunds_controller.rb 0 67 4 268
376
+ controllers/orders/returns_controller.rb 0 67 4 268
377
+ models/invoice.rb 8 34 3 126
378
+ models/shipping/label.rb 9 100 1 109
379
+ controllers/orders_controller.rb 8 0 7 56
365
380
  ```
366
381
 
367
382
  Read it as a work queue: the top row is where a day of refactoring buys the most.
@@ -412,6 +427,12 @@ at all. It only works on a codebase that starts clean.
412
427
  hashira --fail-on cycles,sdp,mixed_audience,wide_edge,roll_call,smells # any subset
413
428
  ```
414
429
 
430
+ Kinds are named as the reports name them, plus three shorthands: `cycles` for
431
+ `cycle`, `sdp` for `sdp_violation`, and `dupe` for `duplication`. `smells`
432
+ expands to every smell kind; a single smell can be named on its own
433
+ (`--fail-on feature_envy`). An unknown kind lists the valid ones rather than
434
+ guessing.
435
+
415
436
  The ratchet is the one you can adopt today. Commit a baseline of what's true now
416
437
  — which edges exist, which findings stand — and the build fails when that set
417
438
  *grows*. It never asks whether the code is good, only whether this commit made it
@@ -423,6 +444,22 @@ hashira --ratchet # fail if either set grew
423
444
  hashira --ratchet --baseline PATH
424
445
  ```
425
446
 
447
+ The baseline records more than which findings exist: where a finding has a
448
+ magnitude — cognitive complexity, clone mass, sprawl count — it records that too.
449
+ So a method already in the baseline going from 10 to 54 is a regression, not
450
+ "unchanged". On a legacy codebase, where most hot methods are baselined on day
451
+ one, that is where the work actually happens:
452
+
453
+ ```console
454
+ $ hashira --ratchet
455
+ WORSE FINDING (was 13, now 24):
456
+ complexity: App::Core::Knot#tangle — cognitive 24, 1 calls (core/knot.rb:4).
457
+ flatten the branching — guard clauses, early returns, or polymorphism.
458
+ ```
459
+
460
+ Baselines written by earlier versions still work: they record identity only, so
461
+ they ratchet on appearance until the next `--update-baseline` records magnitudes.
462
+
426
463
  A regression prints in full, with the evidence that introduced it:
427
464
 
428
465
  ```console
@@ -439,6 +476,23 @@ record the decision: update the baseline, or accept it with a reason.
439
476
  Improvements fail the build too, and say so cheerfully — an unrecorded gain is one
440
477
  the next commit can quietly undo. Re-run `--update-baseline` to lock it in.
441
478
 
479
+ ### Exit codes
480
+
481
+ A build step should be able to tell a regression from a typo without grepping
482
+ English, so the codes are distinct:
483
+
484
+ | code | meaning |
485
+ | ---- | ------- |
486
+ | `0` | clean — nothing found, or nothing worse than the baseline |
487
+ | `1` | findings, or the ratchet found a regression |
488
+ | `2` | misuse — bad flags, missing directory, unusable baseline |
489
+ | `3` | an improvement the baseline has not recorded yet |
490
+ | `70` | internal error, worth reporting |
491
+
492
+ `3` is the one worth wiring specially: it means the code got better and only the
493
+ baseline is stale. Failing on `1` while treating `3` as a nudge lets a build
494
+ block regressions without blocking progress.
495
+
442
496
  ### Accepting by design
443
497
 
444
498
  Anything deliberate goes in the baseline with a reason. It leaves reports and
@@ -466,12 +520,24 @@ sentence turns every exception into a decision somebody reviewed.
466
520
  ## Other formats
467
521
 
468
522
  ```sh
469
- hashira --json # machine format: findings (with digests), accepted, packages,
470
- # edges, complexity, duplication, hotspots
523
+ hashira --json # machine format, never capped by --top
524
+ hashira --json --compact # the same on one line, for piping
471
525
  hashira --format dot # Graphviz digraph
472
526
  hashira --format mermaid # Mermaid diagram
473
527
  ```
474
528
 
529
+ `--json` opens with what produced it — `version` (the schema, bumped when the
530
+ shape changes), `packaging`, `targets`, `files` — then `findings` (each with its
531
+ `digest`), `accepted`, `packages`, `edges`, `folds` (single-type classes joined
532
+ to a base or domain, `{from, to, via}`), `complexity`, `duplication`, and
533
+ `hotspots`. A package with no edges at all reports `"i": null` rather than
534
+ pretending 0/0 is maximally stable.
535
+
536
+ Both diagrams declare every package before the arrows, so a package nothing
537
+ depends on still appears. Mermaid node ids are generated (`p0`, `p1`, …) with
538
+ the real name in the label, so `my-pkg` and `my_pkg` stay two nodes and a
539
+ package called `end` does not break the graph.
540
+
475
541
  ## Why cognitive complexity
476
542
 
477
543
  The older Ruby complexity metrics charge roughly one point per message send and
@@ -2,12 +2,16 @@
2
2
 
3
3
  module Hashira
4
4
  module Analysis
5
+ MAGNITUDES = { "complexity" => :cognitive, "duplication" => :mass, "boundary_sprawl" => :count }.freeze
6
+
5
7
  Finding =
6
8
  Data.define(:kind, :package, :detail, :evidence, :cycle, :digest) do
7
9
  def initialize(cycle: nil, digest: nil, detail: nil, **rest) = super
8
10
 
9
11
  def signature = "#{kind}:#{identity}"
10
12
 
13
+ def magnitude = detail.to_h[MAGNITUDES[kind]]
14
+
11
15
  def identity = digest || package
12
16
 
13
17
  def to_h = super.compact
data/lib/hashira/churn.rb CHANGED
@@ -1,10 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::Churn
4
- LOG = "git log --no-renames --name-only --format= 2>/dev/null"
4
+ LOG = %w[log --no-renames --name-only --format=].freeze
5
5
  SITES_THAT_DRIFT_APART = 2
6
6
 
7
- def self.scan = new(tally(`#{LOG}`))
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
8
14
 
9
15
  def self.tally(output) = output.split("\n").map(&:strip).reject(&:empty?).tally
10
16
 
@@ -12,6 +18,8 @@ class Hashira::Churn
12
18
  @counts = counts
13
19
  end
14
20
 
21
+ def history? = @counts.any?
22
+
15
23
  def hits(file) = @counts.select { |path, _| path.end_with?(file) }.values.max || 0
16
24
 
17
25
  def hot?(members) = changing(members) >= SITES_THAT_DRIFT_APART
@@ -20,10 +20,7 @@ class Hashira::CI::Accepted
20
20
  def to_h = { kind:, package:, digest:, reason: }.compact
21
21
  end
22
22
 
23
- def self.load(path)
24
- return new([]) unless path && File.exist?(path)
25
- new(JSON.parse(File.read(path)).fetch("accepted", []))
26
- end
23
+ def self.load(path) = new(Hashira::CI::Baseline.read(path).fetch("accepted", []))
27
24
 
28
25
  def initialize(entries)
29
26
  @entries = entries.map { Entry.from(it) }
@@ -3,13 +3,28 @@
3
3
  require "json"
4
4
 
5
5
  class Hashira::CI::Baseline
6
- SCHEMA_VERSION = 3
6
+ SCHEMA_VERSION = 4
7
7
 
8
- def self.load(path) = new(path, File.exist?(path) ? JSON.parse(File.read(path)) : {})
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
9
23
 
10
- def initialize(path, recorded)
24
+ def initialize(path, recorded, scope = Hashira::CI::Scope.none)
11
25
  @path = path
12
26
  @recorded = recorded
27
+ @scope = scope
13
28
  end
14
29
 
15
30
  attr_reader :path
@@ -18,12 +33,20 @@ class Hashira::CI::Baseline
18
33
 
19
34
  def edges = @recorded.fetch("edges", [])
20
35
 
21
- def findings = @recorded.fetch("findings", [])
36
+ def findings = self.class.scored(@recorded.fetch("findings", {}))
37
+
38
+ def self.scored(recorded) = recorded.is_a?(Array) ? recorded.to_h { [it, nil] } : recorded
22
39
 
23
40
  def findings? = @recorded.key?("findings")
24
41
 
25
42
  def packaging = @recorded.fetch("packaging", "folder")
26
43
 
44
+ def analyzers = @recorded.fetch("analyzers", wanted[:analyzers])
45
+
46
+ def targets = @recorded.fetch("targets", wanted[:targets])
47
+
48
+ def wanted = @scope.to_h
49
+
27
50
  def write(edges, findings, packaging:)
28
51
  File.write(@path, JSON.pretty_generate(payload(edges, findings, packaging)) << "\n")
29
52
  end
@@ -31,7 +54,7 @@ class Hashira::CI::Baseline
31
54
  private
32
55
 
33
56
  def payload(edges, findings, packaging)
34
- base = { version: SCHEMA_VERSION, packaging:, edges:, findings: }
57
+ base = { version: SCHEMA_VERSION, packaging:, **@scope.to_h, edges:, findings: }
35
58
  accepted = Hashira::CI::Accepted.new(@recorded.fetch("accepted", [])).entries
36
59
  accepted.empty? ? base : base.merge(accepted:)
37
60
  end
@@ -3,14 +3,25 @@
3
3
  module Hashira
4
4
  module CI
5
5
  Diff =
6
- Data.define(:added, :removed) do
6
+ Data.define(:added, :removed, :worsened) do
7
7
  def self.between(current, recorded)
8
- new(added: current - recorded, removed: recorded - current)
8
+ new(
9
+ added: current.keys - recorded.keys, removed: recorded.keys - current.keys,
10
+ worsened: grown(current, recorded)
11
+ )
9
12
  end
10
13
 
11
- def empty? = added.empty? && removed.empty?
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
12
21
 
13
- def worse? = !added.empty?
22
+ def initialize(worsened: [], **) = super
23
+ def empty? = added.empty? && removed.empty? && worsened.empty?
24
+ def worse? = !added.empty? || !worsened.empty?
14
25
  end
15
26
  end
16
27
  end
@@ -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, err: $stderr)
5
5
  @findings = findings
6
6
  @kinds = kinds
7
7
  @io = io
8
+ @err = err
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
+ @err.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,53 @@
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
17
  def check = Hashira::CI::RatchetReport.new(@graph, @findings, io: @io).print(drift, delta)
18
18
 
19
19
  def blocker
20
20
  return "no baseline at #{@baseline.path} — run --update-baseline first" unless @baseline.exist?
21
- mismatch unless @baseline.packaging == packaging
21
+ drifted.first
22
22
  end
23
23
 
24
24
  private
25
25
 
26
+ def drifted = [(mismatch unless @baseline.packaging == packaging), *narrowed(@baseline.wanted)].compact
27
+
28
+ def narrowed(wanted)
29
+ [
30
+ widened("the analyzers", @baseline.analyzers, wanted[:analyzers]),
31
+ widened("the directories", @baseline.targets, wanted[:targets])
32
+ ]
33
+ end
34
+
35
+ def widened(flag, was, now)
36
+ return if was == now
37
+ phrase(flag, was, now)
38
+ end
39
+
40
+ def phrase(flag, was, now)
41
+ "baseline #{@baseline.path} was recorded with #{flag} #{was.join(", ")}, but this run " \
42
+ "uses #{now.join(", ")} — rerun the recorded way, or refresh it with --update-baseline"
43
+ end
44
+
26
45
  def packaging = @graph.packaging.to_s
27
46
 
47
+ def recorded = collapsed(@findings.size, scored.size)
48
+
49
+ def collapsed(total, keys) = keys == total ? "#{total} findings" : "#{total} findings (#{keys} distinct)"
50
+
28
51
  def mismatch
29
52
  recorded = @baseline.packaging
30
53
  "baseline #{@baseline.path} was recorded with --package-by #{recorded}, but this run " \
@@ -33,13 +56,15 @@ class Hashira::CI::Ratchet
33
56
 
34
57
  def edges = @graph.edges.map(&:to_s)
35
58
 
36
- def digests = @findings.map(&:signature).uniq.sort
59
+ def scored
60
+ @scored ||= @findings.group_by(&:signature).sort.to_h { |key, group| [key, group.filter_map(&:magnitude).max] }
61
+ end
37
62
 
38
63
  def drift = Hashira::CI::Diff.new(added: fresh, removed: @baseline.edges - edges)
39
64
 
40
65
  def fresh = @graph.edges.reject { @baseline.edges.include?(it.to_s) }
41
66
 
42
67
  def delta
43
- Hashira::CI::Diff.between(digests, @baseline.findings) if @baseline.findings?
68
+ Hashira::CI::Diff.between(scored, @baseline.findings) if @baseline.findings?
44
69
  end
45
70
  end
@@ -11,7 +11,7 @@ class Hashira::CI::RatchetReport
11
11
  return unchanged 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
@@ -20,9 +20,11 @@ class Hashira::CI::RatchetReport
20
20
 
21
21
  def unchanged
22
22
  @io.puts("Ratchet OK: #{@graph.edges.size} edges, #{@findings.size} findings, unchanged.")
23
- 0
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