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.
@@ -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