rigortype 0.3.4 → 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/effects/core.yml +18 -1
- data/data/effects/registry.yml +31 -3
- 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 +73 -22
- data/docs/manual/03-configuration.md +19 -2
- data/docs/manual/04-diagnostics.md +15 -2
- data/docs/manual/08-skills.md +1 -1
- data/docs/manual/11-ci.md +38 -1
- data/docs/manual/12-caching.md +40 -1
- data/docs/manual/15-type-protection-coverage.md +1 -1
- data/docs/manual/16-rbs-extended-annotations.md +15 -2
- data/docs/manual/18-removing-dead-code.md +13 -4
- data/docs/manual/19-effect-labels.md +678 -0
- data/docs/manual/README.md +7 -2
- data/docs/manual/ci-templates/README.md +9 -0
- 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/rule_catalog.rb +10 -3
- data/lib/rigor/analysis/run_cache_key.rb +12 -0
- data/lib/rigor/analysis/run_cache_probe.rb +69 -1
- data/lib/rigor/analysis/runner/declaration_position.rb +8 -24
- data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +28 -28
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +15 -9
- data/lib/rigor/analysis/runner/pool_coordinator.rb +25 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
- data/lib/rigor/analysis/runner.rb +163 -31
- data/lib/rigor/analysis/worker_session.rb +3 -1
- 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 +149 -10
- data/lib/rigor/cli/effects_diff_renderer.rb +88 -14
- data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
- data/lib/rigor/cli/effects_renderer.rb +41 -3
- data/lib/rigor/cli/effects_report.rb +116 -6
- data/lib/rigor/cli/effects_snapshot_command.rb +75 -11
- data/lib/rigor/cli/unused_command.rb +65 -16
- data/lib/rigor/cli.rb +33 -9
- data/lib/rigor/configuration.rb +37 -25
- data/lib/rigor/configuration_error.rb +20 -0
- data/lib/rigor/effects/collector.rb +38 -1
- data/lib/rigor/effects/definition_lines.rb +100 -0
- data/lib/rigor/effects/entry_points.rb +47 -1
- data/lib/rigor/effects/envelope_check.rb +18 -1
- data/lib/rigor/effects/file_collection.rb +18 -4
- data/lib/rigor/effects/framework_units.rb +68 -13
- data/lib/rigor/effects/inline_anchor.rb +134 -0
- data/lib/rigor/effects/liskov_check.rb +17 -8
- data/lib/rigor/effects/plugin_facts.rb +62 -13
- data/lib/rigor/effects/propagator.rb +79 -19
- data/lib/rigor/effects/registry.rb +10 -3
- data/lib/rigor/effects/scanner.rb +21 -9
- data/lib/rigor/effects/signature_sources.rb +29 -2
- data/lib/rigor/effects/snapshot.rb +74 -26
- data/lib/rigor/effects/snapshot_diff.rb +26 -3
- data/lib/rigor/effects/taint_cause.rb +1 -0
- data/lib/rigor/effects/unit_scan.rb +87 -12
- data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
- data/lib/rigor/plugin/base.rb +4 -0
- data/lib/rigor/plugin/box.rb +18 -2
- data/lib/rigor/plugin/effect_ancestry.rb +80 -0
- data/lib/rigor/plugin/manifest.rb +34 -10
- data/lib/rigor/plugin/registry.rb +10 -3
- data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
- data/lib/rigor/rbs_extended.rb +22 -2
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +17 -2
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +13 -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 +10 -1
|
@@ -108,7 +108,9 @@ module Rigor
|
|
|
108
108
|
return @effect_plugin_facts if @effect_plugin_facts && @effect_plugin_facts_ancestry.equal?(table)
|
|
109
109
|
|
|
110
110
|
@effect_plugin_facts_ancestry = table
|
|
111
|
-
@effect_plugin_facts = Effects::PluginFacts.build(
|
|
111
|
+
@effect_plugin_facts = Effects::PluginFacts.build(
|
|
112
|
+
@plugin_registry, superclasses: table, includes: @project_discovered_includes
|
|
113
|
+
)
|
|
112
114
|
end
|
|
113
115
|
|
|
114
116
|
# The merged per-file collections behind {#effect_table} — the *direct* summaries, before the graph
|
|
@@ -118,23 +120,65 @@ module Rigor
|
|
|
118
120
|
Effects::FileCollection.merge_all(effect_collections)
|
|
119
121
|
end
|
|
120
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
|
+
|
|
121
130
|
# Issue #382 — the same collections keyed by the path that produced each, which is the form the two
|
|
122
131
|
# caches persist: `{ "lib/a.rb" => FileCollection, … }`. The merged {#effect_collection} cannot be
|
|
123
132
|
# persisted per file (merging is where the path is deliberately dropped), and per file is what an
|
|
124
133
|
# ADR-46 recheck needs — it re-collects the changed closure and serves the rest from the snapshot.
|
|
125
134
|
def effect_collections_by_path
|
|
126
135
|
pooled = @pool_coordinator.collected_effects
|
|
127
|
-
|
|
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
|
|
128
171
|
end
|
|
129
172
|
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
@
|
|
137
|
-
|
|
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 = {}
|
|
138
182
|
end
|
|
139
183
|
|
|
140
184
|
# Issue #382 — whether this run's effect collections came from the whole-run effects slot rather than
|
|
@@ -152,6 +196,8 @@ module Rigor
|
|
|
152
196
|
# its entry points are named by *file* globs (`effects.snapshot.reach:`, the `unused --entry-point`
|
|
153
197
|
# syntax), so the key has to be traced back to the file it was written in.
|
|
154
198
|
def effect_sources
|
|
199
|
+
return @cached_effect_sources if @cached_effect_sources
|
|
200
|
+
|
|
155
201
|
effect_collections.each_with_object({}) do |collection, out|
|
|
156
202
|
path = collection.path
|
|
157
203
|
next if path.nil?
|
|
@@ -246,6 +292,11 @@ module Rigor
|
|
|
246
292
|
@file_effects = {}
|
|
247
293
|
@effect_table = nil
|
|
248
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
|
|
249
300
|
@run_dependency_descriptor = nil
|
|
250
301
|
# Memoised activation decision for the `call.self-undefined-method` rule (nil = not yet computed).
|
|
251
302
|
# See `self_undefined_rule_active?`.
|
|
@@ -672,9 +723,7 @@ module Rigor
|
|
|
672
723
|
return nil unless @record_effects
|
|
673
724
|
|
|
674
725
|
descriptor = effects_key_descriptor(key_descriptor)
|
|
675
|
-
|
|
676
|
-
if cached
|
|
677
|
-
adopt_effect_collections(cached)
|
|
726
|
+
if descriptor && served_from_effect_entries?(descriptor)
|
|
678
727
|
@effects_served_from_cache = true
|
|
679
728
|
return nil
|
|
680
729
|
end
|
|
@@ -684,6 +733,32 @@ module Rigor
|
|
|
684
733
|
analysis
|
|
685
734
|
end
|
|
686
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
|
+
|
|
687
762
|
def effects_key_descriptor(key_descriptor)
|
|
688
763
|
Effects::Identity.descriptor(base: key_descriptor, configuration: @configuration,
|
|
689
764
|
plugin_facts: effect_plugin_facts)
|
|
@@ -691,42 +766,92 @@ module Rigor
|
|
|
691
766
|
nil
|
|
692
767
|
end
|
|
693
768
|
|
|
694
|
-
# The read half. A miss, a stale dependency, a corrupt entry and a
|
|
695
|
-
# are one answer — nil, "collect it again" — because none of them
|
|
696
|
-
# 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.
|
|
697
789
|
def peek_effect_collections(descriptor)
|
|
698
790
|
cached = @cache_store.peek_validated(
|
|
699
791
|
producer_id: RunCacheKey::RUN_EFFECTS_PRODUCER_ID, key_descriptor: descriptor
|
|
700
792
|
)
|
|
701
|
-
cached.is_a?(
|
|
793
|
+
return nil unless cached.is_a?(Array) && cached.first.is_a?(Hash)
|
|
794
|
+
|
|
795
|
+
cached.first
|
|
702
796
|
rescue StandardError
|
|
703
797
|
nil
|
|
704
798
|
end
|
|
705
799
|
|
|
706
800
|
# The write half, run only after a miss, so the block never recomputes anything: it hands over the
|
|
707
|
-
# collections the analysis just produced
|
|
708
|
-
#
|
|
709
|
-
#
|
|
710
|
-
#
|
|
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.
|
|
711
811
|
def store_effect_collections(descriptor, expansion, rbs_descriptor)
|
|
712
812
|
return if descriptor.nil?
|
|
713
813
|
|
|
714
814
|
collections = effect_collections_by_path
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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)
|
|
719
822
|
nil
|
|
720
823
|
rescue StandardError
|
|
721
824
|
nil
|
|
722
825
|
end
|
|
723
|
-
|
|
724
|
-
|
|
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
|
|
725
849
|
|
|
726
850
|
# ADR-103 WD8 / #383 — the envelope check. Nothing at all without an `effects:` block, and nothing
|
|
727
851
|
# under `effects.check: false`; with both, one walk of the project's own RBS for `%a{pure}` /
|
|
728
852
|
# `%a{rigor:v1:effect …}`, and only if that finds an envelope does anything else run (the discovery
|
|
729
|
-
# 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).
|
|
730
855
|
#
|
|
731
856
|
# The environment is the one the cacheable path already resolved when there is one, so a warm run
|
|
732
857
|
# reads the envelopes off the loader it built anyway rather than building a second.
|
|
@@ -743,7 +868,7 @@ module Rigor
|
|
|
743
868
|
# ADR-103 WD1 / #386 — the nominal relation `effect.liskov-widened` reads, from the collector's
|
|
744
869
|
# own as-written superclass table, so the Liskov check and the closed-world proven lane resolve
|
|
745
870
|
# the same ancestry. Lazy: only a project that declared an envelope pays the merge.
|
|
746
|
-
ancestry: -> {
|
|
871
|
+
ancestry: -> { effect_ancestry },
|
|
747
872
|
apply_tolerated: !@no_tolerated_effects,
|
|
748
873
|
# #387 — the same compiled plugin tables the collection window scanned under, so an envelope may
|
|
749
874
|
# name a label a plugin opened and the unknown-label check agrees with the scan.
|
|
@@ -751,13 +876,20 @@ module Rigor
|
|
|
751
876
|
).diagnostics
|
|
752
877
|
end
|
|
753
878
|
|
|
754
|
-
# The residual takes the
|
|
879
|
+
# The residual takes the virtual RBS the run ALREADY resolved — never `envelope_rbs_loader`, which
|
|
755
880
|
# builds one on demand. This runs on the effects-off path, where an environment build is a cost
|
|
756
881
|
# the project did not ask for; the `signature_paths:` `.rbs` stratum is always read, and the
|
|
757
882
|
# rbs-inline stratum rides whatever the run happened to have.
|
|
883
|
+
#
|
|
884
|
+
# #441 — two sources, because two shapes of run resolve an environment and only one of them keeps
|
|
885
|
+
# it: `@run_environment` is assigned solely by the ADR-45 result-cacheable path, while every
|
|
886
|
+
# ANALYZING run (`--no-cache`, `--workers N`, `--incremental`, an editor buffer) builds one inside
|
|
887
|
+
# {PoolCoordinator} and lets it go. The snapshot is that run's carrier, and taking it second
|
|
888
|
+
# preserves the cacheable path's byte-identical behaviour.
|
|
758
889
|
def effect_annotation_residual_diagnostics
|
|
759
890
|
EffectAnnotationResidualPass.new(
|
|
760
|
-
configuration: @configuration,
|
|
891
|
+
configuration: @configuration,
|
|
892
|
+
virtual_rbs: @run_environment&.rbs_loader&.virtual_rbs || @snapshots.effect_annotation_carrier
|
|
761
893
|
).diagnostics
|
|
762
894
|
end
|
|
763
895
|
|
|
@@ -187,7 +187,9 @@ module Rigor
|
|
|
187
187
|
# the same project ancestry the parent would have walked.
|
|
188
188
|
def effect_plugin_facts
|
|
189
189
|
@effect_plugin_facts ||= Effects::PluginFacts.build(
|
|
190
|
-
@plugin_registry,
|
|
190
|
+
@plugin_registry,
|
|
191
|
+
superclasses: @project_scope_seed[:discovered_superclasses] || {},
|
|
192
|
+
includes: @project_scope_seed[:discovered_includes] || {}
|
|
191
193
|
)
|
|
192
194
|
end
|
|
193
195
|
private :effect_plugin_facts
|
|
@@ -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]
|