rigortype 0.3.1 → 0.3.3

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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  3. data/data/core_overlay/pathname.rbs +5 -0
  4. data/data/core_overlay/resolv.rbs +31 -0
  5. data/data/core_overlay/string_scanner.rbs +5 -0
  6. data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
  7. data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
  8. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
  9. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
  10. data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
  11. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
  12. data/docs/manual/02-cli-reference.md +71 -11
  13. data/docs/manual/03-configuration.md +16 -1
  14. data/docs/manual/04-diagnostics.md +8 -0
  15. data/docs/manual/12-caching.md +17 -5
  16. data/docs/manual/15-type-protection-coverage.md +122 -1
  17. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  18. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
  19. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
  20. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  21. data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
  22. data/lib/rigor/analysis/check_rules.rb +107 -28
  23. data/lib/rigor/analysis/incremental_session.rb +156 -11
  24. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  25. data/lib/rigor/analysis/rule_catalog.rb +5 -1
  26. data/lib/rigor/analysis/run_cache_key.rb +17 -1
  27. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  28. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  29. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  30. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  31. data/lib/rigor/analysis/runner.rb +78 -7
  32. data/lib/rigor/analysis/severity_stamp.rb +2 -1
  33. data/lib/rigor/bleeding_edge.rb +132 -7
  34. data/lib/rigor/cache/descriptor.rb +6 -1
  35. data/lib/rigor/cache/engine_source.rb +162 -0
  36. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  37. data/lib/rigor/cli/check_command.rb +49 -28
  38. data/lib/rigor/cli/coverage_command.rb +15 -2
  39. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  40. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  41. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  42. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  43. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  44. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  45. data/lib/rigor/cli/protection_renderer.rb +13 -0
  46. data/lib/rigor/cli/protection_report.rb +11 -3
  47. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  48. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  49. data/lib/rigor/configuration.rb +45 -6
  50. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  51. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  52. data/lib/rigor/environment/rbs_loader.rb +434 -75
  53. data/lib/rigor/environment.rb +28 -13
  54. data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
  55. data/lib/rigor/inference/expression_typer.rb +62 -4
  56. data/lib/rigor/inference/fork_map.rb +47 -4
  57. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  58. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  59. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  60. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +158 -12
  61. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  62. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +234 -11
  63. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  64. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  65. data/lib/rigor/inference/method_dispatcher.rb +41 -10
  66. data/lib/rigor/inference/mutation_widening.rb +30 -25
  67. data/lib/rigor/inference/narrowing.rb +3 -1
  68. data/lib/rigor/inference/optimistic_origin.rb +123 -0
  69. data/lib/rigor/inference/protection_scanner.rb +18 -2
  70. data/lib/rigor/inference/receiver_alias.rb +57 -0
  71. data/lib/rigor/inference/scope_indexer.rb +197 -55
  72. data/lib/rigor/inference/singleton_object_constant.rb +63 -0
  73. data/lib/rigor/inference/statement_evaluator.rb +55 -4
  74. data/lib/rigor/language_server/buffer_table.rb +22 -0
  75. data/lib/rigor/language_server/diagnostic_publisher.rb +203 -4
  76. data/lib/rigor/language_server/project_context.rb +45 -0
  77. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  78. data/lib/rigor/language_server/server.rb +20 -1
  79. data/lib/rigor/language_server.rb +1 -0
  80. data/lib/rigor/plugin/node_rule_walk.rb +6 -0
  81. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  82. data/lib/rigor/protection/closure_kill_oracle.rb +190 -0
  83. data/lib/rigor/protection/dependency_closure.rb +59 -0
  84. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  85. data/lib/rigor/protection/discovery_seed.rb +116 -0
  86. data/lib/rigor/protection/kill_signature.rb +31 -0
  87. data/lib/rigor/protection/mutation_cache.rb +355 -0
  88. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  89. data/lib/rigor/protection/mutator.rb +26 -4
  90. data/lib/rigor/runtime/jit.rb +63 -1
  91. data/lib/rigor/scope/discovery_index.rb +9 -0
  92. data/lib/rigor/scope.rb +114 -10
  93. data/lib/rigor/sig_gen/generator.rb +130 -31
  94. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  95. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  96. data/lib/rigor/sig_gen/renderer.rb +20 -1
  97. data/lib/rigor/sig_gen/write_result.rb +6 -4
  98. data/lib/rigor/sig_gen/writer.rb +12 -0
  99. data/lib/rigor/sig_gen.rb +1 -0
  100. data/lib/rigor/source/node_walker.rb +12 -0
  101. data/lib/rigor/type/anonymous_class_name.rb +40 -0
  102. data/lib/rigor/type/nominal.rb +5 -2
  103. data/lib/rigor/type/singleton.rb +4 -1
  104. data/lib/rigor/version.rb +1 -1
  105. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  106. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  107. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  108. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  109. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  110. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  111. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  112. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  113. data/sig/rigor/inference/void_origin.rbs +6 -3
  114. data/sig/rigor/scope.rbs +12 -0
  115. data/sig/rigor.rbs +4 -0
  116. metadata +21 -1
@@ -399,23 +399,38 @@ module Rigor
399
399
  ) { invoke_synthesizer_safely(callable, path) || "" }
400
400
  end
401
401
 
402
- # ADR-32 WD6 — route a synthesizer return value through the per-run failure reporter. The
402
+ # ADR-32 WD6 / WD12 — route a synthesizer return value through the per-run reporter. The
403
403
  # synthesizer's contract (declared in
404
- # `plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb`) admits three return shapes:
405
- # - `String` (non-empty) → successful RBS source
406
- # - `nil` / `""` → no contribution
407
- # - `[:error, message]` → parse failed
404
+ # `plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb`) admits four return shapes:
405
+ # - `String` (non-empty) → successful RBS source
406
+ # - `nil` / `""` → no contribution
407
+ # - `[:error, message]` → parse failed (WD6)
408
+ # - `[:ok, source, [message…]]` → synthesis SUCCEEDED, but an annotation was parsed and not
409
+ # honoured (WD12)
408
410
  # The error tuple is converted into a reporter entry + treated as "no contribution" so the analysis
409
- # pipeline continues. Reporter is `nil` for callers that don't care (legacy Environment.new, tests).
411
+ # pipeline continues. The WD12 tuple keeps its `source` the file's other annotations are good, and
412
+ # dropping them to report one omission would be a strictly worse trade. Reporter is `nil` for callers
413
+ # that don't care (legacy Environment.new, tests).
414
+ #
415
+ # A cached entry written before WD12 is a bare `String`, which falls through the `is_a?(Array)` guard
416
+ # unchanged, so no cache-descriptor bump is needed for the new shape.
410
417
  def interpret_synthesizer_outcome(outcome, plugin, path, reporter)
411
- return outcome unless outcome.is_a?(Array) && outcome[0] == :error
418
+ return outcome unless outcome.is_a?(Array)
419
+
420
+ case outcome[0]
421
+ when :error
422
+ record_synthesis_entry(reporter, plugin, path, outcome[1], :failed)
423
+ nil
424
+ when :ok
425
+ Array(outcome[2]).each { |m| record_synthesis_entry(reporter, plugin, path, m, :not_honoured) }
426
+ outcome[1]
427
+ else
428
+ outcome
429
+ end
430
+ end
412
431
 
413
- reporter&.record(
414
- plugin_id: plugin.manifest.id,
415
- path: path,
416
- message: outcome[1].to_s
417
- )
418
- nil
432
+ def record_synthesis_entry(reporter, plugin, path, message, kind)
433
+ reporter&.record(plugin_id: plugin.manifest.id, path: path, message: message.to_s, kind: kind)
419
434
  end
420
435
 
421
436
  SYNTHESIZER_CACHE_PRODUCER_ID = "plugin.source_rbs_synthesizer"
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../type/anonymous_class_name"
6
+
7
+ module Rigor
8
+ module Inference
9
+ # #319 — identity for the class a `Class.new do ... end` / `Module.new do ... end` creates away from
10
+ # constant-write position.
11
+ #
12
+ # Ruby evaluates such a block with `self` bound to the freshly created class (`class_eval` semantics): a
13
+ # `def` inside it defines an instance method on that class and `attr_reader` runs as a class-level macro.
14
+ # `ScopeIndexer` has always walked that body as a class body — but only through its
15
+ # `Prism::ConstantWriteNode` branch, because the constant supplied the name the discovered-method tables
16
+ # are keyed by. At every other position (`klass = Class.new do ... end`, a bare expression, a method
17
+ # return) there was no name, so the body fell through to the enclosing — usually top-level — scope. Two
18
+ # false positives followed on correct code: `attr_reader` became a `call.unresolved-toplevel` warning
19
+ # (`Scope#toplevel?` is `self_type.nil?`), and the class itself typed as the bare `Singleton[Object]`
20
+ # `MethodDispatcher#class_new_lift` hands back, taking `Object`'s zero-arity `new` with it.
21
+ #
22
+ # This module supplies the missing name: a per-call-site synthetic one, spelled so that no Ruby constant
23
+ # path can collide with it in the discovery tables, and stable across the index pass and the dispatch
24
+ # pass because both derive it from the same `(call node, source path)` pair.
25
+ module AnonymousMetaClass
26
+ module_function
27
+
28
+ # The class-creating meta calls whose block body is a class body. `Struct.new` / `Data.define` are here
29
+ # because the body semantics are identical — the block is `class_eval`'d on the generated subclass — not
30
+ # because their anonymous *value* is modelled: those keep the `StructClass` / `DataClass` carriers
31
+ # {MethodDispatcher::StructFolding} / {MethodDispatcher::DataFolding} build for them.
32
+ META_NEW_SELECTORS = { Class: :new, Module: :new, Struct: :new, Data: :define }.freeze
33
+
34
+ # The receiver constant name (`:Class`, `:Module`, `:Struct`, `:Data`) of a class-creating meta call that
35
+ # carries a literal block, or nil for anything else. The receiver MUST be the bare constant (or its
36
+ # `::`-rooted spelling) — a call through a variable or another call's return has no statically known
37
+ # identity, exactly as `ScopeIndexer#meta_constant_receiver?` requires.
38
+ #
39
+ # Argument shapes are deliberately NOT inspected: `Class.new(Base) { ... }` and
40
+ # `Data.define(*members) { ... }` evaluate their block as a class body whatever the arguments are, and a
41
+ # narrower gate here would leave the body in top-level scope again.
42
+ def block_form_receiver(node)
43
+ return nil unless node.is_a?(Prism::CallNode)
44
+ return nil unless node.block.is_a?(Prism::BlockNode)
45
+
46
+ receiver_name = literal_constant_name(node.receiver)
47
+ return nil if receiver_name.nil?
48
+ return nil unless META_NEW_SELECTORS[receiver_name] == node.name
49
+
50
+ receiver_name
51
+ end
52
+
53
+ # The synthetic class name for `node`'s block body, or nil when `node` is not a recognised block form.
54
+ # {Type::AnonymousClassName} owns the spelling; this decides which call sites earn one and what the key
55
+ # holds.
56
+ #
57
+ # `source_path` is part of the key so two files whose anonymous classes happen to share a line and column
58
+ # do not merge their method sets when the per-file tables are folded into the cross-file seed. It is
59
+ # omitted when the caller has no path (unit-level probes over a bare `Prism` program) — passed through
60
+ # verbatim rather than defaulted, so every pass over the same file derives the same name.
61
+ def name_for(node, source_path = nil)
62
+ receiver_name = block_form_receiver(node)
63
+ return nil if receiver_name.nil?
64
+
65
+ location = node.location
66
+ key = [source_path, location.start_line, location.start_column].compact.join(":")
67
+ Type::AnonymousClassName.build(receiver_name, key)
68
+ end
69
+
70
+ # The bare-constant receiver's name (`Class`, `::Class`), or nil for every other receiver shape.
71
+ def literal_constant_name(node)
72
+ case node
73
+ when Prism::ConstantReadNode
74
+ node.name
75
+ when Prism::ConstantPathNode
76
+ node.name if node.parent.nil?
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -19,6 +19,8 @@ require_relative "indexed_narrowing"
19
19
  require_relative "macro_block_self_type"
20
20
  require_relative "method_dispatcher"
21
21
  require_relative "narrowing"
22
+ require_relative "singleton_object_constant"
23
+ require_relative "optimistic_origin"
22
24
  require_relative "struct_fold_safety"
23
25
 
24
26
  module Rigor
@@ -609,6 +611,13 @@ module Rigor
609
611
  elide_or_union(node.predicate, else_type, then_type)
610
612
  end
611
613
 
614
+ # Issue #286 — the effective optimistic-nil-free cause of an expression. {OptimisticOrigin.resolve} owns
615
+ # the judgment, shared verbatim with `StatementEvaluator#optimistic_origin_for` and the
616
+ # `flow.always-truthy-condition` collector.
617
+ def optimistic_origin_for(node)
618
+ OptimisticOrigin.resolve(node, scope)
619
+ end
620
+
612
621
  def if_else_type(subsequent)
613
622
  return Type::Combinator.constant_of(nil) if subsequent.nil?
614
623
 
@@ -632,6 +641,9 @@ module Rigor
632
641
  # `Constant[false]` fold one branch; `Union[true, false]`, `Dynamic[T]`, and `Top` keep both branches live.
633
642
  def constant_predicate_polarity(predicate)
634
643
  return nil if predicate.nil?
644
+ # ADR-101 — decline on an optimistically nil-free carrier; see
645
+ # `StatementEvaluator#optimistic_carrier?` for why the gate is here and not in `Narrowing`.
646
+ return nil unless optimistic_origin_for(predicate).nil?
635
647
 
636
648
  Narrowing.predicate_certainty(type_of(predicate))
637
649
  end
@@ -648,7 +660,7 @@ module Rigor
648
660
  # fallback is preserved.
649
661
  def type_of_and_or(node)
650
662
  left_type = type_of(node.left)
651
- polarity = constant_value_polarity(left_type)
663
+ polarity = left_operand_polarity(node.left, left_type)
652
664
  return short_circuit_for(node, left_type, polarity) if polarity
653
665
 
654
666
  # The left operand only flows through on the edge that short-circuits: `a || b` yields `a` solely
@@ -674,6 +686,19 @@ module Rigor
674
686
  end
675
687
  end
676
688
 
689
+ # Issue #313 — the node-aware wrapper the `&&` / `||` short-circuit reads. The spec's exclusion binds
690
+ # this gate as much as it binds `flow.always-truthy-condition`, and a `Constant`-only gate is not by
691
+ # itself enough to honour it: a literal hash whose values share one type reads as a lone `Constant`
692
+ # (`UNIFORM[key]` → `Constant[1]`), so the gate would judge the left operand of `UNIFORM[key] || key`
693
+ # provably truthy and discard the author's fallback — the counter-example the spec names verbatim.
694
+ # Declining returns the union of both operands, which is what `StatementEvaluator#eval_and_or` produces
695
+ # anyway, so the two `&&` / `||` typers stay in agreement.
696
+ def left_operand_polarity(left_node, left_type)
697
+ return nil unless optimistic_origin_for(left_node).nil?
698
+
699
+ constant_value_polarity(left_type)
700
+ end
701
+
677
702
  # Returns `:truthy` / `:falsey` for a `Type::Constant`, nil otherwise. Mirrors
678
703
  # `constant_predicate_polarity` but operates on a typed value (already-type-of'd) rather than a Prism
679
704
  # node, so the same predicate analysis can be reused in both contexts.
@@ -1077,8 +1102,12 @@ module Rigor
1077
1102
  # through `Enumerable#select` / `Object#select` and the caller observes `Array[Elem]` instead of the
1078
1103
  # helper's actual return type. The check fires only for `node.receiver.nil?` (true implicit self), so
1079
1104
  # explicit-receiver dispatch is unaffected.
1105
+ #
1106
+ # Issue #316 — the lookup goes through the confidence-gated `Scope#bindable_top_level_def_for`, not the
1107
+ # raw table: inside a block whose `self` is unmodelled, a top-level `def` from ANOTHER file is not
1108
+ # evidence about which method the call reaches, so the bind is declined and the call widens.
1080
1109
  def try_local_def_dispatch(node, receiver, arg_types)
1081
- local_def = node.receiver.nil? ? scope.top_level_def_for(node.name) : nil
1110
+ local_def = node.receiver.nil? ? scope.bindable_top_level_def_for(node.name) : nil
1082
1111
  return nil unless local_def
1083
1112
 
1084
1113
  local_inference = infer_top_level_user_method(local_def, receiver, arg_types)
@@ -1144,7 +1173,7 @@ module Rigor
1144
1173
  # `module_function` body, re-type that body with the call args bound. Sits after the RBS dispatch
1145
1174
  # tier, so foreign / RBS-known singletons (`Math.sqrt`) keep their catalog answer; only
1146
1175
  # project-defined singleton methods reach here.
1147
- singleton_inference = try_singleton_method_inference(receiver, node, arg_types)
1176
+ singleton_inference = try_project_singleton_inference(receiver, node, arg_types)
1148
1177
  return singleton_inference if singleton_inference
1149
1178
 
1150
1179
  # Dynamic-origin propagation: when the receiver is Dynamic[T] and no positive rule resolves the call,
@@ -1321,6 +1350,31 @@ module Rigor
1321
1350
  nil
1322
1351
  end
1323
1352
 
1353
+ # The project-side singleton-method band: a `Foo.bar` call resolved against a `class << …` / `def self.…`
1354
+ # body the project itself wrote. Which of the two tiers applies is decided by the receiver carrier —
1355
+ # `Singleton[Foo]` when the constant names a class or module, `Nominal[…]` when it holds an ordinary
1356
+ # object (#320) — so the two are mutually exclusive and consulting both is one resolution attempt.
1357
+ def try_project_singleton_inference(receiver, call_node, arg_types)
1358
+ try_singleton_method_inference(receiver, call_node, arg_types) ||
1359
+ try_singleton_object_constant_inference(receiver, call_node, arg_types)
1360
+ end
1361
+
1362
+ # #320 — resolves a call whose receiver is a constant holding an ordinary object with a `class << Const`
1363
+ # singleton body, re-typing that body with the call's argument types bound. `self` inside the body IS
1364
+ # that object, so the receiver carrier is passed through unchanged. Own-constant only, and only for a
1365
+ # name the project actually recorded — a miss degrades to today's `Dynamic[top]`, never a false
1366
+ # resolution. `Singleton` receivers never reach here: {#try_singleton_method_inference} owns them.
1367
+ def try_singleton_object_constant_inference(receiver, call_node, arg_types)
1368
+ return nil unless receiver.is_a?(Type::Nominal)
1369
+
1370
+ def_node = SingletonObjectConstant.def_node_for(call_node, receiver, call_node.name, scope)
1371
+ return nil if def_node.nil?
1372
+
1373
+ infer_user_method_return(def_node, receiver, arg_types)
1374
+ rescue StandardError
1375
+ nil
1376
+ end
1377
+
1324
1378
  # ADR-24 slice 2 — resolves `method_name` against `class_name`'s own `def`s, then walks the user-class
1325
1379
  # ancestor chain: included / prepended modules (transitive) and the superclass chain. RBS-known
1326
1380
  # ancestors are NOT walked here — the `MethodDispatcher` RBS tier runs before
@@ -2429,7 +2483,11 @@ module Rigor
2429
2483
  case block_arg
2430
2484
  when Prism::BlockNode
2431
2485
  bindings = BlockParameterBinder.new(expected_param_types: expected).bind(block_arg)
2432
- block_scope = bindings.reduce(scope) { |acc, (name, type)| acc.with_local(name, type) }
2486
+ # Issue #316 mirrors `StatementEvaluator#build_block_entry_scope`: the block body's `self` is the
2487
+ # yielding method's business, so the return-typing pass must see the same unmodelled-self mark.
2488
+ block_scope = bindings.reduce(scope.entering_opaque_block) do |acc, (name, type)|
2489
+ acc.with_local(name, type)
2490
+ end
2433
2491
  block_scope = block_scope.with_self_type(narrowed_self_type) if narrowed_self_type
2434
2492
  type_block_body(block_arg, block_scope)
2435
2493
  when Prism::BlockArgumentNode
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "tmpdir"
4
4
 
5
+ require_relative "../runtime/jit"
6
+
5
7
  module Rigor
6
8
  module Inference
7
9
  # Generic fork-over-slices map for the embarrassingly-parallel per-file passes of the
@@ -35,6 +37,27 @@ module Rigor
35
37
  worker_count > 1 && Process.respond_to?(:fork)
36
38
  end
37
39
 
40
+ # Where a forked child may put process-private scratch files. `nil` on the parent and on the sequential
41
+ # path, meaning "you are not in a child — use `Dir.tmpdir`".
42
+ #
43
+ # A child ends at {.run_worker}'s `exit!`, which skips `at_exit` AND object finalizers, so a `Tempfile`
44
+ # a child creates is never reclaimed by the mechanism that reclaims the parent's (issue #330 —
45
+ # `Protection::ClosureKillOracle`'s per-process mutant file leaked one file per worker per
46
+ # `rigor coverage --protection --mutation` run). Answering with the directory the parent already removes
47
+ # once every child has been collected makes the child's scratch state the parent's problem, which is the
48
+ # only place it can be solved: the names are random, so nothing outside the child could find them.
49
+ #
50
+ # The directory check is the belt to {.run_worker}'s braces: the offer is only worth taking while the
51
+ # parent still has the directory open, and answering `nil` once it is gone degrades to `Dir.tmpdir`
52
+ # instead of raising `Errno::ENOENT` somewhere far from here.
53
+ #
54
+ # @return [String, nil]
55
+ def child_scratch_dir
56
+ return nil unless @child_scratch_dir && File.directory?(@child_scratch_dir)
57
+
58
+ @child_scratch_dir
59
+ end
60
+
38
61
  # Forks `worker_count` children over contiguous slices, waits for each, and returns the Marshal'd
39
62
  # per-slice payloads in slice order. A child that exits abnormally has its slice re-run in-process.
40
63
  def fork_map(items, worker_count, block)
@@ -55,11 +78,31 @@ module Rigor
55
78
  # Child-process body: run the block over the slice, Marshal the result to `out_path`, and `exit!`
56
79
  # (skipping `at_exit` / stdio flush — the payload is durable on disk). Any failure exits non-zero so
57
80
  # the parent re-runs the slice in-process.
81
+ # The `ensure` is not dead code even though `exit!` never unwinds: {.child_scratch_dir} is module state,
82
+ # and a caller that drives this body in-process with `exit!` stubbed (the deferred-YJIT spec does exactly
83
+ # that — a spy in the parent cannot observe a real child) would otherwise leave the offer standing for
84
+ # the rest of the process, pointing at a directory its `Dir.mktmpdir` block has since removed.
58
85
  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)
86
+ status = begin
87
+ # Re-arm deferred YJIT in the child, exactly as the check fork pool does
88
+ # ({Analysis::Runner::PoolCoordinator#run_fork_worker}). The parent's deadline thread does not
89
+ # survive `fork`, so without this a worker runs its whole slice un-JITted while the parent JITs the
90
+ # tail it no longer runs — the pool was a *pessimization* on long runs (`coverage --protection
91
+ # --mutation lib/rigor/analysis`: 37s sequential against 67s at eight workers). The child picks up
92
+ # what is left of the parent's window rather than a fresh one; {Runtime::Jit.rearm_after_fork}
93
+ # carries the mechanism.
94
+ Runtime::Jit.rearm_after_fork
95
+ # Published before the block runs, so anything it lazily creates for this process alone lands in the
96
+ # parent's tmpdir and dies with it. See {.child_scratch_dir}.
97
+ @child_scratch_dir = File.dirname(out_path)
98
+ File.binwrite(out_path, Marshal.dump(block.call(slice)))
99
+ 0
100
+ rescue StandardError
101
+ 1
102
+ ensure
103
+ @child_scratch_dir = nil
104
+ end
105
+ exit!(status)
63
106
  end
64
107
 
65
108
  # Waits for every child, placing each successful payload at its slice index; a child that exited
@@ -117,7 +117,16 @@ module Rigor
117
117
  :succ, :pred, :next, :abs, :magnitude,
118
118
  :bit_length, :to_s, :to_i, :to_int, :to_f,
119
119
  :floor, :ceil, :round, :truncate, :chr,
120
- :inspect, :-@, :+@, :~, :to_r, :to_c
120
+ :inspect, :-@, :+@, :~, :to_r, :to_c,
121
+ # `rationalize` (no-arg form) is the Integer sibling of the already-folded
122
+ # `Float#rationalize` / `Rational#rationalize` — pure, returns `Rational(self, 1)`. The
123
+ # optional-`eps` binary form is NOT mirrored here: unlike `Rational#rationalize` (which reaches
124
+ # `NUMERIC_BINARY`'s catalog fallback via `numeric.yml`'s `nurat_rationalize` leaf entry),
125
+ # `numeric.yml` carries no `rationalize` entry for Integer/Float, so the 1-arg call declines to
126
+ # the RBS tier exactly as it does today — this only closes the 0-arg gap.
127
+ # `abs2` (`self * self`, always a non-negative Integer) is the Integer sibling of the
128
+ # already-folded `Rational#abs2` / `Complex#abs2`.
129
+ :rationalize, :abs2
121
130
  ].freeze
122
131
  FLOAT_UNARY = Set[
123
132
  :zero?, :positive?, :negative?, :nonzero?,
@@ -135,7 +144,12 @@ module Rigor
135
144
  # `self >= 0`, `Math::PI` for `self < 0`. Pure sign test, deterministic; a NaN receiver yields NaN
136
145
  # which `foldable_constant_value?` declines.
137
146
  :arg, :angle, :phase,
138
- :inspect, :-@, :+@
147
+ :inspect, :-@, :+@,
148
+ # `abs2` (`self * self`) is the Float sibling of the already-folded `Rational#abs2` /
149
+ # `Complex#abs2` / (now) `Integer#abs2`. A non-finite receiver (`Infinity`, `NaN`) still folds
150
+ # via `foldable_constant_value?`'s existing NaN guard — `Infinity.abs2` is `Infinity`
151
+ # (foldable), `NaN.abs2` is `NaN` (declined, same as every other NaN-producing Float unary op).
152
+ :abs2
139
153
  ].freeze
140
154
  STRING_UNARY = Set[
141
155
  :upcase, :downcase, :capitalize, :swapcase,
@@ -195,6 +209,11 @@ module Rigor
195
209
  # `flow.always-truthy-condition`.
196
210
  REFLECTIVE_SEND_METHODS = %i[public_send send __send__].to_set.freeze
197
211
 
212
+ # The self-returners, the `Type::Constant` counterpart of `ShapeDispatch`'s `:shape_self` handlers
213
+ # (ADR-76 WD2 / ADR-78 WD3). Each returns the receiver at run time, so the value-pinned carrier
214
+ # survives the call.
215
+ CONSTANT_SELF_RETURNERS = %i[freeze itself dup clone].to_set.freeze
216
+
198
217
  # @return [Rigor::Type::Constant, Rigor::Type::Union, Rigor::Type::IntegerRange, nil]
199
218
  def try_dispatch(context)
200
219
  receiver = context.receiver
@@ -205,6 +224,9 @@ module Rigor
205
224
  first_arg = args.first
206
225
  return nil unless first_arg.is_a?(Type::Constant) && first_arg.value.is_a?(Symbol)
207
226
  end
227
+ self_return = try_fold_self_return(receiver, method_name, args)
228
+ return self_return if self_return
229
+
208
230
  # v0.0.7 — `String#%` against a `Tuple` / `HashShape` argument runs Ruby's format-string engine
209
231
  # when both sides are statically constant. The standard `numeric_set_of` path bails on Tuple /
210
232
  # HashShape arguments because they are not scalar-Constant carriers, so the special-case sits
@@ -221,6 +243,32 @@ module Rigor
221
243
  dispatch_by_arity(receiver_set, method_name, arg_sets)
222
244
  end
223
245
 
246
+ # `freeze` / `itself` / `dup` / `clone` on a value-pinned receiver return the receiver carrier.
247
+ #
248
+ # This sits AHEAD of the catalogue path and deliberately bypasses its purity gate, because the gate
249
+ # asks a question that does not apply. `String#freeze` is classified `purity: mutates_self`
250
+ # (`data/builtins/ruby_core/string.yml`) — correctly, since the C body sets `FL_FREEZE` — and the
251
+ # gate then holds it back from the pure-invoke path. But nothing here is invoked: the receiver type
252
+ # is returned unchanged, and freezing mutates an object's flags, not its value. `Type::Constant`
253
+ # pins a value, not an identity, so the mutation it performs is invisible to the carrier.
254
+ #
255
+ # The gap this closes is `FOO = "bar".freeze`, the standard way to write a String constant in
256
+ # Ruby, which lost its value while `%w[a b].freeze` (a Tuple, via `:shape_self`) and `"bar".-@`
257
+ # (classified `purity: leaf`) both kept theirs. Integer / Float / Symbol had the same hole for a
258
+ # different reason — no `Object`/`Kernel` catalogue owns their self-returners at all.
259
+ #
260
+ # `dup` / `clone` return a NEW object at run time, so the carrier is right about the value and
261
+ # silent about the identity — the same trade `shape_self` already makes, and the same one the
262
+ # existing `"x".dup` fold (which reaches the invoke path, `dup` being `leaf`) already makes.
263
+ # A `clone(freeze: false)` carries an argument and declines here.
264
+ def try_fold_self_return(receiver, method_name, args)
265
+ return nil unless args.empty?
266
+ return nil unless CONSTANT_SELF_RETURNERS.include?(method_name)
267
+ return nil unless receiver.is_a?(Type::Constant)
268
+
269
+ receiver
270
+ end
271
+
224
272
  # `Constant<String> % …` — runs the actual `String#%` operation when both sides are statically
225
273
  # known. The argument may be:
226
274
  # - A `Type::Constant` whose value is a scalar (Integer / Float / String / Symbol). Already handled
@@ -116,7 +116,7 @@ module Rigor
116
116
  map = member_map_for_new(members, context)
117
117
  return degraded_instance(class_name) if map.nil?
118
118
 
119
- Type::Combinator.data_instance_of(members: map, class_name: class_name)
119
+ Type::Combinator.data_instance_of(members: widen_unowned_emptiness(map), class_name: class_name)
120
120
  end
121
121
 
122
122
  # Builds the member -> type map from the call's arguments, honouring the keyword vs positional
@@ -4,6 +4,7 @@ require "prism"
4
4
 
5
5
  require_relative "../../type"
6
6
  require_relative "../../source/node_children"
7
+ require_relative "../mutation_widening"
7
8
 
8
9
  module Rigor
9
10
  module Inference
@@ -18,6 +19,39 @@ module Rigor
18
19
  # Both folders `extend` this module so the projections resolve as their own module functions
19
20
  # (matching their `module_function` style).
20
21
  module MemberShapeProjection
22
+ # A value object's member holds a **reference** to whatever the constructor was handed, and an
23
+ # EMPTY container literal's emptiness is a fact about that argument at the instant of
24
+ # construction — never a fact about the member. The caller keeps its own alias, and "construct
25
+ # empty, then fill" (`r = R.new([], []); xs.each { |x| r.items << x }`) is the dominant Ruby
26
+ # shape, so of every literal a member map can pin, emptiness is the one falsified most often.
27
+ #
28
+ # It is also the only one whose reads fold to `nil` — `.first` / `.last` / `[]` on an empty
29
+ # container — and `nil` is the receiver type that fires `call.undefined-method` on correct code
30
+ # (issue #293). A non-empty literal's element evidence, by contrast, survives an append and its
31
+ # reads never fold to `nil`, so it is kept intact.
32
+ #
33
+ # So an empty `Tuple` / closed empty `HashShape` is recorded widened to its bare nominal —
34
+ # the same carrier, through the same helpers, that {Inference::MutationWidening} installs when
35
+ # it retracts a mutated local's literal shape.
36
+ def widen_unowned_emptiness(member_map)
37
+ return member_map if member_map.nil?
38
+
39
+ member_map.transform_values { |type| widened_empty_container(type) }
40
+ end
41
+
42
+ def widened_empty_container(type)
43
+ case type
44
+ when Type::Tuple
45
+ type.elements.empty? ? MutationWidening.widen_tuple(type) : type
46
+ when Type::HashShape
47
+ # An OPEN shape claims nothing about the keys it does not list — a read of an unlisted key
48
+ # is already `untyped`, not `nil` — so only the closed literal `{}` carries the emptiness.
49
+ type.closed? && type.pairs.empty? ? MutationWidening.widen_hash_shape(type) : type
50
+ else
51
+ type
52
+ end
53
+ end
54
+
21
55
  # A Data/Struct subclass body can redefine a member's synthesised reader (`def x`); when it does,
22
56
  # `inst.x` runs that `def`, not the member, so folding the read would be unsound. A real `def` node
23
57
  # under the class name is the discriminator (the synthesised reader has none), so an entry in the
@@ -109,7 +143,7 @@ module Rigor
109
143
  return nil unless shape.optional_keys.empty?
110
144
  return nil unless shape.pairs.keys.all? { |key| instance.members.key?(key) }
111
145
 
112
- merged = instance.members.merge(shape.pairs)
146
+ merged = widen_unowned_emptiness(instance.members.merge(shape.pairs))
113
147
  yield(merged, instance.class_name)
114
148
  end
115
149
  end