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
@@ -10,9 +10,12 @@ module Rigor
10
10
  # The two formats carry the same content; JSON is meant for tooling (SKILLs, CI, editor integrations) while text is
11
11
  # for interactive inspection. Rows are printed in the order the loader resolved them.
12
12
  class PluginsRenderer # rubocop:disable Metrics/ClassLength
13
- def initialize(rows:, configuration_path:)
13
+ # `inflection:` (ADR-90) is nil, or `{required_by: [ids], available: Boolean}` — the activation-time
14
+ # `Plugin::Inflector` probe result when a loaded plugin consumes the shared inflection helper.
15
+ def initialize(rows:, configuration_path:, inflection: nil)
14
16
  @rows = rows
15
17
  @configuration_path = configuration_path
18
+ @inflection = inflection
16
19
  end
17
20
 
18
21
  def text
@@ -25,17 +28,23 @@ module Rigor
25
28
  end
26
29
  lines << ""
27
30
  lines << footer
31
+ lines.concat(inflection_warning_lines)
28
32
  lines.join("\n")
29
33
  end
30
34
 
31
35
  def json
32
- JSON.pretty_generate(
33
- {
34
- "configuration" => @configuration_path,
35
- "plugins" => @rows.map { |row| row_json(row) },
36
- "summary" => summary
36
+ payload = {
37
+ "configuration" => @configuration_path,
38
+ "plugins" => @rows.map { |row| row_json(row) },
39
+ "summary" => summary
40
+ }
41
+ unless @inflection.nil?
42
+ payload["inflection"] = {
43
+ "required_by" => @inflection.fetch(:required_by),
44
+ "available" => @inflection.fetch(:available)
37
45
  }
38
- )
46
+ end
47
+ JSON.pretty_generate(payload)
39
48
  end
40
49
 
41
50
  # ADR-37 § "Machine-readable capability catalogue" — the focused per-plugin extension-protocol dump. Only loaded
@@ -84,7 +93,7 @@ module Rigor
84
93
  [
85
94
  ["node_rule", row[:node_rule_types]],
86
95
  ["dynamic_return receivers", row[:dynamic_return_receivers]],
87
- ["type_specifier methods", row[:type_specifier_methods]],
96
+ ["narrowing_facts methods", row[:narrowing_facts_methods]],
88
97
  ["produces", row[:produces]],
89
98
  ["consumes", row[:consumes]]
90
99
  ].filter_map { |label, values| "#{label}: #{values.join(', ')}" if values.any? }
@@ -97,7 +106,7 @@ module Rigor
97
106
  "version" => row[:version],
98
107
  "node_rule_types" => row[:node_rule_types],
99
108
  "dynamic_return_receivers" => row[:dynamic_return_receivers],
100
- "type_specifier_methods" => row[:type_specifier_methods],
109
+ "narrowing_facts_methods" => row[:narrowing_facts_methods],
101
110
  "produces" => row[:produces],
102
111
  "consumes" => row[:consumes]
103
112
  }
@@ -122,6 +131,22 @@ module Rigor
122
131
  end
123
132
  end
124
133
 
134
+ # ADR-90 — the text-view degradation warning. Printed only when the probe ran (an Inflector-consuming
135
+ # plugin is loaded) AND inflection is unavailable; the healthy case stays quiet.
136
+ def inflection_warning_lines
137
+ return [] if @inflection.nil? || @inflection.fetch(:available)
138
+
139
+ [
140
+ "",
141
+ "WARNING: ActiveSupport::Inflector is not loadable — inflection-dependent checks of " \
142
+ "#{@inflection.fetch(:required_by).join(', ')} will silently produce no diagnostics.",
143
+ " Rigor found activesupport neither in its own gem environment nor in the project's " \
144
+ "bundler install tree.",
145
+ " Fix: run `bundle install` in the analyzed project (Rigor reads vendor/bundle or " \
146
+ ".bundle/config), or install activesupport into Rigor's gem environment."
147
+ ]
148
+ end
149
+
125
150
  def row_lines(row)
126
151
  marker = row[:status] == :loaded ? "OK " : "ERR"
127
152
  head = if row[:status] == :loaded
@@ -131,6 +156,10 @@ module Rigor
131
156
  end
132
157
  lines = [head]
133
158
  lines << " #{row[:description]}" if row[:description]
159
+ # #194 slice 1 — the file the plugin gem loaded from, so an engine↔plugin version skew (a stale
160
+ # installed `rigortype` shadowing a checkout's bundled plugin) is a glance, not a bisect. Shown for
161
+ # loaded rows and for a require-succeeded-then-failed error row; omitted when unresolvable.
162
+ lines << " path: #{row[:path]}" if row[:path]
134
163
 
135
164
  if row[:status] == :load_error
136
165
  lines << " load error: #{row[:load_error]}"
@@ -170,8 +199,8 @@ module Rigor
170
199
  if row[:dynamic_return_receivers].any?
171
200
  lines << " dynamic_return receivers: #{row[:dynamic_return_receivers].join(', ')}"
172
201
  end
173
- if row[:type_specifier_methods].any?
174
- lines << " type_specifier methods: #{row[:type_specifier_methods].join(', ')}"
202
+ if row[:narrowing_facts_methods].any?
203
+ lines << " narrowing_facts methods: #{row[:narrowing_facts_methods].join(', ')}"
175
204
  end
176
205
  lines
177
206
  end
@@ -213,6 +242,9 @@ module Rigor
213
242
  "id" => row[:id],
214
243
  "version" => row[:version],
215
244
  "description" => row[:description],
245
+ # #194 slice 1 — additive per-row provenance; null when the resolver could not pin the file (a
246
+ # require failure, or an entry-file basename differing from the gem name).
247
+ "path" => row[:path],
216
248
  "config" => row[:config],
217
249
  "signature_paths" => row[:signature_paths].map do |sp|
218
250
  { "path" => sp[:path], "exists" => sp[:exists], "rbs_files" => sp[:rbs_files] }
@@ -231,7 +263,7 @@ module Rigor
231
263
  "source_rbs_synthesizer" => row[:source_rbs_synthesizer],
232
264
  "node_rule_types" => row[:node_rule_types],
233
265
  "dynamic_return_receivers" => row[:dynamic_return_receivers],
234
- "type_specifier_methods" => row[:type_specifier_methods],
266
+ "narrowing_facts_methods" => row[:narrowing_facts_methods],
235
267
  "load_error" => row[:load_error]
236
268
  }
237
269
  end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../environment"
4
+ require_relative "../plugin"
5
+ require_relative "../plugin/loader"
6
+ require_relative "../plugin/services"
7
+ require_relative "../reflection"
8
+ require_relative "../type/combinator"
9
+
10
+ module Rigor
11
+ class CLI
12
+ # Shared construction of the analysis-observing environment for the single-shot probe commands
13
+ # (`type-of`, `type-scan`, `trace`, `annotate`). Each of these answers "what does the engine infer here?"
14
+ # for a hand-picked file or position, so the environment they type against MUST match the one `rigor check`
15
+ # analyses with — otherwise a probe reports a type the real run never computes.
16
+ #
17
+ # The gap this closes: the probes historically built their environment with
18
+ # `Environment.for_project(libraries:, signature_paths:)` only — no plugin registry, no `source_files:`. The
19
+ # `source_rbs_synthesizer` plugin hook (ADR-32 / ADR-93's auto-wired `rigor-rbs-inline`) therefore never
20
+ # ran there, so a class whose only signature comes from an inline `#: () -> void` annotation typed as
21
+ # `Dynamic[top]` under a probe while `check` resolved it through the synthesized RBS. That divergence
22
+ # misattributed a dispatch tier during the #162 transitive-void design pass (see the 2026-07-19 addendum in
23
+ # docs/adr/100-static-diagnostic-family-and-void-origins.md).
24
+ #
25
+ # This threads the two things that were missing — the plugin registry built by the plugin loader, and the
26
+ # `source_files:` the synthesizers run over — matching `rigor check`'s `Environment.for_project` call
27
+ # (built in `Analysis::Runner::PoolCoordinator#build_runner_environment`). A probe legitimately simplifies
28
+ # relative to check: no synthesis-failure reporter (a probe has no diagnostic pipeline) and no synthesis
29
+ # cache store (a single-position probe recomputes cheaply). Plugin loading itself matches check — same
30
+ # `.rigor.yml` `plugins:` config, same ADR-93 auto-wire (already applied by `Configuration.load`), same
31
+ # `enabled:` / `require_magic_comment` semantics via `Plugin::Loader.load`.
32
+ #
33
+ # Fail-soft is the invariant: a project with no plugins, an unresolvable plugin gem, or any error during the
34
+ # plugin-aware build degrades to the bare environment the probes used before — never a crash. `Plugin::Loader`
35
+ # already isolates per-entry load failures onto the registry; the `rescue` here is the belt-and-suspenders
36
+ # around anything the loader or the plugin-aware env build itself might raise.
37
+ module ProbeEnvironment
38
+ module_function
39
+
40
+ # Builds the plugin-aware {Rigor::Environment} a probe types against.
41
+ #
42
+ # @param configuration [Rigor::Configuration] the loaded project configuration (already carries the
43
+ # ADR-93 auto-wired `rigor-rbs-inline` entry when the library is resolvable).
44
+ # @param source_files [Array<String>] the file(s) the probe inspects. Threaded so each loaded plugin's
45
+ # `source_rbs_synthesizer` runs over them at env-build time; an empty list contributes no synthesized RBS.
46
+ # @return [Rigor::Environment]
47
+ def build(configuration:, source_files:)
48
+ Environment.for_project(
49
+ libraries: configuration.libraries,
50
+ signature_paths: configuration.signature_paths,
51
+ plugin_registry: load_plugin_registry(configuration),
52
+ source_files: source_files
53
+ )
54
+ rescue StandardError
55
+ bare(configuration)
56
+ end
57
+
58
+ # Loads the project's configured plugins the same way the plugin-inspection commands (`rigor plugins`,
59
+ # `rigor doctor`) do: a `Plugin::Services` with no cache store (the probe recomputes) driving
60
+ # `Plugin::Loader.load`. Returns `nil` — so `Environment.for_project` skips the plugin tier entirely —
61
+ # when the project declares no plugins.
62
+ def load_plugin_registry(configuration)
63
+ return nil if configuration.plugins.empty?
64
+
65
+ services = Plugin::Services.new(
66
+ reflection: Reflection,
67
+ type: Type::Combinator,
68
+ configuration: configuration,
69
+ cache_store: nil
70
+ )
71
+ Plugin::Loader.load(configuration: configuration, services: services)
72
+ end
73
+
74
+ # The pre-fix environment: RBS + project signatures only, no plugin tier and no synthesized RBS. The
75
+ # fail-soft floor — a probe on a project with broken or absent plugin setup lands here and behaves exactly
76
+ # as it did before this parity fix.
77
+ def bare(configuration)
78
+ Environment.for_project(
79
+ libraries: configuration.libraries,
80
+ signature_paths: configuration.signature_paths
81
+ )
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../inference/fork_map"
6
+
7
+ module Rigor
8
+ class CLI
9
+ # Fork-pool for the `rigor coverage --protection` scan (P3-10). The plugin-aware environment and the
10
+ # seeded scope are built ONCE on the parent — the expensive part: RBS env, plugin registry, and the
11
+ # cross-file discovery + parameter-inference seed — and children copy-on-write inherit them (via
12
+ # {Inference::ForkMap}), each analysing a contiguous slice of paths and returning per-path results the
13
+ # parent merges in original path order.
14
+ #
15
+ # The scan is pure-read (no reporter drains, no plugin-emission merge, no prepare diagnostics), so a
16
+ # slice payload is just `{path => result}`. Determinism: the parent merges slice payloads in slice order
17
+ # and the accumulator absorbs in `paths` order, so the fused report is byte-identical to a sequential run
18
+ # regardless of which worker produced which file.
19
+ module ProtectionForkScan
20
+ module_function
21
+
22
+ # A parse failure for one file, carrying only the marshalable error count (Prism error objects are not
23
+ # reliably marshalable, and the accumulator only needs the count).
24
+ ParseError = Data.define(:count)
25
+
26
+ # @param paths [Array<String>] the files to scan, in caller order.
27
+ # @param scanner [Inference::ProtectionScanner] built on the parent; COW-inherited by workers.
28
+ # @param environment [Rigor::Environment] the scanner's environment, prewarmed here before forking.
29
+ # @param configuration [Rigor::Configuration] for the Prism `target_ruby` version.
30
+ # @param workers [Integer] resolved worker count (≤1 → sequential).
31
+ # @return [Hash{String => Inference::ProtectionScanner::FileResult, ParseError}] one entry per path.
32
+ def run(paths:, scanner:, environment:, configuration:, workers:)
33
+ # Force the full RBS load on the parent so children copy-on-write inherit a warm environment rather
34
+ # than each rebuilding it after the fork (mirrors the check fork pool's parent-side prewarm). A
35
+ # no-op on the sequential path but cheap.
36
+ environment.rbs_loader&.prewarm if Inference::ForkMap.parallel?([workers, paths.size].min)
37
+
38
+ payloads = Inference::ForkMap.call(items: paths, workers: workers) do |slice|
39
+ slice.to_h { |path| [path, scan_path(path, scanner, configuration)] }
40
+ end
41
+ payloads.each_with_object({}) { |slice_results, merged| merged.merge!(slice_results) }
42
+ end
43
+
44
+ # Parses one file and returns the scanner's {Inference::ProtectionScanner::FileResult}, or a
45
+ # {ParseError} when the source does not parse.
46
+ def scan_path(path, scanner, configuration)
47
+ source = File.read(path)
48
+ parse_result = Prism.parse(source, filepath: path, version: configuration.target_ruby)
49
+ return ParseError.new(count: parse_result.errors.size) if parse_result.errors.any?
50
+
51
+ scanner.scan(parse_result.value)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -86,7 +86,13 @@ module Rigor
86
86
  end
87
87
 
88
88
  def record_parse_error(path, errors)
89
- @parse_errors << { "path" => path, "errors" => errors.size }
89
+ record_parse_error_count(path, errors.size)
90
+ end
91
+
92
+ # Count-based variant for the fork-pool path, where a worker carries only the marshalable error count
93
+ # (see {ProtectionForkScan::ParseError}), not the Prism error objects.
94
+ def record_parse_error_count(path, count)
95
+ @parse_errors << { "path" => path, "errors" => count }
90
96
  end
91
97
 
92
98
  def to_report
@@ -42,21 +42,43 @@ module Rigor
42
42
  paths = @argv.empty? ? configuration.paths : @argv
43
43
 
44
44
  observations = collect_observations(configuration, options)
45
- candidates = SigGen::Generator.new(configuration: configuration, paths: paths,
46
- observations: observations,
47
- include_private: options.fetch(:include_private)).run
45
+ generator = SigGen::Generator.new(configuration: configuration, paths: paths,
46
+ observations: observations,
47
+ include_private: options.fetch(:include_private))
48
+ candidates = generator.run
48
49
  mode = options.fetch(:mode).to_sym
49
50
 
50
- if mode == :write
51
- dispatch_write(candidates, configuration, options)
52
- else
53
- dispatch_print_or_diff(candidates, mode, options)
54
- end
55
- 0
51
+ status = if mode == :write
52
+ dispatch_write(candidates, configuration, options)
53
+ else
54
+ dispatch_print_or_diff(candidates, mode, options)
55
+ 0
56
+ end
57
+ report_unrenderable(generator.unrenderable)
58
+ status
56
59
  end
57
60
 
58
61
  private
59
62
 
63
+ # A method whose rendered RBS does not parse is a Rigor rendering defect, not a fact about the user's
64
+ # code — the generator skipped it (so the rest of the signatures are still usable and still valid), but
65
+ # staying silent would leave the user with a quietly incomplete `sig/` and us with an unreported bug.
66
+ # Reported on stderr so it never contaminates `--print` output being piped into a file.
67
+ def report_unrenderable(unrenderable)
68
+ return if unrenderable.empty?
69
+
70
+ @err.puts(
71
+ "rigor sig-gen: skipped #{unrenderable.size} method(s) whose generated RBS does not parse. " \
72
+ "This is a bug in Rigor's RBS rendering, not in your code — please report it. " \
73
+ "The remaining signatures are unaffected."
74
+ )
75
+ unrenderable.each do |method|
76
+ @err.puts(" #{method.path}: #{method.class_name}##{method.method_name}")
77
+ @err.puts(" rendered: #{method.rbs}")
78
+ @err.puts(" #{method.error}")
79
+ end
80
+ end
81
+
60
82
  def dispatch_print_or_diff(candidates, mode, options)
61
83
  SigGen::Renderer.new(out: @out).render(
62
84
  candidates: candidates,
@@ -66,6 +88,7 @@ module Rigor
66
88
  )
67
89
  end
68
90
 
91
+ # @return [Integer] exit status — non-zero when a file the user asked to write could not be written.
69
92
  def dispatch_write(candidates, configuration, options)
70
93
  layout_index = SigGen::LayoutIndex.new(signature_paths: configuration.signature_paths)
71
94
  path_mapper = SigGen::PathMapper.new(configuration: configuration, layout_index: layout_index)
@@ -74,6 +97,10 @@ module Rigor
74
97
  results = writer.write_all(candidates)
75
98
 
76
99
  SigGen::Renderer.new(out: @out).render_write(results: results, format: options.fetch(:format))
100
+ # An assembled file that does not parse is refused rather than written. The user asked for a write and
101
+ # did not get one, so the command must not report success — a green `sig-gen --write` in CI would
102
+ # otherwise mean nothing.
103
+ results.any? { |result| result.action == :skipped_invalid_rbs } ? 1 : 0
77
104
  end
78
105
 
79
106
  # Slice 3 — collect call-site argument observations when `--params=observed` is set. When `--observe=PATH` is not
@@ -37,9 +37,7 @@ module Rigor
37
37
  # the SKILL. Also spelled `describe`, and
38
38
  # surfaced top-level as `rigor describe`.
39
39
  #
40
- # The pre-v0.3.0 verb spellings `rigor skill list` / `print <name>` / `path <name>` still work but emit a stderr
41
- # deprecation notice; they are removed in v0.3.0 (see docs/ROADMAP.md § "Scheduled CLI deprecations"). `describe` is
42
- # a no-argument action, not a name-slot verb, so it stays first-class alongside `--describe`.
40
+ # `describe` is a no-argument action, not a name-slot verb, so it stays first-class alongside `--describe`.
43
41
  class SkillCommand < Command
44
42
  USAGE = <<~USAGE
45
43
  Usage: rigor skill [<name>] [--full <name>] [--path <name>] [--list] [--describe]
@@ -60,30 +58,14 @@ module Rigor
60
58
  rigor skill --full rigor-baseline-reduce
61
59
  rigor skill --path rigor-baseline-reduce
62
60
  rigor skill --describe (also: rigor describe)
63
-
64
- Deprecated (removed in v0.3.0) — use the forms above:
65
- rigor skill list -> rigor skill --list
66
- rigor skill print <name> -> rigor skill <name>
67
- rigor skill path <name> -> rigor skill --path <name>
68
61
  USAGE
69
62
 
70
63
  # The bundled skills live at `<gem_root>/skills/`. From `lib/rigor/cli/skill_command.rb` that is three directories
71
64
  # up.
72
65
  SKILLS_ROOT = File.expand_path("../../../skills", __dir__)
73
66
 
74
- # The verb subcommands the flags superseded keep working with a stderr deprecation notice until this version drops
75
- # them. Each maps to the canonical advice printed and the flag it rewrites to.
76
- LEGACY_VERB_REMOVAL = "v0.3.0"
77
- LEGACY_VERBS = {
78
- "list" => { old: "list", advice: "--list", flag: "--list" },
79
- "print" => { old: "print <name>", advice: "<name>", flag: "--print" },
80
- "path" => { old: "path <name>", advice: "--path <name>", flag: "--path" }
81
- }.freeze
82
-
83
67
  # @return [Integer] CLI exit status.
84
68
  def run
85
- rewrite_legacy_verb!
86
-
87
69
  case @argv.first
88
70
  when nil
89
71
  run_list
@@ -112,19 +94,6 @@ module Rigor
112
94
 
113
95
  private
114
96
 
115
- # Translate a deprecated verb spelling into its flag form, warning once on stderr, so the dispatch above only
116
- # handles canonical forms.
117
- def rewrite_legacy_verb!
118
- spec = LEGACY_VERBS[@argv.first]
119
- return unless spec
120
-
121
- @err.puts(
122
- "rigor skill: `#{spec.fetch(:old)}` is deprecated and will be removed in " \
123
- "#{LEGACY_VERB_REMOVAL}; use `rigor skill #{spec.fetch(:advice)}` instead."
124
- )
125
- @argv[0] = spec.fetch(:flag)
126
- end
127
-
128
97
  def run_list
129
98
  skills = discover_skills
130
99
  if skills.empty?
@@ -12,6 +12,7 @@ require_relative "../inference/flow_tracer"
12
12
  require_relative "../inference/scope_indexer"
13
13
  require_relative "command"
14
14
  require_relative "trace_renderer"
15
+ require_relative "probe_environment"
15
16
 
16
17
  module Rigor
17
18
  class CLI
@@ -90,13 +91,11 @@ module Rigor
90
91
  0
91
92
  end
92
93
 
93
- # Mirrors the single-file path `rigor type-of` takes: a project-aware environment, an empty seed scope, one
94
- # statement-level evaluation of the whole programbut recorded under the FlowTracer.
94
+ # Mirrors the single-file path `rigor type-of` takes: the plugin-aware environment (so a trace replays the
95
+ # same dispatch `rigor check` would, including plugin-synthesized RBS see {ProbeEnvironment}), an empty
96
+ # seed scope, one statement-level evaluation of the whole program — but recorded under the FlowTracer.
95
97
  def record_events(root, file, configuration)
96
- environment = Environment.for_project(
97
- libraries: configuration.libraries,
98
- signature_paths: configuration.signature_paths
99
- )
98
+ environment = ProbeEnvironment.build(configuration: configuration, source_files: [file])
100
99
  scope = Scope.empty(environment: environment, source_path: file)
101
100
  Inference::FlowTracer.record { scope.evaluate(root) }
102
101
  end
@@ -13,6 +13,7 @@ require_relative "../inference/scope_indexer"
13
13
  require_relative "type_of_renderer"
14
14
  require_relative "command"
15
15
  require_relative "options"
16
+ require_relative "probe_environment"
16
17
 
17
18
  module Rigor
18
19
  class CLI
@@ -92,14 +93,14 @@ module Rigor
92
93
  0
93
94
  end
94
95
 
95
- # Builds a project-aware environment relative to the probed file. Project-RBS auto-detection roots at CWD today;
96
- # future work will walk parent directories to find the enclosing `Gemfile`/`*.gemspec` so probes against files
97
- # outside the current process's CWD still see the right `sig/` tree.
98
- def project_environment(_file, configuration)
99
- Environment.for_project(
100
- libraries: configuration.libraries,
101
- signature_paths: configuration.signature_paths
102
- )
96
+ # Builds the plugin-aware environment relative to the probed file, so the reported type matches what `rigor
97
+ # check` computes for the same position including types synthesized from inline RBS annotations by the
98
+ # ADR-93 auto-wired `rigor-rbs-inline` plugin (see {ProbeEnvironment} for the #162 misattribution this
99
+ # closes). The probed file is threaded as the synthesizer's `source_files:`. Project-RBS auto-detection
100
+ # roots at CWD today; future work will walk parent directories to find the enclosing `Gemfile`/`*.gemspec`
101
+ # so probes against files outside the current process's CWD still see the right `sig/` tree.
102
+ def project_environment(file, configuration)
103
+ ProbeEnvironment.build(configuration: configuration, source_files: [file])
103
104
  end
104
105
 
105
106
  def file_exists?(file)
@@ -11,6 +11,7 @@ require_relative "../scope"
11
11
  require_relative "type_scan_renderer"
12
12
  require_relative "type_scan_report"
13
13
  require_relative "command"
14
+ require_relative "probe_environment"
14
15
 
15
16
  module Rigor
16
17
  class CLI
@@ -69,20 +70,19 @@ module Rigor
69
70
 
70
71
  def scan_paths(paths, options)
71
72
  configuration = Configuration.load(options.fetch(:config))
72
- scope = Scope.empty(environment: project_environment(configuration))
73
+ scope = Scope.empty(environment: project_environment(configuration, paths))
73
74
  scanner = Inference::CoverageScanner.new(scope: scope)
74
75
  accumulator = ScanAccumulator.new
75
76
  paths.each { |path| scan_one(path, scanner, accumulator, configuration) }
76
77
  accumulator.to_report(paths, options)
77
78
  end
78
79
 
79
- # Builds a project-aware environment that auto-detects `<cwd>/sig` by default and honours the configuration's
80
- # `libraries:` / `signature_paths:` keys when present.
81
- def project_environment(configuration)
82
- Environment.for_project(
83
- libraries: configuration.libraries,
84
- signature_paths: configuration.signature_paths
85
- )
80
+ # Builds the plugin-aware environment that auto-detects `<cwd>/sig` by default and honours the
81
+ # configuration's `libraries:` / `signature_paths:` keys when present. The scanned `paths` are threaded as
82
+ # the plugin `source_rbs_synthesizer` inputs so coverage reflects the same synthesized RBS `rigor check`
83
+ # sees (see {ProbeEnvironment}).
84
+ def project_environment(configuration, source_files)
85
+ ProbeEnvironment.build(configuration: configuration, source_files: source_files)
86
86
  end
87
87
 
88
88
  def scan_one(path, scanner, accumulator, configuration)
data/lib/rigor/cli.rb CHANGED
@@ -11,7 +11,7 @@ require_relative "analysis/diagnostic"
11
11
  require_relative "analysis/result"
12
12
  require_relative "cli/options"
13
13
  require_relative "cli/diagnostic_formats"
14
- require_relative "cli/ci_detector"
14
+ require_relative "ci_detector"
15
15
 
16
16
  module Rigor
17
17
  # The CLI class is a dispatcher: each `run_*` method delegates to a command-specific class once the command grows
@@ -20,6 +20,16 @@ module Rigor
20
20
  class CLI # rubocop:disable Metrics/ClassLength
21
21
  EXIT_USAGE = 64
22
22
 
23
+ # The published location of `schemas/rigor-config.schema.json`, written into the `.rigor.yml` that
24
+ # `rigor init` generates so an editor validates the file as the user types.
25
+ #
26
+ # This MUST equal the schema's own `$id`, and it MUST resolve. Both are gated
27
+ # (`spec/rigor/config_schema_spec.rb`) because neither failure has a symptom: an unreachable schema
28
+ # is indistinguishable from a schema with no complaints, so a stale URL here silently disables
29
+ # validation for every project that ran `rigor init`. It pointed at a nonexistent org until
30
+ # 2026-07-17 and nothing noticed. See `docs/internal-spec/config.md`.
31
+ CONFIG_SCHEMA_URL = "https://github.com/rigortype/rigor/raw/master/schemas/rigor-config.schema.json"
32
+
23
33
  HANDLERS = {
24
34
  "check" => :run_check,
25
35
  "init" => :run_init,
@@ -138,9 +148,10 @@ module Rigor
138
148
  # most likely to want to edit.
139
149
  def init_template
140
150
  <<~YAML
141
- # yaml-language-server: $schema=https://github.com/zenwerk/rigor/raw/master/schemas/rigor-config.schema.json
142
- # Rigor configuration. See docs/CURRENT_WORK.md for the
143
- # full set of features the analyzer ships in this preview.
151
+ # yaml-language-server: $schema=#{CONFIG_SCHEMA_URL}
152
+ # Rigor configuration. The full key reference is
153
+ # https://github.com/rigortype/rigor/blob/master/docs/manual/03-configuration.md
154
+ # (or run `rigor docs configuration`).
144
155
  #
145
156
  # Keys you may want to edit:
146
157
  # - target_ruby: minimum Ruby version your project targets.
@@ -1,7 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "did_you_mean"
4
+
3
5
  require_relative "signature_path_audit"
4
- require_relative "analysis/check_rules"
6
+ # ADR-87 WD4 — only the pure rule-id table (`ALL_RULES` / `RULE_FAMILIES`) is needed, so require the light
7
+ # `rule_ids.rb` rather than the engine-pulling `check_rules.rb`; keeps `config_audit` (loaded by every check)
8
+ # off the inference engine so the boot-slimming hit path stays engine-free.
9
+ require_relative "analysis/check_rules/rule_ids"
5
10
 
6
11
  module Rigor
7
12
  # Audits a loaded {Configuration} for the class of mistake where a configured value
@@ -36,12 +41,53 @@ module Rigor
36
41
  # paths resolve against (the CLI's CWD), used only by the explicit-path checks.
37
42
  # @return [Array<Warning>]
38
43
  def self.warnings(configuration, project_root: Dir.pwd)
39
- signature_path_warnings(configuration) +
44
+ unknown_key_warnings(configuration) +
45
+ signature_path_warnings(configuration) +
40
46
  library_warnings(configuration) +
41
47
  rule_token_warnings(configuration) +
42
48
  explicit_path_warnings(configuration, project_root)
43
49
  end
44
50
 
51
+ # Top-level keys the loader does not own. The archetypal case is a typo — `excludee:` for
52
+ # `exclude:` — which the loader drops in silence, so the exclusion never applies and the run
53
+ # reports errors from the very files the user meant to skip, with nothing anywhere saying the key
54
+ # did nothing. That is exactly this module's class of mistake, and it was the one whole-key
55
+ # instance of it going unwarned.
56
+ #
57
+ # A reserved namespace ({Configuration::RESERVED_NAMESPACES}) is unknown *on purpose* — another
58
+ # implementation reads it from the same file — so it is exempt. {Configuration#unknown_keys}
59
+ # already applies that exemption; warning here would push users to delete the key their other
60
+ # tool needs.
61
+ #
62
+ # **Top level only, and deliberately.** A nested check would have to name each group's known keys,
63
+ # and DEFAULTS cannot supply them: `DEFAULTS["dependencies"]` carries `source_inference` and
64
+ # `budget_per_gem` but not `budget_overrun_strategy`, which is real (`Configuration::Dependencies`
65
+ # reads it), documented, and schema-declared — so a DEFAULTS-keyed nested check would flag a
66
+ # working config. `severity_overrides:` is an open map of rule ids besides. Nested unknown keys are
67
+ # the schema tier's job: every nested object in `schemas/rigor-config.schema.json` is
68
+ # `additionalProperties: false`, and `config_schema_spec` keeps it complete
69
+ # ([ADR-99](../../docs/adr/99-config-schema-authority.md), `docs/internal-spec/config.md`).
70
+ def self.unknown_key_warnings(configuration)
71
+ configuration.unknown_keys.map do |key|
72
+ suggestion = suggest_config_key(key)
73
+ hint = suggestion ? " Did you mean `#{suggestion}`?" : ""
74
+ Warning.new(
75
+ kind: :unknown_key,
76
+ message: "`#{key}` is not a recognized configuration key; it has no effect.#{hint}",
77
+ fields: { "key" => key, "suggestion" => suggestion }
78
+ )
79
+ end
80
+ end
81
+
82
+ # Nearest known key, or nil. Uses `DidYouMean::SpellChecker` — the engine behind Ruby's own
83
+ # `NoMethodError` hints, and the one `Plugin::Base.suggest` wraps. Called directly rather than
84
+ # through that helper because `plugin/base.rb` pulls Prism and the diagnostic model in, and
85
+ # ADR-87 WD4 keeps this file (loaded by every check) off that path; `did_you_mean` is stdlib.
86
+ def self.suggest_config_key(key)
87
+ DidYouMean::SpellChecker.new(dictionary: Configuration::KNOWN_KEYS).correct(key.to_s).first
88
+ end
89
+ private_class_method :suggest_config_key
90
+
45
91
  # `signature_paths:` entries that resolve to nothing — delegated to {SignaturePathAudit},
46
92
  # which mirrors the loader's `directory?` + recursive `**/*.rbs` acceptance test.
47
93
  def self.signature_path_warnings(configuration)