rigortype 0.0.8 → 0.1.0
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 +234 -22
- data/data/builtins/ruby_core/encoding.yml +210 -0
- data/data/builtins/ruby_core/exception.yml +641 -0
- data/data/builtins/ruby_core/numeric.yml +3 -2
- data/data/builtins/ruby_core/proc.yml +731 -0
- data/data/builtins/ruby_core/random.yml +166 -0
- data/data/builtins/ruby_core/re.yml +689 -0
- data/data/builtins/ruby_core/struct.yml +449 -0
- data/lib/rigor/analysis/check_rules.rb +228 -40
- data/lib/rigor/analysis/diagnostic.rb +15 -1
- data/lib/rigor/analysis/runner.rb +199 -4
- data/lib/rigor/builtins/imported_refinements.rb +6 -1
- data/lib/rigor/cache/rbs_class_ancestor_table.rb +63 -0
- data/lib/rigor/cache/rbs_class_type_param_names.rb +60 -0
- data/lib/rigor/cache/rbs_constant_table.rb +15 -51
- data/lib/rigor/cache/rbs_descriptor.rb +55 -0
- data/lib/rigor/cache/rbs_environment.rb +52 -0
- data/lib/rigor/cache/rbs_environment_marshal_patch.rb +40 -0
- data/lib/rigor/cache/rbs_instance_definitions.rb +79 -0
- data/lib/rigor/cache/rbs_known_class_names.rb +43 -0
- data/lib/rigor/cache/store.rb +81 -15
- data/lib/rigor/cli.rb +45 -7
- data/lib/rigor/configuration/severity_profile.rb +109 -0
- data/lib/rigor/configuration.rb +110 -6
- data/lib/rigor/environment/rbs_hierarchy.rb +18 -5
- data/lib/rigor/environment/rbs_loader.rb +220 -32
- data/lib/rigor/environment.rb +11 -2
- data/lib/rigor/flow_contribution/conflict.rb +81 -0
- data/lib/rigor/flow_contribution/element.rb +53 -0
- data/lib/rigor/flow_contribution/fact.rb +88 -0
- data/lib/rigor/flow_contribution/merge_result.rb +67 -0
- data/lib/rigor/flow_contribution/merger.rb +275 -0
- data/lib/rigor/flow_contribution.rb +179 -0
- data/lib/rigor/inference/block_parameter_binder.rb +15 -0
- data/lib/rigor/inference/builtins/encoding_catalog.rb +67 -0
- data/lib/rigor/inference/builtins/exception_catalog.rb +92 -0
- data/lib/rigor/inference/builtins/proc_catalog.rb +122 -0
- data/lib/rigor/inference/builtins/random_catalog.rb +58 -0
- data/lib/rigor/inference/builtins/re_catalog.rb +81 -0
- data/lib/rigor/inference/builtins/struct_catalog.rb +55 -0
- data/lib/rigor/inference/expression_typer.rb +110 -6
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +16 -1
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +173 -0
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +21 -1
- data/lib/rigor/inference/method_dispatcher.rb +2 -0
- data/lib/rigor/inference/multi_target_binder.rb +2 -0
- data/lib/rigor/inference/narrowing.rb +134 -144
- data/lib/rigor/inference/scope_indexer.rb +75 -1
- data/lib/rigor/inference/statement_evaluator.rb +380 -40
- data/lib/rigor/plugin/access_denied_error.rb +24 -0
- data/lib/rigor/plugin/base.rb +241 -0
- data/lib/rigor/plugin/io_boundary.rb +102 -0
- data/lib/rigor/plugin/load_error.rb +23 -0
- data/lib/rigor/plugin/loader.rb +191 -0
- data/lib/rigor/plugin/manifest.rb +134 -0
- data/lib/rigor/plugin/registry.rb +50 -0
- data/lib/rigor/plugin/services.rb +65 -0
- data/lib/rigor/plugin/trust_policy.rb +99 -0
- data/lib/rigor/plugin.rb +61 -0
- data/lib/rigor/rbs_extended.rb +103 -0
- data/lib/rigor/reflection.rb +2 -2
- data/lib/rigor/type/combinator.rb +72 -0
- data/lib/rigor/type/refined.rb +50 -2
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +13 -0
- data/sig/rigor/environment.rbs +7 -1
- data/sig/rigor/inference.rbs +1 -0
- data/sig/rigor/rbs_extended.rbs +2 -0
- data/sig/rigor/scope.rbs +1 -0
- data/sig/rigor/type.rbs +7 -0
- data/sig/rigor.rbs +3 -1
- metadata +38 -1
|
@@ -42,19 +42,25 @@ module Rigor
|
|
|
42
42
|
# the first preview; later slices broaden it.
|
|
43
43
|
# rubocop:disable Metrics/ModuleLength
|
|
44
44
|
module CheckRules
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
45
|
+
# Canonical identifiers for each rule. Per ADR-8 §
|
|
46
|
+
# "Diagnostic ID family hierarchy", rule names are
|
|
47
|
+
# `family.rule-name` two-segment strings; the families
|
|
48
|
+
# group diagnostics by where they originate
|
|
49
|
+
# (`call.*` for call-site rules, `flow.*` for flow-analysis
|
|
50
|
+
# proofs, `assert.*` for runtime-assertion rules,
|
|
51
|
+
# `dump.*` for debug helpers, `def.*` for method-definition
|
|
52
|
+
# rules). Used by the configuration `disable:` list and the
|
|
53
|
+
# in-source `# rigor:disable <rule>` suppression comment
|
|
54
|
+
# system; new rules MUST register here so user configuration
|
|
55
|
+
# can refer to them.
|
|
56
|
+
RULE_UNDEFINED_METHOD = "call.undefined-method"
|
|
57
|
+
RULE_WRONG_ARITY = "call.wrong-arity"
|
|
58
|
+
RULE_ARGUMENT_TYPE = "call.argument-type-mismatch"
|
|
59
|
+
RULE_NIL_RECEIVER = "call.possible-nil-receiver"
|
|
60
|
+
RULE_DUMP_TYPE = "dump.type"
|
|
61
|
+
RULE_ASSERT_TYPE = "assert.type-mismatch"
|
|
62
|
+
RULE_ALWAYS_RAISES = "flow.always-raises"
|
|
63
|
+
RULE_RETURN_TYPE = "def.return-type-mismatch"
|
|
58
64
|
|
|
59
65
|
ALL_RULES = [
|
|
60
66
|
RULE_UNDEFINED_METHOD,
|
|
@@ -63,9 +69,46 @@ module Rigor
|
|
|
63
69
|
RULE_NIL_RECEIVER,
|
|
64
70
|
RULE_DUMP_TYPE,
|
|
65
71
|
RULE_ASSERT_TYPE,
|
|
66
|
-
RULE_ALWAYS_RAISES
|
|
72
|
+
RULE_ALWAYS_RAISES,
|
|
73
|
+
RULE_RETURN_TYPE
|
|
67
74
|
].freeze
|
|
68
75
|
|
|
76
|
+
# Backward-compat alias table (ADR-8 § "Backward
|
|
77
|
+
# compatibility"). Existing user code with
|
|
78
|
+
# `# rigor:disable undefined-method` /
|
|
79
|
+
# `disable: [undefined-method]` keeps working — the
|
|
80
|
+
# legacy unprefixed identifiers map to their canonical
|
|
81
|
+
# `family.rule-name` form here. Removing the aliases is
|
|
82
|
+
# a future ADR once user code has migrated; until then,
|
|
83
|
+
# both spellings resolve identically.
|
|
84
|
+
LEGACY_RULE_ALIASES = {
|
|
85
|
+
"undefined-method" => RULE_UNDEFINED_METHOD,
|
|
86
|
+
"wrong-arity" => RULE_WRONG_ARITY,
|
|
87
|
+
"argument-type-mismatch" => RULE_ARGUMENT_TYPE,
|
|
88
|
+
"possible-nil-receiver" => RULE_NIL_RECEIVER,
|
|
89
|
+
"dump-type" => RULE_DUMP_TYPE,
|
|
90
|
+
"assert-type" => RULE_ASSERT_TYPE,
|
|
91
|
+
"always-raises" => RULE_ALWAYS_RAISES
|
|
92
|
+
}.freeze
|
|
93
|
+
|
|
94
|
+
# Family wildcard — a `<family>` token in a suppression
|
|
95
|
+
# comment or `disable:` list disables every rule whose
|
|
96
|
+
# canonical id starts with `<family>.`. Per ADR-8 § "1".
|
|
97
|
+
RULE_FAMILIES = %w[call flow assert dump def].freeze
|
|
98
|
+
|
|
99
|
+
# Resolves a user-supplied rule token (`undefined-method`,
|
|
100
|
+
# `call.undefined-method`, or the family wildcard `call`)
|
|
101
|
+
# to the set of canonical rule identifiers it disables.
|
|
102
|
+
# Returns `nil` for `"all"` (the existing wildcard meaning
|
|
103
|
+
# "every rule"), or for unknown tokens.
|
|
104
|
+
def self.resolve_rule_token(token)
|
|
105
|
+
return nil if token == "all"
|
|
106
|
+
return [LEGACY_RULE_ALIASES.fetch(token)] if LEGACY_RULE_ALIASES.key?(token)
|
|
107
|
+
return ALL_RULES.select { |r| r.start_with?("#{token}.") } if RULE_FAMILIES.include?(token)
|
|
108
|
+
|
|
109
|
+
ALL_RULES.include?(token) ? [token] : []
|
|
110
|
+
end
|
|
111
|
+
|
|
69
112
|
module_function
|
|
70
113
|
|
|
71
114
|
# Yields diagnostics for every unrecognised method call on
|
|
@@ -78,35 +121,31 @@ module Rigor
|
|
|
78
121
|
# @param root [Prism::Node]
|
|
79
122
|
# @param scope_index [Hash{Prism::Node => Rigor::Scope}]
|
|
80
123
|
# @return [Array<Rigor::Analysis::Diagnostic>]
|
|
81
|
-
def diagnose(path:, root:, scope_index:, comments: [], disabled_rules: [])
|
|
124
|
+
def diagnose(path:, root:, scope_index:, comments: [], disabled_rules: [])
|
|
82
125
|
diagnostics = []
|
|
83
126
|
Source::NodeWalker.each(root) do |node|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
diagnostics << arity_diagnostic if arity_diagnostic
|
|
91
|
-
|
|
92
|
-
arg_type_diagnostic = argument_type_diagnostic(path, node, scope_index)
|
|
93
|
-
diagnostics << arg_type_diagnostic if arg_type_diagnostic
|
|
94
|
-
|
|
95
|
-
nil_diagnostic = nil_receiver_diagnostic(path, node, scope_index)
|
|
96
|
-
diagnostics << nil_diagnostic if nil_diagnostic
|
|
97
|
-
|
|
98
|
-
dump_diagnostic = dump_type_diagnostic(path, node, scope_index)
|
|
99
|
-
diagnostics << dump_diagnostic if dump_diagnostic
|
|
100
|
-
|
|
101
|
-
assert_diagnostic = assert_type_diagnostic(path, node, scope_index)
|
|
102
|
-
diagnostics << assert_diagnostic if assert_diagnostic
|
|
103
|
-
|
|
104
|
-
raises_diagnostic = always_raises_diagnostic(path, node, scope_index)
|
|
105
|
-
diagnostics << raises_diagnostic if raises_diagnostic
|
|
127
|
+
if node.is_a?(Prism::CallNode)
|
|
128
|
+
diagnostics.concat(call_node_diagnostics(path, node, scope_index))
|
|
129
|
+
elsif node.is_a?(Prism::DefNode)
|
|
130
|
+
return_diagnostic = return_type_mismatch_diagnostic(path, node, scope_index)
|
|
131
|
+
diagnostics << return_diagnostic if return_diagnostic
|
|
132
|
+
end
|
|
106
133
|
end
|
|
107
134
|
filter_suppressed(diagnostics, comments: comments, disabled_rules: disabled_rules)
|
|
108
135
|
end
|
|
109
136
|
|
|
137
|
+
def call_node_diagnostics(path, node, scope_index)
|
|
138
|
+
[
|
|
139
|
+
undefined_method_diagnostic(path, node, scope_index),
|
|
140
|
+
wrong_arity_diagnostic(path, node, scope_index),
|
|
141
|
+
argument_type_diagnostic(path, node, scope_index),
|
|
142
|
+
nil_receiver_diagnostic(path, node, scope_index),
|
|
143
|
+
dump_type_diagnostic(path, node, scope_index),
|
|
144
|
+
assert_type_diagnostic(path, node, scope_index),
|
|
145
|
+
always_raises_diagnostic(path, node, scope_index)
|
|
146
|
+
].compact
|
|
147
|
+
end
|
|
148
|
+
|
|
110
149
|
# v0.0.2 #6 — diagnostic suppression. Two kinds of
|
|
111
150
|
# suppression compose:
|
|
112
151
|
#
|
|
@@ -125,7 +164,7 @@ module Rigor
|
|
|
125
164
|
# silence away.
|
|
126
165
|
def filter_suppressed(diagnostics, comments:, disabled_rules:)
|
|
127
166
|
suppressions = parse_suppression_comments(comments)
|
|
128
|
-
disabled = disabled_rules
|
|
167
|
+
disabled = expand_rule_tokens(disabled_rules)
|
|
129
168
|
|
|
130
169
|
diagnostics.reject do |diagnostic|
|
|
131
170
|
rule = diagnostic.rule
|
|
@@ -137,7 +176,7 @@ module Rigor
|
|
|
137
176
|
end
|
|
138
177
|
end
|
|
139
178
|
|
|
140
|
-
SUPPRESSION_PATTERN = /#\s*rigor:disable\s+(?<rules>[\w
|
|
179
|
+
SUPPRESSION_PATTERN = /#\s*rigor:disable\s+(?<rules>[\w.,\s-]+)/
|
|
141
180
|
private_constant :SUPPRESSION_PATTERN
|
|
142
181
|
|
|
143
182
|
def parse_suppression_comments(comments)
|
|
@@ -148,11 +187,29 @@ module Rigor
|
|
|
148
187
|
next if match.nil?
|
|
149
188
|
|
|
150
189
|
rules = match[:rules].to_s.split(/[\s,]+/).reject(&:empty?)
|
|
151
|
-
rules.each { |
|
|
190
|
+
rules.each { |token| result[comment.location.start_line].merge(expand_token(token)) }
|
|
152
191
|
end
|
|
153
192
|
result
|
|
154
193
|
end
|
|
155
194
|
|
|
195
|
+
# Expands a list of user-supplied rule tokens into the
|
|
196
|
+
# canonical-id set per ADR-8 § "Backward compatibility".
|
|
197
|
+
# `disabled_rules` accepts unprefixed legacy names
|
|
198
|
+
# (`undefined-method`), canonical names
|
|
199
|
+
# (`call.undefined-method`), and family wildcards (`call`).
|
|
200
|
+
def expand_rule_tokens(tokens)
|
|
201
|
+
Array(tokens).each_with_object(Set.new) do |token, set|
|
|
202
|
+
set.merge(expand_token(token.to_s))
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def expand_token(token)
|
|
207
|
+
return ["all"] if token == "all"
|
|
208
|
+
|
|
209
|
+
resolved = resolve_rule_token(token)
|
|
210
|
+
resolved.nil? || resolved.empty? ? [token] : resolved
|
|
211
|
+
end
|
|
212
|
+
|
|
156
213
|
# rubocop:disable Metrics/ClassLength
|
|
157
214
|
class << self
|
|
158
215
|
private
|
|
@@ -792,6 +849,137 @@ module Rigor
|
|
|
792
849
|
severity: :error
|
|
793
850
|
)
|
|
794
851
|
end
|
|
852
|
+
|
|
853
|
+
# ADR-8 § "`def.return-type-mismatch` rule" — flags a
|
|
854
|
+
# `def m(...) ... end` whose body's last expression's
|
|
855
|
+
# type cannot satisfy the RBS-declared return type.
|
|
856
|
+
# Conservative envelope (v0.1.x first cut):
|
|
857
|
+
#
|
|
858
|
+
# - Skips methods without an RBS declaration. The rule
|
|
859
|
+
# has no contract to compare against for source-only
|
|
860
|
+
# methods.
|
|
861
|
+
# - Skips methods whose enclosing class isn't a
|
|
862
|
+
# `Type::Singleton` self_type that we can name (top-
|
|
863
|
+
# level / module-level methods land outside the rule).
|
|
864
|
+
# - Skips methods whose body's last expression is
|
|
865
|
+
# absent or types as `Dynamic[top]` (the analyzer's
|
|
866
|
+
# fail-soft fallback) — emitting on `Dynamic[top]`
|
|
867
|
+
# would be noise.
|
|
868
|
+
# - Compares the inferred body type against the
|
|
869
|
+
# declared return via `accepts?`:
|
|
870
|
+
# :yes → silent
|
|
871
|
+
# :no → emit at :error (severity_profile may
|
|
872
|
+
# re-stamp; default `balanced` keeps the
|
|
873
|
+
# authored severity).
|
|
874
|
+
# :maybe → emit at :warning. Promoted to :error
|
|
875
|
+
# under `severity_profile: strict` per
|
|
876
|
+
# ADR-8 § "Severity profile".
|
|
877
|
+
def return_type_mismatch_diagnostic(path, def_node, scope_index) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
878
|
+
return nil if def_node.body.nil?
|
|
879
|
+
|
|
880
|
+
last_expr = body_last_expression(def_node.body)
|
|
881
|
+
return nil if last_expr.nil?
|
|
882
|
+
|
|
883
|
+
inner_scope = scope_index[last_expr] || scope_index[def_node.body] || scope_index[def_node]
|
|
884
|
+
return nil if inner_scope.nil?
|
|
885
|
+
|
|
886
|
+
declared = declared_return_type(def_node, scope_index)
|
|
887
|
+
return nil if declared.nil?
|
|
888
|
+
|
|
889
|
+
inferred = inner_scope.type_of(last_expr)
|
|
890
|
+
return nil if dynamic_top?(inferred)
|
|
891
|
+
|
|
892
|
+
severity = compare_return(declared, inferred)
|
|
893
|
+
return nil if severity.nil?
|
|
894
|
+
|
|
895
|
+
build_return_type_mismatch_diagnostic(path, def_node, declared, inferred, severity)
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
# The body of a `def` is the last `Prism::StatementsNode`
|
|
899
|
+
# child (or a single expression for one-liner defs).
|
|
900
|
+
# Take the last statement; that's the implicit return.
|
|
901
|
+
def body_last_expression(body)
|
|
902
|
+
case body
|
|
903
|
+
when Prism::StatementsNode then body.body.last
|
|
904
|
+
when Prism::BeginNode then body_last_expression(body.statements)
|
|
905
|
+
else body
|
|
906
|
+
end
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
# Pulls the declared RBS return type for the def. The
|
|
910
|
+
# enclosing class name comes from the def's scope's
|
|
911
|
+
# `self_type`; the method name is on the def itself.
|
|
912
|
+
# `def self.foo` is a singleton method — dispatched
|
|
913
|
+
# through `Reflection.singleton_method_definition`;
|
|
914
|
+
# plain `def foo` uses `instance_method_definition`.
|
|
915
|
+
# Method overloads contribute their union of declared
|
|
916
|
+
# return types (any one of them satisfying the body
|
|
917
|
+
# silences the rule).
|
|
918
|
+
def declared_return_type(def_node, scope_index)
|
|
919
|
+
scope = scope_index[def_node]
|
|
920
|
+
return nil if scope.nil?
|
|
921
|
+
|
|
922
|
+
self_type = scope.self_type
|
|
923
|
+
return nil unless self_type.respond_to?(:class_name)
|
|
924
|
+
|
|
925
|
+
method_def =
|
|
926
|
+
if def_node.receiver.nil?
|
|
927
|
+
Reflection.instance_method_definition(self_type.class_name, def_node.name, scope: scope)
|
|
928
|
+
else
|
|
929
|
+
Reflection.singleton_method_definition(self_type.class_name, def_node.name, scope: scope)
|
|
930
|
+
end
|
|
931
|
+
return nil if method_def.nil?
|
|
932
|
+
|
|
933
|
+
declared_return_union(method_def, scope.environment)
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
def declared_return_union(method_def, _environment)
|
|
937
|
+
translated = method_def.method_types.filter_map do |mt|
|
|
938
|
+
Inference::RbsTypeTranslator.translate(
|
|
939
|
+
mt.type.return_type,
|
|
940
|
+
self_type: nil, instance_type: nil, type_vars: {}
|
|
941
|
+
)
|
|
942
|
+
rescue StandardError
|
|
943
|
+
nil
|
|
944
|
+
end
|
|
945
|
+
return nil if translated.empty?
|
|
946
|
+
|
|
947
|
+
translated.size == 1 ? translated.first : Type::Combinator.union(*translated)
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
def dynamic_top?(type)
|
|
951
|
+
type.is_a?(Type::Dynamic) || (type.respond_to?(:top?) && type.top?.yes?)
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
# Returns the severity to emit at, or nil to stay
|
|
955
|
+
# silent. The first-cut implementation only fires on
|
|
956
|
+
# proven (`:no`) mismatches; `:maybe` is treated as
|
|
957
|
+
# silent until the analyzer's narrowing becomes precise
|
|
958
|
+
# enough to avoid noise on common patterns (`{}` →
|
|
959
|
+
# declared `Hash[K, V]`, `Set.new` → declared
|
|
960
|
+
# `Set[Symbol]`, …). ADR-8's promise to emit on
|
|
961
|
+
# `:maybe` under `severity_profile: strict` is
|
|
962
|
+
# deferred to a follow-up that lands together with the
|
|
963
|
+
# narrowing precision improvements.
|
|
964
|
+
def compare_return(declared, inferred)
|
|
965
|
+
result = declared.accepts(inferred)
|
|
966
|
+
return :error if result.no?
|
|
967
|
+
|
|
968
|
+
nil
|
|
969
|
+
end
|
|
970
|
+
|
|
971
|
+
def build_return_type_mismatch_diagnostic(path, def_node, declared, inferred, severity)
|
|
972
|
+
location = def_node.name_loc || def_node.location
|
|
973
|
+
Diagnostic.new(
|
|
974
|
+
rule: RULE_RETURN_TYPE,
|
|
975
|
+
path: path,
|
|
976
|
+
line: location.start_line,
|
|
977
|
+
column: location.start_column + 1,
|
|
978
|
+
message: "return-type mismatch on `#{def_node.name}': " \
|
|
979
|
+
"declared #{declared.describe(:short)}, inferred #{inferred.describe(:short)}",
|
|
980
|
+
severity: severity
|
|
981
|
+
)
|
|
982
|
+
end
|
|
795
983
|
end
|
|
796
984
|
# rubocop:enable Metrics/ClassLength
|
|
797
985
|
end
|
|
@@ -64,8 +64,22 @@ module Rigor
|
|
|
64
64
|
}
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
+
# Text rendering for `rigor check`. The qualified rule
|
|
68
|
+
# identifier (per ADR-2 § "Plugin Diagnostic Provenance" —
|
|
69
|
+
# `plugin.<id>.<rule>`, `rbs_extended.<rule>`,
|
|
70
|
+
# `generated.<provider>.<rule>`) is appended in brackets
|
|
71
|
+
# whenever the diagnostic carries a non-default `source_family`,
|
|
72
|
+
# so plugin / RBS::Extended / generated provenance is visible
|
|
73
|
+
# in the standard text output without changing the layout for
|
|
74
|
+
# built-in rules. Slice 5 (v0.1.0) wires this surface.
|
|
67
75
|
def to_s
|
|
68
|
-
"#{path}:#{line}:#{column}: #{severity}: #{message}"
|
|
76
|
+
base = "#{path}:#{line}:#{column}: #{severity}: #{message}"
|
|
77
|
+
return base if source_family == DEFAULT_SOURCE_FAMILY
|
|
78
|
+
|
|
79
|
+
qualified = qualified_rule
|
|
80
|
+
return base if qualified.nil?
|
|
81
|
+
|
|
82
|
+
"#{base} [#{qualified}]"
|
|
69
83
|
end
|
|
70
84
|
end
|
|
71
85
|
end
|
|
@@ -4,6 +4,10 @@ require "prism"
|
|
|
4
4
|
|
|
5
5
|
require_relative "../environment"
|
|
6
6
|
require_relative "../scope"
|
|
7
|
+
require_relative "../cache/store"
|
|
8
|
+
require_relative "../plugin"
|
|
9
|
+
require_relative "../reflection"
|
|
10
|
+
require_relative "../type/combinator"
|
|
7
11
|
require_relative "../inference/coverage_scanner"
|
|
8
12
|
require_relative "../inference/scope_indexer"
|
|
9
13
|
require_relative "../inference/method_dispatcher/file_folding"
|
|
@@ -15,10 +19,27 @@ module Rigor
|
|
|
15
19
|
module Analysis
|
|
16
20
|
class Runner # rubocop:disable Metrics/ClassLength
|
|
17
21
|
RUBY_GLOB = "**/*.rb"
|
|
22
|
+
DEFAULT_CACHE_ROOT = ".rigor/cache"
|
|
18
23
|
|
|
19
|
-
|
|
24
|
+
attr_reader :cache_store, :plugin_registry
|
|
25
|
+
|
|
26
|
+
# @param configuration [Rigor::Configuration]
|
|
27
|
+
# @param explain [Boolean] surface fail-soft fallback events
|
|
28
|
+
# as `:info` diagnostics.
|
|
29
|
+
# @param cache_store [Rigor::Cache::Store, nil] the persistent
|
|
30
|
+
# cache the runner exposes to producers (`RbsConstantTable`
|
|
31
|
+
# and successors). Pass `nil` to disable caching for this
|
|
32
|
+
# run; the CLI's `--no-cache` flag wires `nil` through.
|
|
33
|
+
# v0.0.9 group A slice 1 introduces the surface; later
|
|
34
|
+
# slices route real producers through it.
|
|
35
|
+
def initialize(configuration:, explain: false,
|
|
36
|
+
cache_store: Cache::Store.new(root: DEFAULT_CACHE_ROOT),
|
|
37
|
+
plugin_requirer: nil)
|
|
20
38
|
@configuration = configuration
|
|
21
39
|
@explain = explain
|
|
40
|
+
@cache_store = cache_store
|
|
41
|
+
@plugin_requirer = plugin_requirer
|
|
42
|
+
@plugin_registry = Plugin::Registry::EMPTY
|
|
22
43
|
end
|
|
23
44
|
|
|
24
45
|
# Walks every Ruby file under `paths`, parses it, builds a
|
|
@@ -35,18 +56,191 @@ module Rigor
|
|
|
35
56
|
|
|
36
57
|
environment = Environment.for_project(
|
|
37
58
|
libraries: @configuration.libraries,
|
|
38
|
-
signature_paths: @configuration.signature_paths
|
|
59
|
+
signature_paths: @configuration.signature_paths,
|
|
60
|
+
cache_store: @cache_store
|
|
39
61
|
)
|
|
62
|
+
|
|
63
|
+
@plugin_registry = load_plugins
|
|
40
64
|
expansion = expand_paths(paths)
|
|
41
65
|
|
|
42
|
-
diagnostics =
|
|
66
|
+
diagnostics = plugin_load_diagnostics
|
|
67
|
+
diagnostics += expansion.fetch(:errors)
|
|
43
68
|
diagnostics += expansion.fetch(:files).flat_map { |path| analyze_file(path, environment) }
|
|
44
69
|
|
|
45
|
-
Result.new(diagnostics: diagnostics)
|
|
70
|
+
Result.new(diagnostics: apply_severity_profile(diagnostics))
|
|
46
71
|
end
|
|
47
72
|
|
|
48
73
|
private
|
|
49
74
|
|
|
75
|
+
# Loads project-configured plugins through {Rigor::Plugin::Loader}
|
|
76
|
+
# and returns the resulting {Rigor::Plugin::Registry}. Loader
|
|
77
|
+
# failures are isolated: each surfaces as a `:plugin_loader`
|
|
78
|
+
# diagnostic on the run's `Result` rather than aborting the
|
|
79
|
+
# analysis. Plugins that load successfully but contribute no
|
|
80
|
+
# protocol hooks are inert in slice 1; later v0.1.0 slices
|
|
81
|
+
# wire the contribution merger through this registry.
|
|
82
|
+
def load_plugins
|
|
83
|
+
return Plugin::Registry::EMPTY if @configuration.plugins.empty?
|
|
84
|
+
|
|
85
|
+
services = Plugin::Services.new(
|
|
86
|
+
reflection: Reflection,
|
|
87
|
+
type: Type::Combinator,
|
|
88
|
+
configuration: @configuration,
|
|
89
|
+
cache_store: @cache_store,
|
|
90
|
+
trust_policy: build_trust_policy
|
|
91
|
+
)
|
|
92
|
+
if @plugin_requirer
|
|
93
|
+
Plugin::Loader.load(configuration: @configuration, services: services, requirer: @plugin_requirer)
|
|
94
|
+
else
|
|
95
|
+
Plugin::Loader.load(configuration: @configuration, services: services)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Builds the {Rigor::Plugin::TrustPolicy} for this run. Trusted
|
|
100
|
+
# gems are the gem-name half of every entry in
|
|
101
|
+
# `Configuration#plugins`. Allowed read roots default to the
|
|
102
|
+
# project root (CWD), the project's signature_paths, and each
|
|
103
|
+
# trusted gem's `Gem::Specification#full_gem_path`, plus any
|
|
104
|
+
# extras the user listed under `plugins_io.allowed_paths`.
|
|
105
|
+
# Slice 2 keeps `network_policy` `:disabled` — the only value
|
|
106
|
+
# the configuration accepts today.
|
|
107
|
+
def build_trust_policy
|
|
108
|
+
trusted_gems = @configuration.plugins.map { |entry| trusted_gem_name(entry) }.uniq
|
|
109
|
+
roots = [Dir.pwd]
|
|
110
|
+
Array(@configuration.signature_paths).each { |sp| roots << File.expand_path(sp) }
|
|
111
|
+
trusted_gems.each do |gem_name|
|
|
112
|
+
path = trusted_gem_root(gem_name)
|
|
113
|
+
roots << path if path
|
|
114
|
+
end
|
|
115
|
+
@configuration.plugins_io_allowed_paths.each { |p| roots << File.expand_path(p) }
|
|
116
|
+
|
|
117
|
+
Plugin::TrustPolicy.new(
|
|
118
|
+
trusted_gems: trusted_gems,
|
|
119
|
+
allowed_read_roots: roots,
|
|
120
|
+
network_policy: @configuration.plugins_io_network
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def trusted_gem_name(entry)
|
|
125
|
+
case entry
|
|
126
|
+
when String then entry
|
|
127
|
+
when Hash then entry["gem"] || entry["id"]
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def trusted_gem_root(gem_name)
|
|
132
|
+
return nil if gem_name.nil? || gem_name.empty?
|
|
133
|
+
|
|
134
|
+
spec = Gem.loaded_specs[gem_name]
|
|
135
|
+
spec&.full_gem_path # rigor:disable undefined-method
|
|
136
|
+
rescue StandardError
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# ADR-8 § "Severity profile" — re-stamps each diagnostic's
|
|
141
|
+
# severity from the configured profile + per-rule
|
|
142
|
+
# overrides. Rules emit with their authored severity; the
|
|
143
|
+
# profile is the final filter. Diagnostics whose resolved
|
|
144
|
+
# severity is `:off` are dropped from the run result.
|
|
145
|
+
def apply_severity_profile(diagnostics)
|
|
146
|
+
diagnostics.filter_map { |diagnostic| stamp_severity(diagnostic) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def stamp_severity(diagnostic)
|
|
150
|
+
return diagnostic if diagnostic.rule.nil?
|
|
151
|
+
|
|
152
|
+
resolved = Configuration::SeverityProfile.resolve(
|
|
153
|
+
rule: diagnostic.rule,
|
|
154
|
+
authored_severity: diagnostic.severity,
|
|
155
|
+
profile: @configuration.severity_profile,
|
|
156
|
+
overrides: @configuration.severity_overrides
|
|
157
|
+
)
|
|
158
|
+
return nil if resolved == :off
|
|
159
|
+
return diagnostic if resolved == diagnostic.severity
|
|
160
|
+
|
|
161
|
+
Diagnostic.new(
|
|
162
|
+
path: diagnostic.path,
|
|
163
|
+
line: diagnostic.line,
|
|
164
|
+
column: diagnostic.column,
|
|
165
|
+
message: diagnostic.message,
|
|
166
|
+
severity: resolved,
|
|
167
|
+
rule: diagnostic.rule,
|
|
168
|
+
source_family: diagnostic.source_family
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def plugin_load_diagnostics
|
|
173
|
+
@plugin_registry.load_errors.map do |error|
|
|
174
|
+
Diagnostic.new(
|
|
175
|
+
path: ".rigor.yml",
|
|
176
|
+
line: 1,
|
|
177
|
+
column: 1,
|
|
178
|
+
message: error.message,
|
|
179
|
+
severity: :error,
|
|
180
|
+
rule: "load-error",
|
|
181
|
+
source_family: :plugin_loader
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# ADR-7 § "Slice 5-A/5-B" — invokes every loaded plugin's
|
|
187
|
+
# per-file diagnostic emission hook
|
|
188
|
+
# (`Plugin::Base#diagnostics_for_file`) and re-stamps the
|
|
189
|
+
# returned diagnostics with
|
|
190
|
+
# `source_family: "plugin.<manifest.id>"` so plugin
|
|
191
|
+
# authors cannot accidentally publish under another
|
|
192
|
+
# plugin's identifier or under `:builtin`. Plugin
|
|
193
|
+
# exceptions are isolated per ADR-2 § "Plugin Trust and
|
|
194
|
+
# I/O Policy" — a raise from one plugin becomes a
|
|
195
|
+
# `:plugin_loader` `runtime-error` diagnostic without
|
|
196
|
+
# affecting other plugins or the rest of the run.
|
|
197
|
+
def plugin_emitted_diagnostics(path, root, scope)
|
|
198
|
+
return [] if @plugin_registry.empty?
|
|
199
|
+
|
|
200
|
+
@plugin_registry.plugins.flat_map do |plugin|
|
|
201
|
+
collect_plugin_diagnostics(plugin, path, root, scope)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def collect_plugin_diagnostics(plugin, path, root, scope)
|
|
206
|
+
raw = plugin.diagnostics_for_file(path: path, scope: scope, root: root)
|
|
207
|
+
Array(raw).map { |diagnostic| stamp_plugin_diagnostic(diagnostic, plugin.manifest.id) }
|
|
208
|
+
rescue StandardError => e
|
|
209
|
+
[plugin_runtime_error_diagnostic(path, plugin, e)]
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def stamp_plugin_diagnostic(diagnostic, plugin_id)
|
|
213
|
+
Diagnostic.new(
|
|
214
|
+
path: diagnostic.path,
|
|
215
|
+
line: diagnostic.line,
|
|
216
|
+
column: diagnostic.column,
|
|
217
|
+
message: diagnostic.message,
|
|
218
|
+
severity: diagnostic.severity,
|
|
219
|
+
rule: diagnostic.rule,
|
|
220
|
+
source_family: "plugin.#{plugin_id}"
|
|
221
|
+
)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def plugin_runtime_error_diagnostic(path, plugin, error)
|
|
225
|
+
plugin_id = safe_plugin_id(plugin)
|
|
226
|
+
Diagnostic.new(
|
|
227
|
+
path: path,
|
|
228
|
+
line: 1,
|
|
229
|
+
column: 1,
|
|
230
|
+
message: "plugin #{plugin_id.inspect} raised during diagnostics_for_file: " \
|
|
231
|
+
"#{error.class}: #{error.message}",
|
|
232
|
+
severity: :error,
|
|
233
|
+
rule: "runtime-error",
|
|
234
|
+
source_family: :plugin_loader
|
|
235
|
+
)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def safe_plugin_id(plugin)
|
|
239
|
+
plugin.manifest.id
|
|
240
|
+
rescue StandardError
|
|
241
|
+
plugin.class.to_s
|
|
242
|
+
end
|
|
243
|
+
|
|
50
244
|
# Resolves the user-supplied path list into:
|
|
51
245
|
# - `:files` — the concrete `.rb` files to analyze.
|
|
52
246
|
# - `:errors` — `Diagnostic` entries for each path that
|
|
@@ -95,6 +289,7 @@ module Rigor
|
|
|
95
289
|
comments: parse_result.comments,
|
|
96
290
|
disabled_rules: @configuration.disabled_rules
|
|
97
291
|
)
|
|
292
|
+
diagnostics += plugin_emitted_diagnostics(path, parse_result.value, scope)
|
|
98
293
|
diagnostics + explain_diagnostics(path, parse_result.value, scope)
|
|
99
294
|
rescue Errno::ENOENT => e
|
|
100
295
|
[
|
|
@@ -54,13 +54,18 @@ module Rigor
|
|
|
54
54
|
"negative-int" => -> { Type::Combinator.negative_int },
|
|
55
55
|
"non-positive-int" => -> { Type::Combinator.non_positive_int },
|
|
56
56
|
"lowercase-string" => -> { Type::Combinator.lowercase_string },
|
|
57
|
+
"non-lowercase-string" => -> { Type::Combinator.non_lowercase_string },
|
|
57
58
|
"uppercase-string" => -> { Type::Combinator.uppercase_string },
|
|
59
|
+
"non-uppercase-string" => -> { Type::Combinator.non_uppercase_string },
|
|
58
60
|
"numeric-string" => -> { Type::Combinator.numeric_string },
|
|
61
|
+
"non-numeric-string" => -> { Type::Combinator.non_numeric_string },
|
|
59
62
|
"decimal-int-string" => -> { Type::Combinator.decimal_int_string },
|
|
60
63
|
"octal-int-string" => -> { Type::Combinator.octal_int_string },
|
|
61
64
|
"hex-int-string" => -> { Type::Combinator.hex_int_string },
|
|
62
65
|
"non-empty-lowercase-string" => -> { Type::Combinator.non_empty_lowercase_string },
|
|
63
|
-
"non-empty-uppercase-string" => -> { Type::Combinator.non_empty_uppercase_string }
|
|
66
|
+
"non-empty-uppercase-string" => -> { Type::Combinator.non_empty_uppercase_string },
|
|
67
|
+
"literal-string" => -> { Type::Combinator.literal_string },
|
|
68
|
+
"non-empty-literal-string" => -> { Type::Combinator.non_empty_literal_string }
|
|
64
69
|
}.freeze
|
|
65
70
|
private_constant :REGISTRY
|
|
66
71
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rbs_descriptor"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Cache
|
|
7
|
+
# Cache producer that materialises the RBS-declared ancestor
|
|
8
|
+
# chain of every loaded class / module into a Marshal-clean
|
|
9
|
+
# `Hash<String, Array<String>>`. Ancestor names are top-level-
|
|
10
|
+
# stripped (e.g. `"Integer"` not `"::Integer"`) to match
|
|
11
|
+
# `Environment::RbsHierarchy#normalize_name`.
|
|
12
|
+
#
|
|
13
|
+
# The hierarchy is the substrate behind every `class_ordering`
|
|
14
|
+
# query, which is itself a hot path on the dispatcher (overload
|
|
15
|
+
# selection, narrowing, etc.). Building one ancestor chain
|
|
16
|
+
# requires a full `RBS::DefinitionBuilder#build_instance` over
|
|
17
|
+
# that class — a cold-cost dominated by RBS's own resolution
|
|
18
|
+
# work. Caching the table lets a warm process skip the build
|
|
19
|
+
# entirely and pay only a `Marshal.load` of the resulting
|
|
20
|
+
# hash.
|
|
21
|
+
#
|
|
22
|
+
# Cache descriptor shape is shared with every other cache
|
|
23
|
+
# producer that depends on the RBS environment — see
|
|
24
|
+
# {RbsDescriptor.build}.
|
|
25
|
+
class RbsClassAncestorTable
|
|
26
|
+
PRODUCER_ID = "rbs.class_ancestor_table"
|
|
27
|
+
|
|
28
|
+
# @param loader [Rigor::Environment::RbsLoader]
|
|
29
|
+
# @param store [Rigor::Cache::Store]
|
|
30
|
+
# @return [Hash{String => Array<String>}]
|
|
31
|
+
def self.fetch(loader:, store:)
|
|
32
|
+
descriptor = RbsDescriptor.build(loader)
|
|
33
|
+
store.fetch_or_compute(producer_id: PRODUCER_ID, params: {}, descriptor: descriptor) do
|
|
34
|
+
compute(loader)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.compute(loader)
|
|
39
|
+
table = {}
|
|
40
|
+
loader.each_known_class_name do |name|
|
|
41
|
+
key = name.delete_prefix("::")
|
|
42
|
+
ancestors = ancestors_for(loader, key)
|
|
43
|
+
table[key] = ancestors unless ancestors.nil?
|
|
44
|
+
end
|
|
45
|
+
table
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.ancestors_for(loader, class_name)
|
|
49
|
+
definition = loader.instance_definition(class_name)
|
|
50
|
+
return nil if definition.nil?
|
|
51
|
+
|
|
52
|
+
definition.ancestors.ancestors
|
|
53
|
+
.map { |ancestor| ancestor.name.to_s.delete_prefix("::") }
|
|
54
|
+
.uniq
|
|
55
|
+
.freeze
|
|
56
|
+
rescue ::RBS::BaseError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private_class_method :compute, :ancestors_for
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|