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.
- checksums.yaml +4 -4
- data/README.md +36 -50
- data/lib/rigor/analysis/buffer_binding.rb +36 -0
- data/lib/rigor/analysis/check_rules.rb +11 -1
- data/lib/rigor/analysis/dependency_source_inference/index.rb +14 -1
- data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +105 -0
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +32 -12
- data/lib/rigor/analysis/project_scan.rb +39 -0
- data/lib/rigor/analysis/runner.rb +309 -22
- data/lib/rigor/analysis/worker_session.rb +14 -2
- data/lib/rigor/builtins/hkt_builtins.rb +342 -0
- data/lib/rigor/builtins/static_return_refinements.rb +120 -0
- data/lib/rigor/cache/store.rb +33 -3
- data/lib/rigor/cli/lsp_command.rb +129 -0
- data/lib/rigor/cli/type_of_command.rb +44 -5
- data/lib/rigor/cli.rb +74 -12
- data/lib/rigor/configuration.rb +38 -2
- data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
- data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
- data/lib/rigor/environment/rbs_loader.rb +45 -2
- data/lib/rigor/environment/reporters.rb +40 -0
- data/lib/rigor/environment.rb +106 -9
- data/lib/rigor/inference/acceptance.rb +48 -3
- data/lib/rigor/inference/expression_typer.rb +47 -0
- data/lib/rigor/inference/hkt_body.rb +171 -0
- data/lib/rigor/inference/hkt_body_parser.rb +363 -0
- data/lib/rigor/inference/hkt_reducer.rb +256 -0
- data/lib/rigor/inference/hkt_registry.rb +223 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +125 -30
- data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
- data/lib/rigor/inference/method_dispatcher.rb +154 -3
- data/lib/rigor/inference/project_patched_methods.rb +70 -0
- data/lib/rigor/inference/project_patched_scanner.rb +210 -0
- data/lib/rigor/inference/scope_indexer.rb +156 -12
- data/lib/rigor/inference/statement_evaluator.rb +106 -6
- data/lib/rigor/inference/synthetic_method_scanner.rb +94 -16
- data/lib/rigor/language_server/buffer_table.rb +63 -0
- data/lib/rigor/language_server/completion_provider.rb +438 -0
- data/lib/rigor/language_server/debouncer.rb +86 -0
- data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
- data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
- data/lib/rigor/language_server/folding_range_provider.rb +75 -0
- data/lib/rigor/language_server/hover_provider.rb +74 -0
- data/lib/rigor/language_server/hover_renderer.rb +312 -0
- data/lib/rigor/language_server/loop.rb +71 -0
- data/lib/rigor/language_server/project_context.rb +145 -0
- data/lib/rigor/language_server/selection_range_provider.rb +93 -0
- data/lib/rigor/language_server/server.rb +384 -0
- data/lib/rigor/language_server/signature_help_provider.rb +249 -0
- data/lib/rigor/language_server/synchronized_writer.rb +28 -0
- data/lib/rigor/language_server/uri.rb +40 -0
- data/lib/rigor/language_server.rb +29 -0
- data/lib/rigor/plugin/base.rb +63 -0
- data/lib/rigor/plugin/macro/heredoc_template.rb +125 -11
- data/lib/rigor/plugin/manifest.rb +54 -7
- data/lib/rigor/plugin/registry.rb +19 -0
- data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
- data/lib/rigor/rbs_extended.rb +82 -2
- data/lib/rigor/sig_gen/generator.rb +12 -3
- data/lib/rigor/type/app.rb +107 -0
- data/lib/rigor/type.rb +1 -0
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +8 -4
- data/sig/rigor/inference.rbs +2 -0
- data/sig/rigor.rbs +3 -1
- 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
|
-
|
|
354
|
-
return
|
|
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
|