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.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  3. data/data/core_overlay/pathname.rbs +5 -0
  4. data/data/core_overlay/resolv.rbs +31 -0
  5. data/data/core_overlay/string_scanner.rbs +5 -0
  6. data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
  7. data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
  8. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
  9. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
  10. data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
  11. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
  12. data/docs/manual/02-cli-reference.md +71 -11
  13. data/docs/manual/03-configuration.md +16 -1
  14. data/docs/manual/12-caching.md +17 -5
  15. data/docs/manual/15-type-protection-coverage.md +122 -1
  16. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  18. data/lib/rigor/analysis/check_rules.rb +45 -6
  19. data/lib/rigor/analysis/incremental_session.rb +156 -11
  20. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  21. data/lib/rigor/analysis/run_cache_key.rb +17 -1
  22. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  23. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  24. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  25. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  26. data/lib/rigor/analysis/runner.rb +78 -7
  27. data/lib/rigor/bleeding_edge.rb +132 -7
  28. data/lib/rigor/cache/descriptor.rb +6 -1
  29. data/lib/rigor/cache/engine_source.rb +162 -0
  30. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  31. data/lib/rigor/cli/check_command.rb +49 -28
  32. data/lib/rigor/cli/coverage_command.rb +15 -2
  33. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  34. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  35. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  36. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  37. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  38. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  39. data/lib/rigor/cli/protection_renderer.rb +13 -0
  40. data/lib/rigor/cli/protection_report.rb +11 -3
  41. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  42. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  43. data/lib/rigor/configuration.rb +45 -6
  44. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  45. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  46. data/lib/rigor/environment/rbs_loader.rb +434 -75
  47. data/lib/rigor/environment.rb +28 -13
  48. data/lib/rigor/inference/expression_typer.rb +15 -0
  49. data/lib/rigor/inference/fork_map.rb +9 -0
  50. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  51. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  52. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  53. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
  54. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  55. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
  56. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  57. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  58. data/lib/rigor/inference/mutation_widening.rb +30 -25
  59. data/lib/rigor/inference/narrowing.rb +3 -1
  60. data/lib/rigor/inference/optimistic_origin.rb +58 -0
  61. data/lib/rigor/inference/protection_scanner.rb +18 -2
  62. data/lib/rigor/inference/receiver_alias.rb +57 -0
  63. data/lib/rigor/inference/scope_indexer.rb +78 -27
  64. data/lib/rigor/inference/statement_evaluator.rb +40 -2
  65. data/lib/rigor/language_server/buffer_table.rb +22 -0
  66. data/lib/rigor/language_server/diagnostic_publisher.rb +203 -4
  67. data/lib/rigor/language_server/project_context.rb +45 -0
  68. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  69. data/lib/rigor/language_server/server.rb +20 -1
  70. data/lib/rigor/language_server.rb +1 -0
  71. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  72. data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
  73. data/lib/rigor/protection/dependency_closure.rb +59 -0
  74. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  75. data/lib/rigor/protection/discovery_seed.rb +116 -0
  76. data/lib/rigor/protection/kill_signature.rb +31 -0
  77. data/lib/rigor/protection/mutation_cache.rb +355 -0
  78. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  79. data/lib/rigor/protection/mutator.rb +26 -4
  80. data/lib/rigor/runtime/jit.rb +63 -1
  81. data/lib/rigor/scope/discovery_index.rb +9 -0
  82. data/lib/rigor/scope.rb +52 -8
  83. data/lib/rigor/sig_gen/generator.rb +130 -31
  84. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  85. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  86. data/lib/rigor/sig_gen/renderer.rb +20 -1
  87. data/lib/rigor/sig_gen/write_result.rb +6 -4
  88. data/lib/rigor/sig_gen/writer.rb +12 -0
  89. data/lib/rigor/sig_gen.rb +1 -0
  90. data/lib/rigor/version.rb +1 -1
  91. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  92. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  93. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  94. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  95. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  96. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  97. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  98. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  99. data/sig/rigor/inference/void_origin.rbs +6 -3
  100. data/sig/rigor/scope.rbs +8 -0
  101. metadata +17 -1
@@ -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, and the Rigor version.
161
- Change any of those and the snapshot is dropped and the next run
162
- is a full one, so an incremental run can never serve a stale
163
- result. (The fingerprint keys on the analysis *roots* e.g.
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. The framing is
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
 
@@ -119,6 +119,7 @@ module Rigor
119
119
  # the runner grows a new bare id.
120
120
  NON_CHECK_DIAGNOSTIC_IDS = %w[
121
121
  configuration-error load-error pool-degraded runtime-error source-rbs-synthesis-failed
122
+ source-rbs-annotation-not-honoured
122
123
  ].freeze
123
124
  end
124
125
  end
@@ -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 union_contains_nil?(receiver_type)
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
- # ADR-67 WD6b the mismatching argument is an inferred-parameter local, whose type is an
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
- affected = affected_closure(changed, added, removed)
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 = build_runner(analyze_only: analyze_set, record_dependencies: true)
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
- def affected_closure(changed, added, removed)
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
- summary = scan.empty? ? nil : Inference::ScopeIndexer.scan_summary_for_paths(scan)
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
- runner = build_runner(analyze_only: affected)
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
- def run_incremental(snapshot:, fingerprint:)
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:)