rigortype 0.4.5 → 0.4.6

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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/docs/handbook/11-sig-gen.md +11 -2
  4. data/docs/manual/02-cli-reference.md +46 -7
  5. data/docs/manual/03-configuration.md +2 -1
  6. data/docs/manual/04-diagnostics.md +1 -1
  7. data/docs/manual/10-mcp-server.md +9 -1
  8. data/docs/manual/12-caching.md +8 -1
  9. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  10. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  11. data/docs/manual/plugins/rigor-devise.md +15 -0
  12. data/docs/manual/plugins/rigor-rspec.md +2 -1
  13. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  14. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  15. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  16. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +4 -0
  17. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +66 -3
  18. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  19. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  20. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  21. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +51 -0
  22. data/lib/rigor/analysis/check_rules.rb +93 -8
  23. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  24. data/lib/rigor/analysis/edit_replay.rb +67 -0
  25. data/lib/rigor/analysis/incremental.rb +37 -0
  26. data/lib/rigor/analysis/incremental_session.rb +175 -27
  27. data/lib/rigor/analysis/path_expansion.rb +41 -0
  28. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  29. data/lib/rigor/analysis/rule_catalog.rb +5 -1
  30. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  31. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  32. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  33. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  34. data/lib/rigor/analysis/runner/pool_coordinator.rb +17 -4
  35. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  36. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  37. data/lib/rigor/analysis/runner.rb +156 -49
  38. data/lib/rigor/analysis/worker_session.rb +14 -2
  39. data/lib/rigor/cache/incremental_snapshot.rb +30 -7
  40. data/lib/rigor/cli/check_command.rb +93 -4
  41. data/lib/rigor/cli/diff_command.rb +2 -1
  42. data/lib/rigor/cli/lens_command.rb +5 -5
  43. data/lib/rigor/cli/mcp_command.rb +2 -1
  44. data/lib/rigor/cli/options.rb +20 -3
  45. data/lib/rigor/cli/plugins_command.rb +5 -0
  46. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  47. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  48. data/lib/rigor/cli/sig_gen_command.rb +46 -7
  49. data/lib/rigor/cli/type_of_command.rb +2 -1
  50. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  51. data/lib/rigor/config_audit.rb +16 -5
  52. data/lib/rigor/configuration.rb +14 -4
  53. data/lib/rigor/environment/rbs_loader.rb +115 -6
  54. data/lib/rigor/environment/required_features.rb +136 -29
  55. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  56. data/lib/rigor/environment.rb +4 -2
  57. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  58. data/lib/rigor/inference/deep_erasure.rb +138 -0
  59. data/lib/rigor/inference/def_return_typer.rb +159 -9
  60. data/lib/rigor/inference/definer_resolution.rb +17 -3
  61. data/lib/rigor/inference/error_info.rb +17 -3
  62. data/lib/rigor/inference/expression_typer.rb +62 -13
  63. data/lib/rigor/inference/global_write_census.rb +36 -1
  64. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  65. data/lib/rigor/inference/loop_value_record.rb +53 -0
  66. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  67. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +110 -9
  68. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  69. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  70. data/lib/rigor/inference/method_dispatcher.rb +46 -16
  71. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  72. data/lib/rigor/inference/module_function_state.rb +12 -0
  73. data/lib/rigor/inference/mutation_widening.rb +5 -2
  74. data/lib/rigor/inference/narrowing.rb +66 -5
  75. data/lib/rigor/inference/object_mixins.rb +13 -1
  76. data/lib/rigor/inference/project_seed_tables.rb +2 -1
  77. data/lib/rigor/inference/project_yielder.rb +128 -0
  78. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  79. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  80. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  81. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  82. data/lib/rigor/inference/scope_indexer.rb +480 -103
  83. data/lib/rigor/inference/self_call_ivar_writes.rb +18 -6
  84. data/lib/rigor/inference/statement_evaluator.rb +658 -90
  85. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  86. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  87. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  88. data/lib/rigor/language_server/buffer_table.rb +6 -0
  89. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  90. data/lib/rigor/language_server/project_context.rb +59 -3
  91. data/lib/rigor/language_server/server.rb +5 -3
  92. data/lib/rigor/mcp/server.rb +9 -1
  93. data/lib/rigor/plugin/base.rb +26 -11
  94. data/lib/rigor/plugin/manifest.rb +29 -3
  95. data/lib/rigor/plugin/registry.rb +38 -0
  96. data/lib/rigor/protection/discovery_seed.rb +57 -2
  97. data/lib/rigor/rooted_glob.rb +10 -1
  98. data/lib/rigor/scope.rb +14 -5
  99. data/lib/rigor/sig_gen/generator.rb +308 -37
  100. data/lib/rigor/sig_gen/observation_collector.rb +4 -1
  101. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  102. data/lib/rigor/sig_gen/renderer.rb +6 -3
  103. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  104. data/lib/rigor/type/combinator.rb +4 -3
  105. data/lib/rigor/type/constant.rb +45 -3
  106. data/lib/rigor/type/hash_shape.rb +14 -9
  107. data/lib/rigor/type/struct_instance.rb +3 -2
  108. data/lib/rigor/version.rb +1 -1
  109. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +60 -43
  110. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  111. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  112. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  113. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  114. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +6 -5
  115. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  116. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  117. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  118. data/sig/rigor/analysis/baseline.rbs +3 -2
  119. data/sig/rigor/effects/file_collection.rbs +5 -6
  120. data/sig/rigor/effects/registry.rbs +8 -6
  121. data/sig/rigor/effects/summary.rbs +6 -5
  122. data/sig/rigor/environment.rbs +5 -3
  123. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  124. data/sig/rigor/inference.rbs +6 -0
  125. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  126. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  127. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  128. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  129. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  130. data/sig/rigor/plugin/io_boundary.rbs +4 -4
  131. data/sig/rigor/plugin/manifest.rbs +16 -18
  132. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  133. data/sig/rigor/plugin/registry.rbs +1 -1
  134. data/sig/rigor/scope.rbs +31 -50
  135. data/sig/rigor/type.rbs +0 -6
  136. metadata +20 -1
@@ -29,6 +29,7 @@ require_relative "check_rules/void_value_use_collector"
29
29
  require_relative "check_rules/self_closedness_scanner"
30
30
  require_relative "check_rules/source_arity"
31
31
  require_relative "check_rules/lexical_method_sites"
32
+ require_relative "check_rules/rebinding_block_callees"
32
33
  require_relative "check_rules/rbs_receiver_surface"
33
34
  require_relative "check_rules/special_global_setters"
34
35
  require_relative "check_rules/ruby_deprecations"
@@ -450,11 +451,31 @@ module Rigor
450
451
  other_class = ivar_class_for(write[:type])
451
452
  next nil if other_class.nil? || other_class == "NilClass" || other_class == first_class
452
453
  next nil if declaration_admits?(write[:declared], canonical[:type], write[:type])
454
+ next nil if separate_invocations?(write, other_class, writes)
453
455
 
454
456
  build_ivar_write_mismatch_diagnostic(path, write[:node], class_name, ivar_name, first_class, other_class)
455
457
  end
456
458
  end
457
459
 
460
+ # ADR-125 — a divergent write is never live together with the writes it conflicts with when the class
461
+ # serves one entry point per instance (a plugin-declared per-invocation class, e.g. a Rails controller:
462
+ # one action per request) and every conflicting write — non-nil, of a concrete class other than this
463
+ # write's — sits in a DIFFERENT action of the same class. An action is a public method no declared
464
+ # non-action macro names (`before_action :x`, `helper_method :y`); {IvarWriteCollector} decides it. A
465
+ # conflicting write in a private or protected helper, a callback target, a `helper_method`,
466
+ # `initialize`, or the same method keeps the finding. Which helper each action reaches is not proved
467
+ # here.
468
+ def separate_invocations?(write, write_class, writes)
469
+ return false unless write[:action]
470
+
471
+ writes.all? do |other|
472
+ other_class = ivar_class_for(other[:type])
473
+ next true if other_class.nil? || other_class == "NilClass" || other_class == write_class
474
+
475
+ other[:action] && other[:method_name] != write[:method_name]
476
+ end
477
+ end
478
+
458
479
  def declaration_admits?(declared, *write_types)
459
480
  return false if declared.nil?
460
481
 
@@ -969,7 +990,8 @@ module Rigor
969
990
  # ({RbsReceiverSurface}). Last for {#ancestry_declares_method?}'s reason: it walks the class graph.
970
991
  def rbs_ancestor_may_answer?(class_name, method_name, scope, kind)
971
992
  if kind == :singleton
972
- RbsReceiverSurface.singleton_hook?(scope, class_name)
993
+ RbsReceiverSurface.singleton_hook?(scope, class_name) ||
994
+ RbsReceiverSurface.singleton_mixin_may_answer?(scope, class_name, method_name)
973
995
  else
974
996
  RbsReceiverSurface.ancestor_may_answer?(scope, class_name, method_name)
975
997
  end
@@ -1225,12 +1247,22 @@ module Rigor
1225
1247
  # body is morally a class body, so ADR-34 stays silent there — including on a genuinely
1226
1248
  # undefined name inside the block. Ranges are computed once per file.
1227
1249
  return nil if call_inside_receiver_eval_ranges?(eval_ranges, call_node)
1228
- # ADR-121 WD7 — a refinement of `Object` (or one Rigor cannot read) in effect here may define the name.
1229
- return nil if refined_method_in_effect?("Object", call_node, scope, :instance, lexical_sites)
1250
+ return nil if lexically_declined_toplevel_call?(call_node, scope, lexical_sites, scope_index)
1230
1251
 
1231
1252
  build_unresolved_toplevel_diagnostic(path, call_node)
1232
1253
  end
1233
1254
 
1255
+ # The `call.unresolved-toplevel` declines that read the file's syntax, asked last because they walk it.
1256
+ def lexically_declined_toplevel_call?(call_node, scope, lexical_sites, scope_index = nil)
1257
+ # ADR-121 WD7 — a refinement of `Object` (or one Rigor cannot read) in effect here may define the name.
1258
+ return true if refined_method_in_effect?("Object", call_node, scope, :instance, lexical_sites)
1259
+ return false if lexical_sites.nil?
1260
+
1261
+ # Issue #1817 — a block handed to a project method that may `module_eval` / `instance_exec` it runs under
1262
+ # another `self`, so a call in it is not a top-level one ({RebindingBlockCallees}).
1263
+ RebindingBlockCallees.inside_rebound_block?(call_node, scope, lexical_sites.block_calls, scope_index)
1264
+ end
1265
+
1234
1266
  # Issue #1383 — RBS core declares `main`'s private singleton methods on
1235
1267
  # `RBS::Unnamed::TopLevelSelfClass`, so the set follows RBS rather than a hand-kept
1236
1268
  # list. CRuby 4.0's `main` also has `ruby2_keywords`, which RBS does not declare.
@@ -1487,12 +1519,27 @@ module Rigor
1487
1519
  # overlay open receiver ({GEM_OVERLAY_OPEN_RECEIVERS}) whose
1488
1520
  # bundled directory this run actually loaded, a core-overlay
1489
1521
  # open receiver ({CORE_OVERLAY_OPEN_RECEIVERS}), or a
1490
- # Rigor-synthesized stub type (see `RbsLoader#synthesized_type_names`).
1522
+ # Rigor-synthesized stub type (see `RbsLoader#synthesized_type_names`),
1523
+ # or a project-only reopen of a Ruby library class whose own RBS
1524
+ # did not load ({#ruby_library_reopen?}).
1491
1525
  def unbounded_receiver_surface?(class_name, scope)
1492
1526
  bare_name = class_name.to_s.sub(/\A::/, "")
1493
1527
  open_receiver?(class_name, scope) || synthesized_stub_receiver?(class_name, scope) ||
1494
1528
  CORE_OVERLAY_OPEN_RECEIVERS.include?(bare_name) ||
1495
- (GEM_OVERLAY_OPEN_RECEIVERS.include?(bare_name) && gem_overlay_loaded?(scope))
1529
+ (GEM_OVERLAY_OPEN_RECEIVERS.include?(bare_name) && gem_overlay_loaded?(scope)) ||
1530
+ ruby_library_reopen?(class_name, scope)
1531
+ end
1532
+
1533
+ # Issue #2174 — `class Matrix` reopened with one `#:`-annotated method, and no `matrix` signatures
1534
+ # loaded, gives Matrix a declaration holding only that method ({RbsLoader#ruby_library_reopen?}).
1535
+ # `Matrix.identity(2)` is correct Ruby, so the class's surface is treated as open: `call.undefined-method`
1536
+ # declines on it, and a signature it only inherits (`Object#initialize` for `Matrix.new(rows)`) stops
1537
+ # deciding arity and argument types. Methods the project's own declaration lists stay checked.
1538
+ def ruby_library_reopen?(class_name, scope)
1539
+ loader = scope.environment&.rbs_loader
1540
+ return false if loader.nil? || !loader.respond_to?(:ruby_library_reopen?)
1541
+
1542
+ loader.ruby_library_reopen?(class_name)
1496
1543
  end
1497
1544
 
1498
1545
  def open_receiver?(class_name, scope)
@@ -2780,9 +2827,37 @@ module Rigor
2780
2827
 
2781
2828
  # Whether any project or `pre_eval:` file aliases `name`, on either side, which can make it another variable.
2782
2829
  def global_aliased?(scope, name)
2830
+ record_global_census(Inference::GlobalWriteCensus.alias_entry(name))
2783
2831
  Inference::GlobalWriteCensus.aliased?(global_write_census(scope), name)
2784
2832
  end
2785
2833
 
2834
+ # Issue #2158 — a read of the whole-program census records the `global_census:` key of each entry it asks
2835
+ # about ({Inference::GlobalWriteCensus.dependency_key}): a file that starts or stops writing the entry (a `def
2836
+ # write`, an `alias $stdout $x`, a computed `define_method`) re-checks the write. A `pre_eval:` file moves the
2837
+ # snapshot's own fingerprint.
2838
+ def record_global_census(*entries)
2839
+ return unless DependencyRecorder.active?
2840
+
2841
+ entries.each do |entry|
2842
+ DependencyRecorder.read_name(:global_census, Inference::GlobalWriteCensus.dependency_key(entry))
2843
+ end
2844
+ end
2845
+
2846
+ # Whether the census holds any of `entries`, recording only what can flip the answer: the entries present
2847
+ # when it holds one (the answer stays true until every one of them vanishes, and another appearing changes
2848
+ # nothing), every entry when it holds none.
2849
+ #
2850
+ # This rests on an invariant: every census entry can only move a write toward being silenced, and a reported
2851
+ # write is always re-checked through `IncrementalSession#global_write_reporters`, so recording only the
2852
+ # present entries on a yes is sound. A future census entry that makes a write reported would break it, and
2853
+ # its reader would have to record every entry. Recording every entry on a no is defence in depth, since the
2854
+ # write that answer leaves reported re-checks through `global_write_reporters` anyway.
2855
+ def census_holds_any?(census, entries)
2856
+ present = entries.select { |entry| census.include?(entry) }
2857
+ record_global_census(*(present.empty? ? entries : present))
2858
+ !present.empty?
2859
+ end
2860
+
2786
2861
  # A literal is rejected when its class is none the setter takes and, for a setter that also converts
2787
2862
  # (`to_str`, `to_int`) or asks for `write`, the literal's object cannot answer that method.
2788
2863
  def literal_rejected?(class_name, contract, node, scope, lexical_sites)
@@ -2830,11 +2905,15 @@ module Rigor
2830
2905
  end
2831
2906
 
2832
2907
  def program_may_answer?(ancestors, names, census, scope)
2833
- return true if Inference::GlobalWriteCensus.defines_any_of?(census, names)
2908
+ defines = [Inference::GlobalWriteCensus::DEFINES_ANY, *names.map { |name| [:defines, name] }]
2909
+ return true if census_holds_any?(census, defines)
2834
2910
 
2835
2911
  dynamic_mark = Scope::DiscoveryIndex::ENVELOPE_DYNAMIC_MARK
2836
2912
  return true if ancestors.any? { |owner| scope.parameter_envelopes_of(owner).key?(dynamic_mark) }
2837
2913
 
2914
+ # One key for every top-level mixin, a deliberate trade-off: the reader asks about each, so a mixin
2915
+ # appearing or vanishing anywhere re-checks every write that reached this step.
2916
+ record_global_census(%i[main_mixin *])
2838
2917
  main_mixins = Inference::GlobalWriteCensus.main_mixins(census)
2839
2918
  main_mixins.any? { |mixin| rbs_mixin_may_answer?(mixin, names, scope) } ||
2840
2919
  mixin_may_answer?(ancestors, names, scope)
@@ -2876,7 +2955,7 @@ module Rigor
2876
2955
  # active refinement on Ruby 4.0.5; a refined `respond_to_missing?` or `respond_to?`, and every implicit
2877
2956
  # conversion, do not, so a refinement that adds only those leaves the write reported.
2878
2957
  def refined_writer_in_effect?(census, node, scope, lexical_sites)
2879
- Inference::GlobalWriteCensus.refines_write?(census) &&
2958
+ census_holds_any?(census, [Inference::GlobalWriteCensus::REFINES_ANY, %i[refines write]]) &&
2880
2959
  (lexical_sites.nil? || !scope.declared_refinements.empty? ||
2881
2960
  lexical_sites.refinements.any_at?(node.location.start_offset))
2882
2961
  end
@@ -3032,6 +3111,7 @@ module Rigor
3032
3111
  # `Widget.new` would read, and file the dependency edges of, the whole singleton chain. The set is memoised per
3033
3112
  # discovery index.
3034
3113
  def private_singleton_candidate?(scope, name)
3114
+ DependencyRecorder.read_visibility("*", name) if DependencyRecorder.active?
3035
3115
  discovery = scope.discovery
3036
3116
  slot = Thread.current[PRIVATE_NAMES_MEMO]
3037
3117
  unless slot && slot[0].equal?(discovery)
@@ -3058,7 +3138,8 @@ module Rigor
3058
3138
  return true if rows.nil?
3059
3139
 
3060
3140
  offset = call_node.location.start_offset
3061
- rows.none? { |(start, finish, name, _kind, _owner)| !name.nil? && start <= offset && offset < finish }
3141
+ # A row with no side (a `def` in a block, a `define_method` name) is a block's range or no range at all.
3142
+ rows.none? { |(start, finish, _name, kind, _owner)| !kind.nil? && start <= offset && offset < finish }
3062
3143
  end
3063
3144
 
3064
3145
  SINGLETON_WILDCARD_SLOT = %i[singleton *].freeze
@@ -3070,6 +3151,7 @@ module Rigor
3070
3151
  discovery = scope.discovery
3071
3152
  chain = Scope::ResolutionChain.for(scope, class_name.to_s, :singleton, :methods)
3072
3153
  chain.entries.any? do |entry|
3154
+ DependencyRecorder.read_visibility(entry.name, SINGLETON_WILDCARD_SLOT.last) unless entry.external?
3073
3155
  !entry.external? && discovery.contested?(:discovered_method_visibilities,
3074
3156
  [entry.name, SINGLETON_WILDCARD_SLOT])
3075
3157
  end
@@ -4202,6 +4284,9 @@ module Rigor
4202
4284
 
4203
4285
  override_visibility = scope.discovered_method_visibility(class_name, method_name)
4204
4286
  return nil if override_visibility.nil?
4287
+ # Issue #2013 — a contested slot (a later `define_method` or alias gave the name another visibility, or a
4288
+ # write the walk cannot place) proves no reduction.
4289
+ return nil if scope.discovery.contested?(:discovered_method_visibilities, [class_name, method_name])
4205
4290
 
4206
4291
  parent = nearest_ancestor_visibility(scope, class_name, method_name)
4207
4292
  return nil if parent.nil?
@@ -406,6 +406,17 @@ module Rigor
406
406
  end
407
407
  end
408
408
 
409
+ # Issue #2134 — {read_name} for a read of `class_name`'s visibility slots for `method_name`, on either side, or
410
+ # of their contest: `visibility:<class>#<method>`. `IncrementalSession#visibility_name_affected` fires the key
411
+ # when a file's visibility entry or contest for the slot moves. A reader that asks whether ANY class records
412
+ # the name private passes `"*"` as the class; the key fires when any entry of the name moves. A wildcard
413
+ # slot (`[:singleton, :*]`) is `*` as the method name.
414
+ def read_visibility(class_name, method_name)
415
+ return if Thread.current[KEY].nil?
416
+
417
+ read_name(:visibility, "#{class_name}##{method_name}")
418
+ end
419
+
409
420
  # The miss-only spelling of {read_name}: a cross-file lookup of `name` (kind `:method` / `:class` /
410
421
  # `:toplevel` / …) that resolved to nothing. Kept as the name the internal spec's negative-edge
411
422
  # contract uses.
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ # Issue #1967 — the edits `rigor check --verify-incremental-edits` replays on each sampled file, as the text an
8
+ # editor buffer stands in for it with ({IncrementalSession#replay_edit}).
9
+ #
10
+ # - `emptied` — the file with nothing in it. Every declaration and definition it makes disappears, so the replay
11
+ # exercises the edges a removed declaration fires.
12
+ # - `skeleton` — the file's `class` / `module` declarations, nested as written, with every body statement dropped
13
+ # (methods, `refine` blocks, visibility calls, mixins, constants). The declarations stay, so the class-level keys
14
+ # a removed declaration fires stay quiet, and a finer edge such as #2163's `refinement:` is what puts a reader
15
+ # into the closure. Emptying alone does not exercise that one: the broader key it fires re-checks the reader
16
+ # anyway. Not every finer edge is reached: dropping a body still moves a broader key for some of them. A
17
+ # `visibility:` edge is one (#2161's `module_function :two` to `private :two` swap passes both edits with the
18
+ # edge removed), and only an edit that changes a body while keeping it (#2277) would test it.
19
+ #
20
+ # Neither replays an addition or a body-changing edit. A skeleton identical to the file, or with no declaration
21
+ # (the emptied file again, or a file that does not parse), is not replayed.
22
+ module EditReplay
23
+ Edit = Data.define(:label, :source)
24
+
25
+ module_function
26
+
27
+ # The edits to replay for `source`, in order.
28
+ def edits(source)
29
+ emptied = Edit.new(label: "emptying", source: "")
30
+ reduced = skeleton(source)
31
+ return [emptied] if reduced.empty? || reduced == source
32
+
33
+ [emptied, Edit.new(label: "reducing to its class/module skeleton", source: reduced)]
34
+ end
35
+
36
+ # `source`'s `class` / `module` declarations, nested, with their bodies dropped; "" when it does not parse or
37
+ # declares nothing.
38
+ def skeleton(source)
39
+ result = Prism.parse(source)
40
+ return "" unless result.success?
41
+
42
+ lines = declarations(result.value.statements, 0)
43
+ lines.empty? ? "" : "#{lines.join("\n")}\n"
44
+ end
45
+
46
+ # The skeleton lines of the declarations directly in `statements`, indented `depth` levels.
47
+ def declarations(statements, depth)
48
+ return [] unless statements.is_a?(Prism::StatementsNode)
49
+
50
+ statements.body.flat_map do |node|
51
+ next [] unless node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
52
+
53
+ indent = " " * depth
54
+ ["#{indent}#{header_of(node)}", *declarations(node.body, depth + 1), "#{indent}end"]
55
+ end
56
+ end
57
+
58
+ def header_of(node)
59
+ return "module #{node.constant_path.slice}" if node.is_a?(Prism::ModuleNode)
60
+
61
+ superclass = node.superclass
62
+ superclass ? "class #{node.constant_path.slice} < #{superclass.slice}" : "class #{node.constant_path.slice}"
63
+ end
64
+ private_class_method :declarations, :header_of
65
+ end
66
+ end
67
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../inference/global_write_census"
4
+
3
5
  module Rigor
4
6
  module Analysis
5
7
  # ADR-46 slice 2 — the pure set-algebra core of the incremental step, kept side-effect-free and
@@ -132,6 +134,41 @@ module Rigor
132
134
  moved.freeze
133
135
  end
134
136
 
137
+ # Issue #2158 — the `global_census:` keys whose entries moved in this edit: one for every entry that appeared in
138
+ # or vanished from a file's `global_write_census` ({Inference::GlobalWriteCensus.dependency_key}). `paths` MUST
139
+ # include removed files, whose whole census vanished. `before` / `after` map a path to that file's census Set (nil
140
+ # when it records nothing).
141
+ def changed_global_census_keys(paths, before, after)
142
+ moved = Set.new
143
+ paths.each do |path|
144
+ ((before[path] || Set.new) ^ (after[path] || Set.new)).each do |entry|
145
+ moved << "global_census:#{Inference::GlobalWriteCensus.dependency_key(entry)}"
146
+ end
147
+ end
148
+ moved.freeze
149
+ end
150
+
151
+ # The {Inference::ScopeIndexer.visibility_entries} entries that carry a leading marker before `class, slot`.
152
+ MARKED_VISIBILITY_ENTRIES = %i[contested module_function].freeze
153
+
154
+ # Issue #2134 — the `<class>#<method>` slots whose visibility entries or contests moved in this edit: one for
155
+ # every entry that appeared in or vanished from a file's {Inference::ScopeIndexer.visibility_entries}, a
156
+ # singleton slot's `[:singleton, name]` read as `name` (the wildcard slot as `*`), plus `*#<method>`, which the
157
+ # "is the name private anywhere" readers record. That key fires for an entry of any visibility: the merge is
158
+ # last-writer-wins, so dropping a `public :foo` can uncover another file's `private :foo`. `paths` MUST include
159
+ # removed files. Issue #2161 — a `[:module_function, owner, name]` entry fires `<owner>#<name>` the same way.
160
+ def changed_visibility_slots(paths, before, after)
161
+ moved = Set.new
162
+ paths.each do |path|
163
+ ((before[path] || Set.new) ^ (after[path] || Set.new)).each do |entry|
164
+ class_name, slot = MARKED_VISIBILITY_ENTRIES.include?(entry.first) ? entry.drop(1) : entry
165
+ name = slot.is_a?(Array) ? slot.last : slot
166
+ moved << "#{class_name}##{name}" << "*##{name}"
167
+ end
168
+ end
169
+ moved.freeze
170
+ end
171
+
135
172
  # Issue #1120 — the method names whose refinements moved in this edit. A consumer whose answer consulted the
136
173
  # refinement table recorded a `refinement:<method>` edge whichever way it answered, because the answer is a
137
174
  # function of the whole project's refinements of that name. `paths` MUST include removed files, whose whole