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
|
@@ -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")
|
|
@@ -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
|
|
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),
|
|
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]
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
+
FileWalk.glob(dir, "**/*.rb", root: root).map do |path|
|
|
150
150
|
path.sub("#{dir}/", "").sub(/\.rb\z/, "")
|
|
151
|
-
end
|
|
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 |
|
|
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
|
-
|
|
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 "
|
|
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 "
|
|
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
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# Server#maybe_start_live_reload owns
|
|
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
|
|
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
|
|
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
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
|
|
90
|
-
|
|
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}
|
|
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
|