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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +97 -0
- data/README.md +334 -32
- data/lib/hashira/analysis/census.rb +12 -14
- data/lib/hashira/analysis/constant_registry.rb +40 -0
- data/lib/hashira/analysis/cycle_findings.rb +1 -1
- data/lib/hashira/analysis/cycle_search.rb +1 -1
- data/lib/hashira/analysis/definitions.rb +1 -1
- data/lib/hashira/analysis/finding.rb +6 -2
- data/lib/hashira/analysis/graph.rb +3 -3
- data/lib/hashira/analysis/namespace_prefix.rb +32 -0
- data/lib/hashira/analysis/node_walk.rb +7 -1
- data/lib/hashira/analysis/references.rb +2 -2
- data/lib/hashira/analysis/sdp_check.rb +1 -1
- data/lib/hashira/analysis/type_walk.rb +1 -1
- data/lib/hashira/churn.rb +22 -0
- data/lib/hashira/ci/accepted.rb +11 -7
- data/lib/hashira/ci/baseline.rb +40 -0
- data/lib/hashira/ci/diff.rb +15 -0
- data/lib/hashira/ci/edge_diff_report.rb +4 -27
- data/lib/hashira/ci/finding_diff_report.rb +26 -0
- data/lib/hashira/ci/gate.rb +7 -5
- data/lib/hashira/ci/improvement.rb +19 -0
- data/lib/hashira/ci/ratchet.rb +16 -20
- data/lib/hashira/ci/ratchet_report.rb +42 -0
- data/lib/hashira/cli/command_line.rb +12 -6
- data/lib/hashira/cli/fail_on.rb +4 -2
- data/lib/hashira/cli/options.rb +1 -1
- data/lib/hashira/cli/run.rb +9 -3
- data/lib/hashira/cli/skip.rb +27 -0
- data/lib/hashira/cli/usage.rb +10 -5
- data/lib/hashira/cli.rb +1 -1
- data/lib/hashira/complexity/analyzer.rb +45 -0
- data/lib/hashira/complexity/boolean_run.rb +22 -0
- data/lib/hashira/complexity/cognitive_score.rb +72 -0
- data/lib/hashira/complexity/if_chain.rb +45 -0
- data/lib/hashira/complexity/method_finding.rb +52 -0
- data/lib/hashira/complexity/method_score.rb +17 -0
- data/lib/hashira/complexity/rescue_scan.rb +28 -0
- data/lib/hashira/complexity/rollup.rb +22 -0
- data/lib/hashira/duplication/analyzer.rb +21 -0
- data/lib/hashira/duplication/cluster.rb +26 -0
- data/lib/hashira/duplication/clusterer.rb +50 -0
- data/lib/hashira/duplication/delta.rb +39 -0
- data/lib/hashira/duplication/duplication_finding.rb +30 -0
- data/lib/hashira/duplication/extractor.rb +31 -0
- data/lib/hashira/duplication/fragment.rb +42 -0
- data/lib/hashira/duplication/grouping.rb +24 -0
- data/lib/hashira/duplication/index.rb +35 -0
- data/lib/hashira/duplication/maximal.rb +21 -0
- data/lib/hashira/duplication/near_miss.rb +33 -0
- data/lib/hashira/duplication/sequence.rb +36 -0
- data/lib/hashira/duplication/similarity.rb +50 -0
- data/lib/hashira/duplication/union_find.rb +21 -0
- data/lib/hashira/duplication/variance.rb +57 -0
- data/lib/hashira/hotspots/file_cost.rb +19 -0
- data/lib/hashira/hotspots/rollup.rb +33 -0
- data/lib/hashira/pipeline.rb +28 -6
- data/lib/hashira/project.rb +32 -9
- data/lib/hashira/report/complexity_table.rb +40 -0
- data/lib/hashira/report/dependency_map.rb +8 -5
- data/lib/hashira/report/finding_lines.rb +1 -1
- data/lib/hashira/report/graph_payload.rb +29 -0
- data/lib/hashira/report/hotspot_table.rb +41 -0
- data/lib/hashira/report/json.rb +24 -17
- data/lib/hashira/report/metrics_table.rb +1 -2
- data/lib/hashira/report/text.rb +24 -14
- data/lib/hashira/report/view.rb +7 -0
- data/lib/hashira/version.rb +1 -1
- data/lib/hashira.rb +38 -1
- metadata +46 -7
- data/lib/hashira/analysis/root_namespace.rb +0 -14
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b806d4d5704515b3371235d38cdd4e60a0c37b98194c91922d57a8f5c448ffab
|
|
4
|
+
data.tar.gz: 774766f13fa3b820557cb613e9d9b0c7ffcca2d220c30611562d1467a21f142b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
36
|
+
A healthy project reports `Findings (0): none ✓ — structure is healthy`.
|
|
6
37
|
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
52
|
+
Or add it to a project and run it through Bundler:
|
|
12
53
|
|
|
13
|
-
|
|
54
|
+
```ruby
|
|
55
|
+
# Gemfile
|
|
56
|
+
gem "hashira", group: :development
|
|
57
|
+
```
|
|
14
58
|
|
|
15
59
|
```sh
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
hashira app lib # or several — one shared graph
|
|
60
|
+
bundle install
|
|
61
|
+
bundle exec hashira
|
|
19
62
|
```
|
|
20
63
|
|
|
21
|
-
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
##
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
302
|
+
hashira --fail-on cycles,sdp,complexity,duplication # any subset, comma-separated
|
|
60
303
|
```
|
|
61
304
|
|
|
62
|
-
|
|
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 --
|
|
66
|
-
hashira --
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
@
|
|
10
|
-
@
|
|
9
|
+
@registry = ConstantRegistry.new
|
|
10
|
+
@namespace_prefix = NamespacePrefix.infer(@definitions)
|
|
11
11
|
take
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
attr_reader :type_count, :
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|