rigortype 0.1.0 → 0.1.2
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 +7 -2
- data/data/builtins/ruby_core/array.yml +6 -6
- data/data/builtins/ruby_core/hash.yml +1 -1
- data/data/builtins/ruby_core/io.yml +3 -3
- data/data/builtins/ruby_core/numeric.yml +1 -1
- data/data/builtins/ruby_core/pathname.yml +100 -100
- data/data/builtins/ruby_core/proc.yml +1 -1
- data/data/builtins/ruby_core/range.yml +6 -4
- data/data/builtins/ruby_core/string.yml +15 -10
- data/data/builtins/ruby_core/time.yml +3 -3
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +116 -0
- data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +123 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +118 -0
- data/lib/rigor/analysis/check_rules.rb +346 -18
- data/lib/rigor/analysis/rule_catalog.rb +343 -0
- data/lib/rigor/analysis/runner.rb +90 -6
- data/lib/rigor/builtins/regex_refinement.rb +104 -0
- data/lib/rigor/cli/diff_command.rb +169 -0
- data/lib/rigor/cli/explain_command.rb +129 -0
- data/lib/rigor/cli/type_of_command.rb +3 -3
- data/lib/rigor/cli/type_scan_command.rb +4 -4
- data/lib/rigor/cli.rb +29 -5
- data/lib/rigor/configuration/severity_profile.rb +18 -3
- data/lib/rigor/configuration.rb +186 -13
- data/lib/rigor/environment.rb +12 -4
- data/lib/rigor/inference/expression_typer.rb +3 -1
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +31 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +43 -2
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +104 -12
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +68 -2
- data/lib/rigor/inference/method_dispatcher.rb +50 -1
- data/lib/rigor/inference/narrowing.rb +150 -6
- data/lib/rigor/inference/scope_indexer.rb +220 -17
- data/lib/rigor/inference/statement_evaluator.rb +29 -0
- data/lib/rigor/plugin/base.rb +43 -0
- data/lib/rigor/plugin/fact_store.rb +92 -0
- data/lib/rigor/plugin/io_boundary.rb +92 -19
- data/lib/rigor/plugin/load_error.rb +14 -2
- data/lib/rigor/plugin/loader.rb +116 -0
- data/lib/rigor/plugin/manifest.rb +75 -6
- data/lib/rigor/plugin/services.rb +14 -2
- data/lib/rigor/plugin/trust_policy.rb +30 -7
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/scope.rb +30 -5
- data/lib/rigor/trinary.rb +1 -1
- data/lib/rigor/type/integer_range.rb +6 -2
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor/scope.rbs +3 -0
- data/sig/rigor.rbs +8 -2
- metadata +9 -1
|
@@ -106,6 +106,14 @@ module Rigor
|
|
|
106
106
|
discovered_def_nodes = build_discovered_def_nodes(root)
|
|
107
107
|
seeded_scope = seeded_scope.with_discovered_def_nodes(discovered_def_nodes)
|
|
108
108
|
|
|
109
|
+
# v0.1.2 — per-class table of method visibilities
|
|
110
|
+
# (`:public` / `:private` / `:protected`). The
|
|
111
|
+
# `def.method-visibility-mismatch` CheckRule consults
|
|
112
|
+
# the table to flag explicit-non-self calls to a
|
|
113
|
+
# private user method.
|
|
114
|
+
discovered_method_visibilities = build_discovered_method_visibilities(root)
|
|
115
|
+
seeded_scope = seeded_scope.with_discovered_method_visibilities(discovered_method_visibilities)
|
|
116
|
+
|
|
109
117
|
table = {}.compare_by_identity
|
|
110
118
|
table.default = seeded_scope
|
|
111
119
|
|
|
@@ -340,7 +348,8 @@ module Rigor
|
|
|
340
348
|
accumulator.transform_values(&:freeze).freeze
|
|
341
349
|
end
|
|
342
350
|
|
|
343
|
-
|
|
351
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize
|
|
352
|
+
def walk_methods(node, qualified_prefix, in_singleton_class, accumulator)
|
|
344
353
|
return unless node.is_a?(Prism::Node)
|
|
345
354
|
|
|
346
355
|
case node
|
|
@@ -356,6 +365,12 @@ module Rigor
|
|
|
356
365
|
walk_methods(node.body, qualified_prefix, true, accumulator)
|
|
357
366
|
return
|
|
358
367
|
end
|
|
368
|
+
when Prism::ConstantWriteNode
|
|
369
|
+
if meta_new_block_body(node)
|
|
370
|
+
child_prefix = qualified_prefix + [node.name.to_s]
|
|
371
|
+
walk_methods(meta_new_block_body(node), child_prefix, false, accumulator)
|
|
372
|
+
return
|
|
373
|
+
end
|
|
359
374
|
when Prism::DefNode
|
|
360
375
|
record_def_method(node, qualified_prefix, in_singleton_class, accumulator)
|
|
361
376
|
return
|
|
@@ -370,6 +385,24 @@ module Rigor
|
|
|
370
385
|
walk_methods(child, qualified_prefix, in_singleton_class, accumulator)
|
|
371
386
|
end
|
|
372
387
|
end
|
|
388
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize
|
|
389
|
+
|
|
390
|
+
# v0.1.2 — when a `Const = Data.define(*sym) do ... end`
|
|
391
|
+
# / `Const = Struct.new(*sym) do ... end` constant write
|
|
392
|
+
# carries a block, the block body holds method overrides
|
|
393
|
+
# whose canonical class is `Const`. Returns the block body
|
|
394
|
+
# node (a `Prism::StatementsNode`) when the rvalue
|
|
395
|
+
# matches; nil otherwise. Used by `walk_methods` /
|
|
396
|
+
# `walk_def_nodes` to push `Const` onto the qualified
|
|
397
|
+
# prefix before recursing.
|
|
398
|
+
def meta_new_block_body(node)
|
|
399
|
+
return nil unless node.is_a?(Prism::ConstantWriteNode)
|
|
400
|
+
|
|
401
|
+
rvalue = node.value
|
|
402
|
+
return nil unless data_define_call?(rvalue) || struct_new_call?(rvalue)
|
|
403
|
+
|
|
404
|
+
rvalue.block&.body
|
|
405
|
+
end
|
|
373
406
|
|
|
374
407
|
def record_def_method(def_node, qualified_prefix, in_singleton_class, accumulator)
|
|
375
408
|
return if qualified_prefix.empty?
|
|
@@ -397,7 +430,8 @@ module Rigor
|
|
|
397
430
|
accumulator.transform_values(&:freeze).freeze
|
|
398
431
|
end
|
|
399
432
|
|
|
400
|
-
|
|
433
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
434
|
+
def walk_def_nodes(node, qualified_prefix, in_singleton_class, accumulator)
|
|
401
435
|
return unless node.is_a?(Prism::Node)
|
|
402
436
|
|
|
403
437
|
case node
|
|
@@ -413,6 +447,12 @@ module Rigor
|
|
|
413
447
|
walk_def_nodes(node.body, qualified_prefix, true, accumulator)
|
|
414
448
|
return
|
|
415
449
|
end
|
|
450
|
+
when Prism::ConstantWriteNode
|
|
451
|
+
if meta_new_block_body(node)
|
|
452
|
+
child_prefix = qualified_prefix + [node.name.to_s]
|
|
453
|
+
walk_def_nodes(meta_new_block_body(node), child_prefix, false, accumulator)
|
|
454
|
+
return
|
|
455
|
+
end
|
|
416
456
|
when Prism::DefNode
|
|
417
457
|
record_def_node(node, qualified_prefix, in_singleton_class, accumulator)
|
|
418
458
|
return
|
|
@@ -422,6 +462,7 @@ module Rigor
|
|
|
422
462
|
walk_def_nodes(child, qualified_prefix, in_singleton_class, accumulator)
|
|
423
463
|
end
|
|
424
464
|
end
|
|
465
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
425
466
|
|
|
426
467
|
# v0.0.3 A — sentinel key under which `record_def_node`
|
|
427
468
|
# files DefNodes that live outside any class / module
|
|
@@ -440,6 +481,134 @@ module Rigor
|
|
|
440
481
|
accumulator[class_name][def_node.name] = def_node
|
|
441
482
|
end
|
|
442
483
|
|
|
484
|
+
VISIBILITY_MODIFIERS = %i[public private protected].freeze
|
|
485
|
+
|
|
486
|
+
# v0.1.2 — per-class method-visibility table for the
|
|
487
|
+
# `def.method-visibility-mismatch` CheckRule.
|
|
488
|
+
#
|
|
489
|
+
# Tracks two visibility-changing forms:
|
|
490
|
+
#
|
|
491
|
+
# - **Modifier blocks**: a bare `private` / `protected` /
|
|
492
|
+
# `public` call inside a class body switches the
|
|
493
|
+
# "current default" visibility for every subsequent
|
|
494
|
+
# `def` until another modifier flips it again.
|
|
495
|
+
# - **Named-argument form**: `private :foo, :bar` (or
|
|
496
|
+
# the same with `protected` / `public`) marks specific
|
|
497
|
+
# names already-recorded under the class. Symbol-only
|
|
498
|
+
# args are recognised; `private def foo; end` (the
|
|
499
|
+
# wrap-around form) is not yet — it would need
|
|
500
|
+
# tracking the def-call's return-value visibility,
|
|
501
|
+
# which is a separate slice.
|
|
502
|
+
#
|
|
503
|
+
# Top-level (no surrounding class) defs do not contribute
|
|
504
|
+
# — Ruby's top-level visibility nuances (private at
|
|
505
|
+
# top-level marks the method on `Object`) are out of
|
|
506
|
+
# scope for v0.1.2.
|
|
507
|
+
def build_discovered_method_visibilities(root)
|
|
508
|
+
accumulator = {}
|
|
509
|
+
walk_method_visibilities(root, [], false, :public, accumulator)
|
|
510
|
+
accumulator.transform_values(&:freeze).freeze
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize
|
|
514
|
+
def walk_method_visibilities(node, qualified_prefix, in_singleton_class, current_visibility, accumulator)
|
|
515
|
+
return current_visibility unless node.is_a?(Prism::Node)
|
|
516
|
+
|
|
517
|
+
case node
|
|
518
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
519
|
+
name = qualified_name_for(node.constant_path)
|
|
520
|
+
if name
|
|
521
|
+
child_prefix = qualified_prefix + [name]
|
|
522
|
+
walk_method_visibilities(node.body, child_prefix, false, :public, accumulator) if node.body
|
|
523
|
+
return current_visibility
|
|
524
|
+
end
|
|
525
|
+
when Prism::SingletonClassNode
|
|
526
|
+
if node.expression.is_a?(Prism::SelfNode) && node.body
|
|
527
|
+
walk_method_visibilities(node.body, qualified_prefix, true, :public, accumulator)
|
|
528
|
+
return current_visibility
|
|
529
|
+
end
|
|
530
|
+
when Prism::ConstantWriteNode
|
|
531
|
+
if meta_new_block_body(node)
|
|
532
|
+
child_prefix = qualified_prefix + [node.name.to_s]
|
|
533
|
+
walk_method_visibilities(meta_new_block_body(node), child_prefix, false, :public, accumulator)
|
|
534
|
+
return current_visibility
|
|
535
|
+
end
|
|
536
|
+
when Prism::DefNode
|
|
537
|
+
record_def_visibility(node, qualified_prefix, in_singleton_class, current_visibility, accumulator)
|
|
538
|
+
return current_visibility
|
|
539
|
+
when Prism::CallNode
|
|
540
|
+
updated = apply_visibility_call(node, qualified_prefix, current_visibility, accumulator)
|
|
541
|
+
return updated unless updated.equal?(current_visibility)
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# Statement-position StatementsNode preserves
|
|
545
|
+
# left-to-right visibility flow; everything else
|
|
546
|
+
# recurses with the entry visibility unchanged.
|
|
547
|
+
if node.is_a?(Prism::StatementsNode)
|
|
548
|
+
local_visibility = current_visibility
|
|
549
|
+
node.compact_child_nodes.each do |child|
|
|
550
|
+
local_visibility = walk_method_visibilities(child, qualified_prefix, in_singleton_class,
|
|
551
|
+
local_visibility, accumulator)
|
|
552
|
+
end
|
|
553
|
+
else
|
|
554
|
+
node.compact_child_nodes.each do |child|
|
|
555
|
+
walk_method_visibilities(child, qualified_prefix, in_singleton_class, current_visibility, accumulator)
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
current_visibility
|
|
559
|
+
end
|
|
560
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize
|
|
561
|
+
|
|
562
|
+
def record_def_visibility(def_node, qualified_prefix, in_singleton_class, current_visibility, accumulator)
|
|
563
|
+
return if def_node.receiver.is_a?(Prism::SelfNode) || in_singleton_class
|
|
564
|
+
return if qualified_prefix.empty?
|
|
565
|
+
|
|
566
|
+
class_name = qualified_prefix.join("::")
|
|
567
|
+
accumulator[class_name] ||= {}
|
|
568
|
+
accumulator[class_name][def_node.name] = current_visibility
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# Recognises modifier calls on the implicit-self receiver
|
|
572
|
+
# inside a class body. Returns the (possibly updated)
|
|
573
|
+
# current visibility:
|
|
574
|
+
#
|
|
575
|
+
# - `private` / `public` / `protected` (no args) —
|
|
576
|
+
# switch the running default for subsequent defs.
|
|
577
|
+
# - `private :foo, :bar` — back-patch the named methods
|
|
578
|
+
# in the accumulator. Returns `current_visibility`
|
|
579
|
+
# unchanged because the running default does NOT
|
|
580
|
+
# change for this form.
|
|
581
|
+
def apply_visibility_call(call_node, qualified_prefix, current_visibility, accumulator)
|
|
582
|
+
return current_visibility unless call_node.receiver.nil?
|
|
583
|
+
return current_visibility unless VISIBILITY_MODIFIERS.include?(call_node.name)
|
|
584
|
+
return current_visibility if qualified_prefix.empty?
|
|
585
|
+
|
|
586
|
+
args = call_node.arguments&.arguments || []
|
|
587
|
+
if args.empty?
|
|
588
|
+
call_node.name
|
|
589
|
+
else
|
|
590
|
+
apply_named_visibility(args, qualified_prefix, call_node.name, accumulator)
|
|
591
|
+
current_visibility
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def apply_named_visibility(args, qualified_prefix, visibility, accumulator)
|
|
596
|
+
class_name = qualified_prefix.join("::")
|
|
597
|
+
args.each do |arg|
|
|
598
|
+
name = visibility_target_name(arg)
|
|
599
|
+
next if name.nil?
|
|
600
|
+
|
|
601
|
+
accumulator[class_name] ||= {}
|
|
602
|
+
accumulator[class_name][name] = visibility
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def visibility_target_name(arg)
|
|
607
|
+
return arg.unescaped.to_sym if arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode)
|
|
608
|
+
|
|
609
|
+
nil
|
|
610
|
+
end
|
|
611
|
+
|
|
443
612
|
# Registers the alias name in the `discovered_methods` table so
|
|
444
613
|
# `undefined-method` diagnostics are not emitted for calls to the
|
|
445
614
|
# aliased name. The kind mirrors the surrounding class context
|
|
@@ -552,7 +721,7 @@ module Rigor
|
|
|
552
721
|
when Prism::ModuleNode, Prism::ClassNode
|
|
553
722
|
return if record_class_or_module?(node, qualified_prefix, identity_table, discovered)
|
|
554
723
|
when Prism::ConstantWriteNode
|
|
555
|
-
return if
|
|
724
|
+
return if record_meta_new_constant?(node, qualified_prefix, identity_table, discovered)
|
|
556
725
|
end
|
|
557
726
|
|
|
558
727
|
node.compact_child_nodes.each do |child|
|
|
@@ -573,17 +742,23 @@ module Rigor
|
|
|
573
742
|
true
|
|
574
743
|
end
|
|
575
744
|
|
|
576
|
-
# Recognises
|
|
577
|
-
# `Const` (qualified by the surrounding
|
|
578
|
-
# discovered class. `Const.new(...)`
|
|
579
|
-
# `Nominal[Const]` via `meta_new`,
|
|
580
|
-
# `Dynamic[top]` returned by the
|
|
745
|
+
# Recognises class-creating meta calls at constant-write rvalue
|
|
746
|
+
# position and registers `Const` (qualified by the surrounding
|
|
747
|
+
# class/module path) as a discovered class. `Const.new(...)`
|
|
748
|
+
# then resolves to a fresh `Nominal[Const]` via `meta_new`,
|
|
749
|
+
# instead of the un-narrowed `Dynamic[top]` returned by the
|
|
750
|
+
# default `Class#new` envelope.
|
|
581
751
|
#
|
|
582
|
-
#
|
|
583
|
-
#
|
|
584
|
-
#
|
|
585
|
-
|
|
586
|
-
|
|
752
|
+
# Two recognised meta forms:
|
|
753
|
+
#
|
|
754
|
+
# - `Const = Data.define(*Symbol) [do ... end]`
|
|
755
|
+
# - `Const = Struct.new(*Symbol [, keyword_init: ...]) [do ... end]`
|
|
756
|
+
#
|
|
757
|
+
# The block body, if present, is recursed into so any nested
|
|
758
|
+
# class/module declarations in the override block (rare but legal)
|
|
759
|
+
# still feed the discovered table.
|
|
760
|
+
def record_meta_new_constant?(node, qualified_prefix, identity_table, discovered)
|
|
761
|
+
return false unless data_define_call?(node.value) || struct_new_call?(node.value)
|
|
587
762
|
|
|
588
763
|
full = (qualified_prefix + [node.name.to_s]).join("::")
|
|
589
764
|
discovered[full] = Type::Combinator.singleton_of(full)
|
|
@@ -599,18 +774,46 @@ module Rigor
|
|
|
599
774
|
def data_define_call?(node)
|
|
600
775
|
return false unless node.is_a?(Prism::CallNode)
|
|
601
776
|
return false unless node.name == :define
|
|
602
|
-
return false unless
|
|
777
|
+
return false unless meta_constant_receiver?(node.receiver, :Data)
|
|
603
778
|
|
|
604
779
|
args = node.arguments&.arguments || []
|
|
605
780
|
args.all?(Prism::SymbolNode)
|
|
606
781
|
end
|
|
607
782
|
|
|
608
|
-
|
|
783
|
+
# Recognises `Struct.new(*Symbol)` and
|
|
784
|
+
# `Struct.new(*Symbol, keyword_init: <expr>)` at constant-write
|
|
785
|
+
# rvalue position. A trailing `KeywordHashNode` (the
|
|
786
|
+
# `keyword_init: ...` form) is accepted but does not contribute
|
|
787
|
+
# to member discovery; every other argument MUST be a
|
|
788
|
+
# `Prism::SymbolNode`. At least one Symbol member is required —
|
|
789
|
+
# `Struct.new()` is a degenerate form callers don't typically use.
|
|
790
|
+
def struct_new_call?(node)
|
|
791
|
+
return false unless meta_call_with_name?(node, :Struct, :new)
|
|
792
|
+
|
|
793
|
+
args = node.arguments&.arguments || []
|
|
794
|
+
positional = struct_new_positionals(args)
|
|
795
|
+
return false if positional.nil? || positional.empty?
|
|
796
|
+
|
|
797
|
+
positional.all?(Prism::SymbolNode)
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
def meta_call_with_name?(node, receiver_name, method_name)
|
|
801
|
+
return false unless node.is_a?(Prism::CallNode)
|
|
802
|
+
return false unless node.name == method_name
|
|
803
|
+
|
|
804
|
+
meta_constant_receiver?(node.receiver, receiver_name)
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
def struct_new_positionals(args)
|
|
808
|
+
args.last.is_a?(Prism::KeywordHashNode) ? args[0..-2] : args
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
def meta_constant_receiver?(node, expected_name)
|
|
609
812
|
case node
|
|
610
813
|
when Prism::ConstantReadNode
|
|
611
|
-
node.name ==
|
|
814
|
+
node.name == expected_name
|
|
612
815
|
when Prism::ConstantPathNode
|
|
613
|
-
node.parent.nil? && node.name ==
|
|
816
|
+
node.parent.nil? && node.name == expected_name
|
|
614
817
|
end
|
|
615
818
|
end
|
|
616
819
|
|
|
@@ -1015,6 +1015,7 @@ module Rigor
|
|
|
1015
1015
|
# same hierarchy-aware narrowing rules.
|
|
1016
1016
|
def apply_post_return_fact(fact, call_node, current_scope, method_def)
|
|
1017
1017
|
target_node = fact_target_node(fact, call_node, method_def)
|
|
1018
|
+
return apply_self_post_return_fact(fact, target_node, current_scope) if fact.target_kind == :self
|
|
1018
1019
|
return current_scope unless target_node.is_a?(Prism::LocalVariableReadNode)
|
|
1019
1020
|
|
|
1020
1021
|
local_name = target_node.name
|
|
@@ -1025,6 +1026,34 @@ module Rigor
|
|
|
1025
1026
|
current_scope.with_local(local_name, narrowed)
|
|
1026
1027
|
end
|
|
1027
1028
|
|
|
1029
|
+
# v0.1.1 Track 1 slice 3 — `assert self is T` post-return
|
|
1030
|
+
# narrowing for the four supported receiver shapes (mirrors
|
|
1031
|
+
# `Narrowing#apply_self_fact`).
|
|
1032
|
+
def apply_self_post_return_fact(fact, receiver_node, current_scope)
|
|
1033
|
+
case receiver_node
|
|
1034
|
+
when nil, Prism::SelfNode
|
|
1035
|
+
current = current_scope.self_type
|
|
1036
|
+
return current_scope if current.nil?
|
|
1037
|
+
|
|
1038
|
+
narrowed = Narrowing.narrow_for_fact(current, fact, current_scope.environment)
|
|
1039
|
+
current_scope.with_self_type(narrowed)
|
|
1040
|
+
when Prism::LocalVariableReadNode
|
|
1041
|
+
current = current_scope.local(receiver_node.name)
|
|
1042
|
+
return current_scope if current.nil?
|
|
1043
|
+
|
|
1044
|
+
narrowed = Narrowing.narrow_for_fact(current, fact, current_scope.environment)
|
|
1045
|
+
current_scope.with_local(receiver_node.name, narrowed)
|
|
1046
|
+
when Prism::InstanceVariableReadNode
|
|
1047
|
+
current = current_scope.ivar(receiver_node.name)
|
|
1048
|
+
return current_scope if current.nil?
|
|
1049
|
+
|
|
1050
|
+
narrowed = Narrowing.narrow_for_fact(current, fact, current_scope.environment)
|
|
1051
|
+
current_scope.with_ivar(receiver_node.name, narrowed)
|
|
1052
|
+
else
|
|
1053
|
+
current_scope
|
|
1054
|
+
end
|
|
1055
|
+
end
|
|
1056
|
+
|
|
1028
1057
|
# `:self` routes to the call receiver; otherwise we look
|
|
1029
1058
|
# up the matching positional argument by parameter name.
|
|
1030
1059
|
def fact_target_node(fact, call_node, method_def)
|
data/lib/rigor/plugin/base.rb
CHANGED
|
@@ -111,6 +111,49 @@ module Rigor
|
|
|
111
111
|
nil
|
|
112
112
|
end
|
|
113
113
|
|
|
114
|
+
# ADR-2 § "Flow Contribution Bundle" / v0.1.1 Track 2
|
|
115
|
+
# slice 7 — per-call return-type contribution hook. When
|
|
116
|
+
# the inference engine dispatches a `Prism::CallNode` and
|
|
117
|
+
# neither the precision tiers nor RBS resolve a result,
|
|
118
|
+
# `MethodDispatcher` consults each loaded plugin via this
|
|
119
|
+
# hook ahead of `RbsDispatch`. Plugins that override the
|
|
120
|
+
# default return a {Rigor::FlowContribution} bundle whose
|
|
121
|
+
# `return_type` slot pins the call site's result type.
|
|
122
|
+
#
|
|
123
|
+
# Default returns nil — plugins that don't refine return
|
|
124
|
+
# types skip the override. Failures are isolated: a hook
|
|
125
|
+
# that raises gets its contribution dropped silently for
|
|
126
|
+
# this call so the rest of the dispatch chain continues.
|
|
127
|
+
def flow_contribution_for(call_node:, scope:) # rubocop:disable Lint/UnusedMethodArgument
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# ADR-9 slice 3 — per-run preparation hook. The runner
|
|
132
|
+
# invokes `#prepare(services)` on every loaded plugin once
|
|
133
|
+
# per `Analysis::Runner.run`, after `#init` has run on every
|
|
134
|
+
# plugin and before any `#diagnostics_for_file` call.
|
|
135
|
+
# Plugins use this hook to compute and publish facts other
|
|
136
|
+
# plugins consume:
|
|
137
|
+
#
|
|
138
|
+
# def prepare(services)
|
|
139
|
+
# services.fact_store.publish(
|
|
140
|
+
# plugin_id: manifest.id, name: :model_index, value: model_index
|
|
141
|
+
# )
|
|
142
|
+
# end
|
|
143
|
+
#
|
|
144
|
+
# Default no-op so plugins without facts to publish leave
|
|
145
|
+
# `#prepare` unimplemented. Failures isolate as
|
|
146
|
+
# `:plugin_loader runtime-error` diagnostics; a plugin that
|
|
147
|
+
# raises in `#prepare` has its facts considered un-published
|
|
148
|
+
# and downstream consumers see `nil` from `fact_store.read`.
|
|
149
|
+
#
|
|
150
|
+
# Slice 3 calls plugins in registration order. ADR-9 slice 5
|
|
151
|
+
# introduces topological ordering by `consumes:` so producers
|
|
152
|
+
# always run before consumers.
|
|
153
|
+
def prepare(services) # rubocop:disable Lint/UnusedMethodArgument
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
114
157
|
# ADR-7 § "Slice 5-A" — per-file diagnostic emission hook.
|
|
115
158
|
# Override in plugin subclasses to return an array of
|
|
116
159
|
# `Rigor::Analysis::Diagnostic` rows for the analysed file.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
# Per-run cross-plugin fact store. ADR-9 § "Plugin::FactStore".
|
|
6
|
+
#
|
|
7
|
+
# A plugin publishes typed `(plugin_id, name) -> value` tuples
|
|
8
|
+
# in its `Plugin::Base#prepare(services)` hook (slice 3); other
|
|
9
|
+
# plugins read them in `#diagnostics_for_file` via
|
|
10
|
+
# `services.fact_store.read(plugin_id:, name:)`. The store is
|
|
11
|
+
# constructed fresh at the start of every `Analysis::Runner.run`
|
|
12
|
+
# and discarded at the end — caching the underlying expensive
|
|
13
|
+
# computation is the producer's job (`Plugin::Base.producer`);
|
|
14
|
+
# the FactStore just publishes a *reference* to that
|
|
15
|
+
# already-cached result.
|
|
16
|
+
#
|
|
17
|
+
# `(plugin_id, name)` is a unique key. A second `publish` with
|
|
18
|
+
# the same value is a no-op (`==` comparison); a second
|
|
19
|
+
# `publish` with a different value raises {Conflict}. Since
|
|
20
|
+
# `plugin_id` namespaces the key, a real conflict only happens
|
|
21
|
+
# when a single plugin publishes twice with differing values —
|
|
22
|
+
# the conflict signals a plugin-author bug, never a load-time
|
|
23
|
+
# interaction between unrelated plugins.
|
|
24
|
+
class FactStore
|
|
25
|
+
Fact = Data.define(:plugin_id, :name, :value)
|
|
26
|
+
|
|
27
|
+
class Conflict < StandardError
|
|
28
|
+
attr_reader :plugin_id, :name, :existing, :incoming
|
|
29
|
+
|
|
30
|
+
def initialize(plugin_id:, name:, existing:, incoming:)
|
|
31
|
+
@plugin_id = plugin_id
|
|
32
|
+
@name = name
|
|
33
|
+
@existing = existing
|
|
34
|
+
@incoming = incoming
|
|
35
|
+
super(
|
|
36
|
+
"fact store conflict: plugin #{plugin_id.inspect} published " \
|
|
37
|
+
"two different values for #{name.inspect} " \
|
|
38
|
+
"(existing: #{existing.inspect}, incoming: #{incoming.inspect})"
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize
|
|
44
|
+
@facts = {}
|
|
45
|
+
@mutex = Mutex.new
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Writes a `(plugin_id, name) -> value` triple. Idempotent if
|
|
49
|
+
# the same value is published twice (`==`); raises
|
|
50
|
+
# {Conflict} if the values differ.
|
|
51
|
+
#
|
|
52
|
+
# @param plugin_id [String] producing plugin's manifest id.
|
|
53
|
+
# @param name [Symbol, String] fact name (canonicalised to
|
|
54
|
+
# Symbol for lookup).
|
|
55
|
+
# @param value [Object] frozen-shape value object the
|
|
56
|
+
# producer chose to publish. The value is stored as-is.
|
|
57
|
+
def publish(plugin_id:, name:, value:)
|
|
58
|
+
plugin_id = plugin_id.to_s
|
|
59
|
+
name = name.to_sym
|
|
60
|
+
@mutex.synchronize do
|
|
61
|
+
existing = @facts[[plugin_id, name]]
|
|
62
|
+
if existing && existing.value != value
|
|
63
|
+
raise Conflict.new(plugin_id: plugin_id, name: name, existing: existing.value, incoming: value)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
@facts[[plugin_id, name]] = Fact.new(plugin_id: plugin_id, name: name, value: value)
|
|
67
|
+
end
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Object, nil] the published value, or `nil` when no
|
|
72
|
+
# fact is registered. Reads do NOT establish a dependency —
|
|
73
|
+
# `manifest(consumes:)` (slice 4) is the dependency
|
|
74
|
+
# declaration mechanism.
|
|
75
|
+
def read(plugin_id:, name:)
|
|
76
|
+
fact = @mutex.synchronize { @facts[[plugin_id.to_s, name.to_sym]] }
|
|
77
|
+
fact&.value
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [Boolean] whether a fact is registered.
|
|
81
|
+
def published?(plugin_id:, name:)
|
|
82
|
+
@mutex.synchronize { @facts.key?([plugin_id.to_s, name.to_sym]) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @yield [Fact] every published fact in publication order.
|
|
86
|
+
def each_fact(&)
|
|
87
|
+
snapshot = @mutex.synchronize { @facts.values }
|
|
88
|
+
snapshot.each(&)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -27,20 +27,37 @@ module Rigor
|
|
|
27
27
|
# the file's contents, and adds a digest-keyed
|
|
28
28
|
# {Cache::Descriptor::FileEntry} to the boundary's
|
|
29
29
|
# accumulated descriptor.
|
|
30
|
-
# - `#open_url(url)` —
|
|
31
|
-
# `network_policy
|
|
32
|
-
#
|
|
33
|
-
#
|
|
30
|
+
# - `#open_url(url)` — fetches the URL when the policy
|
|
31
|
+
# permits it (`network_policy: :allowlist` plus an
|
|
32
|
+
# `allowed_url_hosts` match) and raises
|
|
33
|
+
# {AccessDeniedError} otherwise. v0.1.2 ships the
|
|
34
|
+
# allowlist surface; the default project policy still
|
|
35
|
+
# has `network_policy: :disabled` so plugins that want
|
|
36
|
+
# network access opt in explicitly through
|
|
37
|
+
# `.rigor.yml`'s `plugins_io.network: allowlist` plus
|
|
38
|
+
# `plugins_io.allowed_url_hosts: [...]`. The HTTP fetch
|
|
39
|
+
# is GET-only over HTTPS, capped at {URL_TIMEOUT_SECONDS}
|
|
40
|
+
# wall time and {URL_MAX_BYTES} body size; non-2xx
|
|
41
|
+
# responses raise {AccessDeniedError} so plugin code
|
|
42
|
+
# doesn't have to rescue mid-build.
|
|
34
43
|
# - `#cache_descriptor` — flushes the accumulated entries into
|
|
35
44
|
# a fresh {Cache::Descriptor} for the contribution that
|
|
36
|
-
# built it.
|
|
45
|
+
# built it. URL fetches contribute `ConfigEntry` rows
|
|
46
|
+
# keyed `"url:#{url}"` with the response body's SHA-256
|
|
47
|
+
# so contributions invalidate when the remote document
|
|
48
|
+
# changes.
|
|
37
49
|
class IoBoundary
|
|
50
|
+
URL_TIMEOUT_SECONDS = 10
|
|
51
|
+
URL_MAX_BYTES = 10 * 1024 * 1024
|
|
52
|
+
|
|
38
53
|
attr_reader :policy, :plugin_id
|
|
39
54
|
|
|
40
|
-
def initialize(policy:, plugin_id:)
|
|
55
|
+
def initialize(policy:, plugin_id:, http_client: DefaultHttpClient.new)
|
|
41
56
|
@policy = policy
|
|
42
57
|
@plugin_id = plugin_id.to_s.dup.freeze
|
|
43
58
|
@file_entries = {}
|
|
59
|
+
@config_entries = {}
|
|
60
|
+
@http_client = http_client
|
|
44
61
|
@mutex = Mutex.new
|
|
45
62
|
end
|
|
46
63
|
|
|
@@ -64,30 +81,39 @@ module Rigor
|
|
|
64
81
|
contents
|
|
65
82
|
end
|
|
66
83
|
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
# the
|
|
70
|
-
# the
|
|
84
|
+
# Fetches the URL when the policy permits it. Returns the
|
|
85
|
+
# response body. Raises {AccessDeniedError} when the policy
|
|
86
|
+
# is `:disabled`, the URL scheme is not `https`, the host is
|
|
87
|
+
# not on the allowlist, the response is non-2xx, the body
|
|
88
|
+
# exceeds {URL_MAX_BYTES}, or the request times out
|
|
89
|
+
# ({URL_TIMEOUT_SECONDS}). On success, records a
|
|
90
|
+
# `ConfigEntry` keyed `"url:#{url}"` with the body's
|
|
91
|
+
# SHA-256 so the cache descriptor invalidates if the remote
|
|
92
|
+
# document changes.
|
|
71
93
|
def open_url(url)
|
|
72
|
-
|
|
94
|
+
url_string = url.to_s
|
|
95
|
+
unless @policy.allow_url?(url_string)
|
|
73
96
|
raise AccessDeniedError.new(
|
|
74
97
|
"plugin #{@plugin_id.inspect} cannot open URL #{url.inspect}: " \
|
|
75
|
-
"
|
|
98
|
+
"URL is not permitted by the active TrustPolicy " \
|
|
99
|
+
"(network_policy=#{@policy.network_policy} allowed_url_hosts=#{@policy.allowed_url_hosts.inspect})",
|
|
76
100
|
reason: :network_disabled,
|
|
77
|
-
resource:
|
|
101
|
+
resource: url_string
|
|
78
102
|
)
|
|
79
103
|
end
|
|
80
104
|
|
|
81
|
-
|
|
105
|
+
body = @http_client.get(url_string, timeout: URL_TIMEOUT_SECONDS, max_bytes: URL_MAX_BYTES)
|
|
106
|
+
record_url_entry(url_string, body)
|
|
107
|
+
body
|
|
82
108
|
end
|
|
83
109
|
|
|
84
110
|
# @return [Rigor::Cache::Descriptor] frozen snapshot of every
|
|
85
|
-
# file the boundary has read so far. Calling this
|
|
86
|
-
# times yields equal descriptors; subsequent
|
|
87
|
-
# the underlying record
|
|
111
|
+
# file / URL the boundary has read so far. Calling this
|
|
112
|
+
# multiple times yields equal descriptors; subsequent
|
|
113
|
+
# reads expand the underlying record tables.
|
|
88
114
|
def cache_descriptor
|
|
89
|
-
|
|
90
|
-
Cache::Descriptor.new(files:
|
|
115
|
+
files, configs = @mutex.synchronize { [@file_entries.values.dup, @config_entries.values.dup] }
|
|
116
|
+
Cache::Descriptor.new(files: files, configs: configs)
|
|
91
117
|
end
|
|
92
118
|
|
|
93
119
|
private
|
|
@@ -97,6 +123,53 @@ module Rigor
|
|
|
97
123
|
entry = Cache::Descriptor::FileEntry.new(path: path, comparator: :digest, value: digest)
|
|
98
124
|
@mutex.synchronize { @file_entries[path] = entry }
|
|
99
125
|
end
|
|
126
|
+
|
|
127
|
+
def record_url_entry(url, body)
|
|
128
|
+
digest = Digest::SHA256.hexdigest(body)
|
|
129
|
+
key = "url:#{url}"
|
|
130
|
+
entry = Cache::Descriptor::ConfigEntry.new(key: key, value_hash: digest)
|
|
131
|
+
@mutex.synchronize { @config_entries[key] = entry }
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Default HTTP client wrapping `Net::HTTP`. Wraps a single
|
|
136
|
+
# `GET` over HTTPS. Specs inject a fake client that conforms
|
|
137
|
+
# to the same `#get(url, timeout:, max_bytes:)` shape so the
|
|
138
|
+
# tests don't require network access.
|
|
139
|
+
class DefaultHttpClient
|
|
140
|
+
# rubocop:disable Metrics/MethodLength
|
|
141
|
+
def get(url, timeout:, max_bytes:)
|
|
142
|
+
require "net/http"
|
|
143
|
+
require "uri"
|
|
144
|
+
|
|
145
|
+
uri = URI.parse(url)
|
|
146
|
+
body = +""
|
|
147
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true,
|
|
148
|
+
open_timeout: timeout,
|
|
149
|
+
read_timeout: timeout) do |http|
|
|
150
|
+
http.request_get(uri.request_uri) do |response|
|
|
151
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
152
|
+
raise Plugin::AccessDeniedError.new(
|
|
153
|
+
"URL #{url.inspect} returned non-success status #{response.code}",
|
|
154
|
+
reason: :url_fetch_failed,
|
|
155
|
+
resource: url
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
response.read_body do |chunk|
|
|
159
|
+
body << chunk
|
|
160
|
+
if body.bytesize > max_bytes
|
|
161
|
+
raise Plugin::AccessDeniedError.new(
|
|
162
|
+
"URL #{url.inspect} body exceeds #{max_bytes} bytes",
|
|
163
|
+
reason: :url_body_too_large,
|
|
164
|
+
resource: url
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
body
|
|
171
|
+
end
|
|
172
|
+
# rubocop:enable Metrics/MethodLength
|
|
100
173
|
end
|
|
101
174
|
end
|
|
102
175
|
end
|