rigortype 0.1.19 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -23
  3. data/lib/rigor/analysis/check_rules/rule_walk.rb +3 -21
  4. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  5. data/lib/rigor/analysis/check_rules.rb +492 -71
  6. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  7. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  8. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  9. data/lib/rigor/analysis/fact_store.rb +5 -4
  10. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +17 -17
  12. data/lib/rigor/analysis/runner/project_pre_passes.rb +9 -8
  13. data/lib/rigor/analysis/runner.rb +17 -6
  14. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  15. data/lib/rigor/analysis/worker_session.rb +10 -14
  16. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  17. data/lib/rigor/cache/store.rb +5 -3
  18. data/lib/rigor/cli/annotate_command.rb +28 -7
  19. data/lib/rigor/cli/baseline_command.rb +4 -3
  20. data/lib/rigor/cli/check_command.rb +115 -16
  21. data/lib/rigor/cli/coverage_command.rb +148 -16
  22. data/lib/rigor/cli/coverage_scan.rb +57 -0
  23. data/lib/rigor/cli/explain_command.rb +2 -0
  24. data/lib/rigor/cli/lsp_command.rb +3 -7
  25. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  26. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  27. data/lib/rigor/cli/options.rb +9 -0
  28. data/lib/rigor/cli/plugins_command.rb +2 -1
  29. data/lib/rigor/cli/protection_renderer.rb +63 -0
  30. data/lib/rigor/cli/protection_report.rb +68 -0
  31. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  32. data/lib/rigor/cli/trace_command.rb +2 -1
  33. data/lib/rigor/cli/triage_command.rb +2 -1
  34. data/lib/rigor/cli/type_of_command.rb +1 -1
  35. data/lib/rigor/cli/type_scan_command.rb +2 -1
  36. data/lib/rigor/cli.rb +3 -2
  37. data/lib/rigor/configuration/dependencies.rb +2 -4
  38. data/lib/rigor/configuration.rb +45 -7
  39. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  40. data/lib/rigor/environment/class_registry.rb +4 -3
  41. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  42. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  43. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  44. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  45. data/lib/rigor/environment/rbs_loader.rb +49 -5
  46. data/lib/rigor/environment.rb +17 -7
  47. data/lib/rigor/flow_contribution/fact.rb +1 -1
  48. data/lib/rigor/flow_contribution.rb +3 -5
  49. data/lib/rigor/inference/acceptance.rb +17 -9
  50. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  51. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  52. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  53. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  54. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  55. data/lib/rigor/inference/expression_typer.rb +20 -28
  56. data/lib/rigor/inference/hkt_body.rb +8 -11
  57. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  58. data/lib/rigor/inference/hkt_registry.rb +10 -11
  59. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  60. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +156 -21
  61. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  62. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  63. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  64. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  65. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  66. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  67. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  68. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +90 -15
  69. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  70. data/lib/rigor/inference/method_dispatcher.rb +40 -48
  71. data/lib/rigor/inference/mutation_widening.rb +5 -11
  72. data/lib/rigor/inference/narrowing.rb +14 -16
  73. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  74. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  75. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  76. data/lib/rigor/inference/protection_scanner.rb +86 -0
  77. data/lib/rigor/inference/scope_indexer.rb +129 -55
  78. data/lib/rigor/inference/statement_evaluator.rb +244 -114
  79. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  80. data/lib/rigor/inference/synthetic_method.rb +7 -7
  81. data/lib/rigor/language_server/completion_provider.rb +6 -12
  82. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  83. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  84. data/lib/rigor/language_server/hover_provider.rb +2 -3
  85. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  86. data/lib/rigor/language_server/server.rb +9 -17
  87. data/lib/rigor/language_server.rb +4 -5
  88. data/lib/rigor/plugin/base.rb +10 -8
  89. data/lib/rigor/plugin/macro/block_as_method.rb +3 -4
  90. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  91. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  92. data/lib/rigor/plugin/macro.rb +4 -5
  93. data/lib/rigor/plugin/manifest.rb +45 -66
  94. data/lib/rigor/plugin/registry.rb +6 -7
  95. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  96. data/lib/rigor/protection/mutation_scanner.rb +120 -0
  97. data/lib/rigor/protection/mutator.rb +246 -0
  98. data/lib/rigor/rbs_extended.rb +24 -36
  99. data/lib/rigor/reflection.rb +4 -7
  100. data/lib/rigor/scope/discovery_index.rb +14 -2
  101. data/lib/rigor/scope.rb +54 -11
  102. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  103. data/lib/rigor/sig_gen/writer.rb +40 -2
  104. data/lib/rigor/source/constant_path.rb +62 -0
  105. data/lib/rigor/source.rb +1 -0
  106. data/lib/rigor/type/bound_method.rb +2 -11
  107. data/lib/rigor/type/combinator.rb +16 -3
  108. data/lib/rigor/type/constant.rb +2 -11
  109. data/lib/rigor/type/data_class.rb +2 -11
  110. data/lib/rigor/type/data_instance.rb +2 -11
  111. data/lib/rigor/type/hash_shape.rb +2 -11
  112. data/lib/rigor/type/integer_range.rb +2 -11
  113. data/lib/rigor/type/intersection.rb +2 -11
  114. data/lib/rigor/type/nominal.rb +2 -11
  115. data/lib/rigor/type/plain_lattice.rb +37 -0
  116. data/lib/rigor/type/refined.rb +72 -13
  117. data/lib/rigor/type/singleton.rb +2 -11
  118. data/lib/rigor/type/struct_class.rb +75 -0
  119. data/lib/rigor/type/struct_instance.rb +93 -0
  120. data/lib/rigor/type/tuple.rb +5 -15
  121. data/lib/rigor/type.rb +2 -0
  122. data/lib/rigor/version.rb +1 -1
  123. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  124. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  125. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +3 -3
  126. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  127. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  128. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +7 -10
  129. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  130. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  131. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +6 -8
  132. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  133. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +1 -2
  134. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  135. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  136. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  137. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  138. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  139. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  140. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  141. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +7 -9
  142. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  143. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  144. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +3 -3
  145. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  146. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  147. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  148. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +1 -1
  149. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  150. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  151. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +5 -5
  152. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
  153. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
  154. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  155. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +19 -14
  156. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  157. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  158. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  159. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  160. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  161. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  162. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  163. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +28 -41
  164. data/sig/rigor/scope.rbs +9 -1
  165. data/sig/rigor/type.rbs +36 -1
  166. metadata +19 -1
@@ -0,0 +1,367 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "scope_indexer"
6
+ require_relative "../source/node_walker"
7
+
8
+ module Rigor
9
+ module Inference
10
+ # Pure argument-type classification for {ParameterInferenceCollector} — no
11
+ # collector state, so it lives outside the orchestration class. Decides which
12
+ # argument types may seed a parameter (concrete enough for the protection
13
+ # metric to bite) and widens a literal argument to its nominal.
14
+ module ParameterArgTypes
15
+ module_function
16
+
17
+ CONSTANT_CLASSES = {
18
+ Integer => "Integer", Float => "Float", String => "String",
19
+ Symbol => "Symbol", Range => "Range", TrueClass => "TrueClass",
20
+ FalseClass => "FalseClass", NilClass => "NilClass"
21
+ }.freeze
22
+
23
+ # A parameter holds a *value of* a type across its lifetime, not a pinned
24
+ # literal — so a `Constant<"text">` argument widens to its nominal
25
+ # (`String`); recurses through unions so `Constant<"a"> | Constant<"b">`
26
+ # collapses to `String`.
27
+ def widen_for_param(type)
28
+ case type
29
+ when Type::Constant
30
+ name = constant_class_name(type.value)
31
+ name ? Type::Combinator.nominal_of(name) : type
32
+ when Type::Union
33
+ Type::Combinator.union(*type.members.map { |member| widen_for_param(member) })
34
+ else
35
+ type
36
+ end
37
+ end
38
+
39
+ def constant_class_name(value)
40
+ CONSTANT_CLASSES.each { |klass, name| return name if value.is_a?(klass) }
41
+ nil
42
+ end
43
+
44
+ # The dispatch class for a receiver type, for the subset the collector
45
+ # resolves to a user `def` (mirrors `CheckRules#concrete_class_name` for the
46
+ # carriers a user-method receiver is typed as). `class_name_of` is the
47
+ # Nominal/Singleton-only variant for an implicit-self `self_type`.
48
+ def concrete_class_name(type)
49
+ case type
50
+ when Type::Nominal, Type::Singleton then type.class_name
51
+ when Type::Tuple then "Array"
52
+ when Type::HashShape then "Hash"
53
+ end
54
+ end
55
+
56
+ def class_name_of(type)
57
+ type.class_name if type.is_a?(Type::Nominal) || type.is_a?(Type::Singleton)
58
+ end
59
+
60
+ # Whether `type` is too gradual to seed (not a concrete dispatch target) —
61
+ # the negation of `ProtectionScanner#concrete_receiver?` (a union is
62
+ # concrete only when every arm is).
63
+ def non_concrete?(type)
64
+ case type
65
+ when Type::Dynamic, Type::Top, Type::Bot then true
66
+ when Type::Union then type.members.any? { |member| non_concrete?(member) }
67
+ else false
68
+ end
69
+ end
70
+ end
71
+
72
+ # ADR-67 WD3 + WD5 — call-site parameter type inference (capped fixpoint).
73
+ #
74
+ # A `def` parameter with no RBS signature types `untyped` (the gradual entry
75
+ # point), so a param flowing into an ivar or a receiver drains everything
76
+ # downstream to `Dynamic`. This pass closes that hole: it walks every project
77
+ # file, types each call's positional arguments in their lexical scope,
78
+ # resolves which user-defined `def` each call targets, and records the
79
+ # **union of resolved call-site argument types** per parameter — TypeProf's
80
+ # "a parameter's type is the union of every actual argument across all call
81
+ # sites". WD5: it iterates (cap {DEFAULT_ROUNDS}), re-seeding each round with
82
+ # the previous round's inferred parameters, so a parameter passed *another*
83
+ # parameter is typed one hop further per round (round 1 alone is the
84
+ # single-level pass).
85
+ #
86
+ # The result is keyed by `[class_name, method_name, kind]` — the same triple
87
+ # `StatementEvaluator#build_method_entry_scope` reconstructs from the lexical
88
+ # class path — so the consumer can seed an undeclared parameter with its
89
+ # inferred type. The table is precision-additive only: it never feeds a
90
+ # parameter-boundary diagnostic (an inferred type lives solely as a body
91
+ # local, and the boundary rules consult RBS, not body locals — see ADR-67
92
+ # WD1), so being wrong cannot manufacture a false positive at a caller.
93
+ #
94
+ # Soundness (WD4): the inferred type is a sound over-approximation only when
95
+ # every contributed call site resolves to a concrete argument type. Any
96
+ # `Dynamic` / `Top` / `Bot` argument (a `send`/dynamic-dispatch caller, or a
97
+ # parameter not yet typed in an earlier round) **poisons** the parameter,
98
+ # which then contributes nothing this round (it may type in a later round
99
+ # once its own argument resolves). Unresolved call sites do not contribute.
100
+ #
101
+ # What it does NOT do yet (deferred — the check-wiring slice): feeding the
102
+ # table into the `check` walk (only `coverage --protection` consumes it
103
+ # today), keyword / optional / rest / block parameters, inherited-method
104
+ # receivers, top-level helpers, and a rigorous closed-call-site-set proof
105
+ # (the union is optimistic over resolved sites — acceptable because the only
106
+ # consumer is the protection metric, which runs no diagnostics).
107
+ class ParameterInferenceCollector
108
+ EMPTY = {}.freeze
109
+
110
+ # A defensive widening cap (ADR-41): a parameter unioned from more than
111
+ # this many distinct concrete call-site types is widened to `untyped`
112
+ # (poisoned) rather than carrying an unbounded union.
113
+ MAX_CALL_SITE_TYPES = 16
114
+
115
+ # WD5 — the call-site union is a worklist fixpoint: each round re-types the
116
+ # project with the previous round's inferred parameters seeded, so a
117
+ # parameter passed *another* parameter is typed one hop further per round.
118
+ # Capped (no true-convergence requirement — the metric tolerates a bounded
119
+ # approximation, and the table can oscillate at the margin since a newly
120
+ # resolved receiver can surface a fresh untyped-argument call site). The
121
+ # cap matches the `Inference::BodyFixpoint` cap-3 convention. Round 1 alone
122
+ # is the single-level pass.
123
+ DEFAULT_ROUNDS = 3
124
+
125
+ # @param files [Array<String>] project `.rb` paths to scan for call sites.
126
+ # @param environment [Rigor::Environment]
127
+ # @param target_ruby [String, nil] Prism parse target.
128
+ # @param max_rounds [Integer] the WD5 fixpoint cap (1 = single-level).
129
+ # @return [Hash{[String,Symbol,Symbol] => Hash{Symbol => Rigor::Type}}] frozen.
130
+ def self.collect(files:, environment:, target_ruby: nil, max_rounds: DEFAULT_ROUNDS)
131
+ new(files: files, environment: environment, target_ruby: target_ruby, max_rounds: max_rounds).collect
132
+ end
133
+
134
+ def initialize(files:, environment:, target_ruby: nil, max_rounds: DEFAULT_ROUNDS)
135
+ @files = files
136
+ @environment = environment
137
+ @target_ruby = target_ruby
138
+ @max_rounds = max_rounds
139
+ # Reset per round (see {#run_round}). `[[class, method, kind], param_sym]`
140
+ # => [Type] of observed concrete arguments (a default-block Hash, not a
141
+ # `{}` literal, so the analyzer types its reads generically — {#finalize}),
142
+ # plus the ids widened to `untyped` (an untyped / over-cap argument).
143
+ @type_observations = Hash.new { |hash, id| hash[id] = [] }
144
+ @poisoned_params = Set.new
145
+ end
146
+
147
+ def collect
148
+ parsed = parse_all
149
+ discovery = discovery_seed_tables
150
+ table = EMPTY
151
+ @max_rounds.times do
152
+ rounded = run_round(parsed, discovery, table)
153
+ return rounded if rounded == table # fixpoint reached
154
+
155
+ table = rounded
156
+ end
157
+ table
158
+ end
159
+
160
+ private
161
+
162
+ # Parse every project file once; rounds re-index the cached ASTs against an
163
+ # evolving seed rather than re-parsing.
164
+ def parse_all
165
+ @files.filter_map do |path|
166
+ result = Prism.parse(File.read(path), filepath: path, version: @target_ruby)
167
+ [path, result.value] if result.errors.empty?
168
+ rescue Errno::ENOENT, Errno::EISDIR, Errno::EACCES
169
+ nil
170
+ end
171
+ end
172
+
173
+ # One fixpoint round: re-type every file with `seed_table` (the previous
174
+ # round's inferred parameters) seeded, collecting the next round's table.
175
+ def run_round(parsed, discovery_tables, seed_table)
176
+ @type_observations = Hash.new { |hash, id| hash[id] = [] }
177
+ @poisoned_params = Set.new
178
+ seed_scope = build_seed_scope(discovery_tables, seed_table)
179
+ parsed.each do |path, ast|
180
+ index = ScopeIndexer.index(ast, default_scope: seed_scope.with_source_path(path))
181
+ Source::NodeWalker.each(ast) do |node|
182
+ record_call(node, index) if node.is_a?(Prism::CallNode)
183
+ end
184
+ end
185
+ finalize
186
+ end
187
+
188
+ # A scope carrying the cross-file discovery index (so `Foo.new` receivers
189
+ # and implicit-self calls resolve to a user `def`) plus the prior round's
190
+ # inferred parameters (so an argument that reads a parameter types to its
191
+ # current inferred type — the WD5 propagation).
192
+ def build_seed_scope(discovery_tables, seed_table)
193
+ base = Scope.empty(environment: @environment)
194
+ tables = discovery_tables
195
+ tables = tables.merge(param_inferred_types: seed_table) unless seed_table.empty?
196
+ return base if tables.empty?
197
+
198
+ base.with_discovery(base.discovery.with(**tables))
199
+ end
200
+
201
+ def discovery_seed_tables
202
+ classes = ScopeIndexer.discovered_classes_for_paths(@files)
203
+ def_index = ScopeIndexer.discovered_def_index_for_paths(@files)
204
+ tables = {}
205
+ tables[:discovered_classes] = classes unless classes.empty?
206
+ DISCOVERY_FIELD.each do |index_key, field|
207
+ table = def_index.fetch(index_key)
208
+ tables[field] = table unless table.empty?
209
+ end
210
+ tables
211
+ rescue StandardError
212
+ # Discovery is best-effort; a malformed corner of the project must not
213
+ # crash the protection scan. Without discovery the collector simply
214
+ # resolves fewer call sites.
215
+ {}
216
+ end
217
+
218
+ DISCOVERY_FIELD = {
219
+ def_nodes: :discovered_def_nodes,
220
+ singleton_def_nodes: :discovered_singleton_def_nodes,
221
+ def_sources: :discovered_def_sources,
222
+ superclasses: :discovered_superclasses,
223
+ includes: :discovered_includes,
224
+ class_sources: :discovered_class_sources,
225
+ method_visibilities: :discovered_method_visibilities,
226
+ methods: :discovered_methods,
227
+ data_member_layouts: :data_member_layouts,
228
+ struct_member_layouts: :struct_member_layouts
229
+ }.freeze
230
+ private_constant :DISCOVERY_FIELD
231
+
232
+ def record_call(call_node, index)
233
+ args = positional_args(call_node)
234
+ return if args.nil?
235
+
236
+ scope = index[call_node]
237
+ return if scope.nil?
238
+
239
+ callee = resolve_callee(call_node, scope, index)
240
+ return if callee.nil?
241
+
242
+ class_name, method, kind, def_node = callee
243
+ requireds = simple_requireds(def_node)
244
+ return if requireds.nil? || requireds.size != args.size
245
+
246
+ key = [class_name, method, kind]
247
+ args.each_with_index do |arg, i|
248
+ arg_scope = index[arg]
249
+ accumulate(key, requireds[i].name, arg_scope&.type_of(arg))
250
+ end
251
+ end
252
+
253
+ # The plain positional arguments, or nil when the call carries any
254
+ # non-plain argument (splat / keyword / block-pass / forwarding) — those
255
+ # break the positional-index ↔ parameter mapping, so the call site is
256
+ # skipped rather than mis-attributed.
257
+ def positional_args(call_node)
258
+ arguments = call_node.arguments
259
+ return [] if arguments.nil?
260
+
261
+ list = arguments.arguments
262
+ return nil if list.any? { |arg| non_plain_argument?(arg) }
263
+
264
+ list
265
+ end
266
+
267
+ def non_plain_argument?(arg)
268
+ arg.is_a?(Prism::SplatNode) ||
269
+ arg.is_a?(Prism::KeywordHashNode) ||
270
+ arg.is_a?(Prism::BlockArgumentNode) ||
271
+ arg.is_a?(Prism::ForwardingArgumentsNode) ||
272
+ arg.is_a?(Prism::AssocNode) ||
273
+ arg.is_a?(Prism::AssocSplatNode)
274
+ end
275
+
276
+ # @return [[String, Symbol, Symbol, Prism::DefNode], nil]
277
+ def resolve_callee(call_node, scope, index)
278
+ if call_node.receiver.nil?
279
+ class_name, kind = implicit_self_target(scope)
280
+ else
281
+ class_name, kind = explicit_receiver_target(call_node.receiver, index, scope)
282
+ end
283
+ return nil if class_name.nil?
284
+
285
+ def_node = lookup_def(scope, class_name, call_node.name, kind)
286
+ return nil if def_node.nil?
287
+
288
+ [class_name, call_node.name, kind, def_node]
289
+ end
290
+
291
+ def implicit_self_target(scope)
292
+ self_type = scope.self_type
293
+ return [nil, nil] if self_type.nil?
294
+
295
+ [ParameterArgTypes.class_name_of(self_type), self_type.is_a?(Type::Singleton) ? :singleton : :instance]
296
+ end
297
+
298
+ def explicit_receiver_target(receiver, index, scope)
299
+ receiver_type = (index[receiver] || scope).type_of(receiver)
300
+ [ParameterArgTypes.concrete_class_name(receiver_type),
301
+ receiver_type.is_a?(Type::Singleton) ? :singleton : :instance]
302
+ end
303
+
304
+ def lookup_def(scope, class_name, method, kind)
305
+ table = kind == :singleton ? scope.discovered_singleton_def_nodes : scope.discovered_def_nodes
306
+ per_class = table[class_name]
307
+ per_class && per_class[method]
308
+ end
309
+
310
+ # The required-positional parameters, or nil when the method's parameter
311
+ # list is not a simple all-required shape (matching the single-level
312
+ # contract `ExpressionTyper#user_method_param_shape_simple?` uses) or
313
+ # contains a destructured `(a, b)` slot (no bindable name).
314
+ def simple_requireds(def_node)
315
+ params = def_node.parameters
316
+ return [] if params.nil?
317
+ return nil unless params.is_a?(Prism::ParametersNode)
318
+ return nil unless params.optionals.empty? && params.rest.nil? && params.posts.empty? &&
319
+ params.keywords.empty? && params.keyword_rest.nil? && params.block.nil?
320
+ return nil unless params.requireds.all?(Prism::RequiredParameterNode)
321
+
322
+ params.requireds
323
+ end
324
+
325
+ def accumulate(key, param_name, arg_type)
326
+ id = [key, param_name.to_sym]
327
+ return if @poisoned_params.include?(id)
328
+
329
+ if arg_type.nil? || ParameterArgTypes.non_concrete?(arg_type)
330
+ poison(id)
331
+ else
332
+ observations = @type_observations[id]
333
+ observations << ParameterArgTypes.widen_for_param(arg_type)
334
+ poison(id) if observations.length > MAX_CALL_SITE_TYPES
335
+ end
336
+ end
337
+
338
+ # A poisoned parameter is dropped from the observation store and recorded
339
+ # so later call sites short-circuit. `id` is the `[[class, method, kind],
340
+ # param]` pair.
341
+ def poison(id)
342
+ @poisoned_params << id
343
+ @type_observations.delete(id)
344
+ end
345
+
346
+ def finalize
347
+ # `result` is a default-block Hash (not a `{}` literal) so the analyzer
348
+ # types its reads generically rather than folding the empty shape — the
349
+ # nesting writes stay plain assignments, no literal-fold conditions.
350
+ result = Hash.new { |hash, key| hash[key] = {} }
351
+ @type_observations.each do |id, observations|
352
+ next if @poisoned_params.include?(id)
353
+ next if observations.empty?
354
+
355
+ union = Type::Combinator.union(*observations)
356
+ # A union that collapsed to a non-concrete shape (e.g. a gradual arm
357
+ # leaked in) is no better than `untyped`; drop it.
358
+ next if ParameterArgTypes.non_concrete?(union)
359
+
360
+ key, param = id
361
+ result[key][param] = union
362
+ end
363
+ result.transform_values(&:freeze).freeze
364
+ end
365
+ end
366
+ end
367
+ end
@@ -13,13 +13,10 @@ module Rigor
13
13
  # project-side `lib/core_ext/string_extensions.rb` patches
14
14
  # are visible to cross-file dispatch.
15
15
  #
16
- # Slice 2 ships the registry at the **floor**: the dispatcher
17
- # answers `Type::Combinator.untyped` (Dynamic[Top]) on a hit;
18
- # return-type inference for patched methods stays deferred
19
- # (a separate slice when concrete demand surfaces — most
20
- # real-world `core_ext` patches return shapes the analyzer
21
- # could heuristically extract via the same machinery the
22
- # ADR-10 walker uses, but slice 2 keeps the surface narrow).
16
+ # The dispatcher answers `Dynamic[T]` (with a heuristic static
17
+ # facet) when `Entry#return_type` is non-nil, or `Dynamic[Top]`
18
+ # when the heuristic declined (`nil`). See {Entry} for the
19
+ # per-field contract.
23
20
  class ProjectPatchedMethods
24
21
  # Frozen value-object recording one `def` observed by the
25
22
  # pre-pass. `class_name` is the qualified prefix
@@ -4,6 +4,7 @@ require "prism"
4
4
 
5
5
  require_relative "project_patched_methods"
6
6
  require_relative "../analysis/dependency_source_inference/return_type_heuristic"
7
+ require_relative "../source/constant_path"
7
8
 
8
9
  module Rigor
9
10
  module Inference
@@ -161,7 +162,7 @@ module Rigor
161
162
  private_class_method :walk_children
162
163
 
163
164
  def descend_class_or_module(node, qualified_prefix, in_singleton_class, source_path, entries)
164
- name = qualified_name_for(node.constant_path)
165
+ name = Source::ConstantPath.qualified_name_or_nil(node.constant_path)
165
166
  if name && node.body
166
167
  walk_node(node.body, qualified_prefix + [name], in_singleton_class, source_path, entries)
167
168
  else
@@ -193,18 +194,6 @@ module Rigor
193
194
  )
194
195
  end
195
196
  private_class_method :record_def_node
196
-
197
- def qualified_name_for(node)
198
- case node
199
- when Prism::ConstantReadNode then node.name.to_s
200
- when Prism::ConstantPathNode
201
- parent = node.parent.nil? ? nil : qualified_name_for(node.parent)
202
- return nil if !node.parent.nil? && parent.nil?
203
-
204
- parent.nil? ? node.name.to_s : "#{parent}::#{node.name}"
205
- end
206
- end
207
- private_class_method :qualified_name_for
208
197
  end
209
198
  end
210
199
  end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "scope_indexer"
4
+ require_relative "../source/node_walker"
5
+
6
+ module Rigor
7
+ module Inference
8
+ # ADR-63 Tier 1 — the static type-protection proxy. Walks every dispatch
9
+ # site (a method call with an explicit receiver) and classifies it by
10
+ # whether the receiver types to a concrete (non-`Dynamic`) type — i.e. a
11
+ # site where Rigor's call rules *can* bite (undefined-method / wrong-arity /
12
+ # argument-type-mismatch). A `Dynamic` / `Top` receiver (or a union with such
13
+ # an arm — gradually valid) is *unprotected*: Rigor is blind there, and a
14
+ # type annotation on it would buy real catching power.
15
+ #
16
+ # This is a sound UPPER BOUND on protection — a concrete receiver is
17
+ # necessary but not sufficient for a diagnostic to actually fire — and it is
18
+ # one `type_of` pass, so it runs interactively and in CI. The truth tier
19
+ # (does a diagnostic fire) is the phased mutation tier.
20
+ class ProtectionScanner
21
+ # A single unprotected call site.
22
+ Site = Data.define(:line, :receiver, :method_name)
23
+
24
+ FileResult = Data.define(:protected_count, :unprotected_count, :sites) do
25
+ def total = protected_count + unprotected_count
26
+
27
+ # Protected ratio; a file with no dispatch sites is vacuously fully
28
+ # protected (nothing to get wrong).
29
+ def ratio = total.zero? ? 1.0 : protected_count.to_f / total
30
+ end
31
+
32
+ def initialize(scope: nil)
33
+ @scope = scope || Scope.empty
34
+ end
35
+
36
+ # @param root [Prism::Node] the parsed AST
37
+ # @return [FileResult]
38
+ def scan(root)
39
+ index = ScopeIndexer.index(root, default_scope: @scope)
40
+ protected_count = 0
41
+ sites = []
42
+
43
+ Source::NodeWalker.each(root) do |node|
44
+ next unless dispatch_site?(node)
45
+
46
+ receiver_type = index[node.receiver].type_of(node.receiver)
47
+ if concrete_receiver?(receiver_type)
48
+ protected_count += 1
49
+ else
50
+ sites << Site.new(
51
+ line: node.location.start_line,
52
+ receiver: safe_describe(receiver_type),
53
+ method_name: node.name.to_s
54
+ )
55
+ end
56
+ end
57
+
58
+ FileResult.new(protected_count: protected_count, unprotected_count: sites.size, sites: sites)
59
+ end
60
+
61
+ private
62
+
63
+ def dispatch_site?(node)
64
+ node.is_a?(Prism::CallNode) && !node.receiver.nil?
65
+ end
66
+
67
+ # A receiver Rigor can reason about: anything that is not `Dynamic` /
68
+ # `Top`, and (for a union) only when every arm is concrete — a single
69
+ # `Dynamic` arm makes the whole call gradually valid, so the rules stay
70
+ # silent there.
71
+ def concrete_receiver?(type)
72
+ case type
73
+ when Type::Dynamic, Type::Top then false
74
+ when Type::Union then type.members.all? { |member| concrete_receiver?(member) }
75
+ else true
76
+ end
77
+ end
78
+
79
+ def safe_describe(type)
80
+ type.respond_to?(:describe) ? type.describe(:short) : type.to_s
81
+ rescue StandardError
82
+ type.class.name
83
+ end
84
+ end
85
+ end
86
+ end