rigortype 0.3.0 → 0.3.2

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 (145) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  3. data/data/builtins/ruby_core/array.yml +416 -392
  4. data/data/builtins/ruby_core/file.yml +42 -42
  5. data/data/builtins/ruby_core/hash.yml +302 -302
  6. data/data/builtins/ruby_core/io.yml +191 -191
  7. data/data/builtins/ruby_core/numeric.yml +321 -366
  8. data/data/builtins/ruby_core/proc.yml +124 -124
  9. data/data/builtins/ruby_core/range.yml +21 -21
  10. data/data/builtins/ruby_core/rational.yml +39 -39
  11. data/data/builtins/ruby_core/re.yml +65 -65
  12. data/data/builtins/ruby_core/set.yml +106 -106
  13. data/data/builtins/ruby_core/struct.yml +14 -14
  14. data/data/core_overlay/pathname.rbs +5 -0
  15. data/data/core_overlay/resolv.rbs +31 -0
  16. data/data/core_overlay/string_scanner.rbs +11 -5
  17. data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
  18. data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
  19. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
  20. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
  21. data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
  22. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
  23. data/docs/handbook/01-getting-started.md +22 -34
  24. data/docs/handbook/06-classes.md +1 -1
  25. data/docs/handbook/07-rbs-and-extended.md +76 -101
  26. data/docs/handbook/08-understanding-errors.md +114 -247
  27. data/docs/handbook/09-plugins.md +54 -144
  28. data/docs/handbook/README.md +5 -3
  29. data/docs/handbook/appendix-liskov.md +4 -2
  30. data/docs/handbook/appendix-phpstan.md +14 -7
  31. data/docs/handbook/appendix-steep.md +4 -2
  32. data/docs/handbook/appendix-type-theory.md +3 -1
  33. data/docs/manual/02-cli-reference.md +103 -11
  34. data/docs/manual/03-configuration.md +16 -1
  35. data/docs/manual/04-diagnostics.md +36 -4
  36. data/docs/manual/06-baseline.md +35 -1
  37. data/docs/manual/08-skills.md +6 -1
  38. data/docs/manual/09-editor-integration.md +3 -2
  39. data/docs/manual/12-caching.md +17 -5
  40. data/docs/manual/15-type-protection-coverage.md +122 -1
  41. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  42. data/docs/manual/plugins/rigor-devise.md +4 -2
  43. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  44. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  45. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +21 -2
  46. data/lib/rigor/analysis/check_rules.rb +79 -19
  47. data/lib/rigor/analysis/incremental_session.rb +156 -11
  48. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  49. data/lib/rigor/analysis/run_cache_key.rb +27 -1
  50. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  51. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  52. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  53. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  54. data/lib/rigor/analysis/runner.rb +80 -8
  55. data/lib/rigor/bleeding_edge.rb +132 -7
  56. data/lib/rigor/cache/descriptor.rb +6 -1
  57. data/lib/rigor/cache/engine_source.rb +162 -0
  58. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  59. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  60. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  61. data/lib/rigor/cache/store.rb +99 -24
  62. data/lib/rigor/cli/check_command.rb +61 -34
  63. data/lib/rigor/cli/check_invocation.rb +84 -0
  64. data/lib/rigor/cli/coverage_command.rb +15 -2
  65. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  66. data/lib/rigor/cli/doctor_command.rb +6 -8
  67. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  68. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  69. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  70. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  71. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  72. data/lib/rigor/cli/protection_renderer.rb +13 -0
  73. data/lib/rigor/cli/protection_report.rb +11 -3
  74. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  75. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  76. data/lib/rigor/cli/skill_command.rb +21 -1
  77. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  78. data/lib/rigor/cli/skill_describe.rb +75 -9
  79. data/lib/rigor/configuration.rb +45 -6
  80. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  81. data/lib/rigor/environment/default_libraries.rb +5 -4
  82. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  83. data/lib/rigor/environment/rbs_loader.rb +434 -75
  84. data/lib/rigor/environment.rb +38 -14
  85. data/lib/rigor/inference/expression_typer.rb +15 -0
  86. data/lib/rigor/inference/fork_map.rb +9 -0
  87. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  88. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  89. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  90. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -1
  91. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
  92. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  93. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
  94. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  95. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  96. data/lib/rigor/inference/mutation_widening.rb +30 -25
  97. data/lib/rigor/inference/narrowing.rb +3 -1
  98. data/lib/rigor/inference/optimistic_origin.rb +58 -0
  99. data/lib/rigor/inference/protection_scanner.rb +18 -2
  100. data/lib/rigor/inference/receiver_alias.rb +57 -0
  101. data/lib/rigor/inference/scope_indexer.rb +78 -27
  102. data/lib/rigor/inference/statement_evaluator.rb +40 -2
  103. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  104. data/lib/rigor/language_server/buffer_table.rb +68 -6
  105. data/lib/rigor/language_server/diagnostic_publisher.rb +207 -4
  106. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  107. data/lib/rigor/language_server/project_context.rb +45 -0
  108. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  109. data/lib/rigor/language_server/server.rb +38 -9
  110. data/lib/rigor/language_server.rb +2 -0
  111. data/lib/rigor/plugin/base.rb +29 -2
  112. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  113. data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
  114. data/lib/rigor/protection/dependency_closure.rb +59 -0
  115. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  116. data/lib/rigor/protection/discovery_seed.rb +116 -0
  117. data/lib/rigor/protection/kill_signature.rb +31 -0
  118. data/lib/rigor/protection/mutation_cache.rb +355 -0
  119. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  120. data/lib/rigor/protection/mutator.rb +26 -4
  121. data/lib/rigor/runtime/jit.rb +63 -1
  122. data/lib/rigor/scope/discovery_index.rb +9 -0
  123. data/lib/rigor/scope.rb +52 -8
  124. data/lib/rigor/sig_gen/generator.rb +130 -31
  125. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  126. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  127. data/lib/rigor/sig_gen/renderer.rb +20 -1
  128. data/lib/rigor/sig_gen/write_result.rb +6 -4
  129. data/lib/rigor/sig_gen/writer.rb +195 -47
  130. data/lib/rigor/sig_gen.rb +1 -0
  131. data/lib/rigor/version.rb +1 -1
  132. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  133. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  134. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  135. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  136. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  137. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  138. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  139. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  140. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  141. data/sig/rigor/cache.rbs +6 -0
  142. data/sig/rigor/inference/void_origin.rbs +21 -0
  143. data/sig/rigor/plugin/base.rbs +4 -3
  144. data/sig/rigor/scope.rbs +8 -0
  145. metadata +23 -3
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Inference
7
+ # Which variables can a receiver EXPRESSION evaluate to?
8
+ #
9
+ # A receiver-fact invalidation (see {MutationWidening}) has to name the binding it invalidates,
10
+ # and the overwhelmingly common receiver — a bare `arr` / `@arr` read — names exactly one. But a
11
+ # receiver may also *select* among variables without naming any of them:
12
+ #
13
+ # (kind == :required ? required : optional)[key] = info
14
+ #
15
+ # The mutation lands on whichever of `required` / `optional` the ternary picked, so BOTH are
16
+ # possible targets and both must forget their literal shape. Reading only the syntactic head
17
+ # left both hashes carrying the empty `HashShape` the literal `{}` wrote, and a downstream
18
+ # `.empty?` then constant-folded into a false `flow.always-truthy-condition`
19
+ # ([#277](https://github.com/rigortype/rigor/issues/277)).
20
+ #
21
+ # The recursion covers only the forms whose value IS one of the sub-expressions: `if` / `unless`
22
+ # (including the ternary spelling), the short-circuit operators, and the transparent wrappers.
23
+ # Anything else — an index read (`declared[kind] << key`), a call result, a literal — names an
24
+ # object no binding can be attributed to and yields `[]`, which is what every receiver form
25
+ # outside the single-read case already contributed. The walk is depth-capped so a pathological
26
+ # nest cannot make receiver classification unbounded.
27
+ module ReceiverAlias
28
+ # Deep enough for any hand-written selection; a nest beyond it degrades to "names no binding".
29
+ WALK_DEPTH_CAP = 6
30
+
31
+ module_function
32
+
33
+ # @param node [Prism::Node, nil] the receiver expression.
34
+ # @param depth [Integer] recursion depth, internal.
35
+ # @return [Array<Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode>] every variable
36
+ # read the expression can evaluate to; empty when it can evaluate to none.
37
+ def candidates(node, depth = 0)
38
+ return [] if node.nil? || depth > WALK_DEPTH_CAP
39
+
40
+ case node
41
+ when Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode then [node]
42
+ when Prism::ParenthesesNode then candidates(node.body, depth + 1)
43
+ when Prism::StatementsNode then candidates(node.body.last, depth + 1)
44
+ when Prism::ElseNode then candidates(node.statements, depth + 1)
45
+ when Prism::IfNode then branches(node.statements, node.subsequent, depth)
46
+ when Prism::UnlessNode then branches(node.statements, node.else_clause, depth)
47
+ when Prism::OrNode, Prism::AndNode then branches(node.left, node.right, depth)
48
+ else []
49
+ end
50
+ end
51
+
52
+ def branches(first, second, depth)
53
+ candidates(first, depth + 1) + candidates(second, depth + 1)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -1275,16 +1275,40 @@ module Rigor
1275
1275
  end
1276
1276
 
1277
1277
  # Merges two `class_name => { method => kind }` tables, unioning the per-class method maps (so a seeded cross-file
1278
- # table and the current file's table combine instead of clobbering).
1278
+ # table and the current file's table combine instead of clobbering). A name recorded on both sides — an instance
1279
+ # `def` here and a `class << self` twin there — merges to {Scope::DiscoveryIndex::METHOD_KIND_BOTH} rather than
1280
+ # letting the overlay's kind win (#239).
1279
1281
  def deep_merge_class_methods(base, overlay)
1280
1282
  return overlay if base.nil? || base.empty?
1281
1283
  return base if overlay.empty?
1282
1284
 
1283
1285
  base.merge(overlay) do |_class_name, base_methods, overlay_methods|
1284
- base_methods.merge(overlay_methods)
1286
+ merge_method_kinds(base_methods, overlay_methods)
1285
1287
  end
1286
1288
  end
1287
1289
 
1290
+ # `{ method => kind }` union that promotes a kind disagreement to `METHOD_KIND_BOTH` instead of clobbering.
1291
+ def merge_method_kinds(base_methods, overlay_methods)
1292
+ base_methods.merge(overlay_methods) do |_method_name, base_kind, overlay_kind|
1293
+ base_kind == overlay_kind ? base_kind : Scope::DiscoveryIndex::METHOD_KIND_BOTH
1294
+ end
1295
+ end
1296
+
1297
+ # The single write path into a `class_name => { method => kind }` existence table. Every recorder goes through
1298
+ # it so a class that defines one name on both sides keeps both facts: the table is keyed by name alone, so a
1299
+ # bare assignment silently replaced the other side's kind and `Scope#discovered_method?` then answered false for
1300
+ # a method the source plainly defines (#239).
1301
+ def record_method_kind(accumulator, class_name, method_name, kind)
1302
+ table = (accumulator[class_name] ||= {})
1303
+ recorded = table[method_name]
1304
+ table[method_name] =
1305
+ if recorded.nil? || recorded == kind
1306
+ kind
1307
+ else
1308
+ Scope::DiscoveryIndex::METHOD_KIND_BOTH
1309
+ end
1310
+ end
1311
+
1288
1312
  # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize
1289
1313
  # Combined `walk_methods` + `walk_def_nodes` descent. The two walks had identical class / module / singleton-class
1290
1314
  # / meta-block traversals and both stopped at `DefNode`; the only divergences are leaf actions (recorded into the
@@ -1393,8 +1417,7 @@ module Rigor
1393
1417
  return if members.empty?
1394
1418
 
1395
1419
  class_name = qualified_prefix.join("::")
1396
- table = (accumulator[class_name] ||= {})
1397
- members.each { |member| table[member] ||= :instance }
1420
+ members.each { |member| record_method_kind(accumulator, class_name, member, :instance) }
1398
1421
  end
1399
1422
 
1400
1423
  # The Symbol member names of a `Data.define(*Symbol)` / `Struct.new(*Symbol [, keyword_init:])` call. For
@@ -1412,8 +1435,7 @@ module Rigor
1412
1435
  class_name = qualified_prefix.join("::")
1413
1436
  singleton = def_singleton?(def_node, qualified_prefix, in_singleton_class)
1414
1437
  kind = singleton ? :singleton : :instance
1415
- accumulator[class_name] ||= {}
1416
- accumulator[class_name][def_node.name] = kind
1438
+ record_method_kind(accumulator, class_name, def_node.name, kind)
1417
1439
  end
1418
1440
 
1419
1441
  # `def Foo.bar` inside `module Foo` (or `def Meta.init` inside `module Meta`) is semantically equivalent to `def
@@ -1918,7 +1940,7 @@ module Rigor
1918
1940
  class_name = qualified_prefix.join("::")
1919
1941
  new_name = alias_node.new_name.unescaped.to_sym
1920
1942
  kind = in_singleton_class ? :singleton : :instance
1921
- (accumulator[class_name] ||= {})[new_name] = kind
1943
+ record_method_kind(accumulator, class_name, new_name, kind)
1922
1944
  end
1923
1945
 
1924
1946
  # Post-pass over the `def_nodes` accumulator: for every `alias` declaration inside a class body, if the original
@@ -1981,8 +2003,7 @@ module Rigor
1981
2003
  return if method_name.nil?
1982
2004
 
1983
2005
  class_name = qualified_prefix.join("::")
1984
- accumulator[class_name] ||= {}
1985
- accumulator[class_name][method_name] = in_singleton_class ? :singleton : :instance
2006
+ record_method_kind(accumulator, class_name, method_name, in_singleton_class ? :singleton : :instance)
1986
2007
  end
1987
2008
 
1988
2009
  # The `attr_*` accessor macros that introduce methods Rigor must treat as source-declared. Without this, a class
@@ -2005,9 +2026,8 @@ module Rigor
2005
2026
  base = literal_method_name(arg)
2006
2027
  next if base.nil?
2007
2028
 
2008
- accumulator[class_name] ||= {}
2009
- accumulator[class_name][base] = kind if reader
2010
- accumulator[class_name][:"#{base}="] = kind if writer
2029
+ record_method_kind(accumulator, class_name, base, kind) if reader
2030
+ record_method_kind(accumulator, class_name, :"#{base}=", kind) if writer
2011
2031
  end
2012
2032
  end
2013
2033
 
@@ -2352,7 +2372,7 @@ module Rigor
2352
2372
  file_index[:def_nodes].each { |cn, methods| (acc[:def_nodes][cn] ||= {}).merge!(methods) }
2353
2373
  file_index[:singleton_def_nodes].each { |cn, methods| (acc[:singleton_def_nodes][cn] ||= {}).merge!(methods) }
2354
2374
  file_index[:method_visibilities].each { |cn, table| (acc[:method_visibilities][cn] ||= {}).merge!(table) }
2355
- file_index[:methods].each { |cn, table| (acc[:methods][cn] ||= {}).merge!(table) }
2375
+ file_index[:methods].each { |cn, table| acc[:methods][cn] = merge_method_kinds(acc[:methods][cn] || {}, table) }
2356
2376
  fold_def_sources(acc, :def_sources, file_index[:def_sources])
2357
2377
  fold_def_sources(acc, :singleton_def_sources, file_index[:singleton_def_sources])
2358
2378
  end
@@ -2468,10 +2488,18 @@ module Rigor
2468
2488
 
2469
2489
  # Removes, per class, the method names that have a project `def` node, leaving only
2470
2490
  # accessor/alias/define_method-introduced methods in the cross-file suppression table.
2491
+ #
2492
+ # `def_nodes` is the INSTANCE-side table, so a name recorded on both sides keeps its singleton half: that half
2493
+ # comes from a `def self.x` / `class << self` definition this rule never covered, and dropping it whole would
2494
+ # reintroduce #239's false `call.undefined-method` across files.
2471
2495
  def subtract_def_methods(methods, def_nodes)
2472
2496
  methods.each_with_object({}) do |(class_name, table), out|
2473
2497
  defs = def_nodes[class_name] || {}
2474
- kept = table.reject { |method_name, _kind| defs.key?(method_name) }
2498
+ kept = table.each_with_object({}) do |(method_name, kind), acc|
2499
+ next acc[method_name] = kind unless defs.key?(method_name)
2500
+
2501
+ acc[method_name] = :singleton if kind == Scope::DiscoveryIndex::METHOD_KIND_BOTH
2502
+ end
2475
2503
  out[class_name] = kept unless kept.empty?
2476
2504
  end
2477
2505
  end
@@ -2517,7 +2545,7 @@ module Rigor
2517
2545
  (acc[:method_visibilities][class_name] ||= {}).merge!(table)
2518
2546
  end
2519
2547
  file_methods.each do |class_name, table|
2520
- (acc[:methods][class_name] ||= {}).merge!(table)
2548
+ acc[:methods][class_name] = merge_method_kinds(acc[:methods][class_name] || {}, table)
2521
2549
  end
2522
2550
  end
2523
2551
 
@@ -2570,24 +2598,47 @@ module Rigor
2570
2598
  node.rigor_each_child { |child| collect_class_decls(child, qualified_prefix, accumulator) }
2571
2599
  end
2572
2600
 
2573
- # T1 (template-corpora survey) — record a `Const = Class.new(Super)` (and the bare `Class.new` / `Module.new`)
2574
- # class-creating constant in the cross-file discovery table so a reference to `Const` from ANOTHER file under the
2575
- # same namespace resolves to the project class instead of falling through to a core same-named class
2601
+ # T1 (template-corpora survey) — record a class-creating constant write (`Const = Class.new(Super)`, the bare
2602
+ # `Class.new` / `Module.new`, and the `Data.define(*sym)` / `Struct.new(*sym)` forms, each with or without a
2603
+ # block) in the cross-file discovery table so a reference to `Const` from ANOTHER file under the same namespace
2604
+ # resolves to the project class instead of falling through to a same-named class elsewhere
2576
2605
  # (`Liquid::SyntaxError = Class.new(Error)` referenced in a sibling file's `rescue SyntaxError => e`, which
2577
2606
  # otherwise resolved to core `::SyntaxError`). Mirrors the single-file `in_source_constants` answer, which types
2578
- # `Class.new(Super)` as `Singleton[Super]` (the constructed class answers method lookups through Super's chain).
2579
- # The superclass name is resolved lexically against the enclosing prefix; a bare `Class.new` with no superclass
2580
- # (or `Module.new`) types as `Singleton[Const]` itself. The block form is left to the existing
2581
- # `meta_new_block_body` machinery only the plain `Class.new(Super)` constant (the namespaced-sibling-error
2582
- # idiom) is added here.
2607
+ # `Class.new(Super)` as `Singleton[Super]` (the constructed class answers method lookups through Super's chain)
2608
+ # and every other recognised form as `Singleton[Const]`.
2609
+ #
2610
+ # #271 — the Data/Struct forms are here because their absence was an ACTIVE false positive, not merely a missed
2611
+ # resolution: a nested `Const = Data.define(...)` invisible cross-file lets Ruby's lexical walk continue to the
2612
+ # PARENT namespace's same-named sibling, so `Analysis::PluginFactFingerprint.from_registry(...).opaque?` typed
2613
+ # its receiver as the unrelated `Rigor::Analysis::Result` and reported `call.undefined-method` on correct code.
2614
+ # Nested `Result` / `Entry` / `Config` Data constants shadowing a sibling are ordinary Ruby, and only the
2615
+ # DEFINING file's `in_source_constants` (never part of the project seed) knew about them.
2583
2616
  def record_class_new_constant_decl(node, qualified_prefix, accumulator)
2584
2617
  rvalue = node.value
2585
- return unless class_new_call?(rvalue) || module_new_call?(rvalue)
2586
- return if rvalue.block # block form: handled by meta_new_block_body walks
2618
+ return unless meta_new_constant_rvalue?(rvalue)
2587
2619
 
2588
2620
  full = (qualified_prefix + [node.name.to_s]).join("::")
2589
- super_name = class_new_superclass_name(rvalue, qualified_prefix, accumulator)
2590
- accumulator[full] = Type::Combinator.singleton_of(super_name || full)
2621
+ accumulator[full] = Type::Combinator.singleton_of(
2622
+ meta_new_constant_decl_name(rvalue, full, qualified_prefix, accumulator)
2623
+ )
2624
+ end
2625
+
2626
+ # The four recognised class-creating rvalue shapes at constant-write position — the same set
2627
+ # {#meta_new_block_body} recognises, so the cross-file table and the per-file block-as-method walk agree on what
2628
+ # counts as a declaration.
2629
+ def meta_new_constant_rvalue?(rvalue)
2630
+ class_new_call?(rvalue) || module_new_call?(rvalue) ||
2631
+ data_define_call?(rvalue) || struct_new_call?(rvalue)
2632
+ end
2633
+
2634
+ # The name the constant's `Singleton[...]` carries. Only a block-less `Class.new(Super)` borrows its superclass's
2635
+ # name (the constructed class answers lookups through `Super`'s chain and declares nothing of its own); every
2636
+ # other form — Data/Struct members, any block body — owns methods under its OWN qualified name, which is exactly
2637
+ # what the per-file `meta_new_constant_type` answers.
2638
+ def meta_new_constant_decl_name(rvalue, full, qualified_prefix, accumulator)
2639
+ return full if rvalue.block || data_define_call?(rvalue) || struct_new_call?(rvalue)
2640
+
2641
+ class_new_superclass_name(rvalue, qualified_prefix, accumulator) || full
2591
2642
  end
2592
2643
 
2593
2644
  # Lexically-qualified name of a `Class.new(Super)` superclass argument, or nil when there is no positional
@@ -247,9 +247,31 @@ module Rigor
247
247
 
248
248
  bound = bound.without_inferred_param_mark(node.name)
249
249
  bound = bound.with_local_origin(node.name, rhs_origin(node.value, post_rhs, rhs_type))
250
+ bound = bound.with_optimistic_local(node.name, optimistic_rhs_origin(node.value, post_rhs))
250
251
  [rhs_type, bound]
251
252
  end
252
253
 
254
+ # Issue #286 — the optimistic-nil-free counterpart of {#rhs_origin}, differing in two ways. It does not
255
+ # gate on `Dynamic`: the values this channel marks are ordinary `Union` / `Constant` / `Nominal`
256
+ # carriers, which is the whole point. And it resolves a bare local read through its binding, so
257
+ # `w = v` keeps the mark — the same propagation `OriginLookup` performs for the Dynamic channel.
258
+ def optimistic_rhs_origin(value_node, scope_after_rhs)
259
+ optimistic_origin_for(value_node, scope_after_rhs)
260
+ end
261
+
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.
265
+ 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
273
+ end
274
+
253
275
  # ADR-82 WD1 — the {Inference::DynamicOrigin} cause to propagate onto a local / ivar being bound to `rhs`.
254
276
  # Returns the cause recorded on the assignment's rhs node when the value is `Dynamic` (so a later
255
277
  # `x` / `@x` receiver-read resolves to why it is dynamic), else `nil` — `with_local_origin` /
@@ -277,6 +299,7 @@ module Rigor
277
299
  rhs_type, post_rhs = sub_eval(node.value, scope)
278
300
  bound = post_rhs.with_ivar(node.name, rhs_type)
279
301
  bound = bound.with_ivar_origin(node.name, rhs_origin(node.value, post_rhs, rhs_type))
302
+ bound = bound.with_optimistic_ivar(node.name, optimistic_rhs_origin(node.value, post_rhs))
280
303
  [rhs_type, bound]
281
304
  end
282
305
 
@@ -543,19 +566,34 @@ module Rigor
543
566
  # non-falsey carriers like `Nominal[Integer]` (Integer is always truthy in Ruby — including 0) also collapse the
544
567
  # dead else.
545
568
  def live_branch_for_if(node, pred_type, post_pred)
546
- case Narrowing.predicate_certainty(pred_type)
569
+ verdict = optimistic_carrier?(node.predicate, post_pred) ? nil : Narrowing.predicate_certainty(pred_type)
570
+ case verdict
547
571
  when :truthy then eval_branch_or_nil(node.statements, post_pred)
548
572
  when :falsey then eval_branch_or_nil(node.subsequent, post_pred)
549
573
  end
550
574
  end
551
575
 
552
576
  def live_branch_for_unless(node, pred_type, post_pred)
553
- case Narrowing.predicate_certainty(pred_type)
577
+ verdict = optimistic_carrier?(node.predicate, post_pred) ? nil : Narrowing.predicate_certainty(pred_type)
578
+ case verdict
554
579
  when :truthy then eval_branch_or_nil(node.else_clause, post_pred)
555
580
  when :falsey then eval_branch_or_nil(node.statements, post_pred)
556
581
  end
557
582
  end
558
583
 
584
+ # ADR-101 — the branch elision MUST NOT conclude truthiness from a carrier whose nil-freeness rests on
585
+ # the `%a{implicitly-returns-nil}` that `RbsDispatch` reads past. Such a value is optimistic, not proof
586
+ # (see {Inference::OptimisticOrigin} and docs/internal-spec/inference-engine.md), so eliding an arm on
587
+ # it deletes a branch the program really takes when the lookup misses.
588
+ #
589
+ # The decline lives here and NOT in `Narrowing.falsey_nominal?` / `.narrow_falsey`: `&&=` / `||=` and
590
+ # the and/or surviving-left edge read those too, and widening the falsey fragment there would re-admit
591
+ # `nil` into a bound local and buy `possible nil receiver` false positives — a soundness fix paid for
592
+ # in FPs, which is the wrong trade.
593
+ def optimistic_carrier?(predicate, scope)
594
+ !optimistic_origin_for(predicate, scope).nil?
595
+ end
596
+
559
597
  def eval_else(node)
560
598
  return [Type::Combinator.constant_of(nil), scope] if node.statements.nil?
561
599
 
@@ -13,12 +13,15 @@ module Rigor
13
13
  module BufferResolution
14
14
  private
15
15
 
16
- # Resolves `[path, entry]` for the document `uri`, or nil when the uri has no file path or no open
17
- # buffer. A caller that destructures `path, entry = buffer_for(uri)` can guard on `entry.nil?` to cover
18
- # both misses (a nil return leaves both locals nil).
16
+ # Resolves `[path, entry]` for the document `uri`, or nil when the uri has no file path, no open buffer,
17
+ # or a buffer the server could not keep in sync with the editor (see `BufferTable#apply_changes`) a
18
+ # position answered from text that has drifted from the editor's is worse than no answer. A caller that
19
+ # destructures `path, entry = buffer_for(uri)` can guard on `entry.nil?` to cover every miss (a nil
20
+ # return leaves both locals nil).
19
21
  def buffer_for(uri)
20
22
  path = Uri.to_path(uri)
21
23
  return nil if path.nil?
24
+ return nil if @buffer_table.desynchronized?(uri)
22
25
 
23
26
  entry = @buffer_table[uri]
24
27
  return nil if entry.nil?
@@ -1,13 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "incremental_sync"
4
+
3
5
  module Rigor
4
6
  module LanguageServer
5
7
  # Per-session virtual file table. The LSP server maintains the canonical view of every open buffer here;
6
8
  # analysis (slice 4+) reads from this table instead of disk so in-flight edits are reflected immediately.
7
9
  #
8
- # Keyed by `DocumentUri` (LSP `file://...` URIs). v1 ships FULL text sync (LSP
9
- # `TextDocumentSyncKind::Full = 1`) so each `didChange` carries the entire buffer text — there's no
10
- # incremental edit application yet. Incremental sync is slice 10 (deferred per the design doc).
10
+ # Keyed by `DocumentUri` (LSP `file://...` URIs). Sync is INCREMENTAL (LSP
11
+ # `TextDocumentSyncKind::Incremental = 2`): a `didChange` carries range edits which {#apply_changes} splices
12
+ # into the held text through {IncrementalSync}. The full-text form ({#change}, and a `contentChanges` entry
13
+ # with no `range`) stays supported — it is still legal under incremental sync and is what `didOpen` and a
14
+ # client-side resync send.
11
15
  class BufferTable
12
16
  # @!attribute uri [String] the LSP DocumentUri (e.g. `file:///abs/path/lib/foo.rb`).
13
17
  # @!attribute bytes [String] the current full text of the buffer.
@@ -16,26 +20,84 @@ module Rigor
16
20
 
17
21
  def initialize
18
22
  @entries = {}
23
+ @desynchronized = {}
24
+ @dirty = {}
19
25
  end
20
26
 
21
27
  # Records a `textDocument/didOpen` event. Replaces any existing entry (LSP clients may re-open a
22
- # previously closed URI; the new version is authoritative).
28
+ # previously closed URI; the new version is authoritative) and clears any desynchronised mark — the
29
+ # payload carries the client's full text, so the two views agree again.
23
30
  def open(uri:, bytes:, version:)
31
+ @desynchronized.delete(uri)
32
+ @dirty.delete(uri)
24
33
  @entries[uri] = Entry.new(uri: uri, bytes: bytes, version: version)
25
34
  end
26
35
 
27
- # Records a `textDocument/didChange` event under FULL sync. The full new buffer text replaces the entry.
28
- # If the client sends a `didChange` for a URI that was never opened (spec violation), the entry is still
36
+ # Records a full-text `textDocument/didChange`. The full new buffer text replaces the entry. If the
37
+ # client sends a `didChange` for a URI that was never opened (spec violation), the entry is still
29
38
  # created — defensive.
30
39
  def change(uri:, bytes:, version:)
40
+ @desynchronized.delete(uri)
41
+ @dirty[uri] = true
31
42
  @entries[uri] = Entry.new(uri: uri, bytes: bytes, version: version)
32
43
  end
33
44
 
45
+ # Applies a `textDocument/didChange` payload under INCREMENTAL sync. Each entry of `changes` is applied
46
+ # in order against the result of the previous one, per the LSP contract.
47
+ #
48
+ # On success the entry is replaced with the spliced text at the new version. On failure — a malformed
49
+ # change, or a range edit for a URI with no held buffer — the held text is left EXACTLY as it was and the
50
+ # URI is marked desynchronised: the server's view and the editor's view have diverged, and every position
51
+ # computed from the stale text would be wrong. Consumers check {#desynchronized?} and decline to answer
52
+ # rather than answering wrongly; the mark clears on the next `didOpen` or full-text change.
53
+ #
54
+ # @return [Boolean] true when the changes were applied.
55
+ def apply_changes(uri:, changes:, version:)
56
+ text = IncrementalSync.apply_all(@entries[uri]&.bytes, changes)
57
+ @desynchronized.delete(uri)
58
+ @dirty[uri] = true
59
+ @entries[uri] = Entry.new(uri: uri, bytes: text, version: version)
60
+ true
61
+ rescue IncrementalSync::UnappliableChange => e
62
+ @desynchronized[uri] = e.message
63
+ false
64
+ end
65
+
66
+ # @return [Boolean] true when the last `didChange` for `uri` could not be applied, so the held text no
67
+ # longer matches the editor's.
68
+ def desynchronized?(uri)
69
+ @desynchronized.key?(uri)
70
+ end
71
+
72
+ # @return [String, nil] why `uri` is desynchronised, for the log line; nil when it is in sync.
73
+ def desynchronization_reason(uri)
74
+ @desynchronized[uri]
75
+ end
76
+
34
77
  # Records a `textDocument/didClose` event. The entry is removed. Subsequent reads via `#[]` return nil.
35
78
  def close(uri:)
79
+ @desynchronized.delete(uri)
80
+ @dirty.delete(uri)
36
81
  @entries.delete(uri)
37
82
  end
38
83
 
84
+ # Records a `textDocument/didSave`. The client has written the buffer, so the held text and the file on
85
+ # disk agree again and the URI stops being dirty.
86
+ #
87
+ # Dirtiness is the PROTOCOL's notion — "the client told us it changed and has not told us it saved" —
88
+ # not a byte comparison against disk. A comparison would look stricter and be weaker: it races with the
89
+ # editor's own write, and the server's truth is what the client notified.
90
+ def save(uri:)
91
+ @dirty.delete(uri)
92
+ end
93
+
94
+ # @return [Boolean] true when `uri` has unsaved changes. A buffer that is dirty may only be published
95
+ # from an analysis that bound ITS bytes — see the publish-set invariant in
96
+ # `docs/design/20260517-language-server.md`.
97
+ def dirty?(uri)
98
+ @dirty.key?(uri)
99
+ end
100
+
39
101
  def [](uri)
40
102
  @entries[uri]
41
103
  end