rigortype 0.1.16 → 0.1.18

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 (180) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +18 -1
  4. data/lib/rigor/analysis/check_rules/rule_walk.rb +67 -0
  5. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +100 -0
  6. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +226 -0
  7. data/lib/rigor/analysis/check_rules.rb +180 -73
  8. data/lib/rigor/analysis/dependency_recorder.rb +122 -0
  9. data/lib/rigor/analysis/diagnostic.rb +18 -0
  10. data/lib/rigor/analysis/incremental.rb +162 -0
  11. data/lib/rigor/analysis/incremental_session.rb +337 -0
  12. data/lib/rigor/analysis/rule_catalog.rb +48 -0
  13. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +580 -0
  14. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  15. data/lib/rigor/analysis/runner/project_pre_passes.rb +318 -0
  16. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  17. data/lib/rigor/analysis/runner.rb +477 -1110
  18. data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
  19. data/lib/rigor/analysis/worker_session.rb +47 -8
  20. data/lib/rigor/builtins/static_return_refinements.rb +7 -1
  21. data/lib/rigor/cache/descriptor.rb +50 -49
  22. data/lib/rigor/cache/incremental_snapshot.rb +153 -0
  23. data/lib/rigor/cache/rbs_cache_producer.rb +34 -0
  24. data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
  25. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
  26. data/lib/rigor/cache/rbs_constant_table.rb +2 -8
  27. data/lib/rigor/cache/rbs_environment.rb +2 -8
  28. data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
  29. data/lib/rigor/cache/store.rb +145 -14
  30. data/lib/rigor/cli/annotate_command.rb +2 -7
  31. data/lib/rigor/cli/baseline_command.rb +2 -7
  32. data/lib/rigor/cli/check_command.rb +705 -0
  33. data/lib/rigor/cli/ci_detector.rb +94 -0
  34. data/lib/rigor/cli/command.rb +47 -0
  35. data/lib/rigor/cli/coverage_command.rb +3 -23
  36. data/lib/rigor/cli/coverage_renderer.rb +3 -8
  37. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  38. data/lib/rigor/cli/diff_command.rb +3 -7
  39. data/lib/rigor/cli/explain_command.rb +2 -7
  40. data/lib/rigor/cli/lsp_command.rb +3 -7
  41. data/lib/rigor/cli/mcp_command.rb +3 -7
  42. data/lib/rigor/cli/options.rb +57 -0
  43. data/lib/rigor/cli/plugin_command.rb +3 -7
  44. data/lib/rigor/cli/plugins_command.rb +2 -7
  45. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  46. data/lib/rigor/cli/renderable.rb +26 -0
  47. data/lib/rigor/cli/sig_gen_command.rb +2 -7
  48. data/lib/rigor/cli/skill_command.rb +3 -7
  49. data/lib/rigor/cli/trace_command.rb +143 -0
  50. data/lib/rigor/cli/trace_renderer.rb +310 -0
  51. data/lib/rigor/cli/triage_command.rb +2 -7
  52. data/lib/rigor/cli/type_of_command.rb +5 -38
  53. data/lib/rigor/cli/type_of_renderer.rb +4 -9
  54. data/lib/rigor/cli/type_scan_command.rb +3 -23
  55. data/lib/rigor/cli/type_scan_renderer.rb +4 -9
  56. data/lib/rigor/cli.rb +15 -532
  57. data/lib/rigor/configuration/dependencies.rb +18 -1
  58. data/lib/rigor/configuration/severity_profile.rb +22 -3
  59. data/lib/rigor/configuration.rb +16 -3
  60. data/lib/rigor/environment/rbs_loader.rb +129 -71
  61. data/lib/rigor/environment.rb +1 -1
  62. data/lib/rigor/inference/acceptance.rb +10 -0
  63. data/lib/rigor/inference/block_parameter_binder.rb +1 -2
  64. data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
  65. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
  66. data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
  67. data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
  68. data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
  69. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
  70. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
  71. data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
  72. data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
  73. data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
  74. data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
  75. data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
  76. data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
  77. data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
  78. data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
  79. data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
  80. data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
  81. data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
  82. data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
  83. data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
  84. data/lib/rigor/inference/expression_typer.rb +149 -63
  85. data/lib/rigor/inference/flow_tracer.rb +180 -0
  86. data/lib/rigor/inference/macro_block_self_type.rb +10 -11
  87. data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
  88. data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
  89. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
  90. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
  91. data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
  92. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
  93. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
  94. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
  95. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
  96. data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
  97. data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
  98. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  99. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +23 -13
  100. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
  101. data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
  102. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
  103. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
  104. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
  105. data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
  106. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
  107. data/lib/rigor/inference/method_dispatcher.rb +185 -84
  108. data/lib/rigor/inference/narrowing.rb +262 -5
  109. data/lib/rigor/inference/scope_indexer.rb +208 -21
  110. data/lib/rigor/inference/statement_evaluator.rb +110 -48
  111. data/lib/rigor/language_server/buffer_resolution.rb +33 -0
  112. data/lib/rigor/language_server/completion_provider.rb +4 -4
  113. data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
  114. data/lib/rigor/language_server/folding_range_provider.rb +4 -4
  115. data/lib/rigor/language_server/hover_provider.rb +4 -4
  116. data/lib/rigor/language_server/selection_range_provider.rb +4 -4
  117. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  118. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  119. data/lib/rigor/plugin/base.rb +302 -45
  120. data/lib/rigor/plugin/node_rule_walk.rb +147 -0
  121. data/lib/rigor/plugin/registry.rb +281 -15
  122. data/lib/rigor/plugin.rb +1 -0
  123. data/lib/rigor/rbs_extended/conformance_checker.rb +293 -0
  124. data/lib/rigor/rbs_extended.rb +39 -0
  125. data/lib/rigor/scope/discovery_index.rb +58 -0
  126. data/lib/rigor/scope.rb +150 -167
  127. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  128. data/lib/rigor/source/literals.rb +14 -0
  129. data/lib/rigor/type/acceptance_router.rb +19 -0
  130. data/lib/rigor/type/accepts_result.rb +3 -10
  131. data/lib/rigor/type/app.rb +3 -7
  132. data/lib/rigor/type/bot.rb +2 -3
  133. data/lib/rigor/type/bound_method.rb +5 -12
  134. data/lib/rigor/type/combinator.rb +22 -0
  135. data/lib/rigor/type/constant.rb +2 -3
  136. data/lib/rigor/type/data_class.rb +80 -0
  137. data/lib/rigor/type/data_instance.rb +100 -0
  138. data/lib/rigor/type/difference.rb +5 -10
  139. data/lib/rigor/type/dynamic.rb +5 -10
  140. data/lib/rigor/type/hash_shape.rb +5 -15
  141. data/lib/rigor/type/integer_range.rb +5 -10
  142. data/lib/rigor/type/intersection.rb +5 -10
  143. data/lib/rigor/type/nominal.rb +5 -10
  144. data/lib/rigor/type/refined.rb +5 -10
  145. data/lib/rigor/type/singleton.rb +5 -10
  146. data/lib/rigor/type/top.rb +2 -3
  147. data/lib/rigor/type/tuple.rb +5 -10
  148. data/lib/rigor/type/union.rb +5 -10
  149. data/lib/rigor/type.rb +2 -0
  150. data/lib/rigor/value_semantics.rb +77 -0
  151. data/lib/rigor/version.rb +1 -1
  152. data/lib/rigor.rb +1 -1
  153. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  154. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  155. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +70 -32
  156. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  157. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +15 -21
  158. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  159. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  160. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  161. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +12 -2
  162. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  163. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  164. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +35 -18
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  166. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  167. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  168. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +83 -36
  169. data/sig/rigor/cache.rbs +19 -0
  170. data/sig/rigor/environment.rbs +0 -2
  171. data/sig/rigor/inference.rbs +27 -0
  172. data/sig/rigor/plugin/base.rbs +1 -2
  173. data/sig/rigor/rbs_extended.rbs +2 -0
  174. data/sig/rigor/scope.rbs +42 -25
  175. data/sig/rigor/source.rbs +1 -0
  176. data/sig/rigor/type.rbs +58 -1
  177. data/sig/rigor.rbs +6 -1
  178. data/skills/rigor-ci-setup/SKILL.md +319 -0
  179. metadata +36 -2
  180. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -79
@@ -1,14 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require "prism"
4
5
  require "tmpdir"
5
6
 
6
7
  require_relative "../environment"
7
8
  require_relative "../scope"
8
9
  require_relative "../cache/store"
10
+ require_relative "../cache/rbs_descriptor"
9
11
  require_relative "../plugin"
10
12
  require_relative "../plugin/source_rbs_synthesis_reporter"
11
13
  require_relative "../rbs_extended/reporter"
14
+ require_relative "../rbs_extended/conformance_checker"
12
15
  require_relative "../reflection"
13
16
  require_relative "../type/combinator"
14
17
  require_relative "../inference/coverage_scanner"
@@ -18,6 +21,10 @@ require_relative "../inference/project_patched_scanner"
18
21
  require_relative "../inference/method_dispatcher/file_folding"
19
22
  require_relative "buffer_binding"
20
23
  require_relative "check_rules"
24
+ require_relative "dependency_recorder"
25
+ require_relative "self_call_resolution_recorder"
26
+ require_relative "incremental"
27
+ require_relative "incremental_session"
21
28
  require_relative "dependency_source_inference"
22
29
  require_relative "diagnostic"
23
30
  require_relative "erb_template_detector"
@@ -25,6 +32,10 @@ require_relative "project_scan"
25
32
  require_relative "result"
26
33
  require_relative "run_stats"
27
34
  require_relative "worker_session"
35
+ require_relative "runner/run_snapshots"
36
+ require_relative "runner/project_pre_passes"
37
+ require_relative "runner/pool_coordinator"
38
+ require_relative "runner/diagnostic_aggregator"
28
39
 
29
40
  module Rigor
30
41
  module Analysis
@@ -33,7 +44,8 @@ module Rigor
33
44
  DEFAULT_CACHE_ROOT = ".rigor/cache"
34
45
 
35
46
  attr_reader :cache_store, :plugin_registry, :dependency_source_index,
36
- :rbs_extended_reporter, :boundary_cross_reporter
47
+ :rbs_extended_reporter, :boundary_cross_reporter, :file_dependencies,
48
+ :analyzed_files, :unresolved_self_calls
37
49
 
38
50
  # @param configuration [Rigor::Configuration]
39
51
  # @param explain [Boolean] surface fail-soft fallback events
@@ -82,10 +94,11 @@ module Rigor
82
94
  # (bundler / lockfile / collection discovery, RbsLoader
83
95
  # construction). Pool mode ignores the override — each
84
96
  # worker continues to build its own Environment.
85
- def initialize(configuration:, explain: false, # rubocop:disable Metrics/ParameterLists
97
+ def initialize(configuration:, explain: false, # rubocop:disable Metrics/ParameterLists,Metrics/AbcSize,Metrics/MethodLength
86
98
  cache_store: Cache::Store.new(root: DEFAULT_CACHE_ROOT),
87
99
  plugin_requirer: nil, workers: 0, collect_stats: true,
88
- buffer: nil, prebuilt: nil, environment: nil)
100
+ buffer: nil, prebuilt: nil, environment: nil,
101
+ record_dependencies: false, record_self_calls: false, analyze_only: nil)
89
102
  @configuration = configuration
90
103
  @explain = explain
91
104
  @cache_store = enforce_read_only_cache(cache_store, buffer)
@@ -95,6 +108,35 @@ module Rigor
95
108
  @buffer = buffer
96
109
  @prebuilt = prebuilt
97
110
  @environment_override = environment
111
+ # ADR-46 slice 1 — opt-in cross-file dependency recording. Off by
112
+ # default; when true, `analyze_file` records each file's
113
+ # cross-file reads into `file_dependencies` (the incremental
114
+ # cache, a later slice, consumes them).
115
+ @record_dependencies = record_dependencies
116
+ # ADR-24 slice 4a — opt-in unresolved-implicit-self-call recording.
117
+ # Off by default; when true, `analyze_file` activates the engine
118
+ # choke-point recorder and collects each file's misses into
119
+ # `unresolved_self_calls` (a later closed-class-gated rule consumes
120
+ # them). Purely observational — diagnostics are byte-identical.
121
+ @record_self_calls = record_self_calls
122
+ @unresolved_self_calls = {}
123
+ # Memoised activation decision for the `call.self-undefined-method`
124
+ # rule (nil = not yet computed). See `self_undefined_rule_active?`.
125
+ @self_undefined_rule_active = nil
126
+ @analyzed_files = [].freeze
127
+ # In-memory source map for `#run_source` — `{ logical_path => source
128
+ # String }`. When set, `parse_source` reads bytes from here instead
129
+ # of disk and `expand_paths` accepts the (possibly non-existent)
130
+ # logical path. nil on a normal disk-backed run.
131
+ @in_memory_sources = nil
132
+ # ADR-46 slice 2 — the subset-analysis hook. When set (a collection
133
+ # of paths), the whole-project pre-pass still runs over every file
134
+ # (so the cross-file index is complete), but only files in this set
135
+ # are analyzed for diagnostics — the body tier re-analyses the
136
+ # affected closure and serves the rest from the per-file cache.
137
+ # `nil` (the default) analyzes everything.
138
+ @analyze_only = analyze_only && Set.new(analyze_only)
139
+ @file_dependencies = {}
98
140
  @plugin_registry = Plugin::Registry::EMPTY
99
141
  @dependency_source_index = DependencySourceInference::Index::EMPTY
100
142
  @rbs_extended_reporter = RbsExtended::Reporter.new
@@ -103,18 +145,24 @@ module Rigor
103
145
  # `#run` resets these for each invocation; pre-seed them to
104
146
  # empty containers so `build_run_stats` / `pre_file_diagnostics`
105
147
  # (private, called only from `#run`) can read them without
106
- # nil-guards.
107
- @class_decl_paths_snapshot = {}.freeze
108
- @signature_paths_snapshot = [].freeze
109
- @synthesized_namespaces_snapshot = [].freeze
148
+ # nil-guards. The four end-of-pass snapshots (RBS class /
149
+ # signature-path tables, synthesized-namespace names,
150
+ # `rigor:v1:conforms-to` results) live in one shared mutable
151
+ # {RunSnapshots} sink so the analysis path that writes them and
152
+ # the run / aggregator code that reads them stay in separate
153
+ # collaborators without a back-reference cycle.
154
+ @snapshots = RunSnapshots.new
110
155
  @cached_plugin_prepare_diagnostics = [].freeze
111
156
  @project_discovered_classes = {}.freeze
112
157
  @project_discovered_def_nodes = {}.freeze
113
158
  @project_discovered_def_sources = {}.freeze
114
159
  @project_discovered_superclasses = {}.freeze
115
160
  @project_discovered_includes = {}.freeze
161
+ @project_discovered_class_sources = {}.freeze
116
162
  @project_discovered_method_visibilities = {}.freeze
117
163
  @project_discovered_methods = {}.freeze
164
+ @project_data_member_layouts = {}.freeze
165
+ build_collaborators
118
166
  end
119
167
 
120
168
  # ADR-pending editor mode — present when the runner is wired
@@ -141,9 +189,7 @@ module Rigor
141
189
  return Result.new(diagnostics: [target_ruby_error]) if target_ruby_error
142
190
 
143
191
  expansion = expand_paths(paths)
144
- @class_decl_paths_snapshot = {}.freeze
145
- @signature_paths_snapshot = []
146
- @synthesized_namespaces_snapshot = []
192
+ @snapshots.reset_for_run
147
193
 
148
194
  if @prebuilt
149
195
  adopt_prebuilt_project_scan(@prebuilt)
@@ -151,19 +197,212 @@ module Rigor
151
197
  run_project_pre_passes(expansion: expansion)
152
198
  end
153
199
 
154
- diagnostics = pre_file_diagnostics(expansion)
155
- diagnostics += analyze_files(target_files(expansion))
156
- diagnostics += rbs_synthesized_namespace_diagnostics
157
- diagnostics += rbs_extended_reporter_diagnostics
158
- diagnostics += boundary_cross_diagnostics
159
- diagnostics += source_rbs_synthesis_diagnostics
200
+ diagnostics = compute_run_diagnostics(expansion)
160
201
 
161
202
  Result.new(
162
- diagnostics: apply_severity_profile(diagnostics),
163
- stats: @collect_stats ? build_run_stats(wall_started_at: wall_started_at, expansion: expansion) : nil
203
+ diagnostics: @diagnostic_aggregator.apply_severity_profile(diagnostics),
204
+ stats: stats_for_run(wall_started_at: wall_started_at, expansion: expansion)
164
205
  )
165
206
  end
166
207
 
208
+ # Analyze a single source String in memory, without writing it to
209
+ # disk — a clean entry point for embedders (LSP / editor mode) and a
210
+ # faster spec path than the per-call tmpdir + chdir. The source is
211
+ # bound to `path` (purely a logical identity carried in diagnostic
212
+ # locations; it need not exist on disk). The full run machinery still
213
+ # runs — environment build, plugin `prepare`, severity profile — so
214
+ # the result matches a one-file disk run; only the cross-file project
215
+ # pre-pass is empty (there is one file, and the per-file indexer
216
+ # self-discovers its own classes / defs).
217
+ #
218
+ # @param source [String] Ruby source to analyze.
219
+ # @param path [String] logical path for diagnostic locations.
220
+ # @return [Result]
221
+ def run_source(source:, path: "(source).rb")
222
+ @in_memory_sources = { path => source }
223
+ run([path])
224
+ ensure
225
+ @in_memory_sources = nil
226
+ end
227
+
228
+ # ADR-46 — the project file set that a run over `paths` would
229
+ # analyze, computed by globbing only (no RBS environment build), so
230
+ # the incremental fingerprint can be derived cheaply on the warm path
231
+ # before deciding whether to build the env at all.
232
+ def analysis_file_set(paths = @configuration.paths)
233
+ expand_paths(paths).fetch(:files)
234
+ end
235
+
236
+ # ADR-46 §2 — inverts {#file_dependencies} into the reverse edge the
237
+ # incremental step walks: `dependents[X] = { A : A read a
238
+ # declaration / body from X }`. On an edit to X, the body tier
239
+ # (slice 2) re-analyses `{X} ∪ dependents[X]` and serves every other
240
+ # file from the per-file cache. Built on demand from the recorded
241
+ # `sources` sets (so it reflects whatever `analyze_file` captured —
242
+ # empty unless the runner was constructed with
243
+ # `record_dependencies: true`). The negative (`missing`) edges are
244
+ # NOT inverted here: they feed the structural tier (slice 3), which
245
+ # re-checks a consumer when a name it looked up and did not resolve
246
+ # later appears.
247
+ def file_dependents
248
+ Incremental.invert(@file_dependencies.transform_values(&:sources))
249
+ end
250
+
251
+ # ADR-46 slice 4 — per-symbol body fingerprints, computed from the
252
+ # project pre-pass def index. Returns a frozen hash of the form:
253
+ # { "path/to/file.rb" => { "ClassName#method" => sha256_hex, … }, … }
254
+ # Used by {Analysis::IncrementalSession} to detect which symbols in a
255
+ # changed file actually changed bodies, so only callers of those
256
+ # specific symbols are re-checked. Only meaningful after a run that
257
+ # populated `@project_discovered_def_nodes` (i.e. any full or subset
258
+ # analysis); returns an empty frozen hash before the first run.
259
+ def symbol_fingerprints
260
+ result = Hash.new { |h, k| h[k] = {} }
261
+ @project_discovered_def_sources.each do |class_name, methods|
262
+ methods.each do |method_sym, path_line|
263
+ path = path_line.split(":", 2).first
264
+ node = @project_discovered_def_nodes.dig(class_name, method_sym)
265
+ next unless node
266
+
267
+ result[path]["#{class_name}##{method_sym}"] =
268
+ Digest::SHA256.hexdigest(node.location.slice)
269
+ end
270
+ end
271
+ result.transform_values(&:freeze).freeze
272
+ end
273
+
274
+ # ADR-46 slice 3 — per-file set of the qualified class/module names
275
+ # declared in that file. Used to detect a class that *appeared* in an
276
+ # edit so a subclass whose ancestor was previously undefined (and so
277
+ # recorded a negative class edge) is re-checked. Inverts the project
278
+ # class-source attribution (class → declaring files).
279
+ def class_declarations
280
+ result = Hash.new { |hash, key| hash[key] = Set.new }
281
+ @project_discovered_class_sources.each do |class_name, files|
282
+ files.each { |file| result[file] << class_name }
283
+ end
284
+ result.transform_values(&:freeze).freeze
285
+ end
286
+
287
+ # ADR-45 — unchanged-project fast path. Serves the whole run's
288
+ # (pre-severity-profile) diagnostics from one record-and-validate
289
+ # cache entry when every file the previous run read is unchanged,
290
+ # skipping the dominant per-file inference. The dependency set is
291
+ # collected AFTER the run (so it captures files the plugins read
292
+ # mid-analysis, e.g. a Pundit policy) and re-validated on the next
293
+ # run; the entry is keyed on the inputs known up front (config, gem
294
+ # / engine versions, analyzed-path set).
295
+ def compute_run_diagnostics(expansion)
296
+ @run_served_from_cache = false
297
+ return assemble_run_diagnostics(expansion) unless run_result_cacheable?
298
+
299
+ environment = @pool_coordinator.resolve_sequential_environment(source_files: target_files(expansion))
300
+ rbs_descriptor = environment&.rbs_loader ? Cache::RbsDescriptor.build(environment.rbs_loader) : Cache::Descriptor.new
301
+ key_descriptor = run_key_descriptor(expansion, rbs_descriptor)
302
+ return assemble_run_diagnostics(expansion, environment: environment) if key_descriptor.nil?
303
+
304
+ computed = false
305
+ diagnostics = @cache_store.fetch_or_validate(
306
+ producer_id: "analysis.run-diagnostics", key_descriptor: key_descriptor
307
+ ) do
308
+ computed = true
309
+ diags = assemble_run_diagnostics(expansion, environment: environment)
310
+ [diags, run_dependency_descriptor(expansion, rbs_descriptor)]
311
+ end
312
+ @run_served_from_cache = !computed
313
+ diagnostics
314
+ rescue StandardError
315
+ # The result cache must never break a run. If anything in the
316
+ # cache path fails, fall back to a direct, uncached analysis.
317
+ @run_served_from_cache = false
318
+ assemble_run_diagnostics(expansion)
319
+ end
320
+
321
+ def assemble_run_diagnostics(expansion, environment: nil)
322
+ diagnostics = @diagnostic_aggregator.pre_file_diagnostics(expansion)
323
+ # ADR-46 — record which project files this run actually analyzed
324
+ # (the `analyze_only` subset, or all of them). The incremental
325
+ # orchestrator serves every analyzed-but-not-affected file from the
326
+ # per-file cache, so it needs the full analyzed set to subtract the
327
+ # affected closure from.
328
+ targets = target_files(expansion)
329
+ @analyzed_files = targets
330
+ diagnostics += @pool_coordinator.analyze_files(targets, environment: environment)
331
+ diagnostics += @diagnostic_aggregator.rbs_synthesized_namespace_diagnostics
332
+ diagnostics += @diagnostic_aggregator.conforms_to_diagnostics
333
+ diagnostics += @diagnostic_aggregator.rbs_extended_reporter_diagnostics
334
+ diagnostics += @diagnostic_aggregator.boundary_cross_diagnostics
335
+ diagnostics + @diagnostic_aggregator.source_rbs_synthesis_diagnostics
336
+ end
337
+
338
+ # A cache hit skipped the analysis, so the per-run stats (wall
339
+ # split, RBS-class counts, …) were never gathered — report none
340
+ # rather than the stale snapshot defaults.
341
+ def stats_for_run(wall_started_at:, expansion:)
342
+ return nil unless @collect_stats
343
+ return nil if @run_served_from_cache
344
+
345
+ build_run_stats(wall_started_at: wall_started_at, expansion: expansion)
346
+ end
347
+
348
+ # Cacheable only for a full sequential project run with a writable
349
+ # cache and no per-buffer / prebuilt override — every other mode has
350
+ # a different result identity (pool workers read in separate
351
+ # processes; editor mode is per-buffer; prebuilt is the LSP path).
352
+ def run_result_cacheable?
353
+ !@cache_store.nil? && !@cache_store.read_only? &&
354
+ @buffer.nil? && @prebuilt.nil? && !pool_mode?
355
+ end
356
+
357
+ # Stable cache key inputs — known before the run: a digest of the
358
+ # resolved configuration, the engine + rbs versions + `--explain`,
359
+ # and the analyzed-path SET (adding/removing a file changes the
360
+ # key; editing one is caught by dependency validation). nil disables
361
+ # the cache for this run rather than risking a malformed key.
362
+ def run_key_descriptor(expansion, rbs_descriptor)
363
+ Cache::Descriptor.new(
364
+ gems: rbs_descriptor.gems,
365
+ configs: rbs_descriptor.configs + [
366
+ config_hash_entry("configuration", Marshal.dump(@configuration.to_h)),
367
+ config_hash_entry("engine", "#{Rigor::VERSION}:#{Cache::Descriptor::SCHEMA_VERSION}:#{@explain}"),
368
+ config_hash_entry("paths", expansion.fetch(:files).sort.join("\n"))
369
+ ]
370
+ )
371
+ rescue StandardError
372
+ nil
373
+ end
374
+
375
+ # Files the run actually depended on, collected AFTER it ran:
376
+ # every analyzed file, every RBS `sig` file (`rbs_descriptor.files`),
377
+ # and every file each plugin read (complete post-run, so reads made
378
+ # mid-analysis are included). Re-digested on the next run by
379
+ # {Descriptor#fresh?}.
380
+ def run_dependency_descriptor(expansion, rbs_descriptor)
381
+ entries = analyzed_file_entries(expansion) + rbs_descriptor.files
382
+ @plugin_registry.plugins.each do |plugin|
383
+ # Read the boundary WITHOUT triggering its lazy `@io_boundary ||=`
384
+ # initializer: plugin instances are frozen after the run, and a
385
+ # plugin that never built a boundary read no files through it, so
386
+ # it contributes no dependencies.
387
+ boundary = plugin.instance_variable_get(:@io_boundary)
388
+ entries.concat(boundary.cache_descriptor.files) if boundary
389
+ end
390
+ Cache::Descriptor.new(files: entries)
391
+ end
392
+
393
+ def analyzed_file_entries(expansion)
394
+ expansion.fetch(:files).map do |path|
395
+ physical = @buffer ? @buffer.resolve(path) : path
396
+ Cache::Descriptor::FileEntry.new(
397
+ path: physical, comparator: :digest, value: Digest::SHA256.file(physical).hexdigest
398
+ )
399
+ end
400
+ end
401
+
402
+ def config_hash_entry(key, payload)
403
+ Cache::Descriptor::ConfigEntry.new(key: key, value_hash: Digest::SHA256.hexdigest(payload))
404
+ end
405
+
167
406
  # Runs every project-wide pre-pass (`load_plugins` +
168
407
  # `plugin#prepare` + dependency-source builder +
169
408
  # synthetic-method scanner + project-patched scanner)
@@ -188,86 +427,18 @@ module Rigor
188
427
  # already populated for subsequent dispatch use.
189
428
  def prepare_project_scan(paths: @configuration.paths)
190
429
  expansion = expand_paths(paths)
191
- run_project_pre_passes(expansion: expansion)
192
- ProjectScan.new(
193
- plugin_registry: @plugin_registry,
194
- dependency_source_index: @dependency_source_index,
195
- synthetic_method_index: @synthetic_method_index,
196
- project_patched_methods: @project_patched_methods,
197
- plugin_prepare_diagnostics: @cached_plugin_prepare_diagnostics.dup.freeze,
198
- pre_eval_diagnostics: @pre_eval_diagnostics_from_scanner.dup.freeze
199
- )
430
+ result = @pre_passes.run(expansion: expansion)
431
+ apply_pre_passes_result(result)
432
+ @pre_passes.build_project_scan(result)
200
433
  end
201
434
 
202
- # Internal: drives every project-wide pre-pass and stores
203
- # the results on instance variables in the order the
204
- # downstream `#run` body expects. Extracted so
205
- # `#prepare_project_scan` and the prebuilt-less `#run` path
206
- # share one implementation.
435
+ # Internal: drives every project-wide pre-pass through the
436
+ # {ProjectPrePasses} collaborator and adopts the resulting
437
+ # state onto the runner's ivar surface in the order the
438
+ # downstream `#run` body expects. Shared by `#prepare_project_scan`
439
+ # and the prebuilt-less `#run` path.
207
440
  def run_project_pre_passes(expansion:)
208
- @plugin_registry = load_plugins
209
- @dependency_source_index = DependencySourceInference::Builder.build(@configuration.dependencies)
210
- # ADR-18 slice 3 — plugin prepare MUST run before the
211
- # synthetic-method scanner so cross-plugin facts
212
- # (`:dry_type_aliases` etc.) are already published when
213
- # the scanner resolves Tier C `returns_from_arg:`
214
- # lookups. The diagnostics produced by prepare are
215
- # captured here so `pre_file_diagnostics` can re-emit
216
- # them in the existing order without invoking prepare
217
- # twice. Pool mode still re-runs prepare per worker
218
- # (workers don't see this early invocation), preserving
219
- # the existing Phase 4b contract.
220
- @cached_plugin_prepare_diagnostics =
221
- pool_mode? ? [] : plugin_prepare_diagnostics
222
- # ADR-16 slice 2b — Tier C pre-pass. Built once per run
223
- # against the resolved file set + the loaded plugin
224
- # registry's `heredoc_templates` so synthetic methods are
225
- # visible cross-file when per-file inference dispatches.
226
- @synthetic_method_index = Inference::SyntheticMethodScanner.scan(
227
- plugin_registry: @plugin_registry,
228
- paths: expansion.fetch(:files),
229
- environment: nil,
230
- fact_store: shared_fact_store,
231
- buffer: @buffer
232
- )
233
- # ADR-17 slice 2 — pre-eval pre-pass. Built once per run
234
- # from the `pre_eval:` entries that exist on disk
235
- # (slice-1's `pre-eval.file-not-found` `:error` already
236
- # surfaced any missing entries; the scanner skips them
237
- # here). The resulting {ProjectPatchedMethods} registry
238
- # is consulted by the dispatcher tier between plugins
239
- # and dependency-source inference so project-side
240
- # patches resolve cross-file.
241
- existing_pre_eval = @configuration.pre_eval.select { |path| File.file?(path) }
242
- pre_eval_outcome = Inference::ProjectPatchedScanner.scan(existing_pre_eval, buffer: @buffer)
243
- @project_patched_methods = pre_eval_outcome.registry
244
- @pre_eval_diagnostics_from_scanner = pre_eval_outcome.diagnostics
245
- # Cross-file class discovery — walks every project file
246
- # for `class Foo` / `module Bar` declarations so a
247
- # `Foo.method_call` receiver in one file resolves a
248
- # `class Foo` declared in a sibling file. Without this
249
- # pre-pass each file's `discovered_classes` was per-file
250
- # only, and lexical lookup fell back to stdlib `::Foo`
251
- # for any user class shadowing a stdlib name (e.g.
252
- # `Google::Cloud::Storage::File`). Cost is one extra
253
- # parse pass over the project; small projects pay
254
- # tens of ms, larger projects ~1s. Future optimisation
255
- # can share parses with the existing scanner passes.
256
- @project_discovered_classes =
257
- Inference::ScopeIndexer.discovered_classes_for_paths(expansion.fetch(:files), buffer: @buffer)
258
- # ADR-24 slice 2 — cross-file def-node + class->superclass
259
- # index so an implicit-self call inside a subclass
260
- # resolves a superclass `def` declared in a sibling
261
- # file. One extra parse pass over the project; shares
262
- # the cost profile of the class-discovery pass above.
263
- def_index =
264
- Inference::ScopeIndexer.discovered_def_index_for_paths(expansion.fetch(:files), buffer: @buffer)
265
- @project_discovered_def_nodes = def_index.fetch(:def_nodes)
266
- @project_discovered_def_sources = def_index.fetch(:def_sources)
267
- @project_discovered_superclasses = def_index.fetch(:superclasses)
268
- @project_discovered_includes = def_index.fetch(:includes)
269
- @project_discovered_method_visibilities = def_index.fetch(:method_visibilities)
270
- @project_discovered_methods = def_index.fetch(:methods)
441
+ apply_pre_passes_result(@pre_passes.run(expansion: expansion))
271
442
  end
272
443
 
273
444
  # Internal: adopts a frozen {ProjectScan} snapshot supplied
@@ -275,156 +446,37 @@ module Rigor
275
446
  # the runner's ivar surface, mirroring what
276
447
  # `run_project_pre_passes` would have produced.
277
448
  def adopt_prebuilt_project_scan(scan)
278
- @plugin_registry = scan.plugin_registry
279
- @dependency_source_index = scan.dependency_source_index
280
- @synthetic_method_index = scan.synthetic_method_index
281
- @project_patched_methods = scan.project_patched_methods
282
- @cached_plugin_prepare_diagnostics = scan.plugin_prepare_diagnostics
283
- @pre_eval_diagnostics_from_scanner = scan.pre_eval_diagnostics
284
- end
285
- private :run_project_pre_passes, :adopt_prebuilt_project_scan
286
-
287
- # ADR-15 Phase 4b routes per-file analysis to either the
288
- # sequential coordinator-side Environment (legacy path,
289
- # default) or a Ractor worker pool built around
290
- # {WorkerSession} (opt-in via `workers:`). The sequential
291
- # path is bit-for-bit unchanged from v0.1.4 / earlier; the
292
- # pool path is the substrate exercised by phase 4c when
293
- # `RIGOR_RACTOR_WORKERS` / `.rigor.yml` `parallel.workers:`
294
- # is wired.
295
- #
296
- # Sequential mode also snapshots `class_decl_paths` from the
297
- # local environment after the per-file loop completes so
298
- # `RunStats` can attribute the RBS class universe between
299
- # project-sig and bundled sources. The env stays a LOCAL
300
- # variable (not an ivar) so it goes GC-eligible when the
301
- # method returns holding it as long-lived state added
302
- # memory pressure that surfaced as a Bus Error during the
303
- # spec suite under Ruby 4.0 + rbs 4.0.2.
304
- def analyze_files(files)
305
- return [] if files.empty?
306
-
307
- if pool_mode?
308
- dispatch_pool(files)
309
- else
310
- environment = resolve_sequential_environment(source_files: files)
311
- # Snapshot the small synthesized-namespace name list (NOT the
312
- # env — see the method comment) so #run can surface the
313
- # malformed-RBS `:info` diagnostic without rebuilding the env.
314
- # Gated on the project actually declaring `signature_paths:`:
315
- # synthesis only matters for the project's own RBS, and
316
- # `#synthesized_namespaces` forces the (otherwise-lazy) RBS env
317
- # to build — doing so when there is no project sig set would
318
- # warm `.rigor/cache` on a bare `--no-stats` run.
319
- @synthesized_namespaces_snapshot =
320
- project_signature_paths? ? (environment.rbs_loader&.synthesized_namespaces || []) : []
321
- result = files.flat_map { |path| analyze_file(path, environment) }
322
- if @collect_stats
323
- loader = environment.rbs_loader
324
- @class_decl_paths_snapshot = loader&.class_decl_paths || {}.freeze
325
- @signature_paths_snapshot = loader&.signature_paths || [].freeze
326
- end
327
- result
328
- end
329
- end
330
-
331
- # Sequential-mode environment resolver. Returns the supplied
332
- # `environment:` override (with the runner's fresh per-run
333
- # reporter pair attached so dispatcher events route to THIS
334
- # runner's diagnostics) when present; otherwise builds a
335
- # fresh Environment per-call via {#build_runner_environment}
336
- # — preserving the pre-override behaviour bit-for-bit.
337
- def resolve_sequential_environment(source_files: [])
338
- return build_runner_environment(source_files: source_files) unless @environment_override
339
-
340
- @environment_override.attach_reporters!(
341
- rbs_extended_reporter: @rbs_extended_reporter,
342
- boundary_cross_reporter: @boundary_cross_reporter
343
- )
344
- @environment_override
345
- end
346
- private :resolve_sequential_environment
347
-
348
- # Pre-file diagnostic streams that fire once per run rather
349
- # than per analyzed file: plugin load / prepare envelopes,
350
- # the ADR-10 dependency-source resolution surface, and the
351
- # `expand_paths` errors for `paths:` entries that don't
352
- # exist or aren't `.rb`. Aggregated here so `#run` stays
353
- # under the ABC budget.
354
- #
355
- # ADR-15 Phase 4b — `plugin_prepare_diagnostics` runs on
356
- # the coordinator's plugin registry under sequential mode;
357
- # under pool mode each worker re-runs `prepare` against
358
- # its own plugin instances, so the pool path drains the
359
- # first worker's prepare-diagnostic snapshot into the
360
- # aggregated diagnostic stream instead (see
361
- # {#analyze_files_in_pool}). Skipping the coordinator
362
- # prepare in pool mode avoids double-running `#prepare`
363
- # against the coordinator-side plugin instances (which
364
- # the pool path never consults for per-file analysis).
365
- def pre_file_diagnostics(expansion)
366
- # ADR-18 slice 3 — prepare diagnostics are captured
367
- # earlier in #run (before the synthetic-method scanner)
368
- # so cross-plugin facts are available to the scanner.
369
- # We re-surface the captured diagnostics here so the
370
- # existing pre_file_diagnostics ordering is preserved.
371
- prepare = pool_mode? ? [] : @cached_plugin_prepare_diagnostics
372
- plugin_load_diagnostics +
373
- prepare +
374
- pre_eval_diagnostics +
375
- dependency_source_diagnostics +
376
- dependency_source_budget_diagnostics +
377
- dependency_source_config_conflict_diagnostics +
378
- rbs_coverage_diagnostics +
379
- expansion.fetch(:errors)
380
- end
381
-
382
- # Returns the per-run shared `Plugin::FactStore` instance.
383
- # All loaded plugins share this store through their
384
- # respective `Plugin::Services` (the same instance is
385
- # threaded by `Plugin::Loader.load`). Returns `nil` when
386
- # no plugins are loaded.
387
- def shared_fact_store
388
- return nil if @plugin_registry.nil? || @plugin_registry.empty?
389
-
390
- @plugin_registry.plugins.first&.services&.fact_store
391
- end
392
-
393
- # ADR-17 slice 1 — surface a `:error` diagnostic for each
394
- # `pre_eval:` entry whose resolved path doesn't exist on
395
- # disk. Loud failure mode (`:error`, not `:warning`):
396
- # a missing pre_eval path is a configuration mistake the
397
- # user must fix before analysis is meaningful.
398
- #
399
- # Slice 2 adds the `:warning` `pre-eval.parse-error`
400
- # stream from the pre-pass scanner — accumulated as
401
- # `@pre_eval_diagnostics_from_scanner` during {#run} and
402
- # merged here so both diagnostics flow through the same
403
- # severity / ordering pipeline.
404
- def pre_eval_diagnostics
405
- not_found = @configuration.pre_eval.filter_map do |path|
406
- next if File.file?(path)
407
-
408
- Diagnostic.new(
409
- path: ".rigor.yml", line: 1, column: 1,
410
- message: "pre_eval entry not found: #{path.inspect}. " \
411
- "Pre-evaluation requires the file to exist on disk; remove the entry " \
412
- "or create the file before re-running analysis.",
413
- severity: :error,
414
- rule: "pre-eval.file-not-found",
415
- source_family: :builtin
416
- )
417
- end
418
- not_found + Array(@pre_eval_diagnostics_from_scanner).map { |hash| diagnostic_from_hash(hash) }
419
- end
420
-
421
- def diagnostic_from_hash(hash)
422
- Diagnostic.new(
423
- path: hash.fetch(:path), line: hash.fetch(:line), column: hash.fetch(:column),
424
- message: hash.fetch(:message), severity: hash.fetch(:severity),
425
- rule: hash.fetch(:rule), source_family: :builtin
426
- )
427
- end
449
+ apply_pre_passes_result(@pre_passes.adopt_prebuilt(scan))
450
+ end
451
+
452
+ # Internal: copies a {ProjectPrePasses::Result} bundle onto the
453
+ # runner's ivars in the assignment order the original inline
454
+ # pre-pass body used, so every downstream reader (per-file
455
+ # analysis seed, pool environment build, diagnostic aggregator)
456
+ # sees the same ivar surface. The prebuilt path leaves the
457
+ # discovery tables at their frozen-empty constructor defaults
458
+ # (the bundle carries `nil` for them, matching the original
459
+ # adopt path that never touched them).
460
+ def apply_pre_passes_result(result)
461
+ @plugin_registry = result.plugin_registry
462
+ @dependency_source_index = result.dependency_source_index
463
+ @cached_plugin_prepare_diagnostics = result.cached_plugin_prepare_diagnostics
464
+ @synthetic_method_index = result.synthetic_method_index
465
+ @project_patched_methods = result.project_patched_methods
466
+ @pre_eval_diagnostics_from_scanner = result.pre_eval_diagnostics_from_scanner
467
+ @project_discovered_classes = result.discovered_classes if result.discovered_classes
468
+ @project_discovered_def_nodes = result.discovered_def_nodes if result.discovered_def_nodes
469
+ @project_discovered_def_sources = result.discovered_def_sources if result.discovered_def_sources
470
+ @project_discovered_superclasses = result.discovered_superclasses if result.discovered_superclasses
471
+ @project_discovered_includes = result.discovered_includes if result.discovered_includes
472
+ @project_discovered_class_sources = result.discovered_class_sources if result.discovered_class_sources
473
+ if result.discovered_method_visibilities
474
+ @project_discovered_method_visibilities = result.discovered_method_visibilities
475
+ end
476
+ @project_discovered_methods = result.discovered_methods if result.discovered_methods
477
+ @project_data_member_layouts = result.data_member_layouts if result.data_member_layouts
478
+ end
479
+ private :run_project_pre_passes, :adopt_prebuilt_project_scan, :apply_pre_passes_result
428
480
 
429
481
  # `target_ruby` flows through to Prism's `version:` option.
430
482
  # Prism enforces the supported range and raises
@@ -463,6 +515,11 @@ module Rigor
463
515
  # buffer".
464
516
  def target_files(expansion)
465
517
  files = expansion.fetch(:files)
518
+ # ADR-46 slice 2 — restrict the analyzed set to the affected
519
+ # closure while the pre-pass (run separately over `expansion`'s
520
+ # full file list) keeps the cross-file index complete. Buffer mode
521
+ # takes precedence — its single logical path is the analyzed set.
522
+ files = files.select { |path| @analyze_only.include?(path) } if @analyze_only
466
523
  return files if @buffer.nil?
467
524
 
468
525
  [@buffer.logical_path]
@@ -482,318 +539,57 @@ module Rigor
482
539
  Cache::Store.new(root: cache_store.root, read_only: true)
483
540
  end
484
541
 
485
- # ADR-15 Phase 4b pool mode is enabled when `@workers > 0`.
486
- # Editor mode (`buffer:` non-nil) silently overrides pool
487
- # mode to sequential: per design § "Ractor pool mode", the
488
- # pool's warm-up cost dominates one-file wall time, so the
489
- # pool gains nothing on a per-buffer invocation. The override
490
- # is part of the contract not a degradation diagnostic —
491
- # because `--workers=N` is a project-scale knob and editor
492
- # mode is per-buffer; the conflict resolves toward the more
493
- # specific axis.
494
- def pool_mode?
495
- return false unless @workers.is_a?(Integer) && @workers.positive?
496
-
497
- @buffer.nil?
498
- end
499
-
500
- # ADR-15 Amendment (2026-05-20) worker-pool backend selector.
501
- # `fork` is the active backend: separate processes sidestep both
502
- # the Ruby Bug #22075 use-after-free and the worker-side
503
- # `Ractor::IsolationError` that make the Ractor pool unusable
504
- # (see the ADR-15 Amendment +
505
- # docs/notes/20260520-ractor-pool-cruby-uaf.md). The Ractor pool
506
- # is preserved but off the default path — `RIGOR_POOL_BACKEND=ractor`
507
- # opts back in so it stays testable. Platforms without `fork`
508
- # (Windows) fall back to sequential.
509
- def pool_backend
510
- return :ractor if ENV["RIGOR_POOL_BACKEND"] == "ractor"
511
- return :fork if Process.respond_to?(:fork)
512
-
513
- :sequential
514
- end
515
-
516
- # Routes pool-mode analysis to the selected backend.
517
- def dispatch_pool(files)
518
- case pool_backend
519
- when :ractor then analyze_files_in_pool(files)
520
- when :fork then analyze_files_in_fork_pool(files)
521
- else
522
- analyze_files_sequentially_fallback(
523
- files, reason: "fork-based parallelism is unavailable on this platform"
524
- )
525
- end
526
- end
527
-
528
- # Coordinator-side Environment used by the sequential code
529
- # path. Pool mode builds one Environment per worker inside
530
- # the worker Ractor's body instead.
531
- #
532
- # ADR-32 WD4 — `source_files:` is threaded down so that
533
- # `Environment.for_project` can invoke each loaded plugin's
534
- # `source_rbs_synthesizer` callable per project source file
535
- # at env-build time. Defaults to `[]` for callers that don't
536
- # have a file list yet (e.g. pre-pass-only build paths); in
537
- # that case no synthesised RBS is contributed.
538
- def build_runner_environment(source_files: [])
539
- Environment.for_project(
540
- libraries: @configuration.libraries,
541
- signature_paths: @configuration.signature_paths,
542
- cache_store: @cache_store,
543
- plugin_registry: @plugin_registry,
544
- dependency_source_index: @dependency_source_index,
542
+ # Wires the three responsibility collaborators. Called at the end
543
+ # of construction (after every state ivar is seeded). The per-run
544
+ # varying state (the plugin registry, dependency-source / scanner
545
+ # indexes, prepare-diagnostic snapshot, and the four end-of-pass
546
+ # snapshots) is reached through reader procs so each collaborator
547
+ # observes the live ivar value at call time without a
548
+ # back-reference cycle. The reporter accumulators and the
549
+ # {RunSnapshots} sink are shared mutable instances.
550
+ def build_collaborators # rubocop:disable Metrics/MethodLength
551
+ @pre_passes = ProjectPrePasses.new(
552
+ configuration: @configuration, cache_store: @cache_store, buffer: @buffer,
553
+ plugin_requirer: @plugin_requirer, pool_mode: -> { pool_mode? }
554
+ )
555
+ @pool_coordinator = PoolCoordinator.new(
556
+ configuration: @configuration, cache_store: @cache_store, explain: @explain,
557
+ workers: @workers, collect_stats: @collect_stats, buffer: @buffer,
558
+ environment_override: @environment_override,
545
559
  rbs_extended_reporter: @rbs_extended_reporter,
546
560
  boundary_cross_reporter: @boundary_cross_reporter,
547
561
  source_rbs_synthesis_reporter: @source_rbs_synthesis_reporter,
548
- bundler_bundle_path: @configuration.bundler_bundle_path,
549
- bundler_auto_detect: @configuration.bundler_auto_detect,
550
- bundler_lockfile: @configuration.bundler_lockfile,
551
- rbs_collection_lockfile: @configuration.rbs_collection_lockfile,
552
- rbs_collection_auto_detect: @configuration.rbs_collection_auto_detect,
553
- synthetic_method_index: @synthetic_method_index,
554
- project_patched_methods: @project_patched_methods,
555
- source_files: source_files
562
+ snapshots: @snapshots,
563
+ plugin_registry: -> { @plugin_registry },
564
+ dependency_source_index: -> { @dependency_source_index },
565
+ synthetic_method_index: -> { @synthetic_method_index },
566
+ project_patched_methods: -> { @project_patched_methods },
567
+ project_scope_seed: -> { project_scope_seed_tables },
568
+ analyze_file: ->(path, environment) { analyze_file(path, environment) }
556
569
  )
557
- end
558
-
559
- # ADR-15 Phase 4b — Ractor pool around {WorkerSession}.
560
- # Spawns `@workers` Ractors; each takes the shareable
561
- # payload (Configuration, cache_root String, plugin
562
- # Blueprint Array, explain Boolean) and builds its OWN
563
- # WorkerSession internally. Files are distributed
564
- # round-robin across the pool; each worker writes back to
565
- # the main Ractor's mailbox via `Ractor.main.send` with
566
- # one of three message kinds:
567
- #
568
- # - `[:prepare, diagnostics]` — once at startup, the
569
- # session's `prepare_diagnostics` snapshot. The
570
- # coordinator keeps the FIRST worker's snapshot only
571
- # (plugin `#prepare` is deterministic per plugin, so
572
- # each worker produces the same diagnostic set; surfacing
573
- # them once avoids N× duplication).
574
- # - `[:file, path, diagnostics]` — one per analysed file.
575
- # - `[:done, drained_reporters]` — once at exit, the
576
- # per-worker reporter snapshots for end-of-pool merge.
577
- #
578
- # The Ruby 4.0+ Ractor model uses a single per-Ractor
579
- # mailbox (no `Ractor.yield`); workers push back via
580
- # `Ractor.main.send`. The coordinator drains its mailbox
581
- # via `Ractor.receive` until it has counted exactly
582
- # `pool.size` `:done` messages.
583
- #
584
- # Diagnostic order: original path order. Workers may
585
- # complete files out of order; the coordinator re-orders
586
- # via the `results_by_path` Hash before flattening.
587
- #
588
- # Reporter merge: per-worker `RbsExtended::Reporter` and
589
- # `BoundaryCrossReporter` entries are replayed into the
590
- # runner-side accumulators via their `record_*` APIs,
591
- # which dedupe on the same keys as a single-session run
592
- # would. Net result: reporter state is identical to the
593
- # sequential path.
594
- def analyze_files_in_pool(files) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
595
- # Pre-warm class-level lazy memos on the MAIN Ractor.
596
- # `Environment::ClassRegistry.default` is the
597
- # default kwarg threaded through `Environment.new`
598
- # inside each worker session; lazy-initialising it
599
- # from a non-main Ractor would trip
600
- # `Ractor::IsolationError`. Touching it here forces
601
- # the (shareable) registry into the class-ivar cache
602
- # before any worker reads.
603
- Environment::ClassRegistry.default
604
-
605
- # ADR-15 Phase 4b.x — pre-warm the RBS cache so
606
- # workers serve every reflection query from the
607
- # Marshal blob on disk. Without this, the first
608
- # cache MISS inside a worker falls through to
609
- # `RBS::EnvironmentLoader.new`, which reads a chain
610
- # of non-`Ractor.shareable?` RubyGems / RBS module
611
- # constants and raises `Ractor::IsolationError`.
612
- # Pre-warming requires a `cache_store`; the run aborts
613
- # to sequential mode otherwise. See ADR-15 Phase 4b.x
614
- # for the full chain of failing constants.
615
- if @cache_store.nil?
616
- return analyze_files_sequentially_fallback(
617
- files, reason: "pool mode requires a cache_store (--no-cache disables pool)"
618
- )
619
- end
620
- prewarm_rbs_cache_for_pool
621
-
622
- configuration = @configuration
623
- cache_root = @cache_store&.root
624
- blueprints = @plugin_registry.blueprints
625
- explain = @explain
626
- # ADR-32 WD4 — the full project file list travels into
627
- # every Ractor worker so each worker's WorkerSession
628
- # can invoke loaded plugins' source_rbs_synthesizers at
629
- # env-build time. The list is a frozen Array<String>;
630
- # cheaply shareable.
631
- shareable_source_files = files.map { |path| path.to_s.dup.freeze }.freeze
632
-
633
- pool = Array.new(@workers) do
634
- Ractor.new(configuration, cache_root, blueprints, explain, shareable_source_files) do |configuration, cache_root, blueprints, explain, shareable_source_files| # rubocop:disable Layout/LineLength
635
- cache_store = cache_root ? Rigor::Cache::Store.new(root: cache_root) : nil
636
- session = Rigor::Analysis::WorkerSession.new(
637
- configuration: configuration,
638
- cache_store: cache_store,
639
- plugin_blueprints: blueprints,
640
- explain: explain,
641
- source_files: shareable_source_files
642
- )
643
- main = Ractor.main
644
- main.send([:prepare, session.prepare_diagnostics])
645
-
646
- loop do
647
- msg = Ractor.receive
648
- break if msg.nil?
649
-
650
- main.send([:file, msg, session.analyze(msg)])
651
- end
652
-
653
- main.send([:done, session.drain_reporters])
654
- end
655
- end
656
-
657
- files.each_with_index { |path, index| pool[index % pool.size].send(path) }
658
- pool.each { |worker| worker.send(nil) }
659
-
660
- prepare_diagnostics = nil
661
- results_by_path = {}
662
- done_count = 0
663
-
664
- while done_count < pool.size
665
- message = Ractor.receive
666
- case message.first
667
- when :prepare
668
- prepare_diagnostics ||= message.last
669
- when :file
670
- results_by_path[message[1]] = message[2]
671
- when :done
672
- merge_worker_reporters(message.last)
673
- done_count += 1
674
- end
675
- end
676
-
677
- pool.each(&:join)
678
-
679
- Array(prepare_diagnostics) + files.flat_map { |path| results_by_path.fetch(path, []) }
680
- end
681
-
682
- # ADR-15 Amendment (2026-05-20) — fork-based worker pool, the
683
- # active backend for `workers > 0`. Builds ONE {WorkerSession}
684
- # on the parent, then `fork`s N children that copy-on-write
685
- # inherit it. Each child analyses a contiguous slice of `files`
686
- # and writes a Marshal'd `{results:, reporters:}` payload to a
687
- # temp file; the parent `Process.wait`s every child, merges the
688
- # payloads, and re-orders diagnostics by original path order.
689
- #
690
- # Separate processes have separate GC heaps and `vm->ci_table`
691
- # (immune to Ruby Bug #22075) and copy-on-write-inherit every
692
- # constant (no `Ractor.shareable?` constraint). See the ADR-15
693
- # Amendment + docs/notes/20260520-ractor-pool-cruby-uaf.md.
694
- #
695
- # A child that exits non-zero (crash / unmarshalable payload) is
696
- # degraded: the parent re-analyses that slice in-process and
697
- # prepends a `pool-degraded` warning.
698
- def analyze_files_in_fork_pool(files) # rubocop:disable Metrics/AbcSize
699
- Environment::ClassRegistry.default
700
-
701
- session = WorkerSession.new(
570
+ @diagnostic_aggregator = DiagnosticAggregator.new(
702
571
  configuration: @configuration,
703
- cache_store: @cache_store,
704
- plugin_blueprints: @plugin_registry.blueprints,
705
- explain: @explain,
706
- synthetic_method_index: @synthetic_method_index,
707
- project_patched_methods: @project_patched_methods,
708
- source_files: files
572
+ rbs_extended_reporter: @rbs_extended_reporter,
573
+ boundary_cross_reporter: @boundary_cross_reporter,
574
+ source_rbs_synthesis_reporter: @source_rbs_synthesis_reporter,
575
+ plugin_registry: -> { @plugin_registry },
576
+ dependency_source_index: -> { @dependency_source_index },
577
+ pool_mode: -> { pool_mode? },
578
+ cached_plugin_prepare_diagnostics: -> { @cached_plugin_prepare_diagnostics },
579
+ pre_eval_diagnostics_from_scanner: -> { @pre_eval_diagnostics_from_scanner },
580
+ synthesized_namespaces_snapshot: -> { @snapshots.synthesized_namespaces },
581
+ conformance_results_snapshot: -> { @snapshots.conformance_results }
709
582
  )
710
- # Force the full RBS load on the parent so children
711
- # copy-on-write inherit a warm Environment rather than each
712
- # rebuilding it after the fork.
713
- session.environment.rbs_loader&.prewarm
714
- snapshot_fork_pool_stats(session) if @collect_stats
715
-
716
- worker_count = [@workers, files.size].min
717
- slices = files.each_slice((files.size.to_f / worker_count).ceil).to_a
718
- results_by_path = {}
719
-
720
- degraded = Dir.mktmpdir("rigor-fork-pool") do |tmpdir|
721
- children = slices.each_with_index.map do |slice, index|
722
- out_path = File.join(tmpdir, "worker-#{index}")
723
- { pid: fork { run_fork_worker(session, slice, out_path) },
724
- slice: slice, out_path: out_path }
725
- end
726
- collect_fork_results(children, results_by_path)
727
- end
728
-
729
- unless degraded.empty?
730
- degraded.each { |path| results_by_path[path] = session.analyze(path) }
731
- merge_worker_reporters(session.drain_reporters)
732
- end
733
-
734
- diagnostics = Array(session.prepare_diagnostics) +
735
- files.flat_map { |path| results_by_path.fetch(path, []) }
736
- degraded.empty? ? diagnostics : diagnostics.unshift(fork_degraded_diagnostic(degraded.size))
737
- end
738
-
739
- # Child-process body for {#analyze_files_in_fork_pool}. Analyses
740
- # the slice with the copy-on-write-inherited session and writes
741
- # the Marshal'd payload to `out_path`. `exit!` skips `at_exit` /
742
- # stdio flush — the payload is already durable on disk by then.
743
- def run_fork_worker(session, slice, out_path)
744
- results = slice.to_h { |path| [path, session.analyze(path)] }
745
- payload = { results: results, reporters: session.drain_reporters }
746
- File.binwrite(out_path, Marshal.dump(payload))
747
- exit!(0)
748
- rescue StandardError
749
- exit!(1)
750
- end
751
-
752
- # Snapshots `class_decl_paths` from the parent session's loader
753
- # so end-of-run {RunStats} can attribute the RBS class universe.
754
- def snapshot_fork_pool_stats(session)
755
- loader = session.environment.rbs_loader
756
- @class_decl_paths_snapshot = loader&.class_decl_paths || {}.freeze
757
- @signature_paths_snapshot = loader&.signature_paths || [].freeze
758
- end
759
-
760
- # Waits for every forked child, merges each successful payload
761
- # into `results_by_path`, and returns the file paths whose
762
- # worker exited abnormally (for in-process degrade).
763
- def collect_fork_results(children, results_by_path)
764
- degraded = []
765
- children.each do |child|
766
- _, status = Process.waitpid2(child[:pid])
767
- payload = fork_worker_payload(status, child[:out_path])
768
- if payload
769
- results_by_path.merge!(payload.fetch(:results))
770
- merge_worker_reporters(payload.fetch(:reporters))
771
- else
772
- degraded.concat(child[:slice])
773
- end
774
- end
775
- degraded
776
- end
777
-
778
- # @return [Hash, nil] the child's `{results:, reporters:}`
779
- # payload, or nil when the child exited abnormally or wrote no
780
- # readable payload. `Marshal.load` is safe here: the blob was
781
- # written by our own forked child to a temp file we created.
782
- def fork_worker_payload(status, out_path)
783
- return nil unless status.success? && File.exist?(out_path)
784
-
785
- Marshal.load(File.binread(out_path)) # rubocop:disable Security/MarshalLoad
786
- rescue StandardError
787
- nil
788
583
  end
789
584
 
790
- def fork_degraded_diagnostic(count)
791
- Diagnostic.new(
792
- path: ".rigor.yml", line: 1, column: 1,
793
- message: "fork pool degraded: #{count} file(s) re-analysed in-process " \
794
- "after a worker exited abnormally",
795
- severity: :warning, rule: "pool-degraded", source_family: :builtin
796
- )
585
+ # ADR-15 Phase 4b — pool mode is enabled when `@workers > 0`.
586
+ # Editor mode (`buffer:` non-nil) silently overrides pool
587
+ # mode to sequential. The real decision lives on
588
+ # {PoolCoordinator}; the predicate stays on the runner because
589
+ # `run_result_cacheable?` consults it (and a spec exercises it
590
+ # via `send`).
591
+ def pool_mode?
592
+ @pool_coordinator.pool_mode?
797
593
  end
798
594
 
799
595
  # End-of-run telemetry. Walks the cached
@@ -807,10 +603,10 @@ module Rigor
807
603
  # Wall + RSS are single syscalls; total cost is bounded
808
604
  # by the snapshot size (~1000-2000 entries).
809
605
  def build_run_stats(wall_started_at:, expansion:)
810
- snapshot = @class_decl_paths_snapshot
606
+ snapshot = @snapshots.class_decl_paths
811
607
  project_sig, bundled = RunStats.partition_classes(
812
608
  class_decl_paths: snapshot,
813
- signature_paths: @signature_paths_snapshot
609
+ signature_paths: @snapshots.signature_paths
814
610
  )
815
611
  RunStats.new(
816
612
  wall_seconds: Process.clock_gettime(Process::CLOCK_MONOTONIC) - wall_started_at,
@@ -825,546 +621,6 @@ module Rigor
825
621
  )
826
622
  end
827
623
 
828
- # ADR-15 Phase 4b.x — drives every cached RBS producer
829
- # on the main Ractor so each worker can serve all
830
- # reflection queries from disk (Marshal-load only).
831
- # Builds a single coordinator-side {Environment} for
832
- # this purpose; the env object is discarded immediately
833
- # after the cache is warm — workers build their own
834
- # `Environment.for_project` inside the Ractor body,
835
- # which then routes through `cached_env` instead of
836
- # `RBS::EnvironmentLoader.new`.
837
- def prewarm_rbs_cache_for_pool
838
- warm_env = Environment.for_project(
839
- libraries: @configuration.libraries,
840
- signature_paths: @configuration.signature_paths,
841
- cache_store: @cache_store,
842
- bundler_bundle_path: @configuration.bundler_bundle_path,
843
- bundler_auto_detect: @configuration.bundler_auto_detect,
844
- bundler_lockfile: @configuration.bundler_lockfile,
845
- rbs_collection_lockfile: @configuration.rbs_collection_lockfile,
846
- rbs_collection_auto_detect: @configuration.rbs_collection_auto_detect
847
- )
848
- warm_env.rbs_loader&.prewarm
849
- end
850
-
851
- # ADR-15 Phase 4b.x — pool-mode safety net. When pool
852
- # mode is configured but a precondition fails (currently:
853
- # `--no-cache` would force workers through
854
- # `EnvironmentLoader.new`), degrade to sequential
855
- # analysis with a `:warning` `pool-degraded` diagnostic
856
- # at run start. The actual per-file analysis runs on
857
- # the coordinator, identical to the default sequential
858
- # path.
859
- def analyze_files_sequentially_fallback(files, reason:)
860
- environment = build_runner_environment
861
- diagnostics = files.flat_map { |path| analyze_file(path, environment) }
862
- loader = environment.rbs_loader
863
- @class_decl_paths_snapshot = loader&.class_decl_paths || {}.freeze
864
- @signature_paths_snapshot = loader&.signature_paths || [].freeze
865
- diagnostics.unshift(
866
- Diagnostic.new(
867
- path: ".rigor.yml", line: 1, column: 1,
868
- message: "pool mode degraded to sequential: #{reason}",
869
- severity: :warning, rule: "pool-degraded", source_family: :builtin
870
- )
871
- )
872
- end
873
-
874
- def merge_worker_reporters(drained)
875
- rbs = drained.fetch(:rbs_extended)
876
- rbs.fetch(:unresolved_payloads).each do |entry|
877
- @rbs_extended_reporter.record_unresolved(
878
- payload: entry.payload, source_location: entry.source_location
879
- )
880
- end
881
- rbs.fetch(:lossy_projections).each do |entry|
882
- @rbs_extended_reporter.record_lossy_projection(
883
- head: entry.head, source_location: entry.source_location
884
- )
885
- end
886
- drained.fetch(:boundary_cross).each do |entry|
887
- @boundary_cross_reporter.record(
888
- class_name: entry.class_name,
889
- method_name: entry.method_name,
890
- gem_name: entry.gem_name,
891
- rbs_display: entry.rbs_display
892
- )
893
- end
894
- # ADR-32 WD6 — merge per-worker synthesizer failures
895
- # back into the coordinator's reporter. Fetched with a
896
- # default empty array so older drains (pre-slice-2)
897
- # remain compatible.
898
- Array(drained[:source_rbs_synthesis]).each do |entry|
899
- @source_rbs_synthesis_reporter.record(
900
- plugin_id: entry.plugin_id, path: entry.path, message: entry.message
901
- )
902
- end
903
- end
904
-
905
- # Loads project-configured plugins through {Rigor::Plugin::Loader}
906
- # and returns the resulting {Rigor::Plugin::Registry}. Loader
907
- # failures are isolated: each surfaces as a `:plugin_loader`
908
- # diagnostic on the run's `Result` rather than aborting the
909
- # analysis. Plugins that load successfully but contribute no
910
- # protocol hooks are inert in slice 1; later v0.1.0 slices
911
- # wire the contribution merger through this registry.
912
- def load_plugins
913
- return Plugin::Registry::EMPTY if @configuration.plugins.empty?
914
-
915
- services = Plugin::Services.new(
916
- reflection: Reflection,
917
- type: Type::Combinator,
918
- configuration: @configuration,
919
- cache_store: @cache_store,
920
- trust_policy: build_trust_policy
921
- )
922
- if @plugin_requirer
923
- Plugin::Loader.load(configuration: @configuration, services: services, requirer: @plugin_requirer)
924
- else
925
- Plugin::Loader.load(configuration: @configuration, services: services)
926
- end
927
- end
928
-
929
- # Builds the {Rigor::Plugin::TrustPolicy} for this run. Trusted
930
- # gems are the gem-name half of every entry in
931
- # `Configuration#plugins`. Allowed read roots default to the
932
- # project root (CWD), the project's signature_paths, and each
933
- # trusted gem's `Gem::Specification#full_gem_path`, plus any
934
- # extras the user listed under `plugins_io.allowed_paths`.
935
- # Slice 2 keeps `network_policy` `:disabled` — the only value
936
- # the configuration accepts today.
937
- def build_trust_policy
938
- trusted_gems = @configuration.plugins.map { |entry| trusted_gem_name(entry) }.uniq
939
- roots = [Dir.pwd]
940
- Array(@configuration.signature_paths).each { |sp| roots << File.expand_path(sp) }
941
- trusted_gems.each do |gem_name|
942
- path = trusted_gem_root(gem_name)
943
- roots << path if path
944
- end
945
- @configuration.plugins_io_allowed_paths.each { |p| roots << File.expand_path(p) }
946
-
947
- Plugin::TrustPolicy.new(
948
- trusted_gems: trusted_gems,
949
- allowed_read_roots: roots,
950
- network_policy: @configuration.plugins_io_network,
951
- allowed_url_hosts: @configuration.plugins_io_allowed_url_hosts
952
- )
953
- end
954
-
955
- def trusted_gem_name(entry)
956
- case entry
957
- when String then entry
958
- when Hash then entry["gem"] || entry["id"]
959
- end
960
- end
961
-
962
- def trusted_gem_root(gem_name)
963
- return nil if gem_name.nil? || gem_name.empty?
964
-
965
- spec = Gem.loaded_specs[gem_name]
966
- spec&.full_gem_path # rigor:disable undefined-method
967
- rescue StandardError
968
- nil
969
- end
970
-
971
- # ADR-8 § "Severity profile" — re-stamps each diagnostic's
972
- # severity from the configured profile + per-rule
973
- # overrides. Rules emit with their authored severity; the
974
- # profile is the final filter. Diagnostics whose resolved
975
- # severity is `:off` are dropped from the run result.
976
- def apply_severity_profile(diagnostics)
977
- diagnostics.filter_map { |diagnostic| stamp_severity(diagnostic) }
978
- end
979
-
980
- def stamp_severity(diagnostic)
981
- return diagnostic if diagnostic.rule.nil?
982
-
983
- resolved = Configuration::SeverityProfile.resolve(
984
- rule: diagnostic.rule,
985
- authored_severity: diagnostic.severity,
986
- profile: @configuration.severity_profile,
987
- overrides: @configuration.severity_overrides
988
- )
989
- return nil if resolved == :off
990
- return diagnostic if resolved == diagnostic.severity
991
-
992
- Diagnostic.new(
993
- path: diagnostic.path,
994
- line: diagnostic.line,
995
- column: diagnostic.column,
996
- message: diagnostic.message,
997
- severity: resolved,
998
- rule: diagnostic.rule,
999
- source_family: diagnostic.source_family
1000
- )
1001
- end
1002
-
1003
- def plugin_load_diagnostics
1004
- @plugin_registry.load_errors.map do |error|
1005
- Diagnostic.new(
1006
- path: ".rigor.yml",
1007
- line: 1,
1008
- column: 1,
1009
- message: error.message,
1010
- severity: :error,
1011
- rule: "load-error",
1012
- source_family: :plugin_loader
1013
- )
1014
- end
1015
- end
1016
-
1017
- # ADR-10 § "Diagnostic prefix family" — surfaces gems
1018
- # listed in `dependencies.source_inference` that RubyGems
1019
- # could not resolve. The run continues; the gem simply
1020
- # contributes nothing this session, mirroring the
1021
- # plugin-load error envelope. Authored `:warning` because
1022
- # an unresolvable gem usually means a typo or a missing
1023
- # `bundle install` rather than a project-blocking problem;
1024
- # the severity profile still re-stamps it.
1025
- def dependency_source_diagnostics
1026
- @dependency_source_index.unresolvable.map do |entry|
1027
- Diagnostic.new(
1028
- path: ".rigor.yml",
1029
- line: 1,
1030
- column: 1,
1031
- message: "dependencies.source_inference[].gem #{entry.gem_name.inspect} could not be " \
1032
- "resolved (#{entry.reason}); skipping",
1033
- severity: :warning,
1034
- rule: "dynamic.dependency-source.gem-not-found",
1035
- source_family: :builtin
1036
- )
1037
- end
1038
- end
1039
-
1040
- # ADR-10 § "Budget interaction" / slice 4 — emits one
1041
- # `:warning` per gem whose Walker run hit the
1042
- # `dependencies.budget_per_gem` cap. The cap is a Walker-
1043
- # side guard rail (slice 4 picks the (α) semantics from
1044
- # ADR-10 WD4: harvesting stops, the dispatcher behaves
1045
- # exactly as before for unrecorded methods). The
1046
- # diagnostic names the gem and points the user at the
1047
- # three remediations: ship RBS, reduce `mode:` from
1048
- # `full` to `when_missing`, or de-list the gem.
1049
- # ADR-10 § "config-conflict diagnostic" / 5d — surfaces
1050
- # `Configuration::Dependencies` warnings accumulated
1051
- # during `from_h` deduplication of the `includes:`-chain
1052
- # source_inference array. Each warning describes a
1053
- # per-gem mode conflict that the merge resolved
1054
- # right-wins; the user sees one diagnostic per conflict.
1055
- # `:warning` matches the user's "warn but don't block"
1056
- # preference per the design discussion.
1057
- def dependency_source_config_conflict_diagnostics
1058
- @configuration.dependencies.warnings.map do |message|
1059
- Diagnostic.new(
1060
- path: ".rigor.yml",
1061
- line: 1,
1062
- column: 1,
1063
- message: message,
1064
- severity: :warning,
1065
- rule: "dynamic.dependency-source.config-conflict",
1066
- source_family: :builtin
1067
- )
1068
- end
1069
- end
1070
-
1071
- def dependency_source_budget_diagnostics
1072
- budget = @configuration.dependencies.budget_per_gem
1073
- @dependency_source_index.budget_exceeded.map do |gem_name|
1074
- Diagnostic.new(
1075
- path: ".rigor.yml",
1076
- line: 1,
1077
- column: 1,
1078
- message: "dependencies.source_inference[].gem #{gem_name.inspect} exceeded the per-gem " \
1079
- "catalog cap (#{budget} method definitions); the remaining methods fall back " \
1080
- "to the existing RBS-or-Dynamic[top] boundary. Ship RBS for the gem, set " \
1081
- "`mode: when_missing` instead of `full`, or de-list the gem.",
1082
- severity: :warning,
1083
- rule: "dynamic.dependency-source.budget-exceeded",
1084
- source_family: :builtin
1085
- )
1086
- end
1087
- end
1088
-
1089
- # O4 Layer 3 slice 3 — graceful-degradation coverage
1090
- # report. When the project has a `Gemfile.lock` (slice 1)
1091
- # and one or more locked gems are not covered by ANY of
1092
- # the four RBS resolution paths (`DEFAULT_LIBRARIES`,
1093
- # `data/vendored_gem_sigs/`, slice-1 bundle-shipped
1094
- # `sig/`, slice-2 `rbs_collection.lock.yaml`), emit a
1095
- # single `:info` diagnostic summarising the uncovered set
1096
- # so the user can act on it (run `rbs collection install`,
1097
- # opt the gem into `dependencies.source_inference:`, or
1098
- # accept the `Dynamic[T]` fallback).
1099
- #
1100
- # Suppressed when the lockfile is empty, when every gem
1101
- # is covered, or when slice 1's `bundler.lockfile`
1102
- # discovery returned nothing (no lockfile to read).
1103
- def rbs_coverage_diagnostics
1104
- locked = Environment::LockfileResolver.locked_gems(
1105
- lockfile_path: @configuration.bundler_lockfile,
1106
- project_root: Dir.pwd,
1107
- auto_detect: @configuration.bundler_auto_detect
1108
- )
1109
- return [] if locked.empty?
1110
-
1111
- bundle_sig_paths = Environment::BundleSigDiscovery.discover(
1112
- bundle_path: @configuration.bundler_bundle_path,
1113
- project_root: Dir.pwd,
1114
- auto_detect: @configuration.bundler_auto_detect,
1115
- locked_gems: locked
1116
- )
1117
- collection_paths = Environment::RbsCollectionDiscovery.discover(
1118
- lockfile_path: @configuration.rbs_collection_lockfile,
1119
- project_root: Dir.pwd,
1120
- auto_detect: @configuration.rbs_collection_auto_detect
1121
- )
1122
- rows = Environment::RbsCoverageReport.classify(
1123
- locked_gems: locked,
1124
- default_libraries: Environment::DEFAULT_LIBRARIES,
1125
- bundle_sig_paths: bundle_sig_paths,
1126
- rbs_collection_paths: collection_paths
1127
- )
1128
- missing = Environment::RbsCoverageReport.missing(rows)
1129
- return [] if missing.empty?
1130
-
1131
- [build_rbs_coverage_missing_diagnostic(missing)]
1132
- end
1133
-
1134
- # Robustness uplift companion (ADR-5) — when the project's
1135
- # `signature_paths:` RBS declared qualified names without their
1136
- # enclosing namespace, `RbsLoader` synthesizes the missing
1137
- # `module`s so the otherwise-inert signatures resolve. Surface a
1138
- # single `:info` diagnostic naming them so the user knows their
1139
- # sig set is malformed (`rbs validate` rejects it) and can fix it
1140
- # at the source. Authored `:info`: the analysis already succeeded;
1141
- # this is advisory, never a gate. Empty for a well-formed sig set.
1142
- def rbs_synthesized_namespace_diagnostics
1143
- synthesized = @synthesized_namespaces_snapshot
1144
- return [] if synthesized.nil? || synthesized.empty?
1145
-
1146
- [build_rbs_synthesized_namespace_diagnostic(synthesized)]
1147
- end
1148
-
1149
- # True when the project declares its own `signature_paths:` (the
1150
- # only place the qualified-name-without-namespace mistake lives).
1151
- def project_signature_paths?
1152
- paths = @configuration.signature_paths
1153
- !(paths.nil? || paths.empty?)
1154
- end
1155
-
1156
- def build_rbs_synthesized_namespace_diagnostic(synthesized)
1157
- sample_size = 5
1158
- sample = synthesized.first(sample_size)
1159
- suffix = synthesized.size > sample_size ? ", and #{synthesized.size - sample_size} more" : ""
1160
- Diagnostic.new(
1161
- path: ".rigor.yml",
1162
- line: 1,
1163
- column: 1,
1164
- message: "#{synthesized.size} RBS namespace(s) under `signature_paths:` are " \
1165
- "referenced by qualified declarations (e.g. `class Foo::Bar`) but never " \
1166
- "declared: #{sample.join(', ')}#{suffix}. `rbs validate` rejects this; " \
1167
- "Rigor synthesized the missing `module`(s) so the signatures still " \
1168
- "resolve. Declare each (`module <name>` / `class <name>`) in your RBS to " \
1169
- "make the sig set valid upstream.",
1170
- severity: :info,
1171
- rule: "rbs.coverage.synthesized-namespace",
1172
- source_family: :builtin
1173
- )
1174
- end
1175
-
1176
- def build_rbs_coverage_missing_diagnostic(missing)
1177
- sample_size = 5
1178
- sample = missing.first(sample_size).map(&:gem_name)
1179
- suffix = missing.size > sample_size ? ", and #{missing.size - sample_size} more" : ""
1180
- Diagnostic.new(
1181
- path: ".rigor.yml",
1182
- line: 1,
1183
- column: 1,
1184
- message: "#{missing.size} gem(s) in Gemfile.lock have no RBS available: " \
1185
- "#{sample.join(', ')}#{suffix}. " \
1186
- "Consider `rbs collection install` to fetch community RBS from " \
1187
- "`ruby/gem_rbs_collection`, ship `sig/` in the gem itself, or " \
1188
- "opt the gem into `dependencies.source_inference:` in `.rigor.yml`.",
1189
- severity: :info,
1190
- rule: "rbs.coverage.missing-gem",
1191
- source_family: :builtin
1192
- )
1193
- end
1194
-
1195
- # ADR-13 slice 3b — drains the per-run
1196
- # {RbsExtended::Reporter} into one diagnostic per accumulated
1197
- # event:
1198
- #
1199
- # - `dynamic.rbs-extended.unresolved` for every annotation
1200
- # payload the parser could not turn into a {Rigor::Type}.
1201
- # Surfaces typos and references to plugin-supplied names
1202
- # the project did not enable.
1203
- # - `dynamic.shape.lossy-projection` for every shape-projection
1204
- # type function (`pick_of`, …) applied to a carrier that
1205
- # loses precision (anything other than `HashShape` / `Tuple`).
1206
- #
1207
- # Both are authored `:info`; the severity profile re-stamps
1208
- # them per project taste. Path / line / column come from the
1209
- # annotation's `RBS::Location` when available, falling back
1210
- # to `.rigor.yml`-style file-level attribution otherwise.
1211
- def rbs_extended_reporter_diagnostics
1212
- return [] if @rbs_extended_reporter.empty?
1213
-
1214
- unresolved = @rbs_extended_reporter.unresolved_payloads.map do |entry|
1215
- build_reporter_diagnostic(
1216
- entry.source_location,
1217
- rule: "dynamic.rbs-extended.unresolved",
1218
- message: "`RBS::Extended` directive payload could not be resolved: " \
1219
- "#{entry.payload.inspect}. Check for typos or enable a plugin " \
1220
- "that contributes the referenced type vocabulary."
1221
- )
1222
- end
1223
-
1224
- lossy = @rbs_extended_reporter.lossy_projections.map do |entry|
1225
- build_reporter_diagnostic(
1226
- entry.source_location,
1227
- rule: "dynamic.shape.lossy-projection",
1228
- message: "Shape projection `#{entry.head}` applied to a carrier without a " \
1229
- "literal shape; the projection degrades to the input type. Author " \
1230
- "a `HashShape` / `Tuple` carrier or accept the unchanged result."
1231
- )
1232
- end
1233
-
1234
- unresolved + lossy
1235
- end
1236
-
1237
- # ADR-10 slice 5c — drains the per-run
1238
- # {DependencySourceInference::BoundaryCrossReporter} into
1239
- # `dynamic.dependency-source.boundary-cross` `:info`
1240
- # diagnostics. Each event flags a call site where RBS
1241
- # dispatch produced a concrete answer AND a `mode: :full`
1242
- # opt-in gem's source catalog ALSO contains an entry for
1243
- # the same `(class_name, method_name)` — i.e., both
1244
- # contracts have an opinion. RBS still wins on the
1245
- # dispatch result; the diagnostic is purely advisory so
1246
- # the user can verify the two contracts haven't drifted.
1247
- #
1248
- # Severity profile re-stamps the rule per project taste.
1249
- # The diagnostic carries no `path` / `line` / `column`
1250
- # because the crossing is per-method-per-gem, not
1251
- # per-call-site — the diagnostic anchors at `.rigor.yml`
1252
- # like the other `dependency-source.*` diagnostics that
1253
- # report on opt-in configuration.
1254
- # ADR-32 WD6 — drains the per-run
1255
- # {Plugin::SourceRbsSynthesisReporter} into
1256
- # `source-rbs-synthesis-failed` `:info` diagnostics. Each
1257
- # entry names the plugin that owns the synthesizer, the
1258
- # source file the rbs-inline parser couldn't process, and
1259
- # the upstream error message. The synthesizer-emitting
1260
- # plugin (currently only `rigor-rbs-inline`) treats a
1261
- # parse failure as a no-contribution event so analysis
1262
- # continues; this stream surfaces the failure so the user
1263
- # can see which files contributed nothing and why.
1264
- #
1265
- # Severity profile re-stamps the rule per project taste.
1266
- def source_rbs_synthesis_diagnostics
1267
- return [] if @source_rbs_synthesis_reporter.empty?
1268
-
1269
- @source_rbs_synthesis_reporter.entries.map do |entry|
1270
- Diagnostic.new(
1271
- path: entry.path, line: 1, column: 1,
1272
- message: "plugin `#{entry.plugin_id}` failed to synthesise RBS from this file: " \
1273
- "#{entry.message}. The file's analysis falls back to no inline-RBS " \
1274
- "contribution. Fix the inline-RBS comment grammar or remove the " \
1275
- "annotation to silence this diagnostic.",
1276
- severity: :info,
1277
- rule: "source-rbs-synthesis-failed",
1278
- source_family: :builtin
1279
- )
1280
- end
1281
- end
1282
-
1283
- def boundary_cross_diagnostics
1284
- return [] if @boundary_cross_reporter.empty?
1285
-
1286
- @boundary_cross_reporter.entries.map do |entry|
1287
- Diagnostic.new(
1288
- path: ".rigor.yml", line: 1, column: 1,
1289
- message: "`#{entry.class_name}##{entry.method_name}` is contributed by both " \
1290
- "RBS (#{entry.rbs_display}) and the `mode: :full` opt-in gem " \
1291
- "`#{entry.gem_name}`. RBS wins on dispatch; verify the gem source " \
1292
- "has not drifted from its RBS contract.",
1293
- severity: :info,
1294
- rule: "dynamic.dependency-source.boundary-cross",
1295
- source_family: :builtin
1296
- )
1297
- end
1298
- end
1299
-
1300
- def build_reporter_diagnostic(source_location, rule:, message:)
1301
- path, line, column = location_fields(source_location)
1302
- Diagnostic.new(
1303
- path: path, line: line, column: column,
1304
- message: message, severity: :info, rule: rule, source_family: :builtin
1305
- )
1306
- end
1307
-
1308
- def location_fields(source_location)
1309
- return [".rigor.yml", 1, 1] if source_location.nil?
1310
-
1311
- path = location_path(source_location)
1312
- line = source_location.respond_to?(:start_line) ? source_location.start_line : 1
1313
- column = source_location.respond_to?(:start_column) ? source_location.start_column + 1 : 1
1314
- [path, line, column]
1315
- rescue StandardError
1316
- [".rigor.yml", 1, 1]
1317
- end
1318
-
1319
- def location_path(source_location)
1320
- buffer = source_location.respond_to?(:buffer) ? source_location.buffer : nil
1321
- return ".rigor.yml" if buffer.nil? || !buffer.respond_to?(:name)
1322
-
1323
- name = buffer.name.to_s
1324
- name.empty? ? ".rigor.yml" : name
1325
- end
1326
-
1327
- # ADR-9 slice 3 — invokes every loaded plugin's `#prepare`
1328
- # hook once per run, after the loader's `#init` pass and
1329
- # before per-file iteration. Plugins publish facts here
1330
- # for cross-plugin consumption via the shared
1331
- # `services.fact_store`. Failures isolate as
1332
- # `:plugin_loader runtime-error` diagnostics, mirroring the
1333
- # `#diagnostics_for_file` raise envelope in
1334
- # `plugin_runtime_error_diagnostic`.
1335
- #
1336
- # Slice 3 visits plugins in registration order. Slice 5
1337
- # introduces topological ordering by `manifest(consumes:)`
1338
- # so producers always run before consumers; until then,
1339
- # `Configuration#plugins` order MUST be producer-first if
1340
- # cross-plugin dependencies exist.
1341
- def plugin_prepare_diagnostics
1342
- return [] if @plugin_registry.empty?
1343
-
1344
- @plugin_registry.plugins.flat_map { |plugin| invoke_plugin_prepare(plugin) }
1345
- end
1346
-
1347
- def invoke_plugin_prepare(plugin)
1348
- plugin.prepare(plugin.services)
1349
- []
1350
- rescue StandardError => e
1351
- [plugin_prepare_error_diagnostic(plugin, e)]
1352
- end
1353
-
1354
- def plugin_prepare_error_diagnostic(plugin, error)
1355
- plugin_id = safe_plugin_id(plugin)
1356
- Diagnostic.new(
1357
- path: ".rigor.yml",
1358
- line: 1,
1359
- column: 1,
1360
- message: "plugin #{plugin_id.inspect} raised during prepare: " \
1361
- "#{error.class}: #{error.message}",
1362
- severity: :error,
1363
- rule: "runtime-error",
1364
- source_family: :plugin_loader
1365
- )
1366
- end
1367
-
1368
624
  # ADR-7 § "Slice 5-A/5-B" — invokes every loaded plugin's
1369
625
  # per-file diagnostic emission hook
1370
626
  # (`Plugin::Base#diagnostics_for_file`) and re-stamps the
@@ -1376,17 +632,42 @@ module Rigor
1376
632
  # I/O Policy" — a raise from one plugin becomes a
1377
633
  # `:plugin_loader` `runtime-error` diagnostic without
1378
634
  # affecting other plugins or the rest of the run.
635
+ # ADR-52 WD1 — only the plugins that overrode
636
+ # `#diagnostics_for_file` or declared a `node_rule` are visited
637
+ # (`contribution_index.for_file_diagnostics`); a skipped plugin's
638
+ # two hooks could only have returned `[]`.
1379
639
  def plugin_emitted_diagnostics(path, root, scope)
1380
640
  return [] if @plugin_registry.empty?
1381
641
 
1382
- @plugin_registry.plugins.flat_map do |plugin|
1383
- collect_plugin_diagnostics(plugin, path, root, scope)
642
+ # ADR-52 WD4 — one engine-owned AST walk per file dispatches each
643
+ # node to every matching (plugin, rule); the per-plugin results
644
+ # are bucketed in registry order so emission stays plugin-major
645
+ # (byte-identical with the old per-plugin walk).
646
+ node_results = node_rule_results_by_plugin(path, root, scope)
647
+
648
+ @plugin_registry.contribution_index.for_file_diagnostics.flat_map do |plugin|
649
+ collect_plugin_diagnostics(plugin, path, root, scope, node_results[plugin])
1384
650
  end
1385
651
  end
1386
652
 
1387
- def collect_plugin_diagnostics(plugin, path, root, scope)
653
+ def node_rule_results_by_plugin(path, root, scope)
654
+ walk = @plugin_registry.node_rule_walk
655
+ return {}.compare_by_identity if walk.empty?
656
+
657
+ results = walk.diagnostics_for_file(path: path, scope: scope, root: root)
658
+ results.each_with_object({}.compare_by_identity) do |result, by_plugin|
659
+ by_plugin[result.plugin] = result
660
+ end
661
+ end
662
+
663
+ def collect_plugin_diagnostics(plugin, path, root, scope, node_result)
1388
664
  raw = Array(plugin.diagnostics_for_file(path: path, scope: scope, root: root))
1389
- raw += plugin.node_rule_diagnostics(path: path, scope: scope, root: root)
665
+ # A node-rule context/rule raise isolates the whole plugin's
666
+ # node-rule contribution, matching the old combined per-plugin
667
+ # rescue (which discarded `diagnostics_for_file` output too).
668
+ raise node_result.error if node_result&.error
669
+
670
+ raw += node_result.diagnostics if node_result
1390
671
  raw.map { |diagnostic| stamp_plugin_diagnostic(diagnostic, plugin.manifest.id) }
1391
672
  rescue StandardError => e
1392
673
  [plugin_runtime_error_diagnostic(path, plugin, e)]
@@ -1455,7 +736,8 @@ module Rigor
1455
736
 
1456
737
  def accept_as_ruby_file?(path)
1457
738
  (File.file?(path) && path.end_with?(".rb")) ||
1458
- (@buffer && path == @buffer.logical_path)
739
+ (@buffer && path == @buffer.logical_path) ||
740
+ @in_memory_sources&.key?(path)
1459
741
  end
1460
742
 
1461
743
  # `Configuration#exclude_patterns` is a list of glob patterns
@@ -1494,6 +776,10 @@ module Rigor
1494
776
  # LOGICAL path. Non-binding paths go through the cheaper
1495
777
  # `Prism.parse_file` codepath unchanged.
1496
778
  def parse_source(path)
779
+ if @in_memory_sources&.key?(path)
780
+ return Prism.parse(@in_memory_sources[path], filepath: path, version: @configuration.target_ruby)
781
+ end
782
+
1497
783
  physical = @buffer ? @buffer.resolve(path) : path
1498
784
  return Prism.parse_file(physical, version: @configuration.target_ruby) if physical == path
1499
785
 
@@ -1507,25 +793,57 @@ module Rigor
1507
793
  # without the project pre-pass (e.g. a single-file probe)
1508
794
  # keeps an empty seed.
1509
795
  def seed_project_scope(scope)
1510
- scope = scope.with_discovered_classes(@project_discovered_classes) unless @project_discovered_classes.empty?
1511
- unless @project_discovered_def_nodes.empty?
1512
- scope = scope.with_discovered_def_nodes(@project_discovered_def_nodes)
1513
- end
1514
- unless @project_discovered_def_sources.empty?
1515
- scope = scope.with_discovered_def_sources(@project_discovered_def_sources)
1516
- end
796
+ tables = project_scope_seed_tables
797
+ return scope if tables.empty?
798
+
799
+ scope.with_discovery(scope.discovery.with(**tables))
800
+ end
801
+
802
+ # The cross-file pre-pass tables {#seed_project_scope} applies, as a
803
+ # plain Hash so the fork-pool path can hand the same seed to its
804
+ # {WorkerSession} (whose per-file scopes would otherwise miss every
805
+ # cross-file def — ADR-15 sequential-equivalence contract).
806
+ def project_scope_seed_tables
807
+ tables = {}
808
+ tables[:discovered_classes] = @project_discovered_classes unless @project_discovered_classes.empty?
809
+ tables[:discovered_def_nodes] = @project_discovered_def_nodes unless @project_discovered_def_nodes.empty?
810
+ tables[:discovered_def_sources] = @project_discovered_def_sources unless @project_discovered_def_sources.empty?
1517
811
  unless @project_discovered_superclasses.empty?
1518
- scope = scope.with_discovered_superclasses(@project_discovered_superclasses)
812
+ tables[:discovered_superclasses] = @project_discovered_superclasses
1519
813
  end
1520
- scope = scope.with_discovered_includes(@project_discovered_includes) unless @project_discovered_includes.empty?
814
+ tables[:discovered_includes] = @project_discovered_includes unless @project_discovered_includes.empty?
1521
815
  unless @project_discovered_method_visibilities.empty?
1522
- scope = scope.with_discovered_method_visibilities(@project_discovered_method_visibilities)
816
+ tables[:discovered_method_visibilities] = @project_discovered_method_visibilities
817
+ end
818
+ tables[:discovered_methods] = @project_discovered_methods unless @project_discovered_methods.empty?
819
+ tables[:data_member_layouts] = @project_data_member_layouts unless @project_data_member_layouts.empty?
820
+ # ADR-46 slice 1 — the class-declaration source map is read only by
821
+ # the ancestry accessors during dependency recording, so seed it
822
+ # only when recording is on; a normal run never carries it.
823
+ if @record_dependencies && !@project_discovered_class_sources.empty?
824
+ tables[:discovered_class_sources] = @project_discovered_class_sources
1523
825
  end
1524
- scope = scope.with_discovered_methods(@project_discovered_methods) unless @project_discovered_methods.empty?
1525
- scope
826
+ tables
1526
827
  end
1527
828
 
1528
- def analyze_file(path, environment) # rubocop:disable Metrics/MethodLength
829
+ # ADR-46 slice 1 when dependency recording is enabled, wrap the
830
+ # per-file analysis so the cross-file reads its inference makes are
831
+ # captured into `file_dependencies[path]`. Off by default: a normal
832
+ # run calls the body directly and the instrumented `Scope` accessors
833
+ # short-circuit on `DependencyRecorder.active? == false`. Recording
834
+ # is observational, so diagnostics are byte-identical either way.
835
+ def analyze_file(path, environment)
836
+ return analyze_file_body(path, environment) unless @record_dependencies
837
+
838
+ diagnostics = nil
839
+ record = DependencyRecorder.record_for(path) do
840
+ diagnostics = analyze_file_body(path, environment)
841
+ end
842
+ @file_dependencies[path] = record
843
+ diagnostics
844
+ end
845
+
846
+ def analyze_file_body(path, environment) # rubocop:disable Metrics/MethodLength
1529
847
  parse_result = parse_source(path)
1530
848
  unless parse_result.errors.empty?
1531
849
  return [] if ErbTemplateDetector.template?(parse_result)
@@ -1534,11 +852,20 @@ module Rigor
1534
852
  end
1535
853
 
1536
854
  scope = seed_project_scope(Scope.empty(environment: environment, source_path: path))
1537
- index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
855
+ # ADR-24 slice 4a/4 — record unresolved implicit-self calls during the
856
+ # typing pass ONLY (not CheckRules, whose own `type_of` queries would
857
+ # otherwise re-trigger the choke-point). `self_call_misses` feeds the
858
+ # `call.self-undefined-method` collector; the recorder is inert unless
859
+ # the rule is active or `record_self_calls:` opted in.
860
+ index = nil
861
+ self_call_record = with_self_call_recording(path) do
862
+ index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
863
+ end
1538
864
  diagnostics = CheckRules.diagnose(
1539
865
  path: path,
1540
866
  root: parse_result.value,
1541
867
  scope_index: index,
868
+ self_call_misses: self_call_record ? self_call_record.calls : [],
1542
869
  comments: parse_result.comments,
1543
870
  disabled_rules: @configuration.disabled_rules
1544
871
  )
@@ -1566,6 +893,46 @@ module Rigor
1566
893
  ]
1567
894
  end
1568
895
 
896
+ # ADR-24 slice 4a — runs `block` (the typing pass) with the self-call
897
+ # recorder active when either the test-only `record_self_calls:` flag is
898
+ # set or the `call.self-undefined-method` rule resolves to a firing
899
+ # severity. Returns the frozen {SelfCallResolutionRecorder::Record}, or
900
+ # nil when recording is inactive (the common path — one integer read).
901
+ def with_self_call_recording(path, &)
902
+ unless self_call_recording_active?
903
+ yield
904
+ return nil
905
+ end
906
+
907
+ record = SelfCallResolutionRecorder.record_for(path, &)
908
+ @unresolved_self_calls[path] = record
909
+ record
910
+ end
911
+
912
+ def self_call_recording_active?
913
+ @record_self_calls || self_undefined_rule_active?
914
+ end
915
+
916
+ # Memoised: the rule fires only when its resolved severity is not `:off`
917
+ # and it is not in `disable:`. Default profiles map it to `:off`, so a
918
+ # normal run never activates the recorder (pending the external WD4
919
+ # corpus FP gate — see ADR-24 § "Slice 4"); a project opts in via
920
+ # `severity_overrides:`.
921
+ def self_undefined_rule_active?
922
+ return @self_undefined_rule_active unless @self_undefined_rule_active.nil?
923
+
924
+ rule = CheckRules::RULE_SELF_UNDEFINED_METHOD
925
+ @self_undefined_rule_active =
926
+ if @configuration.disabled_rules.include?(rule) || @configuration.disabled_rules.include?("call")
927
+ false
928
+ else
929
+ Configuration::SeverityProfile.resolve(
930
+ rule: rule, authored_severity: :warning,
931
+ profile: @configuration.severity_profile, overrides: @configuration.severity_overrides
932
+ ) != :off
933
+ end
934
+ end
935
+
1569
936
  # v0.0.2 #10 — fail-soft fallback explanation. When
1570
937
  # `--explain` is set the runner additionally walks the
1571
938
  # file with `Rigor::Inference::CoverageScanner` and emits