rails-ai-context 5.30.2 → 5.32.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 +868 -0
- data/CONTEXT.md +7 -3
- data/README.md +1 -1
- data/docs/ARCHITECTURE.md +3 -3
- data/docs/CLI.md +1 -1
- data/docs/COMPATIBILITY.md +50 -14
- data/docs/CONFIGURATION.md +4 -1
- data/docs/FAQ.md +1 -1
- data/docs/GUIDE.md +14 -8
- data/docs/INTROSPECTORS.md +50 -23
- data/docs/SECURITY.md +56 -1
- data/docs/TOOLS.md +58 -12
- data/lib/generators/rails_ai_context/install/install_generator.rb +1 -0
- data/lib/rails_ai_context/action_filters.rb +121 -48
- data/lib/rails_ai_context/app_commands.rb +2 -2
- data/lib/rails_ai_context/app_kind.rb +65 -8
- data/lib/rails_ai_context/ast_cache.rb +53 -18
- data/lib/rails_ai_context/base_mixins.rb +46 -16
- data/lib/rails_ai_context/boot_manager.rb +5 -1
- data/lib/rails_ai_context/cli/entry_boot.rb +17 -2
- data/lib/rails_ai_context/cli/tool_runner.rb +15 -1
- data/lib/rails_ai_context/concern_macros.rb +791 -111
- data/lib/rails_ai_context/concern_membership.rb +25 -5
- data/lib/rails_ai_context/concern_paths.rb +63 -17
- data/lib/rails_ai_context/config_yaml.rb +40 -0
- data/lib/rails_ai_context/configuration.rb +3 -3
- data/lib/rails_ai_context/data/docs/index.json +324 -0
- data/lib/rails_ai_context/database_yml.rb +136 -0
- data/lib/rails_ai_context/doctor.rb +30 -37
- data/lib/rails_ai_context/erb_source.rb +40 -0
- data/lib/rails_ai_context/fingerprinter.rb +48 -22
- data/lib/rails_ai_context/fixture_keys.rb +42 -1
- data/lib/rails_ai_context/gem_lock.rb +275 -47
- data/lib/rails_ai_context/git_ignore.rb +13 -11
- data/lib/rails_ai_context/hydrators/hydration_formatter.rb +8 -2
- data/lib/rails_ai_context/hydrators/schema_hint_builder.rb +10 -5
- data/lib/rails_ai_context/introspector.rb +10 -1
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +27 -19
- data/lib/rails_ai_context/introspectors/action_presence.rb +11 -8
- data/lib/rails_ai_context/introspectors/action_resolver.rb +38 -26
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/active_record_settings.rb +56 -0
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +98 -22
- data/lib/rails_ai_context/introspectors/admin_resources.rb +101 -0
- data/lib/rails_ai_context/introspectors/anyway_configs.rb +64 -0
- data/lib/rails_ai_context/introspectors/apartment_config.rb +47 -0
- data/lib/rails_ai_context/introspectors/api_introspector.rb +125 -26
- data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +24 -19
- data/lib/rails_ai_context/introspectors/ast_walk.rb +18 -0
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +71 -18
- data/lib/rails_ai_context/introspectors/autoload_introspector.rb +62 -15
- data/lib/rails_ai_context/introspectors/base.rb +38 -0
- data/lib/rails_ai_context/introspectors/call_site_expansion.rb +349 -39
- data/lib/rails_ai_context/introspectors/component_introspector.rb +26 -14
- data/lib/rails_ai_context/introspectors/config_introspector.rb +22 -9
- data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +1 -4
- data/lib/rails_ai_context/introspectors/controller_filters.rb +335 -31
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +315 -129
- data/lib/rails_ai_context/introspectors/controller_settings.rb +182 -0
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +18 -15
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/declared_constant.rb +108 -23
- data/lib/rails_ai_context/introspectors/eager_load.rb +12 -7
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +13 -5
- data/lib/rails_ai_context/introspectors/env_config_introspector.rb +62 -17
- data/lib/rails_ai_context/introspectors/env_introspector.rb +8 -6
- data/lib/rails_ai_context/introspectors/env_references.rb +21 -4
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +37 -22
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +3 -1
- data/lib/rails_ai_context/introspectors/gemfile_gems.rb +53 -5
- data/lib/rails_ai_context/introspectors/grape_endpoints.rb +109 -0
- data/lib/rails_ai_context/introspectors/helper_names.rb +12 -1
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +101 -46
- data/lib/rails_ai_context/introspectors/includers.rb +6 -1
- data/lib/rails_ai_context/introspectors/initializer_introspector.rb +2 -5
- data/lib/rails_ai_context/introspectors/job_introspector.rb +548 -103
- data/lib/rails_ai_context/introspectors/listener_registration.rb +11 -1
- data/lib/rails_ai_context/introspectors/listeners/associations_listener.rb +51 -4
- data/lib/rails_ai_context/introspectors/listeners/autoload_ignore_listener.rb +31 -0
- data/lib/rails_ai_context/introspectors/listeners/autoload_paths_listener.rb +4 -47
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +80 -14
- data/lib/rails_ai_context/introspectors/listeners/branch_conditions.rb +156 -0
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +85 -23
- data/lib/rails_ai_context/introspectors/listeners/class_definition_listener.rb +35 -6
- data/lib/rails_ai_context/introspectors/listeners/component_structure_listener.rb +30 -2
- data/lib/rails_ai_context/introspectors/listeners/conditional_macro_listener.rb +19 -0
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +255 -10
- data/lib/rails_ai_context/introspectors/listeners/constant_reference_listener.rb +34 -0
- data/lib/rails_ai_context/introspectors/listeners/constructor_macro_listener.rb +123 -0
- data/lib/rails_ai_context/introspectors/listeners/definition_file_paths_listener.rb +66 -0
- data/lib/rails_ai_context/introspectors/listeners/enums_listener.rb +2 -2
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +31 -0
- data/lib/rails_ai_context/introspectors/listeners/filter_macro_listener.rb +54 -0
- data/lib/rails_ai_context/introspectors/listeners/fixture_paths_listener.rb +62 -0
- data/lib/rails_ai_context/introspectors/listeners/gemfile_dsl_listener.rb +42 -1
- data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +28 -2
- data/lib/rails_ai_context/introspectors/listeners/grape_api_listener.rb +137 -0
- data/lib/rails_ai_context/introspectors/listeners/http_client_call_listener.rb +50 -0
- data/lib/rails_ai_context/introspectors/listeners/i18n_load_path_listener.rb +54 -0
- data/lib/rails_ai_context/introspectors/listeners/literal_paths.rb +123 -0
- data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +248 -4
- data/lib/rails_ai_context/introspectors/listeners/mailbox_routing_listener.rb +2 -1
- data/lib/rails_ai_context/introspectors/listeners/method_call_listener.rb +7 -1
- data/lib/rails_ai_context/introspectors/listeners/methods_listener.rb +352 -69
- data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +1 -1
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +35 -26
- data/lib/rails_ai_context/introspectors/listeners/mixins_listener.rb +62 -29
- data/lib/rails_ai_context/introspectors/listeners/mount_listener.rb +8 -0
- data/lib/rails_ai_context/introspectors/listeners/namespaced_roots_listener.rb +35 -0
- data/lib/rails_ai_context/introspectors/listeners/nested_constants_listener.rb +32 -0
- data/lib/rails_ai_context/introspectors/listeners/owner_scope.rb +67 -0
- data/lib/rails_ai_context/introspectors/listeners/preview_paths_listener.rb +16 -7
- data/lib/rails_ai_context/introspectors/listeners/proc_literal_listener.rb +42 -0
- data/lib/rails_ai_context/introspectors/listeners/queue_assignment_listener.rb +54 -0
- data/lib/rails_ai_context/introspectors/listeners/rake_task_dsl_listener.rb +21 -8
- data/lib/rails_ai_context/introspectors/listeners/route_files_listener.rb +4 -11
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +461 -65
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +76 -14
- data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +40 -1
- data/lib/rails_ai_context/introspectors/listeners/validations_listener.rb +10 -1
- data/lib/rails_ai_context/introspectors/listeners/view_paths_listener.rb +37 -0
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +47 -3
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +4 -3
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +72 -12
- data/lib/rails_ai_context/introspectors/migration_replay.rb +110 -14
- data/lib/rails_ai_context/introspectors/model_introspector.rb +708 -214
- data/lib/rails_ai_context/introspectors/module_aliases.rb +11 -3
- data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +9 -57
- data/lib/rails_ai_context/introspectors/observability_introspector.rb +5 -8
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +28 -8
- data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +118 -51
- data/lib/rails_ai_context/introspectors/recurring_schedules.rb +150 -0
- data/lib/rails_ai_context/introspectors/rendered_record.rb +87 -0
- data/lib/rails_ai_context/introspectors/retry_policy.rb +8 -5
- data/lib/rails_ai_context/introspectors/route_introspector.rb +153 -42
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +127 -40
- data/lib/rails_ai_context/introspectors/schema_dump_path.rb +80 -0
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +346 -112
- data/lib/rails_ai_context/introspectors/schema_reader.rb +69 -29
- data/lib/rails_ai_context/introspectors/service_classes.rb +21 -15
- data/lib/rails_ai_context/introspectors/source_calls.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_introspector.rb +17 -2
- data/lib/rails_ai_context/introspectors/source_scan.rb +174 -26
- data/lib/rails_ai_context/introspectors/sqlite_virtual_tables.rb +28 -0
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +16 -5
- data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +372 -98
- data/lib/rails_ai_context/introspectors/superclass_chain.rb +14 -15
- data/lib/rails_ai_context/introspectors/table_name.rb +46 -45
- data/lib/rails_ai_context/introspectors/test_introspector.rb +289 -78
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +10 -5
- data/lib/rails_ai_context/introspectors/view_introspector.rb +5 -40
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +12 -10
- data/lib/rails_ai_context/method_name.rb +31 -0
- data/lib/rails_ai_context/migration_status.rb +5 -5
- data/lib/rails_ai_context/output_guard.rb +6 -4
- data/lib/rails_ai_context/package_json.rb +96 -5
- data/lib/rails_ai_context/path_resolver.rb +170 -15
- data/lib/rails_ai_context/payload.rb +48 -0
- data/lib/rails_ai_context/pending_migrations.rb +15 -18
- data/lib/rails_ai_context/portable_path.rb +76 -4
- data/lib/rails_ai_context/redaction.rb +8 -2
- data/lib/rails_ai_context/route_coverage.rb +5 -1
- data/lib/rails_ai_context/safe_file.rb +4 -1
- data/lib/rails_ai_context/safe_path.rb +61 -10
- data/lib/rails_ai_context/schema_hint.rb +8 -2
- data/lib/rails_ai_context/schema_version.rb +7 -9
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +13 -4
- data/lib/rails_ai_context/serializers/section_facts.rb +5 -1
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +2 -2
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +7 -4
- data/lib/rails_ai_context/server.rb +26 -7
- data/lib/rails_ai_context/test_framework.rb +32 -5
- data/lib/rails_ai_context/tools/analyze_feature.rb +22 -10
- data/lib/rails_ai_context/tools/base_tool.rb +69 -30
- data/lib/rails_ai_context/tools/diagnose.rb +1 -1
- data/lib/rails_ai_context/tools/generate_test.rb +8 -9
- data/lib/rails_ai_context/tools/get_active_support.rb +19 -4
- data/lib/rails_ai_context/tools/get_api.rb +17 -11
- data/lib/rails_ai_context/tools/get_autoload.rb +10 -5
- data/lib/rails_ai_context/tools/get_callbacks.rb +65 -14
- data/lib/rails_ai_context/tools/get_component_catalog.rb +7 -4
- data/lib/rails_ai_context/tools/get_concern.rb +31 -18
- data/lib/rails_ai_context/tools/get_config.rb +52 -18
- data/lib/rails_ai_context/tools/get_controllers.rb +57 -13
- data/lib/rails_ai_context/tools/get_conventions.rb +44 -22
- data/lib/rails_ai_context/tools/get_engines.rb +2 -0
- data/lib/rails_ai_context/tools/get_env.rb +209 -52
- data/lib/rails_ai_context/tools/get_env_config.rb +38 -7
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +56 -7
- data/lib/rails_ai_context/tools/get_helper_methods.rb +136 -26
- data/lib/rails_ai_context/tools/get_job_pattern.rb +143 -87
- data/lib/rails_ai_context/tools/get_mailers.rb +59 -2
- data/lib/rails_ai_context/tools/get_model_details.rb +178 -28
- data/lib/rails_ai_context/tools/get_partial_interface.rb +101 -62
- data/lib/rails_ai_context/tools/get_routes.rb +35 -9
- data/lib/rails_ai_context/tools/get_schema.rb +160 -61
- data/lib/rails_ai_context/tools/get_service_pattern.rb +121 -25
- data/lib/rails_ai_context/tools/get_stimulus.rb +1 -1
- data/lib/rails_ai_context/tools/get_test_info.rb +152 -82
- data/lib/rails_ai_context/tools/get_view.rb +37 -20
- data/lib/rails_ai_context/tools/migration_advisor.rb +64 -24
- data/lib/rails_ai_context/tools/onboard.rb +174 -88
- data/lib/rails_ai_context/tools/performance_check.rb +5 -13
- data/lib/rails_ai_context/tools/query.rb +122 -28
- data/lib/rails_ai_context/tools/read_logs.rb +45 -17
- data/lib/rails_ai_context/tools/runtime_info.rb +1 -1
- data/lib/rails_ai_context/tools/safe_call.rb +20 -15
- data/lib/rails_ai_context/tools/search_code.rb +114 -40
- data/lib/rails_ai_context/tools/search_docs.rb +22 -11
- data/lib/rails_ai_context/tools/security_scan.rb +51 -4
- data/lib/rails_ai_context/tools/validate.rb +21 -4
- data/lib/rails_ai_context/tools/validate_semantics.rb +18 -6
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +2 -2
- data/lib/rails_ai_context/view_file.rb +53 -3
- data/lib/rails_ai_context.rb +10 -2
- data/server.json +1 -1
- metadata +34 -3
|
@@ -47,7 +47,7 @@ module RailsAiContext
|
|
|
47
47
|
component = component.to_s.strip
|
|
48
48
|
|
|
49
49
|
if components.empty?
|
|
50
|
-
note = api_only_note("app/components")
|
|
50
|
+
note = api_only_note("app/components", dir: "app/components")
|
|
51
51
|
return text_response(note) if note
|
|
52
52
|
|
|
53
53
|
return text_response("Component '#{component}' not found - no components exist in app/components/. Create ViewComponent or Phlex components first.")
|
|
@@ -63,7 +63,7 @@ module RailsAiContext
|
|
|
63
63
|
text_response(render_single(found.first, detail))
|
|
64
64
|
else
|
|
65
65
|
if components.empty?
|
|
66
|
-
note = api_only_note("app/components")
|
|
66
|
+
note = api_only_note("app/components", dir: "app/components")
|
|
67
67
|
return text_response(note) if note
|
|
68
68
|
|
|
69
69
|
return text_response(
|
|
@@ -210,8 +210,11 @@ module RailsAiContext
|
|
|
210
210
|
else
|
|
211
211
|
result = "<%= render #{name}.new#{init == ".new" ? "" : init} do |c| %>"
|
|
212
212
|
slots.each do |slot|
|
|
213
|
-
if slot[:
|
|
214
|
-
|
|
213
|
+
if slot[:setters]
|
|
214
|
+
filler = slot[:type] == :many ? "item" : "content"
|
|
215
|
+
slot[:setters].each { |setter| result += "\n <% c.with_#{setter} do %>#{filler}<% end %>" }
|
|
216
|
+
elsif slot[:type] == :many
|
|
217
|
+
result += "\n <% c.with_#{slot[:name].to_s.singularize} do %>item<% end %>"
|
|
215
218
|
else
|
|
216
219
|
result += "\n <% c.with_#{slot[:name]} do %>content<% end %>"
|
|
217
220
|
end
|
|
@@ -171,8 +171,8 @@ module RailsAiContext
|
|
|
171
171
|
end
|
|
172
172
|
|
|
173
173
|
# Only the module's own mixins: a class nested in the file includes for itself.
|
|
174
|
-
mixins = ConcernMembership.
|
|
175
|
-
Introspectors::SourceIntrospector.walk_source(source, { mixins: Introspectors::Listeners::MixinsListener })[:mixins], name
|
|
174
|
+
mixins = ConcernMembership.owned_by(
|
|
175
|
+
Introspectors::SourceIntrospector.walk_source(source, { mixins: Introspectors::Listeners::MixinsListener })[:mixins], name, root: root
|
|
176
176
|
)
|
|
177
177
|
included_modules = mixins.select { |m| m[:macro] == :include }.map { |m| m[:name] }.uniq
|
|
178
178
|
extended_modules = mixins.select { |m| m[:macro] == :extend }.map { |m| m[:name] }.uniq
|
|
@@ -190,14 +190,16 @@ module RailsAiContext
|
|
|
190
190
|
macros.each { |m| lines << "- #{m}" }
|
|
191
191
|
end
|
|
192
192
|
|
|
193
|
-
public_methods = Introspectors::ActionResolver.
|
|
193
|
+
public_methods = unique(Introspectors::ActionResolver.public_methods_in(source, owner: name))
|
|
194
194
|
class_methods = concern_class_methods(source, name)
|
|
195
|
+
own_module_methods = module_methods(source, name)
|
|
195
196
|
render_methods(lines, source, detail, "Public Methods", public_methods)
|
|
196
|
-
render_methods(lines, source, detail, "Class Methods", class_methods
|
|
197
|
+
render_methods(lines, source, detail, "Class Methods", class_methods)
|
|
198
|
+
render_methods(lines, source, detail, "Module Methods", own_module_methods)
|
|
197
199
|
# A module of private helpers is not an empty one.
|
|
198
|
-
if public_methods.empty? && class_methods.empty?
|
|
200
|
+
if public_methods.empty? && class_methods.empty? && own_module_methods.empty?
|
|
199
201
|
render_methods(lines, source, detail, "Private Methods",
|
|
200
|
-
Introspectors::ActionResolver.
|
|
202
|
+
unique(Introspectors::ActionResolver.private_methods_in(source, owner: name)))
|
|
201
203
|
end
|
|
202
204
|
|
|
203
205
|
# Parse callbacks defined in the concern
|
|
@@ -246,29 +248,40 @@ module RailsAiContext
|
|
|
246
248
|
text_response(lines.join("\n"))
|
|
247
249
|
end
|
|
248
250
|
|
|
249
|
-
#
|
|
250
|
-
|
|
251
|
-
private_class_method def self.render_methods(lines, source, detail, title, methods, self_prefix: false)
|
|
251
|
+
# The body comes from this def's own walked bounds, so a same-named private method or delegate is never shown.
|
|
252
|
+
private_class_method def self.render_methods(lines, source, detail, title, methods)
|
|
252
253
|
return if methods.empty?
|
|
253
254
|
|
|
254
255
|
lines << "" << "## #{title}"
|
|
255
256
|
full = RailsAiContext::DetailLevel.full?(detail)
|
|
256
257
|
methods.each do |m|
|
|
257
|
-
|
|
258
|
-
body = full && (
|
|
259
|
-
(self_prefix ? extract_method_source_from_string(source, "self.#{method_name}") : nil))
|
|
258
|
+
signature = Introspectors::ActionResolver.signature(m)
|
|
259
|
+
body = full && Introspectors::ActionResolver.body_of(source, m)
|
|
260
260
|
if body
|
|
261
|
-
lines << "### #{
|
|
261
|
+
lines << "### #{signature}" << "```ruby" << body[:code] << "```" << ""
|
|
262
262
|
else
|
|
263
|
-
lines << "- `#{
|
|
263
|
+
lines << "- `#{signature}`"
|
|
264
264
|
end
|
|
265
265
|
end
|
|
266
266
|
end
|
|
267
267
|
|
|
268
268
|
# `module ClassMethods` defs count too: ActiveSupport::Concern extends the includer with it.
|
|
269
269
|
private_class_method def self.concern_class_methods(source, name)
|
|
270
|
-
(
|
|
271
|
-
|
|
270
|
+
gained = own_class_methods(source, name).select { |m| m[:class_methods_block] }
|
|
271
|
+
unique(gained + Introspectors::ActionResolver.public_methods_in(source, owner: "#{name}::ClassMethods"))
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# `def self.x` and `class << self` methods live on the module; no includer gains them, nor a mixin hook.
|
|
275
|
+
private_class_method def self.module_methods(source, name)
|
|
276
|
+
unique(own_class_methods(source, name).reject { |m| m[:class_methods_block] || ConcernMembership::MIXIN_HOOKS.include?(m[:name]) })
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
private_class_method def self.unique(methods)
|
|
280
|
+
methods.uniq { |m| Introspectors::ActionResolver.signature(m) }
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
private_class_method def self.own_class_methods(source, name)
|
|
284
|
+
Introspectors::ActionResolver.own_methods_in(source, name).select { |m| m[:scope] == :class && m[:visibility] == :public }
|
|
272
285
|
end
|
|
273
286
|
|
|
274
287
|
private_class_method def self.methods_phrase(concern)
|
|
@@ -299,7 +312,7 @@ module RailsAiContext
|
|
|
299
312
|
if source
|
|
300
313
|
public_methods = Introspectors::ActionResolver.public_methods_from_source(source, owner: concern_name)
|
|
301
314
|
class_methods = concern_class_methods(source, concern_name)
|
|
302
|
-
method_count = public_methods.size + class_methods.size
|
|
315
|
+
method_count = public_methods.size + class_methods.size + module_methods(source, concern_name).size
|
|
303
316
|
private_count = Introspectors::ActionResolver.private_methods_from_source(source, owner: concern_name).size if method_count.zero?
|
|
304
317
|
end
|
|
305
318
|
|
|
@@ -507,7 +520,7 @@ module RailsAiContext
|
|
|
507
520
|
sources = includer_dirs(root).flat_map { |dir| safe_glob(dir, "**/*.rb", real_root) }.uniq
|
|
508
521
|
.reject { |file_path| file_path.include?("/concerns/") }
|
|
509
522
|
.filter_map { |file_path| (source = RailsAiContext::SafeFile.read(file_path)) && [ file_path, source ] }
|
|
510
|
-
Introspectors::Includers.of(root, sources, [ concern_name ], macros: %i[include]).values.flatten.uniq.sort
|
|
523
|
+
Introspectors::Includers.of(root, sources, [ concern_name ], macros: %i[include prepend]).values.flatten.uniq.sort
|
|
511
524
|
rescue => e
|
|
512
525
|
RailsAiContext.debug_fail(e, [], label: "find_includers")
|
|
513
526
|
end
|
|
@@ -19,6 +19,10 @@ module RailsAiContext
|
|
|
19
19
|
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
20
20
|
|
|
21
21
|
def self.call(server_context: nil)
|
|
22
|
+
if static_refusal_for(:config) && (rackup = rackup_lines).any?
|
|
23
|
+
return text_response(([ unavailable_text ] + rackup).join("\n"))
|
|
24
|
+
end
|
|
25
|
+
|
|
22
26
|
fetch_section(:config, subject: "Config introspection", remedy: "Add :config to introspectors or use `config.preset = :full`.") do |data|
|
|
23
27
|
lines = [ "# Application Configuration", "" ]
|
|
24
28
|
|
|
@@ -59,6 +63,7 @@ module RailsAiContext
|
|
|
59
63
|
end
|
|
60
64
|
|
|
61
65
|
lines.concat(middleware_lines(data[:middleware_stack]))
|
|
66
|
+
lines.concat(rackup_lines)
|
|
62
67
|
|
|
63
68
|
if data[:initializers]&.any?
|
|
64
69
|
# List every initializer - stock ones often carry active code
|
|
@@ -72,13 +77,23 @@ module RailsAiContext
|
|
|
72
77
|
|
|
73
78
|
if data[:current_attributes]&.any?
|
|
74
79
|
lines << "" << "## CurrentAttributes"
|
|
75
|
-
data[:current_attributes].each { |c| lines <<
|
|
80
|
+
data[:current_attributes].each { |c| lines << current_attributes_line(c, data.dig(:current_attribute_details, c)) }
|
|
76
81
|
end
|
|
77
82
|
|
|
78
83
|
text_response(lines.join("\n"))
|
|
79
84
|
end
|
|
80
85
|
end
|
|
81
86
|
|
|
87
|
+
private_class_method def self.current_attributes_line(name, detail)
|
|
88
|
+
line = "- `#{name}`"
|
|
89
|
+
return line unless detail
|
|
90
|
+
|
|
91
|
+
attributes = Array(detail[:attributes]).map { |a| a[:default] ? "#{a[:name]} (default `#{a[:default]}`)" : a[:name] }
|
|
92
|
+
line += ": #{attributes.join(', ')}" if attributes.any?
|
|
93
|
+
line += "; hooks: #{detail[:hooks].map { |h| "`#{h}`" }.join(', ')}" if Array(detail[:hooks]).any?
|
|
94
|
+
line
|
|
95
|
+
end
|
|
96
|
+
|
|
82
97
|
# Middleware Rails or a development gem puts in every stack.
|
|
83
98
|
DEV_MIDDLEWARE = %w[
|
|
84
99
|
Propshaft::Server WebConsole::Middleware ActionDispatch::Reloader
|
|
@@ -110,6 +125,30 @@ module RailsAiContext
|
|
|
110
125
|
lines
|
|
111
126
|
end
|
|
112
127
|
|
|
128
|
+
# The static refusal reads config.ru alone rather than building every section,
|
|
129
|
+
# and only when the middleware introspector the booted tier reads it through is on.
|
|
130
|
+
private_class_method def self.rackup_lines
|
|
131
|
+
calls = if RailsAiContext.static_tier?
|
|
132
|
+
RailsAiContext.configuration.introspectors.include?(:middleware) ? Introspectors::MiddlewareIntrospector.rackup(rails_app.root.to_s) : []
|
|
133
|
+
else
|
|
134
|
+
Array(Payload.section(cached_context, :middleware)&.dig(:rackup))
|
|
135
|
+
end
|
|
136
|
+
return [] if calls.empty?
|
|
137
|
+
|
|
138
|
+
lines = [ "", "## config.ru (runs before the Rails middleware stack)" ]
|
|
139
|
+
calls.each do |call|
|
|
140
|
+
next lines << "- not read: #{call[:unread]}, so the middleware it adds is unknown" if call[:unread]
|
|
141
|
+
|
|
142
|
+
where = [ "line #{call[:line]}", ("inside `map #{call[:within]}`" if call[:within]), call[:condition] ].compact.join(", ")
|
|
143
|
+
lines << if call[:call] == "map"
|
|
144
|
+
"- `map #{call[:target]}` (#{where}) - its own Rack app; requests under it never reach Rails' router"
|
|
145
|
+
else
|
|
146
|
+
"- `use #{call[:target]}` (#{where})"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
lines
|
|
150
|
+
end
|
|
151
|
+
|
|
113
152
|
# What the middleware introspector found the app owns, by class name.
|
|
114
153
|
private_class_method def self.owned_middleware
|
|
115
154
|
Array(Payload.section(cached_context, :middleware)&.dig(:custom_middleware))
|
|
@@ -155,9 +194,11 @@ module RailsAiContext
|
|
|
155
194
|
end
|
|
156
195
|
|
|
157
196
|
private_class_method def self.detect_database
|
|
158
|
-
|
|
159
|
-
return nil unless
|
|
160
|
-
|
|
197
|
+
env = Rails.configuration.database_configuration&.dig(Rails.env) rescue nil
|
|
198
|
+
return nil unless env.is_a?(Hash)
|
|
199
|
+
|
|
200
|
+
# A multi-database environment nests one block per database; primary is the app's own.
|
|
201
|
+
env["adapter"] || (env["primary"] || env.values.find { |v| v.is_a?(Hash) })&.dig("adapter")
|
|
161
202
|
end
|
|
162
203
|
|
|
163
204
|
private_class_method def self.detect_auth_framework
|
|
@@ -214,23 +255,16 @@ module RailsAiContext
|
|
|
214
255
|
parts.any? ? parts.join(", ") : nil
|
|
215
256
|
end
|
|
216
257
|
|
|
258
|
+
# Action Cable's railtie loads the current environment's cable.yml block into
|
|
259
|
+
# the server config; config.action_cable has no adapter of its own.
|
|
217
260
|
private_class_method def self.detect_action_cable
|
|
218
|
-
|
|
219
|
-
if defined?(ActionCable) && Rails.application.config.respond_to?(:action_cable)
|
|
220
|
-
cable_config = Rails.application.config.action_cable
|
|
221
|
-
adapter = cable_config.adapter if cable_config.respond_to?(:adapter)
|
|
222
|
-
return adapter.to_s if adapter && !adapter.to_s.empty?
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
# YAML fallback for older Rails or when config API isn't available
|
|
226
|
-
cable_yml = rails_app.root.join("config/cable.yml")
|
|
227
|
-
return nil unless File.exist?(cable_yml)
|
|
261
|
+
return nil unless defined?(ActionCable) && ActionCable.respond_to?(:server)
|
|
228
262
|
|
|
229
|
-
|
|
230
|
-
return nil unless
|
|
263
|
+
cable = ActionCable.server.config.cable
|
|
264
|
+
return nil unless cable
|
|
231
265
|
|
|
232
|
-
adapter =
|
|
233
|
-
adapter
|
|
266
|
+
adapter = cable[:adapter] || cable["adapter"]
|
|
267
|
+
adapter.to_s.empty? ? "configured" : adapter.to_s
|
|
234
268
|
rescue StandardError
|
|
235
269
|
nil
|
|
236
270
|
end
|
|
@@ -56,8 +56,9 @@ module RailsAiContext
|
|
|
56
56
|
key = Payload.find_controller(ctx, controller) || controller
|
|
57
57
|
info = controllers[key]
|
|
58
58
|
unless info
|
|
59
|
+
recovery = "Call rails_get_controllers(detail:\"summary\") to see all controllers"
|
|
59
60
|
return not_found_response("Controller", controller, app_controller_names,
|
|
60
|
-
recovery_tool: "
|
|
61
|
+
recovery_tool: data[:unread_engine] ? "#{unread_engine_line(data)} #{recovery}" : recovery)
|
|
61
62
|
end
|
|
62
63
|
return text_response("Error inspecting #{key}: #{info[:error]}") if info[:error]
|
|
63
64
|
|
|
@@ -85,7 +86,7 @@ module RailsAiContext
|
|
|
85
86
|
# Listing mode
|
|
86
87
|
case detail
|
|
87
88
|
when "summary"
|
|
88
|
-
lines =
|
|
89
|
+
lines = listing_header(page, data)
|
|
89
90
|
paginated_names.each do |name|
|
|
90
91
|
info = app_controllers[name]
|
|
91
92
|
action_count = info[:actions]&.size || 0
|
|
@@ -96,7 +97,7 @@ module RailsAiContext
|
|
|
96
97
|
text_response(lines.join("\n"))
|
|
97
98
|
|
|
98
99
|
when "standard"
|
|
99
|
-
lines =
|
|
100
|
+
lines = listing_header(page, data)
|
|
100
101
|
paginated_names.each do |name|
|
|
101
102
|
info = app_controllers[name]
|
|
102
103
|
lines << "- **#{name}** - #{Serializers::SectionFacts.actions_phrase(info)}"
|
|
@@ -105,7 +106,7 @@ module RailsAiContext
|
|
|
105
106
|
text_response(lines.join("\n"))
|
|
106
107
|
|
|
107
108
|
when "full"
|
|
108
|
-
lines =
|
|
109
|
+
lines = listing_header(page, data)
|
|
109
110
|
|
|
110
111
|
# Group sibling controllers that share the same parent and identical structure
|
|
111
112
|
paginated_ctrl = app_controllers.select { |k, _| paginated_names.include?(k) }
|
|
@@ -151,7 +152,9 @@ module RailsAiContext
|
|
|
151
152
|
info, rescue_handlers: true,
|
|
152
153
|
ctx: ctx, name: name, root: rails_app&.root&.to_s
|
|
153
154
|
))
|
|
154
|
-
|
|
155
|
+
# An inherited limit is named with its base in the controller's own detail.
|
|
156
|
+
own_limits = Array(info[:rate_limits]).reject { |limit| limit[:from] }
|
|
157
|
+
lines << "- Rate limit: #{own_limits.map { |limit| limit[:text] }.join('; ')}" if own_limits.any?
|
|
155
158
|
lines << "- Turbo Stream actions: #{info[:turbo_stream_actions].join(', ')}" if info[:turbo_stream_actions]&.any?
|
|
156
159
|
lines << ""
|
|
157
160
|
end
|
|
@@ -174,6 +177,14 @@ module RailsAiContext
|
|
|
174
177
|
)
|
|
175
178
|
end
|
|
176
179
|
|
|
180
|
+
private_class_method def self.listing_header(page, data)
|
|
181
|
+
[ "# Controllers (#{page[:total]})", "", *(data[:unread_engine] ? [ unread_engine_line(data), "" ] : []) ]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private_class_method def self.unread_engine_line(data)
|
|
185
|
+
"The engine at `#{data[:unread_engine]}` is not read unbooted, so its controllers are not listed; boot the app to read them."
|
|
186
|
+
end
|
|
187
|
+
|
|
177
188
|
# A class that defines no action of its own is an answer, and dropping
|
|
178
189
|
# the line left it reading as a walk that did not look. An unread entry
|
|
179
190
|
# already gets a line of its own, so it is not said twice.
|
|
@@ -234,8 +245,7 @@ module RailsAiContext
|
|
|
234
245
|
|
|
235
246
|
if applicable.values.any?(&:any?)
|
|
236
247
|
lines << "" << "## Applicable Filters"
|
|
237
|
-
applicable[:
|
|
238
|
-
applicable[:own].each { |f| lines << filter_line(f) }
|
|
248
|
+
applicable[:chain].each { |f| lines << filter_line(f) }
|
|
239
249
|
applicable[:skipped].each { |name| lines << "- ~~#{name}~~ _(skipped)_" }
|
|
240
250
|
end
|
|
241
251
|
|
|
@@ -289,9 +299,11 @@ module RailsAiContext
|
|
|
289
299
|
lines << "- requires: `:#{sp[:requires]}`" if sp[:requires]
|
|
290
300
|
lines << "- permits: #{sp[:permits].map { |p| "`:#{p}`" }.join(', ')}" if sp[:permits]&.any?
|
|
291
301
|
sp[:nested]&.each do |key, fields|
|
|
292
|
-
|
|
302
|
+
members = fields.is_a?(Hash) ? fields.map { |inner, v| permit_field_text({ inner => v }) } : fields.map { |f| permit_field_text(f, braced: true) }
|
|
303
|
+
lines << "- nested `#{key}:` #{members.map { |m| "`#{m}`" }.join(', ')}"
|
|
293
304
|
end
|
|
294
305
|
sp[:arrays]&.each { |a| lines << "- array: `#{a}: []`" }
|
|
306
|
+
sp[:hashes]&.each { |h| lines << "- hash: `#{h}: {}`" }
|
|
295
307
|
end
|
|
296
308
|
body = extract_method_with_lines(source_path, sp[:name], source: source, owner: controller_name)
|
|
297
309
|
lines << "```ruby" << body[:code] << "```" if body
|
|
@@ -359,6 +371,20 @@ module RailsAiContext
|
|
|
359
371
|
end
|
|
360
372
|
end
|
|
361
373
|
|
|
374
|
+
# One field of a permit list as Ruby spells it: `:name`, `[...]`, `key: [...]`, `key: {}` or `key: { ... }`, braced inside a list.
|
|
375
|
+
private_class_method def self.permit_field_text(field, braced: false)
|
|
376
|
+
return "[#{field.map { |f| permit_field_text(f, braced: true) }.join(', ')}]" if field.is_a?(Array)
|
|
377
|
+
return ":#{field}" unless field.is_a?(Hash)
|
|
378
|
+
|
|
379
|
+
key, value = field.first
|
|
380
|
+
text = if value.is_a?(Hash)
|
|
381
|
+
value.empty? ? "#{key}: {}" : "#{key}: { #{value.map { |inner, v| permit_field_text({ inner => v }) }.join(', ')} }"
|
|
382
|
+
else
|
|
383
|
+
"#{key}: [#{value.map { |f| permit_field_text(f, braced: true) }.join(', ')}]"
|
|
384
|
+
end
|
|
385
|
+
braced ? "{ #{text} }" : text
|
|
386
|
+
end
|
|
387
|
+
|
|
362
388
|
private_class_method def self.filter_line(filter)
|
|
363
389
|
line = "- `#{filter[:kind]}` **#{filter[:name]}**#{Serializers::SectionFacts.filter_origin(filter)}"
|
|
364
390
|
line += " (only: #{filter[:only].join(', ')})" if filter[:only]&.any?
|
|
@@ -438,6 +464,8 @@ module RailsAiContext
|
|
|
438
464
|
lines << "**Parent:** `#{resolved_parent(name, info, ctx)}`" if info[:parent_class]
|
|
439
465
|
lines << "**API controller:** yes" if info[:api_controller]
|
|
440
466
|
lines << "**Formats:** #{info[:respond_to_formats].join(', ')}" if info[:respond_to_formats]&.any?
|
|
467
|
+
declared = RailsAiContext::Introspectors::ControllerSettings.resolve(ctx, name, root: rails_app.root.to_s)
|
|
468
|
+
lines << "**Layout:** #{RailsAiContext::Introspectors::ControllerSettings.layout_phrase(declared[:layout])}" if declared[:layout]
|
|
441
469
|
|
|
442
470
|
lines << "" << "## Actions"
|
|
443
471
|
lines << if info[:actions]&.any?
|
|
@@ -449,21 +477,23 @@ module RailsAiContext
|
|
|
449
477
|
chain = RailsAiContext::ActionFilters.for_controller(ctx, name, root: rails_app.root.to_s)
|
|
450
478
|
if chain.values.any?(&:any?)
|
|
451
479
|
lines << "" << "## Filters"
|
|
452
|
-
chain[:
|
|
453
|
-
chain[:own].each { |f| lines << filter_line(f) }
|
|
480
|
+
chain[:chain].each { |f| lines << filter_line(f) }
|
|
454
481
|
chain[:skipped].each { |skipped| lines << "- ~~#{skipped}~~ _(skipped)_" }
|
|
455
482
|
end
|
|
456
483
|
if info[:concerns_unread]&.any?
|
|
457
484
|
lines << "" << "_#{RailsAiContext::Confidence::UNAVAILABLE} " \
|
|
458
485
|
"#{CountPhrase.call(info[:concerns_unread].size, "included module")} not read, " \
|
|
459
|
-
"so a filter declared there
|
|
486
|
+
"so a filter declared there would be missing from this list: #{info[:concerns_unread].join(', ')}_"
|
|
460
487
|
end
|
|
461
488
|
|
|
462
489
|
if info[:strong_params]&.any?
|
|
463
490
|
lines << "" << "## Strong Params"
|
|
464
491
|
info[:strong_params].each do |sp|
|
|
465
492
|
if sp.is_a?(Hash)
|
|
466
|
-
permits_summary = sp[:permits]
|
|
493
|
+
permits_summary = (Array(sp[:permits]).map { |p| ":#{p}" } +
|
|
494
|
+
Array(sp[:nested]).map { |key, fields| permit_field_text({ key => fields }) } +
|
|
495
|
+
Array(sp[:arrays]).map { |key| "#{key}: []" } +
|
|
496
|
+
Array(sp[:hashes]).map { |key| "#{key}: {}" }).join(", ")
|
|
467
497
|
lines << "- `#{sp[:name]}`#{sp[:requires] ? " (requires: :#{sp[:requires]})" : ""}#{permits_summary.empty? ? "" : " permits: #{permits_summary}"}"
|
|
468
498
|
else
|
|
469
499
|
lines << "- `#{sp}`"
|
|
@@ -471,6 +501,14 @@ module RailsAiContext
|
|
|
471
501
|
end
|
|
472
502
|
end
|
|
473
503
|
|
|
504
|
+
if declared[:settings].any?
|
|
505
|
+
lines << "" << "## Settings"
|
|
506
|
+
declared[:settings].each do |setting|
|
|
507
|
+
origin = [ ("from #{setting[:from]}" unless setting[:from] == name), ("through #{setting[:via]}" if setting[:via]) ].compact
|
|
508
|
+
lines << "- `#{setting[:text]}`#{" _(#{origin.join(' ')})_" if origin.any?}"
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
|
|
474
512
|
# Rescue handlers
|
|
475
513
|
if info[:rescue_from]&.any?
|
|
476
514
|
lines << "" << "## Rescue Handlers"
|
|
@@ -478,7 +516,13 @@ module RailsAiContext
|
|
|
478
516
|
end
|
|
479
517
|
|
|
480
518
|
# Rate limiting
|
|
481
|
-
|
|
519
|
+
limits = Array(info[:rate_limits]).map { |limit| "#{limit[:text]}#{" _(from #{limit[:from]})_" if limit[:from]}" }
|
|
520
|
+
if limits.one?
|
|
521
|
+
lines << "" << "**Rate limit:** #{limits.first}"
|
|
522
|
+
elsif limits.any?
|
|
523
|
+
lines << "" << "**Rate limits:**"
|
|
524
|
+
limits.each { |limit| lines << "- #{limit}" }
|
|
525
|
+
end
|
|
482
526
|
|
|
483
527
|
# Turbo Stream actions
|
|
484
528
|
if info[:turbo_stream_actions]&.any?
|
|
@@ -64,6 +64,12 @@ module RailsAiContext
|
|
|
64
64
|
end
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
+
admin = Introspectors::AdminResources.call(rails_app.root)
|
|
68
|
+
if admin.any?
|
|
69
|
+
lines << "" << "## Admin Resources"
|
|
70
|
+
admin.each { |resource| lines << "- #{Introspectors::AdminResources.line(resource)}" }
|
|
71
|
+
end
|
|
72
|
+
|
|
67
73
|
# Config files - only show non-obvious ones (skip files every Rails app has)
|
|
68
74
|
if conventions[:config_files]&.any?
|
|
69
75
|
obvious = %w[
|
|
@@ -164,6 +170,7 @@ module RailsAiContext
|
|
|
164
170
|
[ "esbuild", %w[esbuild] ],
|
|
165
171
|
[ "Vite", %w[vite] ],
|
|
166
172
|
[ "Webpack", %w[webpack] ],
|
|
173
|
+
[ "Rollup", %w[rollup] ],
|
|
167
174
|
[ "React", %w[react] ],
|
|
168
175
|
[ "Vue", %w[vue] ],
|
|
169
176
|
[ "Svelte", %w[svelte @sveltejs/kit] ],
|
|
@@ -174,26 +181,17 @@ module RailsAiContext
|
|
|
174
181
|
|
|
175
182
|
private_class_method def self.detect_frontend_stack
|
|
176
183
|
root = rails_app.root.to_s
|
|
177
|
-
|
|
178
|
-
|
|
184
|
+
css = Introspectors::AssetPipelineIntrospector.css_framework_label(root)
|
|
179
185
|
stack = STACK_MARKERS.filter_map do |label, packages|
|
|
180
|
-
label if packages.any? { |pkg| RailsAiContext::PackageJson.present?(root, pkg) }
|
|
186
|
+
label if label == css || packages.any? { |pkg| RailsAiContext::PackageJson.present?(root, pkg) }
|
|
181
187
|
end
|
|
182
188
|
|
|
183
|
-
pm =
|
|
189
|
+
pm = RailsAiContext::PackageJson.package_manager(root)&.first
|
|
184
190
|
stack << "#{pm} (package manager)" if pm
|
|
185
191
|
|
|
186
192
|
stack
|
|
187
193
|
end
|
|
188
194
|
|
|
189
|
-
private_class_method def self.detect_package_manager
|
|
190
|
-
return "pnpm" if File.exist?(rails_app.root.join("pnpm-lock.yaml"))
|
|
191
|
-
return "yarn" if File.exist?(rails_app.root.join("yarn.lock"))
|
|
192
|
-
return "bun" if File.exist?(rails_app.root.join("bun.lockb"))
|
|
193
|
-
return "npm" if File.exist?(rails_app.root.join("package-lock.json"))
|
|
194
|
-
nil
|
|
195
|
-
end
|
|
196
|
-
|
|
197
195
|
# Scan controllers for app-specific authorization, flash, and error-handling patterns
|
|
198
196
|
private_class_method def self.detect_app_patterns
|
|
199
197
|
controllers_dir = rails_app.root.join("app", "controllers").to_s
|
|
@@ -224,19 +222,17 @@ module RailsAiContext
|
|
|
224
222
|
flash_alerts.concat(content.scan(/alert:\s*"([^"]*)"/).flatten)
|
|
225
223
|
|
|
226
224
|
# Not-found handling: set_* methods that rescue or redirect on missing records
|
|
227
|
-
content.scan(/def\s+(set_\w+).*?(?=\n\s*def\s|\n\s*end\s*\z)/m).each do |
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
if content.match?(/def\s+#{Regexp.escape(method_name)}.*?(?:rescue\s+ActiveRecord::RecordNotFound|rescue\b)/m)
|
|
231
|
-
redirect_match = content.match(/def\s+#{Regexp.escape(method_name)}.*?redirect_to\s+(\S+)/m)
|
|
225
|
+
content.scan(/def\s+(set_\w+)(.*?)(?=\n\s*def\s|\n\s*end\s*\z)/m).each do |method_name, body|
|
|
226
|
+
if body.match?(/\brescue\b/)
|
|
227
|
+
redirect_match = body.match(/redirect_to\s+(\S+)/)
|
|
232
228
|
target = redirect_match ? redirect_match[1].sub(/,.*/, "") : "..."
|
|
233
229
|
not_found_patterns << "#{method_name} → rescue → redirect_to #{target}" unless not_found_patterns.include?("#{method_name} → rescue → redirect_to #{target}")
|
|
234
230
|
end
|
|
235
231
|
end
|
|
236
232
|
|
|
237
233
|
# Create action flow detection
|
|
238
|
-
if content.match?(/def\s+create
|
|
239
|
-
create_block = content[/def\s+create
|
|
234
|
+
if content.match?(/def\s+create#{RailsAiContext::MethodName.definition_end("create")}/)
|
|
235
|
+
create_block = content[/def\s+create#{RailsAiContext::MethodName.definition_end("create")}.*?(?=\n\s{2}def\s|\n\s{2}private|\z)/m]
|
|
240
236
|
if create_block
|
|
241
237
|
flow_parts = []
|
|
242
238
|
flow_parts << "permission check" if create_block.match?(/can_\w+\??|authorize|authorize!/)
|
|
@@ -395,7 +391,7 @@ module RailsAiContext
|
|
|
395
391
|
return info unless Dir.exist?(locales_dir)
|
|
396
392
|
|
|
397
393
|
real_root = File.realpath(rails_app.root).to_s
|
|
398
|
-
locale_files = safe_glob(locales_dir,
|
|
394
|
+
locale_files = safe_glob(locales_dir, Introspectors::I18nIntrospector::RAILS_LOCALE_GLOB, real_root)
|
|
399
395
|
# The locales each file declares, as the i18n section read them; a
|
|
400
396
|
# file's name gives the locale only as its last part (devise.en.yml).
|
|
401
397
|
read = RailsAiContext::Payload.section(cached_context, :i18n)&.dig(:locale_files)
|
|
@@ -451,7 +447,7 @@ module RailsAiContext
|
|
|
451
447
|
|
|
452
448
|
TEST_SIGNALS = {
|
|
453
449
|
devise: /Devise::Test::IntegrationHelpers/,
|
|
454
|
-
sign_in:
|
|
450
|
+
sign_in: /\bsign_in(?:_as)?\b/,
|
|
455
451
|
assert_select: /assert_select/,
|
|
456
452
|
assert_response: /assert_response/,
|
|
457
453
|
auth_test: /requires?\s+authentication/i
|
|
@@ -469,6 +465,7 @@ module RailsAiContext
|
|
|
469
465
|
|
|
470
466
|
found = {}
|
|
471
467
|
detected_in = []
|
|
468
|
+
sign_in = nil
|
|
472
469
|
|
|
473
470
|
test_files.first(5).each do |path|
|
|
474
471
|
content = RailsAiContext::SafeFile.read(path) or next
|
|
@@ -476,6 +473,7 @@ module RailsAiContext
|
|
|
476
473
|
next if hits.empty?
|
|
477
474
|
|
|
478
475
|
found.update(hits)
|
|
476
|
+
sign_in ||= sign_in_call(content) if hits[:sign_in]
|
|
479
477
|
# Only credit files that actually contributed a signal - an empty
|
|
480
478
|
# scaffold test says nothing about the app's test pattern.
|
|
481
479
|
detected_in << File.basename(path, ".rb").camelize.sub(/Test$/, "")
|
|
@@ -498,7 +496,7 @@ module RailsAiContext
|
|
|
498
496
|
sections << ""
|
|
499
497
|
end
|
|
500
498
|
sections << " test \"[action] renders page\" do"
|
|
501
|
-
sections << " sign_in
|
|
499
|
+
sections << " #{sign_in}" if sign_in
|
|
502
500
|
sections << " get [path]"
|
|
503
501
|
sections << " assert_response :success"
|
|
504
502
|
sections << " assert_select \"h1\", \"[Expected Title]\"" if found[:assert_select]
|
|
@@ -512,6 +510,30 @@ module RailsAiContext
|
|
|
512
510
|
rescue => e
|
|
513
511
|
RailsAiContext.debug_fail(e, [], label: "detect_test_pattern")
|
|
514
512
|
end
|
|
513
|
+
|
|
514
|
+
# A variable or a bare helper name: set outside the call, so a copy of
|
|
515
|
+
# the call alone signs in nil.
|
|
516
|
+
SIGN_IN_BOUND_OUTSIDE = /\A(?:@@?|\$)?[a-z_]\w*\z/
|
|
517
|
+
private_constant :SIGN_IN_BOUND_OUTSIDE
|
|
518
|
+
|
|
519
|
+
# The first sign-in call as the test writes it: Devise's `sign_in` and
|
|
520
|
+
# the `sign_in_as` Rails 8.1's authentication generator defines take
|
|
521
|
+
# different arguments. An argument the skeleton never sets is swapped
|
|
522
|
+
# for the app's own users fixture.
|
|
523
|
+
private_class_method def self.sign_in_call(content)
|
|
524
|
+
hit = RailsAiContext::Introspectors::SourceIntrospector.walk_source(content, {
|
|
525
|
+
sign_in: -> { RailsAiContext::Introspectors::Listeners::GenericMacroListener.new(:sign_in, :sign_in_as) }
|
|
526
|
+
})[:sign_in].first or return nil
|
|
527
|
+
|
|
528
|
+
call = content.byteslice(hit[:offset], hit[:end_offset] - hit[:offset]).squish
|
|
529
|
+
arg = hit[:values].first
|
|
530
|
+
return call unless arg.is_a?(String) && arg.match?(SIGN_IN_BOUND_OUTSIDE)
|
|
531
|
+
|
|
532
|
+
key = fixture_key_for("users", RailsAiContext::Payload.section(cached_context, :tests) || {})
|
|
533
|
+
return "# TODO: #{hit[:macro]} a user built from this app's own test data" unless key
|
|
534
|
+
|
|
535
|
+
call.sub(/(?<![\w@$])#{Regexp.escape(arg)}(?![\w?!])/, "users(:#{key})")
|
|
536
|
+
end
|
|
515
537
|
end
|
|
516
538
|
end
|
|
517
539
|
end
|
|
@@ -31,6 +31,7 @@ module RailsAiContext
|
|
|
31
31
|
if mounted.any?
|
|
32
32
|
mounted.each do |e|
|
|
33
33
|
line = e[:path] ? "- **#{e[:engine]}** at `#{e[:path]}`" : "- **#{e[:engine]}**"
|
|
34
|
+
line += " (`#{e[:condition]}`)" if e[:condition]
|
|
34
35
|
line += " (#{e[:category]})" if e[:category]
|
|
35
36
|
line += " - #{e[:description]}" if e[:description]
|
|
36
37
|
lines << line
|
|
@@ -56,6 +57,7 @@ module RailsAiContext
|
|
|
56
57
|
loaded.each do |e|
|
|
57
58
|
parts = []
|
|
58
59
|
parts << count_phrase(e[:route_count], "route") if e[:route_count]
|
|
60
|
+
parts << count_phrase(e[:dynamic_route_count], "redirect or lambda route") if e[:dynamic_route_count]
|
|
59
61
|
parts << count_phrase(e[:model_count], "model") if e[:model_count]
|
|
60
62
|
line = "- **#{e[:name]}**"
|
|
61
63
|
line += " - #{parts.join(', ')}" if parts.any?
|