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.
Files changed (140) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +155 -0
  3. data/README.md +127 -25
  4. data/lib/hashira/analysis/finding.rb +7 -6
  5. data/lib/hashira/analysis/node_walk.rb +3 -3
  6. data/lib/hashira/analysis/syntax.rb +21 -6
  7. data/lib/hashira/analysis/type_walk.rb +7 -9
  8. data/lib/hashira/churn.rb +11 -13
  9. data/lib/hashira/ci/accepted.rb +27 -31
  10. data/lib/hashira/ci/baseline.rb +22 -24
  11. data/lib/hashira/ci/diff.rb +8 -7
  12. data/lib/hashira/ci/edge_diff_report.rb +13 -17
  13. data/lib/hashira/ci/finding_diff_report.rb +14 -18
  14. data/lib/hashira/ci/gate.rb +19 -23
  15. data/lib/hashira/ci/improvement.rb +9 -14
  16. data/lib/hashira/ci/ratchet.rb +32 -27
  17. data/lib/hashira/ci/ratchet_report.rb +26 -32
  18. data/lib/hashira/cli/arguments.rb +23 -0
  19. data/lib/hashira/cli/command_line.rb +55 -71
  20. data/lib/hashira/cli/fail_on.rb +24 -19
  21. data/lib/hashira/cli/options.rb +9 -4
  22. data/lib/hashira/cli/package_by.rb +14 -0
  23. data/lib/hashira/cli/run.rb +33 -27
  24. data/lib/hashira/cli/skip.rb +14 -20
  25. data/lib/hashira/cli/usage.rb +44 -41
  26. data/lib/hashira/cli.rb +17 -19
  27. data/lib/hashira/complexity/boolean_run.rb +13 -15
  28. data/lib/hashira/complexity/cognitive_score.rb +68 -66
  29. data/lib/hashira/complexity/if_chain.rb +33 -38
  30. data/lib/hashira/complexity/method_finding.rb +20 -42
  31. data/lib/hashira/complexity/method_score.rb +9 -7
  32. data/lib/hashira/complexity/rescue_scan.rb +16 -21
  33. data/lib/hashira/complexity/rollup.rb +13 -15
  34. data/lib/hashira/complexity/scores.rb +44 -0
  35. data/lib/hashira/coupling/audiences.rb +49 -0
  36. data/lib/hashira/coupling/catalog.rb +27 -0
  37. data/lib/hashira/coupling/census.rb +55 -0
  38. data/lib/hashira/coupling/constant_registry.rb +47 -0
  39. data/lib/hashira/coupling/cycle_findings.rb +31 -0
  40. data/lib/hashira/coupling/cycle_search.rb +37 -0
  41. data/lib/hashira/coupling/cycles.rb +14 -0
  42. data/lib/hashira/coupling/definition.rb +22 -0
  43. data/lib/hashira/coupling/definitions.rb +31 -0
  44. data/lib/hashira/coupling/edge.rb +10 -0
  45. data/lib/hashira/coupling/edge_map.rb +33 -0
  46. data/lib/hashira/coupling/folder_placement.rb +17 -0
  47. data/lib/hashira/coupling/folding.rb +56 -0
  48. data/lib/hashira/coupling/graph.rb +63 -0
  49. data/lib/hashira/coupling/metric.rb +15 -0
  50. data/lib/hashira/coupling/mixed_audience_findings.rb +32 -0
  51. data/lib/hashira/coupling/namespace_placement.rb +28 -0
  52. data/lib/hashira/{analysis → coupling}/namespace_prefix.rb +11 -11
  53. data/lib/hashira/coupling/naming.rb +14 -0
  54. data/lib/hashira/coupling/no_folding.rb +13 -0
  55. data/lib/hashira/coupling/placement.rb +19 -0
  56. data/lib/hashira/coupling/references.rb +55 -0
  57. data/lib/hashira/coupling/report.rb +28 -0
  58. data/lib/hashira/coupling/roll_call.rb +45 -0
  59. data/lib/hashira/coupling/roll_call_findings.rb +30 -0
  60. data/lib/hashira/coupling/roster.rb +25 -0
  61. data/lib/hashira/coupling/rule.rb +18 -0
  62. data/lib/hashira/coupling/scope.rb +28 -0
  63. data/lib/hashira/coupling/sdp_check.rb +14 -0
  64. data/lib/hashira/coupling/sdp_violation_findings.rb +25 -0
  65. data/lib/hashira/coupling/wide_edge_findings.rb +25 -0
  66. data/lib/hashira/coupling/words.rb +29 -0
  67. data/lib/hashira/diagram/dot.rb +8 -11
  68. data/lib/hashira/diagram/mermaid.rb +11 -16
  69. data/lib/hashira/diagram/source.rb +15 -0
  70. data/lib/hashira/duplication/clones.rb +17 -0
  71. data/lib/hashira/duplication/cluster.rb +14 -13
  72. data/lib/hashira/duplication/clusters.rb +46 -0
  73. data/lib/hashira/duplication/delta.rb +21 -30
  74. data/lib/hashira/duplication/duplication_finding.rb +17 -23
  75. data/lib/hashira/duplication/fragment.rb +19 -23
  76. data/lib/hashira/duplication/grouping.rb +12 -16
  77. data/lib/hashira/duplication/harvest.rb +27 -0
  78. data/lib/hashira/duplication/index.rb +20 -24
  79. data/lib/hashira/duplication/maximal.rb +11 -15
  80. data/lib/hashira/duplication/near_miss.rb +19 -22
  81. data/lib/hashira/duplication/sequence.rb +20 -22
  82. data/lib/hashira/duplication/similarity.rb +31 -34
  83. data/lib/hashira/duplication/union_find.rb +10 -14
  84. data/lib/hashira/duplication/variance.rb +35 -43
  85. data/lib/hashira/error.rb +4 -2
  86. data/lib/hashira/hotspots/file_cost.rb +8 -7
  87. data/lib/hashira/hotspots/rollup.rb +19 -21
  88. data/lib/hashira/pipeline.rb +41 -31
  89. data/lib/hashira/project.rb +50 -49
  90. data/lib/hashira/report/complexity_table.rb +27 -31
  91. data/lib/hashira/report/dependency_map.rb +17 -22
  92. data/lib/hashira/report/finding_lines.rb +15 -19
  93. data/lib/hashira/report/graph_payload.rb +14 -20
  94. data/lib/hashira/report/hotspot_table.rb +32 -37
  95. data/lib/hashira/report/json.rb +32 -32
  96. data/lib/hashira/report/metrics_table.rb +46 -33
  97. data/lib/hashira/report/phrases.rb +98 -0
  98. data/lib/hashira/report/smell_phrases.rb +68 -0
  99. data/lib/hashira/report/text.rb +59 -52
  100. data/lib/hashira/smells/census.rb +32 -0
  101. data/lib/hashira/smells/check.rb +30 -0
  102. data/lib/hashira/smells/conditions.rb +44 -0
  103. data/lib/hashira/smells/contexts.rb +64 -0
  104. data/lib/hashira/smells/control_parameter.rb +22 -0
  105. data/lib/hashira/smells/data_clump.rb +30 -0
  106. data/lib/hashira/smells/duplicate_method_call.rb +54 -0
  107. data/lib/hashira/smells/feature_envy.rb +17 -0
  108. data/lib/hashira/smells/instance_variable_assumption.rb +44 -0
  109. data/lib/hashira/smells/manual_dispatch.rb +16 -0
  110. data/lib/hashira/smells/module_initialize.rb +9 -0
  111. data/lib/hashira/smells/nil_check.rb +32 -0
  112. data/lib/hashira/smells/param_check.rb +51 -0
  113. data/lib/hashira/smells/refs.rb +53 -0
  114. data/lib/hashira/smells/repeated_conditional.rb +29 -0
  115. data/lib/hashira/smells/report.rb +31 -0
  116. data/lib/hashira/smells/scope.rb +29 -0
  117. data/lib/hashira/smells/too_many_instance_variables.rb +24 -0
  118. data/lib/hashira/smells/utility_function.rb +13 -0
  119. data/lib/hashira/smells/visibility.rb +64 -0
  120. data/lib/hashira/version.rb +1 -1
  121. data/lib/hashira.rb +131 -60
  122. metadata +62 -20
  123. data/lib/hashira/analysis/census.rb +0 -39
  124. data/lib/hashira/analysis/constant_registry.rb +0 -40
  125. data/lib/hashira/analysis/cycle_findings.rb +0 -33
  126. data/lib/hashira/analysis/cycle_search.rb +0 -44
  127. data/lib/hashira/analysis/definitions.rb +0 -27
  128. data/lib/hashira/analysis/edge.rb +0 -9
  129. data/lib/hashira/analysis/edge_map.rb +0 -36
  130. data/lib/hashira/analysis/graph.rb +0 -56
  131. data/lib/hashira/analysis/metric.rb +0 -14
  132. data/lib/hashira/analysis/references.rb +0 -39
  133. data/lib/hashira/analysis/rule.rb +0 -22
  134. data/lib/hashira/analysis/sdp_check.rb +0 -18
  135. data/lib/hashira/analysis/sdp_violation_findings.rb +0 -27
  136. data/lib/hashira/complexity/analyzer.rb +0 -45
  137. data/lib/hashira/diagram/renderer.rb +0 -20
  138. data/lib/hashira/duplication/analyzer.rb +0 -21
  139. data/lib/hashira/duplication/clusterer.rb +0 -50
  140. data/lib/hashira/duplication/extractor.rb +0 -31
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b806d4d5704515b3371235d38cdd4e60a0c37b98194c91922d57a8f5c448ffab
4
- data.tar.gz: 774766f13fa3b820557cb613e9d9b0c7ffcca2d220c30611562d1467a21f142b
3
+ metadata.gz: 574b35721d888046d7d8bbe02129896ebb6305aca89c30127c5372cb855bff74
4
+ data.tar.gz: ecd9eae0bf5a0cb5baf9b7637401ce0e12afdebcaa7e61e1c3f58b155e16a7bc
5
5
  SHA512:
6
- metadata.gz: 8bd5fc6feb807c1255adec14ee18405842a7125dbb4d661c777f85454a831f357484090d90ef39cdc2e1d854df037f61147ede6ef1dfa1854b945bfbb27cd686
7
- data.tar.gz: c853f3b158e0a1e501aeb48333c793eb92fcf0e51c9dabbdd538e643889dfd36141c1bfacfcd8bbebc03934cb0ec96690508cd7747cea99fb4b3e081b1aca4b9
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 duplication metrics for Ruby, read straight from the AST via [Prism](https://github.com/ruby/prism).**
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 three ways —
6
- which packages depend on which, how hard each method is to follow, and what has been
7
- copy-pasted then ranks every file by what it costs you against how often you
8
- actually change it. Every finding names the file and line behind it, and a committed
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
- - **Three analyzers, opt-out.** Coupling, complexity, and duplication run together by default; `--skip` drops any.
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 (9 packages, 75 files)
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 14 3 0 0.00 -
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
- (root) 3 2 4 0.67 -
97
- ci 8 1 2 0.67 -
98
- cli 6 0 4 1.00 -
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) -> analysis(4), complexity(1), duplication(1), hotspots(1) <- ci, cli
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::Report::Text#print 3 7 report/text.rb:11
113
- Hashira::Analysis::CycleSearch#cycle? 3 3 analysis/cycle_search.rb:19
114
- Hashira::CLI::CommandLine#usage_options 3 5 cli/command_line.rb:20
115
- Hashira::Duplication::Delta#kind 3 6 duplication/delta.rb:21
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::CLI::CommandLine 16 15 3
123
- Hashira::Analysis::CycleSearch 8 5 3
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
- cli/run.rb 1 36 2 74
131
- cli/command_line.rb 16 0 3 48
132
- pipeline.rb 7 0 3 21
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
- The three analyzers each answer a different question. The rollup joins them per
242
- file and adds the one signal that isn't in the AST — how often the file actually
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,complexity,duplication # any subset, comma-separated
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 = Data.define(:kind, :package, :message, :evidence, :cycle, :digest) do
6
- def initialize(cycle: nil, digest: nil, **rest) = super
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
- def signature = "#{kind}:#{identity}"
9
+ def signature = "#{kind}:#{identity}"
9
10
 
10
- def identity = digest || package
11
+ def identity = digest || package
11
12
 
12
- def to_h = super.compact
13
- end
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 each_node(node, &)
8
+ def each(node, &)
9
9
  yield(node)
10
- node.compact_child_nodes.each { each_node(it, &) }
10
+ node.compact_child_nodes.each { each(it, &) }
11
11
  end
12
12
 
13
13
  def collect(node)
14
14
  found = []
15
- each_node(node) { found << it }
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 path_segments(node)
10
+ def segments(node)
11
11
  case node
12
12
  when Prism::ConstantReadNode then [node.name.to_s]
13
- when Prism::ConstantPathNode then path_segments(node.parent) + name_of(node)
13
+ when Prism::ConstantPathNode then segments(node.parent) + label(node)
14
14
  else []
15
15
  end
16
16
  end
17
17
 
18
- def name_of(node) = [node.name.to_s]
18
+ def label(node) = [node.name.to_s]
19
19
 
20
- def direct_definitions(type_node)
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
- statements = body.is_a?(Prism::StatementsNode) ? body.body : [body]
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 each_definition(node, prefix = [], &)
11
- return each_child_definition(node, prefix, &) unless type_node?(node)
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
- each_child_definition(node.body, full, &)
14
+ descend(node.body, stack + [full], roots, &)
16
15
  end
17
16
 
18
- def type_node?(node) = node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
17
+ def type?(node) = node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
19
18
 
20
- def each_child_definition(node, prefix, &)
21
- children = node ? node.compact_child_nodes : []
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
- module Hashira
4
- class Churn
5
- LOG = "git log --name-only --format= 2>/dev/null"
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
- def self.from_git = new(tally(`#{LOG}`))
7
+ def self.scan = new(tally(`#{LOG}`))
9
8
 
10
- def self.tally(output) = output.split("\n").map(&:strip).reject(&:empty?).tally
9
+ def self.tally(output) = output.split("\n").map(&:strip).reject(&:empty?).tally
11
10
 
12
- def initialize(counts)
13
- @counts = counts
14
- end
11
+ def initialize(counts)
12
+ @counts = counts
13
+ end
15
14
 
16
- def hits(file) = @counts.select { |path, _| path.end_with?(file) }.values.max || 0
15
+ def hits(file) = @counts.select { |path, _| path.end_with?(file) }.values.max || 0
17
16
 
18
- def hot?(members) = changing(members) >= SITES_THAT_DRIFT_APART
17
+ def hot?(members) = changing(members) >= SITES_THAT_DRIFT_APART
19
18
 
20
- def changing(members) = members.count { |member| hits(member.file).positive? }
21
- end
19
+ def changing(members) = members.count { |member| hits(member.file).positive? }
22
20
  end
@@ -2,47 +2,43 @@
2
2
 
3
3
  require "json"
4
4
 
5
- module Hashira
6
- module CI
7
- class Accepted
8
- Screened = Data.define(:all, :accepted)
5
+ class Hashira::CI::Accepted
6
+ Screened = Data.define(:all, :accepted)
9
7
 
10
- Entry = Data.define(:kind, :package, :digest, :reason) do
11
- def self.from(hash)
12
- new(kind: hash["kind"], package: hash["package"], digest: hash["digest"], reason: hash["reason"])
13
- end
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
- def identity = digest || package
14
+ def matches?(finding) = kind == finding.kind && identity == finding.identity
18
15
 
19
- def label = reason || "accepted (no reason recorded)"
16
+ def identity = digest || package
20
17
 
21
- def to_h = { kind:, package:, digest:, reason: }.compact
22
- end
18
+ def label = reason || "accepted (no reason recorded)"
23
19
 
24
- def self.load(path)
25
- return new([]) unless path && File.exist?(path)
20
+ def to_h = { kind:, package:, digest:, reason: }.compact
21
+ end
26
22
 
27
- new(JSON.parse(File.read(path)).fetch("accepted", []))
28
- end
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
- def initialize(entries)
31
- @entries = entries.map { Entry.from(it) }
32
- end
28
+ def initialize(entries)
29
+ @entries = entries.map { Entry.from(it) }
30
+ end
33
31
 
34
- def entries = @entries.map(&:to_h)
32
+ def entries = @entries.map(&:to_h)
35
33
 
36
- def screen(findings)
37
- accepted, live = findings.map { [it, reason_for(it)] }.partition(&:last)
38
- Screened.new(all: live.map(&:first), accepted:)
39
- end
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
- private
39
+ private
42
40
 
43
- def reason_for(finding)
44
- @entries.find { it.matches?(finding) }&.label
45
- end
46
- end
41
+ def reason(finding)
42
+ @entries.find { it.matches?(finding) }&.label
47
43
  end
48
44
  end