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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +45 -2
  3. data/data/builtins/ruby_core/array.yml +6 -6
  4. data/data/builtins/ruby_core/hash.yml +1 -1
  5. data/data/builtins/ruby_core/io.yml +3 -3
  6. data/data/builtins/ruby_core/numeric.yml +1 -1
  7. data/data/builtins/ruby_core/pathname.yml +100 -100
  8. data/data/builtins/ruby_core/proc.yml +1 -1
  9. data/data/builtins/ruby_core/time.yml +3 -3
  10. data/lib/rigor/analysis/check_rules.rb +228 -40
  11. data/lib/rigor/analysis/diagnostic.rb +15 -1
  12. data/lib/rigor/analysis/runner.rb +269 -7
  13. data/lib/rigor/builtins/regex_refinement.rb +104 -0
  14. data/lib/rigor/cache/rbs_class_ancestor_table.rb +1 -1
  15. data/lib/rigor/cache/rbs_class_type_param_names.rb +1 -1
  16. data/lib/rigor/cache/rbs_constant_table.rb +2 -2
  17. data/lib/rigor/cache/rbs_descriptor.rb +2 -0
  18. data/lib/rigor/cache/rbs_instance_definitions.rb +79 -0
  19. data/lib/rigor/cache/store.rb +2 -0
  20. data/lib/rigor/cli/type_of_command.rb +3 -3
  21. data/lib/rigor/cli/type_scan_command.rb +4 -4
  22. data/lib/rigor/cli.rb +20 -7
  23. data/lib/rigor/configuration/severity_profile.rb +109 -0
  24. data/lib/rigor/configuration.rb +286 -15
  25. data/lib/rigor/environment/rbs_loader.rb +89 -13
  26. data/lib/rigor/environment.rb +12 -4
  27. data/lib/rigor/flow_contribution/conflict.rb +81 -0
  28. data/lib/rigor/flow_contribution/element.rb +53 -0
  29. data/lib/rigor/flow_contribution/fact.rb +88 -0
  30. data/lib/rigor/flow_contribution/merge_result.rb +67 -0
  31. data/lib/rigor/flow_contribution/merger.rb +275 -0
  32. data/lib/rigor/flow_contribution.rb +51 -0
  33. data/lib/rigor/inference/block_parameter_binder.rb +15 -0
  34. data/lib/rigor/inference/expression_typer.rb +87 -6
  35. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +31 -0
  36. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +136 -9
  37. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +21 -1
  38. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +68 -2
  39. data/lib/rigor/inference/method_dispatcher.rb +50 -1
  40. data/lib/rigor/inference/multi_target_binder.rb +2 -0
  41. data/lib/rigor/inference/narrowing.rb +246 -127
  42. data/lib/rigor/inference/scope_indexer.rb +124 -16
  43. data/lib/rigor/inference/statement_evaluator.rb +406 -37
  44. data/lib/rigor/plugin/access_denied_error.rb +24 -0
  45. data/lib/rigor/plugin/base.rb +284 -0
  46. data/lib/rigor/plugin/fact_store.rb +92 -0
  47. data/lib/rigor/plugin/io_boundary.rb +102 -0
  48. data/lib/rigor/plugin/load_error.rb +35 -0
  49. data/lib/rigor/plugin/loader.rb +307 -0
  50. data/lib/rigor/plugin/manifest.rb +203 -0
  51. data/lib/rigor/plugin/registry.rb +50 -0
  52. data/lib/rigor/plugin/services.rb +77 -0
  53. data/lib/rigor/plugin/trust_policy.rb +99 -0
  54. data/lib/rigor/plugin.rb +62 -0
  55. data/lib/rigor/rbs_extended.rb +57 -9
  56. data/lib/rigor/reflection.rb +2 -2
  57. data/lib/rigor/trinary.rb +1 -1
  58. data/lib/rigor/type/integer_range.rb +6 -2
  59. data/lib/rigor/version.rb +1 -1
  60. data/lib/rigor.rb +7 -0
  61. data/sig/rigor/environment.rbs +10 -3
  62. data/sig/rigor/inference.rbs +1 -0
  63. data/sig/rigor/rbs_extended.rbs +2 -0
  64. data/sig/rigor/scope.rbs +1 -0
  65. data/sig/rigor/type.rbs +7 -0
  66. data/sig/rigor.rbs +8 -2
  67. 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 = expansion.fetch(:errors)
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
@@ -53,7 +53,7 @@ module Rigor
53
53
  .map { |ancestor| ancestor.name.to_s.delete_prefix("::") }
54
54
  .uniq
55
55
  .freeze
56
- rescue StandardError
56
+ rescue ::RBS::BaseError
57
57
  nil
58
58
  end
59
59
 
@@ -50,7 +50,7 @@ module Rigor
50
50
  return nil if definition.nil?
51
51
 
52
52
  definition.type_params.dup.freeze
53
- rescue StandardError
53
+ rescue ::RBS::BaseError
54
54
  nil
55
55
  end
56
56
 
@@ -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 StandardError
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
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "digest"
4
4
 
5
+ require_relative "descriptor"
6
+
5
7
  module Rigor
6
8
  module Cache
7
9
  # Shared descriptor builder for cache producers that depend on the
@@ -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
@@ -5,6 +5,8 @@ require "fileutils"
5
5
  require "json"
6
6
  require "securerandom"
7
7
 
8
+ require_relative "descriptor"
9
+
8
10
  module Rigor
9
11
  module Cache
10
12
  # Filesystem-backed cache store. Schema, layout, file format,
@@ -48,7 +48,7 @@ module Rigor
48
48
  private
49
49
 
50
50
  def parse_options
51
- options = { format: "text", trace: false, config: Configuration::DEFAULT_PATH }
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: Configuration::DEFAULT_PATH }
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