rigortype 0.1.5 → 0.1.7

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -79
  3. data/lib/rigor/analysis/baseline.rb +347 -0
  4. data/lib/rigor/analysis/buffer_binding.rb +36 -0
  5. data/lib/rigor/analysis/check_rules.rb +68 -3
  6. data/lib/rigor/analysis/dependency_source_inference/index.rb +14 -1
  7. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +105 -0
  8. data/lib/rigor/analysis/dependency_source_inference/walker.rb +32 -12
  9. data/lib/rigor/analysis/project_scan.rb +39 -0
  10. data/lib/rigor/analysis/runner.rb +309 -22
  11. data/lib/rigor/analysis/worker_session.rb +14 -2
  12. data/lib/rigor/builtins/hkt_builtins.rb +342 -0
  13. data/lib/rigor/builtins/static_return_refinements.rb +142 -0
  14. data/lib/rigor/cache/store.rb +33 -3
  15. data/lib/rigor/cli/baseline_command.rb +377 -0
  16. data/lib/rigor/cli/lsp_command.rb +129 -0
  17. data/lib/rigor/cli/type_of_command.rb +44 -5
  18. data/lib/rigor/cli.rb +142 -13
  19. data/lib/rigor/configuration.rb +58 -2
  20. data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
  21. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  22. data/lib/rigor/environment/rbs_loader.rb +67 -2
  23. data/lib/rigor/environment/reporters.rb +40 -0
  24. data/lib/rigor/environment.rb +119 -9
  25. data/lib/rigor/flow_contribution/fact.rb +20 -10
  26. data/lib/rigor/inference/acceptance.rb +48 -3
  27. data/lib/rigor/inference/expression_typer.rb +64 -2
  28. data/lib/rigor/inference/hkt_body.rb +171 -0
  29. data/lib/rigor/inference/hkt_body_parser.rb +363 -0
  30. data/lib/rigor/inference/hkt_reducer.rb +256 -0
  31. data/lib/rigor/inference/hkt_registry.rb +223 -0
  32. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +125 -30
  33. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +32 -11
  34. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
  35. data/lib/rigor/inference/method_dispatcher.rb +174 -6
  36. data/lib/rigor/inference/narrowing.rb +103 -1
  37. data/lib/rigor/inference/project_patched_methods.rb +70 -0
  38. data/lib/rigor/inference/project_patched_scanner.rb +210 -0
  39. data/lib/rigor/inference/scope_indexer.rb +209 -19
  40. data/lib/rigor/inference/statement_evaluator.rb +172 -11
  41. data/lib/rigor/inference/synthetic_method_scanner.rb +94 -16
  42. data/lib/rigor/language_server/buffer_table.rb +63 -0
  43. data/lib/rigor/language_server/completion_provider.rb +438 -0
  44. data/lib/rigor/language_server/debouncer.rb +86 -0
  45. data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
  46. data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
  47. data/lib/rigor/language_server/folding_range_provider.rb +75 -0
  48. data/lib/rigor/language_server/hover_provider.rb +74 -0
  49. data/lib/rigor/language_server/hover_renderer.rb +312 -0
  50. data/lib/rigor/language_server/loop.rb +71 -0
  51. data/lib/rigor/language_server/project_context.rb +145 -0
  52. data/lib/rigor/language_server/selection_range_provider.rb +93 -0
  53. data/lib/rigor/language_server/server.rb +384 -0
  54. data/lib/rigor/language_server/signature_help_provider.rb +249 -0
  55. data/lib/rigor/language_server/synchronized_writer.rb +28 -0
  56. data/lib/rigor/language_server/uri.rb +40 -0
  57. data/lib/rigor/language_server.rb +29 -0
  58. data/lib/rigor/plugin/base.rb +63 -0
  59. data/lib/rigor/plugin/macro/heredoc_template.rb +127 -13
  60. data/lib/rigor/plugin/macro/trait_registry.rb +1 -1
  61. data/lib/rigor/plugin/manifest.rb +54 -7
  62. data/lib/rigor/plugin/registry.rb +19 -0
  63. data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
  64. data/lib/rigor/rbs_extended.rb +82 -2
  65. data/lib/rigor/sig_gen/generator.rb +12 -3
  66. data/lib/rigor/type/app.rb +107 -0
  67. data/lib/rigor/type.rb +1 -0
  68. data/lib/rigor/version.rb +1 -1
  69. data/sig/rigor/environment.rbs +10 -4
  70. data/sig/rigor/inference.rbs +2 -0
  71. data/sig/rigor.rbs +4 -1
  72. metadata +56 -1
data/lib/rigor/cli.rb CHANGED
@@ -25,7 +25,9 @@ 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,
30
+ "baseline" => :run_baseline
29
31
  }.freeze
30
32
 
31
33
  def self.start(argv = ARGV, out: $stdout, err: $stderr)
@@ -69,24 +71,24 @@ module Rigor
69
71
 
70
72
  def run_check
71
73
  require_relative "analysis/runner"
74
+ require_relative "analysis/buffer_binding"
75
+ require_relative "analysis/baseline"
72
76
  require_relative "cache/store"
73
77
 
74
78
  options = parse_check_options
79
+ buffer = resolve_buffer_binding(options)
80
+ return EXIT_USAGE if buffer == :usage_error
75
81
 
76
82
  configuration = Configuration.load(options.fetch(:config))
77
83
  cache_root = configuration.cache_path
78
84
  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
85
 
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)
86
+ runner = build_check_runner(
87
+ configuration: configuration, options: options,
88
+ buffer: buffer, cache_root: cache_root
88
89
  )
89
- result = runner.run(paths)
90
+ result = runner.run(@argv.empty? ? configuration.paths : @argv)
91
+ result = apply_baseline_filter(result, configuration, options)
90
92
 
91
93
  write_result(result, options.fetch(:format))
92
94
  write_run_stats(result.stats) if result.stats
@@ -94,6 +96,94 @@ module Rigor
94
96
  result.success? ? 0 : 1
95
97
  end
96
98
 
99
+ # ADR-22 — apply the baseline filter as the LAST step of
100
+ # the diagnostic pipeline (after `# rigor:disable`,
101
+ # `severity_profile`, etc. — WD6). Resolution order
102
+ # follows WD2 (b):
103
+ #
104
+ # 1. --no-baseline on the CLI → no baseline.
105
+ # 2. --baseline=PATH on the CLI → load that path.
106
+ # 3. .rigor.yml's `baseline: <path>` → load that path.
107
+ # 4. otherwise → no baseline.
108
+ #
109
+ # When the path resolves and loads successfully, the filter
110
+ # replaces `result.diagnostics` with the surfaced set and
111
+ # writes a one-line summary to stderr (WD7) when any
112
+ # diagnostics were silenced. Load failures emit a warning
113
+ # to stderr and fall through to "no baseline" (graceful
114
+ # degradation).
115
+ def apply_baseline_filter(result, configuration, options)
116
+ path = resolve_baseline_path(configuration, options)
117
+ return result if path.nil?
118
+
119
+ baseline = Analysis::Baseline.load(path)
120
+ return result if baseline.nil?
121
+
122
+ surfaced, silenced_count = baseline.filter(result.diagnostics)
123
+ report_baseline_summary(silenced_count, path) if silenced_count.positive?
124
+ Analysis::Result.new(diagnostics: surfaced, stats: result.stats)
125
+ rescue Analysis::Baseline::LoadError => e
126
+ @err.puts("rigor: baseline load failed: #{e.message} (continuing without baseline)")
127
+ result
128
+ end
129
+
130
+ # WD2 (b) — resolve effective baseline path.
131
+ def resolve_baseline_path(configuration, options)
132
+ cli_value = options.fetch(:baseline)
133
+ case cli_value
134
+ when false then nil # --no-baseline
135
+ when :unset then configuration.baseline_path # fall through to config
136
+ else cli_value # --baseline=PATH
137
+ end
138
+ end
139
+
140
+ def report_baseline_summary(silenced_count, baseline_path)
141
+ @err.puts("rigor: #{silenced_count} diagnostic(s) silenced by baseline #{baseline_path}")
142
+ end
143
+
144
+ def build_check_runner(configuration:, options:, buffer:, cache_root:)
145
+ cache_store = options.fetch(:no_cache) ? nil : Cache::Store.new(root: cache_root)
146
+ Analysis::Runner.new(
147
+ configuration: configuration,
148
+ explain: options.fetch(:explain),
149
+ cache_store: cache_store,
150
+ collect_stats: options.fetch(:stats),
151
+ workers: resolve_workers(options, configuration),
152
+ buffer: buffer
153
+ )
154
+ end
155
+
156
+ # Editor-mode CLI envelope. The `--tmp-file=PATH` /
157
+ # `--instead-of=PATH` pair binds an in-flight buffer file to
158
+ # the logical project path it represents (see
159
+ # `docs/design/20260516-editor-mode.md`). Both flags must
160
+ # appear together; either alone is a usage error. The
161
+ # physical file must be readable; missing-file is a usage
162
+ # error too so editors get one consistent failure shape.
163
+ #
164
+ # Returns:
165
+ # - `nil` when neither flag was supplied (legacy path).
166
+ # - `Rigor::Analysis::BufferBinding` when the pair is valid.
167
+ # - `:usage_error` after writing one diagnostic to stderr;
168
+ # the caller MUST translate this to `EXIT_USAGE`.
169
+ def resolve_buffer_binding(options)
170
+ tmp = options[:tmp_file]
171
+ instead = options[:instead_of]
172
+ return nil if tmp.nil? && instead.nil?
173
+
174
+ if tmp.nil? || instead.nil?
175
+ @err.puts("--tmp-file and --instead-of must appear together")
176
+ return :usage_error
177
+ end
178
+
179
+ unless File.file?(tmp)
180
+ @err.puts("--tmp-file #{tmp.inspect}: no such file or not readable")
181
+ return :usage_error
182
+ end
183
+
184
+ Analysis::BufferBinding.new(logical_path: instead, physical_path: tmp)
185
+ end
186
+
97
187
  # ADR-15 Phase 4c — resolves the worker count by
98
188
  # precedence: CLI `--workers=N` (most explicit) > env
99
189
  # `RIGOR_RACTOR_WORKERS` > config `.rigor.yml`
@@ -112,7 +202,7 @@ module Rigor
112
202
  configuration.parallel_workers
113
203
  end
114
204
 
115
- def parse_check_options
205
+ def parse_check_options # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
116
206
  options = {
117
207
  # `nil` triggers `Configuration.discover` (`.rigor.yml` then
118
208
  # `.rigor.dist.yml`); an explicit `--config=PATH` overrides.
@@ -133,9 +223,19 @@ module Rigor
133
223
  # `RIGOR_RACTOR_WORKERS` then `.rigor.yml`
134
224
  # `parallel.workers:` then 0 (sequential). See
135
225
  # `resolve_workers` for the precedence chain.
136
- workers: nil
226
+ workers: nil,
227
+ # Editor mode (`docs/design/20260516-editor-mode.md`).
228
+ # Both must appear together; the runner uses the pair
229
+ # to bind an in-flight buffer file to its logical path.
230
+ tmp_file: nil,
231
+ instead_of: nil,
232
+ # ADR-22 — baseline filter. `:unset` means "fall through
233
+ # to `.rigor.yml`'s `baseline:` key"; a String overrides
234
+ # the config; `false` (from `--no-baseline`) suppresses
235
+ # any baseline that the config might name.
236
+ baseline: :unset
137
237
  }
138
- parser = OptionParser.new do |opts|
238
+ parser = OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
139
239
  opts.banner = "Usage: rigor check [options] [paths]"
140
240
  opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
141
241
  opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
@@ -151,6 +251,22 @@ module Rigor
151
251
  "Dispatch per-file analysis across N Ractor workers (default: 0; sequential)") do |value|
152
252
  options[:workers] = value
153
253
  end
254
+ opts.on("--tmp-file=PATH",
255
+ "Editor mode: read source bytes from PATH instead of --instead-of (paired)") do |value|
256
+ options[:tmp_file] = value
257
+ end
258
+ opts.on("--instead-of=PATH",
259
+ "Editor mode: the logical project path the buffer represents (paired with --tmp-file)") do |value|
260
+ options[:instead_of] = value
261
+ end
262
+ opts.on("--baseline=PATH",
263
+ "ADR-22: load baseline from PATH (overrides .rigor.yml `baseline:`)") do |value|
264
+ options[:baseline] = value
265
+ end
266
+ opts.on("--no-baseline",
267
+ "ADR-22: ignore any configured baseline for this run") do
268
+ options[:baseline] = false
269
+ end
154
270
  end
155
271
  parser.parse!(@argv)
156
272
  options
@@ -336,6 +452,18 @@ module Rigor
336
452
  SigGenCommand.new(argv: @argv, out: @out, err: @err).run
337
453
  end
338
454
 
455
+ def run_lsp
456
+ require_relative "cli/lsp_command"
457
+
458
+ LspCommand.new(argv: @argv, out: @out, err: @err).run
459
+ end
460
+
461
+ def run_baseline
462
+ require_relative "cli/baseline_command"
463
+
464
+ BaselineCommand.new(argv: @argv, out: @out, err: @err).run
465
+ end
466
+
339
467
  def write_result(result, format)
340
468
  case format
341
469
  when "json"
@@ -376,6 +504,7 @@ module Rigor
376
504
  explain Print the description of one or all CheckRules
377
505
  diff Compare current diagnostics to a saved baseline JSON
378
506
  sig-gen Emit RBS skeletons inferred from .rb sources (ADR-14)
507
+ lsp Run the Rigor Language Server (LSP) over stdio
379
508
  version Print the Rigor version
380
509
  help Print this help
381
510
  HELP
@@ -49,6 +49,24 @@ 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" => [],
62
+ # ADR-22 — baseline file path. nil (default) means no
63
+ # baseline is loaded; the `false` literal is treated as
64
+ # the explicit-disable form for `.rigor.yml`-side override
65
+ # of an upstream `.rigor.dist.yml` `baseline:` declaration.
66
+ # The presence of `.rigor-baseline.yml` on disk alone does
67
+ # NOT activate filtering — the path must be named here
68
+ # (WD2 (b) of ADR-22).
69
+ "baseline" => nil,
52
70
  "fold_platform_specific_paths" => false,
53
71
  "cache" => {
54
72
  "path" => ".rigor/cache"
@@ -145,7 +163,7 @@ module Rigor
145
163
  # MUST be resolved relative to the config file's directory.
146
164
  # `exclude:` is intentionally NOT in this list — its entries
147
165
  # are glob patterns (`**/vendor/**`), not paths.
148
- PATH_KEYS = %w[paths signature_paths].freeze
166
+ PATH_KEYS = %w[paths signature_paths pre_eval].freeze
149
167
  private_constant :PATH_KEYS
150
168
 
151
169
  attr_reader :target_ruby, :paths, :exclude_patterns, :plugins, :cache_path, :disabled_rules,
@@ -155,7 +173,8 @@ module Rigor
155
173
  :severity_profile, :severity_overrides,
156
174
  :dependencies, :parallel_workers,
157
175
  :bundler_bundle_path, :bundler_auto_detect, :bundler_lockfile,
158
- :rbs_collection_lockfile, :rbs_collection_auto_detect
176
+ :rbs_collection_lockfile, :rbs_collection_auto_detect,
177
+ :pre_eval, :baseline_path
159
178
 
160
179
  # Loads a configuration file.
161
180
  #
@@ -307,6 +326,10 @@ module Rigor
307
326
  @libraries = Array(data.fetch("libraries", DEFAULTS.fetch("libraries"))).map(&:to_s).freeze
308
327
  sig_paths = data.fetch("signature_paths", DEFAULTS.fetch("signature_paths"))
309
328
  @signature_paths = sig_paths.nil? ? nil : Array(sig_paths).map(&:to_s).freeze
329
+ @pre_eval = expand_pre_eval_entries(
330
+ Array(data.fetch("pre_eval", DEFAULTS.fetch("pre_eval"))).map(&:to_s)
331
+ )
332
+ @baseline_path = coerce_baseline_path(data.fetch("baseline", DEFAULTS.fetch("baseline")))
310
333
  @fold_platform_specific_paths = data.fetch(
311
334
  "fold_platform_specific_paths", DEFAULTS.fetch("fold_platform_specific_paths")
312
335
  ) == true
@@ -357,6 +380,7 @@ module Rigor
357
380
  "disable" => disabled_rules,
358
381
  "libraries" => libraries,
359
382
  "signature_paths" => signature_paths,
383
+ "pre_eval" => pre_eval,
360
384
  "fold_platform_specific_paths" => fold_platform_specific_paths,
361
385
  "cache" => {
362
386
  "path" => cache_path
@@ -386,6 +410,27 @@ module Rigor
386
410
 
387
411
  private
388
412
 
413
+ # ADR-17 slice 4 — `pre_eval:` glob expansion. Each entry is
414
+ # accepted as either a literal path (slice 1 contract) OR a
415
+ # `File.fnmatch?`-shaped glob pattern (`lib/core_ext/**/*.rb`).
416
+ # Glob meta characters (`*`, `?`, `[`) trigger `Dir.glob`
417
+ # expansion; the resulting file list is folded into the
418
+ # `pre_eval:` set with `uniq`. Literal entries that don't
419
+ # exist on disk continue to surface as `pre-eval.file-not-found`
420
+ # `:error` (slice 1 behaviour); glob entries that match
421
+ # nothing degrade silently to "no contribution from this
422
+ # entry" so a templated `**` pattern in a fresh project
423
+ # doesn't generate an error per match-less pattern.
424
+ def expand_pre_eval_entries(entries)
425
+ entries.flat_map do |entry|
426
+ glob_pattern?(entry) ? Dir.glob(entry, sort: true) : [entry]
427
+ end.uniq.freeze
428
+ end
429
+
430
+ def glob_pattern?(path)
431
+ path.include?("*") || path.include?("?") || path.include?("[")
432
+ end
433
+
389
434
  # Accepts either `"rigor-foo"` (gem-name shorthand) or
390
435
  # `{ "gem" => "rigor-foo", "id" => "foo", "config" => {...} }`
391
436
  # (full form). Returns the canonical hash form so the loader
@@ -452,6 +497,17 @@ module Rigor
452
497
  raise ArgumentError, "parallel.workers must be a non-negative Integer, got #{value.inspect} (#{e.message})"
453
498
  end
454
499
 
500
+ # ADR-22 WD2 (b) — `baseline: <path>` activates the file;
501
+ # `baseline: false` is the explicit-disable form (useful in
502
+ # `.rigor.yml` to override an upstream `.rigor.dist.yml`
503
+ # that names a baseline). `nil` (default / absent key) is
504
+ # also "no baseline".
505
+ def coerce_baseline_path(value)
506
+ return nil if value.nil? || value == false
507
+
508
+ value.to_s
509
+ end
510
+
455
511
  def coerce_network_policy(value)
456
512
  sym = value.to_sym
457
513
  unless VALID_NETWORK_POLICIES.include?(sym)
@@ -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", "bundler", "cgi", "did_you_mean", "idn-ruby", "mysql2", "nokogiri", "pg", "prism", "redis", "rubygems"
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 = {}
@@ -147,6 +160,28 @@ module Rigor
147
160
  end
148
161
  end
149
162
 
163
+ # Returns true when the named RBS declaration is a Module
164
+ # (`RBS::AST::Declarations::Module`) rather than a Class. The
165
+ # `user_class_fallback_receiver` tier consults this to route
166
+ # `Nominal[M].some_kernel_method` (where M is a module mixin
167
+ # like `PP::ObjectMixin`) through the `Nominal[Object]`
168
+ # fallback, because every concrete includer of M sees Kernel
169
+ # / Object instance methods as part of its own ancestor chain.
170
+ #
171
+ # Returns false for classes, for unknown names, and when the
172
+ # RBS environment failed to build (fail-soft).
173
+ def rbs_module?(name)
174
+ return false if env.nil?
175
+
176
+ rbs_name = parse_type_name(name)
177
+ return false if rbs_name.nil?
178
+
179
+ entry = env.class_decls[rbs_name]
180
+ entry.is_a?(::RBS::Environment::ModuleEntry)
181
+ rescue ::RBS::BaseError
182
+ false
183
+ end
184
+
150
185
  # Yields every known class / module / alias name (top-level
151
186
  # prefixed) currently loaded into the environment. The cache
152
187
  # producer that materialises the known-name set uses this so
@@ -165,6 +200,36 @@ module Rigor
165
200
  # v0.0.9 cache `Cache::Descriptor` regression did.
166
201
  end
167
202
 
203
+ # ADR-20 slice 2e — iterates over every `%a{...}`
204
+ # annotation attached to a class- or module-level
205
+ # declaration in the loaded RBS environment, yielding
206
+ # `(annotation_string, source_location)` pairs. Used by
207
+ # {Rigor::Inference::HktRegistry.scan_rbs_loader} to
208
+ # find `rigor:v1:hkt_register` / `rigor:v1:hkt_define`
209
+ # directives in user-authored overlays and merge them
210
+ # into the per-`Environment` HKT registry. Yields nothing
211
+ # when the env failed to build (fail-soft, same shape as
212
+ # {#each_known_class_name}).
213
+ def each_class_decl_annotation
214
+ return enum_for(:each_class_decl_annotation) unless block_given?
215
+ return if env.nil?
216
+
217
+ env.class_decls.each_value do |entry|
218
+ entry.each_decl do |decl|
219
+ next unless decl.respond_to?(:annotations)
220
+
221
+ decl.annotations.each { |a| yield a.string, a.location }
222
+ end
223
+ end
224
+ rescue ::RBS::BaseError, ::Ractor::IsolationError
225
+ # fail-soft: matches each_known_class_name's policy.
226
+ # Ractor::IsolationError surfaces when the scan is
227
+ # invoked from a non-main Ractor pool worker before
228
+ # ADR-15's full deep-freeze migration completes — the
229
+ # worker falls back to the base (builtins-only)
230
+ # registry rather than crashing.
231
+ end
232
+
168
233
  # Returns a frozen `Hash<String, String>` mapping each loaded
169
234
  # class / module name (top-level prefixed) to the file path of
170
235
  # 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