rigortype 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +40 -13
- data/lib/rigor/analysis/fact_store.rb +15 -3
- data/lib/rigor/analysis/result.rb +11 -3
- data/lib/rigor/analysis/run_stats.rb +193 -0
- data/lib/rigor/analysis/runner.rb +387 -12
- data/lib/rigor/analysis/worker_session.rb +327 -0
- data/lib/rigor/builtins/imported_refinements.rb +6 -2
- data/lib/rigor/builtins/regex_refinement.rb +17 -12
- data/lib/rigor/cache/rbs_descriptor.rb +3 -1
- data/lib/rigor/cache/store.rb +40 -7
- data/lib/rigor/cli.rb +52 -2
- data/lib/rigor/configuration.rb +131 -6
- data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
- data/lib/rigor/environment/class_registry.rb +12 -3
- data/lib/rigor/environment/lockfile_resolver.rb +125 -0
- data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
- data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
- data/lib/rigor/environment/rbs_loader.rb +194 -6
- data/lib/rigor/environment/reflection.rb +152 -0
- data/lib/rigor/environment.rb +78 -6
- data/lib/rigor/inference/acceptance.rb +35 -1
- data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
- data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
- data/lib/rigor/inference/expression_typer.rb +12 -2
- data/lib/rigor/inference/macro_block_self_type.rb +96 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -29
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
- data/lib/rigor/inference/method_dispatcher/method_folding.rb +18 -1
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -40
- data/lib/rigor/inference/method_dispatcher.rb +128 -3
- data/lib/rigor/inference/method_parameter_binder.rb +21 -11
- data/lib/rigor/inference/narrowing.rb +127 -8
- data/lib/rigor/inference/synthetic_method.rb +86 -0
- data/lib/rigor/inference/synthetic_method_index.rb +82 -0
- data/lib/rigor/inference/synthetic_method_scanner.rb +521 -0
- data/lib/rigor/plugin/blueprint.rb +60 -0
- data/lib/rigor/plugin/loader.rb +3 -1
- data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
- data/lib/rigor/plugin/macro/external_file.rb +143 -0
- data/lib/rigor/plugin/macro/heredoc_template.rb +201 -0
- data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
- data/lib/rigor/plugin/macro.rb +31 -0
- data/lib/rigor/plugin/manifest.rb +78 -7
- data/lib/rigor/plugin/registry.rb +32 -2
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/trinary.rb +15 -11
- data/lib/rigor/type/bot.rb +6 -3
- data/lib/rigor/type/combinator.rb +12 -1
- data/lib/rigor/type/integer_range.rb +7 -7
- data/lib/rigor/type/refined.rb +18 -12
- data/lib/rigor/type/top.rb +4 -3
- data/lib/rigor/type_node/generic.rb +7 -1
- data/lib/rigor/type_node/identifier.rb +9 -1
- data/lib/rigor/type_node/string_literal.rb +4 -1
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +5 -2
- data/sig/rigor/plugin/blueprint.rbs +7 -0
- data/sig/rigor/plugin/manifest.rbs +1 -1
- data/sig/rigor/plugin/registry.rbs +14 -1
- data/sig/rigor.rbs +35 -2
- metadata +39 -1
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../environment"
|
|
6
|
+
require_relative "../scope"
|
|
7
|
+
require_relative "../cache/store"
|
|
8
|
+
require_relative "../plugin"
|
|
9
|
+
require_relative "../rbs_extended/reporter"
|
|
10
|
+
require_relative "../reflection"
|
|
11
|
+
require_relative "../type/combinator"
|
|
12
|
+
require_relative "../inference/coverage_scanner"
|
|
13
|
+
require_relative "../inference/scope_indexer"
|
|
14
|
+
require_relative "../inference/method_dispatcher/file_folding"
|
|
15
|
+
require_relative "check_rules"
|
|
16
|
+
require_relative "dependency_source_inference"
|
|
17
|
+
require_relative "diagnostic"
|
|
18
|
+
|
|
19
|
+
module Rigor
|
|
20
|
+
module Analysis
|
|
21
|
+
# ADR-15 Phase 4a — per-worker analysis substrate.
|
|
22
|
+
# [ADR-15](../../../docs/adr/15-ractor-concurrency.md)
|
|
23
|
+
# § Phase 4 carves the eventual Ractor-isolated worker pool
|
|
24
|
+
# into three sub-phases; this is the substrate that 4b will
|
|
25
|
+
# wrap in `Ractor.new` and 4c will gate behind
|
|
26
|
+
# `RIGOR_RACTOR_WORKERS`. NO Ractor in the loop yet — 4a
|
|
27
|
+
# exists so the per-worker ownership boundary is testable in
|
|
28
|
+
# the absence of any Ractor coordination.
|
|
29
|
+
#
|
|
30
|
+
# The constructor takes only `Ractor.shareable?` inputs:
|
|
31
|
+
#
|
|
32
|
+
# - `configuration` — Phase 2a ({Rigor::Configuration} is
|
|
33
|
+
# `Ractor.shareable?`).
|
|
34
|
+
# - `cache_store` — frozen-shareable handle is NOT a precondition;
|
|
35
|
+
# future 4b workers build their OWN Store at the shared
|
|
36
|
+
# `cache_root` directory. 4a accepts an already-built Store
|
|
37
|
+
# for the no-Ractor coordinator path.
|
|
38
|
+
# - `plugin_blueprints` — Phase 3a
|
|
39
|
+
# (`Array<Plugin::Blueprint>` is `Ractor.shareable?`).
|
|
40
|
+
# - `explain` — Boolean.
|
|
41
|
+
#
|
|
42
|
+
# Internally the session OWNS (and never shares):
|
|
43
|
+
#
|
|
44
|
+
# - {Rigor::Plugin::Services} bound to the per-worker Store.
|
|
45
|
+
# - {Rigor::Plugin::Registry} materialised from the blueprints
|
|
46
|
+
# via {Rigor::Plugin::Registry.materialize}; each plugin
|
|
47
|
+
# instance, with its mutable per-run accumulators
|
|
48
|
+
# (`@reachable_absurd_nodes`, `*_index`, …) lives entirely
|
|
49
|
+
# inside this session.
|
|
50
|
+
# - {Rigor::RbsExtended::Reporter} +
|
|
51
|
+
# {Rigor::Analysis::DependencySourceInference::BoundaryCrossReporter}
|
|
52
|
+
# (Mutex-bearing; intentionally per-worker — the runner
|
|
53
|
+
# merges entries post-pool via {#drain_reporters}).
|
|
54
|
+
# - {Rigor::Environment} threaded with the per-worker reporters
|
|
55
|
+
# so reporter writes from inference / dispatcher accumulate
|
|
56
|
+
# into the worker's own state.
|
|
57
|
+
#
|
|
58
|
+
# Plugin `prepare` runs ONCE at construction time so each
|
|
59
|
+
# worker is "warm" by the time `#analyze` is first called. Any
|
|
60
|
+
# raise from `prepare` is captured into {#prepare_diagnostics}
|
|
61
|
+
# so the runner can surface them alongside the per-file
|
|
62
|
+
# diagnostic stream.
|
|
63
|
+
#
|
|
64
|
+
# Equivalence contract (proven by spec): given identical
|
|
65
|
+
# `(configuration, cache_store, plugin_blueprints)`, the
|
|
66
|
+
# multiset of diagnostics from
|
|
67
|
+
# `paths.flat_map { |p| session.analyze(p) }` plus
|
|
68
|
+
# {#prepare_diagnostics} plus reporter drains MUST equal the
|
|
69
|
+
# corresponding subset of {Rigor::Analysis::Runner#run}'s
|
|
70
|
+
# output (modulo severity-profile re-stamping, which the
|
|
71
|
+
# session leaves to the caller because it is a per-run
|
|
72
|
+
# aggregate concern).
|
|
73
|
+
class WorkerSession
|
|
74
|
+
attr_reader :configuration, :cache_store, :services, :plugin_registry,
|
|
75
|
+
:dependency_source_index, :environment,
|
|
76
|
+
:rbs_extended_reporter, :boundary_cross_reporter,
|
|
77
|
+
:prepare_diagnostics
|
|
78
|
+
|
|
79
|
+
# @param configuration [Rigor::Configuration]
|
|
80
|
+
# @param cache_store [Rigor::Cache::Store, nil] persistent
|
|
81
|
+
# cache the session exposes to plugin-side producers and
|
|
82
|
+
# the RBS loader. Pass `nil` to disable caching.
|
|
83
|
+
# @param plugin_blueprints [Array<Rigor::Plugin::Blueprint>]
|
|
84
|
+
# replay descriptors. Empty array yields a session with
|
|
85
|
+
# no plugin contributions.
|
|
86
|
+
# @param explain [Boolean] when true, `#analyze` additionally
|
|
87
|
+
# emits one `:info` `fallback` diagnostic per
|
|
88
|
+
# directly-unrecognised node, mirroring
|
|
89
|
+
# {Rigor::Analysis::Runner#explain_diagnostics}.
|
|
90
|
+
def initialize(configuration:, cache_store: nil, # rubocop:disable Metrics/MethodLength
|
|
91
|
+
plugin_blueprints: [], explain: false)
|
|
92
|
+
@configuration = configuration
|
|
93
|
+
@cache_store = cache_store
|
|
94
|
+
@explain = explain
|
|
95
|
+
|
|
96
|
+
# NOTE: `Inference::MethodDispatcher::FileFolding.fold_platform_specific_paths`
|
|
97
|
+
# is process-global state. Writing it from a non-main
|
|
98
|
+
# Ractor would raise `Ractor::IsolationError`, so the
|
|
99
|
+
# session does NOT touch it — the CALLER (typically
|
|
100
|
+
# {Rigor::Analysis::Runner#run}) is responsible for
|
|
101
|
+
# setting it on the main Ractor before spawning the
|
|
102
|
+
# pool. The substrate stays Ractor-safe by construction.
|
|
103
|
+
@rbs_extended_reporter = RbsExtended::Reporter.new
|
|
104
|
+
@boundary_cross_reporter = DependencySourceInference::BoundaryCrossReporter.new
|
|
105
|
+
@dependency_source_index = DependencySourceInference::Builder.build(configuration.dependencies)
|
|
106
|
+
|
|
107
|
+
@services = Plugin::Services.new(
|
|
108
|
+
reflection: Reflection,
|
|
109
|
+
type: Type::Combinator,
|
|
110
|
+
configuration: configuration,
|
|
111
|
+
cache_store: cache_store,
|
|
112
|
+
trust_policy: build_trust_policy
|
|
113
|
+
)
|
|
114
|
+
@plugin_registry = Plugin::Registry.materialize(
|
|
115
|
+
blueprints: plugin_blueprints, services: @services
|
|
116
|
+
)
|
|
117
|
+
@environment = Environment.for_project(
|
|
118
|
+
libraries: configuration.libraries,
|
|
119
|
+
signature_paths: configuration.signature_paths,
|
|
120
|
+
cache_store: cache_store,
|
|
121
|
+
plugin_registry: @plugin_registry,
|
|
122
|
+
dependency_source_index: @dependency_source_index,
|
|
123
|
+
rbs_extended_reporter: @rbs_extended_reporter,
|
|
124
|
+
boundary_cross_reporter: @boundary_cross_reporter,
|
|
125
|
+
bundler_bundle_path: configuration.bundler_bundle_path,
|
|
126
|
+
bundler_auto_detect: configuration.bundler_auto_detect,
|
|
127
|
+
bundler_lockfile: configuration.bundler_lockfile,
|
|
128
|
+
rbs_collection_lockfile: configuration.rbs_collection_lockfile,
|
|
129
|
+
rbs_collection_auto_detect: configuration.rbs_collection_auto_detect
|
|
130
|
+
)
|
|
131
|
+
@prepare_diagnostics = run_plugin_prepare.freeze
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Equivalent of {Rigor::Analysis::Runner#analyze_file} +
|
|
135
|
+
# `plugin_emitted_diagnostics` + `explain_diagnostics`.
|
|
136
|
+
# Returns a flat `Array<Diagnostic>` for the file. Severity
|
|
137
|
+
# profile re-stamping is intentionally NOT applied — that
|
|
138
|
+
# is a per-run aggregate concern handled by the caller.
|
|
139
|
+
def analyze(path)
|
|
140
|
+
parse_result = Prism.parse_file(path, version: @configuration.target_ruby)
|
|
141
|
+
return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
|
|
142
|
+
|
|
143
|
+
scope = Scope.empty(environment: @environment, source_path: path)
|
|
144
|
+
index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
|
|
145
|
+
diagnostics = CheckRules.diagnose(
|
|
146
|
+
path: path,
|
|
147
|
+
root: parse_result.value,
|
|
148
|
+
scope_index: index,
|
|
149
|
+
comments: parse_result.comments,
|
|
150
|
+
disabled_rules: @configuration.disabled_rules
|
|
151
|
+
)
|
|
152
|
+
diagnostics += plugin_emitted_diagnostics(path, parse_result.value, scope)
|
|
153
|
+
diagnostics + explain_diagnostics(path, parse_result.value, scope)
|
|
154
|
+
rescue Errno::ENOENT => e
|
|
155
|
+
[analyzer_error(path, e.message)]
|
|
156
|
+
rescue StandardError => e
|
|
157
|
+
[analyzer_error(path, "internal analyzer error: #{e.class}: #{e.message}")]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Read-once snapshot of the per-worker reporters so the
|
|
161
|
+
# caller (or the eventual Phase 4b pool aggregator) can
|
|
162
|
+
# merge into a single coordinator-side reporter. Both
|
|
163
|
+
# reporters dedupe at write time, so a post-hoc concat +
|
|
164
|
+
# de-dup at the entry-key level is sound.
|
|
165
|
+
def drain_reporters
|
|
166
|
+
{
|
|
167
|
+
rbs_extended: {
|
|
168
|
+
unresolved_payloads: @rbs_extended_reporter.unresolved_payloads,
|
|
169
|
+
lossy_projections: @rbs_extended_reporter.lossy_projections
|
|
170
|
+
},
|
|
171
|
+
boundary_cross: @boundary_cross_reporter.entries
|
|
172
|
+
}
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
# Mirrors {Runner#build_trust_policy}. Workers under Phase
|
|
178
|
+
# 4b will need the same trust derivation, and the
|
|
179
|
+
# configuration is already shareable, so deriving it inside
|
|
180
|
+
# the session keeps the substrate decoupled from the
|
|
181
|
+
# coordinator's helper.
|
|
182
|
+
def build_trust_policy
|
|
183
|
+
trusted_gems = @configuration.plugins.map { |entry| trusted_gem_name(entry) }.uniq
|
|
184
|
+
roots = [Dir.pwd]
|
|
185
|
+
Array(@configuration.signature_paths).each { |sp| roots << File.expand_path(sp) }
|
|
186
|
+
trusted_gems.each do |gem_name|
|
|
187
|
+
path = trusted_gem_root(gem_name)
|
|
188
|
+
roots << path if path
|
|
189
|
+
end
|
|
190
|
+
@configuration.plugins_io_allowed_paths.each { |p| roots << File.expand_path(p) }
|
|
191
|
+
|
|
192
|
+
Plugin::TrustPolicy.new(
|
|
193
|
+
trusted_gems: trusted_gems,
|
|
194
|
+
allowed_read_roots: roots,
|
|
195
|
+
network_policy: @configuration.plugins_io_network,
|
|
196
|
+
allowed_url_hosts: @configuration.plugins_io_allowed_url_hosts
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def trusted_gem_name(entry)
|
|
201
|
+
case entry
|
|
202
|
+
when String then entry
|
|
203
|
+
when Hash then entry["gem"] || entry["id"]
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def trusted_gem_root(gem_name)
|
|
208
|
+
return nil if gem_name.nil? || gem_name.empty?
|
|
209
|
+
|
|
210
|
+
spec = Gem.loaded_specs[gem_name]
|
|
211
|
+
spec&.full_gem_path # rigor:disable undefined-method
|
|
212
|
+
rescue StandardError
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def run_plugin_prepare
|
|
217
|
+
return [] if @plugin_registry.empty?
|
|
218
|
+
|
|
219
|
+
@plugin_registry.plugins.flat_map do |plugin|
|
|
220
|
+
plugin.prepare(plugin.services)
|
|
221
|
+
[]
|
|
222
|
+
rescue StandardError => e
|
|
223
|
+
[plugin_prepare_error_diagnostic(plugin, e)]
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def plugin_prepare_error_diagnostic(plugin, error)
|
|
228
|
+
plugin_id = safe_plugin_id(plugin)
|
|
229
|
+
Diagnostic.new(
|
|
230
|
+
path: ".rigor.yml",
|
|
231
|
+
line: 1,
|
|
232
|
+
column: 1,
|
|
233
|
+
message: "plugin #{plugin_id.inspect} raised during prepare: " \
|
|
234
|
+
"#{error.class}: #{error.message}",
|
|
235
|
+
severity: :error,
|
|
236
|
+
rule: "runtime-error",
|
|
237
|
+
source_family: :plugin_loader
|
|
238
|
+
)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def plugin_emitted_diagnostics(path, root, scope)
|
|
242
|
+
return [] if @plugin_registry.empty?
|
|
243
|
+
|
|
244
|
+
@plugin_registry.plugins.flat_map do |plugin|
|
|
245
|
+
collect_plugin_diagnostics(plugin, path, root, scope)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def collect_plugin_diagnostics(plugin, path, root, scope)
|
|
250
|
+
raw = plugin.diagnostics_for_file(path: path, scope: scope, root: root)
|
|
251
|
+
Array(raw).map { |diagnostic| stamp_plugin_diagnostic(diagnostic, plugin.manifest.id) }
|
|
252
|
+
rescue StandardError => e
|
|
253
|
+
[plugin_runtime_error_diagnostic(path, plugin, e)]
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def stamp_plugin_diagnostic(diagnostic, plugin_id)
|
|
257
|
+
Diagnostic.new(
|
|
258
|
+
path: diagnostic.path,
|
|
259
|
+
line: diagnostic.line,
|
|
260
|
+
column: diagnostic.column,
|
|
261
|
+
message: diagnostic.message,
|
|
262
|
+
severity: diagnostic.severity,
|
|
263
|
+
rule: diagnostic.rule,
|
|
264
|
+
source_family: "plugin.#{plugin_id}"
|
|
265
|
+
)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def plugin_runtime_error_diagnostic(path, plugin, error)
|
|
269
|
+
plugin_id = safe_plugin_id(plugin)
|
|
270
|
+
Diagnostic.new(
|
|
271
|
+
path: path,
|
|
272
|
+
line: 1,
|
|
273
|
+
column: 1,
|
|
274
|
+
message: "plugin #{plugin_id.inspect} raised during diagnostics_for_file: " \
|
|
275
|
+
"#{error.class}: #{error.message}",
|
|
276
|
+
severity: :error,
|
|
277
|
+
rule: "runtime-error",
|
|
278
|
+
source_family: :plugin_loader
|
|
279
|
+
)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def safe_plugin_id(plugin)
|
|
283
|
+
plugin.manifest.id
|
|
284
|
+
rescue StandardError
|
|
285
|
+
plugin.class.to_s
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def explain_diagnostics(path, root, scope)
|
|
289
|
+
return [] unless @explain
|
|
290
|
+
|
|
291
|
+
result = Inference::CoverageScanner.new(scope: scope).scan(root)
|
|
292
|
+
result.events.map { |event| explain_diagnostic(path, event) }
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def explain_diagnostic(path, event)
|
|
296
|
+
location = event.location
|
|
297
|
+
line = location ? location.start_line : 1
|
|
298
|
+
column = location ? location.start_column + 1 : 1
|
|
299
|
+
Diagnostic.new(
|
|
300
|
+
path: path,
|
|
301
|
+
line: line,
|
|
302
|
+
column: column,
|
|
303
|
+
message: "fail-soft fallback at #{event.node_class}: #{event.inner_type.describe(:short)}",
|
|
304
|
+
severity: :info,
|
|
305
|
+
rule: "fallback"
|
|
306
|
+
)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def parse_diagnostics(path, parse_result)
|
|
310
|
+
parse_result.errors.map do |error|
|
|
311
|
+
location = error.location
|
|
312
|
+
Diagnostic.new(
|
|
313
|
+
path: path,
|
|
314
|
+
line: location.start_line,
|
|
315
|
+
column: location.start_column + 1,
|
|
316
|
+
message: error.message,
|
|
317
|
+
severity: :error
|
|
318
|
+
)
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def analyzer_error(path, message)
|
|
323
|
+
Diagnostic.new(path: path, line: 1, column: 1, message: message, severity: :error)
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
@@ -419,9 +419,13 @@ module Rigor
|
|
|
419
419
|
elsif (literal = @scanner.scan(SIGNED_INT))
|
|
420
420
|
TypeNode::IntegerLiteral.new(value: Integer(literal))
|
|
421
421
|
elsif @scanner.scan(SYMBOL_LITERAL)
|
|
422
|
-
|
|
422
|
+
# StringScanner#[] accepts Symbol for named captures
|
|
423
|
+
# (Ruby behaviour); upstream RBS shim only declares the
|
|
424
|
+
# positional-capture (Integer) overload, so the
|
|
425
|
+
# argument-type-mismatch diagnostic is suppressed.
|
|
426
|
+
TypeNode::SymbolLiteral.new(value: @scanner[:value].to_sym) # rigor:disable argument-type-mismatch
|
|
423
427
|
elsif @scanner.scan(STRING_LITERAL)
|
|
424
|
-
TypeNode::StringLiteral.new(value: @scanner[:value])
|
|
428
|
+
TypeNode::StringLiteral.new(value: @scanner[:value]) # rigor:disable argument-type-mismatch
|
|
425
429
|
else
|
|
426
430
|
parse_type_ast
|
|
427
431
|
end
|
|
@@ -9,7 +9,7 @@ module Rigor
|
|
|
9
9
|
# (`decimal-int-string`, `hex-int-string`, `octal-int-string`,
|
|
10
10
|
# `lowercase-string`, `uppercase-string`, `numeric-string`).
|
|
11
11
|
# See `docs/type-specification/imported-built-in-types.md` for
|
|
12
|
-
# the registry the refinements come from and `docs/
|
|
12
|
+
# the registry the refinements come from and `docs/ROADMAP.md`
|
|
13
13
|
# § "v0.1.1 — Planned" Track 1 slice 1 for the binding scope of
|
|
14
14
|
# this recogniser.
|
|
15
15
|
#
|
|
@@ -47,17 +47,22 @@ module Rigor
|
|
|
47
47
|
QUANTIFIER_SOURCE = '(?:\+|\{\d+(?:,\d+)?\})'
|
|
48
48
|
private_constant :QUANTIFIER_SOURCE
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
50
|
+
# ADR-15 Phase 4b.x — `Ractor.make_shareable` (not `.freeze`)
|
|
51
|
+
# because the outer Array contains two-element `[Regexp, Symbol]`
|
|
52
|
+
# rows whose inner Arrays are not frozen by the outer freeze.
|
|
53
|
+
# A worker Ractor iterating `RULES.find { ... }` would trip
|
|
54
|
+
# `Ractor::IsolationError` on the first row access.
|
|
55
|
+
RULES = Ractor.make_shareable([
|
|
56
|
+
[/\A\\d#{QUANTIFIER_SOURCE}\z/, :decimal_int_string],
|
|
57
|
+
[/\A\\h#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
|
|
58
|
+
[/\A\[0-9a-fA-F\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
|
|
59
|
+
[/\A\[0-9a-f\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
|
|
60
|
+
[/\A\[0-9A-F\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
|
|
61
|
+
[/\A\[0-7\]#{QUANTIFIER_SOURCE}\z/, :octal_int_string],
|
|
62
|
+
[/\A\[a-z\]#{QUANTIFIER_SOURCE}\z/, :lowercase_string],
|
|
63
|
+
[/\A\[A-Z\]#{QUANTIFIER_SOURCE}\z/, :uppercase_string],
|
|
64
|
+
[/\A\[\[:digit:\]\]#{QUANTIFIER_SOURCE}\z/, :numeric_string]
|
|
65
|
+
])
|
|
61
66
|
private_constant :RULES
|
|
62
67
|
|
|
63
68
|
BOUND_RE = /\{(\d+)(?:,(\d+))?\}\z/
|
|
@@ -28,7 +28,9 @@ module Rigor
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def self.file_entries(loader)
|
|
31
|
-
loader.signature_paths
|
|
31
|
+
roots = loader.signature_paths +
|
|
32
|
+
Rigor::Environment::RbsLoader.vendored_gem_sig_paths
|
|
33
|
+
roots.flat_map do |root|
|
|
32
34
|
next [] unless root.directory?
|
|
33
35
|
|
|
34
36
|
Dir.glob(root.join("**", "*.rbs")).map do |path|
|
data/lib/rigor/cache/store.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require "json"
|
|
6
|
+
require "monitor"
|
|
6
7
|
require "securerandom"
|
|
7
8
|
|
|
8
9
|
require_relative "descriptor"
|
|
@@ -21,7 +22,7 @@ module Rigor
|
|
|
21
22
|
# next write replaces the bad entry. The trailing SHA-256 catches
|
|
22
23
|
# accidental corruption (partial writes, FS errors); it is **not**
|
|
23
24
|
# a security boundary, per ADR-2's trusted-gem trust model.
|
|
24
|
-
class Store
|
|
25
|
+
class Store # rubocop:disable Metrics/ClassLength
|
|
25
26
|
# Header literal: 5-byte ASCII magic, 1-byte separator, 1-byte
|
|
26
27
|
# format version. Bumped on incompatible on-disk format changes
|
|
27
28
|
# (independent of {Descriptor::SCHEMA_VERSION}, which covers
|
|
@@ -36,6 +37,24 @@ module Rigor
|
|
|
36
37
|
@misses = 0
|
|
37
38
|
@writes = 0
|
|
38
39
|
@by_producer = Hash.new { |h, k| h[k] = { hits: 0, misses: 0, writes: 0 } }
|
|
40
|
+
# Process-level in-memory layer keyed by
|
|
41
|
+
# `(producer_id, cache_key)`. Avoids the disk read +
|
|
42
|
+
# `Marshal.load` cost (the dominant share of repeated
|
|
43
|
+
# cache-hit calls per stackprof) when many short-lived
|
|
44
|
+
# `Analysis::Runner` instances share one `Store` — the
|
|
45
|
+
# spec process, the LSP daemon's repeated re-check
|
|
46
|
+
# path, and any other "many runs, same project" loop.
|
|
47
|
+
# Keys are content-derived (descriptor digests), so
|
|
48
|
+
# cross-fixture contamination is impossible.
|
|
49
|
+
@memo = {}
|
|
50
|
+
# `Analysis::Runner` walks files concurrently (file-
|
|
51
|
+
# level parallelism); the per-file workers share one
|
|
52
|
+
# Store. The monitor guards `@memo` + the counter
|
|
53
|
+
# hashes against concurrent writes. The Monitor is
|
|
54
|
+
# re-entrant so producer blocks can recursively
|
|
55
|
+
# consult the Store (e.g. one cache layer building on
|
|
56
|
+
# another) without dead-locking.
|
|
57
|
+
@monitor = Monitor.new
|
|
39
58
|
end
|
|
40
59
|
|
|
41
60
|
attr_reader :root
|
|
@@ -49,8 +68,10 @@ module Rigor
|
|
|
49
68
|
#
|
|
50
69
|
# @return [Hash] `{ hits:, misses:, writes:, by_producer: { id => { hits:, misses:, writes: } } }`
|
|
51
70
|
def stats
|
|
52
|
-
|
|
53
|
-
|
|
71
|
+
@monitor.synchronize do
|
|
72
|
+
per_producer = @by_producer.transform_values { |counts| counts.dup.freeze }.freeze
|
|
73
|
+
{ hits: @hits, misses: @misses, writes: @writes, by_producer: per_producer }.freeze
|
|
74
|
+
end
|
|
54
75
|
end
|
|
55
76
|
|
|
56
77
|
# Walks the on-disk cache rooted at `root` and reports a
|
|
@@ -128,18 +149,30 @@ module Rigor
|
|
|
128
149
|
ensure_schema_version!
|
|
129
150
|
|
|
130
151
|
key = descriptor.cache_key_for(producer_id: producer_id, params: params)
|
|
131
|
-
|
|
152
|
+
memo_key = [producer_id, key].freeze
|
|
153
|
+
memoed = @monitor.synchronize { @memo[memo_key] if @memo.key?(memo_key) }
|
|
154
|
+
unless memoed.nil?
|
|
155
|
+
@monitor.synchronize { record(:hits, producer_id) }
|
|
156
|
+
return memoed
|
|
157
|
+
end
|
|
132
158
|
|
|
159
|
+
path = entry_path(producer_id, key)
|
|
133
160
|
cached = read_entry(path, deserialize: deserialize)
|
|
134
161
|
unless cached.nil?
|
|
135
|
-
|
|
162
|
+
@monitor.synchronize do
|
|
163
|
+
record(:hits, producer_id)
|
|
164
|
+
@memo[memo_key] = cached.value
|
|
165
|
+
end
|
|
136
166
|
return cached.value
|
|
137
167
|
end
|
|
138
168
|
|
|
139
|
-
record(:misses, producer_id)
|
|
140
169
|
value = block.call
|
|
141
170
|
write_entry(path, descriptor, value, serialize: serialize)
|
|
142
|
-
|
|
171
|
+
@monitor.synchronize do
|
|
172
|
+
record(:misses, producer_id)
|
|
173
|
+
record(:writes, producer_id)
|
|
174
|
+
@memo[memo_key] = value
|
|
175
|
+
end
|
|
143
176
|
value
|
|
144
177
|
end
|
|
145
178
|
|
data/lib/rigor/cli.rb
CHANGED
|
@@ -82,15 +82,36 @@ module Rigor
|
|
|
82
82
|
runner = Analysis::Runner.new(
|
|
83
83
|
configuration: configuration,
|
|
84
84
|
explain: options.fetch(:explain),
|
|
85
|
-
cache_store: cache_store
|
|
85
|
+
cache_store: cache_store,
|
|
86
|
+
collect_stats: options.fetch(:stats),
|
|
87
|
+
workers: resolve_workers(options, configuration)
|
|
86
88
|
)
|
|
87
89
|
result = runner.run(paths)
|
|
88
90
|
|
|
89
91
|
write_result(result, options.fetch(:format))
|
|
92
|
+
write_run_stats(result.stats) if result.stats
|
|
90
93
|
write_cache_stats(cache_root, runner.cache_store) if options.fetch(:cache_stats)
|
|
91
94
|
result.success? ? 0 : 1
|
|
92
95
|
end
|
|
93
96
|
|
|
97
|
+
# ADR-15 Phase 4c — resolves the worker count by
|
|
98
|
+
# precedence: CLI `--workers=N` (most explicit) > env
|
|
99
|
+
# `RIGOR_RACTOR_WORKERS` > config `.rigor.yml`
|
|
100
|
+
# `parallel.workers:` > 0 (sequential default). Returns
|
|
101
|
+
# an Integer; non-numeric values raise so typos fail
|
|
102
|
+
# loudly. CLI / env may pass a negative value — clamped
|
|
103
|
+
# to 0 (sequential) so a stray `-1` doesn't crash the
|
|
104
|
+
# pool spawn loop.
|
|
105
|
+
def resolve_workers(options, configuration)
|
|
106
|
+
cli_value = options[:workers]
|
|
107
|
+
return [Integer(cli_value), 0].max if cli_value
|
|
108
|
+
|
|
109
|
+
env_value = ENV.fetch("RIGOR_RACTOR_WORKERS", nil)
|
|
110
|
+
return [Integer(env_value), 0].max if env_value && !env_value.empty?
|
|
111
|
+
|
|
112
|
+
configuration.parallel_workers
|
|
113
|
+
end
|
|
114
|
+
|
|
94
115
|
def parse_check_options
|
|
95
116
|
options = {
|
|
96
117
|
# `nil` triggers `Configuration.discover` (`.rigor.yml` then
|
|
@@ -100,7 +121,19 @@ module Rigor
|
|
|
100
121
|
explain: false,
|
|
101
122
|
cache_stats: false,
|
|
102
123
|
clear_cache: false,
|
|
103
|
-
no_cache: false
|
|
124
|
+
no_cache: false,
|
|
125
|
+
# Run-stats summary (target files, RBS class universe
|
|
126
|
+
# breakdown, wall time, peak RSS) is on by default
|
|
127
|
+
# because collection is ~free (single syscall for RSS,
|
|
128
|
+
# one walk of `class_decl_paths` for the breakdown).
|
|
129
|
+
# `--no-stats` suppresses it for callers that want a
|
|
130
|
+
# diagnostic-only output stream.
|
|
131
|
+
stats: true,
|
|
132
|
+
# ADR-15 Phase 4c — when nil, falls back to
|
|
133
|
+
# `RIGOR_RACTOR_WORKERS` then `.rigor.yml`
|
|
134
|
+
# `parallel.workers:` then 0 (sequential). See
|
|
135
|
+
# `resolve_workers` for the precedence chain.
|
|
136
|
+
workers: nil
|
|
104
137
|
}
|
|
105
138
|
parser = OptionParser.new do |opts|
|
|
106
139
|
opts.banner = "Usage: rigor check [options] [paths]"
|
|
@@ -110,6 +143,14 @@ module Rigor
|
|
|
110
143
|
opts.on("--cache-stats", "Print on-disk cache inventory at end of run") { options[:cache_stats] = true }
|
|
111
144
|
opts.on("--clear-cache", "Remove the .rigor/cache directory before running") { options[:clear_cache] = true }
|
|
112
145
|
opts.on("--no-cache", "Disable the persistent cache for this run") { options[:no_cache] = true }
|
|
146
|
+
opts.on("--[no-]stats",
|
|
147
|
+
"Print run summary (files, classes, memory, wall time) to stderr (default: on)") do |value|
|
|
148
|
+
options[:stats] = value
|
|
149
|
+
end
|
|
150
|
+
opts.on("--workers=N", Integer,
|
|
151
|
+
"Dispatch per-file analysis across N Ractor workers (default: 0; sequential)") do |value|
|
|
152
|
+
options[:workers] = value
|
|
153
|
+
end
|
|
113
154
|
end
|
|
114
155
|
parser.parse!(@argv)
|
|
115
156
|
options
|
|
@@ -124,6 +165,15 @@ module Rigor
|
|
|
124
165
|
end
|
|
125
166
|
end
|
|
126
167
|
|
|
168
|
+
# Emits the {Analysis::RunStats} summary to STDERR so it
|
|
169
|
+
# doesn't interleave with the diagnostic stream (text or
|
|
170
|
+
# JSON) on STDOUT. JSON consumers can pipe stdout cleanly;
|
|
171
|
+
# interactive users still see the summary on their tty.
|
|
172
|
+
def write_run_stats(stats)
|
|
173
|
+
@err.puts("")
|
|
174
|
+
stats.format(@err)
|
|
175
|
+
end
|
|
176
|
+
|
|
127
177
|
def write_cache_stats(cache_root, runtime_store)
|
|
128
178
|
inv = Cache::Store.disk_inventory(root: cache_root)
|
|
129
179
|
|