rigortype 0.1.5 → 0.1.7
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 +76 -79
- data/lib/rigor/analysis/baseline.rb +347 -0
- data/lib/rigor/analysis/buffer_binding.rb +36 -0
- data/lib/rigor/analysis/check_rules.rb +68 -3
- data/lib/rigor/analysis/dependency_source_inference/index.rb +14 -1
- data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +105 -0
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +32 -12
- data/lib/rigor/analysis/project_scan.rb +39 -0
- data/lib/rigor/analysis/runner.rb +309 -22
- data/lib/rigor/analysis/worker_session.rb +14 -2
- data/lib/rigor/builtins/hkt_builtins.rb +342 -0
- data/lib/rigor/builtins/static_return_refinements.rb +142 -0
- data/lib/rigor/cache/store.rb +33 -3
- data/lib/rigor/cli/baseline_command.rb +377 -0
- data/lib/rigor/cli/lsp_command.rb +129 -0
- data/lib/rigor/cli/type_of_command.rb +44 -5
- data/lib/rigor/cli.rb +142 -13
- data/lib/rigor/configuration.rb +58 -2
- data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
- data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
- data/lib/rigor/environment/rbs_loader.rb +67 -2
- data/lib/rigor/environment/reporters.rb +40 -0
- data/lib/rigor/environment.rb +119 -9
- data/lib/rigor/flow_contribution/fact.rb +20 -10
- data/lib/rigor/inference/acceptance.rb +48 -3
- data/lib/rigor/inference/expression_typer.rb +64 -2
- data/lib/rigor/inference/hkt_body.rb +171 -0
- data/lib/rigor/inference/hkt_body_parser.rb +363 -0
- data/lib/rigor/inference/hkt_reducer.rb +256 -0
- data/lib/rigor/inference/hkt_registry.rb +223 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +125 -30
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +32 -11
- data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
- data/lib/rigor/inference/method_dispatcher.rb +174 -6
- data/lib/rigor/inference/narrowing.rb +103 -1
- data/lib/rigor/inference/project_patched_methods.rb +70 -0
- data/lib/rigor/inference/project_patched_scanner.rb +210 -0
- data/lib/rigor/inference/scope_indexer.rb +209 -19
- data/lib/rigor/inference/statement_evaluator.rb +172 -11
- data/lib/rigor/inference/synthetic_method_scanner.rb +94 -16
- data/lib/rigor/language_server/buffer_table.rb +63 -0
- data/lib/rigor/language_server/completion_provider.rb +438 -0
- data/lib/rigor/language_server/debouncer.rb +86 -0
- data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
- data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
- data/lib/rigor/language_server/folding_range_provider.rb +75 -0
- data/lib/rigor/language_server/hover_provider.rb +74 -0
- data/lib/rigor/language_server/hover_renderer.rb +312 -0
- data/lib/rigor/language_server/loop.rb +71 -0
- data/lib/rigor/language_server/project_context.rb +145 -0
- data/lib/rigor/language_server/selection_range_provider.rb +93 -0
- data/lib/rigor/language_server/server.rb +384 -0
- data/lib/rigor/language_server/signature_help_provider.rb +249 -0
- data/lib/rigor/language_server/synchronized_writer.rb +28 -0
- data/lib/rigor/language_server/uri.rb +40 -0
- data/lib/rigor/language_server.rb +29 -0
- data/lib/rigor/plugin/base.rb +63 -0
- data/lib/rigor/plugin/macro/heredoc_template.rb +127 -13
- data/lib/rigor/plugin/macro/trait_registry.rb +1 -1
- data/lib/rigor/plugin/manifest.rb +54 -7
- data/lib/rigor/plugin/registry.rb +19 -0
- data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
- data/lib/rigor/rbs_extended.rb +82 -2
- data/lib/rigor/sig_gen/generator.rb +12 -3
- data/lib/rigor/type/app.rb +107 -0
- data/lib/rigor/type.rb +1 -0
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +10 -4
- data/sig/rigor/inference.rbs +2 -0
- data/sig/rigor.rbs +4 -1
- metadata +56 -1
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
require_relative "../analysis/baseline"
|
|
6
|
+
require_relative "../analysis/runner"
|
|
7
|
+
require_relative "../cache/store"
|
|
8
|
+
require_relative "../configuration"
|
|
9
|
+
|
|
10
|
+
module Rigor
|
|
11
|
+
class CLI
|
|
12
|
+
# ADR-22 Slice 1 — `rigor baseline {generate}` subcommands.
|
|
13
|
+
# Backed by `Rigor::Analysis::Baseline`. Future slices
|
|
14
|
+
# extend the subcommand surface with `dump`, `drift`,
|
|
15
|
+
# `prune`, `regenerate`.
|
|
16
|
+
#
|
|
17
|
+
# Initial subcommand: `generate`.
|
|
18
|
+
#
|
|
19
|
+
# rigor baseline generate # default: rule-ID rows
|
|
20
|
+
# rigor baseline generate --match-mode message
|
|
21
|
+
# rigor baseline generate --force # overwrite existing
|
|
22
|
+
# rigor baseline generate --output=PATH
|
|
23
|
+
class BaselineCommand # rubocop:disable Metrics/ClassLength
|
|
24
|
+
EXIT_USAGE = 64
|
|
25
|
+
DEFAULT_BASELINE_PATH = ".rigor-baseline.yml"
|
|
26
|
+
|
|
27
|
+
SUBCOMMANDS = %w[generate dump drift prune].freeze
|
|
28
|
+
|
|
29
|
+
def initialize(argv:, out: $stdout, err: $stderr)
|
|
30
|
+
@argv = argv
|
|
31
|
+
@out = out
|
|
32
|
+
@err = err
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def run
|
|
36
|
+
subcommand = @argv.shift
|
|
37
|
+
case subcommand
|
|
38
|
+
when nil, "help", "-h", "--help"
|
|
39
|
+
@out.puts(help)
|
|
40
|
+
0
|
|
41
|
+
when "generate" then run_generate
|
|
42
|
+
when "dump" then run_dump
|
|
43
|
+
when "drift" then run_drift
|
|
44
|
+
when "prune" then run_prune
|
|
45
|
+
else
|
|
46
|
+
@err.puts("Unknown baseline subcommand: #{subcommand.inspect}")
|
|
47
|
+
@err.puts(help)
|
|
48
|
+
EXIT_USAGE
|
|
49
|
+
end
|
|
50
|
+
rescue OptionParser::ParseError => e
|
|
51
|
+
@err.puts(e.message)
|
|
52
|
+
EXIT_USAGE
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def run_generate
|
|
58
|
+
options = parse_generate_options
|
|
59
|
+
path = options.fetch(:output)
|
|
60
|
+
|
|
61
|
+
if File.exist?(path) && !options.fetch(:force)
|
|
62
|
+
@err.puts("rigor: #{path} already exists. Re-run with --force to overwrite.")
|
|
63
|
+
return EXIT_USAGE
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
67
|
+
diagnostics = collect_diagnostics(configuration, options)
|
|
68
|
+
|
|
69
|
+
baseline = Analysis::Baseline.from_diagnostics(diagnostics, match_mode: options.fetch(:match_mode))
|
|
70
|
+
File.write(path, baseline.to_yaml)
|
|
71
|
+
|
|
72
|
+
bucket_count = baseline.size
|
|
73
|
+
diagnostic_count = diagnostics.size
|
|
74
|
+
@err.puts(
|
|
75
|
+
"rigor: wrote baseline to #{path} " \
|
|
76
|
+
"(#{bucket_count} bucket(s) covering #{diagnostic_count} diagnostic(s); " \
|
|
77
|
+
"match-mode: #{options.fetch(:match_mode)})"
|
|
78
|
+
)
|
|
79
|
+
if configuration.baseline_path.nil?
|
|
80
|
+
@err.puts(
|
|
81
|
+
"rigor: note — `.rigor.yml` does not declare `baseline:`; " \
|
|
82
|
+
"add `baseline: #{path}` to activate the suppression."
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
0
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_generate_options
|
|
89
|
+
options = {
|
|
90
|
+
config: nil,
|
|
91
|
+
output: DEFAULT_BASELINE_PATH,
|
|
92
|
+
match_mode: :rule,
|
|
93
|
+
force: false
|
|
94
|
+
}
|
|
95
|
+
parser = OptionParser.new do |opts|
|
|
96
|
+
opts.banner = "Usage: rigor baseline generate [options]"
|
|
97
|
+
opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
|
|
98
|
+
opts.on("--output=PATH", "Write baseline to PATH (default: #{DEFAULT_BASELINE_PATH})") do |v|
|
|
99
|
+
options[:output] = v
|
|
100
|
+
end
|
|
101
|
+
opts.on("--match-mode=MODE", %i[rule message],
|
|
102
|
+
"Row form: rule (default) or message") do |v|
|
|
103
|
+
options[:match_mode] = v
|
|
104
|
+
end
|
|
105
|
+
opts.on("--force", "Overwrite an existing baseline file") { options[:force] = true }
|
|
106
|
+
end
|
|
107
|
+
parser.parse!(@argv)
|
|
108
|
+
options
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def collect_diagnostics(configuration, _options)
|
|
112
|
+
cache_store = Cache::Store.new(root: configuration.cache_path)
|
|
113
|
+
# IMPORTANT: do NOT activate the existing baseline when
|
|
114
|
+
# generating a fresh one — otherwise the new file
|
|
115
|
+
# records the post-filter (silenced) diagnostic set,
|
|
116
|
+
# which is empty after a successful first run.
|
|
117
|
+
configuration_for_generation = override_configuration_baseline_off(configuration)
|
|
118
|
+
runner = Analysis::Runner.new(
|
|
119
|
+
configuration: configuration_for_generation,
|
|
120
|
+
cache_store: cache_store,
|
|
121
|
+
collect_stats: false
|
|
122
|
+
)
|
|
123
|
+
runner.run(configuration_for_generation.paths).diagnostics
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def override_configuration_baseline_off(configuration)
|
|
127
|
+
# Synthesise a new Configuration with `baseline` explicitly
|
|
128
|
+
# disabled. The original Configuration is frozen-ish so we
|
|
129
|
+
# round-trip through the constructor with an override hash.
|
|
130
|
+
defaults = Configuration::DEFAULTS.merge(
|
|
131
|
+
"paths" => configuration.paths,
|
|
132
|
+
"exclude" => configuration.exclude_patterns,
|
|
133
|
+
"plugins" => configuration.plugins.map(&:to_h),
|
|
134
|
+
"disable" => configuration.disabled_rules,
|
|
135
|
+
"libraries" => configuration.libraries,
|
|
136
|
+
"signature_paths" => configuration.signature_paths,
|
|
137
|
+
"pre_eval" => configuration.pre_eval,
|
|
138
|
+
"severity_profile" => configuration.severity_profile.to_s,
|
|
139
|
+
"severity_overrides" => configuration.severity_overrides,
|
|
140
|
+
"baseline" => false,
|
|
141
|
+
"cache" => { "path" => configuration.cache_path }
|
|
142
|
+
)
|
|
143
|
+
Configuration.new(defaults)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# ---- dump --------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def run_dump
|
|
149
|
+
options = parse_dump_options
|
|
150
|
+
baseline = load_baseline_or_exit(options.fetch(:baseline))
|
|
151
|
+
return EXIT_USAGE if baseline == :error
|
|
152
|
+
|
|
153
|
+
rows = filter_dump_rows(baseline.buckets, options)
|
|
154
|
+
case options.fetch(:format)
|
|
155
|
+
when :json then @out.puts(JSON.pretty_generate(dump_to_json(rows)))
|
|
156
|
+
else dump_text(rows, options)
|
|
157
|
+
end
|
|
158
|
+
0
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def parse_dump_options
|
|
162
|
+
options = { baseline: DEFAULT_BASELINE_PATH, format: :text, rule: nil, file: nil }
|
|
163
|
+
parser = OptionParser.new do |opts|
|
|
164
|
+
opts.banner = "Usage: rigor baseline dump [options]"
|
|
165
|
+
opts.on("--baseline=PATH", "Path to the baseline file (default: #{DEFAULT_BASELINE_PATH})") do |v|
|
|
166
|
+
options[:baseline] = v
|
|
167
|
+
end
|
|
168
|
+
opts.on("--format=FORMAT", %i[text json], "Output format: text (default) or json") do |v|
|
|
169
|
+
options[:format] = v
|
|
170
|
+
end
|
|
171
|
+
opts.on("--rule=RULE", "Filter rows by exact rule id") { |v| options[:rule] = v }
|
|
172
|
+
opts.on("--file=GLOB", "Filter rows by File.fnmatch? glob") { |v| options[:file] = v }
|
|
173
|
+
end
|
|
174
|
+
parser.parse!(@argv)
|
|
175
|
+
options
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def filter_dump_rows(buckets, options)
|
|
179
|
+
buckets.select do |b|
|
|
180
|
+
next false if options[:rule] && b.rule != options[:rule]
|
|
181
|
+
next false if options[:file] && !File.fnmatch?(options[:file], b.file)
|
|
182
|
+
|
|
183
|
+
true
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def dump_text(rows, options)
|
|
188
|
+
if rows.empty?
|
|
189
|
+
@out.puts("(no baseline rows matching the supplied filters)")
|
|
190
|
+
return
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Group by rule for readability; rules with the most
|
|
194
|
+
# entries first.
|
|
195
|
+
by_rule = rows.group_by(&:rule).sort_by { |_rule, group| -group.size }
|
|
196
|
+
by_rule.each do |rule, group|
|
|
197
|
+
total = group.sum(&:count)
|
|
198
|
+
@out.puts("#{rule} (#{group.size} bucket(s), #{total} occurrence(s))")
|
|
199
|
+
group.sort_by { |b| [-b.count, b.file] }.each do |bucket|
|
|
200
|
+
label = if bucket.message_regex
|
|
201
|
+
" #{bucket.file}: #{bucket.count} ~/#{bucket.message_regex.source}/"
|
|
202
|
+
else
|
|
203
|
+
" #{bucket.file}: #{bucket.count}"
|
|
204
|
+
end
|
|
205
|
+
@out.puts(label)
|
|
206
|
+
end
|
|
207
|
+
@out.puts("")
|
|
208
|
+
end
|
|
209
|
+
@out.puts("Total: #{rows.size} bucket(s), #{rows.sum(&:count)} occurrence(s)")
|
|
210
|
+
_ = options # reserved for future flags
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def dump_to_json(rows)
|
|
214
|
+
{
|
|
215
|
+
"version" => Analysis::Baseline::CURRENT_VERSION,
|
|
216
|
+
"ignored" => rows.map do |b|
|
|
217
|
+
row = { "file" => b.file, "rule" => b.rule, "count" => b.count }
|
|
218
|
+
row["message"] = b.message_regex.source if b.message_regex
|
|
219
|
+
row
|
|
220
|
+
end
|
|
221
|
+
}
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# ---- drift --------------------------------------------------
|
|
225
|
+
|
|
226
|
+
def run_drift
|
|
227
|
+
options = parse_drift_options
|
|
228
|
+
baseline = load_baseline_or_exit(options.fetch(:baseline))
|
|
229
|
+
return EXIT_USAGE if baseline == :error
|
|
230
|
+
|
|
231
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
232
|
+
diagnostics = collect_diagnostics(configuration, options)
|
|
233
|
+
drift_rows = baseline.audit(diagnostics)
|
|
234
|
+
|
|
235
|
+
shown = if options.fetch(:only).nil?
|
|
236
|
+
drift_rows.reject { |r| r.delta.zero? }
|
|
237
|
+
else
|
|
238
|
+
drift_rows.select { |r| r.status == options.fetch(:only) }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
if shown.empty?
|
|
242
|
+
@out.puts("No drift detected.")
|
|
243
|
+
return 0
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
report_drift_rows(shown, baseline_path: options.fetch(:baseline))
|
|
247
|
+
0
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def parse_drift_options
|
|
251
|
+
options = {
|
|
252
|
+
config: nil,
|
|
253
|
+
baseline: DEFAULT_BASELINE_PATH,
|
|
254
|
+
only: nil
|
|
255
|
+
}
|
|
256
|
+
parser = OptionParser.new do |opts|
|
|
257
|
+
opts.banner = "Usage: rigor baseline drift [options]"
|
|
258
|
+
opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
|
|
259
|
+
opts.on("--baseline=PATH", "Path to the baseline file (default: #{DEFAULT_BASELINE_PATH})") do |v|
|
|
260
|
+
options[:baseline] = v
|
|
261
|
+
end
|
|
262
|
+
opts.on("--only=STATUS", %i[within over cleared reducible],
|
|
263
|
+
"Show only buckets with the given status (within|over|cleared|reducible)") do |v|
|
|
264
|
+
options[:only] = v
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
parser.parse!(@argv)
|
|
268
|
+
options
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def report_drift_rows(rows, baseline_path:) # rubocop:disable Metrics/AbcSize
|
|
272
|
+
@out.puts("Drift report against #{baseline_path}:")
|
|
273
|
+
@out.puts("")
|
|
274
|
+
groups = rows.group_by(&:status)
|
|
275
|
+
%i[over cleared reducible within].each do |status|
|
|
276
|
+
group = groups[status] || []
|
|
277
|
+
next if group.empty?
|
|
278
|
+
|
|
279
|
+
@out.puts(drift_section_header(status, group.size))
|
|
280
|
+
group.sort_by { |r| [r.bucket.file, r.bucket.rule] }.each do |row|
|
|
281
|
+
delta_str = row.delta.positive? ? "+#{row.delta}" : row.delta.to_s
|
|
282
|
+
@out.puts(" #{row.bucket.file} [#{row.bucket.rule}] " \
|
|
283
|
+
"#{row.bucket.count} → #{row.actual_count} (Δ#{delta_str})")
|
|
284
|
+
end
|
|
285
|
+
@out.puts("")
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def drift_section_header(status, count)
|
|
290
|
+
case status
|
|
291
|
+
when :over then "## Over threshold (#{count}) — bucket exceeded; check the regular diagnostic output."
|
|
292
|
+
when :cleared then "## Cleared (#{count}) — `rigor baseline prune` can drop these."
|
|
293
|
+
when :reducible then "## Reducible (#{count}) — tightening opportunity; consider `regenerate` (slice 5)."
|
|
294
|
+
when :within then "## Within threshold (#{count})"
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# ---- prune --------------------------------------------------
|
|
299
|
+
|
|
300
|
+
def run_prune
|
|
301
|
+
options = parse_prune_options
|
|
302
|
+
baseline = load_baseline_or_exit(options.fetch(:baseline))
|
|
303
|
+
return EXIT_USAGE if baseline == :error
|
|
304
|
+
|
|
305
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
306
|
+
diagnostics = collect_diagnostics(configuration, options)
|
|
307
|
+
drift_rows = baseline.audit(diagnostics)
|
|
308
|
+
cleared = drift_rows.select { |r| r.status == :cleared }
|
|
309
|
+
|
|
310
|
+
if cleared.empty?
|
|
311
|
+
@out.puts("No cleared buckets to prune.")
|
|
312
|
+
return 0
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
announce_prune(cleared, options.fetch(:baseline))
|
|
316
|
+
return 0 if options.fetch(:dry_run)
|
|
317
|
+
|
|
318
|
+
pruned = baseline.without(cleared.map(&:bucket))
|
|
319
|
+
File.write(options.fetch(:baseline), pruned.to_yaml)
|
|
320
|
+
@err.puts("rigor: pruned #{cleared.size} bucket(s); baseline now has #{pruned.size} entries.")
|
|
321
|
+
0
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def parse_prune_options
|
|
325
|
+
options = {
|
|
326
|
+
config: nil,
|
|
327
|
+
baseline: DEFAULT_BASELINE_PATH,
|
|
328
|
+
dry_run: false
|
|
329
|
+
}
|
|
330
|
+
parser = OptionParser.new do |opts|
|
|
331
|
+
opts.banner = "Usage: rigor baseline prune [options]"
|
|
332
|
+
opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
|
|
333
|
+
opts.on("--baseline=PATH", "Path to the baseline file (default: #{DEFAULT_BASELINE_PATH})") do |v|
|
|
334
|
+
options[:baseline] = v
|
|
335
|
+
end
|
|
336
|
+
opts.on("--dry-run", "Show what would be dropped without writing the file") { options[:dry_run] = true }
|
|
337
|
+
end
|
|
338
|
+
parser.parse!(@argv)
|
|
339
|
+
options
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def announce_prune(cleared, baseline_path)
|
|
343
|
+
@out.puts("#{cleared.size} bucket(s) to prune from #{baseline_path}:")
|
|
344
|
+
cleared.sort_by { |r| [r.bucket.file, r.bucket.rule] }.each do |row|
|
|
345
|
+
@out.puts(" - #{row.bucket.file} [#{row.bucket.rule}] (was: #{row.bucket.count})")
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# ---- shared helpers ----------------------------------------
|
|
350
|
+
|
|
351
|
+
def load_baseline_or_exit(path)
|
|
352
|
+
unless File.exist?(path)
|
|
353
|
+
@err.puts("rigor: baseline file not found: #{path}")
|
|
354
|
+
return :error
|
|
355
|
+
end
|
|
356
|
+
Analysis::Baseline.load(path)
|
|
357
|
+
rescue Analysis::Baseline::LoadError => e
|
|
358
|
+
@err.puts("rigor: baseline load failed: #{e.message}")
|
|
359
|
+
:error
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def help
|
|
363
|
+
<<~HELP
|
|
364
|
+
Usage: rigor baseline <subcommand> [options]
|
|
365
|
+
|
|
366
|
+
Subcommands:
|
|
367
|
+
generate Write a fresh baseline file from a `rigor check` run.
|
|
368
|
+
dump Print the contents of an existing baseline.
|
|
369
|
+
drift Compare baseline vs current diagnostics (reduction / regression hints).
|
|
370
|
+
prune Drop cleared buckets (`actual == 0`) from the baseline.
|
|
371
|
+
|
|
372
|
+
Run `rigor baseline <subcommand> --help` for subcommand options.
|
|
373
|
+
HELP
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optionparser"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
class CLI
|
|
7
|
+
# Executes the `rigor lsp` command.
|
|
8
|
+
#
|
|
9
|
+
# See `docs/design/20260517-language-server.md` for the design.
|
|
10
|
+
# Slice 1 (this commit) ships the CLI subcommand entry point.
|
|
11
|
+
# The actual stdio JSON-RPC reader / writer is queued for slice 2;
|
|
12
|
+
# invoking `rigor lsp` at slice 1 returns immediately after
|
|
13
|
+
# validating the transport flag.
|
|
14
|
+
class LspCommand
|
|
15
|
+
USAGE = "Usage: rigor lsp [options]"
|
|
16
|
+
|
|
17
|
+
def initialize(argv:, out:, err:)
|
|
18
|
+
@argv = argv
|
|
19
|
+
@out = out
|
|
20
|
+
@err = err
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Integer] CLI exit status.
|
|
24
|
+
def run
|
|
25
|
+
options = parse_options
|
|
26
|
+
return CLI::EXIT_USAGE if options == :usage_error
|
|
27
|
+
|
|
28
|
+
transport = options.fetch(:transport)
|
|
29
|
+
unless transport == "stdio"
|
|
30
|
+
@err.puts("rigor lsp: unsupported transport: #{transport.inspect} (only `stdio` is supported in v1)")
|
|
31
|
+
return CLI::EXIT_USAGE
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
require_relative "../language_server"
|
|
35
|
+
require_relative "../configuration"
|
|
36
|
+
require "language_server-protocol"
|
|
37
|
+
|
|
38
|
+
# STDIN is read frame-by-frame via the gem's `Io::Reader`;
|
|
39
|
+
# STDOUT is wrapped in `SynchronizedWriter` so concurrent
|
|
40
|
+
# writes from the main dispatch thread + the Debouncer's
|
|
41
|
+
# async threads don't interleave frames. The Loop runs
|
|
42
|
+
# until either STDIN hits EOF or `server.exited?`; the
|
|
43
|
+
# process then exits with the server's recorded code
|
|
44
|
+
# (0 after a clean shutdown+exit, 1 otherwise).
|
|
45
|
+
writer = LanguageServer::SynchronizedWriter.new(
|
|
46
|
+
::LanguageServer::Protocol::Transport::Io::Writer.new($stdout)
|
|
47
|
+
)
|
|
48
|
+
server, loop_runner = build_server(writer: writer, config_path: options.fetch(:config))
|
|
49
|
+
loop_runner.run
|
|
50
|
+
server.exit_code || 0
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Builds the full collaborator graph from a fresh
|
|
56
|
+
# `Configuration` + `ProjectContext`. Returns `[server,
|
|
57
|
+
# loop]` so the caller drives the loop and reads
|
|
58
|
+
# `server.exit_code` for the process exit status.
|
|
59
|
+
def build_server(writer:, config_path:) # rubocop:disable Metrics/MethodLength
|
|
60
|
+
configuration = Configuration.load(config_path)
|
|
61
|
+
# ProjectContext caches Environment + Cache::Store across
|
|
62
|
+
# requests so hover / publish hit the warm path. Invalidated
|
|
63
|
+
# by `workspace/didChangeWatchedFiles` and
|
|
64
|
+
# `workspace/didChangeConfiguration`.
|
|
65
|
+
project_context = LanguageServer::ProjectContext.new(configuration: configuration)
|
|
66
|
+
# Single source of truth for buffer state — threaded to
|
|
67
|
+
# Server + all three providers.
|
|
68
|
+
buffer_table = LanguageServer::BufferTable.new
|
|
69
|
+
debouncer = LanguageServer::Debouncer.new
|
|
70
|
+
publisher = LanguageServer::DiagnosticPublisher.new(
|
|
71
|
+
writer: writer, buffer_table: buffer_table, project_context: project_context,
|
|
72
|
+
debouncer: debouncer, debounce_seconds: 0.2
|
|
73
|
+
)
|
|
74
|
+
server = LanguageServer::Server.new(
|
|
75
|
+
buffer_table: buffer_table,
|
|
76
|
+
publisher: publisher,
|
|
77
|
+
hover_provider: LanguageServer::HoverProvider.new(
|
|
78
|
+
buffer_table: buffer_table, project_context: project_context
|
|
79
|
+
),
|
|
80
|
+
document_symbol_provider: LanguageServer::DocumentSymbolProvider.new(
|
|
81
|
+
buffer_table: buffer_table, project_context: project_context
|
|
82
|
+
),
|
|
83
|
+
completion_provider: LanguageServer::CompletionProvider.new(
|
|
84
|
+
buffer_table: buffer_table, project_context: project_context
|
|
85
|
+
),
|
|
86
|
+
signature_help_provider: LanguageServer::SignatureHelpProvider.new(
|
|
87
|
+
buffer_table: buffer_table, project_context: project_context
|
|
88
|
+
),
|
|
89
|
+
folding_range_provider: LanguageServer::FoldingRangeProvider.new(
|
|
90
|
+
buffer_table: buffer_table, project_context: project_context
|
|
91
|
+
),
|
|
92
|
+
selection_range_provider: LanguageServer::SelectionRangeProvider.new(
|
|
93
|
+
buffer_table: buffer_table, project_context: project_context
|
|
94
|
+
),
|
|
95
|
+
project_context: project_context
|
|
96
|
+
)
|
|
97
|
+
loop_runner = LanguageServer::Loop.new(
|
|
98
|
+
reader: ::LanguageServer::Protocol::Transport::Io::Reader.new($stdin),
|
|
99
|
+
writer: writer,
|
|
100
|
+
server: server
|
|
101
|
+
)
|
|
102
|
+
[server, loop_runner]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def parse_options
|
|
106
|
+
options = { transport: "stdio", log: nil, config: nil }
|
|
107
|
+
|
|
108
|
+
parser = OptionParser.new do |opts|
|
|
109
|
+
opts.banner = USAGE
|
|
110
|
+
opts.on("--transport=NAME", "Transport (default: stdio; only stdio supported in v1)") do |value|
|
|
111
|
+
options[:transport] = value
|
|
112
|
+
end
|
|
113
|
+
opts.on("--log=PATH", "Write LSP wire log + server debug to PATH (default: stderr)") do |value|
|
|
114
|
+
options[:log] = value
|
|
115
|
+
end
|
|
116
|
+
opts.on("--config=PATH", "Path to the Rigor configuration file") do |value|
|
|
117
|
+
options[:config] = value
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
parser.parse!(@argv)
|
|
121
|
+
options
|
|
122
|
+
rescue OptionParser::ParseError => e
|
|
123
|
+
@err.puts(e.message)
|
|
124
|
+
@err.puts(USAGE)
|
|
125
|
+
:usage_error
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "optionparser"
|
|
4
4
|
require "prism"
|
|
5
5
|
|
|
6
|
+
require_relative "../analysis/buffer_binding"
|
|
6
7
|
require_relative "../configuration"
|
|
7
8
|
require_relative "../environment"
|
|
8
9
|
require_relative "../scope"
|
|
@@ -38,35 +39,73 @@ module Rigor
|
|
|
38
39
|
# @return [Integer] CLI exit status.
|
|
39
40
|
def run
|
|
40
41
|
options = parse_options
|
|
42
|
+
buffer = resolve_buffer_binding(options)
|
|
43
|
+
return CLI::EXIT_USAGE if buffer == :usage_error
|
|
41
44
|
|
|
42
45
|
target = parse_position_argument(@argv)
|
|
43
46
|
return CLI::EXIT_USAGE if target.nil?
|
|
44
47
|
|
|
45
|
-
execute(target: target, options: options)
|
|
48
|
+
execute(target: target, options: options, buffer: buffer)
|
|
46
49
|
end
|
|
47
50
|
|
|
48
51
|
private
|
|
49
52
|
|
|
50
53
|
def parse_options
|
|
51
|
-
options = { format: "text", trace: false, config: nil }
|
|
54
|
+
options = { format: "text", trace: false, config: nil, tmp_file: nil, instead_of: nil }
|
|
52
55
|
|
|
53
56
|
parser = OptionParser.new do |opts|
|
|
54
57
|
opts.banner = USAGE
|
|
55
58
|
opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
|
|
56
59
|
opts.on("--trace", "Record fail-soft fallbacks via FallbackTracer") { options[:trace] = true }
|
|
57
60
|
opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
|
|
61
|
+
opts.on("--tmp-file=PATH",
|
|
62
|
+
"Editor mode: read source bytes from PATH instead of --instead-of (paired)") do |value|
|
|
63
|
+
options[:tmp_file] = value
|
|
64
|
+
end
|
|
65
|
+
opts.on("--instead-of=PATH",
|
|
66
|
+
"Editor mode: the logical project path the buffer represents (paired with --tmp-file)") do |value|
|
|
67
|
+
options[:instead_of] = value
|
|
68
|
+
end
|
|
58
69
|
end
|
|
59
70
|
parser.parse!(@argv)
|
|
60
71
|
|
|
61
72
|
options
|
|
62
73
|
end
|
|
63
74
|
|
|
64
|
-
|
|
75
|
+
# Mirrors `Rigor::CLI#resolve_buffer_binding` (the `check`
|
|
76
|
+
# path). Returns nil / BufferBinding / :usage_error. The
|
|
77
|
+
# symbol return path lets the caller translate to
|
|
78
|
+
# `CLI::EXIT_USAGE` without raising.
|
|
79
|
+
def resolve_buffer_binding(options)
|
|
80
|
+
tmp = options[:tmp_file]
|
|
81
|
+
instead = options[:instead_of]
|
|
82
|
+
return nil if tmp.nil? && instead.nil?
|
|
83
|
+
|
|
84
|
+
if tmp.nil? || instead.nil?
|
|
85
|
+
@err.puts("--tmp-file and --instead-of must appear together")
|
|
86
|
+
return :usage_error
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
unless File.file?(tmp)
|
|
90
|
+
@err.puts("--tmp-file #{tmp.inspect}: no such file or not readable")
|
|
91
|
+
return :usage_error
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
Rigor::Analysis::BufferBinding.new(logical_path: instead, physical_path: tmp)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def execute(target:, options:, buffer: nil)
|
|
65
98
|
file, line, column = target
|
|
66
|
-
|
|
99
|
+
# Under editor mode the logical `file` may not exist on disk
|
|
100
|
+
# (user editing a new file); the runtime check is only that
|
|
101
|
+
# the BUFFER is readable, which `resolve_buffer_binding`
|
|
102
|
+
# has already enforced. For non-editor mode `file` must
|
|
103
|
+
# exist.
|
|
104
|
+
physical = buffer ? buffer.resolve(file) : file
|
|
105
|
+
return 1 unless file_exists?(buffer ? physical : file)
|
|
67
106
|
|
|
68
107
|
configuration = Configuration.load(options.fetch(:config))
|
|
69
|
-
source = File.read(
|
|
108
|
+
source = File.read(physical)
|
|
70
109
|
parse_result = Prism.parse(source, filepath: file, version: configuration.target_ruby)
|
|
71
110
|
return 1 if parse_errors?(parse_result, file)
|
|
72
111
|
|