rigortype 0.1.1 → 0.1.3
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 +10 -0
- 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/dependency_source_inference/builder.rb +87 -0
- data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +72 -0
- data/lib/rigor/analysis/dependency_source_inference/index.rb +110 -0
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +200 -0
- data/lib/rigor/analysis/dependency_source_inference.rb +37 -0
- data/lib/rigor/analysis/rule_catalog.rb +343 -0
- data/lib/rigor/analysis/runner.rb +96 -6
- data/lib/rigor/cache/descriptor.rb +58 -5
- 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/dependencies.rb +235 -0
- data/lib/rigor/configuration/severity_profile.rb +18 -3
- data/lib/rigor/configuration.rb +53 -13
- data/lib/rigor/environment.rb +16 -4
- data/lib/rigor/flow_contribution/merger.rb +4 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +104 -12
- data/lib/rigor/inference/method_dispatcher.rb +87 -0
- data/lib/rigor/inference/scope_indexer.rb +171 -2
- data/lib/rigor/inference/statement_evaluator.rb +65 -1
- data/lib/rigor/plugin/io_boundary.rb +92 -19
- data/lib/rigor/plugin/manifest.rb +26 -5
- 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/environment.rbs +3 -2
- data/sig/rigor/scope.rbs +3 -0
- metadata +13 -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
|
|
@@ -12,6 +12,7 @@ require_relative "../inference/coverage_scanner"
|
|
|
12
12
|
require_relative "../inference/scope_indexer"
|
|
13
13
|
require_relative "../inference/method_dispatcher/file_folding"
|
|
14
14
|
require_relative "check_rules"
|
|
15
|
+
require_relative "dependency_source_inference"
|
|
15
16
|
require_relative "diagnostic"
|
|
16
17
|
require_relative "result"
|
|
17
18
|
|
|
@@ -21,7 +22,7 @@ module Rigor
|
|
|
21
22
|
RUBY_GLOB = "**/*.rb"
|
|
22
23
|
DEFAULT_CACHE_ROOT = ".rigor/cache"
|
|
23
24
|
|
|
24
|
-
attr_reader :cache_store, :plugin_registry
|
|
25
|
+
attr_reader :cache_store, :plugin_registry, :dependency_source_index
|
|
25
26
|
|
|
26
27
|
# @param configuration [Rigor::Configuration]
|
|
27
28
|
# @param explain [Boolean] surface fail-soft fallback events
|
|
@@ -40,6 +41,7 @@ module Rigor
|
|
|
40
41
|
@cache_store = cache_store
|
|
41
42
|
@plugin_requirer = plugin_requirer
|
|
42
43
|
@plugin_registry = Plugin::Registry::EMPTY
|
|
44
|
+
@dependency_source_index = DependencySourceInference::Index::EMPTY
|
|
43
45
|
end
|
|
44
46
|
|
|
45
47
|
# Walks every Ruby file under `paths`, parses it, builds a
|
|
@@ -58,22 +60,37 @@ module Rigor
|
|
|
58
60
|
return Result.new(diagnostics: [target_ruby_error]) if target_ruby_error
|
|
59
61
|
|
|
60
62
|
@plugin_registry = load_plugins
|
|
63
|
+
@dependency_source_index = DependencySourceInference::Builder.build(@configuration.dependencies)
|
|
61
64
|
environment = Environment.for_project(
|
|
62
65
|
libraries: @configuration.libraries,
|
|
63
66
|
signature_paths: @configuration.signature_paths,
|
|
64
67
|
cache_store: @cache_store,
|
|
65
|
-
plugin_registry: @plugin_registry
|
|
68
|
+
plugin_registry: @plugin_registry,
|
|
69
|
+
dependency_source_index: @dependency_source_index
|
|
66
70
|
)
|
|
67
71
|
expansion = expand_paths(paths)
|
|
68
72
|
|
|
69
|
-
diagnostics =
|
|
70
|
-
diagnostics += plugin_prepare_diagnostics
|
|
71
|
-
diagnostics += expansion.fetch(:errors)
|
|
73
|
+
diagnostics = pre_file_diagnostics(expansion)
|
|
72
74
|
diagnostics += expansion.fetch(:files).flat_map { |path| analyze_file(path, environment) }
|
|
73
75
|
|
|
74
76
|
Result.new(diagnostics: apply_severity_profile(diagnostics))
|
|
75
77
|
end
|
|
76
78
|
|
|
79
|
+
# Pre-file diagnostic streams that fire once per run rather
|
|
80
|
+
# than per analyzed file: plugin load / prepare envelopes,
|
|
81
|
+
# the ADR-10 dependency-source resolution surface, and the
|
|
82
|
+
# `expand_paths` errors for `paths:` entries that don't
|
|
83
|
+
# exist or aren't `.rb`. Aggregated here so `#run` stays
|
|
84
|
+
# under the ABC budget.
|
|
85
|
+
def pre_file_diagnostics(expansion)
|
|
86
|
+
plugin_load_diagnostics +
|
|
87
|
+
plugin_prepare_diagnostics +
|
|
88
|
+
dependency_source_diagnostics +
|
|
89
|
+
dependency_source_budget_diagnostics +
|
|
90
|
+
dependency_source_config_conflict_diagnostics +
|
|
91
|
+
expansion.fetch(:errors)
|
|
92
|
+
end
|
|
93
|
+
|
|
77
94
|
# `target_ruby` flows through to Prism's `version:` option.
|
|
78
95
|
# Prism enforces the supported range and raises
|
|
79
96
|
# `ArgumentError` for versions it does not recognise. Run a
|
|
@@ -140,7 +157,8 @@ module Rigor
|
|
|
140
157
|
Plugin::TrustPolicy.new(
|
|
141
158
|
trusted_gems: trusted_gems,
|
|
142
159
|
allowed_read_roots: roots,
|
|
143
|
-
network_policy: @configuration.plugins_io_network
|
|
160
|
+
network_policy: @configuration.plugins_io_network,
|
|
161
|
+
allowed_url_hosts: @configuration.plugins_io_allowed_url_hosts
|
|
144
162
|
)
|
|
145
163
|
end
|
|
146
164
|
|
|
@@ -206,6 +224,78 @@ module Rigor
|
|
|
206
224
|
end
|
|
207
225
|
end
|
|
208
226
|
|
|
227
|
+
# ADR-10 § "Diagnostic prefix family" — surfaces gems
|
|
228
|
+
# listed in `dependencies.source_inference` that RubyGems
|
|
229
|
+
# could not resolve. The run continues; the gem simply
|
|
230
|
+
# contributes nothing this session, mirroring the
|
|
231
|
+
# plugin-load error envelope. Authored `:warning` because
|
|
232
|
+
# an unresolvable gem usually means a typo or a missing
|
|
233
|
+
# `bundle install` rather than a project-blocking problem;
|
|
234
|
+
# the severity profile still re-stamps it.
|
|
235
|
+
def dependency_source_diagnostics
|
|
236
|
+
@dependency_source_index.unresolvable.map do |entry|
|
|
237
|
+
Diagnostic.new(
|
|
238
|
+
path: ".rigor.yml",
|
|
239
|
+
line: 1,
|
|
240
|
+
column: 1,
|
|
241
|
+
message: "dependencies.source_inference[].gem #{entry.gem_name.inspect} could not be " \
|
|
242
|
+
"resolved (#{entry.reason}); skipping",
|
|
243
|
+
severity: :warning,
|
|
244
|
+
rule: "dynamic.dependency-source.gem-not-found",
|
|
245
|
+
source_family: :builtin
|
|
246
|
+
)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# ADR-10 § "Budget interaction" / slice 4 — emits one
|
|
251
|
+
# `:warning` per gem whose Walker run hit the
|
|
252
|
+
# `dependencies.budget_per_gem` cap. The cap is a Walker-
|
|
253
|
+
# side guard rail (slice 4 picks the (α) semantics from
|
|
254
|
+
# ADR-10 WD4: harvesting stops, the dispatcher behaves
|
|
255
|
+
# exactly as before for unrecorded methods). The
|
|
256
|
+
# diagnostic names the gem and points the user at the
|
|
257
|
+
# three remediations: ship RBS, reduce `mode:` from
|
|
258
|
+
# `full` to `when_missing`, or de-list the gem.
|
|
259
|
+
# ADR-10 § "config-conflict diagnostic" / 5d — surfaces
|
|
260
|
+
# `Configuration::Dependencies` warnings accumulated
|
|
261
|
+
# during `from_h` deduplication of the `includes:`-chain
|
|
262
|
+
# source_inference array. Each warning describes a
|
|
263
|
+
# per-gem mode conflict that the merge resolved
|
|
264
|
+
# right-wins; the user sees one diagnostic per conflict.
|
|
265
|
+
# `:warning` matches the user's "warn but don't block"
|
|
266
|
+
# preference per the design discussion.
|
|
267
|
+
def dependency_source_config_conflict_diagnostics
|
|
268
|
+
@configuration.dependencies.warnings.map do |message|
|
|
269
|
+
Diagnostic.new(
|
|
270
|
+
path: ".rigor.yml",
|
|
271
|
+
line: 1,
|
|
272
|
+
column: 1,
|
|
273
|
+
message: message,
|
|
274
|
+
severity: :warning,
|
|
275
|
+
rule: "dynamic.dependency-source.config-conflict",
|
|
276
|
+
source_family: :builtin
|
|
277
|
+
)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def dependency_source_budget_diagnostics
|
|
282
|
+
budget = @configuration.dependencies.budget_per_gem
|
|
283
|
+
@dependency_source_index.budget_exceeded.map do |gem_name|
|
|
284
|
+
Diagnostic.new(
|
|
285
|
+
path: ".rigor.yml",
|
|
286
|
+
line: 1,
|
|
287
|
+
column: 1,
|
|
288
|
+
message: "dependencies.source_inference[].gem #{gem_name.inspect} exceeded the per-gem " \
|
|
289
|
+
"catalog cap (#{budget} method definitions); the remaining methods fall back " \
|
|
290
|
+
"to the existing RBS-or-Dynamic[top] boundary. Ship RBS for the gem, set " \
|
|
291
|
+
"`mode: when_missing` instead of `full`, or de-list the gem.",
|
|
292
|
+
severity: :warning,
|
|
293
|
+
rule: "dynamic.dependency-source.budget-exceeded",
|
|
294
|
+
source_family: :builtin
|
|
295
|
+
)
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
209
299
|
# ADR-9 slice 3 — invokes every loaded plugin's `#prepare`
|
|
210
300
|
# hook once per run, after the loader's `#init` pass and
|
|
211
301
|
# before per-file iteration. Plugins publish facts here
|
|
@@ -24,8 +24,10 @@ module Rigor
|
|
|
24
24
|
class Descriptor # rubocop:disable Metrics/ClassLength
|
|
25
25
|
# Bumped on incompatible schema changes. The storage layer
|
|
26
26
|
# mixes this into the cache key, so a bump implicitly
|
|
27
|
-
# invalidates every cached value.
|
|
28
|
-
|
|
27
|
+
# invalidates every cached value. v2 added the
|
|
28
|
+
# `dependencies` slot for ADR-10 per-gem-version cache slice
|
|
29
|
+
# invalidation.
|
|
30
|
+
SCHEMA_VERSION = 2
|
|
29
31
|
|
|
30
32
|
# Per-slot entry value objects. Constructors validate enums /
|
|
31
33
|
# required fields and freeze the resulting struct so no caller
|
|
@@ -134,6 +136,54 @@ module Rigor
|
|
|
134
136
|
end
|
|
135
137
|
end
|
|
136
138
|
|
|
139
|
+
# Per-(gem, version, mode) row carrying the cache slice
|
|
140
|
+
# boundary for ADR-10 dependency-source inference. A
|
|
141
|
+
# `bundle update` that bumps a listed gem's pinned version
|
|
142
|
+
# produces a different `gem_version` here and therefore a
|
|
143
|
+
# fresh cache key — invalidating exactly that gem's slice
|
|
144
|
+
# without disturbing other gems' slices or the project's
|
|
145
|
+
# own cache.
|
|
146
|
+
#
|
|
147
|
+
# `mode` mirrors the
|
|
148
|
+
# [Configuration::Dependencies::VALID_MODES](../configuration/dependencies.rb)
|
|
149
|
+
# enum (`:disabled` / `:when_missing` / `:full`); a mode
|
|
150
|
+
# change for the same gem also forces invalidation because
|
|
151
|
+
# the inferred shapes depend on whether RBS overrides the
|
|
152
|
+
# walk.
|
|
153
|
+
class DependencyEntry
|
|
154
|
+
VALID_MODES = %i[disabled when_missing full].freeze
|
|
155
|
+
|
|
156
|
+
attr_reader :gem_name, :gem_version, :mode
|
|
157
|
+
|
|
158
|
+
def initialize(gem_name:, gem_version:, mode:)
|
|
159
|
+
unless VALID_MODES.include?(mode)
|
|
160
|
+
raise ArgumentError,
|
|
161
|
+
"DependencyEntry mode must be one of #{VALID_MODES.inspect}, got #{mode.inspect}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
@gem_name = gem_name.to_s.dup.freeze
|
|
165
|
+
@gem_version = gem_version.to_s.dup.freeze
|
|
166
|
+
@mode = mode
|
|
167
|
+
freeze
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def to_h
|
|
171
|
+
{ "gem_name" => gem_name, "gem_version" => gem_version, "mode" => mode.to_s }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def ==(other)
|
|
175
|
+
other.is_a?(DependencyEntry) &&
|
|
176
|
+
other.gem_name == gem_name &&
|
|
177
|
+
other.gem_version == gem_version &&
|
|
178
|
+
other.mode == mode
|
|
179
|
+
end
|
|
180
|
+
alias eql? ==
|
|
181
|
+
|
|
182
|
+
def hash
|
|
183
|
+
[self.class, gem_name, gem_version, mode].hash
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
137
187
|
# Raised when {.compose} encounters incompatible entries
|
|
138
188
|
# under the same key (file digest mismatch, gem-locked
|
|
139
189
|
# disagreement, …). Callers handle the exception by
|
|
@@ -141,13 +191,14 @@ module Rigor
|
|
|
141
191
|
# contribution silently.
|
|
142
192
|
class Conflict < StandardError; end
|
|
143
193
|
|
|
144
|
-
attr_reader :files, :gems, :plugins, :configs
|
|
194
|
+
attr_reader :files, :gems, :plugins, :configs, :dependencies
|
|
145
195
|
|
|
146
|
-
def initialize(files: [], gems: [], plugins: [], configs: [])
|
|
196
|
+
def initialize(files: [], gems: [], plugins: [], configs: [], dependencies: [])
|
|
147
197
|
@files = files.dup.freeze
|
|
148
198
|
@gems = gems.dup.freeze
|
|
149
199
|
@plugins = plugins.dup.freeze
|
|
150
200
|
@configs = configs.dup.freeze
|
|
201
|
+
@dependencies = dependencies.dup.freeze
|
|
151
202
|
freeze
|
|
152
203
|
end
|
|
153
204
|
|
|
@@ -170,7 +221,8 @@ module Rigor
|
|
|
170
221
|
gems = compose_by_key(descriptors.flat_map(&:gems), :name)
|
|
171
222
|
plugins = compose_by_key(descriptors.flat_map(&:plugins), :id)
|
|
172
223
|
configs = compose_by_key(descriptors.flat_map(&:configs), :key)
|
|
173
|
-
|
|
224
|
+
dependencies = compose_by_key(descriptors.flat_map(&:dependencies), :gem_name)
|
|
225
|
+
new(files: files, gems: gems, plugins: plugins, configs: configs, dependencies: dependencies)
|
|
174
226
|
end
|
|
175
227
|
|
|
176
228
|
# @param producer_id [String]
|
|
@@ -196,6 +248,7 @@ module Rigor
|
|
|
196
248
|
def to_canonical_hash
|
|
197
249
|
{
|
|
198
250
|
"configs" => sort_entries(configs, "key").map(&:to_h),
|
|
251
|
+
"dependencies" => sort_entries(dependencies, "gem_name").map(&:to_h),
|
|
199
252
|
"files" => sort_entries(files, "path").map(&:to_h),
|
|
200
253
|
"gems" => sort_entries(gems, "name").map(&:to_h),
|
|
201
254
|
"plugins" => sort_entries(plugins, "id").map(&:to_h)
|