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,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the Apache License, Version 2.0.
4
+ # Copyright, 2026, by General Intelligence Systems.
5
+
6
+ # Reads the file under inspection with Prism, which ships with Ruby, instead of through the parser gem syntax tree RuboCop hands a cop.
7
+ #
8
+ # A cop which includes this does not use the `on_<type>` callbacks: those are driven by the parser gem tree, so a cop which reads a different tree walks it itself from `on_new_investigation`. What it gains is one parse of the file by the same Prism which runs the program, and a uniform walk over every node.
9
+ #
10
+ # Offences are still reported against `Parser::Source::Range`, because that is what RuboCop and its correctors take. THE TWO DO NOT MEASURE THE SAME WAY: a Prism location counts BYTES and a `Parser::Source::Range` counts CHARACTERS, so a range built from `start_offset` is correct only until the first multibyte character in the file and silently wrong after it. Prism gives both, and `prism_range` takes the character pair.
11
+ #
12
+ # Prism does not track parentage - a node does not name the node it is written inside - so the walk records the parent of each node as it descends.
13
+
14
+ require "prism"
15
+
16
+ module RuboCop
17
+ module Cop
18
+ module PrismSource
19
+ # @returns [Prism::ParseResult] the file, parsed once however many times this is called.
20
+ def prism_result
21
+ self.prism_state.fetch(:result)
22
+ end
23
+
24
+ # Every node in the file, outermost first, in source order.
25
+ #
26
+ # @returns [Array(Prism::Node)] the nodes.
27
+ def prism_nodes
28
+ self.prism_state.fetch(:nodes)
29
+ end
30
+
31
+ # @parameter classes [Array(Class)] the Prism node classes wanted.
32
+ # @returns [Array(Prism::Node)] the nodes which are one of them.
33
+ def prism_each(*classes)
34
+ if classes.empty?
35
+ self.prism_nodes
36
+ else
37
+ self.prism_nodes.select{|node| classes.any?{|klass| node.is_a?(klass)}}
38
+ end
39
+ end
40
+
41
+ # @parameter node [Prism::Node] the node whose parent is wanted.
42
+ # @returns [Prism::Node | nil] the node it is written inside, or nil at the root.
43
+ def prism_parent(node)
44
+ self.prism_state.fetch(:parents)[node]
45
+ end
46
+
47
+ # The nodes a node is written inside, innermost first.
48
+ #
49
+ # @returns [Array(Prism::Node)] the ancestors.
50
+ def prism_ancestors(node)
51
+ [].tap do |found|
52
+ current = self.prism_parent(node)
53
+
54
+ while current
55
+ found << current
56
+ current = self.prism_parent(current)
57
+ end
58
+ end
59
+ end
60
+
61
+ # @returns [Array(Prism::Node)] the nodes written directly inside the given node.
62
+ def prism_children(node)
63
+ node.compact_child_nodes
64
+ end
65
+
66
+ # Everything written inside the given node, at any depth, outermost first.
67
+ #
68
+ # @parameter classes [Array(Class)] the Prism node classes wanted, or none for all of them.
69
+ # @returns [Array(Prism::Node)] the nodes.
70
+ def prism_descendants(node, *classes)
71
+ [].tap do |found|
72
+ pending = node.compact_child_nodes.dup
73
+
74
+ until pending.empty?
75
+ current = pending.shift
76
+
77
+ if classes.empty? or classes.any?{|klass| current.is_a?(klass)}
78
+ found << current
79
+ end
80
+
81
+ pending.concat(current.compact_child_nodes)
82
+ end
83
+ end
84
+ end
85
+
86
+ # Whether the given node contains a heredoc, whose content lives below the line it opens on.
87
+ def prism_heredoc?(node)
88
+ [node].concat(self.prism_descendants(node)).any? do |current|
89
+ case current
90
+ when ::Prism::StringNode, ::Prism::InterpolatedStringNode,
91
+ ::Prism::XStringNode, ::Prism::InterpolatedXStringNode
92
+ current.heredoc?
93
+ else
94
+ false
95
+ end
96
+ end
97
+ end
98
+
99
+ # The statements of a body, whether it holds one or many.
100
+ #
101
+ # @parameter body [Prism::StatementsNode | nil] the body of a method, module, or block.
102
+ # @returns [Array(Prism::Node)] its statements, which is empty when there is no body.
103
+ def prism_statements(body)
104
+ if body.nil?
105
+ []
106
+ else
107
+ body.body
108
+ end
109
+ end
110
+
111
+ # A Prism location as the range RuboCop reports an offence against.
112
+ #
113
+ # @parameter location [Prism::Location] where the offence is.
114
+ # @returns [Parser::Source::Range] the same place, measured for RuboCop.
115
+ def prism_range(location)
116
+ # CHARACTER OFFSETS, NOT `start_offset`. A `Parser::Source::Range` indexes the buffer by character and a Prism location counts bytes, so a range built from the byte pair lands further and further past where it should as the multibyte characters above it accumulate. A corrector handed such a range replaces the wrong text and writes a file which no longer parses.
117
+ ::Parser::Source::Range.new(
118
+ processed_source.buffer,
119
+ location.start_character_offset,
120
+ location.end_character_offset,
121
+ )
122
+ end
123
+
124
+ # One range reaching from the start of the first location to the end of the last, for an offence over a run of statements rather than a single node.
125
+ #
126
+ # @returns [Parser::Source::Range] the span.
127
+ def prism_span(first, last)
128
+ ::Parser::Source::Range.new(
129
+ processed_source.buffer,
130
+ first.start_character_offset,
131
+ last.end_character_offset,
132
+ )
133
+ end
134
+
135
+ # @returns [Parser::Source::Range] the whole of the given node.
136
+ def prism_node_range(node)
137
+ self.prism_range(node.location)
138
+ end
139
+
140
+ # The column a node starts at, counted the way a Ruby String is indexed.
141
+ #
142
+ # @returns [Integer] the character column.
143
+ def prism_column(node)
144
+ # `Prism::Location#start_column` counts bytes, so indexing a line from `processed_source.lines` with it cuts in the wrong place once anything above or before it on that line is multibyte.
145
+ node.location.start_character_column
146
+ end
147
+
148
+ # @returns [String] the source of the given node.
149
+ def prism_source(node)
150
+ node.location.slice
151
+ end
152
+
153
+ # @returns [Array(Prism::InlineComment)] every comment in the file, in source order.
154
+ def prism_comments
155
+ self.prism_result.comments
156
+ end
157
+
158
+ # The run of comment lines written directly above a declaration, which is what documents it.
159
+ #
160
+ # @returns [Array(Prism::InlineComment)] the comments, in source order.
161
+ def prism_leading_comments(node)
162
+ self.prism_state.fetch(:leading)[node] ||= self.contiguous_above(node)
163
+ end
164
+
165
+ # Every comment a declaration owns, which is the same run: a comment belongs to whatever it is written above.
166
+ #
167
+ # @returns [Array(Prism::InlineComment)] the comments, in source order.
168
+ def prism_owned_comments(node)
169
+ self.prism_leading_comments(node)
170
+ end
171
+
172
+ private
173
+
174
+ # Everything read from the file, discarded when RuboCop moves to the next one.
175
+ def prism_state
176
+ source = processed_source
177
+
178
+ unless @prism_state and @prism_source.equal?(source)
179
+ @prism_source = source
180
+ @prism_state = self.read(source)
181
+ end
182
+
183
+ @prism_state
184
+ end
185
+
186
+ def read(source)
187
+ result = ::Prism.parse(source.buffer.source)
188
+
189
+ nodes = []
190
+ parents = {}.compare_by_identity
191
+
192
+ self.descend(result.value, nil, nodes, parents)
193
+
194
+ by_line = {}
195
+
196
+ result.comments.each do |comment|
197
+ by_line[comment.location.start_line] = comment
198
+ end
199
+
200
+ {
201
+ result: result,
202
+ nodes: nodes,
203
+ parents: parents,
204
+ by_line: by_line,
205
+ leading: {}.compare_by_identity,
206
+ }
207
+ end
208
+
209
+ # The tree, recorded flat with the parent of each node, since Prism does not say.
210
+ def descend(node, parent, nodes, parents)
211
+ nodes << node
212
+ parents[node] = parent
213
+
214
+ node.compact_child_nodes.each do |child|
215
+ self.descend(child, node, nodes, parents)
216
+ end
217
+ end
218
+
219
+ # The run of comment lines ending on the line above the node, stopping at the first gap.
220
+ def contiguous_above(node)
221
+ by_line = self.prism_state.fetch(:by_line)
222
+ found = []
223
+ line = node.location.start_line - 1
224
+
225
+ while comment = by_line[line]
226
+ found.unshift(comment)
227
+ line -= 1
228
+ end
229
+
230
+ found
231
+ end
232
+ end
233
+ end
234
+ end
data/cops/use_tap.rb ADDED
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Requires `tap` when a method opens by assigning a local variable and closes by
4
+ # returning that same variable:
5
+ #
6
+ # def require_existing_column! # NO
7
+ # column = params[:id].to_s
8
+ # unless connection.columns(@table).any? { |c| c.name == column }
9
+ # render json: { error: "unknown column" }, status: :not_found
10
+ # end
11
+ # column
12
+ # end
13
+ #
14
+ # def require_existing_column! # YES
15
+ # params[:id].to_s.tap do |column|
16
+ # unless connection.columns(@table).any? { |c| c.name == column }
17
+ # render json: { error: "unknown column" }, status: :not_found
18
+ # end
19
+ # end
20
+ # end
21
+ #
22
+ # The assign-work-return shape states the return value twice — once as the
23
+ # assignment target, once as a bare mention on the last line — and puts the two
24
+ # far enough apart that the trailing `column` reads as a stray expression.
25
+ # `tap` names the value once, scopes it to the block that works on it, and makes
26
+ # "the value flows through unchanged" structural rather than something you check
27
+ # by comparing the first and last lines.
28
+ #
29
+ # A body that REASSIGNS the variable is left alone: `tap` yields the original
30
+ # object and returns it, so that rewrite would change the result.
31
+ module RuboCop
32
+ module Cop
33
+ module Local
34
+ class UseTap < Base
35
+ MSG = "Assigning `%<name>s` and returning it at the end is `tap`; " \
36
+ "write `<expression>.tap do |%<name>s| ... end`."
37
+
38
+ # Every way a local can be written to after the opening assignment.
39
+ REASSIGNMENTS = %i[lvasgn op_asgn or_asgn and_asgn masgn].freeze
40
+
41
+ def on_def(node)
42
+ check(node.body)
43
+ end
44
+
45
+ def on_defs(node)
46
+ check(node.body)
47
+ end
48
+
49
+ private
50
+
51
+ def check(body)
52
+ if tap_shaped?(body)
53
+ add_offense(body, message: format(MSG, name: assigned_name(body)))
54
+ end
55
+ end
56
+
57
+ # First statement assigns a local; last statement is that same local,
58
+ # read and nothing else.
59
+ def tap_shaped?(body)
60
+ body&.begin_type? &&
61
+ body.children.size >= 2 &&
62
+ body.children.first.lvasgn_type? &&
63
+ returns_assigned?(body) &&
64
+ !reassigned?(body)
65
+ end
66
+
67
+ def assigned_name(body)
68
+ body.children.first.name
69
+ end
70
+
71
+ def returns_assigned?(body)
72
+ last = body.children.last
73
+
74
+ last.lvar_type? && last.children.first == assigned_name(body)
75
+ end
76
+
77
+ # `tap` yields the object the receiver evaluated to, so a body that
78
+ # points the name at something else partway through is a different
79
+ # program and must not be flagged.
80
+ def reassigned?(body)
81
+ name = assigned_name(body)
82
+
83
+ body.children[1..-2].any? do |statement|
84
+ statement.each_node(*REASSIGNMENTS).any? do |write|
85
+ writes_name?(write, name)
86
+ end
87
+ end
88
+ end
89
+
90
+ def writes_name?(node, name)
91
+ if node.masgn_type?
92
+ node.assignments.any? { |target| target == name }
93
+ else
94
+ node.children.first == name
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
data/flake.lock CHANGED
@@ -1,23 +1,45 @@
1
1
  {
2
2
  "nodes": {
3
+ "mine": {
4
+ "inputs": {
5
+ "nixpkgs": [
6
+ "nixpkgs"
7
+ ],
8
+ "systems": "systems"
9
+ },
10
+ "locked": {
11
+ "lastModified": 1790610895,
12
+ "narHash": "sha256-sansiZlrb60MrGFzTXNCVVMTU5EsGM6KWvZkA/aNp14=",
13
+ "owner": "n-at-han-k",
14
+ "repo": "flake.nix",
15
+ "rev": "0f3388742debac3e78751796f4568f441c0dee57",
16
+ "type": "github"
17
+ },
18
+ "original": {
19
+ "owner": "n-at-han-k",
20
+ "repo": "flake.nix",
21
+ "type": "github"
22
+ }
23
+ },
3
24
  "nixpkgs": {
4
25
  "locked": {
5
- "lastModified": 1776169885,
6
- "narHash": "sha256-l/iNYDZ4bGOAFQY2q8y5OAfBBtrDAaPuRQqWaFHVRXM=",
26
+ "lastModified": 1780902259,
27
+ "narHash": "sha256-q8yYEC5f1mFlQO9RGna4LTc9QrcvWunX6FYp83munkQ=",
7
28
  "owner": "NixOS",
8
29
  "repo": "nixpkgs",
9
- "rev": "4bd9165a9165d7b5e33ae57f3eecbcb28fb231c9",
30
+ "rev": "bd0ff2d3eac24699c3664d5966b9ef36f388e2ca",
10
31
  "type": "github"
11
32
  },
12
33
  "original": {
13
34
  "owner": "NixOS",
14
- "ref": "nixos-unstable",
15
35
  "repo": "nixpkgs",
36
+ "rev": "bd0ff2d3eac24699c3664d5966b9ef36f388e2ca",
16
37
  "type": "github"
17
38
  }
18
39
  },
19
40
  "root": {
20
41
  "inputs": {
42
+ "mine": "mine",
21
43
  "nixpkgs": "nixpkgs",
22
44
  "utils": "utils"
23
45
  }
@@ -37,9 +59,24 @@
37
59
  "type": "github"
38
60
  }
39
61
  },
62
+ "systems_2": {
63
+ "locked": {
64
+ "lastModified": 1681028828,
65
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
66
+ "owner": "nix-systems",
67
+ "repo": "default",
68
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
69
+ "type": "github"
70
+ },
71
+ "original": {
72
+ "owner": "nix-systems",
73
+ "repo": "default",
74
+ "type": "github"
75
+ }
76
+ },
40
77
  "utils": {
41
78
  "inputs": {
42
- "systems": "systems"
79
+ "systems": "systems_2"
43
80
  },
44
81
  "locked": {
45
82
  "lastModified": 1731533236,
data/flake.nix CHANGED
@@ -2,38 +2,45 @@
2
2
  description = "Ruby gem flake";
3
3
 
4
4
  inputs = {
5
- nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
5
+ nixpkgs.url = "github:NixOS/nixpkgs/bd0ff2d3eac24699c3664d5966b9ef36f388e2ca"; # == this machine's system nixpkgs
6
6
  utils.url = "github:numtide/flake-utils";
7
+ mine.url = "github:n-at-han-k/flake.nix";
8
+ mine.inputs.nixpkgs.follows = "nixpkgs";
7
9
  };
8
- outputs = { self, nixpkgs, utils }:
10
+ outputs = { self, nixpkgs, utils, mine }:
9
11
  utils.lib.eachDefaultSystem (system:
10
12
  let
11
13
  pkgs = nixpkgs.legacyPackages.${system};
12
- ruby = pkgs.ruby_3_4; # Specify version
14
+ lib = mine.lib.${system};
15
+
16
+ gems = pkgs.bundlerEnv {
17
+ name = "kube_schema-gems";
18
+ ruby = pkgs.ruby_3_4;
19
+ gemfile = ./Gemfile;
20
+ lockfile = ./Gemfile.lock;
21
+ gemset = ./gemset.nix;
22
+ };
13
23
  in
14
24
  {
15
- devShells.default = pkgs.mkShell {
16
- nativeBuildInputs = [
17
- pkgs.pkg-config # native extension discovery
18
- ];
19
-
25
+ devShells.default = lib.mkRubyShell {
20
26
  buildInputs = [
21
- pkgs.trufflehog
22
- ruby
23
- pkgs.libyaml # psych gem
24
- pkgs.openssl # openssl gem
27
+ gems
28
+ gems.wrappedRuby
29
+ pkgs.ripgrep # scampi shells out to `rg` to discover __END__ spec sections
25
30
  ];
26
31
 
27
- shellHook = ''
28
- export GEM_HOME="$HOME/.gem-${ruby.version}"
29
- export GEM_PATH="$GEM_HOME"
30
- export PATH="$GEM_HOME/bin:$PATH"
31
- export BUNDLE_GEMFILE="$PWD/Gemfile"
32
- export BUNDLE_PATH="$GEM_HOME"
33
- export BUNDLE_BIN="$GEM_HOME/bin"
34
- '';
32
+ shellHook = /* bash */ ''
33
+ unset RUBYOPT
34
+ export BUNDLE_FORCE_RUBY_PLATFORM=false
35
+ export BUNDLE_GEMFILE="$PWD/Gemfile"
36
+ export BUNDLE_FROZEN=false
37
+
38
+ export LANG="''${LANG:-C.UTF-8}"
39
+ export LC_ALL="''${LC_ALL:-$LANG}"
40
+
41
+ export RUBYLIB="$PWD/lib''${RUBYLIB:+:$RUBYLIB}"
42
+ '';
35
43
  };
36
44
  }
37
45
  );
38
46
  }
39
-