rigortype 0.2.8 → 0.3.0

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 (207) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/core_overlay/csv.rbs +28 -0
  4. data/data/core_overlay/psych.rbs +22 -0
  5. data/docs/handbook/01-getting-started.md +9 -1
  6. data/docs/handbook/02-everyday-types.md +4 -1
  7. data/docs/handbook/03-narrowing.md +2 -1
  8. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  9. data/docs/handbook/06-classes.md +18 -9
  10. data/docs/handbook/08-understanding-errors.md +4 -3
  11. data/docs/handbook/11-sig-gen.md +11 -1
  12. data/docs/handbook/appendix-mypy.md +4 -3
  13. data/docs/install.md +14 -6
  14. data/docs/manual/01-installation.md +99 -4
  15. data/docs/manual/02-cli-reference.md +65 -19
  16. data/docs/manual/03-configuration.md +34 -2
  17. data/docs/manual/04-diagnostics.md +23 -0
  18. data/docs/manual/07-plugins.md +5 -5
  19. data/docs/manual/09-editor-integration.md +4 -4
  20. data/docs/manual/11-ci.md +51 -0
  21. data/docs/manual/12-caching.md +65 -0
  22. data/docs/manual/14-rails-quickstart.md +4 -2
  23. data/docs/manual/15-type-protection-coverage.md +21 -0
  24. data/docs/manual/plugins/rigor-actionpack.md +1 -1
  25. data/docs/manual/plugins/rigor-activerecord.md +12 -5
  26. data/docs/manual/plugins/rigor-rails-routes.md +11 -0
  27. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  28. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  29. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  30. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  31. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  32. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  33. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  34. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  35. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  36. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  37. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  38. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +93 -0
  39. data/lib/rigor/analysis/check_rules.rb +557 -95
  40. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  41. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  42. data/lib/rigor/analysis/incremental_session.rb +456 -51
  43. data/lib/rigor/analysis/path_expansion.rb +42 -0
  44. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  45. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  46. data/lib/rigor/analysis/run_cache_key.rb +68 -0
  47. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  48. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  49. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  50. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  51. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  52. data/lib/rigor/analysis/runner.rb +343 -68
  53. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  54. data/lib/rigor/analysis/worker_session.rb +30 -2
  55. data/lib/rigor/bleeding_edge.rb +37 -8
  56. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  57. data/lib/rigor/cache/descriptor.rb +88 -28
  58. data/lib/rigor/cache/file_digest.rb +158 -0
  59. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  60. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  61. data/lib/rigor/cache/store.rb +207 -60
  62. data/lib/rigor/ci_detector.rb +90 -0
  63. data/lib/rigor/cli/annotate_command.rb +15 -9
  64. data/lib/rigor/cli/baseline_command.rb +4 -1
  65. data/lib/rigor/cli/check_command.rb +212 -27
  66. data/lib/rigor/cli/coverage_command.rb +56 -30
  67. data/lib/rigor/cli/diagnostic_formats.rb +4 -1
  68. data/lib/rigor/cli/docs_command.rb +0 -30
  69. data/lib/rigor/cli/doctor_command.rb +163 -23
  70. data/lib/rigor/cli/lsp_command.rb +5 -0
  71. data/lib/rigor/cli/mcp_command.rb +5 -0
  72. data/lib/rigor/cli/plugins_command.rb +36 -6
  73. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  74. data/lib/rigor/cli/probe_environment.rb +85 -0
  75. data/lib/rigor/cli/protection_fork_scan.rb +55 -0
  76. data/lib/rigor/cli/protection_report.rb +7 -1
  77. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  78. data/lib/rigor/cli/skill_command.rb +1 -32
  79. data/lib/rigor/cli/trace_command.rb +5 -6
  80. data/lib/rigor/cli/type_of_command.rb +9 -8
  81. data/lib/rigor/cli/type_scan_command.rb +8 -8
  82. data/lib/rigor/cli.rb +15 -4
  83. data/lib/rigor/config_audit.rb +48 -2
  84. data/lib/rigor/configuration/severity_profile.rb +31 -0
  85. data/lib/rigor/configuration.rb +156 -8
  86. data/lib/rigor/environment/default_libraries.rb +35 -0
  87. data/lib/rigor/environment/missing_gem_constant_index.rb +128 -0
  88. data/lib/rigor/environment/rbs_loader.rb +164 -5
  89. data/lib/rigor/environment.rb +71 -43
  90. data/lib/rigor/inference/budget_trace.rb +77 -2
  91. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  92. data/lib/rigor/inference/def_handle.rb +23 -0
  93. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  94. data/lib/rigor/inference/def_return_typer.rb +2 -1
  95. data/lib/rigor/inference/expression_typer.rb +379 -113
  96. data/lib/rigor/inference/fork_map.rb +87 -0
  97. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  98. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  99. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  100. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  101. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +11 -0
  102. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  103. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  104. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  105. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  106. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  107. data/lib/rigor/inference/mutation_widening.rb +42 -8
  108. data/lib/rigor/inference/narrowing.rb +245 -17
  109. data/lib/rigor/inference/parameter_inference_collector.rb +70 -10
  110. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  111. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  112. data/lib/rigor/inference/scope_indexer.rb +460 -49
  113. data/lib/rigor/inference/statement_evaluator.rb +111 -17
  114. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  115. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  116. data/lib/rigor/inference/void_origin.rb +25 -0
  117. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  118. data/lib/rigor/language_server/completion_provider.rb +2 -1
  119. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  120. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  121. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  122. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  123. data/lib/rigor/plugin/base.rb +9 -22
  124. data/lib/rigor/plugin/inflector.rb +12 -3
  125. data/lib/rigor/plugin/io_boundary.rb +5 -1
  126. data/lib/rigor/plugin/isolation.rb +81 -11
  127. data/lib/rigor/plugin/load_error.rb +10 -1
  128. data/lib/rigor/plugin/loader.rb +96 -14
  129. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  130. data/lib/rigor/plugin/registry.rb +32 -23
  131. data/lib/rigor/plugin.rb +26 -0
  132. data/lib/rigor/protection/mutator.rb +3 -2
  133. data/lib/rigor/reflection.rb +64 -0
  134. data/lib/rigor/runtime/jit.rb +128 -0
  135. data/lib/rigor/scope/discovery_index.rb +12 -2
  136. data/lib/rigor/scope.rb +122 -28
  137. data/lib/rigor/sig_gen/classification.rb +5 -1
  138. data/lib/rigor/sig_gen/generator.rb +38 -6
  139. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  140. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  141. data/lib/rigor/sig_gen/renderer.rb +10 -0
  142. data/lib/rigor/sig_gen/write_result.rb +9 -4
  143. data/lib/rigor/sig_gen/writer.rb +24 -2
  144. data/lib/rigor/source/node_children.rb +116 -0
  145. data/lib/rigor/source/node_locator.rb +3 -1
  146. data/lib/rigor/source/node_walker.rb +4 -2
  147. data/lib/rigor/source.rb +1 -0
  148. data/lib/rigor/type/difference.rb +28 -24
  149. data/lib/rigor/type/hash_shape.rb +34 -10
  150. data/lib/rigor/version.rb +1 -1
  151. data/lib/rigortype.rb +24 -0
  152. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  154. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +23 -8
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +21 -0
  157. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  158. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +13 -2
  159. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +65 -2
  160. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +20 -2
  161. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  162. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +172 -0
  163. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +22 -8
  164. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  165. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  166. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +32 -0
  167. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  168. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  169. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  170. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  171. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  172. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  173. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  174. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +191 -0
  175. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  176. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +19 -1
  177. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +48 -15
  178. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +42 -12
  179. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  180. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  181. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  182. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  183. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  184. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  185. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  186. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  187. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  188. data/sig/prism_node_children.rbs +9 -0
  189. data/sig/rigor/environment.rbs +1 -0
  190. data/sig/rigor/inference.rbs +7 -0
  191. data/sig/rigor/plugin/base.rbs +2 -3
  192. data/sig/rigor/reflection.rbs +1 -0
  193. data/sig/rigor/scope.rbs +12 -1
  194. data/skills/rigor-ci-setup/SKILL.md +10 -0
  195. data/skills/rigor-editor-setup/SKILL.md +11 -0
  196. data/skills/rigor-next-steps/SKILL.md +23 -2
  197. data/skills/rigor-plugin-author/SKILL.md +3 -3
  198. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  199. data/skills/rigor-plugin-review/SKILL.md +2 -2
  200. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  201. data/skills/rigor-project-init/SKILL.md +29 -3
  202. data/skills/rigor-project-init/references/01-detect.md +1 -1
  203. data/skills/rigor-project-init/references/02-configure.md +39 -5
  204. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  205. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  206. metadata +49 -24
  207. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../diagnostic"
4
+ require_relative "../severity_stamp"
4
5
 
5
6
  module Rigor
6
7
  module Analysis
@@ -31,11 +32,16 @@ module Rigor
31
32
  # @param cached_plugin_prepare_diagnostics [#call] reader returning the prepare-diagnostic snapshot.
32
33
  # @param pre_eval_diagnostics_from_scanner [#call] reader returning the pre-eval scanner diagnostics.
33
34
  # @param synthesized_namespaces_snapshot [#call] reader.
35
+ # @param quarantined_signatures_snapshot [#call] reader returning the `signature_paths:` files skipped
36
+ # because they do not parse (`[path, first_error_line]` pairs).
37
+ # @param env_build_failure_snapshot [#call] reader returning the total RBS env-build failure tuple
38
+ # (`[error_class, first_error_line, conflicting_buffer_names]`) or nil when the env built.
34
39
  # @param conformance_results_snapshot [#call] reader.
35
40
  def initialize(configuration:, rbs_extended_reporter:, boundary_cross_reporter:, # rubocop:disable Metrics/ParameterLists
36
41
  source_rbs_synthesis_reporter:, plugin_registry:, dependency_source_index:,
37
42
  pool_mode:, cached_plugin_prepare_diagnostics:,
38
43
  pre_eval_diagnostics_from_scanner:, synthesized_namespaces_snapshot:,
44
+ quarantined_signatures_snapshot:, env_build_failure_snapshot:,
39
45
  conformance_results_snapshot:)
40
46
  @configuration = configuration
41
47
  @rbs_extended_reporter = rbs_extended_reporter
@@ -47,6 +53,8 @@ module Rigor
47
53
  @cached_plugin_prepare_diagnostics_reader = cached_plugin_prepare_diagnostics
48
54
  @pre_eval_diagnostics_from_scanner_reader = pre_eval_diagnostics_from_scanner
49
55
  @synthesized_namespaces_snapshot_reader = synthesized_namespaces_snapshot
56
+ @quarantined_signatures_snapshot_reader = quarantined_signatures_snapshot
57
+ @env_build_failure_snapshot_reader = env_build_failure_snapshot
50
58
  @conformance_results_snapshot_reader = conformance_results_snapshot
51
59
  end
52
60
 
@@ -73,6 +81,7 @@ module Rigor
73
81
  dependency_source_budget_diagnostics +
74
82
  dependency_source_config_conflict_diagnostics +
75
83
  rbs_coverage_diagnostics +
84
+ rbs_inline_annotation_hint_diagnostics(expansion) +
76
85
  expansion.fetch(:errors)
77
86
  end
78
87
 
@@ -114,7 +123,7 @@ module Rigor
114
123
  path: ".rigor.yml",
115
124
  line: 1,
116
125
  column: 1,
117
- message: error.message,
126
+ message: plugin_load_error_message(error),
118
127
  severity: :error,
119
128
  rule: "load-error",
120
129
  source_family: :plugin_loader
@@ -122,6 +131,16 @@ module Rigor
122
131
  end
123
132
  end
124
133
 
134
+ # #194 slice 1 — when the require SUCCEEDED but configuration / instantiation then failed, the loader
135
+ # stamps the resolved file on the error; naming it here turns an engine↔plugin version skew (a stale
136
+ # installed `rigortype` gem shadowing a checkout's bundled plugin) into a one-line diagnosis. A
137
+ # require that failed outright carries no resolved path and keeps its original message.
138
+ def plugin_load_error_message(error)
139
+ return error.message if error.resolved_path.nil?
140
+
141
+ "#{error.message} (loaded from #{error.resolved_path})"
142
+ end
143
+
125
144
  # ADR-10 § "Diagnostic prefix family" — surfaces gems listed in `dependencies.source_inference`
126
145
  # that RubyGems could not resolve. The run continues; the gem simply contributes nothing this
127
146
  # session, mirroring the plugin-load error envelope. Authored `:warning` because an unresolvable
@@ -224,12 +243,114 @@ module Rigor
224
243
  [build_rbs_coverage_missing_diagnostic(missing)]
225
244
  end
226
245
 
246
+ # ADR-93 WD3 — the standalone residual. A bare `gem install rigortype` has no `rbs-inline` library,
247
+ # so the spec's "annotations are official type sources whenever present" cannot be satisfied: the
248
+ # annotated code reads `Dynamic[top]`. When the library is absent AND the project actually carries
249
+ # annotation-shaped comments, emit one `:info` routing the user to install it. Rigor never bundles it
250
+ # (ADR-0 zero-dep). Suppressed whenever the library resolves (auto-wire or an explicit `plugins:`
251
+ # entry then handles it — including the deliberate `enabled: false` opt-out, which resolves and so is
252
+ # never nagged) or an `rbs-inline` plugin is already active for another reason.
253
+ def rbs_inline_annotation_hint_diagnostics(expansion)
254
+ return [] if Configuration.rbs_inline_library_resolvable?
255
+ return [] if rbs_inline_plugin_active?
256
+
257
+ first = expansion.fetch(:files).find { |path| file_carries_inline_annotation?(path) }
258
+ return [] unless first
259
+
260
+ [build_rbs_inline_unsynthesized_diagnostic(first)]
261
+ end
262
+
263
+ def rbs_inline_plugin_active?
264
+ plugin_registry.plugins.any? { |plugin| plugin.manifest.id == "rbs-inline" }
265
+ end
266
+
267
+ # A deliberately coarse routing scan — NOT the upstream annotation grammar, which is exactly what is
268
+ # unavailable here (that is the whole condition). It keys on the `# @rbs` block form and on a `#:`
269
+ # comment immediately followed by the start of an RBS type (`(`, `[`, `{`, `?`, a `Constant`, or an
270
+ # RBS lowercase base type), and never on an RDoc directive (`#:nodoc:` and friends read as a bare
271
+ # lowercase word closed by a colon), so a project with no real annotations stays silent. Reads the
272
+ # file directly (the analysis buffer is not threaded here) and early-exits on the first hit; this runs
273
+ # at most once per run and only in the library-absent standalone case, so the extra read is bounded.
274
+ INLINE_ANNOTATION_SHAPE = /
275
+ (?:^|\s)\#\s*@rbs\b
276
+ |
277
+ (?:^|\s)\#:[ \t]*(?:[\[({?A-Z]|(?:bool|void|nil|untyped|top|bot|self|instance|class)\b)
278
+ /x
279
+ private_constant :INLINE_ANNOTATION_SHAPE
280
+
281
+ def file_carries_inline_annotation?(path)
282
+ File.foreach(path) do |line|
283
+ return true if INLINE_ANNOTATION_SHAPE.match?(line)
284
+ end
285
+ false
286
+ rescue StandardError
287
+ false
288
+ end
289
+
290
+ def build_rbs_inline_unsynthesized_diagnostic(sample_path)
291
+ Diagnostic.new(
292
+ path: ".rigor.yml",
293
+ line: 1,
294
+ column: 1,
295
+ message: "Inline rbs-inline annotations (`# @rbs …`, `#: <type>`) are present in your project " \
296
+ "(e.g. #{relative_signature_path(sample_path)}), but the `rbs-inline` library is not " \
297
+ "installed, so Rigor cannot read them and the annotated code stays `Dynamic[top]` — " \
298
+ "this run is quieter than your annotations intend, not cleaner. Install the " \
299
+ "`rbs-inline` gem (its dependency closure, `prism` + `rbs`, already ships with Rigor) " \
300
+ "and Rigor honours the annotations automatically (ADR-93); it is not bundled, keeping " \
301
+ "the core zero-dep (ADR-0).",
302
+ severity: :info,
303
+ rule: "rbs.coverage.inline-annotations-unsynthesized",
304
+ source_family: :builtin
305
+ )
306
+ end
307
+
227
308
  # Robustness uplift companion (ADR-5) — when the project's `signature_paths:` RBS declared
228
309
  # qualified names without their enclosing namespace, `RbsLoader` synthesizes the missing `module`s
229
310
  # so the otherwise-inert signatures resolve. Surface a single `:info` diagnostic naming them so the
230
311
  # user knows their sig set is malformed (`rbs validate` rejects it) and can fix it at the source.
231
312
  # Authored `:info`: the analysis already succeeded; this is advisory, never a gate. Empty for a
232
313
  # well-formed sig set.
314
+ # An unparseable `.rbs` under `signature_paths:` is QUARANTINED so the rest of the env survives
315
+ # (PR #50), which means the types it declares are silently absent — calls into them read
316
+ # `Dynamic[top]`, and the run gets *quieter*, not louder. The stderr banner alone never reached CI:
317
+ # it is not a diagnostic, so it is absent from `--format json` / SARIF / GitHub annotations / the LSP
318
+ # and cannot move the exit code. This puts it in the diagnostic stream where every channel sees it.
319
+ #
320
+ # Authored `:warning`, not `:error`: an existing green build must not turn red on upgrade, and the
321
+ # `rbs` gem's own parser moves across versions (ADR-79 keeps Rigor faithful to the project's `rbs`,
322
+ # so a version bump CAN newly reject a file that used to parse). Rejecting a broken sig set outright
323
+ # is a *new required discipline* — ADR-50 WD3 routes those through the bleeding-edge overlay, where
324
+ # the `reject-unparseable-signatures` feature promotes this rule to `:error` for anyone who opts in
325
+ # (and by default at the next major).
326
+ def rbs_quarantined_signature_diagnostics
327
+ quarantined = quarantined_signatures_snapshot
328
+ return [] if quarantined.empty?
329
+
330
+ [build_rbs_quarantined_signature_diagnostic(quarantined)]
331
+ end
332
+
333
+ # The twin of {#rbs_quarantined_signature_diagnostics}, one tier louder in consequence. Quarantine
334
+ # drops ONE unparseable file and keeps the rest of the env; a total build failure — typically a
335
+ # `signature_paths:` entry redeclaring a constant/class Rigor's bundled RBS already ships, which raises
336
+ # `RBS::DuplicatedDeclarationError` at resolve — collapses the WHOLE env to nil, so every type-of query
337
+ # degrades to `Dynamic[top]` and most rules stop firing: the run comes back EMPTY, which reads as clean.
338
+ # The stderr banner ({RbsLoader#warn_about_env_build_failure_once}) is not a diagnostic, so it never
339
+ # reached `--format json` / SARIF / CI annotations / the LSP; this puts it in the diagnostic stream so
340
+ # every channel sees it, and names the conflicting signature files off the raised error's `#decls`.
341
+ #
342
+ # Authored `:warning`, not `:error`, for the same reason as its quarantine twin: the conflict is
343
+ # *typically* between the user's `sig/` and Rigor's OWN bundled RBS, so an `:error` default would let a
344
+ # Rigor release turn a green build red with zero user change (AGENTS.md § FP discipline). The
345
+ # `reject-unparseable-signatures` bleeding-edge feature promotes it to `:error` for anyone who opts in
346
+ # (and by default at the next major).
347
+ def rbs_environment_build_failed_diagnostics
348
+ failure = env_build_failure_snapshot
349
+ return [] if failure.nil?
350
+
351
+ [build_rbs_environment_build_failed_diagnostic(failure)]
352
+ end
353
+
233
354
  def rbs_synthesized_namespace_diagnostics
234
355
  synthesized = synthesized_namespaces_snapshot
235
356
  return [] if synthesized.nil? || synthesized.empty?
@@ -299,6 +420,54 @@ module Rigor
299
420
  methods.size == 1 ? "required method" : "#{methods.size} required methods"
300
421
  end
301
422
 
423
+ def build_rbs_quarantined_signature_diagnostic(quarantined)
424
+ sample_size = 5
425
+ sample = quarantined.first(sample_size).map { |path, _first_line| relative_signature_path(path) }
426
+ suffix = quarantined.size > sample_size ? ", and #{quarantined.size - sample_size} more" : ""
427
+ Diagnostic.new(
428
+ path: ".rigor.yml",
429
+ line: 1,
430
+ column: 1,
431
+ message: "#{quarantined.size} RBS file(s) under `signature_paths:` do not parse and were " \
432
+ "SKIPPED: #{sample.join(', ')}#{suffix}. The rest of your RBS environment still " \
433
+ "loaded, but the types those files declare are absent — calls into them read " \
434
+ "`Dynamic[top]`, so this run is quieter than it should be, not cleaner. Fix the " \
435
+ "parse error(s) (`rbs validate`) to restore that coverage.",
436
+ severity: :warning,
437
+ rule: "rbs.coverage.quarantined-signature",
438
+ source_family: :builtin
439
+ )
440
+ end
441
+
442
+ def build_rbs_environment_build_failed_diagnostic(failure)
443
+ error_class, first_line, buffers = failure
444
+ sample_size = 5
445
+ files = Array(buffers).map { |name| relative_signature_path(name.to_s) }
446
+ sample = files.first(sample_size)
447
+ suffix = files.size > sample_size ? ", and #{files.size - sample_size} more" : ""
448
+ conflicts = sample.empty? ? "" : " Conflicting signature file(s): #{sample.join(', ')}#{suffix}."
449
+ Diagnostic.new(
450
+ path: ".rigor.yml",
451
+ line: 1,
452
+ column: 1,
453
+ message: "The RBS environment failed to build (#{error_class}): #{first_line}.#{conflicts} " \
454
+ "A `signature_paths:` entry typically redeclares a constant or class that Rigor's " \
455
+ "bundled RBS already ships, which collapses the WHOLE environment to nil — every " \
456
+ "type-of query then reads `Dynamic[top]` and most diagnostics stop firing, so this " \
457
+ "run is EMPTY rather than clean. Remove the conflicting declaration from your `sig/` " \
458
+ "(`rbs validate`) to restore type coverage.",
459
+ severity: :warning,
460
+ rule: "rbs.coverage.environment-build-failed",
461
+ source_family: :builtin
462
+ )
463
+ end
464
+
465
+ # The absolute path is what the loader records; the user thinks in project-relative terms.
466
+ def relative_signature_path(path)
467
+ root = "#{Dir.pwd}#{File::SEPARATOR}"
468
+ path.start_with?(root) ? path.delete_prefix(root) : path
469
+ end
470
+
302
471
  def build_rbs_synthesized_namespace_diagnostic(synthesized)
303
472
  sample_size = 5
304
473
  sample = synthesized.first(sample_size)
@@ -457,34 +626,10 @@ module Rigor
457
626
  end
458
627
 
459
628
  # ADR-8 § "Severity profile" — re-stamps each diagnostic's severity from the configured profile +
460
- # per-rule overrides. Rules emit with their authored severity; the profile is the final filter.
461
- # Diagnostics whose resolved severity is `:off` are dropped from the run result.
629
+ # per-rule overrides, dropping any that resolve to `:off`. Delegates to the shared {SeverityStamp} so
630
+ # the ADR-87 WD4 boot-slimming hit path applies the identical final filter.
462
631
  def apply_severity_profile(diagnostics)
463
- diagnostics.filter_map { |diagnostic| stamp_severity(diagnostic) }
464
- end
465
-
466
- def stamp_severity(diagnostic)
467
- return diagnostic if diagnostic.rule.nil?
468
-
469
- resolved = Configuration::SeverityProfile.resolve(
470
- rule: diagnostic.rule,
471
- authored_severity: diagnostic.severity,
472
- profile: @configuration.severity_profile,
473
- overrides: @configuration.severity_overrides,
474
- bleeding_edge_overrides: @configuration.bleeding_edge_severity_overrides
475
- )
476
- return nil if resolved == :off
477
- return diagnostic if resolved == diagnostic.severity
478
-
479
- Diagnostic.new(
480
- path: diagnostic.path,
481
- line: diagnostic.line,
482
- column: diagnostic.column,
483
- message: diagnostic.message,
484
- severity: resolved,
485
- rule: diagnostic.rule,
486
- source_family: diagnostic.source_family
487
- )
632
+ SeverityStamp.apply(diagnostics, @configuration)
488
633
  end
489
634
 
490
635
  private
@@ -513,6 +658,14 @@ module Rigor
513
658
  @synthesized_namespaces_snapshot_reader.call
514
659
  end
515
660
 
661
+ def quarantined_signatures_snapshot
662
+ @quarantined_signatures_snapshot_reader.call
663
+ end
664
+
665
+ def env_build_failure_snapshot
666
+ @env_build_failure_snapshot_reader.call
667
+ end
668
+
516
669
  def conformance_results_snapshot
517
670
  @conformance_results_snapshot_reader.call
518
671
  end
@@ -7,6 +7,7 @@ require_relative "../diagnostic"
7
7
  require_relative "../worker_session"
8
8
  require_relative "../run_stats"
9
9
  require_relative "../../rbs_extended/conformance_checker"
10
+ require_relative "../../runtime/jit"
10
11
 
11
12
  module Rigor
12
13
  module Analysis
@@ -50,11 +51,16 @@ module Rigor
50
51
  boundary_cross_reporter:, source_rbs_synthesis_reporter:,
51
52
  snapshots:, plugin_registry:, dependency_source_index:,
52
53
  synthetic_method_index:, project_patched_methods:,
53
- analyze_file:, project_scope_seed: -> { {} })
54
+ analyze_file:, project_scope_seed: -> { {} }, record_dependencies: false)
54
55
  @configuration = configuration
55
56
  @cache_store = cache_store
56
57
  @explain = explain
57
58
  @workers = workers
59
+ # ADR-46 — the `--incremental` pool path. When true, pool-mode analysis is routed to the fork
60
+ # backend (the Ractor path lacks the cross-file seed and is off-default) and each worker records its
61
+ # slice's cross-file reads, marshalled back into `#collected_dependencies` for the runner's graph.
62
+ @record_dependencies = record_dependencies
63
+ @collected_dependencies = {}
58
64
  @collect_stats = collect_stats
59
65
  @buffer = buffer
60
66
  @environment_override = environment_override
@@ -70,6 +76,11 @@ module Rigor
70
76
  @analyze_file = analyze_file
71
77
  end
72
78
 
79
+ # ADR-46 — the per-file cross-file read records the fork pool captured this run (empty unless
80
+ # `record_dependencies:` and a pool run occurred). The runner folds these into its `file_dependencies`
81
+ # so the incremental session's dependency graph is refreshed whether analysis ran sequential or pooled.
82
+ attr_reader :collected_dependencies
83
+
73
84
  # ADR-15 Phase 4b — pool mode is enabled when `@workers > 0`. Editor mode (`buffer:` non-nil)
74
85
  # silently overrides pool mode to sequential: per design § "Ractor pool mode", the pool's warm-up
75
86
  # cost dominates one-file wall time, so the pool gains nothing on a per-buffer invocation. The
@@ -101,17 +112,7 @@ module Rigor
101
112
  end
102
113
 
103
114
  def analyze_files_sequentially(files, environment)
104
- # Snapshot the small synthesized-namespace name list (NOT the env — see the method comment) so
105
- # #run can surface the malformed-RBS `:info` diagnostic without rebuilding the env. Gated on the
106
- # project actually declaring `signature_paths:`: synthesis only matters for the project's own
107
- # RBS, and `#synthesized_namespaces` forces the (otherwise-lazy) RBS env to build — doing so when
108
- # there is no project sig set would warm `.rigor/cache` on a bare `--no-stats` run.
109
- @snapshots.synthesized_namespaces =
110
- project_signature_paths? ? (environment.rbs_loader&.synthesized_namespaces || []) : []
111
- # `rigor:v1:conforms-to` lives only in the project's own `signature_paths:` RBS, so gate the scan
112
- # the same way and reuse the already-built env (no extra RBS load).
113
- @snapshots.conformance_results =
114
- project_signature_paths? ? RbsExtended::ConformanceChecker.scan(environment.rbs_loader) : []
115
+ snapshot_project_signature_state(environment)
115
116
  result = files.flat_map { |path| @analyze_file.call(path, environment) }
116
117
  if @collect_stats
117
118
  loader = environment.rbs_loader
@@ -121,6 +122,31 @@ module Rigor
121
122
  result
122
123
  end
123
124
 
125
+ # The whole-run findings about the project's OWN `signature_paths:` RBS — the namespaces the loader had
126
+ # to synthesize, the files it had to quarantine, the total env-build failure (if any), and the
127
+ # `rigor:v1:conforms-to` results. Snapshotted as small plain data (NOT the env) so `#run` can surface
128
+ # them as diagnostics without rebuilding it.
129
+ #
130
+ # All are gated on the project actually declaring `signature_paths:`, for one reason: each forces the
131
+ # (otherwise lazy) RBS env to build, and doing that when there is no project sig set would warm
132
+ # `.rigor/cache` on a bare `--no-stats` run. The loader has already memoised each answer, so reading
133
+ # them here is free.
134
+ def snapshot_project_signature_state(environment)
135
+ unless project_signature_paths?
136
+ @snapshots.synthesized_namespaces = []
137
+ @snapshots.quarantined_signatures = []
138
+ @snapshots.conformance_results = []
139
+ @snapshots.env_build_failure = nil
140
+ return
141
+ end
142
+
143
+ loader = environment.rbs_loader
144
+ @snapshots.synthesized_namespaces = loader&.synthesized_namespaces || []
145
+ @snapshots.quarantined_signatures = loader&.quarantined_signatures || []
146
+ @snapshots.env_build_failure = loader&.env_build_failure
147
+ @snapshots.conformance_results = RbsExtended::ConformanceChecker.scan(loader)
148
+ end
149
+
124
150
  # Sequential-mode environment resolver. Returns the supplied `environment:` override (with the
125
151
  # runner's fresh per-run reporter pair attached so dispatcher events route to THIS runner's
126
152
  # diagnostics) when present; otherwise builds a fresh Environment per-call via
@@ -148,8 +174,19 @@ module Rigor
148
174
  :sequential
149
175
  end
150
176
 
151
- # Routes pool-mode analysis to the selected backend.
177
+ # Routes pool-mode analysis to the selected backend. A `record_dependencies` (ADR-46 `--incremental`)
178
+ # run is pinned to the fork backend when `fork` is available: only the fork path marshals the
179
+ # per-worker dependency records back, and the Ractor path additionally lacks the cross-file
180
+ # `project_scope_seed` its worker would need to record complete edges. Without `fork` (Windows) a
181
+ # recording run degrades to sequential, which records correctly through `@analyze_file`.
152
182
  def dispatch_pool(files)
183
+ if @record_dependencies
184
+ return analyze_files_in_fork_pool(files) if Process.respond_to?(:fork)
185
+
186
+ return analyze_files_sequentially_fallback(
187
+ files, reason: "incremental parallelism requires fork; recording sequentially"
188
+ )
189
+ end
153
190
  case pool_backend
154
191
  when :ractor then analyze_files_in_pool(files)
155
192
  when :fork then analyze_files_in_fork_pool(files)
@@ -314,7 +351,8 @@ module Rigor
314
351
  synthetic_method_index: synthetic_method_index,
315
352
  project_patched_methods: project_patched_methods,
316
353
  project_scope_seed: project_scope_seed,
317
- source_files: files
354
+ source_files: files,
355
+ record_dependencies: @record_dependencies
318
356
  )
319
357
  # Force the full RBS load on the parent so children copy-on-write inherit a warm Environment
320
358
  # rather than each rebuilding it after the fork.
@@ -337,6 +375,9 @@ module Rigor
337
375
  unless degraded.empty?
338
376
  degraded.each { |path| results_by_path[path] = session.analyze(path) }
339
377
  merge_worker_reporters(session.drain_reporters)
378
+ # The degraded slice was re-analysed on the parent session, so its dependency records live there,
379
+ # not in a child payload — fold them in alongside the successful children's.
380
+ @collected_dependencies.merge!(session.drain_dependencies) if @record_dependencies
340
381
  end
341
382
 
342
383
  diagnostics = Array(session.prepare_diagnostics) +
@@ -348,8 +389,19 @@ module Rigor
348
389
  # copy-on-write-inherited session and writes the Marshal'd payload to `out_path`. `exit!` skips
349
390
  # `at_exit` / stdio flush — the payload is already durable on disk by then.
350
391
  def run_fork_worker(session, slice, out_path)
392
+ # Re-arm deferred YJIT in the child. The parent (`check` / `coverage`) armed `enable_after` on a
393
+ # background thread, but `fork` copies only the calling thread — the sleeping deadline thread does not
394
+ # survive into the child, so a worker forked before the deadline fires would otherwise run its whole
395
+ # (dominant) analysis slice un-JITted no matter how long it takes. If the parent already enabled YJIT
396
+ # before forking (a slow env build pushed the fork past the deadline), the child inherited the enabled
397
+ # state and this is a no-op; otherwise the child times its own slice and JITs the tail once it proves
398
+ # long enough — the same amortization contract, per worker. Parallel mode is opted into for large
399
+ # projects, so a worker slice reliably outlasts the deadline; a small-project worker finishes first and
400
+ # stays at no-YJIT parity, exactly as the sequential path would.
401
+ Runtime::Jit.enable_after(Runtime::Jit.deadline_seconds)
351
402
  results = slice.to_h { |path| [path, session.analyze(path)] }
352
- payload = { results: results, reporters: session.drain_reporters }
403
+ payload = { results: results, reporters: session.drain_reporters,
404
+ dependencies: session.drain_dependencies }
353
405
  File.binwrite(out_path, Marshal.dump(payload))
354
406
  exit!(0)
355
407
  rescue StandardError
@@ -362,6 +414,12 @@ module Rigor
362
414
  loader = session.environment.rbs_loader
363
415
  @snapshots.class_decl_paths = loader&.class_decl_paths || {}.freeze
364
416
  @snapshots.signature_paths = loader&.signature_paths || [].freeze
417
+ # The workers each quarantine the same broken file, but they report no diagnostics for it — the row is
418
+ # a whole-run one. Read it off the parent session's loader so a pooled run says exactly what a
419
+ # sequential one says. The same reasoning holds for a total env-build failure.
420
+ @snapshots.quarantined_signatures =
421
+ project_signature_paths? ? (loader&.quarantined_signatures || []) : []
422
+ @snapshots.env_build_failure = project_signature_paths? ? loader&.env_build_failure : nil
365
423
  end
366
424
 
367
425
  # Waits for every forked child, merges each successful payload into `results_by_path`, and returns
@@ -374,6 +432,7 @@ module Rigor
374
432
  if payload
375
433
  results_by_path.merge!(payload.fetch(:results))
376
434
  merge_worker_reporters(payload.fetch(:reporters))
435
+ @collected_dependencies.merge!(payload.fetch(:dependencies, {})) if @record_dependencies
377
436
  else
378
437
  degraded.concat(child[:slice])
379
438
  end
@@ -430,6 +489,9 @@ module Rigor
430
489
  loader = environment.rbs_loader
431
490
  @snapshots.class_decl_paths = loader&.class_decl_paths || {}.freeze
432
491
  @snapshots.signature_paths = loader&.signature_paths || [].freeze
492
+ @snapshots.quarantined_signatures =
493
+ project_signature_paths? ? (loader&.quarantined_signatures || []) : []
494
+ @snapshots.env_build_failure = project_signature_paths? ? loader&.env_build_failure : nil
433
495
  diagnostics.unshift(
434
496
  Diagnostic.new(
435
497
  path: ".rigor.yml", line: 1, column: 1,
@@ -22,27 +22,17 @@ module Rigor
22
22
  # predicate is injected (it keys on `@workers` / `@buffer`, owned by the Runner) so this collaborator
23
23
  # never calls back into it.
24
24
  class ProjectPrePasses
25
- # Frozen bundle of the project-wide state the pre-passes produce. Mirrors the ivars
26
- # `run_project_pre_passes` / `adopt_prebuilt` set on the {Runner}, in the same order they were
27
- # assigned.
25
+ # Frozen bundle of the EAGER (env-input) project-wide state the pre-passes produce the slots whose
26
+ # products feed the RBS environment build. Mirrors the env-input ivars `apply_pre_passes_result`
27
+ # sets on the {Runner}. The cross-file discovery tables live in the separate {Discovery} bundle
28
+ # ({#discover}), deferred to the analysis path.
28
29
  Result = Data.define(
29
30
  :plugin_registry,
30
31
  :dependency_source_index,
31
32
  :cached_plugin_prepare_diagnostics,
32
33
  :synthetic_method_index,
33
34
  :project_patched_methods,
34
- :pre_eval_diagnostics_from_scanner,
35
- :discovered_classes,
36
- :discovered_def_nodes,
37
- :discovered_singleton_def_nodes,
38
- :discovered_def_sources,
39
- :discovered_superclasses,
40
- :discovered_includes,
41
- :discovered_class_sources,
42
- :discovered_method_visibilities,
43
- :discovered_methods,
44
- :data_member_layouts,
45
- :struct_member_layouts
35
+ :pre_eval_diagnostics_from_scanner
46
36
  )
47
37
 
48
38
  # @param configuration [Rigor::Configuration]
@@ -58,10 +48,41 @@ module Rigor
58
48
  @pool_mode_reader = pool_mode
59
49
  end
60
50
 
61
- # Internal: drives every project-wide pre-pass and returns the results bundled in {Result} in the
62
- # order the downstream `#run` body expects. Extracted so `#prepare_project_scan` and the
63
- # prebuilt-less `#run` path share one implementation.
64
- def run(expansion:) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
51
+ # Frozen bundle of the cross-file discovery tables the two whole-project parse passes produce. Split
52
+ # out of {Result} so the {Runner} can defer these to the analysis (miss) path — a warm cache HIT never
53
+ # consumes them and so never pays the double parse (see {#discover} + `Runner#ensure_project_discovery`).
54
+ # The slot names mirror the discovery half of {Result} exactly.
55
+ Discovery = Data.define(
56
+ :discovered_classes,
57
+ :discovered_def_nodes,
58
+ :discovered_singleton_def_nodes,
59
+ :discovered_def_sources,
60
+ :discovered_singleton_def_sources,
61
+ :discovered_superclasses,
62
+ :discovered_includes,
63
+ :discovered_class_sources,
64
+ :discovered_method_visibilities,
65
+ :discovered_methods,
66
+ :data_member_layouts,
67
+ :struct_member_layouts
68
+ )
69
+
70
+ # Internal: drives every EAGER project-wide pre-pass — the ones whose products feed the RBS
71
+ # environment build (and therefore the cache key): plugin load + `#prepare`, the ADR-10
72
+ # dependency-source index, the ADR-16 synthetic-method scanner, and the ADR-17 project-patched
73
+ # scanner. The cross-file discovery tables are NOT built here (they are consumed only on the analysis
74
+ # path, so they defer to {#discover}); the {Result} carries `nil` for them, matching {#adopt_prebuilt}.
75
+ # Extracted so `#prepare_project_scan` and the prebuilt-less `#run` path share one implementation.
76
+ def run(expansion:)
77
+ # ADR-90 — resolve the target project's bundle for the plugin isolation layer BEFORE any plugin
78
+ # code runs: `#prepare` hooks (the rails-routes parse, the activerecord model index) already
79
+ # invoke `Plugin::Inflector`, and the `Environment.for_project` assignment of the same value
80
+ # happens only after the pre-passes. Without this, a standalone install degrades inflection for
81
+ # exactly the prepare-time checks even though the analyzed project's bundle carries activesupport.
82
+ Plugin::Isolation.target_bundle_root ||= Environment::BundleSigDiscovery.resolve_bundle_path(
83
+ bundle_path: @configuration.bundler_bundle_path,
84
+ auto_detect: @configuration.bundler_auto_detect
85
+ )&.to_s
65
86
  plugin_registry = load_plugins
66
87
  dependency_source_index = DependencySourceInference::Builder.build(@configuration.dependencies)
67
88
  # ADR-18 slice 3 — plugin prepare MUST run before the synthetic-method scanner so cross-plugin
@@ -91,31 +112,53 @@ module Rigor
91
112
  pre_eval_outcome = Inference::ProjectPatchedScanner.scan(existing_pre_eval, buffer: @buffer)
92
113
  project_patched_methods = pre_eval_outcome.registry
93
114
  pre_eval_diagnostics_from_scanner = pre_eval_outcome.diagnostics
94
- # Cross-file class discovery — walks every project file for `class Foo` / `module Bar`
95
- # declarations so a `Foo.method_call` receiver in one file resolves a `class Foo` declared in a
96
- # sibling file. Without this pre-pass each file's `discovered_classes` was per-file only, and
97
- # lexical lookup fell back to stdlib `::Foo` for any user class shadowing a stdlib name (e.g.
98
- # `Google::Cloud::Storage::File`). Cost is one extra parse pass over the project; small projects
99
- # pay tens of ms, larger projects ~1s. Future optimisation can share parses with the existing
100
- # scanner passes.
101
- discovered_classes =
102
- Inference::ScopeIndexer.discovered_classes_for_paths(expansion.fetch(:files), buffer: @buffer)
103
- # ADR-24 slice 2 — cross-file def-node + class->superclass index so an implicit-self call inside a
104
- # subclass resolves a superclass `def` declared in a sibling file. One extra parse pass over the
105
- # project; shares the cost profile of the class-discovery pass above.
106
- def_index =
107
- Inference::ScopeIndexer.discovered_def_index_for_paths(expansion.fetch(:files), buffer: @buffer)
108
115
  Result.new(
109
116
  plugin_registry: plugin_registry,
110
117
  dependency_source_index: dependency_source_index,
111
118
  cached_plugin_prepare_diagnostics: cached_plugin_prepare_diagnostics,
112
119
  synthetic_method_index: synthetic_method_index,
113
120
  project_patched_methods: project_patched_methods,
114
- pre_eval_diagnostics_from_scanner: pre_eval_diagnostics_from_scanner,
115
- discovered_classes: discovered_classes,
121
+ pre_eval_diagnostics_from_scanner: pre_eval_diagnostics_from_scanner
122
+ )
123
+ end
124
+
125
+ # Internal: the DEFERRED cross-file discovery pre-pass, run lazily on the analysis (miss) path only —
126
+ # a warm cache HIT skips it entirely. Builds two whole-project tables in ONE walk:
127
+ # - Class discovery: every project file's `class Foo` / `module Bar` so a `Foo.method_call` receiver
128
+ # in one file resolves a `class Foo` declared in a sibling file (without it, lexical lookup fell
129
+ # back to stdlib `::Foo` for any user class shadowing a stdlib name).
130
+ # - ADR-24 slice 2 def-node / superclass / include index so an implicit-self call inside a subclass
131
+ # resolves a superclass `def` declared in a sibling file.
132
+ # Returns a frozen {Discovery} bundle; the {Runner} adopts it onto its discovery ivars. Both tables
133
+ # share ONE parse per file via `discovered_project_index_for_paths` — the file is parsed, both
134
+ # collectors walk the tree, and the AST is dropped before the next file (peak RSS stays flat).
135
+ def discover(expansion:)
136
+ build_discovery(
137
+ Inference::ScopeIndexer.discovered_project_index_for_paths(expansion.fetch(:files), buffer: @buffer)
138
+ )
139
+ end
140
+
141
+ # ADR-85 WD2 — the seed-bundle twin of {#discover}. Rebuilds the discovery tables by folding the prior
142
+ # run's per-file bundles (re-walking only changed files) and returns `[Discovery, refreshed_bundles]`;
143
+ # the runner adopts the Discovery and exposes the bundles for the session to persist. On a cold run
144
+ # (`seed_bundles` empty) this is `#discover` plus a fresh bundle set — the discovery index is identical.
145
+ def discover_from_bundles(expansion:, seed_bundles:)
146
+ index = Inference::ScopeIndexer.discovered_project_index_incremental(
147
+ expansion.fetch(:files), seed_bundles: seed_bundles, buffer: @buffer
148
+ )
149
+ [build_discovery(index), index.fetch(:bundles)]
150
+ end
151
+
152
+ # Builds the {Discovery} bundle from a `{ classes:, def_index: }` index hash (shared by the plain and
153
+ # seed-bundle discovery paths).
154
+ def build_discovery(index)
155
+ def_index = index.fetch(:def_index)
156
+ Discovery.new(
157
+ discovered_classes: index.fetch(:classes),
116
158
  discovered_def_nodes: def_index.fetch(:def_nodes),
117
159
  discovered_singleton_def_nodes: def_index.fetch(:singleton_def_nodes),
118
160
  discovered_def_sources: def_index.fetch(:def_sources),
161
+ discovered_singleton_def_sources: def_index.fetch(:singleton_def_sources),
119
162
  discovered_superclasses: def_index.fetch(:superclasses),
120
163
  discovered_includes: def_index.fetch(:includes),
121
164
  discovered_class_sources: def_index.fetch(:class_sources),
@@ -142,8 +185,8 @@ module Rigor
142
185
 
143
186
  # Translates a prebuilt {ProjectScan} snapshot supplied to `Runner.new(prebuilt: ...)` into a
144
187
  # {Result} the runner adopts the same way it adopts a fresh pre-pass run. The discovery tables are
145
- # not part of the snapshot (the LSP path seeds an empty project scope), so they stay at their
146
- # frozen-empty constructor defaults.
188
+ # not part of the snapshot (the LSP path seeds an empty project scope), and `Runner#ensure_project_discovery`
189
+ # is a no-op under `prebuilt`, so they stay at their frozen-empty constructor defaults.
147
190
  def adopt_prebuilt(scan)
148
191
  Result.new(
149
192
  plugin_registry: scan.plugin_registry,
@@ -151,21 +194,24 @@ module Rigor
151
194
  cached_plugin_prepare_diagnostics: scan.plugin_prepare_diagnostics,
152
195
  synthetic_method_index: scan.synthetic_method_index,
153
196
  project_patched_methods: scan.project_patched_methods,
154
- pre_eval_diagnostics_from_scanner: scan.pre_eval_diagnostics,
155
- discovered_classes: nil,
156
- discovered_def_nodes: nil,
157
- discovered_singleton_def_nodes: nil,
158
- discovered_def_sources: nil,
159
- discovered_superclasses: nil,
160
- discovered_includes: nil,
161
- discovered_class_sources: nil,
162
- discovered_method_visibilities: nil,
163
- discovered_methods: nil,
164
- data_member_layouts: nil,
165
- struct_member_layouts: nil
197
+ pre_eval_diagnostics_from_scanner: scan.pre_eval_diagnostics
166
198
  )
167
199
  end
168
200
 
201
+ # ADR-88 WD1 — load the project's plugins and run every `#prepare` hook, returning the prepared
202
+ # {Rigor::Plugin::Registry} WITHOUT the synthetic-method / dependency-source / pre-eval scanners. The
203
+ # incremental fact-surface fingerprint probe ({Analysis::PluginFactFingerprint}) uses this to read the
204
+ # ADR-9 fact store + drive the ADR-60 producers without paying for the full pre-pass or building the RBS
205
+ # environment. `prepare` runs unconditionally here — the probe is always sequential (its `pool_mode?`
206
+ # reader returns false), so the pool-mode skip in {#run} does not apply. Prepare diagnostics are
207
+ # discarded: a plugin that raises in `#prepare` publishes no facts, so its surface is simply absent from
208
+ # the fingerprint, exactly as it would be absent from analysis.
209
+ def prepared_registry
210
+ registry = load_plugins
211
+ plugin_prepare_diagnostics(registry) unless registry.empty?
212
+ registry
213
+ end
214
+
169
215
  # Returns the per-run shared `Plugin::FactStore` instance. All loaded plugins share this store
170
216
  # through their respective `Plugin::Services` (the same instance is threaded by
171
217
  # `Plugin::Loader.load`). Returns `nil` when no plugins are loaded.