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
@@ -35,7 +35,7 @@ module RailsAiContext
35
35
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
36
36
 
37
37
  # What the filters left, in one value: every formatter needs all five.
38
- Found = Data.define(:model_broadcasts, :rb_broadcasts, :view_subscriptions, :view_frames, :warnings)
38
+ Found = Data.define(:model_broadcasts, :rb_broadcasts, :view_subscriptions, :view_frames, :wiring, :warnings)
39
39
 
40
40
  def self.call(detail: "standard", stream: nil, controller: nil, server_context: nil)
41
41
  # An MCP client sends "" for an argument it leaves unset, which is no
@@ -49,13 +49,27 @@ module RailsAiContext
49
49
  rb_broadcasts = Payload.explicit_broadcasts(cached_context)
50
50
  view_subscriptions = Payload.stream_subscriptions(cached_context)
51
51
  view_frames = Payload.turbo_frames(cached_context)
52
+ # Whether a stream is wired is a fact about the whole app: a filter
53
+ # narrows what is shown, not who can reach whom.
54
+ wiring = build_stream_wiring(model_broadcasts, rb_broadcasts, view_subscriptions)
52
55
 
53
56
  if stream
54
57
  stream_lower = stream.downcase
55
- mentions = ->(entry) { entry[:stream]&.downcase&.include?(stream_lower) || entry[:snippet]&.downcase&.include?(stream_lower) }
58
+ names = lambda do |entry|
59
+ labelled = entry[:streams] ? entry[:streams].map { |parts| parts_label(parts) } : [ entry[:parts] ? parts_label(entry[:parts]) : entry[:stream] ]
60
+ labelled.compact
61
+ end
62
+ mentions = ->(entry) { names.call(entry).any? { |name| name.downcase.include?(stream_lower) } || entry[:snippet]&.downcase&.include?(stream_lower) }
63
+ all_streams = (model_broadcasts + rb_broadcasts + view_subscriptions).flat_map(&names).uniq.sort
56
64
  model_broadcasts = model_broadcasts.select(&mentions)
57
65
  rb_broadcasts = rb_broadcasts.select(&mentions)
58
66
  view_subscriptions = view_subscriptions.select(&mentions)
67
+ # The frames and responses are not streams, so the answer was the
68
+ # whole map with no word that the filter matched nothing.
69
+ if model_broadcasts.empty? && rb_broadcasts.empty? && view_subscriptions.empty?
70
+ known = all_streams.any? ? " Streams in this app: #{all_streams.map { |s| "`#{s}`" }.join(', ')}." : ""
71
+ return empty_response("No broadcast or `turbo_stream_from` matches stream:\"#{echo_input(stream)}\".#{known}")
72
+ end
59
73
  end
60
74
 
61
75
  if controller
@@ -66,19 +80,21 @@ module RailsAiContext
66
80
  # A broadcast belongs to the controller when it sits in its path or
67
81
  # feeds a stream one of its surviving views subscribes to (a job
68
82
  # broadcasting to a stream the controller's views listen on).
69
- matched_streams = view_subscriptions.map { |s| s[:stream] }.compact
83
+ models = Payload.models(cached_context)
84
+ subscribed = subscription_streams(view_subscriptions, models)
70
85
  rb_broadcasts = rb_broadcasts.select { |b|
71
86
  controller_file?(b[:file], ctrl_lower) ||
72
- (b[:stream] && matched_streams.any? { |ss| streams_match?(b[:stream], ss) })
87
+ broadcast_streams([], [ b ], models).any? { |cast| subscribed.any? { |sub| stream_relation(sub, cast, models) == :match } }
73
88
  }
74
89
  end
75
90
 
76
- warnings = detect_mismatches(model_broadcasts, rb_broadcasts, view_subscriptions)
77
- filter_label = stream ? "stream:\"#{stream}\"" : controller ? "controller:\"#{controller}\"" : nil
91
+ wiring = wiring_touching(wiring, model_broadcasts + rb_broadcasts + view_subscriptions) if stream || controller
92
+ filter_label = stream ? "stream:\"#{echo_input(stream)}\"" : controller ? "controller:\"#{echo_input(controller)}\"" : nil
78
93
 
79
94
  found = Found.new(
80
95
  model_broadcasts: model_broadcasts, rb_broadcasts: rb_broadcasts,
81
- view_subscriptions: view_subscriptions, view_frames: view_frames, warnings: warnings
96
+ view_subscriptions: view_subscriptions, view_frames: view_frames,
97
+ wiring: wiring, warnings: detect_mismatches(wiring)
82
98
  )
83
99
 
84
100
  case detail
@@ -185,7 +201,7 @@ module RailsAiContext
185
201
  lines << "## Explicit Broadcasts (#{rb_broadcasts.size})"
186
202
  rb_broadcasts.each do |b|
187
203
  target_label = b[:target] ? " target: `#{b[:target]}`" : ""
188
- lines << "- `#{b[:method]}` → stream: `#{b[:stream]}`#{target_label} (`#{b[:file]}:#{b[:line]}`)"
204
+ lines << "- `#{b[:method]}` → stream: `#{b[:parts] ? parts_label(b[:parts]) : b[:stream]}`#{target_label} (`#{b[:file]}:#{b[:line]}`)"
189
205
  end
190
206
  lines << ""
191
207
  end
@@ -278,7 +294,7 @@ module RailsAiContext
278
294
  if rb_broadcasts.any?
279
295
  lines << "## Explicit Broadcasts (#{rb_broadcasts.size})"
280
296
  rb_broadcasts.each do |b|
281
- lines << "### `#{b[:method]}` → `#{b[:stream]}`"
297
+ lines << "### `#{b[:method]}` → `#{b[:parts] ? parts_label(b[:parts]) : b[:stream]}`"
282
298
  lines << "- **File:** `#{b[:file]}:#{b[:line]}`"
283
299
  lines << "- **Target:** `#{b[:target]}`" if b[:target]
284
300
  lines << "- **Partial:** `#{b[:partial]}`" if b[:partial]
@@ -310,7 +326,7 @@ module RailsAiContext
310
326
  end
311
327
 
312
328
  # Wiring map: match broadcast streams to subscription streams
313
- stream_wiring = build_stream_wiring(model_broadcasts, rb_broadcasts, view_subscriptions)
329
+ stream_wiring = found.wiring
314
330
  if stream_wiring.any?
315
331
  lines << "## Stream Wiring"
316
332
  stream_wiring.each do |stream_name, wiring|
@@ -322,10 +338,19 @@ module RailsAiContext
322
338
  lines << "- **Subscribers:** #{wiring[:subscribers].map { |s| "`#{s}`" }.join(', ')}"
323
339
  end
324
340
  if wiring[:broadcasters].any? && wiring[:subscribers].empty?
325
- lines << "- _No subscribers found for this stream_"
341
+ lines << if wiring[:unsure]
342
+ "- _Can't tell whether a subscriber matches: a part of a stream here names nothing this reading resolves_"
343
+ else
344
+ "- _No subscribers found for this stream_"
345
+ end
326
346
  end
327
347
  if wiring[:subscribers].any? && wiring[:broadcasters].empty?
328
- lines << "- _No broadcasters found for this stream_"
348
+ lines << if wiring[:unknown].any?
349
+ "- _Can't tell whether #{wiring[:unknown].map { |b| "`#{b}`" }.join(', ')} broadcast#{"s" if wiring[:unknown].one?} here: " \
350
+ "a part of its stream names nothing this reading resolves_"
351
+ else
352
+ "- _No broadcasters found for this stream_"
353
+ end
329
354
  end
330
355
  lines << ""
331
356
  end
@@ -343,41 +368,35 @@ module RailsAiContext
343
368
  text_response(lines.join("\n"))
344
369
  end
345
370
 
346
- # Detect mismatches between broadcasts and subscriptions
347
- private_class_method def self.detect_mismatches(model_broadcasts, rb_broadcasts, view_subscriptions)
348
- warnings = []
349
-
350
- # Collect all broadcast stream names
351
- broadcast_streams = Set.new
352
- model_broadcasts.each { |b| broadcast_streams << b[:stream] if b[:stream] && !b[:stream].include?("dynamic") && !b[:stream].include?("self") }
353
- rb_broadcasts.each { |b| broadcast_streams << b[:stream] if b[:stream] && !b[:stream].include?("dynamic") }
354
-
355
- # Collect all subscription stream names
356
- subscription_streams = Set.new
357
- view_subscriptions.each { |s| subscription_streams << s[:stream] if s[:stream] && !s[:stream].include?("dynamic") }
358
-
359
- # Broadcasts without subscribers - use fuzzy matching for dynamic streams
360
- orphan_broadcasts = broadcast_streams.reject { |bs|
361
- subscription_streams.any? { |ss| streams_match?(bs, ss) }
362
- }
363
- orphan_broadcasts.each do |stream|
364
- source = rb_broadcasts.find { |b| b[:stream] == stream }
365
- source ||= model_broadcasts.find { |b| b[:stream] == stream }
366
- file_ref = source ? " (#{source[:file]}:#{source[:line]})" : ""
367
- warnings << "Broadcast to `#{stream}` has no matching `turbo_stream_from`#{file_ref}"
371
+ # A broadcast or a subscription as the wiring compares them: its label,
372
+ # where it is, and its stream's parts with each expression resolved to
373
+ # the model whose record it names. Nil parts compare by label alone.
374
+ WiredStream = Data.define(:label, :where, :parts)
375
+
376
+ # The streams a filter's entries take part in, each with every
377
+ # broadcaster and subscriber the app has for it: a comments filter
378
+ # keeps Comment's broadcast, and the post view that hears it, rather
379
+ # than calling the stream unheard.
380
+ private_class_method def self.wiring_touching(wiring, entries)
381
+ places = entries.map { |entry| /#{Regexp.escape("#{entry[:file]}:#{entry[:line]}")}(?!\d)/ }
382
+ wiring.select do |_, streams|
383
+ (streams[:broadcasters] + streams[:subscribers] + streams[:unknown]).any? { |where| places.any? { |place| where.match?(place) } }
368
384
  end
385
+ end
369
386
 
370
- # Subscriptions without broadcasters - use fuzzy matching
371
- orphan_subscriptions = subscription_streams.reject { |ss|
372
- broadcast_streams.any? { |bs| streams_match?(bs, ss) }
373
- }
374
- orphan_subscriptions.each do |stream|
375
- next if stream.include?(",") || stream.include?("@")
376
- source = view_subscriptions.find { |s| s[:stream] == stream }
377
- file_ref = source ? " (#{source[:file]}:#{source[:line]})" : ""
378
- warnings << "Subscription to `#{stream}` has no matching broadcast#{file_ref}"
379
- end
387
+ # Warnings only where the wiring is sure: a stream whose parts this
388
+ # reading could not resolve is "can't tell", not a mismatch.
389
+ private_class_method def self.detect_mismatches(stream_wiring)
390
+ warnings = []
391
+ stream_wiring.each do |label, wiring|
392
+ next if wiring[:unknown].any? || wiring[:unsure]
380
393
 
394
+ if wiring[:subscribers].any? && wiring[:broadcasters].empty?
395
+ warnings << "Subscription to `#{label}` has no matching broadcast (#{wiring[:subscribers].join(', ')})"
396
+ elsif wiring[:broadcasters].any? && wiring[:subscribers].empty?
397
+ warnings << "Broadcast to `#{label}` has no matching `turbo_stream_from` (#{wiring[:broadcasters].join(', ')})"
398
+ end
399
+ end
381
400
  warnings.sort
382
401
  rescue => e
383
402
  RailsAiContext.debug_fail(e, [], label: "detect_mismatches")
@@ -402,32 +421,176 @@ module RailsAiContext
402
421
  false
403
422
  end
404
423
 
405
- # Build a wiring map: stream name → { broadcasters: [...], subscribers: [...] }
424
+ # stream label => { subscribers:, broadcasters:, unknown: [broadcasters
425
+ # it cannot tell about], unsure: a broadcast that might reach some
426
+ # subscriber }. Each subscription lists the broadcasts that reach it, and
427
+ # a broadcast that reaches none gets an entry of its own. Turbo names a
428
+ # stream by its parts, so a model's `[product, :reviews]` and a view's
429
+ # `@product, :reviews` are one stream when both are a Product.
406
430
  private_class_method def self.build_stream_wiring(model_broadcasts, rb_broadcasts, view_subscriptions)
431
+ models = Payload.models(cached_context)
432
+ casts = broadcast_streams(model_broadcasts, rb_broadcasts, models)
433
+ reached = Set.new
434
+ unsure = Set.new
407
435
  wiring = {}
408
436
 
409
- model_broadcasts.each do |b|
410
- next unless b[:stream] && !b[:stream].include?("dynamic")
411
- wiring[b[:stream]] ||= { broadcasters: [], subscribers: [] }
412
- wiring[b[:stream]][:broadcasters] << "#{b[:model]}.#{b[:macro]} (#{b[:file]}:#{b[:line]})"
437
+ subscription_streams(view_subscriptions, models).each do |sub|
438
+ entry = (wiring[sub.label] ||= { broadcasters: [], subscribers: [], unknown: [] })
439
+ entry[:subscribers] << sub.where
440
+ casts.each_with_index do |cast, index|
441
+ case stream_relation(sub, cast, models)
442
+ when :match
443
+ entry[:broadcasters] << cast.where
444
+ reached << index
445
+ when :unknown
446
+ entry[:unknown] << cast.where
447
+ unsure << index
448
+ end
449
+ end
413
450
  end
414
451
 
415
- rb_broadcasts.each do |b|
416
- next unless b[:stream] && !b[:stream].include?("dynamic")
417
- wiring[b[:stream]] ||= { broadcasters: [], subscribers: [] }
418
- wiring[b[:stream]][:broadcasters] << "#{b[:method]} (#{b[:file]}:#{b[:line]})"
419
- end
452
+ casts.each_with_index do |cast, index|
453
+ next if reached.include?(index)
420
454
 
421
- view_subscriptions.each do |s|
422
- next unless s[:stream] && !s[:stream].include?("dynamic")
423
- wiring[s[:stream]] ||= { broadcasters: [], subscribers: [] }
424
- wiring[s[:stream]][:subscribers] << "#{s[:file]}:#{s[:line]}"
455
+ entry = (wiring[cast.label] ||= { broadcasters: [], subscribers: [], unknown: [] })
456
+ entry[:broadcasters] << cast.where
457
+ entry[:unsure] = true if unsure.include?(index)
425
458
  end
426
459
 
460
+ wiring.each_value { |entry| %i[broadcasters subscribers unknown].each { |key| entry[key].uniq! } }
427
461
  wiring.sort_by { |k, _| k }.to_h
428
462
  rescue => e
429
463
  RailsAiContext.debug_fail(e, {}, label: "build_stream_wiring")
430
464
  end
465
+
466
+ private_class_method def self.subscription_streams(view_subscriptions, models)
467
+ view_subscriptions.filter_map do |s|
468
+ next unless s[:stream]
469
+
470
+ WiredStream.new(label: s[:stream], where: "#{s[:file]}:#{s[:line]}", parts: resolve_parts(s[:parts], models, nil))
471
+ end
472
+ end
473
+
474
+ # `broadcasts` and `broadcasts_refreshes` send to two streams, the
475
+ # model's plural on create and the record on update and destroy, so
476
+ # each stream is its own entry, labelled with the events it carries.
477
+ MACRO_EVENTS = [ " on create", " on update and destroy" ].freeze
478
+
479
+ private_class_method def self.broadcast_streams(model_broadcasts, rb_broadcasts, models)
480
+ streams = []
481
+ model_broadcasts.each do |b|
482
+ where = "#{b[:model]}.#{b[:macro]} (#{b[:file]}:#{b[:line]})"
483
+ if b[:streams]
484
+ two = b[:streams].size == 2
485
+ b[:streams].each_with_index do |parts, index|
486
+ streams << WiredStream.new(label: parts_label(parts), where: "#{where}#{MACRO_EVENTS[index] if two}",
487
+ parts: resolve_parts(parts, models, b[:model]))
488
+ end
489
+ elsif b[:stream]
490
+ streams << WiredStream.new(label: b[:stream], where: where, parts: nil)
491
+ end
492
+ end
493
+ rb_broadcasts.each do |b|
494
+ next unless b[:stream]
495
+
496
+ label = b[:parts] ? parts_label(b[:parts]) : b[:stream]
497
+ streams << WiredStream.new(label: label, where: "#{b[:method]} (#{b[:file]}:#{b[:line]})",
498
+ parts: resolve_parts(b[:parts], models, b[:owner]))
499
+ end
500
+ streams
501
+ end
502
+
503
+ # Parts as a subscription label spells them: `user, notifications`.
504
+ private_class_method def self.parts_label(parts)
505
+ parts.map { |part| part[:literal] || part[:expr] }.join(", ")
506
+ end
507
+
508
+ # Each expression as the model whose record it names, when one does;
509
+ # `owner` is the class a broadcast sits in, nil for a view.
510
+ private_class_method def self.resolve_parts(parts, models, owner)
511
+ return nil unless parts.is_a?(Array)
512
+
513
+ parts.map do |part|
514
+ next part unless part[:expr]
515
+
516
+ model = owner ? model_side_record(part[:expr], owner, models) : view_side_record(part[:expr], models)
517
+ model ? { record: model } : part
518
+ end
519
+ end
520
+
521
+ # In a model, `self` is the record and a bare name one of its
522
+ # belongs_to or has_one associations; `Current.user` reads as in a view.
523
+ private_class_method def self.model_side_record(expr, owner, models)
524
+ return view_side_record(expr, models) if expr.start_with?("Current.")
525
+
526
+ name = expr.delete_prefix("self.")
527
+ return (models.key?(owner) ? owner : nil) if name == "self"
528
+ return nil unless name.match?(/\A[a-z_]\w*\z/) && models[owner].is_a?(Hash)
529
+
530
+ assoc = Array(models[owner][:associations]).find do |a|
531
+ a.is_a?(Hash) && a[:name].to_s == name && %w[belongs_to has_one].include?(a[:type].to_s) && !a[:polymorphic]
532
+ end
533
+ assoc && Introspectors::TableName.model_for(assoc[:class_name] || name.camelize, owner, models)
534
+ end
535
+
536
+ # In a view, `@product`, `product`, `current_user` and `Current.user`
537
+ # (Rails 8's authentication keeps the signed-in user there) name a
538
+ # record by the model their name spells, and `@order.user` one through
539
+ # the first model's association.
540
+ private_class_method def self.view_side_record(expr, models)
541
+ head, *chain = expr.delete_prefix("@").delete_prefix("Current.").split(".")
542
+ return nil unless head.to_s.match?(/\A[a-z_]\w*\z/) && chain.all? { |name| name.match?(/\A[a-z_]\w*\z/) }
543
+
544
+ named = head.delete_prefix("current_")
545
+ found = models.keys.map(&:to_s).select { |key| key.demodulize.underscore == named }
546
+ model = found.one? ? found.first : nil
547
+ chain.each { |name| model = model && model_side_record(name, model, models) }
548
+ model
549
+ end
550
+
551
+ # :match, :differ, or :unknown when a part names something this
552
+ # reading cannot resolve.
553
+ private_class_method def self.stream_relation(a, b, models)
554
+ unless a.parts && b.parts
555
+ return :match if streams_match?(a.label, b.label)
556
+
557
+ return [ a.label, b.label ].any? { |label| label.include?("dynamic") } ? :unknown : :differ
558
+ end
559
+ return :differ unless a.parts.size == b.parts.size
560
+
561
+ relations = a.parts.zip(b.parts).map { |x, y| part_relation(x, y, models) }
562
+ return :differ if relations.include?(:differ)
563
+
564
+ relations.include?(:unknown) ? :unknown : :match
565
+ end
566
+
567
+ private_class_method def self.part_relation(x, y, models)
568
+ if x[:literal] && y[:literal]
569
+ x[:literal] == y[:literal] ? :match : :differ
570
+ elsif x[:record] && y[:record]
571
+ return :match if x[:record] == y[:record]
572
+
573
+ # A record streams under its own class, so a parent and its STI
574
+ # subclass may or may not be one stream.
575
+ sti_related?(x[:record], y[:record], models) ? :unknown : :differ
576
+ elsif (x[:literal] && y[:record]) || (x[:record] && y[:literal])
577
+ :differ
578
+ else
579
+ :unknown
580
+ end
581
+ end
582
+
583
+ private_class_method def self.sti_related?(a, b, models)
584
+ ancestors = lambda do |name|
585
+ chain = []
586
+ while (parent = models.dig(name, :parent_model)) && !chain.include?(parent)
587
+ chain << parent
588
+ name = parent
589
+ end
590
+ chain
591
+ end
592
+ ancestors.call(a).include?(b) || ancestors.call(b).include?(a)
593
+ end
431
594
  end
432
595
  end
433
596
  end
@@ -89,7 +89,7 @@ module RailsAiContext
89
89
  else
90
90
  " No view directories found (API-only apps typically have none)."
91
91
  end
92
- return empty_response([ "No views for '#{controller}'.#{hint}#{dirs_note}", static_engine_views_note ].compact.join("\n\n"))
92
+ return empty_response([ "No views for '#{echo_input(controller)}'.#{hint}#{dirs_note}", static_engine_views_note ].compact.join("\n\n"))
93
93
  end
94
94
 
95
95
  templates = filtered_templates
@@ -208,13 +208,11 @@ module RailsAiContext
208
208
  when "full"
209
209
  if controller
210
210
  lines = [ "# Views: #{controller}/", "", *layout_lines(controller) ]
211
- # Combine all content first for cross-template Tailwind compression
212
- all_content = []
213
- templates.sort.each do |name, _meta|
214
- all_content << [ name, strip_svg(read_view_content(name)) ]
215
- end
216
- partials.sort.each do |name, _meta|
217
- all_content << [ name, strip_svg(read_view_content(name)) ]
211
+ # Combine all content first for cross-template Tailwind compression.
212
+ # Each file is the app's source, so its secrets are filtered as
213
+ # they are when one template is read by path.
214
+ all_content = (templates.sort + partials.sort).map do |name, _meta|
215
+ [ name, RailsAiContext::Redaction.redact_source(strip_svg(read_view_content(name)), path: name) ]
218
216
  end
219
217
  # Compress repeated Tailwind classes across all templates
220
218
  combined = all_content.map { |name, c| "## #{name}\n```#{RailsAiContext::ViewFile.fence(name)}\n#{c}\n```\n" }.join("\n")
@@ -310,7 +308,8 @@ module RailsAiContext
310
308
  real = located.realpath
311
309
  if RailsAiContext::DetailLevel.full?(detail)
312
310
  content = RailsAiContext::SafeFile.read(real) || "(error reading)"
313
- lines << "## #{relative}" << "```#{RailsAiContext::ViewFile.fence(relative)}" << strip_svg(content) << "```" << ""
311
+ shown = RailsAiContext::Redaction.redact_source(strip_svg(content), path: relative)
312
+ lines << "## #{relative}" << "```#{RailsAiContext::ViewFile.fence(relative)}" << shown << "```" << ""
314
313
  else
315
314
  content = RailsAiContext::SafeFile.read(real) || ""
316
315
  lines << "- #{relative} (#{count_phrase(content.lines.size, "line")})"
@@ -322,15 +321,15 @@ module RailsAiContext
322
321
  private_class_method def self.read_view_file(path)
323
322
  content, result = RailsAiContext::ViewFile.read(rails_app.root.to_s, path)
324
323
  case result.refusal
325
- when :traversal, :outside then return error_response("Path not allowed: #{path}")
326
- when :sensitive then return error_response("Access denied: #{path} is a sensitive file (secrets/keys/credentials).")
324
+ when :traversal, :outside then return error_response("Path not allowed: #{echo_input(path)}")
325
+ when :sensitive then return error_response("Path not allowed: #{echo_input(path)} (sensitive file)")
327
326
  when :too_large then return text_response("File too large: #{path}")
328
327
  when :missing
329
328
  dir = File.dirname(path.to_s.delete_prefix("app/views/"))
330
329
  siblings = RailsAiContext::ViewFile.each(rails_app.root.to_s, File.join(dir, "*"))
331
330
  .map { |_file, relative| relative }.sort.first(10)
332
331
  hint = siblings.any? ? " Files in #{dir}/: #{siblings.join(', ')}" : ""
333
- return empty_response([ "View not found: #{path}.#{hint}", static_engine_views_note ].compact.join("\n\n"))
332
+ return empty_response([ "View not found: #{echo_input(path)}.#{hint}", static_engine_views_note ].compact.join("\n\n"))
334
333
  end
335
334
  return text_response("Could not read file: #{path}") unless content
336
335
 
@@ -338,10 +337,11 @@ module RailsAiContext
338
337
  # template is fenced as erb, because fencing a JPEG's bytes as ERB
339
338
  # said it was one.
340
339
  unless RailsAiContext::ViewFile.template?(result.relative)
341
- return text_response("# #{result.relative}\n\n_No template handler renders this file._\n\n```\n#{content}\n```")
340
+ shown = RailsAiContext::Redaction.redact_source(content, path: result.relative)
341
+ return text_response("# #{result.relative}\n\n_No template handler renders this file._\n\n```\n#{shown}\n```")
342
342
  end
343
343
 
344
- content = compress_tailwind(strip_svg(content))
344
+ content = RailsAiContext::Redaction.redact_source(compress_tailwind(strip_svg(content)), path: result.relative)
345
345
  text_response("# #{result.relative}\n\n```#{RailsAiContext::ViewFile.fence(result.relative)}\n#{content}\n```")
346
346
  end
347
347