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
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ # The `RIGOR_SHADOW_RULE_WALK` switch, read in one place by both halves of the shadow harness: the rule
6
+ # walk's collectors (ADR-53 Track B, {CheckRules}) and the discovery tables (ADR-116 WD5,
7
+ # {Inference::DeclarationWalk::Shadow}, which also says how a run must be made), and by the cache keys that
8
+ # must not let a warm run answer for either. A leaf file, so the ADR-87 boot-slim probe loads it without
9
+ # any engine code.
10
+ module ShadowHarness
11
+ ENV_KEY = "RIGOR_SHADOW_RULE_WALK"
12
+
13
+ module_function
14
+
15
+ # Set to anything, `0` and the empty string included.
16
+ def enabled?
17
+ !ENV[ENV_KEY].nil?
18
+ end
19
+
20
+ # What the run-result cache and the incremental snapshot key on: nil with the harness off, which adds
21
+ # no key slot, so a run without the variable computes the key it computed before the harness existed.
22
+ def cache_identity
23
+ enabled? ? ENV_KEY : nil
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ # Issue #1536 (ADR-89 WD1 amendment) — what the incremental session knows about the RBS that source-RBS
8
+ # synthesizers derive from each file's comments. The declaration gates read a file's Ruby side only; a
9
+ # synthesizer's output reaches the environment as a `virtual:` buffer, and a reader of that RBS records no
10
+ # edge back to the file, so the session asks this gate one question before deciding a closure: did the
11
+ # edit move any synthesized output? A yes re-analyses the whole project.
12
+ #
13
+ # Each seed bundle carries its file's {Environment::SourceRbsSynthesis.digest} under {DIGEST_KEY}. The
14
+ # gate writes it ({#stamp}), not the scope indexer that builds the rest of the bundle: the digest needs a
15
+ # plugin registry, which discovery never sees. A stamp must describe the synthesized RBS the cached
16
+ # diagnostics of the file's readers were computed under; where that cannot be vouched for, it is nil, which
17
+ # reads as moved.
18
+ #
19
+ # The synthesizers are read from a registry the gate loads for itself, WITHOUT `#prepare`, because the
20
+ # closure is decided before the recheck runner (which loads and prepares its own) exists. That is exact
21
+ # only for a synthesizer reachable without `#prepare`, so {#verify} compares the two sets after every run:
22
+ # a plugin that builds its synthesizer in `#prepare` leaves the gate UNTRUSTED for the rest of the
23
+ # session — every stored digest reads as unknown, and every edit re-analyses the whole project.
24
+ class SourceRbsGate
25
+ DIGEST_KEY = :source_rbs_digest
26
+
27
+ # The paths {#stamp} last stored as unknown because the bytes its bundle was built from are not the
28
+ # bytes the closure was decided on. The session forgets their recorded content digests, so the next run
29
+ # detects them as changed and, reading the unknown stamp, re-analyses the project.
30
+ attr_reader :unbound
31
+
32
+ def initialize(configuration:, cache_store:, plugin_requirer:)
33
+ @configuration = configuration
34
+ @cache_store = cache_store
35
+ @plugin_requirer = plugin_requirer
36
+ @synthesizers = nil
37
+ @readings = nil
38
+ @unbound = Set.new.freeze
39
+ @untrusted = false
40
+ end
41
+
42
+ # True once a run's prepared registry declared a different synthesizer set from the one the gate
43
+ # digests with. Sticky for the session: the configuration, and so the plugin set, is fixed for its life.
44
+ def untrusted?
45
+ @untrusted
46
+ end
47
+
48
+ # Whether this edit moved what any synthesizer contributes: a changed file whose digest differs from
49
+ # the one on its snapshot bundle (or either is unknown), an added file that contributes anything, or a
50
+ # removed file that did. Decided from the synthesizers' output, never from which plugins `plugins:`
51
+ # names. The readings taken here are kept for {#stamp}.
52
+ def moved?(bundles, changed, added, removed)
53
+ return true if @untrusted && [changed, added, removed].any?(&:any?)
54
+
55
+ @readings = (changed + added).to_h { |path| [path, reading(path)] }
56
+ none = Environment::SourceRbsSynthesis::NO_CONTRIBUTION
57
+ changed.any? { |path| !unmoved?(bundles, path) } ||
58
+ added.any? { |path| @readings.fetch(path).last != none } ||
59
+ removed.any? { |path| stored(bundles, path) != none }
60
+ end
61
+
62
+ # Stamps every bundle that needs a digest: one the runner built this run (a baseline's all, a recheck's
63
+ # re-walked ones), and one carrying nil that this run can now vouch for. A bundle the runner reused keeps
64
+ # the digest it carries, which stays exact because a bundle is reused only for byte-identical content.
65
+ #
66
+ # A digest is vouched for only by a reading of the bytes the bundle was built from: the reading
67
+ # {#moved?} took when the closure was decided, or, when `whole_project` (every file was just
68
+ # re-analysed), one taken now. Anything else is stamped nil and listed in {#unbound}; a file saved after
69
+ # the closure was decided is never re-read, because its readers' cached answers predate that save. An
70
+ # untrusted gate stamps every bundle unknown.
71
+ def stamp(bundles, whole_project: false)
72
+ readings = @readings || {}
73
+ @readings = nil
74
+ @unbound = Set.new
75
+ return distrust(bundles) if @untrusted
76
+
77
+ targets = bundles.select { |path, bundle| stamp?(bundle, whole_project || readings.key?(path)) }
78
+ return bundles if targets.empty?
79
+
80
+ stamped = bundles.dup
81
+ targets.each do |path, bundle|
82
+ stamped[path] = bundle.merge(DIGEST_KEY => bound_digest(path, bundle, readings[path], whole_project))
83
+ end
84
+ stamped
85
+ end
86
+
87
+ # Compares, by plugin id and order, the synthesizers the gate digests with against those of
88
+ # `prepared_registry` — the registry the run's environment was built from, after `#prepare` (nil when
89
+ # it could not be built, which also distrusts). Runs after every run, a run that digested nothing
90
+ # included: a stamp written by an earlier process from the other set would otherwise be trusted. On a
91
+ # mismatch the gate turns untrusted and every bundle is stamped unknown. Returns the bundles to keep.
92
+ def verify(prepared_registry, bundles)
93
+ return bundles if synthesizer_ids(prepared_registry&.source_rbs_synthesizers) == synthesizer_ids(synthesizers)
94
+
95
+ @untrusted = true
96
+ distrust(bundles)
97
+ end
98
+
99
+ private
100
+
101
+ def unmoved?(bundles, path)
102
+ before = stored(bundles, path)
103
+ !before.nil? && before == @readings.fetch(path).last
104
+ end
105
+
106
+ def stored(bundles, path)
107
+ bundles[path]&.fetch(DIGEST_KEY, nil)
108
+ end
109
+
110
+ def distrust(bundles)
111
+ bundles.transform_values { |bundle| bundle.merge(DIGEST_KEY => nil) }
112
+ end
113
+
114
+ # A bundle without a digest always needs one; one carrying nil is re-stamped only when this run can
115
+ # vouch for it, so an unknown stamp left by a distrust or an unbound read clears on the file's next edit
116
+ # or the next whole-project run.
117
+ def stamp?(bundle, vouched)
118
+ !bundle.key?(DIGEST_KEY) || (bundle[DIGEST_KEY].nil? && vouched)
119
+ end
120
+
121
+ # The digest for `bundle` when a reading vouches for the bytes it was built from, else nil (and the path
122
+ # joins {#unbound}).
123
+ def bound_digest(path, bundle, reading, whole_project)
124
+ reading ||= reading(path) if whole_project
125
+ return reading.last if reading && reading.first == bundle[:digest]
126
+
127
+ @unbound << path
128
+ nil
129
+ end
130
+
131
+ # `[content SHA-256, digest]`, the SHA nil unless the file read the same before and after the digest was
132
+ # taken, so the pair can be matched against a bundle's content digest.
133
+ def reading(path)
134
+ before = content_sha(path)
135
+ digest = Environment::SourceRbsSynthesis.digest(synthesizers, path, @cache_store)
136
+ [before && before == content_sha(path) ? before : nil, digest]
137
+ end
138
+
139
+ def content_sha(path)
140
+ Digest::SHA256.file(path).hexdigest
141
+ rescue SystemCallError
142
+ nil
143
+ end
144
+
145
+ def synthesizers
146
+ @synthesizers ||= Runner::ProjectPrePasses.new(
147
+ configuration: @configuration, cache_store: @cache_store, buffer: nil,
148
+ plugin_requirer: @plugin_requirer, pool_mode: -> { false }
149
+ ).prepared_registry(prepare: false).source_rbs_synthesizers
150
+ end
151
+
152
+ def synthesizer_ids(pairs)
153
+ pairs&.map { |plugin, _| plugin.manifest.id.to_s }
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ require_relative "../cache/file_digest"
6
+ require_relative "template_unit_paths"
7
+ require_relative "../plugin/template_unit"
8
+
9
+ module Rigor
10
+ module Analysis
11
+ # The build half of {TemplateUnits} (#392, #1038): it expands every loaded plugin's `template_globs:`,
12
+ # hands each match's bytes to the plugin's transform, isolates a failure into a {TemplateUnits::Failure}
13
+ # row, substitutes the editor's buffer for the file on disk where one is bound, and — since #1038 —
14
+ # carries a warm index's units for the templates that did not move.
15
+ #
16
+ # It is a separate file from the index for the reason {TemplateUnitPaths} is: the index is what the run
17
+ # READS, on a hot path, and the reasons the build has to be careful are longer than the build.
18
+ module TemplateUnitCollector
19
+ # Expands every loaded plugin's `template_globs:` and runs its transform.
20
+ #
21
+ # A plugin whose glob matches nothing, or whose hook returns `[]`, contributes nothing. A plugin that
22
+ # RAISES contributes nothing and does not break the run — the same failure isolation
23
+ # `#diagnostics_for_file` has, and for the same reason: a template compiler meeting a file it cannot
24
+ # read must cost that file's typing, never the run.
25
+ #
26
+ # Two plugins claiming one path is a conflict with no principled winner, so registration order decides
27
+ # and the later claim is dropped; the loser is not a diagnostic (the project chose both plugins).
28
+ # @param previous — a warm index from an earlier run of the same project (#1038). Every unit in it
29
+ # whose template is unchanged on disk is carried over instead of recompiled; see the class note.
30
+ def self.collect(registry:, root: Dir.pwd, buffer: nil, previous: nil)
31
+ claims = claims_for(registry)
32
+ claimed = claims.flat_map(&:last).uniq
33
+ signature = claims.map { |plugin, _globs| plugin_signature(plugin) }
34
+ collection = Collection.new(
35
+ root: root, buffer: buffer,
36
+ carried: previous&.carry_over(root: root, claimed_globs: claimed, plugin_signature: signature) || {}
37
+ )
38
+ state = { entries: {}, failures: [], stats: {} }
39
+ claims.each { |plugin, globs| collect_plugin(plugin, globs, collection, state) }
40
+ TemplateUnits.new(state[:entries],
41
+ failures: state[:failures], claimed_globs: claimed, root: root,
42
+ stats: state[:stats], plugin_signature: signature)
43
+ end
44
+
45
+ # The entry point the LSP-facing {ProjectScan} is built through (#1038): the index a later run
46
+ # CARRIES, so it is compiled with no buffer binding — a unit compiled from an editor's in-flight bytes
47
+ # must never reach a snapshot someone reuses.
48
+ #
49
+ # Wrapped in {Cache::FileDigest.with_run} because ADR-87's racy guard is what makes the packs
50
+ # recorded here trustworthy: the recording instant has to be taken BEFORE the bytes are read, so a
51
+ # template written between the read and the stat is recorded as racy and always re-digested.
52
+ # `prepare_project_scan` is not itself inside a run scope (only `Runner#run` is), and with no active
53
+ # table `pack_stat` falls back to an instant taken AFTER its own `File.stat`, which can never be racy
54
+ # — a pack pairing the OLD digest with the NEW stat tuple, which every later publish would validate
55
+ # on the tuple fast path and serve stale. `strict:` rides along for the reason `Runner#run` carries
56
+ # it: under `cache.validation: digest` the stat tier is skipped, here as everywhere else.
57
+ def self.collect_for_scan(registry:, strict: false)
58
+ return TemplateUnits.empty if registry.nil?
59
+
60
+ Cache::FileDigest.with_run(strict: strict) { collect(registry: registry, buffer: nil) }
61
+ end
62
+
63
+ # The `(plugin, globs)` pairs of every plugin that claimed one, in registration order — which is what
64
+ # decides a contested path (see {.collect}).
65
+ #
66
+ # A plugin whose manifest cannot be read claims nothing. `Plugin::Base#manifest` raises for a class
67
+ # that declared none, and a registry is not guaranteed to hold only well-formed plugins (the loader
68
+ # reports such a failure through its own channel); refusing to glob is the quiet, correct answer here
69
+ # rather than a second report of the same defect.
70
+ def self.claims_for(registry)
71
+ registry.plugins.filter_map do |plugin|
72
+ globs = begin
73
+ plugin.manifest.template_globs
74
+ rescue StandardError
75
+ []
76
+ end
77
+ next if globs.empty?
78
+
79
+ [plugin, globs]
80
+ end
81
+ end
82
+ private_class_method :claims_for
83
+
84
+ # What a carried index is checked against, beyond the per-file freshness: the identity of the plugins
85
+ # that claimed a glob. A registry change is a whole-index rebuild, never a per-path question — the
86
+ # transform itself may have changed.
87
+ def self.plugin_signature(plugin)
88
+ "#{plugin.manifest.id}@#{plugin.manifest.version}"
89
+ rescue StandardError
90
+ plugin.class.to_s
91
+ end
92
+ private_class_method :plugin_signature
93
+
94
+ # The immutable half of one collection pass. Split off the mutable `state` hash so the per-plugin and
95
+ # per-path helpers stay inside the parameter-count budget.
96
+ Collection = Data.define(:root, :buffer, :carried)
97
+ private_constant :Collection
98
+
99
+ # #1047 — the carry is decided for a plugin's claim AS A WHOLE, not per path. A transform may read
100
+ # the plugin's OTHER claimed templates: rigor-actionpack seeds a partial's locals from the render
101
+ # sites in every view that renders it, so `users/_card`'s unit depends on the bytes of
102
+ # `users/show`. A per-path carry reused `_card`'s unit after `show` dropped the local it passed,
103
+ # and the stale seed survived every later publish. So if any of a plugin's claimed templates was
104
+ # edited, added or deleted since the carried index was built, none of its units is carried and the
105
+ # whole claim is recompiled. One change is not seen: a template that produced NO unit (declined, or
106
+ # raised) being deleted, because the carried rows record no owning plugin for it. A declined template
107
+ # contributed nothing a sibling could have read from its unit, which is why that is left as stated
108
+ # rather than tracked. What stays exempt is the editor's buffer — a keystroke is not a save,
109
+ # so #1038's per-keystroke property holds — and a path an earlier plugin already claimed.
110
+ #
111
+ # #1065 widened what "any of that plugin's claimed templates" covers: rigor-actionpack now claims the
112
+ # handlers it DECLINES, so editing one `.haml` costs the whole ERB claim its carry. Measured on
113
+ # mastodon at +4 ms — the templates are recompiled from the {RenderLocals} memo rather than from
114
+ # scratch — which is why the claim is decided as a whole here rather than split per handler.
115
+ def self.collect_plugin(plugin, globs, collection, state)
116
+ start_pass(plugin)
117
+ plan = expand(globs, collection.root, collection.buffer).map do |path|
118
+ physical = physical_path(path, collection.root, collection.buffer)
119
+ [path, physical, carried_entry(path, physical, collection, state)]
120
+ end
121
+ whole = whole_carry?(plugin, plan, collection, state)
122
+ plan.each do |path, physical, carried|
123
+ if carried && whole
124
+ state[:entries][path], state[:stats][path] = carried
125
+ else
126
+ compile(plugin, path, physical, state)
127
+ end
128
+ end
129
+ end
130
+ private_class_method :collect_plugin
131
+
132
+ # {Plugin::Base#template_units_pass_started}, isolated: a plugin that raises there loses its own
133
+ # revalidation for this pass and nothing else.
134
+ def self.start_pass(plugin)
135
+ plugin.template_units_pass_started
136
+ rescue StandardError
137
+ nil
138
+ end
139
+ private_class_method :start_pass
140
+
141
+ # True when nothing in this plugin's claim moved: every planned path is carried (or is exempt, see
142
+ # {.collect_plugin}), and no path the carried index held for this plugin has since vanished.
143
+ def self.whole_carry?(plugin, plan, collection, state)
144
+ return false if collection.carried.empty?
145
+
146
+ id = plugin.manifest.id
147
+ planned = plan.to_h { |path, _physical, _carried| [path, true] }
148
+ vanished = collection.carried.any? { |path, (entry, _packed)| entry&.plugin_id == id && !planned.key?(path) }
149
+ return false if vanished
150
+
151
+ plan.all? do |path, physical, carried|
152
+ carried || state[:entries].key?(path) || buffer_bound?(path, collection) ||
153
+ unchanged_without_unit?(path, physical, collection)
154
+ end
155
+ end
156
+ private_class_method :whole_carry?
157
+
158
+ # A template the carried index READ and got no unit for — declined, or its transform raised — whose
159
+ # bytes have not moved. It is re-offered to the plugin as before, but it is not an edit.
160
+ def self.unchanged_without_unit?(path, physical, collection)
161
+ entry, packed = collection.carried[path]
162
+ entry.nil? && !packed.nil? && fresh?(physical, packed)
163
+ end
164
+ private_class_method :unchanged_without_unit?
165
+
166
+ # Reads one claimed template and hands its bytes to the plugin, recording the compiled unit and the
167
+ # freshness token a LATER run revalidates the carry against. A transform that raises costs this file
168
+ # its unit and nothing else.
169
+ def self.compile(plugin, path, physical, state)
170
+ id = plugin.manifest.id
171
+ source = read_source(physical, path, id, state[:failures])
172
+ return if source.nil?
173
+
174
+ state[:stats][path] = Cache::FileDigest.pack_stat(physical, Digest::SHA256.hexdigest(source))
175
+ units = transform(plugin, path, source, state)
176
+ return if units.nil?
177
+
178
+ fallback = "#{id}@#{plugin.manifest.version}"
179
+ units.each { |unit| record(state[:entries], unit, path, fallback, id, state[:failures]) }
180
+ end
181
+ private_class_method :compile
182
+
183
+ # The plugin call itself, with the ADR-2 isolation envelope around it: a raise is one `plugin_loader`
184
+ # row for this template and nil, which costs the file its unit and leaves the run intact. A transform
185
+ # that returns nothing is `[]` — an honest decline — not a failure.
186
+ def self.transform(plugin, path, source, state)
187
+ Array(plugin.template_units_for_file(path: path, source: source))
188
+ rescue StandardError => e
189
+ state[:failures] << TemplateUnits::Failure.new(plugin_id: plugin.manifest.id, path: path,
190
+ message: "#{e.class}: #{e.message}")
191
+ nil
192
+ end
193
+ private_class_method :transform
194
+
195
+ # #1038 — the `[entry, stat_pack]` a warm index already compiled for `path`, when nothing that could
196
+ # change it has moved; nil means "compile it".
197
+ #
198
+ # Three per-path refusals, each load-bearing; the whole-index ones are {TemplateUnits#carry_over}'s.
199
+ # A path another plugin already claimed is left to the loop that follows, so a second claimant still
200
+ # runs and is still reported the way it was before this memo existed. A path the editor's buffer is
201
+ # bound to is ALWAYS recompiled, from the buffer's bytes: the saved file is not what the user is
202
+ # looking at, and a unit compiled from a buffer must never end up in an index a later run carries.
203
+ # Otherwise the file's own freshness decides, through the ADR-87 pack — a moved stat tuple falls back
204
+ # to the content digest, so a touched-but-unedited template is still a reuse and an edited one never
205
+ # is. Any stat failure (the template was deleted between the glob and here) reads as "not fresh",
206
+ # which sends the path down the ordinary read path and produces the ordinary failure row.
207
+ def self.carried_entry(path, physical, collection, state)
208
+ return nil if state[:entries].key?(path)
209
+
210
+ entry, packed = collection.carried[path]
211
+ return nil if entry.nil? || packed.nil?
212
+ return nil if buffer_bound?(path, collection)
213
+ return nil unless fresh?(physical, packed)
214
+
215
+ [entry, packed]
216
+ end
217
+ private_class_method :carried_entry
218
+
219
+ def self.buffer_bound?(path, collection)
220
+ buffer = collection.buffer
221
+ !buffer.nil? && TemplateUnitPaths.relative(buffer.logical_path, collection.root) == path
222
+ end
223
+ private_class_method :buffer_bound?
224
+
225
+ def self.fresh?(physical, packed)
226
+ Cache::FileDigest.stat_fresh?(physical, packed)
227
+ rescue StandardError
228
+ false
229
+ end
230
+ private_class_method :fresh?
231
+
232
+ # Editor mode (#146) — the in-flight buffer's bytes stand in for the file on disk, exactly as
233
+ # `Runner#parse_source` reads them for a `.rb` file. Without this a `--tmp-file` / `--instead-of` pair
234
+ # naming a TEMPLATE compiled the saved file and the editor got diagnostics for bytes it had already
235
+ # replaced. `BufferBinding#resolve` is deliberately NOT used: it compares the logical path by string,
236
+ # and a unit path is project-relative while the editor names its buffer absolutely.
237
+ def self.physical_path(path, root, buffer)
238
+ return buffer.physical_path if buffer && TemplateUnitPaths.relative(buffer.logical_path, root) == path
239
+
240
+ File.join(root, path)
241
+ end
242
+
243
+ def self.read_source(physical, path, plugin_id, failures)
244
+ File.binread(physical)
245
+ rescue StandardError => e
246
+ failures << TemplateUnits::Failure.new(plugin_id: plugin_id, path: path,
247
+ message: "could not be read (#{e.class}: #{e.message})")
248
+ nil
249
+ end
250
+ private_class_method :read_source
251
+
252
+ # Sorted so the run's analysed-path order — and therefore the run cache key's `paths` slot — is
253
+ # independent of the filesystem's directory order.
254
+ #
255
+ # An editor buffer whose logical path MATCHES a claimed glob joins the set even when nothing is on
256
+ # disk at that path. That is the `didOpen` of a freshly created view: the file exists only in the
257
+ # editor, `Dir.glob` cannot see it, and without this the run fell through to parsing the tmp bytes as
258
+ # plain top-level Ruby — no declared `self`, no seeds, so a helper call read as
259
+ # `call.unresolved-toplevel` and the finding the editor was actually looking at was missed. The
260
+ # buffer's own bytes are what {.compile} then reads, and nothing else changes: an editor run is
261
+ # read-only-cached, and `Runner#template_unit_file_entries` already skips a path with no physical file.
262
+ def self.expand(globs, root, buffer = nil)
263
+ paths = globs.flat_map { |glob| Dir.glob(glob, base: root) }
264
+ .select { |path| File.file?(File.join(root, path)) }
265
+ buffered = buffer && TemplateUnitPaths.relative(buffer.logical_path, root)
266
+ paths |= [buffered] if buffered && TemplateUnitPaths.claims?(globs, buffered)
267
+ paths.uniq.sort
268
+ end
269
+
270
+ private_class_method :expand
271
+
272
+ # A unit MUST name the file it was compiled from. Without the check a `path:` naming another project
273
+ # file silently REPLACED that file's source (the engine serves a unit's bytes for its own path), and a
274
+ # `path:` naming something outside the project root was analysed with no dependency-descriptor row —
275
+ # both from a plugin that only had to get one string wrong. A mismatch is reported, not dropped in
276
+ # silence, because a plugin author whose unit vanished has nothing to read.
277
+ def self.record(entries, unit, claimed_path, fallback, plugin_id, failures)
278
+ return unless unit.is_a?(Plugin::TemplateUnit)
279
+
280
+ unless unit.path == claimed_path
281
+ failures << TemplateUnits::Failure.new(
282
+ plugin_id: plugin_id, path: claimed_path,
283
+ message: "returned a unit for #{unit.path.inspect}, which is not the file it was offered; " \
284
+ "a unit may only name its own source"
285
+ )
286
+ return
287
+ end
288
+ # First claim wins — see {.collect}. A duplicate `logical_name` across two DIFFERENT paths is NOT
289
+ # refused: the two units are analysed separately and their summaries union under one `view:` key,
290
+ # which is the same reading a method reopened in two files gets.
291
+ return if entries.key?(unit.path)
292
+
293
+ entries[unit.path] = TemplateUnits::Entry.new(
294
+ logical_name: unit.logical_name, path: unit.path, source: unit.ruby_source,
295
+ line_map: unit.line_map, self_type: unit.self_type, locals: unit.locals,
296
+ ivar_seeds: unit.ivar_seeds, digest: unit.digest(fallback), unit_key: unit.unit_key,
297
+ plugin_id: plugin_id, suppressed_rules: unit.suppressed_rules
298
+ )
299
+ end
300
+ private_class_method :record
301
+ end
302
+ end
303
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ # The path-spelling arithmetic behind {TemplateUnits} (#392).
6
+ #
7
+ # A template unit is keyed the way a claimed glob spells it — project-relative, as `Dir.glob(base:)`
8
+ # returns it. Every other spelling a path can arrive in has to reduce to that one before a lookup or a
9
+ # claim test, and there are more of them than there look to be: a language server names a buffer by its
10
+ # absolute path, a shell hands over `./app/views/x.rbx` or `lib/../app/views/x.rbx`, and the same
11
+ # directory reached through a symlink is the same directory (`Dir.pwd` is always resolved; on macOS an
12
+ # editor's `/var/…` and pwd's `/private/var/…` are one place). Each of those missed the unit and left
13
+ # the template parsed as plain Ruby, so the reduction lives in one place with the reasons attached.
14
+ module TemplateUnitPaths
15
+ module_function
16
+
17
+ # A path as the globs spell it. An analysed path may arrive ABSOLUTE — the language server names a
18
+ # buffer by its full filesystem path, and `rigor check /abs/path` does too — while a claimed glob and
19
+ # everything `Dir.glob(base:)` returns are project-relative. Every lookup and every claim test goes
20
+ # through this, so the two spellings name one unit instead of silently missing each other (which is
21
+ # how an LSP publish for an open `.rbx` reported `call.unresolved-toplevel` for every helper while the
22
+ # unit sat in the index under its relative name). A path outside the root is left alone.
23
+ def relative(path, root)
24
+ # `absolute_path` against the root first, so `./app/views/x.rbx`, `app/views/x.rbx` and
25
+ # `$ROOT/lib/../app/views/x.rbx` all reduce to the one absolute spelling before anything is
26
+ # compared. Without it an `--instead-of=./app/views/x.rbx` matched no unit and was parsed as plain
27
+ # Ruby — the spellings a shell and an editor produce are not the spelling `Dir.glob` returns.
28
+ # `absolute_path`, not `expand_path`: a leading `~` is a directory name here (`~drafts/a.rb`), and
29
+ # `expand_path` reads it as a user's home and raises `ArgumentError` for a user that does not exist.
30
+ root_dir = File.absolute_path(root.to_s)
31
+ text = File.absolute_path(path.to_s, root_dir)
32
+ # BOTH spellings of the root. `absolute_path` does not resolve symlinks, and the root is not always
33
+ # already resolved: `Dir.pwd` is (which is why this was latent), but a caller may pass any path.
34
+ prefixes(root_dir).each do |prefix|
35
+ return text.delete_prefix(prefix) if text.start_with?(prefix)
36
+ end
37
+
38
+ # The same directory reached through a symlink is the same directory. `Dir.pwd` is always the
39
+ # resolved form (`/private/var/…` on macOS) while an editor names a buffer by the path the user
40
+ # opened (`/var/…`), so a string compare alone loses the match.
41
+ resolved = resolved_path(text)
42
+ return text if resolved.nil?
43
+
44
+ prefixes(root_dir).each do |prefix|
45
+ return resolved.delete_prefix(prefix) if resolved.start_with?(prefix)
46
+ end
47
+ text
48
+ end
49
+
50
+ def prefixes(root_dir)
51
+ [root_dir, (begin
52
+ File.realpath(root_dir)
53
+ rescue StandardError
54
+ nil
55
+ end)].compact.uniq.map { |dir| "#{dir}#{File::SEPARATOR}" }
56
+ end
57
+
58
+ # `text` with its **nearest existing ancestor** resolved and the rest re-joined. Realpathing the whole
59
+ # dirname is not enough: a buffer for a view in a directory the editor has not created yet
60
+ # (`app/views/reports/new.rbx` with no `reports/`) raises `ENOENT` there, and a nil answer meant a
61
+ # symlinked root lost the unit entirely for exactly the file most likely to be open.
62
+ def resolved_path(text)
63
+ remainder = []
64
+ current = text
65
+ loop do
66
+ parent = File.dirname(current)
67
+ remainder.unshift(File.basename(current))
68
+ real = begin
69
+ File.realpath(parent)
70
+ rescue StandardError
71
+ nil
72
+ end
73
+ return File.join(real, *remainder) if real
74
+ return nil if parent == current
75
+
76
+ current = parent
77
+ end
78
+ end
79
+
80
+ # `FNM_PATHNAME` so `*` does not cross a directory separator (the same reading `Dir.glob` gives the
81
+ # pattern), `FNM_EXTGLOB` so a `{html,text}` alternation in a claimed glob matches here as it did
82
+ # there — the two flags together are what make this predicate agree with the expansion above.
83
+ def claims?(globs, path)
84
+ # `relative` has already reduced an IN-root path to its project-relative form, so anything still
85
+ # absolute here is outside the project. An unanchored claim (`**/*.rbx`) would otherwise match it,
86
+ # and the plugin would be handed an absolute `path:` for a file the project does not contain.
87
+ return false if path.start_with?(File::SEPARATOR)
88
+
89
+ globs.any? { |glob| File.fnmatch?(glob, path, File::FNM_PATHNAME | File::FNM_EXTGLOB) }
90
+ end
91
+ end
92
+ end
93
+ end