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
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
3
5
|
module RailsAiContext
|
|
4
6
|
module Tools
|
|
5
7
|
class AnalyzeFeature < BaseTool
|
|
@@ -58,6 +60,7 @@ module RailsAiContext
|
|
|
58
60
|
discover_controllers(ctx, pattern, lines)
|
|
59
61
|
discover_routes(ctx, pattern, lines)
|
|
60
62
|
discover_services(root, pattern, lines)
|
|
63
|
+
discover_admin(root, pattern, lines)
|
|
61
64
|
discover_jobs(ctx, pattern, lines)
|
|
62
65
|
discover_views(ctx, root, pattern, lines)
|
|
63
66
|
discover_stimulus(ctx, pattern, lines)
|
|
@@ -126,6 +129,15 @@ module RailsAiContext
|
|
|
126
129
|
end
|
|
127
130
|
end
|
|
128
131
|
|
|
132
|
+
def discover_admin(root, pattern, lines)
|
|
133
|
+
matched = Introspectors::AdminResources.call(root).select { |r| feature_word_match?(r[:model], pattern) }
|
|
134
|
+
return if matched.empty?
|
|
135
|
+
|
|
136
|
+
lines << "## Admin Resources (#{matched.size})"
|
|
137
|
+
matched.each { |resource| lines << "- #{Introspectors::AdminResources.line(resource)}" }
|
|
138
|
+
lines << ""
|
|
139
|
+
end
|
|
140
|
+
|
|
129
141
|
# --- AF: Models ---
|
|
130
142
|
def discover_models(ctx, pattern, lines)
|
|
131
143
|
models = Payload.models(ctx)
|
|
@@ -147,11 +159,10 @@ module RailsAiContext
|
|
|
147
159
|
lines << "**Table:** `#{data[:table_name]}`" if data[:table_name]
|
|
148
160
|
|
|
149
161
|
table_name = data[:table_name]
|
|
150
|
-
if table_name && (
|
|
151
|
-
table_data = tables[table_name]
|
|
162
|
+
if table_name && (table_data = RailsAiContext::Payload.model_table(ctx[:schema], data))
|
|
152
163
|
if table_data&.dig(:columns)&.any?
|
|
153
164
|
cols = table_data[:columns].reject { |c| %w[id created_at updated_at].include?(c[:name]) }
|
|
154
|
-
col_strs = cols.map { |c| col_type = c[:array] ? "#{c[:type]}[]" : c[:type];
|
|
165
|
+
col_strs = cols.map { |c| col_type = c[:array] ? "#{c[:type]}[]" : c[:type]; [ c[:name], col_type ].compact.join(":") }
|
|
155
166
|
lines << "**Columns:** #{col_strs.join(', ')}" if cols.any?
|
|
156
167
|
end
|
|
157
168
|
end
|
|
@@ -376,26 +387,27 @@ module RailsAiContext
|
|
|
376
387
|
end
|
|
377
388
|
|
|
378
389
|
def discover_tests(root, pattern, lines)
|
|
379
|
-
test_dirs = [ File.join(root, "spec"), File.join(root, "test") ]
|
|
380
390
|
real_root = File.realpath(root).to_s
|
|
381
391
|
found = []
|
|
382
392
|
truncated = false
|
|
383
393
|
|
|
384
|
-
|
|
394
|
+
base = RailsAiContext::PathResolver.test_root(root)
|
|
395
|
+
real_base = File.realpath(base).to_s
|
|
396
|
+
[ File.join(base, "spec"), File.join(base, "test") ].each do |dir|
|
|
385
397
|
next unless Dir.exist?(dir)
|
|
386
|
-
suffix_glob = safe_glob(dir, "**/*_{test,spec}.rb",
|
|
398
|
+
suffix_glob = safe_glob(dir, "**/*_{test,spec}.rb", real_base).first(MAX_SCAN_FILES)
|
|
387
399
|
truncated = true if suffix_glob.size == MAX_SCAN_FILES
|
|
388
400
|
# The path, not the basename: the gap checker below matches this
|
|
389
401
|
# way for the same reason, and a spec whose feature word is a
|
|
390
402
|
# directory (billing/invoices/create_spec.rb) is the normal shape
|
|
391
403
|
# of a namespaced suite.
|
|
392
404
|
suffix_glob.each do |path|
|
|
393
|
-
found << path if feature_word_match?(relative_test_name(path,
|
|
405
|
+
found << path if feature_word_match?(relative_test_name(path, real_base), pattern)
|
|
394
406
|
end
|
|
395
|
-
prefix_glob = safe_glob(dir, "**/{test,spec}_*.rb",
|
|
407
|
+
prefix_glob = safe_glob(dir, "**/{test,spec}_*.rb", real_base).first(MAX_SCAN_FILES)
|
|
396
408
|
truncated = true if prefix_glob.size == MAX_SCAN_FILES
|
|
397
409
|
prefix_glob.each do |path|
|
|
398
|
-
found << path if feature_word_match?(relative_test_name(path,
|
|
410
|
+
found << path if feature_word_match?(relative_test_name(path, real_base), pattern)
|
|
399
411
|
end
|
|
400
412
|
end
|
|
401
413
|
found.uniq!
|
|
@@ -404,7 +416,7 @@ module RailsAiContext
|
|
|
404
416
|
header = "## Tests (#{found.size}#{truncated ? " - first #{MAX_SCAN_FILES} per glob scanned" : ""})"
|
|
405
417
|
lines << header
|
|
406
418
|
found.each do |path|
|
|
407
|
-
relative = path.
|
|
419
|
+
relative = Pathname.new(path).relative_path_from(Pathname.new(real_root)).to_s
|
|
408
420
|
source = RailsAiContext::SafeFile.read(path) or next
|
|
409
421
|
test_count = source.each_line.count do |line|
|
|
410
422
|
line.match?(/^\s*(?:test|it|specify)\s+["']/) || line.match?(/^\s*def\s+test_/)
|
|
@@ -133,7 +133,13 @@ module RailsAiContext
|
|
|
133
133
|
# Cache introspection results with TTL + fingerprint invalidation.
|
|
134
134
|
# Uses SHARED_CACHE so all tool subclasses share one introspection
|
|
135
135
|
# result instead of each caching independently.
|
|
136
|
+
# One copy per tool call: a tool reads the context from many helpers,
|
|
137
|
+
# and each copy deep-dups every section.
|
|
136
138
|
def cached_context
|
|
139
|
+
RunCache.fetch([ :tool_context ]) { shared_context_copy }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private def shared_context_copy
|
|
137
143
|
SHARED_CACHE[:mutex].synchronize do
|
|
138
144
|
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
139
145
|
ttl = RailsAiContext.configuration.cache_ttl
|
|
@@ -182,7 +188,6 @@ module RailsAiContext
|
|
|
182
188
|
session_reset!
|
|
183
189
|
AstCache.clear
|
|
184
190
|
PathResolver.clear_code_roots
|
|
185
|
-
Introspectors::TableName.clear_namespace_prefixes
|
|
186
191
|
end
|
|
187
192
|
|
|
188
193
|
# ── Session context helpers ──────────────────────────────────────
|
|
@@ -299,7 +304,7 @@ module RailsAiContext
|
|
|
299
304
|
|
|
300
305
|
# Structured not-found error with fuzzy suggestion and recovery hint.
|
|
301
306
|
# Helps AI agents self-correct without retrying blind.
|
|
302
|
-
def not_found_response(type, name, available, recovery_tool: nil)
|
|
307
|
+
def not_found_response(type, name, available, recovery_tool: nil, note: nil)
|
|
303
308
|
# Don't suggest the exact same string the user typed - that's useless
|
|
304
309
|
suggestions = find_closest_matches(name, available) - [ name ]
|
|
305
310
|
lines = [ "#{type} '#{name}' not found." ]
|
|
@@ -310,9 +315,18 @@ module RailsAiContext
|
|
|
310
315
|
end
|
|
311
316
|
lines << "Available: #{available.first(20).join(', ')}#{"..." if available.size > 20}" if available.any?
|
|
312
317
|
lines << "_Recovery: #{recovery_tool}_" if recovery_tool
|
|
318
|
+
lines << "" << note if note
|
|
313
319
|
empty_response(lines.join("\n"))
|
|
314
320
|
end
|
|
315
321
|
|
|
322
|
+
# Unbooted, a test/dummy's enclosing engine is not a views root.
|
|
323
|
+
def static_engine_views_note
|
|
324
|
+
root = rails_app.root.to_s
|
|
325
|
+
return nil unless RailsAiContext.static_tier? && RailsAiContext::PathResolver.test_root(root) != root
|
|
326
|
+
|
|
327
|
+
"_The views of the engine this app runs in are read only with the app booted._"
|
|
328
|
+
end
|
|
329
|
+
|
|
316
330
|
# A tool ran, answered honestly, and found nothing. Renders exactly
|
|
317
331
|
# like text_response - the mark rides in `_meta`, where a composing
|
|
318
332
|
# tool can read it and a reader never sees it.
|
|
@@ -438,8 +452,10 @@ module RailsAiContext
|
|
|
438
452
|
# an API-only app, or nil when the app has a view layer. Tools check
|
|
439
453
|
# this before rendering "no X found" copy so a legitimately absent
|
|
440
454
|
# surface never reads as "not built yet".
|
|
441
|
-
def api_only_note(section_label)
|
|
455
|
+
def api_only_note(section_label, dir: nil)
|
|
442
456
|
return nil unless api_only_app?
|
|
457
|
+
# rails new --api keeps app/views when it keeps Action Mailer.
|
|
458
|
+
return nil if dir && File.directory?(File.join(rails_app.root.to_s, dir))
|
|
443
459
|
|
|
444
460
|
"Not applicable: this is an API-only app (config.api_only), so #{section_label} does not exist."
|
|
445
461
|
end
|
|
@@ -594,26 +610,17 @@ module RailsAiContext
|
|
|
594
610
|
pattern.match?(/\w\z/) ? "\\b" : ""
|
|
595
611
|
end
|
|
596
612
|
|
|
597
|
-
#
|
|
598
|
-
#
|
|
613
|
+
# "self.x" asks for the class method; the file's own class answers before a nested
|
|
614
|
+
# module (a concern's ClassMethods), which answers only when the class has none.
|
|
599
615
|
def extract_method_source_from_string(source, method_name)
|
|
600
|
-
source_lines = source.lines
|
|
601
616
|
name = method_name.to_s
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
source_lines[start_idx..].each_with_index do |line, i|
|
|
611
|
-
result << line.rstrip
|
|
612
|
-
end_idx = start_idx + i
|
|
613
|
-
break if i > 0 && line.match?(/\A\s{#{def_indent}}end\b/)
|
|
614
|
-
end
|
|
615
|
-
|
|
616
|
-
{ code: result.join("\n"), start_line: start_idx + 1, end_line: end_idx + 1 }
|
|
617
|
+
scope = name.start_with?("self.") ? :class : :instance
|
|
618
|
+
bare = name.delete_prefix("self.")
|
|
619
|
+
resolver = Introspectors::ActionResolver
|
|
620
|
+
methods = resolver.methods_in(source)
|
|
621
|
+
named = methods.select { |m| m[:name] == bare && m[:scope] == scope }
|
|
622
|
+
method = resolver.own_methods(named, resolver.default_owner(source, methods)).first || named.first
|
|
623
|
+
method && resolver.body_of(source, method)
|
|
617
624
|
rescue => e
|
|
618
625
|
RailsAiContext.debug_fail(e, nil, label: "extract_method_source_from_string")
|
|
619
626
|
end
|
|
@@ -636,15 +643,16 @@ module RailsAiContext
|
|
|
636
643
|
return named if named
|
|
637
644
|
end
|
|
638
645
|
|
|
639
|
-
|
|
646
|
+
real_root = File.realpath(rails_app.root.to_s)
|
|
647
|
+
fixture_file = File.join(real_root, "test", "fixtures", "#{table}.yml")
|
|
640
648
|
return nil unless File.exist?(fixture_file)
|
|
641
649
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
650
|
+
real = safe_glob_realpath(fixture_file, real_root, real_root) or return nil
|
|
651
|
+
content = RailsAiContext::SafeFile.read(real) or return nil
|
|
652
|
+
labels = RailsAiContext::FixtureKeys.parse(content)&.keys || content.scan(/^([a-z_]\w*):/i).flatten
|
|
653
|
+
labels.find { |key| RailsAiContext::FixtureKeys.name?(key) }
|
|
654
|
+
rescue SystemCallError => e
|
|
655
|
+
RailsAiContext.debug_fail(e, nil, label: "fixture_key_for")
|
|
648
656
|
end
|
|
649
657
|
|
|
650
658
|
# Fixture set from `set_fixture_class`, then the table name, then the pluralized class
|
|
@@ -704,8 +712,9 @@ module RailsAiContext
|
|
|
704
712
|
method.to_s == RailsAiContext::Introspectors::Listeners::CallbacksListener::INLINE_BLOCK
|
|
705
713
|
end
|
|
706
714
|
|
|
715
|
+
# A constant (`AuditTrail`) is a callback object, not a method.
|
|
707
716
|
def method_name?(method)
|
|
708
|
-
method.to_s.match?(/\A\w
|
|
717
|
+
method.to_s.match?(/\A[a-z_]\w*[?!=]?\z/)
|
|
709
718
|
end
|
|
710
719
|
|
|
711
720
|
# A callback type as a reader can write it. `after_commit_on_create`
|
|
@@ -713,14 +722,28 @@ module RailsAiContext
|
|
|
713
722
|
# `after_commit on: [...]`; Rails has no macro by that name, so an
|
|
714
723
|
# agent that copies it gets a NoMethodError.
|
|
715
724
|
def callback_type_label(type)
|
|
716
|
-
event =
|
|
725
|
+
event = RailsAiContext::Introspectors::Listeners::CallbacksListener.event(type)
|
|
717
726
|
event ? "after_commit (on: :#{event})" : type.to_s
|
|
718
727
|
end
|
|
719
728
|
|
|
729
|
+
# Statically, a config that does not say how Rails orders after_commit
|
|
730
|
+
# and after_rollback leaves those lists in declaration order.
|
|
731
|
+
def commit_order_note(unread)
|
|
732
|
+
return nil if unread.empty?
|
|
733
|
+
|
|
734
|
+
"_after_commit and after_rollback for #{unread.sort.join(', ')} are in declaration order: the config does not say " \
|
|
735
|
+
"whether `run_after_transaction_callbacks_in_order_defined` is on, and when it is off Rails runs them last declared first._"
|
|
736
|
+
end
|
|
737
|
+
|
|
720
738
|
# One callback record rendered as the line the file declares. The
|
|
721
739
|
# declared macro, not the resolved type: `after_commit_on_create` is
|
|
722
740
|
# a key this gem synthesizes, not something the source says.
|
|
723
741
|
def callback_declaration(callback)
|
|
742
|
+
if callback[:skip]
|
|
743
|
+
kind, event = callback[:type].to_s.split("_", 2)
|
|
744
|
+
return "skip_callback :#{event}, :#{kind}, #{callback_target(callback[:method])}#{callback_options_tail(callback[:options])}"
|
|
745
|
+
end
|
|
746
|
+
|
|
724
747
|
name = callback[:name] || callback[:type]
|
|
725
748
|
method = callback[:method].to_s
|
|
726
749
|
target = inline_block_callback?(method) ? "do" : callback_target(method)
|
|
@@ -841,6 +864,22 @@ module RailsAiContext
|
|
|
841
864
|
|
|
842
865
|
private
|
|
843
866
|
|
|
867
|
+
# The locale and variant a view file renders for, reading the locales the app makes available.
|
|
868
|
+
def view_alternate_of(path)
|
|
869
|
+
RailsAiContext::ViewFile.alternate_of(path, RailsAiContext::RunCache.fetch([ :view_locales ]) { available_locales })
|
|
870
|
+
end
|
|
871
|
+
|
|
872
|
+
def booted_app?
|
|
873
|
+
!RailsAiContext.static_tier? && !rails_app.is_a?(RailsAiContext::StaticApp)
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
# Booted, as I18n holds them; unbooted, as the i18n section read them from config or the locale files.
|
|
877
|
+
def available_locales
|
|
878
|
+
return I18n.available_locales.map(&:to_s) if booted_app? && defined?(I18n)
|
|
879
|
+
|
|
880
|
+
Array(RailsAiContext::Payload.section(cached_context, :i18n)&.dig(:available_locales)).map(&:to_s)
|
|
881
|
+
end
|
|
882
|
+
|
|
844
883
|
# English units whatever the locale, unless the app offers no English at all.
|
|
845
884
|
def human_size(bytes)
|
|
846
885
|
ActiveSupport::NumberHelper.number_to_human_size(bytes.to_i, locale: :en)
|
|
@@ -363,7 +363,7 @@ module RailsAiContext
|
|
|
363
363
|
names += Array(model_data[:scopes]).filter_map { |s| s.is_a?(Hash) ? (s[:name] || s["name"])&.to_s : s.to_s }
|
|
364
364
|
|
|
365
365
|
table = model_data[:table_name].to_s
|
|
366
|
-
columns =
|
|
366
|
+
columns = RailsAiContext::Payload.schema_table(cached_context[:schema], table)&.dig(:columns)
|
|
367
367
|
names += Array(columns).filter_map { |c| (c[:name] || c["name"])&.to_s }
|
|
368
368
|
|
|
369
369
|
names.uniq
|
|
@@ -121,7 +121,7 @@ module RailsAiContext
|
|
|
121
121
|
# the subject's existing test, so the answer can point there instead of a new file.
|
|
122
122
|
def test_path(base, kind, stem, fallback_dir, fallback_suffix)
|
|
123
123
|
framework = RailsAiContext::TestFramework
|
|
124
|
-
root = rails_app.root.to_s
|
|
124
|
+
root = RailsAiContext::PathResolver.test_root(rails_app.root.to_s)
|
|
125
125
|
dir, suffix = framework.layout(root, base, kind, framework.subject_stems(cached_context, kind))
|
|
126
126
|
dir ||= fallback_dir
|
|
127
127
|
suffix ||= fallback_suffix
|
|
@@ -131,7 +131,9 @@ module RailsAiContext
|
|
|
131
131
|
full = File.expand_path(rel, root)
|
|
132
132
|
RailsAiContext::SafePath.contained?(full, root) && File.file?(full)
|
|
133
133
|
end
|
|
134
|
-
|
|
134
|
+
app_root = rails_app.root.to_s
|
|
135
|
+
[ RailsAiContext::PathResolver.suite_relative(app_root, "#{dir}/#{stem}#{suffix}"),
|
|
136
|
+
existing && RailsAiContext::PathResolver.suite_relative(app_root, existing), dir ]
|
|
135
137
|
rescue => e
|
|
136
138
|
RailsAiContext.debug_fail(e, [ "#{fallback_dir}/#{stem}#{fallback_suffix}", nil, fallback_dir ], label: "test_path")
|
|
137
139
|
end
|
|
@@ -643,7 +645,7 @@ module RailsAiContext
|
|
|
643
645
|
file_path, existing, dir = test_path("test", :controller, snake, "test/controllers", "_controller_test.rb")
|
|
644
646
|
return existing_test_response(existing) if existing
|
|
645
647
|
|
|
646
|
-
style = RailsAiContext::TestFramework.test_style(rails_app.root, dir)
|
|
648
|
+
style = RailsAiContext::TestFramework.test_style(RailsAiContext::PathResolver.test_root(rails_app.root.to_s), dir)
|
|
647
649
|
res = res.merge(test_case: style[:test_case] == true)
|
|
648
650
|
res[:factory] = find_factory_name(res[:model], tests_data) if style[:factories] && res[:model]
|
|
649
651
|
res[:parents] = route_parents(routes, res, tests_data, ref: "@") if res[:factory]
|
|
@@ -1392,14 +1394,12 @@ module RailsAiContext
|
|
|
1392
1394
|
end
|
|
1393
1395
|
|
|
1394
1396
|
def schema_content_columns(table)
|
|
1395
|
-
|
|
1396
|
-
cols = (tables[table] || tables[table.to_sym] || {})[:columns] || []
|
|
1397
|
+
cols = (RailsAiContext::Payload.schema_table(cached_context[:schema], table) || {})[:columns] || []
|
|
1397
1398
|
cols.map { |c| c[:name].to_s } - %w[id created_at updated_at]
|
|
1398
1399
|
end
|
|
1399
1400
|
|
|
1400
1401
|
def schema_column_type(table, column)
|
|
1401
|
-
|
|
1402
|
-
cols = (tables[table] || tables[table.to_sym] || {})[:columns] || []
|
|
1402
|
+
cols = (RailsAiContext::Payload.schema_table(cached_context[:schema], table) || {})[:columns] || []
|
|
1403
1403
|
col = cols.find { |c| c[:name].to_s == column }
|
|
1404
1404
|
(col && col[:type]).to_s
|
|
1405
1405
|
end
|
|
@@ -1408,8 +1408,7 @@ module RailsAiContext
|
|
|
1408
1408
|
# own value for one of these raises RecordNotUnique even when the
|
|
1409
1409
|
# model declares no uniqueness validation.
|
|
1410
1410
|
def unique_index_columns(table)
|
|
1411
|
-
|
|
1412
|
-
indexes = (tables[table] || tables[table.to_sym] || {})[:indexes] || []
|
|
1411
|
+
indexes = (RailsAiContext::Payload.schema_table(cached_context[:schema], table) || {})[:indexes] || []
|
|
1413
1412
|
indexes.select { |i| i[:unique] }.flat_map { |i| Array(i[:columns]).map(&:to_s) }
|
|
1414
1413
|
end
|
|
1415
1414
|
|
|
@@ -4,7 +4,7 @@ module RailsAiContext
|
|
|
4
4
|
module Tools
|
|
5
5
|
class GetActiveSupport < BaseTool
|
|
6
6
|
tool_name "rails_get_active_support"
|
|
7
|
-
description "Get ActiveSupport surface: concern modules across app/**/concerns, registered deprecators, MessageVerifier/MessageEncryptor usage, tagged logging, subscribed on_load hooks, and cache store. " \
|
|
7
|
+
description "Get ActiveSupport surface: concern modules across app/**/concerns, registered deprecators, MessageVerifier/MessageEncryptor usage, the ActiveSupport::Notifications events the app subscribes to and where, tagged logging, subscribed on_load hooks, and cache store. " \
|
|
8
8
|
"Use when: checking framework-level wiring, finding where crypto helpers are used, or understanding boot-time hooks."
|
|
9
9
|
|
|
10
10
|
input_schema(properties: {})
|
|
@@ -12,7 +12,7 @@ module RailsAiContext
|
|
|
12
12
|
guide_row(
|
|
13
13
|
order: 44,
|
|
14
14
|
mcp: "rails_get_active_support",
|
|
15
|
-
summary: "Concerns registry, deprecators, MessageVerifier usage, on_load hooks, cache store"
|
|
15
|
+
summary: "Concerns registry, deprecators, MessageVerifier usage, notification subscriptions, on_load hooks, cache store"
|
|
16
16
|
)
|
|
17
17
|
|
|
18
18
|
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
@@ -24,6 +24,7 @@ module RailsAiContext
|
|
|
24
24
|
render_concerns(lines, active_support[:concerns])
|
|
25
25
|
render_simple_list(lines, "Deprecators", active_support[:deprecators])
|
|
26
26
|
render_verifier_usage(lines, active_support[:message_verifier_usage])
|
|
27
|
+
render_subscriptions(lines, active_support[:notification_subscriptions])
|
|
27
28
|
render_tagged_logging(lines, active_support[:tagged_logging])
|
|
28
29
|
render_on_load_hooks(lines, active_support[:on_load_hooks])
|
|
29
30
|
render_cache(lines, active_support[:cache_usage])
|
|
@@ -109,12 +110,26 @@ module RailsAiContext
|
|
|
109
110
|
end
|
|
110
111
|
end
|
|
111
112
|
|
|
113
|
+
def render_subscriptions(lines, subscriptions)
|
|
114
|
+
subscriptions = Array(subscriptions)
|
|
115
|
+
return if subscriptions.empty?
|
|
116
|
+
|
|
117
|
+
lines << "" << "## Notification Subscriptions (#{subscriptions.size})"
|
|
118
|
+
subscriptions.each { |s| lines << "- `#{s[:event]}` - #{s[:via]} (`#{s[:file]}:#{s[:line]}`)" }
|
|
119
|
+
end
|
|
120
|
+
|
|
112
121
|
def render_tagged_logging(lines, tagged)
|
|
113
122
|
tagged = tagged || {}
|
|
114
|
-
|
|
123
|
+
tags = tagged[:tags]
|
|
124
|
+
unavailable = tags[:unavailable] if tags.is_a?(Hash)
|
|
125
|
+
return unless tagged[:configured] || unavailable
|
|
115
126
|
|
|
116
127
|
lines << "" << "## Tagged Logging"
|
|
117
|
-
|
|
128
|
+
if unavailable
|
|
129
|
+
lines << "- **Tags:** #{RailsAiContext::Confidence.unavailable(unavailable)}"
|
|
130
|
+
elsif tags&.any?
|
|
131
|
+
lines << "- **Tags:** #{Array(tags).join(', ')}"
|
|
132
|
+
end
|
|
118
133
|
lines << "- **Configured in:** `#{tagged[:initializer]}`" if tagged[:initializer]
|
|
119
134
|
end
|
|
120
135
|
|
|
@@ -52,6 +52,17 @@ module RailsAiContext
|
|
|
52
52
|
end
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
# What serializes responses, or nil when the app has no serializer layer.
|
|
56
|
+
def self.serialization_found(data)
|
|
57
|
+
return serialization_label(data[:serializers]) if serializers?(data)
|
|
58
|
+
|
|
59
|
+
dirs = Array(data.dig(:serializers, :serializer_dirs))
|
|
60
|
+
return if dirs.empty?
|
|
61
|
+
|
|
62
|
+
"no serializer framework detected; the app has its own layer in " +
|
|
63
|
+
dirs.map { |dir| "#{dir[:path]} (#{count_phrase(dir[:files], "file")})" }.join(", ")
|
|
64
|
+
end
|
|
65
|
+
|
|
55
66
|
class << self
|
|
56
67
|
private
|
|
57
68
|
|
|
@@ -103,7 +114,7 @@ module RailsAiContext
|
|
|
103
114
|
source = dirs.any? ? dirs.join(", ") : "app/controllers/api/"
|
|
104
115
|
return "#{data[:api_versioning].join(', ')} (#{source})"
|
|
105
116
|
end
|
|
106
|
-
"not detected (no api/v*
|
|
117
|
+
"not detected (no api/v* directory with Ruby code under app/controllers, app/api or lib/api)"
|
|
107
118
|
end
|
|
108
119
|
|
|
109
120
|
def rate_limiting_line(data)
|
|
@@ -171,7 +182,7 @@ module RailsAiContext
|
|
|
171
182
|
def serializers?(data)
|
|
172
183
|
serializers = data[:serializers]
|
|
173
184
|
return false unless serializers.is_a?(Hash)
|
|
174
|
-
serializers[:jbuilder].to_i > 0 || (serializers[:serializer_classes].is_a?(Array) && serializers[:serializer_classes].any?)
|
|
185
|
+
serializers[:jbuilder].to_i > 0 || serializers[:rabl].to_i > 0 || (serializers[:serializer_classes].is_a?(Array) && serializers[:serializer_classes].any?)
|
|
175
186
|
end
|
|
176
187
|
|
|
177
188
|
def versioning?(data)
|
|
@@ -186,6 +197,8 @@ module RailsAiContext
|
|
|
186
197
|
parts = []
|
|
187
198
|
jbuilder = serializers[:jbuilder].to_i
|
|
188
199
|
parts << "Jbuilder (#{count_phrase(jbuilder, "template")})" if jbuilder > 0
|
|
200
|
+
rabl = serializers[:rabl].to_i
|
|
201
|
+
parts << "RABL (#{count_phrase(rabl, "template")})" if rabl > 0
|
|
189
202
|
|
|
190
203
|
classes = serializers[:serializer_classes]
|
|
191
204
|
if classes.is_a?(Array) && classes.any?
|
|
@@ -198,15 +211,8 @@ module RailsAiContext
|
|
|
198
211
|
end
|
|
199
212
|
|
|
200
213
|
def serialization_line(data)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
dirs = Array(data.dig(:serializers, :serializer_dirs))
|
|
204
|
-
if dirs.any?
|
|
205
|
-
return "no serializer framework detected; the app has its own layer in " +
|
|
206
|
-
dirs.map { |dir| "#{dir[:path]} (#{count_phrase(dir[:files], "file")})" }.join(", ")
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
"none detected (no .jbuilder templates, no app/serializers classes)"
|
|
214
|
+
serialization_found(data) ||
|
|
215
|
+
"none detected (no .jbuilder or .rabl templates, no serializer classes in app/serializers, app/blueprints or app/resources)"
|
|
210
216
|
end
|
|
211
217
|
|
|
212
218
|
def graphql_label(graphql)
|
|
@@ -36,12 +36,14 @@ module RailsAiContext
|
|
|
36
36
|
lines << " - [error: #{l[:error]}]" if l[:error]
|
|
37
37
|
lines << " - collapsed: #{l[:collapsed].join(', ')}" if l[:collapsed]&.any?
|
|
38
38
|
lines << " - ignored: #{l[:ignored].join(', ')}" if l[:ignored]&.any?
|
|
39
|
+
lines << " - not eager loaded: #{l[:not_eager_loaded].join(', ')}" if l[:not_eager_loaded]&.any?
|
|
39
40
|
end
|
|
40
41
|
end
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
render_paths(lines, "Autoload
|
|
44
|
-
render_paths(lines, "
|
|
43
|
+
namespaces = autoload[:autoload_namespaces] || {}
|
|
44
|
+
render_paths(lines, "Autoload paths", autoload[:autoload_paths], namespaces)
|
|
45
|
+
render_paths(lines, "Autoload-once paths", autoload[:autoload_once_paths], namespaces)
|
|
46
|
+
render_paths(lines, "Eager-load paths", autoload[:eager_load_paths], namespaces)
|
|
45
47
|
|
|
46
48
|
inflections = autoload[:custom_inflections] || []
|
|
47
49
|
if inflections.any?
|
|
@@ -59,12 +61,15 @@ module RailsAiContext
|
|
|
59
61
|
# Custom autoload paths (beyond Rails' defaults) matter most, but the
|
|
60
62
|
# full list is what AI needs for "where can this constant live" - cap
|
|
61
63
|
# long framework lists rather than hiding them.
|
|
62
|
-
def render_paths(lines, title, paths)
|
|
64
|
+
def render_paths(lines, title, paths, namespaces)
|
|
63
65
|
paths = Array(paths)
|
|
64
66
|
return if paths.empty?
|
|
65
67
|
|
|
66
68
|
lines << "" << "## #{title} (#{paths.size})"
|
|
67
|
-
paths.first(MAX_PATHS_SHOWN).each
|
|
69
|
+
paths.first(MAX_PATHS_SHOWN).each do |p|
|
|
70
|
+
namespace = namespaces[p] || namespaces[p.to_sym]
|
|
71
|
+
lines << "- `#{p}`#{" (namespace `#{namespace}`)" if namespace}"
|
|
72
|
+
end
|
|
68
73
|
lines << "_... #{paths.size - MAX_PATHS_SHOWN} more_" if paths.size > MAX_PATHS_SHOWN
|
|
69
74
|
end
|
|
70
75
|
end
|
|
@@ -7,7 +7,8 @@ module RailsAiContext
|
|
|
7
7
|
description "Get ActiveRecord model callbacks grouped by type, in Rails event order: before/after/around for validation, save, create, update, destroy. " \
|
|
8
8
|
"Use when: understanding side effects, debugging callback chains, or checking what happens on save/create/destroy. " \
|
|
9
9
|
"Specify model:\"User\" for one model's callbacks. detail:\"full\" includes callback method source code. " \
|
|
10
|
-
"The list is what the model
|
|
10
|
+
"The list is what the model, its base classes and its concerns declare, and within one type the order is the order Rails runs them, " \
|
|
11
|
+
"a before_ or around_ callback with prepend: true first, and after_commit in the order the app's setting gives."
|
|
11
12
|
|
|
12
13
|
# `after_create_commit :x` and `after_commit :y, on: :create` run at the
|
|
13
14
|
# same point and keep their own declared spellings, so the two sit
|
|
@@ -87,11 +88,18 @@ module RailsAiContext
|
|
|
87
88
|
|
|
88
89
|
private_class_method def self.format_model_callbacks(name, data, detail, ctx)
|
|
89
90
|
callbacks = data[:callbacks] || {}
|
|
90
|
-
|
|
91
|
+
association_lines = association_callback_lines(data)
|
|
92
|
+
if callbacks.empty? && association_lines.empty?
|
|
91
93
|
return "# #{name}\n\nNo callbacks defined.\n\n_Next: `rails_get_model_details(model:\"#{name}\")` for full model detail._"
|
|
92
94
|
end
|
|
93
95
|
|
|
94
96
|
lines = [ "# #{name} - Callbacks", "" ]
|
|
97
|
+
if callbacks.empty?
|
|
98
|
+
lines << "No record callbacks defined."
|
|
99
|
+
lines.concat(association_section(association_lines))
|
|
100
|
+
lines << "" << "_Next: `rails_get_model_details(model:\"#{name}\")` for associations and validations_"
|
|
101
|
+
return lines.join("\n")
|
|
102
|
+
end
|
|
95
103
|
|
|
96
104
|
# Organize callbacks by type, in Rails event order
|
|
97
105
|
ordered = order_callbacks(callbacks)
|
|
@@ -126,6 +134,9 @@ module RailsAiContext
|
|
|
126
134
|
end
|
|
127
135
|
end
|
|
128
136
|
|
|
137
|
+
note = commit_order_note([ name ]) if data[:commit_order_unread]
|
|
138
|
+
lines << "" << note if note
|
|
139
|
+
|
|
129
140
|
# Which concern declared what. The callbacks themselves are already in
|
|
130
141
|
# the execution-order list above, with their bodies at detail:full, so
|
|
131
142
|
# repeating either here prints the same declaration twice.
|
|
@@ -139,6 +150,8 @@ module RailsAiContext
|
|
|
139
150
|
end
|
|
140
151
|
end
|
|
141
152
|
|
|
153
|
+
lines.concat(association_section(association_lines))
|
|
154
|
+
|
|
142
155
|
# Cross-reference hints
|
|
143
156
|
lines << ""
|
|
144
157
|
lines << "_Next: `rails_get_model_details(model:\"#{name}\")` for associations and validations"
|
|
@@ -147,40 +160,74 @@ module RailsAiContext
|
|
|
147
160
|
lines.join("\n")
|
|
148
161
|
end
|
|
149
162
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
163
|
+
ASSOCIATION_CALLBACKS = %w[before_add after_add before_remove after_remove].freeze
|
|
164
|
+
|
|
165
|
+
private_class_method def self.association_section(association_lines)
|
|
166
|
+
return [] if association_lines.empty?
|
|
167
|
+
|
|
168
|
+
[ "", "## Association callbacks", "_Run when a record is added to or removed from the collection:_", *association_lines ]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private_class_method def self.association_callback_lines(data)
|
|
172
|
+
hooked_associations(data).map do |a, hooks|
|
|
173
|
+
"- **#{a[:name]}** #{hooks.map { |hook, target| "#{hook} → #{target}" }.join(', ')}"
|
|
154
174
|
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
private_class_method def self.association_list(data)
|
|
178
|
+
hooked = association_callback_lines(data)
|
|
179
|
+
hooked.any? ? [ "_Association callbacks:_", *hooked ] : []
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private_class_method def self.hooked_associations(data)
|
|
183
|
+
Array(data[:associations]).filter_map do |a|
|
|
184
|
+
hooks = (a[:declared_options] || {}).slice(*ASSOCIATION_CALLBACKS)
|
|
185
|
+
[ a, hooks ] if hooks.any?
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private_class_method def self.list_all_callbacks(models, detail, ctx)
|
|
190
|
+
models_with_callbacks = models.filter_map do |name, data|
|
|
191
|
+
next unless data.is_a?(Hash) && !data[:error]
|
|
192
|
+
|
|
193
|
+
data = data.merge(callbacks: data[:callbacks].is_a?(Hash) ? data[:callbacks] : {})
|
|
194
|
+
[ name, data ] if data[:callbacks].any? || hooked_associations(data).any?
|
|
195
|
+
end.to_h
|
|
155
196
|
|
|
156
197
|
if models_with_callbacks.empty?
|
|
157
198
|
return text_response("No models with callbacks found.")
|
|
158
199
|
end
|
|
159
200
|
|
|
160
201
|
lines = [ "# Model Callbacks (#{count_phrase(models_with_callbacks.size, "model")})", "" ]
|
|
202
|
+
# By name within a count, so a model gaining a callback moves only its own section.
|
|
203
|
+
by_count = models_with_callbacks.sort_by { |name, data| [ -data[:callbacks].values.flatten.size, name.to_s ] }
|
|
161
204
|
|
|
162
205
|
case detail
|
|
163
206
|
when "summary"
|
|
164
|
-
|
|
207
|
+
by_count.each do |name, data|
|
|
165
208
|
total = data[:callbacks].values.flatten.size
|
|
166
209
|
types = data[:callbacks].keys.map { |t| callback_type_label(t) }.join(", ")
|
|
167
|
-
|
|
210
|
+
parts = []
|
|
211
|
+
parts << "#{count_phrase(total, "callback")} (#{types})" if total.positive?
|
|
212
|
+
hooked = hooked_associations(data).map { |a, _| a[:name] }
|
|
213
|
+
parts << "association callbacks on #{hooked.join(", ")}" if hooked.any?
|
|
214
|
+
lines << "- **#{name}** - #{parts.join("; ")}"
|
|
168
215
|
end
|
|
169
216
|
lines << "" << "_Use `model:\"Name\"` for callbacks by type._"
|
|
170
217
|
|
|
171
218
|
when "standard"
|
|
172
|
-
|
|
219
|
+
by_count.each do |name, data|
|
|
173
220
|
ordered = order_callbacks(data[:callbacks])
|
|
174
221
|
lines << "## #{name}"
|
|
175
222
|
ordered.each do |type, methods|
|
|
176
223
|
lines << "- **#{callback_type_label(type)}** → #{format_targets(methods, data, type)}"
|
|
177
224
|
end
|
|
178
|
-
lines << ""
|
|
225
|
+
lines.concat(association_list(data)) << ""
|
|
179
226
|
end
|
|
180
227
|
lines << "_Use `model:\"Name\"` with `detail:\"full\"` for callback source code._"
|
|
181
228
|
|
|
182
229
|
when "full"
|
|
183
|
-
|
|
230
|
+
by_count.each do |name, data|
|
|
184
231
|
ordered = order_callbacks(data[:callbacks])
|
|
185
232
|
lines << "## #{name}"
|
|
186
233
|
ordered.each do |type, methods|
|
|
@@ -194,10 +241,13 @@ module RailsAiContext
|
|
|
194
241
|
end
|
|
195
242
|
end
|
|
196
243
|
end
|
|
197
|
-
lines << ""
|
|
244
|
+
lines.concat(association_list(data)) << ""
|
|
198
245
|
end
|
|
199
246
|
end
|
|
200
247
|
|
|
248
|
+
unread = models_with_callbacks.select { |_, data| data[:commit_order_unread] }.keys
|
|
249
|
+
note = commit_order_note(unread) unless detail == "summary"
|
|
250
|
+
lines << "" << note if note
|
|
201
251
|
text_response(lines.join("\n"))
|
|
202
252
|
end
|
|
203
253
|
|
|
@@ -253,11 +303,12 @@ module RailsAiContext
|
|
|
253
303
|
# that is not the model file.
|
|
254
304
|
private_class_method def self.source_location(source)
|
|
255
305
|
prefix = source[:from_concern] ? "#{source[:from_concern]} " : ""
|
|
306
|
+
return "#{prefix}line #{source[:start_line]}" if source[:start_line] == source[:end_line]
|
|
307
|
+
|
|
256
308
|
"#{prefix}lines #{source[:start_line]}-#{source[:end_line]}"
|
|
257
309
|
end
|
|
258
310
|
|
|
259
|
-
# The conditions arrive in step with the names, one entry per
|
|
260
|
-
# occurrence, so the same method declared twice keeps its own condition.
|
|
311
|
+
# The conditions arrive in step with the names, one entry per callback.
|
|
261
312
|
private_class_method def self.format_targets(methods, data = nil, type = nil)
|
|
262
313
|
per_type = Array(((data && data[:callback_conditions]) || {})[type.to_s])
|
|
263
314
|
methods.each_with_index.map do |m, i|
|