rigortype 0.1.16 → 0.1.18

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 (180) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +18 -1
  4. data/lib/rigor/analysis/check_rules/rule_walk.rb +67 -0
  5. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +100 -0
  6. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +226 -0
  7. data/lib/rigor/analysis/check_rules.rb +180 -73
  8. data/lib/rigor/analysis/dependency_recorder.rb +122 -0
  9. data/lib/rigor/analysis/diagnostic.rb +18 -0
  10. data/lib/rigor/analysis/incremental.rb +162 -0
  11. data/lib/rigor/analysis/incremental_session.rb +337 -0
  12. data/lib/rigor/analysis/rule_catalog.rb +48 -0
  13. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +580 -0
  14. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  15. data/lib/rigor/analysis/runner/project_pre_passes.rb +318 -0
  16. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  17. data/lib/rigor/analysis/runner.rb +477 -1110
  18. data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
  19. data/lib/rigor/analysis/worker_session.rb +47 -8
  20. data/lib/rigor/builtins/static_return_refinements.rb +7 -1
  21. data/lib/rigor/cache/descriptor.rb +50 -49
  22. data/lib/rigor/cache/incremental_snapshot.rb +153 -0
  23. data/lib/rigor/cache/rbs_cache_producer.rb +34 -0
  24. data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
  25. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
  26. data/lib/rigor/cache/rbs_constant_table.rb +2 -8
  27. data/lib/rigor/cache/rbs_environment.rb +2 -8
  28. data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
  29. data/lib/rigor/cache/store.rb +145 -14
  30. data/lib/rigor/cli/annotate_command.rb +2 -7
  31. data/lib/rigor/cli/baseline_command.rb +2 -7
  32. data/lib/rigor/cli/check_command.rb +705 -0
  33. data/lib/rigor/cli/ci_detector.rb +94 -0
  34. data/lib/rigor/cli/command.rb +47 -0
  35. data/lib/rigor/cli/coverage_command.rb +3 -23
  36. data/lib/rigor/cli/coverage_renderer.rb +3 -8
  37. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  38. data/lib/rigor/cli/diff_command.rb +3 -7
  39. data/lib/rigor/cli/explain_command.rb +2 -7
  40. data/lib/rigor/cli/lsp_command.rb +3 -7
  41. data/lib/rigor/cli/mcp_command.rb +3 -7
  42. data/lib/rigor/cli/options.rb +57 -0
  43. data/lib/rigor/cli/plugin_command.rb +3 -7
  44. data/lib/rigor/cli/plugins_command.rb +2 -7
  45. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  46. data/lib/rigor/cli/renderable.rb +26 -0
  47. data/lib/rigor/cli/sig_gen_command.rb +2 -7
  48. data/lib/rigor/cli/skill_command.rb +3 -7
  49. data/lib/rigor/cli/trace_command.rb +143 -0
  50. data/lib/rigor/cli/trace_renderer.rb +310 -0
  51. data/lib/rigor/cli/triage_command.rb +2 -7
  52. data/lib/rigor/cli/type_of_command.rb +5 -38
  53. data/lib/rigor/cli/type_of_renderer.rb +4 -9
  54. data/lib/rigor/cli/type_scan_command.rb +3 -23
  55. data/lib/rigor/cli/type_scan_renderer.rb +4 -9
  56. data/lib/rigor/cli.rb +15 -532
  57. data/lib/rigor/configuration/dependencies.rb +18 -1
  58. data/lib/rigor/configuration/severity_profile.rb +22 -3
  59. data/lib/rigor/configuration.rb +16 -3
  60. data/lib/rigor/environment/rbs_loader.rb +129 -71
  61. data/lib/rigor/environment.rb +1 -1
  62. data/lib/rigor/inference/acceptance.rb +10 -0
  63. data/lib/rigor/inference/block_parameter_binder.rb +1 -2
  64. data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
  65. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
  66. data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
  67. data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
  68. data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
  69. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
  70. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
  71. data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
  72. data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
  73. data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
  74. data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
  75. data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
  76. data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
  77. data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
  78. data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
  79. data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
  80. data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
  81. data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
  82. data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
  83. data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
  84. data/lib/rigor/inference/expression_typer.rb +149 -63
  85. data/lib/rigor/inference/flow_tracer.rb +180 -0
  86. data/lib/rigor/inference/macro_block_self_type.rb +10 -11
  87. data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
  88. data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
  89. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
  90. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
  91. data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
  92. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
  93. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
  94. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
  95. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
  96. data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
  97. data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
  98. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  99. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +23 -13
  100. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
  101. data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
  102. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
  103. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
  104. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
  105. data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
  106. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
  107. data/lib/rigor/inference/method_dispatcher.rb +185 -84
  108. data/lib/rigor/inference/narrowing.rb +262 -5
  109. data/lib/rigor/inference/scope_indexer.rb +208 -21
  110. data/lib/rigor/inference/statement_evaluator.rb +110 -48
  111. data/lib/rigor/language_server/buffer_resolution.rb +33 -0
  112. data/lib/rigor/language_server/completion_provider.rb +4 -4
  113. data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
  114. data/lib/rigor/language_server/folding_range_provider.rb +4 -4
  115. data/lib/rigor/language_server/hover_provider.rb +4 -4
  116. data/lib/rigor/language_server/selection_range_provider.rb +4 -4
  117. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  118. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  119. data/lib/rigor/plugin/base.rb +302 -45
  120. data/lib/rigor/plugin/node_rule_walk.rb +147 -0
  121. data/lib/rigor/plugin/registry.rb +281 -15
  122. data/lib/rigor/plugin.rb +1 -0
  123. data/lib/rigor/rbs_extended/conformance_checker.rb +293 -0
  124. data/lib/rigor/rbs_extended.rb +39 -0
  125. data/lib/rigor/scope/discovery_index.rb +58 -0
  126. data/lib/rigor/scope.rb +150 -167
  127. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  128. data/lib/rigor/source/literals.rb +14 -0
  129. data/lib/rigor/type/acceptance_router.rb +19 -0
  130. data/lib/rigor/type/accepts_result.rb +3 -10
  131. data/lib/rigor/type/app.rb +3 -7
  132. data/lib/rigor/type/bot.rb +2 -3
  133. data/lib/rigor/type/bound_method.rb +5 -12
  134. data/lib/rigor/type/combinator.rb +22 -0
  135. data/lib/rigor/type/constant.rb +2 -3
  136. data/lib/rigor/type/data_class.rb +80 -0
  137. data/lib/rigor/type/data_instance.rb +100 -0
  138. data/lib/rigor/type/difference.rb +5 -10
  139. data/lib/rigor/type/dynamic.rb +5 -10
  140. data/lib/rigor/type/hash_shape.rb +5 -15
  141. data/lib/rigor/type/integer_range.rb +5 -10
  142. data/lib/rigor/type/intersection.rb +5 -10
  143. data/lib/rigor/type/nominal.rb +5 -10
  144. data/lib/rigor/type/refined.rb +5 -10
  145. data/lib/rigor/type/singleton.rb +5 -10
  146. data/lib/rigor/type/top.rb +2 -3
  147. data/lib/rigor/type/tuple.rb +5 -10
  148. data/lib/rigor/type/union.rb +5 -10
  149. data/lib/rigor/type.rb +2 -0
  150. data/lib/rigor/value_semantics.rb +77 -0
  151. data/lib/rigor/version.rb +1 -1
  152. data/lib/rigor.rb +1 -1
  153. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  154. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  155. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +70 -32
  156. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  157. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +15 -21
  158. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  159. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  160. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  161. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +12 -2
  162. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  163. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  164. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +35 -18
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  166. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  167. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  168. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +83 -36
  169. data/sig/rigor/cache.rbs +19 -0
  170. data/sig/rigor/environment.rbs +0 -2
  171. data/sig/rigor/inference.rbs +27 -0
  172. data/sig/rigor/plugin/base.rbs +1 -2
  173. data/sig/rigor/rbs_extended.rbs +2 -0
  174. data/sig/rigor/scope.rbs +42 -25
  175. data/sig/rigor/source.rbs +1 -0
  176. data/sig/rigor/type.rbs +58 -1
  177. data/sig/rigor.rbs +6 -1
  178. data/skills/rigor-ci-setup/SKILL.md +319 -0
  179. metadata +36 -2
  180. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -79
@@ -123,6 +123,66 @@ module Rigor
123
123
  end
124
124
  end
125
125
 
126
+ # Three-valued truthiness certainty of a predicate's type,
127
+ # derived from the truthy / falsey fragments above: `:truthy`
128
+ # when no inhabitant is falsey (the falsey fragment is `Bot`),
129
+ # `:falsey` when no inhabitant is truthy, nil when both
130
+ # fragments are inhabited — or when the type itself is nil /
131
+ # `Bot` (dead code is not a certainty claim). This is the single
132
+ # owner of the judgment both branch-elision consumers read
133
+ # (`ExpressionTyper#elide_or_union` on the value side,
134
+ # `StatementEvaluator#live_branch_for_if` on the scope side), so
135
+ # the type a dead branch is elided from and the scope that stops
136
+ # flowing through it can never disagree.
137
+ def predicate_certainty(type)
138
+ return nil if type.nil? || type.is_a?(Type::Bot)
139
+
140
+ truthy_bot = narrow_truthy(type).is_a?(Type::Bot)
141
+ falsey_bot = narrow_falsey(type).is_a?(Type::Bot)
142
+
143
+ return :falsey if truthy_bot && !falsey_bot
144
+ return :truthy if !truthy_bot && falsey_bot
145
+
146
+ nil
147
+ end
148
+
149
+ # Three-valued certainty of `C === subject` for a class / module
150
+ # `when` pattern, derived from {.narrow_class} /
151
+ # {.narrow_not_class}: `:no` when no inhabitant of the subject
152
+ # matches, `:yes` when every inhabitant matches, `:maybe`
153
+ # otherwise. The value-side counterpart of the scope narrowing
154
+ # {.case_when_scopes} performs for the same condition shape, kept
155
+ # here so the branch a `case` expression's type drops and the
156
+ # clause whose body scope goes dead derive from one judgment.
157
+ def class_pattern_certainty(subject_type, class_name, environment:)
158
+ truthy_bot = narrow_class(subject_type, class_name, environment: environment).is_a?(Type::Bot)
159
+ falsey_bot = narrow_not_class(subject_type, class_name, environment: environment).is_a?(Type::Bot)
160
+
161
+ return :no if truthy_bot && !falsey_bot
162
+ return :yes if !truthy_bot && falsey_bot
163
+
164
+ :maybe
165
+ end
166
+
167
+ # Classes whose `===` is plain value equality, so a literal
168
+ # `when` pattern against a pinned `Constant` subject is exact in
169
+ # both directions. Anything else keeps custom-`===` semantics
170
+ # and stays `:maybe` in {.value_pattern_certainty}.
171
+ VALUE_EQUALITY_CLASSES = [Integer, Float, Rational, Complex, String, Symbol,
172
+ TrueClass, FalseClass, NilClass].freeze
173
+
174
+ # Three-valued certainty of `<literal> === subject` for a
175
+ # value-equality literal pattern: exact (`:yes` / `:no`) only
176
+ # when the subject is itself a pinned `Constant` of a
177
+ # value-equality class; `:maybe` otherwise (the runtime value
178
+ # isn't pinned, or `===` may be user-defined).
179
+ def value_pattern_certainty(subject_type, pattern_value)
180
+ return :maybe unless subject_type.is_a?(Type::Constant)
181
+ return :maybe unless VALUE_EQUALITY_CLASSES.any? { |klass| subject_type.value.is_a?(klass) }
182
+
183
+ pattern_value == subject_type.value ? :yes : :no
184
+ end
185
+
126
186
  # Equality fragment of `type` against a trusted literal.
127
187
  #
128
188
  # String/Symbol/Integer equality narrows only when the current
@@ -960,7 +1020,7 @@ module Rigor
960
1020
  end
961
1021
 
962
1022
  def simple_dispatch_name?(name)
963
- %i[nil? ! is_a? kind_of? instance_of? == != === =~].include?(name)
1023
+ %i[nil? ! is_a? kind_of? instance_of? == != === =~ key? has_key? empty? any? none?].include?(name)
964
1024
  end
965
1025
 
966
1026
  def dispatch_call_simple(node, scope, name)
@@ -971,9 +1031,177 @@ module Rigor
971
1031
  when :==, :!= then analyse_equality_predicate(node, scope, equality: name)
972
1032
  when :=== then analyse_case_equality_predicate(node, scope)
973
1033
  when :=~ then analyse_regex_match_predicate(node, scope)
1034
+ when :key?, :has_key? then analyse_key_presence_predicate(node, scope)
1035
+ when :empty?, :any?, :none? then analyse_array_emptiness_predicate(node, scope, name)
1036
+ end
1037
+ end
1038
+
1039
+ # ADR-47 §4-4 (Elixir `tuple_size`/non-empty analogue) — a bare
1040
+ # `arr.empty?` / `arr.any?` / `arr.none?` (no block, no args)
1041
+ # narrows an Array-typed receiver to `non-empty-array[T]` on the
1042
+ # edge that implies "at least one element":
1043
+ #
1044
+ # - `empty?` → false edge (the array is NOT empty)
1045
+ # - `any?` → true edge (a truthy element exists ⇒ non-empty)
1046
+ # - `none?` → false edge (a truthy element exists ⇒ non-empty)
1047
+ #
1048
+ # The opposite edge is the conservative no-op (`any?`/`none?`
1049
+ # falseness does not imply emptiness — the array may hold only
1050
+ # falsey elements; `empty?` truth could narrow to an empty array
1051
+ # but that carrier move is deferred). Only `Nominal[Array, [T]]`
1052
+ # receivers narrow — `Tuple` is already known-length, `Dynamic`
1053
+ # is left alone (gradual guarantee), and a non-Array receiver
1054
+ # (`String#empty?`, `Range#any?`, …) bails so the existing string
1055
+ # / predicate paths still run.
1056
+ def analyse_array_emptiness_predicate(node, scope, name)
1057
+ return nil if node.block
1058
+ return nil unless node.arguments.nil? || node.arguments.arguments.empty?
1059
+
1060
+ reader, writer = emptiness_receiver_accessors(node.receiver)
1061
+ return nil if reader.nil?
1062
+
1063
+ current = scope.public_send(reader, node.receiver.name)
1064
+ return nil if current.nil?
1065
+
1066
+ non_empty = narrow_to_non_empty_array(current)
1067
+ return nil if non_empty.equal?(current) # receiver is not an Array shape → opaque
1068
+
1069
+ non_empty_scope = scope.public_send(writer, node.receiver.name, non_empty)
1070
+ name == :any? ? [non_empty_scope, scope] : [scope, non_empty_scope]
1071
+ end
1072
+
1073
+ def emptiness_receiver_accessors(receiver)
1074
+ case receiver
1075
+ when Prism::LocalVariableReadNode then %i[local with_local]
1076
+ when Prism::InstanceVariableReadNode then %i[ivar with_ivar]
1077
+ else [nil, nil]
974
1078
  end
975
1079
  end
976
1080
 
1081
+ # Refines `Array[T]` (and every Array member of a Union) to
1082
+ # `non-empty-array[T]`; returns the input unchanged when nothing
1083
+ # applies, so the caller can detect "no narrowing".
1084
+ def narrow_to_non_empty_array(type)
1085
+ case type
1086
+ when Type::Nominal
1087
+ return type unless type.class_name == "Array"
1088
+
1089
+ Type::Combinator.non_empty_array(type.type_args.first || Type::Combinator.top)
1090
+ when Type::Union
1091
+ Type::Combinator.union(*type.members.map { |member| narrow_to_non_empty_array(member) })
1092
+ else
1093
+ type
1094
+ end
1095
+ end
1096
+
1097
+ # ADR-47 §4-3 (Elixir `is_map_key/2` analogue) — `h.key?(:foo)`
1098
+ # narrows the receiver on the truthy edge: a `HashShape` whose
1099
+ # `:foo` is an OPTIONAL key has it promoted to REQUIRED, so a
1100
+ # subsequent `h[:foo]` reads the declared value type instead of
1101
+ # `value | nil` (the optionality nil is gone). Sound because key
1102
+ # presence removes only the optionality-injected nil, never the
1103
+ # value's own intrinsic nil (`h = {foo: nil}` keeps `h[:foo]`
1104
+ # nil-typed). The falsey edge is left unchanged — "key absent"
1105
+ # is the conservative no-op. Only literal `Symbol`/`String`
1106
+ # arguments and `LocalVariableReadNode`/`InstanceVariableReadNode`
1107
+ # receivers narrow; everything else (Dynamic, `Nominal[Hash]`,
1108
+ # method-chain receivers, dynamic keys) bails to no narrowing.
1109
+ def analyse_key_presence_predicate(node, scope)
1110
+ return nil if node.arguments.nil?
1111
+ return nil unless node.arguments.arguments.size == 1
1112
+
1113
+ key = static_hash_key(node.arguments.arguments.first)
1114
+ return nil if key.nil?
1115
+
1116
+ case node.receiver
1117
+ when Prism::LocalVariableReadNode
1118
+ key_presence_scopes(node.receiver.name, key, scope, reader: :local, writer: :with_local)
1119
+ when Prism::InstanceVariableReadNode
1120
+ key_presence_scopes(node.receiver.name, key, scope, reader: :ivar, writer: :with_ivar)
1121
+ end
1122
+ end
1123
+
1124
+ def key_presence_scopes(name, key, scope, reader:, writer:)
1125
+ current = scope.public_send(reader, name)
1126
+ return nil if current.nil?
1127
+
1128
+ truthy = narrow_hash_key_present(current, key)
1129
+ # §4-3 false edge — `h.key?(:foo)` being false proves `:foo` is
1130
+ # absent, so on that edge `h[:foo]` reads `nil`. Remove the
1131
+ # (optional) key from the shape; a required key makes the false
1132
+ # edge dead and an unknown key is already nil, both no-ops.
1133
+ falsey = narrow_hash_key_absent(current, key)
1134
+ return nil if truthy.equal?(current) && falsey.equal?(current) # predicate is opaque
1135
+
1136
+ truthy_scope = truthy.equal?(current) ? scope : scope.public_send(writer, name, truthy)
1137
+ falsey_scope = falsey.equal?(current) ? scope : scope.public_send(writer, name, falsey)
1138
+ [truthy_scope, falsey_scope]
1139
+ end
1140
+
1141
+ def static_hash_key(node)
1142
+ case node
1143
+ when Prism::SymbolNode then node.unescaped.to_sym
1144
+ when Prism::StringNode then node.unescaped
1145
+ end
1146
+ end
1147
+
1148
+ # Promotes `key` from optional to required across a HashShape (or
1149
+ # every HashShape member of a Union). Returns the input unchanged
1150
+ # when nothing applies, so the caller can detect "no narrowing".
1151
+ def narrow_hash_key_present(type, key)
1152
+ case type
1153
+ when Type::HashShape
1154
+ promote_hash_key(type, key)
1155
+ when Type::Union
1156
+ Type::Combinator.union(*type.members.map { |member| narrow_hash_key_present(member, key) })
1157
+ else
1158
+ type
1159
+ end
1160
+ end
1161
+
1162
+ def promote_hash_key(shape, key)
1163
+ return shape unless shape.pairs.key?(key) # unknown key → no value type to assert
1164
+ return shape unless shape.optional_key?(key) # already required → nothing to promote
1165
+
1166
+ Type::HashShape.new(
1167
+ shape.pairs,
1168
+ required_keys: shape.required_keys + [key],
1169
+ optional_keys: shape.optional_keys - [key],
1170
+ read_only_keys: shape.read_only_keys,
1171
+ extra_keys: shape.extra_keys
1172
+ )
1173
+ end
1174
+
1175
+ # §4-3 false edge — drops `key` from a HashShape (or every HashShape
1176
+ # member of a Union) so the proven-absent key reads `nil`. Returns
1177
+ # the input unchanged when nothing applies (caller detects "no
1178
+ # narrowing"). Only an *optional* present key is removed: a required
1179
+ # key makes `key?` always true (the false edge is dead, leave the
1180
+ # shape opaque) and a key absent from `pairs` already reads `nil`.
1181
+ def narrow_hash_key_absent(type, key)
1182
+ case type
1183
+ when Type::HashShape
1184
+ remove_hash_key(type, key)
1185
+ when Type::Union
1186
+ Type::Combinator.union(*type.members.map { |member| narrow_hash_key_absent(member, key) })
1187
+ else
1188
+ type
1189
+ end
1190
+ end
1191
+
1192
+ def remove_hash_key(shape, key)
1193
+ return shape unless shape.pairs.key?(key)
1194
+ return shape unless shape.optional_key?(key)
1195
+
1196
+ Type::HashShape.new(
1197
+ shape.pairs.except(key),
1198
+ required_keys: shape.required_keys,
1199
+ optional_keys: shape.optional_keys - [key],
1200
+ read_only_keys: shape.read_only_keys - [key],
1201
+ extra_keys: shape.extra_keys
1202
+ )
1203
+ end
1204
+
977
1205
  # Survey item (b): `/regex/ =~ str` and `str =~ /regex/`
978
1206
  # bind the regex match-data globals on each edge.
979
1207
  #
@@ -1554,13 +1782,42 @@ module Rigor
1554
1782
  return nil if node.arguments.nil?
1555
1783
  return nil unless node.arguments.arguments.size == 1
1556
1784
 
1557
- local_arg = node.arguments.arguments.first
1558
- return nil unless local_arg.is_a?(Prism::LocalVariableReadNode)
1785
+ arg = node.arguments.arguments.first
1786
+ case arg
1787
+ when Prism::LocalVariableReadNode
1788
+ current = scope.local(arg.name)
1789
+ return nil if current.nil?
1559
1790
 
1560
- current = scope.local(local_arg.name)
1791
+ analyse_case_equality_receiver(node.receiver, scope, arg.name, current)
1792
+ when Prism::CallNode
1793
+ analyse_case_equality_on_chain(node.receiver, arg, scope)
1794
+ end
1795
+ end
1796
+
1797
+ # `Class === <local/ivar>.<method>` — the case-equality counterpart
1798
+ # of {.analyse_class_predicate_on_chain}. The `open3` idiom
1799
+ # `if Hash === cmd.last` narrows `cmd.last` to the class inside the
1800
+ # branch, recorded as a single-hop method-chain narrowing keyed on
1801
+ # the chain address (same stability rules as `is_a?` on a chain).
1802
+ # Only static class/module receivers narrow here — the Range /
1803
+ # Regexp literal receivers (`(1..10) === x.foo`) are not a common
1804
+ # method-chain shape and stay deferred.
1805
+ def analyse_case_equality_on_chain(receiver, chain_arg, scope)
1806
+ class_name = static_class_name(receiver)
1807
+ return nil if class_name.nil?
1808
+
1809
+ address = stable_chain_address(chain_arg)
1810
+ return nil if address.nil?
1811
+
1812
+ current = scope.type_of(chain_arg)
1561
1813
  return nil if current.nil?
1562
1814
 
1563
- analyse_case_equality_receiver(node.receiver, scope, local_arg.name, current)
1815
+ truthy_type = narrow_class(current, class_name, exact: false, environment: scope.environment)
1816
+ falsey_type = narrow_not_class(current, class_name, exact: false, environment: scope.environment)
1817
+ [
1818
+ scope.with_method_chain_narrowing(*address, truthy_type),
1819
+ scope.with_method_chain_narrowing(*address, falsey_type)
1820
+ ]
1564
1821
  end
1565
1822
 
1566
1823
  def analyse_case_equality_receiver(receiver, scope, local_name, current)
@@ -68,16 +68,17 @@ module Rigor
68
68
  # collision — same-file declarations are the most
69
69
  # specific authority.
70
70
  merged_classes = default_scope.discovered_classes.merge(discovered_classes)
71
- seeded_scope = default_scope
72
- .with_declared_types(declared_types)
73
- .with_discovered_classes(merged_classes)
71
+ seeded_scope = default_scope.with_discovery(
72
+ default_scope.discovery.with(declared_types: declared_types,
73
+ discovered_classes: merged_classes)
74
+ )
74
75
 
75
76
  # Slice 7 phase 2. Pre-pass over every class/module body
76
77
  # to collect the per-class ivar accumulator. Seeded after
77
78
  # declared_types so the rvalue typer in the pre-pass can
78
79
  # see declaration overrides.
79
80
  class_ivars = build_class_ivar_index(root, seeded_scope)
80
- seeded_scope = seeded_scope.with_class_ivars(class_ivars)
81
+ seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(class_ivars: class_ivars))
81
82
 
82
83
  # Slice 7 phase 6. Same pre-pass shape for cvars (per
83
84
  # class) and globals (program-wide). Globals are also
@@ -86,9 +87,9 @@ module Rigor
86
87
  # not enter a method body) observe the precise type
87
88
  # without consulting the accumulator on every lookup.
88
89
  class_cvars = build_class_cvar_index(root, seeded_scope)
89
- seeded_scope = seeded_scope.with_class_cvars(class_cvars)
90
+ seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(class_cvars: class_cvars))
90
91
  program_globals = build_program_global_index(root, seeded_scope)
91
- seeded_scope = seeded_scope.with_program_globals(program_globals)
92
+ seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(program_globals: program_globals))
92
93
  program_globals.each { |name, type| seeded_scope = seeded_scope.with_global(name, type) }
93
94
 
94
95
  # Slice 7 phase 9. In-source constant value tracking.
@@ -99,7 +100,9 @@ module Rigor
99
100
  # references resolve correctly. Multiple writes to the
100
101
  # same qualified name union via `Type::Combinator.union`.
101
102
  in_source_constants = build_in_source_constants(root, seeded_scope)
102
- seeded_scope = seeded_scope.with_in_source_constants(in_source_constants)
103
+ seeded_scope = seeded_scope.with_discovery(
104
+ seeded_scope.discovery.with(in_source_constants: in_source_constants)
105
+ )
103
106
 
104
107
  # Slice 7 phase 12. In-source method discovery. Walks
105
108
  # every class/module body for `Prism::DefNode` and
@@ -115,7 +118,7 @@ module Rigor
115
118
  discovered_methods = deep_merge_class_methods(
116
119
  default_scope.discovered_methods, build_discovered_methods(root)
117
120
  )
118
- seeded_scope = seeded_scope.with_discovered_methods(discovered_methods)
121
+ seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(discovered_methods: discovered_methods))
119
122
 
120
123
  # v0.0.2 #5 + ADR-24 slice 2 — record per-instance-method
121
124
  # def nodes, the class -> superclass map, and the
@@ -173,12 +176,21 @@ module Rigor
173
176
  method_visibilities = default_scope.discovered_method_visibilities.merge(
174
177
  build_discovered_method_visibilities(root)
175
178
  ) { |_class, cross_file, per_file| cross_file.merge(per_file) }
179
+ # ADR-48 — per-file Data member layouts merged OVER the cross-file
180
+ # seed (same-file declaration is authoritative for its own classes).
181
+ data_member_layouts = default_scope.data_member_layouts.merge(
182
+ build_data_member_layouts(root)
183
+ )
176
184
 
177
- seeded_scope
178
- .with_discovered_def_nodes(def_nodes)
179
- .with_discovered_superclasses(superclasses)
180
- .with_discovered_includes(includes)
181
- .with_discovered_method_visibilities(method_visibilities)
185
+ seeded_scope.with_discovery(
186
+ seeded_scope.discovery.with(
187
+ discovered_def_nodes: def_nodes,
188
+ discovered_superclasses: superclasses,
189
+ discovered_includes: includes,
190
+ discovered_method_visibilities: method_visibilities,
191
+ data_member_layouts: data_member_layouts
192
+ )
193
+ )
182
194
  end
183
195
 
184
196
  # Slice 7 phase 2. Builds the class-level ivar accumulator
@@ -322,7 +334,7 @@ module Rigor
322
334
  end
323
335
  end
324
336
 
325
- def walk_class_ivars(node, qualified_prefix, default_scope, accumulator, mutated_ivars,
337
+ def walk_class_ivars(node, qualified_prefix, default_scope, accumulator, mutated_ivars, # rubocop:disable Metrics/CyclomaticComplexity
326
338
  read_before_write = nil, init_writes = nil)
327
339
  return unless node.is_a?(Prism::Node)
328
340
 
@@ -357,6 +369,13 @@ module Rigor
357
369
  collect_def_ivar_writes(node, qualified_prefix, default_scope, accumulator,
358
370
  mutated_ivars, read_before_write, init_writes)
359
371
  return
372
+ when Prism::CallNode
373
+ if init_writes && !qualified_prefix.empty? &&
374
+ node.block.is_a?(Prism::BlockNode) &&
375
+ block_initializer?(qualified_prefix.join("::"), node.name, default_scope)
376
+ collect_block_ivar_writes(node.block, qualified_prefix, default_scope,
377
+ accumulator, mutated_ivars, init_writes)
378
+ end
360
379
  end
361
380
 
362
381
  node.compact_child_nodes.each do |child|
@@ -393,6 +412,53 @@ module Rigor
393
412
  collect_read_before_write_evidence(def_node, class_name, read_before_write, init_writes, default_scope)
394
413
  end
395
414
 
415
+ # ADR-38 block-form: collects ivar writes from a CallNode's
416
+ # block body (e.g. RSpec `before { @x = … }` / `let(:x) { … }`)
417
+ # and folds them into `init_writes`, suppressing the
418
+ # read-before-write nil contribution the same way a def-form
419
+ # initializer does. The block body is always treated as an
420
+ # initializer (the caller has already verified the method name
421
+ # is declared as a block_method initializer), so there is no
422
+ # read-before-write evidence collection step here.
423
+ def collect_block_ivar_writes(block_node, qualified_prefix, default_scope, accumulator,
424
+ mutated_ivars, init_writes)
425
+ return if block_node.body.nil? || qualified_prefix.empty?
426
+
427
+ class_name = qualified_prefix.join("::")
428
+ self_type = Type::Combinator.nominal_of(class_name)
429
+ body_scope = default_scope.with_self_type(self_type)
430
+
431
+ gather_ivar_writes(block_node.body, body_scope, class_name, accumulator,
432
+ EMPTY_GUARDED_IVARS, mutated_ivars)
433
+
434
+ seen_writes = Set.new
435
+ read_first = Set.new
436
+ detect_read_before_write(block_node.body, seen_writes, read_first)
437
+ init_set = (init_writes[class_name] ||= Set.new)
438
+ seen_writes.each { |name| init_set << name }
439
+ end
440
+
441
+ # ADR-38 block-form gate: true when a loaded plugin declares
442
+ # `method_name` a block-form initializer for `class_name` (or
443
+ # an ancestor). Mirrors `additional_initializer?` but queries
444
+ # `covers_block_method?` instead of `covers_method?`.
445
+ def block_initializer?(class_name, method_name, default_scope)
446
+ return false if class_name.nil? || default_scope.nil?
447
+
448
+ environment = default_scope.environment
449
+ registry = environment&.plugin_registry
450
+ return false if registry.nil?
451
+ return false if registry.respond_to?(:empty?) && registry.empty?
452
+ return false unless registry.respond_to?(:additional_initializers)
453
+
454
+ registry.additional_initializers.any? do |entry|
455
+ entry.covers_block_method?(method_name) &&
456
+ class_matches_constraint?(class_name, entry.receiver_constraint, environment)
457
+ end
458
+ rescue StandardError
459
+ false
460
+ end
461
+
396
462
  # Walks the method body in AST (== execution) order
397
463
  # tracking ivar names whose first reference is a read.
398
464
  # The set is unioned into the class-wide
@@ -865,7 +931,7 @@ module Rigor
865
931
  end
866
932
  end
867
933
 
868
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize
934
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize, Metrics/PerceivedComplexity
869
935
  def walk_methods(node, qualified_prefix, in_singleton_class, accumulator)
870
936
  return unless node.is_a?(Prism::Node)
871
937
 
@@ -874,6 +940,7 @@ module Rigor
874
940
  name = qualified_name_for(node.constant_path)
875
941
  if name
876
942
  child_prefix = qualified_prefix + [name]
943
+ record_meta_superclass_members(node, child_prefix, accumulator) if node.is_a?(Prism::ClassNode)
877
944
  walk_methods(node.body, child_prefix, false, accumulator) if node.body
878
945
  return
879
946
  end
@@ -933,7 +1000,7 @@ module Rigor
933
1000
  end
934
1001
  end
935
1002
  end
936
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize
1003
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize, Metrics/PerceivedComplexity
937
1004
 
938
1005
  # v0.1.2 — when a `Const = Data.define(*sym) do ... end`
939
1006
  # / `Const = Struct.new(*sym) do ... end` constant write
@@ -958,6 +1025,40 @@ module Rigor
958
1025
  rvalue.block&.body
959
1026
  end
960
1027
 
1028
+ # `class Foo < Data.define(:a, :b)` / `class Bar < Struct.new(:x)`
1029
+ # synthesizes reader methods (`a`, `b`, `x`) on the subclass that no
1030
+ # `def` / `attr_*` declares. Register them in the discovered-methods
1031
+ # existence table so an implicit-self read of a member inside the
1032
+ # class body is known to exist — both for the existing
1033
+ # undefined-method suppression and for the ADR-24 slice-4 self-call
1034
+ # recorder, which must treat a synthesized member as an existing
1035
+ # method, not an unresolved call. The block-form
1036
+ # (`Const = Data.define(:a) do ... end`) is handled by the
1037
+ # `ConstantWriteNode` branch's block recursion; its members type
1038
+ # `self` as `Object`, out of scope here.
1039
+ def record_meta_superclass_members(class_node, qualified_prefix, accumulator)
1040
+ superclass = class_node.superclass
1041
+ return unless data_define_call?(superclass) || struct_new_call?(superclass)
1042
+
1043
+ members = meta_member_names(superclass)
1044
+ return if members.empty?
1045
+
1046
+ class_name = qualified_prefix.join("::")
1047
+ table = (accumulator[class_name] ||= {})
1048
+ members.each { |member| table[member] ||= :instance }
1049
+ end
1050
+
1051
+ # The Symbol member names of a `Data.define(*Symbol)` /
1052
+ # `Struct.new(*Symbol [, keyword_init:])` call. For `Struct.new` the
1053
+ # optional leading String name and trailing `keyword_init:` hash are
1054
+ # stripped by {#struct_new_positionals}; `Data.define` args are all
1055
+ # Symbols already.
1056
+ def meta_member_names(call_node)
1057
+ raw = call_node.arguments&.arguments || []
1058
+ symbols = struct_new_call?(call_node) ? (struct_new_positionals(raw) || []) : raw
1059
+ symbols.filter_map { |arg| arg.unescaped.to_sym if arg.is_a?(Prism::SymbolNode) }
1060
+ end
1061
+
961
1062
  def record_def_method(def_node, qualified_prefix, in_singleton_class, accumulator)
962
1063
  return if qualified_prefix.empty?
963
1064
 
@@ -1114,6 +1215,58 @@ module Rigor
1114
1215
  end
1115
1216
  end
1116
1217
 
1218
+ # ADR-48 — per qualified class name -> ordered `Data.define`
1219
+ # member-name list, for both the named-subclass form
1220
+ # (`class Point < Data.define(:x, :y)`) and the constant-assigned
1221
+ # form (`Point = Data.define(:x, :y)`). Only `Data.define` is
1222
+ # recorded: `Struct.new` instances are mutable, so member-value
1223
+ # folding would be unsound (the Struct follow-up is deferred — see
1224
+ # ADR-48 § "Struct follow-up"). Consumed by
1225
+ # {Inference::MethodDispatcher::DataFolding} via
1226
+ # {Scope#data_member_layout}.
1227
+ def build_data_member_layouts(root)
1228
+ accumulator = {}
1229
+ walk_data_member_layouts(root, [], accumulator)
1230
+ accumulator.freeze
1231
+ end
1232
+
1233
+ def walk_data_member_layouts(node, qualified_prefix, accumulator)
1234
+ return unless node.is_a?(Prism::Node)
1235
+
1236
+ case node
1237
+ when Prism::ClassNode
1238
+ name = qualified_name_for(node.constant_path)
1239
+ if name
1240
+ record_data_member_layout(accumulator, qualified_prefix + [name], node.superclass)
1241
+ walk_data_member_layouts(node.body, qualified_prefix + [name], accumulator) if node.body
1242
+ return
1243
+ end
1244
+ when Prism::ModuleNode
1245
+ name = qualified_name_for(node.constant_path)
1246
+ if name
1247
+ walk_data_member_layouts(node.body, qualified_prefix + [name], accumulator) if node.body
1248
+ return
1249
+ end
1250
+ when Prism::ConstantWriteNode
1251
+ record_data_member_layout(accumulator, qualified_prefix + [node.name.to_s], node.value)
1252
+ end
1253
+
1254
+ node.compact_child_nodes.each do |child|
1255
+ walk_data_member_layouts(child, qualified_prefix, accumulator)
1256
+ end
1257
+ end
1258
+
1259
+ # Records `qualified -> [members]` when `expr` is a
1260
+ # `Data.define(*Symbol)` call with at least one literal-Symbol member.
1261
+ def record_data_member_layout(accumulator, qualified_parts, expr)
1262
+ return unless data_define_call?(expr)
1263
+
1264
+ members = meta_member_names(expr)
1265
+ return if members.empty?
1266
+
1267
+ accumulator[qualified_parts.join("::")] = members.freeze
1268
+ end
1269
+
1117
1270
  MIXIN_CALL_NAMES = %i[include prepend].freeze
1118
1271
 
1119
1272
  # ADR-24 slice 2 — per-class/module table mapping a fully
@@ -1480,9 +1633,10 @@ module Rigor
1480
1633
  # @param paths [Array<String>] project file paths.
1481
1634
  # @param buffer [Rigor::Analysis::BufferBinding, nil]
1482
1635
  # @return [Hash{Symbol => Hash}]
1483
- # `{ def_nodes:, def_sources:, superclasses:, includes: }`
1636
+ # `{ def_nodes:, def_sources:, superclasses:, includes:, class_sources: }`
1484
1637
  def discovered_def_index_for_paths(paths, buffer: nil)
1485
- acc = { def_nodes: {}, def_sources: {}, superclasses: {}, includes: {}, method_visibilities: {}, methods: {} }
1638
+ acc = { def_nodes: {}, def_sources: {}, superclasses: {}, includes: {}, method_visibilities: {}, methods: {},
1639
+ class_sources: {}, data_member_layouts: {} }
1486
1640
  paths.each do |path|
1487
1641
  physical = buffer ? buffer.resolve(path) : path
1488
1642
  root = Prism.parse(File.read(physical), filepath: path).value
@@ -1501,7 +1655,9 @@ module Rigor
1501
1655
  # intact while still letting `attr_reader :x` in one file
1502
1656
  # suppress a false undefined-method for `obj.x` in another.
1503
1657
  acc[:methods] = subtract_def_methods(acc[:methods], acc[:def_nodes])
1504
- %i[def_nodes def_sources includes method_visibilities methods].each { |key| acc[key].each_value(&:freeze) }
1658
+ %i[def_nodes def_sources includes method_visibilities methods class_sources].each do |key|
1659
+ acc[key].each_value(&:freeze)
1660
+ end
1505
1661
  acc.transform_values(&:freeze)
1506
1662
  end
1507
1663
 
@@ -1522,10 +1678,21 @@ module Rigor
1522
1678
  # visibility declared in a sibling file.
1523
1679
  def accumulate_project_index(acc, path, root)
1524
1680
  merge_discovered_defs(acc[:def_nodes], acc[:def_sources], path, root)
1525
- acc[:superclasses].merge!(build_discovered_superclasses(root))
1526
- build_discovered_includes(root).each do |class_name, mods|
1681
+ superclasses = build_discovered_superclasses(root)
1682
+ includes = build_discovered_includes(root)
1683
+ acc[:superclasses].merge!(superclasses)
1684
+ includes.each do |class_name, mods|
1527
1685
  acc[:includes][class_name] = ((acc[:includes][class_name] || []) + mods).uniq
1528
1686
  end
1687
+ record_class_sources(acc[:class_sources], path, root, superclasses, includes)
1688
+ merge_class_keyed_index_tables(acc, root)
1689
+ acc[:data_member_layouts].merge!(build_data_member_layouts(root))
1690
+ end
1691
+
1692
+ # Folds the per-class method-visibility and method-existence tables of
1693
+ # one file into the cross-file accumulator (kept out of
1694
+ # {#accumulate_project_index} to hold its ABC budget).
1695
+ def merge_class_keyed_index_tables(acc, root)
1529
1696
  build_discovered_method_visibilities(root).each do |class_name, table|
1530
1697
  (acc[:method_visibilities][class_name] ||= {}).merge!(table)
1531
1698
  end
@@ -1534,6 +1701,26 @@ module Rigor
1534
1701
  end
1535
1702
  end
1536
1703
 
1704
+ # ADR-46 slice 1 — accumulates, per qualified user class/module
1705
+ # name, the set of files that declare it. A class's declaration
1706
+ # shape (its body `def`s, its `class Foo < Bar` superclass, its
1707
+ # `include`s) lives wherever the class is opened, so every file that
1708
+ # contributes a def / superclass / include for a name is a source of
1709
+ # that name's ancestry edges. {Scope#superclass_of} /
1710
+ # {Scope#includes_of} record this set when resolving the edge during
1711
+ # dependency recording (ADR-46). The class-declaration walk
1712
+ # (`collect_class_decls`) catches bodyless / def-less reopenings the
1713
+ # other three builders miss.
1714
+ def record_class_sources(class_sources, path, root, superclasses, includes)
1715
+ names = Set.new
1716
+ collect_class_decls(root, [], decls = {})
1717
+ names.merge(decls.keys)
1718
+ names.merge(superclasses.keys)
1719
+ names.merge(includes.keys)
1720
+ names.merge(build_discovered_def_nodes(root).keys)
1721
+ names.each { |name| (class_sources[name] ||= Set.new) << path }
1722
+ end
1723
+
1537
1724
  # Merges one file's `class → method → DefNode` map into the
1538
1725
  # cross-file `def_nodes` index and records each method's first-
1539
1726
  # seen `"path:line"` definition site in `def_sources` (ADR-17 —