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