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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -2
  3. data/data/builtins/ruby_core/array.yml +6 -6
  4. data/data/builtins/ruby_core/hash.yml +1 -1
  5. data/data/builtins/ruby_core/io.yml +3 -3
  6. data/data/builtins/ruby_core/numeric.yml +1 -1
  7. data/data/builtins/ruby_core/pathname.yml +100 -100
  8. data/data/builtins/ruby_core/proc.yml +1 -1
  9. data/data/builtins/ruby_core/range.yml +6 -4
  10. data/data/builtins/ruby_core/string.yml +15 -10
  11. data/data/builtins/ruby_core/time.yml +3 -3
  12. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +116 -0
  13. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +123 -0
  14. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +118 -0
  15. data/lib/rigor/analysis/check_rules.rb +346 -18
  16. data/lib/rigor/analysis/rule_catalog.rb +343 -0
  17. data/lib/rigor/analysis/runner.rb +90 -6
  18. data/lib/rigor/builtins/regex_refinement.rb +104 -0
  19. data/lib/rigor/cli/diff_command.rb +169 -0
  20. data/lib/rigor/cli/explain_command.rb +129 -0
  21. data/lib/rigor/cli/type_of_command.rb +3 -3
  22. data/lib/rigor/cli/type_scan_command.rb +4 -4
  23. data/lib/rigor/cli.rb +29 -5
  24. data/lib/rigor/configuration/severity_profile.rb +18 -3
  25. data/lib/rigor/configuration.rb +186 -13
  26. data/lib/rigor/environment.rb +12 -4
  27. data/lib/rigor/inference/expression_typer.rb +3 -1
  28. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +31 -0
  29. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +43 -2
  30. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +104 -12
  31. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +68 -2
  32. data/lib/rigor/inference/method_dispatcher.rb +50 -1
  33. data/lib/rigor/inference/narrowing.rb +150 -6
  34. data/lib/rigor/inference/scope_indexer.rb +220 -17
  35. data/lib/rigor/inference/statement_evaluator.rb +29 -0
  36. data/lib/rigor/plugin/base.rb +43 -0
  37. data/lib/rigor/plugin/fact_store.rb +92 -0
  38. data/lib/rigor/plugin/io_boundary.rb +92 -19
  39. data/lib/rigor/plugin/load_error.rb +14 -2
  40. data/lib/rigor/plugin/loader.rb +116 -0
  41. data/lib/rigor/plugin/manifest.rb +75 -6
  42. data/lib/rigor/plugin/services.rb +14 -2
  43. data/lib/rigor/plugin/trust_policy.rb +30 -7
  44. data/lib/rigor/plugin.rb +1 -0
  45. data/lib/rigor/scope.rb +30 -5
  46. data/lib/rigor/trinary.rb +1 -1
  47. data/lib/rigor/type/integer_range.rb +6 -2
  48. data/lib/rigor/version.rb +1 -1
  49. data/sig/rigor/environment.rbs +3 -2
  50. data/sig/rigor/scope.rbs +3 -0
  51. data/sig/rigor.rbs +8 -2
  52. metadata +9 -1
@@ -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
@@ -48,7 +48,7 @@ module Rigor
48
48
  private
49
49
 
50
50
  def parse_options
51
- options = { format: "text", trace: false, config: Configuration::DEFAULT_PATH }
51
+ options = { format: "text", trace: false, config: nil }
52
52
 
53
53
  parser = OptionParser.new do |opts|
54
54
  opts.banner = USAGE
@@ -65,8 +65,9 @@ module Rigor
65
65
  file, line, column = target
66
66
  return 1 unless file_exists?(file)
67
67
 
68
+ configuration = Configuration.load(options.fetch(:config))
68
69
  source = File.read(file)
69
- parse_result = Prism.parse(source, filepath: file)
70
+ parse_result = Prism.parse(source, filepath: file, version: configuration.target_ruby)
70
71
  return 1 if parse_errors?(parse_result, file)
71
72
 
72
73
  node = locate_node(source: source, root: parse_result.value, file: file, line: line, column: column)
@@ -74,7 +75,6 @@ module Rigor
74
75
  return 1 if node.nil?
75
76
 
76
77
  tracer = options[:trace] ? Inference::FallbackTracer.new : nil
77
- configuration = Configuration.load(options.fetch(:config))
78
78
  base_scope = Scope.empty(environment: project_environment(file, configuration))
79
79
 
80
80
  # Build a per-node scope index so locals bound earlier in the
@@ -46,7 +46,7 @@ module Rigor
46
46
 
47
47
  def parse_options
48
48
  options = { format: "text", limit: 10, show_recognized: false, threshold: nil,
49
- config: Configuration::DEFAULT_PATH }
49
+ config: nil }
50
50
 
51
51
  parser = OptionParser.new do |opts|
52
52
  opts.banner = USAGE
@@ -93,7 +93,7 @@ module Rigor
93
93
  scope = Scope.empty(environment: project_environment(configuration))
94
94
  scanner = Inference::CoverageScanner.new(scope: scope)
95
95
  accumulator = ScanAccumulator.new
96
- paths.each { |path| scan_one(path, scanner, accumulator) }
96
+ paths.each { |path| scan_one(path, scanner, accumulator, configuration) }
97
97
  accumulator.to_report(paths, options)
98
98
  end
99
99
 
@@ -107,9 +107,9 @@ module Rigor
107
107
  )
108
108
  end
109
109
 
110
- def scan_one(path, scanner, accumulator)
110
+ def scan_one(path, scanner, accumulator, configuration)
111
111
  source = File.read(path)
112
- parse_result = Prism.parse(source, filepath: path)
112
+ parse_result = Prism.parse(source, filepath: path, version: configuration.target_ruby)
113
113
  if parse_result.errors.any?
114
114
  accumulator.record_parse_error(path, parse_result.errors)
115
115
  return
data/lib/rigor/cli.rb CHANGED
@@ -22,7 +22,9 @@ module Rigor
22
22
  "check" => :run_check,
23
23
  "init" => :run_init,
24
24
  "type-of" => :run_type_of,
25
- "type-scan" => :run_type_scan
25
+ "type-scan" => :run_type_scan,
26
+ "explain" => :run_explain,
27
+ "diff" => :run_diff
26
28
  }.freeze
27
29
 
28
30
  def self.start(argv = ARGV, out: $stdout, err: $stderr)
@@ -70,11 +72,11 @@ module Rigor
70
72
 
71
73
  options = parse_check_options
72
74
 
73
- cache_root = ".rigor/cache"
75
+ configuration = Configuration.load(options.fetch(:config))
76
+ cache_root = configuration.cache_path
74
77
  handle_clear_cache(cache_root) if options.fetch(:clear_cache)
75
78
  cache_store = options.fetch(:no_cache) ? nil : Cache::Store.new(root: cache_root)
76
79
 
77
- configuration = Configuration.load(options.fetch(:config))
78
80
  paths = @argv.empty? ? configuration.paths : @argv
79
81
  runner = Analysis::Runner.new(
80
82
  configuration: configuration,
@@ -90,7 +92,9 @@ module Rigor
90
92
 
91
93
  def parse_check_options
92
94
  options = {
93
- config: Configuration::DEFAULT_PATH,
95
+ # `nil` triggers `Configuration.discover` (`.rigor.yml` then
96
+ # `.rigor.dist.yml`); an explicit `--config=PATH` overrides.
97
+ config: nil,
94
98
  format: "text",
95
99
  explain: false,
96
100
  cache_stats: false,
@@ -170,9 +174,14 @@ module Rigor
170
174
  end
171
175
 
172
176
  def run_init
177
+ # Default destination is `.rigor.dist.yml` — the
178
+ # project-default config that gets committed. Developers
179
+ # who want a personal override layer create `.rigor.yml`
180
+ # alongside it (auto-discovery prefers `.rigor.yml` when
181
+ # both are present; no implicit merge).
173
182
  options = {
174
183
  force: false,
175
- path: Configuration::DEFAULT_PATH
184
+ path: ".rigor.dist.yml"
176
185
  }
177
186
 
178
187
  parser = OptionParser.new do |opts|
@@ -200,6 +209,7 @@ module Rigor
200
209
  # most likely to want to edit.
201
210
  def init_template
202
211
  <<~YAML
212
+ # yaml-language-server: $schema=https://github.com/zenwerk/rigor/raw/master/schemas/rigor-config.schema.json
203
213
  # Rigor configuration. See docs/CURRENT_WORK.md for the
204
214
  # full set of features the analyzer ships in this preview.
205
215
  #
@@ -257,6 +267,18 @@ module Rigor
257
267
  TypeScanCommand.new(argv: @argv, out: @out, err: @err).run
258
268
  end
259
269
 
270
+ def run_explain
271
+ require_relative "cli/explain_command"
272
+
273
+ ExplainCommand.new(argv: @argv, out: @out, err: @err).run
274
+ end
275
+
276
+ def run_diff
277
+ require_relative "cli/diff_command"
278
+
279
+ DiffCommand.new(argv: @argv, out: @out, err: @err).run
280
+ end
281
+
260
282
  def write_result(result, format)
261
283
  case format
262
284
  when "json"
@@ -294,6 +316,8 @@ module Rigor
294
316
  init Create a starter .rigor.yml
295
317
  type-of Print the inferred type at FILE:LINE:COL
296
318
  type-scan Report Scope#type_of coverage across PATHs
319
+ explain Print the description of one or all CheckRules
320
+ diff Compare current diagnostics to a saved baseline JSON
297
321
  version Print the Rigor version
298
322
  help Print this help
299
323
  HELP
@@ -43,9 +43,14 @@ module Rigor
43
43
  "call.argument-type-mismatch" => :warning,
44
44
  "call.possible-nil-receiver" => :warning,
45
45
  "flow.always-raises" => :warning,
46
+ "flow.unreachable-branch" => :info,
47
+ "flow.dead-assignment" => :info,
48
+ "flow.always-truthy-condition" => :info,
46
49
  "assert.type-mismatch" => :error,
47
50
  "dump.type" => :info,
48
- "def.return-type-mismatch" => :warning
51
+ "def.return-type-mismatch" => :warning,
52
+ "def.method-visibility-mismatch" => :warning,
53
+ "def.ivar-write-mismatch" => :warning
49
54
  }.freeze,
50
55
  balanced: {
51
56
  "call.undefined-method" => :error,
@@ -53,9 +58,14 @@ module Rigor
53
58
  "call.argument-type-mismatch" => :error,
54
59
  "call.possible-nil-receiver" => :error,
55
60
  "flow.always-raises" => :error,
61
+ "flow.unreachable-branch" => :warning,
62
+ "flow.dead-assignment" => :warning,
63
+ "flow.always-truthy-condition" => :warning,
56
64
  "assert.type-mismatch" => :error,
57
65
  "dump.type" => :info,
58
- "def.return-type-mismatch" => :warning
66
+ "def.return-type-mismatch" => :warning,
67
+ "def.method-visibility-mismatch" => :error,
68
+ "def.ivar-write-mismatch" => :warning
59
69
  }.freeze,
60
70
  strict: {
61
71
  "call.undefined-method" => :error,
@@ -63,9 +73,14 @@ module Rigor
63
73
  "call.argument-type-mismatch" => :error,
64
74
  "call.possible-nil-receiver" => :error,
65
75
  "flow.always-raises" => :error,
76
+ "flow.unreachable-branch" => :error,
77
+ "flow.dead-assignment" => :error,
78
+ "flow.always-truthy-condition" => :error,
66
79
  "assert.type-mismatch" => :error,
67
80
  "dump.type" => :error,
68
- "def.return-type-mismatch" => :error
81
+ "def.return-type-mismatch" => :error,
82
+ "def.method-visibility-mismatch" => :error,
83
+ "def.ivar-write-mismatch" => :error
69
84
  }.freeze
70
85
  }.freeze
71
86