hashira 0.5.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +98 -0
- data/README.md +131 -56
- data/lib/hashira/analysis/finding.rb +4 -0
- data/lib/hashira/churn.rb +10 -2
- data/lib/hashira/ci/accepted.rb +1 -4
- data/lib/hashira/ci/baseline.rb +28 -5
- data/lib/hashira/ci/diff.rb +15 -4
- data/lib/hashira/ci/finding_diff_report.rb +10 -0
- data/lib/hashira/ci/gate.rb +10 -2
- data/lib/hashira/ci/improvement.rb +1 -1
- data/lib/hashira/ci/ratchet.rb +33 -8
- data/lib/hashira/ci/ratchet_report.rb +4 -2
- data/lib/hashira/ci/scope.rb +11 -0
- data/lib/hashira/ci/status.rb +7 -0
- data/lib/hashira/cli/arguments.rb +6 -1
- data/lib/hashira/cli/fail_on.rb +12 -2
- data/lib/hashira/cli/flags.rb +13 -0
- data/lib/hashira/cli/needs.rb +34 -0
- data/lib/hashira/cli/options.rb +14 -3
- data/lib/hashira/cli/run.rb +32 -5
- data/lib/hashira/cli/top.rb +13 -0
- data/lib/hashira/cli/usage.rb +5 -1
- data/lib/hashira/cli.rb +24 -5
- data/lib/hashira/coupling/metric.rb +7 -1
- data/lib/hashira/diagram/dot.rb +9 -6
- data/lib/hashira/diagram/mermaid.rb +11 -8
- data/lib/hashira/diagram/source.rb +5 -2
- data/lib/hashira/pipeline.rb +15 -19
- data/lib/hashira/project.rb +19 -5
- data/lib/hashira/report/columns.rb +48 -0
- data/lib/hashira/report/complexity_table.rb +12 -16
- data/lib/hashira/report/dependency_map.rb +1 -0
- data/lib/hashira/report/hotspot_table.rb +6 -14
- data/lib/hashira/report/json.rb +9 -2
- data/lib/hashira/report/metrics_table.rb +19 -20
- data/lib/hashira/report/notices.rb +37 -0
- data/lib/hashira/report/phrases.rb +2 -0
- data/lib/hashira/report/smell_phrases.rb +6 -0
- data/lib/hashira/report/text.rb +24 -9
- data/lib/hashira/report/view.rb +4 -1
- data/lib/hashira/smells/boundary_sprawl.rb +43 -0
- data/lib/hashira/smells/census.rb +4 -1
- data/lib/hashira/smells/contexts.rb +1 -1
- data/lib/hashira/smells/feature_envy.rb +3 -1
- data/lib/hashira/smells/foreign.rb +131 -0
- data/lib/hashira/smells/ownership.rb +55 -0
- data/lib/hashira/smells/report.rb +10 -2
- data/lib/hashira/trees.rb +22 -0
- data/lib/hashira/version.rb +1 -1
- data/lib/hashira.rb +6 -0
- metadata +17 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 53480df33daebb25ecac87a601cad36cc6a3d8657a5ccb3cd094c807e1be2c15
|
|
4
|
+
data.tar.gz: 6034a5afdfa48254a48b989405aed85e8e1d6c4f82dea35649d714edd8ed38ee
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 915f8ea2649037c29a4971d4ebc3df94d38c6a976fd4f0792e3e2c80e3c54d23b776a3a511c0dd5fa7552cbbd6463282e9fcd31ba5e510c2a87d82aafbfb6310
|
|
7
|
+
data.tar.gz: 9b17e6bbc1625f7ed54d02eeb7ba8f345451c931e24cf39bd057bc8a06e92d11167d35a0bd1b883b75750aa533fdb39a1513dcb4359929c86d2cb0e21e285cdd
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,104 @@ 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
|
+
|
|
76
|
+
## [0.6.0] - 2026-08-05
|
|
77
|
+
|
|
78
|
+
### Changed
|
|
79
|
+
|
|
80
|
+
- **feature_envy now respects ownership.** The classic remedy — move the
|
|
81
|
+
method onto the envied object — assumes the envied class is yours to edit.
|
|
82
|
+
The smell now stays quiet when the method body itself proves otherwise:
|
|
83
|
+
the name is type-guarded only against constants the analyzed code never
|
|
84
|
+
defines (`node.is_a?(Prism::CallNode)`); every call on it is a literal-key
|
|
85
|
+
read (`msg["id"]`, `values_at`, `dig`, `key?` — wire data, not an object);
|
|
86
|
+
it was built from a literal in the method itself (`options = { ... }`); it
|
|
87
|
+
was derived by calling a foreign name or foreign constant
|
|
88
|
+
(`value = node.unescaped`, `app = Rails.application`); it was rescued from
|
|
89
|
+
a foreign or implied error class (`rescue => e`); the method dispatches on
|
|
90
|
+
it through a constant table keyed entirely by foreign classes
|
|
91
|
+
(`TABLE[node.class]`); or the method is a stateless converter whose last
|
|
92
|
+
act is building a typed object. Guards against types the codebase does
|
|
93
|
+
define — including by suffix, and including subclasses of gem classes —
|
|
94
|
+
still flag, as do rescues from error classes the codebase defines and
|
|
95
|
+
tables keyed by owned classes, so anemic-model envy in Rails apps is
|
|
96
|
+
untouched.
|
|
97
|
+
|
|
98
|
+
### Added
|
|
99
|
+
|
|
100
|
+
- **boundary_sprawl** — the aggregate the suppression above makes room for:
|
|
101
|
+
when 12+ methods across 3+ files each type-guard against the same foreign
|
|
102
|
+
root (`Prism`, `ActiveRecord`, ...), one finding proposes fronting that
|
|
103
|
+
boundary with an adapter. One method inspecting a foreign type is a fact of
|
|
104
|
+
life; a codebase-wide sprawl of them is a missing seam.
|
|
105
|
+
|
|
8
106
|
## [0.5.1] - 2026-08-05
|
|
9
107
|
|
|
10
108
|
### Fixed
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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).
|
|
81
|
-
|
|
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 (
|
|
86
|
-
|
|
87
|
-
package
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
(root)
|
|
99
|
-
cli
|
|
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(
|
|
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
|
|
113
|
-
|
|
114
|
-
Hashira::Coupling::NamespacePrefix#wrapper
|
|
115
|
-
Hashira::Analysis::Syntax#anchor
|
|
116
|
-
Hashira::Smells::Conditions#branches
|
|
117
|
-
Hashira::Coupling::Roster#admit
|
|
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
|
|
123
|
-
|
|
124
|
-
Hashira::Project
|
|
125
|
-
Hashira::
|
|
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
|
|
131
|
-
|
|
132
|
-
pipeline.rb
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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).
|
|
@@ -294,7 +309,7 @@ it does inside Ruby:
|
|
|
294
309
|
## Code smells
|
|
295
310
|
|
|
296
311
|
RuboCop counts lines and branches inside one method; design smells are about how
|
|
297
|
-
objects treat each other, and no line count sees that. hashira ships the
|
|
312
|
+
objects treat each other, and no line count sees that. hashira ships the twelve
|
|
298
313
|
smells that carry that design signal — the object-relationship kinds, not the
|
|
299
314
|
naming, size, and style checks a linter already argues about — read from the
|
|
300
315
|
same parse trees the other analyzers already built:
|
|
@@ -310,7 +325,16 @@ Findings (2):
|
|
|
310
325
|
What each one catches:
|
|
311
326
|
|
|
312
327
|
- **feature_envy** — a method refers to another object more than to itself; the
|
|
313
|
-
behavior probably belongs over there.
|
|
328
|
+
behavior probably belongs over there. Stays quiet when the method's own body
|
|
329
|
+
proves the envied thing is foreign — type-guarded (or table-dispatched) only
|
|
330
|
+
against constants the codebase never defines, read purely through literal
|
|
331
|
+
keys (`msg["id"]`), built from a literal or derived from a foreign call in
|
|
332
|
+
the method itself, rescued from a foreign error class, or consumed by a
|
|
333
|
+
stateless converter that ends by building a typed object — because "move
|
|
334
|
+
the method" needs a destination you own.
|
|
335
|
+
- **boundary_sprawl** — 12+ methods across 3+ files each type-guard against the
|
|
336
|
+
same foreign root (`Prism`, `ActiveRecord`, ...). One method inspecting a
|
|
337
|
+
foreign type is a fact of life; a sprawl of them is a missing adapter.
|
|
314
338
|
- **utility_function** — a public instance method that touches no instance state;
|
|
315
339
|
it isn't really a method of this class. Private stateless helpers are fine, and
|
|
316
340
|
`module_function` modules are exempt — that's what they're for.
|
|
@@ -334,7 +358,7 @@ What each one catches:
|
|
|
334
358
|
cheapest type there is.
|
|
335
359
|
|
|
336
360
|
Smell findings gate and ratchet like every other kind — `--fail-on smells` covers
|
|
337
|
-
all
|
|
361
|
+
all twelve, or name one (`--fail-on feature_envy`); `--skip smells` drops the
|
|
338
362
|
analyzer entirely.
|
|
339
363
|
|
|
340
364
|
## Hotspots
|
|
@@ -346,13 +370,13 @@ changes — because cost you never pay isn't worth paying down:
|
|
|
346
370
|
```console
|
|
347
371
|
Hotspots — cost × churn (where refactoring pays the most):
|
|
348
372
|
|
|
349
|
-
file
|
|
350
|
-
|
|
351
|
-
controllers/orders/refunds_controller.rb
|
|
352
|
-
controllers/orders/returns_controller.rb
|
|
353
|
-
models/invoice.rb
|
|
354
|
-
models/shipping/label.rb
|
|
355
|
-
controllers/orders_controller.rb
|
|
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
|
|
356
380
|
```
|
|
357
381
|
|
|
358
382
|
Read it as a work queue: the top row is where a day of refactoring buys the most.
|
|
@@ -403,6 +427,12 @@ at all. It only works on a codebase that starts clean.
|
|
|
403
427
|
hashira --fail-on cycles,sdp,mixed_audience,wide_edge,roll_call,smells # any subset
|
|
404
428
|
```
|
|
405
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
|
+
|
|
406
436
|
The ratchet is the one you can adopt today. Commit a baseline of what's true now
|
|
407
437
|
— which edges exist, which findings stand — and the build fails when that set
|
|
408
438
|
*grows*. It never asks whether the code is good, only whether this commit made it
|
|
@@ -414,6 +444,22 @@ hashira --ratchet # fail if either set grew
|
|
|
414
444
|
hashira --ratchet --baseline PATH
|
|
415
445
|
```
|
|
416
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
|
+
|
|
417
463
|
A regression prints in full, with the evidence that introduced it:
|
|
418
464
|
|
|
419
465
|
```console
|
|
@@ -430,6 +476,23 @@ record the decision: update the baseline, or accept it with a reason.
|
|
|
430
476
|
Improvements fail the build too, and say so cheerfully — an unrecorded gain is one
|
|
431
477
|
the next commit can quietly undo. Re-run `--update-baseline` to lock it in.
|
|
432
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
|
+
|
|
433
496
|
### Accepting by design
|
|
434
497
|
|
|
435
498
|
Anything deliberate goes in the baseline with a reason. It leaves reports and
|
|
@@ -457,12 +520,24 @@ sentence turns every exception into a decision somebody reviewed.
|
|
|
457
520
|
## Other formats
|
|
458
521
|
|
|
459
522
|
```sh
|
|
460
|
-
hashira --json # machine format
|
|
461
|
-
|
|
523
|
+
hashira --json # machine format, never capped by --top
|
|
524
|
+
hashira --json --compact # the same on one line, for piping
|
|
462
525
|
hashira --format dot # Graphviz digraph
|
|
463
526
|
hashira --format mermaid # Mermaid diagram
|
|
464
527
|
```
|
|
465
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
|
+
|
|
466
541
|
## Why cognitive complexity
|
|
467
542
|
|
|
468
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 =
|
|
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(
|
|
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
|
data/lib/hashira/ci/accepted.rb
CHANGED
|
@@ -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) }
|
data/lib/hashira/ci/baseline.rb
CHANGED
|
@@ -3,13 +3,28 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
class Hashira::CI::Baseline
|
|
6
|
-
SCHEMA_VERSION =
|
|
6
|
+
SCHEMA_VERSION = 4
|
|
7
7
|
|
|
8
|
-
def self.load(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
|
data/lib/hashira/ci/diff.rb
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
|
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
|
data/lib/hashira/ci/gate.rb
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
12
|
+
@io.puts("Lock it in: re-run this command with --update-baseline")
|
|
13
13
|
end
|
|
14
14
|
end
|