hashira 0.3.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -0
  3. data/README.md +42 -1
  4. data/lib/hashira/analysis/catalog.rb +27 -0
  5. data/lib/hashira/analysis/census.rb +34 -27
  6. data/lib/hashira/analysis/constant_registry.rb +39 -36
  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 +10 -10
  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 +14 -13
  65. data/lib/hashira/duplication/clusterer.rb +29 -33
  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 +19 -23
  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 +50 -49
  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 -56
  93. metadata +14 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b806d4d5704515b3371235d38cdd4e60a0c37b98194c91922d57a8f5c448ffab
4
- data.tar.gz: 774766f13fa3b820557cb613e9d9b0c7ffcca2d220c30611562d1467a21f142b
3
+ metadata.gz: 2e10a9ee1cb16f2b8090dfe962aaa9b8c0fc7b2bf598f507c31817cc6a4a334b
4
+ data.tar.gz: 88e97bb9d5a8b524928014aa2248c917dff0fa3d9a19e785cc2477a40341204c
5
5
  SHA512:
6
- metadata.gz: 8bd5fc6feb807c1255adec14ee18405842a7125dbb4d661c777f85454a831f357484090d90ef39cdc2e1d854df037f61147ede6ef1dfa1854b945bfbb27cd686
7
- data.tar.gz: c853f3b158e0a1e501aeb48333c793eb92fcf0e51c9dabbdd538e643889dfd36141c1bfacfcd8bbebc03934cb0ec96690508cd7747cea99fb4b3e081b1aca4b9
6
+ metadata.gz: 332e29ec2e0841e9ee3476e9155d707b00b97ec116fd88a578bd3b38d4aa2f169cb2cd1dcb531fb9e22ba6909893559463f0282f151b0815f76729efd4e913fb
7
+ data.tar.gz: 2ab5337504ff4390112874e55cc661970b94d3638b79ad93900d0d2c642eaa2cdcfdd9e9acd21eb80bca0e5371585fa7a1ea0a6e7ed8512df60dfbc087b397cd
data/CHANGELOG.md CHANGED
@@ -5,6 +5,102 @@ 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
+
8
104
  ## [0.3.0] - 2026-07-26
9
105
 
10
106
  ### Changed
@@ -113,6 +209,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
113
209
  - Output formats: text, JSON, Graphviz dot, Mermaid (`--format`, `--json`).
114
210
  - `--help` and `--version`.
115
211
 
212
+ [0.4.0]: https://github.com/giacope/hashira/releases/tag/v0.4.0
116
213
  [0.3.0]: https://github.com/giacope/hashira/releases/tag/v0.3.0
117
214
  [0.2.0]: https://github.com/giacope/hashira/releases/tag/v0.2.0
118
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
 
@@ -158,6 +158,47 @@ domain layer near 0.00. The findings are about arrows pointing the wrong way:
158
158
  Each finding comes with file-level evidence; for cycles, the shortest cycle
159
159
  path and its lightest edge. What a finding means for your design is your call.
160
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
+
161
202
  ## Cognitive complexity
162
203
 
163
204
  hashira scores every method with **cognitive complexity**, not an ABC or call-count
@@ -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
- @registry = ConstantRegistry.new
10
- @namespace_prefix = NamespacePrefix.infer(@definitions)
11
- take
12
- 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
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
13
19
 
14
- attr_reader :type_count, :namespace_prefix
20
+ def roots = @catalog.roots
15
21
 
16
- def declaring_package = @registry.declaring_package
22
+ def packages = @roster.packages | @placement.baseline
17
23
 
18
- def packages = (@type_count.keys | @definitions.packages)
24
+ def folds = @folding.disclosed
19
25
 
20
- def resolve(segments) = @registry.package_for(after_prefix(segments))
26
+ def resolve(segments, nesting = []) = resolver.resolve(segments, nesting)
21
27
 
22
- private
28
+ def pinpoint(segments) = resolver.pinpoint(segments)
23
29
 
24
- def take
25
- @definitions.each do |node, full, package|
26
- @registry.register(after_prefix(full), package)
27
- @type_count[package] += 1 unless Syntax.direct_definitions(node).empty?
28
- end
29
- end
30
+ def charge(file, nesting) = translate(@placement.charge(file, nesting))
30
31
 
31
- def after_prefix(segments)
32
- return [] if @namespace_prefix.first(segments.length) == segments
32
+ private
33
33
 
34
- depth = @namespace_prefix.length.downto(0).find { segments.first(it) == @namespace_prefix.last(it) }
35
- segments.drop(depth)
36
- end
37
- end
34
+ def tally
35
+ Hashira::Analysis::Roster.new(@placement.placed.map { |definition, package| [definition, translate(package)] })
38
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
@@ -1,40 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- module Analysis
5
- class ConstantRegistry
6
- AMBIGUOUS = Object.new.freeze
7
-
8
- def initialize
9
- @declaring_package = {}
10
- @shorthand = {}
11
- end
12
-
13
- attr_reader :declaring_package
14
-
15
- def register(path, package)
16
- return if path.empty?
17
-
18
- claim(@declaring_package, path, package)
19
- (1...path.length).each { claim(@shorthand, path.drop(it), package) }
20
- end
21
-
22
- def package_for(path)
23
- found = path.length.downto(1).filter_map { claimed(path.first(it)) }.first
24
- found unless found == AMBIGUOUS
25
- end
26
-
27
- private
28
-
29
- def claim(claims, path, package)
30
- key = path.join("::")
31
- claims[key] = claims.fetch(key, package) == package ? package : AMBIGUOUS
32
- end
33
-
34
- def claimed(path)
35
- key = path.join("::")
36
- @declaring_package[key] || @shorthand[key]
37
- end
38
- end
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
39
42
  end
40
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