rails-ai-context 5.30.0 → 5.30.2
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 +159 -0
- data/docs/ARCHITECTURE.md +5 -1
- data/docs/CLI.md +1 -1
- data/docs/COMPATIBILITY.md +7 -1
- data/docs/CONFIGURATION.md +2 -2
- data/docs/FAQ.md +1 -1
- data/docs/GUIDE.md +1 -1
- data/exe/rails-ai-context +3 -4
- data/lib/rails_ai_context/action_filters.rb +69 -6
- data/lib/rails_ai_context/boot_manager.rb +21 -5
- data/lib/rails_ai_context/cli/entry_boot.rb +3 -2
- data/lib/rails_ai_context/cli/tool_runner.rb +22 -3
- data/lib/rails_ai_context/concern_paths.rb +2 -1
- data/lib/rails_ai_context/configuration.rb +6 -6
- data/lib/rails_ai_context/erb_source.rb +22 -3
- data/lib/rails_ai_context/introspector.rb +13 -2
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +43 -16
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +10 -15
- data/lib/rails_ai_context/introspectors/eager_load.rb +22 -10
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +4 -3
- data/lib/rails_ai_context/introspectors/job_introspector.rb +24 -10
- data/lib/rails_ai_context/introspectors/listeners/associations_listener.rb +12 -0
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +9 -1
- data/lib/rails_ai_context/introspectors/listeners/mount_listener.rb +13 -6
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +83 -27
- data/lib/rails_ai_context/introspectors/listeners/with_options_scope.rb +49 -12
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +15 -0
- data/lib/rails_ai_context/introspectors/migration_replay.rb +2 -0
- data/lib/rails_ai_context/introspectors/model_introspector.rb +14 -3
- data/lib/rails_ai_context/introspectors/pg_partitions.rb +73 -0
- data/lib/rails_ai_context/introspectors/route_introspector.rb +62 -4
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +17 -2
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +13 -7
- data/lib/rails_ai_context/introspectors/schema_reader.rb +28 -2
- data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +8 -4
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +18 -16
- data/lib/rails_ai_context/migration_status.rb +6 -1
- data/lib/rails_ai_context/pending_migrations.rb +3 -2
- data/lib/rails_ai_context/portable_path.rb +14 -0
- data/lib/rails_ai_context/schema_adapter.rb +6 -1
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +2 -2
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +5 -0
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -1
- data/lib/rails_ai_context/tools/base_tool.rb +9 -8
- data/lib/rails_ai_context/tools/diagnose.rb +4 -0
- data/lib/rails_ai_context/tools/generate_test.rb +19 -0
- data/lib/rails_ai_context/tools/get_model_details.rb +1 -1
- data/lib/rails_ai_context/tools/get_partial_interface.rb +18 -4
- data/lib/rails_ai_context/tools/get_routes.rb +19 -9
- data/lib/rails_ai_context/tools/get_schema.rb +24 -5
- data/lib/rails_ai_context/tools/get_service_pattern.rb +44 -25
- data/lib/rails_ai_context/tools/get_test_info.rb +8 -0
- data/lib/rails_ai_context/tools/migration_advisor.rb +1 -1
- data/lib/rails_ai_context/tools/query.rb +8 -7
- data/lib/rails_ai_context/tools/runtime_info.rb +13 -10
- data/lib/rails_ai_context/tools/validate_semantics.rb +56 -14
- data/lib/rails_ai_context/version.rb +1 -1
- data/server.json +1 -1
- metadata +2 -1
|
@@ -52,6 +52,7 @@ module RailsAiContext
|
|
|
52
52
|
warnings = []
|
|
53
53
|
config.introspectors.each do |name|
|
|
54
54
|
data = context[name]
|
|
55
|
+
portable_errors!(data)
|
|
55
56
|
if data.is_a?(Hash) && data[:error]
|
|
56
57
|
warnings << { introspector: name.to_s, error: data[:error] }
|
|
57
58
|
end
|
|
@@ -133,12 +134,11 @@ module RailsAiContext
|
|
|
133
134
|
# nothing about the database, and answering for it would be the
|
|
134
135
|
# fabrication this method exists to remove.
|
|
135
136
|
return if schema[:adapter].nil?
|
|
136
|
-
return unless SchemaAdapter.placeholder?(schema[:adapter])
|
|
137
137
|
|
|
138
138
|
# "unknown" is a placeholder too. Swapping one for another buys nothing
|
|
139
139
|
# and would bury the parse mode that at least says how the schema was read.
|
|
140
140
|
resolved = SchemaAdapter.label(context)
|
|
141
|
-
return if SchemaAdapter.placeholder?(resolved)
|
|
141
|
+
return if SchemaAdapter.placeholder?(resolved) || resolved == schema[:adapter]
|
|
142
142
|
|
|
143
143
|
schema[:adapter_source] = schema[:adapter]
|
|
144
144
|
schema[:adapter] = resolved
|
|
@@ -178,6 +178,17 @@ module RailsAiContext
|
|
|
178
178
|
end
|
|
179
179
|
end
|
|
180
180
|
|
|
181
|
+
# An exception message names files by absolute path, and the context is committed.
|
|
182
|
+
def portable_errors!(data)
|
|
183
|
+
case data
|
|
184
|
+
when Hash
|
|
185
|
+
data[:error] = PortablePath.relativize_text(data[:error], app.root) if data[:error].is_a?(String) && !data.frozen?
|
|
186
|
+
data.each_value { |value| portable_errors!(value) }
|
|
187
|
+
when Array
|
|
188
|
+
data.each { |value| portable_errors!(value) }
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
181
192
|
def unavailable_reason
|
|
182
193
|
Introspectors::StaticTier.unavailable_reason
|
|
183
194
|
end
|
|
@@ -21,7 +21,7 @@ module RailsAiContext
|
|
|
21
21
|
result = controllers.each_with_object({}) do |ctrl, hash|
|
|
22
22
|
hash[ctrl.name] = extract_controller_details(ctrl)
|
|
23
23
|
rescue => e
|
|
24
|
-
hash[ctrl.name] = { error: e
|
|
24
|
+
hash[ctrl.name] = { error: portable_message(e) }
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
# Discover controllers from filesystem that may not be loaded as classes.
|
|
@@ -52,7 +52,7 @@ module RailsAiContext
|
|
|
52
52
|
|
|
53
53
|
hash[name] = details[:error] ? details : details.merge(confidence: Confidence::STATIC)
|
|
54
54
|
rescue => e
|
|
55
|
-
hash[path_name] = { error: e
|
|
55
|
+
hash[path_name] = { error: portable_message(e) }
|
|
56
56
|
end
|
|
57
57
|
{
|
|
58
58
|
controllers: fill_inherited_actions(result),
|
|
@@ -224,7 +224,12 @@ module RailsAiContext
|
|
|
224
224
|
}.compact
|
|
225
225
|
details
|
|
226
226
|
rescue => e
|
|
227
|
-
{ error: e
|
|
227
|
+
{ error: portable_message(e) }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# The context is committed, so a load error names the file as the app does.
|
|
231
|
+
def portable_message(error)
|
|
232
|
+
PortablePath.relativize_text(error.message, app.root)
|
|
228
233
|
end
|
|
229
234
|
|
|
230
235
|
def extract_controller_details(ctrl)
|
|
@@ -313,7 +318,7 @@ module RailsAiContext
|
|
|
313
318
|
# Collect only/except constraints from source files in the inheritance chain
|
|
314
319
|
source_constraints = collect_source_constraints(ctrl, source)
|
|
315
320
|
reflection_filters.each do |f|
|
|
316
|
-
if (sc = source_constraints[f[:name]])
|
|
321
|
+
if (sc = source_constraints[[ f[:kind], f[:name] ]])
|
|
317
322
|
f[:only] = sc[:only] if sc[:only]&.any?
|
|
318
323
|
f[:except] = sc[:except] if sc[:except]&.any?
|
|
319
324
|
f[:unless] = sc[:unless] if sc[:unless]
|
|
@@ -324,7 +329,7 @@ module RailsAiContext
|
|
|
324
329
|
# Evaluate known runtime conditions to remove inapplicable filters
|
|
325
330
|
reflection_filters.reject! { |f| filter_excluded_by_condition?(ctrl, f) }
|
|
326
331
|
|
|
327
|
-
return merge_own_source(reflection_filters, source || read_source(ctrl))
|
|
332
|
+
return merge_own_source(reflection_filters, source || read_source(ctrl), ctrl)
|
|
328
333
|
end
|
|
329
334
|
end
|
|
330
335
|
|
|
@@ -349,12 +354,8 @@ module RailsAiContext
|
|
|
349
354
|
while klass&.name && !ActionResolver.framework?(klass, kind: :controller)
|
|
350
355
|
src = (klass == ctrl) ? (current_source || read_source(klass)) : read_source(klass)
|
|
351
356
|
if src
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
# First definition wins (most specific controller in chain)
|
|
356
|
-
constraints[sf[:name]] ||= sf
|
|
357
|
-
end
|
|
357
|
+
# Within one body the last declaration wins; across the chain the most specific class does.
|
|
358
|
+
effective_declarations(own_filters(src, klass.name)).each { |key, sf| constraints[key] ||= sf }
|
|
358
359
|
end
|
|
359
360
|
klass = klass.superclass
|
|
360
361
|
end
|
|
@@ -371,19 +372,45 @@ module RailsAiContext
|
|
|
371
372
|
# spliced in beside the record it takes out, and the body's order
|
|
372
373
|
# decides only whether the skip reads before or after a re-declaration
|
|
373
374
|
# of the same name.
|
|
374
|
-
|
|
375
|
+
# A concern the body includes declares as the body does, so it is read
|
|
376
|
+
# the way the static tier reads it and keeps its `from_concern`.
|
|
377
|
+
def merge_own_source(filters, source, ctrl)
|
|
375
378
|
return filters unless source
|
|
376
379
|
|
|
377
|
-
own =
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
380
|
+
own = own_filters(source, ctrl.name).reject { |f| inherited_concern?(ctrl, f[:from_concern]) }
|
|
381
|
+
# Kind and name: `after_action :audit` inherited beside an own
|
|
382
|
+
# `before_action :audit` is still the ancestor's.
|
|
383
|
+
by_key = filters.group_by { |f| [ f[:kind], f[:name] ] }
|
|
384
|
+
effective_declarations(own).each do |key, declared|
|
|
385
|
+
Array(by_key[key]).each do |f|
|
|
386
|
+
f[:declared] = true
|
|
387
|
+
declared[:from_concern] ? f[:from_concern] = declared[:from_concern] : f.delete(:from_concern)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
381
390
|
skips = own.select { |f| f[:skipped] }
|
|
382
391
|
return filters if skips.empty?
|
|
383
392
|
|
|
384
393
|
splice_skips(filters, own, skips)
|
|
385
394
|
end
|
|
386
395
|
|
|
396
|
+
# ActiveSupport::Concern skips a module already in the ancestors; a plain module's hook runs again.
|
|
397
|
+
def inherited_concern?(ctrl, label)
|
|
398
|
+
return false unless label && ctrl.superclass
|
|
399
|
+
|
|
400
|
+
mod = SuperclassChain.resolve_in_scope(ctrl.name, label) { |candidate| candidate.safe_constantize }
|
|
401
|
+
mod.is_a?(ActiveSupport::Concern) && ctrl.superclass.include?(mod)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def own_filters(source, within)
|
|
405
|
+
ControllerFilters.with_concerns(source, root: app.root.to_s, within: within.to_s,
|
|
406
|
+
cache: (@concern_cache ||= {})).first
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# A later declaration of the same kind and name replaces the earlier one, as Rails does.
|
|
410
|
+
def effective_declarations(own)
|
|
411
|
+
own.reject { |f| f[:skipped] }.to_h { |f| [ [ f[:kind], f[:name] ], f ] }
|
|
412
|
+
end
|
|
413
|
+
|
|
387
414
|
def splice_skips(filters, own, skips)
|
|
388
415
|
placed = Set.new
|
|
389
416
|
merged = filters.flat_map do |f|
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module RailsAiContext
|
|
4
4
|
module Introspectors
|
|
5
|
-
#
|
|
6
|
-
#
|
|
5
|
+
# Approximate row counts per table on PostgreSQL, MySQL and SQLite; any
|
|
6
|
+
# other adapter returns { skipped: true }.
|
|
7
7
|
class DatabaseStatsIntrospector < Base
|
|
8
8
|
extend StaticTier
|
|
9
9
|
static_tier :runtime_only
|
|
@@ -13,12 +13,15 @@ module RailsAiContext
|
|
|
13
13
|
# stats collection for every Trilogy app.
|
|
14
14
|
MYSQL_ADAPTER = /mysql|trilogy/i
|
|
15
15
|
|
|
16
|
+
# activerecord-postgis-adapter reports "PostGIS" for what is a PostgreSQL connection.
|
|
17
|
+
POSTGRES_ADAPTER = /postg/i
|
|
18
|
+
|
|
16
19
|
def call
|
|
17
20
|
return { skipped: true, reason: "ActiveRecord not available" } unless defined?(ActiveRecord::Base)
|
|
18
21
|
|
|
19
22
|
adapter = ActiveRecord::Base.connection.adapter_name.downcase
|
|
20
23
|
case adapter
|
|
21
|
-
when
|
|
24
|
+
when POSTGRES_ADAPTER
|
|
22
25
|
collect_postgresql_stats
|
|
23
26
|
when MYSQL_ADAPTER
|
|
24
27
|
collect_mysql_stats
|
|
@@ -32,18 +35,10 @@ module RailsAiContext
|
|
|
32
35
|
private
|
|
33
36
|
|
|
34
37
|
def collect_postgresql_stats
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
FROM pg_stat_user_tables
|
|
40
|
-
ORDER BY n_live_tup DESC
|
|
41
|
-
SQL
|
|
42
|
-
|
|
43
|
-
tables = rows.map do |row|
|
|
44
|
-
entry = { table: row["table_name"], approximate_rows: row["approximate_row_count"].to_i }
|
|
45
|
-
dead = row["dead_rows"].to_i
|
|
46
|
-
entry[:dead_rows] = dead if dead > 0
|
|
38
|
+
stats = PgPartitions.table_rows(ActiveRecord::Base.connection).sort_by { |t| [ -t[:rows], t[:name] ] }
|
|
39
|
+
tables = stats.map do |t|
|
|
40
|
+
entry = { table: t[:name], approximate_rows: t[:rows] }
|
|
41
|
+
entry[:dead_rows] = t[:dead_rows] if t[:dead_rows] > 0
|
|
47
42
|
entry
|
|
48
43
|
end
|
|
49
44
|
|
|
@@ -43,22 +43,34 @@ module RailsAiContext
|
|
|
43
43
|
end
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
# SourceScan records, each loaded by the constant its file holds. Without
|
|
47
|
+
# the loader's answer (Zeitwerk < 2.6.9) the source's own declaration names
|
|
48
|
+
# it: path_name reads app/services/x.rb as Services::X.
|
|
49
|
+
def files(records)
|
|
50
|
+
loader = Rails.autoloaders.main
|
|
51
|
+
records.each do |record|
|
|
52
|
+
names = [ expected_cpath(loader, record.path) ].compact
|
|
53
|
+
names = DeclaredConstant.declarations(record.source).map(&:name) if names.empty?
|
|
54
|
+
names = [ record.path_name ] if names.empty?
|
|
55
|
+
names.each(&:constantize)
|
|
56
|
+
rescue StandardError, ScriptError => e
|
|
57
|
+
RailsAiContext.debug_fail(e, label: "load #{record.file}")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
46
61
|
# The loader declines a file it does not manage (a pack or an in-repo
|
|
47
62
|
# engine runs its own), by nil or by raising; camelize still names it
|
|
48
63
|
# well enough for that loader's autoload to answer.
|
|
49
64
|
def cpath_for(loader, path, file)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
cpath = loader.cpath_expected_at(file)
|
|
53
|
-
return cpath if cpath
|
|
54
|
-
rescue Zeitwerk::Error
|
|
55
|
-
nil
|
|
56
|
-
end
|
|
57
|
-
end
|
|
65
|
+
expected_cpath(loader, file) || file.delete_prefix(path + File::SEPARATOR).sub(/\.rb\z/, "").camelize
|
|
66
|
+
end
|
|
58
67
|
|
|
59
|
-
|
|
68
|
+
def expected_cpath(loader, file)
|
|
69
|
+
loader.cpath_expected_at(file) if loader.respond_to?(:cpath_expected_at)
|
|
70
|
+
rescue Zeitwerk::Error
|
|
71
|
+
nil
|
|
60
72
|
end
|
|
61
|
-
private_class_method :load_dir, :load_individually, :cpath_for
|
|
73
|
+
private_class_method :load_dir, :load_individually, :cpath_for, :expected_cpath
|
|
62
74
|
end
|
|
63
75
|
end
|
|
64
76
|
end
|
|
@@ -11,11 +11,12 @@ module RailsAiContext
|
|
|
11
11
|
|
|
12
12
|
# Both spellings apps use: `config.i18n.default_locale = :es` in
|
|
13
13
|
# application.rb or an environment file, and a bare
|
|
14
|
-
# `I18n.default_locale = :es` in an initializer
|
|
14
|
+
# `I18n.default_locale = :es` in an initializer, which may also reach
|
|
15
|
+
# config through `Rails.application.config.i18n`.
|
|
15
16
|
# Anchored past the line start so a commented example does not win:
|
|
16
17
|
# GitLab ships `# config.i18n.default_locale = :de` and every coverage
|
|
17
18
|
# line then measured an English app against German.
|
|
18
|
-
DEFAULT_LOCALE_ASSIGNMENT = /^[^\S\n]*(config\.i18n|I18n)\.default_locale\s*=\s*[:"']([\w-]+)/
|
|
19
|
+
DEFAULT_LOCALE_ASSIGNMENT = /^[^\S\n]*((?:(?:::)?Rails\.(?:application\.config|configuration)|config)\.i18n|I18n)\.default_locale\s*=\s*[:"']([\w-]+)/
|
|
19
20
|
|
|
20
21
|
# `config.i18n.available_locales`, and only that: the listener strips the
|
|
21
22
|
# root it matched, so a bare `config.available_locales` is any gem's own
|
|
@@ -102,7 +103,7 @@ module RailsAiContext
|
|
|
102
103
|
end
|
|
103
104
|
|
|
104
105
|
def spelling_of(prefix)
|
|
105
|
-
prefix
|
|
106
|
+
prefix == "I18n" ? :i18n : :config
|
|
106
107
|
end
|
|
107
108
|
|
|
108
109
|
# I18n::Railtie buffers app.config.i18n and applies it from
|
|
@@ -28,7 +28,7 @@ module RailsAiContext
|
|
|
28
28
|
|
|
29
29
|
# @return [Hash] async workers, mailers, and channels
|
|
30
30
|
def call
|
|
31
|
-
jobs =
|
|
31
|
+
jobs = merge_with_source(extract_jobs, extract_jobs_from_source)
|
|
32
32
|
|
|
33
33
|
{
|
|
34
34
|
jobs: jobs,
|
|
@@ -77,9 +77,13 @@ module RailsAiContext
|
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
queue = job.queue_name
|
|
80
|
-
#
|
|
81
|
-
# what it computes.
|
|
82
|
-
|
|
80
|
+
# ActiveJob defaults queue_name to a lambda; any other Proc is a
|
|
81
|
+
# queue_as block, and only the source says what it computes.
|
|
82
|
+
if queue.equal?(ActiveJob::Base.queue_name)
|
|
83
|
+
queue = job.queue_name_from_part(nil)
|
|
84
|
+
elsif queue.is_a?(Proc)
|
|
85
|
+
queue = (job_candidates.key?(job.name) && inherited_queue(job.name)) || COMPUTED_QUEUE
|
|
86
|
+
end
|
|
83
87
|
|
|
84
88
|
{
|
|
85
89
|
name: job.name,
|
|
@@ -196,14 +200,16 @@ module RailsAiContext
|
|
|
196
200
|
}.uniq
|
|
197
201
|
end
|
|
198
202
|
|
|
199
|
-
# Reflection
|
|
200
|
-
#
|
|
201
|
-
|
|
203
|
+
# Reflection answers the queue; the source answers what reflection cannot
|
|
204
|
+
# see: retries and signatures, and the Resque jobs, POROs and jobs in roots
|
|
205
|
+
# Zeitwerk never loads. On a name both answer, reflection wins.
|
|
206
|
+
def merge_with_source(reflected, from_source)
|
|
202
207
|
return from_source if reflected.empty?
|
|
203
208
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
209
|
+
sourced = from_source.index_by { |job| job[:name] }
|
|
210
|
+
merged = reflected.map { |job| (sourced.delete(job[:name]) || {}).except(:unknown_base).merge(job) }
|
|
211
|
+
bases = reflected_bases.map { |base| base[:name] }
|
|
212
|
+
(merged + sourced.values.reject { |job| bases.include?(job[:name]) }).sort_by { |job| job[:name] }
|
|
207
213
|
end
|
|
208
214
|
|
|
209
215
|
# `descendants` is every ActiveJob subclass in the process, and the name
|
|
@@ -491,6 +497,7 @@ module RailsAiContext
|
|
|
491
497
|
# loaded until first delivery. Without this, .descendants is empty
|
|
492
498
|
# and mailers are reported as absent.
|
|
493
499
|
EagerLoad.dir(app.root, kind: "app/mailers")
|
|
500
|
+
load_mailers_outside_mailer_dirs
|
|
494
501
|
|
|
495
502
|
ActionMailer::Base.descendants.filter_map do |mailer|
|
|
496
503
|
# `descendants` is every mailer in the process, and a gem's is not
|
|
@@ -523,6 +530,13 @@ module RailsAiContext
|
|
|
523
530
|
RailsAiContext.debug_fail(e, [], label: "extract_mailers")
|
|
524
531
|
end
|
|
525
532
|
|
|
533
|
+
# A mailer in app/services or app/models loads only when something names it.
|
|
534
|
+
def load_mailers_outside_mailer_dirs
|
|
535
|
+
return if app.config.eager_load
|
|
536
|
+
|
|
537
|
+
EagerLoad.files(mailer_parent_files(SourceScan.paths(app.root, kind: "app/mailers").map(&:file).to_set))
|
|
538
|
+
end
|
|
539
|
+
|
|
526
540
|
# The methods this mailer registers as action callbacks. A block filter
|
|
527
541
|
# has no name to match, so only the symbol ones answer.
|
|
528
542
|
def callback_filters(mailer)
|
|
@@ -24,6 +24,7 @@ module RailsAiContext
|
|
|
24
24
|
type: node.name.to_s,
|
|
25
25
|
name: name,
|
|
26
26
|
computed_name: (true unless literal),
|
|
27
|
+
computed_foreign_key: (true if computed_key?(node)),
|
|
27
28
|
# Sources, not literals: `class_name: Organisation.name` names a
|
|
28
29
|
# class, and the marker names nothing.
|
|
29
30
|
options: scope_options(receiver_name(node)).merge(extract_keyword_sources(node)),
|
|
@@ -31,6 +32,17 @@ module RailsAiContext
|
|
|
31
32
|
confidence: confidence_for(node)
|
|
32
33
|
}.compact
|
|
33
34
|
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# `foreign_key: AUTHOR_KEY` names a column only at run time.
|
|
39
|
+
def computed_key?(node)
|
|
40
|
+
key = extract_keyword_nodes(node)[:foreign_key]
|
|
41
|
+
return false if key.nil?
|
|
42
|
+
|
|
43
|
+
keys = key.is_a?(Prism::ArrayNode) ? key.elements : [ key ]
|
|
44
|
+
keys.empty? || keys.any? { |k| literal_string(k).nil? }
|
|
45
|
+
end
|
|
34
46
|
end
|
|
35
47
|
end
|
|
36
48
|
end
|
|
@@ -6,6 +6,8 @@ module RailsAiContext
|
|
|
6
6
|
# What a replay needs beyond the DSL listeners: down-only ranges (a revert
|
|
7
7
|
# block is one), a block's `t.` statements, and four top-level statements.
|
|
8
8
|
class MigrationReplayListener < BaseListener
|
|
9
|
+
include SchemaDslListener::TableBlock
|
|
10
|
+
|
|
9
11
|
def on_def_node_enter(node)
|
|
10
12
|
return unless node.name == :down
|
|
11
13
|
|
|
@@ -21,11 +23,17 @@ module RailsAiContext
|
|
|
21
23
|
}.freeze
|
|
22
24
|
|
|
23
25
|
def on_call_node_enter(node)
|
|
26
|
+
note_block(node)
|
|
24
27
|
args = node.arguments&.arguments || []
|
|
25
28
|
if %i[down revert].include?(node.name) && node.block
|
|
26
29
|
@results << { kind: :down, range: node.location.start_line..node.location.end_line }
|
|
27
30
|
elsif node.name == :column && block_column?(node.receiver)
|
|
28
31
|
@results << column_definition(node, args)
|
|
32
|
+
elsif TABLE_OPS[node.name] == :remove_reference && block_column?(node.receiver)
|
|
33
|
+
# Table#remove_references drops each name it is given.
|
|
34
|
+
args.reject { |arg| arg.is_a?(Prism::KeywordHashNode) }.each do |arg|
|
|
35
|
+
@results << statement(node, :remove_reference, [ arg, *args.grep(Prism::KeywordHashNode) ]).merge(block: true)
|
|
36
|
+
end
|
|
29
37
|
elsif TABLE_OPS.key?(node.name) && block_column?(node.receiver)
|
|
30
38
|
@results << statement(node, TABLE_OPS[node.name], args).merge(block: true)
|
|
31
39
|
elsif node.name == :create_join_table && (node.receiver.nil? || node.receiver.is_a?(Prism::LocalVariableReadNode))
|
|
@@ -97,7 +105,7 @@ module RailsAiContext
|
|
|
97
105
|
# as a local variable or as a bare call.
|
|
98
106
|
def block_column?(receiver)
|
|
99
107
|
case receiver
|
|
100
|
-
when Prism::LocalVariableReadNode then receiver.name == :t
|
|
108
|
+
when Prism::LocalVariableReadNode then receiver.name == :t && table_param?(receiver)
|
|
101
109
|
when Prism::CallNode then receiver.name == :t && receiver.receiver.nil?
|
|
102
110
|
else false
|
|
103
111
|
end
|
|
@@ -116,9 +116,9 @@ module RailsAiContext
|
|
|
116
116
|
|
|
117
117
|
def prefixed_path(path)
|
|
118
118
|
prefixes = @scopes.filter_map { |scope| scope[:prefix] }
|
|
119
|
-
return nil if prefixes.include?(:unknown)
|
|
120
|
-
return path if prefixes.empty? || path.nil?
|
|
119
|
+
return nil if path.nil? || prefixes.include?(:unknown)
|
|
121
120
|
|
|
121
|
+
# Rails' normalize_path gives every path its leading slash, prefixed or not.
|
|
122
122
|
joined = "#{prefixes.join.chomp("/")}/#{path.to_s.delete_prefix("/")}"
|
|
123
123
|
normalize_route_path(joined == "/" ? joined : joined.chomp("/"))
|
|
124
124
|
end
|
|
@@ -167,8 +167,7 @@ module RailsAiContext
|
|
|
167
167
|
|
|
168
168
|
key = extract_key(assoc.key)
|
|
169
169
|
if key == :at
|
|
170
|
-
|
|
171
|
-
return val.is_a?(String) ? val : nil
|
|
170
|
+
return literal_path(assoc.value)
|
|
172
171
|
end
|
|
173
172
|
end
|
|
174
173
|
end
|
|
@@ -181,14 +180,22 @@ module RailsAiContext
|
|
|
181
180
|
next unless assoc.is_a?(Prism::AssocNode)
|
|
182
181
|
|
|
183
182
|
if app_name(assoc.key)
|
|
184
|
-
|
|
185
|
-
return val.is_a?(String) ? val : nil
|
|
183
|
+
return literal_path(assoc.value)
|
|
186
184
|
end
|
|
187
185
|
end
|
|
188
186
|
end
|
|
189
187
|
|
|
190
188
|
nil
|
|
191
189
|
end
|
|
190
|
+
|
|
191
|
+
# Only a string the source spells out: a constant or a call is a path
|
|
192
|
+
# nothing here can read, and an expression prints no path at all.
|
|
193
|
+
def literal_path(node)
|
|
194
|
+
return nil unless node.is_a?(Prism::StringNode) || node.is_a?(Prism::InterpolatedStringNode)
|
|
195
|
+
|
|
196
|
+
value = extract_value(node)
|
|
197
|
+
value == RailsAiContext::Confidence::INFERRED ? nil : value
|
|
198
|
+
end
|
|
192
199
|
end
|
|
193
200
|
end
|
|
194
201
|
end
|
|
@@ -6,15 +6,51 @@ module RailsAiContext
|
|
|
6
6
|
# Detects schema.rb DSL patterns via Prism AST:
|
|
7
7
|
# create_table, t.string, t.index, add_foreign_key, create_enum
|
|
8
8
|
class SchemaDslListener < BaseListener
|
|
9
|
+
# Whether a `t` receiver is a table: `Tag.find_each { |t| t.update! }` binds
|
|
10
|
+
# `t` to a record. A receiverless block is a table helper's, a def's `t` a helper's argument.
|
|
11
|
+
module TableBlock
|
|
12
|
+
TABLE_BLOCKS = %i[create_table change_table create_join_table].to_set.freeze
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
# The dispatcher enters a block's call before anything inside it.
|
|
17
|
+
def note_block(node)
|
|
18
|
+
params = node.block.is_a?(Prism::BlockNode) && node.block.parameters.is_a?(Prism::BlockParametersNode) &&
|
|
19
|
+
node.block.parameters.parameters
|
|
20
|
+
return unless params
|
|
21
|
+
|
|
22
|
+
names = params.requireds.filter_map { |param| param.name if param.respond_to?(:name) }
|
|
23
|
+
table = node.receiver.nil? || TABLE_BLOCKS.include?(node.name)
|
|
24
|
+
(@blocks ||= []) << [ node.block.location, names, table ]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def table_param?(receiver)
|
|
28
|
+
offset = receiver.location.start_offset
|
|
29
|
+
_, _, table = Array(@blocks).reverse_each.find do |location, names, _|
|
|
30
|
+
names.include?(receiver.name) && offset.between?(location.start_offset, location.end_offset)
|
|
31
|
+
end
|
|
32
|
+
table.nil? || table
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
include TableBlock
|
|
37
|
+
|
|
38
|
+
# Each adapter's ColumnMethods (Rails 7.0 to 8.1), plus neighbor's vector types and PostGIS's.
|
|
9
39
|
COLUMN_TYPES = %w[
|
|
10
|
-
|
|
11
|
-
references belongs_to
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
40
|
+
bigint binary boolean date datetime decimal float integer json string text time timestamp virtual
|
|
41
|
+
primary_key references belongs_to
|
|
42
|
+
bigserial bit bit_varying cidr citext daterange hstore inet interval int4range int8range jsonb ltree
|
|
43
|
+
macaddr money numrange oid point line lseg box path polygon circle serial tsrange tstzrange tsvector
|
|
44
|
+
uuid xml timestamptz enum
|
|
45
|
+
blob tinyblob mediumblob longblob tinytext mediumtext longtext
|
|
46
|
+
unsigned_integer unsigned_bigint unsigned_float unsigned_decimal
|
|
47
|
+
vector halfvec sparsevec cube
|
|
48
|
+
spatial geography geometry geometry_collection line_string multi_line_string multi_point
|
|
49
|
+
multi_polygon st_point st_polygon
|
|
15
50
|
].to_set.freeze
|
|
16
51
|
|
|
17
52
|
def on_call_node_enter(node)
|
|
53
|
+
note_block(node)
|
|
18
54
|
if node.receiver.nil?
|
|
19
55
|
extract_top_level_call(node)
|
|
20
56
|
elsif column_call?(node)
|
|
@@ -23,11 +59,28 @@ module RailsAiContext
|
|
|
23
59
|
extract_index(node)
|
|
24
60
|
elsif check_constraint_call?(node)
|
|
25
61
|
extract_check_constraint(node)
|
|
62
|
+
elsif receiver_is_t?(node.receiver) && !read_elsewhere?(node.name)
|
|
63
|
+
unread_call(node)
|
|
26
64
|
end
|
|
27
65
|
end
|
|
28
66
|
|
|
67
|
+
# Block methods the replay reads, or that never add an index.
|
|
68
|
+
OTHER_TABLE_METHODS = %i[
|
|
69
|
+
column foreign_key remove_foreign_key remove_check_constraint rename_index
|
|
70
|
+
column_exists? index_exists? foreign_key_exists? check_constraint_exists?
|
|
71
|
+
].to_set.freeze
|
|
72
|
+
|
|
29
73
|
private
|
|
30
74
|
|
|
75
|
+
def read_elsewhere?(name)
|
|
76
|
+
OTHER_TABLE_METHODS.include?(name) || MigrationReplayListener::TABLE_OPS.key?(name)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A block call no reader interprets may add an index or a column.
|
|
80
|
+
def unread_call(node)
|
|
81
|
+
@results << { type: :unread_call, name: node.name.to_s, location: node.location.start_line }
|
|
82
|
+
end
|
|
83
|
+
|
|
31
84
|
def extract_top_level_call(node)
|
|
32
85
|
case node.name
|
|
33
86
|
when :create_table
|
|
@@ -72,8 +125,8 @@ module RailsAiContext
|
|
|
72
125
|
to: to_arg.unescaped,
|
|
73
126
|
# Absent means the Rails convention holds; naming it here would
|
|
74
127
|
# make a declared column indistinguishable from a guessed one.
|
|
75
|
-
column: options[:column]
|
|
76
|
-
primary_key: options[:primary_key]
|
|
128
|
+
column: SchemaConventions.primary_key_value(options[:column]),
|
|
129
|
+
primary_key: SchemaConventions.primary_key_value(options[:primary_key]),
|
|
77
130
|
location: node.location.start_line
|
|
78
131
|
}.compact
|
|
79
132
|
end
|
|
@@ -161,33 +214,36 @@ module RailsAiContext
|
|
|
161
214
|
when Prism::CallNode
|
|
162
215
|
receiver.name == :t && receiver.receiver.nil?
|
|
163
216
|
when Prism::LocalVariableReadNode
|
|
164
|
-
receiver.name == :t
|
|
217
|
+
receiver.name == :t && table_param?(receiver)
|
|
165
218
|
else
|
|
166
219
|
false
|
|
167
220
|
end
|
|
168
221
|
end
|
|
169
222
|
|
|
223
|
+
# `t.references :a, :b` defines each name; primary_key's second argument is its type.
|
|
170
224
|
def extract_column(node)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
options = extract_keyword_options(node)
|
|
225
|
+
# `t.string "name", { limit: 50 }` passes its options braced.
|
|
226
|
+
braced, positional = (node.arguments&.arguments || []).reject { |arg| arg.is_a?(Prism::KeywordHashNode) }
|
|
227
|
+
.partition { |arg| arg.is_a?(Prism::HashNode) }
|
|
228
|
+
positional = positional.first(1) if node.name == :primary_key
|
|
229
|
+
names = positional.map { |arg| literal_string(arg) }
|
|
230
|
+
return unread_call(node) if names.empty? || names.any?(&:nil?)
|
|
178
231
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
232
|
+
options = braced.map { |hash| hash_node_to_hash(hash) }.reduce(extract_keyword_options(node), :merge)
|
|
233
|
+
names.each do |col_name|
|
|
234
|
+
@results << {
|
|
235
|
+
type: :column,
|
|
236
|
+
table: nil,
|
|
237
|
+
column_type: node.name.to_s,
|
|
238
|
+
name: col_name,
|
|
239
|
+
options: options,
|
|
240
|
+
# A proc default (`default: -> { "now()" }`) has no literal value,
|
|
241
|
+
# so keep its source for callers that report defaults verbatim.
|
|
242
|
+
default_source: default_source(node),
|
|
243
|
+
default_proc: proc_default?(node),
|
|
244
|
+
location: node.location.start_line
|
|
245
|
+
}
|
|
246
|
+
end
|
|
191
247
|
end
|
|
192
248
|
|
|
193
249
|
def extract_index(node)
|