rigortype 0.1.7 → 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 +23 -1
- data/lib/rigor/analysis/diagnostic.rb +17 -3
- data/lib/rigor/analysis/runner.rb +178 -3
- data/lib/rigor/analysis/worker_session.rb +14 -3
- 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/triage_command.rb +83 -0
- data/lib/rigor/cli/triage_renderer.rb +77 -0
- data/lib/rigor/cli.rb +71 -5
- 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 +300 -18
- 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/rbs_dispatch.rb +33 -8
- 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 +179 -4
- data/lib/rigor/inference/method_parameter_binder.rb +67 -10
- data/lib/rigor/inference/narrowing.rb +29 -10
- data/lib/rigor/inference/scope_indexer.rb +156 -6
- data/lib/rigor/inference/statement_evaluator.rb +43 -21
- 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/scope.rb +46 -0
- data/lib/rigor/triage/catalogue.rb +296 -0
- data/lib/rigor/triage/hint.rb +27 -0
- data/lib/rigor/triage.rb +89 -0
- data/lib/rigor/type/constant.rb +29 -2
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/inference.rbs +1 -0
- data/sig/rigor/scope.rbs +6 -0
- metadata +16 -1
|
@@ -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
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optionparser"
|
|
4
|
+
|
|
5
|
+
require_relative "../configuration"
|
|
6
|
+
require_relative "../analysis/runner"
|
|
7
|
+
require_relative "../cache/store"
|
|
8
|
+
require_relative "../triage"
|
|
9
|
+
require_relative "triage_renderer"
|
|
10
|
+
|
|
11
|
+
module Rigor
|
|
12
|
+
class CLI
|
|
13
|
+
# ADR-23 — executes `rigor triage`.
|
|
14
|
+
#
|
|
15
|
+
# Runs the same analysis as `rigor check`, then summarises the
|
|
16
|
+
# diagnostic stream (rule distribution, per-file hotspots,
|
|
17
|
+
# heuristic hints) instead of printing the raw per-line list.
|
|
18
|
+
# Read-only and advisory (WD4): never edits config, never
|
|
19
|
+
# writes a baseline. Always exits 0 — it is an inspection
|
|
20
|
+
# command, not a gate (`rigor check` remains the gate).
|
|
21
|
+
class TriageCommand
|
|
22
|
+
USAGE = "Usage: rigor triage [options] [paths]"
|
|
23
|
+
DEFAULT_SECTIONS = %i[distribution hotspots hints].freeze
|
|
24
|
+
|
|
25
|
+
def initialize(argv:, out:, err:)
|
|
26
|
+
@argv = argv
|
|
27
|
+
@out = out
|
|
28
|
+
@err = err
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Integer] CLI exit status (always 0).
|
|
32
|
+
def run
|
|
33
|
+
options = parse_options
|
|
34
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
35
|
+
diagnostics = analyze(configuration)
|
|
36
|
+
|
|
37
|
+
report = Triage.analyze(diagnostics, top: options.fetch(:top),
|
|
38
|
+
hints: options.fetch(:sections).include?(:hints))
|
|
39
|
+
renderer = TriageRenderer.new(report, sections: options.fetch(:sections))
|
|
40
|
+
@out.puts(options.fetch(:format) == "json" ? renderer.json : renderer.text)
|
|
41
|
+
0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def parse_options
|
|
47
|
+
options = { config: nil, format: "text", top: 10, sections: DEFAULT_SECTIONS }
|
|
48
|
+
OptionParser.new do |opts|
|
|
49
|
+
opts.banner = USAGE
|
|
50
|
+
opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
|
|
51
|
+
opts.on("--format=FORMAT", "Output format: text (default) or json") { |v| options[:format] = v }
|
|
52
|
+
opts.on("--top=N", Integer, "Hotspot-file count (default 10)") { |v| options[:top] = v }
|
|
53
|
+
opts.on("--hints-only", "Print only the heuristic-hints section") { options[:sections] = %i[hints] }
|
|
54
|
+
opts.on("--no-hints", "Print distribution + hotspots only") do
|
|
55
|
+
options[:sections] = %i[distribution hotspots]
|
|
56
|
+
end
|
|
57
|
+
end.parse!(@argv)
|
|
58
|
+
validate!(options)
|
|
59
|
+
options
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate!(options)
|
|
63
|
+
return if %w[text json].include?(options.fetch(:format))
|
|
64
|
+
|
|
65
|
+
raise OptionParser::InvalidArgument, "unsupported format: #{options.fetch(:format)}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Sequential, cache-backed, no run-stats: triage only needs
|
|
69
|
+
# the diagnostic stream, and sequential keeps the rule
|
|
70
|
+
# distribution deterministic (the fork pool's cross-file
|
|
71
|
+
# divergence would skew the histogram).
|
|
72
|
+
def analyze(configuration)
|
|
73
|
+
runner = Analysis::Runner.new(
|
|
74
|
+
configuration: configuration,
|
|
75
|
+
cache_store: Cache::Store.new(root: configuration.cache_path),
|
|
76
|
+
collect_stats: false,
|
|
77
|
+
workers: 0
|
|
78
|
+
)
|
|
79
|
+
runner.run(@argv.empty? ? configuration.paths : @argv).diagnostics
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "../triage"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
class CLI
|
|
9
|
+
# ADR-23 — renders a {Rigor::Triage::Report} as the `rigor
|
|
10
|
+
# triage` text report or as `--format json`.
|
|
11
|
+
class TriageRenderer
|
|
12
|
+
BAR_WIDTH = 24
|
|
13
|
+
|
|
14
|
+
def initialize(report, sections:)
|
|
15
|
+
@report = report
|
|
16
|
+
@sections = sections # subset of %i[distribution hotspots hints]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def json
|
|
20
|
+
JSON.pretty_generate(Triage.report_to_h(@report))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def text
|
|
24
|
+
blocks = []
|
|
25
|
+
blocks << distribution_block if @sections.include?(:distribution)
|
|
26
|
+
blocks << hotspots_block if @sections.include?(:hotspots)
|
|
27
|
+
blocks << hints_block if @sections.include?(:hints)
|
|
28
|
+
"#{blocks.join("\n\n")}\n"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def distribution_block
|
|
34
|
+
s = @report.summary
|
|
35
|
+
max = @report.distribution.map(&:count).max || 1
|
|
36
|
+
lines = ["Diagnostic distribution — #{s.total} total " \
|
|
37
|
+
"(#{s.error} error / #{s.warning} warning#{" / #{s.info} info" if s.info.positive?})"]
|
|
38
|
+
@report.distribution.each do |row|
|
|
39
|
+
lines << format(" %<rule>-32s %<count>5d %<bar>s",
|
|
40
|
+
rule: row.rule, count: row.count, bar: bar(row.count, max))
|
|
41
|
+
end
|
|
42
|
+
lines.join("\n")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def hotspots_block
|
|
46
|
+
return "Hotspot files\n (none)" if @report.hotspots.empty?
|
|
47
|
+
|
|
48
|
+
lines = ["Hotspot files"]
|
|
49
|
+
@report.hotspots.each do |spot|
|
|
50
|
+
by_rule = spot.by_rule.map { |rule, count| "#{rule}×#{count}" }.join(" ")
|
|
51
|
+
lines << format(" %<file>-40s %<count>4d %<rules>s",
|
|
52
|
+
file: spot.file, count: spot.count, rules: by_rule)
|
|
53
|
+
end
|
|
54
|
+
lines.join("\n")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def hints_block
|
|
58
|
+
return "Hints\n (no heuristic hints)" if @report.hints.empty?
|
|
59
|
+
|
|
60
|
+
lines = ["Hints — heuristics, verify before acting"]
|
|
61
|
+
@report.hints.each do |hint|
|
|
62
|
+
lines << ""
|
|
63
|
+
lines << " [#{hint.confidence} #{hint.id}] #{hint.diagnostic_count} diagnostic(s)"
|
|
64
|
+
lines << " #{hint.summary}"
|
|
65
|
+
lines << " → #{hint.action}"
|
|
66
|
+
end
|
|
67
|
+
lines.join("\n")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def bar(count, max)
|
|
71
|
+
filled = max.zero? ? 0 : (count * BAR_WIDTH / max)
|
|
72
|
+
filled = 1 if filled.zero? && count.positive?
|
|
73
|
+
"█" * filled
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/rigor/cli.rb
CHANGED
|
@@ -21,13 +21,15 @@ 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,
|
|
27
28
|
"diff" => :run_diff,
|
|
28
29
|
"sig-gen" => :run_sig_gen,
|
|
29
30
|
"lsp" => :run_lsp,
|
|
30
|
-
"baseline" => :run_baseline
|
|
31
|
+
"baseline" => :run_baseline,
|
|
32
|
+
"triage" => :run_triage
|
|
31
33
|
}.freeze
|
|
32
34
|
|
|
33
35
|
def self.start(argv = ARGV, out: $stdout, err: $stderr)
|
|
@@ -87,13 +89,56 @@ module Rigor
|
|
|
87
89
|
configuration: configuration, options: options,
|
|
88
90
|
buffer: buffer, cache_root: cache_root
|
|
89
91
|
)
|
|
90
|
-
|
|
91
|
-
result = apply_baseline_filter(
|
|
92
|
+
raw_result = runner.run(@argv.empty? ? configuration.paths : @argv)
|
|
93
|
+
result = apply_baseline_filter(raw_result, configuration, options)
|
|
92
94
|
|
|
93
95
|
write_result(result, options.fetch(:format))
|
|
94
96
|
write_run_stats(result.stats) if result.stats
|
|
95
97
|
write_cache_stats(cache_root, runner.cache_store) if options.fetch(:cache_stats)
|
|
96
|
-
|
|
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.")
|
|
97
142
|
end
|
|
98
143
|
|
|
99
144
|
# ADR-22 — apply the baseline filter as the LAST step of
|
|
@@ -233,7 +278,10 @@ module Rigor
|
|
|
233
278
|
# to `.rigor.yml`'s `baseline:` key"; a String overrides
|
|
234
279
|
# the config; `false` (from `--no-baseline`) suppresses
|
|
235
280
|
# any baseline that the config might name.
|
|
236
|
-
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
|
|
237
285
|
}
|
|
238
286
|
parser = OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
|
|
239
287
|
opts.banner = "Usage: rigor check [options] [paths]"
|
|
@@ -267,6 +315,10 @@ module Rigor
|
|
|
267
315
|
"ADR-22: ignore any configured baseline for this run") do
|
|
268
316
|
options[:baseline] = false
|
|
269
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
|
|
270
322
|
end
|
|
271
323
|
parser.parse!(@argv)
|
|
272
324
|
options
|
|
@@ -422,6 +474,12 @@ module Rigor
|
|
|
422
474
|
YAML
|
|
423
475
|
end
|
|
424
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
|
+
|
|
425
483
|
def run_type_of
|
|
426
484
|
require_relative "cli/type_of_command"
|
|
427
485
|
|
|
@@ -464,6 +522,12 @@ module Rigor
|
|
|
464
522
|
BaselineCommand.new(argv: @argv, out: @out, err: @err).run
|
|
465
523
|
end
|
|
466
524
|
|
|
525
|
+
def run_triage
|
|
526
|
+
require_relative "cli/triage_command"
|
|
527
|
+
|
|
528
|
+
CLI::TriageCommand.new(argv: @argv, out: @out, err: @err).run
|
|
529
|
+
end
|
|
530
|
+
|
|
467
531
|
def write_result(result, format)
|
|
468
532
|
case format
|
|
469
533
|
when "json"
|
|
@@ -499,12 +563,14 @@ module Rigor
|
|
|
499
563
|
Commands:
|
|
500
564
|
check Analyze Ruby source files
|
|
501
565
|
init Create a starter .rigor.yml
|
|
566
|
+
annotate Print FILE with each line's last-expression type
|
|
502
567
|
type-of Print the inferred type at FILE:LINE:COL
|
|
503
568
|
type-scan Report Scope#type_of coverage across PATHs
|
|
504
569
|
explain Print the description of one or all CheckRules
|
|
505
570
|
diff Compare current diagnostics to a saved baseline JSON
|
|
506
571
|
sig-gen Emit RBS skeletons inferred from .rb sources (ADR-14)
|
|
507
572
|
lsp Run the Rigor Language Server (LSP) over stdio
|
|
573
|
+
triage Summarise diagnostics: distribution, hotspots, hints (ADR-23)
|
|
508
574
|
version Print the Rigor version
|
|
509
575
|
help Print this help
|
|
510
576
|
HELP
|
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
|
)
|