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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +141 -0
- data/README.md +53 -5
- data/lib/hashira/analysis/catalog.rb +27 -0
- data/lib/hashira/analysis/census.rb +42 -35
- data/lib/hashira/analysis/constant_registry.rb +43 -0
- data/lib/hashira/analysis/cycle_findings.rb +34 -29
- data/lib/hashira/analysis/cycle_search.rb +28 -35
- data/lib/hashira/analysis/cycles.rb +14 -0
- data/lib/hashira/analysis/definition.rb +22 -0
- data/lib/hashira/analysis/definitions.rb +22 -18
- data/lib/hashira/analysis/edge.rb +4 -3
- data/lib/hashira/analysis/edge_map.rb +23 -26
- data/lib/hashira/analysis/finding.rb +7 -6
- data/lib/hashira/analysis/folder_placement.rb +17 -0
- data/lib/hashira/analysis/folding.rb +54 -0
- data/lib/hashira/analysis/graph.rb +35 -38
- data/lib/hashira/analysis/metric.rb +8 -7
- data/lib/hashira/analysis/namespace_placement.rb +28 -0
- data/lib/hashira/analysis/namespace_prefix.rb +32 -0
- data/lib/hashira/analysis/naming.rb +14 -0
- data/lib/hashira/analysis/no_folding.rb +13 -0
- data/lib/hashira/analysis/node_walk.rb +3 -3
- data/lib/hashira/analysis/placement.rb +19 -0
- data/lib/hashira/analysis/references.rb +14 -13
- data/lib/hashira/analysis/resolver.rb +28 -0
- data/lib/hashira/analysis/roster.rb +25 -0
- data/lib/hashira/analysis/rule.rb +10 -14
- data/lib/hashira/analysis/sdp_check.rb +8 -12
- data/lib/hashira/analysis/sdp_violation_findings.rb +20 -18
- data/lib/hashira/analysis/syntax.rb +21 -6
- data/lib/hashira/analysis/type_walk.rb +7 -9
- data/lib/hashira/churn.rb +11 -13
- data/lib/hashira/ci/accepted.rb +27 -31
- data/lib/hashira/ci/baseline.rb +22 -24
- data/lib/hashira/ci/diff.rb +8 -7
- data/lib/hashira/ci/edge_diff_report.rb +13 -17
- data/lib/hashira/ci/finding_diff_report.rb +14 -18
- data/lib/hashira/ci/gate.rb +19 -23
- data/lib/hashira/ci/improvement.rb +9 -14
- data/lib/hashira/ci/ratchet.rb +36 -27
- data/lib/hashira/ci/ratchet_report.rb +26 -32
- data/lib/hashira/cli/arguments.rb +23 -0
- data/lib/hashira/cli/command_line.rb +55 -71
- data/lib/hashira/cli/fail_on.rb +15 -20
- data/lib/hashira/cli/options.rb +9 -4
- data/lib/hashira/cli/package_by.rb +14 -0
- data/lib/hashira/cli/run.rb +29 -27
- data/lib/hashira/cli/skip.rb +14 -20
- data/lib/hashira/cli/usage.rb +41 -41
- data/lib/hashira/cli.rb +17 -19
- data/lib/hashira/complexity/analyzer.rb +30 -32
- data/lib/hashira/complexity/boolean_run.rb +11 -15
- data/lib/hashira/complexity/cognitive_score.rb +61 -66
- data/lib/hashira/complexity/if_chain.rb +33 -38
- data/lib/hashira/complexity/method_finding.rb +43 -48
- data/lib/hashira/complexity/method_score.rb +9 -7
- data/lib/hashira/complexity/rescue_scan.rb +16 -21
- data/lib/hashira/complexity/rollup.rb +13 -15
- data/lib/hashira/diagram/dot.rb +8 -11
- data/lib/hashira/diagram/mermaid.rb +11 -16
- data/lib/hashira/diagram/renderer.rb +10 -15
- data/lib/hashira/duplication/analyzer.rb +10 -14
- data/lib/hashira/duplication/cluster.rb +15 -12
- data/lib/hashira/duplication/clusterer.rb +31 -29
- data/lib/hashira/duplication/delta.rb +29 -30
- data/lib/hashira/duplication/duplication_finding.rb +19 -21
- data/lib/hashira/duplication/extractor.rb +15 -19
- data/lib/hashira/duplication/fragment.rb +19 -23
- data/lib/hashira/duplication/grouping.rb +12 -16
- data/lib/hashira/duplication/index.rb +20 -24
- data/lib/hashira/duplication/maximal.rb +11 -15
- data/lib/hashira/duplication/near_miss.rb +19 -22
- data/lib/hashira/duplication/sequence.rb +20 -22
- data/lib/hashira/duplication/similarity.rb +28 -35
- data/lib/hashira/duplication/union_find.rb +10 -14
- data/lib/hashira/duplication/variance.rb +36 -44
- data/lib/hashira/error.rb +4 -2
- data/lib/hashira/hotspots/file_cost.rb +8 -7
- data/lib/hashira/hotspots/rollup.rb +19 -21
- data/lib/hashira/pipeline.rb +36 -31
- data/lib/hashira/project.rb +53 -32
- data/lib/hashira/report/complexity_table.rb +27 -31
- data/lib/hashira/report/dependency_map.rb +17 -22
- data/lib/hashira/report/finding_lines.rb +15 -19
- data/lib/hashira/report/graph_payload.rb +14 -20
- data/lib/hashira/report/hotspot_table.rb +32 -37
- data/lib/hashira/report/json.rb +30 -32
- data/lib/hashira/report/metrics_table.rb +46 -33
- data/lib/hashira/report/text.rb +59 -52
- data/lib/hashira/version.rb +1 -1
- data/lib/hashira.rb +92 -55
- metadata +16 -2
- data/lib/hashira/analysis/root_namespace.rb +0 -14
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2e10a9ee1cb16f2b8090dfe962aaa9b8c0fc7b2bf598f507c31817cc6a4a334b
|
|
4
|
+
data.tar.gz: 88e97bb9d5a8b524928014aa2248c917dff0fa3d9a19e785cc2477a40341204c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
11
|
+
def path
|
|
12
|
+
unwind([@package]) if cycle?
|
|
13
|
+
end
|
|
30
14
|
|
|
31
|
-
|
|
32
|
-
@queue << neighbor
|
|
33
|
-
end
|
|
34
|
-
end
|
|
15
|
+
private
|
|
35
16
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
17
|
+
def cycle?
|
|
18
|
+
while (node = @queue.shift)
|
|
19
|
+
return true if node == @package
|
|
20
|
+
visit(node)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
39
23
|
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
class Definitions
|
|
6
|
-
include Enumerable
|
|
3
|
+
class Hashira::Analysis::Definitions
|
|
4
|
+
include Enumerable
|
|
7
5
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
@trees.each { |file, tree| definitions_in(file, tree, &) }
|
|
15
|
-
end
|
|
15
|
+
def packages = @trees.keys.map { @project.package(it) }.uniq
|
|
16
16
|
|
|
17
|
-
|
|
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
|
-
|
|
23
|
+
def survey(tree, set)
|
|
24
|
+
Hashira::Analysis::TypeWalk.each(tree) { |_node, full| set << full }
|
|
25
|
+
end
|
|
20
26
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|