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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -513
  3. data/lib/rigor/analysis/check_rules.rb +20 -0
  4. data/lib/rigor/analysis/runner.rb +67 -9
  5. data/lib/rigor/analysis/worker_session.rb +13 -4
  6. data/lib/rigor/cache/rbs_descriptor.rb +21 -2
  7. data/lib/rigor/cache/rbs_environment.rb +2 -1
  8. data/lib/rigor/cli/annotate_command.rb +274 -0
  9. data/lib/rigor/cli/baseline_command.rb +36 -16
  10. data/lib/rigor/cli/coverage_command.rb +126 -0
  11. data/lib/rigor/cli/coverage_renderer.rb +162 -0
  12. data/lib/rigor/cli/coverage_report.rb +75 -0
  13. data/lib/rigor/cli/mcp_command.rb +70 -0
  14. data/lib/rigor/cli/prism_colorizer.rb +111 -0
  15. data/lib/rigor/cli.rb +134 -6
  16. data/lib/rigor/environment/rbs_loader.rb +46 -5
  17. data/lib/rigor/environment/reporters.rb +3 -2
  18. data/lib/rigor/environment.rb +168 -5
  19. data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
  20. data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
  21. data/lib/rigor/inference/def_return_typer.rb +98 -0
  22. data/lib/rigor/inference/expression_typer.rb +308 -18
  23. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
  24. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +178 -10
  25. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
  26. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
  27. data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
  28. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
  29. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
  30. data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
  31. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +431 -9
  32. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
  33. data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
  34. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
  35. data/lib/rigor/inference/method_dispatcher.rb +148 -1
  36. data/lib/rigor/inference/method_parameter_binder.rb +67 -10
  37. data/lib/rigor/inference/narrowing.rb +29 -10
  38. data/lib/rigor/inference/precision_scanner.rb +131 -0
  39. data/lib/rigor/inference/statement_evaluator.rb +29 -3
  40. data/lib/rigor/mcp/loop.rb +43 -0
  41. data/lib/rigor/mcp/server.rb +263 -0
  42. data/lib/rigor/mcp.rb +16 -0
  43. data/lib/rigor/plugin/base.rb +67 -5
  44. data/lib/rigor/plugin/loader.rb +22 -1
  45. data/lib/rigor/plugin/manifest.rb +101 -10
  46. data/lib/rigor/plugin/protocol_contract.rb +185 -0
  47. data/lib/rigor/plugin/registry.rb +87 -0
  48. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
  49. data/lib/rigor/sig_gen/generator.rb +150 -75
  50. data/lib/rigor/triage/catalogue.rb +2 -2
  51. data/lib/rigor/type/combinator.rb +57 -0
  52. data/lib/rigor/type/constant.rb +29 -2
  53. data/lib/rigor/version.rb +1 -1
  54. data/sig/rigor/analysis/baseline.rbs +39 -0
  55. data/sig/rigor/environment.rbs +3 -2
  56. data/sig/rigor/type.rbs +4 -0
  57. data/sig/rigor.rbs +2 -0
  58. metadata +42 -1
@@ -9,22 +9,22 @@ require_relative "../configuration"
9
9
 
10
10
  module Rigor
11
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`.
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 overwrite.")
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: wrote baseline to #{path} " \
76
- "(#{bucket_count} bucket(s) covering #{diagnostic_count} diagnostic(s); " \
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 generate [options]"
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
- opts.on("--force", "Overwrite an existing baseline file") { options[:force] = true }
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; consider `regenerate` (slice 5)."
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