rigortype 0.3.5 → 0.3.7
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/README.md +5 -5
- data/data/builtins/ruby_core/date.yml +6 -3
- data/data/builtins/ruby_core/enumerable.yml +1 -0
- data/data/builtins/ruby_core/exception.yml +2 -1
- data/data/builtins/ruby_core/file.yml +28 -14
- data/data/builtins/ruby_core/hash.yml +5 -2
- data/data/builtins/ruby_core/io.yml +33 -16
- data/data/builtins/ruby_core/random.yml +2 -1
- data/data/builtins/ruby_core/re.yml +6 -3
- data/data/builtins/ruby_core/struct.yml +2 -0
- data/data/builtins/ruby_core/time.yml +2 -1
- data/data/gem_overlay/activesupport/core_ext.rbs +442 -11
- data/docs/handbook/02-everyday-types.md +1 -1
- data/docs/handbook/04-tuples-and-shapes.md +1 -1
- data/docs/handbook/08-understanding-errors.md +1 -1
- data/docs/handbook/09-plugins.md +2 -2
- data/docs/handbook/10-sorbet.md +1 -1
- data/docs/handbook/README.md +2 -2
- data/docs/handbook/appendix-go.md +1 -1
- data/docs/handbook/appendix-java-csharp.md +2 -2
- data/docs/handbook/appendix-mypy.md +1 -1
- data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
- data/docs/handbook/appendix-rust.md +1 -1
- data/docs/handbook/appendix-type-theory.md +2 -2
- data/docs/handbook/appendix-typescript.md +4 -4
- data/docs/manual/02-cli-reference.md +29 -16
- data/docs/manual/03-configuration.md +1 -1
- data/docs/manual/04-diagnostics.md +12 -0
- data/docs/manual/05-inspecting-types.md +20 -8
- data/docs/manual/07-plugins.md +4 -0
- data/docs/manual/08-skills.md +1 -1
- data/docs/manual/11-ci.md +1 -1
- data/docs/manual/12-caching.md +1 -1
- data/docs/manual/15-type-protection-coverage.md +1 -1
- data/docs/manual/18-removing-dead-code.md +13 -4
- data/docs/manual/19-effect-labels.md +19 -12
- data/docs/manual/README.md +2 -2
- data/docs/manual/plugins/rigor-actionmailer.md +4 -4
- data/docs/manual/plugins/rigor-actionpack.md +61 -0
- data/docs/manual/plugins/rigor-activejob.md +3 -3
- data/docs/manual/plugins/rigor-activerecord.md +78 -5
- data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
- data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
- data/docs/manual/plugins/rigor-rspec.md +6 -2
- data/docs/manual/plugins/rigor-sidekiq.md +22 -0
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
- data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
- data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
- data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
- data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
- data/lib/rigor/analysis/check_rules.rb +383 -48
- data/lib/rigor/analysis/crash_signature.rb +116 -0
- data/lib/rigor/analysis/dependency_recorder.rb +18 -3
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
- data/lib/rigor/analysis/diagnostic.rb +17 -6
- data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
- data/lib/rigor/analysis/incremental.rb +28 -0
- data/lib/rigor/analysis/incremental_session.rb +44 -9
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
- data/lib/rigor/analysis/reachability/graph.rb +44 -16
- data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
- data/lib/rigor/analysis/reachability/scan.rb +27 -7
- data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
- data/lib/rigor/analysis/result.rb +32 -0
- data/lib/rigor/analysis/run_cache_key.rb +53 -1
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +93 -1
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +14 -8
- data/lib/rigor/analysis/runner/pool_coordinator.rb +34 -0
- data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
- data/lib/rigor/analysis/runner/run_snapshots.rb +7 -2
- data/lib/rigor/analysis/runner.rb +298 -38
- data/lib/rigor/analysis/worker_session.rb +10 -1
- data/lib/rigor/bleeding_edge.rb +7 -6
- data/lib/rigor/builtins/hkt_builtins.rb +45 -6
- data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
- data/lib/rigor/builtins/static_return_refinements.rb +20 -1
- data/lib/rigor/cache/descriptor.rb +42 -2
- data/lib/rigor/cache/file_digest.rb +20 -2
- data/lib/rigor/cache/incremental_snapshot.rb +40 -3
- data/lib/rigor/cache/rbs_environment_marshal_patch.rb +31 -8
- data/lib/rigor/cache/store.rb +10 -1
- data/lib/rigor/cli/check_command.rb +67 -48
- data/lib/rigor/cli/coverage_command.rb +28 -24
- data/lib/rigor/cli/coverage_mutation.rb +13 -17
- data/lib/rigor/cli/coverage_scan.rb +47 -6
- data/lib/rigor/cli/doc_links.rb +100 -0
- data/lib/rigor/cli/docs_command.rb +32 -2
- data/lib/rigor/cli/effects_command.rb +27 -2
- data/lib/rigor/cli/effects_diff_renderer.rb +82 -12
- data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
- data/lib/rigor/cli/effects_snapshot_command.rb +52 -7
- data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
- data/lib/rigor/cli/fused_protection_report.rb +23 -2
- data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
- data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
- data/lib/rigor/cli/mutation_protection_report.rb +31 -2
- data/lib/rigor/cli/sig_gen_command.rb +18 -0
- data/lib/rigor/cli/type_of_command.rb +188 -41
- data/lib/rigor/cli/type_of_renderer.rb +72 -3
- data/lib/rigor/cli/type_scan_command.rb +10 -1
- data/lib/rigor/cli/unused_command.rb +65 -16
- data/lib/rigor/cli.rb +22 -9
- data/lib/rigor/configuration.rb +7 -4
- data/lib/rigor/effects/definition_lines.rb +100 -0
- data/lib/rigor/effects/envelope_check.rb +18 -1
- data/lib/rigor/effects/liskov_check.rb +17 -8
- data/lib/rigor/effects/plugin_facts.rb +1 -1
- data/lib/rigor/effects/scanner.rb +2 -3
- data/lib/rigor/effects/signature_sources.rb +13 -2
- data/lib/rigor/effects/snapshot.rb +53 -21
- data/lib/rigor/effects/snapshot_diff.rb +26 -3
- data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
- data/lib/rigor/environment/lockfile_resolver.rb +15 -5
- data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
- data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
- data/lib/rigor/environment/rbs_loader.rb +525 -46
- data/lib/rigor/environment.rb +38 -15
- data/lib/rigor/inference/acceptance.rb +172 -20
- data/lib/rigor/inference/captured_locals.rb +62 -0
- data/lib/rigor/inference/content_join.rb +347 -0
- data/lib/rigor/inference/def_handle.rb +15 -3
- data/lib/rigor/inference/def_node_resolver.rb +62 -3
- data/lib/rigor/inference/expression_typer.rb +1020 -141
- data/lib/rigor/inference/fork_map.rb +6 -1
- data/lib/rigor/inference/hkt_reducer.rb +12 -1
- data/lib/rigor/inference/hkt_registry.rb +46 -0
- data/lib/rigor/inference/hkt_sugar_translator.rb +93 -0
- data/lib/rigor/inference/index_write_widening.rb +48 -0
- data/lib/rigor/inference/indexed_narrowing.rb +14 -0
- data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
- data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
- data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
- data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
- data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
- data/lib/rigor/inference/method_dispatcher.rb +155 -14
- data/lib/rigor/inference/method_parameter_binder.rb +3 -3
- data/lib/rigor/inference/mutation_widening.rb +244 -174
- data/lib/rigor/inference/narrowing.rb +331 -27
- data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
- data/lib/rigor/inference/pre_eval_constants.rb +15 -0
- data/lib/rigor/inference/precision_scanner.rb +5 -2
- data/lib/rigor/inference/project_patched_scanner.rb +2 -1
- data/lib/rigor/inference/rbs_type_translator.rb +89 -39
- data/lib/rigor/inference/scope_indexer.rb +1493 -140
- data/lib/rigor/inference/statement_evaluator.rb +276 -91
- data/lib/rigor/inference/struct_fold_safety.rb +186 -27
- data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
- data/lib/rigor/inference/version_guard.rb +229 -0
- data/lib/rigor/language_server/selection_range_provider.rb +1 -1
- data/lib/rigor/plugin/base.rb +3 -2
- data/lib/rigor/plugin/inflector.rb +14 -5
- data/lib/rigor/plugin/io_boundary.rb +107 -4
- data/lib/rigor/plugin/loader.rb +14 -0
- data/lib/rigor/protection/analysis_guard.rb +60 -0
- data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
- data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
- data/lib/rigor/protection/discovery_seed.rb +1 -1
- data/lib/rigor/protection/measurement_integrity.rb +36 -0
- data/lib/rigor/protection/mutation_scanner.rb +61 -6
- data/lib/rigor/protection/mutator.rb +121 -18
- data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
- data/lib/rigor/reflection.rb +182 -30
- data/lib/rigor/scope/discovery_index.rb +39 -1
- data/lib/rigor/scope.rb +385 -4
- data/lib/rigor/sig_gen/classification.rb +12 -1
- data/lib/rigor/sig_gen/generator.rb +254 -5
- data/lib/rigor/sig_gen/renderer.rb +1 -6
- data/lib/rigor/sig_gen/writer.rb +3 -0
- data/lib/rigor/source/constant_path.rb +79 -2
- data/lib/rigor/triage/catalogue.rb +1 -1
- data/lib/rigor/type/combinator.rb +10 -0
- data/lib/rigor/type/maybe.rb +47 -0
- data/lib/rigor/type/refined.rb +1 -2
- data/lib/rigor/type/result.rb +53 -0
- data/lib/rigor/type.rb +2 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
- data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
- data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
- data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
- data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
- data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
- data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
- data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
- data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
- data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
- data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
- data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
- data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
- data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
- data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
- data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
- data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
- data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
- data/sig/rigor/analysis/baseline.rbs +1 -1
- data/sig/rigor/environment.rbs +3 -0
- data/sig/rigor/inference.rbs +4 -1
- data/sig/rigor/plugin/io_boundary.rbs +2 -0
- data/sig/rigor/reflection.rbs +3 -1
- data/sig/rigor/scope.rbs +25 -1
- data/sig/rigor/type.rbs +95 -1
- data/sig/rigor.rbs +16 -0
- data/skills/rigor-ci-setup/SKILL.md +2 -2
- data/skills/rigor-editor-setup/SKILL.md +2 -2
- data/skills/rigor-mcp-setup/SKILL.md +2 -2
- data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
- data/skills/rigor-plugin-review/SKILL.md +3 -3
- metadata +73 -3
|
@@ -18,19 +18,64 @@ module Rigor
|
|
|
18
18
|
# Each STI child carries an `sti_parent:` pointer the {ModelIndex} uses to inherit the root model's
|
|
19
19
|
# table and DSL surface.
|
|
20
20
|
#
|
|
21
|
-
# Returns rows the {ModelIndex} consumes
|
|
21
|
+
# Returns rows the {ModelIndex} consumes — exactly ONE per model, whatever the number of source
|
|
22
|
+
# declarations: a reopened class contributes its DSL surface into the single row for that name
|
|
23
|
+
# ({#merge_redeclarations}), because a reopen ADDS to a class rather than replacing it.
|
|
22
24
|
#
|
|
23
25
|
# { class_name: "User", table_name_override: nil, sti_parent: nil, ... }
|
|
24
26
|
# { class_name: "Admin", table_name_override: nil, sti_parent: "User", ... }
|
|
25
27
|
#
|
|
28
|
+
# `class_name` (and `superclass_name`) is the constant path WITHOUT a leading `::` — `class ::User`
|
|
29
|
+
# yields `"User"`, exactly as `class User` does, and `class ::User` nested inside `module Admin`
|
|
30
|
+
# yields `"User"` rather than `"Admin::::User"`, because a rooted declaration names the top-level
|
|
31
|
+
# constant regardless of its lexical nesting. Every downstream key — the {ModelIndex} entries, the
|
|
32
|
+
# published `:model_index` fact, `Entry#class_name` — inherits this spelling, and every consumer
|
|
33
|
+
# anchors with the plain constant rendering (#583: the rooted `"::User"` key used to make all three
|
|
34
|
+
# Hash consumers miss such models silently).
|
|
35
|
+
#
|
|
26
36
|
# Limitations (intentional for v0.1.0 of the plugin):
|
|
27
37
|
#
|
|
28
38
|
# - `self.table_name = "..."` recognised only when the RHS is a String literal. Computed names
|
|
29
39
|
# (`self.table_name = "#{tenant}_users"`) are skipped.
|
|
30
40
|
# - Modules (`class Admin::User < ApplicationRecord`) are recognised; the resulting class name is
|
|
31
|
-
# the lexical path (`Admin::User`).
|
|
32
|
-
#
|
|
33
|
-
#
|
|
41
|
+
# the lexical path (`Admin::User`). {ModelIndex.inflected_table_name} derives its TABLE name from
|
|
42
|
+
# only the demodulized last segment, `table_name_prefix` / `table_name_suffix` (#623).
|
|
43
|
+
# - The STI fixpoint matches a superclass name against model class names by exact spelling; richer
|
|
44
|
+
# constant resolution (relative namespacing) is not modelled.
|
|
45
|
+
# - An ENCLOSING namespace's (module OR class — Rails' own ancestor search does not care which)
|
|
46
|
+
# `table_name_prefix` / `table_name_suffix` is recognised as a String literal on `def self.NAME`,
|
|
47
|
+
# `class << self; def NAME`, a plain `self.NAME = "..."` assignment, or one of
|
|
48
|
+
# {ACCESSOR_DECLARATION_METHODS}'s `default:` (a `*_writer` macro does NOT establish the reader
|
|
49
|
+
# Rails' walk probes, so it is not recognised as declaring anything). Any other shape a reader
|
|
50
|
+
# establishment was observed for (a computed RHS, disagreeing reopenings, or a plain assignment with
|
|
51
|
+
# no tracked reader — an unrecognised reader spelling such as `define_singleton_method`,
|
|
52
|
+
# `module_function`, `extend self`, or a hand-rolled ivar reader all fall into this last case) is
|
|
53
|
+
# treated as DECLARED-BUT-UNFOLDABLE — and, per {ModelIndex.inflected_table_name_unreliable?}, does
|
|
54
|
+
# NOT fall back to a guessed value: the whole model's column/alias/association checks stand down
|
|
55
|
+
# instead, because a wrong guess can corroborate a real-but-unrelated table exactly as easily as it
|
|
56
|
+
# can miss.
|
|
57
|
+
# - A model class NESTED INSIDE ANOTHER MODEL CLASS (`Post::Comment` where `Post < ApplicationRecord`
|
|
58
|
+
# — Rails splices the parent's own table name into the middle of the child's, a wholly different
|
|
59
|
+
# mechanism from prefix/suffix) is recognised and stands down the same way (#623 review). A model
|
|
60
|
+
# nested inside an ABSTRACT model reads its OWN demodulized name in real Rails (the splice only
|
|
61
|
+
# applies to a non-abstract parent) but this walker does not track `abstract_class?` and stands it
|
|
62
|
+
# down too — a coverage loss, never a false positive.
|
|
63
|
+
# - A namespace's decorator declared OUTSIDE `model_search_paths` entirely is INVISIBLE, not stood
|
|
64
|
+
# down — this walker only reads files under the configured model roots, so a `table_name_prefix`
|
|
65
|
+
# anywhere else (a plain `def self.table_name_prefix` in `lib/blog.rb`, a
|
|
66
|
+
# `Rails::Engine.isolate_namespace` call in `lib/<engine>/engine.rb`, or any other path
|
|
67
|
+
# `model_search_paths` does not cover) resolves the bare demodulized guess uncorrected, exactly as
|
|
68
|
+
# if nothing had been declared. `Rigor::Plugin::IoBoundary`'s trust policy would permit reading
|
|
69
|
+
# further than `model_search_paths` for this plugin (its `allowed_read_roots` covers the project
|
|
70
|
+
# root), so an EXISTENCE-only scan of a wider file set is reachable in principle; it is not
|
|
71
|
+
# implemented because the right file set is a real design choice (the whole project root? `lib/`?
|
|
72
|
+
# each `engines/*/{app/models,lib}`?) and reading outside `model_search_paths` needs its own cache
|
|
73
|
+
# descriptor / `watch:` wiring to invalidate correctly — see the `producer :model_index` call site's
|
|
74
|
+
# `watch:` block, keyed on `model_search_paths` today.
|
|
75
|
+
# - NOT modelled at all (pre-existing, unrelated to namespace prefixes): a model declaring
|
|
76
|
+
# `table_name_prefix` on ITSELF rather than an enclosing namespace, and a base class (e.g.
|
|
77
|
+
# `ApplicationRecord`) setting `self.table_name_prefix` for every model under it — both already
|
|
78
|
+
# guess wrong identically on unpatched code.
|
|
34
79
|
class ModelDiscoverer
|
|
35
80
|
# @param io_boundary [Rigor::Plugin::IoBoundary]
|
|
36
81
|
# @param search_paths [Array<String>] absolute or project-relative paths.
|
|
@@ -38,11 +83,16 @@ module Rigor
|
|
|
38
83
|
# Declaration macros whose column's runtime value is a rich object, not the SQL scalar. Their column
|
|
39
84
|
# must NOT be narrowed to the schema type (see {ModelIndex.build}'s type-override remap).
|
|
40
85
|
TYPE_OVERRIDE_METHODS = %i[serialize mount_uploader mount_uploaders].freeze
|
|
86
|
+
OUTSIDE_SEARCH_DIRECTORIES = %w[lib config engines].freeze
|
|
41
87
|
|
|
42
|
-
def initialize(io_boundary:, search_paths:, base_classes:)
|
|
88
|
+
def initialize(io_boundary:, search_paths:, base_classes:, project_root: ".")
|
|
43
89
|
@io_boundary = io_boundary
|
|
44
90
|
@search_paths = search_paths
|
|
45
|
-
@
|
|
91
|
+
@project_root = project_root
|
|
92
|
+
# De-rooted for the same reason superclass names are ({#visit_class}): the match is by exact
|
|
93
|
+
# spelling, so a configured `model_base_classes: ["::ApplicationRecord"]` would otherwise never
|
|
94
|
+
# match anything a declaration can render.
|
|
95
|
+
@base_classes = base_classes.to_set { |name| strip_root(name.to_s) }
|
|
46
96
|
# Global set of column names whose declared runtime type overrides the schema scalar
|
|
47
97
|
# (`serialize` / `mount_uploader` / `attribute :x, CustomType`). Collected across every class AND
|
|
48
98
|
# concern module walked — a serialize inside a concern's `included do … end` is invisible to the
|
|
@@ -50,6 +100,15 @@ module Rigor
|
|
|
50
100
|
# column name and applied wherever that column appears. Over-suppresses a same-named scalar
|
|
51
101
|
# column elsewhere (a precision cost, never a false positive).
|
|
52
102
|
@type_override_columns = Set.new
|
|
103
|
+
# `full namespace name => { prefix: decorator?, suffix: decorator? }` — what each walked module OR
|
|
104
|
+
# class body declares about `table_name_prefix` / `table_name_suffix` (#623; keyed by full name
|
|
105
|
+
# regardless of whether the source spelled the namespace `module` or `class` — Rails'
|
|
106
|
+
# `module_parents` ancestor walk does not care either). A `decorator` is `{ literal: }` when
|
|
107
|
+
# foldable to a String, `{ computed: true }` when the namespace declares the name in a shape this
|
|
108
|
+
# walker cannot fold. A key absent from the inner Hash means the namespace says nothing about that
|
|
109
|
+
# name — {#resolve_table_name_decorator} keeps walking outward past it, exactly like Rails' own
|
|
110
|
+
# `respond_to?(:table_name_prefix)` ancestor search.
|
|
111
|
+
@namespace_table_name_decorators = {}
|
|
53
112
|
end
|
|
54
113
|
|
|
55
114
|
attr_reader :type_override_columns
|
|
@@ -64,7 +123,12 @@ module Rigor
|
|
|
64
123
|
tree = Prism.parse(contents).value
|
|
65
124
|
walk_for_classes(tree, []) { |candidate| candidates << candidate }
|
|
66
125
|
end
|
|
67
|
-
|
|
126
|
+
scan_outside_decorators
|
|
127
|
+
superclass_map = build_superclass_map(candidates)
|
|
128
|
+
# Every file is walked (and every module's `table_name_prefix` / `table_name_suffix` recorded)
|
|
129
|
+
# before any row is resolved — a model's file may sort, and so be visited, before the file that
|
|
130
|
+
# declares its enclosing module's decorator.
|
|
131
|
+
attach_table_name_decorators(resolve_models(candidates), superclass_map: superclass_map)
|
|
68
132
|
end
|
|
69
133
|
|
|
70
134
|
private
|
|
@@ -101,21 +165,189 @@ module Rigor
|
|
|
101
165
|
break unless added
|
|
102
166
|
end
|
|
103
167
|
|
|
104
|
-
candidates.filter_map do |candidate|
|
|
168
|
+
rows = candidates.filter_map do |candidate|
|
|
105
169
|
name = candidate[:class_name]
|
|
106
170
|
next unless model_names.key?(name)
|
|
107
171
|
|
|
108
172
|
candidate.merge(sti_parent: sti_parent[name])
|
|
109
173
|
end
|
|
174
|
+
merge_redeclarations(rows)
|
|
110
175
|
end
|
|
111
176
|
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
|
|
115
|
-
|
|
177
|
+
# Collapses the rows that declare the SAME constant into one, so {ModelIndex.build} — which keys
|
|
178
|
+
# its entries by `class_name` — receives at most one row per model.
|
|
179
|
+
#
|
|
180
|
+
# A model is routinely declared more than once: `app/models/user.rb` holds `class User <
|
|
181
|
+
# ApplicationRecord` and a second file reopens it (`class ::User`, `class User`, or a full
|
|
182
|
+
# `class User < ApplicationRecord` redeclaration) to add a method. Ruby's own semantics are
|
|
183
|
+
# ADDITIVE — a reopen contributes what it spells and leaves the rest of the class alone — so the
|
|
184
|
+
# rows are UNIONed here. Taking the last row instead dropped the real declaration's associations,
|
|
185
|
+
# scopes, enums, validations, callbacks and `alias_attribute`s whenever the reopen sorted later in
|
|
186
|
+
# the glob, and `where(<a declared alias>: …)` then surfaced a false `unknown-column` on correct
|
|
187
|
+
# code; taking the first dropped whatever the reopen added. Neither order loses anything now.
|
|
188
|
+
#
|
|
189
|
+
# Merge is by field: the first non-nil `superclass_name` wins (a second declaration is the same
|
|
190
|
+
# thing, or invalid Ruby); `self.table_name =` is an ASSIGNMENT, so the LATER declaration's
|
|
191
|
+
# `table_name_override` wins as it does at load time, and when the two declarations disagree the
|
|
192
|
+
# resolved name is marked computed so no value is pinned — the glob order is not the load order for
|
|
193
|
+
# two full declarations, and a pinned wrong literal folds `Model.table_name == "…"` on correct code;
|
|
194
|
+
# `table_name_computed` is otherwise an OR (any computed name in the class makes the resolved one
|
|
195
|
+
# inexact); name-keyed rows (associations, enums, aliases) let the LAST declaration override an
|
|
196
|
+
# earlier same-name row exactly as {ModelIndex.merge_named_rows} does across an STI chain, and the
|
|
197
|
+
# plain lists union.
|
|
198
|
+
def merge_redeclarations(rows)
|
|
199
|
+
return rows if rows.length < 2
|
|
200
|
+
|
|
201
|
+
rows.each_with_object({}) do |row, acc|
|
|
202
|
+
name = row.fetch(:class_name)
|
|
203
|
+
acc[name] = acc.key?(name) ? merged_row(acc[name], row) : row
|
|
204
|
+
end.values
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# `base` is the earlier declaration, `addition` the later one. See {#merge_redeclarations}.
|
|
208
|
+
def merged_row(base, addition)
|
|
209
|
+
base.merge(
|
|
210
|
+
superclass_name: base[:superclass_name] || addition[:superclass_name],
|
|
211
|
+
abstract_class: base[:abstract_class] || addition[:abstract_class],
|
|
212
|
+
sti_parent: base[:sti_parent] || addition[:sti_parent],
|
|
213
|
+
table_name_override: addition[:table_name_override] || base[:table_name_override],
|
|
214
|
+
table_name_computed: base[:table_name_computed] || addition[:table_name_computed] ||
|
|
215
|
+
conflicting_table_names?(base, addition),
|
|
216
|
+
associations: dedup_named_rows(Array(base[:associations]) + Array(addition[:associations])),
|
|
217
|
+
enums: (base[:enums] || {}).merge(addition[:enums] || {}),
|
|
218
|
+
scopes: (Array(base[:scopes]) + Array(addition[:scopes])).uniq,
|
|
219
|
+
validations: (Array(base[:validations]) + Array(addition[:validations])).uniq,
|
|
220
|
+
callbacks: (Array(base[:callbacks]) + Array(addition[:callbacks])).uniq,
|
|
221
|
+
aliases: (base[:aliases] || {}).merge(addition[:aliases] || {})
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Two literal `self.table_name =` assignments that disagree: see {#merge_redeclarations}.
|
|
226
|
+
def conflicting_table_names?(base, addition)
|
|
227
|
+
a = base[:table_name_override]
|
|
228
|
+
b = addition[:table_name_override]
|
|
229
|
+
!a.nil? && !b.nil? && a != b
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Keeps the LAST row per `:name`, matching {ModelIndex.merge_named_rows}'s override rule.
|
|
233
|
+
def dedup_named_rows(rows)
|
|
234
|
+
rows.to_h { |row| [row[:name], row] }.values
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Resolves each model row's `table_name_prefix:` / `table_name_suffix:` / `table_name_nested_in_model:`
|
|
238
|
+
# (#623, #671, #678, #679) against enclosing namespaces, model's own decorators, superclasses, and the
|
|
239
|
+
# full discovered model set.
|
|
240
|
+
#
|
|
241
|
+
# Deliberately does NOT fold any of this into `table_name_computed` — that flag gates
|
|
242
|
+
# `ModelIndex.table_name_exact?`, which only ever matters when a SOURCE-DECLARED literal
|
|
243
|
+
# (`self.table_name = "…"`) exists, and a literal override bypasses Rails' whole
|
|
244
|
+
# prefix/suffix/nesting computation at runtime. An unrelated namespace's unreadable decorator has no
|
|
245
|
+
# bearing on whether that literal is what the app actually uses. What DOES depend on these three
|
|
246
|
+
# fields is whether {ModelIndex.build} trusts the INFLECTED name enough to look up its columns —
|
|
247
|
+
# see {ModelIndex.inflected_table_name_unreliable?}.
|
|
248
|
+
def attach_table_name_decorators(rows, superclass_map: {})
|
|
249
|
+
models_by_name = rows.to_h { |row| [row.fetch(:class_name), row] }
|
|
250
|
+
rows.map do |row|
|
|
251
|
+
class_name = row.fetch(:class_name)
|
|
252
|
+
row.merge(
|
|
253
|
+
table_name_prefix: resolve_table_name_decorator(class_name, :prefix, superclass_map: superclass_map),
|
|
254
|
+
table_name_suffix: resolve_table_name_decorator(class_name, :suffix, superclass_map: superclass_map),
|
|
255
|
+
table_name_nested_in_model: nested_in_model_class?(class_name, models_by_name)
|
|
256
|
+
)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The decorator (`{ literal: }` / `{ computed: true }`) for `key`:
|
|
261
|
+
# 1. First checks enclosing namespaces outward (mirroring Rails' `full_table_name_prefix`
|
|
262
|
+
# `module_parents` ancestor search).
|
|
263
|
+
# 2. Checks the model's own class decorator.
|
|
264
|
+
# 3. Walks the superclass hierarchy via `superclass_map` (e.g. `ApplicationRecord` or base classes).
|
|
265
|
+
# Falls back to `{ literal: "" }` (AR::Base's own default) when nothing says anything about `key`.
|
|
266
|
+
def resolve_table_name_decorator(class_name, key, superclass_map: {})
|
|
267
|
+
namespace_ancestors(class_name).each do |namespace_name|
|
|
268
|
+
decorator = @namespace_table_name_decorators[namespace_name]
|
|
269
|
+
next unless decorator&.key?(key)
|
|
270
|
+
|
|
271
|
+
return decorator[key]
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
decorator = @namespace_table_name_decorators[class_name]
|
|
275
|
+
return decorator[key] if decorator&.key?(key)
|
|
276
|
+
|
|
277
|
+
curr = superclass_map[class_name]
|
|
278
|
+
visited = Set.new([class_name])
|
|
279
|
+
while curr && !visited.include?(curr)
|
|
280
|
+
visited << curr
|
|
281
|
+
decorator = @namespace_table_name_decorators[curr]
|
|
282
|
+
return decorator[key] if decorator&.key?(key)
|
|
283
|
+
|
|
284
|
+
curr = superclass_map[curr]
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
{ literal: "" }
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def build_superclass_map(candidates)
|
|
291
|
+
candidates_by_name = candidates.to_h { |c| [c[:class_name], c] }
|
|
292
|
+
superclass_map = {}
|
|
293
|
+
candidates.each do |c|
|
|
294
|
+
name = c[:class_name]
|
|
295
|
+
super_name = resolve_superclass_name(c, candidates_by_name)
|
|
296
|
+
superclass_map[name] ||= super_name if super_name
|
|
297
|
+
end
|
|
298
|
+
superclass_map
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def resolve_superclass_name(candidate, candidates_by_name)
|
|
302
|
+
super_name = candidate[:superclass_name]
|
|
303
|
+
return nil if super_name.nil?
|
|
304
|
+
|
|
305
|
+
return super_name if candidates_by_name.key?(super_name) || @base_classes.include?(super_name)
|
|
306
|
+
|
|
307
|
+
segments = candidate[:class_name].split("::")
|
|
308
|
+
(segments.length - 1).downto(1).each do |n|
|
|
309
|
+
prefix = segments.first(n).join("::")
|
|
310
|
+
candidate_super = "#{prefix}::#{super_name}"
|
|
311
|
+
return candidate_super if candidates_by_name.key?(candidate_super)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
super_name
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# `"Foo::Bar::Post"` → `["Foo::Bar", "Foo"]`, nearest first; a top-level `"Post"` → `[]`. Derived
|
|
318
|
+
# purely from the (already de-rooted) class-name String, so it matches regardless of whether the
|
|
319
|
+
# source nested the containing namespaces (`module Foo; module Bar` … / `class Foo; class Bar` …)
|
|
320
|
+
# or wrote one compactly (`module Foo::Bar`).
|
|
321
|
+
def namespace_ancestors(class_name)
|
|
322
|
+
segments = class_name.split("::")
|
|
323
|
+
(segments.length - 1).downto(1).map { |n| segments.first(n).join("::") }
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Whether `class_name`'s IMMEDIATE lexical parent is itself a non-abstract discovered model —
|
|
327
|
+
# `Post::Comment` where `Post < ApplicationRecord` and `Post` is not abstract. Rails'
|
|
328
|
+
# `compute_table_name` special-cases exactly this shape: it splices
|
|
329
|
+
# `"#{parent.table_name.singularize}_"` into the middle of the name, entirely separately from the
|
|
330
|
+
# `table_name_prefix` / `table_name_suffix` mechanism above (`ActiveRecord::ModelSchema::ClassMethods#compute_table_name`'s
|
|
331
|
+
# `module_parent < Base && !module_parent.abstract_class?` arm).
|
|
332
|
+
#
|
|
333
|
+
# Abstract parents (`self.abstract_class = true` or `primary_abstract_class`) do NOT trigger this
|
|
334
|
+
# containment rule in Rails — e.g. `Base::Comment` inflects to `"comments"` with normal live columns.
|
|
335
|
+
def nested_in_model_class?(class_name, models_by_name)
|
|
336
|
+
segments = class_name.split("::")
|
|
337
|
+
return false if segments.length < 2
|
|
116
338
|
|
|
117
|
-
|
|
118
|
-
|
|
339
|
+
parent_name = segments[0..-2].join("::")
|
|
340
|
+
parent = models_by_name[parent_name]
|
|
341
|
+
return false if parent.nil?
|
|
342
|
+
|
|
343
|
+
parent[:abstract_class] != true
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Resolves a superclass NAME against the set of known model class names. Both sides come out of
|
|
347
|
+
# {#declared_constant_name}, so neither carries a leading `::` and the match is by exact spelling.
|
|
348
|
+
# Returns the matched model class name, or nil.
|
|
349
|
+
def model_match(superclass_name, model_names)
|
|
350
|
+
model_names.key?(superclass_name) ? superclass_name : nil
|
|
119
351
|
end
|
|
120
352
|
|
|
121
353
|
def read_safely(path)
|
|
@@ -127,7 +359,8 @@ module Rigor
|
|
|
127
359
|
def ruby_files_under(roots)
|
|
128
360
|
roots.flat_map do |root|
|
|
129
361
|
absolute = File.expand_path(root)
|
|
130
|
-
|
|
362
|
+
# ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
|
|
363
|
+
next [] unless @io_boundary.directory?(absolute)
|
|
131
364
|
|
|
132
365
|
Dir.glob(File.join(absolute, "**", "*.rb"))
|
|
133
366
|
end
|
|
@@ -141,11 +374,45 @@ module Rigor
|
|
|
141
374
|
visit_class(node, lexical_path, &)
|
|
142
375
|
when Prism::ModuleNode
|
|
143
376
|
visit_module(node, lexical_path, &)
|
|
377
|
+
when Prism::CallNode
|
|
378
|
+
handle_top_level_call(node, lexical_path)
|
|
379
|
+
when Prism::DefNode
|
|
380
|
+
handle_top_level_def(node)
|
|
144
381
|
else
|
|
145
382
|
node.rigor_each_child { |child| walk_for_classes(child, lexical_path, &) }
|
|
146
383
|
end
|
|
147
384
|
end
|
|
148
385
|
|
|
386
|
+
def handle_top_level_call(node, lexical_path)
|
|
387
|
+
if node.name == :isolate_namespace
|
|
388
|
+
target = isolate_namespace_target(node, lexical_path.first)
|
|
389
|
+
if target
|
|
390
|
+
@namespace_table_name_decorators[target] ||= {}
|
|
391
|
+
@namespace_table_name_decorators[target][:prefix] = { computed: true }
|
|
392
|
+
end
|
|
393
|
+
elsif node.receiver && %i[table_name_prefix= table_name_suffix=].include?(node.name)
|
|
394
|
+
target = strip_root(constant_path_name(node.receiver))
|
|
395
|
+
if target
|
|
396
|
+
key = TABLE_NAME_DECORATOR_METHODS[node.name.to_s.delete_suffix("=").to_sym]
|
|
397
|
+
@namespace_table_name_decorators[target] ||= {}
|
|
398
|
+
@namespace_table_name_decorators[target][key] = literal_assignment_value(node)
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def handle_top_level_def(node)
|
|
404
|
+
return unless node.receiver
|
|
405
|
+
|
|
406
|
+
target = strip_root(constant_path_name(node.receiver))
|
|
407
|
+
return unless target
|
|
408
|
+
|
|
409
|
+
key = TABLE_NAME_DECORATOR_METHODS[node.name]
|
|
410
|
+
return unless key
|
|
411
|
+
|
|
412
|
+
@namespace_table_name_decorators[target] ||= {}
|
|
413
|
+
@namespace_table_name_decorators[target][key] = literal_def_value(node.body)
|
|
414
|
+
end
|
|
415
|
+
|
|
149
416
|
# Captures EVERY class declaration as a candidate — the `resolve_models` fixpoint decides
|
|
150
417
|
# afterwards which ones are models. The DSL metadata is extracted eagerly; for a non-model class
|
|
151
418
|
# it is simply discarded when the candidate is dropped.
|
|
@@ -153,15 +420,22 @@ module Rigor
|
|
|
153
420
|
class_local_name = constant_path_name(node.constant_path)
|
|
154
421
|
return if class_local_name.nil?
|
|
155
422
|
|
|
156
|
-
full_name = (lexical_path
|
|
157
|
-
superclass = constant_path_name(node.superclass) if node.superclass
|
|
423
|
+
full_name = declared_constant_name(class_local_name, lexical_path)
|
|
424
|
+
superclass = strip_root(constant_path_name(node.superclass)) if node.superclass
|
|
158
425
|
|
|
159
426
|
collect_type_overrides(node.body)
|
|
427
|
+
# Rails' `full_table_name_prefix` walks `module_parents.detect { respond_to?(:table_name_prefix) }`
|
|
428
|
+
# — lexical nesting, which does not care whether the namespace container was written `module Blog`
|
|
429
|
+
# or `class Blog`. A `class` used purely as a namespace holder (or a model class that happens to
|
|
430
|
+
# ALSO declare a decorator for its own nested classes) must be scanned the same way a module is.
|
|
431
|
+
record_table_name_decorators(full_name, node.body, is_class: true)
|
|
160
432
|
|
|
161
433
|
yield({
|
|
162
434
|
class_name: full_name,
|
|
163
435
|
superclass_name: superclass,
|
|
436
|
+
abstract_class: abstract_class?(node.body),
|
|
164
437
|
table_name_override: lookup_table_name_override(node.body),
|
|
438
|
+
table_name_computed: table_name_computed?(node.body),
|
|
165
439
|
associations: lookup_associations(node.body),
|
|
166
440
|
enums: lookup_enums(node.body),
|
|
167
441
|
scopes: lookup_scopes(node.body),
|
|
@@ -171,8 +445,7 @@ module Rigor
|
|
|
171
445
|
})
|
|
172
446
|
|
|
173
447
|
# Recurse into the body in case nested classes exist.
|
|
174
|
-
|
|
175
|
-
walk_for_classes(node.body, inner_path, &) if node.body
|
|
448
|
+
walk_for_classes(node.body, [full_name], &) if node.body
|
|
176
449
|
end
|
|
177
450
|
|
|
178
451
|
def visit_module(node, lexical_path, &)
|
|
@@ -183,10 +456,28 @@ module Rigor
|
|
|
183
456
|
# `included do … end` block; collect their overrides even though the module itself is not a model.
|
|
184
457
|
collect_type_overrides(node.body)
|
|
185
458
|
|
|
186
|
-
|
|
459
|
+
full_name = declared_constant_name(module_local_name, lexical_path)
|
|
460
|
+
record_table_name_decorators(full_name, node.body, is_class: false)
|
|
461
|
+
|
|
462
|
+
inner_path = [full_name]
|
|
187
463
|
walk_for_classes(node.body, inner_path, &) if node.body
|
|
188
464
|
end
|
|
189
465
|
|
|
466
|
+
# The full constant name a `class` / `module` declaration defines, given the rendered local name
|
|
467
|
+
# and the enclosing lexical path. A ROOTED local name (`class ::User`, `module ::Admin`) names the
|
|
468
|
+
# top-level constant whatever the nesting, so the lexical path is dropped and the `::` with it;
|
|
469
|
+
# otherwise the name is appended to the path (`class User` inside `module Admin` → `Admin::User`).
|
|
470
|
+
def declared_constant_name(local_name, lexical_path)
|
|
471
|
+
return strip_root(local_name) if local_name.start_with?("::")
|
|
472
|
+
|
|
473
|
+
(lexical_path + [local_name]).join("::")
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# `::User` → `User`; a name without the root marker is returned unchanged (nil stays nil).
|
|
477
|
+
def strip_root(name)
|
|
478
|
+
name&.delete_prefix("::")
|
|
479
|
+
end
|
|
480
|
+
|
|
190
481
|
# Records the column name of every `serialize :col` / `mount_uploader(s) :col` / `attribute :col,
|
|
191
482
|
# CustomType` in `body` (descending into `with_options` and concern `included do` blocks) into the
|
|
192
483
|
# global {#type_override_columns} set. `attribute :col, :symbol_type` (a built-in scalar type) is
|
|
@@ -229,8 +520,9 @@ module Rigor
|
|
|
229
520
|
type_arg.is_a?(Prism::ConstantReadNode) || type_arg.is_a?(Prism::ConstantPathNode)
|
|
230
521
|
end
|
|
231
522
|
|
|
232
|
-
# Renders a constant-path node (`Admin::User`, `::ApplicationRecord`) as a String
|
|
233
|
-
#
|
|
523
|
+
# Renders a constant-path node (`Admin::User`, `::ApplicationRecord`) as a String, keeping the
|
|
524
|
+
# leading `::` of a rooted path — {#declared_constant_name} reads it as "reset the lexical path"
|
|
525
|
+
# before the marker is dropped. Returns nil for shapes the discoverer chooses not to handle.
|
|
234
526
|
def constant_path_name(node)
|
|
235
527
|
return nil if node.nil?
|
|
236
528
|
|
|
@@ -268,6 +560,490 @@ module Rigor
|
|
|
268
560
|
nil
|
|
269
561
|
end
|
|
270
562
|
|
|
563
|
+
# Whether the class computes its own table name in a way this walker cannot read off the source:
|
|
564
|
+
# `def self.table_name`, a `table_name` def inside `class << self`, or `self.table_name =` with a
|
|
565
|
+
# non-literal RHS (`"#{tenant}_users"`).
|
|
566
|
+
#
|
|
567
|
+
# The resolved name is unaffected — it still falls back to the inflection, and a wrong inflection
|
|
568
|
+
# already degrades harmlessly (no matching table → no columns → the analyzer stays silent). What
|
|
569
|
+
# this flag protects is the {ModelIndex::Entry#table_name_exact?} claim, which licenses PINNING the
|
|
570
|
+
# name to a value. Without it, a computed override on a class whose inflected name happens to match
|
|
571
|
+
# some table in the schema would read as corroborated, and `Model.table_name == "..."` would then
|
|
572
|
+
# fold against a name the application does not use.
|
|
573
|
+
def table_name_computed?(body)
|
|
574
|
+
return false if body.nil?
|
|
575
|
+
|
|
576
|
+
body.rigor_each_child do |node|
|
|
577
|
+
return true if singleton_table_name_def?(node)
|
|
578
|
+
return true if singleton_class_defines_table_name?(node)
|
|
579
|
+
return true if non_literal_table_name_assignment?(node)
|
|
580
|
+
end
|
|
581
|
+
false
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# `def self.table_name` — a DefNode with an explicit `self` receiver.
|
|
585
|
+
def singleton_table_name_def?(node)
|
|
586
|
+
node.is_a?(Prism::DefNode) && node.name == :table_name && node.receiver.is_a?(Prism::SelfNode)
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
# `class << self; def table_name; …; end; end` — the other spelling of the same override.
|
|
590
|
+
def singleton_class_defines_table_name?(node)
|
|
591
|
+
return false unless node.is_a?(Prism::SingletonClassNode)
|
|
592
|
+
return false unless node.body
|
|
593
|
+
|
|
594
|
+
node.body.rigor_each_child do |inner|
|
|
595
|
+
return true if inner.is_a?(Prism::DefNode) && inner.name == :table_name && inner.receiver.nil?
|
|
596
|
+
end
|
|
597
|
+
false
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# `self.table_name = <anything but a String literal>`. The literal form is the one
|
|
601
|
+
# {#lookup_table_name_override} reads; everything else is a name only the running app knows.
|
|
602
|
+
def non_literal_table_name_assignment?(node)
|
|
603
|
+
return false unless node.is_a?(Prism::CallNode) && node.name == :table_name=
|
|
604
|
+
return false unless node.receiver.is_a?(Prism::SelfNode)
|
|
605
|
+
|
|
606
|
+
!node.arguments&.arguments&.first.is_a?(Prism::StringNode)
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
# Whether a class declares itself abstract via `self.abstract_class = true` (or `abstract_class = true`)
|
|
610
|
+
# or Rails 7+'s `primary_abstract_class` (#679). Abstract model classes are skipped by Rails' nested-model
|
|
611
|
+
# containment prefixing rule in `compute_table_name`.
|
|
612
|
+
def abstract_class?(body)
|
|
613
|
+
return false if body.nil?
|
|
614
|
+
|
|
615
|
+
body.rigor_each_child do |node|
|
|
616
|
+
case node
|
|
617
|
+
when Prism::CallNode
|
|
618
|
+
if (node.name == :abstract_class=) && (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
|
|
619
|
+
arg = node.arguments&.arguments&.first
|
|
620
|
+
return true if arg.is_a?(Prism::TrueNode)
|
|
621
|
+
elsif (node.name == :primary_abstract_class) && (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
|
|
622
|
+
return true
|
|
623
|
+
end
|
|
624
|
+
when Prism::SingletonClassNode
|
|
625
|
+
node.body&.rigor_each_child do |inner|
|
|
626
|
+
next unless inner.is_a?(Prism::CallNode)
|
|
627
|
+
|
|
628
|
+
if (inner.name == :abstract_class=) && (inner.receiver.nil? || inner.receiver.is_a?(Prism::SelfNode))
|
|
629
|
+
arg = inner.arguments&.arguments&.first
|
|
630
|
+
return true if arg.is_a?(Prism::TrueNode)
|
|
631
|
+
elsif (inner.name == :primary_abstract_class) && (inner.receiver.nil? || inner.receiver.is_a?(Prism::SelfNode))
|
|
632
|
+
return true
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
end
|
|
637
|
+
false
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
# `table_name_prefix` / `table_name_suffix`, keyed by the setter/reader name a namespace (module OR
|
|
641
|
+
# class) body can declare either one under (#623). Rails' own table-name computation
|
|
642
|
+
# (`ActiveRecord::ModelSchema::ClassMethods#full_table_name_prefix` / `#full_table_name_suffix`)
|
|
643
|
+
# walks a namespaced model's enclosing namespaces outward via `module_parents` and asks each
|
|
644
|
+
# `respond_to?(:table_name_prefix)` — a plain `Blog::Post` reads `posts` unless `Blog` answers that
|
|
645
|
+
# question itself, whether `Blog` was declared `module Blog` or `class Blog`.
|
|
646
|
+
TABLE_NAME_DECORATOR_METHODS = { table_name_prefix: :prefix, table_name_suffix: :suffix }.freeze
|
|
647
|
+
private_constant :TABLE_NAME_DECORATOR_METHODS
|
|
648
|
+
|
|
649
|
+
# The Rails/ActiveSupport macros that can declare a `table_name_prefix` / `table_name_suffix` READER
|
|
650
|
+
# without a hand-written `def self.…`. Rails' walk tests `respond_to?(:table_name_prefix)` — the
|
|
651
|
+
# READER — so this is every macro verified (against bundled `activesupport`) to define one:
|
|
652
|
+
# `mattr_accessor` / `mattr_reader`, their `cattr_*` literal aliases, the `thread_mattr_*` /
|
|
653
|
+
# `thread_cattr_*` thread-local family, and `class_attribute` (valid on a class namespace now that
|
|
654
|
+
# {#visit_class} scans class bodies too — it is defined on `Class`, not `Module`). The `*_writer`
|
|
655
|
+
# spellings (`mattr_writer`, `cattr_writer`, `thread_mattr_writer`, `thread_cattr_writer`) are
|
|
656
|
+
# deliberately EXCLUDED: each defines only `self.#{sym}=` (verified against ActiveSupport's
|
|
657
|
+
# `attribute_accessors.rb` / `attribute_accessors_per_thread.rb`), no reader, so a module declaring
|
|
658
|
+
# only one of those is invisible to `full_table_name_prefix`'s ancestor search exactly as if it had
|
|
659
|
+
# declared nothing.
|
|
660
|
+
ACCESSOR_DECLARATION_METHODS = %i[
|
|
661
|
+
mattr_accessor mattr_reader
|
|
662
|
+
cattr_accessor cattr_reader
|
|
663
|
+
thread_mattr_accessor thread_mattr_reader
|
|
664
|
+
thread_cattr_accessor thread_cattr_reader
|
|
665
|
+
class_attribute
|
|
666
|
+
].freeze
|
|
667
|
+
private_constant :ACCESSOR_DECLARATION_METHODS
|
|
668
|
+
|
|
669
|
+
# Scans a module OR class BODY for `table_name_prefix` / `table_name_suffix` declarations and
|
|
670
|
+
# records what it finds into `@namespace_table_name_decorators`, merged with any earlier reopening
|
|
671
|
+
# of the same namespace.
|
|
672
|
+
def record_table_name_decorators(full_name, body, is_class: false)
|
|
673
|
+
found = table_name_decorators(body, is_class: is_class)
|
|
674
|
+
return if found.empty?
|
|
675
|
+
|
|
676
|
+
existing = @namespace_table_name_decorators[full_name]
|
|
677
|
+
@namespace_table_name_decorators[full_name] =
|
|
678
|
+
existing.nil? ? found : merge_table_name_decorators(existing, found)
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# Walks the namespace body's TOP-LEVEL statements only — a decorator declared inside a nested
|
|
682
|
+
# `module`/`class` belongs to THAT namespace, not this one, and must not be attributed here.
|
|
683
|
+
# Returns `Hash<:prefix|:suffix => { literal: } | { computed: true }>`, only for the names this body
|
|
684
|
+
# actually mentions. A later statement overrides an earlier one within the SAME body — real Ruby
|
|
685
|
+
# assignment order, not a guess: `mattr_accessor :table_name_prefix` (no `default:`, so `computed:
|
|
686
|
+
# true`) followed two lines down by a real `self.table_name_prefix = "blog_"` folds to the literal.
|
|
687
|
+
#
|
|
688
|
+
# For a class (`is_class: true`), `ActiveRecord::Base` already defines reader accessors for
|
|
689
|
+
# `table_name_prefix` and `table_name_suffix` (#671), so a plain assignment `self.NAME = "…"` folds
|
|
690
|
+
# directly to the literal value without requiring an explicit reader declaration.
|
|
691
|
+
#
|
|
692
|
+
# For a module (`is_class: false`), plain assignment folds to the literal ONLY when a reader was
|
|
693
|
+
# established earlier in the same body. An unrecognized or missing reader folds to `computed: true`.
|
|
694
|
+
#
|
|
695
|
+
# Four exotic reader spellings are recognized without assignment (#679):
|
|
696
|
+
# 1. Singleton `attr_reader` (`class << self; attr_reader :table_name_prefix; end`) coupled with `@table_name_prefix = "…"`.
|
|
697
|
+
# 2. `define_singleton_method(:table_name_prefix) { "…" }`.
|
|
698
|
+
# 3. `module_function :table_name_prefix` or `module_function def table_name_prefix = "…"`.
|
|
699
|
+
# 4. `extend self` with `def table_name_prefix = "…"`.
|
|
700
|
+
def table_name_decorators(body, is_class: false)
|
|
701
|
+
return {} if body.nil?
|
|
702
|
+
|
|
703
|
+
decorators = {}
|
|
704
|
+
readers = Set.new
|
|
705
|
+
singleton_attr_readers = Set.new
|
|
706
|
+
ivars = {}
|
|
707
|
+
instance_methods = {}
|
|
708
|
+
extend_self = false
|
|
709
|
+
module_function_all = false
|
|
710
|
+
|
|
711
|
+
body.rigor_each_child do |node|
|
|
712
|
+
case node
|
|
713
|
+
when Prism::DefNode
|
|
714
|
+
if node.receiver.is_a?(Prism::SelfNode)
|
|
715
|
+
key = TABLE_NAME_DECORATOR_METHODS[node.name]
|
|
716
|
+
if key
|
|
717
|
+
readers << key
|
|
718
|
+
decorators[key] = literal_def_value(node.body)
|
|
719
|
+
end
|
|
720
|
+
elsif node.receiver.nil?
|
|
721
|
+
key = TABLE_NAME_DECORATOR_METHODS[node.name]
|
|
722
|
+
if key
|
|
723
|
+
val = literal_def_value(node.body)
|
|
724
|
+
instance_methods[key] = val
|
|
725
|
+
if extend_self || module_function_all
|
|
726
|
+
readers << key
|
|
727
|
+
decorators[key] = val
|
|
728
|
+
end
|
|
729
|
+
end
|
|
730
|
+
end
|
|
731
|
+
when Prism::InstanceVariableWriteNode
|
|
732
|
+
ivar_name = node.name.to_s.delete_prefix("@").to_sym
|
|
733
|
+
key = TABLE_NAME_DECORATOR_METHODS[ivar_name]
|
|
734
|
+
if key
|
|
735
|
+
val = node.value.is_a?(Prism::StringNode) ? { literal: node.value.unescaped } : { computed: true }
|
|
736
|
+
ivars[key] = val
|
|
737
|
+
decorators[key] = val if singleton_attr_readers.include?(key)
|
|
738
|
+
end
|
|
739
|
+
when Prism::CallNode
|
|
740
|
+
if node.receiver.is_a?(Prism::SelfNode) && node.name.to_s.end_with?("=")
|
|
741
|
+
key = TABLE_NAME_DECORATOR_METHODS[node.name.to_s.delete_suffix("=").to_sym]
|
|
742
|
+
if key
|
|
743
|
+
decorators[key] = readers.include?(key) || is_class ? literal_assignment_value(node) : { computed: true }
|
|
744
|
+
end
|
|
745
|
+
elsif (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)) &&
|
|
746
|
+
ACCESSOR_DECLARATION_METHODS.include?(node.name)
|
|
747
|
+
found = mattr_decorator_values(node)
|
|
748
|
+
readers.merge(found.keys)
|
|
749
|
+
decorators.merge!(found)
|
|
750
|
+
elsif (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)) &&
|
|
751
|
+
node.name == :define_singleton_method
|
|
752
|
+
arg0 = node.arguments&.arguments&.first
|
|
753
|
+
name = Rigor::Source::Literals.symbol_name(arg0) || (arg0.is_a?(Prism::StringNode) ? arg0.unescaped : nil)
|
|
754
|
+
key = TABLE_NAME_DECORATOR_METHODS[name.to_sym] if name
|
|
755
|
+
if key
|
|
756
|
+
readers << key
|
|
757
|
+
decorators[key] = node.block.is_a?(Prism::BlockNode) ? literal_def_value(node.block.body) : { computed: true }
|
|
758
|
+
end
|
|
759
|
+
elsif (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)) && node.name == :extend
|
|
760
|
+
arg = node.arguments&.arguments&.first
|
|
761
|
+
if arg.is_a?(Prism::SelfNode)
|
|
762
|
+
extend_self = true
|
|
763
|
+
instance_methods.each do |k, v|
|
|
764
|
+
readers << k
|
|
765
|
+
decorators[k] = v
|
|
766
|
+
end
|
|
767
|
+
end
|
|
768
|
+
elsif (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)) && node.name == :module_function
|
|
769
|
+
args = node.arguments&.arguments || []
|
|
770
|
+
if args.empty?
|
|
771
|
+
module_function_all = true
|
|
772
|
+
instance_methods.each do |k, v|
|
|
773
|
+
readers << k
|
|
774
|
+
decorators[k] = v
|
|
775
|
+
end
|
|
776
|
+
else
|
|
777
|
+
args.each do |arg|
|
|
778
|
+
if arg.is_a?(Prism::DefNode)
|
|
779
|
+
k = TABLE_NAME_DECORATOR_METHODS[arg.name]
|
|
780
|
+
if k
|
|
781
|
+
readers << k
|
|
782
|
+
decorators[k] = literal_def_value(arg.body)
|
|
783
|
+
end
|
|
784
|
+
else
|
|
785
|
+
sym = Rigor::Source::Literals.symbol_name(arg) || (arg.is_a?(Prism::StringNode) ? arg.unescaped : nil)
|
|
786
|
+
k = TABLE_NAME_DECORATOR_METHODS[sym.to_sym] if sym
|
|
787
|
+
if k
|
|
788
|
+
readers << k
|
|
789
|
+
decorators[k] = instance_methods[k] || { computed: true }
|
|
790
|
+
end
|
|
791
|
+
end
|
|
792
|
+
end
|
|
793
|
+
end
|
|
794
|
+
end
|
|
795
|
+
when Prism::SingletonClassNode
|
|
796
|
+
found_decorators, found_readers, found_attr_readers = singleton_class_decorator_values(node, ivars)
|
|
797
|
+
readers.merge(found_readers)
|
|
798
|
+
singleton_attr_readers.merge(found_attr_readers)
|
|
799
|
+
decorators.merge!(found_decorators)
|
|
800
|
+
end
|
|
801
|
+
end
|
|
802
|
+
decorators
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
# `class << self; def table_name_prefix = "blog_"; end; end` or `class << self; attr_reader :table_name_prefix; end`
|
|
806
|
+
# (#623, #679).
|
|
807
|
+
def singleton_class_decorator_values(node, ivars = {})
|
|
808
|
+
return [{}, Set.new, Set.new] unless node.body
|
|
809
|
+
|
|
810
|
+
decorators = {}
|
|
811
|
+
readers = Set.new
|
|
812
|
+
attr_readers = Set.new
|
|
813
|
+
|
|
814
|
+
node.body.rigor_each_child do |inner|
|
|
815
|
+
case inner
|
|
816
|
+
when Prism::DefNode
|
|
817
|
+
next unless inner.receiver.nil?
|
|
818
|
+
|
|
819
|
+
key = TABLE_NAME_DECORATOR_METHODS[inner.name]
|
|
820
|
+
if key
|
|
821
|
+
readers << key
|
|
822
|
+
decorators[key] = literal_def_value(inner.body)
|
|
823
|
+
end
|
|
824
|
+
when Prism::CallNode
|
|
825
|
+
next unless inner.receiver.nil? && inner.name == :attr_reader
|
|
826
|
+
|
|
827
|
+
args = inner.arguments&.arguments || []
|
|
828
|
+
args.each do |arg|
|
|
829
|
+
name = Rigor::Source::Literals.symbol_name(arg) || (arg.is_a?(Prism::StringNode) ? arg.unescaped : nil)
|
|
830
|
+
key = TABLE_NAME_DECORATOR_METHODS[name.to_sym] if name
|
|
831
|
+
next unless key
|
|
832
|
+
|
|
833
|
+
readers << key
|
|
834
|
+
attr_readers << key
|
|
835
|
+
decorators[key] = ivars[key] || { computed: true }
|
|
836
|
+
end
|
|
837
|
+
when Prism::InstanceVariableWriteNode
|
|
838
|
+
ivar_name = inner.name.to_s.delete_prefix("@").to_sym
|
|
839
|
+
key = TABLE_NAME_DECORATOR_METHODS[ivar_name]
|
|
840
|
+
if key
|
|
841
|
+
val = inner.value.is_a?(Prism::StringNode) ? { literal: inner.value.unescaped } : { computed: true }
|
|
842
|
+
ivars[key] = val
|
|
843
|
+
decorators[key] = val if attr_readers.include?(key)
|
|
844
|
+
end
|
|
845
|
+
end
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
[decorators, readers, attr_readers]
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
# `def self.table_name_prefix = "blog_"` and the equivalent regular-method spelling both parse to a
|
|
852
|
+
# `DefNode` whose `body` is a one-statement `StatementsNode` — fold when that statement is a String
|
|
853
|
+
# literal. An empty body (`def self.table_name_prefix; end`), a multi-statement body, or a
|
|
854
|
+
# non-literal single statement (string interpolation, a method call) all decline: the module DOES
|
|
855
|
+
# declare the name, this walker just cannot read off its value.
|
|
856
|
+
def literal_def_value(def_body)
|
|
857
|
+
return { computed: true } if def_body.nil?
|
|
858
|
+
|
|
859
|
+
statements = def_body.body
|
|
860
|
+
return { computed: true } unless statements.size == 1 && statements.first.is_a?(Prism::StringNode)
|
|
861
|
+
|
|
862
|
+
{ literal: statements.first.unescaped }
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
# `self.table_name_prefix = "blog_"` — the plain-assignment spelling (with or without a preceding
|
|
866
|
+
# `mattr_accessor` establishing the writer). Declines to `computed: true` for a non-literal RHS.
|
|
867
|
+
def literal_assignment_value(node)
|
|
868
|
+
arg = node.arguments&.arguments&.first
|
|
869
|
+
return { literal: arg.unescaped } if arg.is_a?(Prism::StringNode)
|
|
870
|
+
|
|
871
|
+
{ computed: true }
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
# `mattr_accessor :table_name_prefix, :table_name_suffix, default: "blog_"` — every
|
|
875
|
+
# {ACCESSOR_DECLARATION_METHODS} macro shares this same call shape: one `default:` across every
|
|
876
|
+
# Symbol name the call declares. Returns a Hash keyed by the `:prefix` / `:suffix` names this call
|
|
877
|
+
# actually declares; a name with no literal `default:` folds to `{ computed: true }` rather than
|
|
878
|
+
# assuming the runtime default of `nil` (a LATER plain assignment in the same body can still upgrade
|
|
879
|
+
# it — see {#table_name_decorators}).
|
|
880
|
+
def mattr_decorator_values(node)
|
|
881
|
+
args = node.arguments&.arguments
|
|
882
|
+
return {} if args.nil?
|
|
883
|
+
|
|
884
|
+
keys = args.filter_map do |arg|
|
|
885
|
+
name = Rigor::Source::Literals.symbol_name(arg)
|
|
886
|
+
TABLE_NAME_DECORATOR_METHODS[name.to_sym] if name
|
|
887
|
+
end
|
|
888
|
+
return {} if keys.empty?
|
|
889
|
+
|
|
890
|
+
value = mattr_default_value(args)
|
|
891
|
+
keys.to_h { |key| [key, value] }
|
|
892
|
+
end
|
|
893
|
+
|
|
894
|
+
def mattr_default_value(args)
|
|
895
|
+
hash_arg = args.find { |arg| arg.is_a?(Prism::KeywordHashNode) }
|
|
896
|
+
return { computed: true } if hash_arg.nil?
|
|
897
|
+
|
|
898
|
+
pair = hash_arg.elements.find do |el|
|
|
899
|
+
el.is_a?(Prism::AssocNode) && Rigor::Source::Literals.symbol_named?(el.key, "default")
|
|
900
|
+
end
|
|
901
|
+
return { computed: true } if pair.nil? || !pair.value.is_a?(Prism::StringNode)
|
|
902
|
+
|
|
903
|
+
{ literal: pair.value.unescaped }
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
# Merges the decorator findings of two reopenings of the SAME module. A key present on only one
|
|
907
|
+
# side wins outright; a key BOTH sides declare wins only when they agree (the same literal) — a
|
|
908
|
+
# cross-file "later wins" order is not derivable (glob order isn't load order, the same reasoning
|
|
909
|
+
# {#conflicting_table_names?} applies to `self.table_name =`), so a disagreement folds to
|
|
910
|
+
# `computed: true` rather than picking one arbitrarily.
|
|
911
|
+
def merge_table_name_decorators(base, addition)
|
|
912
|
+
(base.keys | addition.keys).to_h do |key|
|
|
913
|
+
a = base[key]
|
|
914
|
+
b = addition[key]
|
|
915
|
+
value =
|
|
916
|
+
if a.nil? then b
|
|
917
|
+
elsif b.nil? then a
|
|
918
|
+
elsif a == b then a
|
|
919
|
+
else { computed: true }
|
|
920
|
+
end
|
|
921
|
+
[key, value]
|
|
922
|
+
end
|
|
923
|
+
end
|
|
924
|
+
|
|
925
|
+
# Scans ruby files under project root outside model search paths for table_name_prefix /
|
|
926
|
+
# table_name_suffix / isolate_namespace (#678).
|
|
927
|
+
def scan_outside_decorators
|
|
928
|
+
outside_ruby_files.each do |path|
|
|
929
|
+
contents = read_safely(path)
|
|
930
|
+
next if contents.nil?
|
|
931
|
+
next unless outside_decorator_candidate?(contents)
|
|
932
|
+
|
|
933
|
+
parsed = Prism.parse(contents)
|
|
934
|
+
next if parsed.failure?
|
|
935
|
+
|
|
936
|
+
walk_for_outside_decorators(parsed.value, [])
|
|
937
|
+
end
|
|
938
|
+
end
|
|
939
|
+
|
|
940
|
+
def outside_ruby_files
|
|
941
|
+
model_files = ruby_files_under(@search_paths).to_set
|
|
942
|
+
root = File.expand_path(@project_root)
|
|
943
|
+
return [] unless @io_boundary.directory?(root)
|
|
944
|
+
|
|
945
|
+
outside_roots = OUTSIDE_SEARCH_DIRECTORIES.map { |dir| File.join(root, dir) }
|
|
946
|
+
ruby_files_under(outside_roots).reject { |path| model_files.include?(path) }
|
|
947
|
+
end
|
|
948
|
+
|
|
949
|
+
def outside_decorator_candidate?(contents)
|
|
950
|
+
contents.include?("table_name_prefix") ||
|
|
951
|
+
contents.include?("table_name_suffix") ||
|
|
952
|
+
contents.include?("isolate_namespace")
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
def walk_for_outside_decorators(node, lexical_path)
|
|
956
|
+
return if node.nil?
|
|
957
|
+
|
|
958
|
+
case node
|
|
959
|
+
when Prism::ClassNode
|
|
960
|
+
class_local_name = constant_path_name(node.constant_path)
|
|
961
|
+
if class_local_name
|
|
962
|
+
full_name = declared_constant_name(class_local_name, lexical_path)
|
|
963
|
+
record_outside_decorators(full_name, node.body, [full_name, *lexical_path], is_class: true)
|
|
964
|
+
node.body&.rigor_each_child { |child| walk_for_outside_decorators(child, [full_name, *lexical_path]) }
|
|
965
|
+
end
|
|
966
|
+
when Prism::ModuleNode
|
|
967
|
+
module_local_name = constant_path_name(node.constant_path)
|
|
968
|
+
if module_local_name
|
|
969
|
+
full_name = declared_constant_name(module_local_name, lexical_path)
|
|
970
|
+
record_outside_decorators(full_name, node.body, [full_name, *lexical_path], is_class: false)
|
|
971
|
+
node.body&.rigor_each_child { |child| walk_for_outside_decorators(child, [full_name, *lexical_path]) }
|
|
972
|
+
end
|
|
973
|
+
when Prism::CallNode
|
|
974
|
+
handle_outside_call(node, lexical_path)
|
|
975
|
+
when Prism::DefNode
|
|
976
|
+
handle_outside_def(node)
|
|
977
|
+
else
|
|
978
|
+
node.rigor_each_child { |child| walk_for_outside_decorators(child, lexical_path) }
|
|
979
|
+
end
|
|
980
|
+
end
|
|
981
|
+
|
|
982
|
+
def record_outside_decorators(full_name, body, lexical_path, is_class: false)
|
|
983
|
+
return if body.nil?
|
|
984
|
+
|
|
985
|
+
found = table_name_decorators(body, is_class: is_class)
|
|
986
|
+
if found.any?
|
|
987
|
+
@namespace_table_name_decorators[full_name] ||= {}
|
|
988
|
+
found.each_key do |key|
|
|
989
|
+
@namespace_table_name_decorators[full_name][key] = { computed: true }
|
|
990
|
+
end
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
body.rigor_each_child do |child|
|
|
994
|
+
if child.is_a?(Prism::CallNode) && child.name == :isolate_namespace
|
|
995
|
+
target = isolate_namespace_target(child, lexical_path.first)
|
|
996
|
+
if target
|
|
997
|
+
@namespace_table_name_decorators[target] ||= {}
|
|
998
|
+
@namespace_table_name_decorators[target][:prefix] = { computed: true }
|
|
999
|
+
end
|
|
1000
|
+
end
|
|
1001
|
+
end
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
def handle_outside_call(node, lexical_path)
|
|
1005
|
+
if node.name == :isolate_namespace
|
|
1006
|
+
target = isolate_namespace_target(node, lexical_path.first)
|
|
1007
|
+
if target
|
|
1008
|
+
@namespace_table_name_decorators[target] ||= {}
|
|
1009
|
+
@namespace_table_name_decorators[target][:prefix] = { computed: true }
|
|
1010
|
+
end
|
|
1011
|
+
elsif node.receiver && %i[table_name_prefix= table_name_suffix=].include?(node.name)
|
|
1012
|
+
target = strip_root(constant_path_name(node.receiver))
|
|
1013
|
+
if target
|
|
1014
|
+
key = TABLE_NAME_DECORATOR_METHODS[node.name.to_s.delete_suffix("=").to_sym]
|
|
1015
|
+
if key
|
|
1016
|
+
@namespace_table_name_decorators[target] ||= {}
|
|
1017
|
+
@namespace_table_name_decorators[target][key] = { computed: true }
|
|
1018
|
+
end
|
|
1019
|
+
end
|
|
1020
|
+
end
|
|
1021
|
+
end
|
|
1022
|
+
|
|
1023
|
+
def handle_outside_def(node)
|
|
1024
|
+
return unless node.receiver
|
|
1025
|
+
|
|
1026
|
+
target = strip_root(constant_path_name(node.receiver))
|
|
1027
|
+
return unless target
|
|
1028
|
+
|
|
1029
|
+
key = TABLE_NAME_DECORATOR_METHODS[node.name]
|
|
1030
|
+
return unless key
|
|
1031
|
+
|
|
1032
|
+
@namespace_table_name_decorators[target] ||= {}
|
|
1033
|
+
@namespace_table_name_decorators[target][key] = { computed: true }
|
|
1034
|
+
end
|
|
1035
|
+
|
|
1036
|
+
def isolate_namespace_target(node, lexical_name)
|
|
1037
|
+
arg = node.arguments&.arguments&.first
|
|
1038
|
+
if arg
|
|
1039
|
+
name = constant_path_name(arg)
|
|
1040
|
+
strip_root(name) if name
|
|
1041
|
+
elsif lexical_name
|
|
1042
|
+
segments = lexical_name.to_s.split("::")
|
|
1043
|
+
segments.length > 1 ? segments[0..-2].join("::") : segments.first
|
|
1044
|
+
end
|
|
1045
|
+
end
|
|
1046
|
+
|
|
271
1047
|
# Recognised single-instance and collection association DSL methods. The kind drives the eventual
|
|
272
1048
|
# return-type contribution: singular associations narrow to `Nominal[Target] | nil`, plural ones
|
|
273
1049
|
# narrow to `ActiveRecord::Relation[Target]`.
|