hashira 0.4.0 → 0.5.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 +58 -0
- data/README.md +86 -25
- data/lib/hashira/analysis/finding.rb +2 -2
- data/lib/hashira/ci/ratchet.rb +5 -9
- data/lib/hashira/cli/fail_on.rb +15 -5
- data/lib/hashira/cli/run.rb +8 -4
- data/lib/hashira/cli/usage.rb +8 -5
- data/lib/hashira/complexity/boolean_run.rb +5 -3
- data/lib/hashira/complexity/cognitive_score.rb +21 -14
- data/lib/hashira/complexity/if_chain.rb +14 -14
- data/lib/hashira/complexity/method_finding.rb +3 -20
- data/lib/hashira/complexity/rescue_scan.rb +9 -9
- data/lib/hashira/complexity/{analyzer.rb → scores.rb} +6 -5
- data/lib/hashira/coupling/audiences.rb +49 -0
- data/lib/hashira/{analysis → coupling}/catalog.rb +3 -3
- data/lib/hashira/{analysis → coupling}/census.rb +17 -8
- data/lib/hashira/{analysis → coupling}/constant_registry.rb +6 -2
- data/lib/hashira/coupling/cycle_findings.rb +31 -0
- data/lib/hashira/{analysis → coupling}/cycle_search.rb +1 -1
- data/lib/hashira/{analysis → coupling}/cycles.rb +2 -2
- data/lib/hashira/{analysis → coupling}/definition.rb +1 -1
- data/lib/hashira/{analysis → coupling}/definitions.rb +1 -1
- data/lib/hashira/{analysis → coupling}/edge.rb +1 -1
- data/lib/hashira/{analysis → coupling}/edge_map.rb +10 -10
- data/lib/hashira/{analysis → coupling}/folder_placement.rb +2 -2
- data/lib/hashira/{analysis → coupling}/folding.rb +4 -2
- data/lib/hashira/{analysis → coupling}/graph.rb +18 -8
- data/lib/hashira/{analysis → coupling}/metric.rb +1 -1
- data/lib/hashira/coupling/mixed_audience_findings.rb +32 -0
- data/lib/hashira/{analysis → coupling}/namespace_placement.rb +3 -3
- data/lib/hashira/{analysis → coupling}/namespace_prefix.rb +1 -1
- data/lib/hashira/{analysis → coupling}/naming.rb +2 -2
- data/lib/hashira/{analysis → coupling}/no_folding.rb +1 -1
- data/lib/hashira/{analysis → coupling}/placement.rb +3 -3
- data/lib/hashira/coupling/references.rb +55 -0
- data/lib/hashira/coupling/report.rb +28 -0
- data/lib/hashira/coupling/roll_call.rb +45 -0
- data/lib/hashira/coupling/roll_call_findings.rb +30 -0
- data/lib/hashira/{analysis → coupling}/roster.rb +2 -2
- data/lib/hashira/{analysis → coupling}/rule.rb +1 -1
- data/lib/hashira/{analysis/resolver.rb → coupling/scope.rb} +2 -2
- data/lib/hashira/{analysis → coupling}/sdp_check.rb +1 -1
- data/lib/hashira/coupling/sdp_violation_findings.rb +25 -0
- data/lib/hashira/coupling/wide_edge_findings.rb +25 -0
- data/lib/hashira/coupling/words.rb +29 -0
- data/lib/hashira/diagram/{renderer.rb → source.rb} +2 -2
- data/lib/hashira/duplication/{analyzer.rb → clones.rb} +3 -3
- data/lib/hashira/duplication/{clusterer.rb → clusters.rb} +3 -3
- data/lib/hashira/duplication/delta.rb +6 -14
- data/lib/hashira/duplication/duplication_finding.rb +6 -10
- data/lib/hashira/duplication/{extractor.rb → harvest.rb} +1 -1
- data/lib/hashira/duplication/sequence.rb +3 -1
- data/lib/hashira/duplication/similarity.rb +5 -1
- data/lib/hashira/duplication/variance.rb +3 -3
- data/lib/hashira/hotspots/rollup.rb +1 -1
- data/lib/hashira/pipeline.rb +15 -10
- data/lib/hashira/report/complexity_table.rb +1 -1
- data/lib/hashira/report/finding_lines.rb +1 -1
- data/lib/hashira/report/json.rb +5 -3
- data/lib/hashira/report/phrases.rb +98 -0
- data/lib/hashira/report/smell_phrases.rb +68 -0
- data/lib/hashira/smells/census.rb +32 -0
- data/lib/hashira/smells/check.rb +30 -0
- data/lib/hashira/smells/conditions.rb +44 -0
- data/lib/hashira/smells/contexts.rb +64 -0
- data/lib/hashira/smells/control_parameter.rb +22 -0
- data/lib/hashira/smells/data_clump.rb +30 -0
- data/lib/hashira/smells/duplicate_method_call.rb +54 -0
- data/lib/hashira/smells/feature_envy.rb +17 -0
- data/lib/hashira/smells/instance_variable_assumption.rb +44 -0
- data/lib/hashira/smells/manual_dispatch.rb +16 -0
- data/lib/hashira/smells/module_initialize.rb +9 -0
- data/lib/hashira/smells/nil_check.rb +32 -0
- data/lib/hashira/smells/param_check.rb +51 -0
- data/lib/hashira/smells/refs.rb +53 -0
- data/lib/hashira/smells/repeated_conditional.rb +29 -0
- data/lib/hashira/smells/report.rb +31 -0
- data/lib/hashira/smells/scope.rb +29 -0
- data/lib/hashira/smells/too_many_instance_variables.rb +24 -0
- data/lib/hashira/smells/utility_function.rb +13 -0
- data/lib/hashira/smells/visibility.rb +64 -0
- data/lib/hashira/version.rb +1 -1
- data/lib/hashira.rb +65 -30
- metadata +60 -31
- data/lib/hashira/analysis/cycle_findings.rb +0 -38
- data/lib/hashira/analysis/references.rb +0 -40
- data/lib/hashira/analysis/sdp_violation_findings.rb +0 -29
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 574b35721d888046d7d8bbe02129896ebb6305aca89c30127c5372cb855bff74
|
|
4
|
+
data.tar.gz: ecd9eae0bf5a0cb5baf9b7637401ce0e12afdebcaa7e61e1c3f58b155e16a7bc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5b08c0cb5f567948aa66f6e1e69d5c610b4eeb75811940b7d833e3c08d279372d5d27100c1efa3f0b3917e0f9fbd32d675e7284039e57086044c8e26e7edb509
|
|
7
|
+
data.tar.gz: 10a1128b98b097b168adbaca6b2c0408a712d992725f5c41d1146fcc43cf975511cbc9dd4dd31a3bc3bfc42b2279906859233f66cde4eada83d96f20f3449aae
|
data/CHANGELOG.md
CHANGED
|
@@ -5,10 +5,67 @@ 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.5.0] - 2026-08-04
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `wide_edge` coupling finding: an edge carrying five or more distinct
|
|
13
|
+
constants is an interface with that many reasons to change — front the
|
|
14
|
+
target with one facade. Found from the same constant-level usage data as
|
|
15
|
+
`mixed_audience`. Its first run flagged the pipeline's own five-constant
|
|
16
|
+
reach into `coupling`, dissolved by the new `Coupling::Report` facade.
|
|
17
|
+
- `roll_call` coupling finding: a list of three or more words (symbols or
|
|
18
|
+
string keys in array and hash literals) maintained by hand in three or more
|
|
19
|
+
files across two or more packages is a registry in disguise. Its first run
|
|
20
|
+
flagged the analyzer names synced between the pipeline, `--fail-on`, and the
|
|
21
|
+
JSON report — dissolved by deriving `--fail-on` kinds from
|
|
22
|
+
`Pipeline::ANALYZERS` and the coupling rule roster.
|
|
23
|
+
- `Hashira/ProsePlacement` cop: sentence-length string literals are presentation
|
|
24
|
+
and belong under `report/` or `ci/` — domain classes pass data. All finding
|
|
25
|
+
messages now render in `Report::Phrases` from structured `Finding#detail`;
|
|
26
|
+
`Finding` no longer carries a `message` member (the JSON report still emits
|
|
27
|
+
a phrased `message` per finding).
|
|
28
|
+
- Coverage floors raised to 100% line and 100% branch — and CI now gates
|
|
29
|
+
`wide_edge` and `roll_call` alongside cycles, SDP, and mixed audiences.
|
|
30
|
+
|
|
31
|
+
- `mixed_audience` coupling finding: a package whose constants split into
|
|
32
|
+
parts with disjoint client bases — one set of packages leaning on one slice,
|
|
33
|
+
another set on another — is separate packages in disguise. Detected from
|
|
34
|
+
constant-level inbound references: clients whose touched constants overlap
|
|
35
|
+
merge into one audience; constants used by a strict majority of clients are
|
|
36
|
+
set aside as the shared base layer; two or more remaining parts of at least
|
|
37
|
+
two constants each name the seam. Gate with `--fail-on mixed_audience`.
|
|
38
|
+
Hashira's first run on itself flagged its own oldest namespace, `analysis` —
|
|
39
|
+
and the split below dissolved it.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- **Breaking:** the coupling machinery moved out of `Hashira::Analysis` into
|
|
44
|
+
`Hashira::Coupling` (`Graph`, `Census`, `Cycles`, the structural findings,
|
|
45
|
+
packaging and resolution), matching the `--skip coupling` analyzer name.
|
|
46
|
+
`Hashira::Analysis` now holds only the substrate every analyzer shares:
|
|
47
|
+
`Syntax`, `NodeWalk`, `TypeWalk`, and `Finding`. Exactly the seam the new
|
|
48
|
+
`mixed_audience` finding pointed at; hashira now gates itself with
|
|
49
|
+
`--fail-on cycles,sdp,mixed_audience` and an empty-findings baseline.
|
|
50
|
+
|
|
8
51
|
## [0.4.0] - 2026-08-02
|
|
9
52
|
|
|
10
53
|
### Added
|
|
11
54
|
|
|
55
|
+
- Code smells analyzer: eleven design smells — the object-relationship kinds
|
|
56
|
+
no line count sees —
|
|
57
|
+
`control_parameter`, `data_clump`, `duplicate_method_call`, `feature_envy`,
|
|
58
|
+
`instance_variable_assumption`, `manual_dispatch`, `module_initialize`,
|
|
59
|
+
`nil_check`, `repeated_conditional`, `too_many_instance_variables`, and
|
|
60
|
+
`utility_function` — reported as findings with file:line evidence, gated and
|
|
61
|
+
ratcheted like every other kind. On by default; `--skip smells` drops the
|
|
62
|
+
analyzer; `--fail-on smells` gates all eleven, or name a single kind
|
|
63
|
+
(`--fail-on feature_envy`). `@x ||=` memoization counts neither as class
|
|
64
|
+
state nor as an ivar assumption, `module_function` methods are exempt, and
|
|
65
|
+
`utility_function` flags public instance methods only. Methods born inside
|
|
66
|
+
blocks or `class << self` are seen like any other, and safe navigation
|
|
67
|
+
counts wherever a plain call would.
|
|
68
|
+
|
|
12
69
|
- Rails awareness. A directory with `config/application.rb` inside it (the
|
|
13
70
|
Rails root) or beside it (its `app` folder) is detected as a Rails app:
|
|
14
71
|
coupling defaults to namespace packaging, and under namespace packaging
|
|
@@ -209,6 +266,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
209
266
|
- Output formats: text, JSON, Graphviz dot, Mermaid (`--format`, `--json`).
|
|
210
267
|
- `--help` and `--version`.
|
|
211
268
|
|
|
269
|
+
[0.5.0]: https://github.com/giacope/hashira/releases/tag/v0.5.0
|
|
212
270
|
[0.4.0]: https://github.com/giacope/hashira/releases/tag/v0.4.0
|
|
213
271
|
[0.3.0]: https://github.com/giacope/hashira/releases/tag/v0.3.0
|
|
214
272
|
[0.2.0]: https://github.com/giacope/hashira/releases/tag/v0.2.0
|
data/README.md
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
# hashira
|
|
2
2
|
|
|
3
|
-
🏛️ **Coupling, cognitive-complexity and
|
|
3
|
+
🏛️ **Coupling, cognitive-complexity, duplication and code-smell metrics for Ruby, read straight from the AST via [Prism](https://github.com/ruby/prism).**
|
|
4
4
|
|
|
5
|
-
hashira tells you which file to open first. It reads a Ruby codebase
|
|
6
|
-
which packages depend on which, how hard each method is to follow,
|
|
7
|
-
copy-pasted
|
|
8
|
-
actually change it. Every finding names the file and line behind it, and a committed
|
|
5
|
+
hashira tells you which file to open first. It reads a Ruby codebase four ways —
|
|
6
|
+
which packages depend on which, how hard each method is to follow, what has been
|
|
7
|
+
copy-pasted, and which objects handle each other's data — then ranks every file by
|
|
8
|
+
what it costs you against how often you actually change it. Every finding names the file and line behind it, and a committed
|
|
9
9
|
baseline ratchets the whole set in CI, so the build fails on what *this commit* made
|
|
10
10
|
worse rather than on a score nobody agrees on.
|
|
11
11
|
|
|
12
12
|
- **Zero runtime dependencies.** Prism ships with Ruby 3.4+; nothing else to install.
|
|
13
13
|
- **Reads the AST, never strings.** Every signal comes from the parse tree. Comments and string literals are invisible.
|
|
14
|
-
- **
|
|
14
|
+
- **Four analyzers, opt-out.** Coupling, complexity, duplication, and smells run together by default; `--skip` drops any.
|
|
15
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
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
17
|
- **Made for CI.** Ratchet edges *and* findings against a baseline, so no clean slate is required. Or gate outright with `--fail-on`.
|
|
@@ -39,7 +39,7 @@ A healthy project reports `Findings (0): none ✓ — structure is healthy`.
|
|
|
39
39
|
|
|
40
40
|
## Contents
|
|
41
41
|
|
|
42
|
-
[Install](#install) · [Getting started](#getting-started) · [Coupling: how to read the numbers](#coupling-how-to-read-the-numbers) · [Rails apps](#rails-apps) · [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)
|
|
42
|
+
[Install](#install) · [Getting started](#getting-started) · [Coupling: how to read the numbers](#coupling-how-to-read-the-numbers) · [Rails apps](#rails-apps) · [Cognitive complexity](#cognitive-complexity) · [Duplication](#duplication) · [Code smells](#code-smells) · [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
43
|
|
|
44
44
|
## Install
|
|
45
45
|
|
|
@@ -83,25 +83,28 @@ hashira's own source:
|
|
|
83
83
|
|
|
84
84
|
```console
|
|
85
85
|
$ hashira
|
|
86
|
-
Package (layer) metrics for lib/hashira (
|
|
86
|
+
Package (layer) metrics for lib/hashira (11 packages, 111 files)
|
|
87
87
|
|
|
88
88
|
package TC Ca Ce I Cyc
|
|
89
89
|
----------------------------------------
|
|
90
|
-
analysis
|
|
90
|
+
analysis 3 4 0 0.00 -
|
|
91
91
|
diagram 3 1 0 0.00 -
|
|
92
92
|
hotspots 1 1 0 0.00 -
|
|
93
93
|
duplication 14 2 1 0.33 -
|
|
94
94
|
report 8 2 1 0.33 -
|
|
95
|
+
ci 8 1 1 0.50 -
|
|
95
96
|
complexity 7 1 1 0.50 -
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
coupling 24 1 1 0.50 -
|
|
98
|
+
smells 21 1 1 0.50 -
|
|
99
|
+
(root) 4 1 5 0.83 -
|
|
100
|
+
cli 8 0 4 1.00 -
|
|
99
101
|
|
|
100
102
|
Legend: TC total types, Ca afferent (incoming), Ce efferent (outgoing),
|
|
101
103
|
I=Ce/(Ce+Ca) instability (0=maximally stable, 1=maximally unstable)
|
|
102
104
|
|
|
103
105
|
Dependencies (DependsUpon(refs) -> | <- UsedBy):
|
|
104
|
-
(root) ->
|
|
106
|
+
(root) -> complexity(1), coupling(5), duplication(1), hotspots(1), smells(1) <- cli
|
|
107
|
+
analysis -> (none) <- complexity, coupling, duplication, smells
|
|
105
108
|
duplication -> analysis(3) <- (root), report
|
|
106
109
|
...
|
|
107
110
|
|
|
@@ -109,27 +112,27 @@ Cognitive complexity — worst methods (Cog = how hard to read, Calls = message
|
|
|
109
112
|
|
|
110
113
|
method Cog Calls Loc
|
|
111
114
|
-------------------------------------------------------------
|
|
112
|
-
Hashira::
|
|
113
|
-
Hashira::Analysis::
|
|
114
|
-
Hashira::
|
|
115
|
-
Hashira::
|
|
115
|
+
Hashira::Coupling::NamespacePrefix#wrapper 4 8 coupling/namespace_prefix.rb:19
|
|
116
|
+
Hashira::Analysis::Syntax#anchor 4 12 analysis/syntax.rb:26
|
|
117
|
+
Hashira::Smells::Conditions#branches 4 10 smells/conditions.rb:23
|
|
118
|
+
Hashira::Coupling::Roster#admit 3 4 coupling/roster.rb:19
|
|
116
119
|
...
|
|
117
120
|
|
|
118
121
|
Per-class rollup (Cog total survives extract-method; Peak is the worst method it hides):
|
|
119
122
|
|
|
120
123
|
class Cog Methods Peak
|
|
121
124
|
------------------------------------------------------
|
|
122
|
-
Hashira::
|
|
123
|
-
Hashira::
|
|
125
|
+
Hashira::Project 12 15 3
|
|
126
|
+
Hashira::CLI::CommandLine 12 15 3
|
|
124
127
|
...
|
|
125
128
|
|
|
126
129
|
Hotspots — cost × churn (where refactoring pays the most):
|
|
127
130
|
|
|
128
131
|
file Cog Dup Churn Rank
|
|
129
132
|
-------------------------------------------------------------------------
|
|
130
|
-
|
|
131
|
-
cli/command_line.rb
|
|
132
|
-
|
|
133
|
+
pipeline.rb 10 0 6 60
|
|
134
|
+
cli/command_line.rb 12 0 4 48
|
|
135
|
+
project.rb 12 0 4 48
|
|
133
136
|
...
|
|
134
137
|
|
|
135
138
|
Findings (0):
|
|
@@ -154,6 +157,18 @@ domain layer near 0.00. The findings are about arrows pointing the wrong way:
|
|
|
154
157
|
Stable Dependencies Principle ("depend in the direction of stability"), one of
|
|
155
158
|
Robert C. Martin's [package principles](https://en.wikipedia.org/wiki/Package_principles).
|
|
156
159
|
- **Cycle** — packages depending on each other in a loop.
|
|
160
|
+
- **Mixed audience** — the constants of one package split into parts with
|
|
161
|
+
separate client bases: one set of packages leans on one slice, another set on
|
|
162
|
+
a disjoint slice. Each part is a separate package in disguise; the finding
|
|
163
|
+
names the seam, and — when most clients also share a few constants — the
|
|
164
|
+
shared base layer to extract. Composition roots blur the picture only if they
|
|
165
|
+
touch a constant some other client also touches, which facades avoid.
|
|
166
|
+
- **Wide edge** — one package reaches into another through five or more
|
|
167
|
+
distinct constants. Every constant on the edge is a reason for the client to
|
|
168
|
+
change; a facade narrows the interface to one.
|
|
169
|
+
- **Roll call** — the same list of three or more words (symbols, string keys)
|
|
170
|
+
is maintained by hand in three or more files across packages. The list wants
|
|
171
|
+
to be data with a single owner — a registry the other sites derive from.
|
|
157
172
|
|
|
158
173
|
Each finding comes with file-level evidence; for cycles, the shortest cycle
|
|
159
174
|
path and its lightest edge. What a finding means for your design is your call.
|
|
@@ -277,10 +292,56 @@ it does inside Ruby:
|
|
|
277
292
|
are called out — that's where one copy gets fixed and the other silently
|
|
278
293
|
drifts. Silent when git isn't there; no configuration either way.
|
|
279
294
|
|
|
295
|
+
## Code smells
|
|
296
|
+
|
|
297
|
+
RuboCop counts lines and branches inside one method; design smells are about how
|
|
298
|
+
objects treat each other, and no line count sees that. hashira ships the eleven
|
|
299
|
+
smells that carry that design signal — the object-relationship kinds, not the
|
|
300
|
+
naming, size, and style checks a linter already argues about — read from the
|
|
301
|
+
same parse trees the other analyzers already built:
|
|
302
|
+
|
|
303
|
+
```console
|
|
304
|
+
Findings (2):
|
|
305
|
+
feature_envy: Cart#price refers to 'item' more than to self (cart.rb:12). The behavior may belong on item.
|
|
306
|
+
· item (lines 13, 14)
|
|
307
|
+
control_parameter: Report#write is steered by 'quoted' (report.rb:31). Split the method, or pass a strategy instead of a flag.
|
|
308
|
+
· quoted (line 32)
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
What each one catches:
|
|
312
|
+
|
|
313
|
+
- **feature_envy** — a method refers to another object more than to itself; the
|
|
314
|
+
behavior probably belongs over there.
|
|
315
|
+
- **utility_function** — a public instance method that touches no instance state;
|
|
316
|
+
it isn't really a method of this class. Private stateless helpers are fine, and
|
|
317
|
+
`module_function` modules are exempt — that's what they're for.
|
|
318
|
+
- **control_parameter** — an argument used only to pick an execution path; the
|
|
319
|
+
caller already knew which branch it wanted.
|
|
320
|
+
- **data_clump** — the same two-plus parameters travel through three or more
|
|
321
|
+
methods; a value object is missing.
|
|
322
|
+
- **duplicate_method_call** — the identical receiver-and-arguments call repeated
|
|
323
|
+
inside one method; name the result once.
|
|
324
|
+
- **repeated_conditional** — one class testing the same condition in three or
|
|
325
|
+
more places; polymorphism is overdue.
|
|
326
|
+
- **too_many_instance_variables** — more than four per class. Memoization
|
|
327
|
+
(`@x ||=`) doesn't count as state.
|
|
328
|
+
- **instance_variable_assumption** — an ivar read that no `initialize` ever
|
|
329
|
+
assigns; the reader is assuming another method ran first.
|
|
330
|
+
- **manual_dispatch** — `respond_to?` then send: a type check wearing a duck
|
|
331
|
+
costume.
|
|
332
|
+
- **module_initialize** — `initialize` in a mixin; construction order becomes
|
|
333
|
+
anyone's guess.
|
|
334
|
+
- **nil_check** — `nil?`, `== nil`, `when nil`: simulated polymorphism on the
|
|
335
|
+
cheapest type there is.
|
|
336
|
+
|
|
337
|
+
Smell findings gate and ratchet like every other kind — `--fail-on smells` covers
|
|
338
|
+
all eleven, or name one (`--fail-on feature_envy`); `--skip smells` drops the
|
|
339
|
+
analyzer entirely.
|
|
340
|
+
|
|
280
341
|
## Hotspots
|
|
281
342
|
|
|
282
|
-
|
|
283
|
-
file and adds the one signal that isn't in the AST — how often the file actually
|
|
343
|
+
Each analyzer answers a different question. The hotspot rollup joins the cost
|
|
344
|
+
signals — complexity and duplication — per file and adds the one signal that isn't in the AST — how often the file actually
|
|
284
345
|
changes — because cost you never pay isn't worth paying down:
|
|
285
346
|
|
|
286
347
|
```console
|
|
@@ -340,7 +401,7 @@ it. Git is asked once, lazily, and only if something needs churn.
|
|
|
340
401
|
at all. It only works on a codebase that starts clean.
|
|
341
402
|
|
|
342
403
|
```sh
|
|
343
|
-
hashira --fail-on cycles,sdp,
|
|
404
|
+
hashira --fail-on cycles,sdp,mixed_audience,wide_edge,roll_call,smells # any subset
|
|
344
405
|
```
|
|
345
406
|
|
|
346
407
|
The ratchet is the one you can adopt today. Commit a baseline of what's true now
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
module Hashira
|
|
4
4
|
module Analysis
|
|
5
5
|
Finding =
|
|
6
|
-
Data.define(:kind, :package, :
|
|
7
|
-
def initialize(cycle: nil, digest: nil, **rest) = super
|
|
6
|
+
Data.define(:kind, :package, :detail, :evidence, :cycle, :digest) do
|
|
7
|
+
def initialize(cycle: nil, digest: nil, detail: nil, **rest) = super
|
|
8
8
|
|
|
9
9
|
def signature = "#{kind}:#{identity}"
|
|
10
10
|
|
data/lib/hashira/ci/ratchet.rb
CHANGED
|
@@ -14,21 +14,17 @@ class Hashira::CI::Ratchet
|
|
|
14
14
|
0
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def check
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
def check = Hashira::CI::RatchetReport.new(@graph, @findings, io: @io).print(drift, delta)
|
|
18
|
+
|
|
19
|
+
def blocker
|
|
20
|
+
return "no baseline at #{@baseline.path} — run --update-baseline first" unless @baseline.exist?
|
|
21
|
+
mismatch unless @baseline.packaging == packaging
|
|
21
22
|
end
|
|
22
23
|
|
|
23
24
|
private
|
|
24
25
|
|
|
25
26
|
def packaging = @graph.packaging.to_s
|
|
26
27
|
|
|
27
|
-
def vet
|
|
28
|
-
return if @baseline.packaging == packaging
|
|
29
|
-
raise(Hashira::Error, mismatch)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
28
|
def mismatch
|
|
33
29
|
recorded = @baseline.packaging
|
|
34
30
|
"baseline #{@baseline.path} was recorded with --package-by #{recorded}, but this run " \
|
data/lib/hashira/cli/fail_on.rb
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../pipeline"
|
|
4
|
+
|
|
3
5
|
module Hashira::CLI::FailOn
|
|
6
|
+
SMELLS = %w[
|
|
7
|
+
control_parameter data_clump duplicate_method_call feature_envy instance_variable_assumption
|
|
8
|
+
manual_dispatch module_initialize nil_check repeated_conditional too_many_instance_variables
|
|
9
|
+
utility_function
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
MEASURES = (Hashira::Pipeline::ANALYZERS - %i[coupling smells]).map(&:to_s).freeze
|
|
13
|
+
|
|
4
14
|
KINDS = {
|
|
5
|
-
"cycles" => "cycle", "
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
"
|
|
15
|
+
"cycles" => "cycle", "sdp" => "sdp_violation", "dupe" => "duplication",
|
|
16
|
+
**Hashira::Pipeline::STRUCTURAL.to_h { [it, it] },
|
|
17
|
+
**MEASURES.to_h { [it, it] },
|
|
18
|
+
"smells" => SMELLS, **SMELLS.to_h { [it, it] }
|
|
9
19
|
}.freeze
|
|
10
20
|
|
|
11
21
|
module_function
|
|
12
22
|
|
|
13
23
|
def parse(list)
|
|
14
24
|
return [] unless list
|
|
15
|
-
list.split(",").
|
|
25
|
+
list.split(",").flat_map { Array(kind(it.strip)) }.uniq
|
|
16
26
|
end
|
|
17
27
|
|
|
18
28
|
def kind(name)
|
data/lib/hashira/cli/run.rb
CHANGED
|
@@ -18,11 +18,15 @@ class Hashira::CLI::Run
|
|
|
18
18
|
|
|
19
19
|
def update = ratchet.update
|
|
20
20
|
|
|
21
|
-
def check
|
|
21
|
+
def check
|
|
22
|
+
stop = ratchet.blocker
|
|
23
|
+
raise(Hashira::Error, stop) if stop
|
|
24
|
+
ratchet.check
|
|
25
|
+
end
|
|
22
26
|
|
|
23
27
|
def guard = gate.check
|
|
24
28
|
|
|
25
|
-
def diagram =
|
|
29
|
+
def diagram = source.print
|
|
26
30
|
|
|
27
31
|
def json = report(Hashira::Report::Json)
|
|
28
32
|
|
|
@@ -30,11 +34,11 @@ class Hashira::CLI::Run
|
|
|
30
34
|
|
|
31
35
|
def report(kind) = kind.new(view).print
|
|
32
36
|
|
|
33
|
-
def ratchet = Hashira::CI::Ratchet.new(graph, findings.all, @options.baseline)
|
|
37
|
+
def ratchet = @ratchet ||= Hashira::CI::Ratchet.new(graph, findings.all, @options.baseline)
|
|
34
38
|
|
|
35
39
|
def gate = Hashira::CI::Gate.new(findings, @options.fail_on)
|
|
36
40
|
|
|
37
|
-
def
|
|
41
|
+
def source = Hashira::Diagram::Source.new(graph, @options.mode)
|
|
38
42
|
|
|
39
43
|
def view
|
|
40
44
|
Hashira::Report::View.new(
|
data/lib/hashira/cli/usage.rb
CHANGED
|
@@ -4,16 +4,19 @@ module Hashira::CLI::Usage
|
|
|
4
4
|
TEXT = <<~HELP
|
|
5
5
|
Usage: hashira [DIRECTORY ...] [options]
|
|
6
6
|
|
|
7
|
-
Coupling, cognitive-complexity, and
|
|
8
|
-
Prism, rolled up per file as a ranked list of hotspots.
|
|
9
|
-
directory, auto-detects lib/<gem>.
|
|
7
|
+
Coupling, cognitive-complexity, duplication, and code-smell metrics
|
|
8
|
+
for Ruby, via Prism, rolled up per file as a ranked list of hotspots.
|
|
9
|
+
With no directory, auto-detects lib/<gem>.
|
|
10
10
|
|
|
11
11
|
Options:
|
|
12
12
|
--format FORMAT text (default), json, dot, or mermaid
|
|
13
13
|
--json shorthand for --format json
|
|
14
14
|
--fail-on KINDS exit 1 if findings exist; comma-separated
|
|
15
|
-
kinds: cycles, sdp,
|
|
16
|
-
|
|
15
|
+
kinds: cycles, sdp, mixed_audience, wide_edge,
|
|
16
|
+
roll_call, complexity, duplication, smells (all
|
|
17
|
+
of them), or one smell kind such as feature_envy
|
|
18
|
+
--skip ANALYZERS drop an analyzer; comma-separated: coupling,
|
|
19
|
+
complexity, duplication, smells
|
|
17
20
|
--package-by WHAT group coupling by: auto, folder, or namespace
|
|
18
21
|
(top-level constant). Default auto: namespace for
|
|
19
22
|
Rails apps (config/application.rb in or beside the
|
|
@@ -5,14 +5,16 @@ class Hashira::Complexity::BooleanRun
|
|
|
5
5
|
@scorer = scorer
|
|
6
6
|
end
|
|
7
7
|
|
|
8
|
-
def apply(node
|
|
8
|
+
def apply(node)
|
|
9
9
|
@scorer.add(node, 1, "boolean")
|
|
10
|
-
operands(node).each { @scorer.visit(it
|
|
10
|
+
operands(node).each { @scorer.visit(it) }
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
private
|
|
14
14
|
|
|
15
15
|
def operands(node)
|
|
16
|
-
node.compact_child_nodes.flat_map {
|
|
16
|
+
node.compact_child_nodes.flat_map { kin?(it, node) ? operands(it) : [it] }
|
|
17
17
|
end
|
|
18
|
+
|
|
19
|
+
def kin?(child, node) = child.instance_of?(node.class)
|
|
18
20
|
end
|
|
@@ -27,41 +27,48 @@ class Hashira::Complexity::CognitiveScore
|
|
|
27
27
|
def initialize(def_node)
|
|
28
28
|
@increments = []
|
|
29
29
|
@calls = 0
|
|
30
|
-
|
|
30
|
+
@nesting = 0
|
|
31
|
+
visit(def_node.body)
|
|
31
32
|
end
|
|
32
33
|
|
|
33
|
-
attr_reader :increments, :calls
|
|
34
|
+
attr_reader :increments, :calls, :nesting
|
|
34
35
|
|
|
35
36
|
def total = @increments.sum(&:cost)
|
|
36
37
|
|
|
37
|
-
def visit(node
|
|
38
|
+
def visit(node)
|
|
38
39
|
return unless node
|
|
39
|
-
__send__(HANDLERS.fetch(node.class, :descend), node
|
|
40
|
+
__send__(HANDLERS.fetch(node.class, :descend), node)
|
|
40
41
|
end
|
|
41
42
|
|
|
42
43
|
def add(node, cost, label)
|
|
43
44
|
@increments << Hashira::Complexity::Increment.new(line: node.location.start_line, cost:, label:)
|
|
44
45
|
end
|
|
45
46
|
|
|
47
|
+
def deeper
|
|
48
|
+
@nesting += 1
|
|
49
|
+
yield
|
|
50
|
+
@nesting -= 1
|
|
51
|
+
end
|
|
52
|
+
|
|
46
53
|
private
|
|
47
54
|
|
|
48
|
-
def descend(node
|
|
55
|
+
def descend(node) = node.compact_child_nodes.each { visit(it) }
|
|
49
56
|
|
|
50
|
-
def on_call(node
|
|
57
|
+
def on_call(node)
|
|
51
58
|
@calls += 1
|
|
52
|
-
descend(node
|
|
59
|
+
descend(node)
|
|
53
60
|
end
|
|
54
61
|
|
|
55
|
-
def on_block(node
|
|
62
|
+
def on_block(node) = deeper { descend(node) }
|
|
56
63
|
|
|
57
|
-
def on_nester(node
|
|
58
|
-
add(node, 1 + nesting, LABELS.fetch(node.class))
|
|
59
|
-
|
|
64
|
+
def on_nester(node)
|
|
65
|
+
add(node, 1 + @nesting, LABELS.fetch(node.class))
|
|
66
|
+
deeper { descend(node) }
|
|
60
67
|
end
|
|
61
68
|
|
|
62
|
-
def on_if(node
|
|
69
|
+
def on_if(node) = Hashira::Complexity::IfChain.new(self).apply(node)
|
|
63
70
|
|
|
64
|
-
def on_begin(node
|
|
71
|
+
def on_begin(node) = Hashira::Complexity::RescueScan.new(self).apply(node)
|
|
65
72
|
|
|
66
|
-
def on_boolean(node
|
|
73
|
+
def on_boolean(node) = Hashira::Complexity::BooleanRun.new(self).apply(node)
|
|
67
74
|
end
|
|
@@ -7,34 +7,34 @@ class Hashira::Complexity::IfChain
|
|
|
7
7
|
@scorer = scorer
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def apply(node
|
|
11
|
-
return ternary(node
|
|
12
|
-
branch(node, 1 + nesting,
|
|
10
|
+
def apply(node)
|
|
11
|
+
return ternary(node) unless node.if_keyword
|
|
12
|
+
branch(node, 1 + @scorer.nesting, "if")
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
private
|
|
16
16
|
|
|
17
|
-
def branch(node, cost,
|
|
17
|
+
def branch(node, cost, label)
|
|
18
18
|
@scorer.add(node, cost, label)
|
|
19
|
-
@scorer.visit(node.predicate
|
|
20
|
-
@scorer.visit(node.statements
|
|
21
|
-
tail(node.subsequent
|
|
19
|
+
@scorer.visit(node.predicate)
|
|
20
|
+
@scorer.deeper { @scorer.visit(node.statements) }
|
|
21
|
+
tail(node.subsequent)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def tail(node
|
|
24
|
+
def tail(node)
|
|
25
25
|
case node
|
|
26
|
-
when Prism::IfNode then branch(node, 1,
|
|
27
|
-
when Prism::ElseNode then otherwise(node
|
|
26
|
+
when Prism::IfNode then branch(node, 1, "elsif")
|
|
27
|
+
when Prism::ElseNode then otherwise(node)
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
def otherwise(node
|
|
31
|
+
def otherwise(node)
|
|
32
32
|
@scorer.add(node, 1, "else")
|
|
33
|
-
@scorer.visit(node.statements
|
|
33
|
+
@scorer.deeper { @scorer.visit(node.statements) }
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
def ternary(node
|
|
36
|
+
def ternary(node)
|
|
37
37
|
@scorer.add(node, 1, "ternary")
|
|
38
|
-
node.compact_child_nodes.each { @scorer.visit(it
|
|
38
|
+
node.compact_child_nodes.each { @scorer.visit(it) }
|
|
39
39
|
end
|
|
40
40
|
end
|
|
@@ -1,33 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Hashira::Complexity::MethodFinding
|
|
4
|
-
ADVICE = {
|
|
5
|
-
"if" => "flatten the branching — guard clauses, early returns, or polymorphism.",
|
|
6
|
-
"elsif" => "replace the elsif ladder with a lookup or polymorphic dispatch.",
|
|
7
|
-
"else" => "flatten the branching — guard clauses, early returns, or polymorphism.",
|
|
8
|
-
"case" => "a case this size often wants polymorphism or a dispatch table.",
|
|
9
|
-
"boolean" => "name the compound condition in a predicate method.",
|
|
10
|
-
"rescue" => "narrow the rescue, or lift error handling to the caller.",
|
|
11
|
-
"while" => "extract the loop body into its own method.",
|
|
12
|
-
"until" => "extract the loop body into its own method.",
|
|
13
|
-
"for" => "extract the loop body into its own method.",
|
|
14
|
-
"unless" => "invert to a guard clause or a named predicate.",
|
|
15
|
-
"ternary" => "extract the nested ternary into a named method."
|
|
16
|
-
}.freeze
|
|
17
|
-
|
|
18
4
|
def initialize(score)
|
|
19
5
|
@score = score
|
|
20
6
|
end
|
|
21
7
|
|
|
22
8
|
def to_finding
|
|
23
|
-
Hashira::Analysis::Finding.new(kind: "complexity", package: @score.subject,
|
|
9
|
+
Hashira::Analysis::Finding.new(kind: "complexity", package: @score.subject, detail:, evidence:)
|
|
24
10
|
end
|
|
25
11
|
|
|
26
12
|
private
|
|
27
13
|
|
|
28
|
-
def
|
|
29
|
-
|
|
30
|
-
"(#{@score.file}:#{@score.line}). #{advice}"
|
|
14
|
+
def detail
|
|
15
|
+
{ cognitive: @score.cognitive, calls: @score.calls, site: "#{@score.file}:#{@score.line}", dominant: }
|
|
31
16
|
end
|
|
32
17
|
|
|
33
18
|
def evidence
|
|
@@ -39,8 +24,6 @@ class Hashira::Complexity::MethodFinding
|
|
|
39
24
|
"#{label} +#{incs.sum(&:cost)} (line#{"s" if lines.size > 1} #{lines.join(", ")})"
|
|
40
25
|
end
|
|
41
26
|
|
|
42
|
-
def advice = ADVICE.fetch(dominant)
|
|
43
|
-
|
|
44
27
|
def dominant
|
|
45
28
|
@score.increments.group_by(&:label).transform_values { it.sum(&:cost) }.max_by(&:last).first
|
|
46
29
|
end
|
|
@@ -5,19 +5,19 @@ class Hashira::Complexity::RescueScan
|
|
|
5
5
|
@scorer = scorer
|
|
6
6
|
end
|
|
7
7
|
|
|
8
|
-
def apply(node
|
|
9
|
-
@scorer.visit(node.statements
|
|
10
|
-
clauses(node.rescue_clause
|
|
11
|
-
@scorer.visit(node.else_clause
|
|
12
|
-
@scorer.visit(node.ensure_clause
|
|
8
|
+
def apply(node)
|
|
9
|
+
@scorer.visit(node.statements)
|
|
10
|
+
clauses(node.rescue_clause)
|
|
11
|
+
@scorer.visit(node.else_clause)
|
|
12
|
+
@scorer.visit(node.ensure_clause)
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
private
|
|
16
16
|
|
|
17
|
-
def clauses(node
|
|
17
|
+
def clauses(node)
|
|
18
18
|
return unless node
|
|
19
|
-
@scorer.add(node, 1 + nesting, "rescue")
|
|
20
|
-
@scorer.visit(node.statements
|
|
21
|
-
clauses(node.subsequent
|
|
19
|
+
@scorer.add(node, 1 + @scorer.nesting, "rescue")
|
|
20
|
+
@scorer.deeper { @scorer.visit(node.statements) }
|
|
21
|
+
clauses(node.subsequent)
|
|
22
22
|
end
|
|
23
23
|
end
|