rigortype 0.2.8 → 0.2.9

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/csv.rbs +28 -0
  4. data/data/core_overlay/psych.rbs +22 -0
  5. data/docs/handbook/01-getting-started.md +9 -1
  6. data/docs/handbook/02-everyday-types.md +4 -1
  7. data/docs/handbook/08-understanding-errors.md +3 -3
  8. data/docs/manual/01-installation.md +1 -0
  9. data/docs/manual/02-cli-reference.md +16 -7
  10. data/docs/manual/07-plugins.md +1 -1
  11. data/docs/manual/14-rails-quickstart.md +4 -2
  12. data/docs/manual/15-type-protection-coverage.md +21 -0
  13. data/docs/manual/plugins/rigor-actionpack.md +1 -1
  14. data/docs/manual/plugins/rigor-activerecord.md +12 -5
  15. data/docs/manual/plugins/rigor-rails-routes.md +11 -0
  16. data/lib/rigor/cache/store.rb +174 -57
  17. data/lib/rigor/cli/coverage_command.rb +50 -29
  18. data/lib/rigor/cli/diagnostic_formats.rb +4 -1
  19. data/lib/rigor/cli/protection_fork_scan.rb +55 -0
  20. data/lib/rigor/cli/protection_report.rb +7 -1
  21. data/lib/rigor/environment/missing_gem_constant_index.rb +128 -0
  22. data/lib/rigor/environment.rb +54 -18
  23. data/lib/rigor/inference/expression_typer.rb +20 -2
  24. data/lib/rigor/inference/fork_map.rb +87 -0
  25. data/lib/rigor/inference/narrowing.rb +118 -0
  26. data/lib/rigor/inference/parameter_inference_collector.rb +55 -10
  27. data/lib/rigor/inference/scope_indexer.rb +9 -13
  28. data/lib/rigor/inference/statement_evaluator.rb +48 -3
  29. data/lib/rigor/version.rb +1 -1
  30. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +23 -8
  31. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +21 -0
  32. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +10 -1
  33. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +61 -0
  34. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +20 -2
  35. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +172 -0
  36. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +22 -8
  37. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +32 -0
  38. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +189 -0
  39. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +19 -1
  40. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +41 -10
  41. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +42 -12
  42. data/sig/rigor/environment.rbs +1 -0
  43. metadata +8 -1
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ class Environment
7
+ # ADR-82 WD9 — maps a top-level constant name to the locked, RBS-less gem that declares it, so an
8
+ # unresolved constant read (`Faraday`, `Sidekiq`) can carry the `external_gem_without_rbs` provenance
9
+ # cause instead of the generic `unsupported_syntax`. For a gem with no RBS the constant read is where the
10
+ # class name is last visible — by dispatch time the receiver is already `Dynamic[top]` — so this index is
11
+ # what lets `coverage --protection` route those holes to `add_rbs` honestly.
12
+ #
13
+ # Ownership is established by READING, never by guessing: each gem's conventional entry file
14
+ # (`lib/<name>.rb`, with the dash → directory variant) is parsed with Prism and its top-level
15
+ # class / module / constant declarations recorded under their root name. No gem code runs — the same
16
+ # posture as ADR-72's "loads RBS data only" and the ADR-10 walker. A name-derivation heuristic
17
+ # (`faraday` → `Faraday` by camelizing) is deliberately NOT used: it breaks on irregular names
18
+ # (`activesupport` → `ActiveSupport`) and is exactly the guessing the ADR-82 honesty criterion forbids.
19
+ #
20
+ # Everything fails OPEN to "no owner": a gem that is not installed, an entry file that does not exist or
21
+ # does not parse, a root constant declared only in a deeper file. An unindexed constant keeps today's
22
+ # generic cause — the failure mode is a missing label, never a wrong one.
23
+ #
24
+ # The scan is bounded to entry files (one or two per gem) so building the index over a real app's
25
+ # hundreds of RBS-less gems stays sub-second; it is built lazily on the first unresolved constant, so a
26
+ # project whose constants all resolve never pays it.
27
+ #
28
+ # **Gem-directory resolution.** Rigor runs under its OWN bundle (`BUNDLE_GEMFILE=<rigor>/Gemfile`), so
29
+ # `Gem::Specification.find_by_name` sees rigor's gems, not the target project's — it would resolve only
30
+ # the handful of gems both bundles happen to share (i18n, rack) and miss every project-specific gem
31
+ # (the very ones a Rails app's holes root at). So the primary resolver is the **target's bundle install
32
+ # tree** (`<bundle>/ruby/*/gems/<name>-<version>/`, the same pure-filesystem layout
33
+ # {BundleSigDiscovery} walks — no `Bundler` API, no gem code). `Gem::Specification` remains a last-resort
34
+ # fallback for a project installed against system gems with no discoverable bundle; reading a gem's own
35
+ # entry file for its own top-level namespace constant is version-stable, so even a rigor-vs-target
36
+ # version skew on a shared gem yields the same constant name.
37
+ module MissingGemConstantIndex
38
+ module_function
39
+
40
+ # @param gems [Enumerable<Array(String, String)>] `[gem_name, version]` pairs (the `:missing` rows of
41
+ # {RbsCoverageReport}).
42
+ # @param bundle_path [String, Pathname, nil] the target's resolved bundler install root, or nil.
43
+ # @param spec_resolver [#call] `(name, version) -> String?` fallback dir resolver. Injectable for
44
+ # specs; the default is the RubyGems-metadata lookup (no code load).
45
+ # @return [Hash{String => String}] frozen `root constant name => gem name`. On a collision (two gems
46
+ # declaring the same top-level constant) the first gem wins — the CAUSE recorded downstream
47
+ # (external gem without RBS) is true under either owner.
48
+ def build(gems, bundle_path: nil, spec_resolver: method(:installed_gem_dir))
49
+ bundle_dirs = bundle_gem_dirs(bundle_path)
50
+ index = {}
51
+ gems.each do |gem_name, version|
52
+ dir = bundle_dirs["#{gem_name}-#{version}"] || spec_resolver.call(gem_name, version)
53
+ next unless dir
54
+
55
+ entry_files(dir, gem_name).each do |file|
56
+ top_level_root_constants(file).each { |name| index[name] ||= gem_name }
57
+ end
58
+ end
59
+ index.freeze
60
+ end
61
+
62
+ # `{"<name>-<version>" => gem_dir}` for the target's bundle, or `{}` when no bundle is resolvable. The
63
+ # glob mirrors {BundleSigDiscovery}: `<bundle>/ruby/X.Y.Z/gems/<name>-<version>/`. Keyed on the dir
64
+ # basename so a platform-tagged variant (`ffi-1.17.4-aarch64-linux-gnu`) simply doesn't match a
65
+ # `<name>-<version>` lookup — those gems ship native code, not the pure-Ruby constants this indexes.
66
+ def bundle_gem_dirs(bundle_path)
67
+ return {} if bundle_path.nil?
68
+
69
+ base = Pathname.new(bundle_path)
70
+ return {} unless base.directory?
71
+
72
+ Dir.glob(base.join("ruby", "*", "gems", "*")).each_with_object({}) do |dir, acc|
73
+ acc[File.basename(dir)] ||= dir
74
+ end
75
+ end
76
+
77
+ # RubyGems spec metadata lookup — the gem's on-disk source root, without loading any of its code.
78
+ # Exact-version first, any-version fallback. See the class note on why this is only a fallback.
79
+ def installed_gem_dir(name, version)
80
+ spec = begin
81
+ Gem::Specification.find_by_name(name, "= #{version}")
82
+ rescue Gem::LoadError
83
+ begin
84
+ Gem::Specification.find_by_name(name)
85
+ rescue Gem::LoadError
86
+ nil
87
+ end
88
+ end
89
+ spec&.full_gem_path
90
+ end
91
+
92
+ # The conventional require targets for a gem name: `lib/foo.rb`, and `lib/foo/bar.rb` for a dashed
93
+ # `foo-bar`. This is the require-name convention Bundler.require depends on — a filename convention,
94
+ # not a constant-name guess; the constants come from parsing whichever of these exists.
95
+ def entry_files(dir, gem_name)
96
+ candidates = ["lib/#{gem_name}.rb"]
97
+ candidates << "lib/#{gem_name.tr('-', '/')}.rb" if gem_name.include?("-")
98
+ candidates.filter_map do |relative|
99
+ path = File.join(dir, relative)
100
+ path if File.file?(path)
101
+ end
102
+ end
103
+
104
+ # Root names of the file's top-level declarations. Only direct children of the program are read — the
105
+ # root constant of a gem's namespace is declared at file top level by construction (`module Faraday`,
106
+ # `class Money::Error < …` roots at `Money`), so no recursion into bodies is needed.
107
+ def top_level_root_constants(path)
108
+ result = Prism.parse(File.read(path))
109
+ return [] unless result.errors.empty?
110
+
111
+ result.value.statements.body.filter_map do |node|
112
+ case node
113
+ when Prism::ClassNode, Prism::ModuleNode then root_segment(node.constant_path)
114
+ when Prism::ConstantWriteNode then node.name.to_s
115
+ end
116
+ end
117
+ rescue StandardError
118
+ []
119
+ end
120
+
121
+ def root_segment(node)
122
+ current = node
123
+ current = current.parent while current.is_a?(Prism::ConstantPathNode) && current.parent
124
+ current.respond_to?(:name) && current.name ? current.name.to_s : nil
125
+ end
126
+ end
127
+ end
128
+ end
@@ -8,6 +8,7 @@ require_relative "environment/reflection"
8
8
  require_relative "environment/reporters"
9
9
  require_relative "environment/hkt_registry_holder"
10
10
  require_relative "environment/constant_type_cache_holder"
11
+ require_relative "environment/missing_gem_constant_index"
11
12
  require_relative "environment/bundle_sig_discovery"
12
13
  require_relative "environment/lockfile_resolver"
13
14
  require_relative "environment/rbs_collection_discovery"
@@ -84,7 +85,7 @@ module Rigor
84
85
  rbs_extended_reporter: nil, boundary_cross_reporter: nil,
85
86
  source_rbs_synthesis_reporter: nil,
86
87
  synthetic_method_index: nil, project_patched_methods: nil,
87
- hkt_registry: nil)
88
+ hkt_registry: nil, missing_rbs_gems: [], missing_rbs_bundle_path: nil)
88
89
  @class_registry = class_registry
89
90
  @rbs_loader = rbs_loader
90
91
  @plugin_registry = plugin_registry
@@ -109,6 +110,12 @@ module Rigor
109
110
  @hkt_registry_base = hkt_registry || Inference::HktRegistry::EMPTY
110
111
  @hkt_registry_holder = HktRegistryHolder.new
111
112
  @constant_type_cache = ConstantTypeCacheHolder.new
113
+ # ADR-82 WD9 — `[gem_name, version]` pairs for the locked gems with no resolvable RBS. The
114
+ # root-constant ownership index over them is built lazily (first unresolved constant read) so runs
115
+ # whose constants all resolve never pay the entry-file scan.
116
+ @missing_rbs_gems = missing_rbs_gems.freeze
117
+ @missing_rbs_bundle_path = missing_rbs_bundle_path
118
+ @missing_rbs_gem_constants_holder = HktRegistryHolder.new
112
119
  @name_scope = build_name_scope
113
120
  freeze
114
121
  end
@@ -133,6 +140,21 @@ module Rigor
133
140
  end
134
141
  end
135
142
 
143
+ # ADR-82 WD9 — the gem name owning `root_constant_name`, when that gem is locked in the project's
144
+ # Gemfile.lock, ships no resolvable RBS, and its entry file declares the constant at top level. Nil for
145
+ # everything else — the caller then keeps the generic provenance cause. The index is built once, lazily,
146
+ # from RubyGems metadata + a Prism parse of each gem's entry file; no gem code runs (see
147
+ # {MissingGemConstantIndex}). Under the fork pool a worker that never meets an unresolved constant never
148
+ # builds it.
149
+ def missing_rbs_gem_owner(root_constant_name)
150
+ return nil if @missing_rbs_gems.empty?
151
+
152
+ index = @missing_rbs_gem_constants_holder.fetch do
153
+ MissingGemConstantIndex.build(@missing_rbs_gems, bundle_path: @missing_rbs_bundle_path)
154
+ end
155
+ index[root_constant_name.to_s]
156
+ end
157
+
136
158
  # Backwards-compatible reporter accessors — every existing consumer (rbs_extended, method_dispatcher)
137
159
  # calls these. The frozen `@reporters` container is mutable for slot reassignment via
138
160
  # {#attach_reporters!} below.
@@ -220,6 +242,12 @@ module Rigor
220
242
  auto_detect: bundler_auto_detect,
221
243
  locked_gems: locked.empty? ? nil : locked
222
244
  ).map(&:to_s)
245
+ # ADR-82 WD9 — the resolved bundle root, so the missing-gem constant index reads each RBS-less gem's
246
+ # entry file from the TARGET's bundle (not rigor's own — see `MissingGemConstantIndex`). Resolved
247
+ # once here; passed through to the lazy index build.
248
+ bundle_root = BundleSigDiscovery.resolve_bundle_path(
249
+ bundle_path: bundler_bundle_path, project_root: root, auto_detect: bundler_auto_detect
250
+ )&.to_s
223
251
  # O4 Layer 3 slice 2 — when `rbs collection install` has been run for the target project, parse the
224
252
  # resulting `rbs_collection.lock.yaml` and feed each gem's `<collection_path>/<name>/<version>/`
225
253
  # directory into `signature_paths:`. Stdlib-typed entries are skipped (already covered by
@@ -248,10 +276,9 @@ module Rigor
248
276
  # diagnostic. Appended last so any RBS the project already supplies wins, and skipped for a gem whose
249
277
  # opt-in plugin twin is loaded (no duplicate declaration). The paths ride in
250
278
  # `loader_signature_paths`, so the env cache descriptor digests them for free.
251
- overlay_paths = gem_overlay_paths(
252
- locked: locked, default_libraries: merged_libraries,
253
- bundle_sig_paths: gem_sig_paths, rbs_collection_paths: collection_paths,
254
- plugin_registry: plugin_registry
279
+ missing_gems, overlay_paths = missing_gems_and_overlay_paths(
280
+ locked: locked, default_libraries: merged_libraries, bundle_sig_paths: gem_sig_paths,
281
+ rbs_collection_paths: collection_paths, plugin_registry: plugin_registry
255
282
  )
256
283
  loader_signature_paths = resolved_paths + plugin_sig_paths + gem_sig_paths +
257
284
  collection_paths + overlay_paths
@@ -286,7 +313,9 @@ module Rigor
286
313
  source_rbs_synthesis_reporter: source_rbs_synthesis_reporter,
287
314
  synthetic_method_index: synthetic_method_index,
288
315
  project_patched_methods: project_patched_methods,
289
- hkt_registry: Builtins::HktBuiltins.registry
316
+ hkt_registry: Builtins::HktBuiltins.registry,
317
+ missing_rbs_gems: missing_gems,
318
+ missing_rbs_bundle_path: bundle_root
290
319
  )
291
320
  end
292
321
  # rubocop:enable Metrics/MethodLength, Metrics/ParameterLists
@@ -298,22 +327,29 @@ module Rigor
298
327
  sig.directory? ? [sig] : []
299
328
  end
300
329
 
301
- # ADR-72 resolve the bundled RBS overlay directories to load for this project. A gem is eligible when
302
- # it is locked in the Gemfile.lock, classified `:missing` by {RbsCoverageReport} (no RBS through
303
- # default-library / vendored / bundle-`sig/` / rbs-collection), and its conflicting opt-in plugin (if
304
- # any) is not loaded. Returns `[Pathname]`, deterministically ordered, or `[]` when no lockfile / no
305
- # eligible gem.
306
- def gem_overlay_paths(locked:, default_libraries:, bundle_sig_paths:,
307
- rbs_collection_paths:, plugin_registry:)
308
- return [] if locked.empty?
309
-
310
- missing = RbsCoverageReport.classify(
330
+ # The `:missing` classification (locked gems with no RBS through any resolution path), computed once
331
+ # and consumed twice: as `[gem_name, version]` pairs for the ADR-82 WD9 constant-ownership index, and
332
+ # as the eligible-gem set for the ADR-72 overlay resolution. Returns `[pairs, overlay_paths]`.
333
+ def missing_gems_and_overlay_paths(locked:, default_libraries:, bundle_sig_paths:,
334
+ rbs_collection_paths:, plugin_registry:)
335
+ return [[], []] if locked.empty?
336
+
337
+ rows = RbsCoverageReport.classify(
311
338
  locked_gems: locked, default_libraries: default_libraries,
312
339
  bundle_sig_paths: bundle_sig_paths, rbs_collection_paths: rbs_collection_paths
313
- ).select { |row| row.source == :missing }.map(&:gem_name)
340
+ ).select { |row| row.source == :missing }
341
+ overlays = gem_overlay_paths(missing_gem_names: rows.map(&:gem_name), plugin_registry: plugin_registry)
342
+ [rows.map { |row| [row.gem_name, row.version] }, overlays]
343
+ end
344
+
345
+ # ADR-72 — resolve the bundled RBS overlay directories to load for this project. A gem is eligible when
346
+ # it is classified `:missing` (see {missing_rbs_gem_rows}) and its conflicting opt-in plugin (if any)
347
+ # is not loaded. Returns `[Pathname]`, deterministically ordered, or `[]` when no eligible gem.
348
+ def gem_overlay_paths(missing_gem_names:, plugin_registry:)
349
+ return [] if missing_gem_names.empty?
314
350
 
315
351
  loaded_ids = plugin_registry ? plugin_registry.ids.to_set : Set.new
316
- eligible = missing.reject do |gem_name|
352
+ eligible = missing_gem_names.reject do |gem_name|
317
353
  plugin_id = GEM_OVERLAY_PLUGIN_IDS[gem_name]
318
354
  plugin_id && loaded_ids.include?(plugin_id)
319
355
  end.sort
@@ -385,14 +385,32 @@ module Rigor
385
385
  # `Inference::FallbackTracer` from inside `Rigor::CLI::Foo` resolves to
386
386
  # `Rigor::Inference::FallbackTracer`.
387
387
  def type_of_constant_read(node)
388
- resolve_constant_name(node.name.to_s) || fallback_for(node, family: :prism)
388
+ resolve_constant_name(node.name.to_s) || unresolved_constant_fallback(node, node.name.to_s)
389
389
  end
390
390
 
391
391
  def type_of_constant_path(node)
392
392
  full_name = Source::ConstantPath.qualified_name_or_nil(node)
393
393
  return fallback_for(node, family: :prism) if full_name.nil?
394
394
 
395
- resolve_constant_name(full_name) || fallback_for(node, family: :prism)
395
+ resolve_constant_name(full_name) || unresolved_constant_fallback(node, full_name)
396
+ end
397
+
398
+ # ADR-82 WD9 — an unresolved constant whose root name a locked, RBS-less gem declares carries the
399
+ # `external_gem_without_rbs` cause instead of the generic `unsupported_syntax`. The constant read is
400
+ # where the class name is last visible (a no-RBS gem's receiver never types Nominal, so the dispatch
401
+ # tiers that record this cause under ADR-10 / `pre_eval:` opt-ins can't see it); WD6 chain inheritance
402
+ # then carries the cause through `Faraday.new.get(...)`. Side-channel only — the type stays the same
403
+ # `Dynamic[top]`, and an unindexed constant (project typo, unanalyzed project path) keeps the generic
404
+ # cause: the fail-open direction is a missing label, never a wrong one.
405
+ def unresolved_constant_fallback(node, full_name)
406
+ root = full_name.delete_prefix("::").split("::").first
407
+ owner = root && scope.environment.missing_rbs_gem_owner(root)
408
+ return fallback_for(node, family: :prism) unless owner
409
+
410
+ inner = dynamic_top
411
+ record_fallback(node, family: :prism, inner_type: inner, origin: DynamicOrigin::EXTERNAL_GEM_WITHOUT_RBS)
412
+ scope.record_dynamic_origin(node, DynamicOrigin::EXTERNAL_GEM_WITHOUT_RBS)
413
+ inner
396
414
  end
397
415
 
398
416
  # Try the literal name first, then walk Ruby's lexical lookup by progressively prefixing the surrounding
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+
5
+ module Rigor
6
+ module Inference
7
+ # Generic fork-over-slices map for the embarrassingly-parallel per-file passes of the
8
+ # `rigor coverage --protection` path — the ADR-67 {ParameterInferenceCollector} rounds and the
9
+ # {ProtectionScanner} scan (P3-10). The expensive shared state (the RBS environment, plugin registry,
10
+ # seeded scope, parsed ASTs) is built ONCE on the parent; children copy-on-write inherit it and each map
11
+ # a contiguous slice of `items` to a marshalable payload that the parent collects **in slice order**.
12
+ #
13
+ # A child that exits abnormally (crash, unmarshalable payload) has its slice re-run in-process, so the
14
+ # returned array is identical to a plain sequential `[block.call(items)]` — order-preserving and
15
+ # complete. Slice order is the caller's `items` order, so a caller that merges the payloads in array
16
+ # order reproduces a sequential run byte-for-byte.
17
+ #
18
+ # This owns only the fork mechanics; the parent-side prewarm (forcing the RBS env to fully build so
19
+ # children inherit it warm) and the payload merge are the caller's responsibility.
20
+ module ForkMap
21
+ module_function
22
+
23
+ # @param items [Array] work items (file paths, or `[path, ast]` pairs), in caller order.
24
+ # @param workers [Integer] resolved worker count (≤1, empty items, or no `fork` → sequential).
25
+ # @yield [Array] a contiguous slice of `items`; must return a **marshalable** object.
26
+ # @return [Array] the per-slice block results, in original slice order.
27
+ def call(items:, workers:, &block)
28
+ worker_count = [workers, items.size].min
29
+ return [block.call(items)] unless parallel?(worker_count) && !items.empty?
30
+
31
+ fork_map(items, worker_count, block)
32
+ end
33
+
34
+ def parallel?(worker_count)
35
+ worker_count > 1 && Process.respond_to?(:fork)
36
+ end
37
+
38
+ # Forks `worker_count` children over contiguous slices, waits for each, and returns the Marshal'd
39
+ # per-slice payloads in slice order. A child that exits abnormally has its slice re-run in-process.
40
+ def fork_map(items, worker_count, block)
41
+ slices = items.each_slice((items.size.to_f / worker_count).ceil).to_a
42
+ payloads = Array.new(slices.size)
43
+
44
+ Dir.mktmpdir("rigor-fork-map") do |tmpdir|
45
+ children = slices.each_with_index.map do |slice, index|
46
+ out_path = File.join(tmpdir, "worker-#{index}")
47
+ { pid: fork { run_worker(slice, block, out_path) }, index: index, slice: slice, out_path: out_path }
48
+ end
49
+ collect(children, payloads, block)
50
+ end
51
+
52
+ payloads
53
+ end
54
+
55
+ # Child-process body: run the block over the slice, Marshal the result to `out_path`, and `exit!`
56
+ # (skipping `at_exit` / stdio flush — the payload is durable on disk). Any failure exits non-zero so
57
+ # the parent re-runs the slice in-process.
58
+ def run_worker(slice, block, out_path)
59
+ File.binwrite(out_path, Marshal.dump(block.call(slice)))
60
+ exit!(0)
61
+ rescue StandardError
62
+ exit!(1)
63
+ end
64
+
65
+ # Waits for every child, placing each successful payload at its slice index; a child that exited
66
+ # abnormally has its slice re-run in-process (identical to the sequential result).
67
+ def collect(children, payloads, block)
68
+ children.each do |child|
69
+ _, status = Process.waitpid2(child[:pid])
70
+ payload = worker_payload(status, child[:out_path])
71
+ payloads[child[:index]] = payload.nil? ? block.call(child[:slice]) : payload.fetch(:value)
72
+ end
73
+ end
74
+
75
+ # @return [Hash{value: Object}, nil] the child's payload wrapped so a legitimately-nil block result is
76
+ # distinguishable from an abnormal exit. `Marshal.load` is safe: the blob was written by our own
77
+ # forked child to a temp file we created.
78
+ def worker_payload(status, out_path)
79
+ return nil unless status.success? && File.exist?(out_path)
80
+
81
+ { value: Marshal.load(File.binread(out_path)) } # rubocop:disable Security/MarshalLoad
82
+ rescue StandardError
83
+ nil
84
+ end
85
+ end
86
+ end
87
+ end
@@ -873,6 +873,15 @@ module Rigor
873
873
  string_predicate_result = analyse_string_predicate(node, scope)
874
874
  return apply_safe_nav_non_nil(node, scope, string_predicate_result) if string_predicate_result
875
875
 
876
+ # A predicate whose RBS says it always returns `false` for one union arm cannot have
877
+ # been called on that arm when it answered truthily. `NilClass#present?: () -> false`
878
+ # (ActiveSupport) is the motivating case: `if login.present?` must narrow `String | nil`
879
+ # to `String`, and nothing else in the catalogue does it — `present?` is a gem method the
880
+ # engine must not hardcode, and `rigor:v1:predicate-if-true` facts never reach a union
881
+ # receiver.
882
+ polarity_result = analyse_union_predicate_polarity(node, scope)
883
+ return polarity_result if polarity_result
884
+
876
885
  # A safe-navigation call (`v&.foo`) whose result is truthy proves the receiver was
877
886
  # non-nil — `&.` returns `nil` when the receiver is nil, so a truthy outcome can
878
887
  # only come from a non-nil receiver. Narrow the receiver on the truthy edge even
@@ -2352,6 +2361,115 @@ module Rigor
2352
2361
  end
2353
2362
  end
2354
2363
 
2364
+ # The three value-pinned classes a union arm can name whose instances are the single
2365
+ # value itself. Their finality is what makes the polarity rule below sound: no subclass
2366
+ # can override the predicate, because there is no subclass.
2367
+ POLARITY_ARM_CLASSES = { nil => "NilClass", true => "TrueClass", false => "FalseClass" }.freeze
2368
+ private_constant :POLARITY_ARM_CLASSES
2369
+
2370
+ # Drops union arms a zero-argument predicate's own signature rules out on each edge.
2371
+ #
2372
+ # `if x.present?` with `x: String | nil` must see `String` in the body. Rigor could not:
2373
+ # `present?` is an ActiveSupport method (so the engine must not join the hardcoded
2374
+ # `nil?` / `empty?` catalogue with it), and `resolve_rbs_extended_method` hands a union
2375
+ # receiver no `rigor:v1:predicate-if-true` facts, so annotating the RBS would not help
2376
+ # either. But the answer is already written down: the bundled ActiveSupport signature
2377
+ # declares `NilClass#present?: () -> false`, and a method that always returns `false` for
2378
+ # `nil` cannot have answered truthily on a `nil` receiver.
2379
+ #
2380
+ # Soundness rests on the arm being a value-pinned `nil` / `true` / `false`. For an
2381
+ # ordinary `Nominal[Foo]` arm the static type admits Foo's subclasses, any of which may
2382
+ # override the predicate and return the other polarity; the three classes here have no
2383
+ # subclasses, so the declared return is the runtime return.
2384
+ #
2385
+ # Safe navigation is excluded: `x&.blank?` yields `nil` (falsey) for a nil receiver
2386
+ # rather than `NilClass#blank?`'s declared `true`, so the falsey edge would wrongly drop
2387
+ # the nil arm. `analyse_safe_nav_receiver` below handles that shape's truthy edge.
2388
+ #
2389
+ # Returns `[truthy_scope, falsey_scope]`, or nil when no arm is ruled out, when an edge
2390
+ # would be emptied, or when the receiver has no scope binding to narrow.
2391
+ def analyse_union_predicate_polarity(node, scope)
2392
+ return nil if node.safe_navigation? || !argument_free?(node)
2393
+ return nil unless node.name.end_with?("?")
2394
+
2395
+ receiver_type = receiver_binding_type(node.receiver, scope)
2396
+ return nil unless receiver_type.is_a?(Type::Union)
2397
+
2398
+ truthy, falsey = partition_arms_by_polarity(receiver_type.members, node.name, scope)
2399
+ return nil if truthy.size == receiver_type.members.size && falsey.size == receiver_type.members.size
2400
+ return nil if truthy.empty? || falsey.empty?
2401
+
2402
+ [
2403
+ narrow_receiver_binding(node.receiver, scope, Type::Combinator.union(*truthy)),
2404
+ narrow_receiver_binding(node.receiver, scope, Type::Combinator.union(*falsey))
2405
+ ]
2406
+ end
2407
+
2408
+ # Splits `members` into the arms that survive the truthy edge and those that survive the
2409
+ # falsey edge. An arm whose polarity is unknown survives both.
2410
+ def partition_arms_by_polarity(members, method_name, scope)
2411
+ truthy = []
2412
+ falsey = []
2413
+ members.each do |arm|
2414
+ case arm_predicate_polarity(arm, method_name, scope)
2415
+ when :always_false then falsey << arm
2416
+ when :always_true then truthy << arm
2417
+ else
2418
+ truthy << arm
2419
+ falsey << arm
2420
+ end
2421
+ end
2422
+ [truthy, falsey]
2423
+ end
2424
+
2425
+ # `:always_false` / `:always_true` when every RBS overload of `method_name` on the arm's
2426
+ # class returns that literal; nil for any other arm, signature, or lookup failure.
2427
+ def arm_predicate_polarity(arm, method_name, scope)
2428
+ returns = arm_predicate_return_types(arm, method_name, scope)
2429
+ return nil if returns.nil? || returns.empty?
2430
+ return :always_false if returns.all? { |type| literal_boolean?(type, false) }
2431
+ return :always_true if returns.all? { |type| literal_boolean?(type, true) }
2432
+
2433
+ nil
2434
+ end
2435
+
2436
+ # Declared return type of every RBS overload of `method_name` on the arm's class, or nil
2437
+ # when the arm is not one of the three value-pinned classes or the lookup fails.
2438
+ def arm_predicate_return_types(arm, method_name, scope)
2439
+ return nil unless arm.is_a?(Type::Constant)
2440
+
2441
+ class_name = POLARITY_ARM_CLASSES[arm.value]
2442
+ return nil if class_name.nil?
2443
+
2444
+ definition = Rigor::Reflection.instance_method_definition(class_name, method_name, scope: scope)
2445
+ definition&.method_types&.map { |method_type| method_type.type.return_type }
2446
+ rescue StandardError
2447
+ nil
2448
+ end
2449
+
2450
+ def literal_boolean?(rbs_type, value)
2451
+ rbs_type.is_a?(RBS::Types::Literal) && rbs_type.literal == value
2452
+ end
2453
+
2454
+ # The four receiver shapes with a scope binding the edges can rebind, mirroring
2455
+ # `apply_self_fact`. A method chain or arbitrary expression has none, so it reads nil and
2456
+ # the caller declines.
2457
+ def receiver_binding_type(receiver, scope)
2458
+ case receiver
2459
+ when Prism::LocalVariableReadNode then scope.local(receiver.name)
2460
+ when Prism::InstanceVariableReadNode then scope.ivar(receiver.name)
2461
+ when Prism::SelfNode then scope.self_type
2462
+ end
2463
+ end
2464
+
2465
+ def narrow_receiver_binding(receiver, scope, narrowed)
2466
+ case receiver
2467
+ when Prism::LocalVariableReadNode then scope.with_local(receiver.name, narrowed)
2468
+ when Prism::InstanceVariableReadNode then scope.with_ivar(receiver.name, narrowed)
2469
+ when Prism::SelfNode then scope.with_self_type(narrowed)
2470
+ end
2471
+ end
2472
+
2355
2473
  # Narrows a safe-navigation call's receiver (`v&.foo`) to its non-nil fragment on the
2356
2474
  # truthy edge, returning `[truthy, falsey]` or nil when nothing applies (not safe-nav,
2357
2475
  # opaque receiver, or already non-nil). Used standalone for a bare `v&.foo` truthy edge
@@ -3,6 +3,7 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "scope_indexer"
6
+ require_relative "fork_map"
6
7
  require_relative "../source/node_walker"
7
8
 
8
9
  module Rigor
@@ -120,15 +121,20 @@ module Rigor
120
121
  # @param target_ruby [String, nil] Prism parse target.
121
122
  # @param max_rounds [Integer] the WD5 fixpoint cap (1 = single-level).
122
123
  # @return [Hash{[String,Symbol,Symbol] => Hash{Symbol => Rigor::Type}}] frozen.
123
- def self.collect(files:, environment:, target_ruby: nil, max_rounds: DEFAULT_ROUNDS)
124
- new(files: files, environment: environment, target_ruby: target_ruby, max_rounds: max_rounds).collect
124
+ def self.collect(files:, environment:, target_ruby: nil, max_rounds: DEFAULT_ROUNDS, workers: 0)
125
+ new(files: files, environment: environment, target_ruby: target_ruby,
126
+ max_rounds: max_rounds, workers: workers).collect
125
127
  end
126
128
 
127
- def initialize(files:, environment:, target_ruby: nil, max_rounds: DEFAULT_ROUNDS)
129
+ def initialize(files:, environment:, target_ruby: nil, max_rounds: DEFAULT_ROUNDS, workers: 0)
128
130
  @files = files
129
131
  @environment = environment
130
132
  @target_ruby = target_ruby
131
133
  @max_rounds = max_rounds
134
+ # P3-10 — fork-parallelism for the per-round re-typing (the dominant coverage-protection cost). A
135
+ # round's per-file typing is independent; contributions merge associatively (see {#merge_round}), so
136
+ # forking over file slices is byte-identical to the sequential pass. 0/1 → sequential.
137
+ @workers = workers
132
138
  # Reset per round (see {#run_round}). `[[class, method, kind], param_sym]` => [Type] of
133
139
  # observed concrete arguments (a default-block Hash, not a `{}` literal, so the
134
140
  # analyzer types its reads generically — {#finalize}), plus the ids widened to
@@ -139,6 +145,10 @@ module Rigor
139
145
 
140
146
  def collect
141
147
  parsed = parse_all
148
+ # Force the full RBS load on the parent so forked round-workers copy-on-write inherit a warm
149
+ # environment instead of each rebuilding it (mirrors the check / scan fork pools). A no-op on the
150
+ # sequential path.
151
+ @environment.rbs_loader&.prewarm if ForkMap.parallel?([@workers, parsed.size].min)
142
152
  discovery = discovery_seed_tables
143
153
  table = EMPTY
144
154
  @max_rounds.times do
@@ -164,18 +174,51 @@ module Rigor
164
174
  end
165
175
 
166
176
  # One fixpoint round: re-type every file with `seed_table` (the previous round's inferred
167
- # parameters) seeded, collecting the next round's table.
177
+ # parameters) seeded, collecting the next round's table. The per-file typing is fork-mapped over
178
+ # `@workers` (byte-identical to sequential — see {#merge_round}); each slice returns a marshalable
179
+ # `[observations, poisoned]` contribution the parent merges in slice order.
168
180
  def run_round(parsed, discovery_tables, seed_table)
181
+ seed_scope = build_seed_scope(discovery_tables, seed_table)
182
+ contributions = ForkMap.call(items: parsed, workers: @workers) do |slice|
183
+ accumulate_slice(slice, seed_scope)
184
+ end
185
+ merge_round(contributions)
186
+ end
187
+
188
+ # Types every call in one contiguous file slice, returning a marshalable contribution: the per-`id`
189
+ # observed argument types (default proc stripped for `Marshal`) and the poisoned-`id` list. Uses the
190
+ # per-process observation ivars, so a forked worker and the sequential single-slice call share this
191
+ # exact code.
192
+ def accumulate_slice(parsed_slice, seed_scope)
169
193
  @type_observations = Hash.new { |hash, id| hash[id] = [] }
170
194
  @poisoned_params = Set.new
171
- seed_scope = build_seed_scope(discovery_tables, seed_table)
172
- parsed.each do |path, ast|
195
+ parsed_slice.each do |path, ast|
173
196
  index = ScopeIndexer.index(ast, default_scope: seed_scope.with_source_path(path))
174
197
  Source::NodeWalker.each(ast) do |node|
175
198
  record_call(node, index) if node.is_a?(Prism::CallNode)
176
199
  end
177
200
  end
178
- finalize
201
+ # Copy into a plain Hash, dropping the default proc — a Marshal-unfriendly proc the fork worker
202
+ # would otherwise fail to dump. (`.to_h` returns self here, keeping the proc, so copy explicitly.)
203
+ observations = @type_observations.each_with_object({}) { |(id, types), plain| plain[id] = types }
204
+ [observations, @poisoned_params.to_a]
205
+ end
206
+
207
+ # Merges the per-slice contributions into the round's table. Associative and order-preserving, so the
208
+ # result is identical to a sequential single-slice run: a parameter is poisoned if ANY slice poisoned
209
+ # it, its observations are the file-order concatenation across slices, and the {MAX_CALL_SITE_TYPES}
210
+ # cap re-applies over the merged total (a per-slice sub-cap can undercount, so the parent enforces the
211
+ # real cap — the same "poisoned" outcome the sequential mid-stream cap reaches).
212
+ def merge_round(contributions)
213
+ poisoned = contributions.each_with_object(Set.new) { |(_obs, pois), set| set.merge(pois) }
214
+ observations = {}
215
+ contributions.each do |(obs, _pois)|
216
+ obs.each do |id, types|
217
+ (observations[id] ||= []).concat(types) unless poisoned.include?(id)
218
+ end
219
+ end
220
+ observations.each { |id, types| poisoned << id if types.length > MAX_CALL_SITE_TYPES }
221
+ finalize(observations, poisoned)
179
222
  end
180
223
 
181
224
  # A scope carrying the cross-file discovery index (so `Foo.new` receivers and
@@ -343,13 +386,15 @@ module Rigor
343
386
  @type_observations.delete(id)
344
387
  end
345
388
 
346
- def finalize
389
+ # Builds the round's frozen `[class, method, kind] => {param => Type}` table from the merged
390
+ # observations and poisoned set.
391
+ def finalize(merged_observations, poisoned)
347
392
  # `result` is a default-block Hash (not a `{}` literal) so the analyzer types its reads
348
393
  # generically rather than folding the empty shape — the nesting writes stay plain
349
394
  # assignments, no literal-fold conditions.
350
395
  result = Hash.new { |hash, key| hash[key] = {} }
351
- @type_observations.each do |id, observations|
352
- next if @poisoned_params.include?(id)
396
+ merged_observations.each do |id, observations|
397
+ next if poisoned.include?(id)
353
398
  next if observations.empty?
354
399
 
355
400
  union = Type::Combinator.union(*observations)