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.
- checksums.yaml +4 -4
- data/README.md +41 -6
- data/data/core_overlay/numeric.rbs +33 -0
- data/data/core_overlay/pathname.rbs +25 -0
- data/data/core_overlay/string_scanner.rbs +28 -0
- data/data/gem_overlay/activesupport/core_ext.rbs +473 -0
- data/data/vendored_gem_sigs/ast/ast.rbs +130 -0
- data/data/vendored_gem_sigs/bcrypt/bcrypt.rbs +47 -0
- data/data/vendored_gem_sigs/bundler/bundler.rbs +238 -0
- data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +34 -0
- data/data/vendored_gem_sigs/did_you_mean/did_you_mean_extras.rbs +34 -0
- data/data/vendored_gem_sigs/idn-ruby/idn.rbs +54 -0
- data/data/vendored_gem_sigs/mysql2/client.rbs +55 -0
- data/data/vendored_gem_sigs/mysql2/error.rbs +5 -0
- data/data/vendored_gem_sigs/mysql2/result.rbs +31 -0
- data/data/vendored_gem_sigs/mysql2/statement.rbs +5 -0
- data/data/vendored_gem_sigs/nokogiri/nokogiri.rbs +2332 -0
- data/data/vendored_gem_sigs/nokogiri/nokogiri_html5.rbs +47 -0
- data/data/vendored_gem_sigs/pg/pg.rbs +212 -0
- data/data/vendored_gem_sigs/prism/prism_supplement.rbs +44 -0
- data/data/vendored_gem_sigs/redis/errors.rbs +50 -0
- data/data/vendored_gem_sigs/redis/future.rbs +5 -0
- data/data/vendored_gem_sigs/redis/redis.rbs +348 -0
- data/data/vendored_gem_sigs/redis/redis_extras.rbs +130 -0
- data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +226 -0
- data/lib/rigor/cli/check_command.rb +25 -2
- data/lib/rigor/cli/coverage_command.rb +67 -92
- data/lib/rigor/cli/coverage_mutation.rb +149 -0
- data/lib/rigor/cli/fused_protection_renderer.rb +67 -0
- data/lib/rigor/cli/fused_protection_report.rb +76 -0
- data/lib/rigor/config_audit.rb +152 -0
- data/lib/rigor/configuration.rb +12 -0
- data/lib/rigor/environment/rbs_loader.rb +27 -0
- data/lib/rigor/environment.rb +49 -1
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +17 -7
- data/lib/rigor/inference/statement_evaluator.rb +27 -0
- data/lib/rigor/protection/diagnostic_oracle.rb +51 -0
- data/lib/rigor/protection/mutation_scanner.rb +98 -38
- data/lib/rigor/protection/mutator.rb +21 -0
- data/lib/rigor/protection/test_suite_oracle.rb +68 -0
- data/lib/rigor/signature_path_audit.rb +92 -0
- data/lib/rigor/version.rb +1 -1
- 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
|
-
|
|
66
|
-
|
|
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
|