rigortype 0.0.9 → 0.1.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 +45 -2
- data/data/builtins/ruby_core/array.yml +6 -6
- data/data/builtins/ruby_core/hash.yml +1 -1
- data/data/builtins/ruby_core/io.yml +3 -3
- data/data/builtins/ruby_core/numeric.yml +1 -1
- data/data/builtins/ruby_core/pathname.yml +100 -100
- data/data/builtins/ruby_core/proc.yml +1 -1
- data/data/builtins/ruby_core/time.yml +3 -3
- data/lib/rigor/analysis/check_rules.rb +228 -40
- data/lib/rigor/analysis/diagnostic.rb +15 -1
- data/lib/rigor/analysis/runner.rb +269 -7
- data/lib/rigor/builtins/regex_refinement.rb +104 -0
- data/lib/rigor/cache/rbs_class_ancestor_table.rb +1 -1
- data/lib/rigor/cache/rbs_class_type_param_names.rb +1 -1
- data/lib/rigor/cache/rbs_constant_table.rb +2 -2
- data/lib/rigor/cache/rbs_descriptor.rb +2 -0
- data/lib/rigor/cache/rbs_instance_definitions.rb +79 -0
- data/lib/rigor/cache/store.rb +2 -0
- data/lib/rigor/cli/type_of_command.rb +3 -3
- data/lib/rigor/cli/type_scan_command.rb +4 -4
- data/lib/rigor/cli.rb +20 -7
- data/lib/rigor/configuration/severity_profile.rb +109 -0
- data/lib/rigor/configuration.rb +286 -15
- data/lib/rigor/environment/rbs_loader.rb +89 -13
- data/lib/rigor/environment.rb +12 -4
- data/lib/rigor/flow_contribution/conflict.rb +81 -0
- data/lib/rigor/flow_contribution/element.rb +53 -0
- data/lib/rigor/flow_contribution/fact.rb +88 -0
- data/lib/rigor/flow_contribution/merge_result.rb +67 -0
- data/lib/rigor/flow_contribution/merger.rb +275 -0
- data/lib/rigor/flow_contribution.rb +51 -0
- data/lib/rigor/inference/block_parameter_binder.rb +15 -0
- data/lib/rigor/inference/expression_typer.rb +87 -6
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +31 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +136 -9
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +21 -1
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +68 -2
- data/lib/rigor/inference/method_dispatcher.rb +50 -1
- data/lib/rigor/inference/multi_target_binder.rb +2 -0
- data/lib/rigor/inference/narrowing.rb +246 -127
- data/lib/rigor/inference/scope_indexer.rb +124 -16
- data/lib/rigor/inference/statement_evaluator.rb +406 -37
- data/lib/rigor/plugin/access_denied_error.rb +24 -0
- data/lib/rigor/plugin/base.rb +284 -0
- data/lib/rigor/plugin/fact_store.rb +92 -0
- data/lib/rigor/plugin/io_boundary.rb +102 -0
- data/lib/rigor/plugin/load_error.rb +35 -0
- data/lib/rigor/plugin/loader.rb +307 -0
- data/lib/rigor/plugin/manifest.rb +203 -0
- data/lib/rigor/plugin/registry.rb +50 -0
- data/lib/rigor/plugin/services.rb +77 -0
- data/lib/rigor/plugin/trust_policy.rb +99 -0
- data/lib/rigor/plugin.rb +62 -0
- data/lib/rigor/rbs_extended.rb +57 -9
- data/lib/rigor/reflection.rb +2 -2
- data/lib/rigor/trinary.rb +1 -1
- data/lib/rigor/type/integer_range.rb +6 -2
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +7 -0
- data/sig/rigor/environment.rbs +10 -3
- data/sig/rigor/inference.rbs +1 -0
- data/sig/rigor/rbs_extended.rbs +2 -0
- data/sig/rigor/scope.rbs +1 -0
- data/sig/rigor/type.rbs +7 -0
- data/sig/rigor.rbs +8 -2
- metadata +20 -1
|
@@ -5,6 +5,9 @@ require "prism"
|
|
|
5
5
|
require_relative "../environment"
|
|
6
6
|
require_relative "../scope"
|
|
7
7
|
require_relative "../cache/store"
|
|
8
|
+
require_relative "../plugin"
|
|
9
|
+
require_relative "../reflection"
|
|
10
|
+
require_relative "../type/combinator"
|
|
8
11
|
require_relative "../inference/coverage_scanner"
|
|
9
12
|
require_relative "../inference/scope_indexer"
|
|
10
13
|
require_relative "../inference/method_dispatcher/file_folding"
|
|
@@ -18,7 +21,7 @@ module Rigor
|
|
|
18
21
|
RUBY_GLOB = "**/*.rb"
|
|
19
22
|
DEFAULT_CACHE_ROOT = ".rigor/cache"
|
|
20
23
|
|
|
21
|
-
attr_reader :cache_store
|
|
24
|
+
attr_reader :cache_store, :plugin_registry
|
|
22
25
|
|
|
23
26
|
# @param configuration [Rigor::Configuration]
|
|
24
27
|
# @param explain [Boolean] surface fail-soft fallback events
|
|
@@ -30,10 +33,13 @@ module Rigor
|
|
|
30
33
|
# v0.0.9 group A slice 1 introduces the surface; later
|
|
31
34
|
# slices route real producers through it.
|
|
32
35
|
def initialize(configuration:, explain: false,
|
|
33
|
-
cache_store: Cache::Store.new(root: DEFAULT_CACHE_ROOT)
|
|
36
|
+
cache_store: Cache::Store.new(root: DEFAULT_CACHE_ROOT),
|
|
37
|
+
plugin_requirer: nil)
|
|
34
38
|
@configuration = configuration
|
|
35
39
|
@explain = explain
|
|
36
40
|
@cache_store = cache_store
|
|
41
|
+
@plugin_requirer = plugin_requirer
|
|
42
|
+
@plugin_registry = Plugin::Registry::EMPTY
|
|
37
43
|
end
|
|
38
44
|
|
|
39
45
|
# Walks every Ruby file under `paths`, parses it, builds a
|
|
@@ -48,21 +54,257 @@ module Rigor
|
|
|
48
54
|
Inference::MethodDispatcher::FileFolding.fold_platform_specific_paths =
|
|
49
55
|
@configuration.fold_platform_specific_paths
|
|
50
56
|
|
|
57
|
+
target_ruby_error = validate_target_ruby
|
|
58
|
+
return Result.new(diagnostics: [target_ruby_error]) if target_ruby_error
|
|
59
|
+
|
|
60
|
+
@plugin_registry = load_plugins
|
|
51
61
|
environment = Environment.for_project(
|
|
52
62
|
libraries: @configuration.libraries,
|
|
53
63
|
signature_paths: @configuration.signature_paths,
|
|
54
|
-
cache_store: @cache_store
|
|
64
|
+
cache_store: @cache_store,
|
|
65
|
+
plugin_registry: @plugin_registry
|
|
55
66
|
)
|
|
56
67
|
expansion = expand_paths(paths)
|
|
57
68
|
|
|
58
|
-
diagnostics =
|
|
69
|
+
diagnostics = plugin_load_diagnostics
|
|
70
|
+
diagnostics += plugin_prepare_diagnostics
|
|
71
|
+
diagnostics += expansion.fetch(:errors)
|
|
59
72
|
diagnostics += expansion.fetch(:files).flat_map { |path| analyze_file(path, environment) }
|
|
60
73
|
|
|
61
|
-
Result.new(diagnostics: diagnostics)
|
|
74
|
+
Result.new(diagnostics: apply_severity_profile(diagnostics))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# `target_ruby` flows through to Prism's `version:` option.
|
|
78
|
+
# Prism enforces the supported range and raises
|
|
79
|
+
# `ArgumentError` for versions it does not recognise. Run a
|
|
80
|
+
# one-time smoke parse here so a misconfigured target_ruby
|
|
81
|
+
# surfaces as a single project-level diagnostic instead of
|
|
82
|
+
# crashing the whole run on the first file.
|
|
83
|
+
def validate_target_ruby
|
|
84
|
+
Prism.parse("nil", version: @configuration.target_ruby)
|
|
85
|
+
nil
|
|
86
|
+
rescue ArgumentError => e
|
|
87
|
+
Diagnostic.new(
|
|
88
|
+
path: ".rigor.yml", line: 1, column: 1,
|
|
89
|
+
message: "target_ruby #{@configuration.target_ruby.inspect} is not accepted by Prism: #{e.message}",
|
|
90
|
+
severity: :error,
|
|
91
|
+
rule: "configuration-error",
|
|
92
|
+
source_family: :builtin
|
|
93
|
+
)
|
|
62
94
|
end
|
|
63
95
|
|
|
64
96
|
private
|
|
65
97
|
|
|
98
|
+
# Loads project-configured plugins through {Rigor::Plugin::Loader}
|
|
99
|
+
# and returns the resulting {Rigor::Plugin::Registry}. Loader
|
|
100
|
+
# failures are isolated: each surfaces as a `:plugin_loader`
|
|
101
|
+
# diagnostic on the run's `Result` rather than aborting the
|
|
102
|
+
# analysis. Plugins that load successfully but contribute no
|
|
103
|
+
# protocol hooks are inert in slice 1; later v0.1.0 slices
|
|
104
|
+
# wire the contribution merger through this registry.
|
|
105
|
+
def load_plugins
|
|
106
|
+
return Plugin::Registry::EMPTY if @configuration.plugins.empty?
|
|
107
|
+
|
|
108
|
+
services = Plugin::Services.new(
|
|
109
|
+
reflection: Reflection,
|
|
110
|
+
type: Type::Combinator,
|
|
111
|
+
configuration: @configuration,
|
|
112
|
+
cache_store: @cache_store,
|
|
113
|
+
trust_policy: build_trust_policy
|
|
114
|
+
)
|
|
115
|
+
if @plugin_requirer
|
|
116
|
+
Plugin::Loader.load(configuration: @configuration, services: services, requirer: @plugin_requirer)
|
|
117
|
+
else
|
|
118
|
+
Plugin::Loader.load(configuration: @configuration, services: services)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Builds the {Rigor::Plugin::TrustPolicy} for this run. Trusted
|
|
123
|
+
# gems are the gem-name half of every entry in
|
|
124
|
+
# `Configuration#plugins`. Allowed read roots default to the
|
|
125
|
+
# project root (CWD), the project's signature_paths, and each
|
|
126
|
+
# trusted gem's `Gem::Specification#full_gem_path`, plus any
|
|
127
|
+
# extras the user listed under `plugins_io.allowed_paths`.
|
|
128
|
+
# Slice 2 keeps `network_policy` `:disabled` — the only value
|
|
129
|
+
# the configuration accepts today.
|
|
130
|
+
def build_trust_policy
|
|
131
|
+
trusted_gems = @configuration.plugins.map { |entry| trusted_gem_name(entry) }.uniq
|
|
132
|
+
roots = [Dir.pwd]
|
|
133
|
+
Array(@configuration.signature_paths).each { |sp| roots << File.expand_path(sp) }
|
|
134
|
+
trusted_gems.each do |gem_name|
|
|
135
|
+
path = trusted_gem_root(gem_name)
|
|
136
|
+
roots << path if path
|
|
137
|
+
end
|
|
138
|
+
@configuration.plugins_io_allowed_paths.each { |p| roots << File.expand_path(p) }
|
|
139
|
+
|
|
140
|
+
Plugin::TrustPolicy.new(
|
|
141
|
+
trusted_gems: trusted_gems,
|
|
142
|
+
allowed_read_roots: roots,
|
|
143
|
+
network_policy: @configuration.plugins_io_network
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def trusted_gem_name(entry)
|
|
148
|
+
case entry
|
|
149
|
+
when String then entry
|
|
150
|
+
when Hash then entry["gem"] || entry["id"]
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def trusted_gem_root(gem_name)
|
|
155
|
+
return nil if gem_name.nil? || gem_name.empty?
|
|
156
|
+
|
|
157
|
+
spec = Gem.loaded_specs[gem_name]
|
|
158
|
+
spec&.full_gem_path # rigor:disable undefined-method
|
|
159
|
+
rescue StandardError
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# ADR-8 § "Severity profile" — re-stamps each diagnostic's
|
|
164
|
+
# severity from the configured profile + per-rule
|
|
165
|
+
# overrides. Rules emit with their authored severity; the
|
|
166
|
+
# profile is the final filter. Diagnostics whose resolved
|
|
167
|
+
# severity is `:off` are dropped from the run result.
|
|
168
|
+
def apply_severity_profile(diagnostics)
|
|
169
|
+
diagnostics.filter_map { |diagnostic| stamp_severity(diagnostic) }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def stamp_severity(diagnostic)
|
|
173
|
+
return diagnostic if diagnostic.rule.nil?
|
|
174
|
+
|
|
175
|
+
resolved = Configuration::SeverityProfile.resolve(
|
|
176
|
+
rule: diagnostic.rule,
|
|
177
|
+
authored_severity: diagnostic.severity,
|
|
178
|
+
profile: @configuration.severity_profile,
|
|
179
|
+
overrides: @configuration.severity_overrides
|
|
180
|
+
)
|
|
181
|
+
return nil if resolved == :off
|
|
182
|
+
return diagnostic if resolved == diagnostic.severity
|
|
183
|
+
|
|
184
|
+
Diagnostic.new(
|
|
185
|
+
path: diagnostic.path,
|
|
186
|
+
line: diagnostic.line,
|
|
187
|
+
column: diagnostic.column,
|
|
188
|
+
message: diagnostic.message,
|
|
189
|
+
severity: resolved,
|
|
190
|
+
rule: diagnostic.rule,
|
|
191
|
+
source_family: diagnostic.source_family
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def plugin_load_diagnostics
|
|
196
|
+
@plugin_registry.load_errors.map do |error|
|
|
197
|
+
Diagnostic.new(
|
|
198
|
+
path: ".rigor.yml",
|
|
199
|
+
line: 1,
|
|
200
|
+
column: 1,
|
|
201
|
+
message: error.message,
|
|
202
|
+
severity: :error,
|
|
203
|
+
rule: "load-error",
|
|
204
|
+
source_family: :plugin_loader
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# ADR-9 slice 3 — invokes every loaded plugin's `#prepare`
|
|
210
|
+
# hook once per run, after the loader's `#init` pass and
|
|
211
|
+
# before per-file iteration. Plugins publish facts here
|
|
212
|
+
# for cross-plugin consumption via the shared
|
|
213
|
+
# `services.fact_store`. Failures isolate as
|
|
214
|
+
# `:plugin_loader runtime-error` diagnostics, mirroring the
|
|
215
|
+
# `#diagnostics_for_file` raise envelope in
|
|
216
|
+
# `plugin_runtime_error_diagnostic`.
|
|
217
|
+
#
|
|
218
|
+
# Slice 3 visits plugins in registration order. Slice 5
|
|
219
|
+
# introduces topological ordering by `manifest(consumes:)`
|
|
220
|
+
# so producers always run before consumers; until then,
|
|
221
|
+
# `Configuration#plugins` order MUST be producer-first if
|
|
222
|
+
# cross-plugin dependencies exist.
|
|
223
|
+
def plugin_prepare_diagnostics
|
|
224
|
+
return [] if @plugin_registry.empty?
|
|
225
|
+
|
|
226
|
+
@plugin_registry.plugins.flat_map { |plugin| invoke_plugin_prepare(plugin) }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def invoke_plugin_prepare(plugin)
|
|
230
|
+
plugin.prepare(plugin.services)
|
|
231
|
+
[]
|
|
232
|
+
rescue StandardError => e
|
|
233
|
+
[plugin_prepare_error_diagnostic(plugin, e)]
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def plugin_prepare_error_diagnostic(plugin, error)
|
|
237
|
+
plugin_id = safe_plugin_id(plugin)
|
|
238
|
+
Diagnostic.new(
|
|
239
|
+
path: ".rigor.yml",
|
|
240
|
+
line: 1,
|
|
241
|
+
column: 1,
|
|
242
|
+
message: "plugin #{plugin_id.inspect} raised during prepare: " \
|
|
243
|
+
"#{error.class}: #{error.message}",
|
|
244
|
+
severity: :error,
|
|
245
|
+
rule: "runtime-error",
|
|
246
|
+
source_family: :plugin_loader
|
|
247
|
+
)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# ADR-7 § "Slice 5-A/5-B" — invokes every loaded plugin's
|
|
251
|
+
# per-file diagnostic emission hook
|
|
252
|
+
# (`Plugin::Base#diagnostics_for_file`) and re-stamps the
|
|
253
|
+
# returned diagnostics with
|
|
254
|
+
# `source_family: "plugin.<manifest.id>"` so plugin
|
|
255
|
+
# authors cannot accidentally publish under another
|
|
256
|
+
# plugin's identifier or under `:builtin`. Plugin
|
|
257
|
+
# exceptions are isolated per ADR-2 § "Plugin Trust and
|
|
258
|
+
# I/O Policy" — a raise from one plugin becomes a
|
|
259
|
+
# `:plugin_loader` `runtime-error` diagnostic without
|
|
260
|
+
# affecting other plugins or the rest of the run.
|
|
261
|
+
def plugin_emitted_diagnostics(path, root, scope)
|
|
262
|
+
return [] if @plugin_registry.empty?
|
|
263
|
+
|
|
264
|
+
@plugin_registry.plugins.flat_map do |plugin|
|
|
265
|
+
collect_plugin_diagnostics(plugin, path, root, scope)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def collect_plugin_diagnostics(plugin, path, root, scope)
|
|
270
|
+
raw = plugin.diagnostics_for_file(path: path, scope: scope, root: root)
|
|
271
|
+
Array(raw).map { |diagnostic| stamp_plugin_diagnostic(diagnostic, plugin.manifest.id) }
|
|
272
|
+
rescue StandardError => e
|
|
273
|
+
[plugin_runtime_error_diagnostic(path, plugin, e)]
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def stamp_plugin_diagnostic(diagnostic, plugin_id)
|
|
277
|
+
Diagnostic.new(
|
|
278
|
+
path: diagnostic.path,
|
|
279
|
+
line: diagnostic.line,
|
|
280
|
+
column: diagnostic.column,
|
|
281
|
+
message: diagnostic.message,
|
|
282
|
+
severity: diagnostic.severity,
|
|
283
|
+
rule: diagnostic.rule,
|
|
284
|
+
source_family: "plugin.#{plugin_id}"
|
|
285
|
+
)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def plugin_runtime_error_diagnostic(path, plugin, error)
|
|
289
|
+
plugin_id = safe_plugin_id(plugin)
|
|
290
|
+
Diagnostic.new(
|
|
291
|
+
path: path,
|
|
292
|
+
line: 1,
|
|
293
|
+
column: 1,
|
|
294
|
+
message: "plugin #{plugin_id.inspect} raised during diagnostics_for_file: " \
|
|
295
|
+
"#{error.class}: #{error.message}",
|
|
296
|
+
severity: :error,
|
|
297
|
+
rule: "runtime-error",
|
|
298
|
+
source_family: :plugin_loader
|
|
299
|
+
)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def safe_plugin_id(plugin)
|
|
303
|
+
plugin.manifest.id
|
|
304
|
+
rescue StandardError
|
|
305
|
+
plugin.class.to_s
|
|
306
|
+
end
|
|
307
|
+
|
|
66
308
|
# Resolves the user-supplied path list into:
|
|
67
309
|
# - `:files` — the concrete `.rb` files to analyze.
|
|
68
310
|
# - `:errors` — `Diagnostic` entries for each path that
|
|
@@ -76,7 +318,7 @@ module Rigor
|
|
|
76
318
|
errors = []
|
|
77
319
|
Array(paths).each do |path|
|
|
78
320
|
if File.directory?(path)
|
|
79
|
-
files.concat(Dir.glob(File.join(path, RUBY_GLOB)))
|
|
321
|
+
files.concat(reject_excluded(Dir.glob(File.join(path, RUBY_GLOB))))
|
|
80
322
|
elsif File.file?(path) && path.end_with?(".rb")
|
|
81
323
|
files << path
|
|
82
324
|
elsif File.exist?(path)
|
|
@@ -88,6 +330,25 @@ module Rigor
|
|
|
88
330
|
{ files: files, errors: errors }
|
|
89
331
|
end
|
|
90
332
|
|
|
333
|
+
# `Configuration#exclude_patterns` is a list of glob patterns
|
|
334
|
+
# checked against each globbed path via `File.fnmatch?` (without
|
|
335
|
+
# `FNM_PATHNAME`, so `**` and `*` both span path separators —
|
|
336
|
+
# the patterns behave like substring globs). Built-in defaults
|
|
337
|
+
# exclude `vendor/bundle`, `.bundle`, `node_modules`, and `tmp`
|
|
338
|
+
# so the analyser never walks into vendored deps or build
|
|
339
|
+
# artefacts. User-supplied entries (`.rigor.yml` `exclude:`)
|
|
340
|
+
# layer on top. Explicit file arguments to the CLI bypass this
|
|
341
|
+
# filter — only the directory-glob expansion is filtered.
|
|
342
|
+
def reject_excluded(file_list)
|
|
343
|
+
return file_list if @configuration.exclude_patterns.empty?
|
|
344
|
+
|
|
345
|
+
file_list.reject { |path| excluded?(path) }
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def excluded?(path)
|
|
349
|
+
@configuration.exclude_patterns.any? { |pattern| File.fnmatch?(pattern, path) }
|
|
350
|
+
end
|
|
351
|
+
|
|
91
352
|
def path_error(path, message)
|
|
92
353
|
Diagnostic.new(
|
|
93
354
|
path: path,
|
|
@@ -99,7 +360,7 @@ module Rigor
|
|
|
99
360
|
end
|
|
100
361
|
|
|
101
362
|
def analyze_file(path, environment) # rubocop:disable Metrics/MethodLength
|
|
102
|
-
parse_result = Prism.parse_file(path)
|
|
363
|
+
parse_result = Prism.parse_file(path, version: @configuration.target_ruby)
|
|
103
364
|
return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
|
|
104
365
|
|
|
105
366
|
scope = Scope.empty(environment: environment)
|
|
@@ -111,6 +372,7 @@ module Rigor
|
|
|
111
372
|
comments: parse_result.comments,
|
|
112
373
|
disabled_rules: @configuration.disabled_rules
|
|
113
374
|
)
|
|
375
|
+
diagnostics += plugin_emitted_diagnostics(path, parse_result.value, scope)
|
|
114
376
|
diagnostics + explain_diagnostics(path, parse_result.value, scope)
|
|
115
377
|
rescue Errno::ENOENT => e
|
|
116
378
|
[
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../type"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Builtins
|
|
7
|
+
# Maps a curated table of canonical regex sub-patterns onto the
|
|
8
|
+
# imported refinement carriers Rigor already ships
|
|
9
|
+
# (`decimal-int-string`, `hex-int-string`, `octal-int-string`,
|
|
10
|
+
# `lowercase-string`, `uppercase-string`, `numeric-string`).
|
|
11
|
+
# See `docs/type-specification/imported-built-in-types.md` for
|
|
12
|
+
# the registry the refinements come from and `docs/MILESTONES.md`
|
|
13
|
+
# § "v0.1.1 — Planned" Track 1 slice 1 for the binding scope of
|
|
14
|
+
# this recogniser.
|
|
15
|
+
#
|
|
16
|
+
# The intended consumer is `Inference::Narrowing.analyse_match_write`:
|
|
17
|
+
# given `if /(?<year>\d+)/ =~ str; year; end`, the v0.1.0
|
|
18
|
+
# baseline narrows `year` to plain `String`; v0.1.1 introspects
|
|
19
|
+
# the regex source and narrows further to
|
|
20
|
+
# `decimal-int-string` whenever the named-capture body matches
|
|
21
|
+
# one of the rows in {RULES}.
|
|
22
|
+
#
|
|
23
|
+
# Recognised body shapes (each row admits the `+` quantifier
|
|
24
|
+
# and the bounded `{n}` / `{n,m}` forms with `n >= 1`):
|
|
25
|
+
#
|
|
26
|
+
# - `\d` -> decimal-int-string
|
|
27
|
+
# - `\h` -> hex-int-string
|
|
28
|
+
# - `[0-9a-fA-F]` -> hex-int-string
|
|
29
|
+
# - `[0-9a-f]`, `[0-9A-F]` -> hex-int-string
|
|
30
|
+
# - `[0-7]` -> octal-int-string
|
|
31
|
+
# - `[a-z]` -> lowercase-string
|
|
32
|
+
# - `[A-Z]` -> uppercase-string
|
|
33
|
+
# - `[[:digit:]]` -> numeric-string
|
|
34
|
+
#
|
|
35
|
+
# Anything outside the table returns `nil` so the calling
|
|
36
|
+
# narrowing site falls back to its previous behaviour
|
|
37
|
+
# (plain `String`). Arbitrary regex semantic equivalence is
|
|
38
|
+
# undecidable, so the table is intentionally a small audited
|
|
39
|
+
# set of canonical shapes rather than a general equivalence
|
|
40
|
+
# checker.
|
|
41
|
+
module RegexRefinement
|
|
42
|
+
# `+` (one-or-more) or `{n}` / `{n,m}` (n >= 1, m >= n).
|
|
43
|
+
# The bound check is enforced separately by
|
|
44
|
+
# {valid_bounds?} after the structural match succeeds, so
|
|
45
|
+
# forms like `\d{0,5}` or `\d{5,3}` reject even though they
|
|
46
|
+
# parse syntactically.
|
|
47
|
+
QUANTIFIER_SOURCE = '(?:\+|\{\d+(?:,\d+)?\})'
|
|
48
|
+
private_constant :QUANTIFIER_SOURCE
|
|
49
|
+
|
|
50
|
+
RULES = [
|
|
51
|
+
[/\A\\d#{QUANTIFIER_SOURCE}\z/, :decimal_int_string],
|
|
52
|
+
[/\A\\h#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
|
|
53
|
+
[/\A\[0-9a-fA-F\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
|
|
54
|
+
[/\A\[0-9a-f\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
|
|
55
|
+
[/\A\[0-9A-F\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
|
|
56
|
+
[/\A\[0-7\]#{QUANTIFIER_SOURCE}\z/, :octal_int_string],
|
|
57
|
+
[/\A\[a-z\]#{QUANTIFIER_SOURCE}\z/, :lowercase_string],
|
|
58
|
+
[/\A\[A-Z\]#{QUANTIFIER_SOURCE}\z/, :uppercase_string],
|
|
59
|
+
[/\A\[\[:digit:\]\]#{QUANTIFIER_SOURCE}\z/, :numeric_string]
|
|
60
|
+
].freeze
|
|
61
|
+
private_constant :RULES
|
|
62
|
+
|
|
63
|
+
BOUND_RE = /\{(\d+)(?:,(\d+))?\}\z/
|
|
64
|
+
private_constant :BOUND_RE
|
|
65
|
+
|
|
66
|
+
module_function
|
|
67
|
+
|
|
68
|
+
# @param body [String, nil] a regex sub-pattern, typically the
|
|
69
|
+
# inner body of a `(?<name>body)` named capture. Anchors
|
|
70
|
+
# (`\A`, `\z`, `^`, `$`) are not stripped — the recogniser
|
|
71
|
+
# table targets bodies that the regex engine treats as
|
|
72
|
+
# anchored to the capture group bounds.
|
|
73
|
+
# @return [Rigor::Type, nil] the matching imported refinement
|
|
74
|
+
# carrier, or `nil` if `body` is not a recognised shape.
|
|
75
|
+
def for_capture_body(body)
|
|
76
|
+
return nil if body.nil? || body.empty?
|
|
77
|
+
|
|
78
|
+
rule = RULES.find { |pattern, _| pattern.match?(body) }
|
|
79
|
+
return nil if rule.nil?
|
|
80
|
+
return nil unless valid_bounds?(body)
|
|
81
|
+
|
|
82
|
+
Type::Combinator.public_send(rule.last)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Filters the bounded-quantifier forms to ones whose lower
|
|
86
|
+
# bound is at least 1 and whose upper bound (if any) is at
|
|
87
|
+
# least the lower bound. Without this, `\d{0,5}` would be
|
|
88
|
+
# accepted even though it admits the empty string, which is
|
|
89
|
+
# not a valid `decimal-int-string`.
|
|
90
|
+
def valid_bounds?(body)
|
|
91
|
+
m = BOUND_RE.match(body)
|
|
92
|
+
return true if m.nil?
|
|
93
|
+
|
|
94
|
+
low = Integer(m[1])
|
|
95
|
+
return false if low < 1
|
|
96
|
+
|
|
97
|
+
high = m[2] && Integer(m[2])
|
|
98
|
+
return true if high.nil?
|
|
99
|
+
|
|
100
|
+
low <= high
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -33,10 +33,10 @@ module Rigor
|
|
|
33
33
|
loader.each_constant_decl do |name, entry|
|
|
34
34
|
translated = Inference::RbsTypeTranslator.translate(entry.decl.type)
|
|
35
35
|
table[name] = translated unless translated.is_a?(Type::Bot)
|
|
36
|
-
rescue
|
|
36
|
+
rescue ::RBS::BaseError
|
|
37
37
|
# Skip entries whose RBS type fails to translate; the cache
|
|
38
38
|
# stays robust to a broken signature rather than corrupting
|
|
39
|
-
# the whole table.
|
|
39
|
+
# the whole table. Analyzer-internal errors propagate.
|
|
40
40
|
end
|
|
41
41
|
table
|
|
42
42
|
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rbs_descriptor"
|
|
4
|
+
require_relative "rbs_environment_marshal_patch"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module Cache
|
|
8
|
+
# Cache producer that materialises the full
|
|
9
|
+
# `Hash<String, RBS::Definition>` for instance-side class
|
|
10
|
+
# definitions in the RBS environment, in a single cache
|
|
11
|
+
# entry. Mirrors the {RbsConstantTable} layout.
|
|
12
|
+
#
|
|
13
|
+
# ADR-7 § "Slice 6-D" carry-over and dogfooding feedback:
|
|
14
|
+
# the earlier per-class cache layout (one entry per class,
|
|
15
|
+
# ~1300 files) made warm runs *slower* than `--no-cache`
|
|
16
|
+
# because each `instance_definition` call paid disk-open +
|
|
17
|
+
# `Marshal.load` overhead and the in-memory
|
|
18
|
+
# `RBS::DefinitionBuilder.build_instance` was actually fast
|
|
19
|
+
# given a cached `RBS::Environment`. The single-blob layout
|
|
20
|
+
# collapses that to one `Marshal.load` per process; warm runs
|
|
21
|
+
# now match `--no-cache` timing while preserving the
|
|
22
|
+
# cross-process invalidation story.
|
|
23
|
+
#
|
|
24
|
+
# Marshal-cleanness of `RBS::Definition` is enabled by the
|
|
25
|
+
# v0.0.9 C2 `RBS::Location` patch.
|
|
26
|
+
class RbsInstanceDefinitions
|
|
27
|
+
PRODUCER_ID = "rbs.instance_definitions"
|
|
28
|
+
|
|
29
|
+
# @param loader [Rigor::Environment::RbsLoader]
|
|
30
|
+
# @param store [Rigor::Cache::Store]
|
|
31
|
+
# @return [Hash{String => RBS::Definition}]
|
|
32
|
+
def self.fetch(loader:, store:)
|
|
33
|
+
descriptor = RbsDescriptor.build(loader)
|
|
34
|
+
store.fetch_or_compute(producer_id: PRODUCER_ID, params: {}, descriptor: descriptor) do
|
|
35
|
+
compute(loader)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.compute(loader)
|
|
40
|
+
table = {}
|
|
41
|
+
loader.each_known_class_name do |name|
|
|
42
|
+
definition = loader.uncached_instance_definition(name)
|
|
43
|
+
table[name] = definition if definition
|
|
44
|
+
end
|
|
45
|
+
table
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private_class_method :compute
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Singleton-side equivalent of {RbsInstanceDefinitions}.
|
|
52
|
+
# Caches the full `Hash<String, RBS::Definition>` for the
|
|
53
|
+
# singleton class of every RBS-known class.
|
|
54
|
+
class RbsSingletonDefinitions
|
|
55
|
+
PRODUCER_ID = "rbs.singleton_definitions"
|
|
56
|
+
|
|
57
|
+
# @param loader [Rigor::Environment::RbsLoader]
|
|
58
|
+
# @param store [Rigor::Cache::Store]
|
|
59
|
+
# @return [Hash{String => RBS::Definition}]
|
|
60
|
+
def self.fetch(loader:, store:)
|
|
61
|
+
descriptor = RbsDescriptor.build(loader)
|
|
62
|
+
store.fetch_or_compute(producer_id: PRODUCER_ID, params: {}, descriptor: descriptor) do
|
|
63
|
+
compute(loader)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.compute(loader)
|
|
68
|
+
table = {}
|
|
69
|
+
loader.each_known_class_name do |name|
|
|
70
|
+
definition = loader.uncached_singleton_definition(name)
|
|
71
|
+
table[name] = definition if definition
|
|
72
|
+
end
|
|
73
|
+
table
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private_class_method :compute
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/rigor/cache/store.rb
CHANGED
|
@@ -48,7 +48,7 @@ module Rigor
|
|
|
48
48
|
private
|
|
49
49
|
|
|
50
50
|
def parse_options
|
|
51
|
-
options = { format: "text", trace: false, config:
|
|
51
|
+
options = { format: "text", trace: false, config: nil }
|
|
52
52
|
|
|
53
53
|
parser = OptionParser.new do |opts|
|
|
54
54
|
opts.banner = USAGE
|
|
@@ -65,8 +65,9 @@ module Rigor
|
|
|
65
65
|
file, line, column = target
|
|
66
66
|
return 1 unless file_exists?(file)
|
|
67
67
|
|
|
68
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
68
69
|
source = File.read(file)
|
|
69
|
-
parse_result = Prism.parse(source, filepath: file)
|
|
70
|
+
parse_result = Prism.parse(source, filepath: file, version: configuration.target_ruby)
|
|
70
71
|
return 1 if parse_errors?(parse_result, file)
|
|
71
72
|
|
|
72
73
|
node = locate_node(source: source, root: parse_result.value, file: file, line: line, column: column)
|
|
@@ -74,7 +75,6 @@ module Rigor
|
|
|
74
75
|
return 1 if node.nil?
|
|
75
76
|
|
|
76
77
|
tracer = options[:trace] ? Inference::FallbackTracer.new : nil
|
|
77
|
-
configuration = Configuration.load(options.fetch(:config))
|
|
78
78
|
base_scope = Scope.empty(environment: project_environment(file, configuration))
|
|
79
79
|
|
|
80
80
|
# Build a per-node scope index so locals bound earlier in the
|
|
@@ -46,7 +46,7 @@ module Rigor
|
|
|
46
46
|
|
|
47
47
|
def parse_options
|
|
48
48
|
options = { format: "text", limit: 10, show_recognized: false, threshold: nil,
|
|
49
|
-
config:
|
|
49
|
+
config: nil }
|
|
50
50
|
|
|
51
51
|
parser = OptionParser.new do |opts|
|
|
52
52
|
opts.banner = USAGE
|
|
@@ -93,7 +93,7 @@ module Rigor
|
|
|
93
93
|
scope = Scope.empty(environment: project_environment(configuration))
|
|
94
94
|
scanner = Inference::CoverageScanner.new(scope: scope)
|
|
95
95
|
accumulator = ScanAccumulator.new
|
|
96
|
-
paths.each { |path| scan_one(path, scanner, accumulator) }
|
|
96
|
+
paths.each { |path| scan_one(path, scanner, accumulator, configuration) }
|
|
97
97
|
accumulator.to_report(paths, options)
|
|
98
98
|
end
|
|
99
99
|
|
|
@@ -107,9 +107,9 @@ module Rigor
|
|
|
107
107
|
)
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
-
def scan_one(path, scanner, accumulator)
|
|
110
|
+
def scan_one(path, scanner, accumulator, configuration)
|
|
111
111
|
source = File.read(path)
|
|
112
|
-
parse_result = Prism.parse(source, filepath: path)
|
|
112
|
+
parse_result = Prism.parse(source, filepath: path, version: configuration.target_ruby)
|
|
113
113
|
if parse_result.errors.any?
|
|
114
114
|
accumulator.record_parse_error(path, parse_result.errors)
|
|
115
115
|
return
|