rigortype 0.4.3 → 0.4.4
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 +1 -1
- data/data/core_overlay/ostruct.rbs +40 -0
- data/docs/handbook/11-sig-gen.md +16 -0
- data/docs/manual/02-cli-reference.md +4 -3
- data/docs/manual/03-configuration.md +16 -5
- data/docs/manual/04-diagnostics.md +44 -2
- data/docs/manual/07-plugins.md +1 -1
- data/docs/manual/10-mcp-server.md +32 -3
- data/docs/manual/18-removing-dead-code.md +11 -1
- data/docs/manual/plugins/rigor-rails-routes.md +2 -2
- data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
- data/exe/rigor +12 -17
- data/lib/rigor/analysis/check_rules/rule_ids.rb +57 -0
- data/lib/rigor/analysis/check_rules.rb +46 -36
- data/lib/rigor/analysis/incremental.rb +12 -0
- data/lib/rigor/analysis/incremental_session.rb +121 -21
- data/lib/rigor/analysis/reachability/graph.rb +23 -3
- data/lib/rigor/analysis/reachability/scan.rb +51 -9
- data/lib/rigor/analysis/rule_catalog.rb +6 -1
- data/lib/rigor/analysis/run_cache_key.rb +31 -7
- data/lib/rigor/analysis/run_cache_probe.rb +5 -3
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +48 -12
- data/lib/rigor/analysis/runner/pool_coordinator.rb +110 -15
- data/lib/rigor/analysis/runner/project_pre_passes.rb +29 -4
- data/lib/rigor/analysis/runner.rb +35 -9
- data/lib/rigor/analysis/severity_stamp.rb +34 -1
- data/lib/rigor/analysis/worker_session.rb +57 -1
- data/lib/rigor/cache/descriptor.rb +40 -6
- data/lib/rigor/cache/incremental_snapshot.rb +20 -4
- data/lib/rigor/cli/mcp_command.rb +1 -1
- data/lib/rigor/config_audit.rb +111 -15
- data/lib/rigor/configuration/dependencies.rb +19 -11
- data/lib/rigor/environment/rbs_loader.rb +46 -10
- data/lib/rigor/inference/block_call_timing.rb +5 -0
- data/lib/rigor/inference/captured_locals.rb +26 -3
- data/lib/rigor/inference/definer_resolution.rb +16 -7
- data/lib/rigor/inference/expression_typer.rb +59 -4
- data/lib/rigor/inference/guard_rebinding.rb +7 -2
- data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
- data/lib/rigor/inference/module_function_state.rb +80 -13
- data/lib/rigor/inference/narrowing.rb +184 -68
- data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
- data/lib/rigor/inference/repeated_or_writes.rb +414 -1
- data/lib/rigor/inference/scope_indexer.rb +439 -70
- data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
- data/lib/rigor/inference/statement_evaluator.rb +84 -29
- data/lib/rigor/lens/anchor.rb +3 -3
- data/lib/rigor/lens/contract_reader.rb +15 -0
- data/lib/rigor/lens/document.rb +2 -2
- data/lib/rigor/lens/file_reader.rb +442 -73
- data/lib/rigor/lens/members.rb +65 -15
- data/lib/rigor/lens/run.rb +44 -2
- data/lib/rigor/lens.rb +2 -1
- data/lib/rigor/mcp/server.rb +61 -20
- data/lib/rigor/plugin/box_probe.rb +154 -7
- data/lib/rigor/project_environment.rb +6 -3
- data/lib/rigor/scope.rb +11 -6
- data/lib/rigor/sig_gen/layout_index.rb +36 -0
- data/lib/rigor/sig_gen/method_candidate.rb +8 -0
- data/lib/rigor/sig_gen/path_mapper.rb +18 -2
- data/lib/rigor/sig_gen/writer.rb +47 -34
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
- metadata +4 -1
|
@@ -20,7 +20,26 @@ module Rigor
|
|
|
20
20
|
# First-found wins on duplicate declarations across files; RBS itself allows the same class to be declared
|
|
21
21
|
# in multiple files for additive member contributions, but the writer only needs one canonical target per
|
|
22
22
|
# class.
|
|
23
|
+
#
|
|
24
|
+
# ADR-122 WD2 adds the member level: `(class_name, method_name, kind) → sig_file_path`, so a member the project
|
|
25
|
+
# already declares in one file is updated there rather than inserted a second time into the class's
|
|
26
|
+
# first-found file (#1488).
|
|
23
27
|
class LayoutIndex
|
|
28
|
+
# The `[method_name, kind]` keys a member declaration answers for, `kind` being `:instance` or
|
|
29
|
+
# `:singleton`. A `def self?.f` answers for both kinds; an `attr_accessor` answers for its reader and its
|
|
30
|
+
# writer. Shared with {Writer}, which asks the same question of the target file it is merging into.
|
|
31
|
+
def self.member_keys(member)
|
|
32
|
+
case member
|
|
33
|
+
when RBS::AST::Members::MethodDefinition
|
|
34
|
+
kinds = member.kind == :singleton_instance ? %i[instance singleton] : [member.kind]
|
|
35
|
+
kinds.map { |kind| [member.name, kind] }
|
|
36
|
+
when RBS::AST::Members::AttrReader then [[member.name, member.kind]]
|
|
37
|
+
when RBS::AST::Members::AttrWriter then [[:"#{member.name}=", member.kind]]
|
|
38
|
+
when RBS::AST::Members::AttrAccessor then [[member.name, member.kind], [:"#{member.name}=", member.kind]]
|
|
39
|
+
else []
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
24
43
|
# @param signature_paths —
|
|
25
44
|
# the `.rigor.yml`-configured signature directories.
|
|
26
45
|
# When `nil` or empty, falls back to `<project_root>/sig`
|
|
@@ -29,6 +48,7 @@ module Rigor
|
|
|
29
48
|
def initialize(signature_paths:, project_root: Dir.pwd)
|
|
30
49
|
@signature_paths = resolve_paths(signature_paths, project_root)
|
|
31
50
|
@index = nil
|
|
51
|
+
@members = nil
|
|
32
52
|
end
|
|
33
53
|
|
|
34
54
|
# @param class_name — fully-qualified Ruby class
|
|
@@ -40,6 +60,13 @@ module Rigor
|
|
|
40
60
|
index[class_name]
|
|
41
61
|
end
|
|
42
62
|
|
|
63
|
+
# @return absolute path of the first sig file (in the same order as {#file_for}) that declares
|
|
64
|
+
# `method_name` of `kind` on `class_name`, or `nil` when no file does.
|
|
65
|
+
def member_file_for(class_name, method_name, kind)
|
|
66
|
+
index
|
|
67
|
+
@members[[class_name, method_name, kind]]
|
|
68
|
+
end
|
|
69
|
+
|
|
43
70
|
def empty?
|
|
44
71
|
index.empty?
|
|
45
72
|
end
|
|
@@ -60,6 +87,7 @@ module Rigor
|
|
|
60
87
|
|
|
61
88
|
def build_index
|
|
62
89
|
accumulator = {}
|
|
90
|
+
@members = {}
|
|
63
91
|
Array(@signature_paths).each do |dir|
|
|
64
92
|
base = Pathname(dir)
|
|
65
93
|
next unless base.directory?
|
|
@@ -68,6 +96,7 @@ module Rigor
|
|
|
68
96
|
index_file(Pathname(rbs_file), accumulator)
|
|
69
97
|
end
|
|
70
98
|
end
|
|
99
|
+
@members.freeze
|
|
71
100
|
accumulator.freeze
|
|
72
101
|
end
|
|
73
102
|
|
|
@@ -97,6 +126,13 @@ module Rigor
|
|
|
97
126
|
local_name = decl.name.to_s.sub(/\A::/, "")
|
|
98
127
|
full = prefix.empty? ? local_name : "#{prefix.join('::')}::#{local_name}"
|
|
99
128
|
accumulator[full] ||= rbs_path
|
|
129
|
+
decl.members.each do |member|
|
|
130
|
+
# An `overloading?` member (`def f: ... | ...`) adds to a declaration elsewhere; routing a candidate to
|
|
131
|
+
# it would replace the user's overload.
|
|
132
|
+
next if member.is_a?(RBS::AST::Members::MethodDefinition) && member.overloading?
|
|
133
|
+
|
|
134
|
+
self.class.member_keys(member).each { |name, kind| @members[[full, name, kind]] ||= rbs_path }
|
|
135
|
+
end
|
|
100
136
|
|
|
101
137
|
record_decls(decl.members, prefix + [local_name], rbs_path, accumulator)
|
|
102
138
|
end
|
|
@@ -68,6 +68,14 @@ module Rigor
|
|
|
68
68
|
@declared_annotations + @annotations + [@rbs].compact
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
+
# The RBS member kind `rbs` declares: `:singleton_instance` for a `def self?.` line (a `module_function`
|
|
72
|
+
# method, whose `kind` stays `:instance`), `kind` otherwise. The writer replaces only a member of this
|
|
73
|
+
# kind, so an overwrite neither drops a `self?.` member's singleton half nor adds one beside a split
|
|
74
|
+
# `def self.f` / `def f` pair (#1488).
|
|
75
|
+
def rbs_kind
|
|
76
|
+
kind == :instance && rbs.to_s.start_with?("def self?.") ? :singleton_instance : kind
|
|
77
|
+
end
|
|
78
|
+
|
|
71
79
|
# A copy carrying a different annotation decision. The generator's effect pass rebuilds rather than
|
|
72
80
|
# mutates, because a candidate is frozen the moment it is built.
|
|
73
81
|
def with_effect_annotation(annotations, reason)
|
|
@@ -37,8 +37,11 @@ module Rigor
|
|
|
37
37
|
# returned instead of the 1:1 mirror.
|
|
38
38
|
# @return absolute path of the target `.rbs`
|
|
39
39
|
# file for the candidate.
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
#
|
|
41
|
+
# A member the signature tree already declares (`method_name` / `kind`, ADR-122 WD2) is routed to the file
|
|
42
|
+
# that declares it, even when another file declares its class first (#1488).
|
|
43
|
+
def target_for(source_path, class_name: nil, method_name: nil, kind: nil)
|
|
44
|
+
existing = existing_member_target_for(class_name, method_name, kind) || existing_target_for(class_name)
|
|
42
45
|
return existing if existing
|
|
43
46
|
|
|
44
47
|
rel_to_root = source_relative_to_root(source_path)
|
|
@@ -46,6 +49,19 @@ module Rigor
|
|
|
46
49
|
sig_root_dir / "#{stripped.sub_ext('')}.rbs"
|
|
47
50
|
end
|
|
48
51
|
|
|
52
|
+
# `kind` may be `:singleton_instance` (a `def self?.` candidate), which routes to whichever file declares
|
|
53
|
+
# either half.
|
|
54
|
+
def existing_member_target_for(class_name, method_name, kind)
|
|
55
|
+
return nil if class_name.nil? || method_name.nil? || @layout_index.nil?
|
|
56
|
+
|
|
57
|
+
kinds = kind == :singleton_instance ? %i[instance singleton] : [kind]
|
|
58
|
+
kinds.each do |k|
|
|
59
|
+
file = @layout_index.member_file_for(class_name, method_name, k)
|
|
60
|
+
return file if file
|
|
61
|
+
end
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
49
65
|
def existing_target_for(class_name)
|
|
50
66
|
return nil if class_name.nil? || @layout_index.nil?
|
|
51
67
|
|
data/lib/rigor/sig_gen/writer.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "fileutils"
|
|
|
4
4
|
require "rbs"
|
|
5
5
|
|
|
6
6
|
require_relative "classification"
|
|
7
|
+
require_relative "layout_index"
|
|
7
8
|
require_relative "superclass_spelling"
|
|
8
9
|
require_relative "write_result"
|
|
9
10
|
|
|
@@ -62,8 +63,10 @@ module Rigor
|
|
|
62
63
|
|
|
63
64
|
@global_namespace_kinds = build_namespace_kinds(candidates)
|
|
64
65
|
@global_superclasses = build_superclasses(candidates)
|
|
65
|
-
emittable.group_by
|
|
66
|
-
|
|
66
|
+
grouped = emittable.group_by do |c|
|
|
67
|
+
@path_mapper.target_for(c.path, class_name: c.class_name, method_name: c.method_name, kind: c.rbs_kind)
|
|
68
|
+
end
|
|
69
|
+
grouped.map { |target, group| write_target(target, group) }
|
|
67
70
|
end
|
|
68
71
|
|
|
69
72
|
# @param candidates — only
|
|
@@ -562,7 +565,10 @@ module Rigor
|
|
|
562
565
|
state.applied.concat(methods)
|
|
563
566
|
insert_namespace_chain(state, class_name, kinds, supers, methods)
|
|
564
567
|
else
|
|
565
|
-
|
|
568
|
+
# A class reopened in one file answers for every member of every opening, not just the first one's
|
|
569
|
+
# (#1488): a member another opening declares is user-authored, not new.
|
|
570
|
+
declared = find_class_decls(state.decls, class_name).flat_map { |d| collect_member_pairs(d) }
|
|
571
|
+
state.source = merge_into_existing_class(state.source, decl, methods, state, declared)
|
|
566
572
|
state.decls = parse_signature(state.source) || state.decls
|
|
567
573
|
end
|
|
568
574
|
end
|
|
@@ -589,12 +595,25 @@ module Rigor
|
|
|
589
595
|
nil
|
|
590
596
|
end
|
|
591
597
|
|
|
598
|
+
# Every declaration of `qualified_name` in the file, in {#find_class_decl}'s order (so the first is the one
|
|
599
|
+
# it returns).
|
|
600
|
+
def find_class_decls(decls, qualified_name, prefix = [], found = [])
|
|
601
|
+
decls.each do |decl|
|
|
602
|
+
next unless decl.is_a?(RBS::AST::Declarations::Class) || decl.is_a?(RBS::AST::Declarations::Module)
|
|
603
|
+
|
|
604
|
+
local = decl.name.to_s.sub(/\A::/, "")
|
|
605
|
+
full = prefix.empty? ? local : "#{prefix.join('::')}::#{local}"
|
|
606
|
+
found << decl if full == qualified_name
|
|
607
|
+
find_class_decls(decl.members, qualified_name, prefix + [local], found)
|
|
608
|
+
end
|
|
609
|
+
found
|
|
610
|
+
end
|
|
611
|
+
|
|
592
612
|
def ends_with_newline?(source)
|
|
593
613
|
source.end_with?("\n")
|
|
594
614
|
end
|
|
595
615
|
|
|
596
|
-
def merge_into_existing_class(source, decl, methods, state)
|
|
597
|
-
existing_pairs = collect_member_pairs(decl)
|
|
616
|
+
def merge_into_existing_class(source, decl, methods, state, existing_pairs)
|
|
598
617
|
new_methods, conflicting = partition_against_existing(methods, existing_pairs)
|
|
599
618
|
|
|
600
619
|
source = insert_into_class(source, decl, new_methods)
|
|
@@ -617,31 +636,20 @@ module Rigor
|
|
|
617
636
|
end
|
|
618
637
|
|
|
619
638
|
# Returns a list of `[method_name (Symbol), kind (Symbol)]` pairs for every method-like member in the
|
|
620
|
-
# declaration. ADR-14 slice 4 recognises
|
|
621
|
-
#
|
|
622
|
-
#
|
|
639
|
+
# declaration ({LayoutIndex.member_keys}). ADR-14 slice 4 recognises the three `attr_*` declaration kinds
|
|
640
|
+
# so a source-side `attr_reader :name` and an RBS-side `attr_reader name: T` are treated as the same member
|
|
641
|
+
# (i.e. user-authored); a `def self?.f` counts as both an instance and a singleton `f` (#1488).
|
|
623
642
|
def collect_member_pairs(decl)
|
|
624
|
-
|
|
625
|
-
decl.members.each { |m| collect_pairs_for_member(m, pairs) }
|
|
626
|
-
pairs
|
|
627
|
-
end
|
|
628
|
-
|
|
629
|
-
def collect_pairs_for_member(member, pairs)
|
|
630
|
-
case member
|
|
631
|
-
when RBS::AST::Members::MethodDefinition
|
|
632
|
-
pairs << [member.name, member.kind]
|
|
633
|
-
when RBS::AST::Members::AttrReader
|
|
634
|
-
pairs << [member.name, :instance]
|
|
635
|
-
when RBS::AST::Members::AttrWriter
|
|
636
|
-
pairs << [:"#{member.name}=", :instance]
|
|
637
|
-
when RBS::AST::Members::AttrAccessor
|
|
638
|
-
pairs << [member.name, :instance]
|
|
639
|
-
pairs << [:"#{member.name}=", :instance]
|
|
640
|
-
end
|
|
643
|
+
decl.members.flat_map { |m| LayoutIndex.member_keys(m) }
|
|
641
644
|
end
|
|
642
645
|
|
|
646
|
+
# A `def self?.f` candidate conflicts with a declared instance OR singleton `f`: inserting it beside a lone
|
|
647
|
+
# `def self.f` (or `def f`) would declare that half twice (#1488).
|
|
643
648
|
def partition_against_existing(methods, existing_pairs)
|
|
644
|
-
methods.partition
|
|
649
|
+
methods.partition do |c|
|
|
650
|
+
kinds = c.rbs_kind == :singleton_instance ? %i[instance singleton] : [c.kind]
|
|
651
|
+
kinds.none? { |kind| existing_pairs.include?([c.method_name, kind]) }
|
|
652
|
+
end
|
|
645
653
|
end
|
|
646
654
|
|
|
647
655
|
# Inserts each new method line one column before the class declaration's `end` keyword. The insertion
|
|
@@ -710,7 +718,7 @@ module Rigor
|
|
|
710
718
|
# part of the contract. A candidate that keeps `void` is still eligible, which is what case 2 is for —
|
|
711
719
|
# the `initialize` stub is always spelled `-> void`.
|
|
712
720
|
def replaces_declared_void?(candidate, decl)
|
|
713
|
-
member = find_method_member(decl, candidate
|
|
721
|
+
member = find_method_member(decl, candidate)
|
|
714
722
|
return false if member.nil?
|
|
715
723
|
|
|
716
724
|
declared_void = member.overloads.any? do |overload|
|
|
@@ -722,7 +730,7 @@ module Rigor
|
|
|
722
730
|
# Compares the existing member's source-side RBS text against the candidate's proposed RBS text
|
|
723
731
|
# ({.fewer_untyped?}).
|
|
724
732
|
def tightens_untyped?(candidate, decl, source)
|
|
725
|
-
member = find_method_member(decl, candidate
|
|
733
|
+
member = find_method_member(decl, candidate)
|
|
726
734
|
return false if member.nil?
|
|
727
735
|
|
|
728
736
|
self.class.fewer_untyped?(candidate.rbs, source[member.location.start_pos...member.location.end_pos])
|
|
@@ -737,7 +745,7 @@ module Rigor
|
|
|
737
745
|
# the same method (ADR-112 WD4), which it replaces in the inline attribute's own spelling; an
|
|
738
746
|
# `overloading?` member (`| ...`) is never its target.
|
|
739
747
|
def replaceable_member(decl, candidate)
|
|
740
|
-
member = find_method_member(decl, candidate
|
|
748
|
+
member = find_method_member(decl, candidate)
|
|
741
749
|
return member unless candidate.classification == Classification::INLINE_OVERWRITE
|
|
742
750
|
|
|
743
751
|
member = nil if member.respond_to?(:overloading?) && member.overloading?
|
|
@@ -746,15 +754,20 @@ module Rigor
|
|
|
746
754
|
|
|
747
755
|
def find_attr_member(decl, method_name, kind)
|
|
748
756
|
decl.members.find do |m|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
757
|
+
next false if m.is_a?(RBS::AST::Members::MethodDefinition)
|
|
758
|
+
|
|
759
|
+
LayoutIndex.member_keys(m).include?([method_name, kind])
|
|
752
760
|
end
|
|
753
761
|
end
|
|
754
762
|
|
|
755
|
-
|
|
763
|
+
# Matches on the candidate's {MethodCandidate#rbs_kind}, so a `def self?.f` proposal finds only a
|
|
764
|
+
# `def self?.f` member: a `def f` beside a `def self.f`, or a `def self?.f` under a plain `def f`
|
|
765
|
+
# proposal, is left as the user wrote it. An `overloading?` member (`def f: ... | ...`) adds to a
|
|
766
|
+
# declaration elsewhere rather than being one, so it is never replaced (#1488).
|
|
767
|
+
def find_method_member(decl, candidate)
|
|
756
768
|
decl.members.find do |m|
|
|
757
|
-
m.is_a?(RBS::AST::Members::MethodDefinition) && m.name == method_name &&
|
|
769
|
+
m.is_a?(RBS::AST::Members::MethodDefinition) && m.name == candidate.method_name &&
|
|
770
|
+
m.kind == candidate.rbs_kind && !m.overloading?
|
|
758
771
|
end
|
|
759
772
|
end
|
|
760
773
|
|
data/lib/rigor/version.rb
CHANGED
|
@@ -168,10 +168,14 @@ module Rigor
|
|
|
168
168
|
|
|
169
169
|
# ADR-45 WD1b (#613 / #630) — the classification probes go through the boundary, so an entry that
|
|
170
170
|
# is not there yet (or stops being a directory) is a recorded dependency of the scan's input set.
|
|
171
|
+
# #1803 — so is the walk: `IoBoundary#glob` records which `.rb` files the entry holds, and a file
|
|
172
|
+
# `exclude:` keeps out of analysis, which no analysed-file row covers, reaches the run-result caches
|
|
173
|
+
# when it appears or disappears. The matches are spelled under `entry`, as `Dir.glob` spelled them.
|
|
171
174
|
def scannable_paths(services)
|
|
172
175
|
@scannable_paths ||= services.configuration.paths.flat_map do |entry|
|
|
173
176
|
if io_boundary.directory?(entry)
|
|
174
|
-
|
|
177
|
+
prefix = "#{File.absolute_path(entry)}/"
|
|
178
|
+
io_boundary.glob(entry, "**/*.rb").map { |path| File.join(entry, path.delete_prefix(prefix)) }
|
|
175
179
|
elsif io_boundary.file?(entry) && entry.end_with?(".rb")
|
|
176
180
|
[entry]
|
|
177
181
|
else
|
|
@@ -142,10 +142,14 @@ module Rigor
|
|
|
142
142
|
|
|
143
143
|
# ADR-45 WD1b (#613 / #630) — the classification probes go through the boundary, so an entry that
|
|
144
144
|
# is not there yet (or stops being a directory) is a recorded dependency of the scan's input set.
|
|
145
|
+
# #1803 — so is the walk: `IoBoundary#glob` records which `.rb` files the entry holds, and a file
|
|
146
|
+
# `exclude:` keeps out of analysis, which no analysed-file row covers, reaches the run-result caches
|
|
147
|
+
# when it appears or disappears. The matches are spelled under `entry`, as `Dir.glob` spelled them.
|
|
145
148
|
def scannable_paths(services)
|
|
146
149
|
@scannable_paths ||= services.configuration.paths.flat_map do |entry|
|
|
147
150
|
if io_boundary.directory?(entry)
|
|
148
|
-
|
|
151
|
+
prefix = "#{File.absolute_path(entry)}/"
|
|
152
|
+
io_boundary.glob(entry, "**/*.rb").map { |path| File.join(entry, path.delete_prefix(prefix)) }
|
|
149
153
|
elsif io_boundary.file?(entry) && entry.end_with?(".rb")
|
|
150
154
|
[entry]
|
|
151
155
|
else
|
|
@@ -5,13 +5,20 @@ require "prism"
|
|
|
5
5
|
module Rigor
|
|
6
6
|
module Plugin
|
|
7
7
|
class FFI < Base
|
|
8
|
+
# Issue #1803 — the walk lists and reads through the plugin's {Rigor::Plugin::IoBoundary} (`io:`), so every
|
|
9
|
+
# `.rb` the catalog was built from is a dependency row of the `:ffi_catalog` producer's cache entry and of
|
|
10
|
+
# every run-result cache built on the run. The producer declares no `watch:`, so before this its entry
|
|
11
|
+
# carried no row at all and was served after any edit, an analysed file's included, until `--no-cache`.
|
|
8
12
|
class Discoverer
|
|
9
|
-
|
|
10
|
-
|
|
13
|
+
RUBY_GLOB = "**/*.rb"
|
|
14
|
+
|
|
15
|
+
def self.discover(root:, io:)
|
|
16
|
+
new(root: root, io: io).discover
|
|
11
17
|
end
|
|
12
18
|
|
|
13
|
-
def initialize(root:)
|
|
19
|
+
def initialize(root:, io:)
|
|
14
20
|
@root = root
|
|
21
|
+
@io = io
|
|
15
22
|
end
|
|
16
23
|
|
|
17
24
|
def discover
|
|
@@ -24,7 +31,7 @@ module Rigor
|
|
|
24
31
|
callbacks = {}
|
|
25
32
|
|
|
26
33
|
ruby_files.each do |file_path|
|
|
27
|
-
parse_result =
|
|
34
|
+
parse_result = parse(file_path)
|
|
28
35
|
next unless parse_result.value
|
|
29
36
|
|
|
30
37
|
walk_tree(parse_result.value, nil) do |node, mod_name|
|
|
@@ -61,9 +68,24 @@ module Rigor
|
|
|
61
68
|
private
|
|
62
69
|
|
|
63
70
|
def ruby_files
|
|
64
|
-
return [] unless @root &&
|
|
71
|
+
return [] unless @root && @io.directory?(@root.to_s)
|
|
72
|
+
|
|
73
|
+
boundary_glob.reject { |p| p.include?("/vendor/") || p.include?("/.git/") }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# One `:names` `**/*.rb` row over the root, `vendor/` included: a per-entry walk that skipped it recorded
|
|
77
|
+
# names only, so a top-level entry that changed kind under the same name went unseen. The boundary
|
|
78
|
+
# answers absolute paths; they are spelled back under `@root` as `Dir.glob` spells them, so the filter
|
|
79
|
+
# above sees the same strings it always did.
|
|
80
|
+
def boundary_glob
|
|
81
|
+
root = @root.to_s
|
|
82
|
+
prefix = "#{File.absolute_path(root)}/"
|
|
83
|
+
@io.glob(root, RUBY_GLOB).map { |path| File.join(root, path.delete_prefix(prefix)) }
|
|
84
|
+
end
|
|
65
85
|
|
|
66
|
-
|
|
86
|
+
# The glob's rows record which files match; this read's row carries an edit.
|
|
87
|
+
def parse(file_path)
|
|
88
|
+
Prism.parse(@io.read_file(file_path))
|
|
67
89
|
end
|
|
68
90
|
|
|
69
91
|
def walk_tree(node, current_module, &)
|
|
@@ -151,10 +151,14 @@ module Rigor
|
|
|
151
151
|
|
|
152
152
|
# ADR-45 WD1b (#613 / #630) — the classification probes go through the boundary, so an entry that
|
|
153
153
|
# is not there yet (or stops being a directory) is a recorded dependency of the scan's input set.
|
|
154
|
+
# #1803 — so is the walk: `IoBoundary#glob` records which `.rb` files the entry holds, and a file
|
|
155
|
+
# `exclude:` keeps out of analysis, which no analysed-file row covers, reaches the run-result caches
|
|
156
|
+
# when it appears or disappears. The matches are spelled under `entry`, as `Dir.glob` spelled them.
|
|
154
157
|
def scannable_paths(services)
|
|
155
158
|
@scannable_paths ||= services.configuration.paths.flat_map do |entry|
|
|
156
159
|
if io_boundary.directory?(entry)
|
|
157
|
-
|
|
160
|
+
prefix = "#{File.absolute_path(entry)}/"
|
|
161
|
+
io_boundary.glob(entry, "**/*.rb").map { |path| File.join(entry, path.delete_prefix(prefix)) }
|
|
158
162
|
elsif io_boundary.file?(entry) && entry.end_with?(".rb")
|
|
159
163
|
[entry]
|
|
160
164
|
else
|
|
@@ -18,7 +18,8 @@ module Rigor
|
|
|
18
18
|
# Nominal[T] uniformly, so the distinction with `be_a` is observed at the carrier level but not the
|
|
19
19
|
# runtime).
|
|
20
20
|
# - `be_nil` — narrows `x` to `Constant<nil>`.
|
|
21
|
-
# - `eq(LITERAL)` for a literal-value argument —
|
|
21
|
+
# - `eq(LITERAL)` / `eql(LITERAL)` for a literal-value argument — meets `x` with `Constant<literal>`
|
|
22
|
+
# (see "Equality is the local's own method" below).
|
|
22
23
|
#
|
|
23
24
|
# The matchers below this floor (`be_truthy` / `be_falsey` / `be_within` / `be > / < / >=` /
|
|
24
25
|
# `include` / `start_with` / `match(regex)` / `raise_error` / receive-style mocks) require either
|
|
@@ -35,16 +36,47 @@ module Rigor
|
|
|
35
36
|
# fall through.
|
|
36
37
|
# 3. The matcher is one of the recognised forms above (`be_a` / `be_kind_of` / `be_instance_of` /
|
|
37
38
|
# `be_an_instance_of` / `be_nil` / `eq(literal)` / `eql(literal)` / `match(/regex/)`).
|
|
39
|
+
#
|
|
40
|
+
# ## Equality is the local's own method
|
|
41
|
+
#
|
|
42
|
+
# `expect(x).to eq(lit)` passes when `x == lit` and `eql(lit)` when `x.eql?(lit)`: the local is the
|
|
43
|
+
# receiver, so its own `==` / `eql?` decides, and a class with a custom `==` can equal even a `nil`,
|
|
44
|
+
# boolean or Symbol literal. A positive equality fact therefore never replaces the local's type (issue
|
|
45
|
+
# #1807). It is a meet with the local's current type, member by member:
|
|
46
|
+
#
|
|
47
|
+
# - a member of the literal's own core class (`Integer`, `Float`, `String`, `Symbol`, `NilClass`,
|
|
48
|
+
# `TrueClass`, `FalseClass`) narrows to the literal, a refinement of that class (`non-empty-string`,
|
|
49
|
+
# `decimal-int-string`, `non-zero-int`) included; a `Constant` member is kept when it equals the
|
|
50
|
+
# literal and dropped otherwise; a `Float` member meeting `0.0` (which `-0.0` also equals) is kept;
|
|
51
|
+
# - a core numeric member of another class (`Integer`, `Float`, `Rational`, `Complex`) is kept under
|
|
52
|
+
# `eq` (`1.0 == 1`) and dropped under `eql` (`1.0.eql?(1)` is false);
|
|
53
|
+
# - any other core member (`nil` against `9`, a `String` against `:a`) is dropped: built-in equality
|
|
54
|
+
# across those classes is false;
|
|
55
|
+
# - a member of a core class is kept unnarrowed when the project's source defines the operator on that
|
|
56
|
+
# class or on a core ancestor up to the one that owns the operator at runtime (`class Symbol; def
|
|
57
|
+
# ==`, `class Numeric; def eql?` for an `Integer`, `class Object; def ==` for `nil`): its own method
|
|
58
|
+
# decides.
|
|
59
|
+
#
|
|
60
|
+
# When any member is `Dynamic` or of any other class (a user class, `Object`, `Numeric`, `BigDecimal`),
|
|
61
|
+
# the analyzer emits no fact and the local keeps its type. It also emits none when the project's source
|
|
62
|
+
# defines the operator on the literal's class or those ancestors of it — a core `==` calls back into the
|
|
63
|
+
# literal's (`Integer#==` runs `other == self` for a non-numeric argument, `String#==` for one that
|
|
64
|
+
# responds to `to_str`), so a custom one there can make any member equal — and for an unbound local,
|
|
65
|
+
# when no member survives, and without a scope. A reopening that defines other methods only
|
|
66
|
+
# (`class String; def shout`) does not count.
|
|
67
|
+
#
|
|
68
|
+
# The negative forms (`not_to` / `to_not`) still emit a negative `Constant` fact, which the engine's fact
|
|
69
|
+
# narrowing does not act on, so the local keeps its type there too.
|
|
38
70
|
module MatcherAnalyzer
|
|
39
71
|
module_function
|
|
40
72
|
|
|
41
73
|
# @param call_node — the call whose contribution we're computing. Returns nil when
|
|
42
74
|
# the call shape does not match `expect(local).to matcher`.
|
|
43
|
-
# @param
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
def contribution_for(call_node,
|
|
75
|
+
# @param scope — the scope at the call, or nil. Its environment resolves a matcher's class-name
|
|
76
|
+
# argument to a `Type::Nominal` (without one, a bare `Nominal[<name>]` carrier — sound, the receiver
|
|
77
|
+
# constant may be a user class not in RBS); the local's current type in it is what a positive
|
|
78
|
+
# `eq` / `eql` fact meets (without one, no such fact).
|
|
79
|
+
def contribution_for(call_node, scope:)
|
|
48
80
|
verb = assertion_verb(call_node)
|
|
49
81
|
return nil if verb.nil?
|
|
50
82
|
return nil unless call_node.receiver.is_a?(Prism::CallNode)
|
|
@@ -58,14 +90,15 @@ module Rigor
|
|
|
58
90
|
matcher = matcher_call(call_node)
|
|
59
91
|
return nil if matcher.nil?
|
|
60
92
|
|
|
61
|
-
|
|
93
|
+
negative = verb == :negative
|
|
94
|
+
narrowed_type = narrowed_type_for(matcher, target_local, negative: negative, scope: scope)
|
|
62
95
|
return nil if narrowed_type.nil?
|
|
63
96
|
|
|
64
97
|
fact = Rigor::FlowContribution::Fact.new(
|
|
65
98
|
target_kind: :local,
|
|
66
99
|
target_name: target_local,
|
|
67
100
|
type: narrowed_type,
|
|
68
|
-
negative:
|
|
101
|
+
negative: negative
|
|
69
102
|
)
|
|
70
103
|
Rigor::FlowContribution.new(post_return_facts: [fact])
|
|
71
104
|
end
|
|
@@ -105,7 +138,8 @@ module Rigor
|
|
|
105
138
|
# narrowed type. Returns nil when the matcher is
|
|
106
139
|
# unrecognised or its argument shape does not match the
|
|
107
140
|
# supported envelope.
|
|
108
|
-
def narrowed_type_for(matcher,
|
|
141
|
+
def narrowed_type_for(matcher, target_local, negative:, scope:)
|
|
142
|
+
environment = scope&.environment
|
|
109
143
|
return nil unless matcher.is_a?(Prism::CallNode) && matcher.receiver.nil?
|
|
110
144
|
|
|
111
145
|
case matcher.name
|
|
@@ -116,7 +150,7 @@ module Rigor
|
|
|
116
150
|
|
|
117
151
|
Rigor::Type::Combinator.constant_of(nil)
|
|
118
152
|
when :eq, :eql
|
|
119
|
-
|
|
153
|
+
equality_type_for_literal_arg(matcher, target_local, negative: negative, scope: scope)
|
|
120
154
|
when :match
|
|
121
155
|
# `match(/regex/)` narrows x to String. `match("...")` or `match(arbitrary_object)` falls
|
|
122
156
|
# through — the broader matcher dispatch needs the receiver to be a String, but we can only
|
|
@@ -140,14 +174,123 @@ module Rigor
|
|
|
140
174
|
end
|
|
141
175
|
end
|
|
142
176
|
|
|
143
|
-
def
|
|
177
|
+
def equality_type_for_literal_arg(matcher, target_local, negative:, scope:)
|
|
144
178
|
args = matcher.arguments&.arguments || []
|
|
145
179
|
return nil unless args.size == 1
|
|
146
180
|
|
|
147
181
|
literal_value = literal_value_for(args.first)
|
|
148
182
|
return nil if literal_value.equal?(NO_LITERAL)
|
|
183
|
+
return Rigor::Type::Combinator.constant_of(literal_value) if negative
|
|
184
|
+
return nil if scope.nil?
|
|
185
|
+
|
|
186
|
+
operator = matcher.name == :eql ? :eql? : :==
|
|
187
|
+
equality_meet(scope.local(target_local), literal_value, operator, scope)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# --- equality meet (`eq` / `eql`; see "Equality is the local's own method") ---
|
|
191
|
+
|
|
192
|
+
# Core classes whose `==` / `eql?` Rigor can evaluate against a literal: across any two of them the
|
|
193
|
+
# answer is built in, unless the project defines the operator on one of them.
|
|
194
|
+
EQUALITY_CORE_CLASSES = [Integer, Float, Rational, Complex, String, Symbol, NilClass, TrueClass,
|
|
195
|
+
FalseClass].freeze
|
|
196
|
+
Ractor.make_shareable(EQUALITY_CORE_CLASSES)
|
|
197
|
+
private_constant :EQUALITY_CORE_CLASSES
|
|
198
|
+
|
|
199
|
+
NUMERIC_CLASS_NAMES = %w[Integer Float Rational Complex].freeze
|
|
200
|
+
Ractor.make_shareable(NUMERIC_CLASS_NAMES)
|
|
201
|
+
private_constant :NUMERIC_CLASS_NAMES
|
|
202
|
+
|
|
203
|
+
# For each core class and operator, the class and its ancestors up to and including the one that owns
|
|
204
|
+
# the operator at runtime (`Integer#eql?` is `Numeric`'s, `nil == x` is `BasicObject`'s). A project
|
|
205
|
+
# definition on any of them is the method the member runs; one above the owner is shadowed by it.
|
|
206
|
+
OPERATOR_OWNER_CHAINS = EQUALITY_CORE_CLASSES.each_with_object({}) do |klass, chains|
|
|
207
|
+
%i[== eql?].each do |operator|
|
|
208
|
+
ancestors = klass.ancestors
|
|
209
|
+
owner_index = ancestors.index(klass.instance_method(operator).owner)
|
|
210
|
+
chains[[klass.name, operator]] = ancestors[0..owner_index].map(&:name).freeze
|
|
211
|
+
end
|
|
212
|
+
end.freeze
|
|
213
|
+
Ractor.make_shareable(OPERATOR_OWNER_CHAINS)
|
|
214
|
+
private_constant :OPERATOR_OWNER_CHAINS
|
|
215
|
+
|
|
216
|
+
# Marks a member whose equality with the literal cannot be decided, so the whole meet gives up.
|
|
217
|
+
UNDECIDED = Object.new.freeze
|
|
218
|
+
private_constant :UNDECIDED
|
|
219
|
+
|
|
220
|
+
# The members of `current` that `x <operator> literal` admits, narrowed where the member's class is
|
|
221
|
+
# the literal's, or nil when any member is undecided or none survives. An unbound local (nil) is one
|
|
222
|
+
# undecided member.
|
|
223
|
+
def equality_meet(current, literal, operator, scope)
|
|
224
|
+
return nil if project_defines?(literal.class.name, operator, scope)
|
|
225
|
+
|
|
226
|
+
members = current.is_a?(Rigor::Type::Union) ? current.members : [current]
|
|
227
|
+
kept = []
|
|
228
|
+
members.each do |member|
|
|
229
|
+
met = meet_member(member, literal, operator, scope)
|
|
230
|
+
return nil if met.equal?(UNDECIDED)
|
|
149
231
|
|
|
150
|
-
|
|
232
|
+
kept << met unless met.nil?
|
|
233
|
+
end
|
|
234
|
+
return nil if kept.empty?
|
|
235
|
+
|
|
236
|
+
Rigor::Type::Combinator.union(*kept)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# One member's part of the meet: the member itself or its narrowing, nil when no value of it equals
|
|
240
|
+
# the literal, or UNDECIDED.
|
|
241
|
+
def meet_member(member, literal, operator, scope)
|
|
242
|
+
class_name = member_class_name(member)
|
|
243
|
+
return UNDECIDED if class_name.nil?
|
|
244
|
+
return member if project_defines?(class_name, operator, scope)
|
|
245
|
+
|
|
246
|
+
case member
|
|
247
|
+
when Rigor::Type::Constant then member.value.public_send(operator, literal) ? member : nil
|
|
248
|
+
when Rigor::Type::IntegerRange
|
|
249
|
+
literal.is_a?(Integer) && !member.covers?(literal) ? nil : meet_class(member, class_name, literal, operator)
|
|
250
|
+
else meet_class(member, class_name, literal, operator)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# The core class a member's values belong to, or nil for a member that is not wholly of one. A
|
|
255
|
+
# refinement (`Difference`, `Refined`) belongs to its base's class, an `Intersection` to the class all
|
|
256
|
+
# its members share.
|
|
257
|
+
def member_class_name(member)
|
|
258
|
+
case member
|
|
259
|
+
when Rigor::Type::Difference, Rigor::Type::Refined then member_class_name(member.base)
|
|
260
|
+
when Rigor::Type::Intersection
|
|
261
|
+
names = member.members.map { |part| member_class_name(part) }.uniq
|
|
262
|
+
names.size == 1 ? names.first : nil
|
|
263
|
+
when Rigor::Type::FloatRange then "Float"
|
|
264
|
+
when Rigor::Type::Constant
|
|
265
|
+
klass = EQUALITY_CORE_CLASSES.find { |core| member.value.instance_of?(core) }
|
|
266
|
+
klass&.name
|
|
267
|
+
when Rigor::Type::IntegerRange then "Integer"
|
|
268
|
+
when Rigor::Type::Nominal
|
|
269
|
+
name = member.class_name
|
|
270
|
+
member.type_args.empty? && EQUALITY_CORE_CLASSES.any? { |core| core.name == name } ? name : nil
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def meet_class(member, class_name, literal, operator)
|
|
275
|
+
literal_class = literal.class.name
|
|
276
|
+
if class_name == literal_class
|
|
277
|
+
literal.is_a?(Float) && literal.zero? ? member : Rigor::Type::Combinator.constant_of(literal)
|
|
278
|
+
elsif NUMERIC_CLASS_NAMES.include?(class_name) && NUMERIC_CLASS_NAMES.include?(literal_class)
|
|
279
|
+
operator == :== ? member : nil
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Whether the project's source defines `operator` on the core class `class_name` or on a core ancestor
|
|
284
|
+
# up to the operator's runtime owner (OPERATOR_OWNER_CHAINS): a reopening's `def`, an included or
|
|
285
|
+
# prepended project module, a `define_method`. The def-node walk sees other files' `def`s and records
|
|
286
|
+
# the dependency on them; the discovery table adds `define_method`. An RBS declaration is not
|
|
287
|
+
# consulted: once the project reopens `String` for anything, `String#==`'s declaring owner is a
|
|
288
|
+
# project class, which says nothing about who wrote `==`.
|
|
289
|
+
def project_defines?(class_name, operator, scope)
|
|
290
|
+
OPERATOR_OWNER_CHAINS.fetch([class_name, operator]).any? do |name|
|
|
291
|
+
!scope.user_def_through_ancestors(name, operator).first.nil? ||
|
|
292
|
+
scope.discovered_method_through_ancestors?(name, operator, :instance)
|
|
293
|
+
end
|
|
151
294
|
end
|
|
152
295
|
|
|
153
296
|
def string_type_for_regex_arg(matcher)
|