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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +159 -0
  3. data/docs/ARCHITECTURE.md +5 -1
  4. data/docs/CLI.md +1 -1
  5. data/docs/COMPATIBILITY.md +7 -1
  6. data/docs/CONFIGURATION.md +2 -2
  7. data/docs/FAQ.md +1 -1
  8. data/docs/GUIDE.md +1 -1
  9. data/exe/rails-ai-context +3 -4
  10. data/lib/rails_ai_context/action_filters.rb +69 -6
  11. data/lib/rails_ai_context/boot_manager.rb +21 -5
  12. data/lib/rails_ai_context/cli/entry_boot.rb +3 -2
  13. data/lib/rails_ai_context/cli/tool_runner.rb +22 -3
  14. data/lib/rails_ai_context/concern_paths.rb +2 -1
  15. data/lib/rails_ai_context/configuration.rb +6 -6
  16. data/lib/rails_ai_context/erb_source.rb +22 -3
  17. data/lib/rails_ai_context/introspector.rb +13 -2
  18. data/lib/rails_ai_context/introspectors/controller_introspector.rb +43 -16
  19. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +10 -15
  20. data/lib/rails_ai_context/introspectors/eager_load.rb +22 -10
  21. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +4 -3
  22. data/lib/rails_ai_context/introspectors/job_introspector.rb +24 -10
  23. data/lib/rails_ai_context/introspectors/listeners/associations_listener.rb +12 -0
  24. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +9 -1
  25. data/lib/rails_ai_context/introspectors/listeners/mount_listener.rb +13 -6
  26. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +83 -27
  27. data/lib/rails_ai_context/introspectors/listeners/with_options_scope.rb +49 -12
  28. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +15 -0
  29. data/lib/rails_ai_context/introspectors/migration_replay.rb +2 -0
  30. data/lib/rails_ai_context/introspectors/model_introspector.rb +14 -3
  31. data/lib/rails_ai_context/introspectors/pg_partitions.rb +73 -0
  32. data/lib/rails_ai_context/introspectors/route_introspector.rb +62 -4
  33. data/lib/rails_ai_context/introspectors/schema_conventions.rb +17 -2
  34. data/lib/rails_ai_context/introspectors/schema_introspector.rb +13 -7
  35. data/lib/rails_ai_context/introspectors/schema_reader.rb +28 -2
  36. data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +8 -4
  37. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +18 -16
  38. data/lib/rails_ai_context/migration_status.rb +6 -1
  39. data/lib/rails_ai_context/pending_migrations.rb +3 -2
  40. data/lib/rails_ai_context/portable_path.rb +14 -0
  41. data/lib/rails_ai_context/schema_adapter.rb +6 -1
  42. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
  43. data/lib/rails_ai_context/serializers/context_file_serializer.rb +2 -2
  44. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +5 -0
  45. data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -1
  46. data/lib/rails_ai_context/tools/base_tool.rb +9 -8
  47. data/lib/rails_ai_context/tools/diagnose.rb +4 -0
  48. data/lib/rails_ai_context/tools/generate_test.rb +19 -0
  49. data/lib/rails_ai_context/tools/get_model_details.rb +1 -1
  50. data/lib/rails_ai_context/tools/get_partial_interface.rb +18 -4
  51. data/lib/rails_ai_context/tools/get_routes.rb +19 -9
  52. data/lib/rails_ai_context/tools/get_schema.rb +24 -5
  53. data/lib/rails_ai_context/tools/get_service_pattern.rb +44 -25
  54. data/lib/rails_ai_context/tools/get_test_info.rb +8 -0
  55. data/lib/rails_ai_context/tools/migration_advisor.rb +1 -1
  56. data/lib/rails_ai_context/tools/query.rb +8 -7
  57. data/lib/rails_ai_context/tools/runtime_info.rb +13 -10
  58. data/lib/rails_ai_context/tools/validate_semantics.rb +56 -14
  59. data/lib/rails_ai_context/version.rb +1 -1
  60. data/server.json +1 -1
  61. 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.message }
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.message }
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.message }
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
- extract_filters_from_source(src).each do |sf|
353
- next if sf[:skipped]
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
- def merge_own_source(filters, source)
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 = extract_filters_from_source(source)
378
- declared = own.reject { |f| f[:skipped] }.map { |f| f[:name] }.to_set
379
- by_name = filters.group_by { |f| f[:name] }
380
- declared.each { |name| Array(by_name[name]).each { |f| f[:declared] = true } }
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
- # Collects approximate row counts from PostgreSQL's pg_stat_user_tables.
6
- # Only activates for PostgreSQL adapter; returns { skipped: true } otherwise.
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 /postgresql/
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
- rows = ActiveRecord::Base.connection.select_all(<<~SQL)
36
- SELECT relname AS table_name,
37
- n_live_tup AS approximate_row_count,
38
- n_dead_tup AS dead_rows
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
- if loader.respond_to?(:cpath_expected_at)
51
- begin
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
- file.delete_prefix(path + File::SEPARATOR).sub(/\.rb\z/, "").camelize
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.start_with?("config") ? :config : :i18n
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 = merge_unplaced(extract_jobs, extract_jobs_from_source)
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
- # Reflection holds the Proc and nothing about it; the source says
81
- # what it computes.
82
- queue = (job_candidates.key?(job.name) && inherited_queue(job.name)) || COMPUTED_QUEUE if queue.is_a?(Proc)
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 has no Resque job or PORO to walk: neither is an ActiveJob
200
- # descendant. A name reflection did answer wins.
201
- def merge_unplaced(reflected, from_source)
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
- known = reflected.map { |job| job[:name] }
205
- unplaced = from_source.select { |job| job[:unknown_base] && !known.include?(job[:name]) }
206
- (reflected + unplaced).sort_by { |job| job[:name] }
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
- val = extract_value(assoc.value)
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
- val = extract_value(assoc.value)
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
- string integer text boolean datetime date decimal float binary
11
- references belongs_to jsonb json uuid bigint timestamp timestamptz time
12
- inet cidr macaddr hstore ltree numrange tsrange daterange
13
- bit bit_varying money oid xml point line lseg box path
14
- polygon circle interval serial tsvector virtual primary_key
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]&.to_s,
76
- primary_key: options[:primary_key]&.to_s,
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
- args = node.arguments&.arguments || []
172
- name_arg = args.first
173
- return unless name_arg.is_a?(Prism::StringNode) || name_arg.is_a?(Prism::SymbolNode)
174
-
175
- col_name = literal_string(name_arg)
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
- @results << {
180
- type: :column,
181
- table: nil,
182
- column_type: node.name.to_s,
183
- name: col_name,
184
- options: options,
185
- # A proc default (`default: -> { "now()" }`) has no literal value,
186
- # so keep its source for callers that report defaults verbatim.
187
- default_source: default_source(node),
188
- default_proc: proc_default?(node),
189
- location: node.location.start_line
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)