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
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+
5
+ module Rigor
6
+ module Inference
7
+ # Generic fork-over-slices map for the embarrassingly-parallel per-file passes of the
8
+ # `rigor coverage --protection` path — the ADR-67 {ParameterInferenceCollector} rounds and the
9
+ # {ProtectionScanner} scan (P3-10). The expensive shared state (the RBS environment, plugin registry,
10
+ # seeded scope, parsed ASTs) is built ONCE on the parent; children copy-on-write inherit it and each map
11
+ # a contiguous slice of `items` to a marshalable payload that the parent collects **in slice order**.
12
+ #
13
+ # A child that exits abnormally (crash, unmarshalable payload) has its slice re-run in-process, so the
14
+ # returned array is identical to a plain sequential `[block.call(items)]` — order-preserving and
15
+ # complete. Slice order is the caller's `items` order, so a caller that merges the payloads in array
16
+ # order reproduces a sequential run byte-for-byte.
17
+ #
18
+ # This owns only the fork mechanics; the parent-side prewarm (forcing the RBS env to fully build so
19
+ # children inherit it warm) and the payload merge are the caller's responsibility.
20
+ module ForkMap
21
+ module_function
22
+
23
+ # @param items [Array] work items (file paths, or `[path, ast]` pairs), in caller order.
24
+ # @param workers [Integer] resolved worker count (≤1, empty items, or no `fork` → sequential).
25
+ # @yield [Array] a contiguous slice of `items`; must return a **marshalable** object.
26
+ # @return [Array] the per-slice block results, in original slice order.
27
+ def call(items:, workers:, &block)
28
+ worker_count = [workers, items.size].min
29
+ return [block.call(items)] unless parallel?(worker_count) && !items.empty?
30
+
31
+ fork_map(items, worker_count, block)
32
+ end
33
+
34
+ def parallel?(worker_count)
35
+ worker_count > 1 && Process.respond_to?(:fork)
36
+ end
37
+
38
+ # Forks `worker_count` children over contiguous slices, waits for each, and returns the Marshal'd
39
+ # per-slice payloads in slice order. A child that exits abnormally has its slice re-run in-process.
40
+ def fork_map(items, worker_count, block)
41
+ slices = items.each_slice((items.size.to_f / worker_count).ceil).to_a
42
+ payloads = Array.new(slices.size)
43
+
44
+ Dir.mktmpdir("rigor-fork-map") do |tmpdir|
45
+ children = slices.each_with_index.map do |slice, index|
46
+ out_path = File.join(tmpdir, "worker-#{index}")
47
+ { pid: fork { run_worker(slice, block, out_path) }, index: index, slice: slice, out_path: out_path }
48
+ end
49
+ collect(children, payloads, block)
50
+ end
51
+
52
+ payloads
53
+ end
54
+
55
+ # Child-process body: run the block over the slice, Marshal the result to `out_path`, and `exit!`
56
+ # (skipping `at_exit` / stdio flush — the payload is durable on disk). Any failure exits non-zero so
57
+ # the parent re-runs the slice in-process.
58
+ def run_worker(slice, block, out_path)
59
+ File.binwrite(out_path, Marshal.dump(block.call(slice)))
60
+ exit!(0)
61
+ rescue StandardError
62
+ exit!(1)
63
+ end
64
+
65
+ # Waits for every child, placing each successful payload at its slice index; a child that exited
66
+ # abnormally has its slice re-run in-process (identical to the sequential result).
67
+ def collect(children, payloads, block)
68
+ children.each do |child|
69
+ _, status = Process.waitpid2(child[:pid])
70
+ payload = worker_payload(status, child[:out_path])
71
+ payloads[child[:index]] = payload.nil? ? block.call(child[:slice]) : payload.fetch(:value)
72
+ end
73
+ end
74
+
75
+ # @return [Hash{value: Object}, nil] the child's payload wrapped so a legitimately-nil block result is
76
+ # distinguishable from an abnormal exit. `Marshal.load` is safe: the blob was written by our own
77
+ # forked child to a temp file we created.
78
+ def worker_payload(status, out_path)
79
+ return nil unless status.success? && File.exist?(out_path)
80
+
81
+ { value: Marshal.load(File.binread(out_path)) } # rubocop:disable Security/MarshalLoad
82
+ rescue StandardError
83
+ nil
84
+ end
85
+ end
86
+ end
87
+ end
@@ -11,9 +11,11 @@ module Rigor
11
11
  # and degrading to today's behaviour (no carrier / the `Data` nominal) the moment a premise is
12
12
  # uncertain, so the tier is precision-additive and adds no false-positive surface:
13
13
  #
14
- # 1. `Data.define(:x, :y)` on a `Singleton[Data]` receiver with literal-Symbol args and NO block ->
15
- # `DataClass{members: [...]}`. A block (`Data.define(:x) do ... end`) defers (slice 4 hardens the
16
- # block-body case); non-literal members (`Data.define(*names)`) defer.
14
+ # 1. `Data.define(:x, :y)` on a `Singleton[Data]` receiver with literal-Symbol args ->
15
+ # `DataClass{members: [...]}`. A `do ... end` block folds too when its body redefines no member
16
+ # reader (bare-local block-form parity the block AST stands in for the class name the read-time
17
+ # guard would otherwise need); a `&proc` block, a reader-redefining block, or non-literal members
18
+ # (`Data.define(*names)`) defer.
17
19
  # 2. `.new` / `.[]` on a `DataClass` receiver -> a `DataInstance` whose member map is built from the
18
20
  # call's positional or keyword arguments. An arity / key mismatch degrades to the `Data` (or the
19
21
  # tagged class) nominal rather than a wrong member map.
@@ -63,12 +65,32 @@ module Rigor
63
65
 
64
66
  def fold_define(context)
65
67
  return nil unless context.method_name == :define
66
- # Block-form (`Data.define(:x) do ... end`) defers — slice 4.
67
- return nil unless context.block_type.nil?
68
68
 
69
69
  members = member_names_from_args(context.args)
70
70
  return nil if members.nil? || members.empty?
71
71
 
72
+ # Block-form (`Data.define(:x) do ... end`) bare-local parity (ADR-48 "Remaining"): the assigned
73
+ # constant / subclass forms already fold via the layout side-table, with the reader-redefinition
74
+ # guard consulting `Scope#user_def_for`; the bare-local form has no resolvable class name for it
75
+ # to key on. But the block AST is in hand, so the guard is applied directly against it: fold when
76
+ # the block redefines no member's synthesised reader (folding `inst.x` would otherwise run the
77
+ # redefined `def x`), and bail conservatively — the prior behaviour — the moment the block cannot
78
+ # be soundly cleared (a `&proc` argument, or any `def <member>` in the body).
79
+ return fold_define_block(context, members) unless context.block_type.nil?
80
+
81
+ Type::Combinator.data_class_of(members: members)
82
+ end
83
+
84
+ # The block-form fold decision. Only a literal `do ... end` block whose body redefines no member
85
+ # reader folds; a `&proc` block argument (no scannable body) or a body with a member-reader `def`
86
+ # stays unfolded. The resulting `DataClass` carries no `class_name` — folding is already proven
87
+ # reader-safe here, so the read-time `reader_overridden?` guard (which keys on the class name) has
88
+ # nothing left to catch.
89
+ def fold_define_block(context, members)
90
+ block = context.call_node&.block
91
+ return nil unless block.is_a?(Prism::BlockNode)
92
+ return nil if block_redefines_member_reader?(block, members)
93
+
72
94
  Type::Combinator.data_class_of(members: members)
73
95
  end
74
96
 
@@ -126,6 +148,9 @@ module Rigor
126
148
  shape = args.first
127
149
  return nil unless shape.is_a?(Type::HashShape) && shape.closed?
128
150
  return nil unless shape.optional_keys.empty?
151
+ # Non-Symbol keys (a HashShape may carry String / numeric / bool / nil scalar keys) can never
152
+ # match the Symbol member list — and mixed-class keys would make the `.sort` below raise.
153
+ return nil unless shape.pairs.keys.all?(Symbol)
129
154
  return nil unless shape.pairs.keys.sort == members.sort
130
155
 
131
156
  members.to_h { |name| [name, shape.pairs.fetch(name)] }
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../type"
4
+ require_relative "constant_folding"
4
5
 
5
6
  module Rigor
6
7
  module Inference
@@ -24,9 +25,27 @@ module Rigor
24
25
  #
25
26
  # See `docs/type-specification/value-lattice.md` for the union-distribution contract this tier
26
27
  # mirrors.
28
+ #
29
+ # ADR-91 — this tier is the single owner of the `Kernel` module-function fold surface, covering
30
+ # the conversion/identity folds (`Array` / `Integer` / `Float` / `Rational` / `Complex` / `p` /
31
+ # `pp` / `String` / `Hash`) AND the `format` / `sprintf` template fold (moved here from
32
+ # `LiteralStringFolding` in ADR-91 WD2 — `String#%` stays there as a receiver-typed String
33
+ # method). Ownership gating no longer happens per-fold: the dispatcher consults this tier only
34
+ # when `context.method_name` is in {INTRINSIC_NAMES} and {kernel_owned_call?} passes (ADR-91
35
+ # WD1), so a fold body never re-derives the guard. Callers reaching `try_dispatch` directly
36
+ # (internal dispatcher, unit probes) vouch for ownership themselves.
27
37
  module KernelDispatch
28
38
  module_function
29
39
 
40
+ # ADR-91 WD1 — the compiled method-name surface of Kernel's foldable module functions. The
41
+ # dispatcher gates on membership here before consulting the tier, and the WD3 parity spec
42
+ # derives its case list from this same table so a fold added without an entry never runs and
43
+ # every entry is spelling-parity checked. Public (not private_constant) and Ractor-shareable
44
+ # because the gate and the spec both read it.
45
+ INTRINSIC_NAMES = Ractor.make_shareable(
46
+ Set[:Array, :Integer, :Float, :Rational, :Complex, :p, :pp, :String, :Hash, :format, :sprintf]
47
+ )
48
+
30
49
  # `Kernel#Rational` / `Kernel#Complex` constructor folds. When every argument is a `Type::Constant`
31
50
  # whose value is numeric, we can run the actual Ruby constructor and lift the result into a
32
51
  # `Constant<Rational>` / `Constant<Complex>`. The factory accepts the same shapes as Ruby:
@@ -49,6 +68,26 @@ module Rigor
49
68
  INTEGER_REFINEMENT_PREDICATES = Set[:decimal_int].freeze
50
69
  private_constant :INTEGER_REFINEMENT_PREDICATES
51
70
 
71
+ # `Kernel#p` / `Kernel#pp` return their argument: one argument comes back verbatim, several come
72
+ # back as an Array of them, zero returns nil. The zero-arg form declines here (the RBS `nil` is
73
+ # already exact); the 1-arg form is a precision-PRESERVING identity (the argument type passes
74
+ # through unchanged — shapes, constants, and `Dynamic` alike); the n-arg form is a `Tuple`.
75
+ IDENTITY_PRINTERS = Set[:p, :pp].freeze
76
+ private_constant :IDENTITY_PRINTERS
77
+
78
+ # `Kernel#format` / `Kernel#sprintf` — a String-template fold moved here from
79
+ # `LiteralStringFolding` in ADR-91 WD2 so every Kernel module-function fold sits behind the
80
+ # WD1 gate.
81
+ FORMAT_METHODS = Set[:format, :sprintf].freeze
82
+ private_constant :FORMAT_METHODS
83
+
84
+ # Value classes `Kernel#String` folds over. Each is a literal-carrier class whose `to_s` is a
85
+ # deterministic pure function of the value, so running the real `String()` at fold time is sound.
86
+ STRING_SAFE_CLASSES = [
87
+ String, Symbol, Integer, Float, Rational, Complex, NilClass, TrueClass, FalseClass
88
+ ].freeze
89
+ private_constant :STRING_SAFE_CLASSES
90
+
52
91
  def try_dispatch(context)
53
92
  receiver = context.receiver
54
93
  method_name = context.method_name
@@ -58,10 +97,171 @@ module Rigor
58
97
  return try_numeric_constructor(method_name, args) if NUMERIC_CONSTRUCTORS.key?(method_name)
59
98
  return try_integer(args) if method_name == :Integer
60
99
  return try_float(args) if method_name == :Float
100
+ return try_identity_printer(context) if IDENTITY_PRINTERS.include?(method_name)
101
+ return try_string(context) if method_name == :String
102
+ return try_hash(context) if method_name == :Hash
103
+ return try_format(context) if FORMAT_METHODS.include?(method_name)
104
+
105
+ nil
106
+ end
107
+
108
+ # `Kernel#p(x)` / `Kernel#pp(x)` identity typing. Runtime contract: `p x` returns `x`, `p a, b`
109
+ # returns `[a, b]`, bare `p` returns nil. The 1-arg path returns the argument's type object
110
+ # UNCHANGED — never widened, never concretized (a `Dynamic` argument stays `Dynamic`) — so a
111
+ # `p`-wrapped expression is transparent to downstream inference. The n-arg path materialises the
112
+ # positional types as a `Tuple`. The 0-arg path declines (RBS `nil` is already exact).
113
+ #
114
+ # FP envelope: declines on a splat / forwarding argument (the runtime arity — and hence
115
+ # 1-arg-identity vs n-arg-Array — is not statically known). Ownership is gated once at the
116
+ # dispatcher (ADR-91 WD1), so this body no longer re-checks {#kernel_owned_call?}.
117
+ def try_identity_printer(context)
118
+ args = context.args
119
+ return nil if args.empty?
120
+ return nil if unresolved_argument_arity?(context.call_node)
121
+ return args.first if args.size == 1
122
+
123
+ Type::Combinator.tuple_of(*args)
124
+ end
125
+
126
+ # `Kernel#String(v)` — folds a value-pinned scalar `Constant` to `Constant[String]`
127
+ # (`String(42)` → `"42"`, `String(nil)` → `""`). Restricted to {STRING_SAFE_CLASSES} so the fold
128
+ # never models a user-defined `to_s` / `to_str`; anything else declines to the RBS `String`.
129
+ def try_string(context)
130
+ args = context.args
131
+ return nil unless args.size == 1
132
+ return nil if unresolved_argument_arity?(context.call_node)
133
+
134
+ arg = args.first
135
+ return nil unless arg.is_a?(Type::Constant)
136
+ return nil unless STRING_SAFE_CLASSES.any? { |klass| arg.value.instance_of?(klass) }
61
137
 
138
+ Type::Combinator.constant_of(String(arg.value))
139
+ rescue StandardError
62
140
  nil
63
141
  end
64
142
 
143
+ # `Kernel#Hash(v)` — the trivially-sound slice only. A `HashShape` argument passes through
144
+ # unchanged (`Hash(h)` returns `h` for a real Hash); `Hash(nil)` and `Hash([])` (an empty Tuple)
145
+ # collapse to the empty `HashShape`. Arguments relying on the `to_hash` protocol are not decidable
146
+ # from types alone and decline to the RBS envelope.
147
+ def try_hash(context)
148
+ args = context.args
149
+ return nil unless args.size == 1
150
+ return nil if unresolved_argument_arity?(context.call_node)
151
+
152
+ case (arg = args.first)
153
+ when Type::HashShape then arg
154
+ when Type::Constant then arg.value.nil? ? Type::Combinator.hash_shape_of({}) : nil
155
+ when Type::Tuple then arg.elements.empty? ? Type::Combinator.hash_shape_of({}) : nil
156
+ end
157
+ end
158
+
159
+ # `Kernel#format("hello %s", lit)` / `Kernel#sprintf(...)` — moved here from
160
+ # `LiteralStringFolding` in ADR-91 WD2 so it sits behind the WD1 ownership gate with every
161
+ # other Kernel module-function fold. The template plus every value argument must be
162
+ # literal-bearing ({Type::Combinator.literal_string_compatible?}) or a `Type::Constant` of
163
+ # any value (Constants are always provably literal). The template arg specifically must be
164
+ # literal-bearing — a `Constant<Integer>` first arg would not be a valid format template, so
165
+ # the `Type::Constant` allowance applies only to subsequent value args.
166
+ #
167
+ # When the template AND every value argument are value-pinned `Constant`s, the fold sharpens
168
+ # to the exact `Constant[String]` (`format("%d", 1)` → `"1"`) — the module-function sibling
169
+ # of `ConstantFolding#try_fold_string_format`'s `String#%` fold. This is a strict refinement
170
+ # inside the same firing envelope: every exact-foldable input was already lifted to
171
+ # `literal-string`. Ownership is gated at the dispatcher, so the fold itself needs no check.
172
+ def try_format(context)
173
+ args = context.args
174
+ return nil if args.empty?
175
+
176
+ exact = fold_format_constant(args)
177
+ return exact if exact
178
+ return nil unless Type::Combinator.literal_string_compatible?(args.first)
179
+ return nil unless args.drop(1).all? { |arg| literal_or_constant?(arg) }
180
+
181
+ Type::Combinator.literal_string
182
+ end
183
+
184
+ # Runs the real `Kernel#format` when everything is value-pinned. A malformed directive or an
185
+ # argument-count mismatch raises at fold time; the handler declines so the literal-string
186
+ # lift (or the RBS `String` envelope) answers instead. Results larger than the shared
187
+ # `ConstantFolding::STRING_FOLD_BYTE_LIMIT` decline too, keeping the carrier-size convention.
188
+ def fold_format_constant(args)
189
+ return nil unless args.all?(Type::Constant)
190
+ return nil unless args.first.value.is_a?(String)
191
+
192
+ result = format(args.first.value, *args.drop(1).map(&:value))
193
+ return nil if result.bytesize > ConstantFolding::STRING_FOLD_BYTE_LIMIT
194
+
195
+ Type::Combinator.constant_of(result)
196
+ rescue StandardError
197
+ nil
198
+ end
199
+
200
+ # KernelDispatch's own copy of the literal-or-Constant predicate (LiteralStringFolding keeps
201
+ # its own for the `String#%` fold that stayed there).
202
+ def literal_or_constant?(type)
203
+ Type::Combinator.literal_string_compatible?(type) || type.is_a?(Type::Constant)
204
+ end
205
+
206
+ # True when the call can be attributed to Kernel's own module function. Kernel's instance-side
207
+ # surface is PRIVATE, so a call with an explicit non-`self` receiver is a user-defined method —
208
+ # decline — EXCEPT the `Kernel` module object itself: `module_function` exposes every intrinsic
209
+ # as a public singleton too, so `Kernel.p(x)` / `Kernel.format(...)` dispatch to the same
210
+ # intrinsic as the implicit-self spelling. Likewise decline when the receiver class (or the
211
+ # toplevel) carries a discovered user redefinition of the name (`def p(node)` in a printer class
212
+ # must not be hijacked by the fold; the precise tiers run ahead of user-method inference). With
213
+ # no call_node / scope (internal dispatcher callers, unit probes) the guards pass — the caller
214
+ # vouches for the shape.
215
+ #
216
+ # ADR-91 WD1: the dispatcher calls this ONCE, before consulting the tier, so the fold bodies
217
+ # no longer re-check it. Kept public so the dispatcher (and unit probes) can reach it.
218
+ def kernel_owned_call?(context)
219
+ return false if user_redefined?(context)
220
+
221
+ !explicit_foreign_receiver?(context.call_node) || kernel_module_receiver?(context)
222
+ end
223
+
224
+ # The explicit-receiver spelling of a module function: the receiver TYPE is the `Kernel` module
225
+ # object itself. Typed (not node-spelled) so a user constant that happens to be named `Kernel`
226
+ # inside a namespace does not slip through.
227
+ def kernel_module_receiver?(context)
228
+ receiver = context.receiver
229
+ receiver.is_a?(Type::Singleton) && receiver.class_name == "Kernel"
230
+ end
231
+
232
+ def explicit_foreign_receiver?(call_node)
233
+ return false if call_node.nil?
234
+
235
+ receiver = call_node.receiver
236
+ !(receiver.nil? || receiver.is_a?(Prism::SelfNode))
237
+ end
238
+
239
+ def user_redefined?(context)
240
+ scope = context.scope
241
+ return false if scope.nil?
242
+
243
+ name = context.method_name
244
+ return true if scope.top_level_def_for(name)
245
+
246
+ case (receiver = context.receiver)
247
+ when Type::Nominal then scope.discovered_method?(receiver.class_name, name, :instance)
248
+ when Type::Singleton then scope.discovered_method?(receiver.class_name, name, :singleton)
249
+ else false
250
+ end
251
+ end
252
+
253
+ # True when the argument list carries a splat or an argument-forwarding node — the positional
254
+ # arity (and therefore which fold shape applies) is not statically known. `nil` call_node
255
+ # (internal callers) reads as resolved: the args array is authoritative there.
256
+ def unresolved_argument_arity?(call_node)
257
+ arguments = call_node&.arguments
258
+ return false if arguments.nil?
259
+
260
+ arguments.arguments.any? do |argument|
261
+ argument.is_a?(Prism::SplatNode) || argument.is_a?(Prism::ForwardingArgumentsNode)
262
+ end
263
+ end
264
+
65
265
  # `Kernel#Integer(arg)` / `Integer(arg, base)`. Two folding paths, tried in order:
66
266
  #
67
267
  # 1. A `Refined[String, predicate]` argument whose predicate is a total-parse carrier narrows to
@@ -15,8 +15,9 @@ module Rigor
15
15
  # size cap).
16
16
  # - `Array#join` on `Tuple[…]` receivers whose every element plus the separator argument (when given)
17
17
  # is literal-bearing.
18
- # - `Kernel#format` / `Kernel#sprintf` (any receiver) and `String#%` (literal-bearing receiver) when
19
- # every value argument is literal-bearing or a Type::Constant of any value.
18
+ # - `String#%` (literal-bearing receiver) when every value argument is literal-bearing or a
19
+ # Type::Constant of any value. (`Kernel#format` / `Kernel#sprintf` moved to {KernelDispatch} in
20
+ # ADR-91 WD2 — they are Kernel module functions, not receiver-typed String methods.)
20
21
  #
21
22
  # Result rule:
22
23
  #
@@ -38,7 +39,6 @@ module Rigor
38
39
  module_function
39
40
 
40
41
  CONCAT_METHODS = %i[+ << concat].freeze
41
- FORMAT_METHODS = %i[format sprintf].freeze
42
42
  # v0.1.1 Track 1 slice 5a — methods that, called with no arguments on a literal-bearing receiver,
43
43
  # return a value that is also literal-bearing. `#strip` / `#lstrip` / `#rstrip` / `#chomp` (no-arg)
44
44
  # / `#chop` strip a known subset of characters from the ends, so the survivors are always a
@@ -55,7 +55,7 @@ module Rigor
55
55
  # Integer plus an optional literal padding `String`. When the receiver and the (default or
56
56
  # supplied) padding are both literal-bearing, the result is literal-bearing too.
57
57
  WIDTH_PADDING_METHODS = %i[center ljust rjust].freeze
58
- private_constant :CONCAT_METHODS, :FORMAT_METHODS,
58
+ private_constant :CONCAT_METHODS,
59
59
  :LITERAL_PRESERVING_METHODS, :NON_EMPTY_LITERAL_PRESERVING_METHODS,
60
60
  :WIDTH_PADDING_METHODS
61
61
 
@@ -64,7 +64,6 @@ module Rigor
64
64
  method_name = context.method_name
65
65
  args = context.args
66
66
  return fold_array_join(receiver, args) if method_name == :join
67
- return fold_format(args) if FORMAT_METHODS.include?(method_name)
68
67
  return nil unless Type::Combinator.literal_string_compatible?(receiver)
69
68
  return fold_string_percent(args) if method_name == :%
70
69
  return fold_no_arg(receiver, method_name) if args.empty?
@@ -140,21 +139,27 @@ module Rigor
140
139
  return nil unless receiver.elements.all? { |el| Type::Combinator.literal_string_compatible?(el) }
141
140
  return nil unless args.size <= 1
142
141
  return nil if args.size == 1 && !Type::Combinator.literal_string_compatible?(args.first)
142
+ # Defer to {ShapeDispatch}'s `tuple_join` when the precise `Constant<String>` fold is reachable —
143
+ # every element is a `Constant` and the separator is absent or a `Constant<String>`. This tier runs
144
+ # AHEAD of ShapeDispatch, so returning the generic `literal-string` here would shadow that strictly
145
+ # more precise result (`["a", "b"].join("-")` → `Constant<"a-b">` rather than `literal-string`).
146
+ # Mixed tuples that carry a non-`Constant` `literal-string` element keep folding to `literal-string`
147
+ # here, because no exact value is knowable for them.
148
+ return nil if constant_join_reachable?(receiver, args)
143
149
 
144
150
  Type::Combinator.literal_string
145
151
  end
146
152
 
147
- # `format("hello %s", lit)` / `sprintf(...)` template plus every value argument must be
148
- # literal-bearing ({Type::Combinator.literal_string_compatible?}) or a `Type::Constant` of any
149
- # value (Constants are always provably literal). The template arg specifically must be
150
- # literal-bearing a Constant<Integer> first arg would not be a valid format template, so the
151
- # `Type::Constant` allowance applies only to subsequent value args.
152
- def fold_format(args)
153
- return nil if args.empty?
154
- return nil unless Type::Combinator.literal_string_compatible?(args.first)
155
- return nil unless args.drop(1).all? { |arg| literal_or_constant?(arg) }
153
+ # True when every tuple element is a `Constant` and the separator is absent or a `Constant<String>` —
154
+ # exactly the inputs for which `ShapeDispatch.tuple_join` materialises a precise `Constant<String>`.
155
+ # An empty tuple qualifies (`[].join` → `Constant<"">`). Callers have already verified every element
156
+ # is literal-string-compatible, so a `Constant` element is necessarily a `Constant<String>`.
157
+ def constant_join_reachable?(receiver, args)
158
+ return false unless receiver.elements.all?(Type::Constant)
159
+ return true if args.empty?
156
160
 
157
- Type::Combinator.literal_string
161
+ arg = args.first
162
+ arg.is_a?(Type::Constant) && arg.value.is_a?(String)
158
163
  end
159
164
 
160
165
  # `"foo %s" % "x"` / `"foo %s" % ["x", "y"]` — receiver is the template (already verified
@@ -217,7 +222,8 @@ module Rigor
217
222
  end
218
223
 
219
224
  private_class_method :fold_no_arg, :fold_concat, :fold_repeat, :fold_array_join,
220
- :fold_format, :fold_string_percent, :fold_width_pad,
225
+ :constant_join_reachable?,
226
+ :fold_string_percent, :fold_width_pad,
221
227
  :non_empty_literal_result, :literal_or_constant?,
222
228
  :integer_typed?, :known_negative_integer?,
223
229
  :known_zero_integer?, :known_positive_integer?
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "prism"
4
+
3
5
  require_relative "../../type"
6
+ require_relative "../../source/node_children"
4
7
 
5
8
  module Rigor
6
9
  module Inference
@@ -26,6 +29,36 @@ module Rigor
26
29
  !scope.user_def_for(class_name, method_name).nil?
27
30
  end
28
31
 
32
+ # The bare-local block-form counterpart of {#reader_overridden?}: when a `Data.define`/`Struct.new`
33
+ # block has no resolvable class name to look up in the project def-node table, its member-reader
34
+ # redefinitions are read straight off the block AST instead. True when the block body defines a
35
+ # `def <member>` (a plain instance method, receiver-less) for any member — folding that member's
36
+ # read would run the redefined reader, not return the member. Nested `class`/`module`/`sclass`
37
+ # bodies open a different namespace and are skipped; a `def` anywhere else in the body counts.
38
+ def block_redefines_member_reader?(block, members)
39
+ body = block.body
40
+ return false if body.nil?
41
+
42
+ member_set = members.is_a?(Set) ? members : members.to_set
43
+ reader_def?(body, member_set)
44
+ end
45
+
46
+ def reader_def?(node, member_set)
47
+ return false unless node.is_a?(Prism::Node)
48
+ return true if node.is_a?(Prism::DefNode) && node.receiver.nil? && member_set.include?(node.name)
49
+ # A nested class / module / singleton-class body opens a different namespace — its `def`s do not
50
+ # redefine the value object's reader, so the walk does not descend into it.
51
+ return false if reader_scope_boundary?(node)
52
+
53
+ node.rigor_each_child { |child| return true if reader_def?(child, member_set) }
54
+ false
55
+ end
56
+
57
+ def reader_scope_boundary?(node)
58
+ node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode) ||
59
+ node.is_a?(Prism::SingletonClassNode)
60
+ end
61
+
29
62
  def instance_index(instance, args)
30
63
  return nil unless args.size == 1
31
64
 
@@ -241,6 +241,9 @@ module Rigor
241
241
  # would beat strictly-typed alternatives in pass 2 of the selector.
242
242
  def strictly_typed_params?(method_type, actual_count)
243
243
  fun = method_type.type
244
+ # A `(?)` method type declares no params at all: it is the gradual case by construction, so it
245
+ # must never win the strict pass over a genuinely typed sibling overload.
246
+ return false unless fun.respond_to?(:required_positionals)
244
247
  return false unless arity_compatible?(fun, actual_count)
245
248
 
246
249
  params = positional_params_for(fun, actual_count)
@@ -299,7 +302,11 @@ module Rigor
299
302
  !fun.required_keywords.empty?
300
303
  end
301
304
 
305
+ # `RBS::Types::UntypedFunction` (`(?)`) declares no arity to enforce, so every call site is
306
+ # arity-compatible with it.
302
307
  def arity_compatible?(fun, actual_count)
308
+ return true unless fun.respond_to?(:required_positionals)
309
+
303
310
  min_arity = fun.required_positionals.size + fun.trailing_positionals.size
304
311
  return false if actual_count < min_arity
305
312
 
@@ -314,6 +321,10 @@ module Rigor
314
321
  # Rest_positionals consumes the remainder; we repeat its single declaration for each absorbed
315
322
  # argument.
316
323
  def positional_params_for(fun, actual_count)
324
+ # `(?)` declares no positional params; the caller zips this against the actual args, so an empty
325
+ # list is what "accepts anything, constrains nothing" looks like here.
326
+ return [] unless fun.respond_to?(:required_positionals)
327
+
317
328
  required = fun.required_positionals
318
329
  optional = fun.optional_positionals
319
330
  rest = fun.rest_positionals