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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1491 -0
- data/CONTEXT.md +27 -3
- data/README.md +16 -12
- data/Rakefile +6 -0
- data/app/controllers/rails_ai_context/mcp_controller.rb +112 -84
- data/config/routes.rb +2 -3
- data/docs/ARCHITECTURE.md +7 -5
- data/docs/CLI.md +79 -6
- data/docs/COMPATIBILITY.md +75 -1
- data/docs/CONFIGURATION.md +30 -11
- data/docs/CUSTOM_TOOLS.md +2 -1
- data/docs/FAQ.md +16 -6
- data/docs/GUIDE.md +117 -71
- data/docs/INTROSPECTORS.md +10 -8
- data/docs/QUICKSTART.md +3 -3
- data/docs/RECIPES.md +1 -1
- data/docs/SECURITY.md +109 -25
- data/docs/SETUP.md +30 -6
- data/docs/STANDALONE.md +10 -3
- data/docs/TOOLS.md +152 -34
- data/docs/TROUBLESHOOTING.md +90 -12
- data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
- data/docs/adr/0005-workspace-mcp-entries.md +39 -0
- data/exe/rails-ai-context +733 -104
- data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
- data/lib/rails_ai_context/app_kind.rb +18 -5
- data/lib/rails_ai_context/ast_cache.rb +7 -0
- data/lib/rails_ai_context/base_mixins.rb +8 -8
- data/lib/rails_ai_context/boot_manager.rb +22 -3
- data/lib/rails_ai_context/change_watch.rb +14 -11
- data/lib/rails_ai_context/cli/app_root.rb +401 -0
- data/lib/rails_ai_context/cli/entry_boot.rb +58 -22
- data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
- data/lib/rails_ai_context/code_reloader.rb +115 -1
- data/lib/rails_ai_context/concern_membership.rb +3 -1
- data/lib/rails_ai_context/concern_paths.rb +8 -3
- data/lib/rails_ai_context/configuration.rb +41 -9
- data/lib/rails_ai_context/context_file_report.rb +19 -2
- data/lib/rails_ai_context/database_file.rb +26 -0
- data/lib/rails_ai_context/doctor.rb +1122 -184
- data/lib/rails_ai_context/engine.rb +18 -1
- data/lib/rails_ai_context/erb_source.rb +19 -0
- data/lib/rails_ai_context/executable.rb +21 -0
- data/lib/rails_ai_context/facts_formatter.rb +5 -2
- data/lib/rails_ai_context/file_walk.rb +61 -10
- data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
- data/lib/rails_ai_context/fingerprinter.rb +40 -12
- data/lib/rails_ai_context/gem_lock.rb +186 -27
- data/lib/rails_ai_context/git_ignore.rb +22 -0
- data/lib/rails_ai_context/install/ai_tool.rb +31 -5
- data/lib/rails_ai_context/install/cleanup.rb +21 -0
- data/lib/rails_ai_context/install/program.rb +209 -35
- data/lib/rails_ai_context/install/selection_record.rb +258 -97
- data/lib/rails_ai_context/install/surface.rb +6 -1
- data/lib/rails_ai_context/install/validation_hook.rb +282 -0
- data/lib/rails_ai_context/install/workspace.rb +166 -0
- data/lib/rails_ai_context/install_mode.rb +71 -13
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
- data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
- data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
- data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
- data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +15 -10
- data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
- data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +387 -60
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +15 -3
- data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
- data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +23 -10
- data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
- data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
- data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +78 -4
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
- data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
- data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +13 -4
- data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +29 -8
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
- data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
- data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
- data/lib/rails_ai_context/introspectors/migration_replay.rb +15 -14
- data/lib/rails_ai_context/introspectors/model_introspector.rb +231 -23
- data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
- data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +66 -16
- data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
- data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_scan.rb +39 -9
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
- data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +131 -12
- data/lib/rails_ai_context/introspectors/view_introspector.rb +4 -4
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
- data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
- data/lib/rails_ai_context/live_reload.rb +20 -20
- data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
- data/lib/rails_ai_context/mcp_edge.rb +182 -32
- data/lib/rails_ai_context/middleware.rb +5 -1
- data/lib/rails_ai_context/migration_status.rb +88 -25
- data/lib/rails_ai_context/output_guard.rb +48 -2
- data/lib/rails_ai_context/package_json.rb +1 -1
- data/lib/rails_ai_context/path_resolver.rb +46 -13
- data/lib/rails_ai_context/payload.rb +92 -8
- data/lib/rails_ai_context/pending_migrations.rb +5 -1
- data/lib/rails_ai_context/portable_path.rb +3 -2
- data/lib/rails_ai_context/presets.rb +15 -11
- data/lib/rails_ai_context/redaction.rb +262 -51
- data/lib/rails_ai_context/resources.rb +55 -20
- data/lib/rails_ai_context/route_coverage.rb +8 -0
- data/lib/rails_ai_context/safe_file.rb +14 -1
- data/lib/rails_ai_context/safe_path.rb +101 -4
- data/lib/rails_ai_context/schema_adapter.rb +8 -0
- data/lib/rails_ai_context/schema_version.rb +9 -0
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +88 -33
- data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
- data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +19 -16
- data/lib/rails_ai_context/serializers/guide_examples.rb +142 -0
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +146 -30
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +38 -15
- data/lib/rails_ai_context/serializers/section_facts.rb +44 -1
- data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
- data/lib/rails_ai_context/server.rb +304 -71
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -372
- data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
- data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
- data/lib/rails_ai_context/tools/base_tool.rb +367 -46
- data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
- data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
- data/lib/rails_ai_context/tools/diagnose.rb +218 -11
- data/lib/rails_ai_context/tools/generate_test.rb +339 -39
- data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
- data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
- data/lib/rails_ai_context/tools/get_concern.rb +91 -27
- data/lib/rails_ai_context/tools/get_config.rb +13 -4
- data/lib/rails_ai_context/tools/get_context.rb +71 -14
- data/lib/rails_ai_context/tools/get_controllers.rb +179 -44
- data/lib/rails_ai_context/tools/get_conventions.rb +28 -6
- data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
- data/lib/rails_ai_context/tools/get_env.rb +18 -9
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
- data/lib/rails_ai_context/tools/get_gems.rb +1 -1
- data/lib/rails_ai_context/tools/get_helper_methods.rb +10 -6
- data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
- data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -2
- data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
- data/lib/rails_ai_context/tools/get_model_details.rb +30 -10
- data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
- data/lib/rails_ai_context/tools/get_routes.rb +29 -8
- data/lib/rails_ai_context/tools/get_schema.rb +52 -19
- data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
- data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
- data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
- data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
- data/lib/rails_ai_context/tools/get_view.rb +14 -14
- data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
- data/lib/rails_ai_context/tools/onboard.rb +52 -8
- data/lib/rails_ai_context/tools/performance_check.rb +5 -3
- data/lib/rails_ai_context/tools/query.rb +914 -86
- data/lib/rails_ai_context/tools/read_logs.rb +69 -15
- data/lib/rails_ai_context/tools/review_changes.rb +215 -53
- data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
- data/lib/rails_ai_context/tools/safe_call.rb +35 -14
- data/lib/rails_ai_context/tools/search_code.rb +243 -67
- data/lib/rails_ai_context/tools/search_docs.rb +4 -4
- data/lib/rails_ai_context/tools/security_scan.rb +143 -33
- data/lib/rails_ai_context/tools/session_context.rb +39 -5
- data/lib/rails_ai_context/tools/validate.rb +259 -40
- data/lib/rails_ai_context/tools/validate_semantics.rb +130 -56
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +67 -40
- data/lib/rails_ai_context/view_file.rb +41 -3
- data/lib/rails_ai_context/watcher.rb +51 -13
- data/lib/rails_ai_context.rb +21 -0
- data/server.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
87
|
+
broadcast_streams([], [ b ], models).any? { |cast| subscribed.any? { |sub| stream_relation(sub, cast, models) == :match } }
|
|
73
88
|
}
|
|
74
89
|
end
|
|
75
90
|
|
|
76
|
-
|
|
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,
|
|
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 =
|
|
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 <<
|
|
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 <<
|
|
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
|
-
#
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
next if
|
|
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
|
-
#
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
416
|
-
next
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
|