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
@@ -10,6 +10,8 @@ require_relative "../scope"
10
10
  require_relative "../source/node_locator"
11
11
  require_relative "../inference/fallback_tracer"
12
12
  require_relative "../inference/scope_indexer"
13
+ require_relative "../inference/precision_scanner"
14
+ require_relative "../source/node_children"
13
15
  require_relative "type_of_renderer"
14
16
  require_relative "command"
15
17
  require_relative "options"
@@ -26,9 +28,23 @@ module Rigor
26
28
  # type-of UX (extra flags, watch mode, streaming output) without bloating the CLI shell. Output formatting is
27
29
  # delegated to {TypeOfRenderer}.
28
30
  class TypeOfCommand < Command
29
- USAGE = "Usage: rigor type-of [options] FILE:LINE:COL"
31
+ USAGE = "Usage: rigor type-of [options] FILE:LINE[:COL] [FILE:LINE[:COL] ...]"
30
32
 
31
- Result = Data.define(:file, :line, :column, :node, :type, :tracer)
33
+ Result = Data.define(:file, :line, :column, :node, :type, :tracer, :enumeration) do
34
+ def initialize(enumeration: nil, **rest)
35
+ super
36
+ end
37
+ end
38
+
39
+ LineEnumeration = Data.define(:total, :shown)
40
+
41
+ # One requested position. A nil `column` means "the first 40 expressions starting on this line" — the form that
42
+ # exists because hand-computing a column is the single most error-prone part of using this command.
43
+ Target = Data.define(:file, :line, :column)
44
+
45
+ # Cap on the expressions one bare `FILE:LINE` prints, so a long line cannot bury the answer.
46
+ LINE_ENUMERATION_CAP = 40
47
+ private_constant :LINE_ENUMERATION_CAP
32
48
 
33
49
  # @return [Integer] CLI exit status.
34
50
  def run
@@ -36,10 +52,10 @@ module Rigor
36
52
  buffer = Options.resolve_buffer_binding(options, err: @err)
37
53
  return CLI::EXIT_USAGE if buffer == :usage_error
38
54
 
39
- target = parse_position_argument(@argv)
40
- return CLI::EXIT_USAGE if target.nil?
55
+ targets = parse_position_arguments(@argv)
56
+ return CLI::EXIT_USAGE if targets.nil?
41
57
 
42
- execute(target: target, options: options, buffer: buffer)
58
+ execute(targets: targets, options: options, buffer: buffer)
43
59
  end
44
60
 
45
61
  private
@@ -59,38 +75,123 @@ module Rigor
59
75
  options
60
76
  end
61
77
 
62
- def execute(target:, options:, buffer: nil)
63
- file, line, column = target
64
- # Under editor mode the logical `file` may not exist on disk (user editing a new file); the runtime check is
65
- # only that the BUFFER is readable, which `resolve_buffer_binding` has already enforced. For non-editor mode
66
- # `file` must exist.
78
+ # One process, N positions. Every invocation used to rebuild the plugin-aware environment and reparse the
79
+ # file for a single answer, so investigating a chain of expressions cost one process each — the dominant
80
+ # cost of using this command in anger. The environment is now built once for the whole request and each
81
+ # file is parsed and scope-indexed once, however many positions land in it.
82
+ def execute(targets:, options:, buffer: nil)
83
+ configuration = Configuration.load(options.fetch(:config))
84
+ environment = project_environment(targets.map(&:file).uniq, configuration)
85
+ base_scope = Scope.empty(environment: environment)
86
+
87
+ results_by_target = Array.new(targets.length)
88
+ targets.each_with_index.group_by { |target, _index| target.file }.each do |file, indexed_targets|
89
+ file_results = resolve_file(file, indexed_targets, configuration, base_scope, options, buffer)
90
+ return file_results if file_results.is_a?(Integer)
91
+
92
+ file_results.each { |index, resolved| results_by_target[index] = resolved }
93
+ end
94
+ results = results_by_target.compact.flatten(1)
95
+ return 1 if results.empty?
96
+
97
+ TypeOfRenderer.new(out: @out).render(results, format: options.fetch(:format))
98
+ 0
99
+ end
100
+
101
+ # Every result for one file: parsed and indexed once, then each requested position resolved against that
102
+ # index. Returns an Integer exit status instead when the file itself cannot be probed.
103
+ def resolve_file(file, indexed_targets, configuration, base_scope, options, buffer)
104
+ # Under editor mode the logical `file` may not exist on disk (user editing a new file); the runtime check
105
+ # is only that the BUFFER is readable, which `resolve_buffer_binding` has already enforced.
67
106
  physical = buffer ? buffer.resolve(file) : file
68
107
  return 1 unless file_exists?(buffer ? physical : file)
69
108
 
70
- configuration = Configuration.load(options.fetch(:config))
71
109
  source = File.read(physical)
72
110
  parse_result = Prism.parse(source, filepath: file, version: configuration.target_ruby)
73
111
  return 1 if parse_errors?(parse_result, file)
74
112
 
75
- node = locate_node(source: source, root: parse_result.value, file: file, line: line, column: column)
76
- return CLI::EXIT_USAGE if node == :out_of_range
77
- return 1 if node.nil?
113
+ # Built with no tracer attached it would otherwise double-record fallback events with the per-node
114
+ # `type_of` calls below.
115
+ scope_index = Inference::ScopeIndexer.index(parse_result.value, default_scope: base_scope)
116
+ locator = Source::NodeLocator.new(source: source, root: parse_result.value)
117
+ lines = indexed_targets.filter_map { |target, _index| target.line if target.column.nil? }
118
+ line_nodes, line_totals = collect_line_nodes(parse_result.value, lines)
78
119
 
79
- tracer = options[:trace] ? Inference::FallbackTracer.new : nil
80
- base_scope = Scope.empty(environment: project_environment(file, configuration))
120
+ resolve_targets(file: file, indexed_targets: indexed_targets, locator: locator, line_nodes: line_nodes,
121
+ line_totals: line_totals, scope_index: scope_index, options: options)
122
+ end
81
123
 
82
- # Build a per-node scope index so locals bound earlier in the file flow into the scope used to type the queried
83
- # node. We build the index with no tracer attached (it would otherwise double-record fallback events with the
84
- # second-pass type_of call below), then look up the scope visible at the queried node and run the actual probe
85
- # under it.
86
- scope_index = Inference::ScopeIndexer.index(parse_result.value, default_scope: base_scope)
87
- node_scope = scope_index[node]
124
+ def resolve_targets(file:, indexed_targets:, locator:, line_nodes:, line_totals:, scope_index:, options:)
125
+ indexed_targets.map do |target, index|
126
+ if target.column.nil?
127
+ resolved = enumerate_line(file, target.line, line_nodes.fetch(target.line),
128
+ line_totals.fetch(target.line), scope_index, options)
129
+ next [index, resolved]
130
+ end
88
131
 
89
- type = node_scope.type_of(node, tracer: tracer)
90
- result = Result.new(file: file, line: line, column: column, node: node, type: type, tracer: tracer)
132
+ node = locate_node(locator: locator, file: file, line: target.line, column: target.column)
133
+ return CLI::EXIT_USAGE if node == :out_of_range
134
+ next [index, []] if node.nil?
91
135
 
92
- TypeOfRenderer.new(out: @out).render(result, format: options.fetch(:format))
93
- 0
136
+ [index, [type_result(file, target.line, target.column, node, scope_index, options)]]
137
+ end
138
+ end
139
+
140
+ # Collect every requested line in one full syntax walk. Unlike `Source::NodeWalker`, this deliberately
141
+ # descends into `defined?`: the probe inventories source expressions, including operands Ruby inspects but
142
+ # does not evaluate. Candidate storage stays bounded while retaining the first 40 nodes in display order.
143
+ def collect_line_nodes(root, lines)
144
+ return [{}, {}] if lines.empty?
145
+
146
+ candidates = lines.uniq.to_h { |line| [line, []] }
147
+ totals = candidates.to_h { |line, _nodes| [line, 0] }
148
+ walk_syntax(root) do |node|
149
+ line = node.location.start_line
150
+ nodes = candidates[line]
151
+ next if nodes.nil?
152
+ next if Inference::PrecisionScanner::NON_EXPRESSION_NODE_TYPES.include?(node.class.name)
153
+
154
+ totals[line] += 1
155
+ nodes << node
156
+ trim_line_nodes(nodes) if nodes.length > LINE_ENUMERATION_CAP * 2
157
+ end
158
+ candidates.each_value { |nodes| trim_line_nodes(nodes) }
159
+ [candidates, totals]
160
+ end
161
+
162
+ def walk_syntax(node, &block)
163
+ return unless node.is_a?(Prism::Node)
164
+
165
+ block.call(node)
166
+ node.rigor_each_child { |child| walk_syntax(child, &block) }
167
+ end
168
+
169
+ def trim_line_nodes(nodes)
170
+ nodes.sort_by! { |node| [node.location.start_column, -node.location.length] }
171
+ nodes.slice!(LINE_ENUMERATION_CAP, nodes.length)
172
+ end
173
+
174
+ # Up to 40 expressions STARTING on `line`, outermost first at each 1-based column. This form removes
175
+ # the column arithmetic: `rigor type-of file.rb:42` answers "what are the types on line 42" without the user
176
+ # having to guess which byte the receiver starts at.
177
+ def enumerate_line(file, line, nodes, total, scope_index, options)
178
+ if nodes.empty?
179
+ @err.puts("type-of: no expression found on #{file}:#{line}")
180
+ return []
181
+ end
182
+
183
+ enumeration = LineEnumeration.new(total: total, shown: nodes.length)
184
+ nodes.map do |node|
185
+ type_result(file, line, node.location.start_column + 1, node, scope_index, options,
186
+ enumeration: enumeration)
187
+ end
188
+ end
189
+
190
+ def type_result(file, line, column, node, scope_index, options, enumeration: nil)
191
+ tracer = options[:trace] ? Inference::FallbackTracer.new : nil
192
+ type = scope_index[node].type_of(node, tracer: tracer)
193
+ Result.new(file: file, line: line, column: column, node: node, type: type, tracer: tracer,
194
+ enumeration: enumeration)
94
195
  end
95
196
 
96
197
  # Builds the plugin-aware environment relative to the probed file, so the reported type matches what `rigor
@@ -99,8 +200,8 @@ module Rigor
99
200
  # closes). The probed file is threaded as the synthesizer's `source_files:`. Project-RBS auto-detection
100
201
  # roots at CWD today; future work will walk parent directories to find the enclosing `Gemfile`/`*.gemspec`
101
202
  # so probes against files outside the current process's CWD still see the right `sig/` tree.
102
- def project_environment(file, configuration)
103
- ProbeEnvironment.build(configuration: configuration, source_files: [file])
203
+ def project_environment(files, configuration)
204
+ ProbeEnvironment.build(configuration: configuration, source_files: files)
104
205
  end
105
206
 
106
207
  def file_exists?(file)
@@ -117,8 +218,8 @@ module Rigor
117
218
  true
118
219
  end
119
220
 
120
- def locate_node(source:, root:, file:, line:, column:)
121
- node = Source::NodeLocator.at_position(source: source, root: root, line: line, column: column)
221
+ def locate_node(locator:, file:, line:, column:)
222
+ node = locator.at_position(line: line, column: column)
122
223
  @err.puts("type-of: no expression found at #{file}:#{line}:#{column}") if node.nil?
123
224
  node
124
225
  rescue Source::NodeLocator::OutOfRangeError => e
@@ -126,19 +227,66 @@ module Rigor
126
227
  :out_of_range
127
228
  end
128
229
 
129
- def parse_position_argument(argv)
130
- case argv.size
131
- when 1
132
- parse_colon_form(argv[0])
133
- when 3
134
- decode_position(*argv)
135
- else
136
- @err.puts("type-of: expected FILE:LINE:COL or FILE LINE COL")
230
+ def parse_position_arguments(argv)
231
+ if argv.empty?
232
+ @err.puts("type-of: expected FILE:LINE[:COL] or FILE LINE COL")
137
233
  @err.puts(USAGE)
138
- nil
234
+ return nil
139
235
  end
236
+
237
+ colon = argv.map { |arg| try_colon_form(arg) }
238
+ return colon if colon.all?
239
+ return legacy_triple(argv) if argv.size == 3 && colon.none?
240
+ # A single unparseable argument keeps its old, specific diagnosis ("must be integers", "expected
241
+ # FILE:LINE:COL") rather than the generic multi-position complaint below.
242
+ return single_colon_form(argv[0]) if argv.size == 1
243
+
244
+ @err.puts("type-of: expected FILE:LINE[:COL] (repeatable) or FILE LINE COL, got #{argv.inspect}")
245
+ @err.puts(USAGE)
246
+ nil
247
+ end
248
+
249
+ # `FILE:LINE:COL` or `FILE:LINE`, or nil when the argument is not in either form. Silent: the caller
250
+ # decides whether a nil means "try the three-argument form" or "report a usage error", and a probe should
251
+ # not print a complaint about a shape it is about to accept under a different reading.
252
+ def try_colon_form(arg)
253
+ parts = arg.split(":")
254
+ return nil if parts.size < 2 || !integer_literal?(parts[-1])
255
+
256
+ # Three or more segments is the `FILE:LINE:COL` reading; if its LINE is not a number the argument is a
257
+ # typo, not a column-less position, and must reach the specific "must be integers" diagnosis.
258
+ if parts.size >= 3
259
+ return nil unless integer_literal?(parts[-2])
260
+
261
+ Target.new(file: parts[0..-3].join(":"), line: Integer(parts[-2], 10), column: Integer(parts[-1], 10))
262
+ else
263
+ Target.new(file: parts[0], line: Integer(parts[-1], 10), column: nil)
264
+ end
265
+ end
266
+
267
+ def integer_literal?(value)
268
+ value.match?(/\A\d+\z/)
269
+ end
270
+
271
+ # The pre-batch single-argument path, kept for its error messages. Returns nil after printing one.
272
+ def single_colon_form(arg)
273
+ position = parse_colon_form(arg)
274
+ return nil if position.nil?
275
+
276
+ file, line, column = position
277
+ [Target.new(file: file, line: line, column: column)]
278
+ end
279
+
280
+ def legacy_triple(argv)
281
+ position = decode_position(*argv)
282
+ return nil if position.nil?
283
+
284
+ file, line, column = position
285
+ [Target.new(file: file, line: line, column: column)]
140
286
  end
141
287
 
288
+ # The strict `FILE:LINE:COL` reading, used only to produce the specific error for a single unparseable
289
+ # argument. The permissive reading (which also accepts `FILE:LINE`) is {try_colon_form}.
142
290
  def parse_colon_form(arg)
143
291
  parts = arg.split(":")
144
292
  if parts.size < 3
@@ -149,8 +297,7 @@ module Rigor
149
297
 
150
298
  column = parts.pop
151
299
  line = parts.pop
152
- file = parts.join(":")
153
- decode_position(file, line, column)
300
+ decode_position(parts.join(":"), line, column)
154
301
  end
155
302
 
156
303
  def decode_position(file, line, column)
@@ -20,7 +20,55 @@ module Rigor
20
20
 
21
21
  private
22
22
 
23
- def render_text(result)
23
+ # Takes the whole result list: one invocation can now answer several positions, and a bare `FILE:LINE`
24
+ # answers up to 40 expressions on that line. A single exact result renders byte-identically to before.
25
+ def render_text(results)
26
+ list = Array(results)
27
+ index = 0
28
+ first = true
29
+ while index < list.length
30
+ @out.puts("") unless first
31
+ result = list[index]
32
+ enumeration = result.enumeration
33
+ if enumeration
34
+ finish = index + 1
35
+ finish += 1 while finish < list.length && list[finish].enumeration.equal?(enumeration)
36
+ render_enumeration(list[index...finish], enumeration)
37
+ index = finish
38
+ else
39
+ render_one_text(result)
40
+ index += 1
41
+ end
42
+ first = false
43
+ end
44
+ end
45
+
46
+ # A bare `FILE:LINE` asks "what are the types on this line", and the answer is a table: one row per
47
+ # expression, outermost first at each column, so a method chain reads top to bottom.
48
+ def render_enumeration(results, enumeration)
49
+ @out.puts("#{results.first.file}:#{results.first.line}")
50
+ render_enumeration_rows(results)
51
+ if enumeration.total > results.length
52
+ omitted = enumeration.total - results.length
53
+ @out.puts(" ... #{omitted} additional expressions omitted (limit #{results.length})")
54
+ end
55
+ # After the table, never interleaved with it: a `--trace` fallback list between two rows would break the
56
+ # column alignment the whole form exists for.
57
+ results.each { |result| render_text_fallbacks(result) }
58
+ end
59
+
60
+ def render_enumeration_rows(results)
61
+ width = results.map { |result| node_label(result).length }.max
62
+ results.each do |result|
63
+ @out.puts(format(" %4d %-#{width}s %s", result.column, node_label(result), result.type.describe))
64
+ end
65
+ end
66
+
67
+ def node_label(result)
68
+ result.node.class.name.to_s.delete_prefix("Prism::")
69
+ end
70
+
71
+ def render_one_text(result)
24
72
  @out.puts("#{result.file}:#{result.line}:#{result.column}")
25
73
  @out.puts("node: #{result.node.class}")
26
74
  @out.puts("type: #{result.type.describe}")
@@ -40,7 +88,28 @@ module Rigor
40
88
  end
41
89
  end
42
90
 
43
- def render_json(result)
91
+ # A single result keeps the flat object it has always emitted, so existing consumers are untouched; a
92
+ # multi-position request wraps them in `results` rather than printing several documents to one stream.
93
+ def render_json(results)
94
+ list = Array(results)
95
+ payload = list.size == 1 ? result_to_h(list.first) : { results: list.map { |r| result_to_h(r) } }
96
+ enumerations = line_enumerations(list)
97
+ payload[:line_enumerations] = enumerations unless enumerations.empty?
98
+ @out.puts(JSON.pretty_generate(payload))
99
+ end
100
+
101
+ def line_enumerations(results)
102
+ seen = {}.compare_by_identity
103
+ results.filter_map do |result|
104
+ enumeration = result.enumeration
105
+ next if enumeration.nil? || seen.key?(enumeration)
106
+
107
+ seen[enumeration] = true
108
+ { file: result.file, line: result.line, shown: enumeration.shown, total: enumeration.total }
109
+ end
110
+ end
111
+
112
+ def result_to_h(result)
44
113
  payload = {
45
114
  file: result.file,
46
115
  line: result.line,
@@ -50,7 +119,7 @@ module Rigor
50
119
  erased: result.type.erase_to_rbs
51
120
  }
52
121
  payload[:fallbacks] = result.tracer.map { |event| fallback_to_h(event) } if result.tracer
53
- @out.puts(JSON.pretty_generate(payload))
122
+ payload
54
123
  end
55
124
 
56
125
  def format_fallback_text(event, file)
@@ -12,6 +12,7 @@ require_relative "type_scan_renderer"
12
12
  require_relative "type_scan_report"
13
13
  require_relative "command"
14
14
  require_relative "probe_environment"
15
+ require_relative "coverage_scan"
15
16
 
16
17
  module Rigor
17
18
  class CLI
@@ -70,7 +71,15 @@ module Rigor
70
71
 
71
72
  def scan_paths(paths, options)
72
73
  configuration = Configuration.load(options.fetch(:config))
73
- scope = Scope.empty(environment: project_environment(configuration, paths))
74
+ # Same seed set as `rigor coverage` and `coverage --protection` (#502). Without it a cross-file class
75
+ # constant reads `Dynamic` and is counted as an unrecognised node, so the scan reports the engine as
76
+ # blinder than it is — on Mastodon that alone was most of a 14.7% unrecognised rate.
77
+ scope = CoverageScan.discovery_seeded_scope(
78
+ files: paths,
79
+ configuration: configuration,
80
+ environment: project_environment(configuration, paths),
81
+ parameter_inference: configuration.parameter_inference
82
+ )
74
83
  scanner = Inference::CoverageScanner.new(scope: scope)
75
84
  accumulator = ScanAccumulator.new
76
85
  paths.each { |path| scan_one(path, scanner, accumulator, configuration) }
@@ -6,10 +6,12 @@ require "json"
6
6
  require_relative "../configuration"
7
7
  require_relative "../analysis/path_expansion"
8
8
  require_relative "../analysis/reachability/scan"
9
+ require_relative "../analysis/reachability/scan_cache"
9
10
  require_relative "../analysis/reachability/graph"
10
11
  require_relative "../analysis/reachability/plugin_roots"
11
12
  require_relative "../analysis/reachability/signature_scan"
12
13
  require_relative "../analysis/reachability/project_files"
14
+ require_relative "../cache/store"
13
15
  require_relative "options"
14
16
  require_relative "command"
15
17
  require_relative "probe_environment"
@@ -22,7 +24,11 @@ module Rigor
22
24
  # measured precision of this signal is 7.0% on an adjudicated corpus target
23
25
  # (`docs/notes/20260813-unused-constant-fp-baseline.md`), so its output is a review queue, not a defect
24
26
  # list, and it never enters `rigor check`'s stream at any severity (WD1). Exits 0 whatever it finds.
25
- class UnusedCommand < Command
27
+ #
28
+ # Like {CheckCommand}, it aggregates one command's concerns — the two scans, the graph, and both
29
+ # render formats — that read clearer together than split across micro-classes, so it carries the
30
+ # same ClassLength exemption.
31
+ class UnusedCommand < Command # rubocop:disable Metrics/ClassLength
26
32
  USAGE = "Usage: rigor unused [options] [paths]"
27
33
 
28
34
  # WD7 — the REFERENCE corpus is wider than the ANALYSIS corpus. `.rake` files sit inside `paths:` and
@@ -43,23 +49,32 @@ module Rigor
43
49
 
44
50
  configuration = Configuration.load(options.fetch(:config))
45
51
  paths = @argv.empty? ? configuration.paths : @argv
46
- declarations, references, dynamic_uses = scan(paths, configuration)
52
+ scan_cache = Analysis::Reachability::ScanCache.open(configuration.cache_path,
53
+ target_ruby: configuration.target_ruby)
54
+ declarations, references, dynamic_uses = scan(paths, configuration, scan_cache)
47
55
  references.concat(signature_references(configuration))
48
- dynamic_uses.concat(template_mentions(declarations))
56
+ dynamic_uses.concat(template_mentions(declarations, scan_cache))
49
57
 
50
- contribution = Analysis::Reachability::PluginRoots.collect(configuration: configuration)
58
+ contribution = Analysis::Reachability::PluginRoots.collect(configuration: configuration,
59
+ cache_store: cache_store(configuration))
51
60
  references.concat(plugin_references(contribution.references))
52
- graph = Analysis::Reachability::Graph.new(
53
- declarations: declarations, references: references, dynamic_uses: dynamic_uses,
54
- root_fqns: root_fqns(declarations, options.fetch(:entry_points)) + contribution.roots,
55
- foreign: foreign_predicate(configuration)
56
- )
61
+ graph = build_graph(configuration, options, contribution,
62
+ declarations: declarations, references: references, dynamic_uses: dynamic_uses)
57
63
  emit(graph.report, options, supply: root_supply(contribution.roots, declarations))
64
+ scan_cache.save
58
65
  0
59
66
  end
60
67
 
61
68
  private
62
69
 
70
+ def build_graph(configuration, options, contribution, declarations:, references:, dynamic_uses:)
71
+ Analysis::Reachability::Graph.new(
72
+ declarations: declarations, references: references, dynamic_uses: dynamic_uses,
73
+ root_fqns: root_fqns(declarations, options.fetch(:entry_points)) + contribution.roots,
74
+ foreign: foreign_predicate(configuration)
75
+ )
76
+ end
77
+
63
78
  def parse_options
64
79
  options = { config: nil, format: "text", entry_points: [], limit: nil }
65
80
  parser = OptionParser.new do |opts|
@@ -91,13 +106,15 @@ module Rigor
91
106
  end
92
107
 
93
108
  # Declarations come from the analysed paths; references additionally from the wider corpus (WD7).
94
- def scan(paths, configuration)
109
+ # Every file is consulted every run — an unchanged one contributes its cached scan, so the
110
+ # whole-project completeness the `--incremental` refusal protects is unaffected.
111
+ def scan(paths, configuration, cache)
95
112
  declaration_files = Analysis::PathExpansion.ruby_files(paths, configuration.exclude_patterns).to_set
96
113
  declarations = []
97
114
  references = []
98
115
  dynamic_uses = []
99
116
  (declaration_files + reference_files(paths, configuration)).sort.each do |file|
100
- result = read_and_scan(file, configuration)
117
+ result = cache.serve(:scan, file) { read_and_scan(file, configuration) }
101
118
  next if result.nil?
102
119
 
103
120
  declarations.concat(result.declarations) if declaration_files.include?(file)
@@ -155,24 +172,38 @@ module Rigor
155
172
  end
156
173
  end
157
174
 
158
- def template_mentions(declarations)
175
+ def template_mentions(declarations, cache)
159
176
  names = declarations.map(&:fqn)
160
177
  return [] if names.empty?
161
178
 
162
179
  Analysis::Reachability::ProjectFiles.own(Dir.glob(TEMPLATE_GLOB, base: Dir.pwd), Dir.pwd).flat_map do |rel|
163
- text = File.read(File.expand_path(rel)).scrub
180
+ absolute = File.expand_path(rel)
181
+ haystack = cache.serve(:runs, absolute) { constant_bearing_text(File.read(absolute).scrub) }
164
182
  names.filter_map do |fqn|
165
- next unless text.include?(fqn)
183
+ next unless haystack.include?(fqn)
166
184
 
167
185
  Analysis::Reachability::Scan::DynamicUse.new(name: nil, prefix: fqn, site: nil,
168
186
  reason: "named as a string in #{rel}",
169
- path: rel, line: 1)
187
+ path: rel, line: 1, scope: :exact)
170
188
  end
171
189
  rescue SystemCallError, ArgumentError
172
190
  []
173
191
  end
174
192
  end
175
193
 
194
+ # A maximal run of constant-path characters carrying at least one capital — the only substrings a
195
+ # declaration FQN can occur inside, since an FQN starts with a capital and uses this charset alone.
196
+ # An occurrence cannot cross a non-charset character, so `include?` over the de-duplicated runs
197
+ # (joined by a character outside the charset) answers exactly what `include?` over the whole text
198
+ # did, on a fraction of the bytes: a locale YAML or fixture JSON is almost entirely lowercase prose,
199
+ # and scanning 21 MB of it per declaration name was 81% of this command's wall time on mastodon.
200
+ CONSTANT_BEARING_RUN = /[A-Za-z0-9_:]*[A-Z][A-Za-z0-9_:]*/
201
+ private_constant :CONSTANT_BEARING_RUN
202
+
203
+ def constant_bearing_text(text)
204
+ text.scan(CONSTANT_BEARING_RUN).uniq.join("\n")
205
+ end
206
+
176
207
  def root_fqns(declarations, globs)
177
208
  return [] if globs.empty?
178
209
 
@@ -188,13 +219,31 @@ module Rigor
188
219
  # declaration, which produced three of redmine's artifacts from a single initializer. A name the bundled
189
220
  # (non-project) environment already knows is not ours to call unused. The project's own `sig/` is
190
221
  # deliberately excluded from this environment, so a project class that ships a signature stays owned.
222
+ #
223
+ # The cache store makes the environment a Marshal restore instead of a cold RBS build on every
224
+ # invocation — the same ADR-54 slot `rigor check` reads, keyed apart by this environment's own
225
+ # (sig-less) descriptor. The report only ever asks "is this name known", which the cached
226
+ # environment answers identically: the one thing the cache degrades is `RBS::Location`, and no
227
+ # location is read here.
191
228
  def foreign_predicate(configuration)
192
- env = Environment.for_project(libraries: configuration.libraries, signature_paths: [])
229
+ env = Environment.for_project(libraries: configuration.libraries, signature_paths: [],
230
+ cache_store: cache_store(configuration))
193
231
  ->(fqn) { !env.singleton_for_name(fqn).nil? }
194
232
  rescue StandardError
195
233
  ->(_fqn) { false }
196
234
  end
197
235
 
236
+ # One store per run, shared by the environment build and the plugin-roots collection. Nil when the
237
+ # store cannot be opened — both consumers already treat a nil store as "recompute", which was this
238
+ # command's only mode before it had a cache at all.
239
+ def cache_store(configuration)
240
+ return @cache_store if defined?(@cache_store)
241
+
242
+ @cache_store = Cache::Store.new(root: configuration.cache_path)
243
+ rescue StandardError
244
+ @cache_store = nil
245
+ end
246
+
198
247
  # ADR-102 § Consequences — "a root source that OVER-supplies silently hides real dead code, which is
199
248
  # worse than one that under-supplies, so each plugin's contribution needs its own corpus check". A
200
249
  # supplied root naming a constant the project does not declare is inert in the graph, but it is the
data/lib/rigor/cli.rb CHANGED
@@ -101,13 +101,26 @@ module Rigor
101
101
 
102
102
  def dispatch(command)
103
103
  handler = HANDLERS[command]
104
- return send(handler) if handler
104
+ return arm_jit_deadline { send(handler) } if handler
105
105
 
106
106
  @err.puts("Unknown command: #{command}")
107
107
  @err.puts(help)
108
108
  EXIT_USAGE
109
109
  end
110
110
 
111
+ # Deferred YJIT for EVERY dispatched command, not just `check` / `coverage` where PR #75 first
112
+ # calibrated it. The deadline makes the decision command-independent: a run that finishes inside
113
+ # the window never pays JIT compile, and a run that outlasts it JITs its dominant tail — measured
114
+ # on `rigor effects` cold over Mastodon, which ran its whole 21 s interpreted while the same
115
+ # analysis under `check` took 14.8 s (forcing YJIT on the effects run: 15.9 s; disabling it on
116
+ # check: 21.1 s). `lsp` / `mcp` still call `Runtime::Jit.enable_now` at boot, which makes the
117
+ # deadline thread armed here a no-op when it later fires.
118
+ def arm_jit_deadline
119
+ require_relative "runtime/jit"
120
+ Runtime::Jit.enable_after(Runtime::Jit.deadline_seconds)
121
+ yield
122
+ end
123
+
111
124
  def run_check
112
125
  require_relative "cli/check_command"
113
126
 
@@ -363,17 +376,17 @@ module Rigor
363
376
  check Analyze Ruby source files
364
377
  init Create a starter .rigor.yml
365
378
  annotate Print FILE with each line's last-expression type
366
- type-of Print the inferred type at FILE:LINE:COL
379
+ type-of Print inferred types at FILE:LINE[:COL] positions
367
380
  trace Replay how the engine typed FILE as a terminal animation
368
381
  type-scan Report Scope#type_of coverage across PATHs
369
382
  effects Report each method's effect labels, and the committed effect snapshot
370
- (ADR-103, opt-in; effects update/check/diff/explain)
383
+ (opt-in; effects update/check/diff/explain)
371
384
  explain Print the description of one or all CheckRules
372
385
  diff Compare current diagnostics to a saved baseline JSON
373
- sig-gen Emit RBS skeletons inferred from .rb sources (ADR-14)
386
+ sig-gen Emit RBS skeletons inferred from .rb sources
374
387
  lsp Run the Rigor Language Server (LSP) over stdio
375
- mcp Run the Rigor MCP server over stdio (ADR-33)
376
- triage Summarise diagnostics: distribution, hotspots, hints (ADR-23)
388
+ mcp Run the Rigor MCP server over stdio
389
+ triage Summarise diagnostics: distribution, hotspots, hints
377
390
  coverage Report type-precision coverage (precise vs Dynamic ratio)
378
391
  plugins Report activation status of every configured plugin
379
392
  plugin Browse bundled plugin source as worked examples (list/path/print/root)
@@ -381,9 +394,9 @@ module Rigor
381
394
  describe Recommend the next skill for this project (alias for `skill describe`)
382
395
  skill Recommend the next skill + list/print bundled Agent Skills (skill describe, skill <name>)
383
396
  docs Print the bundled docs offline (docs <name>, docs --list)
384
- show-bleedingedge Show the bleeding-edge overlay + what your config adopts (ADR-50)
385
- doctor Classify setup problems vs clean run with routed next actions (ADR-77)
386
- upgrade Migration command skeleton (ADR-50 WD7, queued)
397
+ show-bleedingedge Show the bleeding-edge overlay + what your config adopts
398
+ doctor Classify setup problems vs clean run with routed next actions
399
+ upgrade Migration command skeleton (queued)
387
400
  version Print the Rigor version
388
401
  help Print this help
389
402
  HELP