rails-ai-context 5.30.1 → 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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +122 -0
  3. data/docs/CLI.md +1 -1
  4. data/docs/COMPATIBILITY.md +3 -1
  5. data/docs/CONFIGURATION.md +2 -2
  6. data/docs/FAQ.md +1 -1
  7. data/docs/GUIDE.md +1 -1
  8. data/exe/rails-ai-context +3 -4
  9. data/lib/rails_ai_context/action_filters.rb +69 -6
  10. data/lib/rails_ai_context/boot_manager.rb +21 -5
  11. data/lib/rails_ai_context/cli/entry_boot.rb +3 -2
  12. data/lib/rails_ai_context/cli/tool_runner.rb +22 -3
  13. data/lib/rails_ai_context/concern_paths.rb +2 -1
  14. data/lib/rails_ai_context/configuration.rb +6 -6
  15. data/lib/rails_ai_context/erb_source.rb +22 -3
  16. data/lib/rails_ai_context/introspector.rb +13 -2
  17. data/lib/rails_ai_context/introspectors/controller_introspector.rb +43 -16
  18. data/lib/rails_ai_context/introspectors/eager_load.rb +22 -10
  19. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +4 -3
  20. data/lib/rails_ai_context/introspectors/job_introspector.rb +24 -10
  21. data/lib/rails_ai_context/introspectors/listeners/associations_listener.rb +12 -0
  22. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +9 -1
  23. data/lib/rails_ai_context/introspectors/listeners/mount_listener.rb +13 -6
  24. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +81 -25
  25. data/lib/rails_ai_context/introspectors/listeners/with_options_scope.rb +49 -12
  26. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +15 -0
  27. data/lib/rails_ai_context/introspectors/migration_replay.rb +2 -0
  28. data/lib/rails_ai_context/introspectors/model_introspector.rb +14 -3
  29. data/lib/rails_ai_context/introspectors/route_introspector.rb +62 -4
  30. data/lib/rails_ai_context/introspectors/schema_conventions.rb +8 -0
  31. data/lib/rails_ai_context/introspectors/schema_introspector.rb +7 -4
  32. data/lib/rails_ai_context/introspectors/schema_reader.rb +2 -0
  33. data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +5 -4
  34. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +18 -16
  35. data/lib/rails_ai_context/migration_status.rb +6 -1
  36. data/lib/rails_ai_context/pending_migrations.rb +3 -2
  37. data/lib/rails_ai_context/portable_path.rb +14 -0
  38. data/lib/rails_ai_context/schema_adapter.rb +6 -1
  39. data/lib/rails_ai_context/serializers/context_file_serializer.rb +2 -2
  40. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +5 -0
  41. data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -1
  42. data/lib/rails_ai_context/tools/base_tool.rb +9 -8
  43. data/lib/rails_ai_context/tools/diagnose.rb +4 -0
  44. data/lib/rails_ai_context/tools/generate_test.rb +19 -0
  45. data/lib/rails_ai_context/tools/get_model_details.rb +1 -1
  46. data/lib/rails_ai_context/tools/get_partial_interface.rb +18 -4
  47. data/lib/rails_ai_context/tools/get_routes.rb +19 -9
  48. data/lib/rails_ai_context/tools/get_schema.rb +22 -4
  49. data/lib/rails_ai_context/tools/get_service_pattern.rb +44 -25
  50. data/lib/rails_ai_context/tools/get_test_info.rb +8 -0
  51. data/lib/rails_ai_context/tools/query.rb +3 -5
  52. data/lib/rails_ai_context/tools/runtime_info.rb +5 -3
  53. data/lib/rails_ai_context/tools/validate_semantics.rb +49 -13
  54. data/lib/rails_ai_context/version.rb +1 -1
  55. data/server.json +1 -1
  56. metadata +1 -1
@@ -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|
@@ -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
@@ -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)
@@ -7,16 +7,53 @@ module RailsAiContext
7
7
  # this tracks the open blocks for `in_scope?` and `scope_options`.
8
8
  module WithOptionsScope
9
9
  UNNAMED_BLOCK_PARAM = :"*"
10
+ UNCERTAIN_BLOCK = Object.new.freeze
11
+ # Zero-arity blocks these run on the class itself, state_machines' and aasm's included;
12
+ # any other such block may be evaluated on another object (`has_details_table do`, `Menu.define do`).
13
+ OWN_BLOCKS = %i[with_options included prepended class_methods concerning state_machine state event aasm].to_set.freeze
14
+ EVALS = %i[class_eval class_exec module_eval module_exec instance_eval instance_exec].to_set.freeze
10
15
 
11
16
  def self.included(base)
12
17
  base.prepend(Dispatch)
13
18
  end
14
19
 
20
+ # Whether a block's declarations may belong to another object: it takes no parameters and
21
+ # its call is neither a known one nor an eval on the class. The block answers whether a
22
+ # local names an open mixin hook's class.
23
+ def self.uncertain_block?(node)
24
+ block = node.block
25
+ return false unless block.is_a?(Prism::BlockNode) && block.parameters.nil?
26
+ return !(OWN_BLOCKS.include?(node.name) || EVALS.include?(node.name)) if node.receiver.nil?
27
+
28
+ receiver = node.receiver
29
+ if EVALS.include?(node.name)
30
+ own = receiver.is_a?(Prism::SelfNode) || (receiver.is_a?(Prism::LocalVariableReadNode) && block_given? && yield(receiver.name))
31
+ return !own
32
+ end
33
+
34
+ # `Menu.define do`, `Class.new do`: a constant's DSL block; `[1].each {}` yields.
35
+ receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
36
+ end
37
+
38
+ # `base` in `def self.included(base)`: the class every includer is.
39
+ def self.hook_param(node)
40
+ return unless node.receiver.is_a?(Prism::SelfNode) && ConcernMembership::MIXIN_HOOKS.include?(node.name.to_s)
41
+
42
+ first = node.parameters&.requireds&.first
43
+ first.name if first.respond_to?(:name)
44
+ end
45
+
15
46
  # Prepended, so the listener's own handler never sees the call that
16
47
  # opened the scope and no listener repeats the guard.
17
48
  module Dispatch
18
49
  def on_call_node_enter(node)
19
- with_options_block?(node) ? enter_scope(node) : super
50
+ return enter_scope(node) if with_options_block?(node)
51
+
52
+ count = @results.size
53
+ super
54
+ # Kept, and marked so a schema check does not judge them against this class's table.
55
+ @results.drop(count).each { |result| result[:scope_uncertain] = true } if uncertain_scope?
56
+ @scopes.push({ node: node, param: UNCERTAIN_BLOCK, options: {} }) if uncertain_block?(node)
20
57
  end
21
58
 
22
59
  # `super` where the listener has its own leave hook: swallowing it
@@ -29,7 +66,8 @@ module RailsAiContext
29
66
  # `base.has_many` inside `def self.included(base)` declares on every includer,
30
67
  # as a `with_options` block's parameter does.
31
68
  def on_def_node_enter(node)
32
- @scopes.push({ node: node, param: hook_param(node), options: {} }) if mixin_hook?(node)
69
+ param = WithOptionsScope.hook_param(node)
70
+ @scopes.push({ node: node, param: param, options: {} }) if param
33
71
  super if defined?(super)
34
72
  end
35
73
 
@@ -46,15 +84,6 @@ module RailsAiContext
46
84
 
47
85
  private
48
86
 
49
- def mixin_hook?(node)
50
- node.receiver.is_a?(Prism::SelfNode) && ConcernMembership::MIXIN_HOOKS.include?(node.name.to_s) && hook_param(node)
51
- end
52
-
53
- def hook_param(node)
54
- first = node.parameters&.requireds&.first
55
- first.name if first.respond_to?(:name)
56
- end
57
-
58
87
  def with_options_block?(node)
59
88
  node.name == :with_options && node.block
60
89
  end
@@ -66,7 +95,15 @@ module RailsAiContext
66
95
  end
67
96
 
68
97
  def leave_scope(node)
69
- @scopes.pop if with_options_block?(node) && @scopes.last&.fetch(:node)&.equal?(node)
98
+ @scopes.pop if @scopes.last&.fetch(:node)&.equal?(node)
99
+ end
100
+
101
+ def uncertain_block?(node)
102
+ WithOptionsScope.uncertain_block?(node) { |name| enclosing_scope(name) }
103
+ end
104
+
105
+ def uncertain_scope?
106
+ @scopes.any? { |scope| scope[:param].equal?(UNCERTAIN_BLOCK) }
70
107
  end
71
108
 
72
109
  # A macro this listener should read: receiverless, or sent to the
@@ -262,12 +262,27 @@ module RailsAiContext
262
262
  tables[table][:columns].reject! { |c| c[:name] == col[:name] }
263
263
  tables[table][:columns] << col
264
264
  end
265
+ add_reference_foreign_key(tables[table], table, name, options || {})
265
266
  return unless ref[:index]
266
267
 
267
268
  tables[table][:indexes].reject! { |idx| idx[:name] == ref[:index][:name] }
268
269
  tables[table][:indexes] << ref[:index]
269
270
  end
270
271
 
272
+ # ReferenceDefinition's foreign_key: (7.0 to 8.1): true points at the
273
+ # pluralized name, a hash may name to_table: and primary_key:.
274
+ def add_reference_foreign_key(table_data, table, name, options)
275
+ fk = options[:foreign_key]
276
+ return if !fk || options[:polymorphic]
277
+
278
+ fk = {} unless fk.is_a?(Hash)
279
+ to = fk[:to_table] || name.to_s.pluralize
280
+ return if to == RailsAiContext::Confidence::INFERRED
281
+
282
+ (table_data[:foreign_keys] ||= []) <<
283
+ SchemaConventions.foreign_key_entry(table, to.to_s, SchemaConventions.reference_column_name(name), fk[:primary_key])
284
+ end
285
+
271
286
  def apply_schema_index(entry, current_table, tables)
272
287
  return unless tables[current_table]
273
288
  cols = entry[:columns]&.map(&:to_s) || []
@@ -211,6 +211,8 @@ module RailsAiContext
211
211
  Statements.apply_schema_column(entry, current_table, tables, pk_type)
212
212
  elsif entry[:type] == :index && current_table
213
213
  Statements.apply_schema_index(entry, current_table, tables)
214
+ elsif entry[:type] == :unread_call && current_table
215
+ SchemaConventions.note_unread_call(tables[current_table], entry[:name])
214
216
  end
215
217
  end
216
218
  end