rigortype 0.4.0 → 0.4.1
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 +4 -3
- data/docs/handbook/08-understanding-errors.md +3 -2
- data/docs/install.md +87 -15
- data/docs/manual/02-cli-reference.md +2 -2
- data/docs/manual/03-configuration.md +5 -0
- data/docs/manual/08-skills.md +4 -3
- data/docs/manual/12-caching.md +43 -4
- data/docs/manual/14-rails-quickstart.md +19 -11
- data/lib/rigor/analysis/check_rules/source_arity.rb +163 -59
- data/lib/rigor/analysis/check_rules.rb +118 -87
- data/lib/rigor/analysis/dependency_recorder.rb +200 -18
- data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +266 -0
- data/lib/rigor/analysis/incremental_run_slot.rb +171 -0
- data/lib/rigor/analysis/incremental_session.rb +273 -49
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +24 -13
- data/lib/rigor/analysis/reachability/scan_cache.rb +2 -1
- data/lib/rigor/analysis/run_cache_key.rb +27 -8
- data/lib/rigor/analysis/run_stats.rb +2 -2
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +1 -1
- data/lib/rigor/analysis/runner/pool_coordinator.rb +2 -2
- data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -13
- data/lib/rigor/analysis/runner.rb +337 -46
- data/lib/rigor/analysis/shadow_harness.rb +27 -0
- data/lib/rigor/analysis/source_rbs_gate.rb +157 -0
- data/lib/rigor/analysis/template_unit_paths.rb +6 -4
- data/lib/rigor/analysis/template_units.rb +15 -5
- data/lib/rigor/analysis/worker_session.rb +4 -3
- data/lib/rigor/cache/descriptor.rb +40 -1
- data/lib/rigor/cache/file_digest.rb +28 -0
- data/lib/rigor/cache/incremental_snapshot.rb +70 -8
- data/lib/rigor/cache/store.rb +63 -3
- data/lib/rigor/cache/value_digest.rb +325 -0
- data/lib/rigor/cli/check_command.rb +83 -13
- data/lib/rigor/cli/doctor_command.rb +1 -1
- data/lib/rigor/cli/effects_report.rb +2 -2
- data/lib/rigor/cli/unused_command.rb +3 -3
- data/lib/rigor/cli.rb +1 -1
- data/lib/rigor/config_audit.rb +3 -2
- data/lib/rigor/config_path.rb +29 -0
- data/lib/rigor/configuration/severity_profile.rb +5 -0
- data/lib/rigor/configuration.rb +6 -5
- data/lib/rigor/effects/plugin_facts.rb +6 -6
- data/lib/rigor/environment/bundle_sig_discovery.rb +5 -3
- data/lib/rigor/environment/lockfile_resolver.rb +10 -1
- data/lib/rigor/environment/rbs_collection_discovery.rb +10 -1
- data/lib/rigor/environment/rbs_loader.rb +1 -1
- data/lib/rigor/environment/source_rbs_synthesis.rb +147 -0
- data/lib/rigor/environment.rb +13 -70
- data/lib/rigor/inference/block_parameter_binder.rb +29 -13
- data/lib/rigor/inference/captured_locals.rb +7 -1
- data/lib/rigor/inference/declaration_walk/context.rb +195 -0
- data/lib/rigor/inference/declaration_walk/errors.rb +18 -0
- data/lib/rigor/inference/declaration_walk/shadow.rb +147 -0
- data/lib/rigor/inference/declaration_walk/traversal.rb +441 -0
- data/lib/rigor/inference/declaration_walk.rb +7 -0
- data/lib/rigor/inference/def_node_resolver.rb +3 -2
- data/lib/rigor/inference/definer_resolution.rb +266 -0
- data/lib/rigor/inference/expression_typer.rb +136 -45
- data/lib/rigor/inference/external_ancestor_resolution.rb +13 -16
- data/lib/rigor/inference/guard_rebinding.rb +53 -12
- data/lib/rigor/inference/macro_block_self_type.rb +14 -2
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +46 -26
- data/lib/rigor/inference/module_function_state.rb +213 -0
- data/lib/rigor/inference/multi_target_binder.rb +21 -16
- data/lib/rigor/inference/narrowing.rb +147 -17
- data/lib/rigor/inference/parameter_inference_collector.rb +10 -9
- data/lib/rigor/inference/scope_indexer/certainty.rb +176 -0
- data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +35 -0
- data/lib/rigor/inference/scope_indexer/def_nestings_collector.rb +47 -0
- data/lib/rigor/inference/scope_indexer/layered_def_nestings.rb +36 -0
- data/lib/rigor/inference/scope_indexer/member_layouts_collector.rb +61 -0
- data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +130 -0
- data/lib/rigor/inference/scope_indexer/superclasses_collector.rb +64 -0
- data/lib/rigor/inference/scope_indexer.rb +1443 -295
- data/lib/rigor/inference/singleton_hook_decline.rb +253 -0
- data/lib/rigor/inference/statement_evaluator.rb +80 -14
- data/lib/rigor/plugin/base.rb +28 -8
- data/lib/rigor/plugin/io_boundary.rb +82 -4
- data/lib/rigor/plugin/isolation.rb +1 -1
- data/lib/rigor/plugin/trust_policy.rb +3 -3
- data/lib/rigor/protection/discovery_seed.rb +18 -9
- data/lib/rigor/protection/mutation_cache.rb +3 -3
- data/lib/rigor/reflection/constant_ancestors.rb +96 -47
- data/lib/rigor/reflection/constant_path.rb +22 -7
- data/lib/rigor/reflection.rb +2 -3
- data/lib/rigor/scope/discovery_index.rb +218 -2
- data/lib/rigor/scope/resolution_chain/relevance.rb +165 -0
- data/lib/rigor/scope/resolution_chain.rb +884 -0
- data/lib/rigor/scope.rb +295 -322
- data/lib/rigor/sig_gen/alias_index.rb +1 -1
- data/lib/rigor/sig_gen/effect_annotation.rb +2 -2
- data/lib/rigor/sig_gen/generator.rb +22 -16
- data/lib/rigor/type/combinator.rb +25 -1
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +10 -0
- data/sig/rigor/environment.rbs +2 -0
- data/sig/rigor/plugin/io_boundary.rbs +1 -0
- data/sig/rigor/scope.rbs +43 -1
- data/skills/rigor-project-init/SKILL.md +37 -11
- data/skills/rigor-project-init/references/02-configure.md +7 -2
- metadata +25 -1
|
@@ -311,12 +311,12 @@ module Rigor
|
|
|
311
311
|
# `[class_name, …ancestors]`, memoised per class. Cycle-guarded and capped: the tables are *as
|
|
312
312
|
# written*, so a project can spell an ancestry that loops.
|
|
313
313
|
#
|
|
314
|
-
# Included and prepended modules are walked beside the superclass, nearer first, because
|
|
315
|
-
#
|
|
316
|
-
#
|
|
317
|
-
#
|
|
318
|
-
#
|
|
319
|
-
#
|
|
314
|
+
# Included and prepended modules are walked beside the superclass, nearer first, because a framework's
|
|
315
|
+
# contract is as often a module as a base class: a Sidekiq worker is `class TriggerWebhookWorker;
|
|
316
|
+
# include Sidekiq::Job`, with no base class at all, so a superclass-only walk could not match a row
|
|
317
|
+
# about it however the plugin spelled one (#456). Breadth-first is close to Ruby's lookup order but not
|
|
318
|
+
# it (a module an included module includes comes after the superclass here); `Scope::ResolutionChain`
|
|
319
|
+
# is the exact order, and this walk over the plugin facts' own copy of the tables does not read it yet.
|
|
320
320
|
def ancestry(class_name)
|
|
321
321
|
@ancestry[class_name] ||= begin
|
|
322
322
|
chain = []
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
|
|
5
|
+
require_relative "../config_path"
|
|
6
|
+
|
|
5
7
|
module Rigor
|
|
6
8
|
class Environment
|
|
7
9
|
# Target-project Bundler awareness (O4, implemented).
|
|
@@ -167,7 +169,7 @@ module Rigor
|
|
|
167
169
|
# for the stats banner so end users can see what rigor picked up.
|
|
168
170
|
def self.resolve_bundle_path(bundle_path:, project_root: Dir.pwd, auto_detect: true, home: nil)
|
|
169
171
|
if bundle_path
|
|
170
|
-
path = Pathname.new(
|
|
172
|
+
path = Pathname.new(Rigor::ConfigPath.absolute(bundle_path.to_s, project_root))
|
|
171
173
|
return path if path.directory?
|
|
172
174
|
|
|
173
175
|
return nil
|
|
@@ -203,7 +205,7 @@ module Rigor
|
|
|
203
205
|
# read of the real `~/.bundle/config`).
|
|
204
206
|
def self.auto_detect(project_root:, home: nil)
|
|
205
207
|
from_config = read_bundle_config_path(File.join(project_root, ".bundle", "config"))
|
|
206
|
-
return
|
|
208
|
+
return Rigor::ConfigPath.absolute(from_config, project_root) if from_config
|
|
207
209
|
|
|
208
210
|
vendor = File.join(project_root, "vendor", "bundle")
|
|
209
211
|
return vendor if File.directory?(vendor)
|
|
@@ -221,7 +223,7 @@ module Rigor
|
|
|
221
223
|
configured = read_bundle_config_path(File.join(home, ".bundle", "config"))
|
|
222
224
|
return nil unless configured
|
|
223
225
|
|
|
224
|
-
resolved =
|
|
226
|
+
resolved = Rigor::ConfigPath.absolute(configured, project_root)
|
|
225
227
|
File.directory?(resolved) ? resolved : nil
|
|
226
228
|
end
|
|
227
229
|
private_class_method :global_bundle_path
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../config_path"
|
|
4
|
+
|
|
3
5
|
module Rigor
|
|
4
6
|
class Environment
|
|
5
7
|
# Gemfile.lock parser for target-project bundler awareness (O4 Layer 3, implemented).
|
|
@@ -109,11 +111,18 @@ module Rigor
|
|
|
109
111
|
end
|
|
110
112
|
private_class_method :do_parse_dependency_names
|
|
111
113
|
|
|
114
|
+
# The absolute path a configured `lockfile:` names, whether or not the file exists. The one place the
|
|
115
|
+
# setting is expanded (a leading `~/` is the home directory), shared by `resolve_lockfile_path` and
|
|
116
|
+
# by `WriteGuard`, which must watch the very path the run reads.
|
|
117
|
+
def self.configured_lockfile_path(lockfile_path, project_root = Dir.pwd)
|
|
118
|
+
Rigor::ConfigPath.absolute(lockfile_path.to_s, project_root)
|
|
119
|
+
end
|
|
120
|
+
|
|
112
121
|
# Returns the resolved lockfile path (`Pathname`) or `nil` when neither explicit nor auto-detect
|
|
113
122
|
# produces one. Public so the stats banner can show what rigor picked up.
|
|
114
123
|
def self.resolve_lockfile_path(lockfile_path:, project_root: Dir.pwd, auto_detect: true)
|
|
115
124
|
if lockfile_path
|
|
116
|
-
path = Pathname.new(
|
|
125
|
+
path = Pathname.new(configured_lockfile_path(lockfile_path, project_root))
|
|
117
126
|
return path if path.file?
|
|
118
127
|
|
|
119
128
|
return nil
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
|
|
5
|
+
require_relative "../config_path"
|
|
6
|
+
|
|
5
7
|
module Rigor
|
|
6
8
|
class Environment
|
|
7
9
|
# `rbs collection install` awareness (O4 Layer 3 slice 2, implemented).
|
|
@@ -68,11 +70,18 @@ module Rigor
|
|
|
68
70
|
gem_paths_from(collection_root, data, skip_gem_names.to_set)
|
|
69
71
|
end
|
|
70
72
|
|
|
73
|
+
# The absolute path a configured `lockfile:` names, whether or not the file exists. The one place the
|
|
74
|
+
# setting is expanded (a leading `~/` is the home directory), shared by `resolve_lockfile_path` and
|
|
75
|
+
# by `WriteGuard`, which must watch the very path the run reads.
|
|
76
|
+
def self.configured_lockfile_path(lockfile_path, project_root = Dir.pwd)
|
|
77
|
+
Rigor::ConfigPath.absolute(lockfile_path.to_s, project_root)
|
|
78
|
+
end
|
|
79
|
+
|
|
71
80
|
# Returns the resolved lockfile path (`Pathname`) or `nil` when neither explicit nor auto-detect
|
|
72
81
|
# produces one. Public so the stats banner can surface what rigor found.
|
|
73
82
|
def self.resolve_lockfile_path(lockfile_path:, project_root: Dir.pwd, auto_detect: true)
|
|
74
83
|
if lockfile_path
|
|
75
|
-
path = Pathname.new(
|
|
84
|
+
path = Pathname.new(configured_lockfile_path(lockfile_path, project_root))
|
|
76
85
|
return path if path.file?
|
|
77
86
|
|
|
78
87
|
return nil
|
|
@@ -812,7 +812,7 @@ module Rigor
|
|
|
812
812
|
buffer_name = location&.buffer&.name
|
|
813
813
|
return false unless buffer_name
|
|
814
814
|
|
|
815
|
-
project_files.include?(File.
|
|
815
|
+
project_files.include?(File.absolute_path(buffer_name.to_s))
|
|
816
816
|
end
|
|
817
817
|
|
|
818
818
|
# Adds empty stub declarations for the missing referenced types (and any enclosing namespace they
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "../cache/value_digest"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
class Environment
|
|
8
|
+
# ADR-32 WD5 — one plugin's `source_rbs_synthesizer` output for one source file, cached per (file content,
|
|
9
|
+
# plugin entry, engine source). Two callers read it, and sharing the function is the point:
|
|
10
|
+
#
|
|
11
|
+
# - {Environment.collect_virtual_rbs} hands each output to the loader as a `virtual:` buffer;
|
|
12
|
+
# - {.digest} reduces every loaded synthesizer's output for a file to one comparable value, which the
|
|
13
|
+
# incremental session (ADR-89 WD1, issue #1536) keeps on the file's seed bundle and compares across an
|
|
14
|
+
# edit.
|
|
15
|
+
#
|
|
16
|
+
# Because the digest reads the very function (and the very cache entries) the loader is fed from, "the
|
|
17
|
+
# digest did not move" means "the loader read the same bytes for this file", not "some model of what
|
|
18
|
+
# the plugin reads did not move". That is why it is taken from the output rather than from the file's
|
|
19
|
+
# comment lines: rbs-inline binds an annotation by adjacency, so a plain comment or a blank line can move
|
|
20
|
+
# one to another declaration, and in an annotated file every `def`, `attr`, constant and mixin gets a
|
|
21
|
+
# skeleton whether or not it carries an annotation of its own.
|
|
22
|
+
module SourceRbsSynthesis
|
|
23
|
+
PRODUCER_ID = "plugin.source_rbs_synthesizer"
|
|
24
|
+
|
|
25
|
+
# The digest of a file no loaded synthesizer contributes anything for — the answer for every file of a
|
|
26
|
+
# project without an annotation, and for every file when no synthesizer is loaded at all. Not a hex
|
|
27
|
+
# digest, so it can never collide with one.
|
|
28
|
+
NO_CONTRIBUTION = "none"
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# The cache stores the empty string `""` as the "no contribution" sentinel because `Cache::Store`
|
|
33
|
+
# treats `nil` as a cache miss. Error tuples are stored as the canonical `[:error, message_string]`
|
|
34
|
+
# Array so the same wrapper short-circuits subsequent runs against unchanged broken input.
|
|
35
|
+
def output_for(plugin, callable, path, cache_store)
|
|
36
|
+
return invoke_safely(callable, path) if cache_store.nil?
|
|
37
|
+
return invoke_safely(callable, path) unless File.file?(path)
|
|
38
|
+
|
|
39
|
+
descriptor = cache_descriptor(plugin, path)
|
|
40
|
+
return invoke_safely(callable, path) if descriptor.nil?
|
|
41
|
+
|
|
42
|
+
cache_store.fetch_or_compute(
|
|
43
|
+
producer_id: PRODUCER_ID,
|
|
44
|
+
params: {},
|
|
45
|
+
descriptor: descriptor,
|
|
46
|
+
generation_cap: generation_cap
|
|
47
|
+
) { invoke_safely(callable, path) || "" }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Issue #1536 — every synthesizer's output for `path`, reduced to one value: {NO_CONTRIBUTION} when none
|
|
51
|
+
# of them contributes anything, a SHA-256 hex digest of each plugin's id and {.contribution} otherwise,
|
|
52
|
+
# and nil when an output could not be read at all.
|
|
53
|
+
#
|
|
54
|
+
# nil means "unknown", and a caller must treat it as moved. Every failure is per file: one plugin that
|
|
55
|
+
# cannot answer for one file leaves every other file's digest intact.
|
|
56
|
+
#
|
|
57
|
+
# @param synthesizers — `Plugin::Registry#source_rbs_synthesizers` pairs.
|
|
58
|
+
def digest(synthesizers, path, cache_store)
|
|
59
|
+
sha = Digest::SHA256.new
|
|
60
|
+
contributed = false
|
|
61
|
+
synthesizers.each do |plugin, callable|
|
|
62
|
+
tag, text = contribution(output_for(plugin, callable, path, cache_store))
|
|
63
|
+
next if tag.nil?
|
|
64
|
+
|
|
65
|
+
contributed = true
|
|
66
|
+
sha << plugin.manifest.id.to_s.b << "\x00" << tag << "\x00" << text.to_s.b << "\x01"
|
|
67
|
+
end
|
|
68
|
+
contributed ? sha.hexdigest : NO_CONTRIBUTION
|
|
69
|
+
rescue StandardError
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# What one output puts into the environment, as a `[tag, text]` pair, or nil for nothing: exactly the
|
|
74
|
+
# RBS {Environment.interpret_synthesizer_outcome} hands the loader. The ADR-32 WD6 / WD12 notices are left
|
|
75
|
+
# out. They reach only the run-level `source-rbs-*` rows, which every run regenerates and the per-file
|
|
76
|
+
# cache never serves, and they quote line numbers, so a line shift in a file carrying a malformed `#:`
|
|
77
|
+
# would otherwise read as a moved contribution. A failed synthesis contributes nothing to the loader but
|
|
78
|
+
# is kept as one stable value of its own, so a file flipping between "no annotation" and "broken
|
|
79
|
+
# annotation" still reads as moved. Any shape the contract does not name is digested whole, by value: the
|
|
80
|
+
# output is computed on one run and served from its cache entry on the next, and a digest of its
|
|
81
|
+
# `Marshal.dump` bytes can differ between the two (issue #1574).
|
|
82
|
+
#
|
|
83
|
+
# The RBS text itself is digested whole, comments included. RBS reads a `# resolve-type-names: false`
|
|
84
|
+
# magic comment at the start of a buffer, rbs-inline copies a `.rb` file's first comment line there, and
|
|
85
|
+
# a string-literal type may span lines that start with `#`, so no line-based stripping is sound (#1549).
|
|
86
|
+
def contribution(output)
|
|
87
|
+
return nil if output.nil? || output == ""
|
|
88
|
+
return ["rbs", output] if output.is_a?(String)
|
|
89
|
+
return ["raw", Cache::ValueDigest.hexdigest(output)] unless output.is_a?(Array)
|
|
90
|
+
|
|
91
|
+
case output[0]
|
|
92
|
+
when :error then ["error", nil]
|
|
93
|
+
when :ok then contribution(output[1])
|
|
94
|
+
else ["raw", Cache::ValueDigest.hexdigest(output)]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# One entry per (plugin, source file), all of them live for as long as the file is in the project — a
|
|
99
|
+
# generation count says nothing about staleness here, so this producer declares itself out of
|
|
100
|
+
# `Cache::Store#evict!`'s compaction pass and is bounded only by the size-based LRU pass. A method
|
|
101
|
+
# rather than a constant: `Cache::Store` is not loaded yet when this file is.
|
|
102
|
+
def generation_cap
|
|
103
|
+
Cache::Store::UNBOUNDED_GENERATIONS
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The key composes the file's content SHA with the plugin's `PluginEntry` (id + version + config_hash),
|
|
107
|
+
# so a config change or a content change invalidates the entry, and with the engine's source identity
|
|
108
|
+
# (issue #1009): a plugin's manifest version does not move when a checkout edits its synthesizer, and
|
|
109
|
+
# a stale string here also keeps the `rbs.virtual_rbs` env key warm, so the new build's rules would
|
|
110
|
+
# read the old build's RBS.
|
|
111
|
+
def cache_descriptor(plugin, path)
|
|
112
|
+
Cache::Descriptor.new(
|
|
113
|
+
files: [Cache::Descriptor::FileEntry.new(
|
|
114
|
+
path: path.to_s,
|
|
115
|
+
comparator: :digest,
|
|
116
|
+
value: input_digest(path)
|
|
117
|
+
)],
|
|
118
|
+
plugins: [plugin.plugin_entry],
|
|
119
|
+
configs: Cache::EngineSource.key_config_entries
|
|
120
|
+
)
|
|
121
|
+
rescue Cache::EngineSource::Unavailable
|
|
122
|
+
# An engine that cannot be identified must not be keyed by its inputs alone (issue #1009): nil runs the
|
|
123
|
+
# synthesizer uncached rather than serving an entry another build may have written.
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def input_digest(path)
|
|
128
|
+
Digest::SHA256.hexdigest(File.binread(path))
|
|
129
|
+
rescue ::SystemCallError
|
|
130
|
+
# Unreadable file → key on the path alone; the synthesizer's File.file?/File.read will see the same
|
|
131
|
+
# failure and return nil.
|
|
132
|
+
Digest::SHA256.hexdigest(path.to_s)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def invoke_safely(callable, path)
|
|
136
|
+
callable.call(path.to_s)
|
|
137
|
+
rescue StandardError
|
|
138
|
+
# WD6 fail-soft — a synthesizer that raises does NOT crash analysis. Unlike the `[:error, msg]`
|
|
139
|
+
# return path (which the runner surfaces as `source-rbs-synthesis-failed`), an unhandled raise is
|
|
140
|
+
# swallowed silently; the unexamined-raise channel is deliberately silent per WD6.
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private_class_method :contribution, :generation_cap, :cache_descriptor, :input_digest, :invoke_safely
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
data/lib/rigor/environment.rb
CHANGED
|
@@ -15,6 +15,7 @@ require_relative "environment/bundle_sig_discovery"
|
|
|
15
15
|
require_relative "environment/lockfile_resolver"
|
|
16
16
|
require_relative "environment/installed_gem_set"
|
|
17
17
|
require_relative "environment/rbs_collection_discovery"
|
|
18
|
+
require_relative "environment/source_rbs_synthesis"
|
|
18
19
|
require_relative "plugin/isolation"
|
|
19
20
|
require_relative "cache/engine_source"
|
|
20
21
|
require_relative "environment/rbs_coverage_report"
|
|
@@ -275,7 +276,7 @@ module Rigor
|
|
|
275
276
|
rbs_collection_lockfile: nil, rbs_collection_auto_detect: false,
|
|
276
277
|
synthetic_method_index: nil, project_patched_methods: nil,
|
|
277
278
|
source_files: [], locked_gems: nil)
|
|
278
|
-
resolved_paths = signature_paths
|
|
279
|
+
resolved_paths = project_signature_roots(signature_paths, root: root)
|
|
279
280
|
# O4 MVP — append per-gem `sig/` directories discovered under the target project's bundler install
|
|
280
281
|
# root. Empty array when neither an explicit path nor auto-detection finds a bundle. Order: user
|
|
281
282
|
# `signature_paths:` win first (semantic precedence inside `RbsLoader.build_env_for`); gem-shipped
|
|
@@ -390,6 +391,13 @@ module Rigor
|
|
|
390
391
|
)
|
|
391
392
|
end
|
|
392
393
|
|
|
394
|
+
# The project-owned `sig/` roots a run reads: the explicit `signature_paths:` when given, else the
|
|
395
|
+
# auto-detected `<root>/sig` (or none). Public so {Cache::IncrementalSnapshot} digests exactly the
|
|
396
|
+
# directories the environment loads, including the auto-detected one a `nil` `signature_paths:` hides.
|
|
397
|
+
def project_signature_roots(signature_paths, root: Dir.pwd)
|
|
398
|
+
signature_paths || default_signature_paths(root)
|
|
399
|
+
end
|
|
400
|
+
|
|
393
401
|
private
|
|
394
402
|
|
|
395
403
|
def default_signature_paths(root)
|
|
@@ -496,11 +504,9 @@ module Rigor
|
|
|
496
504
|
# empty and this method short-circuits to `[]` without walking the file list.
|
|
497
505
|
#
|
|
498
506
|
# WD5 — when `cache_store` is supplied, each (file, plugin) synthesizer call is memoised through
|
|
499
|
-
# `Cache::Store
|
|
500
|
-
#
|
|
501
|
-
#
|
|
502
|
-
# checkout edits its synthesizer, and a stale string here also keeps the `rbs.virtual_rbs` env key warm,
|
|
503
|
-
# so the new build's rules would read the old build's RBS.
|
|
507
|
+
# `Cache::Store` ({SourceRbsSynthesis.output_for}, which owns the key). The incremental session's
|
|
508
|
+
# per-file digest (issue #1536) reads the same function, so the two can never disagree on what a file
|
|
509
|
+
# contributed.
|
|
504
510
|
def collect_virtual_rbs(plugin_registry, source_files, cache_store, reporter)
|
|
505
511
|
return [] if plugin_registry.nil?
|
|
506
512
|
|
|
@@ -511,7 +517,7 @@ module Rigor
|
|
|
511
517
|
result = []
|
|
512
518
|
source_files.each do |path|
|
|
513
519
|
synthesizers.each do |plugin, callable|
|
|
514
|
-
outcome =
|
|
520
|
+
outcome = SourceRbsSynthesis.output_for(plugin, callable, path, cache_store)
|
|
515
521
|
outcome = interpret_synthesizer_outcome(outcome, plugin, path, reporter)
|
|
516
522
|
next if outcome.nil? || outcome.empty?
|
|
517
523
|
|
|
@@ -522,25 +528,6 @@ module Rigor
|
|
|
522
528
|
result
|
|
523
529
|
end
|
|
524
530
|
|
|
525
|
-
# ADR-32 WD5 — cache wrapper around a single (plugin, file) invocation. The cache stores the empty
|
|
526
|
-
# string `""` as the "no contribution" sentinel because `Cache::Store` treats `nil` as a cache miss.
|
|
527
|
-
# Error tuples are stored as the canonical `[:error, message_string]` Array so the same wrapper
|
|
528
|
-
# short-circuits subsequent runs against unchanged broken input.
|
|
529
|
-
def synthesizer_output_for(plugin, callable, path, cache_store)
|
|
530
|
-
return invoke_synthesizer_safely(callable, path) if cache_store.nil?
|
|
531
|
-
return invoke_synthesizer_safely(callable, path) unless File.file?(path)
|
|
532
|
-
|
|
533
|
-
descriptor = build_synthesizer_cache_descriptor(plugin, path)
|
|
534
|
-
return invoke_synthesizer_safely(callable, path) if descriptor.nil?
|
|
535
|
-
|
|
536
|
-
cache_store.fetch_or_compute(
|
|
537
|
-
producer_id: SYNTHESIZER_CACHE_PRODUCER_ID,
|
|
538
|
-
params: {},
|
|
539
|
-
descriptor: descriptor,
|
|
540
|
-
generation_cap: synthesizer_generation_cap
|
|
541
|
-
) { invoke_synthesizer_safely(callable, path) || "" }
|
|
542
|
-
end
|
|
543
|
-
|
|
544
531
|
# ADR-32 WD6 / WD12 — route a synthesizer return value through the per-run reporter. The
|
|
545
532
|
# synthesizer's contract (declared in
|
|
546
533
|
# `plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb`) admits four return shapes:
|
|
@@ -661,50 +648,6 @@ module Rigor
|
|
|
661
648
|
prefix = "#{root}#{File::SEPARATOR}"
|
|
662
649
|
path.to_s.start_with?(prefix) ? path.to_s.delete_prefix(prefix) : path.to_s
|
|
663
650
|
end
|
|
664
|
-
|
|
665
|
-
SYNTHESIZER_CACHE_PRODUCER_ID = "plugin.source_rbs_synthesizer"
|
|
666
|
-
private_constant :SYNTHESIZER_CACHE_PRODUCER_ID
|
|
667
|
-
|
|
668
|
-
# One entry per (plugin, source file), all of them live for as long as the file is in the project — a
|
|
669
|
-
# generation count says nothing about staleness here, so this producer declares itself out of
|
|
670
|
-
# `Cache::Store#evict!`'s compaction pass and is bounded only by the size-based LRU pass. A method
|
|
671
|
-
# rather than a constant: `Cache::Store` is not loaded yet when this class body runs.
|
|
672
|
-
def synthesizer_generation_cap
|
|
673
|
-
Cache::Store::UNBOUNDED_GENERATIONS
|
|
674
|
-
end
|
|
675
|
-
|
|
676
|
-
def build_synthesizer_cache_descriptor(plugin, path)
|
|
677
|
-
Cache::Descriptor.new(
|
|
678
|
-
files: [Cache::Descriptor::FileEntry.new(
|
|
679
|
-
path: path.to_s,
|
|
680
|
-
comparator: :digest,
|
|
681
|
-
value: synthesizer_input_digest(path)
|
|
682
|
-
)],
|
|
683
|
-
plugins: [plugin.plugin_entry],
|
|
684
|
-
configs: Cache::EngineSource.key_config_entries
|
|
685
|
-
)
|
|
686
|
-
rescue Cache::EngineSource::Unavailable
|
|
687
|
-
# An engine that cannot be identified must not be keyed by its inputs alone (issue #1009): nil runs the
|
|
688
|
-
# synthesizer uncached rather than serving an entry another build may have written.
|
|
689
|
-
nil
|
|
690
|
-
end
|
|
691
|
-
|
|
692
|
-
def synthesizer_input_digest(path)
|
|
693
|
-
Digest::SHA256.hexdigest(File.binread(path))
|
|
694
|
-
rescue ::SystemCallError
|
|
695
|
-
# Unreadable file → key on the path alone; the synthesizer's File.file?/File.read will see the same
|
|
696
|
-
# failure and return nil.
|
|
697
|
-
Digest::SHA256.hexdigest(path.to_s)
|
|
698
|
-
end
|
|
699
|
-
|
|
700
|
-
def invoke_synthesizer_safely(callable, path)
|
|
701
|
-
callable.call(path.to_s)
|
|
702
|
-
rescue StandardError
|
|
703
|
-
# WD6 fail-soft — a synthesizer that raises does NOT crash analysis. Unlike the `[:error, msg]`
|
|
704
|
-
# return path (which the runner surfaces as `source-rbs-synthesis-failed`), an unhandled raise is
|
|
705
|
-
# swallowed silently; the unexamined-raise channel is deliberately silent per WD6.
|
|
706
|
-
nil
|
|
707
|
-
end
|
|
708
651
|
end
|
|
709
652
|
|
|
710
653
|
# Resolves a constant name to a Rigor::Type::Nominal (the *instance* type carrier). Consults the static
|
|
@@ -54,14 +54,16 @@ module Rigor
|
|
|
54
54
|
reset_per_bind_state
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
-
# The names the last {#bind} bound optimistically nil-free (see the class comment).
|
|
57
|
+
# The names the last {#bind} bound optimistically nil-free (see the class comment). Read-only: it is a
|
|
58
|
+
# shared frozen empty when the bind marked nothing.
|
|
58
59
|
attr_reader :optimistic
|
|
59
60
|
|
|
60
61
|
# Binds the block's parameters into `scope`: {#bind}'s types through `Scope#with_local`, then the
|
|
61
|
-
# optimistic mark for every name in {#optimistic}.
|
|
62
|
+
# optimistic mark for every name in {#optimistic}. It applies them through
|
|
63
|
+
# {MultiTargetBinder.apply_bindings} directly rather than through a `Result`, which every block entry
|
|
64
|
+
# built only to apply at once ([#1504](https://github.com/rigortype/rigor/issues/1504)).
|
|
62
65
|
def bind_onto(block_node, scope)
|
|
63
|
-
|
|
64
|
-
MultiTargetBinder::Result.new(types: types, optimistic: @optimistic.dup.freeze).apply_to(scope)
|
|
66
|
+
MultiTargetBinder.apply_bindings(scope, bind(block_node, scope: scope), @optimistic)
|
|
65
67
|
end
|
|
66
68
|
|
|
67
69
|
# @return ordered map from parameter name to bound type. Anonymous
|
|
@@ -90,29 +92,40 @@ module Rigor
|
|
|
90
92
|
|
|
91
93
|
private
|
|
92
94
|
|
|
95
|
+
NO_POSITIONS = [].freeze
|
|
96
|
+
NO_NAMES = [].freeze
|
|
97
|
+
private_constant :NO_POSITIONS, :NO_NAMES
|
|
98
|
+
|
|
93
99
|
# {#apply_auto_splat} rewrites the positional table for the block it is binding, so every {#bind} starts
|
|
94
100
|
# from the declared types again; a binder reused across blocks must not see the previous block's splat.
|
|
101
|
+
# The two lists start as shared frozen empties, since most blocks mark nothing ({#mark_optimistic}).
|
|
95
102
|
def reset_per_bind_state
|
|
96
103
|
@expected_param_types = @declared_param_types
|
|
97
104
|
@splat_rest_type = nil
|
|
98
|
-
@optimistic_positions =
|
|
99
|
-
@optimistic =
|
|
105
|
+
@optimistic_positions = NO_POSITIONS
|
|
106
|
+
@optimistic = NO_NAMES
|
|
100
107
|
@scope = nil
|
|
101
108
|
end
|
|
102
109
|
|
|
110
|
+
# Records `name` as bound optimistically, allocating {#optimistic} on the first mark of a {#bind}.
|
|
111
|
+
def mark_optimistic(name)
|
|
112
|
+
@optimistic = [] if @optimistic.equal?(NO_NAMES)
|
|
113
|
+
@optimistic << name
|
|
114
|
+
end
|
|
115
|
+
|
|
103
116
|
# `|_1, _2|` numbered-parameter form. Prism exposes the implicit count through
|
|
104
117
|
# `NumberedParametersNode#maximum` (the highest `_N` referenced in the body); we materialise bindings
|
|
105
118
|
# for `:_1` through `:_maximum`, auto-splatting exactly as the explicit list of that many required
|
|
106
119
|
# positionals would, so the body's `LocalVariableReadNode` lookups see the same types and marks.
|
|
107
120
|
def bind_numbered_parameters(numbered_node)
|
|
108
121
|
arity = numbered_node.maximum
|
|
109
|
-
apply_auto_splat
|
|
122
|
+
apply_auto_splat { BlockAutoSplat::ParameterShape.of_arity(arity) }
|
|
110
123
|
|
|
111
124
|
bindings = {}
|
|
112
125
|
arity.times do |i|
|
|
113
126
|
name = :"_#{i + 1}"
|
|
114
127
|
bindings[name] = positional_type_at(i)
|
|
115
|
-
|
|
128
|
+
mark_optimistic(name) if @optimistic_positions.include?(i)
|
|
116
129
|
end
|
|
117
130
|
bindings
|
|
118
131
|
end
|
|
@@ -127,7 +140,7 @@ module Rigor
|
|
|
127
140
|
params_node = params_root.parameters
|
|
128
141
|
return {} if params_node.nil?
|
|
129
142
|
|
|
130
|
-
apply_auto_splat
|
|
143
|
+
apply_auto_splat { BlockAutoSplat::ParameterShape.of(params_node) }
|
|
131
144
|
|
|
132
145
|
bindings = {}
|
|
133
146
|
bind_positionals(params_node, bindings, 0)
|
|
@@ -152,9 +165,12 @@ module Rigor
|
|
|
152
165
|
# and {BlockAutoSplat} answers a table for that value and `shape`. A multi-arg yield (e.g.
|
|
153
166
|
# `each_with_index`'s `(element, index)` pair) is NOT auto-splatted — matching Ruby semantics where a
|
|
154
167
|
# multi-arg yield to a `|a, b, c|` block fills the extra slot with nil rather than splatting any
|
|
155
|
-
# element.
|
|
156
|
-
|
|
168
|
+
# element. The block answers the parameter list's shape, built only past that test
|
|
169
|
+
# ([#1504](https://github.com/rigortype/rigor/issues/1504)).
|
|
170
|
+
def apply_auto_splat
|
|
157
171
|
return unless @expected_param_types.size == 1
|
|
172
|
+
|
|
173
|
+
shape = yield
|
|
158
174
|
return unless shape.splats?
|
|
159
175
|
|
|
160
176
|
table = BlockAutoSplat.for(shape, @expected_param_types[0])
|
|
@@ -242,12 +258,12 @@ module Rigor
|
|
|
242
258
|
case param
|
|
243
259
|
when Prism::RequiredParameterNode
|
|
244
260
|
bindings[param.name] = positional_type_at(cursor)
|
|
245
|
-
|
|
261
|
+
mark_optimistic(param.name) if @optimistic_positions.include?(cursor)
|
|
246
262
|
when Prism::MultiTargetNode
|
|
247
263
|
nested = MultiTargetBinder.bind_marked(param, positional_type_at(cursor),
|
|
248
264
|
optimistic: @optimistic_positions.include?(cursor), scope: @scope)
|
|
249
265
|
bindings.merge!(nested.types)
|
|
250
|
-
|
|
266
|
+
nested.optimistic.each { |name| mark_optimistic(name) }
|
|
251
267
|
end
|
|
252
268
|
end
|
|
253
269
|
|
|
@@ -413,10 +413,16 @@ module Rigor
|
|
|
413
413
|
NESTED_SCOPE_NODES = Set[Prism::BlockNode, Prism::LambdaNode].freeze
|
|
414
414
|
private_constant :NESTED_SCOPE_NODES
|
|
415
415
|
|
|
416
|
+
# The index arm spells {INDEX_STORE_NODES} out rather than splatting it: `when *ARRAY` copies the array
|
|
417
|
+
# on every evaluation, and this runs once per node of every scanned block body, which cost `rigor check`
|
|
418
|
+
# about 126K allocations over the v0.3.9 `lib` ([#1502](https://github.com/rigortype/rigor/issues/1502),
|
|
419
|
+
# the shape `ScopeIndexer::META_CONSTANT_WRITE_NODES` records). Keep the arm and the list in step.
|
|
416
420
|
def mutated_receiver(node)
|
|
417
421
|
case node
|
|
418
422
|
when Prism::CallNode then node.receiver if MutationWidening::SHAPE_MUTATORS.include?(node.name)
|
|
419
|
-
when
|
|
423
|
+
when Prism::IndexOrWriteNode, Prism::IndexAndWriteNode, Prism::IndexOperatorWriteNode,
|
|
424
|
+
Prism::IndexTargetNode
|
|
425
|
+
node.receiver
|
|
420
426
|
end
|
|
421
427
|
end
|
|
422
428
|
|