rigortype 0.1.8 → 0.1.10

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -513
  3. data/lib/rigor/analysis/check_rules.rb +20 -0
  4. data/lib/rigor/analysis/runner.rb +67 -9
  5. data/lib/rigor/analysis/worker_session.rb +13 -4
  6. data/lib/rigor/cache/rbs_descriptor.rb +21 -2
  7. data/lib/rigor/cache/rbs_environment.rb +2 -1
  8. data/lib/rigor/cli/annotate_command.rb +274 -0
  9. data/lib/rigor/cli/baseline_command.rb +36 -16
  10. data/lib/rigor/cli/coverage_command.rb +126 -0
  11. data/lib/rigor/cli/coverage_renderer.rb +162 -0
  12. data/lib/rigor/cli/coverage_report.rb +75 -0
  13. data/lib/rigor/cli/mcp_command.rb +70 -0
  14. data/lib/rigor/cli/prism_colorizer.rb +111 -0
  15. data/lib/rigor/cli.rb +134 -6
  16. data/lib/rigor/environment/rbs_loader.rb +46 -5
  17. data/lib/rigor/environment/reporters.rb +3 -2
  18. data/lib/rigor/environment.rb +168 -5
  19. data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
  20. data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
  21. data/lib/rigor/inference/def_return_typer.rb +98 -0
  22. data/lib/rigor/inference/expression_typer.rb +308 -18
  23. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
  24. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +178 -10
  25. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
  26. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
  27. data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
  28. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
  29. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
  30. data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
  31. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +431 -9
  32. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
  33. data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
  34. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
  35. data/lib/rigor/inference/method_dispatcher.rb +148 -1
  36. data/lib/rigor/inference/method_parameter_binder.rb +67 -10
  37. data/lib/rigor/inference/narrowing.rb +29 -10
  38. data/lib/rigor/inference/precision_scanner.rb +131 -0
  39. data/lib/rigor/inference/statement_evaluator.rb +29 -3
  40. data/lib/rigor/mcp/loop.rb +43 -0
  41. data/lib/rigor/mcp/server.rb +263 -0
  42. data/lib/rigor/mcp.rb +16 -0
  43. data/lib/rigor/plugin/base.rb +67 -5
  44. data/lib/rigor/plugin/loader.rb +22 -1
  45. data/lib/rigor/plugin/manifest.rb +101 -10
  46. data/lib/rigor/plugin/protocol_contract.rb +185 -0
  47. data/lib/rigor/plugin/registry.rb +87 -0
  48. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
  49. data/lib/rigor/sig_gen/generator.rb +150 -75
  50. data/lib/rigor/triage/catalogue.rb +2 -2
  51. data/lib/rigor/type/combinator.rb +57 -0
  52. data/lib/rigor/type/constant.rb +29 -2
  53. data/lib/rigor/version.rb +1 -1
  54. data/sig/rigor/analysis/baseline.rbs +39 -0
  55. data/sig/rigor/environment.rbs +3 -2
  56. data/sig/rigor/type.rbs +4 -0
  57. data/sig/rigor.rbs +2 -0
  58. metadata +42 -1
data/lib/rigor/cli.rb CHANGED
@@ -21,14 +21,17 @@ module Rigor
21
21
  HANDLERS = {
22
22
  "check" => :run_check,
23
23
  "init" => :run_init,
24
+ "annotate" => :run_annotate,
24
25
  "type-of" => :run_type_of,
25
26
  "type-scan" => :run_type_scan,
26
27
  "explain" => :run_explain,
27
28
  "diff" => :run_diff,
28
29
  "sig-gen" => :run_sig_gen,
29
30
  "lsp" => :run_lsp,
31
+ "mcp" => :run_mcp,
30
32
  "baseline" => :run_baseline,
31
- "triage" => :run_triage
33
+ "triage" => :run_triage,
34
+ "coverage" => :run_coverage
32
35
  }.freeze
33
36
 
34
37
  def self.start(argv = ARGV, out: $stdout, err: $stderr)
@@ -80,7 +83,7 @@ module Rigor
80
83
  buffer = resolve_buffer_binding(options)
81
84
  return EXIT_USAGE if buffer == :usage_error
82
85
 
83
- configuration = Configuration.load(options.fetch(:config))
86
+ configuration = load_check_configuration(options)
84
87
  cache_root = configuration.cache_path
85
88
  handle_clear_cache(cache_root) if options.fetch(:clear_cache)
86
89
 
@@ -88,13 +91,56 @@ module Rigor
88
91
  configuration: configuration, options: options,
89
92
  buffer: buffer, cache_root: cache_root
90
93
  )
91
- result = runner.run(@argv.empty? ? configuration.paths : @argv)
92
- result = apply_baseline_filter(result, configuration, options)
94
+ raw_result = runner.run(@argv.empty? ? configuration.paths : @argv)
95
+ result = apply_baseline_filter(raw_result, configuration, options)
93
96
 
94
97
  write_result(result, options.fetch(:format))
95
98
  write_run_stats(result.stats) if result.stats
96
99
  write_cache_stats(cache_root, runner.cache_store) if options.fetch(:cache_stats)
97
- result.success? ? 0 : 1
100
+
101
+ exit_code = result.success? ? 0 : 1
102
+ exit_code = 1 if baseline_strict_violation?(raw_result.diagnostics, configuration, options)
103
+ exit_code
104
+ end
105
+
106
+ # ADR-22 slice 5 — the `--baseline-strict` CI gate. When the
107
+ # flag is set, ANY baseline drift fails the run — not only
108
+ # excess drift (a bucket over threshold, which already fails
109
+ # via the surfaced diagnostics) but also DEFICIT drift
110
+ # (`actual < count`: the baseline has grown looser than the
111
+ # code and should be regenerated). A no-op, with a stderr
112
+ # note, when no baseline is active — the flag never
113
+ # implicitly loads a baseline the config did not name (WD2).
114
+ def baseline_strict_violation?(raw_diagnostics, configuration, options)
115
+ return false unless options.fetch(:baseline_strict)
116
+
117
+ path = resolve_baseline_path(configuration, options)
118
+ if path.nil?
119
+ @err.puts("rigor: --baseline-strict given but no baseline is active; nothing to gate.")
120
+ return false
121
+ end
122
+
123
+ baseline = Analysis::Baseline.load(path)
124
+ return false if baseline.nil? || baseline.empty?
125
+
126
+ drifted = baseline.audit(raw_diagnostics).reject { |row| row.status == :within }
127
+ return false if drifted.empty?
128
+
129
+ report_strict_drift(drifted, path)
130
+ true
131
+ rescue Analysis::Baseline::LoadError => e
132
+ @err.puts("rigor: baseline load failed: #{e.message} (--baseline-strict gate skipped)")
133
+ false
134
+ end
135
+
136
+ def report_strict_drift(rows, path)
137
+ @err.puts("rigor: --baseline-strict — #{rows.size} bucket(s) drifted from #{path}:")
138
+ rows.sort_by { |r| [r.bucket.file, r.bucket.rule] }.each do |row|
139
+ delta = row.delta.positive? ? "+#{row.delta}" : row.delta.to_s
140
+ @err.puts(" #{row.bucket.file} [#{row.bucket.rule}] " \
141
+ "#{row.bucket.count} → #{row.actual_count} (Δ#{delta}, #{row.status})")
142
+ end
143
+ @err.puts("rigor: run `rigor baseline regenerate` to refresh the baseline.")
98
144
  end
99
145
 
100
146
  # ADR-22 — apply the baseline filter as the LAST step of
@@ -234,7 +280,19 @@ module Rigor
234
280
  # to `.rigor.yml`'s `baseline:` key"; a String overrides
235
281
  # the config; `false` (from `--no-baseline`) suppresses
236
282
  # any baseline that the config might name.
237
- baseline: :unset
283
+ baseline: :unset,
284
+ # ADR-22 slice 5 — `--baseline-strict` CI gate: fail the
285
+ # run on any baseline drift, in either direction.
286
+ baseline_strict: false,
287
+ # ADR-32 WD10 carry-over — `--treat-all-as-inline-rbs`
288
+ # forces the `rigor-rbs-inline` plugin into the loaded
289
+ # plugin set with `require_magic_comment: false` so a
290
+ # single ad-hoc `rigor check` invocation treats every
291
+ # analysed file as inline-RBS without the user editing
292
+ # `.rigor.yml`. Intended for single-file / ad-hoc CI use;
293
+ # ordinary projects should configure the plugin in
294
+ # `.rigor.yml`.
295
+ treat_all_as_inline_rbs: false
238
296
  }
239
297
  parser = OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
240
298
  opts.banner = "Usage: rigor check [options] [paths]"
@@ -268,11 +326,60 @@ module Rigor
268
326
  "ADR-22: ignore any configured baseline for this run") do
269
327
  options[:baseline] = false
270
328
  end
329
+ opts.on("--baseline-strict",
330
+ "ADR-22: fail the run on any baseline drift (CI gate)") do
331
+ options[:baseline_strict] = true
332
+ end
333
+ opts.on("--treat-all-as-inline-rbs",
334
+ "ADR-32: force-load rigor-rbs-inline with require_magic_comment: false") do
335
+ options[:treat_all_as_inline_rbs] = true
336
+ end
271
337
  end
272
338
  parser.parse!(@argv)
273
339
  options
274
340
  end
275
341
 
342
+ # ADR-32 WD10 carry-over — wraps `Configuration.load` so the
343
+ # CLI's `--treat-all-as-inline-rbs` flag can inject a
344
+ # `rigor-rbs-inline` plugin entry with
345
+ # `require_magic_comment: false` into the loaded plugin
346
+ # set. Re-runs the include-aware YAML load and applies the
347
+ # injection before `Configuration.new` so the new entry
348
+ # follows the normal coercion path. A pre-existing
349
+ # `rigor-rbs-inline` entry (by gem name or `id: rbs-inline`)
350
+ # is removed first so the synthesised entry's
351
+ # `require_magic_comment: false` wins unconditionally.
352
+ def load_check_configuration(options)
353
+ return Configuration.load(options.fetch(:config)) unless options.fetch(:treat_all_as_inline_rbs)
354
+
355
+ path = options.fetch(:config) || Configuration.discover
356
+ data = path && File.exist?(path) ? Configuration.load_with_includes(path) : {}
357
+ data = data.dup
358
+ data["plugins"] = inject_treat_all_as_inline_rbs(Array(data["plugins"]))
359
+ Configuration.new(Configuration::DEFAULTS.merge(data))
360
+ end
361
+
362
+ def inject_treat_all_as_inline_rbs(entries)
363
+ filtered = entries.reject { |entry| rigor_rbs_inline_entry?(entry) }
364
+ filtered + [{
365
+ "gem" => "rigor-rbs-inline",
366
+ "id" => "rbs-inline",
367
+ "config" => { "require_magic_comment" => false }
368
+ }]
369
+ end
370
+
371
+ def rigor_rbs_inline_entry?(entry)
372
+ case entry
373
+ when String
374
+ entry == "rigor-rbs-inline"
375
+ when Hash
376
+ string_keyed = entry.to_h { |k, v| [k.to_s, v] }
377
+ string_keyed["gem"] == "rigor-rbs-inline" || string_keyed["id"] == "rbs-inline"
378
+ else
379
+ false
380
+ end
381
+ end
382
+
276
383
  def handle_clear_cache(cache_root)
277
384
  if File.directory?(cache_root)
278
385
  FileUtils.rm_rf(cache_root)
@@ -423,6 +530,12 @@ module Rigor
423
530
  YAML
424
531
  end
425
532
 
533
+ def run_annotate
534
+ require_relative "cli/annotate_command"
535
+
536
+ AnnotateCommand.new(argv: @argv, out: @out, err: @err).run
537
+ end
538
+
426
539
  def run_type_of
427
540
  require_relative "cli/type_of_command"
428
541
 
@@ -459,6 +572,12 @@ module Rigor
459
572
  LspCommand.new(argv: @argv, out: @out, err: @err).run
460
573
  end
461
574
 
575
+ def run_mcp
576
+ require_relative "cli/mcp_command"
577
+
578
+ McpCommand.new(argv: @argv, out: @out, err: @err).run
579
+ end
580
+
462
581
  def run_baseline
463
582
  require_relative "cli/baseline_command"
464
583
 
@@ -471,6 +590,12 @@ module Rigor
471
590
  CLI::TriageCommand.new(argv: @argv, out: @out, err: @err).run
472
591
  end
473
592
 
593
+ def run_coverage
594
+ require_relative "cli/coverage_command"
595
+
596
+ CLI::CoverageCommand.new(argv: @argv, out: @out, err: @err).run
597
+ end
598
+
474
599
  def write_result(result, format)
475
600
  case format
476
601
  when "json"
@@ -506,13 +631,16 @@ module Rigor
506
631
  Commands:
507
632
  check Analyze Ruby source files
508
633
  init Create a starter .rigor.yml
634
+ annotate Print FILE with each line's last-expression type
509
635
  type-of Print the inferred type at FILE:LINE:COL
510
636
  type-scan Report Scope#type_of coverage across PATHs
511
637
  explain Print the description of one or all CheckRules
512
638
  diff Compare current diagnostics to a saved baseline JSON
513
639
  sig-gen Emit RBS skeletons inferred from .rb sources (ADR-14)
514
640
  lsp Run the Rigor Language Server (LSP) over stdio
641
+ mcp Run the Rigor MCP server over stdio (ADR-33)
515
642
  triage Summarise diagnostics: distribution, hotspots, hints (ADR-23)
643
+ coverage Report type-precision coverage (precise vs Dynamic ratio)
516
644
  version Print the Rigor version
517
645
  help Print this help
518
646
  HELP
@@ -56,7 +56,7 @@ module Rigor
56
56
  # run. The gem stubs are intentionally read-only and
57
57
  # appended LAST so user-supplied `signature_paths` win on
58
58
  # name conflicts.
59
- def build_env_for(libraries:, signature_paths:)
59
+ def build_env_for(libraries:, signature_paths:, virtual_rbs: [])
60
60
  rbs_loader = RBS::EnvironmentLoader.new
61
61
  libraries.each do |library|
62
62
  next unless rbs_loader.has_library?(library: library, version: nil)
@@ -70,7 +70,36 @@ module Rigor
70
70
  vendored_gem_sig_paths.each do |path|
71
71
  rbs_loader.add(path: path) if path.directory?
72
72
  end
73
- RBS::Environment.from_loader(rbs_loader).resolve_type_names
73
+ env = RBS::Environment.from_loader(rbs_loader)
74
+ add_virtual_rbs(env, virtual_rbs)
75
+ env.resolve_type_names
76
+ end
77
+
78
+ # ADR-32 WD4 — merge synthesised-from-source RBS strings
79
+ # into the freshly-built environment. Each entry is a
80
+ # `[virtual_filename, rbs_source]` pair. `virtual_filename`
81
+ # is purely for diagnostic provenance (RBS parse errors
82
+ # cite it) — it is not a real file path. Per WD6 the
83
+ # synthesizer-emit path is responsible for catching its
84
+ # own parse errors and returning `nil` rather than
85
+ # garbage; this method assumes its input is parseable
86
+ # and only rescues `RBS::ParsingError` as a fail-soft.
87
+ def add_virtual_rbs(env, virtual_rbs)
88
+ return if virtual_rbs.nil? || virtual_rbs.empty?
89
+
90
+ virtual_rbs.each do |filename, content|
91
+ next if content.nil? || content.empty?
92
+
93
+ buffer = ::RBS::Buffer.new(name: filename.to_s, content: content.to_s)
94
+ _, directives, decls = ::RBS::Parser.parse_signature(buffer)
95
+ source = ::RBS::Source::RBS.new(buffer, directives || [], decls || [])
96
+ env.add_source(source)
97
+ rescue ::RBS::BaseError
98
+ # WD6 fail-soft: a single broken virtual RBS contribution
99
+ # does not pull the whole env down. The plugin layer
100
+ # records a `source-rbs-synthesis-failed` info diagnostic
101
+ # in slice 2; here we just skip the entry.
102
+ end
74
103
  end
75
104
 
76
105
  # Per-gem `data/vendored_gem_sigs/<gem>/` directories that
@@ -95,7 +124,7 @@ module Rigor
95
124
  end
96
125
  end
97
126
 
98
- attr_reader :libraries, :signature_paths, :cache_store
127
+ attr_reader :libraries, :signature_paths, :cache_store, :virtual_rbs
99
128
 
100
129
  # @param libraries [Array<String, Symbol>] stdlib library names to
101
130
  # load on top of core (e.g., `["pathname", "json"]`). Empty by
@@ -114,10 +143,18 @@ module Rigor
114
143
  # reflection artefacts). Pass `nil` (the default) to skip
115
144
  # the cache entirely; the runner threads its own Store
116
145
  # through here when caching is enabled.
117
- def initialize(libraries: [], signature_paths: [], cache_store: nil)
146
+ # @param virtual_rbs [Array<[String, String]>] ADR-32 WD4 —
147
+ # `[virtual_filename, rbs_source]` pairs synthesised from
148
+ # project source by a plugin's
149
+ # `Manifest#source_rbs_synthesizer`. Merged into the env
150
+ # after `signature_paths:` and the vendored stubs. Pass
151
+ # `[]` (the default) when no synthesizer-emitting plugin
152
+ # is loaded.
153
+ def initialize(libraries: [], signature_paths: [], cache_store: nil, virtual_rbs: [])
118
154
  @libraries = libraries.map(&:to_s).freeze
119
155
  @signature_paths = signature_paths.map { |p| Pathname(p) }.freeze
120
156
  @cache_store = cache_store
157
+ @virtual_rbs = virtual_rbs.map { |name, content| [name.to_s.dup.freeze, content.to_s.dup.freeze].freeze }.freeze
121
158
  # Per-loader memoization bucket. Held as a single
122
159
  # mutable Hash so the loader instance itself can be
123
160
  # `.freeze`d (per ADR-15 reflection-facade contract)
@@ -642,7 +679,11 @@ module Rigor
642
679
  end
643
680
 
644
681
  def build_env
645
- self.class.build_env_for(libraries: @libraries, signature_paths: @signature_paths)
682
+ self.class.build_env_for(
683
+ libraries: @libraries,
684
+ signature_paths: @signature_paths,
685
+ virtual_rbs: @virtual_rbs
686
+ )
646
687
  end
647
688
 
648
689
  def build_instance_definition(class_name)
@@ -29,11 +29,12 @@ module Rigor
29
29
  # return nil, and the consumer sites short-circuit on
30
30
  # `reporter.nil?`.
31
31
  class Reporters
32
- attr_accessor :rbs_extended, :boundary_cross
32
+ attr_accessor :rbs_extended, :boundary_cross, :source_rbs_synthesis
33
33
 
34
- def initialize(rbs_extended: nil, boundary_cross: nil)
34
+ def initialize(rbs_extended: nil, boundary_cross: nil, source_rbs_synthesis: nil)
35
35
  @rbs_extended = rbs_extended
36
36
  @boundary_cross = boundary_cross
37
+ @source_rbs_synthesis = source_rbs_synthesis
37
38
  end
38
39
  end
39
40
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  require_relative "environment/class_registry"
4
6
  require_relative "environment/rbs_loader"
5
7
  require_relative "environment/reflection"
@@ -24,7 +26,7 @@ module Rigor
24
26
  # constant-folding tiers cannot answer.
25
27
  #
26
28
  # See docs/internal-spec/inference-engine.md for the binding contract.
27
- class Environment
29
+ class Environment # rubocop:disable Metrics/ClassLength
28
30
  DEFAULT_PROJECT_SIG_DIR = "sig"
29
31
  private_constant :DEFAULT_PROJECT_SIG_DIR
30
32
 
@@ -87,6 +89,7 @@ module Rigor
87
89
  def initialize(class_registry: ClassRegistry.default, rbs_loader: nil, # rubocop:disable Metrics/ParameterLists
88
90
  plugin_registry: nil, dependency_source_index: nil,
89
91
  rbs_extended_reporter: nil, boundary_cross_reporter: nil,
92
+ source_rbs_synthesis_reporter: nil,
90
93
  synthetic_method_index: nil, project_patched_methods: nil,
91
94
  hkt_registry: nil)
92
95
  @class_registry = class_registry
@@ -100,7 +103,8 @@ module Rigor
100
103
  # accessors below preserve the public lookup shape.
101
104
  @reporters = Reporters.new(
102
105
  rbs_extended: rbs_extended_reporter,
103
- boundary_cross: boundary_cross_reporter
106
+ boundary_cross: boundary_cross_reporter,
107
+ source_rbs_synthesis: source_rbs_synthesis_reporter
104
108
  )
105
109
  @synthetic_method_index = synthetic_method_index || Inference::SyntheticMethodIndex::EMPTY
106
110
  @project_patched_methods = project_patched_methods || Inference::ProjectPatchedMethods::EMPTY
@@ -159,6 +163,17 @@ module Rigor
159
163
  @reporters.boundary_cross
160
164
  end
161
165
 
166
+ # ADR-32 WD6 — the per-run accumulator for synthesizer
167
+ # failure events. `Environment.for_project` records
168
+ # `[:error, message]` returns from a plugin's
169
+ # `source_rbs_synthesizer` here so the Runner can emit
170
+ # `source-rbs-synthesis-failed` `:info` diagnostics after
171
+ # analysis completes. Nil when no plugin contributes a
172
+ # synthesizer.
173
+ def source_rbs_synthesis_reporter
174
+ @reporters.source_rbs_synthesis
175
+ end
176
+
162
177
  # Replaces the env's per-run reporter slots. Intended for
163
178
  # long-lived integrations (LSP `ProjectContext`) that share one
164
179
  # Environment instance across many `Runner.run` calls: each call
@@ -210,10 +225,12 @@ module Rigor
210
225
  def for_project(root: Dir.pwd, libraries: [], signature_paths: nil, cache_store: nil,
211
226
  plugin_registry: nil, dependency_source_index: nil,
212
227
  rbs_extended_reporter: nil, boundary_cross_reporter: nil,
228
+ source_rbs_synthesis_reporter: nil,
213
229
  bundler_bundle_path: nil, bundler_auto_detect: false,
214
230
  bundler_lockfile: nil,
215
231
  rbs_collection_lockfile: nil, rbs_collection_auto_detect: false,
216
- synthetic_method_index: nil, project_patched_methods: nil)
232
+ synthetic_method_index: nil, project_patched_methods: nil,
233
+ source_files: [])
217
234
  resolved_paths = signature_paths || default_signature_paths(root)
218
235
  # O4 MVP — append per-gem `sig/` directories discovered
219
236
  # under the target project's bundler install root. Empty
@@ -252,12 +269,39 @@ module Rigor
252
269
  project_root: root,
253
270
  auto_detect: rbs_collection_auto_detect
254
271
  ).map(&:to_s)
255
- loader_signature_paths = resolved_paths + gem_sig_paths + collection_paths
272
+ # ADR-25 RBS signature directories contributed by loaded
273
+ # plugins via their manifest `signature_paths:`. Resolved
274
+ # to absolute dirs by `Plugin::Base#signature_paths`;
275
+ # additive, ranked below the user's explicit
276
+ # `signature_paths:` and above the opportunistic bundle /
277
+ # collection discovery. A duplicate-declaration conflict
278
+ # degrades through the same O7 failure-memo path.
279
+ plugin_sig_paths = plugin_registry ? plugin_registry.signature_paths.map(&:to_s) : []
280
+ loader_signature_paths = resolved_paths + plugin_sig_paths + gem_sig_paths + collection_paths
256
281
  merged_libraries = (DEFAULT_LIBRARIES + libraries.map(&:to_s)).uniq
282
+ # ADR-32 WD4 + WD5 — invoke each loaded plugin's
283
+ # `source_rbs_synthesizer` once per project source file
284
+ # and collect non-nil `[filename, rbs_source]` pairs.
285
+ # The synthesizer-emitting plugin (currently only
286
+ # `rigor-rbs-inline`) is responsible for its own
287
+ # fail-soft on parse errors per WD6; this loop only
288
+ # filters `nil` returns.
289
+ #
290
+ # When a `cache_store` is supplied, each synthesizer
291
+ # invocation is memoised per
292
+ # `(file path + content SHA, plugin id + version + config_hash)`
293
+ # — WD5's cache key — so a second run with unchanged
294
+ # source skips the rbs-inline parse cost. The empty
295
+ # string is the sentinel for "no contribution" so the
296
+ # Store (which treats `nil` as cache miss) can persist
297
+ # the no-contribution decision too.
298
+ virtual_rbs = collect_virtual_rbs(plugin_registry, source_files, cache_store,
299
+ source_rbs_synthesis_reporter)
257
300
  loader = RbsLoader.new(
258
301
  libraries: merged_libraries,
259
302
  signature_paths: loader_signature_paths,
260
- cache_store: cache_store
303
+ cache_store: cache_store,
304
+ virtual_rbs: virtual_rbs
261
305
  )
262
306
  # ADR-20 slice 2c + 2e — seed hkt_registry with the
263
307
  # bundled builtins. The Environment's `#hkt_registry`
@@ -274,6 +318,7 @@ module Rigor
274
318
  dependency_source_index: dependency_source_index,
275
319
  rbs_extended_reporter: rbs_extended_reporter,
276
320
  boundary_cross_reporter: boundary_cross_reporter,
321
+ source_rbs_synthesis_reporter: source_rbs_synthesis_reporter,
277
322
  synthetic_method_index: synthetic_method_index,
278
323
  project_patched_methods: project_patched_methods,
279
324
  hkt_registry: Builtins::HktBuiltins.registry
@@ -287,6 +332,124 @@ module Rigor
287
332
  sig = Pathname(root) / DEFAULT_PROJECT_SIG_DIR
288
333
  sig.directory? ? [sig] : []
289
334
  end
335
+
336
+ # ADR-32 WD4 + WD5 — for each project source file, invoke
337
+ # every plugin-registered synthesizer once and collect
338
+ # non-nil returns. The returned array is `[[virtual_filename,
339
+ # rbs_source], ...]`; the loader threads it through to
340
+ # `RbsLoader.new(virtual_rbs: ...)`.
341
+ #
342
+ # `virtual_filename` is the source file path prefixed with
343
+ # the plugin id so RBS parse errors point back to the
344
+ # contributing plugin in their diagnostic location string.
345
+ #
346
+ # When no plugin declares a synthesizer (the common case),
347
+ # the registry's `source_rbs_synthesizers` is empty and
348
+ # this method short-circuits to `[]` without walking the
349
+ # file list.
350
+ #
351
+ # WD5 — when `cache_store` is supplied, each (file, plugin)
352
+ # synthesizer call is memoised through `Cache::Store`. The
353
+ # cache key composes the file's content SHA with the
354
+ # plugin's `PluginEntry` (id + version + config_hash) so a
355
+ # config change or content change invalidates the entry
356
+ # automatically.
357
+ def collect_virtual_rbs(plugin_registry, source_files, cache_store, reporter)
358
+ return [] if plugin_registry.nil?
359
+
360
+ synthesizers = plugin_registry.source_rbs_synthesizers
361
+ return [] if synthesizers.empty?
362
+ return [] if source_files.nil? || source_files.empty?
363
+
364
+ result = []
365
+ source_files.each do |path|
366
+ synthesizers.each do |plugin, callable|
367
+ outcome = synthesizer_output_for(plugin, callable, path, cache_store)
368
+ outcome = interpret_synthesizer_outcome(outcome, plugin, path, reporter)
369
+ next if outcome.nil? || outcome.empty?
370
+
371
+ virtual_name = "virtual:#{plugin.manifest.id}:#{path}"
372
+ result << [virtual_name, outcome]
373
+ end
374
+ end
375
+ result
376
+ end
377
+
378
+ # ADR-32 WD5 — cache wrapper around a single (plugin,
379
+ # file) invocation. The cache stores the empty string
380
+ # `""` as the "no contribution" sentinel because
381
+ # `Cache::Store` treats `nil` as a cache miss. Error
382
+ # tuples are stored as the canonical
383
+ # `[:error, message_string]` Array so the same wrapper
384
+ # short-circuits subsequent runs against unchanged broken
385
+ # input.
386
+ def synthesizer_output_for(plugin, callable, path, cache_store)
387
+ return invoke_synthesizer_safely(callable, path) if cache_store.nil?
388
+ return invoke_synthesizer_safely(callable, path) unless File.file?(path)
389
+
390
+ descriptor = build_synthesizer_cache_descriptor(plugin, path)
391
+ cache_store.fetch_or_compute(
392
+ producer_id: SYNTHESIZER_CACHE_PRODUCER_ID,
393
+ params: {},
394
+ descriptor: descriptor
395
+ ) { invoke_synthesizer_safely(callable, path) || "" }
396
+ end
397
+
398
+ # ADR-32 WD6 — route a synthesizer return value through
399
+ # the per-run failure reporter. The synthesizer's contract
400
+ # (declared in `plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb`)
401
+ # admits three return shapes:
402
+ # - `String` (non-empty) → successful RBS source
403
+ # - `nil` / `""` → no contribution
404
+ # - `[:error, message]` → parse failed
405
+ # The error tuple is converted into a reporter entry +
406
+ # treated as "no contribution" so the analysis pipeline
407
+ # continues. Reporter is `nil` for callers that don't care
408
+ # (legacy Environment.new, tests).
409
+ def interpret_synthesizer_outcome(outcome, plugin, path, reporter)
410
+ return outcome unless outcome.is_a?(Array) && outcome[0] == :error
411
+
412
+ reporter&.record(
413
+ plugin_id: plugin.manifest.id,
414
+ path: path,
415
+ message: outcome[1].to_s
416
+ )
417
+ nil
418
+ end
419
+
420
+ SYNTHESIZER_CACHE_PRODUCER_ID = "plugin.source_rbs_synthesizer"
421
+ private_constant :SYNTHESIZER_CACHE_PRODUCER_ID
422
+
423
+ def build_synthesizer_cache_descriptor(plugin, path)
424
+ Cache::Descriptor.new(
425
+ files: [Cache::Descriptor::FileEntry.new(
426
+ path: path.to_s,
427
+ comparator: :digest,
428
+ value: synthesizer_input_digest(path)
429
+ )],
430
+ plugins: [plugin.plugin_entry]
431
+ )
432
+ end
433
+
434
+ def synthesizer_input_digest(path)
435
+ Digest::SHA256.hexdigest(File.binread(path))
436
+ rescue ::SystemCallError
437
+ # Unreadable file → key on the path alone; the
438
+ # synthesizer's File.file?/File.read will see the same
439
+ # failure and return nil.
440
+ Digest::SHA256.hexdigest(path.to_s)
441
+ end
442
+
443
+ def invoke_synthesizer_safely(callable, path)
444
+ callable.call(path.to_s)
445
+ rescue StandardError
446
+ # WD6 fail-soft — a synthesizer that raises does NOT
447
+ # crash analysis. Slice 2b will turn this into a
448
+ # `source-rbs-synthesis-failed` info diagnostic; for now
449
+ # the contract is "no analysis crash on a misbehaving
450
+ # synthesizer".
451
+ nil
452
+ end
290
453
  end
291
454
 
292
455
  # Resolves a constant name to a Rigor::Type::Nominal (the *instance*
@@ -57,7 +57,8 @@ module Rigor
57
57
  return nil unless klass
58
58
 
59
59
  bucket_key = kind == :singleton ? "singleton_methods" : "instance_methods"
60
- klass.dig(bucket_key, selector.to_s)
60
+ klass.dig(bucket_key, selector.to_s) ||
61
+ resolve_alias_entry(klass, selector, bucket_key)
61
62
  end
62
63
 
63
64
  def reset!
@@ -66,6 +67,21 @@ module Rigor
66
67
 
67
68
  private
68
69
 
70
+ def resolve_alias_entry(klass, selector, bucket_key)
71
+ return nil unless bucket_key == "instance_methods"
72
+
73
+ aliases = klass["aliases"]
74
+ return nil unless aliases
75
+
76
+ alias_entry = aliases[selector.to_s]
77
+ return nil unless alias_entry
78
+
79
+ target = alias_entry["old"]
80
+ return nil unless target
81
+
82
+ klass.dig(bucket_key, target)
83
+ end
84
+
69
85
  def blocked?(class_name, selector)
70
86
  # Bang-suffixed selectors are mutating by Ruby convention
71
87
  # (`upcase!`, `concat`, etc. are listed explicitly below;
@@ -55,7 +55,16 @@ module Rigor
55
55
  # as `time_localtime`: `time_modify(time)` then a
56
56
  # `time_set_vtm` write and `TZMODE_SET_UTC`. Both
57
57
  # selectors share the cfunc, so both must be blocked.
58
- :gmtime, :utc
58
+ :gmtime, :utc,
59
+ # `getlocal` is not a mutator — it returns a fresh Time —
60
+ # but the fresh Time is pinned to the *analysis machine's*
61
+ # timezone. Folding it through a `Constant[Time]` carrier
62
+ # (which only ever holds a UTC literal from `Time.utc`)
63
+ # would bake a host-dependent wall clock / `utc_offset`
64
+ # into the inferred type. Blocked so the fold stays
65
+ # machine-independent; the RBS tier answers `Nominal[Time]`.
66
+ # `getutc` / `getgm` stay foldable — their result is UTC.
67
+ :getlocal
59
68
  ]
60
69
  }
61
70
  )