rigortype 0.4.2 → 0.4.3
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/docs/handbook/11-sig-gen.md +12 -5
- data/docs/manual/02-cli-reference.md +19 -1
- data/docs/manual/03-configuration.md +6 -4
- data/docs/manual/05-inspecting-types.md +50 -0
- data/docs/manual/12-caching.md +18 -1
- data/docs/manual/14-rails-quickstart.md +2 -2
- data/docs/manual/18-removing-dead-code.md +16 -9
- data/lib/rigor/analysis/check_rules/rule_ids.rb +7 -0
- data/lib/rigor/analysis/check_rules.rb +23 -22
- data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
- data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
- data/lib/rigor/analysis/incremental_session.rb +89 -22
- data/lib/rigor/analysis/path_expansion.rb +62 -6
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
- data/lib/rigor/analysis/reachability/graph.rb +119 -15
- data/lib/rigor/analysis/reachability/scan.rb +332 -173
- data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
- data/lib/rigor/analysis/run_cache_probe.rb +1 -1
- data/lib/rigor/analysis/run_stats.rb +28 -6
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +26 -1
- data/lib/rigor/analysis/runner/pool_coordinator.rb +28 -9
- data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -1
- data/lib/rigor/analysis/runner.rb +29 -11
- data/lib/rigor/analysis/worker_session.rb +1 -1
- data/lib/rigor/cache/engine_source.rb +21 -7
- data/lib/rigor/cache/incremental_snapshot.rb +36 -8
- data/lib/rigor/cli/check_command.rb +138 -39
- data/lib/rigor/cli/docs_command.rb +5 -4
- data/lib/rigor/cli/lens_command.rb +144 -0
- data/lib/rigor/cli/lsp_command.rb +5 -2
- data/lib/rigor/cli/skill_command.rb +3 -3
- data/lib/rigor/cli/skill_describe.rb +1 -1
- data/lib/rigor/cli/type_of_command.rb +2 -1
- data/lib/rigor/cli/unused_command.rb +157 -24
- data/lib/rigor/cli.rb +30 -4
- data/lib/rigor/configuration.rb +15 -0
- data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
- data/lib/rigor/environment/installed_gem_set.rb +38 -11
- data/lib/rigor/environment/lockfile_resolver.rb +37 -6
- data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
- data/lib/rigor/environment/rbs_loader.rb +33 -0
- data/lib/rigor/environment/required_features.rb +4 -2
- data/lib/rigor/environment.rb +12 -5
- data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
- data/lib/rigor/inference/captured_locals.rb +43 -3
- data/lib/rigor/inference/expression_typer.rb +58 -25
- data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
- data/lib/rigor/inference/match_rebinding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
- data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
- data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
- data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
- data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
- data/lib/rigor/inference/method_dispatcher.rb +52 -3
- data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
- data/lib/rigor/inference/project_patched_methods.rb +2 -2
- data/lib/rigor/inference/project_patched_scanner.rb +38 -24
- data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
- data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
- data/lib/rigor/inference/refined_dispatch.rb +62 -14
- data/lib/rigor/inference/repeated_or_writes.rb +2 -0
- data/lib/rigor/inference/scope_indexer.rb +140 -5
- data/lib/rigor/inference/statement_evaluator.rb +223 -31
- data/lib/rigor/inference/untyped_rejoin.rb +75 -0
- data/lib/rigor/language_server/project_context.rb +56 -5
- data/lib/rigor/lens/anchor.rb +115 -0
- data/lib/rigor/lens/contract_reader.rb +118 -0
- data/lib/rigor/lens/document.rb +113 -0
- data/lib/rigor/lens/file_reader.rb +433 -0
- data/lib/rigor/lens/members.rb +153 -0
- data/lib/rigor/lens/parameters.rb +91 -0
- data/lib/rigor/lens/plugins.rb +40 -0
- data/lib/rigor/lens/run.rb +280 -0
- data/lib/rigor/lens/slot.rb +79 -0
- data/lib/rigor/lens.rb +40 -0
- data/lib/rigor/project_environment.rb +20 -1
- data/lib/rigor/protection/mutation_cache.rb +2 -1
- data/lib/rigor/scope.rb +22 -11
- data/lib/rigor/sig_gen/generator.rb +45 -5
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
- data/sig/rigor/scope.rbs +3 -0
- data/sig/rigor.rbs +6 -3
- data/skills/rigor-project-init/references/02-configure.md +2 -2
- metadata +18 -1
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../version"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Lens
|
|
7
|
+
# `context.plugins` (docs/internal-spec/lens.md § Context): the plugins a run loaded, by manifest id.
|
|
8
|
+
module Plugins
|
|
9
|
+
BUNDLED_PLUGINS = File.expand_path("../../../plugins", __dir__).freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# `[{id, version}]` by manifest id (the id slot provenance uses). The version is Rigor's for a plugin bundled
|
|
14
|
+
# with it, else the version of the gem whose files define the plugin class, else null. A plugin id is not a gem
|
|
15
|
+
# name: the bundled `rbs-inline` plugin shares its id with the upstream gem.
|
|
16
|
+
def loaded(environment)
|
|
17
|
+
plugins = environment&.plugin_registry&.plugins || []
|
|
18
|
+
rows = plugins.map { |plugin| { "id" => plugin.manifest.id.to_s, "version" => version(plugin) } }
|
|
19
|
+
rows.uniq.sort_by { |plugin| plugin["id"] }
|
|
20
|
+
rescue StandardError
|
|
21
|
+
[]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def version(plugin)
|
|
25
|
+
file = Object.const_source_location(plugin.class.name)&.first
|
|
26
|
+
return nil if file.nil?
|
|
27
|
+
|
|
28
|
+
absolute = File.expand_path(file)
|
|
29
|
+
return Rigor::VERSION if absolute.start_with?("#{BUNDLED_PLUGINS}/")
|
|
30
|
+
|
|
31
|
+
spec = Gem.loaded_specs.each_value.find do |gem|
|
|
32
|
+
absolute.start_with?("#{gem.full_gem_path}/") # rigor:disable undefined-method
|
|
33
|
+
end
|
|
34
|
+
spec&.version&.to_s
|
|
35
|
+
rescue StandardError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "file_reader"
|
|
6
|
+
require_relative "contract_reader"
|
|
7
|
+
require_relative "members"
|
|
8
|
+
require_relative "plugins"
|
|
9
|
+
require_relative "slot"
|
|
10
|
+
require_relative "../analysis/incremental_session"
|
|
11
|
+
require_relative "../cache/incremental_snapshot"
|
|
12
|
+
require_relative "../cache/store"
|
|
13
|
+
require_relative "../sig_gen/alias_index"
|
|
14
|
+
require_relative "../sig_gen/type_elaborator"
|
|
15
|
+
|
|
16
|
+
module Rigor
|
|
17
|
+
module Lens
|
|
18
|
+
# The check-equivalent computation behind `rigor lens` (docs/internal-spec/lens.md § Computation). The session
|
|
19
|
+
# is the `--incremental` one rooted at the configuration's `paths:`, so it shares the snapshot with
|
|
20
|
+
# `rigor check --incremental`; the targets are forced into the run and read inside it, on the seeded index.
|
|
21
|
+
class Run
|
|
22
|
+
# One target's answer: `status` is `ok` or a failure reason; the rest is set only for `ok`.
|
|
23
|
+
Target = Data.define(:argument, :path, :absolute, :status, :reason, :reader, :diagnostics, :inputs)
|
|
24
|
+
Outcome = Data.define(:targets, :member_rows, :plugins, :warm)
|
|
25
|
+
|
|
26
|
+
# @param root — the project root (absolute); every path in the answer is relative to it
|
|
27
|
+
def initialize(configuration:, root:, arguments:, workers:)
|
|
28
|
+
@configuration = configuration
|
|
29
|
+
@root = root
|
|
30
|
+
@arguments = arguments
|
|
31
|
+
@argument_paths = arguments.to_set { |argument| File.expand_path(argument) }
|
|
32
|
+
@workers = workers
|
|
33
|
+
reset_captures
|
|
34
|
+
@capture_errors = {}
|
|
35
|
+
@plugins = []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call
|
|
39
|
+
session = build_session
|
|
40
|
+
project = session.project_files.to_h { |path| [File.expand_path(path), path] }
|
|
41
|
+
resolved = mark_duplicates(@arguments.map { |argument| resolve(argument, project) })
|
|
42
|
+
forced = resolved.filter_map { |entry| entry[:session_path] }
|
|
43
|
+
session.force_targets = forced.uniq
|
|
44
|
+
diagnostics, warm = analyse(session, forced.uniq)
|
|
45
|
+
targets = resolved.map { |entry| target(entry, diagnostics, session) }
|
|
46
|
+
Outcome.new(targets: targets, member_rows: members(targets, session), plugins: @plugins, warm: warm)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def build_session
|
|
52
|
+
cache_root = @configuration.cache_path
|
|
53
|
+
Analysis::IncrementalSession.new(
|
|
54
|
+
configuration: @configuration,
|
|
55
|
+
cache_store: Cache::Store.new(root: cache_root, max_bytes: @configuration.cache_max_bytes),
|
|
56
|
+
workers: @workers, on_file_indexed: method(:capture)
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A warm run is a recheck that forced the targets. A cold one is a baseline: a sequential baseline indexes
|
|
61
|
+
# every target in this process, a pooled one in workers where the callback never runs. The session also
|
|
62
|
+
# falls back to a baseline after a recheck whose seeds it no longer trusts, and the captures that recheck
|
|
63
|
+
# made are stale. So after a pooled baseline the captures are dropped and one forced recheck, on the
|
|
64
|
+
# baseline's fresh state, reads the targets again.
|
|
65
|
+
def analyse(session, forced)
|
|
66
|
+
fingerprint = Cache::IncrementalSnapshot.fingerprint(configuration: @configuration,
|
|
67
|
+
roots: @configuration.paths)
|
|
68
|
+
snapshot = Cache::IncrementalSnapshot.new(root: @configuration.cache_path)
|
|
69
|
+
diagnostics, warm = session.run_incremental(snapshot: snapshot, fingerprint: fingerprint)
|
|
70
|
+
missing = forced.reject { |path| @captured.key?(File.expand_path(path)) }
|
|
71
|
+
if (!warm && @workers.positive?) || !missing.empty?
|
|
72
|
+
reset_captures
|
|
73
|
+
diagnostics = session.recheck.diagnostics
|
|
74
|
+
end
|
|
75
|
+
[diagnostics, warm]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def reset_captures
|
|
79
|
+
@captured = {}
|
|
80
|
+
@contracts = {}
|
|
81
|
+
@site_paths = {}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# A file requested again, in any spelling, is answered once: the later entries read `duplicate`, naming the
|
|
85
|
+
# argument that carries the answer, so every site key stays unique in the document.
|
|
86
|
+
def mark_duplicates(entries)
|
|
87
|
+
first = {}
|
|
88
|
+
entries.map do |entry|
|
|
89
|
+
next entry unless entry[:status] == "ok"
|
|
90
|
+
|
|
91
|
+
seen = first[entry[:absolute]]
|
|
92
|
+
first[entry[:absolute]] ||= entry[:argument]
|
|
93
|
+
next entry unless seen
|
|
94
|
+
|
|
95
|
+
entry.merge(status: "duplicate", reason: "the same file as #{seen}", session_path: nil)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def resolve(argument, project)
|
|
100
|
+
absolute = File.expand_path(argument)
|
|
101
|
+
entry = { argument: argument, absolute: absolute, path: relative(absolute) }
|
|
102
|
+
return entry.merge(status: "not_found", reason: "no such file") unless File.file?(absolute)
|
|
103
|
+
return entry.merge(status: "not_ruby", reason: "not a Ruby source file") unless absolute.end_with?(".rb")
|
|
104
|
+
|
|
105
|
+
session_path = project[absolute]
|
|
106
|
+
return entry.merge(status: "outside_project", reason: "not under the configured paths:") unless session_path
|
|
107
|
+
|
|
108
|
+
entry.merge(status: "ok", session_path: session_path)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The runner's index callback. An error here never reaches the run: it is recorded, and the target reads
|
|
112
|
+
# `failed` with the reason.
|
|
113
|
+
def capture(path, parse_result, scope, index)
|
|
114
|
+
absolute = File.expand_path(path)
|
|
115
|
+
return unless @argument_paths.include?(absolute)
|
|
116
|
+
|
|
117
|
+
@plugins = Plugins.loaded(scope.environment) if @plugins.empty?
|
|
118
|
+
read_target(absolute, parse_result, scope, index)
|
|
119
|
+
@capture_errors.delete(absolute)
|
|
120
|
+
rescue StandardError => e
|
|
121
|
+
@captured.delete(absolute)
|
|
122
|
+
@capture_errors[absolute] = "#{e.class}: #{e.message}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def read_target(absolute, parse_result, scope, index)
|
|
126
|
+
reader = FileReader.new(path: relative(absolute), parse_result: parse_result, index: index,
|
|
127
|
+
spell: speller(scope.environment)).read
|
|
128
|
+
reader.sites.each do |site|
|
|
129
|
+
@site_paths[site.data["key"]] = absolute
|
|
130
|
+
site.data["members"].each { |name| read_contract(site.data["owner"], site.data["scope"], name, scope) }
|
|
131
|
+
end
|
|
132
|
+
@captured[absolute] = reader
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def read_contract(owner, member_scope, name, scope)
|
|
136
|
+
key = [owner, member_scope, name]
|
|
137
|
+
return if @contracts.key?(key)
|
|
138
|
+
|
|
139
|
+
@contracts[key] = contract_reader(scope.environment).read(owner, member_scope.to_sym, name, scope)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def target(entry, diagnostics, session)
|
|
143
|
+
base = { argument: entry[:argument], path: entry[:path], absolute: entry[:absolute], status: entry[:status],
|
|
144
|
+
reason: entry[:reason], reader: nil, diagnostics: nil, inputs: nil }
|
|
145
|
+
return Target.new(**base) unless entry[:status] == "ok"
|
|
146
|
+
|
|
147
|
+
reader = @captured[entry[:absolute]]
|
|
148
|
+
own = diagnostics.select { |diagnostic| File.expand_path(diagnostic.path.to_s) == entry[:absolute] }
|
|
149
|
+
if reader
|
|
150
|
+
return Target.new(**base, reader: reader, diagnostics: own,
|
|
151
|
+
inputs: inputs(session, entry[:session_path]))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
Target.new(**base, **uncaptured(entry[:absolute], own))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# A forced target the run did not read: one that does not parse stops before indexing; otherwise reading it
|
|
158
|
+
# failed, or the run never indexed it.
|
|
159
|
+
def uncaptured(absolute, own)
|
|
160
|
+
if parse_errors?(absolute)
|
|
161
|
+
{ status: "parse_error", reason: "the file does not parse", diagnostics: own }
|
|
162
|
+
else
|
|
163
|
+
{ status: "failed", reason: @capture_errors.fetch(absolute, "the file was not analysed") }
|
|
164
|
+
end
|
|
165
|
+
rescue StandardError => e
|
|
166
|
+
# The parse itself failed (an unsupported `target_ruby`, say): the run's own parse did too.
|
|
167
|
+
{ status: "failed", reason: "#{e.class}: #{e.message}" }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def parse_errors?(absolute)
|
|
171
|
+
Prism.parse_file(absolute, version: @configuration.target_ruby).errors.any?
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def inputs(session, session_path)
|
|
175
|
+
rows = session.dependency_sources(session_path).filter_map do |source|
|
|
176
|
+
absolute = File.expand_path(source)
|
|
177
|
+
next if absolute == File.expand_path(session_path) || !File.file?(absolute)
|
|
178
|
+
|
|
179
|
+
{ "path" => relative(absolute), "digest" => Lens.digest(File.binread(absolute)) }
|
|
180
|
+
end
|
|
181
|
+
rows.sort_by { |input| input["path"] }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def members(targets, session)
|
|
185
|
+
# A file requested twice, in any spelling, contributes its declarations once.
|
|
186
|
+
readers = targets.filter_map { |target| target.reader && [target.absolute, target.reader] }.to_h
|
|
187
|
+
sites = readers.values.flat_map(&:sites)
|
|
188
|
+
omissions = readers.values.flat_map(&:omissions)
|
|
189
|
+
requested = readers.keys.to_set
|
|
190
|
+
Members.new(sites: sites, omissions: omissions, contracts: @contracts,
|
|
191
|
+
elsewhere: elsewhere(session, requested), site_paths: @site_paths).build
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Other project files that define a member, from the per-file discovery bundles the session keeps. The bundle
|
|
195
|
+
# paths are resolved once; each member then costs one lookup per bundle.
|
|
196
|
+
def elsewhere(session, requested)
|
|
197
|
+
bundles = session.seed_bundles.filter_map do |path, bundle|
|
|
198
|
+
absolute = File.expand_path(path)
|
|
199
|
+
[relative(absolute), bundle] unless requested.include?(absolute)
|
|
200
|
+
end
|
|
201
|
+
lambda do |owner, scope, name|
|
|
202
|
+
entries = bundles.filter_map do |path, bundle|
|
|
203
|
+
line = defined_line(bundle, owner, scope, name)
|
|
204
|
+
line == false ? nil : { "path" => path, "line" => line }
|
|
205
|
+
end
|
|
206
|
+
entries.sort_by { |entry| entry["path"] }
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The line a bundle records for the member, nil when it defines the member without a recorded line (an
|
|
211
|
+
# `attr_*`), or false when it does not define it.
|
|
212
|
+
# A top-level `def` is recorded under discovery's own key, apart from `class Object`; both count for
|
|
213
|
+
# `Object#name`.
|
|
214
|
+
def defined_line(bundle, owner, scope, name)
|
|
215
|
+
owners = if owner == "Object" && scope == "instance"
|
|
216
|
+
[owner,
|
|
217
|
+
Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY]
|
|
218
|
+
else
|
|
219
|
+
[owner]
|
|
220
|
+
end
|
|
221
|
+
owners.each do |key|
|
|
222
|
+
line = defined_line_for(bundle, key, scope, name)
|
|
223
|
+
return line unless line == false
|
|
224
|
+
end
|
|
225
|
+
false
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def defined_line_for(bundle, owner, scope, name)
|
|
229
|
+
table = scope == "singleton" ? bundle[:singleton_def_sources] : bundle[:def_sources]
|
|
230
|
+
source = table&.dig(owner, name.to_sym)
|
|
231
|
+
return Integer(source.to_s.split(":").last) if source
|
|
232
|
+
|
|
233
|
+
kinds = bundle[:methods]&.dig(owner, name.to_sym)
|
|
234
|
+
return false if kinds.nil?
|
|
235
|
+
return nil if kinds == :both || kinds.to_s == scope
|
|
236
|
+
|
|
237
|
+
false
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def speller(environment)
|
|
241
|
+
@alias_index ||= SigGen::AliasIndex.build(environment: environment, signature_paths: signature_roots,
|
|
242
|
+
excluded_paths: plugin_signature_paths(environment))
|
|
243
|
+
lambda do |type, owner|
|
|
244
|
+
@alias_index.fold(SigGen::TypeElaborator.elaborate(type, environment: environment).erase_to_rbs, owner)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def contract_reader(environment)
|
|
249
|
+
@contract_reader ||= ContractReader.new(
|
|
250
|
+
Provenance.new(root: @root, signature_roots: signature_roots.map { |path| File.expand_path(path.to_s) },
|
|
251
|
+
plugin_roots: plugin_roots(environment))
|
|
252
|
+
)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# `{absolute signature directory => plugin id}` for every loaded plugin that ships signatures.
|
|
256
|
+
def plugin_roots(environment)
|
|
257
|
+
plugins = environment&.plugin_registry&.plugins || []
|
|
258
|
+
plugins.each_with_object({}) do |plugin, roots|
|
|
259
|
+
Array(plugin.signature_paths).each { |path| roots[File.expand_path(path.to_s)] = plugin.manifest.id }
|
|
260
|
+
end
|
|
261
|
+
rescue StandardError
|
|
262
|
+
{}
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def signature_roots
|
|
266
|
+
@signature_roots ||= Environment.project_signature_roots(@configuration.signature_paths)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def plugin_signature_paths(environment)
|
|
270
|
+
environment&.plugin_registry&.signature_paths || []
|
|
271
|
+
rescue StandardError
|
|
272
|
+
[]
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def relative(absolute)
|
|
276
|
+
Pathname(absolute).relative_path_from(Pathname(@root)).to_s
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Lens
|
|
5
|
+
# Tagged type slots and RBS-buffer provenance (docs/internal-spec/lens.md § Slots).
|
|
6
|
+
module Slot
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def typed(type, provenance, path: nil, from_site: nil)
|
|
10
|
+
{ "state" => "typed", "type" => type, "provenance" => provenance, "path" => path, "from_site" => from_site }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def dynamic(provenance, cause, path: nil, from_site: nil)
|
|
14
|
+
{ "state" => "dynamic", "type" => "untyped", "provenance" => provenance, "path" => path,
|
|
15
|
+
"from_site" => from_site, "cause" => cause&.to_s }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def unavailable(reason)
|
|
19
|
+
{ "state" => "unavailable", "reason" => reason }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def unavailable?(slot)
|
|
23
|
+
slot.is_a?(Hash) && slot["state"] == "unavailable"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A slot for an RBS type string read from a contract. `untyped` is a dynamic slot whose cause is the
|
|
27
|
+
# authored contract.
|
|
28
|
+
def from_rbs(type, provenance, path)
|
|
29
|
+
return dynamic(provenance, :explicit_untyped, path: path) if type == "untyped"
|
|
30
|
+
|
|
31
|
+
typed(type, provenance, path: path)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The RBS type a slot puts in an overload's `rbs` string: an unavailable slot reads `untyped`.
|
|
35
|
+
def rbs_spelling(slot)
|
|
36
|
+
slot["state"] == "typed" ? slot["type"] : "untyped"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Maps an RBS member's buffer name to a provenance (§ Slots).
|
|
41
|
+
class Provenance
|
|
42
|
+
CACHED_BUFFER = "<cached>"
|
|
43
|
+
|
|
44
|
+
# @param root — the project root (absolute)
|
|
45
|
+
# @param signature_roots — the project signature roots (absolute)
|
|
46
|
+
# @param plugin_roots — `{absolute signature directory => plugin id}` of the loaded plugins
|
|
47
|
+
def initialize(root:, signature_roots:, plugin_roots:)
|
|
48
|
+
@root = root
|
|
49
|
+
@signature_roots = signature_roots
|
|
50
|
+
@plugin_roots = plugin_roots
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# [provenance, path] for a buffer name, or nil when the buffer cannot be classified.
|
|
54
|
+
def classify(buffer_name)
|
|
55
|
+
name = buffer_name.to_s
|
|
56
|
+
return ["inline", nil] if name.start_with?("virtual:rbs-inline:")
|
|
57
|
+
return ["plugin:#{name.split(':', 3)[1]}", nil] if name.start_with?("virtual:")
|
|
58
|
+
return nil if name.empty? || name == CACHED_BUFFER
|
|
59
|
+
|
|
60
|
+
absolute = File.expand_path(name, @root)
|
|
61
|
+
plugin = @plugin_roots.find { |root, _| under?(absolute, [root]) }
|
|
62
|
+
return ["plugin:#{plugin.last}", nil] if plugin
|
|
63
|
+
return ["sig", relative(absolute)] if under?(absolute, @signature_roots)
|
|
64
|
+
|
|
65
|
+
["library", nil]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def relative(absolute)
|
|
69
|
+
Pathname(absolute).relative_path_from(Pathname(@root)).to_s
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def under?(absolute, roots)
|
|
75
|
+
roots.any? { |root| absolute == root || absolute.start_with?("#{root}/") }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/rigor/lens.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "hashing/xxh3"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
# `rigor lens`: a declaration map of the requested files with type provenance, computed as `rigor check` computes
|
|
7
|
+
# them (ADR-113; the output contract is docs/internal-spec/lens.md).
|
|
8
|
+
module Lens
|
|
9
|
+
SCHEMA = "lens/v0"
|
|
10
|
+
# Bumped on every breaking change to the `lens/v0` document (docs/internal-spec/lens.md § Status).
|
|
11
|
+
REVISION = 1
|
|
12
|
+
CAPABILITIES = %w[class module method attribute body_return diagnostics inputs].freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# A Prism location as the document's span object: 1-based lines, 0-based byte columns and offsets.
|
|
17
|
+
def span(location)
|
|
18
|
+
{ "start_line" => location.start_line, "start_column" => location.start_column,
|
|
19
|
+
"end_line" => location.end_line, "end_column" => location.end_column,
|
|
20
|
+
"start_offset" => location.start_offset, "end_offset" => location.end_offset }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The span from the first to the last of a site's contract comments, or nil when it has none.
|
|
24
|
+
def contract_span(comments)
|
|
25
|
+
return nil if comments.empty?
|
|
26
|
+
|
|
27
|
+
first = comments.first.location
|
|
28
|
+
last = comments.last.location
|
|
29
|
+
{ "start_line" => first.start_line, "start_column" => first.start_column, "end_line" => last.end_line,
|
|
30
|
+
"end_column" => last.end_column, "start_offset" => first.start_offset, "end_offset" => last.end_offset }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def digest(bytes)
|
|
34
|
+
"xxh3-64:#{Hashing::XXH3.file_hash(bytes)}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
require_relative "lens/run"
|
|
40
|
+
require_relative "lens/document"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "environment"
|
|
4
|
+
require_relative "inference/project_patched_scanner"
|
|
4
5
|
require_relative "plugin"
|
|
5
6
|
require_relative "plugin/loader"
|
|
6
7
|
require_relative "plugin/services"
|
|
@@ -55,9 +56,12 @@ module Rigor
|
|
|
55
56
|
# `source_rbs_synthesizer` runs over them at env-build time; an empty list contributes no synthesized RBS.
|
|
56
57
|
# @param feature_sources — path => source text the required-feature scan reads in place of that file (an
|
|
57
58
|
# editor buffer bound with `--tmp-file` / `--instead-of`).
|
|
58
|
-
|
|
59
|
+
# @param project_patched_methods — the `pre_eval:` registry (see {.project_patched_methods}); nil keeps
|
|
60
|
+
# the unpatched universe.
|
|
61
|
+
def build(configuration:, source_files:, feature_sources: {}, project_patched_methods: nil)
|
|
59
62
|
features = required_features(configuration, source_files, sources: feature_sources)
|
|
60
63
|
Environment.for_project(
|
|
64
|
+
project_patched_methods: project_patched_methods,
|
|
61
65
|
libraries: configuration.libraries,
|
|
62
66
|
signature_paths: configuration.signature_paths,
|
|
63
67
|
plugin_registry: load_plugin_registry(configuration),
|
|
@@ -69,6 +73,21 @@ module Rigor
|
|
|
69
73
|
bare(configuration, features)
|
|
70
74
|
end
|
|
71
75
|
|
|
76
|
+
# The patched-method registry `rigor check` types against for the project's `pre_eval:` files. It depends
|
|
77
|
+
# on those files alone, so it is built by the same {Inference::ProjectPatchedScanner} the Runner's project
|
|
78
|
+
# pre-pass calls, over the same resolved `configuration.pre_eval` entries (glob-expanded at config load,
|
|
79
|
+
# missing files skipped), without the pre-pass's plugin load / `#prepare` / dependency-source work. An
|
|
80
|
+
# editor `buffer` is honoured, so a `--tmp-file` edit of a pre_eval file is read in flight. Nil when the
|
|
81
|
+
# project lists no `pre_eval:` entry, so a project without one stays byte-identical. Fail-soft.
|
|
82
|
+
def project_patched_methods(configuration, buffer: nil)
|
|
83
|
+
return nil if configuration.pre_eval.empty?
|
|
84
|
+
|
|
85
|
+
existing = configuration.pre_eval.select { |path| File.file?(path) }
|
|
86
|
+
Inference::ProjectPatchedScanner.scan(existing, buffer: buffer).registry
|
|
87
|
+
rescue StandardError
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
72
91
|
# Issue #1700 — the {Environment::RequiredFeatures} the environment gates on: the configured paths plus
|
|
73
92
|
# `extra_files` (the file a probe inspects, which may lie outside them), the same set `rigor check` scans
|
|
74
93
|
# whichever files it is given, so a position types as `rigor check` types it.
|
|
@@ -84,7 +84,8 @@ module Rigor
|
|
|
84
84
|
|
|
85
85
|
# Human-readable reasons the caller prints. Each names something the operator can act on.
|
|
86
86
|
NO_SNAPSHOT = "no reusable `rigor check --incremental` snapshot"
|
|
87
|
-
OPAQUE_PLUGIN = "a plugin
|
|
87
|
+
OPAQUE_PLUGIN = "a plugin is opaque to the incremental fingerprint " \
|
|
88
|
+
"(no fingerprint surface, or a value it cannot digest)"
|
|
88
89
|
UNDIGESTIBLE = "the project-scan tables could not be digested"
|
|
89
90
|
UNIDENTIFIED_ENGINE = "the engine's own source tree could not be digested"
|
|
90
91
|
|
data/lib/rigor/scope.rb
CHANGED
|
@@ -251,11 +251,11 @@ module Rigor
|
|
|
251
251
|
# class in a project that publishes nothing) allocates none.
|
|
252
252
|
EMPTY_PUBLISHED_CONSTANT_IVARS = Set.new.freeze
|
|
253
253
|
# The index `||=` sites of a repeating block body whose slot an earlier run of the body may have filled,
|
|
254
|
-
# keyed by node identity and laid by a block-return pass
|
|
255
|
-
# ({#with_repeated_or_writes}; `Inference::RepeatedOrWrites` decides which).
|
|
256
|
-
# (`StatementEvaluator#index_compound_write_value`) is withheld at such a site.
|
|
257
|
-
# site, so no rebind or narrowing of a variable drops it, and a join keeps a site
|
|
258
|
-
# mark only ever withholds that reading, whose answer is the narrower one.
|
|
254
|
+
# keyed by node identity and laid at the body's entry by a block-return pass or by `StatementEvaluator`'s
|
|
255
|
+
# statement and write-back passes ({#with_repeated_or_writes}; `Inference::RepeatedOrWrites` decides which).
|
|
256
|
+
# The memoizing `||=` reading (`StatementEvaluator#index_compound_write_value`) is withheld at such a site.
|
|
257
|
+
# The mark belongs to the site, so no rebind or narrowing of a variable drops it, and a join keeps a site
|
|
258
|
+
# either arm holds: the mark only ever withholds that reading, whose answer is the narrower one.
|
|
259
259
|
EMPTY_REPEATED_OR_WRITES = {}.compare_by_identity.freeze
|
|
260
260
|
# Issue #1429 — the narrowing a guard leaves on a constant reference (`STDOUT.is_a?(StringIO)`, `if CONFIG`),
|
|
261
261
|
# keyed by the reference's spelling ({Inference::Narrowing.constant_key}: `"STDOUT"`, `"::Foo::BAR"`). A constant
|
|
@@ -457,6 +457,13 @@ module Rigor
|
|
|
457
457
|
@locals[name.to_sym]
|
|
458
458
|
end
|
|
459
459
|
|
|
460
|
+
# Issue #1840 — replaces the joined bindings of locals a branch merge only narrowed with their pre-branch
|
|
461
|
+
# binding ({Inference::UntypedRejoin}). Unlike {#with_local} this is not a write: the value is the one the
|
|
462
|
+
# branches started from, so every fact, narrowing and mark the join kept about it still holds.
|
|
463
|
+
def with_rejoined_locals(bindings)
|
|
464
|
+
rebuild(locals: @locals.merge(bindings).freeze)
|
|
465
|
+
end
|
|
466
|
+
|
|
460
467
|
def with_local(name, type)
|
|
461
468
|
bind_local(name, type, keep_marks: false)
|
|
462
469
|
end
|
|
@@ -1055,20 +1062,22 @@ module Rigor
|
|
|
1055
1062
|
@repeated_or_writes.key?(node)
|
|
1056
1063
|
end
|
|
1057
1064
|
|
|
1058
|
-
# Regex match-data globals (`$~`, `$&`, `$1..$
|
|
1065
|
+
# Regex match-data globals (`$~`, `$&`, `$1..$N`, the pre/post-match and last-paren back-references). Narrowed
|
|
1059
1066
|
# on a successful-`=~` / `case`-`when` match edge (see `Narrowing#regex_match_predicate_scopes`). They live in
|
|
1060
1067
|
# the method frame's special-variable slot, which the method's blocks and closures share (issue #1358), so a
|
|
1061
1068
|
# later call that may run a match in this frame — a match-capable call, a block that may match, a call once
|
|
1062
1069
|
# the frame has made a closure that may — rebinds every one of them, and `eval_call` forgets the narrowed
|
|
1063
1070
|
# facts here. Always safe — only drops facts, so a subsequent read falls back to the default `String | nil`.
|
|
1064
1071
|
# Program-level `$GLOBAL = ...` seeds use other names and are untouched.
|
|
1065
|
-
|
|
1066
|
-
|
|
1072
|
+
# Issue #1384 — the numbered groups are matched by name, not listed: a regex may carry more than nine captures, and
|
|
1073
|
+
# `$10` and above are the same slot as `$1`..`$9`, so forgetting must clear them too.
|
|
1074
|
+
MATCH_DATA_GLOBAL_NAME = /\A\$(?:~|&|`|'|\+|[1-9]\d*)\z/
|
|
1075
|
+
private_constant :MATCH_DATA_GLOBAL_NAME
|
|
1067
1076
|
|
|
1068
1077
|
def forget_match_globals
|
|
1069
1078
|
return self unless match_globals_bound?
|
|
1070
1079
|
|
|
1071
|
-
rebuild(globals: @globals.
|
|
1080
|
+
rebuild(globals: @globals.reject { |name, _| MATCH_DATA_GLOBAL_NAME.match?(name.name) }.freeze)
|
|
1072
1081
|
end
|
|
1073
1082
|
|
|
1074
1083
|
# Issue #1361 — this scope with every bound match-data global rebound to `Dynamic[top]`, and an unbound one left
|
|
@@ -1079,14 +1088,16 @@ module Rigor
|
|
|
1079
1088
|
return self unless match_globals_bound?
|
|
1080
1089
|
|
|
1081
1090
|
untyped = Type::Combinator.untyped
|
|
1082
|
-
rebound =
|
|
1091
|
+
rebound = @globals.each_with_object({}) do |(name, _), acc|
|
|
1092
|
+
acc[name] = untyped if MATCH_DATA_GLOBAL_NAME.match?(name.name)
|
|
1093
|
+
end
|
|
1083
1094
|
rebuild(globals: @globals.merge(rebound).freeze)
|
|
1084
1095
|
end
|
|
1085
1096
|
|
|
1086
1097
|
# True when any match-data global holds a binding, which a match edge or {#untyped_match_globals} makes: the only
|
|
1087
1098
|
# state {#forget_match_globals} can drop, and so the gate on every scan that decides whether to.
|
|
1088
1099
|
def match_globals_bound?
|
|
1089
|
-
!@globals.empty? &&
|
|
1100
|
+
!@globals.empty? && @globals.any? { |name, _| MATCH_DATA_GLOBAL_NAME.match?(name.name) }
|
|
1090
1101
|
end
|
|
1091
1102
|
|
|
1092
1103
|
# Issue #1359 — the last line read, `$_`, lives in the same frame slot as the match globals, and so on the same
|
|
@@ -57,7 +57,8 @@ module Rigor
|
|
|
57
57
|
# a parameter-only inline `# @rbs` annotation) is the ABSENCE of a return statement, not the author's
|
|
58
58
|
# word about it — the opposite of `void`. It is treated like no declaration at all: the inferred return
|
|
59
59
|
# is proposed as `tighter-return`, carrying `untyped` as the declared spelling so `--diff` still shows a
|
|
60
|
-
# declaration existed (#995; see {#declared_untyped?}).
|
|
60
|
+
# declaration existed (#995; see {#declared_untyped?}). A value literal the body proves (Integer, String,
|
|
61
|
+
# Symbol) is widened to its class there unless the `untyped` is only the inline reader's synthesis (#1489).
|
|
61
62
|
# - A `tighter-return` over the class's own declaration replaces the return and nothing else: the rest of
|
|
62
63
|
# the line is the declaration's, spelled as written, so `--overwrite` never changes which calls the
|
|
63
64
|
# signature accepts. A declaration with more than one overload, or one spelled as an `alias`, is left
|
|
@@ -1416,14 +1417,53 @@ module Rigor
|
|
|
1416
1417
|
declared.is_a?(Type::Dynamic)
|
|
1417
1418
|
end
|
|
1418
1419
|
|
|
1419
|
-
# Proposed without {#
|
|
1420
|
-
#
|
|
1421
|
-
#
|
|
1422
|
-
# `
|
|
1420
|
+
# Proposed without {#tighter?}: a declared `untyped` return carries no information to weigh it against, the
|
|
1421
|
+
# same position a method with no declaration at all is in. Over a declaration the author wrote, though, a
|
|
1422
|
+
# VALUE literal the body proves (`-> 1`, `-> "x"`, `-> :k`) is an implementation detail the `untyped` was
|
|
1423
|
+
# abstracting over, so it is widened to its class (`1 | 2` to `Integer`, `"S" | nil` to `(String | nil)`; #1489).
|
|
1424
|
+
# `nil`, `true` and `false` are honest types and stay, as does a computed `Float`. An `untyped` that is only
|
|
1425
|
+
# the inline reader's synthesis ({#synthesized_declaration?}) is no word of the author's and keeps the
|
|
1426
|
+
# strictest carrier. `declared_return_rbs` carries `"untyped"` so the `[tighter, was: untyped]` print tag
|
|
1427
|
+
# says a declaration existed.
|
|
1423
1428
|
def declared_untyped_candidate(path, def_node, class_name, kind, inferred, method_def, rendered: nil)
|
|
1429
|
+
unless synthesized_declaration?(method_def)
|
|
1430
|
+
widened = widen_value_literals(inferred)
|
|
1431
|
+
unless widened.equal?(inferred)
|
|
1432
|
+
# A `rendered` line was spelled from the unwidened type; there is nothing to rebuild it from.
|
|
1433
|
+
return equivalent(path, def_node, class_name, kind, inferred, "untyped") if rendered
|
|
1434
|
+
|
|
1435
|
+
inferred = widened
|
|
1436
|
+
end
|
|
1437
|
+
end
|
|
1438
|
+
|
|
1424
1439
|
tighter_return_candidate(path, def_node, class_name, kind, inferred, method_def, "untyped", rendered)
|
|
1425
1440
|
end
|
|
1426
1441
|
|
|
1442
|
+
VALUE_LITERAL_CLASSES = [Integer, String, Symbol].freeze
|
|
1443
|
+
private_constant :VALUE_LITERAL_CLASSES
|
|
1444
|
+
|
|
1445
|
+
# The same object back when nothing needed widening.
|
|
1446
|
+
def widen_value_literals(type)
|
|
1447
|
+
case type
|
|
1448
|
+
when Type::Constant
|
|
1449
|
+
return type unless VALUE_LITERAL_CLASSES.any? { |klass| type.value.instance_of?(klass) }
|
|
1450
|
+
|
|
1451
|
+
Type::Combinator.nominal_of(type.value.class.name)
|
|
1452
|
+
when Type::Union
|
|
1453
|
+
members = type.members.map { |member| widen_value_literals(member) }
|
|
1454
|
+
members.each_with_index.all? { |m, i| m.equal?(type.members[i]) } ? type : Type::Combinator.union(*members)
|
|
1455
|
+
else
|
|
1456
|
+
type
|
|
1457
|
+
end
|
|
1458
|
+
end
|
|
1459
|
+
|
|
1460
|
+
# A declaration only the inline reader's synthesis (a `virtual:` buffer) supplies — ADR-93 gives every `def` an
|
|
1461
|
+
# `untyped` member, annotated or not — is no word of the author's about the return, so it keeps the proposal
|
|
1462
|
+
# a method with no declaration at all gets: the strictest carrier the body proves.
|
|
1463
|
+
def synthesized_declaration?(method_def)
|
|
1464
|
+
method_def.defs.all? { |type_def| type_def.member.location&.buffer&.name.to_s.start_with?("virtual:") }
|
|
1465
|
+
end
|
|
1466
|
+
|
|
1427
1467
|
def build_declared_return(method_def)
|
|
1428
1468
|
translated = method_def.method_types.filter_map { |mt| translate_method_type_return(mt) }
|
|
1429
1469
|
return nil if translated.empty?
|
data/lib/rigor/version.rb
CHANGED