rigortype 0.3.5 → 0.3.7

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 (266) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/data/builtins/ruby_core/date.yml +6 -3
  4. data/data/builtins/ruby_core/enumerable.yml +1 -0
  5. data/data/builtins/ruby_core/exception.yml +2 -1
  6. data/data/builtins/ruby_core/file.yml +28 -14
  7. data/data/builtins/ruby_core/hash.yml +5 -2
  8. data/data/builtins/ruby_core/io.yml +33 -16
  9. data/data/builtins/ruby_core/random.yml +2 -1
  10. data/data/builtins/ruby_core/re.yml +6 -3
  11. data/data/builtins/ruby_core/struct.yml +2 -0
  12. data/data/builtins/ruby_core/time.yml +2 -1
  13. data/data/gem_overlay/activesupport/core_ext.rbs +442 -11
  14. data/docs/handbook/02-everyday-types.md +1 -1
  15. data/docs/handbook/04-tuples-and-shapes.md +1 -1
  16. data/docs/handbook/08-understanding-errors.md +1 -1
  17. data/docs/handbook/09-plugins.md +2 -2
  18. data/docs/handbook/10-sorbet.md +1 -1
  19. data/docs/handbook/README.md +2 -2
  20. data/docs/handbook/appendix-go.md +1 -1
  21. data/docs/handbook/appendix-java-csharp.md +2 -2
  22. data/docs/handbook/appendix-mypy.md +1 -1
  23. data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
  24. data/docs/handbook/appendix-rust.md +1 -1
  25. data/docs/handbook/appendix-type-theory.md +2 -2
  26. data/docs/handbook/appendix-typescript.md +4 -4
  27. data/docs/manual/02-cli-reference.md +29 -16
  28. data/docs/manual/03-configuration.md +1 -1
  29. data/docs/manual/04-diagnostics.md +12 -0
  30. data/docs/manual/05-inspecting-types.md +20 -8
  31. data/docs/manual/07-plugins.md +4 -0
  32. data/docs/manual/08-skills.md +1 -1
  33. data/docs/manual/11-ci.md +1 -1
  34. data/docs/manual/12-caching.md +1 -1
  35. data/docs/manual/15-type-protection-coverage.md +1 -1
  36. data/docs/manual/18-removing-dead-code.md +13 -4
  37. data/docs/manual/19-effect-labels.md +19 -12
  38. data/docs/manual/README.md +2 -2
  39. data/docs/manual/plugins/rigor-actionmailer.md +4 -4
  40. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  41. data/docs/manual/plugins/rigor-activejob.md +3 -3
  42. data/docs/manual/plugins/rigor-activerecord.md +78 -5
  43. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  44. data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
  45. data/docs/manual/plugins/rigor-rspec.md +6 -2
  46. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  47. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  48. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  49. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  50. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  51. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  52. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  53. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  54. data/lib/rigor/analysis/check_rules.rb +383 -48
  55. data/lib/rigor/analysis/crash_signature.rb +116 -0
  56. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  57. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  58. data/lib/rigor/analysis/diagnostic.rb +17 -6
  59. data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
  60. data/lib/rigor/analysis/incremental.rb +28 -0
  61. data/lib/rigor/analysis/incremental_session.rb +44 -9
  62. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  63. data/lib/rigor/analysis/reachability/graph.rb +44 -16
  64. data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
  65. data/lib/rigor/analysis/reachability/scan.rb +27 -7
  66. data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
  67. data/lib/rigor/analysis/result.rb +32 -0
  68. data/lib/rigor/analysis/run_cache_key.rb +53 -1
  69. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +93 -1
  70. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +14 -8
  71. data/lib/rigor/analysis/runner/pool_coordinator.rb +34 -0
  72. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  73. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -2
  74. data/lib/rigor/analysis/runner.rb +298 -38
  75. data/lib/rigor/analysis/worker_session.rb +10 -1
  76. data/lib/rigor/bleeding_edge.rb +7 -6
  77. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  78. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  79. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  80. data/lib/rigor/cache/descriptor.rb +42 -2
  81. data/lib/rigor/cache/file_digest.rb +20 -2
  82. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  83. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +31 -8
  84. data/lib/rigor/cache/store.rb +10 -1
  85. data/lib/rigor/cli/check_command.rb +67 -48
  86. data/lib/rigor/cli/coverage_command.rb +28 -24
  87. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  88. data/lib/rigor/cli/coverage_scan.rb +47 -6
  89. data/lib/rigor/cli/doc_links.rb +100 -0
  90. data/lib/rigor/cli/docs_command.rb +32 -2
  91. data/lib/rigor/cli/effects_command.rb +27 -2
  92. data/lib/rigor/cli/effects_diff_renderer.rb +82 -12
  93. data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
  94. data/lib/rigor/cli/effects_snapshot_command.rb +52 -7
  95. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  96. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  97. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  98. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  99. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  100. data/lib/rigor/cli/sig_gen_command.rb +18 -0
  101. data/lib/rigor/cli/type_of_command.rb +188 -41
  102. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  103. data/lib/rigor/cli/type_scan_command.rb +10 -1
  104. data/lib/rigor/cli/unused_command.rb +65 -16
  105. data/lib/rigor/cli.rb +22 -9
  106. data/lib/rigor/configuration.rb +7 -4
  107. data/lib/rigor/effects/definition_lines.rb +100 -0
  108. data/lib/rigor/effects/envelope_check.rb +18 -1
  109. data/lib/rigor/effects/liskov_check.rb +17 -8
  110. data/lib/rigor/effects/plugin_facts.rb +1 -1
  111. data/lib/rigor/effects/scanner.rb +2 -3
  112. data/lib/rigor/effects/signature_sources.rb +13 -2
  113. data/lib/rigor/effects/snapshot.rb +53 -21
  114. data/lib/rigor/effects/snapshot_diff.rb +26 -3
  115. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  116. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  117. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  118. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  119. data/lib/rigor/environment/rbs_loader.rb +525 -46
  120. data/lib/rigor/environment.rb +38 -15
  121. data/lib/rigor/inference/acceptance.rb +172 -20
  122. data/lib/rigor/inference/captured_locals.rb +62 -0
  123. data/lib/rigor/inference/content_join.rb +347 -0
  124. data/lib/rigor/inference/def_handle.rb +15 -3
  125. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  126. data/lib/rigor/inference/expression_typer.rb +1020 -141
  127. data/lib/rigor/inference/fork_map.rb +6 -1
  128. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  129. data/lib/rigor/inference/hkt_registry.rb +46 -0
  130. data/lib/rigor/inference/hkt_sugar_translator.rb +93 -0
  131. data/lib/rigor/inference/index_write_widening.rb +48 -0
  132. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  133. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  134. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  135. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  136. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  137. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  138. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  139. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  140. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  141. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  142. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  143. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  144. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  145. data/lib/rigor/inference/mutation_widening.rb +244 -174
  146. data/lib/rigor/inference/narrowing.rb +331 -27
  147. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  148. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  149. data/lib/rigor/inference/precision_scanner.rb +5 -2
  150. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  151. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  152. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  153. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  154. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  155. data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
  156. data/lib/rigor/inference/version_guard.rb +229 -0
  157. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  158. data/lib/rigor/plugin/base.rb +3 -2
  159. data/lib/rigor/plugin/inflector.rb +14 -5
  160. data/lib/rigor/plugin/io_boundary.rb +107 -4
  161. data/lib/rigor/plugin/loader.rb +14 -0
  162. data/lib/rigor/protection/analysis_guard.rb +60 -0
  163. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  164. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  165. data/lib/rigor/protection/discovery_seed.rb +1 -1
  166. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  167. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  168. data/lib/rigor/protection/mutator.rb +121 -18
  169. data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
  170. data/lib/rigor/reflection.rb +182 -30
  171. data/lib/rigor/scope/discovery_index.rb +39 -1
  172. data/lib/rigor/scope.rb +385 -4
  173. data/lib/rigor/sig_gen/classification.rb +12 -1
  174. data/lib/rigor/sig_gen/generator.rb +254 -5
  175. data/lib/rigor/sig_gen/renderer.rb +1 -6
  176. data/lib/rigor/sig_gen/writer.rb +3 -0
  177. data/lib/rigor/source/constant_path.rb +79 -2
  178. data/lib/rigor/triage/catalogue.rb +1 -1
  179. data/lib/rigor/type/combinator.rb +10 -0
  180. data/lib/rigor/type/maybe.rb +47 -0
  181. data/lib/rigor/type/refined.rb +1 -2
  182. data/lib/rigor/type/result.rb +53 -0
  183. data/lib/rigor/type.rb +2 -0
  184. data/lib/rigor/version.rb +1 -1
  185. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  186. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  187. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  188. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  189. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  190. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  191. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  192. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  193. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  194. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  195. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  196. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  197. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  198. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  199. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  200. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  201. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  202. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  203. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  204. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  205. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  206. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  207. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  208. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  209. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  210. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  211. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  212. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  213. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  214. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  215. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  216. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  217. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  218. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  219. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  220. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  221. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  222. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  223. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  224. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  225. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  226. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  227. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  228. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  229. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  230. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  231. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  232. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  233. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  234. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  235. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  236. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  237. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  238. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  239. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  240. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  241. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  242. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  243. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  244. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  245. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  246. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  247. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  248. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  249. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  250. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  251. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  252. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  253. data/sig/rigor/analysis/baseline.rbs +1 -1
  254. data/sig/rigor/environment.rbs +3 -0
  255. data/sig/rigor/inference.rbs +4 -1
  256. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  257. data/sig/rigor/reflection.rbs +3 -1
  258. data/sig/rigor/scope.rbs +25 -1
  259. data/sig/rigor/type.rbs +95 -1
  260. data/sig/rigor.rbs +16 -0
  261. data/skills/rigor-ci-setup/SKILL.md +2 -2
  262. data/skills/rigor-editor-setup/SKILL.md +2 -2
  263. data/skills/rigor-mcp-setup/SKILL.md +2 -2
  264. data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
  265. data/skills/rigor-plugin-review/SKILL.md +3 -3
  266. metadata +73 -3
@@ -11,7 +11,6 @@ require_relative "../analysis/rule_catalog"
11
11
  # The baseline filter runs on EVERY exit path — including the ADR-87 WD4 cache-HIT fast path, which skips
12
12
  # `load_check_dependencies` — so it must be a load-time require. It pulls only YAML, never the engine.
13
13
  require_relative "../analysis/baseline"
14
- require_relative "../runtime/jit"
15
14
  require_relative "command"
16
15
  require_relative "options"
17
16
  require_relative "diagnostic_formats"
@@ -34,12 +33,10 @@ module Rigor
34
33
  # concerns that are clearer read together than split across micro-classes.
35
34
  class CheckCommand < Command # rubocop:disable Metrics/ClassLength
36
35
  # @return [Integer] CLI exit status.
37
- def run # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
38
- # Arm deferred YJIT enablement before any analysis work: the deadline
39
- # thread only fires once a run outlasts the amortization window, so a
40
- # short check finishes before it ever pays JIT compile cost while a
41
- # long run JITs its dominant tail (Runtime::Jit).
42
- Runtime::Jit.enable_after(Runtime::Jit.deadline_seconds)
36
+ #
37
+ # Deferred YJIT enablement (Runtime::Jit) is armed by `CLI#dispatch` for every command, this
38
+ # one included, before any analysis work runs.
39
+ def run # rubocop:disable Metrics/AbcSize
43
40
  # ADR-87 WD4 — parse options + resolve config WITHOUT the inference engine, so the run-cache hit probe
44
41
  # can run first. The heavy engine (`load_check_dependencies`) loads only on a miss / non-cacheable run.
45
42
  options = parse_check_options
@@ -50,7 +47,7 @@ module Rigor
50
47
  configuration = apply_bleeding_edge_override(configuration, options)
51
48
  config_warnings = warn_unresolved_config(configuration)
52
49
  cache_root = configuration.cache_path
53
- handle_clear_cache(cache_root) if options.fetch(:clear_cache)
50
+ handle_clear_cache(cache_root, options.fetch(:format)) if options.fetch(:clear_cache)
54
51
 
55
52
  # ADR-87 WD4 — try to serve the whole run from the ADR-45 cache before booting the engine. A hit boots
56
53
  # only CLI + config + cache + digest code (no `rigor/inference`), skipping the plugin prepass + env
@@ -76,7 +73,7 @@ module Rigor
76
73
  write_run_stats(result.stats) if result.stats
77
74
  write_trace_appendices
78
75
  runner.cache_store&.evict!
79
- write_cache_stats(cache_root, runner.cache_store) if options.fetch(:cache_stats)
76
+ write_cache_stats(cache_root, runner.cache_store, options.fetch(:format)) if options.fetch(:cache_stats)
80
77
 
81
78
  exit_code = result.success? ? 0 : 1
82
79
  exit_code = 1 if baseline_strict_violation?(raw_result.diagnostics, configuration, options)
@@ -195,7 +192,8 @@ module Rigor
195
192
  incremental = normalize_diagnostics(session.reanalyze_subset(subset))
196
193
  full = normalize_diagnostics(verify_full_diagnostics(configuration, paths))
197
194
 
198
- report_verify_incremental(incremental, full, subset_size: subset.size, total: analyzed.size)
195
+ report_verify_incremental(incremental, full, subset_size: subset.size, total: analyzed.size,
196
+ format: options.fetch(:format))
199
197
  end
200
198
 
201
199
  # ADR-46 — cross-process incremental analysis (`--incremental`). Derives the global fingerprint cheaply (no RBS
@@ -230,7 +228,8 @@ module Rigor
230
228
  @err.puts("rigor: --incremental #{warm ? 'warm — reused cached diagnostics' : 'cold — full analysis'} " \
231
229
  "(#{session.analyzed_files.size} files)")
232
230
  emit_incremental_fact_surface_notes(session)
233
- write_incremental_cache_stats(session, cache_root, store) if options.fetch(:cache_stats)
231
+ write_incremental_cache_stats(session, cache_root, store, options.fetch(:format)) if
232
+ options.fetch(:cache_stats)
234
233
 
235
234
  result = apply_baseline_filter(Analysis::Result.new(diagnostics: diagnostics, stats: nil), configuration,
236
235
  options)
@@ -286,8 +285,8 @@ module Rigor
286
285
  # does) plus the fact-surface status line, so an operator can see whether a full run was fact-surface
287
286
  # driven. The plain `check` cache-stats path is bypassed by the incremental short-circuit, so it is
288
287
  # emitted here.
289
- def write_incremental_cache_stats(session, cache_root, store)
290
- write_cache_stats(cache_root, store)
288
+ def write_incremental_cache_stats(session, cache_root, store, format)
289
+ write_cache_stats(cache_root, store, format)
291
290
  status =
292
291
  if !session.opaque_plugin_ids.empty?
293
292
  "opaque (#{session.opaque_plugin_ids.sort.join(', ')})"
@@ -296,7 +295,7 @@ module Rigor
296
295
  else
297
296
  "unchanged"
298
297
  end
299
- @out.puts(" plugin fact surface: #{status}")
298
+ side_output(format).puts(" plugin fact surface: #{status}")
300
299
  end
301
300
 
302
301
  def verify_full_diagnostics(configuration, paths)
@@ -310,11 +309,14 @@ module Rigor
310
309
  end
311
310
  end
312
311
 
313
- def report_verify_incremental(incremental, full, subset_size:, total:)
312
+ # The OK line follows the same rule as the other side outputs (#493) stdout under `text`, stderr
313
+ # under a machine format. The FAILED branch below has always gone to stderr: it is a failure
314
+ # report, and a run that produced one has no valid document to protect anyway.
315
+ def report_verify_incremental(incremental, full, subset_size:, total:, format: "text")
314
316
  if incremental == full
315
- @out.puts("rigor: --verify-incremental OK — incremental " \
316
- "(#{subset_size}/#{total} files re-analyzed, rest from cache) " \
317
- "matches full (#{full.size} diagnostics)")
317
+ side_output(format).puts("rigor: --verify-incremental OK — incremental " \
318
+ "(#{subset_size}/#{total} files re-analyzed, rest from cache) " \
319
+ "matches full (#{full.size} diagnostics)")
318
320
  return 0
319
321
  end
320
322
 
@@ -495,46 +497,46 @@ module Rigor
495
497
  end
496
498
  Options.add_editor_mode(opts, options)
497
499
  opts.on("--baseline=PATH",
498
- "ADR-22: load baseline from PATH (overrides .rigor.yml `baseline:`)") do |value|
500
+ "load baseline from PATH (overrides .rigor.yml `baseline:`)") do |value|
499
501
  options[:baseline] = value
500
502
  end
501
503
  opts.on("--no-baseline",
502
- "ADR-22: ignore any configured baseline for this run") do
504
+ "ignore any configured baseline for this run") do
503
505
  options[:baseline] = false
504
506
  end
505
507
  opts.on("--baseline-strict",
506
- "ADR-22: fail the run on any baseline drift (CI gate)") do
508
+ "fail the run on any baseline drift (CI gate)") do
507
509
  options[:baseline_strict] = true
508
510
  end
509
511
  opts.on("--treat-all-as-inline-rbs",
510
- "ADR-32: force-load rigor-rbs-inline with require_magic_comment: false") do
512
+ "force-load rigor-rbs-inline with require_magic_comment: false") do
511
513
  options[:treat_all_as_inline_rbs] = true
512
514
  end
513
515
  opts.on("--verify-incremental",
514
- "ADR-46: assert incremental analysis matches a full run, then exit") do
516
+ "assert incremental analysis matches a full run, then exit") do
515
517
  options[:verify_incremental] = true
516
518
  end
517
519
  opts.on("--incremental",
518
- "ADR-46: re-analyze only files changed since the last run (cross-process cache)") do
520
+ "re-analyze only files changed since the last run (cross-process cache)") do
519
521
  options[:incremental] = true
520
522
  end
521
523
  opts.on("--no-ci-detect",
522
- "ADR-51: do not auto-emit CI-native output when a CI environment is detected") do
524
+ "do not auto-emit CI-native output when a CI environment is detected") do
523
525
  options[:ci_detect] = false
524
526
  end
525
527
  # ADR-50 § WD2 — `=[LIST]` (not ` [LIST]`) so a bare `--bleeding-edge` never swallows a following positional
526
528
  # path: `rigor check --bleeding-edge lib` adopts the whole overlay and checks `lib`.
527
529
  opts.on("--bleeding-edge=[LIST]",
528
- "ADR-50: adopt the bleeding-edge overlay for this run " \
530
+ "adopt the bleeding-edge overlay for this run " \
529
531
  "(all features, or a comma-separated feature-id list)") do |value|
530
532
  options[:bleeding_edge] = value.nil? || value.split(",").map(&:strip).reject(&:empty?)
531
533
  end
532
534
  opts.on("--no-bleeding-edge",
533
- "ADR-50: ignore any configured bleeding_edge: selection for this run") do
535
+ "ignore any configured bleeding_edge: selection for this run") do
534
536
  options[:bleeding_edge] = false
535
537
  end
536
538
  opts.on("--no-tolerated-effects",
537
- "ADR-103: check effect envelopes as if effects.tolerated: were empty") do
539
+ "check effect envelopes as if effects.tolerated: were empty") do
538
540
  options[:no_tolerated_effects] = true
539
541
  end
540
542
  end
@@ -599,12 +601,13 @@ module Rigor
599
601
  }]
600
602
  end
601
603
 
602
- def handle_clear_cache(cache_root)
604
+ def handle_clear_cache(cache_root, format)
605
+ out = side_output(format)
603
606
  if File.directory?(cache_root)
604
607
  FileUtils.rm_rf(cache_root)
605
- @out.puts("Cleared cache: #{cache_root}")
608
+ out.puts("Cleared cache: #{cache_root}")
606
609
  else
607
- @out.puts("Cache already empty: #{cache_root}")
610
+ out.puts("Cache already empty: #{cache_root}")
608
611
  end
609
612
  end
610
613
 
@@ -772,42 +775,43 @@ module Rigor
772
775
  Kernel.format("%.1f MB", bytes / 1_048_576.0)
773
776
  end
774
777
 
775
- def write_cache_stats(cache_root, runtime_store)
778
+ def write_cache_stats(cache_root, runtime_store, format)
776
779
  inv = Cache::Store.disk_inventory(root: cache_root)
780
+ out = side_output(format)
777
781
 
778
- @out.puts("")
779
- @out.puts("Cache (root: #{inv.fetch(:root)})")
782
+ out.puts("")
783
+ out.puts("Cache (root: #{inv.fetch(:root)})")
780
784
  schema = inv.fetch(:schema_version)
781
- @out.puts(" schema_version: #{schema.nil? ? 'absent' : schema}")
782
- write_disk_inventory(inv)
783
- write_runtime_stats(runtime_store) if runtime_store
785
+ out.puts(" schema_version: #{schema.nil? ? 'absent' : schema}")
786
+ write_disk_inventory(inv, out)
787
+ write_runtime_stats(runtime_store, out) if runtime_store
784
788
  end
785
789
 
786
- def write_disk_inventory(inv)
790
+ def write_disk_inventory(inv, out)
787
791
  if inv.fetch(:total_entries).zero?
788
- @out.puts(" (empty)")
792
+ out.puts(" (empty)")
789
793
  return
790
794
  end
791
795
 
792
- @out.puts(" #{inv.fetch(:total_entries)} entries, #{format_bytes(inv.fetch(:total_bytes))}")
796
+ out.puts(" #{inv.fetch(:total_entries)} entries, #{format_bytes(inv.fetch(:total_bytes))}")
793
797
  inv.fetch(:producers).each do |producer|
794
798
  bytes = format_bytes(producer.fetch(:bytes))
795
- @out.puts(" #{producer.fetch(:id)}: #{producer.fetch(:entries)} entries, #{bytes}")
799
+ out.puts(" #{producer.fetch(:id)}: #{producer.fetch(:entries)} entries, #{bytes}")
796
800
  end
797
801
  end
798
802
 
799
- def write_runtime_stats(store)
803
+ def write_runtime_stats(store, out)
800
804
  stats = store.stats
801
805
  hits = stats.fetch(:hits)
802
806
  misses = stats.fetch(:misses)
803
807
  writes = stats.fetch(:writes)
804
- @out.puts(" this run: #{hits} #{plural(hits, 'hit')}, " \
805
- "#{misses} #{plural(misses, 'miss', 'misses')}, " \
806
- "#{writes} #{plural(writes, 'write')}")
808
+ out.puts(" this run: #{hits} #{plural(hits, 'hit')}, " \
809
+ "#{misses} #{plural(misses, 'miss', 'misses')}, " \
810
+ "#{writes} #{plural(writes, 'write')}")
807
811
  stats.fetch(:by_producer).each do |id, counts|
808
- @out.puts(" #{id}: #{counts.fetch(:hits)} #{plural(counts.fetch(:hits), 'hit')}, " \
809
- "#{counts.fetch(:misses)} #{plural(counts.fetch(:misses), 'miss', 'misses')}, " \
810
- "#{counts.fetch(:writes)} #{plural(counts.fetch(:writes), 'write')}")
812
+ out.puts(" #{id}: #{counts.fetch(:hits)} #{plural(counts.fetch(:hits), 'hit')}, " \
813
+ "#{counts.fetch(:misses)} #{plural(counts.fetch(:misses), 'miss', 'misses')}, " \
814
+ "#{counts.fetch(:writes)} #{plural(counts.fetch(:writes), 'write')}")
811
815
  end
812
816
  end
813
817
 
@@ -822,6 +826,21 @@ module Rigor
822
826
  format("%.1f MiB", bytes / (1024.0 * 1024.0))
823
827
  end
824
828
 
829
+ # Where a human-readable side output goes (#493).
830
+ #
831
+ # `--format text` is prose already, so an extra block after the diagnostics is just more prose. Every
832
+ # other format is a machine contract — a SARIF document a code-scanning upload parses, a Checkstyle
833
+ # or JUnit XML tree, a GitLab Code Quality array — and appending `Cache (root: …)` to it produces a
834
+ # document the consumer rejects. Measured: all seven non-text formats were corrupted by
835
+ # `--cache-stats`, and JSON / SARIF / GitLab failed to parse at all.
836
+ #
837
+ # The information is not dropped, because it is what the user asked for by passing the flag; it moves
838
+ # to stderr, which is where this command already routes everything it says about a run rather than
839
+ # about the code.
840
+ def side_output(format)
841
+ format == "text" ? @out : @err
842
+ end
843
+
825
844
  def write_result(result, format, coverage: nil, config_warnings: [])
826
845
  case format
827
846
  when "json"
@@ -14,7 +14,6 @@ require_relative "../inference/parameter_inference_collector"
14
14
  require_relative "../protection/mutation_scanner"
15
15
  require_relative "../protection/test_suite_oracle"
16
16
  require_relative "../language_server/project_context"
17
- require_relative "../runtime/jit"
18
17
  require_relative "../scope"
19
18
  require_relative "coverage_report"
20
19
  require_relative "coverage_renderer"
@@ -55,11 +54,11 @@ module Rigor
55
54
  DEFAULT_TEST_COMMAND = %w[bundle exec rake].freeze
56
55
 
57
56
  # @return [Integer] CLI exit status.
58
- def run # rubocop:disable Metrics/AbcSize
59
- # Arm deferred YJIT enablement (Runtime::Jit): a scan long enough to
60
- # amortize JIT compile cost enables mid-flight; a short one finishes
61
- # first and never pays it. Same seam as `rigor check`.
62
- Runtime::Jit.enable_after(Runtime::Jit.deadline_seconds)
57
+ #
58
+ # Deferred YJIT enablement (Runtime::Jit) is armed by `CLI#dispatch` for every command, this
59
+ # one included: a scan long enough to amortize JIT compile cost enables mid-flight; a short one
60
+ # finishes first and never pays it.
61
+ def run
63
62
  options = parse_options
64
63
  return mutation_misuse_error if options[:mutation] && !options[:protection]
65
64
  return with_tests_misuse_error if options[:with_tests] && !options[:mutation]
@@ -218,24 +217,23 @@ module Rigor
218
217
  # class. Tier 1 seeds unconditionally because it only reclassifies sites it already counted; Tier 2 adds
219
218
  # sites to a denominator `--threshold` gates CI on, which is why only the latter is gated on a feature id.
220
219
  def scope_with_inferred_params(paths, configuration, environment, workers)
221
- base = Scope.empty(environment: environment)
222
- seed = {}
223
-
224
- discovered = Inference::ScopeIndexer.discovered_classes_for_paths(paths)
225
- seed[:discovered_classes] = discovered unless discovered.empty?
226
-
227
- table = Inference::ParameterInferenceCollector.collect(
228
- files: paths, environment: environment, target_ruby: configuration.target_ruby, workers: workers
220
+ CoverageScan.discovery_seeded_scope(
221
+ files: paths, configuration: configuration, environment: environment,
222
+ parameter_inference: true, workers: workers
229
223
  )
230
- seed[:param_inferred_types] = table unless table.empty?
231
-
232
- return base if seed.empty?
233
-
234
- base.with_discovery(base.discovery.with(**seed))
235
224
  end
236
225
 
226
+ # Issue #686 — an unmeasured file fails the build the way a parse error already does, and for the same
227
+ # reason: the command could not measure part of what it was asked about, so its ratio is an answer to
228
+ # a smaller question than the user asked. Deliberately NOT routed through `--threshold`, which the
229
+ # line below applies to a ratio: a crashed file's mutants leave `killed + survived` at zero, the
230
+ # project ratio is then computed over the files that DID work, and a run that measured nothing at all
231
+ # came out at 1.0 and passed every threshold. It is also deliberately not the `harness_errors` count,
232
+ # which #264 keeps out of the exit code on purpose — a few rescued mutants are the transient that
233
+ # bucket exists to make visible, while a wholly unmeasured FILE is not a transient.
237
234
  def determine_protection_exit(report, options)
238
235
  return 1 unless report.parse_errors.empty?
236
+ return 1 if unmeasured_files_in(report).positive?
239
237
 
240
238
  threshold = options[:threshold]
241
239
  return 0 if threshold.nil?
@@ -243,6 +241,12 @@ module Rigor
243
241
  report.ratio < threshold ? 1 : 0
244
242
  end
245
243
 
244
+ # Issue #686 — the one reader of the count, shared with the stderr warning. The Tier-1 protection
245
+ # report has no such notion, so a report without the field reads as zero rather than erroring.
246
+ def unmeasured_files_in(report)
247
+ report.respond_to?(:unmeasured_files) ? report.unmeasured_files : 0
248
+ end
249
+
246
250
  def usage_error
247
251
  @err.puts("coverage: at least one path is required")
248
252
  @err.puts(USAGE)
@@ -255,11 +259,11 @@ module Rigor
255
259
 
256
260
  # The protection scan must see the same receiver types `rigor check` does — including plugin-contributed
257
261
  # `dynamic_return` types (a controller's `params` → `ActionController::Parameters`, a `Model.where` →
258
- # `ActiveRecord::Relation[Model]`). The bare `CoverageScan.project_environment` carries only the RBS environment
259
- # (no plugin registry), so every plugin-typed receiver reads `Dynamic` and its dispatch site is miscounted as
260
- # *unprotected* — a systematic undercount of what Rigor actually types on a plugin-using project.
261
- # `ProjectContext` builds the plugin-aware environment (registry materialised + the per-run prepare pass that
262
- # primes producers like the controller / model index) exactly as the LSP and the runner do.
262
+ # `ActiveRecord::Relation[Model]`). A bare RBS environment carries no plugin registry, so every plugin-typed
263
+ # receiver reads `Dynamic` and its dispatch site is miscounted as *unprotected* — a systematic undercount of
264
+ # what Rigor actually types on a plugin-using project. `ProjectContext` builds the plugin-aware environment
265
+ # (registry materialised + the per-run prepare pass that primes producers like the controller / model index)
266
+ # exactly as the LSP, the runner, and since #513 the precision path all do.
263
267
  def plugin_aware_environment(configuration)
264
268
  LanguageServer::ProjectContext.new(configuration: configuration).environment
265
269
  end
@@ -9,6 +9,8 @@ require_relative "../protection/dependency_closure"
9
9
  require_relative "../protection/discovery_seed"
10
10
  require_relative "../protection/mutation_cache"
11
11
 
12
+ require_relative "measurement_integrity_warning"
13
+
12
14
  module Rigor
13
15
  class CLI
14
16
  # ADR-63 Tier 2 + ADR-70 — the mutation-effectiveness and fused static∪dynamic protection paths, factored out of
@@ -43,17 +45,13 @@ module Rigor
43
45
 
44
46
  private
45
47
 
46
- # @param report [MutationProtectionReport, FusedProtectionReport] both expose `total_harness_errors`.
48
+ # Both measurement-integrity warnings, and the escalation rule between them, live in
49
+ # {MeasurementIntegrityWarning}. Only the floor stays here: it is this command's policy, and
50
+ # `coverage_command_spec` reads it off this module.
51
+ #
52
+ # @param report [MutationProtectionReport, FusedProtectionReport]
47
53
  def warn_harness_errors(report)
48
- count = report.total_harness_errors
49
- return if count < HARNESS_ERROR_WARN_FLOOR
50
-
51
- @err.puts(
52
- "coverage: #{count} mutants failed inside the measurement harness (\"harness_errors\", " \
53
- "at/above the #{HARNESS_ERROR_WARN_FLOOR}-mutant floor) — excluded from the ratio like a " \
54
- "parse-invalid mutant, but this many suggests a harness defect rather than one-off noise. " \
55
- "Investigate before trusting --threshold on this run."
56
- )
54
+ MeasurementIntegrityWarning.emit(report, err: @err, floor: HARNESS_ERROR_WARN_FLOOR)
57
55
  end
58
56
 
59
57
  # The cross-file knowledge Tier 2 measures with — the #253 gate, and the ONLY place in this feature that
@@ -262,13 +260,11 @@ module Rigor
262
260
  # the two sets were assembled.
263
261
  #
264
262
  # The two cache phases each get their OWN {Cache::FileDigest.with_run} scope and the MEASUREMENT sits
265
- # between them, deliberately: that scope installs a per-path digest memo, and {Protection::ClosureKillOracle}
266
- # rewrites one process-private temp file per mutant and digests it through a buffer binding. A memo
267
- # spanning the measurement would hand every mutant after the first the FIRST one's digest, the mutated
268
- # file's discovery bundle would never be re-walked, and the run would report zero cross-file kills a
269
- # plausible-looking number rather than an error. The oracle's own comment states the same invariant from
270
- # the other side. The cost of two scopes is one extra SHA-256 per cached file, against a measurement
271
- # that is hundreds of analyses.
263
+ # between them, deliberately: that scope installs a per-path digest memo. {Protection::ClosureKillOracle}
264
+ # now writes each mutant to a fresh block-scoped path, so a memo spanning the measurement could no
265
+ # longer serve one mutant another's digest but keeping the measurement outside any memo scope stays
266
+ # correct by construction rather than by the oracle's path discipline, and costs only one extra
267
+ # SHA-256 per cached file against a measurement that is hundreds of analyses.
272
268
  def measure_mutation_files(paths, cache:, scanner:, context:, configuration:, workers:)
273
269
  cached = with_digest_run(configuration) { paths.to_h { |path| [path, cache.fetch(path)] }.compact }
274
270
  pending = paths - cached.keys
@@ -4,7 +4,11 @@ require "prism"
4
4
 
5
5
  require_relative "../configuration"
6
6
  require_relative "../environment"
7
+ require_relative "../inference/parameter_inference_collector"
7
8
  require_relative "../inference/precision_scanner"
9
+ require_relative "../inference/scope_indexer"
10
+ require_relative "../language_server/project_context"
11
+ require_relative "../protection/discovery_seed"
8
12
  require_relative "../scope"
9
13
  require_relative "coverage_report"
10
14
 
@@ -20,19 +24,56 @@ module Rigor
20
24
  # @param files [Array<String>] explicit `.rb` file paths to scan.
21
25
  # @param configuration [Rigor::Configuration]
22
26
  # @return [Rigor::CLI::CoverageReport]
27
+ #
28
+ # Issue #513 — the environment is PLUGIN-AWARE (`LanguageServer::ProjectContext`, the same builder
29
+ # `--protection` uses), not the bare RBS environment this path carried until 2026-09-01. Without the
30
+ # plugin registry every plugin-typed receiver read `Dynamic` and the precision ratio understated the
31
+ # engine by ~1.2 points on a Rails target — the defect the protection path documented and fixed for
32
+ # itself on 2026-07-04, never applied here.
23
33
  def precision_report(files:, configuration:)
24
- scope = Scope.empty(environment: project_environment(configuration))
34
+ scope = discovery_seeded_scope(
35
+ files: files,
36
+ configuration: configuration,
37
+ environment: LanguageServer::ProjectContext.new(configuration: configuration).environment,
38
+ parameter_inference: configuration.parameter_inference
39
+ )
25
40
  scanner = Inference::PrecisionScanner.new(scope: scope)
26
41
  accumulator = CoverageAccumulator.new
27
42
  files.each { |path| scan_into(path, scanner, accumulator, configuration) }
28
43
  accumulator.to_report(files, {})
29
44
  end
30
45
 
31
- def project_environment(configuration)
32
- Environment.for_project(
33
- libraries: configuration.libraries,
34
- signature_paths: configuration.signature_paths
35
- )
46
+ # The cross-file facts a scan must see to report the types the engine actually infers, rather than the types a
47
+ # file-at-a-time walk can reach on its own.
48
+ #
49
+ # Issue #513 — the seed is the FULL check-walk bundle (`Protection::DiscoverySeed.discovery_tables`: classes,
50
+ # def nodes and sources for both kinds, superclasses, includes, visibilities, methods, and the Data / Struct
51
+ # member layouts), not just `discovered_classes`. The two-table seed #505 introduced closed the biggest part of
52
+ # the gap and left the rest: without the def-node / ancestry tables every cross-file call to a source-inferred
53
+ # project method measured as unresolved while `rigor check` resolves it — +0.27pp on redmine, +0.77pp on
54
+ # mastodon from the tables alone.
55
+ #
56
+ # `parameter_inference` is a parameter rather than a config read because the two callers want different answers:
57
+ # the precision lens must mirror the walk it describes (`check` leaves the table empty unless
58
+ # `parameter_inference:` is on, ADR-67 WD6a), while `--protection` seeds it unconditionally by ADR-67's own
59
+ # wiring — it only ever reclassifies sites it already counted.
60
+ #
61
+ # @return [Rigor::Scope]
62
+ def discovery_seeded_scope(files:, configuration:, environment:, parameter_inference:, workers: nil)
63
+ base = Scope.empty(environment: environment)
64
+ seed = Protection::DiscoverySeed.discovery_tables(files).dup
65
+
66
+ if parameter_inference
67
+ table = Inference::ParameterInferenceCollector.collect(
68
+ files: files, environment: environment, target_ruby: configuration.target_ruby,
69
+ workers: workers || configuration.parallel_workers
70
+ )
71
+ seed[:param_inferred_types] = table unless table.empty?
72
+ end
73
+
74
+ return base if seed.empty?
75
+
76
+ base.with_discovery(base.discovery.with(**seed))
36
77
  end
37
78
 
38
79
  # Parses one file and feeds the scan result (or a parse-error record) into `accumulator`. `scanner` /
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class CLI
5
+ # Turns the documentation's own relative links into keys `rigor docs` can answer (#430).
6
+ #
7
+ # The manual and handbook link each other and the design corpus the way any prose does —
8
+ # `[ADR-103](../adr/103-effect-labels.md)`. Those links are correct in the repository and correct on
9
+ # GitHub, and they are the coupling between documents: they say which document explains what, and
10
+ # deleting them to leave a bare `ADR-103` would cost that and give nothing back.
11
+ #
12
+ # They are wrong in exactly one place: a reader looking at the file inside an installed gem, where
13
+ # `docs/adr/` was never packaged. Two earlier attempts fixed the file for that reader and made it
14
+ # worse for the others — rewriting every link to a `blob/master` URL hardcoded a host, an
15
+ # organisation and a branch in 297 places and sent a v0.3.5 reader to a document that had moved on;
16
+ # deleting the markup severed the coupling.
17
+ #
18
+ # So the source keeps its links and `rigor docs` rewrites them on the way out:
19
+ #
20
+ # [ADR-103](../adr/103-effect-labels.md) → [ADR-103][adr/103-effect-labels]
21
+ #
22
+ # The key is the repository path minus `docs/` and `.md`, which for a packaged page is exactly the
23
+ # name `rigor docs` already takes, so the reader can run what they are shown. For an unpackaged one
24
+ # the key still resolves — to a routed message naming the path and the repository, from the single
25
+ # constant below rather than from 297 rewritten links.
26
+ module DocLinks
27
+ REPOSITORY = "https://github.com/rigortype/rigor"
28
+
29
+ # `[label](target)` and `[label](target#anchor)`, skipping absolute URLs and bare anchors.
30
+ MARKDOWN_LINK = /\[([^\]]*)\]\((?!https?:|mailto:|#)([^)#\s]+)(#[^)\s]*)?\)/
31
+ private_constant :MARKDOWN_LINK
32
+
33
+ # Where the gem's `docs/` tree sits, so a rendered path can be made repository-relative.
34
+ GEM_ROOT = File.expand_path("../../..", __dir__)
35
+ private_constant :GEM_ROOT
36
+
37
+ module_function
38
+
39
+ # @param body [String] the page as it is stored.
40
+ # @param from [String] its absolute path, so relative targets resolve.
41
+ # @return [String] the page with every relative link turned into a `rigor docs` key.
42
+ def rewrite(body, from:)
43
+ dir = File.dirname(from)
44
+ body.gsub(MARKDOWN_LINK) do
45
+ label = Regexp.last_match(1)
46
+ target = Regexp.last_match(2)
47
+ anchor = Regexp.last_match(3)
48
+ key = key_for(target, dir)
49
+ key.nil? ? "[#{label}](#{target}#{anchor})" : "[#{label}][#{key}#{anchor}]"
50
+ end
51
+ end
52
+
53
+ # Splits a key into its page and its section, for a reader who pasted one back.
54
+ def split_anchor(key)
55
+ page, anchor = key.to_s.split("#", 2)
56
+ [page, anchor]
57
+ end
58
+
59
+ # The key naming `target` as written in `dir`, or nil when it points outside the repository.
60
+ #
61
+ # A `.md` under `docs/` loses both the prefix and the extension, which is exactly the name
62
+ # `rigor docs` already answers — that is what lets a rendered page hand the reader something they
63
+ # can run. Everything else keeps its repository-relative path verbatim: a directory
64
+ # (`examples/rigor-deprecations/`), a source file (`spec/rigor/environment_spec.rb`), a root
65
+ # document (`CHANGELOG.md`). Those are equally routable and equally worth naming; only the
66
+ # round-trip differs, and {.repository_path} reverses both.
67
+ #
68
+ # `target` is taken as written rather than expanded, because `File.expand_path` drops the trailing
69
+ # slash that tells a directory link apart from a page link — prose that writes `[the ADRs](../adr/)`
70
+ # is pointing at a tree, and a key without the slash would claim there is a page there.
71
+ def key_for(target, dir)
72
+ relative = File.expand_path(target, dir).sub("#{GEM_ROOT}/", "")
73
+ return nil if relative.start_with?("/")
74
+
75
+ relative += "/" if target.end_with?("/")
76
+ return relative unless relative.start_with?("docs/") && relative.end_with?(".md")
77
+
78
+ relative.delete_prefix("docs/").delete_suffix(".md")
79
+ end
80
+
81
+ # Repository directories a key may name verbatim. A key that starts with one of these already IS a
82
+ # path — `docs/adr`, `plugins/rigor-sorbet` — because {.key_for} only rewrites `.md` files under
83
+ # `docs/`. Anything else is a documentation key and gets its prefix and extension back.
84
+ #
85
+ # Keying on the ROOT rather than on "has an extension" is what makes a directory work: prose links
86
+ # a tree as often without a trailing slash (`../adr`) as with one, and no string test tells an
87
+ # extensionless file from a directory.
88
+ REPOSITORY_ROOTS = %w[docs examples plugins spec skills data tool bench apps].freeze
89
+
90
+ # The repository path a key names, reversing {.key_for}.
91
+ def repository_path(key)
92
+ return nil if key.nil? || key.empty? || key.include?("..")
93
+ return key if REPOSITORY_ROOTS.include?(key.split("/").first)
94
+ return key if File.extname(key) != ""
95
+
96
+ "docs/#{key}.md"
97
+ end
98
+ end
99
+ end
100
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "command"
4
+ require_relative "doc_links"
4
5
 
5
6
  module Rigor
6
7
  class CLI
@@ -118,7 +119,7 @@ module Rigor
118
119
  # a UTF-8 header would set the output buffer to UTF-8 and clash with the body.
119
120
  @out.puts("<!-- rigor docs #{doc.fetch(:name)} (rigortype #{Rigor::VERSION}, offline) -->")
120
121
  @out.puts
121
- @out.write(File.read(doc.fetch(:path)))
122
+ @out.write(DocLinks.rewrite(File.read(doc.fetch(:path)), from: doc.fetch(:path)))
122
123
  0
123
124
  end
124
125
 
@@ -138,6 +139,10 @@ module Rigor
138
139
  # @return [Hash, Integer] the doc entry, or an error exit status
139
140
  # after the error has been written to `@err`.
140
141
  def resolve_doc(query)
142
+ # A key printed by a rendered page may carry the section it pointed at; resolve the page and say
143
+ # where to look inside it, rather than refusing a key this command handed out.
144
+ query, anchor = DocLinks.split_anchor(query)
145
+ @err.puts("rigor: (section `#{anchor}`)") if anchor && !anchor.empty?
141
146
  docs = discover_docs
142
147
 
143
148
  exact = docs.find { |doc| doc.fetch(:exact_aliases).include?(query) }
@@ -146,11 +151,36 @@ module Rigor
146
151
  short = docs.select { |doc| doc.fetch(:short_name) == query }
147
152
  case short.size
148
153
  when 1 then short.first
149
- when 0 then name_error(query)
154
+ when 0 then unpackaged_doc(query) || name_error(query)
150
155
  else ambiguous_error(query, short)
151
156
  end
152
157
  end
153
158
 
159
+ # A key the rendered pages hand out for a document the gem does not carry (ADR-74 ships the manual and
160
+ # handbook; the ADR / specification / notes corpus and the repository trees stay out). The key is
161
+ # still answerable — it names a real path — so this routes rather than failing: a reader who followed
162
+ # `[ADR-103][adr/103-effect-labels]` out of `rigor docs` gets told where that document is, which is the
163
+ # whole point of handing them the key instead of a dead relative path or a `master` URL.
164
+ # Categories this command serves itself. A name that fails to resolve inside one of them is a typo,
165
+ # not an unpackaged document, and must reach the did-you-mean listing rather than be routed to a
166
+ # repository path that does not exist — `rigor docs list` and `rigor docs pathh` are the cases
167
+ # that found this.
168
+ PACKAGED_CATEGORIES = %w[manual handbook].freeze
169
+ private_constant :PACKAGED_CATEGORIES
170
+
171
+ def unpackaged_doc(query)
172
+ return nil unless query.include?("/")
173
+ return nil if PACKAGED_CATEGORIES.include?(query.split("/").first)
174
+
175
+ path = DocLinks.repository_path(query)
176
+ return nil if path.nil?
177
+
178
+ @err.puts("rigor: `#{query}` is not packaged with this gem — the manual and handbook are, the " \
179
+ "design records are not.")
180
+ @err.puts("rigor: it is `#{path}` in the Rigor repository (#{DocLinks::REPOSITORY}).")
181
+ 1
182
+ end
183
+
154
184
  # Every bundled doc, each carrying the aliases `rigor docs <name>` accepts and the category used by `--list`.
155
185
  # `install.md` sits at the docs root (category `guide`); the rest are manual / handbook chapters.
156
186
  def discover_docs