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
@@ -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
@@ -844,7 +844,8 @@ module RailsAiContext
844
844
  type: assoc.macro.to_s,
845
845
  # A leading `::` stays: it means top level to the class resolver.
846
846
  class_name: assoc.class_name.to_s,
847
- foreign_key: assoc.foreign_key.to_s
847
+ # Rails 7.1+ answers an Array for a composite key.
848
+ foreign_key: assoc.foreign_key.is_a?(Array) ? assoc.foreign_key.map(&:to_s) : assoc.foreign_key.to_s
848
849
  }
849
850
  detail[:through] = assoc.options[:through].to_s if assoc.options[:through]
850
851
  detail[:source_type] = assoc.options[:source_type].to_s if assoc.options[:source_type]
@@ -1292,9 +1293,9 @@ module RailsAiContext
1292
1293
 
1293
1294
  shaped = assoc.merge(assoc[:name] ? { name: assoc[:name].to_s } : {})
1294
1295
  options = assoc[:options]
1295
- return shaped unless options.is_a?(Hash)
1296
+ return with_default_foreign_key(shaped) unless options.is_a?(Hash)
1296
1297
 
1297
- LIFTED_ASSOCIATION_OPTIONS.each_with_object(shaped) do |key, acc|
1298
+ lifted = LIFTED_ASSOCIATION_OPTIONS.each_with_object(shaped) do |key, acc|
1298
1299
  next unless options.key?(key) && !acc.key?(key)
1299
1300
 
1300
1301
  value = options[key]
@@ -1303,9 +1304,19 @@ module RailsAiContext
1303
1304
  next if value.nil?
1304
1305
 
1305
1306
  acc[key] = if BOOLEAN_ASSOCIATION_OPTIONS.include?(key) then value
1307
+ elsif value.is_a?(Array) then value.map(&:to_s)
1306
1308
  else value.to_s
1307
1309
  end
1308
1310
  end
1311
+ with_default_foreign_key(lifted)
1312
+ end
1313
+
1314
+ # Reflection answers a belongs_to's key when none is declared: the name plus _id.
1315
+ def with_default_foreign_key(assoc)
1316
+ return assoc unless assoc[:type] == "belongs_to" && !assoc.key?(:foreign_key)
1317
+ return assoc if assoc[:computed_name] || assoc[:name].to_s.empty?
1318
+
1319
+ assoc.merge(foreign_key: SchemaConventions.reference_column_name(assoc[:name]))
1309
1320
  end
1310
1321
 
1311
1322
  # A value that survives as itself: `in: %w[draft sent]` reached
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Introspectors
5
+ # A partition repeats its parent's columns, indexes and keys, so it is listed
6
+ # as its partitioned parent, and its rows, bytes and index scans count there.
7
+ module PgPartitions
8
+ module_function
9
+
10
+ # activerecord-postgis-adapter reports "PostGIS" for what is a PostgreSQL connection.
11
+ POSTGRES_ADAPTER = /postg/i
12
+
13
+ # Scoped the way connection.tables is.
14
+ NAMES_SQL = <<~SQL.squish.freeze
15
+ SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
16
+ WHERE c.relispartition AND c.relkind IN ('r', 'p') AND n.nspname = ANY (current_schemas(false))
17
+ SQL
18
+
19
+ def names(conn)
20
+ # relispartition arrived with partitions in 10; querying it before then
21
+ # fails, and a failed query aborts any open transaction.
22
+ return [] unless conn.adapter_name.match?(POSTGRES_ADAPTER) && conn.database_version >= 10_00_00
23
+
24
+ conn.select_values(NAMES_SQL)
25
+ rescue => e
26
+ RailsAiContext.debug_fail(e, [], label: "PgPartitions.names")
27
+ end
28
+
29
+ # @return [Array<Hash>] { name:, rows:, dead_rows: } per table, from the statistics alone
30
+ def table_rows(conn)
31
+ per_table(conn, "SUM(s.n_live_tup) AS live_rows, SUM(s.n_dead_tup) AS dead_rows").map do |r|
32
+ { name: r["name"], rows: r["live_rows"].to_i, dead_rows: r["dead_rows"].to_i }
33
+ end
34
+ end
35
+
36
+ # @return [Array<Hash>] { name:, bytes: } per table. Sizing a relation waits on its lock.
37
+ def table_bytes(conn)
38
+ per_table(conn, "SUM(pg_total_relation_size(s.relid)) AS bytes").map do |r|
39
+ { name: r["name"], bytes: r["bytes"].to_i }
40
+ end
41
+ end
42
+
43
+ # @return [Array<Hash>] { table:, index:, scans: } per index, from pg_stat_user_indexes
44
+ def index_stats(conn)
45
+ conn.select_all(<<~SQL).map do |r|
46
+ SELECT t.relname AS table_name, i.relname AS index_name, SUM(s.idx_scan) AS scans
47
+ FROM pg_stat_user_indexes s
48
+ JOIN pg_class t ON t.oid = #{root_of(conn, "s.relid")}
49
+ JOIN pg_class i ON i.oid = #{root_of(conn, "s.indexrelid")}
50
+ GROUP BY t.oid, t.relname, i.oid, i.relname
51
+ SQL
52
+ { table: r["table_name"], index: r["index_name"], scans: r["scans"].to_i }
53
+ end
54
+ end
55
+
56
+ def per_table(conn, sums)
57
+ conn.select_all(<<~SQL)
58
+ SELECT c.relname AS name, #{sums}
59
+ FROM pg_stat_user_tables s
60
+ JOIN pg_class c ON c.oid = #{root_of(conn, "s.relid")}
61
+ GROUP BY c.oid, c.relname
62
+ SQL
63
+ end
64
+ private_class_method :per_table
65
+
66
+ def root_of(conn, relid)
67
+ # ponytail: pg_partition_root needs PostgreSQL 12, so on 10 and 11 each partition stays its own row.
68
+ conn.database_version >= 12_00_00 ? "COALESCE(pg_partition_root(#{relid}), #{relid})" : relid
69
+ end
70
+ private_class_method :root_of
71
+ end
72
+ end
73
+ end
@@ -53,6 +53,9 @@ module RailsAiContext
53
53
  return { error: "config/routes.rb not found in #{app.root}" } if top_files.empty?
54
54
 
55
55
  records, mounts, files = walk_route_files(top_files)
56
+ in_repo = in_repo_routes(mounts)
57
+ records += in_repo.values.flat_map(&:first)
58
+ mounts = (mounts + in_repo.values.flat_map(&:last)).uniq { |mount| [ mount[:engine], mount[:path] ] }
56
59
  # What an app draws into an engine's table is the engine's, which the
57
60
  # booted tier's Rails.application.routes holds only as the mount.
58
61
  app_records, engine_records = records.partition { |r| r[:engine].nil? }
@@ -79,21 +82,51 @@ module RailsAiContext
79
82
  confidence: Confidence::STATIC
80
83
  }
81
84
  result[:dynamic_routes] = dynamic if dynamic.positive?
82
- engine_routes = engine_route_groups(engine_records, mounts)
85
+ engine_routes = engine_route_groups(engine_records, mounts) + unread_engine_tables(engine_records, mounts)
83
86
  result[:engine_routes] = engine_routes if engine_routes.any?
84
- unread = in_repo_route_files.size
87
+ unread = (in_repo_route_files - in_repo.keys).size
85
88
  result[:in_repo_route_files] = unread if unread.positive?
86
89
  result
87
90
  end
88
91
 
89
- # An in-repo engine's routes.rb draws into its own table, which only boot can place, so
90
- # the count says those routes were skipped instead of reading as the whole total.
92
+ # Every routes.rb under an in-repo engine or plugin root. One the app does not mount has
93
+ # no place in the static table, so the count says it was skipped.
91
94
  def in_repo_route_files
92
95
  PathResolver.code_roots(app.root.to_s)
93
96
  .map { |dir| File.join(dir, "config", "routes.rb") }
94
97
  .select { |path| File.exist?(path) }
95
98
  end
96
99
 
100
+ # Rails loads every engine's routes.rb. One that appends to the app's own
101
+ # table adds app routes; one that draws into an engine the app mounts is
102
+ # placed under the mount.
103
+ # @return [Hash] path => [records, mounts], for files read into the app's table
104
+ def in_repo_routes(mounts)
105
+ walked = in_repo_route_files.to_h { |path| [ path, walk_routes_file(path) ] }
106
+ app_files = walked.keys.select { |path| app_route_file?(path) }.to_set
107
+ mounted = (mounts + app_files.flat_map { |path| walked[path][1] }).map { |m| m[:engine] }.to_set
108
+ walked.each_with_object({}) do |(path, (records, file_mounts, _files)), found|
109
+ app = app_files.include?(path)
110
+ kept = records.select { |r| r[:engine] ? mounted.include?(r[:engine]) : app }
111
+ file_mounts = app ? file_mounts : []
112
+ # A file that draws nothing (all commented out) has nothing left unread.
113
+ found[path] = [ kept, file_mounts ] if kept.any? || file_mounts.any? || records.empty?
114
+ end
115
+ end
116
+
117
+ # `Rails.application.routes` or `X::Application.routes`, drawn, appended or prepended.
118
+ def app_route_file?(path)
119
+ AstWalk.each(AstCache.parse_string(SafeFile.read(path).to_s).value).any? do |node|
120
+ next false unless node.is_a?(Prism::CallNode) && %i[draw append prepend].include?(node.name) && node.block
121
+
122
+ routes = node.receiver
123
+ routes.is_a?(Prism::CallNode) && routes.name == :routes &&
124
+ routes.receiver&.slice.to_s.delete_prefix("::").match?(/\A(Rails\.application|(\w+::)*Application)\z/)
125
+ end
126
+ rescue StandardError, ScriptError => e
127
+ RailsAiContext.debug_fail(e, false, label: "app_route_file? #{path}")
128
+ end
129
+
97
130
  # What config/routes.rb and every file it draws mount, from source. The
98
131
  # engines section reads this rather than walking config/routes.rb on its
99
132
  # own, so the two sections name one set of mounted apps.
@@ -126,11 +159,36 @@ module RailsAiContext
126
159
  ).compact
127
160
  end),
128
161
  dynamic_routes: dynamic.positive? ? dynamic : nil,
162
+ mount_computed: (true if mount_record && mount.nil?),
129
163
  also_mounted_at: mount_records.drop(1).map { |m| m[:path] }.presence
130
164
  }.compact
131
165
  end
132
166
  end
133
167
 
168
+ # An engine the app draws nothing into still has its own table, drawn in
169
+ # its own code, which only boot reads. A mounted Rack app has no table at all.
170
+ def unread_engine_tables(engine_records, mounts)
171
+ drawn = engine_records.map { |r| r[:engine] }.to_set
172
+ roots = PathResolver.autoload_roots(app.root.to_s) + PathResolver.path_gem_libs(app.root.to_s)
173
+ mounts.reject { |m| drawn.include?(m[:engine]) }
174
+ .group_by { |m| m[:engine] }
175
+ .select { |engine, _| engine_constant?(engine, roots) }
176
+ .map do |engine, same|
177
+ { engine: engine, mount: same.first[:path], mount_computed: (true unless same.first[:path]), routes: [],
178
+ also_mounted_at: same.drop(1).map { |m| m[:path] }.presence,
179
+ unavailable: "the engine's own routes are read only with the app booted" }.compact
180
+ end
181
+ end
182
+
183
+ # The app's own definition says whether it subclasses Rails::Engine; a
184
+ # gem's constant is read nowhere here, so its name is all there is.
185
+ def engine_constant?(name, roots)
186
+ path = PathResolver.file_for_constant(app.root.to_s, name, roots: roots)
187
+ return name.to_s.match?(/(?:\A|::)Engine\z/) unless path
188
+
189
+ SafeFile.read(path).to_s.match?(/<\s*(?:::)?Rails::Engine\b/)
190
+ end
191
+
134
192
  # Rails names the proxy after the mount: its `as:`, else the engine's
135
193
  # engine_name, under the names of the scopes around it.
136
194
  def route_proxy(engine, mount)
@@ -32,8 +32,8 @@ module RailsAiContext
32
32
  def foreign_key_entry(from, to, column, primary_key)
33
33
  {
34
34
  from_table: from, to_table: to,
35
- column: column&.to_s || "#{to.to_s.singularize}_id",
36
- primary_key: primary_key&.to_s || "id"
35
+ column: primary_key_value(column) || "#{to.to_s.singularize}_id",
36
+ primary_key: primary_key_value(primary_key) || "id"
37
37
  }
38
38
  end
39
39
 
@@ -63,6 +63,21 @@ module RailsAiContext
63
63
  (key ? leading << key.to_s : leading).to_set
64
64
  end
65
65
 
66
+ # A table whose block called what no reader interprets may have indexes
67
+ # the static schema lacks.
68
+ def note_unread_call(table, name)
69
+ return unless table
70
+
71
+ table[:unread_calls] = Array(table[:unread_calls]) | [ name.to_s ]
72
+ end
73
+
74
+ # A key as a reader writes it beside another key: `id`, or `(id, day)`, so
75
+ # the two sides of a foreign key stay apart.
76
+ def key_text(value)
77
+ columns = Array(value).map(&:to_s)
78
+ columns.size > 1 ? "(#{columns.join(", ")})" : columns.first.to_s
79
+ end
80
+
66
81
  # A partial index's condition as the dump wrote it, for the end of a line.
67
82
  def where_clause(where)
68
83
  where ? " where #{where}" : ""
@@ -12,6 +12,7 @@ module RailsAiContext
12
12
  def call
13
13
  return attach_secondary_databases(static_schema_parse) unless active_record_connected?
14
14
 
15
+ @partitions = PgPartitions.names(connection)
15
16
  if table_names.empty?
16
17
  # Connected but not migrated: the files answer, and the notes say so.
17
18
  @connection_state = "connected, no tables yet"
@@ -30,8 +31,10 @@ module RailsAiContext
30
31
  declared_tables: declared_table_names,
31
32
  check_constraints: check_constraints,
32
33
  enum_types: enum_types,
33
- generated_columns: generated_columns(schema_reader)
34
- })
34
+ generated_columns: generated_columns(schema_reader),
35
+ # What names the migration behind a declared table the connection lacks.
36
+ pending_migrations: RailsAiContext::PendingMigrations.live(RailsAiContext::PendingMigrations.migrate_dir_for(app.root))
37
+ }.compact)
35
38
  end
36
39
 
37
40
  # Static tier entry: skip the connection probe entirely and answer from
@@ -77,7 +80,8 @@ module RailsAiContext
77
80
  end
78
81
 
79
82
  def table_names
80
- @table_names ||= connection.tables.reject { |t| t.start_with?("ar_internal_metadata", "schema_migrations") }
83
+ @table_names ||= (connection.tables - @partitions.to_a)
84
+ .reject { |t| t.start_with?("ar_internal_metadata", "schema_migrations") }
81
85
  end
82
86
 
83
87
  def extract_tables
@@ -135,7 +139,8 @@ module RailsAiContext
135
139
  end
136
140
 
137
141
  def extract_foreign_keys(table)
138
- connection.foreign_keys(table).map do |fk|
142
+ # PostgreSQL clones a key that references a partitioned table once per partition.
143
+ connection.foreign_keys(table).reject { |fk| @partitions.to_a.include?(fk.to_table) }.map do |fk|
139
144
  {
140
145
  from_table: fk.from_table,
141
146
  to_table: fk.to_table,
@@ -158,7 +163,7 @@ module RailsAiContext
158
163
  end
159
164
 
160
165
  def schema_reader
161
- @schema_reader ||= SchemaReader.new(schema_file_path)
166
+ @schema_reader ||= SchemaReader.new(schema_file_path, partitions: @partitions.to_a)
162
167
  end
163
168
 
164
169
  # Constraints and enum types are declared in the dump, not reported by
@@ -352,8 +357,9 @@ module RailsAiContext
352
357
  tables[table_name] = {
353
358
  columns: declared[:columns].map { |c| static_column(c) },
354
359
  indexes: declared[:indexes].filter_map { |i| static_index(i) },
355
- foreign_keys: []
356
- }
360
+ foreign_keys: [],
361
+ unread_calls: declared[:unread_calls]
362
+ }.compact
357
363
  key = declared.dig(:options, :primary_key)
358
364
  tables[table_name][:primary_key] = SchemaConventions.primary_key_value(key) if key
359
365
  end
@@ -59,9 +59,12 @@ module RailsAiContext
59
59
 
60
60
  attr_reader :source
61
61
 
62
- def initialize(path, pk_type: nil)
62
+ # partitions: the tables the database says are partitions, which a
63
+ # schema.rb from before Rails 8 dumps as plain tables.
64
+ def initialize(path, pk_type: nil, partitions: [])
63
65
  @path = path
64
66
  @pk_type = pk_type
67
+ @partitions = partitions
65
68
  @source = File.exist?(path.to_s) ? :schema_rb : :none
66
69
  end
67
70
 
@@ -147,11 +150,32 @@ module RailsAiContext
147
150
  current = absorb(event, schema, current)
148
151
  end
149
152
 
150
- schema
153
+ drop_partitions(schema)
151
154
  rescue => e
152
155
  RailsAiContext.debug_fail(e, empty_schema, label: "SchemaReader")
153
156
  end
154
157
 
158
+ # Rails dumps a partition as a table inheriting its parent, and PostgreSQL
159
+ # lets only a partition inherit a partitioned table.
160
+ def drop_partitions(schema)
161
+ table_options = schema[:tables].transform_values { |t| t.dig(:options, :options).to_s }
162
+ partitioned = table_options.select { |_, o| o.start_with?("PARTITION BY") }.keys
163
+ dumped_partitions = []
164
+ loop do
165
+ found = table_options.select { |_, o| partitioned.include?(o[/\AINHERITS \(([^,]+)\)\z/, 1]) }.keys - dumped_partitions
166
+ break if found.empty?
167
+
168
+ dumped_partitions.concat(found)
169
+ partitioned.concat(found)
170
+ end
171
+ partitions = dumped_partitions | @partitions
172
+
173
+ partitions.each { |name| schema[:tables].delete(name) }
174
+ schema[:foreign_keys].reject! { |fk| partitions.include?(fk[:from]) || partitions.include?(fk[:to]) }
175
+ schema[:check_constraints].reject! { |c| partitions.include?(c[:table]) }
176
+ schema
177
+ end
178
+
155
179
  # AstCache caps parses below the configured schema limit, so a dump
156
180
  # between the two has to be parsed directly. Everything under the cap
157
181
  # goes through the cache, which the other introspectors share.
@@ -186,6 +210,8 @@ module RailsAiContext
186
210
  schema[:tables][current]&.dig(:columns)&.push(column_entry(event)) if current
187
211
  when :index
188
212
  schema[:tables][current]&.dig(:indexes)&.push(index_entry(event)) if current
213
+ when :unread_call
214
+ SchemaConventions.note_unread_call(schema[:tables][current], event[:name]) if current
189
215
  when :add_index
190
216
  schema[:tables][event[:table]]&.dig(:indexes)&.push(index_entry(event))
191
217
  when :foreign_key
@@ -54,10 +54,13 @@ module RailsAiContext
54
54
  # [^;]*? keeps the match inside one statement: with .*? a pkey-only
55
55
  # ADD CONSTRAINT would swallow up to the FOREIGN KEY of a LATER
56
56
  # statement and attribute the FK to the wrong table.
57
- content.scan(/ALTER TABLE\s+(?:ONLY\s+)?(?:public\.)?[`"]?(\w+)[`"]?\s+ADD CONSTRAINT[^;]*?FOREIGN KEY\s*\([`"]?(\w+)[`"]?\)\s*REFERENCES\s+(?:public\.)?[`"]?(\w+)[`"]?\s*\([`"]?(\w+)[`"]?\)/m) do |from, col, to, pk|
58
- tables[from]&.dig(:foreign_keys)&.push({ from_table: from, to_table: to, column: col, primary_key: pk })
57
+ content.scan(/ALTER TABLE\s+(?:ONLY\s+)?(?:public\.)?[`"]?(\w+)[`"]?\s+ADD CONSTRAINT[^;]*?FOREIGN KEY\s*\(([^)]*)\)\s*REFERENCES\s+(?:public\.)?[`"]?(\w+)[`"]?\s*\(([^)]*)\)/m) do |from, cols, to, pks|
58
+ tables[from]&.dig(:foreign_keys)&.push(SchemaConventions.foreign_key_entry(from, to, cols.scan(/\w+/), pks.scan(/\w+/)))
59
59
  end
60
60
 
61
+ # pg_dump writes each partition as a table, then attaches it in exactly this form.
62
+ content.scan(/^ALTER TABLE ONLY .+? ATTACH PARTITION (?:public\.)?(?:"([^"]+)"|(\w+)) /) { |quoted, bare| tables.delete(quoted || bare) }
63
+
61
64
  { dialect: detect_sql_dialect(content), tables: tables }
62
65
  end
63
66
 
@@ -93,8 +96,9 @@ module RailsAiContext
93
96
  body.each_line do |line|
94
97
  line = line.strip.chomp(",")
95
98
  case line
96
- when /\ACONSTRAINT\s+[`"]?\w+[`"]?\s+FOREIGN KEY\s*\([`"]?(\w+)[`"]?\)\s*REFERENCES\s+[`"]?(\w+)[`"]?\s*\([`"]?(\w+)[`"]?\)/i
97
- table[:foreign_keys] << { from_table: table_name, to_table: $2, column: $1, primary_key: $3 }
99
+ when /\ACONSTRAINT\s+[`"]?\w+[`"]?\s+FOREIGN KEY\s*\(([^)]*)\)\s*REFERENCES\s+[`"]?(\w+)[`"]?\s*\(([^)]*)\)/i
100
+ columns, to, keys = $1, $2, $3
101
+ table[:foreign_keys] << SchemaConventions.foreign_key_entry(table_name, to, columns.scan(/\w+/), keys.scan(/\w+/))
98
102
  when /\A(UNIQUE\s+)?(?:KEY|INDEX)\s+[`"](\w+)[`"]\s*(\(.*)/i
99
103
  # Captured to locals first: the parsing below runs more regexes,
100
104
  # which would clobber $~ before the hash literal reads it.
@@ -368,7 +368,7 @@ module RailsAiContext
368
368
  refs = []
369
369
  # The partial or template a render call names, positionally or as a
370
370
  # keyword anywhere in its arguments, with or without parentheses.
371
- render_arguments(content).each do |args|
371
+ self.class.render_calls(content).each do |_, args|
372
372
  named = [ args[RENDER_POSITIONAL, 2], *top_level(args).scan(RENDER_NAMED).flatten ].compact
373
373
  # An interpolated name is decided at runtime, not a partial on disk.
374
374
  refs.concat(named.select { |name| name.match?(PARTIAL_NAME) })
@@ -388,28 +388,21 @@ module RailsAiContext
388
388
  refs.uniq
389
389
  end
390
390
 
391
- # Each render call's argument text: its balanced parentheses, or the rest of
392
- # the line and every line a trailing comma continues onto.
393
- def render_arguments(content)
391
+ # Each render call as [offset, argument text]: its balanced parentheses,
392
+ # or the rest of the line and every line a trailing comma continues onto.
393
+ def self.render_calls(content)
394
394
  content.to_enum(:scan, RENDER_CALL).map do
395
- rest = content[Regexp.last_match.end(0)..]
395
+ at = Regexp.last_match.end(0)
396
+ rest = content[at..]
396
397
  open = rest[/\A\s*\(/]
397
- next continued_line(rest) unless open
398
+ next [ at, continued_line(rest) ] unless open
398
399
 
399
400
  span = RailsAiContext::Brackets.span(rest, open.length - 1, comments: :ruby)
400
- span ? span[1...-1] : rest[open.length..]
401
+ [ at, span ? span[1...-1] : rest[open.length..] ]
401
402
  end
402
403
  end
403
404
 
404
- # The argument text with every nested (), [] and {} group dropped, so a
405
- # `template:` inside `locals: {...}` or `Foo.new(...)` is not the call's.
406
- def top_level(args)
407
- kept = +""
408
- RailsAiContext::Brackets.each_top_level(args, comments: :ruby) { |piece, kind| kept << piece unless %i[group comment].include?(kind) }
409
- kept
410
- end
411
-
412
- def continued_line(text)
405
+ def self.continued_line(text)
413
406
  lines = []
414
407
  text.each_line do |line|
415
408
  lines << line
@@ -417,6 +410,15 @@ module RailsAiContext
417
410
  end
418
411
  lines.join
419
412
  end
413
+ private_class_method :continued_line
414
+
415
+ # The argument text with every nested (), [] and {} group dropped, so a
416
+ # `template:` inside `locals: {...}` or `Foo.new(...)` is not the call's.
417
+ def top_level(args)
418
+ kept = +""
419
+ RailsAiContext::Brackets.each_top_level(args, comments: :ruby) { |piece, kind| kept << piece unless %i[group comment].include?(kind) }
420
+ kept
421
+ end
420
422
 
421
423
  def extract_stimulus_refs(content)
422
424
  self.class.stimulus_refs(content)
@@ -21,7 +21,12 @@ module RailsAiContext
21
21
  return nil unless defined?(ActiveRecord::Base)
22
22
 
23
23
  context = migration_context(migrate_dir)
24
- context.open.pending_migrations.map { |m| { version: m.version.to_s, name: m.name } }
24
+ begin
25
+ context.open.pending_migrations.map { |m| { version: m.version.to_s, name: m.name } }
26
+ rescue ActiveRecord::MigrationError
27
+ # A future or duplicate version stops Rails loading the directory; the applied set still answers.
28
+ PendingMigrations.for(migrate_dir: migrate_dir, applied: context.get_all_versions)
29
+ end
25
30
  rescue => e
26
31
  RailsAiContext.debug_fail(e, nil, label: "MigrationStatus.pending")
27
32
  end
@@ -25,9 +25,10 @@ module RailsAiContext
25
25
  unapplied.map { |m| { version: m[:version], name: m[:name] } }
26
26
  end
27
27
 
28
- # The connection's answer; nil when there is no database to ask.
28
+ # The connection's answer; nil when there is no database to ask. The schema
29
+ # and migrations sections both ask, so a run queries once.
29
30
  def live(migrate_dir)
30
- MigrationStatus.pending(migrate_dir)
31
+ RunCache.fetch([ :pending_migrations, migrate_dir.to_s ]) { MigrationStatus.pending(migrate_dir) }
31
32
  end
32
33
 
33
34
  # Each secondary database keeps its own migrate directory, so a secondary
@@ -80,6 +80,20 @@ module RailsAiContext
80
80
  relative unless relative.start_with?(File::SEPARATOR)
81
81
  end
82
82
 
83
+ # For free text such as an exception message, which names a file by its
84
+ # absolute path, often through the resolved form of the root (/private/tmp).
85
+ def relativize_text(text, root)
86
+ root = root.to_s
87
+ return text.to_s if root.empty?
88
+
89
+ real = File.realpath(root) rescue root
90
+ prefixes = [ root, real ].uniq.sort_by { |prefix| -prefix.length }.map { |prefix| Regexp.escape(prefix) }
91
+ alternation = prefixes.join("|")
92
+ text.to_s
93
+ .gsub(%r{(\A|[\s"'`(:\[=,])(?:#{alternation})/}, '\1')
94
+ .gsub(%r{(\A|[\s"'`(:\[=,])(?:#{alternation})(?=\z|[\s"'`)\],;:])}, '\1.')
95
+ end
96
+
83
97
  def relativize_all(paths, root)
84
98
  Array(paths).map { |path| relativize(path, root) }.uniq
85
99
  end