hashira 0.3.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 +155 -0
- data/README.md +127 -25
- data/lib/hashira/analysis/finding.rb +7 -6
- data/lib/hashira/analysis/node_walk.rb +3 -3
- data/lib/hashira/analysis/syntax.rb +21 -6
- data/lib/hashira/analysis/type_walk.rb +7 -9
- data/lib/hashira/churn.rb +11 -13
- data/lib/hashira/ci/accepted.rb +27 -31
- data/lib/hashira/ci/baseline.rb +22 -24
- data/lib/hashira/ci/diff.rb +8 -7
- data/lib/hashira/ci/edge_diff_report.rb +13 -17
- data/lib/hashira/ci/finding_diff_report.rb +14 -18
- data/lib/hashira/ci/gate.rb +19 -23
- data/lib/hashira/ci/improvement.rb +9 -14
- data/lib/hashira/ci/ratchet.rb +32 -27
- data/lib/hashira/ci/ratchet_report.rb +26 -32
- data/lib/hashira/cli/arguments.rb +23 -0
- data/lib/hashira/cli/command_line.rb +55 -71
- data/lib/hashira/cli/fail_on.rb +24 -19
- data/lib/hashira/cli/options.rb +9 -4
- data/lib/hashira/cli/package_by.rb +14 -0
- data/lib/hashira/cli/run.rb +33 -27
- data/lib/hashira/cli/skip.rb +14 -20
- data/lib/hashira/cli/usage.rb +44 -41
- data/lib/hashira/cli.rb +17 -19
- data/lib/hashira/complexity/boolean_run.rb +13 -15
- data/lib/hashira/complexity/cognitive_score.rb +68 -66
- data/lib/hashira/complexity/if_chain.rb +33 -38
- data/lib/hashira/complexity/method_finding.rb +20 -42
- data/lib/hashira/complexity/method_score.rb +9 -7
- data/lib/hashira/complexity/rescue_scan.rb +16 -21
- data/lib/hashira/complexity/rollup.rb +13 -15
- data/lib/hashira/complexity/scores.rb +44 -0
- data/lib/hashira/coupling/audiences.rb +49 -0
- data/lib/hashira/coupling/catalog.rb +27 -0
- data/lib/hashira/coupling/census.rb +55 -0
- data/lib/hashira/coupling/constant_registry.rb +47 -0
- data/lib/hashira/coupling/cycle_findings.rb +31 -0
- data/lib/hashira/coupling/cycle_search.rb +37 -0
- data/lib/hashira/coupling/cycles.rb +14 -0
- data/lib/hashira/coupling/definition.rb +22 -0
- data/lib/hashira/coupling/definitions.rb +31 -0
- data/lib/hashira/coupling/edge.rb +10 -0
- data/lib/hashira/coupling/edge_map.rb +33 -0
- data/lib/hashira/coupling/folder_placement.rb +17 -0
- data/lib/hashira/coupling/folding.rb +56 -0
- data/lib/hashira/coupling/graph.rb +63 -0
- data/lib/hashira/coupling/metric.rb +15 -0
- data/lib/hashira/coupling/mixed_audience_findings.rb +32 -0
- data/lib/hashira/coupling/namespace_placement.rb +28 -0
- data/lib/hashira/{analysis → coupling}/namespace_prefix.rb +11 -11
- data/lib/hashira/coupling/naming.rb +14 -0
- data/lib/hashira/coupling/no_folding.rb +13 -0
- data/lib/hashira/coupling/placement.rb +19 -0
- 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/coupling/roster.rb +25 -0
- data/lib/hashira/coupling/rule.rb +18 -0
- data/lib/hashira/coupling/scope.rb +28 -0
- data/lib/hashira/coupling/sdp_check.rb +14 -0
- 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/dot.rb +8 -11
- data/lib/hashira/diagram/mermaid.rb +11 -16
- data/lib/hashira/diagram/source.rb +15 -0
- data/lib/hashira/duplication/clones.rb +17 -0
- data/lib/hashira/duplication/cluster.rb +14 -13
- data/lib/hashira/duplication/clusters.rb +46 -0
- data/lib/hashira/duplication/delta.rb +21 -30
- data/lib/hashira/duplication/duplication_finding.rb +17 -23
- data/lib/hashira/duplication/fragment.rb +19 -23
- data/lib/hashira/duplication/grouping.rb +12 -16
- data/lib/hashira/duplication/harvest.rb +27 -0
- data/lib/hashira/duplication/index.rb +20 -24
- data/lib/hashira/duplication/maximal.rb +11 -15
- data/lib/hashira/duplication/near_miss.rb +19 -22
- data/lib/hashira/duplication/sequence.rb +20 -22
- data/lib/hashira/duplication/similarity.rb +31 -34
- data/lib/hashira/duplication/union_find.rb +10 -14
- data/lib/hashira/duplication/variance.rb +35 -43
- data/lib/hashira/error.rb +4 -2
- data/lib/hashira/hotspots/file_cost.rb +8 -7
- data/lib/hashira/hotspots/rollup.rb +19 -21
- data/lib/hashira/pipeline.rb +41 -31
- data/lib/hashira/project.rb +50 -49
- data/lib/hashira/report/complexity_table.rb +27 -31
- data/lib/hashira/report/dependency_map.rb +17 -22
- data/lib/hashira/report/finding_lines.rb +15 -19
- data/lib/hashira/report/graph_payload.rb +14 -20
- data/lib/hashira/report/hotspot_table.rb +32 -37
- data/lib/hashira/report/json.rb +32 -32
- data/lib/hashira/report/metrics_table.rb +46 -33
- data/lib/hashira/report/phrases.rb +98 -0
- data/lib/hashira/report/smell_phrases.rb +68 -0
- data/lib/hashira/report/text.rb +59 -52
- 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 +131 -60
- metadata +62 -20
- data/lib/hashira/analysis/census.rb +0 -39
- data/lib/hashira/analysis/constant_registry.rb +0 -40
- data/lib/hashira/analysis/cycle_findings.rb +0 -33
- data/lib/hashira/analysis/cycle_search.rb +0 -44
- data/lib/hashira/analysis/definitions.rb +0 -27
- data/lib/hashira/analysis/edge.rb +0 -9
- data/lib/hashira/analysis/edge_map.rb +0 -36
- data/lib/hashira/analysis/graph.rb +0 -56
- data/lib/hashira/analysis/metric.rb +0 -14
- data/lib/hashira/analysis/references.rb +0 -39
- data/lib/hashira/analysis/rule.rb +0 -22
- data/lib/hashira/analysis/sdp_check.rb +0 -18
- data/lib/hashira/analysis/sdp_violation_findings.rb +0 -27
- data/lib/hashira/complexity/analyzer.rb +0 -45
- data/lib/hashira/diagram/renderer.rb +0 -20
- data/lib/hashira/duplication/analyzer.rb +0 -21
- data/lib/hashira/duplication/clusterer.rb +0 -50
- data/lib/hashira/duplication/extractor.rb +0 -31
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,6 +5,159 @@ 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
|
+
|
|
51
|
+
## [0.4.0] - 2026-08-02
|
|
52
|
+
|
|
53
|
+
### Added
|
|
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
|
+
|
|
69
|
+
- Rails awareness. A directory with `config/application.rb` inside it (the
|
|
70
|
+
Rails root) or beside it (its `app` folder) is detected as a Rails app:
|
|
71
|
+
coupling defaults to namespace packaging, and under namespace packaging
|
|
72
|
+
references to app-defined `Application*` base classes (`ApplicationRecord`,
|
|
73
|
+
`ApplicationJob`, `ApplicationSerializer`, `ApplicationPolicy`, …) are
|
|
74
|
+
skipped as framework plumbing. An explicit `--package-by folder` keeps the
|
|
75
|
+
full legacy edge set, `Application*` references included.
|
|
76
|
+
- Baselines record their packaging mode (schema v3; older baselines read as
|
|
77
|
+
folder). `--ratchet` refuses a baseline recorded under another mode with
|
|
78
|
+
instructions to rerun with `--package-by <recorded>` or refresh via
|
|
79
|
+
`--update-baseline`, instead of failing every edge as drift after the
|
|
80
|
+
Rails default flips packaging.
|
|
81
|
+
- `--package-by folder|namespace`. Namespace packaging groups types by
|
|
82
|
+
top-level constant (`Billing`, `Ci`, `User`) across layer folders, so the
|
|
83
|
+
coupling tables and findings answer the domain question — does `Billing`
|
|
84
|
+
reach into `Ci`? — instead of restating Rails layout (`models -> jobs`).
|
|
85
|
+
Folder packaging stays the default outside Rails and remains available
|
|
86
|
+
everywhere via the flag.
|
|
87
|
+
|
|
88
|
+
### Changed
|
|
89
|
+
|
|
90
|
+
- **Breaking (Ruby API only; the CLI is unchanged.)** Names throughout the
|
|
91
|
+
library are now single-word, following rubocop-elegant: `Graph#dependents_of`
|
|
92
|
+
is `#incoming`, `Graph#edge_list` is `#edges`, `Project#package_for` is
|
|
93
|
+
`#package`, `Churn.from_git` is `Churn.scan`, and `Similarity#at_least?` is
|
|
94
|
+
`#meets?`. Cycle queries moved off `Graph` onto `Graph#cycles`:
|
|
95
|
+
`graph.cyclic?(p)`, `graph.cycle(p)`, and `graph.weakest(path)` are now
|
|
96
|
+
`graph.cycles.through?(p)`, `graph.cycles.path(p)`, and
|
|
97
|
+
`graph.cycles.weakest(path)`.
|
|
98
|
+
- One cycle finding per distinct loop, reported from its smallest member,
|
|
99
|
+
instead of one per participating package.
|
|
100
|
+
- Under namespace packaging, a top-level class that anchors no namespace of
|
|
101
|
+
its own and inherits from an app-defined class folds into its base's
|
|
102
|
+
package, transitively — a flat family of notification subclasses reports
|
|
103
|
+
as one package, not twenty.
|
|
104
|
+
- Past 25 rows, the metrics table hides single-type packages with no
|
|
105
|
+
outgoing edges and at most one incoming behind a count line; they stay in
|
|
106
|
+
the graph, so their afferent weight still counts. A heavily depended-upon
|
|
107
|
+
package (high Ca) always keeps its row — its stability is the point of
|
|
108
|
+
the table.
|
|
109
|
+
- Under namespace packaging in a Rails app, a singleton class named by
|
|
110
|
+
convention (`SandboxResource`, `UserSerializer`, `AccountPolicy`,
|
|
111
|
+
`PlanDecorator`) folds into its domain's package when that package exists;
|
|
112
|
+
an app-defined superclass still takes precedence over the name.
|
|
113
|
+
- Every fold is disclosed: a `Folded` list under the coupling tables and a
|
|
114
|
+
`folds` array in `--json`, each entry naming the fold and whether it came
|
|
115
|
+
from a base class or a naming suffix.
|
|
116
|
+
- Classes count toward TC even when their body is pure DSL (Alba resources,
|
|
117
|
+
notifiers); only modules still need a directly defined method.
|
|
118
|
+
|
|
119
|
+
### Fixed
|
|
120
|
+
|
|
121
|
+
- References into `Application*` namespaces (`ApplicationCable::Channel`)
|
|
122
|
+
are skipped in Rails apps like the bases themselves, and no longer pull
|
|
123
|
+
channels into a plumbing package.
|
|
124
|
+
- A proper prefix of a reference only matches exact definition paths: with
|
|
125
|
+
an app-defined `Billing::Stripe`, the gem constant `Stripe::RateLimitError`
|
|
126
|
+
no longer resolves to `Billing` when `RateLimitError` is unknown.
|
|
127
|
+
Whole-reference suffix shorthand is untouched.
|
|
128
|
+
- Constants resolve through their lexical nesting, like Ruby. A bare
|
|
129
|
+
`Authentication` inside `class User` now resolves to `User::Authentication`
|
|
130
|
+
before a top-level `Authentication` in another package, superclasses
|
|
131
|
+
resolve in the enclosing scope (but are charged to the class they define),
|
|
132
|
+
and a scoped hit claimed by several packages resolves to nothing rather
|
|
133
|
+
than falling through to a namesake. Kills phantom cross-package edges in
|
|
134
|
+
Rails apps, where nested concerns routinely shadow top-level names.
|
|
135
|
+
- `::`-anchored references resolve at top level only, like Ruby: `::User`
|
|
136
|
+
inside `module Admin` binds to the top-level `User`, never a nested
|
|
137
|
+
`Admin::User` namesake.
|
|
138
|
+
- A constant under a namespaced class (`Invoice::STATES` with
|
|
139
|
+
`Admin::Invoice` defined, referenced inside `Admin`) resolves through the
|
|
140
|
+
enclosing scope by longest registered prefix, so the edge to the class's
|
|
141
|
+
package is kept.
|
|
142
|
+
- A compact reopen (`class Foo::Bar` inside `module Baz`) anchors its root
|
|
143
|
+
like Ruby — innermost enclosing scope that defines it, else top level —
|
|
144
|
+
so its types and references are charged to `Foo`, not `Baz`.
|
|
145
|
+
- A class reopened across files counts once toward TC, and a lone subclass
|
|
146
|
+
reopened in a later-sorting file keeps its base fold; fold results no
|
|
147
|
+
longer depend on file order.
|
|
148
|
+
- Mutually-linked folds (a base fold one way, a suffix fold the other)
|
|
149
|
+
merge into one package instead of swapping the two packages' identities,
|
|
150
|
+
and a fold link from a package to itself is dropped instead of being
|
|
151
|
+
disclosed as `X -> X`.
|
|
152
|
+
- A superclass resolves only against registered definition paths: a bare
|
|
153
|
+
`Base` no longer folds its subclass into an unrelated `Admin::Base`
|
|
154
|
+
matched by suffix shorthand.
|
|
155
|
+
- Namespace-prefix inference votes with every distinct definition path and
|
|
156
|
+
requires a wrapper to enclose all of them, so a domain namespace sharing
|
|
157
|
+
a single folder with top-level classes is kept as a package instead of
|
|
158
|
+
being stripped as a gem wrapper.
|
|
159
|
+
- `--package-by auto` is accepted as the explicit spelling of the default.
|
|
160
|
+
|
|
8
161
|
## [0.3.0] - 2026-07-26
|
|
9
162
|
|
|
10
163
|
### Changed
|
|
@@ -113,6 +266,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
113
266
|
- Output formats: text, JSON, Graphviz dot, Mermaid (`--format`, `--json`).
|
|
114
267
|
- `--help` and `--version`.
|
|
115
268
|
|
|
269
|
+
[0.5.0]: https://github.com/giacope/hashira/releases/tag/v0.5.0
|
|
270
|
+
[0.4.0]: https://github.com/giacope/hashira/releases/tag/v0.4.0
|
|
116
271
|
[0.3.0]: https://github.com/giacope/hashira/releases/tag/v0.3.0
|
|
117
272
|
[0.2.0]: https://github.com/giacope/hashira/releases/tag/v0.2.0
|
|
118
273
|
[0.1.0]: https://github.com/giacope/hashira/releases/tag/v0.1.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) · [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,10 +157,63 @@ 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.
|
|
160
175
|
|
|
176
|
+
## Rails apps
|
|
177
|
+
|
|
178
|
+
Rails layer folders are framework layout, not architecture: models will always
|
|
179
|
+
touch jobs and mailers, so folder packages under `app/` report idioms as
|
|
180
|
+
findings. When the analyzed directory contains a `config/application.rb` (the
|
|
181
|
+
Rails root) or sits beside one (its `app` folder), hashira switches to
|
|
182
|
+
**namespace packaging**: types group by top-level constant
|
|
183
|
+
(`Billing`, `Ci`, `User`) across the layer folders, edges join domains, and the
|
|
184
|
+
findings answer the question a Rails monolith actually has — does `Billing`
|
|
185
|
+
reach into `Ci`?
|
|
186
|
+
|
|
187
|
+
```console
|
|
188
|
+
$ hashira app
|
|
189
|
+
package TC Ca Ce I Cyc
|
|
190
|
+
----------------------------------------
|
|
191
|
+
Account 26 21 18 0.46 YES
|
|
192
|
+
Billing 116 12 11 0.48 YES
|
|
193
|
+
Ci 107 9 16 0.64 YES
|
|
194
|
+
...
|
|
195
|
+
cycle: Account can reach itself: Account -> User -> Account — any change
|
|
196
|
+
may ripple back around. The lightest edge on this cycle is Account -> User (1 ref).
|
|
197
|
+
· models/account.rb:36: User
|
|
198
|
+
· models/user/signup.rb:32: Account
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Under namespace packaging, references to app-defined `Application*` base
|
|
202
|
+
classes (`ApplicationRecord`, `ApplicationJob`, …) are skipped as framework
|
|
203
|
+
plumbing; `--package-by folder` keeps them, so the legacy layer view stays
|
|
204
|
+
complete. Constant resolution
|
|
205
|
+
follows Ruby's lexical nesting everywhere — a bare `Authentication` inside
|
|
206
|
+
`class User` is `User::Authentication`, not a top-level namesake in another
|
|
207
|
+
package — which matters most in Rails apps, where nested concerns routinely
|
|
208
|
+
shadow top-level names.
|
|
209
|
+
|
|
210
|
+
Either grouping can be forced anywhere:
|
|
211
|
+
|
|
212
|
+
```sh
|
|
213
|
+
hashira app --package-by folder # layer view, even in a Rails app
|
|
214
|
+
hashira lib/gem --package-by namespace
|
|
215
|
+
```
|
|
216
|
+
|
|
161
217
|
## Cognitive complexity
|
|
162
218
|
|
|
163
219
|
hashira scores every method with **cognitive complexity**, not an ABC or call-count
|
|
@@ -236,10 +292,56 @@ it does inside Ruby:
|
|
|
236
292
|
are called out — that's where one copy gets fixed and the other silently
|
|
237
293
|
drifts. Silent when git isn't there; no configuration either way.
|
|
238
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
|
+
|
|
239
341
|
## Hotspots
|
|
240
342
|
|
|
241
|
-
|
|
242
|
-
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
|
|
243
345
|
changes — because cost you never pay isn't worth paying down:
|
|
244
346
|
|
|
245
347
|
```console
|
|
@@ -299,7 +401,7 @@ it. Git is asked once, lazily, and only if something needs churn.
|
|
|
299
401
|
at all. It only works on a codebase that starts clean.
|
|
300
402
|
|
|
301
403
|
```sh
|
|
302
|
-
hashira --fail-on cycles,sdp,
|
|
404
|
+
hashira --fail-on cycles,sdp,mixed_audience,wide_edge,roll_call,smells # any subset
|
|
303
405
|
```
|
|
304
406
|
|
|
305
407
|
The ratchet is the one you can adopt today. Commit a baseline of what's true now
|
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module Hashira
|
|
4
4
|
module Analysis
|
|
5
|
-
Finding =
|
|
6
|
-
|
|
5
|
+
Finding =
|
|
6
|
+
Data.define(:kind, :package, :detail, :evidence, :cycle, :digest) do
|
|
7
|
+
def initialize(cycle: nil, digest: nil, detail: nil, **rest) = super
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
def signature = "#{kind}:#{identity}"
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
def identity = digest || package
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
def to_h = super.compact
|
|
14
|
+
end
|
|
14
15
|
end
|
|
15
16
|
end
|
|
@@ -5,14 +5,14 @@ module Hashira
|
|
|
5
5
|
module NodeWalk
|
|
6
6
|
module_function
|
|
7
7
|
|
|
8
|
-
def
|
|
8
|
+
def each(node, &)
|
|
9
9
|
yield(node)
|
|
10
|
-
node.compact_child_nodes.each {
|
|
10
|
+
node.compact_child_nodes.each { each(it, &) }
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def collect(node)
|
|
14
14
|
found = []
|
|
15
|
-
|
|
15
|
+
each(node) { found << it }
|
|
16
16
|
found
|
|
17
17
|
end
|
|
18
18
|
end
|
|
@@ -7,20 +7,35 @@ module Hashira
|
|
|
7
7
|
module Syntax
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
|
-
def
|
|
10
|
+
def segments(node)
|
|
11
11
|
case node
|
|
12
12
|
when Prism::ConstantReadNode then [node.name.to_s]
|
|
13
|
-
when Prism::ConstantPathNode then
|
|
13
|
+
when Prism::ConstantPathNode then segments(node.parent) + label(node)
|
|
14
14
|
else []
|
|
15
15
|
end
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def
|
|
18
|
+
def label(node) = [node.name.to_s]
|
|
19
19
|
|
|
20
|
-
def
|
|
20
|
+
def cbase?(node)
|
|
21
|
+
return false unless node.is_a?(Prism::ConstantPathNode)
|
|
22
|
+
parent = node.parent
|
|
23
|
+
!parent || cbase?(parent)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def anchor(stack, segments, roots)
|
|
27
|
+
return (stack.last || []) + segments if segments.length < 2 || !roots
|
|
28
|
+
base = stack.reverse_each.find { roots.include?(it + segments.first(1)) }
|
|
29
|
+
base ? base + segments : hoist(stack, segments, roots)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def hoist(stack, segments, roots)
|
|
33
|
+
roots.include?(segments.first(1)) ? segments : (stack.last || []) + segments
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def direct(type_node)
|
|
21
37
|
body = type_node.body
|
|
22
|
-
|
|
23
|
-
statements.grep(Prism::DefNode)
|
|
38
|
+
(body.is_a?(Prism::StatementsNode) ? body.body : [body]).grep(Prism::DefNode)
|
|
24
39
|
end
|
|
25
40
|
end
|
|
26
41
|
end
|
|
@@ -7,19 +7,17 @@ module Hashira
|
|
|
7
7
|
module TypeWalk
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
|
-
def
|
|
11
|
-
return
|
|
12
|
-
|
|
13
|
-
full = prefix + Syntax.path_segments(node.constant_path)
|
|
10
|
+
def each(node, stack = [], roots: nil, &)
|
|
11
|
+
return descend(node, stack, roots, &) unless type?(node)
|
|
12
|
+
full = Syntax.anchor(stack, Syntax.segments(node.constant_path), roots)
|
|
14
13
|
yield(node, full)
|
|
15
|
-
|
|
14
|
+
descend(node.body, stack + [full], roots, &)
|
|
16
15
|
end
|
|
17
16
|
|
|
18
|
-
def
|
|
17
|
+
def type?(node) = node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
|
|
19
18
|
|
|
20
|
-
def
|
|
21
|
-
|
|
22
|
-
children.each { each_definition(it, prefix, &) }
|
|
19
|
+
def descend(node, stack, roots, &)
|
|
20
|
+
(node ? node.compact_child_nodes : []).each { each(it, stack, roots:, &) }
|
|
23
21
|
end
|
|
24
22
|
end
|
|
25
23
|
end
|
data/lib/hashira/churn.rb
CHANGED
|
@@ -1,22 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
SITES_THAT_DRIFT_APART = 2
|
|
3
|
+
class Hashira::Churn
|
|
4
|
+
LOG = "git log --name-only --format= 2>/dev/null"
|
|
5
|
+
SITES_THAT_DRIFT_APART = 2
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
def self.scan = new(tally(`#{LOG}`))
|
|
9
8
|
|
|
10
|
-
|
|
9
|
+
def self.tally(output) = output.split("\n").map(&:strip).reject(&:empty?).tally
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
def initialize(counts)
|
|
12
|
+
@counts = counts
|
|
13
|
+
end
|
|
15
14
|
|
|
16
|
-
|
|
15
|
+
def hits(file) = @counts.select { |path, _| path.end_with?(file) }.values.max || 0
|
|
17
16
|
|
|
18
|
-
|
|
17
|
+
def hot?(members) = changing(members) >= SITES_THAT_DRIFT_APART
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
end
|
|
19
|
+
def changing(members) = members.count { |member| hits(member.file).positive? }
|
|
22
20
|
end
|
data/lib/hashira/ci/accepted.rb
CHANGED
|
@@ -2,47 +2,43 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class Accepted
|
|
8
|
-
Screened = Data.define(:all, :accepted)
|
|
5
|
+
class Hashira::CI::Accepted
|
|
6
|
+
Screened = Data.define(:all, :accepted)
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def matches?(finding) = kind == finding.kind && identity == finding.identity
|
|
8
|
+
Entry =
|
|
9
|
+
Data.define(:kind, :package, :digest, :reason) do
|
|
10
|
+
def self.from(hash)
|
|
11
|
+
new(kind: hash["kind"], package: hash["package"], digest: hash["digest"], reason: hash["reason"])
|
|
12
|
+
end
|
|
16
13
|
|
|
17
|
-
|
|
14
|
+
def matches?(finding) = kind == finding.kind && identity == finding.identity
|
|
18
15
|
|
|
19
|
-
|
|
16
|
+
def identity = digest || package
|
|
20
17
|
|
|
21
|
-
|
|
22
|
-
end
|
|
18
|
+
def label = reason || "accepted (no reason recorded)"
|
|
23
19
|
|
|
24
|
-
def
|
|
25
|
-
|
|
20
|
+
def to_h = { kind:, package:, digest:, reason: }.compact
|
|
21
|
+
end
|
|
26
22
|
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
def self.load(path)
|
|
24
|
+
return new([]) unless path && File.exist?(path)
|
|
25
|
+
new(JSON.parse(File.read(path)).fetch("accepted", []))
|
|
26
|
+
end
|
|
29
27
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
def initialize(entries)
|
|
29
|
+
@entries = entries.map { Entry.from(it) }
|
|
30
|
+
end
|
|
33
31
|
|
|
34
|
-
|
|
32
|
+
def entries = @entries.map(&:to_h)
|
|
35
33
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
def screen(findings)
|
|
35
|
+
accepted, live = findings.map { [it, reason(it)] }.partition(&:last)
|
|
36
|
+
Screened.new(all: live.map(&:first), accepted:)
|
|
37
|
+
end
|
|
40
38
|
|
|
41
|
-
|
|
39
|
+
private
|
|
42
40
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
end
|
|
46
|
-
end
|
|
41
|
+
def reason(finding)
|
|
42
|
+
@entries.find { it.matches?(finding) }&.label
|
|
47
43
|
end
|
|
48
44
|
end
|