hashira 0.2.0 → 0.4.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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +141 -0
  3. data/README.md +53 -5
  4. data/lib/hashira/analysis/catalog.rb +27 -0
  5. data/lib/hashira/analysis/census.rb +42 -35
  6. data/lib/hashira/analysis/constant_registry.rb +43 -0
  7. data/lib/hashira/analysis/cycle_findings.rb +34 -29
  8. data/lib/hashira/analysis/cycle_search.rb +28 -35
  9. data/lib/hashira/analysis/cycles.rb +14 -0
  10. data/lib/hashira/analysis/definition.rb +22 -0
  11. data/lib/hashira/analysis/definitions.rb +22 -18
  12. data/lib/hashira/analysis/edge.rb +4 -3
  13. data/lib/hashira/analysis/edge_map.rb +23 -26
  14. data/lib/hashira/analysis/finding.rb +7 -6
  15. data/lib/hashira/analysis/folder_placement.rb +17 -0
  16. data/lib/hashira/analysis/folding.rb +54 -0
  17. data/lib/hashira/analysis/graph.rb +35 -38
  18. data/lib/hashira/analysis/metric.rb +8 -7
  19. data/lib/hashira/analysis/namespace_placement.rb +28 -0
  20. data/lib/hashira/analysis/namespace_prefix.rb +32 -0
  21. data/lib/hashira/analysis/naming.rb +14 -0
  22. data/lib/hashira/analysis/no_folding.rb +13 -0
  23. data/lib/hashira/analysis/node_walk.rb +3 -3
  24. data/lib/hashira/analysis/placement.rb +19 -0
  25. data/lib/hashira/analysis/references.rb +14 -13
  26. data/lib/hashira/analysis/resolver.rb +28 -0
  27. data/lib/hashira/analysis/roster.rb +25 -0
  28. data/lib/hashira/analysis/rule.rb +10 -14
  29. data/lib/hashira/analysis/sdp_check.rb +8 -12
  30. data/lib/hashira/analysis/sdp_violation_findings.rb +20 -18
  31. data/lib/hashira/analysis/syntax.rb +21 -6
  32. data/lib/hashira/analysis/type_walk.rb +7 -9
  33. data/lib/hashira/churn.rb +11 -13
  34. data/lib/hashira/ci/accepted.rb +27 -31
  35. data/lib/hashira/ci/baseline.rb +22 -24
  36. data/lib/hashira/ci/diff.rb +8 -7
  37. data/lib/hashira/ci/edge_diff_report.rb +13 -17
  38. data/lib/hashira/ci/finding_diff_report.rb +14 -18
  39. data/lib/hashira/ci/gate.rb +19 -23
  40. data/lib/hashira/ci/improvement.rb +9 -14
  41. data/lib/hashira/ci/ratchet.rb +36 -27
  42. data/lib/hashira/ci/ratchet_report.rb +26 -32
  43. data/lib/hashira/cli/arguments.rb +23 -0
  44. data/lib/hashira/cli/command_line.rb +55 -71
  45. data/lib/hashira/cli/fail_on.rb +15 -20
  46. data/lib/hashira/cli/options.rb +9 -4
  47. data/lib/hashira/cli/package_by.rb +14 -0
  48. data/lib/hashira/cli/run.rb +29 -27
  49. data/lib/hashira/cli/skip.rb +14 -20
  50. data/lib/hashira/cli/usage.rb +41 -41
  51. data/lib/hashira/cli.rb +17 -19
  52. data/lib/hashira/complexity/analyzer.rb +30 -32
  53. data/lib/hashira/complexity/boolean_run.rb +11 -15
  54. data/lib/hashira/complexity/cognitive_score.rb +61 -66
  55. data/lib/hashira/complexity/if_chain.rb +33 -38
  56. data/lib/hashira/complexity/method_finding.rb +43 -48
  57. data/lib/hashira/complexity/method_score.rb +9 -7
  58. data/lib/hashira/complexity/rescue_scan.rb +16 -21
  59. data/lib/hashira/complexity/rollup.rb +13 -15
  60. data/lib/hashira/diagram/dot.rb +8 -11
  61. data/lib/hashira/diagram/mermaid.rb +11 -16
  62. data/lib/hashira/diagram/renderer.rb +10 -15
  63. data/lib/hashira/duplication/analyzer.rb +10 -14
  64. data/lib/hashira/duplication/cluster.rb +15 -12
  65. data/lib/hashira/duplication/clusterer.rb +31 -29
  66. data/lib/hashira/duplication/delta.rb +29 -30
  67. data/lib/hashira/duplication/duplication_finding.rb +19 -21
  68. data/lib/hashira/duplication/extractor.rb +15 -19
  69. data/lib/hashira/duplication/fragment.rb +19 -23
  70. data/lib/hashira/duplication/grouping.rb +12 -16
  71. data/lib/hashira/duplication/index.rb +20 -24
  72. data/lib/hashira/duplication/maximal.rb +11 -15
  73. data/lib/hashira/duplication/near_miss.rb +19 -22
  74. data/lib/hashira/duplication/sequence.rb +20 -22
  75. data/lib/hashira/duplication/similarity.rb +28 -35
  76. data/lib/hashira/duplication/union_find.rb +10 -14
  77. data/lib/hashira/duplication/variance.rb +36 -44
  78. data/lib/hashira/error.rb +4 -2
  79. data/lib/hashira/hotspots/file_cost.rb +8 -7
  80. data/lib/hashira/hotspots/rollup.rb +19 -21
  81. data/lib/hashira/pipeline.rb +36 -31
  82. data/lib/hashira/project.rb +53 -32
  83. data/lib/hashira/report/complexity_table.rb +27 -31
  84. data/lib/hashira/report/dependency_map.rb +17 -22
  85. data/lib/hashira/report/finding_lines.rb +15 -19
  86. data/lib/hashira/report/graph_payload.rb +14 -20
  87. data/lib/hashira/report/hotspot_table.rb +32 -37
  88. data/lib/hashira/report/json.rb +30 -32
  89. data/lib/hashira/report/metrics_table.rb +46 -33
  90. data/lib/hashira/report/text.rb +59 -52
  91. data/lib/hashira/version.rb +1 -1
  92. data/lib/hashira.rb +92 -55
  93. metadata +16 -2
  94. data/lib/hashira/analysis/root_namespace.rb +0 -14
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8e51a640b2b2db8106e09f6679c9cd8687b834f8132db254b71ab6eb0c53c75c
4
- data.tar.gz: 3fc2559e8d15081d4d201dcd24496939ceff92ce6c21fe33248af4871e03755c
3
+ metadata.gz: 2e10a9ee1cb16f2b8090dfe962aaa9b8c0fc7b2bf598f507c31817cc6a4a334b
4
+ data.tar.gz: 88e97bb9d5a8b524928014aa2248c917dff0fa3d9a19e785cc2477a40341204c
5
5
  SHA512:
6
- metadata.gz: 7c676c8b8764ad0ebab7384a0cd72984efd979853842f3a51c28bb8e079815088f4d3e0fc1569442d4d46371d3cbb700364730cab85f176726c0b40acc6e45ba
7
- data.tar.gz: ea97955b88d6b57c52f980662eec0c0fb690dabac64d47734bf7ad3e94efbb4c18ced3483e8f8afb65833b807aaff248f4bcd37995875a24ea1031c244dc7ccc
6
+ metadata.gz: 332e29ec2e0841e9ee3476e9155d707b00b97ec116fd88a578bd3b38d4aa2f169cb2cd1dcb531fb9e22ba6909893559463f0282f151b0815f76729efd4e913fb
7
+ data.tar.gz: 2ab5337504ff4390112874e55cc661970b94d3638b79ad93900d0d2c642eaa2cdcfdd9e9acd21eb80bca0e5371585fa7a1ea0a6e7ed8512df60dfbc087b397cd
data/CHANGELOG.md CHANGED
@@ -5,6 +5,145 @@ 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.4.0] - 2026-08-02
9
+
10
+ ### Added
11
+
12
+ - Rails awareness. A directory with `config/application.rb` inside it (the
13
+ Rails root) or beside it (its `app` folder) is detected as a Rails app:
14
+ coupling defaults to namespace packaging, and under namespace packaging
15
+ references to app-defined `Application*` base classes (`ApplicationRecord`,
16
+ `ApplicationJob`, `ApplicationSerializer`, `ApplicationPolicy`, …) are
17
+ skipped as framework plumbing. An explicit `--package-by folder` keeps the
18
+ full legacy edge set, `Application*` references included.
19
+ - Baselines record their packaging mode (schema v3; older baselines read as
20
+ folder). `--ratchet` refuses a baseline recorded under another mode with
21
+ instructions to rerun with `--package-by <recorded>` or refresh via
22
+ `--update-baseline`, instead of failing every edge as drift after the
23
+ Rails default flips packaging.
24
+ - `--package-by folder|namespace`. Namespace packaging groups types by
25
+ top-level constant (`Billing`, `Ci`, `User`) across layer folders, so the
26
+ coupling tables and findings answer the domain question — does `Billing`
27
+ reach into `Ci`? — instead of restating Rails layout (`models -> jobs`).
28
+ Folder packaging stays the default outside Rails and remains available
29
+ everywhere via the flag.
30
+
31
+ ### Changed
32
+
33
+ - **Breaking (Ruby API only; the CLI is unchanged.)** Names throughout the
34
+ library are now single-word, following rubocop-elegant: `Graph#dependents_of`
35
+ is `#incoming`, `Graph#edge_list` is `#edges`, `Project#package_for` is
36
+ `#package`, `Churn.from_git` is `Churn.scan`, and `Similarity#at_least?` is
37
+ `#meets?`. Cycle queries moved off `Graph` onto `Graph#cycles`:
38
+ `graph.cyclic?(p)`, `graph.cycle(p)`, and `graph.weakest(path)` are now
39
+ `graph.cycles.through?(p)`, `graph.cycles.path(p)`, and
40
+ `graph.cycles.weakest(path)`.
41
+ - One cycle finding per distinct loop, reported from its smallest member,
42
+ instead of one per participating package.
43
+ - Under namespace packaging, a top-level class that anchors no namespace of
44
+ its own and inherits from an app-defined class folds into its base's
45
+ package, transitively — a flat family of notification subclasses reports
46
+ as one package, not twenty.
47
+ - Past 25 rows, the metrics table hides single-type packages with no
48
+ outgoing edges and at most one incoming behind a count line; they stay in
49
+ the graph, so their afferent weight still counts. A heavily depended-upon
50
+ package (high Ca) always keeps its row — its stability is the point of
51
+ the table.
52
+ - Under namespace packaging in a Rails app, a singleton class named by
53
+ convention (`SandboxResource`, `UserSerializer`, `AccountPolicy`,
54
+ `PlanDecorator`) folds into its domain's package when that package exists;
55
+ an app-defined superclass still takes precedence over the name.
56
+ - Every fold is disclosed: a `Folded` list under the coupling tables and a
57
+ `folds` array in `--json`, each entry naming the fold and whether it came
58
+ from a base class or a naming suffix.
59
+ - Classes count toward TC even when their body is pure DSL (Alba resources,
60
+ notifiers); only modules still need a directly defined method.
61
+
62
+ ### Fixed
63
+
64
+ - References into `Application*` namespaces (`ApplicationCable::Channel`)
65
+ are skipped in Rails apps like the bases themselves, and no longer pull
66
+ channels into a plumbing package.
67
+ - A proper prefix of a reference only matches exact definition paths: with
68
+ an app-defined `Billing::Stripe`, the gem constant `Stripe::RateLimitError`
69
+ no longer resolves to `Billing` when `RateLimitError` is unknown.
70
+ Whole-reference suffix shorthand is untouched.
71
+ - Constants resolve through their lexical nesting, like Ruby. A bare
72
+ `Authentication` inside `class User` now resolves to `User::Authentication`
73
+ before a top-level `Authentication` in another package, superclasses
74
+ resolve in the enclosing scope (but are charged to the class they define),
75
+ and a scoped hit claimed by several packages resolves to nothing rather
76
+ than falling through to a namesake. Kills phantom cross-package edges in
77
+ Rails apps, where nested concerns routinely shadow top-level names.
78
+ - `::`-anchored references resolve at top level only, like Ruby: `::User`
79
+ inside `module Admin` binds to the top-level `User`, never a nested
80
+ `Admin::User` namesake.
81
+ - A constant under a namespaced class (`Invoice::STATES` with
82
+ `Admin::Invoice` defined, referenced inside `Admin`) resolves through the
83
+ enclosing scope by longest registered prefix, so the edge to the class's
84
+ package is kept.
85
+ - A compact reopen (`class Foo::Bar` inside `module Baz`) anchors its root
86
+ like Ruby — innermost enclosing scope that defines it, else top level —
87
+ so its types and references are charged to `Foo`, not `Baz`.
88
+ - A class reopened across files counts once toward TC, and a lone subclass
89
+ reopened in a later-sorting file keeps its base fold; fold results no
90
+ longer depend on file order.
91
+ - Mutually-linked folds (a base fold one way, a suffix fold the other)
92
+ merge into one package instead of swapping the two packages' identities,
93
+ and a fold link from a package to itself is dropped instead of being
94
+ disclosed as `X -> X`.
95
+ - A superclass resolves only against registered definition paths: a bare
96
+ `Base` no longer folds its subclass into an unrelated `Admin::Base`
97
+ matched by suffix shorthand.
98
+ - Namespace-prefix inference votes with every distinct definition path and
99
+ requires a wrapper to enclose all of them, so a domain namespace sharing
100
+ a single folder with top-level classes is kept as a package instead of
101
+ being stripped as a gem wrapper.
102
+ - `--package-by auto` is accepted as the explicit spelling of the default.
103
+
104
+ ## [0.3.0] - 2026-07-26
105
+
106
+ ### Changed
107
+
108
+ - Package boundaries are found at any depth. Directory detection descends
109
+ single-folder wrapper chains (`lib` → `lib/gem` → `lib/gem/core`), so
110
+ `hashira`, `hashira lib`, and `hashira lib/gem/core` land on the same
111
+ boundaries; descent stops at loose code files. Constant resolution now
112
+ strips the inferred shared namespace *prefix* (majority per level across
113
+ packages) instead of a single root module, so analyzing a nested subtree
114
+ resolves cross-package references instead of silently reporting no edges.
115
+ - With several directories, same-named subfolders no longer merge into one
116
+ package: a contested name is qualified by its directory (`app/models` vs
117
+ `lib/models`); unique names stay short.
118
+ - Constant resolution is path-based. Each definition registers its full
119
+ constant path and its suffixes as shorthand; a sighting resolves by longest
120
+ match, and a name claimed by several packages resolves to nothing rather
121
+ than to the last one parsed. A namespace mirrored across layers
122
+ (`Admin::Account` in `app/models/admin`, `Admin::AccountsController` in
123
+ `app/controllers/admin`) now attributes each reference to the right side —
124
+ a model reaching into its controller layer shows up as an edge (and a
125
+ cycle) instead of vanishing as a self-reference — and a bare reference to
126
+ a name declared in exactly one package (`Skill.all`) now counts.
127
+
128
+ ### Fixed
129
+
130
+ - Duplication: a listing interrupted by a statement of another shape is no
131
+ longer windowed as a clone. The rule applied only when an entire sibling run
132
+ was homogeneous, so one trailing `module` after a block of requires — or a
133
+ `banner =` before a run of `o.on` calls — put the whole list back in scope.
134
+ Listings are now the maximal same-shape stretches within a run, and they are
135
+ opaque: no window reaches into one, so a list row never lends its mass to the
136
+ statements beside it. Two files ending a require block with `module Foo` no
137
+ longer match on the tail of the block, and a genuine clone next to a list is
138
+ weighed on its own size rather than the list's.
139
+ - Duplication: a near-miss neighbour no longer buries the exact clone pair
140
+ inside its cluster. Exact matches and near misses are unioned into one
141
+ cluster, which is then judged as a whole — so a single fuzzy member raised the
142
+ mass floor from 16 to 40 and took the exact pair down with it, and adding a
143
+ third, sloppier copy of a duplicated method made the finding disappear. A
144
+ cluster that misses the raised floor now falls back to its identically shaped
145
+ core and is weighed again on the floor that evidence earns.
146
+
8
147
  ## [0.2.0] - 2026-07-25
9
148
 
10
149
  ### Added
@@ -70,5 +209,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
70
209
  - Output formats: text, JSON, Graphviz dot, Mermaid (`--format`, `--json`).
71
210
  - `--help` and `--version`.
72
211
 
212
+ [0.4.0]: https://github.com/giacope/hashira/releases/tag/v0.4.0
213
+ [0.3.0]: https://github.com/giacope/hashira/releases/tag/v0.3.0
73
214
  [0.2.0]: https://github.com/giacope/hashira/releases/tag/v0.2.0
74
215
  [0.1.0]: https://github.com/giacope/hashira/releases/tag/v0.1.0
data/README.md CHANGED
@@ -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) · [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
 
@@ -65,7 +65,9 @@ Requires Ruby 3.4 or newer.
65
65
 
66
66
  ## Getting started
67
67
 
68
- Point hashira at your code, or run it with no arguments to auto-detect `lib/<gem>`:
68
+ Point hashira at your code, or run it with no arguments to auto-detect `lib/<gem>`.
69
+ Single-folder wrapper chains are descended automatically, so `hashira`,
70
+ `hashira lib`, and `hashira lib/gem/core` land on the same package boundaries:
69
71
 
70
72
  ```sh
71
73
  hashira # auto-detects lib/<gem>
@@ -156,6 +158,47 @@ domain layer near 0.00. The findings are about arrows pointing the wrong way:
156
158
  Each finding comes with file-level evidence; for cycles, the shortest cycle
157
159
  path and its lightest edge. What a finding means for your design is your call.
158
160
 
161
+ ## Rails apps
162
+
163
+ Rails layer folders are framework layout, not architecture: models will always
164
+ touch jobs and mailers, so folder packages under `app/` report idioms as
165
+ findings. When the analyzed directory contains a `config/application.rb` (the
166
+ Rails root) or sits beside one (its `app` folder), hashira switches to
167
+ **namespace packaging**: types group by top-level constant
168
+ (`Billing`, `Ci`, `User`) across the layer folders, edges join domains, and the
169
+ findings answer the question a Rails monolith actually has — does `Billing`
170
+ reach into `Ci`?
171
+
172
+ ```console
173
+ $ hashira app
174
+ package TC Ca Ce I Cyc
175
+ ----------------------------------------
176
+ Account 26 21 18 0.46 YES
177
+ Billing 116 12 11 0.48 YES
178
+ Ci 107 9 16 0.64 YES
179
+ ...
180
+ cycle: Account can reach itself: Account -> User -> Account — any change
181
+ may ripple back around. The lightest edge on this cycle is Account -> User (1 ref).
182
+ · models/account.rb:36: User
183
+ · models/user/signup.rb:32: Account
184
+ ```
185
+
186
+ Under namespace packaging, references to app-defined `Application*` base
187
+ classes (`ApplicationRecord`, `ApplicationJob`, …) are skipped as framework
188
+ plumbing; `--package-by folder` keeps them, so the legacy layer view stays
189
+ complete. Constant resolution
190
+ follows Ruby's lexical nesting everywhere — a bare `Authentication` inside
191
+ `class User` is `User::Authentication`, not a top-level namesake in another
192
+ package — which matters most in Rails apps, where nested concerns routinely
193
+ shadow top-level names.
194
+
195
+ Either grouping can be forced anywhere:
196
+
197
+ ```sh
198
+ hashira app --package-by folder # layer view, even in a Rails app
199
+ hashira lib/gem --package-by namespace
200
+ ```
201
+
159
202
  ## Cognitive complexity
160
203
 
161
204
  hashira scores every method with **cognitive complexity**, not an ABC or call-count
@@ -264,9 +307,14 @@ letter repeated — it tells you nothing about what to open first.
264
307
  **Coupling.** A dependency edge A→B exists when a file in package A references a
265
308
  constant declared by package B. Declarations are read from the AST; strings and
266
309
  comments are invisible. A type counts toward TC only if it defines a method
267
- directly in its body; pure namespace wrappers don't count. The root namespace is
268
- inferred (the most common outermost constant), so `App::Alpha` and `Alpha` resolve
269
- to the same package. Each edge carries a **weight**: the number of constant
310
+ directly in its body; pure namespace wrappers don't count. The namespace prefix
311
+ shared by the packages is inferred (`App`, or `App::Core` when analyzing a nested
312
+ subtree), so `App::Alpha` and `Alpha` resolve to the same package. Resolution is
313
+ by longest constant path, so a namespace mirrored across packages
314
+ (`Admin::Account` in models, `Admin::AccountsController` in controllers) sends
315
+ each reference to the right side; a bare name declared in exactly one package
316
+ resolves there, and a name several packages claim resolves to nothing rather
317
+ than to a guess. Each edge carries a **weight**: the number of constant
270
318
  references backing it. A root-level file `x.rb` folds into package `x` when a
271
319
  sibling folder `x/` exists; everything else at the top level lands in `(root)`.
272
320
 
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Analysis::Catalog
4
+ include Enumerable
5
+
6
+ def initialize(definitions)
7
+ @definitions = definitions
8
+ @naming = Hashira::Analysis::Naming.new(definitions)
9
+ @entries = definitions.map { |node, full, folder| entry(node, full, folder) }
10
+ end
11
+
12
+ def each(&) = @entries.each(&)
13
+
14
+ def prefix = @naming.segments
15
+
16
+ def strip(path) = @naming.strip(path)
17
+
18
+ def roots = @definitions.roots
19
+
20
+ def folders = @definitions.packages
21
+
22
+ private
23
+
24
+ def entry(node, full, folder)
25
+ Hashira::Analysis::Definition.new(node:, path: @naming.strip(full), folder:)
26
+ end
27
+ end
@@ -1,39 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- module Analysis
5
- class Census
6
- def initialize(project, trees)
7
- @definitions = Definitions.new(project, trees)
8
- @type_count = Hash.new(0)
9
- @declaring_package = {}
10
- @root_namespace = RootNamespace.infer(@definitions)
11
- take
12
- end
13
-
14
- attr_reader :type_count, :declaring_package, :root_namespace
15
-
16
- def packages = (@type_count.keys | @definitions.packages)
17
-
18
- def resolve(segments)
19
- outer, inner = segments.first(2)
20
- @declaring_package[outer == root_namespace ? inner : outer]
21
- end
22
-
23
- private
24
-
25
- def take
26
- @definitions.each do |node, full, package|
27
- register(full, package)
28
- @type_count[package] += 1 unless Syntax.direct_definitions(node).empty?
29
- end
30
- end
31
-
32
- def register(full, package)
33
- outer, inner = full.first(2)
34
- name = outer == root_namespace ? inner : outer
35
- @declaring_package[name] = package if name
36
- end
37
- end
3
+ class Hashira::Analysis::Census
4
+ def initialize(project, trees, packaging: :folder)
5
+ @catalog = Hashira::Analysis::Catalog.new(Hashira::Analysis::Definitions.new(project, trees))
6
+ @placement = Hashira::Analysis::Placement.build(packaging, project, @catalog)
7
+ @folding = Hashira::Analysis::NoFolding
8
+ @roster = tally
9
+ settle
38
10
  end
11
+
12
+ def packaging = @placement.mode
13
+
14
+ def types = @roster.types
15
+
16
+ def prefix = @catalog.prefix
17
+
18
+ def origins = @roster.origins
19
+
20
+ def roots = @catalog.roots
21
+
22
+ def packages = @roster.packages | @placement.baseline
23
+
24
+ def folds = @folding.disclosed
25
+
26
+ def resolve(segments, nesting = []) = resolver.resolve(segments, nesting)
27
+
28
+ def pinpoint(segments) = resolver.pinpoint(segments)
29
+
30
+ def charge(file, nesting) = translate(@placement.charge(file, nesting))
31
+
32
+ private
33
+
34
+ def tally
35
+ Hashira::Analysis::Roster.new(@placement.placed.map { |definition, package| [definition, translate(package)] })
36
+ end
37
+
38
+ def settle
39
+ @folding = @placement.folding(self)
40
+ @roster = tally unless @folding.map.empty?
41
+ end
42
+
43
+ def translate(package) = @folding.map.fetch(package, package)
44
+
45
+ def resolver = Hashira::Analysis::Resolver.new(@roster.registry, @catalog, @placement)
39
46
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Analysis::ConstantRegistry
4
+ AMBIGUOUS = Object.new.freeze
5
+
6
+ def initialize
7
+ @origins = {}
8
+ @shorthand = {}
9
+ end
10
+
11
+ attr_reader :origins
12
+
13
+ def register(path, package)
14
+ return if path.empty?
15
+ claim(@origins, path, package)
16
+ (1...path.length).each { claim(@shorthand, path.drop(it), package) }
17
+ end
18
+
19
+ def package(path)
20
+ found = anchored(path) || enclosing(path)
21
+ found unless found == AMBIGUOUS
22
+ end
23
+
24
+ def exact(path) = @origins[path.join("::")]
25
+
26
+ def packages = (@origins.values.uniq - [AMBIGUOUS])
27
+
28
+ private
29
+
30
+ def claim(claims, path, package)
31
+ key = path.join("::")
32
+ claims[key] = claims.fetch(key, package) == package ? package : AMBIGUOUS
33
+ end
34
+
35
+ def anchored(path)
36
+ key = path.join("::")
37
+ @origins[key] || @shorthand[key]
38
+ end
39
+
40
+ def enclosing(path)
41
+ (path.length - 1).downto(1).filter_map { @origins[path.first(it).join("::")] }.first
42
+ end
43
+ end
@@ -1,33 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- module Analysis
5
- class CycleFindings < Rule
6
- KIND = "cycle"
7
-
8
- def list
9
- graph.packages.select { graph.cyclic?(it) }.sort.map { cycle_finding(it) }
10
- end
11
-
12
- private
13
-
14
- def cycle_finding(package)
15
- path = graph.cycle_path(package)
16
- finding(package:, cycle: path, evidence: evidence(path),
17
- message: message(package, path, graph.weakest_edge(path)))
18
- end
19
-
20
- def message(package, path, weak_edge)
21
- weak_from, weak_to = weak_edge
22
- weight = graph.weight(weak_from, weak_to)
23
- "#{package} can reach itself: #{path.join(" -> ")} — any change may ripple back " \
24
- "around. The lightest edge on this cycle is #{weak_from} -> #{weak_to} " \
25
- "(#{weight} ref#{"s" unless weight == 1})."
26
- end
27
-
28
- def evidence(path)
29
- path.each_cons(2).flat_map { |from, to| graph.evidence_for(from, to).to_a.first(2) }
30
- end
31
- end
3
+ require_relative "rule"
4
+
5
+ class Hashira::Analysis::CycleFindings < Hashira::Analysis::Rule
6
+ KIND = "cycle"
7
+
8
+ def list
9
+ loops.map { entry(it) }
10
+ end
11
+
12
+ private
13
+
14
+ def loops
15
+ cycles = graph.cycles
16
+ graph.packages.select { cycles.through?(it) }.sort.map { cycles.path(it) }.uniq { it[..-2].sort }
17
+ end
18
+
19
+ def entry(path)
20
+ package = path.first
21
+ finding(
22
+ package:, cycle: path, evidence: evidence(path),
23
+ message: message(package, path, graph.cycles.weakest(path))
24
+ )
25
+ end
26
+
27
+ def message(package, path, weak_edge)
28
+ from, to = weak_edge
29
+ weight = graph.weight(from, to)
30
+ "#{package} can reach itself: #{path.join(" -> ")} — any change may ripple back " \
31
+ "around. The lightest edge on this cycle is #{from} -> #{to} " \
32
+ "(#{weight} ref#{"s" unless weight == 1})."
33
+ end
34
+
35
+ def evidence(path)
36
+ path.each_cons(2).flat_map { |from, to| graph.evidence(from, to).to_a.first(2) }
32
37
  end
33
38
  end
@@ -1,44 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- module Analysis
5
- class CycleSearch
6
- def initialize(dependencies, package)
7
- @dependencies = dependencies
8
- @package = package
9
- @predecessor = {}
10
- @queue = dependencies[package].to_a.each { @predecessor[it] = package }
11
- end
12
-
13
- def path
14
- trace_back([@package]) if cycle?
15
- end
16
-
17
- private
18
-
19
- def cycle?
20
- while (node = @queue.shift)
21
- return true if node == @package
22
-
23
- visit(node)
24
- end
25
- end
3
+ class Hashira::Analysis::CycleSearch
4
+ def initialize(dependencies, package)
5
+ @dependencies = dependencies
6
+ @package = package
7
+ @predecessor = {}
8
+ @queue = dependencies[package].to_a.each { @predecessor[it] = package }
9
+ end
26
10
 
27
- def visit(node)
28
- @dependencies[node].each do |neighbor|
29
- next if @predecessor.key?(neighbor)
11
+ def path
12
+ unwind([@package]) if cycle?
13
+ end
30
14
 
31
- @predecessor[neighbor] = node
32
- @queue << neighbor
33
- end
34
- end
15
+ private
35
16
 
36
- def trace_back(path)
37
- first = path.first
38
- return path if first == @package && path.size > 1
17
+ def cycle?
18
+ while (node = @queue.shift)
19
+ return true if node == @package
20
+ visit(node)
21
+ end
22
+ end
39
23
 
40
- trace_back(path.unshift(@predecessor[first]))
41
- end
24
+ def visit(node)
25
+ @dependencies[node].each do |neighbor|
26
+ next if @predecessor.key?(neighbor)
27
+ @predecessor[neighbor] = node
28
+ @queue << neighbor
42
29
  end
43
30
  end
31
+
32
+ def unwind(path)
33
+ first = path.first
34
+ return path if first == @package && path.size > 1
35
+ unwind(path.unshift(@predecessor[first]))
36
+ end
44
37
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Analysis::Cycles
4
+ def initialize(dependencies, graph)
5
+ @dependencies = dependencies
6
+ @graph = graph
7
+ end
8
+
9
+ def through?(package) = !!path(package)
10
+
11
+ def path(package) = Hashira::Analysis::CycleSearch.new(@dependencies, package).path
12
+
13
+ def weakest(trail) = trail.each_cons(2).min_by { |from, to| @graph.weight(from, to) }
14
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Hashira
6
+ module Analysis
7
+ Definition =
8
+ Data.define(:node, :path, :folder) do
9
+ def name = path.first
10
+
11
+ def nested? = path.length > 1
12
+
13
+ def klass? = node.is_a?(Prism::ClassNode)
14
+
15
+ def singular? = klass? && !nested?
16
+
17
+ def superclass = node.superclass
18
+
19
+ def counted? = klass? || !Hashira::Analysis::Syntax.direct(node).empty?
20
+ end
21
+ end
22
+ end
@@ -1,27 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- module Analysis
5
- class Definitions
6
- include Enumerable
3
+ class Hashira::Analysis::Definitions
4
+ include Enumerable
7
5
 
8
- def initialize(project, trees)
9
- @project = project
10
- @trees = trees
11
- end
6
+ def initialize(project, trees)
7
+ @project = project
8
+ @trees = trees
9
+ end
10
+
11
+ def each(&)
12
+ @trees.each { |file, tree| scan(file, tree, &) }
13
+ end
12
14
 
13
- def each(&)
14
- @trees.each { |file, tree| definitions_in(file, tree, &) }
15
- end
15
+ def packages = @trees.keys.map { @project.package(it) }.uniq
16
16
 
17
- def packages = @trees.keys.map { @project.package_for(it) }.uniq
17
+ def roots
18
+ @roots ||= @trees.each_value.with_object(Set.new) { |tree, set| survey(tree, set) }
19
+ end
20
+
21
+ private
18
22
 
19
- private
23
+ def survey(tree, set)
24
+ Hashira::Analysis::TypeWalk.each(tree) { |_node, full| set << full }
25
+ end
20
26
 
21
- def definitions_in(file, tree)
22
- package = @project.package_for(file)
23
- TypeWalk.each_definition(tree) { |node, full| yield node, full, package }
24
- end
25
- end
27
+ def scan(file, tree)
28
+ package = @project.package(file)
29
+ Hashira::Analysis::TypeWalk.each(tree, roots: roots) { |node, full| yield(node, full, package) }
26
30
  end
27
31
  end
@@ -2,8 +2,9 @@
2
2
 
3
3
  module Hashira
4
4
  module Analysis
5
- Edge = Data.define(:from, :to) do
6
- def to_s = "#{from} -> #{to}"
7
- end
5
+ Edge =
6
+ Data.define(:from, :to) do
7
+ def to_s = "#{from} -> #{to}"
8
+ end
8
9
  end
9
10
  end