hashira 0.1.0 → 0.2.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/README.md +327 -32
  4. data/lib/hashira/analysis/census.rb +0 -2
  5. data/lib/hashira/analysis/cycle_findings.rb +1 -1
  6. data/lib/hashira/analysis/cycle_search.rb +1 -1
  7. data/lib/hashira/analysis/definitions.rb +1 -1
  8. data/lib/hashira/analysis/finding.rb +6 -2
  9. data/lib/hashira/analysis/graph.rb +3 -3
  10. data/lib/hashira/analysis/node_walk.rb +7 -1
  11. data/lib/hashira/analysis/references.rb +2 -2
  12. data/lib/hashira/analysis/sdp_check.rb +1 -1
  13. data/lib/hashira/analysis/type_walk.rb +1 -1
  14. data/lib/hashira/churn.rb +22 -0
  15. data/lib/hashira/ci/accepted.rb +11 -7
  16. data/lib/hashira/ci/baseline.rb +40 -0
  17. data/lib/hashira/ci/diff.rb +15 -0
  18. data/lib/hashira/ci/edge_diff_report.rb +4 -27
  19. data/lib/hashira/ci/finding_diff_report.rb +26 -0
  20. data/lib/hashira/ci/gate.rb +7 -5
  21. data/lib/hashira/ci/improvement.rb +19 -0
  22. data/lib/hashira/ci/ratchet.rb +16 -20
  23. data/lib/hashira/ci/ratchet_report.rb +42 -0
  24. data/lib/hashira/cli/command_line.rb +12 -6
  25. data/lib/hashira/cli/fail_on.rb +4 -2
  26. data/lib/hashira/cli/options.rb +1 -1
  27. data/lib/hashira/cli/run.rb +9 -3
  28. data/lib/hashira/cli/skip.rb +27 -0
  29. data/lib/hashira/cli/usage.rb +10 -5
  30. data/lib/hashira/cli.rb +1 -1
  31. data/lib/hashira/complexity/analyzer.rb +45 -0
  32. data/lib/hashira/complexity/boolean_run.rb +22 -0
  33. data/lib/hashira/complexity/cognitive_score.rb +72 -0
  34. data/lib/hashira/complexity/if_chain.rb +45 -0
  35. data/lib/hashira/complexity/method_finding.rb +52 -0
  36. data/lib/hashira/complexity/method_score.rb +17 -0
  37. data/lib/hashira/complexity/rescue_scan.rb +28 -0
  38. data/lib/hashira/complexity/rollup.rb +22 -0
  39. data/lib/hashira/duplication/analyzer.rb +21 -0
  40. data/lib/hashira/duplication/cluster.rb +24 -0
  41. data/lib/hashira/duplication/clusterer.rb +44 -0
  42. data/lib/hashira/duplication/delta.rb +39 -0
  43. data/lib/hashira/duplication/duplication_finding.rb +30 -0
  44. data/lib/hashira/duplication/extractor.rb +31 -0
  45. data/lib/hashira/duplication/fragment.rb +42 -0
  46. data/lib/hashira/duplication/grouping.rb +24 -0
  47. data/lib/hashira/duplication/index.rb +35 -0
  48. data/lib/hashira/duplication/maximal.rb +21 -0
  49. data/lib/hashira/duplication/near_miss.rb +33 -0
  50. data/lib/hashira/duplication/sequence.rb +34 -0
  51. data/lib/hashira/duplication/similarity.rb +50 -0
  52. data/lib/hashira/duplication/union_find.rb +21 -0
  53. data/lib/hashira/duplication/variance.rb +57 -0
  54. data/lib/hashira/hotspots/file_cost.rb +19 -0
  55. data/lib/hashira/hotspots/rollup.rb +33 -0
  56. data/lib/hashira/pipeline.rb +28 -6
  57. data/lib/hashira/project.rb +10 -7
  58. data/lib/hashira/report/complexity_table.rb +40 -0
  59. data/lib/hashira/report/dependency_map.rb +8 -5
  60. data/lib/hashira/report/finding_lines.rb +1 -1
  61. data/lib/hashira/report/graph_payload.rb +29 -0
  62. data/lib/hashira/report/hotspot_table.rb +41 -0
  63. data/lib/hashira/report/json.rb +24 -17
  64. data/lib/hashira/report/metrics_table.rb +1 -2
  65. data/lib/hashira/report/text.rb +24 -14
  66. data/lib/hashira/report/view.rb +7 -0
  67. data/lib/hashira/version.rb +1 -1
  68. data/lib/hashira.rb +36 -0
  69. metadata +44 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9641685509b4a0fa17ee87a9e29f56906f7ea487622185f935b3a544c9f214bf
4
- data.tar.gz: a2e3e7da103a76e5dd080402c15eb8a3b83ebae1b1d5047303e0a33971fcc203
3
+ metadata.gz: 8e51a640b2b2db8106e09f6679c9cd8687b834f8132db254b71ab6eb0c53c75c
4
+ data.tar.gz: 3fc2559e8d15081d4d201dcd24496939ceff92ce6c21fe33248af4871e03755c
5
5
  SHA512:
6
- metadata.gz: bef9ad9240fdfc1631211b46f87a7d1da6cbb826f4b5b0fabcd8957432915229d85f1d68463fb2c0fef67d8a61d561acadbcba8a14db87b3c8d8b55036d36f7b
7
- data.tar.gz: e8ac4c4da5da2a53415bb16e64599387ccf815e679e767c8b16fcd8200ce2f4f3bcae5c48439d9d480388b657e7c02b16d4c1e258b9a62037f471a40518da278
6
+ metadata.gz: 7c676c8b8764ad0ebab7384a0cd72984efd979853842f3a51c28bb8e079815088f4d3e0fc1569442d4d46371d3cbb700364730cab85f176726c0b40acc6e45ba
7
+ data.tar.gz: ea97955b88d6b57c52f980662eec0c0fb690dabac64d47734bf7ad3e94efbb4c18ced3483e8f8afb65833b807aaff248f4bcd37995875a24ea1031c244dc7ccc
data/CHANGELOG.md CHANGED
@@ -5,6 +5,58 @@ 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.2.0] - 2026-07-25
9
+
10
+ ### Added
11
+
12
+ - Cognitive-complexity analyzer (AST-only): per-method scores
13
+ ranked by readability rather than by call count, the call count shown beside
14
+ each score, and a per-class rollup that survives extract-method.
15
+ - `complexity` findings for methods over the threshold, each with a per-source-line
16
+ breakdown and a suggested refactoring; gate on them with `--fail-on complexity`.
17
+ - Duplication analyzer (structural clone detection, AST-only): statement
18
+ windows from one statement up, whole methods, `when` arms and `rescue` clauses,
19
+ matched exactly and by
20
+ near-miss (Type-3 clones, via an inverted index over rare token types and an LCS
21
+ check), unioned into clusters rather than pairs, each reduced to its maximal
22
+ non-overlapping sites. Runs of identically shaped statements — require blocks,
23
+ routes files — are read as lists, not clones. Window length is capped, so the
24
+ candidate count stays linear in the length of a statement sequence. A match
25
+ whose sites share no name at all is held to a much higher mass floor: identical
26
+ trees collide by coincidence, and structure alone is thin evidence.
27
+ - `duplication` findings that classify what varies across a cluster (literals,
28
+ receiver/message, constant, or control flow) into a refactoring, with a git-churn
29
+ overlay when available; gate on them with `--fail-on duplication`.
30
+ - Hotspot rollup: the per-file join of cognitive complexity, the mass of the clones
31
+ a file carries, and git churn, ranked by `(cognitive + duplication) × churn` — the
32
+ files that cost the most and change the most, worst first. A ranked work queue
33
+ rather than a letter grade. Churn floors at one, so a repo with no git history
34
+ ranks by cost alone instead of collapsing to zero.
35
+ - `--skip` drops any analyzer (`coupling`, `complexity`, `duplication`); all run by
36
+ default. Complexity, duplication and hotspot metrics are included in `--json`
37
+ output; the rollup is omitted when both analyzers feeding it are skipped.
38
+
39
+ - `--ratchet` now guards findings as well as edges. The baseline (schema v2) records
40
+ a signature per finding, and the build fails when the set grows — printing each new
41
+ finding in full, with its evidence. Improvements fail too, and say so, because an
42
+ unrecorded gain is one the next commit can undo. Measures direction, not level: no
43
+ score to chase, and no need to start from a clean codebase.
44
+ - Findings carry a positionless `digest` where they have one. A clone is identified
45
+ by the shape of its canonical fragment rather than by `file:line`, so both a
46
+ baseline entry and an `accepted` entry survive the lines above it moving.
47
+
48
+ ### Changed
49
+
50
+ - The three analyzers share a single parse of the source, so running them together
51
+ costs no more than parsing once; complexity and duplication are computed lazily,
52
+ so skipping one costs nothing.
53
+
54
+ ### Fixed
55
+
56
+ - An `accepted` entry for a duplication finding matched on `file:line`, so it stopped
57
+ matching — and the finding came back — as soon as anything above the clone moved.
58
+ Clones are now accepted by `digest`.
59
+
8
60
  ## [0.1.0] - 2026-07-19
9
61
 
10
62
  ### Added
@@ -18,4 +70,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
18
70
  - Output formats: text, JSON, Graphviz dot, Mermaid (`--format`, `--json`).
19
71
  - `--help` and `--version`.
20
72
 
73
+ [0.2.0]: https://github.com/giacope/hashira/releases/tag/v0.2.0
21
74
  [0.1.0]: https://github.com/giacope/hashira/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,35 +1,140 @@
1
1
  # hashira
2
2
 
3
- :classical_building: Package coupling metrics for Ruby, read from the AST via Prism.
3
+ 🏛️ **Coupling, cognitive-complexity and duplication metrics for Ruby, read straight from the AST via [Prism](https://github.com/ruby/prism).**
4
+
5
+ hashira tells you which file to open first. It reads a Ruby codebase three ways —
6
+ which packages depend on which, how hard each method is to follow, and what has been
7
+ copy-pasted — then ranks every file by what it costs you against how often you
8
+ actually change it. Every finding names the file and line behind it, and a committed
9
+ baseline ratchets the whole set in CI, so the build fails on what *this commit* made
10
+ worse rather than on a score nobody agrees on.
11
+
12
+ - **Zero runtime dependencies.** Prism ships with Ruby 3.4+; nothing else to install.
13
+ - **Reads the AST, never strings.** Every signal comes from the parse tree. Comments and string literals are invisible.
14
+ - **Three analyzers, opt-out.** Coupling, complexity, and duplication run together by default; `--skip` drops any.
15
+ - **Ranked, not graded.** The hotspot rollup orders files by cost × churn — a work queue, not a letter that reads the same on every healthy repo.
16
+ - **Findings, not just a dashboard.** Cycles, SDP violations, complexity hotspots, and clone clusters — each backed by file-level evidence and a plain-language fix.
17
+ - **Made for CI.** Ratchet edges *and* findings against a baseline, so no clean slate is required. Or gate outright with `--fail-on`.
18
+
19
+ Let `billing` and `shipping` start referencing each other, and hashira points to
20
+ the cycle and to the cheapest edge to cut:
21
+
22
+ ```console
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
28
+
29
+ Findings (2):
30
+ cycle: billing can reach itself: billing -> shipping -> billing — any change
31
+ may ripple back around. The lightest edge on this cycle is billing -> shipping (1 ref).
32
+ · billing/client.rb:3: Shipping::Rate
33
+ · shipping/rate.rb:3: Billing::Client
34
+ ```
4
35
 
5
- ## Installation
36
+ A healthy project reports `Findings (0): none ✓ — structure is healthy`.
6
37
 
7
- ```ruby
8
- gem "hashira"
38
+ ---
39
+
40
+ ## Contents
41
+
42
+ [Install](#install) · [Getting started](#getting-started) · [Coupling: how to read the numbers](#coupling-how-to-read-the-numbers) · [Cognitive complexity](#cognitive-complexity) · [Duplication](#duplication) · [Hotspots](#hotspots) · [How it works](#how-it-works) · [CI](#ci) · [Other formats](#other-formats) · [Why cognitive complexity](#why-cognitive-complexity) · [Why no A, D, or zones](#why-no-a-d-or-zones)
43
+
44
+ ## Install
45
+
46
+ hashira is a command-line tool. Install it globally:
47
+
48
+ ```sh
49
+ gem install hashira
9
50
  ```
10
51
 
11
- Requires Ruby 3.4+.
52
+ Or add it to a project and run it through Bundler:
12
53
 
13
- ## Getting Started
54
+ ```ruby
55
+ # Gemfile
56
+ gem "hashira", group: :development
57
+ ```
14
58
 
15
59
  ```sh
16
- hashira # auto-detects lib/<gem>
17
- hashira lib/myapp # or point it at a directory
18
- hashira app lib # or several — one shared graph
60
+ bundle install
61
+ bundle exec hashira
19
62
  ```
20
63
 
21
- You get a report like:
64
+ Requires Ruby 3.4 or newer.
22
65
 
66
+ ## Getting started
67
+
68
+ Point hashira at your code, or run it with no arguments to auto-detect `lib/<gem>`:
69
+
70
+ ```sh
71
+ hashira # auto-detects lib/<gem>
72
+ hashira lib/myapp # or point it at a directory
73
+ hashira app lib # or several — one shared graph
74
+ hashira --skip complexity,duplication # coupling only
75
+ hashira --skip coupling # complexity + duplication
23
76
  ```
77
+
78
+ The full text report is the coupling tables, the complexity tables, the hotspot
79
+ rollup, and the findings (which include any duplication clusters). Here it is on
80
+ hashira's own source:
81
+
82
+ ```console
83
+ $ hashira
84
+ Package (layer) metrics for lib/hashira (9 packages, 75 files)
85
+
24
86
  package TC Ca Ce I Cyc
25
87
  ----------------------------------------
26
- analysis 20 1 0 0.00 -
27
- report 7 2 0 0.00 -
28
- ci 4 1 2 0.67 -
88
+ analysis 14 3 0 0.00 -
89
+ diagram 3 1 0 0.00 -
90
+ hotspots 1 1 0 0.00 -
91
+ duplication 14 2 1 0.33 -
92
+ report 8 2 1 0.33 -
93
+ complexity 7 1 1 0.50 -
94
+ (root) 3 2 4 0.67 -
95
+ ci 8 1 2 0.67 -
29
96
  cli 6 0 4 1.00 -
97
+
98
+ Legend: TC total types, Ca afferent (incoming), Ce efferent (outgoing),
99
+ I=Ce/(Ce+Ca) instability (0=maximally stable, 1=maximally unstable)
100
+
101
+ Dependencies (DependsUpon(refs) -> | <- UsedBy):
102
+ (root) -> analysis(4), complexity(1), duplication(1), hotspots(1) <- ci, cli
103
+ duplication -> analysis(3) <- (root), report
104
+ ...
105
+
106
+ Cognitive complexity — worst methods (Cog = how hard to read, Calls = message sends):
107
+
108
+ method Cog Calls Loc
109
+ -------------------------------------------------------------
110
+ Hashira::Report::Text#print 3 7 report/text.rb:11
111
+ Hashira::Analysis::CycleSearch#cycle? 3 3 analysis/cycle_search.rb:19
112
+ Hashira::CLI::CommandLine#usage_options 3 5 cli/command_line.rb:20
113
+ Hashira::Duplication::Delta#kind 3 6 duplication/delta.rb:21
114
+ ...
115
+
116
+ Per-class rollup (Cog total survives extract-method; Peak is the worst method it hides):
117
+
118
+ class Cog Methods Peak
119
+ ------------------------------------------------------
120
+ Hashira::CLI::CommandLine 16 15 3
121
+ Hashira::Analysis::CycleSearch 8 5 3
122
+ ...
123
+
124
+ Hotspots — cost × churn (where refactoring pays the most):
125
+
126
+ file Cog Dup Churn Rank
127
+ -------------------------------------------------------------------------
128
+ cli/run.rb 1 36 2 74
129
+ cli/command_line.rb 16 0 3 48
130
+ pipeline.rb 7 0 3 21
131
+ ...
132
+
133
+ Findings (0):
134
+ none ✓ — structure is healthy
30
135
  ```
31
136
 
32
- ## How to Read the Numbers
137
+ ## Coupling: how to read the numbers
33
138
 
34
139
  Every folder under the target directory is a **package**. For each one:
35
140
 
@@ -38,54 +143,244 @@ Every folder under the target directory is a **package**. For each one:
38
143
  - **Ce** — how many packages it depends *upon* (efferent, outgoing).
39
144
  - **I** — instability, `Ce / (Ce + Ca)`, from 0 to 1.
40
145
 
41
- **I = 0**: everyone depends on it, it depends on no one a foundation, expensive to change. **I = 1**: nobody depends on it — free to change. Neither is good or bad on its own; a CLI layer *should* be at 1.00, a core domain layer near 0.00. The findings are about arrows pointing the wrong way:
146
+ **I = 0**: everyone depends on it, it depends on no one. That's a foundation,
147
+ expensive to change. **I = 1**: nobody depends on it, so it's free to change.
148
+ Neither is good or bad on its own; a CLI layer *should* sit at 1.00, a core
149
+ domain layer near 0.00. The findings are about arrows pointing the wrong way:
42
150
 
43
- - **SDP violation** — a stable package depends on a less stable one.
151
+ - **SDP violation** — a stable package depends on a less stable one, against the
152
+ Stable Dependencies Principle ("depend in the direction of stability"), one of
153
+ Robert C. Martin's [package principles](https://en.wikipedia.org/wiki/Package_principles).
44
154
  - **Cycle** — packages depending on each other in a loop.
45
155
 
46
- Each finding comes with file-level evidence; for cycles, the shortest cycle path and its lightest edge. What a finding means for your design is your call.
156
+ Each finding comes with file-level evidence; for cycles, the shortest cycle
157
+ path and its lightest edge. What a finding means for your design is your call.
158
+
159
+ ## Cognitive complexity
160
+
161
+ hashira scores every method with **cognitive complexity**, not an ABC or call-count
162
+ metric. The point is to rank methods by how hard they are to *read*, not how many
163
+ messages they send:
164
+
165
+ - **Cog** — the cognitive-complexity score. A flat sequence of calls costs nothing;
166
+ each level of nesting deepens the cost of what sits inside it; a `case` counts
167
+ once regardless of arms; a run of one boolean operator counts once, and mixing
168
+ `&&`/`||` costs more; `elsif`/`else` stay flat instead of compounding.
169
+ - **Calls** — the number of message sends, shown side by side. This is what
170
+ call-count metrics rank on; when Cog and Calls disagree, Cog is the honest one.
171
+ - **Per-class rollup** — the total complexity of a class and its method count. A
172
+ method-only score vanishes when you split one big method into five small ones;
173
+ the class total doesn't, so the rollup catches that dodge.
174
+
175
+ Methods over the threshold become `complexity` findings, each with the breakdown of
176
+ where the points came from and a suggested refactoring:
177
+
178
+ ```console
179
+ complexity: Shop::Checkout::Pricing#total — cognitive 10, 12 calls
180
+ (checkout/pricing.rb:4). flatten the branching — guard clauses, early returns, or polymorphism.
181
+ · if +8 (lines 6, 7, 8, 12)
182
+ · else +1 (line 9)
183
+ · boolean +1 (line 12)
184
+ ```
185
+
186
+ ## Duplication
187
+
188
+ Leave the same code copied across three files and hashira finds the whole family
189
+ in one finding — not three pairs — and tells you what varies:
190
+
191
+ ```console
192
+ Findings (1):
193
+ duplication: 3 similar fragments (mass 45) — differs only in literal values —
194
+ extract a method, pass them as arguments.
195
+ · reports/orders.rb:1-11
196
+ · reports/payouts.rb:1-11
197
+ · reports/refunds.rb:1-11
198
+ ```
199
+
200
+ It reads `.rb` only, so duplication that lives in templates is out of scope. What
201
+ it does inside Ruby:
202
+
203
+ - **Near-miss clones by default.** Every fragment is reduced to its sequence of
204
+ node types, indexed by its rarest types, and candidate pairs are verified with
205
+ a real longest-common-subsequence check. That finds the Type-3 clones — copies
206
+ with a renamed variable or an extra line — that exact structural hashing
207
+ misses. It is always on, and the match carries a score rather than a label.
208
+ - **Sliding windows, down to a single statement.** Every contiguous run of
209
+ statements is considered, so a duplicated stretch buried inside a larger method
210
+ is caught, not only whole bodies. Two sibling controllers that drifted apart
211
+ line by line match here and nowhere else: no single subtree of either one is a
212
+ clone of the other. And one statement can be a clone by itself — the block body
213
+ a view helper repeats verbatim is a single expression.
214
+ - **Whole methods, `when` arms and `rescue` clauses too.** A one-line method has
215
+ no run of statements at all; without these it would be invisible.
216
+ - **Lists aren't clones.** A run of identically shaped statements — a require
217
+ block, a routes file, a column of registrations — is skipped, so windows cut
218
+ out of one don't report a match at every offset.
219
+ - **Clusters, not pairs.** All copies of one thing collapse into a single
220
+ finding with N sites, so the report reads as "fix this once," not a wall of
221
+ pairwise matches.
222
+ - **It tells you how to fix it.** hashira diffs the copies and classifies what
223
+ varies: only literals → extract a method and pass them as arguments; only the
224
+ receiver → extract a method taking it, or use polymorphism; a constant →
225
+ parameterize it; the control flow itself → extract the common core, but verify
226
+ by hand (flagged lower-confidence).
227
+ - **Noise control, from the repo itself.** A shape that recurs everywhere is a
228
+ Ruby idiom, not duplication, so the mass floor rises as a shape gets more
229
+ common, and rare token types drive matching while common ones don't. The floor
230
+ rises again when two sites share nothing but their shape: `each_cons(2).min_by
231
+ { }` and `combination(2).select { }` are the same tree by coincidence, and a
232
+ match with no name in common has to be much bigger to mean anything.
233
+ - **Churn overlay.** When git is available, clones whose files both change often
234
+ are called out — that's where one copy gets fixed and the other silently
235
+ drifts. Silent when git isn't there; no configuration either way.
236
+
237
+ ## Hotspots
238
+
239
+ The three analyzers each answer a different question. The rollup joins them per
240
+ file and adds the one signal that isn't in the AST — how often the file actually
241
+ changes — because cost you never pay isn't worth paying down:
242
+
243
+ ```console
244
+ Hotspots — cost × churn (where refactoring pays the most):
245
+
246
+ file Cog Dup Churn Rank
247
+ -------------------------------------------------------------------------
248
+ controllers/orders/refunds_controller.rb 0 67 4 268
249
+ controllers/orders/returns_controller.rb 0 67 4 268
250
+ models/invoice.rb 8 34 3 126
251
+ models/shipping/label.rb 9 100 1 109
252
+ controllers/orders_controller.rb 8 0 7 56
253
+ ```
254
+
255
+ Read it as a work queue: the top row is where a day of refactoring buys the most.
256
+ A file carrying a clone is charged per site, so one holding both copies pays
257
+ twice. Churn floors at one, so a repo with no git history still ranks by cost.
258
+
259
+ Deliberately not a rating. A letter grade on a healthy codebase is the same
260
+ letter repeated — it tells you nothing about what to open first.
47
261
 
48
- ## How It Works
262
+ ## How it works
49
263
 
50
- A dependency edge A→B exists when a file in package A references a constant declared by package B. Declarations are read from the AST; strings and comments are invisible. A type counts toward TC only if it defines a method directly in its body — pure namespace wrappers don't count. The root namespace is inferred (the most common outermost constant), so `App::Alpha` and `Alpha` resolve to the same package.
264
+ **Coupling.** A dependency edge A→B exists when a file in package A references a
265
+ constant declared by package B. Declarations are read from the AST; strings and
266
+ comments are invisible. A type counts toward TC only if it defines a method
267
+ directly in its body; pure namespace wrappers don't count. The root namespace is
268
+ inferred (the most common outermost constant), so `App::Alpha` and `Alpha` resolve
269
+ to the same package. Each edge carries a **weight**: the number of constant
270
+ references backing it. A root-level file `x.rb` folds into package `x` when a
271
+ sibling folder `x/` exists; everything else at the top level lands in `(root)`.
51
272
 
52
- Each edge carries a **weight**: the number of constant references backing it. A root-level file `x.rb` folds into package `x` when a sibling folder `x/` exists; everything else at the top level lands in `(root)`.
273
+ **Complexity.** Every method body is walked once and scored against the
274
+ cognitive-complexity rules above.
275
+
276
+ **Duplication.** Candidates are every window of one to twelve sibling statements,
277
+ plus every method, `when` arm and `rescue` clause taken whole. Runs of identically
278
+ shaped statements are skipped as lists. Each candidate is hashed structurally and
279
+ matched both exactly and by near-miss — a linear-time bound on the longest common
280
+ subsequence rejects a pair before the real comparison runs — then unioned into
281
+ clusters and reduced to the maximal, non-overlapping ones. All three analyzers
282
+ share a single parse of your source, so running them together costs no more than
283
+ parsing once.
284
+
285
+ **Hotspots.** Each file is charged the cognitive complexity of its methods and
286
+ the mass of every clone site it holds, then multiplied by how many commits touched
287
+ it. Git is asked once, lazily, and only if something needs churn.
53
288
 
54
289
  ## CI
55
290
 
56
- Fail the build when specific finding kinds exist:
291
+ `--fail-on` is the blunt instrument: fail the build when findings of a kind exist
292
+ at all. It only works on a codebase that starts clean.
57
293
 
58
294
  ```sh
59
- hashira --fail-on cycles,sdp # kinds: cycles, sdp
295
+ hashira --fail-on cycles,sdp,complexity,duplication # any subset, comma-separated
60
296
  ```
61
297
 
62
- Or ratchet: commit a baseline and only allow the edge set to shrink. New edges fail with the evidence that introduced them.
298
+ The ratchet is the one you can adopt today. Commit a baseline of what's true now
299
+ — which edges exist, which findings stand — and the build fails when that set
300
+ *grows*. It never asks whether the code is good, only whether this commit made it
301
+ worse, which is the question a build can actually answer:
63
302
 
64
303
  ```sh
65
- hashira --ratchet # compares against hashira_baseline.json
66
- hashira --update-baseline # lock in improvements
304
+ hashira --update-baseline # record today's edges and findings
305
+ hashira --ratchet # fail if either set grew
67
306
  hashira --ratchet --baseline PATH
68
307
  ```
69
308
 
70
- Findings accepted by design can be recorded in the baseline with a reason they leave reports and gates, keeping a one-line reminder each:
309
+ A regression prints in full, with the evidence that introduced it:
310
+
311
+ ```console
312
+ $ hashira --ratchet
313
+ NEW FINDING:
314
+ duplication: 2 similar fragments (mass 44) — extract the shared shape and pass what differs as parameters.
315
+ · billing/refund.rb:1-11
316
+ · orders/checkout.rb:1-11
317
+
318
+ Ratchet FAILED. Either fix what regressed, or — if it is deliberate —
319
+ record the decision: update the baseline, or accept it with a reason.
320
+ ```
321
+
322
+ Improvements fail the build too, and say so cheerfully — an unrecorded gain is one
323
+ the next commit can quietly undo. Re-run `--update-baseline` to lock it in.
324
+
325
+ ### Accepting by design
326
+
327
+ Anything deliberate goes in the baseline with a reason. It leaves reports and
328
+ gates, keeping a one-line reminder of why it's allowed:
71
329
 
72
330
  ```json
73
331
  "accepted": [
74
- {"kind": "sdp_violation", "package": "models", "reason": "config is generated, churn is harmless"}
332
+ {"kind": "sdp_violation", "package": "models", "reason": "config is generated, churn is harmless"},
333
+ {"kind": "complexity", "package": "Legacy::Importer#run", "reason": "vendored, rewrite scheduled"},
334
+ {"kind": "duplication", "digest": "8bbddea787bc", "reason": "generated adapters, regenerated together"}
75
335
  ]
76
336
  ```
77
337
 
78
- ## Other Formats
338
+ Cycles, SDP violations and complexity name a `package` — a package name or a
339
+ method, both stable. A clone has no stable name: its canonical site is a line
340
+ number, and a line number moves whenever anything above it does. So clones are
341
+ accepted by `digest` instead, a fingerprint of the shape itself — read it out of
342
+ `hashira --json`. It survives the clone moving down the file, and stops matching
343
+ when the clone actually changes.
344
+
345
+ That sentence of reason is the part no tool can compute. A ratchet with no escape
346
+ valve gets switched off the first Friday it blocks a release; one that costs a
347
+ sentence turns every exception into a decision somebody reviewed.
348
+
349
+ ## Other formats
79
350
 
80
351
  ```sh
81
- hashira --json # machine format with full evidence + edge weights
352
+ hashira --json # machine format: findings (with digests), accepted, packages,
353
+ # edges, complexity, duplication, hotspots
82
354
  hashira --format dot # Graphviz digraph
83
355
  hashira --format mermaid # Mermaid diagram
84
356
  ```
85
357
 
86
- ## Why No A, D, or Zones
87
-
88
- Classic package-metrics tools also measure abstractness (A), distance-from-main-sequence (D), and the Pain/Uselessness zones. Those assume formal interfaces are how you decouple. Idiomatic Ruby decouples via duck typing, so any abstractness proxy pins to ~0 and the "zone" verdict just restates I. Deliberately skipped.
358
+ ## Why cognitive complexity
359
+
360
+ The older Ruby complexity metrics charge roughly one point per message send and
361
+ multiply by nesting depth, so the score tracks *how many methods you call* more
362
+ than how hard the code is to follow. A flat method that calls twenty collaborators
363
+ outranks a genuinely knotty one with deep conditionals and mixed boolean logic.
364
+ Cognitive complexity was designed the other way around: straight-line code is free
365
+ no matter how long, nesting compounds, and flat structures like `case` are cheap
366
+ because a jump table is easy to read. hashira shows the call count next to the
367
+ score precisely so you can see where the two disagree.
368
+
369
+ The gap is widest on Rails. Rank a Rails app by message sends and the top hits are
370
+ class bodies — `Invoice`, `Order::Pagination`, `Membership` — because a column of
371
+ `has_many` and `validates` declarations is a column of message sends. Class bodies
372
+ are not methods, so cognitive complexity scores them zero and ranks the code that
373
+ actually branches. The per-class rollup then keeps a hotspot visible after it is
374
+ split into five tiny methods, without letting a wall of DSL calls dominate the
375
+ total.
376
+
377
+ ## Why no A, D, or zones
378
+
379
+ Classic package-metrics tools also measure abstractness (A),
380
+ distance-from-main-sequence (D), and the Pain/Uselessness zones. Those assume
381
+ formal interfaces are how you decouple. Idiomatic Ruby decouples via duck
382
+ typing, so any abstractness proxy pins to ~0 and the "zone" verdict just
383
+ restates I. Deliberately skipped.
89
384
 
90
385
  ## Contributing
91
386
 
@@ -29,8 +29,6 @@ module Hashira
29
29
  end
30
30
  end
31
31
 
32
- # Assumes each top-level name is declared by one package; if two
33
- # packages declare the same name, the last file (in sort order) wins.
34
32
  def register(full, package)
35
33
  outer, inner = full.first(2)
36
34
  name = outer == root_namespace ? inner : outer
@@ -6,7 +6,7 @@ module Hashira
6
6
  KIND = "cycle"
7
7
 
8
8
  def list
9
- graph.packages.select { graph.cyclic?(_1) }.sort.map { cycle_finding(_1) }
9
+ graph.packages.select { graph.cyclic?(it) }.sort.map { cycle_finding(it) }
10
10
  end
11
11
 
12
12
  private
@@ -7,7 +7,7 @@ module Hashira
7
7
  @dependencies = dependencies
8
8
  @package = package
9
9
  @predecessor = {}
10
- @queue = dependencies[package].to_a.each { @predecessor[_1] = package }
10
+ @queue = dependencies[package].to_a.each { @predecessor[it] = package }
11
11
  end
12
12
 
13
13
  def path
@@ -14,7 +14,7 @@ module Hashira
14
14
  @trees.each { |file, tree| definitions_in(file, tree, &) }
15
15
  end
16
16
 
17
- def packages = @trees.keys.map { @project.package_for(_1) }.uniq
17
+ def packages = @trees.keys.map { @project.package_for(it) }.uniq
18
18
 
19
19
  private
20
20
 
@@ -2,8 +2,12 @@
2
2
 
3
3
  module Hashira
4
4
  module Analysis
5
- Finding = Data.define(:kind, :package, :message, :evidence, :cycle) do
6
- def initialize(cycle: nil, **rest) = super
5
+ Finding = Data.define(:kind, :package, :message, :evidence, :cycle, :digest) do
6
+ def initialize(cycle: nil, digest: nil, **rest) = super
7
+
8
+ def signature = "#{kind}:#{identity}"
9
+
10
+ def identity = digest || package
7
11
 
8
12
  def to_h = super.compact
9
13
  end
@@ -13,10 +13,10 @@ module Hashira
13
13
 
14
14
  def dependencies_of(package) = dependencies[package].to_a.sort
15
15
 
16
- def dependents_of(package) = packages.select { dependencies[_1].include?(package) }.sort
16
+ def dependents_of(package) = packages.select { dependencies[it].include?(package) }.sort
17
17
 
18
18
  def edge_list
19
- dependencies.sort.flat_map { |from, tos| tos.sort.map { Edge.new(from:, to: _1) } }
19
+ dependencies.sort.flat_map { |from, tos| tos.sort.map { Edge.new(from:, to: it) } }
20
20
  end
21
21
 
22
22
  def weighted_edges
@@ -34,7 +34,7 @@ module Hashira
34
34
  efferent: dependencies[package].size)
35
35
  end
36
36
 
37
- def metrics = packages.to_h { [_1, metric_for(_1)] }
37
+ def metrics = packages.to_h { [it, metric_for(it)] }
38
38
 
39
39
  def sdp_violations = SdpCheck.new(dependencies, metrics).violations
40
40
 
@@ -7,7 +7,13 @@ module Hashira
7
7
 
8
8
  def each_node(node, &)
9
9
  yield(node)
10
- node.compact_child_nodes.each { each_node(_1, &) }
10
+ node.compact_child_nodes.each { each_node(it, &) }
11
+ end
12
+
13
+ def collect(node)
14
+ found = []
15
+ each_node(node) { found << it }
16
+ found
11
17
  end
12
18
  end
13
19
  end
@@ -12,14 +12,14 @@ module Hashira
12
12
  end
13
13
 
14
14
  def each_sighting(tree)
15
- [].tap { collect(tree, _1) }
15
+ [].tap { collect(tree, it) }
16
16
  end
17
17
 
18
18
  def collect(node, accumulator)
19
19
  return unless node
20
20
  return accumulator << [Syntax.path_segments(node), node.location.start_line] if constant?(node)
21
21
 
22
- branches(node).each { collect(_1, accumulator) }
22
+ branches(node).each { collect(it, accumulator) }
23
23
  end
24
24
 
25
25
  def constant?(node) = node.is_a?(Prism::ConstantPathNode) || node.is_a?(Prism::ConstantReadNode)
@@ -10,7 +10,7 @@ module Hashira
10
10
 
11
11
  def violations
12
12
  @dependencies.flat_map do |from, tos|
13
- tos.select { @metrics[_1].instability > @metrics[from].instability }.map { [from, _1] }
13
+ tos.select { @metrics[it].instability > @metrics[from].instability }.map { [from, it] }
14
14
  end
15
15
  end
16
16
  end
@@ -19,7 +19,7 @@ module Hashira
19
19
 
20
20
  def each_child_definition(node, prefix, &)
21
21
  children = node ? node.compact_child_nodes : []
22
- children.each { each_definition(_1, prefix, &) }
22
+ children.each { each_definition(it, prefix, &) }
23
23
  end
24
24
  end
25
25
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ class Churn
5
+ LOG = "git log --name-only --format= 2>/dev/null"
6
+ SITES_THAT_DRIFT_APART = 2
7
+
8
+ def self.from_git = new(tally(`#{LOG}`))
9
+
10
+ def self.tally(output) = output.split("\n").map(&:strip).reject(&:empty?).tally
11
+
12
+ def initialize(counts)
13
+ @counts = counts
14
+ end
15
+
16
+ def hits(file) = @counts.select { |path, _| path.end_with?(file) }.values.max || 0
17
+
18
+ def hot?(members) = changing(members) >= SITES_THAT_DRIFT_APART
19
+
20
+ def changing(members) = members.count { |member| hits(member.file).positive? }
21
+ end
22
+ end