hashira 0.4.0 → 0.5.1

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