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
@@ -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/rbs_receiver_surface"
32
33
  require_relative "check_rules/special_global_setters"
33
34
  require_relative "check_rules/ruby_deprecations"
34
35
 
@@ -79,19 +80,8 @@ module Rigor
79
80
  # runtime one and `def.override-visibility-reduced` cannot prove a reduction.
80
81
  IMPLICITLY_PRIVATE_METHODS = (LIFECYCLE_HOOKS + %i[respond_to_missing?]).freeze
81
82
 
82
- # Resolves a user-supplied rule token (`undefined-method`,
83
- # `call.undefined-method`, or the family wildcard `call`)
84
- # to the set of canonical rule identifiers it disables.
85
- # Returns `nil` for `"all"` (the existing wildcard meaning
86
- # "every rule"), or for unknown tokens.
87
- def self.resolve_rule_token(token)
88
- return nil if token == "all"
89
- return [LEGACY_RULE_ALIASES.fetch(token)] if LEGACY_RULE_ALIASES.key?(token)
90
- return PRE_EVAL_RULES if token == "pre-eval"
91
- return ALL_RULES.select { |r| r.start_with?("#{token}.") } if RULE_FAMILIES.include?(token)
92
-
93
- ALL_RULES.include?(token) ? [token] : []
94
- end
83
+ # The token expansion (`resolve_rule_token` / `expand_rule_tokens` / `expand_token`) lives in the light
84
+ # `check_rules/rule_ids.rb`, so {SeverityStamp} can apply the `disable:` list on the engine-free hit path.
95
85
 
96
86
  module_function
97
87
 
@@ -480,7 +470,7 @@ module Rigor
480
470
  # - **File-level** (v0.1.2): `# rigor:disable-file <rule1>,
481
471
  # <rule2>` anywhere in the file suppresses the matching
482
472
  # diagnostic for every line. `# rigor:disable-file all`
483
- # suppresses every rule across the file. Convention is
473
+ # suppresses every built-in rule across the file. Convention is
484
474
  # to put the comment near the top, but Rigor accepts it
485
475
  # anywhere — the comment scope is "this file" regardless
486
476
  # of position.
@@ -488,7 +478,8 @@ module Rigor
488
478
  # on the same line as the offending expression
489
479
  # suppresses the matching diagnostic for that line
490
480
  # only. `# rigor:disable all` on a line suppresses
491
- # every rule on that line.
481
+ # every built-in rule on that line. A plugin's rows are
482
+ # not passed here; see {.filter_plugin_suppressed}.
492
483
  #
493
484
  # Both in-source forms are recognised only when the
494
485
  # marker is the FIRST thing in the comment, so a
@@ -507,7 +498,7 @@ module Rigor
507
498
  diagnostics.reject do |diagnostic|
508
499
  rule = diagnostic.rule
509
500
  next false if rule.nil?
510
- next true if disabled.include?(rule)
501
+ next true if config_disabled?(rule, disabled)
511
502
  next true if file_suppressions.include?("all") || file_suppressions.include?(rule)
512
503
 
513
504
  line_rules = line_suppressions[diagnostic.line]
@@ -515,6 +506,26 @@ module Rigor
515
506
  end
516
507
  end
517
508
 
509
+ # Issue #1953 — the in-source markers over the rows a plugin emitted while `path` was analysed
510
+ # (`#diagnostics_for_file` and node rules). A row answers to a `plugin.<id>.<rule>` or `plugin.<id>` token
511
+ # only ({.plugin_row_disabled?}); a bare id and `all` keep reaching built-in rows alone. `comments` are
512
+ # `path`'s, so a row the plugin positioned in another file is left to the `disable:` list and the baseline.
513
+ # The `disable:` list itself is applied to these rows by {SeverityStamp}, on every run shape.
514
+ def filter_plugin_suppressed(diagnostics, path:, comments:)
515
+ return diagnostics if diagnostics.empty?
516
+
517
+ line_suppressions, file_suppressions = parse_suppression_comments(comments)
518
+ return diagnostics if line_suppressions.empty? && file_suppressions.empty?
519
+
520
+ diagnostics.reject do |diagnostic|
521
+ next false unless diagnostic.path == path
522
+ next true if plugin_row_disabled?(diagnostic, file_suppressions)
523
+
524
+ line_rules = line_suppressions.fetch(diagnostic.line, nil)
525
+ !line_rules.nil? && plugin_row_disabled?(diagnostic, line_rules)
526
+ end
527
+ end
528
+
518
529
  # Every suppression-recognition pattern below is anchored with `\A` against the COMMENT SLICE —
519
530
  # Prism hands us a slice that starts at the `#`, so `\A#` means "the marker is the first thing in
520
531
  # the comment". A real directive always has that shape, in both the whole-line form and the
@@ -704,24 +715,6 @@ module Rigor
704
715
  )
705
716
  end
706
717
 
707
- # Expands a list of user-supplied rule tokens into the
708
- # canonical-id set per ADR-8 § "Backward compatibility".
709
- # `disabled_rules` accepts unprefixed legacy names
710
- # (`undefined-method`), canonical names
711
- # (`call.undefined-method`), and family wildcards (`call`).
712
- def expand_rule_tokens(tokens)
713
- Array(tokens).each_with_object(Set.new) do |token, set|
714
- set.merge(expand_token(token.to_s))
715
- end
716
- end
717
-
718
- def expand_token(token)
719
- return ["all"] if token == "all"
720
-
721
- resolved = resolve_rule_token(token)
722
- resolved.nil? || resolved.empty? ? [token] : resolved
723
- end
724
-
725
718
  # rubocop:disable-next Metrics/ClassLength
726
719
  class << self
727
720
  private
@@ -809,7 +802,7 @@ module Rigor
809
802
  # rule must not surface a false `undefined-method`
810
803
  # for them.
811
804
  kind = receiver_type.is_a?(Type::Singleton) ? :singleton : :instance
812
- return nil if source_declared_method?(scope, class_name, call_node.name, kind)
805
+ return nil if source_declared_method?(scope, class_name, call_node.name, kind, call_node: call_node)
813
806
 
814
807
  return nil unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
815
808
 
@@ -825,6 +818,8 @@ module Rigor
825
818
  return nil if method_def
826
819
 
827
820
  return nil if last_resort_surface_answers?(receiver_type, class_name, call_node, scope, kind, lexical_sites)
821
+ return nil if subclass_instance_may_answer?(rendered_receiver, class_name, call_node.name, scope,
822
+ call_node.receiver)
828
823
 
829
824
  definition_site = project_definition_site(scope, class_name, call_node.name, kind)
830
825
  # Issue #735 — the site is evidence, and whose RBS this is decides what it is evidence OF. On a
@@ -959,12 +954,51 @@ module Rigor
959
954
  # (see {#ancestry_declares_method?} for why that placement is load-bearing, not tidiness).
960
955
  def last_resort_surface_answers?(receiver_type, class_name, call_node, scope, kind, lexical_sites = nil)
961
956
  return true if lexically_defined_method?(class_name, call_node, scope, kind, lexical_sites)
957
+ return true if project_patched_through_ancestors?(scope, class_name, call_node.name, kind,
958
+ call_node: call_node)
962
959
  return true if module_mixin_receiver?(receiver_type, scope)
963
960
  return true if mixin_self_class_receiver?(call_node, scope)
964
961
  return true if unknown_mixin_includer?(class_name, scope)
965
962
  return true if object_mixin_may_define?(receiver_type, class_name, call_node.name, scope)
963
+ return true if ancestry_declares_method?(scope, class_name, call_node.name, kind)
966
964
 
967
- ancestry_declares_method?(scope, class_name, call_node.name, kind)
965
+ rbs_ancestor_may_answer?(class_name, call_node.name, scope, kind)
966
+ end
967
+
968
+ # Issue #1722 — a `method_missing`, or a project mixin, on one of the class's RBS ancestors
969
+ # ({RbsReceiverSurface}). Last for {#ancestry_declares_method?}'s reason: it walks the class graph.
970
+ def rbs_ancestor_may_answer?(class_name, method_name, scope, kind)
971
+ if kind == :singleton
972
+ RbsReceiverSurface.singleton_hook?(scope, class_name)
973
+ else
974
+ RbsReceiverSurface.ancestor_may_answer?(scope, class_name, method_name)
975
+ end
976
+ end
977
+
978
+ # Issue #1722 — a receiver typed by its class's name, rather than by a literal, may hold an instance of a
979
+ # subclass (a `#: () -> String` signature admits a `MyStr < String`), so a method a project subclass
980
+ # defines, or a subclass's `method_missing`, may be the one that runs. A value the engine built from a
981
+ # literal is exactly its class and keeps firing: a literal type (`"a"`, `1`, a union of them), a shape
982
+ # carrier (`{a: 1}`, `[1, 2]`, `{}`, `[]`, and what folds to one), or a receiver written as a literal
983
+ # (`"a#{x}b"`, which types as plain `String`). A local holding an interpolated String types as plain
984
+ # `String` with no origin, so it still declines. Instance side only.
985
+ def subclass_instance_may_answer?(receiver_type, class_name, method_name, scope, receiver_node = nil)
986
+ return false if receiver_type.is_a?(Type::Singleton)
987
+ return false if exact_literal_receiver?(receiver_type) || LITERAL_RECEIVER_NODES.include?(receiver_node.class)
988
+
989
+ RbsReceiverSurface.subclass_may_define?(scope, class_name, method_name)
990
+ end
991
+
992
+ EXACT_TYPES = [Type::Constant, Type::Tuple, Type::HashShape].freeze
993
+ LITERAL_RECEIVER_NODES = [
994
+ Prism::StringNode, Prism::InterpolatedStringNode, Prism::XStringNode, Prism::InterpolatedXStringNode,
995
+ Prism::ArrayNode, Prism::HashNode, Prism::RegularExpressionNode, Prism::InterpolatedRegularExpressionNode
996
+ ].to_set.freeze
997
+ private_constant :EXACT_TYPES, :LITERAL_RECEIVER_NODES
998
+
999
+ def exact_literal_receiver?(receiver_type)
1000
+ members = receiver_type.is_a?(Type::Union) ? receiver_type.members : [receiver_type]
1001
+ members.all? { |member| EXACT_TYPES.any? { |kind| member.is_a?(kind) } }
968
1002
  end
969
1003
 
970
1004
  # Issue #1697 — a module the project mixes into `Object` (a top-level `include M`, or `include M` in
@@ -1066,10 +1100,13 @@ module Rigor
1066
1100
  # Both paths sit at the same dispatcher precedence; the
1067
1101
  # check must hold them together so neither rule fires a
1068
1102
  # false positive.
1069
- def source_declared_method?(scope, class_name, method_name, kind)
1103
+ #
1104
+ # `call_node:` (issue #1708), when given, is the call asking: a private or protected `pre_eval:` patch
1105
+ # it may not reach does not count ({#patch_callable?}).
1106
+ def source_declared_method?(scope, class_name, method_name, kind, call_node: nil)
1070
1107
  return true if scope.discovered_method?(class_name, method_name, kind)
1071
1108
 
1072
- project_patched_method?(scope, class_name, method_name, kind)
1109
+ project_patched_method?(scope, class_name, method_name, kind, call_node: call_node)
1073
1110
  end
1074
1111
 
1075
1112
  # Issue #723 — the same question as {#source_declared_method?}, asked through the project's own
@@ -1095,12 +1132,42 @@ module Rigor
1095
1132
  # inference (between plugins and dependency-source).
1096
1133
  # Returns false when the environment carries no registry
1097
1134
  # (legacy path) or the lookup misses.
1098
- def project_patched_method?(scope, class_name, method_name, kind)
1135
+ def project_patched_method?(scope, class_name, method_name, kind, call_node: nil)
1099
1136
  environment = scope.environment
1100
1137
  registry = environment&.project_patched_methods
1101
1138
  return false if registry.nil? || registry.empty?
1102
1139
 
1103
- !registry.lookup(class_name: class_name.to_s, method_name: method_name.to_sym, kind: kind).nil?
1140
+ entry = registry.lookup(class_name: class_name.to_s, method_name: method_name.to_sym, kind: kind)
1141
+ !entry.nil? && patch_callable?(registry, entry, scope, call_node)
1142
+ end
1143
+
1144
+ # Issue #1708 — {#project_patched_method?} asked through the receiver's ancestors: a `pre_eval:` patch on
1145
+ # `Object`, `Numeric` or `Enumerable` gives an `Integer` or `Array` the method. Asked only once the
1146
+ # receiver's RBS has come back without the method, so a nearer class's own declaration still decides arity
1147
+ # and argument types; {#source_declared_method?} stays exact for the rules that read it for those.
1148
+ #
1149
+ # `call_node:` as {#source_declared_method?} reads it.
1150
+ def project_patched_through_ancestors?(scope, class_name, method_name, kind, call_node: nil)
1151
+ environment = scope.environment
1152
+ registry = environment&.project_patched_methods
1153
+ return false if registry.nil? || registry.empty?
1154
+
1155
+ entry = registry.lookup_through_ancestors(class_name: class_name.to_s, method_name: method_name.to_sym,
1156
+ kind: kind, rbs_loader: environment.rbs_loader)
1157
+ !entry.nil? && patch_callable?(registry, entry, scope, call_node)
1158
+ end
1159
+
1160
+ # Issue #1708 — whether `call_node` may reach the patch `entry`: Ruby raises NoMethodError for a private
1161
+ # patch called with an explicit receiver other than `self` (`1.kp` on `module Kernel; private def kp`), and
1162
+ # for a protected one called from outside its owner's instances. No call node, and a declined verdict (another
1163
+ # definer the call may reach, {Inference::ProjectPatchedMethods#callable?}), are the permissive answer.
1164
+ def patch_callable?(registry, entry, scope, call_node)
1165
+ return true if call_node.nil?
1166
+
1167
+ receiver = call_node.receiver
1168
+ verdict = registry.callable?(entry, explicit: !receiver.nil? && !receiver.is_a?(Prism::SelfNode),
1169
+ self_type: scope.self_type, rbs_loader: scope.environment.rbs_loader)
1170
+ verdict != false
1104
1171
  end
1105
1172
 
1106
1173
  # ADR-34 — `call.unresolved-toplevel`. Fires on an
@@ -1149,6 +1216,8 @@ module Rigor
1149
1216
  return nil if scope.top_level_def_for(name)
1150
1217
  return nil if source_declared_method?(scope, "Object", name, :instance)
1151
1218
  return nil if Rigor::Reflection.instance_method_definition("Object", name, scope: scope)
1219
+ # Issue #1708 — `main` is an Object, so a `pre_eval:` patch on `Kernel` or `BasicObject` defines the name too.
1220
+ return nil if project_patched_through_ancestors?(scope, "Object", name, :instance)
1152
1221
  return nil if main_singleton_method?(name, scope)
1153
1222
  return nil if Inference::ObjectMixins.may_define?(scope, name, receiver: :implicit)
1154
1223
  # `Target.class_eval { def added = 1; def use_added = added }` files the defs on Target
@@ -1393,6 +1462,21 @@ module Rigor
1393
1462
  GEM_OVERLAY_OPEN_RECEIVERS = Set["ActiveSupport::Duration", "ActiveSupport::Concern"].freeze
1394
1463
  private_constant :GEM_OVERLAY_OPEN_RECEIVERS
1395
1464
 
1465
+ # Issue #1456 — the `data/core_overlay/` twin of {GEM_OVERLAY_OPEN_RECEIVERS}. `OpenStruct` defines each field
1466
+ # as a per-instance singleton method and answers the rest through a private `method_missing` (ostruct 0.6), so
1467
+ # no signature can enumerate its surface. The only other declaration in the environment is json's add-on
1468
+ # reopening (`json_create` / `as_json` / `to_json`), which made the class read as fully declared and every
1469
+ # field access a `call.undefined-method`. `data/core_overlay/ostruct.rbs` declares that `method_missing`, and
1470
+ # an RBS-declared `method_missing` does not by itself open a receiver here, so the class is named. No load
1471
+ # gate is needed: the core overlay loads into every environment that has RBS at all, so the list is active
1472
+ # whenever the declaration it protects is. Both sides of the class are open, as with
1473
+ # {GEM_OVERLAY_OPEN_RECEIVERS}, and an inherited Object / Kernel signature stops deciding `call.wrong-arity`
1474
+ # and `call.argument-type-mismatch` ({#unauthoritative_inherited_signature?}): lost true positives,
1475
+ # accepted. The general rule — a class declared only by another library's partial reopening — is #1978, not
1476
+ # this list.
1477
+ CORE_OVERLAY_OPEN_RECEIVERS = Set["OpenStruct"].freeze
1478
+ private_constant :CORE_OVERLAY_OPEN_RECEIVERS
1479
+
1396
1480
  # ADR-26 — whether `class_name` is declared "open" by a
1397
1481
  # loaded plugin (manifest `open_receivers:`). An open
1398
1482
  # class responds beyond its RBS surface, so the
@@ -1401,11 +1485,14 @@ module Rigor
1401
1485
  # RBS method table, so proving a call "undefined" against it is
1402
1486
  # unsound: a plugin-declared open receiver, a Rigor-bundled-
1403
1487
  # overlay open receiver ({GEM_OVERLAY_OPEN_RECEIVERS}) whose
1404
- # bundled directory this run actually loaded, or a
1488
+ # bundled directory this run actually loaded, a core-overlay
1489
+ # open receiver ({CORE_OVERLAY_OPEN_RECEIVERS}), or a
1405
1490
  # Rigor-synthesized stub type (see `RbsLoader#synthesized_type_names`).
1406
1491
  def unbounded_receiver_surface?(class_name, scope)
1492
+ bare_name = class_name.to_s.sub(/\A::/, "")
1407
1493
  open_receiver?(class_name, scope) || synthesized_stub_receiver?(class_name, scope) ||
1408
- (GEM_OVERLAY_OPEN_RECEIVERS.include?(class_name.to_s.sub(/\A::/, "")) && gem_overlay_loaded?(scope))
1494
+ CORE_OVERLAY_OPEN_RECEIVERS.include?(bare_name) ||
1495
+ (GEM_OVERLAY_OPEN_RECEIVERS.include?(bare_name) && gem_overlay_loaded?(scope))
1409
1496
  end
1410
1497
 
1411
1498
  def open_receiver?(class_name, scope)
@@ -2133,6 +2220,7 @@ module Rigor
2133
2220
  return true unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
2134
2221
  return true unless definition_available?(member, class_name, scope)
2135
2222
  return true if lookup_method(member, class_name, method_name, scope)
2223
+ return true if project_patched_through_ancestors?(scope, class_name, method_name, :instance)
2136
2224
 
2137
2225
  # Issue #723 — the arm's own RBS does not have it; its project ancestry still might. Last, for
2138
2226
  # the reason {#ancestry_declares_method?} carries: this is the probe that walks the class graph.
@@ -2186,12 +2274,30 @@ module Rigor
2186
2274
  # `compose_codepoints` typed `Hash | Hash` for an `Array`, flagging
2187
2275
  # `.pack`). Require at least two distinct arm classes.
2188
2276
  return nil if members.map { |member| concrete_class_name(member) }.uniq.size < 2
2189
- return nil if members.any? { |member| method_present_anywhere?(member, call_node.name, scope) }
2190
- return nil if refined_on_some_arm?(members, call_node, scope, lexical_sites)
2277
+ return nil if union_may_answer?(members, call_node, scope, lexical_sites)
2191
2278
 
2192
2279
  build_undefined_method_diagnostic(path, call_node, receiver_type)
2193
2280
  end
2194
2281
 
2282
+ # Whether some arm of a non-nil union of classes may answer the call, which keeps the union rule silent.
2283
+ def union_may_answer?(members, call_node, scope, lexical_sites)
2284
+ return true if members.any? { |member| method_present_anywhere?(member, call_node.name, scope) }
2285
+ return true if members.any? { |member| arm_surface_may_answer?(member, call_node.name, scope) }
2286
+ # Issue #1722 — `def o.m`, `o.extend(M)` and their kin give the one object the method, whatever its class.
2287
+ return true if lexical_sites&.singleton_local_def?(call_node)
2288
+
2289
+ refined_on_some_arm?(members, call_node, scope, lexical_sites)
2290
+ end
2291
+
2292
+ # Issue #1722 — the scalar rule's RBS-ancestor and subclass declines, asked of one arm. Kept out of
2293
+ # {#method_present_anywhere?}, which `call.possible-nil-receiver` also asks: there a "present" answer is
2294
+ # what lets the rule fire, and a `method_missing` on `Object` answers on the `nil` arm too.
2295
+ def arm_surface_may_answer?(member, method_name, scope)
2296
+ class_name = concrete_class_name(member)
2297
+ rbs_ancestor_may_answer?(class_name, method_name, scope, :instance) ||
2298
+ subclass_instance_may_answer?(member, class_name, method_name, scope)
2299
+ end
2300
+
2195
2301
  # ADR-121 WD7 — a refinement in effect (or one Rigor cannot read) may define the name on one arm's class.
2196
2302
  def refined_on_some_arm?(members, call_node, scope, lexical_sites)
2197
2303
  members.any? do |member|
@@ -2855,6 +2961,13 @@ module Rigor
2855
2961
  # - Issue #1568 — a module the class PREPENDS sits ahead of it in Ruby's order, so a definition there
2856
2962
  # is what the call reaches; the class's own private `def` answers only when nothing prepended
2857
2963
  # defines the name ({#reached_definer_visibility}).
2964
+ # - Issue #1984 — a class-object receiver (`Type::Singleton`, `K.x`) is checked against the singleton side:
2965
+ # the definition the call reaches on `K`'s singleton chain (its own singleton methods, its extended
2966
+ # modules' instance methods, then the superclass's singleton chain) must be recorded `:private`
2967
+ # (`private_class_method`, `class << self` with `private`, a private method of an extended module).
2968
+ # - Issue #1984 — Ruby hands a private call to `method_missing`, so a `method_missing` or
2969
+ # `respond_to_missing?` a project entry on the receiver's chain records, on the side the call reaches,
2970
+ # declines the rule ({#missing_hook_on_chain?}).
2858
2971
  def visibility_mismatch_diagnostic(path, call_node, scope_index, lexical_sites = nil)
2859
2972
  return nil unless explicit_non_self_receiver?(call_node.receiver)
2860
2973
 
@@ -2866,18 +2979,125 @@ module Rigor
2866
2979
  return nil if inferred_param_receiver?(call_node, scope)
2867
2980
 
2868
2981
  receiver_type = scope.type_of(call_node.receiver)
2869
- return nil unless receiver_type.is_a?(Type::Nominal)
2982
+ case receiver_type
2983
+ when Type::Nominal then instance_visibility_mismatch(path, call_node, scope, receiver_type, lexical_sites)
2984
+ when Type::Singleton then singleton_visibility_mismatch(path, call_node, scope, receiver_type, lexical_sites)
2985
+ end
2986
+ end
2870
2987
 
2988
+ def instance_visibility_mismatch(path, call_node, scope, receiver_type, lexical_sites)
2871
2989
  visibility = scope.discovered_method_visibility(receiver_type.class_name, call_node.name)
2872
2990
  return nil unless visibility == :private
2873
2991
 
2874
2992
  reached = reached_definer_visibility(scope, receiver_type.class_name, call_node.name)
2875
2993
  return nil if reached == :undecided
2876
2994
  return nil unless reached.nil? || reached == :private
2995
+ return nil if missing_hook_on_chain?(scope, receiver_type.class_name, :instance)
2877
2996
  # ADR-121 WD7 — a refinement in effect (or one Rigor cannot read) may redefine the method, public.
2878
2997
  return nil if refined_method_in_effect?(receiver_type.class_name, call_node, scope, :instance, lexical_sites)
2879
2998
 
2880
- build_visibility_mismatch_diagnostic(path, call_node, receiver_type)
2999
+ build_visibility_mismatch_diagnostic(path, call_node, receiver_type.class_name)
3000
+ end
3001
+
3002
+ # Issue #1984 — the singleton side reads only the reached definer: a private singleton method may be inherited
3003
+ # (`private_class_method :new` on a superclass) or come from an extended module, so there is no own-table
3004
+ # gate as on the instance side. It reports only:
3005
+ #
3006
+ # - a constant receiver (`K.x`, `A::K.x`): `self.class.x` or a local may hold a subclass that makes the name
3007
+ # public again (`public_class_method :new`, a template-method `def self.x`);
3008
+ # - a call inside a `def` ({#load_time_call?}): code that runs while the files load (a class body's
3009
+ # `ALL = [K.new]` before its `private_class_method :new`) may run before the call is private;
3010
+ # - no class on the chain may have had every singleton name made public (`K.public_class_method(*names)`,
3011
+ # `K.singleton_class.class_eval { … }`; {#singleton_wildcard_contested?}).
3012
+ def singleton_visibility_mismatch(path, call_node, scope, receiver_type, lexical_sites)
3013
+ class_name = receiver_type.class_name
3014
+ return nil unless call_node.receiver.is_a?(Prism::ConstantReadNode) ||
3015
+ call_node.receiver.is_a?(Prism::ConstantPathNode)
3016
+ return nil unless private_singleton_candidate?(scope, call_node.name)
3017
+ return nil if load_time_call?(scope, call_node)
3018
+ return nil unless reached_definer_visibility(scope, class_name, call_node.name, :singleton) == :private
3019
+ return nil if missing_hook_on_chain?(scope, class_name, :singleton)
3020
+ return nil if singleton_wildcard_contested?(scope, class_name)
3021
+ return nil if refined_method_in_effect?(class_name, call_node, scope, :singleton, lexical_sites)
3022
+
3023
+ build_visibility_mismatch_diagnostic(path, call_node, "singleton(#{class_name})")
3024
+ end
3025
+
3026
+ PRIVATE_NAMES_MEMO = :rigor_check_rules_private_singleton_names
3027
+ private_constant :PRIVATE_NAMES_MEMO
3028
+
3029
+ # Issue #1984 — the table-only gate the singleton side asks before it reads a chain, as the instance side asks
3030
+ # its own table first: whether any class records `name` private, on its singleton side or on its instance
3031
+ # side (an extended module's private method is private on the extender's singleton). Without it every
3032
+ # `Widget.new` would read, and file the dependency edges of, the whole singleton chain. The set is memoised per
3033
+ # discovery index.
3034
+ def private_singleton_candidate?(scope, name)
3035
+ discovery = scope.discovery
3036
+ slot = Thread.current[PRIVATE_NAMES_MEMO]
3037
+ unless slot && slot[0].equal?(discovery)
3038
+ slot = [discovery, private_recorded_names(discovery.discovered_method_visibilities)]
3039
+ Thread.current[PRIVATE_NAMES_MEMO] = slot
3040
+ end
3041
+ slot[1].include?(name.to_sym)
3042
+ end
3043
+
3044
+ def private_recorded_names(tables)
3045
+ tables.each_value.with_object(Set.new) do |table, names|
3046
+ table.each { |slot, visibility| names << (slot.is_a?(Array) ? slot.last : slot) if visibility == :private }
3047
+ end
3048
+ end
3049
+
3050
+ # Issue #1984 — whether the call is outside every `def` the file's deferred ranges name. A `def` is named
3051
+ # wherever it is written in a class body, at the top level or in a `class_eval` block, so a call in it is not
3052
+ # load time and may be reported. The top level, class bodies, and blocks and lambdas that are not such a `def`
3053
+ # (a `map { … }` or `define_method` block, a `-> { }`) are load time and decline: such code may run while the
3054
+ # files load, before a later `private_class_method` or in a load order where the private declaration has not
3055
+ # run yet.
3056
+ def load_time_call?(scope, call_node)
3057
+ rows = scope.discovered_deferred_ranges[scope.source_path]
3058
+ return true if rows.nil?
3059
+
3060
+ offset = call_node.location.start_offset
3061
+ rows.none? { |(start, finish, name, _kind, _owner)| !name.nil? && start <= offset && offset < finish }
3062
+ end
3063
+
3064
+ SINGLETON_WILDCARD_SLOT = %i[singleton *].freeze
3065
+ private_constant :SINGLETON_WILDCARD_SLOT
3066
+
3067
+ # Issue #1984 — whether a project class on `class_name`'s singleton chain has its wildcard singleton slot
3068
+ # contested: a call the walk saw may have made any of its singleton methods public.
3069
+ def singleton_wildcard_contested?(scope, class_name)
3070
+ discovery = scope.discovery
3071
+ chain = Scope::ResolutionChain.for(scope, class_name.to_s, :singleton, :methods)
3072
+ chain.entries.any? do |entry|
3073
+ !entry.external? && discovery.contested?(:discovered_method_visibilities,
3074
+ [entry.name, SINGLETON_WILDCARD_SLOT])
3075
+ end
3076
+ end
3077
+
3078
+ VISIBILITY_MISSING_HOOKS = %i[method_missing respond_to_missing?].freeze
3079
+ private_constant :VISIBILITY_MISSING_HOOKS
3080
+
3081
+ # Issue #1984 — whether a project entry on `class_name`'s chain for `side` records a `method_missing` or
3082
+ # `respond_to_missing?` on the side that entry stands for (a singleton entry's singleton methods, a mixin's
3083
+ # instance methods), or the chain cannot be read whole. Ruby sends a private call to `method_missing`, so
3084
+ # such a hook may answer it.
3085
+ def missing_hook_on_chain?(scope, class_name, side)
3086
+ chain = Scope::ResolutionChain.for(scope, class_name.to_s, side, :methods)
3087
+ return true if chain.truncated?
3088
+
3089
+ chain.entries.any? do |entry|
3090
+ next false if entry.external?
3091
+
3092
+ kind = entry.side == :singleton ? :singleton : :instance
3093
+ VISIBILITY_MISSING_HOOKS.any? { |hook| records_hook?(scope, entry.name, hook, kind) }
3094
+ end
3095
+ end
3096
+
3097
+ def records_hook?(scope, owner, hook, kind)
3098
+ return true if scope.discovered_method?(owner, hook, kind)
3099
+
3100
+ !(kind == :singleton ? scope.singleton_def_for(owner, hook) : scope.user_def_for(owner, hook)).nil?
2881
3101
  end
2882
3102
 
2883
3103
  # Issue #1568, ADR-119 C1c — the recorded visibility of the definition a call on `class_name` reaches: the
@@ -2889,8 +3109,8 @@ module Rigor
2889
3109
  # producers contest is not alone (`DefinerResolution` asks the next candidate too), so it is `:undecided`
2890
3110
  # there without a read of the table here. nil is nothing recording the name at all, and the class's own
2891
3111
  # visibility stands. An ancestor the project does not declare is passed over, as the method lookup passes it.
2892
- def reached_definer_visibility(scope, class_name, method_name)
2893
- case Inference::DefinerResolution.resolve(scope, class_name, method_name, :instance, question: :visibility)
3112
+ def reached_definer_visibility(scope, class_name, method_name, side = :instance)
3113
+ case Inference::DefinerResolution.resolve(scope, class_name, method_name, side, question: :visibility)
2894
3114
  in Inference::DefinerResolution::Known(answer:)
2895
3115
  answer
2896
3116
  in Inference::DefinerResolution::UNKNOWN
@@ -2907,14 +3127,14 @@ module Rigor
2907
3127
  true
2908
3128
  end
2909
3129
 
2910
- def build_visibility_mismatch_diagnostic(path, call_node, receiver_type)
3130
+ def build_visibility_mismatch_diagnostic(path, call_node, receiver_name)
2911
3131
  Diagnostic.from_message_loc(
2912
3132
  call_node,
2913
3133
  rule: RULE_VISIBILITY_MISMATCH,
2914
3134
  path: path,
2915
- message: "private method `#{call_node.name}' called on #{receiver_type.class_name} receiver",
3135
+ message: "private method `#{call_node.name}' called on #{receiver_name} receiver",
2916
3136
  severity: :error,
2917
- receiver_type: receiver_type.class_name,
3137
+ receiver_type: receiver_name,
2918
3138
  method_name: call_node.name.to_s
2919
3139
  )
2920
3140
  end
@@ -3991,6 +4211,9 @@ module Rigor
3991
4211
  # prove a reduction, stay silent.
3992
4212
  return nil if parent_visibility.nil?
3993
4213
  return nil unless visibility_reduced?(parent_visibility, override_visibility)
4214
+ # Issue #1569 — a module function's instance copy is private because `module_function` made it so; the
4215
+ # author's intent is the public singleton copy, so that reduction is not reported.
4216
+ return nil if scope.module_function_def?(class_name, def_node)
3994
4217
 
3995
4218
  build_override_visibility_diagnostic(
3996
4219
  path, def_node, parent_class, parent_visibility, override_visibility
@@ -6,6 +6,7 @@ require_relative "return_type_heuristic"
6
6
  require_relative "../../source/constant_path"
7
7
  require_relative "../../source/node_children"
8
8
  require_relative "../../inference/scope_indexer"
9
+ require_relative "../../rooted_glob"
9
10
 
10
11
  module Rigor
11
12
  module Analysis
@@ -127,7 +128,7 @@ module Rigor
127
128
  def walk_root(root_dir, harvest, budget) # rubocop:disable Naming/PredicateMethod
128
129
  return false unless File.directory?(root_dir)
129
130
 
130
- Dir.glob(File.join(root_dir, "**", "*.rb")).each do |path|
131
+ RootedGlob.glob(root_dir, "**/*.rb").each do |path|
131
132
  harvest_file(path, harvest, budget)
132
133
  return true if harvest.catalog.size >= budget
133
134
  end
@@ -120,6 +120,18 @@ module Rigor
120
120
  appeared.freeze
121
121
  end
122
122
 
123
+ # Issue #1459 — the method names some file in `paths` started or stopped defining in this edit, on any class or
124
+ # side. A consumer whose answer asked whether the program defines a name anywhere
125
+ # (`Inference::BlockCallTiming.project_defines_anywhere?`) recorded a `defines:<name>` edge whichever way it
126
+ # answered. `paths` MUST include removed files, whose every name vanished. `before` / `after` map a path to the
127
+ # Set of names it defines (`Inference::ScopeIndexer.defined_method_names`); a path absent from either side
128
+ # defines nothing there.
129
+ def changed_defined_names(paths, before, after)
130
+ moved = Set.new
131
+ paths.each { |path| moved.merge((before[path] || Set.new) ^ (after[path] || Set.new)) }
132
+ moved.freeze
133
+ end
134
+
123
135
  # Issue #1120 — the method names whose refinements moved in this edit. A consumer whose answer consulted the
124
136
  # refinement table recorded a `refinement:<method>` edge whichever way it answered, because the answer is a
125
137
  # function of the whole project's refinements of that name. `paths` MUST include removed files, whose whole
@@ -182,6 +194,55 @@ module Rigor
182
194
  moved.freeze
183
195
  end
184
196
 
197
+ # Issue #1833 — the `[class name, mark]` pairs that appeared in or vanished from the class marks of the files an
198
+ # edit touched: a Symbol key of a `discovered_parameter_envelopes` bucket (the class-wide marks of
199
+ # `Scope::DiscoveryIndex`) or a `[ENVELOPE_REMOVED_KIND, name]` entry. A file may mark a class it does not declare
200
+ # (`x.singleton_class.prepend(Comparable)`, `o.extend(Helper)` in a method body), so neither the class-declaration
201
+ # diff nor a reader's edge to the declaring files sees such a mark appear or vanish.
202
+ #
203
+ # `before` and `after` are lists of envelope tables (`{class name => bucket}`): the touched files' seed-bundle
204
+ # tables before the edit, and the merged table of the re-parsed files after it. The diff is of their unions, which
205
+ # moves whenever the project's union of marks moves (the untouched files contribute the same marks to both).
206
+ # Returns a frozen Set of frozen `[class name, mark]` pairs.
207
+ def changed_class_marks(before, after)
208
+ (class_marks(before) ^ class_marks(after)).freeze
209
+ end
210
+
211
+ def class_marks(tables)
212
+ marks = Set.new
213
+ tables.each do |table|
214
+ table&.each do |class_name, bucket|
215
+ bucket.each_key { |key| marks << [class_name, key].freeze if class_mark?(key) }
216
+ end
217
+ end
218
+ marks
219
+ end
220
+
221
+ def class_mark?(key)
222
+ key.is_a?(Symbol) || (key.is_a?(Array) && key.first == Scope::DiscoveryIndex::ENVELOPE_REMOVED_KIND)
223
+ end
224
+
225
+ # Issue #1661 — the names of the classes and modules whose unpositioned mixin edges
226
+ # (`Scope::DiscoveryIndex#unpositioned_mixins`, `"*"` included) appeared or vanished in the files an edit
227
+ # touched. A file can taint a module it does not otherwise change the chain of (`module Comparable;
228
+ # include(*MODS); end`, `Comparable.include(x) if c`), and an external chain entry has no declaring file
229
+ # for a reader's edge to name. `before` and `after` are lists of `{class => {side => [names]}}` tables,
230
+ # diffed as unions like {changed_class_marks}. Returns a frozen Set of class names.
231
+ def changed_unpositioned_owners(before, after)
232
+ moved = unpositioned_edges(before) ^ unpositioned_edges(after)
233
+ moved.to_set { |(owner, _side, _name)| owner }.freeze
234
+ end
235
+
236
+ def unpositioned_edges(tables)
237
+ edges = Set.new
238
+ tables.each do |table|
239
+ table&.each do |owner, sides|
240
+ sides.each { |side, names| names.each { |name| edges << [owner, side, name].freeze } }
241
+ end
242
+ end
243
+ edges
244
+ end
245
+
185
246
  # Issue #644 — the qualified constant names whose PUBLICATION could have moved in this edit: the names
186
247
  # whose per-file census descriptor differs between the before- and after-state of any file in `paths`.
187
248
  # The value-constant twin of {appeared_classes}, and deliberately NOT its analogue — a class either