rigortype 0.4.4 → 0.4.6

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 (233) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/docs/handbook/11-sig-gen.md +100 -18
  4. data/docs/manual/02-cli-reference.md +85 -16
  5. data/docs/manual/03-configuration.md +12 -9
  6. data/docs/manual/04-diagnostics.md +17 -6
  7. data/docs/manual/05-inspecting-types.md +2 -2
  8. data/docs/manual/06-baseline.md +1 -1
  9. data/docs/manual/07-plugins.md +2 -1
  10. data/docs/manual/08-skills.md +7 -0
  11. data/docs/manual/10-mcp-server.md +17 -2
  12. data/docs/manual/12-caching.md +8 -1
  13. data/docs/manual/18-removing-dead-code.md +18 -7
  14. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  15. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  16. data/docs/manual/plugins/rigor-devise.md +15 -0
  17. data/docs/manual/plugins/rigor-rspec.md +102 -1
  18. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  19. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  20. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  21. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  22. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +115 -7
  23. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +253 -0
  24. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  25. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  26. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  27. data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
  28. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  29. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +64 -4
  30. data/lib/rigor/analysis/check_rules.rb +319 -21
  31. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  32. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  33. data/lib/rigor/analysis/edit_replay.rb +67 -0
  34. data/lib/rigor/analysis/incremental.rb +86 -0
  35. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  36. data/lib/rigor/analysis/incremental_session.rb +336 -46
  37. data/lib/rigor/analysis/path_expansion.rb +44 -1
  38. data/lib/rigor/analysis/reachability/graph.rb +90 -13
  39. data/lib/rigor/analysis/reachability/scan.rb +279 -39
  40. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  41. data/lib/rigor/analysis/rule_catalog.rb +22 -5
  42. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  43. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  44. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  45. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  46. data/lib/rigor/analysis/runner/pool_coordinator.rb +82 -16
  47. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  48. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  49. data/lib/rigor/analysis/runner.rb +222 -111
  50. data/lib/rigor/analysis/severity_stamp.rb +11 -0
  51. data/lib/rigor/analysis/worker_session.rb +24 -1
  52. data/lib/rigor/cache/descriptor.rb +40 -7
  53. data/lib/rigor/cache/engine_source.rb +3 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +49 -8
  55. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  56. data/lib/rigor/cache/store.rb +4 -3
  57. data/lib/rigor/cli/check_command.rb +93 -4
  58. data/lib/rigor/cli/command.rb +3 -1
  59. data/lib/rigor/cli/diff_command.rb +2 -1
  60. data/lib/rigor/cli/docs_command.rb +3 -2
  61. data/lib/rigor/cli/doctor_command.rb +1 -1
  62. data/lib/rigor/cli/explain_command.rb +3 -2
  63. data/lib/rigor/cli/lens_command.rb +5 -5
  64. data/lib/rigor/cli/mcp_command.rb +2 -1
  65. data/lib/rigor/cli/options.rb +20 -3
  66. data/lib/rigor/cli/plugin_command.rb +2 -1
  67. data/lib/rigor/cli/plugins_command.rb +7 -1
  68. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  69. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  70. data/lib/rigor/cli/sig_gen_command.rb +73 -13
  71. data/lib/rigor/cli/skill_command.rb +2 -1
  72. data/lib/rigor/cli/skill_describe.rb +3 -2
  73. data/lib/rigor/cli/type_of_command.rb +2 -1
  74. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  75. data/lib/rigor/cli/unused_command.rb +10 -7
  76. data/lib/rigor/cli.rb +3 -2
  77. data/lib/rigor/config_audit.rb +72 -16
  78. data/lib/rigor/configuration/severity_profile.rb +20 -2
  79. data/lib/rigor/configuration.rb +43 -7
  80. data/lib/rigor/effects/signature_sources.rb +3 -1
  81. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  82. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  83. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  84. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  85. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  86. data/lib/rigor/environment/rbs_loader.rb +136 -7
  87. data/lib/rigor/environment/required_features.rb +136 -29
  88. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  89. data/lib/rigor/environment.rb +4 -2
  90. data/lib/rigor/inference/block_call_timing.rb +7 -1
  91. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  92. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  93. data/lib/rigor/inference/captured_locals.rb +44 -13
  94. data/lib/rigor/inference/clause_widening.rb +90 -0
  95. data/lib/rigor/inference/deep_erasure.rb +138 -0
  96. data/lib/rigor/inference/def_return_typer.rb +159 -9
  97. data/lib/rigor/inference/definer_resolution.rb +101 -20
  98. data/lib/rigor/inference/error_info.rb +17 -3
  99. data/lib/rigor/inference/expression_typer.rb +97 -18
  100. data/lib/rigor/inference/global_write_census.rb +36 -1
  101. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  102. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  103. data/lib/rigor/inference/loop_value_record.rb +53 -0
  104. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  105. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  106. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +278 -42
  107. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  108. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  109. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  110. data/lib/rigor/inference/method_dispatcher.rb +113 -19
  111. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  112. data/lib/rigor/inference/module_function_state.rb +149 -42
  113. data/lib/rigor/inference/mutation_widening.rb +5 -2
  114. data/lib/rigor/inference/narrowing.rb +116 -11
  115. data/lib/rigor/inference/object_mixins.rb +13 -1
  116. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  117. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  118. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  119. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  120. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  121. data/lib/rigor/inference/project_seed_tables.rb +121 -0
  122. data/lib/rigor/inference/project_yielder.rb +128 -0
  123. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  124. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  125. data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
  126. data/lib/rigor/inference/return_barrier.rb +16 -0
  127. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  128. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  129. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  130. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  131. data/lib/rigor/inference/scope_indexer.rb +1144 -197
  132. data/lib/rigor/inference/self_call_ivar_writes.rb +29 -12
  133. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  134. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  135. data/lib/rigor/inference/statement_evaluator.rb +1088 -213
  136. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  137. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  138. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  139. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  140. data/lib/rigor/language_server/buffer_table.rb +6 -0
  141. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  142. data/lib/rigor/language_server/project_context.rb +59 -3
  143. data/lib/rigor/language_server/server.rb +5 -3
  144. data/lib/rigor/lens/contract_reader.rb +45 -2
  145. data/lib/rigor/lens/document.rb +2 -1
  146. data/lib/rigor/lens/file_reader.rb +12 -2
  147. data/lib/rigor/lens/members.rb +16 -2
  148. data/lib/rigor/lens/run.rb +34 -7
  149. data/lib/rigor/mcp/server.rb +11 -3
  150. data/lib/rigor/plugin/base.rb +29 -13
  151. data/lib/rigor/plugin/io_boundary.rb +42 -6
  152. data/lib/rigor/plugin/isolation.rb +4 -3
  153. data/lib/rigor/plugin/manifest.rb +29 -3
  154. data/lib/rigor/plugin/registry.rb +38 -0
  155. data/lib/rigor/protection/discovery_seed.rb +64 -36
  156. data/lib/rigor/protection/mutation_cache.rb +8 -2
  157. data/lib/rigor/rooted_glob.rb +36 -0
  158. data/lib/rigor/scope/discovery_index.rb +5 -0
  159. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  160. data/lib/rigor/scope/resolution_chain.rb +40 -5
  161. data/lib/rigor/scope.rb +59 -7
  162. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  163. data/lib/rigor/sig_gen/classification.rb +4 -1
  164. data/lib/rigor/sig_gen/generator.rb +465 -49
  165. data/lib/rigor/sig_gen/layout_index.rb +5 -2
  166. data/lib/rigor/sig_gen/observation_collector.rb +141 -12
  167. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  168. data/lib/rigor/sig_gen/renderer.rb +16 -155
  169. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  170. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  171. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  172. data/lib/rigor/sig_gen/write_result.rb +3 -1
  173. data/lib/rigor/sig_gen/writer.rb +5 -3
  174. data/lib/rigor/signature_path_audit.rb +4 -2
  175. data/lib/rigor/type/combinator.rb +4 -3
  176. data/lib/rigor/type/constant.rb +54 -3
  177. data/lib/rigor/type/hash_shape.rb +14 -9
  178. data/lib/rigor/type/struct_instance.rb +3 -2
  179. data/lib/rigor/version.rb +1 -1
  180. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  181. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +61 -44
  182. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  183. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  184. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  185. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  186. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  187. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  188. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  189. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  190. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  191. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  192. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  193. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  194. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  195. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +7 -6
  196. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  197. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
  198. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  199. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  200. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  201. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  202. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  203. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  204. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  205. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  206. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  207. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  208. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  209. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  210. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  211. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  212. data/sig/rigor/analysis/baseline.rbs +3 -2
  213. data/sig/rigor/effects/file_collection.rbs +5 -6
  214. data/sig/rigor/effects/registry.rbs +8 -6
  215. data/sig/rigor/effects/summary.rbs +6 -5
  216. data/sig/rigor/environment.rbs +5 -3
  217. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  218. data/sig/rigor/inference.rbs +6 -0
  219. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  220. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  221. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  222. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  223. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  224. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  225. data/sig/rigor/plugin/manifest.rbs +16 -18
  226. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  227. data/sig/rigor/plugin/registry.rbs +1 -1
  228. data/sig/rigor/rooted_glob.rbs +7 -0
  229. data/sig/rigor/scope.rbs +34 -52
  230. data/sig/rigor/type.rbs +0 -6
  231. data/skills/rigor-doctor/references/01-checks.md +23 -11
  232. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  233. metadata +32 -1
@@ -66,7 +66,9 @@ rigor mcp [--transport=stdio] [--config=PATH]
66
66
  transport is queued for v2.
67
67
  - `--config=PATH` — session-level default config path. Individual tool
68
68
  calls may supply their own `config` argument, which takes precedence.
69
- Without either, `Configuration.discover` walks `.rigor.yml` /
69
+ A path that names no file is an error, not a silent switch to the
70
+ defaults, for the session default and for a per-call `config` alike:
71
+ the call returns `isError: true`. Without either, `Configuration.discover` walks `.rigor.yml` /
70
72
  `.rigor.dist.yml` from the working directory.
71
73
 
72
74
  Exit codes: 0 (clean shutdown — stdin EOF), 64 (unknown
@@ -391,6 +393,13 @@ Generate RBS skeleton signatures inferred from Ruby source files.
391
393
  project's test roots: the configured `test_paths:`, or whichever of
392
394
  `spec/` and `test/` exist.
393
395
 
396
+ Under `params: "observed"` the response also carries a top-level
397
+ `observation` object, `{"paths": [...], "missing": [...]}`: the test
398
+ roots that were read and the declared ones that do not exist or that
399
+ the collector would not read (neither a directory nor a `.rb` file). Both
400
+ empty means no test root was found, so every parameter stays `untyped`.
401
+ The key is absent under the default `untyped` policy.
402
+
394
403
  **Returns:** JSON — the same as `rigor sig-gen --print --format json`.
395
404
 
396
405
  ```json
@@ -410,6 +419,12 @@ project's test roots: the configured `test_paths:`, or whichever of
410
419
  Classifications: `new-file`, `new-method`, `tighter-return`,
411
420
  `equivalent`, `skipped`.
412
421
 
422
+ A `paths` entry that is neither a directory nor a `.rb` file is reported
423
+ under a top-level `diagnostics` array, in the shape `rigor_check` uses
424
+ (`"message": "no such file or directory"`). It is a warning beside
425
+ paths that were read, and an error when none was. An empty `candidates`
426
+ list with no `diagnostics` means nothing was found to generate.
427
+
413
428
  **Typical agent use:** call `rigor_sig_gen` to see which methods have
414
429
  precise enough returns to be worth writing into `sig/`, then generate
415
430
  and insert the RBS. The agent should NOT write signatures
@@ -430,7 +445,7 @@ Look up the description of one or all Rigor diagnostic rules.
430
445
 
431
446
  Accepted values: a canonical rule ID (`call.undefined-method`), a
432
447
  legacy alias (`undefined-method`), or a family prefix (`call`, `flow`,
433
- `assert`, `dump`, `def`).
448
+ `def`, `assert`, `dump`, `global`, `suppression`, `static`, `effect`).
434
449
 
435
450
  **Returns:** JSON — an array of rule catalog entries.
436
451
 
@@ -185,7 +185,14 @@ exactly which files an edit can affect. A continuous-integration
185
185
  gate, `rigor check --verify-incremental`, asserts this on every
186
186
  build: it runs the incremental analyzer and a full analysis and
187
187
  fails if they disagree on a single diagnostic, or list the same
188
- diagnostics in a different order.
188
+ diagnostics in a different order. That gate re-analyses an
189
+ unchanged tree, so it cannot see an edit that fails to re-check a
190
+ file it affects. `--verify-incremental-edits=N` also edits `N`
191
+ files one at a time, in memory and not on disk, and compares each
192
+ warm recheck with a full analysis of the edited tree. Each file is
193
+ edited twice: once emptied, and once reduced to its `class` /
194
+ `module` declarations with every body dropped. It replays only
195
+ those two removals, never an addition or any other edit.
189
196
 
190
197
  Rigor is also precise about *what* an edit changed. Re-checking a
191
198
  file that many others depend on — a base class, a widely-used
@@ -6,7 +6,9 @@ into a finite list of specific questions. It does not answer them.
6
6
 
7
7
  The command reference — every flag, every output section — is in
8
8
  [CLI reference](02-cli-reference.md#rigor-unused). This chapter is the
9
- workflow around it.
9
+ workflow around it. For an AI coding agent, the bundled
10
+ [`rigor-unused-adjudicate`](08-skills.md) skill walks through this
11
+ adjudication workflow step by step.
10
12
 
11
13
  **Before you start** you need a working `rigor check` setup: a config
12
14
  file with `paths:`, and the plugins for your framework enabled. See
@@ -270,12 +272,21 @@ implicit, `self` or `self.class` inside a method (or a block in a class
270
272
  body, such as a lambda or `define_method`), the call may run on a
271
273
  subclass or on an includer, so a `Handler` declared in a descendant is
272
274
  kept reachable too. Descendants are the classes written `class D < Base`
273
- and the declarations whose body has a plain `include`, `prepend` or
274
- `extend` of the module; `K.include(Mix)`, `K.send(:include, Mix)`,
275
- `K.extend(Ext)` and `K = Class.new(Base)` are not followed yet. A call
276
- written directly in a class body is not widened. Inside
277
- `Other.instance_eval { }` (or `class_eval`, `module_eval` and their
278
- `_exec` forms) the receiver is `Other`. A name built into a
275
+ or `K = Class.new(Base)`, and the classes that `include`, `prepend` or
276
+ `extend` the module in their body or through a receiver Rigor can name:
277
+ `K.include(Mix)` in an initializer, its `send`, `public_send` and
278
+ `__send__` spellings, or `include Mix` inside `K.class_eval { }`. A call written directly in a
279
+ class body is not widened. Inside `Other.instance_eval { }` (or
280
+ `class_eval`, `module_eval` and their `_exec` forms) the receiver is
281
+ `Other`, and a method the block defines (`def`, `define_method`,
282
+ `define_singleton_method`) runs on `Other` or a descendant. When the
283
+ receiver cannot be named at all, the name is kept wherever it is
284
+ declared: an eval block on a local variable (`k.class_eval { }`), the
285
+ module's own lookups when such a receiver includes it, or a class that
286
+ includes a computed module (`K.include(mods.first)`). A class that gains
287
+ a module through a receiver Rigor cannot name (`[Foo].each { |k|
288
+ k.include(Consts) }`, an `include` in a `Class.new` block) is not
289
+ followed for its own lookups of that module's constants. A name built into a
279
290
  local variable first (`name = "V#{version}"; const_get(name)`) is read
280
291
  the same way. A
281
292
  class name appearing as a string in a `.yml`, `.json` or template file
@@ -77,6 +77,36 @@ plugins:
77
77
  `--fail-on=warning` run — regenerate with `rigor baseline
78
78
  regenerate`.
79
79
 
80
+ ## Instance variable writes across actions
81
+
82
+ The plugin tells the engine that a mailer runs one action per instance
83
+ ([ADR-125](../../adr/125-per-invocation-classes.md)). Its manifest lists
84
+ `ActionMailer::Base` in `per_invocation_classes:`, and the callback macros
85
+ (`before_action` and the rest) and `helper_method` in
86
+ `per_invocation_non_action_macros:`, so a method those name is not an action.
87
+
88
+ So [`def.ivar-write-mismatch`](../04-diagnostics.md#rule-def-ivar-write-mismatch)
89
+ stays silent when `welcome` assigns `@items = [1, 2]` and `digest` assigns
90
+ `@items = { a: 1 }`. It still reports two divergent writes in the same action,
91
+ and a divergence that involves a private or protected helper, `initialize`, or a
92
+ callback target or `helper_method` that a macro in the same file names.
93
+
94
+ The callback macros include `before_deliver`, `after_deliver` and
95
+ `around_deliver`, which run on the action's own instance.
96
+
97
+ Two shapes stay silent because their macros are not listed: a public
98
+ method named by `rescue_from X, with: :handler` or by `layout :method_name`
99
+ counts as an action, as does one a lambda callback
100
+ (`before_action -> { load_roles }`) reaches ([#2320](https://github.com/rigortype/rigor/issues/2320) tracks the fix).
101
+
102
+ Visibility is read in source order, as Rails builds `action_methods` from the
103
+ public instance methods. A method that ends up protected or private is not an
104
+ action: it follows a bare `protected` or `private`, is named by `private :x` or
105
+ `protected :x`, or is defined inline (`private def x`). `public :x` or
106
+ `public def x` makes it one again. So it is no longer checked for a view
107
+ template, and calling it (`UserMailer.helper`) reports `unknown-action` at
108
+ error severity, as Rails raises `NoMethodError` for it.
109
+
80
110
  ## Plugin internals
81
111
 
82
112
  The mailer/concern discoverer, the cached `:mailer_index` producer,
@@ -41,6 +41,36 @@ no-op rather than erroring.
41
41
  | `plugin.actionpack.permit-call` | info | a `params.require(:m).permit(:key, …)` chain resolved to a known model; keys matched against its columns |
42
42
  | `plugin.actionpack.unknown-permit-key` | error | a literal `permit(:key)` is a near-miss (edit distance ≤ 2) of a real column but not one — a likely typo (with a did-you-mean). A key nothing like any column (a legitimate virtual attribute) does not fire |
43
43
 
44
+ The plugin also tells the engine that a controller runs one action
45
+ per instance ([ADR-125](../../adr/125-per-invocation-classes.md)).
46
+ Its manifest lists `ActionController::Base` and
47
+ `ActionController::API` in `per_invocation_classes:`. It also lists
48
+ the callback macros (`before_action` and the rest) and
49
+ `helper_method` in `per_invocation_non_action_macros:`, so a method
50
+ those name is not an action.
51
+
52
+ So `def.ivar-write-mismatch` stays silent when `index` assigns
53
+ `@roles = Role.sorted.to_a` and `permissions` assigns
54
+ `@roles = Role.sorted`. It still reports a divergence that involves
55
+ a private or protected helper, `initialize`, or two writes in the
56
+ same action. It also still reports one that involves a callback
57
+ target or `helper_method`, when the macro naming it sits in the same
58
+ file as the method. Actions defined in a concern module and
59
+ `ActionController::Metal` controllers keep reporting too.
60
+
61
+ Two shapes stay silent because their macros are not listed: a public
62
+ method named by `rescue_from X, with: :handler` or by `layout :method_name`
63
+ counts as an action, as does one a lambda callback
64
+ (`before_action -> { load_roles }`) reaches ([#2320](https://github.com/rigortype/rigor/issues/2320) tracks the fix).
65
+
66
+ These shapes stay silent:
67
+
68
+ - a public method named only by a macro in another file or a
69
+ concern, such as `ApplicationController`;
70
+ - a public method a lambda callback reaches
71
+ (`before_action -> { load_roles }`);
72
+ - an action that calls another action.
73
+
44
74
  Filter and render resolution honours nested-module controller
45
75
  qualification (`module Admin; class WidgetsController` resolves
46
76
  views under `admin/widgets/…`) and silences gem-shipped parent
@@ -48,6 +48,21 @@ whatever class includes the concern.
48
48
 
49
49
  ## Limitations
50
50
 
51
+ - **Needs RBS for the Devise modules.** The methods come from the
52
+ strategy modules' signatures (`Devise::Models::DatabaseAuthenticatable`
53
+ and the rest), which Rigor does not bundle and the `devise` gem does
54
+ not ship. Supply them through `signature_paths:` (or a
55
+ `rbs collection`); a strategy module with no signature contributes
56
+ nothing, and the model's `valid_password?` and friends stay as they
57
+ were. `plugins/rigor-devise/demo/sig/devise.rbs` is a minimal
58
+ example.
59
+ - **Your own definitions win.** A method the model defines itself
60
+ (`def`, `alias_method`, `attr_*`, `define_method`, `delegate`), or
61
+ that a module of your project it includes or prepends defines, is
62
+ left to that definition rather than typed from the Devise module.
63
+ This holds even for a module included before the `devise` call,
64
+ which Devise's module would override at runtime. Only the module's
65
+ own public methods are contributed.
51
66
  - **Instance methods only.** Per-module `ClassMethods` (e.g.
52
67
  `User.reset_password_by_token`) are not yet synthesised.
53
68
  - **Controller helpers deferred.** `current_user` /
@@ -5,7 +5,9 @@ Validates RSpec `let` / `subject` declarations within each
5
5
  checks it ships have the lowest false-positive risk of the
6
6
  proposed RSpec surface, run in pure syntactic-walk mode, and
7
7
  catch real bugs that `rspec` / `rubocop-rspec` don't always
8
- surface clearly. No RSpec runtime dependency.
8
+ surface clearly. It also narrows a local through `expect(x).to`
9
+ matchers, described under [Matcher narrowing](#matcher-narrowing).
10
+ No RSpec runtime dependency.
9
11
 
10
12
  It ships bundled in `rigortype`. Activate it under `plugins:`:
11
13
 
@@ -50,6 +52,105 @@ The walker recognises `RSpec.describe … do` (root), nested
50
52
  `describe` / `context … do`, `let(:name)` / `let!(:name)`, and
51
53
  `subject(:name)` / bare `subject` (the implicit `:subject`).
52
54
 
55
+ ## Matcher narrowing
56
+
57
+ `expect(x).to MATCHER` narrows the local `x` for the rest of the
58
+ example body, so downstream calls resolve against the matched type.
59
+ Only a plain local is a target.
60
+
61
+ ```ruby
62
+ RSpec.describe "Counter" do
63
+ it "narrows through the matcher" do
64
+ count = rand(10) if rand(2).zero? # count is Integer?
65
+ expect(count).to eq(42) # count is 42 from here on
66
+ count + 1 # no possible-nil-receiver warning
67
+ end
68
+ end
69
+ ```
70
+
71
+ | Matcher | Effect on `x` |
72
+ | --- | --- |
73
+ | `eq(literal)` / `eql(literal)` | meet with `Constant<literal>` (see below) |
74
+ | `be_a(T)` / `be_kind_of(T)` / `be_instance_of(T)` / `be_an_instance_of(T)` | meet with `T` |
75
+ | `be_nil` | narrow to `Constant<nil>` |
76
+ | `match(/regex/)` | keep the `String` and `Symbol` members, read an `Object`, `BasicObject` or module member as `String \| Symbol`, and remove `nil`, `true`, `false` and numeric members (unless the project defines `match`, `to_str` or `==` on them); unchanged when a member may be anything else |
77
+ | `not_to be_nil` / `to_not be_nil` | narrow away from `nil` |
78
+ | `not_to be_a(T)` / `not_to be_kind_of(T)` / `not_to be_instance_of(T)` / `not_to be_an_instance_of(T)` | narrow away from `T` |
79
+ | `not_to eq(literal)` / `not_to eql(literal)` (and `to_not`) | unchanged (see below) |
80
+ | `not_to match(/regex/)` (and `to_not`) | unchanged: a `String` that does not match passes |
81
+
82
+ Other matchers emit nothing. `be_truthy` is not narrowed, nor is
83
+ `expect { ... }.to raise_error(...)` (the block form), nor an
84
+ `expect(foo.bar)` whose argument is not a local.
85
+
86
+ ### Equality is a meet, not a replacement
87
+
88
+ `eq(literal)` passes when `x == literal`, and `eql(literal)` when
89
+ `x.eql?(literal)`. The local is the receiver, so `x`'s own operator
90
+ decides, and that operator can hold across classes: `1 == 1.0`, and a
91
+ class with its own `==` (a `ModInt` that compares `to_i`) passes
92
+ `expect(m).to eq(9)`. The matcher therefore never replaces `x`'s
93
+ type. It narrows each member of `x`'s type on its own.
94
+
95
+ The core equality classes are the nine in `EQUALITY_CORE_CLASSES`
96
+ (`plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb`):
97
+ `Integer`, `Float`, `Rational`, `Complex`, `String`, `Symbol`,
98
+ `NilClass`, `TrueClass`, and `FalseClass`. The first four are the
99
+ numeric classes.
100
+
101
+ - a member of the literal's own class narrows to the literal,
102
+ refinements of that class included. After `x = rand(10)`,
103
+ `expect(x).to eq(9)` gives `9`; `Integer?` with `eq(9)` gives `9`,
104
+ because the `nil` member cannot equal `9`. A refinement such as
105
+ `decimal-int-string?` (from `y = x && x.to_s` with `x` an
106
+ `Integer?`) gives `"3"` under `eq("3")`. A `Constant` member is kept
107
+ when it equals the literal and dropped otherwise;
108
+ - a `Float` member meets `0.0` and is kept, since `-0.0` also equals
109
+ it;
110
+ - a numeric member of another class (`Integer`, `Float`, `Rational`,
111
+ `Complex`) is kept under `eq`: `Integer | Float` with `eq(1)` gives
112
+ `1 | Float`;
113
+ - any other member of a core equality class is dropped:
114
+ `String | Integer` with `eq(9)` gives `9`;
115
+ - `eql(literal)` takes the same path, except that a numeric member of
116
+ another class is dropped, because `eql?` compares classes:
117
+ `Integer | Float` with `eql(1)` gives `1`.
118
+
119
+ ### A member, or the whole local, keeps its type in these cases
120
+
121
+ A member outside the core equality classes makes the whole fact
122
+ unavailable, so the local keeps its type, not just that member:
123
+
124
+ - a member is `Dynamic`, or of a class outside `EQUALITY_CORE_CLASSES`,
125
+ such as a user class (`ModInt`), `Numeric`, or a generic `Array` or
126
+ `Hash`. Under `eq(9)` the whole local stays unchanged, so `9 | [1]`
127
+ and `9 | { a: 1 }` do not narrow;
128
+ - the project defines the operator (`==` for `eq`, `eql?` for `eql`)
129
+ on the literal's class, in any analysed file. A `def` or a
130
+ `define_method` both count. A core `==` can call back into the
131
+ literal's own `==` (`Integer#==` for a non-numeric argument,
132
+ `String#==` for one that responds to `to_str`), so the plugin does
133
+ not narrow there even when a kept member would survive;
134
+ - the project reopens a core ancestor of the member's class and
135
+ defines the operator there, such as `class Numeric; def eql?` or
136
+ `class Object; def ==`. Under `eql(9)` an `Integer` stays
137
+ `Integer`; under `eq(9)` the `nil` member of `Integer?` stays `nil`
138
+ while the `Integer` member narrows (`9?`);
139
+ - the literal is `nil`, `true`, or a Symbol, and a member is a user
140
+ class or `Dynamic`: the local's own `==` can match these literals
141
+ too, so the local keeps its type as it does for `9`;
142
+ - the local is unbound, no scope is available, or no member survives
143
+ the match (`nil` with `eq(9)`).
144
+
145
+ A reopening that defines only other methods, such as
146
+ `class String; def shout`, does not count: `String?` with
147
+ `eq("a")` still narrows to `"a"`. A `class Symbol; def ==` in the
148
+ project keeps the `Symbol` member and narrows the rest:
149
+ `Symbol | Integer | nil` with `eq(9)` gives `9 | Symbol`.
150
+
151
+ `not_to eq(literal)`, `to_not eq(literal)`, and their `eql` forms do
152
+ not narrow: `x` keeps the type it had before the matcher.
153
+
53
154
  ## Configuration
54
155
 
55
156
  No configuration knobs. The plugin walks every file on the
@@ -24,6 +24,20 @@ module Rigor
24
24
  def display_path(path)
25
25
  path == physical_path ? logical_path : path
26
26
  end
27
+
28
+ # The file's identity for comparing two spellings of one path: its real path, resolved through its directory
29
+ # when the file is absent from disk (a new buffer's file), so a symlinked root or parent spells it as it spells
30
+ # an existing file. Two accepted limits: a missing name is compared as written, so a name that differs only in
31
+ # case is not matched; and a `..` in a missing path is resolved textually, not through the filesystem.
32
+ def self.real_path(path)
33
+ File.realpath(path)
34
+ rescue SystemCallError
35
+ absolute = File.absolute_path(path)
36
+ parent = File.dirname(absolute)
37
+ return absolute if parent == absolute
38
+
39
+ File.join(real_path(parent), File.basename(absolute))
40
+ end
27
41
  end
28
42
  end
29
43
  end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../../inference/narrowing"
6
+ require_relative "../../environment/member_consistency"
7
+
8
+ module Rigor
9
+ module Analysis
10
+ module CheckRules
11
+ # Issue #1485 — whether a `case` clause the engine reads as exhausted by the earlier clauses (`:prior_exhaustion`
12
+ # in {UnreachableClauseCollector}) is dead whatever the subject holds. The engine's `Bot` says only that the
13
+ # earlier clauses covered every member of the subject's type, and a type Rigor inferred or read from a signature
14
+ # may be narrower than what the program holds: `STDOUT` is typed `IO`, a test passes a `StringIO`, which is not an
15
+ # `IO`, and `case io when IO … when StringIO` runs the second arm.
16
+ #
17
+ # So the verdict stands when the subject entering the `case` is made only of carriers that record what the file
18
+ # literally shows (`x = 1; case x when Integer … when String`), or when every class the clause names is at or
19
+ # below a class an earlier clause names (`when IO … when File`, `when Enumerable … when Array`, a repeated
20
+ # `when IO`): any object the clause could match then matches the earlier clause first. The ancestry is RBS's, or
21
+ # the project's own superclass and mixin edges, never past a class whose RBS ancestry Ruby contradicts
22
+ # (`Tempfile`, `< File` in RBS and `< Delegator` in Ruby). A clause that is not a class test (`in [a]`) names no
23
+ # class, so on a non-literal subject it is never proven.
24
+ #
25
+ # The proof assumes `when Base` matches every instance of a class below `Base`, as `Module#===` does. A class that
26
+ # defines its own `self.===` could break that; no such case has been observed.
27
+ class ClauseCoverage
28
+ # The carriers issue #1429 counts as literal, plus the value-pinned `nil`, `true` and `false`, whose `Nominal`
29
+ # is the single value itself.
30
+ LITERAL_CARRIERS = [Type::Constant, Type::Tuple, Type::HashShape, Type::Singleton].freeze
31
+ VALUE_PINNED_CLASSES = %w[NilClass TrueClass FalseClass].freeze
32
+
33
+ UNRELIABLE_ANCESTRY = Environment::MemberConsistency::RbsProof::UNRELIABLE_ANCESTRY
34
+
35
+ # @param scope — the `case` node's scope, which resolves the clauses' class constants.
36
+ # @param subject_type — the subject's type entering the `case`.
37
+ def initialize(scope, subject_type)
38
+ @scope = scope
39
+ @literal_subject = literal?(subject_type)
40
+ @earlier = []
41
+ end
42
+
43
+ # Adds the classes `clause` matches by class alone to those a later clause may be covered by: each class
44
+ # condition of a `when` (`when IO, nil` still matches every `IO` first), and the bare class pattern of an `in`.
45
+ def record(clause)
46
+ return if @scope.nil?
47
+
48
+ parts = clause.is_a?(Prism::WhenNode) ? clause.conditions : (pattern_alternatives(clause.pattern) || [])
49
+ @earlier.concat(parts.filter_map { |part| class_name(part) })
50
+ end
51
+
52
+ # True when the `:prior_exhaustion` verdict on `clause` may stand.
53
+ def proven?(clause)
54
+ return true if @literal_subject
55
+ return false if @scope.nil?
56
+
57
+ names = clause_class_names(clause)
58
+ return false if names.nil?
59
+
60
+ names.all? do |name|
61
+ ancestors = ancestor_names(name)
62
+ !ancestors.nil? && @earlier.any? { |earlier| ancestors.any? { |ancestor| covered_by?(ancestor, earlier) } }
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def literal?(type)
69
+ members = type.is_a?(Type::Union) ? type.members : [type]
70
+ members.all? do |member|
71
+ LITERAL_CARRIERS.any? { |carrier| member.is_a?(carrier) } ||
72
+ (member.is_a?(Type::Nominal) && VALUE_PINNED_CLASSES.include?(member.class_name))
73
+ end
74
+ end
75
+
76
+ # The class names `clause` matches by class alone, or nil when any part of it is something else: every
77
+ # condition of a `when`, and the bare class pattern of an `in` (`in C`, `in C => x`, `in A | B`).
78
+ def clause_class_names(clause)
79
+ parts = clause.is_a?(Prism::WhenNode) ? clause.conditions : pattern_alternatives(clause.pattern)
80
+ return nil if parts.nil? || parts.empty?
81
+
82
+ names = parts.map { |part| class_name(part) }
83
+ names.include?(nil) ? nil : names
84
+ end
85
+
86
+ def class_name(node)
87
+ return nil unless node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
88
+
89
+ Inference::Narrowing.lexical_class_name(node, @scope)
90
+ end
91
+
92
+ def pattern_alternatives(pattern)
93
+ case pattern
94
+ when Prism::CapturePatternNode then pattern_alternatives(pattern.value)
95
+ when Prism::AlternationPatternNode
96
+ left = pattern_alternatives(pattern.left)
97
+ right = pattern_alternatives(pattern.right)
98
+ left && right && (left + right)
99
+ when Prism::ConstantReadNode, Prism::ConstantPathNode then [pattern]
100
+ end
101
+ end
102
+
103
+ # `name` and the ancestors the project gives it: the project classes and modules on its chain, and the first
104
+ # candidate the environment knows of each ancestor the project does not declare (`< StandardError`,
105
+ # `include Enumerable`). Nil when the chain was cut, so no coverage rests on part of it.
106
+ def ancestor_names(name)
107
+ return [name] unless @scope.known_user_class?(name)
108
+
109
+ external = @scope.external_ancestor_name_candidates(name).map do |candidates|
110
+ candidates.find { |candidate| Reflection.class_known?(candidate, scope: @scope) }
111
+ end
112
+ chain = Scope::ResolutionChain.for(@scope, name, :instance, :methods)
113
+ return nil if chain.truncated?
114
+
115
+ ([name] + chain.entries.reject(&:external?).map(&:name) + external.compact).uniq
116
+ end
117
+
118
+ # True when every instance of `name` is an instance of `earlier` by the environment's class ordering, past no
119
+ # class whose RBS ancestry Ruby contradicts.
120
+ def covered_by?(name, earlier)
121
+ environment = @scope.environment
122
+ case environment.class_ordering(name, earlier)
123
+ when :equal then true
124
+ when :subclass
125
+ UNRELIABLE_ANCESTRY.none? do |unreliable|
126
+ unreliable != earlier && %i[equal subclass].include?(environment.class_ordering(name, unreliable))
127
+ end
128
+ else false
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -21,6 +21,14 @@ module Rigor
21
21
  # - the direct receiver / subject: `param.foo`, `case param`
22
22
  # - an indexed element: `param[i] - x` (the `-` receiver is the `[]` call whose root is `param`)
23
23
  # - any method chain: `param.foo.bar`, `param.foo(arg).baz`, `param.key?(:x)`
24
+ # - an array or hash literal holding the parameter (issue #2189): `[param].first.upcase`,
25
+ # `{a: param}[:a].upcase`. The engine reads such an element back precisely, so the read is the
26
+ # parameter's lower bound again; any other call on the literal declines too, the FP-safe direction.
27
+ # - an instance variable the same method wrote from such a value (issue #2189): `@x = param; @x.upcase`.
28
+ # This one IS a scope mark — {Inference::StatementEvaluator#eval_ivar_write} stamps `:inferred_param`
29
+ # under the ivar's `@`-prefixed name, which no local can spell — because the write and the read are
30
+ # separate statements. The mark is method-local: the class-ivar census that seeds a sibling method's
31
+ # ivars types the write's RHS without the inference seed, so a cross-method read is not a lower bound.
24
32
  #
25
33
  # Every value derived from an inferred parameter by dispatch is itself a lower bound, so firing against it
26
34
  # is an FP by the same construction. No-op on a normal `check` run — `Scope#inferred_param?` is false
@@ -36,17 +44,30 @@ module Rigor
36
44
  return false if node.nil? || depth > MAX_DEPTH
37
45
 
38
46
  case node
39
- when Prism::LocalVariableReadNode then scope.inferred_param?(node.name)
47
+ when Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode then scope.inferred_param?(node.name)
40
48
  when Prism::CallNode then rooted?(node.receiver, scope, depth + 1)
41
- # Value-combining forms: the produced value is a union of the operands, so it is a lower bound when
42
- # ANY operand is inferred-parameter-rooted (`cps[i] - x rescue -1`, `a && param.foo`, `param || b`).
43
- # Declining on the whole expression is FP-safe by the same lower-bound argument.
49
+ when Prism::ArrayNode, Prism::HashNode, Prism::KeywordHashNode, Prism::AssocNode
50
+ literal_rooted?(node, scope, depth + 1)
51
+ else combining_rooted?(node, scope, depth + 1)
52
+ end
53
+ end
54
+
55
+ # An array or hash literal is rooted when any element is: the engine reads the element back precisely.
56
+ def literal_rooted?(node, scope, depth)
57
+ parts = node.is_a?(Prism::AssocNode) ? [node.key, node.value] : node.elements
58
+ parts.any? { |part| rooted?(part, scope, depth) }
59
+ end
60
+
61
+ # Value-combining forms: the produced value is a union of the operands, so it is a lower bound when ANY
62
+ # operand is inferred-parameter-rooted (`cps[i] - x rescue -1`, `a && param.foo`, `param || b`). Declining
63
+ # on the whole expression is FP-safe by the same lower-bound argument.
64
+ def combining_rooted?(node, scope, depth)
65
+ case node
44
66
  when Prism::RescueModifierNode
45
- rooted?(node.expression, scope, depth + 1) || rooted?(node.rescue_expression, scope, depth + 1)
46
- when Prism::AndNode, Prism::OrNode
47
- rooted?(node.left, scope, depth + 1) || rooted?(node.right, scope, depth + 1)
48
- when Prism::ParenthesesNode then rooted?(last_statement(node.body), scope, depth + 1)
49
- when Prism::StatementsNode then rooted?(node.body.last, scope, depth + 1)
67
+ rooted?(node.expression, scope, depth) || rooted?(node.rescue_expression, scope, depth)
68
+ when Prism::AndNode, Prism::OrNode then rooted?(node.left, scope, depth) || rooted?(node.right, scope, depth)
69
+ when Prism::ParenthesesNode then rooted?(last_statement(node.body), scope, depth)
70
+ when Prism::StatementsNode then rooted?(node.body.last, scope, depth)
50
71
  else false
51
72
  end
52
73
  end