rigortype 0.1.15 → 0.1.17

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 (220) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/exe/rigor +19 -0
  4. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +100 -0
  5. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +209 -0
  6. data/lib/rigor/analysis/check_rules.rb +174 -71
  7. data/lib/rigor/analysis/dependency_recorder.rb +122 -0
  8. data/lib/rigor/analysis/diagnostic.rb +58 -0
  9. data/lib/rigor/analysis/incremental.rb +162 -0
  10. data/lib/rigor/analysis/incremental_session.rb +337 -0
  11. data/lib/rigor/analysis/rule_catalog.rb +48 -0
  12. data/lib/rigor/analysis/runner.rb +485 -29
  13. data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
  14. data/lib/rigor/analysis/worker_session.rb +3 -2
  15. data/lib/rigor/builtins/static_return_refinements.rb +7 -1
  16. data/lib/rigor/cache/descriptor.rb +56 -51
  17. data/lib/rigor/cache/incremental_snapshot.rb +147 -0
  18. data/lib/rigor/cache/rbs_cache_producer.rb +30 -0
  19. data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
  20. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
  21. data/lib/rigor/cache/rbs_constant_table.rb +2 -8
  22. data/lib/rigor/cache/rbs_environment.rb +2 -8
  23. data/lib/rigor/cache/rbs_instance_definitions.rb +3 -16
  24. data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
  25. data/lib/rigor/cache/store.rb +99 -1
  26. data/lib/rigor/cli/annotate_command.rb +2 -7
  27. data/lib/rigor/cli/baseline_command.rb +2 -7
  28. data/lib/rigor/cli/command.rb +47 -0
  29. data/lib/rigor/cli/coverage_command.rb +3 -23
  30. data/lib/rigor/cli/coverage_renderer.rb +3 -8
  31. data/lib/rigor/cli/diff_command.rb +3 -7
  32. data/lib/rigor/cli/explain_command.rb +2 -7
  33. data/lib/rigor/cli/lsp_command.rb +3 -7
  34. data/lib/rigor/cli/mcp_command.rb +3 -7
  35. data/lib/rigor/cli/options.rb +57 -0
  36. data/lib/rigor/cli/plugin_command.rb +3 -7
  37. data/lib/rigor/cli/plugins_command.rb +52 -10
  38. data/lib/rigor/cli/plugins_renderer.rb +86 -1
  39. data/lib/rigor/cli/renderable.rb +26 -0
  40. data/lib/rigor/cli/sig_gen_command.rb +2 -7
  41. data/lib/rigor/cli/skill_command.rb +3 -7
  42. data/lib/rigor/cli/triage_command.rb +2 -7
  43. data/lib/rigor/cli/type_of_command.rb +5 -38
  44. data/lib/rigor/cli/type_of_renderer.rb +4 -9
  45. data/lib/rigor/cli/type_scan_command.rb +3 -23
  46. data/lib/rigor/cli/type_scan_renderer.rb +4 -9
  47. data/lib/rigor/cli.rb +260 -48
  48. data/lib/rigor/configuration/dependencies.rb +18 -1
  49. data/lib/rigor/configuration/severity_profile.rb +22 -3
  50. data/lib/rigor/configuration.rb +13 -3
  51. data/lib/rigor/environment/rbs_loader.rb +335 -4
  52. data/lib/rigor/environment.rb +8 -2
  53. data/lib/rigor/inference/block_parameter_binder.rb +1 -2
  54. data/lib/rigor/inference/budget_trace.rb +137 -0
  55. data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
  56. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
  57. data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
  58. data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
  59. data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
  60. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
  61. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
  62. data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
  63. data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
  64. data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
  65. data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
  66. data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
  67. data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
  68. data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
  69. data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
  70. data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
  71. data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
  72. data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
  73. data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
  74. data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
  75. data/lib/rigor/inference/expression_typer.rb +149 -22
  76. data/lib/rigor/inference/hkt_reducer.rb +2 -0
  77. data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
  78. data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
  79. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
  80. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
  81. data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
  82. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
  83. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
  84. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
  85. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
  86. data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
  87. data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
  88. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
  89. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +100 -23
  90. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
  91. data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
  92. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
  93. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
  94. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
  95. data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
  96. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
  97. data/lib/rigor/inference/method_dispatcher.rb +147 -60
  98. data/lib/rigor/inference/narrowing.rb +202 -5
  99. data/lib/rigor/inference/precision_scanner.rb +60 -1
  100. data/lib/rigor/inference/scope_indexer.rb +257 -11
  101. data/lib/rigor/inference/statement_evaluator.rb +110 -26
  102. data/lib/rigor/inference/synthetic_method_index.rb +23 -4
  103. data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
  104. data/lib/rigor/language_server/buffer_resolution.rb +33 -0
  105. data/lib/rigor/language_server/completion_provider.rb +4 -4
  106. data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
  107. data/lib/rigor/language_server/folding_range_provider.rb +4 -4
  108. data/lib/rigor/language_server/hover_provider.rb +4 -4
  109. data/lib/rigor/language_server/selection_range_provider.rb +4 -4
  110. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  111. data/lib/rigor/plugin/additional_initializer.rb +108 -0
  112. data/lib/rigor/plugin/base.rb +337 -2
  113. data/lib/rigor/plugin/box.rb +64 -0
  114. data/lib/rigor/plugin/inflector.rb +121 -0
  115. data/lib/rigor/plugin/isolation.rb +191 -0
  116. data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
  117. data/lib/rigor/plugin/macro.rb +1 -0
  118. data/lib/rigor/plugin/manifest.rb +120 -23
  119. data/lib/rigor/plugin/node_context.rb +62 -0
  120. data/lib/rigor/plugin/registry.rb +49 -1
  121. data/lib/rigor/plugin.rb +3 -0
  122. data/lib/rigor/rbs_extended/conformance_checker.rb +208 -0
  123. data/lib/rigor/rbs_extended.rb +39 -0
  124. data/lib/rigor/scope.rb +123 -9
  125. data/lib/rigor/sig_gen/generator.rb +2 -3
  126. data/lib/rigor/sig_gen/observation_collector.rb +2 -2
  127. data/lib/rigor/source/literals.rb +118 -0
  128. data/lib/rigor/source/node_walker.rb +26 -0
  129. data/lib/rigor/source.rb +1 -0
  130. data/lib/rigor/type/acceptance_router.rb +19 -0
  131. data/lib/rigor/type/accepts_result.rb +3 -10
  132. data/lib/rigor/type/app.rb +3 -7
  133. data/lib/rigor/type/bot.rb +2 -3
  134. data/lib/rigor/type/bound_method.rb +5 -12
  135. data/lib/rigor/type/combinator.rb +23 -1
  136. data/lib/rigor/type/constant.rb +2 -3
  137. data/lib/rigor/type/data_class.rb +80 -0
  138. data/lib/rigor/type/data_instance.rb +100 -0
  139. data/lib/rigor/type/difference.rb +5 -10
  140. data/lib/rigor/type/dynamic.rb +5 -10
  141. data/lib/rigor/type/hash_shape.rb +5 -15
  142. data/lib/rigor/type/integer_range.rb +5 -10
  143. data/lib/rigor/type/intersection.rb +5 -10
  144. data/lib/rigor/type/nominal.rb +5 -10
  145. data/lib/rigor/type/refined.rb +5 -10
  146. data/lib/rigor/type/singleton.rb +5 -10
  147. data/lib/rigor/type/top.rb +2 -3
  148. data/lib/rigor/type/tuple.rb +5 -10
  149. data/lib/rigor/type/union.rb +69 -10
  150. data/lib/rigor/type.rb +2 -0
  151. data/lib/rigor/value_semantics.rb +77 -0
  152. data/lib/rigor/version.rb +1 -1
  153. data/lib/rigor.rb +2 -0
  154. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
  155. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
  156. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
  157. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
  158. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
  159. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
  160. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
  161. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
  162. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
  163. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
  164. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  165. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
  166. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
  167. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
  168. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
  169. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
  170. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
  171. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  172. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
  173. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
  174. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
  175. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
  176. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
  177. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
  178. data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
  179. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
  180. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
  181. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
  182. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
  183. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
  184. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
  185. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
  186. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
  187. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
  188. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +48 -33
  189. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  190. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
  191. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
  192. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
  193. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
  194. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
  195. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
  196. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
  197. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
  198. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
  199. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
  200. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
  201. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
  202. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
  203. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
  204. data/sig/rigor/cache.rbs +19 -0
  205. data/sig/rigor/inference.rbs +22 -0
  206. data/sig/rigor/plugin/access_denied_error.rbs +3 -1
  207. data/sig/rigor/plugin/base.rbs +58 -3
  208. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  209. data/sig/rigor/plugin/manifest.rbs +31 -1
  210. data/sig/rigor/rbs_extended.rbs +2 -0
  211. data/sig/rigor/scope.rbs +5 -0
  212. data/sig/rigor/source.rbs +12 -0
  213. data/sig/rigor/type.rbs +58 -1
  214. data/sig/rigor.rbs +11 -1
  215. data/skills/rigor-plugin-author/SKILL.md +13 -9
  216. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +6 -5
  217. data/skills/rigor-plugin-author/references/02-walker-and-types.md +159 -75
  218. data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
  219. metadata +73 -2
  220. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +0 -114
data/lib/rigor/cli.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "configuration"
9
9
  require_relative "version"
10
10
  require_relative "analysis/diagnostic"
11
11
  require_relative "analysis/result"
12
+ require_relative "cli/options"
12
13
 
13
14
  module Rigor
14
15
  # The CLI class is a dispatcher: each `run_*` method delegates to a
@@ -77,20 +78,19 @@ module Rigor
77
78
  EXIT_USAGE
78
79
  end
79
80
 
80
- def run_check
81
- require_relative "analysis/runner"
82
- require_relative "analysis/buffer_binding"
83
- require_relative "analysis/baseline"
84
- require_relative "cache/store"
85
-
81
+ def run_check # rubocop:disable Metrics/AbcSize
82
+ load_check_dependencies
86
83
  options = parse_check_options
87
- buffer = resolve_buffer_binding(options)
84
+ buffer = Options.resolve_buffer_binding(options, err: @err)
88
85
  return EXIT_USAGE if buffer == :usage_error
89
86
 
90
87
  configuration = load_check_configuration(options)
91
88
  cache_root = configuration.cache_path
92
89
  handle_clear_cache(cache_root) if options.fetch(:clear_cache)
93
90
 
91
+ special = dispatch_special_check_mode(configuration, options, cache_root)
92
+ return special unless special.nil?
93
+
94
94
  runner = build_check_runner(
95
95
  configuration: configuration, options: options,
96
96
  buffer: buffer, cache_root: cache_root
@@ -100,6 +100,8 @@ module Rigor
100
100
 
101
101
  write_result(result, options.fetch(:format))
102
102
  write_run_stats(result.stats) if result.stats
103
+ write_trace_appendices
104
+ runner.cache_store&.evict!
103
105
  write_cache_stats(cache_root, runner.cache_store) if options.fetch(:cache_stats)
104
106
 
105
107
  exit_code = result.success? ? 0 : 1
@@ -107,6 +109,95 @@ module Rigor
107
109
  exit_code
108
110
  end
109
111
 
112
+ # ADR-46 — the two incremental-analysis check modes both fully handle
113
+ # the run and return an exit code (so `run_check` short-circuits);
114
+ # returns nil for an ordinary check.
115
+ def dispatch_special_check_mode(configuration, options, cache_root)
116
+ return run_verify_incremental(configuration) if options.fetch(:verify_incremental)
117
+ return run_incremental_check(configuration, options, cache_root) if options.fetch(:incremental)
118
+
119
+ nil
120
+ end
121
+
122
+ # ADR-46 — the incremental-analysis acceptance gate. Runs a baseline
123
+ # analysis (recording cross-file dependencies), then re-analyzes a
124
+ # representative subset of files and serves the rest from the per-file
125
+ # cache (the body tier), and asserts the merged diagnostics are
126
+ # byte-identical to a full `--no-cache` analysis. A mismatch means the
127
+ # incremental machinery would serve a stale — manufactured —
128
+ # diagnostic, the soundness failure this gate exists to catch. Prints a
129
+ # one-line PASS (exit 0) or the differing diagnostics (exit 1).
130
+ def run_verify_incremental(configuration)
131
+ paths = @argv.empty? ? nil : @argv
132
+ session = Analysis::IncrementalSession.new(configuration: configuration, paths: paths)
133
+ session.baseline
134
+ analyzed = session.analyzed_files
135
+
136
+ # Every other file forms the re-analyzed subset, so the run exercises
137
+ # BOTH the subset-analysis path and the cache-serving path.
138
+ subset = analyzed.each_with_index.select { |_, index| index.even? }.map(&:first)
139
+ incremental = normalize_diagnostics(session.reanalyze_subset(subset))
140
+ full = normalize_diagnostics(verify_full_diagnostics(configuration, paths))
141
+
142
+ report_verify_incremental(incremental, full, subset_size: subset.size, total: analyzed.size)
143
+ end
144
+
145
+ # ADR-46 — cross-process incremental analysis (`--incremental`). Derives
146
+ # the global fingerprint cheaply (no RBS env build), loads the disk
147
+ # snapshot, and on a fingerprint hit re-analyzes only the files changed
148
+ # since the last run (plus their dependents), serving the rest from the
149
+ # snapshot; on a miss runs a full baseline. Persists the updated
150
+ # snapshot for the next invocation. Diagnostics are identical to a full
151
+ # run (the `--verify-incremental` gate enforces this); the win is
152
+ # skipping per-file inference for unchanged files.
153
+ def run_incremental_check(configuration, options, cache_root)
154
+ paths = @argv.empty? ? nil : @argv
155
+ probe = Analysis::Runner.new(configuration: configuration, cache_store: nil)
156
+ files = paths ? probe.analysis_file_set(paths) : probe.analysis_file_set
157
+ fingerprint = Cache::IncrementalSnapshot.fingerprint(
158
+ configuration: configuration, roots: paths || configuration.paths
159
+ )
160
+ snapshot = Cache::IncrementalSnapshot.new(root: cache_root)
161
+ session = Analysis::IncrementalSession.new(configuration: configuration, paths: paths)
162
+
163
+ diagnostics, warm = session.run_incremental(snapshot: snapshot, fingerprint: fingerprint)
164
+ @err.puts("rigor: --incremental #{warm ? 'warm — reused cached diagnostics' : 'cold — full analysis'} " \
165
+ "(#{files.size} files)")
166
+
167
+ result = apply_baseline_filter(Analysis::Result.new(diagnostics: diagnostics, stats: nil), configuration, options)
168
+ write_result(result, options.fetch(:format))
169
+ result.success? ? 0 : 1
170
+ end
171
+
172
+ def verify_full_diagnostics(configuration, paths)
173
+ runner = Analysis::Runner.new(configuration: configuration, cache_store: nil)
174
+ (paths ? runner.run(paths) : runner.run).diagnostics
175
+ end
176
+
177
+ def normalize_diagnostics(diagnostics)
178
+ diagnostics.map(&:to_h).sort_by do |hash|
179
+ [hash["path"].to_s, hash["line"].to_i, hash["column"].to_i, hash["rule"].to_s, hash["message"].to_s]
180
+ end
181
+ end
182
+
183
+ def report_verify_incremental(incremental, full, subset_size:, total:)
184
+ if incremental == full
185
+ @out.puts("rigor: --verify-incremental OK — incremental " \
186
+ "(#{subset_size}/#{total} files re-analyzed, rest from cache) " \
187
+ "matches full (#{full.size} diagnostics)")
188
+ return 0
189
+ end
190
+
191
+ only_incremental = incremental - full
192
+ only_full = full - incremental
193
+ @err.puts("rigor: --verify-incremental FAILED — incremental and full diagnostics differ.")
194
+ @err.puts(" incremental-only: #{only_incremental.size}, full-only: #{only_full.size}")
195
+ (only_incremental + only_full).first(10).each do |hash|
196
+ @err.puts(" #{hash['path']}:#{hash['line']}:#{hash['column']}: [#{hash['rule']}] #{hash['message']}")
197
+ end
198
+ 1
199
+ end
200
+
110
201
  # ADR-22 slice 5 — the `--baseline-strict` CI gate. When the
111
202
  # flag is set, ANY baseline drift fails the run — not only
112
203
  # excess drift (a bucket over threshold, which already fails
@@ -193,7 +284,14 @@ module Rigor
193
284
  end
194
285
 
195
286
  def build_check_runner(configuration:, options:, buffer:, cache_root:)
196
- cache_store = options.fetch(:no_cache) ? nil : Cache::Store.new(root: cache_root)
287
+ cache_store = if options.fetch(:no_cache)
288
+ nil
289
+ else
290
+ Cache::Store.new(
291
+ root: cache_root,
292
+ max_bytes: configuration.cache_max_bytes
293
+ )
294
+ end
197
295
  Analysis::Runner.new(
198
296
  configuration: configuration,
199
297
  explain: options.fetch(:explain),
@@ -204,37 +302,6 @@ module Rigor
204
302
  )
205
303
  end
206
304
 
207
- # Editor-mode CLI envelope. The `--tmp-file=PATH` /
208
- # `--instead-of=PATH` pair binds an in-flight buffer file to
209
- # the logical project path it represents (see
210
- # `docs/design/20260516-editor-mode.md`). Both flags must
211
- # appear together; either alone is a usage error. The
212
- # physical file must be readable; missing-file is a usage
213
- # error too so editors get one consistent failure shape.
214
- #
215
- # Returns:
216
- # - `nil` when neither flag was supplied (legacy path).
217
- # - `Rigor::Analysis::BufferBinding` when the pair is valid.
218
- # - `:usage_error` after writing one diagnostic to stderr;
219
- # the caller MUST translate this to `EXIT_USAGE`.
220
- def resolve_buffer_binding(options)
221
- tmp = options[:tmp_file]
222
- instead = options[:instead_of]
223
- return nil if tmp.nil? && instead.nil?
224
-
225
- if tmp.nil? || instead.nil?
226
- @err.puts("--tmp-file and --instead-of must appear together")
227
- return :usage_error
228
- end
229
-
230
- unless File.file?(tmp)
231
- @err.puts("--tmp-file #{tmp.inspect}: no such file or not readable")
232
- return :usage_error
233
- end
234
-
235
- Analysis::BufferBinding.new(logical_path: instead, physical_path: tmp)
236
- end
237
-
238
305
  # ADR-15 Phase 4c — resolves the worker count by
239
306
  # precedence: CLI `--workers=N` (most explicit) > env
240
307
  # `RIGOR_RACTOR_WORKERS` > config `.rigor.yml`
@@ -296,7 +363,18 @@ module Rigor
296
363
  # `.rigor.yml`. Intended for single-file / ad-hoc CI use;
297
364
  # ordinary projects should configure the plugin in
298
365
  # `.rigor.yml`.
299
- treat_all_as_inline_rbs: false
366
+ treat_all_as_inline_rbs: false,
367
+ # ADR-46 — the incremental-analysis acceptance gate. Runs a
368
+ # baseline analysis, re-analyzes a subset and serves the rest from
369
+ # the per-file cache, and asserts the merged diagnostics are
370
+ # byte-identical to a full `--no-cache` run. Exits non-zero on any
371
+ # mismatch. Off by default.
372
+ verify_incremental: false,
373
+ # ADR-46 — cross-process incremental analysis. With a disk snapshot
374
+ # of the prior run's per-file diagnostics + dependency graph,
375
+ # re-analyzes only the changed closure and serves the rest from the
376
+ # snapshot. Off by default.
377
+ incremental: false
300
378
  }
301
379
  parser = OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
302
380
  opts.banner = "Usage: rigor check [options] [paths]"
@@ -314,14 +392,7 @@ module Rigor
314
392
  "Dispatch per-file analysis across N Ractor workers (default: 0; sequential)") do |value|
315
393
  options[:workers] = value
316
394
  end
317
- opts.on("--tmp-file=PATH",
318
- "Editor mode: read source bytes from PATH instead of --instead-of (paired)") do |value|
319
- options[:tmp_file] = value
320
- end
321
- opts.on("--instead-of=PATH",
322
- "Editor mode: the logical project path the buffer represents (paired with --tmp-file)") do |value|
323
- options[:instead_of] = value
324
- end
395
+ Options.add_editor_mode(opts, options)
325
396
  opts.on("--baseline=PATH",
326
397
  "ADR-22: load baseline from PATH (overrides .rigor.yml `baseline:`)") do |value|
327
398
  options[:baseline] = value
@@ -338,6 +409,14 @@ module Rigor
338
409
  "ADR-32: force-load rigor-rbs-inline with require_magic_comment: false") do
339
410
  options[:treat_all_as_inline_rbs] = true
340
411
  end
412
+ opts.on("--verify-incremental",
413
+ "ADR-46: assert incremental analysis matches a full run, then exit") do
414
+ options[:verify_incremental] = true
415
+ end
416
+ opts.on("--incremental",
417
+ "ADR-46: re-analyze only files changed since the last run (cross-process cache)") do
418
+ options[:incremental] = true
419
+ end
341
420
  end
342
421
  parser.parse!(@argv)
343
422
  options
@@ -402,6 +481,139 @@ module Rigor
402
481
  stats.format(@err)
403
482
  end
404
483
 
484
+ # Opt-in developer diagnostics printed after the run: the
485
+ # inference-cutoff trace (RIGOR_BUDGET_TRACE) and the heap-attribution
486
+ # profile (RIGOR_HEAP_PROFILE). Each gates itself, so this is a no-op
487
+ # on a normal run.
488
+ def write_trace_appendices
489
+ write_budget_trace
490
+ write_heap_profile
491
+ end
492
+
493
+ # Dumps the opt-in inference-cutoff counters (RIGOR_BUDGET_TRACE).
494
+ # These are the hard-coded "budget" guards that silently degrade
495
+ # to `Dynamic[top]` / a fallback bound — counting them shows where
496
+ # inference actually stopped. Process-global counters: meaningful
497
+ # only on a single-process run (`--workers 0`), since they do not
498
+ # cross fork boundaries.
499
+ def write_budget_trace
500
+ return unless Inference::BudgetTrace.enabled?
501
+
502
+ counts = Inference::BudgetTrace.snapshot
503
+ @err.puts("")
504
+ @err.puts("Inference cutoffs (RIGOR_BUDGET_TRACE; --workers 0 for an exact count)")
505
+ @err.puts(" recursion-guard hits: #{counts[Inference::BudgetTrace::RECURSION_GUARD]}")
506
+ @err.puts(" ancestor-walk-limit hits: #{counts[Inference::BudgetTrace::ANCESTOR_WALK_LIMIT]}")
507
+ @err.puts(" hkt-fuel-exhausted hits: #{counts[Inference::BudgetTrace::HKT_FUEL_EXHAUSTED]}")
508
+ write_budget_distributions
509
+ end
510
+
511
+ # Dumps the read-only size distributions (ADR-41 Slice 2a). These
512
+ # observe how large unions actually get, with no cap enforced — the
513
+ # data the `union_size` budget default should be chosen from. The
514
+ # `over` thresholds bracket the TypeProf prior (10) and Rigor's spec
515
+ # default (24).
516
+ def write_budget_distributions
517
+ summary = Inference::BudgetTrace.summarize(Inference::BudgetTrace::UNION_ARITY, over: [10, 24, 40])
518
+ pct = summary[:percentiles]
519
+ @err.puts(" union arity: n=#{summary[:count]} max=#{summary[:max]} " \
520
+ "p50=#{pct[:p50]} p90=#{pct[:p90]} p99=#{pct[:p99]}")
521
+ over = summary[:over]
522
+ @err.puts(" unions ≥10: #{over[10]} ≥24: #{over[24]} ≥40: #{over[40]}")
523
+ end
524
+
525
+ # Dumps a live-heap class breakdown (RIGOR_HEAP_PROFILE) — retained
526
+ # objects by class after a forced GC, ranked by total memsize. The
527
+ # tool for attributing where the analyzer's resident memory goes
528
+ # (ADR-41 Slice 2b): it answers whether the heap is type carriers,
529
+ # RBS objects, Prism nodes, or fact-store Hashes/Strings. Walking the
530
+ # whole heap is slow — a dev probe, not a normal diagnostic. Run
531
+ # single-process (`--workers 0`) so the parent heap is the analysis
532
+ # heap; the gem is required lazily so a normal run never loads it.
533
+ def write_heap_profile
534
+ return if ENV["RIGOR_HEAP_PROFILE"].to_s.empty?
535
+
536
+ by_class, total = tally_live_heap
537
+ @err.puts("")
538
+ @err.puts("Heap profile (RIGOR_HEAP_PROFILE; live objects after GC, by class)")
539
+ @err.puts(" total tracked: #{heap_mb(total)} across #{by_class.size} classes")
540
+ by_class.sort_by { |_, (_, bytes)| -bytes }.first(30).each do |name, (count, bytes)|
541
+ @err.puts(" #{heap_mb(bytes).rjust(10)} #{count.to_s.rjust(9)} obj #{name}")
542
+ end
543
+ write_string_allocation_sites
544
+ end
545
+
546
+ # Loads the analysis-path dependencies lazily (so non-check commands
547
+ # stay light) and starts heap-allocation tracing if requested, before
548
+ # any analysis object is allocated.
549
+ def load_check_dependencies
550
+ require_relative "analysis/runner"
551
+ require_relative "analysis/buffer_binding"
552
+ require_relative "analysis/baseline"
553
+ require_relative "cache/store"
554
+ start_heap_trace_if_requested
555
+ end
556
+
557
+ # Starts allocation tracing (RIGOR_HEAP_TRACE) as early as possible so
558
+ # the heap profile can attribute retained Strings to their allocation
559
+ # `file:line`. Very high overhead — run on a small file subset only.
560
+ def start_heap_trace_if_requested
561
+ return if ENV["RIGOR_HEAP_TRACE"].to_s.empty?
562
+
563
+ require "objspace"
564
+ ObjectSpace.trace_object_allocations_start
565
+ end
566
+
567
+ # When RIGOR_HEAP_TRACE is on, groups the live String objects by their
568
+ # allocation site (`sourcefile:sourceline`) and prints the top sites by
569
+ # count — pinpointing which engine code retains the millions of strings
570
+ # that dominate the large-app heap (ADR-41 Slice 2b). Strings allocated
571
+ # before tracing started report `(pre-trace)`.
572
+ def write_string_allocation_sites
573
+ return if ENV["RIGOR_HEAP_TRACE"].to_s.empty?
574
+
575
+ by_site = Hash.new(0)
576
+ ObjectSpace.each_object(String) do |str|
577
+ file = ObjectSpace.allocation_sourcefile(str)
578
+ line = ObjectSpace.allocation_sourceline(str)
579
+ by_site[file ? "#{file}:#{line}" : "(pre-trace)"] += 1
580
+ end
581
+ @err.puts("")
582
+ @err.puts(" String allocation sites (top 25 by live count)")
583
+ by_site.sort_by { |_, n| -n }.first(25).each do |site, n|
584
+ @err.puts(" #{n.to_s.rjust(9)} #{site}")
585
+ end
586
+ end
587
+
588
+ # Walks the whole live heap (after a forced GC) and tallies
589
+ # `{class_name => [count, memsize]}` plus the grand total. Returns
590
+ # `[by_class, total]`. Slow — a dev probe only.
591
+ def tally_live_heap
592
+ require "objspace"
593
+ GC.start
594
+ by_class = Hash.new { |h, k| h[k] = [0, 0] }
595
+ total = 0
596
+ ObjectSpace.each_object do |obj|
597
+ size = ObjectSpace.memsize_of(obj)
598
+ entry = by_class[heap_class_name(obj)]
599
+ entry[0] += 1
600
+ entry[1] += size
601
+ total += size
602
+ end
603
+ [by_class, total]
604
+ end
605
+
606
+ def heap_class_name(obj)
607
+ klass = Object.instance_method(:class).bind_call(obj)
608
+ klass.name || klass.inspect
609
+ rescue StandardError
610
+ "(unknown)"
611
+ end
612
+
613
+ def heap_mb(bytes)
614
+ Kernel.format("%.1f MB", bytes / 1_048_576.0)
615
+ end
616
+
405
617
  def write_cache_stats(cache_root, runtime_store)
406
618
  inv = Cache::Store.disk_inventory(root: cache_root)
407
619
 
@@ -77,7 +77,7 @@ module Rigor
77
77
  return new([]) if data.nil?
78
78
  raise ArgumentError, "dependencies: must be a Hash, got #{data.inspect}" unless data.is_a?(Hash)
79
79
 
80
- raw_entries = Array(data["source_inference"]).map { |raw| coerce_entry(raw) }
80
+ raw_entries = coerce_source_inference(data["source_inference"])
81
81
  entries, warnings = dedupe_entries(raw_entries)
82
82
  budget = coerce_budget_per_gem(data.fetch("budget_per_gem", DEFAULT_BUDGET_PER_GEM))
83
83
  strategy = coerce_budget_overrun_strategy(
@@ -158,6 +158,23 @@ module Rigor
158
158
 
159
159
  private
160
160
 
161
+ # `source_inference:` is a list of per-gem entries, or `false` /
162
+ # omitted to disable it (the default). Guard the Ruby
163
+ # `Array(false) == [false]` quirk that would otherwise feed `false`
164
+ # straight into coerce_entry and crash the whole run on a perfectly
165
+ # reasonable "off" config (`dependencies: { source_inference: false }`).
166
+ def coerce_source_inference(value)
167
+ return [] if value.nil? || value == false
168
+
169
+ unless value.is_a?(Array)
170
+ raise ArgumentError,
171
+ "dependencies.source_inference: must be a list of entries " \
172
+ "(or false / omitted to disable), got #{value.inspect}"
173
+ end
174
+
175
+ value.map { |raw| coerce_entry(raw) }
176
+ end
177
+
161
178
  def coerce_entry(raw)
162
179
  unless raw.is_a?(Hash)
163
180
  raise ArgumentError,
@@ -39,6 +39,7 @@ module Rigor
39
39
  PROFILES = {
40
40
  lenient: {
41
41
  "call.undefined-method" => :error,
42
+ "call.self-undefined-method" => :off,
42
43
  "call.unresolved-toplevel" => :off,
43
44
  "call.wrong-arity" => :error,
44
45
  "call.argument-type-mismatch" => :warning,
@@ -47,6 +48,7 @@ module Rigor
47
48
  "flow.unreachable-branch" => :info,
48
49
  "flow.dead-assignment" => :info,
49
50
  "flow.always-truthy-condition" => :info,
51
+ "flow.unreachable-clause" => :info,
50
52
  "assert.type-mismatch" => :error,
51
53
  "dump.type" => :info,
52
54
  "def.return-type-mismatch" => :warning,
@@ -54,10 +56,15 @@ module Rigor
54
56
  "def.override-visibility-reduced" => :off,
55
57
  "def.override-return-widened" => :off,
56
58
  "def.override-param-narrowed" => :off,
57
- "def.ivar-write-mismatch" => :warning
59
+ "def.ivar-write-mismatch" => :warning,
60
+ # Opt-in author assertion: you only see it if you wrote a
61
+ # `conforms-to` directive, so it stays a :warning even in
62
+ # lenient — it is never unsolicited noise.
63
+ "rbs_extended.unsatisfied-conformance" => :warning
58
64
  }.freeze,
59
65
  balanced: {
60
66
  "call.undefined-method" => :error,
67
+ "call.self-undefined-method" => :off,
61
68
  "call.unresolved-toplevel" => :warning,
62
69
  "call.wrong-arity" => :error,
63
70
  "call.argument-type-mismatch" => :error,
@@ -66,6 +73,11 @@ module Rigor
66
73
  "flow.unreachable-branch" => :warning,
67
74
  "flow.dead-assignment" => :warning,
68
75
  "flow.always-truthy-condition" => :warning,
76
+ # ADR-47 WD4: stays :info (not :warning like its siblings) in the
77
+ # default balanced profile until the regression-corpus FP gate is
78
+ # green; promote to :warning once Mastodon/GitLab/Redmine triage
79
+ # to zero net false positives.
80
+ "flow.unreachable-clause" => :info,
69
81
  "assert.type-mismatch" => :error,
70
82
  "dump.type" => :info,
71
83
  "def.return-type-mismatch" => :warning,
@@ -73,10 +85,12 @@ module Rigor
73
85
  "def.override-visibility-reduced" => :warning,
74
86
  "def.override-return-widened" => :warning,
75
87
  "def.override-param-narrowed" => :warning,
76
- "def.ivar-write-mismatch" => :warning
88
+ "def.ivar-write-mismatch" => :warning,
89
+ "rbs_extended.unsatisfied-conformance" => :warning
77
90
  }.freeze,
78
91
  strict: {
79
92
  "call.undefined-method" => :error,
93
+ "call.self-undefined-method" => :off,
80
94
  "call.unresolved-toplevel" => :error,
81
95
  "call.wrong-arity" => :error,
82
96
  "call.argument-type-mismatch" => :error,
@@ -85,6 +99,10 @@ module Rigor
85
99
  "flow.unreachable-branch" => :error,
86
100
  "flow.dead-assignment" => :error,
87
101
  "flow.always-truthy-condition" => :error,
102
+ # ADR-47: strict opts into the new rule at :warning (one notch
103
+ # below its :error siblings) while it proves out — see the
104
+ # balanced-profile note above.
105
+ "flow.unreachable-clause" => :warning,
88
106
  "assert.type-mismatch" => :error,
89
107
  "dump.type" => :error,
90
108
  "def.return-type-mismatch" => :error,
@@ -92,7 +110,8 @@ module Rigor
92
110
  "def.override-visibility-reduced" => :error,
93
111
  "def.override-return-widened" => :error,
94
112
  "def.override-param-narrowed" => :error,
95
- "def.ivar-write-mismatch" => :error
113
+ "def.ivar-write-mismatch" => :error,
114
+ "rbs_extended.unsatisfied-conformance" => :error
96
115
  }.freeze
97
116
  }.freeze
98
117
 
@@ -69,7 +69,13 @@ module Rigor
69
69
  "baseline" => nil,
70
70
  "fold_platform_specific_paths" => false,
71
71
  "cache" => {
72
- "path" => ".rigor/cache"
72
+ "path" => ".rigor/cache",
73
+ # LRU eviction cap in bytes. nil (the default) disables eviction;
74
+ # the cache grows until the user runs `rigor check --clear-cache`.
75
+ # Set to a positive integer (e.g. 536870912 for 512 MB) to keep the
76
+ # cache bounded — the least-recently-used entries are removed at the
77
+ # end of each run when the total exceeds this limit.
78
+ "max_bytes" => nil
73
79
  },
74
80
  "plugins_io" => {
75
81
  "network" => "disabled",
@@ -166,7 +172,8 @@ module Rigor
166
172
  PATH_KEYS = %w[paths signature_paths pre_eval].freeze
167
173
  private_constant :PATH_KEYS
168
174
 
169
- attr_reader :target_ruby, :paths, :exclude_patterns, :plugins, :cache_path, :disabled_rules,
175
+ attr_reader :target_ruby, :paths, :exclude_patterns, :plugins, :cache_path, :cache_max_bytes,
176
+ :disabled_rules,
170
177
  :libraries, :signature_paths, :fold_platform_specific_paths,
171
178
  :plugins_io_network, :plugins_io_allowed_paths,
172
179
  :plugins_io_allowed_url_hosts,
@@ -334,6 +341,8 @@ module Rigor
334
341
  "fold_platform_specific_paths", DEFAULTS.fetch("fold_platform_specific_paths")
335
342
  ) == true
336
343
  @cache_path = cache.fetch("path").to_s
344
+ raw_max = cache.fetch("max_bytes")
345
+ @cache_max_bytes = raw_max.nil? ? nil : Integer(raw_max)
337
346
  @plugins_io_network = coerce_network_policy(plugins_io.fetch("network"))
338
347
  @plugins_io_allowed_paths = Array(plugins_io.fetch("allowed_paths")).map(&:to_s).freeze
339
348
  @plugins_io_allowed_url_hosts = Array(plugins_io.fetch("allowed_url_hosts")).map(&:to_s).freeze
@@ -383,7 +392,8 @@ module Rigor
383
392
  "pre_eval" => pre_eval,
384
393
  "fold_platform_specific_paths" => fold_platform_specific_paths,
385
394
  "cache" => {
386
- "path" => cache_path
395
+ "path" => cache_path,
396
+ "max_bytes" => cache_max_bytes
387
397
  },
388
398
  "plugins_io" => {
389
399
  "network" => plugins_io_network.to_s,