rigortype 0.4.3 → 0.4.5

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 (172) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +104 -15
  5. data/docs/manual/02-cli-reference.md +43 -12
  6. data/docs/manual/03-configuration.md +20 -7
  7. data/docs/manual/04-diagnostics.md +56 -3
  8. data/docs/manual/05-inspecting-types.md +2 -2
  9. data/docs/manual/06-baseline.md +1 -1
  10. data/docs/manual/07-plugins.md +3 -2
  11. data/docs/manual/08-skills.md +7 -0
  12. data/docs/manual/10-mcp-server.md +40 -4
  13. data/docs/manual/18-removing-dead-code.md +23 -2
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-rspec.md +101 -1
  16. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  17. data/exe/rigor +12 -17
  18. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  19. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
  20. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
  21. data/lib/rigor/analysis/check_rules/rule_ids.rb +60 -1
  22. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  23. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
  24. data/lib/rigor/analysis/check_rules.rb +274 -51
  25. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  26. data/lib/rigor/analysis/incremental.rb +61 -0
  27. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  28. data/lib/rigor/analysis/incremental_session.rb +270 -28
  29. data/lib/rigor/analysis/path_expansion.rb +3 -1
  30. data/lib/rigor/analysis/reachability/graph.rb +111 -14
  31. data/lib/rigor/analysis/reachability/scan.rb +305 -23
  32. data/lib/rigor/analysis/rule_catalog.rb +22 -4
  33. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  34. data/lib/rigor/analysis/run_cache_probe.rb +5 -3
  35. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +48 -12
  36. data/lib/rigor/analysis/runner/pool_coordinator.rb +179 -31
  37. data/lib/rigor/analysis/runner/project_pre_passes.rb +29 -4
  38. data/lib/rigor/analysis/runner.rb +102 -72
  39. data/lib/rigor/analysis/severity_stamp.rb +45 -1
  40. data/lib/rigor/analysis/worker_session.rb +69 -2
  41. data/lib/rigor/cache/descriptor.rb +79 -12
  42. data/lib/rigor/cache/engine_source.rb +3 -2
  43. data/lib/rigor/cache/incremental_snapshot.rb +39 -5
  44. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  45. data/lib/rigor/cache/store.rb +4 -3
  46. data/lib/rigor/cli/command.rb +3 -1
  47. data/lib/rigor/cli/docs_command.rb +3 -2
  48. data/lib/rigor/cli/doctor_command.rb +1 -1
  49. data/lib/rigor/cli/explain_command.rb +3 -2
  50. data/lib/rigor/cli/mcp_command.rb +1 -1
  51. data/lib/rigor/cli/plugin_command.rb +2 -1
  52. data/lib/rigor/cli/plugins_command.rb +2 -1
  53. data/lib/rigor/cli/sig_gen_command.rb +32 -11
  54. data/lib/rigor/cli/skill_command.rb +2 -1
  55. data/lib/rigor/cli/skill_describe.rb +3 -2
  56. data/lib/rigor/cli/unused_command.rb +10 -7
  57. data/lib/rigor/cli.rb +3 -2
  58. data/lib/rigor/config_audit.rb +163 -22
  59. data/lib/rigor/configuration/dependencies.rb +19 -11
  60. data/lib/rigor/configuration/severity_profile.rb +20 -2
  61. data/lib/rigor/configuration.rb +29 -3
  62. data/lib/rigor/effects/signature_sources.rb +3 -1
  63. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  64. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  65. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  66. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  67. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  68. data/lib/rigor/environment/rbs_loader.rb +67 -11
  69. data/lib/rigor/inference/block_call_timing.rb +12 -1
  70. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  71. data/lib/rigor/inference/captured_locals.rb +67 -13
  72. data/lib/rigor/inference/clause_widening.rb +90 -0
  73. data/lib/rigor/inference/definer_resolution.rb +101 -25
  74. data/lib/rigor/inference/expression_typer.rb +94 -9
  75. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  76. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  77. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  78. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  79. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
  80. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  81. data/lib/rigor/inference/method_dispatcher.rb +67 -3
  82. data/lib/rigor/inference/module_function_state.rb +213 -51
  83. data/lib/rigor/inference/narrowing.rb +235 -75
  84. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  85. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  86. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  87. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  88. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  89. data/lib/rigor/inference/project_seed_tables.rb +120 -0
  90. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  91. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  92. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  93. data/lib/rigor/inference/repeated_or_writes.rb +414 -1
  94. data/lib/rigor/inference/return_barrier.rb +16 -0
  95. data/lib/rigor/inference/scope_indexer.rb +1061 -122
  96. data/lib/rigor/inference/self_call_ivar_writes.rb +346 -0
  97. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  98. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  99. data/lib/rigor/inference/statement_evaluator.rb +512 -150
  100. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  101. data/lib/rigor/lens/anchor.rb +3 -3
  102. data/lib/rigor/lens/contract_reader.rb +60 -2
  103. data/lib/rigor/lens/document.rb +4 -3
  104. data/lib/rigor/lens/file_reader.rb +452 -73
  105. data/lib/rigor/lens/members.rb +81 -17
  106. data/lib/rigor/lens/run.rb +73 -4
  107. data/lib/rigor/lens.rb +2 -1
  108. data/lib/rigor/mcp/server.rb +63 -22
  109. data/lib/rigor/plugin/base.rb +3 -2
  110. data/lib/rigor/plugin/box_probe.rb +154 -7
  111. data/lib/rigor/plugin/io_boundary.rb +42 -6
  112. data/lib/rigor/plugin/isolation.rb +4 -3
  113. data/lib/rigor/project_environment.rb +6 -3
  114. data/lib/rigor/protection/discovery_seed.rb +9 -36
  115. data/lib/rigor/protection/mutation_cache.rb +8 -2
  116. data/lib/rigor/rooted_glob.rb +27 -0
  117. data/lib/rigor/scope/discovery_index.rb +5 -0
  118. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  119. data/lib/rigor/scope/resolution_chain.rb +40 -5
  120. data/lib/rigor/scope.rb +56 -8
  121. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  122. data/lib/rigor/sig_gen/classification.rb +4 -1
  123. data/lib/rigor/sig_gen/generator.rb +167 -22
  124. data/lib/rigor/sig_gen/layout_index.rb +40 -1
  125. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  126. data/lib/rigor/sig_gen/observation_collector.rb +137 -11
  127. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  128. data/lib/rigor/sig_gen/renderer.rb +13 -155
  129. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  130. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  131. data/lib/rigor/sig_gen/write_result.rb +3 -1
  132. data/lib/rigor/sig_gen/writer.rb +50 -35
  133. data/lib/rigor/signature_path_audit.rb +4 -2
  134. data/lib/rigor/type/constant.rb +10 -1
  135. data/lib/rigor/version.rb +1 -1
  136. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  137. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +1 -1
  138. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  139. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  140. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  141. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  142. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  143. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  144. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  145. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  146. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  147. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  148. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
  149. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  150. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  151. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +57 -6
  152. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  153. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  154. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  155. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  156. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  157. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  158. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  159. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  160. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  161. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  162. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  164. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  166. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  167. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  168. data/sig/rigor/rooted_glob.rbs +7 -0
  169. data/sig/rigor/scope.rbs +4 -3
  170. data/skills/rigor-doctor/references/01-checks.md +23 -11
  171. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  172. metadata +16 -1
@@ -33,6 +33,7 @@ There is no conflict in running both simultaneously.
33
33
  | `rigor_check` | `rigor check --format json` | JSON diagnostic report |
34
34
  | `rigor_type_of` | `rigor type-of --format json` | JSON type at `FILE:LINE:COL` |
35
35
  | `rigor_triage` | `rigor triage --format json` | JSON distribution + class/method selectors + hotspots + hints |
36
+ | `rigor_lens` | `rigor lens --format json` | JSON `lens/v0` declaration map (experimental) |
36
37
  | `rigor_annotate` | `rigor annotate --no-color` | Annotated Ruby source |
37
38
  | `rigor_sig_gen` | `rigor sig-gen --print --format json` | JSON RBS skeleton candidates |
38
39
  | `rigor_explain` | `rigor explain --format json` | JSON rule catalog entries |
@@ -320,6 +321,34 @@ by `count` for the highest-leverage class/method, or filter by
320
321
 
321
322
  ---
322
323
 
324
+ ### rigor_lens
325
+
326
+ Map the classes, modules, methods and attributes the given files declare,
327
+ with the type `rigor check` gives each one and where it came from. The
328
+ document is the experimental `lens/v0` format
329
+ ([specification](../internal-spec/lens.md)); see
330
+ [Inspecting inferred types](05-inspecting-types.md) for the command.
331
+
332
+ **Input:**
333
+
334
+ | Argument | Type | Required |
335
+ |---|---|---|
336
+ | `files` | `string[]` | yes |
337
+ | `config` | `string` | no |
338
+
339
+ **Returns:** JSON. Each file carries its `status`, `sites`, `omissions`
340
+ and `diagnostics`; `members` holds the signatures.
341
+
342
+ **Typical agent use:** learn what a file or class offers, and the
343
+ contracts its methods already have, before editing it or writing a type.
344
+ Pass every file in one call: each call runs one analysis, and the first
345
+ call on a project costs a full `rigor check`. Paths, site keys and the cache are
346
+ relative to the directory the server was started in, so start it from the
347
+ project root; `--config` fixes which files belong to the project, not how
348
+ paths are written.
349
+
350
+ ---
351
+
323
352
  ### rigor_annotate
324
353
 
325
354
  Return the given Ruby source file with each line's last-expression
@@ -362,6 +391,13 @@ Generate RBS skeleton signatures inferred from Ruby source files.
362
391
  project's test roots: the configured `test_paths:`, or whichever of
363
392
  `spec/` and `test/` exist.
364
393
 
394
+ Under `params: "observed"` the response also carries a top-level
395
+ `observation` object, `{"paths": [...], "missing": [...]}`: the test
396
+ roots that were read and the declared ones that do not exist or that
397
+ the collector would not read (neither a directory nor a `.rb` file). Both
398
+ empty means no test root was found, so every parameter stays `untyped`.
399
+ The key is absent under the default `untyped` policy.
400
+
365
401
  **Returns:** JSON — the same as `rigor sig-gen --print --format json`.
366
402
 
367
403
  ```json
@@ -401,7 +437,7 @@ Look up the description of one or all Rigor diagnostic rules.
401
437
 
402
438
  Accepted values: a canonical rule ID (`call.undefined-method`), a
403
439
  legacy alias (`undefined-method`), or a family prefix (`call`, `flow`,
404
- `assert`, `dump`, `def`).
440
+ `def`, `assert`, `dump`, `global`, `suppression`, `static`, `effect`).
405
441
 
406
442
  **Returns:** JSON — an array of rule catalog entries.
407
443
 
@@ -482,7 +518,7 @@ You should get a JSON response. If you get nothing or a shell error,
482
518
  `rigor` is not on your `PATH` — fix the install path first (see
483
519
  [Installing Rigor](01-installation.md)).
484
520
 
485
- **`rigor_check` / `rigor_triage` return an empty diagnostics array on a project that has errors.**
521
+ **`rigor_check` / `rigor_triage` return an empty diagnostics array on a project that has errors, or `rigor_lens` reports every file `outside_project`.**
486
522
 
487
523
  The tool uses `Configuration.discover` from the working directory the
488
524
  MCP server was started in. If the client launches `rigor mcp` from a
@@ -517,8 +553,8 @@ warmed by a prior `rigor check` run reduces it significantly.
517
553
 
518
554
  ## Status and roadmap
519
555
 
520
- `rigor mcp` shipped in v0.1.10 with all seven tools and stdio
521
- transport. Queued follow-ups are demand-driven:
556
+ `rigor mcp` shipped in v0.1.10 with seven tools and stdio
557
+ transport; `rigor_lens` joined them in v0.4.4. Queued follow-ups are demand-driven:
522
558
 
523
559
  - **HTTP transport** (slice 2) — `--transport=http` for CI / remote
524
560
  agent use; a minimal Rack endpoint.
@@ -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
@@ -265,7 +267,26 @@ under them are. `const_get` looks the name up from its receiver, so
265
267
  and mixins and at the top level as well, unless the call passes `false`
266
268
  as its second argument. A literal `const_get("Handler")` is a reference
267
269
  looked up in the same order, so it names `Migration::Handler` when
268
- that exists rather than a top-level `Handler`. A name built into a
270
+ that exists rather than a top-level `Handler`. When the receiver is
271
+ implicit, `self` or `self.class` inside a method (or a block in a class
272
+ body, such as a lambda or `define_method`), the call may run on a
273
+ subclass or on an includer, so a `Handler` declared in a descendant is
274
+ kept reachable too. Descendants are the classes written `class D < Base`
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
269
290
  local variable first (`name = "V#{version}"; const_get(name)`) is read
270
291
  the same way. A
271
292
  class name appearing as a string in a `.yml`, `.json` or template file
@@ -127,8 +127,8 @@ routed. Helper-name recognition for all three is unaffected.
127
127
  metaprogramming the parser can't unfold (routes built in a loop
128
128
  over runtime data, helpers injected by an engine the parser
129
129
  doesn't model) may not register, which can surface a false
130
- `unknown-helper`. Record those in a baseline, or
131
- `# rigor:disable` the line.
130
+ `unknown-helper`. Record those in a baseline, or add
131
+ `# rigor:disable plugin.rails-routes.unknown-helper` to the line.
132
132
  - **The "routes file did not load" warning is run-scoped.** It is a
133
133
  fact about your configuration, not about any one source file, so
134
134
  it is reported once per run on `.rigor.yml` rather than on the
@@ -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,104 @@ 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/)` | narrow to `String` |
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
+
81
+ Other matchers emit nothing. `be_truthy` is not narrowed, nor is
82
+ `expect { ... }.to raise_error(...)` (the block form), nor an
83
+ `expect(foo.bar)` whose argument is not a local.
84
+
85
+ ### Equality is a meet, not a replacement
86
+
87
+ `eq(literal)` passes when `x == literal`, and `eql(literal)` when
88
+ `x.eql?(literal)`. The local is the receiver, so `x`'s own operator
89
+ decides, and that operator can hold across classes: `1 == 1.0`, and a
90
+ class with its own `==` (a `ModInt` that compares `to_i`) passes
91
+ `expect(m).to eq(9)`. The matcher therefore never replaces `x`'s
92
+ type. It narrows each member of `x`'s type on its own.
93
+
94
+ The core equality classes are the nine in `EQUALITY_CORE_CLASSES`
95
+ (`plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb`):
96
+ `Integer`, `Float`, `Rational`, `Complex`, `String`, `Symbol`,
97
+ `NilClass`, `TrueClass`, and `FalseClass`. The first four are the
98
+ numeric classes.
99
+
100
+ - a member of the literal's own class narrows to the literal,
101
+ refinements of that class included. After `x = rand(10)`,
102
+ `expect(x).to eq(9)` gives `9`; `Integer?` with `eq(9)` gives `9`,
103
+ because the `nil` member cannot equal `9`. A refinement such as
104
+ `decimal-int-string?` (from `y = x && x.to_s` with `x` an
105
+ `Integer?`) gives `"3"` under `eq("3")`. A `Constant` member is kept
106
+ when it equals the literal and dropped otherwise;
107
+ - a `Float` member meets `0.0` and is kept, since `-0.0` also equals
108
+ it;
109
+ - a numeric member of another class (`Integer`, `Float`, `Rational`,
110
+ `Complex`) is kept under `eq`: `Integer | Float` with `eq(1)` gives
111
+ `1 | Float`;
112
+ - any other member of a core equality class is dropped:
113
+ `String | Integer` with `eq(9)` gives `9`;
114
+ - `eql(literal)` takes the same path, except that a numeric member of
115
+ another class is dropped, because `eql?` compares classes:
116
+ `Integer | Float` with `eql(1)` gives `1`.
117
+
118
+ ### A member, or the whole local, keeps its type in these cases
119
+
120
+ A member outside the core equality classes makes the whole fact
121
+ unavailable, so the local keeps its type, not just that member:
122
+
123
+ - a member is `Dynamic`, or of a class outside `EQUALITY_CORE_CLASSES`,
124
+ such as a user class (`ModInt`), `Numeric`, or a generic `Array` or
125
+ `Hash`. Under `eq(9)` the whole local stays unchanged, so `9 | [1]`
126
+ and `9 | { a: 1 }` do not narrow;
127
+ - the project defines the operator (`==` for `eq`, `eql?` for `eql`)
128
+ on the literal's class, in any analysed file. A `def` or a
129
+ `define_method` both count. A core `==` can call back into the
130
+ literal's own `==` (`Integer#==` for a non-numeric argument,
131
+ `String#==` for one that responds to `to_str`), so the plugin does
132
+ not narrow there even when a kept member would survive;
133
+ - the project reopens a core ancestor of the member's class and
134
+ defines the operator there, such as `class Numeric; def eql?` or
135
+ `class Object; def ==`. Under `eql(9)` an `Integer` stays
136
+ `Integer`; under `eq(9)` the `nil` member of `Integer?` stays `nil`
137
+ while the `Integer` member narrows (`9?`);
138
+ - the literal is `nil`, `true`, or a Symbol, and a member is a user
139
+ class or `Dynamic`: the local's own `==` can match these literals
140
+ too, so the local keeps its type as it does for `9`;
141
+ - the local is unbound, no scope is available, or no member survives
142
+ the match (`nil` with `eq(9)`).
143
+
144
+ A reopening that defines only other methods, such as
145
+ `class String; def shout`, does not count: `String?` with
146
+ `eq("a")` still narrows to `"a"`. A `class Symbol; def ==` in the
147
+ project keeps the `Symbol` member and narrows the rest:
148
+ `Symbol | Integer | nil` with `eq(9)` gives `9 | Symbol`.
149
+
150
+ `not_to eq(literal)`, `to_not eq(literal)`, and their `eql` forms do
151
+ not narrow: `x` keeps the type it had before the matcher.
152
+
53
153
  ## Configuration
54
154
 
55
155
  No configuration knobs. The plugin walks every file on the
@@ -48,7 +48,10 @@ expected kind: `belong_to` / `have_one` (singular), `have_many` /
48
48
 
49
49
  Suppress with the qualified rule, e.g.
50
50
  `# rigor:disable plugin.shoulda-matchers.unknown-column`, or silence
51
- the whole family with `# rigor:disable plugin.shoulda-matchers`.
51
+ the whole family with `# rigor:disable plugin.shoulda-matchers`. The
52
+ same spellings work project-wide in `.rigor.yml`'s `disable:`. The
53
+ bare rule (`# rigor:disable unknown-column`) does not reach these
54
+ diagnostics.
52
55
 
53
56
  ## No configuration
54
57
 
data/exe/rigor CHANGED
@@ -6,8 +6,7 @@
6
6
  # (`RUBY_BOX=1`, not toggleable at runtime). When a project selects that
7
7
  # strategy (`RIGOR_PLUGIN_ISOLATION=ruby_box`, or the legacy `RIGOR_BOX`
8
8
  # alias) we re-exec the same command with the flag set before anything
9
- # else loads. The `RUBY_BOX` guard prevents an infinite re-exec, and the
10
- # inherited environment (RUBYOPT / BUNDLE_*) preserves the Bundler
9
+ # else loads. The inherited environment (RUBYOPT / BUNDLE_*) preserves the Bundler
11
10
  # context across the exec. The `none` and `process` (default) strategies
12
11
  # need no flag, so this is a no-op for them — behaviour is unchanged
13
12
  # unless a project opts into `ruby_box`.
@@ -17,22 +16,18 @@
17
16
  # Rubies through 4.0.7 segfault on Ruby Bug #22260). When it cannot,
18
17
  # the request is dropped with a warning and the run proceeds under the
19
18
  # configured strategy (`process` by default) instead of crashing.
20
- rigor_isolation = ENV["RIGOR_PLUGIN_ISOLATION"].to_s
21
- rigor_wants_box = rigor_isolation == "ruby_box" || !ENV["RIGOR_BOX"].to_s.empty?
22
- if rigor_wants_box && ENV["RUBY_BOX"].to_s.empty?
19
+ #
20
+ # When `RUBY_BOX` is already set (by hand or by a wrapper), this process
21
+ # booted inside the box before any Rigor code ran, so the same probe
22
+ # runs here too, and a refused Ruby is re-exec'ed with `RUBY_BOX`
23
+ # removed. The probe's verdict is cached per Ruby build, and the
24
+ # re-exec is marked so the child neither probes nor re-execs again.
25
+ # Nothing above `require "rigor/cli"` may define a Ractor-shareable
26
+ # lambda: that is what crashes inside the box.
27
+ if !ENV["RUBY_BOX"].to_s.empty? || ENV["RIGOR_PLUGIN_ISOLATION"].to_s == "ruby_box" ||
28
+ !ENV["RIGOR_BOX"].to_s.empty? || ENV.key?("RIGOR_BOX_PROBED")
23
29
  require_relative "../lib/rigor/plugin/box_probe"
24
- if (rigor_box_unsupported = Rigor::Plugin::BoxProbe.unsupported_reason)
25
- rigor_box_warning = "rigor: ruby_box plugin isolation is unavailable: #{rigor_box_unsupported}. " \
26
- "Falling back to the configured plugins_isolation strategy (default: process)."
27
- # `$stderr.puts`, not `warn`: `-W0` silences `warn`, and this fallback must never be silent.
28
- $stderr.puts rigor_box_warning # rubocop:disable Style/StderrPuts
29
- ENV.delete("RIGOR_PLUGIN_ISOLATION") if rigor_isolation == "ruby_box"
30
- ENV.delete("RIGOR_BOX")
31
- else
32
- ENV["RUBY_BOX"] = "1"
33
- ENV["RIGOR_PLUGIN_ISOLATION"] = "ruby_box" if rigor_isolation.empty?
34
- exec(RbConfig.ruby, __FILE__, *ARGV)
35
- end
30
+ exec(RbConfig.ruby, __FILE__, *ARGV) if Rigor::Plugin::BoxProbe.needs_reexec?(ENV)
36
31
  end
37
32
 
38
33
  lib = File.expand_path("../lib", __dir__)
@@ -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
@@ -17,6 +17,13 @@ module Rigor
17
17
  # - **Singleton defs on locals.** `def o.m` defines `m` on the one object `o` holds. The local's type
18
18
  # is not changed; `o.m` is simply not reported within the scope the `def` is written in (the
19
19
  # enclosing `def`, `class` / `module` body, or file — a block shares its enclosing scope's locals).
20
+ # Issue #1722 — the other spellings that give that one object methods make the local opaque for every
21
+ # name in the same scope: `o.extend(M)`, `o.define_singleton_method(...)` (only the literal name it
22
+ # defines, when it names one), any call on `o.singleton_class` (`class_eval { def m }`, `define_method`,
23
+ # `include`), `class << o`, and an `o.instance_eval` / `o.instance_exec` that is given a String or whose
24
+ # block writes a `def` or calls `extend` / `define_singleton_method`. Where in the scope the call sits,
25
+ # and whether it runs, are not read: a call under an `if` may run. A local assigned `o.clone` shares
26
+ # what `o` has (`clone` copies the singleton class, `dup` does not).
20
27
  #
21
28
  # Built lazily: the walk runs the first time a would-fire call asks, so a file with no such call pays
22
29
  # one small object and nothing else.
@@ -34,22 +41,45 @@ module Rigor
34
41
 
35
42
  def refinements = (@refinements ||= Inference::InEffectRefinements.new(@root))
36
43
 
37
- # Does a `def <local>.<name>` for this call's local receiver and method name sit in the scope the call
38
- # is in?
44
+ # Does a `def <local>.<name>` for this call's local receiver and method name — or a call that gives the
45
+ # local's object methods Rigor does not enumerate (issue #1722) — sit in the scope the call is in?
39
46
  def singleton_local_def?(call_node)
40
47
  receiver = call_node.receiver
41
48
  return false unless receiver.is_a?(Prism::LocalVariableReadNode)
42
49
 
43
50
  build
44
- offset = call_node.location.start_offset
45
- @singleton_defs.any? do |local, method_name, start, stop|
46
- local == receiver.name && method_name == call_node.name && offset >= start && offset < stop &&
47
- !nested_scope_holds?(offset, start, stop)
48
- end
51
+ singleton_local_holds?(receiver.name, call_node.name, call_node.location.start_offset, CLONE_DEPTH)
49
52
  end
50
53
 
54
+ # The name an opening records when it may define any method on the local's object.
55
+ ANY_METHOD = Object.new.freeze
56
+ # How many `t = s.clone` links a lookup follows.
57
+ CLONE_DEPTH = 4
58
+ EVAL_CALLS = %i[instance_eval instance_exec].freeze
59
+ EVAL_BLOCK_CALLS = %i[extend define_singleton_method].freeze
60
+ private_constant :ANY_METHOD, :CLONE_DEPTH, :EVAL_CALLS, :EVAL_BLOCK_CALLS
61
+
51
62
  private
52
63
 
64
+ def singleton_local_holds?(local_name, method_name, offset, depth)
65
+ opened = in_scope?(@singleton_defs, offset) do |local, name|
66
+ local == local_name && (name.equal?(ANY_METHOD) || name == method_name)
67
+ end
68
+ return true if opened
69
+ return false if depth.zero?
70
+
71
+ @clones.any? do |target, source, start, stop|
72
+ target == local_name && offset >= start && offset < stop && !nested_scope_holds?(offset, start, stop) &&
73
+ singleton_local_holds?(source, method_name, offset, depth - 1)
74
+ end
75
+ end
76
+
77
+ def in_scope?(rows, offset)
78
+ rows.any? do |local, name, start, stop|
79
+ yield(local, name) && offset >= start && offset < stop && !nested_scope_holds?(offset, start, stop)
80
+ end
81
+ end
82
+
53
83
  # Is `offset` inside a local scope nested in the one `[start, stop)` spans? A `def` in the file's top level
54
84
  # does not see the top level's `o`.
55
85
  def nested_scope_holds?(offset, start, stop)
@@ -64,6 +94,7 @@ module Rigor
64
94
 
65
95
  @built = true
66
96
  @singleton_defs = []
97
+ @clones = []
67
98
  @scopes = []
68
99
  return if @root.nil?
69
100
 
@@ -78,11 +109,16 @@ module Rigor
78
109
  when Prism::ClassNode, Prism::ModuleNode
79
110
  return walk_children(node.body, scope_span(node))
80
111
  when Prism::SingletonClassNode
112
+ record_opened_local(node.expression, ANY_METHOD, locals)
81
113
  walk(node.expression, locals)
82
114
  return walk_children(node.body, scope_span(node))
83
115
  when Prism::DefNode
84
116
  record_singleton_def(node, locals)
85
117
  return walk_children(node.body, scope_span(node))
118
+ when Prism::CallNode
119
+ record_singleton_opening(node, locals)
120
+ when Prism::LocalVariableWriteNode
121
+ record_clone(node, locals)
86
122
  end
87
123
 
88
124
  walk_children(node, locals)
@@ -101,6 +137,74 @@ module Rigor
101
137
  @singleton_defs << [receiver.name, node.name, *locals]
102
138
  end
103
139
 
140
+ # Issue #1722 — a call that gives the object its receiver local holds singleton methods.
141
+ def record_singleton_opening(node, locals)
142
+ receiver = node.receiver
143
+ if singleton_class_of_local?(receiver)
144
+ record_opened_local(receiver.receiver, ANY_METHOD, locals)
145
+ elsif receiver.is_a?(Prism::LocalVariableReadNode)
146
+ name = opened_method_name(node)
147
+ record_opened_local(receiver, name, locals) if name
148
+ end
149
+ end
150
+
151
+ # `o.singleton_class`.
152
+ def singleton_class_of_local?(node)
153
+ node.is_a?(Prism::CallNode) && node.name == :singleton_class && node.arguments.nil? &&
154
+ node.receiver.is_a?(Prism::LocalVariableReadNode)
155
+ end
156
+
157
+ # The method name a call on a local defines on the local's object: the literal name given to
158
+ # `define_singleton_method`, {ANY_METHOD} where it may be any name, or nil when the call defines none.
159
+ def opened_method_name(node)
160
+ case node.name
161
+ when :extend then ANY_METHOD
162
+ when :define_singleton_method then literal_name(node.arguments&.arguments&.first) || ANY_METHOD
163
+ when *EVAL_CALLS then ANY_METHOD if eval_defines?(node)
164
+ end
165
+ end
166
+
167
+ def literal_name(node)
168
+ node.unescaped.to_sym if node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
169
+ end
170
+
171
+ # An `instance_eval` given code as a String, or whose block writes a `def` or calls `extend` /
172
+ # `define_singleton_method` on its `self`.
173
+ def eval_defines?(node)
174
+ return true unless node.arguments.nil?
175
+
176
+ block = node.block
177
+ block.is_a?(Prism::BlockNode) && defines_on_self?(block.body)
178
+ end
179
+
180
+ def defines_on_self?(node)
181
+ return false if node.nil?
182
+ return true if node.is_a?(Prism::DefNode)
183
+ if node.is_a?(Prism::CallNode) && EVAL_BLOCK_CALLS.include?(node.name) &&
184
+ (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
185
+ return true
186
+ end
187
+
188
+ found = false
189
+ node.rigor_each_child { |child| found ||= defines_on_self?(child) }
190
+ found
191
+ end
192
+
193
+ def record_opened_local(node, method_name, locals)
194
+ return unless node.is_a?(Prism::LocalVariableReadNode)
195
+
196
+ @singleton_defs << [node.name, method_name, *locals]
197
+ end
198
+
199
+ # `t = s.clone`: `clone` copies the singleton class, so `t` holds what `s` was given.
200
+ def record_clone(node, locals)
201
+ value = node.value
202
+ return unless value.is_a?(Prism::CallNode) && value.name == :clone
203
+ return unless value.receiver.is_a?(Prism::LocalVariableReadNode)
204
+
205
+ @clones << [node.name, value.receiver.name, *locals]
206
+ end
207
+
104
208
  # The span of a node that opens a local scope, recorded for {#nested_scope_holds?}.
105
209
  def scope_span(node)
106
210
  span = span_of(node)