rigortype 0.1.5 → 0.1.6

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +36 -50
  3. data/lib/rigor/analysis/buffer_binding.rb +36 -0
  4. data/lib/rigor/analysis/check_rules.rb +11 -1
  5. data/lib/rigor/analysis/dependency_source_inference/index.rb +14 -1
  6. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +105 -0
  7. data/lib/rigor/analysis/dependency_source_inference/walker.rb +32 -12
  8. data/lib/rigor/analysis/project_scan.rb +39 -0
  9. data/lib/rigor/analysis/runner.rb +309 -22
  10. data/lib/rigor/analysis/worker_session.rb +14 -2
  11. data/lib/rigor/builtins/hkt_builtins.rb +342 -0
  12. data/lib/rigor/builtins/static_return_refinements.rb +120 -0
  13. data/lib/rigor/cache/store.rb +33 -3
  14. data/lib/rigor/cli/lsp_command.rb +129 -0
  15. data/lib/rigor/cli/type_of_command.rb +44 -5
  16. data/lib/rigor/cli.rb +74 -12
  17. data/lib/rigor/configuration.rb +38 -2
  18. data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
  19. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  20. data/lib/rigor/environment/rbs_loader.rb +45 -2
  21. data/lib/rigor/environment/reporters.rb +40 -0
  22. data/lib/rigor/environment.rb +106 -9
  23. data/lib/rigor/inference/acceptance.rb +48 -3
  24. data/lib/rigor/inference/expression_typer.rb +47 -0
  25. data/lib/rigor/inference/hkt_body.rb +171 -0
  26. data/lib/rigor/inference/hkt_body_parser.rb +363 -0
  27. data/lib/rigor/inference/hkt_reducer.rb +256 -0
  28. data/lib/rigor/inference/hkt_registry.rb +223 -0
  29. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +125 -30
  30. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
  31. data/lib/rigor/inference/method_dispatcher.rb +154 -3
  32. data/lib/rigor/inference/project_patched_methods.rb +70 -0
  33. data/lib/rigor/inference/project_patched_scanner.rb +210 -0
  34. data/lib/rigor/inference/scope_indexer.rb +156 -12
  35. data/lib/rigor/inference/statement_evaluator.rb +106 -6
  36. data/lib/rigor/inference/synthetic_method_scanner.rb +94 -16
  37. data/lib/rigor/language_server/buffer_table.rb +63 -0
  38. data/lib/rigor/language_server/completion_provider.rb +438 -0
  39. data/lib/rigor/language_server/debouncer.rb +86 -0
  40. data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
  41. data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
  42. data/lib/rigor/language_server/folding_range_provider.rb +75 -0
  43. data/lib/rigor/language_server/hover_provider.rb +74 -0
  44. data/lib/rigor/language_server/hover_renderer.rb +312 -0
  45. data/lib/rigor/language_server/loop.rb +71 -0
  46. data/lib/rigor/language_server/project_context.rb +145 -0
  47. data/lib/rigor/language_server/selection_range_provider.rb +93 -0
  48. data/lib/rigor/language_server/server.rb +384 -0
  49. data/lib/rigor/language_server/signature_help_provider.rb +249 -0
  50. data/lib/rigor/language_server/synchronized_writer.rb +28 -0
  51. data/lib/rigor/language_server/uri.rb +40 -0
  52. data/lib/rigor/language_server.rb +29 -0
  53. data/lib/rigor/plugin/base.rb +63 -0
  54. data/lib/rigor/plugin/macro/heredoc_template.rb +125 -11
  55. data/lib/rigor/plugin/manifest.rb +54 -7
  56. data/lib/rigor/plugin/registry.rb +19 -0
  57. data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
  58. data/lib/rigor/rbs_extended.rb +82 -2
  59. data/lib/rigor/sig_gen/generator.rb +12 -3
  60. data/lib/rigor/type/app.rb +107 -0
  61. data/lib/rigor/type.rb +1 -0
  62. data/lib/rigor/version.rb +1 -1
  63. data/sig/rigor/environment.rbs +8 -4
  64. data/sig/rigor/inference.rbs +2 -0
  65. data/sig/rigor.rbs +3 -1
  66. metadata +54 -1
@@ -3,6 +3,7 @@
3
3
  require "optionparser"
4
4
  require "prism"
5
5
 
6
+ require_relative "../analysis/buffer_binding"
6
7
  require_relative "../configuration"
7
8
  require_relative "../environment"
8
9
  require_relative "../scope"
@@ -38,35 +39,73 @@ module Rigor
38
39
  # @return [Integer] CLI exit status.
39
40
  def run
40
41
  options = parse_options
42
+ buffer = resolve_buffer_binding(options)
43
+ return CLI::EXIT_USAGE if buffer == :usage_error
41
44
 
42
45
  target = parse_position_argument(@argv)
43
46
  return CLI::EXIT_USAGE if target.nil?
44
47
 
45
- execute(target: target, options: options)
48
+ execute(target: target, options: options, buffer: buffer)
46
49
  end
47
50
 
48
51
  private
49
52
 
50
53
  def parse_options
51
- options = { format: "text", trace: false, config: nil }
54
+ options = { format: "text", trace: false, config: nil, tmp_file: nil, instead_of: nil }
52
55
 
53
56
  parser = OptionParser.new do |opts|
54
57
  opts.banner = USAGE
55
58
  opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
56
59
  opts.on("--trace", "Record fail-soft fallbacks via FallbackTracer") { options[:trace] = true }
57
60
  opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
61
+ opts.on("--tmp-file=PATH",
62
+ "Editor mode: read source bytes from PATH instead of --instead-of (paired)") do |value|
63
+ options[:tmp_file] = value
64
+ end
65
+ opts.on("--instead-of=PATH",
66
+ "Editor mode: the logical project path the buffer represents (paired with --tmp-file)") do |value|
67
+ options[:instead_of] = value
68
+ end
58
69
  end
59
70
  parser.parse!(@argv)
60
71
 
61
72
  options
62
73
  end
63
74
 
64
- def execute(target:, options:)
75
+ # Mirrors `Rigor::CLI#resolve_buffer_binding` (the `check`
76
+ # path). Returns nil / BufferBinding / :usage_error. The
77
+ # symbol return path lets the caller translate to
78
+ # `CLI::EXIT_USAGE` without raising.
79
+ def resolve_buffer_binding(options)
80
+ tmp = options[:tmp_file]
81
+ instead = options[:instead_of]
82
+ return nil if tmp.nil? && instead.nil?
83
+
84
+ if tmp.nil? || instead.nil?
85
+ @err.puts("--tmp-file and --instead-of must appear together")
86
+ return :usage_error
87
+ end
88
+
89
+ unless File.file?(tmp)
90
+ @err.puts("--tmp-file #{tmp.inspect}: no such file or not readable")
91
+ return :usage_error
92
+ end
93
+
94
+ Rigor::Analysis::BufferBinding.new(logical_path: instead, physical_path: tmp)
95
+ end
96
+
97
+ def execute(target:, options:, buffer: nil)
65
98
  file, line, column = target
66
- return 1 unless file_exists?(file)
99
+ # Under editor mode the logical `file` may not exist on disk
100
+ # (user editing a new file); the runtime check is only that
101
+ # the BUFFER is readable, which `resolve_buffer_binding`
102
+ # has already enforced. For non-editor mode `file` must
103
+ # exist.
104
+ physical = buffer ? buffer.resolve(file) : file
105
+ return 1 unless file_exists?(buffer ? physical : file)
67
106
 
68
107
  configuration = Configuration.load(options.fetch(:config))
69
- source = File.read(file)
108
+ source = File.read(physical)
70
109
  parse_result = Prism.parse(source, filepath: file, version: configuration.target_ruby)
71
110
  return 1 if parse_errors?(parse_result, file)
72
111
 
data/lib/rigor/cli.rb CHANGED
@@ -25,7 +25,8 @@ module Rigor
25
25
  "type-scan" => :run_type_scan,
26
26
  "explain" => :run_explain,
27
27
  "diff" => :run_diff,
28
- "sig-gen" => :run_sig_gen
28
+ "sig-gen" => :run_sig_gen,
29
+ "lsp" => :run_lsp
29
30
  }.freeze
30
31
 
31
32
  def self.start(argv = ARGV, out: $stdout, err: $stderr)
@@ -69,24 +70,22 @@ module Rigor
69
70
 
70
71
  def run_check
71
72
  require_relative "analysis/runner"
73
+ require_relative "analysis/buffer_binding"
72
74
  require_relative "cache/store"
73
75
 
74
76
  options = parse_check_options
77
+ buffer = resolve_buffer_binding(options)
78
+ return EXIT_USAGE if buffer == :usage_error
75
79
 
76
80
  configuration = Configuration.load(options.fetch(:config))
77
81
  cache_root = configuration.cache_path
78
82
  handle_clear_cache(cache_root) if options.fetch(:clear_cache)
79
- cache_store = options.fetch(:no_cache) ? nil : Cache::Store.new(root: cache_root)
80
83
 
81
- paths = @argv.empty? ? configuration.paths : @argv
82
- runner = Analysis::Runner.new(
83
- configuration: configuration,
84
- explain: options.fetch(:explain),
85
- cache_store: cache_store,
86
- collect_stats: options.fetch(:stats),
87
- workers: resolve_workers(options, configuration)
84
+ runner = build_check_runner(
85
+ configuration: configuration, options: options,
86
+ buffer: buffer, cache_root: cache_root
88
87
  )
89
- result = runner.run(paths)
88
+ result = runner.run(@argv.empty? ? configuration.paths : @argv)
90
89
 
91
90
  write_result(result, options.fetch(:format))
92
91
  write_run_stats(result.stats) if result.stats
@@ -94,6 +93,49 @@ module Rigor
94
93
  result.success? ? 0 : 1
95
94
  end
96
95
 
96
+ def build_check_runner(configuration:, options:, buffer:, cache_root:)
97
+ cache_store = options.fetch(:no_cache) ? nil : Cache::Store.new(root: cache_root)
98
+ Analysis::Runner.new(
99
+ configuration: configuration,
100
+ explain: options.fetch(:explain),
101
+ cache_store: cache_store,
102
+ collect_stats: options.fetch(:stats),
103
+ workers: resolve_workers(options, configuration),
104
+ buffer: buffer
105
+ )
106
+ end
107
+
108
+ # Editor-mode CLI envelope. The `--tmp-file=PATH` /
109
+ # `--instead-of=PATH` pair binds an in-flight buffer file to
110
+ # the logical project path it represents (see
111
+ # `docs/design/20260516-editor-mode.md`). Both flags must
112
+ # appear together; either alone is a usage error. The
113
+ # physical file must be readable; missing-file is a usage
114
+ # error too so editors get one consistent failure shape.
115
+ #
116
+ # Returns:
117
+ # - `nil` when neither flag was supplied (legacy path).
118
+ # - `Rigor::Analysis::BufferBinding` when the pair is valid.
119
+ # - `:usage_error` after writing one diagnostic to stderr;
120
+ # the caller MUST translate this to `EXIT_USAGE`.
121
+ def resolve_buffer_binding(options)
122
+ tmp = options[:tmp_file]
123
+ instead = options[:instead_of]
124
+ return nil if tmp.nil? && instead.nil?
125
+
126
+ if tmp.nil? || instead.nil?
127
+ @err.puts("--tmp-file and --instead-of must appear together")
128
+ return :usage_error
129
+ end
130
+
131
+ unless File.file?(tmp)
132
+ @err.puts("--tmp-file #{tmp.inspect}: no such file or not readable")
133
+ return :usage_error
134
+ end
135
+
136
+ Analysis::BufferBinding.new(logical_path: instead, physical_path: tmp)
137
+ end
138
+
97
139
  # ADR-15 Phase 4c — resolves the worker count by
98
140
  # precedence: CLI `--workers=N` (most explicit) > env
99
141
  # `RIGOR_RACTOR_WORKERS` > config `.rigor.yml`
@@ -112,7 +154,7 @@ module Rigor
112
154
  configuration.parallel_workers
113
155
  end
114
156
 
115
- def parse_check_options
157
+ def parse_check_options # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
116
158
  options = {
117
159
  # `nil` triggers `Configuration.discover` (`.rigor.yml` then
118
160
  # `.rigor.dist.yml`); an explicit `--config=PATH` overrides.
@@ -133,7 +175,12 @@ module Rigor
133
175
  # `RIGOR_RACTOR_WORKERS` then `.rigor.yml`
134
176
  # `parallel.workers:` then 0 (sequential). See
135
177
  # `resolve_workers` for the precedence chain.
136
- workers: nil
178
+ workers: nil,
179
+ # Editor mode (`docs/design/20260516-editor-mode.md`).
180
+ # Both must appear together; the runner uses the pair
181
+ # to bind an in-flight buffer file to its logical path.
182
+ tmp_file: nil,
183
+ instead_of: nil
137
184
  }
138
185
  parser = OptionParser.new do |opts|
139
186
  opts.banner = "Usage: rigor check [options] [paths]"
@@ -151,6 +198,14 @@ module Rigor
151
198
  "Dispatch per-file analysis across N Ractor workers (default: 0; sequential)") do |value|
152
199
  options[:workers] = value
153
200
  end
201
+ opts.on("--tmp-file=PATH",
202
+ "Editor mode: read source bytes from PATH instead of --instead-of (paired)") do |value|
203
+ options[:tmp_file] = value
204
+ end
205
+ opts.on("--instead-of=PATH",
206
+ "Editor mode: the logical project path the buffer represents (paired with --tmp-file)") do |value|
207
+ options[:instead_of] = value
208
+ end
154
209
  end
155
210
  parser.parse!(@argv)
156
211
  options
@@ -336,6 +391,12 @@ module Rigor
336
391
  SigGenCommand.new(argv: @argv, out: @out, err: @err).run
337
392
  end
338
393
 
394
+ def run_lsp
395
+ require_relative "cli/lsp_command"
396
+
397
+ LspCommand.new(argv: @argv, out: @out, err: @err).run
398
+ end
399
+
339
400
  def write_result(result, format)
340
401
  case format
341
402
  when "json"
@@ -376,6 +437,7 @@ module Rigor
376
437
  explain Print the description of one or all CheckRules
377
438
  diff Compare current diagnostics to a saved baseline JSON
378
439
  sig-gen Emit RBS skeletons inferred from .rb sources (ADR-14)
440
+ lsp Run the Rigor Language Server (LSP) over stdio
379
441
  version Print the Rigor version
380
442
  help Print this help
381
443
  HELP
@@ -49,6 +49,16 @@ module Rigor
49
49
  "disable" => [],
50
50
  "libraries" => [],
51
51
  "signature_paths" => nil,
52
+ # ADR-17 — project-side monkey-patch pre-evaluation.
53
+ # Empty by default; users opt in by listing explicit files
54
+ # that the analyzer walks before per-file inference so
55
+ # patched-method declarations are visible across the
56
+ # project (e.g. `lib/core_ext/string_extensions.rb`). Slice 1
57
+ # plumbing only — listed files are validated at config-load
58
+ # time (`pre-eval.file-not-found` on a missing path), but
59
+ # the dispatcher tier consuming the registry lands in
60
+ # slice 2.
61
+ "pre_eval" => [],
52
62
  "fold_platform_specific_paths" => false,
53
63
  "cache" => {
54
64
  "path" => ".rigor/cache"
@@ -145,7 +155,7 @@ module Rigor
145
155
  # MUST be resolved relative to the config file's directory.
146
156
  # `exclude:` is intentionally NOT in this list — its entries
147
157
  # are glob patterns (`**/vendor/**`), not paths.
148
- PATH_KEYS = %w[paths signature_paths].freeze
158
+ PATH_KEYS = %w[paths signature_paths pre_eval].freeze
149
159
  private_constant :PATH_KEYS
150
160
 
151
161
  attr_reader :target_ruby, :paths, :exclude_patterns, :plugins, :cache_path, :disabled_rules,
@@ -155,7 +165,8 @@ module Rigor
155
165
  :severity_profile, :severity_overrides,
156
166
  :dependencies, :parallel_workers,
157
167
  :bundler_bundle_path, :bundler_auto_detect, :bundler_lockfile,
158
- :rbs_collection_lockfile, :rbs_collection_auto_detect
168
+ :rbs_collection_lockfile, :rbs_collection_auto_detect,
169
+ :pre_eval
159
170
 
160
171
  # Loads a configuration file.
161
172
  #
@@ -307,6 +318,9 @@ module Rigor
307
318
  @libraries = Array(data.fetch("libraries", DEFAULTS.fetch("libraries"))).map(&:to_s).freeze
308
319
  sig_paths = data.fetch("signature_paths", DEFAULTS.fetch("signature_paths"))
309
320
  @signature_paths = sig_paths.nil? ? nil : Array(sig_paths).map(&:to_s).freeze
321
+ @pre_eval = expand_pre_eval_entries(
322
+ Array(data.fetch("pre_eval", DEFAULTS.fetch("pre_eval"))).map(&:to_s)
323
+ )
310
324
  @fold_platform_specific_paths = data.fetch(
311
325
  "fold_platform_specific_paths", DEFAULTS.fetch("fold_platform_specific_paths")
312
326
  ) == true
@@ -357,6 +371,7 @@ module Rigor
357
371
  "disable" => disabled_rules,
358
372
  "libraries" => libraries,
359
373
  "signature_paths" => signature_paths,
374
+ "pre_eval" => pre_eval,
360
375
  "fold_platform_specific_paths" => fold_platform_specific_paths,
361
376
  "cache" => {
362
377
  "path" => cache_path
@@ -386,6 +401,27 @@ module Rigor
386
401
 
387
402
  private
388
403
 
404
+ # ADR-17 slice 4 — `pre_eval:` glob expansion. Each entry is
405
+ # accepted as either a literal path (slice 1 contract) OR a
406
+ # `File.fnmatch?`-shaped glob pattern (`lib/core_ext/**/*.rb`).
407
+ # Glob meta characters (`*`, `?`, `[`) trigger `Dir.glob`
408
+ # expansion; the resulting file list is folded into the
409
+ # `pre_eval:` set with `uniq`. Literal entries that don't
410
+ # exist on disk continue to surface as `pre-eval.file-not-found`
411
+ # `:error` (slice 1 behaviour); glob entries that match
412
+ # nothing degrade silently to "no contribution from this
413
+ # entry" so a templated `**` pattern in a fresh project
414
+ # doesn't generate an error per match-less pattern.
415
+ def expand_pre_eval_entries(entries)
416
+ entries.flat_map do |entry|
417
+ glob_pattern?(entry) ? Dir.glob(entry, sort: true) : [entry]
418
+ end.uniq.freeze
419
+ end
420
+
421
+ def glob_pattern?(path)
422
+ path.include?("*") || path.include?("?") || path.include?("[")
423
+ end
424
+
389
425
  # Accepts either `"rigor-foo"` (gem-name shorthand) or
390
426
  # `{ "gem" => "rigor-foo", "id" => "foo", "config" => {...} }`
391
427
  # (full form). Returns the canonical hash form so the loader
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class Environment
5
+ # ADR-20 slice 2e — mutable single-slot memoization
6
+ # container for the per-Environment HKT registry. Held by
7
+ # {Environment} so the otherwise-frozen instance can
8
+ # still cache a computed value on first access.
9
+ #
10
+ # Concurrent {#fetch} calls from multiple threads against
11
+ # one Environment are NOT serialised here — the LSP
12
+ # single-publish-at-a-time discipline and the Ractor
13
+ # pool's per-worker Environment shape already prevent
14
+ # cross-thread races. If a future caller introduces a
15
+ # multi-threaded reader path against a shared
16
+ # Environment, the synchronisation belongs at that
17
+ # caller's seam, not here.
18
+ class HktRegistryHolder
19
+ def initialize
20
+ @loaded = false
21
+ @value = nil
22
+ end
23
+
24
+ def fetch
25
+ return @value if @loaded
26
+
27
+ @value = yield
28
+ @loaded = true
29
+ @value
30
+ end
31
+ end
32
+ end
33
+ end
@@ -42,7 +42,7 @@ module Rigor
42
42
  # enough that hard-coding is acceptable; a directory walk
43
43
  # at every call would add stat-cost to no benefit.)
44
44
  VENDORED_GEM_NAMES = Set[
45
- "bcrypt", "idn-ruby", "mysql2", "nokogiri", "pg", "redis"
45
+ "bcrypt", "idn-ruby", "mysql2", "nokogiri", "pg", "prism", "redis"
46
46
  ].freeze
47
47
 
48
48
  # @param locked_gems [Hash{String => LockfileResolver::LockedGem}]
@@ -83,7 +83,7 @@ module Rigor
83
83
  VENDORED_GEM_SIGS_ROOT = File.expand_path(
84
84
  "../../../data/vendored_gem_sigs",
85
85
  __dir__
86
- )
86
+ ).freeze
87
87
  private_constant :VENDORED_GEM_SIGS_ROOT
88
88
 
89
89
  def vendored_gem_sig_paths
@@ -118,7 +118,20 @@ module Rigor
118
118
  @libraries = libraries.map(&:to_s).freeze
119
119
  @signature_paths = signature_paths.map { |p| Pathname(p) }.freeze
120
120
  @cache_store = cache_store
121
- @state = { env: nil, builder: nil }
121
+ # Per-loader memoization bucket. Held as a single
122
+ # mutable Hash so the loader instance itself can be
123
+ # `.freeze`d (per ADR-15 reflection-facade contract)
124
+ # without losing the lazy-memo behaviour. Slot names
125
+ # currently consulted: `:env`, `:env_loaded`,
126
+ # `:env_build_warned`, `:builder`, `:reflection`,
127
+ # `:instance_definitions_table`,
128
+ # `:singleton_definitions_table`. Constructed via
129
+ # `Hash.new` (NOT a `{ ... }` literal) so Rigor's
130
+ # `HashShape` narrowing doesn't infer a fixed key set
131
+ # from the initial state and fold post-initial slot
132
+ # reads (e.g. `@state[:env_loaded]`) to a constant
133
+ # `nil`.
134
+ @state = Hash.new # rubocop:disable Style/EmptyLiteral
122
135
  @instance_definition_cache = {}
123
136
  @singleton_definition_cache = {}
124
137
  @class_known_cache = {}
@@ -165,6 +178,36 @@ module Rigor
165
178
  # v0.0.9 cache `Cache::Descriptor` regression did.
166
179
  end
167
180
 
181
+ # ADR-20 slice 2e — iterates over every `%a{...}`
182
+ # annotation attached to a class- or module-level
183
+ # declaration in the loaded RBS environment, yielding
184
+ # `(annotation_string, source_location)` pairs. Used by
185
+ # {Rigor::Inference::HktRegistry.scan_rbs_loader} to
186
+ # find `rigor:v1:hkt_register` / `rigor:v1:hkt_define`
187
+ # directives in user-authored overlays and merge them
188
+ # into the per-`Environment` HKT registry. Yields nothing
189
+ # when the env failed to build (fail-soft, same shape as
190
+ # {#each_known_class_name}).
191
+ def each_class_decl_annotation
192
+ return enum_for(:each_class_decl_annotation) unless block_given?
193
+ return if env.nil?
194
+
195
+ env.class_decls.each_value do |entry|
196
+ entry.each_decl do |decl|
197
+ next unless decl.respond_to?(:annotations)
198
+
199
+ decl.annotations.each { |a| yield a.string, a.location }
200
+ end
201
+ end
202
+ rescue ::RBS::BaseError, ::Ractor::IsolationError
203
+ # fail-soft: matches each_known_class_name's policy.
204
+ # Ractor::IsolationError surfaces when the scan is
205
+ # invoked from a non-main Ractor pool worker before
206
+ # ADR-15's full deep-freeze migration completes — the
207
+ # worker falls back to the base (builtins-only)
208
+ # registry rather than crashing.
209
+ end
210
+
168
211
  # Returns a frozen `Hash<String, String>` mapping each loaded
169
212
  # class / module name (top-level prefixed) to the file path of
170
213
  # its FIRST declaration's RBS source. Used by
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class Environment
5
+ # Mutable container for the per-run analysis reporters
6
+ # ({Rigor::RbsExtended::Reporter} and
7
+ # {Rigor::Analysis::DependencySourceInference::BoundaryCrossReporter}).
8
+ # Held by {Environment} as a single attr; the reporters can be
9
+ # swapped through {Environment#attach_reporters!} so long-lived
10
+ # integrations (the LSP `ProjectContext`, future editor-mode
11
+ # daemons) can share one Environment across many `Runner.run`
12
+ # calls without each call's diagnostic events accumulating into
13
+ # a single reporter pair.
14
+ #
15
+ # Per-publish reset is the contract: at the start of every
16
+ # `Runner.run` in sequential mode, the runner stamps the
17
+ # environment's `Reporters` slot with the runner's own
18
+ # freshly-built reporter pair. Dispatchers / `RbsExtended`
19
+ # consumers continue to write through
20
+ # `environment.rbs_extended_reporter` /
21
+ # `environment.boundary_cross_reporter` — the lookup just hops
22
+ # through the `Reporters` slot rather than reading a frozen
23
+ # ivar.
24
+ #
25
+ # Construction default is `nil` on both slots so existing
26
+ # callers that don't care about reporters (project-default
27
+ # `Environment.default`, test scopes that don't drive
28
+ # dispatch) keep their current behaviour: reporter lookups
29
+ # return nil, and the consumer sites short-circuit on
30
+ # `reporter.nil?`.
31
+ class Reporters
32
+ attr_accessor :rbs_extended, :boundary_cross
33
+
34
+ def initialize(rbs_extended: nil, boundary_cross: nil)
35
+ @rbs_extended = rbs_extended
36
+ @boundary_cross = boundary_cross
37
+ end
38
+ end
39
+ end
40
+ end
@@ -3,11 +3,16 @@
3
3
  require_relative "environment/class_registry"
4
4
  require_relative "environment/rbs_loader"
5
5
  require_relative "environment/reflection"
6
+ require_relative "environment/reporters"
7
+ require_relative "environment/hkt_registry_holder"
6
8
  require_relative "environment/bundle_sig_discovery"
7
9
  require_relative "environment/lockfile_resolver"
8
10
  require_relative "environment/rbs_collection_discovery"
9
11
  require_relative "environment/rbs_coverage_report"
10
12
  require_relative "inference/synthetic_method_index"
13
+ require_relative "inference/project_patched_methods"
14
+ require_relative "inference/hkt_registry"
15
+ require_relative "builtins/hkt_builtins"
11
16
  require_relative "type_node/name_scope"
12
17
  require_relative "type_node/resolver_chain"
13
18
 
@@ -57,8 +62,8 @@ module Rigor
57
62
  ].freeze
58
63
 
59
64
  attr_reader :class_registry, :rbs_loader, :plugin_registry, :dependency_source_index,
60
- :rbs_extended_reporter, :boundary_cross_reporter, :name_scope,
61
- :synthetic_method_index
65
+ :reporters, :name_scope,
66
+ :synthetic_method_index, :project_patched_methods
62
67
 
63
68
  # @param class_registry [Rigor::Environment::ClassRegistry]
64
69
  # @param rbs_loader [Rigor::Environment::RbsLoader, nil] when nil the
@@ -79,24 +84,105 @@ module Rigor
79
84
  # sources the dispatcher consults BELOW RBS dispatch.
80
85
  # When nil (the default), no dep-source contribution
81
86
  # participates and the dispatcher tier is a no-op.
82
- def initialize(class_registry: ClassRegistry.default, rbs_loader: nil,
87
+ def initialize(class_registry: ClassRegistry.default, rbs_loader: nil, # rubocop:disable Metrics/ParameterLists
83
88
  plugin_registry: nil, dependency_source_index: nil,
84
89
  rbs_extended_reporter: nil, boundary_cross_reporter: nil,
85
- synthetic_method_index: nil)
90
+ synthetic_method_index: nil, project_patched_methods: nil,
91
+ hkt_registry: nil)
86
92
  @class_registry = class_registry
87
93
  @rbs_loader = rbs_loader
88
94
  @plugin_registry = plugin_registry
89
95
  @dependency_source_index = dependency_source_index
90
- @rbs_extended_reporter = rbs_extended_reporter
91
- @boundary_cross_reporter = boundary_cross_reporter
96
+ # ADR-pending — reporters live in a mutable container so
97
+ # long-lived integrations (LSP `ProjectContext`) can swap
98
+ # them per `Runner.run` without rebuilding the env. The
99
+ # existing `#rbs_extended_reporter` / `#boundary_cross_reporter`
100
+ # accessors below preserve the public lookup shape.
101
+ @reporters = Reporters.new(
102
+ rbs_extended: rbs_extended_reporter,
103
+ boundary_cross: boundary_cross_reporter
104
+ )
92
105
  @synthetic_method_index = synthetic_method_index || Inference::SyntheticMethodIndex::EMPTY
106
+ @project_patched_methods = project_patched_methods || Inference::ProjectPatchedMethods::EMPTY
107
+ # ADR-20 slice 2c + 2e — the per-env HKT registry
108
+ # consulted by the reducer when resolving `Type::App`
109
+ # carriers. Defaults to {Inference::HktRegistry::EMPTY};
110
+ # the {.default} / {.for_project} class methods seed it
111
+ # with the bundled builtins (`json::value`, …) plus any
112
+ # `%a{rigor:v1:hkt_register / hkt_define}` annotations
113
+ # the RBS loader exposes. The hkt_registry getter
114
+ # (defined below) MEMOIZES the result of merging the
115
+ # base with the RBS scan so the scan is paid at most
116
+ # once per Environment lifetime — and only when first
117
+ # consulted, leaving fast paths like `rigor check
118
+ # --cache-stats --no-stats` from doing the RBS env
119
+ # build at all.
120
+ @hkt_registry_base = hkt_registry || Inference::HktRegistry::EMPTY
121
+ @hkt_registry_holder = HktRegistryHolder.new
93
122
  @name_scope = build_name_scope
94
123
  freeze
95
124
  end
96
125
 
126
+ # ADR-20 slices 2e + 6 — lazy HKT registry getter.
127
+ # Merge order on first call: builtins (base) ← plugin
128
+ # manifest aggregation ← RBS env scan. Last-write-wins on
129
+ # URI collisions so user-authored `.rbs` overlays beat
130
+ # plugin entries, which beat the bundled JSON_VALUE.
131
+ # Memoised; single-threaded use only (under the Ractor
132
+ # pool path each worker has its own Environment so
133
+ # cross-worker mutation is impossible; the LSP
134
+ # single-publish-at-a-time invariant serialises here).
135
+ def hkt_registry
136
+ @hkt_registry_holder.fetch do
137
+ with_plugin_overlay = if @plugin_registry.respond_to?(:hkt_overlay_registry)
138
+ @hkt_registry_base.merge(@plugin_registry.hkt_overlay_registry)
139
+ else
140
+ @hkt_registry_base
141
+ end
142
+ Inference::HktRegistry.scan_rbs_loader(
143
+ @rbs_loader,
144
+ base: with_plugin_overlay,
145
+ reporter: rbs_extended_reporter
146
+ )
147
+ end
148
+ end
149
+
150
+ # Backwards-compatible reporter accessors — every existing
151
+ # consumer (rbs_extended, method_dispatcher) calls these. The
152
+ # frozen `@reporters` container is mutable for slot reassignment
153
+ # via {#attach_reporters!} below.
154
+ def rbs_extended_reporter
155
+ @reporters.rbs_extended
156
+ end
157
+
158
+ def boundary_cross_reporter
159
+ @reporters.boundary_cross
160
+ end
161
+
162
+ # Replaces the env's per-run reporter slots. Intended for
163
+ # long-lived integrations (LSP `ProjectContext`) that share one
164
+ # Environment instance across many `Runner.run` calls: each call
165
+ # attaches its own fresh reporter pair so per-call diagnostic
166
+ # events stay scoped to that call rather than accumulating
167
+ # across publishes.
168
+ #
169
+ # Single-threaded use only. Concurrent publishes against one
170
+ # Environment must serialise — the LSP `Server` debouncer +
171
+ # synchronized writer already enforces this for the editor
172
+ # path. The Ractor pool path builds a per-worker Environment
173
+ # and does not reach this surface.
174
+ def attach_reporters!(rbs_extended_reporter:, boundary_cross_reporter:)
175
+ @reporters.rbs_extended = rbs_extended_reporter
176
+ @reporters.boundary_cross = boundary_cross_reporter
177
+ nil
178
+ end
179
+
97
180
  class << self
98
181
  def default
99
- @default ||= new(rbs_loader: RbsLoader.default).freeze
182
+ @default ||= new(
183
+ rbs_loader: RbsLoader.default,
184
+ hkt_registry: Builtins::HktBuiltins.registry
185
+ ).freeze
100
186
  end
101
187
 
102
188
  # Builds an Environment that consults the project's local
@@ -127,7 +213,7 @@ module Rigor
127
213
  bundler_bundle_path: nil, bundler_auto_detect: false,
128
214
  bundler_lockfile: nil,
129
215
  rbs_collection_lockfile: nil, rbs_collection_auto_detect: false,
130
- synthetic_method_index: nil)
216
+ synthetic_method_index: nil, project_patched_methods: nil)
131
217
  resolved_paths = signature_paths || default_signature_paths(root)
132
218
  # O4 MVP — append per-gem `sig/` directories discovered
133
219
  # under the target project's bundler install root. Empty
@@ -173,13 +259,24 @@ module Rigor
173
259
  signature_paths: loader_signature_paths,
174
260
  cache_store: cache_store
175
261
  )
262
+ # ADR-20 slice 2c + 2e — seed hkt_registry with the
263
+ # bundled builtins. The Environment's `#hkt_registry`
264
+ # getter then LAZILY merges in the RBS env scan on
265
+ # first call so fast paths that don't consult HKT
266
+ # (e.g. `rigor check --cache-stats --no-stats`) don't
267
+ # pay the eager env-build cost up front. URI
268
+ # collisions let the user-authored overlay win over
269
+ # the bundled builtin (last-write-wins per ADR-20
270
+ # OQ3 tentative).
176
271
  new(
177
272
  rbs_loader: loader,
178
273
  plugin_registry: plugin_registry,
179
274
  dependency_source_index: dependency_source_index,
180
275
  rbs_extended_reporter: rbs_extended_reporter,
181
276
  boundary_cross_reporter: boundary_cross_reporter,
182
- synthetic_method_index: synthetic_method_index
277
+ synthetic_method_index: synthetic_method_index,
278
+ project_patched_methods: project_patched_methods,
279
+ hkt_registry: Builtins::HktBuiltins.registry
183
280
  )
184
281
  end
185
282
  # rubocop:enable Metrics/MethodLength, Metrics/ParameterLists