rigortype 0.1.1 → 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/data/builtins/ruby_core/range.yml +6 -4
- data/data/builtins/ruby_core/string.yml +15 -10
- 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 +2 -1
- data/lib/rigor/cli/diff_command.rb +169 -0
- data/lib/rigor/cli/explain_command.rb +129 -0
- data/lib/rigor/cli.rb +18 -1
- data/lib/rigor/configuration/severity_profile.rb +18 -3
- data/lib/rigor/configuration.rb +10 -4
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +104 -12
- data/lib/rigor/inference/scope_indexer.rb +171 -2
- data/lib/rigor/plugin/io_boundary.rb +92 -19
- data/lib/rigor/plugin/trust_policy.rb +30 -7
- data/lib/rigor/scope.rb +30 -5
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/scope.rbs +3 -0
- metadata +7 -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
|
|
@@ -140,7 +140,8 @@ module Rigor
|
|
|
140
140
|
Plugin::TrustPolicy.new(
|
|
141
141
|
trusted_gems: trusted_gems,
|
|
142
142
|
allowed_read_roots: roots,
|
|
143
|
-
network_policy: @configuration.plugins_io_network
|
|
143
|
+
network_policy: @configuration.plugins_io_network,
|
|
144
|
+
allowed_url_hosts: @configuration.plugins_io_allowed_url_hosts
|
|
144
145
|
)
|
|
145
146
|
end
|
|
146
147
|
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optionparser"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
class CLI
|
|
8
|
+
# Executes `rigor diff <baseline.json> [paths...]`. Compares
|
|
9
|
+
# the current `rigor check` diagnostics against a saved
|
|
10
|
+
# baseline JSON (the output of a previous `rigor check
|
|
11
|
+
# --format=json` run) and prints the delta:
|
|
12
|
+
#
|
|
13
|
+
# - **new** — diagnostics in the current run that were not
|
|
14
|
+
# in the baseline (typically a regression introduced in
|
|
15
|
+
# this PR).
|
|
16
|
+
# - **fixed** — diagnostics in the baseline that no longer
|
|
17
|
+
# appear in the current run (typically progress).
|
|
18
|
+
#
|
|
19
|
+
# Identity for matching is the tuple
|
|
20
|
+
# `(path, line, column, rule, source_family, message)`.
|
|
21
|
+
# An edit that moves a diagnostic to a new line surfaces as
|
|
22
|
+
# one fixed + one new pair, which lines up with the user's
|
|
23
|
+
# mental model of "you changed the line, the analyzer's
|
|
24
|
+
# position changed too."
|
|
25
|
+
#
|
|
26
|
+
# CI usage: commit a `rigor.baseline.json` produced once
|
|
27
|
+
# with `rigor check --format=json > rigor.baseline.json`,
|
|
28
|
+
# then run `rigor diff rigor.baseline.json` in CI. Exit code
|
|
29
|
+
# is `1` when any new diagnostic appears, `0` otherwise —
|
|
30
|
+
# so adding new errors fails CI but legacy errors recorded
|
|
31
|
+
# in the baseline don't.
|
|
32
|
+
class DiffCommand # rubocop:disable Metrics/ClassLength
|
|
33
|
+
USAGE = "Usage: rigor diff [options] <baseline.json> [paths...]"
|
|
34
|
+
|
|
35
|
+
def initialize(argv:, out:, err:)
|
|
36
|
+
@argv = argv
|
|
37
|
+
@out = out
|
|
38
|
+
@err = err
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Integer] CLI exit status.
|
|
42
|
+
def run
|
|
43
|
+
options = parse_options
|
|
44
|
+
|
|
45
|
+
baseline_path = @argv.shift
|
|
46
|
+
if baseline_path.nil?
|
|
47
|
+
@err.puts(USAGE)
|
|
48
|
+
return CLI::EXIT_USAGE
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
baseline = load_diagnostics(baseline_path)
|
|
52
|
+
current = options.fetch(:current_path) ? load_diagnostics(options.fetch(:current_path)) : run_current(options)
|
|
53
|
+
return CLI::EXIT_USAGE if baseline.nil? || current.nil?
|
|
54
|
+
|
|
55
|
+
diff = compute_diff(baseline, current)
|
|
56
|
+
write_diff(
|
|
57
|
+
diff, options.fetch(:format),
|
|
58
|
+
baseline_path: baseline_path,
|
|
59
|
+
baseline_count: baseline.size,
|
|
60
|
+
current_count: current.size
|
|
61
|
+
)
|
|
62
|
+
diff[:new].any? ? 1 : 0
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def parse_options
|
|
68
|
+
options = { format: "text", current_path: nil, config: nil }
|
|
69
|
+
OptionParser.new do |opt|
|
|
70
|
+
opt.banner = USAGE
|
|
71
|
+
opt.on("--format=FORMAT", %w[text json], "Output format (text | json). Default: text.") do |fmt|
|
|
72
|
+
options[:format] = fmt
|
|
73
|
+
end
|
|
74
|
+
opt.on("--current=PATH", "Compare to the saved current JSON instead of running `rigor check`.") do |path|
|
|
75
|
+
options[:current_path] = path
|
|
76
|
+
end
|
|
77
|
+
opt.on("--config=PATH", "Path to .rigor.yml. Forwarded to the implicit `rigor check` run.") do |path|
|
|
78
|
+
options[:config] = path
|
|
79
|
+
end
|
|
80
|
+
end.parse!(@argv)
|
|
81
|
+
options
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Runs `rigor check` against the remaining argv (or the
|
|
85
|
+
# configured paths) and returns the diagnostics array.
|
|
86
|
+
# Reuses the analyzer + configuration plumbing the
|
|
87
|
+
# check-command path uses.
|
|
88
|
+
def run_current(options)
|
|
89
|
+
require_relative "../analysis/runner"
|
|
90
|
+
require_relative "../configuration"
|
|
91
|
+
|
|
92
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
93
|
+
paths = @argv.empty? ? configuration.paths : @argv
|
|
94
|
+
result = Analysis::Runner.new(configuration: configuration).run(paths)
|
|
95
|
+
result.diagnostics.map(&:to_h)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def load_diagnostics(path)
|
|
99
|
+
unless File.file?(path)
|
|
100
|
+
@err.puts("Baseline file not found: #{path}")
|
|
101
|
+
return nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
payload = JSON.parse(File.read(path))
|
|
105
|
+
payload.is_a?(Hash) ? Array(payload["diagnostics"]) : Array(payload)
|
|
106
|
+
rescue JSON::ParserError => e
|
|
107
|
+
@err.puts("Invalid JSON in #{path}: #{e.message}")
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
KEY_FIELDS = %w[path line column rule source_family message].freeze
|
|
112
|
+
private_constant :KEY_FIELDS
|
|
113
|
+
|
|
114
|
+
def compute_diff(baseline, current)
|
|
115
|
+
baseline_keys = baseline.to_set { |d| identity_for(d) }
|
|
116
|
+
current_keys = current.to_set { |d| identity_for(d) }
|
|
117
|
+
|
|
118
|
+
new_diags = current.reject { |d| baseline_keys.include?(identity_for(d)) }
|
|
119
|
+
fixed = baseline.reject { |d| current_keys.include?(identity_for(d)) }
|
|
120
|
+
{ new: new_diags, fixed: fixed }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def identity_for(diagnostic)
|
|
124
|
+
KEY_FIELDS.map { |k| diagnostic[k] }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def write_diff(diff, format, baseline_path:, baseline_count:, current_count:)
|
|
128
|
+
case format
|
|
129
|
+
when "json"
|
|
130
|
+
write_diff_json(diff, baseline_path, baseline_count, current_count)
|
|
131
|
+
else
|
|
132
|
+
write_diff_text(diff, baseline_path, baseline_count, current_count)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def write_diff_json(diff, baseline_path, baseline_count, current_count)
|
|
137
|
+
@out.puts(JSON.pretty_generate(
|
|
138
|
+
"baseline" => baseline_path,
|
|
139
|
+
"baseline_count" => baseline_count,
|
|
140
|
+
"current_count" => current_count,
|
|
141
|
+
"new" => diff[:new],
|
|
142
|
+
"fixed" => diff[:fixed]
|
|
143
|
+
))
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def write_diff_text(diff, baseline_path, baseline_count, current_count)
|
|
147
|
+
@out.puts("# diff against #{baseline_path} (#{baseline_count} baseline / #{current_count} current)")
|
|
148
|
+
diff[:new].each { |d| @out.puts("+ NEW #{render_diagnostic(d)}") }
|
|
149
|
+
diff[:fixed].each { |d| @out.puts("- FIXED #{render_diagnostic(d)}") }
|
|
150
|
+
@out.puts("")
|
|
151
|
+
@out.puts("#{diff[:new].size} new, #{diff[:fixed].size} fixed")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def render_diagnostic(diagnostic)
|
|
155
|
+
rule = qualified_rule_for(diagnostic)
|
|
156
|
+
position = "#{diagnostic['path']}:#{diagnostic['line']}:#{diagnostic['column']}"
|
|
157
|
+
"#{position} [#{rule}] #{diagnostic['message']}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def qualified_rule_for(diagnostic)
|
|
161
|
+
family = diagnostic["source_family"]
|
|
162
|
+
rule = diagnostic["rule"]
|
|
163
|
+
return rule if family.nil? || family == "" || family == "builtin"
|
|
164
|
+
|
|
165
|
+
"#{family}.#{rule}"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optionparser"
|
|
5
|
+
|
|
6
|
+
require_relative "../analysis/rule_catalog"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
class CLI
|
|
10
|
+
# Executes `rigor explain <rule>`. Prints the catalog entry for
|
|
11
|
+
# one canonical rule id, a legacy alias, or a family wildcard
|
|
12
|
+
# (`call`, `flow`, `assert`, `dump`, `def`).
|
|
13
|
+
#
|
|
14
|
+
# Without arguments lists every rule's id and one-line summary.
|
|
15
|
+
#
|
|
16
|
+
# The command is read-only: no parser, no analyzer, no I/O
|
|
17
|
+
# beyond the rendered catalog. Useful when a user sees a
|
|
18
|
+
# diagnostic in the editor and wants to know what the rule
|
|
19
|
+
# means without leaving the terminal.
|
|
20
|
+
class ExplainCommand
|
|
21
|
+
USAGE = "Usage: rigor explain [options] [<rule>]"
|
|
22
|
+
|
|
23
|
+
def initialize(argv:, out:, err:)
|
|
24
|
+
@argv = argv
|
|
25
|
+
@out = out
|
|
26
|
+
@err = err
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Integer] CLI exit status.
|
|
30
|
+
def run
|
|
31
|
+
options = parse_options
|
|
32
|
+
|
|
33
|
+
if @argv.empty?
|
|
34
|
+
render_index(options.fetch(:format))
|
|
35
|
+
return 0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
token = @argv.shift
|
|
39
|
+
entries = Analysis::RuleCatalog.resolve(token)
|
|
40
|
+
if entries.empty?
|
|
41
|
+
@err.puts("Unknown rule: #{token}")
|
|
42
|
+
@err.puts("Run `rigor explain` with no arguments to list every rule.")
|
|
43
|
+
return CLI::EXIT_USAGE
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
render_entries(entries, options.fetch(:format))
|
|
47
|
+
0
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def parse_options
|
|
53
|
+
options = { format: "text" }
|
|
54
|
+
OptionParser.new do |opt|
|
|
55
|
+
opt.banner = USAGE
|
|
56
|
+
opt.on("--format=FORMAT", %w[text json], "Output format (text | json). Default: text.") do |fmt|
|
|
57
|
+
options[:format] = fmt
|
|
58
|
+
end
|
|
59
|
+
end.parse!(@argv)
|
|
60
|
+
options
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def render_index(format)
|
|
64
|
+
case format
|
|
65
|
+
when "json" then @out.puts(JSON.pretty_generate(Analysis::RuleCatalog.all.map(&:to_h)))
|
|
66
|
+
else render_index_text
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def render_index_text
|
|
71
|
+
@out.puts("Available rules:")
|
|
72
|
+
@out.puts("")
|
|
73
|
+
Analysis::RuleCatalog.all.each do |entry|
|
|
74
|
+
@out.puts(" #{entry.id.ljust(33)} #{entry.summary}")
|
|
75
|
+
end
|
|
76
|
+
@out.puts("")
|
|
77
|
+
@out.puts("Run `rigor explain <rule>` for the full description.")
|
|
78
|
+
@out.puts("Family wildcards (`call`, `flow`, `assert`, `dump`, `def`) print every rule under that prefix.")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def render_entries(entries, format)
|
|
82
|
+
case format
|
|
83
|
+
when "json" then @out.puts(JSON.pretty_generate(entries.map(&:to_h)))
|
|
84
|
+
else
|
|
85
|
+
entries.each_with_index do |entry, index|
|
|
86
|
+
@out.puts("") if index.positive?
|
|
87
|
+
render_entry_text(entry)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def render_entry_text(entry)
|
|
93
|
+
@out.puts(entry.id)
|
|
94
|
+
@out.puts("=" * entry.id.length)
|
|
95
|
+
@out.puts("")
|
|
96
|
+
@out.puts(entry.summary)
|
|
97
|
+
@out.puts("")
|
|
98
|
+
render_aliases(entry)
|
|
99
|
+
render_severity(entry)
|
|
100
|
+
render_section("Fires when:", entry.fires_when)
|
|
101
|
+
render_section("Does not fire when:", entry.does_not_fire_when)
|
|
102
|
+
@out.puts("Suppression: #{entry.suppression}")
|
|
103
|
+
@out.puts("Since: rigor #{entry.since}")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def render_aliases(entry)
|
|
107
|
+
return if entry.aliases.empty?
|
|
108
|
+
|
|
109
|
+
@out.puts("Legacy aliases: #{entry.aliases.join(', ')}")
|
|
110
|
+
@out.puts("")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def render_severity(entry)
|
|
114
|
+
@out.puts("Authored severity: :#{entry.severity_authored}")
|
|
115
|
+
profile_table = entry.severity_by_profile.map { |profile, sev| "#{profile} → :#{sev}" }.join(", ")
|
|
116
|
+
@out.puts("Severity by profile: #{profile_table}")
|
|
117
|
+
@out.puts("")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def render_section(heading, items)
|
|
121
|
+
return if items.empty?
|
|
122
|
+
|
|
123
|
+
@out.puts(heading)
|
|
124
|
+
items.each { |item| @out.puts(" - #{item}") }
|
|
125
|
+
@out.puts("")
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|