kube_cluster 1.9.1 → 2.0.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.
data/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to this project are 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
+ ## [Unreleased]
9
+
10
+ ## [2.0.0] - 2026-10-17
11
+
12
+ ### Added
13
+ - `require "kube_cluster"` now works as an entry point alongside the existing
14
+ `require "kube/cluster"`.
15
+
16
+ ### Changed
17
+ - **Breaking:** requires kube_schema `~> 2.0`, which compiles schema
18
+ definitions lazily, one at a time, on demand, instead of compiling the
19
+ whole OpenAPI document at boot. `Kube::Cluster[kind].schema` is now a
20
+ `Kube::Schema::Lazy::Source` rather than a `JSONSchemer::Schema`; it answers
21
+ the same `#value`/`#valid?`/`#validate`, so call sites that only use those
22
+ methods need no changes, but code that inspects the schema object's class
23
+ will need updating.
24
+
8
25
  ## [1.9.1] - 2026-09-28
9
26
 
10
27
  ### Fixed
data/Gemfile CHANGED
@@ -2,7 +2,19 @@
2
2
 
3
3
  source "https://rubygems.org"
4
4
 
5
- gemspec
5
+ gem "activesupport", "~> 8.0"
6
+ gem "kube_kubectl", "~> 2.0"
7
+ gem "kube_schema", "~> 2.0"
8
+
9
+ gem "lefthook", "~> 2.1"
10
+ gem "rake", "~> 13.0"
11
+ gem "scampi", "~> 1.0"
12
+ gem "rubocop"
13
+ gem "decode"
14
+
15
+ group :release do
16
+ gem "gem_kit-release", "~> 0.3"
17
+ end
6
18
 
7
19
  group :maintenance, optional: true do
8
20
  gem "utopia-project"
data/Gemfile.lock CHANGED
@@ -1,11 +1,3 @@
1
- PATH
2
- remote: .
3
- specs:
4
- kube_cluster (1.8.0)
5
- activesupport (~> 8.0)
6
- kube_kubectl (~> 2.0)
7
- kube_schema (~> 1.11.0)
8
-
9
1
  GEM
10
2
  remote: https://rubygems.org/
11
3
  specs:
@@ -22,6 +14,7 @@ GEM
22
14
  securerandom (>= 0.3)
23
15
  tzinfo (~> 2.0, >= 2.0.5)
24
16
  uri (>= 0.13.1)
17
+ ast (2.4.3)
25
18
  async (2.39.0)
26
19
  console (~> 1.29)
27
20
  fiber-annotation
@@ -73,7 +66,7 @@ GEM
73
66
  bake (~> 0.21)
74
67
  markly (~> 0.8)
75
68
  base64 (0.3.0)
76
- bigdecimal (4.1.2)
69
+ bigdecimal (4.1.3)
77
70
  build-files (1.10.2)
78
71
  concurrent-ruby (1.3.6)
79
72
  connection_pool (3.0.2)
@@ -107,6 +100,10 @@ GEM
107
100
  fiber-local (1.1.0)
108
101
  fiber-storage
109
102
  fiber-storage (1.0.1)
103
+ gem_kit (0.2.0)
104
+ gem_kit-release (0.3.2)
105
+ gem_kit (>= 0.1, < 1.0)
106
+ thor (~> 1.3)
110
107
  hana (1.3.7)
111
108
  http-accept (2.2.1)
112
109
  i18n (1.14.8)
@@ -132,10 +129,13 @@ GEM
132
129
  rubyshell (~> 1.5)
133
130
  shellwords (~> 0.2.2)
134
131
  string_builder (~> 1.2.2)
135
- kube_schema (1.11.0)
132
+ kube_schema (2.0.0)
133
+ gem_kit (~> 0.2)
136
134
  json_schemer (~> 2.5.0)
137
135
  rubyshell (~> 1.5.0)
136
+ language_server-protocol (3.17.0.6)
138
137
  lefthook (2.1.10)
138
+ lint_roller (1.1.0)
139
139
  localhost (1.8.0)
140
140
  bake
141
141
  logger (1.7.0)
@@ -167,6 +167,10 @@ GEM
167
167
  net-smtp (0.5.1)
168
168
  net-protocol
169
169
  openssl (4.0.2)
170
+ parallel (2.3.0)
171
+ parser (3.3.12.0)
172
+ ast (~> 2.4.1)
173
+ racc
170
174
  pp (0.6.3)
171
175
  prettyprint
172
176
  prettyprint (0.2.0)
@@ -186,11 +190,13 @@ GEM
186
190
  psych (5.3.1)
187
191
  date
188
192
  stringio
193
+ racc (1.8.1)
189
194
  rack (3.2.6)
190
195
  rackula (1.4.1)
191
196
  falcon (~> 0.46)
192
197
  samovar (~> 2.1)
193
198
  variant
199
+ rainbow (3.1.1)
194
200
  rake (13.4.2)
195
201
  rbs (4.0.2)
196
202
  logger
@@ -200,9 +206,24 @@ GEM
200
206
  erb
201
207
  psych (>= 4.0.0)
202
208
  tsort
203
- regexp_parser (2.12.0)
209
+ regexp_parser (2.13.1)
204
210
  reline (0.6.3)
205
211
  io-console (~> 0.5)
212
+ rubocop (1.92.0)
213
+ json (>= 2.3)
214
+ language_server-protocol (~> 3.17.0.2)
215
+ lint_roller (~> 1.1.0)
216
+ parallel (>= 1.10)
217
+ parser (>= 3.3.0.2)
218
+ rainbow (>= 2.2.2, < 4.0)
219
+ regexp_parser (>= 2.11.3, < 3.0)
220
+ rubocop-ast (>= 1.49.0, < 2.0)
221
+ ruby-progressbar (~> 1.7)
222
+ unicode-display_width (>= 2.4.0, < 4.0)
223
+ rubocop-ast (1.50.0)
224
+ parser (>= 3.3.7.2)
225
+ prism (~> 1.7)
226
+ ruby-progressbar (1.13.0)
206
227
  rubyshell (1.5.0)
207
228
  rugged (1.9.0)
208
229
  samovar (2.4.1)
@@ -211,16 +232,20 @@ GEM
211
232
  scampi (1.0.0)
212
233
  securerandom (0.4.1)
213
234
  shellwords (0.2.2)
214
- simpleidn (0.2.3)
235
+ simpleidn (0.3.0)
215
236
  string-format (0.2.0)
216
237
  string_builder (1.2.4)
217
238
  stringio (3.2.0)
239
+ thor (1.5.0)
218
240
  thread-local (1.1.0)
219
241
  timeout (0.6.1)
220
242
  traces (0.18.2)
221
243
  tsort (0.2.0)
222
244
  tzinfo (2.0.6)
223
245
  concurrent-ruby (~> 1.0)
246
+ unicode-display_width (3.3.0)
247
+ unicode-emoji (~> 4.3)
248
+ unicode-emoji (4.3.0)
224
249
  uri (1.1.1)
225
250
  utopia (2.32.1)
226
251
  bake (~> 0.20)
@@ -254,14 +279,19 @@ PLATFORMS
254
279
  x86_64-linux
255
280
 
256
281
  DEPENDENCIES
282
+ activesupport (~> 8.0)
257
283
  bake-gem
258
284
  bake-modernize
259
285
  bake-releases
260
- kube_cluster!
286
+ decode
287
+ gem_kit-release (~> 0.3)
288
+ kube_kubectl (~> 2.0)
289
+ kube_schema (~> 2.0)
261
290
  lefthook (~> 2.1)
262
291
  rake (~> 13.0)
292
+ rubocop
263
293
  scampi (~> 1.0)
264
294
  utopia-project
265
295
 
266
296
  BUNDLED WITH
267
- 2.6.9
297
+ 2.7.2
data/bin/rubocop ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+
3
+ exec bundle exec rubocop "$@"
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bans assigning a `begin ... end` to a variable.
4
+ #
5
+ # @example
6
+ # begin
7
+ # lvar = ...
8
+ # end
9
+ module RuboCop
10
+ module Cop
11
+ module Local
12
+ class BeginBlockAssignment < Base
13
+ MSG = "Do not assign a `begin...end` expression to a variable; " \
14
+ "move the assignment inside the begin block."
15
+
16
+ def on_lvasgn(node)
17
+ check(node)
18
+ end
19
+ alias on_ivasgn on_lvasgn
20
+ alias on_cvasgn on_lvasgn
21
+ alias on_gvasgn on_lvasgn
22
+
23
+ private
24
+
25
+ def check(node)
26
+ rhs = node.children.last
27
+ if rhs.is_a?(RuboCop::AST::Node) && rhs.type == :kwbegin
28
+ add_offense(node)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the Apache License, Version 2.0.
4
+ # Copyright, 2026, by General Intelligence Systems.
5
+
6
+ # Checks the structure of a comment, which decode parses into three parts: the summary, the annotations, and the summary belonging to each annotation.
7
+ #
8
+ # @example
9
+ # # Connect to the given endpoint, returning the HTTP client and reference. <- summary
10
+ # # @parameter endpoint [Endpoint] used to connect to the remote system. <- annotation
11
+ # # @returns [Tuple(Client, Reference)] the client and reference.
12
+ #
13
+ # Four things are checked, each measured against how async-rest, async-discord and async-ollama document themselves:
14
+ #
15
+ # A paragraph occupies one line. None of the 99 summary paragraphs in those three projects is wrapped, and async-rest's run to 544 characters.
16
+ #
17
+ # A summary is short, and no longer than the code it describes earns. Across 76 documented methods the median summary is 4 to 9 words whatever the length of the method, so the allowance starts at ten words and grows by three per line of body, to a ceiling of thirty.
18
+ #
19
+ # A module's summary is ten words or fewer. The only commented module in async-rest is exactly ten.
20
+ #
21
+ # A documented method says what it takes and returns. A summary with no annotations describes the method in prose and leaves the signature undocumented, which is what annotations are for. A method with no comment at all is not this cop's business: that is documentation coverage, which decode reports on separately.
22
+ #
23
+ # Nothing here is corrected automatically. A comment which breaks these rules has to be rewritten rather than reflowed, and a correction which only moved the words around would hide that.
24
+ #
25
+ # Each check is configured on its own, because they are not all worth applying everywhere:
26
+ #
27
+ # @example
28
+ # Local/CommentStructure:
29
+ # Annotations:
30
+ # Include:
31
+ # - "lib/**/*.rb"
32
+ # Length:
33
+ # Exclude:
34
+ # - "bin/**/*"
35
+ # Wrapping:
36
+ # Enabled: true
37
+ #
38
+ # A check with no `Include` runs everywhere; given one, it runs only where a path matches. `Exclude` then removes paths from whatever is left. `Annotations` defaults to `lib` alone: a script in `bin` and a case in `test` have no interface for anyone else to call, so annotating them documents nothing.
39
+ #
40
+ # What a declaration says is read from the run of comment lines touching it. Where an offence is reported is read from the wider span the library attributes to the declaration, less any magic comment, because decode reports a part's position within that span rather than the line it came from.
41
+
42
+ require "decode/language/ruby/generic"
43
+
44
+ require_relative "support/prism_source"
45
+
46
+ module RuboCop
47
+ module Cop
48
+ module Local
49
+ class CommentStructure < Base
50
+ include PrismSource
51
+
52
+ MSG_WRAPPED_SUMMARY = "Do not wrap a summary paragraph: a paragraph is one line, however long."
53
+
54
+ MSG_WRAPPED_ANNOTATION = "Do not wrap the summary of `@%<directive>s`: an annotation's summary is one line."
55
+
56
+ MSG_TOO_LONG = "This summary is %<words>d words; %<allowed>d is the most %<subject>s may have."
57
+
58
+ MSG_NO_ANNOTATIONS = "This method is described in prose but its parameters and return value are not: document them with annotations, or say nothing."
59
+
60
+ TAGS = Decode::Language::Ruby::Generic::TAGS
61
+
62
+ # Each check, and the paths it applies to when the configuration names none.
63
+ CHECKS = {
64
+ "Wrapping" => nil,
65
+ "Length" => nil,
66
+ "Annotations" => ["lib/**/*.rb"].freeze,
67
+ }.freeze
68
+
69
+ GLOB = File::FNM_PATHNAME | File::FNM_EXTGLOB
70
+
71
+ HEADER = /\A#\s*(frozen_string_literal:|Released under|Copyright,|!)/
72
+
73
+ # What a Ruby file says to the interpreter rather than to a reader.
74
+ MAGIC = /\A#\s*(frozen_string_literal:|encoding:|coding:|warn_indent:|shareable_constant_value:|!|-\*-)/
75
+
76
+ TERMINAL = [".", ":", "?", "!"].freeze
77
+
78
+ # Multi-line content is the point of these, so their summaries are left alone.
79
+ FREEFORM = [Decode::Comment::Example].freeze
80
+
81
+ def on_new_investigation
82
+ if self.applies?("Wrapping")
83
+ self.blocks.each do |block|
84
+ self.check_wrapping(block)
85
+ end
86
+ end
87
+
88
+ if self.applies?("Length")
89
+ self.prism_each(::Prism::ModuleNode).each do |node|
90
+ self.check_summary(node, "a module summary", self.cop_config.fetch("ModuleWords", 10))
91
+ end
92
+
93
+ self.prism_each(::Prism::ClassNode).each do |node|
94
+ self.check_summary(node, "a class summary", self.cop_config.fetch("MaximumWords", 30))
95
+ end
96
+ end
97
+
98
+ self.prism_each(::Prism::DefNode).each do |node|
99
+ self.check_method(node)
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ # Whether a check applies to the file being inspected.
106
+ def applies?(check)
107
+ configuration = self.cop_config[check] || {}
108
+
109
+ if configuration["Enabled"] == false
110
+ false
111
+ else
112
+ path = ::RuboCop::PathUtil.relative_path(processed_source.file_path.to_s)
113
+
114
+ included = configuration["Include"] || CHECKS.fetch(check)
115
+ excluded = configuration["Exclude"] || []
116
+
117
+ self.matches?(path, included, true) and !self.matches?(path, excluded, false)
118
+ end
119
+ end
120
+
121
+ # @parameter default [Boolean] what an absent or empty list of patterns means.
122
+ def matches?(path, patterns, default)
123
+ if patterns.nil? or patterns.empty?
124
+ default
125
+ else
126
+ patterns.any?{|pattern| File.fnmatch?(pattern, path, GLOB)}
127
+ end
128
+ end
129
+
130
+ def check_method(node)
131
+ lines = node.location.end_line - node.location.start_line + 1
132
+ base = self.cop_config.fetch("BaseWords", 10)
133
+ per_line = self.cop_config.fetch("WordsPerLine", 3)
134
+ ceiling = self.cop_config.fetch("MaximumWords", 30)
135
+
136
+ allowed = [base + (per_line * lines), ceiling].min
137
+
138
+ if self.applies?("Length")
139
+ self.check_summary(node, "a summary of #{lines} #{lines == 1 ? "line" : "lines"} of code", allowed)
140
+ end
141
+
142
+ if self.applies?("Annotations")
143
+ self.check_annotations(node)
144
+ end
145
+ end
146
+
147
+ # The comment which documents a declaration, as the parts decode reads it as.
148
+ def parts_of(node)
149
+ comments = self.documenting(node)
150
+
151
+ if comments.empty?
152
+ nil
153
+ else
154
+ self.parse(comments)
155
+ end
156
+ end
157
+
158
+ # The comment above a declaration, without the file's own header.
159
+ def documenting(node)
160
+ self.prism_leading_comments(node).reject{|comment| self.prism_source(comment).match?(HEADER)}
161
+ end
162
+
163
+ # Every comment the declaration owns, which is what the parts are indexed against.
164
+ def owned(node)
165
+ self.prism_owned_comments(node).drop_while{|comment| self.prism_source(comment).match?(MAGIC)}
166
+ end
167
+
168
+ def check_summary(node, subject, allowed)
169
+ parts = self.parts_of(node)
170
+
171
+ if parts
172
+ summary = parts.map(&:first).grep(Decode::Comment::Text)
173
+ words = summary.sum{|text| text.line.split.size}
174
+
175
+ if words > allowed
176
+ message = format(MSG_TOO_LONG, words: words, allowed: allowed, subject: subject)
177
+
178
+ add_offense(self.summary_range(node, parts, summary.first), message: message)
179
+ end
180
+ end
181
+ end
182
+
183
+ def check_annotations(node)
184
+ parts = self.parts_of(node)
185
+
186
+ if parts and parts.any?{|part, _| part.is_a?(Decode::Comment::Text)}
187
+ annotations = parts.map(&:first).reject{|part| part.is_a?(Decode::Comment::Text)}
188
+
189
+ if annotations.empty?
190
+ add_offense(self.prism_range(node.name_loc), message: MSG_NO_ANNOTATIONS)
191
+ end
192
+ end
193
+ end
194
+
195
+ # Consecutive comment lines: a block is what documents one thing, so a wrap can only happen inside one.
196
+ def blocks
197
+ [].tap do |found|
198
+ current = []
199
+
200
+ self.prism_comments.each do |comment|
201
+ text = self.prism_source(comment)
202
+
203
+ if text.match?(HEADER) or text.start_with?("# rubocop:")
204
+ next
205
+ end
206
+
207
+ if current.empty? or comment.location.start_line == current.last.location.start_line + 1
208
+ current << comment
209
+ else
210
+ found << current
211
+ current = [comment]
212
+ end
213
+ end
214
+
215
+ unless current.empty?
216
+ found << current
217
+ end
218
+ end
219
+ end
220
+
221
+ def check_wrapping(block)
222
+ nodes = self.parse(block)
223
+
224
+ nodes.each_with_index do |(node, lines), index|
225
+ if node.is_a?(Decode::Comment::Text)
226
+ following = nodes[index + 1]&.first
227
+
228
+ if following.is_a?(Decode::Comment::Text) and !self.terminated?(node.line)
229
+ add_offense(self.prism_range(block[lines.first.first].location), message: MSG_WRAPPED_SUMMARY)
230
+ end
231
+ elsif !FREEFORM.include?(node.class)
232
+ summary = lines.drop(1).reject{|line| line.last.strip.start_with?("@")}
233
+
234
+ if summary.size > 1
235
+ message = format(MSG_WRAPPED_ANNOTATION, directive: node.directive)
236
+
237
+ add_offense(self.prism_range(block[summary.first.first].location), message: message)
238
+ end
239
+ end
240
+ end
241
+ end
242
+
243
+ # decode consumes the lines it parses and does not report where they came from, so they are handed over through something which records what it gave away.
244
+ def parse(block)
245
+ source = Consumable.new(block.each_with_index.map{|comment, index| [index, self.text_of(comment)]})
246
+ nodes = []
247
+ cursor = 0
248
+
249
+ TAGS.parse(source) do |node|
250
+ if node
251
+ nodes << [node, source.log[cursor..]]
252
+ cursor = source.log.size
253
+ end
254
+ end
255
+
256
+ nodes
257
+ end
258
+
259
+ # The comment without its `#`, keeping the indentation which tells decode what is nested.
260
+ def text_of(comment)
261
+ self.prism_source(comment).sub(/\A#\s?/, "")
262
+ end
263
+
264
+ # Where to report a summary which is too long: the comment line it starts on, or the declaration itself when the summary cannot be traced back to one.
265
+ def summary_range(node, parts, text)
266
+ comments = self.owned(node)
267
+ entry = text && parts.find{|part, _| part.equal?(text)}
268
+
269
+ if entry and comments[entry.last.first.first]
270
+ self.prism_range(comments[entry.last.first.first].location)
271
+ else
272
+ self.prism_node_range(node)
273
+ end
274
+ end
275
+
276
+ def terminated?(line)
277
+ text = line.strip
278
+
279
+ text.empty? or TERMINAL.any?{|mark| text.end_with?(mark)}
280
+ end
281
+
282
+ # What decode shifts lines off, which remembers them.
283
+ class Consumable
284
+ def initialize(entries)
285
+ @entries = entries
286
+ @log = []
287
+ end
288
+
289
+ attr :log
290
+
291
+ def first = @entries.first&.last
292
+
293
+ def shift
294
+ entry = @entries.shift
295
+
296
+ if entry
297
+ @log << entry
298
+ entry.last
299
+ end
300
+ end
301
+
302
+ def empty? = @entries.empty?
303
+ end
304
+ end
305
+ end
306
+ end
307
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bans a constant that does not match its file's path.
4
+ #
5
+ # @example
6
+ # lib/mailers/welcome.rb -> Mailers::Welcome # YES
7
+ # lib/mailers/welcome.rb -> WelcomeMailer # NO
8
+ # lib/models/11_organization.rb -> Models::Organization # YES
9
+ # lib/jobs/sync_tenant_claims.rb -> Jobs::SyncTenantClaims # YES
10
+ # lib/jobs/sync_tenant_claims.rb -> SyncTenantClaimsJob # NO
11
+ module RuboCop
12
+ module Cop
13
+ module Local
14
+ class ConstantMatchesPath < Base
15
+ MSG = "`%<actual>s` does not match its path; this file should define " \
16
+ "`%<expected>s` (or a constant nested inside it)."
17
+
18
+ MSG_EMPTY = "This file defines no namespaced constant; it should define " \
19
+ "`%<expected>s`."
20
+
21
+ # Directories that are a load path root, not a namespace segment.
22
+ def roots = Array(cop_config.fetch("Roots", ["lib"]))
23
+
24
+ def inflections = cop_config.fetch("Inflections", {})
25
+
26
+ # Paths that legitimately define something else, such as monkey patches.
27
+ def ignored = Array(cop_config.fetch("IgnorePaths", []))
28
+
29
+ def on_new_investigation
30
+ if processed_source.blank?
31
+ nil
32
+ else
33
+ expected_namespace(processed_source.file_path).then do |expected|
34
+ # A file outside a configured root, or an ignored path, has no namespace to be wrong about. Nor does one that defines no constant at all -- an empty file, or one that only runs code.
35
+ unless expected.nil?
36
+ report(top_level_constants(processed_source.ast), expected)
37
+ end
38
+ end
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def report(defined, expected)
45
+ defined.each do |name, node|
46
+ unless matches?(name, expected)
47
+ add_offense(node, message: format(MSG, actual: name, expected: expected))
48
+ end
49
+ end
50
+ end
51
+
52
+ # `Mailers::Welcome` is right for lib/mailers/welcome.rb, and so is `Mailers` alone (a file may open its own namespace to add to it).
53
+ def matches?(actual, expected)
54
+ actual == expected ||
55
+ expected.start_with?("#{actual}::") ||
56
+ actual.start_with?("#{expected}::")
57
+ end
58
+
59
+ def expected_namespace(path)
60
+ parts = path.to_s.split(File::SEPARATOR)
61
+ root = parts.rindex { |part| roots.include?(part) }
62
+ segments = root && parts[(root + 1)..]
63
+
64
+ if segments.nil? || segments.empty? || ignored?(segments)
65
+ nil
66
+ else
67
+ named = segments.dup
68
+ named[-1] = named[-1].sub(/\.rb\z/, "")
69
+ named.map { |segment| camelize(segment) }.join("::")
70
+ end
71
+ end
72
+
73
+ def ignored?(segments)
74
+ ignored.any? do |glob|
75
+ File.fnmatch?(glob, segments.join("/"), File::FNM_PATHNAME)
76
+ end
77
+ end
78
+
79
+ # `11_organization` -> `Organization`. The prefix orders loading; it is not part of the name.
80
+ def camelize(segment)
81
+ name = segment.sub(/\A\d+_/, "")
82
+
83
+ if inflections.key?(name)
84
+ inflections[name]
85
+ else
86
+ name.split("_").map { |word| inflections[word] || word.capitalize }.join
87
+ end
88
+ end
89
+
90
+ # The constants a file opens at its top level, flattened through compact forms so `module A; class B` and `class A::B` both read as `A::B`.
91
+ def top_level_constants(node, prefix = nil)
92
+ if node.nil?
93
+ []
94
+ elsif node.begin_type?
95
+ node.children.flat_map { |child| top_level_constants(child, prefix) }
96
+ elsif node.module_type? || node.class_type?
97
+ constants_in(node, prefix)
98
+ else
99
+ []
100
+ end
101
+ end
102
+
103
+ def constants_in(node, prefix)
104
+ name = qualified(node.children.first)
105
+
106
+ if name.nil?
107
+ []
108
+ else
109
+ if prefix
110
+ full = "#{prefix}::#{name}"
111
+ else
112
+ full = name
113
+ end
114
+
115
+ body = node.children[node.class_type? ? 2 : 1]
116
+ nested = top_level_constants(body, full)
117
+
118
+ # Report the DEEPEST constant: `module Jobs; class Foo` is an offence about `Jobs::Foo`, not about `Jobs`.
119
+ if nested.empty?
120
+ [[full, node]]
121
+ else
122
+ nested
123
+ end
124
+ end
125
+ end
126
+
127
+ def qualified(const)
128
+ if const&.const_type?
129
+ namespace = const.namespace
130
+
131
+ if namespace.nil? || namespace.cbase_type?
132
+ const.short_name.to_s
133
+ else
134
+ inner = qualified(namespace)
135
+ inner && "#{inner}::#{const.short_name}"
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end