rigortype 0.2.9 → 0.3.1

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 (222) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/builtins/ruby_core/array.yml +416 -392
  4. data/data/builtins/ruby_core/file.yml +42 -42
  5. data/data/builtins/ruby_core/hash.yml +302 -302
  6. data/data/builtins/ruby_core/io.yml +191 -191
  7. data/data/builtins/ruby_core/numeric.yml +321 -366
  8. data/data/builtins/ruby_core/proc.yml +124 -124
  9. data/data/builtins/ruby_core/range.yml +21 -21
  10. data/data/builtins/ruby_core/rational.yml +39 -39
  11. data/data/builtins/ruby_core/re.yml +65 -65
  12. data/data/builtins/ruby_core/set.yml +106 -106
  13. data/data/builtins/ruby_core/struct.yml +14 -14
  14. data/data/core_overlay/string_scanner.rbs +6 -5
  15. data/docs/handbook/01-getting-started.md +22 -34
  16. data/docs/handbook/03-narrowing.md +2 -1
  17. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  18. data/docs/handbook/06-classes.md +19 -10
  19. data/docs/handbook/07-rbs-and-extended.md +76 -101
  20. data/docs/handbook/08-understanding-errors.md +114 -246
  21. data/docs/handbook/09-plugins.md +54 -144
  22. data/docs/handbook/11-sig-gen.md +11 -1
  23. data/docs/handbook/README.md +5 -3
  24. data/docs/handbook/appendix-liskov.md +4 -2
  25. data/docs/handbook/appendix-mypy.md +4 -3
  26. data/docs/handbook/appendix-phpstan.md +14 -7
  27. data/docs/handbook/appendix-steep.md +4 -2
  28. data/docs/handbook/appendix-type-theory.md +3 -1
  29. data/docs/install.md +14 -6
  30. data/docs/manual/01-installation.md +98 -4
  31. data/docs/manual/02-cli-reference.md +81 -12
  32. data/docs/manual/03-configuration.md +34 -2
  33. data/docs/manual/04-diagnostics.md +59 -4
  34. data/docs/manual/06-baseline.md +35 -1
  35. data/docs/manual/07-plugins.md +4 -4
  36. data/docs/manual/08-skills.md +6 -1
  37. data/docs/manual/09-editor-integration.md +7 -6
  38. data/docs/manual/11-ci.md +51 -0
  39. data/docs/manual/12-caching.md +65 -0
  40. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  41. data/docs/manual/plugins/rigor-devise.md +4 -2
  42. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  43. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  44. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  45. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  46. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  47. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  48. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  49. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  50. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  51. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  52. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  53. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +112 -0
  54. data/lib/rigor/analysis/check_rules.rb +581 -98
  55. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  56. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  57. data/lib/rigor/analysis/incremental_session.rb +456 -51
  58. data/lib/rigor/analysis/path_expansion.rb +42 -0
  59. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  60. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  61. data/lib/rigor/analysis/run_cache_key.rb +78 -0
  62. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  63. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  64. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  65. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  66. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  67. data/lib/rigor/analysis/runner.rb +344 -68
  68. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  69. data/lib/rigor/analysis/worker_session.rb +30 -2
  70. data/lib/rigor/bleeding_edge.rb +37 -8
  71. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  72. data/lib/rigor/cache/descriptor.rb +88 -28
  73. data/lib/rigor/cache/file_digest.rb +158 -0
  74. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  75. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  76. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  77. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  78. data/lib/rigor/cache/store.rb +132 -27
  79. data/lib/rigor/ci_detector.rb +90 -0
  80. data/lib/rigor/cli/annotate_command.rb +15 -9
  81. data/lib/rigor/cli/baseline_command.rb +4 -1
  82. data/lib/rigor/cli/check_command.rb +223 -32
  83. data/lib/rigor/cli/check_invocation.rb +84 -0
  84. data/lib/rigor/cli/coverage_command.rb +6 -1
  85. data/lib/rigor/cli/docs_command.rb +0 -30
  86. data/lib/rigor/cli/doctor_command.rb +169 -31
  87. data/lib/rigor/cli/lsp_command.rb +5 -0
  88. data/lib/rigor/cli/mcp_command.rb +5 -0
  89. data/lib/rigor/cli/plugins_command.rb +36 -6
  90. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  91. data/lib/rigor/cli/probe_environment.rb +85 -0
  92. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  93. data/lib/rigor/cli/skill_command.rb +22 -33
  94. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  95. data/lib/rigor/cli/skill_describe.rb +75 -9
  96. data/lib/rigor/cli/trace_command.rb +5 -6
  97. data/lib/rigor/cli/type_of_command.rb +9 -8
  98. data/lib/rigor/cli/type_scan_command.rb +8 -8
  99. data/lib/rigor/cli.rb +15 -4
  100. data/lib/rigor/config_audit.rb +48 -2
  101. data/lib/rigor/configuration/severity_profile.rb +31 -0
  102. data/lib/rigor/configuration.rb +156 -8
  103. data/lib/rigor/environment/default_libraries.rb +36 -0
  104. data/lib/rigor/environment/rbs_loader.rb +164 -5
  105. data/lib/rigor/environment.rb +29 -28
  106. data/lib/rigor/inference/budget_trace.rb +77 -2
  107. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  108. data/lib/rigor/inference/def_handle.rb +23 -0
  109. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  110. data/lib/rigor/inference/def_return_typer.rb +2 -1
  111. data/lib/rigor/inference/expression_typer.rb +361 -113
  112. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  113. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  114. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  115. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  116. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +17 -1
  117. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  118. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  119. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  120. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  121. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  122. data/lib/rigor/inference/mutation_widening.rb +42 -8
  123. data/lib/rigor/inference/narrowing.rb +127 -17
  124. data/lib/rigor/inference/parameter_inference_collector.rb +15 -0
  125. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  126. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  127. data/lib/rigor/inference/scope_indexer.rb +451 -36
  128. data/lib/rigor/inference/statement_evaluator.rb +64 -15
  129. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  130. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  131. data/lib/rigor/inference/void_origin.rb +25 -0
  132. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  133. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  134. data/lib/rigor/language_server/buffer_table.rb +46 -6
  135. data/lib/rigor/language_server/completion_provider.rb +2 -1
  136. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -0
  137. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  138. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  139. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  140. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  141. data/lib/rigor/language_server/server.rb +19 -9
  142. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  143. data/lib/rigor/language_server.rb +1 -0
  144. data/lib/rigor/plugin/base.rb +38 -24
  145. data/lib/rigor/plugin/inflector.rb +12 -3
  146. data/lib/rigor/plugin/io_boundary.rb +5 -1
  147. data/lib/rigor/plugin/isolation.rb +81 -11
  148. data/lib/rigor/plugin/load_error.rb +10 -1
  149. data/lib/rigor/plugin/loader.rb +96 -14
  150. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  151. data/lib/rigor/plugin/registry.rb +32 -23
  152. data/lib/rigor/plugin.rb +26 -0
  153. data/lib/rigor/protection/mutator.rb +3 -2
  154. data/lib/rigor/reflection.rb +64 -0
  155. data/lib/rigor/runtime/jit.rb +128 -0
  156. data/lib/rigor/scope/discovery_index.rb +12 -2
  157. data/lib/rigor/scope.rb +122 -28
  158. data/lib/rigor/sig_gen/classification.rb +5 -1
  159. data/lib/rigor/sig_gen/generator.rb +38 -6
  160. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  161. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  162. data/lib/rigor/sig_gen/renderer.rb +10 -0
  163. data/lib/rigor/sig_gen/write_result.rb +9 -4
  164. data/lib/rigor/sig_gen/writer.rb +207 -49
  165. data/lib/rigor/source/node_children.rb +116 -0
  166. data/lib/rigor/source/node_locator.rb +3 -1
  167. data/lib/rigor/source/node_walker.rb +4 -2
  168. data/lib/rigor/source.rb +1 -0
  169. data/lib/rigor/type/difference.rb +28 -24
  170. data/lib/rigor/type/hash_shape.rb +34 -10
  171. data/lib/rigor/version.rb +1 -1
  172. data/lib/rigortype.rb +24 -0
  173. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  174. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  175. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  176. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  177. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  178. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +3 -1
  179. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -2
  180. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  181. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  182. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  183. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  184. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  185. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  186. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  187. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  188. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  189. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  190. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +3 -1
  191. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  192. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +7 -5
  193. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  194. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  195. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  196. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  197. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  198. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  199. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  200. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  201. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  202. data/sig/prism_node_children.rbs +9 -0
  203. data/sig/rigor/cache.rbs +6 -0
  204. data/sig/rigor/inference/void_origin.rbs +18 -0
  205. data/sig/rigor/inference.rbs +7 -0
  206. data/sig/rigor/plugin/base.rbs +6 -6
  207. data/sig/rigor/reflection.rbs +1 -0
  208. data/sig/rigor/scope.rbs +12 -1
  209. data/skills/rigor-ci-setup/SKILL.md +10 -0
  210. data/skills/rigor-editor-setup/SKILL.md +11 -0
  211. data/skills/rigor-next-steps/SKILL.md +23 -2
  212. data/skills/rigor-plugin-author/SKILL.md +3 -3
  213. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  214. data/skills/rigor-plugin-review/SKILL.md +2 -2
  215. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  216. data/skills/rigor-project-init/SKILL.md +29 -3
  217. data/skills/rigor-project-init/references/01-detect.md +1 -1
  218. data/skills/rigor-project-init/references/02-configure.md +39 -5
  219. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  220. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  221. metadata +48 -26
  222. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -8,6 +8,9 @@ require_relative "../environment"
8
8
  require_relative "../scope"
9
9
  require_relative "../cache/store"
10
10
  require_relative "../cache/rbs_descriptor"
11
+ require_relative "../cache/file_digest"
12
+ require_relative "run_cache_key"
13
+ require_relative "path_expansion"
11
14
  require_relative "../plugin"
12
15
  require_relative "../plugin/source_rbs_synthesis_reporter"
13
16
  require_relative "../rbs_extended/reporter"
@@ -15,6 +18,7 @@ require_relative "../rbs_extended/conformance_checker"
15
18
  require_relative "../reflection"
16
19
  require_relative "../type/combinator"
17
20
  require_relative "../inference/coverage_scanner"
21
+ require_relative "../inference/parameter_inference_collector"
18
22
  require_relative "../inference/scope_indexer"
19
23
  require_relative "../inference/synthetic_method_scanner"
20
24
  require_relative "../inference/project_patched_scanner"
@@ -40,12 +44,28 @@ require_relative "runner/diagnostic_aggregator"
40
44
  module Rigor
41
45
  module Analysis
42
46
  class Runner # rubocop:disable Metrics/ClassLength
43
- RUBY_GLOB = "**/*.rb"
44
47
  DEFAULT_CACHE_ROOT = ".rigor/cache"
45
48
 
49
+ # ADR-89 WD2 — bounds on the persisted return summaries (per-def observed call keys, and total defs
50
+ # summarised) so the incremental snapshot stays small. A def observed under more keys than the per-def
51
+ # cap keeps only the first; a def past the total cap is dropped (its symbol dependents then always
52
+ # re-check — the conservative direction).
53
+ RETURN_SUMMARY_KEYS_PER_DEF = 8
54
+ RETURN_SUMMARY_TOTAL_CAP = 4000
55
+
46
56
  attr_reader :cache_store, :plugin_registry, :dependency_source_index,
47
- :rbs_extended_reporter, :boundary_cross_reporter, :file_dependencies,
48
- :analyzed_files, :unresolved_self_calls
57
+ :rbs_extended_reporter, :boundary_cross_reporter,
58
+ :analyzed_files, :unresolved_self_calls, :seed_bundles
59
+
60
+ # ADR-46 — the per-file cross-file read records this run captured (empty unless
61
+ # `record_dependencies: true`). Sequential analysis records into `@file_dependencies` via
62
+ # {#analyze_file}; the fork pool records per-worker and marshals the records into the coordinator, so a
63
+ # pooled `--incremental` recheck refreshes the same dependency graph a sequential recheck would. A run
64
+ # is either sequential OR pooled for a given file set, so the two maps never carry the same key.
65
+ def file_dependencies
66
+ pooled = @pool_coordinator.collected_dependencies
67
+ pooled.empty? ? @file_dependencies : @file_dependencies.merge(pooled)
68
+ end
49
69
 
50
70
  # @param configuration [Rigor::Configuration]
51
71
  # @param explain [Boolean] surface fail-soft fallback events as `:info` diagnostics.
@@ -79,7 +99,8 @@ module Rigor
79
99
  cache_store: Cache::Store.new(root: DEFAULT_CACHE_ROOT),
80
100
  plugin_requirer: nil, workers: 0, collect_stats: true,
81
101
  buffer: nil, prebuilt: nil, environment: nil,
82
- record_dependencies: false, record_self_calls: false, analyze_only: nil)
102
+ record_dependencies: false, record_self_calls: false, analyze_only: nil,
103
+ seed_bundles: nil, collect_seed_bundles: false)
83
104
  @configuration = configuration
84
105
  @explain = explain
85
106
  @cache_store = enforce_read_only_cache(cache_store, buffer)
@@ -112,6 +133,13 @@ module Rigor
112
133
  # set are analyzed for diagnostics — the body tier re-analyses the affected closure and serves the
113
134
  # rest from the per-file cache. `nil` (the default) analyzes everything.
114
135
  @analyze_only = analyze_only && Set.new(analyze_only)
136
+ # ADR-85 WD2 — seed-bundle discovery. When `collect_seed_bundles`, the cross-file discovery pre-pass
137
+ # rebuilds from the prior run's per-file bundles (`@restored_seed_bundles`, re-walking only changed
138
+ # files) instead of parsing every file, and exposes the refreshed set via `#seed_bundles` for the
139
+ # session to persist. Off by default — a plain `rigor check` keeps today's parse+walk.
140
+ @collect_seed_bundles = collect_seed_bundles
141
+ @restored_seed_bundles = seed_bundles || {}
142
+ @seed_bundles = {}.freeze
115
143
  @file_dependencies = {}
116
144
  @plugin_registry = Plugin::Registry::EMPTY
117
145
  @dependency_source_index = DependencySourceInference::Index::EMPTY
@@ -130,6 +158,7 @@ module Rigor
130
158
  @project_discovered_def_nodes = {}.freeze
131
159
  @project_discovered_singleton_def_nodes = {}.freeze
132
160
  @project_discovered_def_sources = {}.freeze
161
+ @project_discovered_singleton_def_sources = {}.freeze
133
162
  @project_discovered_superclasses = {}.freeze
134
163
  @project_discovered_includes = {}.freeze
135
164
  @project_discovered_class_sources = {}.freeze
@@ -137,6 +166,16 @@ module Rigor
137
166
  @project_discovered_methods = {}.freeze
138
167
  @project_data_member_layouts = {}.freeze
139
168
  @project_struct_member_layouts = {}.freeze
169
+ # ADR-67 WD6a — the call-site parameter-inference table, populated by the opt-in pre-pass in
170
+ # `assemble_run_diagnostics` when `parameter_inference:` is enabled, then seeded onto every per-file
171
+ # scope (sequential + fork-worker) through `project_scope_seed_tables`. Empty by default, so the gate-off
172
+ # run carries no table and is byte-identical.
173
+ @project_param_inferred_types = {}.freeze
174
+ # ADR-84 WD2 — per-run identity token for the user-method return memo's bucket (see
175
+ # Scope::DiscoveryIndex#run_generation). Minted fresh in `run_analysis` so the memo never serves an
176
+ # entry across a run boundary (LSP re-check, ADR-62 warm loop); nil until the first run so
177
+ # runner-less probes keep the per-file fallback.
178
+ @run_generation = nil
140
179
  build_collaborators
141
180
  end
142
181
 
@@ -150,6 +189,19 @@ module Rigor
150
189
  # errors. The Environment is built once at run start through `Environment.for_project` so all files
151
190
  # share the same RBS load.
152
191
  def run(paths = @configuration.paths)
192
+ # One per-run file-digest memo spans the whole run, so a path is SHA-256'd at most once across the
193
+ # run-diagnostics dependency descriptor, its `fresh?` validation, the RBS signature tree, and every
194
+ # plugin producer's watched-glob validation (they overlap heavily on the warm path). The nested ADR-85
195
+ # WD3 memo yields one stable `Prism::DefNode` per resolved bundle handle for the run (both are no-ops
196
+ # outside their respective consumers — an empty thread-local table).
197
+ # ADR-87 WD1 — `cache.validation` (or the RIGOR_STRICT_VALIDATION env, which wins) selects the
198
+ # freshness path for this run; the `auto` default resolves strict in CI (#190), stat-first elsewhere.
199
+ Cache::FileDigest.with_run(strict: @configuration.cache_validation_strict?) do
200
+ Inference::DefNodeResolver.with_run { run_analysis(paths) }
201
+ end
202
+ end
203
+
204
+ def run_analysis(paths)
153
205
  Inference::MethodDispatcher::FileFolding.fold_platform_specific_paths =
154
206
  @configuration.fold_platform_specific_paths
155
207
 
@@ -160,6 +212,12 @@ module Rigor
160
212
 
161
213
  expansion = expand_paths(paths)
162
214
  @snapshots.reset_for_run
215
+ # Per-run reset of the deferred-discovery memo (see `#ensure_project_discovery`).
216
+ @project_discovery_done = false
217
+ # ADR-84 WD2 — roll the return-memo bucket: a fresh frozen token per run makes every per-file scope
218
+ # of THIS run share one memo bucket while entries from any earlier run in this process (stale after
219
+ # an edit) become unreachable.
220
+ @run_generation = Object.new.freeze
163
221
 
164
222
  if @prebuilt
165
223
  adopt_prebuilt_project_scan(@prebuilt)
@@ -167,6 +225,11 @@ module Rigor
167
225
  run_project_pre_passes(expansion: expansion)
168
226
  end
169
227
 
228
+ # The recording / subset (ADR-46) modes read the discovery tables outside the analysis assembly, so
229
+ # they force the build eagerly here (matching pre-slice-1 timing); every other mode defers it to the
230
+ # miss path so a warm cache HIT skips the two whole-project parse passes entirely.
231
+ ensure_project_discovery(expansion) if force_eager_discovery?
232
+
170
233
  diagnostics = compute_run_diagnostics(expansion)
171
234
 
172
235
  Result.new(
@@ -207,7 +270,7 @@ module Rigor
207
270
  # edges are NOT inverted here: they feed the structural tier (slice 3), which re-checks a consumer
208
271
  # when a name it looked up and did not resolve later appears.
209
272
  def file_dependents
210
- Incremental.invert(@file_dependencies.transform_values(&:sources))
273
+ Incremental.invert(file_dependencies.transform_values(&:sources))
211
274
  end
212
275
 
213
276
  # ADR-46 slice 4 — per-symbol body fingerprints, computed from the project pre-pass def index. Returns
@@ -219,18 +282,35 @@ module Rigor
219
282
  # frozen hash before the first run.
220
283
  def symbol_fingerprints
221
284
  result = Hash.new { |h, k| h[k] = {} }
222
- @project_discovered_def_sources.each do |class_name, methods|
285
+ collect_symbol_fingerprints(result, @project_discovered_def_sources, @project_discovered_def_nodes, "#")
286
+ # ADR-46 slice 4 (singleton) — class/singleton-method bodies live in the parallel singleton tables the
287
+ # instance loop never read (recon S5). Fingerprint them under a `"Class.method"` key (the format the
288
+ # `singleton_def_for` dependency edge uses) so a `def self.x` body edit produces a changed pair.
289
+ collect_symbol_fingerprints(result, @project_discovered_singleton_def_sources,
290
+ @project_discovered_singleton_def_nodes, ".")
291
+ result.transform_values(&:freeze).freeze
292
+ end
293
+
294
+ # Folds one def-source/def-node table pair into the per-file fingerprint map under `separator` (`#`
295
+ # instance, `.` singleton). `sources` supplies the `"path:line"` (a `Prism::Location` hides its file);
296
+ # the node supplies the body fingerprint.
297
+ def collect_symbol_fingerprints(result, sources, nodes, separator)
298
+ sources.each do |class_name, methods|
223
299
  methods.each do |method_sym, path_line|
224
300
  path = path_line.split(":", 2).first
225
- node = @project_discovered_def_nodes.dig(class_name, method_sym)
301
+ node = nodes.dig(class_name, method_sym)
226
302
  next unless node
227
303
 
228
- result[path]["#{class_name}##{method_sym}"] =
229
- Digest::SHA256.hexdigest(node.location.slice)
304
+ # ADR-85 WD3 — on the incremental warm path an unchanged file's def is a `DefHandle` carrying the
305
+ # slice fingerprint captured when its bundle was built (no re-parse); a live node (cold / re-walked
306
+ # file) is sliced as before. This is the only value-deref consumer of the def-node table besides the
307
+ # three accessor choke points.
308
+ result[path]["#{class_name}#{separator}#{method_sym}"] =
309
+ node.is_a?(Inference::DefHandle) ? node.fingerprint : Digest::SHA256.hexdigest(node.location.slice)
230
310
  end
231
311
  end
232
- result.transform_values(&:freeze).freeze
233
312
  end
313
+ private :collect_symbol_fingerprints
234
314
 
235
315
  # ADR-46 slice 3 — per-file set of the qualified class/module names declared in that file. Used to
236
316
  # detect a class that *appeared* in an edit so a subclass whose ancestor was previously undefined
@@ -244,6 +324,113 @@ module Rigor
244
324
  result.transform_values(&:freeze).freeze
245
325
  end
246
326
 
327
+ # ADR-89 WD2 — the per-method observed-key return summaries the run's ADR-84 return memo just captured.
328
+ # For each project method with live memo entries, a bounded `{ keys:, returns:, effects: }` summary the
329
+ # incremental session persists: `keys` the observed `[receiver, arg_types]` type tuples, `returns` their
330
+ # `describe(:short)` descriptors, `effects` the content-mutated parameter positions (a caller-visible
331
+ # arg-flooring surface). Only meaningful right after a run populated the memo (baseline / recheck),
332
+ # before the bucket rolls. Keys are held live here; the session Marshals them for the snapshot.
333
+ #
334
+ # @return [Hash] `{ [path, "Class#method" | "Class.method"] => { keys:, returns:, effects: } }`.
335
+ def return_summaries
336
+ memo = Inference::ExpressionTyper.harvest_return_memo
337
+ return {} if memo.empty?
338
+
339
+ result = {}
340
+ effects_evaluator = Inference::StatementEvaluator.new(scope: Scope.empty)
341
+ collect_return_summaries(result, @project_discovered_def_nodes, @project_discovered_def_sources,
342
+ "#", memo, effects_evaluator)
343
+ collect_return_summaries(result, @project_discovered_singleton_def_nodes,
344
+ @project_discovered_singleton_def_sources, ".", memo, effects_evaluator)
345
+ result
346
+ end
347
+
348
+ # Folds one def-node/def-source table pair's memo entries into the return-summary result under
349
+ # `separator`. A live def node (a cold / re-walked file) is the identity every cross-file caller
350
+ # resolved through, so `memo[node]` holds those callers' observed keys; a {DefHandle} (an unchanged
351
+ # file on the warm path) never keyed a memo entry, so it is skipped (its summary is carried over from
352
+ # the snapshot). Bounded per def and in total so the snapshot stays small.
353
+ def collect_return_summaries(result, nodes, sources, separator, memo, effects_evaluator)
354
+ nodes.each do |class_name, methods|
355
+ methods.each do |method_name, node|
356
+ next if node.is_a?(Inference::DefHandle)
357
+
358
+ entries = memo[node]
359
+ next if entries.nil? || entries.empty?
360
+
361
+ path_line = sources.dig(class_name, method_name)
362
+ next if path_line.nil?
363
+
364
+ break if result.size >= RETURN_SUMMARY_TOTAL_CAP
365
+
366
+ capped = entries.first(RETURN_SUMMARY_KEYS_PER_DEF)
367
+ result[[path_line.split(":", 2).first, "#{class_name}#{separator}#{method_name}"]] = {
368
+ keys: capped.map { |entry| [entry.receiver, entry.arg_types] },
369
+ returns: capped.map { |entry| entry.result.describe(:short) },
370
+ effects: content_effects(effects_evaluator, node)
371
+ }
372
+ end
373
+ end
374
+ end
375
+
376
+ # The content-mutated parameter positions of `node`, or `[]` if the computation raises (defensive: the
377
+ # effects surface only routes attention, never soundness — a missing set reads as "no floor").
378
+ def content_effects(effects_evaluator, node)
379
+ effects_evaluator.content_mutated_parameter_positions(node)
380
+ rescue StandardError
381
+ []
382
+ end
383
+
384
+ # ADR-89 WD2 — re-evaluate the return type of specific project methods at previously-observed call keys,
385
+ # WITHOUT analyzing any file. The incremental session calls this session-side (before dispatching the
386
+ # recheck) to prove a declaration-stable, changed callee returns the same type at every key its baseline
387
+ # callers used, so those callers' re-analysis can be skipped. Builds the cross-file discovery index (the
388
+ # ADR-85 seed-bundle fold, re-walking only edited files) and the RBS environment (served from the cache
389
+ # store) exactly as a real run's setup does, then re-drives each spec's def through the ADR-84 return
390
+ # memo. Off any hot path — invoked only when declaration-stable changed pairs carry a persisted summary.
391
+ #
392
+ # @param paths [Array<String>, nil] analysis roots (nil → the configuration's `paths:`).
393
+ # @param specs [Array<Hash>] each `{ class_name:, method_name:, singleton:, keys: [[receiver, args], …] }`.
394
+ # @return [Hash] `{ [class_name, method_name, singleton] => [return_descriptor_or_nil, …] }`.
395
+ def evaluate_return_types(paths, specs)
396
+ return {} if specs.empty?
397
+
398
+ Cache::FileDigest.with_run(strict: @configuration.cache_validation_strict?) do
399
+ Inference::DefNodeResolver.with_run { evaluate_return_types_setup(paths, specs) }
400
+ end
401
+ end
402
+
403
+ # Builds the discovery index + environment (a real run's prologue, minus per-file analysis) and
404
+ # re-evaluates each spec's def. Extracted so {#evaluate_return_types}'s `with_run` wrappers stay thin.
405
+ def evaluate_return_types_setup(paths, specs)
406
+ expansion = expand_paths(paths || @configuration.paths)
407
+ @project_discovery_done = false
408
+ @run_generation = Object.new.freeze
409
+ run_project_pre_passes(expansion: expansion)
410
+ ensure_project_discovery(expansion)
411
+ environment = @pool_coordinator.resolve_sequential_environment(source_files: target_files(expansion))
412
+ specs.to_h do |spec|
413
+ [[spec[:class_name], spec[:method_name], spec[:singleton]], evaluate_spec_returns(spec, environment)]
414
+ end
415
+ end
416
+
417
+ # Re-evaluates one spec's def at each of its observed keys. Locates the (live) def node in the discovery
418
+ # index, builds a project-seeded scope, and returns one return descriptor per key (nil when the def is
419
+ # missing / bodyless or the memo refuses a transient result — the caller reads either as "not provably
420
+ # stable" and keeps the dependents).
421
+ def evaluate_spec_returns(spec, environment)
422
+ table = spec[:singleton] ? @project_discovered_singleton_def_nodes : @project_discovered_def_nodes
423
+ node = table.dig(spec[:class_name], spec[:method_name])
424
+ node = Inference::DefNodeResolver.resolve(node) if node.is_a?(Inference::DefHandle)
425
+ return spec[:keys].map { nil } if node.nil?
426
+
427
+ scope = seed_project_scope(Scope.empty(environment: environment, source_path: spec[:path]))
428
+ spec[:keys].map do |(receiver, arg_types)|
429
+ result = scope.user_method_return(node, receiver, arg_types)
430
+ result&.describe(:short)
431
+ end
432
+ end
433
+
247
434
  # ADR-45 — unchanged-project fast path. Serves the whole run's (pre-severity-profile) diagnostics
248
435
  # from one record-and-validate cache entry when every file the previous run read is unchanged,
249
436
  # skipping the dominant per-file inference. The dependency set is collected AFTER the run (so it
@@ -255,13 +442,20 @@ module Rigor
255
442
  return assemble_run_diagnostics(expansion) unless run_result_cacheable?
256
443
 
257
444
  environment = @pool_coordinator.resolve_sequential_environment(source_files: target_files(expansion))
258
- rbs_descriptor = environment&.rbs_loader ? Cache::RbsDescriptor.build(environment.rbs_loader) : Cache::Descriptor.new
445
+ # Lazy-files descriptor: the cache KEY reads only `gems` + `configs`; the RBS signature-tree `files`
446
+ # are digested solely by `run_dependency_descriptor` on a MISS, so a warm HIT never walks the tree.
447
+ rbs_descriptor = if environment&.rbs_loader
448
+ Cache::RbsDescriptor.build_run(environment.rbs_loader)
449
+ else
450
+ Cache::Descriptor.new
451
+ end
259
452
  key_descriptor = run_key_descriptor(expansion, rbs_descriptor)
260
453
  return assemble_run_diagnostics(expansion, environment: environment) if key_descriptor.nil?
261
454
 
262
455
  computed = false
263
456
  diagnostics = @cache_store.fetch_or_validate(
264
- producer_id: "analysis.run-diagnostics", key_descriptor: key_descriptor
457
+ producer_id: RunCacheKey::RUN_DIAGNOSTICS_PRODUCER_ID, key_descriptor: key_descriptor,
458
+ generation_cap: RunCacheKey::GENERATION_CAP
265
459
  ) do
266
460
  computed = true
267
461
  diags = assemble_run_diagnostics(expansion, environment: environment)
@@ -277,6 +471,17 @@ module Rigor
277
471
  end
278
472
 
279
473
  def assemble_run_diagnostics(expansion, environment: nil)
474
+ # Force the deferred cross-file discovery pre-pass on the analysis (miss) path. Memoised, so the
475
+ # eager force in `#run` (recording / subset modes) makes this a no-op. A warm cache HIT never calls
476
+ # `assemble_run_diagnostics`, so it never runs the two whole-project parse passes. Runs over the FULL
477
+ # expansion — subset (`analyze_only`) mode still needs the complete cross-file index (ADR-46 §2).
478
+ ensure_project_discovery(expansion)
479
+ # ADR-67 WD6a — the opt-in call-site parameter-inference pre-pass. Runs on the parent BEFORE the pool
480
+ # split (so every worker sees the same frozen table — the seed-before-fork determinism the discovery
481
+ # tables use) and only on the analysis (miss / non-cacheable) path (a warm ADR-45 cache HIT never
482
+ # assembles). Gate off → no-op, and `environment` is left untouched so its lazy build timing is
483
+ # unchanged. Returns the resolved environment so the sequential dispatch reuses it (no double build).
484
+ environment = seed_parameter_inference(expansion, environment)
280
485
  diagnostics = @diagnostic_aggregator.pre_file_diagnostics(expansion)
281
486
  # ADR-46 — record which project files this run actually analyzed (the `analyze_only` subset, or
282
487
  # all of them). The incremental orchestrator serves every analyzed-but-not-affected file from the
@@ -284,6 +489,8 @@ module Rigor
284
489
  targets = target_files(expansion)
285
490
  @analyzed_files = targets
286
491
  diagnostics += @pool_coordinator.analyze_files(targets, environment: environment)
492
+ diagnostics += @diagnostic_aggregator.rbs_quarantined_signature_diagnostics
493
+ diagnostics += @diagnostic_aggregator.rbs_environment_build_failed_diagnostics
287
494
  diagnostics += @diagnostic_aggregator.rbs_synthesized_namespace_diagnostics
288
495
  diagnostics += @diagnostic_aggregator.conforms_to_diagnostics
289
496
  diagnostics += @diagnostic_aggregator.rbs_extended_reporter_diagnostics
@@ -291,6 +498,31 @@ module Rigor
291
498
  diagnostics + @diagnostic_aggregator.source_rbs_synthesis_diagnostics
292
499
  end
293
500
 
501
+ # ADR-67 WD6a — the check-walk parameter-inference pre-pass. Populates `@project_param_inferred_types`
502
+ # (read by `project_scope_seed_tables`) with the call-site union of every undeclared parameter, running
503
+ # ONE round (a single hop of call-site → param typing; the protection scan's three-round fixpoint stays a
504
+ # protection-surface luxury until measured). No-op unless `parameter_inference:` is enabled, so the
505
+ # default run pays exactly nothing here and `environment` passes through unchanged (preserving the lazy
506
+ # env-build timing). When enabled, it resolves the environment once — the collector types call-site
507
+ # arguments against the same RBS / plugin surface the check uses — and returns it so the sequential
508
+ # dispatch reuses that build. The whole-project file set (not the `analyze_only` subset) is scanned: the
509
+ # inference is cross-file (a call site in one file types a parameter in another). Fails soft — a collector
510
+ # error must never break a run, so the table stays empty and the run proceeds unseeded.
511
+ def seed_parameter_inference(expansion, environment)
512
+ return environment unless @configuration.parameter_inference
513
+
514
+ files = expansion.fetch(:files)
515
+ environment ||= @pool_coordinator.resolve_sequential_environment(source_files: files)
516
+ @project_param_inferred_types = Inference::ParameterInferenceCollector.collect(
517
+ files: files, environment: environment,
518
+ target_ruby: @configuration.target_ruby, max_rounds: 1, workers: @workers
519
+ )
520
+ environment
521
+ rescue StandardError
522
+ @project_param_inferred_types = {}.freeze
523
+ environment
524
+ end
525
+
294
526
  # A cache hit skipped the analysis, so the per-run stats (wall split, RBS-class counts, …) were never
295
527
  # gathered — report none rather than the stale snapshot defaults.
296
528
  def stats_for_run(wall_started_at:, expansion:)
@@ -303,9 +535,18 @@ module Rigor
303
535
  # Cacheable only for a full sequential project run with a writable cache and no per-buffer /
304
536
  # prebuilt override — every other mode has a different result identity (pool workers read in
305
537
  # separate processes; editor mode is per-buffer; prebuilt is the LSP path).
538
+ #
539
+ # The ADR-46 incremental modes are excluded too, now that they carry a real cache store (ADR-85
540
+ # WD1): a `record_dependencies` run MUST perform per-file analysis to capture the dependency
541
+ # graph (a cache-served run records nothing, leaving the next recheck's dependents empty —
542
+ # unsound), and an `analyze_only` subset run produces intentionally partial diagnostics that
543
+ # share the full run's result key (`run_key_descriptor` keys on the whole expansion, not the
544
+ # subset), so serving one as the other would manufacture a wrong result. Both instead use the
545
+ # store for the RBS-env + plugin-producer tiers, where the incremental win actually lives.
306
546
  def run_result_cacheable?
307
547
  !@cache_store.nil? && !@cache_store.read_only? &&
308
- @buffer.nil? && @prebuilt.nil? && !pool_mode?
548
+ @buffer.nil? && @prebuilt.nil? && !pool_mode? &&
549
+ !@record_dependencies && @analyze_only.nil?
309
550
  end
310
551
 
311
552
  # Stable cache key inputs — known before the run: a digest of the resolved configuration, the engine
@@ -313,16 +554,13 @@ module Rigor
313
554
  # editing one is caught by dependency validation). nil disables the cache for this run rather than
314
555
  # risking a malformed key.
315
556
  def run_key_descriptor(expansion, rbs_descriptor)
316
- Cache::Descriptor.new(
317
- gems: rbs_descriptor.gems,
318
- configs: rbs_descriptor.configs + [
319
- config_hash_entry("configuration", Marshal.dump(@configuration.to_h)),
320
- config_hash_entry("engine", "#{Rigor::VERSION}:#{Cache::Descriptor::SCHEMA_VERSION}:#{@explain}"),
321
- config_hash_entry("paths", expansion.fetch(:files).sort.join("\n"))
322
- ]
557
+ # ADR-87 WD4 — the key is built through the shared {RunCacheKey} builder the boot-slimming probe also
558
+ # uses, so the miss path (here, passing the loader's `rbs_descriptor.configs`) and the hit path (which
559
+ # reconstructs `rbs.libraries` from config) can never drift out of key agreement.
560
+ RunCacheKey.descriptor(
561
+ configuration: @configuration, files: expansion.fetch(:files),
562
+ explain: @explain, rbs_config_entries: rbs_descriptor.configs
323
563
  )
324
- rescue StandardError
325
- nil
326
564
  end
327
565
 
328
566
  # Files the run actually depended on, collected AFTER it ran: every analyzed file, every RBS `sig`
@@ -343,16 +581,12 @@ module Rigor
343
581
  def analyzed_file_entries(expansion)
344
582
  expansion.fetch(:files).map do |path|
345
583
  physical = @buffer ? @buffer.resolve(path) : path
346
- Cache::Descriptor::FileEntry.new(
347
- path: physical, comparator: :digest, value: Digest::SHA256.file(physical).hexdigest
348
- )
584
+ # ADR-87 WD1 — validation-only dependency descriptor, so the stat-then-digest `:stat` comparator
585
+ # applies (the env-cache KEY files stay `:digest`).
586
+ Cache::Descriptor::FileEntry.stat(path: physical, digest: Cache::FileDigest.hexdigest(physical))
349
587
  end
350
588
  end
351
589
 
352
- def config_hash_entry(key, payload)
353
- Cache::Descriptor::ConfigEntry.new(key: key, value_hash: Digest::SHA256.hexdigest(payload))
354
- end
355
-
356
590
  # Runs every project-wide pre-pass (`load_plugins` +
357
591
  # `plugin#prepare` + dependency-source builder +
358
592
  # synthetic-method scanner + project-patched scanner)
@@ -399,35 +633,73 @@ module Rigor
399
633
  apply_pre_passes_result(@pre_passes.adopt_prebuilt(scan))
400
634
  end
401
635
 
402
- # Internal: copies a {ProjectPrePasses::Result} bundle onto the runner's ivars in the assignment
403
- # order the original inline pre-pass body used, so every downstream reader (per-file analysis seed,
404
- # pool environment build, diagnostic aggregator) sees the same ivar surface. The prebuilt path leaves
405
- # the discovery tables at their frozen-empty constructor defaults (the bundle carries `nil` for them,
406
- # matching the original adopt path that never touched them).
407
- def apply_pre_passes_result(result) # rubocop:disable Metrics/AbcSize
636
+ # Internal: copies a {ProjectPrePasses::Result} bundle's EAGER (env-input) slots onto the runner's
637
+ # ivars, so every downstream reader (pool environment build, diagnostic aggregator) sees the same ivar
638
+ # surface. The cross-file discovery tables are NOT carried here `#run` (prebuilt-less) and
639
+ # `adopt_prebuilt` both leave them at their frozen-empty constructor defaults, and the analysis path
640
+ # fills them lazily via {#ensure_project_discovery}. The prebuilt (LSP) path never fills them, matching
641
+ # the original adopt behaviour that seeded an empty project scope.
642
+ def apply_pre_passes_result(result)
408
643
  @plugin_registry = result.plugin_registry
409
644
  @dependency_source_index = result.dependency_source_index
410
645
  @cached_plugin_prepare_diagnostics = result.cached_plugin_prepare_diagnostics
411
646
  @synthetic_method_index = result.synthetic_method_index
412
647
  @project_patched_methods = result.project_patched_methods
413
648
  @pre_eval_diagnostics_from_scanner = result.pre_eval_diagnostics_from_scanner
414
- @project_discovered_classes = result.discovered_classes if result.discovered_classes
415
- @project_discovered_def_nodes = result.discovered_def_nodes if result.discovered_def_nodes
416
- if result.discovered_singleton_def_nodes
417
- @project_discovered_singleton_def_nodes = result.discovered_singleton_def_nodes
418
- end
419
- @project_discovered_def_sources = result.discovered_def_sources if result.discovered_def_sources
420
- @project_discovered_superclasses = result.discovered_superclasses if result.discovered_superclasses
421
- @project_discovered_includes = result.discovered_includes if result.discovered_includes
422
- @project_discovered_class_sources = result.discovered_class_sources if result.discovered_class_sources
423
- if result.discovered_method_visibilities
424
- @project_discovered_method_visibilities = result.discovered_method_visibilities
649
+ end
650
+
651
+ # Internal: adopts a {ProjectPrePasses::Discovery} bundle (the two whole-project discovery passes)
652
+ # onto the runner's discovery ivars, in the same assignment order the original inline pre-pass used.
653
+ # Called only from {#ensure_project_discovery}.
654
+ def apply_discovery_result(discovery)
655
+ @project_discovered_classes = discovery.discovered_classes
656
+ @project_discovered_def_nodes = discovery.discovered_def_nodes
657
+ @project_discovered_singleton_def_nodes = discovery.discovered_singleton_def_nodes
658
+ @project_discovered_def_sources = discovery.discovered_def_sources
659
+ @project_discovered_singleton_def_sources = discovery.discovered_singleton_def_sources
660
+ @project_discovered_superclasses = discovery.discovered_superclasses
661
+ @project_discovered_includes = discovery.discovered_includes
662
+ @project_discovered_class_sources = discovery.discovered_class_sources
663
+ @project_discovered_method_visibilities = discovery.discovered_method_visibilities
664
+ @project_discovered_methods = discovery.discovered_methods
665
+ @project_data_member_layouts = discovery.data_member_layouts
666
+ @project_struct_member_layouts = discovery.struct_member_layouts
667
+ end
668
+
669
+ # Internal: builds the deferred cross-file discovery tables at most once per run and adopts them.
670
+ # Memoised on `@project_discovery_done` (reset at the start of `#run`). No-op under `@prebuilt` — the
671
+ # LSP path deliberately seeds an empty project scope from a snapshot that carries no discovery tables,
672
+ # so forcing a build there would change that contract. Called eagerly from `#run` for the recording /
673
+ # subset (ADR-46) modes and lazily from `#assemble_run_diagnostics` on the analysis path, so a warm
674
+ # cache HIT (which never assembles) never pays the double parse.
675
+ def ensure_project_discovery(expansion)
676
+ return if @prebuilt
677
+ return if @project_discovery_done
678
+
679
+ @project_discovery_done = true
680
+ if @collect_seed_bundles
681
+ # ADR-85 WD2 — rebuild discovery from the prior run's bundles (re-walking only changed files) and
682
+ # capture the refreshed bundle set for the session to persist.
683
+ discovery, @seed_bundles = @pre_passes.discover_from_bundles(
684
+ expansion: expansion, seed_bundles: @restored_seed_bundles
685
+ )
686
+ apply_discovery_result(discovery)
687
+ else
688
+ apply_discovery_result(@pre_passes.discover(expansion: expansion))
425
689
  end
426
- @project_discovered_methods = result.discovered_methods if result.discovered_methods
427
- @project_data_member_layouts = result.data_member_layouts if result.data_member_layouts
428
- @project_struct_member_layouts = result.struct_member_layouts if result.struct_member_layouts
429
690
  end
430
- private :run_project_pre_passes, :adopt_prebuilt_project_scan, :apply_pre_passes_result
691
+
692
+ # ADR-46 — the dependency-recording and subset-analysis modes read the discovery tables OUTSIDE the
693
+ # analysis assembly (`Runner#symbol_fingerprints` / `#class_declarations`, consumed by
694
+ # {IncrementalSession} after the run), so they force the build eagerly — matching the pre-slice-1
695
+ # timing where discovery always ran before `compute_run_diagnostics`. Every other mode defers to the
696
+ # lazy build inside `#assemble_run_diagnostics`.
697
+ def force_eager_discovery?
698
+ @record_dependencies || !@analyze_only.nil?
699
+ end
700
+
701
+ private :run_project_pre_passes, :adopt_prebuilt_project_scan, :apply_pre_passes_result,
702
+ :apply_discovery_result, :ensure_project_discovery, :force_eager_discovery?
431
703
 
432
704
  # Ruby versions probed (ascending) to discover the lowest one this Prism build accepts for
433
705
  # `version:`. Prism exposes no version list, so the floor is found empirically — only when a
@@ -518,6 +790,7 @@ module Rigor
518
790
  @pool_coordinator = PoolCoordinator.new(
519
791
  configuration: @configuration, cache_store: @cache_store, explain: @explain,
520
792
  workers: @workers, collect_stats: @collect_stats, buffer: @buffer,
793
+ record_dependencies: @record_dependencies,
521
794
  environment_override: @environment_override,
522
795
  rbs_extended_reporter: @rbs_extended_reporter,
523
796
  boundary_cross_reporter: @boundary_cross_reporter,
@@ -541,6 +814,8 @@ module Rigor
541
814
  cached_plugin_prepare_diagnostics: -> { @cached_plugin_prepare_diagnostics },
542
815
  pre_eval_diagnostics_from_scanner: -> { @pre_eval_diagnostics_from_scanner },
543
816
  synthesized_namespaces_snapshot: -> { @snapshots.synthesized_namespaces },
817
+ quarantined_signatures_snapshot: -> { @snapshots.quarantined_signatures },
818
+ env_build_failure_snapshot: -> { @snapshots.env_build_failure },
544
819
  conformance_results_snapshot: -> { @snapshots.conformance_results }
545
820
  )
546
821
  end
@@ -678,7 +953,7 @@ module Rigor
678
953
  bad = []
679
954
  Array(paths).each do |path|
680
955
  if File.directory?(path)
681
- files.concat(reject_excluded(Dir.glob(File.join(path, RUBY_GLOB))))
956
+ files.concat(PathExpansion.directory_files(path, @configuration.exclude_patterns))
682
957
  # Editor-mode bypass: the buffer's logical path is treated as a real `.rb` file regardless of
683
958
  # on-disk presence — `parse_source` reads bytes from the buffer's physical path. Common case: LSP
684
959
  # client editing a brand-new file.
@@ -710,22 +985,6 @@ module Rigor
710
985
  @in_memory_sources&.key?(path)
711
986
  end
712
987
 
713
- # `Configuration#exclude_patterns` is a list of glob patterns checked against each globbed path via
714
- # `File.fnmatch?` (without `FNM_PATHNAME`, so `**` and `*` both span path separators — the patterns
715
- # behave like substring globs). Built-in defaults exclude `vendor/bundle`, `.bundle`, `node_modules`,
716
- # and `tmp` so the analyser never walks into vendored deps or build artefacts. User-supplied entries
717
- # (`.rigor.yml` `exclude:`) layer on top. Explicit file arguments to the CLI bypass this filter — only
718
- # the directory-glob expansion is filtered.
719
- def reject_excluded(file_list)
720
- return file_list if @configuration.exclude_patterns.empty?
721
-
722
- file_list.reject { |path| excluded?(path) }
723
- end
724
-
725
- def excluded?(path)
726
- @configuration.exclude_patterns.any? { |pattern| File.fnmatch?(pattern, path) }
727
- end
728
-
729
988
  def path_error(path, message, severity: :error)
730
989
  Diagnostic.new(
731
990
  path: path,
@@ -766,12 +1025,15 @@ module Rigor
766
1025
  # cross-file def — ADR-15 sequential-equivalence contract).
767
1026
  def project_scope_seed_tables
768
1027
  tables = {}
1028
+ # ADR-84 WD2 — the run-scope token rides the same seed so the fork/Ractor `WorkerSession` scopes
1029
+ # bucket identically to the sequential path.
1030
+ tables[:run_generation] = @run_generation if @run_generation
769
1031
  tables[:discovered_classes] = @project_discovered_classes unless @project_discovered_classes.empty?
770
1032
  tables[:discovered_def_nodes] = @project_discovered_def_nodes unless @project_discovered_def_nodes.empty?
771
1033
  unless @project_discovered_singleton_def_nodes.empty?
772
1034
  tables[:discovered_singleton_def_nodes] = @project_discovered_singleton_def_nodes
773
1035
  end
774
- tables[:discovered_def_sources] = @project_discovered_def_sources unless @project_discovered_def_sources.empty?
1036
+ seed_def_source_tables(tables)
775
1037
  unless @project_discovered_superclasses.empty?
776
1038
  tables[:discovered_superclasses] = @project_discovered_superclasses
777
1039
  end
@@ -780,6 +1042,10 @@ module Rigor
780
1042
  tables[:discovered_method_visibilities] = @project_discovered_method_visibilities
781
1043
  end
782
1044
  tables[:discovered_methods] = @project_discovered_methods unless @project_discovered_methods.empty?
1045
+ # ADR-67 WD6a — the call-site parameter-inference table rides the same seed so a pooled `WorkerSession`
1046
+ # scope seeds inferred parameters identically to the sequential path. Empty (and absent) unless the
1047
+ # `parameter_inference:` gate ran the pre-pass.
1048
+ tables[:param_inferred_types] = @project_param_inferred_types unless @project_param_inferred_types.empty?
783
1049
  seed_member_layout_tables(tables)
784
1050
  # ADR-46 slice 1 — the class-declaration source map is read only by the ancestry accessors during
785
1051
  # dependency recording, so seed it only when recording is on; a normal run never carries it.
@@ -789,6 +1055,16 @@ module Rigor
789
1055
  tables
790
1056
  end
791
1057
 
1058
+ # ADR-46 — seed the instance + singleton `"path:line"` def-source tables (each only when non-empty).
1059
+ # Extracted to keep {#project_scope_seed_tables} under the complexity budget. The singleton table (slice 4
1060
+ # extension) rides the same seed so a pooled `WorkerSession` records singleton symbol edges identically.
1061
+ def seed_def_source_tables(tables)
1062
+ tables[:discovered_def_sources] = @project_discovered_def_sources unless @project_discovered_def_sources.empty?
1063
+ return if @project_discovered_singleton_def_sources.empty?
1064
+
1065
+ tables[:discovered_singleton_def_sources] = @project_discovered_singleton_def_sources
1066
+ end
1067
+
792
1068
  # ADR-48 — seed the Data + Struct member-layout tables (each only when non-empty). Extracted to keep
793
1069
  # {#project_scope_seed_tables} under the complexity budget.
794
1070
  def seed_member_layout_tables(tables)