rails-ai-context 5.32.2 → 5.34.0

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 (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1491 -0
  3. data/CONTEXT.md +27 -3
  4. data/README.md +16 -12
  5. data/Rakefile +6 -0
  6. data/app/controllers/rails_ai_context/mcp_controller.rb +112 -84
  7. data/config/routes.rb +2 -3
  8. data/docs/ARCHITECTURE.md +7 -5
  9. data/docs/CLI.md +79 -6
  10. data/docs/COMPATIBILITY.md +75 -1
  11. data/docs/CONFIGURATION.md +30 -11
  12. data/docs/CUSTOM_TOOLS.md +2 -1
  13. data/docs/FAQ.md +16 -6
  14. data/docs/GUIDE.md +117 -71
  15. data/docs/INTROSPECTORS.md +10 -8
  16. data/docs/QUICKSTART.md +3 -3
  17. data/docs/RECIPES.md +1 -1
  18. data/docs/SECURITY.md +109 -25
  19. data/docs/SETUP.md +30 -6
  20. data/docs/STANDALONE.md +10 -3
  21. data/docs/TOOLS.md +152 -34
  22. data/docs/TROUBLESHOOTING.md +90 -12
  23. data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
  24. data/docs/adr/0005-workspace-mcp-entries.md +39 -0
  25. data/exe/rails-ai-context +733 -104
  26. data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
  27. data/lib/rails_ai_context/app_kind.rb +18 -5
  28. data/lib/rails_ai_context/ast_cache.rb +7 -0
  29. data/lib/rails_ai_context/base_mixins.rb +8 -8
  30. data/lib/rails_ai_context/boot_manager.rb +22 -3
  31. data/lib/rails_ai_context/change_watch.rb +14 -11
  32. data/lib/rails_ai_context/cli/app_root.rb +401 -0
  33. data/lib/rails_ai_context/cli/entry_boot.rb +58 -22
  34. data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
  35. data/lib/rails_ai_context/code_reloader.rb +115 -1
  36. data/lib/rails_ai_context/concern_membership.rb +3 -1
  37. data/lib/rails_ai_context/concern_paths.rb +8 -3
  38. data/lib/rails_ai_context/configuration.rb +41 -9
  39. data/lib/rails_ai_context/context_file_report.rb +19 -2
  40. data/lib/rails_ai_context/database_file.rb +26 -0
  41. data/lib/rails_ai_context/doctor.rb +1122 -184
  42. data/lib/rails_ai_context/engine.rb +18 -1
  43. data/lib/rails_ai_context/erb_source.rb +19 -0
  44. data/lib/rails_ai_context/executable.rb +21 -0
  45. data/lib/rails_ai_context/facts_formatter.rb +5 -2
  46. data/lib/rails_ai_context/file_walk.rb +61 -10
  47. data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
  48. data/lib/rails_ai_context/fingerprinter.rb +40 -12
  49. data/lib/rails_ai_context/gem_lock.rb +186 -27
  50. data/lib/rails_ai_context/git_ignore.rb +22 -0
  51. data/lib/rails_ai_context/install/ai_tool.rb +31 -5
  52. data/lib/rails_ai_context/install/cleanup.rb +21 -0
  53. data/lib/rails_ai_context/install/program.rb +209 -35
  54. data/lib/rails_ai_context/install/selection_record.rb +258 -97
  55. data/lib/rails_ai_context/install/surface.rb +6 -1
  56. data/lib/rails_ai_context/install/validation_hook.rb +282 -0
  57. data/lib/rails_ai_context/install/workspace.rb +166 -0
  58. data/lib/rails_ai_context/install_mode.rb +71 -13
  59. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
  60. data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
  61. data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
  62. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
  63. data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
  64. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
  65. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
  66. data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
  67. data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
  68. data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
  69. data/lib/rails_ai_context/introspectors/auth_introspector.rb +15 -10
  70. data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
  71. data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
  72. data/lib/rails_ai_context/introspectors/controller_introspector.rb +387 -60
  73. data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
  74. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +15 -3
  75. data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
  76. data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
  77. data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
  78. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
  79. data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
  80. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
  81. data/lib/rails_ai_context/introspectors/gem_introspector.rb +23 -10
  82. data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
  83. data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
  84. data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
  85. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -3
  86. data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
  87. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
  88. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +78 -4
  89. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
  90. data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
  91. data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
  92. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +13 -4
  93. data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
  94. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
  95. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +29 -8
  96. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
  97. data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
  98. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
  99. data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
  100. data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
  101. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
  102. data/lib/rails_ai_context/introspectors/migration_replay.rb +15 -14
  103. data/lib/rails_ai_context/introspectors/model_introspector.rb +231 -23
  104. data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
  105. data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
  106. data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
  107. data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
  108. data/lib/rails_ai_context/introspectors/schema_introspector.rb +66 -16
  109. data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
  110. data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
  111. data/lib/rails_ai_context/introspectors/source_scan.rb +39 -9
  112. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
  113. data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
  114. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +131 -12
  115. data/lib/rails_ai_context/introspectors/view_introspector.rb +4 -4
  116. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
  117. data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
  118. data/lib/rails_ai_context/live_reload.rb +20 -20
  119. data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
  120. data/lib/rails_ai_context/mcp_edge.rb +182 -32
  121. data/lib/rails_ai_context/middleware.rb +5 -1
  122. data/lib/rails_ai_context/migration_status.rb +88 -25
  123. data/lib/rails_ai_context/output_guard.rb +48 -2
  124. data/lib/rails_ai_context/package_json.rb +1 -1
  125. data/lib/rails_ai_context/path_resolver.rb +46 -13
  126. data/lib/rails_ai_context/payload.rb +92 -8
  127. data/lib/rails_ai_context/pending_migrations.rb +5 -1
  128. data/lib/rails_ai_context/portable_path.rb +3 -2
  129. data/lib/rails_ai_context/presets.rb +15 -11
  130. data/lib/rails_ai_context/redaction.rb +262 -51
  131. data/lib/rails_ai_context/resources.rb +55 -20
  132. data/lib/rails_ai_context/route_coverage.rb +8 -0
  133. data/lib/rails_ai_context/safe_file.rb +14 -1
  134. data/lib/rails_ai_context/safe_path.rb +101 -4
  135. data/lib/rails_ai_context/schema_adapter.rb +8 -0
  136. data/lib/rails_ai_context/schema_version.rb +9 -0
  137. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +88 -33
  138. data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
  139. data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
  140. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
  141. data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
  142. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +19 -16
  143. data/lib/rails_ai_context/serializers/guide_examples.rb +142 -0
  144. data/lib/rails_ai_context/serializers/markdown_serializer.rb +146 -30
  145. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +38 -15
  146. data/lib/rails_ai_context/serializers/section_facts.rb +44 -1
  147. data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
  148. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
  149. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
  150. data/lib/rails_ai_context/server.rb +304 -71
  151. data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -372
  152. data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
  153. data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
  154. data/lib/rails_ai_context/tools/base_tool.rb +367 -46
  155. data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
  156. data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
  157. data/lib/rails_ai_context/tools/diagnose.rb +218 -11
  158. data/lib/rails_ai_context/tools/generate_test.rb +339 -39
  159. data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
  160. data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
  161. data/lib/rails_ai_context/tools/get_concern.rb +91 -27
  162. data/lib/rails_ai_context/tools/get_config.rb +13 -4
  163. data/lib/rails_ai_context/tools/get_context.rb +71 -14
  164. data/lib/rails_ai_context/tools/get_controllers.rb +179 -44
  165. data/lib/rails_ai_context/tools/get_conventions.rb +28 -6
  166. data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
  167. data/lib/rails_ai_context/tools/get_env.rb +18 -9
  168. data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
  169. data/lib/rails_ai_context/tools/get_gems.rb +1 -1
  170. data/lib/rails_ai_context/tools/get_helper_methods.rb +10 -6
  171. data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
  172. data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -2
  173. data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
  174. data/lib/rails_ai_context/tools/get_model_details.rb +30 -10
  175. data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
  176. data/lib/rails_ai_context/tools/get_routes.rb +29 -8
  177. data/lib/rails_ai_context/tools/get_schema.rb +52 -19
  178. data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
  179. data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
  180. data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
  181. data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
  182. data/lib/rails_ai_context/tools/get_view.rb +14 -14
  183. data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
  184. data/lib/rails_ai_context/tools/onboard.rb +52 -8
  185. data/lib/rails_ai_context/tools/performance_check.rb +5 -3
  186. data/lib/rails_ai_context/tools/query.rb +914 -86
  187. data/lib/rails_ai_context/tools/read_logs.rb +69 -15
  188. data/lib/rails_ai_context/tools/review_changes.rb +215 -53
  189. data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
  190. data/lib/rails_ai_context/tools/safe_call.rb +35 -14
  191. data/lib/rails_ai_context/tools/search_code.rb +243 -67
  192. data/lib/rails_ai_context/tools/search_docs.rb +4 -4
  193. data/lib/rails_ai_context/tools/security_scan.rb +143 -33
  194. data/lib/rails_ai_context/tools/session_context.rb +39 -5
  195. data/lib/rails_ai_context/tools/validate.rb +259 -40
  196. data/lib/rails_ai_context/tools/validate_semantics.rb +130 -56
  197. data/lib/rails_ai_context/version.rb +1 -1
  198. data/lib/rails_ai_context/vfs.rb +67 -40
  199. data/lib/rails_ai_context/view_file.rb +41 -3
  200. data/lib/rails_ai_context/watcher.rb +51 -13
  201. data/lib/rails_ai_context.rb +21 -0
  202. data/server.json +1 -1
  203. metadata +14 -1
@@ -40,6 +40,8 @@ module RailsAiContext
40
40
  # the filesystem. Tool output notes when the cap is hit so the AI agent knows
41
41
  # to narrow its feature keyword. v5.8.1 hardening; all glob sites in r2.
42
42
  MAX_SCAN_FILES = 500
43
+ # The callers named under each service; rails_get_service_pattern(service:) lists more.
44
+ CALLERS_SHOWN = 5
43
45
 
44
46
  # The directories rspec-rails and minitest file tests under by kind.
45
47
  SUITE_TYPE_DIRS = %w[
@@ -51,6 +53,11 @@ module RailsAiContext
51
53
  def self.call(feature:, server_context: nil)
52
54
  feature = feature.to_s.strip
53
55
  return text_response("Please provide a feature keyword (e.g. 'post', 'payment', 'authentication').") if feature.empty?
56
+ # Every layer word-matches the keyword against every name it holds, so
57
+ # a 100 KB keyword took ten seconds to match nothing.
58
+ if name_too_long?(feature)
59
+ return empty_response("No matches found for '#{echo_input(feature)}'. A feature keyword is a word or two, such as 'payment'.")
60
+ end
54
61
  ctx = cached_context
55
62
  pattern = feature.downcase
56
63
  root = rails_app.root.to_s
@@ -64,14 +71,14 @@ module RailsAiContext
64
71
  discover_jobs(ctx, pattern, lines)
65
72
  discover_views(ctx, root, pattern, lines)
66
73
  discover_stimulus(ctx, pattern, lines)
67
- test_files, tests_truncated = discover_tests(root, pattern, lines)
74
+ _feature_tests, all_tests, tests_truncated = discover_tests(root, pattern, lines)
68
75
  discover_related_models(ctx, matched_models, lines)
69
76
  discover_concerns(ctx, matched_models, lines)
70
77
  discover_callbacks(ctx, matched_models, lines)
71
78
  discover_channels(ctx, pattern, lines)
72
79
  discover_mailers(ctx, pattern, lines)
73
80
  discover_env_dependencies(root, pattern, matched_models, lines)
74
- discover_test_gaps(root, pattern, matched_models, ctx, test_files || [], lines, truncated: tests_truncated)
81
+ discover_test_gaps(root, pattern, matched_models, ctx, all_tests || [], lines, truncated: tests_truncated)
75
82
  discover_components(ctx, pattern, lines)
76
83
 
77
84
  # For auth-related keywords, also discover auth gems
@@ -95,7 +102,7 @@ module RailsAiContext
95
102
  else
96
103
  "\n\nThis app has no models yet, so there are no feature names to suggest."
97
104
  end
98
- return text_response("No matches found for '#{feature}'. No models, controllers, routes, services, or views match this keyword.#{hint}")
105
+ return text_response("No matches found for '#{echo_input(feature)}'. No models, controllers, routes, services, or views match this keyword.#{hint}")
99
106
  end
100
107
 
101
108
  text_response(lines.join("\n"))
@@ -184,9 +191,11 @@ module RailsAiContext
184
191
  lines << "**Enums:** #{enum_strs.join('; ')}"
185
192
  end
186
193
  end
194
+ # Only after a section: an empty one left a blank line per layer,
195
+ # four in a row under the heading of a feature with no models.
196
+ lines << ""
187
197
  end
188
198
 
189
- lines << ""
190
199
  matched
191
200
  end
192
201
 
@@ -218,8 +227,8 @@ module RailsAiContext
218
227
  lines << "- **Skipped filters:** #{split[:skipped].map { |n| "~~#{n}~~" }.join(', ')}"
219
228
  end
220
229
  end
230
+ lines << ""
221
231
  end
222
- lines << ""
223
232
  end
224
233
 
225
234
  # --- AF: Routes ---
@@ -242,8 +251,8 @@ module RailsAiContext
242
251
  lines << "- `#{r[:verb]}` `#{r[:path]}` → #{ctrl}##{r[:action]}#{helper}"
243
252
  end
244
253
  end
254
+ lines << ""
245
255
  end
246
- lines << ""
247
256
  end
248
257
 
249
258
  # --- AF1: Services ---
@@ -252,6 +261,7 @@ module RailsAiContext
252
261
  return unless Dir.exist?(dir)
253
262
 
254
263
  real_root = File.realpath(root).to_s
264
+ real_dir = File.realpath(dir).to_s
255
265
  candidates = safe_glob(dir, "**/*.rb", real_root).first(MAX_SCAN_FILES)
256
266
  # Prefer basename match (fast, no file read) and only fall back to
257
267
  # a content scan for files whose basename doesn't match. This
@@ -262,20 +272,46 @@ module RailsAiContext
262
272
  end
263
273
  return if found.empty?
264
274
 
265
- lines << "## Services (#{found.size}#{candidates.size == MAX_SCAN_FILES ? " - first #{MAX_SCAN_FILES} scanned" : ""})"
266
- found.each do |path|
267
- relative = path.sub("#{real_root}/", "")
275
+ services = found.filter_map do |path|
268
276
  source = RailsAiContext::SafeFile.read(path) or next
269
- line_count = source.lines.size
270
- methods = source.scan(/^\s*def (?:self\.)?(\w+)/m).flatten.reject { |m| m == "initialize" }
271
- lines << "- `#{relative}` (#{count_phrase(line_count, "line")})"
277
+ # The path carries the namespace Zeitwerk expects, as rails_get_service_pattern reads it.
278
+ constant = path.delete_prefix("#{real_dir}/").delete_suffix(".rb").camelize
279
+ { path: path, source: source, name: Introspectors::ServiceClasses.declaration(source, constant).first }
280
+ end
281
+ # A feature's services are usually run from controllers its name does
282
+ # not match: CheckoutService from two OrdersControllers.
283
+ named = services.select { |s| s[:name] }
284
+ callers, callers_truncated = GetServicePattern.callers_of(named.to_h { |s| [ s[:name], s[:path] ] }, real_root)
285
+
286
+ lines << "## Services (#{found.size}#{candidates.size == MAX_SCAN_FILES ? " - first #{MAX_SCAN_FILES} scanned" : ""})"
287
+ services.each do |service|
288
+ relative = service[:path].sub("#{real_root}/", "")
289
+ methods = service[:name] ? service_methods(service[:source], service[:name]) : []
290
+ lines << "- `#{relative}` (#{count_phrase(service[:source].lines.size, "line")})"
272
291
  lines << " Methods: #{methods.first(20).join(', ')}" if methods.any?
292
+ called_by = callers.fetch(service[:name], [])
293
+ next if called_by.empty?
294
+
295
+ more = called_by.size > CALLERS_SHOWN ? " and #{called_by.size - CALLERS_SHOWN} more" : ""
296
+ lines << " Called by: #{called_by.first(CALLERS_SHOWN).map { |c| "`#{c}`" }.join(', ')}#{more}"
297
+ end
298
+ if callers_truncated
299
+ lines << "_The caller scan stopped after #{count_phrase(GetServicePattern::MAX_CALLER_SCAN_FILES, "file")}._"
273
300
  end
274
301
  lines << ""
275
302
  rescue => e
276
303
  RailsAiContext.debug_fail(e, nil, label: "discover_services")
277
304
  end
278
305
 
306
+ # The service class's own public methods, by the names Ruby calls
307
+ # them: a line scan listed `success` for CheckoutService, read off the
308
+ # `success?` of the Result struct nested in it.
309
+ def service_methods(source, name)
310
+ Introspectors::ActionResolver.own_methods_in(source, name)
311
+ .select { |m| m[:visibility] == :public }
312
+ .map { |m| m[:scope] == :class ? "self.#{m[:name]}" : m[:name].to_s }
313
+ end
314
+
279
315
  # --- AF2: Jobs ---
280
316
  def discover_jobs(ctx, pattern, lines)
281
317
  payload_section(Payload.jobs(ctx), pattern, lines, "Jobs") do |job|
@@ -285,6 +321,16 @@ module RailsAiContext
285
321
  bits << "perform(#{job[:perform_signature]})" if job[:perform_signature]
286
322
  bits.any? ? " (#{bits.join(', ')})" : ""
287
323
  end
324
+ # A Sidekiq worker is not an ActiveJob job, and in an app that runs
325
+ # its background work that way it is the only job there is.
326
+ payload_section(Payload.workers(ctx), pattern, lines, "Sidekiq Workers") do |worker|
327
+ bits = []
328
+ queue = (worker[:options] || {}).find { |key, _| key.to_s == "queue" }&.last
329
+ bits << "queue: #{queue}" if queue
330
+ bits << Array(worker[:retries]).join("; ") if Array(worker[:retries]).any?
331
+ bits << "#{worker[:entry_point] || "perform"}(#{worker[:perform_signature]})" if worker[:perform_signature]
332
+ bits.any? ? " (#{bits.join(', ')})" : ""
333
+ end
288
334
  end
289
335
 
290
336
  # The jobs, mailers and channels the :jobs introspector read, matched
@@ -386,9 +432,13 @@ module RailsAiContext
386
432
  relative.sub(%r{(\A|/)(?:spec|test)_}, "\\1")
387
433
  end
388
434
 
435
+ # @return [Array(Array<String>, Array<String>, Boolean)] the tests the
436
+ # feature word names, every test read (relative to the suite's root),
437
+ # and whether a glob stopped at MAX_SCAN_FILES
389
438
  def discover_tests(root, pattern, lines)
390
439
  real_root = File.realpath(root).to_s
391
440
  found = []
441
+ all = []
392
442
  truncated = false
393
443
 
394
444
  base = RailsAiContext::PathResolver.test_root(root)
@@ -409,9 +459,11 @@ module RailsAiContext
409
459
  prefix_glob.each do |path|
410
460
  found << path if feature_word_match?(relative_test_name(path, real_base), pattern)
411
461
  end
462
+ all.concat(suffix_glob, prefix_glob)
412
463
  end
413
464
  found.uniq!
414
- return [ found, truncated ] if found.empty?
465
+ all = all.uniq.map { |path| path.delete_prefix("#{real_base}/") }
466
+ return [ found, all, truncated ] if found.empty?
415
467
 
416
468
  header = "## Tests (#{found.size}#{truncated ? " - first #{MAX_SCAN_FILES} per glob scanned" : ""})"
417
469
  lines << header
@@ -424,9 +476,9 @@ module RailsAiContext
424
476
  lines << "- `#{relative}` (#{count_phrase(test_count, "test")})"
425
477
  end
426
478
  lines << ""
427
- [ found, truncated ]
479
+ [ found, all, truncated ]
428
480
  rescue => e
429
- RailsAiContext.debug_fail(e, [ [], false ], label: "discover_tests")
481
+ RailsAiContext.debug_fail(e, [ [], [], false ], label: "discover_tests")
430
482
  end
431
483
 
432
484
  # --- Test coverage gaps ---
@@ -441,8 +493,10 @@ module RailsAiContext
441
493
  # Paths, not basenames: a namespaced name underscores to admin/badges,
442
494
  # which no basename can contain, so every namespaced controller was
443
495
  # reported as having no test - Discourse's Admin::BadgesController
444
- # against spec/requests/admin/badges_controller_spec.rb.
445
- test_basenames = test_files.map { |f| f.to_s.sub(/\.rb\z/, "").sub("#{root}/", "") }
496
+ # against spec/requests/admin/badges_controller_spec.rb. Every test
497
+ # in the suite, not only those the feature word names: the auth rule
498
+ # matches User for `authentication`, a word its user_test.rb lacks.
499
+ test_basenames = test_files.map { |f| f.to_s.sub(/\.rb\z/, "") }
446
500
 
447
501
  # Check models
448
502
  matched_models&.each do |name, _data|
@@ -544,7 +598,7 @@ module RailsAiContext
544
598
  next unless data.is_a?(Hash)
545
599
  (data[:callbacks] || {}).each do |type, methods|
546
600
  next unless methods.is_a?(Array)
547
- methods.each { |m| callbacks << "#{name}: #{type} #{callback_target(m)}" }
601
+ methods.each { |m| callbacks << "#{name}: #{callback_type_label(type)} #{callback_target(m)}" }
548
602
  end
549
603
  end
550
604
  return if callbacks.empty?
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module RailsAiContext
6
+ module Tools
7
+ # json_schemer, which mcp 1.x checks tool arguments with, words each error
8
+ # through I18n when the process has I18n, after asking it once whether
9
+ # json_schemer's own translations exist. Asking loads every locale file
10
+ # the app has, so where one does not parse, the question raised on every
11
+ # invalid argument (the answer is only kept when there is one) and the
12
+ # client got an internal error, stderr a stack trace, instead of
13
+ # "Invalid arguments: ...". When it raises, json_schemer is given the
14
+ # answer an app without its translations gets, and the check runs again.
15
+ class ArgumentSchema < MCP::Tool::InputSchema
16
+ def validate_arguments(arguments)
17
+ super
18
+ rescue ValidationError
19
+ raise
20
+ rescue StandardError => e
21
+ raise unless locale_failure?(e) && words_without_i18n!
22
+
23
+ super
24
+ end
25
+
26
+ private
27
+
28
+ # Only an error out of I18n loading the app's locale files: the answer
29
+ # is process-wide, so any other failure leaves json_schemer as it is.
30
+ # A YAML locale that does not parse raises I18n::InvalidLocaleData; a
31
+ # Ruby one raises its own error from inside I18n's loader.
32
+ def locale_failure?(error)
33
+ return true if defined?(::I18n::ArgumentError) && error.is_a?(::I18n::ArgumentError)
34
+
35
+ Array(error.backtrace).any? { |line| line.match?(%r{/i18n-[^/]+/lib/i18n/}) }
36
+ end
37
+
38
+ # Once: a check that fails the same way again has another cause. The
39
+ # answer is kept on the JSONSchemer module, where Result#i18n? reads it.
40
+ def words_without_i18n!
41
+ return false unless defined?(::JSONSchemer::Result) && ::JSONSchemer::Result.method_defined?(:i18n?)
42
+ return false if ::JSONSchemer.class_variable_defined?(:@@i18n) && ::JSONSchemer.class_variable_get(:@@i18n) == false
43
+
44
+ ::JSONSchemer.class_variable_set(:@@i18n, false)
45
+ true
46
+ end
47
+ end
48
+ end
49
+ end