rigortype 0.1.19 → 0.2.0

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 (166) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -23
  3. data/lib/rigor/analysis/check_rules/rule_walk.rb +3 -21
  4. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  5. data/lib/rigor/analysis/check_rules.rb +492 -71
  6. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  7. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  8. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  9. data/lib/rigor/analysis/fact_store.rb +5 -4
  10. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +17 -17
  12. data/lib/rigor/analysis/runner/project_pre_passes.rb +9 -8
  13. data/lib/rigor/analysis/runner.rb +17 -6
  14. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  15. data/lib/rigor/analysis/worker_session.rb +10 -14
  16. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  17. data/lib/rigor/cache/store.rb +5 -3
  18. data/lib/rigor/cli/annotate_command.rb +28 -7
  19. data/lib/rigor/cli/baseline_command.rb +4 -3
  20. data/lib/rigor/cli/check_command.rb +115 -16
  21. data/lib/rigor/cli/coverage_command.rb +148 -16
  22. data/lib/rigor/cli/coverage_scan.rb +57 -0
  23. data/lib/rigor/cli/explain_command.rb +2 -0
  24. data/lib/rigor/cli/lsp_command.rb +3 -7
  25. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  26. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  27. data/lib/rigor/cli/options.rb +9 -0
  28. data/lib/rigor/cli/plugins_command.rb +2 -1
  29. data/lib/rigor/cli/protection_renderer.rb +63 -0
  30. data/lib/rigor/cli/protection_report.rb +68 -0
  31. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  32. data/lib/rigor/cli/trace_command.rb +2 -1
  33. data/lib/rigor/cli/triage_command.rb +2 -1
  34. data/lib/rigor/cli/type_of_command.rb +1 -1
  35. data/lib/rigor/cli/type_scan_command.rb +2 -1
  36. data/lib/rigor/cli.rb +3 -2
  37. data/lib/rigor/configuration/dependencies.rb +2 -4
  38. data/lib/rigor/configuration.rb +45 -7
  39. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  40. data/lib/rigor/environment/class_registry.rb +4 -3
  41. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  42. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  43. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  44. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  45. data/lib/rigor/environment/rbs_loader.rb +49 -5
  46. data/lib/rigor/environment.rb +17 -7
  47. data/lib/rigor/flow_contribution/fact.rb +1 -1
  48. data/lib/rigor/flow_contribution.rb +3 -5
  49. data/lib/rigor/inference/acceptance.rb +17 -9
  50. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  51. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  52. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  53. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  54. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  55. data/lib/rigor/inference/expression_typer.rb +20 -28
  56. data/lib/rigor/inference/hkt_body.rb +8 -11
  57. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  58. data/lib/rigor/inference/hkt_registry.rb +10 -11
  59. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  60. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +156 -21
  61. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  62. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  63. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  64. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  65. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  66. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  67. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  68. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +90 -15
  69. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  70. data/lib/rigor/inference/method_dispatcher.rb +40 -48
  71. data/lib/rigor/inference/mutation_widening.rb +5 -11
  72. data/lib/rigor/inference/narrowing.rb +14 -16
  73. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  74. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  75. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  76. data/lib/rigor/inference/protection_scanner.rb +86 -0
  77. data/lib/rigor/inference/scope_indexer.rb +129 -55
  78. data/lib/rigor/inference/statement_evaluator.rb +244 -114
  79. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  80. data/lib/rigor/inference/synthetic_method.rb +7 -7
  81. data/lib/rigor/language_server/completion_provider.rb +6 -12
  82. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  83. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  84. data/lib/rigor/language_server/hover_provider.rb +2 -3
  85. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  86. data/lib/rigor/language_server/server.rb +9 -17
  87. data/lib/rigor/language_server.rb +4 -5
  88. data/lib/rigor/plugin/base.rb +10 -8
  89. data/lib/rigor/plugin/macro/block_as_method.rb +3 -4
  90. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  91. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  92. data/lib/rigor/plugin/macro.rb +4 -5
  93. data/lib/rigor/plugin/manifest.rb +45 -66
  94. data/lib/rigor/plugin/registry.rb +6 -7
  95. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  96. data/lib/rigor/protection/mutation_scanner.rb +120 -0
  97. data/lib/rigor/protection/mutator.rb +246 -0
  98. data/lib/rigor/rbs_extended.rb +24 -36
  99. data/lib/rigor/reflection.rb +4 -7
  100. data/lib/rigor/scope/discovery_index.rb +14 -2
  101. data/lib/rigor/scope.rb +54 -11
  102. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  103. data/lib/rigor/sig_gen/writer.rb +40 -2
  104. data/lib/rigor/source/constant_path.rb +62 -0
  105. data/lib/rigor/source.rb +1 -0
  106. data/lib/rigor/type/bound_method.rb +2 -11
  107. data/lib/rigor/type/combinator.rb +16 -3
  108. data/lib/rigor/type/constant.rb +2 -11
  109. data/lib/rigor/type/data_class.rb +2 -11
  110. data/lib/rigor/type/data_instance.rb +2 -11
  111. data/lib/rigor/type/hash_shape.rb +2 -11
  112. data/lib/rigor/type/integer_range.rb +2 -11
  113. data/lib/rigor/type/intersection.rb +2 -11
  114. data/lib/rigor/type/nominal.rb +2 -11
  115. data/lib/rigor/type/plain_lattice.rb +37 -0
  116. data/lib/rigor/type/refined.rb +72 -13
  117. data/lib/rigor/type/singleton.rb +2 -11
  118. data/lib/rigor/type/struct_class.rb +75 -0
  119. data/lib/rigor/type/struct_instance.rb +93 -0
  120. data/lib/rigor/type/tuple.rb +5 -15
  121. data/lib/rigor/type.rb +2 -0
  122. data/lib/rigor/version.rb +1 -1
  123. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  124. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  125. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +3 -3
  126. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  127. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  128. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +7 -10
  129. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  130. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  131. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +6 -8
  132. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  133. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +1 -2
  134. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  135. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  136. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  137. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  138. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  139. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  140. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  141. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +7 -9
  142. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  143. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  144. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +3 -3
  145. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  146. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  147. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  148. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +1 -1
  149. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  150. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  151. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +5 -5
  152. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
  153. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
  154. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  155. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +19 -14
  156. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  157. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  158. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  159. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  160. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  161. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  162. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  163. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +28 -41
  164. data/sig/rigor/scope.rbs +9 -1
  165. data/sig/rigor/type.rbs +36 -1
  166. metadata +19 -1
@@ -1,14 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "English"
3
4
  require "optionparser"
4
5
  require "prism"
5
6
 
6
7
  require_relative "../configuration"
8
+ require_relative "options"
7
9
  require_relative "../environment"
8
10
  require_relative "../inference/precision_scanner"
11
+ require_relative "../inference/protection_scanner"
12
+ require_relative "../inference/parameter_inference_collector"
13
+ require_relative "../protection/mutation_scanner"
14
+ require_relative "../language_server/project_context"
9
15
  require_relative "../scope"
10
16
  require_relative "coverage_report"
11
17
  require_relative "coverage_renderer"
18
+ require_relative "coverage_scan"
19
+ require_relative "protection_report"
20
+ require_relative "protection_renderer"
21
+ require_relative "mutation_protection_report"
22
+ require_relative "mutation_protection_renderer"
12
23
  require_relative "command"
13
24
 
14
25
  module Rigor
@@ -32,10 +43,15 @@ module Rigor
32
43
  # @return [Integer] CLI exit status.
33
44
  def run
34
45
  options = parse_options
46
+ return mutation_misuse_error if options[:mutation] && !options[:protection]
47
+ return run_mutation_protection(options) if options[:mutation]
48
+
35
49
  paths = collect_paths(@argv, command_name: "coverage")
36
50
  return CLI::EXIT_USAGE if paths.nil?
37
51
  return usage_error if paths.empty?
38
52
 
53
+ return run_protection(paths, options) if options[:protection]
54
+
39
55
  report = scan_paths(paths, options)
40
56
  CoverageRenderer.new(out: @out).render(report, format: options.fetch(:format))
41
57
  determine_exit(report, options)
@@ -44,45 +60,112 @@ module Rigor
44
60
  private
45
61
 
46
62
  def parse_options
47
- options = { format: "text", threshold: nil, config: nil }
63
+ options = { format: "text", threshold: nil, config: nil, protection: false, mutation: false }
48
64
 
49
65
  OptionParser.new do |opts|
50
66
  opts.banner = USAGE
51
67
  opts.on("--format=FORMAT", "Output format: text or json") { |v| options[:format] = v }
52
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
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 }
53
78
  opts.on(
54
79
  "--threshold=RATIO", Float,
55
- "Exit 1 when precision ratio is below RATIO (0.0–1.0)"
80
+ "Exit 1 when the precision (or, with --protection, protection/effectiveness) ratio is below RATIO (0.0–1.0)"
56
81
  ) { |v| options[:threshold] = v }
57
82
  end.parse!(@argv)
58
83
 
59
84
  options
60
85
  end
61
86
 
62
- def usage_error
63
- @err.puts("coverage: at least one path is required")
87
+ def mutation_misuse_error
88
+ @err.puts("coverage: --mutation requires --protection")
64
89
  @err.puts(USAGE)
65
90
  CLI::EXIT_USAGE
66
91
  end
67
92
 
68
- def scan_paths(paths, options)
93
+ def run_protection(paths, options)
94
+ report = scan_protection(paths, options)
95
+ ProtectionRenderer.new(out: @out).render(report, format: options.fetch(:format))
96
+ determine_protection_exit(report, options)
97
+ end
98
+
99
+ def scan_protection(paths, options)
69
100
  configuration = Configuration.load(options.fetch(:config))
70
- scope = Scope.empty(environment: project_environment(configuration))
71
- scanner = Inference::PrecisionScanner.new(scope: scope)
72
- accumulator = CoverageAccumulator.new
101
+ environment = project_environment(configuration)
102
+ scope = scope_with_inferred_params(paths, configuration, environment)
103
+ scanner = Inference::ProtectionScanner.new(scope: scope)
104
+ accumulator = ProtectionAccumulator.new
73
105
 
74
106
  paths.each { |path| scan_one(path, scanner, accumulator, configuration) }
75
- accumulator.to_report(paths, options)
107
+ accumulator.to_report
76
108
  end
77
109
 
78
- def project_environment(configuration)
79
- Environment.for_project(
80
- libraries: configuration.libraries,
81
- signature_paths: configuration.signature_paths
110
+ # ADR-67 WD3 — seed the call-site parameter-inference table so the
111
+ # protection scan counts an inferred-parameter receiver (e.g. `node.loc`
112
+ # where `node` is a `def compile(node)` parameter) as protected when its
113
+ # call sites resolve to concrete argument types. ONLY the parameter table
114
+ # is seeded — no cross-file discovery — so every site that does not gain
115
+ # an inferred parameter type is classified byte-identically to the
116
+ # un-inferred baseline. Collection spans the scanned `paths`.
117
+ def scope_with_inferred_params(paths, configuration, environment)
118
+ base = Scope.empty(environment: environment)
119
+ table = Inference::ParameterInferenceCollector.collect(
120
+ files: paths, environment: environment, target_ruby: configuration.target_ruby
82
121
  )
122
+ return base if table.empty?
123
+
124
+ base.with_discovery(base.discovery.with(param_inferred_types: table))
83
125
  end
84
126
 
85
- def scan_one(path, scanner, accumulator, configuration)
127
+ def determine_protection_exit(report, options)
128
+ return 1 unless report.parse_errors.empty?
129
+
130
+ threshold = options[:threshold]
131
+ return 0 if threshold.nil?
132
+
133
+ report.ratio < threshold ? 1 : 0
134
+ end
135
+
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)
86
169
  source = File.read(path)
87
170
  parse_result = Prism.parse(source, filepath: path, version: configuration.target_ruby)
88
171
  if parse_result.errors.any?
@@ -90,7 +173,56 @@ module Rigor
90
173
  return
91
174
  end
92
175
 
93
- accumulator.absorb(path, scanner.scan(parse_result.value))
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
+ def usage_error
208
+ @err.puts("coverage: at least one path is required")
209
+ @err.puts(USAGE)
210
+ CLI::EXIT_USAGE
211
+ end
212
+
213
+ def scan_paths(paths, options)
214
+ CoverageScan.precision_report(files: paths, configuration: Configuration.load(options.fetch(:config)))
215
+ end
216
+
217
+ # Delegated to the shared scan module (see {CoverageScan}); the
218
+ # protection path below reuses both, and `rigor check --coverage`
219
+ # reuses `precision_report` over the same machinery.
220
+ def project_environment(configuration)
221
+ CoverageScan.project_environment(configuration)
222
+ end
223
+
224
+ def scan_one(path, scanner, accumulator, configuration)
225
+ CoverageScan.scan_into(path, scanner, accumulator, configuration)
94
226
  end
95
227
 
96
228
  def determine_exit(report, options)
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../configuration"
6
+ require_relative "../environment"
7
+ require_relative "../inference/precision_scanner"
8
+ require_relative "../scope"
9
+ require_relative "coverage_report"
10
+
11
+ module Rigor
12
+ class CLI
13
+ # Shared type-precision scan behind both `rigor coverage` (the
14
+ # dedicated command) and `rigor check --coverage` (the in-run
15
+ # coverage block). Walks each file's AST, types every expression via
16
+ # `Scope#type_of`, and accumulates the precision-tier breakdown into a
17
+ # `CoverageReport`. Extracted so the two surfaces stay byte-identical
18
+ # on the same file set.
19
+ module CoverageScan
20
+ module_function
21
+
22
+ # @param files [Array<String>] explicit `.rb` file paths to scan.
23
+ # @param configuration [Rigor::Configuration]
24
+ # @return [Rigor::CLI::CoverageReport]
25
+ def precision_report(files:, configuration:)
26
+ scope = Scope.empty(environment: project_environment(configuration))
27
+ scanner = Inference::PrecisionScanner.new(scope: scope)
28
+ accumulator = CoverageAccumulator.new
29
+ files.each { |path| scan_into(path, scanner, accumulator, configuration) }
30
+ accumulator.to_report(files, {})
31
+ end
32
+
33
+ def project_environment(configuration)
34
+ Environment.for_project(
35
+ libraries: configuration.libraries,
36
+ signature_paths: configuration.signature_paths
37
+ )
38
+ end
39
+
40
+ # Parses one file and feeds the scan result (or a parse-error
41
+ # record) into `accumulator`. `scanner` / `accumulator` are a
42
+ # matched pair — a `PrecisionScanner` + `CoverageAccumulator`, or a
43
+ # `ProtectionScanner` + `ProtectionAccumulator` — both of which
44
+ # respond to `scan(node)` and `absorb(path, result)`.
45
+ def scan_into(path, scanner, accumulator, configuration)
46
+ source = File.read(path)
47
+ parse_result = Prism.parse(source, filepath: path, version: configuration.target_ruby)
48
+ if parse_result.errors.any?
49
+ accumulator.record_parse_error(path, parse_result.errors)
50
+ return
51
+ end
52
+
53
+ accumulator.absorb(path, scanner.scan(parse_result.value))
54
+ end
55
+ end
56
+ end
57
+ end
@@ -95,6 +95,7 @@ module Rigor
95
95
  render_section("Fires when:", entry.fires_when)
96
96
  render_section("Does not fire when:", entry.does_not_fire_when)
97
97
  @out.puts("Suppression: #{entry.suppression}")
98
+ @out.puts("Documentation: #{entry.documentation_url}")
98
99
  @out.puts("Since: rigor #{entry.since}")
99
100
  end
100
101
 
@@ -109,6 +110,7 @@ module Rigor
109
110
  @out.puts("Authored severity: :#{entry.severity_authored}")
110
111
  profile_table = entry.severity_by_profile.map { |profile, sev| "#{profile} → :#{sev}" }.join(", ")
111
112
  @out.puts("Severity by profile: #{profile_table}")
113
+ @out.puts("Evidence tier: #{entry.evidence_tier || 'n/a (informational)'}")
112
114
  @out.puts("")
113
115
  end
114
116
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "command"
4
+ require_relative "options"
4
5
 
5
6
  require "optionparser"
6
7
 
@@ -8,11 +9,8 @@ module Rigor
8
9
  class CLI
9
10
  # Executes the `rigor lsp` command.
10
11
  #
12
+ # Starts a long-running LSP server over stdio (JSON-RPC).
11
13
  # See `docs/design/20260517-language-server.md` for the design.
12
- # Slice 1 (this commit) ships the CLI subcommand entry point.
13
- # The actual stdio JSON-RPC reader / writer is queued for slice 2;
14
- # invoking `rigor lsp` at slice 1 returns immediately after
15
- # validating the transport flag.
16
14
  class LspCommand < Command
17
15
  USAGE = "Usage: rigor lsp [options]"
18
16
 
@@ -109,9 +107,7 @@ module Rigor
109
107
  opts.on("--log=PATH", "Write LSP wire log + server debug to PATH (default: stderr)") do |value|
110
108
  options[:log] = value
111
109
  end
112
- opts.on("--config=PATH", "Path to the Rigor configuration file") do |value|
113
- options[:config] = value
114
- end
110
+ Options.add_config(opts, options)
115
111
  end
116
112
  parser.parse!(@argv)
117
113
  options
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rigor
6
+ class CLI
7
+ # Renders a {MutationProtectionReport} (ADR-63 Tier 2) as text or JSON. The
8
+ # text form leads with the effectiveness ratio (caught breakages), then the
9
+ # breakages Rigor missed ("add a type here"), then the least-effective files.
10
+ # The framing is always *where to add a type*, never "your code is broken".
11
+ class MutationProtectionRenderer
12
+ TOP_CALLS = 15
13
+ TOP_FILES = 10
14
+
15
+ def initialize(out:)
16
+ @out = out
17
+ end
18
+
19
+ def render(report, format:)
20
+ format == "json" ? render_json(report) : render_text(report)
21
+ end
22
+
23
+ private
24
+
25
+ def render_json(report)
26
+ @out.puts(JSON.pretty_generate(report.to_h))
27
+ end
28
+
29
+ def render_text(report)
30
+ pct = (report.ratio * 100).round(1)
31
+ @out.puts "Type-protection effectiveness (Tier 2 — mutation kill rate)"
32
+ @out.puts " caught breakages: #{report.total_killed} / #{report.grand_total} (#{pct}%)"
33
+ @out.puts " (effectiveness = when a type-visible bug was introduced, Rigor caught it)"
34
+ render_missed(report)
35
+ render_files(report)
36
+ end
37
+
38
+ def render_missed(report)
39
+ missed = report.missed
40
+ return if missed.empty?
41
+
42
+ @out.puts "\nAdd a type here — breakages Rigor missed (a wrong call that stayed silent):"
43
+ missed.first(TOP_CALLS).each do |call|
44
+ @out.puts format(" %<count>-5d #%<method>s e.g. %<sites>s",
45
+ count: call.count, method: call.method_name, sites: call.examples.join(" "))
46
+ end
47
+ @out.puts " (#{missed.size - TOP_CALLS} more)" if missed.size > TOP_CALLS
48
+ end
49
+
50
+ def render_files(report)
51
+ worst = report.files.reject { |f| f.survived.zero? }.sort_by(&:ratio).first(TOP_FILES)
52
+ return if worst.empty?
53
+
54
+ @out.puts "\nLeast-effective files:"
55
+ worst.each do |file|
56
+ total = file.killed + file.survived
57
+ @out.puts format(" %<pct>5.1f%% %<path>s (%<n>d/%<total>d breakages caught)",
58
+ pct: file.ratio * 100, path: file.path, n: file.killed, total: total)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class CLI
5
+ # ADR-63 Tier 2 — aggregates per-file {Protection::MutationScanner}
6
+ # results into a project-level *effectiveness* report: the kill ratio (when
7
+ # a type-visible bug was introduced, how often Rigor caught it), the per-file
8
+ # breakdown, and a ranked "add a type here" list keyed by the method whose
9
+ # breakage Rigor most often *missed* — the sites where a receiver annotation
10
+ # would buy real catching power.
11
+ #
12
+ # The framing is load-bearing (ADR-63 Criterion A / ADR-62 Criterion A): the
13
+ # number is *effectiveness*, the survivors are *missed breakages / where to
14
+ # add a type*, never "your code is broken".
15
+ FileEffectiveness = Data.define(:path, :killed, :survived, :ratio)
16
+ MissedBreakage = Data.define(:method_name, :count, :examples)
17
+
18
+ MutationProtectionReport = Data.define(:files, :missed, :parse_errors) do
19
+ def total_killed = files.sum(&:killed)
20
+ def total_survived = files.sum(&:survived)
21
+ def grand_total = total_killed + total_survived
22
+ def ratio = grand_total.zero? ? 1.0 : total_killed.to_f / grand_total
23
+
24
+ def to_h
25
+ {
26
+ "mode" => "mutation",
27
+ "killed" => total_killed,
28
+ "survived" => total_survived,
29
+ "effectiveness_ratio" => ratio.round(4),
30
+ "files" => files.map do |f|
31
+ { "path" => f.path, "killed" => f.killed,
32
+ "survived" => f.survived, "ratio" => f.ratio.round(4) }
33
+ end,
34
+ "add_a_type_here" => missed.map do |m|
35
+ { "method" => m.method_name, "count" => m.count, "examples" => m.examples }
36
+ end,
37
+ "parse_errors" => parse_errors
38
+ }
39
+ end
40
+ end
41
+
42
+ class MutationProtectionAccumulator
43
+ def initialize
44
+ @files = []
45
+ @missed = Hash.new { |h, k| h[k] = { count: 0, examples: [] } }
46
+ @parse_errors = []
47
+ end
48
+
49
+ def absorb(file_result)
50
+ @files << FileEffectiveness.new(
51
+ path: file_result.path, killed: file_result.killed,
52
+ survived: file_result.survived, ratio: file_result.ratio
53
+ )
54
+ file_result.sites.each do |site|
55
+ bucket = @missed[site.method_name]
56
+ bucket[:count] += 1
57
+ bucket[:examples] << "#{file_result.path}:#{site.line}" if bucket[:examples].size < 3
58
+ end
59
+ end
60
+
61
+ def record_parse_error(path, errors)
62
+ @parse_errors << { "path" => path, "errors" => errors.size }
63
+ end
64
+
65
+ def to_report
66
+ missed = @missed
67
+ .map { |method, v| MissedBreakage.new(method_name: method, count: v[:count], examples: v[:examples]) }
68
+ .sort_by { |m| [-m.count, m.method_name] }
69
+ MutationProtectionReport.new(files: @files, missed: missed, parse_errors: @parse_errors)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -17,6 +17,15 @@ module Rigor
17
17
  module Options
18
18
  module_function
19
19
 
20
+ # Defines the standard `--config=PATH` flag on `parser`, writing the
21
+ # path into `options[:config]`. Used by every subcommand that loads a
22
+ # `.rigor.yml`; the few whose `--config` help text is intentionally
23
+ # bespoke (`diff`, `mcp`, `show-bleedingedge`) keep their own
24
+ # `parser.on` rather than this shared wording.
25
+ def add_config(parser, options)
26
+ parser.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
27
+ end
28
+
20
29
  # Defines the `--tmp-file` / `--instead-of` editor-mode flag pair
21
30
  # on `parser`, writing into `options`.
22
31
  def add_editor_mode(parser, options)
@@ -3,6 +3,7 @@
3
3
  require "optparse"
4
4
 
5
5
  require_relative "../configuration"
6
+ require_relative "options"
6
7
  require_relative "../plugin"
7
8
  require_relative "../plugin/loader"
8
9
  require_relative "../plugin/services"
@@ -102,7 +103,7 @@ module Rigor
102
103
  options = { config: nil, format: "text", strict: false, capabilities: false }
103
104
  OptionParser.new do |opts|
104
105
  opts.banner = USAGE
105
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
106
+ Options.add_config(opts, options)
106
107
  opts.on("--format=FORMAT", "Output format: text (default) or json") { |v| options[:format] = v }
107
108
  opts.on("--strict", "Exit 1 if any plugin failed to load (CI gate)") { options[:strict] = true }
108
109
  opts.on("--capabilities", "Emit the per-plugin extension-protocol catalogue (ADR-37)") do
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rigor
6
+ class CLI
7
+ # Renders an {ProtectionReport} (ADR-63 Tier 1) as text or JSON. The text
8
+ # form leads with the protected ratio, then the highest-traffic untyped
9
+ # dispatches ("add a type here"), then the lowest-protected files. The
10
+ # framing is always *where to add a type*, never "your code is broken".
11
+ class ProtectionRenderer
12
+ TOP_CALLS = 15
13
+ TOP_FILES = 10
14
+
15
+ def initialize(out:)
16
+ @out = out
17
+ end
18
+
19
+ def render(report, format:)
20
+ format == "json" ? render_json(report) : render_text(report)
21
+ end
22
+
23
+ private
24
+
25
+ def render_json(report)
26
+ @out.puts(JSON.pretty_generate(report.to_h))
27
+ end
28
+
29
+ def render_text(report)
30
+ pct = (report.ratio * 100).round(1)
31
+ @out.puts "Type-protection coverage (Tier 1 — dispatch-site receiver concreteness)"
32
+ @out.puts " protected dispatch sites: #{report.total_protected} / #{report.grand_total} (#{pct}%)"
33
+ @out.puts " (protected = Rigor can catch a wrong call here; an upper bound on real protection)"
34
+ render_untyped_calls(report)
35
+ render_files(report)
36
+ end
37
+
38
+ def render_untyped_calls(report)
39
+ calls = report.untyped_calls
40
+ return if calls.empty?
41
+
42
+ @out.puts "\nAdd a type here — methods most often called on an untyped receiver:"
43
+ calls.first(TOP_CALLS).each do |call|
44
+ @out.puts format(" %<count>-5d #%<method>s e.g. %<sites>s",
45
+ count: call.count, method: call.method_name, sites: call.examples.join(" "))
46
+ end
47
+ @out.puts " (#{calls.size - TOP_CALLS} more)" if calls.size > TOP_CALLS
48
+ end
49
+
50
+ def render_files(report)
51
+ worst = report.files.reject { |f| f.unprotected_count.zero? }.sort_by(&:ratio).first(TOP_FILES)
52
+ return if worst.empty?
53
+
54
+ @out.puts "\nLeast-protected files:"
55
+ worst.each do |file|
56
+ total = file.protected_count + file.unprotected_count
57
+ @out.puts format(" %<pct>5.1f%% %<path>s (%<n>d/%<total>d protected)",
58
+ pct: file.ratio * 100, path: file.path, n: file.protected_count, total: total)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class CLI
5
+ # ADR-63 Tier 1 — aggregates per-file {Inference::ProtectionScanner}
6
+ # results into a project-level protection report: the protected ratio, the
7
+ # per-file breakdown, and a ranked "add a type here" list keyed by the
8
+ # method called on an unprotected (`Dynamic`) receiver — the highest-traffic
9
+ # untyped dispatches, where a receiver annotation buys the most catching
10
+ # power.
11
+ FileProtection = Data.define(:path, :protected_count, :unprotected_count, :ratio)
12
+ UntypedCall = Data.define(:method_name, :count, :examples)
13
+
14
+ ProtectionReport = Data.define(:files, :untyped_calls, :parse_errors) do
15
+ def total_protected = files.sum(&:protected_count)
16
+ def total_unprotected = files.sum(&:unprotected_count)
17
+ def grand_total = total_protected + total_unprotected
18
+ def ratio = grand_total.zero? ? 1.0 : total_protected.to_f / grand_total
19
+
20
+ def to_h
21
+ {
22
+ "protected" => total_protected,
23
+ "unprotected" => total_unprotected,
24
+ "protection_ratio" => ratio.round(4),
25
+ "files" => files.map do |f|
26
+ { "path" => f.path, "protected" => f.protected_count,
27
+ "unprotected" => f.unprotected_count, "ratio" => f.ratio.round(4) }
28
+ end,
29
+ "add_a_type_here" => untyped_calls.map do |c|
30
+ { "method" => c.method_name, "count" => c.count, "examples" => c.examples }
31
+ end,
32
+ "parse_errors" => parse_errors
33
+ }
34
+ end
35
+ end
36
+
37
+ class ProtectionAccumulator
38
+ def initialize
39
+ @files = []
40
+ @calls = Hash.new { |h, k| h[k] = { count: 0, examples: [] } }
41
+ @parse_errors = []
42
+ end
43
+
44
+ def absorb(path, file_result)
45
+ @files << FileProtection.new(
46
+ path: path, protected_count: file_result.protected_count,
47
+ unprotected_count: file_result.unprotected_count, ratio: file_result.ratio
48
+ )
49
+ file_result.sites.each do |site|
50
+ bucket = @calls[site.method_name]
51
+ bucket[:count] += 1
52
+ bucket[:examples] << "#{path}:#{site.line}" if bucket[:examples].size < 3
53
+ end
54
+ end
55
+
56
+ def record_parse_error(path, errors)
57
+ @parse_errors << { "path" => path, "errors" => errors.size }
58
+ end
59
+
60
+ def to_report
61
+ untyped = @calls
62
+ .map { |method, v| UntypedCall.new(method_name: method, count: v[:count], examples: v[:examples]) }
63
+ .sort_by { |c| [-c.count, c.method_name] }
64
+ ProtectionReport.new(files: @files, untyped_calls: untyped, parse_errors: @parse_errors)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -3,6 +3,7 @@
3
3
  require "optionparser"
4
4
 
5
5
  require_relative "../configuration"
6
+ require_relative "options"
6
7
  require_relative "../sig_gen"
7
8
  require_relative "command"
8
9
 
@@ -145,7 +146,7 @@ module Rigor
145
146
  opts.on("--tighter-returns", "Emit only tighter-return classifications") do
146
147
  options[:selection] << SigGen::Classification::TIGHTER_RETURN
147
148
  end
148
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
149
+ Options.add_config(opts, options)
149
150
  end
150
151
  end
151
152
 
@@ -5,6 +5,7 @@ require "optionparser"
5
5
  require "prism"
6
6
 
7
7
  require_relative "../configuration"
8
+ require_relative "options"
8
9
  require_relative "../environment"
9
10
  require_relative "../scope"
10
11
  require_relative "../inference/flow_tracer"
@@ -63,7 +64,7 @@ module Rigor
63
64
  options[:line] = value
64
65
  end
65
66
  opts.on("--verbose", "Include every expression enter/result frame") { options[:verbose] = true }
66
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
67
+ Options.add_config(opts, options)
67
68
  end
68
69
  parser.parse!(@argv)
69
70
  options