rigortype 0.1.5 → 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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +36 -50
  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/project_scan.rb +39 -0
  9. data/lib/rigor/analysis/runner.rb +309 -22
  10. data/lib/rigor/analysis/worker_session.rb +14 -2
  11. data/lib/rigor/builtins/hkt_builtins.rb +342 -0
  12. data/lib/rigor/builtins/static_return_refinements.rb +120 -0
  13. data/lib/rigor/cache/store.rb +33 -3
  14. data/lib/rigor/cli/lsp_command.rb +129 -0
  15. data/lib/rigor/cli/type_of_command.rb +44 -5
  16. data/lib/rigor/cli.rb +74 -12
  17. data/lib/rigor/configuration.rb +38 -2
  18. data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
  19. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  20. data/lib/rigor/environment/rbs_loader.rb +45 -2
  21. data/lib/rigor/environment/reporters.rb +40 -0
  22. data/lib/rigor/environment.rb +106 -9
  23. data/lib/rigor/inference/acceptance.rb +48 -3
  24. data/lib/rigor/inference/expression_typer.rb +47 -0
  25. data/lib/rigor/inference/hkt_body.rb +171 -0
  26. data/lib/rigor/inference/hkt_body_parser.rb +363 -0
  27. data/lib/rigor/inference/hkt_reducer.rb +256 -0
  28. data/lib/rigor/inference/hkt_registry.rb +223 -0
  29. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +125 -30
  30. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
  31. data/lib/rigor/inference/method_dispatcher.rb +154 -3
  32. data/lib/rigor/inference/project_patched_methods.rb +70 -0
  33. data/lib/rigor/inference/project_patched_scanner.rb +210 -0
  34. data/lib/rigor/inference/scope_indexer.rb +156 -12
  35. data/lib/rigor/inference/statement_evaluator.rb +106 -6
  36. data/lib/rigor/inference/synthetic_method_scanner.rb +94 -16
  37. data/lib/rigor/language_server/buffer_table.rb +63 -0
  38. data/lib/rigor/language_server/completion_provider.rb +438 -0
  39. data/lib/rigor/language_server/debouncer.rb +86 -0
  40. data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
  41. data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
  42. data/lib/rigor/language_server/folding_range_provider.rb +75 -0
  43. data/lib/rigor/language_server/hover_provider.rb +74 -0
  44. data/lib/rigor/language_server/hover_renderer.rb +312 -0
  45. data/lib/rigor/language_server/loop.rb +71 -0
  46. data/lib/rigor/language_server/project_context.rb +145 -0
  47. data/lib/rigor/language_server/selection_range_provider.rb +93 -0
  48. data/lib/rigor/language_server/server.rb +384 -0
  49. data/lib/rigor/language_server/signature_help_provider.rb +249 -0
  50. data/lib/rigor/language_server/synchronized_writer.rb +28 -0
  51. data/lib/rigor/language_server/uri.rb +40 -0
  52. data/lib/rigor/language_server.rb +29 -0
  53. data/lib/rigor/plugin/base.rb +63 -0
  54. data/lib/rigor/plugin/macro/heredoc_template.rb +125 -11
  55. data/lib/rigor/plugin/manifest.rb +54 -7
  56. data/lib/rigor/plugin/registry.rb +19 -0
  57. data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
  58. data/lib/rigor/rbs_extended.rb +82 -2
  59. data/lib/rigor/sig_gen/generator.rb +12 -3
  60. data/lib/rigor/type/app.rb +107 -0
  61. data/lib/rigor/type.rb +1 -0
  62. data/lib/rigor/version.rb +1 -1
  63. data/sig/rigor/environment.rbs +8 -4
  64. data/sig/rigor/inference.rbs +2 -0
  65. data/sig/rigor.rbs +3 -1
  66. metadata +54 -1
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../type"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module MethodDispatcher
8
+ # Stable-sort an overload list so that "receiver-affinity"
9
+ # arms come first. An overload is receiver-affinity-matching
10
+ # when every positional param's class equals `self_type`'s
11
+ # class name OR is one of its proper RBS ancestors. The
12
+ # canonical case the helper exists for: when `bigdecimal`'s
13
+ # stdlib RBS reopens `Integer#+` at the FRONT of the
14
+ # overload list with `(BigDecimal) -> BigDecimal`, that
15
+ # disjoint-sibling arm would win every dispatch for
16
+ # `Integer#+(?)` by overload-list position alone, returning
17
+ # a spurious `BigDecimal` for plain integer arithmetic.
18
+ # Demoting the arm honours the coerce convention: when the
19
+ # arg type is unknown or itself an Integer, the
20
+ # receiver-preserving `(Integer) -> Integer` arm should win.
21
+ #
22
+ # No-op when (a) the environment can't answer
23
+ # `class_ordering` (nil env), or (b) the receiver isn't a
24
+ # nominal / singleton carrying a class name. The partition
25
+ # is stable, so within each bucket the RBS-declared order
26
+ # is preserved.
27
+ module ReceiverAffinity
28
+ module_function
29
+
30
+ def reorder(overloads, self_type:, environment:)
31
+ return overloads if environment.nil?
32
+
33
+ self_class_name = self_type_class_name(self_type)
34
+ return overloads if self_class_name.nil?
35
+
36
+ affinity, other = overloads.partition do |mt|
37
+ overload_param_classes_in_ancestry?(mt, self_class_name, environment)
38
+ end
39
+ affinity + other
40
+ end
41
+
42
+ class << self
43
+ private
44
+
45
+ def self_type_class_name(self_type)
46
+ case self_type
47
+ when Type::Nominal, Type::Singleton then self_type.class_name
48
+ end
49
+ end
50
+
51
+ def overload_param_classes_in_ancestry?(method_type, self_class_name, environment)
52
+ fun = method_type.type
53
+ params = fun.required_positionals + fun.optional_positionals + fun.trailing_positionals
54
+ return false if params.empty?
55
+
56
+ params.all? { |param| param_class_in_ancestry?(param.type, self_class_name, environment) }
57
+ end
58
+
59
+ # Walks Optional and Union one level so `(Numeric?)` and
60
+ # `(Integer | Float)` still classify when every branch
61
+ # sits in the ancestry. Non-`ClassInstance` shapes
62
+ # (Alias / Interface / Intersection / type variables)
63
+ # don't carry a clean class identity and therefore
64
+ # disqualify the overload from the affinity bucket.
65
+ def param_class_in_ancestry?(rbs_type, self_class_name, environment)
66
+ case rbs_type
67
+ when RBS::Types::ClassInstance
68
+ class_in_ancestry?(rbs_type.name.to_s.delete_prefix("::"), self_class_name, environment)
69
+ when RBS::Types::Optional
70
+ param_class_in_ancestry?(rbs_type.type, self_class_name, environment)
71
+ when RBS::Types::Union
72
+ rbs_type.types.all? { |t| param_class_in_ancestry?(t, self_class_name, environment) }
73
+ else
74
+ false
75
+ end
76
+ end
77
+
78
+ def class_in_ancestry?(param_class_name, self_class_name, environment)
79
+ return true if param_class_name == self_class_name
80
+
81
+ environment.class_ordering(self_class_name, param_class_name) == :subclass
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -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
@@ -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