rigortype 0.2.0 → 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 (43) 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/cli/check_command.rb +25 -2
  27. data/lib/rigor/cli/coverage_command.rb +67 -92
  28. data/lib/rigor/cli/coverage_mutation.rb +149 -0
  29. data/lib/rigor/cli/fused_protection_renderer.rb +67 -0
  30. data/lib/rigor/cli/fused_protection_report.rb +76 -0
  31. data/lib/rigor/config_audit.rb +152 -0
  32. data/lib/rigor/configuration.rb +12 -0
  33. data/lib/rigor/environment/rbs_loader.rb +27 -0
  34. data/lib/rigor/environment.rb +49 -1
  35. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +17 -7
  36. data/lib/rigor/inference/statement_evaluator.rb +27 -0
  37. data/lib/rigor/protection/diagnostic_oracle.rb +51 -0
  38. data/lib/rigor/protection/mutation_scanner.rb +98 -38
  39. data/lib/rigor/protection/mutator.rb +21 -0
  40. data/lib/rigor/protection/test_suite_oracle.rb +68 -0
  41. data/lib/rigor/signature_path_audit.rb +92 -0
  42. data/lib/rigor/version.rb +1 -1
  43. metadata +31 -1
@@ -0,0 +1,226 @@
1
+ #
2
+ # Rigor-side supplement to the rbs gem's
3
+ # `core/rubygems/*.rbs` declarations.
4
+ #
5
+ # Re-opens the rubygems classes / Gem module to ADD the
6
+ # declarations the upstream RBS omits. Every block below adds
7
+ # new method signatures only — it does NOT redeclare anything
8
+ # the upstream RBS already covers, so the loader does not raise
9
+ # `RBS::DuplicatedDeclarationError`.
10
+ #
11
+ # Driven by the `references/ruby/lib` survey
12
+ # (`docs/CURRENT_WORK.md` § "Queued engine items") which
13
+ # clusters `undefined-method` on Gem-side selectors most-touched
14
+ # by rubygems' own source tree and by bundler. Returns are
15
+ # `untyped` where Rigor cannot infer a precise shape — the goal
16
+ # is to silence false positives, not to author precise sigs.
17
+ #
18
+ module Gem
19
+ # Singleton additions surfacing in the survey but absent from
20
+ # the upstream `core/rubygems/rubygems.rbs`. All are real
21
+ # methods defined in `lib/rubygems/defaults.rb`,
22
+ # `lib/rubygems.rb`, or the platform-specific override files.
23
+ def self.target_rbconfig: () -> untyped
24
+ def self.set_target_rbconfig: (untyped) -> untyped
25
+ def self.load_safe_marshal: () -> untyped
26
+ def self.safe_load_marshal: (untyped) -> untyped
27
+ def self.verbose: () -> bool
28
+ def self.really_verbose: () -> bool
29
+ def self.discover_gems_on_require: () -> bool
30
+ def self.discover_gems_on_require=: (bool) -> bool
31
+ def self.default_user_install: () -> bool
32
+ def self.dynamic_library_suffixes: () -> Array[String]
33
+ def self.extension_api_version: () -> String
34
+ def self.find_default_spec: (untyped) -> untyped?
35
+ def self.install_extension_in_lib: () -> bool
36
+ def self.load_bundler_extensions: () -> untyped
37
+ def self.load_plugin_files: (?untyped plugins) -> untyped
38
+ def self.open_file: (String path, String mode) { (IO) -> untyped } -> untyped
39
+ def self.open_file_with_lock: (String path) { (IO) -> untyped } -> untyped
40
+ def self.state_file: () -> String
41
+ def self.vendor_dir: () -> String
42
+ def self.activate_bin_path: (String name, ?String exec_name, ?untyped requirements) -> String
43
+ end
44
+
45
+ class Gem::Platform
46
+ # `Gem::Platform.new(arg)` is the canonical constructor; the
47
+ # upstream `core/rubygems/platform.rbs` declares the class
48
+ # but no `initialize`, which forces RBS to default to
49
+ # `(): void` and the wrong-arity rule fires for every call
50
+ # site. The accessor / predicate set covers the most-used
51
+ # surface bundler and rubygems internals touch.
52
+ def initialize: (*untyped) -> void
53
+ attr_accessor cpu: String?
54
+ attr_accessor os: String?
55
+ attr_accessor version: String?
56
+ def to_s: () -> String
57
+ def ==: (untyped) -> bool
58
+ def ===: (untyped) -> bool
59
+ def =~: (untyped) -> bool?
60
+ def eql?: (untyped) -> bool
61
+ def hash: () -> Integer
62
+ def match_spec?: (untyped) -> bool
63
+ def match_gem?: (untyped, untyped) -> bool
64
+
65
+ def self.local: () -> Platform
66
+ def self.match_spec?: (untyped) -> bool
67
+ def self.match_gem?: (untyped, untyped) -> bool
68
+ def self.installable?: (untyped) -> bool
69
+ def self.sort_priority: (untyped) -> Integer
70
+ end
71
+
72
+ class Gem::Dependency
73
+ # `Gem::Dependency.new(name, *requirements, type: :runtime)`.
74
+ # Upstream RBS declares the class shell but no constructor; a
75
+ # permissive splat keeps the canonical and the
76
+ # `(name, requirements, type)` invocation forms both silent.
77
+ def initialize: (*untyped) -> void
78
+ attr_accessor name: String
79
+ attr_accessor requirement: Gem::Requirement
80
+ attr_accessor type: Symbol
81
+ attr_accessor prerelease: bool
82
+ def matches_spec?: (untyped) -> bool
83
+ def match?: (*untyped) -> bool
84
+ def to_s: () -> String
85
+ def ==: (untyped) -> bool
86
+ def eql?: (untyped) -> bool
87
+ def hash: () -> Integer
88
+ end
89
+
90
+ class Gem::Specification
91
+ def self.find_all_by_name: (String name, *untyped requirements) -> Array[Gem::Specification]
92
+ def self.stubs: () -> Array[untyped]
93
+ def self.default_stubs: (?String pattern) -> Array[untyped]
94
+ def self.reset: () -> void
95
+ def self.from_yaml: (untyped) -> Gem::Specification
96
+ def self.load: (String path) -> Gem::Specification?
97
+ def self._all: () -> Array[Gem::Specification]
98
+ def self.each: () { (Gem::Specification) -> untyped } -> void
99
+
100
+ # Instance-side surface widely used by bundler / rubygems
101
+ # internals. `attr_accessor` mirrors the writer + reader pair
102
+ # so the survey's `.name=` / `.version=` / `.full_gem_path=` /
103
+ # `.loaded_from=` / `.post_install_message=` writes resolve too.
104
+ def activate: () -> untyped
105
+ def specification_version: () -> Integer
106
+ def files: () -> Array[String]
107
+ def default_gem?: () -> bool
108
+ def spec_file: () -> String
109
+ def runtime_dependencies: () -> Array[Gem::Dependency]
110
+ def required_rubygems_version: () -> Gem::Requirement
111
+ def required_ruby_version: () -> Gem::Requirement
112
+ def require_paths: () -> Array[String]
113
+ attr_accessor name: String
114
+ attr_accessor version: Gem::Version
115
+ attr_accessor full_gem_path: String
116
+ attr_accessor loaded_from: String?
117
+ attr_accessor post_install_message: String?
118
+ end
119
+
120
+ class Gem::BasicSpecification
121
+ def version: () -> Gem::Version
122
+ def executables: () -> Array[String]
123
+ def name: () -> String
124
+ def full_name: () -> String
125
+ def gem_dir: () -> String
126
+ end
127
+
128
+ class Gem::Version
129
+ def segments: () -> Array[untyped]
130
+ end
131
+
132
+ class Gem::Requirement
133
+ def requirements: () -> Array[untyped]
134
+ def self.create: (*untyped) -> Gem::Requirement
135
+ end
136
+
137
+ class Gem::ConfigFile
138
+ def []: (untyped) -> untyped
139
+ def []=: (untyped, untyped) -> untyped
140
+ def write: () -> void
141
+ def verbose: () -> bool
142
+ def verbose=: (bool) -> bool
143
+ def really_verbose: () -> bool
144
+ def ssl_ca_cert: () -> String?
145
+ def ssl_client_cert: () -> String?
146
+ def ssl_verify_mode: () -> Integer?
147
+ def credentials_path: () -> String
148
+ def api_keys: () -> Hash[String, String]
149
+ def cert_expiration_length_days: () -> Integer
150
+ def sources: () -> Array[String]
151
+ def update_sources=: (bool) -> bool
152
+ def unset_api_key!: () -> bool
153
+ def state_file_writable?: () -> bool
154
+ def set_api_key: (untyped host, untyped key) -> untyped
155
+ def rubygems_api_key: () -> String?
156
+ def rubygems_api_key=: (String?) -> String?
157
+ def last_update_check: () -> Integer
158
+ def last_update_check=: (Integer) -> Integer
159
+ def ipv4_fallback_enabled: () -> bool
160
+ def install_extension_in_lib: () -> bool
161
+ def each: () { ([String, untyped]) -> untyped } -> void
162
+ end
163
+
164
+ class Gem::LoadError < LoadError
165
+ attr_accessor name: String
166
+ attr_accessor requirement: Gem::Requirement
167
+ end
168
+
169
+ class Gem::SourceList
170
+ include Enumerable[untyped]
171
+ def initialize: () -> void
172
+ def include?: (untyped) -> bool
173
+ def each: () { (untyped) -> untyped } -> void
174
+ def to_a: () -> Array[untyped]
175
+ def from: (untyped) -> SourceList
176
+ end
177
+
178
+ class Gem::RequestSet
179
+ def initialize: (*untyped) -> void
180
+ def import: (untyped) -> untyped
181
+ def source_set: () -> untyped
182
+ def installed_gems: () -> Hash[String, untyped]
183
+ def install: (?untyped options) { (?untyped) -> untyped } -> untyped
184
+ def install_from_gemdeps: (untyped options) { (?untyped) -> untyped } -> untyped
185
+ def resolve: (?untyped) -> untyped
186
+ def resolve_current: () -> untyped
187
+ def resolve_dependencies: () -> untyped
188
+ def errors: () -> Array[untyped]
189
+ attr_accessor remote: bool
190
+ attr_accessor development: bool
191
+ attr_accessor development_shallow: bool
192
+ attr_accessor ignore_dependencies: bool
193
+ attr_accessor prerelease: bool
194
+ attr_accessor soft_missing: bool
195
+ attr_accessor without_groups: Array[Symbol]
196
+ attr_accessor always_install: untyped
197
+ attr_accessor installing: bool
198
+ end
199
+
200
+ class Gem::DependencyInstaller
201
+ def initialize: (*untyped) -> void
202
+ def install: (untyped, ?untyped) -> Array[untyped]
203
+ def installed_gems: () -> Array[untyped]
204
+ def errors: () -> Array[untyped]
205
+ end
206
+
207
+ class Gem::Uninstaller
208
+ def initialize: (*untyped) -> void
209
+ def uninstall: () -> void
210
+ end
211
+
212
+ class Gem::PathSupport
213
+ def initialize: (*untyped) -> void
214
+ end
215
+
216
+ class Gem::Installer
217
+ def initialize: (*untyped) -> void
218
+ end
219
+
220
+ class Gem::DependencyInstaller
221
+ def initialize: (*untyped) -> void
222
+ end
223
+
224
+ class Gem::MissingSpecError < StandardError
225
+ def initialize: (*untyped) -> void
226
+ end
@@ -5,6 +5,7 @@ require "json"
5
5
  require "optionparser"
6
6
 
7
7
  require_relative "../configuration"
8
+ require_relative "../config_audit"
8
9
  require_relative "../analysis/result"
9
10
  require_relative "../analysis/rule_catalog"
10
11
  require_relative "coverage_scan"
@@ -38,6 +39,7 @@ module Rigor
38
39
 
39
40
  configuration = load_check_configuration(options)
40
41
  configuration = apply_bleeding_edge_override(configuration, options)
42
+ config_warnings = warn_unresolved_config(configuration)
41
43
  cache_root = configuration.cache_path
42
44
  handle_clear_cache(cache_root) if options.fetch(:clear_cache)
43
45
 
@@ -52,7 +54,7 @@ module Rigor
52
54
  result = apply_baseline_filter(raw_result, configuration, options)
53
55
 
54
56
  coverage = compute_coverage(runner, configuration, options)
55
- write_result(result, options.fetch(:format), coverage: coverage)
57
+ write_result(result, options.fetch(:format), coverage: coverage, config_warnings: config_warnings)
56
58
  emit_ci_detected_output(result, options)
57
59
  write_run_stats(result.stats) if result.stats
58
60
  write_trace_appendices
@@ -412,6 +414,26 @@ module Rigor
412
414
  options
413
415
  end
414
416
 
417
+ # Surfaces the class of mistake where a configured value resolves
418
+ # to nothing — a typo'd or moved `signature_paths:` / bundler /
419
+ # collection path, an unknown `libraries:` name, an inert
420
+ # `disable:` / `severity_overrides:` rule id ({ConfigAudit}). The
421
+ # loader filters each one silently, and the downstream symptom is
422
+ # confusing: missing signatures turn calls into high-confidence
423
+ # `call.undefined-method` firings, and an unrecognised suppression
424
+ # token leaves the rule firing as if the line were never written —
425
+ # so a one-character mistake can read as hundreds of real errors.
426
+ # Each finding is emitted to STDERR (a warning, not a hard error —
427
+ # partial / optional bundles are a valid setup) and the returned
428
+ # list rides into the `--format=json` payload under `config_warnings`
429
+ # so CI and framework consumers can assert on it. The audits only
430
+ # fire on explicit, working-setup-safe signals (see {ConfigAudit}).
431
+ def warn_unresolved_config(configuration)
432
+ warnings = ConfigAudit.warnings(configuration)
433
+ warnings.each { |warning| @err.puts("rigor: #{warning.message}") }
434
+ warnings
435
+ end
436
+
415
437
  # ADR-32 WD10 carry-over — wraps `Configuration.load` so the
416
438
  # CLI's `--treat-all-as-inline-rbs` flag can inject a
417
439
  # `rigor-rbs-inline` plugin entry with
@@ -671,11 +693,12 @@ module Rigor
671
693
  format("%.1f MiB", bytes / (1024.0 * 1024.0))
672
694
  end
673
695
 
674
- def write_result(result, format, coverage: nil)
696
+ def write_result(result, format, coverage: nil, config_warnings: [])
675
697
  case format
676
698
  when "json"
677
699
  payload = enrich_json(result.to_h)
678
700
  payload["coverage"] = coverage_payload(coverage) if coverage
701
+ payload["config_warnings"] = config_warnings.map(&:to_h) unless config_warnings.empty?
679
702
  @out.puts(JSON.pretty_generate(payload))
680
703
  when "text"
681
704
  write_text_result(result)
@@ -3,6 +3,7 @@
3
3
  require "English"
4
4
  require "optionparser"
5
5
  require "prism"
6
+ require "shellwords"
6
7
 
7
8
  require_relative "../configuration"
8
9
  require_relative "options"
@@ -11,6 +12,7 @@ require_relative "../inference/precision_scanner"
11
12
  require_relative "../inference/protection_scanner"
12
13
  require_relative "../inference/parameter_inference_collector"
13
14
  require_relative "../protection/mutation_scanner"
15
+ require_relative "../protection/test_suite_oracle"
14
16
  require_relative "../language_server/project_context"
15
17
  require_relative "../scope"
16
18
  require_relative "coverage_report"
@@ -20,6 +22,9 @@ require_relative "protection_report"
20
22
  require_relative "protection_renderer"
21
23
  require_relative "mutation_protection_report"
22
24
  require_relative "mutation_protection_renderer"
25
+ require_relative "fused_protection_report"
26
+ require_relative "fused_protection_renderer"
27
+ require_relative "coverage_mutation"
23
28
  require_relative "command"
24
29
 
25
30
  module Rigor
@@ -38,12 +43,20 @@ module Rigor
38
43
  # 1 — precision ratio < threshold, or parse errors encountered
39
44
  # 64 — usage error
40
45
  class CoverageCommand < Command
46
+ include CoverageMutation
47
+
41
48
  USAGE = "Usage: rigor coverage [options] PATH..."
42
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
+
43
54
  # @return [Integer] CLI exit status.
44
55
  def run
45
56
  options = parse_options
46
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]
47
60
  return run_mutation_protection(options) if options[:mutation]
48
61
 
49
62
  paths = collect_paths(@argv, command_name: "coverage")
@@ -60,36 +73,69 @@ module Rigor
60
73
  private
61
74
 
62
75
  def parse_options
63
- options = { format: "text", threshold: nil, config: nil, protection: false, mutation: false }
64
-
65
- OptionParser.new do |opts|
66
- opts.banner = USAGE
67
- opts.on("--format=FORMAT", "Output format: text or json") { |v| options[:format] = 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 }
78
- opts.on(
79
- "--threshold=RATIO", Float,
80
- "Exit 1 when the precision (or, with --protection, protection/effectiveness) ratio is below RATIO (0.0–1.0)"
81
- ) { |v| options[:threshold] = v }
82
- end.parse!(@argv)
83
-
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)
84
80
  options
85
81
  end
86
82
 
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
+
87
121
  def mutation_misuse_error
88
122
  @err.puts("coverage: --mutation requires --protection")
89
123
  @err.puts(USAGE)
90
124
  CLI::EXIT_USAGE
91
125
  end
92
126
 
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
+
93
139
  def run_protection(paths, options)
94
140
  report = scan_protection(paths, options)
95
141
  ProtectionRenderer.new(out: @out).render(report, format: options.fetch(:format))
@@ -133,77 +179,6 @@ module Rigor
133
179
  report.ratio < threshold ? 1 : 0
134
180
  end
135
181
 
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)
169
- source = File.read(path)
170
- parse_result = Prism.parse(source, filepath: path, version: configuration.target_ruby)
171
- if parse_result.errors.any?
172
- accumulator.record_parse_error(path, parse_result.errors)
173
- return
174
- end
175
-
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
182
  def usage_error
208
183
  @err.puts("coverage: at least one path is required")
209
184
  @err.puts(USAGE)
@@ -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