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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -0
- data/README.md +119 -53
- 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 +12 -18
- 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/text.rb +24 -9
- data/lib/hashira/report/view.rb +4 -1
- data/lib/hashira/trees.rb +22 -0
- data/lib/hashira/version.rb +1 -1
- data/lib/hashira.rb +6 -0
- metadata +8 -1
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,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
|
-
|
|
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).
|
|
@@ -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
|
|
359
|
-
|
|
360
|
-
controllers/orders/refunds_controller.rb
|
|
361
|
-
controllers/orders/returns_controller.rb
|
|
362
|
-
models/invoice.rb
|
|
363
|
-
models/shipping/label.rb
|
|
364
|
-
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
|
|
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
|
|
470
|
-
|
|
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 =
|
|
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
|
data/lib/hashira/ci/ratchet.rb
CHANGED
|
@@ -1,30 +1,53 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Hashira::CI::Ratchet
|
|
4
|
-
def initialize(graph, findings,
|
|
4
|
+
def initialize(graph, findings, baseline, io: $stdout)
|
|
5
5
|
@graph = graph
|
|
6
6
|
@findings = findings
|
|
7
|
-
@baseline =
|
|
7
|
+
@baseline = baseline
|
|
8
8
|
@io = io
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def update
|
|
12
|
-
@baseline.write(edges,
|
|
13
|
-
@io.puts("Baseline updated: #{edges.size} edges, #{
|
|
14
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|