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.
- checksums.yaml +4 -4
- data/README.md +5 -5
- data/data/core_overlay/ostruct.rbs +40 -0
- data/docs/handbook/11-sig-gen.md +104 -15
- data/docs/manual/02-cli-reference.md +43 -12
- data/docs/manual/03-configuration.md +20 -7
- data/docs/manual/04-diagnostics.md +56 -3
- data/docs/manual/05-inspecting-types.md +2 -2
- data/docs/manual/06-baseline.md +1 -1
- data/docs/manual/07-plugins.md +3 -2
- data/docs/manual/08-skills.md +7 -0
- data/docs/manual/10-mcp-server.md +40 -4
- data/docs/manual/18-removing-dead-code.md +23 -2
- data/docs/manual/plugins/rigor-rails-routes.md +2 -2
- data/docs/manual/plugins/rigor-rspec.md +101 -1
- data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
- data/exe/rigor +12 -17
- data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
- data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
- data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +60 -1
- data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
- data/lib/rigor/analysis/check_rules.rb +274 -51
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
- data/lib/rigor/analysis/incremental.rb +61 -0
- data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
- data/lib/rigor/analysis/incremental_session.rb +270 -28
- data/lib/rigor/analysis/path_expansion.rb +3 -1
- data/lib/rigor/analysis/reachability/graph.rb +111 -14
- data/lib/rigor/analysis/reachability/scan.rb +305 -23
- data/lib/rigor/analysis/rule_catalog.rb +22 -4
- data/lib/rigor/analysis/run_cache_key.rb +31 -7
- data/lib/rigor/analysis/run_cache_probe.rb +5 -3
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +48 -12
- data/lib/rigor/analysis/runner/pool_coordinator.rb +179 -31
- data/lib/rigor/analysis/runner/project_pre_passes.rb +29 -4
- data/lib/rigor/analysis/runner.rb +102 -72
- data/lib/rigor/analysis/severity_stamp.rb +45 -1
- data/lib/rigor/analysis/worker_session.rb +69 -2
- data/lib/rigor/cache/descriptor.rb +79 -12
- data/lib/rigor/cache/engine_source.rb +3 -2
- data/lib/rigor/cache/incremental_snapshot.rb +39 -5
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cache/store.rb +4 -3
- data/lib/rigor/cli/command.rb +3 -1
- data/lib/rigor/cli/docs_command.rb +3 -2
- data/lib/rigor/cli/doctor_command.rb +1 -1
- data/lib/rigor/cli/explain_command.rb +3 -2
- data/lib/rigor/cli/mcp_command.rb +1 -1
- data/lib/rigor/cli/plugin_command.rb +2 -1
- data/lib/rigor/cli/plugins_command.rb +2 -1
- data/lib/rigor/cli/sig_gen_command.rb +32 -11
- data/lib/rigor/cli/skill_command.rb +2 -1
- data/lib/rigor/cli/skill_describe.rb +3 -2
- data/lib/rigor/cli/unused_command.rb +10 -7
- data/lib/rigor/cli.rb +3 -2
- data/lib/rigor/config_audit.rb +163 -22
- data/lib/rigor/configuration/dependencies.rb +19 -11
- data/lib/rigor/configuration/severity_profile.rb +20 -2
- data/lib/rigor/configuration.rb +29 -3
- data/lib/rigor/effects/signature_sources.rb +3 -1
- data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
- data/lib/rigor/environment/gated_signature_guard.rb +2 -1
- data/lib/rigor/environment/gated_signature_plan.rb +2 -1
- data/lib/rigor/environment/installed_gem_set.rb +2 -1
- data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
- data/lib/rigor/environment/rbs_loader.rb +67 -11
- data/lib/rigor/inference/block_call_timing.rb +12 -1
- data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
- data/lib/rigor/inference/captured_locals.rb +67 -13
- data/lib/rigor/inference/clause_widening.rb +90 -0
- data/lib/rigor/inference/definer_resolution.rb +101 -25
- data/lib/rigor/inference/expression_typer.rb +94 -9
- data/lib/rigor/inference/guard_rebinding.rb +7 -2
- data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
- data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
- data/lib/rigor/inference/macro_block_self_type.rb +20 -6
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
- data/lib/rigor/inference/method_dispatcher.rb +67 -3
- data/lib/rigor/inference/module_function_state.rb +213 -51
- data/lib/rigor/inference/narrowing.rb +235 -75
- data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
- data/lib/rigor/inference/project_patched_methods.rb +168 -4
- data/lib/rigor/inference/project_patched_mixins.rb +162 -0
- data/lib/rigor/inference/project_patched_scanner.rb +51 -12
- data/lib/rigor/inference/project_patched_visibility.rb +214 -0
- data/lib/rigor/inference/project_seed_tables.rb +120 -0
- data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
- data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
- data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
- data/lib/rigor/inference/repeated_or_writes.rb +414 -1
- data/lib/rigor/inference/return_barrier.rb +16 -0
- data/lib/rigor/inference/scope_indexer.rb +1061 -122
- data/lib/rigor/inference/self_call_ivar_writes.rb +346 -0
- data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
- data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
- data/lib/rigor/inference/statement_evaluator.rb +512 -150
- data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
- data/lib/rigor/lens/anchor.rb +3 -3
- data/lib/rigor/lens/contract_reader.rb +60 -2
- data/lib/rigor/lens/document.rb +4 -3
- data/lib/rigor/lens/file_reader.rb +452 -73
- data/lib/rigor/lens/members.rb +81 -17
- data/lib/rigor/lens/run.rb +73 -4
- data/lib/rigor/lens.rb +2 -1
- data/lib/rigor/mcp/server.rb +63 -22
- data/lib/rigor/plugin/base.rb +3 -2
- data/lib/rigor/plugin/box_probe.rb +154 -7
- data/lib/rigor/plugin/io_boundary.rb +42 -6
- data/lib/rigor/plugin/isolation.rb +4 -3
- data/lib/rigor/project_environment.rb +6 -3
- data/lib/rigor/protection/discovery_seed.rb +9 -36
- data/lib/rigor/protection/mutation_cache.rb +8 -2
- data/lib/rigor/rooted_glob.rb +27 -0
- data/lib/rigor/scope/discovery_index.rb +5 -0
- data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
- data/lib/rigor/scope/resolution_chain.rb +40 -5
- data/lib/rigor/scope.rb +56 -8
- data/lib/rigor/sig_gen/alias_index.rb +2 -1
- data/lib/rigor/sig_gen/classification.rb +4 -1
- data/lib/rigor/sig_gen/generator.rb +167 -22
- data/lib/rigor/sig_gen/layout_index.rb +40 -1
- data/lib/rigor/sig_gen/method_candidate.rb +8 -0
- data/lib/rigor/sig_gen/observation_collector.rb +137 -11
- data/lib/rigor/sig_gen/path_mapper.rb +18 -2
- data/lib/rigor/sig_gen/renderer.rb +13 -155
- data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
- data/lib/rigor/sig_gen/write_reporter.rb +203 -0
- data/lib/rigor/sig_gen/write_result.rb +3 -1
- data/lib/rigor/sig_gen/writer.rb +50 -35
- data/lib/rigor/signature_path_audit.rb +4 -2
- data/lib/rigor/type/constant.rb +10 -1
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +1 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
- data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +57 -6
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
- data/sig/rigor/plugin/io_boundary.rbs +5 -2
- data/sig/rigor/rooted_glob.rbs +7 -0
- data/sig/rigor/scope.rbs +4 -3
- data/skills/rigor-doctor/references/01-checks.md +23 -11
- data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
- 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`, `
|
|
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
|
|
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`.
|
|
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.
|
|
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
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|