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
@@ -109,11 +109,12 @@ module RailsAiContext
109
109
  else
110
110
  return json_response(table_data.except(:unread_calls).merge({ database: database }.compact)) if format == "json"
111
111
 
112
- output = format_table_markdown(table_key, table_data, models_data, schema[:enum_types], schema[:search_path], listed_as: listed_as)
112
+ output = format_table_markdown(table_key, table_data, models_data, schema[:enum_types], schema[:search_path],
113
+ listed_as: listed_as, schema: schema, database: (databases.first if databases.size == 1))
113
114
  output = output.sub("\n\n", "\n\n**Database:** #{database}\n") if database
114
115
  end
115
116
  # Cross-reference hint for AI: suggest next tool call
116
- model_refs = models_for_table([ table_key, listed_as ], models_data)
117
+ model_refs = models_for_table([ table_key, listed_as ], models_data, schema: schema, database: (databases.first if databases.size == 1))
117
118
  if model_refs.any?
118
119
  output += "\n\n_Next: `rails_get_model_details(model:\"#{model_refs.first}\")` for associations, validations, scopes._"
119
120
  end
@@ -139,7 +140,7 @@ module RailsAiContext
139
140
  idx_count = data[:indexes]&.size || 0
140
141
  lines << "- **#{name}**#{relation_suffix(data)} - #{count_phrase(col_count, "column")}, #{count_phrase(idx_count, "index", plural: "indexes")}"
141
142
  end
142
- coverage = model_coverage_lines(tables, models_data)
143
+ coverage = model_coverage_lines(tables, models_data, schema)
143
144
  lines.concat([ "" ] + coverage) if coverage.any?
144
145
  lines.concat(secondary_databases_lines(schema))
145
146
  if page[:offset] + page[:limit] < total
@@ -150,7 +151,7 @@ module RailsAiContext
150
151
 
151
152
  when "standard"
152
153
  # Sort by column count (most complex first) - AI agents care about big tables first
153
- sorted = tables.keys.sort_by { |name| -(tables[name][:columns]&.size || 0) }
154
+ sorted = tables.keys.sort_by.with_index { |name, i| [ -(tables[name][:columns]&.size || 0), i ] }
154
155
  page = paginate(sorted, offset: offset, limit: limit, default_limit: 25)
155
156
  paginated = page[:items]
156
157
  return json_page_response(schema, tables, paginated, models_data) if format == "json"
@@ -195,7 +196,7 @@ module RailsAiContext
195
196
 
196
197
  # Detect encrypted columns from model data
197
198
  encrypted_cols = Set.new
198
- model_refs = models_for_table(name, models_data)
199
+ model_refs = models_for_table(name, models_data, schema: schema, database: "primary")
199
200
  model_refs.each do |model_name|
200
201
  (models_data.dig(model_name, :encrypts) || []).each { |f| encrypted_cols.add(f) }
201
202
  end
@@ -215,13 +216,17 @@ module RailsAiContext
215
216
  trailing.fetch(c[:name], []).uniq.each { |before| hints << "in index after #{before.join(', ')}" }
216
217
  end
217
218
  hints << "encrypted" if encrypted_cols.include?(c[:name])
219
+ # The database computes a generated column, so a write to it fails.
220
+ hints << (c[:stored] ? "generated, stored" : "generated") if c.key?(:generated)
218
221
  # Show default value if present
219
222
  if c.key?(:default) && !c[:default].nil? && c[:default] != ""
220
223
  hints << "default: #{c[:default]}"
221
224
  end
222
225
  # A clause can hold a column list, so clauses part with a semicolon.
223
226
  hint_str = hints.any? ? " [#{hints.join('; ')}]" : ""
224
- "#{[ c[:name], c[:type] ].compact.join(':')}#{hint_str}"
227
+ # An array column reads `string[]`, as the table view writes it.
228
+ type = c[:type] && c[:array] ? "#{c[:type]}[]" : c[:type]
229
+ "#{[ c[:name], type ].compact.join(':')}#{hint_str}"
225
230
  end.join(", ")
226
231
  # Inline model info so AI doesn't need a separate get_model_details call
227
232
  # Every model on the table, richest first: an STI child or a
@@ -247,9 +252,12 @@ module RailsAiContext
247
252
  lines << ""
248
253
  end
249
254
 
250
- coverage = model_coverage_lines(tables, models_data)
255
+ coverage = model_coverage_lines(tables, models_data, schema)
251
256
  lines.concat(coverage + [ "" ]) if coverage.any?
252
257
  lines.concat(secondary_databases_lines(schema))
258
+ if page[:offset] + page[:limit] < total
259
+ lines << "_Showing #{paginated.size} of #{total}. Use `offset:#{page[:offset] + page[:limit]}` for more._"
260
+ end
253
261
  lines << "_Use `detail:\"summary\"` for all #{Introspectors::SchemaConventions.relations_phrase(tables)}, `detail:\"full\"` for indexes/FKs, or `table:\"name\"` for one table._" if total > page[:limit]
254
262
  text_response(lines.join("\n"))
255
263
 
@@ -265,10 +273,10 @@ module RailsAiContext
265
273
  lines = [ "# Schema Full Detail (#{paginated.size} of #{Introspectors::SchemaConventions.relations_phrase(tables)})", "" ]
266
274
  lines.concat(note_lines(schema))
267
275
  paginated.each do |name|
268
- lines << format_table_markdown(name, tables[name], models_data, schema[:enum_types], schema[:search_path])
276
+ lines << format_table_markdown(name, tables[name], models_data, schema[:enum_types], schema[:search_path], schema: schema, database: "primary")
269
277
  lines << ""
270
278
  end
271
- coverage = model_coverage_lines(tables, models_data)
279
+ coverage = model_coverage_lines(tables, models_data, schema)
272
280
  lines.concat(coverage + [ "" ]) if coverage.any?
273
281
  lines.concat(secondary_databases_lines(schema))
274
282
  if page[:offset] + page[:limit] < total
@@ -343,8 +351,14 @@ module RailsAiContext
343
351
  RailsAiContext.debug_fail(e, Set.new, label: "declared_join_tables")
344
352
  end
345
353
 
346
- private_class_method def self.models_for_table(table_names, models)
347
- models.select { |_, d| d.is_a?(Hash) && Array(table_names).compact.include?(d[:table_name]) }.keys
354
+ # A table name is not a table: in a multi-database app the primary's
355
+ # `settings` and analytics' `settings` are two, and a model reads the one
356
+ # its database holds. `database` keeps the models that read that one.
357
+ private_class_method def self.models_for_table(table_names, models, schema: nil, database: nil)
358
+ models.select do |_, d|
359
+ d.is_a?(Hash) && Array(table_names).compact.include?(d[:table_name]) &&
360
+ (database.nil? || Payload.model_databases(schema, d).include?(database))
361
+ end.keys
348
362
  rescue => e
349
363
  RailsAiContext.debug_fail(e, [], label: "models_for_table")
350
364
  end
@@ -383,6 +397,11 @@ module RailsAiContext
383
397
  lines
384
398
  end
385
399
 
400
+ # The dump a static parse read, from its own note when nothing else names it.
401
+ private_class_method def self.dump_name(schema)
402
+ schema[:declared_in] || schema[:note].to_s[/\AParsed from (\S+)/, 1] || "db/schema.rb"
403
+ end
404
+
386
405
  private_class_method def self.static_source_lines(schema)
387
406
  lines = note_lines(schema)
388
407
  lines << "**Dialect:** #{schema[:dialect]}" if schema[:dialect] && schema[:dialect] != "unknown"
@@ -398,7 +417,14 @@ module RailsAiContext
398
417
  "#{connected_total}. Missing: #{missing.sort.first(5).join(', ')}" \
399
418
  "#{missing.size > 5 ? " (+#{missing.size - 5} more)" : ""}. Run `rails db:migrate`._"
400
419
  end
401
- if schema[:pending_migrations].is_a?(Array)
420
+ # A database that holds none of the tables the dump lists is created
421
+ # but not migrated, and the listing above is the dump's.
422
+ if schema[:connected_tables]&.zero? && (declared_total = Introspectors::SchemaConventions.table_count(schema[:tables] || {})).positive?
423
+ lines << "_#{dump_name(schema)} declares #{count_phrase(declared_total, "table")}; the connected database has none of them. Run `rails db:migrate`._"
424
+ end
425
+ if schema[:pending_unknown]
426
+ lines << "**Pending migrations:** not known: #{schema[:pending_unknown]}"
427
+ elsif schema[:pending_migrations].is_a?(Array)
402
428
  pending = schema[:pending_migrations]
403
429
  if pending.any?
404
430
  lines << "**Pending migrations:** #{pending_phrase(pending)}"
@@ -437,7 +463,7 @@ module RailsAiContext
437
463
  private_class_method def self.json_page_response(schema, tables, names, models = {})
438
464
  # unread_calls is the static reader's note to validate; the booted tier has none.
439
465
  page = names.to_h { |name| [ name, tables[name].is_a?(Hash) ? tables[name].except(:unread_calls) : tables[name] ] }
440
- coverage = model_coverage(tables, models)
466
+ coverage = model_coverage(tables, models, schema)
441
467
  json_response(schema.merge(tables: page, gem_owned_tables: coverage[:gems], tables_without_model_file: coverage[:unclaimed]))
442
468
  end
443
469
 
@@ -445,9 +471,11 @@ module RailsAiContext
445
471
 
446
472
  # Over every table, not the page: which ones no model file claims, and
447
473
  # of those, which a gem the app bundles owns.
448
- private_class_method def self.model_coverage(tables, models)
474
+ private_class_method def self.model_coverage(tables, models, schema = nil)
449
475
  # A view or virtual table often has no model and is still read, so only tables count.
450
- unclaimed = tables.keys.sort.select { |name| !tables[name][:kind] && models_for_table(name, models).empty? } - habtm_join_tables(models).to_a
476
+ database = ("primary" if schema)
477
+ unclaimed = tables.keys.sort.select { |name| !tables[name][:kind] && models_for_table(name, models, schema: schema, database: database).empty? } -
478
+ habtm_join_tables(models).to_a
451
479
  unclaimed -= declared_join_tables(models).to_a if unclaimed.any?
452
480
  # A file the walk could not read claims no table, but it is still a model file.
453
481
  unclaimed -= models.filter_map { |_, d| d[:table_name] || Introspectors::TableName.stem(d[:file]) if d.is_a?(Hash) && d[:error] && d[:file] }
@@ -455,8 +483,8 @@ module RailsAiContext
455
483
  { gems: gems, unclaimed: unclaimed - gems.values.flatten }
456
484
  end
457
485
 
458
- private_class_method def self.model_coverage_lines(tables, models)
459
- coverage = model_coverage(tables, models)
486
+ private_class_method def self.model_coverage_lines(tables, models, schema = nil)
487
+ coverage = model_coverage(tables, models, schema)
460
488
  lines = coverage[:gems].map { |gem, owned| "Tables the #{gem} gem owns: #{capped(owned)}" }
461
489
  unclaimed = coverage[:unclaimed]
462
490
  if unclaimed.any?
@@ -526,14 +554,19 @@ module RailsAiContext
526
554
  end
527
555
 
528
556
  # listed_as: the listing's name for a table asked for by its qualified name.
529
- private_class_method def self.format_table_markdown(name, data, models, enum_types = nil, search_path = nil, listed_as: nil)
557
+ private_class_method def self.format_table_markdown(name, data, models, enum_types = nil, search_path = nil, listed_as: nil, schema: nil, database: nil)
530
558
  columns = data[:columns] || []
531
559
  # Always show Nullable and Default - agents need these for migrations and validations
532
560
  has_defaults = columns.any? { |c| c.key?(:default) && !c[:default].nil? }
533
561
 
534
- model_refs = models_for_table([ name, listed_as ], models)
562
+ model_refs = models_for_table([ name, listed_as ], models, schema: schema, database: database)
535
563
  lines = [ "## #{RELATION_KINDS.fetch(data[:kind].to_s, "Table")}: #{name}", "" ]
536
564
  lines << "**Models:** #{model_refs.join(', ')}" if model_refs.any?
565
+ # The listing marks these [encrypted]; a column the database holds as
566
+ # ciphertext reads differently from what the model hands back.
567
+ column_names = columns.map { |c| c[:name].to_s }
568
+ encrypted = model_refs.flat_map { |m| Array(models.dig(m, :encrypts)).map(&:to_s) }.uniq & column_names
569
+ lines << "**Encrypted:** #{encrypted.map { |c| "`#{c}`" }.join(', ')} (`encrypts`: ciphertext in the database)" if encrypted.any?
537
570
  lines << "**Module:** #{data[:module]}" if data[:module]
538
571
  lines << "**Primary key:** #{RailsAiContext::Introspectors::SchemaConventions.primary_key_label(data[:primary_key])}" if data[:primary_key]
539
572
  lines << "**Comment:** #{data[:comment]}" if data[:comment]
@@ -597,34 +597,48 @@ module RailsAiContext
597
597
  end
598
598
 
599
599
  private_class_method def self.find_callers(class_name, real_root, own_file = nil)
600
- callers = Set.new
601
- search_dirs = caller_search_dirs(real_root)
600
+ callers, truncated = callers_of({ class_name => own_file }, real_root)
601
+ [ callers[class_name], truncated ]
602
+ end
603
+
604
+ # The files that name each service, from one pass over the tree:
605
+ # analyze_feature asks for every service a feature matched at once. The
606
+ # services' own files are not read.
607
+ #
608
+ # @param services [Hash{String => String, nil}] class name => the file defining it
609
+ # @return [Array(Hash{String => Array<String>}, Boolean)] the callers of each, by path,
610
+ # and whether the scan stopped at MAX_CALLER_SCAN_FILES
611
+ def self.callers_of(services, real_root)
602
612
  # A bare `include?` matched `Billing::Invoices::Create` inside
603
613
  # `Workers::Billing::Invoices::CreateReminderWorker`, and the
604
614
  # underscored-path skip dropped the one real caller, whose path
605
615
  # contains the service's own path as a prefix.
606
- reference = /(?<![\w:])(?:::)?#{Regexp.escape(class_name)}(?![\w:])/
616
+ references = services.keys.to_h { |name| [ name, /(?<![\w:])(?:::)?#{Regexp.escape(name)}(?![\w:])/ ] }
607
617
  # A second file declaring the same short name under its own namespace
608
618
  # is not a caller of this one, so the declaration is not a reference.
609
- definition = /\b(?:class|module)\s+(?:::)?#{Regexp.escape(class_name)}(?![\w:])/
619
+ definitions = services.keys.to_h { |name| [ name, /\b(?:class|module)\s+(?:::)?#{Regexp.escape(name)}(?![\w:])/ ] }
610
620
 
611
621
  # The paths first, so the ceiling is measured against the files there
612
622
  # are rather than the files read: a tree of exactly the cap skips
613
623
  # nothing and must not say it stopped.
614
- paths = search_dirs.flat_map { |dir| safe_glob(dir, "**/*.rb", real_root) }.uniq
615
- paths.reject! { |real| real == own_file } if own_file
624
+ paths = caller_search_dirs(real_root).flat_map { |dir| safe_glob(dir, "**/*.rb", real_root) }.uniq
625
+ paths -= services.values.compact
616
626
  truncated = paths.size > MAX_CALLER_SCAN_FILES
617
627
 
628
+ callers = services.keys.to_h { |name| [ name, [] ] }
618
629
  paths.first(MAX_CALLER_SCAN_FILES).each do |real|
619
630
  source = safe_read(real)
620
631
  next unless source
621
- next unless source.match?(reference)
622
- next unless source.gsub(definition, "").match?(reference)
623
632
 
624
- callers << real.sub("#{real_root}/", "")
633
+ references.each do |name, reference|
634
+ next unless source.match?(reference)
635
+ next unless source.gsub(definitions[name], "").match?(reference)
636
+
637
+ callers[name] << real.sub("#{real_root}/", "")
638
+ end
625
639
  end
626
640
 
627
- [ callers.to_a.sort, truncated ]
641
+ [ callers.transform_values(&:sort), truncated ]
628
642
  end
629
643
 
630
644
  private_class_method def self.detect_common_pattern(stats)
@@ -258,13 +258,49 @@ module RailsAiContext
258
258
  attrs << "data-#{html_name}-#{kebab}-value=\"...\""
259
259
  end
260
260
 
261
+ # A class or an outlet the controller reads throws "Missing attribute"
262
+ # without its attribute, so the markup carries one for each.
263
+ (ctrl[:classes] || []).each do |name|
264
+ attrs << "data-#{html_name}-#{name.to_s.gsub(/([a-z\d])([A-Z])/, '\1-\2').downcase}-class=\"...\""
265
+ end
266
+ (ctrl[:outlets] || []).each do |name|
267
+ attrs << "data-#{html_name}-#{name}-outlet=\"...\""
268
+ end
269
+
270
+ used = view_action_events(html_name)
271
+ own_calls = Array(ctrl[:self_calls])
261
272
  (ctrl[:actions] || []).each do |a|
262
- attrs << "data-action=\"click->#{html_name}##{a}\""
273
+ events = used[a]
274
+ if events
275
+ # The event the app's own markup wires the action to: a text field's
276
+ # `input->`, not the `click->` every action used to get.
277
+ events.each { |event| attrs << "data-action=\"#{"#{event}->" if event}#{html_name}##{a}\"" }
278
+ elsif !own_calls.include?(a)
279
+ # No event names the element's default one: click for a button,
280
+ # input for a text field, submit for a form.
281
+ attrs << "data-action=\"#{html_name}##{a}\""
282
+ end
283
+ # A method no markup names and the controller calls itself is a helper, not an action.
263
284
  end
264
285
 
265
286
  attrs
266
287
  end
267
288
 
289
+ # {action => the events the app's templates wire it to, nil for the
290
+ # element's default}, for one controller.
291
+ private_class_method def self.view_action_events(html_name)
292
+ root = rails_app.root.to_s
293
+ descriptor = /(?:([\w:.@+-]+)->)?(?<![\w-])#{Regexp.escape(html_name)}#(\w+)/
294
+ Introspectors::StimulusIntrospector.template_files(root).each_with_object({}) do |path, found|
295
+ raw = RailsAiContext::SafeFile.read(path) or next
296
+ next unless raw.include?("#{html_name}#")
297
+
298
+ raw.scan(descriptor) { |event, action| (found[action] ||= []) << event unless Array(found[action]).include?(event) }
299
+ end
300
+ rescue => e
301
+ RailsAiContext.debug_fail(e, {}, label: "view_action_events")
302
+ end
303
+
268
304
  # Views are named by their path under their views root, anything else from the app root.
269
305
  private_class_method def self.find_views_using(controller_name)
270
306
  root = rails_app.root.to_s
@@ -230,6 +230,10 @@ module RailsAiContext
230
230
  refused = refuse_unsafe_paths([ name ])
231
231
  return refused if refused
232
232
 
233
+ # Longer than any file name, so no test file carries it, and each
234
+ # searched path it was put in echoed it whole: a 600 KB answer.
235
+ return empty_response("No test file found for #{echo_input(name)}.") if name_too_long?(name)
236
+
233
237
  candidates = RailsAiContext::TestFramework.candidates(suite_root, type, snake, cached_context)
234
238
  exercising = type == :controller ? exercising_tests(snake, candidates) : []
235
239
 
@@ -246,7 +250,7 @@ module RailsAiContext
246
250
  listed = examples(content)
247
251
  "# #{shown(rel)} (#{count_phrase(listed.size, "test")})\n\n#{listed.join("\n")}"
248
252
  else
249
- "# #{shown(rel)}\n\n```ruby\n#{content}\n```"
253
+ "# #{shown(rel)}\n\n```ruby\n#{RailsAiContext::Redaction.redact_source(content, path: rel)}\n```"
250
254
  end
251
255
  return text_response(answer + exercising_section(exercising, "## Also exercised by"))
252
256
  end
@@ -264,7 +268,7 @@ module RailsAiContext
264
268
  # A refused candidate was never read, so listing it reads as a search
265
269
  # that happened. When every one was refused, the name is the answer.
266
270
  if contained.empty?
267
- return error_response("No test file found for #{name}: the name was refused, it leaves the app root " \
271
+ return error_response("Path not allowed: the test file for #{name} leaves the app root " \
268
272
  "or names a sensitive file.")
269
273
  end
270
274
 
@@ -402,7 +406,17 @@ module RailsAiContext
402
406
  lines << ""
403
407
  lines << "RSpec.describe ModelName, type: :model do"
404
408
  lines << " describe \"validations\" do"
405
- lines << " it { is_expected.to validate_presence_of(:field) }"
409
+ # validate_presence_of is shoulda-matchers', a NoMethodError in an
410
+ # app that does not bundle it; rails_generate_test asks the same.
411
+ if RailsAiContext::GemLock.for(rails_app.root.to_s).present?("shoulda-matchers")
412
+ lines << " it { is_expected.to validate_presence_of(:field) }"
413
+ else
414
+ lines << " it \"requires field\" do"
415
+ lines << " record = ModelName.new"
416
+ lines << " expect(record).not_to be_valid"
417
+ lines << " expect(record.errors[:field]).to include(\"can't be blank\")"
418
+ lines << " end"
419
+ end
406
420
  lines << " end"
407
421
  lines << ""
408
422
  lines << " describe \"#method_name\" do"
@@ -628,7 +642,7 @@ module RailsAiContext
628
642
 
629
643
  relationships
630
644
  rescue => e
631
- {}
645
+ RailsAiContext.debug_fail(e, {}, label: "extract_fixture_relationships")
632
646
  end
633
647
  end
634
648
  end