rigortype 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +7 -2
- data/data/builtins/ruby_core/array.yml +6 -6
- data/data/builtins/ruby_core/hash.yml +1 -1
- data/data/builtins/ruby_core/io.yml +3 -3
- data/data/builtins/ruby_core/numeric.yml +1 -1
- data/data/builtins/ruby_core/pathname.yml +100 -100
- data/data/builtins/ruby_core/proc.yml +1 -1
- data/data/builtins/ruby_core/range.yml +6 -4
- data/data/builtins/ruby_core/string.yml +15 -10
- data/data/builtins/ruby_core/time.yml +3 -3
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +116 -0
- data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +123 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +118 -0
- data/lib/rigor/analysis/check_rules.rb +346 -18
- data/lib/rigor/analysis/rule_catalog.rb +343 -0
- data/lib/rigor/analysis/runner.rb +90 -6
- data/lib/rigor/builtins/regex_refinement.rb +104 -0
- data/lib/rigor/cli/diff_command.rb +169 -0
- data/lib/rigor/cli/explain_command.rb +129 -0
- data/lib/rigor/cli/type_of_command.rb +3 -3
- data/lib/rigor/cli/type_scan_command.rb +4 -4
- data/lib/rigor/cli.rb +29 -5
- data/lib/rigor/configuration/severity_profile.rb +18 -3
- data/lib/rigor/configuration.rb +186 -13
- data/lib/rigor/environment.rb +12 -4
- data/lib/rigor/inference/expression_typer.rb +3 -1
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +31 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +43 -2
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +104 -12
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +68 -2
- data/lib/rigor/inference/method_dispatcher.rb +50 -1
- data/lib/rigor/inference/narrowing.rb +150 -6
- data/lib/rigor/inference/scope_indexer.rb +220 -17
- data/lib/rigor/inference/statement_evaluator.rb +29 -0
- data/lib/rigor/plugin/base.rb +43 -0
- data/lib/rigor/plugin/fact_store.rb +92 -0
- data/lib/rigor/plugin/io_boundary.rb +92 -19
- data/lib/rigor/plugin/load_error.rb +14 -2
- data/lib/rigor/plugin/loader.rb +116 -0
- data/lib/rigor/plugin/manifest.rb +75 -6
- data/lib/rigor/plugin/services.rb +14 -2
- data/lib/rigor/plugin/trust_policy.rb +30 -7
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/scope.rb +30 -5
- data/lib/rigor/trinary.rb +1 -1
- data/lib/rigor/type/integer_range.rb +6 -2
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor/scope.rbs +3 -0
- data/sig/rigor.rbs +8 -2
- metadata +9 -1
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "check_rules"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Analysis
|
|
7
|
+
# Single-source-of-truth metadata table for every CheckRule
|
|
8
|
+
# the analyzer ships. Consumed by `rigor explain <rule>` so
|
|
9
|
+
# users can read the same information the docs site eventually
|
|
10
|
+
# publishes without leaving the terminal.
|
|
11
|
+
#
|
|
12
|
+
# Each entry carries:
|
|
13
|
+
#
|
|
14
|
+
# - `id` — canonical rule id (`call.undefined-method`).
|
|
15
|
+
# - `summary` — single-line headline (≤ 80 chars).
|
|
16
|
+
# - `fires_when` — bullet-shaped list of conditions that
|
|
17
|
+
# trigger the rule, in the order a reader can scan
|
|
18
|
+
# top-to-bottom.
|
|
19
|
+
# - `does_not_fire_when` — explicit list of cases the rule
|
|
20
|
+
# intentionally skips. Useful for "why am I NOT seeing
|
|
21
|
+
# this diagnostic?" questions.
|
|
22
|
+
# - `suppression` — short note on how to suppress (in-source
|
|
23
|
+
# `# rigor:disable` and the v0.1.2 file-scope variant
|
|
24
|
+
# `# rigor:disable-file`, plus `.rigor.yml` `disable:`,
|
|
25
|
+
# apply to every rule, so the note covers any rule-specific
|
|
26
|
+
# nuance — e.g. unreachable-branch lives on the dead-branch
|
|
27
|
+
# line, not the predicate line).
|
|
28
|
+
# - `severity_authored` — Symbol the rule emits with.
|
|
29
|
+
# - `severity_by_profile` — Hash of `:lenient` / `:balanced`
|
|
30
|
+
# / `:strict` to the configured severity per profile, taken
|
|
31
|
+
# from `Configuration::SeverityProfile::PROFILES`.
|
|
32
|
+
# - `since` — first version the rule shipped in.
|
|
33
|
+
module RuleCatalog # rubocop:disable Metrics/ModuleLength
|
|
34
|
+
Entry = Data.define(:id, :summary, :fires_when, :does_not_fire_when,
|
|
35
|
+
:suppression, :severity_authored, :severity_by_profile, :since) do
|
|
36
|
+
def aliases
|
|
37
|
+
CheckRules::LEGACY_RULE_ALIASES.select { |_legacy, canonical| canonical == id }.keys
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Hash-shaped form for `--format=json` consumers. Keys are
|
|
41
|
+
# Strings so the payload is JSON-stable without a transform
|
|
42
|
+
# pass.
|
|
43
|
+
def to_h
|
|
44
|
+
{
|
|
45
|
+
"id" => id,
|
|
46
|
+
"aliases" => aliases,
|
|
47
|
+
"summary" => summary,
|
|
48
|
+
"fires_when" => fires_when,
|
|
49
|
+
"does_not_fire_when" => does_not_fire_when,
|
|
50
|
+
"suppression" => suppression,
|
|
51
|
+
"severity_authored" => severity_authored.to_s,
|
|
52
|
+
"severity_by_profile" => severity_by_profile.transform_keys(&:to_s).transform_values(&:to_s),
|
|
53
|
+
"since" => since
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
ENTRIES = {
|
|
59
|
+
CheckRules::RULE_UNDEFINED_METHOD => Entry.new(
|
|
60
|
+
id: CheckRules::RULE_UNDEFINED_METHOD,
|
|
61
|
+
summary: "Method does not exist on the receiver's statically-known class.",
|
|
62
|
+
fires_when: [
|
|
63
|
+
"The call is `receiver.method(...)` with an explicit receiver.",
|
|
64
|
+
"The receiver type resolves to `Type::Nominal` / `Singleton` / `Constant` / `Tuple` / `HashShape`.",
|
|
65
|
+
"The receiver class is RBS-known (declared in the loaded environment).",
|
|
66
|
+
"The user has not declared the method via `def` or recognised `define_method`.",
|
|
67
|
+
"Neither the receiver class nor an ancestor's RBS sig declares the method."
|
|
68
|
+
],
|
|
69
|
+
does_not_fire_when: [
|
|
70
|
+
"Implicit-self calls (no receiver) — too noisy without per-method RBS for every helper.",
|
|
71
|
+
"Receiver is `Dynamic[T]` / `Top` / `Union` — by definition the method set isn't enumerable.",
|
|
72
|
+
"Receiver class is in the loader but its RBS definition cannot be built (constant aliases)."
|
|
73
|
+
],
|
|
74
|
+
suppression: "`# rigor:disable call.undefined-method` on the call line, " \
|
|
75
|
+
"or `disable: [\"call.undefined-method\"]` in `.rigor.yml`.",
|
|
76
|
+
severity_authored: :error,
|
|
77
|
+
severity_by_profile: { lenient: :error, balanced: :error, strict: :error },
|
|
78
|
+
since: "0.0.1"
|
|
79
|
+
),
|
|
80
|
+
|
|
81
|
+
CheckRules::RULE_WRONG_ARITY => Entry.new(
|
|
82
|
+
id: CheckRules::RULE_WRONG_ARITY,
|
|
83
|
+
summary: "Call's positional argument count is outside the declared overloads' envelope.",
|
|
84
|
+
fires_when: [
|
|
85
|
+
"Call is `receiver.method(args...)` with explicit receiver + plain positional args.",
|
|
86
|
+
"Receiver class is RBS-known and the method has a definition.",
|
|
87
|
+
"Actual positional count is below the min or above the max across all overloads."
|
|
88
|
+
],
|
|
89
|
+
does_not_fire_when: [
|
|
90
|
+
"Call uses `*splat`, keyword arguments, block-pass, or forwarded arguments.",
|
|
91
|
+
"Method declares required keyword arguments (caller must pass kwargs the rule doesn't model).",
|
|
92
|
+
"Method has a `*rest` positional parameter (max arity is unbounded)."
|
|
93
|
+
],
|
|
94
|
+
suppression: "`# rigor:disable call.wrong-arity`.",
|
|
95
|
+
severity_authored: :error,
|
|
96
|
+
severity_by_profile: { lenient: :error, balanced: :error, strict: :error },
|
|
97
|
+
since: "0.0.1"
|
|
98
|
+
),
|
|
99
|
+
|
|
100
|
+
CheckRules::RULE_ARGUMENT_TYPE => Entry.new(
|
|
101
|
+
id: CheckRules::RULE_ARGUMENT_TYPE,
|
|
102
|
+
summary: "Call passes an argument whose type the parameter cannot accept.",
|
|
103
|
+
fires_when: [
|
|
104
|
+
"The parameter type rejects the argument under `accepts(arg, mode: :gradual)`.",
|
|
105
|
+
"Method has a single overload (multi-overload checking is deferred).",
|
|
106
|
+
"Both sides have a non-Dynamic concrete type."
|
|
107
|
+
],
|
|
108
|
+
does_not_fire_when: [
|
|
109
|
+
"Either the parameter or the argument is `Dynamic[T]`.",
|
|
110
|
+
"Method has multiple overloads.",
|
|
111
|
+
"Method has `*rest_positionals`, required keywords, or trailing positionals."
|
|
112
|
+
],
|
|
113
|
+
suppression: "`# rigor:disable call.argument-type-mismatch`.",
|
|
114
|
+
severity_authored: :error,
|
|
115
|
+
severity_by_profile: { lenient: :warning, balanced: :error, strict: :error },
|
|
116
|
+
since: "0.0.2"
|
|
117
|
+
),
|
|
118
|
+
|
|
119
|
+
CheckRules::RULE_NIL_RECEIVER => Entry.new(
|
|
120
|
+
id: CheckRules::RULE_NIL_RECEIVER,
|
|
121
|
+
summary: "Receiver may be nil and the method is not defined on NilClass.",
|
|
122
|
+
fires_when: [
|
|
123
|
+
"Receiver type is `Type::Union` containing `Constant<nil>` (or `nil` from the RBS Optional).",
|
|
124
|
+
"The non-nil branch has the method, but `NilClass` does not.",
|
|
125
|
+
"Call is not safe-navigation (`x&.method`)."
|
|
126
|
+
],
|
|
127
|
+
does_not_fire_when: [
|
|
128
|
+
"Method exists on every member of the union (including NilClass).",
|
|
129
|
+
"Receiver was narrowed via `return if x.nil?` / similar early-return guard.",
|
|
130
|
+
"Call uses safe-navigation (`x&.method`)."
|
|
131
|
+
],
|
|
132
|
+
suppression: "`# rigor:disable call.possible-nil-receiver`.",
|
|
133
|
+
severity_authored: :error,
|
|
134
|
+
severity_by_profile: { lenient: :warning, balanced: :error, strict: :error },
|
|
135
|
+
since: "0.0.2"
|
|
136
|
+
),
|
|
137
|
+
|
|
138
|
+
CheckRules::RULE_DUMP_TYPE => Entry.new(
|
|
139
|
+
id: CheckRules::RULE_DUMP_TYPE,
|
|
140
|
+
summary: "`dump_type(expr)` from Rigor::Testing — informational type print.",
|
|
141
|
+
fires_when: [
|
|
142
|
+
"Top-level / DSL-block call to `dump_type(expr)` after `include Rigor::Testing`."
|
|
143
|
+
],
|
|
144
|
+
does_not_fire_when: [
|
|
145
|
+
"Outside a context that includes Rigor::Testing.",
|
|
146
|
+
"Argument is not a single expression."
|
|
147
|
+
],
|
|
148
|
+
suppression: "Remove the `dump_type` call (it's a debug helper, not a real diagnostic).",
|
|
149
|
+
severity_authored: :info,
|
|
150
|
+
severity_by_profile: { lenient: :info, balanced: :info, strict: :error },
|
|
151
|
+
since: "0.0.1"
|
|
152
|
+
),
|
|
153
|
+
|
|
154
|
+
CheckRules::RULE_ASSERT_TYPE => Entry.new(
|
|
155
|
+
id: CheckRules::RULE_ASSERT_TYPE,
|
|
156
|
+
summary: "`assert_type(\"<expected>\", expr)` from Rigor::Testing — type-equality check.",
|
|
157
|
+
fires_when: [
|
|
158
|
+
"Inferred type's display does not match the asserted string.",
|
|
159
|
+
"Useful in fixture self-assertions (every `spec/integration/fixtures/*.rb` uses it)."
|
|
160
|
+
],
|
|
161
|
+
does_not_fire_when: [
|
|
162
|
+
"Inferred type matches the assertion exactly."
|
|
163
|
+
],
|
|
164
|
+
suppression: "Update the assertion to the actual inferred type, or correct the source.",
|
|
165
|
+
severity_authored: :error,
|
|
166
|
+
severity_by_profile: { lenient: :error, balanced: :error, strict: :error },
|
|
167
|
+
since: "0.0.1"
|
|
168
|
+
),
|
|
169
|
+
|
|
170
|
+
CheckRules::RULE_ALWAYS_RAISES => Entry.new(
|
|
171
|
+
id: CheckRules::RULE_ALWAYS_RAISES,
|
|
172
|
+
summary: "Call provably raises (today: Integer division-by-zero).",
|
|
173
|
+
fires_when: [
|
|
174
|
+
"Receiver is `Integer` / `IntegerRange` / `Constant<Integer>`.",
|
|
175
|
+
"Operator is `/` / `%` / `div` / `modulo` / `divmod`.",
|
|
176
|
+
"Argument is a `Constant<Integer>` whose value is exactly zero."
|
|
177
|
+
],
|
|
178
|
+
does_not_fire_when: [
|
|
179
|
+
"Receiver is Float / Rational (those return Infinity / NaN, not an exception).",
|
|
180
|
+
"Argument is a Union containing zero (\"may raise\" not \"always raises\")."
|
|
181
|
+
],
|
|
182
|
+
suppression: "`# rigor:disable flow.always-raises`.",
|
|
183
|
+
severity_authored: :error,
|
|
184
|
+
severity_by_profile: { lenient: :warning, balanced: :error, strict: :error },
|
|
185
|
+
since: "0.0.3"
|
|
186
|
+
),
|
|
187
|
+
|
|
188
|
+
CheckRules::RULE_UNREACHABLE_BRANCH => Entry.new(
|
|
189
|
+
id: CheckRules::RULE_UNREACHABLE_BRANCH,
|
|
190
|
+
summary: "An if / unless / ternary's literal predicate makes one branch dead.",
|
|
191
|
+
fires_when: [
|
|
192
|
+
"Predicate is a syntactic literal: `true` / `false` / `nil` / Integer / Float / String / Symbol / Regexp.",
|
|
193
|
+
"The corresponding dead branch carries a non-empty body."
|
|
194
|
+
],
|
|
195
|
+
does_not_fire_when: [
|
|
196
|
+
"Predicate is an inferred-constant expression (not a literal). The literal-only envelope avoids " \
|
|
197
|
+
"false positives from Rigor's incomplete loop / mutation / RBS-strictness modelling.",
|
|
198
|
+
"The dead branch is empty (no useful location to point at)."
|
|
199
|
+
],
|
|
200
|
+
suppression: "`# rigor:disable unreachable-branch` on the dead-branch line (the diagnostic " \
|
|
201
|
+
"points at the dead branch, not the predicate, so the suppression goes there).",
|
|
202
|
+
severity_authored: :warning,
|
|
203
|
+
severity_by_profile: { lenient: :info, balanced: :warning, strict: :error },
|
|
204
|
+
since: "0.1.2"
|
|
205
|
+
),
|
|
206
|
+
|
|
207
|
+
CheckRules::RULE_ALWAYS_TRUTHY_CONDITION => Entry.new(
|
|
208
|
+
id: CheckRules::RULE_ALWAYS_TRUTHY_CONDITION,
|
|
209
|
+
summary: "An if / unless / ternary predicate's inferred type folds to a constant.",
|
|
210
|
+
fires_when: [
|
|
211
|
+
"Predicate's inferred type is `Type::Constant<true | false | nil | ...>`.",
|
|
212
|
+
"Predicate is NOT a syntactic literal (the literal-only `flow.unreachable-branch` rule covers those)."
|
|
213
|
+
],
|
|
214
|
+
does_not_fire_when: [
|
|
215
|
+
"Predicate sits inside a `WhileNode` / `UntilNode` / `ForNode` / `BlockNode` ancestor — " \
|
|
216
|
+
"Rigor's mutation tracking through loop bodies is incomplete enough that an inferred " \
|
|
217
|
+
"`Constant<bool>` can be a false positive.",
|
|
218
|
+
"Predicate is a defensive `.nil?` / `.empty?` / `.zero?` / `.any?` / `.none?` / `.all?` / " \
|
|
219
|
+
"`.respond_to?` call — these typically fire when the user is being more cautious than the " \
|
|
220
|
+
"RBS strict-on-returns sig admits.",
|
|
221
|
+
"Predicate folds to a non-Constant type (Union / Nominal / Dynamic / etc.)."
|
|
222
|
+
],
|
|
223
|
+
suppression: "`# rigor:disable always-truthy-condition` on the predicate line.",
|
|
224
|
+
severity_authored: :warning,
|
|
225
|
+
severity_by_profile: { lenient: :info, balanced: :warning, strict: :error },
|
|
226
|
+
since: "0.1.2"
|
|
227
|
+
),
|
|
228
|
+
|
|
229
|
+
CheckRules::RULE_DEAD_ASSIGNMENT => Entry.new(
|
|
230
|
+
id: CheckRules::RULE_DEAD_ASSIGNMENT,
|
|
231
|
+
summary: "Local variable assigned in a method body but never read.",
|
|
232
|
+
fires_when: [
|
|
233
|
+
"Plain `LocalVariableWriteNode` (not `+=` / `||=` / multi-assign) inside a `DefNode` body.",
|
|
234
|
+
"The target name does not appear as a `LocalVariableReadNode` anywhere in the same body, " \
|
|
235
|
+
"including nested blocks / lambdas.",
|
|
236
|
+
"The write is not the last statement of the body (Ruby's implicit return)."
|
|
237
|
+
],
|
|
238
|
+
does_not_fire_when: [
|
|
239
|
+
"Top-level / class-body assignments (their reachability spans the file's introspection / require surface).",
|
|
240
|
+
"The target name starts with `_` (Ruby convention for intentionally-unused).",
|
|
241
|
+
"The write is a destructure (`a, b = foo`) or operator-write (`x += 1` / `x ||= 1`).",
|
|
242
|
+
"The write is the last statement of the method body (assignments return their rvalue)."
|
|
243
|
+
],
|
|
244
|
+
suppression: "`# rigor:disable dead-assignment` on the offending line, or rename the local to `_<name>`.",
|
|
245
|
+
severity_authored: :warning,
|
|
246
|
+
severity_by_profile: { lenient: :info, balanced: :warning, strict: :error },
|
|
247
|
+
since: "0.1.2"
|
|
248
|
+
),
|
|
249
|
+
|
|
250
|
+
CheckRules::RULE_RETURN_TYPE => Entry.new(
|
|
251
|
+
id: CheckRules::RULE_RETURN_TYPE,
|
|
252
|
+
summary: "Method body's last-expression type is incompatible with the declared return type.",
|
|
253
|
+
fires_when: [
|
|
254
|
+
"Method has a `def` body the engine can re-type.",
|
|
255
|
+
"Method's RBS sig declares a non-`untyped` return type.",
|
|
256
|
+
"Body's inferred return type does not flow into the declared type under gradual acceptance.",
|
|
257
|
+
"When the RBS sig carries `%a{rigor:v1:return: <refinement>}` (v0.1.2), the refinement " \
|
|
258
|
+
"carrier — `non-empty-string`, `positive-int`, etc. — replaces the bare RBS class for the " \
|
|
259
|
+
"comparison, so a body the bare class would accept may still fail the refinement."
|
|
260
|
+
],
|
|
261
|
+
does_not_fire_when: [
|
|
262
|
+
"Method's declared return is `untyped` / `void`.",
|
|
263
|
+
"Body's last expression is `Dynamic[T]` (the engine cannot rule out the declared type)."
|
|
264
|
+
],
|
|
265
|
+
suppression: "`# rigor:disable def.return-type-mismatch`.",
|
|
266
|
+
severity_authored: :warning,
|
|
267
|
+
severity_by_profile: { lenient: :warning, balanced: :warning, strict: :error },
|
|
268
|
+
since: "0.1.0"
|
|
269
|
+
),
|
|
270
|
+
|
|
271
|
+
CheckRules::RULE_VISIBILITY_MISMATCH => Entry.new(
|
|
272
|
+
id: CheckRules::RULE_VISIBILITY_MISMATCH,
|
|
273
|
+
summary: "Explicit-receiver call to a method declared `private` in source.",
|
|
274
|
+
fires_when: [
|
|
275
|
+
"Call is `receiver.method(...)` with explicit non-self receiver.",
|
|
276
|
+
"Receiver type resolves to `Type::Nominal[X]`.",
|
|
277
|
+
"X is a user-defined class whose source carries the method under `private`."
|
|
278
|
+
],
|
|
279
|
+
does_not_fire_when: [
|
|
280
|
+
"Implicit-self call (no receiver) — always allowed for private.",
|
|
281
|
+
"Receiver is `self` (Ruby 2.7+ permits `self.private_method`).",
|
|
282
|
+
"Receiver class is RBS-known but not user-source-defined (RBS-side visibility is deferred).",
|
|
283
|
+
"Method is `:protected` (subclass tracking is deferred)."
|
|
284
|
+
],
|
|
285
|
+
suppression: "`# rigor:disable method-visibility-mismatch`.",
|
|
286
|
+
severity_authored: :error,
|
|
287
|
+
severity_by_profile: { lenient: :warning, balanced: :error, strict: :error },
|
|
288
|
+
since: "0.1.2"
|
|
289
|
+
),
|
|
290
|
+
|
|
291
|
+
CheckRules::RULE_IVAR_WRITE_MISMATCH => Entry.new(
|
|
292
|
+
id: CheckRules::RULE_IVAR_WRITE_MISMATCH,
|
|
293
|
+
summary: "Same instance variable assigned a different concrete class within one class.",
|
|
294
|
+
fires_when: [
|
|
295
|
+
"Two or more `@var = ...` writes occur in instance methods of the same class.",
|
|
296
|
+
"First write's rvalue resolves to a concrete class (Nominal / Singleton / Constant / Tuple → " \
|
|
297
|
+
"\"Array\" / HashShape → \"Hash\").",
|
|
298
|
+
"A later write's rvalue resolves to a different concrete class."
|
|
299
|
+
],
|
|
300
|
+
does_not_fire_when: [
|
|
301
|
+
"Later write is `nil` — the `@cache = nil` clear-idiom is allowlisted.",
|
|
302
|
+
"Either side is Union / Dynamic / IntegerRange / a shape-varied carrier.",
|
|
303
|
+
"Writes live in different classes that happen to share an ivar name.",
|
|
304
|
+
"Writes are in `def self.foo` (singleton) bodies — those track separately."
|
|
305
|
+
],
|
|
306
|
+
suppression: "`# rigor:disable ivar-write-mismatch` on the offending write.",
|
|
307
|
+
severity_authored: :error,
|
|
308
|
+
severity_by_profile: { lenient: :warning, balanced: :warning, strict: :error },
|
|
309
|
+
since: "0.1.2"
|
|
310
|
+
)
|
|
311
|
+
}.freeze
|
|
312
|
+
|
|
313
|
+
module_function
|
|
314
|
+
|
|
315
|
+
# Looks up a rule by canonical id, legacy alias, or family
|
|
316
|
+
# wildcard. Returns an Array<Entry>:
|
|
317
|
+
#
|
|
318
|
+
# - canonical id → 1-element array,
|
|
319
|
+
# - legacy alias → 1-element array (resolved to canonical),
|
|
320
|
+
# - family token (`call`) → every entry under that family,
|
|
321
|
+
# - unknown token → empty array.
|
|
322
|
+
def resolve(token)
|
|
323
|
+
token = token.to_s
|
|
324
|
+
return [ENTRIES.fetch(token)] if ENTRIES.key?(token)
|
|
325
|
+
|
|
326
|
+
if CheckRules::LEGACY_RULE_ALIASES.key?(token)
|
|
327
|
+
canonical = CheckRules::LEGACY_RULE_ALIASES.fetch(token)
|
|
328
|
+
return [ENTRIES.fetch(canonical)]
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
if CheckRules::RULE_FAMILIES.include?(token)
|
|
332
|
+
return ENTRIES.values.select { |entry| entry.id.start_with?("#{token}.") }.sort_by(&:id)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
[]
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def all
|
|
339
|
+
ENTRIES.values.sort_by(&:id)
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|
|
@@ -54,22 +54,45 @@ module Rigor
|
|
|
54
54
|
Inference::MethodDispatcher::FileFolding.fold_platform_specific_paths =
|
|
55
55
|
@configuration.fold_platform_specific_paths
|
|
56
56
|
|
|
57
|
+
target_ruby_error = validate_target_ruby
|
|
58
|
+
return Result.new(diagnostics: [target_ruby_error]) if target_ruby_error
|
|
59
|
+
|
|
60
|
+
@plugin_registry = load_plugins
|
|
57
61
|
environment = Environment.for_project(
|
|
58
62
|
libraries: @configuration.libraries,
|
|
59
63
|
signature_paths: @configuration.signature_paths,
|
|
60
|
-
cache_store: @cache_store
|
|
64
|
+
cache_store: @cache_store,
|
|
65
|
+
plugin_registry: @plugin_registry
|
|
61
66
|
)
|
|
62
|
-
|
|
63
|
-
@plugin_registry = load_plugins
|
|
64
67
|
expansion = expand_paths(paths)
|
|
65
68
|
|
|
66
69
|
diagnostics = plugin_load_diagnostics
|
|
70
|
+
diagnostics += plugin_prepare_diagnostics
|
|
67
71
|
diagnostics += expansion.fetch(:errors)
|
|
68
72
|
diagnostics += expansion.fetch(:files).flat_map { |path| analyze_file(path, environment) }
|
|
69
73
|
|
|
70
74
|
Result.new(diagnostics: apply_severity_profile(diagnostics))
|
|
71
75
|
end
|
|
72
76
|
|
|
77
|
+
# `target_ruby` flows through to Prism's `version:` option.
|
|
78
|
+
# Prism enforces the supported range and raises
|
|
79
|
+
# `ArgumentError` for versions it does not recognise. Run a
|
|
80
|
+
# one-time smoke parse here so a misconfigured target_ruby
|
|
81
|
+
# surfaces as a single project-level diagnostic instead of
|
|
82
|
+
# crashing the whole run on the first file.
|
|
83
|
+
def validate_target_ruby
|
|
84
|
+
Prism.parse("nil", version: @configuration.target_ruby)
|
|
85
|
+
nil
|
|
86
|
+
rescue ArgumentError => e
|
|
87
|
+
Diagnostic.new(
|
|
88
|
+
path: ".rigor.yml", line: 1, column: 1,
|
|
89
|
+
message: "target_ruby #{@configuration.target_ruby.inspect} is not accepted by Prism: #{e.message}",
|
|
90
|
+
severity: :error,
|
|
91
|
+
rule: "configuration-error",
|
|
92
|
+
source_family: :builtin
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
73
96
|
private
|
|
74
97
|
|
|
75
98
|
# Loads project-configured plugins through {Rigor::Plugin::Loader}
|
|
@@ -117,7 +140,8 @@ module Rigor
|
|
|
117
140
|
Plugin::TrustPolicy.new(
|
|
118
141
|
trusted_gems: trusted_gems,
|
|
119
142
|
allowed_read_roots: roots,
|
|
120
|
-
network_policy: @configuration.plugins_io_network
|
|
143
|
+
network_policy: @configuration.plugins_io_network,
|
|
144
|
+
allowed_url_hosts: @configuration.plugins_io_allowed_url_hosts
|
|
121
145
|
)
|
|
122
146
|
end
|
|
123
147
|
|
|
@@ -183,6 +207,47 @@ module Rigor
|
|
|
183
207
|
end
|
|
184
208
|
end
|
|
185
209
|
|
|
210
|
+
# ADR-9 slice 3 — invokes every loaded plugin's `#prepare`
|
|
211
|
+
# hook once per run, after the loader's `#init` pass and
|
|
212
|
+
# before per-file iteration. Plugins publish facts here
|
|
213
|
+
# for cross-plugin consumption via the shared
|
|
214
|
+
# `services.fact_store`. Failures isolate as
|
|
215
|
+
# `:plugin_loader runtime-error` diagnostics, mirroring the
|
|
216
|
+
# `#diagnostics_for_file` raise envelope in
|
|
217
|
+
# `plugin_runtime_error_diagnostic`.
|
|
218
|
+
#
|
|
219
|
+
# Slice 3 visits plugins in registration order. Slice 5
|
|
220
|
+
# introduces topological ordering by `manifest(consumes:)`
|
|
221
|
+
# so producers always run before consumers; until then,
|
|
222
|
+
# `Configuration#plugins` order MUST be producer-first if
|
|
223
|
+
# cross-plugin dependencies exist.
|
|
224
|
+
def plugin_prepare_diagnostics
|
|
225
|
+
return [] if @plugin_registry.empty?
|
|
226
|
+
|
|
227
|
+
@plugin_registry.plugins.flat_map { |plugin| invoke_plugin_prepare(plugin) }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def invoke_plugin_prepare(plugin)
|
|
231
|
+
plugin.prepare(plugin.services)
|
|
232
|
+
[]
|
|
233
|
+
rescue StandardError => e
|
|
234
|
+
[plugin_prepare_error_diagnostic(plugin, e)]
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def plugin_prepare_error_diagnostic(plugin, error)
|
|
238
|
+
plugin_id = safe_plugin_id(plugin)
|
|
239
|
+
Diagnostic.new(
|
|
240
|
+
path: ".rigor.yml",
|
|
241
|
+
line: 1,
|
|
242
|
+
column: 1,
|
|
243
|
+
message: "plugin #{plugin_id.inspect} raised during prepare: " \
|
|
244
|
+
"#{error.class}: #{error.message}",
|
|
245
|
+
severity: :error,
|
|
246
|
+
rule: "runtime-error",
|
|
247
|
+
source_family: :plugin_loader
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
|
|
186
251
|
# ADR-7 § "Slice 5-A/5-B" — invokes every loaded plugin's
|
|
187
252
|
# per-file diagnostic emission hook
|
|
188
253
|
# (`Plugin::Base#diagnostics_for_file`) and re-stamps the
|
|
@@ -254,7 +319,7 @@ module Rigor
|
|
|
254
319
|
errors = []
|
|
255
320
|
Array(paths).each do |path|
|
|
256
321
|
if File.directory?(path)
|
|
257
|
-
files.concat(Dir.glob(File.join(path, RUBY_GLOB)))
|
|
322
|
+
files.concat(reject_excluded(Dir.glob(File.join(path, RUBY_GLOB))))
|
|
258
323
|
elsif File.file?(path) && path.end_with?(".rb")
|
|
259
324
|
files << path
|
|
260
325
|
elsif File.exist?(path)
|
|
@@ -266,6 +331,25 @@ module Rigor
|
|
|
266
331
|
{ files: files, errors: errors }
|
|
267
332
|
end
|
|
268
333
|
|
|
334
|
+
# `Configuration#exclude_patterns` is a list of glob patterns
|
|
335
|
+
# checked against each globbed path via `File.fnmatch?` (without
|
|
336
|
+
# `FNM_PATHNAME`, so `**` and `*` both span path separators —
|
|
337
|
+
# the patterns behave like substring globs). Built-in defaults
|
|
338
|
+
# exclude `vendor/bundle`, `.bundle`, `node_modules`, and `tmp`
|
|
339
|
+
# so the analyser never walks into vendored deps or build
|
|
340
|
+
# artefacts. User-supplied entries (`.rigor.yml` `exclude:`)
|
|
341
|
+
# layer on top. Explicit file arguments to the CLI bypass this
|
|
342
|
+
# filter — only the directory-glob expansion is filtered.
|
|
343
|
+
def reject_excluded(file_list)
|
|
344
|
+
return file_list if @configuration.exclude_patterns.empty?
|
|
345
|
+
|
|
346
|
+
file_list.reject { |path| excluded?(path) }
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def excluded?(path)
|
|
350
|
+
@configuration.exclude_patterns.any? { |pattern| File.fnmatch?(pattern, path) }
|
|
351
|
+
end
|
|
352
|
+
|
|
269
353
|
def path_error(path, message)
|
|
270
354
|
Diagnostic.new(
|
|
271
355
|
path: path,
|
|
@@ -277,7 +361,7 @@ module Rigor
|
|
|
277
361
|
end
|
|
278
362
|
|
|
279
363
|
def analyze_file(path, environment) # rubocop:disable Metrics/MethodLength
|
|
280
|
-
parse_result = Prism.parse_file(path)
|
|
364
|
+
parse_result = Prism.parse_file(path, version: @configuration.target_ruby)
|
|
281
365
|
return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
|
|
282
366
|
|
|
283
367
|
scope = Scope.empty(environment: environment)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../type"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Builtins
|
|
7
|
+
# Maps a curated table of canonical regex sub-patterns onto the
|
|
8
|
+
# imported refinement carriers Rigor already ships
|
|
9
|
+
# (`decimal-int-string`, `hex-int-string`, `octal-int-string`,
|
|
10
|
+
# `lowercase-string`, `uppercase-string`, `numeric-string`).
|
|
11
|
+
# See `docs/type-specification/imported-built-in-types.md` for
|
|
12
|
+
# the registry the refinements come from and `docs/MILESTONES.md`
|
|
13
|
+
# § "v0.1.1 — Planned" Track 1 slice 1 for the binding scope of
|
|
14
|
+
# this recogniser.
|
|
15
|
+
#
|
|
16
|
+
# The intended consumer is `Inference::Narrowing.analyse_match_write`:
|
|
17
|
+
# given `if /(?<year>\d+)/ =~ str; year; end`, the v0.1.0
|
|
18
|
+
# baseline narrows `year` to plain `String`; v0.1.1 introspects
|
|
19
|
+
# the regex source and narrows further to
|
|
20
|
+
# `decimal-int-string` whenever the named-capture body matches
|
|
21
|
+
# one of the rows in {RULES}.
|
|
22
|
+
#
|
|
23
|
+
# Recognised body shapes (each row admits the `+` quantifier
|
|
24
|
+
# and the bounded `{n}` / `{n,m}` forms with `n >= 1`):
|
|
25
|
+
#
|
|
26
|
+
# - `\d` -> decimal-int-string
|
|
27
|
+
# - `\h` -> hex-int-string
|
|
28
|
+
# - `[0-9a-fA-F]` -> hex-int-string
|
|
29
|
+
# - `[0-9a-f]`, `[0-9A-F]` -> hex-int-string
|
|
30
|
+
# - `[0-7]` -> octal-int-string
|
|
31
|
+
# - `[a-z]` -> lowercase-string
|
|
32
|
+
# - `[A-Z]` -> uppercase-string
|
|
33
|
+
# - `[[:digit:]]` -> numeric-string
|
|
34
|
+
#
|
|
35
|
+
# Anything outside the table returns `nil` so the calling
|
|
36
|
+
# narrowing site falls back to its previous behaviour
|
|
37
|
+
# (plain `String`). Arbitrary regex semantic equivalence is
|
|
38
|
+
# undecidable, so the table is intentionally a small audited
|
|
39
|
+
# set of canonical shapes rather than a general equivalence
|
|
40
|
+
# checker.
|
|
41
|
+
module RegexRefinement
|
|
42
|
+
# `+` (one-or-more) or `{n}` / `{n,m}` (n >= 1, m >= n).
|
|
43
|
+
# The bound check is enforced separately by
|
|
44
|
+
# {valid_bounds?} after the structural match succeeds, so
|
|
45
|
+
# forms like `\d{0,5}` or `\d{5,3}` reject even though they
|
|
46
|
+
# parse syntactically.
|
|
47
|
+
QUANTIFIER_SOURCE = '(?:\+|\{\d+(?:,\d+)?\})'
|
|
48
|
+
private_constant :QUANTIFIER_SOURCE
|
|
49
|
+
|
|
50
|
+
RULES = [
|
|
51
|
+
[/\A\\d#{QUANTIFIER_SOURCE}\z/, :decimal_int_string],
|
|
52
|
+
[/\A\\h#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
|
|
53
|
+
[/\A\[0-9a-fA-F\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
|
|
54
|
+
[/\A\[0-9a-f\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
|
|
55
|
+
[/\A\[0-9A-F\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
|
|
56
|
+
[/\A\[0-7\]#{QUANTIFIER_SOURCE}\z/, :octal_int_string],
|
|
57
|
+
[/\A\[a-z\]#{QUANTIFIER_SOURCE}\z/, :lowercase_string],
|
|
58
|
+
[/\A\[A-Z\]#{QUANTIFIER_SOURCE}\z/, :uppercase_string],
|
|
59
|
+
[/\A\[\[:digit:\]\]#{QUANTIFIER_SOURCE}\z/, :numeric_string]
|
|
60
|
+
].freeze
|
|
61
|
+
private_constant :RULES
|
|
62
|
+
|
|
63
|
+
BOUND_RE = /\{(\d+)(?:,(\d+))?\}\z/
|
|
64
|
+
private_constant :BOUND_RE
|
|
65
|
+
|
|
66
|
+
module_function
|
|
67
|
+
|
|
68
|
+
# @param body [String, nil] a regex sub-pattern, typically the
|
|
69
|
+
# inner body of a `(?<name>body)` named capture. Anchors
|
|
70
|
+
# (`\A`, `\z`, `^`, `$`) are not stripped — the recogniser
|
|
71
|
+
# table targets bodies that the regex engine treats as
|
|
72
|
+
# anchored to the capture group bounds.
|
|
73
|
+
# @return [Rigor::Type, nil] the matching imported refinement
|
|
74
|
+
# carrier, or `nil` if `body` is not a recognised shape.
|
|
75
|
+
def for_capture_body(body)
|
|
76
|
+
return nil if body.nil? || body.empty?
|
|
77
|
+
|
|
78
|
+
rule = RULES.find { |pattern, _| pattern.match?(body) }
|
|
79
|
+
return nil if rule.nil?
|
|
80
|
+
return nil unless valid_bounds?(body)
|
|
81
|
+
|
|
82
|
+
Type::Combinator.public_send(rule.last)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Filters the bounded-quantifier forms to ones whose lower
|
|
86
|
+
# bound is at least 1 and whose upper bound (if any) is at
|
|
87
|
+
# least the lower bound. Without this, `\d{0,5}` would be
|
|
88
|
+
# accepted even though it admits the empty string, which is
|
|
89
|
+
# not a valid `decimal-int-string`.
|
|
90
|
+
def valid_bounds?(body)
|
|
91
|
+
m = BOUND_RE.match(body)
|
|
92
|
+
return true if m.nil?
|
|
93
|
+
|
|
94
|
+
low = Integer(m[1])
|
|
95
|
+
return false if low < 1
|
|
96
|
+
|
|
97
|
+
high = m[2] && Integer(m[2])
|
|
98
|
+
return true if high.nil?
|
|
99
|
+
|
|
100
|
+
low <= high
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|