rails-ai-context 5.30.2 → 5.32.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 (222) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +868 -0
  3. data/CONTEXT.md +7 -3
  4. data/README.md +1 -1
  5. data/docs/ARCHITECTURE.md +3 -3
  6. data/docs/CLI.md +1 -1
  7. data/docs/COMPATIBILITY.md +50 -14
  8. data/docs/CONFIGURATION.md +4 -1
  9. data/docs/FAQ.md +1 -1
  10. data/docs/GUIDE.md +14 -8
  11. data/docs/INTROSPECTORS.md +50 -23
  12. data/docs/SECURITY.md +56 -1
  13. data/docs/TOOLS.md +58 -12
  14. data/lib/generators/rails_ai_context/install/install_generator.rb +1 -0
  15. data/lib/rails_ai_context/action_filters.rb +121 -48
  16. data/lib/rails_ai_context/app_commands.rb +2 -2
  17. data/lib/rails_ai_context/app_kind.rb +65 -8
  18. data/lib/rails_ai_context/ast_cache.rb +53 -18
  19. data/lib/rails_ai_context/base_mixins.rb +46 -16
  20. data/lib/rails_ai_context/boot_manager.rb +5 -1
  21. data/lib/rails_ai_context/cli/entry_boot.rb +17 -2
  22. data/lib/rails_ai_context/cli/tool_runner.rb +15 -1
  23. data/lib/rails_ai_context/concern_macros.rb +791 -111
  24. data/lib/rails_ai_context/concern_membership.rb +25 -5
  25. data/lib/rails_ai_context/concern_paths.rb +63 -17
  26. data/lib/rails_ai_context/config_yaml.rb +40 -0
  27. data/lib/rails_ai_context/configuration.rb +3 -3
  28. data/lib/rails_ai_context/data/docs/index.json +324 -0
  29. data/lib/rails_ai_context/database_yml.rb +136 -0
  30. data/lib/rails_ai_context/doctor.rb +30 -37
  31. data/lib/rails_ai_context/erb_source.rb +40 -0
  32. data/lib/rails_ai_context/fingerprinter.rb +48 -22
  33. data/lib/rails_ai_context/fixture_keys.rb +42 -1
  34. data/lib/rails_ai_context/gem_lock.rb +275 -47
  35. data/lib/rails_ai_context/git_ignore.rb +13 -11
  36. data/lib/rails_ai_context/hydrators/hydration_formatter.rb +8 -2
  37. data/lib/rails_ai_context/hydrators/schema_hint_builder.rb +10 -5
  38. data/lib/rails_ai_context/introspector.rb +10 -1
  39. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +27 -19
  40. data/lib/rails_ai_context/introspectors/action_presence.rb +11 -8
  41. data/lib/rails_ai_context/introspectors/action_resolver.rb +38 -26
  42. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +2 -2
  43. data/lib/rails_ai_context/introspectors/active_record_settings.rb +56 -0
  44. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -1
  45. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +98 -22
  46. data/lib/rails_ai_context/introspectors/admin_resources.rb +101 -0
  47. data/lib/rails_ai_context/introspectors/anyway_configs.rb +64 -0
  48. data/lib/rails_ai_context/introspectors/apartment_config.rb +47 -0
  49. data/lib/rails_ai_context/introspectors/api_introspector.rb +125 -26
  50. data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +24 -19
  51. data/lib/rails_ai_context/introspectors/ast_walk.rb +18 -0
  52. data/lib/rails_ai_context/introspectors/auth_introspector.rb +71 -18
  53. data/lib/rails_ai_context/introspectors/autoload_introspector.rb +62 -15
  54. data/lib/rails_ai_context/introspectors/base.rb +38 -0
  55. data/lib/rails_ai_context/introspectors/call_site_expansion.rb +349 -39
  56. data/lib/rails_ai_context/introspectors/component_introspector.rb +26 -14
  57. data/lib/rails_ai_context/introspectors/config_introspector.rb +22 -9
  58. data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +1 -4
  59. data/lib/rails_ai_context/introspectors/controller_filters.rb +335 -31
  60. data/lib/rails_ai_context/introspectors/controller_introspector.rb +315 -129
  61. data/lib/rails_ai_context/introspectors/controller_settings.rb +182 -0
  62. data/lib/rails_ai_context/introspectors/convention_introspector.rb +18 -15
  63. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +1 -1
  64. data/lib/rails_ai_context/introspectors/declared_constant.rb +108 -23
  65. data/lib/rails_ai_context/introspectors/eager_load.rb +12 -7
  66. data/lib/rails_ai_context/introspectors/engine_introspector.rb +13 -5
  67. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +62 -17
  68. data/lib/rails_ai_context/introspectors/env_introspector.rb +8 -6
  69. data/lib/rails_ai_context/introspectors/env_references.rb +21 -4
  70. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +37 -22
  71. data/lib/rails_ai_context/introspectors/gem_introspector.rb +3 -1
  72. data/lib/rails_ai_context/introspectors/gemfile_gems.rb +53 -5
  73. data/lib/rails_ai_context/introspectors/grape_endpoints.rb +109 -0
  74. data/lib/rails_ai_context/introspectors/helper_names.rb +12 -1
  75. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +101 -46
  76. data/lib/rails_ai_context/introspectors/includers.rb +6 -1
  77. data/lib/rails_ai_context/introspectors/initializer_introspector.rb +2 -5
  78. data/lib/rails_ai_context/introspectors/job_introspector.rb +548 -103
  79. data/lib/rails_ai_context/introspectors/listener_registration.rb +11 -1
  80. data/lib/rails_ai_context/introspectors/listeners/associations_listener.rb +51 -4
  81. data/lib/rails_ai_context/introspectors/listeners/autoload_ignore_listener.rb +31 -0
  82. data/lib/rails_ai_context/introspectors/listeners/autoload_paths_listener.rb +4 -47
  83. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +80 -14
  84. data/lib/rails_ai_context/introspectors/listeners/branch_conditions.rb +156 -0
  85. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +85 -23
  86. data/lib/rails_ai_context/introspectors/listeners/class_definition_listener.rb +35 -6
  87. data/lib/rails_ai_context/introspectors/listeners/component_structure_listener.rb +30 -2
  88. data/lib/rails_ai_context/introspectors/listeners/conditional_macro_listener.rb +19 -0
  89. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +255 -10
  90. data/lib/rails_ai_context/introspectors/listeners/constant_reference_listener.rb +34 -0
  91. data/lib/rails_ai_context/introspectors/listeners/constructor_macro_listener.rb +123 -0
  92. data/lib/rails_ai_context/introspectors/listeners/definition_file_paths_listener.rb +66 -0
  93. data/lib/rails_ai_context/introspectors/listeners/enums_listener.rb +2 -2
  94. data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +31 -0
  95. data/lib/rails_ai_context/introspectors/listeners/filter_macro_listener.rb +54 -0
  96. data/lib/rails_ai_context/introspectors/listeners/fixture_paths_listener.rb +62 -0
  97. data/lib/rails_ai_context/introspectors/listeners/gemfile_dsl_listener.rb +42 -1
  98. data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +28 -2
  99. data/lib/rails_ai_context/introspectors/listeners/grape_api_listener.rb +137 -0
  100. data/lib/rails_ai_context/introspectors/listeners/http_client_call_listener.rb +50 -0
  101. data/lib/rails_ai_context/introspectors/listeners/i18n_load_path_listener.rb +54 -0
  102. data/lib/rails_ai_context/introspectors/listeners/literal_paths.rb +123 -0
  103. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +248 -4
  104. data/lib/rails_ai_context/introspectors/listeners/mailbox_routing_listener.rb +2 -1
  105. data/lib/rails_ai_context/introspectors/listeners/method_call_listener.rb +7 -1
  106. data/lib/rails_ai_context/introspectors/listeners/methods_listener.rb +352 -69
  107. data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +1 -1
  108. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +35 -26
  109. data/lib/rails_ai_context/introspectors/listeners/mixins_listener.rb +62 -29
  110. data/lib/rails_ai_context/introspectors/listeners/mount_listener.rb +8 -0
  111. data/lib/rails_ai_context/introspectors/listeners/namespaced_roots_listener.rb +35 -0
  112. data/lib/rails_ai_context/introspectors/listeners/nested_constants_listener.rb +32 -0
  113. data/lib/rails_ai_context/introspectors/listeners/owner_scope.rb +67 -0
  114. data/lib/rails_ai_context/introspectors/listeners/preview_paths_listener.rb +16 -7
  115. data/lib/rails_ai_context/introspectors/listeners/proc_literal_listener.rb +42 -0
  116. data/lib/rails_ai_context/introspectors/listeners/queue_assignment_listener.rb +54 -0
  117. data/lib/rails_ai_context/introspectors/listeners/rake_task_dsl_listener.rb +21 -8
  118. data/lib/rails_ai_context/introspectors/listeners/route_files_listener.rb +4 -11
  119. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +461 -65
  120. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +76 -14
  121. data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +40 -1
  122. data/lib/rails_ai_context/introspectors/listeners/validations_listener.rb +10 -1
  123. data/lib/rails_ai_context/introspectors/listeners/view_paths_listener.rb +37 -0
  124. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +47 -3
  125. data/lib/rails_ai_context/introspectors/migration_introspector.rb +3 -3
  126. data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +4 -3
  127. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +72 -12
  128. data/lib/rails_ai_context/introspectors/migration_replay.rb +110 -14
  129. data/lib/rails_ai_context/introspectors/model_introspector.rb +708 -214
  130. data/lib/rails_ai_context/introspectors/module_aliases.rb +11 -3
  131. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +9 -57
  132. data/lib/rails_ai_context/introspectors/observability_introspector.rb +5 -8
  133. data/lib/rails_ai_context/introspectors/performance_introspector.rb +28 -8
  134. data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +118 -51
  135. data/lib/rails_ai_context/introspectors/recurring_schedules.rb +150 -0
  136. data/lib/rails_ai_context/introspectors/rendered_record.rb +87 -0
  137. data/lib/rails_ai_context/introspectors/retry_policy.rb +8 -5
  138. data/lib/rails_ai_context/introspectors/route_introspector.rb +153 -42
  139. data/lib/rails_ai_context/introspectors/schema_conventions.rb +127 -40
  140. data/lib/rails_ai_context/introspectors/schema_dump_path.rb +80 -0
  141. data/lib/rails_ai_context/introspectors/schema_introspector.rb +346 -112
  142. data/lib/rails_ai_context/introspectors/schema_reader.rb +69 -29
  143. data/lib/rails_ai_context/introspectors/service_classes.rb +21 -15
  144. data/lib/rails_ai_context/introspectors/source_calls.rb +1 -1
  145. data/lib/rails_ai_context/introspectors/source_introspector.rb +17 -2
  146. data/lib/rails_ai_context/introspectors/source_scan.rb +174 -26
  147. data/lib/rails_ai_context/introspectors/sqlite_virtual_tables.rb +28 -0
  148. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +16 -5
  149. data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +372 -98
  150. data/lib/rails_ai_context/introspectors/superclass_chain.rb +14 -15
  151. data/lib/rails_ai_context/introspectors/table_name.rb +46 -45
  152. data/lib/rails_ai_context/introspectors/test_introspector.rb +289 -78
  153. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +10 -5
  154. data/lib/rails_ai_context/introspectors/view_introspector.rb +5 -40
  155. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +12 -10
  156. data/lib/rails_ai_context/method_name.rb +31 -0
  157. data/lib/rails_ai_context/migration_status.rb +5 -5
  158. data/lib/rails_ai_context/output_guard.rb +6 -4
  159. data/lib/rails_ai_context/package_json.rb +96 -5
  160. data/lib/rails_ai_context/path_resolver.rb +170 -15
  161. data/lib/rails_ai_context/payload.rb +48 -0
  162. data/lib/rails_ai_context/pending_migrations.rb +15 -18
  163. data/lib/rails_ai_context/portable_path.rb +76 -4
  164. data/lib/rails_ai_context/redaction.rb +8 -2
  165. data/lib/rails_ai_context/route_coverage.rb +5 -1
  166. data/lib/rails_ai_context/safe_file.rb +4 -1
  167. data/lib/rails_ai_context/safe_path.rb +61 -10
  168. data/lib/rails_ai_context/schema_hint.rb +8 -2
  169. data/lib/rails_ai_context/schema_version.rb +7 -9
  170. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
  171. data/lib/rails_ai_context/serializers/markdown_serializer.rb +13 -4
  172. data/lib/rails_ai_context/serializers/section_facts.rb +5 -1
  173. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +2 -2
  174. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +7 -4
  175. data/lib/rails_ai_context/server.rb +26 -7
  176. data/lib/rails_ai_context/test_framework.rb +32 -5
  177. data/lib/rails_ai_context/tools/analyze_feature.rb +22 -10
  178. data/lib/rails_ai_context/tools/base_tool.rb +69 -30
  179. data/lib/rails_ai_context/tools/diagnose.rb +1 -1
  180. data/lib/rails_ai_context/tools/generate_test.rb +8 -9
  181. data/lib/rails_ai_context/tools/get_active_support.rb +19 -4
  182. data/lib/rails_ai_context/tools/get_api.rb +17 -11
  183. data/lib/rails_ai_context/tools/get_autoload.rb +10 -5
  184. data/lib/rails_ai_context/tools/get_callbacks.rb +65 -14
  185. data/lib/rails_ai_context/tools/get_component_catalog.rb +7 -4
  186. data/lib/rails_ai_context/tools/get_concern.rb +31 -18
  187. data/lib/rails_ai_context/tools/get_config.rb +52 -18
  188. data/lib/rails_ai_context/tools/get_controllers.rb +57 -13
  189. data/lib/rails_ai_context/tools/get_conventions.rb +44 -22
  190. data/lib/rails_ai_context/tools/get_engines.rb +2 -0
  191. data/lib/rails_ai_context/tools/get_env.rb +209 -52
  192. data/lib/rails_ai_context/tools/get_env_config.rb +38 -7
  193. data/lib/rails_ai_context/tools/get_frontend_stack.rb +56 -7
  194. data/lib/rails_ai_context/tools/get_helper_methods.rb +136 -26
  195. data/lib/rails_ai_context/tools/get_job_pattern.rb +143 -87
  196. data/lib/rails_ai_context/tools/get_mailers.rb +59 -2
  197. data/lib/rails_ai_context/tools/get_model_details.rb +178 -28
  198. data/lib/rails_ai_context/tools/get_partial_interface.rb +101 -62
  199. data/lib/rails_ai_context/tools/get_routes.rb +35 -9
  200. data/lib/rails_ai_context/tools/get_schema.rb +160 -61
  201. data/lib/rails_ai_context/tools/get_service_pattern.rb +121 -25
  202. data/lib/rails_ai_context/tools/get_stimulus.rb +1 -1
  203. data/lib/rails_ai_context/tools/get_test_info.rb +152 -82
  204. data/lib/rails_ai_context/tools/get_view.rb +37 -20
  205. data/lib/rails_ai_context/tools/migration_advisor.rb +64 -24
  206. data/lib/rails_ai_context/tools/onboard.rb +174 -88
  207. data/lib/rails_ai_context/tools/performance_check.rb +5 -13
  208. data/lib/rails_ai_context/tools/query.rb +122 -28
  209. data/lib/rails_ai_context/tools/read_logs.rb +45 -17
  210. data/lib/rails_ai_context/tools/runtime_info.rb +1 -1
  211. data/lib/rails_ai_context/tools/safe_call.rb +20 -15
  212. data/lib/rails_ai_context/tools/search_code.rb +114 -40
  213. data/lib/rails_ai_context/tools/search_docs.rb +22 -11
  214. data/lib/rails_ai_context/tools/security_scan.rb +51 -4
  215. data/lib/rails_ai_context/tools/validate.rb +21 -4
  216. data/lib/rails_ai_context/tools/validate_semantics.rb +18 -6
  217. data/lib/rails_ai_context/version.rb +1 -1
  218. data/lib/rails_ai_context/vfs.rb +2 -2
  219. data/lib/rails_ai_context/view_file.rb +53 -3
  220. data/lib/rails_ai_context.rb +10 -2
  221. data/server.json +1 -1
  222. metadata +34 -3
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "prism"
4
+
3
5
  module RailsAiContext
4
6
  module Tools
5
7
  class GetPartialInterface < BaseTool
@@ -49,7 +51,7 @@ module RailsAiContext
49
51
  end
50
52
 
51
53
  if view_dirs.empty?
52
- note = api_only_note("app/views")
54
+ note = api_only_note("app/views", dir: "app/views")
53
55
  return text_response(note) if note
54
56
 
55
57
  return text_response("No app/views/ directory found.")
@@ -71,7 +73,7 @@ module RailsAiContext
71
73
  unless located
72
74
  available = view_dirs.flat_map { |dir| find_available_partials(dir, root) }.uniq.sort.first(30)
73
75
  return not_found_response("Partial", partial, available,
74
- recovery_tool: "Call rails_get_view(detail:\"summary\") to see all views and partials")
76
+ recovery_tool: "Call rails_get_view(detail:\"summary\") to see all views and partials", note: static_engine_views_note)
75
77
  end
76
78
 
77
79
  file_path = located.realpath
@@ -83,11 +85,14 @@ module RailsAiContext
83
85
  return text_response("Could not read partial file.") unless source
84
86
 
85
87
  relative_path = located.relative
86
- partial_name = relative_path.sub(%r{\A.*app/views/}, "")
88
+ partial_name = view_relative(File.expand_path(relative_path, root), view_dirs)
87
89
 
88
90
  # Parse the partial's interface
89
91
  magic_locals = extract_magic_comment_locals(source)
90
- render_sites = view_dirs.flat_map { |dir| find_render_sites(dir, partial, root) }
92
+ # The resolved file, not the caller's spelling: a bare name has no
93
+ # directory for a render site to be matched against.
94
+ prefixed = prefix_partial_paths?(root)
95
+ render_sites = view_dirs.flat_map { |dir| find_render_sites(dir, partial_name, root, prefixed) }
91
96
  method_calls = {}
92
97
 
93
98
  # Primary: locals from render call sites (ground truth)
@@ -98,8 +103,8 @@ module RailsAiContext
98
103
 
99
104
  # Combine: render-site locals first, then source-detected locals
100
105
  # Filter out noise: single chars, capitalized words, known helpers
101
- all_locals = (magic_locals + render_locals + source_locals).uniq
102
- .reject { |l| l.length <= 1 || l.match?(/\A[A-Z]/) || l.match?(/\Arender_/) }
106
+ all_locals = (Array(magic_locals) + render_locals + source_locals).uniq
107
+ .reject { |l| l.length <= 1 || l.match?(/\A[A-Z*]/) || l.match?(/\Arender_/) }
103
108
  .sort
104
109
 
105
110
  # Extract method calls only for confirmed locals
@@ -118,7 +123,7 @@ module RailsAiContext
118
123
  lines = [ "# Partial: #{partial_name}", "" ]
119
124
 
120
125
  if all_locals.any?
121
- magic_note = magic_locals.any? ? " (#{magic_locals.size} declared via magic comment)" : ""
126
+ magic_note = magic_locals&.any? ? " (#{magic_locals.size} declared via magic comment)" : ""
122
127
  lines << "**Locals:** #{all_locals.join(', ')}#{magic_note}"
123
128
  else
124
129
  lines << "**Locals:** none detected"
@@ -135,8 +140,9 @@ module RailsAiContext
135
140
  lines = [ "# Partial: #{partial_name}", "" ]
136
141
  lines << "**File:** `#{relative_path}` (#{count_phrase(source.lines.size, "line")})"
137
142
 
138
- if magic_locals.any?
139
- lines << "**Declared locals** (Rails 7.1+ magic comment): #{magic_locals.join(', ')}"
143
+ if magic_locals
144
+ declared = magic_locals.any? ? magic_locals.join(", ") : "none, so passing any local raises"
145
+ lines << "**Declared locals** (Rails 7.1+ magic comment): #{declared}"
140
146
  end
141
147
 
142
148
  if all_locals.any?
@@ -151,7 +157,7 @@ module RailsAiContext
151
157
  lines << "- **#{local}**"
152
158
  end
153
159
  end
154
- elsif !full
160
+ elsif !full && !magic_locals
155
161
  lines << "" << "_No local variables detected in this partial._"
156
162
  end
157
163
 
@@ -203,9 +209,11 @@ module RailsAiContext
203
209
  # A fixed extension list refused `.text.erb`, which the Available
204
210
  # list built by globbing had just offered. Rails names a partial by
205
211
  # its directory and basename, whatever format and handler follow.
212
+ # The plain file before a locale or variant one: Rails renders it for a request that asks for neither.
213
+ plain_first = ->(paths) { paths.sort_by { |path| [ view_alternate_of(path) ? 1 : 0, path ] } }
206
214
  candidates = [
207
- *Dir.glob(File.join(views_dir, *dir_parts, "#{prefixed_basename}.*")).sort,
208
- *Dir.glob(File.join(views_dir, *dir_parts, "#{unprefixed_basename}.*")).sort,
215
+ *plain_first.call(Dir.glob(File.join(views_dir, *dir_parts, "#{prefixed_basename}.*"))),
216
+ *plain_first.call(Dir.glob(File.join(views_dir, *dir_parts, "#{unprefixed_basename}.*"))),
209
217
  File.join(views_dir, partial)
210
218
  ]
211
219
 
@@ -219,10 +227,10 @@ module RailsAiContext
219
227
  located.ok? || located.refusal == :too_large ? located : nil
220
228
  end
221
229
 
222
- # A view file's name as the app renders it: its path under whichever
223
- # views root holds it.
230
+ # A view file's name as the app renders it: its path under the innermost
231
+ # views root that holds it.
224
232
  private_class_method def self.view_relative(path, view_dirs)
225
- dir = view_dirs.find { |d| path.start_with?(d + File::SEPARATOR) }
233
+ dir = RailsAiContext::ViewFile.root_for(path, view_dirs)
226
234
  dir ? path.delete_prefix(dir + File::SEPARATOR) : path
227
235
  end
228
236
 
@@ -233,26 +241,29 @@ module RailsAiContext
233
241
  return [] if name.include?("/")
234
242
 
235
243
  name = "_#{name}" unless name.start_with?("_")
236
- Dir.glob(File.join(views_dir, "**", "#{name}.*")).sort.select { |c| File.file?(c) }
244
+ RailsAiContext::ViewFile.glob(rails_app.root.to_s, views_dir, File.join("**", "#{name}.*")).select { |c| File.file?(c) }
237
245
  end
238
246
 
239
- # Extract locals declared via Rails 7.1+ magic comment: <%# locals: (name:, title: "default") %>
247
+ # ActionView::Template::STRICT_LOCALS_REGEX as of Rails 8.0, held here so
248
+ # the static tier reads the comment the same way.
249
+ STRICT_LOCALS = /\#\s+locals:\s+\((.*?)\)(?=\s*-?%>|\s*$)/m
250
+
251
+ # The names a strict locals comment declares; nil without one, [] for
252
+ # `()`, which allows no locals at all.
240
253
  private_class_method def self.extract_magic_comment_locals(source)
241
- locals = []
254
+ list = source[STRICT_LOCALS, 1]
255
+ return nil unless list
242
256
 
243
- source.each_line do |line|
244
- if (match = line.match(/<%#\s*locals:\s*\(([^)]+)\)\s*%>/))
245
- params_str = match[1]
246
- # Parse Ruby-style keyword params: name:, title: "default", count: 0
247
- params_str.scan(/([\w]+):/) do |param_match|
248
- locals << param_match[0]
249
- end
250
- end
251
- end
257
+ result = RailsAiContext::AstCache.parse_string("def _(#{list}); end")
258
+ return list.scan(/(\w+):/).flatten.uniq unless result.success?
252
259
 
253
- locals.uniq
260
+ params = result.value.statements.body.first.parameters
261
+ return [] unless params
262
+
263
+ rest = params.keyword_rest
264
+ params.keywords.map { |p| p.name.to_s } + (rest.is_a?(Prism::KeywordRestParameterNode) ? [ "**#{rest.name}" ] : [])
254
265
  rescue => e
255
- RailsAiContext.debug_fail(e, [], label: "extract_magic_comment_locals")
266
+ RailsAiContext.debug_fail(e, nil, label: "extract_magic_comment_locals")
256
267
  end
257
268
 
258
269
  # Extract local variable references from ERB source.
@@ -324,7 +335,7 @@ module RailsAiContext
324
335
  end
325
336
 
326
337
  # Find all views that render this partial and extract the locals they pass.
327
- private_class_method def self.find_render_sites(views_dir, partial, root)
338
+ private_class_method def self.find_render_sites(views_dir, partial, root, prefixed)
328
339
  sites = []
329
340
  # Build search names: the partial can be referenced multiple ways
330
341
  # Normalize: strip underscore prefix from basename and extensions
@@ -346,28 +357,15 @@ module RailsAiContext
346
357
  basename # status_badge
347
358
  ].uniq
348
359
 
349
- # Implicit object/collection rendering resolves to this partial via
350
- # to_partial_path: `render @post`, `render post`, `render @posts`,
351
- # `render posts` all hit posts/_post. These are extremely common and
352
- # carry no quoted partial name, so the explicit-string match above
353
- # misses them. Only treat the partial as a conventional resource
354
- # partial (posts/_post) when its directory is the pluralized basename
355
- # or it is top-level, to avoid matching unrelated variables.
356
- plural = basename.pluralize
357
- implicit_names =
358
- if dir_prefix.empty? || dir_prefix.split("/").last == plural
359
- [ basename, plural ].uniq
360
- else
361
- []
362
- end
360
+ object_paths = {}
363
361
 
364
- view_files = Dir.glob(File.join(views_dir, RailsAiContext::ViewFile::MARKUP_GLOB)).sort
362
+ view_files = RailsAiContext::ViewFile.glob(root, views_dir, RailsAiContext::ViewFile::MARKUP_GLOB)
365
363
 
366
364
  view_files.each do |file|
367
365
  content = safe_read(file)
368
366
  next unless content
369
367
 
370
- relative = file.sub("#{root}/", "")
368
+ relative = RailsAiContext::PortablePath.relativize(file, root)
371
369
 
372
370
  lines = content.lines
373
371
  # Per call, not per line: `render(` and a call split over lines are
@@ -380,7 +378,7 @@ module RailsAiContext
380
378
 
381
379
  covered = at + args.length
382
380
  line_num = content[0...at].count("\n") + 1
383
- line = "render #{args.gsub(/\s+/, " ").strip}"
381
+ line = Introspectors::ViewTemplateIntrospector.render_line(args)
384
382
  spanned = lines[(line_num - 1)..(line_num - 1 + args.chomp.count("\n"))]
385
383
  snippet = spanned.size > 1 ? spanned.join(" ").squish : lines[line_num - 1].strip
386
384
 
@@ -411,21 +409,13 @@ module RailsAiContext
411
409
 
412
410
  next if matched_line
413
411
 
414
- # Implicit object/collection render: `render @post`, `render post`,
415
- # `render @posts`. The variable is a bareword or ivar (no quotes),
416
- # and \b guards against prefixes like post_path / posts_controller.
417
- implicit_names.each do |var|
418
- next unless line.match?(/render\s+@?#{Regexp.escape(var)}\b/)
419
- next if line.match?(/render\s.*["']/) # a quoted render is explicit, handled above
412
+ chain = line[Introspectors::ViewTemplateIntrospector::IMPLICIT_RENDER, 1]
413
+ next unless chain && !line.include?("partial:")
420
414
 
421
- sites << {
422
- file: relative,
423
- line: line_num,
424
- locals: [],
425
- snippet: snippet
426
- }
427
- break
428
- end
415
+ view_dir = File.dirname(file.delete_prefix(views_dir + File::SEPARATOR))
416
+ next unless implicit_partial(chain, view_dir, prefixed, root, object_paths) == canonical
417
+
418
+ sites << { file: relative, line: line_num, locals: [], snippet: snippet }
429
419
  end
430
420
  end
431
421
 
@@ -434,6 +424,55 @@ module RailsAiContext
434
424
  RailsAiContext.debug_fail(e, [], label: "find_render_sites")
435
425
  end
436
426
 
427
+ # The partial Rails renders for the records `chain` names from a view in
428
+ # view_dir, which stands in for the controller's path; nil when the
429
+ # model's to_partial_path or the chain cannot be read.
430
+ private_class_method def self.implicit_partial(chain, view_dir, prefixed, root, memo)
431
+ model, = Introspectors::RenderedRecord.resolve(chain, root, memo)
432
+ path = model && Introspectors::RenderedRecord.partial_path(model, root, memo)
433
+ return path unless path && prefixed
434
+
435
+ merge_prefix_into_object_path(view_dir == "." ? "" : view_dir, path)
436
+ end
437
+
438
+ # ActionView::AbstractRenderer#merge_prefix_into_object_path.
439
+ private_class_method def self.merge_prefix_into_object_path(prefix, object_path)
440
+ return object_path unless prefix.include?("/") && object_path.include?("/")
441
+
442
+ prefixes = []
443
+ object_dirs = object_path.split("/")[0..-3]
444
+ File.dirname(prefix).split("/").each_with_index do |dir, index|
445
+ break if dir == object_dirs[index]
446
+
447
+ prefixes << dir
448
+ end
449
+ (prefixes << object_path).join("/")
450
+ end
451
+
452
+ # Rails prefixes a record's partial with the controller namespace unless
453
+ # ActionView::Base.prefix_partial_path_with_controller_namespace is false.
454
+ PREFIX_SETTER = :prefix_partial_path_with_controller_namespace=
455
+
456
+ private_class_method def self.prefix_partial_paths?(root)
457
+ return ActionView::Base.prefix_partial_path_with_controller_namespace != false if booted_app? && defined?(ActionView::Base)
458
+
459
+ prefix = "#{root.to_s.chomp('/')}/"
460
+ files = [ "config/application.rb", "config/environments/#{rails_env_name}.rb" ] +
461
+ RailsAiContext::PathResolver.initializer_paths(root).map { |path| path.delete_prefix(prefix) }
462
+ last = nil
463
+ # The setter name is ActionView's own, so config.action_view, ActionView::Base and an on_load `self.` all match.
464
+ listener = -> { Introspectors::Listeners::MethodCallListener.new(names: [ PREFIX_SETTER ]) }
465
+ files.each do |relative|
466
+ next unless RailsAiContext::SafePath.locate(relative, under: root).ok?
467
+
468
+ walked = Introspectors::SourceIntrospector.walk(File.join(root, relative), { setting: listener })
469
+ walked[:setting].each { |call| last = call[:arguments].first if [ true, false ].include?(call[:arguments].first) }
470
+ end
471
+ last != false
472
+ rescue => e
473
+ RailsAiContext.debug_fail(e, true, label: "prefix_partial_paths?")
474
+ end
475
+
437
476
  # Extract local variable names from a render call line.
438
477
  private_class_method def self.extract_locals_from_render(line)
439
478
  locals = []
@@ -492,7 +531,7 @@ module RailsAiContext
492
531
 
493
532
  # Find available partials for fuzzy matching in not_found_response.
494
533
  private_class_method def self.find_available_partials(views_dir, root)
495
- Dir.glob(File.join(views_dir, "**", "_*")).select { |f| File.file?(f) && RailsAiContext::ViewFile.template?(f) }.map do |f|
534
+ RailsAiContext::ViewFile.glob(root, views_dir, File.join("**", "_*")).select { |f| File.file?(f) && RailsAiContext::ViewFile.template?(f) }.map do |f|
496
535
  relative = f.sub("#{views_dir}/", "")
497
536
  # Strip underscore prefix and extension for display
498
537
  parts = relative.split("/")
@@ -36,12 +36,15 @@ module RailsAiContext
36
36
 
37
37
  HINTED_FILTERS = 3
38
38
 
39
+ GEM_DRAWN_NOTE = "_Read from source: #{RailsAiContext::RouteCoverage::GEM_DRAWN} (Active Storage, Action Mailbox, conductor) " \
40
+ "are read only with the app booted, so no count here includes them._"
41
+
39
42
  # The filters this controller actually runs, read the way every other
40
43
  # surface reads them, so a skipped one is never named here. The cut is
41
44
  # stated rather than silent.
42
45
  def self.filter_hint(ctrl_class, ctx)
43
46
  chain = RailsAiContext::ActionFilters.for_controller(ctx, ctrl_class, root: rails_app&.root&.to_s)
44
- names = (chain[:inherited] + chain[:own]).map { |f| f[:name] }
47
+ names = chain[:chain].map { |f| f[:name] }
45
48
  return nil if names.empty?
46
49
 
47
50
  rest = names.size - HINTED_FILTERS
@@ -57,15 +60,27 @@ module RailsAiContext
57
60
 
58
61
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
59
62
 
63
+ GRAPE_LIMIT = 100
64
+
60
65
  # A mounted app answers on a path and has no controller#action, so
61
66
  # no controller group can hold it. Counting it and then dropping it left
62
67
  # the one endpoint a reader was looking for named nowhere.
63
- private_class_method def self.mounted_apps_lines(mounted_apps, engine_routes = [])
68
+ private_class_method def self.mounted_apps_lines(mounted_apps, engine_routes = [], grape = {}, list_grape: true)
64
69
  lines = []
65
70
  unless mounted_apps.empty?
66
71
  lines << "" << "## Mounted Apps (#{mounted_apps.size})"
67
72
  mounted_apps.each do |app|
68
- lines << (app[:path] ? "- **#{app[:engine]}** at `#{app[:path]}`" : "- **#{app[:engine]}**")
73
+ row = app[:path] ? "- **#{app[:engine]}** at `#{app[:path]}`" : "- **#{app[:engine]}**"
74
+ row += " (`#{app[:condition]}`)" if app[:condition]
75
+ endpoints = Array(grape&.dig(app[:engine]))
76
+ if endpoints.any? && !list_grape
77
+ row += " (#{count_phrase(endpoints.size, "Grape endpoint")}, listed at detail:\"standard\")"
78
+ end
79
+ lines << row
80
+ if list_grape
81
+ endpoints.first(GRAPE_LIMIT).each { |endpoint| lines << " - #{RailsAiContext::Introspectors::GrapeEndpoints.line(endpoint)}" }
82
+ lines << " - _#{endpoints.size - GRAPE_LIMIT} more, in #{endpoints.map { |e| e[:file] }.uniq.first(3).join(', ')}_" if endpoints.size > GRAPE_LIMIT
83
+ end
69
84
  end
70
85
  lines << "_A mounted app's own routes are in its table, not in the count above._"
71
86
  end
@@ -160,8 +175,10 @@ module RailsAiContext
160
175
  by_controller = by_controller.transform_values { |actions| RailsAiContext::RouteCoverage.dedupe_put_patch_routes(actions) }
161
176
  filtered_total = by_controller.values.sum(&:size)
162
177
  count_label = count_phrase(filtered_total, "route")
178
+ from_source = routes[:confidence] == RailsAiContext::Confidence::STATIC && controller.nil?
163
179
  if excluded_framework_count > 0 && controller.nil?
164
180
  count_label += ", excluding #{count_phrase(excluded_framework_count, "framework route")}"
181
+ count_label += " drawn in the app's route files" if from_source
165
182
  end
166
183
  # "engine mount" named the wrong thing: a plain Rack app attached
167
184
  # with `mount` or with `match ... to:` is not an engine, and both
@@ -177,7 +194,7 @@ module RailsAiContext
177
194
  # thing an unbooted answer must not do. All three detail levels share
178
195
  # this label. Gated on `controller` because the caveat is about the
179
196
  # whole table, and a filtered answer is not that.
180
- count_label += RailsAiContext::RouteCoverage.suffix(routes) if controller.nil?
197
+ count_label += RailsAiContext::RouteCoverage.suffix(routes, gem_drawn: false) if controller.nil?
181
198
 
182
199
  case detail
183
200
  when "summary"
@@ -219,7 +236,8 @@ module RailsAiContext
219
236
  lines << "- _#{fw_names} framework routes: #{total_fw} total_"
220
237
  end
221
238
 
222
- lines.concat(mounted_apps_lines(mounted_apps, controller ? [] : routes[:engine_routes]))
239
+ lines.concat(mounted_apps_lines(mounted_apps, controller ? [] : routes[:engine_routes], routes[:grape_endpoints], list_grape: false))
240
+ lines << "" << GEM_DRAWN_NOTE if from_source
223
241
 
224
242
  if routes[:api_namespaces]&.any?
225
243
  lines << "" << "API namespaces: #{routes[:api_namespaces].join(', ')}"
@@ -274,15 +292,17 @@ module RailsAiContext
274
292
  else
275
293
  ""
276
294
  end
277
- lines << "- `#{r[:verb]}` `#{r[:path]}` → #{r[:action]}#{helper_part}#{params_part}"
295
+ condition = r[:condition] ? " (`#{r[:condition]}`)" : ""
296
+ lines << "- `#{r[:verb]}` `#{r[:path]}` → #{r[:action]}#{helper_part}#{params_part}#{condition}"
278
297
  end
279
298
 
280
- lines.concat(mounted_apps_lines(mounted_apps, controller ? [] : routes[:engine_routes]))
299
+ lines.concat(mounted_apps_lines(mounted_apps, controller ? [] : routes[:engine_routes], routes[:grape_endpoints]))
281
300
 
282
301
  if excluded_framework_count > 0 && controller.nil?
283
302
  lines << "" << "_#{count_phrase(excluded_framework_count, "framework route")} hidden. " \
284
303
  "Use `app_only:false` to include them._"
285
304
  end
305
+ lines << "" << GEM_DRAWN_NOTE if from_source
286
306
 
287
307
  lines << "" << page[:hint] unless page[:hint].empty?
288
308
  text_response(lines.join("\n"))
@@ -295,9 +315,15 @@ module RailsAiContext
295
315
  lines << "| Verb | Path | Controller#Action | Name |"
296
316
  lines << "|------|------|-------------------|------|"
297
317
  page[:items].each do |r|
298
- lines << "| #{r[:verb]} | `#{r[:path]}` | #{r[:_ctrl]}##{r[:action]} | #{r[:name] || '-'} |"
318
+ path = "`#{r[:path]}`"
319
+ path += " `#{r[:constraints]}`" if r[:constraints]
320
+ path += " (`#{r[:condition]}`)" if r[:condition]
321
+ cells = [ r[:verb], path, "#{r[:_ctrl]}##{r[:action]}", r[:name] || "-" ]
322
+ # A merged verb is `PATCH|PUT`, and a bare pipe splits the row.
323
+ lines << "| #{cells.map { |cell| cell.to_s.gsub("|", "\\|") }.join(' | ')} |"
299
324
  end
300
- lines.concat(mounted_apps_lines(mounted_apps, controller ? [] : routes[:engine_routes]))
325
+ lines.concat(mounted_apps_lines(mounted_apps, controller ? [] : routes[:engine_routes], routes[:grape_endpoints]))
326
+ lines << "" << GEM_DRAWN_NOTE if from_source
301
327
 
302
328
  if routes[:api_namespaces]&.any?
303
329
  lines << "" << "## API namespaces: #{routes[:api_namespaces].join(', ')}"