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
@@ -30,7 +30,8 @@ errors_demo.rb:24:1: error: `User.find` expects at least 1 argument, got 0 [plug
30
30
  | Recognised `Model.find` / `Model.find_by` / `Model.where` call | `:info` | `plugin.activerecord.model-call` |
31
31
  | `Model.find_by(unknown: ...)` / `Model.where(unknown: ...)` | `:error` | `plugin.activerecord.unknown-column` |
32
32
  | `Model.find` with 0 args | `:error` | `plugin.activerecord.wrong-arity` |
33
- | No schema source (`db/schema.rb` or `db/structure.sql`) readable | `:warning` | `plugin.activerecord.load-error` |
33
+ | No schema source (`db/schema.rb` or `db/structure.sql`) present — reduced mode | `:info` | `plugin.activerecord.load-error` |
34
+ | A schema source that exists but cannot be read or parsed | `:warning` | `plugin.activerecord.load-error` |
34
35
 
35
36
  Did-you-mean suggestions use `DidYouMean` fuzzy matching against
36
37
  the resolved table's column names.
@@ -75,18 +76,90 @@ Chained query methods keep the element type, and iteration
75
76
  scope invoked on a typed relation (`User.where(...).published`)
76
77
  never surfaces a false `call.undefined-method`.
77
78
 
79
+ `User.table_name` types as `String`, and as the exact string
80
+ only when your source says the name: a literal
81
+ `self.table_name = "people"` on the class or on an STI ancestor,
82
+ with nothing in that chain computing the name at runtime (a
83
+ `def self.table_name`, a `class << self` version of it, or an
84
+ interpolated assignment all count as computing it). Every other
85
+ name — anything the plugin derived by pluralizing the class name —
86
+ stays plain `String`.
87
+
88
+ That includes names that look confirmed. A `users` table in your
89
+ schema is not evidence that it is `User`'s table: with a
90
+ `self.table_name_prefix` on the base class, `User` really reads
91
+ `app_users`, and a `users` table belonging to some other model
92
+ would "confirm" the wrong guess. A wrong exact string is worse
93
+ than an honest `String` — code comparing `User.table_name` would
94
+ quietly take the wrong branch — so the plugin pins only what you
95
+ wrote down. `User.quoted_table_name` is always `String`; the
96
+ quoting is up to the database adapter.
97
+
98
+ A model declared inside a Ruby module or class (`Blog::Post`)
99
+ resolves its table the way Rails does for the cases below: the
100
+ namespace is dropped, not flattened into the name, so `Blog::Post`
101
+ reads `posts`, not `blog_posts`. A `table_name_prefix` /
102
+ `table_name_suffix` the enclosing namespace declares as a literal
103
+ (`def self.table_name_prefix = "blog_"`, `class << self` with the
104
+ same, or `mattr_accessor :table_name_prefix, default: "blog_"`) is
105
+ applied on top, so the same model reads `blog_posts` once `Blog`
106
+ sets that. `mattr_writer` does not count — it defines no reader, so
107
+ Rails never actually reads the value back, and neither does the
108
+ plugin.
109
+
110
+ `Blog::Post.table_name` still reads as the plain demodulized name
111
+ (`posts`) when `Blog`'s prefix/suffix is declared in a shape the
112
+ plugin cannot read as a literal (a computed value, two disagreeing
113
+ declarations) — but that string is informational only in this case.
114
+ The plugin does not trust it enough to look up columns against it:
115
+ guessing a bare name is the guess most likely to hit an unrelated
116
+ REAL table in a namespaced app, and a wrong corroboration is worse
117
+ than none, so `Blog::Post`'s column, alias and association checks
118
+ stand down entirely rather than run against a table that might not
119
+ be the real one.
120
+
78
121
  ## Limitations
79
122
 
80
123
  - **Direct-superclass match only.** `class Admin < User` where
81
124
  `User < ApplicationRecord` is not discovered. Either add `User`
82
125
  to `model_base_classes`, or list every concrete model
83
126
  explicitly.
127
+ - **A model nested inside ANOTHER non-abstract model class stands down rather
128
+ than guesses.** `Post::Comment` where `Post < ApplicationRecord` and `Post`
129
+ is not abstract hits a different Rails naming rule entirely — the parent's own
130
+ table name is spliced into the middle of the child's, not a
131
+ prefix/suffix — so the plugin recognises the shape and stands
132
+ `Comment`'s column / alias / association checks down instead of
133
+ computing (or guessing at) the real name. (A model nested inside
134
+ an *abstract* parent class, such as `Base::Comment` where `Base` declares
135
+ `self.abstract_class = true` or `primary_abstract_class`, correctly resolves
136
+ its plain demodulized table name with full column checks.)
137
+ - **External `table_name_prefix` / `table_name_suffix` declarations and
138
+ engines.** Declarations outside `model_search_paths` (e.g. in `lib/` or an
139
+ engine's `isolate_namespace`) are detected across the project and cause
140
+ affected models to safely stand down with an empty column set, rather than
141
+ guessing an incorrect table name. Within `model_search_paths`, model-level
142
+ and base-class `table_name_prefix` declarations (literal or computed) are
143
+ resolved directly.
84
144
  - **PostgreSQL `db/structure.sql` fallback.** When `db/schema.rb` is
85
145
  absent, the plugin parses `db/structure.sql` (the `schema_format =
86
146
  :sql` dump) for the same column/type table. It reads PostgreSQL DDL
87
147
  only; a column whose SQL type has no Ruby mapping (a custom enum,
88
148
  `tsvector`, `ltree`) degrades to `Object` (never dropped), and
89
149
  non-`public`-schema partition tables are skipped.
150
+ - **No committed schema — reduced mode.** A project that ships raw
151
+ migrations and gitignores `db/schema.rb` (the DB-agnostic Rails
152
+ pattern) still gets table names, finders, scopes and associations:
153
+ those are read from your model source, not from the schema. Only
154
+ the column-dependent half stands down — column readers stay
155
+ untyped and `where(col:)` keys are not validated, exactly as they
156
+ are for a table the schema does not describe. The plugin says so
157
+ once per run at `:info`. Committing a schema dump (or pointing
158
+ `schema_file` / `structure_sql_file` at one) turns the column half
159
+ back on from the next cold run — a warm cache keeps serving the
160
+ reduced index until it is invalidated, so use `rigor check
161
+ --no-cache` (or `make cache-clean`) if you want to see the change
162
+ immediately.
90
163
  - **Column reads, not setters.** The plugin types instance-side
91
164
  column *reads* (`user.name`, `user.admin?`) and singular
92
165
  associations, but not the `name=` setter or the dirty-tracking
@@ -44,7 +44,9 @@ Roughly the top ~40 selectors plus their close neighbours, across:
44
44
  `#constantize`, `#pluralize`, …), filters (`#squish`, `#truncate`),
45
45
  `#html_safe`, `#starts_with?` / `#ends_with?`, conversions.
46
46
  - **Time / Date / DateTime** — `.current`, `.zone`, `#yesterday`,
47
- `#tomorrow`, `#beginning_of_*` / `#end_of_*`, `#ago`, `#since`.
47
+ `#tomorrow`, `#beginning_of_*` / `#end_of_*`, `#ago`, `#since`. `Time`
48
+ additionally carries its **whole** Rails instance surface (see below);
49
+ `Date` and `DateTime` carry the same subset they always did.
48
50
  - **Array** — `.wrap`, `#to_sentence`, `#in_groups_of`, `#second` …
49
51
  `#fifth`, `#compact_blank`, `#exclude?`.
50
52
  - **Hash** — `#symbolize_keys` / `#stringify_keys` (+ deep / bang),
@@ -57,25 +59,125 @@ Roughly the top ~40 selectors plus their close neighbours, across:
57
59
  Time.current # without the bundle: call.undefined-method Time.current
58
60
  ```
59
61
 
62
+ ## Durations are typed
63
+
64
+ `1.day`, `5.minutes`, `2.5.hours` and every other multiplier type as
65
+ `ActiveSupport::Duration`, and the arithmetic around them keeps its
66
+ meaning:
67
+
68
+ ```ruby
69
+ 1.day # ActiveSupport::Duration
70
+ Time.current - 30.minutes # Time
71
+ 2 * 1.day # ActiveSupport::Duration
72
+ 1.day + 1.hour # ActiveSupport::Duration
73
+ Date.today - 1.week # Date | Time
74
+ ```
75
+
76
+ `Date ± duration` is a union because that is what Rails does: a
77
+ date-part duration gives you back a `Date`, a sub-day one gives you a
78
+ `Time`.
79
+
80
+ Rigor ships a **partial** signature for `ActiveSupport::Duration`: the
81
+ reader surface — `#to_i` / `#in_seconds`, `#to_f`, `#in_minutes` /
82
+ `#in_hours` / `#in_days` / `#in_weeks` / `#in_months` / `#in_years`,
83
+ `#iso8601`, `#parts` — is typed, so `3.hours.in_minutes` is `Float` and
84
+ `1.day.to_i * 2` is `Integer`. `#ago` / `#until` / `#before` / `#since`
85
+ / `#from_now` / `#after` are NOT part of that surface — they default
86
+ to `Time.current`, and typing them was blocked on Rails' `Time`
87
+ instance extensions being declared first, which the section below now
88
+ does; the multipliers themselves are tracked separately. Every
89
+ other member — the arithmetic operators above aside, `==`, and
90
+ anything else Duration forwards through `method_missing` — resolves
91
+ without a diagnostic too, while the site still counts as a concrete
92
+ receiver for `rigor coverage --protection`. Naming
93
+ `ActiveSupport::Duration` at all would normally be the wrong move — a
94
+ partial signature on a class whose real surface forwards to
95
+ `method_missing` turns every omitted member into a false
96
+ `call.undefined-method` — so the plugin lists it under
97
+ `open_receivers:`, the same exemption `rigor-activerecord` gives
98
+ `ActiveRecord::Relation`.
99
+
100
+ The multiplier only fires on a receiver Rigor has proven numeric, so
101
+ `created_at.day`, `Date.today.year` and your own object's `#days` keep
102
+ the answers they always had.
103
+
104
+ ## The Rails `Time` instance surface is declared, not sampled
105
+
106
+ `Time` is a core Ruby class, so RBS knows it fully and it is **closed**:
107
+ a name the signatures do not declare is reported
108
+ `call.undefined-method`. That makes an omission on `Time` just as much
109
+ of a false positive as a wrong return type, with no gradual middle, so
110
+ this bundle declares the surface ActiveSupport adds by audit against the
111
+ gem's own sources rather than by a "top selectors" sample.
112
+
113
+ ```ruby
114
+ Time.current.to_fs(:db) # String
115
+ Time.current.formatted_offset # String
116
+ Time.current.past? # bool
117
+ Time.current.at_beginning_of_hour # Time
118
+ Time.current.days_ago(3).all_week # Range[Time]
119
+ Time.current.in_time_zone("Hawaii") # untyped (ActiveSupport::TimeWithZone)
120
+ Time.current.definitely_not_here # still call.undefined-method
121
+ ```
122
+
123
+ That is the predicates (`#past?`, `#future?`, `#today?`, `#on_weekend?`,
124
+ …), the whole `#days_ago` / `#months_since` / `#next_occurring` family,
125
+ the quarter and `at_`-prefixed spellings, the `#all_week` / `#all_month`
126
+ / `#all_quarter` / `#all_year` ranges, `#to_fs` / `#to_formatted_s` /
127
+ `#formatted_offset` / `#rfc3339`, `#in_time_zone`, and the `Time.`
128
+ singletons `.days_in_month`, `.days_in_year`, `.rfc3339`, `.use_zone`,
129
+ `.find_zone` / `.find_zone!` and `.zone_default`.
130
+
131
+ Where a return cannot honestly be named it is widened rather than
132
+ guessed: `#in_time_zone` answers an `ActiveSupport::TimeWithZone`, which
133
+ this bundle does not model, so it reads `untyped`.
134
+
135
+ What is left out is twelve names, measured against a real
136
+ `require "active_support/all"`: ten instance and two singleton, every one
137
+ an `alias_method` artefact of ActiveSupport's own `+` / `-` / `<=>` /
138
+ `eql?` / `Time.at` overrides — the `plus_with{,out}_duration`,
139
+ `minus_with{,out}_duration`, `minus_with{,out}_coercion`,
140
+ `compare_with{,out}_coercion`, `eql_with{,out}_coercion` and
141
+ `Time.at_with{,out}_coercion` pairs. They are public at runtime and
142
+ `:nodoc:` in the source, and nothing outside ActiveSupport calls them;
143
+ code that does will see them reported.
144
+
145
+ `Date` and `DateTime` are extended by the same ActiveSupport modules and
146
+ do **not** carry this yet — `Date.current.past?` still reports.
147
+
60
148
  ## No diagnostics, no config
61
149
 
62
- The plugin is RBS-only — it emits no diagnostics and has no
63
- configuration knobs. It contributes its signatures unconditionally when
64
- listed under `plugins:`.
150
+ The plugin emits no diagnostics and has no configuration knobs. It
151
+ contributes its signatures and the Duration typing above —
152
+ unconditionally when listed under `plugins:`.
65
153
 
66
154
  ## Limitations
67
155
 
68
- - **Conservative return types.** `Integer#days` really returns
69
- `ActiveSupport::Duration`, but the bundle types it `untyped` because
70
- the analysis environment usually lacks the Duration class the goal
71
- is to silence undefined-method, not to give precise returns. Likewise
72
- `#html_safe` is typed `String` (not `SafeBuffer`) and `#try` / `#try!`
73
- return `untyped`.
156
+ - **Conservative return types.** `#html_safe` is typed `String` (not
157
+ `SafeBuffer`) and `#try` / `#try!` return `untyped` — the goal for
158
+ those is to silence undefined-method, not to give precise returns.
159
+ (The Duration multipliers are one exception: they are declared
160
+ `untyped` in the bundle and then typed by the plugin instead not
161
+ because the bundle can't name `ActiveSupport::Duration` (it does,
162
+ described above), but because moving the multiplier return itself
163
+ into RBS to match hasn't happened yet. `ActiveSupport::Duration`'s
164
+ own reader surface is the other exception, described above.)
165
+ - **`duration / x` is not typed.** `1.day / 2` is a Duration but
166
+ `1.day / 1.hour` is a plain `24`; the answer depends on the operand,
167
+ so Rigor declines rather than guessing.
168
+ - **`duration + Time` is not typed either.** `30.minutes + Time.now`
169
+ raises at runtime — `Duration#+` cannot coerce a Time, and `-`, `*`,
170
+ and a `Date` or `DateTime` on the right fail the same way — so Rigor
171
+ claims nothing for it. `Time.now + 30.minutes` is the form that has a
172
+ value, and it is typed `Time`.
74
173
  - **Project-private monkey-patches are not covered** — only real
75
174
  ActiveSupport extensions. For your own core-class patches see the
76
175
  `pre_eval:` mechanism ([ADR-17](../../adr/17-monkey-patch-pre-evaluation.md)).
77
- - **Top ~40 selectors, not exhaustive.** ActiveSupport ships hundreds of
78
- extensions; this covers the head of the real-world distribution.
176
+ - **Top ~40 selectors, not exhaustive** except on `Time`, where the
177
+ closed-core-class argument above makes a sample unsound and the audit
178
+ is exhaustive but for the twelve `:nodoc:` alias-chain artefacts named
179
+ there. Elsewhere ActiveSupport ships hundreds of extensions and this
180
+ covers the head of the real-world distribution.
79
181
 
80
182
  ## Plugin internals
81
183
 
@@ -42,6 +42,28 @@ arguments.
42
42
  | `plugin.sidekiq.missing-schedule` | error | `perform_in()` / `perform_at()` called with zero arguments (the schedule is required even when `#perform` takes none) |
43
43
  | `plugin.sidekiq.load-error` | warning | worker discovery failed (parse/read error) — once per file |
44
44
 
45
+ ## What it types
46
+
47
+ `perform_async` / `perform_in` / `perform_at` on a **discovered**
48
+ worker return the job id, so the value you assign is a `String`:
49
+
50
+ ```ruby
51
+ jid = WelcomeEmailWorker.perform_async(123)
52
+ jid.upcase # String — resolved, and checked
53
+ OtherThing.perform_async # untouched: not a discovered worker
54
+ ```
55
+
56
+ `perform_inline` is not typed — it runs the job in-process and returns
57
+ whatever your `#perform` returns.
58
+
59
+ The type is plain `String`, not `String?`, even though
60
+ `Sidekiq::Client#push` returns nil when a client middleware halts the
61
+ chain. That path needs a middleware in your own app that returns false
62
+ from `#call` — rare, deliberate, and code that already knows to check —
63
+ whereas typing it nullable puts a `call.possible-nil-receiver` error on
64
+ the ordinary `jid = W.perform_async(id); jid.length`. Rigor takes the
65
+ answer that is silent on the common code.
66
+
45
67
  ## Configuration
46
68
 
47
69
  ```yaml
@@ -5,6 +5,7 @@ require "prism"
5
5
  require_relative "../../source/node_children"
6
6
  require_relative "../../inference/optimistic_origin"
7
7
  require_relative "inferred_param_guard"
8
+ require_relative "published_constant_guard"
8
9
 
9
10
  module Rigor
10
11
  module Analysis
@@ -119,6 +120,17 @@ module Rigor
119
120
  predicate_type = scope.type_of(predicate)
120
121
  return unless predicate_type.is_a?(Type::Constant)
121
122
 
123
+ # Issue #644 — a predicate whose constancy rests on a value constant declared in ANOTHER file is
124
+ # not a logic error the reader's author can see: `if MODE == :production` folds only because the
125
+ # project-wide table published `MODE`, and a configuration constant read in ten files would put
126
+ # this warning in all ten. The value stays published (dispatch and argument typing keep it); only
127
+ # the firing is withheld, which is the direction the carrier discipline allows.
128
+ #
129
+ # LAST of the gates on purpose. It is the only one that can resolve a def and walk its body, and a
130
+ # predicate that does not fold to a `Type::Constant` was never going to fire — so the fold check
131
+ # above is what keeps the hop off every ordinary `if` in a project that publishes anything at all.
132
+ return if PublishedConstantGuard.rooted?(predicate, scope)
133
+
122
134
  polarity = predicate_type.value.nil? || predicate_type.value == false ? :falsey : :truthy
123
135
  @results << Result.new(node: predicate, polarity: polarity)
124
136
  end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../../inference/version_guard"
6
+ require_relative "../../source/node_children"
7
+
8
+ module Rigor
9
+ module Analysis
10
+ module CheckRules
11
+ # ADR-47 WD5 — drops the diagnostics that land inside the **dead arm of a decidable version guard**
12
+ # (issue #627).
13
+ #
14
+ # {Inference::StatementEvaluator} already skips evaluating that arm, so its writes never join into the
15
+ # post-`if` scope. Skipping the evaluation is not by itself enough to silence the arm, though: the
16
+ # rule walk visits every node of the file whether or not the evaluator typed it, so a call whose
17
+ # receiver is typeable on its own — a constant, a literal — still reports. `mail/lib/mail/yaml.rb`'s
18
+ #
19
+ # ::YAML.safe_load(yaml, permitted_classes) # the Psych < 3.1 positional form
20
+ #
21
+ # is exactly that shape: honest against the Ruby 4 Psych signature, and unreachable on the Ruby the
22
+ # user is checking with. This is the second half of the fix — the arm stops producing diagnostics.
23
+ #
24
+ # The dead arms are found by re-asking {Inference::VersionGuard}, which is a pure function of the AST,
25
+ # so this filter and the evaluator's arm elision cannot disagree about which arm is dead.
26
+ #
27
+ # Applied to the type / flow rules only, and BEFORE `suppression.*` joins the list: a malformed
28
+ # `# rigor:disable` marker inside a dead arm is still a real authoring error — the marker's own
29
+ # well-formedness does not depend on whether the code around it runs.
30
+ module DeadVersionGuardArms
31
+ module_function
32
+
33
+ # @param diagnostics [Array<Rigor::Analysis::Diagnostic>]
34
+ # @param root [Prism::Node]
35
+ # @return [Array<Rigor::Analysis::Diagnostic>]
36
+ def filter(diagnostics, root)
37
+ # The scan is a whole-file walk, so it is paid only when there is something to drop. A file with
38
+ # no diagnostics — the overwhelming majority — never walks.
39
+ return diagnostics if diagnostics.empty?
40
+
41
+ arms = scan(root)
42
+ return diagnostics if arms.empty?
43
+
44
+ diagnostics.reject { |diagnostic| arms.any? { |arm| covers?(arm, diagnostic) } }
45
+ end
46
+
47
+ # @param root [Prism::Node]
48
+ # @return [Array<Prism::Location>] the source ranges of every dead version-guard arm
49
+ def scan(root)
50
+ arms = []
51
+ collect(root, arms)
52
+ arms
53
+ end
54
+
55
+ def collect(node, arms)
56
+ return unless node.is_a?(Prism::Node)
57
+
58
+ dead = dead_arm(node)
59
+ arms << dead.location if dead
60
+ # Nothing inside a dead arm can produce a surviving diagnostic, so the walk does not descend into
61
+ # it — a nested guard there would only add a range already covered.
62
+ node.rigor_each_child { |child| collect(child, arms) unless dead && child.equal?(dead) }
63
+ end
64
+ private_class_method :collect
65
+
66
+ # The arm that cannot run, or nil when the guard is undecidable (both arms live — the pre-existing
67
+ # behaviour) or the dead arm is absent (`foo if RUBY_VERSION >= "3.1"` has no `else`).
68
+ def dead_arm(node)
69
+ case node
70
+ when Prism::IfNode
71
+ case Inference::VersionGuard.verdict(node.predicate)
72
+ when :truthy then node.subsequent
73
+ when :falsey then node.statements
74
+ end
75
+ when Prism::UnlessNode
76
+ # `unless` runs its body on the FALSEY edge, so the arms are swapped.
77
+ case Inference::VersionGuard.verdict(node.predicate)
78
+ when :truthy then node.statements
79
+ when :falsey then node.else_clause
80
+ end
81
+ end
82
+ end
83
+ private_class_method :dead_arm
84
+
85
+ # Prism columns are 0-based and `Diagnostic#column` is 1-based; the location's end is exclusive.
86
+ # Compared as `[line, column]` pairs rather than by line alone so a one-line guard
87
+ # (`RUBY_VERSION >= "3.1" ? a(1) : a(1, 2)`) drops only the dead half.
88
+ def covers?(location, diagnostic)
89
+ position = [diagnostic.line, diagnostic.column - 1]
90
+ return false if (position <=> [location.start_line, location.start_column]).negative?
91
+
92
+ (position <=> [location.end_line, location.end_column]).negative?
93
+ end
94
+ private_class_method :covers?
95
+ end
96
+ end
97
+ end
98
+ end
@@ -69,9 +69,9 @@ module Rigor
69
69
 
70
70
  case node
71
71
  when Prism::ClassNode, Prism::ModuleNode
72
- name = Source::ConstantPath.qualified_name(node.constant_path)
73
- if name
74
- walk(node.body, qualified_prefix + [name]) if node.body
72
+ child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
73
+ if child_prefix
74
+ walk(node.body, child_prefix) if node.body
75
75
  return
76
76
  end
77
77
  when Prism::DefNode
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../../source/constant_path"
6
+ require_relative "../../source/node_children"
7
+
8
+ module Rigor
9
+ module Analysis
10
+ module CheckRules
11
+ # Issue #644 — the shared "does this expression's constancy rest on a constant declared in ANOTHER
12
+ # file?" predicate, and the single question the truthiness rule family must ask.
13
+ #
14
+ # The cross-file value-constant table publishes `MODE = :production` project-wide so a reader can
15
+ # dispatch on it, type an argument with it, and see `MODE.upcase` fail. That is the precision the table
16
+ # exists for. What it must NOT do is let a rule *conclude* from the value: `if MODE == :production` in
17
+ # another file folds to `Constant[true]`, and reporting that as `flow.always-truthy-condition` fires on
18
+ # correct code — on the single Ruby idiom whose whole point is a project-wide constant that gets
19
+ # branched on, whose declaration the reader's author never opened. A configuration constant read in ten
20
+ # files would put a warning in all ten.
21
+ #
22
+ # This is [ADR-58](../../../../docs/adr/58-declaration-sourced-ivar-typing.md)'s discipline applied to a
23
+ # different carrier: "a consumer may only ever *withhold* a firing it would otherwise make, so the mark
24
+ # can lose precision but can never manufacture a false positive"
25
+ # (`docs/internal-spec/inference-engine.md` § "Carrier and discipline"). The carrier is deliberately NOT
26
+ # the ADR-58 `Set[[kind, name]]`: that set is flow state, keyed on a *binding* that rebinding drops, and
27
+ # it is already shared by two marks with opposite join policies (ADR-67 WD6b's `:inferred_param` unions
28
+ # where ADR-58's kinds intersect). "Which constants did the project publish, and which does this file
29
+ # declare itself" is ambient, never varies along a flow edge, and so belongs in the
30
+ # `Scope::DiscoveryIndex` — which is exactly ADR-53's membership criterion.
31
+ #
32
+ # `rooted?` is purely syntactic, in the shape of {InferredParamGuard}: it walks a predicate expression
33
+ # down to the roots its constancy could rest on and reports whether any is a published foreign
34
+ # constant. It covers
35
+ #
36
+ # - the bare predicate: `if PAGE_SIZE`, `unless AppConfig::LOG_LEVEL`
37
+ # - a comparison or any method chain on one: `MODE == :production`, `PAGE_SIZE > 100`, `VERSION >= 3`
38
+ # - a constant in ARGUMENT position: `%i[development test].include?(ENV_NAME)` — unlike
39
+ # {InferredParamGuard}, arguments are walked, because a predicate's fold rests on its arguments just
40
+ # as much as on its receiver
41
+ # - `&&` / `||` / parentheses / `rescue` composition, and `!`, which Prism spells as a `CallNode`
42
+ # - ONE interprocedural hop into a project predicate method whose body reads such a constant, whatever
43
+ # spells its owner: `unless production?` (implicit self), `self.prod?`, `AppConfig.production?` (the
44
+ # idiomatic shape — a configuration module exposing predicates), a lexically relative
45
+ # `Inner.production?`, and a call chain `KlassConfig.new.production?`. The owner comes from the
46
+ # receiver's inferred TYPE, so every spelling that names the same module lands on the same table. The
47
+ # hop is bounded (one level, a node budget) and deliberately coarse: ANY published foreign constant
48
+ # anywhere in the callee's body declines, rather than a proof that the return value derives from it.
49
+ # Over-declining withholds a warning; under-declining would emit one. ADR-58's *Non-transitivity*
50
+ # passage is the precedent for stopping at one hop rather than building a provenance channel through
51
+ # the return memo.
52
+ #
53
+ # Deliberately NOT covered, and a separate change: a value copied into a local (`m = MODE; m == :x`),
54
+ # into an ivar (`@mode = MODE` in `initialize`), or through a same-file constant alias
55
+ # (`MODE2 = AppConfig::MODE`). Each needs flow provenance — ADR-58's `r = @x` stamping is the shape —
56
+ # rather than a syntactic root walk, and approximating it here is exactly the ad-hoc widening ADR-58's
57
+ # *Non-transitivity* passage forbids. Two hops (`live? -> prod? -> MODE`) also stay uncovered.
58
+ module PublishedConstantGuard
59
+ # A defensive depth cap against a pathological chain (the walk is otherwise linear in chain length).
60
+ MAX_DEPTH = 64
61
+
62
+ # A node cap on the one-hop callee-body scan, so a predicate calling a very large method cannot make
63
+ # rule collection quadratic. Exhausting it answers false — the firing direction, and the same
64
+ # conservative default the collector's other gates take when they cannot decide.
65
+ BODY_SCAN_BUDGET = 2000
66
+
67
+ module_function
68
+
69
+ # @return [Boolean] true when the rule MUST withhold its firing.
70
+ def rooted?(node, scope, depth = 0)
71
+ return false if node.nil? || depth > MAX_DEPTH || scope.nil?
72
+
73
+ case node
74
+ when Prism::ConstantReadNode then scope.published_constant?(node.name.to_s)
75
+ when Prism::ConstantPathNode then published_path?(node, scope)
76
+ when Prism::CallNode then rooted_call?(node, scope, depth)
77
+ else rooted_through_composition?(node, scope, depth)
78
+ end
79
+ end
80
+
81
+ # `&&` / `||` / `rescue` combine two operands and a parenthesised or multi-statement body yields its
82
+ # last: the produced value is constant only if a rooted operand made it so, so declining on either
83
+ # side is the withholding direction.
84
+ def rooted_through_composition?(node, scope, depth)
85
+ case node
86
+ when Prism::AndNode, Prism::OrNode
87
+ rooted?(node.left, scope, depth + 1) || rooted?(node.right, scope, depth + 1)
88
+ when Prism::RescueModifierNode
89
+ rooted?(node.expression, scope, depth + 1) || rooted?(node.rescue_expression, scope, depth + 1)
90
+ when Prism::ParenthesesNode then rooted?(last_statement(node.body), scope, depth + 1)
91
+ when Prism::StatementsNode then rooted?(node.body.last, scope, depth + 1)
92
+ else false
93
+ end
94
+ end
95
+
96
+ # A call is rooted when its receiver is, when any argument is, or when the project method it names
97
+ # reads such a constant (the one interprocedural hop).
98
+ def rooted_call?(node, scope, depth)
99
+ return true if rooted?(node.receiver, scope, depth + 1)
100
+ return true if Array(node.arguments&.arguments).any? { |arg| rooted?(arg, scope, depth + 1) }
101
+
102
+ callee_reads_published_constant?(node, scope)
103
+ end
104
+
105
+ # The reference AS WRITTEN (`AppConfig::MODE`), so `Scope#published_constant?` can match its local
106
+ # exemption exactly rather than on the last segment. A path that renders no qualified name falls back
107
+ # to its own last segment, which is all there is to ask about.
108
+ def published_path?(node, scope)
109
+ rendered = Source::ConstantPath.qualified_name_or_nil(node) || node.name&.to_s
110
+ !rendered.nil? && scope.published_constant?(rendered)
111
+ end
112
+
113
+ # The one hop. Resolves the callee through the ordinary `Scope` accessors (so the ADR-46 recorder sees
114
+ # the read and the caller gains an edge to the callee — the conservative direction) and scans its
115
+ # body. Fails soft to false: a resolution this cannot make is not a reason to withhold.
116
+ def callee_reads_published_constant?(node, scope)
117
+ return false if scope.published_constant_names.empty?
118
+
119
+ def_node = resolve_callee(node, scope)
120
+ return false if def_node.nil?
121
+
122
+ body_reads_published_constant?(def_node.body, scope)
123
+ rescue StandardError
124
+ false
125
+ end
126
+
127
+ # `foo` / `self.foo` resolve against the enclosing self. Every other receiver resolves through its
128
+ # inferred TYPE, never through the name it was spelled with: `Inner.production?` inside `module Outer`
129
+ # is `Outer::Inner`, and keying the table on the bare `Inner` both misses that (a new warning on
130
+ # ordinary code) and, where a top-level `Inner` also exists, reads the WRONG module's methods. That is
131
+ # the defect family #635 fixed in `Narrowing`, so the engine's own resolver answers it rather than a
132
+ # walk reimplemented here. Going through the type also covers a call-chain receiver for free —
133
+ # `KlassConfig.new.production?` types `Nominal[KlassConfig]`, whose instance table is the one to read.
134
+ def resolve_callee(node, scope)
135
+ receiver = node.receiver
136
+ return resolve_self_call(node.name, scope) if receiver.nil? || receiver.is_a?(Prism::SelfNode)
137
+
138
+ resolve_typed_receiver_call(receiver, node.name, scope)
139
+ end
140
+
141
+ # A `Singleton[X]` receiver names the singleton table (`AppConfig.production?`, a module's own
142
+ # predicates); a `Nominal[X]` receiver names the instance one. Anything else — `Dynamic`, a constant
143
+ # that resolves to no project class — is not a hop this walk can make.
144
+ def resolve_typed_receiver_call(receiver, method_name, scope)
145
+ receiver_type = scope.type_of(receiver)
146
+ case receiver_type
147
+ when Type::Singleton then scope.singleton_def_for(receiver_type.class_name, method_name)
148
+ when Type::Nominal then scope.user_def_for(receiver_type.class_name, method_name)
149
+ end
150
+ end
151
+
152
+ # A `Singleton[X]` self (a `def self.…` body) names the singleton table; a `Nominal[X]` self names
153
+ # the instance one; no class at all is the top-level pseudo-class.
154
+ def resolve_self_call(method_name, scope)
155
+ self_type = scope.self_type
156
+ owner = self_type.respond_to?(:class_name) ? self_type.class_name : nil
157
+ return scope.top_level_def_for(method_name) if owner.nil?
158
+
159
+ if self_type.is_a?(Type::Singleton)
160
+ scope.singleton_def_for(owner, method_name)
161
+ else
162
+ scope.user_def_for(owner, method_name)
163
+ end
164
+ end
165
+
166
+ # An explicit worklist rather than recursion, so the node budget is one loop-carried local instead of
167
+ # a shared mutable cell (the engine value-pins a one-element Array counter and then self-flags the
168
+ # guard's own `negative?` as an always-falsey condition — this rule's own medicine).
169
+ def body_reads_published_constant?(root, scope)
170
+ stack = [root]
171
+ budget = BODY_SCAN_BUDGET
172
+ until stack.empty?
173
+ node = stack.pop
174
+ next unless node.is_a?(Prism::Node)
175
+
176
+ budget -= 1
177
+ return false if budget.negative?
178
+ return true if published_constant_node?(node, scope)
179
+
180
+ node.rigor_each_child { |child| stack << child }
181
+ end
182
+ false
183
+ end
184
+
185
+ def published_constant_node?(node, scope)
186
+ case node
187
+ when Prism::ConstantReadNode then scope.published_constant?(node.name.to_s)
188
+ when Prism::ConstantPathNode then published_path?(node, scope)
189
+ else false
190
+ end
191
+ end
192
+
193
+ def last_statement(body)
194
+ body.is_a?(Prism::StatementsNode) ? body.body.last : body
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
@@ -170,8 +170,7 @@ module Rigor
170
170
  def extend_prefix(node, prefix)
171
171
  return prefix unless CLASS_OR_MODULE_NODE_CLASSES.any? { |klass| node.is_a?(klass) }
172
172
 
173
- name = Source::ConstantPath.qualified_name(node.constant_path)
174
- name ? prefix + [name] : prefix
173
+ Source::ConstantPath.declaration_prefix(prefix, node.constant_path) || prefix
175
174
  end
176
175
  end
177
176
  end
@@ -44,12 +44,12 @@ module Rigor
44
44
  case node
45
45
  when Prism::ModuleNode
46
46
  name = Source::ConstantPath.qualified_name_or_nil(node.constant_path)
47
- child_prefix = name ? prefix + [name] : prefix
47
+ child_prefix = Source::ConstantPath.declaration_prefix(prefix, node.constant_path) || prefix
48
48
  names << child_prefix.join("::") if name
49
49
  walk(node.body, child_prefix, names) if node.body
50
50
  when Prism::ClassNode
51
51
  name = Source::ConstantPath.qualified_name_or_nil(node.constant_path)
52
- child_prefix = name ? prefix + [name] : prefix
52
+ child_prefix = Source::ConstantPath.declaration_prefix(prefix, node.constant_path) || prefix
53
53
  names << child_prefix.join("::") if name && class_surface_open?(node)
54
54
  walk(node.body, child_prefix, names) if node.body
55
55
  else