rigortype 0.2.8 → 0.3.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 (207) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/core_overlay/csv.rbs +28 -0
  4. data/data/core_overlay/psych.rbs +22 -0
  5. data/docs/handbook/01-getting-started.md +9 -1
  6. data/docs/handbook/02-everyday-types.md +4 -1
  7. data/docs/handbook/03-narrowing.md +2 -1
  8. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  9. data/docs/handbook/06-classes.md +18 -9
  10. data/docs/handbook/08-understanding-errors.md +4 -3
  11. data/docs/handbook/11-sig-gen.md +11 -1
  12. data/docs/handbook/appendix-mypy.md +4 -3
  13. data/docs/install.md +14 -6
  14. data/docs/manual/01-installation.md +99 -4
  15. data/docs/manual/02-cli-reference.md +65 -19
  16. data/docs/manual/03-configuration.md +34 -2
  17. data/docs/manual/04-diagnostics.md +23 -0
  18. data/docs/manual/07-plugins.md +5 -5
  19. data/docs/manual/09-editor-integration.md +4 -4
  20. data/docs/manual/11-ci.md +51 -0
  21. data/docs/manual/12-caching.md +65 -0
  22. data/docs/manual/14-rails-quickstart.md +4 -2
  23. data/docs/manual/15-type-protection-coverage.md +21 -0
  24. data/docs/manual/plugins/rigor-actionpack.md +1 -1
  25. data/docs/manual/plugins/rigor-activerecord.md +12 -5
  26. data/docs/manual/plugins/rigor-rails-routes.md +11 -0
  27. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  28. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  29. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  30. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  31. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  32. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  33. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  34. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  35. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  36. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  37. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  38. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +93 -0
  39. data/lib/rigor/analysis/check_rules.rb +557 -95
  40. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  41. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  42. data/lib/rigor/analysis/incremental_session.rb +456 -51
  43. data/lib/rigor/analysis/path_expansion.rb +42 -0
  44. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  45. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  46. data/lib/rigor/analysis/run_cache_key.rb +68 -0
  47. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  48. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  49. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  50. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  51. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  52. data/lib/rigor/analysis/runner.rb +343 -68
  53. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  54. data/lib/rigor/analysis/worker_session.rb +30 -2
  55. data/lib/rigor/bleeding_edge.rb +37 -8
  56. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  57. data/lib/rigor/cache/descriptor.rb +88 -28
  58. data/lib/rigor/cache/file_digest.rb +158 -0
  59. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  60. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  61. data/lib/rigor/cache/store.rb +207 -60
  62. data/lib/rigor/ci_detector.rb +90 -0
  63. data/lib/rigor/cli/annotate_command.rb +15 -9
  64. data/lib/rigor/cli/baseline_command.rb +4 -1
  65. data/lib/rigor/cli/check_command.rb +212 -27
  66. data/lib/rigor/cli/coverage_command.rb +56 -30
  67. data/lib/rigor/cli/diagnostic_formats.rb +4 -1
  68. data/lib/rigor/cli/docs_command.rb +0 -30
  69. data/lib/rigor/cli/doctor_command.rb +163 -23
  70. data/lib/rigor/cli/lsp_command.rb +5 -0
  71. data/lib/rigor/cli/mcp_command.rb +5 -0
  72. data/lib/rigor/cli/plugins_command.rb +36 -6
  73. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  74. data/lib/rigor/cli/probe_environment.rb +85 -0
  75. data/lib/rigor/cli/protection_fork_scan.rb +55 -0
  76. data/lib/rigor/cli/protection_report.rb +7 -1
  77. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  78. data/lib/rigor/cli/skill_command.rb +1 -32
  79. data/lib/rigor/cli/trace_command.rb +5 -6
  80. data/lib/rigor/cli/type_of_command.rb +9 -8
  81. data/lib/rigor/cli/type_scan_command.rb +8 -8
  82. data/lib/rigor/cli.rb +15 -4
  83. data/lib/rigor/config_audit.rb +48 -2
  84. data/lib/rigor/configuration/severity_profile.rb +31 -0
  85. data/lib/rigor/configuration.rb +156 -8
  86. data/lib/rigor/environment/default_libraries.rb +35 -0
  87. data/lib/rigor/environment/missing_gem_constant_index.rb +128 -0
  88. data/lib/rigor/environment/rbs_loader.rb +164 -5
  89. data/lib/rigor/environment.rb +71 -43
  90. data/lib/rigor/inference/budget_trace.rb +77 -2
  91. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  92. data/lib/rigor/inference/def_handle.rb +23 -0
  93. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  94. data/lib/rigor/inference/def_return_typer.rb +2 -1
  95. data/lib/rigor/inference/expression_typer.rb +379 -113
  96. data/lib/rigor/inference/fork_map.rb +87 -0
  97. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  98. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  99. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  100. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  101. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +11 -0
  102. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  103. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  104. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  105. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  106. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  107. data/lib/rigor/inference/mutation_widening.rb +42 -8
  108. data/lib/rigor/inference/narrowing.rb +245 -17
  109. data/lib/rigor/inference/parameter_inference_collector.rb +70 -10
  110. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  111. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  112. data/lib/rigor/inference/scope_indexer.rb +460 -49
  113. data/lib/rigor/inference/statement_evaluator.rb +111 -17
  114. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  115. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  116. data/lib/rigor/inference/void_origin.rb +25 -0
  117. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  118. data/lib/rigor/language_server/completion_provider.rb +2 -1
  119. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  120. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  121. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  122. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  123. data/lib/rigor/plugin/base.rb +9 -22
  124. data/lib/rigor/plugin/inflector.rb +12 -3
  125. data/lib/rigor/plugin/io_boundary.rb +5 -1
  126. data/lib/rigor/plugin/isolation.rb +81 -11
  127. data/lib/rigor/plugin/load_error.rb +10 -1
  128. data/lib/rigor/plugin/loader.rb +96 -14
  129. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  130. data/lib/rigor/plugin/registry.rb +32 -23
  131. data/lib/rigor/plugin.rb +26 -0
  132. data/lib/rigor/protection/mutator.rb +3 -2
  133. data/lib/rigor/reflection.rb +64 -0
  134. data/lib/rigor/runtime/jit.rb +128 -0
  135. data/lib/rigor/scope/discovery_index.rb +12 -2
  136. data/lib/rigor/scope.rb +122 -28
  137. data/lib/rigor/sig_gen/classification.rb +5 -1
  138. data/lib/rigor/sig_gen/generator.rb +38 -6
  139. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  140. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  141. data/lib/rigor/sig_gen/renderer.rb +10 -0
  142. data/lib/rigor/sig_gen/write_result.rb +9 -4
  143. data/lib/rigor/sig_gen/writer.rb +24 -2
  144. data/lib/rigor/source/node_children.rb +116 -0
  145. data/lib/rigor/source/node_locator.rb +3 -1
  146. data/lib/rigor/source/node_walker.rb +4 -2
  147. data/lib/rigor/source.rb +1 -0
  148. data/lib/rigor/type/difference.rb +28 -24
  149. data/lib/rigor/type/hash_shape.rb +34 -10
  150. data/lib/rigor/version.rb +1 -1
  151. data/lib/rigortype.rb +24 -0
  152. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  154. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +23 -8
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +21 -0
  157. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  158. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +13 -2
  159. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +65 -2
  160. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +20 -2
  161. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  162. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +172 -0
  163. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +22 -8
  164. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  165. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  166. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +32 -0
  167. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  168. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  169. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  170. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  171. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  172. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  173. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  174. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +191 -0
  175. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  176. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +19 -1
  177. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +48 -15
  178. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +42 -12
  179. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  180. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  181. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  182. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  183. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  184. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  185. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  186. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  187. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  188. data/sig/prism_node_children.rbs +9 -0
  189. data/sig/rigor/environment.rbs +1 -0
  190. data/sig/rigor/inference.rbs +7 -0
  191. data/sig/rigor/plugin/base.rbs +2 -3
  192. data/sig/rigor/reflection.rbs +1 -0
  193. data/sig/rigor/scope.rbs +12 -1
  194. data/skills/rigor-ci-setup/SKILL.md +10 -0
  195. data/skills/rigor-editor-setup/SKILL.md +11 -0
  196. data/skills/rigor-next-steps/SKILL.md +23 -2
  197. data/skills/rigor-plugin-author/SKILL.md +3 -3
  198. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  199. data/skills/rigor-plugin-review/SKILL.md +2 -2
  200. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  201. data/skills/rigor-project-init/SKILL.md +29 -3
  202. data/skills/rigor-project-init/references/01-detect.md +1 -1
  203. data/skills/rigor-project-init/references/02-configure.md +39 -5
  204. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  205. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  206. metadata +49 -24
  207. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -14,6 +14,7 @@ require_relative "../inference/parameter_inference_collector"
14
14
  require_relative "../protection/mutation_scanner"
15
15
  require_relative "../protection/test_suite_oracle"
16
16
  require_relative "../language_server/project_context"
17
+ require_relative "../runtime/jit"
17
18
  require_relative "../scope"
18
19
  require_relative "coverage_report"
19
20
  require_relative "coverage_renderer"
@@ -25,6 +26,8 @@ require_relative "mutation_protection_renderer"
25
26
  require_relative "fused_protection_report"
26
27
  require_relative "fused_protection_renderer"
27
28
  require_relative "coverage_mutation"
29
+ require_relative "protection_fork_scan"
30
+ require_relative "check_runner_factory"
28
31
  require_relative "command"
29
32
 
30
33
  module Rigor
@@ -51,30 +54,43 @@ module Rigor
51
54
  DEFAULT_TEST_COMMAND = %w[bundle exec rake].freeze
52
55
 
53
56
  # @return [Integer] CLI exit status.
54
- def run
57
+ def run # rubocop:disable Metrics/AbcSize
58
+ # Arm deferred YJIT enablement (Runtime::Jit): a scan long enough to
59
+ # amortize JIT compile cost enables mid-flight; a short one finishes
60
+ # first and never pays it. Same seam as `rigor check`.
61
+ Runtime::Jit.enable_after(Runtime::Jit.deadline_seconds)
55
62
  options = parse_options
56
63
  return mutation_misuse_error if options[:mutation] && !options[:protection]
57
64
  return with_tests_misuse_error if options[:with_tests] && !options[:mutation]
58
65
  return include_dynamic_misuse_error if options[:include_dynamic] && !options[:with_tests]
59
66
  return run_mutation_protection(options) if options[:mutation]
60
67
 
61
- paths = collect_paths(@argv, command_name: "coverage")
68
+ configuration = Configuration.load(options.fetch(:config))
69
+ paths = resolve_paths(configuration)
62
70
  return CLI::EXIT_USAGE if paths.nil?
63
71
  return usage_error if paths.empty?
64
72
 
65
- return run_protection(paths, options) if options[:protection]
73
+ return run_protection(paths, options, configuration) if options[:protection]
66
74
 
67
- report = scan_paths(paths, options)
75
+ report = scan_paths(paths, configuration)
68
76
  CoverageRenderer.new(out: @out).render(report, format: options.fetch(:format))
69
77
  determine_exit(report, options)
70
78
  end
71
79
 
72
80
  private
73
81
 
82
+ # Like `rigor check`, fall back to the configured `paths:` when no path is given on the command line
83
+ # (`coverage` previously required an explicit path — operational friction the two commands should not
84
+ # differ on). The mutation mode keeps its own git-changed-files default and returns before this.
85
+ def resolve_paths(configuration)
86
+ args = @argv.empty? ? configuration.paths : @argv
87
+ collect_paths(args, command_name: "coverage")
88
+ end
89
+
74
90
  def parse_options
75
91
  options = { format: "text", threshold: nil, config: nil, protection: false, mutation: false,
76
92
  with_tests: false, test_command: DEFAULT_TEST_COMMAND, include_dynamic: false,
77
- limit: nil, seed: 1 }
93
+ limit: nil, seed: 1, workers: nil }
78
94
  OptionParser.new { |opts| define_options(opts, options) }.parse!(@argv)
79
95
  options
80
96
  end
@@ -87,6 +103,10 @@ module Rigor
87
103
  options[:protection] = true
88
104
  end
89
105
  define_mutation_options(opts, options)
106
+ opts.on("--workers=N", Integer, "With --protection: fork N workers over the scanned files " \
107
+ "(default: config parallel.workers / RIGOR_RACTOR_WORKERS / 0)") do |v|
108
+ options[:workers] = v
109
+ end
90
110
  opts.on("--threshold=RATIO", Float, "Exit 1 when the precision (or, with --protection, " \
91
111
  "protection/effectiveness) ratio is below RATIO (0.0–1.0)") do |v|
92
112
  options[:threshold] = v
@@ -135,23 +155,39 @@ module Rigor
135
155
  CLI::EXIT_USAGE
136
156
  end
137
157
 
138
- def run_protection(paths, options)
139
- report = scan_protection(paths, options)
158
+ def run_protection(paths, options, configuration)
159
+ report = scan_protection(paths, options, configuration)
140
160
  ProtectionRenderer.new(out: @out).render(report, format: options.fetch(:format))
141
161
  determine_protection_exit(report, options)
142
162
  end
143
163
 
144
- def scan_protection(paths, options)
145
- configuration = Configuration.load(options.fetch(:config))
164
+ # Builds the plugin-aware environment and the seeded scope ONCE on the parent, then fork-pools the
165
+ # per-file scan across the resolved worker count (P3-10). The scan is pure-read and each file is
166
+ # independent, so this is a fork-map + ordered reduce: {ProtectionForkScan} returns `{path => result}`
167
+ # and the parent absorbs in `paths` order so the report is byte-identical to a sequential run.
168
+ def scan_protection(paths, options, configuration)
146
169
  environment = plugin_aware_environment(configuration)
147
- scope = scope_with_inferred_params(paths, configuration, environment)
170
+ workers = CheckRunnerFactory.resolve_workers(options, configuration)
171
+ scope = scope_with_inferred_params(paths, configuration, environment, workers)
148
172
  scanner = Inference::ProtectionScanner.new(scope: scope)
149
173
  accumulator = ProtectionAccumulator.new
150
174
 
151
- paths.each { |path| scan_one(path, scanner, accumulator, configuration) }
175
+ results = ProtectionForkScan.run(
176
+ paths: paths, scanner: scanner, environment: environment,
177
+ configuration: configuration, workers: workers
178
+ )
179
+ paths.each { |path| absorb_protection_result(accumulator, path, results.fetch(path)) }
152
180
  accumulator.to_report
153
181
  end
154
182
 
183
+ def absorb_protection_result(accumulator, path, result)
184
+ if result.is_a?(ProtectionForkScan::ParseError)
185
+ accumulator.record_parse_error_count(path, result.count)
186
+ else
187
+ accumulator.absorb(path, result)
188
+ end
189
+ end
190
+
155
191
  # Seed the protection scan's scope with the same cross-file facts `rigor check` resolves against, so a receiver
156
192
  # reads the type it actually has rather than a stripped-scope `Dynamic`:
157
193
  #
@@ -168,7 +204,7 @@ module Rigor
168
204
  #
169
205
  # Both span the scanned `paths` only (no whole-project pre-pass) — a site that gains neither is classified exactly
170
206
  # as before.
171
- def scope_with_inferred_params(paths, configuration, environment)
207
+ def scope_with_inferred_params(paths, configuration, environment, workers)
172
208
  base = Scope.empty(environment: environment)
173
209
  seed = {}
174
210
 
@@ -176,7 +212,7 @@ module Rigor
176
212
  seed[:discovered_classes] = discovered unless discovered.empty?
177
213
 
178
214
  table = Inference::ParameterInferenceCollector.collect(
179
- files: paths, environment: environment, target_ruby: configuration.target_ruby
215
+ files: paths, environment: environment, target_ruby: configuration.target_ruby, workers: workers
180
216
  )
181
217
  seed[:param_inferred_types] = table unless table.empty?
182
218
 
@@ -200,31 +236,21 @@ module Rigor
200
236
  CLI::EXIT_USAGE
201
237
  end
202
238
 
203
- def scan_paths(paths, options)
204
- CoverageScan.precision_report(files: paths, configuration: Configuration.load(options.fetch(:config)))
205
- end
206
-
207
- # Delegated to the shared scan module (see {CoverageScan}); the protection path below reuses both, and `rigor
208
- # check --coverage` reuses `precision_report` over the same machinery.
209
- def project_environment(configuration)
210
- CoverageScan.project_environment(configuration)
239
+ def scan_paths(paths, configuration)
240
+ CoverageScan.precision_report(files: paths, configuration: configuration)
211
241
  end
212
242
 
213
243
  # The protection scan must see the same receiver types `rigor check` does — including plugin-contributed
214
244
  # `dynamic_return` types (a controller's `params` → `ActionController::Parameters`, a `Model.where` →
215
- # `ActiveRecord::Relation[Model]`). The bare `project_environment` carries only the RBS environment (no plugin
216
- # registry), so every plugin-typed receiver reads `Dynamic` and its dispatch site is miscounted as *unprotected* —
217
- # a systematic undercount of what Rigor actually types on a plugin-using project. `ProjectContext` builds the
218
- # plugin-aware environment (registry materialised + the per-run prepare pass that primes producers like the
219
- # controller / model index) exactly as the LSP and the runner do.
245
+ # `ActiveRecord::Relation[Model]`). The bare `CoverageScan.project_environment` carries only the RBS environment
246
+ # (no plugin registry), so every plugin-typed receiver reads `Dynamic` and its dispatch site is miscounted as
247
+ # *unprotected* — a systematic undercount of what Rigor actually types on a plugin-using project.
248
+ # `ProjectContext` builds the plugin-aware environment (registry materialised + the per-run prepare pass that
249
+ # primes producers like the controller / model index) exactly as the LSP and the runner do.
220
250
  def plugin_aware_environment(configuration)
221
251
  LanguageServer::ProjectContext.new(configuration: configuration).environment
222
252
  end
223
253
 
224
- def scan_one(path, scanner, accumulator, configuration)
225
- CoverageScan.scan_into(path, scanner, accumulator, configuration)
226
- end
227
-
228
254
  def determine_exit(report, options)
229
255
  return 1 unless report.parse_errors.empty?
230
256
 
@@ -33,7 +33,9 @@ module Rigor
33
33
  FORMATS.include?(format)
34
34
  end
35
35
 
36
- # Renders `result` in the named CI format. Callers gate on {.supports?} first; an unrecognised format returns nil.
36
+ # Renders `result` in the named CI format. Callers gate on {.supports?} first, so an unrecognised format means
37
+ # {FORMATS} and the dispatch below have drifted apart — raise rather than return nil, which every caller would
38
+ # then have to guard.
37
39
  def render(result, format)
38
40
  case format
39
41
  when "sarif" then Sarif.new(result).render
@@ -42,6 +44,7 @@ module Rigor
42
44
  when "checkstyle" then Checkstyle.new(result).render
43
45
  when "junit" then Junit.new(result).render
44
46
  when "teamcity" then Teamcity.new(result).render
47
+ else raise ArgumentError, "unsupported format: #{format}"
45
48
  end
46
49
  end
47
50
 
@@ -20,9 +20,6 @@ module Rigor
20
20
  #
21
21
  # `<name>` resolves a category-qualified path (`handbook/03-narrowing`), a prefixed basename (`03-narrowing`), or a
22
22
  # short name (`narrowing`, when it is unique across categories).
23
- #
24
- # The pre-v0.3.0 verb spellings `rigor docs list` / `rigor docs path <name>` still work but emit a stderr
25
- # deprecation notice; they are removed in v0.3.0 (see docs/ROADMAP.md § "Scheduled CLI deprecations").
26
23
  class DocsCommand < Command
27
24
  USAGE = <<~USAGE
28
25
  Usage: rigor docs [<name>] [--path <name>] [--list [<category>]]
@@ -46,10 +43,6 @@ module Rigor
46
43
  rigor docs editor-integration
47
44
  rigor docs --path 17-driving-improvement
48
45
  rigor docs --list handbook
49
-
50
- Deprecated (removed in v0.3.0) — use the flags above:
51
- rigor docs list -> rigor docs --list
52
- rigor docs path <name> -> rigor docs --path <name>
53
46
  USAGE
54
47
 
55
48
  # The bundled docs live at `<gem_root>/docs/`. From `lib/rigor/cli/docs_command.rb` that is three directories up.
@@ -58,18 +51,8 @@ module Rigor
58
51
  HANDBOOK_ROOT = File.join(DOCS_ROOT, "handbook")
59
52
  LLMS_INDEX = File.join(DOCS_ROOT, "llms.txt")
60
53
 
61
- # The verb subcommands the flags superseded keep working with a stderr deprecation notice until this version drops
62
- # them. Each maps to the canonical advice printed and the flag it rewrites to.
63
- LEGACY_VERB_REMOVAL = "v0.3.0"
64
- LEGACY_VERBS = {
65
- "list" => { old: "list", advice: "--list", flag: "--list" },
66
- "path" => { old: "path <name>", advice: "--path <name>", flag: "--path" }
67
- }.freeze
68
-
69
54
  # @return [Integer] CLI exit status.
70
55
  def run
71
- rewrite_legacy_verb!
72
-
73
56
  case @argv.first
74
57
  when nil
75
58
  run_index
@@ -92,19 +75,6 @@ module Rigor
92
75
 
93
76
  private
94
77
 
95
- # Translate a deprecated verb spelling into its flag form, warning once on stderr, so the dispatch above only
96
- # handles canonical forms.
97
- def rewrite_legacy_verb!
98
- spec = LEGACY_VERBS[@argv.first]
99
- return unless spec
100
-
101
- @err.puts(
102
- "rigor docs: `#{spec.fetch(:old)}` is deprecated and will be removed in " \
103
- "#{LEGACY_VERB_REMOVAL}; use `rigor docs #{spec.fetch(:advice)}` instead."
104
- )
105
- @argv[0] = spec.fetch(:flag)
106
- end
107
-
108
78
  def run_index
109
79
  if File.file?(LLMS_INDEX)
110
80
  @out.write(File.read(LLMS_INDEX))
@@ -34,6 +34,8 @@ module Rigor
34
34
  CHECK_PLUGINS = "plugins"
35
35
  CHECK_BASELINE = "baseline"
36
36
  CHECK_RAILS = "rails_plugins"
37
+ CHECK_GEMFILE = "gemfile_install"
38
+ CHECK_PLUGIN_SKEW = "plugin_skew"
37
39
 
38
40
  RAILS_LOCK_MARKERS = %w[railties actionpack activerecord actioncable].freeze
39
41
  RAILS_PLUGIN_MARKERS = %w[
@@ -63,8 +65,9 @@ module Rigor
63
65
  # 3. RBS environment check.
64
66
  findings.concat(check_rbs_environment(result))
65
67
 
66
- # 4. Plugin load check.
67
- findings.concat(check_plugins(configuration))
68
+ # 4. Plugin load check + 8. installation-skew check share one loaded registry.
69
+ registry = load_plugin_registry(configuration)
70
+ findings.concat(check_plugins(registry))
68
71
 
69
72
  # 5. Baseline drift check.
70
73
  findings.concat(check_baseline(configuration, result))
@@ -72,6 +75,12 @@ module Rigor
72
75
  # 6. Rails locked but no Rails plugins.
73
76
  findings.concat(check_rails_plugins(configuration))
74
77
 
78
+ # 7. Rigor itself resolved as a project dependency.
79
+ findings.concat(check_gemfile_install)
80
+
81
+ # 8. A bundled plugin loaded from a different rigortype installation than the engine (#194 slice 3).
82
+ findings.concat(check_plugin_skew(registry))
83
+
75
84
  report(findings, options.fetch(:format))
76
85
  findings.any? { |f| f[:status] == :fail } ? 1 : 0
77
86
  rescue OptionParser::InvalidArgument => e
@@ -122,37 +131,67 @@ module Rigor
122
131
  stats = result.stats
123
132
  return [] unless stats
124
133
 
134
+ quarantined = quarantined_signature_diagnostics(result)
125
135
  if stats.rbs_classes_total.zero?
126
- [
127
- {
128
- check: CHECK_RBS,
129
- status: :fail,
130
- message: "RBS environment is empty (#{stats.rbs_classes_total} classes available)",
131
- hint: "The RBS environment failed to build or loaded no signatures. " \
132
- "Check `signature_paths:` for duplicate declarations, or run " \
133
- "`rbs collection install` for gem signatures."
134
- }
135
- ]
136
+ [empty_rbs_env_finding(stats)]
137
+ elsif quarantined.any?
138
+ [degraded_rbs_env_finding(stats, quarantined)]
136
139
  else
137
- [
138
- {
139
- check: CHECK_RBS,
140
- status: :pass,
141
- message: "RBS environment healthy (#{stats.rbs_classes_total} classes available)",
142
- hint: nil
143
- }
144
- ]
140
+ [healthy_rbs_env_finding(stats)]
145
141
  end
146
142
  end
147
143
 
148
- def check_plugins(configuration)
144
+ def empty_rbs_env_finding(stats)
145
+ {
146
+ check: CHECK_RBS,
147
+ status: :fail,
148
+ message: "RBS environment is empty (#{stats.rbs_classes_total} classes available)",
149
+ hint: "The RBS environment failed to build or loaded no signatures. " \
150
+ "Check `signature_paths:` for duplicate declarations, or run " \
151
+ "`rbs collection install` for gem signatures."
152
+ }
153
+ end
154
+
155
+ # The env is non-empty precisely BECAUSE the broken file was quarantined, so the class count alone reads
156
+ # as healthy — this is the case doctor used to pass. Classify the diagnostic the run already produced
157
+ # (ADR-77: route existing evidence) rather than re-probing the sig tree.
158
+ def degraded_rbs_env_finding(stats, quarantined)
159
+ {
160
+ check: CHECK_RBS,
161
+ status: :warn,
162
+ message: "RBS environment degraded — #{quarantined.size} `signature_paths:` file(s) " \
163
+ "skipped, #{stats.rbs_classes_total} classes available",
164
+ hint: "An unparseable `.rbs` is skipped so the rest of the env survives, which makes the " \
165
+ "run quieter, not cleaner: the types it declares are gone. Run `rbs validate` on your " \
166
+ "`sig/` set and fix the parse error. To make this fail the build, opt into the " \
167
+ "`reject-unparseable-signatures` bleeding-edge feature."
168
+ }
169
+ end
170
+
171
+ def healthy_rbs_env_finding(stats)
172
+ {
173
+ check: CHECK_RBS,
174
+ status: :pass,
175
+ message: "RBS environment healthy (#{stats.rbs_classes_total} classes available)",
176
+ hint: nil
177
+ }
178
+ end
179
+
180
+ def quarantined_signature_diagnostics(result)
181
+ result.diagnostics.select { |diagnostic| diagnostic.rule == "rbs.coverage.quarantined-signature" }
182
+ end
183
+
184
+ def load_plugin_registry(configuration)
149
185
  services = Plugin::Services.new(
150
186
  reflection: Reflection,
151
187
  type: Type::Combinator,
152
188
  configuration: configuration,
153
189
  cache_store: nil
154
190
  )
155
- registry = Plugin::Loader.load(configuration: configuration, services: services)
191
+ Plugin::Loader.load(configuration: configuration, services: services)
192
+ end
193
+
194
+ def check_plugins(registry)
156
195
  errors = registry.load_errors
157
196
  return [] if errors.empty?
158
197
 
@@ -166,6 +205,52 @@ module Rigor
166
205
  ]
167
206
  end
168
207
 
208
+ # WD5 (#194 slice 3) — a bundled plugin should load from the engine's own `plugins/` tree; slice 2
209
+ # anchors it there. This guards the residual anchoring cannot see: a bundled plugin whose resolved file
210
+ # sits OUTSIDE the engine tree — the fallback-require path, or a genuinely mixed installation where a
211
+ # foreign copy was already loaded. That is the #194 skew, which ran the engine with a load-bearing
212
+ # false-positive gate silently missing. Only plugins the engine bundles are checked; a third-party /
213
+ # project-bundle plugin is not the engine's to vouch for and is never flagged, and a nil resolved path
214
+ # (an injected requirer, an in-process no-op load) carries no provenance and is skipped.
215
+ def check_plugin_skew(registry)
216
+ registry.resolved_gem_paths.filter_map do |gem_name, resolved_path|
217
+ next if resolved_path.nil?
218
+ next unless Plugin::Loader.bundled_plugin_path(gem_name)
219
+ next if inside_engine_tree?(resolved_path)
220
+
221
+ plugin_skew_finding(gem_name, resolved_path)
222
+ end
223
+ end
224
+
225
+ def plugin_skew_finding(gem_name, resolved_path)
226
+ {
227
+ check: CHECK_PLUGIN_SKEW,
228
+ status: :warn,
229
+ message: "Plugin #{gem_name} loaded from a different rigortype installation than the engine " \
230
+ "(loaded from #{resolved_path}; engine at #{Plugin::Loader::ENGINE_ROOT})",
231
+ hint: "The engine and its bundled plugins are versioned together, so a copy resolved from another " \
232
+ "installation can run the engine with a mismatched plugin (the cause of wrong diagnostics in " \
233
+ "#194). Ensure a single `rigortype` is on the load path — remove any separately-installed " \
234
+ "`rigortype` gem that shadows this checkout, or reinstall so the engine and its `plugins/` " \
235
+ "tree come from one place."
236
+ }
237
+ end
238
+
239
+ # True when `resolved_path` lives inside the engine's own tree. Both sides are resolved through
240
+ # `File.realpath` first so a symlinked gem home / checkout (e.g. macOS `/var` → `/private/var`) does not
241
+ # read as foreign; a vanished file degrades to `File.expand_path` rather than raising.
242
+ def inside_engine_tree?(resolved_path)
243
+ engine = real_or_expanded(Plugin::Loader::ENGINE_ROOT)
244
+ target = real_or_expanded(resolved_path)
245
+ target == engine || target.start_with?("#{engine}#{File::SEPARATOR}")
246
+ end
247
+
248
+ def real_or_expanded(path)
249
+ File.realpath(path)
250
+ rescue StandardError
251
+ File.expand_path(path)
252
+ end
253
+
169
254
  def check_baseline(configuration, result)
170
255
  path = configuration.baseline_path
171
256
  return [] if path.nil? || !File.exist?(path)
@@ -238,6 +323,52 @@ module Rigor
238
323
  false
239
324
  end
240
325
 
326
+ # Rigor is a tool, not a library (ADR-27): resolving it as one of the project's own dependencies pins the
327
+ # application to Rigor's Ruby and drags Rigor's dependency graph into the app's resolution. The gem carries
328
+ # guardrails against arriving here, but they only fire during install and at boot — a project that already
329
+ # made the mistake never sees them again, so doctor names the state.
330
+ def check_gemfile_install
331
+ lock = File.join(Dir.pwd, "Gemfile.lock")
332
+ return [] unless File.file?(lock)
333
+ return [] unless rubygems_sourced_rigortype?(lock)
334
+
335
+ [
336
+ {
337
+ check: CHECK_GEMFILE,
338
+ status: :fail,
339
+ message: "Rigor is resolved as a dependency of this project (`rigortype` under GEM in Gemfile.lock)",
340
+ hint: "Remove `rigortype` from the Gemfile and install Rigor on its own — see " \
341
+ "https://github.com/rigortype/rigor/blob/master/docs/install.md. To keep the version " \
342
+ "pinned, use `mise use --pin gem:rigortype`, or an isolated `BUNDLE_GEMFILE` holding only " \
343
+ "Rigor (the CI chapter's pattern), which leaves your application's resolution untouched."
344
+ }
345
+ ]
346
+ end
347
+
348
+ # True only when `rigortype` resolves from a GEM remote — i.e. an ordinary `gem "rigortype"` in the app's
349
+ # Gemfile, the case worth reporting.
350
+ #
351
+ # The source is load-bearing, not decoration: Rigor's own repo, every fork of it, and any project vendoring
352
+ # Rigor for development all carry `rigortype` in Gemfile.lock too — under PATH (`remote: .`, from the
353
+ # `gemspec` directive) or GIT. Matching the name alone would fail this check on Rigor itself. Reading the
354
+ # lock by section also keeps a CHECKSUMS entry and a *dependency* line nested under another gem (six-space
355
+ # indent) from counting as a resolution.
356
+ def rubygems_sourced_rigortype?(path)
357
+ section = nil
358
+ in_specs = false
359
+
360
+ File.foreach(path) do |line|
361
+ case line
362
+ when /\A(\S.*?)\s*\z/ then (section = Regexp.last_match(1)) && (in_specs = false)
363
+ when /\A specs:\s*\z/ then in_specs = true
364
+ when /\A rigortype \(/ then return true if section == "GEM" && in_specs
365
+ end
366
+ end
367
+ false
368
+ rescue StandardError
369
+ false
370
+ end
371
+
241
372
  # ------------------------------------------------------------------
242
373
  # Reporting
243
374
  # ------------------------------------------------------------------
@@ -272,7 +403,9 @@ module Rigor
272
403
  if failures.empty? && warnings.empty?
273
404
  @out.puts("rigor doctor: all checks passed — no setup problems detected.")
274
405
  else
275
- @out.puts("rigor doctor: #{failures.size} issue(s) found")
406
+ # Count warnings too: a warn-only run (a degraded RBS env, a malformed baseline) used to headline
407
+ # "0 issue(s) found" above the very finding it was reporting.
408
+ @out.puts("rigor doctor: #{summary_counts(failures, warnings)}")
276
409
  failures.each { |f| print_finding(f) }
277
410
  warnings.each { |f| print_finding(f) } unless warnings.empty?
278
411
  end
@@ -280,6 +413,13 @@ module Rigor
280
413
  passes.each { |f| print_finding(f) } unless passes.empty?
281
414
  end
282
415
 
416
+ def summary_counts(failures, warnings)
417
+ parts = []
418
+ parts << "#{failures.size} issue(s)" unless failures.empty?
419
+ parts << "#{warnings.size} warning(s)" unless warnings.empty?
420
+ "#{parts.join(', ')} found"
421
+ end
422
+
283
423
  def print_finding(finding)
284
424
  label = case finding[:status]
285
425
  when :fail then "[FAIL]"
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "command"
4
4
  require_relative "options"
5
+ require_relative "../runtime/jit"
5
6
 
6
7
  require "optionparser"
7
8
 
@@ -25,6 +26,10 @@ module Rigor
25
26
  return CLI::EXIT_USAGE
26
27
  end
27
28
 
29
+ # A long-lived server always outlasts the JIT compile cost, so enable
30
+ # YJIT at boot rather than on the analysis deadline (Runtime::Jit).
31
+ Runtime::Jit.enable_now
32
+
28
33
  require_relative "../language_server"
29
34
  require_relative "../configuration"
30
35
  require "language_server-protocol"
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "command"
4
+ require_relative "../runtime/jit"
4
5
 
5
6
  require "optionparser"
6
7
 
@@ -27,6 +28,10 @@ module Rigor
27
28
  return CLI::EXIT_USAGE
28
29
  end
29
30
 
31
+ # A long-lived server always outlasts the JIT compile cost, so enable
32
+ # YJIT at boot rather than on the analysis deadline (Runtime::Jit).
33
+ Runtime::Jit.enable_now
34
+
30
35
  require_relative "../mcp"
31
36
  require_relative "../version"
32
37
 
@@ -7,6 +7,8 @@ require_relative "options"
7
7
  require_relative "../plugin"
8
8
  require_relative "../plugin/loader"
9
9
  require_relative "../plugin/services"
10
+ require_relative "../plugin/inflector"
11
+ require_relative "../environment/bundle_sig_discovery"
10
12
  require_relative "../reflection"
11
13
  require_relative "../type/combinator"
12
14
  require_relative "plugins_renderer"
@@ -67,7 +69,8 @@ module Rigor
67
69
  configuration = Configuration.load(options.fetch(:config))
68
70
  rows = build_rows(configuration)
69
71
 
70
- renderer = PluginsRenderer.new(rows: rows, configuration_path: config_path)
72
+ renderer = PluginsRenderer.new(rows: rows, configuration_path: config_path,
73
+ inflection: inflection_note(configuration, rows))
71
74
  @out.puts(render(renderer, options))
72
75
 
73
76
  any_load_errors = rows.any? { |row| row.fetch(:status) == :load_error }
@@ -129,6 +132,25 @@ module Rigor
129
132
  rows + orphan_errors
130
133
  end
131
134
 
135
+ # ADR-90 — activation-time visibility for inflection degradation. When a loaded plugin consumes
136
+ # `Plugin::Inflector` (ADR-39), probe whether the real `ActiveSupport::Inflector` is actually
137
+ # reachable — through Rigor's own gem environment or the analyzed project's bundle — so a standalone
138
+ # install where every inflection-dependent check would silently produce no diagnostics says so here
139
+ # instead of reporting an unqualified `[OK]`. Returns nil when no consumer is loaded (a non-Rails
140
+ # project never sees the probe).
141
+ def inflection_note(configuration, rows)
142
+ loaded_ids = rows.filter_map { |row| row[:id] if row[:status] == :loaded }
143
+ required_by = loaded_ids & Plugin::Inflector::CONSUMER_PLUGIN_IDS
144
+ return nil if required_by.empty?
145
+
146
+ Plugin::Isolation.target_bundle_root ||= Environment::BundleSigDiscovery.resolve_bundle_path(
147
+ bundle_path: configuration.bundler_bundle_path,
148
+ project_root: Dir.pwd,
149
+ auto_detect: configuration.bundler_auto_detect
150
+ )&.to_s
151
+ { required_by: required_by, available: Plugin::Inflector.available? }
152
+ end
153
+
132
154
  def build_services(configuration)
133
155
  Plugin::Services.new(
134
156
  reflection: Reflection,
@@ -148,7 +170,10 @@ module Rigor
148
170
  end
149
171
 
150
172
  if plugin
151
- loaded_row(plugin, gem_name, config)
173
+ # #194 slice 1 — a loaded plugin's resolved file rides on the registry keyed by gem name (the
174
+ # frozen plugin instance can't carry it). nil when the gem's entry-file basename differs from its
175
+ # name, or the resolver couldn't pin it.
176
+ loaded_row(plugin, gem_name, config, registry.resolved_gem_paths[gem_name])
152
177
  else
153
178
  # Find the load error whose plugin_ref names this entry (the ref is set by Loader to the gem name on require
154
179
  # failures and to the manifest id on later failures).
@@ -168,9 +193,10 @@ module Rigor
168
193
  [derived_id, gem_name].include?(plugin.manifest.id)
169
194
  end
170
195
 
171
- def loaded_row(plugin, gem_name, config)
196
+ def loaded_row(plugin, gem_name, config, resolved_path)
172
197
  manifest = plugin.manifest
173
198
  identity_fields(gem_name, manifest, config)
199
+ .merge(path: resolved_path)
174
200
  .merge(extension_fields(plugin, manifest))
175
201
  .merge(narrow_protocol_fields(plugin))
176
202
  .merge(load_error: nil)
@@ -185,7 +211,7 @@ module Rigor
185
211
  {
186
212
  node_rule_types: klass.node_rules.map { |r| r[:node_type].name }.uniq,
187
213
  dynamic_return_receivers: klass.dynamic_returns.flat_map { |r| r[:receivers] }.uniq,
188
- type_specifier_methods: klass.type_specifiers.flat_map { |r| r[:methods] }.map(&:to_s).uniq
214
+ narrowing_facts_methods: klass.narrowing_facts_rules.flat_map { |r| r[:methods] }.map(&:to_s).uniq
189
215
  }
190
216
  end
191
217
 
@@ -230,6 +256,9 @@ module Rigor
230
256
  id: entry_id,
231
257
  version: nil,
232
258
  description: nil,
259
+ # #194 slice 1 — set only when the require SUCCEEDED but a later step failed (the loader stamps the
260
+ # file on the error); a require that failed outright has no resolvable file, so this stays nil.
261
+ path: error&.resolved_path,
233
262
  config: config,
234
263
  signature_paths: [],
235
264
  open_receivers: [],
@@ -246,7 +275,7 @@ module Rigor
246
275
  source_rbs_synthesizer: false,
247
276
  node_rule_types: [],
248
277
  dynamic_return_receivers: [],
249
- type_specifier_methods: [],
278
+ narrowing_facts_methods: [],
250
279
  load_error: error&.message || "plugin did not register or could not be matched to a registered class"
251
280
  }
252
281
  end
@@ -310,6 +339,7 @@ module Rigor
310
339
  id: nil,
311
340
  version: nil,
312
341
  description: nil,
342
+ path: error.resolved_path,
313
343
  config: {},
314
344
  signature_paths: [],
315
345
  open_receivers: [], owns_receivers: [], produces: [], consumes: [],
@@ -317,7 +347,7 @@ module Rigor
317
347
  type_node_resolvers: 0,
318
348
  hkt_registrations: 0, hkt_definitions: 0,
319
349
  protocol_contracts: 0, source_rbs_synthesizer: false,
320
- node_rule_types: [], dynamic_return_receivers: [], type_specifier_methods: [],
350
+ node_rule_types: [], dynamic_return_receivers: [], narrowing_facts_methods: [],
321
351
  load_error: error.message
322
352
  }
323
353
  end