rigortype 0.2.0 → 0.2.2
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 +82 -20
- 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/docs/handbook/01-getting-started.md +311 -0
- data/docs/handbook/02-everyday-types.md +337 -0
- data/docs/handbook/03-narrowing.md +359 -0
- data/docs/handbook/04-tuples-and-shapes.md +321 -0
- data/docs/handbook/05-methods-and-blocks.md +339 -0
- data/docs/handbook/06-classes.md +305 -0
- data/docs/handbook/07-rbs-and-extended.md +427 -0
- data/docs/handbook/08-understanding-errors.md +373 -0
- data/docs/handbook/09-plugins.md +241 -0
- data/docs/handbook/10-sorbet.md +347 -0
- data/docs/handbook/11-sig-gen.md +312 -0
- data/docs/handbook/12-lightweight-hkt.md +333 -0
- data/docs/handbook/README.md +275 -0
- data/docs/handbook/appendix-elixir.md +370 -0
- data/docs/handbook/appendix-go.md +399 -0
- data/docs/handbook/appendix-java-csharp.md +470 -0
- data/docs/handbook/appendix-liskov.md +580 -0
- data/docs/handbook/appendix-mypy.md +370 -0
- data/docs/handbook/appendix-phpstan.md +338 -0
- data/docs/handbook/appendix-protocols-and-structural-typing.md +292 -0
- data/docs/handbook/appendix-rust.md +446 -0
- data/docs/handbook/appendix-steep.md +336 -0
- data/docs/handbook/appendix-type-theory.md +1662 -0
- data/docs/handbook/appendix-typeprof.md +416 -0
- data/docs/handbook/appendix-typescript.md +332 -0
- data/docs/install.md +189 -0
- data/docs/llms.txt +72 -0
- data/docs/manual/01-installation.md +342 -0
- data/docs/manual/02-cli-reference.md +557 -0
- data/docs/manual/03-configuration.md +152 -0
- data/docs/manual/04-diagnostics.md +206 -0
- data/docs/manual/05-inspecting-types.md +109 -0
- data/docs/manual/06-baseline.md +104 -0
- data/docs/manual/07-plugins.md +92 -0
- data/docs/manual/08-skills.md +143 -0
- data/docs/manual/09-editor-integration.md +245 -0
- data/docs/manual/10-mcp-server.md +532 -0
- data/docs/manual/11-ci.md +274 -0
- data/docs/manual/12-caching.md +116 -0
- data/docs/manual/13-troubleshooting.md +120 -0
- data/docs/manual/14-rails-quickstart.md +332 -0
- data/docs/manual/15-type-protection-coverage.md +204 -0
- data/docs/manual/16-rbs-extended-annotations.md +190 -0
- data/docs/manual/17-driving-improvement.md +160 -0
- data/docs/manual/README.md +87 -0
- data/docs/manual/ci-templates/README.md +58 -0
- data/docs/manual/plugins/README.md +86 -0
- data/docs/manual/plugins/rigor-actioncable.md +78 -0
- data/docs/manual/plugins/rigor-actionmailer.md +74 -0
- data/docs/manual/plugins/rigor-actionpack.md +80 -0
- data/docs/manual/plugins/rigor-activejob.md +58 -0
- data/docs/manual/plugins/rigor-activerecord.md +102 -0
- data/docs/manual/plugins/rigor-activestorage.md +74 -0
- data/docs/manual/plugins/rigor-activesupport-core-ext.md +86 -0
- data/docs/manual/plugins/rigor-devise.md +70 -0
- data/docs/manual/plugins/rigor-dry-schema.md +56 -0
- data/docs/manual/plugins/rigor-dry-struct.md +60 -0
- data/docs/manual/plugins/rigor-dry-types.md +59 -0
- data/docs/manual/plugins/rigor-dry-validation.md +62 -0
- data/docs/manual/plugins/rigor-factorybot.md +76 -0
- data/docs/manual/plugins/rigor-graphql.md +89 -0
- data/docs/manual/plugins/rigor-hanami.md +83 -0
- data/docs/manual/plugins/rigor-mangrove.md +73 -0
- data/docs/manual/plugins/rigor-minitest.md +86 -0
- data/docs/manual/plugins/rigor-pundit.md +72 -0
- data/docs/manual/plugins/rigor-rails-i18n.md +92 -0
- data/docs/manual/plugins/rigor-rails-routes.md +94 -0
- data/docs/manual/plugins/rigor-rails.md +44 -0
- data/docs/manual/plugins/rigor-rbs-inline.md +83 -0
- data/docs/manual/plugins/rigor-rspec-rails.md +72 -0
- data/docs/manual/plugins/rigor-rspec.md +86 -0
- data/docs/manual/plugins/rigor-shoulda-matchers.md +78 -0
- data/docs/manual/plugins/rigor-sidekiq.md +78 -0
- data/docs/manual/plugins/rigor-sinatra.md +61 -0
- data/docs/manual/plugins/rigor-sorbet.md +63 -0
- data/docs/manual/plugins/rigor-statesman.md +75 -0
- data/docs/manual/plugins/rigor-typescript-utility-types.md +71 -0
- data/exe/rigor +1 -1
- data/lib/rigor/analysis/incremental_session.rb +4 -2
- data/lib/rigor/analysis/run_stats.rb +13 -1
- data/lib/rigor/analysis/runner.rb +54 -12
- data/lib/rigor/cli/check_command.rb +26 -3
- data/lib/rigor/cli/coverage_command.rb +67 -92
- data/lib/rigor/cli/coverage_mutation.rb +149 -0
- data/lib/rigor/cli/docs_command.rb +248 -0
- data/lib/rigor/cli/fused_protection_renderer.rb +67 -0
- data/lib/rigor/cli/fused_protection_report.rb +76 -0
- data/lib/rigor/cli/skill_command.rb +103 -41
- data/lib/rigor/cli/skill_describe.rb +346 -0
- data/lib/rigor/cli.rb +25 -3
- 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 +140 -38
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +37 -6
- data/lib/rigor/inference/scope_indexer.rb +87 -89
- data/lib/rigor/inference/statement_evaluator.rb +27 -0
- data/lib/rigor/plugin/isolation.rb +5 -5
- data/lib/rigor/plugin/loader.rb +4 -2
- 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
- data/skills/rigor-ask/SKILL.md +172 -0
- data/skills/rigor-doctor/SKILL.md +87 -0
- data/skills/rigor-editor-setup/SKILL.md +114 -0
- data/skills/rigor-mcp-setup/SKILL.md +117 -0
- data/skills/rigor-monkeypatch-resolve/SKILL.md +79 -0
- data/skills/rigor-next-steps/SKILL.md +113 -0
- data/skills/rigor-plugin-tune/SKILL.md +79 -0
- data/skills/rigor-protection-uplift/SKILL.md +133 -0
- data/skills/rigor-rbs-setup/SKILL.md +128 -0
- data/skills/rigor-upgrade/SKILL.md +79 -0
- metadata +120 -1
|
@@ -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
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "command"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
class CLI
|
|
7
|
+
# `rigor docs` — serve the documentation bundled with the
|
|
8
|
+
# `rigortype` gem OFFLINE (ADR-74). The skills (`rigor skill <name>`)
|
|
9
|
+
# already ride in the gem; this is the doc twin, so once Rigor is
|
|
10
|
+
# installed an agent can read the guidance the SKILL-driven UX routes
|
|
11
|
+
# to without the network. The canonical web copy is
|
|
12
|
+
# rigor.typedduck.fail/llms.txt; the gem ships `docs/install.md`,
|
|
13
|
+
# `docs/llms.txt`, and the full user-facing **manual** and
|
|
14
|
+
# **handbook** (the drive-Rigor chapters — the contributor-facing
|
|
15
|
+
# ADR / spec / notes corpus stays web-only).
|
|
16
|
+
#
|
|
17
|
+
# Grammar (mirrors `rigor skill`): the positional slot is always a
|
|
18
|
+
# doc *name*; alternative outputs are flags, so a page named `list`
|
|
19
|
+
# or `path` can never be shadowed by a verb.
|
|
20
|
+
#
|
|
21
|
+
# - `rigor docs` — print the bundled `llms.txt` index.
|
|
22
|
+
# - `rigor docs <name>` — print a doc page to stdout.
|
|
23
|
+
# - `rigor docs --path <name>` — one-line absolute path, for a Read tool.
|
|
24
|
+
# - `rigor docs --list [<cat>]` — table of name + path (optionally one category).
|
|
25
|
+
#
|
|
26
|
+
# `<name>` resolves a category-qualified path (`handbook/03-narrowing`),
|
|
27
|
+
# a prefixed basename (`03-narrowing`), or a short name (`narrowing`,
|
|
28
|
+
# when it is unique across categories).
|
|
29
|
+
#
|
|
30
|
+
# The pre-v0.3.0 verb spellings `rigor docs list` / `rigor docs path
|
|
31
|
+
# <name>` still work but emit a stderr deprecation notice; they are
|
|
32
|
+
# removed in v0.3.0 (see docs/ROADMAP.md § "Scheduled CLI
|
|
33
|
+
# deprecations").
|
|
34
|
+
class DocsCommand < Command
|
|
35
|
+
USAGE = <<~USAGE
|
|
36
|
+
Usage: rigor docs [<name>] [--path <name>] [--list [<category>]]
|
|
37
|
+
|
|
38
|
+
With no argument, prints the bundled llms.txt offline doc index.
|
|
39
|
+
|
|
40
|
+
rigor docs Print the offline doc index (llms.txt)
|
|
41
|
+
rigor docs <name> Print a doc page to stdout
|
|
42
|
+
rigor docs --path <name> Print the absolute path of a doc
|
|
43
|
+
rigor docs --list [<category>] List bundled docs (optionally one category)
|
|
44
|
+
|
|
45
|
+
Categories: manual, handbook (plus the top-level install guide).
|
|
46
|
+
A page is addressable by its category-qualified path
|
|
47
|
+
(`handbook/03-narrowing`), its prefixed name (`03-narrowing`),
|
|
48
|
+
or its short name (`narrowing`, when unique across categories).
|
|
49
|
+
|
|
50
|
+
Examples:
|
|
51
|
+
rigor docs
|
|
52
|
+
rigor docs install
|
|
53
|
+
rigor docs handbook/03-narrowing
|
|
54
|
+
rigor docs editor-integration
|
|
55
|
+
rigor docs --path 17-driving-improvement
|
|
56
|
+
rigor docs --list handbook
|
|
57
|
+
|
|
58
|
+
Deprecated (removed in v0.3.0) — use the flags above:
|
|
59
|
+
rigor docs list -> rigor docs --list
|
|
60
|
+
rigor docs path <name> -> rigor docs --path <name>
|
|
61
|
+
USAGE
|
|
62
|
+
|
|
63
|
+
# The bundled docs live at `<gem_root>/docs/`. From
|
|
64
|
+
# `lib/rigor/cli/docs_command.rb` that is three directories up.
|
|
65
|
+
DOCS_ROOT = File.expand_path("../../../docs", __dir__)
|
|
66
|
+
MANUAL_ROOT = File.join(DOCS_ROOT, "manual")
|
|
67
|
+
HANDBOOK_ROOT = File.join(DOCS_ROOT, "handbook")
|
|
68
|
+
LLMS_INDEX = File.join(DOCS_ROOT, "llms.txt")
|
|
69
|
+
|
|
70
|
+
# The verb subcommands the flags superseded keep working with a
|
|
71
|
+
# stderr deprecation notice until this version drops them. Each maps
|
|
72
|
+
# to the canonical advice printed and the flag it rewrites to.
|
|
73
|
+
LEGACY_VERB_REMOVAL = "v0.3.0"
|
|
74
|
+
LEGACY_VERBS = {
|
|
75
|
+
"list" => { old: "list", advice: "--list", flag: "--list" },
|
|
76
|
+
"path" => { old: "path <name>", advice: "--path <name>", flag: "--path" }
|
|
77
|
+
}.freeze
|
|
78
|
+
|
|
79
|
+
# @return [Integer] CLI exit status.
|
|
80
|
+
def run
|
|
81
|
+
rewrite_legacy_verb!
|
|
82
|
+
|
|
83
|
+
case @argv.first
|
|
84
|
+
when nil
|
|
85
|
+
run_index
|
|
86
|
+
when "-h", "--help", "help"
|
|
87
|
+
@out.puts(USAGE)
|
|
88
|
+
0
|
|
89
|
+
when "--list"
|
|
90
|
+
@argv.shift
|
|
91
|
+
run_list(@argv.shift)
|
|
92
|
+
when "--path"
|
|
93
|
+
@argv.shift
|
|
94
|
+
run_path(@argv.shift)
|
|
95
|
+
when "--print"
|
|
96
|
+
@argv.shift
|
|
97
|
+
run_print(@argv.shift)
|
|
98
|
+
else
|
|
99
|
+
run_print(@argv.shift)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
# Translate a deprecated verb spelling into its flag form, warning
|
|
106
|
+
# once on stderr, so the dispatch above only handles canonical forms.
|
|
107
|
+
def rewrite_legacy_verb!
|
|
108
|
+
spec = LEGACY_VERBS[@argv.first]
|
|
109
|
+
return unless spec
|
|
110
|
+
|
|
111
|
+
@err.puts(
|
|
112
|
+
"rigor docs: `#{spec.fetch(:old)}` is deprecated and will be removed in " \
|
|
113
|
+
"#{LEGACY_VERB_REMOVAL}; use `rigor docs #{spec.fetch(:advice)}` instead."
|
|
114
|
+
)
|
|
115
|
+
@argv[0] = spec.fetch(:flag)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def run_index
|
|
119
|
+
if File.file?(LLMS_INDEX)
|
|
120
|
+
@out.write(File.read(LLMS_INDEX))
|
|
121
|
+
0
|
|
122
|
+
else
|
|
123
|
+
run_list(nil)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def run_list(category)
|
|
128
|
+
docs = discover_docs
|
|
129
|
+
if category
|
|
130
|
+
categories = docs.map { |doc| doc.fetch(:category) }.uniq
|
|
131
|
+
unless categories.include?(category)
|
|
132
|
+
@err.puts("Unknown category: #{category}")
|
|
133
|
+
@err.puts("Available categories: #{categories.join(', ')}")
|
|
134
|
+
return 1
|
|
135
|
+
end
|
|
136
|
+
docs = docs.select { |doc| doc.fetch(:category) == category }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
if docs.empty?
|
|
140
|
+
@err.puts("No bundled docs found under #{DOCS_ROOT}")
|
|
141
|
+
return 1
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
width = docs.map { |doc| doc.fetch(:relative).length }.max
|
|
145
|
+
docs.each do |doc|
|
|
146
|
+
@out.puts(format("%-#{width}s %s", doc.fetch(:relative), doc.fetch(:path)))
|
|
147
|
+
end
|
|
148
|
+
0
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def run_print(name)
|
|
152
|
+
return usage_error("a doc name is required") if name.nil?
|
|
153
|
+
|
|
154
|
+
doc = resolve_doc(name)
|
|
155
|
+
return doc if doc.is_a?(Integer) # error status, already reported
|
|
156
|
+
|
|
157
|
+
# ASCII-only provenance header: the doc body is read with the
|
|
158
|
+
# external encoding (US-ASCII under a C locale), so a UTF-8 header
|
|
159
|
+
# would set the output buffer to UTF-8 and clash with the body.
|
|
160
|
+
@out.puts("<!-- rigor docs #{doc.fetch(:name)} (rigortype #{Rigor::VERSION}, offline) -->")
|
|
161
|
+
@out.puts
|
|
162
|
+
@out.write(File.read(doc.fetch(:path)))
|
|
163
|
+
0
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def run_path(name)
|
|
167
|
+
return usage_error("`--path` requires a doc name") if name.nil?
|
|
168
|
+
|
|
169
|
+
doc = resolve_doc(name)
|
|
170
|
+
return doc if doc.is_a?(Integer)
|
|
171
|
+
|
|
172
|
+
@out.puts(doc.fetch(:path))
|
|
173
|
+
0
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Resolve a query to a single doc. Exact relative-path and
|
|
177
|
+
# prefixed-basename aliases are unique; a short (prefix-stripped)
|
|
178
|
+
# name is accepted only when one category owns it.
|
|
179
|
+
#
|
|
180
|
+
# @return [Hash, Integer] the doc entry, or an error exit status
|
|
181
|
+
# after the error has been written to `@err`.
|
|
182
|
+
def resolve_doc(query)
|
|
183
|
+
docs = discover_docs
|
|
184
|
+
|
|
185
|
+
exact = docs.find { |doc| doc.fetch(:exact_aliases).include?(query) }
|
|
186
|
+
return exact if exact
|
|
187
|
+
|
|
188
|
+
short = docs.select { |doc| doc.fetch(:short_name) == query }
|
|
189
|
+
case short.size
|
|
190
|
+
when 1 then short.first
|
|
191
|
+
when 0 then name_error(query)
|
|
192
|
+
else ambiguous_error(query, short)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Every bundled doc, each carrying the aliases `rigor docs <name>`
|
|
197
|
+
# accepts and the category used by `--list`. `install.md` sits at
|
|
198
|
+
# the docs root (category `guide`); the rest are manual / handbook
|
|
199
|
+
# chapters.
|
|
200
|
+
def discover_docs
|
|
201
|
+
paths = [File.join(DOCS_ROOT, "install.md")]
|
|
202
|
+
paths += Dir.glob(File.join(MANUAL_ROOT, "**", "*.md")) # Dir.glob is already sorted
|
|
203
|
+
paths += Dir.glob(File.join(HANDBOOK_ROOT, "**", "*.md"))
|
|
204
|
+
paths.select { |path| File.file?(path) }.map { |path| doc_entry(path) }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def doc_entry(path)
|
|
208
|
+
relative = path.delete_prefix("#{DOCS_ROOT}/").delete_suffix(".md")
|
|
209
|
+
base = File.basename(path, ".md")
|
|
210
|
+
short = base.sub(/\A\d+-/, "")
|
|
211
|
+
segments = relative.split("/")
|
|
212
|
+
category = segments.size > 1 ? segments.first : "guide"
|
|
213
|
+
{
|
|
214
|
+
name: base,
|
|
215
|
+
relative: relative,
|
|
216
|
+
path: path,
|
|
217
|
+
category: category,
|
|
218
|
+
# Always-unique addresses: the `docs/`-relative path and the
|
|
219
|
+
# prefixed basename. `handbook/03-narrowing` and `03-narrowing`.
|
|
220
|
+
exact_aliases: [relative, base].uniq,
|
|
221
|
+
# The prefix-stripped short name (`narrowing`); ambiguous when
|
|
222
|
+
# two categories carry the same chapter slug (`plugins`).
|
|
223
|
+
short_name: short
|
|
224
|
+
}
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def name_error(name)
|
|
228
|
+
@err.puts("Unknown doc: #{name}")
|
|
229
|
+
@err.puts("Available docs (try `rigor docs --list`):")
|
|
230
|
+
discover_docs.each { |doc| @err.puts(" #{doc.fetch(:relative)}") }
|
|
231
|
+
1
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def ambiguous_error(query, candidates)
|
|
235
|
+
@err.puts("Ambiguous doc name: #{query}")
|
|
236
|
+
@err.puts("Matches several docs — qualify with the category path:")
|
|
237
|
+
candidates.each { |doc| @err.puts(" #{doc.fetch(:relative)}") }
|
|
238
|
+
1
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def usage_error(message)
|
|
242
|
+
@err.puts(message)
|
|
243
|
+
@err.puts(USAGE)
|
|
244
|
+
Rigor::CLI::EXIT_USAGE
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -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
|