rigortype 0.1.8 → 0.1.10
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/analysis/runner.rb +67 -9
- data/lib/rigor/analysis/worker_session.rb +13 -4
- data/lib/rigor/cache/rbs_descriptor.rb +21 -2
- data/lib/rigor/cache/rbs_environment.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +274 -0
- data/lib/rigor/cli/baseline_command.rb +36 -16
- data/lib/rigor/cli/coverage_command.rb +126 -0
- data/lib/rigor/cli/coverage_renderer.rb +162 -0
- data/lib/rigor/cli/coverage_report.rb +75 -0
- data/lib/rigor/cli/mcp_command.rb +70 -0
- data/lib/rigor/cli/prism_colorizer.rb +111 -0
- data/lib/rigor/cli.rb +134 -6
- data/lib/rigor/environment/rbs_loader.rb +46 -5
- data/lib/rigor/environment/reporters.rb +3 -2
- data/lib/rigor/environment.rb +168 -5
- data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
- data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
- data/lib/rigor/inference/def_return_typer.rb +98 -0
- data/lib/rigor/inference/expression_typer.rb +308 -18
- data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +178 -10
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
- 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 +431 -9
- 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/precision_scanner.rb +131 -0
- data/lib/rigor/inference/statement_evaluator.rb +29 -3
- data/lib/rigor/mcp/loop.rb +43 -0
- data/lib/rigor/mcp/server.rb +263 -0
- data/lib/rigor/mcp.rb +16 -0
- data/lib/rigor/plugin/base.rb +67 -5
- data/lib/rigor/plugin/loader.rb +22 -1
- data/lib/rigor/plugin/manifest.rb +101 -10
- data/lib/rigor/plugin/protocol_contract.rb +185 -0
- data/lib/rigor/plugin/registry.rb +87 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
- data/lib/rigor/sig_gen/generator.rb +150 -75
- data/lib/rigor/triage/catalogue.rb +2 -2
- data/lib/rigor/type/combinator.rb +57 -0
- data/lib/rigor/type/constant.rb +29 -2
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/analysis/baseline.rbs +39 -0
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor/type.rbs +4 -0
- data/sig/rigor.rbs +2 -0
- metadata +42 -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,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optionparser"
|
|
4
|
+
require "prism"
|
|
5
|
+
|
|
6
|
+
require_relative "../configuration"
|
|
7
|
+
require_relative "../environment"
|
|
8
|
+
require_relative "../inference/precision_scanner"
|
|
9
|
+
require_relative "../scope"
|
|
10
|
+
require_relative "coverage_report"
|
|
11
|
+
require_relative "coverage_renderer"
|
|
12
|
+
|
|
13
|
+
module Rigor
|
|
14
|
+
class CLI
|
|
15
|
+
# Executes the `rigor coverage` command.
|
|
16
|
+
#
|
|
17
|
+
# Walks every Prism node in one or more files, infers its type via
|
|
18
|
+
# `Rigor::Scope#type_of`, and classifies the result into precision tiers
|
|
19
|
+
# (constant / nominal / shaped / refined / bot / dynamic_specific /
|
|
20
|
+
# dynamic_top / top). Reports aggregate and per-file statistics so
|
|
21
|
+
# maintainers can track type-precision trends and SKILL pipelines can
|
|
22
|
+
# measure the impact of adding new constant-fold or shape-dispatch rules.
|
|
23
|
+
#
|
|
24
|
+
# Exit codes:
|
|
25
|
+
# 0 — scan complete, precision ratio ≥ threshold (or no threshold given)
|
|
26
|
+
# 1 — precision ratio < threshold, or parse errors encountered
|
|
27
|
+
# 64 — usage error
|
|
28
|
+
class CoverageCommand
|
|
29
|
+
USAGE = "Usage: rigor coverage [options] PATH..."
|
|
30
|
+
|
|
31
|
+
def initialize(argv:, out:, err:)
|
|
32
|
+
@argv = argv
|
|
33
|
+
@out = out
|
|
34
|
+
@err = err
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Integer] CLI exit status.
|
|
38
|
+
def run
|
|
39
|
+
options = parse_options
|
|
40
|
+
paths = collect_paths(@argv)
|
|
41
|
+
return CLI::EXIT_USAGE if paths.nil?
|
|
42
|
+
return usage_error if paths.empty?
|
|
43
|
+
|
|
44
|
+
report = scan_paths(paths, options)
|
|
45
|
+
CoverageRenderer.new(out: @out).render(report, format: options.fetch(:format))
|
|
46
|
+
determine_exit(report, options)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def parse_options
|
|
52
|
+
options = { format: "text", threshold: nil, config: nil }
|
|
53
|
+
|
|
54
|
+
OptionParser.new do |opts|
|
|
55
|
+
opts.banner = USAGE
|
|
56
|
+
opts.on("--format=FORMAT", "Output format: text or json") { |v| options[:format] = v }
|
|
57
|
+
opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
|
|
58
|
+
opts.on(
|
|
59
|
+
"--threshold=RATIO", Float,
|
|
60
|
+
"Exit 1 when precision ratio is below RATIO (0.0–1.0)"
|
|
61
|
+
) { |v| options[:threshold] = v }
|
|
62
|
+
end.parse!(@argv)
|
|
63
|
+
|
|
64
|
+
options
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def collect_paths(args)
|
|
68
|
+
paths = []
|
|
69
|
+
args.each do |arg|
|
|
70
|
+
if File.directory?(arg)
|
|
71
|
+
paths.concat(Dir.glob(File.join(arg, "**/*.rb")))
|
|
72
|
+
elsif File.file?(arg)
|
|
73
|
+
paths << arg
|
|
74
|
+
else
|
|
75
|
+
@err.puts("coverage: not a file or directory: #{arg}")
|
|
76
|
+
return nil
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
paths.uniq
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def usage_error
|
|
83
|
+
@err.puts("coverage: at least one path is required")
|
|
84
|
+
@err.puts(USAGE)
|
|
85
|
+
CLI::EXIT_USAGE
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def scan_paths(paths, options)
|
|
89
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
90
|
+
scope = Scope.empty(environment: project_environment(configuration))
|
|
91
|
+
scanner = Inference::PrecisionScanner.new(scope: scope)
|
|
92
|
+
accumulator = CoverageAccumulator.new
|
|
93
|
+
|
|
94
|
+
paths.each { |path| scan_one(path, scanner, accumulator, configuration) }
|
|
95
|
+
accumulator.to_report(paths, options)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def project_environment(configuration)
|
|
99
|
+
Environment.for_project(
|
|
100
|
+
libraries: configuration.libraries,
|
|
101
|
+
signature_paths: configuration.signature_paths
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def scan_one(path, scanner, accumulator, configuration)
|
|
106
|
+
source = File.read(path)
|
|
107
|
+
parse_result = Prism.parse(source, filepath: path, version: configuration.target_ruby)
|
|
108
|
+
if parse_result.errors.any?
|
|
109
|
+
accumulator.record_parse_error(path, parse_result.errors)
|
|
110
|
+
return
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
accumulator.absorb(path, scanner.scan(parse_result.value))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def determine_exit(report, options)
|
|
117
|
+
return 1 unless report.parse_errors.empty?
|
|
118
|
+
|
|
119
|
+
threshold = options[:threshold]
|
|
120
|
+
return 0 if threshold.nil?
|
|
121
|
+
|
|
122
|
+
report.precision_ratio < threshold ? 1 : 0
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
class CLI
|
|
7
|
+
# Renders a `CoverageReport` as terminal-friendly text or JSON.
|
|
8
|
+
class CoverageRenderer
|
|
9
|
+
TIER_LABELS = {
|
|
10
|
+
constant: "constant",
|
|
11
|
+
nominal: "nominal",
|
|
12
|
+
shaped: "shaped (Tuple/Hash/Range/generic)",
|
|
13
|
+
refined: "refined",
|
|
14
|
+
bot: "bot (unreachable)",
|
|
15
|
+
dynamic_specific: "dynamic — partial info",
|
|
16
|
+
dynamic_top: "dynamic — opaque (untyped)",
|
|
17
|
+
top: "top"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def initialize(out:)
|
|
21
|
+
@out = out
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def render(report, format:)
|
|
25
|
+
case format
|
|
26
|
+
when "text" then render_text(report)
|
|
27
|
+
when "json" then render_json(report)
|
|
28
|
+
else raise OptionParser::InvalidArgument, "unsupported format: #{format}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def render_text(report)
|
|
35
|
+
render_text_header(report)
|
|
36
|
+
render_text_summary(report)
|
|
37
|
+
render_text_tier_table(report)
|
|
38
|
+
render_text_per_file(report) if report.per_file.size > 1
|
|
39
|
+
render_text_parse_errors(report)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def render_text_header(report)
|
|
43
|
+
n = report.files.size
|
|
44
|
+
suffix = n == 1 ? "" : "s"
|
|
45
|
+
@out.puts("Type coverage: #{n} file#{suffix}")
|
|
46
|
+
report.files.first(5).each { |f| @out.puts(" - #{f}") }
|
|
47
|
+
@out.puts(" ... (#{n - 5} more)") if n > 5
|
|
48
|
+
@out.puts
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def render_text_summary(report)
|
|
52
|
+
g = report.grand_total
|
|
53
|
+
p = report.precise_count
|
|
54
|
+
o = report.opaque_count
|
|
55
|
+
@out.puts("Summary:")
|
|
56
|
+
@out.puts(" files processed: #{report.files.size - report.parse_errors.size}")
|
|
57
|
+
@out.puts(" parse errors: #{report.parse_errors.size}")
|
|
58
|
+
@out.puts(" expressions typed: #{g}")
|
|
59
|
+
@out.puts(" precise: #{p}#{pct(p, g)}")
|
|
60
|
+
@out.puts(" dynamic (opaque): #{o}#{pct(o, g)}")
|
|
61
|
+
@out.puts(" precision ratio: #{(report.precision_ratio * 100).round(2)}%")
|
|
62
|
+
@out.puts
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def render_text_tier_table(report)
|
|
66
|
+
@out.puts("Tier breakdown:")
|
|
67
|
+
g = report.grand_total
|
|
68
|
+
Inference::PrecisionScanner::TIERS.each do |tier|
|
|
69
|
+
n = report.tier_count(tier)
|
|
70
|
+
next if n.zero?
|
|
71
|
+
|
|
72
|
+
label = TIER_LABELS.fetch(tier, tier.to_s).ljust(36)
|
|
73
|
+
@out.puts(" #{label} #{n.to_s.rjust(7)}#{pct(n, g)}")
|
|
74
|
+
end
|
|
75
|
+
@out.puts
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def render_text_per_file(report)
|
|
79
|
+
@out.puts("Per-file breakdown:")
|
|
80
|
+
width = report.per_file.map { |e| e[:file].size }.max || 0
|
|
81
|
+
report.per_file.sort_by { |e| e[:result].precision_ratio }.each do |entry|
|
|
82
|
+
r = entry[:result]
|
|
83
|
+
next if r.total.zero?
|
|
84
|
+
|
|
85
|
+
ratio_str = "#{(r.precision_ratio * 100).round(1)}%".rjust(6)
|
|
86
|
+
@out.puts(" #{entry[:file].ljust(width)} #{ratio_str} (#{r.precise_count}/#{r.total})")
|
|
87
|
+
end
|
|
88
|
+
@out.puts
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def render_text_parse_errors(report)
|
|
92
|
+
return if report.parse_errors.empty?
|
|
93
|
+
|
|
94
|
+
@out.puts("Parse errors:")
|
|
95
|
+
report.parse_errors.each do |entry|
|
|
96
|
+
@out.puts(" #{entry[:file]}: #{entry[:errors].join('; ')}")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def render_json(report)
|
|
101
|
+
@out.puts(JSON.pretty_generate(json_payload(report)))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def json_payload(report)
|
|
105
|
+
g = report.grand_total
|
|
106
|
+
{
|
|
107
|
+
summary: json_summary(report, g),
|
|
108
|
+
by_tier: tier_payload(g) { |tier| report.tier_count(tier) },
|
|
109
|
+
by_file: report.per_file.map { |e| file_payload(e) },
|
|
110
|
+
parse_errors: report.parse_errors.map { |e| { file: e[:file], errors: e[:errors] } }
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def json_summary(report, grand_total)
|
|
115
|
+
g = grand_total
|
|
116
|
+
dsc = report.total.dynamic_specific_count
|
|
117
|
+
{
|
|
118
|
+
files_processed: report.files.size - report.parse_errors.size,
|
|
119
|
+
parse_errors: report.parse_errors.size,
|
|
120
|
+
expressions_typed: g,
|
|
121
|
+
precise_count: report.precise_count,
|
|
122
|
+
precise_ratio: ratio_f(report.precision_ratio),
|
|
123
|
+
dynamic_opaque_count: report.opaque_count,
|
|
124
|
+
dynamic_opaque_ratio: ratio_f(report.opaque_ratio),
|
|
125
|
+
dynamic_specific_count: dsc,
|
|
126
|
+
dynamic_specific_ratio: ratio_f(dsc.fdiv(g.nonzero? || 1))
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def tier_payload(grand_total)
|
|
131
|
+
g = grand_total
|
|
132
|
+
Inference::PrecisionScanner::TIERS.to_h do |tier|
|
|
133
|
+
n = yield tier
|
|
134
|
+
[tier, { count: n, ratio: ratio_f(n.fdiv(g.nonzero? || 1)) }]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def file_payload(entry)
|
|
139
|
+
r = entry[:result]
|
|
140
|
+
{
|
|
141
|
+
file: entry[:file],
|
|
142
|
+
expressions_typed: r.total,
|
|
143
|
+
precise_count: r.precise_count,
|
|
144
|
+
precise_ratio: ratio_f(r.precision_ratio),
|
|
145
|
+
dynamic_opaque_count: r.opaque_count,
|
|
146
|
+
dynamic_opaque_ratio: ratio_f(r.opaque_ratio),
|
|
147
|
+
by_tier: tier_payload(r.total) { |tier| r.tier_counts.fetch(tier, 0) }
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def pct(numerator, denominator)
|
|
152
|
+
return "" if denominator.zero?
|
|
153
|
+
|
|
154
|
+
" (#{(numerator.fdiv(denominator) * 100).round(1)}%)"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def ratio_f(val)
|
|
158
|
+
val.round(4)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
class CLI
|
|
5
|
+
# Aggregated precision-coverage report assembled by `CoverageCommand`.
|
|
6
|
+
# Holds per-file breakdowns and accumulated totals; consumed by
|
|
7
|
+
# `CoverageRenderer` for text and JSON output.
|
|
8
|
+
class CoverageReport < Data.define(
|
|
9
|
+
:files,
|
|
10
|
+
:parse_errors,
|
|
11
|
+
:per_file,
|
|
12
|
+
:total
|
|
13
|
+
)
|
|
14
|
+
# Sum of all per-file totals.
|
|
15
|
+
def grand_total
|
|
16
|
+
total.total
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def precise_count
|
|
20
|
+
total.precise_count
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def opaque_count
|
|
24
|
+
total.opaque_count
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def precision_ratio
|
|
28
|
+
total.precision_ratio
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def opaque_ratio
|
|
32
|
+
total.opaque_ratio
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def tier_count(tier)
|
|
36
|
+
total.tier_counts.fetch(tier, 0)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Mutable accumulator used while scanning files.
|
|
41
|
+
class CoverageAccumulator
|
|
42
|
+
require_relative "../inference/precision_scanner"
|
|
43
|
+
|
|
44
|
+
def initialize
|
|
45
|
+
@per_file = []
|
|
46
|
+
@parse_errors = []
|
|
47
|
+
# Accumulated totals across all files.
|
|
48
|
+
@total_total = 0
|
|
49
|
+
@total_tier_counts = Inference::PrecisionScanner::TIERS.to_h { |t| [t, 0] }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def absorb(path, file_result)
|
|
53
|
+
@per_file << { file: path, result: file_result }
|
|
54
|
+
@total_total += file_result.total
|
|
55
|
+
file_result.tier_counts.each { |tier, n| @total_tier_counts[tier] += n }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def record_parse_error(path, errors)
|
|
59
|
+
@parse_errors << { file: path, errors: errors.map(&:message) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def to_report(files, _options)
|
|
63
|
+
CoverageReport.new(
|
|
64
|
+
files: files,
|
|
65
|
+
parse_errors: @parse_errors,
|
|
66
|
+
per_file: @per_file,
|
|
67
|
+
total: Inference::PrecisionScanner::FileResult.new(
|
|
68
|
+
total: @total_total,
|
|
69
|
+
tier_counts: @total_tier_counts
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optionparser"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
class CLI
|
|
7
|
+
# Executes the `rigor mcp` command.
|
|
8
|
+
#
|
|
9
|
+
# Starts a long-running MCP (Model Context Protocol) server over stdio.
|
|
10
|
+
# The server exposes Rigor's analysis tools as MCP tool calls over a
|
|
11
|
+
# newline-delimited JSON-RPC 2.0 stream. See ADR-33.
|
|
12
|
+
#
|
|
13
|
+
# Slice 1 ships the stdio transport with seven read-only tools:
|
|
14
|
+
# rigor_check, rigor_type_of, rigor_triage, rigor_annotate,
|
|
15
|
+
# rigor_sig_gen, rigor_explain, rigor_coverage.
|
|
16
|
+
class McpCommand
|
|
17
|
+
USAGE = "Usage: rigor mcp [options]"
|
|
18
|
+
|
|
19
|
+
def initialize(argv:, out:, err:)
|
|
20
|
+
@argv = argv
|
|
21
|
+
@out = out
|
|
22
|
+
@err = err
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Integer] CLI exit status.
|
|
26
|
+
def run
|
|
27
|
+
options = parse_options
|
|
28
|
+
return CLI::EXIT_USAGE if options == :usage_error
|
|
29
|
+
|
|
30
|
+
transport = options.fetch(:transport)
|
|
31
|
+
unless transport == "stdio"
|
|
32
|
+
@err.puts("rigor mcp: unsupported transport: #{transport.inspect} (only `stdio` is supported in v1)")
|
|
33
|
+
return CLI::EXIT_USAGE
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
require_relative "../mcp"
|
|
37
|
+
require_relative "../version"
|
|
38
|
+
|
|
39
|
+
server = MCP::Server.new(config_path: options.fetch(:config), err: $stderr)
|
|
40
|
+
loop_runner = MCP::Loop.new(input: $stdin, output: $stdout, server: server)
|
|
41
|
+
loop_runner.run
|
|
42
|
+
0
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def parse_options
|
|
48
|
+
options = { transport: "stdio", config: nil }
|
|
49
|
+
|
|
50
|
+
parser = OptionParser.new do |opts|
|
|
51
|
+
opts.banner = USAGE
|
|
52
|
+
opts.on("--transport=NAME",
|
|
53
|
+
"Transport (default: stdio; only stdio is supported in v1)") do |value|
|
|
54
|
+
options[:transport] = value
|
|
55
|
+
end
|
|
56
|
+
opts.on("--config=PATH",
|
|
57
|
+
"Session-level default config path (individual tool calls may override)") do |value|
|
|
58
|
+
options[:config] = value
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
parser.parse!(@argv)
|
|
62
|
+
options
|
|
63
|
+
rescue OptionParser::ParseError => e
|
|
64
|
+
@err.puts(e.message)
|
|
65
|
+
@err.puts(USAGE)
|
|
66
|
+
:usage_error
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -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
|