rails-ai-context 5.32.2 → 5.33.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 +1287 -0
- data/CONTEXT.md +26 -2
- data/README.md +13 -9
- data/Rakefile +6 -0
- data/app/controllers/rails_ai_context/mcp_controller.rb +114 -84
- data/docs/ARCHITECTURE.md +6 -4
- data/docs/CLI.md +79 -6
- data/docs/COMPATIBILITY.md +65 -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 +8 -7
- 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 +145 -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/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 +53 -20
- 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 +11 -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 +176 -26
- 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_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 +10 -3
- data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +56 -8
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +14 -2
- 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 +2 -1
- 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 +2 -2
- data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +46 -1
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
- data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +3 -1
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +14 -3
- 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.rb +2 -2
- data/lib/rails_ai_context/introspectors/model_introspector.rb +91 -5
- 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_introspector.rb +57 -4
- data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_scan.rb +13 -9
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +130 -11
- data/lib/rails_ai_context/introspectors/view_introspector.rb +3 -3
- 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 +185 -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 +55 -7
- 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 +254 -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 +86 -32
- 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 +17 -15
- data/lib/rails_ai_context/serializers/guide_examples.rb +141 -0
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +142 -29
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +36 -14
- data/lib/rails_ai_context/serializers/section_facts.rb +35 -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 +112 -43
- 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 +352 -41
- 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 +157 -40
- data/lib/rails_ai_context/tools/get_conventions.rb +26 -4
- 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 +9 -5
- data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
- data/lib/rails_ai_context/tools/get_job_pattern.rb +2 -1
- data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
- data/lib/rails_ai_context/tools/get_model_details.rb +16 -6
- data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
- data/lib/rails_ai_context/tools/get_routes.rb +19 -6
- data/lib/rails_ai_context/tools/get_schema.rb +51 -18
- 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 +46 -6
- data/lib/rails_ai_context/tools/performance_check.rb +4 -2
- 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 +204 -43
- 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 +3 -3
- data/lib/rails_ai_context/tools/security_scan.rb +141 -31
- data/lib/rails_ai_context/tools/session_context.rb +38 -4
- data/lib/rails_ai_context/tools/validate.rb +259 -40
- data/lib/rails_ai_context/tools/validate_semantics.rb +129 -55
- 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 +13 -1
|
@@ -56,18 +56,22 @@ module RailsAiContext
|
|
|
56
56
|
key = Payload.find_controller(ctx, controller) || controller
|
|
57
57
|
info = controllers[key]
|
|
58
58
|
unless info
|
|
59
|
+
return text_response(application_controller_answer(ctx, controllers)) if key.casecmp?("ApplicationController")
|
|
60
|
+
|
|
59
61
|
recovery = "Call rails_get_controllers(detail:\"summary\") to see all controllers"
|
|
60
62
|
return not_found_response("Controller", controller, app_controller_names,
|
|
61
63
|
recovery_tool: data[:unread_engine] ? "#{unread_engine_line(data)} #{recovery}" : recovery)
|
|
62
64
|
end
|
|
63
65
|
return text_response("Error inspecting #{key}: #{info[:error]}") if info[:error]
|
|
64
66
|
|
|
67
|
+
chosen = resolution_note(controller, key)
|
|
65
68
|
# Specific action - return source code
|
|
66
69
|
if action
|
|
67
|
-
|
|
70
|
+
answer = format_action_source(key, info, action)
|
|
71
|
+
return chosen ? text_response("#{chosen}\n\n#{answer.content.first[:text]}") : answer
|
|
68
72
|
end
|
|
69
73
|
|
|
70
|
-
return text_response(format_controller(key, info, ctx))
|
|
74
|
+
return text_response([ chosen, format_controller(key, info, ctx) ].compact.join("\n\n"))
|
|
71
75
|
end
|
|
72
76
|
|
|
73
77
|
app_controllers = Payload.app_controllers(ctx)
|
|
@@ -86,7 +90,7 @@ module RailsAiContext
|
|
|
86
90
|
# Listing mode
|
|
87
91
|
case detail
|
|
88
92
|
when "summary"
|
|
89
|
-
lines = listing_header(page, data)
|
|
93
|
+
lines = listing_header(page, data, action)
|
|
90
94
|
paginated_names.each do |name|
|
|
91
95
|
info = app_controllers[name]
|
|
92
96
|
action_count = info[:actions]&.size || 0
|
|
@@ -97,7 +101,7 @@ module RailsAiContext
|
|
|
97
101
|
text_response(lines.join("\n"))
|
|
98
102
|
|
|
99
103
|
when "standard"
|
|
100
|
-
lines = listing_header(page, data)
|
|
104
|
+
lines = listing_header(page, data, action)
|
|
101
105
|
paginated_names.each do |name|
|
|
102
106
|
info = app_controllers[name]
|
|
103
107
|
lines << "- **#{name}** - #{Serializers::SectionFacts.actions_phrase(info)}"
|
|
@@ -106,7 +110,7 @@ module RailsAiContext
|
|
|
106
110
|
text_response(lines.join("\n"))
|
|
107
111
|
|
|
108
112
|
when "full"
|
|
109
|
-
lines = listing_header(page, data)
|
|
113
|
+
lines = listing_header(page, data, action)
|
|
110
114
|
|
|
111
115
|
# Group sibling controllers that share the same parent and identical structure
|
|
112
116
|
paginated_ctrl = app_controllers.select { |k, _| paginated_names.include?(k) }
|
|
@@ -177,8 +181,70 @@ module RailsAiContext
|
|
|
177
181
|
)
|
|
178
182
|
end
|
|
179
183
|
|
|
180
|
-
private_class_method def self.listing_header(page, data)
|
|
181
|
-
[ "# Controllers (#{page[:total]})", ""
|
|
184
|
+
private_class_method def self.listing_header(page, data, action = nil)
|
|
185
|
+
lines = [ "# Controllers (#{page[:total]})", "" ]
|
|
186
|
+
# An action is one controller's, and the listing used to come back as
|
|
187
|
+
# though none had been asked for.
|
|
188
|
+
lines.push("_`action:\"#{action.to_s.truncate(60)}\"` names an action of one controller, so it needs `controller:` too; this is the listing._", "") if action
|
|
189
|
+
lines.push(unread_engine_line(data), "") if data[:unread_engine]
|
|
190
|
+
lines
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The formats the controller answers: its respond_to blocks, and a
|
|
194
|
+
# template of another format beside its views, which Rails renders for
|
|
195
|
+
# that format with no respond_to at all: bazaar's products/index.json.jbuilder
|
|
196
|
+
# answers products.json, yet the line read "html, turbo_stream". A
|
|
197
|
+
# plain html-only controller gets no line.
|
|
198
|
+
private_class_method def self.formats_phrase(info, ctx, name)
|
|
199
|
+
declared = Array(info[:respond_to_formats]).map(&:to_s)
|
|
200
|
+
route_key = Payload.controller_route_key(ctx, name)
|
|
201
|
+
templates = RailsAiContext::PathResolver.view_dirs(rails_app.root.to_s).flat_map do |dir|
|
|
202
|
+
Dir.glob(File.join(dir, route_key, "*")).filter_map do |path|
|
|
203
|
+
base = File.basename(path)
|
|
204
|
+
parts = base.split(".")
|
|
205
|
+
next if base.start_with?("_") || parts.size < 3 || !File.file?(path)
|
|
206
|
+
|
|
207
|
+
[ parts[-2].sub(/\+.*\z/, ""), base ]
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
extra = templates.reject { |format, _| declared.include?(format) || format == "html" }
|
|
211
|
+
.group_by(&:first).map { |format, found| "#{format} (#{found.map(&:last).sort.join(', ')})" }
|
|
212
|
+
shown = declared + extra
|
|
213
|
+
shown.any? ? shown.join(", ") : nil
|
|
214
|
+
rescue => e
|
|
215
|
+
RailsAiContext.debug_fail(e, nil, label: "formats_phrase")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# A bare name that matched a namespaced controller: `UsersController`
|
|
219
|
+
# answered for Admin::UsersController without a word that it had.
|
|
220
|
+
private_class_method def self.resolution_note(asked, key)
|
|
221
|
+
return nil unless key.include?("::") && !asked.to_s.include?("::") && !asked.to_s.include?("/")
|
|
222
|
+
|
|
223
|
+
"_There is no top-level `#{asked.to_s.truncate(60)}`; this is `#{key}`, the controller that name matches._"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# ApplicationController is every controller's base and no listed
|
|
227
|
+
# controller of its own, so asking for it answered "not found" though
|
|
228
|
+
# the app has one. What it declares is read off a controller that
|
|
229
|
+
# inherits it.
|
|
230
|
+
private_class_method def self.application_controller_answer(ctx, controllers)
|
|
231
|
+
root = rails_app.root.to_s
|
|
232
|
+
child = controllers.keys.sort_by { |name| [ name.count(":"), name ] }
|
|
233
|
+
.find { |name| controllers[name].is_a?(Hash) && controllers[name][:parent_class].to_s.delete_prefix("::") == "ApplicationController" }
|
|
234
|
+
lines = [ "# ApplicationController", "" ]
|
|
235
|
+
unless child
|
|
236
|
+
lines << "The base the app's controllers inherit from. No controller read here inherits it directly, so what it declares is not read."
|
|
237
|
+
return lines.join("\n")
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
lines << "The base the app's controllers inherit from, not listed as a controller of its own. What it declares reaches every controller that inherits it; as #{child} reads it:"
|
|
241
|
+
filters = RailsAiContext::ActionFilters.for_controller(ctx, child, root: root)[:inherited].select { |f| f[:from].to_s == "ApplicationController" }
|
|
242
|
+
lines.push("", "## Filters", *filters.map { |f| filter_line(f) }) if filters.any?
|
|
243
|
+
settings = RailsAiContext::Introspectors::ControllerSettings.resolve(ctx, child, root: root)[:settings].select { |s| s[:from].to_s == "ApplicationController" }
|
|
244
|
+
lines.push("", "## Settings", *settings.map { |s| "- `#{s[:text]}`#{" _(through #{s[:via]})_" if s[:via]}" }) if settings.any?
|
|
245
|
+
lines.push("", "_It declares no filter or setting the controllers below it inherit._") if filters.empty? && settings.empty?
|
|
246
|
+
lines.push("", "_Next: `rails_get_controllers(controller:\"#{child}\")` for a controller that inherits it_")
|
|
247
|
+
lines.join("\n")
|
|
182
248
|
end
|
|
183
249
|
|
|
184
250
|
private_class_method def self.unread_engine_line(data)
|
|
@@ -225,11 +291,21 @@ module RailsAiContext
|
|
|
225
291
|
actions = info[:actions] || []
|
|
226
292
|
# Case-insensitive action lookup for consistency with other tools
|
|
227
293
|
action_name = actions.find { |a| a.to_s.downcase == action_name.to_s.downcase }&.to_s || action_name.to_s
|
|
294
|
+
carried = RailsAiContext::Payload.controller_file(cached_context, controller_name)
|
|
295
|
+
# Rails counts a public method a concern or a parent controller gives
|
|
296
|
+
# among this controller's actions, though the listing takes one only
|
|
297
|
+
# where a route names it.
|
|
298
|
+
unlisted = nil
|
|
228
299
|
unless actions.map(&:to_s).include?(action_name)
|
|
229
|
-
|
|
300
|
+
unlisted = RailsAiContext::Introspectors::ActionSource.unlisted_action(rails_app.root, controller_name, action_name,
|
|
301
|
+
file: carried, controllers: RailsAiContext::Payload.controllers(cached_context))
|
|
302
|
+
unless unlisted
|
|
303
|
+
return empty_response("Action '#{echo_input(action_name)}' not found in #{controller_name}. Available: #{actions.join(', ')}")
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
action_name = unlisted[:name]
|
|
230
307
|
end
|
|
231
308
|
|
|
232
|
-
carried = RailsAiContext::Payload.controller_file(cached_context, controller_name)
|
|
233
309
|
source_path = carried ? rails_app.root.join(carried) : nil
|
|
234
310
|
source = source_path && safe_read(source_path.to_s)
|
|
235
311
|
|
|
@@ -237,11 +313,19 @@ module RailsAiContext
|
|
|
237
313
|
root: rails_app.root.to_s)
|
|
238
314
|
|
|
239
315
|
# Extract source code with line numbers
|
|
240
|
-
source_with_lines = source && extract_method_with_lines(source_path, action_name, source: source,
|
|
241
|
-
owner: controller_name)
|
|
316
|
+
source_with_lines = unlisted || (source && extract_method_with_lines(source_path, action_name, source: source,
|
|
317
|
+
owner: controller_name))
|
|
318
|
+
# An action from a concern or a parent controller has no def in this file.
|
|
319
|
+
source_with_lines ||= RailsAiContext::Introspectors::ActionSource.find(rails_app.root, controller_name,
|
|
320
|
+
action_name, file: carried, controllers: RailsAiContext::Payload.controllers(cached_context))
|
|
321
|
+
elsewhere = source_with_lines&.dig(:file)
|
|
322
|
+
elsewhere = nil if elsewhere == carried
|
|
242
323
|
|
|
243
324
|
lines = [ "# #{controller_name}##{action_name}", "" ]
|
|
244
325
|
lines << "**File:** `#{carried}`" if carried
|
|
326
|
+
parent = source_with_lines&.dig(:owner)
|
|
327
|
+
lines << "**Inherited:** from #{parent}, defined in `#{source_with_lines[:file]}`." if parent && parent != controller_name
|
|
328
|
+
lines << "**Not listed:** #{RailsAiContext::Introspectors::ActionSource::UNLISTED_NOTE}" if unlisted
|
|
245
329
|
|
|
246
330
|
if applicable.values.any?(&:any?)
|
|
247
331
|
lines << "" << "## Applicable Filters"
|
|
@@ -250,8 +334,9 @@ module RailsAiContext
|
|
|
250
334
|
end
|
|
251
335
|
|
|
252
336
|
if source_with_lines
|
|
253
|
-
|
|
254
|
-
lines << "
|
|
337
|
+
span = "lines #{source_with_lines[:start_line]}-#{source_with_lines[:end_line]}"
|
|
338
|
+
lines << "" << "## Source (#{elsewhere ? "`#{elsewhere}`, #{span}" : span})"
|
|
339
|
+
lines << "```ruby" << redacted(source_with_lines[:code], elsewhere || carried) << "```"
|
|
255
340
|
|
|
256
341
|
ivars = RailsAiContext::Introspectors::ActionResolver.assigned_ivars(source_with_lines[:code])
|
|
257
342
|
lines << "" << "## Instance Variables" << ivars.map { |v| "- `@#{v}`" }.join("\n") if ivars.any?
|
|
@@ -263,7 +348,7 @@ module RailsAiContext
|
|
|
263
348
|
lines << "" << "## Private Methods Called"
|
|
264
349
|
called_methods.each do |pm|
|
|
265
350
|
lines << "### #{pm[:name]} (lines #{pm[:start_line]}-#{pm[:end_line]})"
|
|
266
|
-
lines << "```ruby" << pm[:code] << "```"
|
|
351
|
+
lines << "```ruby" << redacted(pm[:code], carried) << "```"
|
|
267
352
|
end
|
|
268
353
|
end
|
|
269
354
|
|
|
@@ -271,15 +356,15 @@ module RailsAiContext
|
|
|
271
356
|
render_map = extract_render_map(source_with_lines[:code])
|
|
272
357
|
if render_map[:redirects].any? || render_map[:renders].any?
|
|
273
358
|
lines << "" << "## Render Map"
|
|
274
|
-
render_map[:redirects].each { |r| lines << "- **Redirect:** #{r}" }
|
|
275
|
-
render_map[:renders].each { |r| lines << "- **Render:** #{r}" }
|
|
359
|
+
render_map[:redirects].each { |r| lines << "- **Redirect:** #{redacted(r, carried)}" }
|
|
360
|
+
render_map[:renders].each { |r| lines << "- **Render:** #{redacted(r, carried)}" }
|
|
276
361
|
end
|
|
277
362
|
if render_map[:side_effects].any?
|
|
278
363
|
lines << "" << "## Side Effects"
|
|
279
364
|
render_map[:side_effects].each { |s| lines << "- #{s}" }
|
|
280
365
|
end
|
|
281
366
|
else
|
|
282
|
-
lines << "" << "_Could not extract source code. File: #{
|
|
367
|
+
lines << "" << "_Could not extract source code. File: #{carried || "not recorded for #{controller_name}"}_"
|
|
283
368
|
end
|
|
284
369
|
|
|
285
370
|
# One action's answer used to carry every strong-params method in the
|
|
@@ -306,11 +391,11 @@ module RailsAiContext
|
|
|
306
391
|
sp[:hashes]&.each { |h| lines << "- hash: `#{h}: {}`" }
|
|
307
392
|
end
|
|
308
393
|
body = extract_method_with_lines(source_path, sp[:name], source: source, owner: controller_name)
|
|
309
|
-
lines << "```ruby" << body[:code] << "```" if body
|
|
394
|
+
lines << "```ruby" << redacted(body[:code], carried) << "```" if body
|
|
310
395
|
else
|
|
311
396
|
body = extract_method_with_lines(source_path, sp, source: source, owner: controller_name)
|
|
312
397
|
if body
|
|
313
|
-
lines << "```ruby" << body[:code] << "```"
|
|
398
|
+
lines << "```ruby" << redacted(body[:code], carried) << "```"
|
|
314
399
|
else
|
|
315
400
|
lines << "- `#{sp}`"
|
|
316
401
|
end
|
|
@@ -394,32 +479,13 @@ module RailsAiContext
|
|
|
394
479
|
|
|
395
480
|
# Extract render map from action source: redirects, renders, and side effects
|
|
396
481
|
private_class_method def self.extract_render_map(code)
|
|
397
|
-
redirects =
|
|
398
|
-
renders = []
|
|
482
|
+
redirects, renders = render_and_redirect_calls(code)
|
|
399
483
|
side_effects = []
|
|
400
484
|
enqueues = Introspectors::SourceCalls.enqueue_calls(code, enqueue_helpers).group_by { |hit| hit[:line] }
|
|
401
485
|
|
|
402
486
|
code.each_line.with_index(1) do |line, number|
|
|
403
487
|
stripped = line.strip
|
|
404
488
|
|
|
405
|
-
# Detect redirect_to calls
|
|
406
|
-
if (m = stripped.match(/redirect_to\s+(.+)/))
|
|
407
|
-
target = m[1].sub(/\s*,\s*(status|notice|alert|flash):.*/, "")
|
|
408
|
-
desc = "redirect_to #{target.strip}"
|
|
409
|
-
flash_parts = []
|
|
410
|
-
flash_parts << "notice: #{Regexp.last_match(1)}" if stripped.match(/notice:\s*("[^"]*"|'[^']*'|[^,)]+)/)
|
|
411
|
-
flash_parts << "alert: #{Regexp.last_match(1)}" if stripped.match(/alert:\s*("[^"]*"|'[^']*'|[^,)]+)/)
|
|
412
|
-
desc += " (#{flash_parts.join(', ')})" if flash_parts.any?
|
|
413
|
-
redirects << desc
|
|
414
|
-
end
|
|
415
|
-
|
|
416
|
-
# Detect render calls
|
|
417
|
-
if (m = stripped.match(/render\s+(.+)/))
|
|
418
|
-
render_args = m[1]
|
|
419
|
-
desc = "render #{render_args.sub(/\s*\}?\s*$/, "").strip}"
|
|
420
|
-
renders << desc
|
|
421
|
-
end
|
|
422
|
-
|
|
423
489
|
# Detect side effects
|
|
424
490
|
if stripped.match?(/\.save[!]?(\s|\(|$)/)
|
|
425
491
|
obj = stripped.match(/(\S+)\.save/)&.send(:[], 1) || "object"
|
|
@@ -447,6 +513,56 @@ module RailsAiContext
|
|
|
447
513
|
RailsAiContext.debug_fail(e, { redirects: [], renders: [], side_effects: [] }, label: "extract_render_map")
|
|
448
514
|
end
|
|
449
515
|
|
|
516
|
+
# Each render and redirect_to call as the source writes its arguments.
|
|
517
|
+
# Lines were matched one at a time and their ends trimmed, which cut the
|
|
518
|
+
# brace closing a block in `render json: accounts.map { ... }` and the
|
|
519
|
+
# parenthesis closing `t(".created")` in a redirect's notice.
|
|
520
|
+
private_class_method def self.render_and_redirect_calls(code)
|
|
521
|
+
redirects = []
|
|
522
|
+
renders = []
|
|
523
|
+
tree = RailsAiContext::AstCache.parse_string(code).value
|
|
524
|
+
action = tree.statements.body.first.then { |node| node.name if node.is_a?(Prism::DefNode) }
|
|
525
|
+
visit = lambda do |node|
|
|
526
|
+
if node.is_a?(Prism::CallNode) && node.receiver.nil? && node.arguments
|
|
527
|
+
case node.name
|
|
528
|
+
when :redirect_to then redirects << redirect_text(node.arguments.arguments)
|
|
529
|
+
when :render then renders << "render #{node.arguments.slice.squish}"
|
|
530
|
+
end
|
|
531
|
+
elsif action && implicit_format_render?(node)
|
|
532
|
+
# `format.turbo_stream` with no block renders the action's own
|
|
533
|
+
# template for that format, a render no `render` call names.
|
|
534
|
+
renders << "render :#{action}, formats: :#{node.name} (implicit, from `format.#{node.name}`)"
|
|
535
|
+
end
|
|
536
|
+
node.compact_child_nodes.each(&visit)
|
|
537
|
+
end
|
|
538
|
+
visit.call(tree)
|
|
539
|
+
[ redirects, renders ]
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
RESPONSE_FORMATS = %i[html json xml js turbo_stream csv text].freeze
|
|
543
|
+
|
|
544
|
+
private_class_method def self.implicit_format_render?(node)
|
|
545
|
+
node.is_a?(Prism::CallNode) && node.receiver.is_a?(Prism::LocalVariableReadNode) &&
|
|
546
|
+
RESPONSE_FORMATS.include?(node.name) && node.arguments.nil? && node.block.nil?
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# `redirect_to target (notice: ..., alert: ...)`: the flash it sets, and
|
|
550
|
+
# none of the status or other options beside it.
|
|
551
|
+
private_class_method def self.redirect_text(arguments)
|
|
552
|
+
options, target = arguments.partition { |arg| arg.is_a?(Prism::KeywordHashNode) }
|
|
553
|
+
flash = options.flat_map(&:elements).filter_map do |pair|
|
|
554
|
+
key = pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode) ? pair.key.unescaped : nil
|
|
555
|
+
"#{key}: #{pair.value.slice.squish}" if %w[notice alert].include?(key)
|
|
556
|
+
end
|
|
557
|
+
"redirect_to #{target.map { |arg| arg.slice.squish }.join(", ")}#{" (#{flash.join(', ')})" if flash.any?}"
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# Source as it is shown: every secret in it filtered, as search_code and
|
|
561
|
+
# get_edit_context show the same lines.
|
|
562
|
+
private_class_method def self.redacted(code, path)
|
|
563
|
+
RailsAiContext::Redaction.redact_source(code, path: path)
|
|
564
|
+
end
|
|
565
|
+
|
|
450
566
|
private_class_method def self.extract_method_with_lines(file_path, method_name, source: nil, owner: nil)
|
|
451
567
|
unless source
|
|
452
568
|
return nil unless file_path && File.exist?(file_path)
|
|
@@ -463,7 +579,8 @@ module RailsAiContext
|
|
|
463
579
|
lines = [ "# #{name}", "" ]
|
|
464
580
|
lines << "**Parent:** `#{resolved_parent(name, info, ctx)}`" if info[:parent_class]
|
|
465
581
|
lines << "**API controller:** yes" if info[:api_controller]
|
|
466
|
-
|
|
582
|
+
formats = formats_phrase(info, ctx, name)
|
|
583
|
+
lines << "**Formats:** #{formats}" if formats
|
|
467
584
|
declared = RailsAiContext::Introspectors::ControllerSettings.resolve(ctx, name, root: rails_app.root.to_s)
|
|
468
585
|
lines << "**Layout:** #{RailsAiContext::Introspectors::ControllerSettings.layout_phrase(declared[:layout])}" if declared[:layout]
|
|
469
586
|
|
|
@@ -192,6 +192,15 @@ module RailsAiContext
|
|
|
192
192
|
stack
|
|
193
193
|
end
|
|
194
194
|
|
|
195
|
+
# How a create action checks permission: the app's own `can_x?`,
|
|
196
|
+
# CanCanCan's `authorize!`, or Pundit's `authorize record`.
|
|
197
|
+
private_class_method def self.permission_style(block)
|
|
198
|
+
if block.match?(/\bcan_\w+\??/) then :can_method
|
|
199
|
+
elsif block.match?(/\bauthorize!/) then :cancan
|
|
200
|
+
elsif block.match?(/\bauthorize\b/) then :pundit
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
195
204
|
# Scan controllers for app-specific authorization, flash, and error-handling patterns
|
|
196
205
|
private_class_method def self.detect_app_patterns
|
|
197
206
|
controllers_dir = rails_app.root.join("app", "controllers").to_s
|
|
@@ -210,10 +219,14 @@ module RailsAiContext
|
|
|
210
219
|
html_response_count = 0
|
|
211
220
|
show_only_controllers = []
|
|
212
221
|
has_services = Dir.exist?(rails_app.root.join("app", "services"))
|
|
222
|
+
permission_styles = []
|
|
223
|
+
real_controllers_dir = File.realpath(controllers_dir).to_s
|
|
213
224
|
|
|
214
225
|
safe_glob(controllers_dir, "**/*.rb", real_root).each do |path|
|
|
215
226
|
content = RailsAiContext::SafeFile.read(path) or next
|
|
216
|
-
|
|
227
|
+
# The path under app/controllers, so Api::V1::ProductsController is
|
|
228
|
+
# not a second ProductsController.
|
|
229
|
+
controller_name = path.delete_prefix("#{real_controllers_dir}/").delete_suffix(".rb")
|
|
217
230
|
|
|
218
231
|
auth_checks.concat(content.scan(/\b(can_\w+\??)/).flatten)
|
|
219
232
|
auth_denials.concat(content.scan(/redirect_to\s+.+?,\s*alert:\s*"([^"]*)"/).flatten
|
|
@@ -235,7 +248,10 @@ module RailsAiContext
|
|
|
235
248
|
create_block = content[/def\s+create#{RailsAiContext::MethodName.definition_end("create")}.*?(?=\n\s{2}def\s|\n\s{2}private|\z)/m]
|
|
236
249
|
if create_block
|
|
237
250
|
flow_parts = []
|
|
238
|
-
|
|
251
|
+
if (style = permission_style(create_block))
|
|
252
|
+
flow_parts << "permission check"
|
|
253
|
+
permission_styles << style
|
|
254
|
+
end
|
|
239
255
|
flow_parts << "build" if create_block.match?(/\.new\(|\.build\(|\.create\(/)
|
|
240
256
|
# `create!` raises instead of returning false - it is not the
|
|
241
257
|
# save-then-branch flow this skeleton describes.
|
|
@@ -305,9 +321,13 @@ module RailsAiContext
|
|
|
305
321
|
# whichever the app's own create actions use most.
|
|
306
322
|
failure_status = create_error_statuses.tally.max_by { |_, count| count }&.first || "unprocessable_entity"
|
|
307
323
|
|
|
324
|
+
# The check the app's own create actions make: a Pundit app
|
|
325
|
+
# authorizes the record, where the skeleton used to call a
|
|
326
|
+
# `can_[permission]?` method no Pundit app defines.
|
|
327
|
+
style = permission_styles.tally.max_by { |_, count| count }&.first
|
|
308
328
|
sections << "```ruby"
|
|
309
329
|
sections << "def create"
|
|
310
|
-
if has_permission_check
|
|
330
|
+
if has_permission_check && style == :can_method
|
|
311
331
|
sections << " unless current_user.can_[permission]?"
|
|
312
332
|
sections << ' redirect_to [path], alert: "[limit message]"'
|
|
313
333
|
sections << " return"
|
|
@@ -316,6 +336,8 @@ module RailsAiContext
|
|
|
316
336
|
sections << " @record = current_user.[association].build([params_method])"
|
|
317
337
|
else
|
|
318
338
|
sections << " @record = [Model].new([params_method])"
|
|
339
|
+
sections << " authorize @record" if has_permission_check && style == :pundit
|
|
340
|
+
sections << " authorize! :create, @record" if has_permission_check && style == :cancan
|
|
319
341
|
end
|
|
320
342
|
if has_save
|
|
321
343
|
sections << ""
|
|
@@ -340,7 +362,7 @@ module RailsAiContext
|
|
|
340
362
|
sections << "end"
|
|
341
363
|
sections << "```"
|
|
342
364
|
sections << ""
|
|
343
|
-
sections << "Detected in: #{create_flows.map { |f| f.split(':').first }.join(', ')}"
|
|
365
|
+
sections << "Detected in: #{create_flows.map { |f| f.split(': ').first }.join(', ')}"
|
|
344
366
|
end
|
|
345
367
|
|
|
346
368
|
if show_only_controllers.any?
|
|
@@ -47,33 +47,46 @@ module RailsAiContext
|
|
|
47
47
|
|
|
48
48
|
located = RailsAiContext::SafePath.locate(file, under: rails_app.root.to_s)
|
|
49
49
|
case located.refusal
|
|
50
|
-
when :traversal, :outside then return error_response("Path not allowed: #{file}")
|
|
51
|
-
when :sensitive then return error_response("
|
|
50
|
+
when :traversal, :outside then return error_response("Path not allowed: #{echo_input(file)}")
|
|
51
|
+
when :sensitive then return error_response("Path not allowed: #{echo_input(file)} (sensitive file)")
|
|
52
52
|
when :too_large then return text_response("File too large: #{file}")
|
|
53
53
|
when :missing
|
|
54
|
+
root = rails_app.root.to_s
|
|
54
55
|
basename = File.basename(file)
|
|
55
|
-
|
|
56
|
+
asked = File.expand_path(file, root)
|
|
57
|
+
# Files of that name the tool would read, never the path asked for:
|
|
58
|
+
# a link that resolves nowhere is not a file to retry.
|
|
59
|
+
candidates = RailsAiContext::FileWalk.each_file(File.join(root, "app"), root: root)
|
|
60
|
+
.select { |path| File.basename(path) == basename && path != asked }.sort.first(5)
|
|
56
61
|
hint = if candidates.any?
|
|
57
|
-
suggestions = candidates.map { |c| c.
|
|
62
|
+
suggestions = candidates.map { |c| c.delete_prefix("#{root}/") }
|
|
58
63
|
" Did you mean: #{suggestions.join(', ')}? Use the full path relative to Rails root."
|
|
59
64
|
else
|
|
60
|
-
|
|
65
|
+
# An example built from the name the caller gave was that same
|
|
66
|
+
# path back for `app/models/nope.rb`, read as a file to retry.
|
|
67
|
+
" No file named #{echo_input(basename)} is under app/."
|
|
61
68
|
end
|
|
62
|
-
return empty_response("File not found: #{file}.#{hint}")
|
|
69
|
+
return empty_response("File not found: #{echo_input(file)}.#{hint}")
|
|
63
70
|
end
|
|
64
71
|
real = located.realpath
|
|
65
72
|
|
|
66
73
|
source_lines = (RailsAiContext::SafeFile.read(real) || "").lines
|
|
67
74
|
context_lines = [ context_lines.to_i, 0 ].max
|
|
68
75
|
|
|
76
|
+
# Source leaving the app passes through Redaction, the whole file at once, so a window
|
|
77
|
+
# opening inside a PEM key still knows it is inside one. `near` is looked for in the lines
|
|
78
|
+
# as the caller sees them: found in the file as written, a guess at a filtered value's
|
|
79
|
+
# start brought back a window where a wrong guess brought "not found".
|
|
80
|
+
redacted = RailsAiContext::Redaction.redact_source_lines(source_lines.map(&:rstrip), path: file)
|
|
81
|
+
|
|
69
82
|
# Find all matching lines
|
|
70
83
|
matches = []
|
|
71
|
-
|
|
84
|
+
redacted.each_with_index do |line, idx|
|
|
72
85
|
matches << idx if line.include?(near)
|
|
73
86
|
end
|
|
74
87
|
|
|
75
88
|
if matches.empty?
|
|
76
|
-
return empty_response("'#{near}' not found in #{file} (#{count_phrase(source_lines.size, "line")}).\n\nAvailable methods:\n#{extract_methods(source_lines)}")
|
|
89
|
+
return empty_response("'#{echo_input(near)}' not found in #{file} (#{count_phrase(source_lines.size, "line")}).\n\nAvailable methods:\n#{extract_methods(source_lines)}")
|
|
77
90
|
end
|
|
78
91
|
|
|
79
92
|
# Build context window around first match
|
|
@@ -94,9 +107,6 @@ module RailsAiContext
|
|
|
94
107
|
end_idx = [ end_idx, method_end ].max
|
|
95
108
|
end
|
|
96
109
|
|
|
97
|
-
# Source leaving the app passes through Redaction, the whole file at once, so a window
|
|
98
|
-
# opening inside a PEM key still knows it is inside one.
|
|
99
|
-
redacted = RailsAiContext::Redaction.redact_source_lines(source_lines.map(&:rstrip), path: file)
|
|
100
110
|
context_code = redacted[start_idx..end_idx].map.with_index do |line, i|
|
|
101
111
|
"#{(start_idx + i + 1).to_s.rjust(4)} #{line}"
|
|
102
112
|
end.join("\n")
|
|
@@ -109,9 +119,12 @@ module RailsAiContext
|
|
|
109
119
|
else ""
|
|
110
120
|
end
|
|
111
121
|
|
|
112
|
-
# Detect enclosing class and method for context
|
|
113
|
-
|
|
114
|
-
|
|
122
|
+
# Detect enclosing class and method for context, named from the redacted lines: an endless
|
|
123
|
+
# method's line is its whole body (`def headers = { "Authorization" => "Bearer ..." }`), and
|
|
124
|
+
# the label keeps its name alone.
|
|
125
|
+
class_idx = match_idx.downto(0).find { |i| source_lines[i].match?(/\A\s*(class|module)\s/) }
|
|
126
|
+
class_name = class_idx && redacted[class_idx].strip.sub(/\s*<.*/, "")
|
|
127
|
+
method_name = method_start ? redacted[method_start].strip.sub(/\s*\(.*/, "").sub(/\s+=\s.*/, "").sub(/\Adef\s+/, "def ") : nil
|
|
115
128
|
context_label = [ class_name, method_name ].compact.join(" > ")
|
|
116
129
|
|
|
117
130
|
output = [ "# #{file} (lines #{start_idx + 1}-#{end_idx + 1} of #{source_lines.size})", "" ]
|
|
@@ -98,9 +98,10 @@ module RailsAiContext
|
|
|
98
98
|
|
|
99
99
|
# Named in the answer, because a name missing from it is otherwise
|
|
100
100
|
# indistinguishable from a name the app does not read.
|
|
101
|
-
SCAN_NOTE = "_Scanned `app`, `config` and `lib` for `.rb`, `.rake`, `.erb` and config `.yml`, plus `config.ru`, `db/seeds` and the Ruby scripts in `bin
|
|
102
|
-
"
|
|
103
|
-
"
|
|
101
|
+
SCAN_NOTE = "_Scanned `app`, `config` and `lib` for `.rb`, `.rake`, `.erb` and config `.yml`, plus `config.ru`, `db/seeds` and the Ruby scripts in `bin`; " \
|
|
102
|
+
"a YAML comment line is skipped, since what its ERB tag writes is thrown away. " \
|
|
103
|
+
"Config YAML on `sensitive_patterns` (config/database.yml, config/cable.yml, config/storage.yml and the rest) is read for the ENV names in its ERB tags only, " \
|
|
104
|
+
"so a default there is listed as not read; credentials, keys and the rest are never read._"
|
|
104
105
|
|
|
105
106
|
private_class_method def self.format_standard(env_vars, env_example, deploy_and_settings, external_services, credentials_keys, encrypted_columns)
|
|
106
107
|
lines = [ "# Environment Configuration", "" ]
|
|
@@ -127,6 +128,9 @@ module RailsAiContext
|
|
|
127
128
|
" (#{DEFAULTS_DIFFER}; `detail:\"full\"` names each)"
|
|
128
129
|
elsif defaults.size == 1 && defaults.first.is_a?(String)
|
|
129
130
|
" (default: `#{defaults.first}`)"
|
|
131
|
+
elsif sites.any? && sites.all? { |v| v[:default_unread] }
|
|
132
|
+
# Read as no default at all, which for a fetch means a KeyError.
|
|
133
|
+
" (has a default, not read: #{sites.map { |v| "`#{v[:file]}`" }.uniq.join(', ')} is on `sensitive_patterns`)"
|
|
130
134
|
else
|
|
131
135
|
""
|
|
132
136
|
end
|
|
@@ -207,10 +211,13 @@ module RailsAiContext
|
|
|
207
211
|
if env_vars.any?
|
|
208
212
|
lines << "## Environment Variables by Category"
|
|
209
213
|
|
|
210
|
-
# Build a map: var_name -> { details from all files }
|
|
214
|
+
# Build a map: var_name -> { details from all files }. The scan
|
|
215
|
+
# keys each file by its realpath, which under a symlinked root (a
|
|
216
|
+
# macOS /tmp, /var) starts other than the root as the app names it.
|
|
217
|
+
real_root = File.realpath(root)
|
|
211
218
|
var_details = {}
|
|
212
219
|
env_vars.sort_by { |file, _| file }.each do |file, vars|
|
|
213
|
-
relative = file.
|
|
220
|
+
relative = file.delete_prefix("#{real_root}/")
|
|
214
221
|
vars.each do |v|
|
|
215
222
|
var_details[v[:name]] ||= { files: [], defaults: [] }
|
|
216
223
|
var_details[v[:name]][:files] << { file: relative, line: v[:line], default: v[:default],
|
|
@@ -238,8 +245,8 @@ module RailsAiContext
|
|
|
238
245
|
# most needs, since it raises KeyError when the variable is unset.
|
|
239
246
|
file_locations = v[:files].map { |f|
|
|
240
247
|
at = f[:line] ? "#{f[:file]}:#{f[:line]}" : f[:file]
|
|
241
|
-
next at unless disagree?(v[:files])
|
|
242
248
|
next "#{at} default not read" if f[:default_unread]
|
|
249
|
+
next at unless disagree?(v[:files])
|
|
243
250
|
|
|
244
251
|
case f[:default]
|
|
245
252
|
when String then "#{at} default: `#{f[:default]}`"
|
|
@@ -783,10 +790,12 @@ module RailsAiContext
|
|
|
783
790
|
groups.sort_by { |k, _| CATEGORY_ORDER.index(k) || 99 }
|
|
784
791
|
end
|
|
785
792
|
|
|
786
|
-
# Every site each variable is read at.
|
|
793
|
+
# Every site each variable is read at, with the file it is in.
|
|
787
794
|
private_class_method def self.sites_by_name(env_vars)
|
|
788
|
-
|
|
789
|
-
|
|
795
|
+
# The scan keys each file by its realpath.
|
|
796
|
+
root = "#{File.realpath(rails_app.root.to_s)}/"
|
|
797
|
+
env_vars.each_with_object(Hash.new { |h, k| h[k] = [] }) do |(path, vars), found|
|
|
798
|
+
vars.each { |v| found[v[:name]] << v.merge(file: path.to_s.delete_prefix(root)) }
|
|
790
799
|
end
|
|
791
800
|
end
|
|
792
801
|
|
|
@@ -329,7 +329,9 @@ module RailsAiContext
|
|
|
329
329
|
end
|
|
330
330
|
|
|
331
331
|
if turbo
|
|
332
|
-
|
|
332
|
+
# The broadcast_*_to calls as well as the model macros: bazaar's
|
|
333
|
+
# three explicit broadcasts went uncounted beside its one macro.
|
|
334
|
+
broadcasts = Array(turbo[:model_broadcasts]).size + Array(turbo[:explicit_broadcasts]).size
|
|
333
335
|
frames = turbo[:turbo_frames]&.size || 0
|
|
334
336
|
parts = []
|
|
335
337
|
parts << count_phrase(broadcasts, "broadcast") if broadcasts > 0
|
|
@@ -90,7 +90,7 @@ module RailsAiContext
|
|
|
90
90
|
elsif page[:total].zero?
|
|
91
91
|
all_cats = (gems[:notable_gems] || []).map { |g| g[:category] }.uniq.sort
|
|
92
92
|
hint = all_cats.any? ? " Available categories: #{all_cats.join(', ')}" : ""
|
|
93
|
-
lines << "_No notable gems found#{" in category '#{category}'" unless category == 'all'}.#{hint}_"
|
|
93
|
+
lines << "_No notable gems found#{" in category '#{echo_input(category)}'" unless category == 'all'}.#{hint}_"
|
|
94
94
|
end
|
|
95
95
|
# An empty page past the end is not an empty app. Saying "no notable
|
|
96
96
|
# gems found" above "No items at offset 9999. Total: 13." contradicts
|
|
@@ -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
|
|
@@ -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
|
|
|
@@ -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?
|