rigortype 0.3.8 → 0.3.9

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 (447) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/capability_roles/capability_roles.rbs +36 -0
  4. data/data/gem_overlay/activesupport/core_ext.rbs +109 -13
  5. data/docs/handbook/02-everyday-types.md +17 -14
  6. data/docs/handbook/03-narrowing.md +37 -4
  7. data/docs/handbook/05-methods-and-blocks.md +1 -1
  8. data/docs/handbook/06-classes.md +2 -2
  9. data/docs/handbook/07-rbs-and-extended.md +14 -1
  10. data/docs/handbook/11-sig-gen.md +72 -1
  11. data/docs/handbook/README.md +1 -1
  12. data/docs/handbook/appendix-elixir.md +2 -2
  13. data/docs/handbook/appendix-go.md +2 -2
  14. data/docs/handbook/appendix-java-csharp.md +2 -2
  15. data/docs/handbook/appendix-mypy.md +2 -2
  16. data/docs/handbook/appendix-phpstan.md +1 -1
  17. data/docs/handbook/appendix-rust.md +1 -1
  18. data/docs/handbook/appendix-type-theory.md +4 -4
  19. data/docs/handbook/appendix-typescript.md +1 -1
  20. data/docs/llms.txt +2 -0
  21. data/docs/manual/02-cli-reference.md +32 -4
  22. data/docs/manual/03-configuration.md +11 -0
  23. data/docs/manual/04-diagnostics.md +11 -0
  24. data/docs/manual/07-plugins.md +15 -5
  25. data/docs/manual/08-skills.md +23 -2
  26. data/docs/manual/11-ci.md +9 -0
  27. data/docs/manual/15-type-protection-coverage.md +8 -0
  28. data/docs/manual/16-rbs-extended-annotations.md +6 -1
  29. data/docs/manual/18-removing-dead-code.md +10 -8
  30. data/docs/manual/plugins/rigor-actionpack.md +18 -0
  31. data/docs/manual/plugins/rigor-activerecord.md +28 -0
  32. data/docs/manual/plugins/rigor-activesupport-core-ext.md +32 -2
  33. data/docs/manual/plugins/rigor-rbs-inline.md +42 -1
  34. data/lib/rigor/analysis/baseline.rb +2 -2
  35. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +1 -1
  36. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +1 -5
  37. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +10 -2
  38. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +1 -1
  39. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +15 -6
  40. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -1
  41. data/lib/rigor/analysis/check_rules/rule_walk.rb +39 -4
  42. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +1 -1
  43. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +1 -1
  44. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +2 -2
  45. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +0 -1
  46. data/lib/rigor/analysis/check_rules.rb +200 -32
  47. data/lib/rigor/analysis/crash_signature.rb +8 -24
  48. data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +1 -1
  49. data/lib/rigor/analysis/dependency_source_inference/builder.rb +0 -2
  50. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +0 -2
  51. data/lib/rigor/analysis/dependency_source_inference/index.rb +5 -5
  52. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +1 -2
  53. data/lib/rigor/analysis/dependency_source_inference/walker.rb +4 -4
  54. data/lib/rigor/analysis/effects_cache_probe.rb +3 -4
  55. data/lib/rigor/analysis/erb_template_detector.rb +1 -2
  56. data/lib/rigor/analysis/fact_store.rb +6 -1
  57. data/lib/rigor/analysis/incremental.rb +16 -0
  58. data/lib/rigor/analysis/incremental_session.rb +81 -22
  59. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +0 -1
  60. data/lib/rigor/analysis/reachability/graph.rb +3 -5
  61. data/lib/rigor/analysis/reachability/plugin_roots.rb +6 -5
  62. data/lib/rigor/analysis/reachability/project_files.rb +2 -2
  63. data/lib/rigor/analysis/reachability/scan.rb +5 -5
  64. data/lib/rigor/analysis/reachability/scan_cache.rb +2 -2
  65. data/lib/rigor/analysis/reachability/signature_scan.rb +2 -2
  66. data/lib/rigor/analysis/result.rb +1 -3
  67. data/lib/rigor/analysis/rule_catalog.rb +4 -1
  68. data/lib/rigor/analysis/run_cache_key.rb +1 -1
  69. data/lib/rigor/analysis/run_cache_probe.rb +3 -5
  70. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -8
  71. data/lib/rigor/analysis/runner/declaration_position.rb +1 -2
  72. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +177 -26
  73. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +2 -3
  74. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +9 -10
  75. data/lib/rigor/analysis/runner/pool_coordinator.rb +256 -88
  76. data/lib/rigor/analysis/runner/project_pre_passes.rb +12 -6
  77. data/lib/rigor/analysis/runner/run_snapshots.rb +6 -1
  78. data/lib/rigor/analysis/runner.rb +133 -35
  79. data/lib/rigor/analysis/worker_session.rb +9 -10
  80. data/lib/rigor/bleeding_edge.rb +12 -24
  81. data/lib/rigor/builtins/hkt_builtins.rb +2 -2
  82. data/lib/rigor/builtins/imported_refinements.rb +155 -21
  83. data/lib/rigor/builtins/predefined_constant_refinements.rb +5 -7
  84. data/lib/rigor/builtins/regex_refinement.rb +4 -4
  85. data/lib/rigor/builtins/static_return_refinements.rb +5 -6
  86. data/lib/rigor/cache/annotation_location.rb +2 -4
  87. data/lib/rigor/cache/descriptor.rb +58 -20
  88. data/lib/rigor/cache/engine_source.rb +4 -4
  89. data/lib/rigor/cache/file_digest.rb +9 -1
  90. data/lib/rigor/cache/incremental_snapshot.rb +40 -6
  91. data/lib/rigor/cache/rbs_class_ancestor_table.rb +0 -3
  92. data/lib/rigor/cache/rbs_class_type_param_names.rb +0 -3
  93. data/lib/rigor/cache/rbs_constant_table.rb +0 -3
  94. data/lib/rigor/cache/rbs_descriptor.rb +79 -11
  95. data/lib/rigor/cache/rbs_environment.rb +8 -4
  96. data/lib/rigor/cache/rbs_known_class_names.rb +0 -3
  97. data/lib/rigor/cache/store.rb +77 -32
  98. data/lib/rigor/cli/annotate_command.rb +5 -6
  99. data/lib/rigor/cli/check_command.rb +57 -10
  100. data/lib/rigor/cli/check_invocation.rb +6 -11
  101. data/lib/rigor/cli/check_runner_factory.rb +1 -5
  102. data/lib/rigor/cli/coverage_command.rb +1 -1
  103. data/lib/rigor/cli/coverage_mutation.rb +12 -3
  104. data/lib/rigor/cli/coverage_scan.rb +1 -5
  105. data/lib/rigor/cli/diff_command.rb +1 -1
  106. data/lib/rigor/cli/doc_links.rb +3 -3
  107. data/lib/rigor/cli/docs_command.rb +2 -2
  108. data/lib/rigor/cli/doctor_command.rb +85 -38
  109. data/lib/rigor/cli/effects_command.rb +2 -2
  110. data/lib/rigor/cli/effects_diff_renderer.rb +3 -3
  111. data/lib/rigor/cli/effects_snapshot_command.rb +1 -1
  112. data/lib/rigor/cli/explain_command.rb +34 -1
  113. data/lib/rigor/cli/fused_protection_report.rb +7 -1
  114. data/lib/rigor/cli/lsp_command.rb +1 -1
  115. data/lib/rigor/cli/mcp_command.rb +1 -1
  116. data/lib/rigor/cli/measurement_integrity_warning.rb +4 -5
  117. data/lib/rigor/cli/mutation_fork_scan.rb +6 -6
  118. data/lib/rigor/cli/mutation_protection_report.rb +7 -1
  119. data/lib/rigor/cli/plugin_command.rb +1 -1
  120. data/lib/rigor/cli/plugins_command.rb +2 -1
  121. data/lib/rigor/cli/prism_colorizer.rb +2 -2
  122. data/lib/rigor/cli/protection_fork_scan.rb +6 -6
  123. data/lib/rigor/cli/show_bleedingedge_command.rb +1 -1
  124. data/lib/rigor/cli/sig_gen_command.rb +2 -2
  125. data/lib/rigor/cli/skill_command.rb +1 -1
  126. data/lib/rigor/cli/skill_deep_probe.rb +4 -4
  127. data/lib/rigor/cli/skill_describe.rb +36 -35
  128. data/lib/rigor/cli/trace_command.rb +4 -4
  129. data/lib/rigor/cli/trace_renderer.rb +5 -6
  130. data/lib/rigor/cli/triage_command.rb +1 -1
  131. data/lib/rigor/cli/type_of_command.rb +10 -6
  132. data/lib/rigor/cli/type_scan_command.rb +4 -4
  133. data/lib/rigor/cli/unused_command.rb +11 -3
  134. data/lib/rigor/cli/upgrade_command.rb +1 -1
  135. data/lib/rigor/cli.rb +68 -6
  136. data/lib/rigor/config_audit.rb +30 -5
  137. data/lib/rigor/configuration/severity_profile.rb +6 -6
  138. data/lib/rigor/configuration.rb +43 -4
  139. data/lib/rigor/effects/attribution.rb +1 -3
  140. data/lib/rigor/effects/config_envelopes.rb +6 -8
  141. data/lib/rigor/effects/definition_lines.rb +25 -6
  142. data/lib/rigor/effects/effect_table.rb +0 -1
  143. data/lib/rigor/effects/entry_points.rb +0 -2
  144. data/lib/rigor/effects/envelope_check.rb +8 -8
  145. data/lib/rigor/effects/envelope_index.rb +5 -8
  146. data/lib/rigor/effects/framework_units.rb +4 -6
  147. data/lib/rigor/effects/identity.rb +4 -6
  148. data/lib/rigor/effects/inline_anchor.rb +7 -7
  149. data/lib/rigor/effects/label_intent.rb +3 -4
  150. data/lib/rigor/effects/liskov_check.rb +8 -8
  151. data/lib/rigor/effects/method_key.rb +1 -1
  152. data/lib/rigor/effects/plugin_facts.rb +4 -6
  153. data/lib/rigor/effects/propagator.rb +2 -3
  154. data/lib/rigor/effects/scanner.rb +1 -1
  155. data/lib/rigor/effects/signature_sources.rb +3 -5
  156. data/lib/rigor/effects/snapshot.rb +10 -10
  157. data/lib/rigor/effects/snapshot_diff.rb +1 -1
  158. data/lib/rigor/effects/unit_scan.rb +10 -10
  159. data/lib/rigor/effects/unknown_label_check.rb +3 -8
  160. data/lib/rigor/effects/unknown_label_report.rb +3 -4
  161. data/lib/rigor/environment/bundle_sig_discovery.rb +6 -6
  162. data/lib/rigor/environment/class_registry.rb +3 -1
  163. data/lib/rigor/environment/failure_slot.rb +2 -2
  164. data/lib/rigor/environment/installed_gem_set.rb +85 -0
  165. data/lib/rigor/environment/lockfile_resolver.rb +51 -4
  166. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -4
  167. data/lib/rigor/environment/rbs_collection_discovery.rb +5 -5
  168. data/lib/rigor/environment/rbs_coverage_report.rb +5 -5
  169. data/lib/rigor/environment/rbs_hierarchy.rb +3 -1
  170. data/lib/rigor/environment/rbs_loader.rb +388 -59
  171. data/lib/rigor/environment.rb +105 -20
  172. data/lib/rigor/flow_contribution/merger.rb +0 -2
  173. data/lib/rigor/flow_contribution.rb +11 -13
  174. data/lib/rigor/inference/acceptance.rb +153 -8
  175. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  176. data/lib/rigor/inference/body_fixpoint.rb +5 -5
  177. data/lib/rigor/inference/budget_trace.rb +1 -5
  178. data/lib/rigor/inference/captured_locals.rb +2 -3
  179. data/lib/rigor/inference/closure_escape_analyzer.rb +2 -4
  180. data/lib/rigor/inference/coverage_scanner.rb +4 -6
  181. data/lib/rigor/inference/dynamic_origin.rb +1 -1
  182. data/lib/rigor/inference/element_read_widening.rb +168 -0
  183. data/lib/rigor/inference/expression_typer.rb +763 -143
  184. data/lib/rigor/inference/fork_map.rb +5 -7
  185. data/lib/rigor/inference/hkt_reducer.rb +2 -3
  186. data/lib/rigor/inference/hkt_registry.rb +4 -7
  187. data/lib/rigor/inference/index_write_widening.rb +1 -5
  188. data/lib/rigor/inference/macro_block_self_type.rb +1 -4
  189. data/lib/rigor/inference/method_dispatcher/block_folding.rb +2 -9
  190. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +1 -1
  191. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +305 -19
  192. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  193. data/lib/rigor/inference/method_dispatcher/file_folding.rb +1 -1
  194. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +2 -2
  195. data/lib/rigor/inference/method_dispatcher/json_folding.rb +1 -1
  196. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +7 -1
  197. data/lib/rigor/inference/method_dispatcher/math_folding.rb +41 -3
  198. data/lib/rigor/inference/method_dispatcher/method_folding.rb +2 -5
  199. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +61 -47
  200. data/lib/rigor/inference/method_dispatcher/random_folding.rb +82 -0
  201. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +165 -31
  202. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +25 -4
  203. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +2 -8
  204. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +1 -1
  205. data/lib/rigor/inference/method_dispatcher/set_folding.rb +1 -1
  206. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -1
  207. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +1 -1
  208. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +1 -2
  209. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -5
  210. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +85 -4
  211. data/lib/rigor/inference/method_dispatcher/time_folding.rb +1 -1
  212. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +1 -2
  213. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +1 -1
  214. data/lib/rigor/inference/method_dispatcher.rb +12 -13
  215. data/lib/rigor/inference/method_parameter_binder.rb +4 -6
  216. data/lib/rigor/inference/multi_target_binder.rb +1 -3
  217. data/lib/rigor/inference/mutation_rejoin.rb +168 -0
  218. data/lib/rigor/inference/mutation_widening.rb +100 -51
  219. data/lib/rigor/inference/narrowing.rb +299 -46
  220. data/lib/rigor/inference/optimistic_origin.rb +1 -7
  221. data/lib/rigor/inference/origin_lookup.rb +2 -3
  222. data/lib/rigor/inference/parameter_inference_collector.rb +5 -6
  223. data/lib/rigor/inference/pre_eval_constants.rb +6 -5
  224. data/lib/rigor/inference/precision_scanner.rb +3 -4
  225. data/lib/rigor/inference/project_patched_methods.rb +2 -2
  226. data/lib/rigor/inference/project_patched_scanner.rb +3 -3
  227. data/lib/rigor/inference/protection_scanner.rb +1 -2
  228. data/lib/rigor/inference/range_constant.rb +57 -0
  229. data/lib/rigor/inference/rbs_type_translator.rb +49 -11
  230. data/lib/rigor/inference/receiver_alias.rb +3 -3
  231. data/lib/rigor/inference/refinement_mutation.rb +72 -0
  232. data/lib/rigor/inference/scope_indexer.rb +688 -151
  233. data/lib/rigor/inference/statement_evaluator.rb +221 -50
  234. data/lib/rigor/inference/string_mutation.rb +60 -0
  235. data/lib/rigor/inference/struct_fold_safety.rb +6 -7
  236. data/lib/rigor/inference/synthetic_method_index.rb +1 -2
  237. data/lib/rigor/inference/synthetic_method_scanner.rb +4 -6
  238. data/lib/rigor/inference/version_guard.rb +28 -21
  239. data/lib/rigor/inference/void_origin.rb +3 -3
  240. data/lib/rigor/inference/void_tail_summary.rb +2 -4
  241. data/lib/rigor/language_server/buffer_table.rb +4 -4
  242. data/lib/rigor/language_server/completion_provider.rb +1 -1
  243. data/lib/rigor/language_server/debouncer.rb +1 -1
  244. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  245. data/lib/rigor/language_server/document_symbol_provider.rb +1 -1
  246. data/lib/rigor/language_server/folding_range_provider.rb +1 -1
  247. data/lib/rigor/language_server/hover_provider.rb +1 -1
  248. data/lib/rigor/language_server/hover_renderer.rb +2 -2
  249. data/lib/rigor/language_server/incremental_sync.rb +6 -6
  250. data/lib/rigor/language_server/project_context.rb +2 -5
  251. data/lib/rigor/language_server/publish_batcher.rb +2 -2
  252. data/lib/rigor/language_server/selection_range_provider.rb +2 -2
  253. data/lib/rigor/language_server/server.rb +9 -9
  254. data/lib/rigor/language_server/signature_help_provider.rb +1 -1
  255. data/lib/rigor/language_server/uri.rb +1 -1
  256. data/lib/rigor/plugin/base.rb +90 -21
  257. data/lib/rigor/plugin/bundled_catalog.rb +167 -0
  258. data/lib/rigor/plugin/effect_attribution.rb +7 -7
  259. data/lib/rigor/plugin/effect_entry_points.rb +3 -3
  260. data/lib/rigor/plugin/fact_store.rb +6 -6
  261. data/lib/rigor/plugin/io_boundary.rb +93 -8
  262. data/lib/rigor/plugin/isolation.rb +29 -6
  263. data/lib/rigor/plugin/loader.rb +31 -12
  264. data/lib/rigor/plugin/macro/heredoc_template.rb +1 -1
  265. data/lib/rigor/plugin/macro/trait_registry.rb +1 -1
  266. data/lib/rigor/plugin/manifest.rb +23 -1
  267. data/lib/rigor/plugin/registry.rb +42 -12
  268. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +12 -1
  269. data/lib/rigor/plugin/trust_policy.rb +2 -4
  270. data/lib/rigor/plugin/type_node_resolver.rb +3 -3
  271. data/lib/rigor/plugin_gap_advisory.rb +96 -0
  272. data/lib/rigor/project_environment.rb +138 -0
  273. data/lib/rigor/protection/analysis_guard.rb +93 -14
  274. data/lib/rigor/protection/closure_kill_oracle.rb +67 -23
  275. data/lib/rigor/protection/dependency_closure.rb +5 -8
  276. data/lib/rigor/protection/diagnostic_oracle.rb +2 -2
  277. data/lib/rigor/protection/discovery_seed.rb +11 -11
  278. data/lib/rigor/protection/kill_signature.rb +2 -4
  279. data/lib/rigor/protection/measurement_integrity.rb +1 -3
  280. data/lib/rigor/protection/mutation_cache.rb +6 -9
  281. data/lib/rigor/protection/mutation_scanner.rb +12 -15
  282. data/lib/rigor/protection/mutator.rb +2 -1
  283. data/lib/rigor/protection/test_suite_oracle.rb +5 -5
  284. data/lib/rigor/rbs_extended/conformance_checker.rb +4 -3
  285. data/lib/rigor/rbs_extended/envelope_scanner.rb +4 -6
  286. data/lib/rigor/rbs_extended/reporter.rb +36 -10
  287. data/lib/rigor/rbs_extended.rb +61 -21
  288. data/lib/rigor/reflection/constant_path.rb +126 -0
  289. data/lib/rigor/reflection.rb +26 -10
  290. data/lib/rigor/runtime/jit.rb +6 -8
  291. data/lib/rigor/scope/discovery_index.rb +30 -0
  292. data/lib/rigor/scope.rb +203 -4
  293. data/lib/rigor/sig_gen/generator.rb +115 -22
  294. data/lib/rigor/sig_gen/layout_index.rb +3 -4
  295. data/lib/rigor/sig_gen/meta_class_shape.rb +3 -5
  296. data/lib/rigor/sig_gen/observation_collector.rb +17 -15
  297. data/lib/rigor/sig_gen/path_mapper.rb +4 -6
  298. data/lib/rigor/sig_gen/rbs_validity.rb +4 -4
  299. data/lib/rigor/sig_gen/renderer.rb +4 -5
  300. data/lib/rigor/sig_gen/skip_reason_catalog.rb +111 -0
  301. data/lib/rigor/sig_gen/superclass_spelling.rb +27 -0
  302. data/lib/rigor/sig_gen/type_elaborator.rb +1 -3
  303. data/lib/rigor/sig_gen/writer.rb +23 -6
  304. data/lib/rigor/signature_path_audit.rb +153 -6
  305. data/lib/rigor/source/literals.rb +0 -23
  306. data/lib/rigor/source/node_children.rb +0 -2
  307. data/lib/rigor/source/node_locator.rb +5 -11
  308. data/lib/rigor/source/node_walker.rb +2 -5
  309. data/lib/rigor/triage/catalogue.rb +1 -3
  310. data/lib/rigor/triage.rb +3 -5
  311. data/lib/rigor/type/accepts_result.rb +24 -6
  312. data/lib/rigor/type/combinator.rb +68 -5
  313. data/lib/rigor/type/data_class.rb +2 -2
  314. data/lib/rigor/type/data_instance.rb +4 -4
  315. data/lib/rigor/type/float_range.rb +128 -0
  316. data/lib/rigor/type/hash_shape.rb +5 -5
  317. data/lib/rigor/type/integer_range.rb +13 -8
  318. data/lib/rigor/type/nominal.rb +8 -2
  319. data/lib/rigor/type/refined.rb +3 -3
  320. data/lib/rigor/type/struct_class.rb +3 -3
  321. data/lib/rigor/type/struct_instance.rb +4 -4
  322. data/lib/rigor/type.rb +1 -0
  323. data/lib/rigor/type_node/generic.rb +1 -1
  324. data/lib/rigor/type_node/range_literal.rb +25 -0
  325. data/lib/rigor/type_node/resolver_chain.rb +1 -1
  326. data/lib/rigor/type_node.rb +1 -0
  327. data/lib/rigor/version.rb +1 -1
  328. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +0 -4
  329. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +0 -1
  330. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +0 -2
  331. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +1 -0
  332. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +0 -4
  333. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +3 -5
  334. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +1 -4
  335. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +1 -0
  336. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +33 -30
  337. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +0 -1
  338. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +1 -3
  339. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +28 -2
  340. data/plugins/rigor-actionpack/sig/action_controller.rbs +71 -0
  341. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +0 -4
  342. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +1 -1
  343. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +0 -1
  344. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +0 -2
  345. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +1 -1
  346. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +1 -0
  347. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +3 -4
  348. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +1 -2
  349. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +1 -2
  350. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +42 -7
  351. data/plugins/rigor-activerecord/sig/active_record/framework.rbs +123 -0
  352. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +1 -0
  353. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -2
  354. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +119 -14
  355. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +1 -0
  356. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +1 -0
  357. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +9 -7
  358. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +7 -3
  359. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +1 -0
  360. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +8 -6
  361. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +11 -7
  362. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +13 -11
  363. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +9 -4
  364. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +1 -0
  365. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +0 -5
  366. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +0 -1
  367. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +0 -1
  368. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +8 -1
  369. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +36 -4
  370. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +1 -0
  371. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +8 -6
  372. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +6 -3
  373. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +1 -0
  374. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +2 -1
  375. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +0 -3
  376. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +1 -0
  377. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +0 -5
  378. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -2
  379. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +0 -1
  380. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +0 -1
  381. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +1 -0
  382. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +0 -12
  383. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +1 -3
  384. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +0 -1
  385. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +1 -0
  386. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +5 -5
  387. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +1 -10
  388. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +3 -3
  389. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +2 -2
  390. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +2 -2
  391. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +2 -2
  392. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +9 -10
  393. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +2 -3
  394. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -0
  395. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +1 -0
  396. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +13 -1
  397. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +128 -3
  398. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +0 -3
  399. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +0 -3
  400. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -5
  401. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +2 -3
  402. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +1 -0
  403. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +0 -3
  404. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +1 -0
  405. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +1 -0
  406. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +1 -9
  407. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +1 -0
  408. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +0 -4
  409. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +1 -1
  410. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +1 -1
  411. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +0 -1
  412. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +0 -2
  413. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +1 -0
  414. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -0
  415. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +2 -5
  416. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +1 -4
  417. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +3 -9
  418. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +4 -4
  419. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +1 -2
  420. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +4 -5
  421. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +7 -5
  422. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +5 -5
  423. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +1 -0
  424. data/sig/rigor/analysis/reachability/scan_cache.rbs +12 -0
  425. data/sig/rigor/cache.rbs +2 -3
  426. data/sig/rigor/environment.rbs +5 -0
  427. data/sig/rigor/inference.rbs +3 -10
  428. data/sig/rigor/plugin/io_boundary.rbs +4 -0
  429. data/sig/rigor/plugin/manifest.rbs +2 -1
  430. data/sig/rigor/reflection.rbs +3 -1
  431. data/sig/rigor/scope.rbs +24 -3
  432. data/sig/rigor/sig_gen/skip_reason_catalog.rbs +14 -0
  433. data/sig/rigor/type.rbs +27 -1
  434. data/sig/rigor.rbs +1 -0
  435. data/skills/rigor-ask/SKILL.md +8 -6
  436. data/skills/rigor-plugin-author/references/02-walker-and-types.md +13 -2
  437. data/skills/rigor-project-init/SKILL.md +6 -3
  438. data/skills/rigor-project-init/references/02-configure.md +10 -6
  439. data/skills/rigor-project-init/references/06-agent-contract.md +67 -0
  440. data/skills/rigor-type-oracle/SKILL.md +228 -0
  441. data/skills/rigor-type-oracle/references/01-oracle-commands.md +261 -0
  442. data/skills/rigor-type-oracle/references/02-agents-md-fragment.md +52 -0
  443. data/skills/rigor-type-oracle/references/03-gap-protocol.md +128 -0
  444. data/skills/rigor-unused-adjudicate/SKILL.md +8 -5
  445. metadata +26 -3
  446. data/lib/rigor/cli/probe_environment.rb +0 -85
  447. data/sig/rigor/inference/builtins/numeric_catalog.rbs +0 -3
@@ -7,6 +7,7 @@ require_relative "../type"
7
7
  require_relative "../source/constant_path"
8
8
  require_relative "../source/node_children"
9
9
  require_relative "../cache/file_digest"
10
+ require_relative "../analysis/check_rules/published_constant_guard"
10
11
  require_relative "anonymous_meta_class"
11
12
  require_relative "def_handle"
12
13
  require_relative "index_write_widening"
@@ -46,27 +47,42 @@ module Rigor
46
47
  # seed bundle through `Marshal` unchanged.
47
48
  CONSTANT_UNPUBLISHABLE = :unpublishable
48
49
 
50
+ # Issue #668 — the census key a constant write through a base no name reaches is filed under:
51
+ # `*::LIMIT` for `k::LIMIT = 7`. `*` is not a constant character, so the key can never collide with a
52
+ # name a program writes, while its LAST SEGMENT is the real one — which is what the ADR-46 `constant:`
53
+ # edge and {Analysis::Incremental.changed_constant_publications} key on, so both keep working unchanged.
54
+ DYNAMIC_TARGET_PREFIX = "*::"
55
+
49
56
  module_function
50
57
 
51
58
  # Build the scope index for a Prism program subtree.
52
59
  #
53
- # @param root [Prism::Node] usually a `Prism::ProgramNode`, but any
60
+ # @param root usually a `Prism::ProgramNode`, but any
54
61
  # subtree the caller wants the indexer to walk works.
55
- # @param default_scope [Rigor::Scope] the scope used for the root,
62
+ # @param default_scope the scope used for the root,
56
63
  # and the fallback returned for any Prism node not contained in
57
64
  # `root`'s subtree.
58
- # @param converged_loop_recording [Boolean] display-path flag —
65
+ # @param converged_loop_recording display-path flag —
59
66
  # when true the evaluator re-records fixpoint-tracked loop
60
67
  # bodies from their CONVERGED bindings so per-line probes
61
68
  # (`rigor annotate`) reflect the post-writeback state, not the
62
69
  # cap-N intermediate constants. Off for the check path.
63
- # @return [Hash{Prism::Node => Rigor::Scope}] identity-comparing
70
+ # @return identity-comparing
64
71
  # table whose default value is `default_scope`.
72
+ # The no-op re-anchoring table (#722 residue 2): shared so the overwhelmingly common
73
+ # nothing-to-re-anchor walk allocates none.
74
+ EMPTY_RENAMES = {}.freeze
75
+
65
76
  def index(root, default_scope:, converged_loop_recording: false) # rubocop:disable Metrics/AbcSize
66
77
  # Slice A-declarations. Build the declaration overrides first so every scope handed to the StatementEvaluator
67
78
  # already carries the table; structural sharing through `Scope#with_local` / `#with_fact` / `#with_self_type`
68
79
  # propagates it across every derived scope.
69
- declared_types, discovered_classes = build_declaration_artifacts(root)
80
+ # Issue #722 residue 2 — the project's settled declaration names are the oracle the file's own
81
+ # compact headers are re-anchored against. `discovered_class_sources` would be the more direct
82
+ # reading, but it is seeded only under `--record-dependencies`; this table is the one every run
83
+ # carries, and the fold has already applied its own re-anchoring to it.
84
+ declared_types, discovered_classes =
85
+ build_declaration_artifacts(root, default_scope.discovered_classes.keys.to_set)
70
86
  # Merge the indexer's findings on top of whatever the base scope already carries so callers that seed cross-file
71
87
  # class knowledge (e.g. the ADR-14 `SigGen::ObservationCollector` pre-walking project `lib/` before scanning
72
88
  # `spec/`) keep their seeds alongside the per-file declarations the indexer itself discovers. Indexer-found
@@ -110,6 +126,7 @@ module Rigor
110
126
  build_in_source_constants(root, seeded_scope), literal_mutations[:constants]
111
127
  )
112
128
  seeded_scope = seed_constant_tables(seeded_scope, default_scope, in_source_constants, root)
129
+ seeded_scope = seed_published_constant_ivars(seeded_scope, root)
113
130
 
114
131
  # Slice 7 phase 12. In-source method discovery. Walks every class/module body for `Prism::DefNode` and
115
132
  # recognised `define_method` calls and records the introduced method names. `rigor check` consults the table to
@@ -193,7 +210,11 @@ module Rigor
193
210
  # #526 — this file's extends folded against the MERGED instance def-nodes (so `extend M` sees a
194
211
  # sibling-file M through the cross-file seed). The project-wide fold in {#finalize_def_index}
195
212
  # covers cross-file CONSUMERS; this covers the file that declares the extend.
196
- methods_table = fold_per_file_extends(root, def_nodes, singleton_def_nodes, seeded_scope)
213
+ #
214
+ # Issue #898 — and the same walk's table is now kept, merged over the cross-file seed the way
215
+ # `includes` is: `Narrowing` asks it what a class object's singleton ancestry holds.
216
+ file_extends, extends = merge_extend_tables(default_scope, root)
217
+ methods_table = fold_per_file_extends(file_extends, def_nodes, singleton_def_nodes, seeded_scope)
197
218
 
198
219
  seeded_scope.with_discovery(
199
220
  seeded_scope.discovery.with(
@@ -204,6 +225,7 @@ module Rigor
204
225
  discovered_superclasses: superclasses,
205
226
  discovered_header_nestings: header_nestings,
206
227
  discovered_includes: includes,
228
+ discovered_extends: extends,
207
229
  discovered_method_visibilities: method_visibilities,
208
230
  data_member_layouts: data_member_layouts,
209
231
  struct_member_layouts: struct_member_layouts
@@ -247,10 +269,20 @@ module Rigor
247
269
  merged.freeze
248
270
  end
249
271
 
272
+ # Issue #898 — one walk, two consumers: the raw per-file table the #526 method fold reads, and the
273
+ # same table merged over the cross-file seed for the scope. Returned as a pair so a caller cannot
274
+ # pair the fold with a table a different parse produced, as {#merge_ancestry_tables} is.
275
+ def merge_extend_tables(default_scope, root)
276
+ file_extends = build_discovered_extends(root)
277
+ merged = default_scope.discovered_extends.merge(
278
+ file_extends
279
+ ) { |_class, cross_file, per_file| (cross_file + per_file).uniq }
280
+ [file_extends, merged]
281
+ end
282
+
250
283
  # The per-file half of the #526 fold: mutable copies of the merged tables take the extends, and the
251
284
  # existence table (already seeded onto the scope) is rebuilt only when the fold touched it.
252
- def fold_per_file_extends(root, def_nodes, singleton_def_nodes, seeded_scope)
253
- extends = build_discovered_extends(root)
285
+ def fold_per_file_extends(extends, def_nodes, singleton_def_nodes, seeded_scope)
254
286
  methods_table = seeded_scope.discovered_methods
255
287
  return methods_table if extends.empty?
256
288
 
@@ -1476,7 +1508,7 @@ module Rigor
1476
1508
  # is a known in-place mutator (`MutationWidening`'s tables) or a plain attribute/index writer
1477
1509
  # (`name=` / `[]=`). Non-mutating reads never register, so `VERSION`-style constants keep their fold.
1478
1510
  #
1479
- # @return [Hash{Symbol => Object}] `:constants` — a Set of candidate qualified names (each mutation
1511
+ # @return `:constants` — a Set of candidate qualified names (each mutation
1480
1512
  # contributes every lexical-resolution candidate, `A::B::C` outward to bare `C`, mirroring how the
1481
1513
  # reads resolve); `:cvars` — `{class_name => Set[Symbol]}`.
1482
1514
  def collect_literal_receiver_mutations(root)
@@ -1522,19 +1554,35 @@ module Rigor
1522
1554
  when Prism::ConstantReadNode
1523
1555
  constant_mutation_candidates(receiver.name.to_s, nesting, census[:constants])
1524
1556
  when Prism::ConstantPathNode
1525
- # The lexical candidates a PATH spelling reaches, for the same reason the bare arm above takes
1526
- # them: `Holder::TABLE[:k] = 1` inside `module Admin` mutates whatever `Holder::TABLE` resolves
1527
- # to there, and since [#690](https://github.com/rigortype/rigor/issues/690) the write accumulator
1528
- # keys that entry `Admin::Holder::TABLE`. Recording only the as-written name left the mutated
1529
- # constant matched by nothing and its closed empty shape intact — the very fold #540 exists to
1530
- # retract. Over-recording only widens, which is the direction this census is allowed to err in.
1531
- full = Source::ConstantPath.qualified_name_or_nil(receiver)
1532
- constant_mutation_candidates(full, nesting, census[:constants]) if full
1557
+ path_mutation_candidates(receiver, nesting, census[:constants])
1533
1558
  when Prism::ClassVariableReadNode
1534
1559
  census[:cvars][qualified_prefix.join("::")] << receiver.name unless qualified_prefix.empty?
1535
1560
  end
1536
1561
  end
1537
1562
 
1563
+ # The lexical candidates a PATH spelling reaches, for the same reason the bare arm takes them:
1564
+ # `Holder::TABLE[:k] = 1` inside `module Admin` mutates whatever `Holder::TABLE` resolves to there,
1565
+ # and since [#690](https://github.com/rigortype/rigor/issues/690) the write accumulator keys that
1566
+ # entry `Admin::Holder::TABLE`. Recording only the as-written name left the mutated constant matched
1567
+ # by nothing and its closed empty shape intact — the very fold #540 exists to retract. Over-recording
1568
+ # only widens, which is the direction this census is allowed to err in.
1569
+ #
1570
+ # A ROOTED receiver is the one spelling that reaches no lexical candidate at all, so widening them is
1571
+ # not erring in the allowed direction but discarding precision the code hands over
1572
+ # ([#703](https://github.com/rigortype/rigor/issues/703)): `::Table::ROWS[k] = 1` names the top-level
1573
+ # constant unconditionally, so the sibling `Admin::Table::ROWS` a bare spelling would also reach is
1574
+ # untouched and keeps its empty-shape fold. This is the exemption #690 established on the WRITE side
1575
+ # ({#constant_path_write_key}), which the two arms have to agree on: the strict render drops the root
1576
+ # marker, so the rooted and unrooted spellings are indistinguishable by name alone.
1577
+ def path_mutation_candidates(receiver, nesting, into)
1578
+ full = Source::ConstantPath.qualified_name_or_nil(receiver)
1579
+ return if full.nil?
1580
+
1581
+ return into << full if Source::ConstantPath.rooted?(receiver)
1582
+
1583
+ constant_mutation_candidates(full, nesting, into)
1584
+ end
1585
+
1538
1586
  # The receiver a node mutates, or nil when the node is not a mutation.
1539
1587
  def mutating_receiver_of(node)
1540
1588
  case node
@@ -1605,29 +1653,117 @@ module Rigor
1605
1653
  # `Scope#published_constant?`: a project-published constant the analysed file also assigns is one its
1606
1654
  # author can see, so the truthiness rules keep firing on it. Both are seeded here, where the per-file
1607
1655
  # table is still separable from the project seed it is about to merge over.
1656
+ # Issue #667 — the alias table rides the same census and the same gate, because it answers the third
1657
+ # half of the same question: which of THIS file's own constant names carry a value the author never
1658
+ # saw. One census call now feeds both sets.
1608
1659
  def seed_constant_tables(seeded_scope, default_scope, in_source_constants, root)
1609
1660
  merged = merge_seeded_constants(default_scope.in_source_constants, in_source_constants)
1661
+ published = default_scope.published_constant_names
1662
+ census = published.empty? ? nil : constant_write_census(root)
1610
1663
  seeded_scope.with_discovery(
1611
- seeded_scope.discovery.with(in_source_constants: merged,
1612
- local_constant_names: local_constant_name_set(root, default_scope))
1664
+ seeded_scope.discovery.with(
1665
+ in_source_constants: merged,
1666
+ local_constant_names: local_constant_name_set(census, published),
1667
+ published_constant_alias_names: published_constant_alias_name_set(census, published)
1668
+ )
1613
1669
  )
1614
1670
  end
1615
1671
 
1616
1672
  # The QUALIFIED names this file assigns that the project also published, frozen. Read from the same
1617
- # {#constant_writes_for_file} census the cross-file table is built from, not from the typed per-file
1673
+ # {#constant_write_census} the cross-file table is built from, not from the typed per-file
1618
1674
  # table: the typed walk cannot see a multi-assign / `self::` / operator-write target, so deriving the
1619
1675
  # exemption from it would be a second source of truth blind to exactly the forms the census exists to
1620
1676
  # catch. Gated on the project having published anything at all, so a project with no cross-file value
1621
1677
  # constants pays no extra walk and allocates nothing; the result is filtered to the published names, so
1622
1678
  # what it holds is bounded by what the exemption can ever be asked about.
1623
- def local_constant_name_set(root, default_scope)
1624
- published = default_scope.published_constant_names
1625
- return Scope::DiscoveryIndex::EMPTY.local_constant_names if published.empty?
1626
-
1627
- names = constant_writes_for_file(root).keys.select { |name| published.include?(name.split("::").last) }
1679
+ #
1680
+ # It reads the census's `declared` half rather than its keys ([#710](https://github.com/rigortype/rigor/issues/710)).
1681
+ # The two consumers want opposite answers about a write whose base nothing names: the conflict rule asks
1682
+ # "could another file's value for this name be wrong?" — yes, so the name stays censused — and this
1683
+ # exemption asks "did this file declare it?" — no, `[Foo].each { |k| k::X = 1 }` declares `Foo::X`.
1684
+ # Granting it un-withheld `flow.always-truthy-condition` for a name the file never wrote.
1685
+ def local_constant_name_set(census, published)
1686
+ return Scope::DiscoveryIndex::EMPTY.local_constant_names if census.nil?
1687
+
1688
+ names = census.declared.select { |name| published.include?(name.split("::").last) }
1628
1689
  names.empty? ? Scope::DiscoveryIndex::EMPTY.local_constant_names : names.to_set.freeze
1629
1690
  end
1630
1691
 
1692
+ # Issue #667 — the last segments of the constants this file assigns straight from a constant the
1693
+ # project published and this file does NOT itself declare. `MODE2 = AppConfig::MODE` renames a foreign
1694
+ # declaration, so the local-declaration exemption releasing `MODE2` is the wrong answer: the author can
1695
+ # see the alias but not the value. The source's own foreignness is asked against the census's
1696
+ # `declared` half — the same suffix relation `Scope#published_constant?` uses — so a same-file
1697
+ # `MODE2 = MODE1` pair stays fully reportable.
1698
+ def published_constant_alias_name_set(census, published)
1699
+ return Scope::DiscoveryIndex::EMPTY.published_constant_alias_names if census.nil?
1700
+
1701
+ names = census.aliases.filter_map do |target, source|
1702
+ next unless published.include?(source.split("::").last)
1703
+ next if census_declares_constant?(census.declared, source)
1704
+
1705
+ target.split("::").last
1706
+ end
1707
+ names.empty? ? Scope::DiscoveryIndex::EMPTY.published_constant_alias_names : names.to_set.freeze
1708
+ end
1709
+
1710
+ def census_declares_constant?(declared, reference)
1711
+ return true if declared.include?(reference)
1712
+
1713
+ suffix = "::#{reference}"
1714
+ declared.any? { |name| name.end_with?(suffix) }
1715
+ end
1716
+
1717
+ # Issue #667 — `{class name => Set[ivar name]}` for an ivar whose class-ivar seed is a copy of a
1718
+ # foreign published constant (`@mode = AppConfig::MODE` in `initialize`). The cross-method half of the
1719
+ # copy provenance: the write and the `@mode == :production` that reads it sit in different method
1720
+ # bodies, so no flow edge connects them and the fact has to be censused per class, exactly as the
1721
+ # ADR-58 nil seed it rides beside is. Runs only when the project published something.
1722
+ def seed_published_constant_ivars(seeded_scope, root)
1723
+ return seeded_scope if seeded_scope.published_constant_names.empty?
1724
+
1725
+ table = {}
1726
+ walk_published_constant_ivars(root, [], seeded_scope, table)
1727
+ return seeded_scope if table.empty?
1728
+
1729
+ seeded_scope.with_discovery(
1730
+ seeded_scope.discovery.with(published_constant_ivars: table.transform_values(&:freeze).freeze)
1731
+ )
1732
+ end
1733
+
1734
+ def walk_published_constant_ivars(node, qualified_prefix, scope, table)
1735
+ return unless node.is_a?(Prism::Node)
1736
+
1737
+ case node
1738
+ when Prism::ClassNode, Prism::ModuleNode
1739
+ child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
1740
+ if child_prefix
1741
+ walk_published_constant_ivars(node.body, child_prefix, scope, table) if node.body
1742
+ return
1743
+ end
1744
+ when Prism::DefNode
1745
+ # A `def self.…` body writes the singleton's ivars, which take no instance seed at all.
1746
+ return unless node.receiver.nil?
1747
+ when Prism::InstanceVariableWriteNode
1748
+ record_published_constant_ivar(node, qualified_prefix, scope, table)
1749
+ end
1750
+
1751
+ node.rigor_each_child { |child| walk_published_constant_ivars(child, qualified_prefix, scope, table) }
1752
+ end
1753
+
1754
+ # The class-ivar accumulator (already seeded when this runs) is the pre-gate: an ivar whose seed is not
1755
+ # a `Type::Constant` can never fold a predicate, so there is nothing for the mark to withhold and the
1756
+ # guard's one interprocedural hop is not worth paying for it.
1757
+ def record_published_constant_ivar(node, qualified_prefix, scope, table)
1758
+ return if qualified_prefix.empty?
1759
+
1760
+ class_name = qualified_prefix.join("::")
1761
+ return unless scope.class_ivars_for(class_name)[node.name].is_a?(Type::Constant)
1762
+ return unless Analysis::CheckRules::PublishedConstantGuard.rooted?(node.value, scope)
1763
+
1764
+ (table[class_name] ||= Set.new) << node.name
1765
+ end
1766
+
1631
1767
  def merge_seeded_constants(seeded, per_file)
1632
1768
  return per_file if seeded.nil? || seeded.empty?
1633
1769
 
@@ -1707,19 +1843,40 @@ module Rigor
1707
1843
  # there is still the enclosing declaration's and every walk below this stays as it was).
1708
1844
  #
1709
1845
  # {OPAQUE_SELF} where the new `self` reaches no class or module name: a `class_eval` receiver that is
1710
- # not a static constant path, an {OPAQUE_SELF_BLOCK_CALLS} body, and the `Class.new { … }` /
1711
- # `Module.new` / `Struct.new` / `Data.define` block, whose class is named only by a constant write the
1712
- # walk would have to thread down to it. Declining there costs a resolution the engine never had; naming
1713
- # the LEXICAL enclosure instead is the guess that fires, because it is the one name Ruby is guaranteed
1714
- # NOT to have written.
1715
- def rebound_block_self(node, qualified_prefix, default_scope = nil)
1846
+ # not a static constant path, an {OPAQUE_SELF_BLOCK_CALLS} body, and a `Class.new { … }` /
1847
+ # `Module.new` / `Struct.new` / `Data.define` block no enclosing constant write names. Declining there
1848
+ # costs a resolution the engine never had; naming the LEXICAL enclosure instead is the guess that fires,
1849
+ # because it is the one name Ruby is guaranteed NOT to have written.
1850
+ #
1851
+ # `meta_owner` is the name such a write DOES give the block's class, threaded one hop down by the
1852
+ # caller ({#meta_new_block_owner}) ([#710](https://github.com/rigortype/rigor/issues/710)). It is
1853
+ # supplied only by the publication census; the typed walk returns at a `ConstantWriteNode` without
1854
+ # descending into its rvalue, so no `self::` write inside one reaches that walk at all.
1855
+ def rebound_block_self(node, qualified_prefix, default_scope = nil, meta_owner = nil)
1716
1856
  return nil unless node.is_a?(Prism::CallNode) && node.block.is_a?(Prism::BlockNode)
1717
- return OPAQUE_SELF if OPAQUE_SELF_BLOCK_CALLS.include?(node.name) || meta_new_constant_rvalue?(node)
1857
+ return OPAQUE_SELF if OPAQUE_SELF_BLOCK_CALLS.include?(node.name)
1858
+ return meta_owner || OPAQUE_SELF if meta_new_constant_rvalue?(node)
1718
1859
  return nil unless SELF_REBINDING_EVAL_CALLS.include?(node.name)
1719
1860
 
1720
1861
  eval_receiver_self(node.receiver, qualified_prefix, default_scope)
1721
1862
  end
1722
1863
 
1864
+ # Issue #710 — the qualified name a `Klass = Class.new { … }` gives the block's class, or nil when
1865
+ # `node` is not that form. Ruby names the constructed class after the constant it is first assigned to,
1866
+ # so `self::X = 7` in the block writes `Klass::X` and nothing about it is opaque. The recognition is
1867
+ # {#meta_new_block_body}'s, shared with the block-as-method walk.
1868
+ #
1869
+ # A constant PATH write (`N::Made = Class.new { … }`) keeps the opaque answer even though that recognition
1870
+ # now takes it ([#703](https://github.com/rigortype/rigor/issues/703)). This census keys a path write AS
1871
+ # WRITTEN ({#constant_path_write_name}) rather than through the nesting, so naming the block's class here
1872
+ # would publish a `self::X = …` inside it under a name Ruby does not give it; declining suppresses the
1873
+ # name instead, which is the gradual direction. Moving it belongs with moving the census's own path key.
1874
+ def meta_new_block_owner(node, qualified_prefix)
1875
+ return nil unless node.is_a?(Prism::ConstantWriteNode) && meta_new_block_body(node)
1876
+
1877
+ qualified_write_name(qualified_prefix, node.name.to_s)
1878
+ end
1879
+
1723
1880
  # The `self` a `Recv.class_eval { … }` block runs under. An implicit or literal `self` receiver leaves
1724
1881
  # `self` where it was — nil, so nothing about the walk changes. A static constant path resolves the way
1725
1882
  # a path write's own namespace resolves ({#resolved_write_namespace}); anything else is opaque. A nil
@@ -1944,7 +2101,7 @@ module Rigor
1944
2101
  end
1945
2102
  end
1946
2103
 
1947
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize
2104
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
1948
2105
  # Combined `walk_methods` + `walk_def_nodes` descent. The two walks had identical class / module / singleton-class
1949
2106
  # / meta-block traversals and both stopped at `DefNode`; the only divergences are leaf actions (recorded into the
1950
2107
  # right accumulator) and the original `walk_methods` returning at `AliasMethodNode` (its symbol-only children
@@ -1972,9 +2129,9 @@ module Rigor
1972
2129
  return
1973
2130
  end
1974
2131
  end
1975
- when Prism::ConstantWriteNode
1976
- if meta_new_block_body(node)
1977
- child_prefix = qualified_prefix + [node.name.to_s]
2132
+ when Prism::ConstantWriteNode, Prism::ConstantPathWriteNode
2133
+ child_prefix = meta_new_body_prefix(node, qualified_prefix)
2134
+ if child_prefix
1978
2135
  record_meta_members(node.value, child_prefix, methods_acc)
1979
2136
  walk_methods_and_def_nodes(meta_new_block_body(node), child_prefix, false, methods_acc, def_nodes_acc,
1980
2137
  source_path)
@@ -2087,7 +2244,7 @@ module Rigor
2087
2244
  Source::ConstantPath.qualified_name(expression.target)
2088
2245
  end
2089
2246
  end
2090
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize
2247
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
2091
2248
 
2092
2249
  # v0.1.2 — when a `Const = Data.define(*sym) do ... end` / `Const = Struct.new(*sym) do ... end` constant write
2093
2250
  # carries a block, the block body holds method overrides whose canonical class is `Const`. Survey item (e)
@@ -2095,8 +2252,15 @@ module Rigor
2095
2252
  # ADR-16 Tier A "block-as-method" idiom at constant-write position. Returns the block body node (a
2096
2253
  # `Prism::StatementsNode`) when the rvalue matches; nil otherwise. Used by `walk_methods` / `walk_def_nodes` to
2097
2254
  # push `Const` onto the qualified prefix before recursing.
2255
+ #
2256
+ # Issue [#703](https://github.com/rigortype/rigor/issues/703) — a PATH write is the same idiom.
2257
+ # `Holder::Thing = Struct.new(:a) do … end` inside a module is ordinary Ruby, and declining it here lost the
2258
+ # factory registration, the member layout and the block's own defs together: the constant answered
2259
+ # `singleton(Struct)` and its instance `Struct`, a class RBS knows, so every member read and every override
2260
+ # call fired `call.undefined-method` on code Ruby runs. The name the write gives the class is
2261
+ # {#meta_new_child_prefix}'s, so every walk that pushes it agrees.
2098
2262
  def meta_new_block_body(node)
2099
- return nil unless node.is_a?(Prism::ConstantWriteNode)
2263
+ return nil unless node.is_a?(Prism::ConstantWriteNode) || node.is_a?(Prism::ConstantPathWriteNode)
2100
2264
 
2101
2265
  rvalue = node.value
2102
2266
  return nil unless rvalue.is_a?(Prism::CallNode)
@@ -2108,6 +2272,36 @@ module Rigor
2108
2272
  rvalue.block&.body
2109
2273
  end
2110
2274
 
2275
+ # The qualified prefix a meta-new constant write names its class under, given the lexical prefix the write
2276
+ # sits in — the segments every scope-free pre-pass pushes before descending into the block, and the key
2277
+ # {#record_meta_new_constant?} files the discovered class under. Nil for a target that is neither form.
2278
+ #
2279
+ # A PATH write takes exactly what a compact `class Holder::Thing` header at the same position takes
2280
+ # ({Source::ConstantPath.declaration_prefix}), rooted reset included. These pre-passes run before any scope
2281
+ # exists, so the namespace cannot be resolved the way {#constant_path_write_key} resolves it for the typed
2282
+ # table — but the two land on the same name anyway, because registering `Admin::Holder::Thing` here is what
2283
+ # makes `Admin::Holder` a namespace {#resolved_write_namespace} knows.
2284
+ #
2285
+ # Where the namespace is NOT under the enclosure — a top-level `Loner` written as `Loner::Made = Struct.new(…)`
2286
+ # inside `module Admin` — that qualification is a guess, and {#synthesize_namespace_prefixes} then answers a
2287
+ # read of `Loner` there with the `Admin::Loner` it invented. That is the same answer the equivalent
2288
+ # `class Loner::Made` header has always produced, from the same two functions: the two spellings of one
2289
+ # declaration stay consistent rather than one of them carrying a second approximation of its own.
2290
+ def meta_new_child_prefix(node, qualified_prefix)
2291
+ case node
2292
+ when Prism::ConstantWriteNode
2293
+ qualified_prefix + [node.name.to_s]
2294
+ when Prism::ConstantPathWriteNode
2295
+ Source::ConstantPath.declaration_prefix(qualified_prefix, node.target)
2296
+ end
2297
+ end
2298
+
2299
+ # {#meta_new_child_prefix} for the walks that only care about a write carrying a BLOCK — nil where the rvalue
2300
+ # opens none, so a block-less `Thing = Struct.new(:a)` keeps falling through to the ordinary child descent.
2301
+ def meta_new_body_prefix(node, qualified_prefix)
2302
+ meta_new_block_body(node) && meta_new_child_prefix(node, qualified_prefix)
2303
+ end
2304
+
2111
2305
  # `class Foo < Data.define(:a, :b)` / `class Bar < Struct.new(:x)` synthesizes reader methods (`a`, `b`, `x`) on
2112
2306
  # the subclass that no `def` / `attr_*` declares. Register them in the discovered-methods existence table so an
2113
2307
  # implicit-self read of a member inside the class body is known to exist — both for the existing undefined-method
@@ -2283,9 +2477,9 @@ module Rigor
2283
2477
  return
2284
2478
  end
2285
2479
  end
2286
- when Prism::ConstantWriteNode
2287
- if meta_new_block_body(node)
2288
- child_prefix = qualified_prefix + [node.name.to_s]
2480
+ when Prism::ConstantWriteNode, Prism::ConstantPathWriteNode
2481
+ child_prefix = meta_new_body_prefix(node, qualified_prefix)
2482
+ if child_prefix
2289
2483
  walk_singleton_body(meta_new_block_body(node), child_prefix, false, accumulator)
2290
2484
  return
2291
2485
  end
@@ -2450,7 +2644,7 @@ module Rigor
2450
2644
  # the enclosing cref — `[]` for the first spelling, `["Admin"]` for the second. A class reopened under two
2451
2645
  # spellings keeps the LAST one walked, matching how `superclasses` itself merges.
2452
2646
  #
2453
- # @return [Array(Hash{String=>String}, Hash{String=>Array[String]})] `[superclasses, header_nestings]`
2647
+ # @return })] `[superclasses, header_nestings]`
2454
2648
  def build_superclass_tables(root, source_path = nil)
2455
2649
  accumulator = { superclasses: {}, header_nestings: {} }
2456
2650
  walk_class_superclasses(root, [], accumulator, source_path)
@@ -2489,7 +2683,8 @@ module Rigor
2489
2683
  # prefix without resetting Ruby's nesting ([#708](https://github.com/rigortype/rigor/issues/708)).
2490
2684
  def record_declaration_ancestry(node, nesting, child_prefix, accumulator)
2491
2685
  full = child_prefix.join("::")
2492
- add_header_nesting(accumulator[:header_nestings], full, nesting) if declares_ancestor_name?(node)
2686
+ names = declared_ancestor_names(node)
2687
+ add_header_nesting(accumulator[:header_nestings], full, nesting, names) if names
2493
2688
  return unless node.is_a?(Prism::ClassNode)
2494
2689
 
2495
2690
  accumulator[:superclasses][full] = recorded_ancestor_name(node.superclass) if node.superclass
@@ -2513,8 +2708,10 @@ module Rigor
2513
2708
  "::#{name}"
2514
2709
  end
2515
2710
 
2516
- # Whether this declaration SITE writes an ancestor name a superclass in its header, or a mixin call
2517
- # in its own body. Only such a site contributes a header nesting.
2711
+ # The ancestor names this declaration SITE writes — the superclass in its header, and every mixin
2712
+ # argument in its own body — or nil when the site writes none. Only a site that writes one
2713
+ # contributes a header nesting, and the names are what key it: Ruby resolves each ancestor name in
2714
+ # the cref of the site that WROTE it, and a class's sites need not agree on that cref.
2518
2715
  #
2519
2716
  # Issue #708's review: a per-CLASS union of every site's chain is wrong for a rooted reopen inside
2520
2717
  # another namespace. `class ::Foo; def extra = 1; end` written in `module Outer` contributed `["Outer"]`
@@ -2522,71 +2719,128 @@ module Rigor
2522
2719
  # Ruby never looks at, ahead of the right one, on a site that named no ancestor at all. A site that
2523
2720
  # writes no ancestor name has no ancestor for its cref to govern, so it has nothing to say here.
2524
2721
  #
2722
+ # Issue #728: dropping those sites was necessary and not sufficient. When the rooted reopen genuinely
2723
+ # names a mixin — `class ::Foo; include Helper; end` in `module Outer` — the site is recorded, and a
2724
+ # per-class union then hands its `["Outer"]` to `Foo`'s superclass `Base` as well, which is the same
2725
+ # `Outer::Base` on the same repro. Keying by the written name is what separates them: `Base` resolves
2726
+ # in the empty chain of the top-level site that wrote `< Base`, `Helper` in the `["Outer"]` chain of
2727
+ # the site that wrote `include Helper`, which is what Ruby does for each independently.
2728
+ #
2525
2729
  # This keeps what the union was FOR. It was added because last-writer-wins let one rails TEST file
2526
2730
  # reopening `ActiveRecord::Relation` compactly beat the library declaration, costing the class all nine
2527
- # of its `include`d modules; that reopen writes no ancestor name either, so it now contributes nothing
2731
+ # of its `include`d modules; that reopen writes no ancestor name either, so it contributes nothing
2528
2732
  # rather than winning — the protection comes from the recording rule instead of from a merge rule.
2529
- # Two sites that BOTH write ancestor names still union, which is the conservative reading of an
2530
- # ambiguity one table cannot represent.
2531
2733
  #
2532
2734
  # The mixin scan counts only a call whose `self` is the declaration itself, so it stops at every
2533
2735
  # construct that rebinds `self`: a nested `class` / `module` (that body's calls belong to the nested
2534
2736
  # class), a `class << self` body, and a block `#rebound_block_self` classifies as rebinding — the
2535
2737
  # `Class.new` / `Module.new` / `Struct.new` / `Data.define` and `class_eval` family. An `include`
2536
2738
  # written in any of those attaches to something other than this class, so treating it as this site's
2537
- # ancestor name reinstates exactly the defect above with a different spelling.
2739
+ # ancestor name reinstates exactly the defect above with a different spelling. {#walk_class_includes}
2740
+ # classifies the same shapes the same way since [#749](https://github.com/rigortype/rigor/pull/749),
2741
+ # so the two walks agree on which site owns a mixin.
2538
2742
  #
2539
- # It stays structural rather than exhaustive: `self.include M` and `send(:include, M)` record nothing
2540
- # and fall back to the peel, which is this walk's pre-#682 answer and never a new firing — and the
2541
- # includes walk misses those two as well, so the class's ancestry agrees. For the REBINDING shapes it
2542
- # does not: `walk_class_includes` still charges an `include` written in a `class << self` body or a
2543
- # `Class.new` block to the enclosing class, where Ruby attaches it elsewhere. This scan is right and
2544
- # that one is wrong; the divergence is [#728](https://github.com/rigortype/rigor/issues/728).
2545
- def declares_ancestor_name?(node)
2546
- return true if node.is_a?(Prism::ClassNode) && node.superclass
2547
-
2743
+ # It stays structural rather than exhaustive: `self.include M` and `send(:include, M)` are recorded by
2744
+ # neither walk, so the class's ancestry agrees. `Recv.class_eval { include M }` is the one shape where
2745
+ # they diverge in the surviving direction the includes walk names `Recv`, this one cannot see the
2746
+ # site from `Recv`'s own declaration and that is what {#add_header_nesting}'s unkeyed entry answers.
2747
+ def declared_ancestor_names(node)
2748
+ names = []
2749
+ superclass = node.is_a?(Prism::ClassNode) ? node.superclass : nil
2750
+ if superclass
2751
+ recorded = recorded_ancestor_name(superclass)
2752
+ names << recorded if recorded
2753
+ end
2548
2754
  body = node.body
2549
- body ? mixin_call_in_body?(body) : false
2755
+ mixin = body ? collect_mixin_names(body, names) : false
2756
+ return names if superclass || mixin
2757
+
2758
+ nil
2550
2759
  end
2551
2760
 
2552
- def mixin_call_in_body?(node)
2761
+ # Appends each mixin argument this body names to `names`, and answers whether it writes a mixin call
2762
+ # at all — a `include some_method` writes one while naming nothing renderable, and the site still owns
2763
+ # a chain for the names it does write. Arguments are rendered exactly as {#record_mixin_call} renders
2764
+ # them, so a name recorded here is the same String the includes table stores and the resolver asks for.
2765
+ def collect_mixin_names(node, names)
2553
2766
  return false unless node.is_a?(Prism::Node)
2554
2767
  return false if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode) ||
2555
2768
  node.is_a?(Prism::SingletonClassNode)
2556
- return true if node.is_a?(Prism::CallNode) && node.receiver.nil? && MIXIN_CALL_NAMES.include?(node.name)
2557
2769
 
2770
+ found = mixin_call?(node)
2771
+ record_mixin_names(node, names) if found
2558
2772
  rebinds = rebound_block_self(node, EMPTY_PREFIX)
2559
2773
  node.rigor_each_child do |child|
2560
2774
  next if rebinds && child.is_a?(Prism::BlockNode)
2561
2775
 
2562
- return true if mixin_call_in_body?(child)
2776
+ found = true if collect_mixin_names(child, names)
2563
2777
  end
2564
- false
2778
+ found
2565
2779
  end
2566
2780
 
2567
- # Adds one declaration site's header nesting to the per-class table, UNIONING it with what a
2568
- # previous site recorded, most-qualified first. A class reopened under two spellings has two header
2569
- # nestings while the ancestor names its sites write collapse into ONE table, so taking either site's
2570
- # chain alone drops a candidate the other one had — and dropping a candidate drops an ancestor edge,
2571
- # the false-positive direction. Rails is the corpus case: `activerecord` declares
2572
- # `ActiveRecord::Relation` inside `module ActiveRecord` and one test file reopens it as the compact
2573
- # `class ActiveRecord::Relation`, so last-writer-wins cost the library class nine included modules.
2574
- # The union is the conservative reading of an ambiguity the per-class table cannot represent: it
2575
- # degrades such a class to the pre-fix candidate list rather than to a shorter one.
2576
- def add_header_nesting(table, name, entries)
2577
- existing = table[name]
2578
- return table[name] = entries.freeze if existing.nil?
2579
- return if entries.all? { |entry| existing.include?(entry) }
2781
+ def mixin_call?(node)
2782
+ node.is_a?(Prism::CallNode) && node.receiver.nil? && MIXIN_CALL_NAMES.include?(node.name)
2783
+ end
2580
2784
 
2581
- table[name] = (existing | entries).sort_by { |entry| [-entry.split("::").size, entry] }.freeze
2785
+ def record_mixin_names(node, names)
2786
+ node.arguments&.arguments&.each do |arg|
2787
+ mod = Source::ConstantPath.qualified_name(arg)
2788
+ names << mod if mod
2789
+ end
2790
+ end
2791
+
2792
+ # Adds one declaration site's header nesting to the per-class table, under each ancestor NAME the site
2793
+ # wrote plus one unkeyed entry.
2794
+ #
2795
+ # Issue #728 — the keyed entries are the answer proper: `Foo`'s superclass `Base` and the `Helper` a
2796
+ # rooted reopen of `Foo` includes are resolved in different crefs by Ruby, and a per-class chain can
2797
+ # only give them the same one. Two sites that write the SAME name still union, most-qualified first,
2798
+ # since one table cannot say which of two spellings a later reader meant.
2799
+ #
2800
+ # The unkeyed entry ({Scope::DiscoveryIndex::UNKEYED_HEADER_NESTING}) is that union over every
2801
+ # ancestor-naming site, and it
2802
+ # answers an ancestor name no site recorded under its own key — a mixin the includes walk attributes
2803
+ # to a class from OUTSIDE its declaration (`Recv.class_eval { include M }`), or a dynamic `include`
2804
+ # argument this walk cannot render. It is the pre-#728 answer, so such a name is unchanged rather than
2805
+ # degraded, and a shorter list there would drop an ancestor edge, the false-positive direction.
2806
+ def add_header_nesting(table, name, entries, ancestor_names)
2807
+ bucket = { Scope::DiscoveryIndex::UNKEYED_HEADER_NESTING => entries }
2808
+ ancestor_names.each { |raw| bucket[raw] = entries }
2809
+ merge_header_nesting_bucket(table, name, bucket)
2582
2810
  end
2583
2811
 
2584
2812
  # {#add_header_nesting} over a whole table, for the per-file and cross-file merges.
2585
2813
  def merge_header_nestings(target, incoming)
2586
- incoming.each { |name, entries| add_header_nesting(target, name, entries) }
2814
+ incoming.each { |name, bucket| merge_header_nesting_bucket(target, name, bucket) }
2587
2815
  target
2588
2816
  end
2589
2817
 
2818
+ # Merges one class's bucket into the table WITHOUT mutating what is already there: a per-file table is
2819
+ # merged into a shallow `dup` of the cross-file seed, whose buckets the seed still owns.
2820
+ def merge_header_nesting_bucket(table, name, incoming)
2821
+ existing = table[name]
2822
+ return table[name] = frozen_bucket(incoming) if existing.nil?
2823
+
2824
+ merged = existing.dup
2825
+ incoming.each do |raw, entries|
2826
+ previous = merged[raw]
2827
+ merged[raw] = previous.nil? ? entries.freeze : union_header_nesting(previous, entries)
2828
+ end
2829
+ table[name] = merged.freeze
2830
+ end
2831
+
2832
+ def frozen_bucket(bucket)
2833
+ return bucket if bucket.frozen?
2834
+
2835
+ bucket.each_value(&:freeze).freeze
2836
+ end
2837
+
2838
+ def union_header_nesting(existing, entries)
2839
+ return existing if entries.all? { |entry| existing.include?(entry) }
2840
+
2841
+ (existing | entries).sort_by { |entry| [-entry.split("::").size, entry] }.freeze
2842
+ end
2843
+
2590
2844
  # #319 — `Class.new(Parent) do ... end` names its superclass in the first positional. Recording it under the
2591
2845
  # call site's anonymous name keeps `Parent`'s surface reachable from the block body (whose `self_type` is now
2592
2846
  # `Singleton[<anonymous>]`) and from an instance of the resulting class, so giving the anonymous class an
@@ -2639,8 +2893,9 @@ module Rigor
2639
2893
  walk_data_member_layouts(node.body, child_prefix, accumulator) if node.body
2640
2894
  return
2641
2895
  end
2642
- when Prism::ConstantWriteNode
2643
- record_data_member_layout(accumulator, qualified_prefix + [node.name.to_s], node.value)
2896
+ when Prism::ConstantWriteNode, Prism::ConstantPathWriteNode
2897
+ child_prefix = meta_new_child_prefix(node, qualified_prefix)
2898
+ record_data_member_layout(accumulator, child_prefix, node.value) if child_prefix
2644
2899
  end
2645
2900
 
2646
2901
  node.rigor_each_child do |child|
@@ -2689,8 +2944,9 @@ module Rigor
2689
2944
  walk_struct_member_layouts(node.body, child_prefix, accumulator) if node.body
2690
2945
  return
2691
2946
  end
2692
- when Prism::ConstantWriteNode
2693
- record_struct_member_layout(accumulator, qualified_prefix + [node.name.to_s], node.value)
2947
+ when Prism::ConstantWriteNode, Prism::ConstantPathWriteNode
2948
+ child_prefix = meta_new_child_prefix(node, qualified_prefix)
2949
+ record_struct_member_layout(accumulator, child_prefix, node.value) if child_prefix
2694
2950
  end
2695
2951
 
2696
2952
  node.rigor_each_child do |child|
@@ -2755,7 +3011,8 @@ module Rigor
2755
3011
  # Issue #728 — `class << self; include M; end` mixes M into the SINGLETON: it contributes class
2756
3012
  # methods, not the instance surface this table feeds. Descending with no owner keeps any nested
2757
3013
  # declaration walked (a `ClassNode` child sets its own owner) while the mixin calls in the
2758
- # singleton body record nothing, which is what `extends` is for.
3014
+ # singleton body record nothing, which is what `extends` is for — and since #915 the extend walk
3015
+ # does take them, so the form is recorded rather than dropped.
2759
3016
  current_class = nil
2760
3017
  when Prism::CallNode
2761
3018
  record_mixin_call(node, current_class, accumulator)
@@ -2808,13 +3065,16 @@ module Rigor
2808
3065
  # order-sensitivity (only SUBSEQUENT defs become module functions) is deliberately over-approximated
2809
3066
  # — the extra names only suppress `undefined-method` and enable inference on calls that raise at
2810
3067
  # runtime, both the ADR-5-safe direction.
3068
+ #
3069
+ # Issue #915 — plus `class << self; include M; end`, the same singleton ancestor spelled through the
3070
+ # singleton-class body. It folds like an `extend` because Ruby makes it one.
2811
3071
  def build_discovered_extends(root)
2812
3072
  accumulator = {}
2813
3073
  walk_class_extends(root, [], nil, accumulator)
2814
3074
  accumulator.transform_values { |mods| mods.uniq.freeze }.freeze
2815
3075
  end
2816
3076
 
2817
- def walk_class_extends(node, qualified_prefix, current_class, accumulator)
3077
+ def walk_class_extends(node, qualified_prefix, current_class, accumulator, in_singleton: false)
2818
3078
  return unless node.is_a?(Prism::Node)
2819
3079
 
2820
3080
  case node
@@ -2825,18 +3085,35 @@ module Rigor
2825
3085
  walk_class_extends(node.body, child_prefix, full, accumulator) if node.body
2826
3086
  return
2827
3087
  end
3088
+ when Prism::SingletonClassNode
3089
+ # Issue #915 — `class << self` opens the enclosing declaration's OWN singleton, so an `include`
3090
+ # written in it is the same singleton ancestor an `extend` in the class body would add. Only the
3091
+ # `self` form is followed: `class << obj` names something this walk cannot resolve to a class.
3092
+ # Nested declarations inside the body take the `ClassNode` branch above and reset the flag with
3093
+ # their own owner, so the marker cannot leak past the singleton body it belongs to.
3094
+ in_singleton = node.expression.is_a?(Prism::SelfNode)
3095
+ current_class = nil unless in_singleton
2828
3096
  when Prism::CallNode
2829
- record_extend_call(node, current_class, accumulator)
3097
+ record_extend_call(node, current_class, accumulator, in_singleton: in_singleton)
2830
3098
  end
2831
3099
 
2832
3100
  node.rigor_each_child do |child|
2833
- walk_class_extends(child, qualified_prefix, current_class, accumulator)
3101
+ walk_class_extends(child, qualified_prefix, current_class, accumulator, in_singleton: in_singleton)
2834
3102
  end
2835
3103
  end
2836
3104
 
2837
- def record_extend_call(node, current_class, accumulator)
3105
+ # Inside a `class << self` body the mixin calls are the singleton-side ones and `extend` is not:
3106
+ # `class << self; extend M; end` puts M on the singleton's OWN singleton, one level further out than
3107
+ # anything this table describes, and `module_function` in a singleton body is not the scope toggle
3108
+ # {#build_discovered_extends} over-approximates. So the two arms are disjoint rather than additive.
3109
+ def record_extend_call(node, current_class, accumulator, in_singleton: false)
2838
3110
  return unless current_class && node.receiver.nil?
2839
3111
 
3112
+ if in_singleton
3113
+ record_extend_targets(node, current_class, accumulator) if MIXIN_CALL_NAMES.include?(node.name)
3114
+ return
3115
+ end
3116
+
2840
3117
  case node.name
2841
3118
  when :extend then record_extend_targets(node, current_class, accumulator)
2842
3119
  when :module_function
@@ -2918,9 +3195,9 @@ module Rigor
2918
3195
  return current_visibility
2919
3196
  end
2920
3197
  end
2921
- when Prism::ConstantWriteNode
2922
- if meta_new_block_body(node)
2923
- child_prefix = qualified_prefix + [node.name.to_s]
3198
+ when Prism::ConstantWriteNode, Prism::ConstantPathWriteNode
3199
+ child_prefix = meta_new_body_prefix(node, qualified_prefix)
3200
+ if child_prefix
2924
3201
  walk_method_visibilities(meta_new_block_body(node), child_prefix, false, :public, accumulator)
2925
3202
  return current_visibility
2926
3203
  end
@@ -3190,9 +3467,7 @@ module Rigor
3190
3467
  # `Singleton[Object]` fallback anyway. The residual leak is `Class`-only (`M.new`, `M.superclass`), which mistypes
3191
3468
  # only code that raises `NoMethodError` at runtime.
3192
3469
  #
3193
- # @param paths [Array<String>] project file paths.
3194
- # @param buffer [Rigor::Analysis::BufferBinding, nil]
3195
- # @return [Hash{String => Rigor::Type::Singleton}]
3470
+ # @param paths project file paths.
3196
3471
  def discovered_classes_for_paths(paths, buffer: nil)
3197
3472
  accumulator = {}
3198
3473
  paths.each do |path|
@@ -3220,9 +3495,8 @@ module Rigor
3220
3495
  # monkey-patch on a core/stdlib/gem class is called cross-file (ADR-17). First write wins, matching `def_nodes`'
3221
3496
  # own merge order.
3222
3497
  #
3223
- # @param paths [Array<String>] project file paths.
3224
- # @param buffer [Rigor::Analysis::BufferBinding, nil]
3225
- # @return [Hash{Symbol => Hash}]
3498
+ # @param paths project file paths.
3499
+ # @return
3226
3500
  # `{ def_nodes:, def_sources:, superclasses:, includes:, class_sources: }`
3227
3501
  def discovered_def_index_for_paths(paths, buffer: nil)
3228
3502
  acc = new_def_index_accumulator
@@ -3246,7 +3520,7 @@ module Rigor
3246
3520
  # all of them (recon §2 dedup). A per-file live index (built + folded, exactly as
3247
3521
  # {#discovered_project_index_incremental}'s changed-file branch) yields the live def nodes the signature
3248
3522
  # reads their parameter structure from.
3249
- # @return [Hash{Symbol => Object}] `{ def_index:, code_fingerprints:, declaration_signatures: }`.
3523
+ # @return `{ def_index:, code_fingerprints:, declaration_signatures: }`.
3250
3524
  def scan_summary_for_paths(paths, buffer: nil)
3251
3525
  acc = new_def_index_accumulator
3252
3526
  code_fingerprints = {}
@@ -3436,9 +3710,8 @@ module Rigor
3436
3710
  # rescue's real target) contributes nothing to either table. The subset-scoped callers
3437
3711
  # ({IncrementalSession}, `coverage --protection`) keep calling the individual methods unchanged.
3438
3712
  #
3439
- # @param paths [Array<String>] project file paths.
3440
- # @param buffer [Rigor::Analysis::BufferBinding, nil]
3441
- # @return [Hash{Symbol => Object}] `{ classes: Hash, def_index: Hash }`.
3713
+ # @param paths project file paths.
3714
+ # @return `{ classes: Hash, def_index: Hash }`.
3442
3715
  def discovered_project_index_for_paths(paths, buffer: nil)
3443
3716
  classes = {}
3444
3717
  acc = new_def_index_accumulator
@@ -3451,7 +3724,28 @@ module Rigor
3451
3724
  # Skip files that fail to parse or read; the per-file analyzer surfaces the parse error separately.
3452
3725
  next
3453
3726
  end
3454
- { classes: classes.freeze, def_index: finalize_def_index(acc) }
3727
+ finalize_project_index(classes, acc)
3728
+ end
3729
+
3730
+ # The shared tail of the two whole-project discovery walks: settles the def-index and re-anchors the
3731
+ # `classes` table it accumulated beside it.
3732
+ def finalize_project_index(classes, acc)
3733
+ def_index = finalize_def_index(acc)
3734
+ { classes: rename_compact_classes(classes, def_index[:compact_header_renames]).freeze,
3735
+ def_index: def_index }
3736
+ end
3737
+
3738
+ # Issue #722 residue 2 — the `classes` table is accumulated beside the def-index rather than inside it,
3739
+ # so it is re-anchored here from the renames the fold settled. The value is rebuilt, not moved: it is a
3740
+ # `Singleton[…]` of the key, and a moved key with the old singleton inside would type `Outer::Leaf` as
3741
+ # `Wrap::Outer::Leaf` at every reference.
3742
+ def rename_compact_classes(classes, renames)
3743
+ return classes if renames.nil? || renames.empty?
3744
+
3745
+ classes.each_with_object({}) do |(name, type), out|
3746
+ renamed = rename_compact_name(renames, name)
3747
+ out[renamed] = renamed == name ? type : Type::Combinator.singleton_of(renamed)
3748
+ end
3455
3749
  end
3456
3750
 
3457
3751
  # ADR-85 WD2 — the incremental cross-file discovery pass. The bundle-driven twin of
@@ -3468,10 +3762,9 @@ module Rigor
3468
3762
  # lazily. On a cold run (`seed_bundles` empty) every file is re-walked, so the index is entirely live —
3469
3763
  # identical to {#discovered_project_index_for_paths} — while the bundles are built for the next run.
3470
3764
  #
3471
- # @param paths [Array<String>] project file paths, in canonical order.
3472
- # @param seed_bundles [Hash{String => Hash}] the prior run's per-file bundles, keyed by logical path.
3473
- # @param buffer [Rigor::Analysis::BufferBinding, nil]
3474
- # @return [Hash{Symbol => Object}] `{ classes:, def_index:, bundles: }`.
3765
+ # @param paths project file paths, in canonical order.
3766
+ # @param seed_bundles the prior run's per-file bundles, keyed by logical path.
3767
+ # @return `{ classes:, def_index:, bundles: }`.
3475
3768
  def discovered_project_index_incremental(paths, seed_bundles:, buffer: nil)
3476
3769
  classes = {}
3477
3770
  acc = new_def_index_accumulator
@@ -3500,7 +3793,7 @@ module Rigor
3500
3793
  # Skip files that fail to parse / read; the per-file analyzer surfaces the parse error separately.
3501
3794
  next
3502
3795
  end
3503
- { classes: classes.freeze, def_index: finalize_def_index(acc), bundles: bundles }
3796
+ finalize_project_index(classes, acc).merge(bundles: bundles)
3504
3797
  end
3505
3798
 
3506
3799
  # Builds ONE file's isolated def-index contribution (live `Prism::DefNode`s) by folding it into a fresh
@@ -3567,6 +3860,9 @@ module Rigor
3567
3860
  accumulate_module_lists(acc[:includes], file_index[:includes])
3568
3861
  accumulate_module_lists(acc[:extends], file_index[:extends] || {})
3569
3862
  file_index[:class_sources].each { |cn, files| (acc[:class_sources][cn] ||= Set.new).merge(files) }
3863
+ # Issue #722 residue 2 — a pre-#722 seed bundle carries no candidates; the SCHEMA bump makes such a
3864
+ # blob a cold rebuild, but default so any in-flight fold stays total.
3865
+ acc[:compact_headers].merge!(file_index[:compact_headers] || {})
3570
3866
  acc[:data_member_layouts].merge!(file_index[:data_member_layouts])
3571
3867
  acc[:struct_member_layouts].merge!(file_index[:struct_member_layouts])
3572
3868
  end
@@ -3605,6 +3901,9 @@ module Rigor
3605
3901
  method_visibilities: file_index[:method_visibilities],
3606
3902
  methods: file_index[:methods],
3607
3903
  class_source_names: file_index[:class_sources].keys,
3904
+ # Issue #722 residue 2 — plain data, so the bundle stays Marshal-clean and a warm incremental file
3905
+ # re-anchors its compact headers the way a cold walk of it does.
3906
+ compact_headers: file_index[:compact_headers],
3608
3907
  # Issue #644 — the file's publication census (`name => [literal] | :unpublishable`; the path is
3609
3908
  # the bundle key). Plain data, so the bundle stays Marshal-clean.
3610
3909
  constant_writes: file_index[:constant_writes].transform_values { |by_path| by_path.values.first },
@@ -3632,6 +3931,7 @@ module Rigor
3632
3931
  method_visibilities: bundle[:method_visibilities],
3633
3932
  methods: bundle[:methods],
3634
3933
  class_sources: bundle[:class_source_names].to_h { |name| [name, Set[path]] },
3934
+ compact_headers: bundle[:compact_headers] || {},
3635
3935
  # Issue #644 — a pre-#644 bundle carries no census; the SCHEMA bump makes such a blob a cold
3636
3936
  # rebuild, but default so any in-flight fold stays total.
3637
3937
  constant_writes: (bundle[:constant_writes] || {}).transform_values { |descriptor| { path => descriptor } },
@@ -3669,12 +3969,19 @@ module Rigor
3669
3969
  singleton_def_nodes: {}, def_sources: {}, singleton_def_sources: {},
3670
3970
  superclasses: {}, header_nestings: {}, includes: {}, extends: {}, method_visibilities: {}, methods: {},
3671
3971
  class_sources: {},
3972
+ # Issue #722 residue 2 — compact-header re-anchor candidates, adjudicated in {#finalize_def_index}.
3973
+ compact_headers: {},
3672
3974
  constant_writes: {},
3673
3975
  data_member_layouts: {}, struct_member_layouts: {} }
3674
3976
  end
3675
3977
 
3676
3978
  # Post-processes and freezes a fully-folded def-index accumulator.
3677
3979
  def finalize_def_index(acc)
3980
+ # Issue #722 residue 2 — settle the compact-header keys FIRST: every whole-project pass below reads
3981
+ # class-keyed tables, and the renames are only knowable now that `class_sources` spans the project.
3982
+ renames = compact_header_renames(acc[:compact_headers] || {}, acc[:class_sources].keys.to_set)
3983
+ apply_compact_header_renames!(acc, renames) unless renames.empty?
3984
+ acc[:compact_header_renames] = renames
3678
3985
  # Issue #644 — resolve the cross-file constant-reassignment rule here, where the whole project's
3679
3986
  # write census is known, and turn the surviving literals into their published `Type::Constant`.
3680
3987
  acc[:constant_values], acc[:constant_sources] = finalize_constant_writes(acc[:constant_writes])
@@ -3733,7 +4040,8 @@ module Rigor
3733
4040
  merge_header_nestings(acc[:header_nestings], header_nestings)
3734
4041
  accumulate_module_lists(acc[:includes], includes)
3735
4042
  accumulate_module_lists(acc[:extends], build_discovered_extends(root))
3736
- record_class_sources(acc[:class_sources], path, root, superclasses, includes, file_def_nodes)
4043
+ record_class_sources(acc[:class_sources], path, root, superclasses, includes, file_def_nodes,
4044
+ acc[:compact_headers])
3737
4045
  merge_constant_literal_tables(acc, root, path)
3738
4046
  merge_class_keyed_index_tables(acc, root, file_methods)
3739
4047
  merge_member_layout_tables(acc, root)
@@ -3773,9 +4081,9 @@ module Rigor
3773
4081
  # ancestry edges. {Scope#superclass_of} / {Scope#includes_of} record this set when resolving the edge during
3774
4082
  # dependency recording (ADR-46). The class-declaration walk (`collect_class_decls`) catches bodyless / def-less
3775
4083
  # reopenings the other three builders miss.
3776
- def record_class_sources(class_sources, path, root, superclasses, includes, file_def_nodes)
4084
+ def record_class_sources(class_sources, path, root, superclasses, includes, file_def_nodes, compacts = nil)
3777
4085
  names = Set.new
3778
- collect_class_decls(root, [], decls = {})
4086
+ collect_class_decls(root, [], decls = {}, compacts)
3779
4087
  names.merge(decls.keys)
3780
4088
  names.merge(superclasses.keys)
3781
4089
  names.merge(includes.keys)
@@ -3823,10 +4131,25 @@ module Rigor
3823
4131
  #
3824
4132
  # A name written TWICE in one file is unpublishable too, which is what makes a conditional
3825
4133
  # `X = :a if c` / `X = :b` pair gradual rather than pinned to whichever arm the walk saw first.
3826
- def constant_writes_for_file(root)
3827
- writes = {}
3828
- walk_constant_write_census(root, [], writes, Set.new)
3829
- writes
4134
+ def constant_writes_for_file(root) = constant_write_census(root).writes
4135
+
4136
+ # The tables one file's publication census fills in a single walk, carried together so the walk's two
4137
+ # `self`-tracking parameters stay within the signature budget. `writes` is the census itself; `seen`
4138
+ # the names already recorded, so a repeat write retracts whatever either rvalue was; `declared` the
4139
+ # subset whose write NAMES its target, which is the only half {Scope#local_constant_names} may exempt
4140
+ # ([#710](https://github.com/rigortype/rigor/issues/710)).
4141
+ # `aliases` is `{qualified target name => the constant reference its rvalue names}` for the one
4142
+ # unpublishable rvalue shape whose provenance is still knowable ([#667](https://github.com/rigortype/rigor/issues/667)):
4143
+ # a plain `MODE2 = AppConfig::MODE`. The census cannot publish a VALUE for it — resolving the source
4144
+ # is the typed walk's job, not this syntactic one — but it can record that the name is a rename of
4145
+ # another, which is all the withholding guard needs.
4146
+ CensusTables = Data.define(:writes, :seen, :declared, :aliases)
4147
+ private_constant :CensusTables
4148
+
4149
+ def constant_write_census(root)
4150
+ tables = CensusTables.new(writes: {}, seen: Set.new, declared: Set.new, aliases: {})
4151
+ walk_constant_write_census(root, [], tables)
4152
+ tables
3830
4153
  end
3831
4154
 
3832
4155
  # Mirrors {#walk_constant_writes}'s traversal for the forms it shares — the lexical class/module prefix
@@ -3835,60 +4158,66 @@ module Rigor
3835
4158
  # `||=` constant write, a `ConstantTargetNode` under a `MultiWriteNode` (`A, B = :x, :y`), a `self::X =`
3836
4159
  # whose target renders no qualified name, and the inner write of a chain (`C = D = :x`), which is why a
3837
4160
  # `ConstantWriteNode` descends into its own rvalue instead of returning.
3838
- def walk_constant_write_census(node, qualified_prefix, writes, seen, self_owner = nil)
4161
+ def walk_constant_write_census(node, qualified_prefix, tables, self_owner = nil, meta_owner = nil)
3839
4162
  return unless node.is_a?(Prism::Node)
3840
4163
 
3841
4164
  case node
3842
4165
  when Prism::ClassNode, Prism::ModuleNode
3843
4166
  child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
3844
4167
  if child_prefix
3845
- walk_constant_write_census(node.body, child_prefix, writes, seen) if node.body
4168
+ walk_constant_write_census(node.body, child_prefix, tables) if node.body
3846
4169
  return
3847
4170
  end
3848
4171
  else
3849
- census_constant_write(node, qualified_prefix, writes, seen, self_owner)
4172
+ census_constant_write(node, qualified_prefix, tables, self_owner)
3850
4173
  end
3851
4174
 
3852
- rebound = rebound_block_self(node, qualified_prefix)
4175
+ rebound = rebound_block_self(node, qualified_prefix, nil, meta_owner)
4176
+ # A `ConstantWriteNode`'s only child is its rvalue, so this reaches exactly the call whose block the
4177
+ # constant names — and nil everywhere else, leaving every other descent as it was.
4178
+ child_meta_owner = meta_new_block_owner(node, qualified_prefix)
3853
4179
  node.rigor_each_child do |child|
3854
4180
  owner = rebound && child.is_a?(Prism::BlockNode) ? rebound : self_owner
3855
- walk_constant_write_census(child, qualified_prefix, writes, seen, owner)
4181
+ walk_constant_write_census(child, qualified_prefix, tables, owner, child_meta_owner)
3856
4182
  end
3857
4183
  end
3858
4184
 
3859
4185
  # Censuses `node` when it is any constant-assigning form. Only a plain `ConstantWriteNode` /
3860
4186
  # `ConstantPathWriteNode` can contribute a VALUE; every other form is recorded unpublishable, which
3861
4187
  # suppresses publication of the name exactly as a second file's write would.
3862
- def census_constant_write(node, qualified_prefix, writes, seen, self_owner = nil)
4188
+ def census_constant_write(node, qualified_prefix, tables, self_owner = nil)
3863
4189
  case node
3864
4190
  when Prism::ConstantWriteNode
3865
4191
  record_constant_write_census(qualified_write_name(qualified_prefix, node.name.to_s),
3866
- constant_literal_value(node.value), writes, seen)
4192
+ constant_literal_value(node.value), tables,
4193
+ alias_of: constant_alias_source(node.value))
3867
4194
  when Prism::ConstantPathWriteNode
3868
4195
  record_constant_write_census(constant_path_write_name(node.target, qualified_prefix, self_owner),
3869
- constant_path_write_literal(node, self_owner), writes, seen)
4196
+ constant_path_write_literal(node, self_owner), tables,
4197
+ nameable: nameable_write_target?(node.target, self_owner),
4198
+ alias_of: constant_alias_source(node.value))
3870
4199
  when Prism::ConstantOperatorWriteNode, Prism::ConstantOrWriteNode, Prism::ConstantAndWriteNode
3871
- record_constant_write_census(qualified_write_name(qualified_prefix, node.name.to_s), nil, writes, seen)
4200
+ record_constant_write_census(qualified_write_name(qualified_prefix, node.name.to_s), nil, tables)
3872
4201
  when Prism::ConstantPathOperatorWriteNode, Prism::ConstantPathOrWriteNode, Prism::ConstantPathAndWriteNode
3873
4202
  record_constant_write_census(constant_path_write_name(node.target, qualified_prefix, self_owner),
3874
- nil, writes, seen)
4203
+ nil, tables, nameable: nameable_write_target?(node.target, self_owner))
3875
4204
  when Prism::MultiWriteNode
3876
- census_multi_write_constants(node, qualified_prefix, writes, seen, self_owner)
4205
+ census_multi_write_constants(node, qualified_prefix, tables, self_owner)
3877
4206
  end
3878
4207
  end
3879
4208
 
3880
4209
  # `A, B = :x, :y` and `A, *rest = …`. A destructured element's value is a projection of the right-hand
3881
4210
  # side, which this syntactic walk does not evaluate, so every constant target is censused unpublishable.
3882
- def census_multi_write_constants(node, qualified_prefix, writes, seen, self_owner = nil)
4211
+ def census_multi_write_constants(node, qualified_prefix, tables, self_owner = nil)
3883
4212
  targets = node.lefts + node.rights
3884
4213
  targets << node.rest if node.rest
3885
4214
  targets.each do |target|
3886
4215
  case target
3887
4216
  when Prism::ConstantTargetNode
3888
- record_constant_write_census(qualified_write_name(qualified_prefix, target.name.to_s), nil, writes, seen)
4217
+ record_constant_write_census(qualified_write_name(qualified_prefix, target.name.to_s), nil, tables)
3889
4218
  when Prism::ConstantPathTargetNode
3890
4219
  record_constant_write_census(constant_path_write_name(target, qualified_prefix, self_owner),
3891
- nil, writes, seen)
4220
+ nil, tables, nameable: nameable_write_target?(target, self_owner))
3892
4221
  end
3893
4222
  end
3894
4223
  end
@@ -3901,25 +4230,37 @@ module Rigor
3901
4230
  # censuses key a path write differently until that is settled.
3902
4231
  # `self::BAR = …` names whatever `self` is at that point, which the walk carries in `self_owner`
3903
4232
  # ([#705](https://github.com/rigortype/rigor/issues/705)) — the enclosing lexical namespace in an
3904
- # ordinary body, the receiver inside a `class_eval` block. Where that `self` is {OPAQUE_SELF}, and for
4233
+ # ordinary body, the receiver inside a `class_eval` block, and the constant a `Klass = Class.new { }`
4234
+ # assigns the block's class to ({#meta_new_block_owner}). Where that `self` is {OPAQUE_SELF}, and for
3905
4235
  # any other dynamic receiver (`klass::BAR = …`), the target is "some class, then `::BAR`" and the name
3906
- # falls back to the bare last segment the one spelling that class could make it, `Object`. Suppressing
3907
- # a top-level name is gradual typing, which is the safe direction here; the VALUE is what such a write
3908
- # must not contribute ({#constant_path_write_literal}).
4236
+ # falls back to the WILDCARD {DYNAMIC_TARGET_PREFIX} key, which {#finalize_constant_writes} expands
4237
+ # into a retraction of every censused name with that last segment
4238
+ # ([#668](https://github.com/rigortype/rigor/issues/668)). The bare last segment — the pre-#668
4239
+ # fallback — was the single name such a write can never create, so filing it there suppressed the one
4240
+ # name the form cannot touch and left published every name it can. The VALUE is what such a write must
4241
+ # not contribute either ({#constant_path_write_literal}), and the fallback name is withheld from the
4242
+ # local-declaration exemption ({#local_constant_name_set}).
3909
4243
  def constant_path_write_name(target, qualified_prefix, self_owner = nil)
3910
4244
  full = Source::ConstantPath.qualified_name_or_nil(target)
3911
4245
  return full if full
3912
4246
 
3913
4247
  base = target.name&.to_s
3914
4248
  return nil if base.nil?
3915
- return base unless target.parent.is_a?(Prism::SelfNode)
4249
+ return dynamic_target_key(base) unless target.parent.is_a?(Prism::SelfNode)
3916
4250
 
3917
- self_write_name(qualified_prefix, self_owner, base) || base
4251
+ self_write_name(qualified_prefix, self_owner, base) || dynamic_target_key(base)
4252
+ end
4253
+
4254
+ def dynamic_target_key(segment) = "#{DYNAMIC_TARGET_PREFIX}#{segment}"
4255
+
4256
+ # The last segment a wildcard census key stands for, or nil for an ordinary qualified name.
4257
+ def dynamic_target_segment(name)
4258
+ name.delete_prefix(DYNAMIC_TARGET_PREFIX) if name.start_with?(DYNAMIC_TARGET_PREFIX)
3918
4259
  end
3919
4260
 
3920
4261
  # The publishable literal a path write contributes — none, whenever its base is not statically
3921
4262
  # nameable ([#705](https://github.com/rigortype/rigor/issues/705)). `[Foo].each { |k| k::X = 1 }`
3922
- # renders as the bare `X`, and publishing `1` under it handed every reader of the top-level `X` a value
4263
+ # renders under the wildcard key, and publishing `1` there handed every reader of a project `X` a value
3923
4264
  # the program never has: `X == 2` folded to `false`, and in the WRITING file — where
3924
4265
  # `Scope#local_constant_names` exempts the name from #644's withholding guard —
3925
4266
  # `flow.always-truthy-condition` then fired on correct code.
@@ -3950,10 +4291,40 @@ module Rigor
3950
4291
  # Records one censused write. `seen` carries the names this file has already written, so a repeat write
3951
4292
  # retracts the publishable descriptor whatever either rvalue was — neither arm of an in-file
3952
4293
  # reassignment is ever published, and the name still counts as written.
3953
- def record_constant_write_census(full, literal, writes, seen)
4294
+ #
4295
+ # `nameable` is false only for the wildcard fallback {#constant_path_write_name} takes when nothing
4296
+ # names the write's base. Such a key belongs in `writes` — another file's value for a name it reaches
4297
+ # is not to be trusted — and NOT in `declared`, which answers the opposite question
4298
+ # ([#710](https://github.com/rigortype/rigor/issues/710)). The two are separate tables rather than one
4299
+ # richer descriptor because a name can be written both ways in one file, and then the file DID declare
4300
+ # it however the two writes are ordered.
4301
+ def record_constant_write_census(full, literal, tables, nameable: true, alias_of: nil)
3954
4302
  return if full.nil?
3955
4303
 
3956
- writes[full] = seen.add?(full) ? (literal || CONSTANT_UNPUBLISHABLE) : CONSTANT_UNPUBLISHABLE
4304
+ tables.declared << full if nameable
4305
+ first_write = tables.seen.add?(full)
4306
+ tables.writes[full] = first_write ? (literal || CONSTANT_UNPUBLISHABLE) : CONSTANT_UNPUBLISHABLE
4307
+ record_constant_alias_census(full, alias_of, tables, first_write && nameable)
4308
+ end
4309
+
4310
+ # Issue #667 — a name written TWICE is not an alias of anything the walk can name, exactly as it is
4311
+ # unpublishable above; the `delete` is what retracts a first write's record when the second arrives.
4312
+ def record_constant_alias_census(full, alias_of, tables, keep)
4313
+ if keep && alias_of
4314
+ tables.aliases[full] = alias_of
4315
+ else
4316
+ tables.aliases.delete(full)
4317
+ end
4318
+ end
4319
+
4320
+ # The constant reference an rvalue names, or nil when it names none. `AppConfig::MODE` renders
4321
+ # qualified; a path that renders no qualified name falls back to its own last segment, which is all
4322
+ # `Scope#published_constant?` can be asked about anyway.
4323
+ def constant_alias_source(rvalue)
4324
+ case rvalue
4325
+ when Prism::ConstantReadNode then rvalue.name.to_s
4326
+ when Prism::ConstantPathNode then Source::ConstantPath.qualified_name_or_nil(rvalue) || rvalue.name&.to_s
4327
+ end
3957
4328
  end
3958
4329
 
3959
4330
  # The frozen-scalar literal fold: `[value]` for a publishable rvalue, nil to decline. The one-element
@@ -3983,20 +4354,51 @@ module Rigor
3983
4354
  # fold time) so the fold stays order-independent, which is what lets the ADR-85 incremental path re-fold
3984
4355
  # a changed file's contribution in place.
3985
4356
  #
3986
- # @return [Array(Hash, Hash)] `[{name => Type}, {name => Set[path]}]`.
4357
+ # A {DYNAMIC_TARGET_PREFIX} key is not a name and never publishes; it is expanded by
4358
+ # {#apply_dynamic_target_writes} into a retraction over the names it could have written.
4359
+ #
4360
+ # @return `[{name => Type}, {name => Set[path]}]`.
3987
4361
  def finalize_constant_writes(writes)
3988
4362
  values = {}
3989
4363
  sources = {}
4364
+ dynamic = {}
3990
4365
  writes.each do |name, by_path|
3991
- sources[name] = by_path.keys.to_set.freeze
4366
+ segment = dynamic_target_segment(name)
4367
+ next dynamic[segment] = by_path.keys.to_set if segment
4368
+
4369
+ sources[name] = by_path.keys.to_set
3992
4370
  next unless by_path.size == 1
3993
4371
 
3994
4372
  descriptor = by_path.values.first
3995
4373
  values[name] = Type::Combinator.constant_of(descriptor.first) if descriptor.is_a?(Array)
3996
4374
  end
4375
+ apply_dynamic_target_writes(values, sources, dynamic)
3997
4376
  [values, sources]
3998
4377
  end
3999
4378
 
4379
+ # Issue #668 — expands each wildcard census key. `k::LIMIT = 7` can create `Anything::LIMIT`, so it
4380
+ # retracts every censused name whose last segment is `LIMIT` and joins the writing file to that name's
4381
+ # attribution; the bare `LIMIT` is retracted along with the rest, being the one name the analyzer
4382
+ # cannot rule out either (`k` may be `Object`). Over-retracting makes readers gradual, which is the
4383
+ # direction the census's whole decline list takes; leaving a name published that a dynamic write may
4384
+ # already have replaced is the single failure the census exists to prevent.
4385
+ #
4386
+ # The wildcard key survives into the attribution so a producer with no censused name of its own — a
4387
+ # `pre_eval:` publication for a file outside `paths:` — can still see the conflict
4388
+ # ({Analysis::Runner#publishable_pre_eval_constants}).
4389
+ def apply_dynamic_target_writes(values, sources, dynamic)
4390
+ return if dynamic.empty?
4391
+
4392
+ sources.each do |name, paths|
4393
+ writers = dynamic[name.split("::").last]
4394
+ next if writers.nil?
4395
+
4396
+ paths.merge(writers)
4397
+ values.delete(name)
4398
+ end
4399
+ dynamic.each { |segment, paths| sources[dynamic_target_key(segment)] = paths }
4400
+ end
4401
+
4000
4402
  # Merges one file's `class → method → DefNode` map into the cross-file `def_nodes` index and records each method's
4001
4403
  # first- seen `"path:line"` definition site in `def_sources` (ADR-17 — the un-registered-project-patch signal
4002
4404
  # `call.undefined-method` and `rigor triage` key on).
@@ -4012,7 +4414,7 @@ module Rigor
4012
4414
 
4013
4415
  # Cross-file counterpart of `record_declarations` — registers every `class` / `module` declaration under its
4014
4416
  # qualified name and descends into the body (so `module Foo; class Bar` registers both `Foo` and `Foo::Bar`).
4015
- def collect_class_decls(node, qualified_prefix, accumulator)
4417
+ def collect_class_decls(node, qualified_prefix, accumulator, compacts = nil)
4016
4418
  return unless node.is_a?(Prism::Node)
4017
4419
 
4018
4420
  case node
@@ -4021,13 +4423,119 @@ module Rigor
4021
4423
  if child_prefix
4022
4424
  full = child_prefix.join("::")
4023
4425
  accumulator[full] = Type::Combinator.singleton_of(full)
4024
- return collect_class_decls(node.body, child_prefix, accumulator) if node.body
4426
+ record_compact_header(compacts, qualified_prefix, node.constant_path, full) if compacts
4427
+ return collect_class_decls(node.body, child_prefix, accumulator, compacts) if node.body
4025
4428
  end
4026
4429
  when Prism::ConstantWriteNode
4027
4430
  record_class_new_constant_decl(node, qualified_prefix, accumulator)
4028
4431
  end
4029
4432
 
4030
- node.rigor_each_child { |child| collect_class_decls(child, qualified_prefix, accumulator) }
4433
+ node.rigor_each_child { |child| collect_class_decls(child, qualified_prefix, accumulator, compacts) }
4434
+ end
4435
+
4436
+ # Issue [#722](https://github.com/rigortype/rigor/issues/722) residue 2 — records what a COMPACT header
4437
+ # written inside an enclosing namespace would name if its leading segment fell through to the top level.
4438
+ # `class Outer::Leaf` inside `module Wrap` is keyed `Wrap::Outer::Leaf` by the per-node
4439
+ # {Source::ConstantPath.declaration_prefix}, but Ruby resolves the leading `Outer` by ORDINARY constant
4440
+ # lookup: it reopens `::Outer::Leaf` unless `Wrap::Outer` exists. Whether it exists is a whole-project
4441
+ # fact, so the candidate is recorded here and adjudicated once the fold knows every declared name
4442
+ # ({#compact_header_renames}).
4443
+ #
4444
+ # @param compacts — `{recorded name => [namespace the header needs, name it falls through to]}`.
4445
+ def record_compact_header(compacts, qualified_prefix, constant_path, full)
4446
+ return if qualified_prefix.empty?
4447
+ return unless constant_path.is_a?(Prism::ConstantPathNode)
4448
+ return if Source::ConstantPath.rooted?(constant_path)
4449
+
4450
+ rendered = Source::ConstantPath.qualified_name(constant_path)
4451
+ return unless rendered&.include?("::")
4452
+
4453
+ compacts[full] = [(qualified_prefix + [rendered.split("::").first]).join("::"), rendered]
4454
+ end
4455
+
4456
+ # Issue #722 residue 2 — the whole-project adjudication of {#record_compact_header}'s candidates:
4457
+ # `{name the walk recorded => name Ruby actually reopens}`.
4458
+ #
4459
+ # A candidate moves only when the project ANSWERS BOTH halves of Ruby's lookup: the namespace the
4460
+ # nesting would supply (`Wrap::Outer`) is declared nowhere, and the top level the segment falls through
4461
+ # to (`Outer`) IS declared. The second half is the false-positive bound, not a convenience — a
4462
+ # `Wrap::Outer` that merely never appears in project source (a gem, an RBS-only class) is a namespace
4463
+ # that DOES exist at runtime, and re-anchoring on its absence would answer with a wrong class where
4464
+ # today's mis-keying only answers with silence.
4465
+ #
4466
+ # `declared_names` is the RAW declared set (`class_sources`), never the table
4467
+ # {#synthesize_namespace_prefixes} has run over: that synthesis invents `Wrap::Outer` from
4468
+ # `Wrap::Outer::Leaf` itself, so consulting it would make every candidate look already-resolved.
4469
+ def compact_header_renames(compacts, declared_names)
4470
+ compacts.each_with_object({}) do |(recorded, (required_namespace, reanchored)), out|
4471
+ next if declared_names.include?(required_namespace)
4472
+ next unless declared_names.include?(reanchored.split("::").first)
4473
+
4474
+ out[recorded] = reanchored
4475
+ end
4476
+ end
4477
+
4478
+ # Rewrites `name` when it is, or is nested under, a re-anchored compact header. Nested declarations
4479
+ # ride the same move: `class Outer::Leaf` inside `module Wrap` carrying a `class Inner` filed the inner
4480
+ # class under `Wrap::Outer::Leaf::Inner`, and it reopens `::Outer::Leaf::Inner` for the same reason.
4481
+ def rename_compact_name(renames, name)
4482
+ return name if renames.empty?
4483
+
4484
+ string = name.to_s
4485
+ renamed = renames[string]
4486
+ return renamed if renamed
4487
+
4488
+ renames.each do |recorded, reanchored|
4489
+ return "#{reanchored}#{string[recorded.length..]}" if string.start_with?("#{recorded}::")
4490
+ end
4491
+ string
4492
+ end
4493
+
4494
+ # Re-keys one class-keyed table. A rename lands a declaration on a key the table may ALREADY hold —
4495
+ # which is the whole point: `class Outer::Leaf` written twice, once at the top level and once inside
4496
+ # `module Wrap`, is one class with both bodies. So the arriving contribution is COMBINED with the
4497
+ # sitting one under the table's own fold semantics rather than replacing it; a plain `transform_keys`
4498
+ # dropped the top-level body's methods outright.
4499
+ def rekey_class_table(table, renames)
4500
+ table.each_with_object({}) do |(name, value), out|
4501
+ renamed = rename_compact_name(renames, name)
4502
+ sitting = out[renamed]
4503
+ out[renamed] = sitting.nil? ? value : combine_rekeyed_entries(sitting, value)
4504
+ end
4505
+ end
4506
+
4507
+ # The per-shape combine {#rekey_class_table} applies. Every class-keyed table's value is a Hash of
4508
+ # per-member entries, a Set or Array of names, or a single scalar fact (a superclass name, a member
4509
+ # layout); the first two union, and a scalar keeps the entry already sitting, matching the first-wins
4510
+ # fold `class_sources` and the `"path:line"` source tables use.
4511
+ def combine_rekeyed_entries(sitting, arriving)
4512
+ case sitting
4513
+ when Hash then sitting.merge(arriving)
4514
+ when Set, Array then sitting | arriving
4515
+ else sitting
4516
+ end
4517
+ end
4518
+
4519
+ # Applies {#compact_header_renames} to every class-keyed table of a folded def-index accumulator, plus
4520
+ # the nesting chains whose entries are class names. Runs before {#finalize_def_index}'s own whole-project
4521
+ # passes so those see the settled keys.
4522
+ def apply_compact_header_renames!(acc, renames)
4523
+ %i[def_nodes singleton_def_nodes def_sources singleton_def_sources superclasses header_nestings
4524
+ includes extends method_visibilities methods class_sources data_member_layouts
4525
+ struct_member_layouts constant_writes].each do |key|
4526
+ acc[key] = rekey_class_table(acc[key], renames)
4527
+ end
4528
+ # A `header_nestings` value is a BUCKET (`raw header → chain`, plus the unkeyed union), not a
4529
+ # chain: mapping the bucket itself turned every entry into a `[raw, chain]` pair rendered as a
4530
+ # string, and the next per-file merge (`merge_header_nesting_bucket`) and every
4531
+ # `Scope#recorded_header_nesting` lookup then raised on an Array where a Hash was expected — an
4532
+ # internal analyzer error on every file of a project with one compact header (#984).
4533
+ acc[:header_nestings] = acc[:header_nestings].transform_values do |bucket|
4534
+ bucket.transform_values { |chain| chain.map { |entry| rename_compact_name(renames, entry) } }
4535
+ end
4536
+ acc[:def_nestings].transform_values! do |chain|
4537
+ chain&.map { |entry| rename_compact_name(renames, entry) }
4538
+ end
4031
4539
  end
4032
4540
 
4033
4541
  # T1 (template-corpora survey) — record a class-creating constant write (`Const = Class.new(Super)`, the bare
@@ -4100,13 +4608,30 @@ module Rigor
4100
4608
  # remain real references (resolved through the ordinary lexical walk), so we annotate ONLY the topmost path node.
4101
4609
  # Nested declarations contribute their fully qualified path: `class A::B; class C; ...` produces `A::B` for the
4102
4610
  # outer and `A::B::C` for the inner.
4103
- def build_declaration_artifacts(root)
4611
+ # @param declared_names — the project's RAW declared-class names, the oracle
4612
+ # {#compact_header_renames} adjudicates this file's compact headers against.
4613
+ def build_declaration_artifacts(root, declared_names = nil)
4104
4614
  identity_table = {}.compare_by_identity
4105
4615
  discovered = {}
4106
- record_declarations(root, [], identity_table, discovered)
4616
+ record_declarations(root, [], identity_table, discovered,
4617
+ file_compact_header_renames(root, declared_names))
4107
4618
  [identity_table.freeze, synthesize_namespace_prefixes(discovered).freeze]
4108
4619
  end
4109
4620
 
4621
+ # Issue #722 residue 2 — this file's share of the fold's re-anchoring, adjudicated against the project
4622
+ # names the fold has already settled. Empty (so the per-node answer stands) whenever the caller has no
4623
+ # project table to consult: a `Scope.empty` probe sees one file and cannot know what else declares
4624
+ # `Wrap::Outer`, and guessing there is the false positive the whole rule is bounded to avoid.
4625
+ def file_compact_header_renames(root, declared_names)
4626
+ return EMPTY_RENAMES if declared_names.nil? || declared_names.empty?
4627
+
4628
+ compacts = {}
4629
+ collect_class_decls(root, [], {}, compacts)
4630
+ return EMPTY_RENAMES if compacts.empty?
4631
+
4632
+ compact_header_renames(compacts, declared_names)
4633
+ end
4634
+
4110
4635
  # Issue #528 — every proper prefix of a discovered COMPACT class name is a namespace module that
4111
4636
  # provably exists at runtime, even when no `module X` declaration is anywhere in the source
4112
4637
  # (Zeitwerk derives it from the directory: mastodon writes `class Api::V1::AccountsController`
@@ -4132,30 +4657,35 @@ module Rigor
4132
4657
  discovered.merge(additions)
4133
4658
  end
4134
4659
 
4135
- def record_declarations(node, qualified_prefix, identity_table, discovered)
4660
+ def record_declarations(node, qualified_prefix, identity_table, discovered, renames = EMPTY_RENAMES)
4136
4661
  return unless node.is_a?(Prism::Node)
4137
4662
 
4138
4663
  case node
4139
4664
  when Prism::ModuleNode, Prism::ClassNode
4140
- return if record_class_or_module?(node, qualified_prefix, identity_table, discovered)
4141
- when Prism::ConstantWriteNode
4665
+ return if record_class_or_module?(node, qualified_prefix, identity_table, discovered, renames)
4666
+ when Prism::ConstantWriteNode, Prism::ConstantPathWriteNode
4142
4667
  return if record_meta_new_constant?(node, qualified_prefix, identity_table, discovered)
4143
4668
  end
4144
4669
 
4145
4670
  node.rigor_each_child do |child|
4146
- record_declarations(child, qualified_prefix, identity_table, discovered)
4671
+ record_declarations(child, qualified_prefix, identity_table, discovered, renames)
4147
4672
  end
4148
4673
  end
4149
4674
 
4150
- def record_class_or_module?(node, qualified_prefix, identity_table, discovered)
4675
+ def record_class_or_module?(node, qualified_prefix, identity_table, discovered, renames = EMPTY_RENAMES)
4151
4676
  child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
4152
4677
  return false unless child_prefix
4153
4678
 
4154
- full = child_prefix.join("::")
4679
+ # Issue #722 residue 2 — the per-file twin of the fold's re-anchoring. The file's own declaration
4680
+ # table merges OVER the project seed, so without this a compact header would re-publish the key the
4681
+ # fold already moved and a reference written INSIDE the enclosing namespace would resolve to it
4682
+ # again. The adjudication itself is the project's: `renames` arrives already settled.
4683
+ full = rename_compact_name(renames, child_prefix.join("::"))
4684
+ child_prefix = full.split("::")
4155
4685
  singleton = Type::Combinator.singleton_of(full)
4156
4686
  identity_table[node.constant_path] = singleton
4157
4687
  discovered[full] = singleton
4158
- record_declarations(node.body, child_prefix, identity_table, discovered) if node.body
4688
+ record_declarations(node.body, child_prefix, identity_table, discovered, renames) if node.body
4159
4689
  true
4160
4690
  end
4161
4691
 
@@ -4168,13 +4698,20 @@ module Rigor
4168
4698
  # - `Const = Data.define(*Symbol) [do ... end]`
4169
4699
  # - `Const = Struct.new(*Symbol [, keyword_init: ...]) [do ... end]`
4170
4700
  #
4701
+ # Issue [#703](https://github.com/rigortype/rigor/issues/703) — and their `Holder::Const = …` spellings, which
4702
+ # name a class just as unconditionally. Until this arm took them, `Holder::Thing.new(1)` fell to the default
4703
+ # `Class#new` envelope on `singleton(Struct)` and handed every reader a bare `Struct`.
4704
+ #
4171
4705
  # The block body, if present, is recursed into so any nested class/module declarations in the override block (rare
4172
4706
  # but legal) still feed the discovered table.
4173
4707
  def record_meta_new_constant?(node, qualified_prefix, identity_table, discovered)
4174
4708
  factory_call = resolve_meta_factory_call(node.value)
4175
4709
  return false unless factory_call
4176
4710
 
4177
- full = (qualified_prefix + [node.name.to_s]).join("::")
4711
+ child_prefix = meta_new_child_prefix(node, qualified_prefix)
4712
+ return false if child_prefix.nil?
4713
+
4714
+ full = child_prefix.join("::")
4178
4715
  discovered[full] = Type::Combinator.singleton_of(full)
4179
4716
  record_declarations(node.value, qualified_prefix, identity_table, discovered)
4180
4717
  true