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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1024 -0
- data/CONTEXT.md +37 -7
- data/CONTRIBUTING.md +30 -1
- data/README.md +9 -9
- data/SECURITY.md +2 -2
- data/docs/ARCHITECTURE.md +12 -4
- data/docs/CLI.md +44 -15
- data/docs/COMPATIBILITY.md +34 -8
- data/docs/CONFIGURATION.md +28 -5
- data/docs/CUSTOM_TOOLS.md +8 -2
- data/docs/FAQ.md +5 -5
- data/docs/GUIDE.md +33 -65
- data/docs/INTROSPECTORS.md +4 -4
- data/docs/QUICKSTART.md +1 -1
- data/docs/RECIPES.md +5 -5
- data/docs/RELEASING.md +3 -1
- data/docs/SECURITY.md +255 -74
- data/docs/SETUP.md +1 -1
- data/docs/STANDALONE.md +2 -2
- data/docs/TOOLS.md +151 -90
- data/docs/TROUBLESHOOTING.md +123 -8
- data/docs/_config.yml +1 -1
- data/docs/adr/0008-query-runs-only-where-it-can-guard.md +38 -0
- data/docs/adr/0009-dbhub-shapes-in-ruby.md +26 -0
- data/docs/adr/0010-one-config-tool-with-sections.md +32 -0
- data/docs/index.md +2 -2
- data/exe/rails-ai-context +24 -9
- data/lib/generators/rails_ai_context/install/install_generator.rb +2 -0
- data/lib/rails_ai_context/app_databases.rb +377 -0
- data/lib/rails_ai_context/app_kind.rb +2 -0
- data/lib/rails_ai_context/cli/app_root.rb +1 -0
- data/lib/rails_ai_context/cli/tool_runner.rb +110 -99
- data/lib/rails_ai_context/configuration.rb +15 -8
- data/lib/rails_ai_context/database_family/mysql_runtime.rb +170 -0
- data/lib/rails_ai_context/database_family/postgresql_plan.rb +124 -0
- data/lib/rails_ai_context/database_family/postgresql_runtime.rb +211 -0
- data/lib/rails_ai_context/database_family/sqlite_runtime.rb +391 -0
- data/lib/rails_ai_context/database_family.rb +282 -0
- data/lib/rails_ai_context/database_file.rb +61 -7
- data/lib/rails_ai_context/database_yml.rb +7 -29
- data/lib/rails_ai_context/doctor.rb +15 -16
- data/lib/rails_ai_context/instrumentation.rb +1 -1
- data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +8 -23
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +6 -70
- data/lib/rails_ai_context/introspectors/declared_constant.rb +19 -7
- data/lib/rails_ai_context/introspectors/env_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_references.rb +1 -1
- data/lib/rails_ai_context/introspectors/job_introspector.rb +7 -7
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +8 -11
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +9 -9
- data/lib/rails_ai_context/introspectors/model_introspector.rb +33 -39
- data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +33 -30
- data/lib/rails_ai_context/introspectors/node_source.rb +2 -0
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +80 -24
- data/lib/rails_ai_context/introspectors/pg_partitions.rb +1 -4
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +6 -8
- data/lib/rails_ai_context/introspectors/schema_dump_path.rb +12 -14
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +47 -27
- data/lib/rails_ai_context/introspectors/schema_reader.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_scan.rb +39 -4
- data/lib/rails_ai_context/migration_status.rb +18 -46
- data/lib/rails_ai_context/payload.rb +48 -1
- data/lib/rails_ai_context/pending_migrations.rb +3 -4
- data/lib/rails_ai_context/run_cache.rb +5 -0
- data/lib/rails_ai_context/schema_adapter.rb +13 -18
- data/lib/rails_ai_context/secret_columns.rb +506 -0
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -2
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +14 -10
- data/lib/rails_ai_context/serializers/section_facts.rb +12 -2
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +5 -2
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +35 -7
- data/lib/rails_ai_context/server.rb +143 -29
- data/lib/rails_ai_context/sql_gate.rb +1007 -0
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -2
- data/lib/rails_ai_context/test_helper.rb +3 -1
- data/lib/rails_ai_context/tools/analyze_feature.rb +11 -7
- data/lib/rails_ai_context/tools/base_tool.rb +71 -48
- data/lib/rails_ai_context/tools/dependency_graph.rb +1 -1
- data/lib/rails_ai_context/tools/diagnose.rb +105 -42
- data/lib/rails_ai_context/tools/generate_test.rb +4 -6
- data/lib/rails_ai_context/tools/get_active_support.rb +1 -6
- data/lib/rails_ai_context/tools/get_api.rb +1 -6
- data/lib/rails_ai_context/tools/get_autoload.rb +1 -6
- data/lib/rails_ai_context/tools/get_callbacks.rb +5 -5
- data/lib/rails_ai_context/tools/get_component_catalog.rb +6 -7
- data/lib/rails_ai_context/tools/get_concern.rb +6 -6
- data/lib/rails_ai_context/tools/get_config.rb +189 -12
- data/lib/rails_ai_context/tools/get_context.rb +111 -105
- data/lib/rails_ai_context/tools/get_controllers.rb +8 -5
- data/lib/rails_ai_context/tools/get_conventions.rb +1 -6
- data/lib/rails_ai_context/tools/get_engines.rb +1 -6
- data/lib/rails_ai_context/tools/get_env.rb +1 -6
- data/lib/rails_ai_context/tools/get_env_config.rb +2 -8
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +1 -6
- data/lib/rails_ai_context/tools/get_gems.rb +1 -6
- data/lib/rails_ai_context/tools/get_helper_methods.rb +4 -5
- data/lib/rails_ai_context/tools/get_i18n.rb +1 -7
- data/lib/rails_ai_context/tools/get_job_pattern.rb +1 -1
- data/lib/rails_ai_context/tools/get_mailers.rb +1 -1
- data/lib/rails_ai_context/tools/get_model_details.rb +5 -8
- data/lib/rails_ai_context/tools/get_partial_interface.rb +3 -4
- data/lib/rails_ai_context/tools/get_schema.rb +10 -8
- data/lib/rails_ai_context/tools/get_service_pattern.rb +60 -24
- data/lib/rails_ai_context/tools/get_turbo_map.rb +1 -1
- data/lib/rails_ai_context/tools/migration_advisor.rb +47 -32
- data/lib/rails_ai_context/tools/onboard.rb +24 -24
- data/lib/rails_ai_context/tools/performance_check.rb +15 -2
- data/lib/rails_ai_context/tools/query.rb +142 -1356
- data/lib/rails_ai_context/tools/read_logs.rb +1 -1
- data/lib/rails_ai_context/tools/review_changes.rb +6 -7
- data/lib/rails_ai_context/tools/runtime_info.rb +125 -88
- data/lib/rails_ai_context/tools/safe_call.rb +70 -28
- data/lib/rails_ai_context/tools/search_code.rb +0 -4
- data/lib/rails_ai_context/tools/search_docs.rb +3 -1
- data/lib/rails_ai_context/tools/security_scan.rb +9 -5
- data/lib/rails_ai_context/tools/session_context.rb +18 -12
- data/lib/rails_ai_context/tools/validate.rb +1 -0
- data/lib/rails_ai_context/tools/validate_semantics.rb +5 -5
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context.rb +4 -7
- data/server.json +2 -2
- 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
|
-
|
|
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 << "-
|
|
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
|
-
.
|
|
51
|
-
|
|
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 =
|
|
75
|
-
.
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
229
|
+
records.map { |r| r[:entryless] && mixins.include?(r[:class_name]) ? r.merge(left_out: :mixin) : r }
|
|
222
230
|
end
|
|
223
231
|
|
|
224
|
-
|
|
225
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
@@ -43,7 +43,7 @@ module RailsAiContext
|
|
|
43
43
|
)
|
|
44
44
|
|
|
45
45
|
guide_row(
|
|
46
|
-
order:
|
|
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
|
|
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
|
|
328
|
+
return [ [], nil ] if on_primary?(table)
|
|
329
329
|
|
|
330
|
-
yml = RailsAiContext::DatabaseYml
|
|
331
330
|
root = rails_app.root
|
|
332
|
-
|
|
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
|
-
|
|
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 #{
|
|
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
|
|
401
|
-
#
|
|
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 <<
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
912
|
-
#
|
|
913
|
-
|
|
914
|
-
|
|
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
|
-
|
|
919
|
-
|
|
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
|
-
|
|
923
|
-
|
|
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 (
|
|
10
|
+
"Key params: detail (summary/standard/full)."
|
|
11
11
|
|
|
12
12
|
input_schema(
|
|
13
13
|
properties: {
|
|
14
|
-
detail:
|
|
15
|
-
|
|
16
|
-
|
|
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:
|
|
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
|
|
45
|
-
|
|
46
|
-
when
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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 =
|
|
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:
|
|
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]
|