rigortype 0.4.3 → 0.4.5

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 (172) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +104 -15
  5. data/docs/manual/02-cli-reference.md +43 -12
  6. data/docs/manual/03-configuration.md +20 -7
  7. data/docs/manual/04-diagnostics.md +56 -3
  8. data/docs/manual/05-inspecting-types.md +2 -2
  9. data/docs/manual/06-baseline.md +1 -1
  10. data/docs/manual/07-plugins.md +3 -2
  11. data/docs/manual/08-skills.md +7 -0
  12. data/docs/manual/10-mcp-server.md +40 -4
  13. data/docs/manual/18-removing-dead-code.md +23 -2
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-rspec.md +101 -1
  16. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  17. data/exe/rigor +12 -17
  18. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  19. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
  20. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
  21. data/lib/rigor/analysis/check_rules/rule_ids.rb +60 -1
  22. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  23. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
  24. data/lib/rigor/analysis/check_rules.rb +274 -51
  25. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  26. data/lib/rigor/analysis/incremental.rb +61 -0
  27. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  28. data/lib/rigor/analysis/incremental_session.rb +270 -28
  29. data/lib/rigor/analysis/path_expansion.rb +3 -1
  30. data/lib/rigor/analysis/reachability/graph.rb +111 -14
  31. data/lib/rigor/analysis/reachability/scan.rb +305 -23
  32. data/lib/rigor/analysis/rule_catalog.rb +22 -4
  33. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  34. data/lib/rigor/analysis/run_cache_probe.rb +5 -3
  35. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +48 -12
  36. data/lib/rigor/analysis/runner/pool_coordinator.rb +179 -31
  37. data/lib/rigor/analysis/runner/project_pre_passes.rb +29 -4
  38. data/lib/rigor/analysis/runner.rb +102 -72
  39. data/lib/rigor/analysis/severity_stamp.rb +45 -1
  40. data/lib/rigor/analysis/worker_session.rb +69 -2
  41. data/lib/rigor/cache/descriptor.rb +79 -12
  42. data/lib/rigor/cache/engine_source.rb +3 -2
  43. data/lib/rigor/cache/incremental_snapshot.rb +39 -5
  44. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  45. data/lib/rigor/cache/store.rb +4 -3
  46. data/lib/rigor/cli/command.rb +3 -1
  47. data/lib/rigor/cli/docs_command.rb +3 -2
  48. data/lib/rigor/cli/doctor_command.rb +1 -1
  49. data/lib/rigor/cli/explain_command.rb +3 -2
  50. data/lib/rigor/cli/mcp_command.rb +1 -1
  51. data/lib/rigor/cli/plugin_command.rb +2 -1
  52. data/lib/rigor/cli/plugins_command.rb +2 -1
  53. data/lib/rigor/cli/sig_gen_command.rb +32 -11
  54. data/lib/rigor/cli/skill_command.rb +2 -1
  55. data/lib/rigor/cli/skill_describe.rb +3 -2
  56. data/lib/rigor/cli/unused_command.rb +10 -7
  57. data/lib/rigor/cli.rb +3 -2
  58. data/lib/rigor/config_audit.rb +163 -22
  59. data/lib/rigor/configuration/dependencies.rb +19 -11
  60. data/lib/rigor/configuration/severity_profile.rb +20 -2
  61. data/lib/rigor/configuration.rb +29 -3
  62. data/lib/rigor/effects/signature_sources.rb +3 -1
  63. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  64. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  65. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  66. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  67. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  68. data/lib/rigor/environment/rbs_loader.rb +67 -11
  69. data/lib/rigor/inference/block_call_timing.rb +12 -1
  70. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  71. data/lib/rigor/inference/captured_locals.rb +67 -13
  72. data/lib/rigor/inference/clause_widening.rb +90 -0
  73. data/lib/rigor/inference/definer_resolution.rb +101 -25
  74. data/lib/rigor/inference/expression_typer.rb +94 -9
  75. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  76. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  77. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  78. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  79. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
  80. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  81. data/lib/rigor/inference/method_dispatcher.rb +67 -3
  82. data/lib/rigor/inference/module_function_state.rb +213 -51
  83. data/lib/rigor/inference/narrowing.rb +235 -75
  84. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  85. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  86. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  87. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  88. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  89. data/lib/rigor/inference/project_seed_tables.rb +120 -0
  90. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  91. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  92. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  93. data/lib/rigor/inference/repeated_or_writes.rb +414 -1
  94. data/lib/rigor/inference/return_barrier.rb +16 -0
  95. data/lib/rigor/inference/scope_indexer.rb +1061 -122
  96. data/lib/rigor/inference/self_call_ivar_writes.rb +346 -0
  97. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  98. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  99. data/lib/rigor/inference/statement_evaluator.rb +512 -150
  100. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  101. data/lib/rigor/lens/anchor.rb +3 -3
  102. data/lib/rigor/lens/contract_reader.rb +60 -2
  103. data/lib/rigor/lens/document.rb +4 -3
  104. data/lib/rigor/lens/file_reader.rb +452 -73
  105. data/lib/rigor/lens/members.rb +81 -17
  106. data/lib/rigor/lens/run.rb +73 -4
  107. data/lib/rigor/lens.rb +2 -1
  108. data/lib/rigor/mcp/server.rb +63 -22
  109. data/lib/rigor/plugin/base.rb +3 -2
  110. data/lib/rigor/plugin/box_probe.rb +154 -7
  111. data/lib/rigor/plugin/io_boundary.rb +42 -6
  112. data/lib/rigor/plugin/isolation.rb +4 -3
  113. data/lib/rigor/project_environment.rb +6 -3
  114. data/lib/rigor/protection/discovery_seed.rb +9 -36
  115. data/lib/rigor/protection/mutation_cache.rb +8 -2
  116. data/lib/rigor/rooted_glob.rb +27 -0
  117. data/lib/rigor/scope/discovery_index.rb +5 -0
  118. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  119. data/lib/rigor/scope/resolution_chain.rb +40 -5
  120. data/lib/rigor/scope.rb +56 -8
  121. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  122. data/lib/rigor/sig_gen/classification.rb +4 -1
  123. data/lib/rigor/sig_gen/generator.rb +167 -22
  124. data/lib/rigor/sig_gen/layout_index.rb +40 -1
  125. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  126. data/lib/rigor/sig_gen/observation_collector.rb +137 -11
  127. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  128. data/lib/rigor/sig_gen/renderer.rb +13 -155
  129. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  130. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  131. data/lib/rigor/sig_gen/write_result.rb +3 -1
  132. data/lib/rigor/sig_gen/writer.rb +50 -35
  133. data/lib/rigor/signature_path_audit.rb +4 -2
  134. data/lib/rigor/type/constant.rb +10 -1
  135. data/lib/rigor/version.rb +1 -1
  136. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  137. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +1 -1
  138. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  139. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  140. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  141. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  142. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  143. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  144. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  145. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  146. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  147. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  148. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
  149. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  150. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  151. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +57 -6
  152. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  153. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  154. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  155. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  156. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  157. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  158. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  159. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  160. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  161. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  162. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  164. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  166. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  167. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  168. data/sig/rigor/rooted_glob.rbs +7 -0
  169. data/sig/rigor/scope.rbs +4 -3
  170. data/skills/rigor-doctor/references/01-checks.md +23 -11
  171. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  172. metadata +16 -1
@@ -151,10 +151,17 @@ module Rigor
151
151
  positional_marks(block, found, stores, scope, element_types)
152
152
  end
153
153
 
154
+ # {StraightLine.sites}: the sites of a straight-line body that an earlier store in it may have filled, with the
155
+ # root's kind read from `scope`, the body's entry scope.
156
+ def straight_line_sites(body, scope) = StraightLine.sites(body, scope)
157
+
154
158
  # One source slice is far cheaper than the walk, and a body that spells no `||=` holds no such site. A
155
- # heredoc's text lies outside its node's location, so a body that opens one is walked regardless.
159
+ # heredoc's text lies outside its node's location, so a body that opens one is walked regardless. A slice that
160
+ # is not valid in its encoding (an invalid byte in a comment) cannot be regex-matched, so it is walked too.
156
161
  def may_hold_or_write?(body)
157
162
  source = body.slice
163
+ return true unless source.valid_encoding?
164
+
158
165
  source.include?("||=") || source.match?(Forms::HEREDOC_OPENER)
159
166
  end
160
167
 
@@ -373,6 +380,412 @@ module Rigor
373
380
  found.installs.none? { |(other_root, other_depth)| other_root == root && other_depth < depth }
374
381
  end
375
382
 
383
+ # The index `||=` sites of a straight-line body — a method body, or a file's top level — whose slot a store that
384
+ # comes EARLIER in the body may have filled (issue #1885). A `class` / `module` body is typed from a scope of its
385
+ # own, which the top level's marks do not reach, so its sites are never marked; its stores are still walked, since
386
+ # one into a constant or a global reaches a later top-level site. Whether that store ran is not something the
387
+ # reading at the later site can see: a conditional store's slot narrowing does not survive the join with the arm
388
+ # that skipped it, and a store in a block (`tap`, `then`) leaves none behind, so the later site's `[]` read was a
389
+ # lone `Dynamic` again and the memoization reading answered its own rvalue —
390
+ # `c[:k] ||= :e if cond; (c[:k] ||= :f)` typed `:f` where Ruby answers `:e` when `cond` held. Only a store
391
+ # through the site's own variable counts ({RepeatedOrWrites.receiver_path}); an alias (`d = c`), a block
392
+ # parameter and a helper method's store are not seen, which is the cost the memoization reading already
393
+ # accepts. A store reaches the site's slot when it is
394
+ #
395
+ # - at the site's depth, with a key that may name the same slot (a literal that `eql?`s the site's, or one that is
396
+ # no literal; a mutator such as `merge!` or `insert` writes any slot);
397
+ # - above it, installing anything but an empty `{}` / `[]` / `Hash.new` / `Array.new` (`c[:a] = { k: :e }` above
398
+ # `c[:a][:k] ||= :f`);
399
+ # - below it, through the site's slot (`c[:a][:k] = 1` cannot run unless `c[:a]` holds an object).
400
+ #
401
+ # Two relations reach the slot only when the root object is not a `Hash` ({Analysis#unless_hash}): a store that
402
+ # only removes or reorders (`delete`, `shift`, `compact!`), which on an `Array` moves an element into the slot,
403
+ # and a literal key that differs from the site's but that an `Array` reads as the same index or an unknown `[]`
404
+ # may normalise (`:k` and `"k"`, `1` and `1.0`, `-1` and `2`). The statement evaluator and the user-method return
405
+ # re-walk lay the sites at the body's entry (`Scope#with_repeated_or_writes`), the same mark the repeating-block
406
+ # passes lay, deciding the root's kind from the entry scope and the body's writes.
407
+ module StraightLine
408
+ # `marked` is marked whatever the root is; `unless_hash` maps a site to the root the entry scope decides on;
409
+ # `root_writes` maps each root the body writes to `:hash` when every write stores a new `Hash`, else `:other`.
410
+ Analysis = Data.define(:marked, :unless_hash, :root_writes)
411
+ NOTHING = Analysis.new(marked: NO_SITES, unless_hash: {}.compare_by_identity.freeze, root_writes: {}.freeze)
412
+
413
+ # The stores at one `[root, depth]`, or the deeper stores through one of its slots. `by_key` groups the ones
414
+ # with a literal key by that key (`eql?`, as a `Hash` compares), and `by_tag` the same groups by
415
+ # {.key_tag}; `wild` holds the ones with no literal key, `removers` the remove-or-reorder calls, and
416
+ # `installers` the ones that may put a non-empty object into a slot of this object.
417
+ Bucket = Struct.new(:by_key, :by_tag, :wild, :removers, :installers) do
418
+ def self.fresh = new({}, Hash.new { |table, tag| table[tag] = [] }, [], [], [])
419
+
420
+ def add_keyed(value, store)
421
+ group = by_key[value]
422
+ unless group
423
+ group = by_key[value] = []
424
+ by_tag[StraightLine.key_tag(value)] << value
425
+ end
426
+ group << store
427
+ end
428
+ end
429
+
430
+ # The tags under which two different literal keys name different slots of any receiver.
431
+ DISTINCT_TAGS = Set[:symbol, :string, :index].freeze
432
+
433
+ # The main Ractor's {.analysis} memo, keyed on the body node by identity; a pool worker computes it every
434
+ # time.
435
+ @memo = ObjectSpace::WeakMap.new
436
+
437
+ module_function
438
+
439
+ def sites(body, scope)
440
+ found = analysis(body)
441
+ return found.marked if found.unless_hash.empty?
442
+
443
+ found.marked + found.unless_hash.filter_map { |site, root| site unless hash_root?(root, found, scope) }
444
+ end
445
+
446
+ def analysis(body)
447
+ return NOTHING unless body.is_a?(Prism::Node) && RepeatedOrWrites.may_hold_or_write?(body)
448
+ return compute(body) unless Ractor.main?
449
+
450
+ @memo[body] ||= compute(body)
451
+ end
452
+
453
+ def compute(body)
454
+ walked = RepeatedOrWrites::Walk.new([], {}.compare_by_identity, [], Set.new, {})
455
+ RepeatedOrWrites.walk(body, true, walked)
456
+ return NOTHING if walked.or_writes.empty?
457
+
458
+ Scan.new(body, walked).run
459
+ end
460
+
461
+ def key_tag(value)
462
+ case value
463
+ when Symbol then :symbol
464
+ when String then :string
465
+ when Integer then value.negative? ? :negative : :index
466
+ else :float
467
+ end
468
+ end
469
+
470
+ def literal_key_value(node)
471
+ case node
472
+ when Prism::SymbolNode then node.unescaped.to_sym
473
+ when Prism::StringNode then node.unescaped
474
+ when Prism::IntegerNode, Prism::FloatNode then node.value
475
+ end
476
+ end
477
+
478
+ # Whether the entry scope and the body's own writes show the root is a `Hash`: a local the entry scope
479
+ # binds (a parameter) or an instance variable by its entry type, a local only the body binds by its writes;
480
+ # either way no write in the body may store anything else.
481
+ def hash_root?(root, found, scope)
482
+ writes = found.root_writes[root]
483
+ return false if writes == :other
484
+
485
+ kind, name = root
486
+ case kind
487
+ when :local then scope.locals.key?(name) ? hash_type?(scope.local(name)) : writes == :hash
488
+ when :ivar then hash_type?(scope.ivar(name))
489
+ else false
490
+ end
491
+ end
492
+
493
+ def hash_type?(type)
494
+ case type
495
+ when Type::HashShape then true
496
+ when Type::Nominal then type.class_name == "Hash"
497
+ else false
498
+ end
499
+ end
500
+
501
+ # An expression that evaluates to a new core `Hash`.
502
+ def new_hash?(node)
503
+ case node
504
+ when Prism::HashNode then true
505
+ when Prism::CallNode then node.name == :new && constant_named?(node.receiver, :Hash)
506
+ else false
507
+ end
508
+ end
509
+
510
+ # An expression that evaluates to a new, empty `Hash` or `Array`, which holds no slot a later site reads.
511
+ def empty_fresh?(node)
512
+ case node
513
+ when Prism::HashNode, Prism::ArrayNode then node.elements.empty?
514
+ when Prism::CallNode
515
+ node.name == :new && node.arguments.nil? && node.block.nil? &&
516
+ (constant_named?(node.receiver, :Hash) || constant_named?(node.receiver, :Array))
517
+ else false
518
+ end
519
+ end
520
+
521
+ def constant_named?(node, name)
522
+ case node
523
+ when Prism::ConstantReadNode then node.name == name
524
+ when Prism::ConstantPathNode then node.parent.nil? && node.name == name
525
+ else false
526
+ end
527
+ end
528
+
529
+ # One body's stores, bucketed by object so that each site compares only against the stores that can reach
530
+ # it: a site whose key is a literal looks up its own key's group, the stores with no literal key, and the
531
+ # groups of the tags that do not tell its key apart.
532
+ class Scan
533
+ def initialize(body, walked)
534
+ @body = body
535
+ @walked = walked
536
+ @buckets = Hash.new { |table, at| table[at] = Bucket.fresh }
537
+ @deeper = Hash.new { |table, at| table[at] = Bucket.fresh }
538
+ @parents = nil
539
+ end
540
+
541
+ def run
542
+ @walked.stores.each { |store| index(store) }
543
+ marked = []
544
+ unless_hash = {}.compare_by_identity
545
+ @walked.or_writes.each do |site|
546
+ case verdict(site)
547
+ when :marked then marked << site
548
+ when :unless_hash then unless_hash[site] = RepeatedOrWrites.receiver_path(site.receiver, 0).first
549
+ end
550
+ end
551
+ Analysis.new(marked: marked.freeze, unless_hash: unless_hash.freeze,
552
+ root_writes: unless_hash.empty? ? NOTHING.root_writes : root_writes)
553
+ end
554
+
555
+ private
556
+
557
+ def index(store)
558
+ return if store.path.nil?
559
+
560
+ root, depth = store.path
561
+ bucket = @buckets[store.path]
562
+ node = store.node
563
+ if node.is_a?(Prism::CallNode) && UnknownStoreWidening::VALUE_PRESERVING.include?(node.name)
564
+ bucket.removers << store
565
+ else
566
+ add(bucket, StraightLine.literal_key_value(store_key(node)), store)
567
+ bucket.installers << store unless empty_value?(node)
568
+ end
569
+ chain_keys(node.receiver).each_with_index do |key, at|
570
+ break if at >= depth
571
+
572
+ add(@deeper[[root, at]], StraightLine.literal_key_value(key), store)
573
+ end
574
+ end
575
+
576
+ def add(bucket, value, store)
577
+ value.nil? ? bucket.wild << store : bucket.add_keyed(value, store)
578
+ end
579
+
580
+ # The key a store writes, or nil when it may write any slot (a mutator such as `merge!` or `<<`).
581
+ def store_key(node)
582
+ return FreshKeys.sole_key(node) unless node.is_a?(Prism::CallNode)
583
+
584
+ arguments = node.arguments&.arguments
585
+ node.name == :[]= && arguments&.size == 2 ? arguments.first : nil
586
+ end
587
+
588
+ # Whether the store puts nothing a deeper site could read: an index write of an empty new collection.
589
+ def empty_value?(node)
590
+ value = case node
591
+ when Prism::CallNode
592
+ return false unless node.name == :[]=
593
+
594
+ node.arguments&.arguments&.last
595
+ when Prism::IndexTargetNode then nil
596
+ else node.value
597
+ end
598
+ !value.nil? && StraightLine.empty_fresh?(value)
599
+ end
600
+
601
+ # The key of each element read between the store's root and its receiver, root first; nil for a key that is
602
+ # no single argument.
603
+ def chain_keys(node)
604
+ keys = []
605
+ loop do
606
+ case node
607
+ when Prism::ParenthesesNode
608
+ statements = node.body
609
+ break unless statements.is_a?(Prism::StatementsNode) && statements.body.size == 1
610
+
611
+ node = statements.body.first
612
+ when Prism::CallNode
613
+ break unless node.name == :[] && node.receiver
614
+
615
+ keys.unshift(FreshKeys.sole_key(node))
616
+ node = node.receiver
617
+ when *IndexWriteWidening::NODE_CLASSES
618
+ keys.unshift(FreshKeys.sole_key(node))
619
+ node = node.receiver
620
+ else break
621
+ end
622
+ end
623
+ keys
624
+ end
625
+
626
+ # `:marked`, `:unless_hash` or nil for one site. A literal key told apart only on a `Hash` is decided by the
627
+ # root's kind for a site on the root alone; a deeper object's kind is not known.
628
+ def verdict(site)
629
+ path = RepeatedOrWrites.receiver_path(site.receiver, 0)
630
+ return nil if path.nil?
631
+
632
+ same = at_depth(path, site)
633
+ return :marked if same == :marked || (same && path.last.positive?)
634
+
635
+ above = above_depth(path, site)
636
+ return :marked if above == :marked
637
+
638
+ :unless_hash if same || above
639
+ end
640
+
641
+ # The stores at the site's depth, and the deeper ones through its object: `:marked`, `:weak` (reaching
642
+ # unless the object is a `Hash`) or nil.
643
+ def at_depth(path, site)
644
+ value = StraightLine.literal_key_value(FreshKeys.sole_key(site))
645
+ weak = false
646
+ [@buckets.fetch(path, nil), @deeper.fetch(path, nil)].compact.each do |bucket|
647
+ strong, loose = candidates(bucket, value)
648
+ return :marked if reaches?(strong, site)
649
+
650
+ weak ||= reaches?(loose, site)
651
+ end
652
+ weak ? :weak : nil
653
+ end
654
+
655
+ # The stores into a container above the site's object. A remover there moves objects between slots only
656
+ # on an `Array`, which the root's kind decides for the root alone.
657
+ def above_depth(path, site)
658
+ root, depth = path
659
+ weak = false
660
+ (0...depth).each do |level|
661
+ bucket = @buckets.fetch([root, level], nil) or next
662
+ return :marked if reaches?([bucket.installers], site)
663
+ next unless reaches?([bucket.removers], site)
664
+ return :marked unless level.zero?
665
+
666
+ weak = true
667
+ end
668
+ weak ? :weak : nil
669
+ end
670
+
671
+ # The store groups of `bucket` that reach a site keyed `value` on any receiver, and those that reach it
672
+ # unless the receiver is a `Hash`.
673
+ def candidates(bucket, value)
674
+ return [[bucket.wild, *bucket.by_key.values], [bucket.removers]] if value.nil?
675
+
676
+ strong = [bucket.wild]
677
+ own = bucket.by_key[value]
678
+ strong << own if own
679
+ tag = StraightLine.key_tag(value)
680
+ loose = [bucket.removers]
681
+ bucket.by_tag.each do |other_tag, values|
682
+ next if other_tag == tag && DISTINCT_TAGS.include?(tag)
683
+
684
+ values.each { |other| loose << bucket.by_key[other] unless other.eql?(value) }
685
+ end
686
+ [strong, loose]
687
+ end
688
+
689
+ # Whether a store in `groups` ends before `site` starts and may have run first. A store that encloses the
690
+ # site (`(c[:a] ||= []) << x` around `c[:a] ||= []`) stores after evaluating it, so it is not earlier.
691
+ def reaches?(groups, site)
692
+ start = site.location.start_offset
693
+ groups.any? do |stores|
694
+ stores.any? do |store|
695
+ node = store.node
696
+ node.location.end_offset <= start &&
697
+ !ExclusiveArms.exclusive?(node, site, @body, @parents ||= ExclusiveArms.parents(@body))
698
+ end
699
+ end
700
+ end
701
+
702
+ # Each variable root the body writes, `:hash` when every write stores a new `Hash`. A nested `def` is skipped;
703
+ # a `class` / `module` body is not, so a same-named variable written there only makes the answer `:other`,
704
+ # the side that keeps the mark.
705
+ def root_writes
706
+ table = {}
707
+ collect_writes(@body, table)
708
+ table.freeze
709
+ end
710
+
711
+ def collect_writes(node, table)
712
+ return if node.is_a?(Prism::DefNode)
713
+
714
+ kind = Forms::VARIABLE_KINDS[node.class]
715
+ if kind && !Forms::READ_NODES.include?(node.class)
716
+ fresh_hash = Forms::VALUE_WRITE_NODES.include?(node.class) && StraightLine.new_hash?(node.value)
717
+ root = [kind, node.name]
718
+ table[root] = table.fetch(root, :hash) == :hash && fresh_hash ? :hash : :other
719
+ end
720
+ node.rigor_each_child { |child| collect_writes(child, table) }
721
+ end
722
+ end
723
+
724
+ # Whether a store and a later site sit in different arms of one `if` / `unless` (a ternary included), so that
725
+ # the store never runs before the site in the same run of the body. That holds when no loop or lambda between
726
+ # that conditional and the body runs both arms in turn. For a receiver that outlives the call — an instance
727
+ # variable, a constant, a global — an earlier call of the same method can have run the other arm; the exemption
728
+ # follows the memoization reading's existing optimism about what other calls stored. A block is not counted: one
729
+ # its call may run more than once is the repeating-body marks' case ({.sites}), and one that runs at most once
730
+ # (`tap`) repeats nothing. Any other relation — a `case` (whose `when` conditions run before a later arm),
731
+ # `&&` / `||`, a `begin` and its `rescue` — counts as one where the store may have run first.
732
+ module ExclusiveArms
733
+ LOOP_NODES = Set[Prism::LambdaNode, Prism::WhileNode, Prism::UntilNode, Prism::ForNode].freeze
734
+
735
+ module_function
736
+
737
+ # The parent of every node under `body`, by identity.
738
+ def parents(body)
739
+ table = {}.compare_by_identity
740
+ pending = [body]
741
+ until pending.empty?
742
+ node = pending.pop
743
+ node.rigor_each_child do |child|
744
+ table[child] = node
745
+ pending << child
746
+ end
747
+ end
748
+ table
749
+ end
750
+
751
+ def exclusive?(store, site, body, parents)
752
+ chain = ancestors(store, body, parents)
753
+ child = site
754
+ node = parents[site]
755
+ until node.nil? || chain.key?(node)
756
+ child = node
757
+ node = parents[node]
758
+ end
759
+ return false if node.nil? || !different_arms?(node, chain[node], child)
760
+
761
+ ancestors(node, body, parents).each_key.none? { |above| LOOP_NODES.include?(above.class) }
762
+ end
763
+
764
+ # `node` and its ancestors up to `body`, each mapped to the child the walk came up through.
765
+ def ancestors(node, body, parents)
766
+ chain = {}.compare_by_identity
767
+ child = nil
768
+ until node.nil?
769
+ chain[node] = child
770
+ break if node.equal?(body)
771
+
772
+ child = node
773
+ node = parents[node]
774
+ end
775
+ chain
776
+ end
777
+
778
+ def different_arms?(node, left, right)
779
+ arms = case node
780
+ when Prism::IfNode then [node.statements, node.subsequent]
781
+ when Prism::UnlessNode then [node.statements, node.else_clause]
782
+ else return false
783
+ end
784
+ [left, right].all? { |arm| arms.any? { |candidate| candidate.equal?(arm) } }
785
+ end
786
+ end
787
+ end
788
+
376
789
  # Whether an index `||=` site's key differs, position by position, from every earlier position's key under
377
790
  # the per-element fold.
378
791
  module FreshKeys
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "prism"
4
4
 
5
+ require_relative "../source/node_children"
6
+
5
7
  module Rigor
6
8
  module Inference
7
9
  # The constructs whose body `return` leaves only that body, not the enclosing method. A `return` anywhere else —
@@ -39,6 +41,20 @@ module Rigor
39
41
  DEFINE_CALLS.include?(name) || (name == :lambda && kernel_receiver?(call_node.receiver))
40
42
  end
41
43
 
44
+ # True when a `return` under `node` exits the enclosing method: one reachable without crossing a barrier node
45
+ # or a barrier call's block. Allocation-free and early-exiting, for a caller that asks it of every block body.
46
+ def exits_method?(node)
47
+ return false if node.nil? || node?(node)
48
+ return true if node.is_a?(Prism::ReturnNode)
49
+
50
+ barrier_block = node.block if node.is_a?(Prism::CallNode) && block_call?(node)
51
+ node.rigor_each_child do |child|
52
+ next if child.equal?(barrier_block)
53
+ return true if exits_method?(child)
54
+ end
55
+ false
56
+ end
57
+
42
58
  # True when a `lambda` call on `receiver` reaches `Kernel#lambda`: no receiver, `self`, `Kernel` or `::Kernel`.
43
59
  def kernel_receiver?(receiver)
44
60
  case receiver