rigortype 0.1.5 → 0.1.7

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -79
  3. data/lib/rigor/analysis/baseline.rb +347 -0
  4. data/lib/rigor/analysis/buffer_binding.rb +36 -0
  5. data/lib/rigor/analysis/check_rules.rb +68 -3
  6. data/lib/rigor/analysis/dependency_source_inference/index.rb +14 -1
  7. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +105 -0
  8. data/lib/rigor/analysis/dependency_source_inference/walker.rb +32 -12
  9. data/lib/rigor/analysis/project_scan.rb +39 -0
  10. data/lib/rigor/analysis/runner.rb +309 -22
  11. data/lib/rigor/analysis/worker_session.rb +14 -2
  12. data/lib/rigor/builtins/hkt_builtins.rb +342 -0
  13. data/lib/rigor/builtins/static_return_refinements.rb +142 -0
  14. data/lib/rigor/cache/store.rb +33 -3
  15. data/lib/rigor/cli/baseline_command.rb +377 -0
  16. data/lib/rigor/cli/lsp_command.rb +129 -0
  17. data/lib/rigor/cli/type_of_command.rb +44 -5
  18. data/lib/rigor/cli.rb +142 -13
  19. data/lib/rigor/configuration.rb +58 -2
  20. data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
  21. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  22. data/lib/rigor/environment/rbs_loader.rb +67 -2
  23. data/lib/rigor/environment/reporters.rb +40 -0
  24. data/lib/rigor/environment.rb +119 -9
  25. data/lib/rigor/flow_contribution/fact.rb +20 -10
  26. data/lib/rigor/inference/acceptance.rb +48 -3
  27. data/lib/rigor/inference/expression_typer.rb +64 -2
  28. data/lib/rigor/inference/hkt_body.rb +171 -0
  29. data/lib/rigor/inference/hkt_body_parser.rb +363 -0
  30. data/lib/rigor/inference/hkt_reducer.rb +256 -0
  31. data/lib/rigor/inference/hkt_registry.rb +223 -0
  32. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +125 -30
  33. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +32 -11
  34. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
  35. data/lib/rigor/inference/method_dispatcher.rb +174 -6
  36. data/lib/rigor/inference/narrowing.rb +103 -1
  37. data/lib/rigor/inference/project_patched_methods.rb +70 -0
  38. data/lib/rigor/inference/project_patched_scanner.rb +210 -0
  39. data/lib/rigor/inference/scope_indexer.rb +209 -19
  40. data/lib/rigor/inference/statement_evaluator.rb +172 -11
  41. data/lib/rigor/inference/synthetic_method_scanner.rb +94 -16
  42. data/lib/rigor/language_server/buffer_table.rb +63 -0
  43. data/lib/rigor/language_server/completion_provider.rb +438 -0
  44. data/lib/rigor/language_server/debouncer.rb +86 -0
  45. data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
  46. data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
  47. data/lib/rigor/language_server/folding_range_provider.rb +75 -0
  48. data/lib/rigor/language_server/hover_provider.rb +74 -0
  49. data/lib/rigor/language_server/hover_renderer.rb +312 -0
  50. data/lib/rigor/language_server/loop.rb +71 -0
  51. data/lib/rigor/language_server/project_context.rb +145 -0
  52. data/lib/rigor/language_server/selection_range_provider.rb +93 -0
  53. data/lib/rigor/language_server/server.rb +384 -0
  54. data/lib/rigor/language_server/signature_help_provider.rb +249 -0
  55. data/lib/rigor/language_server/synchronized_writer.rb +28 -0
  56. data/lib/rigor/language_server/uri.rb +40 -0
  57. data/lib/rigor/language_server.rb +29 -0
  58. data/lib/rigor/plugin/base.rb +63 -0
  59. data/lib/rigor/plugin/macro/heredoc_template.rb +127 -13
  60. data/lib/rigor/plugin/macro/trait_registry.rb +1 -1
  61. data/lib/rigor/plugin/manifest.rb +54 -7
  62. data/lib/rigor/plugin/registry.rb +19 -0
  63. data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
  64. data/lib/rigor/rbs_extended.rb +82 -2
  65. data/lib/rigor/sig_gen/generator.rb +12 -3
  66. data/lib/rigor/type/app.rb +107 -0
  67. data/lib/rigor/type.rb +1 -0
  68. data/lib/rigor/version.rb +1 -1
  69. data/sig/rigor/environment.rbs +10 -4
  70. data/sig/rigor/inference.rbs +2 -0
  71. data/sig/rigor.rbs +4 -1
  72. metadata +56 -1
@@ -4,6 +4,8 @@ require_relative "../reflection"
4
4
  require_relative "../type"
5
5
  require_relative "../flow_contribution"
6
6
  require_relative "../flow_contribution/merger"
7
+ require_relative "../builtins/hkt_builtins"
8
+ require_relative "../builtins/static_return_refinements"
7
9
  require_relative "method_dispatcher/constant_folding"
8
10
  require_relative "method_dispatcher/literal_string_folding"
9
11
  require_relative "method_dispatcher/shape_dispatch"
@@ -62,7 +64,7 @@ module Rigor
62
64
  # @param environment [Rigor::Environment, nil] required for
63
65
  # RBS-backed dispatch; when nil only constant folding can fire.
64
66
  # @return [Rigor::Type, nil] inferred result type, or `nil` for "no rule".
65
- def dispatch(receiver_type:, method_name:, arg_types:,
67
+ def dispatch(receiver_type:, method_name:, arg_types:, # rubocop:disable Metrics/MethodLength
66
68
  block_type: nil, environment: nil,
67
69
  call_node: nil, scope: nil)
68
70
  return nil if receiver_type.nil?
@@ -88,6 +90,32 @@ module Rigor
88
90
  plugin_result = try_plugin_contribution(call_node, scope)
89
91
  return plugin_result if plugin_result
90
92
 
93
+ # ADR-20 slice 3 — Rigor-bundled HKT-builtin return-
94
+ # type tier. Sits ABOVE `RbsDispatch.try_dispatch` so
95
+ # the handful of stdlib methods whose upstream RBS
96
+ # signature is `untyped` but whose runtime shape Rigor
97
+ # models via a Lightweight HKT (`json::value`,
98
+ # eventually `dry_monads::result`, …) get the reduced
99
+ # type instead of `Dynamic[Top]`. The table that
100
+ # populates this tier lives in
101
+ # `Rigor::Builtins::HktBuiltins::METHOD_RETURN_OVERRIDES`;
102
+ # plugin-supplied per-method overrides are out of
103
+ # scope for slice 3 and continue to flow through the
104
+ # `try_plugin_contribution` tier above.
105
+ hkt_builtin_result = try_hkt_builtin_return(receiver_type, method_name, arg_types, environment)
106
+ return hkt_builtin_result if hkt_builtin_result
107
+
108
+ # Rigor-bundled static refinement tier. Sits between HKT
109
+ # and RBS so stdlib methods whose upstream RBS is broader
110
+ # than the documented behaviour (e.g. `Kernel#__dir__`
111
+ # declared `() -> String?` when the documented return is
112
+ # `non-empty-string | nil`) get the tightened type
113
+ # without modifying the vendored `ruby/rbs` submodule.
114
+ # The override table lives in
115
+ # `Rigor::Builtins::StaticReturnRefinements::OVERRIDES`.
116
+ static_refinement = try_static_refinement(receiver_type, method_name, arg_types)
117
+ return static_refinement if static_refinement
118
+
91
119
  rbs_result = RbsDispatch.try_dispatch(
92
120
  receiver: receiver_type, method_name: method_name, args: arg_types,
93
121
  environment: environment, block_type: block_type
@@ -111,6 +139,20 @@ module Rigor
111
139
  )
112
140
  return synthetic_result if synthetic_result
113
141
 
142
+ # ADR-17 slice 2 — project-side patched-method tier.
143
+ # Sits BELOW the substrate / plugin tiers and ABOVE
144
+ # dependency-source inference per ADR-17 § "Inference
145
+ # contract". When the user's `pre_eval:` list named a
146
+ # file that re-opens a class (e.g.,
147
+ # `lib/core_ext/string_extensions.rb` declaring
148
+ # `class String; def to_url; end; end`), the pre-pass
149
+ # populated `ProjectPatchedMethods` with the `(class,
150
+ # method, kind)` triple; this tier surfaces it as
151
+ # `Dynamic[top]` so the patched call resolves
152
+ # cross-file without `call.undefined-method`.
153
+ patched_result = try_project_patched_method(receiver_type, method_name, environment)
154
+ return patched_result if patched_result
155
+
114
156
  # ADR-10 slice 2b-ii — dependency-source inference tier.
115
157
  # Sits BELOW RBS dispatch (RBS / RBS::Inline / generated
116
158
  # stubs / plugin contracts always win) and ABOVE the
@@ -217,6 +259,80 @@ module Rigor
217
259
  # keeps moving — the run-level diagnostic envelope (per
218
260
  # ADR-2 § "Plugin Trust and I/O Policy") is owned by
219
261
  # `Analysis::Runner#plugin_emitted_diagnostics`.
262
+ # ADR-20 slice 3 — looks up the receiver / method pair
263
+ # in {Rigor::Builtins::HktBuiltins::METHOD_RETURN_OVERRIDES}
264
+ # and returns the reduced HKT type. Only fires when the
265
+ # receiver is a {Rigor::Type::Singleton} (the
266
+ # `JSON.parse` shape) and the registry-backed reduction
267
+ # succeeds; returns `nil` otherwise so the dispatcher
268
+ # falls through to RBS.
269
+ def try_hkt_builtin_return(receiver_type, method_name, arg_types, environment)
270
+ return nil if environment.nil?
271
+ return nil unless receiver_type.is_a?(Type::Singleton)
272
+
273
+ Rigor::Builtins::HktBuiltins.method_return_override(
274
+ class_name: receiver_type.class_name,
275
+ method_name: method_name,
276
+ kind: :singleton,
277
+ arg_types: arg_types,
278
+ hkt_registry: environment.hkt_registry
279
+ )
280
+ end
281
+
282
+ # Consults the Rigor-bundled static refinement table for a
283
+ # (owner-class, method-name, kind) entry. Kernel methods
284
+ # are mixed into every non-BasicObject class, so an
285
+ # implicit-self `__dir__` call (receiver_type =
286
+ # Nominal[ClassName]) is matched by looking up Kernel as
287
+ # the owner. Explicit `Kernel.__dir__` (receiver_type =
288
+ # Singleton[Kernel]) and instance-side calls
289
+ # (receiver_type = Nominal[Klass]) share the `:both` row.
290
+ #
291
+ # The receiver-side ancestor check is intentionally cheap:
292
+ # any non-BasicObject Nominal / Singleton matches every
293
+ # Kernel-owned override. BasicObject explicitly excludes
294
+ # Kernel and is therefore rejected. The narrow risk of a
295
+ # user-defined `def __dir__` shadowing Kernel's method
296
+ # would also alter the runtime answer; users with that
297
+ # configuration opt out via a `signature_paths` overlay
298
+ # declaring their own return type.
299
+ def try_static_refinement(receiver_type, method_name, arg_types)
300
+ candidates = Rigor::Builtins::StaticReturnRefinements.owners_for(method_name)
301
+ return nil if candidates.empty?
302
+
303
+ owner = static_refinement_owner_for(receiver_type, candidates)
304
+ return nil unless owner
305
+
306
+ kind = receiver_type.is_a?(Type::Singleton) ? :singleton : :instance
307
+ Rigor::Builtins::StaticReturnRefinements.lookup(
308
+ owner_class_name: owner,
309
+ method_name: method_name,
310
+ kind: kind,
311
+ arg_types: arg_types
312
+ )
313
+ end
314
+
315
+ # Picks the most specific override owner the receiver
316
+ # honours. For Kernel-owned overrides the receiver simply
317
+ # needs to be a real-class Nominal / Singleton (i.e. not
318
+ # BasicObject and not a Dynamic / Constant / shape carrier
319
+ # — those carriers go through their own narrower tiers).
320
+ def static_refinement_owner_for(receiver_type, candidates)
321
+ receiver_class = static_refinement_class_for(receiver_type)
322
+ return nil unless receiver_class
323
+
324
+ return "Kernel" if candidates.include?("Kernel") && receiver_class != "BasicObject"
325
+
326
+ candidates.find { |owner| owner == receiver_class }
327
+ end
328
+
329
+ def static_refinement_class_for(receiver_type)
330
+ case receiver_type
331
+ when Type::Singleton, Type::Nominal
332
+ receiver_type.class_name
333
+ end
334
+ end
335
+
220
336
  def try_plugin_contribution(call_node, scope)
221
337
  return nil if call_node.nil? || scope.nil?
222
338
 
@@ -334,6 +450,30 @@ module Rigor
334
450
  end
335
451
  end
336
452
 
453
+ # ADR-17 slice 2 — project-side patched-method tier.
454
+ # Slice 3a uses the registry's heuristic-extracted
455
+ # `return_type` (populated via the same
456
+ # `Analysis::DependencySourceInference::ReturnTypeHeuristic`
457
+ # the ADR-10 walker uses): a `def to_url; "hello"; end`
458
+ # patched onto `String` now resolves `s.to_url` to
459
+ # `Dynamic[Nominal[String]]` instead of the pre-3a
460
+ # `Dynamic[Top]`. Falls back to `Dynamic[Top]` when the
461
+ # heuristic declined (non-literal tail expression).
462
+ def try_project_patched_method(receiver_type, method_name, environment)
463
+ registry = environment&.project_patched_methods
464
+ return nil if registry.nil? || registry.empty?
465
+
466
+ class_name = synthetic_method_class_name(receiver_type)
467
+ return nil if class_name.nil?
468
+
469
+ kind = receiver_type.is_a?(Type::Singleton) ? :singleton : :instance
470
+ entry = registry.lookup(class_name: class_name, method_name: method_name, kind: kind)
471
+ return nil if entry.nil?
472
+ return Type::Combinator.untyped if entry.return_type.nil?
473
+
474
+ Type::Combinator.dynamic(entry.return_type)
475
+ end
476
+
337
477
  def try_dependency_source(receiver_type, method_name, environment)
338
478
  index = environment&.dependency_source_index
339
479
  return nil if index.nil? || index.empty?
@@ -350,8 +490,8 @@ module Rigor
350
490
  # inference must not contribute behind their backs.
351
491
  return nil if plugin_owns_receiver?(class_name, environment)
352
492
 
353
- contribution_kind = index.contribution_for(class_name: class_name, method_name: method_name)
354
- return Type::Combinator.untyped if contribution_kind
493
+ contribution = index.contribution_for(class_name: class_name, method_name: method_name)
494
+ return dependency_source_return_type(contribution) if contribution
355
495
 
356
496
  # ADR-10 5b — β budget semantics. On a catalog miss,
357
497
  # if the receiver class belongs to a budget-exceeded
@@ -403,6 +543,17 @@ module Rigor
403
543
  )
404
544
  end
405
545
 
546
+ # Maps a {DependencySourceInference::Walker::CatalogEntry}
547
+ # to the Type the dispatcher returns at the call site.
548
+ # When the heuristic recovered a static facet, wrap it in
549
+ # `Dynamic[T]` per ADR-10's gem-boundary contract;
550
+ # otherwise fall back to the pre-heuristic `Dynamic[top]`.
551
+ def dependency_source_return_type(contribution)
552
+ return Type::Combinator.untyped if contribution.return_type.nil?
553
+
554
+ Type::Combinator.dynamic(contribution.return_type)
555
+ end
556
+
406
557
  # Composite preflight for {#record_boundary_cross_if_applicable}.
407
558
  # Returns the receiver class name only when every prerequisite
408
559
  # for emitting the diagnostic is satisfied (environment carries
@@ -506,21 +657,38 @@ module Rigor
506
657
  fallback_receiver = user_class_fallback_receiver(receiver_type, environment)
507
658
  return nil if fallback_receiver.nil?
508
659
 
660
+ # Preserve the ORIGINAL receiver type as the `self`
661
+ # substitution so `Kernel#dup: () -> self` and other
662
+ # `self`-returning methods route through Object's RBS
663
+ # while still returning the caller's type rather than
664
+ # `Object`. Without this, `base = self.dup` inside a
665
+ # `Bundler::URI::Generic` instance method types `base`
666
+ # as `Object` because `Bundler::URI::Generic` is not in
667
+ # RBS and the fallback's `self` resolves to Object.
509
668
  RbsDispatch.try_dispatch(
510
669
  receiver: fallback_receiver,
511
670
  method_name: method_name,
512
671
  args: arg_types,
513
672
  environment: environment,
514
- block_type: block_type
673
+ block_type: block_type,
674
+ self_type_override: receiver_type
515
675
  )
516
676
  end
517
677
 
518
678
  def user_class_fallback_receiver(receiver_type, environment)
519
679
  case receiver_type
520
680
  when Type::Nominal
521
- return nil if Rigor::Reflection.rbs_class_known?(receiver_type.class_name, environment: environment)
681
+ # Modules: even when RBS knows the module, an instance
682
+ # method on a mixin-only module (e.g. `PP::ObjectMixin`)
683
+ # observes Kernel / Object methods through every concrete
684
+ # includer's ancestor chain. Route through the
685
+ # `Nominal[Object]` fallback so `self.inspect` /
686
+ # `self.respond_to?` / `self.class` resolve cleanly when
687
+ # the module itself does not declare them.
688
+ known = Rigor::Reflection.rbs_class_known?(receiver_type.class_name, environment: environment)
689
+ return environment.nominal_for_name("Object") if !known || environment.rbs_module?(receiver_type.class_name)
522
690
 
523
- environment.nominal_for_name("Object")
691
+ nil
524
692
  when Type::Singleton
525
693
  return nil if Rigor::Reflection.rbs_class_known?(receiver_type.class_name, environment: environment)
526
694
 
@@ -950,7 +950,7 @@ module Rigor
950
950
  end
951
951
 
952
952
  def simple_dispatch_name?(name)
953
- %i[nil? ! is_a? kind_of? instance_of? == != ===].include?(name)
953
+ %i[nil? ! is_a? kind_of? instance_of? == != === =~].include?(name)
954
954
  end
955
955
 
956
956
  def dispatch_call_simple(node, scope, name)
@@ -960,9 +960,111 @@ module Rigor
960
960
  when :instance_of? then analyse_class_predicate(node, scope, exact: true)
961
961
  when :==, :!= then analyse_equality_predicate(node, scope, equality: name)
962
962
  when :=== then analyse_case_equality_predicate(node, scope)
963
+ when :=~ then analyse_regex_match_predicate(node, scope)
963
964
  end
964
965
  end
965
966
 
967
+ # Survey item (b): `/regex/ =~ str` and `str =~ /regex/`
968
+ # bind the regex match-data globals on each edge.
969
+ #
970
+ # - Truthy edge (`=~` returned an Integer position — the
971
+ # match succeeded): `$~` to `Nominal[MatchData]`; `$&`
972
+ # and `$1..$N` (where N is the number of capture groups
973
+ # in the regex source) to `Nominal[String]`. This is the
974
+ # same optimistic-narrowing shape the existing
975
+ # `analyse_match_write` uses for named captures inside
976
+ # `if /(?<x>...)/ =~ str` — optional groups in the
977
+ # regex source (`(\d+)?`) would bind `$N` to `nil` at
978
+ # runtime, but the floor here matches the common idiom
979
+ # (required captures) and lets `unless /(\d+)/ =~ s;
980
+ # raise; end; $1.to_i` resolve cleanly.
981
+ # - Falsey edge (`=~` returned nil — no match): `$~` and
982
+ # every numbered / back-reference global bound to
983
+ # `Constant<nil>`.
984
+ #
985
+ # Returns nil (no narrowing) when the receiver / argument
986
+ # pair does not include a `RegularExpressionNode` literal
987
+ # we can count.
988
+ def analyse_regex_match_predicate(node, scope)
989
+ return nil if node.arguments.nil?
990
+ return nil unless node.arguments.arguments.size == 1
991
+
992
+ regex_node = regex_match_literal(node.receiver, node.arguments.arguments.first)
993
+ return nil if regex_node.nil?
994
+
995
+ group_count = count_regex_capture_groups(regex_node.unescaped)
996
+ regex_match_predicate_scopes(scope, group_count)
997
+ end
998
+
999
+ def regex_match_literal(left, right)
1000
+ return left if left.is_a?(Prism::RegularExpressionNode)
1001
+ return right if right.is_a?(Prism::RegularExpressionNode)
1002
+
1003
+ nil
1004
+ end
1005
+
1006
+ # Curated set of back-reference globals bound by every
1007
+ # `=~`. Numbered references (`$1..$N`) are handled
1008
+ # separately because N depends on the regex source.
1009
+ REGEX_MATCH_GLOBALS = %i[$~ $& $` $' $+].freeze
1010
+ private_constant :REGEX_MATCH_GLOBALS
1011
+
1012
+ def regex_match_predicate_scopes(scope, group_count)
1013
+ string_t = Type::Combinator.nominal_of("String")
1014
+ match_data_t = Type::Combinator.nominal_of("MatchData")
1015
+ nil_t = Type::Combinator.constant_of(nil)
1016
+
1017
+ truthy = scope
1018
+ falsey = scope
1019
+ truthy = truthy.with_global(:$~, match_data_t)
1020
+ falsey = falsey.with_global(:$~, nil_t)
1021
+ REGEX_MATCH_GLOBALS.each do |name|
1022
+ next if name == :$~
1023
+
1024
+ truthy = truthy.with_global(name, string_t)
1025
+ falsey = falsey.with_global(name, nil_t)
1026
+ end
1027
+ group_count.times do |i|
1028
+ name = :"$#{i + 1}"
1029
+ truthy = truthy.with_global(name, string_t)
1030
+ falsey = falsey.with_global(name, nil_t)
1031
+ end
1032
+ [truthy, falsey]
1033
+ end
1034
+
1035
+ # Counts capture groups (numbered + named — both
1036
+ # contribute to `$1..$N`) in a regex source. Backslash
1037
+ # escapes are skipped; non-capturing `(?:...)`, lookahead
1038
+ # `(?=...)` / `(?!...)`, and lookbehind `(?<=...)` /
1039
+ # `(?<!...)` do NOT count. Named groups `(?<name>...)`
1040
+ # DO count. The walker is intentionally light — it does
1041
+ # not parse the regex AST, just scans char-by-char — so
1042
+ # exotic constructs that overlap the lookaround syntax
1043
+ # may miscount; the unsoundness is bounded (over- or
1044
+ # under-binding a few `$N` globals) and we already accept
1045
+ # the same shape of unsoundness for `analyse_match_write`.
1046
+ def count_regex_capture_groups(source)
1047
+ i = 0
1048
+ total = 0
1049
+ length = source.length
1050
+ while i < length
1051
+ c = source[i]
1052
+ if c == "\\"
1053
+ i += 2
1054
+ next
1055
+ end
1056
+ if c == "("
1057
+ if source[i + 1] == "?"
1058
+ total += 1 if source[i + 2] == "<" && source[i + 3] != "=" && source[i + 3] != "!"
1059
+ else
1060
+ total += 1
1061
+ end
1062
+ end
1063
+ i += 1
1064
+ end
1065
+ total
1066
+ end
1067
+
966
1068
  def dispatch_call_numeric(node, scope, name)
967
1069
  if COMPARISON_OPERATORS.include?(name)
968
1070
  analyse_comparison_predicate(node, scope, comparator: name)
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Inference
5
+ # ADR-17 § "Inference contract" — project-wide patched-method
6
+ # registry populated by the pre-eval pre-pass (slice 2) from
7
+ # the user's `.rigor.yml` `pre_eval:` list.
8
+ #
9
+ # Each entry records one `def` declaration the pre-pass
10
+ # observed inside a class / module body. The dispatcher's
11
+ # `try_project_patched_method` tier consults this registry
12
+ # between the plugin tier and the dependency-source tier so
13
+ # project-side `lib/core_ext/string_extensions.rb` patches
14
+ # are visible to cross-file dispatch.
15
+ #
16
+ # Slice 2 ships the registry at the **floor**: the dispatcher
17
+ # answers `Type::Combinator.untyped` (Dynamic[Top]) on a hit;
18
+ # return-type inference for patched methods stays deferred
19
+ # (a separate slice when concrete demand surfaces — most
20
+ # real-world `core_ext` patches return shapes the analyzer
21
+ # could heuristically extract via the same machinery the
22
+ # ADR-10 walker uses, but slice 2 keeps the surface narrow).
23
+ class ProjectPatchedMethods
24
+ # Frozen value-object recording one `def` observed by the
25
+ # pre-pass. `class_name` is the qualified prefix
26
+ # (`"String"`, `"Foo::Bar"`); `method_name` is the
27
+ # declared name; `kind` is `:instance` or `:singleton`;
28
+ # `source_path` / `source_line` carry attribution for
29
+ # diagnostics; `return_type` is the
30
+ # {Analysis::DependencySourceInference::ReturnTypeHeuristic}-
31
+ # extracted static facet (a `Rigor::Type::*`) or `nil`
32
+ # when the heuristic declined. The dispatcher wraps a
33
+ # non-nil `return_type` in `Dynamic[T]`; a `nil`
34
+ # `return_type` falls back to `Dynamic[top]`.
35
+ Entry = Data.define(:class_name, :method_name, :kind, :source_path, :source_line, :return_type) do
36
+ def initialize(class_name:, method_name:, kind:, source_path:, source_line:, return_type: nil)
37
+ super
38
+ end
39
+ end
40
+
41
+ attr_reader :by_key
42
+
43
+ # @param entries [Array<Entry>] flat list of declarations
44
+ # observed during the pre-pass. First-write-wins on
45
+ # `(class_name, method_name, kind)` duplicates so the
46
+ # `pre-eval.duplicate-declaration` diagnostic emission
47
+ # stays decoupled from registry behaviour.
48
+ def initialize(entries: [])
49
+ @by_key = entries.each_with_object({}) do |entry, acc|
50
+ key = [entry.class_name, entry.method_name, entry.kind]
51
+ acc[key] ||= entry
52
+ end.freeze
53
+ freeze
54
+ end
55
+
56
+ # @return [Entry, nil] the recorded entry for the given
57
+ # `(class_name, method_name, kind)` triple, or `nil`
58
+ # when no pre-eval file declared it.
59
+ def lookup(class_name:, method_name:, kind:)
60
+ @by_key[[class_name, method_name, kind]]
61
+ end
62
+
63
+ def empty?
64
+ @by_key.empty?
65
+ end
66
+
67
+ EMPTY = new.freeze
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "project_patched_methods"
6
+ require_relative "../analysis/dependency_source_inference/return_type_heuristic"
7
+
8
+ module Rigor
9
+ module Inference
10
+ # ADR-17 slice 2 — pre-pass scanner. Walks every file the user
11
+ # listed under `pre_eval:` and harvests every `def` /
12
+ # `def self.` declaration inside a class / module body into a
13
+ # {ProjectPatchedMethods} registry the dispatcher consults
14
+ # below the plugin tier.
15
+ #
16
+ # The walker is intentionally a strict subset of
17
+ # {Rigor::Inference::ScopeIndexer}'s machinery: it only needs
18
+ # `class C; def m; end; end` shape recognition, not full
19
+ # inference. Parse errors degrade to a fail-soft `:warning`
20
+ # `pre-eval.parse-error` diagnostic accumulated alongside
21
+ # the registry; per ADR-17 § "Failure modes" a parse failure
22
+ # in a pre-eval file MUST NOT abort the rest of the run.
23
+ module ProjectPatchedScanner
24
+ # Frozen scan outcome carrying the populated registry and
25
+ # the per-file warnings the runner emits at run start.
26
+ class Result < Data.define(:registry, :diagnostics)
27
+ def initialize(registry:, diagnostics: [])
28
+ super(
29
+ registry: registry,
30
+ diagnostics: diagnostics.freeze
31
+ )
32
+ end
33
+ end
34
+
35
+ module_function
36
+
37
+ # @param paths [Array<String>] absolute paths to the
38
+ # pre-eval files. The runner has already validated that
39
+ # each path exists (slice-1 `pre-eval.file-not-found`
40
+ # `:error` covers missing entries); the scanner does NOT
41
+ # re-check existence.
42
+ # @param buffer [Rigor::Analysis::BufferBinding, nil]
43
+ # editor-mode buffer binding. When set, the scanner reads
44
+ # the buffer's physical bytes if a pre-eval entry matches
45
+ # the logical path, so users editing a monkey-patch file
46
+ # see the in-flight version in their analysis.
47
+ # @return [Result] the populated registry plus any
48
+ # per-file warnings.
49
+ def scan(paths, buffer: nil)
50
+ entries = []
51
+ diagnostics = []
52
+ paths.each { |path| scan_file(path, entries, diagnostics, buffer) }
53
+ diagnostics.concat(duplicate_declaration_diagnostics(entries))
54
+ Result.new(
55
+ registry: ProjectPatchedMethods.new(entries: entries),
56
+ diagnostics: diagnostics
57
+ )
58
+ end
59
+
60
+ # ADR-17 § "Failure modes" — when two pre-eval entries
61
+ # declare the same `(class_name, method_name, kind)` triple,
62
+ # emit one `:info` `pre-eval.duplicate-declaration`
63
+ # diagnostic per collision. The registry's first-write-wins
64
+ # behaviour is unchanged; the diagnostic just makes the
65
+ # shadowing visible so users notice when a later patch
66
+ # is silently masked.
67
+ def duplicate_declaration_diagnostics(entries)
68
+ seen = {}
69
+ entries.each_with_object([]) do |entry, acc|
70
+ key = [entry.class_name, entry.method_name, entry.kind]
71
+ if (first = seen[key])
72
+ acc << build_diagnostic(
73
+ path: entry.source_path,
74
+ line: entry.source_line,
75
+ column: 1,
76
+ severity: :info,
77
+ rule: "pre-eval.duplicate-declaration",
78
+ message: "pre-eval duplicate declaration: " \
79
+ "#{entry.class_name}##{entry.method_name} " \
80
+ "(#{entry.kind}) is already declared at " \
81
+ "#{first.source_path}:#{first.source_line}. " \
82
+ "The first declaration wins; this entry is shadowed."
83
+ )
84
+ else
85
+ seen[key] = entry
86
+ end
87
+ end
88
+ end
89
+ private_class_method :duplicate_declaration_diagnostics
90
+
91
+ def scan_file(path, entries, diagnostics, buffer = nil)
92
+ physical = buffer ? buffer.resolve(path) : path
93
+ parse_result =
94
+ if physical == path
95
+ Prism.parse_file(path)
96
+ else
97
+ Prism.parse(File.read(physical), filepath: path)
98
+ end
99
+ unless parse_result.errors.empty?
100
+ diagnostics << parse_error_diagnostic(path, parse_result.errors)
101
+ return
102
+ end
103
+
104
+ walk_node(parse_result.value, [], false, path, entries)
105
+ rescue StandardError => e
106
+ diagnostics << build_diagnostic(
107
+ path: path, line: 1, column: 1,
108
+ severity: :warning,
109
+ rule: "pre-eval.parse-error",
110
+ message: "rigor: failed to read pre_eval entry #{path.inspect}: " \
111
+ "#{e.class}: #{e.message}. Pre-evaluation skipped for this file; " \
112
+ "the rest of the run proceeds."
113
+ )
114
+ end
115
+ private_class_method :scan_file
116
+
117
+ def parse_error_diagnostic(path, errors)
118
+ first = errors.first
119
+ line = first.respond_to?(:location) ? first.location&.start_line || 1 : 1
120
+ build_diagnostic(
121
+ path: path, line: line, column: 1,
122
+ severity: :warning,
123
+ rule: "pre-eval.parse-error",
124
+ message: "rigor: pre_eval entry #{path.inspect} has a parse error " \
125
+ "(#{first&.message}). Pre-evaluation skipped for this file; " \
126
+ "the rest of the run proceeds."
127
+ )
128
+ end
129
+ private_class_method :parse_error_diagnostic
130
+
131
+ # Builds a diagnostic Hash-shape the runner translates to a
132
+ # `Rigor::Analysis::Diagnostic`. The scanner intentionally
133
+ # does NOT depend on the analysis layer (it's a pre-pass);
134
+ # the runner adapts at the call site.
135
+ def build_diagnostic(path:, line:, column:, severity:, rule:, message:)
136
+ { path: path, line: line, column: column, severity: severity, rule: rule, message: message }
137
+ end
138
+ private_class_method :build_diagnostic
139
+
140
+ def walk_node(node, qualified_prefix, in_singleton_class, source_path, entries)
141
+ return unless node.is_a?(Prism::Node)
142
+
143
+ case node
144
+ when Prism::ClassNode, Prism::ModuleNode
145
+ descend_class_or_module(node, qualified_prefix, in_singleton_class, source_path, entries)
146
+ when Prism::SingletonClassNode
147
+ descend_singleton_class(node, qualified_prefix, source_path, entries)
148
+ when Prism::DefNode
149
+ record_def_node(node, qualified_prefix, in_singleton_class, source_path, entries)
150
+ else
151
+ walk_children(node, qualified_prefix, in_singleton_class, source_path, entries)
152
+ end
153
+ end
154
+ private_class_method :walk_node
155
+
156
+ def walk_children(node, qualified_prefix, in_singleton_class, source_path, entries)
157
+ node.compact_child_nodes.each do |child|
158
+ walk_node(child, qualified_prefix, in_singleton_class, source_path, entries)
159
+ end
160
+ end
161
+ private_class_method :walk_children
162
+
163
+ def descend_class_or_module(node, qualified_prefix, in_singleton_class, source_path, entries)
164
+ name = qualified_name_for(node.constant_path)
165
+ if name && node.body
166
+ walk_node(node.body, qualified_prefix + [name], in_singleton_class, source_path, entries)
167
+ else
168
+ walk_children(node, qualified_prefix, in_singleton_class, source_path, entries)
169
+ end
170
+ end
171
+ private_class_method :descend_class_or_module
172
+
173
+ def descend_singleton_class(node, qualified_prefix, source_path, entries)
174
+ if node.expression.is_a?(Prism::SelfNode) && node.body
175
+ walk_node(node.body, qualified_prefix, true, source_path, entries)
176
+ else
177
+ walk_children(node, qualified_prefix, false, source_path, entries)
178
+ end
179
+ end
180
+ private_class_method :descend_singleton_class
181
+
182
+ def record_def_node(node, qualified_prefix, in_singleton_class, source_path, entries)
183
+ return if qualified_prefix.empty?
184
+
185
+ class_name = qualified_prefix.join("::")
186
+ kind = node.receiver.is_a?(Prism::SelfNode) || in_singleton_class ? :singleton : :instance
187
+ line = node.location&.start_line || 1
188
+ return_type = Analysis::DependencySourceInference::ReturnTypeHeuristic.extract(node)
189
+ entries << ProjectPatchedMethods::Entry.new(
190
+ class_name: class_name, method_name: node.name, kind: kind,
191
+ source_path: source_path, source_line: line,
192
+ return_type: return_type
193
+ )
194
+ end
195
+ private_class_method :record_def_node
196
+
197
+ def qualified_name_for(node)
198
+ case node
199
+ when Prism::ConstantReadNode then node.name.to_s
200
+ when Prism::ConstantPathNode
201
+ parent = node.parent.nil? ? nil : qualified_name_for(node.parent)
202
+ return nil if !node.parent.nil? && parent.nil?
203
+
204
+ parent.nil? ? node.name.to_s : "#{parent}::#{node.name}"
205
+ end
206
+ end
207
+ private_class_method :qualified_name_for
208
+ end
209
+ end
210
+ end