rigortype 0.1.16 → 0.1.17

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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +100 -0
  3. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +209 -0
  4. data/lib/rigor/analysis/check_rules.rb +149 -70
  5. data/lib/rigor/analysis/dependency_recorder.rb +122 -0
  6. data/lib/rigor/analysis/diagnostic.rb +18 -0
  7. data/lib/rigor/analysis/incremental.rb +162 -0
  8. data/lib/rigor/analysis/incremental_session.rb +337 -0
  9. data/lib/rigor/analysis/rule_catalog.rb +48 -0
  10. data/lib/rigor/analysis/runner.rb +434 -37
  11. data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
  12. data/lib/rigor/builtins/static_return_refinements.rb +7 -1
  13. data/lib/rigor/cache/descriptor.rb +50 -49
  14. data/lib/rigor/cache/incremental_snapshot.rb +147 -0
  15. data/lib/rigor/cache/rbs_cache_producer.rb +30 -0
  16. data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
  17. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
  18. data/lib/rigor/cache/rbs_constant_table.rb +2 -8
  19. data/lib/rigor/cache/rbs_environment.rb +2 -8
  20. data/lib/rigor/cache/rbs_instance_definitions.rb +3 -16
  21. data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
  22. data/lib/rigor/cache/store.rb +99 -1
  23. data/lib/rigor/cli/annotate_command.rb +2 -7
  24. data/lib/rigor/cli/baseline_command.rb +2 -7
  25. data/lib/rigor/cli/command.rb +47 -0
  26. data/lib/rigor/cli/coverage_command.rb +3 -23
  27. data/lib/rigor/cli/coverage_renderer.rb +3 -8
  28. data/lib/rigor/cli/diff_command.rb +3 -7
  29. data/lib/rigor/cli/explain_command.rb +2 -7
  30. data/lib/rigor/cli/lsp_command.rb +3 -7
  31. data/lib/rigor/cli/mcp_command.rb +3 -7
  32. data/lib/rigor/cli/options.rb +57 -0
  33. data/lib/rigor/cli/plugin_command.rb +3 -7
  34. data/lib/rigor/cli/plugins_command.rb +2 -7
  35. data/lib/rigor/cli/renderable.rb +26 -0
  36. data/lib/rigor/cli/sig_gen_command.rb +2 -7
  37. data/lib/rigor/cli/skill_command.rb +3 -7
  38. data/lib/rigor/cli/triage_command.rb +2 -7
  39. data/lib/rigor/cli/type_of_command.rb +5 -38
  40. data/lib/rigor/cli/type_of_renderer.rb +4 -9
  41. data/lib/rigor/cli/type_scan_command.rb +3 -23
  42. data/lib/rigor/cli/type_scan_renderer.rb +4 -9
  43. data/lib/rigor/cli.rb +125 -43
  44. data/lib/rigor/configuration/dependencies.rb +18 -1
  45. data/lib/rigor/configuration/severity_profile.rb +22 -3
  46. data/lib/rigor/configuration.rb +13 -3
  47. data/lib/rigor/environment/rbs_loader.rb +76 -3
  48. data/lib/rigor/inference/block_parameter_binder.rb +1 -2
  49. data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
  50. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
  51. data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
  52. data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
  53. data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
  54. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
  55. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
  56. data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
  57. data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
  58. data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
  59. data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
  60. data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
  61. data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
  62. data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
  63. data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
  64. data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
  65. data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
  66. data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
  67. data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
  68. data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
  69. data/lib/rigor/inference/expression_typer.rb +140 -20
  70. data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
  71. data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
  72. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
  73. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
  74. data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
  75. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
  76. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
  77. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
  78. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
  79. data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
  80. data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
  81. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +23 -13
  82. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
  83. data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
  84. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
  85. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
  86. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
  87. data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
  88. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
  89. data/lib/rigor/inference/method_dispatcher.rb +99 -59
  90. data/lib/rigor/inference/narrowing.rb +202 -5
  91. data/lib/rigor/inference/scope_indexer.rb +134 -7
  92. data/lib/rigor/inference/statement_evaluator.rb +105 -26
  93. data/lib/rigor/language_server/buffer_resolution.rb +33 -0
  94. data/lib/rigor/language_server/completion_provider.rb +4 -4
  95. data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
  96. data/lib/rigor/language_server/folding_range_provider.rb +4 -4
  97. data/lib/rigor/language_server/hover_provider.rb +4 -4
  98. data/lib/rigor/language_server/selection_range_provider.rb +4 -4
  99. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  100. data/lib/rigor/plugin/base.rb +20 -4
  101. data/lib/rigor/plugin/registry.rb +39 -1
  102. data/lib/rigor/rbs_extended/conformance_checker.rb +208 -0
  103. data/lib/rigor/rbs_extended.rb +39 -0
  104. data/lib/rigor/scope.rb +123 -9
  105. data/lib/rigor/type/acceptance_router.rb +19 -0
  106. data/lib/rigor/type/accepts_result.rb +3 -10
  107. data/lib/rigor/type/app.rb +3 -7
  108. data/lib/rigor/type/bot.rb +2 -3
  109. data/lib/rigor/type/bound_method.rb +5 -12
  110. data/lib/rigor/type/combinator.rb +17 -0
  111. data/lib/rigor/type/constant.rb +2 -3
  112. data/lib/rigor/type/data_class.rb +80 -0
  113. data/lib/rigor/type/data_instance.rb +100 -0
  114. data/lib/rigor/type/difference.rb +5 -10
  115. data/lib/rigor/type/dynamic.rb +5 -10
  116. data/lib/rigor/type/hash_shape.rb +5 -15
  117. data/lib/rigor/type/integer_range.rb +5 -10
  118. data/lib/rigor/type/intersection.rb +5 -10
  119. data/lib/rigor/type/nominal.rb +5 -10
  120. data/lib/rigor/type/refined.rb +5 -10
  121. data/lib/rigor/type/singleton.rb +5 -10
  122. data/lib/rigor/type/top.rb +2 -3
  123. data/lib/rigor/type/tuple.rb +5 -10
  124. data/lib/rigor/type/union.rb +5 -10
  125. data/lib/rigor/type.rb +2 -0
  126. data/lib/rigor/value_semantics.rb +77 -0
  127. data/lib/rigor/version.rb +1 -1
  128. data/lib/rigor.rb +1 -0
  129. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +12 -2
  130. data/sig/rigor/cache.rbs +19 -0
  131. data/sig/rigor/inference.rbs +22 -0
  132. data/sig/rigor/rbs_extended.rbs +2 -0
  133. data/sig/rigor/scope.rbs +5 -0
  134. data/sig/rigor/type.rbs +58 -1
  135. data/sig/rigor.rbs +6 -1
  136. metadata +22 -1
@@ -4,6 +4,7 @@ require "prism"
4
4
 
5
5
  require_relative "../type"
6
6
  require_relative "../ast"
7
+ require_relative "../analysis/self_call_resolution_recorder"
7
8
  require_relative "block_parameter_binder"
8
9
  require_relative "budget_trace"
9
10
  require_relative "fallback"
@@ -111,7 +112,7 @@ module Rigor
111
112
  Prism::GlobalVariableOperatorWriteNode => :type_of_assignment_write,
112
113
  Prism::GlobalVariableOrWriteNode => :type_of_assignment_write,
113
114
  Prism::GlobalVariableAndWriteNode => :type_of_assignment_write,
114
- # Compound writes that share the .value rvalue protocol
115
+ # Compound writes that share the `.value` rvalue accessor
115
116
  Prism::LocalVariableOperatorWriteNode => :type_of_assignment_write,
116
117
  Prism::LocalVariableOrWriteNode => :type_of_assignment_write,
117
118
  Prism::LocalVariableAndWriteNode => :type_of_assignment_write,
@@ -443,8 +444,12 @@ module Rigor
443
444
  candidates = []
444
445
  while prefix && !prefix.empty?
445
446
  candidates << "#{prefix}::#{name}"
446
- prefix = prefix.rpartition("::").first
447
- prefix = nil if prefix.empty?
447
+ # Strip the last `::` segment without `rpartition`'s throwaway
448
+ # 3-element array + extra substrings (this loop is the sole
449
+ # caller of the `String#rpartition` allocation seen in the
450
+ # profile): `rindex` + slice gives the same prefix, or nil.
451
+ idx = prefix.rindex("::")
452
+ prefix = idx ? prefix[0, idx] : nil
448
453
  end
449
454
  candidates << name
450
455
  candidates
@@ -1260,9 +1265,64 @@ module Rigor
1260
1265
  # MUST NOT record a tracer event.
1261
1266
  return dynamic_top if receiver.is_a?(Type::Dynamic)
1262
1267
 
1268
+ # ADR-24 slice 4a — this is the engine choke-point where an
1269
+ # implicit-self call has exhausted every resolution tier (RBS
1270
+ # dispatch + user-class ancestor walk) and falls through to
1271
+ # `Dynamic[top]`. When the slice-4 recorder is active, capture the
1272
+ # miss so a later slice's closed-class gate can flag it. Off by
1273
+ # default: `active?` is a plain integer read.
1274
+ record_unresolved_self_call(node, receiver) if Analysis::SelfCallResolutionRecorder.active?
1275
+
1263
1276
  fallback_for(node, family: :prism)
1264
1277
  end
1265
1278
 
1279
+ # ADR-24 slice 4a — records an unresolved *implicit-self* call (no
1280
+ # explicit receiver) whose `self` types to a concrete user class.
1281
+ # Explicit-receiver misses are out of scope (the existing
1282
+ # `call.undefined-method` rule already owns receiver-typed dispatch);
1283
+ # a non-`Nominal` self (top-level / DSL-block `self`, or a `Dynamic`
1284
+ # self) is skipped so the gradual guarantee is never touched here.
1285
+ def record_unresolved_self_call(node, receiver)
1286
+ return unless node.receiver.nil?
1287
+ return unless receiver.is_a?(Type::Nominal)
1288
+ return if self_call_method_known?(receiver.class_name, node.name)
1289
+
1290
+ location = node.message_loc || node.location
1291
+ Analysis::SelfCallResolutionRecorder.record(
1292
+ class_name: receiver.class_name,
1293
+ method_name: node.name,
1294
+ node: node,
1295
+ path: scope.source_path,
1296
+ line: location&.start_line,
1297
+ column: location ? location.start_column + 1 : nil
1298
+ )
1299
+ end
1300
+
1301
+ # The recorder must capture *existence* misses, not type misses.
1302
+ # Reaching the choke-point means RBS dispatch produced no result, but
1303
+ # a project method can still EXIST without an inferable return type —
1304
+ # a `module_function` sibling whose body the engine can't fully type,
1305
+ # an `attr_reader` / `define_method` / `Data.define` member. Recording
1306
+ # those would reproduce the 135 false positives of slice-4 attempt 1.
1307
+ # So skip any name the engine's own existence signals already know:
1308
+ # a `def` resolvable through the ancestor walk, or an own-class entry
1309
+ # in the discovered-methods table (`def` / `attr_*` / `define_method`
1310
+ # / alias). This reuses the engine's real resolution — the
1311
+ # "collect, don't recompute" lesson — so only a name that exists
1312
+ # nowhere a project signal can see reaches the recorder.
1313
+ # `module_function` records its defs as `:singleton` (an implicit-self
1314
+ # call inside such a method dispatches to the module's singleton
1315
+ # method), while ordinary instance methods record `:instance`. The
1316
+ # recorder cannot tell the two contexts apart from the call node, so
1317
+ # existence under EITHER kind suppresses recording — the FP-safe
1318
+ # choice, since either means the method genuinely exists.
1319
+ def self_call_method_known?(class_name, method_name)
1320
+ return true if resolve_user_def_through_ancestors(class_name, method_name)
1321
+
1322
+ scope.discovered_method?(class_name, method_name, :instance) ||
1323
+ scope.discovered_method?(class_name, method_name, :singleton)
1324
+ end
1325
+
1266
1326
  # v0.0.2 #5 — re-types the body of a user-defined
1267
1327
  # instance method with the call site's argument types
1268
1328
  # bound to the method's parameters. Used as a
@@ -1349,7 +1409,44 @@ module Rigor
1349
1409
  ANCESTOR_WALK_LIMIT = 100
1350
1410
  private_constant :ANCESTOR_WALK_LIMIT
1351
1411
 
1412
+ CLASS_GRAPH_CACHE_KEY = :__rigor_class_graph_cache__
1413
+ private_constant :CLASS_GRAPH_CACHE_KEY
1414
+
1415
+ # Run-scoped memo for the static class-graph resolvers below. They
1416
+ # are pure functions of the *frozen* project index trio
1417
+ # (`discovered_def_nodes` / `discovered_superclasses` /
1418
+ # `discovered_includes`) — `user_def_for` / `superclass_of` /
1419
+ # `includes_of` read nothing else, and never touch the current
1420
+ # scope's locals or narrowings — so a result computed for one
1421
+ # `(class, method)` is valid for every `Scope` that shares those
1422
+ # tables. `ExpressionTyper` is rebuilt per `Scope#type_of`, so the
1423
+ # memo lives on `Thread.current` rather than on `self`. It is keyed
1424
+ # by the *identity* of the three frozen tables (nested
1425
+ # `compare_by_identity` stores): a new analysis generation, or any
1426
+ # `Scope` that swaps an index via `with_discovered_*`, transparently
1427
+ # lands in a fresh bucket while everything sharing the tables shares
1428
+ # the memo. Steady-state cost is three identity-keyed hash reads and
1429
+ # zero allocation — the `||=` chains only allocate on the first miss
1430
+ # of a generation. (Pool mode forks per worker, so the
1431
+ # `Thread.current` store is process-local and never crosses a
1432
+ # project boundary.)
1433
+ def class_graph_buckets
1434
+ store = (Thread.current[CLASS_GRAPH_CACHE_KEY] ||= {}.compare_by_identity)
1435
+ by_def = (store[scope.discovered_def_nodes] ||= {}.compare_by_identity)
1436
+ by_super = (by_def[scope.discovered_superclasses] ||= {}.compare_by_identity)
1437
+ by_super[scope.discovered_includes] ||= { name: {}, user_def: {} }
1438
+ end
1439
+
1352
1440
  def resolve_user_def_through_ancestors(class_name, method_name)
1441
+ cache = class_graph_buckets[:user_def]
1442
+ table = (cache[class_name.to_s] ||= {})
1443
+ key = method_name.to_sym
1444
+ return table[key] if table.key?(key)
1445
+
1446
+ table[key] = compute_user_def_through_ancestors(class_name, method_name)
1447
+ end
1448
+
1449
+ def compute_user_def_through_ancestors(class_name, method_name)
1353
1450
  queue = [class_name.to_s]
1354
1451
  seen = {}
1355
1452
  visited = 0
@@ -1398,6 +1495,14 @@ module Rigor
1398
1495
  # no candidate names a discovered user class (e.g. the
1399
1496
  # superclass is an RBS-known or third-party class).
1400
1497
  def resolve_ancestor_class_name(subclass_qualified, raw_superclass)
1498
+ by_subclass = (class_graph_buckets[:name][subclass_qualified] ||= {})
1499
+ return by_subclass[raw_superclass] if by_subclass.key?(raw_superclass)
1500
+
1501
+ by_subclass[raw_superclass] =
1502
+ compute_ancestor_class_name(subclass_qualified, raw_superclass)
1503
+ end
1504
+
1505
+ def compute_ancestor_class_name(subclass_qualified, raw_superclass)
1401
1506
  segments = subclass_qualified.split("::")
1402
1507
  (segments.length - 1).downto(0) do |i|
1403
1508
  candidate = (segments[0, i] + [raw_superclass]).join("::")
@@ -1466,23 +1571,38 @@ module Rigor
1466
1571
  return nil unless params.nil? || user_method_param_shape_simple?(params)
1467
1572
  return nil unless required.size == arg_types.size
1468
1573
 
1469
- fresh = Scope.empty(environment: scope.environment)
1470
- .with_declared_types(scope.declared_types)
1471
- .with_discovered_classes(scope.discovered_classes)
1472
- .with_in_source_constants(scope.in_source_constants)
1473
- .with_class_ivars(scope.class_ivars)
1474
- .with_class_cvars(scope.class_cvars)
1475
- .with_program_globals(scope.program_globals)
1476
- .with_discovered_methods(scope.discovered_methods)
1477
- .with_discovered_def_nodes(scope.discovered_def_nodes)
1478
- .with_discovered_superclasses(scope.discovered_superclasses)
1479
- .with_discovered_includes(scope.discovered_includes)
1480
- .with_self_type(receiver)
1481
-
1482
- required.each_with_index do |param, index|
1483
- fresh = fresh.with_local(param.name, arg_types[index])
1484
- end
1485
- fresh
1574
+ # Bind required positionals by index. The body scope starts from an
1575
+ # empty fact store and narrowing set, so `with_local`'s fact /
1576
+ # narrowing invalidations would be no-ops here — build the locals
1577
+ # table directly (matching `with_local`'s `name.to_sym` key).
1578
+ locals = {}
1579
+ required.each_with_index { |param, index| locals[param.name.to_sym] = arg_types[index] }
1580
+
1581
+ # Construct the body scope in a SINGLE allocation. The previous
1582
+ # `Scope.empty.with_*.with_*…` chain allocated a fresh frozen Scope
1583
+ # per field — ~12 throwaway Scopes to build one body scope, run per
1584
+ # user-method-call inference, which made these `with_*` the dominant
1585
+ # `Scope#rebuild` source. Each field here is a plain inherited
1586
+ # reference (the project-wide indexes + self_type); every unset
1587
+ # field defaults to the same empty binding the old chain left it at,
1588
+ # so the result is identical (ADR-44).
1589
+ Scope.new(
1590
+ environment: scope.environment,
1591
+ locals: locals.freeze,
1592
+ self_type: receiver,
1593
+ declared_types: scope.declared_types,
1594
+ discovered_classes: scope.discovered_classes,
1595
+ in_source_constants: scope.in_source_constants,
1596
+ class_ivars: scope.class_ivars,
1597
+ class_cvars: scope.class_cvars,
1598
+ program_globals: scope.program_globals,
1599
+ discovered_methods: scope.discovered_methods,
1600
+ discovered_def_nodes: scope.discovered_def_nodes,
1601
+ discovered_def_sources: scope.discovered_def_sources,
1602
+ discovered_superclasses: scope.discovered_superclasses,
1603
+ discovered_includes: scope.discovered_includes,
1604
+ discovered_class_sources: scope.discovered_class_sources
1605
+ )
1486
1606
  end
1487
1607
 
1488
1608
  # First iteration accepts only required positional
@@ -69,7 +69,11 @@ module Rigor
69
69
  # the call's block. `nil` means "no block at the call site"
70
70
  # and disqualifies every rule here.
71
71
  # @return [Rigor::Type, nil]
72
- def try_fold(receiver:, method_name:, args:, block_type:)
72
+ def try_dispatch(context)
73
+ receiver = context.receiver
74
+ method_name = context.method_name
75
+ args = context.args
76
+ block_type = context.block_type
73
77
  return nil if receiver.nil? || block_type.nil?
74
78
 
75
79
  truthiness = constant_truthiness(block_type)
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Inference
5
+ module MethodDispatcher
6
+ # Immutable value object carrying everything a dispatch tier needs
7
+ # to fold a single call site. Built once per `MethodDispatcher.dispatch`
8
+ # and threaded — unchanged — through every tier, replacing the
9
+ # `(receiver:, method_name:, args:, …)` keyword quartet that each
10
+ # tier used to redeclare (several with `# rubocop:disable
11
+ # Metrics/ParameterLists`).
12
+ #
13
+ # Every tier satisfies one interface — `try_dispatch(CallContext) ->
14
+ # Rigor::Type?` (the `_DispatchTier` RBS interface). Pure tiers
15
+ # (the singleton folders, ConstantFolding, ShapeDispatch, …) read
16
+ # only `receiver` / `method_name` / `args` and ignore the rest;
17
+ # the RBS / backward / block-param tiers consult the wider context.
18
+ #
19
+ # Derived call sites (the user-class fallback's `public_only` RBS
20
+ # retry, the Tier-B origin-module redispatch) use `Data#with` to
21
+ # copy the base context with the few fields that differ rather than
22
+ # rebuilding the quartet by hand.
23
+ #
24
+ # Fields:
25
+ # - `receiver` — the receiver `Rigor::Type` (nil short-circuits)
26
+ # - `method_name` — the called selector (Symbol)
27
+ # - `args` — positional argument `Rigor::Type`s
28
+ # - `block_type` — the block's `Rigor::Type`, or nil
29
+ # - `environment` — the analysis `Environment` (RBS loader, …)
30
+ # - `call_node` — the Prism call node, when available
31
+ # - `scope` — the enclosing `Scope` (discovered methods, …)
32
+ # - `self_type_override` — receiver to attribute private dispatch to
33
+ # - `public_only` — suppress private-method resolution (explicit, non-self receiver)
34
+ CallContext = Data.define(
35
+ :receiver, :method_name, :args,
36
+ :block_type, :environment, :call_node, :scope,
37
+ :self_type_override, :public_only
38
+ ) do
39
+ # Keyword factory with nil/false defaults for the optional
40
+ # context fields, so a caller that only has the call quartet
41
+ # (the common precise-tier path) need not spell out the rest.
42
+ #
43
+ # This is the single place the call-context field list is
44
+ # enumerated — the whole point of the value object is to absorb
45
+ # the wide keyword list the tiers used to each redeclare. The
46
+ # ParameterLists disable here retires the per-tier disables (the
47
+ # `RbsDispatch` quartet-plus signatures) rather than adding to
48
+ # them.
49
+ def self.build(receiver:, method_name:, args:, # rubocop:disable Metrics/ParameterLists
50
+ block_type: nil, environment: nil, call_node: nil,
51
+ scope: nil, self_type_override: nil, public_only: false)
52
+ # Positional `new` (field-definition order) avoids the keyword
53
+ # hash a `new(receiver:, …)` call allocates — this runs once per
54
+ # dispatch and was a top allocation site. Order MUST track the
55
+ # `Data.define` field list above.
56
+ new(
57
+ receiver, method_name, args,
58
+ block_type, environment, call_node, scope,
59
+ self_type_override, public_only
60
+ )
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "cgi/util"
4
4
  require_relative "../../type"
5
+ require_relative "singleton_folding"
5
6
 
6
7
  module Rigor
7
8
  module Inference
@@ -63,22 +64,21 @@ module Rigor
63
64
  module_function
64
65
 
65
66
  # @return [Rigor::Type, nil] folded result, or nil to defer.
66
- def try_dispatch(receiver:, method_name:, args:)
67
- return nil unless dispatch_target?(receiver)
67
+ def try_dispatch(context)
68
+ receiver = context.receiver
69
+ method_name = context.method_name
70
+ args = context.args
71
+ return nil unless SingletonFolding.receiver?(receiver, "CGI")
68
72
  return nil unless CGI_ALL_ESCAPE_METHODS.include?(method_name)
69
73
 
70
74
  fold_cgi_call(method_name, args)
71
75
  end
72
76
 
73
- def dispatch_target?(receiver)
74
- receiver.is_a?(Type::Singleton) && receiver.class_name == "CGI"
75
- end
76
-
77
77
  def fold_cgi_call(method_name, args)
78
78
  return nil if args.empty?
79
- return nil unless args.first.is_a?(Type::Constant) && args.first.value.is_a?(String)
80
79
 
81
- str = args.first.value
80
+ str = SingletonFolding.constant_string(args.first)
81
+ return nil if str.nil?
82
82
 
83
83
  if CGI_ELEMENT_ESCAPE_METHODS.include?(method_name)
84
84
  fold_cgi_element(method_name, str, args.drop(1))
@@ -94,9 +94,10 @@ module Rigor
94
94
  # must be `Constant[String]` element names.
95
95
  def fold_cgi_element(method_name, str, element_args)
96
96
  elements = element_args.map do |arg|
97
- return nil unless arg.is_a?(Type::Constant) && arg.value.is_a?(String)
97
+ value = SingletonFolding.constant_string(arg)
98
+ return nil if value.nil?
98
99
 
99
- arg.value
100
+ value
100
101
  end
101
102
 
102
103
  Type::Combinator.constant_of(CGI.public_send(method_name, str, *elements))
@@ -57,7 +57,10 @@ module Rigor
57
57
  :+, :*, :==, :!=, :<, :<=, :>, :>=, :<=>,
58
58
  :start_with?, :end_with?, :include?,
59
59
  :delete_prefix, :delete_suffix,
60
- :match?, :index, :rindex, :center, :ljust, :rjust
60
+ :match?, :index, :rindex, :center, :ljust, :rjust,
61
+ # 1-arg pure transforms/queries whose output never exceeds the
62
+ # input: `delete`/`squeeze` shrink the string, `count` → Integer.
63
+ :delete, :count, :squeeze
61
64
  ].freeze
62
65
  SYMBOL_BINARY = Set[:==, :!=, :<=>, :<, :<=, :>, :>=].freeze
63
66
  BOOL_BINARY = Set[:&, :|, :^, :==, :!=, :===].freeze
@@ -98,7 +101,7 @@ module Rigor
98
101
  STRING_UNARY = Set[
99
102
  :upcase, :downcase, :capitalize, :swapcase,
100
103
  :reverse, :length, :size, :bytesize,
101
- :empty?, :strip, :lstrip, :rstrip, :chomp, :chop,
104
+ :empty?, :strip, :lstrip, :rstrip, :chomp, :chop, :squeeze,
102
105
  :to_s, :to_str, :to_sym, :intern,
103
106
  :to_i, :to_f, :ord, :chr, :hex, :oct, :succ, :next,
104
107
  :inspect, :hash
@@ -132,7 +135,10 @@ module Rigor
132
135
  UNION_FOLD_OUTPUT_LIMIT = 8
133
136
 
134
137
  # @return [Rigor::Type::Constant, Rigor::Type::Union, Rigor::Type::IntegerRange, nil]
135
- def try_fold(receiver:, method_name:, args:)
138
+ def try_dispatch(context)
139
+ receiver = context.receiver
140
+ method_name = context.method_name
141
+ args = context.args
136
142
  # v0.0.7 — `String#%` against a `Tuple` / `HashShape`
137
143
  # argument runs Ruby's format-string engine when both
138
144
  # sides are statically constant. The standard
@@ -1150,7 +1156,7 @@ module Rigor
1150
1156
  #
1151
1157
  # Resolution order:
1152
1158
  #
1153
- # 1. Primary class catalog (e.g. NumericCatalog for an
1159
+ # 1. Primary class catalog (e.g. NUMERIC_CATALOG for an
1154
1160
  # Integer receiver). When the catalog has an entry —
1155
1161
  # even one classified `:dispatch` — that answer wins.
1156
1162
  # The class's direct `rb_define_method` registration is
@@ -1210,8 +1216,8 @@ module Rigor
1210
1216
  # arm first and the catalog would consult the Date entry
1211
1217
  # in `DATE_CATALOG` for the wrong class.
1212
1218
  CATALOG_BY_CLASS = Ractor.make_shareable([
1213
- [Integer, [Builtins::NumericCatalog, "Integer"]],
1214
- [Float, [Builtins::NumericCatalog, "Float"]],
1219
+ [Integer, [Builtins::NUMERIC_CATALOG, "Integer"]],
1220
+ [Float, [Builtins::NUMERIC_CATALOG, "Float"]],
1215
1221
  [String, [Builtins::STRING_CATALOG, "String"]],
1216
1222
  [Symbol, [Builtins::STRING_CATALOG, "Symbol"]],
1217
1223
  [Array, [Builtins::ARRAY_CATALOG, "Array"]],
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../type"
4
+ require_relative "singleton_folding"
5
+
6
+ module Rigor
7
+ module Inference
8
+ module MethodDispatcher
9
+ # ADR-48 — `Data.define` value folding. Three responsibilities, all
10
+ # gated on a fully-decidable shape and degrading to today's behaviour
11
+ # (no carrier / the `Data` nominal) the moment a premise is uncertain,
12
+ # so the tier is precision-additive and adds no false-positive
13
+ # surface:
14
+ #
15
+ # 1. `Data.define(:x, :y)` on a `Singleton[Data]` receiver with
16
+ # literal-Symbol args and NO block -> `DataClass{members: [...]}`.
17
+ # A block (`Data.define(:x) do ... end`) defers (slice 4 hardens
18
+ # the block-body case); non-literal members (`Data.define(*names)`)
19
+ # defer.
20
+ # 2. `.new` / `.[]` on a `DataClass` receiver -> a `DataInstance`
21
+ # whose member map is built from the call's positional or keyword
22
+ # arguments. An arity / key mismatch degrades to the `Data` (or the
23
+ # tagged class) nominal rather than a wrong member map.
24
+ # 3. member reads + `[]` / `to_h` / `deconstruct` / `deconstruct_keys`
25
+ # / `members` / `with` on a `DataInstance` receiver -> the precise
26
+ # projected type. Unhandled methods return nil so the pipeline
27
+ # projects the instance to its nominal through RbsDispatch.
28
+ #
29
+ # See docs/adr/48-data-struct-value-folding.md.
30
+ module DataFolding
31
+ module_function
32
+
33
+ # @return [Rigor::Type, nil] the folded result, or nil to defer.
34
+ def try_dispatch(context)
35
+ receiver = context.receiver
36
+
37
+ return fold_define(context) if SingletonFolding.receiver?(receiver, "Data")
38
+
39
+ case receiver
40
+ when Type::DataClass
41
+ materialize_instance(receiver.members, receiver.class_name, context)
42
+ when Type::DataInstance
43
+ fold_instance(receiver, context)
44
+ when Type::Singleton
45
+ fold_named_new(receiver, context)
46
+ end
47
+ end
48
+
49
+ # A `Data.define` value object assigned to a constant (or a
50
+ # `class Point < Data.define(...)` subclass) is canonicalised by the
51
+ # engine to `Singleton[Point]`, not a `DataClass` — so its member
52
+ # layout is read from the project side-table the scope indexer built
53
+ # (`Scope#data_member_layout`) rather than from the receiver carrier.
54
+ def fold_named_new(singleton, context)
55
+ scope = context.scope
56
+ return nil if scope.nil?
57
+
58
+ members = scope.data_member_layout(singleton.class_name)
59
+ return nil if members.nil?
60
+
61
+ materialize_instance(members, singleton.class_name, context)
62
+ end
63
+
64
+ # --- 1. Data.define(:x, :y) -------------------------------------
65
+
66
+ def fold_define(context)
67
+ return nil unless context.method_name == :define
68
+ # Block-form (`Data.define(:x) do ... end`) defers — slice 4.
69
+ return nil unless context.block_type.nil?
70
+
71
+ members = member_names_from_args(context.args)
72
+ return nil if members.nil? || members.empty?
73
+
74
+ Type::Combinator.data_class_of(members: members)
75
+ end
76
+
77
+ # The ordered Symbol member names, or nil when any argument is not
78
+ # a literal `Constant[Symbol]` (a splat or dynamic name).
79
+ def member_names_from_args(args)
80
+ names = args.map do |arg|
81
+ return nil unless arg.is_a?(Type::Constant) && arg.value.is_a?(Symbol)
82
+
83
+ arg.value
84
+ end
85
+ return nil unless names.uniq.size == names.size
86
+
87
+ names
88
+ end
89
+
90
+ # --- 2. Point.new(...) / Point[...] -----------------------------
91
+
92
+ def materialize_instance(members, class_name, context)
93
+ method_name = context.method_name
94
+ return nil unless %i[new []].include?(method_name)
95
+
96
+ map = member_map_for_new(members, context)
97
+ return degraded_instance(class_name) if map.nil?
98
+
99
+ Type::Combinator.data_instance_of(members: map, class_name: class_name)
100
+ end
101
+
102
+ # Builds the member -> type map from the call's arguments, honouring
103
+ # the keyword vs positional distinction read off the call node. nil
104
+ # when the arguments cannot soundly populate every member.
105
+ def member_map_for_new(members, context)
106
+ if keyword_new?(context)
107
+ keyword_member_map(members, context.args)
108
+ else
109
+ positional_member_map(members, context.args)
110
+ end
111
+ end
112
+
113
+ # `Point.new(x: 1, y: 2)` arrives as a single trailing `HashShape`
114
+ # arg whose call node is a `KeywordHashNode`. Distinguishing it from
115
+ # a positional hash (`Point.new({x: 1})`, a `HashNode`) needs the
116
+ # call node, since both type to a `HashShape`.
117
+ def keyword_new?(context)
118
+ node = context.call_node
119
+ return false if node.nil?
120
+
121
+ arguments = node.arguments&.arguments
122
+ return false if arguments.nil? || arguments.empty?
123
+
124
+ arguments.last.is_a?(Prism::KeywordHashNode)
125
+ end
126
+
127
+ def keyword_member_map(members, args)
128
+ return nil unless args.size == 1
129
+
130
+ shape = args.first
131
+ return nil unless shape.is_a?(Type::HashShape) && shape.closed?
132
+ return nil unless shape.optional_keys.empty?
133
+ return nil unless shape.pairs.keys.sort == members.sort
134
+
135
+ members.to_h { |name| [name, shape.pairs.fetch(name)] }
136
+ end
137
+
138
+ def positional_member_map(members, args)
139
+ return nil unless args.size == members.size
140
+
141
+ members.zip(args).to_h
142
+ end
143
+
144
+ # A `.new` whose arguments do not fold to a precise map still has a
145
+ # sound, more-precise-than-Dynamic answer: an instance of the
146
+ # tagged class (or the `Data` supertype).
147
+ def degraded_instance(class_name)
148
+ Type::Combinator.nominal_of(class_name || "Data")
149
+ end
150
+
151
+ # --- 3. inst.x / inst[...] / inst.to_h / ... --------------------
152
+
153
+ def fold_instance(instance, context)
154
+ method_name = context.method_name
155
+ args = context.args
156
+ members = instance.members
157
+
158
+ if members.key?(method_name) && args.empty? && !reader_overridden?(instance, method_name, context.scope)
159
+ return members.fetch(method_name)
160
+ end
161
+
162
+ case method_name
163
+ when :[] then instance_index(instance, args)
164
+ when :to_h, :to_hash then instance_to_h(instance)
165
+ when :deconstruct then instance_deconstruct(instance)
166
+ when :deconstruct_keys then instance_deconstruct_keys(instance, args)
167
+ when :members then instance_members(instance)
168
+ when :with then instance_with(instance, args)
169
+ end
170
+ end
171
+
172
+ # A `Data.define` class body (the `class Point < Data.define(:x);
173
+ # def x; …; end; end` subclass body, or a `Const = Data.define(:x) do
174
+ # def x; …; end; end` block) can redefine a member's synthesised
175
+ # reader. When it does, `inst.x` runs that `def`, NOT the member, so
176
+ # folding the read to the member type would be unsound (a downstream
177
+ # FP). Both named forms register the override as a real `def` node
178
+ # under the class name, so an entry in the project def-node table is
179
+ # the discriminator (the synthesised reader has no def node). The
180
+ # value accessors `[]` / `to_h` / `deconstruct` bypass the reader and
181
+ # stay foldable, so this gate is on the bare member read only.
182
+ def reader_overridden?(instance, method_name, scope)
183
+ class_name = instance.class_name
184
+ return false if class_name.nil? || scope.nil?
185
+
186
+ !scope.user_def_for(class_name, method_name).nil?
187
+ end
188
+
189
+ def instance_index(instance, args)
190
+ return nil unless args.size == 1
191
+
192
+ arg = args.first
193
+ return nil unless arg.is_a?(Type::Constant)
194
+
195
+ key = arg.value
196
+ case key
197
+ when Symbol
198
+ instance.members[key]
199
+ when Integer
200
+ values = instance.members.values
201
+ idx = key.negative? ? key + values.size : key
202
+ values[idx] if idx && idx >= 0 && idx < values.size
203
+ end
204
+ end
205
+
206
+ def instance_to_h(instance)
207
+ Type::Combinator.hash_shape_of(instance.members.dup)
208
+ end
209
+
210
+ def instance_deconstruct(instance)
211
+ Type::Combinator.tuple_of(*instance.members.values)
212
+ end
213
+
214
+ # `deconstruct_keys(nil)` / `deconstruct_keys([:x])` both yield a
215
+ # subset of the member map; the conservative, always-correct answer
216
+ # is the full closed member shape.
217
+ def instance_deconstruct_keys(instance, args)
218
+ return nil unless args.size <= 1
219
+
220
+ Type::Combinator.hash_shape_of(instance.members.dup)
221
+ end
222
+
223
+ def instance_members(instance)
224
+ Type::Combinator.tuple_of(*instance.member_names.map { |name| Type::Combinator.constant_of(name) })
225
+ end
226
+
227
+ # `Data#with(x: 9)` returns a new frozen copy with the named members
228
+ # overridden. Only a closed keyword `HashShape` whose keys are a
229
+ # subset of the members folds; anything else defers (RBS resolves
230
+ # `with` to `self`, returning the unchanged instance type).
231
+ def instance_with(instance, args)
232
+ return instance if args.empty?
233
+ return nil unless args.size == 1
234
+
235
+ shape = args.first
236
+ return nil unless shape.is_a?(Type::HashShape) && shape.closed?
237
+ return nil unless shape.optional_keys.empty?
238
+ return nil unless shape.pairs.keys.all? { |key| instance.members.key?(key) }
239
+
240
+ merged = instance.members.merge(shape.pairs)
241
+ Type::Combinator.data_instance_of(members: merged, class_name: instance.class_name)
242
+ end
243
+ end
244
+ end
245
+ end
246
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../type"
4
+ require_relative "singleton_folding"
4
5
 
5
6
  module Rigor
6
7
  module Inference
@@ -90,7 +91,10 @@ module Rigor
90
91
 
91
92
  # @return [Rigor::Type, nil] folded result, or nil to defer
92
93
  # to the next dispatcher tier.
93
- def try_dispatch(receiver:, method_name:, args:)
94
+ def try_dispatch(context)
95
+ receiver = context.receiver
96
+ method_name = context.method_name
97
+ args = context.args
94
98
  return nil unless dispatch_target?(receiver)
95
99
  return nil unless FILE_PURE_CLASS_METHODS.include?(method_name)
96
100
  return nil if platform_specific_skip?(method_name)
@@ -107,7 +111,7 @@ module Rigor
107
111
  end
108
112
 
109
113
  def dispatch_target?(receiver)
110
- receiver.is_a?(Type::Singleton) && receiver.class_name == "File"
114
+ SingletonFolding.receiver?(receiver, "File")
111
115
  end
112
116
 
113
117
  def constant_string_args(args)