rigortype 0.1.18 → 0.2.0

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 (210) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -224
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +32 -23
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +151 -23
  8. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  9. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
  10. data/lib/rigor/analysis/check_rules.rb +756 -132
  11. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  12. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  13. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  14. data/lib/rigor/analysis/diagnostic.rb +8 -0
  15. data/lib/rigor/analysis/fact_store.rb +5 -4
  16. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  17. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +19 -18
  18. data/lib/rigor/analysis/runner/project_pre_passes.rb +13 -9
  19. data/lib/rigor/analysis/runner.rb +75 -27
  20. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  21. data/lib/rigor/analysis/worker_session.rb +31 -25
  22. data/lib/rigor/bleeding_edge.rb +123 -0
  23. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  24. data/lib/rigor/cache/descriptor.rb +86 -8
  25. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  26. data/lib/rigor/cache/store.rb +5 -3
  27. data/lib/rigor/cli/annotate_command.rb +122 -16
  28. data/lib/rigor/cli/baseline_command.rb +4 -3
  29. data/lib/rigor/cli/check_command.rb +118 -16
  30. data/lib/rigor/cli/coverage_command.rb +148 -16
  31. data/lib/rigor/cli/coverage_scan.rb +57 -0
  32. data/lib/rigor/cli/explain_command.rb +2 -0
  33. data/lib/rigor/cli/lsp_command.rb +3 -7
  34. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  35. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  36. data/lib/rigor/cli/options.rb +9 -0
  37. data/lib/rigor/cli/plugins_command.rb +4 -5
  38. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  39. data/lib/rigor/cli/protection_renderer.rb +63 -0
  40. data/lib/rigor/cli/protection_report.rb +68 -0
  41. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  42. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  43. data/lib/rigor/cli/trace_command.rb +2 -1
  44. data/lib/rigor/cli/triage_command.rb +8 -4
  45. data/lib/rigor/cli/triage_renderer.rb +15 -1
  46. data/lib/rigor/cli/type_of_command.rb +1 -1
  47. data/lib/rigor/cli/type_scan_command.rb +2 -1
  48. data/lib/rigor/cli.rb +12 -3
  49. data/lib/rigor/configuration/dependencies.rb +2 -4
  50. data/lib/rigor/configuration/severity_profile.rb +13 -1
  51. data/lib/rigor/configuration.rb +100 -6
  52. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  53. data/lib/rigor/environment/class_registry.rb +4 -3
  54. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  55. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  56. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  57. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  58. data/lib/rigor/environment/rbs_loader.rb +74 -5
  59. data/lib/rigor/environment.rb +17 -7
  60. data/lib/rigor/flow_contribution/fact.rb +1 -1
  61. data/lib/rigor/flow_contribution.rb +3 -5
  62. data/lib/rigor/inference/acceptance.rb +17 -9
  63. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  64. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  65. data/lib/rigor/inference/budget_trace.rb +29 -2
  66. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  67. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  68. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  69. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  70. data/lib/rigor/inference/expression_typer.rb +1072 -71
  71. data/lib/rigor/inference/hkt_body.rb +8 -11
  72. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  73. data/lib/rigor/inference/hkt_registry.rb +10 -11
  74. data/lib/rigor/inference/macro_block_self_type.rb +2 -2
  75. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  76. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  77. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +210 -35
  78. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  79. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  80. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  81. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  82. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  83. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  84. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  85. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  86. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  87. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +237 -24
  88. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  89. data/lib/rigor/inference/method_dispatcher.rb +112 -49
  90. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  91. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  92. data/lib/rigor/inference/mutation_widening.rb +147 -11
  93. data/lib/rigor/inference/narrowing.rb +284 -53
  94. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  95. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  96. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  97. data/lib/rigor/inference/protection_scanner.rb +86 -0
  98. data/lib/rigor/inference/scope_indexer.rb +821 -76
  99. data/lib/rigor/inference/statement_evaluator.rb +1179 -102
  100. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  101. data/lib/rigor/inference/synthetic_method.rb +7 -7
  102. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  103. data/lib/rigor/language_server/completion_provider.rb +6 -12
  104. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  105. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  106. data/lib/rigor/language_server/hover_provider.rb +2 -3
  107. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  108. data/lib/rigor/language_server/server.rb +9 -17
  109. data/lib/rigor/language_server.rb +4 -5
  110. data/lib/rigor/plugin/base.rb +245 -87
  111. data/lib/rigor/plugin/macro/block_as_method.rb +25 -25
  112. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  113. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  114. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  115. data/lib/rigor/plugin/macro.rb +6 -8
  116. data/lib/rigor/plugin/manifest.rb +49 -90
  117. data/lib/rigor/plugin/node_rule_walk.rb +59 -14
  118. data/lib/rigor/plugin/registry.rb +18 -18
  119. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  120. data/lib/rigor/protection/mutation_scanner.rb +120 -0
  121. data/lib/rigor/protection/mutator.rb +246 -0
  122. data/lib/rigor/rbs_extended.rb +24 -36
  123. data/lib/rigor/reflection.rb +4 -7
  124. data/lib/rigor/scope/discovery_index.rb +16 -2
  125. data/lib/rigor/scope.rb +185 -16
  126. data/lib/rigor/sig_gen/generator.rb +8 -0
  127. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  128. data/lib/rigor/sig_gen/writer.rb +40 -2
  129. data/lib/rigor/source/constant_path.rb +62 -0
  130. data/lib/rigor/source.rb +1 -0
  131. data/lib/rigor/triage/catalogue.rb +4 -19
  132. data/lib/rigor/triage.rb +69 -1
  133. data/lib/rigor/type/bound_method.rb +2 -11
  134. data/lib/rigor/type/combinator.rb +45 -3
  135. data/lib/rigor/type/constant.rb +2 -11
  136. data/lib/rigor/type/data_class.rb +2 -11
  137. data/lib/rigor/type/data_instance.rb +2 -11
  138. data/lib/rigor/type/hash_shape.rb +2 -11
  139. data/lib/rigor/type/integer_range.rb +2 -11
  140. data/lib/rigor/type/intersection.rb +2 -11
  141. data/lib/rigor/type/nominal.rb +2 -11
  142. data/lib/rigor/type/plain_lattice.rb +37 -0
  143. data/lib/rigor/type/refined.rb +72 -13
  144. data/lib/rigor/type/singleton.rb +2 -11
  145. data/lib/rigor/type/struct_class.rb +75 -0
  146. data/lib/rigor/type/struct_instance.rb +93 -0
  147. data/lib/rigor/type/tuple.rb +5 -15
  148. data/lib/rigor/type.rb +2 -0
  149. data/lib/rigor/version.rb +1 -1
  150. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  151. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  152. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +16 -32
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  154. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +34 -100
  157. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  158. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  159. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  160. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +26 -27
  161. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  162. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +9 -8
  163. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  164. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  165. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  166. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  167. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  168. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  169. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  170. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +18 -49
  171. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  172. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  173. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +4 -4
  174. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  175. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  176. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  177. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  178. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +22 -35
  179. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  180. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  181. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +16 -23
  182. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  183. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
  184. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
  185. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  186. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +21 -27
  187. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  188. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  189. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  190. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  191. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  192. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  193. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  194. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  195. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  196. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  197. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +52 -40
  198. data/sig/rigor/analysis/fact_store.rbs +3 -0
  199. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  200. data/sig/rigor/plugin/base.rbs +5 -2
  201. data/sig/rigor/plugin/manifest.rbs +1 -2
  202. data/sig/rigor/scope.rbs +18 -1
  203. data/sig/rigor/type.rbs +37 -1
  204. data/sig/rigor.rbs +1 -1
  205. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  206. data/skills/rigor-plugin-author/SKILL.md +6 -4
  207. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  208. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  209. metadata +25 -2
  210. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -6,6 +6,8 @@ require "optionparser"
6
6
 
7
7
  require_relative "../configuration"
8
8
  require_relative "../analysis/result"
9
+ require_relative "../analysis/rule_catalog"
10
+ require_relative "coverage_scan"
9
11
  require_relative "command"
10
12
  require_relative "options"
11
13
  require_relative "diagnostic_formats"
@@ -35,6 +37,7 @@ module Rigor
35
37
  return CLI::EXIT_USAGE if buffer == :usage_error
36
38
 
37
39
  configuration = load_check_configuration(options)
40
+ configuration = apply_bleeding_edge_override(configuration, options)
38
41
  cache_root = configuration.cache_path
39
42
  handle_clear_cache(cache_root) if options.fetch(:clear_cache)
40
43
 
@@ -48,7 +51,8 @@ module Rigor
48
51
  raw_result = runner.run(@argv.empty? ? configuration.paths : @argv)
49
52
  result = apply_baseline_filter(raw_result, configuration, options)
50
53
 
51
- write_result(result, options.fetch(:format))
54
+ coverage = compute_coverage(runner, configuration, options)
55
+ write_result(result, options.fetch(:format), coverage: coverage)
52
56
  emit_ci_detected_output(result, options)
53
57
  write_run_stats(result.stats) if result.stats
54
58
  write_trace_appendices
@@ -276,29 +280,18 @@ module Rigor
276
280
 
277
281
  def parse_check_options # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
278
282
  options = {
279
- # `nil` triggers `Configuration.discover` (`.rigor.yml` then
280
- # `.rigor.dist.yml`); an explicit `--config=PATH` overrides.
281
283
  config: nil,
282
284
  format: "text",
283
285
  explain: false,
284
286
  cache_stats: false,
285
287
  clear_cache: false,
286
288
  no_cache: false,
287
- # Run-stats summary (target files, RBS class universe
288
- # breakdown, wall time, peak RSS) is on by default
289
- # because collection is ~free (single syscall for RSS,
290
- # one walk of `class_decl_paths` for the breakdown).
291
- # `--no-stats` suppresses it for callers that want a
292
- # diagnostic-only output stream.
293
289
  stats: true,
294
290
  # ADR-15 Phase 4c — when nil, falls back to
295
291
  # `RIGOR_RACTOR_WORKERS` then `.rigor.yml`
296
292
  # `parallel.workers:` then 0 (sequential). See
297
293
  # `resolve_workers` for the precedence chain.
298
294
  workers: nil,
299
- # Editor mode (`docs/design/20260516-editor-mode.md`).
300
- # Both must appear together; the runner uses the pair
301
- # to bind an in-flight buffer file to its logical path.
302
295
  tmp_file: nil,
303
296
  instead_of: nil,
304
297
  # ADR-22 — baseline filter. `:unset` means "fall through
@@ -335,17 +328,34 @@ module Rigor
335
328
  # the human output; for GitLab / reviewdog-routed CIs, print a
336
329
  # one-line hint. On by default; `--no-ci-detect` (or
337
330
  # `RIGOR_CI_DETECT=0`) disables it.
338
- ci_detect: true
331
+ ci_detect: true,
332
+ # ADR-50 § WD2 — the `--bleeding-edge[=ids]` / `--no-bleeding-edge`
333
+ # CLI mirror of the `bleeding_edge:` config key. `:unset` means "no
334
+ # flag — use the configured selection"; `true` adopts the whole
335
+ # overlay, `false` adopts none, and an Array of ids adopts only
336
+ # those (see `apply_bleeding_edge_override`).
337
+ bleeding_edge: :unset,
338
+ # Type-precision coverage block. Off by default — it is a
339
+ # second precision pass over the analyzed files (the same scan
340
+ # `rigor coverage` runs), so it is opt-in to keep the default
341
+ # check path's cost unchanged. When set, `--format json` gains
342
+ # a `coverage` object (scan_files + precision tiers) and the
343
+ # text output prints a one-line coverage summary.
344
+ coverage: false
339
345
  }
340
346
  parser = OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
341
347
  opts.banner = "Usage: rigor check [options] [paths]"
342
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
348
+ Options.add_config(opts, options)
343
349
  opts.on("--format=FORMAT",
344
350
  "Output format: text, json, sarif, github, gitlab, checkstyle, junit, teamcity") do |value|
345
351
  options[:format] = value
346
352
  end
347
353
  opts.on("--explain", "Surface fail-soft fallback events as :info diagnostics") { options[:explain] = true }
348
354
  opts.on("--cache-stats", "Print on-disk cache inventory at end of run") { options[:cache_stats] = true }
355
+ opts.on("--coverage",
356
+ "Add a type-precision coverage block (an extra precision pass over the analyzed files)") do
357
+ options[:coverage] = true
358
+ end
349
359
  opts.on("--clear-cache", "Remove the .rigor/cache directory before running") { options[:clear_cache] = true }
350
360
  opts.on("--no-cache", "Disable the persistent cache for this run") { options[:no_cache] = true }
351
361
  opts.on("--[no-]stats",
@@ -385,6 +395,18 @@ module Rigor
385
395
  "ADR-51: do not auto-emit CI-native output when a CI environment is detected") do
386
396
  options[:ci_detect] = false
387
397
  end
398
+ # ADR-50 § WD2 — `=[LIST]` (not ` [LIST]`) so a bare `--bleeding-edge`
399
+ # never swallows a following positional path: `rigor check
400
+ # --bleeding-edge lib` adopts the whole overlay and checks `lib`.
401
+ opts.on("--bleeding-edge=[LIST]",
402
+ "ADR-50: adopt the bleeding-edge overlay for this run " \
403
+ "(all features, or a comma-separated feature-id list)") do |value|
404
+ options[:bleeding_edge] = value.nil? || value.split(",").map(&:strip).reject(&:empty?)
405
+ end
406
+ opts.on("--no-bleeding-edge",
407
+ "ADR-50: ignore any configured bleeding_edge: selection for this run") do
408
+ options[:bleeding_edge] = false
409
+ end
388
410
  end
389
411
  parser.parse!(@argv)
390
412
  options
@@ -410,6 +432,20 @@ module Rigor
410
432
  Configuration.new(Configuration::DEFAULTS.merge(data))
411
433
  end
412
434
 
435
+ # ADR-50 § WD2 — applies the `--bleeding-edge[=ids]` / `--no-bleeding-edge`
436
+ # CLI selection over the configured `bleeding_edge:` value, mirroring the
437
+ # CLI-over-config precedence `--workers` and `--no-cache` follow. `:unset`
438
+ # (no flag) leaves the loaded configuration untouched; any other value is
439
+ # normalised by {Configuration#with_bleeding_edge}, so the two
440
+ # `SeverityProfile.resolve` sites (and the worker path, which receives the
441
+ # whole frozen Configuration) see the run's selection.
442
+ def apply_bleeding_edge_override(configuration, options)
443
+ selection = options.fetch(:bleeding_edge)
444
+ return configuration if selection == :unset
445
+
446
+ configuration.with_bleeding_edge(selection)
447
+ end
448
+
413
449
  def inject_treat_all_as_inline_rbs(entries)
414
450
  filtered = entries.reject { |entry| rigor_rbs_inline_entry?(entry) }
415
451
  filtered + [{
@@ -473,6 +509,9 @@ module Rigor
473
509
  @err.puts(" recursion-guard hits: #{counts[Inference::BudgetTrace::RECURSION_GUARD]}")
474
510
  @err.puts(" ancestor-walk-limit hits: #{counts[Inference::BudgetTrace::ANCESTOR_WALK_LIMIT]}")
475
511
  @err.puts(" hkt-fuel-exhausted hits: #{counts[Inference::BudgetTrace::HKT_FUEL_EXHAUSTED]}")
512
+ @err.puts(" recursion-unroll-fuel hits: #{counts[Inference::BudgetTrace::RECURSION_UNROLL_FUEL]}")
513
+ @err.puts(" recursion-fixpoint-cap hits: #{counts[Inference::BudgetTrace::RECURSION_FIXPOINT_CAP]}")
514
+ @err.puts(" block-writeback-cap hits: #{counts[Inference::BudgetTrace::BLOCK_WRITEBACK_CAP]}")
476
515
  write_budget_distributions
477
516
  end
478
517
 
@@ -632,12 +671,15 @@ module Rigor
632
671
  format("%.1f MiB", bytes / (1024.0 * 1024.0))
633
672
  end
634
673
 
635
- def write_result(result, format)
674
+ def write_result(result, format, coverage: nil)
636
675
  case format
637
676
  when "json"
638
- @out.puts(JSON.pretty_generate(result.to_h))
677
+ payload = enrich_json(result.to_h)
678
+ payload["coverage"] = coverage_payload(coverage) if coverage
679
+ @out.puts(JSON.pretty_generate(payload))
639
680
  when "text"
640
681
  write_text_result(result)
682
+ write_coverage_summary(coverage) if coverage
641
683
  when ->(fmt) { CLI::DiagnosticFormats.supports?(fmt) }
642
684
  # ADR-51 — CI-native renderings (SARIF / GitHub Actions commands /
643
685
  # GitLab Code Quality). The `github` form is empty when there are no
@@ -649,6 +691,66 @@ module Rigor
649
691
  end
650
692
  end
651
693
 
694
+ # Runs the type-precision scan (`--coverage`) over the same file set
695
+ # the check analyzed and returns a `CoverageReport`, or nil when the
696
+ # flag is off. It is a second pass — the same scan `rigor coverage`
697
+ # runs, reused via {CoverageScan} — so it is opt-in to keep the
698
+ # default check path's cost unchanged.
699
+ def compute_coverage(runner, configuration, options)
700
+ return nil unless options.fetch(:coverage)
701
+
702
+ files = @argv.empty? ? runner.analysis_file_set : runner.analysis_file_set(@argv)
703
+ CoverageScan.precision_report(files: files, configuration: configuration)
704
+ end
705
+
706
+ # The `coverage` block embedded in `--format json`. Mirrors the
707
+ # `summary` of `rigor coverage --format json` (the same vocabulary —
708
+ # `precise_ratio`, not a separate `typed_ratio`) plus `scan_files`,
709
+ # so a consumer reads one stream to learn both what fired and how
710
+ # much of the analyzed surface Rigor could type.
711
+ def coverage_payload(report)
712
+ {
713
+ "scan_files" => report.files.size - report.parse_errors.size,
714
+ "parse_errors" => report.parse_errors.size,
715
+ "expressions_typed" => report.grand_total,
716
+ "precise_count" => report.precise_count,
717
+ "precise_ratio" => report.precision_ratio.round(4),
718
+ "dynamic_opaque_count" => report.opaque_count,
719
+ "dynamic_opaque_ratio" => report.opaque_ratio.round(4)
720
+ }
721
+ end
722
+
723
+ def write_coverage_summary(report)
724
+ files = report.files.size - report.parse_errors.size
725
+ pct = (report.precision_ratio * 100).round(1)
726
+ @out.puts("Type coverage: #{files} file(s), #{pct}% precise " \
727
+ "(#{report.precise_count}/#{report.grand_total} expressions). " \
728
+ "Run `rigor coverage` for the full per-file / per-tier breakdown.")
729
+ end
730
+
731
+ # Adds the per-rule `evidence_tier` and `documentation_url` fields
732
+ # to each diagnostic in the `--format json` payload. Both are pure
733
+ # functions of the rule id (the rule catalogue, ADR-61 / the
734
+ # 2026-06-15 feedback §4 + §5.1), so they enrich the presentation
735
+ # layer here rather than threading through every diagnostic
736
+ # construction site. Only built-in rules carry catalogue metadata;
737
+ # plugin / `rbs_extended` / parse-error diagnostics are left
738
+ # untouched (they host their own documentation and confidence).
739
+ def enrich_json(payload)
740
+ Array(payload["diagnostics"]).each do |diag|
741
+ next unless diag["source_family"] == Analysis::Diagnostic::DEFAULT_SOURCE_FAMILY.to_s
742
+
743
+ rule = diag["rule"]
744
+ next unless rule
745
+
746
+ tier = Analysis::RuleCatalog.evidence_tier(rule)
747
+ diag["evidence_tier"] = tier.to_s if tier
748
+ url = Analysis::RuleCatalog.documentation_url(rule)
749
+ diag["documentation_url"] = url if url
750
+ end
751
+ payload
752
+ end
753
+
652
754
  # ADR-51 WD7 — CI auto-detection. Only augments the default human
653
755
  # (`text`) output: an explicit `--format` means the caller is in control
654
756
  # and is left untouched. For a first-class stdout-native CI (GitHub
@@ -1,14 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "English"
3
4
  require "optionparser"
4
5
  require "prism"
5
6
 
6
7
  require_relative "../configuration"
8
+ require_relative "options"
7
9
  require_relative "../environment"
8
10
  require_relative "../inference/precision_scanner"
11
+ require_relative "../inference/protection_scanner"
12
+ require_relative "../inference/parameter_inference_collector"
13
+ require_relative "../protection/mutation_scanner"
14
+ require_relative "../language_server/project_context"
9
15
  require_relative "../scope"
10
16
  require_relative "coverage_report"
11
17
  require_relative "coverage_renderer"
18
+ require_relative "coverage_scan"
19
+ require_relative "protection_report"
20
+ require_relative "protection_renderer"
21
+ require_relative "mutation_protection_report"
22
+ require_relative "mutation_protection_renderer"
12
23
  require_relative "command"
13
24
 
14
25
  module Rigor
@@ -32,10 +43,15 @@ module Rigor
32
43
  # @return [Integer] CLI exit status.
33
44
  def run
34
45
  options = parse_options
46
+ return mutation_misuse_error if options[:mutation] && !options[:protection]
47
+ return run_mutation_protection(options) if options[:mutation]
48
+
35
49
  paths = collect_paths(@argv, command_name: "coverage")
36
50
  return CLI::EXIT_USAGE if paths.nil?
37
51
  return usage_error if paths.empty?
38
52
 
53
+ return run_protection(paths, options) if options[:protection]
54
+
39
55
  report = scan_paths(paths, options)
40
56
  CoverageRenderer.new(out: @out).render(report, format: options.fetch(:format))
41
57
  determine_exit(report, options)
@@ -44,45 +60,112 @@ module Rigor
44
60
  private
45
61
 
46
62
  def parse_options
47
- options = { format: "text", threshold: nil, config: nil }
63
+ options = { format: "text", threshold: nil, config: nil, protection: false, mutation: false }
48
64
 
49
65
  OptionParser.new do |opts|
50
66
  opts.banner = USAGE
51
67
  opts.on("--format=FORMAT", "Output format: text or json") { |v| options[:format] = v }
52
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
68
+ Options.add_config(opts, options)
69
+ opts.on(
70
+ "--protection",
71
+ "Report type-protection coverage (ADR-63 Tier 1) instead of type precision"
72
+ ) { options[:protection] = true }
73
+ opts.on(
74
+ "--mutation",
75
+ "With --protection: measure actual mutation effectiveness (ADR-63 Tier 2). " \
76
+ "Scopes to git-changed files when no paths are given; explicit paths override."
77
+ ) { options[:mutation] = true }
53
78
  opts.on(
54
79
  "--threshold=RATIO", Float,
55
- "Exit 1 when precision ratio is below RATIO (0.0–1.0)"
80
+ "Exit 1 when the precision (or, with --protection, protection/effectiveness) ratio is below RATIO (0.0–1.0)"
56
81
  ) { |v| options[:threshold] = v }
57
82
  end.parse!(@argv)
58
83
 
59
84
  options
60
85
  end
61
86
 
62
- def usage_error
63
- @err.puts("coverage: at least one path is required")
87
+ def mutation_misuse_error
88
+ @err.puts("coverage: --mutation requires --protection")
64
89
  @err.puts(USAGE)
65
90
  CLI::EXIT_USAGE
66
91
  end
67
92
 
68
- def scan_paths(paths, options)
93
+ def run_protection(paths, options)
94
+ report = scan_protection(paths, options)
95
+ ProtectionRenderer.new(out: @out).render(report, format: options.fetch(:format))
96
+ determine_protection_exit(report, options)
97
+ end
98
+
99
+ def scan_protection(paths, options)
69
100
  configuration = Configuration.load(options.fetch(:config))
70
- scope = Scope.empty(environment: project_environment(configuration))
71
- scanner = Inference::PrecisionScanner.new(scope: scope)
72
- accumulator = CoverageAccumulator.new
101
+ environment = project_environment(configuration)
102
+ scope = scope_with_inferred_params(paths, configuration, environment)
103
+ scanner = Inference::ProtectionScanner.new(scope: scope)
104
+ accumulator = ProtectionAccumulator.new
73
105
 
74
106
  paths.each { |path| scan_one(path, scanner, accumulator, configuration) }
75
- accumulator.to_report(paths, options)
107
+ accumulator.to_report
76
108
  end
77
109
 
78
- def project_environment(configuration)
79
- Environment.for_project(
80
- libraries: configuration.libraries,
81
- signature_paths: configuration.signature_paths
110
+ # ADR-67 WD3 — seed the call-site parameter-inference table so the
111
+ # protection scan counts an inferred-parameter receiver (e.g. `node.loc`
112
+ # where `node` is a `def compile(node)` parameter) as protected when its
113
+ # call sites resolve to concrete argument types. ONLY the parameter table
114
+ # is seeded — no cross-file discovery — so every site that does not gain
115
+ # an inferred parameter type is classified byte-identically to the
116
+ # un-inferred baseline. Collection spans the scanned `paths`.
117
+ def scope_with_inferred_params(paths, configuration, environment)
118
+ base = Scope.empty(environment: environment)
119
+ table = Inference::ParameterInferenceCollector.collect(
120
+ files: paths, environment: environment, target_ruby: configuration.target_ruby
82
121
  )
122
+ return base if table.empty?
123
+
124
+ base.with_discovery(base.discovery.with(param_inferred_types: table))
83
125
  end
84
126
 
85
- def scan_one(path, scanner, accumulator, configuration)
127
+ def determine_protection_exit(report, options)
128
+ return 1 unless report.parse_errors.empty?
129
+
130
+ threshold = options[:threshold]
131
+ return 0 if threshold.nil?
132
+
133
+ report.ratio < threshold ? 1 : 0
134
+ end
135
+
136
+ # ADR-63 Tier 2 — the mutation-effectiveness deep dive. Builds the RBS
137
+ # environment + project pre-pass once (the warm loop), then re-analyses
138
+ # each target file's mutants against its clean baseline. Defaults to the
139
+ # git-changed `.rb` files; explicit paths override (and enable the
140
+ # whole-project opt-in, which is minutes).
141
+ def run_mutation_protection(options)
142
+ explicit = collect_paths(@argv, command_name: "coverage")
143
+ return CLI::EXIT_USAGE if explicit.nil?
144
+
145
+ target_files = explicit.empty? ? changed_ruby_files : explicit
146
+ if target_files.empty?
147
+ @out.puts("No changed Ruby files to measure — pass paths to measure explicitly.")
148
+ return 0
149
+ end
150
+
151
+ report = scan_mutation_protection(target_files, options)
152
+ MutationProtectionRenderer.new(out: @out).render(report, format: options.fetch(:format))
153
+ determine_protection_exit(report, options)
154
+ end
155
+
156
+ def scan_mutation_protection(paths, options)
157
+ configuration = Configuration.load(options.fetch(:config))
158
+ context = LanguageServer::ProjectContext.new(configuration: configuration)
159
+ scanner = Protection::MutationScanner.new(
160
+ configuration: configuration, environment: context.environment, project_scan: context.project_scan
161
+ )
162
+ accumulator = MutationProtectionAccumulator.new
163
+
164
+ paths.each { |path| scan_mutation_one(path, scanner, accumulator, configuration) }
165
+ accumulator.to_report
166
+ end
167
+
168
+ def scan_mutation_one(path, scanner, accumulator, configuration)
86
169
  source = File.read(path)
87
170
  parse_result = Prism.parse(source, filepath: path, version: configuration.target_ruby)
88
171
  if parse_result.errors.any?
@@ -90,7 +173,56 @@ module Rigor
90
173
  return
91
174
  end
92
175
 
93
- accumulator.absorb(path, scanner.scan(parse_result.value))
176
+ accumulator.absorb(scanner.scan_file(path, source: source))
177
+ end
178
+
179
+ # The git-changed (modified / added / untracked) `.rb` files that exist on
180
+ # disk — the default Tier 2 scope. Returns [] outside a git work tree or
181
+ # when git is unavailable; the caller then reports "nothing to measure".
182
+ def changed_ruby_files
183
+ output = git_status_porcelain
184
+ return [] if output.nil?
185
+
186
+ output.each_line.filter_map { |line| changed_path(line) }.uniq.select { |p| File.file?(p) }
187
+ end
188
+
189
+ # Parse one `git status --porcelain` line (`XY <path>`, or `R old -> new`)
190
+ # into a candidate `.rb` path, or nil.
191
+ def changed_path(line)
192
+ path = line[3..]&.chomp
193
+ return nil if path.nil? || path.empty?
194
+
195
+ path = path.split(" -> ", 2).last if path.include?(" -> ")
196
+ path = path.delete_prefix('"').delete_suffix('"')
197
+ path.end_with?(".rb") ? path : nil
198
+ end
199
+
200
+ def git_status_porcelain
201
+ output = IO.popen(%w[git status --porcelain --untracked-files=all], err: File::NULL, &:read)
202
+ $CHILD_STATUS&.success? ? output : nil
203
+ rescue SystemCallError
204
+ nil
205
+ end
206
+
207
+ def usage_error
208
+ @err.puts("coverage: at least one path is required")
209
+ @err.puts(USAGE)
210
+ CLI::EXIT_USAGE
211
+ end
212
+
213
+ def scan_paths(paths, options)
214
+ CoverageScan.precision_report(files: paths, configuration: Configuration.load(options.fetch(:config)))
215
+ end
216
+
217
+ # Delegated to the shared scan module (see {CoverageScan}); the
218
+ # protection path below reuses both, and `rigor check --coverage`
219
+ # reuses `precision_report` over the same machinery.
220
+ def project_environment(configuration)
221
+ CoverageScan.project_environment(configuration)
222
+ end
223
+
224
+ def scan_one(path, scanner, accumulator, configuration)
225
+ CoverageScan.scan_into(path, scanner, accumulator, configuration)
94
226
  end
95
227
 
96
228
  def determine_exit(report, options)
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../configuration"
6
+ require_relative "../environment"
7
+ require_relative "../inference/precision_scanner"
8
+ require_relative "../scope"
9
+ require_relative "coverage_report"
10
+
11
+ module Rigor
12
+ class CLI
13
+ # Shared type-precision scan behind both `rigor coverage` (the
14
+ # dedicated command) and `rigor check --coverage` (the in-run
15
+ # coverage block). Walks each file's AST, types every expression via
16
+ # `Scope#type_of`, and accumulates the precision-tier breakdown into a
17
+ # `CoverageReport`. Extracted so the two surfaces stay byte-identical
18
+ # on the same file set.
19
+ module CoverageScan
20
+ module_function
21
+
22
+ # @param files [Array<String>] explicit `.rb` file paths to scan.
23
+ # @param configuration [Rigor::Configuration]
24
+ # @return [Rigor::CLI::CoverageReport]
25
+ def precision_report(files:, configuration:)
26
+ scope = Scope.empty(environment: project_environment(configuration))
27
+ scanner = Inference::PrecisionScanner.new(scope: scope)
28
+ accumulator = CoverageAccumulator.new
29
+ files.each { |path| scan_into(path, scanner, accumulator, configuration) }
30
+ accumulator.to_report(files, {})
31
+ end
32
+
33
+ def project_environment(configuration)
34
+ Environment.for_project(
35
+ libraries: configuration.libraries,
36
+ signature_paths: configuration.signature_paths
37
+ )
38
+ end
39
+
40
+ # Parses one file and feeds the scan result (or a parse-error
41
+ # record) into `accumulator`. `scanner` / `accumulator` are a
42
+ # matched pair — a `PrecisionScanner` + `CoverageAccumulator`, or a
43
+ # `ProtectionScanner` + `ProtectionAccumulator` — both of which
44
+ # respond to `scan(node)` and `absorb(path, result)`.
45
+ def scan_into(path, scanner, accumulator, configuration)
46
+ source = File.read(path)
47
+ parse_result = Prism.parse(source, filepath: path, version: configuration.target_ruby)
48
+ if parse_result.errors.any?
49
+ accumulator.record_parse_error(path, parse_result.errors)
50
+ return
51
+ end
52
+
53
+ accumulator.absorb(path, scanner.scan(parse_result.value))
54
+ end
55
+ end
56
+ end
57
+ end
@@ -95,6 +95,7 @@ module Rigor
95
95
  render_section("Fires when:", entry.fires_when)
96
96
  render_section("Does not fire when:", entry.does_not_fire_when)
97
97
  @out.puts("Suppression: #{entry.suppression}")
98
+ @out.puts("Documentation: #{entry.documentation_url}")
98
99
  @out.puts("Since: rigor #{entry.since}")
99
100
  end
100
101
 
@@ -109,6 +110,7 @@ module Rigor
109
110
  @out.puts("Authored severity: :#{entry.severity_authored}")
110
111
  profile_table = entry.severity_by_profile.map { |profile, sev| "#{profile} → :#{sev}" }.join(", ")
111
112
  @out.puts("Severity by profile: #{profile_table}")
113
+ @out.puts("Evidence tier: #{entry.evidence_tier || 'n/a (informational)'}")
112
114
  @out.puts("")
113
115
  end
114
116
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "command"
4
+ require_relative "options"
4
5
 
5
6
  require "optionparser"
6
7
 
@@ -8,11 +9,8 @@ module Rigor
8
9
  class CLI
9
10
  # Executes the `rigor lsp` command.
10
11
  #
12
+ # Starts a long-running LSP server over stdio (JSON-RPC).
11
13
  # See `docs/design/20260517-language-server.md` for the design.
12
- # Slice 1 (this commit) ships the CLI subcommand entry point.
13
- # The actual stdio JSON-RPC reader / writer is queued for slice 2;
14
- # invoking `rigor lsp` at slice 1 returns immediately after
15
- # validating the transport flag.
16
14
  class LspCommand < Command
17
15
  USAGE = "Usage: rigor lsp [options]"
18
16
 
@@ -109,9 +107,7 @@ module Rigor
109
107
  opts.on("--log=PATH", "Write LSP wire log + server debug to PATH (default: stderr)") do |value|
110
108
  options[:log] = value
111
109
  end
112
- opts.on("--config=PATH", "Path to the Rigor configuration file") do |value|
113
- options[:config] = value
114
- end
110
+ Options.add_config(opts, options)
115
111
  end
116
112
  parser.parse!(@argv)
117
113
  options
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rigor
6
+ class CLI
7
+ # Renders a {MutationProtectionReport} (ADR-63 Tier 2) as text or JSON. The
8
+ # text form leads with the effectiveness ratio (caught breakages), then the
9
+ # breakages Rigor missed ("add a type here"), then the least-effective files.
10
+ # The framing is always *where to add a type*, never "your code is broken".
11
+ class MutationProtectionRenderer
12
+ TOP_CALLS = 15
13
+ TOP_FILES = 10
14
+
15
+ def initialize(out:)
16
+ @out = out
17
+ end
18
+
19
+ def render(report, format:)
20
+ format == "json" ? render_json(report) : render_text(report)
21
+ end
22
+
23
+ private
24
+
25
+ def render_json(report)
26
+ @out.puts(JSON.pretty_generate(report.to_h))
27
+ end
28
+
29
+ def render_text(report)
30
+ pct = (report.ratio * 100).round(1)
31
+ @out.puts "Type-protection effectiveness (Tier 2 — mutation kill rate)"
32
+ @out.puts " caught breakages: #{report.total_killed} / #{report.grand_total} (#{pct}%)"
33
+ @out.puts " (effectiveness = when a type-visible bug was introduced, Rigor caught it)"
34
+ render_missed(report)
35
+ render_files(report)
36
+ end
37
+
38
+ def render_missed(report)
39
+ missed = report.missed
40
+ return if missed.empty?
41
+
42
+ @out.puts "\nAdd a type here — breakages Rigor missed (a wrong call that stayed silent):"
43
+ missed.first(TOP_CALLS).each do |call|
44
+ @out.puts format(" %<count>-5d #%<method>s e.g. %<sites>s",
45
+ count: call.count, method: call.method_name, sites: call.examples.join(" "))
46
+ end
47
+ @out.puts " (#{missed.size - TOP_CALLS} more)" if missed.size > TOP_CALLS
48
+ end
49
+
50
+ def render_files(report)
51
+ worst = report.files.reject { |f| f.survived.zero? }.sort_by(&:ratio).first(TOP_FILES)
52
+ return if worst.empty?
53
+
54
+ @out.puts "\nLeast-effective files:"
55
+ worst.each do |file|
56
+ total = file.killed + file.survived
57
+ @out.puts format(" %<pct>5.1f%% %<path>s (%<n>d/%<total>d breakages caught)",
58
+ pct: file.ratio * 100, path: file.path, n: file.killed, total: total)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end