hashira 0.4.0 → 0.5.1
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 +86 -0
- data/README.md +90 -30
- data/lib/hashira/analysis/finding.rb +2 -2
- data/lib/hashira/analysis/syntax.rb +6 -2
- data/lib/hashira/churn.rb +1 -1
- data/lib/hashira/ci/ratchet.rb +5 -9
- data/lib/hashira/cli/command_line.rb +10 -49
- data/lib/hashira/cli/fail_on.rb +9 -5
- data/lib/hashira/cli/flag.rb +29 -0
- data/lib/hashira/cli/flags.rb +50 -0
- data/lib/hashira/cli/format.rb +13 -0
- data/lib/hashira/cli/options.rb +2 -0
- data/lib/hashira/cli/run.rb +8 -4
- data/lib/hashira/cli/usage.rb +20 -21
- data/lib/hashira/cli.rb +1 -1
- 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 +4 -19
- 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 +11 -6
- 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 +6 -2
- data/lib/hashira/{analysis → coupling}/definitions.rb +5 -2
- 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 +6 -2
- data/lib/hashira/{analysis → coupling}/rule.rb +1 -1
- data/lib/hashira/{analysis/resolver.rb → coupling/scope.rb} +7 -3
- 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 +8 -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 +18 -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 +34 -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 +32 -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 +46 -0
- data/lib/hashira/smells/manual_dispatch.rb +16 -0
- data/lib/hashira/smells/module_initialize.rb +11 -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 +31 -0
- data/lib/hashira/smells/report.rb +34 -0
- data/lib/hashira/smells/scope.rb +29 -0
- data/lib/hashira/smells/too_many_instance_variables.rb +26 -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 +68 -30
- metadata +63 -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: 698031b3fff8e705bf80379be16ee05ecacfa9c137459ab8beb855cee1c624dd
|
|
4
|
+
data.tar.gz: c50d6bc7ee142f665c8764d56d7fdf07de381b00529715e37682aed6f0f1856d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4ce8b38adba57e0d1bdf6343362efb3e205c6455d5634bf3cd07c2b429870423268fc4263215ad3ea767284a7acc906901259acbbec7570d386db7fc0759626b
|
|
7
|
+
data.tar.gz: 3670d8b067f2535d745c3785d52087f2aa189909511566e8452818da408e82882077fe428a72b16470640f60bddcdcbd0fbc6109fc412d64652648c20b1bcb9c
|
data/CHANGELOG.md
CHANGED
|
@@ -5,10 +5,94 @@ 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.1] - 2026-08-05
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- The churn scan passes `--no-renames` to `git log`, counting a move as a
|
|
13
|
+
delete plus an add. Rename detection needs blob contents, so on a partial
|
|
14
|
+
clone (`--filter=blob:none`) the old command lazy-fetched objects from the
|
|
15
|
+
network one at a time — on a long history the scan stalled for minutes and
|
|
16
|
+
looked like a hang. It also made the tally depend on which blobs git could
|
|
17
|
+
see, so the same tree could report different churn (and different
|
|
18
|
+
findings) run to run. A dogfood run against a large open-source app fell
|
|
19
|
+
from 3m39s to under 9 seconds.
|
|
20
|
+
- A bare reference to a Ruby core constant (`String`, `Regexp`, `File`, …)
|
|
21
|
+
no longer couples to whichever package defines a namespaced namesake such
|
|
22
|
+
as `Sql::Nodes::Regexp` — Ruby would resolve it to the core class, so
|
|
23
|
+
hashira now drops the edge. The registry answers these through `rooted`,
|
|
24
|
+
which skips the shorthand tails: a lexical namesake still shadows the core
|
|
25
|
+
name as Ruby's own lookup does, and a project that reopens the class at
|
|
26
|
+
top level still owns it. Dogfooding against a large open-source library,
|
|
27
|
+
this deleted a phantom `mixed_audience` finding built entirely on `Array`,
|
|
28
|
+
`String`, and `File`.
|
|
29
|
+
- Constants assigned in a class body (`Node = Struct.new(:path)`) now join the
|
|
30
|
+
census as definitions, so a bare reference resolves to the local constant
|
|
31
|
+
instead of a foreign package's namesake — a karat run had minted two phantom
|
|
32
|
+
edges this way. For usage counts they still collapse into their enclosing
|
|
33
|
+
type: `wide_edge` measures classes, not the constants they carry.
|
|
34
|
+
|
|
35
|
+
## [0.5.0] - 2026-08-04
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- `wide_edge` coupling finding: an edge carrying five or more distinct
|
|
40
|
+
constants is an interface with that many reasons to change — front the
|
|
41
|
+
target with one facade. Found from the same constant-level usage data as
|
|
42
|
+
`mixed_audience`. Its first run flagged the pipeline's own five-constant
|
|
43
|
+
reach into `coupling`, dissolved by the new `Coupling::Report` facade.
|
|
44
|
+
- `roll_call` coupling finding: a list of three or more words (symbols or
|
|
45
|
+
string keys in array and hash literals) maintained by hand in three or more
|
|
46
|
+
files across two or more packages is a registry in disguise. Its first run
|
|
47
|
+
flagged the analyzer names synced between the pipeline, `--fail-on`, and the
|
|
48
|
+
JSON report — dissolved by deriving `--fail-on` kinds from
|
|
49
|
+
`Pipeline::ANALYZERS` and the coupling rule roster.
|
|
50
|
+
- `Hashira/ProsePlacement` cop: sentence-length string literals are presentation
|
|
51
|
+
and belong under `report/` or `ci/` — domain classes pass data. All finding
|
|
52
|
+
messages now render in `Report::Phrases` from structured `Finding#detail`;
|
|
53
|
+
`Finding` no longer carries a `message` member (the JSON report still emits
|
|
54
|
+
a phrased `message` per finding).
|
|
55
|
+
- Coverage floors raised to 100% line and 100% branch — and CI now gates
|
|
56
|
+
`wide_edge` and `roll_call` alongside cycles, SDP, and mixed audiences.
|
|
57
|
+
|
|
58
|
+
- `mixed_audience` coupling finding: a package whose constants split into
|
|
59
|
+
parts with disjoint client bases — one set of packages leaning on one slice,
|
|
60
|
+
another set on another — is separate packages in disguise. Detected from
|
|
61
|
+
constant-level inbound references: clients whose touched constants overlap
|
|
62
|
+
merge into one audience; constants used by a strict majority of clients are
|
|
63
|
+
set aside as the shared base layer; two or more remaining parts of at least
|
|
64
|
+
two constants each name the seam. Gate with `--fail-on mixed_audience`.
|
|
65
|
+
Hashira's first run on itself flagged its own oldest namespace, `analysis` —
|
|
66
|
+
and the split below dissolved it.
|
|
67
|
+
|
|
68
|
+
### Changed
|
|
69
|
+
|
|
70
|
+
- **Breaking:** the coupling machinery moved out of `Hashira::Analysis` into
|
|
71
|
+
`Hashira::Coupling` (`Graph`, `Census`, `Cycles`, the structural findings,
|
|
72
|
+
packaging and resolution), matching the `--skip coupling` analyzer name.
|
|
73
|
+
`Hashira::Analysis` now holds only the substrate every analyzer shares:
|
|
74
|
+
`Syntax`, `NodeWalk`, `TypeWalk`, and `Finding`. Exactly the seam the new
|
|
75
|
+
`mixed_audience` finding pointed at; hashira now gates itself with
|
|
76
|
+
`--fail-on cycles,sdp,mixed_audience` and an empty-findings baseline.
|
|
77
|
+
|
|
8
78
|
## [0.4.0] - 2026-08-02
|
|
9
79
|
|
|
10
80
|
### Added
|
|
11
81
|
|
|
82
|
+
- Code smells analyzer: eleven design smells — the object-relationship kinds
|
|
83
|
+
no line count sees —
|
|
84
|
+
`control_parameter`, `data_clump`, `duplicate_method_call`, `feature_envy`,
|
|
85
|
+
`instance_variable_assumption`, `manual_dispatch`, `module_initialize`,
|
|
86
|
+
`nil_check`, `repeated_conditional`, `too_many_instance_variables`, and
|
|
87
|
+
`utility_function` — reported as findings with file:line evidence, gated and
|
|
88
|
+
ratcheted like every other kind. On by default; `--skip smells` drops the
|
|
89
|
+
analyzer; `--fail-on smells` gates all eleven, or name a single kind
|
|
90
|
+
(`--fail-on feature_envy`). `@x ||=` memoization counts neither as class
|
|
91
|
+
state nor as an ivar assumption, `module_function` methods are exempt, and
|
|
92
|
+
`utility_function` flags public instance methods only. Methods born inside
|
|
93
|
+
blocks or `class << self` are seen like any other, and safe navigation
|
|
94
|
+
counts wherever a plain call would.
|
|
95
|
+
|
|
12
96
|
- Rails awareness. A directory with `config/application.rb` inside it (the
|
|
13
97
|
Rails root) or beside it (its `app` folder) is detected as a Rails app:
|
|
14
98
|
coupling defaults to namespace packaging, and under namespace packaging
|
|
@@ -209,6 +293,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
209
293
|
- Output formats: text, JSON, Graphviz dot, Mermaid (`--format`, `--json`).
|
|
210
294
|
- `--help` and `--version`.
|
|
211
295
|
|
|
296
|
+
[0.5.1]: https://github.com/giacope/hashira/releases/tag/v0.5.1
|
|
297
|
+
[0.5.0]: https://github.com/giacope/hashira/releases/tag/v0.5.0
|
|
212
298
|
[0.4.0]: https://github.com/giacope/hashira/releases/tag/v0.4.0
|
|
213
299
|
[0.3.0]: https://github.com/giacope/hashira/releases/tag/v0.3.0
|
|
214
300
|
[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`.
|
|
@@ -26,11 +26,10 @@ package TC Ca Ce I Cyc
|
|
|
26
26
|
billing 1 1 1 0.50 YES
|
|
27
27
|
shipping 1 1 1 0.50 YES
|
|
28
28
|
|
|
29
|
-
Findings (
|
|
30
|
-
cycle: billing can reach itself: billing -> shipping -> billing — any change
|
|
31
|
-
|
|
32
|
-
·
|
|
33
|
-
· shipping/rate.rb:3: Billing::Client
|
|
29
|
+
Findings (1):
|
|
30
|
+
cycle: billing can reach itself: billing -> shipping -> billing — any change may ripple back around. The lightest edge on this cycle is billing -> shipping (1 ref).
|
|
31
|
+
· billing/client.rb:5: Shipping::Rate
|
|
32
|
+
· shipping/rate.rb:8: Billing::Client
|
|
34
33
|
```
|
|
35
34
|
|
|
36
35
|
A healthy project reports `Findings (0): none ✓ — structure is healthy`.
|
|
@@ -39,7 +38,7 @@ A healthy project reports `Findings (0): none ✓ — structure is healthy`.
|
|
|
39
38
|
|
|
40
39
|
## Contents
|
|
41
40
|
|
|
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)
|
|
41
|
+
[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
42
|
|
|
44
43
|
## Install
|
|
45
44
|
|
|
@@ -83,25 +82,28 @@ hashira's own source:
|
|
|
83
82
|
|
|
84
83
|
```console
|
|
85
84
|
$ hashira
|
|
86
|
-
Package (layer) metrics for lib/hashira (
|
|
85
|
+
Package (layer) metrics for lib/hashira (11 packages, 111 files)
|
|
87
86
|
|
|
88
87
|
package TC Ca Ce I Cyc
|
|
89
88
|
----------------------------------------
|
|
90
|
-
analysis
|
|
89
|
+
analysis 3 4 0 0.00 -
|
|
91
90
|
diagram 3 1 0 0.00 -
|
|
92
91
|
hotspots 1 1 0 0.00 -
|
|
93
92
|
duplication 14 2 1 0.33 -
|
|
94
93
|
report 8 2 1 0.33 -
|
|
94
|
+
ci 8 1 1 0.50 -
|
|
95
95
|
complexity 7 1 1 0.50 -
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
coupling 24 1 1 0.50 -
|
|
97
|
+
smells 21 1 1 0.50 -
|
|
98
|
+
(root) 4 1 5 0.83 -
|
|
99
|
+
cli 8 0 4 1.00 -
|
|
99
100
|
|
|
100
101
|
Legend: TC total types, Ca afferent (incoming), Ce efferent (outgoing),
|
|
101
102
|
I=Ce/(Ce+Ca) instability (0=maximally stable, 1=maximally unstable)
|
|
102
103
|
|
|
103
104
|
Dependencies (DependsUpon(refs) -> | <- UsedBy):
|
|
104
|
-
(root) ->
|
|
105
|
+
(root) -> complexity(1), coupling(5), duplication(1), hotspots(1), smells(1) <- cli
|
|
106
|
+
analysis -> (none) <- complexity, coupling, duplication, smells
|
|
105
107
|
duplication -> analysis(3) <- (root), report
|
|
106
108
|
...
|
|
107
109
|
|
|
@@ -109,27 +111,27 @@ Cognitive complexity — worst methods (Cog = how hard to read, Calls = message
|
|
|
109
111
|
|
|
110
112
|
method Cog Calls Loc
|
|
111
113
|
-------------------------------------------------------------
|
|
112
|
-
Hashira::
|
|
113
|
-
Hashira::Analysis::
|
|
114
|
-
Hashira::
|
|
115
|
-
Hashira::
|
|
114
|
+
Hashira::Coupling::NamespacePrefix#wrapper 4 8 coupling/namespace_prefix.rb:19
|
|
115
|
+
Hashira::Analysis::Syntax#anchor 4 12 analysis/syntax.rb:26
|
|
116
|
+
Hashira::Smells::Conditions#branches 4 10 smells/conditions.rb:23
|
|
117
|
+
Hashira::Coupling::Roster#admit 3 4 coupling/roster.rb:19
|
|
116
118
|
...
|
|
117
119
|
|
|
118
120
|
Per-class rollup (Cog total survives extract-method; Peak is the worst method it hides):
|
|
119
121
|
|
|
120
122
|
class Cog Methods Peak
|
|
121
123
|
------------------------------------------------------
|
|
122
|
-
Hashira::
|
|
123
|
-
Hashira::
|
|
124
|
+
Hashira::Project 12 15 3
|
|
125
|
+
Hashira::CLI::CommandLine 12 15 3
|
|
124
126
|
...
|
|
125
127
|
|
|
126
128
|
Hotspots — cost × churn (where refactoring pays the most):
|
|
127
129
|
|
|
128
130
|
file Cog Dup Churn Rank
|
|
129
131
|
-------------------------------------------------------------------------
|
|
130
|
-
|
|
131
|
-
cli/command_line.rb
|
|
132
|
-
|
|
132
|
+
pipeline.rb 10 0 6 60
|
|
133
|
+
cli/command_line.rb 12 0 4 48
|
|
134
|
+
project.rb 12 0 4 48
|
|
133
135
|
...
|
|
134
136
|
|
|
135
137
|
Findings (0):
|
|
@@ -154,6 +156,18 @@ domain layer near 0.00. The findings are about arrows pointing the wrong way:
|
|
|
154
156
|
Stable Dependencies Principle ("depend in the direction of stability"), one of
|
|
155
157
|
Robert C. Martin's [package principles](https://en.wikipedia.org/wiki/Package_principles).
|
|
156
158
|
- **Cycle** — packages depending on each other in a loop.
|
|
159
|
+
- **Mixed audience** — the constants of one package split into parts with
|
|
160
|
+
separate client bases: one set of packages leans on one slice, another set on
|
|
161
|
+
a disjoint slice. Each part is a separate package in disguise; the finding
|
|
162
|
+
names the seam, and — when most clients also share a few constants — the
|
|
163
|
+
shared base layer to extract. Composition roots blur the picture only if they
|
|
164
|
+
touch a constant some other client also touches, which facades avoid.
|
|
165
|
+
- **Wide edge** — one package reaches into another through five or more
|
|
166
|
+
distinct constants. Every constant on the edge is a reason for the client to
|
|
167
|
+
change; a facade narrows the interface to one.
|
|
168
|
+
- **Roll call** — the same list of three or more words (symbols, string keys)
|
|
169
|
+
is maintained by hand in three or more files across packages. The list wants
|
|
170
|
+
to be data with a single owner — a registry the other sites derive from.
|
|
157
171
|
|
|
158
172
|
Each finding comes with file-level evidence; for cycles, the shortest cycle
|
|
159
173
|
path and its lightest edge. What a finding means for your design is your call.
|
|
@@ -277,10 +291,56 @@ it does inside Ruby:
|
|
|
277
291
|
are called out — that's where one copy gets fixed and the other silently
|
|
278
292
|
drifts. Silent when git isn't there; no configuration either way.
|
|
279
293
|
|
|
294
|
+
## Code smells
|
|
295
|
+
|
|
296
|
+
RuboCop counts lines and branches inside one method; design smells are about how
|
|
297
|
+
objects treat each other, and no line count sees that. hashira ships the eleven
|
|
298
|
+
smells that carry that design signal — the object-relationship kinds, not the
|
|
299
|
+
naming, size, and style checks a linter already argues about — read from the
|
|
300
|
+
same parse trees the other analyzers already built:
|
|
301
|
+
|
|
302
|
+
```console
|
|
303
|
+
Findings (2):
|
|
304
|
+
feature_envy: Cart#price refers to 'item' more than to self (cart.rb:12). The behavior may belong on item.
|
|
305
|
+
· item (lines 13, 14)
|
|
306
|
+
control_parameter: Report#write is steered by 'quoted' (report.rb:31). Split the method, or pass a strategy instead of a flag.
|
|
307
|
+
· quoted (line 32)
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
What each one catches:
|
|
311
|
+
|
|
312
|
+
- **feature_envy** — a method refers to another object more than to itself; the
|
|
313
|
+
behavior probably belongs over there.
|
|
314
|
+
- **utility_function** — a public instance method that touches no instance state;
|
|
315
|
+
it isn't really a method of this class. Private stateless helpers are fine, and
|
|
316
|
+
`module_function` modules are exempt — that's what they're for.
|
|
317
|
+
- **control_parameter** — an argument used only to pick an execution path; the
|
|
318
|
+
caller already knew which branch it wanted.
|
|
319
|
+
- **data_clump** — the same two-plus parameters travel through three or more
|
|
320
|
+
methods; a value object is missing.
|
|
321
|
+
- **duplicate_method_call** — the identical receiver-and-arguments call repeated
|
|
322
|
+
inside one method; name the result once.
|
|
323
|
+
- **repeated_conditional** — one class testing the same condition in three or
|
|
324
|
+
more places; polymorphism is overdue.
|
|
325
|
+
- **too_many_instance_variables** — more than four per class. Memoization
|
|
326
|
+
(`@x ||=`) doesn't count as state.
|
|
327
|
+
- **instance_variable_assumption** — an ivar read that no `initialize` ever
|
|
328
|
+
assigns; the reader is assuming another method ran first.
|
|
329
|
+
- **manual_dispatch** — `respond_to?` then send: a type check wearing a duck
|
|
330
|
+
costume.
|
|
331
|
+
- **module_initialize** — `initialize` in a mixin; construction order becomes
|
|
332
|
+
anyone's guess.
|
|
333
|
+
- **nil_check** — `nil?`, `== nil`, `when nil`: simulated polymorphism on the
|
|
334
|
+
cheapest type there is.
|
|
335
|
+
|
|
336
|
+
Smell findings gate and ratchet like every other kind — `--fail-on smells` covers
|
|
337
|
+
all eleven, or name one (`--fail-on feature_envy`); `--skip smells` drops the
|
|
338
|
+
analyzer entirely.
|
|
339
|
+
|
|
280
340
|
## Hotspots
|
|
281
341
|
|
|
282
|
-
|
|
283
|
-
file and adds the one signal that isn't in the AST — how often the file actually
|
|
342
|
+
Each analyzer answers a different question. The hotspot rollup joins the cost
|
|
343
|
+
signals — complexity and duplication — per file and adds the one signal that isn't in the AST — how often the file actually
|
|
284
344
|
changes — because cost you never pay isn't worth paying down:
|
|
285
345
|
|
|
286
346
|
```console
|
|
@@ -340,7 +400,7 @@ it. Git is asked once, lazily, and only if something needs churn.
|
|
|
340
400
|
at all. It only works on a codebase that starts clean.
|
|
341
401
|
|
|
342
402
|
```sh
|
|
343
|
-
hashira --fail-on cycles,sdp,
|
|
403
|
+
hashira --fail-on cycles,sdp,mixed_audience,wide_edge,roll_call,smells # any subset
|
|
344
404
|
```
|
|
345
405
|
|
|
346
406
|
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
|
|
|
@@ -33,9 +33,13 @@ module Hashira
|
|
|
33
33
|
roots.include?(segments.first(1)) ? segments : (stack.last || []) + segments
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
def direct(type_node)
|
|
36
|
+
def direct(type_node) = statements(type_node).grep(Prism::DefNode)
|
|
37
|
+
|
|
38
|
+
def constants(type_node) = statements(type_node).grep(Prism::ConstantWriteNode)
|
|
39
|
+
|
|
40
|
+
def statements(type_node)
|
|
37
41
|
body = type_node.body
|
|
38
|
-
|
|
42
|
+
body.is_a?(Prism::StatementsNode) ? body.body : [body]
|
|
39
43
|
end
|
|
40
44
|
end
|
|
41
45
|
end
|
data/lib/hashira/churn.rb
CHANGED
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 " \
|
|
@@ -1,47 +1,30 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Hashira::CLI::CommandLine
|
|
4
|
-
DEFAULT_BASELINE = "hashira_baseline.json"
|
|
5
|
-
|
|
6
|
-
FORMATS = %w[text json dot mermaid].freeze
|
|
7
|
-
|
|
8
|
-
CI_FLAGS = { "--update-baseline" => :update, "--ratchet" => :ratchet }.freeze
|
|
9
|
-
|
|
10
4
|
def initialize(argv)
|
|
11
5
|
@arguments = Hashira::CLI::Arguments.new(argv)
|
|
12
6
|
end
|
|
13
7
|
|
|
14
|
-
def options =
|
|
8
|
+
def options = page || parsed
|
|
15
9
|
|
|
16
10
|
private
|
|
17
11
|
|
|
18
|
-
def
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
def page
|
|
13
|
+
flag = Hashira::CLI::FLAGS.select(&:page?).find { seen?(it) }
|
|
14
|
+
Hashira::CLI::Options.page(flag.mode) if flag
|
|
21
15
|
end
|
|
22
16
|
|
|
23
|
-
def
|
|
24
|
-
Hashira::CLI::Options.new(directories: [], mode:, baseline: nil, fail_on: [], skip: [], packaging: :auto)
|
|
25
|
-
end
|
|
17
|
+
def seen?(flag) = flag.names.any? { @arguments.delete(it) }
|
|
26
18
|
|
|
27
19
|
def parsed
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
Hashira::CLI::Options.new(directories: @arguments.rest, **values)
|
|
20
|
+
parses = Hashira::CLI::FLAGS.reject(&:page?).to_h { [it, it.read(@arguments)] }
|
|
21
|
+
Hashira::CLI::Options.new(directories: @arguments.rest, mode: mode(parses), **fields(parses))
|
|
31
22
|
end
|
|
32
23
|
|
|
33
|
-
def
|
|
34
|
-
{
|
|
35
|
-
skip: Hashira::CLI::Skip.parse(take("--skip", "")),
|
|
36
|
-
fail_on: Hashira::CLI::FailOn.parse(take("--fail-on", "")),
|
|
37
|
-
baseline: take("--baseline", DEFAULT_BASELINE), packaging: grouping
|
|
38
|
-
}
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def grouping = Hashira::CLI::PackageBy.parse(take("--package-by", ""))
|
|
24
|
+
def fields(parses) = parses.to_h { |flag, value| [flag.field, value] }.except(nil)
|
|
42
25
|
|
|
43
|
-
def mode(
|
|
44
|
-
case (asked =
|
|
26
|
+
def mode(parses)
|
|
27
|
+
case (asked = parses.filter_map { |flag, value| flag.bid(value) }.uniq(&:last))
|
|
45
28
|
in [] then :text
|
|
46
29
|
in [[_flag, mode]] then mode
|
|
47
30
|
else conflict(asked)
|
|
@@ -51,26 +34,4 @@ class Hashira::CLI::CommandLine
|
|
|
51
34
|
def conflict(asked)
|
|
52
35
|
raise(Hashira::Error, "conflicting options: #{asked.map(&:first).join(" and ")}")
|
|
53
36
|
end
|
|
54
|
-
|
|
55
|
-
def requested(fail_on)
|
|
56
|
-
CI_FLAGS.filter_map { |flag, mode| [flag, mode] if delete(flag) } + gate(fail_on) + formats
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def gate(fail_on) = fail_on.empty? ? [] : [["--fail-on", :fail_on]]
|
|
60
|
-
|
|
61
|
-
def formats
|
|
62
|
-
chosen = format
|
|
63
|
-
modes = chosen.empty? ? [] : [["--format #{chosen}", chosen.to_sym]]
|
|
64
|
-
delete("--json") ? modes + [["--json", :json]] : modes
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def format
|
|
68
|
-
wanted = take("--format", "")
|
|
69
|
-
return wanted if wanted.empty? || FORMATS.include?(wanted)
|
|
70
|
-
raise(Hashira::Error.unknown("--format", wanted, FORMATS))
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def take(flag, default) = @arguments.take(flag, default)
|
|
74
|
-
|
|
75
|
-
def delete(flag) = @arguments.delete(flag)
|
|
76
37
|
end
|
data/lib/hashira/cli/fail_on.rb
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../pipeline"
|
|
4
|
+
|
|
3
5
|
module Hashira::CLI::FailOn
|
|
6
|
+
MEASURES = (Hashira::Pipeline::ANALYZERS - %i[coupling smells]).map(&:to_s).freeze
|
|
7
|
+
|
|
4
8
|
KINDS = {
|
|
5
|
-
"cycles" => "cycle", "
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
"
|
|
9
|
+
"cycles" => "cycle", "sdp" => "sdp_violation", "dupe" => "duplication",
|
|
10
|
+
**Hashira::Pipeline::STRUCTURAL.to_h { [it, it] },
|
|
11
|
+
**MEASURES.to_h { [it, it] },
|
|
12
|
+
"smells" => Hashira::Pipeline::SMELLS, **Hashira::Pipeline::SMELLS.to_h { [it, it] }
|
|
9
13
|
}.freeze
|
|
10
14
|
|
|
11
15
|
module_function
|
|
12
16
|
|
|
13
17
|
def parse(list)
|
|
14
18
|
return [] unless list
|
|
15
|
-
list.split(",").
|
|
19
|
+
list.split(",").flat_map { Array(kind(it.strip)) }.uniq
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
def kind(name)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::CLI
|
|
4
|
+
Flag =
|
|
5
|
+
Data.define(:name, :arg, :default, :field, :parse, :mode, :text) do
|
|
6
|
+
def initialize(**attributes) = super(arg: nil, default: "", field: nil, parse: nil, mode: nil, **attributes)
|
|
7
|
+
|
|
8
|
+
def read(arguments) = arg ? take(arguments) : arguments.delete(name)
|
|
9
|
+
|
|
10
|
+
def take(arguments)
|
|
11
|
+
raw = arguments.take(name, default)
|
|
12
|
+
parse ? parse.parse(raw) : raw
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def bid(value)
|
|
16
|
+
case [mode, value]
|
|
17
|
+
in [nil, _] | [_, nil] | [_, []] then nil
|
|
18
|
+
in [:parsed, chosen] then ["#{name} #{chosen}", chosen]
|
|
19
|
+
else [name, mode]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def label = [name, arg].compact.join(" ")
|
|
24
|
+
|
|
25
|
+
def names = name.split(", ")
|
|
26
|
+
|
|
27
|
+
def page? = Hashira::CLI::Usage::PAGES.include?(mode)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "fail_on"
|
|
4
|
+
require_relative "flag"
|
|
5
|
+
require_relative "format"
|
|
6
|
+
require_relative "package_by"
|
|
7
|
+
require_relative "skip"
|
|
8
|
+
|
|
9
|
+
class Hashira::CLI
|
|
10
|
+
FLAGS = [
|
|
11
|
+
Flag.new(
|
|
12
|
+
name: "--format", arg: "FORMAT", parse: Format, mode: :parsed,
|
|
13
|
+
text: ["text (default), json, dot, or mermaid"]
|
|
14
|
+
),
|
|
15
|
+
Flag.new(name: "--json", mode: :json, text: ["shorthand for --format json"]),
|
|
16
|
+
Flag.new(
|
|
17
|
+
name: "--fail-on", arg: "KINDS", field: :fail_on, parse: FailOn, mode: :fail_on,
|
|
18
|
+
text: [
|
|
19
|
+
"exit 1 if findings exist; comma-separated",
|
|
20
|
+
"kinds: cycles, sdp, mixed_audience, wide_edge,",
|
|
21
|
+
"roll_call, complexity, duplication, smells (all",
|
|
22
|
+
"of them), or one smell kind such as feature_envy"
|
|
23
|
+
]
|
|
24
|
+
),
|
|
25
|
+
Flag.new(
|
|
26
|
+
name: "--skip", arg: "ANALYZERS", field: :skip, parse: Skip,
|
|
27
|
+
text: ["drop an analyzer; comma-separated: coupling,", "complexity, duplication, smells"]
|
|
28
|
+
),
|
|
29
|
+
Flag.new(
|
|
30
|
+
name: "--package-by", arg: "WHAT", field: :packaging, parse: PackageBy,
|
|
31
|
+
text: [
|
|
32
|
+
"group coupling by: auto, folder, or namespace",
|
|
33
|
+
"(top-level constant). Default auto: namespace for",
|
|
34
|
+
"Rails apps (config/application.rb in or beside the",
|
|
35
|
+
"analyzed directory), folder otherwise"
|
|
36
|
+
]
|
|
37
|
+
),
|
|
38
|
+
Flag.new(
|
|
39
|
+
name: "--ratchet", mode: :ratchet,
|
|
40
|
+
text: ["fail when edges or findings appear that the", "baseline lacks"]
|
|
41
|
+
),
|
|
42
|
+
Flag.new(name: "--update-baseline", mode: :update, text: ["record the current edges and findings"]),
|
|
43
|
+
Flag.new(
|
|
44
|
+
name: "--baseline", arg: "PATH", field: :baseline, default: "hashira_baseline.json",
|
|
45
|
+
text: ["baseline file (default: hashira_baseline.json)"]
|
|
46
|
+
),
|
|
47
|
+
Flag.new(name: "-h, --help", mode: :help, text: ["print this help"]),
|
|
48
|
+
Flag.new(name: "--version", mode: :version, text: ["print the version"])
|
|
49
|
+
].freeze
|
|
50
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira::CLI::Format
|
|
4
|
+
CHOICES = %w[text json dot mermaid].freeze
|
|
5
|
+
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def parse(value)
|
|
9
|
+
return if value.empty?
|
|
10
|
+
return value.to_sym if CHOICES.include?(value)
|
|
11
|
+
raise(Hashira::Error.unknown("--format", value, CHOICES))
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/hashira/cli/options.rb
CHANGED
|
@@ -5,6 +5,8 @@ class Hashira::CLI
|
|
|
5
5
|
Data.define(:directories, :mode, :baseline, :fail_on, :skip, :packaging) do
|
|
6
6
|
def self.parse(argv) = CommandLine.new(argv).options
|
|
7
7
|
|
|
8
|
+
def self.page(mode) = new(directories: [], mode:, baseline: "", fail_on: [], skip: [], packaging: :auto)
|
|
9
|
+
|
|
8
10
|
def pipeline
|
|
9
11
|
Hashira::Pipeline.new(Hashira::Project.detect(directories), enabled: analyzers, packaging:)
|
|
10
12
|
end
|
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(
|