rigortype 0.3.2 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 53ce9543e32229f3a80491f33886ac27cc0e1d3c0bb834536c703ab669188dd2
4
- data.tar.gz: f9bb40e6e3a44a8bd362cee2b5f46a406d36391c5cf3053982950f4acae574df
3
+ metadata.gz: ddd6ef9252340b3eb225c6f897e877b77805c4debc2444af0a6fa32db9aebea7
4
+ data.tar.gz: 8cb4ddc14efbf6aa1c6bc685e0135b3ce031f56cedb31429e1625272c23b38ac
5
5
  SHA512:
6
- metadata.gz: f233af3b211b096772e29ee29eda2ef7a7bca353e02f300c0f6ed36cbfb66deb73df709486896207f9459a0fbb5203c155ed257790c7d8122562780b1a532391
7
- data.tar.gz: 21ed0e73b967f692d6df3a65a9cd6444b5b801b545cb82cb59e4cec8d0effdcec4e8f9b97ebd7a811a0ebe8a08366bdaabbb07df1338a59a939e5d855281b2be
6
+ metadata.gz: 7be72cdb0e5c5a7e5a417291d44fdbfef44628dda68597b2610c1865efb95adf37d492f7fce505965b5e394e9a685504917bcb7aaaa756091086d9217676dbba
7
+ data.tar.gz: 53e6b313960d0a3606be6925dafe0b71850850fcaff78b2f1015d3c4bd2ff97af452e0eaef70da9d4e14f9094e890d72fbc4da77e5508cf4c51ad6650fb680ea
data/README.md CHANGED
@@ -231,7 +231,7 @@ rigor docs --list # list every bundled page
231
231
 
232
232
  ## Status
233
233
 
234
- Current release: **`v0.3.2`** (2026-08-08) — on the
234
+ Current release: **`v0.3.3`** (2026-08-09) — on the
235
235
  `0.3.x` line, continuing the evaluation era opened by `v0.2.0`, the first
236
236
  publicly-announced (general / evaluation) release. The line publishes
237
237
  an enumerated [compatibility surface](docs/compatibility.md) as a
@@ -225,6 +225,14 @@ on the line the diagnostic points at; there is no
225
225
  `disable-block` form, so an expression spread over several
226
226
  lines needs the comment on each line that fires.
227
227
 
228
+ The marker has to be the **first thing in the comment** — a
229
+ whole-line `# rigor:disable …` or a trailing
230
+ `expr # rigor:disable …`. A comment that merely quotes the
231
+ syntax, as this manual does throughout, is ordinary prose: it
232
+ suppresses nothing and warns about nothing. The same goes for a
233
+ doubled `## rigor:disable …` and for a marker written inside an
234
+ `=begin` / `=end` block — neither activates.
235
+
228
236
  A marker that cannot work is flagged rather than silently
229
237
  ignored: a token that names no known rule (a typo like
230
238
  `call.undefined-metod`) fires
@@ -3,6 +3,7 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "../../source/node_children"
6
+ require_relative "../../inference/optimistic_origin"
6
7
  require_relative "inferred_param_guard"
7
8
 
8
9
  module Rigor
@@ -108,6 +109,13 @@ module Rigor
108
109
  # the precision-additive contract.
109
110
  return if InferredParamGuard.rooted?(predicate, scope)
110
111
 
112
+ # Issue #313 — a predicate whose constancy rests on an optimistically nil-free carrier is a bet, not
113
+ # proof: `MAP[key]` omits `nil` because pessimising the defaulted-Hash idiom costs more false
114
+ # positives than the miss it would model, so `MAP[key].nil?` folding to `false` says nothing about
115
+ # whether the key was there. The spec forbids this rule concluding from such a value; the derivation
116
+ # is what carries the mark across `.nil?`, `!` and `&&` / `||` to the predicate the rule reads.
117
+ return unless Inference::OptimisticOrigin.resolve(predicate, scope).nil?
118
+
111
119
  predicate_type = scope.type_of(predicate)
112
120
  return unless predicate_type.is_a?(Type::Constant)
113
121
 
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ module CheckRules
8
+ # ADR-58 WD1 — the shared "is this value's optionality declaration-sourced?" predicate.
9
+ #
10
+ # A `nil` whose only provenance is a declaration (the class-ivar index seed of a ctor `@x = nil`, a
11
+ # non-definitely-assigned ivar read) is real type information but not diagnostic fuel: the working
12
+ # program's cross-method invariant is assumed per the robustness principle ([ADR-5]). Every negative rule
13
+ # that would fire *because of* such a nil must therefore consult the same provenance question, and get the
14
+ # same answer.
15
+ #
16
+ # Before issue #324 each rule spelled the lookup itself, and they drifted: `call.possible-nil-receiver`
17
+ # read the `:local` mark (so `r = @right; r.key` was excused — ADR-58's own motivating shape), while both
18
+ # `call.argument-type-mismatch` gates opened by requiring a literal `Prism::InstanceVariableReadNode`, so
19
+ # `c = @count; sink.take_int(c)` fired on the same value with the same provenance. This module is that one
20
+ # question, asked in one place.
21
+ #
22
+ # **The mark is deliberately NOT transitive.** `Scope#with_declaration_sourced_local`
23
+ # ({Inference::StatementEvaluator#eval_local_write}) stamps `:local` only when the RHS is a *pure read of a
24
+ # currently declaration-sourced ivar*, so a second hop (`c = @count; d = c`) and an `||=` rewrite land on
25
+ # the plain `with_local` path and carry no mark.
26
+ #
27
+ # A branch join is NOT one of those cases, contrary to what this comment claimed when it landed: both
28
+ # arms of `if c then r = @count else r = @count end` stamp `(:local, :r)`, and `join_declaration_sourced`
29
+ # INTERSECTS, so the mark survives. Only an *asymmetric* join drops it, and by that intersection rather
30
+ # than by reaching `with_local`. The normative statement — establishment, drop, join and the full list of
31
+ # unmarked shapes, each verified against the implementation — is
32
+ # `docs/internal-spec/inference-engine.md` § "Declaration-sourced provenance mark (ADR-58)"; keep this
33
+ # comment subordinate to it.
34
+ #
35
+ # So `marked?` matches exactly the two node shapes the scope actually models and invents no propagation
36
+ # of its own; anything flow-live keeps firing.
37
+ module DeclarationSourcedGuard
38
+ module_function
39
+
40
+ # True when `node` is a direct read of a binding whose optionality is purely declaration-sourced. Any
41
+ # flow-live touch (a method-local `@x = nil` write, a failed-guard narrowing, a rebinding local write)
42
+ # drops the mark upstream, so this returns false there and the caller fires exactly as before.
43
+ def marked?(node, scope)
44
+ case node
45
+ when Prism::InstanceVariableReadNode then scope.declaration_sourced?(:ivar, node.name)
46
+ when Prism::LocalVariableReadNode then scope.declaration_sourced?(:local, node.name)
47
+ else false
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -145,6 +145,13 @@ module Rigor
145
145
  return unless node.is_a?(Prism::Node)
146
146
 
147
147
  dispatch(node, hooks, context)
148
+ # Issue #318 — `defined?`'s operand is never evaluated (the runtime inspects it statically), so
149
+ # nothing under a `DefinedNode` is reachable, evaluated code. Dispatching the DefinedNode itself
150
+ # is fine (a collector may care that it exists); descending into `#value` would feed collectors
151
+ # source that can never execute, e.g. `undefined_method_diagnostic` flagging a call inside
152
+ # `defined? @x && @x.method_call`.
153
+ return if node.is_a?(Prism::DefinedNode)
154
+
148
155
  child_context = descend(node, context)
149
156
  node.rigor_each_child { |child| walk(child, hooks, child_context) }
150
157
  end
@@ -5,11 +5,13 @@ require "prism"
5
5
  require_relative "../reflection"
6
6
  require_relative "../source/node_walker"
7
7
  require_relative "../source/constant_path"
8
+ require_relative "../inference/singleton_object_constant"
8
9
  require_relative "../type"
9
10
  require_relative "diagnostic"
10
11
  require_relative "dependency_recorder"
11
12
  require_relative "check_rules/rule_ids"
12
13
  require_relative "check_rules/inferred_param_guard"
14
+ require_relative "check_rules/declaration_sourced_guard"
13
15
  require_relative "check_rules/rule_walk"
14
16
  require_relative "check_rules/always_truthy_condition_collector"
15
17
  require_relative "check_rules/unreachable_clause_collector"
@@ -424,6 +426,12 @@ module Rigor
424
426
  # only. `# rigor:disable all` on a line suppresses
425
427
  # every rule on that line.
426
428
  #
429
+ # Both in-source forms are recognised only when the
430
+ # marker is the FIRST thing in the comment, so a
431
+ # directive quoted inside ordinary prose — as the bullets
432
+ # above quote it — is not a directive. See the pattern
433
+ # constants below.
434
+ #
427
435
  # Diagnostics with `rule == nil` (parse errors, path
428
436
  # errors, internal analyzer errors) are NEVER
429
437
  # suppressed — they represent failures the user cannot
@@ -443,17 +451,26 @@ module Rigor
443
451
  end
444
452
  end
445
453
 
446
- LINE_SUPPRESSION_PATTERN = /#\s*rigor:disable(?!-file)\s+(?<rules>[\w.,\s-]+)/
454
+ # Every suppression-recognition pattern below is anchored with `\A` against the COMMENT SLICE —
455
+ # Prism hands us a slice that starts at the `#`, so `\A#` means "the marker is the first thing in
456
+ # the comment". A real directive always has that shape, in both the whole-line form and the
457
+ # trailing `code # rigor:disable <rule>` form; prose that merely quotes a directive
458
+ # (`... like `# rigor:disable-file all` ...`) has text before the inner `#` and no longer
459
+ # activates anything. Two consequences worth naming: a doc-tool `##` comment never activates (the
460
+ # second `#` is neither whitespace nor the marker word), and an `=begin`/`=end` block comment
461
+ # never activates either (its slice starts at `=begin`). Unanchored, these patterns silently
462
+ # file-suppressed this very file — see issue #306.
463
+ LINE_SUPPRESSION_PATTERN = /\A#\s*rigor:disable(?!-file)\s+(?<rules>[\w.,\s-]+)/
447
464
  private_constant :LINE_SUPPRESSION_PATTERN
448
465
 
449
- FILE_SUPPRESSION_PATTERN = /#\s*rigor:disable-file\s+(?<rules>[\w.,\s-]+)/
466
+ FILE_SUPPRESSION_PATTERN = /\A#\s*rigor:disable-file\s+(?<rules>[\w.,\s-]+)/
450
467
  private_constant :FILE_SUPPRESSION_PATTERN
451
468
 
452
469
  # A `rigor:disable[-file]` marker word regardless of whether any rule tokens follow. Used only by the
453
470
  # `suppression.empty` detection — the two suppression patterns above require at least one token
454
471
  # character, so a bare `# rigor:disable` never reaches them. The lookahead keeps
455
472
  # `rigor:disable-something-else` from counting as a marker.
456
- BARE_SUPPRESSION_MARKER = /#\s*rigor:disable(?<file>-file)?(?![\w-])(?<rest>.*)/
473
+ BARE_SUPPRESSION_MARKER = /\A#\s*rigor:disable(?<file>-file)?(?![\w-])(?<rest>.*)/
457
474
  private_constant :BARE_SUPPRESSION_MARKER
458
475
 
459
476
  # A `rigor:` marker word that is NOT part of Rigor's suppression grammar but reads like an attempted
@@ -463,7 +480,7 @@ module Rigor
463
480
  # surveillance they silently suppress nothing. Matches `disable-<suffix>` for any suffix other than
464
481
  # `file`, and `enable` with or without a suffix.
465
482
  UNKNOWN_SUPPRESSION_MARKER =
466
- /#\s*rigor:(?<marker>disable-(?!file(?![\w-]))[\w-]+|enable(?:-[\w-]+)?)(?![\w-])(?<rest>.*)/
483
+ /\A#\s*rigor:(?<marker>disable-(?!file(?![\w-]))[\w-]+|enable(?:-[\w-]+)?)(?![\w-])(?<rest>.*)/
467
484
  private_constant :UNKNOWN_SUPPRESSION_MARKER
468
485
 
469
486
  # @return [Array<(Hash{Integer => Set}, Set)>] pair of
@@ -527,9 +544,10 @@ module Rigor
527
544
  end
528
545
 
529
546
  # A comment carrying the marker word but not the token-bearing suppression grammar. A remainder of
530
- # nothing but whitespace / commas is a genuinely empty marker (`# rigor:disable`); anything else
531
- # (documentation prose like "`# rigor:disable <rule>` comments") is left alone as an ordinary
532
- # comment, matching the parse path, which never treats it as a suppression either.
547
+ # nothing but whitespace / commas is a genuinely empty marker (`# rigor:disable`); anything else is
548
+ # left alone as an ordinary comment, matching the parse path, which never treats it as a
549
+ # suppression either. Prose that merely quotes a marker is already excluded a step earlier by the
550
+ # `\A` anchor, since the quotation is not at the start of the comment.
533
551
  def diagnose_bare_suppression_marker(path, comment, source, diagnostics)
534
552
  bare = BARE_SUPPRESSION_MARKER.match(source)
535
553
  if bare
@@ -545,8 +563,9 @@ module Rigor
545
563
 
546
564
  # `# rigor:disable-next-line <rule>` / `# rigor:enable <rule>` — a marker word Rigor's grammar does
547
565
  # not recognise but that reads as an attempted suppression (the RuboCop reflex). Fires only when the
548
- # remainder is empty or looks like a rule list, so prose mentioning the spelling in backticks stays
549
- # an ordinary comment — the same escape the empty-marker detection observes.
566
+ # marker opens the comment and the remainder is empty or looks like a rule list, so prose mentioning
567
+ # the spelling in backticks stays an ordinary comment — the same escape the empty-marker detection
568
+ # observes.
550
569
  def diagnose_unknown_suppression_marker(path, comment, source, diagnostics)
551
570
  unknown = UNKNOWN_SUPPRESSION_MARKER.match(source)
552
571
  return if unknown.nil?
@@ -656,6 +675,13 @@ module Rigor
656
675
  # non-nil constituent (which, for a cross-file project def, would
657
676
  # be a working-code false positive).
658
677
  receiver_type = safe_navigation_receiver(call_node, scope)
678
+
679
+ # #320 — the private-singleton-object idiom (`class << Merger = Object.new`). The body's methods
680
+ # are recorded on the constant's own name, but the receiver reads back as `Object`, so the
681
+ # class-keyed probes below cannot see them. Recover the name from the receiver syntax. Scoped to
682
+ # the recorded name only: `Merger.nope` still fires.
683
+ return nil if Inference::SingletonObjectConstant.recorded?(call_node, receiver_type, call_node.name, scope)
684
+
659
685
  class_name = concrete_class_name(receiver_type)
660
686
  # A union receiver has no single concrete class. The scalar path
661
687
  # below cannot reason about it, but the call is still definitely
@@ -1255,8 +1281,12 @@ module Rigor
1255
1281
  # principle. The nil stays in the displayed type; only its use as
1256
1282
  # diagnostic fuel is withheld. Any flow-live touch (method-local
1257
1283
  # nil write, failed-guard narrowing) drops the mark upstream, so
1258
- # flow-observed nil keeps firing exactly as before.
1259
- return nil if scope.declaration_sourced?(:local, call_node.receiver.name)
1284
+ # flow-observed nil keeps firing exactly as before. The receiver
1285
+ # is already narrowed to a local read above, so this asks
1286
+ # {DeclarationSourcedGuard} exactly what it asked before — but it
1287
+ # now asks it through the predicate the argument-type gates share
1288
+ # (issue #324), which is what keeps the two rules from drifting.
1289
+ return nil if DeclarationSourcedGuard.marked?(call_node.receiver, scope)
1260
1290
 
1261
1291
  # ADR-67 WD6b — an inferred-parameter receiver's type (incl. any nil constituent unioned in from a
1262
1292
  # nil call site) is an open-call-site lower bound; a possible-nil firing against it is an FP by
@@ -2383,13 +2413,16 @@ module Rigor
2383
2413
  params.map { |param| param.type.to_s.delete_prefix("::") }.uniq.join(" | ")
2384
2414
  end
2385
2415
 
2386
- # ADR-58 parity for the nil channel: a declaration-sourced ivar read
2387
- # that types as nil is the same not-diagnostic-fuel case the union
2388
- # path gates in {#declaration_sourced_nil_only_mismatch?}; suppress it
2389
- # here too so a ctor-seeded `@x = nil` read passed as an argument does
2390
- # not fire on a working program's cross-method invariant.
2416
+ # ADR-58 parity for the nil channel: a declaration-sourced read that
2417
+ # types as nil is the same not-diagnostic-fuel case the union path
2418
+ # gates in {#declaration_sourced_nil_only_mismatch?}; suppress it here
2419
+ # too so a ctor-seeded `@x = nil` read passed as an argument does not
2420
+ # fire on a working program's cross-method invariant. Issue #324 —
2421
+ # delegates to {DeclarationSourcedGuard}, so a local copy of the ivar
2422
+ # (`c = @count`) is excused on the same terms `possible-nil-receiver`
2423
+ # already excused it.
2391
2424
  def declaration_sourced_nil_argument?(arg, scope)
2392
- arg.is_a?(Prism::InstanceVariableReadNode) && scope.declaration_sourced?(:ivar, arg.name)
2425
+ DeclarationSourcedGuard.marked?(arg, scope)
2393
2426
  end
2394
2427
 
2395
2428
  def first_argument_mismatch(method_type, call_node, scope, param_overrides)
@@ -2452,14 +2485,15 @@ module Rigor
2452
2485
  !declaration_sourced_nil_only_mismatch?(arg, arg_type, param_type, scope)
2453
2486
  end
2454
2487
 
2455
- # True when `arg` is a declaration-sourced ivar read whose rejection is
2488
+ # True when `arg` is a declaration-sourced read whose rejection is
2456
2489
  # caused solely by its nil constituent: stripping nil from the argument
2457
2490
  # type yields a type the parameter accepts (gradual mode). Mirrors the
2458
- # `possible-nil-receiver` WD1 gate, keyed on the ivar provenance mark
2459
- # rather than a local copy.
2491
+ # `possible-nil-receiver` WD1 gate since issue #324 through the very
2492
+ # same {DeclarationSourcedGuard} predicate, so the ivar read and its
2493
+ # local copy (`c = @count`) are one case rather than two spellings that
2494
+ # can drift apart again.
2460
2495
  def declaration_sourced_nil_only_mismatch?(arg, arg_type, param_type, scope)
2461
- return false unless arg.is_a?(Prism::InstanceVariableReadNode)
2462
- return false unless scope.declaration_sourced?(:ivar, arg.name)
2496
+ return false unless DeclarationSourcedGuard.marked?(arg, scope)
2463
2497
  return false unless arg_type.is_a?(Type::Union)
2464
2498
  return false unless union_contains_nil?(arg_type)
2465
2499
 
@@ -2720,6 +2754,12 @@ module Rigor
2720
2754
 
2721
2755
  # Returns true when `override_visibility` is strictly more restrictive than `parent_visibility`
2722
2756
  # under the public > protected > private ordering.
2757
+ #
2758
+ # The nil guard below is defence, not dead code. `VISIBILITY_RANK` is a closed literal hash read
2759
+ # with a dynamic key, so a symbol outside the table reads as nil at runtime. The engine folds that
2760
+ # read to the nil-free value union `0 | 1 | 2`, which `internal-spec/inference-engine.md` declares
2761
+ # OPTIMISTIC rather than proof — and since issue #313 that mark survives the `.nil?` fold and the
2762
+ # `||` composition, so no suppression directive is needed here.
2723
2763
  def visibility_reduced?(parent_visibility, override_visibility)
2724
2764
  parent_rank = VISIBILITY_RANK[parent_visibility]
2725
2765
  override_rank = VISIBILITY_RANK[override_visibility]
@@ -660,6 +660,8 @@ module Rigor
660
660
  "The token resolves (canonical id, legacy alias, `all`, family wildcard, known engine id).",
661
661
  "The token starts with `plugin.` — plugins load dynamically, so their rule vocabulary cannot " \
662
662
  "be enumerated statically and under-warning is the FP-safe direction.",
663
+ "The marker does not open the comment (documentation prose quoting the syntax, a doubled " \
664
+ "`##` comment, or an `=begin` block) — that is not parsed as a suppression either.",
663
665
  "The comment merely mentions the marker followed by non-token text (documentation prose " \
664
666
  "like \"`# rigor:disable <rule>` comments\") — that is not parsed as a suppression either."
665
667
  ],
@@ -684,6 +686,7 @@ module Rigor
684
686
  does_not_fire_when: [
685
687
  "At least one token follows the marker (each token is then checked by " \
686
688
  "`suppression.unknown-rule` instead).",
689
+ "The marker does not open the comment (documentation prose quoting the syntax).",
687
690
  "Non-token text follows the marker (documentation prose mentioning the syntax)."
688
691
  ],
689
692
  suppression: "Complete the marker (`# rigor:disable <rule>` / `all`) or delete it; " \
@@ -730,7 +733,7 @@ module Rigor
730
733
  summary: "A comment uses a suppression marker Rigor does not recognise " \
731
734
  "(`rigor:disable-next-line`, `rigor:enable`, ...).",
732
735
  fires_when: [
733
- "A comment carries `rigor:disable-<suffix>` with a suffix other than `file`, or " \
736
+ "A comment OPENS with `rigor:disable-<suffix>` for a suffix other than `file`, or with " \
734
737
  "`rigor:enable[-<suffix>]` — typically the RuboCop reflex `# rigor:disable-next-line " \
735
738
  "<rule>` — followed by nothing or a rule-list-shaped remainder.",
736
739
  "Such a marker is invisible to the whole suppression grammar, so it silently suppresses " \
@@ -740,6 +743,7 @@ module Rigor
740
743
  does_not_fire_when: [
741
744
  "The marker is one of the two recognised forms (their tokens are then checked by " \
742
745
  "`suppression.unknown-rule` / `suppression.empty` instead).",
746
+ "The marker does not open the comment (documentation prose quoting the spelling).",
743
747
  "Non-token text follows the marker (documentation prose mentioning the spelling)."
744
748
  ],
745
749
  suppression: "Rewrite as `# rigor:disable <rules>` on the offending line (Rigor has no " \
@@ -35,7 +35,8 @@ module Rigor
35
35
  Diagnostic.new(
36
36
  path: diagnostic.path, line: diagnostic.line, column: diagnostic.column,
37
37
  message: diagnostic.message, severity: resolved, rule: diagnostic.rule,
38
- source_family: diagnostic.source_family
38
+ source_family: diagnostic.source_family, receiver_type: diagnostic.receiver_type,
39
+ method_name: diagnostic.method_name, project_definition_site: diagnostic.project_definition_site
39
40
  )
40
41
  end
41
42
  end
@@ -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,16 +611,11 @@ module Rigor
609
611
  elide_or_union(node.predicate, else_type, then_type)
610
612
  end
611
613
 
612
- # Issue #286 — the effective optimistic-nil-free cause of an expression, resolving a bare local read (or
613
- # a local write in value position) through its binding. Mirrors `StatementEvaluator#optimistic_origin_for`.
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.
614
617
  def optimistic_origin_for(node)
615
- recorded = scope.optimistic_origins[node]
616
- return recorded if recorded
617
-
618
- case node
619
- when Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode then scope.optimistic_local(node.name)
620
- when Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode then scope.optimistic_ivar(node.name)
621
- end
618
+ OptimisticOrigin.resolve(node, scope)
622
619
  end
623
620
 
624
621
  def if_else_type(subsequent)
@@ -663,7 +660,7 @@ module Rigor
663
660
  # fallback is preserved.
664
661
  def type_of_and_or(node)
665
662
  left_type = type_of(node.left)
666
- polarity = constant_value_polarity(left_type)
663
+ polarity = left_operand_polarity(node.left, left_type)
667
664
  return short_circuit_for(node, left_type, polarity) if polarity
668
665
 
669
666
  # The left operand only flows through on the edge that short-circuits: `a || b` yields `a` solely
@@ -689,6 +686,19 @@ module Rigor
689
686
  end
690
687
  end
691
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
+
692
702
  # Returns `:truthy` / `:falsey` for a `Type::Constant`, nil otherwise. Mirrors
693
703
  # `constant_predicate_polarity` but operates on a typed value (already-type-of'd) rather than a Prism
694
704
  # node, so the same predicate analysis can be reused in both contexts.
@@ -1092,8 +1102,12 @@ module Rigor
1092
1102
  # through `Enumerable#select` / `Object#select` and the caller observes `Array[Elem]` instead of the
1093
1103
  # helper's actual return type. The check fires only for `node.receiver.nil?` (true implicit self), so
1094
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.
1095
1109
  def try_local_def_dispatch(node, receiver, arg_types)
1096
- 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
1097
1111
  return nil unless local_def
1098
1112
 
1099
1113
  local_inference = infer_top_level_user_method(local_def, receiver, arg_types)
@@ -1159,7 +1173,7 @@ module Rigor
1159
1173
  # `module_function` body, re-type that body with the call args bound. Sits after the RBS dispatch
1160
1174
  # tier, so foreign / RBS-known singletons (`Math.sqrt`) keep their catalog answer; only
1161
1175
  # project-defined singleton methods reach here.
1162
- singleton_inference = try_singleton_method_inference(receiver, node, arg_types)
1176
+ singleton_inference = try_project_singleton_inference(receiver, node, arg_types)
1163
1177
  return singleton_inference if singleton_inference
1164
1178
 
1165
1179
  # Dynamic-origin propagation: when the receiver is Dynamic[T] and no positive rule resolves the call,
@@ -1336,6 +1350,31 @@ module Rigor
1336
1350
  nil
1337
1351
  end
1338
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
+
1339
1378
  # ADR-24 slice 2 — resolves `method_name` against `class_name`'s own `def`s, then walks the user-class
1340
1379
  # ancestor chain: included / prepended modules (transitive) and the superclass chain. RBS-known
1341
1380
  # ancestors are NOT walked here — the `MethodDispatcher` RBS tier runs before
@@ -2444,7 +2483,11 @@ module Rigor
2444
2483
  case block_arg
2445
2484
  when Prism::BlockNode
2446
2485
  bindings = BlockParameterBinder.new(expected_param_types: expected).bind(block_arg)
2447
- 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
2448
2491
  block_scope = block_scope.with_self_type(narrowed_self_type) if narrowed_self_type
2449
2492
  type_block_body(block_arg, block_scope)
2450
2493
  when Prism::BlockArgumentNode
@@ -37,6 +37,27 @@ module Rigor
37
37
  worker_count > 1 && Process.respond_to?(:fork)
38
38
  end
39
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
+
40
61
  # Forks `worker_count` children over contiguous slices, waits for each, and returns the Marshal'd
41
62
  # per-slice payloads in slice order. A child that exits abnormally has its slice re-run in-process.
42
63
  def fork_map(items, worker_count, block)
@@ -57,18 +78,31 @@ module Rigor
57
78
  # Child-process body: run the block over the slice, Marshal the result to `out_path`, and `exit!`
58
79
  # (skipping `at_exit` / stdio flush — the payload is durable on disk). Any failure exits non-zero so
59
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.
60
85
  def run_worker(slice, block, out_path)
61
- # Re-arm deferred YJIT in the child, exactly as the check fork pool does
62
- # ({Analysis::Runner::PoolCoordinator#run_fork_worker}). The parent's deadline thread does not survive
63
- # `fork`, so without this a worker runs its whole slice un-JITted while the parent JITs the tail it no
64
- # longer runs the pool was a *pessimization* on long runs (`coverage --protection --mutation
65
- # lib/rigor/analysis`: 37s sequential against 67s at eight workers). The child picks up what is left of
66
- # the parent's window rather than a fresh one; {Runtime::Jit.rearm_after_fork} carries the mechanism.
67
- Runtime::Jit.rearm_after_fork
68
- File.binwrite(out_path, Marshal.dump(block.call(slice)))
69
- exit!(0)
70
- rescue StandardError
71
- 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)
72
106
  end
73
107
 
74
108
  # Waits for every child, placing each successful payload at its slice index; a child that exited