rails-ai-context 5.30.2 → 5.31.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +173 -0
- data/docs/COMPATIBILITY.md +21 -7
- data/docs/CONFIGURATION.md +3 -0
- data/docs/GUIDE.md +7 -1
- data/docs/INTROSPECTORS.md +6 -1
- data/docs/SECURITY.md +23 -1
- data/docs/TOOLS.md +11 -2
- data/lib/rails_ai_context/action_filters.rb +2 -3
- data/lib/rails_ai_context/base_mixins.rb +44 -16
- data/lib/rails_ai_context/concern_macros.rb +698 -92
- data/lib/rails_ai_context/concern_membership.rb +6 -4
- data/lib/rails_ai_context/concern_paths.rb +29 -3
- data/lib/rails_ai_context/configuration.rb +2 -2
- data/lib/rails_ai_context/git_ignore.rb +13 -11
- data/lib/rails_ai_context/introspectors/action_presence.rb +5 -3
- data/lib/rails_ai_context/introspectors/action_resolver.rb +2 -1
- data/lib/rails_ai_context/introspectors/call_site_expansion.rb +198 -18
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/declared_constant.rb +15 -6
- data/lib/rails_ai_context/introspectors/job_introspector.rb +55 -10
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +3 -3
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +28 -1
- data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +3 -1
- data/lib/rails_ai_context/introspectors/listeners/mixins_listener.rb +21 -28
- data/lib/rails_ai_context/introspectors/listeners/owner_scope.rb +67 -0
- data/lib/rails_ai_context/introspectors/listeners/proc_literal_listener.rb +42 -0
- data/lib/rails_ai_context/introspectors/model_introspector.rb +277 -118
- data/lib/rails_ai_context/introspectors/source_scan.rb +5 -3
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +13 -4
- data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +104 -21
- data/lib/rails_ai_context/method_name.rb +31 -0
- data/lib/rails_ai_context/output_guard.rb +6 -4
- data/lib/rails_ai_context/redaction.rb +8 -2
- data/lib/rails_ai_context/safe_file.rb +4 -1
- data/lib/rails_ai_context/safe_path.rb +17 -4
- data/lib/rails_ai_context/server.rb +26 -7
- data/lib/rails_ai_context/tools/base_tool.rb +11 -2
- data/lib/rails_ai_context/tools/get_callbacks.rb +14 -6
- data/lib/rails_ai_context/tools/get_concern.rb +1 -1
- data/lib/rails_ai_context/tools/get_conventions.rb +5 -7
- data/lib/rails_ai_context/tools/get_job_pattern.rb +8 -2
- data/lib/rails_ai_context/tools/get_model_details.rb +4 -3
- data/lib/rails_ai_context/tools/get_schema.rb +4 -0
- data/lib/rails_ai_context/tools/performance_check.rb +5 -13
- data/lib/rails_ai_context/tools/query.rb +122 -28
- data/lib/rails_ai_context/tools/search_code.rb +64 -22
- data/lib/rails_ai_context/tools/search_docs.rb +4 -5
- data/lib/rails_ai_context/tools/security_scan.rb +3 -1
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context.rb +1 -1
- data/server.json +1 -1
- metadata +4 -1
|
@@ -14,6 +14,8 @@ module RailsAiContext
|
|
|
14
14
|
include: %w[included append_features], prepend: %w[prepended prepend_features], extend: %w[extended]
|
|
15
15
|
}.freeze
|
|
16
16
|
MIXIN_HOOKS = HOOKS_BY_MACRO.values.flatten.freeze
|
|
17
|
+
# The macros that give the class a module's methods as class methods.
|
|
18
|
+
SINGLETON_MACROS = %i[extend singleton_include singleton_prepend].freeze
|
|
17
19
|
# The block ActiveSupport::Concern runs for each.
|
|
18
20
|
CONCERN_BLOCKS = { include: :included, prepend: :prepended }.freeze
|
|
19
21
|
FRAMEWORK_PREFIXES = %w[
|
|
@@ -60,12 +62,12 @@ module RailsAiContext
|
|
|
60
62
|
|
|
61
63
|
# Matched on the last segment: `module Edition; module Featurable` and
|
|
62
64
|
# `module Edition::Featurable` both write one constant.
|
|
63
|
-
def
|
|
64
|
-
return Array(
|
|
65
|
+
def owned_by(records, name)
|
|
66
|
+
return Array(records) if Array(records).none? { |record| record.key?(:owner) }
|
|
65
67
|
|
|
66
|
-
own = own_owner(
|
|
68
|
+
own = own_owner(records, name)
|
|
67
69
|
# `Other.include X` written in the body mixes into Other.
|
|
68
|
-
Array(
|
|
70
|
+
Array(records).select { |record| own && !record[:receiver] && Array(record[:owner]).join("::") == own }
|
|
69
71
|
end
|
|
70
72
|
|
|
71
73
|
def own_owner(records, name)
|
|
@@ -133,7 +133,34 @@ module RailsAiContext
|
|
|
133
133
|
# bare `include DebugConcern` inside Fasp::Provider resolves at runtime
|
|
134
134
|
# to Fasp::Provider::DebugConcern, which the literal spelling misses.
|
|
135
135
|
# @param dirs [Array<String>, nil] pre-resolved concern directories
|
|
136
|
-
|
|
136
|
+
# @param outer [Boolean] whether a module with no file of its own is found in its outer constant's file
|
|
137
|
+
def find_file(root, concern_name, prefer: nil, within: nil, dirs: nil, outer: true)
|
|
138
|
+
found = find_named(root, concern_name, prefer: prefer, within: within, dirs: dirs)
|
|
139
|
+
(found || (outer_named(root, concern_name, within: within, dirs: dirs) if outer))&.last
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The source of the module a reference resolves to: its file, or its own node in its outer constant's file.
|
|
143
|
+
def module_source(root, concern_name, prefer: nil, within: nil)
|
|
144
|
+
path = find_file(root, concern_name, prefer: prefer, within: within, outer: false)
|
|
145
|
+
return SafeFile.read(path) if path
|
|
146
|
+
|
|
147
|
+
name, path = outer_named(root, concern_name, within: within)
|
|
148
|
+
path && Introspectors::DeclaredConstant.module_node(AstCache.parse(path).value, name)&.slice
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# [`Outer::Inner`, Outer's file] for a module with no file of its own, which Zeitwerk loads with Outer.
|
|
152
|
+
def outer_named(root, concern_name, within: nil, dirs: nil)
|
|
153
|
+
RunCache.fetch([ :outer_named, root, concern_name, within, dirs ]) do
|
|
154
|
+
candidate_names(concern_name, within).lazy.filter_map do |name|
|
|
155
|
+
outer = name.rpartition("::").first
|
|
156
|
+
path = !outer.empty? && find_file(root, outer, dirs: dirs)
|
|
157
|
+
[ name, path ] if path && Introspectors::DeclaredConstant.module_node(AstCache.parse(path).value, name)
|
|
158
|
+
end.first
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# [the constant the reference resolves to, its file] for `find_file`'s file, or nil.
|
|
163
|
+
def find_named(root, concern_name, prefer: nil, within: nil, dirs: nil)
|
|
137
164
|
# Wherever Zeitwerk would look, plus an in-repo path gem's lib. A lib the app does
|
|
138
165
|
# not add to its autoload paths holds nothing it autoloads, so it is skipped.
|
|
139
166
|
dirs = ordered_dirs(root, prefer, dirs) + PathResolver.app_roots(root) + PathResolver.declared_roots(root) +
|
|
@@ -144,8 +171,7 @@ module RailsAiContext
|
|
|
144
171
|
next if underscore.empty? || underscore.include?("..")
|
|
145
172
|
|
|
146
173
|
found = dirs.find { |dir| file_exist?(dir, "#{underscore}.rb") }
|
|
147
|
-
|
|
148
|
-
return path if path
|
|
174
|
+
return [ name, File.join(found, "#{underscore}.rb") ] if found
|
|
149
175
|
end
|
|
150
176
|
|
|
151
177
|
nil
|
|
@@ -387,8 +387,8 @@ module RailsAiContext
|
|
|
387
387
|
@server_name = "rails-ai-context"
|
|
388
388
|
@introspectors = PRESETS[:full].dup
|
|
389
389
|
@excluded_paths = %w[node_modules tmp log vendor .git doc docs]
|
|
390
|
-
# Files each tool's own docs say to gitignore.
|
|
391
|
-
#
|
|
390
|
+
# Files each tool's own docs say to gitignore. A placeholder beside one
|
|
391
|
+
# (config/application.example.yml, .env.example) stays readable: see SafePath.sensitive?.
|
|
392
392
|
@sensitive_patterns = %w[
|
|
393
393
|
.env .env.* *.env .envrc
|
|
394
394
|
config/master.key
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "open3"
|
|
4
|
-
|
|
5
3
|
module RailsAiContext
|
|
6
4
|
# git's ignore-file rules (globs, anchoring, last match wins, negation) for
|
|
7
5
|
# the doctor, and ripgrep's walk over a tree for the search fallback.
|
|
@@ -147,7 +145,7 @@ module RailsAiContext
|
|
|
147
145
|
def global
|
|
148
146
|
return @global if defined?(@global)
|
|
149
147
|
|
|
150
|
-
@global = source(@root, GitIgnore.global_excludes_path
|
|
148
|
+
@global = source(@root, GitIgnore.global_excludes_path)
|
|
151
149
|
end
|
|
152
150
|
end
|
|
153
151
|
|
|
@@ -169,15 +167,19 @@ module RailsAiContext
|
|
|
169
167
|
nil
|
|
170
168
|
end
|
|
171
169
|
|
|
172
|
-
# `core.excludesFile` as
|
|
173
|
-
#
|
|
174
|
-
def global_excludes_path
|
|
175
|
-
configured, status = Open3.capture2("git", "config", "--get", "core.excludesFile", chdir: root)
|
|
176
|
-
configured = configured.to_s.strip
|
|
177
|
-
return File.expand_path(configured) if status.success? && !configured.empty?
|
|
178
|
-
|
|
170
|
+
# `core.excludesFile` as ripgrep 15 finds it: ~/.gitconfig, then the XDG
|
|
171
|
+
# git config, then git's default (ignore crate `gitconfig_excludes_path`).
|
|
172
|
+
def global_excludes_path
|
|
179
173
|
xdg = ENV["XDG_CONFIG_HOME"].to_s
|
|
180
|
-
|
|
174
|
+
config_dir = xdg.empty? ? File.join(Dir.home, ".config") : xdg
|
|
175
|
+
# A setting that is not UTF-8 is skipped, as ripgrep skips it.
|
|
176
|
+
configured = [ File.join(Dir.home, ".gitconfig"), File.join(config_dir, "git", "config") ].lazy.filter_map do |config|
|
|
177
|
+
path = File.file?(config) && File.binread(config)[/^\s*excludesfile\s*=\s*"?\s*(\S+?)\s*"?\s*$/i, 1]
|
|
178
|
+
path && path.force_encoding(Encoding::UTF_8).valid_encoding? ? path : nil
|
|
179
|
+
end.first
|
|
180
|
+
return File.expand_path(configured.gsub("~", Dir.home)) if configured
|
|
181
|
+
|
|
182
|
+
File.join(config_dir, "git", "ignore")
|
|
181
183
|
rescue StandardError
|
|
182
184
|
nil
|
|
183
185
|
end
|
|
@@ -83,7 +83,7 @@ module RailsAiContext
|
|
|
83
83
|
# the actions its class macros build with define_method, and an unread
|
|
84
84
|
# one only hides class methods.
|
|
85
85
|
def read_modules(source, owner, root, names, unread, depth)
|
|
86
|
-
mixins = ConcernMembership.
|
|
86
|
+
mixins = ConcernMembership.owned_by(
|
|
87
87
|
SourceIntrospector.walk_source(source, { mixins: Listeners::MixinsListener })[:mixins], owner
|
|
88
88
|
)
|
|
89
89
|
mixins.select { |m| m[:ancestor] || (m[:macro] == :extend && !m[:receiver]) }.uniq { |m| m[:name] }.each do |mixin|
|
|
@@ -104,8 +104,10 @@ module RailsAiContext
|
|
|
104
104
|
end
|
|
105
105
|
|
|
106
106
|
def module_source(root, mod, owner)
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
source = ConcernPaths.module_source(root.to_s, mod, within: owner)
|
|
108
|
+
return source if source
|
|
109
|
+
|
|
110
|
+
path = SuperclassChain.resolve_in_scope(owner, mod) { |c| PathResolver.file_for_constant(root.to_s, c) }
|
|
109
111
|
path && SafeFile.read(path)
|
|
110
112
|
end
|
|
111
113
|
|
|
@@ -197,7 +197,8 @@ module RailsAiContext
|
|
|
197
197
|
end
|
|
198
198
|
|
|
199
199
|
def scanned_body(lines, method_name)
|
|
200
|
-
|
|
200
|
+
name_end = RailsAiContext::MethodName.definition_end(method_name)
|
|
201
|
+
start_idx = lines.index { |l| l.match?(/^\s*def\s+#{Regexp.escape(method_name.to_s)}#{name_end}/i) }
|
|
201
202
|
return nil unless start_idx
|
|
202
203
|
|
|
203
204
|
indent = lines[start_idx][/\A\s*/].length
|
|
@@ -12,7 +12,8 @@ module RailsAiContext
|
|
|
12
12
|
UNKNOWN = Object.new.freeze
|
|
13
13
|
# `pairs` is set for a literal hash: its `key: value` text, which reads
|
|
14
14
|
# as keyword arguments where the parameter is passed as the last one.
|
|
15
|
-
|
|
15
|
+
# `items` is set for a list of literals: each one's source.
|
|
16
|
+
Binding = Struct.new(:value, :source, :pairs, :value_sources, :items)
|
|
16
17
|
|
|
17
18
|
# The rewritten body, and for each of its lines the line of the mixin's
|
|
18
19
|
# file it came from, so what the body declares keeps its real location.
|
|
@@ -62,7 +63,7 @@ module RailsAiContext
|
|
|
62
63
|
def entries(definition, call, listeners)
|
|
63
64
|
return {} unless definition.body
|
|
64
65
|
|
|
65
|
-
bindings = unwritten(bind(definition.parameters, call), definition.body)
|
|
66
|
+
bindings = rest_bindings(unwritten(bind(definition.parameters, call), definition.body), definition, call)
|
|
66
67
|
out = Output.new
|
|
67
68
|
undecided = []
|
|
68
69
|
emit(definition.body, bindings, out, undecided, 0, [])
|
|
@@ -79,6 +80,16 @@ module RailsAiContext
|
|
|
79
80
|
[ key, kept ]
|
|
80
81
|
end
|
|
81
82
|
end
|
|
83
|
+
unbound = bindings.filter_map { |name, binding| name if name.is_a?(Symbol) && binding.source.nil? }
|
|
84
|
+
if unbound.any?
|
|
85
|
+
data = data.transform_values do |found|
|
|
86
|
+
Array(found).reject do |entry|
|
|
87
|
+
held = names_unbound?(entry, out, unbound)
|
|
88
|
+
conditional << declaration(entry, out, []) if held
|
|
89
|
+
held
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
82
93
|
data = data.transform_values { |found| Array(found).map { |entry| relocated(entry, out) } }
|
|
83
94
|
data.merge(conditional: conditional, foreign: foreign_entries(out, listeners))
|
|
84
95
|
end
|
|
@@ -128,14 +139,30 @@ module RailsAiContext
|
|
|
128
139
|
# The call written at the entry's line, less any block, with the
|
|
129
140
|
# conditions it runs under.
|
|
130
141
|
def declaration(entry, out, conditions)
|
|
131
|
-
call =
|
|
132
|
-
node.is_a?(Prism::CallNode) && node.location.start_line == entry[:location]
|
|
133
|
-
end
|
|
142
|
+
call = call_at(out, entry[:location])
|
|
134
143
|
{ declaration: call ? call_source(call) : out.text.lines[entry[:location] - 1].to_s.strip,
|
|
135
144
|
condition: (conditions.join(" and ") if conditions.any?),
|
|
136
145
|
location: out.source_line(entry[:location]) || entry[:location] }.compact
|
|
137
146
|
end
|
|
138
147
|
|
|
148
|
+
def call_at(out, line)
|
|
149
|
+
AstWalk.each(AstCache.parse_string(out.text).value).find do |node|
|
|
150
|
+
node.is_a?(Prism::CallNode) && node.location.start_line == line
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# A parameter the expansion cannot bind is written as its bare name, which a
|
|
155
|
+
# listener would read as what the call declares.
|
|
156
|
+
def names_unbound?(entry, out, names)
|
|
157
|
+
return false unless entry.is_a?(Hash) && entry[:location]
|
|
158
|
+
|
|
159
|
+
Array(call_at(out, entry[:location])&.arguments&.arguments).any? do |argument|
|
|
160
|
+
argument = argument.expression if argument.is_a?(Prism::SplatNode)
|
|
161
|
+
(argument.is_a?(Prism::LocalVariableReadNode) || (argument.is_a?(Prism::CallNode) && argument.variable_call?)) &&
|
|
162
|
+
names.include?(argument.name)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
139
166
|
def call_source(call)
|
|
140
167
|
text = call.slice
|
|
141
168
|
text = text.byteslice(0, call.block.location.start_offset - call.location.start_offset) if call.block.is_a?(Prism::BlockNode)
|
|
@@ -197,19 +224,118 @@ module RailsAiContext
|
|
|
197
224
|
Prism::LocalVariableOrWriteNode, Prism::LocalVariableAndWriteNode
|
|
198
225
|
].freeze
|
|
199
226
|
|
|
200
|
-
# A parameter the body assigns, in a block too, no
|
|
201
|
-
# call's argument wherever it is read, so it is bound to nothing.
|
|
227
|
+
# A parameter the body assigns or changes in place, in a block too, no
|
|
228
|
+
# longer holds the call's argument wherever it is read, so it is bound to nothing.
|
|
202
229
|
def unwritten(bindings, body)
|
|
230
|
+
changed(body).each { |name| bindings[name] = unknown if bindings.key?(name) }
|
|
231
|
+
bindings
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# `local[key] ||= v`, `&&=` and `+=` change the local in place too.
|
|
235
|
+
INDEX_WRITES = [ Prism::IndexOrWriteNode, Prism::IndexAndWriteNode, Prism::IndexOperatorWriteNode ].freeze
|
|
236
|
+
|
|
237
|
+
# Methods that change their receiver in place.
|
|
238
|
+
MUTATORS = %i[<< []= push append unshift prepend insert concat pop shift delete delete_at delete_if keep_if
|
|
239
|
+
clear replace store update fill].freeze
|
|
240
|
+
|
|
241
|
+
# Readers that return an object the receiver holds, not a copy.
|
|
242
|
+
HELD_READERS = %i[[] fetch dig].freeze
|
|
243
|
+
|
|
244
|
+
# The method's own locals the body writes or changes in place, outside the `kept` statements.
|
|
245
|
+
def changed(body, kept = [])
|
|
246
|
+
found = Set.new
|
|
203
247
|
stack = [ [ body, 0 ] ]
|
|
204
248
|
until stack.empty?
|
|
205
249
|
node, blocks = stack.pop
|
|
206
|
-
|
|
250
|
+
next if kept.any? { |statement| statement.equal?(node) }
|
|
251
|
+
|
|
252
|
+
found << node.name if WRITES.include?(node.class) && node.depth == blocks
|
|
253
|
+
receiver = node.receiver if INDEX_WRITES.include?(node.class) ||
|
|
254
|
+
(node.is_a?(Prism::CallNode) && (MUTATORS.include?(node.name) || node.name.match?(/\w!\z/)))
|
|
255
|
+
# `options[:a][:b] = v` changes what `options` holds; `options.dup[:a] = v` changes a copy.
|
|
256
|
+
receiver = receiver.receiver while receiver.is_a?(Prism::CallNode) && HELD_READERS.include?(receiver.name)
|
|
257
|
+
found << receiver.name if receiver.is_a?(Prism::LocalVariableReadNode) && receiver.depth == blocks
|
|
207
258
|
blocks += 1 if node.is_a?(Prism::BlockNode) || node.is_a?(Prism::LambdaNode)
|
|
208
259
|
stack.concat(node.compact_child_nodes.map { |child| [ child, blocks ] })
|
|
209
260
|
end
|
|
261
|
+
found
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# `*args` holds the call's positionals past the other parameters, less a trailing hash the body takes
|
|
265
|
+
# (`extract_options!`), plus literals the leading statements push; a local changed elsewhere is unknown.
|
|
266
|
+
def rest_bindings(bindings, definition, call)
|
|
267
|
+
rest = definition.parameters&.rest
|
|
268
|
+
return bindings unless call && rest.respond_to?(:name) && rest.name && definition.body.is_a?(Prism::StatementsNode)
|
|
269
|
+
|
|
270
|
+
given = rest_arguments(definition.parameters, call)
|
|
271
|
+
taken = [ "#{rest.name}.extract_options!", "#{rest.name}.last.is_a?(Hash)?#{rest.name}.pop:{}" ]
|
|
272
|
+
kept = []
|
|
273
|
+
list = given
|
|
274
|
+
leading = true
|
|
275
|
+
definition.body.body.each do |node|
|
|
276
|
+
if node.is_a?(Prism::LocalVariableWriteNode) && taken.include?(node.value.slice.delete(" "))
|
|
277
|
+
last = given&.last
|
|
278
|
+
bindings[node.name] =
|
|
279
|
+
if given.nil? then unknown
|
|
280
|
+
elsif symbol_keyed?(last) then literal(last)
|
|
281
|
+
elsif last.nil? || literal_source?(last) then hash_binding({}, {})
|
|
282
|
+
else unknown
|
|
283
|
+
end
|
|
284
|
+
list = leading && list && (symbol_keyed?(list.last) ? list[0...-1] : list)
|
|
285
|
+
kept << node
|
|
286
|
+
elsif leading && (grown = pushed(node, rest.name, list, bindings))
|
|
287
|
+
list = grown
|
|
288
|
+
kept << node
|
|
289
|
+
else
|
|
290
|
+
leading = false
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
changed_names = changed(definition.body, kept)
|
|
294
|
+
kept.each { |node| bindings[node.name] = unknown if node.is_a?(Prism::LocalVariableWriteNode) && changed_names.include?(node.name) }
|
|
295
|
+
list = nil if !list || changed_names.include?(rest.name) || list.any? { |item| !literal_source?(item) }
|
|
296
|
+
bindings[rest.name] = list ? list_binding(list) : unknown
|
|
210
297
|
bindings
|
|
211
298
|
end
|
|
212
299
|
|
|
300
|
+
# The call's arguments `*rest` takes, nil when they cannot be told (a splat, too few).
|
|
301
|
+
def rest_arguments(parameters, call)
|
|
302
|
+
arguments = Array(call.arguments&.arguments)
|
|
303
|
+
return nil if arguments.any? { |argument| argument.is_a?(Prism::SplatNode) }
|
|
304
|
+
|
|
305
|
+
takes_keywords = parameters.keywords.any? || parameters.keyword_rest
|
|
306
|
+
arguments = arguments[0...-1] if takes_keywords && arguments.last.is_a?(Prism::KeywordHashNode)
|
|
307
|
+
before = parameters.requireds.size
|
|
308
|
+
after = parameters.posts.size
|
|
309
|
+
return nil if arguments.size < before + after
|
|
310
|
+
|
|
311
|
+
before += [ parameters.optionals.size, arguments.size - before - after ].min
|
|
312
|
+
arguments[before...(arguments.size - after)]
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# The list `args << :x` or `args.push(:x)` leaves, under a modifier `if` the bindings decide; nil for any other statement.
|
|
316
|
+
def pushed(node, name, list, bindings)
|
|
317
|
+
return nil unless list
|
|
318
|
+
|
|
319
|
+
if node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode)
|
|
320
|
+
return nil if node.is_a?(Prism::IfNode) ? node.subsequent : node.else_clause
|
|
321
|
+
return nil unless node.statements&.body&.one? && (grown = pushed(node.statements.body.first, name, list, bindings))
|
|
322
|
+
|
|
323
|
+
truth = truth_of(evaluate(node.predicate, bindings.merge(name => list_binding(list)), 0))
|
|
324
|
+
return nil if truth == UNKNOWN
|
|
325
|
+
|
|
326
|
+
return (node.is_a?(Prism::UnlessNode) ? !truth : truth) ? grown : list
|
|
327
|
+
end
|
|
328
|
+
return nil unless node.is_a?(Prism::CallNode) && %i[<< push].include?(node.name) && node.block.nil?
|
|
329
|
+
return nil unless node.receiver.is_a?(Prism::LocalVariableReadNode) && node.receiver.name == name
|
|
330
|
+
|
|
331
|
+
items = Array(node.arguments&.arguments)
|
|
332
|
+
list + items if items.all? { |item| literal_source?(item) }
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def list_binding(items)
|
|
336
|
+
Binding.new(items.map { |item| value_of(item) }, "[#{items.map(&:slice).join(", ")}]", nil, nil, items.map(&:slice))
|
|
337
|
+
end
|
|
338
|
+
|
|
213
339
|
# A hash with symbol keys binds by its pairs' own source, whatever
|
|
214
340
|
# the values are: `length: { in: 4..Proposal.max }` stands in for itself.
|
|
215
341
|
def literal(node)
|
|
@@ -270,9 +396,14 @@ module RailsAiContext
|
|
|
270
396
|
def emit(node, bindings, out, undecided, depth, conditions)
|
|
271
397
|
case node
|
|
272
398
|
when Prism::LocalVariableReadNode
|
|
273
|
-
source = node
|
|
399
|
+
source = bound(bindings, node, depth)&.source
|
|
274
400
|
return out.append(source, node.location.start_line) if source
|
|
401
|
+
when Prism::InterpolatedSymbolNode, Prism::InterpolatedStringNode
|
|
402
|
+
folded = !heredoc?(node) && interpolated(node, bindings, depth)
|
|
403
|
+
return out.append(folded.inspect, node.location.start_line) if folded
|
|
275
404
|
when Prism::CallNode
|
|
405
|
+
return emit_each(node, bindings, out, undecided, depth, conditions) if unrolled?(node, bindings, depth)
|
|
406
|
+
|
|
276
407
|
looked_up = hash_lookup(node, bindings, depth)
|
|
277
408
|
return out.append(looked_up, node.location.start_line) if looked_up
|
|
278
409
|
when Prism::IfNode, Prism::UnlessNode
|
|
@@ -310,13 +441,57 @@ module RailsAiContext
|
|
|
310
441
|
out.append(text.byteslice(position, text.bytesize - position), line_at.call(position))
|
|
311
442
|
end
|
|
312
443
|
|
|
444
|
+
# The binding a read reaches: the method's parameter, or the parameter of a block written once per item.
|
|
445
|
+
def bound(bindings, node, depth)
|
|
446
|
+
level = depth - node.depth
|
|
447
|
+
level.zero? ? bindings[node.name] : bindings[[ node.name, level ]]
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# `"#{field}_changed?"` with every part a literal or a bound literal, as the value it makes.
|
|
451
|
+
def interpolated(node, bindings, depth)
|
|
452
|
+
text = node.parts.map do |part|
|
|
453
|
+
next part.unescaped if part.is_a?(Prism::StringNode)
|
|
454
|
+
|
|
455
|
+
statements = part.is_a?(Prism::EmbeddedStatementsNode) && part.statements&.body
|
|
456
|
+
return nil unless statements&.one?
|
|
457
|
+
|
|
458
|
+
inner = statements.first
|
|
459
|
+
value = inner.is_a?(Prism::LocalVariableReadNode) ? bound(bindings, inner, depth)&.value : value_of(inner)
|
|
460
|
+
return nil if value.nil? || value == UNKNOWN || value.is_a?(Hash) || value.is_a?(Array)
|
|
461
|
+
|
|
462
|
+
value.to_s
|
|
463
|
+
end.join
|
|
464
|
+
node.is_a?(Prism::InterpolatedSymbolNode) ? text.to_sym : text
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# `args.each do |field| ... end` over a bound list of literals.
|
|
468
|
+
def unrolled?(node, bindings, depth)
|
|
469
|
+
receiver = node.receiver
|
|
470
|
+
return false unless node.name == :each && node.arguments.nil? && node.block.is_a?(Prism::BlockNode) && node.block.body
|
|
471
|
+
return false unless receiver.is_a?(Prism::LocalVariableReadNode) && bound(bindings, receiver, depth)&.items
|
|
472
|
+
|
|
473
|
+
params = node.block.parameters&.parameters
|
|
474
|
+
params && params.requireds.one? && params.requireds.first.respond_to?(:name) && params.optionals.empty? && params.rest.nil? && params.posts.empty?
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# The block's body written once per item, its parameter bound to that item.
|
|
478
|
+
def emit_each(node, bindings, out, undecided, depth, conditions)
|
|
479
|
+
name = node.block.parameters.parameters.requireds.first.name
|
|
480
|
+
binding = bound(bindings, node.receiver, depth)
|
|
481
|
+
binding.items.zip(binding.value).each do |source, value|
|
|
482
|
+
emit(node.block.body, bindings.merge([ name, depth + 1 ] => Binding.new(value, source)), out, undecided, depth + 1, conditions)
|
|
483
|
+
out.append("\n", node.location.end_line)
|
|
484
|
+
end
|
|
485
|
+
out
|
|
486
|
+
end
|
|
487
|
+
|
|
313
488
|
# A call's arguments, each written from its own node and joined with a
|
|
314
489
|
# comma: an argument that stands for no keywords at all (an options hash
|
|
315
490
|
# left at `{}`) is left out whole, with no separator to dangle.
|
|
316
491
|
def emit_arguments(node, bindings, out, undecided, depth, conditions)
|
|
317
492
|
written = false
|
|
318
493
|
node.arguments.each do |argument|
|
|
319
|
-
pairs = keyword_argument(node, argument, bindings, depth)
|
|
494
|
+
pairs = keyword_argument(node, argument, bindings, depth) || splatted_items(argument, bindings, depth)
|
|
320
495
|
next if pairs&.empty?
|
|
321
496
|
|
|
322
497
|
out.append(", ", argument.location.start_line) if written
|
|
@@ -326,12 +501,19 @@ module RailsAiContext
|
|
|
326
501
|
out
|
|
327
502
|
end
|
|
328
503
|
|
|
504
|
+
# `*names` over a bound list of literals is its items.
|
|
505
|
+
def splatted_items(argument, bindings, depth)
|
|
506
|
+
return unless argument.is_a?(Prism::SplatNode) && argument.expression.is_a?(Prism::LocalVariableReadNode)
|
|
507
|
+
|
|
508
|
+
bound(bindings, argument.expression, depth)&.items&.join(", ")
|
|
509
|
+
end
|
|
510
|
+
|
|
329
511
|
# `options[:length]` on a hash parameter the call passed as a literal:
|
|
330
512
|
# the value's own source, or `nil` for a key the call left out.
|
|
331
513
|
def hash_lookup(node, bindings, depth)
|
|
332
|
-
return unless node.name == :[] && node.receiver.is_a?(Prism::LocalVariableReadNode)
|
|
514
|
+
return unless node.name == :[] && node.receiver.is_a?(Prism::LocalVariableReadNode)
|
|
333
515
|
|
|
334
|
-
sources = bindings
|
|
516
|
+
sources = bound(bindings, node.receiver, depth)&.value_sources
|
|
335
517
|
key = Array(node.arguments&.arguments)
|
|
336
518
|
return unless sources && key.one? && key.first.is_a?(Prism::SymbolNode)
|
|
337
519
|
|
|
@@ -350,9 +532,7 @@ module RailsAiContext
|
|
|
350
532
|
# `slice`, `except` or a `reject { |key| key == :x }` on it. Anything else
|
|
351
533
|
# on it is not read, and the expression stays as written.
|
|
352
534
|
def hash_expression(node, bindings, depth)
|
|
353
|
-
if node.is_a?(Prism::LocalVariableReadNode)
|
|
354
|
-
return node.depth == depth ? bindings[node.name]&.then { |b| b if b.value_sources } : nil
|
|
355
|
-
end
|
|
535
|
+
return bound(bindings, node, depth)&.then { |b| b if b.value_sources } if node.is_a?(Prism::LocalVariableReadNode)
|
|
356
536
|
return unless node.is_a?(Prism::CallNode) && node.receiver
|
|
357
537
|
|
|
358
538
|
base = hash_expression(node.receiver, bindings, depth) or return
|
|
@@ -363,7 +543,7 @@ module RailsAiContext
|
|
|
363
543
|
when :merge
|
|
364
544
|
return unless arguments.one? && symbol_keyed?(arguments.first) && node.block.nil?
|
|
365
545
|
|
|
366
|
-
added = arguments.first.elements.to_h { |pair| [ key_of(pair), pair.value.
|
|
546
|
+
added = arguments.first.elements.to_h { |pair| [ key_of(pair), emit(pair.value, bindings, Output.new, [], depth, []).text ] }
|
|
367
547
|
hash_binding(UNKNOWN, sources.merge(added))
|
|
368
548
|
when :slice then keys && hash_binding(UNKNOWN, sources.slice(*keys))
|
|
369
549
|
when :except then keys && hash_binding(UNKNOWN, sources.except(*keys))
|
|
@@ -465,7 +645,7 @@ module RailsAiContext
|
|
|
465
645
|
def evaluate(node, bindings, depth)
|
|
466
646
|
case node
|
|
467
647
|
when Prism::LocalVariableReadNode
|
|
468
|
-
node
|
|
648
|
+
(bound(bindings, node, depth) || unknown).value
|
|
469
649
|
when Prism::ParenthesesNode
|
|
470
650
|
body = node.body&.body
|
|
471
651
|
body&.one? ? evaluate(body.first, bindings, depth) : UNKNOWN
|
|
@@ -502,7 +682,7 @@ module RailsAiContext
|
|
|
502
682
|
when :[] then receiver.is_a?(Hash) ? receiver[arguments.first] : UNKNOWN
|
|
503
683
|
when :key?, :has_key?, :include? then receiver.is_a?(Hash) ? receiver.key?(arguments.first) : UNKNOWN
|
|
504
684
|
when :many?, :any?, :empty?, :size, :length
|
|
505
|
-
return UNKNOWN unless receiver.is_a?(Hash) && arguments.empty? && !node.block
|
|
685
|
+
return UNKNOWN unless (receiver.is_a?(Hash) || receiver.is_a?(Array)) && arguments.empty? && !node.block
|
|
506
686
|
|
|
507
687
|
node.name == :many? ? receiver.size > 1 : receiver.public_send(node.name)
|
|
508
688
|
when :fetch
|
|
@@ -290,10 +290,10 @@ module RailsAiContext
|
|
|
290
290
|
# would have Rails dispatch to; the routes decide which are actions.
|
|
291
291
|
def concern_actions(concerns, within, filters)
|
|
292
292
|
Array(concerns).flat_map do |concern|
|
|
293
|
-
|
|
294
|
-
next [] unless
|
|
293
|
+
source = ConcernPaths.module_source(app.root.to_s, concern.to_s, prefer: "controller", within: within)
|
|
294
|
+
next [] unless source
|
|
295
295
|
|
|
296
|
-
offered = (@concern_actions ||= {})[
|
|
296
|
+
offered = (@concern_actions ||= {})[source] ||= ActionResolver.module_actions(source)
|
|
297
297
|
ActionResolver.deliverable_actions(offered, filter_names(filters))
|
|
298
298
|
end.uniq
|
|
299
299
|
rescue => e
|
|
@@ -157,17 +157,26 @@ module RailsAiContext
|
|
|
157
157
|
# @param root [Prism::Node] the file's parse tree
|
|
158
158
|
# @return [Prism::ModuleNode, nil]
|
|
159
159
|
def module_node(root, name)
|
|
160
|
-
|
|
160
|
+
module_nodes(root)[name]
|
|
161
161
|
end
|
|
162
162
|
|
|
163
|
-
# Every module the tree declares, by qualified name
|
|
164
|
-
#
|
|
163
|
+
# Every module the tree declares, by qualified name: the first that is
|
|
164
|
+
# not a stub, as a namespace stub above the definition is not it.
|
|
165
165
|
#
|
|
166
166
|
# @return [Hash{String => Prism::ModuleNode}]
|
|
167
167
|
def module_nodes(root)
|
|
168
|
-
constants(root).
|
|
169
|
-
|
|
170
|
-
|
|
168
|
+
constants(root).select { |_, node| node.is_a?(Prism::ModuleNode) }
|
|
169
|
+
.group_by(&:first).transform_values { |found| definition(found.map { |_, node| node }) }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Of the nodes writing one constant, the one defining it: the first that is not a stub.
|
|
173
|
+
def definition(nodes)
|
|
174
|
+
nodes.find { |node| !stub?(node) } || nodes.first
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# A class or module holding nothing but such stubs: `module Acts; module Journalized; end; end`.
|
|
178
|
+
def stub?(node)
|
|
179
|
+
Array(node.body&.compact_child_nodes).all? { |child| (child.is_a?(Prism::ModuleNode) || child.is_a?(Prism::ClassNode)) && stub?(child) }
|
|
171
180
|
end
|
|
172
181
|
|
|
173
182
|
# Each class and module the tree declares, in source order: its
|
|
@@ -77,12 +77,15 @@ module RailsAiContext
|
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
queue = job.queue_name
|
|
80
|
-
#
|
|
81
|
-
#
|
|
80
|
+
# The default is a lambda; another Proc is a queue_as block, and a
|
|
81
|
+
# Proc argument is stored as its inspect string, with the line that set it.
|
|
82
82
|
if queue.equal?(ActiveJob::Base.queue_name)
|
|
83
83
|
queue = job.queue_name_from_part(nil)
|
|
84
84
|
elsif queue.is_a?(Proc)
|
|
85
|
-
queue = (
|
|
85
|
+
queue = labelled(COMPUTED_QUEUE, queue.source_location && block_at(*queue.source_location))
|
|
86
|
+
elsif queue.to_s.include?("#<Proc:")
|
|
87
|
+
location = queue.match(/#<Proc:0x\h+ (.+):(\d+)(?: \(lambda\))?>/)
|
|
88
|
+
queue = labelled(PROC_QUEUE, location && proc_at(location[1], location[2].to_i))
|
|
86
89
|
end
|
|
87
90
|
|
|
88
91
|
{
|
|
@@ -139,7 +142,39 @@ module RailsAiContext
|
|
|
139
142
|
end
|
|
140
143
|
|
|
141
144
|
def inherited_queue(name)
|
|
142
|
-
chain_of(name).lazy.filter_map { |link| queue_as(job_candidates[link].ast
|
|
145
|
+
chain_of(name).lazy.filter_map { |link| queue_as(job_candidates[link].ast) }.first
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def labelled(label, source)
|
|
149
|
+
source ? "#{label}: `#{source}`" : label
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def block_at(file, line)
|
|
153
|
+
queue_as_walk(file)&.dig(:macros)&.find { |m| m[:macro] == :queue_as && m[:location] == line && m[:block] }&.dig(:block)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Only one Proc literal on the line says which one it was.
|
|
157
|
+
def proc_at(file, line)
|
|
158
|
+
found = queue_as_walk(file)&.dig(:procs)&.select { |literal| literal[:line] == line } || []
|
|
159
|
+
found.first[:source] if found.one?
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Only the app's own files are read; a job file reuses the walk that found it.
|
|
163
|
+
def queue_as_walk(file)
|
|
164
|
+
@queue_as_walks ||= {}
|
|
165
|
+
@queue_as_walks.fetch(file) do
|
|
166
|
+
@queue_as_walks[file] = begin
|
|
167
|
+
real = File.realpath(file)
|
|
168
|
+
root = app.root.to_s
|
|
169
|
+
if SourceScan.under_root?(file, real, root, app_root_real)
|
|
170
|
+
relative = SourceScan.relative_file(file, real, root, app_root_real)
|
|
171
|
+
job_candidates.values.find { |candidate| candidate.file == relative }&.ast ||
|
|
172
|
+
(source = SafeFile.read(file)) && SourceIntrospector.walk_source(source, QUEUE_AS_LISTENERS)
|
|
173
|
+
end
|
|
174
|
+
rescue SystemCallError
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
end
|
|
143
178
|
end
|
|
144
179
|
|
|
145
180
|
# This class and its ancestors among the candidates, nearest first.
|
|
@@ -153,17 +188,28 @@ module RailsAiContext
|
|
|
153
188
|
end
|
|
154
189
|
|
|
155
190
|
COMPUTED_QUEUE = "computed by a block"
|
|
191
|
+
PROC_QUEUE = "queue_as given a Proc: ActiveJob does not call it, so the queue is named after the Proc's text"
|
|
192
|
+
PROC_LITERAL = /\A(?:->|(?:lambda|proc|Proc\.new)(?![\w.]))/
|
|
193
|
+
|
|
194
|
+
QUEUE_AS_LISTENERS = {
|
|
195
|
+
macros: -> { Listeners::GenericMacroListener.new(*JOB_MACROS, block_source: [ :queue_as ]) },
|
|
196
|
+
procs: Listeners::ProcLiteralListener
|
|
197
|
+
}.freeze
|
|
156
198
|
|
|
157
199
|
# A literal queue by name; one picked at enqueue time by the source that
|
|
158
200
|
# picks it, the way other computed values read.
|
|
159
|
-
def queue_as(
|
|
160
|
-
hit = macros.find { |m| m[:macro] == :queue_as } or return nil
|
|
201
|
+
def queue_as(ast)
|
|
202
|
+
hit = ast[:macros].find { |m| m[:macro] == :queue_as } or return nil
|
|
161
203
|
return hit[:args].first.to_s if hit[:args].any?
|
|
204
|
+
return labelled(COMPUTED_QUEUE, hit[:block]) if hit[:block]
|
|
162
205
|
|
|
163
206
|
source = hit[:values].first.to_s.gsub(/\s+/, " ").strip
|
|
164
207
|
return COMPUTED_QUEUE if source.empty?
|
|
165
208
|
|
|
166
|
-
|
|
209
|
+
assigned = Array(ast[:procs]).find { |literal| literal[:constant] == source }
|
|
210
|
+
return labelled(PROC_QUEUE, assigned[:source]) if assigned
|
|
211
|
+
|
|
212
|
+
source.match?(PROC_LITERAL) ? labelled(PROC_QUEUE, source) : "`#{source}` (computed)"
|
|
167
213
|
end
|
|
168
214
|
|
|
169
215
|
def sidekiq_options(macros)
|
|
@@ -321,11 +367,10 @@ module RailsAiContext
|
|
|
321
367
|
declared: declaration.name,
|
|
322
368
|
declares: declarations.map(&:name),
|
|
323
369
|
superclass: declaration.superclass,
|
|
324
|
-
ast: SourceIntrospector.walk_source(record.source,
|
|
325
|
-
macros: -> { Listeners::GenericMacroListener.new(*JOB_MACROS) },
|
|
370
|
+
ast: SourceIntrospector.walk_source(record.source, QUEUE_AS_LISTENERS.merge(
|
|
326
371
|
methods: Listeners::MethodsListener,
|
|
327
372
|
calls: SourceCalls.listener
|
|
328
|
-
|
|
373
|
+
))
|
|
329
374
|
)
|
|
330
375
|
end
|
|
331
376
|
end
|
|
@@ -322,9 +322,9 @@ module RailsAiContext
|
|
|
322
322
|
end
|
|
323
323
|
end
|
|
324
324
|
|
|
325
|
-
def confidence_for(node)
|
|
326
|
-
|
|
327
|
-
|
|
325
|
+
def confidence_for(node) = self.class.confidence_for(node)
|
|
326
|
+
|
|
327
|
+
def self.confidence_for(node) = RailsAiContext::Confidence.for_node(node)
|
|
328
328
|
end
|
|
329
329
|
end
|
|
330
330
|
end
|