rails-ai-context 5.32.2 → 5.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1491 -0
  3. data/CONTEXT.md +27 -3
  4. data/README.md +16 -12
  5. data/Rakefile +6 -0
  6. data/app/controllers/rails_ai_context/mcp_controller.rb +112 -84
  7. data/config/routes.rb +2 -3
  8. data/docs/ARCHITECTURE.md +7 -5
  9. data/docs/CLI.md +79 -6
  10. data/docs/COMPATIBILITY.md +75 -1
  11. data/docs/CONFIGURATION.md +30 -11
  12. data/docs/CUSTOM_TOOLS.md +2 -1
  13. data/docs/FAQ.md +16 -6
  14. data/docs/GUIDE.md +117 -71
  15. data/docs/INTROSPECTORS.md +10 -8
  16. data/docs/QUICKSTART.md +3 -3
  17. data/docs/RECIPES.md +1 -1
  18. data/docs/SECURITY.md +109 -25
  19. data/docs/SETUP.md +30 -6
  20. data/docs/STANDALONE.md +10 -3
  21. data/docs/TOOLS.md +152 -34
  22. data/docs/TROUBLESHOOTING.md +90 -12
  23. data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
  24. data/docs/adr/0005-workspace-mcp-entries.md +39 -0
  25. data/exe/rails-ai-context +733 -104
  26. data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
  27. data/lib/rails_ai_context/app_kind.rb +18 -5
  28. data/lib/rails_ai_context/ast_cache.rb +7 -0
  29. data/lib/rails_ai_context/base_mixins.rb +8 -8
  30. data/lib/rails_ai_context/boot_manager.rb +22 -3
  31. data/lib/rails_ai_context/change_watch.rb +14 -11
  32. data/lib/rails_ai_context/cli/app_root.rb +401 -0
  33. data/lib/rails_ai_context/cli/entry_boot.rb +58 -22
  34. data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
  35. data/lib/rails_ai_context/code_reloader.rb +115 -1
  36. data/lib/rails_ai_context/concern_membership.rb +3 -1
  37. data/lib/rails_ai_context/concern_paths.rb +8 -3
  38. data/lib/rails_ai_context/configuration.rb +41 -9
  39. data/lib/rails_ai_context/context_file_report.rb +19 -2
  40. data/lib/rails_ai_context/database_file.rb +26 -0
  41. data/lib/rails_ai_context/doctor.rb +1122 -184
  42. data/lib/rails_ai_context/engine.rb +18 -1
  43. data/lib/rails_ai_context/erb_source.rb +19 -0
  44. data/lib/rails_ai_context/executable.rb +21 -0
  45. data/lib/rails_ai_context/facts_formatter.rb +5 -2
  46. data/lib/rails_ai_context/file_walk.rb +61 -10
  47. data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
  48. data/lib/rails_ai_context/fingerprinter.rb +40 -12
  49. data/lib/rails_ai_context/gem_lock.rb +186 -27
  50. data/lib/rails_ai_context/git_ignore.rb +22 -0
  51. data/lib/rails_ai_context/install/ai_tool.rb +31 -5
  52. data/lib/rails_ai_context/install/cleanup.rb +21 -0
  53. data/lib/rails_ai_context/install/program.rb +209 -35
  54. data/lib/rails_ai_context/install/selection_record.rb +258 -97
  55. data/lib/rails_ai_context/install/surface.rb +6 -1
  56. data/lib/rails_ai_context/install/validation_hook.rb +282 -0
  57. data/lib/rails_ai_context/install/workspace.rb +166 -0
  58. data/lib/rails_ai_context/install_mode.rb +71 -13
  59. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
  60. data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
  61. data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
  62. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
  63. data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
  64. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
  65. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
  66. data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
  67. data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
  68. data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
  69. data/lib/rails_ai_context/introspectors/auth_introspector.rb +15 -10
  70. data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
  71. data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
  72. data/lib/rails_ai_context/introspectors/controller_introspector.rb +387 -60
  73. data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
  74. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +15 -3
  75. data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
  76. data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
  77. data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
  78. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
  79. data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
  80. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
  81. data/lib/rails_ai_context/introspectors/gem_introspector.rb +23 -10
  82. data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
  83. data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
  84. data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
  85. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -3
  86. data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
  87. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
  88. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +78 -4
  89. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
  90. data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
  91. data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
  92. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +13 -4
  93. data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
  94. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
  95. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +29 -8
  96. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
  97. data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
  98. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
  99. data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
  100. data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
  101. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
  102. data/lib/rails_ai_context/introspectors/migration_replay.rb +15 -14
  103. data/lib/rails_ai_context/introspectors/model_introspector.rb +231 -23
  104. data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
  105. data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
  106. data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
  107. data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
  108. data/lib/rails_ai_context/introspectors/schema_introspector.rb +66 -16
  109. data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
  110. data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
  111. data/lib/rails_ai_context/introspectors/source_scan.rb +39 -9
  112. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
  113. data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
  114. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +131 -12
  115. data/lib/rails_ai_context/introspectors/view_introspector.rb +4 -4
  116. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
  117. data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
  118. data/lib/rails_ai_context/live_reload.rb +20 -20
  119. data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
  120. data/lib/rails_ai_context/mcp_edge.rb +182 -32
  121. data/lib/rails_ai_context/middleware.rb +5 -1
  122. data/lib/rails_ai_context/migration_status.rb +88 -25
  123. data/lib/rails_ai_context/output_guard.rb +48 -2
  124. data/lib/rails_ai_context/package_json.rb +1 -1
  125. data/lib/rails_ai_context/path_resolver.rb +46 -13
  126. data/lib/rails_ai_context/payload.rb +92 -8
  127. data/lib/rails_ai_context/pending_migrations.rb +5 -1
  128. data/lib/rails_ai_context/portable_path.rb +3 -2
  129. data/lib/rails_ai_context/presets.rb +15 -11
  130. data/lib/rails_ai_context/redaction.rb +262 -51
  131. data/lib/rails_ai_context/resources.rb +55 -20
  132. data/lib/rails_ai_context/route_coverage.rb +8 -0
  133. data/lib/rails_ai_context/safe_file.rb +14 -1
  134. data/lib/rails_ai_context/safe_path.rb +101 -4
  135. data/lib/rails_ai_context/schema_adapter.rb +8 -0
  136. data/lib/rails_ai_context/schema_version.rb +9 -0
  137. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +88 -33
  138. data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
  139. data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
  140. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
  141. data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
  142. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +19 -16
  143. data/lib/rails_ai_context/serializers/guide_examples.rb +142 -0
  144. data/lib/rails_ai_context/serializers/markdown_serializer.rb +146 -30
  145. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +38 -15
  146. data/lib/rails_ai_context/serializers/section_facts.rb +44 -1
  147. data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
  148. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
  149. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
  150. data/lib/rails_ai_context/server.rb +304 -71
  151. data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -372
  152. data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
  153. data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
  154. data/lib/rails_ai_context/tools/base_tool.rb +367 -46
  155. data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
  156. data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
  157. data/lib/rails_ai_context/tools/diagnose.rb +218 -11
  158. data/lib/rails_ai_context/tools/generate_test.rb +339 -39
  159. data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
  160. data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
  161. data/lib/rails_ai_context/tools/get_concern.rb +91 -27
  162. data/lib/rails_ai_context/tools/get_config.rb +13 -4
  163. data/lib/rails_ai_context/tools/get_context.rb +71 -14
  164. data/lib/rails_ai_context/tools/get_controllers.rb +179 -44
  165. data/lib/rails_ai_context/tools/get_conventions.rb +28 -6
  166. data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
  167. data/lib/rails_ai_context/tools/get_env.rb +18 -9
  168. data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
  169. data/lib/rails_ai_context/tools/get_gems.rb +1 -1
  170. data/lib/rails_ai_context/tools/get_helper_methods.rb +10 -6
  171. data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
  172. data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -2
  173. data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
  174. data/lib/rails_ai_context/tools/get_model_details.rb +30 -10
  175. data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
  176. data/lib/rails_ai_context/tools/get_routes.rb +29 -8
  177. data/lib/rails_ai_context/tools/get_schema.rb +52 -19
  178. data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
  179. data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
  180. data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
  181. data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
  182. data/lib/rails_ai_context/tools/get_view.rb +14 -14
  183. data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
  184. data/lib/rails_ai_context/tools/onboard.rb +52 -8
  185. data/lib/rails_ai_context/tools/performance_check.rb +5 -3
  186. data/lib/rails_ai_context/tools/query.rb +914 -86
  187. data/lib/rails_ai_context/tools/read_logs.rb +69 -15
  188. data/lib/rails_ai_context/tools/review_changes.rb +215 -53
  189. data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
  190. data/lib/rails_ai_context/tools/safe_call.rb +35 -14
  191. data/lib/rails_ai_context/tools/search_code.rb +243 -67
  192. data/lib/rails_ai_context/tools/search_docs.rb +4 -4
  193. data/lib/rails_ai_context/tools/security_scan.rb +143 -33
  194. data/lib/rails_ai_context/tools/session_context.rb +39 -5
  195. data/lib/rails_ai_context/tools/validate.rb +259 -40
  196. data/lib/rails_ai_context/tools/validate_semantics.rb +130 -56
  197. data/lib/rails_ai_context/version.rb +1 -1
  198. data/lib/rails_ai_context/vfs.rb +67 -40
  199. data/lib/rails_ai_context/view_file.rb +41 -3
  200. data/lib/rails_ai_context/watcher.rb +51 -13
  201. data/lib/rails_ai_context.rb +21 -0
  202. data/server.json +1 -1
  203. metadata +14 -1
@@ -22,14 +22,14 @@ module RailsAiContext
22
22
  # the fingerprint and the conventions all share.
23
23
  def controller_paths(root)
24
24
  JS_ROOTS.flat_map { |dir| PathResolver.dirs_for(root, dir) }
25
- .flat_map { |js_root| controller_files(js_root).map { |path| [ path, js_root ] } }
25
+ .flat_map { |js_root| controller_files(js_root, root).map { |path| [ path, js_root ] } }
26
26
  .uniq(&:first).sort_by(&:first)
27
27
  end
28
28
 
29
29
  # Walked rather than globbed so node_modules is pruned instead of
30
30
  # entered: globbing one real frontend/node_modules costs seconds.
31
- def controller_files(js_root)
32
- FileWalk.each_file(js_root, skip: SKIP_DIRS).select { |path| File.basename(path).match?(CONTROLLER_FILE) }
31
+ def controller_files(js_root, root)
32
+ FileWalk.each_file(js_root, skip: SKIP_DIRS, root: root.to_s).select { |path| File.basename(path).match?(CONTROLLER_FILE) }
33
33
  end
34
34
 
35
35
  JS_FILE = /\.(?:js|ts|jsx|tsx)\z/
@@ -134,7 +134,7 @@ module RailsAiContext
134
134
  def js_files(root)
135
135
  JS_ROOTS.flat_map { |dir| PathResolver.dirs_for(root, dir) }
136
136
  .flat_map { |js_root|
137
- FileWalk.each_file(js_root, skip: SKIP_DIRS + TEST_DIRS)
137
+ FileWalk.each_file(js_root, skip: SKIP_DIRS + TEST_DIRS, root: root.to_s)
138
138
  .select { |path| path.match?(JS_FILE) && !File.basename(path).match?(TEST_FILE) }
139
139
  }
140
140
  .uniq
@@ -312,7 +312,7 @@ module RailsAiContext
312
312
  dirs = TEMPLATE_KINDS.flat_map { |kind| PathResolver.dirs_for(root, kind) }.uniq
313
313
  dirs += PathResolver.view_dirs(root).reject { |views| dirs.any? { |dir| views == dir || views.start_with?("#{dir}/") } }
314
314
  dirs.flat_map { |dir|
315
- FileWalk.each_file(dir, skip: SKIP_DIRS).select { |path| path.match?(TEMPLATE_FILE) }.sort
315
+ FileWalk.each_file(dir, skip: SKIP_DIRS, root: root.to_s).select { |path| path.match?(TEMPLATE_FILE) }.sort
316
316
  }.uniq
317
317
  end
318
318
 
@@ -405,6 +405,9 @@ module RailsAiContext
405
405
  targets: static_array(content, "targets", /["'](\w+)["']/),
406
406
  values: extract_values(content),
407
407
  actions: extract_actions(content),
408
+ # The methods the controller calls on itself (`this.render()`): one
409
+ # no markup names is a helper the copy-paste markup leaves out.
410
+ self_calls: content.scan(/\bthis\.(\w+)\s*\(/).flatten.uniq.presence,
408
411
  outlets: static_array(content, "outlets", /["']([^"']+)["']/),
409
412
  classes: static_array(content, "classes", /["']([^"']+)["']/),
410
413
  lifecycle: extract_lifecycle(content),
@@ -452,9 +455,14 @@ module RailsAiContext
452
455
  end
453
456
  end
454
457
 
458
+ # Stimulus calls these itself when a value, a target or an outlet
459
+ # changes, so no markup names them: `quantityValueChanged` was listed as
460
+ # an action and pasted as `click->cart#quantityValueChanged`.
461
+ CHANGE_CALLBACK = /\A\w+(?:ValueChanged|TargetConnected|TargetDisconnected|OutletConnected|OutletDisconnected)\z/
462
+
455
463
  def extract_actions(content)
456
464
  content.scan(/^\s+(?:async\s+)?(\w+)\s*\([^)]*\)\s*\{/).flatten
457
- .reject { |m| %w[constructor connect disconnect initialize if else for while switch catch function].include?(m) }
465
+ .reject { |m| %w[constructor connect disconnect initialize if else for while switch catch function].include?(m) || m.match?(CHANGE_CALLBACK) }
458
466
  end
459
467
 
460
468
  def extract_import_graph(content)
@@ -491,6 +499,7 @@ module RailsAiContext
491
499
 
492
500
  def extract_lifecycle(content)
493
501
  hooks = content.scan(/\b(connect|disconnect|initialize)\s*\(\s*\)/).flatten.uniq
502
+ hooks += content.scan(/^\s+(?:async\s+)?(\w+)\s*\([^)]*\)\s*\{/).flatten.grep(CHANGE_CALLBACK).uniq
494
503
  hooks.any? ? hooks : nil
495
504
  rescue => e
496
505
  RailsAiContext.debug_fail(e, nil, label: "extract_lifecycle")
@@ -427,7 +427,7 @@ module RailsAiContext
427
427
  end
428
428
  end
429
429
  end
430
- shared.sort_by { |s| s[:name] }
430
+ shared.sort_by.with_index { |s, i| [ s[:name], i ] }
431
431
  rescue => e
432
432
  RailsAiContext.debug_fail(e, [], label: "detect_shared_examples")
433
433
  end
@@ -60,7 +60,7 @@ module RailsAiContext
60
60
  each_view_line do |file, line, line_num|
61
61
  next unless line.include?("turbo_stream_from")
62
62
 
63
- subscriptions << { stream: subscription_stream(line), file: file, line: line_num, snippet: line.strip }
63
+ subscriptions << { stream: subscription_stream(line), parts: subscription_parts(line), file: file, line: line_num, snippet: line.strip }
64
64
  end
65
65
  subscriptions
66
66
  rescue => e
@@ -130,8 +130,19 @@ module RailsAiContext
130
130
  args.map(&:strip).reject(&:empty?)
131
131
  end
132
132
 
133
+ # The src value whole, up to the comma or bracket that ends it at the top
134
+ # level: a match that stopped at the first `)` cut
135
+ # `product_comments_path(@product)` short of its own.
133
136
  def frame_src(line)
134
- line[/src:\s*["']?([^"',\s)]+)/, 1]
137
+ start = line =~ /\bsrc:\s*/ or return nil
138
+ value = +""
139
+ RailsAiContext::Brackets.each_top_level(line[(start + Regexp.last_match(0).length)..], comments: :ruby) do |piece, kind|
140
+ break if kind == :comment || (kind == :char && [ ",", ")", "}" ].include?(piece))
141
+
142
+ value << piece
143
+ end
144
+ value = value.sub(/\s*(?:-?%>|\bdo\b).*\z/m, "").strip
145
+ value.empty? ? nil : value.gsub(/\A["']|["']\z/, "")
135
146
  end
136
147
 
137
148
  # `turbo_stream_from :notifications`, `"notifications"`, `@room`,
@@ -200,7 +211,7 @@ module RailsAiContext
200
211
  end
201
212
  end
202
213
 
203
- { models: models.sort_by { |b| [ b[:model], b[:line] ] }, explicit: explicit }
214
+ { models: models.sort_by.with_index { |b, i| [ b[:model], b[:line], i ] }, explicit: explicit }
204
215
  rescue => e
205
216
  RailsAiContext.debug_fail(e, { models: [], explicit: [] }, label: "scan_broadcasts")
206
217
  end
@@ -211,15 +222,21 @@ module RailsAiContext
211
222
  next unless hit[:receiver].nil?
212
223
 
213
224
  owner ||= owner_name(record)
214
- { model: owner, macro: hit[:name], stream: macro_stream(hit), file: record.file, line: hit[:line], snippet: hit[:snippet] }
225
+ { model: owner, macro: hit[:name], stream: macro_stream(hit), streams: macro_streams(hit, owner),
226
+ file: record.file, line: hit[:line], snippet: hit[:snippet] }
215
227
  end
216
228
  end
217
229
 
218
230
  def explicit_entries(record, hits)
231
+ owner = nil
219
232
  hits.map do |hit|
233
+ owner ||= owner_name(record)
220
234
  {
221
235
  method: hit[:name],
222
- stream: call_stream(hit[:arguments].first),
236
+ stream: call_stream(hit[:arguments]),
237
+ parts: stream_parts(hit[:arguments], hit[:computed]),
238
+ # The class the call sits in, which an expression such as `user` is read against.
239
+ owner: owner,
223
240
  target: hit[:options][:target]&.to_s,
224
241
  partial: hit[:options][:partial]&.to_s,
225
242
  file: record.file,
@@ -229,6 +246,104 @@ module RailsAiContext
229
246
  end
230
247
  end
231
248
 
249
+ # A stream as the parts Turbo builds its name from: a literal (a symbol
250
+ # or a string) or an expression, which rails_get_turbo_map resolves to
251
+ # the record it names. A record streams under its GlobalID, so
252
+ # `[product, :reviews]` in a model and `@product, :reviews` in a view are
253
+ # one stream when both name a Product. Nil for an interpolated name,
254
+ # which only its text can be compared by. A `broadcast_*_to` call takes
255
+ # its stream as every positional argument, so `user, :notifications`
256
+ # and `[user, :notifications]` are the same two parts.
257
+ def stream_parts(arguments, computed)
258
+ parts = Array(arguments).flatten.map do |value|
259
+ case value
260
+ when Symbol then { literal: value.to_s }
261
+ when String
262
+ return nil if value.match?(/\A["'].*#\{/m)
263
+
264
+ Array(computed).include?(value) ? { expr: value } : { literal: value }
265
+ else return nil
266
+ end
267
+ end
268
+ parts.empty? ? nil : parts
269
+ end
270
+
271
+ # The streams a broadcast macro sends to (turbo-rails broadcastable.rb):
272
+ # `broadcasts` and `broadcasts_refreshes` to the stream they name, the
273
+ # model's plural by default, on create, and to the record itself on
274
+ # update and destroy; the `_to` forms to what the method a symbol names,
275
+ # or a lambda, returns for the record.
276
+ def macro_streams(hit, owner)
277
+ first = hit[:arguments].first
278
+ named = first.is_a?(Symbol) || (first.is_a?(String) && !Array(hit[:computed]).include?(first))
279
+ case hit[:name]
280
+ when "broadcasts", "broadcasts_refreshes"
281
+ plural = named ? first.to_s : owner.to_s.underscore.tr("/", "_").pluralize
282
+ [ [ { literal: plural } ], [ { expr: "self" } ] ]
283
+ else
284
+ parts = named ? [ { expr: first.to_s } ] : lambda_parts(first.to_s)
285
+ parts && [ parts ]
286
+ end
287
+ end
288
+
289
+ # `->(card) { [card.board, :cards] }`: each part read against the record
290
+ # the lambda is given, so `card.board` is the record's own `board`.
291
+ def lambda_parts(source)
292
+ lambda = first_node(AstCache.parse_string(source).value) { |node| node.is_a?(Prism::LambdaNode) } or return nil
293
+ param = lambda.parameters&.parameters&.requireds&.first
294
+ param_name = param.respond_to?(:name) ? param.name : nil
295
+ body = lambda.body&.body&.last or return nil
296
+ elements = body.is_a?(Prism::ArrayNode) ? body.elements : [ body ]
297
+ parts = elements.map do |element|
298
+ case element
299
+ when Prism::SymbolNode, Prism::StringNode then { literal: element.unescaped }
300
+ when Prism::CallNode
301
+ reads_param = element.receiver.is_a?(Prism::LocalVariableReadNode) && element.receiver.name == param_name
302
+ return nil unless reads_param && element.arguments.nil?
303
+
304
+ { expr: element.name.to_s }
305
+ when Prism::LocalVariableReadNode
306
+ return nil unless element.name == param_name
307
+
308
+ { expr: "self" }
309
+ else return nil
310
+ end
311
+ end
312
+ parts.empty? ? nil : parts
313
+ rescue StandardError => e
314
+ RailsAiContext.debug_fail(e, nil, label: "lambda_parts")
315
+ end
316
+
317
+ def first_node(node, &block)
318
+ return node if yield(node)
319
+
320
+ node.compact_child_nodes.each do |child|
321
+ found = first_node(child, &block)
322
+ return found if found
323
+ end
324
+ nil
325
+ end
326
+
327
+ # A view's `turbo_stream_from` arguments as stream parts: a symbol or a
328
+ # string is a literal, anything else an expression (`@product`,
329
+ # `current_user`) the tool resolves against the app's models.
330
+ def subscription_parts(line)
331
+ code = line[/turbo_stream_from\b.*?(?=\s*-?%>|\s*\bdo\b|\z)/m] or return nil
332
+ call = first_node(AstCache.parse_string(code).value) { |node| node.is_a?(Prism::CallNode) && node.name == :turbo_stream_from }
333
+ arguments = call&.arguments&.arguments&.reject { |arg| arg.is_a?(Prism::KeywordHashNode) }
334
+ return nil if arguments.nil? || arguments.empty?
335
+
336
+ arguments.map do |arg|
337
+ case arg
338
+ when Prism::SymbolNode, Prism::StringNode then { literal: arg.unescaped }
339
+ when Prism::InterpolatedStringNode then return nil
340
+ else { expr: arg.slice }
341
+ end
342
+ end
343
+ rescue StandardError => e
344
+ RailsAiContext.debug_fail(e, nil, label: "subscription_parts")
345
+ end
346
+
232
347
  # A concerns directory is an autoload root, so its path name carries no
233
348
  # `Concerns::` segment; every scan here names a class through this.
234
349
  def owner_name(record)
@@ -256,13 +371,17 @@ module RailsAiContext
256
371
  end
257
372
 
258
373
  # `"post_#{post.id}"` reads as `post_{id}`; a symbol, a string or a bare
259
- # identifier as itself; anything else is dynamic.
260
- def call_stream(argument)
261
- text = argument.to_s
262
- return normalize_interpolation(text) if text.match?(/\A["'].*#\{/)
263
- return text if argument.is_a?(Symbol) || text.match?(/\A\w+\z/)
264
-
265
- "(dynamic)"
374
+ # identifier as itself; anything else is dynamic. Several arguments
375
+ # are one stream, labelled part by part as a subscription is.
376
+ def call_stream(arguments)
377
+ labels = Array(arguments).flatten.map do |argument|
378
+ text = argument.to_s
379
+ next normalize_interpolation(text) if text.match?(/\A["'].*#\{/)
380
+ next text if argument.is_a?(Symbol) || text.match?(/\A\w+\z/)
381
+
382
+ return "(dynamic)"
383
+ end
384
+ labels.empty? ? "(dynamic)" : labels.join(", ")
266
385
  end
267
386
 
268
387
  def detect_morph_meta
@@ -125,7 +125,7 @@ module RailsAiContext
125
125
  dir = File.join(root, "app/helpers")
126
126
  return [] unless Dir.exist?(dir)
127
127
 
128
- Dir.glob(File.join(dir, "**/*.rb")).filter_map do |path|
128
+ FileWalk.glob(dir, "**/*.rb", root: root).filter_map do |path|
129
129
  source = RailsAiContext::SafeFile.read(path)
130
130
  next if source && !HelperNames.view_helper?(source, path, dir)
131
131
 
@@ -146,9 +146,9 @@ module RailsAiContext
146
146
  dir = File.join(root, "app/components")
147
147
  return [] unless Dir.exist?(dir)
148
148
 
149
- Dir.glob(File.join(dir, "**/*.rb")).filter_map do |path|
149
+ FileWalk.glob(dir, "**/*.rb", root: root).map do |path|
150
150
  path.sub("#{dir}/", "").sub(/\.rb\z/, "")
151
- end.sort
151
+ end
152
152
  end
153
153
 
154
154
  def detect_template_engines
@@ -184,7 +184,7 @@ module RailsAiContext
184
184
  end
185
185
  end
186
186
 
187
- counts.sort_by { |_, v| -v }.to_h
187
+ counts.sort_by.with_index { |(_, v), i| [ -v, i ] }.to_h
188
188
  rescue => e
189
189
  RailsAiContext.debug_fail(e, {}, label: "detect_form_builders")
190
190
  end
@@ -363,15 +363,21 @@ 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
+ # A partial named outside a render call: turbo_stream.prepend and the
367
+ # broadcast_* methods take `partial:`, and jbuilder renders one with
368
+ # `json.partial!` or `json.array! ..., partial:`.
369
+ PARTIAL_ARG = /(?:\bjson\.partial!\s*\(?\s*|\bpartial:\s*|:partial\s*=>\s*)(["'])([^"']+)\1/
366
370
  # `render @posts`, `render(post)`, `render @posts, cached: true`: a bare record or collection, or a
367
371
  # chain on one (`@post.comments.recent`), matched against "render <args>"; RenderedRecord reads the chain.
368
372
  IMPLICIT_RENDER = /\Arender\s*\(?\s*@?((?:[a-z_]\w*\.)*[a-z_]\w*)\s*(?:[,)]|\s(?:if|unless)\b|-?\s*\z)/
369
373
 
370
374
  def extract_partial_refs(content)
371
375
  refs = []
376
+ spans = []
372
377
  # The partial or template a render call names, positionally or as a
373
378
  # keyword anywhere in its arguments, with or without parentheses.
374
- self.class.render_calls(content).each do |_, args|
379
+ self.class.render_calls(content).each do |at, args|
380
+ spans << (at...(at + args.length))
375
381
  named = [ args[RENDER_POSITIONAL, 2], *top_level(args).scan(RENDER_NAMED).flatten ].compact
376
382
  # An interpolated name is decided at runtime, not a partial on disk.
377
383
  refs.concat(named.select { |name| name.match?(PARTIAL_NAME) })
@@ -380,6 +386,14 @@ module RailsAiContext
380
386
  # The records' model names the partial; `replies` with class_name "Comment" renders comments/_comment.
381
387
  refs << (collection ? model.pluralize : model) if model
382
388
  end
389
+ # A render call's own arguments were read above, where a `partial:`
390
+ # key nested in its locals is no partial of its own.
391
+ content.to_enum(:scan, PARTIAL_ARG).each do
392
+ match = Regexp.last_match
393
+ next if spans.any? { |span| span.cover?(match.begin(0)) }
394
+
395
+ refs << match[2] if match[2].match?(PARTIAL_NAME)
396
+ end
383
397
  # Phlex: render ComponentName.new(...) or render(ComponentName.new(...))
384
398
  content.scan(/render[\s(]+([A-Z]\w+(?:::\w+)*)\.new/).each { |m| refs << m[0] }
385
399
  refs.uniq
@@ -20,13 +20,16 @@ module RailsAiContext
20
20
  # @param root [String, Pathname] project root directory
21
21
  # @param io [IO] stream for output (stderr by default)
22
22
  # @param warn_only [Boolean] skip prompt even if TTY, only print warning
23
- def prompt_legacy_files(ai_tools, root:, io: $stderr, warn_only: false)
23
+ # @param place [String, nil] the root as the person who typed the
24
+ # command names it, when they typed it outside the root: the files and
25
+ # the rm line are named from there, so the rm removes them where it is run
26
+ def prompt_legacy_files(ai_tools, root:, io: $stderr, warn_only: false, place: nil)
24
27
  allowed = ai_tools ? Array(ai_tools).map(&:to_sym).to_set : nil
25
28
 
26
29
  present = LEGACY_FILES.filter_map do |entry|
27
30
  next if allowed && !allowed.include?(entry[:ai_tool])
28
31
  full = File.join(root.to_s, entry[:path])
29
- [ entry[:path], full ] if File.exist?(full)
32
+ [ place ? File.join(place, entry[:path]) : entry[:path], full ] if File.exist?(full)
30
33
  end
31
34
  return if present.empty?
32
35
 
@@ -38,7 +41,7 @@ module RailsAiContext
38
41
 
39
42
  if warn_only || !$stdin.tty?
40
43
  io.puts "Delete manually when ready:"
41
- io.puts " rm -f #{present.map(&:first).join(' ')}"
44
+ io.puts " #{rm_line(present)}"
42
45
  return
43
46
  end
44
47
 
@@ -55,8 +58,18 @@ module RailsAiContext
55
58
  end
56
59
  else
57
60
  io.puts " Kept. Delete manually when ready:"
58
- io.puts " rm -f #{present.map(&:first).join(' ')}"
61
+ io.puts " #{rm_line(present)}"
59
62
  end
60
63
  end
64
+
65
+ # The rm a reader can paste: each path a single shell word.
66
+ def rm_line(present)
67
+ words = present.map do |(shown, _)|
68
+ next shown if shown.match?(%r{\A[A-Za-z0-9_\-.,:+/@]+\z})
69
+
70
+ "'#{shown.gsub("'") { %q('\\'') }}'"
71
+ end
72
+ "rm -f #{words.join(' ')}"
73
+ end
61
74
  end
62
75
  end
@@ -1,12 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- # Keeps a long-lived MCP server truthful: when the app changes, drop the
5
- # tool caches and tell connected clients to re-query. The loop - watch
6
- # list, fingerprint gate, code reload - is ChangeWatch's; this supplies
7
- # the non-blocking background behavior, the debounce, and the
8
- # cache-and-notify reaction. A missing `listen` gem raises out of start;
9
- # Server#maybe_start_live_reload owns that policy.
4
+ # Tells connected clients when the app changed, so they re-query. Answers
5
+ # do not wait on it: each call checks the files itself before it reads
6
+ # anything (BaseTool.refresh_if_files_changed!). The loop - watch list,
7
+ # fingerprint gate - is ChangeWatch's; this supplies the non-blocking
8
+ # background behavior, the debounce, and the notification. A missing
9
+ # `listen` gem raises out of start; Server#maybe_start_live_reload owns
10
+ # that policy.
10
11
  class LiveReload
11
12
  include CountPhrase
12
13
 
@@ -27,7 +28,7 @@ module RailsAiContext
27
28
  end
28
29
 
29
30
  debounce = RailsAiContext.configuration.live_reload_debounce
30
- listener = @watch.start(debounce: debounce) { |paths, reloaded| react(paths, reloaded) }
31
+ listener = @watch.start(debounce: debounce) { |paths| react(paths) }
31
32
  return unless listener
32
33
 
33
34
  # After the listener, not before: these lines are only true once a
@@ -46,7 +47,7 @@ module RailsAiContext
46
47
  # Run a batch of changed paths through the shared gate. Public for
47
48
  # testability - specs drive this instead of a real Listen thread.
48
49
  def handle_change(changed_paths = [])
49
- @watch.gate(changed_paths) { |paths, reloaded| react(paths, reloaded) }
50
+ @watch.gate(changed_paths) { |paths| react(paths) }
50
51
  end
51
52
 
52
53
  # Group changed file paths by category (model, controller, etc.)
@@ -83,22 +84,21 @@ module RailsAiContext
83
84
 
84
85
  private
85
86
 
86
- # The gate already reloaded the app's code; dropping the caches after
87
- # that order is what keeps the server from answering with constants
88
- # Rails autoloaded at boot.
89
- def react(paths, reloaded)
90
- Tools::BaseTool.reset_all_caches!
91
-
87
+ # On Listen's thread, so it loads no app code (see ChangeWatch#gate), and
88
+ # it drops no cache either: the first call after the edit already did,
89
+ # when it checked the files, or the next one will. A reload or a drop
90
+ # here would only repeat that call's work, a full introspection a second
91
+ # and a half after every edit.
92
+ def react(paths)
92
93
  message = format_change_message(categorize_changes(paths))
94
+ unless CodeReloader.reloadable?
95
+ message += " RAILS_ENV=#{RailsAiContext.environment_name} does not reload code, so what reflection reads stays as of boot."
96
+ end
93
97
 
94
98
  mcp_server.notify_resources_list_changed
95
- mcp_server.notify_log_message(
96
- data: "#{message} Tool caches invalidated#{reloaded ? " and app code reloaded" : ""}.",
97
- level: "info",
98
- logger: "rails-ai-context"
99
- )
99
+ mcp_server.notify_log_message(data: message, level: "info", logger: "rails-ai-context")
100
100
 
101
- $stderr.puts "[rails-ai-context] #{message} Tool caches invalidated#{reloaded ? " and app code reloaded" : ""}."
101
+ $stderr.puts "[rails-ai-context] #{message}"
102
102
  rescue => e
103
103
  $stderr.puts "[rails-ai-context] Live reload error: #{e.message}"
104
104
  end