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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +40 -13
  3. data/lib/rigor/analysis/fact_store.rb +15 -3
  4. data/lib/rigor/analysis/result.rb +11 -3
  5. data/lib/rigor/analysis/run_stats.rb +193 -0
  6. data/lib/rigor/analysis/runner.rb +387 -12
  7. data/lib/rigor/analysis/worker_session.rb +327 -0
  8. data/lib/rigor/builtins/imported_refinements.rb +6 -2
  9. data/lib/rigor/builtins/regex_refinement.rb +17 -12
  10. data/lib/rigor/cache/rbs_descriptor.rb +3 -1
  11. data/lib/rigor/cache/store.rb +40 -7
  12. data/lib/rigor/cli.rb +52 -2
  13. data/lib/rigor/configuration.rb +131 -6
  14. data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
  15. data/lib/rigor/environment/class_registry.rb +12 -3
  16. data/lib/rigor/environment/lockfile_resolver.rb +125 -0
  17. data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
  18. data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
  19. data/lib/rigor/environment/rbs_loader.rb +194 -6
  20. data/lib/rigor/environment/reflection.rb +152 -0
  21. data/lib/rigor/environment.rb +78 -6
  22. data/lib/rigor/inference/acceptance.rb +35 -1
  23. data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
  24. data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
  25. data/lib/rigor/inference/expression_typer.rb +12 -2
  26. data/lib/rigor/inference/macro_block_self_type.rb +96 -0
  27. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -29
  28. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
  29. data/lib/rigor/inference/method_dispatcher/method_folding.rb +18 -1
  30. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -1
  31. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -40
  32. data/lib/rigor/inference/method_dispatcher.rb +128 -3
  33. data/lib/rigor/inference/method_parameter_binder.rb +21 -11
  34. data/lib/rigor/inference/narrowing.rb +127 -8
  35. data/lib/rigor/inference/synthetic_method.rb +86 -0
  36. data/lib/rigor/inference/synthetic_method_index.rb +82 -0
  37. data/lib/rigor/inference/synthetic_method_scanner.rb +521 -0
  38. data/lib/rigor/plugin/blueprint.rb +60 -0
  39. data/lib/rigor/plugin/loader.rb +3 -1
  40. data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
  41. data/lib/rigor/plugin/macro/external_file.rb +143 -0
  42. data/lib/rigor/plugin/macro/heredoc_template.rb +201 -0
  43. data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
  44. data/lib/rigor/plugin/macro.rb +31 -0
  45. data/lib/rigor/plugin/manifest.rb +78 -7
  46. data/lib/rigor/plugin/registry.rb +32 -2
  47. data/lib/rigor/plugin.rb +1 -0
  48. data/lib/rigor/trinary.rb +15 -11
  49. data/lib/rigor/type/bot.rb +6 -3
  50. data/lib/rigor/type/combinator.rb +12 -1
  51. data/lib/rigor/type/integer_range.rb +7 -7
  52. data/lib/rigor/type/refined.rb +18 -12
  53. data/lib/rigor/type/top.rb +4 -3
  54. data/lib/rigor/type_node/generic.rb +7 -1
  55. data/lib/rigor/type_node/identifier.rb +9 -1
  56. data/lib/rigor/type_node/string_literal.rb +4 -1
  57. data/lib/rigor/version.rb +1 -1
  58. data/sig/rigor/environment.rbs +5 -2
  59. data/sig/rigor/plugin/blueprint.rbs +7 -0
  60. data/sig/rigor/plugin/manifest.rbs +1 -1
  61. data/sig/rigor/plugin/registry.rbs +14 -1
  62. data/sig/rigor.rbs +35 -2
  63. 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
- TypeNode::SymbolLiteral.new(value: @scanner[:value].to_sym)
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/MILESTONES.md`
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
- 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
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.flat_map do |root|
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|
@@ -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
- per_producer = @by_producer.transform_values { |counts| counts.dup.freeze }.freeze
53
- { hits: @hits, misses: @misses, writes: @writes, by_producer: per_producer }.freeze
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
- path = entry_path(producer_id, key)
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
- record(:hits, producer_id)
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
- record(:writes, producer_id)
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