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.
@@ -7,6 +7,7 @@ require_relative "../type"
7
7
  require_relative "../source/constant_path"
8
8
  require_relative "../source/node_children"
9
9
  require_relative "../cache/file_digest"
10
+ require_relative "anonymous_meta_class"
10
11
  require_relative "def_handle"
11
12
  require_relative "mutation_widening"
12
13
  require_relative "narrowing"
@@ -133,7 +134,7 @@ module Rigor
133
134
  # file_def_nodes]` so the caller can thread the def-node table into {#merge_project_method_indexes} without
134
135
  # walking the file a second time.
135
136
  def seed_discovered_methods(seeded_scope, default_scope, root)
136
- file_methods, file_def_nodes = build_methods_and_def_nodes(root)
137
+ file_methods, file_def_nodes = build_methods_and_def_nodes(root, default_scope.source_path)
137
138
  discovered_methods = deep_merge_class_methods(default_scope.discovered_methods, file_methods)
138
139
  scope = seeded_scope.with_discovery(seeded_scope.discovery.with(discovered_methods: discovered_methods))
139
140
  [scope, file_def_nodes]
@@ -161,7 +162,7 @@ module Rigor
161
162
  build_discovered_singleton_def_nodes(root)
162
163
  ) { |_class, cross_file, per_file| cross_file.merge(per_file) }
163
164
  superclasses = default_scope.discovered_superclasses.merge(
164
- build_discovered_superclasses(root)
165
+ build_discovered_superclasses(root, default_scope.source_path)
165
166
  )
166
167
  includes = default_scope.discovered_includes.merge(
167
168
  build_discovered_includes(root)
@@ -1266,10 +1267,10 @@ module Rigor
1266
1267
  # `walk_methods` and `walk_def_nodes` had byte-identical class / module / singleton / meta-block descents (both
1267
1268
  # stop at `DefNode`), so a single combined walk records both accumulators at once instead of traversing every file
1268
1269
  # twice.
1269
- def build_methods_and_def_nodes(root)
1270
+ def build_methods_and_def_nodes(root, source_path = nil)
1270
1271
  methods = {}
1271
1272
  def_nodes = {}
1272
- walk_methods_and_def_nodes(root, [], false, methods, def_nodes)
1273
+ walk_methods_and_def_nodes(root, [], false, methods, def_nodes, source_path)
1273
1274
  apply_alias_def_nodes(root, def_nodes)
1274
1275
  [methods.transform_values(&:freeze).freeze, def_nodes.transform_values(&:freeze).freeze]
1275
1276
  end
@@ -1315,7 +1316,8 @@ module Rigor
1315
1316
  # right accumulator) and the original `walk_methods` returning at `AliasMethodNode` (its symbol-only children
1316
1317
  # carry no def / class node, so not descending them is byte-identical for `def_nodes` too). See
1317
1318
  # {#build_methods_and_def_nodes}.
1318
- def walk_methods_and_def_nodes(node, qualified_prefix, in_singleton_class, methods_acc, def_nodes_acc)
1319
+ def walk_methods_and_def_nodes(node, qualified_prefix, in_singleton_class, methods_acc, def_nodes_acc,
1320
+ source_path = nil)
1319
1321
  return unless node.is_a?(Prism::Node)
1320
1322
 
1321
1323
  case node
@@ -1324,21 +1326,27 @@ module Rigor
1324
1326
  if name
1325
1327
  child_prefix = qualified_prefix + [name]
1326
1328
  record_meta_superclass_members(node, child_prefix, methods_acc) if node.is_a?(Prism::ClassNode)
1327
- walk_methods_and_def_nodes(node.body, child_prefix, false, methods_acc, def_nodes_acc) if node.body
1329
+ if node.body
1330
+ walk_methods_and_def_nodes(node.body, child_prefix, false, methods_acc, def_nodes_acc, source_path)
1331
+ end
1328
1332
  return
1329
1333
  end
1330
1334
  when Prism::SingletonClassNode
1331
1335
  if node.body
1332
1336
  singleton_prefix = singleton_class_prefix(node, qualified_prefix)
1333
1337
  if singleton_prefix
1334
- walk_methods_and_def_nodes(node.body, singleton_prefix, true, methods_acc, def_nodes_acc)
1338
+ walk_methods_and_def_nodes(node.body, singleton_prefix, true, methods_acc, def_nodes_acc, source_path)
1335
1339
  return
1336
1340
  end
1337
1341
  end
1338
1342
  when Prism::ConstantWriteNode
1339
1343
  if meta_new_block_body(node)
1340
1344
  child_prefix = qualified_prefix + [node.name.to_s]
1341
- walk_methods_and_def_nodes(meta_new_block_body(node), child_prefix, false, methods_acc, def_nodes_acc)
1345
+ walk_methods_and_def_nodes(meta_new_block_body(node), child_prefix, false, methods_acc, def_nodes_acc,
1346
+ source_path)
1347
+ # No anonymous registration here: the constant IS the name, and `StatementEvaluator` never routes a
1348
+ # constant-write rvalue through the block-body narrowing (its scope index still shows `self` as
1349
+ # `Dynamic[top]` inside such a body), so the two passes agree on the constant name alone.
1342
1350
  return
1343
1351
  end
1344
1352
  when Prism::DefNode
@@ -1349,14 +1357,43 @@ module Rigor
1349
1357
  record_alias_method(node, qualified_prefix, in_singleton_class, methods_acc)
1350
1358
  return
1351
1359
  when Prism::CallNode
1352
- record_define_method(node, qualified_prefix, in_singleton_class, methods_acc) if node.name == :define_method
1353
- if ATTR_MACROS.include?(node.name)
1354
- record_attr_methods(node, qualified_prefix, in_singleton_class, methods_acc)
1360
+ anonymous = record_call_node_methods(node, qualified_prefix, in_singleton_class, methods_acc, source_path)
1361
+ if anonymous
1362
+ walk_anonymous_meta_block(node, anonymous, qualified_prefix, in_singleton_class, methods_acc,
1363
+ def_nodes_acc, source_path)
1364
+ return
1355
1365
  end
1356
1366
  end
1357
1367
 
1358
1368
  node.rigor_each_child do |child|
1359
- walk_methods_and_def_nodes(child, qualified_prefix, in_singleton_class, methods_acc, def_nodes_acc)
1369
+ walk_methods_and_def_nodes(child, qualified_prefix, in_singleton_class, methods_acc, def_nodes_acc,
1370
+ source_path)
1371
+ end
1372
+ end
1373
+
1374
+ # The `Prism::CallNode` leaf actions of {#walk_methods_and_def_nodes}: the `define_method` / `attr_*` macro
1375
+ # recorders, plus the {AnonymousMetaClass} name of a class-creating meta call carrying a block (nil for
1376
+ # every other call), which the caller uses to decide whether the block body needs the anonymous-class-body
1377
+ # descent.
1378
+ def record_call_node_methods(node, qualified_prefix, in_singleton_class, methods_acc, source_path)
1379
+ record_define_method(node, qualified_prefix, in_singleton_class, methods_acc) if node.name == :define_method
1380
+ record_attr_methods(node, qualified_prefix, in_singleton_class, methods_acc) if ATTR_MACROS.include?(node.name)
1381
+ AnonymousMetaClass.name_for(node, source_path)
1382
+ end
1383
+
1384
+ # #319 — walks a `Class.new do ... end` / `Module.new do ... end` / `Struct.new(*sym) do ... end` /
1385
+ # `Data.define(*sym) do ... end` block body as the class body it is at runtime, keyed by the call site's
1386
+ # synthetic anonymous `name`; the call's other children (receiver, arguments) keep the enclosing prefix.
1387
+ def walk_anonymous_meta_block(call_node, name, qualified_prefix, in_singleton_class, methods_acc,
1388
+ def_nodes_acc, source_path)
1389
+ call_node.rigor_each_child do |child|
1390
+ if child.equal?(call_node.block)
1391
+ body = call_node.block.body
1392
+ walk_methods_and_def_nodes(body, [name], false, methods_acc, def_nodes_acc, source_path) if body
1393
+ else
1394
+ walk_methods_and_def_nodes(child, qualified_prefix, in_singleton_class, methods_acc, def_nodes_acc,
1395
+ source_path)
1396
+ end
1360
1397
  end
1361
1398
  end
1362
1399
 
@@ -1366,21 +1403,38 @@ module Rigor
1366
1403
  # (semantically `class << self`).
1367
1404
  # - `class << Foo` not nested in `class Foo` returns `[Foo]`
1368
1405
  # so methods defined inside register on Foo's singleton.
1406
+ # - `class << Foo = <expr>` (#320, the private-singleton-object
1407
+ # idiom) is the same case: Ruby evaluates the assignment, then
1408
+ # opens the singleton of the resulting object — which is the
1409
+ # object `Foo` now holds — so the body's methods are reachable
1410
+ # as `Foo.<name>` exactly as for a plain constant read.
1369
1411
  # - Any other expression (variable, method call) returns nil
1370
1412
  # so the walker falls through and skips the body.
1371
1413
  def singleton_class_prefix(node, qualified_prefix)
1372
- case node.expression
1373
- when Prism::SelfNode
1414
+ return qualified_prefix if node.expression.is_a?(Prism::SelfNode)
1415
+
1416
+ rendered = singleton_receiver_constant_name(node.expression)
1417
+ return nil unless rendered
1418
+
1419
+ if !qualified_prefix.empty? && qualified_prefix.last == rendered
1374
1420
  qualified_prefix
1375
- when Prism::ConstantReadNode, Prism::ConstantPathNode
1376
- rendered = Source::ConstantPath.qualified_name(node.expression)
1377
- return nil unless rendered
1421
+ else
1422
+ rendered.split("::")
1423
+ end
1424
+ end
1378
1425
 
1379
- if !qualified_prefix.empty? && qualified_prefix.last == rendered
1380
- qualified_prefix
1381
- else
1382
- rendered.split("::")
1383
- end
1426
+ # The constant a `class << X` operand names, or nil when the operand is not constant-shaped. Both the
1427
+ # read spellings (`Foo`, `A::Foo`, `::Foo`) and the two constant-*write* spellings (`Foo = expr`,
1428
+ # `A::Foo = expr`) resolve to the same unqualified rendering the read branch uses, so a body opened on
1429
+ # the assignment and one opened on a later plain read land on the same table key.
1430
+ def singleton_receiver_constant_name(expression)
1431
+ case expression
1432
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
1433
+ Source::ConstantPath.qualified_name(expression)
1434
+ when Prism::ConstantWriteNode
1435
+ expression.name.to_s
1436
+ when Prism::ConstantPathWriteNode
1437
+ Source::ConstantPath.qualified_name(expression.target)
1384
1438
  end
1385
1439
  end
1386
1440
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize
@@ -1483,6 +1537,24 @@ module Rigor
1483
1537
  class_name = qualified_prefix.empty? ? TOP_LEVEL_DEF_KEY : qualified_prefix.join("::")
1484
1538
  accumulator[class_name] ||= {}
1485
1539
  accumulator[class_name][def_node.name] = def_node
1540
+ record_anonymous_body_def_as_toplevel(def_node, qualified_prefix, accumulator)
1541
+ end
1542
+
1543
+ # #319 — a `def` inside an anonymous `Class.new` / `Module.new` body ALSO stays in the `<toplevel>` table.
1544
+ # Before the anonymous class had a name the body was walked with an empty prefix, so every such `def`
1545
+ # landed there; that is the same leniency this key already grants a `def` nested in any other DSL block
1546
+ # (see {TOP_LEVEL_DEF_KEY}), and it is what lets an implicit-self call elsewhere in the file resolve
1547
+ # against a method the anonymous module contributes to some other object's `self` — the
1548
+ # `Module.new { def start; end }` mixed into a spawned actor environment, then called from the sibling
1549
+ # `spawn(...) { start }` block. Giving the body a class of its own must not silently retract it: the call
1550
+ # resolves at runtime, and `call.unresolved-toplevel` firing on it would be a new false positive traded
1551
+ # for the ones this change retires. Never clobbers a real top-level `def` of the same name.
1552
+ def record_anonymous_body_def_as_toplevel(def_node, qualified_prefix, accumulator)
1553
+ return unless qualified_prefix.length == 1
1554
+ return unless Type::AnonymousClassName.match?(qualified_prefix.first)
1555
+
1556
+ table = (accumulator[TOP_LEVEL_DEF_KEY] ||= {})
1557
+ table[def_node.name] ||= def_node
1486
1558
  end
1487
1559
 
1488
1560
  # Module-singleton call resolution (ADR-57 follow-up) — the SINGLETON-side mirror of `build_discovered_def_nodes`.
@@ -1617,16 +1689,18 @@ module Rigor
1617
1689
  # `class Foo < Bar` declaration. Only constant superclasses are recorded (`class Foo < Struct.new(...)` and other
1618
1690
  # non-constant superclasses produce no entry). The as-written name is resolved to a qualified class at the call
1619
1691
  # site against the subclass's lexical nesting — see `ExpressionTyper#resolve_ancestor_class_name`.
1620
- def build_discovered_superclasses(root)
1692
+ def build_discovered_superclasses(root, source_path = nil)
1621
1693
  accumulator = {}
1622
- walk_class_superclasses(root, [], accumulator)
1694
+ walk_class_superclasses(root, [], accumulator, source_path)
1623
1695
  accumulator.freeze
1624
1696
  end
1625
1697
 
1626
- def walk_class_superclasses(node, qualified_prefix, accumulator)
1698
+ def walk_class_superclasses(node, qualified_prefix, accumulator, source_path = nil)
1627
1699
  return unless node.is_a?(Prism::Node)
1628
1700
 
1629
1701
  case node
1702
+ when Prism::CallNode
1703
+ record_anonymous_meta_superclass(node, accumulator, source_path)
1630
1704
  when Prism::ClassNode
1631
1705
  name = Source::ConstantPath.qualified_name(node.constant_path)
1632
1706
  if name
@@ -1645,10 +1719,27 @@ module Rigor
1645
1719
  end
1646
1720
 
1647
1721
  node.rigor_each_child do |child|
1648
- walk_class_superclasses(child, qualified_prefix, accumulator)
1722
+ walk_class_superclasses(child, qualified_prefix, accumulator, source_path)
1649
1723
  end
1650
1724
  end
1651
1725
 
1726
+ # #319 — `Class.new(Parent) do ... end` names its superclass in the first positional. Recording it under the
1727
+ # call site's anonymous name keeps `Parent`'s surface reachable from the block body (whose `self_type` is now
1728
+ # `Singleton[<anonymous>]`) and from an instance of the resulting class, so giving the anonymous class an
1729
+ # identity does not cost the inheritance the old `Singleton[Parent]` answer carried for free.
1730
+ def record_anonymous_meta_superclass(call_node, accumulator, source_path)
1731
+ return unless AnonymousMetaClass.block_form_receiver(call_node) == :Class
1732
+
1733
+ arg = call_node.arguments&.arguments&.first
1734
+ return if arg.nil?
1735
+
1736
+ superclass = Source::ConstantPath.qualified_name(arg)
1737
+ return if superclass.nil?
1738
+
1739
+ name = AnonymousMetaClass.name_for(call_node, source_path)
1740
+ accumulator[name] = superclass if name
1741
+ end
1742
+
1652
1743
  # ADR-48 — per qualified class name -> ordered `Data.define` member-name list, for both the named-subclass form
1653
1744
  # (`class Point < Data.define(:x, :y)`) and the constant-assigned form (`Point = Data.define(:x, :y)`). Only
1654
1745
  # `Data.define` is recorded: `Struct.new` instances are mutable, so member-value folding would be unsound (the
@@ -2511,7 +2602,7 @@ module Rigor
2511
2602
  # One combined descent yields both the methods existence table and the def-node table; the latter is also
2512
2603
  # consumed by `record_class_sources`, so a def-dense file is walked once here instead of three times (methods +
2513
2604
  # def-nodes ×2). See {#build_methods_and_def_nodes}.
2514
- file_methods, file_def_nodes = build_methods_and_def_nodes(root)
2605
+ file_methods, file_def_nodes = build_methods_and_def_nodes(root, path)
2515
2606
  merge_discovered_defs(acc[:def_nodes], acc[:def_sources], path, file_def_nodes)
2516
2607
  # ADR-46 slice 4 (singleton) — record the singleton-side `"path:line"` sources alongside the nodes,
2517
2608
  # the exact mirror of the instance-side `merge_discovered_defs`, so a class/singleton-method body edit
@@ -2519,7 +2610,7 @@ module Rigor
2519
2610
  # silently degrading to the file's full ancestry closure.
2520
2611
  merge_discovered_defs(acc[:singleton_def_nodes], acc[:singleton_def_sources], path,
2521
2612
  build_discovered_singleton_def_nodes(root))
2522
- superclasses = build_discovered_superclasses(root)
2613
+ superclasses = build_discovered_superclasses(root, path)
2523
2614
  includes = build_discovered_includes(root)
2524
2615
  acc[:superclasses].merge!(superclasses)
2525
2616
  includes.each do |class_name, mods|
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../source/constant_path"
4
+
5
+ module Rigor
6
+ module Inference
7
+ # Issue #320 — the *private-singleton-object* idiom: a constant holds a plain object whose singleton
8
+ # class carries the methods.
9
+ #
10
+ # class << Merger = Object.new
11
+ # def merge_attributes!(a, b) = a
12
+ # end
13
+ # Merger.merge_attributes!({}, {})
14
+ #
15
+ # {ScopeIndexer} already records such a body under the constant's own name with kind `:singleton` — that
16
+ # keying is correct for both spellings, since `Foo.bar` means "call `bar` on the value of constant `Foo`"
17
+ # whether the value is a class object or an ordinary object. The gap is on the *receiver* side: when the
18
+ # constant names a class or module the read types as `Singleton[Foo]` and dispatch recovers the name from
19
+ # the type, but when it holds an ordinary object the read types as `Nominal[Object]` and the name is
20
+ # gone — so every method in the body is invisible and `call.undefined-method` fires on working code.
21
+ #
22
+ # This module recovers the name from the receiver *syntax* instead, for exactly the receivers whose type
23
+ # has lost it. `Singleton` receivers are excluded so the established class/module path keeps its
24
+ # precedence; a receiver whose constant has no recorded singleton body yields nil, so an undefined method
25
+ # on such a constant (`Merger.nope`) still fires.
26
+ module SingletonObjectConstant
27
+ module_function
28
+
29
+ # The constant name a call's receiver names, when that receiver is a constant reference whose type is
30
+ # not already a `Singleton` carrier. Nil for every other receiver shape.
31
+ def receiver_constant_name(call_node, receiver_type)
32
+ return nil if receiver_type.is_a?(Type::Singleton)
33
+
34
+ receiver = call_node.receiver
35
+ return nil unless receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
36
+
37
+ Source::ConstantPath.qualified_name(receiver)
38
+ end
39
+
40
+ # True when the project recorded `method_name` on the singleton side of the constant this call's
41
+ # receiver names. The suppression probe for `call.undefined-method`.
42
+ def recorded?(call_node, receiver_type, method_name, scope)
43
+ return false if scope.nil?
44
+
45
+ name = receiver_constant_name(call_node, receiver_type)
46
+ return false if name.nil?
47
+
48
+ scope.discovered_method?(name, method_name, :singleton)
49
+ end
50
+
51
+ # The `Prism::DefNode` for `method_name` in the constant's singleton body, or nil. The inference tier's
52
+ # entry point.
53
+ def def_node_for(call_node, receiver_type, method_name, scope)
54
+ return nil if scope.nil?
55
+
56
+ name = receiver_constant_name(call_node, receiver_type)
57
+ return nil if name.nil?
58
+
59
+ scope.singleton_def_for(name, method_name)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -8,6 +8,7 @@ require_relative "../analysis/fact_store"
8
8
  require_relative "../source/node_walker"
9
9
  require_relative "../source/node_children"
10
10
  require_relative "../source/constant_path"
11
+ require_relative "anonymous_meta_class"
11
12
  require_relative "block_parameter_binder"
12
13
  require_relative "body_fixpoint"
13
14
  require_relative "dynamic_origin"
@@ -20,6 +21,7 @@ require_relative "method_parameter_binder"
20
21
  require_relative "multi_target_binder"
21
22
  require_relative "mutation_widening"
22
23
  require_relative "narrowing"
24
+ require_relative "optimistic_origin"
23
25
 
24
26
  module Rigor
25
27
  module Inference
@@ -259,17 +261,11 @@ module Rigor
259
261
  optimistic_origin_for(value_node, scope_after_rhs)
260
262
  end
261
263
 
262
- # The effective optimistic-nil-free cause of an expression: the mark on its own node, else — for a bare
263
- # local read or a local write used in value position (`if (x = MAP[k])`, where the write has already
264
- # bound the mark) the one propagated onto the binding.
264
+ # The effective optimistic-nil-free cause of an expression. {Inference::OptimisticOrigin.resolve} owns
265
+ # the judgment the mark on the node itself, the binding a bare local / ivar read resolves through, and
266
+ # the predicate-fold derivation of issue #313.
265
267
  def optimistic_origin_for(node, scope)
266
- recorded = scope.optimistic_origins[node]
267
- return recorded if recorded
268
-
269
- case node
270
- when Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode then scope.optimistic_local(node.name)
271
- when Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode then scope.optimistic_ivar(node.name)
272
- end
268
+ Inference::OptimisticOrigin.resolve(node, scope)
273
269
  end
274
270
 
275
271
  # ADR-82 WD1 — the {Inference::DynamicOrigin} cause to propagate onto a local / ivar being bound to `rhs`.
@@ -1827,7 +1823,20 @@ module Rigor
1827
1823
  return unless block.is_a?(Prism::BlockNode)
1828
1824
 
1829
1825
  block_entry = build_block_entry_scope(node, block)
1830
- sub_eval(block, block_entry)
1826
+ # #319 — `Class.new do ... end` and friends evaluate their block as a CLASS BODY (`class_eval`
1827
+ # semantics): `self` is the freshly created class, so a `def` inside defines an instance method on it
1828
+ # and `attr_reader` runs as a class-level macro. Enter the block under the same `self_type` /
1829
+ # class-context a `class Foo ... end` body gets, keyed by the call site's anonymous name — the name
1830
+ # `ScopeIndexer` registered the body's methods under. Without it the body inherits the enclosing
1831
+ # scope, and at file top level that means `Scope#toplevel?` (a nil `self_type`) reports every macro
1832
+ # call in the body as `call.unresolved-toplevel`.
1833
+ #
1834
+ # Outer locals stay visible: unlike a `class` keyword body, the block is a closure.
1835
+ anonymous = AnonymousMetaClass.name_for(node, scope.source_path)
1836
+ return sub_eval(block, block_entry) if anonymous.nil?
1837
+
1838
+ sub_eval(block, block_entry.with_self_type(Type::Combinator.singleton_of(anonymous)),
1839
+ class_context: [ClassFrame.new(name: anonymous, singleton: false)])
1831
1840
  end
1832
1841
 
1833
1842
  # Slice 6 phase C sub-phase 3b/3c. When the call carries a block whose receiving method is NOT proven
@@ -2504,7 +2513,11 @@ module Rigor
2504
2513
  def build_block_entry_scope(call_node, block_node)
2505
2514
  expected = expected_block_param_types_for(call_node)
2506
2515
  bindings = BlockParameterBinder.new(expected_param_types: expected).bind(block_node)
2507
- scope_with_params = bindings.reduce(scope) { |acc, (name, type)| acc.with_local(name, type) }
2516
+ # Issue #316 every block body enters with `self` unmodelled (`Scope#entering_opaque_block`); the
2517
+ # yielding method, not the lexical context, decides what `self` is, and Rigor does not track it.
2518
+ scope_with_params = bindings.reduce(scope.entering_opaque_block) do |acc, (name, type)|
2519
+ acc.with_local(name, type)
2520
+ end
2508
2521
  block_local_names(block_node).reduce(scope_with_params) do |acc, name|
2509
2522
  acc.with_local(name, Type::Combinator.constant_of(nil))
2510
2523
  end
@@ -92,6 +92,12 @@ module Rigor
92
92
  dispatch_plugins(node, ancestors, path, scope, states)
93
93
  collector_driver&.visit(node, context)
94
94
 
95
+ # Issue #318 — `defined?`'s operand is never evaluated, so nothing under a `Prism::DefinedNode` is
96
+ # reachable code. Dispatching plugin rules / the collector driver against the DefinedNode itself is
97
+ # fine; descending into `#value` would feed both plugin node_rules and the built-in collectors
98
+ # source that can never run.
99
+ return if node.is_a?(Prism::DefinedNode)
100
+
95
101
  child_context = collector_driver&.descend(node, context)
96
102
  ancestors.push(node)
97
103
  node.rigor_each_child do |child|
@@ -4,6 +4,7 @@ require "tempfile"
4
4
 
5
5
  require_relative "../analysis/buffer_binding"
6
6
  require_relative "../analysis/runner"
7
+ require_relative "../inference/fork_map"
7
8
  require_relative "diagnostic_oracle"
8
9
  require_relative "discovery_seed"
9
10
  require_relative "kill_signature"
@@ -111,6 +112,18 @@ module Rigor
111
112
  [path, *(@dependents[path] || [])]
112
113
  end
113
114
 
115
+ # Release the process-private mutant file now, instead of waiting for `Tempfile`'s finalizer at process
116
+ # exit. A one-shot `rigor coverage` run does not need this — the process ends and the file goes with it —
117
+ # but a caller that outlives the oracle does: the spec suite's `after(:suite)` residue check (issue #330)
118
+ # runs while the process is still alive, so an un-released file reads as a leak there even though it
119
+ # would have been reclaimed a moment later. Idempotent, and safe on an oracle that never mutated
120
+ # anything (the file is created lazily).
121
+ def release!
122
+ @mutant_file&.close!
123
+ @mutant_file = nil
124
+ @mutant_pid = nil
125
+ end
126
+
114
127
  private
115
128
 
116
129
  # The diagnostic signatures the dependents of `path` report while `source` stands in for it. Empty (and
@@ -136,11 +149,17 @@ module Rigor
136
149
 
137
150
  # The process-private mutant file. Created lazily, and re-created after a fork ({CLI::MutationForkScan}
138
151
  # workers must not share one path), which the pid guard detects.
152
+ #
153
+ # A worker's copy goes in {Inference::ForkMap.child_scratch_dir} rather than the system temp dir: the
154
+ # worker ends at `exit!`, which runs neither `at_exit` nor `Tempfile`'s finalizer, so a file placed
155
+ # anywhere else survives the run with nobody left who knows its name (issue #330). On the parent — and
156
+ # on the sequential path — that reader answers `nil`, which is `Tempfile.new`'s own default, and the
157
+ # finalizer reclaims the file at normal exit as before.
139
158
  def mutant_file
140
159
  return @mutant_file if @mutant_file && @mutant_pid == Process.pid
141
160
 
142
161
  @mutant_pid = Process.pid
143
- @mutant_file = Tempfile.new(["rigor-mutant-", ".rb"])
162
+ @mutant_file = Tempfile.new(["rigor-mutant-", ".rb"], Inference::ForkMap.child_scratch_dir)
144
163
  end
145
164
 
146
165
  def seed_for(buffer)
data/lib/rigor/scope.rb CHANGED
@@ -23,6 +23,7 @@ module Rigor
23
23
  :indexed_narrowings, :method_chain_narrowings,
24
24
  :declaration_sourced,
25
25
  :source_path, :discovery, :struct_fold_safe_locals,
26
+ :opaque_block_self,
26
27
  :dynamic_origins, :local_origins, :ivar_origins,
27
28
  :void_origins,
28
29
  :optimistic_origins, :optimistic_locals, :optimistic_ivars
@@ -147,6 +148,7 @@ module Rigor
147
148
  declaration_sourced: EMPTY_DECLARATION_SOURCED,
148
149
  source_path: nil,
149
150
  struct_fold_safe_locals: EMPTY_FOLD_SAFE,
151
+ opaque_block_self: false,
150
152
  dynamic_origins: {}.compare_by_identity,
151
153
  local_origins: EMPTY_ORIGINS,
152
154
  ivar_origins: EMPTY_ORIGINS,
@@ -168,6 +170,7 @@ module Rigor
168
170
  @declaration_sourced = declaration_sourced
169
171
  @source_path = source_path
170
172
  @struct_fold_safe_locals = struct_fold_safe_locals
173
+ @opaque_block_self = opaque_block_self
171
174
  @dynamic_origins = dynamic_origins
172
175
  @local_origins = local_origins
173
176
  @ivar_origins = ivar_origins
@@ -274,6 +277,21 @@ module Rigor
274
277
  rebuild(struct_fold_safe_locals: locals)
275
278
  end
276
279
 
280
+ # Issue #316 — marks a block body whose `self` Rigor does not model. Ruby gives a block no `self` of its
281
+ # own: the yielding method decides, and `instance_eval` / `instance_exec` (the mechanism behind every
282
+ # `self`-rebinding DSL — RSpec example groups, `Class.new { … }`, Rake, Sinatra) is indistinguishable from
283
+ # `Array#each` without knowing the callee. The flag is set at every block entry that leaves `self_type`
284
+ # unnarrowed and is inherited by every scope derived inside the block; it never leaks past the block,
285
+ # because `eval_call` returns the caller's scope unchanged.
286
+ def entering_opaque_block
287
+ return self if @opaque_block_self
288
+
289
+ rebuild(opaque_block_self: true)
290
+ end
291
+
292
+ # True when this scope sits inside a block whose `self` is unmodelled ({#entering_opaque_block}).
293
+ def opaque_block_self? = @opaque_block_self
294
+
277
295
  # True when `name`'s `Struct` member reads are fold-safe in this body (the local is provably never mutated /
278
296
  # aliased / escaped).
279
297
  def struct_fold_safe?(name)
@@ -352,8 +370,11 @@ module Rigor
352
370
  # whose type is an open-call-site *lower bound* — firing against a lower bound is a false positive by
353
371
  # construction (the ADR-67 WD1 reasoning at the parameter boundary, carried one hop into the body). The
354
372
  # distinct kind keeps the inferred-param sites separable from ADR-58's ivar-copy `:local` mark, which a
355
- # later un-guarding slice (WD6b) needs. `with_local` drops it on any flow-live rewrite of the local
356
- # (`drop_local_declaration_marks`), so only the pristine parameter binding carries it.
373
+ # later un-guarding slice (WD6b) needs and the two kinds behave OPPOSITELY on both axes: `:local` is
374
+ # dropped by `with_local` and intersected by `join`, while `:inferred_param` is sticky across `with_local`
375
+ # and unioned by `join`. See {#without_inferred_param_mark} below for the clearing contract, and
376
+ # `docs/internal-spec/inference-engine.md` § "Declaration-sourced provenance mark (ADR-58)" for the
377
+ # normative statement of both.
357
378
  def with_inferred_param_mark(name)
358
379
  rebuild(declaration_sourced: add_declaration_sourced(:inferred_param, name))
359
380
  end
@@ -505,6 +526,43 @@ module Rigor
505
526
  Inference::DefNodeResolver.resolve(entry)
506
527
  end
507
528
 
529
+ # Issue #316 — the CONFIDENCE-GATED companion of {#top_level_def_for}, and the only accessor the type
530
+ # inference may bind through. {#top_level_def_for} stays unrestricted because it also serves the
531
+ # *suppression* side (`call.unresolved-toplevel`, `call.undefined-method`): a name the project defines at
532
+ # the top level must never be reported as unresolved, whatever this gate decides.
533
+ #
534
+ # Returns nil — decline to bind, stay silent — when BOTH hold:
535
+ #
536
+ # 1. The call site sits inside a block whose `self` is unmodelled ({#opaque_block_self?}) and no narrowed
537
+ # `self_type` says otherwise. A top-level `def` is a private method on `Object`, so it is *callable*
538
+ # from any `self`; what the analyzer cannot see is whether the block's real `self` gained a PUBLIC
539
+ # same-named method by `include` / `extend`, which wins the MRO over the private `Object` def. RSpec's
540
+ # `output` / `include` / `match` matchers against a project's own `def output` are exactly this.
541
+ # 2. The `def` lives in a DIFFERENT file from the call site. Collocation is the evidence that the two
542
+ # belong to one lexical structure — the `RSpec.describe do; def helper; end; it { helper } end` case
543
+ # v0.0.3 A and #319 deliberately serve. Cross-file, the two share only a name.
544
+ #
545
+ # Both conditions are required, so a top-level helper called from genuine top-level code keeps resolving
546
+ # (cross-file included), and a helper defined beside its DSL-block call site keeps resolving too. When the
547
+ # project pre-pass recorded no source for the name, the file test cannot be answered and the historical
548
+ # bind is kept.
549
+ def bindable_top_level_def_for(method_name)
550
+ node = top_level_def_for(method_name)
551
+ return node if node.nil?
552
+ return node unless @opaque_block_self && @self_type.nil?
553
+
554
+ same_file_top_level_def?(method_name) ? node : nil
555
+ end
556
+
557
+ def same_file_top_level_def?(method_name)
558
+ key = Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY
559
+ site = discovered_def_sources.dig(key, method_name.to_sym)
560
+ return true if site.nil? || @source_path.nil?
561
+
562
+ File.expand_path(site.sub(/:\d+\z/, "")) == File.expand_path(@source_path)
563
+ end
564
+ private :same_file_top_level_def?
565
+
508
566
  # ADR-46 slice 3 — a top-level (`def helper` outside any class) call has NO class ancestry to walk, so unlike
509
567
  # {#user_def_for} a miss here records no positive ancestry edge that would re-check the consumer when the
510
568
  # method later appears. Record the cross-file edge explicitly: the file defining the top-level method
@@ -756,6 +814,7 @@ module Rigor
756
814
  declaration_sourced: @declaration_sourced,
757
815
  source_path: @source_path,
758
816
  struct_fold_safe_locals: @struct_fold_safe_locals,
817
+ opaque_block_self: @opaque_block_self,
759
818
  dynamic_origins: @dynamic_origins,
760
819
  local_origins: @local_origins,
761
820
  ivar_origins: @ivar_origins,
@@ -774,6 +833,7 @@ module Rigor
774
833
  declaration_sourced: declaration_sourced,
775
834
  source_path: source_path,
776
835
  struct_fold_safe_locals: struct_fold_safe_locals,
836
+ opaque_block_self: opaque_block_self,
777
837
  dynamic_origins: dynamic_origins,
778
838
  local_origins: local_origins,
779
839
  ivar_origins: ivar_origins,
@@ -14,6 +14,14 @@ module Rigor
14
14
  #
15
15
  # Non-Prism children (literals embedded in node attributes, virtual nodes, or `nil` slots) are silently
16
16
  # skipped so callers can rely on every yielded value responding to the `Prism::Node` API.
17
+ #
18
+ # Issue #318 — a `Prism::DefinedNode`'s operand is never evaluated at runtime (`defined?` inspects the
19
+ # expression statically; it does not run it), so the walk yields the `DefinedNode` itself but does NOT
20
+ # descend into its `value` subtree. Every consumer of this walker treats a yielded node as "reachable,
21
+ # evaluated code" (mutation/break/return scans, the check-rules main-pass oracle, coverage and precision
22
+ # probes); walking into the operand would make them reason about code that can never run, which is
23
+ # exactly the false-positive class the issue reports (`defined?(@x) && ...` flagging a call that is
24
+ # actually inert).
17
25
  module NodeWalker
18
26
  module_function
19
27
 
@@ -30,6 +38,8 @@ module Rigor
30
38
  return unless node.is_a?(Prism::Node)
31
39
 
32
40
  yield node
41
+ return if node.is_a?(Prism::DefinedNode)
42
+
33
43
  node.rigor_each_child { |child| walk(child, &) }
34
44
  end
35
45
 
@@ -52,6 +62,8 @@ module Rigor
52
62
  return unless node.is_a?(Prism::Node)
53
63
 
54
64
  block.call(node, ancestors)
65
+ return if node.is_a?(Prism::DefinedNode)
66
+
55
67
  ancestors.push(node)
56
68
  node.rigor_each_child { |child| walk_with_ancestors(child, ancestors, &block) }
57
69
  ancestors.pop
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Type
5
+ # #319 — the spelling of a class that Ruby created without a name: the `Class.new do ... end` /
6
+ # `Module.new do ... end` form away from constant-write position, which owns whatever its block body defines
7
+ # but has no constant to be keyed by.
8
+ #
9
+ # `Inference::AnonymousMetaClass` decides WHICH call sites get one and what goes in the key; this module owns
10
+ # the spelling, because the two places a name reaches a human live here. `#<Label:key>` is deliberately
11
+ # unspellable as a Ruby constant path, so a synthetic name can never collide with a real class in the
12
+ # discovery tables.
13
+ #
14
+ # Both renderings drop the key. `describe` keeps only the label (`#<Class>`) because the key is a file
15
+ # position — reproducing it would pin every `assert_type` fixture and precision snapshot to a line number.
16
+ # `erase_to_rbs` answers `untyped`: the name is not valid RBS, and emitting it from `rigor sig-gen` would
17
+ # produce a signature file that does not parse.
18
+ module AnonymousClassName
19
+ module_function
20
+
21
+ PREFIX = "#<"
22
+
23
+ # `build("Class", "lib/a.rb:3:17") #=> "#<Class:lib/a.rb:3:17>"`
24
+ def build(label, key)
25
+ "#{PREFIX}#{label}:#{key}>"
26
+ end
27
+
28
+ def match?(class_name)
29
+ class_name.is_a?(String) && class_name.start_with?(PREFIX)
30
+ end
31
+
32
+ # The display form: the label alone, key dropped.
33
+ def display(class_name)
34
+ return class_name unless match?(class_name)
35
+
36
+ "#{PREFIX}#{class_name.delete_prefix(PREFIX).split(':', 2).first}>"
37
+ end
38
+ end
39
+ end
40
+ end