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
@@ -51,7 +51,10 @@ module RailsAiContext
51
51
  when "summary"
52
52
  lines = [ "# Test Infrastructure", "" ]
53
53
  lines << "- **Framework:** #{data[:framework]}"
54
- lines << "- **Factories:** #{count_phrase(data[:factories][:count], "file")}" if data[:factories]
54
+ lines.concat(suite_lines)
55
+ lines << "- **Factories:** #{RailsAiContext::TestFramework.factory_files_phrase(data[:factories]) || data[:factories][:location]}" if data[:factories]
56
+ lines << "- **Fabricators:** #{count_phrase(data[:fabricators][:count], "file")}" if data[:fabricators]
57
+ lines << "- **Cucumber:** #{count_phrase(data[:cucumber][:count], "feature file")}" if data[:cucumber]
55
58
  lines << "- **Fixtures:** #{RailsAiContext::TestFramework.fixture_phrase(data[:fixtures])}" if data[:fixtures]
56
59
  if data[:test_files]&.any?
57
60
  total = data[:test_files].values.sum { |v| v[:count] }
@@ -63,11 +66,15 @@ module RailsAiContext
63
66
  when "standard"
64
67
  lines = [ "# Test Infrastructure", "" ]
65
68
  lines << "- **Framework:** #{data[:framework]}"
66
- lines << "- **Factories:** #{data[:factories][:location]} (#{count_phrase(data[:factories][:count], "file")})" if data[:factories]
69
+ lines.concat(suite_lines)
70
+ lines << "- **Factories:** #{RailsAiContext::TestFramework.factory_location(data[:factories])}" if data[:factories]
71
+ lines << "- **Fabricators:** #{data[:fabricators][:location]} (#{count_phrase(data[:fabricators][:count], "file")})" if data[:fabricators]
72
+ lines << cucumber_line(data[:cucumber]) if data[:cucumber]
67
73
  lines << "- **Fixtures:** #{data[:fixtures][:location]} (#{RailsAiContext::TestFramework.fixture_phrase(data[:fixtures])})" if data[:fixtures]
68
74
  lines << "- **System tests:** #{data[:system_tests][:location]}" if data[:system_tests]
69
75
  lines << "- **CI:** #{data[:ci_config].join(', ')}" if data[:ci_config]&.any?
70
76
  lines << "- **Coverage:** #{data[:coverage]}" if data[:coverage]
77
+ lines.concat(ci_step_lines(data[:ci_steps], data[:ci_steps_dir]))
71
78
 
72
79
  if data[:test_files]&.any?
73
80
  lines << "" << "## Test Files"
@@ -92,35 +99,43 @@ module RailsAiContext
92
99
  when "full"
93
100
  lines = [ "# Test Infrastructure (Full Detail)", "" ]
94
101
  lines << "- **Framework:** #{data[:framework]}"
102
+ lines.concat(suite_lines)
95
103
  lines << "- **CI:** #{data[:ci_config].join(', ')}" if data[:ci_config]&.any?
96
104
  lines << "- **Coverage:** #{data[:coverage]}" if data[:coverage]
105
+ lines << cucumber_line(data[:cucumber]) if data[:cucumber]
106
+ lines.concat(ci_step_lines(data[:ci_steps], data[:ci_steps_dir]))
97
107
 
98
108
  lines.concat(trait_lines(data[:factory_traits], 20))
99
109
 
100
110
  if data[:fixture_names]&.any?
101
111
  lines << "" << "## Fixtures"
102
- parsed_fixtures = parse_all_fixture_contents
103
- if parsed_fixtures.any?
104
- parsed_fixtures.each do |file, entries|
105
- lines << "- **#{file}:**"
106
- entries.each do |entry_name, attrs|
107
- attr_str = attrs.map { |k, v| "#{k}: #{v}" }.join(", ")
108
- lines << " - `#{entry_name}`: #{attr_str}"
109
- end
112
+ parsed_fixtures = parse_all_fixture_contents(data[:fixtures])
113
+ data[:fixture_names].each do |set, labels|
114
+ entries = parsed_fixtures[set.to_s]
115
+ unless entries.is_a?(Hash)
116
+ why = entries == :too_large ? "over the #{max_test_file_size} byte read limit" : "not parsed as YAML"
117
+ erb_labels = Array((data[:fixture_erb_labels] || {})[set]).map { |label| "`#{label}` (set by ERB)" }
118
+ lines << "- **#{set}:** #{(Array(labels) + erb_labels).join(', ')} _(#{why}; labels only)_"
119
+ next
110
120
  end
111
121
 
112
- # Fixture relationships section
113
- relationships = extract_fixture_relationships(parsed_fixtures)
114
- if relationships.any?
115
- lines << "" << "## Fixture Relationships"
116
- relationships.each do |parent, children|
117
- lines << "- **#{parent}** ← #{children.join(', ')}"
122
+ lines << "- **#{set}:**"
123
+ entries.each do |entry_name, attrs|
124
+ attr_str = attrs.map { |k, v| "#{k}: #{erb_shown(v)}" }.join(", ")
125
+ label = if RailsAiContext::ConfigYaml.marked?(entry_name.to_s)
126
+ "`#{erb_shown(entry_name)}` _(label set by ERB)_"
127
+ else
128
+ "`#{entry_name}`"
118
129
  end
130
+ lines << (attr_str.empty? ? " - #{label}" : " - #{label}: #{attr_str}")
119
131
  end
120
- else
121
- # Fallback to simple names if parsing fails
122
- data[:fixture_names].each do |file, names|
123
- lines << "- **#{file}:** #{names.join(', ')}"
132
+ end
133
+
134
+ relationships = extract_fixture_relationships(parsed_fixtures.select { |_, entries| entries.is_a?(Hash) })
135
+ if relationships.any?
136
+ lines << "" << "## Fixture Relationships"
137
+ relationships.each do |parent, children|
138
+ lines << "- **#{parent}** ← #{children.join(', ')}"
124
139
  end
125
140
  end
126
141
  end
@@ -137,6 +152,11 @@ module RailsAiContext
137
152
  end
138
153
  end
139
154
 
155
+ if data[:fabricator_names]&.any?
156
+ lines << "" << "## Fabricators"
157
+ data[:fabricator_names].each { |file, names| lines << "- **#{file}:** #{names.join(', ')}" }
158
+ end
159
+
140
160
  if data[:test_helper_setup]&.any?
141
161
  lines << "" << "## Test Helper Setup"
142
162
  data[:test_helper_setup].each { |m| lines << "- `#{m}`" }
@@ -163,6 +183,32 @@ module RailsAiContext
163
183
  RailsAiContext.configuration.max_test_file_size
164
184
  end
165
185
 
186
+ # An engine's test/dummy is tested by the engine's suite, which every path here is under.
187
+ private_class_method def self.suite_root
188
+ RailsAiContext::PathResolver.test_root(rails_app.root.to_s)
189
+ end
190
+
191
+ private_class_method def self.erb_shown(text)
192
+ RailsAiContext::ConfigYaml.shown(text)
193
+ end
194
+
195
+ private_class_method def self.shown(rel)
196
+ RailsAiContext::PathResolver.suite_relative(rails_app.root.to_s, rel)
197
+ end
198
+
199
+ private_class_method def self.suite_lines
200
+ app_root = rails_app.root.to_s
201
+ if suite_root == app_root
202
+ return unread_suite_gemfile ? [ "- **Suite:** not read: #{RailsAiContext::GemLock.for(app_root).unread_bundle}" ] : []
203
+ end
204
+
205
+ [ "- **Suite:** the engine's, at `#{RailsAiContext::PathResolver.suite_relative(app_root, ".")}`; the paths below are under it" ]
206
+ end
207
+
208
+ private_class_method def self.unread_suite_gemfile
209
+ RailsAiContext::TestFramework.unread_gemfile(suite_root) if suite_root == rails_app.root.to_s
210
+ end
211
+
166
212
  private_class_method def self.find_test_file(name, type, detail = "full")
167
213
  # Normalize: accept "Admin::PostsController", "admin/posts", "Posts", "posts" (plural)
168
214
  snake = case type
@@ -184,13 +230,13 @@ module RailsAiContext
184
230
  refused = refuse_unsafe_paths([ name ])
185
231
  return refused if refused
186
232
 
187
- candidates = RailsAiContext::TestFramework.candidates(rails_app.root.to_s, type, snake, cached_context)
233
+ candidates = RailsAiContext::TestFramework.candidates(suite_root, type, snake, cached_context)
188
234
  exercising = type == :controller ? exercising_tests(snake, candidates) : []
189
235
 
190
236
  contained = []
191
237
  too_large = nil
192
238
  candidates.each do |rel|
193
- content, resolution = RailsAiContext::SafePath.read(rel, under: rails_app.root.to_s, max_size: max_test_file_size)
239
+ content, resolution = RailsAiContext::SafePath.read(rel, under: suite_root, max_size: max_test_file_size)
194
240
  contained << rel unless ESCAPING_REFUSALS.include?(resolution.refusal)
195
241
  too_large ||= resolution if resolution.refusal == :too_large
196
242
  next unless content
@@ -198,15 +244,15 @@ module RailsAiContext
198
244
  # Summary/standard: return just test names (saves 2000+ tokens vs full source)
199
245
  answer = if names_only?(detail)
200
246
  listed = examples(content)
201
- "# #{rel} (#{count_phrase(listed.size, "test")})\n\n#{listed.join("\n")}"
247
+ "# #{shown(rel)} (#{count_phrase(listed.size, "test")})\n\n#{listed.join("\n")}"
202
248
  else
203
- "# #{rel}\n\n```ruby\n#{content}\n```"
249
+ "# #{shown(rel)}\n\n```ruby\n#{content}\n```"
204
250
  end
205
251
  return text_response(answer + exercising_section(exercising, "## Also exercised by"))
206
252
  end
207
253
 
208
254
  if too_large
209
- return text_response("# #{too_large.relative}\n\n_The test file exists but was not read: #{human_size(File.size(too_large.realpath))} is over " \
255
+ return text_response("# #{shown(too_large.relative)}\n\n_The test file exists but was not read: #{human_size(File.size(too_large.realpath))} is over " \
210
256
  "`max_test_file_size` (#{human_size(max_test_file_size)})._" +
211
257
  exercising_section(exercising, "## Also exercised by"))
212
258
  end
@@ -222,7 +268,9 @@ module RailsAiContext
222
268
  "or names a sensitive file.")
223
269
  end
224
270
 
225
- empty_response("No test file found for #{name}. Searched: #{contained.join(', ')}#{nearby_tests_hint(contained)}")
271
+ unread = unread_suite_gemfile
272
+ unread &&= "\n\nNot searched: the engine's suite at `#{File.dirname(unread)}` is not read, #{RailsAiContext::GemLock.for(suite_root).unread_bundle}."
273
+ empty_response("No test file found for #{name}. Searched: #{contained.map { |rel| shown(rel) }.join(', ')}#{nearby_tests_hint(contained)}#{unread}")
226
274
  end
227
275
 
228
276
  # Where a controller is driven from outside its own test, and what each
@@ -239,7 +287,7 @@ module RailsAiContext
239
287
  #
240
288
  # @return [Array<Array(String, String, String)>] [path, kind, source]
241
289
  private_class_method def self.exercising_tests(snake, primary)
242
- root = rails_app.root.to_s
290
+ root = suite_root
243
291
  real_root = File.realpath(root)
244
292
  routes = Array(RouteCoverage.all_by_controller(cached_context[:routes])[snake])
245
293
  helpers = routes.filter_map { |route| route[:name] }.uniq
@@ -271,7 +319,7 @@ module RailsAiContext
271
319
  lines = heading ? [ "", "", heading, "" ] : [ "", "" ]
272
320
  found.first(MAX_EXERCISING).each do |rel, kind, source|
273
321
  listed = examples(source)
274
- lines << "- `#{rel}` (#{kind}, #{count_phrase(listed.size, "test")})"
322
+ lines << "- `#{shown(rel)}` (#{kind}, #{count_phrase(listed.size, "test")})"
275
323
  listed.each { |test| lines << " #{test}" } if names
276
324
  end
277
325
  lines << "- ... #{found.size - MAX_EXERCISING} more" if found.size > MAX_EXERCISING
@@ -306,7 +354,7 @@ module RailsAiContext
306
354
  # Nearby test files, to help the agent find the right one. The glob base
307
355
  # is the realpath, so a symlinked test directory cannot widen it.
308
356
  private_class_method def self.nearby_tests_hint(candidates)
309
- root = rails_app.root.to_s
357
+ root = suite_root
310
358
  real_root = File.realpath(root)
311
359
 
312
360
  nearby = candidates.map { |rel| File.dirname(File.join(root, rel)) }.uniq.flat_map do |dir|
@@ -315,7 +363,7 @@ module RailsAiContext
315
363
  real_dir = File.realpath(dir)
316
364
  next [] unless RailsAiContext::SafePath.contained?(real_dir, real_root)
317
365
 
318
- Dir.glob(File.join(real_dir, "*")).map { |f| f.delete_prefix("#{real_root}/") }.first(10)
366
+ Dir.glob(File.join(real_dir, "*")).map { |f| shown(f.delete_prefix("#{real_root}/")) }.first(10)
319
367
  end
320
368
 
321
369
  nearby.any? ? "\n\nFiles in test directory: #{nearby.join(', ')}" : ""
@@ -323,6 +371,18 @@ module RailsAiContext
323
371
  ""
324
372
  end
325
373
 
374
+ private_class_method def self.cucumber_line(cucumber)
375
+ "- **Cucumber:** #{cucumber[:location]} (#{count_phrase(cucumber[:count], "feature file")}, " \
376
+ "#{count_phrase(cucumber[:step_definitions].to_i, "step definition file")})"
377
+ end
378
+
379
+ private_class_method def self.ci_step_lines(steps, dir)
380
+ return [] unless steps.is_a?(Array) && steps.any?
381
+
382
+ [ "", "## CI Steps (`#{dir}config/ci.rb`, run by `#{dir}bin/ci`)" ] +
383
+ steps.map { |step| "- #{step[:name]}: `#{step[:command]}`" }
384
+ end
385
+
326
386
  private_class_method def self.trait_lines(traits, limit)
327
387
  return [] unless traits.is_a?(Hash) && traits.any?
328
388
 
@@ -364,13 +424,13 @@ module RailsAiContext
364
424
  # Detect Devise + sign_in pattern from existing tests
365
425
  has_devise = false
366
426
  has_sign_in = false
367
- test_dir = rails_app.root.join("test").to_s
427
+ test_dir = File.join(suite_root, "test")
368
428
  if Dir.exist?(test_dir)
369
- real_root = File.realpath(rails_app.root).to_s
429
+ real_root = File.realpath(suite_root).to_s
370
430
  safe_glob(test_dir, "**/*_test.rb", real_root).first(5).each do |path|
371
431
  content = RailsAiContext::SafeFile.read(path) or next
372
432
  has_devise = true if content.include?("Devise::Test")
373
- has_sign_in = true if content.include?("sign_in")
433
+ has_sign_in = true if content.match?(/\bsign_in\b/)
374
434
  end
375
435
  end
376
436
 
@@ -433,10 +493,10 @@ module RailsAiContext
433
493
  private_class_method def self.parse_factory_details(relative_path)
434
494
  # Try common factory locations
435
495
  candidates = [
436
- rails_app.root.join("spec/factories/#{relative_path}"),
437
- rails_app.root.join("test/factories/#{relative_path}"),
438
- rails_app.root.join("spec/factories", relative_path),
439
- rails_app.root.join("test/factories", relative_path)
496
+ File.join(suite_root, "spec/factories/#{relative_path}"),
497
+ File.join(suite_root, "test/factories/#{relative_path}"),
498
+ File.join(suite_root, "spec/factories", relative_path),
499
+ File.join(suite_root, "test/factories", relative_path)
440
500
  ]
441
501
  path = candidates.find { |p| File.exist?(p) }
442
502
  return nil unless path
@@ -464,62 +524,68 @@ module RailsAiContext
464
524
  RailsAiContext.debug_fail(e, nil, label: "parse_factory_details")
465
525
  end
466
526
 
467
- # Parse a single fixture YAML file, returning a hash of entry_name => filtered attributes
527
+ FIXTURE_SKIP_KEYS = %w[created_at updated_at id].freeze
528
+
529
+ FIXTURE_VALUE_LIMIT = 100
530
+
531
+ # A fixture file's labels and their short attributes as text, :too_large
532
+ # past the read limit, or nil when it was not read or does not parse.
468
533
  private_class_method def self.parse_fixture_contents(file_path)
469
- return {} unless File.exist?(file_path)
470
- return {} if File.size(file_path) > max_test_file_size
471
-
472
- require "yaml"
473
- content = RailsAiContext::SafeFile.read(file_path)
474
- return {} unless content
475
- # Handle ERB-templated fixtures: replace "<%=...%>" (with surrounding quotes) and bare <%...%>
476
- content = content.gsub(/"<%=.*?%>"/, '"erb_value"')
477
- content = content.gsub(/'<%=.*?%>'/, "'erb_value'")
478
- content = content.gsub(/<%.*?%>/, "erb_value")
479
- parsed = YAML.safe_load(content, permitted_classes: [ Date, Time, Symbol ]) rescue nil
480
- return {} unless parsed.is_a?(Hash)
481
-
482
- skip_keys = %w[created_at updated_at id]
483
- entries = {}
484
-
485
- parsed.each do |entry_name, attributes|
486
- next unless attributes.is_a?(Hash)
487
- filtered = {}
488
- attributes.each do |key, value|
489
- next if skip_keys.include?(key.to_s)
490
- str_value = value.to_s
491
- next if str_value.length > 100
492
- filtered[key] = value
534
+ return :too_large if File.size(file_path) > max_test_file_size
535
+
536
+ content = RailsAiContext::SafeFile.read(file_path, max_size: max_test_file_size) or return nil
537
+ parsed = RailsAiContext::FixtureKeys.parse(content) or return nil
538
+
539
+ parsed.transform_values do |attributes|
540
+ attributes.each_with_object({}) do |(key, value), shown|
541
+ next if FIXTURE_SKIP_KEYS.include?(key.to_s)
542
+
543
+ text = short_text(value)
544
+ shown[key] = text if text
493
545
  end
494
- entries[entry_name] = filtered if filtered.any?
495
546
  end
547
+ end
496
548
 
497
- entries
498
- rescue => e
499
- {}
549
+ # The value as text when it is short, else nil. A container is sized
550
+ # before to_s runs, since YAML aliases can nest one into an exponential
551
+ # string from a few lines.
552
+ private_class_method def self.short_text(value)
553
+ budget = FIXTURE_VALUE_LIMIT
554
+ pending = [ value ]
555
+ until pending.empty?
556
+ item = pending.pop
557
+ case item
558
+ when Hash then budget -= item.size; pending.concat(item.keys, item.values)
559
+ when Array then budget -= item.size; pending.concat(item)
560
+ else budget -= item.to_s.length
561
+ end
562
+ return nil if budget.negative?
563
+ end
564
+ text = value.to_s
565
+ text if text.length <= FIXTURE_VALUE_LIMIT
500
566
  end
501
567
 
502
- # Parse all fixture files, returning { "fixture_file" => { entry => attrs } }
503
- private_class_method def self.parse_all_fixture_contents
504
- fixture_dirs = [
505
- rails_app.root.join("test", "fixtures").to_s,
506
- rails_app.root.join("spec", "fixtures").to_s
507
- ]
508
- real_root = File.realpath(rails_app.root).to_s
568
+ # { set => entries, or nil when unparsed } over the fixture directories
569
+ # the introspector found, each set named by its path under its directory.
570
+ private_class_method def self.parse_all_fixture_contents(fixtures)
571
+ return {} unless fixtures.is_a?(Hash)
509
572
 
573
+ root = suite_root
574
+ real_root = File.realpath(root)
510
575
  results = {}
511
- fixture_dirs.each do |dir|
512
- next unless Dir.exist?(dir)
513
- safe_glob(dir, "**/*.yml", real_root).sort.each do |path|
514
- rel_name = File.basename(path, ".yml")
515
- entries = parse_fixture_contents(path)
516
- results[rel_name] = entries if entries.any?
576
+ Array(fixtures[:locations]).each do |rel|
577
+ dir = File.join(root, rel)
578
+ Dir.glob(File.join(dir, "**", "*.yml")).sort.each do |path|
579
+ set = path.delete_prefix("#{dir}/").delete_suffix(".yml")
580
+ next if results.key?(set)
581
+
582
+ real = safe_glob_realpath(path, real_root, real_root) or next
583
+ results[set] = parse_fixture_contents(real)
517
584
  end
518
585
  end
519
-
520
586
  results
521
- rescue => e
522
- {}
587
+ rescue SystemCallError => e
588
+ RailsAiContext.debug_fail(e, {}, label: "parse_all_fixture_contents")
523
589
  end
524
590
 
525
591
  # Extract relationships: find foreign key references between fixtures
@@ -535,7 +601,11 @@ module RailsAiContext
535
601
 
536
602
  parsed_fixtures.each do |file, entries|
537
603
  entries.each do |entry_name, attrs|
604
+ entry_name = erb_shown(entry_name)
538
605
  attrs.each do |key, value|
606
+ # An id ERB computes names no row we can see.
607
+ next if RailsAiContext::ConfigYaml.marked?(value)
608
+
539
609
  # Foreign key pattern: key ends with _id and value matches an entry, or
540
610
  # key matches a fixture file name and value is a fixture entry reference
541
611
  str_value = value.to_s
@@ -75,15 +75,21 @@ module RailsAiContext
75
75
  # commonly), so a controller filter that simply doesn't match
76
76
  # anything must not be reported as "views don't exist here".
77
77
  if templates.empty? && partials.empty?
78
- note = api_only_note("app/views")
78
+ note = api_only_note("app/views", dir: "app/views")
79
79
  return text_response(note) if note
80
80
  end
81
81
 
82
82
  all_dirs = view_directories(templates, partials)
83
83
  suggestion = find_closest_match(ctrl_lower, all_dirs)
84
84
  hint = suggestion ? " Did you mean '#{suggestion}'?" : ""
85
- dirs_note = all_dirs.any? ? " Directories with views: #{all_dirs.join(', ')}" : " No view directories found (API-only apps typically have none)."
86
- return empty_response("No views for '#{controller}'.#{hint}#{dirs_note}")
85
+ dirs_note = if all_dirs.any?
86
+ " Directories with views: #{all_dirs.join(', ')}"
87
+ elsif layout_files.any?
88
+ " The app has only layouts, listed by `controller:\"layouts\"`."
89
+ else
90
+ " No view directories found (API-only apps typically have none)."
91
+ end
92
+ return empty_response([ "No views for '#{controller}'.#{hint}#{dirs_note}", static_engine_views_note ].compact.join("\n\n"))
87
93
  end
88
94
 
89
95
  templates = filtered_templates
@@ -94,7 +100,7 @@ module RailsAiContext
94
100
  # genuinely has no views anywhere (as opposed to a filter simply not
95
101
  # matching anything, handled above).
96
102
  if controller.nil? && templates.empty? && partials.empty?
97
- note = api_only_note("app/views")
103
+ note = api_only_note("app/views", dir: "app/views")
98
104
  return text_response(note) if note
99
105
  end
100
106
 
@@ -103,7 +109,7 @@ module RailsAiContext
103
109
  case detail
104
110
  when "summary"
105
111
  all_dirs = view_groups(templates, partials)
106
- lines = views_header_lines(templates, partials, layouts)
112
+ lines = views_header_lines(templates, partials, layouts, controller: controller)
107
113
  all_dirs.each do |ctrl|
108
114
  ctrl_templates = views_in_group(templates, ctrl)
109
115
  ctrl_partials = views_in_group(partials, ctrl)
@@ -115,10 +121,12 @@ module RailsAiContext
115
121
  stim = meta[:stimulus]&.any? ? " stimulus: #{meta[:stimulus].join(', ')}" : ""
116
122
  comps = meta[:components]&.any? ? " components: #{meta[:components].join(', ')}" : ""
117
123
  phlex_tag = meta[:phlex] ? " [phlex]" : ""
118
- lines << "- #{name} (#{count_phrase(meta[:lines], "line")}#{phlex_tag})#{parts}#{comps}#{stim}"
124
+ alternate = (note = view_alternate_of(name)) ? " #{note}" : ""
125
+ lines << "- #{name} (#{count_phrase(meta[:lines], "line")}#{phlex_tag})#{alternate}#{parts}#{comps}#{stim}"
119
126
  end
120
127
  ctrl_partials.sort.each do |name, meta|
121
- lines << "- #{name} (#{count_phrase(meta[:lines], "line")})"
128
+ alternate = (note = view_alternate_of(name)) ? " #{note}" : ""
129
+ lines << "- #{name} (#{count_phrase(meta[:lines], "line")})#{alternate}"
122
130
  end
123
131
  lines << ""
124
132
  end
@@ -129,7 +137,7 @@ module RailsAiContext
129
137
  # want the same file's metadata.
130
138
  metadata = Hash.new { |h, name| h[name] = extract_view_metadata(name) }
131
139
  all_dirs = view_groups(templates, partials)
132
- lines = views_header_lines(templates, partials, layouts)
140
+ lines = views_header_lines(templates, partials, layouts, controller: controller)
133
141
 
134
142
  # Form builders and component usage from views introspector
135
143
  form_builders = data[:form_builders_detected]
@@ -148,7 +156,7 @@ module RailsAiContext
148
156
 
149
157
  lines << "## #{group_heading(ctrl)}" unless controller && all_dirs.size == 1
150
158
  ctrl_templates.sort.each do |name, meta|
151
- detail_parts = []
159
+ detail_parts = [ view_alternate_of(name) ].compact
152
160
  extra = metadata[name]
153
161
 
154
162
  if meta[:phlex]
@@ -178,7 +186,8 @@ module RailsAiContext
178
186
  helpers = meta[:helpers]&.any? ? " helpers: #{meta[:helpers].join(', ')}" : ""
179
187
  locals = extract_partial_locals(name, templates)
180
188
  locals_str = locals&.any? ? " **locals:** #{locals.join(', ')}" : ""
181
- lines << "- #{name} (#{count_phrase(meta[:lines], "line")})#{fields}#{helpers}#{locals_str}"
189
+ alternate = (note = view_alternate_of(name)) ? " #{note}" : ""
190
+ lines << "- #{name} (#{count_phrase(meta[:lines], "line")})#{alternate}#{fields}#{helpers}#{locals_str}"
182
191
  end
183
192
  lines << ""
184
193
  end
@@ -198,7 +207,7 @@ module RailsAiContext
198
207
 
199
208
  when "full"
200
209
  if controller
201
- lines = [ "# Views: #{controller}/", "" ]
210
+ lines = [ "# Views: #{controller}/", "", *layout_lines(controller) ]
202
211
  # Combine all content first for cross-template Tailwind compression
203
212
  all_content = []
204
213
  templates.sort.each do |name, _meta|
@@ -208,7 +217,7 @@ module RailsAiContext
208
217
  all_content << [ name, strip_svg(read_view_content(name)) ]
209
218
  end
210
219
  # Compress repeated Tailwind classes across all templates
211
- combined = all_content.map { |name, c| "## #{name}\n```erb\n#{c}\n```\n" }.join("\n")
220
+ combined = all_content.map { |name, c| "## #{name}\n```#{RailsAiContext::ViewFile.fence(name)}\n#{c}\n```\n" }.join("\n")
212
221
  combined = compress_tailwind(combined)
213
222
  lines << combined
214
223
  text_response(lines.join("\n"))
@@ -223,6 +232,7 @@ module RailsAiContext
223
232
  count = views_in_group(templates, ctrl).size + views_in_group(partials, ctrl).size
224
233
  lines << "- `controller:\"#{ctrl}\"` (#{count_phrase(count, "file")})"
225
234
  end
235
+ lines << "- `controller:\"layouts\"` (#{count_phrase(layouts.size, "layout")})" if layouts.any?
226
236
  lines << "" << "_Or use `path:\"controller/action.html.erb\"` for a specific file._"
227
237
  text_response(lines.join("\n"))
228
238
  end
@@ -263,13 +273,20 @@ module RailsAiContext
263
273
 
264
274
  # Layouts sit outside the template and partial maps, so a heading naming
265
275
  # only those two numbers never added up to the files under app/views.
266
- private_class_method def self.views_header_lines(templates, partials, layouts)
276
+ private_class_method def self.views_header_lines(templates, partials, layouts, controller: nil)
267
277
  parts = [ count_phrase(templates.size, "template"), count_phrase(partials.size, "partial") ]
268
278
  parts << count_phrase(layouts.size, "layout") if layouts.any?
269
279
 
270
280
  lines = [ "# Views (#{parts.join(', ')})", "" ]
281
+ lines << static_engine_views_note << "" if static_engine_views_note
271
282
  lines << "_Layouts are listed by `controller:\"layouts\"`._" << "" if layouts.any?
272
- lines
283
+ lines + layout_lines(controller)
284
+ end
285
+
286
+ private_class_method def self.layout_lines(controller)
287
+ name = controller && RailsAiContext::Payload.find_controller(cached_context, controller)
288
+ layout = name && Introspectors::ControllerSettings.resolve(cached_context, name, root: rails_app.root.to_s)[:layout]
289
+ layout ? [ "**Layout:** #{Introspectors::ControllerSettings.layout_phrase(layout)}", "" ] : []
273
290
  end
274
291
 
275
292
  # The template map excludes app/views/layouts, so the directory is the
@@ -293,7 +310,7 @@ module RailsAiContext
293
310
  real = located.realpath
294
311
  if RailsAiContext::DetailLevel.full?(detail)
295
312
  content = RailsAiContext::SafeFile.read(real) || "(error reading)"
296
- lines << "## #{relative}" << "```erb" << strip_svg(content) << "```" << ""
313
+ lines << "## #{relative}" << "```#{RailsAiContext::ViewFile.fence(relative)}" << strip_svg(content) << "```" << ""
297
314
  else
298
315
  content = RailsAiContext::SafeFile.read(real) || ""
299
316
  lines << "- #{relative} (#{count_phrase(content.lines.size, "line")})"
@@ -313,7 +330,7 @@ module RailsAiContext
313
330
  siblings = RailsAiContext::ViewFile.each(rails_app.root.to_s, File.join(dir, "*"))
314
331
  .map { |_file, relative| relative }.sort.first(10)
315
332
  hint = siblings.any? ? " Files in #{dir}/: #{siblings.join(', ')}" : ""
316
- return empty_response("View not found: #{path}.#{hint}")
333
+ return empty_response([ "View not found: #{path}.#{hint}", static_engine_views_note ].compact.join("\n\n"))
317
334
  end
318
335
  return text_response("Could not read file: #{path}") unless content
319
336
 
@@ -325,7 +342,7 @@ module RailsAiContext
325
342
  end
326
343
 
327
344
  content = compress_tailwind(strip_svg(content))
328
- text_response("# #{result.relative}\n\n```erb\n#{content}\n```")
345
+ text_response("# #{result.relative}\n\n```#{RailsAiContext::ViewFile.fence(result.relative)}\n#{content}\n```")
329
346
  end
330
347
 
331
348
  # Strip inline SVG blocks - they're visual noise that buries the signal AI needs.
@@ -426,7 +443,7 @@ module RailsAiContext
426
443
 
427
444
  private_class_method def self.read_from_disk(controller:, path:, detail:)
428
445
  if RailsAiContext::PathResolver.view_dirs(rails_app.root.to_s).empty?
429
- note = api_only_note("app/views")
446
+ note = api_only_note("app/views", dir: "app/views")
430
447
  return text_response(note) if note
431
448
 
432
449
  return text_response("No app/views directory found.")
@@ -457,8 +474,8 @@ module RailsAiContext
457
474
  end
458
475
 
459
476
  templates, partials = files.partition { |f| !File.basename(f).start_with?("_") }
460
- lines = views_header_lines(templates, partials, controller ? [] : layout_files)
461
- files.each { |f| lines << "- #{f}" }
477
+ lines = views_header_lines(templates, partials, controller ? [] : layout_files, controller: controller)
478
+ files.each { |f| lines << [ "- #{f}", view_alternate_of(f) ].compact.join(" - ") }
462
479
  text_response(lines.join("\n"))
463
480
  end
464
481
  end