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.
- checksums.yaml +4 -4
- data/README.md +5 -5
- data/data/gem_overlay/activesupport/core_ext.rbs +33 -0
- data/docs/handbook/02-everyday-types.md +1 -1
- data/docs/handbook/04-tuples-and-shapes.md +1 -1
- data/docs/handbook/08-understanding-errors.md +1 -1
- data/docs/handbook/09-plugins.md +2 -2
- data/docs/handbook/10-sorbet.md +1 -1
- data/docs/handbook/README.md +2 -2
- data/docs/handbook/appendix-go.md +1 -1
- data/docs/handbook/appendix-java-csharp.md +2 -2
- data/docs/handbook/appendix-mypy.md +1 -1
- data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
- data/docs/handbook/appendix-rust.md +1 -1
- data/docs/handbook/appendix-type-theory.md +2 -2
- data/docs/handbook/appendix-typescript.md +4 -4
- data/docs/manual/02-cli-reference.md +13 -9
- data/docs/manual/03-configuration.md +1 -1
- data/docs/manual/04-diagnostics.md +12 -0
- data/docs/manual/08-skills.md +1 -1
- data/docs/manual/11-ci.md +1 -1
- data/docs/manual/12-caching.md +1 -1
- data/docs/manual/15-type-protection-coverage.md +1 -1
- data/docs/manual/18-removing-dead-code.md +13 -4
- data/docs/manual/19-effect-labels.md +19 -12
- data/docs/manual/README.md +2 -2
- data/docs/manual/plugins/rigor-actionmailer.md +4 -4
- data/docs/manual/plugins/rigor-activejob.md +3 -3
- data/docs/manual/plugins/rigor-activerecord.md +4 -4
- data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
- data/docs/manual/plugins/rigor-rspec.md +6 -2
- data/lib/rigor/analysis/diagnostic.rb +17 -6
- data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
- data/lib/rigor/analysis/reachability/graph.rb +30 -11
- data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
- data/lib/rigor/analysis/reachability/scan.rb +20 -4
- data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
- data/lib/rigor/analysis/run_cache_key.rb +12 -0
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +14 -8
- data/lib/rigor/analysis/runner.rb +151 -28
- data/lib/rigor/cache/file_digest.rb +20 -2
- data/lib/rigor/cli/check_command.rb +67 -48
- data/lib/rigor/cli/coverage_command.rb +5 -6
- data/lib/rigor/cli/doc_links.rb +100 -0
- data/lib/rigor/cli/docs_command.rb +32 -2
- data/lib/rigor/cli/effects_command.rb +27 -2
- data/lib/rigor/cli/effects_diff_renderer.rb +82 -12
- data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
- data/lib/rigor/cli/effects_snapshot_command.rb +52 -7
- data/lib/rigor/cli/unused_command.rb +65 -16
- data/lib/rigor/cli.rb +21 -8
- data/lib/rigor/effects/definition_lines.rb +100 -0
- data/lib/rigor/effects/envelope_check.rb +18 -1
- data/lib/rigor/effects/liskov_check.rb +17 -8
- data/lib/rigor/effects/signature_sources.rb +13 -2
- data/lib/rigor/effects/snapshot.rb +53 -21
- data/lib/rigor/effects/snapshot_diff.rb +26 -3
- data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
- data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
- data/lib/rigor/version.rb +1 -1
- data/skills/rigor-ci-setup/SKILL.md +2 -2
- data/skills/rigor-editor-setup/SKILL.md +2 -2
- data/skills/rigor-mcp-setup/SKILL.md +2 -2
- data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
- data/skills/rigor-plugin-review/SKILL.md +3 -3
- 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
|
-
|
|
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
|
-
#
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
@
|
|
139
|
-
|
|
173
|
+
# #482 — the 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
|
-
|
|
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
|
|
697
|
-
# are one answer — nil, "collect it again" — because none of them
|
|
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?(
|
|
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
|
|
710
|
-
#
|
|
711
|
-
#
|
|
712
|
-
#
|
|
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
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
-
|
|
726
|
-
|
|
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
|
|
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: -> {
|
|
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 =
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
504
|
+
"ignore any configured baseline for this run") do
|
|
503
505
|
options[:baseline] = false
|
|
504
506
|
end
|
|
505
507
|
opts.on("--baseline-strict",
|
|
506
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
608
|
+
out.puts("Cleared cache: #{cache_root}")
|
|
606
609
|
else
|
|
607
|
-
|
|
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
|
-
|
|
779
|
-
|
|
782
|
+
out.puts("")
|
|
783
|
+
out.puts("Cache (root: #{inv.fetch(:root)})")
|
|
780
784
|
schema = inv.fetch(:schema_version)
|
|
781
|
-
|
|
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
|
-
|
|
792
|
+
out.puts(" (empty)")
|
|
789
793
|
return
|
|
790
794
|
end
|
|
791
795
|
|
|
792
|
-
|
|
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
|
-
|
|
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
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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]
|