rigortype 0.1.9 → 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 +1 -1
- 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 +57 -7
- 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.rb +73 -3
- data/lib/rigor/environment/rbs_loader.rb +46 -5
- data/lib/rigor/environment/reporters.rb +3 -2
- data/lib/rigor/environment.rb +159 -4
- data/lib/rigor/inference/def_return_typer.rb +98 -0
- data/lib/rigor/inference/expression_typer.rb +143 -12
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
- data/lib/rigor/inference/precision_scanner.rb +131 -0
- data/lib/rigor/inference/statement_evaluator.rb +26 -2
- 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 +28 -5
- data/lib/rigor/plugin/manifest.rb +33 -5
- data/lib/rigor/plugin/registry.rb +21 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
- data/lib/rigor/sig_gen/generator.rb +150 -75
- data/lib/rigor/type/combinator.rb +57 -0
- 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 +32 -1
|
@@ -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
|
data/lib/rigor/cli.rb
CHANGED
|
@@ -28,8 +28,10 @@ module Rigor
|
|
|
28
28
|
"diff" => :run_diff,
|
|
29
29
|
"sig-gen" => :run_sig_gen,
|
|
30
30
|
"lsp" => :run_lsp,
|
|
31
|
+
"mcp" => :run_mcp,
|
|
31
32
|
"baseline" => :run_baseline,
|
|
32
|
-
"triage" => :run_triage
|
|
33
|
+
"triage" => :run_triage,
|
|
34
|
+
"coverage" => :run_coverage
|
|
33
35
|
}.freeze
|
|
34
36
|
|
|
35
37
|
def self.start(argv = ARGV, out: $stdout, err: $stderr)
|
|
@@ -81,7 +83,7 @@ module Rigor
|
|
|
81
83
|
buffer = resolve_buffer_binding(options)
|
|
82
84
|
return EXIT_USAGE if buffer == :usage_error
|
|
83
85
|
|
|
84
|
-
configuration =
|
|
86
|
+
configuration = load_check_configuration(options)
|
|
85
87
|
cache_root = configuration.cache_path
|
|
86
88
|
handle_clear_cache(cache_root) if options.fetch(:clear_cache)
|
|
87
89
|
|
|
@@ -281,7 +283,16 @@ module Rigor
|
|
|
281
283
|
baseline: :unset,
|
|
282
284
|
# ADR-22 slice 5 — `--baseline-strict` CI gate: fail the
|
|
283
285
|
# run on any baseline drift, in either direction.
|
|
284
|
-
baseline_strict: false
|
|
286
|
+
baseline_strict: false,
|
|
287
|
+
# ADR-32 WD10 carry-over — `--treat-all-as-inline-rbs`
|
|
288
|
+
# forces the `rigor-rbs-inline` plugin into the loaded
|
|
289
|
+
# plugin set with `require_magic_comment: false` so a
|
|
290
|
+
# single ad-hoc `rigor check` invocation treats every
|
|
291
|
+
# analysed file as inline-RBS without the user editing
|
|
292
|
+
# `.rigor.yml`. Intended for single-file / ad-hoc CI use;
|
|
293
|
+
# ordinary projects should configure the plugin in
|
|
294
|
+
# `.rigor.yml`.
|
|
295
|
+
treat_all_as_inline_rbs: false
|
|
285
296
|
}
|
|
286
297
|
parser = OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
|
|
287
298
|
opts.banner = "Usage: rigor check [options] [paths]"
|
|
@@ -319,11 +330,56 @@ module Rigor
|
|
|
319
330
|
"ADR-22: fail the run on any baseline drift (CI gate)") do
|
|
320
331
|
options[:baseline_strict] = true
|
|
321
332
|
end
|
|
333
|
+
opts.on("--treat-all-as-inline-rbs",
|
|
334
|
+
"ADR-32: force-load rigor-rbs-inline with require_magic_comment: false") do
|
|
335
|
+
options[:treat_all_as_inline_rbs] = true
|
|
336
|
+
end
|
|
322
337
|
end
|
|
323
338
|
parser.parse!(@argv)
|
|
324
339
|
options
|
|
325
340
|
end
|
|
326
341
|
|
|
342
|
+
# ADR-32 WD10 carry-over — wraps `Configuration.load` so the
|
|
343
|
+
# CLI's `--treat-all-as-inline-rbs` flag can inject a
|
|
344
|
+
# `rigor-rbs-inline` plugin entry with
|
|
345
|
+
# `require_magic_comment: false` into the loaded plugin
|
|
346
|
+
# set. Re-runs the include-aware YAML load and applies the
|
|
347
|
+
# injection before `Configuration.new` so the new entry
|
|
348
|
+
# follows the normal coercion path. A pre-existing
|
|
349
|
+
# `rigor-rbs-inline` entry (by gem name or `id: rbs-inline`)
|
|
350
|
+
# is removed first so the synthesised entry's
|
|
351
|
+
# `require_magic_comment: false` wins unconditionally.
|
|
352
|
+
def load_check_configuration(options)
|
|
353
|
+
return Configuration.load(options.fetch(:config)) unless options.fetch(:treat_all_as_inline_rbs)
|
|
354
|
+
|
|
355
|
+
path = options.fetch(:config) || Configuration.discover
|
|
356
|
+
data = path && File.exist?(path) ? Configuration.load_with_includes(path) : {}
|
|
357
|
+
data = data.dup
|
|
358
|
+
data["plugins"] = inject_treat_all_as_inline_rbs(Array(data["plugins"]))
|
|
359
|
+
Configuration.new(Configuration::DEFAULTS.merge(data))
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def inject_treat_all_as_inline_rbs(entries)
|
|
363
|
+
filtered = entries.reject { |entry| rigor_rbs_inline_entry?(entry) }
|
|
364
|
+
filtered + [{
|
|
365
|
+
"gem" => "rigor-rbs-inline",
|
|
366
|
+
"id" => "rbs-inline",
|
|
367
|
+
"config" => { "require_magic_comment" => false }
|
|
368
|
+
}]
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def rigor_rbs_inline_entry?(entry)
|
|
372
|
+
case entry
|
|
373
|
+
when String
|
|
374
|
+
entry == "rigor-rbs-inline"
|
|
375
|
+
when Hash
|
|
376
|
+
string_keyed = entry.to_h { |k, v| [k.to_s, v] }
|
|
377
|
+
string_keyed["gem"] == "rigor-rbs-inline" || string_keyed["id"] == "rbs-inline"
|
|
378
|
+
else
|
|
379
|
+
false
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
327
383
|
def handle_clear_cache(cache_root)
|
|
328
384
|
if File.directory?(cache_root)
|
|
329
385
|
FileUtils.rm_rf(cache_root)
|
|
@@ -516,6 +572,12 @@ module Rigor
|
|
|
516
572
|
LspCommand.new(argv: @argv, out: @out, err: @err).run
|
|
517
573
|
end
|
|
518
574
|
|
|
575
|
+
def run_mcp
|
|
576
|
+
require_relative "cli/mcp_command"
|
|
577
|
+
|
|
578
|
+
McpCommand.new(argv: @argv, out: @out, err: @err).run
|
|
579
|
+
end
|
|
580
|
+
|
|
519
581
|
def run_baseline
|
|
520
582
|
require_relative "cli/baseline_command"
|
|
521
583
|
|
|
@@ -528,6 +590,12 @@ module Rigor
|
|
|
528
590
|
CLI::TriageCommand.new(argv: @argv, out: @out, err: @err).run
|
|
529
591
|
end
|
|
530
592
|
|
|
593
|
+
def run_coverage
|
|
594
|
+
require_relative "cli/coverage_command"
|
|
595
|
+
|
|
596
|
+
CLI::CoverageCommand.new(argv: @argv, out: @out, err: @err).run
|
|
597
|
+
end
|
|
598
|
+
|
|
531
599
|
def write_result(result, format)
|
|
532
600
|
case format
|
|
533
601
|
when "json"
|
|
@@ -570,7 +638,9 @@ module Rigor
|
|
|
570
638
|
diff Compare current diagnostics to a saved baseline JSON
|
|
571
639
|
sig-gen Emit RBS skeletons inferred from .rb sources (ADR-14)
|
|
572
640
|
lsp Run the Rigor Language Server (LSP) over stdio
|
|
641
|
+
mcp Run the Rigor MCP server over stdio (ADR-33)
|
|
573
642
|
triage Summarise diagnostics: distribution, hotspots, hints (ADR-23)
|
|
643
|
+
coverage Report type-precision coverage (precise vs Dynamic ratio)
|
|
574
644
|
version Print the Rigor version
|
|
575
645
|
help Print this help
|
|
576
646
|
HELP
|
|
@@ -56,7 +56,7 @@ module Rigor
|
|
|
56
56
|
# run. The gem stubs are intentionally read-only and
|
|
57
57
|
# appended LAST so user-supplied `signature_paths` win on
|
|
58
58
|
# name conflicts.
|
|
59
|
-
def build_env_for(libraries:, signature_paths:)
|
|
59
|
+
def build_env_for(libraries:, signature_paths:, virtual_rbs: [])
|
|
60
60
|
rbs_loader = RBS::EnvironmentLoader.new
|
|
61
61
|
libraries.each do |library|
|
|
62
62
|
next unless rbs_loader.has_library?(library: library, version: nil)
|
|
@@ -70,7 +70,36 @@ module Rigor
|
|
|
70
70
|
vendored_gem_sig_paths.each do |path|
|
|
71
71
|
rbs_loader.add(path: path) if path.directory?
|
|
72
72
|
end
|
|
73
|
-
RBS::Environment.from_loader(rbs_loader)
|
|
73
|
+
env = RBS::Environment.from_loader(rbs_loader)
|
|
74
|
+
add_virtual_rbs(env, virtual_rbs)
|
|
75
|
+
env.resolve_type_names
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# ADR-32 WD4 — merge synthesised-from-source RBS strings
|
|
79
|
+
# into the freshly-built environment. Each entry is a
|
|
80
|
+
# `[virtual_filename, rbs_source]` pair. `virtual_filename`
|
|
81
|
+
# is purely for diagnostic provenance (RBS parse errors
|
|
82
|
+
# cite it) — it is not a real file path. Per WD6 the
|
|
83
|
+
# synthesizer-emit path is responsible for catching its
|
|
84
|
+
# own parse errors and returning `nil` rather than
|
|
85
|
+
# garbage; this method assumes its input is parseable
|
|
86
|
+
# and only rescues `RBS::ParsingError` as a fail-soft.
|
|
87
|
+
def add_virtual_rbs(env, virtual_rbs)
|
|
88
|
+
return if virtual_rbs.nil? || virtual_rbs.empty?
|
|
89
|
+
|
|
90
|
+
virtual_rbs.each do |filename, content|
|
|
91
|
+
next if content.nil? || content.empty?
|
|
92
|
+
|
|
93
|
+
buffer = ::RBS::Buffer.new(name: filename.to_s, content: content.to_s)
|
|
94
|
+
_, directives, decls = ::RBS::Parser.parse_signature(buffer)
|
|
95
|
+
source = ::RBS::Source::RBS.new(buffer, directives || [], decls || [])
|
|
96
|
+
env.add_source(source)
|
|
97
|
+
rescue ::RBS::BaseError
|
|
98
|
+
# WD6 fail-soft: a single broken virtual RBS contribution
|
|
99
|
+
# does not pull the whole env down. The plugin layer
|
|
100
|
+
# records a `source-rbs-synthesis-failed` info diagnostic
|
|
101
|
+
# in slice 2; here we just skip the entry.
|
|
102
|
+
end
|
|
74
103
|
end
|
|
75
104
|
|
|
76
105
|
# Per-gem `data/vendored_gem_sigs/<gem>/` directories that
|
|
@@ -95,7 +124,7 @@ module Rigor
|
|
|
95
124
|
end
|
|
96
125
|
end
|
|
97
126
|
|
|
98
|
-
attr_reader :libraries, :signature_paths, :cache_store
|
|
127
|
+
attr_reader :libraries, :signature_paths, :cache_store, :virtual_rbs
|
|
99
128
|
|
|
100
129
|
# @param libraries [Array<String, Symbol>] stdlib library names to
|
|
101
130
|
# load on top of core (e.g., `["pathname", "json"]`). Empty by
|
|
@@ -114,10 +143,18 @@ module Rigor
|
|
|
114
143
|
# reflection artefacts). Pass `nil` (the default) to skip
|
|
115
144
|
# the cache entirely; the runner threads its own Store
|
|
116
145
|
# through here when caching is enabled.
|
|
117
|
-
|
|
146
|
+
# @param virtual_rbs [Array<[String, String]>] ADR-32 WD4 —
|
|
147
|
+
# `[virtual_filename, rbs_source]` pairs synthesised from
|
|
148
|
+
# project source by a plugin's
|
|
149
|
+
# `Manifest#source_rbs_synthesizer`. Merged into the env
|
|
150
|
+
# after `signature_paths:` and the vendored stubs. Pass
|
|
151
|
+
# `[]` (the default) when no synthesizer-emitting plugin
|
|
152
|
+
# is loaded.
|
|
153
|
+
def initialize(libraries: [], signature_paths: [], cache_store: nil, virtual_rbs: [])
|
|
118
154
|
@libraries = libraries.map(&:to_s).freeze
|
|
119
155
|
@signature_paths = signature_paths.map { |p| Pathname(p) }.freeze
|
|
120
156
|
@cache_store = cache_store
|
|
157
|
+
@virtual_rbs = virtual_rbs.map { |name, content| [name.to_s.dup.freeze, content.to_s.dup.freeze].freeze }.freeze
|
|
121
158
|
# Per-loader memoization bucket. Held as a single
|
|
122
159
|
# mutable Hash so the loader instance itself can be
|
|
123
160
|
# `.freeze`d (per ADR-15 reflection-facade contract)
|
|
@@ -642,7 +679,11 @@ module Rigor
|
|
|
642
679
|
end
|
|
643
680
|
|
|
644
681
|
def build_env
|
|
645
|
-
self.class.build_env_for(
|
|
682
|
+
self.class.build_env_for(
|
|
683
|
+
libraries: @libraries,
|
|
684
|
+
signature_paths: @signature_paths,
|
|
685
|
+
virtual_rbs: @virtual_rbs
|
|
686
|
+
)
|
|
646
687
|
end
|
|
647
688
|
|
|
648
689
|
def build_instance_definition(class_name)
|
|
@@ -29,11 +29,12 @@ module Rigor
|
|
|
29
29
|
# return nil, and the consumer sites short-circuit on
|
|
30
30
|
# `reporter.nil?`.
|
|
31
31
|
class Reporters
|
|
32
|
-
attr_accessor :rbs_extended, :boundary_cross
|
|
32
|
+
attr_accessor :rbs_extended, :boundary_cross, :source_rbs_synthesis
|
|
33
33
|
|
|
34
|
-
def initialize(rbs_extended: nil, boundary_cross: nil)
|
|
34
|
+
def initialize(rbs_extended: nil, boundary_cross: nil, source_rbs_synthesis: nil)
|
|
35
35
|
@rbs_extended = rbs_extended
|
|
36
36
|
@boundary_cross = boundary_cross
|
|
37
|
+
@source_rbs_synthesis = source_rbs_synthesis
|
|
37
38
|
end
|
|
38
39
|
end
|
|
39
40
|
end
|