rigortype 0.1.19 → 0.2.1

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 (197) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -6
  3. data/data/core_overlay/numeric.rbs +33 -0
  4. data/data/core_overlay/pathname.rbs +25 -0
  5. data/data/core_overlay/string_scanner.rbs +28 -0
  6. data/data/gem_overlay/activesupport/core_ext.rbs +473 -0
  7. data/data/vendored_gem_sigs/ast/ast.rbs +130 -0
  8. data/data/vendored_gem_sigs/bcrypt/bcrypt.rbs +47 -0
  9. data/data/vendored_gem_sigs/bundler/bundler.rbs +238 -0
  10. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +34 -0
  11. data/data/vendored_gem_sigs/did_you_mean/did_you_mean_extras.rbs +34 -0
  12. data/data/vendored_gem_sigs/idn-ruby/idn.rbs +54 -0
  13. data/data/vendored_gem_sigs/mysql2/client.rbs +55 -0
  14. data/data/vendored_gem_sigs/mysql2/error.rbs +5 -0
  15. data/data/vendored_gem_sigs/mysql2/result.rbs +31 -0
  16. data/data/vendored_gem_sigs/mysql2/statement.rbs +5 -0
  17. data/data/vendored_gem_sigs/nokogiri/nokogiri.rbs +2332 -0
  18. data/data/vendored_gem_sigs/nokogiri/nokogiri_html5.rbs +47 -0
  19. data/data/vendored_gem_sigs/pg/pg.rbs +212 -0
  20. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +44 -0
  21. data/data/vendored_gem_sigs/redis/errors.rbs +50 -0
  22. data/data/vendored_gem_sigs/redis/future.rbs +5 -0
  23. data/data/vendored_gem_sigs/redis/redis.rbs +348 -0
  24. data/data/vendored_gem_sigs/redis/redis_extras.rbs +130 -0
  25. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +226 -0
  26. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -23
  27. data/lib/rigor/analysis/check_rules/rule_walk.rb +3 -21
  28. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  29. data/lib/rigor/analysis/check_rules.rb +492 -71
  30. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  31. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  32. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  33. data/lib/rigor/analysis/fact_store.rb +5 -4
  34. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  35. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +17 -17
  36. data/lib/rigor/analysis/runner/project_pre_passes.rb +9 -8
  37. data/lib/rigor/analysis/runner.rb +17 -6
  38. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  39. data/lib/rigor/analysis/worker_session.rb +10 -14
  40. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  41. data/lib/rigor/cache/store.rb +5 -3
  42. data/lib/rigor/cli/annotate_command.rb +28 -7
  43. data/lib/rigor/cli/baseline_command.rb +4 -3
  44. data/lib/rigor/cli/check_command.rb +138 -16
  45. data/lib/rigor/cli/coverage_command.rb +138 -31
  46. data/lib/rigor/cli/coverage_mutation.rb +149 -0
  47. data/lib/rigor/cli/coverage_scan.rb +57 -0
  48. data/lib/rigor/cli/explain_command.rb +2 -0
  49. data/lib/rigor/cli/fused_protection_renderer.rb +67 -0
  50. data/lib/rigor/cli/fused_protection_report.rb +76 -0
  51. data/lib/rigor/cli/lsp_command.rb +3 -7
  52. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  53. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  54. data/lib/rigor/cli/options.rb +9 -0
  55. data/lib/rigor/cli/plugins_command.rb +2 -1
  56. data/lib/rigor/cli/protection_renderer.rb +63 -0
  57. data/lib/rigor/cli/protection_report.rb +68 -0
  58. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  59. data/lib/rigor/cli/trace_command.rb +2 -1
  60. data/lib/rigor/cli/triage_command.rb +2 -1
  61. data/lib/rigor/cli/type_of_command.rb +1 -1
  62. data/lib/rigor/cli/type_scan_command.rb +2 -1
  63. data/lib/rigor/cli.rb +3 -2
  64. data/lib/rigor/config_audit.rb +152 -0
  65. data/lib/rigor/configuration/dependencies.rb +2 -4
  66. data/lib/rigor/configuration.rb +57 -7
  67. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  68. data/lib/rigor/environment/class_registry.rb +4 -3
  69. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  70. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  71. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  72. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  73. data/lib/rigor/environment/rbs_loader.rb +76 -5
  74. data/lib/rigor/environment.rb +66 -8
  75. data/lib/rigor/flow_contribution/fact.rb +1 -1
  76. data/lib/rigor/flow_contribution.rb +3 -5
  77. data/lib/rigor/inference/acceptance.rb +17 -9
  78. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  79. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  80. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  81. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  82. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  83. data/lib/rigor/inference/expression_typer.rb +20 -28
  84. data/lib/rigor/inference/hkt_body.rb +8 -11
  85. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  86. data/lib/rigor/inference/hkt_registry.rb +10 -11
  87. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  88. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +169 -24
  89. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  90. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  91. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  92. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  93. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  94. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  95. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  96. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +90 -15
  97. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  98. data/lib/rigor/inference/method_dispatcher.rb +40 -48
  99. data/lib/rigor/inference/mutation_widening.rb +5 -11
  100. data/lib/rigor/inference/narrowing.rb +14 -16
  101. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  102. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  103. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  104. data/lib/rigor/inference/protection_scanner.rb +86 -0
  105. data/lib/rigor/inference/scope_indexer.rb +129 -55
  106. data/lib/rigor/inference/statement_evaluator.rb +271 -114
  107. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  108. data/lib/rigor/inference/synthetic_method.rb +7 -7
  109. data/lib/rigor/language_server/completion_provider.rb +6 -12
  110. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  111. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  112. data/lib/rigor/language_server/hover_provider.rb +2 -3
  113. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  114. data/lib/rigor/language_server/server.rb +9 -17
  115. data/lib/rigor/language_server.rb +4 -5
  116. data/lib/rigor/plugin/base.rb +10 -8
  117. data/lib/rigor/plugin/macro/block_as_method.rb +3 -4
  118. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  119. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  120. data/lib/rigor/plugin/macro.rb +4 -5
  121. data/lib/rigor/plugin/manifest.rb +45 -66
  122. data/lib/rigor/plugin/registry.rb +6 -7
  123. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  124. data/lib/rigor/protection/diagnostic_oracle.rb +51 -0
  125. data/lib/rigor/protection/mutation_scanner.rb +180 -0
  126. data/lib/rigor/protection/mutator.rb +267 -0
  127. data/lib/rigor/protection/test_suite_oracle.rb +68 -0
  128. data/lib/rigor/rbs_extended.rb +24 -36
  129. data/lib/rigor/reflection.rb +4 -7
  130. data/lib/rigor/scope/discovery_index.rb +14 -2
  131. data/lib/rigor/scope.rb +54 -11
  132. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  133. data/lib/rigor/sig_gen/writer.rb +40 -2
  134. data/lib/rigor/signature_path_audit.rb +92 -0
  135. data/lib/rigor/source/constant_path.rb +62 -0
  136. data/lib/rigor/source.rb +1 -0
  137. data/lib/rigor/type/bound_method.rb +2 -11
  138. data/lib/rigor/type/combinator.rb +16 -3
  139. data/lib/rigor/type/constant.rb +2 -11
  140. data/lib/rigor/type/data_class.rb +2 -11
  141. data/lib/rigor/type/data_instance.rb +2 -11
  142. data/lib/rigor/type/hash_shape.rb +2 -11
  143. data/lib/rigor/type/integer_range.rb +2 -11
  144. data/lib/rigor/type/intersection.rb +2 -11
  145. data/lib/rigor/type/nominal.rb +2 -11
  146. data/lib/rigor/type/plain_lattice.rb +37 -0
  147. data/lib/rigor/type/refined.rb +72 -13
  148. data/lib/rigor/type/singleton.rb +2 -11
  149. data/lib/rigor/type/struct_class.rb +75 -0
  150. data/lib/rigor/type/struct_instance.rb +93 -0
  151. data/lib/rigor/type/tuple.rb +5 -15
  152. data/lib/rigor/type.rb +2 -0
  153. data/lib/rigor/version.rb +1 -1
  154. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  155. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  156. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +3 -3
  157. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  158. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  159. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +7 -10
  160. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  161. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  162. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +6 -8
  163. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  164. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +1 -2
  165. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  166. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  167. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  168. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  169. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  170. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  171. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  172. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +7 -9
  173. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  174. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  175. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +3 -3
  176. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  177. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  178. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  179. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +1 -1
  180. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  181. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  182. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +5 -5
  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 +19 -14
  187. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  188. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  189. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  190. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  191. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  192. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  193. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  194. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +28 -41
  195. data/sig/rigor/scope.rbs +9 -1
  196. data/sig/rigor/type.rbs +36 -1
  197. metadata +49 -1
@@ -1,14 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "English"
3
4
  require "optionparser"
4
5
  require "prism"
6
+ require "shellwords"
5
7
 
6
8
  require_relative "../configuration"
9
+ require_relative "options"
7
10
  require_relative "../environment"
8
11
  require_relative "../inference/precision_scanner"
12
+ require_relative "../inference/protection_scanner"
13
+ require_relative "../inference/parameter_inference_collector"
14
+ require_relative "../protection/mutation_scanner"
15
+ require_relative "../protection/test_suite_oracle"
16
+ require_relative "../language_server/project_context"
9
17
  require_relative "../scope"
10
18
  require_relative "coverage_report"
11
19
  require_relative "coverage_renderer"
20
+ require_relative "coverage_scan"
21
+ require_relative "protection_report"
22
+ require_relative "protection_renderer"
23
+ require_relative "mutation_protection_report"
24
+ require_relative "mutation_protection_renderer"
25
+ require_relative "fused_protection_report"
26
+ require_relative "fused_protection_renderer"
27
+ require_relative "coverage_mutation"
12
28
  require_relative "command"
13
29
 
14
30
  module Rigor
@@ -27,15 +43,28 @@ module Rigor
27
43
  # 1 — precision ratio < threshold, or parse errors encountered
28
44
  # 64 — usage error
29
45
  class CoverageCommand < Command
46
+ include CoverageMutation
47
+
30
48
  USAGE = "Usage: rigor coverage [options] PATH..."
31
49
 
50
+ # ADR-70 — the default test runner hook for `--with-tests`. The
51
+ # conventional Ruby test task; override with `--test-command`.
52
+ DEFAULT_TEST_COMMAND = %w[bundle exec rake].freeze
53
+
32
54
  # @return [Integer] CLI exit status.
33
55
  def run
34
56
  options = parse_options
57
+ return mutation_misuse_error if options[:mutation] && !options[:protection]
58
+ return with_tests_misuse_error if options[:with_tests] && !options[:mutation]
59
+ return include_dynamic_misuse_error if options[:include_dynamic] && !options[:with_tests]
60
+ return run_mutation_protection(options) if options[:mutation]
61
+
35
62
  paths = collect_paths(@argv, command_name: "coverage")
36
63
  return CLI::EXIT_USAGE if paths.nil?
37
64
  return usage_error if paths.empty?
38
65
 
66
+ return run_protection(paths, options) if options[:protection]
67
+
39
68
  report = scan_paths(paths, options)
40
69
  CoverageRenderer.new(out: @out).render(report, format: options.fetch(:format))
41
70
  determine_exit(report, options)
@@ -44,53 +73,131 @@ module Rigor
44
73
  private
45
74
 
46
75
  def parse_options
47
- options = { format: "text", threshold: nil, config: nil }
48
-
49
- OptionParser.new do |opts|
50
- opts.banner = USAGE
51
- 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 }
53
- opts.on(
54
- "--threshold=RATIO", Float,
55
- "Exit 1 when precision ratio is below RATIO (0.0–1.0)"
56
- ) { |v| options[:threshold] = v }
57
- end.parse!(@argv)
58
-
76
+ options = { format: "text", threshold: nil, config: nil, protection: false, mutation: false,
77
+ with_tests: false, test_command: DEFAULT_TEST_COMMAND, include_dynamic: false,
78
+ limit: nil, seed: 1 }
79
+ OptionParser.new { |opts| define_options(opts, options) }.parse!(@argv)
59
80
  options
60
81
  end
61
82
 
62
- def usage_error
63
- @err.puts("coverage: at least one path is required")
83
+ def define_options(opts, options)
84
+ opts.banner = USAGE
85
+ opts.on("--format=FORMAT", "Output format: text or json") { |v| options[:format] = v }
86
+ Options.add_config(opts, options)
87
+ opts.on("--protection", "Report type-protection coverage (ADR-63 Tier 1) instead of type precision") do
88
+ options[:protection] = true
89
+ end
90
+ define_mutation_options(opts, options)
91
+ opts.on("--threshold=RATIO", Float, "Exit 1 when the precision (or, with --protection, " \
92
+ "protection/effectiveness) ratio is below RATIO (0.0–1.0)") do |v|
93
+ options[:threshold] = v
94
+ end
95
+ end
96
+
97
+ def define_mutation_options(opts, options)
98
+ opts.on("--mutation", "With --protection: measure actual mutation effectiveness (ADR-63 Tier 2). " \
99
+ "Scopes to git-changed files when no paths are given; explicit paths override.") do
100
+ options[:mutation] = true
101
+ end
102
+ opts.on("--with-tests", "With --mutation: also measure dynamic (test-suite) protection (ADR-70). " \
103
+ "Runs --test-command against each type-survivor; reports the fused map.") do
104
+ options[:with_tests] = true
105
+ end
106
+ opts.on("--test-command=CMD", "The test runner hook for --with-tests " \
107
+ "(default: #{DEFAULT_TEST_COMMAND.join(' ')})") do |v|
108
+ options[:test_command] = Shellwords.split(v)
109
+ end
110
+ opts.on("--include-dynamic", "With --with-tests: also mutate Dynamic-receiver (untyped) sites, where a " \
111
+ "test is the only protection (ADR-69 Seam 2). Completes the map, runs more.") do
112
+ options[:include_dynamic] = true
113
+ end
114
+ opts.on("--limit=N", Integer,
115
+ "Sample at most N mutations/file under --mutation (caps cost; ratios become estimates)") do |v|
116
+ options[:limit] = v
117
+ end
118
+ opts.on("--seed=N", Integer, "RNG seed for --limit sampling (default 1)") { |v| options[:seed] = v }
119
+ end
120
+
121
+ def mutation_misuse_error
122
+ @err.puts("coverage: --mutation requires --protection")
64
123
  @err.puts(USAGE)
65
124
  CLI::EXIT_USAGE
66
125
  end
67
126
 
68
- def scan_paths(paths, options)
127
+ def with_tests_misuse_error
128
+ @err.puts("coverage: --with-tests requires --mutation (and --protection)")
129
+ @err.puts(USAGE)
130
+ CLI::EXIT_USAGE
131
+ end
132
+
133
+ def include_dynamic_misuse_error
134
+ @err.puts("coverage: --include-dynamic requires --with-tests (a Dynamic site's only protection is a test)")
135
+ @err.puts(USAGE)
136
+ CLI::EXIT_USAGE
137
+ end
138
+
139
+ def run_protection(paths, options)
140
+ report = scan_protection(paths, options)
141
+ ProtectionRenderer.new(out: @out).render(report, format: options.fetch(:format))
142
+ determine_protection_exit(report, options)
143
+ end
144
+
145
+ def scan_protection(paths, options)
69
146
  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
147
+ environment = project_environment(configuration)
148
+ scope = scope_with_inferred_params(paths, configuration, environment)
149
+ scanner = Inference::ProtectionScanner.new(scope: scope)
150
+ accumulator = ProtectionAccumulator.new
73
151
 
74
152
  paths.each { |path| scan_one(path, scanner, accumulator, configuration) }
75
- accumulator.to_report(paths, options)
153
+ accumulator.to_report
76
154
  end
77
155
 
78
- def project_environment(configuration)
79
- Environment.for_project(
80
- libraries: configuration.libraries,
81
- signature_paths: configuration.signature_paths
156
+ # ADR-67 WD3 — seed the call-site parameter-inference table so the
157
+ # protection scan counts an inferred-parameter receiver (e.g. `node.loc`
158
+ # where `node` is a `def compile(node)` parameter) as protected when its
159
+ # call sites resolve to concrete argument types. ONLY the parameter table
160
+ # is seeded — no cross-file discovery — so every site that does not gain
161
+ # an inferred parameter type is classified byte-identically to the
162
+ # un-inferred baseline. Collection spans the scanned `paths`.
163
+ def scope_with_inferred_params(paths, configuration, environment)
164
+ base = Scope.empty(environment: environment)
165
+ table = Inference::ParameterInferenceCollector.collect(
166
+ files: paths, environment: environment, target_ruby: configuration.target_ruby
82
167
  )
168
+ return base if table.empty?
169
+
170
+ base.with_discovery(base.discovery.with(param_inferred_types: table))
83
171
  end
84
172
 
85
- def scan_one(path, scanner, accumulator, configuration)
86
- source = File.read(path)
87
- parse_result = Prism.parse(source, filepath: path, version: configuration.target_ruby)
88
- if parse_result.errors.any?
89
- accumulator.record_parse_error(path, parse_result.errors)
90
- return
91
- end
173
+ def determine_protection_exit(report, options)
174
+ return 1 unless report.parse_errors.empty?
175
+
176
+ threshold = options[:threshold]
177
+ return 0 if threshold.nil?
178
+
179
+ report.ratio < threshold ? 1 : 0
180
+ end
92
181
 
93
- accumulator.absorb(path, scanner.scan(parse_result.value))
182
+ def usage_error
183
+ @err.puts("coverage: at least one path is required")
184
+ @err.puts(USAGE)
185
+ CLI::EXIT_USAGE
186
+ end
187
+
188
+ def scan_paths(paths, options)
189
+ CoverageScan.precision_report(files: paths, configuration: Configuration.load(options.fetch(:config)))
190
+ end
191
+
192
+ # Delegated to the shared scan module (see {CoverageScan}); the
193
+ # protection path below reuses both, and `rigor check --coverage`
194
+ # reuses `precision_report` over the same machinery.
195
+ def project_environment(configuration)
196
+ CoverageScan.project_environment(configuration)
197
+ end
198
+
199
+ def scan_one(path, scanner, accumulator, configuration)
200
+ CoverageScan.scan_into(path, scanner, accumulator, configuration)
94
201
  end
95
202
 
96
203
  def determine_exit(report, options)
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "prism"
5
+
6
+ module Rigor
7
+ class CLI
8
+ # ADR-63 Tier 2 + ADR-70 — the mutation-effectiveness and fused static∪dynamic
9
+ # protection paths, factored out of {CoverageCommand} to keep that command
10
+ # focused on dispatch. Mixed in, so each method runs in the command instance
11
+ # (using `@out` / `@err` / `@argv` / `collect_paths` / `determine_protection_exit`
12
+ # and the Protection + LanguageServer collaborators the command requires).
13
+ module CoverageMutation
14
+ private
15
+
16
+ # ADR-63 Tier 2 — the mutation-effectiveness deep dive. Builds the RBS
17
+ # environment + project pre-pass once (the warm loop), then re-analyses
18
+ # each target file's mutants against its clean baseline. Defaults to the
19
+ # git-changed `.rb` files; explicit paths override (and enable the
20
+ # whole-project opt-in, which is minutes).
21
+ def run_mutation_protection(options)
22
+ explicit = collect_paths(@argv, command_name: "coverage")
23
+ return CLI::EXIT_USAGE if explicit.nil?
24
+
25
+ target_files = explicit.empty? ? changed_ruby_files : explicit
26
+ if target_files.empty?
27
+ @out.puts("No changed Ruby files to measure — pass paths to measure explicitly.")
28
+ return 0
29
+ end
30
+
31
+ note_sampling(options)
32
+ return run_fused_protection(target_files, options) if options[:with_tests]
33
+
34
+ report = scan_mutation_protection(target_files, options)
35
+ MutationProtectionRenderer.new(out: @out).render(report, format: options.fetch(:format))
36
+ determine_protection_exit(report, options)
37
+ end
38
+
39
+ # A `--limit` sample makes the report an estimate (per-file ratios over a
40
+ # random N of the mutations). Say so on stderr — stdout stays clean for JSON.
41
+ def note_sampling(options)
42
+ return unless options[:limit]
43
+
44
+ @err.puts(
45
+ "coverage: sampling at most #{options[:limit]} mutations/file " \
46
+ "(seed #{options[:seed]}); ratios are estimates."
47
+ )
48
+ end
49
+
50
+ # ADR-70 — the fused static∪dynamic deep dive. The type pass is the ADR-63
51
+ # Tier 2 warm loop; each type-survivor is then run against the project's
52
+ # test suite (the runner hook). The suite MUST pass on clean code first, or
53
+ # "a mutant survived" is meaningless — abort with a clear message if not.
54
+ def run_fused_protection(paths, options)
55
+ configuration = Configuration.load(options.fetch(:config))
56
+ test_oracle = Protection::TestSuiteOracle.new(command: options.fetch(:test_command))
57
+ return suite_not_green_error(options) unless test_oracle.green?
58
+
59
+ context = LanguageServer::ProjectContext.new(configuration: configuration)
60
+ scanner = Protection::MutationScanner.new(
61
+ configuration: configuration, environment: context.environment, project_scan: context.project_scan,
62
+ limit: options[:limit], seed: options[:seed],
63
+ site_selector: options[:include_dynamic] ? :all : :biteable
64
+ )
65
+ accumulator = FusedProtectionAccumulator.new
66
+ paths.each { |path| scan_fused_one(path, scanner, accumulator, test_oracle, configuration) }
67
+ report = accumulator.to_report
68
+ FusedProtectionRenderer.new(out: @out).render(report, format: options.fetch(:format))
69
+ determine_protection_exit(report, options)
70
+ end
71
+
72
+ def scan_fused_one(path, scanner, accumulator, test_oracle, configuration)
73
+ source = File.read(path)
74
+ parse_result = Prism.parse(source, filepath: path, version: configuration.target_ruby)
75
+ if parse_result.errors.any?
76
+ accumulator.record_parse_error(path, parse_result.errors)
77
+ return
78
+ end
79
+
80
+ accumulator.absorb(scanner.scan_file_fused(path, source: source, test_oracle: test_oracle))
81
+ end
82
+
83
+ def suite_not_green_error(options)
84
+ @err.puts(
85
+ "coverage: the test suite must pass on clean code to measure test protection " \
86
+ "(ran: #{options.fetch(:test_command).join(' ')})"
87
+ )
88
+ @err.puts(
89
+ " the command must exit 0 on a clean tree. A non-zero exit on otherwise-passing " \
90
+ "tests also trips this — e.g. a SimpleCov coverage floor on a file-scoped run; " \
91
+ "point --test-command at a plain pass/fail runner."
92
+ )
93
+ 1
94
+ end
95
+
96
+ def scan_mutation_protection(paths, options)
97
+ configuration = Configuration.load(options.fetch(:config))
98
+ context = LanguageServer::ProjectContext.new(configuration: configuration)
99
+ scanner = Protection::MutationScanner.new(
100
+ configuration: configuration, environment: context.environment, project_scan: context.project_scan,
101
+ limit: options[:limit], seed: options[:seed]
102
+ )
103
+ accumulator = MutationProtectionAccumulator.new
104
+
105
+ paths.each { |path| scan_mutation_one(path, scanner, accumulator, configuration) }
106
+ accumulator.to_report
107
+ end
108
+
109
+ def scan_mutation_one(path, scanner, accumulator, configuration)
110
+ source = File.read(path)
111
+ parse_result = Prism.parse(source, filepath: path, version: configuration.target_ruby)
112
+ if parse_result.errors.any?
113
+ accumulator.record_parse_error(path, parse_result.errors)
114
+ return
115
+ end
116
+
117
+ accumulator.absorb(scanner.scan_file(path, source: source))
118
+ end
119
+
120
+ # The git-changed (modified / added / untracked) `.rb` files that exist on
121
+ # disk — the default Tier 2 scope. Returns [] outside a git work tree or
122
+ # when git is unavailable; the caller then reports "nothing to measure".
123
+ def changed_ruby_files
124
+ output = git_status_porcelain
125
+ return [] if output.nil?
126
+
127
+ output.each_line.filter_map { |line| changed_path(line) }.uniq.select { |p| File.file?(p) }
128
+ end
129
+
130
+ # Parse one `git status --porcelain` line (`XY <path>`, or `R old -> new`)
131
+ # into a candidate `.rb` path, or nil.
132
+ def changed_path(line)
133
+ path = line[3..]&.chomp
134
+ return nil if path.nil? || path.empty?
135
+
136
+ path = path.split(" -> ", 2).last if path.include?(" -> ")
137
+ path = path.delete_prefix('"').delete_suffix('"')
138
+ path.end_with?(".rb") ? path : nil
139
+ end
140
+
141
+ def git_status_porcelain
142
+ output = IO.popen(%w[git status --porcelain --untracked-files=all], err: File::NULL, &:read)
143
+ $CHILD_STATUS&.success? ? output : nil
144
+ rescue SystemCallError
145
+ nil
146
+ end
147
+ end
148
+ end
149
+ end
@@ -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
 
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rigor
6
+ class CLI
7
+ # Renders a {FusedProtectionReport} (ADR-70) as text or JSON. The text form
8
+ # leads with the fused protected ratio (caught by *either* a type or a test),
9
+ # splits it into the two axes, then lists the unprotected breakages ("add a
10
+ # type or a test here") and the least-protected files. The framing is always
11
+ # *where to add protection*, never "your code is broken".
12
+ class FusedProtectionRenderer
13
+ TOP_CALLS = 15
14
+ TOP_FILES = 10
15
+
16
+ def initialize(out:)
17
+ @out = out
18
+ end
19
+
20
+ def render(report, format:)
21
+ format == "json" ? render_json(report) : render_text(report)
22
+ end
23
+
24
+ private
25
+
26
+ def render_json(report)
27
+ @out.puts(JSON.pretty_generate(report.to_h))
28
+ end
29
+
30
+ def render_text(report)
31
+ pct = (report.ratio * 100).round(1)
32
+ @out.puts "Fused protection (static type ∪ dynamic test)"
33
+ @out.puts " protected: #{report.protected_total} / #{report.grand_total} (#{pct}%)"
34
+ @out.puts " by type: #{report.total_type_killed}"
35
+ @out.puts " by test: #{report.total_test_killed} (type-survivors a test caught)"
36
+ @out.puts " unprotected: #{report.total_unprotected} (neither — add a type or a test)"
37
+ render_unprotected(report)
38
+ render_files(report)
39
+ end
40
+
41
+ def render_unprotected(report)
42
+ unprotected = report.unprotected
43
+ return if unprotected.empty?
44
+
45
+ @out.puts "\nAdd protection here — breakages neither a type nor a test caught:"
46
+ unprotected.first(TOP_CALLS).each do |call|
47
+ @out.puts format(" %<count>-5d #%<method>s e.g. %<sites>s",
48
+ count: call.count, method: call.method_name, sites: call.examples.join(" "))
49
+ end
50
+ @out.puts " (#{unprotected.size - TOP_CALLS} more)" if unprotected.size > TOP_CALLS
51
+ end
52
+
53
+ def render_files(report)
54
+ worst = report.files.reject { |f| f.unprotected.zero? }.sort_by(&:ratio).first(TOP_FILES)
55
+ return if worst.empty?
56
+
57
+ @out.puts "\nLeast-protected files:"
58
+ worst.each do |file|
59
+ total = file.type_killed + file.test_killed + file.unprotected
60
+ protected_n = file.type_killed + file.test_killed
61
+ @out.puts format(" %<pct>5.1f%% %<path>s (%<n>d/%<total>d protected)",
62
+ pct: file.ratio * 100, path: file.path, n: protected_n, total: total)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class CLI
5
+ # ADR-70 — aggregates per-file {Protection::MutationScanner::FusedFileResult}
6
+ # into a project-level **fused** protection report: how many type-visible
7
+ # breakages were caught by the type checker, how many of the *type-survivors*
8
+ # were caught by the test suite, and which sites neither axis caught — the
9
+ # ranked "add a type OR a test here" list.
10
+ #
11
+ # Framing (ADR-63 / ADR-62 Criterion A, extended): the payload is the
12
+ # **attribution** — which protection axis is missing — never raw survival.
13
+ # An unprotected site is "add protection here", never "your code is broken".
14
+ FusedFileProtection = Data.define(:path, :type_killed, :test_killed, :unprotected, :ratio)
15
+ UnprotectedBreakage = Data.define(:method_name, :count, :examples)
16
+
17
+ FusedProtectionReport = Data.define(:files, :unprotected, :parse_errors) do
18
+ def total_type_killed = files.sum(&:type_killed)
19
+ def total_test_killed = files.sum(&:test_killed)
20
+ def total_unprotected = files.sum(&:unprotected)
21
+ def grand_total = total_type_killed + total_test_killed + total_unprotected
22
+ def protected_total = total_type_killed + total_test_killed
23
+ def ratio = grand_total.zero? ? 1.0 : protected_total.to_f / grand_total
24
+
25
+ def to_h
26
+ {
27
+ "mode" => "protection-fused",
28
+ "type_killed" => total_type_killed,
29
+ "test_killed" => total_test_killed,
30
+ "unprotected" => total_unprotected,
31
+ "protected_ratio" => ratio.round(4),
32
+ "files" => files.map do |f|
33
+ { "path" => f.path, "type_killed" => f.type_killed, "test_killed" => f.test_killed,
34
+ "unprotected" => f.unprotected, "ratio" => f.ratio.round(4) }
35
+ end,
36
+ "add_protection_here" => unprotected.map do |m|
37
+ { "method" => m.method_name, "count" => m.count, "examples" => m.examples }
38
+ end,
39
+ "parse_errors" => parse_errors
40
+ }
41
+ end
42
+ end
43
+
44
+ class FusedProtectionAccumulator
45
+ def initialize
46
+ @files = []
47
+ @unprotected = Hash.new { |h, k| h[k] = { count: 0, examples: [] } }
48
+ @parse_errors = []
49
+ end
50
+
51
+ def absorb(file_result)
52
+ @files << FusedFileProtection.new(
53
+ path: file_result.path, type_killed: file_result.type_killed,
54
+ test_killed: file_result.test_killed, unprotected: file_result.unprotected,
55
+ ratio: file_result.ratio
56
+ )
57
+ file_result.sites.each do |site|
58
+ bucket = @unprotected[site.method_name]
59
+ bucket[:count] += 1
60
+ bucket[:examples] << "#{file_result.path}:#{site.line}" if bucket[:examples].size < 3
61
+ end
62
+ end
63
+
64
+ def record_parse_error(path, errors)
65
+ @parse_errors << { "path" => path, "errors" => errors.size }
66
+ end
67
+
68
+ def to_report
69
+ unprotected = @unprotected
70
+ .map { |m, v| UnprotectedBreakage.new(method_name: m, count: v[:count], examples: v[:examples]) }
71
+ .sort_by { |m| [-m.count, m.method_name] }
72
+ FusedProtectionReport.new(files: @files, unprotected: unprotected, parse_errors: @parse_errors)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -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