rigortype 0.2.9 → 0.3.1

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 (222) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/builtins/ruby_core/array.yml +416 -392
  4. data/data/builtins/ruby_core/file.yml +42 -42
  5. data/data/builtins/ruby_core/hash.yml +302 -302
  6. data/data/builtins/ruby_core/io.yml +191 -191
  7. data/data/builtins/ruby_core/numeric.yml +321 -366
  8. data/data/builtins/ruby_core/proc.yml +124 -124
  9. data/data/builtins/ruby_core/range.yml +21 -21
  10. data/data/builtins/ruby_core/rational.yml +39 -39
  11. data/data/builtins/ruby_core/re.yml +65 -65
  12. data/data/builtins/ruby_core/set.yml +106 -106
  13. data/data/builtins/ruby_core/struct.yml +14 -14
  14. data/data/core_overlay/string_scanner.rbs +6 -5
  15. data/docs/handbook/01-getting-started.md +22 -34
  16. data/docs/handbook/03-narrowing.md +2 -1
  17. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  18. data/docs/handbook/06-classes.md +19 -10
  19. data/docs/handbook/07-rbs-and-extended.md +76 -101
  20. data/docs/handbook/08-understanding-errors.md +114 -246
  21. data/docs/handbook/09-plugins.md +54 -144
  22. data/docs/handbook/11-sig-gen.md +11 -1
  23. data/docs/handbook/README.md +5 -3
  24. data/docs/handbook/appendix-liskov.md +4 -2
  25. data/docs/handbook/appendix-mypy.md +4 -3
  26. data/docs/handbook/appendix-phpstan.md +14 -7
  27. data/docs/handbook/appendix-steep.md +4 -2
  28. data/docs/handbook/appendix-type-theory.md +3 -1
  29. data/docs/install.md +14 -6
  30. data/docs/manual/01-installation.md +98 -4
  31. data/docs/manual/02-cli-reference.md +81 -12
  32. data/docs/manual/03-configuration.md +34 -2
  33. data/docs/manual/04-diagnostics.md +59 -4
  34. data/docs/manual/06-baseline.md +35 -1
  35. data/docs/manual/07-plugins.md +4 -4
  36. data/docs/manual/08-skills.md +6 -1
  37. data/docs/manual/09-editor-integration.md +7 -6
  38. data/docs/manual/11-ci.md +51 -0
  39. data/docs/manual/12-caching.md +65 -0
  40. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  41. data/docs/manual/plugins/rigor-devise.md +4 -2
  42. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  43. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  44. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  45. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  46. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  47. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  48. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  49. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  50. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  51. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  52. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  53. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +112 -0
  54. data/lib/rigor/analysis/check_rules.rb +581 -98
  55. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  56. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  57. data/lib/rigor/analysis/incremental_session.rb +456 -51
  58. data/lib/rigor/analysis/path_expansion.rb +42 -0
  59. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  60. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  61. data/lib/rigor/analysis/run_cache_key.rb +78 -0
  62. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  63. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  64. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  65. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  66. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  67. data/lib/rigor/analysis/runner.rb +344 -68
  68. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  69. data/lib/rigor/analysis/worker_session.rb +30 -2
  70. data/lib/rigor/bleeding_edge.rb +37 -8
  71. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  72. data/lib/rigor/cache/descriptor.rb +88 -28
  73. data/lib/rigor/cache/file_digest.rb +158 -0
  74. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  75. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  76. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  77. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  78. data/lib/rigor/cache/store.rb +132 -27
  79. data/lib/rigor/ci_detector.rb +90 -0
  80. data/lib/rigor/cli/annotate_command.rb +15 -9
  81. data/lib/rigor/cli/baseline_command.rb +4 -1
  82. data/lib/rigor/cli/check_command.rb +223 -32
  83. data/lib/rigor/cli/check_invocation.rb +84 -0
  84. data/lib/rigor/cli/coverage_command.rb +6 -1
  85. data/lib/rigor/cli/docs_command.rb +0 -30
  86. data/lib/rigor/cli/doctor_command.rb +169 -31
  87. data/lib/rigor/cli/lsp_command.rb +5 -0
  88. data/lib/rigor/cli/mcp_command.rb +5 -0
  89. data/lib/rigor/cli/plugins_command.rb +36 -6
  90. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  91. data/lib/rigor/cli/probe_environment.rb +85 -0
  92. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  93. data/lib/rigor/cli/skill_command.rb +22 -33
  94. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  95. data/lib/rigor/cli/skill_describe.rb +75 -9
  96. data/lib/rigor/cli/trace_command.rb +5 -6
  97. data/lib/rigor/cli/type_of_command.rb +9 -8
  98. data/lib/rigor/cli/type_scan_command.rb +8 -8
  99. data/lib/rigor/cli.rb +15 -4
  100. data/lib/rigor/config_audit.rb +48 -2
  101. data/lib/rigor/configuration/severity_profile.rb +31 -0
  102. data/lib/rigor/configuration.rb +156 -8
  103. data/lib/rigor/environment/default_libraries.rb +36 -0
  104. data/lib/rigor/environment/rbs_loader.rb +164 -5
  105. data/lib/rigor/environment.rb +29 -28
  106. data/lib/rigor/inference/budget_trace.rb +77 -2
  107. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  108. data/lib/rigor/inference/def_handle.rb +23 -0
  109. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  110. data/lib/rigor/inference/def_return_typer.rb +2 -1
  111. data/lib/rigor/inference/expression_typer.rb +361 -113
  112. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  113. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  114. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  115. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  116. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +17 -1
  117. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  118. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  119. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  120. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  121. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  122. data/lib/rigor/inference/mutation_widening.rb +42 -8
  123. data/lib/rigor/inference/narrowing.rb +127 -17
  124. data/lib/rigor/inference/parameter_inference_collector.rb +15 -0
  125. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  126. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  127. data/lib/rigor/inference/scope_indexer.rb +451 -36
  128. data/lib/rigor/inference/statement_evaluator.rb +64 -15
  129. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  130. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  131. data/lib/rigor/inference/void_origin.rb +25 -0
  132. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  133. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  134. data/lib/rigor/language_server/buffer_table.rb +46 -6
  135. data/lib/rigor/language_server/completion_provider.rb +2 -1
  136. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -0
  137. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  138. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  139. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  140. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  141. data/lib/rigor/language_server/server.rb +19 -9
  142. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  143. data/lib/rigor/language_server.rb +1 -0
  144. data/lib/rigor/plugin/base.rb +38 -24
  145. data/lib/rigor/plugin/inflector.rb +12 -3
  146. data/lib/rigor/plugin/io_boundary.rb +5 -1
  147. data/lib/rigor/plugin/isolation.rb +81 -11
  148. data/lib/rigor/plugin/load_error.rb +10 -1
  149. data/lib/rigor/plugin/loader.rb +96 -14
  150. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  151. data/lib/rigor/plugin/registry.rb +32 -23
  152. data/lib/rigor/plugin.rb +26 -0
  153. data/lib/rigor/protection/mutator.rb +3 -2
  154. data/lib/rigor/reflection.rb +64 -0
  155. data/lib/rigor/runtime/jit.rb +128 -0
  156. data/lib/rigor/scope/discovery_index.rb +12 -2
  157. data/lib/rigor/scope.rb +122 -28
  158. data/lib/rigor/sig_gen/classification.rb +5 -1
  159. data/lib/rigor/sig_gen/generator.rb +38 -6
  160. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  161. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  162. data/lib/rigor/sig_gen/renderer.rb +10 -0
  163. data/lib/rigor/sig_gen/write_result.rb +9 -4
  164. data/lib/rigor/sig_gen/writer.rb +207 -49
  165. data/lib/rigor/source/node_children.rb +116 -0
  166. data/lib/rigor/source/node_locator.rb +3 -1
  167. data/lib/rigor/source/node_walker.rb +4 -2
  168. data/lib/rigor/source.rb +1 -0
  169. data/lib/rigor/type/difference.rb +28 -24
  170. data/lib/rigor/type/hash_shape.rb +34 -10
  171. data/lib/rigor/version.rb +1 -1
  172. data/lib/rigortype.rb +24 -0
  173. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  174. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  175. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  176. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  177. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  178. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +3 -1
  179. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -2
  180. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  181. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  182. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  183. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  184. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  185. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  186. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  187. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  188. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  189. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  190. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +3 -1
  191. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  192. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +7 -5
  193. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  194. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  195. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  196. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  197. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  198. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  199. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  200. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  201. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  202. data/sig/prism_node_children.rbs +9 -0
  203. data/sig/rigor/cache.rbs +6 -0
  204. data/sig/rigor/inference/void_origin.rbs +18 -0
  205. data/sig/rigor/inference.rbs +7 -0
  206. data/sig/rigor/plugin/base.rbs +6 -6
  207. data/sig/rigor/reflection.rbs +1 -0
  208. data/sig/rigor/scope.rbs +12 -1
  209. data/skills/rigor-ci-setup/SKILL.md +10 -0
  210. data/skills/rigor-editor-setup/SKILL.md +11 -0
  211. data/skills/rigor-next-steps/SKILL.md +23 -2
  212. data/skills/rigor-plugin-author/SKILL.md +3 -3
  213. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  214. data/skills/rigor-plugin-review/SKILL.md +2 -2
  215. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  216. data/skills/rigor-project-init/SKILL.md +29 -3
  217. data/skills/rigor-project-init/references/01-detect.md +1 -1
  218. data/skills/rigor-project-init/references/02-configure.md +39 -5
  219. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  220. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  221. metadata +48 -26
  222. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -12,7 +12,7 @@ require_relative "../plugin/loader"
12
12
  require_relative "../plugin/services"
13
13
  require_relative "../reflection"
14
14
  require_relative "../type/combinator"
15
- require_relative "check_runner_factory"
15
+ require_relative "check_invocation"
16
16
  require_relative "command"
17
17
  require_relative "options"
18
18
 
@@ -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[
@@ -51,20 +53,19 @@ module Rigor
51
53
  findings.concat(audit_config(configuration))
52
54
 
53
55
  # 2. Run a scoped analysis to gather stats + diagnostics for the
54
- # deeper checks. Use no-cache so the probe doesn't churn disk.
55
- runner = CheckRunnerFactory.build(
56
+ # deeper checks, through the shared check-invocation entry point (#148).
57
+ # `READ_ONLY_OPTIONS` is no-cache so the probe doesn't churn disk.
58
+ result = CheckInvocation.run(
56
59
  configuration: configuration,
57
- options: { no_cache: true, explain: false, stats: true, workers: 0 },
58
- buffer: nil,
59
- cache_root: configuration.cache_path
60
- )
61
- result = runner.run(configuration.paths)
60
+ options: CheckInvocation::READ_ONLY_OPTIONS
61
+ ).result
62
62
 
63
63
  # 3. RBS environment check.
64
64
  findings.concat(check_rbs_environment(result))
65
65
 
66
- # 4. Plugin load check.
67
- findings.concat(check_plugins(configuration))
66
+ # 4. Plugin load check + 8. installation-skew check share one loaded registry.
67
+ registry = load_plugin_registry(configuration)
68
+ findings.concat(check_plugins(registry))
68
69
 
69
70
  # 5. Baseline drift check.
70
71
  findings.concat(check_baseline(configuration, result))
@@ -72,6 +73,12 @@ module Rigor
72
73
  # 6. Rails locked but no Rails plugins.
73
74
  findings.concat(check_rails_plugins(configuration))
74
75
 
76
+ # 7. Rigor itself resolved as a project dependency.
77
+ findings.concat(check_gemfile_install)
78
+
79
+ # 8. A bundled plugin loaded from a different rigortype installation than the engine (#194 slice 3).
80
+ findings.concat(check_plugin_skew(registry))
81
+
75
82
  report(findings, options.fetch(:format))
76
83
  findings.any? { |f| f[:status] == :fail } ? 1 : 0
77
84
  rescue OptionParser::InvalidArgument => e
@@ -122,37 +129,67 @@ module Rigor
122
129
  stats = result.stats
123
130
  return [] unless stats
124
131
 
132
+ quarantined = quarantined_signature_diagnostics(result)
125
133
  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
- ]
134
+ [empty_rbs_env_finding(stats)]
135
+ elsif quarantined.any?
136
+ [degraded_rbs_env_finding(stats, quarantined)]
136
137
  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
- ]
138
+ [healthy_rbs_env_finding(stats)]
145
139
  end
146
140
  end
147
141
 
148
- def check_plugins(configuration)
142
+ def empty_rbs_env_finding(stats)
143
+ {
144
+ check: CHECK_RBS,
145
+ status: :fail,
146
+ message: "RBS environment is empty (#{stats.rbs_classes_total} classes available)",
147
+ hint: "The RBS environment failed to build or loaded no signatures. " \
148
+ "Check `signature_paths:` for duplicate declarations, or run " \
149
+ "`rbs collection install` for gem signatures."
150
+ }
151
+ end
152
+
153
+ # The env is non-empty precisely BECAUSE the broken file was quarantined, so the class count alone reads
154
+ # as healthy — this is the case doctor used to pass. Classify the diagnostic the run already produced
155
+ # (ADR-77: route existing evidence) rather than re-probing the sig tree.
156
+ def degraded_rbs_env_finding(stats, quarantined)
157
+ {
158
+ check: CHECK_RBS,
159
+ status: :warn,
160
+ message: "RBS environment degraded — #{quarantined.size} `signature_paths:` file(s) " \
161
+ "skipped, #{stats.rbs_classes_total} classes available",
162
+ hint: "An unparseable `.rbs` is skipped so the rest of the env survives, which makes the " \
163
+ "run quieter, not cleaner: the types it declares are gone. Run `rbs validate` on your " \
164
+ "`sig/` set and fix the parse error. To make this fail the build, opt into the " \
165
+ "`reject-unparseable-signatures` bleeding-edge feature."
166
+ }
167
+ end
168
+
169
+ def healthy_rbs_env_finding(stats)
170
+ {
171
+ check: CHECK_RBS,
172
+ status: :pass,
173
+ message: "RBS environment healthy (#{stats.rbs_classes_total} classes available)",
174
+ hint: nil
175
+ }
176
+ end
177
+
178
+ def quarantined_signature_diagnostics(result)
179
+ result.diagnostics.select { |diagnostic| diagnostic.rule == "rbs.coverage.quarantined-signature" }
180
+ end
181
+
182
+ def load_plugin_registry(configuration)
149
183
  services = Plugin::Services.new(
150
184
  reflection: Reflection,
151
185
  type: Type::Combinator,
152
186
  configuration: configuration,
153
187
  cache_store: nil
154
188
  )
155
- registry = Plugin::Loader.load(configuration: configuration, services: services)
189
+ Plugin::Loader.load(configuration: configuration, services: services)
190
+ end
191
+
192
+ def check_plugins(registry)
156
193
  errors = registry.load_errors
157
194
  return [] if errors.empty?
158
195
 
@@ -166,6 +203,52 @@ module Rigor
166
203
  ]
167
204
  end
168
205
 
206
+ # WD5 (#194 slice 3) — a bundled plugin should load from the engine's own `plugins/` tree; slice 2
207
+ # anchors it there. This guards the residual anchoring cannot see: a bundled plugin whose resolved file
208
+ # sits OUTSIDE the engine tree — the fallback-require path, or a genuinely mixed installation where a
209
+ # foreign copy was already loaded. That is the #194 skew, which ran the engine with a load-bearing
210
+ # false-positive gate silently missing. Only plugins the engine bundles are checked; a third-party /
211
+ # project-bundle plugin is not the engine's to vouch for and is never flagged, and a nil resolved path
212
+ # (an injected requirer, an in-process no-op load) carries no provenance and is skipped.
213
+ def check_plugin_skew(registry)
214
+ registry.resolved_gem_paths.filter_map do |gem_name, resolved_path|
215
+ next if resolved_path.nil?
216
+ next unless Plugin::Loader.bundled_plugin_path(gem_name)
217
+ next if inside_engine_tree?(resolved_path)
218
+
219
+ plugin_skew_finding(gem_name, resolved_path)
220
+ end
221
+ end
222
+
223
+ def plugin_skew_finding(gem_name, resolved_path)
224
+ {
225
+ check: CHECK_PLUGIN_SKEW,
226
+ status: :warn,
227
+ message: "Plugin #{gem_name} loaded from a different rigortype installation than the engine " \
228
+ "(loaded from #{resolved_path}; engine at #{Plugin::Loader::ENGINE_ROOT})",
229
+ hint: "The engine and its bundled plugins are versioned together, so a copy resolved from another " \
230
+ "installation can run the engine with a mismatched plugin (the cause of wrong diagnostics in " \
231
+ "#194). Ensure a single `rigortype` is on the load path — remove any separately-installed " \
232
+ "`rigortype` gem that shadows this checkout, or reinstall so the engine and its `plugins/` " \
233
+ "tree come from one place."
234
+ }
235
+ end
236
+
237
+ # True when `resolved_path` lives inside the engine's own tree. Both sides are resolved through
238
+ # `File.realpath` first so a symlinked gem home / checkout (e.g. macOS `/var` → `/private/var`) does not
239
+ # read as foreign; a vanished file degrades to `File.expand_path` rather than raising.
240
+ def inside_engine_tree?(resolved_path)
241
+ engine = real_or_expanded(Plugin::Loader::ENGINE_ROOT)
242
+ target = real_or_expanded(resolved_path)
243
+ target == engine || target.start_with?("#{engine}#{File::SEPARATOR}")
244
+ end
245
+
246
+ def real_or_expanded(path)
247
+ File.realpath(path)
248
+ rescue StandardError
249
+ File.expand_path(path)
250
+ end
251
+
169
252
  def check_baseline(configuration, result)
170
253
  path = configuration.baseline_path
171
254
  return [] if path.nil? || !File.exist?(path)
@@ -238,6 +321,52 @@ module Rigor
238
321
  false
239
322
  end
240
323
 
324
+ # Rigor is a tool, not a library (ADR-27): resolving it as one of the project's own dependencies pins the
325
+ # application to Rigor's Ruby and drags Rigor's dependency graph into the app's resolution. The gem carries
326
+ # guardrails against arriving here, but they only fire during install and at boot — a project that already
327
+ # made the mistake never sees them again, so doctor names the state.
328
+ def check_gemfile_install
329
+ lock = File.join(Dir.pwd, "Gemfile.lock")
330
+ return [] unless File.file?(lock)
331
+ return [] unless rubygems_sourced_rigortype?(lock)
332
+
333
+ [
334
+ {
335
+ check: CHECK_GEMFILE,
336
+ status: :fail,
337
+ message: "Rigor is resolved as a dependency of this project (`rigortype` under GEM in Gemfile.lock)",
338
+ hint: "Remove `rigortype` from the Gemfile and install Rigor on its own — see " \
339
+ "https://github.com/rigortype/rigor/blob/master/docs/install.md. To keep the version " \
340
+ "pinned, use `mise use --pin gem:rigortype`, or an isolated `BUNDLE_GEMFILE` holding only " \
341
+ "Rigor (the CI chapter's pattern), which leaves your application's resolution untouched."
342
+ }
343
+ ]
344
+ end
345
+
346
+ # True only when `rigortype` resolves from a GEM remote — i.e. an ordinary `gem "rigortype"` in the app's
347
+ # Gemfile, the case worth reporting.
348
+ #
349
+ # The source is load-bearing, not decoration: Rigor's own repo, every fork of it, and any project vendoring
350
+ # Rigor for development all carry `rigortype` in Gemfile.lock too — under PATH (`remote: .`, from the
351
+ # `gemspec` directive) or GIT. Matching the name alone would fail this check on Rigor itself. Reading the
352
+ # lock by section also keeps a CHECKSUMS entry and a *dependency* line nested under another gem (six-space
353
+ # indent) from counting as a resolution.
354
+ def rubygems_sourced_rigortype?(path)
355
+ section = nil
356
+ in_specs = false
357
+
358
+ File.foreach(path) do |line|
359
+ case line
360
+ when /\A(\S.*?)\s*\z/ then (section = Regexp.last_match(1)) && (in_specs = false)
361
+ when /\A specs:\s*\z/ then in_specs = true
362
+ when /\A rigortype \(/ then return true if section == "GEM" && in_specs
363
+ end
364
+ end
365
+ false
366
+ rescue StandardError
367
+ false
368
+ end
369
+
241
370
  # ------------------------------------------------------------------
242
371
  # Reporting
243
372
  # ------------------------------------------------------------------
@@ -272,7 +401,9 @@ module Rigor
272
401
  if failures.empty? && warnings.empty?
273
402
  @out.puts("rigor doctor: all checks passed — no setup problems detected.")
274
403
  else
275
- @out.puts("rigor doctor: #{failures.size} issue(s) found")
404
+ # Count warnings too: a warn-only run (a degraded RBS env, a malformed baseline) used to headline
405
+ # "0 issue(s) found" above the very finding it was reporting.
406
+ @out.puts("rigor doctor: #{summary_counts(failures, warnings)}")
276
407
  failures.each { |f| print_finding(f) }
277
408
  warnings.each { |f| print_finding(f) } unless warnings.empty?
278
409
  end
@@ -280,6 +411,13 @@ module Rigor
280
411
  passes.each { |f| print_finding(f) } unless passes.empty?
281
412
  end
282
413
 
414
+ def summary_counts(failures, warnings)
415
+ parts = []
416
+ parts << "#{failures.size} issue(s)" unless failures.empty?
417
+ parts << "#{warnings.size} warning(s)" unless warnings.empty?
418
+ "#{parts.join(', ')} found"
419
+ end
420
+
283
421
  def print_finding(finding)
284
422
  label = case finding[:status]
285
423
  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