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
@@ -34,7 +34,7 @@ module RailsAiContext
34
34
  )
35
35
 
36
36
  guide_row(
37
- order: 33,
37
+ order: 28,
38
38
  mcp: "rails_read_logs(level:\"X\")",
39
39
  cli_args: "level=X",
40
40
  summary: "Reverse file tail with level filtering and sensitive data redaction"
@@ -27,7 +27,7 @@ module RailsAiContext
27
27
  )
28
28
 
29
29
  guide_row(
30
- order: 36,
30
+ order: 31,
31
31
  mcp: "rails_review_changes(ref:\"main\")",
32
32
  cli_args: "ref=main",
33
33
  summary: "PR/commit review: file context + warnings (missing indexes, removed validations)"
@@ -150,12 +150,11 @@ module RailsAiContext
150
150
  rb_files = classified.select { |c| c[:file].end_with?(".rb") }.map { |c| c[:file] }
151
151
  if rb_files.any?
152
152
  file_list = rb_files.first(10).map { |f| "\"#{f}\"" }.join(", ")
153
- lines << "_Next: `rails_validate(files:[#{file_list}], level:\"rails\")` to validate all changes._"
153
+ hint = next_hint(pointer("rails_validate", %(files:[#{file_list}], level:"rails"), "to validate all changes."))
154
+ lines << hint if hint
154
155
  end
155
156
 
156
157
  text_response(lines.join("\n"))
157
- rescue => e
158
- text_response("Review error: #{e.message}")
159
158
  end
160
159
 
161
160
  class << self
@@ -286,7 +285,7 @@ module RailsAiContext
286
285
  when :model
287
286
  model_name = File.basename(file, ".rb").camelize
288
287
  begin
289
- result = GetModelDetails.call(model: model_name, detail: "standard")
288
+ result = compose(GetModelDetails, model: model_name, detail: "standard")
290
289
  lines << "" << "**Model context:** #{model_name}" unless empty?(result)
291
290
  rescue => e; RailsAiContext.debug_fail(e, nil, label: "review_changes context lookup"); end
292
291
 
@@ -294,7 +293,7 @@ module RailsAiContext
294
293
  ctrl_name = File.basename(file, ".rb").camelize
295
294
  snake = ctrl_name.underscore.delete_suffix("_controller")
296
295
  begin
297
- result = GetRoutes.call(controller: snake, detail: "summary")
296
+ result = compose(GetRoutes, controller: snake, detail: "summary")
298
297
  lines << "" << "**Routes:**" << response_text(result) unless empty?(result)
299
298
  rescue => e; RailsAiContext.debug_fail(e, nil, label: "review_changes context lookup"); end
300
299
 
@@ -324,7 +323,7 @@ module RailsAiContext
324
323
 
325
324
  when :routes
326
325
  begin
327
- result = GetRoutes.call(detail: "summary")
326
+ result = compose(GetRoutes, detail: "summary")
328
327
  summary = response_text(result).lines.first&.strip&.sub(/\A#+\s*/, "")
329
328
  lines << "" << "**Current routes:** #{summary}" unless summary.to_s.empty?
330
329
  rescue => e; RailsAiContext.debug_fail(e, nil, label: "review_changes context lookup"); end
@@ -15,12 +15,16 @@ module RailsAiContext
15
15
  type: "string",
16
16
  enum: %w[database cache jobs connections],
17
17
  description: "Filter to one section. Omit for all sections."
18
+ },
19
+ database: {
20
+ type: "string",
21
+ description: "The app database whose table sizes and index usage to show, by its config/database.yml name. Default: the primary."
18
22
  }
19
23
  }
20
24
  )
21
25
 
22
26
  guide_row(
23
- order: 38,
27
+ order: 33,
24
28
  mcp: "rails_runtime_info(detail:\"standard\")",
25
29
  cli_args: "detail=standard",
26
30
  summary: "Live runtime: DB pool, table sizes, cache stats, job queues, pending migrations"
@@ -28,19 +32,21 @@ module RailsAiContext
28
32
 
29
33
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: false, open_world_hint: false)
30
34
 
31
- def self.call(detail: "standard", section: nil, server_context: nil)
35
+ def self.call(detail: "standard", section: nil, database: nil, server_context: nil)
32
36
  if (refusal = static_tier_refusal("Live runtime state"))
33
37
  return refusal
34
38
  end
39
+ named = !database.to_s.empty?
40
+ sections = section ? [ section ] : %w[connections database cache jobs]
41
+ # The database section leases the named database itself, and refuses one it cannot reach.
42
+ AppDatabases.lease(rails_app.root, database) { nil } if named && !sections.include?("database")
35
43
 
36
44
  lines = [ "# Runtime Info", "" ]
37
45
 
38
- sections = section ? [ section ] : %w[connections database cache jobs]
39
-
40
46
  sections.each do |s|
41
47
  result = case s
42
48
  when "connections" then gather_connection_pool
43
- when "database" then gather_database(detail)
49
+ when "database" then gather_database(detail, database)
44
50
  when "cache" then gather_cache
45
51
  when "jobs" then gather_jobs(detail)
46
52
  end
@@ -48,12 +54,15 @@ module RailsAiContext
48
54
  end
49
55
 
50
56
  if lines.size <= 2
51
- lines << "_No runtime data available. Ensure the app has a database connection._"
57
+ lines << "_No runtime data available: no section had anything to show._"
58
+ end
59
+ if section && section != "database" && named
60
+ lines << "_The #{section} section does not read `database`, so #{database} was not used._"
52
61
  end
53
62
 
54
63
  text_response(lines.join("\n"))
55
- rescue => e
56
- text_response("Runtime info error: #{e.message}")
64
+ rescue AppDatabases::Error => e
65
+ error_response(e.message)
57
66
  end
58
67
 
59
68
  class << self
@@ -91,17 +100,32 @@ module RailsAiContext
91
100
 
92
101
  # ── Database section ─────────────────────────────────────────────
93
102
 
94
- def gather_database(detail)
103
+ def gather_database(detail, database)
95
104
  return [ "## Database", "", "_ActiveRecord not available._", "" ] unless defined?(ActiveRecord::Base)
96
105
 
97
- conn = ActiveRecord::Base.connection
98
- adapter = conn.adapter_name.downcase
99
- lines = [ "## Database", "" ]
106
+ AppDatabases.lease(rails_app.root, database) { |conn| database_lines(conn, detail, database) }
107
+ rescue AppDatabases::Missing, AppDatabases::Unreachable => e
108
+ raise unless database.to_s.empty?
109
+
110
+ [ "## Database", "", "_Not available: #{e.message}_", "" ]
111
+ rescue AppDatabases::Error
112
+ raise
113
+ rescue => e
114
+ [ "## Database", "", "_Not available: #{e.message}_", "" ]
115
+ end
116
+
117
+ def database_lines(conn, detail, database)
118
+ family = DatabaseFamily.of(conn)
119
+ lines = [ database.to_s.empty? ? "## Database" : "## Database: #{database}", "" ]
100
120
  lines << "**Adapter:** #{SchemaAdapter.display(conn.adapter_name)}"
121
+ unless family.known?
122
+ lines << "" << "_Table sizes and index usage are not available for the #{family.name} adapter._"
123
+ end
101
124
 
102
- # Table sizes
103
- sizes = gather_table_sizes(conn, adapter)
104
- if sizes&.any?
125
+ sizes = gather_table_sizes(conn, family)
126
+ if sizes == :unread
127
+ lines << "" << "_Table sizes were not read: the read failed or took longer than query_timeout._"
128
+ elsif sizes&.any?
105
129
  lines << "" << "### Table Sizes"
106
130
  lines << "| Table | Size |"
107
131
  lines << "|-------|------|"
@@ -114,21 +138,16 @@ module RailsAiContext
114
138
  lines << "_#{count_phrase(sizes.size - shown, "more table")}..._" if sizes.size > shown
115
139
  end
116
140
 
117
- # Pending migrations
118
- pending = gather_pending_migrations
119
- if pending
120
- if pending.empty?
121
- lines << "" << "**Migrations:** all up to date"
122
- else
123
- lines << "" << "**Pending migrations:** #{pending.size}"
124
- pending.each { |m| lines << "- #{m[:version]} #{m[:name]}" }
125
- end
126
- end
141
+ lines.concat(database.to_s.empty? ? pending_migration_lines : own_pending_migration_lines(conn, database))
127
142
 
128
143
  # Index usage (full detail only)
129
144
  if RailsAiContext::DetailLevel.full?(detail)
130
- index_usage = gather_index_usage(conn, adapter)
131
- if index_usage&.any?
145
+ index_usage = gather_index_usage(conn, family)
146
+ if index_usage == :unread
147
+ lines << "" << "_Index usage was not read: the read failed or took longer than query_timeout._"
148
+ elsif index_usage.nil? && family.known?
149
+ lines << "" << "_Index usage is not available for the #{family.name} database family._"
150
+ elsif index_usage&.any?
132
151
  lines << "" << "### Index Usage"
133
152
  unused = index_usage.select { |i| i[:scans] == 0 }
134
153
  if unused.any?
@@ -144,8 +163,6 @@ module RailsAiContext
144
163
 
145
164
  lines << ""
146
165
  lines
147
- rescue => e
148
- [ "## Database", "", "_Not available: #{e.message}_", "" ]
149
166
  end
150
167
 
151
168
  # Rails-managed bookkeeping tables - real tables, but noise in a
@@ -153,72 +170,76 @@ module RailsAiContext
153
170
  # introspector, which also skips them).
154
171
  INTERNAL_TABLES = %w[schema_migrations ar_internal_metadata].freeze
155
172
 
156
- # Trilogy is Rails 8's default MySQL adapter (`adapter_name` reports
157
- # "Trilogy", not "Mysql2") - matching only /mysql/ silently drops
158
- # table sizes for every Trilogy app.
159
- MYSQL_ADAPTER = /mysql|trilogy/i
160
-
161
- # activerecord-postgis-adapter reports "PostGIS" for what is a PostgreSQL connection.
162
- POSTGRES_ADAPTER = /postg/i
163
-
164
- def gather_table_sizes(conn, adapter)
165
- rows =
166
- case adapter
167
- when POSTGRES_ADAPTER
168
- Introspectors::PgPartitions.table_bytes(conn).sort_by { |r| [ -r[:bytes], r[:name] ] }
169
- when MYSQL_ADAPTER
170
- sql = "SELECT table_name AS name, (data_length + index_length) AS bytes FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = DATABASE() ORDER BY bytes DESC"
171
- conn.select_all(sql).map { |r| { name: r["name"], bytes: r["bytes"].to_i } }
172
- when /sqlite/
173
- gather_sqlite_table_sizes(conn)
174
- end
175
- return nil unless rows
173
+ def gather_table_sizes(conn, family)
174
+ return nil unless family.known?
176
175
 
177
- rows.reject { |r| INTERNAL_TABLES.include?(r[:name]) }
176
+ AppDatabases.bounded(conn) { family.table_sizes(conn) }&.reject { |r| INTERNAL_TABLES.include?(r[:name]) }
178
177
  rescue => e
179
- RailsAiContext.debug_fail(e, nil, label: "gather_table_sizes")
178
+ RailsAiContext.debug_fail(e, :unread, label: "gather_table_sizes")
180
179
  end
181
180
 
182
- def gather_sqlite_table_sizes(conn)
183
- # dbstat lists every b-tree: user tables, their indexes, and SQLite
184
- # internals (sqlite_schema, sqlite_autoindex_*, sqlite_sequence).
185
- # Restrict to real application tables so indexes and internal objects
186
- # don't crowd out (or get mistaken for) the tables agents care about.
187
- app_tables = conn.select_values(
188
- "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
189
- ).to_set
190
-
191
- begin
192
- result = conn.select_all("SELECT name, SUM(pgsize) AS bytes FROM dbstat GROUP BY name ORDER BY bytes DESC")
193
- rows = result.filter_map do |r|
194
- { name: r["name"], bytes: r["bytes"].to_i } if app_tables.include?(r["name"])
195
- end
196
- return rows if rows.any?
181
+ # Every database Rails migrates here, each through its own connection.
182
+ def pending_migration_lines
183
+ here = AppDatabases.for(rails_app.root).here
184
+ primary = here.find(&:primary)
185
+ migrated = here.select(&:tasks)
186
+ return primary_pending_lines if primary.nil? || migrated == [ primary ]
187
+ return no_migrations_line(primary) if migrated.empty?
188
+
189
+ states = migrated.to_h { |db| [ db, db.pending_migrations ] }.reject { |_, state| state[:error].is_a?(AppDatabases::InMemory) }
190
+ read, unread = states.partition { |_, state| state[:pending] }
191
+ lines = pending_lines(read.to_h { |db, state| [ db.name, state[:pending] ] }, named: true)
192
+ unread.each { |db, state| lines << "" << "_Pending migrations could not be read for #{db.name}: #{state[:error].message.lines.first.to_s.strip}_" }
193
+ lines
194
+ end
195
+
196
+ # The app's one migrated database, read through the app's own connection.
197
+ def primary_pending_lines
198
+ dirs = RailsAiContext::PendingMigrations.migrate_dirs_for(rails_app.root)
199
+ pending = begin
200
+ AppDatabases.bounded(ActiveRecord::Base.connection) { RailsAiContext::PendingMigrations.live(dirs) }
197
201
  rescue => e
198
- RailsAiContext.debug_fail(e, label: "gather_sqlite_table_sizes (dbstat)")
202
+ RailsAiContext.debug_fail(e, nil, label: "primary_pending_lines")
199
203
  end
200
-
201
- # Fallback: whole-DB size split across user tables (dbstat unavailable).
202
- page_count = conn.select_value("PRAGMA page_count").to_i
203
- page_size = conn.select_value("PRAGMA page_size").to_i
204
- total = page_count * page_size
205
- tables = app_tables.to_a.sort
206
- tables.map { |t| { name: t, bytes: total / [ tables.size, 1 ].max } }
204
+ pending ? pending_lines({ "primary" => pending }) : []
207
205
  end
208
206
 
209
- def gather_pending_migrations
210
- RailsAiContext::PendingMigrations.live(RailsAiContext::PendingMigrations.migrate_dirs_for(rails_app.root))
207
+ # A named database's own, read through the connection the section leased.
208
+ def own_pending_migration_lines(conn, name)
209
+ db = AppDatabases.for(rails_app.root).fetch(name)
210
+ return no_migrations_line(db) unless db.tasks
211
+
212
+ dirs = db.primary ? RailsAiContext::PendingMigrations.migrate_dirs_for(rails_app.root) : db.migrate_dirs
213
+ pending_lines({ db.name => AppDatabases.bounded(conn) { RailsAiContext::MigrationStatus.pending_on(conn.pool, dirs) } })
214
+ rescue AppDatabases::Error
215
+ raise
216
+ rescue => e
217
+ [ "", "_Pending migrations could not be read for #{name}: #{e.message.lines.first.to_s.strip}_" ]
211
218
  end
212
219
 
213
- def gather_index_usage(conn, adapter)
214
- case adapter
215
- when POSTGRES_ADAPTER
216
- Introspectors::PgPartitions.index_stats(conn).sort_by { |i| [ i[:scans], i[:table], i[:index] ] }
217
- else
218
- nil
220
+ # found: each database that was read, by name, with its pending migrations. named: says which database each is in.
221
+ def pending_lines(found, named: false)
222
+ return [] if found.empty?
223
+
224
+ pending = found.flat_map { |name, list| list.map { |m| "- #{m[:version]} #{m[:name]}#{" (#{name})" if named}" } }
225
+ return [ "", "**Migrations:** all up to date#{" in #{found.keys.join(', ')}" if named}" ] if pending.empty?
226
+
227
+ counts = found.reject { |_, list| list.empty? }.map { |name, list| "#{list.size} in #{name}" }
228
+ total = if !named then pending.size
229
+ else counts.one? ? counts.first : "#{pending.size} (#{counts.join(', ')})"
219
230
  end
231
+ [ "", "**Pending migrations:** #{total}", *pending ]
232
+ end
233
+
234
+ def no_migrations_line(db)
235
+ reason = db.replica ? "it is a replica" : "its database.yml entry turns `database_tasks` off"
236
+ [ "", "_Rails runs no migrations on #{db.name}: #{reason}._" ]
237
+ end
238
+
239
+ def gather_index_usage(conn, family)
240
+ AppDatabases.bounded(conn) { family.index_usage(conn) } if family.known?
220
241
  rescue => e
221
- RailsAiContext.debug_fail(e, nil, label: "gather_index_usage")
242
+ RailsAiContext.debug_fail(e, :unread, label: "gather_index_usage")
222
243
  end
223
244
 
224
245
  # ── Cache section ────────────────────────────────────────────────
@@ -271,17 +292,33 @@ module RailsAiContext
271
292
  store = (env[:notable] || {})["cache_store"]
272
293
  next unless store && !store.match?(PROCESS_STORES)
273
294
 
274
- # The store's name; its options are rails_get_env_config's to show, unshortened.
295
+ # The store's name; its options are the env_config section's to show, unshortened.
275
296
  "- #{env[:name]}: `#{store[/\A:\w+/] || store}` (`#{env[:file]}`)"
276
297
  end
277
298
  end
278
299
 
300
+ # Solid Cache answers #stats from its databases, one per shard, and
301
+ # connecting to a SQLite file that is not there creates it.
302
+ def cache_database_missing(cache)
303
+ return unless defined?(::SolidCache::Store) && cache.is_a?(::SolidCache::Store)
304
+
305
+ cache.with_each_connection do
306
+ config = ::SolidCache::Record.connection_db_config
307
+ AppDatabases.present!(config.name, config)
308
+ end
309
+ nil
310
+ rescue AppDatabases::Missing, AppDatabases::Unreachable => e
311
+ e.message
312
+ end
313
+
279
314
  def gather_cache
280
315
  cache = Rails.cache
281
316
  lines = [ "## Cache", "" ]
282
- lines << "**Store:** #{cache.class.name.demodulize}, this MCP server process's `Rails.cache` (#{rails_env_name})"
317
+ lines << "**Store:** #{cache.class.name.delete_prefix("ActiveSupport::Cache::")}, this MCP server process's `Rails.cache` (#{rails_env_name})"
283
318
 
284
- if cache.respond_to?(:stats)
319
+ if (missing = cache_database_missing(cache))
320
+ lines << "_#{missing}_"
321
+ elsif cache.respond_to?(:stats)
285
322
  cache_stat_lines(cache.stats).each { |line| lines << line }
286
323
  elsif cache.respond_to?(:redis)
287
324
  begin
@@ -309,8 +346,8 @@ module RailsAiContext
309
346
  lines << "" << cache_scope(cache)
310
347
  elsewhere = configured_elsewhere
311
348
  if elsewhere.any?
312
- lines.push("", "**The app's cache in other environments:**", *elsewhere,
313
- "_`rails_get_env_config(environment:\"...\")` shows each one's options._")
349
+ options = pointer("rails_get_env_config", %(environment:"..."), "shows each one's options.")
350
+ lines.push("", "**The app's cache in other environments:**", *elsewhere, *("_#{options}_" if options))
314
351
  end
315
352
 
316
353
  lines << ""
@@ -13,6 +13,22 @@ module RailsAiContext
13
13
  # model's context instead, with enough detail to retry or route around
14
14
  # the failure.
15
15
  module SafeCall
16
+ # The content with the note ending its first text block, or the content
17
+ # itself when there is no note or no text to end.
18
+ def self.noted(content, note)
19
+ first = Array(content).first
20
+ return content unless note && first.is_a?(Hash) && first[:type] == "text"
21
+
22
+ body = first[:text].to_s
23
+ [ first.merge(text: body.strip.empty? ? note.lstrip : "#{body}#{note}") ] + content[1..].to_a
24
+ end
25
+
26
+ # The parameters tools/list shows, which for rails_get_config leaves out those of a section turned off.
27
+ def self.published_params(tool)
28
+ schema = tool.respond_to?(:input_schema_value) ? tool.input_schema_value : tool.input_schema
29
+ (schema&.to_h&.dig(:properties) || {}).keys.map(&:to_s)
30
+ end
31
+
16
32
  def call(**kwargs)
17
33
  # An MCP client sends whatever keys it likes. Reaching the tool with
18
34
  # one it does not declare answers with an unknown-keyword ArgumentError
@@ -27,10 +43,6 @@ module RailsAiContext
27
43
  # have its note consumed by the inner one.
28
44
  discarded = discarded_detail(kwargs)
29
45
  kwargs = listify_arrays(normalize_detail(kwargs))
30
- # Before the call reads anything: an edit since the last call drops
31
- # the caches and reloads the app's code (see the method).
32
- RailsAiContext::Tools::BaseTool.refresh_if_files_changed!
33
-
34
46
  answer = -> { answer_call(discarded) { super(**kwargs) } }
35
47
 
36
48
  # Only the call the client made is its query. A composite tool answers
@@ -39,14 +51,27 @@ module RailsAiContext
39
51
  # first inner call took the outer one's params with it.
40
52
  return answer.call if Thread.current[:rails_ai_context_call]
41
53
 
42
- Thread.current[:rails_ai_context_call] = { tool: self, params: session_params(kwargs) }
54
+ # Before the call reads anything: an edit since the last call drops
55
+ # the caches and reloads the app's code (see the method).
56
+ RailsAiContext::Tools::BaseTool.refresh_if_files_changed!
57
+ call = Thread.current[:rails_ai_context_call] = { tool: self, params: session_params(kwargs) }
43
58
  begin
44
- answer.call
59
+ with_crashes(answer.call, call[:crashed])
45
60
  ensure
46
61
  Thread.current[:rails_ai_context_call] = nil
47
62
  end
48
63
  end
49
64
 
65
+ def unknown_param_response(unknown, known = SafeCall.published_params(self))
66
+ lines = unknown.map do |name|
67
+ suggestion = RailsAiContext::Tools::BaseTool.find_closest_match(name, known)
68
+ suggestion ? "'#{name}' - did you mean '#{suggestion}'?" : "'#{name}'"
69
+ end
70
+ valid = known.any? ? "Valid params: #{known.join(', ')}" : "This tool takes no params."
71
+ text = "Unknown param#{"s" if unknown.size > 1}: #{lines.join(', ')}\n#{valid}"
72
+ MCP::Tool::Response.new([ { type: "text", text: text } ], error: true)
73
+ end
74
+
50
75
  private
51
76
 
52
77
  def answer_call(discarded)
@@ -61,16 +86,40 @@ module RailsAiContext
61
86
  RailsAiContext::CodeReloader.with_app_code do
62
87
  begin
63
88
  # One call is one run: what it reads per controller or per route is read once.
64
- append_note(RailsAiContext::RunCache.around { yield }, invalid_detail_note(discarded))
65
- # ScriptError too: a SyntaxError or LoadError out of an app file the
66
- # tool reached must cost that call, not the stdio server and every
67
- # tool with it.
68
- rescue StandardError, ScriptError => e
69
- failure_response(e)
89
+ response, left_out = with_left_out_frame { RailsAiContext::RunCache.around { yield } }
90
+ response = append_note(response, RailsAiContext::Tools::BaseTool.left_out_note(left_out))
91
+ append_note(response, invalid_detail_note(discarded))
92
+ # An app file that cannot load, a stack overflow or a SecurityError costs that call,
93
+ # where a StandardError rescue alone would let it end the stdio server and every tool with it.
94
+ rescue StandardError, ScriptError, SystemStackError, SecurityError => e
95
+ failure_response(e).tap do |failure|
96
+ call = Thread.current[:rails_ai_context_call]
97
+ (call[:crashed] ||= []) << failure.content.first[:text] if call
98
+ end
70
99
  end
71
100
  end
72
101
  end
73
102
 
103
+ # A tool this call ran crashed: the answer is an error, and it says so where its text left the crash out.
104
+ def with_crashes(response, crashes)
105
+ return response unless crashes && response.respond_to?(:error?) && !response.error?
106
+
107
+ text = response.content.first&.dig(:text).to_s
108
+ unsaid = crashes.uniq.reject { |failure| text.include?(failure.lines.first.strip) }
109
+ rebuilt(response, SafeCall.noted(response.content, ("\n\n#{unsaid.join("\n\n")}" if unsaid.any?)), error: true)
110
+ end
111
+
112
+ # The tools this call's composed sections left out, kept apart from
113
+ # those of any tool it calls.
114
+ def with_left_out_frame
115
+ stack = (Thread.current[RailsAiContext::Tools::BaseTool::LEFT_OUT] ||= [])
116
+ stack.push([])
117
+ response = yield
118
+ [ response, stack.last.uniq ]
119
+ ensure
120
+ stack.pop
121
+ end
122
+
74
123
  # The declared params are the contract, so a tool whose method takes
75
124
  # **kwargs refuses the same keys as one that names them.
76
125
  def unknown_params(kwargs)
@@ -80,17 +129,6 @@ module RailsAiContext
80
129
  RailsAiContext::Tools::BaseTool.unknown_param_names(kwargs.keys, properties)
81
130
  end
82
131
 
83
- def unknown_param_response(unknown)
84
- known = (input_schema.to_h[:properties] || {}).keys.map(&:to_s)
85
- lines = unknown.map do |name|
86
- suggestion = RailsAiContext::Tools::BaseTool.find_closest_match(name, known)
87
- suggestion ? "'#{name}' - did you mean '#{suggestion}'?" : "'#{name}'"
88
- end
89
- valid = known.any? ? "Valid params: #{known.join(', ')}" : "This tool takes no params."
90
- text = "Unknown param#{"s" if unknown.size > 1}: #{lines.join(', ')}\n#{valid}"
91
- MCP::Tool::Response.new([ { type: "text", text: text } ], error: true)
92
- end
93
-
94
132
  # A string where the schema declares an array is a list of one, and a
95
133
  # comma-separated one is the list the CLI documents (`a.rb,b.rb`): an MCP
96
134
  # client can send either, and review_changes answered a string with
@@ -108,6 +146,9 @@ module RailsAiContext
108
146
 
109
147
  def failure_response(error)
110
148
  label = respond_to?(:tool_name) ? tool_name : name
149
+ # A section renderer answers inside rails_get_config, the tool the caller named.
150
+ section = RailsAiContext::Tools::GetConfig.retired_section(label)
151
+ label = "rails_get_config (section #{section.name})" if section && section.renderer.equal?(self)
111
152
  origin = portable_origin(Array(error.backtrace).first.to_s)
112
153
  text = +"Tool #{label} failed: #{error.class}: #{error.message.to_s.lines.first&.strip}\n"
113
154
  text << "At: #{origin}\n" unless origin.empty?
@@ -161,12 +202,13 @@ module RailsAiContext
161
202
  def append_note(response, note)
162
203
  return response unless note && response.respond_to?(:content)
163
204
 
164
- content = response.content
165
- first = content.first
166
- return response unless first.is_a?(Hash) && first[:type] == "text"
205
+ noted = SafeCall.noted(response.content, note)
206
+ return response if noted.equal?(response.content)
167
207
 
168
- noted = [ first.merge(text: "#{first[:text]}#{note}") ] + content[1..].to_a
208
+ rebuilt(response, noted, error: response.error?)
209
+ end
169
210
 
211
+ def rebuilt(response, content, error:)
170
212
  # `meta` arrived in mcp 0.13, which is the gemspec floor, but the
171
213
  # reader is still asked for rather than assumed: getting it wrong
172
214
  # turns a good answer into a NoMethodError that SafeCall's own rescue
@@ -174,7 +216,7 @@ module RailsAiContext
174
216
  extra = { structured_content: response.structured_content }
175
217
  extra[:meta] = response.meta if response.respond_to?(:meta)
176
218
 
177
- MCP::Tool::Response.new(noted, error: response.error?, **extra)
219
+ MCP::Tool::Response.new(content, error: error, **extra)
178
220
  end
179
221
  end
180
222
  end
@@ -395,8 +395,6 @@ module RailsAiContext
395
395
  .reject { |r| sensitive_file?(r[:file]) || sensitive_in_tree?(r[:file], trees, root) }
396
396
  .first(max_results)
397
397
  [ rows, failed ]
398
- rescue => e
399
- [ [ { file: "error", line_number: 0, content: e.message } ], false ]
400
398
  end
401
399
 
402
400
  # rg opens sensitive files too, and one it cannot read is not an error
@@ -862,8 +860,6 @@ module RailsAiContext
862
860
  return definition_missing_response(lines.join("\n")) if def_results.empty?
863
861
 
864
862
  text_response(lines.join("\n"))
865
- rescue => e
866
- text_response("Trace error: #{e.message}")
867
863
  end
868
864
 
869
865
  SCOPE_WRAPPERS = %w[scope lambda proc].freeze
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+ require "json"
3
5
  require "net/http"
4
6
  require "uri"
5
7
 
@@ -37,7 +39,7 @@ module RailsAiContext
37
39
  )
38
40
 
39
41
  guide_row(
40
- order: 31,
42
+ order: 26,
41
43
  mcp: "rails_search_docs(query:\"X\")",
42
44
  cli_args: "query=X",
43
45
  summary: "Bundled topic index with weighted keyword search, on-demand GitHub fetch"
@@ -36,7 +36,7 @@ module RailsAiContext
36
36
  )
37
37
 
38
38
  guide_row(
39
- order: 24,
39
+ order: 21,
40
40
  mcp: "rails_security_scan",
41
41
  summary: "Brakeman static analysis: SQL injection, XSS, mass assignment"
42
42
  )
@@ -94,8 +94,12 @@ module RailsAiContext
94
94
  # can still answer - from outside the bundle, which is where it is.
95
95
  unbundled_scan(min_confidence, resolved_checks)
96
96
  end
97
- return text_response(unavailable_message(scan&.dig(:unavailable))) if scan.nil? || scan.key?(:unavailable)
98
- return text_response("Brakeman scan failed: #{portable_error(scan[:error])}") if scan[:error]
97
+ if scan.nil? || scan.key?(:unavailable)
98
+ # A run that said why it failed did start; one that could not start is brakeman not being there.
99
+ message = unavailable_message(scan&.dig(:unavailable))
100
+ return scan&.dig(:unavailable) ? error_response(message) : text_response(message)
101
+ end
102
+ return error_response("Brakeman scan failed: #{portable_error(scan[:error])}") if scan[:error]
99
103
 
100
104
  warnings = scan[:warnings]
101
105
  checks_run = scan[:checks_run]
@@ -147,7 +151,7 @@ module RailsAiContext
147
151
 
148
152
  report, failure = run_brakeman_unbundled(min_confidence, resolved_checks)
149
153
  unless report.is_a?(Hash) && report["warnings"].is_a?(Array)
150
- failure = echo_checks(failure, resolved_checks)
154
+ failure = echo_checks(report.nil? ? failure : "its report could not be read", resolved_checks)
151
155
  # A check name brakeman does not know is the caller's to fix, and
152
156
  # is answered as the in-process scan answers it: the Gemfile advice
153
157
  # an unavailable scan gets would send them to change nothing wrong.
@@ -311,7 +315,7 @@ module RailsAiContext
311
315
 
312
316
  _out, err = with_unbundled_env { capture_with_timeout(command) }
313
317
  next [ nil, "no report after #{SCAN_TIMEOUT} seconds" ] if err.nil?
314
- next [ nil, brakeman_error_line(err) ] unless File.file?(report_path) && File.size?(report_path)
318
+ next [ nil, brakeman_error_line(err) || "it wrote no report and printed nothing" ] unless File.file?(report_path) && File.size?(report_path)
315
319
 
316
320
  [ JSON.parse(File.read(report_path)), nil ]
317
321
  end