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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/data/core_overlay/csv.rbs +28 -0
- data/data/core_overlay/psych.rbs +22 -0
- data/docs/handbook/01-getting-started.md +9 -1
- data/docs/handbook/02-everyday-types.md +4 -1
- data/docs/handbook/08-understanding-errors.md +3 -3
- data/docs/manual/01-installation.md +1 -0
- data/docs/manual/02-cli-reference.md +16 -7
- data/docs/manual/07-plugins.md +1 -1
- data/docs/manual/14-rails-quickstart.md +4 -2
- data/docs/manual/15-type-protection-coverage.md +21 -0
- data/docs/manual/plugins/rigor-actionpack.md +1 -1
- data/docs/manual/plugins/rigor-activerecord.md +12 -5
- data/docs/manual/plugins/rigor-rails-routes.md +11 -0
- data/lib/rigor/cache/store.rb +174 -57
- data/lib/rigor/cli/coverage_command.rb +50 -29
- data/lib/rigor/cli/diagnostic_formats.rb +4 -1
- data/lib/rigor/cli/protection_fork_scan.rb +55 -0
- data/lib/rigor/cli/protection_report.rb +7 -1
- data/lib/rigor/environment/missing_gem_constant_index.rb +128 -0
- data/lib/rigor/environment.rb +54 -18
- data/lib/rigor/inference/expression_typer.rb +20 -2
- data/lib/rigor/inference/fork_map.rb +87 -0
- data/lib/rigor/inference/narrowing.rb +118 -0
- data/lib/rigor/inference/parameter_inference_collector.rb +55 -10
- data/lib/rigor/inference/scope_indexer.rb +9 -13
- data/lib/rigor/inference/statement_evaluator.rb +48 -3
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +23 -8
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +21 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +10 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +61 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +20 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +172 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +22 -8
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +32 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +189 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +19 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +41 -10
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +42 -12
- data/sig/rigor/environment.rbs +1 -0
- 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
|
data/lib/rigor/environment.rb
CHANGED
|
@@ -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 =
|
|
252
|
-
locked: locked, default_libraries: merged_libraries,
|
|
253
|
-
|
|
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
|
-
#
|
|
302
|
-
#
|
|
303
|
-
#
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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 }
|
|
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 =
|
|
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) ||
|
|
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) ||
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
352
|
-
next if
|
|
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)
|