rigortype 0.1.8 → 0.1.9
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 +186 -513
- data/lib/rigor/analysis/check_rules.rb +20 -0
- data/lib/rigor/cli/annotate_command.rb +224 -0
- data/lib/rigor/cli/baseline_command.rb +36 -16
- data/lib/rigor/cli/prism_colorizer.rb +111 -0
- data/lib/rigor/cli.rb +62 -4
- data/lib/rigor/environment.rb +9 -1
- data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
- data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
- data/lib/rigor/inference/expression_typer.rb +165 -6
- data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +173 -10
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
- data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
- data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +316 -2
- data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
- data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
- data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
- data/lib/rigor/inference/method_dispatcher.rb +148 -1
- data/lib/rigor/inference/method_parameter_binder.rb +67 -10
- data/lib/rigor/inference/narrowing.rb +29 -10
- data/lib/rigor/inference/statement_evaluator.rb +3 -1
- data/lib/rigor/plugin/base.rb +39 -0
- data/lib/rigor/plugin/loader.rb +22 -1
- data/lib/rigor/plugin/manifest.rb +73 -10
- data/lib/rigor/plugin/protocol_contract.rb +185 -0
- data/lib/rigor/plugin/registry.rb +66 -0
- data/lib/rigor/triage/catalogue.rb +2 -2
- data/lib/rigor/type/constant.rb +29 -2
- data/lib/rigor/version.rb +1 -1
- metadata +11 -1
|
@@ -355,6 +355,15 @@ module Rigor
|
|
|
355
355
|
class_name = concrete_class_name(receiver_type)
|
|
356
356
|
return nil if class_name.nil?
|
|
357
357
|
|
|
358
|
+
# ADR-26 — a plugin may declare a class "open": one
|
|
359
|
+
# known to respond beyond its RBS-declared method
|
|
360
|
+
# surface (e.g. `ActiveRecord::Relation`, which
|
|
361
|
+
# delegates an unbounded set of user-defined scopes to
|
|
362
|
+
# its model). Flagging an undefined method on a class
|
|
363
|
+
# with an open dynamic surface is unsound, so the rule
|
|
364
|
+
# skips it.
|
|
365
|
+
return nil if open_receiver?(class_name, scope)
|
|
366
|
+
|
|
358
367
|
# Slice 7 phase 12 — suppress when the user has
|
|
359
368
|
# declared the method in source (instance `def`,
|
|
360
369
|
# `def self.foo`, or recognised `define_method`).
|
|
@@ -424,6 +433,17 @@ module Rigor
|
|
|
424
433
|
nil
|
|
425
434
|
end
|
|
426
435
|
|
|
436
|
+
# ADR-26 — whether `class_name` is declared "open" by a
|
|
437
|
+
# loaded plugin (manifest `open_receivers:`). An open
|
|
438
|
+
# class responds beyond its RBS surface, so the
|
|
439
|
+
# `call.undefined-method` rule must not fire for it.
|
|
440
|
+
def open_receiver?(class_name, scope)
|
|
441
|
+
registry = scope.environment&.plugin_registry
|
|
442
|
+
return false if registry.nil?
|
|
443
|
+
|
|
444
|
+
registry.open_receiver?(class_name)
|
|
445
|
+
end
|
|
446
|
+
|
|
427
447
|
def definition_available?(receiver_type, class_name, scope)
|
|
428
448
|
if receiver_type.is_a?(Type::Singleton)
|
|
429
449
|
!Rigor::Reflection.singleton_definition(class_name, scope: scope).nil?
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optionparser"
|
|
4
|
+
require "prism"
|
|
5
|
+
|
|
6
|
+
require_relative "../configuration"
|
|
7
|
+
require_relative "../environment"
|
|
8
|
+
require_relative "../scope"
|
|
9
|
+
require_relative "../inference/scope_indexer"
|
|
10
|
+
require_relative "prism_colorizer"
|
|
11
|
+
|
|
12
|
+
module Rigor
|
|
13
|
+
class CLI
|
|
14
|
+
# Executes `rigor annotate FILE`.
|
|
15
|
+
#
|
|
16
|
+
# For every source line the command finds the expression the
|
|
17
|
+
# line evaluates to — the last statement that ends on the line
|
|
18
|
+
# (so `1; 2; 3` reports `3`), or, for a line that no statement
|
|
19
|
+
# closes, the widest expression ending there (so the `if nil`
|
|
20
|
+
# header reports its condition). It infers that expression's
|
|
21
|
+
# type and appends a `#=> dump_type: <type>` comment.
|
|
22
|
+
#
|
|
23
|
+
# The annotated source is re-parsed with Prism — a sanity gate,
|
|
24
|
+
# since the appended text is always a comment — and printed to
|
|
25
|
+
# stdout with IRB-style syntax highlighting via
|
|
26
|
+
# {PrismColorizer}.
|
|
27
|
+
class AnnotateCommand
|
|
28
|
+
USAGE = "Usage: rigor annotate [options] FILE"
|
|
29
|
+
|
|
30
|
+
# Appended ` #=> dump_type: <type>` suffix. Matched and
|
|
31
|
+
# stripped before re-annotating so re-running is idempotent.
|
|
32
|
+
ANNOTATION_PATTERN = /\s*#=>\s*dump_type:.*\z/
|
|
33
|
+
|
|
34
|
+
def initialize(argv:, out:, err:)
|
|
35
|
+
@argv = argv
|
|
36
|
+
@out = out
|
|
37
|
+
@err = err
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Integer] CLI exit status.
|
|
41
|
+
def run
|
|
42
|
+
options = parse_options
|
|
43
|
+
file = @argv.shift
|
|
44
|
+
if file.nil?
|
|
45
|
+
@err.puts(USAGE)
|
|
46
|
+
return CLI::EXIT_USAGE
|
|
47
|
+
end
|
|
48
|
+
unless File.file?(file)
|
|
49
|
+
@err.puts("annotate: file not found: #{file}")
|
|
50
|
+
return 1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
execute(file, options)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def parse_options
|
|
59
|
+
# Default: colour a tty, unless `NO_COLOR` opts out. An
|
|
60
|
+
# explicit `--color` / `--no-color` overrides both.
|
|
61
|
+
options = { config: nil, color: @out.tty? && !no_color_env? }
|
|
62
|
+
|
|
63
|
+
parser = OptionParser.new do |opts|
|
|
64
|
+
opts.banner = USAGE
|
|
65
|
+
opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
|
|
66
|
+
opts.on("--[no-]color",
|
|
67
|
+
"Force or disable ANSI colour (default: auto-detect a tty; honours NO_COLOR)") do |value|
|
|
68
|
+
options[:color] = value
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
parser.parse!(@argv)
|
|
72
|
+
|
|
73
|
+
options
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# https://no-color.org — colour output is suppressed by
|
|
77
|
+
# default when `NO_COLOR` is present and not an empty string,
|
|
78
|
+
# regardless of its value.
|
|
79
|
+
def no_color_env?
|
|
80
|
+
value = ENV.fetch("NO_COLOR", nil)
|
|
81
|
+
!value.nil? && !value.empty?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def execute(file, options)
|
|
85
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
86
|
+
source = File.read(file)
|
|
87
|
+
parse_result = Prism.parse(source, filepath: file, version: configuration.target_ruby)
|
|
88
|
+
return 1 if parse_errors?(parse_result, file)
|
|
89
|
+
|
|
90
|
+
scope_index = Inference::ScopeIndexer.index(
|
|
91
|
+
parse_result.value, default_scope: base_scope(configuration)
|
|
92
|
+
)
|
|
93
|
+
line_types = LineTypeCollector.new(scope_index).collect(parse_result.value)
|
|
94
|
+
|
|
95
|
+
@out.puts(render(annotate(source, line_types), color: options.fetch(:color)))
|
|
96
|
+
0
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def base_scope(configuration)
|
|
100
|
+
Scope.empty(
|
|
101
|
+
environment: Environment.for_project(
|
|
102
|
+
libraries: configuration.libraries,
|
|
103
|
+
signature_paths: configuration.signature_paths
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def parse_errors?(parse_result, file)
|
|
109
|
+
return false if parse_result.success?
|
|
110
|
+
|
|
111
|
+
parse_result.errors.each do |error|
|
|
112
|
+
@err.puts("#{file}:#{error.location.start_line}: #{error.message}")
|
|
113
|
+
end
|
|
114
|
+
true
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Appends ` #=> dump_type: <type>` to every line a type was
|
|
118
|
+
# inferred for, aligning the comment column.
|
|
119
|
+
def annotate(source, line_types)
|
|
120
|
+
lines = source.lines
|
|
121
|
+
column = annotation_column(lines, line_types)
|
|
122
|
+
|
|
123
|
+
lines.each_with_index.map do |line, index|
|
|
124
|
+
type = line_types[index + 1]
|
|
125
|
+
eol = line.end_with?("\n") ? "\n" : ""
|
|
126
|
+
code = line.chomp.sub(ANNOTATION_PATTERN, "")
|
|
127
|
+
next "#{code}#{eol}" if type.nil?
|
|
128
|
+
|
|
129
|
+
"#{code.ljust(column)} #=> dump_type: #{type.describe(:short)}#{eol}"
|
|
130
|
+
end.join
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def annotation_column(lines, line_types)
|
|
134
|
+
widths = lines.each_index.filter_map do |index|
|
|
135
|
+
next unless line_types.key?(index + 1)
|
|
136
|
+
|
|
137
|
+
lines[index].chomp.sub(ANNOTATION_PATTERN, "").length
|
|
138
|
+
end
|
|
139
|
+
widths.max || 0
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def render(annotated, color:)
|
|
143
|
+
return annotated unless color
|
|
144
|
+
return annotated unless Prism.parse(annotated).success?
|
|
145
|
+
|
|
146
|
+
PrismColorizer.colorize(annotated)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Walks a parsed program and resolves, per source line, the
|
|
151
|
+
# type of the expression the line evaluates to. Used only by
|
|
152
|
+
# {AnnotateCommand}.
|
|
153
|
+
class LineTypeCollector
|
|
154
|
+
def initialize(scope_index)
|
|
155
|
+
@scope_index = scope_index
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @param program [Prism::ProgramNode]
|
|
159
|
+
# @return [Hash{Integer => Rigor::Type}] 1-indexed line => type.
|
|
160
|
+
def collect(program)
|
|
161
|
+
by_line = {}
|
|
162
|
+
each_statement(program) do |statement|
|
|
163
|
+
type = type_of(statement)
|
|
164
|
+
by_line[statement.location.end_line] = type unless type.nil?
|
|
165
|
+
end
|
|
166
|
+
fill_uncovered_lines(program, by_line)
|
|
167
|
+
by_line
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
# Yields each statement node (a child of any `StatementsNode`
|
|
173
|
+
# anywhere in the tree) in source order, so a later statement
|
|
174
|
+
# ending on a line overwrites an earlier one — `1; 2; 3`
|
|
175
|
+
# resolves to `3`.
|
|
176
|
+
def each_statement(node, &)
|
|
177
|
+
return if node.nil?
|
|
178
|
+
|
|
179
|
+
node.body.each(&) if node.is_a?(Prism::StatementsNode)
|
|
180
|
+
node.compact_child_nodes.each { |child| each_statement(child, &) }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# For a line no statement closes (the `if` / block header
|
|
184
|
+
# lines), fall back to the widest expression ending there.
|
|
185
|
+
def fill_uncovered_lines(program, by_line)
|
|
186
|
+
widest_per_line(program).each do |line, node|
|
|
187
|
+
next if by_line.key?(line)
|
|
188
|
+
|
|
189
|
+
type = type_of(node)
|
|
190
|
+
by_line[line] = type unless type.nil?
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def widest_per_line(program)
|
|
195
|
+
widest = {}
|
|
196
|
+
walk(program) do |node|
|
|
197
|
+
next if node.is_a?(Prism::ProgramNode) || node.is_a?(Prism::StatementsNode)
|
|
198
|
+
|
|
199
|
+
line = node.location.end_line
|
|
200
|
+
current = widest[line]
|
|
201
|
+
widest[line] = node if current.nil? || span(node) > span(current)
|
|
202
|
+
end
|
|
203
|
+
widest
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def span(node)
|
|
207
|
+
node.location.end_offset - node.location.start_offset
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def walk(node, &block)
|
|
211
|
+
return if node.nil?
|
|
212
|
+
|
|
213
|
+
block.call(node)
|
|
214
|
+
node.compact_child_nodes.each { |child| walk(child, &block) }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def type_of(node)
|
|
218
|
+
@scope_index[node].type_of(node)
|
|
219
|
+
rescue StandardError
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -9,22 +9,22 @@ require_relative "../configuration"
|
|
|
9
9
|
|
|
10
10
|
module Rigor
|
|
11
11
|
class CLI
|
|
12
|
-
# ADR-22
|
|
13
|
-
#
|
|
14
|
-
# extend the subcommand surface with `dump`, `drift`,
|
|
15
|
-
# `prune`, `regenerate`.
|
|
16
|
-
#
|
|
17
|
-
# Initial subcommand: `generate`.
|
|
12
|
+
# ADR-22 — `rigor baseline {generate,regenerate,dump,drift,
|
|
13
|
+
# prune}` subcommands, backed by `Rigor::Analysis::Baseline`.
|
|
18
14
|
#
|
|
19
15
|
# rigor baseline generate # default: rule-ID rows
|
|
20
16
|
# rigor baseline generate --match-mode message
|
|
21
17
|
# rigor baseline generate --force # overwrite existing
|
|
22
18
|
# rigor baseline generate --output=PATH
|
|
19
|
+
# rigor baseline regenerate # slice 5: unconditional rewrite
|
|
20
|
+
# rigor baseline dump
|
|
21
|
+
# rigor baseline drift
|
|
22
|
+
# rigor baseline prune
|
|
23
23
|
class BaselineCommand # rubocop:disable Metrics/ClassLength
|
|
24
24
|
EXIT_USAGE = 64
|
|
25
25
|
DEFAULT_BASELINE_PATH = ".rigor-baseline.yml"
|
|
26
26
|
|
|
27
|
-
SUBCOMMANDS = %w[generate dump drift prune].freeze
|
|
27
|
+
SUBCOMMANDS = %w[generate regenerate dump drift prune].freeze
|
|
28
28
|
|
|
29
29
|
def initialize(argv:, out: $stdout, err: $stderr)
|
|
30
30
|
@argv = argv
|
|
@@ -39,6 +39,7 @@ module Rigor
|
|
|
39
39
|
@out.puts(help)
|
|
40
40
|
0
|
|
41
41
|
when "generate" then run_generate
|
|
42
|
+
when "regenerate" then run_regenerate
|
|
42
43
|
when "dump" then run_dump
|
|
43
44
|
when "drift" then run_drift
|
|
44
45
|
when "prune" then run_prune
|
|
@@ -59,21 +60,36 @@ module Rigor
|
|
|
59
60
|
path = options.fetch(:output)
|
|
60
61
|
|
|
61
62
|
if File.exist?(path) && !options.fetch(:force)
|
|
62
|
-
@err.puts("rigor: #{path} already exists. Re-run with --force to
|
|
63
|
+
@err.puts("rigor: #{path} already exists. Re-run with --force to " \
|
|
64
|
+
"overwrite, or use `rigor baseline regenerate`.")
|
|
63
65
|
return EXIT_USAGE
|
|
64
66
|
end
|
|
65
67
|
|
|
68
|
+
write_baseline(options, verb: "wrote baseline to")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# ADR-22 slice 5 — `regenerate` is `generate --force`: the
|
|
72
|
+
# end-of-quality-improvement-session refresh after landing
|
|
73
|
+
# baseline-reducing fixes. It rewrites the file
|
|
74
|
+
# unconditionally (no existence guard, no `--force` flag),
|
|
75
|
+
# so `rigor baseline regenerate` reads cleanly as "make the
|
|
76
|
+
# baseline match reality again".
|
|
77
|
+
def run_regenerate
|
|
78
|
+
options = parse_generate_options(subcommand: "regenerate")
|
|
79
|
+
write_baseline(options, verb: "regenerated baseline")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def write_baseline(options, verb:)
|
|
83
|
+
path = options.fetch(:output)
|
|
66
84
|
configuration = Configuration.load(options.fetch(:config))
|
|
67
85
|
diagnostics = collect_diagnostics(configuration, options)
|
|
68
86
|
|
|
69
87
|
baseline = Analysis::Baseline.from_diagnostics(diagnostics, match_mode: options.fetch(:match_mode))
|
|
70
88
|
File.write(path, baseline.to_yaml)
|
|
71
89
|
|
|
72
|
-
bucket_count = baseline.size
|
|
73
|
-
diagnostic_count = diagnostics.size
|
|
74
90
|
@err.puts(
|
|
75
|
-
"rigor:
|
|
76
|
-
"(#{
|
|
91
|
+
"rigor: #{verb} #{path} " \
|
|
92
|
+
"(#{baseline.size} bucket(s) covering #{diagnostics.size} diagnostic(s); " \
|
|
77
93
|
"match-mode: #{options.fetch(:match_mode)})"
|
|
78
94
|
)
|
|
79
95
|
if configuration.baseline_path.nil?
|
|
@@ -85,7 +101,7 @@ module Rigor
|
|
|
85
101
|
0
|
|
86
102
|
end
|
|
87
103
|
|
|
88
|
-
def parse_generate_options
|
|
104
|
+
def parse_generate_options(subcommand: "generate")
|
|
89
105
|
options = {
|
|
90
106
|
config: nil,
|
|
91
107
|
output: DEFAULT_BASELINE_PATH,
|
|
@@ -93,7 +109,7 @@ module Rigor
|
|
|
93
109
|
force: false
|
|
94
110
|
}
|
|
95
111
|
parser = OptionParser.new do |opts|
|
|
96
|
-
opts.banner = "Usage: rigor baseline
|
|
112
|
+
opts.banner = "Usage: rigor baseline #{subcommand} [options]"
|
|
97
113
|
opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
|
|
98
114
|
opts.on("--output=PATH", "Write baseline to PATH (default: #{DEFAULT_BASELINE_PATH})") do |v|
|
|
99
115
|
options[:output] = v
|
|
@@ -102,7 +118,10 @@ module Rigor
|
|
|
102
118
|
"Row form: rule (default) or message") do |v|
|
|
103
119
|
options[:match_mode] = v
|
|
104
120
|
end
|
|
105
|
-
|
|
121
|
+
# `regenerate` always overwrites — no `--force` to offer.
|
|
122
|
+
if subcommand == "generate"
|
|
123
|
+
opts.on("--force", "Overwrite an existing baseline file") { options[:force] = true }
|
|
124
|
+
end
|
|
106
125
|
end
|
|
107
126
|
parser.parse!(@argv)
|
|
108
127
|
options
|
|
@@ -290,7 +309,7 @@ module Rigor
|
|
|
290
309
|
case status
|
|
291
310
|
when :over then "## Over threshold (#{count}) — bucket exceeded; check the regular diagnostic output."
|
|
292
311
|
when :cleared then "## Cleared (#{count}) — `rigor baseline prune` can drop these."
|
|
293
|
-
when :reducible then "## Reducible (#{count}) — tightening opportunity;
|
|
312
|
+
when :reducible then "## Reducible (#{count}) — tightening opportunity; run `rigor baseline regenerate`."
|
|
294
313
|
when :within then "## Within threshold (#{count})"
|
|
295
314
|
end
|
|
296
315
|
end
|
|
@@ -365,6 +384,7 @@ module Rigor
|
|
|
365
384
|
|
|
366
385
|
Subcommands:
|
|
367
386
|
generate Write a fresh baseline file from a `rigor check` run.
|
|
387
|
+
regenerate Rewrite the baseline unconditionally (post-fix refresh).
|
|
368
388
|
dump Print the contents of an existing baseline.
|
|
369
389
|
drift Compare baseline vs current diagnostics (reduction / regression hints).
|
|
370
390
|
prune Drop cleared buckets (`actual == 0`) from the baseline.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
class CLI
|
|
7
|
+
# Re-lexes Ruby source with Prism and wraps each token in an
|
|
8
|
+
# ANSI colour escape, producing IRB-style syntax highlighting.
|
|
9
|
+
#
|
|
10
|
+
# Rigor ships no runtime dependencies (ADR-0), so the `irb`
|
|
11
|
+
# gem's `IRB::Color` is not available; this module reproduces
|
|
12
|
+
# the same effect from Prism's own token stream. `rigor
|
|
13
|
+
# annotate` re-parses its annotated output through here before
|
|
14
|
+
# printing.
|
|
15
|
+
module PrismColorizer
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
RESET = "\e[0m"
|
|
19
|
+
|
|
20
|
+
# token category => ANSI SGR parameters.
|
|
21
|
+
CATEGORY_SGR = {
|
|
22
|
+
comment: "90", # bright black (faint grey)
|
|
23
|
+
keyword: "33", # yellow
|
|
24
|
+
literal_kw: "36", # cyan — nil / true / false / self
|
|
25
|
+
number: "34", # blue
|
|
26
|
+
string: "32", # green
|
|
27
|
+
symbol: "36", # cyan
|
|
28
|
+
constant: "1;34", # bold blue
|
|
29
|
+
variable: "34", # blue — @ivar / @@cvar / $gvar
|
|
30
|
+
default: nil
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
LITERAL_KEYWORDS = %i[
|
|
34
|
+
KEYWORD_NIL KEYWORD_TRUE KEYWORD_FALSE KEYWORD_SELF
|
|
35
|
+
KEYWORD___FILE__ KEYWORD___LINE__ KEYWORD___ENCODING__
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
VARIABLE_TOKENS = %i[INSTANCE_VARIABLE CLASS_VARIABLE GLOBAL_VARIABLE].freeze
|
|
39
|
+
|
|
40
|
+
# @param source [String] Ruby source.
|
|
41
|
+
# @return [String] the source with ANSI colour escapes, or
|
|
42
|
+
# the input unchanged when lexing surfaces an error.
|
|
43
|
+
def colorize(source)
|
|
44
|
+
result = Prism.lex(source)
|
|
45
|
+
return source unless result.errors.empty?
|
|
46
|
+
|
|
47
|
+
render(source, result.value)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def render(source, lexed)
|
|
51
|
+
out = +""
|
|
52
|
+
offset = 0
|
|
53
|
+
previous_type = nil
|
|
54
|
+
lexed.each do |entry|
|
|
55
|
+
token = entry.first
|
|
56
|
+
location = token.location
|
|
57
|
+
out << source[offset...location.start_offset]
|
|
58
|
+
break if token.type == :EOF
|
|
59
|
+
|
|
60
|
+
text = source[location.start_offset...location.end_offset]
|
|
61
|
+
out << paint(text, effective_category(token.type, previous_type))
|
|
62
|
+
offset = location.end_offset
|
|
63
|
+
previous_type = token.type
|
|
64
|
+
end
|
|
65
|
+
out << (source[offset..] || "")
|
|
66
|
+
out
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The token after a `SYMBOL_BEGIN` (`:`) carries the symbol
|
|
70
|
+
# name — and Prism lexes `:then` / `:class` etc. as a
|
|
71
|
+
# keyword token — so it is painted with the symbol colour
|
|
72
|
+
# regardless of its own token type.
|
|
73
|
+
def effective_category(token_type, previous_type)
|
|
74
|
+
return :symbol if previous_type == :SYMBOL_BEGIN
|
|
75
|
+
|
|
76
|
+
category(token_type)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def paint(text, category)
|
|
80
|
+
sgr = CATEGORY_SGR.fetch(category)
|
|
81
|
+
return text if sgr.nil? || text.empty?
|
|
82
|
+
|
|
83
|
+
# Keep a trailing newline outside the colour span so the
|
|
84
|
+
# reset sits on the token's own line (comments include it).
|
|
85
|
+
trailing = text[/\s*\z/] || ""
|
|
86
|
+
body = trailing.empty? ? text : text[0...-trailing.length]
|
|
87
|
+
return text if body.empty?
|
|
88
|
+
|
|
89
|
+
"\e[#{sgr}m#{body}#{RESET}#{trailing}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def category(token_type)
|
|
93
|
+
name = token_type.to_s
|
|
94
|
+
return :comment if token_type == :COMMENT
|
|
95
|
+
return :literal_kw if LITERAL_KEYWORDS.include?(token_type)
|
|
96
|
+
return :keyword if name.start_with?("KEYWORD_")
|
|
97
|
+
return :number if number_token?(name)
|
|
98
|
+
return :string if name.start_with?("STRING_", "HEREDOC_") || token_type == :STRING_CONTENT
|
|
99
|
+
return :symbol if name.start_with?("SYMBOL_")
|
|
100
|
+
return :constant if token_type == :CONSTANT
|
|
101
|
+
return :variable if VARIABLE_TOKENS.include?(token_type)
|
|
102
|
+
|
|
103
|
+
:default
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def number_token?(name)
|
|
107
|
+
name.start_with?("INTEGER", "FLOAT", "RATIONAL", "IMAGINARY")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
data/lib/rigor/cli.rb
CHANGED
|
@@ -21,6 +21,7 @@ module Rigor
|
|
|
21
21
|
HANDLERS = {
|
|
22
22
|
"check" => :run_check,
|
|
23
23
|
"init" => :run_init,
|
|
24
|
+
"annotate" => :run_annotate,
|
|
24
25
|
"type-of" => :run_type_of,
|
|
25
26
|
"type-scan" => :run_type_scan,
|
|
26
27
|
"explain" => :run_explain,
|
|
@@ -88,13 +89,56 @@ module Rigor
|
|
|
88
89
|
configuration: configuration, options: options,
|
|
89
90
|
buffer: buffer, cache_root: cache_root
|
|
90
91
|
)
|
|
91
|
-
|
|
92
|
-
result = apply_baseline_filter(
|
|
92
|
+
raw_result = runner.run(@argv.empty? ? configuration.paths : @argv)
|
|
93
|
+
result = apply_baseline_filter(raw_result, configuration, options)
|
|
93
94
|
|
|
94
95
|
write_result(result, options.fetch(:format))
|
|
95
96
|
write_run_stats(result.stats) if result.stats
|
|
96
97
|
write_cache_stats(cache_root, runner.cache_store) if options.fetch(:cache_stats)
|
|
97
|
-
|
|
98
|
+
|
|
99
|
+
exit_code = result.success? ? 0 : 1
|
|
100
|
+
exit_code = 1 if baseline_strict_violation?(raw_result.diagnostics, configuration, options)
|
|
101
|
+
exit_code
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# ADR-22 slice 5 — the `--baseline-strict` CI gate. When the
|
|
105
|
+
# flag is set, ANY baseline drift fails the run — not only
|
|
106
|
+
# excess drift (a bucket over threshold, which already fails
|
|
107
|
+
# via the surfaced diagnostics) but also DEFICIT drift
|
|
108
|
+
# (`actual < count`: the baseline has grown looser than the
|
|
109
|
+
# code and should be regenerated). A no-op, with a stderr
|
|
110
|
+
# note, when no baseline is active — the flag never
|
|
111
|
+
# implicitly loads a baseline the config did not name (WD2).
|
|
112
|
+
def baseline_strict_violation?(raw_diagnostics, configuration, options)
|
|
113
|
+
return false unless options.fetch(:baseline_strict)
|
|
114
|
+
|
|
115
|
+
path = resolve_baseline_path(configuration, options)
|
|
116
|
+
if path.nil?
|
|
117
|
+
@err.puts("rigor: --baseline-strict given but no baseline is active; nothing to gate.")
|
|
118
|
+
return false
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
baseline = Analysis::Baseline.load(path)
|
|
122
|
+
return false if baseline.nil? || baseline.empty?
|
|
123
|
+
|
|
124
|
+
drifted = baseline.audit(raw_diagnostics).reject { |row| row.status == :within }
|
|
125
|
+
return false if drifted.empty?
|
|
126
|
+
|
|
127
|
+
report_strict_drift(drifted, path)
|
|
128
|
+
true
|
|
129
|
+
rescue Analysis::Baseline::LoadError => e
|
|
130
|
+
@err.puts("rigor: baseline load failed: #{e.message} (--baseline-strict gate skipped)")
|
|
131
|
+
false
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def report_strict_drift(rows, path)
|
|
135
|
+
@err.puts("rigor: --baseline-strict — #{rows.size} bucket(s) drifted from #{path}:")
|
|
136
|
+
rows.sort_by { |r| [r.bucket.file, r.bucket.rule] }.each do |row|
|
|
137
|
+
delta = row.delta.positive? ? "+#{row.delta}" : row.delta.to_s
|
|
138
|
+
@err.puts(" #{row.bucket.file} [#{row.bucket.rule}] " \
|
|
139
|
+
"#{row.bucket.count} → #{row.actual_count} (Δ#{delta}, #{row.status})")
|
|
140
|
+
end
|
|
141
|
+
@err.puts("rigor: run `rigor baseline regenerate` to refresh the baseline.")
|
|
98
142
|
end
|
|
99
143
|
|
|
100
144
|
# ADR-22 — apply the baseline filter as the LAST step of
|
|
@@ -234,7 +278,10 @@ module Rigor
|
|
|
234
278
|
# to `.rigor.yml`'s `baseline:` key"; a String overrides
|
|
235
279
|
# the config; `false` (from `--no-baseline`) suppresses
|
|
236
280
|
# any baseline that the config might name.
|
|
237
|
-
baseline: :unset
|
|
281
|
+
baseline: :unset,
|
|
282
|
+
# ADR-22 slice 5 — `--baseline-strict` CI gate: fail the
|
|
283
|
+
# run on any baseline drift, in either direction.
|
|
284
|
+
baseline_strict: false
|
|
238
285
|
}
|
|
239
286
|
parser = OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
|
|
240
287
|
opts.banner = "Usage: rigor check [options] [paths]"
|
|
@@ -268,6 +315,10 @@ module Rigor
|
|
|
268
315
|
"ADR-22: ignore any configured baseline for this run") do
|
|
269
316
|
options[:baseline] = false
|
|
270
317
|
end
|
|
318
|
+
opts.on("--baseline-strict",
|
|
319
|
+
"ADR-22: fail the run on any baseline drift (CI gate)") do
|
|
320
|
+
options[:baseline_strict] = true
|
|
321
|
+
end
|
|
271
322
|
end
|
|
272
323
|
parser.parse!(@argv)
|
|
273
324
|
options
|
|
@@ -423,6 +474,12 @@ module Rigor
|
|
|
423
474
|
YAML
|
|
424
475
|
end
|
|
425
476
|
|
|
477
|
+
def run_annotate
|
|
478
|
+
require_relative "cli/annotate_command"
|
|
479
|
+
|
|
480
|
+
AnnotateCommand.new(argv: @argv, out: @out, err: @err).run
|
|
481
|
+
end
|
|
482
|
+
|
|
426
483
|
def run_type_of
|
|
427
484
|
require_relative "cli/type_of_command"
|
|
428
485
|
|
|
@@ -506,6 +563,7 @@ module Rigor
|
|
|
506
563
|
Commands:
|
|
507
564
|
check Analyze Ruby source files
|
|
508
565
|
init Create a starter .rigor.yml
|
|
566
|
+
annotate Print FILE with each line's last-expression type
|
|
509
567
|
type-of Print the inferred type at FILE:LINE:COL
|
|
510
568
|
type-scan Report Scope#type_of coverage across PATHs
|
|
511
569
|
explain Print the description of one or all CheckRules
|
data/lib/rigor/environment.rb
CHANGED
|
@@ -252,7 +252,15 @@ module Rigor
|
|
|
252
252
|
project_root: root,
|
|
253
253
|
auto_detect: rbs_collection_auto_detect
|
|
254
254
|
).map(&:to_s)
|
|
255
|
-
|
|
255
|
+
# ADR-25 — RBS signature directories contributed by loaded
|
|
256
|
+
# plugins via their manifest `signature_paths:`. Resolved
|
|
257
|
+
# to absolute dirs by `Plugin::Base#signature_paths`;
|
|
258
|
+
# additive, ranked below the user's explicit
|
|
259
|
+
# `signature_paths:` and above the opportunistic bundle /
|
|
260
|
+
# collection discovery. A duplicate-declaration conflict
|
|
261
|
+
# degrades through the same O7 failure-memo path.
|
|
262
|
+
plugin_sig_paths = plugin_registry ? plugin_registry.signature_paths.map(&:to_s) : []
|
|
263
|
+
loader_signature_paths = resolved_paths + plugin_sig_paths + gem_sig_paths + collection_paths
|
|
256
264
|
merged_libraries = (DEFAULT_LIBRARIES + libraries.map(&:to_s)).uniq
|
|
257
265
|
loader = RbsLoader.new(
|
|
258
266
|
libraries: merged_libraries,
|
|
@@ -57,7 +57,8 @@ module Rigor
|
|
|
57
57
|
return nil unless klass
|
|
58
58
|
|
|
59
59
|
bucket_key = kind == :singleton ? "singleton_methods" : "instance_methods"
|
|
60
|
-
klass.dig(bucket_key, selector.to_s)
|
|
60
|
+
klass.dig(bucket_key, selector.to_s) ||
|
|
61
|
+
resolve_alias_entry(klass, selector, bucket_key)
|
|
61
62
|
end
|
|
62
63
|
|
|
63
64
|
def reset!
|
|
@@ -66,6 +67,21 @@ module Rigor
|
|
|
66
67
|
|
|
67
68
|
private
|
|
68
69
|
|
|
70
|
+
def resolve_alias_entry(klass, selector, bucket_key)
|
|
71
|
+
return nil unless bucket_key == "instance_methods"
|
|
72
|
+
|
|
73
|
+
aliases = klass["aliases"]
|
|
74
|
+
return nil unless aliases
|
|
75
|
+
|
|
76
|
+
alias_entry = aliases[selector.to_s]
|
|
77
|
+
return nil unless alias_entry
|
|
78
|
+
|
|
79
|
+
target = alias_entry["old"]
|
|
80
|
+
return nil unless target
|
|
81
|
+
|
|
82
|
+
klass.dig(bucket_key, target)
|
|
83
|
+
end
|
|
84
|
+
|
|
69
85
|
def blocked?(class_name, selector)
|
|
70
86
|
# Bang-suffixed selectors are mutating by Ruby convention
|
|
71
87
|
# (`upcase!`, `concat`, etc. are listed explicitly below;
|
|
@@ -55,7 +55,16 @@ module Rigor
|
|
|
55
55
|
# as `time_localtime`: `time_modify(time)` then a
|
|
56
56
|
# `time_set_vtm` write and `TZMODE_SET_UTC`. Both
|
|
57
57
|
# selectors share the cfunc, so both must be blocked.
|
|
58
|
-
:gmtime, :utc
|
|
58
|
+
:gmtime, :utc,
|
|
59
|
+
# `getlocal` is not a mutator — it returns a fresh Time —
|
|
60
|
+
# but the fresh Time is pinned to the *analysis machine's*
|
|
61
|
+
# timezone. Folding it through a `Constant[Time]` carrier
|
|
62
|
+
# (which only ever holds a UTC literal from `Time.utc`)
|
|
63
|
+
# would bake a host-dependent wall clock / `utc_offset`
|
|
64
|
+
# into the inferred type. Blocked so the fold stays
|
|
65
|
+
# machine-independent; the RBS tier answers `Nominal[Time]`.
|
|
66
|
+
# `getutc` / `getgm` stay foldable — their result is UTC.
|
|
67
|
+
:getlocal
|
|
59
68
|
]
|
|
60
69
|
}
|
|
61
70
|
)
|