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.
Files changed (142) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +82 -20
  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/docs/handbook/01-getting-started.md +311 -0
  27. data/docs/handbook/02-everyday-types.md +337 -0
  28. data/docs/handbook/03-narrowing.md +359 -0
  29. data/docs/handbook/04-tuples-and-shapes.md +321 -0
  30. data/docs/handbook/05-methods-and-blocks.md +339 -0
  31. data/docs/handbook/06-classes.md +305 -0
  32. data/docs/handbook/07-rbs-and-extended.md +427 -0
  33. data/docs/handbook/08-understanding-errors.md +373 -0
  34. data/docs/handbook/09-plugins.md +241 -0
  35. data/docs/handbook/10-sorbet.md +347 -0
  36. data/docs/handbook/11-sig-gen.md +312 -0
  37. data/docs/handbook/12-lightweight-hkt.md +333 -0
  38. data/docs/handbook/README.md +275 -0
  39. data/docs/handbook/appendix-elixir.md +370 -0
  40. data/docs/handbook/appendix-go.md +399 -0
  41. data/docs/handbook/appendix-java-csharp.md +470 -0
  42. data/docs/handbook/appendix-liskov.md +580 -0
  43. data/docs/handbook/appendix-mypy.md +370 -0
  44. data/docs/handbook/appendix-phpstan.md +338 -0
  45. data/docs/handbook/appendix-protocols-and-structural-typing.md +292 -0
  46. data/docs/handbook/appendix-rust.md +446 -0
  47. data/docs/handbook/appendix-steep.md +336 -0
  48. data/docs/handbook/appendix-type-theory.md +1662 -0
  49. data/docs/handbook/appendix-typeprof.md +416 -0
  50. data/docs/handbook/appendix-typescript.md +332 -0
  51. data/docs/install.md +189 -0
  52. data/docs/llms.txt +72 -0
  53. data/docs/manual/01-installation.md +342 -0
  54. data/docs/manual/02-cli-reference.md +557 -0
  55. data/docs/manual/03-configuration.md +152 -0
  56. data/docs/manual/04-diagnostics.md +206 -0
  57. data/docs/manual/05-inspecting-types.md +109 -0
  58. data/docs/manual/06-baseline.md +104 -0
  59. data/docs/manual/07-plugins.md +92 -0
  60. data/docs/manual/08-skills.md +143 -0
  61. data/docs/manual/09-editor-integration.md +245 -0
  62. data/docs/manual/10-mcp-server.md +532 -0
  63. data/docs/manual/11-ci.md +274 -0
  64. data/docs/manual/12-caching.md +116 -0
  65. data/docs/manual/13-troubleshooting.md +120 -0
  66. data/docs/manual/14-rails-quickstart.md +332 -0
  67. data/docs/manual/15-type-protection-coverage.md +204 -0
  68. data/docs/manual/16-rbs-extended-annotations.md +190 -0
  69. data/docs/manual/17-driving-improvement.md +160 -0
  70. data/docs/manual/README.md +87 -0
  71. data/docs/manual/ci-templates/README.md +58 -0
  72. data/docs/manual/plugins/README.md +86 -0
  73. data/docs/manual/plugins/rigor-actioncable.md +78 -0
  74. data/docs/manual/plugins/rigor-actionmailer.md +74 -0
  75. data/docs/manual/plugins/rigor-actionpack.md +80 -0
  76. data/docs/manual/plugins/rigor-activejob.md +58 -0
  77. data/docs/manual/plugins/rigor-activerecord.md +102 -0
  78. data/docs/manual/plugins/rigor-activestorage.md +74 -0
  79. data/docs/manual/plugins/rigor-activesupport-core-ext.md +86 -0
  80. data/docs/manual/plugins/rigor-devise.md +70 -0
  81. data/docs/manual/plugins/rigor-dry-schema.md +56 -0
  82. data/docs/manual/plugins/rigor-dry-struct.md +60 -0
  83. data/docs/manual/plugins/rigor-dry-types.md +59 -0
  84. data/docs/manual/plugins/rigor-dry-validation.md +62 -0
  85. data/docs/manual/plugins/rigor-factorybot.md +76 -0
  86. data/docs/manual/plugins/rigor-graphql.md +89 -0
  87. data/docs/manual/plugins/rigor-hanami.md +83 -0
  88. data/docs/manual/plugins/rigor-mangrove.md +73 -0
  89. data/docs/manual/plugins/rigor-minitest.md +86 -0
  90. data/docs/manual/plugins/rigor-pundit.md +72 -0
  91. data/docs/manual/plugins/rigor-rails-i18n.md +92 -0
  92. data/docs/manual/plugins/rigor-rails-routes.md +94 -0
  93. data/docs/manual/plugins/rigor-rails.md +44 -0
  94. data/docs/manual/plugins/rigor-rbs-inline.md +83 -0
  95. data/docs/manual/plugins/rigor-rspec-rails.md +72 -0
  96. data/docs/manual/plugins/rigor-rspec.md +86 -0
  97. data/docs/manual/plugins/rigor-shoulda-matchers.md +78 -0
  98. data/docs/manual/plugins/rigor-sidekiq.md +78 -0
  99. data/docs/manual/plugins/rigor-sinatra.md +61 -0
  100. data/docs/manual/plugins/rigor-sorbet.md +63 -0
  101. data/docs/manual/plugins/rigor-statesman.md +75 -0
  102. data/docs/manual/plugins/rigor-typescript-utility-types.md +71 -0
  103. data/exe/rigor +1 -1
  104. data/lib/rigor/analysis/incremental_session.rb +4 -2
  105. data/lib/rigor/analysis/run_stats.rb +13 -1
  106. data/lib/rigor/analysis/runner.rb +54 -12
  107. data/lib/rigor/cli/check_command.rb +26 -3
  108. data/lib/rigor/cli/coverage_command.rb +67 -92
  109. data/lib/rigor/cli/coverage_mutation.rb +149 -0
  110. data/lib/rigor/cli/docs_command.rb +248 -0
  111. data/lib/rigor/cli/fused_protection_renderer.rb +67 -0
  112. data/lib/rigor/cli/fused_protection_report.rb +76 -0
  113. data/lib/rigor/cli/skill_command.rb +103 -41
  114. data/lib/rigor/cli/skill_describe.rb +346 -0
  115. data/lib/rigor/cli.rb +25 -3
  116. data/lib/rigor/config_audit.rb +152 -0
  117. data/lib/rigor/configuration.rb +12 -0
  118. data/lib/rigor/environment/rbs_loader.rb +27 -0
  119. data/lib/rigor/environment.rb +49 -1
  120. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +140 -38
  121. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +37 -6
  122. data/lib/rigor/inference/scope_indexer.rb +87 -89
  123. data/lib/rigor/inference/statement_evaluator.rb +27 -0
  124. data/lib/rigor/plugin/isolation.rb +5 -5
  125. data/lib/rigor/plugin/loader.rb +4 -2
  126. data/lib/rigor/protection/diagnostic_oracle.rb +51 -0
  127. data/lib/rigor/protection/mutation_scanner.rb +98 -38
  128. data/lib/rigor/protection/mutator.rb +21 -0
  129. data/lib/rigor/protection/test_suite_oracle.rb +68 -0
  130. data/lib/rigor/signature_path_audit.rb +92 -0
  131. data/lib/rigor/version.rb +1 -1
  132. data/skills/rigor-ask/SKILL.md +172 -0
  133. data/skills/rigor-doctor/SKILL.md +87 -0
  134. data/skills/rigor-editor-setup/SKILL.md +114 -0
  135. data/skills/rigor-mcp-setup/SKILL.md +117 -0
  136. data/skills/rigor-monkeypatch-resolve/SKILL.md +79 -0
  137. data/skills/rigor-next-steps/SKILL.md +113 -0
  138. data/skills/rigor-plugin-tune/SKILL.md +79 -0
  139. data/skills/rigor-protection-uplift/SKILL.md +133 -0
  140. data/skills/rigor-rbs-setup/SKILL.md +128 -0
  141. data/skills/rigor-upgrade/SKILL.md +79 -0
  142. 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
- 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
@@ -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