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