rigortype 0.3.1 → 0.3.2
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/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/rule_ids.rb +1 -0
- data/lib/rigor/analysis/check_rules.rb +45 -6
- data/lib/rigor/analysis/incremental_session.rb +156 -11
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
- 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/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/expression_typer.rb +15 -0
- data/lib/rigor/inference/fork_map.rb +9 -0
- 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 +16 -0
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
- 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/mutation_widening.rb +30 -25
- data/lib/rigor/inference/narrowing.rb +3 -1
- data/lib/rigor/inference/optimistic_origin.rb +58 -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 +78 -27
- data/lib/rigor/inference/statement_evaluator.rb +40 -2
- 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/source_rbs_synthesis_reporter.rb +9 -3
- data/lib/rigor/protection/closure_kill_oracle.rb +171 -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 +52 -8
- 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/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 +8 -0
- metadata +17 -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
|
|
|
@@ -1271,13 +1271,23 @@ module Rigor
|
|
|
1271
1271
|
# presence checks below cannot rule out a sound call.
|
|
1272
1272
|
return nil unless Rigor::Reflection.rbs_class_known?("NilClass", scope: scope)
|
|
1273
1273
|
|
|
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)
|
|
1274
|
+
return nil unless nil_bearing_union_witnesses?(receiver_type, call_node.name, scope)
|
|
1277
1275
|
|
|
1278
1276
|
build_nil_receiver_diagnostic(path, call_node)
|
|
1279
1277
|
end
|
|
1280
1278
|
|
|
1279
|
+
# The receiver-type half of the rule, factored out of the node-shape
|
|
1280
|
+
# guards above: the union must carry nil, must carry a non-nil arm the
|
|
1281
|
+
# presence question can be asked of, must support the method on every
|
|
1282
|
+
# non-nil arm (so the call is only wrong on the nil path), and the
|
|
1283
|
+
# method must be absent from `NilClass` (so the nil path really raises).
|
|
1284
|
+
def nil_bearing_union_witnesses?(receiver_type, method_name, scope)
|
|
1285
|
+
union_contains_nil?(receiver_type) &&
|
|
1286
|
+
union_has_nameable_non_nil_arm?(receiver_type) &&
|
|
1287
|
+
union_method_present_on_non_nil?(receiver_type, method_name, scope) &&
|
|
1288
|
+
!nil_class_has_method?(method_name, scope)
|
|
1289
|
+
end
|
|
1290
|
+
|
|
1281
1291
|
def union_contains_nil?(union)
|
|
1282
1292
|
union.members.any? { |member| nil_member?(member) }
|
|
1283
1293
|
end
|
|
@@ -1302,6 +1312,23 @@ module Rigor
|
|
|
1302
1312
|
(member.is_a?(Type::Nominal) && member.class_name == "NilClass")
|
|
1303
1313
|
end
|
|
1304
1314
|
|
|
1315
|
+
# Possible-nil may witness only where the presence question below is
|
|
1316
|
+
# ANSWERABLE. `method_present_anywhere?` reports "present" for a
|
|
1317
|
+
# nameless arm (Dynamic / Top / Bot) — the permissive polarity the
|
|
1318
|
+
# union-undefined-method rule's FP safety rests on — so a union whose
|
|
1319
|
+
# non-nil arms are ALL nameless satisfied that gate vacuously and fired
|
|
1320
|
+
# on every method name, including names defined on no class anywhere.
|
|
1321
|
+
# That inverted the intent: the gate suppressed exactly where knowledge
|
|
1322
|
+
# exists (`String | nil` calling a nonexistent method stays silent) and
|
|
1323
|
+
# permitted exactly where none does. Requiring one nameable concrete
|
|
1324
|
+
# arm restores the polarity without touching the shared helper:
|
|
1325
|
+
# `String | nil` keeps firing, and so does `Dynamic | String | nil` —
|
|
1326
|
+
# the nameless arm stays permissive inside the all-arms check, which is
|
|
1327
|
+
# only about the arms' method surface.
|
|
1328
|
+
def union_has_nameable_non_nil_arm?(union)
|
|
1329
|
+
union.members.any? { |m| !nil_member?(m) && !concrete_class_name(m).nil? }
|
|
1330
|
+
end
|
|
1331
|
+
|
|
1305
1332
|
# The non-nil members must collectively support the
|
|
1306
1333
|
# method (i.e. for every non-nil member, the method
|
|
1307
1334
|
# exists on its class via RBS or in-source discovery).
|
|
@@ -2075,13 +2102,25 @@ module Rigor
|
|
|
2075
2102
|
param_overrides = Rigor::RbsExtended.param_type_override_map(method_def, environment: scope.environment)
|
|
2076
2103
|
mismatch = argument_mismatch(method_def.method_types, call_node, scope, param_overrides)
|
|
2077
2104
|
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)
|
|
2105
|
+
return nil if inferred_param_mismatch_verdict?(call_node, mismatch, scope)
|
|
2081
2106
|
|
|
2082
2107
|
build_argument_type_diagnostic(path, call_node, class_name, mismatch)
|
|
2083
2108
|
end
|
|
2084
2109
|
|
|
2110
|
+
# ADR-67 WD6b — an argument-type-mismatch verdict resting on an open-call-site lower bound, on
|
|
2111
|
+
# either side of the call. The ARGUMENT side: the mismatching argument is an inferred-parameter
|
|
2112
|
+
# local, so firing against it is an FP by construction. The RECEIVER side: when the receiver roots
|
|
2113
|
+
# at an inferred parameter, the method whose parameter contract the argument was checked against
|
|
2114
|
+
# was itself resolved through a lower-bound type, so the whole verdict is speculative. The 2026-07-30
|
|
2115
|
+
# self-check surfaced the receiver half as a guard hole: seeding `env : RBS::Environment` activated
|
|
2116
|
+
# this rule on `env.unload(culprits)` and flagged a correct Array argument against `unload`'s
|
|
2117
|
+
# declared `Set[Pathname]` — an upstream signature stricter than its implementation, exactly the FP
|
|
2118
|
+
# class WD6b exists to suppress. The other guarded rules already declined on a param-rooted
|
|
2119
|
+
# receiver; this brings argument-type-mismatch in line.
|
|
2120
|
+
def inferred_param_mismatch_verdict?(call_node, mismatch, scope)
|
|
2121
|
+
inferred_param_argument?(mismatch[:node], scope) || inferred_param_receiver?(call_node, scope)
|
|
2122
|
+
end
|
|
2123
|
+
|
|
2085
2124
|
# Single overload → the exact per-argument acceptance (unchanged).
|
|
2086
2125
|
# Multiple overloads → the nil channel (a pure-`nil` argument every
|
|
2087
2126
|
# overload rejects) plus, on non-coerce methods, the non-nil channel
|
|
@@ -62,9 +62,14 @@ module Rigor
|
|
|
62
62
|
# pool records each worker's cross-file reads and marshals them back (PoolCoordinator), so the
|
|
63
63
|
# dependency graph a pooled recheck rebuilds equals the sequential one.
|
|
64
64
|
def initialize(configuration:, paths: nil, environment: nil, cache_store: nil, plugin_requirer: nil,
|
|
65
|
-
workers: 0)
|
|
65
|
+
workers: 0, buffer: nil)
|
|
66
66
|
@configuration = configuration
|
|
67
67
|
@paths = paths
|
|
68
|
+
# Editor mode option B (#146) — the in-flight buffer, threaded into every internal Runner so the
|
|
69
|
+
# pre-passes and the closure re-analysis read the editor's bytes at the logical path. A session
|
|
70
|
+
# holding one MUST NOT persist its snapshot: its `@digests` and `@cache` describe bytes that exist
|
|
71
|
+
# only in the editor. {#run_buffer_recheck} is the only entry that honours that.
|
|
72
|
+
@buffer = buffer
|
|
68
73
|
@environment = environment
|
|
69
74
|
@cache_store = cache_store
|
|
70
75
|
@plugin_requirer = plugin_requirer
|
|
@@ -93,6 +98,13 @@ module Rigor
|
|
|
93
98
|
# ADR-89 WD2 — per-def observed-key return summaries: [path, symbol] => { keys:, returns:, effects: }.
|
|
94
99
|
# Harvested from the ADR-84 return memo after each run; drives the behavioural-stability gate.
|
|
95
100
|
@return_summaries = {}
|
|
101
|
+
# ADR-67 WD6c lift — the `parameter_inference:` seed table the cached diagnostics were computed
|
|
102
|
+
# under (`{}` when the gate is off — the gate state is constant across a snapshot's lifetime because
|
|
103
|
+
# the configuration is part of the global fingerprint). A recheck recomputes the table fresh and
|
|
104
|
+
# diffs it against this: the pre-pass is whole-project by design, so the fresh table is ground truth
|
|
105
|
+
# and a missing invalidation edge is impossible by construction — the reason this is a table diff
|
|
106
|
+
# and not the caller→callee edge recording #204 first sketched.
|
|
107
|
+
@param_table = {}
|
|
96
108
|
# ADR-88 WD1 — the plugin fact-surface digest computed for THIS invocation (nil until a
|
|
97
109
|
# `#run_incremental` pass runs / a plugin-free project) and the reporting flags a caller (the CLI
|
|
98
110
|
# banner + `--cache-stats`) reads after `#run_incremental`. `@last_runner` is the analysis runner the
|
|
@@ -120,6 +132,9 @@ module Rigor
|
|
|
120
132
|
@seed_bundles = runner.seed_bundles # ADR-85 WD2 — the freshly built bundle set for the next run.
|
|
121
133
|
absorb_dependency_graph(runner)
|
|
122
134
|
@return_summaries = runner.return_summaries # ADR-89 WD2 — the full-run behavioural surface.
|
|
135
|
+
# ADR-67 WD6c lift — the seed table the runner's own pre-pass computed ({} when the gate is off).
|
|
136
|
+
# Reading it back, rather than computing it here, keeps the baseline single-collect.
|
|
137
|
+
@param_table = runner.param_inferred_types
|
|
123
138
|
@cache = per_file(diagnostics)
|
|
124
139
|
@digests = @analyzed.to_h { |path| [path, pack_digest(path)] }
|
|
125
140
|
diagnostics
|
|
@@ -134,30 +149,79 @@ module Rigor
|
|
|
134
149
|
added = current - previous
|
|
135
150
|
removed = previous - current
|
|
136
151
|
changed = changed_paths(current & previous)
|
|
137
|
-
|
|
152
|
+
# ADR-67 WD6c lift — recompute the whole-project inferred-param table BEFORE deciding the closure,
|
|
153
|
+
# and diff it against the snapshot's copy: an entry that moved (because a caller's argument type
|
|
154
|
+
# changed, a caller appeared, or one vanished) invalidates the CALLEE's file and its symbol
|
|
155
|
+
# dependents, none of which the file-digest tier can see (the callee's text is unchanged).
|
|
156
|
+
fresh_params = fresh_param_table(current, changed, added, removed)
|
|
157
|
+
param_files, param_pairs = param_seed_invalidation(fresh_params)
|
|
158
|
+
affected = affected_closure(changed, added, removed, param_files, param_pairs)
|
|
138
159
|
analyze_set = affected & current
|
|
139
|
-
runner
|
|
160
|
+
# The freshly collected table is handed to the runner so the run seeds from the SAME table the diff
|
|
161
|
+
# was decided on (and the collector runs once per recheck, not twice).
|
|
162
|
+
runner = build_runner(analyze_only: analyze_set, record_dependencies: true,
|
|
163
|
+
param_inferred_types: fresh_params)
|
|
140
164
|
fresh = run_runner(runner).diagnostics
|
|
141
165
|
@last_runner = runner # ADR-88 WD1 — the post-hoc fact-surface fingerprint reads this prepared registry.
|
|
142
166
|
reused = (current & previous) - affected.to_a
|
|
143
167
|
merged = fresh + reused.flat_map { |path| @cache[path] || [] }
|
|
144
168
|
absorb(runner, fresh, current, analyze_set, removed)
|
|
169
|
+
@param_table = fresh_params
|
|
145
170
|
Recheck.new(diagnostics: merged, changed: changed.to_set, added: added.to_set,
|
|
146
171
|
removed: removed.to_set, affected: affected, reused: reused.to_set)
|
|
147
172
|
end
|
|
148
173
|
|
|
174
|
+
# Editor mode option B (#146) — a whole-project recheck with the editor's buffer substituted for one
|
|
175
|
+
# file, for the CLI's `--incremental --tmp-file=X --instead-of=Y`. Returns the {Recheck} when the
|
|
176
|
+
# snapshot could be reused, or nil when it could not — the caller then falls back to option A
|
|
177
|
+
# (single-file scope) rather than paying a full baseline, because that baseline would repeat on every
|
|
178
|
+
# keystroke: this session MUST NOT save, so nothing it computes can warm the next invocation.
|
|
179
|
+
#
|
|
180
|
+
# Not saving is the whole safety story. `@digests` and `@cache` here describe the buffer's bytes, which
|
|
181
|
+
# exist only in the editor; persisting them would make the next `rigor check --incremental` believe the
|
|
182
|
+
# on-disk file was already analysed in a state it was never in.
|
|
183
|
+
def run_buffer_recheck(snapshot:, fingerprint:)
|
|
184
|
+
Cache::FileDigest.with_run(strict: @configuration.cache_validation_strict?) do
|
|
185
|
+
restored = fingerprint && snapshot.load(fingerprint: fingerprint)
|
|
186
|
+
break nil unless restored
|
|
187
|
+
|
|
188
|
+
restore(restored)
|
|
189
|
+
result = recheck
|
|
190
|
+
adopt_plugin_fact_fingerprint
|
|
191
|
+
# The ADR-88 gate applies unchanged: if the plugin fact surface moved, the cache-served files may be
|
|
192
|
+
# stale. A full baseline is the sound answer for `--incremental`, but in editor mode it is also the
|
|
193
|
+
# latency this mode exists to avoid, so decline and let the caller drop to single-file scope.
|
|
194
|
+
break nil unless @plugin_fact_reusable.reusable_against?(restored.plugin_fact_digest)
|
|
195
|
+
|
|
196
|
+
result
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
149
200
|
# The frozen set of files a #recheck must re-analyse: the symbol/ancestry-granularity closure of the
|
|
150
201
|
# changed files (slice 4), the added files themselves, the consumers of any symbol / class that
|
|
151
202
|
# *appeared* in a changed OR added file (slice 3 — a now-defined `call.unresolved-toplevel` target or
|
|
152
203
|
# `def.override-*` ancestor), and the consumers of every removed file (which now miss what it
|
|
153
204
|
# provided). An added file has no before-state, so all its symbols / classes appear.
|
|
154
|
-
|
|
205
|
+
#
|
|
206
|
+
# ADR-67 WD6c lift — `param_files` / `param_pairs` are the callee files (and their `[file, symbol]`
|
|
207
|
+
# pairs) whose inferred-param seeds moved since the snapshot. Their text is unchanged, so they enter
|
|
208
|
+
# the closure here: the files themselves re-analyse (their in-body diagnostics were computed under the
|
|
209
|
+
# old seeds), and their pairs join the SYMBOL fan-out — a seed change shifts the callee's inferred
|
|
210
|
+
# return exactly the way a body edit does, so it reuses the same audited dependents machinery. The
|
|
211
|
+
# pairs join AFTER the ADR-89 WD2 behavioural-stability pruning: that gate re-evaluates returns under
|
|
212
|
+
# the snapshot's OLD seeds, which is the wrong oracle for a pair whose seeds are the thing that moved.
|
|
213
|
+
def affected_closure(changed, added, removed, param_files = Set.new, param_pairs = Set.new)
|
|
155
214
|
scan = changed + added
|
|
156
215
|
# Parse the changed / added set ONCE for the per-symbol fingerprints, the class declarations, AND the
|
|
157
216
|
# ADR-89 WD1 declaration signatures. They were separate `discovered_def_index_for_paths` passes over
|
|
158
217
|
# the same `scan` set — a duplicate re-parse of every changed file each recheck (recon §2 / the P6
|
|
159
218
|
# recheck-floor audit).
|
|
160
|
-
|
|
219
|
+
# `buffer:` is load-bearing for editor mode option B (#146), not an optimisation: this scan decides
|
|
220
|
+
# the closure, so reading the buffer's logical path from DISK would compare the snapshot's symbol
|
|
221
|
+
# fingerprints against bytes the user has already edited away — every dependent of the unsaved change
|
|
222
|
+
# would then be served from cache, which is the stale answer whole-project editor scope exists to
|
|
223
|
+
# avoid. `ScopeIndexer.scan_summary_for_paths` resolves each path through the binding.
|
|
224
|
+
summary = scan.empty? ? nil : Inference::ScopeIndexer.scan_summary_for_paths(scan, buffer: @buffer)
|
|
161
225
|
scan_index = summary && summary[:def_index]
|
|
162
226
|
declaration_signatures = (summary && summary[:declaration_signatures]) || {}
|
|
163
227
|
new_fps = symbol_fingerprints_from_index(scan_index)
|
|
@@ -176,13 +240,23 @@ module Rigor
|
|
|
176
240
|
# previously-observed call key + content-mutation effects) is unchanged is behaviourally stable: its
|
|
177
241
|
# symbol dependents' cached diagnostics stay valid, so drop them. `symbol_pairs` is `changed_pairs`
|
|
178
242
|
# minus those stable pairs, and only it (not `changed_pairs`) drives the symbol-dependent fan-out.
|
|
179
|
-
symbol_pairs = behaviourally_unstable_pairs(changed_pairs, unstable, scan_index)
|
|
243
|
+
symbol_pairs = behaviourally_unstable_pairs(changed_pairs, unstable, scan_index) | param_pairs
|
|
180
244
|
base = dependents_base(unstable, symbol_pairs)
|
|
181
245
|
closure = base | changed.to_set | added.to_set | negative_affected(scan, new_fps, new_class_decls)
|
|
246
|
+
closure = param_seed_closure(closure, param_files)
|
|
182
247
|
removed.each { |path| closure |= @dependents[path] || Set.new }
|
|
183
248
|
closure.freeze
|
|
184
249
|
end
|
|
185
250
|
|
|
251
|
+
# ADR-67 WD6c lift — the seed-invalidated callees' own contribution to the closure: the files
|
|
252
|
+
# themselves, plus — on a pre-slice-4 snapshot with no symbol edges, where the pairs' symbol fan-out
|
|
253
|
+
# found nothing — their file-level dependents (wider, always sound).
|
|
254
|
+
def param_seed_closure(closure, param_files)
|
|
255
|
+
closure |= param_files
|
|
256
|
+
param_files.each { |path| closure |= @dependents[path] || Set.new } if @symbol_sources.empty?
|
|
257
|
+
closure
|
|
258
|
+
end
|
|
259
|
+
|
|
186
260
|
# The dependents contributed by the declaration-unstable changed files and the behaviourally-unstable
|
|
187
261
|
# symbol pairs: the ADR-46 slice-4 symbol-granular fan-out when either is present (ancestry deps of the
|
|
188
262
|
# unstable files + symbol deps of the changed pairs), else the coarse file-level fan-out.
|
|
@@ -194,6 +268,47 @@ module Rigor
|
|
|
194
268
|
end
|
|
195
269
|
end
|
|
196
270
|
|
|
271
|
+
# ADR-67 WD6c lift — the fresh whole-project inferred-param table for this recheck ({} when the gate
|
|
272
|
+
# is off). When NO file moved, the collector's inputs are unchanged — the project files are identical,
|
|
273
|
+
# and the env-side inputs (configuration, `sig/`, the gem set, the engine version) are constant under
|
|
274
|
+
# a matched snapshot fingerprint — so the stored table is provably identical and the whole-project
|
|
275
|
+
# re-collect is skipped (the ADR-87 null-recheck fast path stays collect-free).
|
|
276
|
+
def fresh_param_table(current, changed, added, removed)
|
|
277
|
+
return {} unless @configuration.parameter_inference
|
|
278
|
+
return @param_table if changed.empty? && added.empty? && removed.empty?
|
|
279
|
+
|
|
280
|
+
build_runner.collect_param_inference_table(current)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# ADR-67 WD6c lift — the `[files, pairs]` the fresh table invalidates. For every `[class, method,
|
|
284
|
+
# kind]` entry that differs from the snapshot's copy (added, removed, or value-changed — `Type#==`
|
|
285
|
+
# structural equality, the same comparison the collector's own fixpoint termination uses, already
|
|
286
|
+
# exercised across Marshal round-trips by its fork workers), every snapshot file defining that symbol
|
|
287
|
+
# re-analyses and its `[file, symbol]` pair joins the symbol fan-out. An entry attributable to NO
|
|
288
|
+
# snapshot file is a def that first appeared in this edit: its file is in the changed/added set (so it
|
|
289
|
+
# re-analyses anyway) and its prior callers are the negative-dependency closure's job — nothing is
|
|
290
|
+
# lost by skipping it here. The restored types are compared and then DISCARDED (the run seeds from the
|
|
291
|
+
# fresh table), so a cache-carried stale memo ivar can never poison a live lookup.
|
|
292
|
+
def param_seed_invalidation(fresh_params)
|
|
293
|
+
return [Set.new, Set.new] if fresh_params.equal?(@param_table) || @param_table == fresh_params
|
|
294
|
+
|
|
295
|
+
files = Set.new
|
|
296
|
+
pairs = Set.new
|
|
297
|
+
(@param_table.keys | fresh_params.keys).each do |key|
|
|
298
|
+
next if @param_table[key] == fresh_params[key]
|
|
299
|
+
|
|
300
|
+
class_name, method_name, kind = key
|
|
301
|
+
symbol = "#{class_name}#{kind == :singleton ? '.' : '#'}#{method_name}"
|
|
302
|
+
@symbol_fingerprints.each do |path, symbols|
|
|
303
|
+
next unless symbols.key?(symbol)
|
|
304
|
+
|
|
305
|
+
files << path
|
|
306
|
+
pairs << [path, symbol]
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
[files, pairs]
|
|
310
|
+
end
|
|
311
|
+
|
|
197
312
|
# ADR-89 WD2 — `changed_pairs` minus the behaviourally-STABLE pairs whose symbol dependents may be
|
|
198
313
|
# skipped. A pair `[path, "Class#method"]` is a candidate when its file is declaration-stable (WD1), it
|
|
199
314
|
# carries a persisted return summary, and the (edited) def is GATE-ELIGIBLE — its only cross-file body
|
|
@@ -266,7 +381,9 @@ module Rigor
|
|
|
266
381
|
# mutating session state. Returns the merged diagnostics.
|
|
267
382
|
def reanalyze_subset(subset)
|
|
268
383
|
affected = subset.to_set
|
|
269
|
-
|
|
384
|
+
# ADR-67 WD6c lift — seed the subset run from the baseline's own table so the verification engine
|
|
385
|
+
# exercises the exact seeds the served cache entries were computed under (and skips a re-collect).
|
|
386
|
+
runner = build_runner(analyze_only: affected, param_inferred_types: @param_table)
|
|
270
387
|
fresh = run_runner(runner).diagnostics
|
|
271
388
|
reused = @analyzed - affected.to_a
|
|
272
389
|
fresh + reused.flat_map { |path| @cache[path] || [] }
|
|
@@ -278,7 +395,12 @@ module Rigor
|
|
|
278
395
|
# persist the updated snapshot for the next process. Returns `[diagnostics, warm]` — `warm` is true
|
|
279
396
|
# when a snapshot was restored. A nil `fingerprint` (uncomputable inputs) disables persistence: a
|
|
280
397
|
# plain full run.
|
|
281
|
-
|
|
398
|
+
# `persist: false` runs the same restore / recheck / baseline decision without writing the snapshot
|
|
399
|
+
# back. The language server (#246) needs exactly that: it keeps its session in memory for the life of
|
|
400
|
+
# the process, and writing shared state would race the way its read-only cache store already declines
|
|
401
|
+
# to. Every soundness gate below — the fingerprint, the ADR-88 fact surface — is unchanged, so the
|
|
402
|
+
# decision to reuse is made identically whether or not the result is saved.
|
|
403
|
+
def run_incremental(snapshot:, fingerprint:, persist: true)
|
|
282
404
|
# ADR-87 WD1 — install the per-run digest table + recording instant + strict flag for the whole
|
|
283
405
|
# invocation so change-detection's stat-then-digest freshness (`#pack_digest` / `#stat_fresh?`) honours
|
|
284
406
|
# `cache.validation: digest` (and `RIGOR_STRICT_VALIDATION`, which the env-only path already sees) and
|
|
@@ -320,7 +442,7 @@ module Rigor
|
|
|
320
442
|
warm = false
|
|
321
443
|
skip_save = false
|
|
322
444
|
end
|
|
323
|
-
snapshot.save(fingerprint: fingerprint, payload: to_payload) if fingerprint && !skip_save
|
|
445
|
+
snapshot.save(fingerprint: fingerprint, payload: to_payload) if persist && fingerprint && !skip_save
|
|
324
446
|
[diagnostics, warm]
|
|
325
447
|
end
|
|
326
448
|
end
|
|
@@ -386,6 +508,7 @@ module Rigor
|
|
|
386
508
|
@missing = payload.missing || {}
|
|
387
509
|
@class_decls = payload.class_decls || {}
|
|
388
510
|
@return_summaries = payload.return_summaries || {}
|
|
511
|
+
@param_table = payload.param_table || {} # ADR-67 WD6c lift — the seeds the cache was built under.
|
|
389
512
|
@symbol_dependents = Incremental.invert_symbols(@symbol_sources)
|
|
390
513
|
@ancestry_dependents = Incremental.invert(@ancestry_sources)
|
|
391
514
|
@negative_dependents = Incremental.invert(@missing)
|
|
@@ -398,7 +521,8 @@ module Rigor
|
|
|
398
521
|
symbol_fingerprints: @symbol_fingerprints, missing: @missing,
|
|
399
522
|
class_decls: @class_decls, seed_bundles: @seed_bundles,
|
|
400
523
|
plugin_fact_digest: @plugin_fact_digest,
|
|
401
|
-
return_summaries: marshal_safe_return_summaries
|
|
524
|
+
return_summaries: marshal_safe_return_summaries,
|
|
525
|
+
param_table: marshal_safe_param_table
|
|
402
526
|
)
|
|
403
527
|
end
|
|
404
528
|
|
|
@@ -416,6 +540,18 @@ module Rigor
|
|
|
416
540
|
end
|
|
417
541
|
end
|
|
418
542
|
|
|
543
|
+
# ADR-67 WD6c lift — the param table filtered to Marshal-clean entries, the same guard (and reason) as
|
|
544
|
+
# {#marshal_safe_return_summaries} above. A dropped entry re-appears as "added" in the next recheck's
|
|
545
|
+
# diff, so its callee re-checks — the conservative direction.
|
|
546
|
+
def marshal_safe_param_table
|
|
547
|
+
@param_table.each_with_object({}) do |(key, params), safe|
|
|
548
|
+
Marshal.dump(params)
|
|
549
|
+
safe[key] = params
|
|
550
|
+
rescue StandardError
|
|
551
|
+
next
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
|
|
419
555
|
# Fold a #recheck's fresh results back into the cache + graph so the session is correct across
|
|
420
556
|
# multiple edits: the analyzed set gets fresh diagnostics + digests + dependency edges, removed files
|
|
421
557
|
# are evicted from every map, and the analyzed-file list advances to `current`.
|
|
@@ -669,7 +805,7 @@ module Rigor
|
|
|
669
805
|
Runner.new(
|
|
670
806
|
configuration: @configuration, cache_store: @cache_store, environment: @environment,
|
|
671
807
|
plugin_requirer: @plugin_requirer, seed_bundles: @seed_bundles, collect_seed_bundles: true,
|
|
672
|
-
workers: @workers, **
|
|
808
|
+
workers: @workers, buffer: @buffer, **
|
|
673
809
|
)
|
|
674
810
|
end
|
|
675
811
|
|
|
@@ -696,10 +832,19 @@ module Rigor
|
|
|
696
832
|
candidates.reject { |path| stat_fresh?(path) }
|
|
697
833
|
end
|
|
698
834
|
|
|
835
|
+
# A bound buffer's logical path is never fresh: the bytes to analyse live in the editor's temp file, and
|
|
836
|
+
# the recorded entry describes the file on disk. Re-analysing it when the two happen to agree costs one
|
|
837
|
+
# file; trusting the stat tuple would serve the editor its own stale diagnostics.
|
|
838
|
+
def buffer_path?(path)
|
|
839
|
+
!@buffer.nil? && path == @buffer.logical_path
|
|
840
|
+
end
|
|
841
|
+
|
|
699
842
|
# True when `path`'s recorded stat entry proves it unchanged since the last analysis. Any stat / parse
|
|
700
843
|
# failure (missing entry, unreadable / vanished file) reads as NOT fresh (→ re-analyse), preserving the
|
|
701
844
|
# prior `digest(path) != recorded` "changed" semantics for a file that cannot be validated.
|
|
702
845
|
def stat_fresh?(path)
|
|
846
|
+
return false if buffer_path?(path)
|
|
847
|
+
|
|
703
848
|
entry = @digests[path]
|
|
704
849
|
return false if entry.nil?
|
|
705
850
|
|
|
@@ -71,6 +71,20 @@ module Rigor
|
|
|
71
71
|
new.digest_registry(registry)
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
+
# The fact surface reduced to the one String a cache KEY can carry, or nil when the surface cannot be
|
|
75
|
+
# seen at all (an opaque plugin — one that contributes call-site types while declaring none of the three
|
|
76
|
+
# fingerprint channels). A nil obliges the caller to decline caching entirely, which is the same
|
|
77
|
+
# conservative direction {Result#reusable_against?} takes for the incremental snapshot: a key that
|
|
78
|
+
# silently omitted an invisible input would serve a stale value rather than miss.
|
|
79
|
+
#
|
|
80
|
+
# Keeping the opaque decision here, rather than at each cache's call site, means a new consumer cannot
|
|
81
|
+
# key on `digest` while forgetting that an opaque surface makes it meaningless.
|
|
82
|
+
# @return [String, nil]
|
|
83
|
+
def self.key_digest(registry)
|
|
84
|
+
result = from_registry(registry)
|
|
85
|
+
result.opaque? ? nil : result.digest.to_s
|
|
86
|
+
end
|
|
87
|
+
|
|
74
88
|
# Loads the plugins and runs every `#prepare` hook sequentially, returning the prepared registry (nil on
|
|
75
89
|
# any failure → the caller treats it as "no fact surface").
|
|
76
90
|
def self.prepared_registry(configuration:, cache_store:, plugin_requirer:)
|