rigortype 0.2.9 → 0.3.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 (184) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/docs/handbook/03-narrowing.md +2 -1
  4. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  5. data/docs/handbook/06-classes.md +18 -9
  6. data/docs/handbook/08-understanding-errors.md +1 -0
  7. data/docs/handbook/11-sig-gen.md +11 -1
  8. data/docs/handbook/appendix-mypy.md +4 -3
  9. data/docs/install.md +14 -6
  10. data/docs/manual/01-installation.md +98 -4
  11. data/docs/manual/02-cli-reference.md +49 -12
  12. data/docs/manual/03-configuration.md +34 -2
  13. data/docs/manual/04-diagnostics.md +23 -0
  14. data/docs/manual/07-plugins.md +4 -4
  15. data/docs/manual/09-editor-integration.md +4 -4
  16. data/docs/manual/11-ci.md +51 -0
  17. data/docs/manual/12-caching.md +65 -0
  18. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  19. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  20. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  21. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  22. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  23. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  24. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  25. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  26. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  27. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  28. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  29. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +93 -0
  30. data/lib/rigor/analysis/check_rules.rb +557 -95
  31. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  32. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  33. data/lib/rigor/analysis/incremental_session.rb +456 -51
  34. data/lib/rigor/analysis/path_expansion.rb +42 -0
  35. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  36. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  37. data/lib/rigor/analysis/run_cache_key.rb +68 -0
  38. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  39. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  40. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  41. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  42. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  43. data/lib/rigor/analysis/runner.rb +343 -68
  44. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  45. data/lib/rigor/analysis/worker_session.rb +30 -2
  46. data/lib/rigor/bleeding_edge.rb +37 -8
  47. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  48. data/lib/rigor/cache/descriptor.rb +88 -28
  49. data/lib/rigor/cache/file_digest.rb +158 -0
  50. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  51. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  52. data/lib/rigor/cache/store.rb +33 -3
  53. data/lib/rigor/ci_detector.rb +90 -0
  54. data/lib/rigor/cli/annotate_command.rb +15 -9
  55. data/lib/rigor/cli/baseline_command.rb +4 -1
  56. data/lib/rigor/cli/check_command.rb +212 -27
  57. data/lib/rigor/cli/coverage_command.rb +6 -1
  58. data/lib/rigor/cli/docs_command.rb +0 -30
  59. data/lib/rigor/cli/doctor_command.rb +163 -23
  60. data/lib/rigor/cli/lsp_command.rb +5 -0
  61. data/lib/rigor/cli/mcp_command.rb +5 -0
  62. data/lib/rigor/cli/plugins_command.rb +36 -6
  63. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  64. data/lib/rigor/cli/probe_environment.rb +85 -0
  65. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  66. data/lib/rigor/cli/skill_command.rb +1 -32
  67. data/lib/rigor/cli/trace_command.rb +5 -6
  68. data/lib/rigor/cli/type_of_command.rb +9 -8
  69. data/lib/rigor/cli/type_scan_command.rb +8 -8
  70. data/lib/rigor/cli.rb +15 -4
  71. data/lib/rigor/config_audit.rb +48 -2
  72. data/lib/rigor/configuration/severity_profile.rb +31 -0
  73. data/lib/rigor/configuration.rb +156 -8
  74. data/lib/rigor/environment/default_libraries.rb +35 -0
  75. data/lib/rigor/environment/rbs_loader.rb +164 -5
  76. data/lib/rigor/environment.rb +19 -27
  77. data/lib/rigor/inference/budget_trace.rb +77 -2
  78. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  79. data/lib/rigor/inference/def_handle.rb +23 -0
  80. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  81. data/lib/rigor/inference/def_return_typer.rb +2 -1
  82. data/lib/rigor/inference/expression_typer.rb +361 -113
  83. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  84. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  85. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  86. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  87. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +11 -0
  88. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  89. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  90. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  91. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  92. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  93. data/lib/rigor/inference/mutation_widening.rb +42 -8
  94. data/lib/rigor/inference/narrowing.rb +127 -17
  95. data/lib/rigor/inference/parameter_inference_collector.rb +15 -0
  96. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  97. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  98. data/lib/rigor/inference/scope_indexer.rb +451 -36
  99. data/lib/rigor/inference/statement_evaluator.rb +64 -15
  100. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  101. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  102. data/lib/rigor/inference/void_origin.rb +25 -0
  103. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  104. data/lib/rigor/language_server/completion_provider.rb +2 -1
  105. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  106. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  107. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  108. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  109. data/lib/rigor/plugin/base.rb +9 -22
  110. data/lib/rigor/plugin/inflector.rb +12 -3
  111. data/lib/rigor/plugin/io_boundary.rb +5 -1
  112. data/lib/rigor/plugin/isolation.rb +81 -11
  113. data/lib/rigor/plugin/load_error.rb +10 -1
  114. data/lib/rigor/plugin/loader.rb +96 -14
  115. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  116. data/lib/rigor/plugin/registry.rb +32 -23
  117. data/lib/rigor/plugin.rb +26 -0
  118. data/lib/rigor/protection/mutator.rb +3 -2
  119. data/lib/rigor/reflection.rb +64 -0
  120. data/lib/rigor/runtime/jit.rb +128 -0
  121. data/lib/rigor/scope/discovery_index.rb +12 -2
  122. data/lib/rigor/scope.rb +122 -28
  123. data/lib/rigor/sig_gen/classification.rb +5 -1
  124. data/lib/rigor/sig_gen/generator.rb +38 -6
  125. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  126. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  127. data/lib/rigor/sig_gen/renderer.rb +10 -0
  128. data/lib/rigor/sig_gen/write_result.rb +9 -4
  129. data/lib/rigor/sig_gen/writer.rb +24 -2
  130. data/lib/rigor/source/node_children.rb +116 -0
  131. data/lib/rigor/source/node_locator.rb +3 -1
  132. data/lib/rigor/source/node_walker.rb +4 -2
  133. data/lib/rigor/source.rb +1 -0
  134. data/lib/rigor/type/difference.rb +28 -24
  135. data/lib/rigor/type/hash_shape.rb +34 -10
  136. data/lib/rigor/version.rb +1 -1
  137. data/lib/rigortype.rb +24 -0
  138. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  139. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  140. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  141. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  142. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +3 -1
  143. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -2
  144. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  145. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  146. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  147. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  148. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  149. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  150. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  151. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  152. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  153. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  154. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +3 -1
  155. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  156. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +7 -5
  157. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  158. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  159. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  160. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  161. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  162. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  164. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  166. data/sig/prism_node_children.rbs +9 -0
  167. data/sig/rigor/inference.rbs +7 -0
  168. data/sig/rigor/plugin/base.rbs +2 -3
  169. data/sig/rigor/reflection.rbs +1 -0
  170. data/sig/rigor/scope.rbs +12 -1
  171. data/skills/rigor-ci-setup/SKILL.md +10 -0
  172. data/skills/rigor-editor-setup/SKILL.md +11 -0
  173. data/skills/rigor-next-steps/SKILL.md +23 -2
  174. data/skills/rigor-plugin-author/SKILL.md +3 -3
  175. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  176. data/skills/rigor-plugin-review/SKILL.md +2 -2
  177. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  178. data/skills/rigor-project-init/SKILL.md +29 -3
  179. data/skills/rigor-project-init/references/01-detect.md +1 -1
  180. data/skills/rigor-project-init/references/02-configure.md +39 -5
  181. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  182. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  183. metadata +42 -24
  184. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -5,6 +5,9 @@ require "prism"
5
5
  require_relative "../scope"
6
6
  require_relative "../type"
7
7
  require_relative "../source/constant_path"
8
+ require_relative "../source/node_children"
9
+ require_relative "../cache/file_digest"
10
+ require_relative "def_handle"
8
11
  require_relative "mutation_widening"
9
12
  require_relative "narrowing"
10
13
  require_relative "statement_evaluator"
@@ -310,7 +313,7 @@ module Rigor
310
313
  end
311
314
  end
312
315
 
313
- def walk_class_ivars(node, qualified_prefix, default_scope, accumulator, mutated_ivars, # rubocop:disable Metrics/CyclomaticComplexity,Metrics/ParameterLists
316
+ def walk_class_ivars(node, qualified_prefix, default_scope, accumulator, mutated_ivars, # rubocop:disable Metrics/ParameterLists
314
317
  read_before_write = nil, init_writes = nil, method_assign_effects = nil)
315
318
  return unless node.is_a?(Prism::Node)
316
319
 
@@ -346,7 +349,7 @@ module Rigor
346
349
  end
347
350
  end
348
351
 
349
- node.compact_child_nodes.each do |child|
352
+ node.rigor_each_child do |child|
350
353
  walk_class_ivars(child, qualified_prefix, default_scope, accumulator,
351
354
  mutated_ivars, read_before_write, init_writes, method_assign_effects)
352
355
  end
@@ -511,7 +514,7 @@ module Rigor
511
514
  (init_writes[class_name] ||= Set.new) << node.name
512
515
  end
513
516
 
514
- node.compact_child_nodes.each do |child|
517
+ node.rigor_each_child do |child|
515
518
  collect_class_body_ivar_writes(child, class_name, init_writes)
516
519
  end
517
520
  end
@@ -522,16 +525,49 @@ module Rigor
522
525
 
523
526
  read_first << node.name if node.is_a?(Prism::InstanceVariableReadNode) && !seen_writes.include?(node.name)
524
527
 
528
+ # N1 — parallel / multiple assignment (`@m, @n = [], []`). The ivar targets are `InstanceVariableTargetNode`s,
529
+ # not `InstanceVariableWriteNode`s, so the generic descent below never records them as writes. Left unhandled,
530
+ # an ivar written only via massign in `initialize` stays absent from `init_writes`, and
531
+ # `contribute_read_before_write_nil!` then unions a spurious `nil` into its class-ivar seed — masking a
532
+ # genuinely-typed `@m` (e.g. `Tuple[]`) as `T | nil` at every sibling read. The RHS runs before any target is
533
+ # committed, so descend into `value` FIRST (an ivar read there is read-before-write), then mark every ivar
534
+ # target as written.
535
+ if node.is_a?(Prism::MultiWriteNode)
536
+ detect_read_before_write(node.value, seen_writes, read_first) if node.value
537
+ detect_multi_write_target_writes(node, seen_writes, read_first)
538
+ return
539
+ end
540
+
525
541
  # Descend BEFORE recording a write — `@x = @x + 1`'s RHS is an `InstanceVariableReadNode` that runs before the
526
- # write is committed; the read is therefore read-before-write semantically. Prism's `compact_child_nodes`
527
- # returns the value child before the lvalue target, matching this order.
528
- node.compact_child_nodes.each do |c|
542
+ # write is committed; the read is therefore read-before-write semantically. `each_child` yields the value
543
+ # child before the lvalue target (`compact_child_nodes` field order), matching this order.
544
+ node.rigor_each_child do |c|
529
545
  detect_read_before_write(c, seen_writes, read_first)
530
546
  end
531
547
 
532
548
  seen_writes << node.name if IVAR_WRITE_NODES.any? { |klass| node.is_a?(klass) }
533
549
  end
534
550
 
551
+ # Records each ivar target of a `MultiWriteNode` / nested `MultiTargetNode` into `seen_writes`, and descends into
552
+ # any non-ivar target (a `CallTargetNode` / `IndexTargetNode` receiver such as `@obj.x, @y = …`) so an ivar read
553
+ # inside a target receiver still counts as read-before-write.
554
+ def detect_multi_write_target_writes(node, seen_writes, read_first)
555
+ targets = (node.lefts || []) + [node.rest].compact + (node.rights || [])
556
+ targets.each do |target|
557
+ case target
558
+ when Prism::InstanceVariableTargetNode
559
+ seen_writes << target.name
560
+ when Prism::MultiTargetNode
561
+ detect_multi_write_target_writes(target, seen_writes, read_first)
562
+ when Prism::SplatNode
563
+ inner = target.expression
564
+ seen_writes << inner.name if inner.is_a?(Prism::InstanceVariableTargetNode)
565
+ else
566
+ detect_read_before_write(target, seen_writes, read_first)
567
+ end
568
+ end
569
+ end
570
+
535
571
  IVAR_BARRIER_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode].freeze
536
572
  private_constant :IVAR_BARRIER_NODES
537
573
 
@@ -568,7 +604,7 @@ module Rigor
568
604
  return
569
605
  end
570
606
 
571
- node.compact_child_nodes.each do |c|
607
+ node.rigor_each_child do |c|
572
608
  gather_ivar_writes(c, scope, class_name, accumulator, guarded_ivars, mutated_ivars, dead_writes)
573
609
  end
574
610
  end
@@ -742,7 +778,7 @@ module Rigor
742
778
  return acc
743
779
  end
744
780
 
745
- root.compact_child_nodes.each { |c| collect_class_method_defs(c, prefix, acc) }
781
+ root.rigor_each_child { |c| collect_class_method_defs(c, prefix, acc) }
746
782
  acc
747
783
  end
748
784
 
@@ -773,7 +809,7 @@ module Rigor
773
809
  return acc unless node.is_a?(Prism::Node)
774
810
 
775
811
  acc << node.name if node.is_a?(Prism::InstanceVariableWriteNode) && !nil_literal_value?(node.value)
776
- node.compact_child_nodes.each { |c| ivar_write_targets(c, acc) }
812
+ node.rigor_each_child { |c| ivar_write_targets(c, acc) }
777
813
  acc
778
814
  end
779
815
 
@@ -1106,7 +1142,7 @@ module Rigor
1106
1142
  return
1107
1143
  end
1108
1144
 
1109
- node.compact_child_nodes.each do |child|
1145
+ node.rigor_each_child do |child|
1110
1146
  walk_class_cvars(child, qualified_prefix, default_scope, accumulator)
1111
1147
  end
1112
1148
  end
@@ -1125,7 +1161,7 @@ module Rigor
1125
1161
  record_cvar_write(node, scope, class_name, accumulator) if node.is_a?(Prism::ClassVariableWriteNode)
1126
1162
  return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
1127
1163
 
1128
- node.compact_child_nodes.each { |c| gather_cvar_writes(c, scope, class_name, accumulator) }
1164
+ node.rigor_each_child { |c| gather_cvar_writes(c, scope, class_name, accumulator) }
1129
1165
  end
1130
1166
 
1131
1167
  def record_cvar_write(node, scope, class_name, accumulator)
@@ -1149,7 +1185,7 @@ module Rigor
1149
1185
  return unless node.is_a?(Prism::Node)
1150
1186
 
1151
1187
  record_global_write(node, scope, accumulator) if node.is_a?(Prism::GlobalVariableWriteNode)
1152
- node.compact_child_nodes.each { |c| gather_global_writes(c, scope, accumulator) }
1188
+ node.rigor_each_child { |c| gather_global_writes(c, scope, accumulator) }
1153
1189
  end
1154
1190
 
1155
1191
  def record_global_write(node, scope, accumulator)
@@ -1189,7 +1225,7 @@ module Rigor
1189
1225
  return
1190
1226
  end
1191
1227
 
1192
- node.compact_child_nodes.each do |child|
1228
+ node.rigor_each_child do |child|
1193
1229
  walk_constant_writes(child, qualified_prefix, default_scope, accumulator)
1194
1230
  end
1195
1231
  end
@@ -1249,7 +1285,7 @@ module Rigor
1249
1285
  end
1250
1286
  end
1251
1287
 
1252
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize, Metrics/PerceivedComplexity
1288
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize
1253
1289
  # Combined `walk_methods` + `walk_def_nodes` descent. The two walks had identical class / module / singleton-class
1254
1290
  # / meta-block traversals and both stopped at `DefNode`; the only divergences are leaf actions (recorded into the
1255
1291
  # right accumulator) and the original `walk_methods` returning at `AliasMethodNode` (its symbol-only children
@@ -1295,7 +1331,7 @@ module Rigor
1295
1331
  end
1296
1332
  end
1297
1333
 
1298
- node.compact_child_nodes.each do |child|
1334
+ node.rigor_each_child do |child|
1299
1335
  walk_methods_and_def_nodes(child, qualified_prefix, in_singleton_class, methods_acc, def_nodes_acc)
1300
1336
  end
1301
1337
  end
@@ -1323,7 +1359,7 @@ module Rigor
1323
1359
  end
1324
1360
  end
1325
1361
  end
1326
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize, Metrics/PerceivedComplexity
1362
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize
1327
1363
 
1328
1364
  # v0.1.2 — when a `Const = Data.define(*sym) do ... end` / `Const = Struct.new(*sym) do ... end` constant write
1329
1365
  # carries a block, the block body holds method overrides whose canonical class is `Const`. Survey item (e)
@@ -1474,7 +1510,7 @@ module Rigor
1474
1510
  return
1475
1511
  end
1476
1512
 
1477
- node.compact_child_nodes.each do |child|
1513
+ node.rigor_each_child do |child|
1478
1514
  walk_singleton_def_nodes(child, qualified_prefix, in_singleton_class, accumulator)
1479
1515
  end
1480
1516
  end
@@ -1586,7 +1622,7 @@ module Rigor
1586
1622
  end
1587
1623
  end
1588
1624
 
1589
- node.compact_child_nodes.each do |child|
1625
+ node.rigor_each_child do |child|
1590
1626
  walk_class_superclasses(child, qualified_prefix, accumulator)
1591
1627
  end
1592
1628
  end
@@ -1623,7 +1659,7 @@ module Rigor
1623
1659
  record_data_member_layout(accumulator, qualified_prefix + [node.name.to_s], node.value)
1624
1660
  end
1625
1661
 
1626
- node.compact_child_nodes.each do |child|
1662
+ node.rigor_each_child do |child|
1627
1663
  walk_data_member_layouts(child, qualified_prefix, accumulator)
1628
1664
  end
1629
1665
  end
@@ -1670,7 +1706,7 @@ module Rigor
1670
1706
  record_struct_member_layout(accumulator, qualified_prefix + [node.name.to_s], node.value)
1671
1707
  end
1672
1708
 
1673
- node.compact_child_nodes.each do |child|
1709
+ node.rigor_each_child do |child|
1674
1710
  walk_struct_member_layouts(child, qualified_prefix, accumulator)
1675
1711
  end
1676
1712
  end
@@ -1731,7 +1767,7 @@ module Rigor
1731
1767
  record_mixin_call(node, current_class, accumulator)
1732
1768
  end
1733
1769
 
1734
- node.compact_child_nodes.each do |child|
1770
+ node.rigor_each_child do |child|
1735
1771
  walk_class_includes(child, qualified_prefix, current_class, accumulator)
1736
1772
  end
1737
1773
  end
@@ -1772,7 +1808,7 @@ module Rigor
1772
1808
  accumulator.transform_values(&:freeze).freeze
1773
1809
  end
1774
1810
 
1775
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize
1811
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize
1776
1812
  def walk_method_visibilities(node, qualified_prefix, in_singleton_class, current_visibility, accumulator)
1777
1813
  return current_visibility unless node.is_a?(Prism::Node)
1778
1814
 
@@ -1810,18 +1846,18 @@ module Rigor
1810
1846
  # entry visibility unchanged.
1811
1847
  if node.is_a?(Prism::StatementsNode)
1812
1848
  local_visibility = current_visibility
1813
- node.compact_child_nodes.each do |child|
1849
+ node.rigor_each_child do |child|
1814
1850
  local_visibility = walk_method_visibilities(child, qualified_prefix, in_singleton_class,
1815
1851
  local_visibility, accumulator)
1816
1852
  end
1817
1853
  else
1818
- node.compact_child_nodes.each do |child|
1854
+ node.rigor_each_child do |child|
1819
1855
  walk_method_visibilities(child, qualified_prefix, in_singleton_class, current_visibility, accumulator)
1820
1856
  end
1821
1857
  end
1822
1858
  current_visibility
1823
1859
  end
1824
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize
1860
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize
1825
1861
 
1826
1862
  def record_def_visibility(def_node, qualified_prefix, in_singleton_class, current_visibility, accumulator)
1827
1863
  return if def_node.receiver.is_a?(Prism::SelfNode) || in_singleton_class
@@ -1922,7 +1958,7 @@ module Rigor
1922
1958
  return accumulator
1923
1959
  end
1924
1960
 
1925
- node.compact_child_nodes.each { |child| collect_class_alias_map(child, qualified_prefix, accumulator) }
1961
+ node.rigor_each_child { |child| collect_class_alias_map(child, qualified_prefix, accumulator) }
1926
1962
  accumulator
1927
1963
  end
1928
1964
 
@@ -2031,9 +2067,7 @@ module Rigor
2031
2067
  # @return [Hash{Symbol => Hash}]
2032
2068
  # `{ def_nodes:, def_sources:, superclasses:, includes:, class_sources: }`
2033
2069
  def discovered_def_index_for_paths(paths, buffer: nil)
2034
- acc = { def_nodes: {}, singleton_def_nodes: {}, def_sources: {}, superclasses: {}, includes: {},
2035
- method_visibilities: {}, methods: {}, class_sources: {}, data_member_layouts: {},
2036
- struct_member_layouts: {} }
2070
+ acc = new_def_index_accumulator
2037
2071
  paths.each do |path|
2038
2072
  physical = buffer ? buffer.resolve(path) : path
2039
2073
  root = Prism.parse(File.read(physical), filepath: path).value
@@ -2042,13 +2076,391 @@ module Rigor
2042
2076
  # Skip files that fail to parse or read; the per-file analyzer surfaces the parse error separately.
2043
2077
  next
2044
2078
  end
2079
+ finalize_def_index(acc)
2080
+ end
2081
+
2082
+ # B1 / ADR-89 WD1 (incremental propagation gates) — parses `paths` ONCE and returns the merged def-index
2083
+ # (as {#discovered_def_index_for_paths}), each file's comment-stripped {#code_fingerprint} (B1), AND each
2084
+ # file's {#declaration_signature} (ADR-89 WD1 — the per-def SIGNATURE shape that replaces the per-def body
2085
+ # fingerprint, so a body edit that leaves every signature equal is declaration-stable). The incremental
2086
+ # session drives the fingerprint / class-declaration change detection off `def_index` and the
2087
+ # declaration-stability skip decision off `declaration_signatures`, so a changed file is parsed once for
2088
+ # all of them (recon §2 dedup). A per-file live index (built + folded, exactly as
2089
+ # {#discovered_project_index_incremental}'s changed-file branch) yields the live def nodes the signature
2090
+ # reads their parameter structure from.
2091
+ # @return [Hash{Symbol => Object}] `{ def_index:, code_fingerprints:, declaration_signatures: }`.
2092
+ def scan_summary_for_paths(paths, buffer: nil)
2093
+ acc = new_def_index_accumulator
2094
+ code_fingerprints = {}
2095
+ declaration_signatures = {}
2096
+ paths.each do |path|
2097
+ physical = buffer ? buffer.resolve(path) : path
2098
+ source = File.read(physical)
2099
+ parsed = Prism.parse(source, filepath: path)
2100
+ file_index = build_file_index(path, parsed.value)
2101
+ fold_file_index(acc, file_index)
2102
+ code_fingerprints[path] = code_fingerprint(source, parsed.comments)
2103
+ declaration_signatures[path] = declaration_signature(file_index)
2104
+ rescue StandardError
2105
+ next
2106
+ end
2107
+ { def_index: finalize_def_index(acc), code_fingerprints: code_fingerprints,
2108
+ declaration_signatures: declaration_signatures }
2109
+ end
2110
+
2111
+ # ADR-89 WD1 — a per-file digest of every cross-file DECLARATION surface an ancestry / file-level
2112
+ # dependent consumes, EXCLUDING method bodies (which only symbol dependents consume, via the ADR-46
2113
+ # symbol edges the change-detection already fingerprints per method). A body edit that touches no
2114
+ # signature leaves this equal, so the session drops the file's ancestry / file-level dependents; an
2115
+ # arity / visibility / added-or-removed-method / ancestry / member-layout edit moves it. Deliberately
2116
+ # SYNTACTIC (parameter structure from the def node, not inferred types).
2117
+ #
2118
+ # It captures, from a single file's live def-index: declared class/module names, superclass + include
2119
+ # ancestry, Data/Struct member layouts, the accessor/alias/define_method existence table, per-method
2120
+ # visibilities, and per method (instance + singleton) its name, parameter signature, AND def-start LINE.
2121
+ # The line is load-bearing for soundness: a body edit that SHIFTS a later def's line moves the ADR-17
2122
+ # `project_definition_site` a `call.undefined-method` consumer embeds, so a line shift must invalidate
2123
+ # the declaration (the file's dependents re-check) — this is why the signature is a superset of B1's
2124
+ # comment-stripped code fingerprint on line-moving edits, and strictly more permissive only on
2125
+ # same-line body edits (a local rename, an internal literal), which is the collapse WD1 exists for.
2126
+ def declaration_signature(file_index)
2127
+ parts = []
2128
+ append_ancestry_signature(parts, file_index)
2129
+ append_declaration_tables(parts, file_index)
2130
+ append_def_signatures(parts, file_index[:def_nodes], "#")
2131
+ append_def_signatures(parts, file_index[:singleton_def_nodes], ".")
2132
+ Digest::SHA256.hexdigest(parts.join("\x00"))
2133
+ end
2134
+
2135
+ # The class-declaration + ancestry + member-layout surface of the declaration signature (declared class
2136
+ # names, superclass / include ancestry, Data/Struct member layouts). Kept out of
2137
+ # {#declaration_signature} to hold its ABC budget.
2138
+ def append_ancestry_signature(parts, file_index)
2139
+ parts.concat(file_index[:class_sources].keys.sort_by(&:to_s).map { |cn| "c:#{cn}" })
2140
+ parts.concat(sorted_by_class(file_index[:superclasses]).map { |cn, sc| "s:#{cn}<#{sc}" })
2141
+ parts.concat(sorted_by_class(file_index[:includes]).map do |cn, mods|
2142
+ "i:#{cn}=#{Array(mods).map(&:to_s).sort.join(',')}"
2143
+ end)
2144
+ parts.concat(sorted_by_class(file_index[:data_member_layouts]).map { |cn, l| "d:#{cn}=#{l.inspect}" })
2145
+ parts.concat(sorted_by_class(file_index[:struct_member_layouts]).map { |cn, l| "t:#{cn}=#{l.inspect}" })
2146
+ end
2147
+
2148
+ # A class-keyed table's pairs sorted by class name (stringified) for a deterministic signature.
2149
+ def sorted_by_class(table)
2150
+ table.sort_by { |cn, _| cn.to_s }
2151
+ end
2152
+
2153
+ # The method-existence and visibility surfaces of the declaration signature (kept out of
2154
+ # {#declaration_signature} to hold its ABC budget). Both are consumed cross-file — the existence table
2155
+ # by undefined-method suppression, the visibilities by the ADR-35 override-visibility rule.
2156
+ def append_declaration_tables(parts, file_index)
2157
+ file_index[:methods].sort_by { |cn, _| cn.to_s }.each do |cn, table|
2158
+ table.sort_by { |m, _| m.to_s }.each { |m, kind| parts << "e:#{cn}##{m}=#{kind}" }
2159
+ end
2160
+ file_index[:method_visibilities].sort_by { |cn, _| cn.to_s }.each do |cn, table|
2161
+ table.sort_by { |m, _| m.to_s }.each { |m, vis| parts << "v:#{cn}##{m}=#{vis}" }
2162
+ end
2163
+ end
2164
+
2165
+ # Appends one def-node table's per-method signature entries (name + parameter structure + def-start
2166
+ # line) under `separator` (`#` instance / `.` singleton). Nodes here are always LIVE (both call sites —
2167
+ # {#scan_summary_for_paths} and {#build_seed_bundle} — pass a freshly parsed single-file index), so the
2168
+ # parameter structure and location are read directly.
2169
+ def append_def_signatures(parts, defs, separator)
2170
+ defs.sort_by { |cn, _| cn.to_s }.each do |class_name, methods|
2171
+ methods.sort_by { |m, _| m.to_s }.each do |method_name, node|
2172
+ parts << "m:#{class_name}#{separator}#{method_name}#{parameter_signature(node)}@#{def_start_line(node)}"
2173
+ end
2174
+ end
2175
+ end
2176
+
2177
+ # ADR-89 WD1 — a compact, order-preserving descriptor of a def node's parameter STRUCTURE: each
2178
+ # parameter's kind (required / optional / rest / post / keyword-required / keyword-optional /
2179
+ # keyword-rest / block / forwarding) and name, plus default-PRESENCE (implied by the optional kinds).
2180
+ # Names are included for all parameter positions: soundness-first (a keyword-name change is Liskov-
2181
+ # visible to overriders; a positional-name change is rare and only over-conservatively keeps a
2182
+ # dependent). Not the inferred parameter TYPE — this gate is deliberately syntactic.
2183
+ def parameter_signature(node)
2184
+ return "()" unless node.respond_to?(:parameters)
2185
+
2186
+ params = node.parameters
2187
+ return "()" if params.nil?
2188
+
2189
+ parts = []
2190
+ parts.concat(labelled_params(params.requireds, "r"))
2191
+ parts.concat(labelled_params(params.optionals, "o"))
2192
+ parts.concat(labelled_params(params.posts, "p"))
2193
+ parts.concat(labelled_params(params.keywords) { |p| keyword_param_kind(p) })
2194
+ parts.concat(rest_param_parts(params))
2195
+ "(#{parts.join(',')})"
2196
+ end
2197
+
2198
+ # The `<kind>:<name>` labels for a list of parameter nodes (nil → none). `kind` is a fixed String or a
2199
+ # block computing it per node (for the keyword-required / keyword-optional split).
2200
+ def labelled_params(nodes, kind = nil)
2201
+ (nodes || []).map { |p| "#{kind || yield(p)}:#{param_label(p)}" }
2202
+ end
2203
+
2204
+ # The single rest / keyword-rest / block parameter labels (each present at most once).
2205
+ def rest_param_parts(params)
2206
+ parts = []
2207
+ parts << "*:#{param_label(params.rest)}" if params.rest
2208
+ parts << "**:#{param_label(params.keyword_rest)}" if params.keyword_rest
2209
+ parts << "&:#{param_label(params.block)}" if params.block
2210
+ parts
2211
+ end
2212
+
2213
+ def keyword_param_kind(param)
2214
+ param.is_a?(Prism::OptionalKeywordParameterNode) ? "ko" : "kr"
2215
+ end
2216
+
2217
+ # A parameter node's name, or a class-tagged sentinel for the nameless / destructuring forms
2218
+ # (`MultiTargetNode`, an anonymous `*` / `**` / `&`, `NoKeywordsParameterNode`, `ForwardingParameterNode`).
2219
+ def param_label(param)
2220
+ return "" if param.nil?
2221
+
2222
+ param.respond_to?(:name) && param.name ? param.name.to_s : param.class.name.split("::").last
2223
+ end
2224
+
2225
+ # The 1-based start line of a live def node (the `project_definition_site` an ADR-17 consumer embeds).
2226
+ def def_start_line(node)
2227
+ node.respond_to?(:location) ? node.location.start_line : 0
2228
+ end
2229
+
2230
+ # B1 — the SHA-256 of `source` with every comment's byte range excised (a line comment ends before its
2231
+ # newline, so the newline is KEPT: stripping it preserves the line count, hence every def's start line
2232
+ # and the whole engine bundle). Two revisions of a file that differ ONLY in comment text therefore share
2233
+ # a code fingerprint — the signal the bundle-equality gate uses to prove a changed file's *code* (and so
2234
+ # every code-derived cross-file fact the engine and code-reading plugins produce from it) is unchanged.
2235
+ # A comment that adds or removes a LINE shifts subsequent code, so the fingerprint changes (the gate then
2236
+ # conservatively keeps the file's dependents — a line shift can move a def the ADR-17 diagnostic names).
2237
+ def code_fingerprint(source, comments)
2238
+ bytes = source.b
2239
+ return Digest::SHA256.hexdigest(bytes) if comments.empty?
2240
+
2241
+ result = +"".b
2242
+ cursor = 0
2243
+ comments.sort_by { |comment| comment.location.start_offset }.each do |comment|
2244
+ loc = comment.location
2245
+ result << bytes.byteslice(cursor, loc.start_offset - cursor) if loc.start_offset > cursor
2246
+ cursor = loc.end_offset if loc.end_offset > cursor
2247
+ end
2248
+ result << bytes.byteslice(cursor, bytes.bytesize - cursor)
2249
+ Digest::SHA256.hexdigest(result)
2250
+ end
2251
+
2252
+ # Combined single-parse cross-file pre-pass used by the project-wide runner pre-pass
2253
+ # ({Analysis::Runner::ProjectPrePasses#discover}). {#discovered_classes_for_paths} and
2254
+ # {#discovered_def_index_for_paths} each `Prism.parse` every project file independently; this walks the
2255
+ # project ONCE, parsing each file a single time and driving BOTH collectors over the same tree, then
2256
+ # returns `{ classes:, def_index: }`. Each file is parsed, collected, and dropped before the next
2257
+ # iteration, so no more than one AST is held alive at a time (peak RSS stays flat on large projects).
2258
+ #
2259
+ # Error degradation is identical to the two independent loops it replaces: a read / parse failure (the
2260
+ # rescue's real target) contributes nothing to either table. The subset-scoped callers
2261
+ # ({IncrementalSession}, `coverage --protection`) keep calling the individual methods unchanged.
2262
+ #
2263
+ # @param paths [Array<String>] project file paths.
2264
+ # @param buffer [Rigor::Analysis::BufferBinding, nil]
2265
+ # @return [Hash{Symbol => Object}] `{ classes: Hash, def_index: Hash }`.
2266
+ def discovered_project_index_for_paths(paths, buffer: nil)
2267
+ classes = {}
2268
+ acc = new_def_index_accumulator
2269
+ paths.each do |path|
2270
+ physical = buffer ? buffer.resolve(path) : path
2271
+ root = Prism.parse(File.read(physical), filepath: path).value
2272
+ collect_class_decls(root, [], classes)
2273
+ accumulate_project_index(acc, path, root)
2274
+ rescue StandardError
2275
+ # Skip files that fail to parse or read; the per-file analyzer surfaces the parse error separately.
2276
+ next
2277
+ end
2278
+ { classes: classes.freeze, def_index: finalize_def_index(acc) }
2279
+ end
2280
+
2281
+ # ADR-85 WD2 — the incremental cross-file discovery pass. The bundle-driven twin of
2282
+ # {#discovered_project_index_for_paths}: instead of parsing + walking every file, it folds each file's
2283
+ # cached per-file *seed bundle* (plain-data tables + `(node_id, name, fingerprint)` def-node handles),
2284
+ # re-walking ONLY the files whose current digest does not match their cached bundle (changed / added).
2285
+ # Rebuilds in canonical file order — the sound reconstruction (the merged tables are Set-union / later-wins
2286
+ # / whole-project-finalize, so a changed file cannot be delta-patched, only re-folded in place; recon Q2).
2287
+ #
2288
+ # Returns `{ classes:, def_index:, bundles: }` — `bundles` is the CURRENT per-file bundle set (cached ones
2289
+ # reused, changed ones refreshed, removed ones absent) so the caller persists the up-to-date snapshot.
2290
+ # A re-walked file's methods stay LIVE `Prism::DefNode`s in the returned index (no on-demand re-parse for
2291
+ # a file we just walked); an unchanged file's methods are {DefHandle}s the accessor choke points resolve
2292
+ # lazily. On a cold run (`seed_bundles` empty) every file is re-walked, so the index is entirely live —
2293
+ # identical to {#discovered_project_index_for_paths} — while the bundles are built for the next run.
2294
+ #
2295
+ # @param paths [Array<String>] project file paths, in canonical order.
2296
+ # @param seed_bundles [Hash{String => Hash}] the prior run's per-file bundles, keyed by logical path.
2297
+ # @param buffer [Rigor::Analysis::BufferBinding, nil]
2298
+ # @return [Hash{Symbol => Object}] `{ classes:, def_index:, bundles: }`.
2299
+ def discovered_project_index_incremental(paths, seed_bundles:, buffer: nil)
2300
+ classes = {}
2301
+ acc = new_def_index_accumulator
2302
+ bundles = {}
2303
+ paths.each do |path|
2304
+ physical = buffer ? buffer.resolve(path) : path
2305
+ digest = Cache::FileDigest.hexdigest(physical)
2306
+ cached = seed_bundles[path]
2307
+ if cached && cached[:digest] == digest
2308
+ bundles[path] = cached
2309
+ classes.merge!(cached[:classes])
2310
+ fold_file_index(acc, bundle_to_file_index(cached, path))
2311
+ else
2312
+ source = File.read(physical)
2313
+ parsed = Prism.parse(source, filepath: path)
2314
+ root = parsed.value
2315
+ file_index = build_file_index(path, root)
2316
+ file_classes = {}
2317
+ collect_class_decls(root, [], file_classes)
2318
+ bundles[path] = build_seed_bundle(file_index, file_classes, digest,
2319
+ code_fingerprint(source, parsed.comments))
2320
+ classes.merge!(file_classes)
2321
+ fold_file_index(acc, file_index)
2322
+ end
2323
+ rescue StandardError
2324
+ # Skip files that fail to parse / read; the per-file analyzer surfaces the parse error separately.
2325
+ next
2326
+ end
2327
+ { classes: classes.freeze, def_index: finalize_def_index(acc), bundles: bundles }
2328
+ end
2329
+
2330
+ # Builds ONE file's isolated def-index contribution (live `Prism::DefNode`s) by folding it into a fresh
2331
+ # accumulator — recon Q2(a)'s "constructible in isolation" property. Shared by the cold-baseline bundle
2332
+ # build and the incremental changed-file re-walk.
2333
+ def build_file_index(path, root)
2334
+ file_acc = new_def_index_accumulator
2335
+ accumulate_project_index(file_acc, path, root)
2336
+ file_acc
2337
+ end
2338
+
2339
+ # ADR-85 WD2 — folds a single file's isolated def-index contribution into the cross-file accumulator,
2340
+ # applying EXACTLY the merge semantics {#accumulate_project_index} applies incrementally (def_nodes
2341
+ # later-wins, def_sources first-wins, includes / class_sources accumulate, everything else later-wins).
2342
+ # Polymorphic over the def-node value: a re-walked file's `file_index` carries live nodes; a cached
2343
+ # bundle's carries {DefHandle}s. The merges never deref the value, so both fold identically.
2344
+ def fold_file_index(acc, file_index)
2345
+ fold_def_tables(acc, file_index)
2346
+ fold_ancestry_tables(acc, file_index)
2347
+ end
2348
+
2349
+ # def_nodes / singleton_def_nodes / method_visibilities / methods fold class-nested later-wins;
2350
+ # def_sources / singleton_def_sources fold first-wins ({#fold_def_sources}).
2351
+ def fold_def_tables(acc, file_index)
2352
+ file_index[:def_nodes].each { |cn, methods| (acc[:def_nodes][cn] ||= {}).merge!(methods) }
2353
+ file_index[:singleton_def_nodes].each { |cn, methods| (acc[:singleton_def_nodes][cn] ||= {}).merge!(methods) }
2354
+ file_index[:method_visibilities].each { |cn, table| (acc[:method_visibilities][cn] ||= {}).merge!(table) }
2355
+ file_index[:methods].each { |cn, table| (acc[:methods][cn] ||= {}).merge!(table) }
2356
+ fold_def_sources(acc, :def_sources, file_index[:def_sources])
2357
+ fold_def_sources(acc, :singleton_def_sources, file_index[:singleton_def_sources])
2358
+ end
2359
+
2360
+ # A `"path:line"` source table (instance or singleton) is first-file-wins per `(class, method)` (`||=`),
2361
+ # matching `merge_discovered_defs`.
2362
+ def fold_def_sources(acc, key, file_sources)
2363
+ file_sources.each do |cn, methods|
2364
+ target = (acc[key][cn] ||= {})
2365
+ methods.each { |method_name, source| target[method_name] ||= source }
2366
+ end
2367
+ end
2368
+
2369
+ # superclasses later-wins; includes / class_sources accumulate; member layouts later-wins.
2370
+ def fold_ancestry_tables(acc, file_index)
2371
+ acc[:superclasses].merge!(file_index[:superclasses])
2372
+ file_index[:includes].each { |cn, mods| acc[:includes][cn] = ((acc[:includes][cn] || []) + mods).uniq }
2373
+ file_index[:class_sources].each { |cn, files| (acc[:class_sources][cn] ||= Set.new).merge(files) }
2374
+ acc[:data_member_layouts].merge!(file_index[:data_member_layouts])
2375
+ acc[:struct_member_layouts].merge!(file_index[:struct_member_layouts])
2376
+ end
2377
+
2378
+ # ADR-85 WD2 — converts a file's live single-file index + its class table into a Marshal-clean seed
2379
+ # bundle: the plain-data tables verbatim, the def-node tables re-expressed as `[node_id, name,
2380
+ # fingerprint]` triples (the path is the bundle key), the class-source names (path implicit), and the
2381
+ # content digest that gates the bundle's reuse.
2382
+ def build_seed_bundle(file_index, file_classes, digest, code_fingerprint)
2383
+ {
2384
+ digest: digest,
2385
+ # B1 — the comment-stripped code fingerprint, so a recheck can prove this file's edit was
2386
+ # comment-only and skip its dependents.
2387
+ code_fingerprint: code_fingerprint,
2388
+ # ADR-89 WD1 — the per-def SIGNATURE-shape declaration signature (bodies excluded, def lines kept),
2389
+ # so a recheck can prove this file's body edit changed no declaration and skip its ancestry /
2390
+ # file-level dependents. Computed from the same live `file_index` the bundle is built from, so the
2391
+ # value stored here equals the one a later recheck recomputes for an unchanged declaration.
2392
+ declaration_signature: declaration_signature(file_index),
2393
+ classes: file_classes,
2394
+ def_nodes: live_defs_to_bundle(file_index[:def_nodes]),
2395
+ singleton_def_nodes: live_defs_to_bundle(file_index[:singleton_def_nodes]),
2396
+ def_sources: file_index[:def_sources],
2397
+ singleton_def_sources: file_index[:singleton_def_sources],
2398
+ superclasses: file_index[:superclasses],
2399
+ includes: file_index[:includes],
2400
+ method_visibilities: file_index[:method_visibilities],
2401
+ methods: file_index[:methods],
2402
+ class_source_names: file_index[:class_sources].keys,
2403
+ data_member_layouts: file_index[:data_member_layouts],
2404
+ struct_member_layouts: file_index[:struct_member_layouts]
2405
+ }
2406
+ end
2407
+
2408
+ # ADR-85 WD2 — reconstitutes a cached bundle into a single-file index {#fold_file_index} folds: the
2409
+ # def-node triples become {DefHandle}s bound to this file's `path`, and the class-source names become a
2410
+ # `{name => Set[path]}` table (the shape `accumulate_project_index` produces).
2411
+ def bundle_to_file_index(bundle, path)
2412
+ {
2413
+ def_nodes: bundle_defs_to_handles(bundle[:def_nodes], path),
2414
+ singleton_def_nodes: bundle_defs_to_handles(bundle[:singleton_def_nodes], path),
2415
+ def_sources: bundle[:def_sources],
2416
+ # ADR-85 schema 7 — a pre-7 bundle lacks this key; the SCHEMA bump loads it as a clean cold rebuild,
2417
+ # but default to `{}` so any in-flight fold stays total.
2418
+ singleton_def_sources: bundle[:singleton_def_sources] || {},
2419
+ superclasses: bundle[:superclasses],
2420
+ includes: bundle[:includes],
2421
+ method_visibilities: bundle[:method_visibilities],
2422
+ methods: bundle[:methods],
2423
+ class_sources: bundle[:class_source_names].to_h { |name| [name, Set[path]] },
2424
+ data_member_layouts: bundle[:data_member_layouts],
2425
+ struct_member_layouts: bundle[:struct_member_layouts]
2426
+ }
2427
+ end
2428
+
2429
+ # `{class => {method => Prism::DefNode}}` → `{class => {method => [node_id, name, fingerprint]}}`.
2430
+ def live_defs_to_bundle(defs)
2431
+ defs.transform_values do |methods|
2432
+ methods.transform_values do |node|
2433
+ [node.node_id, node.name.to_s, Digest::SHA256.hexdigest(node.location.slice)]
2434
+ end
2435
+ end
2436
+ end
2437
+
2438
+ # `{class => {method => [node_id, name, fingerprint]}}` → `{class => {method => DefHandle}}` for `path`.
2439
+ def bundle_defs_to_handles(defs, path)
2440
+ defs.transform_values do |methods|
2441
+ methods.transform_values do |(node_id, name, fingerprint)|
2442
+ DefHandle.new(path: path, node_id: node_id, name: name, fingerprint: fingerprint)
2443
+ end
2444
+ end
2445
+ end
2446
+
2447
+ # The empty per-run accumulator the def-index passes fold each file into.
2448
+ def new_def_index_accumulator
2449
+ { def_nodes: {}, singleton_def_nodes: {}, def_sources: {}, singleton_def_sources: {},
2450
+ superclasses: {}, includes: {}, method_visibilities: {}, methods: {}, class_sources: {},
2451
+ data_member_layouts: {}, struct_member_layouts: {} }
2452
+ end
2453
+
2454
+ # Post-processes and freezes a fully-folded def-index accumulator.
2455
+ def finalize_def_index(acc)
2045
2456
  # Cross-file method suppression is for the project's OWN accessors (attr_* / define_method / alias) — NOT for
2046
2457
  # plain `def`s. A cross-file `def` on a class is exactly the ADR-17 monkey-patch case the undefined-method rule
2047
2458
  # deliberately surfaces (fire + def-site annotation, nudging `pre_eval:`), so dropping the `def`-declared names
2048
2459
  # keeps that contract intact while still letting `attr_reader :x` in one file suppress a false undefined-method
2049
2460
  # for `obj.x` in another.
2050
2461
  acc[:methods] = subtract_def_methods(acc[:methods], acc[:def_nodes])
2051
- %i[def_nodes singleton_def_nodes def_sources includes method_visibilities methods class_sources].each do |key|
2462
+ %i[def_nodes singleton_def_nodes def_sources singleton_def_sources includes method_visibilities
2463
+ methods class_sources].each do |key|
2052
2464
  acc[key].each_value(&:freeze)
2053
2465
  end
2054
2466
  acc.transform_values(&:freeze)
@@ -2073,9 +2485,12 @@ module Rigor
2073
2485
  # def-nodes ×2). See {#build_methods_and_def_nodes}.
2074
2486
  file_methods, file_def_nodes = build_methods_and_def_nodes(root)
2075
2487
  merge_discovered_defs(acc[:def_nodes], acc[:def_sources], path, file_def_nodes)
2076
- build_discovered_singleton_def_nodes(root).each do |class_name, methods|
2077
- (acc[:singleton_def_nodes][class_name] ||= {}).merge!(methods)
2078
- end
2488
+ # ADR-46 slice 4 (singleton) record the singleton-side `"path:line"` sources alongside the nodes,
2489
+ # the exact mirror of the instance-side `merge_discovered_defs`, so a class/singleton-method body edit
2490
+ # produces a changed `"Class.method"` fingerprint pair (and its call sites a symbol edge) instead of
2491
+ # silently degrading to the file's full ancestry closure.
2492
+ merge_discovered_defs(acc[:singleton_def_nodes], acc[:singleton_def_sources], path,
2493
+ build_discovered_singleton_def_nodes(root))
2079
2494
  superclasses = build_discovered_superclasses(root)
2080
2495
  includes = build_discovered_includes(root)
2081
2496
  acc[:superclasses].merge!(superclasses)
@@ -2152,7 +2567,7 @@ module Rigor
2152
2567
  record_class_new_constant_decl(node, qualified_prefix, accumulator)
2153
2568
  end
2154
2569
 
2155
- node.compact_child_nodes.each { |child| collect_class_decls(child, qualified_prefix, accumulator) }
2570
+ node.rigor_each_child { |child| collect_class_decls(child, qualified_prefix, accumulator) }
2156
2571
  end
2157
2572
 
2158
2573
  # T1 (template-corpora survey) — record a `Const = Class.new(Super)` (and the bare `Class.new` / `Module.new`)
@@ -2219,7 +2634,7 @@ module Rigor
2219
2634
  return if record_meta_new_constant?(node, qualified_prefix, identity_table, discovered)
2220
2635
  end
2221
2636
 
2222
- node.compact_child_nodes.each do |child|
2637
+ node.rigor_each_child do |child|
2223
2638
  record_declarations(child, qualified_prefix, identity_table, discovered)
2224
2639
  end
2225
2640
  end
@@ -2345,7 +2760,7 @@ module Rigor
2345
2760
  when Prism::UnlessNode
2346
2761
  propagate_unless_branches(node, table, current_scope)
2347
2762
  else
2348
- node.compact_child_nodes.each { |child| propagate(child, table, current_scope) }
2763
+ node.rigor_each_child { |child| propagate(child, table, current_scope) }
2349
2764
  end
2350
2765
  end
2351
2766