rigortype 0.1.6 → 0.1.8
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 +40 -29
- data/lib/rigor/analysis/baseline.rb +347 -0
- data/lib/rigor/analysis/check_rules.rb +60 -3
- data/lib/rigor/analysis/diagnostic.rb +17 -3
- data/lib/rigor/analysis/runner.rb +178 -3
- data/lib/rigor/analysis/worker_session.rb +14 -3
- data/lib/rigor/builtins/static_return_refinements.rb +23 -1
- data/lib/rigor/cli/baseline_command.rb +377 -0
- data/lib/rigor/cli/triage_command.rb +83 -0
- data/lib/rigor/cli/triage_renderer.rb +77 -0
- data/lib/rigor/cli.rb +78 -3
- data/lib/rigor/configuration.rb +21 -1
- data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
- data/lib/rigor/environment/rbs_loader.rb +22 -0
- data/lib/rigor/environment.rb +13 -0
- data/lib/rigor/flow_contribution/fact.rb +20 -10
- data/lib/rigor/inference/expression_typer.rb +152 -14
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +57 -11
- data/lib/rigor/inference/method_dispatcher.rb +50 -5
- data/lib/rigor/inference/narrowing.rb +103 -1
- data/lib/rigor/inference/scope_indexer.rb +209 -13
- data/lib/rigor/inference/statement_evaluator.rb +91 -10
- data/lib/rigor/plugin/macro/heredoc_template.rb +2 -2
- data/lib/rigor/plugin/macro/trait_registry.rb +1 -1
- data/lib/rigor/scope.rb +46 -0
- data/lib/rigor/triage/catalogue.rb +296 -0
- data/lib/rigor/triage/hint.rb +27 -0
- data/lib/rigor/triage.rb +89 -0
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +2 -0
- data/sig/rigor/inference.rbs +1 -0
- data/sig/rigor/scope.rbs +6 -0
- data/sig/rigor.rbs +1 -0
- metadata +8 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "prism"
|
|
4
|
+
require "tmpdir"
|
|
4
5
|
|
|
5
6
|
require_relative "../environment"
|
|
6
7
|
require_relative "../scope"
|
|
@@ -104,6 +105,9 @@ module Rigor
|
|
|
104
105
|
@signature_paths_snapshot = [].freeze
|
|
105
106
|
@cached_plugin_prepare_diagnostics = [].freeze
|
|
106
107
|
@project_discovered_classes = {}.freeze
|
|
108
|
+
@project_discovered_def_nodes = {}.freeze
|
|
109
|
+
@project_discovered_superclasses = {}.freeze
|
|
110
|
+
@project_discovered_includes = {}.freeze
|
|
107
111
|
end
|
|
108
112
|
|
|
109
113
|
# ADR-pending editor mode — present when the runner is wired
|
|
@@ -241,6 +245,16 @@ module Rigor
|
|
|
241
245
|
# can share parses with the existing scanner passes.
|
|
242
246
|
@project_discovered_classes =
|
|
243
247
|
Inference::ScopeIndexer.discovered_classes_for_paths(expansion.fetch(:files), buffer: @buffer)
|
|
248
|
+
# ADR-24 slice 2 — cross-file def-node + class->superclass
|
|
249
|
+
# index so an implicit-self call inside a subclass
|
|
250
|
+
# resolves a superclass `def` declared in a sibling
|
|
251
|
+
# file. One extra parse pass over the project; shares
|
|
252
|
+
# the cost profile of the class-discovery pass above.
|
|
253
|
+
def_index =
|
|
254
|
+
Inference::ScopeIndexer.discovered_def_index_for_paths(expansion.fetch(:files), buffer: @buffer)
|
|
255
|
+
@project_discovered_def_nodes = def_index.fetch(:def_nodes)
|
|
256
|
+
@project_discovered_superclasses = def_index.fetch(:superclasses)
|
|
257
|
+
@project_discovered_includes = def_index.fetch(:includes)
|
|
244
258
|
end
|
|
245
259
|
|
|
246
260
|
# Internal: adopts a frozen {ProjectScan} snapshot supplied
|
|
@@ -278,7 +292,7 @@ module Rigor
|
|
|
278
292
|
return [] if files.empty?
|
|
279
293
|
|
|
280
294
|
if pool_mode?
|
|
281
|
-
|
|
295
|
+
dispatch_pool(files)
|
|
282
296
|
else
|
|
283
297
|
environment = resolve_sequential_environment
|
|
284
298
|
result = files.flat_map { |path| analyze_file(path, environment) }
|
|
@@ -460,6 +474,34 @@ module Rigor
|
|
|
460
474
|
@buffer.nil?
|
|
461
475
|
end
|
|
462
476
|
|
|
477
|
+
# ADR-15 Amendment (2026-05-20) — worker-pool backend selector.
|
|
478
|
+
# `fork` is the active backend: separate processes sidestep both
|
|
479
|
+
# the Ruby Bug #22075 use-after-free and the worker-side
|
|
480
|
+
# `Ractor::IsolationError` that make the Ractor pool unusable
|
|
481
|
+
# (see the ADR-15 Amendment +
|
|
482
|
+
# docs/notes/20260520-ractor-pool-cruby-uaf.md). The Ractor pool
|
|
483
|
+
# is preserved but off the default path — `RIGOR_POOL_BACKEND=ractor`
|
|
484
|
+
# opts back in so it stays testable. Platforms without `fork`
|
|
485
|
+
# (Windows) fall back to sequential.
|
|
486
|
+
def pool_backend
|
|
487
|
+
return :ractor if ENV["RIGOR_POOL_BACKEND"] == "ractor"
|
|
488
|
+
return :fork if Process.respond_to?(:fork)
|
|
489
|
+
|
|
490
|
+
:sequential
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Routes pool-mode analysis to the selected backend.
|
|
494
|
+
def dispatch_pool(files)
|
|
495
|
+
case pool_backend
|
|
496
|
+
when :ractor then analyze_files_in_pool(files)
|
|
497
|
+
when :fork then analyze_files_in_fork_pool(files)
|
|
498
|
+
else
|
|
499
|
+
analyze_files_sequentially_fallback(
|
|
500
|
+
files, reason: "fork-based parallelism is unavailable on this platform"
|
|
501
|
+
)
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
|
|
463
505
|
# Coordinator-side Environment used by the sequential code
|
|
464
506
|
# path. Pool mode builds one Environment per worker inside
|
|
465
507
|
# the worker Ractor's body instead.
|
|
@@ -598,6 +640,122 @@ module Rigor
|
|
|
598
640
|
Array(prepare_diagnostics) + files.flat_map { |path| results_by_path.fetch(path, []) }
|
|
599
641
|
end
|
|
600
642
|
|
|
643
|
+
# ADR-15 Amendment (2026-05-20) — fork-based worker pool, the
|
|
644
|
+
# active backend for `workers > 0`. Builds ONE {WorkerSession}
|
|
645
|
+
# on the parent, then `fork`s N children that copy-on-write
|
|
646
|
+
# inherit it. Each child analyses a contiguous slice of `files`
|
|
647
|
+
# and writes a Marshal'd `{results:, reporters:}` payload to a
|
|
648
|
+
# temp file; the parent `Process.wait`s every child, merges the
|
|
649
|
+
# payloads, and re-orders diagnostics by original path order.
|
|
650
|
+
#
|
|
651
|
+
# Separate processes have separate GC heaps and `vm->ci_table`
|
|
652
|
+
# (immune to Ruby Bug #22075) and copy-on-write-inherit every
|
|
653
|
+
# constant (no `Ractor.shareable?` constraint). See the ADR-15
|
|
654
|
+
# Amendment + docs/notes/20260520-ractor-pool-cruby-uaf.md.
|
|
655
|
+
#
|
|
656
|
+
# A child that exits non-zero (crash / unmarshalable payload) is
|
|
657
|
+
# degraded: the parent re-analyses that slice in-process and
|
|
658
|
+
# prepends a `pool-degraded` warning.
|
|
659
|
+
def analyze_files_in_fork_pool(files) # rubocop:disable Metrics/AbcSize
|
|
660
|
+
Environment::ClassRegistry.default
|
|
661
|
+
|
|
662
|
+
session = WorkerSession.new(
|
|
663
|
+
configuration: @configuration,
|
|
664
|
+
cache_store: @cache_store,
|
|
665
|
+
plugin_blueprints: @plugin_registry.blueprints,
|
|
666
|
+
explain: @explain,
|
|
667
|
+
synthetic_method_index: @synthetic_method_index,
|
|
668
|
+
project_patched_methods: @project_patched_methods
|
|
669
|
+
)
|
|
670
|
+
# Force the full RBS load on the parent so children
|
|
671
|
+
# copy-on-write inherit a warm Environment rather than each
|
|
672
|
+
# rebuilding it after the fork.
|
|
673
|
+
session.environment.rbs_loader&.prewarm
|
|
674
|
+
snapshot_fork_pool_stats(session) if @collect_stats
|
|
675
|
+
|
|
676
|
+
worker_count = [@workers, files.size].min
|
|
677
|
+
slices = files.each_slice((files.size.to_f / worker_count).ceil).to_a
|
|
678
|
+
results_by_path = {}
|
|
679
|
+
|
|
680
|
+
degraded = Dir.mktmpdir("rigor-fork-pool") do |tmpdir|
|
|
681
|
+
children = slices.each_with_index.map do |slice, index|
|
|
682
|
+
out_path = File.join(tmpdir, "worker-#{index}")
|
|
683
|
+
{ pid: fork { run_fork_worker(session, slice, out_path) },
|
|
684
|
+
slice: slice, out_path: out_path }
|
|
685
|
+
end
|
|
686
|
+
collect_fork_results(children, results_by_path)
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
unless degraded.empty?
|
|
690
|
+
degraded.each { |path| results_by_path[path] = session.analyze(path) }
|
|
691
|
+
merge_worker_reporters(session.drain_reporters)
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
diagnostics = Array(session.prepare_diagnostics) +
|
|
695
|
+
files.flat_map { |path| results_by_path.fetch(path, []) }
|
|
696
|
+
degraded.empty? ? diagnostics : diagnostics.unshift(fork_degraded_diagnostic(degraded.size))
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
# Child-process body for {#analyze_files_in_fork_pool}. Analyses
|
|
700
|
+
# the slice with the copy-on-write-inherited session and writes
|
|
701
|
+
# the Marshal'd payload to `out_path`. `exit!` skips `at_exit` /
|
|
702
|
+
# stdio flush — the payload is already durable on disk by then.
|
|
703
|
+
def run_fork_worker(session, slice, out_path)
|
|
704
|
+
results = slice.to_h { |path| [path, session.analyze(path)] }
|
|
705
|
+
payload = { results: results, reporters: session.drain_reporters }
|
|
706
|
+
File.binwrite(out_path, Marshal.dump(payload))
|
|
707
|
+
exit!(0)
|
|
708
|
+
rescue StandardError
|
|
709
|
+
exit!(1)
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
# Snapshots `class_decl_paths` from the parent session's loader
|
|
713
|
+
# so end-of-run {RunStats} can attribute the RBS class universe.
|
|
714
|
+
def snapshot_fork_pool_stats(session)
|
|
715
|
+
loader = session.environment.rbs_loader
|
|
716
|
+
@class_decl_paths_snapshot = loader&.class_decl_paths || {}.freeze
|
|
717
|
+
@signature_paths_snapshot = loader&.signature_paths || [].freeze
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
# Waits for every forked child, merges each successful payload
|
|
721
|
+
# into `results_by_path`, and returns the file paths whose
|
|
722
|
+
# worker exited abnormally (for in-process degrade).
|
|
723
|
+
def collect_fork_results(children, results_by_path)
|
|
724
|
+
degraded = []
|
|
725
|
+
children.each do |child|
|
|
726
|
+
_, status = Process.waitpid2(child[:pid])
|
|
727
|
+
payload = fork_worker_payload(status, child[:out_path])
|
|
728
|
+
if payload
|
|
729
|
+
results_by_path.merge!(payload.fetch(:results))
|
|
730
|
+
merge_worker_reporters(payload.fetch(:reporters))
|
|
731
|
+
else
|
|
732
|
+
degraded.concat(child[:slice])
|
|
733
|
+
end
|
|
734
|
+
end
|
|
735
|
+
degraded
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
# @return [Hash, nil] the child's `{results:, reporters:}`
|
|
739
|
+
# payload, or nil when the child exited abnormally or wrote no
|
|
740
|
+
# readable payload. `Marshal.load` is safe here: the blob was
|
|
741
|
+
# written by our own forked child to a temp file we created.
|
|
742
|
+
def fork_worker_payload(status, out_path)
|
|
743
|
+
return nil unless status.success? && File.exist?(out_path)
|
|
744
|
+
|
|
745
|
+
Marshal.load(File.binread(out_path)) # rubocop:disable Security/MarshalLoad
|
|
746
|
+
rescue StandardError
|
|
747
|
+
nil
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
def fork_degraded_diagnostic(count)
|
|
751
|
+
Diagnostic.new(
|
|
752
|
+
path: ".rigor.yml", line: 1, column: 1,
|
|
753
|
+
message: "fork pool degraded: #{count} file(s) re-analysed in-process " \
|
|
754
|
+
"after a worker exited abnormally",
|
|
755
|
+
severity: :warning, rule: "pool-degraded", source_family: :builtin
|
|
756
|
+
)
|
|
757
|
+
end
|
|
758
|
+
|
|
601
759
|
# End-of-run telemetry. Walks the cached
|
|
602
760
|
# `class_decl_paths` snapshot (sequential mode: from
|
|
603
761
|
# the coordinator's environment; pool mode: from the
|
|
@@ -1221,12 +1379,29 @@ module Rigor
|
|
|
1221
1379
|
Prism.parse(File.read(physical), filepath: path, version: @configuration.target_ruby)
|
|
1222
1380
|
end
|
|
1223
1381
|
|
|
1382
|
+
# Seeds the cross-file project pre-pass indexes onto a
|
|
1383
|
+
# fresh per-file scope: discovered classes, and the ADR-24
|
|
1384
|
+
# def-node / superclass / included-module maps. Each is
|
|
1385
|
+
# applied only when non-empty so a runner constructed
|
|
1386
|
+
# without the project pre-pass (e.g. a single-file probe)
|
|
1387
|
+
# keeps an empty seed.
|
|
1388
|
+
def seed_project_scope(scope)
|
|
1389
|
+
scope = scope.with_discovered_classes(@project_discovered_classes) unless @project_discovered_classes.empty?
|
|
1390
|
+
unless @project_discovered_def_nodes.empty?
|
|
1391
|
+
scope = scope.with_discovered_def_nodes(@project_discovered_def_nodes)
|
|
1392
|
+
end
|
|
1393
|
+
unless @project_discovered_superclasses.empty?
|
|
1394
|
+
scope = scope.with_discovered_superclasses(@project_discovered_superclasses)
|
|
1395
|
+
end
|
|
1396
|
+
scope = scope.with_discovered_includes(@project_discovered_includes) unless @project_discovered_includes.empty?
|
|
1397
|
+
scope
|
|
1398
|
+
end
|
|
1399
|
+
|
|
1224
1400
|
def analyze_file(path, environment) # rubocop:disable Metrics/MethodLength
|
|
1225
1401
|
parse_result = parse_source(path)
|
|
1226
1402
|
return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
|
|
1227
1403
|
|
|
1228
|
-
scope = Scope.empty(environment: environment, source_path: path)
|
|
1229
|
-
scope = scope.with_discovered_classes(@project_discovered_classes) unless @project_discovered_classes.empty?
|
|
1404
|
+
scope = seed_project_scope(Scope.empty(environment: environment, source_path: path))
|
|
1230
1405
|
index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
|
|
1231
1406
|
diagnostics = CheckRules.diagnose(
|
|
1232
1407
|
path: path,
|
|
@@ -38,6 +38,12 @@ module Rigor
|
|
|
38
38
|
# - `plugin_blueprints` — Phase 3a
|
|
39
39
|
# (`Array<Plugin::Blueprint>` is `Ractor.shareable?`).
|
|
40
40
|
# - `explain` — Boolean.
|
|
41
|
+
# - `synthetic_method_index` / `project_patched_methods` —
|
|
42
|
+
# optional (default `nil`). NOT `Ractor.shareable?`, so the
|
|
43
|
+
# Ractor pool path leaves them unset; the fork backend
|
|
44
|
+
# (ADR-15 Amendment), which builds the session pre-fork on the
|
|
45
|
+
# parent, threads the runner's project-scan results through so
|
|
46
|
+
# per-file inference matches the sequential path exactly.
|
|
41
47
|
#
|
|
42
48
|
# Internally the session OWNS (and never shares):
|
|
43
49
|
#
|
|
@@ -70,7 +76,7 @@ module Rigor
|
|
|
70
76
|
# output (modulo severity-profile re-stamping, which the
|
|
71
77
|
# session leaves to the caller because it is a per-run
|
|
72
78
|
# aggregate concern).
|
|
73
|
-
class WorkerSession
|
|
79
|
+
class WorkerSession # rubocop:disable Metrics/ClassLength
|
|
74
80
|
attr_reader :configuration, :cache_store, :services, :plugin_registry,
|
|
75
81
|
:dependency_source_index, :environment,
|
|
76
82
|
:rbs_extended_reporter, :boundary_cross_reporter,
|
|
@@ -88,11 +94,14 @@ module Rigor
|
|
|
88
94
|
# directly-unrecognised node, mirroring
|
|
89
95
|
# {Rigor::Analysis::Runner#explain_diagnostics}.
|
|
90
96
|
def initialize(configuration:, cache_store: nil, # rubocop:disable Metrics/MethodLength
|
|
91
|
-
plugin_blueprints: [], explain: false, buffer: nil
|
|
97
|
+
plugin_blueprints: [], explain: false, buffer: nil,
|
|
98
|
+
synthetic_method_index: nil, project_patched_methods: nil)
|
|
92
99
|
@configuration = configuration
|
|
93
100
|
@cache_store = cache_store
|
|
94
101
|
@explain = explain
|
|
95
102
|
@buffer = buffer
|
|
103
|
+
@synthetic_method_index = synthetic_method_index
|
|
104
|
+
@project_patched_methods = project_patched_methods
|
|
96
105
|
|
|
97
106
|
# NOTE: `Inference::MethodDispatcher::FileFolding.fold_platform_specific_paths`
|
|
98
107
|
# is process-global state. Writing it from a non-main
|
|
@@ -127,7 +136,9 @@ module Rigor
|
|
|
127
136
|
bundler_auto_detect: configuration.bundler_auto_detect,
|
|
128
137
|
bundler_lockfile: configuration.bundler_lockfile,
|
|
129
138
|
rbs_collection_lockfile: configuration.rbs_collection_lockfile,
|
|
130
|
-
rbs_collection_auto_detect: configuration.rbs_collection_auto_detect
|
|
139
|
+
rbs_collection_auto_detect: configuration.rbs_collection_auto_detect,
|
|
140
|
+
synthetic_method_index: @synthetic_method_index,
|
|
141
|
+
project_patched_methods: @project_patched_methods
|
|
131
142
|
)
|
|
132
143
|
@prepare_diagnostics = run_plugin_prepare.freeze
|
|
133
144
|
end
|
|
@@ -53,6 +53,9 @@ module Rigor
|
|
|
53
53
|
).freeze
|
|
54
54
|
private_constant :NON_EMPTY_STRING_OR_NIL
|
|
55
55
|
|
|
56
|
+
NON_EMPTY_STRING = Type::Combinator.non_empty_string.freeze
|
|
57
|
+
private_constant :NON_EMPTY_STRING
|
|
58
|
+
|
|
56
59
|
# `Kernel#__dir__` returns the canonical directory of the
|
|
57
60
|
# source file the call appears in, or `nil` when the file
|
|
58
61
|
# is invalid / not available (typically `-e` and similar
|
|
@@ -62,12 +65,31 @@ module Rigor
|
|
|
62
65
|
KERNEL_DIR = ->(_arg_types) { NON_EMPTY_STRING_OR_NIL }
|
|
63
66
|
private_constant :KERNEL_DIR
|
|
64
67
|
|
|
68
|
+
# `File.expand_path(path, ?dir_string)` always returns an
|
|
69
|
+
# absolute path string. Even `File.expand_path("")` expands
|
|
70
|
+
# to the current working directory's absolute path. The
|
|
71
|
+
# upstream RBS row is `(path file_name, ?path dir_string) ->
|
|
72
|
+
# String`; the refinement tightens to `non-empty-string`.
|
|
73
|
+
#
|
|
74
|
+
# `File.dirname(path, ?level)` always returns at least `"."`
|
|
75
|
+
# (or `"/"` for absolute roots), so the return is never the
|
|
76
|
+
# empty string. Upstream RBS returns `String`; the refinement
|
|
77
|
+
# tightens to `non-empty-string`.
|
|
78
|
+
#
|
|
79
|
+
# `File.basename` is intentionally NOT refined: it returns
|
|
80
|
+
# `""` for `File.basename("")`, so `non-empty-string` would
|
|
81
|
+
# be unsound.
|
|
82
|
+
FILE_NON_EMPTY = ->(_arg_types) { NON_EMPTY_STRING }
|
|
83
|
+
private_constant :FILE_NON_EMPTY
|
|
84
|
+
|
|
65
85
|
# Frozen ((owner_class_name, method_name, kind) => handler)
|
|
66
86
|
# table. The kind tag is `:both`, `:singleton`, or
|
|
67
87
|
# `:instance`. New entries SHOULD prefer `:both` unless the
|
|
68
88
|
# singleton- and instance-side shapes genuinely differ.
|
|
69
89
|
OVERRIDES = {
|
|
70
|
-
["Kernel", :__dir__, :both] => KERNEL_DIR
|
|
90
|
+
["Kernel", :__dir__, :both] => KERNEL_DIR,
|
|
91
|
+
["File", :expand_path, :singleton] => FILE_NON_EMPTY,
|
|
92
|
+
["File", :dirname, :singleton] => FILE_NON_EMPTY
|
|
71
93
|
}.freeze
|
|
72
94
|
private_constant :OVERRIDES
|
|
73
95
|
|