kube_schema 1.11.0 → 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.
@@ -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
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bans a call with more than `MaxArguments` arguments.
4
+ #
5
+ # @example
6
+ # walk(rest, current_dir, controller_segments, dynamic_pairs + [[param_name, segment]], last_dir_name)
7
+ #
8
+ # @example Each argument on its own line
9
+ # walk( # YES
10
+ # rest,
11
+ # current_dir,
12
+ # controller_segments,
13
+ # dynamic_pairs + [[param_name, segment]],
14
+ # last_dir_name,
15
+ # )
16
+ module RuboCop
17
+ module Cop
18
+ module Local
19
+ class MultilineMethodArguments < Base
20
+ extend AutoCorrector
21
+
22
+ MSG = "Call takes %<count>d arguments; put each one on its own line."
23
+
24
+ def on_send(node)
25
+ if too_many_on_one_line?(node)
26
+ add_offense(node, message: format(MSG, count: node.arguments.count)) do |corrector|
27
+ break_arguments(corrector, node)
28
+ end
29
+ end
30
+ end
31
+
32
+ alias on_csend on_send
33
+ alias on_super on_send
34
+
35
+ private
36
+
37
+ def too_many_on_one_line?(node)
38
+ node.parenthesized? &&
39
+ node.arguments.count > max_arguments &&
40
+ !every_argument_on_its_own_line?(node)
41
+ end
42
+
43
+ # The first argument must clear the line that opened the call, and no two arguments may share a line.
44
+ def every_argument_on_its_own_line?(node)
45
+ lines = node.arguments.map { |argument| argument.source_range.first_line }
46
+
47
+ lines.first > node.loc.begin.line && lines == lines.uniq
48
+ end
49
+
50
+ def break_arguments(corrector, node)
51
+ if safe_to_break?(node)
52
+ corrector.insert_after(node.loc.begin, "\n")
53
+
54
+ node.arguments.each_cons(2) do |left, right|
55
+ corrector.replace(between(left, right), ",\n")
56
+ end
57
+
58
+ corrector.insert_before(node.loc.end, "\n")
59
+ end
60
+ end
61
+
62
+ # A heredoc's body sits below its line, and comments between arguments would be overwritten; both are left alone.
63
+ def safe_to_break?(node)
64
+ node.arguments.none? { |argument| contains_heredoc?(argument) } &&
65
+ node.arguments.each_cons(2).none? do |left, right|
66
+ between(left, right).source.include?("#")
67
+ end
68
+ end
69
+
70
+ def contains_heredoc?(node)
71
+ node.each_node(:str, :dstr, :xstr).any?(&:heredoc?)
72
+ end
73
+
74
+ # The comma-and-whitespace run separating two arguments.
75
+ def between(left, right)
76
+ Parser::Source::Range.new(
77
+ processed_source.buffer,
78
+ left.source_range.end_pos,
79
+ right.source_range.begin_pos,
80
+ )
81
+ end
82
+
83
+ def max_arguments
84
+ cop_config.fetch("MaxArguments", 3)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end