rails-ai-context 5.35.0 → 5.35.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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1024 -0
  3. data/CONTEXT.md +37 -7
  4. data/CONTRIBUTING.md +30 -1
  5. data/README.md +9 -9
  6. data/SECURITY.md +2 -2
  7. data/docs/ARCHITECTURE.md +12 -4
  8. data/docs/CLI.md +44 -15
  9. data/docs/COMPATIBILITY.md +34 -8
  10. data/docs/CONFIGURATION.md +28 -5
  11. data/docs/CUSTOM_TOOLS.md +8 -2
  12. data/docs/FAQ.md +5 -5
  13. data/docs/GUIDE.md +33 -65
  14. data/docs/INTROSPECTORS.md +4 -4
  15. data/docs/QUICKSTART.md +1 -1
  16. data/docs/RECIPES.md +5 -5
  17. data/docs/RELEASING.md +3 -1
  18. data/docs/SECURITY.md +255 -74
  19. data/docs/SETUP.md +1 -1
  20. data/docs/STANDALONE.md +2 -2
  21. data/docs/TOOLS.md +151 -90
  22. data/docs/TROUBLESHOOTING.md +123 -8
  23. data/docs/_config.yml +1 -1
  24. data/docs/adr/0008-query-runs-only-where-it-can-guard.md +38 -0
  25. data/docs/adr/0009-dbhub-shapes-in-ruby.md +26 -0
  26. data/docs/adr/0010-one-config-tool-with-sections.md +32 -0
  27. data/docs/index.md +2 -2
  28. data/exe/rails-ai-context +24 -9
  29. data/lib/generators/rails_ai_context/install/install_generator.rb +2 -0
  30. data/lib/rails_ai_context/app_databases.rb +377 -0
  31. data/lib/rails_ai_context/app_kind.rb +2 -0
  32. data/lib/rails_ai_context/cli/app_root.rb +1 -0
  33. data/lib/rails_ai_context/cli/tool_runner.rb +110 -99
  34. data/lib/rails_ai_context/configuration.rb +15 -8
  35. data/lib/rails_ai_context/database_family/mysql_runtime.rb +170 -0
  36. data/lib/rails_ai_context/database_family/postgresql_plan.rb +124 -0
  37. data/lib/rails_ai_context/database_family/postgresql_runtime.rb +211 -0
  38. data/lib/rails_ai_context/database_family/sqlite_runtime.rb +391 -0
  39. data/lib/rails_ai_context/database_family.rb +282 -0
  40. data/lib/rails_ai_context/database_file.rb +61 -7
  41. data/lib/rails_ai_context/database_yml.rb +7 -29
  42. data/lib/rails_ai_context/doctor.rb +15 -16
  43. data/lib/rails_ai_context/instrumentation.rb +1 -1
  44. data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +8 -23
  45. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +6 -70
  46. data/lib/rails_ai_context/introspectors/declared_constant.rb +19 -7
  47. data/lib/rails_ai_context/introspectors/env_introspector.rb +1 -1
  48. data/lib/rails_ai_context/introspectors/env_references.rb +1 -1
  49. data/lib/rails_ai_context/introspectors/job_introspector.rb +7 -7
  50. data/lib/rails_ai_context/introspectors/migration_introspector.rb +8 -11
  51. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +9 -9
  52. data/lib/rails_ai_context/introspectors/model_introspector.rb +33 -39
  53. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +33 -30
  54. data/lib/rails_ai_context/introspectors/node_source.rb +2 -0
  55. data/lib/rails_ai_context/introspectors/performance_introspector.rb +80 -24
  56. data/lib/rails_ai_context/introspectors/pg_partitions.rb +1 -4
  57. data/lib/rails_ai_context/introspectors/schema_conventions.rb +6 -8
  58. data/lib/rails_ai_context/introspectors/schema_dump_path.rb +12 -14
  59. data/lib/rails_ai_context/introspectors/schema_introspector.rb +47 -27
  60. data/lib/rails_ai_context/introspectors/schema_reader.rb +1 -1
  61. data/lib/rails_ai_context/introspectors/source_scan.rb +39 -4
  62. data/lib/rails_ai_context/migration_status.rb +18 -46
  63. data/lib/rails_ai_context/payload.rb +48 -1
  64. data/lib/rails_ai_context/pending_migrations.rb +3 -4
  65. data/lib/rails_ai_context/run_cache.rb +5 -0
  66. data/lib/rails_ai_context/schema_adapter.rb +13 -18
  67. data/lib/rails_ai_context/secret_columns.rb +506 -0
  68. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -2
  69. data/lib/rails_ai_context/serializers/markdown_serializer.rb +14 -10
  70. data/lib/rails_ai_context/serializers/section_facts.rb +12 -2
  71. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +5 -2
  72. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +35 -7
  73. data/lib/rails_ai_context/server.rb +143 -29
  74. data/lib/rails_ai_context/sql_gate.rb +1007 -0
  75. data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -2
  76. data/lib/rails_ai_context/test_helper.rb +3 -1
  77. data/lib/rails_ai_context/tools/analyze_feature.rb +11 -7
  78. data/lib/rails_ai_context/tools/base_tool.rb +71 -48
  79. data/lib/rails_ai_context/tools/dependency_graph.rb +1 -1
  80. data/lib/rails_ai_context/tools/diagnose.rb +105 -42
  81. data/lib/rails_ai_context/tools/generate_test.rb +4 -6
  82. data/lib/rails_ai_context/tools/get_active_support.rb +1 -6
  83. data/lib/rails_ai_context/tools/get_api.rb +1 -6
  84. data/lib/rails_ai_context/tools/get_autoload.rb +1 -6
  85. data/lib/rails_ai_context/tools/get_callbacks.rb +5 -5
  86. data/lib/rails_ai_context/tools/get_component_catalog.rb +6 -7
  87. data/lib/rails_ai_context/tools/get_concern.rb +6 -6
  88. data/lib/rails_ai_context/tools/get_config.rb +189 -12
  89. data/lib/rails_ai_context/tools/get_context.rb +111 -105
  90. data/lib/rails_ai_context/tools/get_controllers.rb +8 -5
  91. data/lib/rails_ai_context/tools/get_conventions.rb +1 -6
  92. data/lib/rails_ai_context/tools/get_engines.rb +1 -6
  93. data/lib/rails_ai_context/tools/get_env.rb +1 -6
  94. data/lib/rails_ai_context/tools/get_env_config.rb +2 -8
  95. data/lib/rails_ai_context/tools/get_frontend_stack.rb +1 -6
  96. data/lib/rails_ai_context/tools/get_gems.rb +1 -6
  97. data/lib/rails_ai_context/tools/get_helper_methods.rb +4 -5
  98. data/lib/rails_ai_context/tools/get_i18n.rb +1 -7
  99. data/lib/rails_ai_context/tools/get_job_pattern.rb +1 -1
  100. data/lib/rails_ai_context/tools/get_mailers.rb +1 -1
  101. data/lib/rails_ai_context/tools/get_model_details.rb +5 -8
  102. data/lib/rails_ai_context/tools/get_partial_interface.rb +3 -4
  103. data/lib/rails_ai_context/tools/get_schema.rb +10 -8
  104. data/lib/rails_ai_context/tools/get_service_pattern.rb +60 -24
  105. data/lib/rails_ai_context/tools/get_turbo_map.rb +1 -1
  106. data/lib/rails_ai_context/tools/migration_advisor.rb +47 -32
  107. data/lib/rails_ai_context/tools/onboard.rb +24 -24
  108. data/lib/rails_ai_context/tools/performance_check.rb +15 -2
  109. data/lib/rails_ai_context/tools/query.rb +142 -1356
  110. data/lib/rails_ai_context/tools/read_logs.rb +1 -1
  111. data/lib/rails_ai_context/tools/review_changes.rb +6 -7
  112. data/lib/rails_ai_context/tools/runtime_info.rb +125 -88
  113. data/lib/rails_ai_context/tools/safe_call.rb +70 -28
  114. data/lib/rails_ai_context/tools/search_code.rb +0 -4
  115. data/lib/rails_ai_context/tools/search_docs.rb +3 -1
  116. data/lib/rails_ai_context/tools/security_scan.rb +9 -5
  117. data/lib/rails_ai_context/tools/session_context.rb +18 -12
  118. data/lib/rails_ai_context/tools/validate.rb +1 -0
  119. data/lib/rails_ai_context/tools/validate_semantics.rb +5 -5
  120. data/lib/rails_ai_context/version.rb +1 -1
  121. data/lib/rails_ai_context.rb +4 -7
  122. data/server.json +2 -2
  123. metadata +14 -3
@@ -115,9 +115,8 @@ module RailsAiContext
115
115
  end
116
116
  # Cross-reference hint for AI: suggest next tool call
117
117
  model_refs = models_for_table([ table_key, listed_as ], models_data, schema: schema, database: (databases.first if databases.size == 1))
118
- if model_refs.any?
119
- output += "\n\n_Next: `rails_get_model_details(model:\"#{model_refs.first}\")` for associations, validations, scopes._"
120
- end
118
+ hint = next_hint(pointer("rails_get_model_details", %(model:"#{model_refs.first}"), "for associations, validations, scopes.")) if model_refs.any?
119
+ output += "\n\n#{hint}" if hint
121
120
  return text_response(output)
122
121
  end
123
122
 
@@ -142,7 +141,7 @@ module RailsAiContext
142
141
  end
143
142
  coverage = model_coverage_lines(tables, models_data, schema)
144
143
  lines.concat([ "" ] + coverage) if coverage.any?
145
- lines.concat(secondary_databases_lines(schema))
144
+ lines.concat(secondary_databases_lines(schema, ctx))
146
145
  if page[:offset] + page[:limit] < total
147
146
  lines << "" << "_Showing #{paginated.size} of #{total}. Use `offset:#{page[:offset] + page[:limit]}` for more, or `table:\"name\"` for full detail._"
148
147
  lines << "_cache_key: #{cache_key}_"
@@ -254,7 +253,7 @@ module RailsAiContext
254
253
 
255
254
  coverage = model_coverage_lines(tables, models_data, schema)
256
255
  lines.concat(coverage + [ "" ]) if coverage.any?
257
- lines.concat(secondary_databases_lines(schema))
256
+ lines.concat(secondary_databases_lines(schema, ctx))
258
257
  if page[:offset] + page[:limit] < total
259
258
  lines << "_Showing #{paginated.size} of #{total}. Use `offset:#{page[:offset] + page[:limit]}` for more._"
260
259
  end
@@ -278,7 +277,7 @@ module RailsAiContext
278
277
  end
279
278
  coverage = model_coverage_lines(tables, models_data, schema)
280
279
  lines.concat(coverage + [ "" ]) if coverage.any?
281
- lines.concat(secondary_databases_lines(schema))
280
+ lines.concat(secondary_databases_lines(schema, ctx))
282
281
  if page[:offset] + page[:limit] < total
283
282
  lines << "_Showing #{paginated.size} of #{total}. Use `offset:#{page[:offset] + page[:limit]}` for more._"
284
283
  lines << "_cache_key: #{cache_key}_"
@@ -439,15 +438,18 @@ module RailsAiContext
439
438
  # cache, cable) to its own schema/structure file. This tool targets the
440
439
  # primary database's tables in detail, so secondaries get a compact
441
440
  # one-line-per-database summary rather than full column detail.
442
- private_class_method def self.secondary_databases_lines(schema)
441
+ private_class_method def self.secondary_databases_lines(schema, ctx)
443
442
  secondary = schema[:secondary_databases]
444
443
  return [] unless secondary
445
444
 
446
445
  lines = [ "", "## Secondary databases", "" ]
447
446
  secondary.each do |name, db|
447
+ label = "**#{name}**#{Payload.named_database_mark(ctx, name)}"
448
+ next lines << "- #{label}: #{db[:note]}" if db[:tables].blank?
449
+
448
450
  pending = Array(db[:pending_migrations])
449
451
  pending_text = pending.any? ? "; pending migrations: #{pending_phrase(pending)}" : ""
450
- lines << "- **#{name}**: #{Introspectors::SchemaConventions.relations_phrase(db[:tables])} (#{db[:tables].keys.join(', ')}) - #{db[:note]}#{pending_text}"
452
+ lines << "- #{label}: #{Introspectors::SchemaConventions.relations_phrase(db[:tables])} (#{db[:tables].keys.join(', ')}) - #{db[:note]}#{pending_text}"
451
453
  end
452
454
  lines
453
455
  end
@@ -46,9 +46,10 @@ module RailsAiContext
46
46
 
47
47
  # A module in a concerns directory is a concern, not a service; the generated files'
48
48
  # Services line uses the same rule.
49
- service_files = service_dirs.flat_map { |d| safe_glob(d, "**/*.rb", real_root) }.uniq.sort
50
- .reject { |f| Introspectors::ServiceClasses.concern?(relative_under(f, real_service_dirs)) }
51
- return no_services_response if service_files.empty?
49
+ concern_files, service_files = service_dirs.flat_map { |d| safe_glob(d, "**/*.rb", real_root) }.uniq.sort
50
+ .partition { |f| Introspectors::ServiceClasses.concern?(relative_under(f, real_service_dirs)) }
51
+ concerns = concern_files.map { |f| { path: f, left_out: :concern } }
52
+ return no_services_response([ left_out_note(concerns, real_service_dirs) ].compact) if service_files.empty?
52
53
 
53
54
  # One lookup for the whole call: it walks the service tree on first
54
55
  # use and only a class whose superclass is not ActiveInteraction::Base
@@ -59,7 +60,7 @@ module RailsAiContext
59
60
  return format_single_service(service, service_files, real_service_dirs, real_root, lookup)
60
61
  end
61
62
 
62
- format_service_listing(service_files, real_service_dirs, real_root, detail, lookup)
63
+ format_service_listing(service_files, real_service_dirs, real_root, detail, lookup, concerns)
63
64
  end
64
65
 
65
66
  # A service lives under app/services, a pack, an in-repo engine or a
@@ -71,8 +72,8 @@ module RailsAiContext
71
72
  end
72
73
 
73
74
  private_class_method def self.format_single_service(service, service_files, service_dirs, root, lookup)
74
- records = without_mixins(match_service_files(service, service_files, service_dirs)
75
- .filter_map { |f| service_record(f, service_dirs, lookup) }, root)
75
+ records = classified(match_service_files(service, service_files, service_dirs), service_dirs, root, lookup)
76
+ .reject { |r| r[:left_out] }
76
77
  matches = records.map { |r| r[:path] }
77
78
 
78
79
  if matches.size > 1
@@ -182,12 +183,11 @@ module RailsAiContext
182
183
  end
183
184
  end
184
185
  if scan_truncated
185
- lines << "" << "_The caller scan stopped after #{count_phrase(MAX_CALLER_SCAN_FILES, "file")}; " \
186
- "run `rails_search_code(pattern:\"#{class_name}\")` for the rest._"
186
+ search = pointer("rails_search_code", %(pattern:"#{class_name}"))
187
+ lines << "" << "_The caller scan stopped after #{count_phrase(MAX_CALLER_SCAN_FILES, "file")}#{"; run #{search} for the rest" if search}._"
187
188
  end
188
189
 
189
- # Cross-reference hints
190
- lines << "" << "_Next: `rails_search_code(pattern:\"#{class_name}\")` for all references_"
190
+ add_next_hint(lines, pointer("rails_search_code", %(pattern:"#{class_name}"), "for all references"))
191
191
 
192
192
  text_response(lines.join("\n"))
193
193
  end
@@ -195,17 +195,25 @@ module RailsAiContext
195
195
  # The classes the listing counts before it sets the bases apart. The by-name
196
196
  # lookup reads the same list, so it never answers what the listing leaves out.
197
197
  private_class_method def self.service_records(service_files, service_dirs, root, lookup)
198
- without_mixins(service_files.filter_map { |file| service_record(file, service_dirs, lookup) }, root)
198
+ classified(service_files, service_dirs, root, lookup).reject { |r| r[:left_out] }
199
+ end
200
+
201
+ # Every file with the one reason it is no service, if any. The listing's
202
+ # count and its note of what it left out both read this.
203
+ private_class_method def self.classified(files, service_dirs, root, lookup)
204
+ tag_mixins(files.map { |file| service_record(file, service_dirs, lookup) }, root)
199
205
  end
200
206
 
201
207
  private_class_method def self.service_record(file, service_dirs, lookup)
202
208
  source = safe_read(file)
203
209
  # Kept unread so a by-name lookup can say the file is too large.
204
210
  return { path: file } unless source
205
- return if Introspectors::ServiceClasses.concern?(nil, source)
211
+ return { path: file, left_out: :concern } if Introspectors::ServiceClasses.concern?(nil, source)
206
212
 
207
213
  class_name, superclass, nesting = name_and_superclass(source, file, service_dirs)
208
- return if Introspectors::ServiceClasses.mailer?(source, class_name, superclass, lookup)
214
+ if Introspectors::ServiceClasses.mailer?(source, class_name, superclass, lookup)
215
+ return { path: file, class_name: class_name, left_out: :mailer }
216
+ end
209
217
 
210
218
  { path: file, source: source, class_name: class_name, superclass: superclass, nesting: nesting,
211
219
  entryless: Introspectors::ServiceClasses.entryless_module?(source, class_name) }
@@ -213,16 +221,45 @@ module RailsAiContext
213
221
 
214
222
  # A module other classes mix in is a concern in all but directory, and
215
223
  # the generated files' Services line leaves it out by the same rule.
216
- private_class_method def self.without_mixins(records, root)
224
+ private_class_method def self.tag_mixins(records, root)
217
225
  entryless = records.select { |r| r[:entryless] }.map { |r| r[:class_name] }
218
226
  return records if entryless.empty?
219
227
 
220
228
  mixins = Introspectors::ServiceClasses.mixed_in(root, entryless)
221
- records.reject { |r| mixins.include?(r[:class_name]) }
229
+ records.map { |r| r[:entryless] && mixins.include?(r[:class_name]) ? r.merge(left_out: :mixin) : r }
222
230
  end
223
231
 
224
- private_class_method def self.format_service_listing(service_files, service_dirs, root, detail, lookup)
225
- read = service_records(service_files, service_dirs, root, lookup).select { |record| record[:source] }.map do |record|
232
+ LEFT_OUT_NAMES_PER_KIND = 5
233
+ LEFT_OUT_KINDS = {
234
+ base: [ "base class", " (named above)" ],
235
+ concern: [ "concern", "" ],
236
+ mixin: [ "module", " with no entry point that other code mixes in" ],
237
+ mailer: [ "mailer", "" ],
238
+ unread: [ "file", " too large to read" ]
239
+ }.freeze
240
+
241
+ # The heading counts services and a reader counts files, so the files
242
+ # that are no service are counted by kind beside it.
243
+ private_class_method def self.left_out_note(records, service_dirs)
244
+ left_out = records.select { |r| r[:left_out] }
245
+ return nil if left_out.empty?
246
+
247
+ kinds = LEFT_OUT_KINDS.filter_map do |kind, (noun, suffix)|
248
+ of_kind = left_out.select { |r| r[:left_out] == kind }
249
+ next if of_kind.empty?
250
+
251
+ names = of_kind.map { |r| r[:class_name] || constant_for(r[:path], service_dirs) }.sort
252
+ more = names.size - LEFT_OUT_NAMES_PER_KIND
253
+ named = kind == :base ? "" : " (#{names.first(LEFT_OUT_NAMES_PER_KIND).join(', ')}#{" and #{more} more" if more.positive?})"
254
+ "#{count_phrase(of_kind.size, noun)}#{suffix}#{named}"
255
+ end
256
+ "_#{left_out.size} of the #{count_phrase(records.size, "Ruby file")} under the service directories " \
257
+ "#{left_out.size == 1 ? 'is' : 'are'} left out: #{kinds.join(', ')}._"
258
+ end
259
+
260
+ private_class_method def self.format_service_listing(service_files, service_dirs, root, detail, lookup, concerns)
261
+ records = concerns + classified(service_files, service_dirs, root, lookup).map { |r| r[:source] || r[:left_out] ? r : r.merge(left_out: :unread) }
262
+ read = records.reject { |record| record[:left_out] }.map do |record|
226
263
  [ record, *class_interface(record[:source], constant_for(record[:path], service_dirs)) ]
227
264
  end
228
265
  # A listed parent's macros are already read, so its children's constructors reuse them.
@@ -247,15 +284,15 @@ module RailsAiContext
247
284
 
248
285
  bases = base_class_names(service_data)
249
286
  service_data = service_data.reject { |s| bases.include?(s[:class_name]) }
287
+ notes = [ bases_note("services", bases),
288
+ left_out_note(records.map { |r| bases.include?(r[:class_name]) && !r[:left_out] ? r.merge(left_out: :base) : r }, service_dirs) ].compact
250
289
  # A directory of base classes alone has no service anybody calls, and
251
290
  # still names the bases it left out.
252
- return no_services_response(bases) if service_data.empty?
291
+ return no_services_response(notes) if service_data.empty?
253
292
 
254
293
  total = service_data.size
255
294
  lines = [ "# Service Objects (#{total})", "" ]
256
- if bases.any?
257
- lines << bases_note("services", bases) << ""
258
- end
295
+ lines.push(*notes, "") if notes.any?
259
296
 
260
297
  # Pattern detection
261
298
  detected = detect_common_pattern(pattern_stats(service_data))
@@ -314,9 +351,8 @@ module RailsAiContext
314
351
  stats
315
352
  end
316
353
 
317
- private_class_method def self.no_services_response(bases = [])
318
- text_response([ "A services directory exists but contains no service objects.",
319
- bases_note("services", bases) ].compact.join("\n\n"))
354
+ private_class_method def self.no_services_response(notes = [])
355
+ text_response([ "A services directory exists but contains no service objects.", *notes.join("\n").presence ].join("\n\n"))
320
356
  end
321
357
 
322
358
  # A base class is not a service a caller invokes: counted as one it
@@ -23,7 +23,7 @@ module RailsAiContext
23
23
  )
24
24
 
25
25
  guide_row(
26
- order: 19,
26
+ order: 18,
27
27
  mcp: "rails_get_turbo_map",
28
28
  summary: "Turbo Stream/Frame wiring + mismatch warnings"
29
29
  )
@@ -43,7 +43,7 @@ module RailsAiContext
43
43
  )
44
44
 
45
45
  guide_row(
46
- order: 28,
46
+ order: 25,
47
47
  mcp: "rails_migration_advisor(action:\"X\", table:\"Y\")",
48
48
  cli_args: "action=X table=Y",
49
49
  summary: "Generate migration code, flag irreversible ops"
@@ -123,7 +123,7 @@ module RailsAiContext
123
123
  end
124
124
  # What the app's migrations already hold: a file the generator's name
125
125
  # collides with, and a pending one that makes the same change.
126
- prior = prior_migration_lines(action, table, column, rename_to, type, migration_files_for(target), pending_versions(schema, databases))
126
+ prior = prior_migration_lines(action, table, column, rename_to, type, migration_files_for(target), pending_versions(schema, table, databases))
127
127
  lines.concat(prior)
128
128
  # The generator refuses a name another migration has, so its command is not offered.
129
129
  target = false if prior.any? { |line| line.start_with?("**Warning:** Another migration is already named") }
@@ -190,8 +190,8 @@ module RailsAiContext
190
190
  end
191
191
 
192
192
  # The versions not yet run on that database; nil when unknown.
193
- def pending_versions(schema, databases)
194
- pending = if databases.empty? || databases.include?("primary")
193
+ def pending_versions(schema, table, databases)
194
+ pending = if on_primary?(table)
195
195
  schema&.dig(:pending_migrations)
196
196
  else
197
197
  schema&.dig(:secondary_databases, databases.first, :pending_migrations)
@@ -325,11 +325,14 @@ module RailsAiContext
325
325
  # [lines, target]: target is nil for the primary, false when no environment configures the
326
326
  # database, else the generator's --database flag and the RAILS_ENV that configures it.
327
327
  def database_option(table, databases)
328
- return [ [], nil ] if databases.empty? || databases.include?("primary")
328
+ return [ [], nil ] if on_primary?(table)
329
329
 
330
- yml = RailsAiContext::DatabaseYml
331
330
  root = rails_app.root
332
- found = databases.to_h { |db| [ db, (entry = yml.entry(root, db)) ? [ nil, entry ] : yml.elsewhere(root, db) ] }.compact
331
+ listed = RailsAiContext::AppDatabases.for(root)
332
+ found = databases.filter_map do |name|
333
+ db = listed[name]
334
+ [ name, [ (db.environments unless db.here), db.settings ] ] if db&.config
335
+ end.to_h
333
336
  if found.empty?
334
337
  note = "**Database:** `#{table}` is in the #{databases.join(", ")} dump, which no environment in config/database.yml configures: " \
335
338
  "no `--database` reaches it, and a migration generated here runs on the primary."
@@ -337,13 +340,15 @@ module RailsAiContext
337
340
  end
338
341
 
339
342
  names = found.keys
340
- env_name = found.values.first.first
343
+ elsewhere = found.values.first.first
344
+ env_name = elsewhere&.first
345
+ configuring = "#{RailsAiContext::AppDatabases.environment_phrase(elsewhere)} #{elsewhere.one? ? "configures" : "configure"}" if elsewhere
341
346
  configured = Array(found.values.first.last["migrations_paths"])
342
347
  dirs = configured.any? ? configured.map { |path| File.expand_path(path, root.to_s) } : [ File.join(root.to_s, "db", "#{names.first}_migrate") ]
343
348
  target = { flag: " --database #{names.first}", env: env_name, dirs: dirs }
344
349
  paths = configured.join(", ")
345
- note = "**Database:** `#{table}` is in #{[ names[0..-2].join(", "), names.last ].reject(&:empty?).join(" and ")}," \
346
- "#{" which #{env_name} configures and this environment does not," if env_name} not the primary database: generate with #{"`RAILS_ENV=#{env_name}` and " if env_name}`#{target[:flag].strip}` " \
350
+ note = "**Database:** `#{table}` is in #{[ names[0..-2].join(", "), names.last ].reject(&:empty?).join(" and ")}" \
351
+ "#{", which #{configuring} and this environment does not" if env_name}: generate with #{"`RAILS_ENV=#{env_name}` and " if env_name}`#{target[:flag].strip}` " \
347
352
  "so the migration lands in #{env_name && !paths.empty? ? paths : "that database's migrations_paths"} and " \
348
353
  "#{"#{env_name}'s " if env_name}`bin/rails db:migrate` runs it there."
349
354
  groups = found.group_by { |_, (_, entry)| Array(entry["migrations_paths"]) }
@@ -397,11 +402,10 @@ module RailsAiContext
397
402
  lines
398
403
  end
399
404
 
400
- # NOT NULL with no default has no value for the rows already there, so
401
- # the migration fails on a table that holds any. The row count is the
402
- # database's own estimate when the app is booted.
405
+ # NOT NULL with no default fails on a table that holds rows. The row count is the booted
406
+ # primary's own estimate, so a table only another database holds has none.
403
407
  def not_null_warning(table, column)
404
- stats = Array(Payload.section(cached_context, :database_stats)&.dig(:tables)).find { |t| t[:table].to_s == table }
408
+ stats = (Array(Payload.section(cached_context, :database_stats)&.dig(:tables)).find { |t| t[:table].to_s == table } if on_primary?(table))
405
409
  rows = stats&.dig(:approximate_rows)
406
410
  held = if rows.nil? then "if `#{table}` holds any rows"
407
411
  elsif stats[:at_least] then "as `#{table}` holds at least #{count_phrase(rows, "row")}"
@@ -505,10 +509,13 @@ module RailsAiContext
505
509
  lines << "```"
506
510
  lines << ""
507
511
  lines << "**Reversible:** Yes"
508
- lines << if mysql_adapter?
512
+ lines << case family(table).key
513
+ when :mysql
509
514
  "**Note:** MySQL/MariaDB add indexes to InnoDB tables via online DDL by default (`ALGORITHM=INPLACE`, no table lock) - there's no `algorithm: :concurrently` equivalent to reach for here (that option is PostgreSQL-only; Rails raises `ArgumentError` if you pass it on this adapter)."
510
- else
515
+ when :postgresql
511
516
  "**Note:** For large tables, consider `algorithm: :concurrently` (PostgreSQL) to avoid locking"
517
+ else
518
+ "**Note:** Building an index on a large table can block writes while it runs."
512
519
  end
513
520
  lines
514
521
  end
@@ -708,21 +715,22 @@ module RailsAiContext
708
715
  ]
709
716
  when "change_type"
710
717
  [
711
- "**`change_column` (type change) blocks writes** on Postgres for the duration of the table rewrite, which can be hours on large tables.",
718
+ "**`change_column` (type change) blocks writes**#{" on Postgres" if family(table).postgresql?} for the duration of the table rewrite, which can be hours on large tables.",
712
719
  "Safer pattern: add a new column with the new type, backfill, dual-write, swap, drop the old column."
713
720
  ]
714
721
  when "add_index"
715
722
  # strong_migrations only requires algorithm: :concurrently on
716
723
  # PostgreSQL (its ACCESS EXCLUSIVE lock is the problem) - it
717
724
  # doesn't flag plain add_index on MySQL/MariaDB or SQLite at all.
718
- if postgresql_adapter? && !options.to_s.include?("concurrently")
725
+ if family(table).postgresql? && !options.to_s.include?("concurrently")
719
726
  [
720
727
  "**`add_index` without `algorithm: :concurrently`** acquires an `ACCESS EXCLUSIVE` lock on Postgres and blocks writes.",
721
728
  "Use `add_index :#{table}, :#{column}, algorithm: :concurrently` and add `disable_ddl_transaction!` at the top of the migration."
722
729
  ]
723
730
  end
724
731
  when "add_association"
725
- if mysql_adapter?
732
+ case family(table).key
733
+ when :mysql
726
734
  [
727
735
  "**`add_foreign_key` blocks writes on both tables while validating existing rows.** MySQL/MariaDB has no `validate: false` + separate-validation two-step like Postgres.",
728
736
  "If you're certain all rows are valid, wrap in `safety_assured` and drop `foreign_key_checks` for the duration:",
@@ -730,18 +738,24 @@ module RailsAiContext
730
738
  " 2. `add_foreign_key :#{table}, :#{column}`",
731
739
  " 3. `execute \"SET SESSION foreign_key_checks = 1\"`"
732
740
  ]
733
- else
741
+ when :postgresql
734
742
  [
735
743
  "**`add_foreign_key` validates existing rows by default**, which acquires a `SHARE ROW EXCLUSIVE` lock on both tables.",
736
744
  "Safer two-step pattern:",
737
745
  " 1. `add_foreign_key :#{table}, :#{column}, validate: false` (lock-free)",
738
746
  " 2. In a separate migration: `validate_foreign_key :#{table}, :#{column}`"
739
747
  ]
748
+ else
749
+ [ "**`add_foreign_key` validates existing rows by default**, which can block writes on both tables while it runs." ]
740
750
  end
741
751
  when "add_column"
742
752
  if options.to_s.match?(/null:\s*false/) && !options.to_s.include?("default:")
743
753
  [
744
- "**Adding a `NOT NULL` column without a default rewrites the table** on older Postgres and fails on existing rows.",
754
+ if family(table).postgresql?
755
+ "**Adding a `NOT NULL` column without a default rewrites the table** on older Postgres and fails on existing rows."
756
+ else
757
+ "**Adding a `NOT NULL` column without a default** needs a value for every existing row."
758
+ end,
745
759
  "Safer pattern: add the column nullable, backfill in batches, then add the NOT NULL constraint with `change_column_null`."
746
760
  ]
747
761
  end
@@ -806,7 +820,8 @@ module RailsAiContext
806
820
  end
807
821
  return [ "", "## Code that names `#{column}`", "", "_No line in app/, lib/ or config/ names it._" ] if found.empty?
808
822
 
809
- more = found.size > MENTION_CAP ? [ "- _...and #{found.size - MENTION_CAP} more; `rails_search_code(pattern:\"#{column}\", exact_match:true)` lists them all_" ] : []
823
+ search = pointer("rails_search_code", %(pattern:"#{column}", exact_match:true), "lists them all")
824
+ more = found.size > MENTION_CAP ? [ "- _...and #{found.size - MENTION_CAP} more#{"; #{search}" if search}_" ] : []
810
825
  [ "", "## Code that names `#{column}` (#{count_phrase(found.size, "line")})", "" ] + found.first(MENTION_CAP) + more
811
826
  rescue => e
812
827
  RailsAiContext.debug_fail(e, [], label: "column_mention_lines")
@@ -908,19 +923,19 @@ module RailsAiContext
908
923
  parts.join(".")
909
924
  end
910
925
 
911
- # Locking/DDL advice differs by adapter (MySQL's online DDL vs
912
- # Postgres's ACCESS EXCLUSIVE locks), so callers need to know which
913
- # family the app is actually running before printing lock advice.
914
- def current_adapter
915
- cached_context.dig(:schema, :adapter).to_s
916
- end
926
+ # The family of the database that holds the table, since locking advice differs by family.
927
+ # An undecided database is no family and gets advice that names no database.
928
+ def family(table)
929
+ return RailsAiContext::DatabaseFamily.of(cached_context.dig(:schema, :adapter).to_s) if on_primary?(table)
917
930
 
918
- def mysql_adapter?
919
- current_adapter.match?(/mysql|trilogy/i)
931
+ held_in = Payload.schema_databases(Payload.section(cached_context, :schema), table)
932
+ RailsAiContext::AppDatabases.for(rails_app.root)[held_in.first]&.family || RailsAiContext::DatabaseFamily.of(nil)
920
933
  end
921
934
 
922
- def postgresql_adapter?
923
- current_adapter.match?(/postg/i)
935
+ # A table no database holds yet goes to the primary. Another database can carry the name "primary".
936
+ def on_primary?(table)
937
+ holders = Payload.table_holders(Payload.section(cached_context, :schema), table)
938
+ holders.empty? || holders.any? { |database, _, _| database.nil? }
924
939
  end
925
940
  end
926
941
  end
@@ -7,20 +7,18 @@ module RailsAiContext
7
7
  description "Get a narrative walkthrough of the Rails application - stack, data model, authentication, key flows, " \
8
8
  "background jobs, frontend, testing, getting started instructions, and the app's custom rake tasks, generators and Railties. " \
9
9
  "Use when: first encountering a project, onboarding a new developer, or orienting an AI agent. " \
10
- "Key params: detail (quick/standard/full)."
10
+ "Key params: detail (summary/standard/full)."
11
11
 
12
12
  input_schema(
13
13
  properties: {
14
- detail: {
15
- type: "string",
16
- enum: %w[quick standard full],
17
- description: "Detail level. quick: 1-paragraph overview. standard: structured walkthrough (default). full: all subsystems included."
18
- }
14
+ detail: RailsAiContext::DetailLevel.schema(
15
+ "Detail level. summary: 1-paragraph overview. standard: structured walkthrough (default). full: all subsystems included."
16
+ )
19
17
  }
20
18
  )
21
19
 
22
20
  guide_row(
23
- order: 37,
21
+ order: 32,
24
22
  mcp: "rails_onboard(detail:\"standard\")",
25
23
  cli_args: "detail=standard",
26
24
  summary: "Narrative app walkthrough for new developers or AI agents"
@@ -41,24 +39,20 @@ module RailsAiContext
41
39
  ctx = cached_context
42
40
 
43
41
  body = case detail
44
- when "quick"
45
- compose_quick(ctx)
46
- when "full"
42
+ when RailsAiContext::DetailLevel::SUMMARY
43
+ compose_summary(ctx)
44
+ when RailsAiContext::DetailLevel::FULL
47
45
  compose_full(ctx)
48
46
  else
49
47
  compose_standard(ctx)
50
48
  end
51
49
  text_response(body, suffix: introspection_warnings_note(ctx))
52
- rescue => e
53
- text_response("Onboard error: #{e.message}")
54
50
  end
55
51
 
56
52
  class << self
57
53
  private
58
54
 
59
- # ── Quick: single paragraph ──────────────────────────────────────
60
-
61
- def compose_quick(ctx)
55
+ def compose_summary(ctx)
62
56
  app = ctx[:app_name] || "This Rails app"
63
57
 
64
58
  ruby = ruby_phrase(ctx)
@@ -74,11 +68,15 @@ module RailsAiContext
74
68
  stats = []
75
69
  schema = Payload.section(ctx, :schema)
76
70
  if schema
77
- own = own_secondary_databases(ctx, schema)
71
+ unread, own = own_secondary_databases(ctx, schema).partition { |db| db[:unread] }
78
72
  table_count = schema[:total_tables].to_i + own.sum { |db| db[:tables] }
79
73
  if table_count > 0
80
74
  stats << count_phrase(table_count, "table") + (own.any? ? " in #{count_phrase(own.size + 1, "database")}" : "")
81
75
  end
76
+ if unread.any?
77
+ stats << "the #{unread.map { |db| db[:name] }.to_sentence(two_words_connector: " and ", last_word_connector: " and ")} " \
78
+ "#{unread.one? ? 'database' : 'databases'} not read"
79
+ end
82
80
  end
83
81
 
84
82
  models = Payload.models(ctx)
@@ -142,7 +140,7 @@ module RailsAiContext
142
140
  db = "#{adapter} (#{count_phrase(schema[:total_tables].to_i, 'table')})"
143
141
  # Every database the app's own dumps declare, as rails_get_schema reads them.
144
142
  own_secondary_databases(ctx, schema).each do |other|
145
- db += ", plus the #{other[:name]} database#{" on #{other[:adapter]}" if other[:adapter]} (#{count_phrase(other[:tables], 'table')})"
143
+ db += ", plus the #{other[:name]} database#{Payload.named_database_mark(ctx, other[:name])}#{" on #{other[:adapter]}" if other[:adapter]} (#{other[:unread] ? 'tables not read' : count_phrase(other[:tables], 'table')})"
146
144
  end
147
145
  elsif RailsAiContext::AppKind.mongoid?(rails_app.root)
148
146
  database = RailsAiContext::AppKind.mongoid_database(rails_app.root)
@@ -156,7 +154,11 @@ module RailsAiContext
156
154
  lines << "#{ctx[:app_name]} is a Rails#{" #{rails}" if rails} application#{ruby_clause(ctx)} #{db ? "on #{db}" : "without Active Record"}."
157
155
  solid = schema ? solid_databases(schema) : {}
158
156
  if solid.any?
159
- lines << "#{solid.values.uniq.to_sentence} keep#{"s" if solid.values.uniq.one?} their tables in databases of their own: #{solid.keys.join(", ")}."
157
+ frameworks = solid.values.uniq
158
+ keep, own = frameworks.one? ? [ "keeps its", "its own" ] : [ "keep their", "their own" ]
159
+ place = solid.one? ? "a database of #{own}" : "databases of #{own}"
160
+ names = solid.keys.map { |name| "#{name}#{Payload.named_database_mark(ctx, name)}" }
161
+ lines << "#{frameworks.to_sentence(last_word_connector: " and ")} #{keep} tables in #{place}: #{names.join(", ")}."
160
162
  end
161
163
  unread = unread_bundle(rails)
162
164
  lines << unread if unread
@@ -185,15 +187,13 @@ module RailsAiContext
185
187
  Payload.framework_databases(schema).to_h { |name, info| [ name.to_s, Payload.database_framework(info) ] }
186
188
  end
187
189
 
188
- # The app's own databases besides the primary, as its schema dumps
189
- # declare them: metrics71's analytics database on SQLite went
190
- # uncounted beside its PostgreSQL primary. Each is
191
- # { name:, adapter:, tables: }, the adapter nil when database.yml does not say.
190
+ # The app's own databases besides the primary, as its schema dumps declare them. The adapter is nil when
191
+ # database.yml does not say; `unread` marks a dump that could not be read, whose table count is no count.
192
192
  def own_secondary_databases(ctx, schema)
193
- configured = Array(Payload.section(ctx, :multi_database)&.dig(:databases)).grep(Hash).to_h { |d| [ d[:name].to_s, d ] }
193
+ configured = Payload.databases(ctx).to_h { |d| [ d[:name].to_s, d ] }
194
194
  Payload.app_databases(schema).map do |name, db|
195
195
  { name: name.to_s, adapter: RailsAiContext::SchemaAdapter.database_label(configured[name.to_s]),
196
- tables: (db[:total_tables] || db[:tables].size).to_i }
196
+ tables: (db[:total_tables] || db[:tables].size).to_i, unread: db[:unread] }
197
197
  end
198
198
  end
199
199
 
@@ -38,7 +38,7 @@ module RailsAiContext
38
38
  )
39
39
 
40
40
  guide_row(
41
- order: 26,
41
+ order: 23,
42
42
  mcp: "rails_performance_check(model:\"X\")",
43
43
  cli_args: "model=X",
44
44
  summary: "N+1 risks, missing indexes, Model.all anti-patterns"
@@ -72,6 +72,12 @@ module RailsAiContext
72
72
 
73
73
  lines << "**Total issues found:** #{filtered_count}"
74
74
  lines << ""
75
+ unchecked = Array(data[:unchecked_databases])
76
+ if unchecked.any?
77
+ said = unchecked.map { |db| "#{db[:name]} (#{[ Payload.named_scope_words(cached_context, db[:name]), db[:reason] ].compact.join(', ')})" }
78
+ lines << "**Not checked:** #{said.join(', ')}"
79
+ lines << ""
80
+ end
75
81
 
76
82
  if RailsAiContext::DetailLevel.summary?(detail)
77
83
  kept.each do |name, items|
@@ -163,6 +169,13 @@ module RailsAiContext
163
169
  " (#{item[:controller]}#{action}#{view})"
164
170
  end
165
171
 
172
+ # " (queue database)", and where only another environment configures it, " (queue database, production only)".
173
+ def database_tag(name)
174
+ return "" unless name
175
+
176
+ " (#{[ "#{name} database", Payload.named_scope_words(cached_context, name) ].compact.join(", ")})"
177
+ end
178
+
166
179
  def render_section(title, items, model_filter, detail)
167
180
  return [] unless items&.any?
168
181
 
@@ -172,7 +185,7 @@ module RailsAiContext
172
185
  lines = [ "## #{title} (#{filtered.size})", "" ]
173
186
 
174
187
  filtered.each do |item|
175
- lines << "- **#{item[:model] || item[:table] || "Unknown"}**"
188
+ lines << "- **#{item[:model] || item[:table] || "Unknown"}**#{database_tag(item[:database])}"
176
189
  lines << " #{item[:suggestion]}" if item[:suggestion]
177
190
  if RailsAiContext::DetailLevel.full?(detail)
178
191
  lines << " Controller: #{item[:controller]}" if item[:controller]