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.
Files changed (213) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/date.yml +6 -3
  4. data/data/builtins/ruby_core/enumerable.yml +1 -0
  5. data/data/builtins/ruby_core/exception.yml +2 -1
  6. data/data/builtins/ruby_core/file.yml +28 -14
  7. data/data/builtins/ruby_core/hash.yml +5 -2
  8. data/data/builtins/ruby_core/io.yml +33 -16
  9. data/data/builtins/ruby_core/random.yml +2 -1
  10. data/data/builtins/ruby_core/re.yml +6 -3
  11. data/data/builtins/ruby_core/struct.yml +2 -0
  12. data/data/builtins/ruby_core/time.yml +2 -1
  13. data/data/gem_overlay/activesupport/core_ext.rbs +409 -11
  14. data/docs/manual/02-cli-reference.md +16 -7
  15. data/docs/manual/05-inspecting-types.md +20 -8
  16. data/docs/manual/07-plugins.md +4 -0
  17. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  18. data/docs/manual/plugins/rigor-activerecord.md +74 -1
  19. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  20. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  21. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  22. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  23. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  24. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  25. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  26. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  27. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  28. data/lib/rigor/analysis/check_rules.rb +383 -48
  29. data/lib/rigor/analysis/crash_signature.rb +116 -0
  30. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  31. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  32. data/lib/rigor/analysis/incremental.rb +28 -0
  33. data/lib/rigor/analysis/incremental_session.rb +44 -9
  34. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  35. data/lib/rigor/analysis/reachability/graph.rb +14 -5
  36. data/lib/rigor/analysis/reachability/scan.rb +7 -3
  37. data/lib/rigor/analysis/result.rb +32 -0
  38. data/lib/rigor/analysis/run_cache_key.rb +41 -1
  39. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +93 -1
  40. data/lib/rigor/analysis/runner/pool_coordinator.rb +34 -0
  41. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  42. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -2
  43. data/lib/rigor/analysis/runner.rb +147 -10
  44. data/lib/rigor/analysis/worker_session.rb +10 -1
  45. data/lib/rigor/bleeding_edge.rb +7 -6
  46. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  47. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  48. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  49. data/lib/rigor/cache/descriptor.rb +42 -2
  50. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  51. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +31 -8
  52. data/lib/rigor/cache/store.rb +10 -1
  53. data/lib/rigor/cli/coverage_command.rb +23 -18
  54. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  55. data/lib/rigor/cli/coverage_scan.rb +47 -6
  56. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  57. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  58. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  59. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  60. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  61. data/lib/rigor/cli/sig_gen_command.rb +18 -0
  62. data/lib/rigor/cli/type_of_command.rb +188 -41
  63. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  64. data/lib/rigor/cli/type_scan_command.rb +10 -1
  65. data/lib/rigor/cli.rb +1 -1
  66. data/lib/rigor/configuration.rb +7 -4
  67. data/lib/rigor/effects/plugin_facts.rb +1 -1
  68. data/lib/rigor/effects/scanner.rb +2 -3
  69. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  70. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  71. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  72. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  73. data/lib/rigor/environment/rbs_loader.rb +525 -46
  74. data/lib/rigor/environment.rb +38 -15
  75. data/lib/rigor/inference/acceptance.rb +172 -20
  76. data/lib/rigor/inference/captured_locals.rb +62 -0
  77. data/lib/rigor/inference/content_join.rb +347 -0
  78. data/lib/rigor/inference/def_handle.rb +15 -3
  79. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  80. data/lib/rigor/inference/expression_typer.rb +1020 -141
  81. data/lib/rigor/inference/fork_map.rb +6 -1
  82. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  83. data/lib/rigor/inference/hkt_registry.rb +46 -0
  84. data/lib/rigor/inference/hkt_sugar_translator.rb +93 -0
  85. data/lib/rigor/inference/index_write_widening.rb +48 -0
  86. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  87. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  88. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  89. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  90. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  91. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  92. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  93. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  94. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  95. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  96. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  97. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  98. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  99. data/lib/rigor/inference/mutation_widening.rb +244 -174
  100. data/lib/rigor/inference/narrowing.rb +331 -27
  101. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  102. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  103. data/lib/rigor/inference/precision_scanner.rb +5 -2
  104. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  105. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  106. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  107. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  108. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  109. data/lib/rigor/inference/version_guard.rb +229 -0
  110. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  111. data/lib/rigor/plugin/base.rb +3 -2
  112. data/lib/rigor/plugin/inflector.rb +14 -5
  113. data/lib/rigor/plugin/io_boundary.rb +107 -4
  114. data/lib/rigor/plugin/loader.rb +14 -0
  115. data/lib/rigor/protection/analysis_guard.rb +60 -0
  116. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  117. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  118. data/lib/rigor/protection/discovery_seed.rb +1 -1
  119. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  120. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  121. data/lib/rigor/protection/mutator.rb +121 -18
  122. data/lib/rigor/reflection.rb +182 -30
  123. data/lib/rigor/scope/discovery_index.rb +39 -1
  124. data/lib/rigor/scope.rb +385 -4
  125. data/lib/rigor/sig_gen/classification.rb +12 -1
  126. data/lib/rigor/sig_gen/generator.rb +254 -5
  127. data/lib/rigor/sig_gen/renderer.rb +1 -6
  128. data/lib/rigor/sig_gen/writer.rb +3 -0
  129. data/lib/rigor/source/constant_path.rb +79 -2
  130. data/lib/rigor/triage/catalogue.rb +1 -1
  131. data/lib/rigor/type/combinator.rb +10 -0
  132. data/lib/rigor/type/maybe.rb +47 -0
  133. data/lib/rigor/type/refined.rb +1 -2
  134. data/lib/rigor/type/result.rb +53 -0
  135. data/lib/rigor/type.rb +2 -0
  136. data/lib/rigor/version.rb +1 -1
  137. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  138. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  139. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  140. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  141. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  142. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  143. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  144. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  145. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  146. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  147. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  148. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  149. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  150. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  151. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  152. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  153. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  154. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  155. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  156. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  157. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  158. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  159. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  160. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  161. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  162. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  163. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  164. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  165. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  166. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  167. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  168. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  169. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  170. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  171. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  172. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  173. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  174. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  175. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  176. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  177. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  178. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  179. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  180. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  181. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  182. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  183. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  184. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  185. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  186. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  187. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  188. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  189. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  190. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  191. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  192. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  193. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  194. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  195. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  196. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  197. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  198. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  199. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  200. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  201. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  202. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  203. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  204. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  205. data/sig/rigor/analysis/baseline.rbs +1 -1
  206. data/sig/rigor/environment.rbs +3 -0
  207. data/sig/rigor/inference.rbs +4 -1
  208. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  209. data/sig/rigor/reflection.rbs +3 -1
  210. data/sig/rigor/scope.rbs +25 -1
  211. data/sig/rigor/type.rbs +95 -1
  212. data/sig/rigor.rbs +16 -0
  213. 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
- # `table_name_override` is non-nil when the source contained `self.table_name = "..."`. When nil,
16
- # the table name derives from {Inflector.tableize}.
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
- Entry = Struct.new(:class_name, :table_name, :columns, :associations,
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
- table_name = sti_table_name(chain)
100
- columns = apply_type_overrides(schema_table.columns_for(table_name) || [], overrides)
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
- Rigor::Plugin::Inflector.tableize(strip_leading_namespace(chain.first.fetch(:class_name)))
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 discoverer might prefix with `::` for top-level constants depending on
184
- # how it resolved the path; the table-name derivation uses the short form regardless.
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.start_with?("::") ? name[2..] : 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
- # Bumped 2026-05-28implicit-self class-side AR call resolution: `select(:uri).group(:uri)` inside
66
+ # 0.8.0, 2026-09-02a 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.5.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 reads are captured
106
- # into the record-and-validate dependency descriptor after the block runs.
107
- producer :model_index, watch: -> { [[@model_search_paths, "**/*.rb"]] } do |_params|
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. Discovery failures (missing schema, unparseable models) leave the fact unpublished —
147
- # the consumer's own degrade path runs (typically a no-op).
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
- if index.nil?
162
- # Project-global error (missing `db/schema.rb`, parse failure, etc.) emit once per run rather
163
- # than once per analyzed file. On a Redmine-shape project that uses migrations only (no
164
- # `schema.rb`), the old path produced 346 identical load-errors; on a Solidus monorepo (no
165
- # top-level `schema.rb`), 999.
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[find find_by! find_by where all order limit none select].freeze
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
- entry = index.find(model_name) || index.find("::#{model_name}")
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) || 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) || 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:, columns: }` map without
496
- # depending on this plugin's `ModelIndex` / `SchemaTable::Column` carrier classes.
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
- table = schema_table_or_nil
516
- return nil if table.nil?
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
- @load_errors << "rigor-activerecord: schema file `#{@schema_file}` (or `#{@structure_sql_file}`) " \
546
- "not found; AR call checks skipped"
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
- @load_errors << "rigor-activerecord: failed to parse `#{@schema_file}`: #{e.class}: #{e.message}"
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
- def load_error_diagnostics(path)
554
- @load_errors.uniq.map do |message|
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: :warning,
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
- attachments = @attachment_index.attachments_for(owner) ||
45
- @attachment_index.attachments_for("::#{owner}")
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
- next [] unless File.directory?(absolute)
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 + [class_local_name]).join("::")
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
- inner_path = lexical_path + [class_local_name]
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 = lexical_path + [module_local_name]
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