rigortype 0.1.4 → 0.1.6

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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +69 -56
  3. data/lib/rigor/analysis/buffer_binding.rb +36 -0
  4. data/lib/rigor/analysis/check_rules.rb +11 -1
  5. data/lib/rigor/analysis/dependency_source_inference/index.rb +14 -1
  6. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +105 -0
  7. data/lib/rigor/analysis/dependency_source_inference/walker.rb +32 -12
  8. data/lib/rigor/analysis/fact_store.rb +15 -3
  9. data/lib/rigor/analysis/project_scan.rb +39 -0
  10. data/lib/rigor/analysis/result.rb +11 -3
  11. data/lib/rigor/analysis/run_stats.rb +193 -0
  12. data/lib/rigor/analysis/runner.rb +681 -19
  13. data/lib/rigor/analysis/worker_session.rb +339 -0
  14. data/lib/rigor/builtins/hkt_builtins.rb +342 -0
  15. data/lib/rigor/builtins/imported_refinements.rb +6 -2
  16. data/lib/rigor/builtins/regex_refinement.rb +17 -12
  17. data/lib/rigor/builtins/static_return_refinements.rb +120 -0
  18. data/lib/rigor/cache/rbs_descriptor.rb +3 -1
  19. data/lib/rigor/cache/store.rb +72 -9
  20. data/lib/rigor/cli/lsp_command.rb +129 -0
  21. data/lib/rigor/cli/type_of_command.rb +44 -5
  22. data/lib/rigor/cli.rb +122 -10
  23. data/lib/rigor/configuration.rb +168 -7
  24. data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
  25. data/lib/rigor/environment/class_registry.rb +12 -3
  26. data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
  27. data/lib/rigor/environment/lockfile_resolver.rb +125 -0
  28. data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
  29. data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
  30. data/lib/rigor/environment/rbs_loader.rb +238 -7
  31. data/lib/rigor/environment/reflection.rb +152 -0
  32. data/lib/rigor/environment/reporters.rb +40 -0
  33. data/lib/rigor/environment.rb +179 -10
  34. data/lib/rigor/inference/acceptance.rb +83 -4
  35. data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
  36. data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
  37. data/lib/rigor/inference/expression_typer.rb +59 -2
  38. data/lib/rigor/inference/hkt_body.rb +171 -0
  39. data/lib/rigor/inference/hkt_body_parser.rb +363 -0
  40. data/lib/rigor/inference/hkt_reducer.rb +256 -0
  41. data/lib/rigor/inference/hkt_registry.rb +223 -0
  42. data/lib/rigor/inference/macro_block_self_type.rb +96 -0
  43. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -29
  44. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
  45. data/lib/rigor/inference/method_dispatcher/method_folding.rb +18 -1
  46. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +126 -31
  47. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
  48. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -40
  49. data/lib/rigor/inference/method_dispatcher.rb +282 -6
  50. data/lib/rigor/inference/method_parameter_binder.rb +21 -11
  51. data/lib/rigor/inference/narrowing.rb +127 -8
  52. data/lib/rigor/inference/project_patched_methods.rb +70 -0
  53. data/lib/rigor/inference/project_patched_scanner.rb +210 -0
  54. data/lib/rigor/inference/scope_indexer.rb +156 -12
  55. data/lib/rigor/inference/statement_evaluator.rb +106 -6
  56. data/lib/rigor/inference/synthetic_method.rb +86 -0
  57. data/lib/rigor/inference/synthetic_method_index.rb +82 -0
  58. data/lib/rigor/inference/synthetic_method_scanner.rb +599 -0
  59. data/lib/rigor/language_server/buffer_table.rb +63 -0
  60. data/lib/rigor/language_server/completion_provider.rb +438 -0
  61. data/lib/rigor/language_server/debouncer.rb +86 -0
  62. data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
  63. data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
  64. data/lib/rigor/language_server/folding_range_provider.rb +75 -0
  65. data/lib/rigor/language_server/hover_provider.rb +74 -0
  66. data/lib/rigor/language_server/hover_renderer.rb +312 -0
  67. data/lib/rigor/language_server/loop.rb +71 -0
  68. data/lib/rigor/language_server/project_context.rb +145 -0
  69. data/lib/rigor/language_server/selection_range_provider.rb +93 -0
  70. data/lib/rigor/language_server/server.rb +384 -0
  71. data/lib/rigor/language_server/signature_help_provider.rb +249 -0
  72. data/lib/rigor/language_server/synchronized_writer.rb +28 -0
  73. data/lib/rigor/language_server/uri.rb +40 -0
  74. data/lib/rigor/language_server.rb +29 -0
  75. data/lib/rigor/plugin/base.rb +63 -0
  76. data/lib/rigor/plugin/blueprint.rb +60 -0
  77. data/lib/rigor/plugin/loader.rb +3 -1
  78. data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
  79. data/lib/rigor/plugin/macro/external_file.rb +143 -0
  80. data/lib/rigor/plugin/macro/heredoc_template.rb +315 -0
  81. data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
  82. data/lib/rigor/plugin/macro.rb +31 -0
  83. data/lib/rigor/plugin/manifest.rb +127 -9
  84. data/lib/rigor/plugin/registry.rb +51 -2
  85. data/lib/rigor/plugin.rb +1 -0
  86. data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
  87. data/lib/rigor/rbs_extended.rb +82 -2
  88. data/lib/rigor/sig_gen/generator.rb +12 -3
  89. data/lib/rigor/trinary.rb +15 -11
  90. data/lib/rigor/type/app.rb +107 -0
  91. data/lib/rigor/type/bot.rb +6 -3
  92. data/lib/rigor/type/combinator.rb +12 -1
  93. data/lib/rigor/type/integer_range.rb +7 -7
  94. data/lib/rigor/type/refined.rb +18 -12
  95. data/lib/rigor/type/top.rb +4 -3
  96. data/lib/rigor/type.rb +1 -0
  97. data/lib/rigor/type_node/generic.rb +7 -1
  98. data/lib/rigor/type_node/identifier.rb +9 -1
  99. data/lib/rigor/type_node/string_literal.rb +4 -1
  100. data/lib/rigor/version.rb +1 -1
  101. data/sig/rigor/environment.rbs +11 -4
  102. data/sig/rigor/inference.rbs +2 -0
  103. data/sig/rigor/plugin/blueprint.rbs +7 -0
  104. data/sig/rigor/plugin/manifest.rbs +1 -1
  105. data/sig/rigor/plugin/registry.rbs +14 -1
  106. data/sig/rigor.rbs +37 -2
  107. metadata +92 -1
@@ -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
@@ -177,8 +177,10 @@ module Rigor
177
177
  return if def_node.body.nil? || qualified_prefix.empty?
178
178
 
179
179
  class_name = qualified_prefix.join("::")
180
+ singleton = def_node.receiver.is_a?(Prism::SelfNode) ||
181
+ def_receiver_targets_lexical_self?(def_node.receiver, qualified_prefix)
180
182
  self_type =
181
- if def_node.receiver.is_a?(Prism::SelfNode)
183
+ if singleton
182
184
  Type::Combinator.singleton_of(class_name)
183
185
  else
184
186
  Type::Combinator.nominal_of(class_name)
@@ -371,9 +373,12 @@ module Rigor
371
373
  return
372
374
  end
373
375
  when Prism::SingletonClassNode
374
- if node.expression.is_a?(Prism::SelfNode) && node.body
375
- walk_methods(node.body, qualified_prefix, true, accumulator)
376
- return
376
+ if node.body
377
+ singleton_prefix = singleton_class_prefix(node, qualified_prefix)
378
+ if singleton_prefix
379
+ walk_methods(node.body, singleton_prefix, true, accumulator)
380
+ return
381
+ end
377
382
  end
378
383
  when Prism::ConstantWriteNode
379
384
  if meta_new_block_body(node)
@@ -395,6 +400,30 @@ module Rigor
395
400
  walk_methods(child, qualified_prefix, in_singleton_class, accumulator)
396
401
  end
397
402
  end
403
+
404
+ # Resolves a `class << X` body's qualified prefix.
405
+ # - `class << self` keeps `qualified_prefix` (the enclosing class).
406
+ # - `class << Foo` inside `class Foo` collapses to the same prefix
407
+ # (semantically `class << self`).
408
+ # - `class << Foo` not nested in `class Foo` returns `[Foo]`
409
+ # so methods defined inside register on Foo's singleton.
410
+ # - Any other expression (variable, method call) returns nil
411
+ # so the walker falls through and skips the body.
412
+ def singleton_class_prefix(node, qualified_prefix)
413
+ case node.expression
414
+ when Prism::SelfNode
415
+ qualified_prefix
416
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
417
+ rendered = qualified_name_for(node.expression)
418
+ return nil unless rendered
419
+
420
+ if !qualified_prefix.empty? && qualified_prefix.last == rendered
421
+ qualified_prefix
422
+ else
423
+ rendered.split("::")
424
+ end
425
+ end
426
+ end
398
427
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
399
428
 
400
429
  # v0.1.2 — when a `Const = Data.define(*sym) do ... end`
@@ -418,11 +447,50 @@ module Rigor
418
447
  return if qualified_prefix.empty?
419
448
 
420
449
  class_name = qualified_prefix.join("::")
421
- kind = def_node.receiver.is_a?(Prism::SelfNode) || in_singleton_class ? :singleton : :instance
450
+ singleton = def_singleton?(def_node, qualified_prefix, in_singleton_class)
451
+ kind = singleton ? :singleton : :instance
422
452
  accumulator[class_name] ||= {}
423
453
  accumulator[class_name][def_node.name] = kind
424
454
  end
425
455
 
456
+ # `def Foo.bar` inside `module Foo` (or `def Meta.init` inside
457
+ # `module Meta`) is semantically equivalent to `def self.bar`:
458
+ # at the def-site, the runtime value of the constant `Foo` is
459
+ # the module itself (== `self`). Recognise the form so the
460
+ # method registers as singleton on the enclosing class.
461
+ #
462
+ # The cross-class form `def Bar.baz` inside `module Foo` —
463
+ # where the receiver names a constant other than the
464
+ # enclosing class — is not supported at this slice; falls
465
+ # through to `:instance` (current behaviour) rather than
466
+ # silently re-routing the registration.
467
+ def def_singleton?(def_node, qualified_prefix, in_singleton_class)
468
+ return true if def_node.receiver.is_a?(Prism::SelfNode) || in_singleton_class
469
+
470
+ def_receiver_targets_lexical_self?(def_node.receiver, qualified_prefix)
471
+ end
472
+
473
+ # Only `Prism::ConstantReadNode` is observed in real Ruby —
474
+ # Prism mis-parses `def C::P.method` as `def C.P` (Ruby itself
475
+ # rejects the form as a SyntaxError). The ConstantPathNode
476
+ # branch stays defensive in case Prism's grammar widens.
477
+ def def_receiver_targets_lexical_self?(receiver, qualified_prefix)
478
+ return false if qualified_prefix.empty?
479
+
480
+ case receiver
481
+ when Prism::ConstantReadNode
482
+ receiver.name.to_s == qualified_prefix.last
483
+ when Prism::ConstantPathNode
484
+ rendered = render_constant_path(receiver)
485
+ return false unless rendered
486
+
487
+ path = rendered.split("::")
488
+ qualified_prefix.last(path.length) == path
489
+ else
490
+ false
491
+ end
492
+ end
493
+
426
494
  # v0.0.2 #5 — instance-side def-node recording. Walks
427
495
  # class bodies the same way as `build_discovered_methods`
428
496
  # but records the actual `Prism::DefNode` for each
@@ -452,9 +520,12 @@ module Rigor
452
520
  return
453
521
  end
454
522
  when Prism::SingletonClassNode
455
- if node.expression.is_a?(Prism::SelfNode) && node.body
456
- walk_def_nodes(node.body, qualified_prefix, true, accumulator)
457
- return
523
+ if node.body
524
+ singleton_prefix = singleton_class_prefix(node, qualified_prefix)
525
+ if singleton_prefix
526
+ walk_def_nodes(node.body, singleton_prefix, true, accumulator)
527
+ return
528
+ end
458
529
  end
459
530
  when Prism::ConstantWriteNode
460
531
  if meta_new_block_body(node)
@@ -481,7 +552,7 @@ module Rigor
481
552
  TOP_LEVEL_DEF_KEY = "<toplevel>"
482
553
 
483
554
  def record_def_node(def_node, qualified_prefix, in_singleton_class, accumulator)
484
- return if def_node.receiver.is_a?(Prism::SelfNode) || in_singleton_class
555
+ return if def_singleton?(def_node, qualified_prefix, in_singleton_class)
485
556
 
486
557
  class_name = qualified_prefix.empty? ? TOP_LEVEL_DEF_KEY : qualified_prefix.join("::")
487
558
  accumulator[class_name] ||= {}
@@ -530,9 +601,12 @@ module Rigor
530
601
  return current_visibility
531
602
  end
532
603
  when Prism::SingletonClassNode
533
- if node.expression.is_a?(Prism::SelfNode) && node.body
534
- walk_method_visibilities(node.body, qualified_prefix, true, :public, accumulator)
535
- return current_visibility
604
+ if node.body
605
+ singleton_prefix = singleton_class_prefix(node, qualified_prefix)
606
+ if singleton_prefix
607
+ walk_method_visibilities(node.body, singleton_prefix, true, :public, accumulator)
608
+ return current_visibility
609
+ end
536
610
  end
537
611
  when Prism::ConstantWriteNode
538
612
  if meta_new_block_body(node)
@@ -703,6 +777,76 @@ module Rigor
703
777
  node.unescaped&.to_sym
704
778
  end
705
779
 
780
+ # Walks every file in `paths` (each path is parsed once with
781
+ # `Prism.parse_file`) and returns the unioned project-wide
782
+ # `discovered_classes` Hash: `{qualified_name => Singleton[…]}`.
783
+ # Used by `Analysis::Runner` to seed each file's
784
+ # `default_scope.discovered_classes` so that lexical
785
+ # constant lookup in one file resolves a `class Foo`
786
+ # declared in a sibling file. Per-file collisions are
787
+ # last-write-wins (matches the existing in-file merge
788
+ # semantics). Parse failures fail-soft to an empty
789
+ # contribution. The `buffer` argument, when present,
790
+ # redirects reads for the bound logical path to the
791
+ # buffer's physical path so editor-mode pre-passes see
792
+ # the in-flight bytes.
793
+ #
794
+ # **Modules are intentionally excluded** from the
795
+ # project-wide seed: a `module M; module_function; def x; end; end`
796
+ # body, when surfaced as `singleton(M)` to the dispatcher,
797
+ # falls through to `Kernel#x` (or any Module ancestor
798
+ # method) when the project's per-file
799
+ # `discovered_methods` doesn't know `M.x` — leading to
800
+ # surprising types like `Kernel.select → Array[String]`.
801
+ # Until cross-file `discovered_methods` follows the same
802
+ # project-wide seed, registering modules here would
803
+ # introduce regressions in modules-with-module_function
804
+ # idioms that previously resolved to `Dynamic[Top]`.
805
+ # Class declarations are safe because per-file
806
+ # `discovered_methods` already tracks `def self.x` /
807
+ # `def x` instance and singleton methods consistently.
808
+ #
809
+ # @param paths [Array<String>] project file paths.
810
+ # @param buffer [Rigor::Analysis::BufferBinding, nil]
811
+ # @return [Hash{String => Rigor::Type::Singleton}]
812
+ def discovered_classes_for_paths(paths, buffer: nil)
813
+ accumulator = {}
814
+ paths.each do |path|
815
+ physical = buffer ? buffer.resolve(path) : path
816
+ source = File.read(physical)
817
+ root = Prism.parse(source, filepath: path).value
818
+ collect_class_decls(root, [], accumulator)
819
+ rescue StandardError
820
+ # Skip files that fail to parse or read; the per-file
821
+ # analyzer surfaces the parse error separately.
822
+ next
823
+ end
824
+ accumulator.freeze
825
+ end
826
+
827
+ # Class-only variant of `record_declarations` — descends
828
+ # into nested module bodies (so `module Foo; class Bar`
829
+ # registers `Foo::Bar`) but never registers the module
830
+ # itself in `accumulator`.
831
+ def collect_class_decls(node, qualified_prefix, accumulator)
832
+ return unless node.is_a?(Prism::Node)
833
+
834
+ case node
835
+ when Prism::ClassNode
836
+ name = qualified_name_for(node.constant_path)
837
+ if name
838
+ full = (qualified_prefix + [name]).join("::")
839
+ accumulator[full] = Type::Combinator.singleton_of(full)
840
+ return collect_class_decls(node.body, qualified_prefix + [name], accumulator) if node.body
841
+ end
842
+ when Prism::ModuleNode
843
+ name = qualified_name_for(node.constant_path)
844
+ return collect_class_decls(node.body, qualified_prefix + [name], accumulator) if name && node.body
845
+ end
846
+
847
+ node.compact_child_nodes.each { |child| collect_class_decls(child, qualified_prefix, accumulator) }
848
+ end
849
+
706
850
  # Walks the program once for `Prism::ModuleNode` and
707
851
  # `Prism::ClassNode`, recording the `Singleton[<qualified>]`
708
852
  # type for the outermost `constant_path` node of each
@@ -760,6 +760,19 @@ module Rigor
760
760
  # name as a Symbol, so the produced type is `Constant[:name]`.
761
761
  def eval_def(node)
762
762
  body_scope = build_method_entry_scope(node)
763
+ # Parameter default value expressions (e.g. `self.x` in
764
+ # `def copy(x: self.x)`) execute when the method is
765
+ # *invoked*, not when the `def` is read; their `self` is
766
+ # the instance receiver, not the surrounding class body.
767
+ # Walk the parameters subtree under `body_scope` so the
768
+ # scope-index records the instance `self_type` for every
769
+ # node inside parameter defaults. `propagate` would
770
+ # otherwise drop them to the outer class-body scope (where
771
+ # `self_type` is `singleton(C)`), making `self.foo` look
772
+ # like a singleton-side call. Observed surfacing 915 false
773
+ # positives in `prism-1.9.0`'s auto-generated `copy`
774
+ # methods alone.
775
+ sub_eval(node.parameters, body_scope, class_context: @class_context) if node.parameters
763
776
  sub_eval(node.body, body_scope, class_context: @class_context) if node.body
764
777
  [Type::Combinator.constant_of(node.name), scope]
765
778
  end
@@ -783,6 +796,21 @@ module Rigor
783
796
  def eval_call(node)
784
797
  call_type = scope.type_of(node, tracer: tracer)
785
798
  evaluate_block_if_present(node)
799
+ # `ruby2_keywords def foo(...)` (and similar wrappers like
800
+ # `private def`, `public def`, `module_function def`) parse
801
+ # the def as the call's positional argument; the
802
+ # ExpressionTyper#type_of_def handler types it as
803
+ # `Constant[:foo]` without walking the body. Without
804
+ # explicitly evaluating the argument-position def, the body's
805
+ # scope-index entries inherit the outer class-body
806
+ # `self_type = singleton(C)` from `ScopeIndexer.propagate`,
807
+ # so `self.helper` inside reports `undefined method 'helper'
808
+ # for singleton(C)`. Walking each argument-position def under
809
+ # the current evaluator (not a sub_eval — the def's effects
810
+ # do not bind into the surrounding scope) populates the
811
+ # scope index with the correct instance / singleton
812
+ # `self_type` for the def's body.
813
+ evaluate_def_arguments(node)
786
814
  post_scope = record_closure_escape_if_any(node)
787
815
  post_scope = apply_rbs_extended_assertions(node, post_scope)
788
816
  post_scope = apply_plugin_assertions(node, post_scope)
@@ -790,6 +818,15 @@ module Rigor
790
818
  [call_type, post_scope]
791
819
  end
792
820
 
821
+ def evaluate_def_arguments(call_node)
822
+ args = call_node.arguments
823
+ return unless args.respond_to?(:arguments)
824
+
825
+ args.arguments.each do |arg|
826
+ eval_def(arg) if arg.is_a?(Prism::DefNode)
827
+ end
828
+ end
829
+
793
830
  # v0.0.3 — recognises a small catalogue of RSpec
794
831
  # matcher patterns as assert-shaped narrows on the
795
832
  # local passed to `expect(...)`. The pattern is
@@ -1399,7 +1436,37 @@ module Rigor
1399
1436
  end
1400
1437
 
1401
1438
  def singleton_def?(def_node)
1402
- def_node.receiver.is_a?(Prism::SelfNode) || current_frame_singleton?
1439
+ return true if def_node.receiver.is_a?(Prism::SelfNode) || current_frame_singleton?
1440
+
1441
+ def_receiver_targets_lexical_self?(def_node.receiver)
1442
+ end
1443
+
1444
+ # `def Foo.bar` inside `module Foo` (or `def Meta.init` inside
1445
+ # `module Meta`) — explicit-receiver def that semantically
1446
+ # equals `def self.bar` because the receiver constant
1447
+ # resolves to `self` at the def-site. Matched against the
1448
+ # current class context's tail to cover both the
1449
+ # `def OpenURI.x` form (single segment) and the
1450
+ # `def OpenURI::Meta.x` form (qualified path). Cross-class
1451
+ # receivers (`def Bar.baz` inside `module Foo` where the
1452
+ # receiver names a different constant) are not promoted to
1453
+ # singleton at this slice.
1454
+ def def_receiver_targets_lexical_self?(receiver)
1455
+ return false if @class_context.empty?
1456
+
1457
+ prefix = @class_context.map(&:name)
1458
+ case receiver
1459
+ when Prism::ConstantReadNode
1460
+ receiver.name.to_s == prefix.last
1461
+ when Prism::ConstantPathNode
1462
+ rendered = render_constant_path(receiver)
1463
+ return false unless rendered
1464
+
1465
+ path = rendered.split("::")
1466
+ prefix.last(path.length) == path
1467
+ else
1468
+ false
1469
+ end
1403
1470
  end
1404
1471
 
1405
1472
  # Slice A-engine. Inside a class body `class Foo; ...; end`,
@@ -1452,12 +1519,45 @@ module Rigor
1452
1519
  end
1453
1520
 
1454
1521
  def singleton_context_for(node)
1455
- return @class_context unless node.expression.is_a?(Prism::SelfNode)
1456
- return @class_context if @class_context.empty?
1522
+ case node.expression
1523
+ when Prism::SelfNode
1524
+ return @class_context if @class_context.empty?
1525
+
1526
+ outer = @class_context[0..-2]
1527
+ last = @class_context.last
1528
+ outer + [ClassFrame.new(name: last.name, singleton: true)]
1529
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
1530
+ target = singleton_constant_target(node.expression)
1531
+ return @class_context unless target
1532
+
1533
+ # `class << Foo` inside `class Foo` (the canonical
1534
+ # pattern in Ruby's own time.rb) is semantically
1535
+ # `class << self` — replace the enclosing frame with a
1536
+ # singleton frame for the same name so method
1537
+ # registration and `self_type` lookup land on Foo.
1538
+ # When the target names a different constant (rare
1539
+ # cross-class form), append a fresh singleton frame
1540
+ # tagged with the target FQN; the bodies are scoped to
1541
+ # that target rather than to the lexical enclosing
1542
+ # class.
1543
+ if !@class_context.empty? && @class_context.last.name == target
1544
+ outer = @class_context[0..-2]
1545
+ outer + [ClassFrame.new(name: target, singleton: true)]
1546
+ else
1547
+ [ClassFrame.new(name: target, singleton: true)]
1548
+ end
1549
+ else
1550
+ @class_context
1551
+ end
1552
+ end
1457
1553
 
1458
- outer = @class_context[0..-2]
1459
- last = @class_context.last
1460
- outer + [ClassFrame.new(name: last.name, singleton: true)]
1554
+ def singleton_constant_target(expression)
1555
+ case expression
1556
+ when Prism::ConstantReadNode
1557
+ expression.name.to_s
1558
+ when Prism::ConstantPathNode
1559
+ render_constant_path(expression)
1560
+ end
1461
1561
  end
1462
1562
 
1463
1563
  # The qualified name of the immediately-enclosing class (joining
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Inference
5
+ # ADR-16 Tier C output — one synthetic method declared by a
6
+ # plugin's `Plugin::Macro::HeredocTemplate` entry, after the
7
+ # pre-pass has interpolated the call-site literal symbol into
8
+ # the template name. Stored in {SyntheticMethodIndex} and
9
+ # consulted by {MethodDispatcher} below the RBS dispatch tier.
10
+ #
11
+ # Per ADR-16 § WD13 (cost-bounded best-effort): the v0.1.x
12
+ # delivery commitment is the floor — method names emit; their
13
+ # return types degrade to `Dynamic[T]` until slice 6
14
+ # (precision promotion) routes the recorded `return_type`
15
+ # string through ADR-13's `Plugin::TypeNodeResolver` chain.
16
+ # The string is preserved so the ceiling slice can resolve it
17
+ # without re-walking.
18
+ #
19
+ # The `provenance` Hash carries debug / `--explain` metadata:
20
+ # plugin id, the template's call shape, and the source
21
+ # location of the originating DSL call. Surfaced through the
22
+ # dispatcher's `macro.tier_c.*` provenance markers.
23
+ class SyntheticMethod
24
+ INSTANCE = :instance
25
+ SINGLETON = :singleton
26
+ VALID_KINDS = [INSTANCE, SINGLETON].freeze
27
+
28
+ attr_reader :class_name, :method_name, :return_type, :kind, :provenance
29
+
30
+ def initialize(class_name:, method_name:, return_type:, kind: INSTANCE, provenance: {})
31
+ validate!(class_name, method_name, return_type, kind, provenance)
32
+ @class_name = class_name.dup.freeze
33
+ @method_name = method_name.to_sym
34
+ @return_type = return_type.dup.freeze
35
+ @kind = kind
36
+ @provenance = provenance.transform_keys(&:to_sym).transform_values do |v|
37
+ v.is_a?(String) ? v.dup.freeze : v
38
+ end.freeze
39
+ freeze
40
+ end
41
+
42
+ def instance? = kind == INSTANCE
43
+ def singleton? = kind == SINGLETON
44
+
45
+ def to_h
46
+ {
47
+ "class_name" => class_name,
48
+ "method_name" => method_name.to_s,
49
+ "return_type" => return_type,
50
+ "kind" => kind.to_s,
51
+ "provenance" => provenance.transform_keys(&:to_s)
52
+ }
53
+ end
54
+
55
+ def ==(other)
56
+ other.is_a?(SyntheticMethod) && to_h == other.to_h
57
+ end
58
+ alias eql? ==
59
+
60
+ def hash
61
+ to_h.hash
62
+ end
63
+
64
+ private
65
+
66
+ def validate!(class_name, method_name, return_type, kind, provenance)
67
+ unless class_name.is_a?(String) && !class_name.empty?
68
+ raise ArgumentError, "SyntheticMethod#class_name must be non-empty String, got #{class_name.inspect}"
69
+ end
70
+ unless method_name.is_a?(Symbol) || (method_name.is_a?(String) && !method_name.empty?)
71
+ raise ArgumentError, "SyntheticMethod#method_name must be Symbol/non-empty String, got #{method_name.inspect}"
72
+ end
73
+ unless return_type.is_a?(String) && !return_type.empty?
74
+ raise ArgumentError, "SyntheticMethod#return_type must be non-empty String, got #{return_type.inspect}"
75
+ end
76
+ unless VALID_KINDS.include?(kind)
77
+ raise ArgumentError, "SyntheticMethod#kind must be one of #{VALID_KINDS.inspect}, got #{kind.inspect}"
78
+ end
79
+
80
+ return if provenance.is_a?(Hash)
81
+
82
+ raise ArgumentError, "SyntheticMethod#provenance must be a Hash, got #{provenance.inspect}"
83
+ end
84
+ end
85
+ end
86
+ end