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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a0b871ab3e0bf096a2752a48a4c3776d2dbe2ee0cae33b2df8af89649d0952ca
4
- data.tar.gz: b1058810a8d965e1010044691d1b11571f21b077581f5e435d40b0d263f133b9
3
+ metadata.gz: 137dafce03e707a097cf684620fbd9c9efc8989347e77b88c13da103102f5da5
4
+ data.tar.gz: e0ab59d2e1909042c991764b5384217601aaf42b036bd6916042efc2107e0200
5
5
  SHA512:
6
- metadata.gz: 24d9aae54c58e39a54799efdf9749f501000162c8ea8f9a38e02523fb6ee3122a98daf6591cfaf7816f3a2cd66f20218c08ff27579bc1b58a269e4ce7bddda92
7
- data.tar.gz: 158bf3af24e081df3483e3d124833f6943404cfdab6ca1f2f7235115a400cfb1c48f885fb16b290fc529ebc1556c8eb5ed97119f635c9869ae881b44be51c599
6
+ metadata.gz: 2c4895c6d045e05e610fda4c51ce763e0ef876049d933dbd4a3d9f99ac24c8648583a4a3f39e5c2baadfae263736914ba2a428781024ead0410b2d968585b8b8
7
+ data.tar.gz: dec6a978383a76ad57fdf34c98b5ea4b571775d44baafc0f5c02c3bbe75b7a56e274609c1586fc77af94e93f65f501579a5b6fa09bfd4db3952bb3cb412c153b
data/CHANGELOG.md CHANGED
@@ -5,6 +5,112 @@ 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
+
46
+ ## [0.7.0] - 2026-08-10
47
+
48
+ ### Added
49
+
50
+ - **The ratchet compares magnitudes, not just identities.** The baseline now
51
+ records a value beside each finding's signature — cognitive complexity,
52
+ clone-cluster mass — so a baselined method that gets measurably worse fails
53
+ the build instead of hiding behind set membership. On a legacy codebase this
54
+ is the case the ratchet exists for: everything hot is already baselined on
55
+ day one.
56
+ - **Exit codes stop meaning six different things.** 0 clean, 1 findings or a
57
+ caught regression, 2 misuse, 3 an improvement the baseline has not recorded,
58
+ 70 internal error. Failing on 1 while treating 3 as a nudge blocks
59
+ regressions without blocking progress. Unexpected exceptions now print the
60
+ class, message, origin frame, and where to report — not a backtrace.
61
+ - **The report says what produced it.** The heading names the packaging mode,
62
+ files Ruby itself rejects are counted and named on stderr instead of
63
+ contributing half-parsed trees silently, and a terminal run shows a sign of
64
+ life before the parse and a timing line after (never when stderr is not a
65
+ tty — stdout stays byte-identical).
66
+ - `--top N` caps every list at once; the package table and findings list gain
67
+ a default cap of 25 with a note saying what was withheld. `--json` is never
68
+ capped.
69
+ - `--compact` emits `--json` on one line instead of pretty-printed
70
+ indentation, and `--json` now opens with schema version, packaging, targets,
71
+ and file count.
72
+
73
+ ### Fixed
74
+
75
+ - **Green no longer means unchecked.** `--fail-on ""` armed nothing and
76
+ passed; `--fail-on cycles --skip coupling` switched off the only analyzer
77
+ that finds cycles and announced there were none; a directory with no Ruby
78
+ files was congratulated on its healthy structure. All three now fail
79
+ loudly.
80
+ - **The baseline guards the whole scope it was recorded under.** A baseline
81
+ recorded over four analyzers, compared against a run with `--skip smells`,
82
+ reported every smell finding as an improvement and suggested locking it in.
83
+ Schema 4 records analyzers and target directories, and the ratchet refuses
84
+ a mismatched run the same way the packaging guard already did.
85
+ - Churn runs `git -C <directory>` instead of reading the working directory,
86
+ so analyzing a repo from anywhere else no longer zeroes every count and
87
+ silently reorders the hotspot queue. A run with no history says so; an
88
+ unreadable baseline is a one-line error, not nine frames of Ruby.
89
+ - Five CLI misreadings: a file argument is named as a file (with the
90
+ directory to try), duplicate directories are deduplicated by realpath, a
91
+ gem whose lib holds only loose files is accepted, a value flag given twice
92
+ is not "unknown", and a diagram whose analyzer is skipped is refused
93
+ instead of drawn anyway.
94
+ - Diagrams stop losing packages: mermaid/dot ids are generated so `my-pkg`
95
+ and `my_pkg` no longer merge (and a package named `end` no longer breaks
96
+ the grammar), and isolated packages appear instead of vanishing.
97
+ - `Gate FAILED` names the kinds that actually fired, worst first, mirrored to
98
+ stderr; `--update-baseline` and `--ratchet` no longer report contradictory
99
+ totals for the same run.
100
+ - Tables size their columns to their contents: long names clip in the middle
101
+ instead of pushing rows into ribbons, numeric columns right-align, and
102
+ trailing whitespace is gone.
103
+ - An edgeless package prints "—" and sorts last instead of claiming I=0.00
104
+ beside genuine foundations.
105
+
106
+ ### Changed
107
+
108
+ - The four house cops and shared style defaults moved to the published
109
+ `rubocop-kata` gem; `.rubocop.yml` keeps only project-specific config.
110
+ - Docs: the `--fail-on` shorthands (`cycles`, `sdp`, `dupe`) are documented,
111
+ and a bare `hashira` in a Rails root notes once on stderr that `hashira app`
112
+ reads the application.
113
+
8
114
  ## [0.6.0] - 2026-08-05
9
115
 
10
116
  ### 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,52 @@ 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
+ ### 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
+
508
+ ### Exit codes
509
+
510
+ A build step should be able to tell a regression from a typo without grepping
511
+ English, so the codes are distinct:
512
+
513
+ | code | meaning |
514
+ | ---- | ------- |
515
+ | `0` | clean — nothing found, or nothing worse than the baseline |
516
+ | `1` | findings, or the ratchet found a regression |
517
+ | `2` | misuse — bad flags, missing directory, unusable baseline |
518
+ | `3` | an improvement the baseline has not recorded yet |
519
+ | `70` | internal error, worth reporting |
520
+
521
+ `3` is the one worth wiring specially: it means the code got better and only the
522
+ baseline is stale. Failing on `1` while treating `3` as a nudge lets a build
523
+ block regressions without blocking progress.
524
+
442
525
  ### Accepting by design
443
526
 
444
527
  Anything deliberate goes in the baseline with a reason. It leaves reports and
@@ -466,12 +549,24 @@ sentence turns every exception into a decision somebody reviewed.
466
549
  ## Other formats
467
550
 
468
551
  ```sh
469
- hashira --json # machine format: findings (with digests), accepted, packages,
470
- # edges, complexity, duplication, hotspots
552
+ hashira --json # machine format, never capped by --top
553
+ hashira --json --compact # the same on one line, for piping
471
554
  hashira --format dot # Graphviz digraph
472
555
  hashira --format mermaid # Mermaid diagram
473
556
  ```
474
557
 
558
+ `--json` opens with what produced it — `version` (the schema, bumped when the
559
+ shape changes), `packaging`, `targets`, `files` — then `findings` (each with its
560
+ `digest`), `accepted`, `packages`, `edges`, `folds` (single-type classes joined
561
+ to a base or domain, `{from, to, via}`), `complexity`, `duplication`, and
562
+ `hotspots`. A package with no edges at all reports `"i": null` rather than
563
+ pretending 0/0 is maximally stable.
564
+
565
+ Both diagrams declare every package before the arrows, so a package nothing
566
+ depends on still appears. Mermaid node ids are generated (`p0`, `p1`, …) with
567
+ the real name in the label, so `my-pkg` and `my_pkg` stay two nodes and a
568
+ package called `end` does not break the graph.
569
+
475
570
  ## Why cognitive complexity
476
571
 
477
572
  The older Ruby complexity metrics charge roughly one point per message send and
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
@@ -2,15 +2,19 @@
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
- def to_h = super.compact
17
+ def to_h = super.merge(detail: detail&.to_h).compact
14
18
  end
15
19
  end
16
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,17 +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"
5
4
  SITES_THAT_DRIFT_APART = 2
6
5
 
7
- def self.scan = new(tally(`#{LOG}`))
8
-
9
- def self.tally(output) = output.split("\n").map(&:strip).reject(&:empty?).tally
6
+ def self.build(directory) = new(Hashira::GitLog.new(directory).counts)
10
7
 
11
8
  def initialize(counts)
12
9
  @counts = counts
13
10
  end
14
11
 
12
+ def history? = @counts.any?
13
+
15
14
  def hits(file) = @counts.select { |path, _| path.end_with?(file) }.values.max || 0
16
15
 
17
16
  def hot?(members) = changing(members) >= SITES_THAT_DRIFT_APART
@@ -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,19 +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)
24
- return new([]) unless path && File.exist?(path)
25
- new(JSON.parse(File.read(path)).fetch("accepted", []))
26
- end
23
+ def self.build(path) = new(Hashira::CI::Baseline.new(path).accepted)
27
24
 
28
- def initialize(entries)
29
- @entries = entries.map { Entry.from(it) }
25
+ def initialize(list)
26
+ @list = list
30
27
  end
31
28
 
32
- def entries = @entries.map(&:to_h)
29
+ def entries = records.map(&:to_h)
33
30
 
34
31
  def screen(findings)
35
32
  accepted, live = findings.map { [it, reason(it)] }.partition(&:last)
@@ -38,7 +35,9 @@ class Hashira::CI::Accepted
38
35
 
39
36
  private
40
37
 
38
+ def records = @_records ||= @list.map { Entry.build(it) }
39
+
41
40
  def reason(finding)
42
- @entries.find { it.matches?(finding) }&.label
41
+ records.find { it.matches?(finding) }&.label
43
42
  end
44
43
  end
@@ -3,26 +3,39 @@
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)) : {})
9
-
10
- def initialize(path, recorded)
8
+ def initialize(path, analyzers: [], targets: [])
11
9
  @path = path
12
- @recorded = recorded
10
+ @analyzers = analyzers
11
+ @targets = targets
13
12
  end
14
13
 
15
14
  attr_reader :path
16
15
 
17
16
  def exist? = File.exist?(@path)
18
17
 
19
- 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", [])
25
+
26
+ def findings = keyed(recorded.fetch("findings", {}))
27
+
28
+ def findings? = recorded.key?("findings")
29
+
30
+ def accepted = recorded.fetch("accepted", [])
31
+
32
+ def packaging = recorded.fetch("packaging", "folder")
20
33
 
21
- def findings = @recorded.fetch("findings", [])
34
+ def analyzers = recorded.fetch("analyzers", wanted[:analyzers])
22
35
 
23
- def findings? = @recorded.key?("findings")
36
+ def targets = recorded.fetch("targets", wanted[:targets])
24
37
 
25
- def packaging = @recorded.fetch("packaging", "folder")
38
+ def wanted = scope.to_h
26
39
 
27
40
  def write(edges, findings, packaging:)
28
41
  File.write(@path, JSON.pretty_generate(payload(edges, findings, packaging)) << "\n")
@@ -30,9 +43,23 @@ class Hashira::CI::Baseline
30
43
 
31
44
  private
32
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
+
33
60
  def payload(edges, findings, packaging)
34
- base = { version: SCHEMA_VERSION, packaging:, edges:, findings: }
35
- accepted = Hashira::CI::Accepted.new(@recorded.fetch("accepted", [])).entries
36
- 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)
37
64
  end
38
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
@@ -3,14 +3,10 @@
3
3
  module Hashira
4
4
  module CI
5
5
  Diff =
6
- Data.define(:added, :removed) do
7
- def self.between(current, recorded)
8
- new(added: current - recorded, removed: recorded - current)
9
- end
10
-
11
- def empty? = added.empty? && removed.empty?
12
-
13
- def worse? = !added.empty?
6
+ Data.define(:added, :removed, :worsened) do
7
+ def initialize(worsened: [], **) = super
8
+ def empty? = added.empty? && removed.empty? && worsened.empty?
9
+ def worse? = !added.empty? || !worsened.empty?
14
10
  end
15
11
  end
16
12
  end