rigortype 0.3.6 → 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 +1 -1
- 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 +409 -11
- data/docs/manual/02-cli-reference.md +16 -7
- data/docs/manual/05-inspecting-types.md +20 -8
- data/docs/manual/07-plugins.md +4 -0
- data/docs/manual/plugins/rigor-actionpack.md +61 -0
- data/docs/manual/plugins/rigor-activerecord.md +74 -1
- data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
- 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/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 +14 -5
- data/lib/rigor/analysis/reachability/scan.rb +7 -3
- data/lib/rigor/analysis/result.rb +32 -0
- data/lib/rigor/analysis/run_cache_key.rb +41 -1
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +93 -1
- 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 +147 -10
- 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/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/coverage_command.rb +23 -18
- data/lib/rigor/cli/coverage_mutation.rb +13 -17
- data/lib/rigor/cli/coverage_scan.rb +47 -6
- 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.rb +1 -1
- data/lib/rigor/configuration.rb +7 -4
- data/lib/rigor/effects/plugin_facts.rb +1 -1
- data/lib/rigor/effects/scanner.rb +2 -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/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/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
- metadata +69 -3
|
@@ -12,8 +12,18 @@ module Rigor
|
|
|
12
12
|
# children plus transitive STI subclasses) and yields a row per model.
|
|
13
13
|
# 2. The plugin combines those rows with the parsed {SchemaTable} to produce this index.
|
|
14
14
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
15
|
+
# Phase 2's schema is OPTIONAL. A project that ships raw migrations only — the DB-agnostic Rails
|
|
16
|
+
# pattern, where `db/schema.rb` is gitignored (Redmine) — has no schema to combine, and `schema_table:
|
|
17
|
+
# nil` then yields the REDUCED index: every {Entry} carries its resolved table name, associations,
|
|
18
|
+
# enums, scopes, validations, callbacks and aliases, and an EMPTY column set. Only the column-keyed
|
|
19
|
+
# surface stands down; the schema was never an input to any of the rest. {#columns_known?} tells the
|
|
20
|
+
# two modes apart — an empty column set means "the schema is unknown", not "this model has no
|
|
21
|
+
# columns", and consumers that would fire on an unrecognised column must not read the two the same
|
|
22
|
+
# way.
|
|
23
|
+
#
|
|
24
|
+
# `table_name_override` is non-nil when the source contained `self.table_name = "..."`. When nil, the
|
|
25
|
+
# table name derives from {.inflected_table_name} — demodulize, then {Inflector.tableize}, then a
|
|
26
|
+
# `table_name_prefix` / `table_name_suffix` the enclosing module declares and the discoverer can fold.
|
|
17
27
|
#
|
|
18
28
|
# Single-table-inheritance subclasses (`class Admin < User`) carry an `sti_parent:` pointer; their
|
|
19
29
|
# {Entry} resolves its table from the root model and inherits the chain's declared associations /
|
|
@@ -29,9 +39,18 @@ module Rigor
|
|
|
29
39
|
# - `nullable` — Boolean; whether a `:singular` accessor can return `nil`. `has_one` → `true`;
|
|
30
40
|
# `belongs_to` → `false` (required by default since Rails 5) unless `optional: true`
|
|
31
41
|
# / `required: false`. Meaningless for `:collection` rows.
|
|
32
|
-
|
|
42
|
+
#
|
|
43
|
+
# `table_name_exact` records whether `table_name` is the name the application actually uses, or a
|
|
44
|
+
# derivation that merely looks right. It is true ONLY when the source declared the name as a String
|
|
45
|
+
# literal — `self.table_name = "…"` on this class or, walking leaf → root, on an STI ancestor — and
|
|
46
|
+
# no class in that chain also computes the name at runtime. An {Inflector.tableize} guess is never
|
|
47
|
+
# exact. Only a true reading licenses pinning the name to a value; see
|
|
48
|
+
# `Activerecord#table_name_return_type` for why the schema is not admitted as corroboration.
|
|
49
|
+
Entry = Struct.new(:class_name, :table_name, :table_name_exact, :columns, :associations,
|
|
33
50
|
:enums, :scopes, :validations, :callbacks, :aliases,
|
|
34
51
|
keyword_init: true) do
|
|
52
|
+
def table_name_exact? = table_name_exact == true
|
|
53
|
+
|
|
35
54
|
def column(name)
|
|
36
55
|
columns.find { |c| c.name == name.to_s }
|
|
37
56
|
end
|
|
@@ -75,19 +94,35 @@ module Rigor
|
|
|
75
94
|
|
|
76
95
|
attr_reader :entries
|
|
77
96
|
|
|
78
|
-
def initialize(entries)
|
|
97
|
+
def initialize(entries, columns_known: true)
|
|
79
98
|
@entries = entries.freeze
|
|
99
|
+
@columns_known = columns_known
|
|
80
100
|
freeze
|
|
81
101
|
end
|
|
82
102
|
|
|
103
|
+
# Whether a schema was parsed into this index. `false` in the reduced mode (no `db/schema.rb` and no
|
|
104
|
+
# `db/structure.sql`), where every entry's column set is empty because the columns are UNKNOWN.
|
|
105
|
+
def columns_known? = @columns_known == true
|
|
106
|
+
|
|
107
|
+
# Entries are keyed by the de-rooted constant path (`"User"`, `"Admin::User"` — never `"::User"`;
|
|
108
|
+
# see {ModelDiscoverer}), while a QUERY may legitimately arrive rooted: `::User.find(1)` renders its
|
|
109
|
+
# receiver as `"::User"`. The root marker is dropped here, once, so every lookup accepts both
|
|
110
|
+
# spellings and no caller needs a `find(name) || find("::#{name}")` retry (#583).
|
|
83
111
|
def find(class_name)
|
|
84
|
-
entries[class_name.to_s]
|
|
112
|
+
entries[strip_leading_namespace(class_name.to_s)]
|
|
85
113
|
end
|
|
86
114
|
|
|
87
|
-
def model?(class_name) = entries.key?(class_name.to_s)
|
|
115
|
+
def model?(class_name) = entries.key?(strip_leading_namespace(class_name.to_s))
|
|
88
116
|
def class_names = entries.keys
|
|
89
117
|
def empty? = entries.empty?
|
|
90
118
|
|
|
119
|
+
# `schema_table` may be nil — see the reduced mode described on the class. Every other input is
|
|
120
|
+
# source-derived, so the entries are identical in both modes apart from `columns`.
|
|
121
|
+
#
|
|
122
|
+
# `model_rows` is expected UNIQUE by `class_name`: the entry Hash is keyed by that name, so a second
|
|
123
|
+
# row for a model would replace the first outright. {ModelDiscoverer#merge_redeclarations} is the
|
|
124
|
+
# single home of that guarantee — a reopened class arrives as one merged row, never as two — and the
|
|
125
|
+
# de-duplication is deliberately not repeated here, where a second copy could only drift from it.
|
|
91
126
|
def self.build(model_rows:, schema_table:, type_override_columns: nil)
|
|
92
127
|
rows_by_name = model_rows.to_h { |row| [row.fetch(:class_name), row] }
|
|
93
128
|
overrides = type_override_columns || []
|
|
@@ -96,8 +131,22 @@ module Rigor
|
|
|
96
131
|
class_name = row.fetch(:class_name)
|
|
97
132
|
# The STI ancestry chain, root → self. For a plain (non-STI) model this is just `[row]`.
|
|
98
133
|
chain = sti_chain(row, rows_by_name)
|
|
99
|
-
|
|
100
|
-
|
|
134
|
+
declared = declared_table_name(chain)
|
|
135
|
+
table_name = declared || inflected_table_name(chain)
|
|
136
|
+
# An UNRELIABLE inflected name (see {.inflected_table_name_unreliable?}) is never looked up in
|
|
137
|
+
# the schema at all — `table_name` is still the best-effort demodulized guess for DISPLAY
|
|
138
|
+
# (`model-call`'s "table: ..." info diagnostic), but its COLUMNS are the empty set on purpose,
|
|
139
|
+
# the same stand-down path `Analyzer#validate_column_hash_call` already gives a view-backed
|
|
140
|
+
# model with no schema-side columns. Looking the guess up anyway risks the corroboration
|
|
141
|
+
# `table_name_exact?` above already refuses for a MUCH narrower reason: a real, unrelated table
|
|
142
|
+
# can share the bare demodulized name, and column-checking against it is worse than not
|
|
143
|
+
# checking at all — a wrong `unknown-column` (or a wrong silence) on correct code either way.
|
|
144
|
+
columns =
|
|
145
|
+
if declared.nil? && inflected_table_name_unreliable?(chain)
|
|
146
|
+
[]
|
|
147
|
+
else
|
|
148
|
+
apply_type_overrides(schema_table&.columns_for(table_name) || [], overrides)
|
|
149
|
+
end
|
|
101
150
|
|
|
102
151
|
# STI children inherit their ancestors' declared associations / enums / aliases / scopes /
|
|
103
152
|
# validations / callbacks. Without the merge a `where(<parent-association>: ...)` on the
|
|
@@ -105,6 +154,7 @@ module Rigor
|
|
|
105
154
|
acc[class_name] = Entry.new(
|
|
106
155
|
class_name: class_name,
|
|
107
156
|
table_name: table_name,
|
|
157
|
+
table_name_exact: table_name_exact?(chain, declared),
|
|
108
158
|
columns: columns.freeze,
|
|
109
159
|
associations: merge_named_rows(chain.flat_map { |r| Array(r[:associations]) }),
|
|
110
160
|
enums: merge_enums(chain),
|
|
@@ -114,7 +164,7 @@ module Rigor
|
|
|
114
164
|
aliases: merge_aliases(chain)
|
|
115
165
|
).freeze
|
|
116
166
|
end
|
|
117
|
-
new(entries.freeze)
|
|
167
|
+
new(entries.freeze, columns_known: !schema_table.nil?)
|
|
118
168
|
end
|
|
119
169
|
|
|
120
170
|
# Remaps every type-overridden column's `ruby_type` to `"Object"` so instance-side column narrowing
|
|
@@ -150,11 +200,100 @@ module Rigor
|
|
|
150
200
|
# The effective table name for an STI chain: the nearest explicit `self.table_name =` override
|
|
151
201
|
# walking leaf → root, else the name inflected from the root class.
|
|
152
202
|
def self.sti_table_name(chain)
|
|
203
|
+
declared_table_name(chain) || inflected_table_name(chain)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Whether `table_name` is the name the application actually uses — the condition that licenses
|
|
207
|
+
# pinning it to a value. DECLARED-ONLY: a String literal `self.table_name =` on this class or an STI
|
|
208
|
+
# ancestor, and no class in the chain computing the name at runtime (`def self.table_name`, the
|
|
209
|
+
# `class << self` spelling, a non-literal assignment) — a computed override anywhere beats an
|
|
210
|
+
# ancestor's literal, because it is what actually answers.
|
|
211
|
+
#
|
|
212
|
+
# An inflected name is NEVER exact, not even when the parsed schema has a table by that name.
|
|
213
|
+
# Existence in the schema is not corroboration: the table can belong to a different model. Under an
|
|
214
|
+
# `ApplicationRecord` that sets `self.table_name_prefix = "app_"`, `User` really reads `app_users`
|
|
215
|
+
# while a schema carrying both `users` (another model's, declared) and `app_users` would "confirm"
|
|
216
|
+
# the inflected `users` — and `User.table_name == "app_users"`, true at runtime, would then fold
|
|
217
|
+
# always-falsey. Prefix / suffix / namespace derivation is not reachably airtight, so the lane is
|
|
218
|
+
# dropped rather than patched.
|
|
219
|
+
def self.table_name_exact?(chain, declared)
|
|
220
|
+
return false if chain.any? { |row| row[:table_name_computed] }
|
|
221
|
+
|
|
222
|
+
!declared.nil?
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# The nearest explicit `self.table_name = "..."` walking leaf → root, or nil when the chain declared
|
|
226
|
+
# none. Split out from {sti_table_name} because "was it declared?" is the question
|
|
227
|
+
# `table_name_exact` answers, and re-deriving it from the resolved name is not possible.
|
|
228
|
+
def self.declared_table_name(chain)
|
|
153
229
|
chain.reverse_each do |row|
|
|
154
230
|
override = row[:table_name_override]
|
|
155
231
|
return override if override
|
|
156
232
|
end
|
|
157
|
-
|
|
233
|
+
nil
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# The name inflected from the ROOT class of the chain — an STI child shares its root's table. Rails
|
|
237
|
+
# derives a namespaced model's table from its DEMODULIZED class name — `Blog::Post` → `posts`, the
|
|
238
|
+
# `Blog::` dropped entirely — and prepends/appends `table_name_prefix` / `table_name_suffix` only
|
|
239
|
+
# when the ENCLOSING module actually declares one
|
|
240
|
+
# (`ActiveRecord::ModelSchema::ClassMethods#undecorated_table_name` /
|
|
241
|
+
# `#full_table_name_prefix`); it never flattens the namespace into the table name the way a naive
|
|
242
|
+
# `tableize("Blog::Post")` would (`"blog_posts"`, silently wrong for every namespaced model — #623).
|
|
243
|
+
# The row's `class_name` is already de-rooted ({ModelDiscoverer#declared_constant_name}), so it
|
|
244
|
+
# feeds the demodulize/inflect/decorate steps as is.
|
|
245
|
+
def self.inflected_table_name(chain)
|
|
246
|
+
root = chain.first
|
|
247
|
+
base = Rigor::Plugin::Inflector.tableize(demodulize(root.fetch(:class_name)))
|
|
248
|
+
prefix = decorator_literal(root[:table_name_prefix])
|
|
249
|
+
suffix = decorator_literal(root[:table_name_suffix])
|
|
250
|
+
"#{prefix}#{base}#{suffix}"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# `"Blog::Post"` → `"Post"`; a name without a namespace is returned unchanged. Matches
|
|
254
|
+
# `ActiveSupport::Inflector#demodulize` for a well-formed constant path — a plain String split, no
|
|
255
|
+
# gem call needed (unlike {Inflector}'s allow-listed methods, there is no real-vs-approximated
|
|
256
|
+
# divergence risk here to guard against).
|
|
257
|
+
def self.demodulize(class_name)
|
|
258
|
+
class_name.to_s.split("::").last
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Reads a `table_name_prefix:` / `table_name_suffix:` decorator ({ModelDiscoverer}'s resolved
|
|
262
|
+
# `:table_name_prefix` / `:table_name_suffix` row field, always present on a discovered row): the
|
|
263
|
+
# folded literal when the discoverer could read one off the enclosing namespace, or `""` — both when
|
|
264
|
+
# no enclosing namespace declares the name AND when one does but in a shape the discoverer cannot
|
|
265
|
+
# fold. The second case is deliberately NOT a guess at what the real prefix/suffix string is
|
|
266
|
+
# (#623) — synthesising a default like `"<namespace>_"` would risk corroborating the WRONG table.
|
|
267
|
+
# What THIS empty string does not by itself do is make the resulting name safe to trust for column
|
|
268
|
+
# lookup: see {.inflected_table_name_unreliable?}, which is what actually stands the columns down
|
|
269
|
+
# for this case — the string returned here still feeds `table_name`'s informational display value.
|
|
270
|
+
def self.decorator_literal(decorator)
|
|
271
|
+
(decorator || {}).fetch(:literal, "")
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Whether the chain's INFLECTED name (only reached when nothing was `declared_table_name`) is
|
|
275
|
+
# trustworthy enough to look up in the schema. `false` when either the root's `table_name_prefix` or
|
|
276
|
+
# `table_name_suffix` decorator is `{ computed: true }` (declared by an enclosing namespace, but not
|
|
277
|
+
# in a shape the discoverer could fold — see {ModelDiscoverer}'s class doc), or when the root is
|
|
278
|
+
# nested inside another discovered model class (`table_name_nested_in_model`, a wholly different
|
|
279
|
+
# Rails naming rule this walker does not attempt — same file).
|
|
280
|
+
#
|
|
281
|
+
# A namespace whose decorator this discoverer never even SAW cannot be flagged here at all — it
|
|
282
|
+
# reads as "nothing declared," not "declared but unreadable." {ModelDiscoverer}'s class doc names
|
|
283
|
+
# three such gaps: a prefix declared outside `model_search_paths` entirely (a `def
|
|
284
|
+
# self.table_name_prefix` in `lib/blog.rb`, a `Rails::Engine.isolate_namespace` call), a model
|
|
285
|
+
# declaring `table_name_prefix` on ITSELF, and a base-class-level `table_name_prefix`. The last two
|
|
286
|
+
# are pre-existing — unpatched code guesses wrong identically. The first is a live, documented gap
|
|
287
|
+
# this fix does not close (a namespace this discoverer never reads a file for is indistinguishable
|
|
288
|
+
# from one that genuinely declares nothing), tracked for a follow-up rather than guessed shut.
|
|
289
|
+
def self.inflected_table_name_unreliable?(chain)
|
|
290
|
+
root = chain.first
|
|
291
|
+
decorator_unreliable?(root[:table_name_prefix]) || decorator_unreliable?(root[:table_name_suffix]) ||
|
|
292
|
+
root[:table_name_nested_in_model] == true
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def self.decorator_unreliable?(decorator)
|
|
296
|
+
!decorator.nil? && decorator[:computed] == true
|
|
158
297
|
end
|
|
159
298
|
|
|
160
299
|
# Dedups association-style rows by `:name`, keeping the LAST occurrence so a child redeclaration
|
|
@@ -180,11 +319,15 @@ module Rigor
|
|
|
180
319
|
end.freeze
|
|
181
320
|
end
|
|
182
321
|
|
|
183
|
-
# `::User` → `User`. The
|
|
184
|
-
#
|
|
322
|
+
# `::User` → `User`. The query-side half of the key contract: entries never carry the root marker,
|
|
323
|
+
# so a rooted lookup name is normalised to the spelling the entries use ({#find} / {#model?}).
|
|
185
324
|
def self.strip_leading_namespace(name)
|
|
186
|
-
name.
|
|
325
|
+
name.delete_prefix("::")
|
|
187
326
|
end
|
|
327
|
+
|
|
328
|
+
private
|
|
329
|
+
|
|
330
|
+
def strip_leading_namespace(name) = self.class.strip_leading_namespace(name)
|
|
188
331
|
end
|
|
189
332
|
end
|
|
190
333
|
end
|
|
@@ -34,6 +34,17 @@ module Rigor
|
|
|
34
34
|
# The per-file `#diagnostics_for_file` hook delegates to {Analyzer}, which walks Prism and emits
|
|
35
35
|
# diagnostics for `Model.find` / `Model.find_by` / `Model.where` calls against the index.
|
|
36
36
|
#
|
|
37
|
+
# ## Reduced mode (no schema file)
|
|
38
|
+
#
|
|
39
|
+
# The schema gates the COLUMN surface and nothing else. When neither file is present — the DB-agnostic
|
|
40
|
+
# Rails pattern of shipping raw migrations and gitignoring `db/schema.rb`, as Redmine does — the plugin
|
|
41
|
+
# builds a columns-less {ModelIndex} from {ModelDiscoverer} output alone and keeps typing table names,
|
|
42
|
+
# finders, scopes and associations off it. Column readers and column-keyed checks then decline exactly
|
|
43
|
+
# as an unrecognised column does: `Analyzer` already treats an empty column set as "cannot check"
|
|
44
|
+
# (view-backed models hit the same path on a schema-PRESENT project), and `#column_return_type` finds
|
|
45
|
+
# no column and contributes nothing. The `:model_index` fact is deliberately NOT published in this mode
|
|
46
|
+
# — see `#prepare`.
|
|
47
|
+
#
|
|
37
48
|
# ## Configuration
|
|
38
49
|
#
|
|
39
50
|
# plugins:
|
|
@@ -52,11 +63,31 @@ module Rigor
|
|
|
52
63
|
class Activerecord < Rigor::Plugin::Base
|
|
53
64
|
manifest(
|
|
54
65
|
id: "activerecord",
|
|
55
|
-
#
|
|
66
|
+
# 0.8.0, 2026-09-02 — a model declared `class ::User` is keyed by its de-rooted name (`"User"`) in
|
|
67
|
+
# the {ModelIndex} and the published `:model_index` fact (#583). The version is part of the
|
|
68
|
+
# producer cache KEY: a cached 0.7.0 index still keys such a model `"::User"`, and `ModelIndex#find`
|
|
69
|
+
# now normalises every query to the de-rooted spelling, so serving that payload warm would miss the
|
|
70
|
+
# model on every lookup until an unrelated model edit happened to invalidate the entry. The same
|
|
71
|
+
# bump covers the second half of the change — a model reopened in a second file is now ONE merged
|
|
72
|
+
# entry rather than the last declaration's, so a cached 0.7.0 entry can also be missing the DSL
|
|
73
|
+
# surface the merge restores.
|
|
74
|
+
#
|
|
75
|
+
# 0.7.0, 2026-09-01 — `table_name` / `quoted_table_name` join the recognised class-side surface, and
|
|
76
|
+
# `ModelIndex::Entry` gains the `table_name_exact` member that gates value-pinning. A Struct member
|
|
77
|
+
# addition is not Marshal-compatible with the cached 0.6.0 payload, so the bump (part of the
|
|
78
|
+
# producer cache key) is what keeps a warm run from loading it.
|
|
79
|
+
#
|
|
80
|
+
# 0.6.0, 2026-09-01 — reduced mode: a missing `db/schema.rb` / `db/structure.sql` degrades to a
|
|
81
|
+
# columns-less {ModelIndex} instead of disabling the plugin. The version is part of the producer
|
|
82
|
+
# cache KEY, so the bump is what stops a warm run from serving the pre-change payload (which,
|
|
83
|
+
# having no `columns_known` ivar, would read as reduced mode on a schema-PRESENT project and
|
|
84
|
+
# withhold the `:model_index` fact).
|
|
85
|
+
#
|
|
86
|
+
# 0.5.0, 2026-05-28 — implicit-self class-side AR call resolution: `select(:uri).group(:uri)` inside
|
|
56
87
|
# a scope lambda body / class-method body now contributes `Relation[Model]` via `scope.self_type`
|
|
57
88
|
# instead of falling through to `Kernel#select` (the IO multiplexer, `Array[String]` return). Plus
|
|
58
89
|
# `:select` added to the relation-entry-point list.
|
|
59
|
-
version: "0.
|
|
90
|
+
version: "0.8.0",
|
|
60
91
|
description: "Types ActiveRecord finders against the project's db/schema.rb and AR models.",
|
|
61
92
|
config_schema: {
|
|
62
93
|
"schema_file" => { kind: :string, default: "db/schema.rb" },
|
|
@@ -102,9 +133,13 @@ module Rigor
|
|
|
102
133
|
end
|
|
103
134
|
|
|
104
135
|
# Cached: model index. Walks every model file, then composes the rows with the cached schema table.
|
|
105
|
-
# `watch:` (ADR-60 WD3) covers model-file additions; the discoverer's in-block
|
|
106
|
-
# into the record-and-validate dependency descriptor after the block runs.
|
|
107
|
-
producer :model_index,
|
|
136
|
+
# `watch:` (ADR-60 WD3) covers model-file additions and outside declarations; the discoverer's in-block
|
|
137
|
+
# reads are captured into the record-and-validate dependency descriptor after the block runs.
|
|
138
|
+
producer :model_index,
|
|
139
|
+
watch: lambda {
|
|
140
|
+
outside = ModelDiscoverer::OUTSIDE_SEARCH_DIRECTORIES.map { |dir| [dir, "**/*.rb"] }
|
|
141
|
+
[[@model_search_paths, "**/*.rb"], *outside]
|
|
142
|
+
} do |_params|
|
|
108
143
|
discoverer = ModelDiscoverer.new(
|
|
109
144
|
io_boundary: io_boundary,
|
|
110
145
|
search_paths: @model_search_paths,
|
|
@@ -143,11 +178,20 @@ module Rigor
|
|
|
143
178
|
# }
|
|
144
179
|
#
|
|
145
180
|
# Consumers do `services.fact_store.read(plugin_id: "activerecord", name: :model_index)` and look up
|
|
146
|
-
# by class name
|
|
147
|
-
# the
|
|
181
|
+
# by class name — the DE-ROOTED constant path (`"User"`, `"Admin::User"`), never `"::User"`, however
|
|
182
|
+
# the model was declared (#583; see {ModelDiscoverer}). Discovery failures (missing schema,
|
|
183
|
+
# unparseable models) leave the fact unpublished — the consumer's own degrade path runs (typically a
|
|
184
|
+
# no-op).
|
|
148
185
|
def prepare(services)
|
|
149
186
|
index = model_index
|
|
150
187
|
return if index.nil? || index.empty?
|
|
188
|
+
# Reduced mode stays UNPUBLISHED. Every consumer reads `columns:` as authoritative and fires on a
|
|
189
|
+
# key missing from it — rigor-actionpack's `permit(:title)` check and rigor-shoulda-matchers'
|
|
190
|
+
# `have_db_column(:title)` / `validate_presence_of(:title)` matchers both do — so publishing the
|
|
191
|
+
# reduced index's empty column set would convert this precision win into a false positive on every
|
|
192
|
+
# strong-parameter key and every column matcher in the project. Withholding it leaves those
|
|
193
|
+
# consumers exactly where they are today on a schema-less target: silent.
|
|
194
|
+
return unless index.columns_known?
|
|
151
195
|
|
|
152
196
|
services.fact_store.publish(
|
|
153
197
|
plugin_id: manifest.id,
|
|
@@ -158,20 +202,13 @@ module Rigor
|
|
|
158
202
|
|
|
159
203
|
def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
|
|
160
204
|
index = model_index
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return [] if @load_errors_emitted
|
|
167
|
-
|
|
168
|
-
@load_errors_emitted = true
|
|
169
|
-
return load_error_diagnostics(path)
|
|
170
|
-
end
|
|
171
|
-
return [] if index.empty?
|
|
172
|
-
return [] if migration_path?(path)
|
|
205
|
+
# The schema-load disclosure is independent of whether an index was built: reduced mode still
|
|
206
|
+
# produces one (and still analyzes), while a genuine index failure produces one and nothing else.
|
|
207
|
+
diagnostics = consume_load_error_diagnostics(path)
|
|
208
|
+
return diagnostics if index.nil? || index.empty?
|
|
209
|
+
return diagnostics if migration_path?(path)
|
|
173
210
|
|
|
174
|
-
Analyzer.new(path: path, model_index: index).analyze(root).diagnostics
|
|
211
|
+
diagnostics.concat(Analyzer.new(path: path, model_index: index).analyze(root).diagnostics)
|
|
175
212
|
end
|
|
176
213
|
|
|
177
214
|
# Rails migration files (`db/migrate/<timestamp>_*.rb`) and post-migration files
|
|
@@ -195,7 +232,10 @@ module Rigor
|
|
|
195
232
|
# The class-side finder / relation entry-point names `finder_return_type` recognises. Static half of
|
|
196
233
|
# the `dynamic_return` name gate; the run-time half comes from the model index (scopes,
|
|
197
234
|
# associations, columns).
|
|
198
|
-
FINDER_METHOD_NAMES = %i[
|
|
235
|
+
FINDER_METHOD_NAMES = %i[
|
|
236
|
+
find find_by! find_by where all order limit none select
|
|
237
|
+
table_name quoted_table_name
|
|
238
|
+
].freeze
|
|
199
239
|
private_constant :FINDER_METHOD_NAMES
|
|
200
240
|
|
|
201
241
|
# Return-type contribution via the run-time `methods:` name gate (ADR-52 slice 5b). `Model.find(id)`
|
|
@@ -259,7 +299,8 @@ module Rigor
|
|
|
259
299
|
model_name = constant_receiver_name(call_node.receiver)
|
|
260
300
|
return nil if model_name.nil?
|
|
261
301
|
|
|
262
|
-
|
|
302
|
+
# A rooted receiver (`::User.find`) renders as `"::User"`; `ModelIndex#find` normalises it.
|
|
303
|
+
entry = index.find(model_name)
|
|
263
304
|
return nil if entry.nil?
|
|
264
305
|
|
|
265
306
|
finder_return_type(call_node, entry) ||
|
|
@@ -285,7 +326,7 @@ module Rigor
|
|
|
285
326
|
self_type = scope.self_type
|
|
286
327
|
return nil unless self_type.is_a?(Rigor::Type::Singleton)
|
|
287
328
|
|
|
288
|
-
entry = index.find(self_type.class_name)
|
|
329
|
+
entry = index.find(self_type.class_name)
|
|
289
330
|
return nil if entry.nil?
|
|
290
331
|
|
|
291
332
|
finder_return_type(call_node, entry) ||
|
|
@@ -319,9 +360,41 @@ module Rigor
|
|
|
319
360
|
# of the query DSL chains through the bundled `ActiveRecord::Relation` RBS once a relation is
|
|
320
361
|
# open.
|
|
321
362
|
relation_of(entry.class_name)
|
|
363
|
+
when :table_name
|
|
364
|
+
table_name_return_type(entry)
|
|
365
|
+
when :quoted_table_name
|
|
366
|
+
# Quoting is adapter-dependent — `"users"` on PostgreSQL, backticked on MySQL — so unlike
|
|
367
|
+
# `table_name` the exact string is not derivable from project source even when the bare name is.
|
|
368
|
+
Rigor::Type::Combinator.nominal_of("String")
|
|
322
369
|
end
|
|
323
370
|
end
|
|
324
371
|
|
|
372
|
+
# `Model.table_name` is a String either way; whether it is safe to VALUE-PIN turns on how the name was
|
|
373
|
+
# derived. `Entry#table_name_exact?` holds ONLY for a name the source declared as a String literal
|
|
374
|
+
# (`self.table_name = "…"`, inherited down an STI chain, and not overridden by a computed one). An
|
|
375
|
+
# inflected name is never pinned, however plausible: a `table_name_prefix` / `table_name_suffix` on
|
|
376
|
+
# the namespace, an abstract class (whose real answer is `nil`), and a project-custom inflection rule
|
|
377
|
+
# all silently change the real name, and `Constant["users"]` on any of them is a wrong precise type
|
|
378
|
+
# that value-dependent folding downstream acts on — `Model.table_name == <real name>` folds
|
|
379
|
+
# always-falsey, an error on working code.
|
|
380
|
+
#
|
|
381
|
+
# The schema is deliberately NOT admitted as corroboration for an inflected name. A table existing
|
|
382
|
+
# does not make it THIS model's table: with a `table_name_prefix = "app_"`, `User` reads `app_users`
|
|
383
|
+
# while a `users` table declared by another model would "confirm" the inflection. Prefix / suffix /
|
|
384
|
+
# namespace derivation is not reachably airtight, and a smaller set of pins beats a wrong one.
|
|
385
|
+
# Widening to `String` is monotone imprecision — and it still closes the opacity the call site had,
|
|
386
|
+
# which is the whole measured win.
|
|
387
|
+
#
|
|
388
|
+
# `String` rather than `String | nil` for the abstract-class case follows the same call as
|
|
389
|
+
# `#column_return_type`: under-reporting nil is a false negative, over-reporting it lights up
|
|
390
|
+
# `possible-nil-receiver` on the ordinary idiom, and this project ranks the latter as the worse
|
|
391
|
+
# failure.
|
|
392
|
+
def table_name_return_type(entry)
|
|
393
|
+
return Rigor::Type::Combinator.nominal_of("String") unless entry.table_name_exact?
|
|
394
|
+
|
|
395
|
+
Rigor::Type::Combinator.constant_of(entry.table_name)
|
|
396
|
+
end
|
|
397
|
+
|
|
325
398
|
# `Post.published` / `Post.recent(5)` — a user-declared `scope` returns a relation of the model
|
|
326
399
|
# regardless of the arguments it takes.
|
|
327
400
|
def class_scope_return_type(call_node, entry)
|
|
@@ -358,7 +431,7 @@ module Rigor
|
|
|
358
431
|
model_name = relation_element_class_name(scope.type_of(call_node.receiver))
|
|
359
432
|
return nil if model_name.nil?
|
|
360
433
|
|
|
361
|
-
entry = index.find(model_name)
|
|
434
|
+
entry = index.find(model_name)
|
|
362
435
|
return nil if entry.nil?
|
|
363
436
|
return nil unless entry.scope?(call_node.name)
|
|
364
437
|
|
|
@@ -392,8 +465,7 @@ module Rigor
|
|
|
392
465
|
receiver_type = scope.type_of(call_node.receiver)
|
|
393
466
|
return nil unless receiver_type.is_a?(Rigor::Type::Nominal)
|
|
394
467
|
|
|
395
|
-
entry = index.find(receiver_type.class_name)
|
|
396
|
-
index.find("::#{receiver_type.class_name}")
|
|
468
|
+
entry = index.find(receiver_type.class_name)
|
|
397
469
|
return nil if entry.nil?
|
|
398
470
|
|
|
399
471
|
association_return_type(entry, call_node.name) ||
|
|
@@ -492,8 +564,11 @@ module Rigor
|
|
|
492
564
|
end
|
|
493
565
|
|
|
494
566
|
# Marshal-clean Hash form for the cross-plugin fact store. Consumers (rigor-actionpack Phase 1,
|
|
495
|
-
# rigor-factorybot Phase 1 (c), ...) get a flat `class_name → { table:,
|
|
496
|
-
# depending on this plugin's `ModelIndex` / `SchemaTable::Column` carrier
|
|
567
|
+
# rigor-factorybot Phase 1 (c), rigor-shoulda-matchers, ...) get a flat `class_name → { table:,
|
|
568
|
+
# columns: }` map without depending on this plugin's `ModelIndex` / `SchemaTable::Column` carrier
|
|
569
|
+
# classes. The keys are the index's own — de-rooted constant paths — and the consumers key into the
|
|
570
|
+
# Hash directly with the plain rendering of the constant they anchor on, with no two-spelling retry
|
|
571
|
+
# (#583).
|
|
497
572
|
def index_to_published_hash(index)
|
|
498
573
|
index.entries.transform_values do |entry|
|
|
499
574
|
{
|
|
@@ -512,15 +587,17 @@ module Rigor
|
|
|
512
587
|
def model_index
|
|
513
588
|
return @model_index if @model_index
|
|
514
589
|
|
|
515
|
-
|
|
516
|
-
return
|
|
517
|
-
|
|
590
|
+
# A missing schema no longer disables the plugin. `schema_table_or_nil` is still called here — not
|
|
591
|
+
# for its return value (the producer block calls it again, memoised), but because a cache HIT skips
|
|
592
|
+
# the producer block entirely and the reduced-mode disclosure would then never be recorded on a warm
|
|
593
|
+
# run.
|
|
594
|
+
schema_table_or_nil
|
|
518
595
|
# ADR-60 WD3 record-and-validate: the producer's own in-block `ModelDiscoverer` reads are
|
|
519
596
|
# captured into the dependency descriptor after the block runs, and the producer's `watch:` covers
|
|
520
597
|
# model-file additions — so no priming walk is needed (it used to run the discover twice).
|
|
521
598
|
@model_index = cache_for(:model_index, params: {}).call
|
|
522
599
|
rescue StandardError => e
|
|
523
|
-
@load_errors << "model index build failed: #{e.class}: #{e.message}"
|
|
600
|
+
@load_errors << { message: "model index build failed: #{e.class}: #{e.message}", severity: :warning }
|
|
524
601
|
nil
|
|
525
602
|
end
|
|
526
603
|
|
|
@@ -539,25 +616,48 @@ module Rigor
|
|
|
539
616
|
# here.
|
|
540
617
|
@schema_table = cache_for(:schema_table, params: {}).call
|
|
541
618
|
rescue Plugin::AccessDeniedError => e
|
|
542
|
-
@load_errors << "rigor-activerecord: #{e.message}"
|
|
619
|
+
@load_errors << { message: "rigor-activerecord: #{e.message}#{REDUCED_MODE_SUFFIX}",
|
|
620
|
+
severity: :warning }
|
|
543
621
|
nil
|
|
544
622
|
rescue Errno::ENOENT
|
|
545
|
-
|
|
546
|
-
|
|
623
|
+
# NOT an error: a project that commits raw migrations only and gitignores `db/schema.rb` is the
|
|
624
|
+
# DB-agnostic Rails pattern, not a misconfiguration. The plugin still types finders, scopes,
|
|
625
|
+
# associations and table names off the discovered models, so this is a disclosure of REDUCED
|
|
626
|
+
# capability rather than a failure — `:info`, the grade the plugins use for "here is what I
|
|
627
|
+
# recognised", not `:warning`, the grade they use for "I could not run".
|
|
628
|
+
@load_errors << { message: "rigor-activerecord: schema file `#{@schema_file}` (or " \
|
|
629
|
+
"`#{@structure_sql_file}`) not found#{REDUCED_MODE_SUFFIX}",
|
|
630
|
+
severity: :info }
|
|
547
631
|
nil
|
|
548
632
|
rescue StandardError => e
|
|
549
|
-
|
|
633
|
+
# A schema that EXISTS but does not parse is a real, actionable problem — the user meant it to be
|
|
634
|
+
# read. Reduced mode still applies, but the disclosure stays a warning.
|
|
635
|
+
@load_errors << { message: "rigor-activerecord: failed to parse `#{@schema_file}`: " \
|
|
636
|
+
"#{e.class}: #{e.message}#{REDUCED_MODE_SUFFIX}",
|
|
637
|
+
severity: :warning }
|
|
550
638
|
nil
|
|
551
639
|
end
|
|
552
640
|
|
|
553
|
-
|
|
554
|
-
|
|
641
|
+
# The half of every schema-load disclosure that says what the plugin still does. Kept as one string so
|
|
642
|
+
# the three paths above cannot drift on the description of the mode they all land in.
|
|
643
|
+
REDUCED_MODE_SUFFIX = "; typing models from source only — column checks " \
|
|
644
|
+
"(`where(col:)`, column readers) are skipped"
|
|
645
|
+
private_constant :REDUCED_MODE_SUFFIX
|
|
646
|
+
|
|
647
|
+
# Project-global disclosures, emitted once per run on the first analyzed file rather than once per
|
|
648
|
+
# file. On a Redmine-shape project (migrations only, no `schema.rb`) the per-file form produced 346
|
|
649
|
+
# identical rows; on a Solidus monorepo, 999.
|
|
650
|
+
def consume_load_error_diagnostics(path)
|
|
651
|
+
return [] if @load_errors_emitted
|
|
652
|
+
|
|
653
|
+
@load_errors_emitted = true
|
|
654
|
+
@load_errors.uniq.map do |error|
|
|
555
655
|
Rigor::Analysis::Diagnostic.new(
|
|
556
656
|
path: path,
|
|
557
657
|
line: 1,
|
|
558
658
|
column: 1,
|
|
559
|
-
message: message,
|
|
560
|
-
severity: :
|
|
659
|
+
message: error.fetch(:message),
|
|
660
|
+
severity: error.fetch(:severity),
|
|
561
661
|
rule: "load-error"
|
|
562
662
|
)
|
|
563
663
|
end
|
|
@@ -41,8 +41,8 @@ module Rigor
|
|
|
41
41
|
return unless node.receiver.is_a?(Prism::ConstantReadNode)
|
|
42
42
|
|
|
43
43
|
owner = node.receiver.name.to_s
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
# `AttachmentIndex#attachments_for` de-roots the query itself (#621) — no retry arm here.
|
|
45
|
+
attachments = @attachment_index.attachments_for(owner)
|
|
46
46
|
return if attachments.nil?
|
|
47
47
|
|
|
48
48
|
# Only flag when the method matches a known attachment name (the `dynamic_return` rule provides
|
|
@@ -26,7 +26,9 @@ module Rigor
|
|
|
26
26
|
# - Only Symbol-literal attachment names are recognised. `has_one_attached(args)` or computed names
|
|
27
27
|
# decline.
|
|
28
28
|
# - Modules (`class Admin::User`) are recognised; the resulting class name is the lexical path
|
|
29
|
-
# (`Admin::User`).
|
|
29
|
+
# (`Admin::User`). A ROOTED declaration (`class ::User`) names the top-level constant whatever its
|
|
30
|
+
# nesting, so it yields `"User"` — the same spelling `class User` does, which is what every
|
|
31
|
+
# consumer anchors on (#621).
|
|
30
32
|
class AttachmentDiscoverer
|
|
31
33
|
ATTACHMENT_METHODS = {
|
|
32
34
|
has_one_attached: :singular,
|
|
@@ -64,7 +66,8 @@ module Rigor
|
|
|
64
66
|
def ruby_files_under(roots)
|
|
65
67
|
roots.flat_map do |root|
|
|
66
68
|
absolute = File.expand_path(root)
|
|
67
|
-
|
|
69
|
+
# ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
|
|
70
|
+
next [] unless @io_boundary.directory?(absolute)
|
|
68
71
|
|
|
69
72
|
Dir.glob(File.join(absolute, "**", "*.rb"))
|
|
70
73
|
end
|
|
@@ -87,22 +90,31 @@ module Rigor
|
|
|
87
90
|
class_local_name = constant_path_name(node.constant_path)
|
|
88
91
|
return if class_local_name.nil?
|
|
89
92
|
|
|
90
|
-
full_name = (lexical_path
|
|
93
|
+
full_name = declared_constant_name(class_local_name, lexical_path)
|
|
91
94
|
attachments = collect_attachments(node.body)
|
|
92
95
|
yield full_name, attachments
|
|
93
96
|
|
|
94
|
-
|
|
95
|
-
walk(node.body, inner_path, &) if node.body
|
|
97
|
+
walk(node.body, [full_name], &) if node.body
|
|
96
98
|
end
|
|
97
99
|
|
|
98
100
|
def visit_module(node, lexical_path, &)
|
|
99
101
|
module_local_name = constant_path_name(node.constant_path)
|
|
100
102
|
return if module_local_name.nil?
|
|
101
103
|
|
|
102
|
-
inner_path =
|
|
104
|
+
inner_path = [declared_constant_name(module_local_name, lexical_path)]
|
|
103
105
|
walk(node.body, inner_path, &) if node.body
|
|
104
106
|
end
|
|
105
107
|
|
|
108
|
+
# The full constant name a `class` / `module` declaration defines, given the rendered local name
|
|
109
|
+
# and the enclosing lexical path. A ROOTED local name (`class ::User`) names the top-level constant
|
|
110
|
+
# whatever the nesting, so the lexical path is dropped and the `::` with it; otherwise the name is
|
|
111
|
+
# appended to the path (`class User` inside `module Admin` → `Admin::User`).
|
|
112
|
+
def declared_constant_name(local_name, lexical_path)
|
|
113
|
+
return local_name.delete_prefix("::") if local_name.start_with?("::")
|
|
114
|
+
|
|
115
|
+
(lexical_path + [local_name]).join("::")
|
|
116
|
+
end
|
|
117
|
+
|
|
106
118
|
def collect_attachments(body)
|
|
107
119
|
return [] if body.nil?
|
|
108
120
|
|
|
@@ -126,6 +138,8 @@ module Rigor
|
|
|
126
138
|
Rigor::Source::Literals.symbol_name(node.arguments&.arguments&.first)
|
|
127
139
|
end
|
|
128
140
|
|
|
141
|
+
# Renders a constant-path node as a String, KEEPING the leading `::` of a rooted path —
|
|
142
|
+
# {#declared_constant_name} reads it as "reset the lexical path" before the marker is dropped.
|
|
129
143
|
def constant_path_name(node)
|
|
130
144
|
return nil if node.nil?
|
|
131
145
|
|