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
|
@@ -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
|
|
@@ -389,16 +389,13 @@ module Rigor
|
|
|
389
389
|
# copy-on-write-inherited session and writes the Marshal'd payload to `out_path`. `exit!` skips
|
|
390
390
|
# `at_exit` / stdio flush — the payload is already durable on disk by then.
|
|
391
391
|
def run_fork_worker(session, slice, out_path)
|
|
392
|
-
# Re-arm deferred YJIT in the child
|
|
393
|
-
#
|
|
394
|
-
#
|
|
395
|
-
#
|
|
396
|
-
#
|
|
397
|
-
#
|
|
398
|
-
|
|
399
|
-
# projects, so a worker slice reliably outlasts the deadline; a small-project worker finishes first and
|
|
400
|
-
# stays at no-YJIT parity, exactly as the sequential path would.
|
|
401
|
-
Runtime::Jit.enable_after(Runtime::Jit.deadline_seconds)
|
|
392
|
+
# Re-arm deferred YJIT in the child: the parent's deadline thread does not survive `fork`, so a worker
|
|
393
|
+
# forked before the deadline fires would otherwise run its whole (dominant) analysis slice un-JITted
|
|
394
|
+
# no matter how long it takes. {Runtime::Jit.rearm_after_fork} carries what is *left* of the parent's
|
|
395
|
+
# window, so the run's amortization deadline stays one deadline rather than one per worker. Parallel
|
|
396
|
+
# mode is opted into for large projects, so a slice reliably outlasts the remainder; a small-project
|
|
397
|
+
# worker finishes first and stays at no-YJIT parity, exactly as the sequential path would.
|
|
398
|
+
Runtime::Jit.rearm_after_fork
|
|
402
399
|
results = slice.to_h { |path| [path, session.analyze(path)] }
|
|
403
400
|
payload = { results: results, reporters: session.drain_reporters,
|
|
404
401
|
dependencies: session.drain_dependencies }
|
|
@@ -186,7 +186,9 @@ module Rigor
|
|
|
186
186
|
# Translates a prebuilt {ProjectScan} snapshot supplied to `Runner.new(prebuilt: ...)` into a
|
|
187
187
|
# {Result} the runner adopts the same way it adopts a fresh pre-pass run. The discovery tables are
|
|
188
188
|
# not part of the snapshot (the LSP path seeds an empty project scope), and `Runner#ensure_project_discovery`
|
|
189
|
-
# is a no-op under `prebuilt`, so they stay at their frozen-empty constructor defaults
|
|
189
|
+
# is a no-op under `prebuilt`, so they stay at their frozen-empty constructor defaults — unless the
|
|
190
|
+
# caller opted into `Runner.new(discovery_seed:)` (issue #260), which seeds the per-file scopes
|
|
191
|
+
# directly and leaves these ivars alone.
|
|
190
192
|
def adopt_prebuilt(scan)
|
|
191
193
|
Result.new(
|
|
192
194
|
plugin_registry: scan.plugin_registry,
|
|
@@ -40,6 +40,7 @@ require_relative "runner/run_snapshots"
|
|
|
40
40
|
require_relative "runner/project_pre_passes"
|
|
41
41
|
require_relative "runner/pool_coordinator"
|
|
42
42
|
require_relative "runner/diagnostic_aggregator"
|
|
43
|
+
require_relative "runner/buffer_pool_dispatcher"
|
|
43
44
|
|
|
44
45
|
module Rigor
|
|
45
46
|
module Analysis
|
|
@@ -95,12 +96,19 @@ module Rigor
|
|
|
95
96
|
# pass a shared env so per-publish work doesn't repeat the `Environment.for_project` build (bundler
|
|
96
97
|
# / lockfile / collection discovery, RbsLoader construction). Pool mode ignores the override — each
|
|
97
98
|
# worker continues to build its own Environment.
|
|
99
|
+
# @param discovery_seed [Hash, nil] issue #260 — opt-in cross-file discovery tables, keyed by
|
|
100
|
+
# {Scope::DiscoveryIndex} slot name, seeded onto every per-file scope through
|
|
101
|
+
# `project_scope_seed_tables`. The ONE deliberate exception to "a `prebuilt:` runner carries no
|
|
102
|
+
# discovery tables": {Protection::DiagnosticOracle} threads the table set Tier 2's site filter already
|
|
103
|
+
# judges anchors against, so a site admitted because a sibling-file class resolved is also a site the
|
|
104
|
+
# oracle can kill at. nil (the default) leaves the prebuilt/LSP contract byte-identical.
|
|
98
105
|
def initialize(configuration:, explain: false, # rubocop:disable Metrics/ParameterLists,Metrics/AbcSize,Metrics/MethodLength
|
|
99
106
|
cache_store: Cache::Store.new(root: DEFAULT_CACHE_ROOT),
|
|
100
107
|
plugin_requirer: nil, workers: 0, collect_stats: true,
|
|
101
108
|
buffer: nil, prebuilt: nil, environment: nil,
|
|
102
109
|
record_dependencies: false, record_self_calls: false, analyze_only: nil,
|
|
103
|
-
seed_bundles: nil, collect_seed_bundles: false
|
|
110
|
+
seed_bundles: nil, collect_seed_bundles: false, param_inferred_types: nil,
|
|
111
|
+
discovery_seed: nil)
|
|
104
112
|
@configuration = configuration
|
|
105
113
|
@explain = explain
|
|
106
114
|
@cache_store = enforce_read_only_cache(cache_store, buffer)
|
|
@@ -140,6 +148,16 @@ module Rigor
|
|
|
140
148
|
@collect_seed_bundles = collect_seed_bundles
|
|
141
149
|
@restored_seed_bundles = seed_bundles || {}
|
|
142
150
|
@seed_bundles = {}.freeze
|
|
151
|
+
# ADR-67 WD6c lift — a precomputed inferred-param table. When the incremental session already ran the
|
|
152
|
+
# collector (it must, to diff the table against its snapshot BEFORE deciding the re-analyse closure),
|
|
153
|
+
# it hands the result here so `seed_parameter_inference` seeds without a second whole-project collect
|
|
154
|
+
# — and so the table the diff was decided on and the table this run seeds from are the SAME object,
|
|
155
|
+
# not merely an equal recomputation. nil (the default) keeps the runner self-sufficient.
|
|
156
|
+
@param_inferred_types_override = param_inferred_types
|
|
157
|
+
# Issue #260 — the opt-in cross-file discovery seed (see the `discovery_seed:` doc above). Frozen and
|
|
158
|
+
# never mutated; `project_scope_seed_tables` starts from a copy of it and lets any table this run
|
|
159
|
+
# actually computed win.
|
|
160
|
+
@discovery_seed = discovery_seed&.freeze
|
|
143
161
|
@file_dependencies = {}
|
|
144
162
|
@plugin_registry = Plugin::Registry::EMPTY
|
|
145
163
|
@dependency_source_index = DependencySourceInference::Index::EMPTY
|
|
@@ -324,6 +342,32 @@ module Rigor
|
|
|
324
342
|
result.transform_values(&:freeze).freeze
|
|
325
343
|
end
|
|
326
344
|
|
|
345
|
+
# ADR-67 WD6c lift — the inferred-param table this run seeded from (frozen; empty when
|
|
346
|
+
# `parameter_inference:` is off or the pre-pass failed soft). The incremental session reads it back
|
|
347
|
+
# after a baseline so the snapshot records the seeds the cached diagnostics were computed under.
|
|
348
|
+
def param_inferred_types
|
|
349
|
+
@project_param_inferred_types
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# ADR-67 WD6c lift — computes the whole-project inferred-param table without running an analysis.
|
|
353
|
+
# The incremental session calls this BEFORE deciding its re-analyse closure: the table's diff against
|
|
354
|
+
# the snapshot's stored table is what invalidates a callee whose seeds moved because a *caller* file
|
|
355
|
+
# changed. Same collector invocation as {#seed_parameter_inference} (one round, same workers), so the
|
|
356
|
+
# session-computed table and an in-run collect are byte-identical by construction. Fails soft to the
|
|
357
|
+
# empty table — which the caller's diff then treats as "every stored entry removed", the conservative
|
|
358
|
+
# direction (those callees re-check).
|
|
359
|
+
def collect_param_inference_table(files)
|
|
360
|
+
return {}.freeze unless @configuration.parameter_inference
|
|
361
|
+
|
|
362
|
+
environment = @pool_coordinator.resolve_sequential_environment(source_files: files)
|
|
363
|
+
Inference::ParameterInferenceCollector.collect(
|
|
364
|
+
files: files, environment: environment,
|
|
365
|
+
target_ruby: @configuration.target_ruby, max_rounds: 1, workers: @workers
|
|
366
|
+
)
|
|
367
|
+
rescue StandardError
|
|
368
|
+
{}.freeze
|
|
369
|
+
end
|
|
370
|
+
|
|
327
371
|
# ADR-89 WD2 — the per-method observed-key return summaries the run's ADR-84 return memo just captured.
|
|
328
372
|
# For each project method with live memo entries, a bounded `{ keys:, returns:, effects: }` summary the
|
|
329
373
|
# incremental session persists: `keys` the observed `[receiver, arg_types]` type tuples, `returns` their
|
|
@@ -511,6 +555,11 @@ module Rigor
|
|
|
511
555
|
def seed_parameter_inference(expansion, environment)
|
|
512
556
|
return environment unless @configuration.parameter_inference
|
|
513
557
|
|
|
558
|
+
if @param_inferred_types_override
|
|
559
|
+
@project_param_inferred_types = @param_inferred_types_override
|
|
560
|
+
return environment
|
|
561
|
+
end
|
|
562
|
+
|
|
514
563
|
files = expansion.fetch(:files)
|
|
515
564
|
environment ||= @pool_coordinator.resolve_sequential_environment(source_files: files)
|
|
516
565
|
@project_param_inferred_types = Inference::ParameterInferenceCollector.collect(
|
|
@@ -638,7 +687,9 @@ module Rigor
|
|
|
638
687
|
# surface. The cross-file discovery tables are NOT carried here — `#run` (prebuilt-less) and
|
|
639
688
|
# `adopt_prebuilt` both leave them at their frozen-empty constructor defaults, and the analysis path
|
|
640
689
|
# fills them lazily via {#ensure_project_discovery}. The prebuilt (LSP) path never fills them, matching
|
|
641
|
-
# the original adopt behaviour that seeded an empty project scope
|
|
690
|
+
# the original adopt behaviour that seeded an empty project scope — with ONE deliberate exception, the
|
|
691
|
+
# opt-in `discovery_seed:` constructor seam (issue #260), which bypasses the ivars entirely and rides
|
|
692
|
+
# `project_scope_seed_tables`. A prebuilt runner constructed without it is unchanged.
|
|
642
693
|
def apply_pre_passes_result(result)
|
|
643
694
|
@plugin_registry = result.plugin_registry
|
|
644
695
|
@dependency_source_index = result.dependency_source_index
|
|
@@ -669,7 +720,9 @@ module Rigor
|
|
|
669
720
|
# Internal: builds the deferred cross-file discovery tables at most once per run and adopts them.
|
|
670
721
|
# Memoised on `@project_discovery_done` (reset at the start of `#run`). No-op under `@prebuilt` — the
|
|
671
722
|
# LSP path deliberately seeds an empty project scope from a snapshot that carries no discovery tables,
|
|
672
|
-
# so forcing a build there would change that contract.
|
|
723
|
+
# so forcing a build there would change that contract. A caller that DOES want cross-file knowledge under
|
|
724
|
+
# `prebuilt:` supplies it explicitly through `discovery_seed:` (issue #260) rather than by re-walking the
|
|
725
|
+
# project here. Called eagerly from `#run` for the recording /
|
|
673
726
|
# subset (ADR-46) modes and lazily from `#assemble_run_diagnostics` on the analysis path, so a warm
|
|
674
727
|
# cache HIT (which never assembles) never pays the double parse.
|
|
675
728
|
def ensure_project_discovery(expansion)
|
|
@@ -757,11 +810,18 @@ module Rigor
|
|
|
757
810
|
def target_files(expansion)
|
|
758
811
|
files = expansion.fetch(:files)
|
|
759
812
|
# ADR-46 slice 2 — restrict the analyzed set to the affected closure while the pre-pass (run
|
|
760
|
-
# separately over `expansion`'s full file list) keeps the cross-file index complete.
|
|
761
|
-
|
|
762
|
-
|
|
813
|
+
# separately over `expansion`'s full file list) keeps the cross-file index complete.
|
|
814
|
+
if @analyze_only
|
|
815
|
+
# Editor mode option B (#146) — with BOTH set, the closure wins and the buffer is one member of it.
|
|
816
|
+
# The logical path joins even when it is not on disk under `paths:` (design § "Failure envelope"),
|
|
817
|
+
# the same allowance option A makes below.
|
|
818
|
+
files = files.select { |path| @analyze_only.include?(path) }
|
|
819
|
+
files |= [@buffer.logical_path] if @buffer && @analyze_only.include?(@buffer.logical_path)
|
|
820
|
+
return files
|
|
821
|
+
end
|
|
763
822
|
return files if @buffer.nil?
|
|
764
823
|
|
|
824
|
+
# Editor mode option A — no closure, so the buffer's single logical path IS the analyzed set.
|
|
765
825
|
[@buffer.logical_path]
|
|
766
826
|
end
|
|
767
827
|
|
|
@@ -1023,8 +1083,12 @@ module Rigor
|
|
|
1023
1083
|
# The cross-file pre-pass tables {#seed_project_scope} applies, as a plain Hash so the fork-pool path
|
|
1024
1084
|
# can hand the same seed to its {WorkerSession} (whose per-file scopes would otherwise miss every
|
|
1025
1085
|
# cross-file def — ADR-15 sequential-equivalence contract).
|
|
1086
|
+
#
|
|
1087
|
+
# Issue #260 — an opt-in `discovery_seed:` is the BASE of the result, so every table this run computed
|
|
1088
|
+
# for itself still wins. Under `prebuilt:` (the only caller that passes one today) the run computes
|
|
1089
|
+
# none, so the seed applies wholesale.
|
|
1026
1090
|
def project_scope_seed_tables
|
|
1027
|
-
tables =
|
|
1091
|
+
tables = discovery_seed_base
|
|
1028
1092
|
# ADR-84 WD2 — the run-scope token rides the same seed so the fork/Ractor `WorkerSession` scopes
|
|
1029
1093
|
# bucket identically to the sequential path.
|
|
1030
1094
|
tables[:run_generation] = @run_generation if @run_generation
|
|
@@ -1055,6 +1119,13 @@ module Rigor
|
|
|
1055
1119
|
tables
|
|
1056
1120
|
end
|
|
1057
1121
|
|
|
1122
|
+
# Issue #260 — the mutable starting point {#project_scope_seed_tables} fills in: a copy of the opt-in
|
|
1123
|
+
# `discovery_seed:` when one was supplied, otherwise the empty Hash every other run has always started
|
|
1124
|
+
# from. Extracted so the seed costs {#project_scope_seed_tables} no branch of its complexity budget.
|
|
1125
|
+
def discovery_seed_base
|
|
1126
|
+
@discovery_seed ? @discovery_seed.dup : {}
|
|
1127
|
+
end
|
|
1128
|
+
|
|
1058
1129
|
# ADR-46 — seed the instance + singleton `"path:line"` def-source tables (each only when non-empty).
|
|
1059
1130
|
# Extracted to keep {#project_scope_seed_tables} under the complexity budget. The singleton table (slice 4
|
|
1060
1131
|
# extension) rides the same seed so a pooled `WorkerSession` records singleton symbol edges identically.
|