rigortype 0.3.9 → 0.4.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 (407) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -4
  3. data/data/core_overlay/enumerable.rbs +51 -0
  4. data/data/core_overlay/enumerator.rbs +84 -0
  5. data/data/core_overlay/hash_rbs3.rbs +41 -0
  6. data/data/core_overlay/process.rbs +40 -0
  7. data/data/core_overlay/string_io.rbs +33 -0
  8. data/data/effects/core.yml +3 -3
  9. data/data/gem_overlay/activesupport/core_ext.rbs +127 -4
  10. data/docs/handbook/03-narrowing.md +95 -10
  11. data/docs/handbook/04-tuples-and-shapes.md +8 -6
  12. data/docs/handbook/07-rbs-and-extended.md +16 -11
  13. data/docs/handbook/08-understanding-errors.md +3 -2
  14. data/docs/handbook/10-sorbet.md +9 -10
  15. data/docs/handbook/11-sig-gen.md +454 -13
  16. data/docs/install.md +87 -15
  17. data/docs/manual/02-cli-reference.md +65 -4
  18. data/docs/manual/03-configuration.md +12 -0
  19. data/docs/manual/04-diagnostics.md +4 -0
  20. data/docs/manual/07-plugins.md +1 -1
  21. data/docs/manual/08-skills.md +4 -3
  22. data/docs/manual/10-mcp-server.md +3 -2
  23. data/docs/manual/12-caching.md +43 -4
  24. data/docs/manual/14-rails-quickstart.md +19 -11
  25. data/docs/manual/16-rbs-extended-annotations.md +40 -7
  26. data/docs/manual/19-effect-labels.md +10 -0
  27. data/docs/manual/plugins/README.md +7 -0
  28. data/docs/manual/plugins/rigor-actioncable.md +8 -1
  29. data/docs/manual/plugins/rigor-actionmailer.md +7 -0
  30. data/docs/manual/plugins/rigor-actionpack.md +243 -0
  31. data/docs/manual/plugins/rigor-active-model-serializers.md +153 -0
  32. data/docs/manual/plugins/rigor-activejob.md +7 -0
  33. data/docs/manual/plugins/rigor-activerecord.md +110 -4
  34. data/docs/manual/plugins/rigor-activestorage.md +8 -1
  35. data/docs/manual/plugins/rigor-activesupport-core-ext.md +11 -5
  36. data/docs/manual/plugins/rigor-grape.md +106 -0
  37. data/docs/manual/plugins/rigor-graphql.md +23 -2
  38. data/docs/manual/plugins/rigor-pundit.md +8 -1
  39. data/docs/manual/plugins/rigor-rails-i18n.md +10 -5
  40. data/docs/manual/plugins/rigor-rails-routes.md +7 -0
  41. data/docs/manual/plugins/rigor-rbs-inline.md +212 -25
  42. data/docs/manual/plugins/rigor-sidekiq.md +8 -1
  43. data/docs/manual/plugins/rigor-sorbet.md +21 -5
  44. data/exe/rigor +19 -4
  45. data/lib/rigor/analysis/baseline.rb +1 -1
  46. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +167 -2
  47. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +189 -0
  48. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +5 -3
  49. data/lib/rigor/analysis/check_rules/rule_ids.rb +20 -5
  50. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  51. data/lib/rigor/analysis/check_rules/source_arity.rb +427 -0
  52. data/lib/rigor/analysis/check_rules/special_global_setters.rb +146 -0
  53. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +36 -2
  54. data/lib/rigor/analysis/check_rules.rb +638 -136
  55. data/lib/rigor/analysis/dependency_recorder.rb +222 -17
  56. data/lib/rigor/analysis/fact_store.rb +9 -0
  57. data/lib/rigor/analysis/incremental.rb +26 -0
  58. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +266 -0
  59. data/lib/rigor/analysis/incremental_run_slot.rb +171 -0
  60. data/lib/rigor/analysis/incremental_session.rb +309 -53
  61. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +24 -13
  62. data/lib/rigor/analysis/project_scan.rb +11 -1
  63. data/lib/rigor/analysis/reachability/plugin_roots.rb +0 -1
  64. data/lib/rigor/analysis/reachability/scan_cache.rb +3 -1
  65. data/lib/rigor/analysis/rule_catalog.rb +127 -0
  66. data/lib/rigor/analysis/run_cache_key.rb +46 -18
  67. data/lib/rigor/analysis/run_stats.rb +2 -2
  68. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +168 -12
  69. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +9 -4
  70. data/lib/rigor/analysis/runner/pool_coordinator.rb +109 -17
  71. data/lib/rigor/analysis/runner/project_pre_passes.rb +44 -17
  72. data/lib/rigor/analysis/runner.rb +603 -54
  73. data/lib/rigor/analysis/shadow_harness.rb +27 -0
  74. data/lib/rigor/analysis/source_rbs_gate.rb +157 -0
  75. data/lib/rigor/analysis/template_unit_collector.rb +303 -0
  76. data/lib/rigor/analysis/template_unit_paths.rb +93 -0
  77. data/lib/rigor/analysis/template_unit_positions.rb +293 -0
  78. data/lib/rigor/analysis/template_units.rb +409 -0
  79. data/lib/rigor/analysis/worker_session.rb +57 -13
  80. data/lib/rigor/bleeding_edge.rb +0 -2
  81. data/lib/rigor/builtins/hkt_builtins.rb +1 -0
  82. data/lib/rigor/builtins/imported_refinements.rb +4 -0
  83. data/lib/rigor/builtins/regex_refinement.rb +17 -9
  84. data/lib/rigor/builtins/static_return_refinements.rb +2 -0
  85. data/lib/rigor/cache/descriptor.rb +45 -1
  86. data/lib/rigor/cache/engine_source.rb +29 -1
  87. data/lib/rigor/cache/file_digest.rb +28 -0
  88. data/lib/rigor/cache/incremental_snapshot.rb +102 -8
  89. data/lib/rigor/cache/rbs_cache_producer.rb +4 -0
  90. data/lib/rigor/cache/rbs_descriptor.rb +37 -7
  91. data/lib/rigor/cache/store.rb +63 -4
  92. data/lib/rigor/cache/value_digest.rb +325 -0
  93. data/lib/rigor/ci_detector.rb +1 -0
  94. data/lib/rigor/cli/check_command.rb +83 -13
  95. data/lib/rigor/cli/doc_links.rb +1 -1
  96. data/lib/rigor/cli/docs_command.rb +4 -4
  97. data/lib/rigor/cli/doctor_command.rb +1 -1
  98. data/lib/rigor/cli/effects_report.rb +2 -2
  99. data/lib/rigor/cli/plugin_command.rb +3 -3
  100. data/lib/rigor/cli/prism_colorizer.rb +0 -1
  101. data/lib/rigor/cli/sig_gen_command.rb +210 -29
  102. data/lib/rigor/cli/skill_command.rb +1 -1
  103. data/lib/rigor/cli/skill_describe.rb +0 -1
  104. data/lib/rigor/cli/type_of_command.rb +19 -5
  105. data/lib/rigor/cli/type_of_renderer.rb +20 -9
  106. data/lib/rigor/cli/type_of_template_probe.rb +189 -0
  107. data/lib/rigor/cli/unused_command.rb +3 -3
  108. data/lib/rigor/cli.rb +21 -3
  109. data/lib/rigor/config_audit.rb +3 -2
  110. data/lib/rigor/config_path.rb +29 -0
  111. data/lib/rigor/configuration/severity_profile.rb +24 -3
  112. data/lib/rigor/configuration.rb +71 -7
  113. data/lib/rigor/effects/ancestry_recorder.rb +191 -0
  114. data/lib/rigor/effects/attribution.rb +11 -2
  115. data/lib/rigor/effects/callee_rule.rb +368 -0
  116. data/lib/rigor/effects/catalog.rb +7 -4
  117. data/lib/rigor/effects/collector.rb +11 -5
  118. data/lib/rigor/effects/config_envelopes.rb +9 -2
  119. data/lib/rigor/effects/definition_context.rb +179 -0
  120. data/lib/rigor/effects/effect_table.rb +11 -3
  121. data/lib/rigor/effects/envelope_check.rb +1 -1
  122. data/lib/rigor/effects/envelope_index.rb +15 -0
  123. data/lib/rigor/effects/file_collection.rb +59 -5
  124. data/lib/rigor/effects/framework_units.rb +1 -1
  125. data/lib/rigor/effects/identity.rb +16 -0
  126. data/lib/rigor/effects/local_ownership.rb +38 -12
  127. data/lib/rigor/effects/method_key.rb +21 -0
  128. data/lib/rigor/effects/mutation_classifier.rb +23 -12
  129. data/lib/rigor/effects/plugin_facts.rb +49 -37
  130. data/lib/rigor/effects/propagator.rb +295 -16
  131. data/lib/rigor/effects/registry.rb +1 -1
  132. data/lib/rigor/effects/scanner.rb +121 -72
  133. data/lib/rigor/effects/signature_sources.rb +1 -1
  134. data/lib/rigor/effects/snapshot.rb +2 -1
  135. data/lib/rigor/effects/summary.rb +27 -4
  136. data/lib/rigor/effects/unit_scan.rb +385 -30
  137. data/lib/rigor/effects/visibility.rb +101 -0
  138. data/lib/rigor/environment/bundle_sig_discovery.rb +5 -3
  139. data/lib/rigor/environment/lockfile_resolver.rb +27 -1
  140. data/lib/rigor/environment/member_consistency/comparator.rb +708 -0
  141. data/lib/rigor/environment/member_consistency.rb +298 -0
  142. data/lib/rigor/environment/rbs_collection_discovery.rb +10 -1
  143. data/lib/rigor/environment/rbs_loader.rb +400 -134
  144. data/lib/rigor/environment/source_rbs_synthesis.rb +147 -0
  145. data/lib/rigor/environment.rb +104 -96
  146. data/lib/rigor/hashing/xxh3.rb +264 -0
  147. data/lib/rigor/inference/acceptance.rb +139 -9
  148. data/lib/rigor/inference/block_auto_splat.rb +216 -0
  149. data/lib/rigor/inference/block_call_timing.rb +338 -0
  150. data/lib/rigor/inference/block_parameter_binder.rb +88 -26
  151. data/lib/rigor/inference/block_repetition.rb +71 -0
  152. data/lib/rigor/inference/body_fixpoint.rb +2 -1
  153. data/lib/rigor/inference/budget_trace.rb +2 -1
  154. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  155. data/lib/rigor/inference/builtins/string_catalog.rb +1 -1
  156. data/lib/rigor/inference/captured_locals.rb +393 -15
  157. data/lib/rigor/inference/closure_escape_analyzer.rb +157 -13
  158. data/lib/rigor/inference/content_join.rb +200 -27
  159. data/lib/rigor/inference/declaration_walk/context.rb +195 -0
  160. data/lib/rigor/inference/declaration_walk/errors.rb +18 -0
  161. data/lib/rigor/inference/declaration_walk/shadow.rb +147 -0
  162. data/lib/rigor/inference/declaration_walk/traversal.rb +441 -0
  163. data/lib/rigor/inference/declaration_walk.rb +7 -0
  164. data/lib/rigor/inference/def_node_resolver.rb +3 -2
  165. data/lib/rigor/inference/def_return_typer.rb +11 -7
  166. data/lib/rigor/inference/define_method_block_self.rb +64 -0
  167. data/lib/rigor/inference/definer_resolution.rb +266 -0
  168. data/lib/rigor/inference/element_read_widening.rb +22 -10
  169. data/lib/rigor/inference/error_info.rb +196 -0
  170. data/lib/rigor/inference/expression_typer.rb +1665 -538
  171. data/lib/rigor/inference/external_ancestor_resolution.rb +264 -0
  172. data/lib/rigor/inference/fresh_frame_blocks.rb +127 -0
  173. data/lib/rigor/inference/global_write_census.rb +239 -0
  174. data/lib/rigor/inference/guard_rebinding.rb +488 -0
  175. data/lib/rigor/inference/hash_lookup_mutation.rb +88 -0
  176. data/lib/rigor/inference/index_write_widening.rb +16 -3
  177. data/lib/rigor/inference/indexed_narrowing.rb +61 -9
  178. data/lib/rigor/inference/jump_targets.rb +82 -0
  179. data/lib/rigor/inference/last_line/implicit_self.rb +210 -0
  180. data/lib/rigor/inference/last_line/self_evidence.rb +329 -0
  181. data/lib/rigor/inference/last_line.rb +340 -0
  182. data/lib/rigor/inference/last_status.rb +144 -0
  183. data/lib/rigor/inference/macro_block_self_type.rb +179 -17
  184. data/lib/rigor/inference/match_rebinding/calls.rb +281 -0
  185. data/lib/rigor/inference/match_rebinding/frame.rb +148 -0
  186. data/lib/rigor/inference/match_rebinding/operands.rb +236 -0
  187. data/lib/rigor/inference/match_rebinding/self_calls.rb +83 -0
  188. data/lib/rigor/inference/match_rebinding.rb +392 -0
  189. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  190. data/lib/rigor/inference/method_dispatcher/block_folding.rb +85 -24
  191. data/lib/rigor/inference/method_dispatcher/facet_distribution.rb +147 -0
  192. data/lib/rigor/inference/method_dispatcher/hash_transform_keys_folding.rb +191 -0
  193. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +4 -0
  194. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +159 -0
  195. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +113 -73
  196. data/lib/rigor/inference/method_dispatcher/process_folding.rb +56 -0
  197. data/lib/rigor/inference/method_dispatcher/proven_overload.rb +72 -0
  198. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +901 -38
  199. data/lib/rigor/inference/method_dispatcher/self_substitute.rb +142 -0
  200. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +128 -53
  201. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +1 -1
  202. data/lib/rigor/inference/method_dispatcher.rb +214 -13
  203. data/lib/rigor/inference/method_parameter_binder.rb +8 -3
  204. data/lib/rigor/inference/module_function_state.rb +213 -0
  205. data/lib/rigor/inference/multi_target_binder.rb +345 -52
  206. data/lib/rigor/inference/mutation_rejoin.rb +4 -1
  207. data/lib/rigor/inference/mutation_widening.rb +70 -48
  208. data/lib/rigor/inference/narrowing.rb +674 -124
  209. data/lib/rigor/inference/operand_effects.rb +167 -0
  210. data/lib/rigor/inference/operand_walk.rb +88 -0
  211. data/lib/rigor/inference/optimistic_origin.rb +152 -9
  212. data/lib/rigor/inference/parameter_inference_collector.rb +13 -11
  213. data/lib/rigor/inference/project_method_ownership.rb +136 -0
  214. data/lib/rigor/inference/project_patched_methods.rb +7 -2
  215. data/lib/rigor/inference/project_patched_scanner.rb +7 -3
  216. data/lib/rigor/inference/receiver_alias.rb +90 -1
  217. data/lib/rigor/inference/receiver_blind_block.rb +219 -0
  218. data/lib/rigor/inference/refinement_mutation.rb +15 -11
  219. data/lib/rigor/inference/repeated_or_writes.rb +463 -0
  220. data/lib/rigor/inference/return_barrier.rb +54 -0
  221. data/lib/rigor/inference/rewrite_mutation.rb +120 -0
  222. data/lib/rigor/inference/scope_indexer/certainty.rb +176 -0
  223. data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +35 -0
  224. data/lib/rigor/inference/scope_indexer/def_nestings_collector.rb +47 -0
  225. data/lib/rigor/inference/scope_indexer/layered_def_nestings.rb +36 -0
  226. data/lib/rigor/inference/scope_indexer/member_layouts_collector.rb +61 -0
  227. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +130 -0
  228. data/lib/rigor/inference/scope_indexer/superclasses_collector.rb +64 -0
  229. data/lib/rigor/inference/scope_indexer.rb +5913 -701
  230. data/lib/rigor/inference/singleton_hook_decline.rb +253 -0
  231. data/lib/rigor/inference/statement_evaluator.rb +3249 -497
  232. data/lib/rigor/inference/stored_block_call.rb +54 -0
  233. data/lib/rigor/inference/string_mutation.rb +44 -7
  234. data/lib/rigor/inference/unknown_store_widening.rb +200 -0
  235. data/lib/rigor/inference/unthreaded_rebinds.rb +282 -0
  236. data/lib/rigor/language_server/debouncer.rb +0 -1
  237. data/lib/rigor/language_server/diagnostic_publisher.rb +3 -2
  238. data/lib/rigor/language_server/hover_renderer.rb +3 -3
  239. data/lib/rigor/language_server/project_context.rb +5 -3
  240. data/lib/rigor/mcp/server.rb +2 -1
  241. data/lib/rigor/plugin/base.rb +196 -13
  242. data/lib/rigor/plugin/box_probe.rb +91 -0
  243. data/lib/rigor/plugin/bundled_catalog.rb +1 -1
  244. data/lib/rigor/plugin/effect_attribution.rb +58 -4
  245. data/lib/rigor/plugin/io_boundary.rb +82 -4
  246. data/lib/rigor/plugin/isolation.rb +1 -1
  247. data/lib/rigor/plugin/loader.rb +2 -1
  248. data/lib/rigor/plugin/macro/block_as_method.rb +45 -6
  249. data/lib/rigor/plugin/manifest.rb +71 -10
  250. data/lib/rigor/plugin/registry.rb +35 -1
  251. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  252. data/lib/rigor/plugin/template_unit.rb +196 -0
  253. data/lib/rigor/plugin/trust_policy.rb +3 -3
  254. data/lib/rigor/plugin.rb +1 -0
  255. data/lib/rigor/protection/discovery_seed.rb +20 -9
  256. data/lib/rigor/protection/kill_signature.rb +0 -1
  257. data/lib/rigor/protection/mutation_cache.rb +4 -5
  258. data/lib/rigor/rbs_extended.rb +27 -0
  259. data/lib/rigor/reflection/constant_ancestors.rb +146 -0
  260. data/lib/rigor/reflection/constant_path.rb +41 -13
  261. data/lib/rigor/reflection.rb +73 -107
  262. data/lib/rigor/scope/discovery_index.rb +351 -2
  263. data/lib/rigor/scope/resolution_chain/relevance.rb +165 -0
  264. data/lib/rigor/scope/resolution_chain.rb +884 -0
  265. data/lib/rigor/scope.rb +981 -198
  266. data/lib/rigor/sig_gen/alias_index.rb +289 -0
  267. data/lib/rigor/sig_gen/classification.rb +22 -5
  268. data/lib/rigor/sig_gen/declaration_equivalence.rb +127 -0
  269. data/lib/rigor/sig_gen/effect_annotation.rb +202 -0
  270. data/lib/rigor/sig_gen/generator.rb +580 -47
  271. data/lib/rigor/sig_gen/inline_declarations.rb +184 -0
  272. data/lib/rigor/sig_gen/method_candidate.rb +47 -3
  273. data/lib/rigor/sig_gen/observation_collector.rb +1 -1
  274. data/lib/rigor/sig_gen/renderer.rb +124 -9
  275. data/lib/rigor/sig_gen/skip_reason_catalog.rb +44 -1
  276. data/lib/rigor/sig_gen/write_result.rb +19 -3
  277. data/lib/rigor/sig_gen/writer.rb +166 -35
  278. data/lib/rigor/sig_gen.rb +3 -0
  279. data/lib/rigor/signature_path_audit.rb +1 -1
  280. data/lib/rigor/source/node_walker.rb +0 -3
  281. data/lib/rigor/source/parameter_envelope.rb +72 -0
  282. data/lib/rigor/source.rb +1 -0
  283. data/lib/rigor/type/combinator.rb +112 -12
  284. data/lib/rigor/type/difference.rb +1 -0
  285. data/lib/rigor/type/hash_shape.rb +1 -1
  286. data/lib/rigor/type/refined.rb +1 -0
  287. data/lib/rigor/version.rb +1 -1
  288. data/lib/rigor.rb +1 -0
  289. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +1 -0
  290. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +9 -5
  291. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -4
  292. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +0 -1
  293. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_scan.rb +96 -0
  294. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +65 -8
  295. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/erb_compiler.rb +270 -0
  296. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +402 -0
  297. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +370 -0
  298. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_units.rb +132 -0
  299. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +239 -4
  300. data/plugins/rigor-actionpack/sig/action_view.rbs +43 -0
  301. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +220 -0
  302. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_index.rb +56 -0
  303. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +263 -0
  304. data/plugins/rigor-active-model-serializers/lib/rigor-active-model-serializers.rb +7 -0
  305. data/plugins/rigor-active-model-serializers/sig/active_model_serializers.rbs +45 -0
  306. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +1 -0
  307. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +9 -5
  308. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +35 -3
  309. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +72 -12
  310. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +380 -3
  311. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +14 -6
  312. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +281 -35
  313. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +206 -31
  314. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -14
  315. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +9 -2
  316. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +171 -5
  317. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +2 -0
  318. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +1 -0
  319. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +1 -1
  320. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +10 -0
  321. data/plugins/rigor-grape/lib/rigor/plugin/grape.rb +141 -0
  322. data/plugins/rigor-grape/lib/rigor-grape.rb +6 -0
  323. data/plugins/rigor-grape/sig/grape.rbs +263 -0
  324. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +72 -2
  325. data/plugins/rigor-graphql/sig/graphql.rbs +485 -0
  326. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +2 -0
  327. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +11 -7
  328. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +35 -37
  329. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +0 -1
  330. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +2 -0
  331. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +1 -0
  332. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +9 -17
  333. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline/same_line_annotations.rb +149 -0
  334. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +217 -36
  335. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +1 -0
  336. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +9 -5
  337. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +0 -1
  338. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +46 -1
  339. data/plugins/rigor-sorbet/sig/sorbet.rbs +277 -0
  340. data/sig/rigor/analysis/baseline.rbs +69 -7
  341. data/sig/rigor/analysis/fact_store.rbs +1 -1
  342. data/sig/rigor/analysis/project_scan.rbs +74 -0
  343. data/sig/rigor/effects/config_envelopes.rbs +100 -0
  344. data/sig/rigor/effects/effect_table.rbs +60 -0
  345. data/sig/rigor/effects/envelope.rbs +97 -0
  346. data/sig/rigor/effects/envelope_index.rbs +33 -0
  347. data/sig/rigor/effects/file_collection.rbs +81 -0
  348. data/sig/rigor/effects/label.rbs +26 -0
  349. data/sig/rigor/effects/label_set.rbs +46 -0
  350. data/sig/rigor/effects/method_key.rbs +24 -0
  351. data/sig/rigor/effects/origin.rbs +50 -0
  352. data/sig/rigor/effects/plugin_facts.rbs +141 -0
  353. data/sig/rigor/effects/registry.rbs +68 -0
  354. data/sig/rigor/effects/summary.rbs +50 -0
  355. data/sig/rigor/effects/taint_cause.rbs +12 -0
  356. data/sig/rigor/environment.rbs +14 -10
  357. data/sig/rigor/inference/optimistic_origin.rbs +10 -0
  358. data/sig/rigor/inference.rbs +6 -4
  359. data/sig/rigor/plugin/additional_initializer.rbs +36 -0
  360. data/sig/rigor/plugin/base.rbs +30 -7
  361. data/sig/rigor/plugin/effect_ancestry.rbs +27 -0
  362. data/sig/rigor/plugin/effect_attribution.rbs +66 -0
  363. data/sig/rigor/plugin/effect_edge.rbs +33 -0
  364. data/sig/rigor/plugin/effect_entry_points.rbs +25 -0
  365. data/sig/rigor/plugin/io_boundary.rbs +2 -1
  366. data/sig/rigor/plugin/loader.rbs +3 -3
  367. data/sig/rigor/plugin/manifest.rbs +50 -11
  368. data/sig/rigor/plugin/protocol_contract.rbs +68 -0
  369. data/sig/rigor/plugin/registry.rbs +62 -1
  370. data/sig/rigor/plugin.rbs +1 -1
  371. data/sig/rigor/rbs_extended.rbs +1 -1
  372. data/sig/rigor/reflection.rbs +7 -6
  373. data/sig/rigor/scope.rbs +177 -20
  374. data/sig/rigor/sig_gen/skip_reason_catalog.rbs +14 -7
  375. data/sig/rigor/source.rbs +4 -4
  376. data/sig/rigor/testing.rbs +10 -4
  377. data/sig/rigor/type.rbs +6 -0
  378. data/sig/rigor.rbs +38 -20
  379. data/skills/rigor-ask/SKILL.md +8 -5
  380. data/skills/rigor-baseline-reduce/SKILL.md +4 -6
  381. data/skills/rigor-ci-setup/SKILL.md +17 -21
  382. data/skills/rigor-doctor/SKILL.md +24 -22
  383. data/skills/rigor-doctor/references/01-checks.md +97 -33
  384. data/skills/rigor-editor-setup/SKILL.md +6 -4
  385. data/skills/rigor-mcp-setup/SKILL.md +5 -4
  386. data/skills/rigor-monkeypatch-resolve/SKILL.md +5 -3
  387. data/skills/rigor-next-steps/SKILL.md +4 -2
  388. data/skills/rigor-plugin-author/SKILL.md +19 -23
  389. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +9 -8
  390. data/skills/rigor-plugin-author/references/02-walker-and-types.md +12 -25
  391. data/skills/rigor-plugin-author/references/03-test-and-ship.md +6 -8
  392. data/skills/rigor-plugin-review/SKILL.md +6 -4
  393. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +2 -1
  394. data/skills/rigor-plugin-tune/SKILL.md +4 -2
  395. data/skills/rigor-project-init/SKILL.md +46 -18
  396. data/skills/rigor-project-init/references/01-detect.md +13 -9
  397. data/skills/rigor-project-init/references/02-configure.md +40 -10
  398. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +14 -14
  399. data/skills/rigor-project-init/references/04-sig-uplift.md +27 -14
  400. data/skills/rigor-protection-uplift/SKILL.md +4 -6
  401. data/skills/rigor-rbs-setup/SKILL.md +4 -2
  402. data/skills/rigor-type-oracle/SKILL.md +4 -6
  403. data/skills/rigor-type-oracle/references/01-oracle-commands.md +12 -8
  404. data/skills/rigor-type-oracle/references/03-gap-protocol.md +0 -3
  405. data/skills/rigor-unused-adjudicate/SKILL.md +4 -2
  406. data/skills/rigor-upgrade/SKILL.md +13 -8
  407. metadata +132 -1
@@ -48,6 +48,7 @@ require_relative "runner/run_snapshots"
48
48
  require_relative "runner/project_pre_passes"
49
49
  require_relative "runner/pool_coordinator"
50
50
  require_relative "runner/diagnostic_aggregator"
51
+ require_relative "template_units"
51
52
  require_relative "runner/effect_envelope_pass"
52
53
  require_relative "runner/effect_annotation_residual_pass"
53
54
  require_relative "runner/buffer_pool_dispatcher"
@@ -107,6 +108,32 @@ module Rigor
107
108
  @effect_table || Effects::EffectTable.empty
108
109
  end
109
110
 
111
+ # ADR-103 WD9 (#391) — the envelope index this run resolved, for `rigor sig-gen`'s annotation
112
+ # emission to consult. Public for the same reason {#effect_table} is: a consumer outside the
113
+ # diagnostic stream needs what the run already computed, and rebuilding it from configuration
114
+ # alone would read a narrower set of strata — the accepted signatures and the rbs-inline virtual
115
+ # RBS both come from the built environment.
116
+ #
117
+ # Built on demand when the run never analysed a file. A WARM whole-run cache hit serves the
118
+ # propagated table from cache, so {#analyze_with_effects} — the only caller of
119
+ # {#effect_envelope_index} — never runs and the ivar stays nil. Returning an empty index there
120
+ # would silently drop sig-gen's "the author already declared a bound" gate on exactly the runs a
121
+ # user makes most often: the same `sig-gen` invocation would withhold on its first run and emit on
122
+ # its second. `@run_environment` is resolved before the cache is consulted, so the warm build reads
123
+ # the same strata the cold one does.
124
+ #
125
+ # `@run_environment` is nil only when the run resolved no environment at all — a run over zero
126
+ # project files, or one that never reached {#compute_run_diagnostics}. The build then falls back to
127
+ # the configuration-only strata (`sig/` annotations, class-level ones and `effects.envelopes:`,
128
+ # without the accepted signatures or the rbs-inline virtual RBS), which is the narrower answer but
129
+ # not a wrong one: a run with no environment analysed no file, so there is no candidate for the
130
+ # missing strata to have bounded.
131
+ def effect_envelopes
132
+ return @effect_envelope_index if @effect_envelope_index
133
+
134
+ effect_envelope_index(@run_environment)
135
+ end
136
+
110
137
  # ADR-103 WD2 / WD6 / WD10 / #387 — the loaded plugins' effect contributions, compiled once per
111
138
  # process. Memoised on first use rather than built in the constructor for two reasons: the plugin
112
139
  # registry is adopted after construction (`apply_prebuilt` / the plugin-load pre-pass), and the
@@ -270,11 +297,15 @@ module Rigor
270
297
  record_dependencies: false, record_self_calls: false, analyze_only: nil,
271
298
  seed_bundles: nil, collect_seed_bundles: false, param_inferred_types: nil,
272
299
  discovery_seed: nil, no_tolerated_effects: false,
273
- restored_run_level_rows: nil)
300
+ restored_run_level_rows: nil, served_per_file_diagnostics: nil)
274
301
  @configuration = configuration
275
302
  # Issues #796 / #794 — the previous full run's run-level rows, supplied only by
276
303
  # {IncrementalSession} on a narrowed run; see {PoolCoordinator#replay_restored_run_level_rows}.
277
304
  @restored_run_level_rows = restored_run_level_rows
305
+ # Issue #1524 — `{path => rows}` the {IncrementalSession} serves from its per-file cache for the files a
306
+ # narrowed run does not analyse, spliced into this run's per-file stream by
307
+ # {#interleave_served_diagnostics}. Empty on every other run.
308
+ @served_per_file_diagnostics = served_per_file_diagnostics || {}
278
309
  @explain = explain
279
310
  @cache_store = enforce_read_only_cache(cache_store, buffer)
280
311
  @plugin_requirer = plugin_requirer
@@ -314,6 +345,10 @@ module Rigor
314
345
  # check reads.
315
346
  @no_tolerated_effects = no_tolerated_effects
316
347
  @file_effects = {}
348
+ # #392 — the run's template units, synthesised once per run after the plugins load (see
349
+ # {#template_units}). nil until then; a project whose plugins declare no `template_globs:` resolves
350
+ # to the shared empty index and pays one `Array#empty?` for the whole run.
351
+ @template_units = nil
317
352
  @effect_table = nil
318
353
  @effects_served_from_cache = false
319
354
  # #482 — set only by a run served from the summary entry: the two derived tables it carries, and
@@ -322,6 +357,12 @@ module Rigor
322
357
  @cached_effect_ancestry = nil
323
358
  @cached_collections_loader = nil
324
359
  @run_dependency_descriptor = nil
360
+ # ADR-45 WD2 (#1507) — the plugin reads each file's analysis made (`{path => [row]}`), and the boundary
361
+ # rows as they stood around the per-file loop; see {#incremental_slot_rows}. Recorded only by a
362
+ # dependency-recording (incremental) run.
363
+ @analysis_reads = {}
364
+ @pre_analysis_plugin_rows = nil
365
+ @post_analysis_plugin_rows = nil
325
366
  # Memoised activation decision for the `call.self-undefined-method` rule (nil = not yet computed).
326
367
  # See `self_undefined_rule_active?`.
327
368
  @self_undefined_rule_active = nil
@@ -378,8 +419,17 @@ module Rigor
378
419
  # Issue #682 — the `Module.nesting` each class / module declaration HEADER is written in.
379
420
  @project_discovered_header_nestings = {}.freeze
380
421
  @project_discovered_includes = {}.freeze
422
+ # Issue #1123 — the instance-side prepend table (`prepend M`, and `Recv.prepend(M)`), in
423
+ # instance-ancestor SEARCH order.
424
+ @project_discovered_prepends = {}.freeze
381
425
  # Issue #898 — the singleton-side twin of the include table (`extend M` / `extend self`).
382
426
  @project_discovered_extends = {}.freeze
427
+ @project_unpositioned_mixins = {}.freeze
428
+ @project_discovered_deferred_ranges = {}.freeze
429
+ # Issue #1120 — `{refined class => {method => [refining modules]}}`, every `refine X do … end` body.
430
+ @project_discovered_refinements = {}.freeze
431
+ # Issue #1367 — the project's `Inference::GlobalWriteCensus`, which the `global.*` write rules read.
432
+ @project_discovered_global_write_census = Set.new.freeze
383
433
  @project_discovered_class_sources = {}.freeze
384
434
  # Issue #644 — the cross-file VALUE-constant publication table (`{qualified name => Type::Constant}`,
385
435
  # literal writes only) and its per-name write attribution. The first seeds `in_source_constants` on
@@ -390,8 +440,15 @@ module Rigor
390
440
  # ...and the raw per-(name, path) publication census the incremental producer diffs.
391
441
  @project_constant_writes = {}.freeze
392
442
  @published_constant_name_set = nil
443
+ @constant_writer_index = nil
444
+ @constant_shadower_index = nil
393
445
  @project_discovered_method_visibilities = {}.freeze
394
446
  @project_discovered_methods = {}.freeze
447
+ # ADR-119 WD1 — the `possible_*` / `contested_*` siblings of the paired members, ONE Hash keyed by sibling
448
+ # name so no adoption or seed path names a sibling and none can drop one.
449
+ @project_discovery_siblings = Scope::DiscoveryIndex::EMPTY_SIBLINGS
450
+ # Issue #992 — the cross-file parameter-envelope table `call.wrong-arity` reads for an undeclared `def`.
451
+ @project_discovered_parameter_envelopes = {}.freeze
395
452
  @project_data_member_layouts = {}.freeze
396
453
  @project_struct_member_layouts = {}.freeze
397
454
  # Issue #684 — set per run by {#project_discovery_expansion}; nil on every path that does not widen.
@@ -453,12 +510,21 @@ module Rigor
453
510
  @snapshots.reset_for_run
454
511
  # Per-run reset of the deferred-discovery memo (see `#ensure_project_discovery`).
455
512
  @project_discovery_done = false
513
+ # #392 — re-derive the template units each run: their source files can change between two runs of
514
+ # one long-lived Runner (the LSP's), and a unit is never served from a cache of its own. Since
515
+ # #1038 "re-derive" is not "recompile": a `prebuilt:` runner carries the snapshot's warm index into
516
+ # {#template_units}, which still re-expands every claim and revalidates every template against the
517
+ # filesystem, and only compiles what actually moved.
518
+ @template_units = nil
456
519
  # Per-run reset of the environment the cacheable path resolves, reused by the envelope pass so a
457
520
  # run never builds two.
458
521
  @run_environment = nil
459
522
  # #788 — the per-file reader is assigned only on the analysis (miss) path; reset it here so a run the
460
523
  # ADR-45 result cache serves does not answer with the previous run's rows.
461
524
  @per_file_diagnostics = [].freeze
525
+ @analysis_reads = {}
526
+ @pre_analysis_plugin_rows = nil
527
+ @post_analysis_plugin_rows = nil
462
528
  # ADR-84 WD2 — roll the return-memo bucket: a fresh frozen token per run makes every per-file scope
463
529
  # of THIS run share one memo bucket while entries from any earlier run in this process (stale after
464
530
  # an edit) become unreachable.
@@ -746,7 +812,10 @@ module Rigor
746
812
  @run_served_from_cache = false
747
813
  return assemble_run_diagnostics(expansion) unless run_result_cacheable?
748
814
 
749
- environment = @pool_coordinator.resolve_sequential_environment(source_files: target_files(expansion))
815
+ # #392 — `source_files:` is what a plugin's `source_rbs_synthesizer` is offered at env-build time,
816
+ # and a template unit is not Ruby the synthesiser can read (rbs-inline would be handed `.rbx`
817
+ # bytes). The `.rb` expansion is the right set here; the units join the ANALYSED set only.
818
+ environment = @pool_coordinator.resolve_sequential_environment(source_files: expansion.fetch(:files))
750
819
  # Lazy-files descriptor: the cache KEY reads only `gems` + `configs`; the RBS signature-tree `files`
751
820
  # are digested solely by `run_dependency_descriptor` on a MISS, so a warm HIT never walks the tree.
752
821
  rbs_descriptor = if environment&.rbs_loader
@@ -776,6 +845,11 @@ module Rigor
776
845
  # then hit — the stats it would otherwise suppress were all gathered.
777
846
  @run_served_from_cache = !computed && analysis.nil?
778
847
  diagnostics
848
+ rescue Inference::DeclarationWalk::ContractError
849
+ # A broken walk contract in the project pre-pass (a shadow-harness divergence, an unknown variant) is
850
+ # not a cache failure: the fallback below would re-run the analysis with the pre-pass marked done and
851
+ # no project index, and report the resulting false positives instead of the failure.
852
+ raise
779
853
  rescue StandardError
780
854
  # The result cache must never break a run. If anything in the cache path fails, fall back to a
781
855
  # direct, uncached analysis.
@@ -998,7 +1072,14 @@ module Rigor
998
1072
  def close_effect_graph
999
1073
  return unless @record_effects
1000
1074
 
1001
- @effect_table = Effects::Propagator.propagate(effect_collection, discharge: effect_discharge)
1075
+ # `declined_unit_keys:` — #1065. The templates a plugin claimed and produced no unit for; a render
1076
+ # edge's format fallback must not resolve past one of them, because the file exists and the
1077
+ # framework runs it, so the other format's effects are not what that render produces.
1078
+ @effect_table = Effects::Propagator.propagate(
1079
+ effect_collection,
1080
+ discharge: effect_discharge,
1081
+ declined_unit_keys: template_units.declined_unit_keys
1082
+ )
1002
1083
  rescue StandardError
1003
1084
  @effect_table = Effects::EffectTable.empty
1004
1085
  end
@@ -1029,6 +1110,11 @@ module Rigor
1029
1110
  # untouched so its lazy build timing is unchanged.
1030
1111
  environment = seed_pre_eval_constants(expansion, environment)
1031
1112
  diagnostics = @diagnostic_aggregator.pre_file_diagnostics(expansion)
1113
+ # #392 — a template transform that raised, a template that could not be read, or a unit naming a
1114
+ # file it was not offered. Reported through the same `:plugin_loader` `runtime-error` envelope a
1115
+ # raise from `#diagnostics_for_file` uses (ADR-2 § "Plugin Trust and I/O Policy"), rather than
1116
+ # dropped in silence: a plugin author whose view stopped being analysed has nothing else to read.
1117
+ diagnostics += template_unit_failure_diagnostics
1032
1118
  # ADR-46 — record which project files this run actually analyzed (the `analyze_only` subset, or
1033
1119
  # all of them). The incremental orchestrator serves every analyzed-but-not-affected file from the
1034
1120
  # per-file cache, so it needs the full analyzed set to subtract the affected closure from.
@@ -1045,6 +1131,11 @@ module Rigor
1045
1131
  # override re-stamps it; and a pool backend folds `.rigor.yml`-positioned prepare / pool-degraded rows
1046
1132
  # into the same return, which the slice drops. The run's own stream is stamped once, at the end.
1047
1133
  diagnostics += analyze_targets(targets, environment: environment, project_files: expansion.fetch(:files))
1134
+ # Issue #1060 — the positioned batches plugins registered through `Plugin::Base#emit_once`, read once
1135
+ # every worker payload has been merged. Kept beside the per-file stream (their rows name files and
1136
+ # lines) but outside `#per_file_diagnostics`, which `analyze_targets` has already sliced, so no one
1137
+ # file's incremental cache entry carries a project-wide batch.
1138
+ diagnostics += @diagnostic_aggregator.plugin_run_emission_diagnostics
1048
1139
  # ADR-103 WD12 — the effect fixpoint, in the post-pool aggregation slot beside the conformance
1049
1140
  # results. Graph-only over a finite lattice, so it is a plain worklist to a true fixpoint; it
1050
1141
  # contributes NO diagnostics and its result leaves through `#effect_table`, never through the
@@ -1053,6 +1144,18 @@ module Rigor
1053
1144
  diagnostics + post_analysis_diagnostic_streams
1054
1145
  end
1055
1146
 
1147
+ def template_unit_failure_diagnostics
1148
+ template_units.failures.map do |failure|
1149
+ Diagnostic.new(
1150
+ path: failure.path, line: 1, column: 1,
1151
+ message: "plugin #{failure.plugin_id.inspect} produced no template unit for " \
1152
+ "#{failure.path}: #{failure.message}",
1153
+ severity: :error, rule: "runtime-error", source_family: :plugin_loader
1154
+ )
1155
+ end
1156
+ end
1157
+ private :template_unit_failure_diagnostics
1158
+
1056
1159
  # The rbs-coverage build-failure ladder plus every remaining post-analysis stream
1057
1160
  # `#assemble_run_diagnostics` appends, in their fixed contract order (see the comments at each call
1058
1161
  # below and at the streams' own definitions). Split out of `#assemble_run_diagnostics` so that
@@ -1077,6 +1180,11 @@ module Rigor
1077
1180
  diagnostics += @diagnostic_aggregator.rbs_extended_reporter_diagnostics
1078
1181
  diagnostics += @diagnostic_aggregator.boundary_cross_diagnostics
1079
1182
  diagnostics += @diagnostic_aggregator.source_rbs_synthesis_diagnostics
1183
+ # Issue #1051 — read after `analyze_targets` for the same reason the refusal stream below is: a
1184
+ # plugin may register a disclosure from `#prepare` (the usual case) or while analysing a file, and
1185
+ # both have to be in before the table is harvested. Ordered before the refusals so the run-level
1186
+ # plugin rows read plugin-authored notice first, engine-authored envelope second.
1187
+ diagnostics += @diagnostic_aggregator.plugin_run_disclosure_diagnostics
1080
1188
  # Issue #959 — read LAST (after `analyze_targets`, which already ran before this method is called),
1081
1189
  # so every per-file plugin call has had its chance to record a refusal on its (per-plugin-instance
1082
1190
  # memoised) `IoBoundary`, alongside the prepare-time refusals the same boundary instance may
@@ -1089,17 +1197,55 @@ module Rigor
1089
1197
  # `#per_file_diagnostics`: the `analyze_files` return, stamped with the same severity profile the run's
1090
1198
  # own stream gets (`SeverityStamp` drops `:off` rows and re-stamps overrides, and the per-file cache never
1091
1199
  # re-stamps), sliced to the rows positioned at the targets. Returns the RAW return for the run's stream,
1092
- # which is stamped once, at the end of `#run_analysis`.
1200
+ # which is stamped once, at the end of `#run_analysis` — on a narrowed incremental run with the served
1201
+ # files' rows spliced in (#1524), which `#per_file_diagnostics` never carries.
1093
1202
  def analyze_targets(targets, environment:, project_files:)
1094
- raw = @pool_coordinator.analyze_files(targets, environment: environment, project_files: project_files)
1203
+ raw = template_units.remap(
1204
+ @pool_coordinator.analyze_files(targets, environment: environment, project_files: project_files)
1205
+ )
1206
+ # ADR-45 WD2 (#1507) — the boundaries as the per-file loop left them; with the snapshot the first
1207
+ # analysed file took, the rows {#incremental_slot_rows} checks every analysis-time read against.
1208
+ @post_analysis_plugin_rows = plugin_boundary_rows if @record_dependencies
1095
1209
  analysed = targets.to_set
1096
1210
  @per_file_diagnostics = @diagnostic_aggregator.apply_severity_profile(raw)
1097
1211
  .select { |diagnostic| analysed.include?(diagnostic.path) }
1098
1212
  .freeze
1099
- raw
1213
+ interleave_served_diagnostics(raw, analysed, project_files | targets)
1100
1214
  end
1101
1215
  private :analyze_targets
1102
1216
 
1217
+ # Issue #1524 — splices the rows {IncrementalSession} serves from its per-file cache into this narrowed
1218
+ # run's per-file stream, each file's rows where a full run of the same tree lists them. A full run
1219
+ # analyses `order` (the project's files, then any buffer and template-unit targets) one file after
1220
+ # another and a narrowed run a subsequence of it, so a served file's rows go just before the first row
1221
+ # positioned at an analysed file later in `order`, or at the end when there is none. Only such a row
1222
+ # marks a place, so the rows this run produced keep their relative order and a pool backend's
1223
+ # `.rigor.yml` rows stay at the head. Splicing here rather than into the finished result keeps the
1224
+ # run-level streams on either side of the per-file stream, where a full run has them. The served rows
1225
+ # are already severity-resolved and the run's final stamp leaves them unchanged: a resolved severity
1226
+ # resolves to itself, and a row the profile turns `:off` is never cached.
1227
+ def interleave_served_diagnostics(raw, analysed, order)
1228
+ served = @served_per_file_diagnostics
1229
+ return raw if served.empty?
1230
+
1231
+ rank = order.each_with_index.to_h
1232
+ # A served path the expansion no longer lists (a file deleted between the session's listing and this
1233
+ # run) keeps its rows, after every other file's, as the concatenation this replaced did.
1234
+ known, unknown = served.keys.partition { |path| rank.key?(path) }
1235
+ pending = known.sort_by { |path| rank.fetch(path) }
1236
+ merged = []
1237
+ raw.each do |diagnostic|
1238
+ # `order` lists every target, so an analysed path always has a rank; one that did not would only
1239
+ # mark no place, since a misplaced row must never abort the run (ADR-5).
1240
+ position = analysed.include?(diagnostic.path) ? rank.fetch(diagnostic.path, -1) : -1
1241
+ merged.concat(served.fetch(pending.shift)) while pending.any? && rank.fetch(pending.first) < position
1242
+ merged << diagnostic
1243
+ end
1244
+ (pending + unknown).each { |path| merged.concat(served.fetch(path)) }
1245
+ merged
1246
+ end
1247
+ private :interleave_served_diagnostics
1248
+
1103
1249
  # ADR-67 WD6a — the check-walk parameter-inference pre-pass. Populates `@project_param_inferred_types`
1104
1250
  # (read by `project_scope_seed_tables`) with the call-site union of every undeclared parameter, running
1105
1251
  # ONE round (a single hop of call-site → param typing; the protection scan's three-round fixpoint stays a
@@ -1199,7 +1345,8 @@ module Rigor
1199
1345
  # reconstructs `rbs.libraries` from config) can never drift out of key agreement.
1200
1346
  RunCacheKey.descriptor(
1201
1347
  configuration: @configuration, files: expansion.fetch(:files),
1202
- explain: @explain, rbs_config_entries: rbs_descriptor.configs
1348
+ explain: @explain, rbs_config_entries: rbs_descriptor.configs,
1349
+ template_units_digest: template_units.digest
1203
1350
  )
1204
1351
  end
1205
1352
 
@@ -1216,30 +1363,210 @@ module Rigor
1216
1363
 
1217
1364
  def build_run_dependency_descriptor(expansion, rbs_descriptor)
1218
1365
  entries = analyzed_file_entries(expansion) + discovery_file_entries(expansion) +
1219
- pre_eval_file_entries + rbs_descriptor.files
1366
+ pre_eval_file_entries + template_unit_file_entries + rbs_descriptor.files
1220
1367
  # #979 — the signature ROOTS' listings, one glob row each. `rbs_descriptor.files` covers only the
1221
1368
  # `.rbs` files that existed while the run read them, so without these a signature file written after
1222
1369
  # the run left the slot validating fresh and the warm run answered without it.
1223
- globs = rbs_descriptor.globs.dup
1370
+ globs = rbs_descriptor.globs + template_unit_glob_entries
1371
+ boundary = plugin_boundary_rows
1372
+ entries.concat(boundary.files)
1373
+ globs.concat(boundary.globs).concat(producer_watch_glob_entries)
1374
+ Cache::Descriptor.new(files: entries, globs: globs.uniq)
1375
+ end
1376
+
1377
+ # Every file a plugin read or probed through its `IoBoundary` (#577 / #613), and every directory it listed
1378
+ # (#954), as the boundaries stand when asked.
1379
+ def plugin_boundary_rows
1380
+ files = []
1381
+ globs = []
1224
1382
  @plugin_registry.plugins.each do |plugin|
1225
1383
  # Read the boundary WITHOUT triggering its lazy `@io_boundary ||=` initializer: plugin instances
1226
1384
  # are frozen after the run, and a plugin that never built a boundary read no files through it,
1227
1385
  # so it contributes no dependencies.
1228
1386
  boundary = plugin.instance_variable_get(:@io_boundary)
1229
- if boundary
1230
- # #629 — a boundary carries GlobEntry listing rows too (`IoBoundary#list_directory`), and they
1231
- # are the only edge back to a file the run decided was ABSENT from a directory it consulted.
1232
- boundary_descriptor = boundary.cache_descriptor
1233
- entries.concat(boundary_descriptor.files)
1234
- globs.concat(boundary_descriptor.globs)
1387
+ next unless boundary
1388
+
1389
+ # #629 — a boundary carries GlobEntry listing rows too (`IoBoundary#list_directory`), and they
1390
+ # are the only edge back to a file the run decided was ABSENT from a directory it consulted.
1391
+ boundary_descriptor = boundary.cache_descriptor
1392
+ files.concat(boundary_descriptor.files)
1393
+ globs.concat(boundary_descriptor.globs)
1394
+ end
1395
+ Cache::Descriptor.new(files: files, globs: globs)
1396
+ end
1397
+
1398
+ # One glob row per producer `watch:` pattern (ADR-60 WD3), recomputed against the tree as it stands.
1399
+ def producer_watch_glob_entries
1400
+ @plugin_registry.plugins.flat_map do |plugin|
1401
+ plugin.class.producers.each_value.flat_map do |prod|
1402
+ prod[:watch] ? plugin.send(:watch_glob_entries, prod[:watch]) : []
1235
1403
  end
1236
- plugin.class.producers.each_value do |prod|
1237
- globs.concat(plugin.send(:watch_glob_entries, prod[:watch])) if prod[:watch]
1404
+ end
1405
+ end
1406
+
1407
+ # ADR-45 WD2 (#1507) — the rows `rigor check --incremental`'s run-result slot records beside the analysed
1408
+ # files (whose digests {IncrementalSession} holds itself). Every run reads these inputs again and re-derives
1409
+ # its answer from them; they are split by when the row was taken, which decides whether the session's
1410
+ # {IncrementalRunSlot::WriteGuard} must vouch for it:
1411
+ #
1412
+ # - `observed` — every plugin row (`#prepare`, producers, template synthesis, and this run's per-file reads
1413
+ # too), recorded by the {Plugin::IoBoundary} as the plugin read. It includes each producer's `watch:`
1414
+ # globs, which a producer replays into its boundary whether it computed or was served (#1558). A save
1415
+ # after the read leaves the row stale, so no guard is needed.
1416
+ # - `derived` — rows taken when the run ENDS: the template files and their claimed globs (#392, re-analysed
1417
+ # every run), and one existence row per analysis root and per `pre_eval:` entry. A run's path errors (`no
1418
+ # such file or directory`, `pre-eval.file-not-found`) answer to those last rows; the plain slot records
1419
+ # neither, and the incremental path regenerates the errors every run, so a slot that served one past the
1420
+ # edit that retracts it would print what no run of that tree prints. Existence rows carry ADR-45 WD1b's
1421
+ # bound: a directory replaced by a file of the same name stays fresh.
1422
+ # - `by_file` — `{path => Descriptor}`, the plugin reads each file's analysis made
1423
+ # ({Plugin::IoBoundary.attributing}). A narrowed run analyses only its closure, so the session carries the
1424
+ # other files' reads forward from the previous slot.
1425
+ #
1426
+ # The template rows stand in for the `template-units` slot the plain key carries, which only a run can
1427
+ # compute. That digest is a function of each unit's compiled Ruby (the template's bytes, and whatever else
1428
+ # the transform read, through its boundary), its transform (the plugin, whose identity and configuration
1429
+ # the key carries) and the synthesis version (the engine's): each of those has a row or a key slot.
1430
+ #
1431
+ # The inputs a recheck does NOT re-derive are {#baseline_dependency_rows}. nil when this run cannot vouch for
1432
+ # its rows — a pool run, whose workers' reads never reach this process; a run that recorded no dependencies,
1433
+ # whose analysis no session owns; or a run in which a boundary row changed during the per-file loop that no
1434
+ # file's analysis was credited with, such as a read from a thread the plugin started.
1435
+ IncrementalSlotRows = Data.define(:observed, :derived, :by_file)
1436
+
1437
+ # @param roots — the analysis roots the run was given.
1438
+ def incremental_slot_rows(roots:)
1439
+ return nil if pool_mode? || !@record_dependencies
1440
+ return nil unless analysis_reads_attributed?
1441
+
1442
+ IncrementalSlotRows.new(
1443
+ observed: plugin_boundary_rows,
1444
+ derived: Cache::Descriptor.new(
1445
+ files: template_unit_file_entries + existence_entries(Array(roots) + @configuration.pre_eval),
1446
+ globs: template_unit_glob_entries
1447
+ ),
1448
+ by_file: @analysis_reads.transform_values do |reads|
1449
+ Cache::Descriptor.new(
1450
+ files: reads.grep(Cache::Descriptor::FileEntry).uniq,
1451
+ globs: reads.grep(Cache::Descriptor::GlobEntry).uniq
1452
+ )
1453
+ end
1454
+ )
1455
+ end
1456
+
1457
+ # Whether every boundary row that appeared or changed during the per-file loop is one some file's analysis
1458
+ # was credited with. A row outside every file's attribution would ride this run's `run` rows only, and the
1459
+ # next narrowed run — which does not repeat the read — would drop it.
1460
+ def analysis_reads_attributed?
1461
+ post = @post_analysis_plugin_rows || plugin_boundary_rows
1462
+ pre = @pre_analysis_plugin_rows || post
1463
+ credited = @analysis_reads.each_value.flat_map(&:itself).to_set
1464
+ (post.files - pre.files).all? { |row| credited.include?(row) } &&
1465
+ (post.globs - pre.globs).all? { |row| credited.include?(row) }
1466
+ end
1467
+
1468
+ # ADR-45 WD2 (#1507) — the rows a FULL run records for the inputs an incremental recheck does not re-derive,
1469
+ # which the session therefore carries unchanged from the full run that recorded them to every later slot:
1470
+ #
1471
+ # - the signature tree the plain slot records (`RbsDescriptor.file_entries` / `.glob_entries` over the loader
1472
+ # roots this run's analysis resolved), and an existence row per signature root, configured or the
1473
+ # auto-detected `sig` — a recheck rebuilds no environment unless its closure is non-empty, and the snapshot
1474
+ # fingerprint digests the same roots (configured, or the auto-detected `sig`), so a change to one drops the
1475
+ # snapshot and forces a full run rather than reaching a recheck;
1476
+ # - the discovered-not-analysed files (#684) and a listing row per discovery root — a narrowed recheck does
1477
+ # not widen discovery at all;
1478
+ # - every `pre_eval:` file outside `files` (a stat row, or an absence row) — the session tracks only analysed
1479
+ # files, and one that is analysed is guarded by its own row.
1480
+ #
1481
+ # Recomputing any of these on a recheck would vouch for the tree the recheck saw while its answer still
1482
+ # carries rows computed before the change, so a change to one keeps the probe declining until the next full
1483
+ # run. nil when this process analysed no file itself — a pool run, or a run with no files.
1484
+ #
1485
+ # Split by what identifies the input. `pinned` holds the signature files outside the project's own signature
1486
+ # roots, which the slot's key identifies already: the engine's own (`data/`, a bundled plugin's `sig/`, a
1487
+ # gem overlay) by the engine slot, a gem's `sig/` and an `rbs collection` directory by the lockfiles. `owned`
1488
+ # holds the rest, which only their rows identify. The session's write guard refuses an `owned` row whose
1489
+ # change time it cannot read, and passes over a `pinned` one.
1490
+ BaselineRows = Data.define(:owned, :pinned)
1491
+
1492
+ SignatureRoots = Struct.new(:signature_paths)
1493
+ private_constant :SignatureRoots
1494
+
1495
+ # @param files — the run's analysed-path set.
1496
+ def baseline_dependency_rows(files:)
1497
+ return nil if pool_mode? || !@collect_stats || @analyzed_files.empty?
1498
+
1499
+ own, pinned = signature_rows
1500
+ BaselineRows.new(
1501
+ owned: Cache::Descriptor.new(
1502
+ files: own.files + existence_entries(signature_root_candidates) +
1503
+ discovery_file_entries({ files: files }) + unanalysed_pre_eval_entries(files),
1504
+ globs: (own.globs + discovery_glob_entries).uniq
1505
+ ),
1506
+ pinned: pinned
1507
+ )
1508
+ end
1509
+
1510
+ # The signature tree's rows, the project's own and the rest.
1511
+ def signature_rows
1512
+ roots = SignatureRoots.new(@snapshots.signature_paths)
1513
+ own = signature_root_candidates.map { |root| File.join(File.absolute_path(root.to_s), "") }
1514
+ own_files, other_files = Cache::RbsDescriptor.file_entries(roots, comparator: :stat)
1515
+ .partition { |row| within?(row.path, own) }
1516
+ own_globs, other_globs = Cache::RbsDescriptor.glob_entries(roots).partition { |row| within?(row.root, own) }
1517
+ [Cache::Descriptor.new(files: own_files, globs: own_globs.uniq),
1518
+ Cache::Descriptor.new(files: other_files, globs: other_globs.uniq)]
1519
+ end
1520
+
1521
+ def within?(path, roots)
1522
+ directory = File.join(File.absolute_path(path.to_s), "")
1523
+ roots.any? { |root| directory.start_with?(root) }
1524
+ end
1525
+
1526
+ # The project's own signature roots, whether or not they exist: the configured `signature_paths:`, or the
1527
+ # `sig` directory `Environment.default_signature_paths` auto-detects when none are configured. The
1528
+ # signature rows' listing rows cover a file appearing under a root that existed; only an existence row
1529
+ # covers the root itself appearing.
1530
+ def signature_root_candidates
1531
+ @configuration.signature_paths || ["sig"]
1532
+ end
1533
+
1534
+ # One names-mode listing row per directory the discovery widening globbed, so a file appearing there is seen
1535
+ # as well as an edit to one that existed.
1536
+ def discovery_glob_entries
1537
+ return [] if @project_discovery_expansion.nil?
1538
+
1539
+ @configuration.paths.select { |root| File.directory?(root) }.map do |root|
1540
+ Cache::Descriptor::GlobEntry.compute(root: root, pattern: PathExpansion::RUBY_GLOB, mode: :names)
1541
+ end
1542
+ end
1543
+
1544
+ def unanalysed_pre_eval_entries(files)
1545
+ analysed = files.to_set { |path| File.absolute_path(path) }
1546
+ outside = @configuration.pre_eval.reject { |path| analysed.include?(File.absolute_path(path)) }
1547
+ outside.map do |path|
1548
+ if File.file?(path)
1549
+ Cache::Descriptor::FileEntry.stat(path: path, digest: Cache::FileDigest.hexdigest(path))
1550
+ else
1551
+ existence_entries([path]).first
1238
1552
  end
1239
1553
  end
1240
- Cache::Descriptor.new(files: entries, globs: globs.uniq)
1241
1554
  end
1242
1555
 
1556
+ def existence_entries(paths)
1557
+ paths.uniq.map do |path|
1558
+ physical = @buffer ? @buffer.resolve(path) : path
1559
+ if File.exist?(physical)
1560
+ Cache::Descriptor::FileEntry.present(path: physical)
1561
+ else
1562
+ Cache::Descriptor::FileEntry.absent(path: physical)
1563
+ end
1564
+ end
1565
+ end
1566
+ private :plugin_boundary_rows, :producer_watch_glob_entries, :existence_entries,
1567
+ :analysis_reads_attributed?, :signature_root_candidates, :discovery_glob_entries,
1568
+ :unanalysed_pre_eval_entries, :signature_rows, :within?
1569
+
1243
1570
  # Issue #684 — the project files this run DISCOVERED but did not analyse. A widened run's diagnostics
1244
1571
  # are a function of them (that is the entire point: the method whose absence would have been reported
1245
1572
  # is defined in one of them), so the run-result cache must invalidate when one changes. Empty on every
@@ -1268,6 +1595,30 @@ module Rigor
1268
1595
  # expansion — so without this entry, editing one would leave the run-result cache serving diagnostics
1269
1596
  # computed against the previous version. The common case (a `lib/core_ext/` file that is also under
1270
1597
  # `paths:`) contributes a duplicate entry, which the descriptor's per-path validation absorbs.
1598
+ # #392 — the TEMPLATE files behind the run's units. The key slot carries the compiled Ruby's digest
1599
+ # (`template_units_digest`), which is what decides whether a cached answer describes this source; these
1600
+ # entries are the validation half, so a template edited between two runs re-globs and re-digests here
1601
+ # exactly as an analysed `.rb` file does. Both are needed: a transform that compiles two different
1602
+ # templates to the same Ruby must still not resurrect the wrong PATH in a diagnostic.
1603
+ # #392 — one `:names` row per claimed `template_globs:` pattern, whether or not it matched. The file
1604
+ # rows below cover edits to templates that EXIST; only a glob row notices one appearing or vanishing,
1605
+ # which is what let a project's first template stay invisible to every warm run.
1606
+ def template_unit_glob_entries
1607
+ template_units.glob_entries
1608
+ end
1609
+
1610
+ # Every template the run READ, successes and failures alike: a `plugin_loader` row produced by a
1611
+ # template that would not compile must not outlive the edit that fixes it, and a buffer-bound path is
1612
+ # digested at the bytes the run actually read.
1613
+ def template_unit_file_entries
1614
+ template_units.source_paths.filter_map do |path|
1615
+ physical = template_units.physical_path(path, @buffer)
1616
+ next unless File.file?(physical)
1617
+
1618
+ Cache::Descriptor::FileEntry.stat(path: physical, digest: Cache::FileDigest.hexdigest(physical))
1619
+ end
1620
+ end
1621
+
1271
1622
  def pre_eval_file_entries
1272
1623
  @configuration.pre_eval.filter_map do |path|
1273
1624
  physical = @buffer ? @buffer.resolve(path) : path
@@ -1352,19 +1703,42 @@ module Rigor
1352
1703
  @project_discovered_singleton_def_sources = discovery.discovered_singleton_def_sources
1353
1704
  @project_discovered_superclasses = discovery.discovered_superclasses
1354
1705
  @project_discovered_header_nestings = discovery.discovered_header_nestings
1355
- @project_discovered_includes = discovery.discovered_includes
1356
- @project_discovered_extends = discovery.discovered_extends
1706
+ apply_discovery_mixin_tables(discovery)
1707
+ @project_discovered_deferred_ranges = discovery.discovered_deferred_ranges
1708
+ apply_discovery_call_surface_tables(discovery)
1357
1709
  @project_discovered_class_sources = discovery.discovered_class_sources
1358
1710
  @project_constant_values = discovery.constant_values
1359
1711
  @published_constant_name_set = nil
1712
+ @constant_writer_index = nil
1713
+ @constant_shadower_index = nil
1360
1714
  @project_constant_sources = discovery.constant_sources
1361
1715
  @project_constant_writes = discovery.constant_writes
1362
1716
  @project_discovered_method_visibilities = discovery.discovered_method_visibilities
1363
1717
  @project_discovered_methods = discovery.discovered_methods
1718
+ @project_discovered_parameter_envelopes = discovery.discovered_parameter_envelopes
1364
1719
  @project_data_member_layouts = discovery.data_member_layouts
1365
1720
  @project_struct_member_layouts = discovery.struct_member_layouts
1366
1721
  end
1367
1722
 
1723
+ # The tables only check rules read beyond dispatch — the issue #1120 refinements and the issue #1367
1724
+ # global-write census — and the ADR-119 WD1 siblings of the paired members. Extracted to keep
1725
+ # {#apply_discovery_result} under its ABC budget.
1726
+ def apply_discovery_call_surface_tables(discovery)
1727
+ @project_discovery_siblings = discovery.siblings
1728
+ @project_discovered_refinements = discovery.discovered_refinements
1729
+ @project_discovered_global_write_census = discovery.discovered_global_write_census
1730
+ end
1731
+
1732
+ # The three mixin tables the discovery pass carries — the ADR-24 `include` map, its issue #1123
1733
+ # `prepend` twin, and the #898 `extend` table. Extracted to keep {#apply_discovery_result} under its
1734
+ # ABC budget.
1735
+ def apply_discovery_mixin_tables(discovery)
1736
+ @project_discovered_includes = discovery.discovered_includes
1737
+ @project_discovered_prepends = discovery.discovered_prepends
1738
+ @project_discovered_extends = discovery.discovered_extends
1739
+ @project_unpositioned_mixins = discovery.unpositioned_mixins
1740
+ end
1741
+
1368
1742
  # Internal: builds the deferred cross-file discovery tables at most once per run and adopts them.
1369
1743
  # Memoised on `@project_discovery_done` (reset at the start of `#run`). No-op under `@prebuilt` — the
1370
1744
  # LSP path deliberately seeds an empty project scope from a snapshot that carries no discovery tables,
@@ -1379,7 +1753,7 @@ module Rigor
1379
1753
 
1380
1754
  @project_discovery_done = true
1381
1755
  # Issue #684 — the cross-file index spans the PROJECT even when the analysis targets a file list.
1382
- expansion = @project_discovery_expansion || expansion
1756
+ expansion = @project_discovery_expansion || once_each(expansion)
1383
1757
  if @collect_seed_bundles
1384
1758
  # ADR-85 WD2 — rebuild discovery from the prior run's bundles (re-walking only changed files) and
1385
1759
  # capture the refreshed bundle set for the session to persist.
@@ -1413,11 +1787,42 @@ module Rigor
1413
1787
  # Excluded, each because its expansion is already the one it means: the LSP `prebuilt:` path (which
1414
1788
  # seeds discovery from a snapshot), editor `buffer:` mode, `run_source`'s in-memory single file, and
1415
1789
  # ADR-46's `analyze_only` subset mode (which already passes the full expansion and filters later).
1790
+ #
1791
+ # Issue #1556 — the widened set holds each FILE once. `.rigor.yml`'s `paths:` load as absolute paths and a
1792
+ # path argument usually does not, so `rigor check lib` widened discovery over both spellings of every file:
1793
+ # the pre-pass then saw two writers for every constant a file declares and withheld its publication (#644),
1794
+ # and every file was a discovered-not-analysed dependency of the run's cache entry.
1795
+ #
1796
+ # The widening decision itself still compares the paths as spelled. `exclude:` patterns match a path as it
1797
+ # is spelled, so until #1576 is fixed the configured absolute `lib` can discover files the argument `lib`
1798
+ # excludes (a relative `lib/gen/**` pattern never matches `/abs/lib/gen/x.rb`), and treating the two as the
1799
+ # same root would drop those files from discovery.
1416
1800
  def project_discovery_expansion(paths, expansion)
1417
1801
  return nil unless widen_discovery_to_project?(paths)
1418
1802
 
1419
- widened = expand_paths(@configuration.paths | paths)
1420
- widened.fetch(:files).size > expansion.fetch(:files).size ? widened : nil
1803
+ analysed = expansion.fetch(:files)
1804
+ files = discovery_files(expand_paths(@configuration.paths | paths).fetch(:files), analysed)
1805
+ # Overlapping arguments (`lib lib/user.rb`) name an analysed file twice; the widened set names it once.
1806
+ files.size > analysed.uniq { |path| File.absolute_path(path) }.size ? { files: files, errors: [] } : nil
1807
+ end
1808
+
1809
+ # The expansion with each file once, first spelling kept: overlapping arguments (`lib ./lib`) that do not
1810
+ # widen discovery still name a file twice, and the pre-pass would take every constant in it for one written
1811
+ # twice (#1556). Symlinked aliases (`src -> lib`) are not folded; that needs a realpath and stays out of scope.
1812
+ def once_each(expansion)
1813
+ files = expansion.fetch(:files)
1814
+ unique = files.uniq { |path| File.absolute_path(path) }
1815
+ unique.size == files.size ? expansion : expansion.merge(files: unique)
1816
+ end
1817
+
1818
+ # Each file once, in project order, under the spelling the analysis uses when it is analysed.
1819
+ def discovery_files(project_files, analysed_files)
1820
+ spelling = analysed_files.to_h { |path| [File.absolute_path(path), path] }
1821
+ seen = Set.new
1822
+ project_files.filter_map do |path|
1823
+ absolute = File.absolute_path(path)
1824
+ spelling.fetch(absolute, path) if seen.add?(absolute)
1825
+ end
1421
1826
  end
1422
1827
 
1423
1828
  def widen_discovery_to_project?(paths)
@@ -1436,7 +1841,7 @@ module Rigor
1436
1841
 
1437
1842
  private :run_project_pre_passes, :adopt_prebuilt_project_scan, :apply_pre_passes_result,
1438
1843
  :apply_discovery_result, :ensure_project_discovery, :force_eager_discovery?,
1439
- :project_discovery_expansion, :widen_discovery_to_project?
1844
+ :project_discovery_expansion, :discovery_files, :once_each, :widen_discovery_to_project?
1440
1845
 
1441
1846
  # Ruby versions probed (ascending) to discover the lowest one this Prism build accepts for
1442
1847
  # `version:`. Prism exposes no version list, so the floor is found empirically — only when a
@@ -1498,6 +1903,25 @@ module Rigor
1498
1903
  # The buffer's logical path is added to the file list even if it's not under `paths:` — per design §
1499
1904
  # "Failure envelope": "--instead-of=Y with Y not under any paths: directory → treated as a valid
1500
1905
  # logical identity for the buffer".
1906
+ # #392 — the run's template units. Built once per run, on the parent, AFTER the plugin pre-pass has
1907
+ # loaded the registry; a run whose plugins declare no `template_globs:` never globs and never calls a
1908
+ # plugin.
1909
+ #
1910
+ # #1038 — a runner built with `prebuilt:` hands the snapshot's warm index to `collect(previous:)`, so
1911
+ # a per-buffer publish against a long-lived {LanguageServer::ProjectContext} re-runs the transform
1912
+ # only for a template that actually moved on disk (and for the buffer's own, always). The globs are
1913
+ # still re-expanded and every surviving template is still revalidated, so an added, deleted or edited
1914
+ # template is seen on the next publish without the owner invalidating anything. A runner with no
1915
+ # `prebuilt:` — every CLI run — passes no `previous:` and builds the index exactly as before.
1916
+ def template_units
1917
+ @template_units ||= if @plugin_registry.nil?
1918
+ TemplateUnits.empty
1919
+ else
1920
+ TemplateUnits.collect(registry: @plugin_registry, buffer: @buffer,
1921
+ previous: @prebuilt&.template_units)
1922
+ end
1923
+ end
1924
+
1501
1925
  def target_files(expansion)
1502
1926
  files = expansion.fetch(:files)
1503
1927
  # ADR-46 slice 2 — restrict the analyzed set to the affected closure while the pre-pass (run
@@ -1508,14 +1932,41 @@ module Rigor
1508
1932
  # the same allowance option A makes below.
1509
1933
  files = files.select { |path| @analyze_only.include?(path) }
1510
1934
  files |= [@buffer.logical_path] if @buffer && @analyze_only.include?(@buffer.logical_path)
1511
- return files
1935
+ return template_unit_targets(files)
1512
1936
  end
1513
- return files if @buffer.nil?
1937
+ return template_unit_targets(files) if @buffer.nil?
1514
1938
 
1515
- # Editor mode option A — no closure, so the buffer's single logical path IS the analyzed set.
1939
+ # Editor mode option A — no closure, so the buffer's single logical path IS the analyzed set. The
1940
+ # template units deliberately do NOT join it: a per-buffer publish answers about the buffer the
1941
+ # editor is showing, and appending every view would publish diagnostics for files the editor did
1942
+ # not ask about (and re-parse them on every keystroke).
1516
1943
  [@buffer.logical_path]
1517
1944
  end
1518
1945
 
1946
+ # #392 — the template units join the analysed set AFTER any narrowing, so an `--incremental` recheck
1947
+ # re-analyses every unit even when its closure is one `.rb` file. Nothing in the ADR-46 dependency
1948
+ # graph names a synthesised file, so there is no edge that could put a unit in a closure — narrowing
1949
+ # first and appending second is what makes "always re-analysed" true rather than a comment. The cost
1950
+ # is one parse per template on the warm path; the alternative silently drops every template
1951
+ # diagnostic from the second run onwards.
1952
+ #
1953
+ # Appended rather than merged in sorted order so the `.rb` expansion's own order — and every output
1954
+ # that follows it — is byte-identical for a project with no units.
1955
+ def template_unit_targets(files)
1956
+ return files if template_units.empty?
1957
+
1958
+ files + (template_units.paths - files)
1959
+ end
1960
+ private :template_unit_targets
1961
+
1962
+ # #392 — the unit paths this run synthesised. Read by {IncrementalSession}, which keeps them OUT of
1963
+ # its analysed-file set: a unit is never served from the per-file cache and never counts as a file
1964
+ # that was removed from the project, because `#target_files` re-analyses it every run.
1965
+ def template_unit_paths
1966
+ template_units.paths
1967
+ end
1968
+ public :template_unit_paths
1969
+
1519
1970
  # Editor mode (`buffer:` non-nil) auto-flips the cache store to `read_only: true` so multiple
1520
1971
  # debounced editor invocations against the same project don't churn the on-disk cache or race on
1521
1972
  # schema-version writes. Cache reads continue unchanged; misses still run the producer block but the
@@ -1552,6 +2003,7 @@ module Rigor
1552
2003
  synthetic_method_index: -> { @synthetic_method_index },
1553
2004
  project_patched_methods: -> { @project_patched_methods },
1554
2005
  project_scope_seed: -> { project_scope_seed_tables },
2006
+ template_units: -> { template_units },
1555
2007
  analyze_file: ->(path, environment) { analyze_file(path, environment) },
1556
2008
  restored_run_level_rows: @restored_run_level_rows
1557
2009
  )
@@ -1571,7 +2023,8 @@ module Rigor
1571
2023
  env_build_failure_snapshot: -> { @snapshots.env_build_failure },
1572
2024
  definition_build_failures_snapshot: -> { @snapshots.definition_build_failures },
1573
2025
  hkt_scan_failure_snapshot: -> { @snapshots.hkt_scan_failure },
1574
- conformance_results_snapshot: -> { @snapshots.conformance_results }
2026
+ conformance_results_snapshot: -> { @snapshots.conformance_results },
2027
+ pooled_run_disclosures: -> { @pool_coordinator.collected_run_disclosures }
1575
2028
  )
1576
2029
  end
1577
2030
 
@@ -1598,7 +2051,9 @@ module Rigor
1598
2051
  RunStats.new(
1599
2052
  wall_seconds: Process.clock_gettime(Process::CLOCK_MONOTONIC) - wall_started_at,
1600
2053
  peak_rss_bytes: RunStats.peak_rss_bytes,
1601
- target_files: expansion.fetch(:files).size,
2054
+ # #392 — the template units count too: they are files this run parsed and typed, and a stats line
2055
+ # that omitted them would under-report exactly the work a views-heavy project added.
2056
+ target_files: expansion.fetch(:files).size + template_units.paths.size,
1602
2057
  rbs_classes_total: snapshot.size,
1603
2058
  rbs_classes_project_sig: project_sig,
1604
2059
  rbs_classes_bundled: bundled,
@@ -1649,7 +2104,7 @@ module Rigor
1649
2104
  end
1650
2105
 
1651
2106
  def shadow_rule_walk?
1652
- ENV.fetch("RIGOR_SHADOW_RULE_WALK", nil)
2107
+ ShadowHarness.enabled?
1653
2108
  end
1654
2109
 
1655
2110
  def collect_plugin_diagnostics(plugin, path, root, scope, node_result)
@@ -1755,6 +2210,12 @@ module Rigor
1755
2210
  # so Prism's location data carries the LOGICAL path. Non-binding paths go through the cheaper
1756
2211
  # `Prism.parse_file` codepath unchanged.
1757
2212
  def parse_source(path)
2213
+ entry = template_units[path]
2214
+ if entry
2215
+ return Prism.parse(entry.source, filepath: path, version: @configuration.target_ruby,
2216
+ scopes: template_units.parse_scopes(path))
2217
+ end
2218
+
1758
2219
  if @in_memory_sources&.key?(path)
1759
2220
  return Prism.parse(@in_memory_sources[path], filepath: path, version: @configuration.target_ruby)
1760
2221
  end
@@ -1788,13 +2249,9 @@ module Rigor
1788
2249
  # bucket identically to the sequential path.
1789
2250
  tables[:run_generation] = @run_generation if @run_generation
1790
2251
  tables[:discovered_classes] = @project_discovered_classes unless @project_discovered_classes.empty?
1791
- tables[:discovered_def_nodes] = @project_discovered_def_nodes unless @project_discovered_def_nodes.empty?
1792
2252
  unless @project_discovered_def_nestings.empty?
1793
2253
  tables[:discovered_def_nestings] = @project_discovered_def_nestings
1794
2254
  end
1795
- unless @project_discovered_singleton_def_nodes.empty?
1796
- tables[:discovered_singleton_def_nodes] = @project_discovered_singleton_def_nodes
1797
- end
1798
2255
  seed_def_source_tables(tables)
1799
2256
  unless @project_discovered_superclasses.empty?
1800
2257
  tables[:discovered_superclasses] = @project_discovered_superclasses
@@ -1803,33 +2260,77 @@ module Rigor
1803
2260
  tables[:discovered_header_nestings] = @project_discovered_header_nestings
1804
2261
  end
1805
2262
  seed_mixin_tables(tables)
1806
- unless @project_discovered_method_visibilities.empty?
1807
- tables[:discovered_method_visibilities] = @project_discovered_method_visibilities
1808
- end
1809
- tables[:discovered_methods] = @project_discovered_methods unless @project_discovered_methods.empty?
2263
+ seed_paired_tables(tables)
2264
+ seed_call_surface_tables(tables)
1810
2265
  seed_opt_in_pre_pass_tables(tables)
1811
2266
  seed_member_layout_tables(tables)
1812
2267
  seed_dependency_attribution_tables(tables)
1813
- tables
2268
+ # Only a base seed can bring in half a pair (`seed_paired_tables` writes both halves), so a run without
2269
+ # one skips the copy this costs per file.
2270
+ @discovery_seed ? Scope::DiscoveryIndex.compact_pairs(tables) : tables
2271
+ end
2272
+
2273
+ # ADR-119 WD1 — the members `Scope::DiscoveryIndex::SIBLINGS` pairs, each with its sibling. A pair rides the
2274
+ # seed when either half is non-empty and is left out when both are, which keeps an `discovery_seed:` base's
2275
+ # table for a pair this run computed nothing for. {Scope::DiscoveryIndex#with} takes the two together, and
2276
+ # `compact_pairs` completes a half pair a base seed brought in.
2277
+ PAIRED_MEMBER_IVARS = Scope::DiscoveryIndex::SIBLINGS.keys.to_h do |member|
2278
+ [member, :"@project_#{member}"]
2279
+ end.freeze
2280
+ private_constant :PAIRED_MEMBER_IVARS
2281
+
2282
+ def seed_paired_tables(tables)
2283
+ PAIRED_MEMBER_IVARS.each do |member, ivar|
2284
+ sibling = Scope::DiscoveryIndex::SIBLINGS.fetch(member)
2285
+ table = instance_variable_get(ivar)
2286
+ own = @project_discovery_siblings.fetch(sibling)
2287
+ next if table.empty? && own.empty?
2288
+
2289
+ tables[member] = table
2290
+ tables[sibling] = own
2291
+ end
2292
+ end
2293
+
2294
+ # The tables only check rules read: the issue #1120 refinements (`call.undefined-method`) and the issue #1367
2295
+ # global-write census (the `global.*` write rules); the issue #992 envelopes ride {#seed_paired_tables}. Split
2296
+ # out of {#project_scope_seed_tables} to keep it under the complexity budget.
2297
+ def seed_call_surface_tables(tables)
2298
+ tables[:discovered_refinements] = @project_discovered_refinements unless @project_discovered_refinements.empty?
2299
+ # Issue #1367 — read by the `global.*` write rules.
2300
+ return if @project_discovered_global_write_census.empty?
2301
+
2302
+ tables[:discovered_global_write_census] = @project_discovered_global_write_census
1814
2303
  end
1815
2304
 
1816
- # The two mixin tables: the ADR-24 instance-side `include` / `prepend` map and its #898 singleton-side
1817
- # `extend` twin. Paired here because they are read as one picture of a class's ancestry, and split out
1818
- # of {#project_scope_seed_tables} to keep it under the complexity budget.
2305
+ # The three mixin tables: the ADR-24 instance-side `include` map, its issue #1123 `prepend` twin
2306
+ # (which carries the instance-ancestor order the include table's set-shaped value cannot), and the
2307
+ # #898 singleton-side `extend` table. Grouped here because they are read as one picture of a class's
2308
+ # ancestry, and split out of {#project_scope_seed_tables} to keep it under the complexity budget.
1819
2309
  def seed_mixin_tables(tables)
1820
2310
  tables[:discovered_includes] = @project_discovered_includes unless @project_discovered_includes.empty?
2311
+ tables[:discovered_prepends] = @project_discovered_prepends unless @project_discovered_prepends.empty?
1821
2312
  tables[:discovered_extends] = @project_discovered_extends unless @project_discovered_extends.empty?
2313
+ tables[:unpositioned_mixins] = @project_unpositioned_mixins unless @project_unpositioned_mixins.empty?
1822
2314
  end
1823
2315
 
1824
- # ADR-46 slice 1 / issue #644 — the two SOURCE-ATTRIBUTION tables, read only by the recording accessors
2316
+ # ADR-46 slice 1 / issue #644 — the two SOURCE-ATTRIBUTION tables, read by the recording accessors
1825
2317
  # (`Scope#record_class_dependency` for the class-declaration map, `Scope#record_constant_dependency`
1826
- # for the constant-write map). A normal run carries neither. Extracted to keep
1827
- # {#project_scope_seed_tables} under the complexity budget.
2318
+ # for the constant-write map). The class-declaration map rides every run: an ancestor-order reader also
2319
+ # asks whether a class is declared in more than one file. The constant-write map rides only a recording
2320
+ # run. Extracted to keep {#project_scope_seed_tables} under the complexity budget.
2321
+ #
2322
+ # Issue #617 — the census's binding writes, grouped by last segment, ride every run instead: a constant
2323
+ # compound write whose plain read resolves nothing reads its binding off them, a TYPE rather than an edge.
2324
+ # Issue #1290's shadowing writes ride beside them for the lexical ladder, for the same reason.
1828
2325
  def seed_dependency_attribution_tables(tables)
1829
- return unless @record_dependencies
1830
-
2326
+ writers = constant_writer_index
2327
+ tables[:constant_writers] = writers unless writers.empty?
2328
+ shadowers = constant_shadower_index
2329
+ tables[:constant_shadowers] = shadowers unless shadowers.empty?
1831
2330
  tables[:discovered_class_sources] = @project_discovered_class_sources unless
1832
2331
  @project_discovered_class_sources.empty?
2332
+ return unless @record_dependencies
2333
+
1833
2334
  tables[:constant_sources] = @project_constant_sources unless @project_constant_sources.empty?
1834
2335
  end
1835
2336
 
@@ -1860,6 +2361,44 @@ module Rigor
1860
2361
  tables[:published_constant_names] = names unless names.empty?
1861
2362
  end
1862
2363
 
2364
+ # Issue #617 — the censused names that bind, grouped by last segment (`Scope#bound_constant_names`). A
2365
+ # name some write other than a memo `||=` touches always binds. A memo-only name binds once memos of its
2366
+ # segment span two files: either may load first and set the constant the other then reads, so only a
2367
+ # memo no other file shares keeps the idiom's reading. Files are counted expanded, because discovery can
2368
+ # walk one file under two spellings (`lib/b.rb`, `./lib/b.rb`).
2369
+ def constant_writer_index
2370
+ @constant_writer_index ||= begin
2371
+ memo_files = memo_files_by_segment
2372
+ @project_constant_writes.each_with_object({}) do |(name, by_path), index|
2373
+ segment = name.split("::").last
2374
+ next if memo_only?(by_path) && memo_files.fetch(segment).size < 2
2375
+
2376
+ (index[segment] ||= []) << name
2377
+ end.each_value(&:freeze).freeze
2378
+ end
2379
+ end
2380
+
2381
+ # Issue #1290 — the censused names some write other than a memo `||=` assigns, grouped by last segment
2382
+ # (`Scope#shadowing_constant_names`): the ones that exist whether or not an outer constant of the name
2383
+ # does, and so stop the lexical ladder above it. A memo-only name is left out however many files memoize
2384
+ # it, unlike in {#constant_writer_index}: `X ||= v` resolves `X` through the very lookup whose lower rung
2385
+ # the stop would replace, so wherever that rung answers, the memo finds it and never creates the name.
2386
+ def constant_shadower_index
2387
+ @constant_shadower_index ||= @project_constant_writes.each_with_object({}) do |(name, by_path), index|
2388
+ (index[name.split("::").last] ||= []) << name unless memo_only?(by_path)
2389
+ end.each_value(&:freeze).freeze
2390
+ end
2391
+
2392
+ def memo_files_by_segment
2393
+ @project_constant_writes.each_with_object({}) do |(name, by_path), files|
2394
+ set = (files[name.split("::").last] ||= Set.new)
2395
+ by_path.each { |path, descriptor| set << File.absolute_path(path) if memo_descriptor?(descriptor) }
2396
+ end
2397
+ end
2398
+
2399
+ def memo_only?(by_path) = by_path.each_value.all? { |descriptor| memo_descriptor?(descriptor) }
2400
+ def memo_descriptor?(descriptor) = descriptor == Inference::ScopeIndexer::CONSTANT_MEMO
2401
+
1863
2402
  # Issue #644 — the LAST SEGMENTS of the published table, the run-wide half of
1864
2403
  # `Scope#published_constant?`. Seeded on EVERY run (unlike `constant_sources`, which only a recording
1865
2404
  # run needs) because {CheckRules::PublishedConstantGuard} asks the question on every `if` / `unless`.
@@ -1911,14 +2450,14 @@ module Rigor
1911
2450
  writers = @project_constant_sources[name]
1912
2451
  wildcard = @project_constant_sources["#{Inference::ScopeIndexer::DYNAMIC_TARGET_PREFIX}#{segment}"]
1913
2452
  [writers, wildcard].any? do |paths|
1914
- paths&.any? { |path| !listed.include?(File.expand_path(path)) }
2453
+ paths&.any? { |path| !listed.include?(File.absolute_path(path)) }
1915
2454
  end
1916
2455
  end
1917
2456
 
1918
2457
  # The listed `pre_eval:` files, expanded so a census path and a configured one compare as the same
1919
2458
  # file whichever spelling each arrived in.
1920
2459
  def pre_eval_path_set
1921
- @pre_eval_path_set ||= @configuration.pre_eval.to_set { |path| File.expand_path(path) }
2460
+ @pre_eval_path_set ||= @configuration.pre_eval.to_set { |path| File.absolute_path(path) }
1922
2461
  end
1923
2462
 
1924
2463
  # ADR-46 — seed the instance + singleton `"path:line"` def-source tables (each only when non-empty).
@@ -1948,11 +2487,18 @@ module Rigor
1948
2487
  def analyze_file(path, environment)
1949
2488
  return analyze_with_effects(path, environment) unless @record_dependencies
1950
2489
 
2490
+ # ADR-45 WD2 (#1507) — every plugin read this file's analysis makes is attributed to it, so the
2491
+ # incremental session can validate them on a later run that serves this file's answer from cache.
2492
+ @pre_analysis_plugin_rows ||= plugin_boundary_rows
2493
+ reads = []
1951
2494
  diagnostics = nil
1952
- record = DependencyRecorder.record_for(path) do
1953
- diagnostics = analyze_with_effects(path, environment)
2495
+ record = Plugin::IoBoundary.attributing(reads) do
2496
+ DependencyRecorder.record_for(path) do
2497
+ diagnostics = analyze_with_effects(path, environment)
2498
+ end
1954
2499
  end
1955
2500
  @file_dependencies[path] = record
2501
+ @analysis_reads[path] = reads unless reads.empty?
1956
2502
  diagnostics
1957
2503
  end
1958
2504
 
@@ -1963,10 +2509,11 @@ module Rigor
1963
2509
  def analyze_with_effects(path, environment)
1964
2510
  return analyze_file_body(path, environment) unless @record_effects
1965
2511
 
2512
+ entry = template_units[path]
1966
2513
  diagnostics = nil
1967
2514
  collection = Effects::Collector.collect_for(
1968
2515
  path, attribution: @effect_attribution, envelopes: effect_envelope_index(environment),
1969
- plugin_facts: effect_plugin_facts
2516
+ plugin_facts: effect_plugin_facts, unit_key: entry&.unit_key, unit_owner: entry&.self_type
1970
2517
  ) do
1971
2518
  diagnostics = analyze_file_body(path, environment)
1972
2519
  end
@@ -2005,7 +2552,9 @@ module Rigor
2005
2552
  # is a per-FILE site, so one `Thread.current` read is already nothing. The per-dispatch site in
2006
2553
  # `ExpressionTyper#call_type_for` is the one that pays for the integer fast path.
2007
2554
  Effects::Collector.record_root(parse_result.value)
2008
- scope = seed_project_scope(Scope.empty(environment: environment, source_path: path))
2555
+ scope = template_units.seed(
2556
+ seed_project_scope(Scope.empty(environment: environment, source_path: path)), path
2557
+ )
2009
2558
  # ADR-24 slice 4a/4 — record unresolved implicit-self calls during the typing pass ONLY (not
2010
2559
  # CheckRules, whose own `type_of` queries would otherwise re-trigger the choke-point).
2011
2560
  # `self_call_misses` feeds the `call.self-undefined-method` collector; the recorder is inert unless
@@ -2045,7 +2594,7 @@ module Rigor
2045
2594
  # output and the plugin diagnostics are then built from that single walk's results.
2046
2595
  def rule_and_plugin_diagnostics(path, parse_result, scope, index, self_call_misses)
2047
2596
  root = parse_result.value
2048
- node_collectors = CheckRules.build_node_collectors(path, index)
2597
+ node_collectors = CheckRules.build_node_collectors(path, index, root)
2049
2598
  node_results = node_rule_results_by_plugin(path, root, scope, node_collectors, index)
2050
2599
  diagnostics = CheckRules.diagnose(
2051
2600
  path: path,