rigortype 0.1.16 → 0.1.17

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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +100 -0
  3. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +209 -0
  4. data/lib/rigor/analysis/check_rules.rb +149 -70
  5. data/lib/rigor/analysis/dependency_recorder.rb +122 -0
  6. data/lib/rigor/analysis/diagnostic.rb +18 -0
  7. data/lib/rigor/analysis/incremental.rb +162 -0
  8. data/lib/rigor/analysis/incremental_session.rb +337 -0
  9. data/lib/rigor/analysis/rule_catalog.rb +48 -0
  10. data/lib/rigor/analysis/runner.rb +434 -37
  11. data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
  12. data/lib/rigor/builtins/static_return_refinements.rb +7 -1
  13. data/lib/rigor/cache/descriptor.rb +50 -49
  14. data/lib/rigor/cache/incremental_snapshot.rb +147 -0
  15. data/lib/rigor/cache/rbs_cache_producer.rb +30 -0
  16. data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
  17. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
  18. data/lib/rigor/cache/rbs_constant_table.rb +2 -8
  19. data/lib/rigor/cache/rbs_environment.rb +2 -8
  20. data/lib/rigor/cache/rbs_instance_definitions.rb +3 -16
  21. data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
  22. data/lib/rigor/cache/store.rb +99 -1
  23. data/lib/rigor/cli/annotate_command.rb +2 -7
  24. data/lib/rigor/cli/baseline_command.rb +2 -7
  25. data/lib/rigor/cli/command.rb +47 -0
  26. data/lib/rigor/cli/coverage_command.rb +3 -23
  27. data/lib/rigor/cli/coverage_renderer.rb +3 -8
  28. data/lib/rigor/cli/diff_command.rb +3 -7
  29. data/lib/rigor/cli/explain_command.rb +2 -7
  30. data/lib/rigor/cli/lsp_command.rb +3 -7
  31. data/lib/rigor/cli/mcp_command.rb +3 -7
  32. data/lib/rigor/cli/options.rb +57 -0
  33. data/lib/rigor/cli/plugin_command.rb +3 -7
  34. data/lib/rigor/cli/plugins_command.rb +2 -7
  35. data/lib/rigor/cli/renderable.rb +26 -0
  36. data/lib/rigor/cli/sig_gen_command.rb +2 -7
  37. data/lib/rigor/cli/skill_command.rb +3 -7
  38. data/lib/rigor/cli/triage_command.rb +2 -7
  39. data/lib/rigor/cli/type_of_command.rb +5 -38
  40. data/lib/rigor/cli/type_of_renderer.rb +4 -9
  41. data/lib/rigor/cli/type_scan_command.rb +3 -23
  42. data/lib/rigor/cli/type_scan_renderer.rb +4 -9
  43. data/lib/rigor/cli.rb +125 -43
  44. data/lib/rigor/configuration/dependencies.rb +18 -1
  45. data/lib/rigor/configuration/severity_profile.rb +22 -3
  46. data/lib/rigor/configuration.rb +13 -3
  47. data/lib/rigor/environment/rbs_loader.rb +76 -3
  48. data/lib/rigor/inference/block_parameter_binder.rb +1 -2
  49. data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
  50. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
  51. data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
  52. data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
  53. data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
  54. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
  55. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
  56. data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
  57. data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
  58. data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
  59. data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
  60. data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
  61. data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
  62. data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
  63. data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
  64. data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
  65. data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
  66. data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
  67. data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
  68. data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
  69. data/lib/rigor/inference/expression_typer.rb +140 -20
  70. data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
  71. data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
  72. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
  73. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
  74. data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
  75. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
  76. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
  77. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
  78. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
  79. data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
  80. data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
  81. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +23 -13
  82. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
  83. data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
  84. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
  85. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
  86. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
  87. data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
  88. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
  89. data/lib/rigor/inference/method_dispatcher.rb +99 -59
  90. data/lib/rigor/inference/narrowing.rb +202 -5
  91. data/lib/rigor/inference/scope_indexer.rb +134 -7
  92. data/lib/rigor/inference/statement_evaluator.rb +105 -26
  93. data/lib/rigor/language_server/buffer_resolution.rb +33 -0
  94. data/lib/rigor/language_server/completion_provider.rb +4 -4
  95. data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
  96. data/lib/rigor/language_server/folding_range_provider.rb +4 -4
  97. data/lib/rigor/language_server/hover_provider.rb +4 -4
  98. data/lib/rigor/language_server/selection_range_provider.rb +4 -4
  99. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  100. data/lib/rigor/plugin/base.rb +20 -4
  101. data/lib/rigor/plugin/registry.rb +39 -1
  102. data/lib/rigor/rbs_extended/conformance_checker.rb +208 -0
  103. data/lib/rigor/rbs_extended.rb +39 -0
  104. data/lib/rigor/scope.rb +123 -9
  105. data/lib/rigor/type/acceptance_router.rb +19 -0
  106. data/lib/rigor/type/accepts_result.rb +3 -10
  107. data/lib/rigor/type/app.rb +3 -7
  108. data/lib/rigor/type/bot.rb +2 -3
  109. data/lib/rigor/type/bound_method.rb +5 -12
  110. data/lib/rigor/type/combinator.rb +17 -0
  111. data/lib/rigor/type/constant.rb +2 -3
  112. data/lib/rigor/type/data_class.rb +80 -0
  113. data/lib/rigor/type/data_instance.rb +100 -0
  114. data/lib/rigor/type/difference.rb +5 -10
  115. data/lib/rigor/type/dynamic.rb +5 -10
  116. data/lib/rigor/type/hash_shape.rb +5 -15
  117. data/lib/rigor/type/integer_range.rb +5 -10
  118. data/lib/rigor/type/intersection.rb +5 -10
  119. data/lib/rigor/type/nominal.rb +5 -10
  120. data/lib/rigor/type/refined.rb +5 -10
  121. data/lib/rigor/type/singleton.rb +5 -10
  122. data/lib/rigor/type/top.rb +2 -3
  123. data/lib/rigor/type/tuple.rb +5 -10
  124. data/lib/rigor/type/union.rb +5 -10
  125. data/lib/rigor/type.rb +2 -0
  126. data/lib/rigor/value_semantics.rb +77 -0
  127. data/lib/rigor/version.rb +1 -1
  128. data/lib/rigor.rb +1 -0
  129. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +12 -2
  130. data/sig/rigor/cache.rbs +19 -0
  131. data/sig/rigor/inference.rbs +22 -0
  132. data/sig/rigor/rbs_extended.rbs +2 -0
  133. data/sig/rigor/scope.rbs +5 -0
  134. data/sig/rigor/type.rbs +58 -1
  135. data/sig/rigor.rbs +6 -1
  136. metadata +22 -1
@@ -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"
@@ -33,7 +40,8 @@ module Rigor
33
40
  DEFAULT_CACHE_ROOT = ".rigor/cache"
34
41
 
35
42
  attr_reader :cache_store, :plugin_registry, :dependency_source_index,
36
- :rbs_extended_reporter, :boundary_cross_reporter
43
+ :rbs_extended_reporter, :boundary_cross_reporter, :file_dependencies,
44
+ :analyzed_files, :unresolved_self_calls
37
45
 
38
46
  # @param configuration [Rigor::Configuration]
39
47
  # @param explain [Boolean] surface fail-soft fallback events
@@ -82,10 +90,11 @@ module Rigor
82
90
  # (bundler / lockfile / collection discovery, RbsLoader
83
91
  # construction). Pool mode ignores the override — each
84
92
  # worker continues to build its own Environment.
85
- def initialize(configuration:, explain: false, # rubocop:disable Metrics/ParameterLists
93
+ def initialize(configuration:, explain: false, # rubocop:disable Metrics/ParameterLists,Metrics/AbcSize,Metrics/MethodLength
86
94
  cache_store: Cache::Store.new(root: DEFAULT_CACHE_ROOT),
87
95
  plugin_requirer: nil, workers: 0, collect_stats: true,
88
- buffer: nil, prebuilt: nil, environment: nil)
96
+ buffer: nil, prebuilt: nil, environment: nil,
97
+ record_dependencies: false, record_self_calls: false, analyze_only: nil)
89
98
  @configuration = configuration
90
99
  @explain = explain
91
100
  @cache_store = enforce_read_only_cache(cache_store, buffer)
@@ -95,6 +104,35 @@ module Rigor
95
104
  @buffer = buffer
96
105
  @prebuilt = prebuilt
97
106
  @environment_override = environment
107
+ # ADR-46 slice 1 — opt-in cross-file dependency recording. Off by
108
+ # default; when true, `analyze_file` records each file's
109
+ # cross-file reads into `file_dependencies` (the incremental
110
+ # cache, a later slice, consumes them).
111
+ @record_dependencies = record_dependencies
112
+ # ADR-24 slice 4a — opt-in unresolved-implicit-self-call recording.
113
+ # Off by default; when true, `analyze_file` activates the engine
114
+ # choke-point recorder and collects each file's misses into
115
+ # `unresolved_self_calls` (a later closed-class-gated rule consumes
116
+ # them). Purely observational — diagnostics are byte-identical.
117
+ @record_self_calls = record_self_calls
118
+ @unresolved_self_calls = {}
119
+ # Memoised activation decision for the `call.self-undefined-method`
120
+ # rule (nil = not yet computed). See `self_undefined_rule_active?`.
121
+ @self_undefined_rule_active = nil
122
+ @analyzed_files = [].freeze
123
+ # In-memory source map for `#run_source` — `{ logical_path => source
124
+ # String }`. When set, `parse_source` reads bytes from here instead
125
+ # of disk and `expand_paths` accepts the (possibly non-existent)
126
+ # logical path. nil on a normal disk-backed run.
127
+ @in_memory_sources = nil
128
+ # ADR-46 slice 2 — the subset-analysis hook. When set (a collection
129
+ # of paths), the whole-project pre-pass still runs over every file
130
+ # (so the cross-file index is complete), but only files in this set
131
+ # are analyzed for diagnostics — the body tier re-analyses the
132
+ # affected closure and serves the rest from the per-file cache.
133
+ # `nil` (the default) analyzes everything.
134
+ @analyze_only = analyze_only && Set.new(analyze_only)
135
+ @file_dependencies = {}
98
136
  @plugin_registry = Plugin::Registry::EMPTY
99
137
  @dependency_source_index = DependencySourceInference::Index::EMPTY
100
138
  @rbs_extended_reporter = RbsExtended::Reporter.new
@@ -103,18 +141,27 @@ module Rigor
103
141
  # `#run` resets these for each invocation; pre-seed them to
104
142
  # empty containers so `build_run_stats` / `pre_file_diagnostics`
105
143
  # (private, called only from `#run`) can read them without
106
- # nil-guards.
144
+ # nil-guards. Kept inline (not a helper) so the engine's own
145
+ # flow analysis sees the ivars established in the constructor.
107
146
  @class_decl_paths_snapshot = {}.freeze
108
147
  @signature_paths_snapshot = [].freeze
109
148
  @synthesized_namespaces_snapshot = [].freeze
149
+ # `rigor:v1:conforms-to` results, snapshotted from the
150
+ # per-run RBS env in `analyze_files_sequentially` (gated on
151
+ # the project declaring `signature_paths:`) and drained by
152
+ # `conforms_to_diagnostics`. Inline default per the comment
153
+ # above so the engine's own flow analysis sees it seeded.
154
+ @conformance_results_snapshot = [].freeze
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
118
165
  end
119
166
 
120
167
  # ADR-pending editor mode — present when the runner is wired
@@ -144,6 +191,7 @@ module Rigor
144
191
  @class_decl_paths_snapshot = {}.freeze
145
192
  @signature_paths_snapshot = []
146
193
  @synthesized_namespaces_snapshot = []
194
+ @conformance_results_snapshot = []
147
195
 
148
196
  if @prebuilt
149
197
  adopt_prebuilt_project_scan(@prebuilt)
@@ -151,17 +199,210 @@ module Rigor
151
199
  run_project_pre_passes(expansion: expansion)
152
200
  end
153
201
 
202
+ diagnostics = compute_run_diagnostics(expansion)
203
+
204
+ Result.new(
205
+ diagnostics: apply_severity_profile(diagnostics),
206
+ stats: stats_for_run(wall_started_at: wall_started_at, expansion: expansion)
207
+ )
208
+ end
209
+
210
+ # Analyze a single source String in memory, without writing it to
211
+ # disk — a clean entry point for embedders (LSP / editor mode) and a
212
+ # faster spec path than the per-call tmpdir + chdir. The source is
213
+ # bound to `path` (purely a logical identity carried in diagnostic
214
+ # locations; it need not exist on disk). The full run machinery still
215
+ # runs — environment build, plugin `prepare`, severity profile — so
216
+ # the result matches a one-file disk run; only the cross-file project
217
+ # pre-pass is empty (there is one file, and the per-file indexer
218
+ # self-discovers its own classes / defs).
219
+ #
220
+ # @param source [String] Ruby source to analyze.
221
+ # @param path [String] logical path for diagnostic locations.
222
+ # @return [Result]
223
+ def run_source(source:, path: "(source).rb")
224
+ @in_memory_sources = { path => source }
225
+ run([path])
226
+ ensure
227
+ @in_memory_sources = nil
228
+ end
229
+
230
+ # ADR-46 — the project file set that a run over `paths` would
231
+ # analyze, computed by globbing only (no RBS environment build), so
232
+ # the incremental fingerprint can be derived cheaply on the warm path
233
+ # before deciding whether to build the env at all.
234
+ def analysis_file_set(paths = @configuration.paths)
235
+ expand_paths(paths).fetch(:files)
236
+ end
237
+
238
+ # ADR-46 §2 — inverts {#file_dependencies} into the reverse edge the
239
+ # incremental step walks: `dependents[X] = { A : A read a
240
+ # declaration / body from X }`. On an edit to X, the body tier
241
+ # (slice 2) re-analyses `{X} ∪ dependents[X]` and serves every other
242
+ # file from the per-file cache. Built on demand from the recorded
243
+ # `sources` sets (so it reflects whatever `analyze_file` captured —
244
+ # empty unless the runner was constructed with
245
+ # `record_dependencies: true`). The negative (`missing`) edges are
246
+ # NOT inverted here: they feed the structural tier (slice 3), which
247
+ # re-checks a consumer when a name it looked up and did not resolve
248
+ # later appears.
249
+ def file_dependents
250
+ Incremental.invert(@file_dependencies.transform_values(&:sources))
251
+ end
252
+
253
+ # ADR-46 slice 4 — per-symbol body fingerprints, computed from the
254
+ # project pre-pass def index. Returns a frozen hash of the form:
255
+ # { "path/to/file.rb" => { "ClassName#method" => sha256_hex, … }, … }
256
+ # Used by {Analysis::IncrementalSession} to detect which symbols in a
257
+ # changed file actually changed bodies, so only callers of those
258
+ # specific symbols are re-checked. Only meaningful after a run that
259
+ # populated `@project_discovered_def_nodes` (i.e. any full or subset
260
+ # analysis); returns an empty frozen hash before the first run.
261
+ def symbol_fingerprints
262
+ result = Hash.new { |h, k| h[k] = {} }
263
+ @project_discovered_def_sources.each do |class_name, methods|
264
+ methods.each do |method_sym, path_line|
265
+ path = path_line.split(":", 2).first
266
+ node = @project_discovered_def_nodes.dig(class_name, method_sym)
267
+ next unless node
268
+
269
+ result[path]["#{class_name}##{method_sym}"] =
270
+ Digest::SHA256.hexdigest(node.location.slice)
271
+ end
272
+ end
273
+ result.transform_values(&:freeze).freeze
274
+ end
275
+
276
+ # ADR-46 slice 3 — per-file set of the qualified class/module names
277
+ # declared in that file. Used to detect a class that *appeared* in an
278
+ # edit so a subclass whose ancestor was previously undefined (and so
279
+ # recorded a negative class edge) is re-checked. Inverts the project
280
+ # class-source attribution (class → declaring files).
281
+ def class_declarations
282
+ result = Hash.new { |hash, key| hash[key] = Set.new }
283
+ @project_discovered_class_sources.each do |class_name, files|
284
+ files.each { |file| result[file] << class_name }
285
+ end
286
+ result.transform_values(&:freeze).freeze
287
+ end
288
+
289
+ # ADR-45 — unchanged-project fast path. Serves the whole run's
290
+ # (pre-severity-profile) diagnostics from one record-and-validate
291
+ # cache entry when every file the previous run read is unchanged,
292
+ # skipping the dominant per-file inference. The dependency set is
293
+ # collected AFTER the run (so it captures files the plugins read
294
+ # mid-analysis, e.g. a Pundit policy) and re-validated on the next
295
+ # run; the entry is keyed on the inputs known up front (config, gem
296
+ # / engine versions, analyzed-path set).
297
+ def compute_run_diagnostics(expansion)
298
+ @run_served_from_cache = false
299
+ return assemble_run_diagnostics(expansion) unless run_result_cacheable?
300
+
301
+ environment = resolve_sequential_environment(source_files: target_files(expansion))
302
+ rbs_descriptor = environment&.rbs_loader ? Cache::RbsDescriptor.build(environment.rbs_loader) : Cache::Descriptor.new
303
+ key_descriptor = run_key_descriptor(expansion, rbs_descriptor)
304
+ return assemble_run_diagnostics(expansion, environment: environment) if key_descriptor.nil?
305
+
306
+ computed = false
307
+ diagnostics = @cache_store.fetch_or_validate(
308
+ producer_id: "analysis.run-diagnostics", key_descriptor: key_descriptor
309
+ ) do
310
+ computed = true
311
+ diags = assemble_run_diagnostics(expansion, environment: environment)
312
+ [diags, run_dependency_descriptor(expansion, rbs_descriptor)]
313
+ end
314
+ @run_served_from_cache = !computed
315
+ diagnostics
316
+ rescue StandardError
317
+ # The result cache must never break a run. If anything in the
318
+ # cache path fails, fall back to a direct, uncached analysis.
319
+ @run_served_from_cache = false
320
+ assemble_run_diagnostics(expansion)
321
+ end
322
+
323
+ def assemble_run_diagnostics(expansion, environment: nil)
154
324
  diagnostics = pre_file_diagnostics(expansion)
155
- diagnostics += analyze_files(target_files(expansion))
325
+ # ADR-46 — record which project files this run actually analyzed
326
+ # (the `analyze_only` subset, or all of them). The incremental
327
+ # orchestrator serves every analyzed-but-not-affected file from the
328
+ # per-file cache, so it needs the full analyzed set to subtract the
329
+ # affected closure from.
330
+ targets = target_files(expansion)
331
+ @analyzed_files = targets
332
+ diagnostics += analyze_files(targets, environment: environment)
156
333
  diagnostics += rbs_synthesized_namespace_diagnostics
334
+ diagnostics += conforms_to_diagnostics
157
335
  diagnostics += rbs_extended_reporter_diagnostics
158
336
  diagnostics += boundary_cross_diagnostics
159
- diagnostics += source_rbs_synthesis_diagnostics
160
-
161
- Result.new(
162
- diagnostics: apply_severity_profile(diagnostics),
163
- stats: @collect_stats ? build_run_stats(wall_started_at: wall_started_at, expansion: expansion) : nil
337
+ diagnostics + source_rbs_synthesis_diagnostics
338
+ end
339
+
340
+ # A cache hit skipped the analysis, so the per-run stats (wall
341
+ # split, RBS-class counts, ) were never gathered — report none
342
+ # rather than the stale snapshot defaults.
343
+ def stats_for_run(wall_started_at:, expansion:)
344
+ return nil unless @collect_stats
345
+ return nil if @run_served_from_cache
346
+
347
+ build_run_stats(wall_started_at: wall_started_at, expansion: expansion)
348
+ end
349
+
350
+ # Cacheable only for a full sequential project run with a writable
351
+ # cache and no per-buffer / prebuilt override — every other mode has
352
+ # a different result identity (pool workers read in separate
353
+ # processes; editor mode is per-buffer; prebuilt is the LSP path).
354
+ def run_result_cacheable?
355
+ !@cache_store.nil? && !@cache_store.read_only? &&
356
+ @buffer.nil? && @prebuilt.nil? && !pool_mode?
357
+ end
358
+
359
+ # Stable cache key inputs — known before the run: a digest of the
360
+ # resolved configuration, the engine + rbs versions + `--explain`,
361
+ # and the analyzed-path SET (adding/removing a file changes the
362
+ # key; editing one is caught by dependency validation). nil disables
363
+ # the cache for this run rather than risking a malformed key.
364
+ def run_key_descriptor(expansion, rbs_descriptor)
365
+ Cache::Descriptor.new(
366
+ gems: rbs_descriptor.gems,
367
+ configs: rbs_descriptor.configs + [
368
+ config_hash_entry("configuration", Marshal.dump(@configuration.to_h)),
369
+ config_hash_entry("engine", "#{Rigor::VERSION}:#{Cache::Descriptor::SCHEMA_VERSION}:#{@explain}"),
370
+ config_hash_entry("paths", expansion.fetch(:files).sort.join("\n"))
371
+ ]
164
372
  )
373
+ rescue StandardError
374
+ nil
375
+ end
376
+
377
+ # Files the run actually depended on, collected AFTER it ran:
378
+ # every analyzed file, every RBS `sig` file (`rbs_descriptor.files`),
379
+ # and every file each plugin read (complete post-run, so reads made
380
+ # mid-analysis are included). Re-digested on the next run by
381
+ # {Descriptor#fresh?}.
382
+ def run_dependency_descriptor(expansion, rbs_descriptor)
383
+ entries = analyzed_file_entries(expansion) + rbs_descriptor.files
384
+ @plugin_registry.plugins.each do |plugin|
385
+ # Read the boundary WITHOUT triggering its lazy `@io_boundary ||=`
386
+ # initializer: plugin instances are frozen after the run, and a
387
+ # plugin that never built a boundary read no files through it, so
388
+ # it contributes no dependencies.
389
+ boundary = plugin.instance_variable_get(:@io_boundary)
390
+ entries.concat(boundary.cache_descriptor.files) if boundary
391
+ end
392
+ Cache::Descriptor.new(files: entries)
393
+ end
394
+
395
+ def analyzed_file_entries(expansion)
396
+ expansion.fetch(:files).map do |path|
397
+ physical = @buffer ? @buffer.resolve(path) : path
398
+ Cache::Descriptor::FileEntry.new(
399
+ path: physical, comparator: :digest, value: Digest::SHA256.file(physical).hexdigest
400
+ )
401
+ end
402
+ end
403
+
404
+ def config_hash_entry(key, payload)
405
+ Cache::Descriptor::ConfigEntry.new(key: key, value_hash: Digest::SHA256.hexdigest(payload))
165
406
  end
166
407
 
167
408
  # Runs every project-wide pre-pass (`load_plugins` +
@@ -204,7 +445,7 @@ module Rigor
204
445
  # downstream `#run` body expects. Extracted so
205
446
  # `#prepare_project_scan` and the prebuilt-less `#run` path
206
447
  # share one implementation.
207
- def run_project_pre_passes(expansion:)
448
+ def run_project_pre_passes(expansion:) # rubocop:disable Metrics/AbcSize
208
449
  @plugin_registry = load_plugins
209
450
  @dependency_source_index = DependencySourceInference::Builder.build(@configuration.dependencies)
210
451
  # ADR-18 slice 3 — plugin prepare MUST run before the
@@ -266,8 +507,10 @@ module Rigor
266
507
  @project_discovered_def_sources = def_index.fetch(:def_sources)
267
508
  @project_discovered_superclasses = def_index.fetch(:superclasses)
268
509
  @project_discovered_includes = def_index.fetch(:includes)
510
+ @project_discovered_class_sources = def_index.fetch(:class_sources)
269
511
  @project_discovered_method_visibilities = def_index.fetch(:method_visibilities)
270
512
  @project_discovered_methods = def_index.fetch(:methods)
513
+ @project_data_member_layouts = def_index.fetch(:data_member_layouts)
271
514
  end
272
515
 
273
516
  # Internal: adopts a frozen {ProjectScan} snapshot supplied
@@ -301,31 +544,36 @@ module Rigor
301
544
  # method returns — holding it as long-lived state added
302
545
  # memory pressure that surfaced as a Bus Error during the
303
546
  # spec suite under Ruby 4.0 + rbs 4.0.2.
304
- def analyze_files(files)
547
+ def analyze_files(files, environment: nil)
305
548
  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
549
+ return dispatch_pool(files) if pool_mode?
550
+
551
+ analyze_files_sequentially(files, environment || resolve_sequential_environment(source_files: files))
552
+ end
553
+
554
+ def analyze_files_sequentially(files, environment)
555
+ # Snapshot the small synthesized-namespace name list (NOT the
556
+ # env see the method comment) so #run can surface the
557
+ # malformed-RBS `:info` diagnostic without rebuilding the env.
558
+ # Gated on the project actually declaring `signature_paths:`:
559
+ # synthesis only matters for the project's own RBS, and
560
+ # `#synthesized_namespaces` forces the (otherwise-lazy) RBS env
561
+ # to build doing so when there is no project sig set would
562
+ # warm `.rigor/cache` on a bare `--no-stats` run.
563
+ @synthesized_namespaces_snapshot =
564
+ project_signature_paths? ? (environment.rbs_loader&.synthesized_namespaces || []) : []
565
+ # `rigor:v1:conforms-to` lives only in the project's own
566
+ # `signature_paths:` RBS, so gate the scan the same way and
567
+ # reuse the already-built env (no extra RBS load).
568
+ @conformance_results_snapshot =
569
+ project_signature_paths? ? RbsExtended::ConformanceChecker.scan(environment.rbs_loader) : []
570
+ result = files.flat_map { |path| analyze_file(path, environment) }
571
+ if @collect_stats
572
+ loader = environment.rbs_loader
573
+ @class_decl_paths_snapshot = loader&.class_decl_paths || {}.freeze
574
+ @signature_paths_snapshot = loader&.signature_paths || [].freeze
328
575
  end
576
+ result
329
577
  end
330
578
 
331
579
  # Sequential-mode environment resolver. Returns the supplied
@@ -463,6 +711,11 @@ module Rigor
463
711
  # buffer".
464
712
  def target_files(expansion)
465
713
  files = expansion.fetch(:files)
714
+ # ADR-46 slice 2 — restrict the analyzed set to the affected
715
+ # closure while the pre-pass (run separately over `expansion`'s
716
+ # full file list) keeps the cross-file index complete. Buffer mode
717
+ # takes precedence — its single logical path is the analyzed set.
718
+ files = files.select { |path| @analyze_only.include?(path) } if @analyze_only
466
719
  return files if @buffer.nil?
467
720
 
468
721
  [@buffer.logical_path]
@@ -1146,6 +1399,72 @@ module Rigor
1146
1399
  [build_rbs_synthesized_namespace_diagnostic(synthesized)]
1147
1400
  end
1148
1401
 
1402
+ # Maps the per-run `rigor:v1:conforms-to` scan results into
1403
+ # diagnostics (spec: `rbs-extended.md` § "Explicit conformance
1404
+ # directive"). A class that declares `conforms-to _Interface`
1405
+ # but is missing a required interface method surfaces as
1406
+ # `rbs_extended.unsatisfied-conformance`; an unresolvable
1407
+ # interface name surfaces as `dynamic.rbs-extended.unresolved`
1408
+ # `:info` (the same fail-soft channel the other directive
1409
+ # parsers use). Empty for a project with no directive, a
1410
+ # well-formed conformance, or a non-sequential pool run (the
1411
+ # snapshot mirrors `synthesized_namespaces`).
1412
+ def conforms_to_diagnostics
1413
+ results = @conformance_results_snapshot
1414
+ return [] if results.nil? || results.empty?
1415
+
1416
+ results.map { |record| build_conformance_diagnostic(record) }
1417
+ end
1418
+
1419
+ def build_conformance_diagnostic(record)
1420
+ case record
1421
+ when RbsExtended::ConformanceChecker::Unsatisfied
1422
+ build_unsatisfied_conformance_diagnostic(record)
1423
+ when RbsExtended::ConformanceChecker::IncompatibleSignature
1424
+ build_incompatible_signature_diagnostic(record)
1425
+ else # UnresolvedInterface
1426
+ build_reporter_diagnostic(
1427
+ record.location,
1428
+ rule: "dynamic.rbs-extended.unresolved",
1429
+ message: "`#{record.class_name}` declares `conforms-to #{record.interface_name}` but " \
1430
+ "interface `#{record.interface_name}` is not loaded. Check for a typo or add " \
1431
+ "the `sig`/library that declares it to the RBS load path."
1432
+ )
1433
+ end
1434
+ end
1435
+
1436
+ def build_unsatisfied_conformance_diagnostic(record)
1437
+ path, line, column = location_fields(record.location)
1438
+ Diagnostic.new(
1439
+ path: path, line: line, column: column,
1440
+ message: "`#{record.class_name}` declares `conforms-to #{record.interface_name}` " \
1441
+ "but does not provide #{pluralize_methods(record.missing_methods)}: " \
1442
+ "#{record.missing_methods.map { |m| "`##{m}`" }.join(', ')}. Implement the " \
1443
+ "missing method(s) or remove the directive.",
1444
+ severity: :warning,
1445
+ rule: "rbs_extended.unsatisfied-conformance",
1446
+ source_family: :builtin
1447
+ )
1448
+ end
1449
+
1450
+ def build_incompatible_signature_diagnostic(record)
1451
+ path, line, column = location_fields(record.location)
1452
+ Diagnostic.new(
1453
+ path: path, line: line, column: column,
1454
+ message: "`#{record.class_name}##{record.method_name}` does not satisfy " \
1455
+ "`conforms-to #{record.interface_name}`: #{record.detail}. Adjust the " \
1456
+ "signature to a subtype of the interface contract.",
1457
+ severity: :warning,
1458
+ rule: "rbs_extended.unsatisfied-conformance",
1459
+ source_family: :builtin,
1460
+ method_name: record.method_name
1461
+ )
1462
+ end
1463
+
1464
+ def pluralize_methods(methods)
1465
+ methods.size == 1 ? "required method" : "#{methods.size} required methods"
1466
+ end
1467
+
1149
1468
  # True when the project declares its own `signature_paths:` (the
1150
1469
  # only place the qualified-name-without-namespace mistake lives).
1151
1470
  def project_signature_paths?
@@ -1455,7 +1774,8 @@ module Rigor
1455
1774
 
1456
1775
  def accept_as_ruby_file?(path)
1457
1776
  (File.file?(path) && path.end_with?(".rb")) ||
1458
- (@buffer && path == @buffer.logical_path)
1777
+ (@buffer && path == @buffer.logical_path) ||
1778
+ @in_memory_sources&.key?(path)
1459
1779
  end
1460
1780
 
1461
1781
  # `Configuration#exclude_patterns` is a list of glob patterns
@@ -1494,6 +1814,10 @@ module Rigor
1494
1814
  # LOGICAL path. Non-binding paths go through the cheaper
1495
1815
  # `Prism.parse_file` codepath unchanged.
1496
1816
  def parse_source(path)
1817
+ if @in_memory_sources&.key?(path)
1818
+ return Prism.parse(@in_memory_sources[path], filepath: path, version: @configuration.target_ruby)
1819
+ end
1820
+
1497
1821
  physical = @buffer ? @buffer.resolve(path) : path
1498
1822
  return Prism.parse_file(physical, version: @configuration.target_ruby) if physical == path
1499
1823
 
@@ -1522,10 +1846,34 @@ module Rigor
1522
1846
  scope = scope.with_discovered_method_visibilities(@project_discovered_method_visibilities)
1523
1847
  end
1524
1848
  scope = scope.with_discovered_methods(@project_discovered_methods) unless @project_discovered_methods.empty?
1849
+ scope = scope.with_data_member_layouts(@project_data_member_layouts) unless @project_data_member_layouts.empty?
1850
+ # ADR-46 slice 1 — the class-declaration source map is read only by
1851
+ # the ancestry accessors during dependency recording, so seed it
1852
+ # only when recording is on; a normal run never carries it.
1853
+ if @record_dependencies && !@project_discovered_class_sources.empty?
1854
+ scope = scope.with_discovered_class_sources(@project_discovered_class_sources)
1855
+ end
1525
1856
  scope
1526
1857
  end
1527
1858
 
1528
- def analyze_file(path, environment) # rubocop:disable Metrics/MethodLength
1859
+ # ADR-46 slice 1 when dependency recording is enabled, wrap the
1860
+ # per-file analysis so the cross-file reads its inference makes are
1861
+ # captured into `file_dependencies[path]`. Off by default: a normal
1862
+ # run calls the body directly and the instrumented `Scope` accessors
1863
+ # short-circuit on `DependencyRecorder.active? == false`. Recording
1864
+ # is observational, so diagnostics are byte-identical either way.
1865
+ def analyze_file(path, environment)
1866
+ return analyze_file_body(path, environment) unless @record_dependencies
1867
+
1868
+ diagnostics = nil
1869
+ record = DependencyRecorder.record_for(path) do
1870
+ diagnostics = analyze_file_body(path, environment)
1871
+ end
1872
+ @file_dependencies[path] = record
1873
+ diagnostics
1874
+ end
1875
+
1876
+ def analyze_file_body(path, environment) # rubocop:disable Metrics/MethodLength
1529
1877
  parse_result = parse_source(path)
1530
1878
  unless parse_result.errors.empty?
1531
1879
  return [] if ErbTemplateDetector.template?(parse_result)
@@ -1534,11 +1882,20 @@ module Rigor
1534
1882
  end
1535
1883
 
1536
1884
  scope = seed_project_scope(Scope.empty(environment: environment, source_path: path))
1537
- index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
1885
+ # ADR-24 slice 4a/4 — record unresolved implicit-self calls during the
1886
+ # typing pass ONLY (not CheckRules, whose own `type_of` queries would
1887
+ # otherwise re-trigger the choke-point). `self_call_misses` feeds the
1888
+ # `call.self-undefined-method` collector; the recorder is inert unless
1889
+ # the rule is active or `record_self_calls:` opted in.
1890
+ index = nil
1891
+ self_call_record = with_self_call_recording(path) do
1892
+ index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
1893
+ end
1538
1894
  diagnostics = CheckRules.diagnose(
1539
1895
  path: path,
1540
1896
  root: parse_result.value,
1541
1897
  scope_index: index,
1898
+ self_call_misses: self_call_record ? self_call_record.calls : [],
1542
1899
  comments: parse_result.comments,
1543
1900
  disabled_rules: @configuration.disabled_rules
1544
1901
  )
@@ -1566,6 +1923,46 @@ module Rigor
1566
1923
  ]
1567
1924
  end
1568
1925
 
1926
+ # ADR-24 slice 4a — runs `block` (the typing pass) with the self-call
1927
+ # recorder active when either the test-only `record_self_calls:` flag is
1928
+ # set or the `call.self-undefined-method` rule resolves to a firing
1929
+ # severity. Returns the frozen {SelfCallResolutionRecorder::Record}, or
1930
+ # nil when recording is inactive (the common path — one integer read).
1931
+ def with_self_call_recording(path, &)
1932
+ unless self_call_recording_active?
1933
+ yield
1934
+ return nil
1935
+ end
1936
+
1937
+ record = SelfCallResolutionRecorder.record_for(path, &)
1938
+ @unresolved_self_calls[path] = record
1939
+ record
1940
+ end
1941
+
1942
+ def self_call_recording_active?
1943
+ @record_self_calls || self_undefined_rule_active?
1944
+ end
1945
+
1946
+ # Memoised: the rule fires only when its resolved severity is not `:off`
1947
+ # and it is not in `disable:`. Default profiles map it to `:off`, so a
1948
+ # normal run never activates the recorder (pending the external WD4
1949
+ # corpus FP gate — see ADR-24 § "Slice 4"); a project opts in via
1950
+ # `severity_overrides:`.
1951
+ def self_undefined_rule_active?
1952
+ return @self_undefined_rule_active unless @self_undefined_rule_active.nil?
1953
+
1954
+ rule = CheckRules::RULE_SELF_UNDEFINED_METHOD
1955
+ @self_undefined_rule_active =
1956
+ if @configuration.disabled_rules.include?(rule) || @configuration.disabled_rules.include?("call")
1957
+ false
1958
+ else
1959
+ Configuration::SeverityProfile.resolve(
1960
+ rule: rule, authored_severity: :warning,
1961
+ profile: @configuration.severity_profile, overrides: @configuration.severity_overrides
1962
+ ) != :off
1963
+ end
1964
+ end
1965
+
1569
1966
  # v0.0.2 #10 — fail-soft fallback explanation. When
1570
1967
  # `--explain` is set the runner additionally walks the
1571
1968
  # file with `Rigor::Inference::CoverageScanner` and emits