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
|
@@ -204,11 +204,15 @@ module RailsAiContext
|
|
|
204
204
|
end
|
|
205
205
|
end
|
|
206
206
|
|
|
207
|
-
# Cross-reference hints
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
207
|
+
# Cross-reference hints, to the views and the controller the helper is
|
|
208
|
+
# named for, and only those the app has: ApplicationHelper has no
|
|
209
|
+
# controller of its own to send the reader to.
|
|
210
|
+
route_key = relative_under(file_path, helper_dirs).delete_suffix(".rb").delete_suffix("_helper")
|
|
211
|
+
hints = []
|
|
212
|
+
hints << "`rails_get_view(controller:\"#{route_key}\")` for views" if RailsAiContext::PathResolver.view_dirs(root).any? { |dir| Dir.exist?(File.join(dir, route_key)) }
|
|
213
|
+
controller = Payload.controller_for_route_key(cached_context, route_key)&.first
|
|
214
|
+
hints << "`rails_get_controllers(controller:\"#{controller}\")` for controller" if controller
|
|
215
|
+
lines << "" << "_Next: #{hints.join(' | ')}_" if hints.any?
|
|
212
216
|
|
|
213
217
|
text_response(lines.join("\n"))
|
|
214
218
|
end
|
|
@@ -229,7 +233,7 @@ module RailsAiContext
|
|
|
229
233
|
}
|
|
230
234
|
end
|
|
231
235
|
|
|
232
|
-
sorted = helpers_data.sort_by { |h| -h[:method_count] }
|
|
236
|
+
sorted = helpers_data.sort_by.with_index { |h, i| [ -h[:method_count], i ] }
|
|
233
237
|
page = paginate(sorted, offset: offset, limit: limit, default_limit: 50)
|
|
234
238
|
declared = controller_helper_methods(root)
|
|
235
239
|
|
|
@@ -132,7 +132,7 @@ module RailsAiContext
|
|
|
132
132
|
lines << "- **Fallbacks:** #{Array(fallbacks[locale.to_sym] || fallbacks[locale]).join(', ')}"
|
|
133
133
|
end
|
|
134
134
|
|
|
135
|
-
render_file_list(lines, page, "Files for #{locale}", "_No locale files found for '#{locale}'._")
|
|
135
|
+
render_file_list(lines, page, "Files for #{echo_input(locale)}", "_No locale files found for '#{echo_input(locale)}'._")
|
|
136
136
|
text_response(lines.join("\n"))
|
|
137
137
|
end
|
|
138
138
|
|
|
@@ -417,7 +417,7 @@ module RailsAiContext
|
|
|
417
417
|
# Queue summary
|
|
418
418
|
# An unresolved base has no queue the scan can claim, so it is counted apart.
|
|
419
419
|
queues = job_data.map { |j| j[:queue] || (j[:unknown_base] ? "unknown" : "default") }
|
|
420
|
-
.tally.sort_by { |_, c| -c }
|
|
420
|
+
.tally.sort_by.with_index { |(_, c), i| [ -c, i ] }
|
|
421
421
|
if queues.any?
|
|
422
422
|
queue_str = queues.map { |q, c| "#{q}(#{c})" }.join(", ")
|
|
423
423
|
lines << "**Queues:** #{queue_str}"
|
|
@@ -549,9 +549,10 @@ module RailsAiContext
|
|
|
549
549
|
body = perform && Introspectors::ActionResolver.body_of(source, perform)
|
|
550
550
|
return [] unless body
|
|
551
551
|
|
|
552
|
-
body[:code].lines.map(&:strip).select { |line|
|
|
552
|
+
guards = body[:code].lines.map(&:strip).select { |line|
|
|
553
553
|
line.match?(/\Areturn\s+(if|unless)\b/) || (line.match?(/\Areturn\b/) && line.length < 120)
|
|
554
554
|
}.first(10)
|
|
555
|
+
RailsAiContext::Redaction.redact_source_lines(guards)
|
|
555
556
|
end
|
|
556
557
|
|
|
557
558
|
private_class_method def self.extract_broadcasts(source)
|
|
@@ -29,8 +29,8 @@ module RailsAiContext
|
|
|
29
29
|
|
|
30
30
|
guide_row(
|
|
31
31
|
order: 41,
|
|
32
|
-
mcp: "rails_get_mailers(mailer:\"
|
|
33
|
-
cli_args: "mailer=
|
|
32
|
+
mcp: "rails_get_mailers(mailer:\"X\")",
|
|
33
|
+
cli_args: "mailer=X",
|
|
34
34
|
summary: "Mailer classes with delivery actions and delivery method, mailboxes and their routing"
|
|
35
35
|
)
|
|
36
36
|
|
|
@@ -76,7 +76,7 @@ module RailsAiContext
|
|
|
76
76
|
elsif page[:total].zero?
|
|
77
77
|
# An empty page past the end is not an app without mailers, and the
|
|
78
78
|
# hint below already says where the end is.
|
|
79
|
-
lines << "_No mailers found#{" matching '#{mailer}'" if mailer}._"
|
|
79
|
+
lines << "_No mailers found#{" matching '#{echo_input(mailer)}'" if mailer}._"
|
|
80
80
|
end
|
|
81
81
|
|
|
82
82
|
lines.concat(mailbox_lines(Payload.section(cached_context, :action_mailbox))) if mailer.nil? && page[:offset].zero?
|
|
@@ -44,7 +44,7 @@ module RailsAiContext
|
|
|
44
44
|
data = models[key]
|
|
45
45
|
unless data
|
|
46
46
|
reason = RailsAiContext::AppKind.without_active_record(rails_app.root)
|
|
47
|
-
return empty_response("No Active Record model named #{model}: #{reason}.") if reason && models.empty?
|
|
47
|
+
return empty_response("No Active Record model named #{echo_input(model)}: #{reason}.") if reason && models.empty?
|
|
48
48
|
|
|
49
49
|
return not_found_response("Model", model, models.keys.sort,
|
|
50
50
|
recovery_tool: "Call rails_get_model_details(detail:\"summary\") to see all models")
|
|
@@ -96,8 +96,9 @@ module RailsAiContext
|
|
|
96
96
|
next
|
|
97
97
|
end
|
|
98
98
|
assocs = Serializers::SectionFacts.associations_list(data).join(", ")
|
|
99
|
+
note = Serializers::SectionFacts.table_note(Payload.section(cached_context, :schema), data)
|
|
99
100
|
line = "- **#{name}**"
|
|
100
|
-
line += " (table: #{data[:table_name]})" if data[:table_name]
|
|
101
|
+
line += note ? " (table #{note})" : (" (table: #{data[:table_name]})" if data[:table_name]).to_s
|
|
101
102
|
line += " - #{assocs}" unless assocs.empty?
|
|
102
103
|
lines << line
|
|
103
104
|
end
|
|
@@ -155,15 +156,20 @@ module RailsAiContext
|
|
|
155
156
|
private_class_method def self.format_model(name, data)
|
|
156
157
|
# Static-tier entries already carry [STATIC]; a runtime entry with a
|
|
157
158
|
# resolved table is reflection-confirmed, hence [VERIFIED].
|
|
159
|
+
note = Serializers::SectionFacts.table_note(Payload.section(cached_context, :schema), data)
|
|
158
160
|
header_tag = if data[:confidence]
|
|
159
161
|
" #{data[:confidence]}"
|
|
160
|
-
elsif data[:table_name] && !RailsAiContext.static_tier?
|
|
162
|
+
elsif data[:table_name] && !note && !RailsAiContext.static_tier?
|
|
161
163
|
" #{Confidence::VERIFIED}"
|
|
162
164
|
else
|
|
163
165
|
""
|
|
164
166
|
end
|
|
165
167
|
lines = [ "# #{name}#{header_tag}", "" ]
|
|
166
|
-
|
|
168
|
+
if note
|
|
169
|
+
lines << "**Table:** #{note}"
|
|
170
|
+
elsif data[:table_name]
|
|
171
|
+
lines << "**Table:** `#{data[:table_name]}`"
|
|
172
|
+
end
|
|
167
173
|
if (database = data[:database])
|
|
168
174
|
lines << "**Database:** `#{database[:connects_to]}`#{" (only #{database[:condition]})" if database[:condition]}" \
|
|
169
175
|
"#{", inherited from `#{database[:declared_in]}`" if database[:declared_in]}"
|
|
@@ -174,6 +180,10 @@ module RailsAiContext
|
|
|
174
180
|
# all, so this stands outside that section.
|
|
175
181
|
bases_unread = data[:bases_unread]
|
|
176
182
|
lines << unread_bases_line(bases_unread) if bases_unread&.any?
|
|
183
|
+
if (patches = data[:patches_unread])&.any?
|
|
184
|
+
lines << "#{Confidence::UNAVAILABLE} #{count_phrase(patches.size, "patch module")} not read, so what " \
|
|
185
|
+
"#{patches.one? ? "it adds" : "they add"} to #{name} is missing here: #{patches.join(', ')}"
|
|
186
|
+
end
|
|
177
187
|
|
|
178
188
|
# File structure - compact one-line format
|
|
179
189
|
structure = extract_model_structure(name)
|
|
@@ -220,11 +230,16 @@ module RailsAiContext
|
|
|
220
230
|
# Mongoid documents have no AR table/columns - render declared fields
|
|
221
231
|
# and embedded relations directly from the source-parsed macros instead.
|
|
222
232
|
if data[:mongoid]
|
|
233
|
+
# The collection is where the documents live, as the table is for a
|
|
234
|
+
# record; an embedded document has none of its own.
|
|
235
|
+
lines.insert(2, "**Collection:** `#{data[:collection]}`") if data[:collection]
|
|
236
|
+
lines.insert(2, "**Embedded in:** `#{data[:embedded_in]}` (no collection of its own)") if data[:embedded_in]
|
|
223
237
|
if data[:fields]&.any?
|
|
224
238
|
lines << "" << "## Fields"
|
|
225
239
|
data[:fields].each do |f|
|
|
226
240
|
type_str = f[:type] ? ": #{f[:type]}" : ""
|
|
227
|
-
|
|
241
|
+
from = f[:implicit] ? " _(#{f[:implicit]})_" : ""
|
|
242
|
+
lines << "- `#{f[:name]}`#{type_str}#{", default: #{f[:default]}" if f.key?(:default)}#{from}"
|
|
228
243
|
end
|
|
229
244
|
end
|
|
230
245
|
|
|
@@ -282,7 +297,7 @@ module RailsAiContext
|
|
|
282
297
|
data[:validations].each do |v|
|
|
283
298
|
next if v[:implicit] && (v[:attributes] & written).any?
|
|
284
299
|
|
|
285
|
-
dedup_key = [ v[:kind].to_s, v[:attributes].sort, v[:options] ]
|
|
300
|
+
dedup_key = [ v[:kind].to_s, v[:validator], v[:attributes].sort, v[:options] ]
|
|
286
301
|
next if seen_validations.include?(dedup_key)
|
|
287
302
|
seen_validations << dedup_key
|
|
288
303
|
attrs = v[:attributes].join(", ")
|
|
@@ -563,8 +578,10 @@ module RailsAiContext
|
|
|
563
578
|
# Cross-reference hints - guide AI to related tools
|
|
564
579
|
hints = []
|
|
565
580
|
hints << "`rails_get_schema(table:\"#{data[:table_name]}\")` for columns/indexes" if data[:table_name]
|
|
566
|
-
|
|
567
|
-
|
|
581
|
+
# Only a controller the app has: the conventional name is a guess, and
|
|
582
|
+
# a hint naming a class that does not exist sends the reader nowhere.
|
|
583
|
+
controller_name = Payload.controller_for_route_key(cached_context, name.underscore.pluralize)&.first
|
|
584
|
+
hints << "`rails_get_controllers(controller:\"#{controller_name}\")` for actions" if controller_name
|
|
568
585
|
hints << "`rails_analyze_feature(feature:\"#{name}\")` for full-stack view"
|
|
569
586
|
lines << "" << "_Next: #{hints.join(' | ')}_"
|
|
570
587
|
|
|
@@ -579,9 +596,12 @@ module RailsAiContext
|
|
|
579
596
|
end
|
|
580
597
|
|
|
581
598
|
# A carried path can name a gem rather than the app, and joining that to
|
|
582
|
-
# the app root opens nothing. One reader answers both shapes.
|
|
599
|
+
# the app root opens nothing. One reader answers both shapes. A file a
|
|
600
|
+
# symlink carries out of the app is not read.
|
|
583
601
|
private_class_method def self.resolved_model_path(model_name)
|
|
584
|
-
|
|
602
|
+
root = rails_app.root.to_s
|
|
603
|
+
path = RailsAiContext::PortablePath.resolve(relative_model_path(model_name), root)
|
|
604
|
+
path unless path && RailsAiContext::PathResolver.linked_out?(path, root)
|
|
585
605
|
end
|
|
586
606
|
|
|
587
607
|
# The macro's options are nested under an :options key. Printing that
|
|
@@ -46,8 +46,8 @@ module RailsAiContext
|
|
|
46
46
|
# decides whether the partial exists.
|
|
47
47
|
guard = RailsAiContext::SafePath.locate(partial, under: views_dir, root: root)
|
|
48
48
|
case guard.refusal
|
|
49
|
-
when :traversal then return error_response("Path not allowed: #{partial}")
|
|
50
|
-
when :sensitive then return error_response("Path not allowed: #{partial} (sensitive file)")
|
|
49
|
+
when :traversal then return error_response("Path not allowed: #{echo_input(partial)}")
|
|
50
|
+
when :sensitive then return error_response("Path not allowed: #{echo_input(partial)} (sensitive file)")
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
if view_dirs.empty?
|
|
@@ -70,6 +70,12 @@ module RailsAiContext
|
|
|
70
70
|
|
|
71
71
|
located = view_dirs.lazy.filter_map { |dir| resolve_partial_path(dir, partial) }.first
|
|
72
72
|
|
|
73
|
+
# The file the name resolves to, refused on policy: a link out of the app.
|
|
74
|
+
case located&.refusal
|
|
75
|
+
when :traversal, :outside then return error_response("Path not allowed: #{echo_input(partial)}")
|
|
76
|
+
when :sensitive then return error_response("Path not allowed: #{echo_input(partial)} (sensitive file)")
|
|
77
|
+
end
|
|
78
|
+
|
|
73
79
|
unless located
|
|
74
80
|
available = view_dirs.flat_map { |dir| find_available_partials(dir, root) }.uniq.sort.first(30)
|
|
75
81
|
return not_found_response("Partial", partial, available,
|
|
@@ -92,14 +98,24 @@ module RailsAiContext
|
|
|
92
98
|
# The resolved file, not the caller's spelling: a bare name has no
|
|
93
99
|
# directory for a render site to be matched against.
|
|
94
100
|
prefixed = prefix_partial_paths?(root)
|
|
95
|
-
render_sites = view_dirs.flat_map { |dir| find_render_sites(dir, partial_name, root, prefixed) }
|
|
101
|
+
render_sites = view_dirs.flat_map { |dir| find_render_sites(dir, partial_name, root, prefixed) } +
|
|
102
|
+
code_render_sites(root, canonical_name(partial_name))
|
|
103
|
+
# `_post.html.erb` and `_post.json.jbuilder` share a name but not their
|
|
104
|
+
# callers: each renders only where its format is the one looked up.
|
|
105
|
+
partial_format = template_format(file_path)
|
|
106
|
+
render_sites = render_sites.select { |site| renders_format?(site, partial_format) }
|
|
96
107
|
method_calls = {}
|
|
97
108
|
|
|
98
109
|
# Primary: locals from render call sites (ground truth)
|
|
99
110
|
render_locals = render_sites.flat_map { |rs| rs[:locals] || [] }.uniq
|
|
100
111
|
|
|
101
112
|
# Secondary: local_assigns checks + defined? guards in partial source
|
|
102
|
-
|
|
113
|
+
helpers = Introspectors::HelperNames.for(root)
|
|
114
|
+
source_locals = if file_path.end_with?(".jbuilder")
|
|
115
|
+
jbuilder_locals(source, helpers)
|
|
116
|
+
else
|
|
117
|
+
extract_local_variable_references(source, helpers)
|
|
118
|
+
end
|
|
103
119
|
|
|
104
120
|
# Combine: render-site locals first, then source-detected locals
|
|
105
121
|
# Filter out noise: single chars, capitalized words, known helpers
|
|
@@ -170,7 +186,7 @@ module RailsAiContext
|
|
|
170
186
|
next unless full && site[:snippet]
|
|
171
187
|
|
|
172
188
|
lines << " ```erb"
|
|
173
|
-
lines << " #{site[:snippet].strip}"
|
|
189
|
+
lines << " #{RailsAiContext::Redaction.redact_source(site[:snippet].strip, path: site[:file])}"
|
|
174
190
|
lines << " ```"
|
|
175
191
|
end
|
|
176
192
|
if render_sites.size > cap
|
|
@@ -183,7 +199,7 @@ module RailsAiContext
|
|
|
183
199
|
if full
|
|
184
200
|
lines << "" << "## Source"
|
|
185
201
|
lines << "```erb"
|
|
186
|
-
lines << source
|
|
202
|
+
lines << RailsAiContext::Redaction.redact_source(source, path: relative_path)
|
|
187
203
|
lines << "```"
|
|
188
204
|
else
|
|
189
205
|
lines << ""
|
|
@@ -223,8 +239,9 @@ module RailsAiContext
|
|
|
223
239
|
|
|
224
240
|
return nil unless found
|
|
225
241
|
|
|
242
|
+
# A refused file comes back with its refusal, so the caller says so rather than call it missing.
|
|
226
243
|
located = RailsAiContext::SafePath.locate(found.delete_prefix(views_dir + File::SEPARATOR), under: views_dir, root: rails_app.root.to_s)
|
|
227
|
-
located.
|
|
244
|
+
located.refusal == :missing ? nil : located
|
|
228
245
|
end
|
|
229
246
|
|
|
230
247
|
# A view file's name as the app renders it: its path under the innermost
|
|
@@ -309,10 +326,14 @@ module RailsAiContext
|
|
|
309
326
|
code = match[0].strip
|
|
310
327
|
next if code.start_with?("#")
|
|
311
328
|
|
|
312
|
-
# 1. Standalone ERB output: <%= local_name %> or <%= local_name.method
|
|
313
|
-
|
|
329
|
+
# 1. Standalone ERB output: <%= local_name %> or <%= local_name.method %>.
|
|
330
|
+
# A name called with parentheses is a method, and one ending in
|
|
331
|
+
# _path or _url a route helper: `dom_id(notification)` and
|
|
332
|
+
# `mark_read_notification_path(notification)` were listed as locals.
|
|
333
|
+
if (m = code.match(/\A\s*([a-z_]\w*)\s*(?:\z|\.)/))
|
|
314
334
|
name = m[1]
|
|
315
|
-
locals << name unless known_non_locals.include?(name) || block_params.include?(name) ||
|
|
335
|
+
locals << name unless known_non_locals.include?(name) || block_params.include?(name) ||
|
|
336
|
+
helpers.include?(name) || route_helper?(name)
|
|
316
337
|
end
|
|
317
338
|
|
|
318
339
|
# 2. defined?(local) guard pattern
|
|
@@ -334,6 +355,200 @@ module RailsAiContext
|
|
|
334
355
|
RailsAiContext.debug_fail(e, [], label: "extract_local_variable_references")
|
|
335
356
|
end
|
|
336
357
|
|
|
358
|
+
# Every template a render site can sit in, jbuilder's Ruby included.
|
|
359
|
+
SITE_GLOB = "**/*.{erb,haml,slim,jbuilder}"
|
|
360
|
+
|
|
361
|
+
# Whether a site looks a partial of this format up: in the formats the
|
|
362
|
+
# call names, else those its place gives it (nil for any).
|
|
363
|
+
private_class_method def self.renders_format?(site, format)
|
|
364
|
+
return true unless format
|
|
365
|
+
|
|
366
|
+
formats = site.key?(:formats) ? site[:formats] : template_lookup(site[:file])
|
|
367
|
+
formats.nil? || formats.include?(format)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# A template looks in its own format: a json.jbuilder in json, an
|
|
371
|
+
# html.erb in html, and one whose name gives none in any. A
|
|
372
|
+
# turbo_stream template looks in html too (Turbo's request accepts
|
|
373
|
+
# html), as a js one does (Rails' html fallback for js).
|
|
374
|
+
private_class_method def self.template_lookup(file)
|
|
375
|
+
own = template_format(file) or return nil
|
|
376
|
+
%w[turbo_stream js].include?(own) ? [ own, "html" ] : [ own ]
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# `render partial: "posts/post", formats: [:json]`: the formats a call
|
|
380
|
+
# names, which Rails looks the partial up in whatever the request asked.
|
|
381
|
+
# Action View reads `options[:formats]` alone, from the hash it renders
|
|
382
|
+
# with: the one holding `partial:`, or json.partial!'s keywords. So a
|
|
383
|
+
# `format:` is a local, as is any key inside `locals: {}`, and in
|
|
384
|
+
# `render "shared/date", date: d, format: :short` every key is one.
|
|
385
|
+
FORMATS_OPTION = /(?<![\w:])(?:formats:|:formats\s*=>)\s*(?:%[iIwW])?(\[[^\]]*\]|:\w+|["']\w+["'])/
|
|
386
|
+
|
|
387
|
+
# The formats the options beside offset `at` name: those of the
|
|
388
|
+
# innermost bracket group holding it, or of the whole call, read with
|
|
389
|
+
# each hash and call nested there blanked. nil for no offset, which a
|
|
390
|
+
# call that names its partial first has.
|
|
391
|
+
private_class_method def self.named_formats(call, at)
|
|
392
|
+
return nil unless at
|
|
393
|
+
|
|
394
|
+
level = +""
|
|
395
|
+
RailsAiContext::Brackets.each_top_level(call, comments: :ruby) do |piece, kind|
|
|
396
|
+
from = level.length
|
|
397
|
+
return named_formats(piece[1...-1], at - from - 1) if kind == :group && at > from && at < from + piece.length - 1
|
|
398
|
+
|
|
399
|
+
nested = kind == :comment || (kind == :group && !piece.start_with?("["))
|
|
400
|
+
level << (nested ? " " * piece.length : piece)
|
|
401
|
+
end
|
|
402
|
+
level[FORMATS_OPTION, 1]&.scan(/\w+/)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Where a render names the partial as its `partial:` option, or nil
|
|
406
|
+
# where it names it first, as the shorthand does.
|
|
407
|
+
private_class_method def self.partial_key_at(line, name)
|
|
408
|
+
line =~ /(?:\bpartial:|:partial\s*=>)\s*(["'])#{Regexp.escape(name)}\1/
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# The formats a call in Ruby renders its partial in when it names none.
|
|
412
|
+
# A Turbo Stream builder renders html, and so do a broadcast and
|
|
413
|
+
# ApplicationController.render outside a request. In a controller or a
|
|
414
|
+
# helper the request decides, html or json alike, unless the call sits
|
|
415
|
+
# in a `format.json { }` block of a respond_to.
|
|
416
|
+
private_class_method def self.code_site_formats(call, relative, line, format_blocks)
|
|
417
|
+
return [ "html" ] if call.match?(/\bturbo_stream\.\w+/)
|
|
418
|
+
|
|
419
|
+
block = format_blocks.select { |from, to, _| from <= line && line <= to }.max_by(&:first)
|
|
420
|
+
return (block.last == "any" ? nil : [ block.last ]) if block
|
|
421
|
+
return nil if relative.match?(%r{(?:\A|/)app/(?:controllers|helpers)/})
|
|
422
|
+
|
|
423
|
+
[ "html" ]
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# [first line, last line, format] of each `format.json { }` block a
|
|
427
|
+
# respond_to holds, whatever its block names the collector.
|
|
428
|
+
private_class_method def self.format_blocks(content)
|
|
429
|
+
blocks = []
|
|
430
|
+
visit = lambda do |node|
|
|
431
|
+
if node.is_a?(Prism::CallNode) && node.name == :respond_to && node.block.is_a?(Prism::BlockNode)
|
|
432
|
+
param = node.block.parameters&.parameters&.requireds&.first
|
|
433
|
+
collector = param.respond_to?(:name) ? param.name : nil
|
|
434
|
+
collect = lambda do |inner|
|
|
435
|
+
if inner.is_a?(Prism::CallNode) && inner.block && inner.receiver.is_a?(Prism::LocalVariableReadNode) && inner.receiver.name == collector
|
|
436
|
+
blocks << [ inner.block.location.start_line, inner.block.location.end_line, inner.name.to_s ]
|
|
437
|
+
end
|
|
438
|
+
inner.compact_child_nodes.each(&collect)
|
|
439
|
+
end
|
|
440
|
+
collect.call(node.block)
|
|
441
|
+
end
|
|
442
|
+
node.compact_child_nodes.each(&visit)
|
|
443
|
+
end
|
|
444
|
+
visit.call(RailsAiContext::AstCache.parse_string(content).value)
|
|
445
|
+
blocks
|
|
446
|
+
rescue => e
|
|
447
|
+
RailsAiContext.debug_fail(e, [], label: "format_blocks")
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# A template's format as its name gives it; jbuilder's handler writes
|
|
451
|
+
# json, its default format, when the name gives none.
|
|
452
|
+
private_class_method def self.template_format(path)
|
|
453
|
+
RailsAiContext::ViewFile.format_of(path) || ("json" if path.end_with?(".jbuilder"))
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# "shared/_status_badge.html.erb" as a render call names it: "shared/status_badge".
|
|
457
|
+
private_class_method def self.canonical_name(partial_name)
|
|
458
|
+
parts = partial_name.split("/")
|
|
459
|
+
(parts[0...-1] + [ parts.last.delete_prefix("_").sub(/\..*\z/, "") ]).join("/")
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
private_class_method def self.route_helper?(name)
|
|
463
|
+
name.match?(/_(?:path|url)\z/)
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# Names Ruby cannot tell from a method call at parse time that a jbuilder
|
|
467
|
+
# template reads without defining them itself.
|
|
468
|
+
JBUILDER_NON_LOCALS = %w[json local_assigns params request controller current_page? render cache!].to_set.freeze
|
|
469
|
+
|
|
470
|
+
# A jbuilder partial is Ruby throughout, so the ERB tag scan found none
|
|
471
|
+
# of its locals: `invoice` in api/v1/invoices/_invoice.json.jbuilder.
|
|
472
|
+
# A local reads as a bare name the file never assigns, which Prism marks
|
|
473
|
+
# as a variable call; a helper or a route helper is not one.
|
|
474
|
+
private_class_method def self.jbuilder_locals(source, helpers)
|
|
475
|
+
names = Set.new
|
|
476
|
+
visit = lambda do |node|
|
|
477
|
+
names << node.name.to_s if node.is_a?(Prism::CallNode) && node.variable_call?
|
|
478
|
+
node.compact_child_nodes.each(&visit)
|
|
479
|
+
end
|
|
480
|
+
visit.call(RailsAiContext::AstCache.parse_string(source).value)
|
|
481
|
+
names.reject { |name| JBUILDER_NON_LOCALS.include?(name) || helpers.include?(name) || route_helper?(name) }.sort
|
|
482
|
+
rescue => e
|
|
483
|
+
RailsAiContext.debug_fail(e, [], label: "jbuilder_locals")
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Where a partial is named outside a render call: turbo_stream.prepend
|
|
487
|
+
# in a .turbo_stream.erb, broadcast_prepend_to in a model, json.partial!
|
|
488
|
+
# and json.array! in jbuilder. Ruby code outside the views names a
|
|
489
|
+
# partial by its full path, since it has no view directory of its own.
|
|
490
|
+
CODE_DIRS = %w[app/models app/controllers app/jobs app/channels app/helpers].freeze
|
|
491
|
+
# The keywords Turbo and jbuilder take beside a partial, which are not its locals.
|
|
492
|
+
PARTIAL_CALL_KEYWORDS = %w[target targets action attributes method html content renderable request_id as].freeze
|
|
493
|
+
|
|
494
|
+
private_class_method def self.partial_arg_sites(content, relative, names, skip_spans: [], code: false)
|
|
495
|
+
lines = content.lines
|
|
496
|
+
sites = []
|
|
497
|
+
blocks = nil
|
|
498
|
+
content.to_enum(:scan, Introspectors::ViewTemplateIntrospector::PARTIAL_ARG).each do
|
|
499
|
+
match = Regexp.last_match
|
|
500
|
+
at = match.begin(0)
|
|
501
|
+
next unless names.include?(match[2])
|
|
502
|
+
next if skip_spans.any? { |from, to| at >= from && at < to }
|
|
503
|
+
|
|
504
|
+
# The line the call starts on, which a trailing comma carries onto this one.
|
|
505
|
+
start = content[0...at].count("\n")
|
|
506
|
+
start -= 1 while start.positive? && lines[start - 1].rstrip.end_with?(",")
|
|
507
|
+
call = call_lines(lines, start)
|
|
508
|
+
site = { file: relative, line: start + 1, locals: partial_call_locals(call), snippet: call.squish }
|
|
509
|
+
# The name's own offset in the call: the options it is rendered
|
|
510
|
+
# with sit beside it, inside json.partial!'s parentheses too.
|
|
511
|
+
named = named_formats(call, match.begin(1) - lines.first(start).sum(&:length))
|
|
512
|
+
if code
|
|
513
|
+
site[:formats] = named || code_site_formats(call, relative, start + 1, blocks ||= format_blocks(content))
|
|
514
|
+
elsif named
|
|
515
|
+
site[:formats] = named
|
|
516
|
+
end
|
|
517
|
+
sites << site
|
|
518
|
+
end
|
|
519
|
+
sites
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# The line a call starts on and every line a trailing comma carries it onto.
|
|
523
|
+
private_class_method def self.call_lines(lines, index)
|
|
524
|
+
taken = []
|
|
525
|
+
lines[index..].each do |line|
|
|
526
|
+
taken << line
|
|
527
|
+
break unless line.split("%>", 2).first.rstrip.end_with?(",")
|
|
528
|
+
end
|
|
529
|
+
taken.join.split("%>", 2).first
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# The locals a call hands the partial: its `locals:` hash, the name
|
|
533
|
+
# `as:` gives each record, or json.partial!'s own keywords.
|
|
534
|
+
private_class_method def self.partial_call_locals(call)
|
|
535
|
+
named = call[/\bas:\s*:(\w+)/, 1]
|
|
536
|
+
(extract_locals_from_render(call) - PARTIAL_CALL_KEYWORDS + [ named ]).compact.uniq
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
private_class_method def self.code_render_sites(root, canonical)
|
|
540
|
+
CODE_DIRS.flat_map { |kind| RailsAiContext::PathResolver.dirs_for(root, kind) }.uniq.flat_map do |dir|
|
|
541
|
+
RailsAiContext::FileWalk.glob(dir, "**/*.rb", root: root).flat_map do |path|
|
|
542
|
+
content = safe_read(path)
|
|
543
|
+
next [] unless content&.include?(canonical)
|
|
544
|
+
|
|
545
|
+
partial_arg_sites(content, RailsAiContext::PortablePath.relativize(path, root), [ canonical ], code: true)
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
rescue => e
|
|
549
|
+
RailsAiContext.debug_fail(e, [], label: "code_render_sites")
|
|
550
|
+
end
|
|
551
|
+
|
|
337
552
|
# Find all views that render this partial and extract the locals they pass.
|
|
338
553
|
private_class_method def self.find_render_sites(views_dir, partial, root, prefixed)
|
|
339
554
|
sites = []
|
|
@@ -359,7 +574,7 @@ module RailsAiContext
|
|
|
359
574
|
|
|
360
575
|
object_paths = {}
|
|
361
576
|
|
|
362
|
-
view_files = RailsAiContext::ViewFile.glob(root, views_dir,
|
|
577
|
+
view_files = RailsAiContext::ViewFile.glob(root, views_dir, SITE_GLOB)
|
|
363
578
|
|
|
364
579
|
view_files.each do |file|
|
|
365
580
|
content = safe_read(file)
|
|
@@ -371,8 +586,10 @@ module RailsAiContext
|
|
|
371
586
|
# Per call, not per line: `render(` and a call split over lines are
|
|
372
587
|
# one call whose arguments the line alone does not hold.
|
|
373
588
|
covered = 0
|
|
589
|
+
spans = []
|
|
374
590
|
Introspectors::ViewTemplateIntrospector.render_calls(content).each do |at, args|
|
|
375
591
|
args = args.split("%>", 2).first
|
|
592
|
+
spans << [ at, at + args.length ]
|
|
376
593
|
# A render inside another's arguments is already part of that call's site.
|
|
377
594
|
next if at < covered
|
|
378
595
|
|
|
@@ -384,25 +601,22 @@ module RailsAiContext
|
|
|
384
601
|
|
|
385
602
|
matched_line = false
|
|
386
603
|
search_patterns.each do |search_name|
|
|
387
|
-
# Match render "partial_name" or render partial: "partial_name"
|
|
388
|
-
#
|
|
389
|
-
next unless line.match?(/render\s.*["']
|
|
604
|
+
# Match render "partial_name" or render partial: "partial_name",
|
|
605
|
+
# the name whole: "comments/form" is another directory's form.
|
|
606
|
+
next unless line.match?(/render\s.*["']#{Regexp.escape(search_name)}["']/)
|
|
390
607
|
|
|
391
|
-
#
|
|
608
|
+
# A bare name reaches the partial only from its own directory.
|
|
392
609
|
if search_name == basename && dir_prefix.length > 0
|
|
393
|
-
# Only match if the full path is referenced, or the render is in the same directory
|
|
394
610
|
file_dir = File.dirname(file).sub("#{views_dir}/", "")
|
|
395
|
-
next unless
|
|
611
|
+
next unless file_dir == dir_prefix
|
|
396
612
|
end
|
|
397
613
|
|
|
398
614
|
locals_passed = extract_locals_from_render(line)
|
|
399
615
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
snippet: snippet
|
|
405
|
-
}
|
|
616
|
+
site = { file: relative, line: line_num, locals: locals_passed, snippet: snippet }
|
|
617
|
+
named = named_formats(line, partial_key_at(line, search_name))
|
|
618
|
+
site[:formats] = named if named
|
|
619
|
+
sites << site
|
|
406
620
|
matched_line = true
|
|
407
621
|
break # one match per call is enough
|
|
408
622
|
end
|
|
@@ -417,6 +631,11 @@ module RailsAiContext
|
|
|
417
631
|
|
|
418
632
|
sites << { file: relative, line: line_num, locals: [], snippet: snippet }
|
|
419
633
|
end
|
|
634
|
+
|
|
635
|
+
# A bare name reaches the partial only from its own directory.
|
|
636
|
+
file_dir = File.dirname(file).sub("#{views_dir}/", "")
|
|
637
|
+
names = dir_prefix.empty? || file_dir == dir_prefix ? search_patterns : [ canonical ]
|
|
638
|
+
sites.concat(partial_arg_sites(content, relative, names, skip_spans: spans))
|
|
420
639
|
end
|
|
421
640
|
|
|
422
641
|
sites
|
|
@@ -25,7 +25,9 @@ module RailsAiContext
|
|
|
25
25
|
},
|
|
26
26
|
app_only: {
|
|
27
27
|
type: "boolean",
|
|
28
|
-
description: "
|
|
28
|
+
description: "Leave out the routes Rails' engines and gems draw on their own (Active Storage, Action Mailbox and its conductor, " \
|
|
29
|
+
"Turbo Native navigation, Rails' info and mailer-preview pages). Routes the app's route files declare stay, " \
|
|
30
|
+
"devise_for's and the health check's included. A controller filter searches every route. Default: true."
|
|
29
31
|
}
|
|
30
32
|
}
|
|
31
33
|
)
|
|
@@ -41,16 +43,25 @@ module RailsAiContext
|
|
|
41
43
|
|
|
42
44
|
# The filters this controller actually runs, read the way every other
|
|
43
45
|
# surface reads them, so a skipped one is never named here. The cut is
|
|
44
|
-
# stated rather than silent.
|
|
46
|
+
# stated rather than silent. A filter that runs on some actions only says
|
|
47
|
+
# which, so a route whose action skips it is not read as guarded by it.
|
|
45
48
|
def self.filter_hint(ctrl_class, ctx)
|
|
46
49
|
chain = RailsAiContext::ActionFilters.for_controller(ctx, ctrl_class, root: rails_app&.root&.to_s)
|
|
47
|
-
names = chain[:chain].map { |f| f[:name] }
|
|
50
|
+
names = chain[:chain].map { |f| "#{f[:name]}#{filter_scope(f)}" }
|
|
48
51
|
return nil if names.empty?
|
|
49
52
|
|
|
50
53
|
rest = names.size - HINTED_FILTERS
|
|
51
54
|
"#{names.first(HINTED_FILTERS).join(', ')}#{rest.positive? ? " (+#{rest} more)" : ''}"
|
|
52
55
|
end
|
|
53
56
|
|
|
57
|
+
def self.filter_scope(filter)
|
|
58
|
+
scope = +""
|
|
59
|
+
scope << " (only: #{Array(filter[:only]).join(', ')})" if Array(filter[:only]).any?
|
|
60
|
+
scope << " (except: #{Array(filter[:except]).join(', ')})" if Array(filter[:except]).any?
|
|
61
|
+
scope + RailsAiContext::Serializers::SectionFacts.skip_condition_tail(filter)
|
|
62
|
+
end
|
|
63
|
+
private_class_method :filter_scope
|
|
64
|
+
|
|
54
65
|
guide_row(
|
|
55
66
|
order: 8,
|
|
56
67
|
mcp: "rails_get_routes(controller:\"X\")",
|
|
@@ -110,16 +121,21 @@ module RailsAiContext
|
|
|
110
121
|
elsif group[:mount_computed] then " at a path the routes compute"
|
|
111
122
|
else " (not mounted by the app's routes)"
|
|
112
123
|
end
|
|
113
|
-
at += " (also #{
|
|
124
|
+
at += " (also #{also_mounted_at(group)})" if group[:also_mounted_at]
|
|
114
125
|
at
|
|
115
126
|
end
|
|
116
127
|
|
|
128
|
+
private_class_method def self.also_mounted_at(group)
|
|
129
|
+
group[:also_mounted_at].map { |path| "`#{path}`" }.join(", ")
|
|
130
|
+
end
|
|
131
|
+
|
|
117
132
|
# The routes a controller filter matches in the engines' tables, one
|
|
118
133
|
# group per controller labelled with its engine, listed like the app's.
|
|
119
134
|
private_class_method def self.engine_controller_groups(engine_routes, needles)
|
|
120
135
|
Array(engine_routes).each_with_object({}) do |group, found|
|
|
121
136
|
matches = Array(group[:routes]).select { |r| needles.any? { |n| path_segments_match?(r[:controller].to_s, n, tail: true) } }
|
|
122
137
|
where = group[:mount] || group[:mount_computed] ? "" : ", not mounted by the app's routes"
|
|
138
|
+
where += ", also mounted at #{also_mounted_at(group)}" if group[:also_mounted_at]
|
|
123
139
|
matches.group_by { |r| r[:controller] }.each do |ctrl, rows|
|
|
124
140
|
found["#{ctrl} (in #{group[:engine]}'s table#{where})"] = rows
|
|
125
141
|
end
|
|
@@ -143,8 +159,10 @@ module RailsAiContext
|
|
|
143
159
|
|
|
144
160
|
# Filter out internal Rails routes by default, remembering how many
|
|
145
161
|
# were dropped so headers can say the count is app-only, not the total.
|
|
162
|
+
# A controller filter names the routes it wants, so it searches the
|
|
163
|
+
# whole table: `controller:"active_storage/blobs"` is asked for by name.
|
|
146
164
|
excluded_framework_count = 0
|
|
147
|
-
if app_only
|
|
165
|
+
if app_only && controller.nil?
|
|
148
166
|
framework_ctrls = by_controller.select { |k, _| framework_controller?(k) }
|
|
149
167
|
excluded_framework_count = framework_ctrls.values.sum { |actions| RailsAiContext::RouteCoverage.dedupe_put_patch_routes(actions).size }
|
|
150
168
|
by_controller = by_controller.reject { |k, _| framework_controller?(k) }
|
|
@@ -164,9 +182,12 @@ module RailsAiContext
|
|
|
164
182
|
filtered = filtered.merge(engine_controller_groups(routes[:engine_routes], needles))
|
|
165
183
|
if filtered.empty?
|
|
166
184
|
# The whole controller list would bury the sentence saying the name matched none.
|
|
167
|
-
known = by_controller.keys.sort
|
|
168
|
-
|
|
169
|
-
|
|
185
|
+
known = by_controller.keys.reject { |k| framework_controller?(k) }.sort
|
|
186
|
+
# Booted, a dynamic route has no controller, so it cannot be the one asked for.
|
|
187
|
+
unexpanded = routes[:confidence] == RailsAiContext::Confidence::STATIC ? routes[:dynamic_routes].to_i : 0
|
|
188
|
+
unread = ". #{count_phrase(unexpanded, "dynamic construct")} not expanded, so a route for this controller may be missing from this table." if unexpanded.positive?
|
|
189
|
+
return empty_response("No routes for '#{echo_input(controller)}'. Controllers: " \
|
|
190
|
+
"#{known.first(20).join(', ')}#{" ... and #{known.size - 20} more" if known.size > 20}#{unread}")
|
|
170
191
|
end
|
|
171
192
|
by_controller = filtered
|
|
172
193
|
end
|