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
@@ -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
- Diagnostic.new(
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. The parent (`check` / `coverage`) armed `enable_after` on a
393
- # background thread, but `fork` copies only the calling thread the sleeping deadline thread does not
394
- # survive into the child, so a worker forked before the deadline fires would otherwise run its whole
395
- # (dominant) analysis slice un-JITted no matter how long it takes. If the parent already enabled YJIT
396
- # before forking (a slow env build pushed the fork past the deadline), the child inherited the enabled
397
- # state and this is a no-op; otherwise the child times its own slice and JITs the tail once it proves
398
- # long enough — the same amortization contract, per worker. Parallel mode is opted into for large
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. Called eagerly from `#run` for the recording /
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. Buffer mode
761
- # takes precedence — its single logical path is the analyzed set.
762
- files = files.select { |path| @analyze_only.include?(path) } if @analyze_only
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.