rigortype 0.3.9 → 0.4.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 (363) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  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/10-sorbet.md +9 -10
  14. data/docs/handbook/11-sig-gen.md +454 -13
  15. data/docs/manual/02-cli-reference.md +63 -2
  16. data/docs/manual/03-configuration.md +7 -0
  17. data/docs/manual/04-diagnostics.md +4 -0
  18. data/docs/manual/07-plugins.md +1 -1
  19. data/docs/manual/10-mcp-server.md +3 -2
  20. data/docs/manual/16-rbs-extended-annotations.md +40 -7
  21. data/docs/manual/19-effect-labels.md +10 -0
  22. data/docs/manual/plugins/README.md +7 -0
  23. data/docs/manual/plugins/rigor-actioncable.md +8 -1
  24. data/docs/manual/plugins/rigor-actionmailer.md +7 -0
  25. data/docs/manual/plugins/rigor-actionpack.md +243 -0
  26. data/docs/manual/plugins/rigor-active-model-serializers.md +153 -0
  27. data/docs/manual/plugins/rigor-activejob.md +7 -0
  28. data/docs/manual/plugins/rigor-activerecord.md +110 -4
  29. data/docs/manual/plugins/rigor-activestorage.md +8 -1
  30. data/docs/manual/plugins/rigor-activesupport-core-ext.md +11 -5
  31. data/docs/manual/plugins/rigor-grape.md +106 -0
  32. data/docs/manual/plugins/rigor-graphql.md +23 -2
  33. data/docs/manual/plugins/rigor-pundit.md +8 -1
  34. data/docs/manual/plugins/rigor-rails-i18n.md +10 -5
  35. data/docs/manual/plugins/rigor-rails-routes.md +7 -0
  36. data/docs/manual/plugins/rigor-rbs-inline.md +212 -25
  37. data/docs/manual/plugins/rigor-sidekiq.md +8 -1
  38. data/docs/manual/plugins/rigor-sorbet.md +21 -5
  39. data/exe/rigor +19 -4
  40. data/lib/rigor/analysis/baseline.rb +1 -1
  41. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +167 -2
  42. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +189 -0
  43. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +5 -3
  44. data/lib/rigor/analysis/check_rules/rule_ids.rb +20 -5
  45. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  46. data/lib/rigor/analysis/check_rules/source_arity.rb +323 -0
  47. data/lib/rigor/analysis/check_rules/special_global_setters.rb +146 -0
  48. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +36 -2
  49. data/lib/rigor/analysis/check_rules.rb +520 -49
  50. data/lib/rigor/analysis/dependency_recorder.rb +23 -0
  51. data/lib/rigor/analysis/fact_store.rb +9 -0
  52. data/lib/rigor/analysis/incremental.rb +26 -0
  53. data/lib/rigor/analysis/incremental_session.rb +36 -4
  54. data/lib/rigor/analysis/project_scan.rb +11 -1
  55. data/lib/rigor/analysis/reachability/plugin_roots.rb +0 -1
  56. data/lib/rigor/analysis/reachability/scan_cache.rb +1 -0
  57. data/lib/rigor/analysis/rule_catalog.rb +127 -0
  58. data/lib/rigor/analysis/run_cache_key.rb +20 -11
  59. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +167 -11
  60. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +9 -4
  61. data/lib/rigor/analysis/runner/pool_coordinator.rb +107 -15
  62. data/lib/rigor/analysis/runner/project_pre_passes.rb +26 -7
  63. data/lib/rigor/analysis/runner.rb +278 -20
  64. data/lib/rigor/analysis/template_unit_collector.rb +303 -0
  65. data/lib/rigor/analysis/template_unit_paths.rb +91 -0
  66. data/lib/rigor/analysis/template_unit_positions.rb +293 -0
  67. data/lib/rigor/analysis/template_units.rb +399 -0
  68. data/lib/rigor/analysis/worker_session.rb +53 -10
  69. data/lib/rigor/bleeding_edge.rb +0 -2
  70. data/lib/rigor/builtins/hkt_builtins.rb +1 -0
  71. data/lib/rigor/builtins/imported_refinements.rb +4 -0
  72. data/lib/rigor/builtins/regex_refinement.rb +17 -9
  73. data/lib/rigor/builtins/static_return_refinements.rb +2 -0
  74. data/lib/rigor/cache/descriptor.rb +6 -1
  75. data/lib/rigor/cache/engine_source.rb +29 -1
  76. data/lib/rigor/cache/incremental_snapshot.rb +33 -1
  77. data/lib/rigor/cache/rbs_cache_producer.rb +4 -0
  78. data/lib/rigor/cache/rbs_descriptor.rb +37 -7
  79. data/lib/rigor/cache/store.rb +0 -1
  80. data/lib/rigor/ci_detector.rb +1 -0
  81. data/lib/rigor/cli/doc_links.rb +1 -1
  82. data/lib/rigor/cli/docs_command.rb +4 -4
  83. data/lib/rigor/cli/plugin_command.rb +3 -3
  84. data/lib/rigor/cli/prism_colorizer.rb +0 -1
  85. data/lib/rigor/cli/sig_gen_command.rb +210 -29
  86. data/lib/rigor/cli/skill_command.rb +1 -1
  87. data/lib/rigor/cli/skill_describe.rb +0 -1
  88. data/lib/rigor/cli/type_of_command.rb +19 -5
  89. data/lib/rigor/cli/type_of_renderer.rb +20 -9
  90. data/lib/rigor/cli/type_of_template_probe.rb +189 -0
  91. data/lib/rigor/cli.rb +21 -3
  92. data/lib/rigor/configuration/severity_profile.rb +19 -3
  93. data/lib/rigor/configuration.rb +66 -3
  94. data/lib/rigor/effects/ancestry_recorder.rb +191 -0
  95. data/lib/rigor/effects/attribution.rb +11 -2
  96. data/lib/rigor/effects/callee_rule.rb +368 -0
  97. data/lib/rigor/effects/catalog.rb +7 -4
  98. data/lib/rigor/effects/collector.rb +11 -5
  99. data/lib/rigor/effects/config_envelopes.rb +9 -2
  100. data/lib/rigor/effects/definition_context.rb +179 -0
  101. data/lib/rigor/effects/effect_table.rb +11 -3
  102. data/lib/rigor/effects/envelope_check.rb +1 -1
  103. data/lib/rigor/effects/envelope_index.rb +15 -0
  104. data/lib/rigor/effects/file_collection.rb +59 -5
  105. data/lib/rigor/effects/framework_units.rb +1 -1
  106. data/lib/rigor/effects/identity.rb +16 -0
  107. data/lib/rigor/effects/local_ownership.rb +38 -12
  108. data/lib/rigor/effects/method_key.rb +21 -0
  109. data/lib/rigor/effects/mutation_classifier.rb +23 -12
  110. data/lib/rigor/effects/plugin_facts.rb +43 -31
  111. data/lib/rigor/effects/propagator.rb +295 -16
  112. data/lib/rigor/effects/registry.rb +1 -1
  113. data/lib/rigor/effects/scanner.rb +121 -72
  114. data/lib/rigor/effects/signature_sources.rb +1 -1
  115. data/lib/rigor/effects/snapshot.rb +2 -1
  116. data/lib/rigor/effects/summary.rb +27 -4
  117. data/lib/rigor/effects/unit_scan.rb +385 -30
  118. data/lib/rigor/effects/visibility.rb +101 -0
  119. data/lib/rigor/environment/lockfile_resolver.rb +17 -0
  120. data/lib/rigor/environment/member_consistency/comparator.rb +708 -0
  121. data/lib/rigor/environment/member_consistency.rb +298 -0
  122. data/lib/rigor/environment/rbs_loader.rb +399 -133
  123. data/lib/rigor/environment.rb +103 -38
  124. data/lib/rigor/hashing/xxh3.rb +264 -0
  125. data/lib/rigor/inference/acceptance.rb +139 -9
  126. data/lib/rigor/inference/block_auto_splat.rb +216 -0
  127. data/lib/rigor/inference/block_call_timing.rb +338 -0
  128. data/lib/rigor/inference/block_parameter_binder.rb +73 -27
  129. data/lib/rigor/inference/block_repetition.rb +71 -0
  130. data/lib/rigor/inference/body_fixpoint.rb +2 -1
  131. data/lib/rigor/inference/budget_trace.rb +2 -1
  132. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  133. data/lib/rigor/inference/builtins/string_catalog.rb +1 -1
  134. data/lib/rigor/inference/captured_locals.rb +387 -15
  135. data/lib/rigor/inference/closure_escape_analyzer.rb +157 -13
  136. data/lib/rigor/inference/content_join.rb +200 -27
  137. data/lib/rigor/inference/def_return_typer.rb +11 -7
  138. data/lib/rigor/inference/define_method_block_self.rb +64 -0
  139. data/lib/rigor/inference/element_read_widening.rb +22 -10
  140. data/lib/rigor/inference/error_info.rb +196 -0
  141. data/lib/rigor/inference/expression_typer.rb +1532 -496
  142. data/lib/rigor/inference/external_ancestor_resolution.rb +267 -0
  143. data/lib/rigor/inference/fresh_frame_blocks.rb +127 -0
  144. data/lib/rigor/inference/global_write_census.rb +239 -0
  145. data/lib/rigor/inference/guard_rebinding.rb +447 -0
  146. data/lib/rigor/inference/hash_lookup_mutation.rb +88 -0
  147. data/lib/rigor/inference/index_write_widening.rb +16 -3
  148. data/lib/rigor/inference/indexed_narrowing.rb +61 -9
  149. data/lib/rigor/inference/jump_targets.rb +82 -0
  150. data/lib/rigor/inference/last_line/implicit_self.rb +210 -0
  151. data/lib/rigor/inference/last_line/self_evidence.rb +329 -0
  152. data/lib/rigor/inference/last_line.rb +340 -0
  153. data/lib/rigor/inference/last_status.rb +144 -0
  154. data/lib/rigor/inference/macro_block_self_type.rb +167 -17
  155. data/lib/rigor/inference/match_rebinding/calls.rb +281 -0
  156. data/lib/rigor/inference/match_rebinding/frame.rb +148 -0
  157. data/lib/rigor/inference/match_rebinding/operands.rb +236 -0
  158. data/lib/rigor/inference/match_rebinding/self_calls.rb +83 -0
  159. data/lib/rigor/inference/match_rebinding.rb +392 -0
  160. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  161. data/lib/rigor/inference/method_dispatcher/block_folding.rb +85 -24
  162. data/lib/rigor/inference/method_dispatcher/facet_distribution.rb +147 -0
  163. data/lib/rigor/inference/method_dispatcher/hash_transform_keys_folding.rb +191 -0
  164. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +4 -0
  165. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +159 -0
  166. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +113 -73
  167. data/lib/rigor/inference/method_dispatcher/process_folding.rb +56 -0
  168. data/lib/rigor/inference/method_dispatcher/proven_overload.rb +72 -0
  169. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +881 -38
  170. data/lib/rigor/inference/method_dispatcher/self_substitute.rb +142 -0
  171. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +128 -53
  172. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +1 -1
  173. data/lib/rigor/inference/method_dispatcher.rb +214 -13
  174. data/lib/rigor/inference/method_parameter_binder.rb +8 -3
  175. data/lib/rigor/inference/multi_target_binder.rb +340 -52
  176. data/lib/rigor/inference/mutation_rejoin.rb +4 -1
  177. data/lib/rigor/inference/mutation_widening.rb +70 -48
  178. data/lib/rigor/inference/narrowing.rb +540 -120
  179. data/lib/rigor/inference/operand_effects.rb +167 -0
  180. data/lib/rigor/inference/operand_walk.rb +88 -0
  181. data/lib/rigor/inference/optimistic_origin.rb +152 -9
  182. data/lib/rigor/inference/parameter_inference_collector.rb +3 -2
  183. data/lib/rigor/inference/project_method_ownership.rb +136 -0
  184. data/lib/rigor/inference/project_patched_methods.rb +7 -2
  185. data/lib/rigor/inference/project_patched_scanner.rb +7 -3
  186. data/lib/rigor/inference/receiver_alias.rb +90 -1
  187. data/lib/rigor/inference/receiver_blind_block.rb +219 -0
  188. data/lib/rigor/inference/refinement_mutation.rb +15 -11
  189. data/lib/rigor/inference/repeated_or_writes.rb +463 -0
  190. data/lib/rigor/inference/return_barrier.rb +54 -0
  191. data/lib/rigor/inference/rewrite_mutation.rb +120 -0
  192. data/lib/rigor/inference/scope_indexer.rb +4615 -551
  193. data/lib/rigor/inference/statement_evaluator.rb +3176 -490
  194. data/lib/rigor/inference/stored_block_call.rb +54 -0
  195. data/lib/rigor/inference/string_mutation.rb +44 -7
  196. data/lib/rigor/inference/unknown_store_widening.rb +200 -0
  197. data/lib/rigor/inference/unthreaded_rebinds.rb +282 -0
  198. data/lib/rigor/language_server/debouncer.rb +0 -1
  199. data/lib/rigor/language_server/diagnostic_publisher.rb +3 -2
  200. data/lib/rigor/language_server/hover_renderer.rb +3 -3
  201. data/lib/rigor/language_server/project_context.rb +5 -3
  202. data/lib/rigor/mcp/server.rb +2 -1
  203. data/lib/rigor/plugin/base.rb +168 -5
  204. data/lib/rigor/plugin/box_probe.rb +91 -0
  205. data/lib/rigor/plugin/bundled_catalog.rb +1 -1
  206. data/lib/rigor/plugin/effect_attribution.rb +58 -4
  207. data/lib/rigor/plugin/loader.rb +2 -1
  208. data/lib/rigor/plugin/macro/block_as_method.rb +45 -6
  209. data/lib/rigor/plugin/manifest.rb +71 -10
  210. data/lib/rigor/plugin/registry.rb +35 -1
  211. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  212. data/lib/rigor/plugin/template_unit.rb +196 -0
  213. data/lib/rigor/plugin.rb +1 -0
  214. data/lib/rigor/protection/discovery_seed.rb +3 -1
  215. data/lib/rigor/protection/kill_signature.rb +0 -1
  216. data/lib/rigor/protection/mutation_cache.rb +1 -2
  217. data/lib/rigor/rbs_extended.rb +27 -0
  218. data/lib/rigor/reflection/constant_ancestors.rb +97 -0
  219. data/lib/rigor/reflection/constant_path.rb +19 -6
  220. data/lib/rigor/reflection.rb +72 -105
  221. data/lib/rigor/scope/discovery_index.rb +135 -2
  222. data/lib/rigor/scope.rb +859 -49
  223. data/lib/rigor/sig_gen/alias_index.rb +289 -0
  224. data/lib/rigor/sig_gen/classification.rb +22 -5
  225. data/lib/rigor/sig_gen/declaration_equivalence.rb +127 -0
  226. data/lib/rigor/sig_gen/effect_annotation.rb +202 -0
  227. data/lib/rigor/sig_gen/generator.rb +558 -31
  228. data/lib/rigor/sig_gen/inline_declarations.rb +184 -0
  229. data/lib/rigor/sig_gen/method_candidate.rb +47 -3
  230. data/lib/rigor/sig_gen/observation_collector.rb +1 -1
  231. data/lib/rigor/sig_gen/renderer.rb +124 -9
  232. data/lib/rigor/sig_gen/skip_reason_catalog.rb +44 -1
  233. data/lib/rigor/sig_gen/write_result.rb +19 -3
  234. data/lib/rigor/sig_gen/writer.rb +166 -35
  235. data/lib/rigor/sig_gen.rb +3 -0
  236. data/lib/rigor/signature_path_audit.rb +1 -1
  237. data/lib/rigor/source/node_walker.rb +0 -3
  238. data/lib/rigor/source/parameter_envelope.rb +72 -0
  239. data/lib/rigor/source.rb +1 -0
  240. data/lib/rigor/type/combinator.rb +88 -12
  241. data/lib/rigor/type/difference.rb +1 -0
  242. data/lib/rigor/type/hash_shape.rb +1 -1
  243. data/lib/rigor/type/refined.rb +1 -0
  244. data/lib/rigor/version.rb +1 -1
  245. data/lib/rigor.rb +1 -0
  246. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +1 -0
  247. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +9 -5
  248. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -4
  249. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +0 -1
  250. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_scan.rb +96 -0
  251. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +65 -8
  252. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/erb_compiler.rb +270 -0
  253. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +402 -0
  254. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +370 -0
  255. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_units.rb +132 -0
  256. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +239 -4
  257. data/plugins/rigor-actionpack/sig/action_view.rbs +43 -0
  258. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +220 -0
  259. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_index.rb +56 -0
  260. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +263 -0
  261. data/plugins/rigor-active-model-serializers/lib/rigor-active-model-serializers.rb +7 -0
  262. data/plugins/rigor-active-model-serializers/sig/active_model_serializers.rbs +45 -0
  263. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +1 -0
  264. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +9 -5
  265. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +35 -3
  266. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +72 -12
  267. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +380 -3
  268. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +14 -6
  269. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +281 -35
  270. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +206 -31
  271. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -14
  272. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +9 -2
  273. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +171 -5
  274. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +2 -0
  275. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +1 -0
  276. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +1 -1
  277. data/plugins/rigor-grape/lib/rigor/plugin/grape.rb +141 -0
  278. data/plugins/rigor-grape/lib/rigor-grape.rb +6 -0
  279. data/plugins/rigor-grape/sig/grape.rbs +263 -0
  280. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +72 -2
  281. data/plugins/rigor-graphql/sig/graphql.rbs +485 -0
  282. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +2 -0
  283. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +11 -7
  284. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +35 -37
  285. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +0 -1
  286. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +2 -0
  287. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +1 -0
  288. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +9 -17
  289. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline/same_line_annotations.rb +149 -0
  290. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +217 -36
  291. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +1 -0
  292. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +9 -5
  293. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +0 -1
  294. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +46 -1
  295. data/plugins/rigor-sorbet/sig/sorbet.rbs +277 -0
  296. data/sig/rigor/analysis/baseline.rbs +69 -7
  297. data/sig/rigor/analysis/fact_store.rbs +1 -1
  298. data/sig/rigor/analysis/project_scan.rbs +74 -0
  299. data/sig/rigor/effects/config_envelopes.rbs +100 -0
  300. data/sig/rigor/effects/effect_table.rbs +60 -0
  301. data/sig/rigor/effects/envelope.rbs +97 -0
  302. data/sig/rigor/effects/envelope_index.rbs +33 -0
  303. data/sig/rigor/effects/file_collection.rbs +81 -0
  304. data/sig/rigor/effects/label.rbs +26 -0
  305. data/sig/rigor/effects/label_set.rbs +46 -0
  306. data/sig/rigor/effects/method_key.rbs +24 -0
  307. data/sig/rigor/effects/origin.rbs +50 -0
  308. data/sig/rigor/effects/plugin_facts.rbs +141 -0
  309. data/sig/rigor/effects/registry.rbs +68 -0
  310. data/sig/rigor/effects/summary.rbs +50 -0
  311. data/sig/rigor/effects/taint_cause.rbs +12 -0
  312. data/sig/rigor/environment.rbs +12 -10
  313. data/sig/rigor/inference/optimistic_origin.rbs +10 -0
  314. data/sig/rigor/inference.rbs +6 -4
  315. data/sig/rigor/plugin/additional_initializer.rbs +36 -0
  316. data/sig/rigor/plugin/base.rbs +30 -7
  317. data/sig/rigor/plugin/effect_ancestry.rbs +27 -0
  318. data/sig/rigor/plugin/effect_attribution.rbs +66 -0
  319. data/sig/rigor/plugin/effect_edge.rbs +33 -0
  320. data/sig/rigor/plugin/effect_entry_points.rbs +25 -0
  321. data/sig/rigor/plugin/io_boundary.rbs +1 -1
  322. data/sig/rigor/plugin/loader.rbs +3 -3
  323. data/sig/rigor/plugin/manifest.rbs +50 -11
  324. data/sig/rigor/plugin/protocol_contract.rbs +68 -0
  325. data/sig/rigor/plugin/registry.rbs +62 -1
  326. data/sig/rigor/plugin.rbs +1 -1
  327. data/sig/rigor/rbs_extended.rbs +1 -1
  328. data/sig/rigor/reflection.rbs +7 -6
  329. data/sig/rigor/scope.rbs +135 -20
  330. data/sig/rigor/sig_gen/skip_reason_catalog.rbs +14 -7
  331. data/sig/rigor/source.rbs +4 -4
  332. data/sig/rigor/testing.rbs +10 -4
  333. data/sig/rigor/type.rbs +6 -0
  334. data/sig/rigor.rbs +38 -20
  335. data/skills/rigor-ask/SKILL.md +8 -5
  336. data/skills/rigor-baseline-reduce/SKILL.md +4 -6
  337. data/skills/rigor-ci-setup/SKILL.md +17 -21
  338. data/skills/rigor-doctor/SKILL.md +24 -22
  339. data/skills/rigor-doctor/references/01-checks.md +97 -33
  340. data/skills/rigor-editor-setup/SKILL.md +6 -4
  341. data/skills/rigor-mcp-setup/SKILL.md +5 -4
  342. data/skills/rigor-monkeypatch-resolve/SKILL.md +5 -3
  343. data/skills/rigor-next-steps/SKILL.md +4 -2
  344. data/skills/rigor-plugin-author/SKILL.md +19 -23
  345. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +9 -8
  346. data/skills/rigor-plugin-author/references/02-walker-and-types.md +12 -25
  347. data/skills/rigor-plugin-author/references/03-test-and-ship.md +6 -8
  348. data/skills/rigor-plugin-review/SKILL.md +6 -4
  349. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +2 -1
  350. data/skills/rigor-plugin-tune/SKILL.md +4 -2
  351. data/skills/rigor-project-init/SKILL.md +9 -7
  352. data/skills/rigor-project-init/references/01-detect.md +13 -9
  353. data/skills/rigor-project-init/references/02-configure.md +33 -8
  354. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +14 -14
  355. data/skills/rigor-project-init/references/04-sig-uplift.md +27 -14
  356. data/skills/rigor-protection-uplift/SKILL.md +4 -6
  357. data/skills/rigor-rbs-setup/SKILL.md +4 -2
  358. data/skills/rigor-type-oracle/SKILL.md +4 -6
  359. data/skills/rigor-type-oracle/references/01-oracle-commands.md +12 -8
  360. data/skills/rigor-type-oracle/references/03-gap-protocol.md +0 -3
  361. data/skills/rigor-unused-adjudicate/SKILL.md +4 -2
  362. data/skills/rigor-upgrade/SKILL.md +13 -8
  363. metadata +108 -1
@@ -0,0 +1,399 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ require_relative "../cache/descriptor"
6
+ require_relative "template_unit_collector"
7
+ require_relative "template_unit_paths"
8
+ require_relative "../plugin/template_unit"
9
+ require_relative "../type/combinator"
10
+ require_relative "diagnostic"
11
+
12
+ module Rigor
13
+ module Analysis
14
+ # The run's **template units** — the engine side of the ADR-16 Tier-D seam #392 revived (design note
15
+ # `docs/design/20260816-effect-labels.md` § 11.3).
16
+ #
17
+ # Built ONCE, on the parent, before analysis: the loaded plugins' `template_globs:` are expanded, each
18
+ # match's bytes are handed to {Plugin::Base#template_units_for_file}, and what comes back is frozen into
19
+ # this index. Everything after that point is data — `Marshal`-clean Strings and Hashes — so a fork-pool
20
+ # worker analyses a unit from exactly the bytes the parent synthesised and the pooled and sequential
21
+ # paths cannot disagree about what the template said.
22
+ #
23
+ # ## Why the unit is analysed as a file rather than as a synthesised method
24
+ #
25
+ # Wrapping `ruby_source` in a `class … def … end` would shift every line, so the engine would have to
26
+ # compose the plugin's line map with an offset of its own, and a change to the wrapper would silently
27
+ # move every diagnostic. Instead the unit's Ruby is parsed **as-is**, under a per-file {Scope} whose
28
+ # `self_type`, locals and ivars the index seeds directly — the same seeding
29
+ # `StatementEvaluator#build_method_entry_scope` performs at a method boundary, applied at the file
30
+ # boundary. Line 12 of `ruby_source` is line 12 of the parse, and the ONLY mapping in play is the
31
+ # plugin's own.
32
+ #
33
+ # ## What a unit does not participate in
34
+ #
35
+ # `--incremental` dependents. A template unit is synthesised, not read, so nothing in the ADR-46
36
+ # dependency graph names it and an edit to a `.rb` file a unit calls into has no edge back. The
37
+ # conservative reading is taken and stated: **a run always re-analyses every template unit**, which
38
+ # costs a parse per template on the warm incremental path and can never serve a stale answer. Making
39
+ # units first-class dependents is a later slice's work, and needs a decision the design note does not
40
+ # settle.
41
+ #
42
+ # ## Carrying an index across runs (#1038)
43
+ #
44
+ # A long-lived {LanguageServer::ProjectContext} holds one warm {ProjectScan}, which carries the index it
45
+ # built, and every per-buffer publish hands it back through `collect(previous:)`. A path whose template
46
+ # has not moved on disk REUSES the unit already compiled for it, so a keystroke runs no plugin code —
47
+ # for ERB (#393) that is an Erubi compile of every view in the project, per keystroke, which is what
48
+ # this exists to remove. What is re-done per run is cheap and is what notices a change: the globs are
49
+ # re-expanded (only a glob notices a template APPEARING or vanishing) and each surviving path is
50
+ # revalidated through the ADR-87 stat-then-digest choke point ({Cache::FileDigest.stat_fresh?}), whose
51
+ # authority is the content digest, not the stat tuple. Three things rebuild the index outright, because
52
+ # each can change what a transform produces for bytes that never moved: a different root, a different
53
+ # set of claimed globs, and a different set of glob-claiming plugins.
54
+ #
55
+ # A fourth rebuilds one PLUGIN'S claim outright (#1047): any of that plugin's templates edited, added or
56
+ # deleted (except the deletion of a template that had produced no unit, which the carried rows cannot
57
+ # attribute to a plugin and which contributed nothing a sibling could read). A transform may read the
58
+ # plugin's other templates — rigor-actionpack seeds a partial's locals from the views that render it —
59
+ # so the freshness of `_card` alone cannot vouch for `_card`'s unit. The editor's buffer does not count
60
+ # as an edit, so this never costs a keystroke, and it bites only on an on-disk edit the owner has not
61
+ # invalidated for — a save already rebuilds cold. A template read with no unit (declined, or raised) is
62
+ # carried as a bare stat pack for exactly this decision, so an unchanged declined file is not mistaken
63
+ # for an edit.
64
+ #
65
+ # Separately, and whatever the rest of the index does, the editor's own buffer is never carried: a
66
+ # path the buffer is bound to is recompiled from the buffer's bytes on every publish, and the compiled
67
+ # result stays in THAT run's index — the warm index on the ProjectScan only ever holds units compiled
68
+ # from files on disk. A template the plugin DECLINES, or whose transform raised, likewise produces no
69
+ # unit to carry and is re-offered every run.
70
+ #
71
+ # A sequential CLI run passes no `previous:` and so builds the index from scratch exactly as before: it
72
+ # has no warm index to carry, its process ends with the run, and adding a cross-process memo would be a
73
+ # second cache to prove sound for no measurable gain (the index is built once per `rigor check`).
74
+ class TemplateUnits
75
+ # What a declared type name binds to when nothing resolves it. `Dynamic[top]` — the analyzer's own
76
+ # "a value is here and I cannot see its class".
77
+ DYNAMIC = Type::Combinator.dynamic(Type::Combinator.top)
78
+ private_constant :DYNAMIC
79
+
80
+ # One synthesised unit, flattened off the plugin's {Plugin::TemplateUnit} with its identity resolved.
81
+ #
82
+ # `path` is both the file the user wrote AND the logical path the engine analyses under: a diagnostic
83
+ # the parse produces already carries it, so nothing has to rewrite a path — only a LINE.
84
+ Entry = Data.define(:logical_name, :path, :source, :line_map, :self_type, :locals, :ivar_seeds,
85
+ :digest, :unit_key, :plugin_id, :suppressed_rules) do
86
+ # #393 — the per-unit rule posture. True when the plugin declared that this family of finding is
87
+ # not meaningful in its compiled output.
88
+ def suppresses?(rule)
89
+ return false if rule.nil? || suppressed_rules.empty?
90
+
91
+ suppressed_rules.any? { |prefix| rule.to_s.start_with?(prefix) }
92
+ end
93
+
94
+ # See {Plugin::TemplateUnit#template_line}. Re-implemented on the flattened entry so a worker never
95
+ # needs the plugin value object (or the plugin) to position a diagnostic.
96
+ def template_line(ruby_line)
97
+ return ruby_line if line_map.empty?
98
+ return line_map[ruby_line] if line_map.key?(ruby_line)
99
+
100
+ before = line_map.keys.select { |line| line < ruby_line }
101
+ before.empty? ? 1 : line_map[before.max]
102
+ end
103
+ end
104
+
105
+ # The index a run with no template-claiming plugin analyses under. Eagerly built on the main Ractor
106
+ # at load time (bottom of the class body) rather than memoised on first use: `@empty ||= new({})` is
107
+ # a class-ivar WRITE, which a non-main Ractor may not perform. {WorkerSession#initialize} reaches it
108
+ # whenever `template_units:` is not supplied, which is exactly how the Ractor backend constructs its
109
+ # workers — so the lazy memo killed every worker in its constructor and degraded every run (#1055).
110
+ class << self
111
+ attr_reader :empty
112
+ end
113
+
114
+ # Expands every loaded plugin's `template_globs:` and runs its transform. The work lives in
115
+ # {TemplateUnitCollector}, which owns the glob expansion, the failure isolation, the editor-buffer
116
+ # substitution and the #1038 carry; this class is what the rest of the run READS.
117
+ #
118
+ # @param previous — a warm index from an earlier run of the same project (#1038). Every unit in it
119
+ # whose template is unchanged on disk is carried over instead of recompiled; see the class note.
120
+ def self.collect(registry:, root: Dir.pwd, buffer: nil, previous: nil)
121
+ TemplateUnitCollector.collect(registry: registry, root: root, buffer: buffer, previous: previous)
122
+ end
123
+
124
+ # One template file a plugin claimed and did not deliver a usable unit for — a transform that raised,
125
+ # a file that could not be read, or a unit naming the wrong path. Reported as a `plugin_loader`
126
+ # `runtime-error` row by {Runner#template_unit_failure_diagnostics}, which is the isolation envelope
127
+ # every other plugin hook reports through (ADR-2 § "Plugin Trust and I/O Policy").
128
+ Failure = Data.define(:plugin_id, :path, :message)
129
+
130
+ def initialize(entries, failures: [], claimed_globs: [], root: Dir.pwd, stats: {},
131
+ plugin_signature: [])
132
+ @entries = entries.freeze
133
+ @failures = failures.freeze
134
+ # The globs the loaded plugins claimed, whether or not anything matched. They are the CACHE's
135
+ # business, not the analysis's: see {#glob_entries}.
136
+ @claimed_globs = claimed_globs.freeze
137
+ @root = root
138
+ # #1038 — the freshness token per template this index READ successfully: the ADR-87
139
+ # `(digest, size, mtime_ns, ctime_ns, inode)` pack over the bytes the transform was handed. Not part
140
+ # of the index's own identity ({#digest}) — a stat is not an answer, only the question of whether a
141
+ # carried answer still holds.
142
+ @stats = stats.freeze
143
+ @plugin_signature = plugin_signature.freeze
144
+ @declined_unit_keys = build_declined_unit_keys
145
+ freeze
146
+ end
147
+
148
+ attr_reader :failures
149
+
150
+ # #1038 — the `{ path => [entry, stat_pack] }` a later run may reuse, or `{}` when the whole index has
151
+ # to be rebuilt. The three whole-index refusals live here rather than per path because each of them
152
+ # can change what a transform PRODUCES for a template whose bytes never moved: a different root is a
153
+ # different project, a different claim set can hand a path to a different plugin, and a different
154
+ # plugin set is different code.
155
+ def carry_over(root:, claimed_globs:, plugin_signature:)
156
+ return {} unless @root.to_s == root.to_s
157
+ return {} unless @claimed_globs == claimed_globs
158
+ return {} unless @plugin_signature == plugin_signature
159
+
160
+ # A template that was READ but produced no unit (declined, or its transform raised) is carried as
161
+ # `[nil, pack]`: there is no unit to reuse, but its freshness still answers whether it CHANGED, which
162
+ # is what the collector's whole-claim decision needs (#1047) — without it one declined template
163
+ # would read as an edit on every run and cost the whole claim its carry.
164
+ @stats.each_with_object({}) do |(path, packed), carried|
165
+ carried[path] = [@entries[path], packed] if packed
166
+ end
167
+ end
168
+
169
+ def empty?
170
+ @entries.empty?
171
+ end
172
+
173
+ # #1065 — the `view:` keys of the templates this run READ and produced no unit for: an ERB whose
174
+ # compiled Ruby does not parse, and a handler the claiming plugin declines outright (a `.haml` view
175
+ # under a decline-only claim). They are what stops the effect edge's format fallback from resolving
176
+ # PAST a template that exists: Action View runs `_watchers.js.haml`, so joining `_watchers.html`'s
177
+ # effects there would be a label no execution produces. An unclaimed handler is invisible here and
178
+ # the fallback still fires for it, which is why a plugin claims the handlers it declines.
179
+ #
180
+ # The key is derived from the paths the plugin ITSELF keyed: a produced unit gives the root prefix
181
+ # its logical name was taken relative to (`app/views/` for `app/views/users/_card.html.erb` →
182
+ # `users/_card.html`), and a declined path under that prefix is keyed the same way, minus its
183
+ # handler. A run whose claim produced no unit at all learns no prefix and blocks nothing, which
184
+ # leaves the fallback exactly as it was rather than guessing at a layout the engine does not own.
185
+ attr_reader :declined_unit_keys
186
+
187
+ # The logical paths the run analyses on top of its `.rb` expansion, sorted.
188
+ def paths
189
+ @entries.keys.sort
190
+ end
191
+
192
+ def [](path)
193
+ @entries[normalize(path)]
194
+ end
195
+
196
+ def key?(path)
197
+ @entries.key?(normalize(path))
198
+ end
199
+
200
+ # `{ path => ruby_source }` — what {Runner#parse_source} and {WorkerSession#parse_source} read
201
+ # instead of the bytes on disk.
202
+ def sources
203
+ @entries.transform_values(&:source)
204
+ end
205
+
206
+ # The index's cache identity: every unit's digest (source bytes + transform id + synthesis version)
207
+ # keyed by path, plus every FAILURE keyed by path, hashed once. nil only when the run's plugins
208
+ # claimed no globs at all, so a project with no template-unit plugin perturbs no cache key.
209
+ #
210
+ # The failures are in it because a run that produced only failures still produced an ANSWER — one
211
+ # `plugin_loader` row per file — and without them that run's key equalled the no-templates key. A
212
+ # later run whose template now compiles, or whose template is gone, reconstructed the same key, and
213
+ # the ADR-87 boot-slim probe (which loads no plugin, so its slot is always absent) served the stale
214
+ # rows. The slot now exists whenever any plugin claimed a glob, which is exactly the condition under
215
+ # which the probe's key is knowingly unreconstructable.
216
+ def build_declined_unit_keys
217
+ declined = @stats.keys - @entries.keys
218
+ return NO_DECLINED_KEYS if declined.empty?
219
+
220
+ prefixes = @entries.filter_map { |path, entry| root_prefix(path, entry.logical_name) }.uniq
221
+ return NO_DECLINED_KEYS if prefixes.empty?
222
+
223
+ declined.filter_map { |path| declined_unit_key(path, prefixes) }.uniq.sort.freeze
224
+ end
225
+ private :build_declined_unit_keys
226
+
227
+ NO_DECLINED_KEYS = [].freeze
228
+ private_constant :NO_DECLINED_KEYS
229
+
230
+ # The directory a plugin took a logical name relative to, or nil when the path and the name do not
231
+ # agree — a plugin free to key a unit any way it likes is one this derivation must decline for.
232
+ def root_prefix(path, logical_name)
233
+ stem = strip_handler(path)
234
+ stem.end_with?(logical_name) ? stem.delete_suffix(logical_name) : nil
235
+ end
236
+ private :root_prefix
237
+
238
+ def declined_unit_key(path, prefixes)
239
+ stem = strip_handler(path)
240
+ prefix = prefixes.find { |candidate| stem.start_with?(candidate) && stem.length > candidate.length }
241
+ prefix.nil? ? nil : "#{Plugin::TemplateUnit::KEY_PREFIX}#{stem.delete_prefix(prefix)}"
242
+ end
243
+ private :declined_unit_key
244
+
245
+ # `app/views/users/_card.html.erb` → `app/views/users/_card.html`. A template's logical name never
246
+ # carries its handler, which is the one naming rule the seam itself states.
247
+ def strip_handler(path)
248
+ path.sub(%r{\.[^./]+\z}, "")
249
+ end
250
+ private :strip_handler
251
+
252
+ def digest
253
+ return nil if @claimed_globs.empty?
254
+
255
+ rows = @entries.keys.sort.map { |path| "unit\x00#{path}\x00#{@entries[path].digest}" } +
256
+ @failures.map { |failure| "fail\x00#{failure.path}\x00#{failure.message}" }.sort
257
+ Digest::SHA256.hexdigest((["globs\x00#{@claimed_globs.sort.join("\x00")}"] + rows).join("\n"))
258
+ end
259
+
260
+ # The ADR-60 WD3 / #979 `:names` glob rows the run's dependency descriptor records: one per claimed
261
+ # pattern, whether or not it matched.
262
+ #
263
+ # Without them a template APPEARING under a claimed glob moved nothing a warm run could see — the
264
+ # descriptor listed only the files that already existed — so a project whose first template was added
265
+ # between two runs kept replaying the answer computed before it existed. `:names` rather than `:stat`
266
+ # for the same reason the signature roots use it (`cache.md` § the run-descriptor row inventory): the
267
+ # question a glob row adds is which files MATCH, and edits to those files are carried by their own
268
+ # file rows.
269
+ def glob_entries
270
+ @claimed_globs.map do |pattern|
271
+ Cache::Descriptor::GlobEntry.compute(root: @root.to_s, pattern: pattern, mode: :names)
272
+ end
273
+ end
274
+
275
+ # Every template file this run read — the ones that produced a unit AND the ones that failed. A
276
+ # failure's file is as much an input to the run's answer as a success's: the `plugin_loader` row it
277
+ # produced must not outlive the edit that fixes the template.
278
+ def source_paths
279
+ (@entries.keys + @failures.map(&:path)).uniq.sort
280
+ end
281
+
282
+ # The Prism `scopes:` argument for a unit's parse, or nil for a path that is not one.
283
+ #
284
+ # Without it the seeded locals are inert: Prism parses a bare identifier with no assignment in sight
285
+ # as a **method call**, so `size` in a template was a `CallNode` and `Scope#local(:size)` was never
286
+ # consulted. Declaring the render site's locals as an enclosing scope is exactly what Rails does when
287
+ # it compiles a partial's locals into the method's parameters, and it is what makes `locals:` mean
288
+ # anything.
289
+ def parse_scopes(path)
290
+ entry = @entries[normalize(path)]
291
+ return nil if entry.nil? || entry.locals.empty?
292
+
293
+ [entry.locals.keys.map(&:to_sym)]
294
+ end
295
+
296
+ # The `view:<logical_name>` effect-unit key for a path, or nil.
297
+ # The file whose bytes a unit's path was READ from: the editor's buffer when one is bound to it, the
298
+ # project file otherwise. Read by the run's dependency descriptor, so it digests what the run read.
299
+ def physical_path(path, buffer)
300
+ TemplateUnitCollector.physical_path(path, @root, buffer)
301
+ end
302
+
303
+ def unit_key_for(path)
304
+ @entries[normalize(path)]&.unit_key
305
+ end
306
+
307
+ # Binds the declared `self`, locals and ivar seeds onto the per-file scope, so the unit's body types
308
+ # as the render site would run it.
309
+ #
310
+ # A type name that resolves to nothing is bound `Dynamic` rather than guessed OR left unbound, and
311
+ # the difference matters most for `self_type:`. Leaving it unbound is not "no claim": it is the claim
312
+ # that the body runs at top level, so every helper call in the template reports
313
+ # `call.unresolved-toplevel` — a finding per line, caused by the plugin naming a class whose RBS the
314
+ # project does not ship (`ActionView::Base`, on the very first Rails app to try this). `Dynamic` is
315
+ # the honest reading of "a receiver is declared and the analyzer cannot see it" (ADR-5), and it is
316
+ # silent.
317
+ def seed(scope, path)
318
+ entry = @entries[normalize(path)]
319
+ return scope if entry.nil?
320
+
321
+ scope = bind_self(scope, entry)
322
+ entry.locals.each do |name, type_name|
323
+ scope = scope.with_local(name.to_sym, resolve(scope, type_name))
324
+ end
325
+ entry.ivar_seeds.each do |name, type_name|
326
+ scope = scope.with_ivar(name.to_sym, resolve(scope, type_name))
327
+ end
328
+ scope
329
+ end
330
+
331
+ # Re-points a diagnostic produced inside a unit at the template's own line, and drops the ones the
332
+ # unit's plugin declared it does not report (#393). Every other diagnostic passes through untouched,
333
+ # so it is safe to stamp a whole run's stream through this.
334
+ #
335
+ # The suppression is per unit rather than global because it is a claim about ONE compiler's output:
336
+ # a plugin whose synthesised receivers are still coarse suppresses `call.` so the view layer can be
337
+ # analysed for its EFFECTS — which need no receiver precision — without the typing half costing the
338
+ # project a diagnostic per template line. It is deliberately not a `disable:` entry: `disable:` would
339
+ # silence the rule in the project's `.rb` files too, which is the opposite of what is wanted.
340
+ def remap(diagnostics)
341
+ return diagnostics if @entries.empty?
342
+
343
+ diagnostics.filter_map do |diagnostic|
344
+ entry = @entries[normalize(diagnostic.path)]
345
+ next diagnostic if entry.nil?
346
+ next nil if entry.suppresses?(diagnostic.rule)
347
+ next diagnostic if entry.line_map.empty?
348
+
349
+ relocate(diagnostic, entry.template_line(diagnostic.line))
350
+ end
351
+ end
352
+
353
+ # The template's lines and the compiled Ruby's columns do not correspond — a compiler preserves lines
354
+ # and rewrites the text of each (Erubi's documented property, and the reason a Rails backtrace can
355
+ # name `show.html.erb:12`). So a diagnostic from a unit keeps its line and drops to column 1 rather
356
+ # than pointing at a column of a file whose bytes are not what was analysed.
357
+ #
358
+ # Applied whenever the unit carries a map, NOT only when the line moves: a compiler that happens to
359
+ # leave a line where it was still rewrote that line's text, and ERB is exactly that case — the map is
360
+ # near-identity and the columns are meaningless anyway.
361
+ def relocate(diagnostic, line)
362
+ Diagnostic.new(
363
+ path: diagnostic.path, line: line, column: 1, message: diagnostic.message,
364
+ severity: diagnostic.severity, rule: diagnostic.rule, source_family: diagnostic.source_family,
365
+ receiver_type: diagnostic.receiver_type, method_name: diagnostic.method_name,
366
+ project_definition_site: diagnostic.project_definition_site
367
+ )
368
+ end
369
+
370
+ private
371
+
372
+ def normalize(path)
373
+ TemplateUnitPaths.relative(path, @root)
374
+ end
375
+
376
+ def bind_self(scope, entry)
377
+ return scope if entry.self_type.nil?
378
+
379
+ scope.with_self_type(resolve(scope, entry.self_type))
380
+ end
381
+
382
+ def resolve(scope, type_name)
383
+ environment = scope.environment
384
+ resolved = environment&.nominal_for_name(type_name)
385
+ return resolved if resolved
386
+ return DYNAMIC unless scope.discovered_classes.key?(type_name)
387
+
388
+ Type::Combinator.nominal_of(type_name)
389
+ end
390
+
391
+ # Populates the `@empty` singleton on the main Ractor at load time. `Ractor.make_shareable` rather
392
+ # than `freeze` so a worker's READ of the class ivar is legal too: a non-main Ractor may read a
393
+ # class/module ivar only when the value is deeply shareable, and `#initialize` freezes the index
394
+ # itself but leaves `@root` — a fresh `Dir.pwd` String — unfrozen. The baked root is inert here: an
395
+ # index with no entries and no claimed globs answers nothing and carries nothing over.
396
+ @empty = Ractor.make_shareable(new({}))
397
+ end
398
+ end
399
+ end
@@ -22,6 +22,7 @@ require_relative "dependency_recorder"
22
22
  require_relative "dependency_source_inference"
23
23
  require_relative "diagnostic"
24
24
  require_relative "erb_template_detector"
25
+ require_relative "template_units"
25
26
 
26
27
  module Rigor
27
28
  module Analysis
@@ -83,7 +84,8 @@ module Rigor
83
84
  def initialize(configuration:, cache_store: nil, # rubocop:disable Metrics/MethodLength,Metrics/ParameterLists
84
85
  plugin_blueprints: [], explain: false, buffer: nil,
85
86
  synthetic_method_index: nil, project_patched_methods: nil,
86
- project_scope_seed: {}, source_files: [], record_dependencies: false)
87
+ project_scope_seed: {}, source_files: [], record_dependencies: false,
88
+ template_units: nil, locked_gems: nil)
87
89
  @configuration = configuration
88
90
  @cache_store = cache_store
89
91
  @explain = explain
@@ -108,6 +110,15 @@ module Rigor
108
110
  # ADR-32 WD4 — full project file list (frozen Array<String>) for env-build-time invocation of any
109
111
  # loaded plugin's `source_rbs_synthesizer` callable.
110
112
  @source_files = source_files
113
+ # #392 — the run's template units, synthesised on the PARENT before the fork and inherited here as
114
+ # frozen data. A worker analyses a unit from the same bytes the parent compiled, which is what makes
115
+ # pooled and sequential agree about a file that does not exist on disk in the form being parsed.
116
+ @template_units = template_units || TemplateUnits.empty
117
+ # #1064 — the project's `Gemfile.lock` map, resolved by the PARENT. Bundler's lockfile parser reads
118
+ # RubyGems module state a non-main Ractor may not touch, so a Ractor worker that resolved it here got
119
+ # `Ractor::IsolationError` and an empty map — no gem `sig/` directories, no ADR-72 overlays. nil (the
120
+ # fork backend and every other caller) resolves inside `Environment.for_project` exactly as before.
121
+ @locked_gems = locked_gems
111
122
 
112
123
  # NOTE: `Inference::MethodDispatcher::FileFolding.fold_platform_specific_paths` is process-global
113
124
  # state. Writing it from a non-main Ractor would raise `Ractor::IsolationError`, so the session does
@@ -140,7 +151,8 @@ module Rigor
140
151
  **ProjectEnvironment.dependency_discovery_options(configuration),
141
152
  synthetic_method_index: @synthetic_method_index,
142
153
  project_patched_methods: @project_patched_methods,
143
- source_files: @source_files
154
+ source_files: @source_files,
155
+ locked_gems: @locked_gems
144
156
  )
145
157
  @prepare_diagnostics = run_plugin_prepare.freeze
146
158
  end
@@ -164,10 +176,12 @@ module Rigor
164
176
  def analyze_with_effects(path)
165
177
  return analyze_body(path) unless @record_effects
166
178
 
179
+ entry = @template_units[path]
167
180
  diagnostics = nil
168
181
  collection = Effects::Collector.collect_for(
169
182
  path, attribution: @effect_attribution, envelopes: effect_envelope_index,
170
- plugin_facts: effect_plugin_facts
183
+ plugin_facts: effect_plugin_facts, unit_key: entry&.unit_key,
184
+ unit_owner: entry&.self_type
171
185
  ) do
172
186
  diagnostics = analyze_body(path)
173
187
  end
@@ -206,21 +220,24 @@ module Rigor
206
220
  return parse_diagnostics(path, parse_result)
207
221
  end
208
222
 
209
- Effects::Collector.record_root(parse_result.value)
210
- scope = seed_project_scope(Scope.empty(environment: @environment, source_path: path))
211
- index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
223
+ root = parse_result.value
224
+ Effects::Collector.record_root(root)
225
+ scope = @template_units.seed(
226
+ seed_project_scope(Scope.empty(environment: @environment, source_path: path)), path
227
+ )
228
+ index = Inference::ScopeIndexer.index(root, default_scope: scope)
212
229
  # ADR-53 B4 — built-in collectors + plugin node rules share one walk.
213
- node_collectors = CheckRules.build_node_collectors(path, index)
214
- node_results = node_rule_results_by_plugin(path, parse_result.value, scope, node_collectors, index)
230
+ node_collectors = CheckRules.build_node_collectors(path, index, root)
231
+ node_results = node_rule_results_by_plugin(path, root, scope, node_collectors, index)
215
232
  diagnostics = CheckRules.diagnose(
216
233
  path: path,
217
- root: parse_result.value,
234
+ root: root,
218
235
  scope_index: index,
219
236
  comments: parse_result.comments,
220
237
  disabled_rules: @configuration.disabled_rules,
221
238
  node_collectors: node_collectors
222
239
  )
223
- diagnostics += plugin_emitted_diagnostics(path, parse_result.value, scope, node_results)
240
+ diagnostics += plugin_emitted_diagnostics(path, root, scope, node_results)
224
241
  diagnostics + explain_diagnostics(path, parse_result.value, scope)
225
242
  rescue Errno::ENOENT => e
226
243
  [analyzer_error(path, e.message)]
@@ -295,6 +312,26 @@ module Rigor
295
312
  @file_effects
296
313
  end
297
314
 
315
+ # Issue #1051 — the run-scoped disclosures ({Plugin::Base#disclose_once}) this session's plugin
316
+ # instances registered, each tagged with the plugin id the coordinator de-duplicates by — including the
317
+ # positioned batches of {Plugin::Base#emit_once} (#1060), which ride the same records. Marshal-clean
318
+ # (frozen Hashes of Strings and Symbols, and frozen `Diagnostic` copies for a batch) so the fork pool
319
+ # ships them back like the three drains above;
320
+ # a registration made on the PARENT during `#prepare` is already visible to the coordinator through
321
+ # the pre-fork session, and one a worker makes while analysing its slice arrives through the payload.
322
+ # Entries are not cleared — a drain is a read, and the parent session is drained again on the degrade
323
+ # path.
324
+ def drain_run_disclosures
325
+ return [] if @plugin_registry.empty?
326
+
327
+ @plugin_registry.plugins.flat_map do |plugin|
328
+ plugin_id = safe_plugin_id(plugin)
329
+ Array(plugin.run_disclosure_records).map { |record| record.merge(plugin_id: plugin_id) }
330
+ rescue StandardError
331
+ []
332
+ end
333
+ end
334
+
298
335
  private
299
336
 
300
337
  # Mirrors {Runner#seed_project_scope}: applies the cross-file pre-pass discovery tables the
@@ -310,6 +347,12 @@ module Rigor
310
347
  # physical bytes but stamp the parse buffer's `filepath:` as the LOGICAL path so downstream
311
348
  # diagnostics carry the logical path.
312
349
  def parse_source(path)
350
+ entry = @template_units[path]
351
+ if entry
352
+ return Prism.parse(entry.source, filepath: path, version: @configuration.target_ruby,
353
+ scopes: @template_units.parse_scopes(path))
354
+ end
355
+
313
356
  physical = @buffer ? @buffer.resolve(path) : path
314
357
  return Prism.parse_file(physical, version: @configuration.target_ruby) if physical == path
315
358
 
@@ -195,12 +195,10 @@ module Rigor
195
195
 
196
196
  module_function
197
197
 
198
- # @return the whole overlay.
199
198
  def features
200
199
  FEATURES
201
200
  end
202
201
 
203
- # @return every feature id in the overlay.
204
202
  def feature_ids
205
203
  FEATURES.map(&:id)
206
204
  end
@@ -241,6 +241,7 @@ module Rigor
241
241
  ["CSV", :readlines, :singleton] => CSV_PARSED_SPEC,
242
242
  ["CSV", :parse_line, :singleton] => CSV_ROW_SPEC
243
243
  }.freeze
244
+ Ractor.make_shareable(METHOD_RETURN_OVERRIDES)
244
245
 
245
246
  # @return the reduced HKT type for
246
247
  # the given (class_name, method_name, kind) triple,
@@ -64,6 +64,7 @@ module Rigor
64
64
  "non-nan-float" => -> { Type::Combinator.non_nan_float },
65
65
  "finite-float" => -> { Type::Combinator.finite_float }
66
66
  }.freeze
67
+ Ractor.make_shareable(REGISTRY)
67
68
  private_constant :REGISTRY
68
69
 
69
70
  # `name[T]` / `name[K, V]` — type-arg parameterised refinements. Each builder takes an
@@ -141,6 +142,7 @@ module Rigor
141
142
  Type::Combinator.readonly_of(args.first)
142
143
  }
143
144
  }.freeze
145
+ Ractor.make_shareable(PARAMETERISED_TYPE_BUILDERS)
144
146
  private_constant :PARAMETERISED_TYPE_BUILDERS
145
147
 
146
148
  # `Class[range]` — a numeric class head bounded by a Ruby range literal (ADR-109). The
@@ -176,6 +178,7 @@ module Rigor
176
178
  Type::Combinator.float_range(lo, hi, exclude_end: exclusive)
177
179
  }
178
180
  }.freeze
181
+ Ractor.make_shareable(RANGE_HEAD_BUILDERS)
179
182
  private_constant :RANGE_HEAD_BUILDERS
180
183
 
181
184
  # `name<min, max>` — the PHPStan-style integer-bound form ADR-109 deprecates: still parsed so
@@ -193,6 +196,7 @@ module Rigor
193
196
  Type::Combinator.integer_range(bounds[0], bounds[1])
194
197
  }
195
198
  }.freeze
199
+ Ractor.make_shareable(PARAMETERISED_INT_BUILDERS)
196
200
  private_constant :PARAMETERISED_INT_BUILDERS
197
201
 
198
202
  module_function
@@ -20,14 +20,22 @@ module Rigor
20
20
  # `{n,m}` forms with `n >= 1`):
21
21
  #
22
22
  # - `\d`, `[0-9]` -> decimal-int-string
23
- # - `\h` -> hex-int-string
24
- # - `[0-9a-fA-F]` -> hex-int-string
25
- # - `[0-9a-f]`, `[0-9A-F]` -> hex-int-string
26
- # - `[0-7]` -> octal-int-string
23
+ # - `\h` -> non-empty-string
24
+ # - `[0-9a-fA-F]` -> non-empty-string
25
+ # - `[0-9a-f]`, `[0-9A-F]` -> non-empty-string
26
+ # - `[0-7]` -> non-empty-string
27
27
  # - `[a-z]` -> lowercase-string
28
28
  # - `[A-Z]` -> uppercase-string
29
29
  # - `[[:digit:]]` -> numeric-string
30
30
  #
31
+ # #1004 — the hex/octal digit classes do NOT map to `hex-int-string` / `octal-int-string`.
32
+ # Those refinements' predicates (`Type::Refined` PREDICATES, in `type/refined.rb`) REQUIRE the
33
+ # conventional `0x` / `0o` prefix, but a bare digit class like `[0-9a-fA-F]+` matches "ff" —
34
+ # a string with no prefix at all. Mapping a matched digit run to a refinement its own
35
+ # predicate rejects is unsound (worse than the plain `String` it replaces), so these rows
36
+ # drop to `non-empty-string`: the `+` / `{n,}` (n >= 1) quantifier already guarantees the
37
+ # captured run is never empty, and that much is true regardless of prefix.
38
+ #
31
39
  # Anything outside the table returns `nil` so the calling narrowing site falls back to
32
40
  # its previous behaviour (plain `String`). Arbitrary regex semantic equivalence is
33
41
  # undecidable, so the table is intentionally a small audited set of canonical shapes
@@ -46,11 +54,11 @@ module Rigor
46
54
  RULES = Ractor.make_shareable([
47
55
  [/\A\\d#{QUANTIFIER_SOURCE}\z/, :decimal_int_string],
48
56
  [/\A\[0-9\]#{QUANTIFIER_SOURCE}\z/, :decimal_int_string],
49
- [/\A\\h#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
50
- [/\A\[0-9a-fA-F\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
51
- [/\A\[0-9a-f\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
52
- [/\A\[0-9A-F\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
53
- [/\A\[0-7\]#{QUANTIFIER_SOURCE}\z/, :octal_int_string],
57
+ [/\A\\h#{QUANTIFIER_SOURCE}\z/, :non_empty_string],
58
+ [/\A\[0-9a-fA-F\]#{QUANTIFIER_SOURCE}\z/, :non_empty_string],
59
+ [/\A\[0-9a-f\]#{QUANTIFIER_SOURCE}\z/, :non_empty_string],
60
+ [/\A\[0-9A-F\]#{QUANTIFIER_SOURCE}\z/, :non_empty_string],
61
+ [/\A\[0-7\]#{QUANTIFIER_SOURCE}\z/, :non_empty_string],
54
62
  [/\A\[a-z\]#{QUANTIFIER_SOURCE}\z/, :lowercase_string],
55
63
  [/\A\[A-Z\]#{QUANTIFIER_SOURCE}\z/, :uppercase_string],
56
64
  [/\A\[\[:digit:\]\]#{QUANTIFIER_SOURCE}\z/, :numeric_string]
@@ -93,6 +93,7 @@ module Rigor
93
93
  ["IO", :read, :singleton] => IO_READ_NO_LENGTH,
94
94
  ["File", :read, :singleton] => IO_READ_NO_LENGTH
95
95
  }.freeze
96
+ Ractor.make_shareable(OVERRIDES)
96
97
  private_constant :OVERRIDES
97
98
 
98
99
  # Looks up a refined return type for the given call.
@@ -124,6 +125,7 @@ module Rigor
124
125
  acc[mname] ||= []
125
126
  acc[mname] << owner unless acc[mname].include?(owner)
126
127
  end.freeze
128
+ Ractor.make_shareable(OWNERS_BY_METHOD)
127
129
  private_constant :OWNERS_BY_METHOD
128
130
 
129
131
  # @return the candidate owner class names for a bare method-name
@@ -53,7 +53,12 @@ module Rigor
53
53
  # interface a `conforms-to` names) invalidates the warm entry. A pre-9 entry carries no signature-root
54
54
  # row and would validate fresh across exactly that edit — the #577 reasoning, one slot over — so cached
55
55
  # entries must read as misses once and rebuild carrying the rows.
56
- SCHEMA_VERSION = 9
56
+ # v10: #1123 — the def-index seed bundles gain the `:prepends` table, the instance-ancestor-ORDER half
57
+ # of the `include` table (`prepend M`, and `Recv.prepend(M)`), which `Scope#user_def_through_ancestors`
58
+ # searches ahead of a class's own `def`s. A pre-10 bundle would silently contribute no prepends for an
59
+ # unchanged file, so that file's consumers would keep answering with the pre-fix MRO — cached bundles
60
+ # must read as misses once and rebuild carrying the slot.
61
+ SCHEMA_VERSION = 10
57
62
 
58
63
  # Per-slot entry value objects. Constructors validate enums / required fields and freeze the resulting
59
64
  # struct so no caller can mutate after the entry is in a Descriptor.