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
|
@@ -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:)
|
|
@@ -660,6 +660,8 @@ module Rigor
|
|
|
660
660
|
"The token resolves (canonical id, legacy alias, `all`, family wildcard, known engine id).",
|
|
661
661
|
"The token starts with `plugin.` — plugins load dynamically, so their rule vocabulary cannot " \
|
|
662
662
|
"be enumerated statically and under-warning is the FP-safe direction.",
|
|
663
|
+
"The marker does not open the comment (documentation prose quoting the syntax, a doubled " \
|
|
664
|
+
"`##` comment, or an `=begin` block) — that is not parsed as a suppression either.",
|
|
663
665
|
"The comment merely mentions the marker followed by non-token text (documentation prose " \
|
|
664
666
|
"like \"`# rigor:disable <rule>` comments\") — that is not parsed as a suppression either."
|
|
665
667
|
],
|
|
@@ -684,6 +686,7 @@ module Rigor
|
|
|
684
686
|
does_not_fire_when: [
|
|
685
687
|
"At least one token follows the marker (each token is then checked by " \
|
|
686
688
|
"`suppression.unknown-rule` instead).",
|
|
689
|
+
"The marker does not open the comment (documentation prose quoting the syntax).",
|
|
687
690
|
"Non-token text follows the marker (documentation prose mentioning the syntax)."
|
|
688
691
|
],
|
|
689
692
|
suppression: "Complete the marker (`# rigor:disable <rule>` / `all`) or delete it; " \
|
|
@@ -730,7 +733,7 @@ module Rigor
|
|
|
730
733
|
summary: "A comment uses a suppression marker Rigor does not recognise " \
|
|
731
734
|
"(`rigor:disable-next-line`, `rigor:enable`, ...).",
|
|
732
735
|
fires_when: [
|
|
733
|
-
"A comment
|
|
736
|
+
"A comment OPENS with `rigor:disable-<suffix>` for a suffix other than `file`, or with " \
|
|
734
737
|
"`rigor:enable[-<suffix>]` — typically the RuboCop reflex `# rigor:disable-next-line " \
|
|
735
738
|
"<rule>` — followed by nothing or a rule-list-shaped remainder.",
|
|
736
739
|
"Such a marker is invisible to the whole suppression grammar, so it silently suppresses " \
|
|
@@ -740,6 +743,7 @@ module Rigor
|
|
|
740
743
|
does_not_fire_when: [
|
|
741
744
|
"The marker is one of the two recognised forms (their tokens are then checked by " \
|
|
742
745
|
"`suppression.unknown-rule` / `suppression.empty` instead).",
|
|
746
|
+
"The marker does not open the comment (documentation prose quoting the spelling).",
|
|
743
747
|
"Non-token text follows the marker (documentation prose mentioning the spelling)."
|
|
744
748
|
],
|
|
745
749
|
suppression: "Rewrite as `# rigor:disable <rules>` on the offending line (Rigor has no " \
|
|
@@ -13,6 +13,7 @@ end
|
|
|
13
13
|
|
|
14
14
|
require_relative "../version"
|
|
15
15
|
require_relative "../cache/descriptor"
|
|
16
|
+
require_relative "../cache/engine_source"
|
|
16
17
|
require_relative "../cache/rbs_descriptor"
|
|
17
18
|
require_relative "../environment/default_libraries"
|
|
18
19
|
|
|
@@ -51,7 +52,7 @@ module Rigor
|
|
|
51
52
|
def descriptor(configuration:, files:, explain:, rbs_config_entries:)
|
|
52
53
|
Cache::Descriptor.new(
|
|
53
54
|
gems: [Cache::RbsDescriptor.rbs_gem_entry],
|
|
54
|
-
configs: rbs_config_entries + [
|
|
55
|
+
configs: rbs_config_entries + engine_source_entries + [
|
|
55
56
|
config_entry("configuration", Marshal.dump(configuration.to_h)),
|
|
56
57
|
config_entry("engine",
|
|
57
58
|
"#{Rigor::VERSION}:#{Cache::Descriptor::SCHEMA_VERSION}:#{explain}"),
|
|
@@ -62,6 +63,21 @@ module Rigor
|
|
|
62
63
|
nil
|
|
63
64
|
end
|
|
64
65
|
|
|
66
|
+
# Issue #285 — the `engine` slot above pins the engine by VERSION, which identifies the source only
|
|
67
|
+
# for a released gem. A checkout (a contributor's, or a `bundle add rigor, github:` clone) gets one
|
|
68
|
+
# extra slot carrying a digest of the engine's own source, so editing `lib/rigor/inference/*.rb` no
|
|
69
|
+
# longer replays the pre-edit diagnostics out of a warm cache. A released install adds NO entry, so
|
|
70
|
+
# its key — and its hit rate — are exactly what they were.
|
|
71
|
+
#
|
|
72
|
+
# {Cache::EngineSource::Unavailable} is left to propagate into `descriptor`'s rescue, which disables
|
|
73
|
+
# the cache for the run: an engine we cannot identify must not be keyed by its version alone.
|
|
74
|
+
def engine_source_entries
|
|
75
|
+
identity = Cache::EngineSource.process_identity
|
|
76
|
+
return [] if identity.nil?
|
|
77
|
+
|
|
78
|
+
[config_entry("engine-source", identity)]
|
|
79
|
+
end
|
|
80
|
+
|
|
65
81
|
def config_entry(key, payload)
|
|
66
82
|
Cache::Descriptor::ConfigEntry.new(key: key, value_hash: Digest::SHA256.hexdigest(payload))
|
|
67
83
|
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
|
|
5
|
+
require_relative "../../environment"
|
|
6
|
+
require_relative "../../runtime/jit"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
module Analysis
|
|
10
|
+
class Runner
|
|
11
|
+
# Issue #142 — LSP multi-buffer dispatch. Runs one independent single-buffer `Runner#run` call per
|
|
12
|
+
# dirty buffer due to publish, distributed across a fork-based worker pool, reusing the SAME warm
|
|
13
|
+
# `Environment` + `ProjectScan` a sequential per-buffer publish already shares via
|
|
14
|
+
# `Rigor::LanguageServer::ProjectContext` — so a batch pays zero extra RBS-env-build or plugin
|
|
15
|
+
# `#prepare` cost over today's sequential path; only the per-file inference itself moves off the main
|
|
16
|
+
# process.
|
|
17
|
+
#
|
|
18
|
+
# Deliberately a SIBLING of {PoolCoordinator}, not a method on it. PoolCoordinator's unit of dispatch is
|
|
19
|
+
# "one shared {WorkerSession}, a project's files split across it" — that shape needs cross-worker
|
|
20
|
+
# reporter merge because ONE session's reporters accumulate per file. This dispatcher's unit of
|
|
21
|
+
# dispatch is "one COMPLETE `Runner#run` call per job", and each job is already fully self-contained
|
|
22
|
+
# (its own fresh reporters, its own severity-profile stamp, its own diagnostic aggregation) by
|
|
23
|
+
# construction — there is nothing to merge back except the diagnostics array itself. Forcing this
|
|
24
|
+
# through PoolCoordinator's constructor (which exists to wire ONE Runner's own mutable ivar surface
|
|
25
|
+
# through reader procs) would buy nothing.
|
|
26
|
+
#
|
|
27
|
+
# Sequential-equivalence contract: each job's diagnostics are EXACTLY what
|
|
28
|
+
# `Runner.new(buffer:, prebuilt:, environment:, configuration:, cache_store:, collect_stats: false)
|
|
29
|
+
# .run([binding.logical_path]).diagnostics`
|
|
30
|
+
# returns today — the same call `LanguageServer::DiagnosticPublisher#run_analysis` already makes
|
|
31
|
+
# inline for a single buffer. This dispatcher only changes WHERE that call executes (a forked child vs.
|
|
32
|
+
# the parent), never what it computes — so two runs of the same dirty set publish byte-identical
|
|
33
|
+
# results, and a run that cannot use the pool falls back to running every job in-process rather than
|
|
34
|
+
# failing.
|
|
35
|
+
#
|
|
36
|
+
# Buffer-substitution correctness (the highest-risk part of #142): EVERY job carries its OWN
|
|
37
|
+
# `BufferBinding` — a fork worker never shares one session-wide binding the way `PoolCoordinator`'s
|
|
38
|
+
# editor-mode single-buffer contract does. A binding not threaded all the way into the child's `Runner`
|
|
39
|
+
# would make that worker parse the file as it sits on disk and publish plausible-looking, stale
|
|
40
|
+
# diagnostics; every code path below carries `binding` explicitly rather than defaulting to nil.
|
|
41
|
+
class BufferPoolDispatcher
|
|
42
|
+
# Below this many bindings, `#analyze` takes the sequential in-process path even when the pool is
|
|
43
|
+
# otherwise available (workers positive, `fork` available, `cache_store` present). Reviewed and
|
|
44
|
+
# required for #142 to merge: a throwaway measurement against this repo's own `lib/rigor` (46-353
|
|
45
|
+
# files, ~1-2ms/file analysis once `Environment` + `ProjectScan` are warm — the exact steady state
|
|
46
|
+
# this dispatcher runs in) showed the FORK + `Marshal` + `Process.waitpid2` overhead exceeding the
|
|
47
|
+
# parallelism win below roughly N=12, and only paying off cleanly from N=16 up (interleaved,
|
|
48
|
+
# median-of-5, `workers: 8`):
|
|
49
|
+
#
|
|
50
|
+
# N=8 sequential=0.018s pooled=0.027s speedup=0.66x (pool SLOWER)
|
|
51
|
+
# N=12 sequential=0.021s pooled=0.021s speedup=1.00x (break-even, noisy)
|
|
52
|
+
# N=16 sequential=0.028s pooled=0.025s speedup=1.09x
|
|
53
|
+
# N=24 sequential=0.038s pooled=0.026s speedup=1.47x
|
|
54
|
+
# N=32 sequential=0.050s pooled=0.029s speedup=1.73x
|
|
55
|
+
#
|
|
56
|
+
# 16 sits with margin ABOVE the noisy break-even (12), not at it — full method + table in #142's PR
|
|
57
|
+
# description.
|
|
58
|
+
#
|
|
59
|
+
# This is deliberately a FIXED default, not a per-project auto-calibrated one. Per-file analysis cost
|
|
60
|
+
# varies enormously by project — this repo's own `lib/rigor` is ~1-2ms/file warm; a large Rails
|
|
61
|
+
# controller or model file can cost far more, which would make pooling profitable at a MUCH smaller N
|
|
62
|
+
# there. A cost-aware gate — deciding from the publisher's own observed per-publish duration instead
|
|
63
|
+
# of a bare file count — would adapt automatically, but needs plumbing that does not exist today (the
|
|
64
|
+
# publisher tracks no timing history) and is disproportionate to what closing #142 calls for; it is a
|
|
65
|
+
# reasonable follow-up, not attempted here. A project whose crossover sits at a different N overrides
|
|
66
|
+
# via `RIGOR_LSP_POOL_MIN_BATCH` (mirrors `RIGOR_RACTOR_WORKERS`'s override shape).
|
|
67
|
+
DEFAULT_MIN_BATCH_SIZE = 16
|
|
68
|
+
|
|
69
|
+
# @return [Integer] `RIGOR_LSP_POOL_MIN_BATCH` when set to a non-empty value, else
|
|
70
|
+
# {DEFAULT_MIN_BATCH_SIZE}.
|
|
71
|
+
def self.resolve_min_batch_size
|
|
72
|
+
env_value = ENV.fetch("RIGOR_LSP_POOL_MIN_BATCH", nil)
|
|
73
|
+
return DEFAULT_MIN_BATCH_SIZE if env_value.nil? || env_value.empty?
|
|
74
|
+
|
|
75
|
+
Integer(env_value)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @param configuration [Rigor::Configuration]
|
|
79
|
+
# @param cache_store [Rigor::Cache::Store, nil]
|
|
80
|
+
# @param environment [Rigor::Environment] the warm, shared per-session Environment
|
|
81
|
+
# (`ProjectContext#environment`) every job's Runner reuses instead of rebuilding.
|
|
82
|
+
# @param prebuilt [Rigor::Analysis::ProjectScan] the warm, shared pre-pass snapshot
|
|
83
|
+
# (`ProjectContext#project_scan`) every job's Runner adopts instead of re-scanning.
|
|
84
|
+
# @param workers [Integer] pool size. `#analyze` degrades to sequential in-process execution — one
|
|
85
|
+
# job at a time, no `fork` — when fewer than 2 bindings are submitted, fewer than `min_batch_size`
|
|
86
|
+
# bindings are submitted, `workers` is not positive, `fork` is unavailable on this platform, or
|
|
87
|
+
# `cache_store` is nil. The middle two are the fork-pool-is-not-worth-it-yet gate documented on
|
|
88
|
+
# {DEFAULT_MIN_BATCH_SIZE}; the last two mirror
|
|
89
|
+
# {PoolCoordinator#analyze_files_in_pool}'s own fork-pool preconditions.
|
|
90
|
+
# @param min_batch_size [Integer] see {DEFAULT_MIN_BATCH_SIZE}. Exposed as a constructor param
|
|
91
|
+
# (rather than read from the env internally) purely for spec control; production callers get the
|
|
92
|
+
# resolved default.
|
|
93
|
+
def initialize(configuration:, cache_store:, environment:, prebuilt:, workers:,
|
|
94
|
+
min_batch_size: self.class.resolve_min_batch_size)
|
|
95
|
+
@configuration = configuration
|
|
96
|
+
@cache_store = cache_store
|
|
97
|
+
@environment = environment
|
|
98
|
+
@prebuilt = prebuilt
|
|
99
|
+
@workers = workers
|
|
100
|
+
@min_batch_size = min_batch_size
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Runs one `Runner#run([binding.logical_path])` per binding and returns `Array<Array<Diagnostic>>` —
|
|
104
|
+
# one diagnostics array per input binding, IN INPUT ORDER. The parent always absorbs worker results
|
|
105
|
+
# re-indexed by POSITION, never keyed on path, so two bindings that happen to share a logical path
|
|
106
|
+
# (should not occur — one dirty buffer per URI — but is not assumed away) can never collide or
|
|
107
|
+
# silently overwrite one another.
|
|
108
|
+
def analyze(bindings)
|
|
109
|
+
return [] if bindings.empty?
|
|
110
|
+
return bindings.map { |binding| run_one(binding) } unless dispatchable?(bindings)
|
|
111
|
+
|
|
112
|
+
dispatch_in_fork_pool(bindings)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def dispatchable?(bindings)
|
|
118
|
+
bindings.size > 1 && bindings.size >= @min_batch_size &&
|
|
119
|
+
@workers.is_a?(Integer) && @workers.positive? &&
|
|
120
|
+
Process.respond_to?(:fork) && !@cache_store.nil?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# One job, run exactly as `DiagnosticPublisher#run_analysis` runs it today — the equivalence contract
|
|
124
|
+
# this whole class exists to preserve.
|
|
125
|
+
def run_one(binding)
|
|
126
|
+
Runner.new(
|
|
127
|
+
configuration: @configuration, cache_store: @cache_store, collect_stats: false,
|
|
128
|
+
buffer: binding, prebuilt: @prebuilt, environment: @environment
|
|
129
|
+
).run([binding.logical_path]).diagnostics
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def dispatch_in_fork_pool(bindings)
|
|
133
|
+
# Pre-warm the memoized class registry on the parent so every forked child inherits it via
|
|
134
|
+
# copy-on-write instead of each re-computing it — mirrors `PoolCoordinator`'s own pool entry points.
|
|
135
|
+
Environment::ClassRegistry.default
|
|
136
|
+
|
|
137
|
+
worker_count = [@workers, bindings.size].min
|
|
138
|
+
indexed = bindings.each_with_index.to_a
|
|
139
|
+
slices = indexed.each_slice((indexed.size.to_f / worker_count).ceil).to_a
|
|
140
|
+
results_by_index = {}
|
|
141
|
+
|
|
142
|
+
degraded = Dir.mktmpdir("rigor-lsp-buffer-pool") do |tmpdir|
|
|
143
|
+
children = slices.each_with_index.map do |slice, worker_index|
|
|
144
|
+
out_path = File.join(tmpdir, "worker-#{worker_index}")
|
|
145
|
+
{ pid: fork { run_fork_worker(slice, out_path) }, slice: slice, out_path: out_path }
|
|
146
|
+
end
|
|
147
|
+
collect_fork_results(children, results_by_index)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
degraded.each do |slice|
|
|
151
|
+
slice.each { |binding, index| results_by_index[index] = run_one(binding) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
indexed.map { |_binding, index| results_by_index.fetch(index, []) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Child-process body. `fork` copies only the calling thread, so any deferred-YJIT deadline the parent
|
|
158
|
+
# armed does not survive into this child — re-arm it first, mirroring
|
|
159
|
+
# `PoolCoordinator#run_fork_worker`. In practice the LSP process enables YJIT immediately at boot
|
|
160
|
+
# (`Runtime::Jit.enable_now` in `LspCommand`, never the deferred-deadline path), so this is a cheap
|
|
161
|
+
# no-op today — kept for parity with every other fork site and in case that boot-time choice changes.
|
|
162
|
+
# `exit!` skips `at_exit` / stdio flush; the payload is already durable on disk by then.
|
|
163
|
+
def run_fork_worker(slice, out_path)
|
|
164
|
+
Runtime::Jit.rearm_after_fork
|
|
165
|
+
results = slice.to_h { |binding, index| [index, run_one(binding)] }
|
|
166
|
+
File.binwrite(out_path, Marshal.dump(results))
|
|
167
|
+
exit!(0)
|
|
168
|
+
rescue StandardError
|
|
169
|
+
exit!(1)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Waits for every forked child, merges each successful payload into `results_by_index`, and returns
|
|
173
|
+
# the (binding, index) slices whose worker exited abnormally, for in-process degrade.
|
|
174
|
+
def collect_fork_results(children, results_by_index)
|
|
175
|
+
degraded = []
|
|
176
|
+
children.each do |child|
|
|
177
|
+
_, status = Process.waitpid2(child[:pid])
|
|
178
|
+
payload = fork_worker_payload(status, child[:out_path])
|
|
179
|
+
if payload
|
|
180
|
+
results_by_index.merge!(payload)
|
|
181
|
+
else
|
|
182
|
+
degraded << child[:slice]
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
degraded
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# @return [Hash, nil] the child's `{index => diagnostics}` payload, or nil when the child exited
|
|
189
|
+
# abnormally or wrote no readable payload. `Marshal.load` is safe here: the blob was written by our
|
|
190
|
+
# own forked child to a temp file we created.
|
|
191
|
+
def fork_worker_payload(status, out_path)
|
|
192
|
+
return nil unless status.success? && File.exist?(out_path)
|
|
193
|
+
|
|
194
|
+
Marshal.load(File.binread(out_path)) # rubocop:disable Security/MarshalLoad
|
|
195
|
+
rescue StandardError
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -557,19 +557,37 @@ module Rigor
|
|
|
557
557
|
return [] if @source_rbs_synthesis_reporter.empty?
|
|
558
558
|
|
|
559
559
|
@source_rbs_synthesis_reporter.entries.map do |entry|
|
|
560
|
-
|
|
561
|
-
path: entry.path, line: 1, column: 1,
|
|
562
|
-
message: "plugin `#{entry.plugin_id}` failed to synthesise RBS from this file: " \
|
|
563
|
-
"#{entry.message}. The file's analysis falls back to no inline-RBS " \
|
|
564
|
-
"contribution. Fix the inline-RBS comment grammar or remove the " \
|
|
565
|
-
"annotation to silence this diagnostic.",
|
|
566
|
-
severity: :info,
|
|
567
|
-
rule: "source-rbs-synthesis-failed",
|
|
568
|
-
source_family: :builtin
|
|
569
|
-
)
|
|
560
|
+
entry.kind == :not_honoured ? not_honoured_diagnostic(entry) : synthesis_failed_diagnostic(entry)
|
|
570
561
|
end
|
|
571
562
|
end
|
|
572
563
|
|
|
564
|
+
def synthesis_failed_diagnostic(entry)
|
|
565
|
+
Diagnostic.new(
|
|
566
|
+
path: entry.path, line: 1, column: 1,
|
|
567
|
+
message: "plugin `#{entry.plugin_id}` failed to synthesise RBS from this file: " \
|
|
568
|
+
"#{entry.message}. The file's analysis falls back to no inline-RBS " \
|
|
569
|
+
"contribution. Fix the inline-RBS comment grammar or remove the " \
|
|
570
|
+
"annotation to silence this diagnostic.",
|
|
571
|
+
severity: :info,
|
|
572
|
+
rule: "source-rbs-synthesis-failed",
|
|
573
|
+
source_family: :builtin
|
|
574
|
+
)
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
# ADR-32 WD12 — the synthesis SUCCEEDED; one annotation inside it was parsed and then contributed
|
|
578
|
+
# nothing. Distinct from the failure above in the only way that matters to the reader: the rest of
|
|
579
|
+
# the file's annotations ARE in effect, so the advice is to fix one comment, not to distrust the file.
|
|
580
|
+
def not_honoured_diagnostic(entry)
|
|
581
|
+
Diagnostic.new(
|
|
582
|
+
path: entry.path, line: 1, column: 1,
|
|
583
|
+
message: "plugin `#{entry.plugin_id}` parsed an inline-RBS annotation in this file but did " \
|
|
584
|
+
"not honour it: #{entry.message} The file's other annotations are unaffected.",
|
|
585
|
+
severity: :info,
|
|
586
|
+
rule: "source-rbs-annotation-not-honoured",
|
|
587
|
+
source_family: :builtin
|
|
588
|
+
)
|
|
589
|
+
end
|
|
590
|
+
|
|
573
591
|
# ADR-10 slice 5c — drains the per-run {DependencySourceInference::BoundaryCrossReporter} into
|
|
574
592
|
# `dynamic.dependency-source.boundary-cross` `:info` diagnostics. Each event flags a call site
|
|
575
593
|
# where RBS dispatch produced a concrete answer AND a `mode: :full` opt-in gem's source catalog
|