rigortype 0.2.9 → 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 (184) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/docs/handbook/03-narrowing.md +2 -1
  4. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  5. data/docs/handbook/06-classes.md +18 -9
  6. data/docs/handbook/08-understanding-errors.md +1 -0
  7. data/docs/handbook/11-sig-gen.md +11 -1
  8. data/docs/handbook/appendix-mypy.md +4 -3
  9. data/docs/install.md +14 -6
  10. data/docs/manual/01-installation.md +98 -4
  11. data/docs/manual/02-cli-reference.md +49 -12
  12. data/docs/manual/03-configuration.md +34 -2
  13. data/docs/manual/04-diagnostics.md +23 -0
  14. data/docs/manual/07-plugins.md +4 -4
  15. data/docs/manual/09-editor-integration.md +4 -4
  16. data/docs/manual/11-ci.md +51 -0
  17. data/docs/manual/12-caching.md +65 -0
  18. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  19. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  20. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  21. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  22. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  23. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  24. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  25. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  26. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  27. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  28. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  29. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +93 -0
  30. data/lib/rigor/analysis/check_rules.rb +557 -95
  31. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  32. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  33. data/lib/rigor/analysis/incremental_session.rb +456 -51
  34. data/lib/rigor/analysis/path_expansion.rb +42 -0
  35. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  36. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  37. data/lib/rigor/analysis/run_cache_key.rb +68 -0
  38. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  39. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  40. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  41. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  42. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  43. data/lib/rigor/analysis/runner.rb +343 -68
  44. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  45. data/lib/rigor/analysis/worker_session.rb +30 -2
  46. data/lib/rigor/bleeding_edge.rb +37 -8
  47. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  48. data/lib/rigor/cache/descriptor.rb +88 -28
  49. data/lib/rigor/cache/file_digest.rb +158 -0
  50. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  51. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  52. data/lib/rigor/cache/store.rb +33 -3
  53. data/lib/rigor/ci_detector.rb +90 -0
  54. data/lib/rigor/cli/annotate_command.rb +15 -9
  55. data/lib/rigor/cli/baseline_command.rb +4 -1
  56. data/lib/rigor/cli/check_command.rb +212 -27
  57. data/lib/rigor/cli/coverage_command.rb +6 -1
  58. data/lib/rigor/cli/docs_command.rb +0 -30
  59. data/lib/rigor/cli/doctor_command.rb +163 -23
  60. data/lib/rigor/cli/lsp_command.rb +5 -0
  61. data/lib/rigor/cli/mcp_command.rb +5 -0
  62. data/lib/rigor/cli/plugins_command.rb +36 -6
  63. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  64. data/lib/rigor/cli/probe_environment.rb +85 -0
  65. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  66. data/lib/rigor/cli/skill_command.rb +1 -32
  67. data/lib/rigor/cli/trace_command.rb +5 -6
  68. data/lib/rigor/cli/type_of_command.rb +9 -8
  69. data/lib/rigor/cli/type_scan_command.rb +8 -8
  70. data/lib/rigor/cli.rb +15 -4
  71. data/lib/rigor/config_audit.rb +48 -2
  72. data/lib/rigor/configuration/severity_profile.rb +31 -0
  73. data/lib/rigor/configuration.rb +156 -8
  74. data/lib/rigor/environment/default_libraries.rb +35 -0
  75. data/lib/rigor/environment/rbs_loader.rb +164 -5
  76. data/lib/rigor/environment.rb +19 -27
  77. data/lib/rigor/inference/budget_trace.rb +77 -2
  78. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  79. data/lib/rigor/inference/def_handle.rb +23 -0
  80. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  81. data/lib/rigor/inference/def_return_typer.rb +2 -1
  82. data/lib/rigor/inference/expression_typer.rb +361 -113
  83. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  84. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  85. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  86. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  87. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +11 -0
  88. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  89. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  90. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  91. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  92. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  93. data/lib/rigor/inference/mutation_widening.rb +42 -8
  94. data/lib/rigor/inference/narrowing.rb +127 -17
  95. data/lib/rigor/inference/parameter_inference_collector.rb +15 -0
  96. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  97. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  98. data/lib/rigor/inference/scope_indexer.rb +451 -36
  99. data/lib/rigor/inference/statement_evaluator.rb +64 -15
  100. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  101. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  102. data/lib/rigor/inference/void_origin.rb +25 -0
  103. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  104. data/lib/rigor/language_server/completion_provider.rb +2 -1
  105. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  106. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  107. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  108. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  109. data/lib/rigor/plugin/base.rb +9 -22
  110. data/lib/rigor/plugin/inflector.rb +12 -3
  111. data/lib/rigor/plugin/io_boundary.rb +5 -1
  112. data/lib/rigor/plugin/isolation.rb +81 -11
  113. data/lib/rigor/plugin/load_error.rb +10 -1
  114. data/lib/rigor/plugin/loader.rb +96 -14
  115. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  116. data/lib/rigor/plugin/registry.rb +32 -23
  117. data/lib/rigor/plugin.rb +26 -0
  118. data/lib/rigor/protection/mutator.rb +3 -2
  119. data/lib/rigor/reflection.rb +64 -0
  120. data/lib/rigor/runtime/jit.rb +128 -0
  121. data/lib/rigor/scope/discovery_index.rb +12 -2
  122. data/lib/rigor/scope.rb +122 -28
  123. data/lib/rigor/sig_gen/classification.rb +5 -1
  124. data/lib/rigor/sig_gen/generator.rb +38 -6
  125. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  126. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  127. data/lib/rigor/sig_gen/renderer.rb +10 -0
  128. data/lib/rigor/sig_gen/write_result.rb +9 -4
  129. data/lib/rigor/sig_gen/writer.rb +24 -2
  130. data/lib/rigor/source/node_children.rb +116 -0
  131. data/lib/rigor/source/node_locator.rb +3 -1
  132. data/lib/rigor/source/node_walker.rb +4 -2
  133. data/lib/rigor/source.rb +1 -0
  134. data/lib/rigor/type/difference.rb +28 -24
  135. data/lib/rigor/type/hash_shape.rb +34 -10
  136. data/lib/rigor/version.rb +1 -1
  137. data/lib/rigortype.rb +24 -0
  138. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  139. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  140. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  141. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  142. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +3 -1
  143. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -2
  144. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  145. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  146. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  147. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  148. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  149. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  150. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  151. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  152. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  153. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  154. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +3 -1
  155. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  156. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +7 -5
  157. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  158. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  159. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  160. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  161. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  162. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  164. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  166. data/sig/prism_node_children.rbs +9 -0
  167. data/sig/rigor/inference.rbs +7 -0
  168. data/sig/rigor/plugin/base.rbs +2 -3
  169. data/sig/rigor/reflection.rbs +1 -0
  170. data/sig/rigor/scope.rbs +12 -1
  171. data/skills/rigor-ci-setup/SKILL.md +10 -0
  172. data/skills/rigor-editor-setup/SKILL.md +11 -0
  173. data/skills/rigor-next-steps/SKILL.md +23 -2
  174. data/skills/rigor-plugin-author/SKILL.md +3 -3
  175. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  176. data/skills/rigor-plugin-review/SKILL.md +2 -2
  177. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  178. data/skills/rigor-project-init/SKILL.md +29 -3
  179. data/skills/rigor-project-init/references/01-detect.md +1 -1
  180. data/skills/rigor-project-init/references/02-configure.md +39 -5
  181. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  182. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  183. metadata +42 -24
  184. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -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
@@ -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