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.
@@ -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.value(name, entry[:default])
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