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 "pathname"
4
+
3
5
  module RailsAiContext
4
6
  module Introspectors
5
7
  # Discovers test infrastructure: framework, factories/fixtures,
@@ -10,20 +12,34 @@ module RailsAiContext
10
12
 
11
13
  TEST_FILE_GLOB = "*_{spec,test}.rb"
12
14
 
15
+ # An engine's test/dummy is tested by the engine's suite.
16
+ def suite_root
17
+ @suite_root ||= PathResolver.test_root(root)
18
+ end
19
+
20
+ RAILS_CI = "config/ci.rb"
21
+
13
22
  def call
23
+ ci_steps, ci_steps_dir = detect_ci_steps
14
24
  {
15
25
  framework: detect_framework,
16
26
  factories: detect_factories,
17
27
  factory_names: detect_factory_names,
18
28
  computed_factories: factory_definitions[:computed].nonzero?,
29
+ fabricators: detect_fabricators,
30
+ fabricator_names: fabricator_definitions.presence,
31
+ cucumber: detect_cucumber,
19
32
  fixtures: detect_fixtures,
20
- fixture_names: detect_fixture_names,
33
+ fixture_names: fixture_labels[:names],
34
+ fixture_erb_labels: fixture_labels[:erb],
21
35
  system_tests: detect_system_tests,
22
36
  test_helpers: detect_test_helpers,
23
37
  test_helper_setup: detect_test_helper_setup,
24
38
  test_files: test_categories,
25
39
  vcr_cassettes: detect_vcr,
26
40
  ci_config: detect_ci,
41
+ ci_steps: ci_steps,
42
+ ci_steps_dir: ci_steps_dir,
27
43
  coverage: detect_coverage,
28
44
  factory_traits: detect_factory_traits,
29
45
  test_count_by_category: detect_test_count_by_category,
@@ -35,7 +51,7 @@ module RailsAiContext
35
51
  private
36
52
 
37
53
  def detect_framework
38
- RailsAiContext::TestFramework.for(root)
54
+ RailsAiContext::TestFramework.for(suite_root)
39
55
  rescue => e
40
56
  RailsAiContext.debug_fail(e, "unknown", label: "detect_framework")
41
57
  end
@@ -45,7 +61,7 @@ module RailsAiContext
45
61
  # answers the other way.
46
62
  def first_dir_with(glob, *rels)
47
63
  rels.each do |rel|
48
- dir = File.join(root, rel)
64
+ dir = File.join(suite_root, rel)
49
65
  next unless Dir.exist?(dir)
50
66
 
51
67
  count = Dir.glob(File.join(dir, "**", glob)).size
@@ -55,25 +71,180 @@ module RailsAiContext
55
71
  end
56
72
 
57
73
  def detect_factories
58
- first_dir_with("*.rb", "spec/factories", "test/factories")
74
+ locations_with_count(factory_sources)
75
+ end
76
+
77
+ # factory_bot's definition_file_paths: each is loaded as `path.rb` and
78
+ # as a directory.
79
+ FACTORY_PATHS = %w[factories test/factories spec/factories].freeze
80
+ FABRICATOR_PATHS = %w[test/fabricators spec/fabricators].freeze
81
+ UNREAD_FACTORY_PATH = "a path a test helper builds at run time (not read)"
82
+
83
+ # [location, files] for every place factory_bot loads definitions
84
+ # from, each pack's spec/factories and test/factories included as
85
+ # packs-rails adds them.
86
+ def factory_sources
87
+ @factory_sources ||= factory_definition_paths.flat_map do |rel|
88
+ next [ [ UNREAD_FACTORY_PATH, [] ] ] if rel.equal?(UNREAD_FACTORY_PATH)
89
+
90
+ single = app_files("#{rel}.rb")
91
+ files = app_files(File.join(rel, "**", "*.rb"))
92
+ [ ([ "#{rel}.rb", single ] if single.any?), ([ rel, files ] if files.any?) ].compact
93
+ end
94
+ end
95
+
96
+ # The paths factory_bot loads: factory_bot_rails finds the defaults before any
97
+ # helper runs, and a helper's writes count only once find_definitions (adds)
98
+ # or reload (replaces) runs after them.
99
+ def factory_definition_paths
100
+ paths = defaults = FACTORY_PATHS + pack_factory_paths
101
+ loaded = defaults if RailsAiContext::GemLock.for(root).present?("factory_bot_rails")
102
+ helper_paths.flat_map { |path| Array(helper_walk(path)&.dig(:definition_paths)) }.each do |event|
103
+ case event[:load]
104
+ when :find_definitions then loaded = Array(loaded) + paths
105
+ when :reload then loaded = paths
106
+ else
107
+ found = event[:paths].map { |rel| Pathname.new(rel).cleanpath.to_s }.reject { |rel| RailsAiContext::SafePath.traversal?(rel) }
108
+ found << UNREAD_FACTORY_PATH if event[:unread]
109
+ paths = event[:replace] ? found : paths + found
110
+ end
111
+ end
112
+ (loaded || defaults).uniq
113
+ end
114
+
115
+ # ponytail: packs-specification's default pack_paths; a packs.yml that
116
+ # sets its own pack_paths is not read.
117
+ def pack_factory_paths
118
+ Dir.glob(File.join(suite_root, "packs", "{*,*/*}", "package.yml")).sort.flat_map do |manifest|
119
+ pack = File.dirname(manifest)
120
+ next [] if Dir.glob(File.join(pack, "*.gemspec")).any?
121
+
122
+ rel = pack.delete_prefix("#{suite_root}/")
123
+ [ File.join(rel, "spec/factories"), File.join(rel, "test/factories") ]
124
+ end
125
+ end
126
+
127
+ def fabricator_sources
128
+ @fabricator_sources ||= FABRICATOR_PATHS.filter_map do |rel|
129
+ files = app_files(File.join(rel, "**", "*.rb"))
130
+ [ rel, files ] if files.any?
131
+ end
132
+ end
133
+
134
+ def detect_fabricators
135
+ locations_with_count(fabricator_sources)
136
+ end
137
+
138
+ def fabricator_definitions
139
+ fabricator_sources.flat_map(&:last).each_with_object({}) do |path, names|
140
+ found = SourceIntrospector.walk(path, { fabricators: -> { Listeners::GenericMacroListener.new(:Fabricator) } })[:fabricators]
141
+ named = found.map { |hit| hit[:args].first.to_s }.reject(&:empty?)
142
+ names[path.delete_prefix("#{suite_root}/")] = named if named.any?
143
+ end
144
+ end
145
+
146
+ def detect_cucumber
147
+ features = app_files("features/**/*.feature")
148
+ return nil if features.empty?
149
+
150
+ { location: "features", count: features.size, step_definitions: app_files("features/step_definitions/**/*.rb").size }
151
+ end
152
+
153
+ # A path a helper builds at run time has no files to count, so the count covers only the paths read.
154
+ def locations_with_count(sources)
155
+ return nil if sources.empty?
156
+
157
+ read = sources.reject { |location, _| location.equal?(UNREAD_FACTORY_PATH) }
158
+ found = { location: sources.map(&:first).join(", "), count: (read.sum { |_, files| files.size } if read.any?) }
159
+ read.size < sources.size ? found.merge(unread: true) : found
160
+ end
161
+
162
+ # The files a glob under the suite root matches, sorted, leaving out any whose
163
+ # real path leaves the app or names a sensitive file. A count (`read: false`)
164
+ # keeps a sensitive-named file that sits where it is listed, since it opens nothing.
165
+ # A directory is resolved once for all its files; only a linked file needs its own realpath.
166
+ def app_files(pattern, read: true)
167
+ real_root = (@real_root ||= File.realpath(suite_root))
168
+ real_dirs = {}
169
+ Dir.glob(File.join(suite_root, pattern)).sort.select do |path|
170
+ real = if File.lstat(path).symlink?
171
+ File.realpath(path)
172
+ else
173
+ dir = File.dirname(path)
174
+ File.join(real_dirs[dir] ||= File.realpath(dir), File.basename(path))
175
+ end
176
+ relative = real.delete_prefix("#{real_root}/")
177
+ linked = relative != path.delete_prefix("#{suite_root}/")
178
+ File.file?(real) && RailsAiContext::SafePath.contained?(real, real_root) &&
179
+ !((read || linked) && RailsAiContext::SafePath.sensitive?(relative))
180
+ rescue SystemCallError
181
+ false
182
+ end
59
183
  end
60
184
 
61
185
  # The YAML fixture sets Rails loads, and apart from them the other files
62
186
  # a fixtures directory holds for file_fixture: an app can keep one YAML file
63
187
  # beside hundreds of JSON, XML and binary ones.
64
188
  def detect_fixtures
65
- found = first_dir_with("*.yml", "spec/fixtures", "test/fixtures") or return nil
189
+ rows = fixture_dirs.map { |rel| [ rel, app_files(File.join(rel, "**", "*"), read: false) ] }
190
+ return nil if rows.empty?
66
191
 
67
- dir = File.join(root, found[:location])
68
- others = Dir.glob(File.join(dir, "**", "*")).count { |path| File.file?(path) && File.extname(path) != ".yml" }
192
+ sets = rows.sum { |_, files| files.count { |path| File.extname(path) == ".yml" } }
193
+ others = rows.sum { |_, files| files.count { |path| File.extname(path) != ".yml" } }
194
+ locations = rows.map(&:first)
195
+ found = { location: locations.join(", "), locations: locations, count: sets }
69
196
  others.positive? ? found.merge(other_files: others) : found
70
197
  end
71
198
 
199
+ DEFAULT_FIXTURE_DIRS = %w[spec/fixtures test/fixtures].freeze
200
+
201
+ # The first default directory that holds a set (an app with both reports
202
+ # one, as for factories), then each directory a test helper adds to
203
+ # fixture_paths that holds one and stays inside the app.
204
+ def fixture_dirs
205
+ @fixture_dirs ||= begin
206
+ real_root = File.realpath(suite_root)
207
+ writes = helper_paths
208
+ .to_h { |path| [ path, Array(helper_walk(path)&.dig(:fixture_paths)) ] }
209
+ configured = writes.values.flatten.grep(String)
210
+ defaults = DEFAULT_FIXTURE_DIRS.reject { |rel| rel == "spec/fixtures" && rspec_fixture_paths_known?(writes) }
211
+ candidates = [ defaults.find { |rel| fixture_sets?(rel, real_root) } ] +
212
+ configured.map { |rel| Pathname.new(rel).cleanpath.to_s }
213
+ candidates.compact.uniq.select { |rel| fixture_sets?(rel, real_root) }
214
+ end
215
+ end
216
+
217
+ # rspec-rails gives fixture_paths no default, so an RSpec suite loads only the
218
+ # directories its helpers name; spec/fixtures stays only when a write under spec/ is unreadable.
219
+ def rspec_fixture_paths_known?(writes)
220
+ spec_dir = File.join(suite_root, "spec/")
221
+ %w[spec/rails_helper.rb spec/spec_helper.rb].any? { |rel| File.file?(File.join(suite_root, rel)) } &&
222
+ writes.none? { |path, found| path.start_with?(spec_dir) && found.include?(:unread) }
223
+ end
224
+
225
+ def helper_paths
226
+ HELPER_FILES.map { |rel| File.join(suite_root, rel) } + support_files
227
+ end
228
+
229
+ def support_files
230
+ @support_files ||= HELPER_DIRS.flat_map { |rel| Dir.glob(File.join(suite_root, rel, "**", "*.rb")).sort }
231
+ end
232
+
233
+ def fixture_sets?(rel, real_root)
234
+ return false if RailsAiContext::SafePath.traversal?(rel)
235
+
236
+ real = File.realpath(File.join(suite_root, rel))
237
+ RailsAiContext::SafePath.contained?(real, real_root) && File.directory?(real) &&
238
+ Dir.glob(File.join(real, "**", "*.yml")).any?
239
+ rescue SystemCallError
240
+ false
241
+ end
242
+
72
243
  # Both bases are summed: an app that keeps system tests under spec/ and
73
244
  # test/ has both, and reporting one hid the other.
74
245
  def detect_system_tests
75
246
  rows = %w[spec/system test/system].filter_map do |rel|
76
- dir = File.join(root, rel)
247
+ dir = File.join(suite_root, rel)
77
248
  next unless Dir.exist?(dir)
78
249
 
79
250
  count = Dir.glob(File.join(dir, "**/#{TEST_FILE_GLOB}")).size
@@ -84,16 +255,12 @@ module RailsAiContext
84
255
  { location: rows.map(&:first).join(", "), count: rows.sum(&:last) }
85
256
  end
86
257
 
87
- def detect_test_helpers
88
- dirs = [
89
- File.join(root, "spec/support"),
90
- File.join(root, "test/helpers")
91
- ]
258
+ # Where suites keep helper modules. test/helpers is not one: Rails runs
259
+ # it as a test folder for helper tests.
260
+ HELPER_DIRS = %w[spec/support test/support test/test_helpers].freeze
92
261
 
93
- dirs.filter_map do |dir|
94
- next unless Dir.exist?(dir)
95
- Dir.glob(File.join(dir, "**/*.rb")).map { |f| f.sub("#{root}/", "") }
96
- end.flatten.sort
262
+ def detect_test_helpers
263
+ HELPER_DIRS.flat_map { |rel| Dir.glob(File.join(suite_root, rel, "**/*.rb")) }.map { |f| f.sub("#{suite_root}/", "") }.sort
97
264
  end
98
265
 
99
266
  def detect_factory_names
@@ -103,87 +270,135 @@ module RailsAiContext
103
270
  # One walk per factory file for both the factories and their traits.
104
271
  # A factory whose name is computed (`factory :"#{model}_comment"` in a
105
272
  # loop, as Consul writes) has no name to list, so it is counted apart.
106
- # The first factory directory that has any factory wins.
107
273
  def factory_definitions
108
274
  @factory_definitions ||= begin
109
- found = { names: nil, traits: nil, computed: 0 }
110
- %w[spec/factories test/factories].each do |dir_rel|
111
- dir = File.join(root, dir_rel)
112
- next unless Dir.exist?(dir)
113
-
114
- names = {}
115
- traits = {}
116
- computed = 0
117
- Dir.glob(File.join(dir, "**/*.rb")).each do |path|
118
- ast_data = SourceIntrospector.walk(path, {
119
- factories: -> { Listeners::GenericMacroListener.new(:factory) },
120
- traits: -> { Listeners::GenericMacroListener.new(:trait) }
121
- })
122
- named = ast_data[:factories].map { |hit| hit[:args].first.to_s }
123
- computed += named.count(&:empty?)
124
- named = named.reject(&:empty?)
125
- trait_names = ast_data[:traits].map { |hit| hit[:args].first.to_s }.reject(&:empty?)
126
- names[path.sub("#{root}/", "")] = named if named.any?
127
- traits[File.basename(path)] = trait_names if trait_names.any?
128
- end
129
- next if names.empty? && traits.empty? && computed.zero?
130
-
131
- found = { names: names.presence, traits: traits.presence, computed: computed }
132
- break
275
+ names = {}
276
+ traits = {}
277
+ computed = 0
278
+ factory_sources.flat_map(&:last).each do |path|
279
+ ast_data = SourceIntrospector.walk(path, {
280
+ factories: -> { Listeners::GenericMacroListener.new(:factory) },
281
+ traits: -> { Listeners::GenericMacroListener.new(:trait) }
282
+ })
283
+ named = ast_data[:factories].map { |hit| hit[:args].first.to_s }
284
+ computed += named.count(&:empty?)
285
+ named = named.reject(&:empty?)
286
+ trait_names = ast_data[:traits].map { |hit| hit[:args].first.to_s }.reject(&:empty?)
287
+ names[path.sub("#{suite_root}/", "")] = named if named.any?
288
+ traits[File.basename(path)] = trait_names if trait_names.any?
133
289
  end
134
- found
290
+ { names: names.presence, traits: traits.presence, computed: computed }
135
291
  end
136
292
  end
137
293
 
138
- def detect_fixture_names
139
- %w[spec/fixtures test/fixtures].each do |dir_rel|
140
- dir = File.join(root, dir_rel)
141
- next unless Dir.exist?(dir)
142
-
294
+ # Each set named as Rails names it, by its path under its directory,
295
+ # with the labels ActiveRecord loads. A file that does not read as YAML
296
+ # still gives the labels its top-level keys spell. A label ERB computes
297
+ # names no row we can write, so it is kept apart, shown as ERB.
298
+ def fixture_labels
299
+ @fixture_labels ||= begin
143
300
  names = {}
144
- Dir.glob(File.join(dir, "**/*.yml")).each do |path|
145
- # The set's name, as Rails reads it: its path under the directory.
146
- file = path.delete_prefix("#{dir}/").delete_suffix(".yml")
301
+ erb = {}
302
+ read_fixture_labels(names, erb)
303
+ { names: names.presence, erb: erb.presence }
304
+ end
305
+ end
306
+
307
+ def read_fixture_labels(names, erb)
308
+ fixture_dirs.each do |rel|
309
+ dir = File.join(suite_root, rel)
310
+ app_files(File.join(rel, "**", "*.yml")).each do |path|
311
+ set = path.delete_prefix("#{dir}/").delete_suffix(".yml")
312
+ next if names.key?(set)
313
+
147
314
  content = RailsAiContext::SafeFile.read(path) or next
148
- keys = content.scan(/^(\w+):/).flatten.select { |key| RailsAiContext::FixtureKeys.name?(key) }
149
- names[file] = keys if keys.any?
315
+ labels = RailsAiContext::FixtureKeys.parse(content)&.keys ||
316
+ content.scan(/^(\w+):/).flatten.select { |key| RailsAiContext::FixtureKeys.name?(key) }
317
+ computed, named = labels.partition { |label| RailsAiContext::ConfigYaml.marked?(label) }
318
+ names[set] = named if labels.any?
319
+ erb[set] = computed.map { |label| RailsAiContext::ConfigYaml.shown(label) } if computed.any?
150
320
  end
151
- return names if names.any?
152
321
  end
153
- nil
154
322
  end
155
323
 
324
+ # Calls that configure every test or every system test, shown as written.
325
+ SETUP_MACROS = %i[parallelize fixtures driven_by].freeze
326
+
327
+ HELPER_FILES = %w[spec/rails_helper.rb spec/spec_helper.rb test/test_helper.rb].freeze
328
+
156
329
  def detect_test_helper_setup
157
- helpers = %w[spec/rails_helper.rb spec/spec_helper.rb test/test_helper.rb].map { |rel| File.join(root, rel) }
330
+ helpers = (HELPER_FILES + %w[test/application_system_test_case.rb]).map { |rel| File.join(suite_root, rel) }
158
331
  # Apps also configure helpers in support files (Errbit's spec/support/devise.rb). A bare
159
332
  # include there is usually a support module's own mixin, so only config.include counts.
160
- support = %w[spec/support test/support].flat_map { |rel| Dir.glob(File.join(root, rel, "**", "*.rb")).sort }
333
+ support = support_files
161
334
 
162
335
  setup = []
336
+ calls = []
163
337
  (helpers + support).each do |path|
164
- next unless File.file?(path)
165
- ast = SourceIntrospector.walk(path, {
166
- bare: -> { Listeners::GenericMacroListener.new(:include) },
167
- chained: -> { Listeners::ChainedCallListener.new(:include, receiver: :config) }
168
- })
338
+ ast = helper_walk(path) or next
169
339
  hits = helpers.include?(path) ? ast[:bare] + ast[:chained] : ast[:chained]
170
340
  # `config.include Helpers, :js` scopes Helpers to tagged examples; the tag is no helper.
171
341
  hits.each { |hit| setup.concat(hit[:values].map(&:to_s).grep(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/)) }
342
+ source = AstCache.parse(path).source
343
+ ast[:setup].each { |hit| calls << source.slice(hit[:offset], hit[:end_offset] - hit[:offset]).squish }
172
344
  end
173
- setup.uniq
345
+ (setup + calls).uniq
346
+ end
347
+
348
+ # One walk per helper file serves every reader of it.
349
+ def helper_walk(path)
350
+ @helper_walks ||= {}
351
+ return @helper_walks[path] if @helper_walks.key?(path)
352
+
353
+ @helper_walks[path] = File.file?(path) ? SourceIntrospector.walk(path, {
354
+ bare: -> { Listeners::GenericMacroListener.new(:include) },
355
+ chained: -> { Listeners::ChainedCallListener.new(:include, receiver: :config) },
356
+ setup: -> { Listeners::GenericMacroListener.new(*SETUP_MACROS) },
357
+ fixture_paths: -> { Listeners::FixturePathsListener.new(file: path.delete_prefix("#{suite_root}/")) },
358
+ definition_paths: -> { Listeners::DefinitionFilePathsListener.new(file: path.delete_prefix("#{suite_root}/")) },
359
+ cleaner: -> { Listeners::ConfigAssignmentListener.new(:DatabaseCleaner) }
360
+ }) : nil
174
361
  end
175
362
 
176
363
  def detect_vcr
177
364
  first_dir_with("*.yml", "spec/cassettes", "spec/vcr_cassettes", "test/cassettes", "test/vcr_cassettes")
178
365
  end
179
366
 
367
+ # A dummy's own config/ci.rb and its engine's .github are both the project's CI.
180
368
  def detect_ci
181
- configs = []
182
- configs << "github_actions" if Dir.exist?(File.join(root, ".github/workflows"))
183
- configs << "circleci" if File.exist?(File.join(root, ".circleci/config.yml"))
184
- configs << "gitlab_ci" if File.exist?(File.join(root, ".gitlab-ci.yml"))
185
- configs << "travis" if File.exist?(File.join(root, ".travis.yml"))
186
- configs
369
+ ci_roots.flat_map do |dir|
370
+ configs = []
371
+ configs << "rails_ci" if File.file?(File.join(dir, RAILS_CI))
372
+ configs << "github_actions" if Dir.exist?(File.join(dir, ".github/workflows"))
373
+ configs << "circleci" if File.exist?(File.join(dir, ".circleci/config.yml"))
374
+ configs << "gitlab_ci" if File.exist?(File.join(dir, ".gitlab-ci.yml"))
375
+ configs << "travis" if File.exist?(File.join(dir, ".travis.yml"))
376
+ configs << "buildkite" if Dir.exist?(File.join(dir, ".buildkite")) || Dir.glob(File.join(dir, "buildkite.{yml,yaml,json}")).any?
377
+ configs << "jenkins" if File.file?(File.join(dir, "Jenkinsfile"))
378
+ configs << "bitbucket_pipelines" if File.file?(File.join(dir, "bitbucket-pipelines.yml"))
379
+ configs
380
+ end.uniq
381
+ end
382
+
383
+ def ci_roots
384
+ [ root, suite_root ].uniq
385
+ end
386
+
387
+ # The steps bin/ci runs, from the `step title, *command` calls of the
388
+ # CI DSL Rails 8.1 generates, and the directory of config/ci.rb when it is not the suite root.
389
+ def detect_ci_steps
390
+ dir, content = ci_roots.lazy.filter_map { |d| (text = RailsAiContext::SafePath.read(RAILS_CI, under: d).first) && [ d, text ] }.first
391
+ return nil unless content
392
+
393
+ # Paths in the answer are under the suite root, which for a test/dummy is the engine's.
394
+ ci_dir = "#{Pathname.new(PathResolver.root_key(dir)).relative_path_from(Pathname.new(PathResolver.root_key(suite_root)))}/" unless dir == suite_root
395
+
396
+ hits = SourceIntrospector.walk_source(content, steps: -> { Listeners::GenericMacroListener.new(:step) })[:steps]
397
+ steps = hits.filter_map do |hit|
398
+ title, *command = hit[:values]
399
+ { name: title, command: command.join(" ") } if title.is_a?(String)
400
+ end
401
+ [ steps.presence, ci_dir ]
187
402
  end
188
403
 
189
404
  def detect_coverage
@@ -199,7 +414,7 @@ module RailsAiContext
199
414
  def detect_shared_examples
200
415
  shared = []
201
416
  %w[spec test].each do |base|
202
- support_dir = File.join(root, base, "support")
417
+ support_dir = File.join(suite_root, base, "support")
203
418
  next unless Dir.exist?(support_dir)
204
419
  Dir.glob(File.join(support_dir, "**/*.rb")).each do |path|
205
420
  ast_data = SourceIntrospector.walk(path, {
@@ -208,7 +423,7 @@ module RailsAiContext
208
423
  ast_data[:shared].each do |entry|
209
424
  name = entry[:args].first&.to_s
210
425
  next unless name && !name.empty?
211
- shared << { name: name, file: path.sub("#{root}/", "") }
426
+ shared << { name: name, file: path.sub("#{suite_root}/", "") }
212
427
  end
213
428
  end
214
429
  end
@@ -222,12 +437,8 @@ module RailsAiContext
222
437
  # complete signal; the unsuffixed name is the pre-2.0 single gem.
223
438
  if RailsAiContext::GemLock.for(root).any?("database_cleaner", "database_cleaner-core")
224
439
  strategy = nil
225
- %w[spec/rails_helper.rb spec/spec_helper.rb test/test_helper.rb].each do |helper|
226
- path = File.join(root, helper)
227
- next unless File.exist?(path)
228
- ast = SourceIntrospector.walk(path, {
229
- cleaner: -> { Listeners::ConfigAssignmentListener.new(:DatabaseCleaner) }
230
- })
440
+ HELPER_FILES.each do |helper|
441
+ ast = helper_walk(File.join(suite_root, helper)) or next
231
442
  hit = ast[:cleaner].find { |h| h[:assignment] && h[:path] == [ :strategy ] }
232
443
  strategy = hit[:value].to_s if hit
233
444
  end
@@ -261,7 +472,7 @@ module RailsAiContext
261
472
  rows = Hash.new { |h, k| h[k] = [] }
262
473
 
263
474
  %w[spec test].each do |base|
264
- base_dir = File.join(root, base)
475
+ base_dir = File.join(suite_root, base)
265
476
  next unless Dir.exist?(base_dir)
266
477
 
267
478
  Dir.children(base_dir).sort.each do |entry|
@@ -9,7 +9,7 @@ module RailsAiContext
9
9
 
10
10
  MODEL_BROADCAST_MACROS = %w[broadcasts broadcasts_to broadcasts_refreshes broadcasts_refreshes_to].freeze
11
11
  BROADCAST_CALL = /\Abroadcast_\w+_to\z/
12
- BROADCAST_KINDS = %w[app/controllers app/models app/services app/jobs app/workers app/channels].freeze
12
+ BROADCAST_KINDS = %w[app/controllers app/services app/jobs app/workers app/channels].freeze
13
13
 
14
14
  # A mention of these helpers anywhere in a controller is the signal,
15
15
  # whether called, referenced or guarded. Vocabulary, not structure, so
@@ -90,7 +90,7 @@ module RailsAiContext
90
90
  # The views root a path was found under, for the containment check that
91
91
  # keeps a symlink escaping the tree out of the answer.
92
92
  def views_root_of(path)
93
- PathResolver.view_dirs(root).find { |dir| path.start_with?("#{dir}/") } || views_dir
93
+ RailsAiContext::ViewFile.root_for(path, PathResolver.view_dirs(root)) || views_dir
94
94
  end
95
95
 
96
96
  # Views are not Ruby, so they are read line by line. Files are yielded
@@ -187,10 +187,15 @@ module RailsAiContext
187
187
  def scan_broadcasts
188
188
  models = []
189
189
  explicit = []
190
- BROADCAST_KINDS.each do |kind|
191
- SourceScan.each(root, kind: kind, skip_concerns: false) do |record|
190
+ seen = Set.new
191
+ # A model outside app/models also sits under another kind's directory.
192
+ [ :models, *BROADCAST_KINDS ].each do |kind|
193
+ records = kind == :models ? model_sources(skip_concerns: false) : SourceScan.each(root, kind: kind, skip_concerns: false)
194
+ records.each do |record|
195
+ next unless seen.add?(record.path)
196
+
192
197
  walked = walk_broadcasts(record.source)
193
- models.concat(model_entries(record, walked[:macros])) if kind == "app/models"
198
+ models.concat(model_entries(record, walked[:macros])) if kind == :models
194
199
  explicit.concat(explicit_entries(record, walked[:calls]))
195
200
  end
196
201
  end
@@ -17,8 +17,7 @@ module RailsAiContext
17
17
  view_components: extract_view_components,
18
18
  template_engines: detect_template_engines,
19
19
  form_builders_detected: detect_form_builders,
20
- component_usage: detect_component_usage,
21
- conditional_layouts: detect_conditional_layouts
20
+ component_usage: detect_component_usage
22
21
  }
23
22
  end
24
23
 
@@ -127,9 +126,12 @@ module RailsAiContext
127
126
  return [] unless Dir.exist?(dir)
128
127
 
129
128
  Dir.glob(File.join(dir, "**/*.rb")).filter_map do |path|
129
+ source = RailsAiContext::SafeFile.read(path)
130
+ next if source && !HelperNames.view_helper?(source, path, dir)
131
+
130
132
  relative = path.sub("#{dir}/", "")
131
133
  module_name = relative.sub(/\.rb\z/, "").camelize
132
- ast_data = SourceIntrospector.walk(path, { methods: Listeners::MethodsListener })
134
+ ast_data = SourceIntrospector.walk(path, { methods: Listeners::MethodsListener }, source: source)
133
135
  methods = ActionResolver.own_methods(ast_data[:methods], module_name).map { |m| m[:name] }
134
136
  {
135
137
  file: relative,
@@ -202,43 +204,6 @@ module RailsAiContext
202
204
  rescue => e
203
205
  RailsAiContext.debug_fail(e, [], label: "detect_component_usage")
204
206
  end
205
-
206
- def detect_conditional_layouts
207
- layouts = []
208
- controllers_dir = File.join(app.root, "app", "controllers")
209
- return layouts unless Dir.exist?(controllers_dir)
210
-
211
- Dir.glob(File.join(controllers_dir, "**", "*.rb")).each do |path|
212
- ast_data = SourceIntrospector.walk(path, {
213
- layout_calls: -> { Listeners::GenericMacroListener.new(:layout) }
214
- })
215
-
216
- ast_data[:layout_calls].each do |macro|
217
- layout_name = macro[:args]&.first&.to_s
218
- # Also check options for string-based layout names
219
- layout_name ||= macro[:options].values.first&.to_s if macro[:options]&.any?
220
- next unless layout_name
221
-
222
- entry = { layout: layout_name, controller: File.basename(path, ".rb").camelize }
223
- opts = macro[:options] || {}
224
- entry[:only] = Array(opts[:only]).map(&:to_s) if opts[:only]
225
- entry[:except] = Array(opts[:except]).map(&:to_s) if opts[:except]
226
-
227
- # Build condition string from options for backward compat
228
- condition_parts = []
229
- condition_parts << "only: #{opts[:only].inspect}" if opts[:only]
230
- condition_parts << "except: #{opts[:except].inspect}" if opts[:except]
231
- entry[:condition] = condition_parts.join(", ") if condition_parts.any?
232
-
233
- layouts << entry
234
- end
235
- rescue => e
236
- RailsAiContext.debug_fail(e, label: "detect_conditional_layouts")
237
- end
238
- layouts
239
- rescue => e
240
- RailsAiContext.debug_fail(e, [], label: "detect_conditional_layouts")
241
- end
242
207
  end
243
208
  end
244
209
  end
@@ -363,6 +363,9 @@ module RailsAiContext
363
363
  # A partial or template name: a path, never text with spaces or quotes in it.
364
364
  PARTIAL_NAME = %r{\A[\w./-]+\z}
365
365
  RENDER_NAMED = /(?:\b(?:partial|template)\s*:|:(?:partial|template)\s*=>)\s*["']([^"']+)["']/
366
+ # `render @posts`, `render(post)`, `render @posts, cached: true`: a bare record or collection, or a
367
+ # chain on one (`@post.comments.recent`), matched against "render <args>"; RenderedRecord reads the chain.
368
+ IMPLICIT_RENDER = /\Arender\s*\(?\s*@?((?:[a-z_]\w*\.)*[a-z_]\w*)\s*(?:[,)]|\s(?:if|unless)\b|-?\s*\z)/
366
369
 
367
370
  def extract_partial_refs(content)
368
371
  refs = []
@@ -372,22 +375,21 @@ module RailsAiContext
372
375
  named = [ args[RENDER_POSITIONAL, 2], *top_level(args).scan(RENDER_NAMED).flatten ].compact
373
376
  # An interpolated name is decided at runtime, not a partial on disk.
374
377
  refs.concat(named.select { |name| name.match?(PARTIAL_NAME) })
375
- end
376
- # render @collection
377
- content.scan(/render\s+@(\w+)/).each { |m| refs << m[0] }
378
- # render article / render(article) - Rails 7+ bare local-variable/object
379
- # render (the scaffold default: `render @articles.each { |article| render article }`).
380
- # The negative lookahead excludes keyword-argument hashes like
381
- # `render json: ...` / `render partial: ...`, where a colon immediately
382
- # follows the identifier instead of a statement boundary.
383
- content.scan(/\brender[\s(]+([a-z_]\w*)\b(?!\s*:)/).each do |m|
384
- refs << m[0] unless RENDER_KEYWORD_ARGS.include?(m[0])
378
+ chain = self.class.render_line(args)[IMPLICIT_RENDER, 1] if named.empty?
379
+ model, collection = chain && !RENDER_KEYWORD_ARGS.include?(chain) && RenderedRecord.resolve(chain, root, @rendered_models ||= {})
380
+ # The records' model names the partial; `replies` with class_name "Comment" renders comments/_comment.
381
+ refs << (collection ? model.pluralize : model) if model
385
382
  end
386
383
  # Phlex: render ComponentName.new(...) or render(ComponentName.new(...))
387
384
  content.scan(/render[\s(]+([A-Z]\w+(?:::\w+)*)\.new/).each { |m| refs << m[0] }
388
385
  refs.uniq
389
386
  end
390
387
 
388
+ # A render call's ERB-free arguments on one line, as IMPLICIT_RENDER reads them.
389
+ def self.render_line(args)
390
+ "render #{args.split("%>", 2).first.gsub(/\s+/, " ").strip}"
391
+ end
392
+
391
393
  # Each render call as [offset, argument text]: its balanced parentheses,
392
394
  # or the rest of the line and every line a trailing comma continues onto.
393
395
  def self.render_calls(content)