rigortype 0.1.18 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -224
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +32 -23
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +151 -23
  8. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  9. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
  10. data/lib/rigor/analysis/check_rules.rb +756 -132
  11. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  12. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  13. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  14. data/lib/rigor/analysis/diagnostic.rb +8 -0
  15. data/lib/rigor/analysis/fact_store.rb +5 -4
  16. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  17. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +19 -18
  18. data/lib/rigor/analysis/runner/project_pre_passes.rb +13 -9
  19. data/lib/rigor/analysis/runner.rb +75 -27
  20. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  21. data/lib/rigor/analysis/worker_session.rb +31 -25
  22. data/lib/rigor/bleeding_edge.rb +123 -0
  23. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  24. data/lib/rigor/cache/descriptor.rb +86 -8
  25. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  26. data/lib/rigor/cache/store.rb +5 -3
  27. data/lib/rigor/cli/annotate_command.rb +122 -16
  28. data/lib/rigor/cli/baseline_command.rb +4 -3
  29. data/lib/rigor/cli/check_command.rb +118 -16
  30. data/lib/rigor/cli/coverage_command.rb +148 -16
  31. data/lib/rigor/cli/coverage_scan.rb +57 -0
  32. data/lib/rigor/cli/explain_command.rb +2 -0
  33. data/lib/rigor/cli/lsp_command.rb +3 -7
  34. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  35. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  36. data/lib/rigor/cli/options.rb +9 -0
  37. data/lib/rigor/cli/plugins_command.rb +4 -5
  38. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  39. data/lib/rigor/cli/protection_renderer.rb +63 -0
  40. data/lib/rigor/cli/protection_report.rb +68 -0
  41. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  42. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  43. data/lib/rigor/cli/trace_command.rb +2 -1
  44. data/lib/rigor/cli/triage_command.rb +8 -4
  45. data/lib/rigor/cli/triage_renderer.rb +15 -1
  46. data/lib/rigor/cli/type_of_command.rb +1 -1
  47. data/lib/rigor/cli/type_scan_command.rb +2 -1
  48. data/lib/rigor/cli.rb +12 -3
  49. data/lib/rigor/configuration/dependencies.rb +2 -4
  50. data/lib/rigor/configuration/severity_profile.rb +13 -1
  51. data/lib/rigor/configuration.rb +100 -6
  52. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  53. data/lib/rigor/environment/class_registry.rb +4 -3
  54. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  55. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  56. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  57. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  58. data/lib/rigor/environment/rbs_loader.rb +74 -5
  59. data/lib/rigor/environment.rb +17 -7
  60. data/lib/rigor/flow_contribution/fact.rb +1 -1
  61. data/lib/rigor/flow_contribution.rb +3 -5
  62. data/lib/rigor/inference/acceptance.rb +17 -9
  63. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  64. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  65. data/lib/rigor/inference/budget_trace.rb +29 -2
  66. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  67. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  68. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  69. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  70. data/lib/rigor/inference/expression_typer.rb +1072 -71
  71. data/lib/rigor/inference/hkt_body.rb +8 -11
  72. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  73. data/lib/rigor/inference/hkt_registry.rb +10 -11
  74. data/lib/rigor/inference/macro_block_self_type.rb +2 -2
  75. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  76. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  77. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +210 -35
  78. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  79. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  80. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  81. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  82. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  83. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  84. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  85. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  86. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  87. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +237 -24
  88. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  89. data/lib/rigor/inference/method_dispatcher.rb +112 -49
  90. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  91. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  92. data/lib/rigor/inference/mutation_widening.rb +147 -11
  93. data/lib/rigor/inference/narrowing.rb +284 -53
  94. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  95. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  96. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  97. data/lib/rigor/inference/protection_scanner.rb +86 -0
  98. data/lib/rigor/inference/scope_indexer.rb +821 -76
  99. data/lib/rigor/inference/statement_evaluator.rb +1179 -102
  100. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  101. data/lib/rigor/inference/synthetic_method.rb +7 -7
  102. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  103. data/lib/rigor/language_server/completion_provider.rb +6 -12
  104. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  105. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  106. data/lib/rigor/language_server/hover_provider.rb +2 -3
  107. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  108. data/lib/rigor/language_server/server.rb +9 -17
  109. data/lib/rigor/language_server.rb +4 -5
  110. data/lib/rigor/plugin/base.rb +245 -87
  111. data/lib/rigor/plugin/macro/block_as_method.rb +25 -25
  112. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  113. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  114. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  115. data/lib/rigor/plugin/macro.rb +6 -8
  116. data/lib/rigor/plugin/manifest.rb +49 -90
  117. data/lib/rigor/plugin/node_rule_walk.rb +59 -14
  118. data/lib/rigor/plugin/registry.rb +18 -18
  119. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  120. data/lib/rigor/protection/mutation_scanner.rb +120 -0
  121. data/lib/rigor/protection/mutator.rb +246 -0
  122. data/lib/rigor/rbs_extended.rb +24 -36
  123. data/lib/rigor/reflection.rb +4 -7
  124. data/lib/rigor/scope/discovery_index.rb +16 -2
  125. data/lib/rigor/scope.rb +185 -16
  126. data/lib/rigor/sig_gen/generator.rb +8 -0
  127. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  128. data/lib/rigor/sig_gen/writer.rb +40 -2
  129. data/lib/rigor/source/constant_path.rb +62 -0
  130. data/lib/rigor/source.rb +1 -0
  131. data/lib/rigor/triage/catalogue.rb +4 -19
  132. data/lib/rigor/triage.rb +69 -1
  133. data/lib/rigor/type/bound_method.rb +2 -11
  134. data/lib/rigor/type/combinator.rb +45 -3
  135. data/lib/rigor/type/constant.rb +2 -11
  136. data/lib/rigor/type/data_class.rb +2 -11
  137. data/lib/rigor/type/data_instance.rb +2 -11
  138. data/lib/rigor/type/hash_shape.rb +2 -11
  139. data/lib/rigor/type/integer_range.rb +2 -11
  140. data/lib/rigor/type/intersection.rb +2 -11
  141. data/lib/rigor/type/nominal.rb +2 -11
  142. data/lib/rigor/type/plain_lattice.rb +37 -0
  143. data/lib/rigor/type/refined.rb +72 -13
  144. data/lib/rigor/type/singleton.rb +2 -11
  145. data/lib/rigor/type/struct_class.rb +75 -0
  146. data/lib/rigor/type/struct_instance.rb +93 -0
  147. data/lib/rigor/type/tuple.rb +5 -15
  148. data/lib/rigor/type.rb +2 -0
  149. data/lib/rigor/version.rb +1 -1
  150. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  151. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  152. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +16 -32
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  154. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +34 -100
  157. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  158. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  159. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  160. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +26 -27
  161. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  162. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +9 -8
  163. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  164. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  165. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  166. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  167. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  168. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  169. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  170. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +18 -49
  171. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  172. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  173. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +4 -4
  174. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  175. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  176. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  177. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  178. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +22 -35
  179. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  180. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  181. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +16 -23
  182. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  183. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
  184. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
  185. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  186. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +21 -27
  187. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  188. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  189. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  190. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  191. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  192. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  193. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  194. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  195. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  196. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  197. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +52 -40
  198. data/sig/rigor/analysis/fact_store.rbs +3 -0
  199. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  200. data/sig/rigor/plugin/base.rbs +5 -2
  201. data/sig/rigor/plugin/manifest.rbs +1 -2
  202. data/sig/rigor/scope.rbs +18 -1
  203. data/sig/rigor/type.rbs +37 -1
  204. data/sig/rigor.rbs +1 -1
  205. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  206. data/skills/rigor-plugin-author/SKILL.md +6 -4
  207. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  208. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  209. metadata +25 -2
  210. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -58,11 +58,10 @@ module Rigor
58
58
  # slices route real producers through it.
59
59
  # @param workers [Integer] ADR-15 Phase 4b — when greater
60
60
  # than zero, per-file analysis dispatches across a pool of
61
- # N Ractor workers built around {WorkerSession}. Default
62
- # `0` keeps the sequential code path bit-for-bit
63
- # unchanged. Phase 4c will wire the CLI / `.rigor.yml`
64
- # surface that produces non-zero values; this slice
65
- # leaves the parameter as a programmatic opt-in only.
61
+ # N workers. Default `0` keeps the sequential code path
62
+ # bit-for-bit unchanged. Controlled via the
63
+ # `RIGOR_RACTOR_WORKERS` env var or `.rigor.yml`
64
+ # `parallel.workers:` (Phase 4c, fully wired).
66
65
  # @param collect_stats [Boolean] when true (default), `#run`
67
66
  # builds a {RunStats} summary exposed via `result.stats`
68
67
  # — this forces the RBS env build at end-of-run so the
@@ -155,6 +154,7 @@ module Rigor
155
154
  @cached_plugin_prepare_diagnostics = [].freeze
156
155
  @project_discovered_classes = {}.freeze
157
156
  @project_discovered_def_nodes = {}.freeze
157
+ @project_discovered_singleton_def_nodes = {}.freeze
158
158
  @project_discovered_def_sources = {}.freeze
159
159
  @project_discovered_superclasses = {}.freeze
160
160
  @project_discovered_includes = {}.freeze
@@ -162,6 +162,7 @@ module Rigor
162
162
  @project_discovered_method_visibilities = {}.freeze
163
163
  @project_discovered_methods = {}.freeze
164
164
  @project_data_member_layouts = {}.freeze
165
+ @project_struct_member_layouts = {}.freeze
165
166
  build_collaborators
166
167
  end
167
168
 
@@ -457,7 +458,7 @@ module Rigor
457
458
  # discovery tables at their frozen-empty constructor defaults
458
459
  # (the bundle carries `nil` for them, matching the original
459
460
  # adopt path that never touched them).
460
- def apply_pre_passes_result(result)
461
+ def apply_pre_passes_result(result) # rubocop:disable Metrics/AbcSize
461
462
  @plugin_registry = result.plugin_registry
462
463
  @dependency_source_index = result.dependency_source_index
463
464
  @cached_plugin_prepare_diagnostics = result.cached_plugin_prepare_diagnostics
@@ -466,6 +467,9 @@ module Rigor
466
467
  @pre_eval_diagnostics_from_scanner = result.pre_eval_diagnostics_from_scanner
467
468
  @project_discovered_classes = result.discovered_classes if result.discovered_classes
468
469
  @project_discovered_def_nodes = result.discovered_def_nodes if result.discovered_def_nodes
470
+ if result.discovered_singleton_def_nodes
471
+ @project_discovered_singleton_def_nodes = result.discovered_singleton_def_nodes
472
+ end
469
473
  @project_discovered_def_sources = result.discovered_def_sources if result.discovered_def_sources
470
474
  @project_discovered_superclasses = result.discovered_superclasses if result.discovered_superclasses
471
475
  @project_discovered_includes = result.discovered_includes if result.discovered_includes
@@ -475,6 +479,7 @@ module Rigor
475
479
  end
476
480
  @project_discovered_methods = result.discovered_methods if result.discovered_methods
477
481
  @project_data_member_layouts = result.data_member_layouts if result.data_member_layouts
482
+ @project_struct_member_layouts = result.struct_member_layouts if result.struct_member_layouts
478
483
  end
479
484
  private :run_project_pre_passes, :adopt_prebuilt_project_scan, :apply_pre_passes_result
480
485
 
@@ -636,30 +641,44 @@ module Rigor
636
641
  # `#diagnostics_for_file` or declared a `node_rule` are visited
637
642
  # (`contribution_index.for_file_diagnostics`); a skipped plugin's
638
643
  # two hooks could only have returned `[]`.
639
- def plugin_emitted_diagnostics(path, root, scope)
644
+ def plugin_emitted_diagnostics(path, root, scope, node_results)
640
645
  return [] if @plugin_registry.empty?
641
646
 
642
- # ADR-52 WD4 — one engine-owned AST walk per file dispatches each
643
- # node to every matching (plugin, rule); the per-plugin results
644
- # are bucketed in registry order so emission stays plugin-major
645
- # (byte-identical with the old per-plugin walk).
646
- node_results = node_rule_results_by_plugin(path, root, scope)
647
-
648
647
  @plugin_registry.contribution_index.for_file_diagnostics.flat_map do |plugin|
649
648
  collect_plugin_diagnostics(plugin, path, root, scope, node_results[plugin])
650
649
  end
651
650
  end
652
651
 
653
- def node_rule_results_by_plugin(path, root, scope)
652
+ # ADR-52 WD4 + ADR-53 B4 — one engine-owned AST walk per file
653
+ # dispatches each node to every matching (plugin, rule) AND drives
654
+ # the built-in node collectors (`node_collectors`), so the file is
655
+ # walked once for both. The per-plugin results are bucketed in
656
+ # registry order so plugin emission stays plugin-major
657
+ # (byte-identical with the old per-plugin walk); the collectors are
658
+ # populated in place for `diagnose` to consume.
659
+ #
660
+ # When no plugin declares a node rule, the walk still runs to drive
661
+ # the collectors (the converged path replaces the standalone
662
+ # `RuleWalk.run`); `node_collectors` nil means a caller that does
663
+ # not need built-in collection from this walk.
664
+ def node_rule_results_by_plugin(path, root, scope, node_collectors, scope_index)
654
665
  walk = @plugin_registry.node_rule_walk
655
- return {}.compare_by_identity if walk.empty?
666
+ driver = node_collectors && CheckRules.node_collector_driver(node_collectors)
667
+ return {}.compare_by_identity if walk.empty? && driver.nil?
656
668
 
657
- results = walk.diagnostics_for_file(path: path, scope: scope, root: root)
669
+ results = walk.diagnostics_for_file(
670
+ path: path, scope: scope, root: root, collector_driver: driver
671
+ )
672
+ CheckRules.shadow_verify_converged_collectors(path, root, scope_index, node_collectors) if shadow_rule_walk?
658
673
  results.each_with_object({}.compare_by_identity) do |result, by_plugin|
659
674
  by_plugin[result.plugin] = result
660
675
  end
661
676
  end
662
677
 
678
+ def shadow_rule_walk?
679
+ ENV.fetch("RIGOR_SHADOW_RULE_WALK", nil)
680
+ end
681
+
663
682
  def collect_plugin_diagnostics(plugin, path, root, scope, node_result)
664
683
  raw = Array(plugin.diagnostics_for_file(path: path, scope: scope, root: root))
665
684
  # A node-rule context/rule raise isolates the whole plugin's
@@ -807,6 +826,9 @@ module Rigor
807
826
  tables = {}
808
827
  tables[:discovered_classes] = @project_discovered_classes unless @project_discovered_classes.empty?
809
828
  tables[:discovered_def_nodes] = @project_discovered_def_nodes unless @project_discovered_def_nodes.empty?
829
+ unless @project_discovered_singleton_def_nodes.empty?
830
+ tables[:discovered_singleton_def_nodes] = @project_discovered_singleton_def_nodes
831
+ end
810
832
  tables[:discovered_def_sources] = @project_discovered_def_sources unless @project_discovered_def_sources.empty?
811
833
  unless @project_discovered_superclasses.empty?
812
834
  tables[:discovered_superclasses] = @project_discovered_superclasses
@@ -816,7 +838,7 @@ module Rigor
816
838
  tables[:discovered_method_visibilities] = @project_discovered_method_visibilities
817
839
  end
818
840
  tables[:discovered_methods] = @project_discovered_methods unless @project_discovered_methods.empty?
819
- tables[:data_member_layouts] = @project_data_member_layouts unless @project_data_member_layouts.empty?
841
+ seed_member_layout_tables(tables)
820
842
  # ADR-46 slice 1 — the class-declaration source map is read only by
821
843
  # the ancestry accessors during dependency recording, so seed it
822
844
  # only when recording is on; a normal run never carries it.
@@ -826,6 +848,16 @@ module Rigor
826
848
  tables
827
849
  end
828
850
 
851
+ # ADR-48 — seed the Data + Struct member-layout tables (each only when
852
+ # non-empty). Extracted to keep {#project_scope_seed_tables} under the
853
+ # complexity budget.
854
+ def seed_member_layout_tables(tables)
855
+ tables[:data_member_layouts] = @project_data_member_layouts unless @project_data_member_layouts.empty?
856
+ return if @project_struct_member_layouts.empty?
857
+
858
+ tables[:struct_member_layouts] = @project_struct_member_layouts
859
+ end
860
+
829
861
  # ADR-46 slice 1 — when dependency recording is enabled, wrap the
830
862
  # per-file analysis so the cross-file reads its inference makes are
831
863
  # captured into `file_dependencies[path]`. Off by default: a normal
@@ -861,15 +893,8 @@ module Rigor
861
893
  self_call_record = with_self_call_recording(path) do
862
894
  index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
863
895
  end
864
- diagnostics = CheckRules.diagnose(
865
- path: path,
866
- root: parse_result.value,
867
- scope_index: index,
868
- self_call_misses: self_call_record ? self_call_record.calls : [],
869
- comments: parse_result.comments,
870
- disabled_rules: @configuration.disabled_rules
871
- )
872
- diagnostics += plugin_emitted_diagnostics(path, parse_result.value, scope)
896
+ self_call_misses = self_call_record ? self_call_record.calls : []
897
+ diagnostics = rule_and_plugin_diagnostics(path, parse_result, scope, index, self_call_misses)
873
898
  diagnostics + explain_diagnostics(path, parse_result.value, scope)
874
899
  rescue Errno::ENOENT => e
875
900
  [
@@ -893,6 +918,28 @@ module Rigor
893
918
  ]
894
919
  end
895
920
 
921
+ # ADR-53 B4 — the built-in node collectors and the plugin node rules
922
+ # share ONE traversal of the file. The collectors are built here (they
923
+ # need the completed `index`) and populated by the converged plugin
924
+ # walk; `node_results` carries the per-plugin node-rule output. Both
925
+ # the built-in `diagnose` output and the plugin diagnostics are then
926
+ # built from that single walk's results.
927
+ def rule_and_plugin_diagnostics(path, parse_result, scope, index, self_call_misses)
928
+ root = parse_result.value
929
+ node_collectors = CheckRules.build_node_collectors(path, index)
930
+ node_results = node_rule_results_by_plugin(path, root, scope, node_collectors, index)
931
+ diagnostics = CheckRules.diagnose(
932
+ path: path,
933
+ root: root,
934
+ scope_index: index,
935
+ self_call_misses: self_call_misses,
936
+ comments: parse_result.comments,
937
+ disabled_rules: @configuration.disabled_rules,
938
+ node_collectors: node_collectors
939
+ )
940
+ diagnostics + plugin_emitted_diagnostics(path, root, scope, node_results)
941
+ end
942
+
896
943
  # ADR-24 slice 4a — runs `block` (the typing pass) with the self-call
897
944
  # recorder active when either the test-only `record_self_calls:` flag is
898
945
  # set or the `call.self-undefined-method` rule resolves to a firing
@@ -928,7 +975,8 @@ module Rigor
928
975
  else
929
976
  Configuration::SeverityProfile.resolve(
930
977
  rule: rule, authored_severity: :warning,
931
- profile: @configuration.severity_profile, overrides: @configuration.severity_overrides
978
+ profile: @configuration.severity_profile, overrides: @configuration.severity_overrides,
979
+ bleeding_edge_overrides: @configuration.bleeding_edge_severity_overrides
932
980
  ) != :off
933
981
  end
934
982
  end
@@ -21,10 +21,9 @@ module Rigor
21
21
  # reach the recorder. This module is the ADR-46 / ADR-47 "collect at
22
22
  # evaluation time, never recompute" lesson applied to self-calls.
23
23
  #
24
- # A later slice consumes the recorded misses behind a confidently-
25
- # closed-class gate to emit `call.self-undefined-method`, behind its
26
- # own external-corpus false-positive gate. This slice (4a) lands the
27
- # plumbing OFF by default — {active?} is false on a normal run, so the
24
+ # ADR-24 slice 4 (`call.self-undefined-method`) consumes the recorded
25
+ # misses behind a confidently-closed-class gate (see `CheckRules` L775).
26
+ # The rule ships `:off` by default — {active?} is false on a normal run, so the
28
27
  # instrumented choke-point pays a single integer read and records
29
28
  # nothing. Recording is purely observational; it never changes a
30
29
  # diagnostic.
@@ -21,21 +21,19 @@ module Rigor
21
21
  module Analysis
22
22
  # ADR-15 Phase 4a — per-worker analysis substrate.
23
23
  # [ADR-15](../../../docs/adr/15-ractor-concurrency.md)
24
- # § Phase 4 carves the eventual Ractor-isolated worker pool
25
- # into three sub-phases; this is the substrate that 4b will
26
- # wrap in `Ractor.new` and 4c will gate behind
27
- # `RIGOR_RACTOR_WORKERS`. NO Ractor in the loop yet 4a
28
- # exists so the per-worker ownership boundary is testable in
29
- # the absence of any Ractor coordination.
24
+ # § Phase 4 carves the Ractor-isolated worker pool into sub-phases;
25
+ # 4a/4b/4c all landed, but the Ractor pool (4b) is blocked by Ruby
26
+ # Bug #22075 (UAF) the active pool backend is fork (ADR-15 Amendment).
27
+ # This class exists so the per-worker ownership boundary is testable
28
+ # independently of any pool coordinator.
30
29
  #
31
30
  # The constructor takes only `Ractor.shareable?` inputs:
32
31
  #
33
32
  # - `configuration` — Phase 2a ({Rigor::Configuration} is
34
33
  # `Ractor.shareable?`).
35
- # - `cache_store` — frozen-shareable handle is NOT a precondition;
36
- # future 4b workers build their OWN Store at the shared
37
- # `cache_root` directory. 4a accepts an already-built Store
38
- # for the no-Ractor coordinator path.
34
+ # - `cache_store` — the fork backend passes the parent runner's
35
+ # pre-built Store (`cache_store: @cache_store` in PoolCoordinator);
36
+ # workers share it rather than building their own at `cache_root`.
39
37
  # - `plugin_blueprints` — Phase 3a
40
38
  # (`Array<Plugin::Blueprint>` is `Ractor.shareable?`).
41
39
  # - `explain` — Boolean.
@@ -175,14 +173,18 @@ module Rigor
175
173
 
176
174
  scope = seed_project_scope(Scope.empty(environment: @environment, source_path: path))
177
175
  index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
176
+ # ADR-53 B4 — built-in collectors + plugin node rules share one walk.
177
+ node_collectors = CheckRules.build_node_collectors(path, index)
178
+ node_results = node_rule_results_by_plugin(path, parse_result.value, scope, node_collectors, index)
178
179
  diagnostics = CheckRules.diagnose(
179
180
  path: path,
180
181
  root: parse_result.value,
181
182
  scope_index: index,
182
183
  comments: parse_result.comments,
183
- disabled_rules: @configuration.disabled_rules
184
+ disabled_rules: @configuration.disabled_rules,
185
+ node_collectors: node_collectors
184
186
  )
185
- diagnostics += plugin_emitted_diagnostics(path, parse_result.value, scope)
187
+ diagnostics += plugin_emitted_diagnostics(path, parse_result.value, scope, node_results)
186
188
  diagnostics + explain_diagnostics(path, parse_result.value, scope)
187
189
  rescue Errno::ENOENT => e
188
190
  [analyzer_error(path, e.message)]
@@ -230,11 +232,9 @@ module Rigor
230
232
  Prism.parse(File.read(physical), filepath: path, version: @configuration.target_ruby)
231
233
  end
232
234
 
233
- # Mirrors {Runner#build_trust_policy}. Workers under Phase
234
- # 4b will need the same trust derivation, and the
235
- # configuration is already shareable, so deriving it inside
235
+ # Mirrors {Runner#build_trust_policy}. Deriving trust inside
236
236
  # the session keeps the substrate decoupled from the
237
- # coordinator's helper.
237
+ # coordinator; configuration is already Ractor-shareable.
238
238
  def build_trust_policy
239
239
  trusted_gems = @configuration.plugins.map { |entry| trusted_gem_name(entry) }.uniq
240
240
  roots = [Dir.pwd]
@@ -294,24 +294,30 @@ module Rigor
294
294
  )
295
295
  end
296
296
 
297
- def plugin_emitted_diagnostics(path, root, scope)
297
+ def plugin_emitted_diagnostics(path, root, scope, node_results)
298
298
  return [] if @plugin_registry.empty?
299
299
 
300
- # ADR-52 WD4 — single engine-owned node-rule walk per file; the
301
- # results are bucketed per plugin (registry order) so emission
302
- # stays plugin-major and byte-identical with the per-plugin walk.
303
- node_results = node_rule_results_by_plugin(path, root, scope)
304
-
305
300
  @plugin_registry.plugins.flat_map do |plugin|
306
301
  collect_plugin_diagnostics(plugin, path, root, scope, node_results[plugin])
307
302
  end
308
303
  end
309
304
 
310
- def node_rule_results_by_plugin(path, root, scope)
305
+ # ADR-52 WD4 + ADR-53 B4 — single engine-owned walk per file drives
306
+ # both the plugin node rules (bucketed per plugin in registry order,
307
+ # plugin-major emission) and the built-in node collectors
308
+ # (`node_collectors`, populated in place). Runs even with no node-rule
309
+ # plugins so the collectors still get driven (converged path).
310
+ def node_rule_results_by_plugin(path, root, scope, node_collectors, scope_index)
311
311
  walk = @plugin_registry.node_rule_walk
312
- return {}.compare_by_identity if walk.empty?
312
+ driver = node_collectors && CheckRules.node_collector_driver(node_collectors)
313
+ return {}.compare_by_identity if walk.empty? && driver.nil?
313
314
 
314
- results = walk.diagnostics_for_file(path: path, scope: scope, root: root)
315
+ results = walk.diagnostics_for_file(
316
+ path: path, scope: scope, root: root, collector_driver: driver
317
+ )
318
+ if ENV["RIGOR_SHADOW_RULE_WALK"]
319
+ CheckRules.shadow_verify_converged_collectors(path, root, scope_index, node_collectors)
320
+ end
315
321
  results.each_with_object({}.compare_by_identity) do |result, by_plugin|
316
322
  by_plugin[result.plugin] = result
317
323
  end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ # ADR-50 § WD2 — the bleeding-edge overlay.
5
+ #
6
+ # A Rigor-maintained set of the *next major's* queued changes —
7
+ # severity-map promotions and new-discipline rule enablements — that a
8
+ # user can adopt early, before they become default-on at a major
9
+ # (ADR-50 § WD7). It is orthogonal to `severity_profile:` (how loud
10
+ # *today's* rules are) and is versioned with the gem, NOT a
11
+ # user-supplied file: the inspectable counterpart to PHPStan's
12
+ # `bleedingEdge` include.
13
+ #
14
+ # The overlay is **empty today** — no discipline has yet been queued
15
+ # for the next major. This module is the WD2 *foundation* (the v0.1.19
16
+ # slice): the surface (`bleeding_edge:` config, the
17
+ # `rigor show-bleedingedge` command, the severity-composition hook in
18
+ # {Configuration::SeverityProfile.resolve}) exists and is wired
19
+ # end-to-end, so the first real feature lands as a single {FEATURES}
20
+ # entry with no engine plumbing.
21
+ #
22
+ # Each feature carries a **stable feature id** — part of the ADR-50
23
+ # WD1 contract vocabulary: the config, the `show` command, and the
24
+ # eventual CHANGELOG migration note all name the same id, and a
25
+ # feature graduates to default-on at a major by being removed from
26
+ # {FEATURES}.
27
+ module BleedingEdge
28
+ # One queued change.
29
+ #
30
+ # @!attribute id
31
+ # @return [String] the stable feature id (contract vocabulary).
32
+ # @!attribute summary
33
+ # @return [String] a one-line description of what it changes.
34
+ # @!attribute severity_overrides
35
+ # @return [Hash{String => Symbol}] canonical rule id → the
36
+ # severity this feature imposes. Composed *below* the user's own
37
+ # `severity_overrides:` and *above* the active `severity_profile`
38
+ # (see {Configuration::SeverityProfile.resolve}).
39
+ Feature = Data.define(:id, :summary, :severity_overrides) do
40
+ def to_h
41
+ {
42
+ "id" => id,
43
+ "summary" => summary,
44
+ "severity_overrides" => severity_overrides.transform_values(&:to_s)
45
+ }
46
+ end
47
+ end
48
+
49
+ # The overlay. Empty until the first next-major discipline is
50
+ # queued; add a {Feature} here (with a stable id) when one is.
51
+ FEATURES = [].freeze
52
+
53
+ module_function
54
+
55
+ # @return [Array<Feature>] the whole overlay.
56
+ def features
57
+ FEATURES
58
+ end
59
+
60
+ # @return [Array<String>] every feature id in the overlay.
61
+ def feature_ids
62
+ FEATURES.map(&:id)
63
+ end
64
+
65
+ # @param id [String]
66
+ # @return [Feature, nil]
67
+ def feature(id)
68
+ FEATURES.find { |f| f.id == id }
69
+ end
70
+
71
+ # Resolves a normalized `bleeding_edge:` selector (see
72
+ # {Configuration#bleeding_edge}) to the active {Feature} list.
73
+ # Unknown ids in a `list` / `except` selector are simply absent from
74
+ # the overlay and contribute nothing — symmetric with how
75
+ # `severity_overrides:` keeps an unknown rule id inert until it
76
+ # lands (robust across gem versions).
77
+ #
78
+ # @param selector [Hash] `{ "mode" => "none" }`,
79
+ # `{ "mode" => "all" }`, `{ "mode" => "all", "except" => [ids] }`,
80
+ # or `{ "mode" => "list", "ids" => [ids] }`.
81
+ # @return [Array<Feature>]
82
+ def active_features(selector)
83
+ case selector["mode"]
84
+ when "all"
85
+ except = selector["except"] || []
86
+ FEATURES.reject { |f| except.include?(f.id) }
87
+ when "list"
88
+ ids = selector["ids"] || []
89
+ FEATURES.select { |f| ids.include?(f.id) }
90
+ else
91
+ []
92
+ end
93
+ end
94
+
95
+ # The merged severity-override map the active features impose for a
96
+ # selector. Frozen so the result is `Ractor.shareable?`.
97
+ #
98
+ # @param selector [Hash] see {#active_features}.
99
+ # @return [Hash{String => Symbol}]
100
+ def severity_overrides_for(selector)
101
+ active_features(selector).each_with_object({}) do |feature, acc|
102
+ acc.merge!(feature.severity_overrides)
103
+ end.freeze
104
+ end
105
+
106
+ # Feature ids named by a selector that are NOT in the overlay
107
+ # (typo / graduated / from a newer gem). Surfaced by
108
+ # `rigor show-bleedingedge` as a hint; never an error.
109
+ #
110
+ # @param selector [Hash] see {#active_features}.
111
+ # @return [Array<String>]
112
+ def unknown_selected_ids(selector)
113
+ named =
114
+ case selector["mode"]
115
+ when "list" then selector["ids"] || []
116
+ when "all" then selector["except"] || []
117
+ else []
118
+ end
119
+ known = feature_ids
120
+ named.reject { |id| known.include?(id) }
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../type"
4
+
5
+ module Rigor
6
+ module Builtins
7
+ # Refined types for predefined Ruby / stdlib constants whose upstream
8
+ # RBS signatures are broader than the constants' documented runtime
9
+ # invariants.
10
+ #
11
+ # Resolution is two-tiered:
12
+ #
13
+ # **Tier 1 — exact-value whitelist** (`FOLDED_CONSTANTS`):
14
+ # Constants whose value is bit-for-bit identical across every Ruby
15
+ # version and platform are folded to `Constant[T]`: the `Math::PI`
16
+ # / `Math::E` math constants (C's `M_PI` / `M_E`) and the four
17
+ # IEEE 754 binary64 magnitude constants `Float::INFINITY` /
18
+ # `::MAX` / `::MIN` / `::EPSILON` (each a single format-mandated bit
19
+ # pattern). Add new entries only when the value is truly
20
+ # cross-implementation invariant AND compares reflexively under
21
+ # `==` — the latter is why `Float::NAN` is deliberately EXCLUDED:
22
+ # `NaN == NaN` is `false`, so a `Constant[NAN]` would violate the
23
+ # `Type::Constant` `==` / `eql?` / `hash` contract (it would hash
24
+ # equal to itself yet compare unequal), corrupting type-equality
25
+ # and union dedup. The binary64 *integer* shape parameters
26
+ # (`Float::DIG` / `MANT_DIG` / `MAX_EXP` / …) are intentionally NOT
27
+ # folded: upstream RBS hedges them as "Usually defaults to …", and
28
+ # as plain `Integer`s they fall through Tier 2 to the RBS type
29
+ # harmlessly. `Complex::I` is deferred (no complex-fold consumer).
30
+ #
31
+ # **Tier 2 — runtime String inspection**:
32
+ # For any other constant, the module resolves it via `const_get`
33
+ # against the analyzer's own Ruby runtime. Core / stdlib constants
34
+ # (e.g. `RUBY_VERSION`, `RUBY_PLATFORM`) are always loaded into the
35
+ # analyzer process; project-defined constants are not (they live only
36
+ # in ASTs), so their `const_get` raises `NameError` and the lookup
37
+ # falls through to the RBS type tier.
38
+ #
39
+ # For a successfully resolved `String` value:
40
+ # - empty string → no refinement (fall through to RBS `String`)
41
+ # - a Ruby numeric literal → `numeric-string`
42
+ # - non-empty otherwise → `non-empty-string`
43
+ #
44
+ # **Exclusion set** (`RUNTIME_INSPECTION_EXCLUDED`):
45
+ # String constants that appear non-empty in the current runtime but
46
+ # are documented to be potentially empty in some build configuration
47
+ # or alternative implementation. Exclusions are populated by
48
+ # scanning Ruby's C source (version.c, etc.) and RBS comments for
49
+ # any constant whose documentation says "may be empty" or
50
+ # "platform-specific default". None are known today; the set
51
+ # exists as a safety net.
52
+ #
53
+ # This module is consulted by `Environment#constant_for_name` BEFORE
54
+ # the RBS constant-type table (widest types) but AFTER in-source
55
+ # constant writes (the user's own `Math::PI = 0.0` takes precedence
56
+ # via the lexical-candidate walk in `ExpressionTyper`).
57
+ module PredefinedConstantRefinements
58
+ # --- tier 1 -------------------------------------------------------
59
+
60
+ # Exact-value fold whitelist. Keys are unqualified constant paths
61
+ # (no leading "::") matching what `Environment#constant_for_name`
62
+ # receives.
63
+ FOLDED_CONSTANTS = {
64
+ # Math module — IEEE 754 bit-identical across all MRI / JRuby /
65
+ # TruffleRuby builds; folding enables precise constant arithmetic.
66
+ "Math::PI" => Type::Combinator.constant_of(::Math::PI).freeze,
67
+ "Math::E" => Type::Combinator.constant_of(::Math::E).freeze,
68
+
69
+ # Float magnitude limits — each a single format-mandated IEEE 754
70
+ # binary64 bit pattern (`+Inf`, `DBL_MAX`, `DBL_MIN`,
71
+ # `DBL_EPSILON`), reflexive under `==`. `Float::NAN` is excluded
72
+ # (non-reflexive `==` — see the module-level note).
73
+ "Float::INFINITY" => Type::Combinator.constant_of(::Float::INFINITY).freeze,
74
+ "Float::MAX" => Type::Combinator.constant_of(::Float::MAX).freeze,
75
+ "Float::MIN" => Type::Combinator.constant_of(::Float::MIN).freeze,
76
+ "Float::EPSILON" => Type::Combinator.constant_of(::Float::EPSILON).freeze
77
+ }.freeze
78
+ private_constant :FOLDED_CONSTANTS
79
+
80
+ # --- tier 2 -------------------------------------------------------
81
+
82
+ # String constants whose runtime value is non-empty in the current
83
+ # Ruby but that should NOT be narrowed because they are documented
84
+ # to be potentially empty in some build or implementation.
85
+ #
86
+ # Methodology: grep Ruby's version.c and similar C sources, and the
87
+ # RBS comment corpus, for any constant annotated with "may be empty"
88
+ # or "platform-specific default". Add the full qualified path
89
+ # (without leading "::") when a genuine risk is found.
90
+ RUNTIME_INSPECTION_EXCLUDED = Set[].freeze
91
+ private_constant :RUNTIME_INSPECTION_EXCLUDED
92
+
93
+ NON_EMPTY_STRING = Type::Combinator.non_empty_string.freeze
94
+ NUMERIC_STRING = Type::Combinator.numeric_string.freeze
95
+ private_constant :NON_EMPTY_STRING, :NUMERIC_STRING
96
+
97
+ # --- public API ---------------------------------------------------
98
+
99
+ # @param name [String] unqualified constant name (e.g. `"Math::PI"`,
100
+ # `"RUBY_VERSION"`, `"Ruby::ENGINE"`)
101
+ # @return [Rigor::Type, nil] refined type, or nil to fall through
102
+ def self.lookup(name)
103
+ FOLDED_CONSTANTS[name] || inspect_runtime_string(name)
104
+ end
105
+
106
+ # --- private ------------------------------------------------------
107
+
108
+ # Resolves `name` via `const_get` in the analyzer's runtime and
109
+ # returns a refined String carrier, or nil.
110
+ def self.inspect_runtime_string(name)
111
+ return nil if RUNTIME_INSPECTION_EXCLUDED.include?(name)
112
+
113
+ mod = ::Object
114
+ name.split("::").each do |part|
115
+ # Resolve only constants already present — never let analysing a
116
+ # reference drive the analyzer's own runtime to autoload or run a
117
+ # `const_missing` hook. A `Digest::UUID` reference in project code
118
+ # otherwise makes `const_get` trigger `Digest.const_missing` →
119
+ # `require "digest/uuid"`, and a missing optional library raises
120
+ # `LoadError` (a `ScriptError`, not the `NameError` the const_get
121
+ # walk expects), which would abort the whole run rather than fall
122
+ # through to the RBS tier. `const_defined?(part, false)` answers
123
+ # the same "is this resolvable here" question without the side
124
+ # effect — a project-defined constant (the common case) is simply
125
+ # absent and returns nil, no exception raised.
126
+ return nil unless mod.is_a?(::Module) && mod.const_defined?(part, false)
127
+
128
+ mod = mod.const_get(part, false)
129
+ end
130
+
131
+ return nil unless mod.is_a?(::String) && !mod.empty?
132
+
133
+ classify_string(mod)
134
+ rescue ::NameError, ::TypeError, ::LoadError
135
+ nil
136
+ end
137
+ private_class_method :inspect_runtime_string
138
+
139
+ # @param value [String] a non-empty string
140
+ # @return [Rigor::Type]
141
+ def self.classify_string(value)
142
+ if Type::Refined.ruby_numeric_literal?(value)
143
+ NUMERIC_STRING
144
+ else
145
+ NON_EMPTY_STRING
146
+ end
147
+ end
148
+ private_class_method :classify_string
149
+ end
150
+ end
151
+ end