rigortype 0.2.8 → 0.3.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 (207) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/core_overlay/csv.rbs +28 -0
  4. data/data/core_overlay/psych.rbs +22 -0
  5. data/docs/handbook/01-getting-started.md +9 -1
  6. data/docs/handbook/02-everyday-types.md +4 -1
  7. data/docs/handbook/03-narrowing.md +2 -1
  8. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  9. data/docs/handbook/06-classes.md +18 -9
  10. data/docs/handbook/08-understanding-errors.md +4 -3
  11. data/docs/handbook/11-sig-gen.md +11 -1
  12. data/docs/handbook/appendix-mypy.md +4 -3
  13. data/docs/install.md +14 -6
  14. data/docs/manual/01-installation.md +99 -4
  15. data/docs/manual/02-cli-reference.md +65 -19
  16. data/docs/manual/03-configuration.md +34 -2
  17. data/docs/manual/04-diagnostics.md +23 -0
  18. data/docs/manual/07-plugins.md +5 -5
  19. data/docs/manual/09-editor-integration.md +4 -4
  20. data/docs/manual/11-ci.md +51 -0
  21. data/docs/manual/12-caching.md +65 -0
  22. data/docs/manual/14-rails-quickstart.md +4 -2
  23. data/docs/manual/15-type-protection-coverage.md +21 -0
  24. data/docs/manual/plugins/rigor-actionpack.md +1 -1
  25. data/docs/manual/plugins/rigor-activerecord.md +12 -5
  26. data/docs/manual/plugins/rigor-rails-routes.md +11 -0
  27. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  28. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  29. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  30. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  31. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  32. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  33. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  34. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  35. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  36. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  37. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  38. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +93 -0
  39. data/lib/rigor/analysis/check_rules.rb +557 -95
  40. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  41. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  42. data/lib/rigor/analysis/incremental_session.rb +456 -51
  43. data/lib/rigor/analysis/path_expansion.rb +42 -0
  44. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  45. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  46. data/lib/rigor/analysis/run_cache_key.rb +68 -0
  47. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  48. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  49. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  50. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  51. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  52. data/lib/rigor/analysis/runner.rb +343 -68
  53. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  54. data/lib/rigor/analysis/worker_session.rb +30 -2
  55. data/lib/rigor/bleeding_edge.rb +37 -8
  56. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  57. data/lib/rigor/cache/descriptor.rb +88 -28
  58. data/lib/rigor/cache/file_digest.rb +158 -0
  59. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  60. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  61. data/lib/rigor/cache/store.rb +207 -60
  62. data/lib/rigor/ci_detector.rb +90 -0
  63. data/lib/rigor/cli/annotate_command.rb +15 -9
  64. data/lib/rigor/cli/baseline_command.rb +4 -1
  65. data/lib/rigor/cli/check_command.rb +212 -27
  66. data/lib/rigor/cli/coverage_command.rb +56 -30
  67. data/lib/rigor/cli/diagnostic_formats.rb +4 -1
  68. data/lib/rigor/cli/docs_command.rb +0 -30
  69. data/lib/rigor/cli/doctor_command.rb +163 -23
  70. data/lib/rigor/cli/lsp_command.rb +5 -0
  71. data/lib/rigor/cli/mcp_command.rb +5 -0
  72. data/lib/rigor/cli/plugins_command.rb +36 -6
  73. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  74. data/lib/rigor/cli/probe_environment.rb +85 -0
  75. data/lib/rigor/cli/protection_fork_scan.rb +55 -0
  76. data/lib/rigor/cli/protection_report.rb +7 -1
  77. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  78. data/lib/rigor/cli/skill_command.rb +1 -32
  79. data/lib/rigor/cli/trace_command.rb +5 -6
  80. data/lib/rigor/cli/type_of_command.rb +9 -8
  81. data/lib/rigor/cli/type_scan_command.rb +8 -8
  82. data/lib/rigor/cli.rb +15 -4
  83. data/lib/rigor/config_audit.rb +48 -2
  84. data/lib/rigor/configuration/severity_profile.rb +31 -0
  85. data/lib/rigor/configuration.rb +156 -8
  86. data/lib/rigor/environment/default_libraries.rb +35 -0
  87. data/lib/rigor/environment/missing_gem_constant_index.rb +128 -0
  88. data/lib/rigor/environment/rbs_loader.rb +164 -5
  89. data/lib/rigor/environment.rb +71 -43
  90. data/lib/rigor/inference/budget_trace.rb +77 -2
  91. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  92. data/lib/rigor/inference/def_handle.rb +23 -0
  93. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  94. data/lib/rigor/inference/def_return_typer.rb +2 -1
  95. data/lib/rigor/inference/expression_typer.rb +379 -113
  96. data/lib/rigor/inference/fork_map.rb +87 -0
  97. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  98. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  99. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  100. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  101. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +11 -0
  102. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  103. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  104. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  105. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  106. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  107. data/lib/rigor/inference/mutation_widening.rb +42 -8
  108. data/lib/rigor/inference/narrowing.rb +245 -17
  109. data/lib/rigor/inference/parameter_inference_collector.rb +70 -10
  110. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  111. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  112. data/lib/rigor/inference/scope_indexer.rb +460 -49
  113. data/lib/rigor/inference/statement_evaluator.rb +111 -17
  114. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  115. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  116. data/lib/rigor/inference/void_origin.rb +25 -0
  117. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  118. data/lib/rigor/language_server/completion_provider.rb +2 -1
  119. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  120. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  121. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  122. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  123. data/lib/rigor/plugin/base.rb +9 -22
  124. data/lib/rigor/plugin/inflector.rb +12 -3
  125. data/lib/rigor/plugin/io_boundary.rb +5 -1
  126. data/lib/rigor/plugin/isolation.rb +81 -11
  127. data/lib/rigor/plugin/load_error.rb +10 -1
  128. data/lib/rigor/plugin/loader.rb +96 -14
  129. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  130. data/lib/rigor/plugin/registry.rb +32 -23
  131. data/lib/rigor/plugin.rb +26 -0
  132. data/lib/rigor/protection/mutator.rb +3 -2
  133. data/lib/rigor/reflection.rb +64 -0
  134. data/lib/rigor/runtime/jit.rb +128 -0
  135. data/lib/rigor/scope/discovery_index.rb +12 -2
  136. data/lib/rigor/scope.rb +122 -28
  137. data/lib/rigor/sig_gen/classification.rb +5 -1
  138. data/lib/rigor/sig_gen/generator.rb +38 -6
  139. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  140. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  141. data/lib/rigor/sig_gen/renderer.rb +10 -0
  142. data/lib/rigor/sig_gen/write_result.rb +9 -4
  143. data/lib/rigor/sig_gen/writer.rb +24 -2
  144. data/lib/rigor/source/node_children.rb +116 -0
  145. data/lib/rigor/source/node_locator.rb +3 -1
  146. data/lib/rigor/source/node_walker.rb +4 -2
  147. data/lib/rigor/source.rb +1 -0
  148. data/lib/rigor/type/difference.rb +28 -24
  149. data/lib/rigor/type/hash_shape.rb +34 -10
  150. data/lib/rigor/version.rb +1 -1
  151. data/lib/rigortype.rb +24 -0
  152. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  154. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +23 -8
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +21 -0
  157. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  158. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +13 -2
  159. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +65 -2
  160. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +20 -2
  161. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  162. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +172 -0
  163. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +22 -8
  164. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  165. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  166. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +32 -0
  167. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  168. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  169. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  170. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  171. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  172. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  173. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  174. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +191 -0
  175. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  176. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +19 -1
  177. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +48 -15
  178. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +42 -12
  179. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  180. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  181. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  182. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  183. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  184. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  185. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  186. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  187. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  188. data/sig/prism_node_children.rbs +9 -0
  189. data/sig/rigor/environment.rbs +1 -0
  190. data/sig/rigor/inference.rbs +7 -0
  191. data/sig/rigor/plugin/base.rbs +2 -3
  192. data/sig/rigor/reflection.rbs +1 -0
  193. data/sig/rigor/scope.rbs +12 -1
  194. data/skills/rigor-ci-setup/SKILL.md +10 -0
  195. data/skills/rigor-editor-setup/SKILL.md +11 -0
  196. data/skills/rigor-next-steps/SKILL.md +23 -2
  197. data/skills/rigor-plugin-author/SKILL.md +3 -3
  198. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  199. data/skills/rigor-plugin-review/SKILL.md +2 -2
  200. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  201. data/skills/rigor-project-init/SKILL.md +29 -3
  202. data/skills/rigor-project-init/references/01-detect.md +1 -1
  203. data/skills/rigor-project-init/references/02-configure.md +39 -5
  204. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  205. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  206. metadata +49 -24
  207. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -6,10 +6,12 @@ require_relative "../reflection"
6
6
  require_relative "../type"
7
7
  require_relative "../analysis/fact_store"
8
8
  require_relative "../source/node_walker"
9
+ require_relative "../source/node_children"
9
10
  require_relative "../source/constant_path"
10
11
  require_relative "block_parameter_binder"
11
12
  require_relative "body_fixpoint"
12
13
  require_relative "dynamic_origin"
14
+ require_relative "../analysis/check_rules/inferred_param_guard"
13
15
  require_relative "struct_fold_safety"
14
16
  require_relative "closure_escape_analyzer"
15
17
  require_relative "indexed_narrowing"
@@ -187,6 +189,16 @@ module Rigor
187
189
  [@scope.type_of(node, tracer: @tracer), @scope]
188
190
  end
189
191
 
192
+ # ADR-89 WD2 — the sorted positions of the positional parameters whose CONTENT `def_node` mutates
193
+ # (`callee_content_mutated_parameters`, the ADR-56 arg-flooring surface a caller consumes). A per-def
194
+ # static property of the AST — this is a pure exposure of the existing private computation (it never
195
+ # reads the scope), so the incremental session can compute it on any `StatementEvaluator` (an empty
196
+ # scope suffices), persist it in a return summary, and re-check a callee's symbol dependents when it
197
+ # moves (a caller's arg flooring changes even if the callee's return does not).
198
+ def content_mutated_parameter_positions(def_node)
199
+ callee_content_mutated_parameters(def_node).values.uniq.sort
200
+ end
201
+
190
202
  private
191
203
 
192
204
  attr_reader :scope, :tracer
@@ -224,6 +236,16 @@ module Rigor
224
236
  end
225
237
 
226
238
  bound = post_rhs.with_local(node.name, rhs_type)
239
+ # ADR-67 WD6b — a local whose RHS is (transitively) rooted at an inferred parameter inherits the
240
+ # "inferred, not declared" mark, so a subsequent use of the local declines the negative rules for the
241
+ # same lower-bound reason (`vindex = codepoints[i] - x; vindex < y`). The mark is STICKY across
242
+ # narrowing/joins (see `Scope#without_inferred_param_mark`), so a genuine rewrite from a non-param RHS
243
+ # must clear it here. No-op unless the `parameter_inference:` gate seeded a parameter this RHS reaches.
244
+ if Analysis::CheckRules::InferredParamGuard.rooted?(node.value, scope)
245
+ return [rhs_type, bound.with_inferred_param_mark(node.name)]
246
+ end
247
+
248
+ bound = bound.without_inferred_param_mark(node.name)
227
249
  bound = bound.with_local_origin(node.name, rhs_origin(node.value, post_rhs, rhs_type))
228
250
  [rhs_type, bound]
229
251
  end
@@ -748,7 +770,14 @@ module Rigor
748
770
  node.is_a?(Prism::ModuleNode) ||
749
771
  node.is_a?(Prism::BlockNode)
750
772
 
751
- node.compact_child_nodes.any? { |c| arm_contains_retry?(c) }
773
+ found = false
774
+ node.rigor_each_child do |c|
775
+ next unless arm_contains_retry?(c)
776
+
777
+ found = true
778
+ break
779
+ end
780
+ found
752
781
  end
753
782
 
754
783
  def absorb_retry_rebinds(accumulator, entry_scope, arm_post_scope)
@@ -936,7 +965,7 @@ module Rigor
936
965
  return if node.nil?
937
966
 
938
967
  found[node] = true if node.is_a?(Prism::BreakNode)
939
- node.compact_child_nodes.each do |child|
968
+ node.rigor_each_child do |child|
940
969
  next if BREAK_BOUNDARY_NODES.any? { |klass| child.is_a?(klass) }
941
970
 
942
971
  collect_direct_breaks(child, found)
@@ -1322,6 +1351,13 @@ module Rigor
1322
1351
  # (e.g. `arms << x`, `@tags << hashtag`). Stops a literal-shape carrier (`Tuple` / `HashShape`) from outliving
1323
1352
  # its justification when the value is mutated. Always-safe (loses precision, never invents facts).
1324
1353
  post_scope = MutationWidening.widen_after_call(call_node: node, current_scope: post_scope)
1354
+ # ADR-48 slice 4 — Struct member-setter re-typing. After `s.x = v` on a fold-safe StructInstance local, rebind
1355
+ # `s` to a StructInstance with member `:x` replaced by the assigned type, so a later `s.x` folds to `v` and a
1356
+ # sibling `s.y` stays precise. `call_type` is the setter's own result (the assigned value type). Sound only for
1357
+ # a fold-safe local (never aliased / escaped, straight-line setters) — the fold-safe scan is the gate.
1358
+ post_scope = MethodDispatcher::StructFolding.apply_setter_writeback(
1359
+ call_node: node, assigned_type: call_type, scope: post_scope
1360
+ )
1325
1361
  # ADR-57 slice 3 work-item 1 (cross-method-boundary variant). When a self-call resolves to a user method that
1326
1362
  # CONTENT-mutates one of its parameters inside an escaping block (the `build_option_parser(opts)` idiom — the
1327
1363
  # callee returns an `OptionParser` whose `opts.on { o[:k] = v }` blocks close over the passed-in hash), floor
@@ -1624,9 +1660,9 @@ module Rigor
1624
1660
  def collect_gated_statement_contributions(index, relevant, name, call_node, current_scope)
1625
1661
  result = nil
1626
1662
  relevant.each do |plugin|
1627
- next unless index.type_specifier_candidate_for?(plugin, name)
1663
+ next unless index.narrowing_facts_candidate_for?(plugin, name)
1628
1664
 
1629
- facts = plugin.type_specifier_facts(call_node: call_node, scope: current_scope)
1665
+ facts = plugin.narrowing_facts_for(call_node: call_node, scope: current_scope)
1630
1666
  (result ||= []) << Rigor::FlowContribution.new(post_return_facts: facts) if facts && !facts.empty?
1631
1667
  rescue StandardError
1632
1668
  next
@@ -1825,10 +1861,19 @@ module Rigor
1825
1861
  def_node = resolve_self_callee_def(node)
1826
1862
  return base_scope if def_node.nil?
1827
1863
 
1828
- escaped = escaped_content_parameters(def_node)
1829
- return base_scope if escaped.empty?
1864
+ mutated = callee_content_mutated_parameters(def_node)
1865
+ return base_scope if mutated.empty?
1866
+
1867
+ floor_arguments_at_positions(node, mutated, base_scope)
1868
+ end
1830
1869
 
1831
- floor_arguments_at_positions(node, escaped, base_scope)
1870
+ # The `{ name => position }` positional parameters whose content the callee mutates, from either channel: those
1871
+ # escape-mutated inside a block (which may run later / repeatedly) AND those mutated directly in the method body
1872
+ # during the call itself (`declaration[:prefix] = v`). Both leave the caller's argument binding stale after the
1873
+ # call, so both floor it. Memoised per def node (the merge is otherwise recomputed at every call site).
1874
+ def callee_content_mutated_parameters(def_node)
1875
+ cache = (@callee_mutated_param_cache ||= {}.compare_by_identity)
1876
+ cache[def_node] ||= escaped_content_parameters(def_node).merge(direct_content_parameters(def_node))
1832
1877
  end
1833
1878
 
1834
1879
  # The user def a self-dispatch `node` resolves to in the enclosing class, or nil. Reuses the discovery index
@@ -1872,6 +1917,42 @@ module Rigor
1872
1917
  positions.slice(*mutated)
1873
1918
  end
1874
1919
 
1920
+ # The `{ name => position }` positional parameters whose CONTENT the callee mutates directly in its method body —
1921
+ # a top-level `param[k] = v` / `param << x`, outside a nested block. Memoised per def node. The block-nested case
1922
+ # is `escaped_content_parameters`'s job; walking only outside nested blocks / defs / lambdas here means a matching
1923
+ # parameter-name read is genuinely at method scope (depth 0) rather than a block-local of the same name that
1924
+ # merely shadows the parameter — so we never floor a caller argument the callee did not actually mutate.
1925
+ def direct_content_parameters(def_node)
1926
+ cache = (@direct_param_cache ||= {}.compare_by_identity)
1927
+ cache[def_node] ||= compute_direct_content_parameters(def_node)
1928
+ end
1929
+
1930
+ def compute_direct_content_parameters(def_node)
1931
+ positions = positional_parameter_positions(def_node)
1932
+ return {} if positions.empty?
1933
+
1934
+ mutated = Set.new
1935
+ each_node_outside_nested_scopes(def_node.body) do |descendant|
1936
+ name, = content_mutation_target(descendant) { |r| r.is_a?(Prism::LocalVariableReadNode) && r.depth.zero? }
1937
+ mutated << name if !name.nil? && positions.key?(name)
1938
+ end
1939
+ positions.slice(*mutated)
1940
+ end
1941
+
1942
+ # Yields every node reachable from `body` without crossing into a nested block / def / lambda — i.e. the nodes
1943
+ # that execute in the method's own scope. A local-variable read found here has depth 0 relative to the method, so
1944
+ # a content mutation whose receiver is a positional-parameter name is a genuine mutation of that parameter.
1945
+ def each_node_outside_nested_scopes(node, &)
1946
+ return if node.nil?
1947
+
1948
+ yield node
1949
+ node.rigor_each_child do |child|
1950
+ next if child.is_a?(Prism::BlockNode) || child.is_a?(Prism::DefNode) || child.is_a?(Prism::LambdaNode)
1951
+
1952
+ each_node_outside_nested_scopes(child, &)
1953
+ end
1954
+ end
1955
+
1875
1956
  # A receiver-independent over-approximation of `ClosureEscapeAnalyzer`'s non-escaping verdict, used when scanning
1876
1957
  # a callee body where the block- owning call's receiver TYPE is not available. A call whose method name is a known
1877
1958
  # structural iterator (`each` / `map` / `tap` / …) runs its block synchronously and does not retain it, so its
@@ -2435,7 +2516,7 @@ module Rigor
2435
2516
  sub_eval(node.body, fresh, class_context: new_context)
2436
2517
  end
2437
2518
 
2438
- def build_method_entry_scope(def_node)
2519
+ def build_method_entry_scope(def_node) # rubocop:disable Metrics/AbcSize
2439
2520
  singleton = singleton_def?(def_node)
2440
2521
  binder = MethodParameterBinder.new(
2441
2522
  environment: scope.environment,
@@ -2447,8 +2528,11 @@ module Rigor
2447
2528
  # ADR-67 WD3 — override an undeclared parameter with its call-site inferred type (precision-additive; an
2448
2529
  # RBS-declared parameter wins, the table is empty on a normal `check` run). The inferred type lives only as a
2449
2530
  # body local, never as an RBS contract, so it cannot fire a parameter-boundary diagnostic (WD1, satisfied by
2450
- # construction).
2451
- bindings = seed_inferred_param_types(bindings, def_node, singleton)
2531
+ # construction). WD6b — the second element is the set of parameters this seed overrode, stamped with
2532
+ # the "inferred, not declared" provenance mark at bind time so the in-body rules can decline on them.
2533
+ seeded = seed_inferred_param_types(bindings, def_node, singleton)
2534
+ bindings = seeded.fetch(0)
2535
+ inferred_names = seeded.fetch(1)
2452
2536
 
2453
2537
  # Method bodies do NOT see the outer scope's locals. They start from a fresh scope with the same environment,
2454
2538
  # then receive the parameter bindings. Slice 7 phase 2: instance defs ALSO seed their `ivars` map from the
@@ -2466,7 +2550,7 @@ module Rigor
2466
2550
  def_node.body, ->(name) { scope.struct_member_layout(name)&.[](:members) }
2467
2551
  )
2468
2552
  )
2469
- bindings.reduce(fresh) { |acc, (name, type)| bind_param(acc, name, type) }
2553
+ bindings.reduce(fresh) { |acc, (name, type)| bind_param(acc, name, type, inferred_names) }
2470
2554
  end
2471
2555
 
2472
2556
  # ADR-82 root-enrichment — bind a method parameter, and for an *undeclared* (untyped) parameter seed its
@@ -2475,8 +2559,14 @@ module Rigor
2475
2559
  # route to parameter inference rather than reporting no cause at all. Reuses the WD1 `local_origins`
2476
2560
  # channel, so WD6 then carries the cause through any chain rooted at the parameter (`x.foo.bar`). An
2477
2561
  # RBS-declared / call-site-inferred parameter (a non-untyped binding) keeps no origin — it is not a hole.
2478
- def bind_param(acc, name, type)
2562
+ #
2563
+ # ADR-67 WD6b — a parameter the call-site inference seed overrode (`inferred_names`) is stamped with the
2564
+ # "inferred, not declared" provenance mark instead: its type is a concrete (non-untyped) lower bound, so it
2565
+ # is not an `inferred_return_untyped` hole, but the negative in-body rules must still decline on it (the
2566
+ # union is open, so firing is an FP by construction). The mark drops on any flow-live rewrite of the local.
2567
+ def bind_param(acc, name, type, inferred_names = nil)
2479
2568
  bound = acc.with_local(name, type)
2569
+ return bound.with_inferred_param_mark(name) if inferred_names&.include?(name)
2480
2570
  return bound unless untyped_binding?(type)
2481
2571
 
2482
2572
  bound.with_local_origin(name, DynamicOrigin::INFERRED_RETURN_UNTYPED)
@@ -2489,19 +2579,23 @@ module Rigor
2489
2579
  # seed is byte-identical there.
2490
2580
  def seed_inferred_param_types(bindings, def_node, singleton)
2491
2581
  inferred = scope.param_inferred_types
2492
- return bindings if inferred.empty?
2582
+ return [bindings, nil] if inferred.empty?
2493
2583
 
2494
2584
  path = current_class_path
2495
- return bindings if path.nil?
2585
+ return [bindings, nil] if path.nil?
2496
2586
 
2497
2587
  table = inferred[[path, def_node.name, singleton ? :singleton : :instance]]
2498
- return bindings if table.nil? || table.empty?
2588
+ return [bindings, nil] if table.nil? || table.empty?
2499
2589
 
2500
2590
  merged = bindings.dup
2591
+ overridden = nil
2501
2592
  table.each do |name, type|
2502
- merged[name] = type if merged.key?(name) && untyped_binding?(merged[name])
2593
+ next unless merged.key?(name) && untyped_binding?(merged[name])
2594
+
2595
+ merged[name] = type
2596
+ (overridden ||= Set.new) << name
2503
2597
  end
2504
- merged
2598
+ [merged, overridden]
2505
2599
  end
2506
2600
 
2507
2601
  # True for the `Dynamic[Top]` carrier `MethodParameterBinder` leaves on an undeclared parameter — the only
@@ -3,6 +3,7 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "../source/constant_path"
6
+ require_relative "../source/node_children"
6
7
 
7
8
  module Rigor
8
9
  module Inference
@@ -59,11 +60,18 @@ module Rigor
59
60
  return EMPTY if members.empty?
60
61
 
61
62
  total = Hash.new(0)
62
- pure = Hash.new(0)
63
- count_uses(root, members, total, pure)
64
-
63
+ safe_uses = Hash.new(0)
64
+ deferred_setter = {}
65
+ count_uses(root, members, total, safe_uses, deferred_setter, false)
66
+
67
+ # A local is fold-safe iff every read of it is a safe use — a pure read OR (ADR-48 slice 4) a
68
+ # straight-line member setter (`n.x = v`) that the setter write-back re-types the binding for.
69
+ # `deferred_setter` disqualifies a local whose setter sits inside a loop / block / lambda, where a
70
+ # single static pass cannot model the setter's per-iteration effect (the write-back would leave a
71
+ # stale binding for the fold to read).
65
72
  safe = members.each_key.select do |name|
66
- writes[name] == 1 && total[name].positive? && total[name] == pure[name]
73
+ writes[name] == 1 && total[name].positive? &&
74
+ total[name] == safe_uses[name] && !deferred_setter[name]
67
75
  end
68
76
  safe.empty? ? EMPTY : safe.to_set
69
77
  end
@@ -84,22 +92,30 @@ module Rigor
84
92
  end
85
93
  end
86
94
 
87
- # Pass 2 — count, per recorded struct local, total reads vs. reads that are the receiver of a pure-read call.
88
- def count_uses(node, members, total, pure)
95
+ # Pass 2 — count, per recorded struct local, total reads vs. SAFE-use reads (the receiver of a pure-read call,
96
+ # or of a straight-line member setter). `deferred` is true inside a loop / block / lambda; a member setter seen
97
+ # there marks the local's `deferred_setter` so it is excluded (its write-back cannot be modelled statically).
98
+ def count_uses(node, members, total, safe_uses, deferred_setter, deferred)
89
99
  return if node.nil?
90
100
 
91
101
  total[node.name] += 1 if node.is_a?(Prism::LocalVariableReadNode) && members.key?(node.name)
92
102
 
93
103
  if node.is_a?(Prism::CallNode)
94
104
  receiver = node.receiver
95
- if receiver.is_a?(Prism::LocalVariableReadNode) && members.key?(receiver.name) &&
96
- pure_read_call?(node, members[receiver.name])
97
- pure[receiver.name] += 1
105
+ if receiver.is_a?(Prism::LocalVariableReadNode) && members.key?(receiver.name)
106
+ member_set = members[receiver.name]
107
+ if pure_read_call?(node, member_set)
108
+ safe_uses[receiver.name] += 1
109
+ elsif member_setter_call?(node, member_set)
110
+ safe_uses[receiver.name] += 1
111
+ deferred_setter[receiver.name] = true if deferred
112
+ end
98
113
  end
99
114
  end
100
115
 
116
+ child_deferred = deferred || deferred_boundary?(node)
101
117
  each_local_scope_child(node) do |child|
102
- count_uses(child, members, total, pure)
118
+ count_uses(child, members, total, safe_uses, deferred_setter, child_deferred)
103
119
  end
104
120
  end
105
121
 
@@ -110,6 +126,27 @@ module Rigor
110
126
  FIXED_READS.include?(name) || member_set.include?(name)
111
127
  end
112
128
 
129
+ # A `n.<member> = v` attribute setter on the receiver (ADR-48 slice 4): the selector strips its trailing `=` to
130
+ # a member reader and the call carries exactly one argument. Comparison operators (`==`, `>=`, `!=`) also end
131
+ # with `=` but never strip to a member, so they stay unknown (unsafe) calls. `:[]=` is likewise not a member.
132
+ def member_setter_call?(call_node, member_set)
133
+ name = call_node.name.to_s
134
+ return false unless name.length > 1 && name.end_with?("=")
135
+ return false unless member_set.include?(name[0..-2].to_sym)
136
+
137
+ (call_node.arguments&.arguments&.size || 0) == 1
138
+ end
139
+
140
+ # Constructs whose body runs zero-or-many times or is deferred (loops, blocks, lambdas): a single static pass
141
+ # over the body cannot model a member setter's effect across iterations, so a setter inside one disqualifies the
142
+ # local. Straight-line conditionals (`if` / `unless` / `case`) are NOT boundaries — their branch scopes join
143
+ # soundly, so a setter in one branch is fine.
144
+ def deferred_boundary?(node)
145
+ node.is_a?(Prism::WhileNode) || node.is_a?(Prism::UntilNode) ||
146
+ node.is_a?(Prism::ForNode) || node.is_a?(Prism::BlockNode) ||
147
+ node.is_a?(Prism::LambdaNode)
148
+ end
149
+
113
150
  # The member set of a `<Struct chain>.new(...)` / `.[]` materialisation, or nil. Handles the inline
114
151
  # `Struct.new(:a, :b).new(...)` form and the `Const.new(...)` form (resolved through the layout side-table).
115
152
  def struct_materialization_members(value_node, layout_lookup)
@@ -156,7 +193,7 @@ module Rigor
156
193
  # Yields each child to recurse into, skipping the subtree of a nested local-variable-scope boundary (a `def` /
157
194
  # `class` / `module`).
158
195
  def each_local_scope_child(node)
159
- node.compact_child_nodes.each do |child|
196
+ node.rigor_each_child do |child|
160
197
  next if scope_boundary?(child)
161
198
 
162
199
  yield child
@@ -5,6 +5,7 @@ require "prism"
5
5
  require_relative "../plugin/macro/heredoc_template"
6
6
  require_relative "../plugin/macro/trait_registry"
7
7
  require_relative "../source/literals"
8
+ require_relative "../source/node_children"
8
9
  require_relative "synthetic_method"
9
10
  require_relative "synthetic_method_index"
10
11
 
@@ -166,12 +167,12 @@ module Rigor
166
167
  name = class_name_from(node, scope_stack)
167
168
  yield name, node if name
168
169
  new_stack = scope_stack + [node]
169
- node.body&.compact_child_nodes&.each { |child| walk_classes(child, new_stack, &) }
170
+ node.body&.rigor_each_child { |child| walk_classes(child, new_stack, &) }
170
171
  when Prism::ModuleNode
171
172
  new_stack = scope_stack + [node]
172
- node.body&.compact_child_nodes&.each { |child| walk_classes(child, new_stack, &) }
173
+ node.body&.rigor_each_child { |child| walk_classes(child, new_stack, &) }
173
174
  else
174
- node.compact_child_nodes.each { |child| walk_classes(child, scope_stack, &) }
175
+ node.rigor_each_child { |child| walk_classes(child, scope_stack, &) }
175
176
  end
176
177
  end
177
178
 
@@ -303,12 +304,12 @@ module Rigor
303
304
  name = class_name_from(node, scope_stack)
304
305
  yield name, node.body
305
306
  new_stack = scope_stack + [node]
306
- node.body&.compact_child_nodes&.each { |child| walk_module_decls(child, new_stack, &) }
307
+ node.body&.rigor_each_child { |child| walk_module_decls(child, new_stack, &) }
307
308
  when Prism::ClassNode
308
309
  new_stack = scope_stack + [node]
309
- node.body&.compact_child_nodes&.each { |child| walk_module_decls(child, new_stack, &) }
310
+ node.body&.rigor_each_child { |child| walk_module_decls(child, new_stack, &) }
310
311
  else
311
- node.compact_child_nodes.each { |child| walk_module_decls(child, scope_stack, &) }
312
+ node.rigor_each_child { |child| walk_module_decls(child, scope_stack, &) }
312
313
  end
313
314
  end
314
315
 
@@ -372,7 +373,7 @@ module Rigor
372
373
  hierarchy.freeze
373
374
  end
374
375
 
375
- def walk_class_decls(node, scope_stack, &) # rubocop:disable Metrics/PerceivedComplexity
376
+ def walk_class_decls(node, scope_stack, &)
376
377
  return unless node.respond_to?(:compact_child_nodes)
377
378
 
378
379
  if node.is_a?(Prism::ClassNode)
@@ -380,19 +381,19 @@ module Rigor
380
381
  parent = parent_name_from(node, scope_stack)
381
382
  yield name, parent if name
382
383
  new_stack = scope_stack + [node]
383
- node.body&.compact_child_nodes&.each { |child| walk_class_decls(child, new_stack, &) }
384
+ node.body&.rigor_each_child { |child| walk_class_decls(child, new_stack, &) }
384
385
  elsif node.is_a?(Prism::ModuleNode)
385
386
  new_stack = scope_stack + [node]
386
- node.body&.compact_child_nodes&.each { |child| walk_class_decls(child, new_stack, &) }
387
+ node.body&.rigor_each_child { |child| walk_class_decls(child, new_stack, &) }
387
388
  else
388
- node.compact_child_nodes.each { |child| walk_class_decls(child, scope_stack, &) }
389
+ node.rigor_each_child { |child| walk_class_decls(child, scope_stack, &) }
389
390
  end
390
391
  end
391
392
 
392
393
  # Yields `(class_name, call_node)` for every Prism::CallNode at class-body top level (singleton-context calls).
393
394
  # Nested method bodies, blocks, and conditionals are skipped — the Tier C call shapes the substrate targets all
394
395
  # live at the class body's top level.
395
- def walk_class_bodies(node, scope_stack = [], &) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
396
+ def walk_class_bodies(node, scope_stack = [], &)
396
397
  return unless node.respond_to?(:compact_child_nodes)
397
398
 
398
399
  if node.is_a?(Prism::ClassNode)
@@ -403,12 +404,12 @@ module Rigor
403
404
  yield name, stmt if stmt.is_a?(Prism::CallNode) && stmt.receiver.nil?
404
405
  end
405
406
  end
406
- node.body&.compact_child_nodes&.each { |child| walk_class_bodies(child, new_stack, &) }
407
+ node.body&.rigor_each_child { |child| walk_class_bodies(child, new_stack, &) }
407
408
  elsif node.is_a?(Prism::ModuleNode)
408
409
  new_stack = scope_stack + [node]
409
- node.body&.compact_child_nodes&.each { |child| walk_class_bodies(child, new_stack, &) }
410
+ node.body&.rigor_each_child { |child| walk_class_bodies(child, new_stack, &) }
410
411
  else
411
- node.compact_child_nodes.each { |child| walk_class_bodies(child, scope_stack, &) }
412
+ node.rigor_each_child { |child| walk_class_bodies(child, scope_stack, &) }
412
413
  end
413
414
  end
414
415
 
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Inference
5
+ # ADR-100 WD3 — the resolved `-> void` origin site recorded in {Rigor::Scope#void_origins}. A small cause
6
+ # record, never a carrier field: it says *which* author-declared `-> void` return was recovered to `top`
7
+ # at a use site, so the `static.value-use.void` diagnostic can name it. Mirrors the provenance-as-
8
+ # side-channel discipline {DynamicOrigin} established (ADR-75) — it never participates in subtyping,
9
+ # consistency, normalization, or erasure.
10
+ #
11
+ # @!attribute class_name
12
+ # @return [String] the receiver class whose method declared `-> void`.
13
+ # @!attribute method_name
14
+ # @return [Symbol] the method whose return was recovered.
15
+ # @!attribute kind
16
+ # @return [Symbol] `:instance` or `:singleton`.
17
+ VoidOrigin = Data.define(:class_name, :method_name, :kind) do
18
+ # A human-facing `Class#method` / `Class.method` label for the diagnostic message.
19
+ def label
20
+ separator = kind == :singleton ? "." : "#"
21
+ "#{class_name}#{separator}#{method_name}"
22
+ end
23
+ end
24
+ end
25
+ end