rigortype 0.3.1 → 0.3.3
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 +10 -10
- data/data/core_overlay/pathname.rbs +5 -0
- data/data/core_overlay/resolv.rbs +31 -0
- data/data/core_overlay/string_scanner.rbs +5 -0
- data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
- data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
- data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
- data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
- data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
- data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
- data/docs/manual/02-cli-reference.md +71 -11
- data/docs/manual/03-configuration.md +16 -1
- data/docs/manual/04-diagnostics.md +8 -0
- data/docs/manual/12-caching.md +17 -5
- data/docs/manual/15-type-protection-coverage.md +122 -1
- data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
- data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
- data/lib/rigor/analysis/check_rules.rb +107 -28
- data/lib/rigor/analysis/incremental_session.rb +156 -11
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
- data/lib/rigor/analysis/rule_catalog.rb +5 -1
- data/lib/rigor/analysis/run_cache_key.rb +17 -1
- data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
- data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
- data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
- data/lib/rigor/analysis/runner.rb +78 -7
- data/lib/rigor/analysis/severity_stamp.rb +2 -1
- data/lib/rigor/bleeding_edge.rb +132 -7
- data/lib/rigor/cache/descriptor.rb +6 -1
- data/lib/rigor/cache/engine_source.rb +162 -0
- data/lib/rigor/cache/incremental_snapshot.rb +87 -18
- data/lib/rigor/cli/check_command.rb +49 -28
- data/lib/rigor/cli/coverage_command.rb +15 -2
- data/lib/rigor/cli/coverage_mutation.rb +242 -11
- data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
- data/lib/rigor/cli/fused_protection_report.rb +12 -3
- data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
- data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
- data/lib/rigor/cli/mutation_protection_report.rb +26 -5
- data/lib/rigor/cli/protection_renderer.rb +13 -0
- data/lib/rigor/cli/protection_report.rb +11 -3
- data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
- data/lib/rigor/cli/sig_gen_command.rb +5 -4
- data/lib/rigor/configuration.rb +45 -6
- data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
- data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
- data/lib/rigor/environment/rbs_loader.rb +434 -75
- data/lib/rigor/environment.rb +28 -13
- data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
- data/lib/rigor/inference/expression_typer.rb +62 -4
- data/lib/rigor/inference/fork_map.rb +47 -4
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +158 -12
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +234 -11
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
- data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
- data/lib/rigor/inference/method_dispatcher.rb +41 -10
- data/lib/rigor/inference/mutation_widening.rb +30 -25
- data/lib/rigor/inference/narrowing.rb +3 -1
- data/lib/rigor/inference/optimistic_origin.rb +123 -0
- data/lib/rigor/inference/protection_scanner.rb +18 -2
- data/lib/rigor/inference/receiver_alias.rb +57 -0
- data/lib/rigor/inference/scope_indexer.rb +197 -55
- data/lib/rigor/inference/singleton_object_constant.rb +63 -0
- data/lib/rigor/inference/statement_evaluator.rb +55 -4
- data/lib/rigor/language_server/buffer_table.rb +22 -0
- data/lib/rigor/language_server/diagnostic_publisher.rb +203 -4
- data/lib/rigor/language_server/project_context.rb +45 -0
- data/lib/rigor/language_server/publish_batcher.rb +82 -0
- data/lib/rigor/language_server/server.rb +20 -1
- data/lib/rigor/language_server.rb +1 -0
- data/lib/rigor/plugin/node_rule_walk.rb +6 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
- data/lib/rigor/protection/closure_kill_oracle.rb +190 -0
- data/lib/rigor/protection/dependency_closure.rb +59 -0
- data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
- data/lib/rigor/protection/discovery_seed.rb +116 -0
- data/lib/rigor/protection/kill_signature.rb +31 -0
- data/lib/rigor/protection/mutation_cache.rb +355 -0
- data/lib/rigor/protection/mutation_scanner.rb +55 -11
- data/lib/rigor/protection/mutator.rb +26 -4
- data/lib/rigor/runtime/jit.rb +63 -1
- data/lib/rigor/scope/discovery_index.rb +9 -0
- data/lib/rigor/scope.rb +114 -10
- data/lib/rigor/sig_gen/generator.rb +130 -31
- data/lib/rigor/sig_gen/layout_index.rb +6 -0
- data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
- data/lib/rigor/sig_gen/renderer.rb +20 -1
- data/lib/rigor/sig_gen/write_result.rb +6 -4
- data/lib/rigor/sig_gen/writer.rb +12 -0
- data/lib/rigor/sig_gen.rb +1 -0
- data/lib/rigor/source/node_walker.rb +12 -0
- data/lib/rigor/type/anonymous_class_name.rb +40 -0
- data/lib/rigor/type/nominal.rb +5 -2
- data/lib/rigor/type/singleton.rb +4 -1
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
- data/sig/rigor/inference/void_origin.rbs +6 -3
- data/sig/rigor/scope.rbs +12 -0
- data/sig/rigor.rbs +4 -0
- metadata +21 -1
data/docs/manual/12-caching.md
CHANGED
|
@@ -28,12 +28,23 @@ on:
|
|
|
28
28
|
- the **content** of the source and `.rbs` files that fed it,
|
|
29
29
|
- the **gems** in play, by name and locked version,
|
|
30
30
|
- the active **plugins**, by ID and version,
|
|
31
|
-
- the relevant **configuration
|
|
31
|
+
- the relevant **configuration**,
|
|
32
|
+
- **Rigor itself**, by version.
|
|
32
33
|
|
|
33
34
|
Change any of those and the dependent entries are recomputed
|
|
34
35
|
automatically. A corrupt or unreadable entry is treated as a
|
|
35
36
|
miss and overwritten — bad cache state cannot wedge a run.
|
|
36
37
|
|
|
38
|
+
Rigor's version identifies its own code only for a released gem.
|
|
39
|
+
If you run Rigor from a git checkout — a working copy you are
|
|
40
|
+
patching, or `gem "rigor", github:` tracking a branch, where two
|
|
41
|
+
commits share one version number — the entries that hold
|
|
42
|
+
analysis results are keyed on the content of Rigor's own source
|
|
43
|
+
as well, so editing the analyzer and re-running recomputes
|
|
44
|
+
instead of replaying the previous answer. That costs one pass
|
|
45
|
+
over Rigor's source per run (about 20 ms); an installed gem
|
|
46
|
+
neither pays it nor needs it.
|
|
47
|
+
|
|
37
48
|
The cache is also schema-versioned: after a Rigor upgrade that
|
|
38
49
|
changes the cache format, the stale cache is purged on the
|
|
39
50
|
first writable run.
|
|
@@ -157,10 +168,11 @@ affected files in parallel just as a full run does.
|
|
|
157
168
|
|
|
158
169
|
The snapshot lives under the cache directory (`.rigor/cache`)
|
|
159
170
|
and is keyed by a fingerprint of your configuration, your locked
|
|
160
|
-
gems, your project's own `sig/` RBS,
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
171
|
+
gems, your project's own `sig/` RBS, the Rigor version, and — if
|
|
172
|
+
you run Rigor from a checkout — the content of Rigor's own
|
|
173
|
+
source. Change any of those and the snapshot is dropped and the
|
|
174
|
+
next run is a full one, so an incremental run can never serve a
|
|
175
|
+
stale result. (The fingerprint keys on the analysis *roots* — e.g.
|
|
164
176
|
`["lib"]` — not the expanded file list, so adding or removing a
|
|
165
177
|
file *under* those roots does **not** drop the snapshot: the
|
|
166
178
|
incremental session re-analyzes the added files and the
|
|
@@ -48,6 +48,17 @@ power. `--threshold=RATIO` turns it into a CI gate (exit `1`
|
|
|
48
48
|
below the ratio) and `--format=json` carries the structured
|
|
49
49
|
fields.
|
|
50
50
|
|
|
51
|
+
Some `protected` sites are typed only through call-site
|
|
52
|
+
[parameter inference](03-configuration.md) (`parameter_inference:`
|
|
53
|
+
— an undeclared parameter seeded from what its callers pass)
|
|
54
|
+
rather than a declaration — a lower bound that can only widen, so
|
|
55
|
+
none of Rigor's negative rules fire against it yet
|
|
56
|
+
([ADR-67](../adr/67-parameter-type-inference.md) WD6b). The text
|
|
57
|
+
report calls these out as "lower-bound-typed" when there are any,
|
|
58
|
+
and the JSON form always carries the count as `lower_bound_typed`.
|
|
59
|
+
It is a sub-count *within* `protected`, never subtracted from it —
|
|
60
|
+
the protected ratio itself is unaffected.
|
|
61
|
+
|
|
51
62
|
On a large project, `--workers=N` fork-parallelizes the scan
|
|
52
63
|
(both the parameter-inference pre-pass and the per-file scan),
|
|
53
64
|
with output byte-identical to a sequential run. The worker count
|
|
@@ -74,11 +85,117 @@ real "add a type here" site, surfaced with no guesswork.
|
|
|
74
85
|
|
|
75
86
|
It runs many analyses, so it defaults to the **git-changed** `.rb`
|
|
76
87
|
files (pass explicit paths to widen — whole-project is minutes) and
|
|
77
|
-
is an opt-in CI deep-dive, not an interactive check.
|
|
88
|
+
is an opt-in CI deep-dive, not an interactive check. `--workers=N`
|
|
89
|
+
applies here too, and it is the main lever on a wide run: the
|
|
90
|
+
whole-project pre-pass is paid once, then the per-file measurement —
|
|
91
|
+
which is nearly all of the wall time — is fork-mapped across
|
|
92
|
+
workers, byte-identically to a sequential run.
|
|
93
|
+
|
|
94
|
+
The framing is
|
|
78
95
|
always *effectiveness / where to add a type*, never "your code is
|
|
79
96
|
broken": a surviving breakage at a `Dynamic` site is a place
|
|
80
97
|
the type net does not reach.
|
|
81
98
|
|
|
99
|
+
### Re-running it is cheap
|
|
100
|
+
|
|
101
|
+
Each file's measurement is cached and served back while nothing
|
|
102
|
+
that could change it has moved — the file itself, any file it was
|
|
103
|
+
recorded as reading from, the resolved configuration, your `sig/`,
|
|
104
|
+
the gem set, the engine version, `--limit` / `--seed`, and which
|
|
105
|
+
bleeding-edge features you have adopted. Edit one file and that
|
|
106
|
+
file plus the files recorded as reading from it are re-measured;
|
|
107
|
+
the rest are served. A one-line stderr report says what happened,
|
|
108
|
+
so `--format=json` stdout stays clean:
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
coverage: mutation cache — re-measured 2 file(s), 350 served from cache.
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The cache reads the cross-file dependency edges that a
|
|
115
|
+
`rigor check --incremental` run records, so warm one once per
|
|
116
|
+
project:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
rigor check --incremental # records the edges
|
|
120
|
+
rigor coverage --protection --mutation lib # measures, then caches
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Without that snapshot — or under `--no-cache`, or with
|
|
124
|
+
`dependent-closure-kill-oracle` adopted, whose verdicts depend on
|
|
125
|
+
*other* files' diagnostics and so cannot be validated per file —
|
|
126
|
+
every file is re-measured and the stderr line names the reason. A
|
|
127
|
+
file the snapshot does not know about is re-measured rather than
|
|
128
|
+
served: the cache never guesses.
|
|
129
|
+
|
|
130
|
+
### Which sites Tier 2 measures
|
|
131
|
+
|
|
132
|
+
Tier 2 only mutates a site where Rigor holds a *concrete* receiver
|
|
133
|
+
type — at a `Dynamic` receiver the breakage would survive by
|
|
134
|
+
construction, which measures nothing. Today it decides that from
|
|
135
|
+
the file alone, so a call on a project class declared in a
|
|
136
|
+
**sibling** file (`Post.where`, `Account.find`) reads `Dynamic` and
|
|
137
|
+
the site is left out of the denominator entirely, even though Tier
|
|
138
|
+
1 counts it.
|
|
139
|
+
|
|
140
|
+
The [`discovery-seeded-mutation-sites`](02-cli-reference.md#rigor-show-bleedingedge)
|
|
141
|
+
bleeding-edge feature closes that gap, giving Tier 2 the same
|
|
142
|
+
cross-file view Tier 1 has — on *both* halves of the measurement,
|
|
143
|
+
site selection and the re-analysis that decides whether a mutation
|
|
144
|
+
was caught, so an admitted site is one a breakage can really trip:
|
|
145
|
+
|
|
146
|
+
```yaml
|
|
147
|
+
# .rigor.yml
|
|
148
|
+
bleeding_edge:
|
|
149
|
+
- discovery-seeded-mutation-sites
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
It is **off by default and opt-in on purpose**. Those sites are
|
|
153
|
+
mostly ones the type net does not yet catch, so admitting them
|
|
154
|
+
*lowers* the reported effectiveness ratio on unchanged code — and
|
|
155
|
+
`--threshold=RATIO` exits 1 below a ratio you pin in CI. Adopt it
|
|
156
|
+
when you are ready to re-baseline that number; it is the intended
|
|
157
|
+
default at a future major.
|
|
158
|
+
|
|
159
|
+
### Where a catch is allowed to land
|
|
160
|
+
|
|
161
|
+
Deciding whether a breakage was caught is a separate question from
|
|
162
|
+
which sites are measured, and it has the same blind spot in the
|
|
163
|
+
opposite direction. Tier 2 re-analyses the mutated file and looks
|
|
164
|
+
at *that file's* diagnostics — so the most valuable catch Rigor
|
|
165
|
+
delivers is scored as a miss. Change what a method returns and the
|
|
166
|
+
error appears in its **callers**, which is exactly the cross-file
|
|
167
|
+
reach the analyzer exists for.
|
|
168
|
+
|
|
169
|
+
The [`dependent-closure-kill-oracle`](02-cli-reference.md#rigor-show-bleedingedge)
|
|
170
|
+
bleeding-edge feature re-analyses the mutated file **and the files
|
|
171
|
+
that depend on it**, and counts the breakage as caught when a new
|
|
172
|
+
diagnostic appears anywhere in that set:
|
|
173
|
+
|
|
174
|
+
```yaml
|
|
175
|
+
# .rigor.yml
|
|
176
|
+
bleeding_edge:
|
|
177
|
+
- dependent-closure-kill-oracle
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
It composes with the site seed above — one decides what is
|
|
181
|
+
measured, the other where a catch may land — and either can be
|
|
182
|
+
adopted alone. The mutated file's verdict is unchanged, so this can
|
|
183
|
+
only **add** kills: the ratio moves up or not at all, and it cannot
|
|
184
|
+
turn a `--threshold` build red. A number recorded under it is still
|
|
185
|
+
not comparable with one recorded without it.
|
|
186
|
+
|
|
187
|
+
It costs more: each surviving breakage is re-analysed once per
|
|
188
|
+
dependent file (the caught ones stop at the mutated file), and the
|
|
189
|
+
dependency graph itself is one extra whole-project pass per run —
|
|
190
|
+
about a third more wall time on Rigor's own `lib`.
|
|
191
|
+
|
|
192
|
+
Worth knowing before you pay that: on the two codebases measured so
|
|
193
|
+
far (Rigor's own `lib`, redmine's `app/models`) it added **no**
|
|
194
|
+
kills. A breakage that survives there is one the analyzer reports
|
|
195
|
+
*nowhere* — not one it reports in a caller — which is a statement
|
|
196
|
+
about where the type net has teeth, and exactly what the
|
|
197
|
+
unprotected-site list is telling you.
|
|
198
|
+
|
|
82
199
|
What *does* reach it is your tests.
|
|
83
200
|
|
|
84
201
|
## The fused view — types **and** tests (`--with-tests`)
|
|
@@ -236,6 +353,10 @@ them as a deep-dive, not a per-keystroke check:
|
|
|
236
353
|
(suite runtime)`. A fast, well-scoped test command is the biggest
|
|
237
354
|
lever.
|
|
238
355
|
- **Cap with `--limit`** on `--include-dynamic` or large files.
|
|
356
|
+
- **Spread with `--workers=N`** on `--mutation` (and Tier 1). The
|
|
357
|
+
fused `--with-tests` tier stays sequential — the suite hook shells
|
|
358
|
+
out, and concurrent runs would race — so widen it with scope and
|
|
359
|
+
`--limit` instead.
|
|
239
360
|
|
|
240
361
|
## In CI
|
|
241
362
|
|
|
@@ -45,6 +45,28 @@ plugin id/version + config), so an unchanged second run skips the parse.
|
|
|
45
45
|
| Rule | Severity | Fires when |
|
|
46
46
|
| --- | --- | --- |
|
|
47
47
|
| `plugin.rbs-inline.source-rbs-synthesis-failed` | info | rbs-inline could not parse a file; analysis falls back to no inline-RBS contribution and the diagnostic carries the upstream error |
|
|
48
|
+
| `plugin.rbs-inline.source-rbs-annotation-not-honoured` | info | an annotation parsed successfully but contributed nothing — the file's other annotations still apply. Today this means the `# @rbs module-self: Foo` spelling; see below |
|
|
49
|
+
|
|
50
|
+
## Which inline-RBS dialect Rigor reads
|
|
51
|
+
|
|
52
|
+
There are two implementations of inline RBS: the
|
|
53
|
+
[`rbs-inline` gem](https://github.com/soutaro/rbs-inline), which this plugin
|
|
54
|
+
runs, and the `RBS::InlineParser` built into `rbs` 4.x. **Rigor reads the
|
|
55
|
+
gem's dialect** ([ADR-32](../../adr/32-rbs-inline-comment-ingestion.md) WD11).
|
|
56
|
+
They overlap almost entirely — `#:`, `@rbs` method types, `def self.`,
|
|
57
|
+
instance-variable annotations, `@rbs skip` all behave identically — but they
|
|
58
|
+
are not the same grammar, and one difference bites in practice:
|
|
59
|
+
|
|
60
|
+
| you write | Rigor honours it |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| `# @rbs module-self Comparable` | yes |
|
|
63
|
+
| `# @rbs module-self: Comparable` | **no** — this is the spelling in rbs's own `docs/inline.md` |
|
|
64
|
+
|
|
65
|
+
Rigor reports the second form as
|
|
66
|
+
`plugin.rbs-inline.source-rbs-annotation-not-honoured` rather than dropping it
|
|
67
|
+
in silence. Constructs the gem supports and the built-in parser does not —
|
|
68
|
+
`@rbs generic T`, `@rbs!` embedded RBS blocks, `@rbs inherits`, method
|
|
69
|
+
visibility — all work here.
|
|
48
70
|
|
|
49
71
|
## Configuration
|
|
50
72
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "prism"
|
|
4
4
|
|
|
5
5
|
require_relative "../../source/node_children"
|
|
6
|
+
require_relative "../../inference/optimistic_origin"
|
|
6
7
|
require_relative "inferred_param_guard"
|
|
7
8
|
|
|
8
9
|
module Rigor
|
|
@@ -108,6 +109,13 @@ module Rigor
|
|
|
108
109
|
# the precision-additive contract.
|
|
109
110
|
return if InferredParamGuard.rooted?(predicate, scope)
|
|
110
111
|
|
|
112
|
+
# Issue #313 — a predicate whose constancy rests on an optimistically nil-free carrier is a bet, not
|
|
113
|
+
# proof: `MAP[key]` omits `nil` because pessimising the defaulted-Hash idiom costs more false
|
|
114
|
+
# positives than the miss it would model, so `MAP[key].nil?` folding to `false` says nothing about
|
|
115
|
+
# whether the key was there. The spec forbids this rule concluding from such a value; the derivation
|
|
116
|
+
# is what carries the mark across `.nil?`, `!` and `&&` / `||` to the predicate the rule reads.
|
|
117
|
+
return unless Inference::OptimisticOrigin.resolve(predicate, scope).nil?
|
|
118
|
+
|
|
111
119
|
predicate_type = scope.type_of(predicate)
|
|
112
120
|
return unless predicate_type.is_a?(Type::Constant)
|
|
113
121
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Analysis
|
|
7
|
+
module CheckRules
|
|
8
|
+
# ADR-58 WD1 — the shared "is this value's optionality declaration-sourced?" predicate.
|
|
9
|
+
#
|
|
10
|
+
# A `nil` whose only provenance is a declaration (the class-ivar index seed of a ctor `@x = nil`, a
|
|
11
|
+
# non-definitely-assigned ivar read) is real type information but not diagnostic fuel: the working
|
|
12
|
+
# program's cross-method invariant is assumed per the robustness principle ([ADR-5]). Every negative rule
|
|
13
|
+
# that would fire *because of* such a nil must therefore consult the same provenance question, and get the
|
|
14
|
+
# same answer.
|
|
15
|
+
#
|
|
16
|
+
# Before issue #324 each rule spelled the lookup itself, and they drifted: `call.possible-nil-receiver`
|
|
17
|
+
# read the `:local` mark (so `r = @right; r.key` was excused — ADR-58's own motivating shape), while both
|
|
18
|
+
# `call.argument-type-mismatch` gates opened by requiring a literal `Prism::InstanceVariableReadNode`, so
|
|
19
|
+
# `c = @count; sink.take_int(c)` fired on the same value with the same provenance. This module is that one
|
|
20
|
+
# question, asked in one place.
|
|
21
|
+
#
|
|
22
|
+
# **The mark is deliberately NOT transitive.** `Scope#with_declaration_sourced_local`
|
|
23
|
+
# ({Inference::StatementEvaluator#eval_local_write}) stamps `:local` only when the RHS is a *pure read of a
|
|
24
|
+
# currently declaration-sourced ivar*, so a second hop (`c = @count; d = c`) and an `||=` rewrite land on
|
|
25
|
+
# the plain `with_local` path and carry no mark.
|
|
26
|
+
#
|
|
27
|
+
# A branch join is NOT one of those cases, contrary to what this comment claimed when it landed: both
|
|
28
|
+
# arms of `if c then r = @count else r = @count end` stamp `(:local, :r)`, and `join_declaration_sourced`
|
|
29
|
+
# INTERSECTS, so the mark survives. Only an *asymmetric* join drops it, and by that intersection rather
|
|
30
|
+
# than by reaching `with_local`. The normative statement — establishment, drop, join and the full list of
|
|
31
|
+
# unmarked shapes, each verified against the implementation — is
|
|
32
|
+
# `docs/internal-spec/inference-engine.md` § "Declaration-sourced provenance mark (ADR-58)"; keep this
|
|
33
|
+
# comment subordinate to it.
|
|
34
|
+
#
|
|
35
|
+
# So `marked?` matches exactly the two node shapes the scope actually models and invents no propagation
|
|
36
|
+
# of its own; anything flow-live keeps firing.
|
|
37
|
+
module DeclarationSourcedGuard
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
# True when `node` is a direct read of a binding whose optionality is purely declaration-sourced. Any
|
|
41
|
+
# flow-live touch (a method-local `@x = nil` write, a failed-guard narrowing, a rebinding local write)
|
|
42
|
+
# drops the mark upstream, so this returns false there and the caller fires exactly as before.
|
|
43
|
+
def marked?(node, scope)
|
|
44
|
+
case node
|
|
45
|
+
when Prism::InstanceVariableReadNode then scope.declaration_sourced?(:ivar, node.name)
|
|
46
|
+
when Prism::LocalVariableReadNode then scope.declaration_sourced?(:local, node.name)
|
|
47
|
+
else false
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -145,6 +145,13 @@ module Rigor
|
|
|
145
145
|
return unless node.is_a?(Prism::Node)
|
|
146
146
|
|
|
147
147
|
dispatch(node, hooks, context)
|
|
148
|
+
# Issue #318 — `defined?`'s operand is never evaluated (the runtime inspects it statically), so
|
|
149
|
+
# nothing under a `DefinedNode` is reachable, evaluated code. Dispatching the DefinedNode itself
|
|
150
|
+
# is fine (a collector may care that it exists); descending into `#value` would feed collectors
|
|
151
|
+
# source that can never execute, e.g. `undefined_method_diagnostic` flagging a call inside
|
|
152
|
+
# `defined? @x && @x.method_call`.
|
|
153
|
+
return if node.is_a?(Prism::DefinedNode)
|
|
154
|
+
|
|
148
155
|
child_context = descend(node, context)
|
|
149
156
|
node.rigor_each_child { |child| walk(child, hooks, child_context) }
|
|
150
157
|
end
|
|
@@ -5,11 +5,13 @@ require "prism"
|
|
|
5
5
|
require_relative "../reflection"
|
|
6
6
|
require_relative "../source/node_walker"
|
|
7
7
|
require_relative "../source/constant_path"
|
|
8
|
+
require_relative "../inference/singleton_object_constant"
|
|
8
9
|
require_relative "../type"
|
|
9
10
|
require_relative "diagnostic"
|
|
10
11
|
require_relative "dependency_recorder"
|
|
11
12
|
require_relative "check_rules/rule_ids"
|
|
12
13
|
require_relative "check_rules/inferred_param_guard"
|
|
14
|
+
require_relative "check_rules/declaration_sourced_guard"
|
|
13
15
|
require_relative "check_rules/rule_walk"
|
|
14
16
|
require_relative "check_rules/always_truthy_condition_collector"
|
|
15
17
|
require_relative "check_rules/unreachable_clause_collector"
|
|
@@ -424,6 +426,12 @@ module Rigor
|
|
|
424
426
|
# only. `# rigor:disable all` on a line suppresses
|
|
425
427
|
# every rule on that line.
|
|
426
428
|
#
|
|
429
|
+
# Both in-source forms are recognised only when the
|
|
430
|
+
# marker is the FIRST thing in the comment, so a
|
|
431
|
+
# directive quoted inside ordinary prose — as the bullets
|
|
432
|
+
# above quote it — is not a directive. See the pattern
|
|
433
|
+
# constants below.
|
|
434
|
+
#
|
|
427
435
|
# Diagnostics with `rule == nil` (parse errors, path
|
|
428
436
|
# errors, internal analyzer errors) are NEVER
|
|
429
437
|
# suppressed — they represent failures the user cannot
|
|
@@ -443,17 +451,26 @@ module Rigor
|
|
|
443
451
|
end
|
|
444
452
|
end
|
|
445
453
|
|
|
446
|
-
|
|
454
|
+
# Every suppression-recognition pattern below is anchored with `\A` against the COMMENT SLICE —
|
|
455
|
+
# Prism hands us a slice that starts at the `#`, so `\A#` means "the marker is the first thing in
|
|
456
|
+
# the comment". A real directive always has that shape, in both the whole-line form and the
|
|
457
|
+
# trailing `code # rigor:disable <rule>` form; prose that merely quotes a directive
|
|
458
|
+
# (`... like `# rigor:disable-file all` ...`) has text before the inner `#` and no longer
|
|
459
|
+
# activates anything. Two consequences worth naming: a doc-tool `##` comment never activates (the
|
|
460
|
+
# second `#` is neither whitespace nor the marker word), and an `=begin`/`=end` block comment
|
|
461
|
+
# never activates either (its slice starts at `=begin`). Unanchored, these patterns silently
|
|
462
|
+
# file-suppressed this very file — see issue #306.
|
|
463
|
+
LINE_SUPPRESSION_PATTERN = /\A#\s*rigor:disable(?!-file)\s+(?<rules>[\w.,\s-]+)/
|
|
447
464
|
private_constant :LINE_SUPPRESSION_PATTERN
|
|
448
465
|
|
|
449
|
-
FILE_SUPPRESSION_PATTERN =
|
|
466
|
+
FILE_SUPPRESSION_PATTERN = /\A#\s*rigor:disable-file\s+(?<rules>[\w.,\s-]+)/
|
|
450
467
|
private_constant :FILE_SUPPRESSION_PATTERN
|
|
451
468
|
|
|
452
469
|
# A `rigor:disable[-file]` marker word regardless of whether any rule tokens follow. Used only by the
|
|
453
470
|
# `suppression.empty` detection — the two suppression patterns above require at least one token
|
|
454
471
|
# character, so a bare `# rigor:disable` never reaches them. The lookahead keeps
|
|
455
472
|
# `rigor:disable-something-else` from counting as a marker.
|
|
456
|
-
BARE_SUPPRESSION_MARKER =
|
|
473
|
+
BARE_SUPPRESSION_MARKER = /\A#\s*rigor:disable(?<file>-file)?(?![\w-])(?<rest>.*)/
|
|
457
474
|
private_constant :BARE_SUPPRESSION_MARKER
|
|
458
475
|
|
|
459
476
|
# A `rigor:` marker word that is NOT part of Rigor's suppression grammar but reads like an attempted
|
|
@@ -463,7 +480,7 @@ module Rigor
|
|
|
463
480
|
# surveillance they silently suppress nothing. Matches `disable-<suffix>` for any suffix other than
|
|
464
481
|
# `file`, and `enable` with or without a suffix.
|
|
465
482
|
UNKNOWN_SUPPRESSION_MARKER =
|
|
466
|
-
|
|
483
|
+
/\A#\s*rigor:(?<marker>disable-(?!file(?![\w-]))[\w-]+|enable(?:-[\w-]+)?)(?![\w-])(?<rest>.*)/
|
|
467
484
|
private_constant :UNKNOWN_SUPPRESSION_MARKER
|
|
468
485
|
|
|
469
486
|
# @return [Array<(Hash{Integer => Set}, Set)>] pair of
|
|
@@ -527,9 +544,10 @@ module Rigor
|
|
|
527
544
|
end
|
|
528
545
|
|
|
529
546
|
# A comment carrying the marker word but not the token-bearing suppression grammar. A remainder of
|
|
530
|
-
# nothing but whitespace / commas is a genuinely empty marker (`# rigor:disable`); anything else
|
|
531
|
-
#
|
|
532
|
-
#
|
|
547
|
+
# nothing but whitespace / commas is a genuinely empty marker (`# rigor:disable`); anything else is
|
|
548
|
+
# left alone as an ordinary comment, matching the parse path, which never treats it as a
|
|
549
|
+
# suppression either. Prose that merely quotes a marker is already excluded a step earlier by the
|
|
550
|
+
# `\A` anchor, since the quotation is not at the start of the comment.
|
|
533
551
|
def diagnose_bare_suppression_marker(path, comment, source, diagnostics)
|
|
534
552
|
bare = BARE_SUPPRESSION_MARKER.match(source)
|
|
535
553
|
if bare
|
|
@@ -545,8 +563,9 @@ module Rigor
|
|
|
545
563
|
|
|
546
564
|
# `# rigor:disable-next-line <rule>` / `# rigor:enable <rule>` — a marker word Rigor's grammar does
|
|
547
565
|
# not recognise but that reads as an attempted suppression (the RuboCop reflex). Fires only when the
|
|
548
|
-
# remainder is empty or looks like a rule list, so prose mentioning
|
|
549
|
-
# an ordinary comment — the same escape the empty-marker detection
|
|
566
|
+
# marker opens the comment and the remainder is empty or looks like a rule list, so prose mentioning
|
|
567
|
+
# the spelling in backticks stays an ordinary comment — the same escape the empty-marker detection
|
|
568
|
+
# observes.
|
|
550
569
|
def diagnose_unknown_suppression_marker(path, comment, source, diagnostics)
|
|
551
570
|
unknown = UNKNOWN_SUPPRESSION_MARKER.match(source)
|
|
552
571
|
return if unknown.nil?
|
|
@@ -656,6 +675,13 @@ module Rigor
|
|
|
656
675
|
# non-nil constituent (which, for a cross-file project def, would
|
|
657
676
|
# be a working-code false positive).
|
|
658
677
|
receiver_type = safe_navigation_receiver(call_node, scope)
|
|
678
|
+
|
|
679
|
+
# #320 — the private-singleton-object idiom (`class << Merger = Object.new`). The body's methods
|
|
680
|
+
# are recorded on the constant's own name, but the receiver reads back as `Object`, so the
|
|
681
|
+
# class-keyed probes below cannot see them. Recover the name from the receiver syntax. Scoped to
|
|
682
|
+
# the recorded name only: `Merger.nope` still fires.
|
|
683
|
+
return nil if Inference::SingletonObjectConstant.recorded?(call_node, receiver_type, call_node.name, scope)
|
|
684
|
+
|
|
659
685
|
class_name = concrete_class_name(receiver_type)
|
|
660
686
|
# A union receiver has no single concrete class. The scalar path
|
|
661
687
|
# below cannot reason about it, but the call is still definitely
|
|
@@ -1255,8 +1281,12 @@ module Rigor
|
|
|
1255
1281
|
# principle. The nil stays in the displayed type; only its use as
|
|
1256
1282
|
# diagnostic fuel is withheld. Any flow-live touch (method-local
|
|
1257
1283
|
# nil write, failed-guard narrowing) drops the mark upstream, so
|
|
1258
|
-
# flow-observed nil keeps firing exactly as before.
|
|
1259
|
-
|
|
1284
|
+
# flow-observed nil keeps firing exactly as before. The receiver
|
|
1285
|
+
# is already narrowed to a local read above, so this asks
|
|
1286
|
+
# {DeclarationSourcedGuard} exactly what it asked before — but it
|
|
1287
|
+
# now asks it through the predicate the argument-type gates share
|
|
1288
|
+
# (issue #324), which is what keeps the two rules from drifting.
|
|
1289
|
+
return nil if DeclarationSourcedGuard.marked?(call_node.receiver, scope)
|
|
1260
1290
|
|
|
1261
1291
|
# ADR-67 WD6b — an inferred-parameter receiver's type (incl. any nil constituent unioned in from a
|
|
1262
1292
|
# nil call site) is an open-call-site lower bound; a possible-nil firing against it is an FP by
|
|
@@ -1271,13 +1301,23 @@ module Rigor
|
|
|
1271
1301
|
# presence checks below cannot rule out a sound call.
|
|
1272
1302
|
return nil unless Rigor::Reflection.rbs_class_known?("NilClass", scope: scope)
|
|
1273
1303
|
|
|
1274
|
-
return nil unless
|
|
1275
|
-
return nil unless union_method_present_on_non_nil?(receiver_type, call_node.name, scope)
|
|
1276
|
-
return nil if nil_class_has_method?(call_node.name, scope)
|
|
1304
|
+
return nil unless nil_bearing_union_witnesses?(receiver_type, call_node.name, scope)
|
|
1277
1305
|
|
|
1278
1306
|
build_nil_receiver_diagnostic(path, call_node)
|
|
1279
1307
|
end
|
|
1280
1308
|
|
|
1309
|
+
# The receiver-type half of the rule, factored out of the node-shape
|
|
1310
|
+
# guards above: the union must carry nil, must carry a non-nil arm the
|
|
1311
|
+
# presence question can be asked of, must support the method on every
|
|
1312
|
+
# non-nil arm (so the call is only wrong on the nil path), and the
|
|
1313
|
+
# method must be absent from `NilClass` (so the nil path really raises).
|
|
1314
|
+
def nil_bearing_union_witnesses?(receiver_type, method_name, scope)
|
|
1315
|
+
union_contains_nil?(receiver_type) &&
|
|
1316
|
+
union_has_nameable_non_nil_arm?(receiver_type) &&
|
|
1317
|
+
union_method_present_on_non_nil?(receiver_type, method_name, scope) &&
|
|
1318
|
+
!nil_class_has_method?(method_name, scope)
|
|
1319
|
+
end
|
|
1320
|
+
|
|
1281
1321
|
def union_contains_nil?(union)
|
|
1282
1322
|
union.members.any? { |member| nil_member?(member) }
|
|
1283
1323
|
end
|
|
@@ -1302,6 +1342,23 @@ module Rigor
|
|
|
1302
1342
|
(member.is_a?(Type::Nominal) && member.class_name == "NilClass")
|
|
1303
1343
|
end
|
|
1304
1344
|
|
|
1345
|
+
# Possible-nil may witness only where the presence question below is
|
|
1346
|
+
# ANSWERABLE. `method_present_anywhere?` reports "present" for a
|
|
1347
|
+
# nameless arm (Dynamic / Top / Bot) — the permissive polarity the
|
|
1348
|
+
# union-undefined-method rule's FP safety rests on — so a union whose
|
|
1349
|
+
# non-nil arms are ALL nameless satisfied that gate vacuously and fired
|
|
1350
|
+
# on every method name, including names defined on no class anywhere.
|
|
1351
|
+
# That inverted the intent: the gate suppressed exactly where knowledge
|
|
1352
|
+
# exists (`String | nil` calling a nonexistent method stays silent) and
|
|
1353
|
+
# permitted exactly where none does. Requiring one nameable concrete
|
|
1354
|
+
# arm restores the polarity without touching the shared helper:
|
|
1355
|
+
# `String | nil` keeps firing, and so does `Dynamic | String | nil` —
|
|
1356
|
+
# the nameless arm stays permissive inside the all-arms check, which is
|
|
1357
|
+
# only about the arms' method surface.
|
|
1358
|
+
def union_has_nameable_non_nil_arm?(union)
|
|
1359
|
+
union.members.any? { |m| !nil_member?(m) && !concrete_class_name(m).nil? }
|
|
1360
|
+
end
|
|
1361
|
+
|
|
1305
1362
|
# The non-nil members must collectively support the
|
|
1306
1363
|
# method (i.e. for every non-nil member, the method
|
|
1307
1364
|
# exists on its class via RBS or in-source discovery).
|
|
@@ -2075,13 +2132,25 @@ module Rigor
|
|
|
2075
2132
|
param_overrides = Rigor::RbsExtended.param_type_override_map(method_def, environment: scope.environment)
|
|
2076
2133
|
mismatch = argument_mismatch(method_def.method_types, call_node, scope, param_overrides)
|
|
2077
2134
|
return nil if mismatch.nil?
|
|
2078
|
-
|
|
2079
|
-
# open-call-site lower bound; firing argument-type-mismatch against it is an FP by construction.
|
|
2080
|
-
return nil if inferred_param_argument?(mismatch[:node], scope)
|
|
2135
|
+
return nil if inferred_param_mismatch_verdict?(call_node, mismatch, scope)
|
|
2081
2136
|
|
|
2082
2137
|
build_argument_type_diagnostic(path, call_node, class_name, mismatch)
|
|
2083
2138
|
end
|
|
2084
2139
|
|
|
2140
|
+
# ADR-67 WD6b — an argument-type-mismatch verdict resting on an open-call-site lower bound, on
|
|
2141
|
+
# either side of the call. The ARGUMENT side: the mismatching argument is an inferred-parameter
|
|
2142
|
+
# local, so firing against it is an FP by construction. The RECEIVER side: when the receiver roots
|
|
2143
|
+
# at an inferred parameter, the method whose parameter contract the argument was checked against
|
|
2144
|
+
# was itself resolved through a lower-bound type, so the whole verdict is speculative. The 2026-07-30
|
|
2145
|
+
# self-check surfaced the receiver half as a guard hole: seeding `env : RBS::Environment` activated
|
|
2146
|
+
# this rule on `env.unload(culprits)` and flagged a correct Array argument against `unload`'s
|
|
2147
|
+
# declared `Set[Pathname]` — an upstream signature stricter than its implementation, exactly the FP
|
|
2148
|
+
# class WD6b exists to suppress. The other guarded rules already declined on a param-rooted
|
|
2149
|
+
# receiver; this brings argument-type-mismatch in line.
|
|
2150
|
+
def inferred_param_mismatch_verdict?(call_node, mismatch, scope)
|
|
2151
|
+
inferred_param_argument?(mismatch[:node], scope) || inferred_param_receiver?(call_node, scope)
|
|
2152
|
+
end
|
|
2153
|
+
|
|
2085
2154
|
# Single overload → the exact per-argument acceptance (unchanged).
|
|
2086
2155
|
# Multiple overloads → the nil channel (a pure-`nil` argument every
|
|
2087
2156
|
# overload rejects) plus, on non-coerce methods, the non-nil channel
|
|
@@ -2344,13 +2413,16 @@ module Rigor
|
|
|
2344
2413
|
params.map { |param| param.type.to_s.delete_prefix("::") }.uniq.join(" | ")
|
|
2345
2414
|
end
|
|
2346
2415
|
|
|
2347
|
-
# ADR-58 parity for the nil channel: a declaration-sourced
|
|
2348
|
-
#
|
|
2349
|
-
#
|
|
2350
|
-
#
|
|
2351
|
-
#
|
|
2416
|
+
# ADR-58 parity for the nil channel: a declaration-sourced read that
|
|
2417
|
+
# types as nil is the same not-diagnostic-fuel case the union path
|
|
2418
|
+
# gates in {#declaration_sourced_nil_only_mismatch?}; suppress it here
|
|
2419
|
+
# too so a ctor-seeded `@x = nil` read passed as an argument does not
|
|
2420
|
+
# fire on a working program's cross-method invariant. Issue #324 —
|
|
2421
|
+
# delegates to {DeclarationSourcedGuard}, so a local copy of the ivar
|
|
2422
|
+
# (`c = @count`) is excused on the same terms `possible-nil-receiver`
|
|
2423
|
+
# already excused it.
|
|
2352
2424
|
def declaration_sourced_nil_argument?(arg, scope)
|
|
2353
|
-
|
|
2425
|
+
DeclarationSourcedGuard.marked?(arg, scope)
|
|
2354
2426
|
end
|
|
2355
2427
|
|
|
2356
2428
|
def first_argument_mismatch(method_type, call_node, scope, param_overrides)
|
|
@@ -2413,14 +2485,15 @@ module Rigor
|
|
|
2413
2485
|
!declaration_sourced_nil_only_mismatch?(arg, arg_type, param_type, scope)
|
|
2414
2486
|
end
|
|
2415
2487
|
|
|
2416
|
-
# True when `arg` is a declaration-sourced
|
|
2488
|
+
# True when `arg` is a declaration-sourced read whose rejection is
|
|
2417
2489
|
# caused solely by its nil constituent: stripping nil from the argument
|
|
2418
2490
|
# type yields a type the parameter accepts (gradual mode). Mirrors the
|
|
2419
|
-
# `possible-nil-receiver` WD1 gate
|
|
2420
|
-
#
|
|
2491
|
+
# `possible-nil-receiver` WD1 gate — since issue #324 through the very
|
|
2492
|
+
# same {DeclarationSourcedGuard} predicate, so the ivar read and its
|
|
2493
|
+
# local copy (`c = @count`) are one case rather than two spellings that
|
|
2494
|
+
# can drift apart again.
|
|
2421
2495
|
def declaration_sourced_nil_only_mismatch?(arg, arg_type, param_type, scope)
|
|
2422
|
-
return false unless
|
|
2423
|
-
return false unless scope.declaration_sourced?(:ivar, arg.name)
|
|
2496
|
+
return false unless DeclarationSourcedGuard.marked?(arg, scope)
|
|
2424
2497
|
return false unless arg_type.is_a?(Type::Union)
|
|
2425
2498
|
return false unless union_contains_nil?(arg_type)
|
|
2426
2499
|
|
|
@@ -2681,6 +2754,12 @@ module Rigor
|
|
|
2681
2754
|
|
|
2682
2755
|
# Returns true when `override_visibility` is strictly more restrictive than `parent_visibility`
|
|
2683
2756
|
# under the public > protected > private ordering.
|
|
2757
|
+
#
|
|
2758
|
+
# The nil guard below is defence, not dead code. `VISIBILITY_RANK` is a closed literal hash read
|
|
2759
|
+
# with a dynamic key, so a symbol outside the table reads as nil at runtime. The engine folds that
|
|
2760
|
+
# read to the nil-free value union `0 | 1 | 2`, which `internal-spec/inference-engine.md` declares
|
|
2761
|
+
# OPTIMISTIC rather than proof — and since issue #313 that mark survives the `.nil?` fold and the
|
|
2762
|
+
# `||` composition, so no suppression directive is needed here.
|
|
2684
2763
|
def visibility_reduced?(parent_visibility, override_visibility)
|
|
2685
2764
|
parent_rank = VISIBILITY_RANK[parent_visibility]
|
|
2686
2765
|
override_rank = VISIBILITY_RANK[override_visibility]
|