rigortype 0.3.5 → 0.3.6

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/data/gem_overlay/activesupport/core_ext.rbs +33 -0
  4. data/docs/handbook/02-everyday-types.md +1 -1
  5. data/docs/handbook/04-tuples-and-shapes.md +1 -1
  6. data/docs/handbook/08-understanding-errors.md +1 -1
  7. data/docs/handbook/09-plugins.md +2 -2
  8. data/docs/handbook/10-sorbet.md +1 -1
  9. data/docs/handbook/README.md +2 -2
  10. data/docs/handbook/appendix-go.md +1 -1
  11. data/docs/handbook/appendix-java-csharp.md +2 -2
  12. data/docs/handbook/appendix-mypy.md +1 -1
  13. data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
  14. data/docs/handbook/appendix-rust.md +1 -1
  15. data/docs/handbook/appendix-type-theory.md +2 -2
  16. data/docs/handbook/appendix-typescript.md +4 -4
  17. data/docs/manual/02-cli-reference.md +13 -9
  18. data/docs/manual/03-configuration.md +1 -1
  19. data/docs/manual/04-diagnostics.md +12 -0
  20. data/docs/manual/08-skills.md +1 -1
  21. data/docs/manual/11-ci.md +1 -1
  22. data/docs/manual/12-caching.md +1 -1
  23. data/docs/manual/15-type-protection-coverage.md +1 -1
  24. data/docs/manual/18-removing-dead-code.md +13 -4
  25. data/docs/manual/19-effect-labels.md +19 -12
  26. data/docs/manual/README.md +2 -2
  27. data/docs/manual/plugins/rigor-actionmailer.md +4 -4
  28. data/docs/manual/plugins/rigor-activejob.md +3 -3
  29. data/docs/manual/plugins/rigor-activerecord.md +4 -4
  30. data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
  31. data/docs/manual/plugins/rigor-rspec.md +6 -2
  32. data/lib/rigor/analysis/diagnostic.rb +17 -6
  33. data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
  34. data/lib/rigor/analysis/reachability/graph.rb +30 -11
  35. data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
  36. data/lib/rigor/analysis/reachability/scan.rb +20 -4
  37. data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
  38. data/lib/rigor/analysis/run_cache_key.rb +12 -0
  39. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +14 -8
  40. data/lib/rigor/analysis/runner.rb +151 -28
  41. data/lib/rigor/cache/file_digest.rb +20 -2
  42. data/lib/rigor/cli/check_command.rb +67 -48
  43. data/lib/rigor/cli/coverage_command.rb +5 -6
  44. data/lib/rigor/cli/doc_links.rb +100 -0
  45. data/lib/rigor/cli/docs_command.rb +32 -2
  46. data/lib/rigor/cli/effects_command.rb +27 -2
  47. data/lib/rigor/cli/effects_diff_renderer.rb +82 -12
  48. data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
  49. data/lib/rigor/cli/effects_snapshot_command.rb +52 -7
  50. data/lib/rigor/cli/unused_command.rb +65 -16
  51. data/lib/rigor/cli.rb +21 -8
  52. data/lib/rigor/effects/definition_lines.rb +100 -0
  53. data/lib/rigor/effects/envelope_check.rb +18 -1
  54. data/lib/rigor/effects/liskov_check.rb +17 -8
  55. data/lib/rigor/effects/signature_sources.rb +13 -2
  56. data/lib/rigor/effects/snapshot.rb +53 -21
  57. data/lib/rigor/effects/snapshot_diff.rb +26 -3
  58. data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
  59. data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
  60. data/lib/rigor/version.rb +1 -1
  61. data/skills/rigor-ci-setup/SKILL.md +2 -2
  62. data/skills/rigor-editor-setup/SKILL.md +2 -2
  63. data/skills/rigor-mcp-setup/SKILL.md +2 -2
  64. data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
  65. data/skills/rigor-plugin-review/SKILL.md +3 -3
  66. metadata +5 -1
@@ -120,23 +120,65 @@ module Rigor
120
120
  Effects::FileCollection.merge_all(effect_collections)
121
121
  end
122
122
 
123
+ # The run's as-written superclass table — what `effect.liskov-widened` reads. Served straight from
124
+ # the summary entry on a warm hit (#482), where merging every collection to recover one of its
125
+ # tables would be the whole cost the split removes.
126
+ def effect_ancestry
127
+ @cached_effect_ancestry || effect_collection.superclasses
128
+ end
129
+
123
130
  # Issue #382 — the same collections keyed by the path that produced each, which is the form the two
124
131
  # caches persist: `{ "lib/a.rb" => FileCollection, … }`. The merged {#effect_collection} cannot be
125
132
  # persisted per file (merging is where the path is deliberately dropped), and per file is what an
126
133
  # ADR-46 recheck needs — it re-collects the changed closure and serves the rest from the snapshot.
127
134
  def effect_collections_by_path
128
135
  pooled = @pool_coordinator.collected_effects
129
- pooled.empty? ? @file_effects.dup : @file_effects.merge(pooled)
136
+ base = forced_file_effects
137
+ pooled.empty? ? base.dup : base.merge(pooled)
138
+ end
139
+
140
+ # #482 — the per-file collections of a run served from the summary entry, loaded on the first
141
+ # question that genuinely needs them. A served run adopts the table, the sources and the ancestry
142
+ # from the small entry and never touches the collections blob; this is the escape hatch that makes
143
+ # that split safe, because a consumer the split did not anticipate loads the blob here instead of
144
+ # silently reading an empty table. Nil loader (an analyzing run) is the identity.
145
+ def forced_file_effects
146
+ loader = @cached_collections_loader
147
+ return @file_effects if loader.nil?
148
+
149
+ @cached_collections_loader = nil
150
+ loaded = loader.call
151
+ @file_effects = loaded if loaded.is_a?(Hash)
152
+ @file_effects
153
+ end
154
+
155
+ # Issue #382 — adopt persisted collections as this run's own. The warm-hit half of the whole-run
156
+ # effects slot: the analysis did not run, so `#assemble_run_diagnostics` never reached
157
+ # {#close_effect_graph}. The slot stores the propagated table BESIDE the collections — the whole-run
158
+ # entry is already invalidated by every analyzed file (the same post-run dependency descriptor as
159
+ # the diagnostics slot) and by every meaning input (the effects identity), which is exactly the
160
+ # invalidation a stored closure needs — so a warm hit adopts both and re-runs nothing. An entry
161
+ # from before the table rode along reads as a miss (`#peek_effect_collections`' shape guard), and
162
+ # the ADR-46 incremental snapshot still persists per-file collections only: a recheck re-collects a
163
+ # partial closure, which is the case a stored table genuinely cannot serve.
164
+ def adopt_effect_collections(collections, table: nil)
165
+ @file_effects = collections
166
+ if table
167
+ @effect_table = table
168
+ else
169
+ close_effect_graph
170
+ end
130
171
  end
131
172
 
132
- # Issue #382adopt persisted collections as this run's own and close the graph over them. The
133
- # warm-hit half of the whole-run effects slot: the analysis did not run, so `#assemble_run_diagnostics`
134
- # never reached {#close_effect_graph} and the fixpoint is run here instead. The fixpoint is ALWAYS
135
- # re-run rather than persisted — it is the cheap half, and a stored table would have to be invalidated
136
- # by every input a summary has.
137
- def adopt_effect_collections(collections)
138
- @file_effects = collections
139
- close_effect_graph
173
+ # #482the warm-hit half after the split: everything a served run reads, with the collections left
174
+ # behind a loader ({#forced_file_effects}). `sources` and `ancestry` are the two derived tables a
175
+ # served run would otherwise have to merge the whole collection set to recover.
176
+ def adopt_effect_summary(table:, sources:, ancestry:, collections_loader:)
177
+ @effect_table = table
178
+ @cached_effect_sources = sources
179
+ @cached_effect_ancestry = ancestry
180
+ @cached_collections_loader = collections_loader
181
+ @file_effects = {}
140
182
  end
141
183
 
142
184
  # Issue #382 — whether this run's effect collections came from the whole-run effects slot rather than
@@ -154,6 +196,8 @@ module Rigor
154
196
  # its entry points are named by *file* globs (`effects.snapshot.reach:`, the `unused --entry-point`
155
197
  # syntax), so the key has to be traced back to the file it was written in.
156
198
  def effect_sources
199
+ return @cached_effect_sources if @cached_effect_sources
200
+
157
201
  effect_collections.each_with_object({}) do |collection, out|
158
202
  path = collection.path
159
203
  next if path.nil?
@@ -248,6 +292,11 @@ module Rigor
248
292
  @file_effects = {}
249
293
  @effect_table = nil
250
294
  @effects_served_from_cache = false
295
+ # #482 — set only by a run served from the summary entry: the two derived tables it carries, and
296
+ # the loader that fetches the collections blob if anything still asks for per-file form.
297
+ @cached_effect_sources = nil
298
+ @cached_effect_ancestry = nil
299
+ @cached_collections_loader = nil
251
300
  @run_dependency_descriptor = nil
252
301
  # Memoised activation decision for the `call.self-undefined-method` rule (nil = not yet computed).
253
302
  # See `self_undefined_rule_active?`.
@@ -674,9 +723,7 @@ module Rigor
674
723
  return nil unless @record_effects
675
724
 
676
725
  descriptor = effects_key_descriptor(key_descriptor)
677
- cached = descriptor && peek_effect_collections(descriptor)
678
- if cached
679
- adopt_effect_collections(cached)
726
+ if descriptor && served_from_effect_entries?(descriptor)
680
727
  @effects_served_from_cache = true
681
728
  return nil
682
729
  end
@@ -686,6 +733,32 @@ module Rigor
686
733
  analysis
687
734
  end
688
735
 
736
+ # The two warm lanes, in cost order (#482).
737
+ #
738
+ # The summary entry is what a warm run wants: the table and the two derived tables, and no
739
+ # collections blob. Its absence is not a miss on its own — a run whose propagation fail-softed
740
+ # deliberately stores no table (see {#storable_effect_table}), and an entry written before the
741
+ # split has only the collections — so the second lane adopts the collections and re-runs the
742
+ # fixpoint over them, which is the pre-split behaviour and the retry the fail-soft posture wants.
743
+ # Only when neither entry answers does the run re-analyse.
744
+ #
745
+ # @return [Boolean] whether this run was served.
746
+ def served_from_effect_entries?(descriptor)
747
+ summary = peek_effect_summary(descriptor)
748
+ if summary
749
+ table, sources, ancestry = summary
750
+ adopt_effect_summary(table: table, sources: sources, ancestry: ancestry,
751
+ collections_loader: -> { peek_effect_collections(descriptor) || {} })
752
+ return true
753
+ end
754
+
755
+ collections = peek_effect_collections(descriptor)
756
+ return false if collections.nil?
757
+
758
+ adopt_effect_collections(collections)
759
+ true
760
+ end
761
+
689
762
  def effects_key_descriptor(key_descriptor)
690
763
  Effects::Identity.descriptor(base: key_descriptor, configuration: @configuration,
691
764
  plugin_facts: effect_plugin_facts)
@@ -693,42 +766,92 @@ module Rigor
693
766
  nil
694
767
  end
695
768
 
696
- # The read half. A miss, a stale dependency, a corrupt entry and a stored value of the wrong shape
697
- # are one answer — nil, "collect it again" — because none of them can be told apart from the outside
698
- # and all of them have the same remedy.
769
+ # The read half of the serving entry (#482). A miss, a stale dependency, a corrupt entry and a
770
+ # stored value of the wrong shape are one answer — nil, "collect it again" — because none of them
771
+ # can be told apart from the outside and all of them have the same remedy. An entry from before the
772
+ # split simply does not exist under this producer id, so an older cache re-collects once.
773
+ def peek_effect_summary(descriptor)
774
+ cached = @cache_store.peek_validated(
775
+ producer_id: RunCacheKey::RUN_EFFECTS_TABLE_PRODUCER_ID, key_descriptor: descriptor
776
+ )
777
+ return nil unless cached.is_a?(Array) && cached.length == 3
778
+ return nil unless cached[0].is_a?(Effects::EffectTable) && cached[1].is_a?(Hash) &&
779
+ cached[2].is_a?(Hash)
780
+
781
+ cached
782
+ rescue StandardError
783
+ nil
784
+ end
785
+
786
+ # The read half of the collections entry — only ever asked by {#forced_file_effects}, on the paths
787
+ # that need per-file form. The stored shape is `[collections]`; an entry from before the split is
788
+ # `[collections, table]` and its first member is the same Hash, so it still reads.
699
789
  def peek_effect_collections(descriptor)
700
790
  cached = @cache_store.peek_validated(
701
791
  producer_id: RunCacheKey::RUN_EFFECTS_PRODUCER_ID, key_descriptor: descriptor
702
792
  )
703
- cached.is_a?(Hash) ? cached : nil
793
+ return nil unless cached.is_a?(Array) && cached.first.is_a?(Hash)
794
+
795
+ cached.first
704
796
  rescue StandardError
705
797
  nil
706
798
  end
707
799
 
708
800
  # The write half, run only after a miss, so the block never recomputes anything: it hands over the
709
- # collections the analysis just produced, validated against the same post-run dependency descriptor
710
- # the diagnostics slot records. Fail-soft a collection that will not Marshal (which nothing in
711
- # {Effects::FileCollection} should be, and the fork pool already proves per file) costs the next run
712
- # its warm start and nothing else.
801
+ # collections the analysis just produced and the table the analysis already closed over them, so
802
+ # a warm hit re-runs neither the merge nor the fixpoint validated against the same post-run
803
+ # dependency descriptor the diagnostics slot records. Fail-soft a collection that will not
804
+ # Marshal (which nothing in {Effects::FileCollection} should be, and the fork pool already proves
805
+ # per file) costs the next run its warm start and nothing else.
806
+ # The write half, run only after a miss, so neither block recomputes anything. Two entries under one
807
+ # key descriptor and one dependency descriptor (#482): the summary a warm run serves from, and the
808
+ # collections the incremental and fail-soft paths read. The summary is written LAST, so a run
809
+ # interrupted between the two leaves a collections entry that the next run's summary miss simply
810
+ # re-collects — never a summary whose collections are absent.
713
811
  def store_effect_collections(descriptor, expansion, rbs_descriptor)
714
812
  return if descriptor.nil?
715
813
 
716
814
  collections = effect_collections_by_path
717
- @cache_store.fetch_or_validate(
718
- producer_id: RunCacheKey::RUN_EFFECTS_PRODUCER_ID, key_descriptor: descriptor,
719
- generation_cap: RunCacheKey::EFFECTS_GENERATION_CAP
720
- ) { [collections, run_dependency_descriptor(expansion, rbs_descriptor)] }
815
+ dependencies = run_dependency_descriptor(expansion, rbs_descriptor)
816
+ write_effect_entry(RunCacheKey::RUN_EFFECTS_PRODUCER_ID, descriptor, [collections], dependencies)
817
+ table = storable_effect_table(collections)
818
+ return nil if table.nil?
819
+
820
+ write_effect_entry(RunCacheKey::RUN_EFFECTS_TABLE_PRODUCER_ID, descriptor,
821
+ [table, effect_sources, effect_collection.superclasses], dependencies)
721
822
  nil
722
823
  rescue StandardError
723
824
  nil
724
825
  end
725
- private :serve_effect_collections, :effects_key_descriptor,
726
- :peek_effect_collections, :store_effect_collections
826
+
827
+ def write_effect_entry(producer_id, descriptor, payload, dependencies)
828
+ @cache_store.fetch_or_validate(
829
+ producer_id: producer_id, key_descriptor: descriptor,
830
+ generation_cap: RunCacheKey::EFFECTS_GENERATION_CAP
831
+ ) { [payload, dependencies] }
832
+ end
833
+
834
+ # An empty table over non-empty summaries is {#close_effect_graph}'s fail-soft answer, not a
835
+ # result — persisting it would freeze one run's propagation failure into every warm hit until the
836
+ # next invalidation. Storing nil instead makes the warm hit re-run the fixpoint, which is exactly
837
+ # the retry the fail-soft posture wants.
838
+ def storable_effect_table(collections)
839
+ table = @effect_table
840
+ return nil if table.nil?
841
+ return nil if table.empty? && collections.any? { |_, collection| !collection.summaries.empty? }
842
+
843
+ table
844
+ end
845
+ private :serve_effect_collections, :served_from_effect_entries?,
846
+ :effects_key_descriptor, :peek_effect_summary,
847
+ :peek_effect_collections, :store_effect_collections, :write_effect_entry,
848
+ :storable_effect_table, :forced_file_effects
727
849
 
728
850
  # ADR-103 WD8 / #383 — the envelope check. Nothing at all without an `effects:` block, and nothing
729
851
  # under `effects.check: false`; with both, one walk of the project's own RBS for `%a{pure}` /
730
852
  # `%a{rigor:v1:effect …}`, and only if that finds an envelope does anything else run (the discovery
731
- # tables the `def` positions come from are forced from inside the pass, lazily, for that reason).
853
+ # tables the `def` positions come from are forced from inside the judgment, on the first finding
854
+ # built — a judged-clean envelope forces no discovery at all).
732
855
  #
733
856
  # The environment is the one the cacheable path already resolved when there is one, so a warm run
734
857
  # reads the envelopes off the loader it built anyway rather than building a second.
@@ -745,7 +868,7 @@ module Rigor
745
868
  # ADR-103 WD1 / #386 — the nominal relation `effect.liskov-widened` reads, from the collector's
746
869
  # own as-written superclass table, so the Liskov check and the closed-world proven lane resolve
747
870
  # the same ancestry. Lazy: only a project that declared an envelope pays the merge.
748
- ancestry: -> { effect_collection.superclasses },
871
+ ancestry: -> { effect_ancestry },
749
872
  apply_tolerated: !@no_tolerated_effects,
750
873
  # #387 — the same compiled plugin tables the collection window scanned under, so an envelope may
751
874
  # name a label a plugin opened and the unknown-label check agrees with the scan.
@@ -30,9 +30,10 @@ module Rigor
30
30
  # identical to a bare `Digest::SHA256.file`.
31
31
  module FileDigest
32
32
  MEMO_KEY = :rigor_cache_file_digest_memo
33
+ STAT_KEY = :rigor_cache_file_stat_memo
33
34
  INSTANT_KEY = :rigor_cache_recording_instant
34
35
  STRICT_KEY = :rigor_cache_strict_validation
35
- private_constant :MEMO_KEY, :INSTANT_KEY, :STRICT_KEY
36
+ private_constant :MEMO_KEY, :STAT_KEY, :INSTANT_KEY, :STRICT_KEY
36
37
 
37
38
  # Set in the environment to force the strict digest-always validation path for a single run, regardless
38
39
  # of the `cache.validation` config setting (the env wins). The escape hatch for a filesystem whose stat
@@ -47,14 +48,17 @@ module Rigor
47
48
  # digest-always path for `cache.validation: digest`.
48
49
  def self.with_run(strict: false)
49
50
  previous_memo = Thread.current[MEMO_KEY]
51
+ previous_stat = Thread.current[STAT_KEY]
50
52
  previous_instant = Thread.current[INSTANT_KEY]
51
53
  previous_strict = Thread.current[STRICT_KEY]
52
54
  Thread.current[MEMO_KEY] = {}
55
+ Thread.current[STAT_KEY] = {}
53
56
  Thread.current[INSTANT_KEY] = now_ns
54
57
  Thread.current[STRICT_KEY] = strict
55
58
  yield
56
59
  ensure
57
60
  Thread.current[MEMO_KEY] = previous_memo
61
+ Thread.current[STAT_KEY] = previous_stat
58
62
  Thread.current[INSTANT_KEY] = previous_instant
59
63
  Thread.current[STRICT_KEY] = previous_strict
60
64
  end
@@ -102,12 +106,26 @@ module Rigor
102
106
  digest = parsed[0]
103
107
  return hexdigest(path) == digest if strict_validation?
104
108
 
105
- st = File.stat(path)
109
+ st = validation_stat(path)
106
110
  return true if !racy?(parsed) && tuple_matches?(st, parsed)
107
111
 
108
112
  hexdigest(path) == digest
109
113
  end
110
114
 
115
+ # The VALIDATION-side stat, served from the per-run table when one is installed — a collecting run
116
+ # validates the effects entry and the diagnostics entry against the same ~thousands-of-files
117
+ # dependency descriptor, and the second pass is pure repetition under the run's own stable-filesystem
118
+ # premise (see the module doc). The RECORDING side ({.pack_stat}) deliberately keeps its direct
119
+ # `File.stat`: it packs the tuple a *future* run validates, after the content was read, and must
120
+ # describe that moment rather than an earlier probe's. A stat failure propagates un-memoised, exactly
121
+ # as {.hexdigest} treats a read failure.
122
+ def self.validation_stat(path)
123
+ memo = Thread.current[STAT_KEY]
124
+ return File.stat(path) if memo.nil?
125
+
126
+ memo[path] ||= File.stat(path)
127
+ end
128
+
111
129
  def self.recording_instant_ns
112
130
  Thread.current[INSTANT_KEY] || now_ns
113
131
  end
@@ -11,7 +11,6 @@ require_relative "../analysis/rule_catalog"
11
11
  # The baseline filter runs on EVERY exit path — including the ADR-87 WD4 cache-HIT fast path, which skips
12
12
  # `load_check_dependencies` — so it must be a load-time require. It pulls only YAML, never the engine.
13
13
  require_relative "../analysis/baseline"
14
- require_relative "../runtime/jit"
15
14
  require_relative "command"
16
15
  require_relative "options"
17
16
  require_relative "diagnostic_formats"
@@ -34,12 +33,10 @@ module Rigor
34
33
  # concerns that are clearer read together than split across micro-classes.
35
34
  class CheckCommand < Command # rubocop:disable Metrics/ClassLength
36
35
  # @return [Integer] CLI exit status.
37
- def run # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
38
- # Arm deferred YJIT enablement before any analysis work: the deadline
39
- # thread only fires once a run outlasts the amortization window, so a
40
- # short check finishes before it ever pays JIT compile cost while a
41
- # long run JITs its dominant tail (Runtime::Jit).
42
- Runtime::Jit.enable_after(Runtime::Jit.deadline_seconds)
36
+ #
37
+ # Deferred YJIT enablement (Runtime::Jit) is armed by `CLI#dispatch` for every command, this
38
+ # one included, before any analysis work runs.
39
+ def run # rubocop:disable Metrics/AbcSize
43
40
  # ADR-87 WD4 — parse options + resolve config WITHOUT the inference engine, so the run-cache hit probe
44
41
  # can run first. The heavy engine (`load_check_dependencies`) loads only on a miss / non-cacheable run.
45
42
  options = parse_check_options
@@ -50,7 +47,7 @@ module Rigor
50
47
  configuration = apply_bleeding_edge_override(configuration, options)
51
48
  config_warnings = warn_unresolved_config(configuration)
52
49
  cache_root = configuration.cache_path
53
- handle_clear_cache(cache_root) if options.fetch(:clear_cache)
50
+ handle_clear_cache(cache_root, options.fetch(:format)) if options.fetch(:clear_cache)
54
51
 
55
52
  # ADR-87 WD4 — try to serve the whole run from the ADR-45 cache before booting the engine. A hit boots
56
53
  # only CLI + config + cache + digest code (no `rigor/inference`), skipping the plugin prepass + env
@@ -76,7 +73,7 @@ module Rigor
76
73
  write_run_stats(result.stats) if result.stats
77
74
  write_trace_appendices
78
75
  runner.cache_store&.evict!
79
- write_cache_stats(cache_root, runner.cache_store) if options.fetch(:cache_stats)
76
+ write_cache_stats(cache_root, runner.cache_store, options.fetch(:format)) if options.fetch(:cache_stats)
80
77
 
81
78
  exit_code = result.success? ? 0 : 1
82
79
  exit_code = 1 if baseline_strict_violation?(raw_result.diagnostics, configuration, options)
@@ -195,7 +192,8 @@ module Rigor
195
192
  incremental = normalize_diagnostics(session.reanalyze_subset(subset))
196
193
  full = normalize_diagnostics(verify_full_diagnostics(configuration, paths))
197
194
 
198
- report_verify_incremental(incremental, full, subset_size: subset.size, total: analyzed.size)
195
+ report_verify_incremental(incremental, full, subset_size: subset.size, total: analyzed.size,
196
+ format: options.fetch(:format))
199
197
  end
200
198
 
201
199
  # ADR-46 — cross-process incremental analysis (`--incremental`). Derives the global fingerprint cheaply (no RBS
@@ -230,7 +228,8 @@ module Rigor
230
228
  @err.puts("rigor: --incremental #{warm ? 'warm — reused cached diagnostics' : 'cold — full analysis'} " \
231
229
  "(#{session.analyzed_files.size} files)")
232
230
  emit_incremental_fact_surface_notes(session)
233
- write_incremental_cache_stats(session, cache_root, store) if options.fetch(:cache_stats)
231
+ write_incremental_cache_stats(session, cache_root, store, options.fetch(:format)) if
232
+ options.fetch(:cache_stats)
234
233
 
235
234
  result = apply_baseline_filter(Analysis::Result.new(diagnostics: diagnostics, stats: nil), configuration,
236
235
  options)
@@ -286,8 +285,8 @@ module Rigor
286
285
  # does) plus the fact-surface status line, so an operator can see whether a full run was fact-surface
287
286
  # driven. The plain `check` cache-stats path is bypassed by the incremental short-circuit, so it is
288
287
  # emitted here.
289
- def write_incremental_cache_stats(session, cache_root, store)
290
- write_cache_stats(cache_root, store)
288
+ def write_incremental_cache_stats(session, cache_root, store, format)
289
+ write_cache_stats(cache_root, store, format)
291
290
  status =
292
291
  if !session.opaque_plugin_ids.empty?
293
292
  "opaque (#{session.opaque_plugin_ids.sort.join(', ')})"
@@ -296,7 +295,7 @@ module Rigor
296
295
  else
297
296
  "unchanged"
298
297
  end
299
- @out.puts(" plugin fact surface: #{status}")
298
+ side_output(format).puts(" plugin fact surface: #{status}")
300
299
  end
301
300
 
302
301
  def verify_full_diagnostics(configuration, paths)
@@ -310,11 +309,14 @@ module Rigor
310
309
  end
311
310
  end
312
311
 
313
- def report_verify_incremental(incremental, full, subset_size:, total:)
312
+ # The OK line follows the same rule as the other side outputs (#493) stdout under `text`, stderr
313
+ # under a machine format. The FAILED branch below has always gone to stderr: it is a failure
314
+ # report, and a run that produced one has no valid document to protect anyway.
315
+ def report_verify_incremental(incremental, full, subset_size:, total:, format: "text")
314
316
  if incremental == full
315
- @out.puts("rigor: --verify-incremental OK — incremental " \
316
- "(#{subset_size}/#{total} files re-analyzed, rest from cache) " \
317
- "matches full (#{full.size} diagnostics)")
317
+ side_output(format).puts("rigor: --verify-incremental OK — incremental " \
318
+ "(#{subset_size}/#{total} files re-analyzed, rest from cache) " \
319
+ "matches full (#{full.size} diagnostics)")
318
320
  return 0
319
321
  end
320
322
 
@@ -495,46 +497,46 @@ module Rigor
495
497
  end
496
498
  Options.add_editor_mode(opts, options)
497
499
  opts.on("--baseline=PATH",
498
- "ADR-22: load baseline from PATH (overrides .rigor.yml `baseline:`)") do |value|
500
+ "load baseline from PATH (overrides .rigor.yml `baseline:`)") do |value|
499
501
  options[:baseline] = value
500
502
  end
501
503
  opts.on("--no-baseline",
502
- "ADR-22: ignore any configured baseline for this run") do
504
+ "ignore any configured baseline for this run") do
503
505
  options[:baseline] = false
504
506
  end
505
507
  opts.on("--baseline-strict",
506
- "ADR-22: fail the run on any baseline drift (CI gate)") do
508
+ "fail the run on any baseline drift (CI gate)") do
507
509
  options[:baseline_strict] = true
508
510
  end
509
511
  opts.on("--treat-all-as-inline-rbs",
510
- "ADR-32: force-load rigor-rbs-inline with require_magic_comment: false") do
512
+ "force-load rigor-rbs-inline with require_magic_comment: false") do
511
513
  options[:treat_all_as_inline_rbs] = true
512
514
  end
513
515
  opts.on("--verify-incremental",
514
- "ADR-46: assert incremental analysis matches a full run, then exit") do
516
+ "assert incremental analysis matches a full run, then exit") do
515
517
  options[:verify_incremental] = true
516
518
  end
517
519
  opts.on("--incremental",
518
- "ADR-46: re-analyze only files changed since the last run (cross-process cache)") do
520
+ "re-analyze only files changed since the last run (cross-process cache)") do
519
521
  options[:incremental] = true
520
522
  end
521
523
  opts.on("--no-ci-detect",
522
- "ADR-51: do not auto-emit CI-native output when a CI environment is detected") do
524
+ "do not auto-emit CI-native output when a CI environment is detected") do
523
525
  options[:ci_detect] = false
524
526
  end
525
527
  # ADR-50 § WD2 — `=[LIST]` (not ` [LIST]`) so a bare `--bleeding-edge` never swallows a following positional
526
528
  # path: `rigor check --bleeding-edge lib` adopts the whole overlay and checks `lib`.
527
529
  opts.on("--bleeding-edge=[LIST]",
528
- "ADR-50: adopt the bleeding-edge overlay for this run " \
530
+ "adopt the bleeding-edge overlay for this run " \
529
531
  "(all features, or a comma-separated feature-id list)") do |value|
530
532
  options[:bleeding_edge] = value.nil? || value.split(",").map(&:strip).reject(&:empty?)
531
533
  end
532
534
  opts.on("--no-bleeding-edge",
533
- "ADR-50: ignore any configured bleeding_edge: selection for this run") do
535
+ "ignore any configured bleeding_edge: selection for this run") do
534
536
  options[:bleeding_edge] = false
535
537
  end
536
538
  opts.on("--no-tolerated-effects",
537
- "ADR-103: check effect envelopes as if effects.tolerated: were empty") do
539
+ "check effect envelopes as if effects.tolerated: were empty") do
538
540
  options[:no_tolerated_effects] = true
539
541
  end
540
542
  end
@@ -599,12 +601,13 @@ module Rigor
599
601
  }]
600
602
  end
601
603
 
602
- def handle_clear_cache(cache_root)
604
+ def handle_clear_cache(cache_root, format)
605
+ out = side_output(format)
603
606
  if File.directory?(cache_root)
604
607
  FileUtils.rm_rf(cache_root)
605
- @out.puts("Cleared cache: #{cache_root}")
608
+ out.puts("Cleared cache: #{cache_root}")
606
609
  else
607
- @out.puts("Cache already empty: #{cache_root}")
610
+ out.puts("Cache already empty: #{cache_root}")
608
611
  end
609
612
  end
610
613
 
@@ -772,42 +775,43 @@ module Rigor
772
775
  Kernel.format("%.1f MB", bytes / 1_048_576.0)
773
776
  end
774
777
 
775
- def write_cache_stats(cache_root, runtime_store)
778
+ def write_cache_stats(cache_root, runtime_store, format)
776
779
  inv = Cache::Store.disk_inventory(root: cache_root)
780
+ out = side_output(format)
777
781
 
778
- @out.puts("")
779
- @out.puts("Cache (root: #{inv.fetch(:root)})")
782
+ out.puts("")
783
+ out.puts("Cache (root: #{inv.fetch(:root)})")
780
784
  schema = inv.fetch(:schema_version)
781
- @out.puts(" schema_version: #{schema.nil? ? 'absent' : schema}")
782
- write_disk_inventory(inv)
783
- write_runtime_stats(runtime_store) if runtime_store
785
+ out.puts(" schema_version: #{schema.nil? ? 'absent' : schema}")
786
+ write_disk_inventory(inv, out)
787
+ write_runtime_stats(runtime_store, out) if runtime_store
784
788
  end
785
789
 
786
- def write_disk_inventory(inv)
790
+ def write_disk_inventory(inv, out)
787
791
  if inv.fetch(:total_entries).zero?
788
- @out.puts(" (empty)")
792
+ out.puts(" (empty)")
789
793
  return
790
794
  end
791
795
 
792
- @out.puts(" #{inv.fetch(:total_entries)} entries, #{format_bytes(inv.fetch(:total_bytes))}")
796
+ out.puts(" #{inv.fetch(:total_entries)} entries, #{format_bytes(inv.fetch(:total_bytes))}")
793
797
  inv.fetch(:producers).each do |producer|
794
798
  bytes = format_bytes(producer.fetch(:bytes))
795
- @out.puts(" #{producer.fetch(:id)}: #{producer.fetch(:entries)} entries, #{bytes}")
799
+ out.puts(" #{producer.fetch(:id)}: #{producer.fetch(:entries)} entries, #{bytes}")
796
800
  end
797
801
  end
798
802
 
799
- def write_runtime_stats(store)
803
+ def write_runtime_stats(store, out)
800
804
  stats = store.stats
801
805
  hits = stats.fetch(:hits)
802
806
  misses = stats.fetch(:misses)
803
807
  writes = stats.fetch(:writes)
804
- @out.puts(" this run: #{hits} #{plural(hits, 'hit')}, " \
805
- "#{misses} #{plural(misses, 'miss', 'misses')}, " \
806
- "#{writes} #{plural(writes, 'write')}")
808
+ out.puts(" this run: #{hits} #{plural(hits, 'hit')}, " \
809
+ "#{misses} #{plural(misses, 'miss', 'misses')}, " \
810
+ "#{writes} #{plural(writes, 'write')}")
807
811
  stats.fetch(:by_producer).each do |id, counts|
808
- @out.puts(" #{id}: #{counts.fetch(:hits)} #{plural(counts.fetch(:hits), 'hit')}, " \
809
- "#{counts.fetch(:misses)} #{plural(counts.fetch(:misses), 'miss', 'misses')}, " \
810
- "#{counts.fetch(:writes)} #{plural(counts.fetch(:writes), 'write')}")
812
+ out.puts(" #{id}: #{counts.fetch(:hits)} #{plural(counts.fetch(:hits), 'hit')}, " \
813
+ "#{counts.fetch(:misses)} #{plural(counts.fetch(:misses), 'miss', 'misses')}, " \
814
+ "#{counts.fetch(:writes)} #{plural(counts.fetch(:writes), 'write')}")
811
815
  end
812
816
  end
813
817
 
@@ -822,6 +826,21 @@ module Rigor
822
826
  format("%.1f MiB", bytes / (1024.0 * 1024.0))
823
827
  end
824
828
 
829
+ # Where a human-readable side output goes (#493).
830
+ #
831
+ # `--format text` is prose already, so an extra block after the diagnostics is just more prose. Every
832
+ # other format is a machine contract — a SARIF document a code-scanning upload parses, a Checkstyle
833
+ # or JUnit XML tree, a GitLab Code Quality array — and appending `Cache (root: …)` to it produces a
834
+ # document the consumer rejects. Measured: all seven non-text formats were corrupted by
835
+ # `--cache-stats`, and JSON / SARIF / GitLab failed to parse at all.
836
+ #
837
+ # The information is not dropped, because it is what the user asked for by passing the flag; it moves
838
+ # to stderr, which is where this command already routes everything it says about a run rather than
839
+ # about the code.
840
+ def side_output(format)
841
+ format == "text" ? @out : @err
842
+ end
843
+
825
844
  def write_result(result, format, coverage: nil, config_warnings: [])
826
845
  case format
827
846
  when "json"
@@ -14,7 +14,6 @@ require_relative "../inference/parameter_inference_collector"
14
14
  require_relative "../protection/mutation_scanner"
15
15
  require_relative "../protection/test_suite_oracle"
16
16
  require_relative "../language_server/project_context"
17
- require_relative "../runtime/jit"
18
17
  require_relative "../scope"
19
18
  require_relative "coverage_report"
20
19
  require_relative "coverage_renderer"
@@ -55,11 +54,11 @@ module Rigor
55
54
  DEFAULT_TEST_COMMAND = %w[bundle exec rake].freeze
56
55
 
57
56
  # @return [Integer] CLI exit status.
58
- def run # rubocop:disable Metrics/AbcSize
59
- # Arm deferred YJIT enablement (Runtime::Jit): a scan long enough to
60
- # amortize JIT compile cost enables mid-flight; a short one finishes
61
- # first and never pays it. Same seam as `rigor check`.
62
- Runtime::Jit.enable_after(Runtime::Jit.deadline_seconds)
57
+ #
58
+ # Deferred YJIT enablement (Runtime::Jit) is armed by `CLI#dispatch` for every command, this
59
+ # one included: a scan long enough to amortize JIT compile cost enables mid-flight; a short one
60
+ # finishes first and never pays it.
61
+ def run
63
62
  options = parse_options
64
63
  return mutation_misuse_error if options[:mutation] && !options[:protection]
65
64
  return with_tests_misuse_error if options[:with_tests] && !options[:mutation]