rigortype 0.1.9 → 0.1.11

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 (158) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/rigor/analysis/baseline.rb +51 -15
  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 +57 -7
  9. data/lib/rigor/cli/baseline_command.rb +4 -3
  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.rb +88 -5
  15. data/lib/rigor/environment/rbs_loader.rb +46 -5
  16. data/lib/rigor/environment/reporters.rb +3 -2
  17. data/lib/rigor/environment.rb +159 -4
  18. data/lib/rigor/inference/def_return_typer.rb +98 -0
  19. data/lib/rigor/inference/expression_typer.rb +143 -12
  20. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
  21. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
  22. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
  23. data/lib/rigor/inference/precision_scanner.rb +131 -0
  24. data/lib/rigor/inference/statement_evaluator.rb +26 -2
  25. data/lib/rigor/mcp/loop.rb +43 -0
  26. data/lib/rigor/mcp/server.rb +263 -0
  27. data/lib/rigor/mcp.rb +16 -0
  28. data/lib/rigor/plugin/base.rb +28 -5
  29. data/lib/rigor/plugin/manifest.rb +33 -5
  30. data/lib/rigor/plugin/registry.rb +21 -0
  31. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
  32. data/lib/rigor/sig_gen/generator.rb +150 -75
  33. data/lib/rigor/type/combinator.rb +57 -0
  34. data/lib/rigor/version.rb +1 -1
  35. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  36. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  37. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  38. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  39. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  40. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
  41. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
  42. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
  43. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
  44. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  45. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
  46. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
  47. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
  48. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
  49. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  50. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  51. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  52. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  53. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  54. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  55. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
  56. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  57. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  58. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  59. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
  60. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
  61. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
  62. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  63. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  64. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  66. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  67. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  68. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  69. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
  70. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  71. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
  72. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  73. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  74. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  75. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  76. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  77. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  78. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  79. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  80. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  81. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  82. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  83. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  84. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  85. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  86. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  87. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  88. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  89. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  90. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  91. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  93. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  94. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  95. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  96. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  97. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  98. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  99. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  100. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  101. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  102. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  103. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  104. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  105. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  106. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  107. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
  108. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  109. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  110. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
  111. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  112. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
  113. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
  114. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
  115. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
  116. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  117. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  118. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  119. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  120. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  121. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  122. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  125. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  126. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  127. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  128. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  129. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  130. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  131. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  132. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  133. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  134. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  135. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  136. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  137. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  138. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  139. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  140. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  141. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  142. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  143. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  144. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  145. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  146. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  147. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  148. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  149. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  150. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  151. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  152. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  153. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  154. data/sig/rigor/analysis/baseline.rbs +39 -0
  155. data/sig/rigor/environment.rbs +3 -2
  156. data/sig/rigor/type.rbs +4 -0
  157. data/sig/rigor.rbs +2 -0
  158. metadata +180 -1
@@ -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
data/lib/rigor/cli.rb CHANGED
@@ -28,8 +28,11 @@ 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,
35
+ "playground" => :run_playground
33
36
  }.freeze
34
37
 
35
38
  def self.start(argv = ARGV, out: $stdout, err: $stderr)
@@ -81,7 +84,7 @@ module Rigor
81
84
  buffer = resolve_buffer_binding(options)
82
85
  return EXIT_USAGE if buffer == :usage_error
83
86
 
84
- configuration = Configuration.load(options.fetch(:config))
87
+ configuration = load_check_configuration(options)
85
88
  cache_root = configuration.cache_path
86
89
  handle_clear_cache(cache_root) if options.fetch(:clear_cache)
87
90
 
@@ -118,7 +121,7 @@ module Rigor
118
121
  return false
119
122
  end
120
123
 
121
- baseline = Analysis::Baseline.load(path)
124
+ baseline = Analysis::Baseline.load(path, project_root: Dir.pwd)
122
125
  return false if baseline.nil? || baseline.empty?
123
126
 
124
127
  drifted = baseline.audit(raw_diagnostics).reject { |row| row.status == :within }
@@ -161,7 +164,7 @@ module Rigor
161
164
  path = resolve_baseline_path(configuration, options)
162
165
  return result if path.nil?
163
166
 
164
- baseline = Analysis::Baseline.load(path)
167
+ baseline = Analysis::Baseline.load(path, project_root: Dir.pwd)
165
168
  return result if baseline.nil?
166
169
 
167
170
  surfaced, silenced_count = baseline.filter(result.diagnostics)
@@ -281,7 +284,16 @@ module Rigor
281
284
  baseline: :unset,
282
285
  # ADR-22 slice 5 — `--baseline-strict` CI gate: fail the
283
286
  # run on any baseline drift, in either direction.
284
- baseline_strict: false
287
+ baseline_strict: false,
288
+ # ADR-32 WD10 carry-over — `--treat-all-as-inline-rbs`
289
+ # forces the `rigor-rbs-inline` plugin into the loaded
290
+ # plugin set with `require_magic_comment: false` so a
291
+ # single ad-hoc `rigor check` invocation treats every
292
+ # analysed file as inline-RBS without the user editing
293
+ # `.rigor.yml`. Intended for single-file / ad-hoc CI use;
294
+ # ordinary projects should configure the plugin in
295
+ # `.rigor.yml`.
296
+ treat_all_as_inline_rbs: false
285
297
  }
286
298
  parser = OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
287
299
  opts.banner = "Usage: rigor check [options] [paths]"
@@ -319,11 +331,56 @@ module Rigor
319
331
  "ADR-22: fail the run on any baseline drift (CI gate)") do
320
332
  options[:baseline_strict] = true
321
333
  end
334
+ opts.on("--treat-all-as-inline-rbs",
335
+ "ADR-32: force-load rigor-rbs-inline with require_magic_comment: false") do
336
+ options[:treat_all_as_inline_rbs] = true
337
+ end
322
338
  end
323
339
  parser.parse!(@argv)
324
340
  options
325
341
  end
326
342
 
343
+ # ADR-32 WD10 carry-over — wraps `Configuration.load` so the
344
+ # CLI's `--treat-all-as-inline-rbs` flag can inject a
345
+ # `rigor-rbs-inline` plugin entry with
346
+ # `require_magic_comment: false` into the loaded plugin
347
+ # set. Re-runs the include-aware YAML load and applies the
348
+ # injection before `Configuration.new` so the new entry
349
+ # follows the normal coercion path. A pre-existing
350
+ # `rigor-rbs-inline` entry (by gem name or `id: rbs-inline`)
351
+ # is removed first so the synthesised entry's
352
+ # `require_magic_comment: false` wins unconditionally.
353
+ def load_check_configuration(options)
354
+ return Configuration.load(options.fetch(:config)) unless options.fetch(:treat_all_as_inline_rbs)
355
+
356
+ path = options.fetch(:config) || Configuration.discover
357
+ data = path && File.exist?(path) ? Configuration.load_with_includes(path) : {}
358
+ data = data.dup
359
+ data["plugins"] = inject_treat_all_as_inline_rbs(Array(data["plugins"]))
360
+ Configuration.new(Configuration::DEFAULTS.merge(data))
361
+ end
362
+
363
+ def inject_treat_all_as_inline_rbs(entries)
364
+ filtered = entries.reject { |entry| rigor_rbs_inline_entry?(entry) }
365
+ filtered + [{
366
+ "gem" => "rigor-rbs-inline",
367
+ "id" => "rbs-inline",
368
+ "config" => { "require_magic_comment" => false }
369
+ }]
370
+ end
371
+
372
+ def rigor_rbs_inline_entry?(entry)
373
+ case entry
374
+ when String
375
+ entry == "rigor-rbs-inline"
376
+ when Hash
377
+ string_keyed = entry.to_h { |k, v| [k.to_s, v] }
378
+ string_keyed["gem"] == "rigor-rbs-inline" || string_keyed["id"] == "rbs-inline"
379
+ else
380
+ false
381
+ end
382
+ end
383
+
327
384
  def handle_clear_cache(cache_root)
328
385
  if File.directory?(cache_root)
329
386
  FileUtils.rm_rf(cache_root)
@@ -516,6 +573,12 @@ module Rigor
516
573
  LspCommand.new(argv: @argv, out: @out, err: @err).run
517
574
  end
518
575
 
576
+ def run_mcp
577
+ require_relative "cli/mcp_command"
578
+
579
+ McpCommand.new(argv: @argv, out: @out, err: @err).run
580
+ end
581
+
519
582
  def run_baseline
520
583
  require_relative "cli/baseline_command"
521
584
 
@@ -528,6 +591,23 @@ module Rigor
528
591
  CLI::TriageCommand.new(argv: @argv, out: @out, err: @err).run
529
592
  end
530
593
 
594
+ def run_coverage
595
+ require_relative "cli/coverage_command"
596
+
597
+ CLI::CoverageCommand.new(argv: @argv, out: @out, err: @err).run
598
+ end
599
+
600
+ def run_playground
601
+ begin
602
+ require "rigor/playground"
603
+ rescue LoadError
604
+ @err.puts "rigor playground requires the rigor-playground gem."
605
+ @err.puts "Install it with: gem install rigor-playground"
606
+ return EXIT_USAGE
607
+ end
608
+ Rigor::CLI::PlaygroundCommand.new(@argv[1..], @out, @err).run
609
+ end
610
+
531
611
  def write_result(result, format)
532
612
  case format
533
613
  when "json"
@@ -570,7 +650,10 @@ module Rigor
570
650
  diff Compare current diagnostics to a saved baseline JSON
571
651
  sig-gen Emit RBS skeletons inferred from .rb sources (ADR-14)
572
652
  lsp Run the Rigor Language Server (LSP) over stdio
653
+ mcp Run the Rigor MCP server over stdio (ADR-33)
573
654
  triage Summarise diagnostics: distribution, hotspots, hints (ADR-23)
655
+ coverage Report type-precision coverage (precise vs Dynamic ratio)
656
+ playground Start the browser playground (requires rigor-playground gem)
574
657
  version Print the Rigor version
575
658
  help Print this help
576
659
  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).resolve_type_names
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
- def initialize(libraries: [], signature_paths: [], cache_store: nil)
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(libraries: @libraries, signature_paths: @signature_paths)
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