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
|
@@ -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)
|
|
@@ -222,14 +288,28 @@ module RailsAiContext
|
|
|
222
288
|
end
|
|
223
289
|
|
|
224
290
|
private_class_method def self.format_action_source(controller_name, info, action_name)
|
|
291
|
+
# An alias is the same class: its actions are read from the controller it names.
|
|
292
|
+
target = info[:alias_of] && RailsAiContext::Payload.controllers(cached_context)[info[:alias_of]]
|
|
293
|
+
return format_action_source(info[:alias_of], target, action_name) if target.is_a?(Hash) && !target[:alias_of]
|
|
294
|
+
|
|
225
295
|
actions = info[:actions] || []
|
|
226
296
|
# Case-insensitive action lookup for consistency with other tools
|
|
227
297
|
action_name = actions.find { |a| a.to_s.downcase == action_name.to_s.downcase }&.to_s || action_name.to_s
|
|
298
|
+
carried = RailsAiContext::Payload.controller_file(cached_context, controller_name)
|
|
299
|
+
# Rails counts a public method a concern or a parent controller gives
|
|
300
|
+
# among this controller's actions, though the listing takes one only
|
|
301
|
+
# where a route names it.
|
|
302
|
+
unlisted = nil
|
|
228
303
|
unless actions.map(&:to_s).include?(action_name)
|
|
229
|
-
|
|
304
|
+
unlisted = RailsAiContext::Introspectors::ActionSource.unlisted_action(rails_app.root, controller_name, action_name,
|
|
305
|
+
file: carried, controllers: RailsAiContext::Payload.controllers(cached_context))
|
|
306
|
+
unless unlisted
|
|
307
|
+
return empty_response("Action '#{echo_input(action_name)}' not found in #{controller_name}. Available: #{actions.join(', ')}")
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
action_name = unlisted[:name]
|
|
230
311
|
end
|
|
231
312
|
|
|
232
|
-
carried = RailsAiContext::Payload.controller_file(cached_context, controller_name)
|
|
233
313
|
source_path = carried ? rails_app.root.join(carried) : nil
|
|
234
314
|
source = source_path && safe_read(source_path.to_s)
|
|
235
315
|
|
|
@@ -237,11 +317,19 @@ module RailsAiContext
|
|
|
237
317
|
root: rails_app.root.to_s)
|
|
238
318
|
|
|
239
319
|
# 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)
|
|
320
|
+
source_with_lines = unlisted || (source && extract_method_with_lines(source_path, action_name, source: source,
|
|
321
|
+
owner: controller_name))
|
|
322
|
+
# An action from a concern or a parent controller has no def in this file.
|
|
323
|
+
source_with_lines ||= RailsAiContext::Introspectors::ActionSource.find(rails_app.root, controller_name,
|
|
324
|
+
action_name, file: carried, controllers: RailsAiContext::Payload.controllers(cached_context))
|
|
325
|
+
elsewhere = source_with_lines&.dig(:file)
|
|
326
|
+
elsewhere = nil if elsewhere == carried
|
|
242
327
|
|
|
243
328
|
lines = [ "# #{controller_name}##{action_name}", "" ]
|
|
244
329
|
lines << "**File:** `#{carried}`" if carried
|
|
330
|
+
parent = source_with_lines&.dig(:owner)
|
|
331
|
+
lines << "**Inherited:** from #{parent}, defined in `#{source_with_lines[:file]}`." if parent && parent != controller_name
|
|
332
|
+
lines << "**Not listed:** #{RailsAiContext::Introspectors::ActionSource::UNLISTED_NOTE}" if unlisted
|
|
245
333
|
|
|
246
334
|
if applicable.values.any?(&:any?)
|
|
247
335
|
lines << "" << "## Applicable Filters"
|
|
@@ -250,8 +338,9 @@ module RailsAiContext
|
|
|
250
338
|
end
|
|
251
339
|
|
|
252
340
|
if source_with_lines
|
|
253
|
-
|
|
254
|
-
lines << "
|
|
341
|
+
span = "lines #{source_with_lines[:start_line]}-#{source_with_lines[:end_line]}"
|
|
342
|
+
lines << "" << "## Source (#{elsewhere ? "`#{elsewhere}`, #{span}" : span})"
|
|
343
|
+
lines << "```ruby" << redacted(source_with_lines[:code], elsewhere || carried) << "```"
|
|
255
344
|
|
|
256
345
|
ivars = RailsAiContext::Introspectors::ActionResolver.assigned_ivars(source_with_lines[:code])
|
|
257
346
|
lines << "" << "## Instance Variables" << ivars.map { |v| "- `@#{v}`" }.join("\n") if ivars.any?
|
|
@@ -263,7 +352,7 @@ module RailsAiContext
|
|
|
263
352
|
lines << "" << "## Private Methods Called"
|
|
264
353
|
called_methods.each do |pm|
|
|
265
354
|
lines << "### #{pm[:name]} (lines #{pm[:start_line]}-#{pm[:end_line]})"
|
|
266
|
-
lines << "```ruby" << pm[:code] << "```"
|
|
355
|
+
lines << "```ruby" << redacted(pm[:code], carried) << "```"
|
|
267
356
|
end
|
|
268
357
|
end
|
|
269
358
|
|
|
@@ -271,15 +360,15 @@ module RailsAiContext
|
|
|
271
360
|
render_map = extract_render_map(source_with_lines[:code])
|
|
272
361
|
if render_map[:redirects].any? || render_map[:renders].any?
|
|
273
362
|
lines << "" << "## Render Map"
|
|
274
|
-
render_map[:redirects].each { |r| lines << "- **Redirect:** #{r}" }
|
|
275
|
-
render_map[:renders].each { |r| lines << "- **Render:** #{r}" }
|
|
363
|
+
render_map[:redirects].each { |r| lines << "- **Redirect:** #{redacted(r, carried)}" }
|
|
364
|
+
render_map[:renders].each { |r| lines << "- **Render:** #{redacted(r, carried)}" }
|
|
276
365
|
end
|
|
277
366
|
if render_map[:side_effects].any?
|
|
278
367
|
lines << "" << "## Side Effects"
|
|
279
368
|
render_map[:side_effects].each { |s| lines << "- #{s}" }
|
|
280
369
|
end
|
|
281
370
|
else
|
|
282
|
-
lines << "" << "_Could not extract source code. File: #{
|
|
371
|
+
lines << "" << "_Could not extract source code. File: #{carried || "not recorded for #{controller_name}"}_"
|
|
283
372
|
end
|
|
284
373
|
|
|
285
374
|
# One action's answer used to carry every strong-params method in the
|
|
@@ -292,9 +381,9 @@ module RailsAiContext
|
|
|
292
381
|
lines << "" << "## Strong Params"
|
|
293
382
|
action_params.each do |sp|
|
|
294
383
|
if sp.is_a?(Hash)
|
|
295
|
-
lines << "### #{sp[:name]}"
|
|
384
|
+
lines << "### #{sp[:name]}#{params_origin(sp)}"
|
|
296
385
|
if sp[:unrestricted]
|
|
297
|
-
lines << "-
|
|
386
|
+
lines << "- #{UNRESTRICTED}"
|
|
298
387
|
else
|
|
299
388
|
lines << "- requires: `:#{sp[:requires]}`" if sp[:requires]
|
|
300
389
|
lines << "- permits: #{sp[:permits].map { |p| "`:#{p}`" }.join(', ')}" if sp[:permits]&.any?
|
|
@@ -304,13 +393,14 @@ module RailsAiContext
|
|
|
304
393
|
end
|
|
305
394
|
sp[:arrays]&.each { |a| lines << "- array: `#{a}: []`" }
|
|
306
395
|
sp[:hashes]&.each { |h| lines << "- hash: `#{h}: {}`" }
|
|
396
|
+
sp[:computed]&.each { |key, source| lines << "- nested, computed: `#{key}: #{source}`" }
|
|
307
397
|
end
|
|
308
398
|
body = extract_method_with_lines(source_path, sp[:name], source: source, owner: controller_name)
|
|
309
|
-
lines << "```ruby" << body[:code] << "```" if body
|
|
399
|
+
lines << "```ruby" << redacted(body[:code], carried) << "```" if body
|
|
310
400
|
else
|
|
311
401
|
body = extract_method_with_lines(source_path, sp, source: source, owner: controller_name)
|
|
312
402
|
if body
|
|
313
|
-
lines << "```ruby" << body[:code] << "```"
|
|
403
|
+
lines << "```ruby" << redacted(body[:code], carried) << "```"
|
|
314
404
|
else
|
|
315
405
|
lines << "- `#{sp}`"
|
|
316
406
|
end
|
|
@@ -371,6 +461,12 @@ module RailsAiContext
|
|
|
371
461
|
end
|
|
372
462
|
end
|
|
373
463
|
|
|
464
|
+
UNRESTRICTED = "**WARNING:** `params.permit!` - all parameters allowed"
|
|
465
|
+
|
|
466
|
+
private_class_method def self.params_origin(entry)
|
|
467
|
+
" _(from #{entry[:from]})_" if entry[:from]
|
|
468
|
+
end
|
|
469
|
+
|
|
374
470
|
# One field of a permit list as Ruby spells it: `:name`, `[...]`, `key: [...]`, `key: {}` or `key: { ... }`, braced inside a list.
|
|
375
471
|
private_class_method def self.permit_field_text(field, braced: false)
|
|
376
472
|
return "[#{field.map { |f| permit_field_text(f, braced: true) }.join(', ')}]" if field.is_a?(Array)
|
|
@@ -394,32 +490,13 @@ module RailsAiContext
|
|
|
394
490
|
|
|
395
491
|
# Extract render map from action source: redirects, renders, and side effects
|
|
396
492
|
private_class_method def self.extract_render_map(code)
|
|
397
|
-
redirects =
|
|
398
|
-
renders = []
|
|
493
|
+
redirects, renders = render_and_redirect_calls(code)
|
|
399
494
|
side_effects = []
|
|
400
495
|
enqueues = Introspectors::SourceCalls.enqueue_calls(code, enqueue_helpers).group_by { |hit| hit[:line] }
|
|
401
496
|
|
|
402
497
|
code.each_line.with_index(1) do |line, number|
|
|
403
498
|
stripped = line.strip
|
|
404
499
|
|
|
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
500
|
# Detect side effects
|
|
424
501
|
if stripped.match?(/\.save[!]?(\s|\(|$)/)
|
|
425
502
|
obj = stripped.match(/(\S+)\.save/)&.send(:[], 1) || "object"
|
|
@@ -447,6 +524,56 @@ module RailsAiContext
|
|
|
447
524
|
RailsAiContext.debug_fail(e, { redirects: [], renders: [], side_effects: [] }, label: "extract_render_map")
|
|
448
525
|
end
|
|
449
526
|
|
|
527
|
+
# Each render and redirect_to call as the source writes its arguments.
|
|
528
|
+
# Lines were matched one at a time and their ends trimmed, which cut the
|
|
529
|
+
# brace closing a block in `render json: accounts.map { ... }` and the
|
|
530
|
+
# parenthesis closing `t(".created")` in a redirect's notice.
|
|
531
|
+
private_class_method def self.render_and_redirect_calls(code)
|
|
532
|
+
redirects = []
|
|
533
|
+
renders = []
|
|
534
|
+
tree = RailsAiContext::AstCache.parse_string(code).value
|
|
535
|
+
action = tree.statements.body.first.then { |node| node.name if node.is_a?(Prism::DefNode) }
|
|
536
|
+
visit = lambda do |node|
|
|
537
|
+
if node.is_a?(Prism::CallNode) && node.receiver.nil? && node.arguments
|
|
538
|
+
case node.name
|
|
539
|
+
when :redirect_to then redirects << redirect_text(node.arguments.arguments)
|
|
540
|
+
when :render then renders << "render #{node.arguments.slice.squish}"
|
|
541
|
+
end
|
|
542
|
+
elsif action && implicit_format_render?(node)
|
|
543
|
+
# `format.turbo_stream` with no block renders the action's own
|
|
544
|
+
# template for that format, a render no `render` call names.
|
|
545
|
+
renders << "render :#{action}, formats: :#{node.name} (implicit, from `format.#{node.name}`)"
|
|
546
|
+
end
|
|
547
|
+
node.compact_child_nodes.each(&visit)
|
|
548
|
+
end
|
|
549
|
+
visit.call(tree)
|
|
550
|
+
[ redirects, renders ]
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
RESPONSE_FORMATS = %i[html json xml js turbo_stream csv text].freeze
|
|
554
|
+
|
|
555
|
+
private_class_method def self.implicit_format_render?(node)
|
|
556
|
+
node.is_a?(Prism::CallNode) && node.receiver.is_a?(Prism::LocalVariableReadNode) &&
|
|
557
|
+
RESPONSE_FORMATS.include?(node.name) && node.arguments.nil? && node.block.nil?
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# `redirect_to target (notice: ..., alert: ...)`: the flash it sets, and
|
|
561
|
+
# none of the status or other options beside it.
|
|
562
|
+
private_class_method def self.redirect_text(arguments)
|
|
563
|
+
options, target = arguments.partition { |arg| arg.is_a?(Prism::KeywordHashNode) }
|
|
564
|
+
flash = options.flat_map(&:elements).filter_map do |pair|
|
|
565
|
+
key = pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode) ? pair.key.unescaped : nil
|
|
566
|
+
"#{key}: #{pair.value.slice.squish}" if %w[notice alert].include?(key)
|
|
567
|
+
end
|
|
568
|
+
"redirect_to #{target.map { |arg| arg.slice.squish }.join(", ")}#{" (#{flash.join(', ')})" if flash.any?}"
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# Source as it is shown: every secret in it filtered, as search_code and
|
|
572
|
+
# get_edit_context show the same lines.
|
|
573
|
+
private_class_method def self.redacted(code, path)
|
|
574
|
+
RailsAiContext::Redaction.redact_source(code, path: path)
|
|
575
|
+
end
|
|
576
|
+
|
|
450
577
|
private_class_method def self.extract_method_with_lines(file_path, method_name, source: nil, owner: nil)
|
|
451
578
|
unless source
|
|
452
579
|
return nil unless file_path && File.exist?(file_path)
|
|
@@ -461,9 +588,11 @@ module RailsAiContext
|
|
|
461
588
|
|
|
462
589
|
private_class_method def self.format_controller(name, info, ctx)
|
|
463
590
|
lines = [ "# #{name}", "" ]
|
|
591
|
+
lines << "**Alias of:** `#{info[:alias_of]}`, the same class under a second name" if info[:alias_of]
|
|
464
592
|
lines << "**Parent:** `#{resolved_parent(name, info, ctx)}`" if info[:parent_class]
|
|
465
593
|
lines << "**API controller:** yes" if info[:api_controller]
|
|
466
|
-
|
|
594
|
+
formats = formats_phrase(info, ctx, name)
|
|
595
|
+
lines << "**Formats:** #{formats}" if formats
|
|
467
596
|
declared = RailsAiContext::Introspectors::ControllerSettings.resolve(ctx, name, root: rails_app.root.to_s)
|
|
468
597
|
lines << "**Layout:** #{RailsAiContext::Introspectors::ControllerSettings.layout_phrase(declared[:layout])}" if declared[:layout]
|
|
469
598
|
|
|
@@ -493,8 +622,14 @@ module RailsAiContext
|
|
|
493
622
|
permits_summary = (Array(sp[:permits]).map { |p| ":#{p}" } +
|
|
494
623
|
Array(sp[:nested]).map { |key, fields| permit_field_text({ key => fields }) } +
|
|
495
624
|
Array(sp[:arrays]).map { |key| "#{key}: []" } +
|
|
496
|
-
Array(sp[:hashes]).map { |key| "#{key}: {}" }
|
|
497
|
-
|
|
625
|
+
Array(sp[:hashes]).map { |key| "#{key}: {}" } +
|
|
626
|
+
Array(sp[:computed]).map { |key, source| "#{key}: #{source}" }).join(", ")
|
|
627
|
+
shape = if sp[:unrestricted]
|
|
628
|
+
" #{UNRESTRICTED}"
|
|
629
|
+
else
|
|
630
|
+
"#{sp[:requires] ? " (requires: :#{sp[:requires]})" : ""}#{permits_summary.empty? ? "" : " permits: #{permits_summary}"}"
|
|
631
|
+
end
|
|
632
|
+
lines << "- `#{sp[:name]}`#{params_origin(sp)}#{shape}"
|
|
498
633
|
else
|
|
499
634
|
lines << "- `#{sp}`"
|
|
500
635
|
end
|
|
@@ -94,8 +94,8 @@ module RailsAiContext
|
|
|
94
94
|
fingerprint_parts = []
|
|
95
95
|
fingerprint_parts << conventions[:architecture].map { |a| humanize_arch(a) }.join(", ") if conventions[:architecture]&.any?
|
|
96
96
|
fingerprint_parts << conventions[:patterns].map { |p| humanize_pattern(p) }.join(", ") if conventions[:patterns]&.any?
|
|
97
|
-
top_dirs = conventions[:directory_structure]
|
|
98
|
-
fingerprint_parts << "key dirs: #{top_dirs.join(', ')}" if top_dirs
|
|
97
|
+
top_dirs = (conventions[:directory_structure] || {}).sort_by.with_index { |(_, v), i| [ -v, i ] }.first(5).map { |k, _| k }
|
|
98
|
+
fingerprint_parts << "key dirs: #{top_dirs.join(', ')}" if top_dirs.any?
|
|
99
99
|
if fingerprint_parts.any?
|
|
100
100
|
lines << "" << "## Convention Fingerprint"
|
|
101
101
|
lines << "This app uses #{fingerprint_parts.join('; ')}."
|
|
@@ -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
|