hashira 0.4.0 → 0.5.0

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