rigortype 0.0.9 → 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 +40 -2
- data/lib/rigor/analysis/check_rules.rb +228 -40
- data/lib/rigor/analysis/diagnostic.rb +15 -1
- data/lib/rigor/analysis/runner.rb +183 -4
- data/lib/rigor/cache/rbs_class_ancestor_table.rb +1 -1
- data/lib/rigor/cache/rbs_class_type_param_names.rb +1 -1
- data/lib/rigor/cache/rbs_constant_table.rb +2 -2
- data/lib/rigor/cache/rbs_descriptor.rb +2 -0
- data/lib/rigor/cache/rbs_instance_definitions.rb +79 -0
- data/lib/rigor/cache/store.rb +2 -0
- data/lib/rigor/cli.rb +9 -3
- data/lib/rigor/configuration/severity_profile.rb +109 -0
- data/lib/rigor/configuration.rb +110 -6
- data/lib/rigor/environment/rbs_loader.rb +89 -13
- 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 +51 -0
- data/lib/rigor/inference/block_parameter_binder.rb +15 -0
- data/lib/rigor/inference/expression_typer.rb +84 -5
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +95 -9
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +21 -1
- data/lib/rigor/inference/multi_target_binder.rb +2 -0
- data/lib/rigor/inference/narrowing.rb +105 -130
- 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 +57 -9
- data/lib/rigor/reflection.rb +2 -2
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +7 -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
- metadata +18 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 257469533556c55164c5dbb9ea351c142ea600b17a90b6f9e632b5307bbbac08
|
|
4
|
+
data.tar.gz: be2141e8f53748716bd7f19e95632a1fab17fde8110dd824d981baaf2522371c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 221248b2968dcd5d11963197468407ffbc34f2c41b79aa45321a5171e8183817e1ed67fbac37c852c6f22c910b065fef7214cf5f5ef2f03ec6f3c2aef5cce213
|
|
7
|
+
data.tar.gz: d2b096836618fd21a44810aa7c87bad2b0fd28a90baf395dd206ece02844d65819f1ab0cc80ea7a956a4b590351a4f67572be959d8b4effc9d086ff120498e2e
|
data/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Rigor
|
|
2
2
|
|
|
3
3
|
[](https://badge.fury.io/rb/rigortype)
|
|
4
|
-

|
|
4
|
+
[](https://github.com/rigortype/rigor/blob/master/LICENSE)
|
|
5
|
+
[](https://deepwiki.com/rigortype/rigor)
|
|
5
6
|
|
|
6
7
|
**Inference-first static analysis for Ruby.** Add Rigor to your
|
|
7
8
|
Gemfile and run `rigor check` over your code — no annotations,
|
|
@@ -207,6 +208,12 @@ genuinely proved.
|
|
|
207
208
|
|
|
208
209
|
### Where the type model is documented
|
|
209
210
|
|
|
211
|
+
- **End-user handbook** — chapter-by-chapter walkthrough of
|
|
212
|
+
the type model written for Ruby programmers without prior
|
|
213
|
+
static-typing background:
|
|
214
|
+
[`docs/handbook/`](docs/handbook/README.md). Start here if
|
|
215
|
+
you want a guided tour of how Rigor sees your code rather
|
|
216
|
+
than a spec deep-dive.
|
|
210
217
|
- One-page mental model:
|
|
211
218
|
[`docs/types.md`](docs/types.md).
|
|
212
219
|
- Binding spec corpus:
|
|
@@ -366,6 +373,37 @@ The full per-release surface lives in
|
|
|
366
373
|
analyzer guarantees live under
|
|
367
374
|
[`docs/internal-spec/`](docs/internal-spec/).
|
|
368
375
|
|
|
376
|
+
## Plugins (v0.1.0)
|
|
377
|
+
|
|
378
|
+
`v0.1.0` adds an extension API so projects can teach Rigor about
|
|
379
|
+
their own DSLs. Six worked examples ship under
|
|
380
|
+
[`examples/`](examples/) — each is a fully-shaped plugin gem
|
|
381
|
+
with a runnable demo and an end-to-end integration spec, and
|
|
382
|
+
each spotlights a different facet of the plugin contract:
|
|
383
|
+
|
|
384
|
+
- [`rigor-deprecations`](examples/rigor-deprecations/) —
|
|
385
|
+
smallest possible plugin (~80 lines); config-driven rules.
|
|
386
|
+
- [`rigor-lisp-eval`](examples/rigor-lisp-eval/) — typing literal
|
|
387
|
+
AST arguments at a method call.
|
|
388
|
+
- [`rigor-statesman`](examples/rigor-statesman/) — two-pass DSL
|
|
389
|
+
analysis (collect declarations, then validate references).
|
|
390
|
+
- [`rigor-pattern`](examples/rigor-pattern/) — plugin →
|
|
391
|
+
analyzer collaboration via `Scope#type_of` and the
|
|
392
|
+
literal-string carrier.
|
|
393
|
+
- [`rigor-units`](examples/rigor-units/) — local-variable flow
|
|
394
|
+
tracking through arithmetic.
|
|
395
|
+
- [`rigor-routes`](examples/rigor-routes/) — `Plugin::IoBoundary`
|
|
396
|
+
reads under `TrustPolicy` plus cache producers (slice 2 +
|
|
397
|
+
slice 6) — the most architecturally complete example.
|
|
398
|
+
|
|
399
|
+
[`examples/README.md`](examples/README.md) is the plugin
|
|
400
|
+
authoring landing page — comparison table, recommended reading
|
|
401
|
+
order, and the architectural map of which surface each example
|
|
402
|
+
exercises. The binding contract for the plugin API lives in
|
|
403
|
+
[`docs/adr/2-extension-api.md`](docs/adr/2-extension-api.md)
|
|
404
|
+
and the slice-by-slice normative specs under
|
|
405
|
+
[`docs/internal-spec/plugin*.md`](docs/internal-spec/).
|
|
406
|
+
|
|
369
407
|
## Configuration
|
|
370
408
|
|
|
371
409
|
`rigor init` writes a starter `.rigor.yml`:
|
|
@@ -421,4 +459,4 @@ skill documentation contributors should know about.
|
|
|
421
459
|
|
|
422
460
|
Mozilla Public License Version 2.0. See [`LICENSE`](LICENSE).
|
|
423
461
|
</content>
|
|
424
|
-
</invoke>
|
|
462
|
+
</invoke>
|
|
@@ -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
|
|
@@ -5,6 +5,9 @@ require "prism"
|
|
|
5
5
|
require_relative "../environment"
|
|
6
6
|
require_relative "../scope"
|
|
7
7
|
require_relative "../cache/store"
|
|
8
|
+
require_relative "../plugin"
|
|
9
|
+
require_relative "../reflection"
|
|
10
|
+
require_relative "../type/combinator"
|
|
8
11
|
require_relative "../inference/coverage_scanner"
|
|
9
12
|
require_relative "../inference/scope_indexer"
|
|
10
13
|
require_relative "../inference/method_dispatcher/file_folding"
|
|
@@ -18,7 +21,7 @@ module Rigor
|
|
|
18
21
|
RUBY_GLOB = "**/*.rb"
|
|
19
22
|
DEFAULT_CACHE_ROOT = ".rigor/cache"
|
|
20
23
|
|
|
21
|
-
attr_reader :cache_store
|
|
24
|
+
attr_reader :cache_store, :plugin_registry
|
|
22
25
|
|
|
23
26
|
# @param configuration [Rigor::Configuration]
|
|
24
27
|
# @param explain [Boolean] surface fail-soft fallback events
|
|
@@ -30,10 +33,13 @@ module Rigor
|
|
|
30
33
|
# v0.0.9 group A slice 1 introduces the surface; later
|
|
31
34
|
# slices route real producers through it.
|
|
32
35
|
def initialize(configuration:, explain: false,
|
|
33
|
-
cache_store: Cache::Store.new(root: DEFAULT_CACHE_ROOT)
|
|
36
|
+
cache_store: Cache::Store.new(root: DEFAULT_CACHE_ROOT),
|
|
37
|
+
plugin_requirer: nil)
|
|
34
38
|
@configuration = configuration
|
|
35
39
|
@explain = explain
|
|
36
40
|
@cache_store = cache_store
|
|
41
|
+
@plugin_requirer = plugin_requirer
|
|
42
|
+
@plugin_registry = Plugin::Registry::EMPTY
|
|
37
43
|
end
|
|
38
44
|
|
|
39
45
|
# Walks every Ruby file under `paths`, parses it, builds a
|
|
@@ -53,16 +59,188 @@ module Rigor
|
|
|
53
59
|
signature_paths: @configuration.signature_paths,
|
|
54
60
|
cache_store: @cache_store
|
|
55
61
|
)
|
|
62
|
+
|
|
63
|
+
@plugin_registry = load_plugins
|
|
56
64
|
expansion = expand_paths(paths)
|
|
57
65
|
|
|
58
|
-
diagnostics =
|
|
66
|
+
diagnostics = plugin_load_diagnostics
|
|
67
|
+
diagnostics += expansion.fetch(:errors)
|
|
59
68
|
diagnostics += expansion.fetch(:files).flat_map { |path| analyze_file(path, environment) }
|
|
60
69
|
|
|
61
|
-
Result.new(diagnostics: diagnostics)
|
|
70
|
+
Result.new(diagnostics: apply_severity_profile(diagnostics))
|
|
62
71
|
end
|
|
63
72
|
|
|
64
73
|
private
|
|
65
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
|
+
|
|
66
244
|
# Resolves the user-supplied path list into:
|
|
67
245
|
# - `:files` — the concrete `.rb` files to analyze.
|
|
68
246
|
# - `:errors` — `Diagnostic` entries for each path that
|
|
@@ -111,6 +289,7 @@ module Rigor
|
|
|
111
289
|
comments: parse_result.comments,
|
|
112
290
|
disabled_rules: @configuration.disabled_rules
|
|
113
291
|
)
|
|
292
|
+
diagnostics += plugin_emitted_diagnostics(path, parse_result.value, scope)
|
|
114
293
|
diagnostics + explain_diagnostics(path, parse_result.value, scope)
|
|
115
294
|
rescue Errno::ENOENT => e
|
|
116
295
|
[
|