rails-ai-context 5.36.7 → 5.36.8
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 +95 -0
- data/docs/COMPATIBILITY.md +9 -0
- data/docs/CONFIGURATION.md +1 -1
- data/docs/SECURITY.md +37 -2
- data/docs/STANDALONE.md +1 -1
- data/docs/TOOLS.md +5 -5
- data/lib/rails_ai_context/cli/entry_boot.rb +2 -0
- data/lib/rails_ai_context/doctor.rb +4 -1
- data/lib/rails_ai_context/initializer_settings.rb +600 -0
- data/lib/rails_ai_context/introspectors/env_references.rb +1 -1
- data/lib/rails_ai_context/redaction.rb +653 -17
- data/lib/rails_ai_context/safe_path.rb +101 -11
- data/lib/rails_ai_context/sql_gate.rb +5 -0
- data/lib/rails_ai_context/tools/get_env.rb +20 -30
- data/lib/rails_ai_context/tools/get_partial_interface.rb +12 -3
- data/lib/rails_ai_context/tools/get_view.rb +6 -3
- data/lib/rails_ai_context/tools/review_changes.rb +74 -29
- data/lib/rails_ai_context/tools/search_code.rb +24 -8
- data/lib/rails_ai_context/version.rb +1 -1
- metadata +2 -1
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "find"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "prism"
|
|
6
|
+
|
|
7
|
+
module RailsAiContext
|
|
8
|
+
# The security settings an app's Ruby config writes, read without running it. The static tier runs
|
|
9
|
+
# no initializer, so a pattern a configure block added to sensitive_patterns was dropped there and
|
|
10
|
+
# the file it names was served. Each literal written to a key is added on top of the YAML; a removal
|
|
11
|
+
# is left out, the stricter reading; anything the reader cannot follow refuses the static tier. A
|
|
12
|
+
# file under config/ is read whole, and so is the top level of any other file that names the gem,
|
|
13
|
+
# which runs as the file loads; a method's body there, a tool's or a helper's that runs when
|
|
14
|
+
# called, is read for what it writes.
|
|
15
|
+
module InitializerSettings
|
|
16
|
+
KEYS = %i[sensitive_patterns excluded_paths skip_tools].freeze
|
|
17
|
+
# Each returns the array, so `<<` chains append to it too, and so do these removals.
|
|
18
|
+
ADDS = %i[<< push append concat unshift prepend insert].freeze
|
|
19
|
+
SELF_REMOVES = %i[delete_if reject! select! filter! keep_if clear].freeze
|
|
20
|
+
REMOVES = (SELF_REMOVES + %i[delete delete_at pop shift]).freeze
|
|
21
|
+
# Reads that change nothing and hand back nothing of the array's own, called with no block.
|
|
22
|
+
READS = %i[size length count empty? any? none? include? join inspect to_s frozen?].freeze
|
|
23
|
+
# Reads that take a block of each pattern. Under config/ the value of a LIVE one, which can hold
|
|
24
|
+
# the array or its patterns, is not to be kept.
|
|
25
|
+
ITERATORS = %i[each each_with_index map flat_map filter_map collect select filter reject find detect any? all? none? count sum
|
|
26
|
+
min_by max_by sort_by group_by partition find_index index].freeze
|
|
27
|
+
LIVE = (ADDS + SELF_REMOVES + %i[each each_with_index select filter reject find detect min_by max_by sort_by group_by partition
|
|
28
|
+
map flat_map filter_map collect dup clone to_a entries]).freeze
|
|
29
|
+
# What changes a pattern string in place, and the calls that hand back the string they are called on.
|
|
30
|
+
MUTATORS = %i[<< concat replace insert prepend clear []= setbyte force_encoding].freeze
|
|
31
|
+
ELEMENT_SELF = %i[itself to_s to_str +@ freeze presence].freeze
|
|
32
|
+
# Calls whose block's value goes nowhere, so a statement ending the block keeps nothing: the
|
|
33
|
+
# configure call, Rails' hooks, the iterators that hand back their receiver. Any other call may
|
|
34
|
+
# hand the block's value back (`then`, `synchronize`, `fetch`), so it counts as kept.
|
|
35
|
+
DISCARDING = %i[configure before_configuration before_initialize before_eager_load after_initialize after_routes_loaded
|
|
36
|
+
to_prepare to_run to_complete on_load initializer console rake_tasks generators server runner
|
|
37
|
+
included extended prepended class_methods subscribe monotonic_subscribe task namespace
|
|
38
|
+
each each_with_index each_with_object each_key each_value each_pair each_slice each_cons each_char each_line
|
|
39
|
+
each_byte reverse_each times upto downto step loop tap delete_if keep_if reject! select! filter!].freeze
|
|
40
|
+
# Calls on a list that hand back one of its patterns, and calls that hand back a copy holding the same patterns.
|
|
41
|
+
ELEMENT_GETTERS = %i[first last [] at fetch dig sample min max find detect slice].freeze
|
|
42
|
+
COPIES = %i[dup clone to_a entries take drop reverse uniq compact sort sort_by min_by max_by first last values_at
|
|
43
|
+
each_slice select filter reject].freeze
|
|
44
|
+
# Calls naming a local by a string, which can reach the configure block's param past any read of it.
|
|
45
|
+
EVALS = %i[binding eval].freeze
|
|
46
|
+
# Calls that turn a name into the module or the configuration.
|
|
47
|
+
REFLECTIVE = %i[const_get send public_send __send__ instance_variable_get method public_method instance_method
|
|
48
|
+
eval instance_eval class_eval module_eval constantize safe_constantize].freeze
|
|
49
|
+
GEM_NAME = /\A(?:::)?(?:RailsAiContext(?:::|\z)|rails_ai_context\z)/
|
|
50
|
+
# Folders the app does not run as it boots, or that only tools write, at the top; at any depth, its
|
|
51
|
+
# tests (an engine's test app among them), an installed copy of this gem and a generator's templates.
|
|
52
|
+
UNBOOTED = %w[db tmp log node_modules coverage public vendor/bundle vendor/ruby vendor/cache vendor/gems].freeze
|
|
53
|
+
UNBOOTED_ANYWHERE = %w[spec test].freeze
|
|
54
|
+
GEM_COPY = /\Arails-ai-context(?:-\d.*)?\z/
|
|
55
|
+
UNBOOTED_GLOBS = [ *UNBOOTED.map { |dir| "!/#{dir}/" }, *UNBOOTED_ANYWHERE.map { |dir| "!#{dir}/" }, "!rails-ai-context/",
|
|
56
|
+
"!rails-ai-context-[0-9]*/", "!**/generators/**/templates/**" ].freeze
|
|
57
|
+
# A file Rails runs as configuration is read whole.
|
|
58
|
+
CONFIG = %r{(?:\A|/)config/}
|
|
59
|
+
# Deeper than any configuration nests; read no further, so the reader never runs out of stack.
|
|
60
|
+
MAX_DEPTH = 1000
|
|
61
|
+
# Rails globs these through a link, which neither search below follows.
|
|
62
|
+
BOOT_GLOBS = %w[config/initializers/**/*.rb config/environments/*.rb].freeze
|
|
63
|
+
# A block in these runs before .rails-ai-context.yml is read, and a key it assigns keeps the YAML off that key.
|
|
64
|
+
BEFORE_YAML = %r{\Aconfig/(?:application|environments/[^/]+)\.rb\z}
|
|
65
|
+
# Ruby config files are small; a larger one is searched for the gem's name before it is refused.
|
|
66
|
+
MAX_SOURCE = 10_000_000
|
|
67
|
+
MARKERS = %w[RailsAiContext rails_ai_context].freeze
|
|
68
|
+
|
|
69
|
+
Found = Struct.new(:values, :assigned, :refusals)
|
|
70
|
+
|
|
71
|
+
module_function
|
|
72
|
+
|
|
73
|
+
# Every Ruby file of the app that names the gem, however the boot reaches it.
|
|
74
|
+
#
|
|
75
|
+
# @raise [ConfigurationError] naming the first line it could not read
|
|
76
|
+
def apply!(root, config = RailsAiContext.configuration)
|
|
77
|
+
root = File.expand_path(root.to_s)
|
|
78
|
+
found = boot_files(root).to_h { |relative| [ relative, read(File.join(root, relative), relative) ] }
|
|
79
|
+
if (file, line, what = found.values.flat_map(&:refusals).first)
|
|
80
|
+
raise ConfigurationError, "#{file}:#{line}: the static tier cannot read #{what} without booting the app, so it serves nothing: " \
|
|
81
|
+
"the file a setting there keeps back would be read. Write the value as a literal list, such as %w[config/keys.yml], " \
|
|
82
|
+
"or set it in .rails-ai-context.yml, which every tier reads."
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
defaults = Configuration.new
|
|
86
|
+
KEYS.each do |key|
|
|
87
|
+
added = found.values.flat_map { |each| each.values[key] }
|
|
88
|
+
added += defaults.public_send(key) if found.any? { |relative, each| relative.match?(BEFORE_YAML) && each.assigned.include?(key) }
|
|
89
|
+
config.public_send(:"#{key}=", (config.public_send(key) + added.map(&:to_s)).uniq) if added.any?
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The Ruby files that name the gem, hidden files, links and folders the boot does not run aside,
|
|
94
|
+
# with config/initializers and config/environments read through a link as Rails globs them. A file
|
|
95
|
+
# required by a computed path, autoloaded or in an engine is read with the rest; one outside the
|
|
96
|
+
# app is not. ripgrep finds them when it is there; the walk lists the same files without it.
|
|
97
|
+
def boot_files(root)
|
|
98
|
+
found = (Executable.on_path?("rg") && ripgrep_files(root)) || walked_files(root).select { |relative| names_gem?(File.join(root, relative)) }
|
|
99
|
+
(found + Dir.glob(BOOT_GLOBS, base: root).select { |relative| names_gem?(File.join(root, relative)) }).uniq.sort
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# nil when ripgrep fails, so the walk answers instead. It reads a file with a NUL in it as Ruby does,
|
|
103
|
+
# and names come back split on NUL, which no file name holds.
|
|
104
|
+
def ripgrep_files(root)
|
|
105
|
+
globs = [ "*.rb", "!.*", *UNBOOTED_GLOBS ].flat_map { |glob| [ "-g", glob ] }
|
|
106
|
+
markers = MARKERS.flat_map { |marker| [ "-e", marker ] }
|
|
107
|
+
# The path is given, or ripgrep searches its standard input, which a pipe is.
|
|
108
|
+
out, status = Open3.capture2("rg", "--files-with-matches", "--fixed-strings", "--no-ignore", "--no-messages", "--text", "--null",
|
|
109
|
+
"--path-separator", "/", *globs, *markers, ".", chdir: root, err: File::NULL)
|
|
110
|
+
out.split("\0").map { |path| path.delete_prefix("./") } if status.success? || status.exitstatus == 1
|
|
111
|
+
rescue SystemCallError
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def walked_files(root)
|
|
116
|
+
files = []
|
|
117
|
+
Find.find(root) do |path|
|
|
118
|
+
next if path == root
|
|
119
|
+
|
|
120
|
+
relative = path.delete_prefix("#{root}/")
|
|
121
|
+
name = File.basename(path)
|
|
122
|
+
next Find.prune if name.start_with?(".") || File.symlink?(path) || UNBOOTED.include?(relative)
|
|
123
|
+
next Find.prune if File.directory?(path) && (UNBOOTED_ANYWHERE.include?(name) || name.match?(GEM_COPY) ||
|
|
124
|
+
(name == "templates" && relative.match?(%r{(?:\A|/)generators/})))
|
|
125
|
+
|
|
126
|
+
files << relative if path.end_with?(".rb") && File.file?(path)
|
|
127
|
+
end
|
|
128
|
+
files
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# A file that cannot be read whole is read a line at a time, and taken to name the gem when it cannot be read at all.
|
|
132
|
+
def names_gem?(path)
|
|
133
|
+
File.foreach(path).any? { |line| MARKERS.any? { |marker| line.include?(marker) } }
|
|
134
|
+
rescue SystemCallError, ArgumentError
|
|
135
|
+
true
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def read(path, relative)
|
|
139
|
+
found = Found.new(Hash.new { |hash, key| hash[key] = [] }, Set.new, [])
|
|
140
|
+
source = SafeFile.read(path, max_size: MAX_SOURCE)
|
|
141
|
+
result = source && AstCache.parse_string(source)
|
|
142
|
+
if result.nil? || result.errors.any?
|
|
143
|
+
found.refusals << [ relative, result ? result.errors.first.location.start_line : 1, "this file" ]
|
|
144
|
+
elsif too_deep?(result.value)
|
|
145
|
+
found.refusals << [ relative, 1, "this file" ]
|
|
146
|
+
else
|
|
147
|
+
Reader.new(found, relative, config: relative.match?(CONFIG)).visit(result.value)
|
|
148
|
+
end
|
|
149
|
+
found
|
|
150
|
+
rescue SystemStackError
|
|
151
|
+
found.refusals.unshift([ relative, 1, "this file" ])
|
|
152
|
+
found
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def too_deep?(node)
|
|
156
|
+
stack = [ [ node, 1 ] ]
|
|
157
|
+
until stack.empty?
|
|
158
|
+
current, depth = stack.pop
|
|
159
|
+
return true if depth > MAX_DEPTH
|
|
160
|
+
|
|
161
|
+
current.compact_child_nodes.each { |child| stack << [ child, depth + 1 ] }
|
|
162
|
+
end
|
|
163
|
+
false
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Roots are the configure block's param, RailsAiContext.configuration and the app config's
|
|
167
|
+
# rails_ai_context. A root may only receive a call the configuration's API names, a key's value
|
|
168
|
+
# only an add, a removal or a read, and the module only its own calls: anything else could change
|
|
169
|
+
# a setting where this reader cannot see. Strict, under config/ and at the top of any file, the
|
|
170
|
+
# list and its patterns are not to be kept or handed on either; in a method outside config/, only
|
|
171
|
+
# what changes them is held back.
|
|
172
|
+
class Reader < Prism::Visitor
|
|
173
|
+
API = Configuration.public_instance_methods(false).to_set.freeze
|
|
174
|
+
MODULE_API = (RailsAiContext.singleton_methods(false).reject { |name| name.end_with?("=") } + %i[respond_to?]).to_set.freeze
|
|
175
|
+
|
|
176
|
+
def initialize(found, file, config: true)
|
|
177
|
+
super()
|
|
178
|
+
@found = found
|
|
179
|
+
@file = file
|
|
180
|
+
@config = config
|
|
181
|
+
@methods = 0
|
|
182
|
+
@roots = []
|
|
183
|
+
@elements = []
|
|
184
|
+
@used = [ false ]
|
|
185
|
+
@consumed = Set.new.compare_by_identity
|
|
186
|
+
@statements = Set.new.compare_by_identity
|
|
187
|
+
@chained = Set.new.compare_by_identity
|
|
188
|
+
@discarding = Set.new.compare_by_identity
|
|
189
|
+
@keys = {}.compare_by_identity
|
|
190
|
+
@element_of = {}.compare_by_identity
|
|
191
|
+
@frames = Hash.new { |hash, name| hash[name] = [] }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# A statement's value goes nowhere, but the last one's is the value of what holds it: a block's, a
|
|
195
|
+
# method's, the parentheses' or a condition's, kept when that is.
|
|
196
|
+
def visit_statements_node(node)
|
|
197
|
+
@statements.merge(@used.last ? node.body[0...-1] : node.body)
|
|
198
|
+
super
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
%i[parentheses if unless case case_match begin].each do |type|
|
|
202
|
+
define_method(:"visit_#{type}_node") do |node|
|
|
203
|
+
@used.push(!@statements.include?(node))
|
|
204
|
+
super(node)
|
|
205
|
+
ensure
|
|
206
|
+
@used.pop
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def visit_def_node(node)
|
|
211
|
+
@used.push(true)
|
|
212
|
+
@methods += 1
|
|
213
|
+
super
|
|
214
|
+
ensure
|
|
215
|
+
@methods -= 1
|
|
216
|
+
@used.pop
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def visit_lambda_node(node)
|
|
220
|
+
@used.push(true)
|
|
221
|
+
super
|
|
222
|
+
ensure
|
|
223
|
+
@used.pop
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# A block given each pattern is read once, by each_element.
|
|
227
|
+
def visit_block_node(node)
|
|
228
|
+
return if @consumed.include?(node)
|
|
229
|
+
|
|
230
|
+
@used.push(!@discarding.include?(node))
|
|
231
|
+
super
|
|
232
|
+
ensure
|
|
233
|
+
@used.pop unless @consumed.include?(node)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def visit_call_node(node)
|
|
237
|
+
receiver = node.receiver
|
|
238
|
+
@discarding << node.block if node.block && DISCARDING.include?(node.name)
|
|
239
|
+
# A read of a list (`.join`, `.size`) hands back something new, so the list it reads is not kept.
|
|
240
|
+
@chained << unwrap(receiver) if receiver && READS.include?(node.name) && !node.block
|
|
241
|
+
return refuse(node, node.name) if receiver.nil? && EVALS.include?(node.name) && @roots.any?
|
|
242
|
+
return refuse(node, "RailsAiContext") if REFLECTIVE.include?(node.name) && [ receiver, *node.arguments&.arguments ].any? { |arg| gem_name?(arg) }
|
|
243
|
+
return element_call(node) if element?(receiver)
|
|
244
|
+
|
|
245
|
+
if gem_constant?(receiver)
|
|
246
|
+
@consumed << receiver
|
|
247
|
+
return refuse(node, "RailsAiContext") unless MODULE_API.include?(node.name)
|
|
248
|
+
return configure(node) if node.name == :configure
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
if root?(receiver)
|
|
252
|
+
@consumed << receiver
|
|
253
|
+
key = node.name.to_s.delete_suffix("=").to_sym
|
|
254
|
+
if node.block || !(API.include?(node.name) || node.name == :respond_to?)
|
|
255
|
+
refuse(node, root_name(receiver))
|
|
256
|
+
elsif node.name.end_with?("=") && KEYS.include?(key)
|
|
257
|
+
add(node, key, assigned(node, key), assigned: true)
|
|
258
|
+
elsif KEYS.include?(node.name) && !@consumed.include?(node) && strict?
|
|
259
|
+
refuse(node, node.name)
|
|
260
|
+
end
|
|
261
|
+
elsif !@consumed.include?(node) && (key = target_key(receiver))
|
|
262
|
+
mutate(node, key)
|
|
263
|
+
elsif root?(node) && !@consumed.include?(node)
|
|
264
|
+
refuse(node, root_name(node))
|
|
265
|
+
end
|
|
266
|
+
super
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def visit_call_operator_write_node(node)
|
|
270
|
+
write(node) { %i[+ |].include?(node.binary_operator) ? :add : (%i[- &].include?(node.binary_operator) ? :remove : nil) }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def visit_call_or_write_node(node)
|
|
274
|
+
write(node) { :add }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def visit_call_and_write_node(node)
|
|
278
|
+
write(node) { :add }
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# The configuration handed on is changed where this reader cannot see; a pattern handed on, only
|
|
282
|
+
# under config/.
|
|
283
|
+
def visit_local_variable_read_node(node)
|
|
284
|
+
handed(node, node.name)
|
|
285
|
+
super
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def visit_it_local_variable_read_node(node)
|
|
289
|
+
handed(node, :it)
|
|
290
|
+
super
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def visit_constant_read_node(node)
|
|
294
|
+
refuse(node, "RailsAiContext") if node.name == :RailsAiContext && !@consumed.include?(node)
|
|
295
|
+
super
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# RailsAiContext::Engine names a class the app may mount; RailsAiContext::Configuration is the
|
|
299
|
+
# settings themselves. Outside config/ a constant of it may be read.
|
|
300
|
+
def visit_constant_path_node(node)
|
|
301
|
+
parent = node.parent
|
|
302
|
+
@consumed << parent if !strict? && parent.is_a?(Prism::ConstantPathNode) && parent.name == :Configuration && gem_constant?(parent.parent)
|
|
303
|
+
if gem_constant?(node.parent)
|
|
304
|
+
@consumed << node.parent
|
|
305
|
+
refuse(node, "RailsAiContext::Configuration") if node.name == :Configuration && (strict? || !@consumed.include?(node))
|
|
306
|
+
elsif gem_constant?(node) && !@consumed.include?(node)
|
|
307
|
+
refuse(node, "RailsAiContext")
|
|
308
|
+
end
|
|
309
|
+
super
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Outside config/ a tool may open the gem's namespace to sit in it; never its configuration's class.
|
|
313
|
+
def visit_module_node(node)
|
|
314
|
+
@consumed << node.constant_path if !@config && gem_constant?(node.constant_path)
|
|
315
|
+
super
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# What `defined?` names is not run.
|
|
320
|
+
def visit_defined_node(_node); end
|
|
321
|
+
|
|
322
|
+
private
|
|
323
|
+
|
|
324
|
+
def gem_name?(node)
|
|
325
|
+
(node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)) && node.unescaped.match?(GEM_NAME)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Strict under config/ and outside any method: a method elsewhere runs when called, not as its file loads.
|
|
329
|
+
def strict?
|
|
330
|
+
@config || @methods.zero?
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def handed(node, name)
|
|
334
|
+
return if @consumed.include?(node)
|
|
335
|
+
|
|
336
|
+
if @roots.include?(name)
|
|
337
|
+
refuse(node, name)
|
|
338
|
+
elsif (frame = element_frame(name)) && strict?
|
|
339
|
+
refuse(node, frame.last)
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def element_frame(name)
|
|
344
|
+
@frames[name].last
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def configure(node)
|
|
348
|
+
visit(node.arguments)
|
|
349
|
+
return refuse(node, "RailsAiContext.configure") unless node.block.is_a?(Prism::BlockNode)
|
|
350
|
+
|
|
351
|
+
param = block_param(node.block)
|
|
352
|
+
return refuse(node, what: "the parameters of this configure block") if param == :unread
|
|
353
|
+
|
|
354
|
+
@roots.push(param)
|
|
355
|
+
visit(node.block)
|
|
356
|
+
ensure
|
|
357
|
+
@roots.pop if param && param != :unread
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def write(node)
|
|
361
|
+
# Another setting is the configuration's own business, written any way the API allows.
|
|
362
|
+
if root?(node.receiver) && !KEYS.include?(node.read_name)
|
|
363
|
+
@consumed << node.receiver
|
|
364
|
+
refuse(node, root_name(node.receiver)) unless API.include?(node.read_name)
|
|
365
|
+
return node.value.accept(self)
|
|
366
|
+
end
|
|
367
|
+
return super_write(node) unless root?(node.receiver) && KEYS.include?(node.read_name)
|
|
368
|
+
|
|
369
|
+
@consumed << node.receiver
|
|
370
|
+
case yield
|
|
371
|
+
when :add then add(node, node.read_name, literals(node.value), assigned: true)
|
|
372
|
+
when :remove then @found.assigned << node.read_name
|
|
373
|
+
else refuse(node, node.read_name)
|
|
374
|
+
end
|
|
375
|
+
super_write(node)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def super_write(node)
|
|
379
|
+
node.compact_child_nodes.each { |child| child.accept(self) }
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# The literals a setter is given, `key = key + [...]` and `key = key | [...]` among them.
|
|
383
|
+
def assigned(node, key)
|
|
384
|
+
value = node.arguments&.arguments&.first
|
|
385
|
+
if value.is_a?(Prism::CallNode) && %i[+ |].include?(value.name) && value.arguments&.arguments&.size == 1
|
|
386
|
+
other = value.arguments.arguments.first
|
|
387
|
+
return literals(other) if key_read?(value.receiver, key) && @consumed.add(value.receiver).add(value)
|
|
388
|
+
return literals(value.receiver) if value.name == :+ && key_read?(other, key) && @consumed.add(other).add(value)
|
|
389
|
+
end
|
|
390
|
+
literals(value)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def key_read?(node, key)
|
|
394
|
+
node.is_a?(Prism::CallNode) && node.name == key && node.arguments.nil? && root?(node.receiver)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# The key whose array `node` is: a read of it off a root, or a call chained on one that returns
|
|
398
|
+
# the array (`<< "a" << "b"`, `.clear.concat(...)`), parentheses or begin around either. Each link
|
|
399
|
+
# remembers its key, so a chain is walked once however long.
|
|
400
|
+
def target_key(node)
|
|
401
|
+
chain = []
|
|
402
|
+
node = unwrap(node)
|
|
403
|
+
while node.is_a?(Prism::CallNode) && (ADDS + SELF_REMOVES).include?(node.name) && !root?(node.receiver) && !@keys.key?(node)
|
|
404
|
+
chain << node
|
|
405
|
+
node = unwrap(node.receiver)
|
|
406
|
+
end
|
|
407
|
+
key = @keys.key?(node) ? @keys[node] : key_read(node)
|
|
408
|
+
chain.each { |link| @keys[link] = key }
|
|
409
|
+
key
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# The key a read off a root names, or nil.
|
|
413
|
+
def key_read(node)
|
|
414
|
+
return nil unless node.is_a?(Prism::CallNode) && KEYS.include?(node.name) && node.arguments.nil? && root?(node.receiver)
|
|
415
|
+
|
|
416
|
+
@consumed << node
|
|
417
|
+
node.name
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def unwrap(node)
|
|
421
|
+
loop do
|
|
422
|
+
body = node.body if node.is_a?(Prism::ParenthesesNode)
|
|
423
|
+
body = node.statements if node.is_a?(Prism::BeginNode) && !node.rescue_clause && !node.else_clause && !node.ensure_clause
|
|
424
|
+
break node unless body.is_a?(Prism::StatementsNode) && body.body.size == 1
|
|
425
|
+
|
|
426
|
+
node = body.body.first
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def mutate(node, key)
|
|
431
|
+
@chained << unwrap(node.receiver)
|
|
432
|
+
# The array an add or a removal returns is the setting's own: kept anywhere, it is changed unseen.
|
|
433
|
+
return refuse(node, key) if strict? && LIVE.include?(node.name) && !@statements.include?(node) && !@chained.include?(node)
|
|
434
|
+
return each_element(node, key) if REMOVES.include?(node.name) || ITERATORS.include?(node.name) || (READS.include?(node.name) && !node.block)
|
|
435
|
+
unless ADDS.include?(node.name) && !node.block
|
|
436
|
+
return refuse(node, key) if strict? || node.block || node.name.end_with?("!") || %i[[]= fill replace].include?(node.name)
|
|
437
|
+
|
|
438
|
+
return
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
args = node.arguments&.arguments || []
|
|
442
|
+
args = args.drop(1) if node.name == :insert && args.first.is_a?(Prism::IntegerNode)
|
|
443
|
+
values = args.map { |arg| literals(arg.is_a?(Prism::SplatNode) ? arg.expression : arg) }
|
|
444
|
+
add(node, key, values.all? ? values.flatten : nil)
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# A block given each pattern may read it, never change it in place or hand it on.
|
|
448
|
+
def each_element(node, key)
|
|
449
|
+
block = node.block
|
|
450
|
+
case block
|
|
451
|
+
when nil then nil
|
|
452
|
+
when Prism::BlockArgumentNode
|
|
453
|
+
symbol = block.expression
|
|
454
|
+
refuse(node, key) unless symbol.is_a?(Prism::SymbolNode) && !mutator?(symbol.unescaped.to_sym)
|
|
455
|
+
else
|
|
456
|
+
param = block_param(block)
|
|
457
|
+
return refuse(node, key) if param == :unread
|
|
458
|
+
|
|
459
|
+
frame = [ param, key ]
|
|
460
|
+
@elements.push(frame)
|
|
461
|
+
@frames[param] << frame
|
|
462
|
+
visit(block)
|
|
463
|
+
@frames[param].pop
|
|
464
|
+
@elements.pop
|
|
465
|
+
@consumed << block
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# The frame of the block whose pattern node is, itself or through a call that hands it back
|
|
470
|
+
# (`p.to_s`, `+p`), at any depth of blocks. Each call remembers its answer, so a long run of such
|
|
471
|
+
# calls is walked once.
|
|
472
|
+
def element?(node)
|
|
473
|
+
node = unwrap(node)
|
|
474
|
+
return @element_of[node] if @element_of.key?(node)
|
|
475
|
+
|
|
476
|
+
@element_of[node] =
|
|
477
|
+
case node
|
|
478
|
+
when Prism::CallNode then element_call_frame(node)
|
|
479
|
+
when Prism::LocalVariableReadNode then element_frame(node.name)
|
|
480
|
+
when Prism::ItLocalVariableReadNode then element_frame(:it)
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# A call that hands back a pattern: one on a pattern that returns it (`p.to_s`), or one on a list
|
|
485
|
+
# that returns one of its patterns (`list.last`).
|
|
486
|
+
def element_call_frame(node)
|
|
487
|
+
return nil unless node.receiver
|
|
488
|
+
|
|
489
|
+
if ELEMENT_SELF.include?(node.name) && !node.block
|
|
490
|
+
element?(node.receiver)
|
|
491
|
+
elsif ELEMENT_GETTERS.include?(node.name) && (key = target_key(list_under_copies(node.receiver)))
|
|
492
|
+
[ nil, key ]
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# The list a run of copies (`.dup`, `.take(1)`) starts from.
|
|
497
|
+
def list_under_copies(node)
|
|
498
|
+
node = unwrap(node)
|
|
499
|
+
node = unwrap(node.receiver) while node.is_a?(Prism::CallNode) && COPIES.include?(node.name) && node.receiver && !root?(node.receiver)
|
|
500
|
+
node
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def element_call(node)
|
|
504
|
+
frame = element?(node.receiver)
|
|
505
|
+
@consumed << node.receiver
|
|
506
|
+
@consumed << unwrap(node.receiver)
|
|
507
|
+
refuse(node, frame.last) if mutator?(node.name) || node.block
|
|
508
|
+
super_call(node)
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def mutator?(name)
|
|
512
|
+
name.end_with?("!") || MUTATORS.include?(name)
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def super_call(node)
|
|
516
|
+
node.compact_child_nodes.each { |child| child.accept(self) }
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def add(node, key, values, assigned: false)
|
|
520
|
+
return refuse(node, key) if values.nil?
|
|
521
|
+
|
|
522
|
+
@found.assigned << key if assigned
|
|
523
|
+
@found.values[key].concat(values)
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# The strings a value holds, or nil when it is no literal: a string or a symbol, an array of
|
|
527
|
+
# them, `.freeze` on one, or a condition each of whose branches is one.
|
|
528
|
+
def literals(node)
|
|
529
|
+
case node
|
|
530
|
+
when Prism::StringNode, Prism::SymbolNode then [ node.unescaped ]
|
|
531
|
+
when Prism::NilNode then []
|
|
532
|
+
when Prism::ArrayNode
|
|
533
|
+
values = node.elements.map { |element| literals(element.is_a?(Prism::SplatNode) ? element.expression : element) }
|
|
534
|
+
values.flatten if values.all?
|
|
535
|
+
when Prism::CallNode then literals(node.receiver) if node.name == :freeze && node.receiver && node.arguments.nil?
|
|
536
|
+
when Prism::ParenthesesNode then literals(node.body.body.first) if node.body.is_a?(Prism::StatementsNode) && node.body.body.size == 1
|
|
537
|
+
when Prism::IfNode, Prism::UnlessNode
|
|
538
|
+
other = node.is_a?(Prism::IfNode) ? node.subsequent : node.else_clause
|
|
539
|
+
branches = [ node.statements, other.is_a?(Prism::ElseNode) ? other.statements : other ]
|
|
540
|
+
values = branches.map { |branch| branch.nil? ? [] : literals(branch.is_a?(Prism::StatementsNode) ? branch.body.last : branch) }
|
|
541
|
+
values.flatten if values.all?
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def root?(node)
|
|
546
|
+
case node
|
|
547
|
+
when Prism::LocalVariableReadNode then @roots.include?(node.name)
|
|
548
|
+
when Prism::ItLocalVariableReadNode then @roots.include?(:it)
|
|
549
|
+
when Prism::CallNode
|
|
550
|
+
return false unless node.arguments.nil?
|
|
551
|
+
|
|
552
|
+
# config.x is the app's own namespace, which the gem does not read.
|
|
553
|
+
(node.name == :configuration && gem_constant?(node.receiver)) ||
|
|
554
|
+
(node.name == :rails_ai_context && !node.receiver.nil? && !(node.receiver.is_a?(Prism::CallNode) && node.receiver.name == :x))
|
|
555
|
+
else false
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def root_name(node)
|
|
560
|
+
case node
|
|
561
|
+
when Prism::CallNode then node.name == :rails_ai_context ? "config.rails_ai_context" : "RailsAiContext.configuration"
|
|
562
|
+
when Prism::ItLocalVariableReadNode then :it
|
|
563
|
+
else node.name
|
|
564
|
+
end
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
# RailsAiContext, ::RailsAiContext or Object::RailsAiContext.
|
|
568
|
+
def gem_constant?(node, name = :RailsAiContext)
|
|
569
|
+
return false unless node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
|
|
570
|
+
return false unless node.name == name
|
|
571
|
+
return true if node.is_a?(Prism::ConstantReadNode) || node.parent.nil?
|
|
572
|
+
|
|
573
|
+
name == :RailsAiContext && gem_constant?(node.parent, :Object)
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
# The name the block binds the configuration to: nil for none, :unread for a shape this reader does not follow.
|
|
577
|
+
def block_param(block)
|
|
578
|
+
params = block.parameters
|
|
579
|
+
case params
|
|
580
|
+
when nil then nil
|
|
581
|
+
when Prism::NumberedParametersNode then :_1
|
|
582
|
+
when Prism::ItParametersNode then :it
|
|
583
|
+
when Prism::BlockParametersNode
|
|
584
|
+
list = params.parameters
|
|
585
|
+
return nil if list.nil?
|
|
586
|
+
|
|
587
|
+
plain = list.requireds.size == 1 && list.requireds.first.is_a?(Prism::RequiredParameterNode) && list.optionals.empty? &&
|
|
588
|
+
list.rest.nil? && list.posts.empty? && list.keywords.empty? && list.keyword_rest.nil? && list.block.nil?
|
|
589
|
+
plain ? list.requireds.first.name : :unread
|
|
590
|
+
else :unread
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def refuse(node, name = nil, what: "what this line does to `#{name}`")
|
|
595
|
+
@found.refusals << [ @file, node.location.start_line, what ]
|
|
596
|
+
nil
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
end
|
|
@@ -104,7 +104,7 @@ module RailsAiContext
|
|
|
104
104
|
# `ENV["X"]` answers nil when unset; only a fetch without a default raises.
|
|
105
105
|
ref[:bracket] = true if entry[:method] == "[]"
|
|
106
106
|
if entry[:default]
|
|
107
|
-
ref[:default] = entry[:default] == "nil" ? "nil" : RailsAiContext::Redaction.
|
|
107
|
+
ref[:default] = entry[:default] == "nil" ? "nil" : RailsAiContext::Redaction.env_value(name, entry[:default])
|
|
108
108
|
elsif entry[:has_default]
|
|
109
109
|
ref[:default] = COMPUTED_DEFAULT
|
|
110
110
|
end
|