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
|
@@ -52,7 +52,7 @@ module RailsAiContext
|
|
|
52
52
|
|
|
53
53
|
unloadable_models.each { |class_name, details| result[class_name] ||= details }
|
|
54
54
|
|
|
55
|
-
result
|
|
55
|
+
tag_tenancy(result)
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
# Models the static scan finds, abstract bases left out. excluded_models is the app's
|
|
@@ -80,7 +80,7 @@ module RailsAiContext
|
|
|
80
80
|
candidates = static_candidates
|
|
81
81
|
sti_parents = candidates.keys.to_h { |name| [ name, sti_parent(name, candidates, []) ] }
|
|
82
82
|
bases = declared_bases(candidates)
|
|
83
|
-
candidates.each_with_object({}) do |(class_name, candidate), result|
|
|
83
|
+
models = candidates.each_with_object({}) do |(class_name, candidate), result|
|
|
84
84
|
# Hidden from the listing, kept in the walk: its children still
|
|
85
85
|
# inherit its table and its declarations.
|
|
86
86
|
next if config.excluded_models.include?(class_name)
|
|
@@ -112,6 +112,7 @@ module RailsAiContext
|
|
|
112
112
|
table = resolve_table_name(class_name, candidates)
|
|
113
113
|
result[class_name] = static_model_details(candidate[:path], class_name, file: candidate[:file],
|
|
114
114
|
table_name: table,
|
|
115
|
+
primary_key: resolve_primary_key(class_name, candidates),
|
|
115
116
|
inherited_from: declaring_bases(class_name, candidates),
|
|
116
117
|
sti: static_sti_info(class_name, sti_parents),
|
|
117
118
|
parent_model: sti_parents[class_name])
|
|
@@ -123,14 +124,38 @@ module RailsAiContext
|
|
|
123
124
|
# not have.
|
|
124
125
|
result[class_name] = { error: e.message, file: candidate[:file], table_name: table }.compact
|
|
125
126
|
end
|
|
127
|
+
tag_tenancy(models)
|
|
126
128
|
end
|
|
127
129
|
|
|
128
130
|
private
|
|
129
131
|
|
|
132
|
+
# Apartment keeps an excluded model, and an STI subclass sharing its table, in the
|
|
133
|
+
# shared schema; every other ActiveRecord model has a table in each tenant's schema.
|
|
134
|
+
def tag_tenancy(models)
|
|
135
|
+
config = ApartmentConfig.read(app.root) or return models
|
|
136
|
+
excluded = config[:excluded_models]
|
|
137
|
+
models.each do |name, data|
|
|
138
|
+
next if data[:error] || data[:mongoid]
|
|
139
|
+
|
|
140
|
+
scope = if excluded.nil? then "unknown"
|
|
141
|
+
elsif apartment_shared?(name, models, excluded) then "shared"
|
|
142
|
+
else "per_tenant"
|
|
143
|
+
end
|
|
144
|
+
data[:tenancy] = { gem: "apartment", scope: scope, declared_in: config[:file], excluded_models: config[:excluded_models_source] }.compact
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def apartment_shared?(name, models, excluded, seen = [])
|
|
149
|
+
return true if excluded.include?(name)
|
|
150
|
+
return false if seen.include?(name)
|
|
151
|
+
|
|
152
|
+
parent = models.dig(name, :sti, :sti_parent)
|
|
153
|
+
parent ? apartment_shared?(parent, models, excluded, seen + [ name ]) : false
|
|
154
|
+
end
|
|
155
|
+
|
|
130
156
|
# The same shape the booted tier reports under :sti, off the chain the
|
|
131
|
-
# static tier already resolves to share the base's table.
|
|
132
|
-
#
|
|
133
|
-
# to be read to name it.
|
|
157
|
+
# static tier already resolves to share the base's table. The caller
|
|
158
|
+
# drops it when the schema shows that table has no type column.
|
|
134
159
|
def static_sti_info(class_name, sti_parents)
|
|
135
160
|
parent = sti_parents[class_name]
|
|
136
161
|
children = sti_parents.select { |_name, other_parent| other_parent == class_name }.keys.sort
|
|
@@ -144,6 +169,18 @@ module RailsAiContext
|
|
|
144
169
|
}.compact
|
|
145
170
|
end
|
|
146
171
|
|
|
172
|
+
# Rails runs no STI on a table without the type column; a table the dump does not hold is not known to lack it.
|
|
173
|
+
def lacks_column?(table, column)
|
|
174
|
+
reader = SchemaReader.for(app.root)
|
|
175
|
+
reader.tables.key?(table) && column != RailsAiContext::Confidence::INFERRED && !reader.column?(table, column)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Rails' default column, the literal a class sets, or nil where `inheritance_column = nil` turns STI off.
|
|
179
|
+
def static_type_column(source_data)
|
|
180
|
+
setting = Array(source_data[:macros]).select { |m| m[:macro] == :model_setting && m[:setting] == "inheritance_column" }.last
|
|
181
|
+
setting ? setting[:literal].presence : "type"
|
|
182
|
+
end
|
|
183
|
+
|
|
147
184
|
# A model file the app cannot load leaves its class out of reflection,
|
|
148
185
|
# and leaving it out here answers that the model does not exist and that
|
|
149
186
|
# its table has no model at all. The file is a fact the source walk
|
|
@@ -155,6 +192,9 @@ module RailsAiContext
|
|
|
155
192
|
candidates = static_candidates
|
|
156
193
|
@unloadable.each_with_object({}) do |(class_name, error), entries|
|
|
157
194
|
candidate = candidates[class_name]
|
|
195
|
+
# Other roots hold generators and gem subclasses too; static lists one only as a model.
|
|
196
|
+
next if @outside_model_dirs&.include?(class_name) && !static_model?(class_name, candidates)
|
|
197
|
+
|
|
158
198
|
entries[class_name] = {
|
|
159
199
|
error: error,
|
|
160
200
|
file: candidate&.dig(:file),
|
|
@@ -172,7 +212,7 @@ module RailsAiContext
|
|
|
172
212
|
# process cannot stat is an error entry rather than one that quietly
|
|
173
213
|
# is not there. The count is an answer too.
|
|
174
214
|
def static_candidates
|
|
175
|
-
SourceScan.
|
|
215
|
+
SourceScan.model_paths(app.root).each_with_object({}) do |record, found|
|
|
176
216
|
begin
|
|
177
217
|
source = model_source(record.path) if File.size(record.path) <= RailsAiContext.configuration.max_file_size
|
|
178
218
|
if source.nil?
|
|
@@ -182,7 +222,7 @@ module RailsAiContext
|
|
|
182
222
|
|
|
183
223
|
next if mixin_path?(record.path_name.underscore, source)
|
|
184
224
|
|
|
185
|
-
declarations = DeclaredConstant.declarations(source)
|
|
225
|
+
declarations = DeclaredConstant.declarations(source, path_name: record.path_name)
|
|
186
226
|
declaration = DeclaredConstant.declaration_for(declarations, record.path_name)
|
|
187
227
|
class_name = declaration&.name || record.path_name
|
|
188
228
|
# Two files can declare one class (an app reopening a model to add methods);
|
|
@@ -197,6 +237,7 @@ module RailsAiContext
|
|
|
197
237
|
path: record.path,
|
|
198
238
|
file: record.file,
|
|
199
239
|
superclass: declaration&.superclass,
|
|
240
|
+
superclass_nesting: declaration&.nesting,
|
|
200
241
|
abstract: abstract_class?(source)
|
|
201
242
|
}.merge(TableName.declarations(source, class_name, app.root))
|
|
202
243
|
rescue => e
|
|
@@ -244,6 +285,10 @@ module RailsAiContext
|
|
|
244
285
|
end
|
|
245
286
|
end
|
|
246
287
|
|
|
288
|
+
def static_model?(class_name, candidates)
|
|
289
|
+
candidates.key?(class_name) && !candidates.dig(class_name, :abstract) && model_class?(class_name, candidates)
|
|
290
|
+
end
|
|
291
|
+
|
|
247
292
|
def model_class?(class_name, candidates, seen = [])
|
|
248
293
|
return false if seen.include?(class_name)
|
|
249
294
|
# A base under app/models that nobody can read is taken at its word:
|
|
@@ -271,7 +316,8 @@ module RailsAiContext
|
|
|
271
316
|
# so `Admin::Report < Post` means the top-level Post unless Admin
|
|
272
317
|
# declares one.
|
|
273
318
|
def resolve_superclass(name, from, candidates)
|
|
274
|
-
|
|
319
|
+
nesting = candidates.dig(from, :superclass_nesting)
|
|
320
|
+
SuperclassChain.resolve_in_scope(from, name, nesting: nesting) { |qualified| qualified if candidates.key?(qualified) }
|
|
275
321
|
end
|
|
276
322
|
|
|
277
323
|
# Rails' own order: what the class assigns itself wins, an STI child
|
|
@@ -290,10 +336,43 @@ module RailsAiContext
|
|
|
290
336
|
|
|
291
337
|
[ namespace_affix(class_name, candidates, :table_name_prefix),
|
|
292
338
|
contained_prefix(class_name, candidates, seen),
|
|
293
|
-
TableName.stem(candidate[:path]),
|
|
339
|
+
TableName.stem(candidate[:path], pluralize_tables?(class_name, candidates)),
|
|
294
340
|
namespace_affix(class_name, candidates, :table_name_suffix) ].join
|
|
295
341
|
end
|
|
296
342
|
|
|
343
|
+
# A key the class does not assign is read through a new connection, which an
|
|
344
|
+
# unreachable host can hold for a full connect timeout per model; unconnected, read the source.
|
|
345
|
+
def model_primary_key(model)
|
|
346
|
+
return model.primary_key if model.connected?
|
|
347
|
+
|
|
348
|
+
base = model.respond_to?(:base_class) ? model.base_class : model
|
|
349
|
+
path = model_source_path(base)
|
|
350
|
+
path && TableName.declarations(model_source(path), base.name, app.root)[:primary_key]
|
|
351
|
+
rescue StandardError => e
|
|
352
|
+
RailsAiContext.debug_fail(e, nil, label: "model_primary_key")
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Rails reads an STI child's key off its base class; an abstract base's assignment reaches no model.
|
|
356
|
+
def resolve_primary_key(class_name, candidates, seen = [])
|
|
357
|
+
own = candidates.dig(class_name, :primary_key)
|
|
358
|
+
return own if own
|
|
359
|
+
|
|
360
|
+
parent = sti_parent(class_name, candidates, seen)
|
|
361
|
+
parent && resolve_primary_key(parent, candidates, seen + [ class_name ])
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# A class attribute: the class's own assignment, else its superclass chain's, else the app's.
|
|
365
|
+
def pluralize_tables?(class_name, candidates, seen = [])
|
|
366
|
+
own = candidates.dig(class_name, :pluralize_table_names)
|
|
367
|
+
return own unless own.nil?
|
|
368
|
+
|
|
369
|
+
parent = candidates.dig(class_name, :superclass)
|
|
370
|
+
resolved = parent && !seen.include?(class_name) && resolve_superclass(parent, class_name, candidates)
|
|
371
|
+
return pluralize_tables?(resolved, candidates, seen + [ class_name ]) if resolved && candidates.key?(resolved)
|
|
372
|
+
|
|
373
|
+
ActiveRecordSettings.for(app.root)[:pluralize_table_names] != false
|
|
374
|
+
end
|
|
375
|
+
|
|
297
376
|
# compute_table_name (7.0 and 8.1): a class nested in a concrete model
|
|
298
377
|
# takes that model's singular table as a prefix (Project::Phase, project_phases).
|
|
299
378
|
def contained_prefix(class_name, candidates, seen)
|
|
@@ -302,7 +381,9 @@ module RailsAiContext
|
|
|
302
381
|
return "" unless model_class?(parent, candidates)
|
|
303
382
|
|
|
304
383
|
table = resolve_table_name(parent, candidates, seen + [ class_name ])
|
|
305
|
-
|
|
384
|
+
return "" if table.to_s.empty?
|
|
385
|
+
|
|
386
|
+
"#{pluralize_tables?(parent, candidates) ? table.singularize : table}_"
|
|
306
387
|
end
|
|
307
388
|
|
|
308
389
|
# Every class this one inherits declarations from: the superclass chain
|
|
@@ -383,7 +464,7 @@ module RailsAiContext
|
|
|
383
464
|
|
|
384
465
|
scope.pop
|
|
385
466
|
end
|
|
386
|
-
candidates.dig(class_name, key) ||
|
|
467
|
+
candidates.dig(class_name, key) || ActiveRecordSettings.for(app.root)[key] || ""
|
|
387
468
|
end
|
|
388
469
|
|
|
389
470
|
def isolated_prefixes
|
|
@@ -411,7 +492,7 @@ module RailsAiContext
|
|
|
411
492
|
return false unless segments.include?("concerns")
|
|
412
493
|
return true if segments.first == "concerns"
|
|
413
494
|
|
|
414
|
-
!DeclaredConstant.declares_class?(source)
|
|
495
|
+
!DeclaredConstant.declares_class?(source, path_name: relative.camelize)
|
|
415
496
|
end
|
|
416
497
|
|
|
417
498
|
# A class whose file is inside an installed gem (PaperTrail::Version) is
|
|
@@ -426,24 +507,13 @@ module RailsAiContext
|
|
|
426
507
|
location = Object.const_source_location(model.name)&.first
|
|
427
508
|
return false unless location
|
|
428
509
|
|
|
429
|
-
|
|
430
|
-
Gem.loaded_specs.each_value.any? do |spec|
|
|
431
|
-
dir = "#{spec.full_gem_path}#{File::SEPARATOR}"
|
|
432
|
-
location.start_with?(dir) && !app_owned_gem?(spec, dir, root)
|
|
433
|
-
end
|
|
510
|
+
PortablePath.gem_file?(location, app.root)
|
|
434
511
|
rescue NameError, ArgumentError, TypeError
|
|
435
512
|
false
|
|
436
513
|
end
|
|
437
514
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
# is still the gems' own.
|
|
441
|
-
def app_owned_gem?(spec, dir, root)
|
|
442
|
-
return true if root.start_with?(dir)
|
|
443
|
-
return false unless dir.start_with?(root) && defined?(Bundler::Source::Path)
|
|
444
|
-
|
|
445
|
-
source = spec.respond_to?(:source) ? spec.source : nil
|
|
446
|
-
source.is_a?(Bundler::Source::Path) && !source.is_a?(Bundler::Source::Git)
|
|
515
|
+
def model_dir_file?(path)
|
|
516
|
+
PathResolver.model_dirs(app.root).any? { |dir| SafePath.contained?(path, PathResolver.root_key(dir)) }
|
|
447
517
|
end
|
|
448
518
|
|
|
449
519
|
def discover_models
|
|
@@ -458,12 +528,13 @@ module RailsAiContext
|
|
|
458
528
|
end
|
|
459
529
|
|
|
460
530
|
known = models.map(&:name).to_set
|
|
531
|
+
@outside_model_dirs = Set.new
|
|
461
532
|
# Concerns stay in: a nested concerns directory is a namespace, so a
|
|
462
533
|
# class declared under one is a model and constantize sorts the mixins
|
|
463
534
|
# out. A top-level `app/models/concerns` is an autoload root instead,
|
|
464
535
|
# so its files declare no `Concerns::` prefix and that path name never
|
|
465
536
|
# constantizes.
|
|
466
|
-
SourceScan.
|
|
537
|
+
SourceScan.model_paths(app.root, base_model: model_base_check).each do |record|
|
|
467
538
|
next if record.path_name.start_with?("Concerns::")
|
|
468
539
|
next if known.include?(record.path_name)
|
|
469
540
|
next if config.excluded_models.include?(record.path_name)
|
|
@@ -471,9 +542,10 @@ module RailsAiContext
|
|
|
471
542
|
# The path does not name the class: an app inflection only changes
|
|
472
543
|
# case, so activitypub/activity.rb camelizes to a constant the app
|
|
473
544
|
# does not have and the file was listed as a model that will not
|
|
474
|
-
# load.
|
|
545
|
+
# load. A root pushed under a namespace declares a prefix the path
|
|
546
|
+
# lacks. Read only where the camelized name is not already loaded, so
|
|
475
547
|
# a booted run does not parse every model file to learn nothing.
|
|
476
|
-
class_name = DeclaredConstant.
|
|
548
|
+
class_name = DeclaredConstant.named(model_source(record.path).to_s, record.path_name)
|
|
477
549
|
next if known.include?(class_name)
|
|
478
550
|
next if config.excluded_models.include?(class_name)
|
|
479
551
|
|
|
@@ -487,6 +559,7 @@ module RailsAiContext
|
|
|
487
559
|
# its name is recorded: a file that exists for a class reflection
|
|
488
560
|
# lacks is not the same answer as no such model.
|
|
489
561
|
@unloadable[class_name] = e.message.to_s.lines.first.to_s.strip
|
|
562
|
+
@outside_model_dirs << class_name unless model_dir_file?(record.path)
|
|
490
563
|
end
|
|
491
564
|
end
|
|
492
565
|
|
|
@@ -501,9 +574,10 @@ module RailsAiContext
|
|
|
501
574
|
# and the superclasses are merged here too, or the static tier
|
|
502
575
|
# out-answers this one.
|
|
503
576
|
bases = booted_declaring_bases(model)
|
|
504
|
-
calls =
|
|
577
|
+
calls = singleton_lookup([ [ model.name, model_source_path(model) ], *bases ])
|
|
505
578
|
source_data, unread, bases_unread, hidden =
|
|
506
|
-
merge_class_and_bases(own_source, model.name, calls, bases, file: model_source_path(model)
|
|
579
|
+
merge_class_and_bases(own_source, model.name, calls, bases, file: model_source_path(model),
|
|
580
|
+
commits_in_order: booted_commits_in_order)
|
|
507
581
|
|
|
508
582
|
class_methods = extract_class_methods_from_ast(model, source_data)
|
|
509
583
|
instance_methods = extract_instance_methods_from_ast(model, source_data)
|
|
@@ -512,12 +586,14 @@ module RailsAiContext
|
|
|
512
586
|
# reflection's own list is inflated by an attribute method per column
|
|
513
587
|
# as soon as anything instantiates the model.
|
|
514
588
|
source_instance_methods = own_source_methods(model, source_data)
|
|
589
|
+
concerns = booted_concerns(model, source_data[:mixins])
|
|
515
590
|
|
|
516
591
|
details = {
|
|
517
592
|
table_name: model.table_name,
|
|
593
|
+
primary_key: model_primary_key(model),
|
|
518
594
|
file: relative_to_root(model_source_path(model)),
|
|
519
595
|
# Reflection-based (runtime, most accurate for these)
|
|
520
|
-
associations: extract_associations(model),
|
|
596
|
+
associations: extract_associations(model, source_data),
|
|
521
597
|
# Reflection has no text for a Proc condition and names `validates_with` by kind only,
|
|
522
598
|
# so the list is read off the source; an unreadable file falls back to reflection.
|
|
523
599
|
validations: booted_validations(model, source_data),
|
|
@@ -526,8 +602,8 @@ module RailsAiContext
|
|
|
526
602
|
# hold no block callbacks, so both tiers read the model's source.
|
|
527
603
|
callbacks: group_callbacks_by_type(source_data[:callbacks]),
|
|
528
604
|
callback_conditions: callback_conditions(source_data[:callbacks]),
|
|
529
|
-
concerns:
|
|
530
|
-
concern_sources: concern_sources(
|
|
605
|
+
concerns: concerns,
|
|
606
|
+
concern_sources: concern_sources(concerns, source_data[:mixins], booted: true),
|
|
531
607
|
concerns_hidden: (hidden.size if hidden.any?),
|
|
532
608
|
concern_callbacks: concern_callbacks(source_data[:callbacks]),
|
|
533
609
|
concerns_unread: (unread if unread.any?),
|
|
@@ -537,7 +613,9 @@ module RailsAiContext
|
|
|
537
613
|
# AST-based (replaces regex source parsing)
|
|
538
614
|
custom_validates: extract_custom_validates_from_ast(source_data),
|
|
539
615
|
custom_validate_conditions: custom_validate_conditions(source_data),
|
|
616
|
+
custom_validate_blocks: custom_validate_blocks(source_data),
|
|
540
617
|
scopes: extract_scopes_from_ast(source_data),
|
|
618
|
+
default_scopes: default_scopes(source_data),
|
|
541
619
|
class_methods: class_methods.first(PAYLOAD_METHOD_CAP),
|
|
542
620
|
class_method_count: class_methods.size,
|
|
543
621
|
instance_methods: instance_methods.first(PAYLOAD_METHOD_CAP),
|
|
@@ -547,6 +625,7 @@ module RailsAiContext
|
|
|
547
625
|
|
|
548
626
|
sti_info = extract_sti_info(model)
|
|
549
627
|
details[:sti] = sti_info if sti_info
|
|
628
|
+
|
|
550
629
|
parent = model.superclass
|
|
551
630
|
details[:parent_model] = parent.name if parent < ActiveRecord::Base && !parent.abstract_class?
|
|
552
631
|
|
|
@@ -561,6 +640,7 @@ module RailsAiContext
|
|
|
561
640
|
# AST-based detailed macros (replaces regex)
|
|
562
641
|
detailed = extract_detailed_macros_from_ast(source_data)
|
|
563
642
|
details.merge!(detailed)
|
|
643
|
+
details[:ignored_columns] = model.ignored_columns.map(&:to_s).presence
|
|
564
644
|
|
|
565
645
|
details.compact
|
|
566
646
|
end
|
|
@@ -659,8 +739,7 @@ module RailsAiContext
|
|
|
659
739
|
AstWalk.each(AstCache.parse_string(source).value) do |node|
|
|
660
740
|
next unless node.is_a?(Prism::CallNode) && node.receiver.is_a?(Prism::SelfNode)
|
|
661
741
|
|
|
662
|
-
|
|
663
|
-
return setting unless setting.nil?
|
|
742
|
+
return setting_value(node) if node.name == :belongs_to_required_by_default=
|
|
664
743
|
end
|
|
665
744
|
end
|
|
666
745
|
nil
|
|
@@ -708,67 +787,79 @@ module RailsAiContext
|
|
|
708
787
|
validator.kind == :presence && validator.options[:message] == :required
|
|
709
788
|
end
|
|
710
789
|
|
|
711
|
-
# Rails' rule:
|
|
712
|
-
# default. An option
|
|
790
|
+
# Rails' rule: a required: key sets optional: to its negation, then a nil
|
|
791
|
+
# optional: means the app's default. An unevaluable option makes it conditional.
|
|
713
792
|
def static_required_belongs_to(associations, default = belongs_to_required_by_default?)
|
|
714
793
|
Array(associations).select { |a| a.is_a?(Hash) && a[:type].to_s == "belongs_to" }.filter_map do |a|
|
|
715
794
|
name = a[:name].to_s
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
elsif !optional_value.nil?
|
|
722
|
-
|
|
723
|
-
|
|
795
|
+
options = a[:options] || {}
|
|
796
|
+
optional_value = a.key?(:optional) ? a[:optional] : options[:optional]
|
|
797
|
+
if options.key?(:required)
|
|
798
|
+
required = literal_boolean(options[:required])
|
|
799
|
+
required.nil? ? [ name, "required: #{options[:required]} is true" ] : (name if required)
|
|
800
|
+
elsif !optional_value.nil?
|
|
801
|
+
optional = literal_boolean(optional_value)
|
|
802
|
+
optional.nil? ? [ name, "optional: #{optional_value} is false" ] : (name unless optional)
|
|
803
|
+
elsif default.is_a?(String) then [ name, default ]
|
|
724
804
|
elsif default then name
|
|
725
805
|
end
|
|
726
806
|
end
|
|
727
807
|
end
|
|
728
808
|
|
|
729
809
|
def literal_boolean(value)
|
|
730
|
-
{ true => true, false => false, "true" => true, "false" => false }[value]
|
|
810
|
+
{ true => true, false => false, nil => false, "true" => true, "false" => false }[value]
|
|
731
811
|
end
|
|
732
812
|
|
|
733
|
-
#
|
|
734
|
-
#
|
|
735
|
-
# receiver (`config.active_record`, `ActiveRecord::Base`); without one,
|
|
736
|
-
# Rails turns it on from `load_defaults 5.0`.
|
|
813
|
+
# Without a `belongs_to_required_by_default =` of its own, Rails turns it
|
|
814
|
+
# on from `load_defaults 5.0`. True, false, or the condition it holds under.
|
|
737
815
|
def belongs_to_required_by_default?
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
816
|
+
setting = framework_setting(:belongs_to_required_by_default=, since: 5.0)
|
|
817
|
+
setting.assigned ? setting.value : setting.version.to_f >= 5.0
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
# `value` is the deciding assignment's setting_value when `assigned`;
|
|
821
|
+
# else `load_defaults`' `version` decides.
|
|
822
|
+
FrameworkSetting = Struct.new(:assigned, :value, :version, :read, keyword_init: true)
|
|
823
|
+
|
|
824
|
+
# Application.rb then the initializers, as Rails runs them; a `load_defaults`
|
|
825
|
+
# of version `since` or later assigns the setting too, and the last line wins.
|
|
826
|
+
def framework_setting(setter, since:)
|
|
827
|
+
@framework_settings ||= {}
|
|
828
|
+
@framework_settings[setter] ||= begin
|
|
829
|
+
root = app.root.to_s
|
|
830
|
+
application = File.join(root, "config", "application.rb")
|
|
831
|
+
setting = FrameworkSetting.new(assigned: false, read: false)
|
|
832
|
+
[ application, *PathResolver.initializer_paths(root) ].each do |path|
|
|
833
|
+
source = SafeFile.read(path) or next
|
|
834
|
+
setting.read ||= path == application
|
|
835
|
+
AstWalk.each(AstCache.parse_string(source).value) do |node|
|
|
836
|
+
next unless node.is_a?(Prism::CallNode)
|
|
837
|
+
|
|
838
|
+
if node.name == setter && node.receiver
|
|
839
|
+
setting.assigned = true
|
|
840
|
+
setting.value = setting_value(node)
|
|
841
|
+
elsif node.name == :load_defaults && path == application && node.receiver&.slice.to_s == "config"
|
|
842
|
+
setting.version = defaults_version(node.arguments&.arguments&.first)
|
|
843
|
+
setting.assigned = false if setting.version.to_f >= since
|
|
844
|
+
end
|
|
755
845
|
end
|
|
756
846
|
end
|
|
847
|
+
setting
|
|
757
848
|
end
|
|
758
|
-
@belongs_to_required = setting.nil? ? version.to_f >= 5.0 : setting
|
|
759
849
|
end
|
|
760
850
|
|
|
761
|
-
#
|
|
762
|
-
#
|
|
763
|
-
def
|
|
764
|
-
return nil unless node.name == :belongs_to_required_by_default=
|
|
765
|
-
|
|
851
|
+
# A literal true, false or nil (false, as Rails tests for truth) as itself;
|
|
852
|
+
# any other value reads as the condition "<setting> = <expression> is true".
|
|
853
|
+
def setting_value(node)
|
|
766
854
|
value = node.arguments&.arguments&.first
|
|
767
|
-
{ Prism::TrueNode => true, Prism::FalseNode => false }[value.class]
|
|
855
|
+
literal = { Prism::TrueNode => true, Prism::FalseNode => false, Prism::NilNode => false }[value.class]
|
|
856
|
+
return literal unless literal.nil? && value
|
|
857
|
+
|
|
858
|
+
"#{node.name.to_s.delete_suffix('=')} = #{value.slice} is true"
|
|
768
859
|
end
|
|
769
860
|
|
|
770
|
-
# A literal version as written; anything else (`Rails::VERSION::STRING.to_f`)
|
|
771
|
-
#
|
|
861
|
+
# A literal version as written; anything else (`Rails::VERSION::STRING.to_f`) is the
|
|
862
|
+
# running Rails, Float::INFINITY: on for belongs_to, unknown for the commit order.
|
|
772
863
|
def defaults_version(arg)
|
|
773
864
|
case arg
|
|
774
865
|
when Prism::FloatNode, Prism::IntegerNode then arg.value.to_f
|
|
@@ -782,11 +873,31 @@ module RailsAiContext
|
|
|
782
873
|
# ActiveRecord::Base's chain, which is what a gem (or an initializer)
|
|
783
874
|
# puts into all models. A module a gem macro includes into this model
|
|
784
875
|
# alone (`devise :lockable`) stays.
|
|
785
|
-
def booted_concerns(model)
|
|
786
|
-
own = (model.ancestors - every_model_modules).reject { |mod| mod.is_a?(Class) }.filter_map(&:name).reverse
|
|
876
|
+
def booted_concerns(model, mixins = [])
|
|
877
|
+
own = (model.ancestors - every_model_modules - base_gem_modules(model, mixins)).reject { |mod| mod.is_a?(Class) }.filter_map(&:name).reverse
|
|
787
878
|
ConcernMembership.payload(own)
|
|
788
879
|
end
|
|
789
880
|
|
|
881
|
+
# A module a gem's inherited hook mixes into every model (Kaminari) is left out; one the app includes or defines stays.
|
|
882
|
+
def base_gem_modules(model, mixins)
|
|
883
|
+
written = ConcernMembership.from_mixins(mixins)
|
|
884
|
+
gem_bases(model).flat_map { |base| base.ancestors - every_model_modules }.uniq.select do |mod|
|
|
885
|
+
!mod.is_a?(Class) && mod.name && !written.include?(mod.name) && !ConcernPaths.find_file(app.root.to_s, mod.name)
|
|
886
|
+
end
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
def abstract_bases(model)
|
|
890
|
+
model.ancestors.select { |klass| klass.is_a?(Class) && klass < ActiveRecord::Base && klass != model && klass.abstract_class? }
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
# The app's bases a gem's inherited hook reaches as it reaches the model (Kaminari: each direct ActiveRecord::Base child).
|
|
894
|
+
def gem_bases(model)
|
|
895
|
+
bases = abstract_bases(model)
|
|
896
|
+
return bases unless model.superclass == ActiveRecord::Base
|
|
897
|
+
|
|
898
|
+
bases | ActiveRecord::Base.subclasses.select { |klass| klass.name && klass.abstract_class? }
|
|
899
|
+
end
|
|
900
|
+
|
|
790
901
|
def every_model_modules
|
|
791
902
|
@every_model_modules ||= ActiveRecord::Base.ancestors
|
|
792
903
|
end
|
|
@@ -824,15 +935,32 @@ module RailsAiContext
|
|
|
824
935
|
|
|
825
936
|
# ── Reflection-based extraction (unchanged) ─────────────────────
|
|
826
937
|
|
|
827
|
-
def extract_associations(model)
|
|
938
|
+
def extract_associations(model, source_data = empty_source_data)
|
|
939
|
+
declared = Array(source_data[:associations]).group_by { |a| [ a[:type].to_s, a[:name].to_s ] }
|
|
828
940
|
# The reject stays ahead of the map: class_name/foreign_key on an
|
|
829
941
|
# excluded reflection with a broken :through raises, and `call`'s
|
|
830
942
|
# per-model rescue would replace the whole model with one error line.
|
|
831
943
|
model.reflect_on_all_associations.reject { |assoc| excluded_association?(assoc.name) }.map do |assoc|
|
|
832
|
-
association_detail(assoc)
|
|
944
|
+
with_declared_options(association_detail(assoc), declared[[ assoc.macro.to_s, assoc.name.to_s ]]&.first, model)
|
|
833
945
|
end
|
|
834
946
|
end
|
|
835
947
|
|
|
948
|
+
# Only what the source declares, so both tiers print one spelling; a gem
|
|
949
|
+
# macro's generated association carries Rails' options, not the app's.
|
|
950
|
+
def with_declared_options(detail, source, model = nil)
|
|
951
|
+
options = declared_association_options(detail[:type], source&.dig(:options))
|
|
952
|
+
extensions = source&.dig(:extension_methods)
|
|
953
|
+
delegated = source&.dig(:delegated_types)
|
|
954
|
+
unread = source&.dig(:delegated_types_source)
|
|
955
|
+
# delegated_type defines `<role>_types`, which answers a list the source names only as an expression.
|
|
956
|
+
if unread && model.respond_to?(reader = :"#{detail[:name]}_types")
|
|
957
|
+
delegated = Array(model.public_send(reader)).map(&:to_s).presence
|
|
958
|
+
unread = nil if delegated
|
|
959
|
+
end
|
|
960
|
+
detail.merge({ declared_options: options, extension_methods: extensions, delegated_types: delegated,
|
|
961
|
+
delegated_types_source: unread }.compact)
|
|
962
|
+
end
|
|
963
|
+
|
|
836
964
|
# One reflection that cannot resolve costs that reflection, not the
|
|
837
965
|
# model. `class_name` on a `:through` whose through association does not
|
|
838
966
|
# exist ends in `nil.klass`, and the per-model rescue in `call` then
|
|
@@ -908,10 +1036,11 @@ module RailsAiContext
|
|
|
908
1036
|
end
|
|
909
1037
|
|
|
910
1038
|
def extract_sti_info(model)
|
|
1039
|
+
type_column = model.inheritance_column.to_s
|
|
911
1040
|
has_type_column = if model.connected? && model.table_exists?
|
|
912
|
-
model.columns_hash.key?(
|
|
1041
|
+
model.columns_hash.key?(type_column)
|
|
913
1042
|
else
|
|
914
|
-
SchemaReader.for(app.root).column?(model.table_name,
|
|
1043
|
+
SchemaReader.for(app.root).column?(model.table_name, type_column)
|
|
915
1044
|
end
|
|
916
1045
|
|
|
917
1046
|
return nil unless has_type_column
|
|
@@ -933,7 +1062,8 @@ module RailsAiContext
|
|
|
933
1062
|
{
|
|
934
1063
|
sti_base: sti_parent.nil? && children.any?,
|
|
935
1064
|
sti_parent: sti_parent,
|
|
936
|
-
sti_children: children.empty? ? nil : children
|
|
1065
|
+
sti_children: children.empty? ? nil : children,
|
|
1066
|
+
type_column: type_column
|
|
937
1067
|
}.compact
|
|
938
1068
|
rescue => e
|
|
939
1069
|
RailsAiContext.debug_fail(e, nil, label: "extract_sti_info")
|
|
@@ -942,7 +1072,7 @@ module RailsAiContext
|
|
|
942
1072
|
# ── AST-based extraction (replaces all regex parsing) ──────────
|
|
943
1073
|
|
|
944
1074
|
def extract_scopes_from_ast(source_data)
|
|
945
|
-
source_data[:scopes].map do |s|
|
|
1075
|
+
source_data[:scopes].reject { |s| s[:default] }.map do |s|
|
|
946
1076
|
{
|
|
947
1077
|
name: s[:name],
|
|
948
1078
|
body: s[:body],
|
|
@@ -952,12 +1082,26 @@ module RailsAiContext
|
|
|
952
1082
|
end
|
|
953
1083
|
end
|
|
954
1084
|
|
|
1085
|
+
# Base first, as Rails stacks them; each one filters every query.
|
|
1086
|
+
def default_scopes(source_data)
|
|
1087
|
+
found = Array(source_data[:scopes]).select { |s| s[:default] }.map { |s| s.slice(:body, :all_queries, :confidence) }
|
|
1088
|
+
found.presence
|
|
1089
|
+
end
|
|
1090
|
+
|
|
955
1091
|
def extract_custom_validates_from_ast(source_data)
|
|
956
1092
|
source_data[:validations]
|
|
957
1093
|
.select { |v| v[:kind] == "custom" }
|
|
958
1094
|
.flat_map { |v| v[:attributes] }
|
|
959
1095
|
end
|
|
960
1096
|
|
|
1097
|
+
# A validate block is listed by what it runs; Rails registers it next to any methods named.
|
|
1098
|
+
def custom_validate_blocks(source_data)
|
|
1099
|
+
found = Array(source_data[:validations]).select { |v| v[:kind] == "custom" && v[:block] }.map do |v|
|
|
1100
|
+
{ body: v[:block], conditions: (v[:options] || {}).slice(*CONDITION_KEYS, :on).presence }.compact
|
|
1101
|
+
end
|
|
1102
|
+
found.presence
|
|
1103
|
+
end
|
|
1104
|
+
|
|
961
1105
|
# `validate :check, on: :create` runs only then; the method name alone
|
|
962
1106
|
# reads as a rule that always holds.
|
|
963
1107
|
def custom_validate_conditions(source_data)
|
|
@@ -968,26 +1112,28 @@ module RailsAiContext
|
|
|
968
1112
|
found.presence
|
|
969
1113
|
end
|
|
970
1114
|
|
|
1115
|
+
# The options that decide the value a new record holds and the names of
|
|
1116
|
+
# the methods the enum defines.
|
|
971
1117
|
def extract_enum_options_from_ast(source_data)
|
|
972
|
-
source_data[:enums].each_with_object({}) do |enum, opts|
|
|
973
|
-
|
|
974
|
-
entry
|
|
975
|
-
entry
|
|
976
|
-
opts[enum[:name]] = entry if entry.any?
|
|
1118
|
+
Array(source_data[:enums]).each_with_object({}) do |enum, opts|
|
|
1119
|
+
options = enum[:options] || {}
|
|
1120
|
+
entry = %i[prefix suffix default].to_h { |key| [ key, options.fetch(key) { options[:"_#{key}"] } ] }
|
|
1121
|
+
entry.reject! { |_, value| value.nil? || value == false }
|
|
1122
|
+
opts[enum[:name].to_s] = entry if entry.any?
|
|
977
1123
|
end
|
|
978
1124
|
end
|
|
979
1125
|
|
|
980
1126
|
CONDITION_KEYS = %i[if unless].freeze
|
|
981
1127
|
|
|
982
|
-
# One conditions entry per callback
|
|
983
|
-
#
|
|
1128
|
+
# One conditions entry per callback, in step with the name list: one method can sit under
|
|
1129
|
+
# two types with different conditions.
|
|
984
1130
|
def callback_conditions(callbacks)
|
|
985
1131
|
Array(callbacks).each_with_object({}) do |cb, hash|
|
|
986
1132
|
next unless cb.is_a?(Hash) && cb[:type]
|
|
987
1133
|
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
conditions =
|
|
1134
|
+
options = cb[:options] || {}
|
|
1135
|
+
conditions = options.select { |key, _| CONDITION_KEYS.include?(key) }
|
|
1136
|
+
conditions = { on: options[:on] }.merge(conditions) if options.key?(:on) && !Listeners::CallbacksListener.names_event?(cb[:type])
|
|
991
1137
|
(hash[cb[:type].to_s] ||= []) << (conditions.empty? ? nil : conditions)
|
|
992
1138
|
end.reject { |_, list| list.none? }
|
|
993
1139
|
end
|
|
@@ -1018,7 +1164,10 @@ module RailsAiContext
|
|
|
1018
1164
|
.reject { |m| scope_names.include?(m) }
|
|
1019
1165
|
|
|
1020
1166
|
# Reflection-discovered class methods (for completeness)
|
|
1021
|
-
|
|
1167
|
+
# The abstract bases' class methods reach every model, the static tier reads none of
|
|
1168
|
+
# them, and a gem's (Kaminari's `page`) would otherwise be listed on each model.
|
|
1169
|
+
base_methods = gem_bases(model).flat_map(&:methods)
|
|
1170
|
+
all_methods = (model.methods - ActiveRecord::Base.methods - Object.methods - base_methods)
|
|
1022
1171
|
.reject { |m|
|
|
1023
1172
|
ms = m.to_s
|
|
1024
1173
|
ms == "self" ||
|
|
@@ -1073,12 +1222,11 @@ module RailsAiContext
|
|
|
1073
1222
|
has_rich_text: :has_rich_text,
|
|
1074
1223
|
generates_token_for: :generates_token_for,
|
|
1075
1224
|
serialize: :serialize,
|
|
1076
|
-
|
|
1077
|
-
|
|
1225
|
+
has_secure_token: :has_secure_token,
|
|
1226
|
+
attr_readonly: :attr_readonly,
|
|
1227
|
+
query_constraints: :query_constraints
|
|
1078
1228
|
}.freeze
|
|
1079
1229
|
|
|
1080
|
-
BROADCAST_MACROS = %i[broadcasts broadcasts_to broadcasts_refreshes_to].to_set.freeze
|
|
1081
|
-
|
|
1082
1230
|
def extract_macros_from_ast(source_data, source_path = nil)
|
|
1083
1231
|
macros = {}
|
|
1084
1232
|
source_data[:macros].each do |m|
|
|
@@ -1086,17 +1234,36 @@ module RailsAiContext
|
|
|
1086
1234
|
|
|
1087
1235
|
if macro == :has_secure_password
|
|
1088
1236
|
macros[:has_secure_password] = true
|
|
1237
|
+
(macros[:secure_passwords] ||= []) << { attribute: m[:attribute], options: m[:written] || {} }
|
|
1089
1238
|
elsif (key = ATTRIBUTE_MACRO_MAP[macro])
|
|
1090
1239
|
(macros[key] ||= []) << m[:attribute]
|
|
1240
|
+
(macros[:serialize_options] ||= {})[m[:attribute]] = m[:written] if macro == :serialize && m[:written]&.any?
|
|
1241
|
+
elsif Listeners::MacrosListener::STORE_MACROS.include?(macro)
|
|
1242
|
+
add_store_accessors(macros, m)
|
|
1243
|
+
elsif macro == :accepts_nested_attributes_for
|
|
1244
|
+
(macros[:nested_attributes] ||= []) << { names: m[:names], options: m[:options] }.compact
|
|
1245
|
+
elsif macro == :ignored_columns
|
|
1246
|
+
macros[:ignored_columns] = apply_ignored_columns(macros[:ignored_columns] || [], m)
|
|
1091
1247
|
elsif macro == :delegate
|
|
1092
|
-
(macros[:delegations] ||= []) <<
|
|
1248
|
+
(macros[:delegations] ||= []) << delegation_entry(m)
|
|
1093
1249
|
elsif macro == :delegate_missing_to
|
|
1094
1250
|
macros[:delegate_missing_to] = m[:to]
|
|
1095
1251
|
elsif macro == :attribute
|
|
1096
|
-
(macros[:attributes] ||= []) << { name: m[:attribute], type: m[:type] }.compact
|
|
1252
|
+
(macros[:attributes] ||= []) << { name: m[:attribute], type: m[:type], default: m.dig(:options, :default) }.compact
|
|
1253
|
+
elsif macro == :alias_attribute
|
|
1254
|
+
(macros[:alias_attributes] ||= []) << { name: m[:attribute], target: m[:target] }
|
|
1255
|
+
elsif macro == :model_setting
|
|
1256
|
+
# Bases arrive first, so the class's own assignment wins.
|
|
1257
|
+
(macros[:model_settings] ||= {})[m[:setting]] = m[:value]
|
|
1258
|
+
elsif macro == :connects_to
|
|
1259
|
+
macros[:database] = { connects_to: m[:text], condition: m[:condition], declared_in: m[:declared_in], writing: m[:writing] }.compact
|
|
1260
|
+
elsif macro == :gem_macro
|
|
1261
|
+
(macros[:gem_macros] ||= []) << m.slice(:text, :adds)
|
|
1262
|
+
elsif macro == :aasm
|
|
1263
|
+
(macros[:state_machines] ||= []) << m.slice(:column, :initial, :states, :events)
|
|
1097
1264
|
end
|
|
1098
1265
|
|
|
1099
|
-
if BROADCAST_MACROS.include?(macro)
|
|
1266
|
+
if Listeners::MacrosListener::BROADCAST_MACROS.include?(macro)
|
|
1100
1267
|
(macros[:broadcasts] ||= []) << macro.to_s
|
|
1101
1268
|
macros[:broadcasts].uniq!
|
|
1102
1269
|
end
|
|
@@ -1108,6 +1275,59 @@ module RailsAiContext
|
|
|
1108
1275
|
macros.reject { |_, v| v.is_a?(Array) && v.empty? }
|
|
1109
1276
|
end
|
|
1110
1277
|
|
|
1278
|
+
# Declarations arrive bases first, so `=` replaces what a base set and
|
|
1279
|
+
# `+=` adds to it; a list the source computes cannot be read.
|
|
1280
|
+
def apply_ignored_columns(current, macro)
|
|
1281
|
+
return [ RailsAiContext::Confidence::INFERRED ] if macro[:columns].nil? || current.include?(RailsAiContext::Confidence::INFERRED)
|
|
1282
|
+
|
|
1283
|
+
case macro[:op]
|
|
1284
|
+
when :assign then macro[:columns].uniq
|
|
1285
|
+
when :add then (current + macro[:columns]).uniq
|
|
1286
|
+
else current - macro[:columns]
|
|
1287
|
+
end
|
|
1288
|
+
end
|
|
1289
|
+
|
|
1290
|
+
# Rails names a store accessor "#{prefix}_#{key}_#{suffix}", where true
|
|
1291
|
+
# stands for the column name (ActiveRecord::Store.store_accessor).
|
|
1292
|
+
def add_store_accessors(macros, macro)
|
|
1293
|
+
column = macro[:attribute]
|
|
1294
|
+
(macros[:store] ||= []) << column unless macros[:store]&.include?(column)
|
|
1295
|
+
keys = macro[:keys] || []
|
|
1296
|
+
return if keys.empty?
|
|
1297
|
+
|
|
1298
|
+
options = macro[:options] || {}
|
|
1299
|
+
accessors = (macros[:store_accessors] ||= {})[column] ||= []
|
|
1300
|
+
affixes = options.values_at(:prefix, :suffix)
|
|
1301
|
+
if affixes.include?(RailsAiContext::Confidence::INFERRED)
|
|
1302
|
+
accessors << RailsAiContext::Confidence::INFERRED unless accessors.include?(RailsAiContext::Confidence::INFERRED)
|
|
1303
|
+
return
|
|
1304
|
+
end
|
|
1305
|
+
|
|
1306
|
+
prefix, suffix = affixes.map { |affix| affix == true ? column : (affix.is_a?(Symbol) || affix.is_a?(String) ? affix : nil) }
|
|
1307
|
+
keys.each do |key|
|
|
1308
|
+
name = [ prefix, key, suffix ].compact.join("_")
|
|
1309
|
+
accessors << name unless accessors.include?(name)
|
|
1310
|
+
end
|
|
1311
|
+
end
|
|
1312
|
+
|
|
1313
|
+
# ActiveSupport names a prefixed delegate "#{prefix == true ? to : prefix}_#{method}".
|
|
1314
|
+
def delegation_entry(macro)
|
|
1315
|
+
options = macro[:options] || {}
|
|
1316
|
+
entry = { methods: macro[:methods], to: macro[:to] }
|
|
1317
|
+
entry[:private] = true if options[:private] == true
|
|
1318
|
+
prefix = options[:prefix]
|
|
1319
|
+
case prefix
|
|
1320
|
+
when true, Symbol, String
|
|
1321
|
+
if prefix == RailsAiContext::Confidence::INFERRED
|
|
1322
|
+
entry[:prefix_computed] = true
|
|
1323
|
+
else
|
|
1324
|
+
head = prefix == true ? macro[:to] : prefix
|
|
1325
|
+
entry[:defines] = macro[:methods].map { |name| "#{head}_#{name}" } if head
|
|
1326
|
+
end
|
|
1327
|
+
end
|
|
1328
|
+
entry
|
|
1329
|
+
end
|
|
1330
|
+
|
|
1111
1331
|
# Extract constant definitions from source via AST.
|
|
1112
1332
|
# Finds ConstantWriteNode where the value is an ArrayNode
|
|
1113
1333
|
# (covers %w[], %i[], and literal array forms).
|
|
@@ -1128,9 +1348,7 @@ module RailsAiContext
|
|
|
1128
1348
|
name = node.name.to_s
|
|
1129
1349
|
# Only capture UPPER_CASE constants (matching the old regex behavior)
|
|
1130
1350
|
if name.match?(/\A[A-Z][A-Z_]+\z/)
|
|
1131
|
-
|
|
1132
|
-
value_node = node.value
|
|
1133
|
-
value_node = value_node.receiver if value_node.is_a?(Prism::CallNode) && value_node.name == :freeze
|
|
1351
|
+
value_node = unwrap_frozen(node.value)
|
|
1134
1352
|
|
|
1135
1353
|
if value_node.is_a?(Prism::ArrayNode)
|
|
1136
1354
|
values = value_node.elements.filter_map { |el|
|
|
@@ -1147,25 +1365,37 @@ module RailsAiContext
|
|
|
1147
1365
|
node.child_nodes.compact.each { |child| find_constant_arrays(child, constants) }
|
|
1148
1366
|
end
|
|
1149
1367
|
|
|
1368
|
+
# `%w[...].freeze` and `Ractor.make_shareable(%w[...])` are the array itself, frozen.
|
|
1369
|
+
def unwrap_frozen(node)
|
|
1370
|
+
loop do
|
|
1371
|
+
break node unless node.is_a?(Prism::CallNode)
|
|
1372
|
+
|
|
1373
|
+
if node.name == :freeze && node.arguments.nil?
|
|
1374
|
+
node = node.receiver
|
|
1375
|
+
elsif node.name == :make_shareable && node.receiver&.slice&.delete_prefix("::") == "Ractor" && node.arguments&.arguments&.size == 1
|
|
1376
|
+
node = node.arguments.arguments.first
|
|
1377
|
+
else
|
|
1378
|
+
break node
|
|
1379
|
+
end
|
|
1380
|
+
end
|
|
1381
|
+
end
|
|
1382
|
+
|
|
1150
1383
|
def extract_detailed_macros_from_ast(source_data)
|
|
1151
1384
|
encryption = []
|
|
1152
1385
|
normalizations = []
|
|
1153
1386
|
tokens = []
|
|
1154
1387
|
|
|
1155
1388
|
source_data[:macros].each do |m|
|
|
1389
|
+
written = m[:written] || {}
|
|
1156
1390
|
case m[:macro]
|
|
1157
1391
|
when :encrypts
|
|
1158
|
-
|
|
1159
|
-
opts[:deterministic] = true if m[:options][:deterministic] == true
|
|
1160
|
-
opts[:downcase] = true if m[:options][:downcase] == true
|
|
1161
|
-
encryption << { field: m[:attribute], options: opts }
|
|
1392
|
+
encryption << { field: m[:attribute], options: written }
|
|
1162
1393
|
when :normalizes
|
|
1163
|
-
entry = { field: m[:attribute] }
|
|
1164
|
-
entry[:transformation] = m[:options][:with].to_s if m[:options][:with]
|
|
1394
|
+
entry = { field: m[:attribute], transformation: written[:with], options: written.except(:with).presence }
|
|
1165
1395
|
normalizations << entry.compact
|
|
1166
1396
|
when :generates_token_for
|
|
1167
1397
|
entry = { purpose: m[:attribute] }
|
|
1168
|
-
entry[:expires_in] =
|
|
1398
|
+
entry[:expires_in] = written[:expires_in] if written[:expires_in]
|
|
1169
1399
|
tokens << entry.compact
|
|
1170
1400
|
end
|
|
1171
1401
|
end
|
|
@@ -1275,6 +1505,31 @@ module RailsAiContext
|
|
|
1275
1505
|
].freeze
|
|
1276
1506
|
BOOLEAN_ASSOCIATION_OPTIONS = %i[polymorphic optional].freeze
|
|
1277
1507
|
|
|
1508
|
+
# What the record already says another way, or what Rails keeps for itself. A has_many's declared
|
|
1509
|
+
# foreign key stays: its record carries reflection's default key too.
|
|
1510
|
+
UNLISTED_ASSOCIATION_OPTIONS = [ *(LIFTED_ASSOCIATION_OPTIONS - [ :foreign_key ]), :anonymous_class ].freeze
|
|
1511
|
+
|
|
1512
|
+
# Every other option the association declares, each as display text:
|
|
1513
|
+
# its callbacks, extensions, counter cache and the rest.
|
|
1514
|
+
def declared_association_options(type, options)
|
|
1515
|
+
return nil unless options.is_a?(Hash)
|
|
1516
|
+
|
|
1517
|
+
shown = options.except(*UNLISTED_ASSOCIATION_OPTIONS)
|
|
1518
|
+
shown = shown.except(:required, :foreign_key, :query_constraints) if type.to_s == "belongs_to"
|
|
1519
|
+
shown.to_h { |key, value| [ key.to_s, association_option_text(value) ] }.presence
|
|
1520
|
+
end
|
|
1521
|
+
|
|
1522
|
+
def association_option_text(value)
|
|
1523
|
+
case value
|
|
1524
|
+
when Symbol then ":#{value}"
|
|
1525
|
+
when Module then value.name.to_s
|
|
1526
|
+
when Proc then "(proc)"
|
|
1527
|
+
when Array then "[#{value.map { |v| association_option_text(v) }.join(', ')}]"
|
|
1528
|
+
when Hash then "{ #{value.map { |k, v| "#{k}: #{association_option_text(v)}" }.join(', ')} }"
|
|
1529
|
+
else value.to_s
|
|
1530
|
+
end
|
|
1531
|
+
end
|
|
1532
|
+
|
|
1278
1533
|
# A habtm join_table built from the class's affixes, as the table it names.
|
|
1279
1534
|
def with_join_tables(associations, path, class_name)
|
|
1280
1535
|
own = nil
|
|
@@ -1308,7 +1563,18 @@ module RailsAiContext
|
|
|
1308
1563
|
else value.to_s
|
|
1309
1564
|
end
|
|
1310
1565
|
end
|
|
1311
|
-
|
|
1566
|
+
lifted = with_rails_option_rules(lifted, options)
|
|
1567
|
+
declared = declared_association_options(lifted[:type], options)
|
|
1568
|
+
with_default_foreign_key(declared ? lifted.merge(declared_options: declared) : lifted)
|
|
1569
|
+
end
|
|
1570
|
+
|
|
1571
|
+
# `required:` on a belongs_to sets `optional:` to its negation; Rails 7.1 takes `query_constraints:` as the foreign key.
|
|
1572
|
+
def with_rails_option_rules(assoc, options)
|
|
1573
|
+
required = literal_boolean(options[:required]) if options.key?(:required)
|
|
1574
|
+
assoc = assoc.merge(optional: !required) if assoc[:type] == "belongs_to" && !required.nil?
|
|
1575
|
+
keys = options[:query_constraints]
|
|
1576
|
+
assoc = assoc.merge(foreign_key: keys.map(&:to_s)) if keys.is_a?(Array) && !assoc.key?(:foreign_key)
|
|
1577
|
+
assoc
|
|
1312
1578
|
end
|
|
1313
1579
|
|
|
1314
1580
|
# Reflection answers a belongs_to's key when none is declared: the name plus _id.
|
|
@@ -1338,10 +1604,11 @@ module RailsAiContext
|
|
|
1338
1604
|
end
|
|
1339
1605
|
|
|
1340
1606
|
def static_model_details(path, class_name, file: relative_to_root(path), table_name: nil, inherited_from: [],
|
|
1341
|
-
sti: nil, parent_model: nil)
|
|
1607
|
+
sti: nil, parent_model: nil, primary_key: nil)
|
|
1342
1608
|
own = own_body(source_walk(path), class_name)
|
|
1343
|
-
calls =
|
|
1344
|
-
data, unread, bases_unread, hidden = merge_class_and_bases(own, class_name, calls, inherited_from, file: path
|
|
1609
|
+
calls = singleton_lookup([ [ class_name, path ], *Array(inherited_from) ])
|
|
1610
|
+
data, unread, bases_unread, hidden = merge_class_and_bases(own, class_name, calls, inherited_from, file: path,
|
|
1611
|
+
commits_in_order: static_commits_in_order)
|
|
1345
1612
|
own_methods = ActionResolver.own_methods(own[:methods], class_name)
|
|
1346
1613
|
scope_names = Array(data[:scopes]).filter_map { |scope| scope[:name]&.to_s }.to_set
|
|
1347
1614
|
static_instance_methods = own_methods.select { |m| m[:scope] == :instance && m[:visibility] == :public }.map { |m| m[:name].to_s }
|
|
@@ -1350,15 +1617,19 @@ module RailsAiContext
|
|
|
1350
1617
|
details = {
|
|
1351
1618
|
confidence: Confidence::STATIC,
|
|
1352
1619
|
table_name: table_name || TableName.stem(path),
|
|
1620
|
+
primary_key: primary_key,
|
|
1353
1621
|
associations: with_join_tables(reject_excluded_associations(data[:associations]), path, class_name),
|
|
1354
1622
|
validations: static_validations(data, [ path, *Array(inherited_from).map(&:last) ]),
|
|
1355
1623
|
custom_validates: extract_custom_validates_from_ast(data),
|
|
1356
1624
|
custom_validate_conditions: custom_validate_conditions(data),
|
|
1357
|
-
|
|
1625
|
+
custom_validate_blocks: custom_validate_blocks(data),
|
|
1626
|
+
scopes: Array(data[:scopes]).reject { |s| s[:default] },
|
|
1627
|
+
default_scopes: default_scopes(data),
|
|
1358
1628
|
# The booted tier answers a Hash of attribute => value map, and
|
|
1359
1629
|
# every renderer destructures one; the listener's records are a
|
|
1360
1630
|
# different shape under the same key.
|
|
1361
1631
|
enums: static_enums(data[:enums]),
|
|
1632
|
+
enum_options: extract_enum_options_from_ast(data).presence,
|
|
1362
1633
|
# Same shape as the booted tier: a Hash keyed by callback type. The
|
|
1363
1634
|
# listener hands back a flat Array, and every consumer filters on
|
|
1364
1635
|
# `callbacks.is_a?(Hash)` - so passing it through rendered "No models
|
|
@@ -1372,6 +1643,7 @@ module RailsAiContext
|
|
|
1372
1643
|
concern_callbacks: concern_callbacks(data[:callbacks]),
|
|
1373
1644
|
concerns_unread: (unread if unread.any?),
|
|
1374
1645
|
bases_unread: (bases_unread if bases_unread.any?),
|
|
1646
|
+
commit_order_unread: commit_order_unread(data[:callbacks]),
|
|
1375
1647
|
conditional_declarations: declarations(data[:conditional]),
|
|
1376
1648
|
foreign_declarations: declarations(data[:foreign]),
|
|
1377
1649
|
macros: data[:macros],
|
|
@@ -1391,6 +1663,8 @@ module RailsAiContext
|
|
|
1391
1663
|
}
|
|
1392
1664
|
details.merge!(extract_macros_from_ast(data, path))
|
|
1393
1665
|
details.merge!(extract_detailed_macros_from_ast(data))
|
|
1666
|
+
type_column = static_type_column(data)
|
|
1667
|
+
details[:sti] = sti && type_column && !lacks_column?(details[:table_name], type_column) ? sti.merge(type_column: type_column) : nil
|
|
1394
1668
|
downgrade_records(details.compact)
|
|
1395
1669
|
end
|
|
1396
1670
|
|
|
@@ -1400,6 +1674,9 @@ module RailsAiContext
|
|
|
1400
1674
|
# associations, on that tier.
|
|
1401
1675
|
MERGED_CONCERN_KEYS = %i[associations validations scopes enums callbacks macros].freeze
|
|
1402
1676
|
|
|
1677
|
+
# The class's own declarations a method body can hold; MacrosListener skips method bodies itself.
|
|
1678
|
+
METHOD_PLACED_KEYS = %i[validations associations scopes enums].freeze
|
|
1679
|
+
|
|
1403
1680
|
# The merged keys, plus what a mixin's macro methods declare only under a
|
|
1404
1681
|
# condition the call cannot decide.
|
|
1405
1682
|
WALKED_KEYS = [ *MERGED_CONCERN_KEYS, :conditional, :foreign ].freeze
|
|
@@ -1408,36 +1685,73 @@ module RailsAiContext
|
|
|
1408
1685
|
# so on the booted tier an unread concern costs the other keys only.
|
|
1409
1686
|
REFLECTED_CONCERN_KEYS = %i[associations validations enums].freeze
|
|
1410
1687
|
|
|
1411
|
-
# The
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
# sum of what it included.
|
|
1415
|
-
# The modules mixed into every model from outside, and the app modules
|
|
1416
|
-
# the class extends itself with: a macro method one of them defines is
|
|
1417
|
-
# read where the class calls it. An extended module whose file is not the
|
|
1418
|
-
# app's is a gem's, and a gem's macro methods are not read.
|
|
1419
|
-
def class_extras(own, class_name)
|
|
1420
|
-
extended = Array(own[:mixins]).filter_map do |mixin|
|
|
1421
|
-
next unless mixin[:macro] == :extend && !mixin[:receiver]
|
|
1422
|
-
next unless ConcernMembership.candidate?(mixin[:name])
|
|
1423
|
-
|
|
1424
|
-
path = ConcernPaths.find_file(app.root.to_s, mixin[:name], prefer: "model", within: class_name)
|
|
1425
|
-
BaseMixins::Mixin.new(mixin[:name], path, :extend) if path
|
|
1426
|
-
end
|
|
1427
|
-
BaseMixins.models(app.root.to_s) + extended
|
|
1688
|
+
# The walk only collects: where a declaration lands waits for every class's walk.
|
|
1689
|
+
Walk = Struct.new(:own, :collected, :unread, :hidden, :skipped, :rank) do
|
|
1690
|
+
def self.empty(own) = new(own, {}, [], [], Set.new, 0)
|
|
1428
1691
|
end
|
|
1429
1692
|
|
|
1430
|
-
def
|
|
1431
|
-
|
|
1693
|
+
def walk_class(own, class_name, calls, extra: [], file: nil, rank: 0)
|
|
1694
|
+
found = ConcernMacros.collect(
|
|
1432
1695
|
app.root.to_s, own[:mixins] || [],
|
|
1433
1696
|
keys: [ *WALKED_KEYS, :expanded ], prefer: "model", within: class_name,
|
|
1434
1697
|
cache: @source_cache, calls: calls, extra: extra, file: file
|
|
1435
1698
|
)
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1699
|
+
placement = found.placement
|
|
1700
|
+
included_at = included_at(own, placement)
|
|
1701
|
+
every = extra.map(&:name).to_set
|
|
1702
|
+
bodies = method_bodies(own)
|
|
1703
|
+
mixins = found.mixins.map do |label, macro, defs, in_module|
|
|
1704
|
+
line, order = included_at.call(label)
|
|
1705
|
+
added, line = in_module || [ ConcernMacros::SingletonLookup.added_in(rank, line, bodies), line ]
|
|
1706
|
+
ConcernMacros::SingletonLookup::Mixin.new(label, macro, defs, added, [ line, order ], every.include?(placement.dig(label, 0)))
|
|
1707
|
+
end
|
|
1708
|
+
calls.add(rank, found.included_calls, found.block_sites, mixins)
|
|
1709
|
+
Walk.new(own, found.collected, found.unread, found.hidden, found.skipped, rank)
|
|
1710
|
+
end
|
|
1439
1711
|
|
|
1440
|
-
|
|
1712
|
+
# Where a concern the walk read joins the class's chain: at the line
|
|
1713
|
+
# including it, after the concerns that include brought in before it.
|
|
1714
|
+
def included_at(own, placement)
|
|
1715
|
+
line_of = Array(own[:mixins]).reverse.to_h { |mixin| [ mixin[:name], mixin[:location].to_i ] }
|
|
1716
|
+
lambda do |name|
|
|
1717
|
+
top, order = placement[name]
|
|
1718
|
+
top ? [ line_of[top].to_i, order.to_i ] : [ 0, 0 ]
|
|
1719
|
+
end
|
|
1720
|
+
end
|
|
1721
|
+
|
|
1722
|
+
# A walk's declarations where Ruby runs them: one in a method body, the class's own or a
|
|
1723
|
+
# module's, once per call running that body; a concern's where its code runs.
|
|
1724
|
+
def settle(walk, calls, expanded = nil)
|
|
1725
|
+
own, rank = walk.own, walk.rank
|
|
1726
|
+
collected = walk.collected.to_h do |key, entries|
|
|
1727
|
+
[ key, entries.flat_map { |entry| place(entry, rank, calls) }.then { |found| key == :callbacks ? found : found.map { |entry| entry.except(*CHAIN_KEYS) }.uniq } ]
|
|
1728
|
+
end
|
|
1729
|
+
own = without_expanded_calls(own, Array(collected.delete(:expanded)) + Array(expanded))
|
|
1730
|
+
bodies = method_bodies(own)
|
|
1731
|
+
callbacks = Array(own[:callbacks]).flat_map { |cb| placed_in_method(cb, bodies, rank, calls) || [ cb.merge(rank: rank) ] }
|
|
1732
|
+
own = own.merge(callbacks: callbacks + Array(collected.delete(:callbacks)))
|
|
1733
|
+
own = own.merge(METHOD_PLACED_KEYS.to_h { |key| [ key, Array(own[key]).select { |entry| (placed_in_method(entry, bodies, rank, calls) || [ entry ]).any? } ] })
|
|
1734
|
+
[ collected.empty? ? own : merge_inherited(own, collected), walk.unread, walk.hidden ]
|
|
1735
|
+
end
|
|
1736
|
+
|
|
1737
|
+
# Nil outside a method body; else where the calls running that method place the
|
|
1738
|
+
# declaration, none for an instance method.
|
|
1739
|
+
def placed_in_method(entry, bodies, rank, calls)
|
|
1740
|
+
line = entry[:location] if entry.is_a?(Hash)
|
|
1741
|
+
method = line && ConcernMacros.enclosing(bodies, line)&.last
|
|
1742
|
+
# A `def self.default_scope` is the declaration itself, not a body holding one.
|
|
1743
|
+
return nil if method.nil? || (method[:name] == entry[:name] && method[:location] == line)
|
|
1744
|
+
return [] unless method[:scope] == :class
|
|
1745
|
+
|
|
1746
|
+
calls.placed(entry, [ rank, method[:location] ], [ line ])
|
|
1747
|
+
end
|
|
1748
|
+
|
|
1749
|
+
def place(entry, rank, calls)
|
|
1750
|
+
entry.is_a?(Hash) ? calls.mixed_in(entry, rank) : [ entry ]
|
|
1751
|
+
end
|
|
1752
|
+
|
|
1753
|
+
def method_bodies(own)
|
|
1754
|
+
Array(own[:methods]).filter_map { |m| [ m[:location]..m[:end_location], m ] if m[:location] && m[:end_location] }
|
|
1441
1755
|
end
|
|
1442
1756
|
|
|
1443
1757
|
# A `validates_translation :title` call the listener read as a validation
|
|
@@ -1457,33 +1771,133 @@ module RailsAiContext
|
|
|
1457
1771
|
#
|
|
1458
1772
|
# Each side is walked again only when the other has since learned a call
|
|
1459
1773
|
# that releases something it held back.
|
|
1460
|
-
def merge_class_and_bases(own, class_name, calls, bases, file:)
|
|
1461
|
-
extra =
|
|
1462
|
-
mine =
|
|
1774
|
+
def merge_class_and_bases(own, class_name, calls, bases, file:, commits_in_order:)
|
|
1775
|
+
extra = BaseMixins.models(app.root.to_s)
|
|
1776
|
+
mine = walk_class(own, class_name, calls, extra: extra, file: file)
|
|
1463
1777
|
walked = walk_bases(bases, calls)
|
|
1464
1778
|
ConcernMacros::MAX_DEPTH.times do
|
|
1465
|
-
known = Set.new(calls.
|
|
1466
|
-
break unless mine.
|
|
1779
|
+
known = Set.new(calls.sites_by_name.keys)
|
|
1780
|
+
break unless mine.skipped.intersect?(known)
|
|
1467
1781
|
|
|
1468
|
-
mine =
|
|
1469
|
-
held = walked.flat_map { |_name,
|
|
1470
|
-
grown = Set.new(calls.
|
|
1782
|
+
mine = walk_class(own, class_name, calls, extra: extra, file: file)
|
|
1783
|
+
held = walked.flat_map { |_name, walk| walk ? walk.skipped.to_a : [] }.to_set
|
|
1784
|
+
grown = Set.new(calls.sites_by_name.keys) - known
|
|
1471
1785
|
walked = walk_bases(bases, calls) if held.intersect?(grown)
|
|
1472
1786
|
end
|
|
1473
|
-
|
|
1474
|
-
|
|
1787
|
+
declared = base_declarations(calls, [ [ class_name, file ], *bases ])
|
|
1788
|
+
expanded = Array(declared.delete(:expanded)).group_by { |call| call[:rank] }
|
|
1789
|
+
data, unread, hidden = settle(mine, calls, expanded[0])
|
|
1790
|
+
data = merge_inherited(data, declared)
|
|
1791
|
+
settled = walked.map { |name, walk| walk ? [ name, walk.own, *settle(walk, calls, expanded[walk.rank]) ] : [ name, nil ] }
|
|
1792
|
+
data, *rest = merge_inherited_macros(data, unread, hidden, settled)
|
|
1793
|
+
[ data.merge(callbacks: chain_order(data[:callbacks], commits_in_order)), *rest ]
|
|
1794
|
+
end
|
|
1795
|
+
|
|
1796
|
+
CHAIN_KEYS = %i[rank chain_at hook owner].freeze
|
|
1797
|
+
|
|
1798
|
+
# What the class methods the app's base bodies give the class declare, at each call reaching them,
|
|
1799
|
+
# with the calls read that way (`:expanded`) by the rank they stand at.
|
|
1800
|
+
def base_declarations(calls, classes)
|
|
1801
|
+
found = Hash.new { |hash, key| hash[key] = [] }
|
|
1802
|
+
base_defs(classes).each do |node, definition|
|
|
1803
|
+
next unless node.is_a?(Prism::DefNode)
|
|
1804
|
+
|
|
1805
|
+
sites = Array(calls.sites_by_name[definition.name]).compact
|
|
1806
|
+
expansion, = ConcernMacros.expand_calls(node, sites, [ *WALKED_KEYS, :expanded ], SourceIntrospector::LISTENER_MAP) do |entry, site|
|
|
1807
|
+
calls.placed(entry, definition.key, [ entry[:location] ], site)
|
|
1808
|
+
end
|
|
1809
|
+
expansion.each do |key, entries|
|
|
1810
|
+
found[key].concat(%i[callbacks expanded].include?(key) ? entries : entries.map { |entry| entry.except(*CHAIN_KEYS) }.uniq)
|
|
1811
|
+
end
|
|
1812
|
+
end
|
|
1813
|
+
found.to_h
|
|
1814
|
+
end
|
|
1815
|
+
|
|
1816
|
+
# Rails builds the chain from the outermost base in, each class in the
|
|
1817
|
+
# order its body runs.
|
|
1818
|
+
def chain_order(callbacks, commits_in_order)
|
|
1819
|
+
sorted = Array(callbacks).each_with_index.sort_by do |cb, index|
|
|
1820
|
+
[ -cb[:rank].to_i, cb[:chain_at] || [ cb[:location].to_i, -1 ], index ]
|
|
1821
|
+
end
|
|
1822
|
+
run_order(callback_chain(without_skipped(sorted.map(&:first))), commits_in_order).map { |cb| cb.except(*CHAIN_KEYS) }
|
|
1823
|
+
end
|
|
1824
|
+
|
|
1825
|
+
# A `skip_callback` removes the matching callback already in the chain;
|
|
1826
|
+
# with `if:`/`unless:` Rails keeps it under the negated condition.
|
|
1827
|
+
def without_skipped(callbacks)
|
|
1828
|
+
callbacks.each_with_object([]) do |cb, kept|
|
|
1829
|
+
next kept << cb unless cb[:skip]
|
|
1830
|
+
|
|
1831
|
+
key = Listeners::CallbacksListener.chain_key(cb[:type])
|
|
1832
|
+
matching = ->(k) { Listeners::CallbacksListener.chain_key(k[:type]) == key && k[:method].to_s == cb[:method].to_s }
|
|
1833
|
+
conditions = (cb[:options] || {}).slice(:if, :unless)
|
|
1834
|
+
if conditions.empty?
|
|
1835
|
+
kept.reject!(&matching)
|
|
1836
|
+
else
|
|
1837
|
+
kept.map! { |k| matching.call(k) ? k.merge(options: negated_conditions(k[:options] || {}, conditions)) : k }
|
|
1838
|
+
end
|
|
1839
|
+
end
|
|
1840
|
+
end
|
|
1841
|
+
|
|
1842
|
+
def negated_conditions(options, skip)
|
|
1843
|
+
flipped = { unless: skip[:if], if: skip[:unless] }.compact
|
|
1844
|
+
options.merge(flipped) { |_key, ours, theirs| [ *Array(ours), theirs ] }
|
|
1845
|
+
end
|
|
1846
|
+
|
|
1847
|
+
# A prepended callback goes to the front and after callbacks run from the
|
|
1848
|
+
# back; ActiveModel prepends every after_*, after_commit only on request.
|
|
1849
|
+
def run_order(callbacks, commits_in_order)
|
|
1850
|
+
ordered = callbacks.dup
|
|
1851
|
+
callbacks.each_index.group_by { |i| Listeners::CallbacksListener.chain_key(callbacks[i][:type]) }.each_value do |slots|
|
|
1852
|
+
chain = slots.each_with_object([]) do |i, list|
|
|
1853
|
+
prepended?(callbacks[i], commits_in_order) ? list.unshift(callbacks[i]) : list.push(callbacks[i])
|
|
1854
|
+
end
|
|
1855
|
+
chain.reverse! if Listeners::CallbacksListener.after?(callbacks[slots.first][:type])
|
|
1856
|
+
slots.zip(chain) { |i, cb| ordered[i] = cb }
|
|
1857
|
+
end
|
|
1858
|
+
ordered
|
|
1859
|
+
end
|
|
1860
|
+
|
|
1861
|
+
# An unknown setting keeps the transaction callbacks in declaration order.
|
|
1862
|
+
def prepended?(callback, commits_in_order)
|
|
1863
|
+
declared = callback.dig(:options, :prepend).to_s == "true"
|
|
1864
|
+
return declared unless Listeners::CallbacksListener.after?(callback[:type])
|
|
1865
|
+
return true unless Listeners::CallbacksListener.transaction?(callback[:type])
|
|
1866
|
+
|
|
1867
|
+
declared || commits_in_order != false
|
|
1868
|
+
end
|
|
1869
|
+
|
|
1870
|
+
# Rails 7.0 has no setting and always runs them last declared first.
|
|
1871
|
+
def booted_commits_in_order
|
|
1872
|
+
ActiveRecord.respond_to?(:run_after_transaction_callbacks_in_order_defined) &&
|
|
1873
|
+
ActiveRecord.run_after_transaction_callbacks_in_order_defined == true
|
|
1874
|
+
end
|
|
1875
|
+
|
|
1876
|
+
# nil when the config cannot say: no config/application.rb, a version or
|
|
1877
|
+
# a value that is not a literal. A chain order has no way to carry a condition.
|
|
1878
|
+
def static_commits_in_order
|
|
1879
|
+
setting = framework_setting(:run_after_transaction_callbacks_in_order_defined=, since: 7.1)
|
|
1880
|
+
return (setting.value unless setting.value.is_a?(String)) if setting.assigned
|
|
1881
|
+
return nil if !setting.read || setting.version == Float::INFINITY
|
|
1882
|
+
|
|
1883
|
+
setting.version.to_f >= 7.1
|
|
1884
|
+
end
|
|
1885
|
+
|
|
1886
|
+
# Only a chain with two of them can be out of order.
|
|
1887
|
+
def commit_order_unread(callbacks)
|
|
1888
|
+
return nil unless static_commits_in_order.nil?
|
|
1889
|
+
|
|
1890
|
+
chains = Array(callbacks).select { |cb| Listeners::CallbacksListener.transaction?(cb[:type]) }.map { |cb| Listeners::CallbacksListener.chain_key(cb[:type]) }
|
|
1891
|
+
true if chains.tally.values.any? { |count| count > 1 }
|
|
1475
1892
|
end
|
|
1476
1893
|
|
|
1477
1894
|
# Each base read with its concerns; a base's concern macro that sits in a
|
|
1478
1895
|
# class method runs for the class that calls it, the child as often as
|
|
1479
1896
|
# the base.
|
|
1480
1897
|
def walk_bases(bases, calls)
|
|
1481
|
-
Array(bases).map do |name, path|
|
|
1898
|
+
Array(bases).each_with_index.map do |(name, path), index|
|
|
1482
1899
|
own = sti_base_source(path)
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
own = own_body(own, name)
|
|
1486
|
-
[ name, own, *merge_concern_macros(own, name, calls, file: path) ]
|
|
1900
|
+
[ name, own && walk_class(own_body(own, name), name, calls, file: path, rank: index + 1) ]
|
|
1487
1901
|
end
|
|
1488
1902
|
end
|
|
1489
1903
|
|
|
@@ -1503,7 +1917,9 @@ module RailsAiContext
|
|
|
1503
1917
|
next
|
|
1504
1918
|
end
|
|
1505
1919
|
|
|
1506
|
-
|
|
1920
|
+
# A connection is declared on an abstract base, so the child says which one.
|
|
1921
|
+
macros = Array(base[:macros]).map { |m| m[:macro] == :connects_to ? m.merge(declared_in: name) : m }
|
|
1922
|
+
data = merge_inherited(data, base.slice(*WALKED_KEYS).merge(macros: macros))
|
|
1507
1923
|
# A base's concerns are the child's too: the child's record already
|
|
1508
1924
|
# carries what they declared, and its callbacks credit them by name.
|
|
1509
1925
|
data[:mixins] = Array(data[:mixins]) | Array(own[:mixins])
|
|
@@ -1553,21 +1969,18 @@ module RailsAiContext
|
|
|
1553
1969
|
def merge_inherited(mine, inherited)
|
|
1554
1970
|
merged = mine.merge(inherited) { |_key, ours, theirs| Array(ours) + Array(theirs) }
|
|
1555
1971
|
merged[:associations] = dedup(merged[:associations]) { |a| [ a[:type], a[:name] ] }
|
|
1556
|
-
|
|
1972
|
+
# Default scopes stack base first; a named scope the model redefines is its own.
|
|
1973
|
+
base_defaults, base_named = Array(inherited[:scopes]).partition { |s| s[:default] }
|
|
1974
|
+
own_defaults, own_named = Array(mine[:scopes]).partition { |s| s[:default] }
|
|
1975
|
+
merged[:scopes] = dedup(base_defaults + own_defaults + own_named + base_named) { |s| s[:default] ? [ s[:name], s[:body] ] : s[:name] }
|
|
1557
1976
|
merged[:enums] = dedup(merged[:enums]) { |e| e[:name].to_s }
|
|
1558
1977
|
# `encrypts :secret` on a base and again on the child is one macro, and
|
|
1559
1978
|
# the consumers read it as a list of attributes. The key is the
|
|
1560
1979
|
# declaration: the line it was read at differs between two files, and
|
|
1561
1980
|
# the concern tag differs between two ways of reaching one file.
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
# twice, so these two are not deduped alike. Two blocks are two
|
|
1566
|
-
# callbacks unless they are one line of one file read twice.
|
|
1567
|
-
merged[:callbacks] = dedup(merged[:callbacks]) do |c|
|
|
1568
|
-
block = c[:method].to_s == Listeners::CallbacksListener::INLINE_BLOCK
|
|
1569
|
-
[ c[:type], c[:method].to_s, (block ? [ c[:from_concern], c[:location] ] : nil) ]
|
|
1570
|
-
end
|
|
1981
|
+
# Bases first, the order Rails runs them: a child's `ignored_columns +=` adds to its base's list.
|
|
1982
|
+
merged[:macros] = dedup(Array(inherited[:macros]) + Array(mine[:macros])) { |m| m.except(:from_concern, :location, :owner) }
|
|
1983
|
+
merged[:callbacks] = Array(inherited[:callbacks]) + Array(mine[:callbacks])
|
|
1571
1984
|
# One source line read twice is still one declaration: a concern the
|
|
1572
1985
|
# model and one of its bases both include is walked once per class, and
|
|
1573
1986
|
# `included do` runs once. Two validations really written twice differ
|
|
@@ -1576,6 +1989,16 @@ module RailsAiContext
|
|
|
1576
1989
|
merged
|
|
1577
1990
|
end
|
|
1578
1991
|
|
|
1992
|
+
SYMBOL_TARGET = /\A[a-z_]\w*[?!]?\z/
|
|
1993
|
+
|
|
1994
|
+
# Rails keeps the later of a symbol declared twice; two blocks stay two.
|
|
1995
|
+
def callback_chain(callbacks)
|
|
1996
|
+
keys = callbacks.each_with_index.map do |c, i|
|
|
1997
|
+
c[:method].to_s.match?(SYMBOL_TARGET) ? [ Listeners::CallbacksListener.chain_key(c[:type]), c[:method].to_s ] : i
|
|
1998
|
+
end
|
|
1999
|
+
keys.zip(callbacks).reverse.uniq(&:first).reverse.map(&:last)
|
|
2000
|
+
end
|
|
2001
|
+
|
|
1579
2002
|
# The model's own declaration wins: it is the one whose options the
|
|
1580
2003
|
# class actually runs with.
|
|
1581
2004
|
def dedup(entries)
|
|
@@ -1628,24 +2051,31 @@ module RailsAiContext
|
|
|
1628
2051
|
def mongoid_static_models
|
|
1629
2052
|
entries = mongoid_candidates
|
|
1630
2053
|
models = mongoid_model_names(entries)
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
end
|
|
1636
|
-
next unless models.include?(class_name)
|
|
2054
|
+
done = {}
|
|
2055
|
+
entries.each_key { |class_name| mongoid_entry(class_name, entries, models, done) }
|
|
2056
|
+
entries.keys.select { |class_name| done[class_name] }.to_h { |class_name| [ class_name, done[class_name].except(:settled_callbacks) ] }
|
|
2057
|
+
end
|
|
1637
2058
|
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
2059
|
+
# A subclass of a document is a document; its parent is read first.
|
|
2060
|
+
def mongoid_entry(class_name, entries, models, done)
|
|
2061
|
+
return done[class_name] if done.key?(class_name)
|
|
2062
|
+
|
|
2063
|
+
entry = entries[class_name]
|
|
2064
|
+
return done[class_name] = { error: entry[:error] } if entry[:error]
|
|
2065
|
+
return done[class_name] = nil unless models.include?(class_name)
|
|
2066
|
+
|
|
2067
|
+
done[class_name] = false
|
|
2068
|
+
parent_name = entry[:superclass] && SuperclassChain.resolve_in_scope(class_name, entry[:superclass], nesting: entry[:nesting]) { |q| q if models.include?(q) }
|
|
2069
|
+
parent = parent_name && mongoid_entry(parent_name, entries, models, done)
|
|
2070
|
+
done[class_name] = if entry[:source].include?("Mongoid::Document") || (parent.is_a?(Hash) && parent[:mongoid])
|
|
2071
|
+
mongoid_model_details(entry[:source], class_name, entry[:path], parent: parent && parent[:mongoid] ? [ parent_name, parent ] : nil)
|
|
2072
|
+
.merge(file: relative_to_root(entry[:path]))
|
|
2073
|
+
else
|
|
2074
|
+
# A hybrid app's AR model gets only its own or the derived table here: no namespace prefix, no STI parent.
|
|
2075
|
+
static_model_details(entry[:path], class_name, table_name: TableName.explicit(entry[:source], class_name, app.root))
|
|
1648
2076
|
end
|
|
2077
|
+
rescue => e
|
|
2078
|
+
done[class_name] = { error: e.message }
|
|
1649
2079
|
end
|
|
1650
2080
|
|
|
1651
2081
|
def mongoid_candidates
|
|
@@ -1665,8 +2095,8 @@ module RailsAiContext
|
|
|
1665
2095
|
class_name = DeclaredConstant.resolve(source, relative.camelize)
|
|
1666
2096
|
next if found.key?(class_name) || config.excluded_models.include?(class_name)
|
|
1667
2097
|
|
|
1668
|
-
declaration = DeclaredConstant.declaration_for(DeclaredConstant.declarations(source), class_name)
|
|
1669
|
-
found[class_name] = { path: path, source: source, superclass: declaration&.superclass }
|
|
2098
|
+
declaration = DeclaredConstant.declaration_for(DeclaredConstant.declarations(source, path_name: relative.camelize), class_name)
|
|
2099
|
+
found[class_name] = { path: path, source: source, superclass: declaration&.superclass, nesting: declaration&.nesting }
|
|
1670
2100
|
rescue => e
|
|
1671
2101
|
found[relative.camelize] ||= { error: e.message }
|
|
1672
2102
|
end
|
|
@@ -1684,7 +2114,7 @@ module RailsAiContext
|
|
|
1684
2114
|
added = entries.select do |name, entry|
|
|
1685
2115
|
parent = entry[:superclass]
|
|
1686
2116
|
!names.include?(name) && parent &&
|
|
1687
|
-
SuperclassChain.resolve_in_scope(name, parent) { |q| q if names.include?(q) }
|
|
2117
|
+
SuperclassChain.resolve_in_scope(name, parent, nesting: entry[:nesting]) { |q| q if names.include?(q) }
|
|
1688
2118
|
end.keys
|
|
1689
2119
|
break if added.empty?
|
|
1690
2120
|
|
|
@@ -1704,7 +2134,10 @@ module RailsAiContext
|
|
|
1704
2134
|
# A path the read could not answer falls through to the path walk, which raises. A class
|
|
1705
2135
|
# nested in the model's file includes for itself, not for the model.
|
|
1706
2136
|
def own_body(data, class_name)
|
|
1707
|
-
data.merge(mixins: ConcernMembership.
|
|
2137
|
+
data.merge(mixins: ConcernMembership.owned_by(data[:mixins], class_name, root: app.root),
|
|
2138
|
+
callbacks: ConcernMembership.owned_by(data[:callbacks], class_name),
|
|
2139
|
+
macros: ConcernMembership.owned_by(data[:macros], class_name),
|
|
2140
|
+
scopes: ConcernMembership.owned_by(data[:scopes], class_name).map { |scope| scope.except(:owner) })
|
|
1708
2141
|
end
|
|
1709
2142
|
|
|
1710
2143
|
def source_walk(path)
|
|
@@ -1712,70 +2145,131 @@ module RailsAiContext
|
|
|
1712
2145
|
source ? SourceIntrospector.walk_source(source) : SourceIntrospector.call(path)
|
|
1713
2146
|
end
|
|
1714
2147
|
|
|
1715
|
-
#
|
|
1716
|
-
#
|
|
1717
|
-
|
|
1718
|
-
# A concern's `included do` calls join the set too, so a base walked later sees them.
|
|
1719
|
-
def class_calls(paths)
|
|
2148
|
+
# The class files' side of the lookup, read only when a concern asks: their class-body
|
|
2149
|
+
# calls and their own class methods, `alias_method` in `class << self` included.
|
|
2150
|
+
def singleton_lookup(classes)
|
|
1720
2151
|
reader = lambda do
|
|
1721
|
-
|
|
2152
|
+
ranks = {}
|
|
2153
|
+
defs = []
|
|
2154
|
+
readable = classes.each_with_index.select { |(_, path), _| path && readable_source?(path) }
|
|
2155
|
+
found = readable.each_with_object({}) do |((name, path), rank), into|
|
|
1722
2156
|
source = model_source(path)
|
|
1723
|
-
|
|
1724
|
-
|
|
2157
|
+
scope = class_scope((source ? AstCache.parse_string(source) : AstCache.parse(path)).value, name)
|
|
2158
|
+
own = scope.select { |node| node.is_a?(Prism::CallNode) && (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)) }
|
|
2159
|
+
.group_by { |node| node.name.to_s }
|
|
2160
|
+
own.each_value { |sites| sites.each { |site| ranks[site.__id__] = rank } }
|
|
2161
|
+
defs.concat(ConcernMacros::SingletonLookup.own_defs(scope, rank))
|
|
2162
|
+
ConcernMacros::Run.merge_calls(into, own)
|
|
1725
2163
|
rescue StandardError, ScriptError => e
|
|
1726
2164
|
# A file the walk cannot read calls nothing it can see.
|
|
1727
2165
|
RailsAiContext.debug_fail(e, nil, label: "class calls of #{path}")
|
|
1728
2166
|
end
|
|
2167
|
+
defs.concat(base_defs(classes).map(&:last))
|
|
2168
|
+
ConcernMacros::SingletonLookup::Read.new(found, ranks, defs, classes.size)
|
|
1729
2169
|
end
|
|
1730
|
-
|
|
2170
|
+
ConcernMacros::SingletonLookup.new(reader)
|
|
1731
2171
|
end
|
|
1732
2172
|
|
|
1733
|
-
#
|
|
1734
|
-
#
|
|
1735
|
-
|
|
1736
|
-
|
|
2173
|
+
# [node, definition] for each class method a base body outside app/models gives the class: one
|
|
2174
|
+
# reopening ApplicationRecord at that base's rank, the rest at the outermost; each after its base's file.
|
|
2175
|
+
def base_defs(classes)
|
|
2176
|
+
application = classes.index { |name, _| name == "ApplicationRecord" }
|
|
2177
|
+
BaseMixins.bodies(app.root.to_s).flat_map do |body|
|
|
2178
|
+
rank = body.target == "ApplicationRecord" ? application : classes.size
|
|
2179
|
+
next [] unless rank
|
|
1737
2180
|
|
|
1738
|
-
|
|
1739
|
-
@own ||= reader.call
|
|
2181
|
+
ConcernMacros::SingletonLookup.member_defs(body.statements, rank, body.order)
|
|
1740
2182
|
end
|
|
1741
2183
|
end
|
|
1742
2184
|
|
|
2185
|
+
# The nodes the class body runs with the class as self: the node opening a
|
|
2186
|
+
# method, nested class or `class << x` is there, its body is not.
|
|
2187
|
+
def class_scope(tree, class_name)
|
|
2188
|
+
short = class_name.to_s.split("::").last.to_s
|
|
2189
|
+
roots = AstWalk.each(tree).select { |node| node.is_a?(Prism::ClassNode) && node.constant_path.slice.split("::").last.casecmp?(short) }
|
|
2190
|
+
roots.empty? ? AstWalk.scope(tree) : roots.flat_map { |root| AstWalk.scope(root.body) }
|
|
2191
|
+
end
|
|
1743
2192
|
|
|
1744
|
-
|
|
2193
|
+
MONGOID_MACROS = -> { Listeners::GenericMacroListener.new(%i[field embeds_many embeds_one embedded_in store_in index], call_source: %i[index]) }
|
|
2194
|
+
|
|
2195
|
+
def mongoid_model_details(source, class_name, path, parent: nil)
|
|
1745
2196
|
data = SourceIntrospector.walk_source(source, {
|
|
1746
|
-
mongoid:
|
|
2197
|
+
mongoid: MONGOID_MACROS,
|
|
1747
2198
|
associations: Listeners::AssociationsListener,
|
|
1748
2199
|
validations: Listeners::ValidationsListener,
|
|
1749
2200
|
scopes: Listeners::ScopesListener,
|
|
1750
2201
|
callbacks: Listeners::CallbacksListener,
|
|
1751
|
-
methods: Listeners::MethodsListener
|
|
2202
|
+
methods: Listeners::MethodsListener,
|
|
2203
|
+
mixins: Listeners::MixinsListener
|
|
1752
2204
|
})
|
|
1753
|
-
|
|
2205
|
+
# A concern's `included` block declares into the document before the body that follows it.
|
|
2206
|
+
from_concerns = ConcernMacros.collect(app.root.to_s, data[:mixins], keys: %i[mongoid], prefer: "model", within: class_name,
|
|
2207
|
+
listeners: { mongoid: MONGOID_MACROS, mixins: Listeners::MixinsListener }, file: path).collected
|
|
2208
|
+
macros = Array(from_concerns[:mongoid]) + Array(data[:mongoid])
|
|
2209
|
+
calls = singleton_lookup([ [ class_name, path ] ])
|
|
2210
|
+
calls.add(0, {}, {}, [])
|
|
2211
|
+
own = own_body(data.merge(mixins: []), class_name)
|
|
2212
|
+
parent_name, inherited = parent
|
|
2213
|
+
settled = settle(Walk.empty(own), calls).first[:callbacks]
|
|
2214
|
+
# A parent's callbacks join the chain as a base's do, one rank above the subclass's.
|
|
2215
|
+
settled = Array(inherited&.dig(:settled_callbacks)).map { |cb| cb.merge(rank: cb[:rank].to_i + 1) } + settled
|
|
2216
|
+
# Mongoid sets after_commit without prepend, as Rails 7.0 does, so it runs last declared first.
|
|
2217
|
+
callbacks = chain_order(settled, false)
|
|
1754
2218
|
details = {
|
|
1755
2219
|
confidence: Confidence::STATIC,
|
|
1756
2220
|
mongoid: true,
|
|
1757
|
-
fields: macros.select { |m| m[:macro] == :field }
|
|
1758
|
-
.map { |m| { name: m[:args].first, type: m[:options][:type] }.compact },
|
|
2221
|
+
fields: macros.select { |m| m[:macro] == :field }.map { |m| mongoid_field(m) },
|
|
1759
2222
|
embeds: macros.select { |m| %i[embeds_many embeds_one embedded_in].include?(m[:macro]) }
|
|
1760
2223
|
.map { |m| { type: m[:macro], name: m[:args].first } },
|
|
2224
|
+
indexes: macros.select { |m| m[:macro] == :index }.map { |m| m[:text] },
|
|
1761
2225
|
# An embedded child is a relation like any other, so every count and the graph see it.
|
|
1762
2226
|
associations: reject_excluded_associations(Array(data[:associations]) + embedded_associations(macros)),
|
|
1763
2227
|
validations: data[:validations],
|
|
1764
|
-
scopes:
|
|
2228
|
+
scopes: own[:scopes],
|
|
1765
2229
|
# Same shape as the booted tier: a Hash keyed by callback type. The
|
|
1766
2230
|
# listener hands back a flat Array, and every consumer filters on
|
|
1767
2231
|
# `callbacks.is_a?(Hash)` - so passing it through rendered "No models
|
|
1768
2232
|
# with callbacks found" and then raised a TypeError on a Hash lookup
|
|
1769
2233
|
# against an Array.
|
|
1770
|
-
callbacks: group_callbacks_by_type(
|
|
1771
|
-
callback_conditions: callback_conditions(
|
|
1772
|
-
methods: data[:methods]
|
|
2234
|
+
callbacks: group_callbacks_by_type(callbacks),
|
|
2235
|
+
callback_conditions: callback_conditions(callbacks),
|
|
2236
|
+
methods: data[:methods],
|
|
2237
|
+
settled_callbacks: settled
|
|
1773
2238
|
}
|
|
1774
|
-
|
|
1775
|
-
|
|
2239
|
+
if inherited
|
|
2240
|
+
{ fields: :name, embeds: :name, associations: :name, scopes: :name }.each do |key, name|
|
|
2241
|
+
mine = Array(details[key]).map { |entry| entry[name].to_s }
|
|
2242
|
+
details[key] = Array(inherited[key]).reject { |entry| mine.include?(entry[name].to_s) } + Array(details[key])
|
|
2243
|
+
end
|
|
2244
|
+
details[:validations] = (Array(inherited[:validations]) + Array(details[:validations])).uniq
|
|
2245
|
+
details[:parent_model] = parent_name
|
|
2246
|
+
end
|
|
2247
|
+
embedded = macros.find { |m| m[:macro] == :embedded_in }
|
|
2248
|
+
embedded_in = embedded ? embedded_parent(embedded) : inherited&.dig(:embedded_in)
|
|
2249
|
+
if embedded_in
|
|
2250
|
+
details[:embedded_in] = embedded_in
|
|
2251
|
+
else
|
|
2252
|
+
# Mongoid's collection_name: store_in's, else the root class's name tableized with "/" as "_".
|
|
2253
|
+
details[:collection] = macros.find { |m| m[:macro] == :store_in }&.dig(:options, :collection) ||
|
|
2254
|
+
inherited&.dig(:collection) || class_name.tableize.tr("/", "_")
|
|
2255
|
+
end
|
|
1776
2256
|
downgrade_records(details)
|
|
1777
2257
|
end
|
|
1778
2258
|
|
|
2259
|
+
# A default Mongoid computes (a lambda) is left out, as the replay leaves a computed column default.
|
|
2260
|
+
def mongoid_field(macro)
|
|
2261
|
+
options = macro[:options]
|
|
2262
|
+
default = SchemaConventions.format_default(options[:default]) unless options[:default] == Confidence::INFERRED
|
|
2263
|
+
{ name: macro[:args].first, type: options[:type], default: default }.compact
|
|
2264
|
+
end
|
|
2265
|
+
|
|
2266
|
+
def embedded_parent(macro)
|
|
2267
|
+
options = macro[:options] || {}
|
|
2268
|
+
return "#{macro[:args].first} (polymorphic)" if options[:polymorphic]
|
|
2269
|
+
|
|
2270
|
+
options[:class_name]&.to_s || macro[:args].first.to_s.camelize
|
|
2271
|
+
end
|
|
2272
|
+
|
|
1779
2273
|
def embedded_associations(macros)
|
|
1780
2274
|
macros.select { |m| %i[embeds_many embeds_one].include?(m[:macro]) }.map do |m|
|
|
1781
2275
|
{ name: m[:args].first.to_s, type: m[:macro].to_s, class_name: m[:options][:class_name]&.to_s }.compact
|