rigortype 0.4.1 → 0.4.3

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 (230) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/io.yml +1 -0
  4. data/data/builtins/ruby_core/proc.yml +4 -0
  5. data/data/builtins/ruby_core/random.yml +4 -0
  6. data/data/builtins/ruby_core/struct.yml +3 -0
  7. data/data/core_overlay/env.rbs +34 -0
  8. data/data/core_overlay/gc.rbs +50 -0
  9. data/data/core_overlay/hash.rbs +45 -0
  10. data/data/core_overlay/integer.rbs +31 -0
  11. data/data/core_overlay/io_buffer.rbs +27 -0
  12. data/data/core_overlay/kernel.rbs +40 -0
  13. data/data/core_overlay/match_data.rbs +28 -0
  14. data/data/core_overlay/module.rbs +51 -0
  15. data/data/core_overlay/object_space.rbs +29 -0
  16. data/data/core_overlay/proc.rbs +45 -0
  17. data/data/core_overlay/range.rbs +40 -0
  18. data/data/core_overlay/source_range.rbs +78 -0
  19. data/data/core_overlay/string.rbs +86 -0
  20. data/data/vendored_gem_sigs/prime/integer-extension.rbs +41 -0
  21. data/data/vendored_gem_sigs/prime/prime.rbs +372 -0
  22. data/docs/handbook/01-getting-started.md +3 -3
  23. data/docs/handbook/06-classes.md +3 -1
  24. data/docs/handbook/11-sig-gen.md +18 -5
  25. data/docs/handbook/appendix-liskov.md +5 -0
  26. data/docs/manual/02-cli-reference.md +19 -1
  27. data/docs/manual/03-configuration.md +7 -5
  28. data/docs/manual/04-diagnostics.md +2 -1
  29. data/docs/manual/05-inspecting-types.md +50 -0
  30. data/docs/manual/06-baseline.md +2 -0
  31. data/docs/manual/07-plugins.md +1 -1
  32. data/docs/manual/12-caching.md +18 -1
  33. data/docs/manual/14-rails-quickstart.md +2 -2
  34. data/docs/manual/18-removing-dead-code.md +41 -4
  35. data/docs/manual/plugins/README.md +6 -1
  36. data/docs/manual/plugins/rigor-activerecord.md +21 -0
  37. data/docs/manual/plugins/rigor-alba.md +60 -0
  38. data/docs/manual/plugins/rigor-minitest.md +22 -4
  39. data/docs/manual/plugins/rigor-typelizer.md +71 -0
  40. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +27 -3
  41. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +21 -92
  42. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +275 -0
  43. data/lib/rigor/analysis/check_rules/rule_ids.rb +11 -0
  44. data/lib/rigor/analysis/check_rules/source_arity.rb +21 -2
  45. data/lib/rigor/analysis/check_rules.rb +292 -59
  46. data/lib/rigor/analysis/dependency_recorder.rb +15 -0
  47. data/lib/rigor/analysis/dependency_source_inference/builder.rb +8 -1
  48. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +21 -0
  49. data/lib/rigor/analysis/dependency_source_inference/index.rb +36 -11
  50. data/lib/rigor/analysis/dependency_source_inference/walker.rb +196 -27
  51. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  52. data/lib/rigor/analysis/erb_template_detector.rb +2 -1
  53. data/lib/rigor/analysis/incremental.rb +29 -9
  54. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +30 -5
  55. data/lib/rigor/analysis/incremental_run_slot.rb +6 -4
  56. data/lib/rigor/analysis/incremental_session.rb +103 -23
  57. data/lib/rigor/analysis/path_expansion.rb +62 -6
  58. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  59. data/lib/rigor/analysis/reachability/graph.rb +241 -37
  60. data/lib/rigor/analysis/reachability/scan.rb +490 -77
  61. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  62. data/lib/rigor/analysis/rule_catalog.rb +43 -0
  63. data/lib/rigor/analysis/run_cache_key.rb +16 -1
  64. data/lib/rigor/analysis/run_cache_probe.rb +1 -1
  65. data/lib/rigor/analysis/run_stats.rb +28 -6
  66. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +113 -6
  67. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -19
  68. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -1
  69. data/lib/rigor/analysis/runner/run_snapshots.rb +6 -1
  70. data/lib/rigor/analysis/runner.rb +62 -13
  71. data/lib/rigor/analysis/worker_session.rb +8 -5
  72. data/lib/rigor/cache/engine_source.rb +21 -7
  73. data/lib/rigor/cache/incremental_snapshot.rb +55 -5
  74. data/lib/rigor/cli/annotate_command.rb +5 -0
  75. data/lib/rigor/cli/baseline_command.rb +7 -18
  76. data/lib/rigor/cli/check_command.rb +140 -40
  77. data/lib/rigor/cli/docs_command.rb +5 -4
  78. data/lib/rigor/cli/lens_command.rb +144 -0
  79. data/lib/rigor/cli/lsp_command.rb +5 -2
  80. data/lib/rigor/cli/skill_command.rb +3 -3
  81. data/lib/rigor/cli/skill_describe.rb +1 -1
  82. data/lib/rigor/cli/trace_command.rb +5 -0
  83. data/lib/rigor/cli/type_of_command.rb +6 -3
  84. data/lib/rigor/cli/unused_command.rb +166 -27
  85. data/lib/rigor/cli.rb +39 -5
  86. data/lib/rigor/configuration/severity_profile.rb +6 -0
  87. data/lib/rigor/configuration.rb +61 -2
  88. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  89. data/lib/rigor/environment/gated_signature_guard.rb +43 -0
  90. data/lib/rigor/environment/gated_signature_plan.rb +339 -0
  91. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  92. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  93. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  94. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  95. data/lib/rigor/environment/rbs_loader.rb +316 -14
  96. data/lib/rigor/environment/required_features.rb +208 -0
  97. data/lib/rigor/environment.rb +33 -8
  98. data/lib/rigor/inference/acceptance.rb +28 -3
  99. data/lib/rigor/inference/builtins/cruby_definers.rb +78 -0
  100. data/lib/rigor/inference/builtins/method_catalog.rb +3 -0
  101. data/lib/rigor/inference/captured_locals.rb +43 -3
  102. data/lib/rigor/inference/def_node_resolver.rb +23 -1
  103. data/lib/rigor/inference/expression_typer.rb +261 -29
  104. data/lib/rigor/inference/global_write_census.rb +7 -4
  105. data/lib/rigor/inference/guard_rebinding.rb +1 -1
  106. data/lib/rigor/inference/in_effect_refinements/proc_literals.rb +83 -0
  107. data/lib/rigor/inference/in_effect_refinements/refine_defs.rb +54 -0
  108. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +286 -0
  109. data/lib/rigor/inference/in_effect_refinements/walk.rb +276 -0
  110. data/lib/rigor/inference/in_effect_refinements.rb +256 -0
  111. data/lib/rigor/inference/key_presence_guard/scan.rb +281 -0
  112. data/lib/rigor/inference/key_presence_guard.rb +303 -0
  113. data/lib/rigor/inference/macro_block_self_type.rb +59 -12
  114. data/lib/rigor/inference/match_rebinding.rb +1 -1
  115. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  116. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  117. data/lib/rigor/inference/method_dispatcher/arithmetic_sequence_elements.rb +73 -0
  118. data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +68 -0
  119. data/lib/rigor/inference/method_dispatcher/integer_step_block_params.rb +81 -0
  120. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -0
  121. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  122. data/lib/rigor/inference/method_dispatcher/keyword_arguments.rb +267 -0
  123. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  124. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  125. data/lib/rigor/inference/method_dispatcher/method_folding.rb +2 -1
  126. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  127. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +122 -105
  128. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +247 -28
  129. data/lib/rigor/inference/method_dispatcher/splat_arity.rb +71 -0
  130. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  131. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  132. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  133. data/lib/rigor/inference/method_dispatcher.rb +96 -14
  134. data/lib/rigor/inference/module_function_state.rb +2 -1
  135. data/lib/rigor/inference/narrowing.rb +20 -6
  136. data/lib/rigor/inference/object_mixins.rb +180 -0
  137. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  138. data/lib/rigor/inference/project_patched_methods.rb +12 -7
  139. data/lib/rigor/inference/project_patched_scanner.rb +108 -26
  140. data/lib/rigor/inference/refine_census.rb +239 -0
  141. data/lib/rigor/inference/refine_self.rb +317 -0
  142. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  143. data/lib/rigor/inference/refined_dispatch/own_method.rb +475 -0
  144. data/lib/rigor/inference/refined_dispatch.rb +320 -0
  145. data/lib/rigor/inference/repeated_or_writes.rb +2 -0
  146. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +13 -0
  147. data/lib/rigor/inference/scope_indexer.rb +827 -65
  148. data/lib/rigor/inference/statement_evaluator.rb +294 -51
  149. data/lib/rigor/inference/string_mutation.rb +9 -3
  150. data/lib/rigor/inference/synthetic_method_scanner.rb +2 -1
  151. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  152. data/lib/rigor/inference/version_guard.rb +19 -10
  153. data/lib/rigor/language_server/project_context.rb +87 -5
  154. data/lib/rigor/language_server/server.rb +18 -0
  155. data/lib/rigor/lens/anchor.rb +115 -0
  156. data/lib/rigor/lens/contract_reader.rb +118 -0
  157. data/lib/rigor/lens/document.rb +113 -0
  158. data/lib/rigor/lens/file_reader.rb +433 -0
  159. data/lib/rigor/lens/members.rb +153 -0
  160. data/lib/rigor/lens/parameters.rb +91 -0
  161. data/lib/rigor/lens/plugins.rb +40 -0
  162. data/lib/rigor/lens/run.rb +280 -0
  163. data/lib/rigor/lens/slot.rb +79 -0
  164. data/lib/rigor/lens.rb +40 -0
  165. data/lib/rigor/plugin/inflector.rb +1 -1
  166. data/lib/rigor/plugin/io_boundary.rb +42 -3
  167. data/lib/rigor/plugin/macro/block_as_method.rb +51 -8
  168. data/lib/rigor/project_environment.rb +48 -6
  169. data/lib/rigor/protection/mutation_cache.rb +3 -2
  170. data/lib/rigor/protection/mutator.rb +1 -1
  171. data/lib/rigor/reflection/constant_ancestors.rb +127 -11
  172. data/lib/rigor/reflection/constant_path.rb +13 -2
  173. data/lib/rigor/reflection.rb +40 -8
  174. data/lib/rigor/scope/discovery_index.rb +40 -1
  175. data/lib/rigor/scope/resolution_chain.rb +159 -11
  176. data/lib/rigor/scope.rb +211 -21
  177. data/lib/rigor/sig_gen/classification.rb +4 -1
  178. data/lib/rigor/sig_gen/generator.rb +100 -10
  179. data/lib/rigor/sig_gen/method_candidate.rb +2 -2
  180. data/lib/rigor/sig_gen/skip_reason_catalog.rb +12 -0
  181. data/lib/rigor/source/alias_names.rb +52 -0
  182. data/lib/rigor/source/literals.rb +3 -2
  183. data/lib/rigor/source/symbol_name.rb +20 -0
  184. data/lib/rigor/type/combinator.rb +34 -2
  185. data/lib/rigor/type/float_range.rb +4 -1
  186. data/lib/rigor/type/nominal.rb +6 -1
  187. data/lib/rigor/version.rb +1 -1
  188. data/plugins/rigor-ac-library-rb/lib/rigor/plugin/ac_library_rb.rb +28 -0
  189. data/plugins/rigor-ac-library-rb/lib/rigor-ac-library-rb.rb +3 -0
  190. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/convolution.rbs +7 -0
  191. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/data_structures.rbs +160 -0
  192. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/graph.rbs +94 -0
  193. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/math.rbs +42 -0
  194. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/modint.rbs +71 -0
  195. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/object.rbs +37 -0
  196. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/segtree.rbs +83 -0
  197. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +2 -1
  198. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  199. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  200. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +100 -0
  201. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +94 -10
  202. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +43 -2
  203. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +3 -1
  204. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +149 -0
  205. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +74 -0
  206. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +104 -0
  207. data/plugins/rigor-alba/lib/rigor/plugin/alba.rb +85 -0
  208. data/plugins/rigor-alba/lib/rigor-alba.rb +3 -0
  209. data/plugins/rigor-alba/sig/alba.rbs +4 -0
  210. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +2 -2
  211. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +29 -12
  212. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +7 -6
  213. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +130 -21
  214. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +6 -4
  215. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +4 -4
  216. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +1 -1
  217. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1 -1
  218. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +31 -0
  219. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +102 -0
  220. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +87 -0
  221. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +76 -0
  222. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +73 -0
  223. data/plugins/rigor-typelizer/lib/rigor-typelizer.rb +3 -0
  224. data/sig/rigor/analysis/dependency_source_inference/index.rbs +1 -1
  225. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  226. data/sig/rigor/scope.rbs +30 -4
  227. data/sig/rigor/source.rbs +4 -0
  228. data/sig/rigor.rbs +9 -4
  229. data/skills/rigor-project-init/references/02-configure.md +2 -2
  230. metadata +83 -1
@@ -72,6 +72,7 @@ module Rigor
72
72
  )
73
73
  runner = invocation.runner
74
74
  raw_result = invocation.result
75
+ settle_run_stats(raw_result.stats)
75
76
  result = apply_baseline_filter(raw_result, configuration, options)
76
77
 
77
78
  coverage = compute_coverage(runner, configuration, options)
@@ -180,9 +181,9 @@ module Rigor
180
181
  # A plain `--incremental` run the session would answer as a warm recheck and write a slot for. Every other
181
182
  # shape declines, and the full path handles it exactly as before: an editor buffer (never saved), `--no-cache`
182
183
  # (no store to hold a slot), `--verify-incremental`, a worker pool (the session writes no slot for one: a
183
- # worker's plugin reads never reach the process that records them), and `--explain`, which the session
184
- # ignores today (#1533) — the slot is keyed without it, so the probe declines rather than answer for a flag
185
- # its writer never saw. The rest mirror the plain probe: `--coverage`, `--cache-stats` and the
184
+ # worker's plugin reads never reach the process that records them), and `--explain` (#1533: the session
185
+ # writes no slot for it — the slot key has no explain slot — so the probe declines rather than answer for a
186
+ # flag its writer never saw). The rest mirror the plain probe: `--coverage`, `--cache-stats` and the
186
187
  # `RIGOR_*_TRACE` probes each want the full path's own output.
187
188
  def incremental_run_hit_eligible?(configuration, options, buffer)
188
189
  options.fetch(:incremental) && buffer.nil? &&
@@ -195,6 +196,7 @@ module Rigor
195
196
  # The tail {#run_incremental_check} gives a warm run, from the slot's answer: the same banner, baseline
196
197
  # filter, output and exit code. No fact-surface note, since a warm run never has one.
197
198
  def finalize_incremental_hit(hit, configuration, options)
199
+ # The slot is written only for a run whose every row still validates, so nothing was re-analyzed.
198
200
  report_incremental_run(true, hit.file_count)
199
201
  write_incremental_result(hit.result.diagnostics, configuration, options)
200
202
  end
@@ -267,8 +269,8 @@ module Rigor
267
269
  # Every other file forms the re-analyzed subset, so the run exercises BOTH the subset-analysis path and the
268
270
  # cache-serving path.
269
271
  subset = analyzed.each_with_index.select { |_, index| index.even? }.map(&:first)
270
- incremental = normalize_diagnostics(session.reanalyze_subset(subset))
271
- full = normalize_diagnostics(verify_full_diagnostics(configuration, paths))
272
+ incremental = session.reanalyze_subset(subset).map(&:to_h)
273
+ full = verify_full_diagnostics(configuration, paths, collect_stats: options.fetch(:stats)).map(&:to_h)
272
274
 
273
275
  report_verify_incremental(incremental, full, subset_size: subset.size, total: analyzed.size,
274
276
  format: options.fetch(:format))
@@ -288,10 +290,11 @@ module Rigor
288
290
  nil
289
291
  else
290
292
  Cache::IncrementalSnapshot.fingerprint(
291
- configuration: configuration, roots: paths || configuration.paths
293
+ configuration: configuration, roots: paths || configuration.paths,
294
+ explain: options.fetch(:explain)
292
295
  )
293
296
  end
294
- snapshot = Cache::IncrementalSnapshot.new(root: cache_root)
297
+ snapshot = Cache::IncrementalSnapshot.new(root: cache_root, explain: options.fetch(:explain))
295
298
  store = incremental_cache_store(configuration, options, cache_root)
296
299
  session = Analysis::IncrementalSession.new(
297
300
  configuration: configuration, paths: paths,
@@ -300,7 +303,7 @@ module Rigor
300
303
  # (CLI `--workers` > `RIGOR_RACTOR_WORKERS` > `parallel.workers:` > 0) so the recheck's closure
301
304
  # re-analysis parallelises; the fork pool marshals dependency records back so the graph is sound.
302
305
  workers: CheckRunnerFactory.resolve_workers(options, configuration),
303
- buffer: buffer
306
+ buffer: buffer, explain: options.fetch(:explain)
304
307
  )
305
308
 
306
309
  return run_editor_mode_option_b(session, snapshot, fingerprint, configuration, options) if buffer
@@ -309,7 +312,8 @@ module Rigor
309
312
  # The banner's file count comes from the session's analyzed set (cold analyses all; a warm recheck's
310
313
  # `@analyzed` advances to the current file set), so a dedicated probe Runner + `Dir.glob` is no longer
311
314
  # built just to size the banner (recon §3 — the analysis tree was expanded three times per recheck).
312
- report_incremental_run(warm, session.analyzed_files.size)
315
+ # A warm banner reports the recheck's own sets (#1526), which only the session knows.
316
+ report_incremental_run(warm, session.analyzed_files.size, recheck: session.last_recheck)
313
317
  emit_incremental_fact_surface_notes(session)
314
318
  write_incremental_cache_stats(session, cache_root, store, options.fetch(:format)) if
315
319
  options.fetch(:cache_stats)
@@ -317,9 +321,47 @@ module Rigor
317
321
  write_incremental_result(diagnostics, configuration, options)
318
322
  end
319
323
 
320
- def report_incremental_run(warm, file_count)
321
- @err.puts("rigor: --incremental #{warm ? 'warm — reused cached diagnostics' : 'cold — full analysis'} " \
322
- "(#{file_count} files)")
324
+ # #1526 — a warm banner says how many of the analysed files the recheck re-analysed, and why: `changed` is the
325
+ # edited set, `added` / `removed` the structural delta, and the rest came from the snapshot. A run served from
326
+ # the run-result slot has no recheck, which means nothing changed: every file is reused.
327
+ def report_incremental_run(warm, file_count, recheck: nil)
328
+ body = if warm
329
+ "warm — #{warm_recheck_summary(file_count, recheck)}"
330
+ else
331
+ "cold — full analysis (#{pluralize(file_count, 'file')})"
332
+ end
333
+ @err.puts("rigor: --incremental #{body}")
334
+ end
335
+
336
+ # The warm clause. `file_count` is the analysed set M (the subset under a path argument); re-analysed files
337
+ # are M minus the reused set, which includes files invalidated by inferred-parameter changes (ADR-67), not only
338
+ # the edited closure. View templates are not in M: the recheck re-runs every one, so they are appended as their
339
+ # own count rather than folded into the file counts.
340
+ def warm_recheck_summary(file_count, recheck)
341
+ reused = recheck ? recheck.reused.size : file_count
342
+ # Current files are either reused or re-analysed (the recheck's `analyze_set`), so the count is taken from
343
+ # the reused set rather than `affected`, which can also name files that were removed.
344
+ analyzed = file_count - reused
345
+ head = "re-analysed #{analyzed} of #{pluralize(file_count, 'file')}"
346
+ head += " (#{change_counts(recheck).join(', ')})" if recheck && !change_counts(recheck).empty?
347
+ head += reuse_clause(analyzed, reused)
348
+ templates = recheck ? recheck.templates : 0
349
+ templates.positive? ? "#{head}, plus #{pluralize(templates, 'view template')}" : head
350
+ end
351
+
352
+ def change_counts(recheck)
353
+ { "changed" => recheck.changed.size, "added" => recheck.added.size, "removed" => recheck.removed.size }
354
+ .filter_map { |label, count| "#{count} #{label}" if count.positive? }
355
+ end
356
+
357
+ def reuse_clause(analyzed, reused)
358
+ return ", reused all" if analyzed.zero?
359
+
360
+ reused.zero? ? "" : ", reused the rest"
361
+ end
362
+
363
+ def pluralize(count, noun)
364
+ "#{count} #{count == 1 ? noun : "#{noun}s"}"
323
365
  end
324
366
 
325
367
  # The incremental tail, shared by the full path and the ADR-45 WD2 slot hit so a served run prints exactly
@@ -366,17 +408,30 @@ module Rigor
366
408
 
367
409
  # ADR-88 WD1 — a one-line stderr note when the plugin fact surface (an ADR-9 fact, an ADR-60 producer
368
410
  # value, or an `incremental_state_fingerprint` hook) forced a full run: either it CHANGED since the
369
- # snapshot (a Sorbet sig edit, a schema change) or a contributing plugin declares NO fingerprint surface
370
- # (opaque — a full run every invocation). Both are the sound direction; the note explains why a recheck
371
- # was not warm.
411
+ # snapshot (a Sorbet sig edit, a schema change) or a plugin is opaque to the fingerprint (a full run every
412
+ # invocation): it contributes types and declares NO fingerprint surface, or a value it declares cannot be
413
+ # digested. Each opaque plugin's reason is worded per {PluginFactFingerprint::OpaqueReason}, and an
414
+ # undigestible value is named. Both are the sound direction; the note explains why a recheck was not warm.
372
415
  def emit_incremental_fact_surface_notes(session)
373
- opaque = session.opaque_plugin_ids
374
- unless opaque.empty?
375
- @err.puts("rigor: --incremental plugin#{'s' unless opaque.size == 1} #{opaque.sort.join(', ')} " \
376
- "contribute call-site types with no incremental fingerprint surface; the snapshot cannot " \
377
- "be reused (full analysis every run).")
416
+ reasons = session.opaque_reasons
417
+ surfaceless = reasons.keys.select { |id| reasons.fetch(id).kind == :no_surface }.sort
418
+ unless surfaceless.empty?
419
+ many = surfaceless.size > 1
420
+ @err.puts("rigor: --incremental plugin#{'s' if many} #{surfaceless.join(', ')} " \
421
+ "contribute#{'s' unless many} call-site types with no incremental fingerprint surface; " \
422
+ "the snapshot cannot be reused (full analysis every run).")
423
+ end
424
+ reasons.each do |id, reason|
425
+ next if reason.kind == :no_surface
426
+
427
+ @err.puts("rigor: --incremental #{reason.describe(id)}; the snapshot cannot be reused " \
428
+ "(full analysis every run).")
429
+ end
430
+ if session.fact_store_unreadable?
431
+ @err.puts("rigor: --incremental the plugin fact store could not be read; the snapshot cannot be reused " \
432
+ "(full analysis every run).")
378
433
  end
379
- return unless session.fact_surface_invalidated? && opaque.empty?
434
+ return unless session.fact_surface_invalidated? && !session.opaque_fact_surface?
380
435
 
381
436
  @err.puts("rigor: --incremental plugin fact surface changed since the snapshot; ran a full analysis.")
382
437
  end
@@ -388,8 +443,10 @@ module Rigor
388
443
  def write_incremental_cache_stats(session, cache_root, store, format)
389
444
  write_cache_stats(cache_root, store, format)
390
445
  status =
391
- if !session.opaque_plugin_ids.empty?
392
- "opaque (#{session.opaque_plugin_ids.sort.join(', ')})"
446
+ if session.opaque_fact_surface?
447
+ verdicts = session.opaque_reasons.map { |id, reason| "#{id}: #{reason.summary}" }
448
+ verdicts << "fact store: could not be read" if session.fact_store_unreadable?
449
+ "opaque (#{verdicts.join('; ')})"
393
450
  elsif session.fact_surface_invalidated?
394
451
  "changed — snapshot invalidated"
395
452
  else
@@ -398,38 +455,72 @@ module Rigor
398
455
  side_output(format).puts(" plugin fact surface: #{status}")
399
456
  end
400
457
 
401
- def verify_full_diagnostics(configuration, paths)
402
- runner = Analysis::Runner.new(configuration: configuration, cache_store: nil)
458
+ def verify_full_diagnostics(configuration, paths, collect_stats: true)
459
+ runner = Analysis::Runner.new(configuration: configuration, cache_store: nil, collect_stats: collect_stats)
403
460
  (paths ? runner.run(paths) : runner.run).diagnostics
404
461
  end
405
462
 
406
- def normalize_diagnostics(diagnostics)
407
- diagnostics.map(&:to_h).sort_by do |hash|
408
- [hash["path"].to_s, hash["line"].to_i, hash["column"].to_i, hash["rule"].to_s, hash["message"].to_s]
409
- end
410
- end
411
-
463
+ # Issue #1542 — the rows are compared as a multiset over every field each row carries, so two rows that tie on
464
+ # the sort key but differ in severity, or a duplicated row, are a set mismatch and never a silent 0/0. Only when
465
+ # the multisets agree is the order compared, and an order mismatch is reported as its own failure, with the first
466
+ # index at which the two lists diverge, so a set regression and an order regression stay distinguishable.
467
+ #
412
468
  # The OK line follows the same rule as the other side outputs (#493) — stdout under `text`, stderr
413
469
  # under a machine format. The FAILED branch below has always gone to stderr: it is a failure
414
470
  # report, and a run that produced one has no valid document to protect anyway.
415
471
  def report_verify_incremental(incremental, full, subset_size:, total:, format: "text")
416
- if incremental == full
417
- side_output(format).puts("rigor: --verify-incremental OK — incremental " \
418
- "(#{subset_size}/#{total} files re-analyzed, rest from cache) " \
419
- "matches full (#{full.size} diagnostics)")
420
- return 0
472
+ return report_verify_set_mismatch(incremental, full) unless incremental.tally == full.tally
473
+ return report_verify_order_mismatch(incremental, full) unless incremental == full
474
+
475
+ side_output(format).puts("rigor: --verify-incremental OK — incremental " \
476
+ "(#{subset_size}/#{total} files re-analyzed, rest from cache) " \
477
+ "matches full (#{full.size} diagnostics)")
478
+ 0
479
+ end
480
+
481
+ # The rows one side has more of than the other, counted by multiplicity: [x, x, y] against [x, y, y] has one
482
+ # extra x on the left and one extra y on the right.
483
+ def multiset_difference(left, right)
484
+ budget = right.tally
485
+ left.reject do |row|
486
+ next false unless budget.fetch(row, 0).positive?
487
+
488
+ budget[row] -= 1
489
+ true
421
490
  end
491
+ end
422
492
 
423
- only_incremental = incremental - full
424
- only_full = full - incremental
493
+ def report_verify_set_mismatch(incremental, full)
494
+ only_incremental = multiset_difference(incremental, full)
495
+ only_full = multiset_difference(full, incremental)
425
496
  @err.puts("rigor: --verify-incremental FAILED — incremental and full diagnostics differ.")
426
497
  @err.puts(" incremental-only: #{only_incremental.size}, full-only: #{only_full.size}")
427
498
  (only_incremental + only_full).first(10).each do |hash|
428
- @err.puts(" #{hash['path']}:#{hash['line']}:#{hash['column']}: [#{hash['rule']}] #{hash['message']}")
499
+ @err.puts(" #{diagnostic_location(hash)}")
429
500
  end
430
501
  1
431
502
  end
432
503
 
504
+ def report_verify_order_mismatch(incremental, full)
505
+ index = (0...incremental.size).find { |i| incremental[i] != full[i] }
506
+ @err.puts("rigor: --verify-incremental FAILED — same set, different order.")
507
+ @err.puts(" incremental and full report the same #{full.size} diagnostics in a different order; " \
508
+ "first divergence at index #{index}:")
509
+ @err.puts(" incremental: #{diagnostic_location(incremental[index])}")
510
+ @err.puts(" full: #{diagnostic_location(full[index])}")
511
+ 1
512
+ end
513
+
514
+ # Severity, source family and the optional fields a row may carry, so two rows that differ only in one of them
515
+ # never print as the same line.
516
+ def diagnostic_location(hash)
517
+ extra = %w[receiver_type method_name project_definition_site].filter_map do |key|
518
+ " #{key}=#{hash[key]}" if hash.key?(key)
519
+ end
520
+ "#{hash['path']}:#{hash['line']}:#{hash['column']}: #{hash['severity']} [#{hash['rule']}] " \
521
+ "(#{hash['source_family']}) #{hash['message']}#{extra.join}"
522
+ end
523
+
433
524
  # ADR-22 slice 5 — the `--baseline-strict` CI gate. When the flag is set, ANY baseline drift fails the run — not
434
525
  # only excess drift (a bucket over threshold, which already fails via the surfaced diagnostics) but also DEFICIT
435
526
  # drift (`actual < count`: the baseline has grown looser than the code and should be regenerated). A no-op, with a
@@ -685,9 +776,11 @@ module Rigor
685
776
  # ADR-103 WD15 — captured before `DEFAULTS.merge` below folds "no `effects:` key" and "`effects:
686
777
  # false`" into the same value; see `Configuration.load`'s identical capture.
687
778
  effects_key_present = data.key?("effects")
779
+ target_ruby_key_present = data.key?("target_ruby")
688
780
  data = data.dup
689
781
  data["plugins"] = inject_treat_all_as_inline_rbs(Array(data["plugins"]))
690
- Configuration.new(Configuration::DEFAULTS.merge(data), effects_key_present)
782
+ Configuration.new(Configuration::DEFAULTS.merge(data), effects_key_present, target_ruby_key_present)
783
+ .with_exclude_base(path && File.exist?(path) ? File.dirname(path) : nil)
691
784
  end
692
785
 
693
786
  # ADR-50 § WD2 — applies the `--bleeding-edge[=ids]` / `--no-bleeding-edge` CLI selection over the configured
@@ -724,6 +817,13 @@ module Rigor
724
817
  end
725
818
  end
726
819
 
820
+ # Issue #1529 — reads the run's peak RSS straight after the analysis, before coverage or any output, so the
821
+ # figure the summary prints (and the JSON `stats` block carries) covers the analysis alone. A nil `stats`
822
+ # (`--no-stats`, or a run served from a cache) reads nothing.
823
+ def settle_run_stats(stats)
824
+ stats&.peak_rss_bytes
825
+ end
826
+
727
827
  # Emits the {Analysis::RunStats} summary to STDERR so it doesn't interleave with the diagnostic stream (text or
728
828
  # JSON) on STDOUT. JSON consumers can pipe stdout cleanly; interactive users still see the summary on their tty.
729
829
  def write_run_stats(stats)
@@ -78,7 +78,7 @@ module Rigor
78
78
 
79
79
  def run_index
80
80
  if File.file?(LLMS_INDEX)
81
- @out.write(File.read(LLMS_INDEX))
81
+ @out.write(File.read(LLMS_INDEX, encoding: Encoding::UTF_8))
82
82
  0
83
83
  else
84
84
  run_list(nil)
@@ -115,11 +115,12 @@ module Rigor
115
115
  doc = resolve_doc(name)
116
116
  return doc if doc.is_a?(Integer) # error status, already reported
117
117
 
118
- # ASCII-only provenance header: the doc body is read with the external encoding (US-ASCII under a C locale), so
119
- # a UTF-8 header would set the output buffer to UTF-8 and clash with the body.
118
+ # The bundled page is UTF-8 by contract, so it is read as UTF-8 explicitly: under a C or POSIX locale the
119
+ # process default external encoding is US-ASCII, and a non-ASCII body would otherwise raise in `gsub`.
120
+ # The provenance header stays ASCII-only so it never disagrees with the rest of the output.
120
121
  @out.puts("<!-- rigor docs #{doc.fetch(:name)} (rigortype #{Rigor::VERSION}, offline) -->")
121
122
  @out.puts
122
- @out.write(DocLinks.rewrite(File.read(doc.fetch(:path)), from: doc.fetch(:path)))
123
+ @out.write(DocLinks.rewrite(File.read(doc.fetch(:path), encoding: Encoding::UTF_8), from: doc.fetch(:path)))
123
124
  0
124
125
  end
125
126
 
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+
6
+ require_relative "command"
7
+ require_relative "options"
8
+ require_relative "check_runner_factory"
9
+ require_relative "../configuration"
10
+ require_relative "../lens"
11
+
12
+ module Rigor
13
+ class CLI
14
+ # `rigor lens FILE...` — a declaration map of the files with type provenance, computed as `rigor check`
15
+ # computes them (ADR-113; output contract docs/internal-spec/lens.md). Experimental until `lens/v1`.
16
+ class LensCommand < Command
17
+ USAGE = "Usage: rigor lens [--format=text|json] [--config=PATH] [--workers=N] FILE..."
18
+ FORMATS = %w[text json].freeze
19
+
20
+ def run
21
+ options = parse_options
22
+ return CLI::EXIT_USAGE if options.nil?
23
+
24
+ config = options.fetch(:config)
25
+ if config && !File.file?(config)
26
+ usage_error("no configuration file at #{config}")
27
+ return CLI::EXIT_USAGE
28
+ end
29
+
30
+ configuration = Configuration.load(config)
31
+ config_path = options.fetch(:config) || Configuration.discover
32
+ # The working directory: Rigor auto-detects sig/ and the lockfiles there (lens.md § Serialization).
33
+ root = Dir.pwd
34
+ workers = CheckRunnerFactory.resolve_workers(options, configuration)
35
+ outcome = Lens::Run.new(configuration: configuration, root: root, arguments: @argv, workers: workers).call
36
+ document = Lens::Document.new(outcome: outcome, configuration: configuration, root: root,
37
+ config_path: config_path).to_h
38
+ @err.puts("rigor lens: #{outcome.warm ? 'warm' : 'cold'} — #{@argv.size} target(s)")
39
+ render(document, options.fetch(:format))
40
+ outcome.targets.any? { |target| target.status == "ok" } ? 0 : 1
41
+ end
42
+
43
+ private
44
+
45
+ def parse_options
46
+ options = { format: "text", config: nil, workers: nil }
47
+ OptionParser.new do |opts|
48
+ opts.banner = USAGE
49
+ opts.on("--format=FORMAT", "Output format: text (default) or json") { |value| options[:format] = value }
50
+ opts.on("--workers=N", Integer, "Worker processes for a cold run") { |value| options[:workers] = value }
51
+ Options.add_config(opts, options)
52
+ end.parse!(@argv)
53
+ return usage_error("--format must be text or json") unless FORMATS.include?(options[:format])
54
+ return usage_error("no FILE given") if @argv.empty?
55
+
56
+ options
57
+ rescue OptionParser::ParseError => e
58
+ usage_error(e.message)
59
+ end
60
+
61
+ def usage_error(message)
62
+ @err.puts("rigor lens: #{message}")
63
+ @err.puts(USAGE)
64
+ nil
65
+ end
66
+
67
+ def render(document, format)
68
+ if format == "json"
69
+ @out.puts(JSON.generate(document))
70
+ else
71
+ TextRenderer.new(document, @out).render
72
+ end
73
+ end
74
+
75
+ # The text rendering for agents and people. Its layout is not a contract; the `LINE:HASH` anchor token is.
76
+ class TextRenderer
77
+ def initialize(document, out)
78
+ @document = document
79
+ @out = out
80
+ @members = document["members"].each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |member, index|
81
+ member["sites"].each { |key| index[key] << member }
82
+ end
83
+ end
84
+
85
+ def render
86
+ @document["files"].each { |file| render_file(file) }
87
+ end
88
+
89
+ private
90
+
91
+ def render_file(file)
92
+ unless file["status"] == "ok"
93
+ @out.puts("#{file['argument']} #{file['status']}: #{file['reason']}")
94
+ return
95
+ end
96
+
97
+ omitted = file["omissions"].empty? ? "" : " (#{file['omissions'].size} omitted)"
98
+ @out.puts("#{file['path']} #{file['digest']}#{omitted}")
99
+ file["sites"].each { |site| render_site(site) }
100
+ file["omissions"].each do |omission|
101
+ @out.puts(" omitted #{omission['kind']} #{omission['name']} (line #{omission.dig('span', 'start_line')})")
102
+ end
103
+ file["diagnostics"].each do |diagnostic|
104
+ @out.puts(" #{diagnostic['line']}:#{diagnostic['column']} #{diagnostic['severity']} " \
105
+ "#{diagnostic['message']} [#{diagnostic['rule']}]")
106
+ end
107
+ end
108
+
109
+ def render_site(site)
110
+ case site["kind"]
111
+ when "class", "module" then @out.puts(" #{site['anchor']} #{site['kind']} #{site['name']}")
112
+ else
113
+ @members[site["key"]].each { |member| render_member(site, member) }
114
+ end
115
+ end
116
+
117
+ def render_member(site, member)
118
+ separator = member["scope"] == "singleton" ? "." : "#"
119
+ if member["overloads"].empty?
120
+ elsewhere = member["also_defined_at"].map { |entry| "#{entry['path']}:#{entry['line']}" }
121
+ @out.puts(" #{site['anchor']} #{site['owner']}#{separator}#{member['name']} " \
122
+ "[#{member['basis']}#{"; also #{elsewhere.join(', ')}" unless elsewhere.empty?}]")
123
+ end
124
+ member["overloads"].each do |overload|
125
+ signature = overload["rbs"] || "(signature unavailable: #{unavailable_reasons(overload).join(', ')})"
126
+ @out.puts(" #{site['anchor']} #{site['owner']}#{separator}#{member['name']}: #{signature} " \
127
+ "[#{member['basis']}#{provenance_note(overload)}]")
128
+ end
129
+ end
130
+
131
+ def provenance_note(overload)
132
+ provenances = ([overload["return"]] + (overload["params"] || []).map { |param| param["type"] })
133
+ .filter_map { |slot| slot["provenance"] }.uniq
134
+ provenances.empty? ? "" : " #{provenances.join('+')}"
135
+ end
136
+
137
+ def unavailable_reasons(overload)
138
+ ([overload["return"]] + (overload["params"] || []).map { |param| param["type"] })
139
+ .filter_map { |slot| slot["reason"] }.uniq
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -53,8 +53,11 @@ module Rigor
53
53
  def build_server(writer:, config_path:) # rubocop:disable Metrics/MethodLength
54
54
  configuration = Configuration.load(config_path)
55
55
  # ProjectContext caches Environment + Cache::Store across requests so hover / publish hit the warm path.
56
- # Invalidated by `workspace/didChangeWatchedFiles` and `workspace/didChangeConfiguration`.
57
- project_context = LanguageServer::ProjectContext.new(configuration: configuration)
56
+ # Invalidated by `workspace/didChangeWatchedFiles` and `workspace/didChangeConfiguration`; every save round
57
+ # also re-reads the configuration (the loader below) and re-baselines when its inputs moved (#1613).
58
+ project_context = LanguageServer::ProjectContext.new(
59
+ configuration: configuration, configuration_loader: -> { Configuration.load(config_path) }
60
+ )
58
61
  # Single source of truth for buffer state — threaded to Server + all three providers.
59
62
  buffer_table = LanguageServer::BufferTable.new
60
63
  debouncer = LanguageServer::Debouncer.new
@@ -128,7 +128,7 @@ module Rigor
128
128
 
129
129
  @out.puts(render_print_header(skill))
130
130
  @out.puts
131
- @out.write(File.read(skill.fetch(:path)))
131
+ @out.write(File.read(skill.fetch(:path), encoding: Encoding::UTF_8))
132
132
  0
133
133
  end
134
134
 
@@ -145,14 +145,14 @@ module Rigor
145
145
 
146
146
  @out.puts(render_print_header(skill))
147
147
  @out.puts
148
- @out.write(File.read(skill.fetch(:path)))
148
+ @out.write(File.read(skill.fetch(:path), encoding: Encoding::UTF_8))
149
149
 
150
150
  reference_files(skill).each do |ref|
151
151
  @out.puts
152
152
  @out.puts
153
153
  @out.puts("<!-- ===== references/#{File.basename(ref)} (bundled with rigortype #{Rigor::VERSION}) ===== -->")
154
154
  @out.puts
155
- @out.write(File.read(ref))
155
+ @out.write(File.read(ref, encoding: Encoding::UTF_8))
156
156
  end
157
157
  0
158
158
  end
@@ -377,7 +377,7 @@ module Rigor
377
377
  # Parse a SKILL.md's leading `---`-delimited YAML frontmatter. Returns {} on any missing or malformed block so the
378
378
  # catalogue degrades to a name-only entry rather than raising.
379
379
  def frontmatter(path)
380
- text = File.read(path)
380
+ text = File.read(path, encoding: Encoding::UTF_8)
381
381
  return {} unless text.start_with?("---\n")
382
382
 
383
383
  closing = text.index("\n---\n", 4)
@@ -69,7 +69,12 @@ module Rigor
69
69
  def execute(file:, options:)
70
70
  configuration = Configuration.load(options.fetch(:config))
71
71
  source = File.read(file, encoding: Encoding::UTF_8)
72
+ # Prism parses the ORIGINAL bytes, so a `# -*- coding: -*-` file is read in its own encoding. Only the text the
73
+ # renderer sees is scrubbed: the colouriser and line splitter raise on a byte invalid in UTF-8 (a comment,
74
+ # `__END__` data, a non-UTF-8 file).
75
+ # Scrubbing is byte-for-byte (`?` per bad byte), so Prism's positions still address it.
72
76
  parse_result = Prism.parse(source, filepath: file, version: configuration.target_ruby)
77
+ source = source.scrub { |bytes| "?" * bytes.bytesize } unless source.valid_encoding?
73
78
  return 1 if parse_errors?(parse_result, file)
74
79
 
75
80
  events = record_events(parse_result.value, file, configuration)
@@ -87,7 +87,7 @@ module Rigor
87
87
  # file is parsed and scope-indexed once, however many positions land in it.
88
88
  def execute(targets:, options:, buffer: nil)
89
89
  configuration = Configuration.load(options.fetch(:config))
90
- environment = project_environment(targets.map(&:file).uniq, configuration)
90
+ environment = project_environment(targets.map(&:file).uniq, configuration, buffer)
91
91
  base_scope = Scope.empty(environment: environment)
92
92
 
93
93
  results_by_target = Array.new(targets.length)
@@ -218,8 +218,11 @@ module Rigor
218
218
  # closes). The probed file is threaded as the synthesizer's `source_files:`. Project-RBS auto-detection
219
219
  # roots at CWD today; future work will walk parent directories to find the enclosing `Gemfile`/`*.gemspec`
220
220
  # so probes against files outside the current process's CWD still see the right `sig/` tree.
221
- def project_environment(files, configuration)
222
- ProjectEnvironment.build(configuration: configuration, source_files: files)
221
+ # Issue #1700 — an editor buffer is scanned for required features in place of the file it stands for.
222
+ def project_environment(files, configuration, buffer = nil)
223
+ patched = ProjectEnvironment.project_patched_methods(configuration, buffer: buffer)
224
+ ProjectEnvironment.build(configuration: configuration, source_files: files, project_patched_methods: patched,
225
+ feature_sources: ProjectEnvironment.buffer_sources(buffer))
223
226
  end
224
227
 
225
228
  def file_exists?(file)