rigortype 0.3.1 → 0.3.2
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 +10 -10
- data/data/core_overlay/pathname.rbs +5 -0
- data/data/core_overlay/resolv.rbs +31 -0
- data/data/core_overlay/string_scanner.rbs +5 -0
- data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
- data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
- data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
- data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
- data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
- data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
- data/docs/manual/02-cli-reference.md +71 -11
- data/docs/manual/03-configuration.md +16 -1
- data/docs/manual/12-caching.md +17 -5
- data/docs/manual/15-type-protection-coverage.md +122 -1
- data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
- data/lib/rigor/analysis/check_rules.rb +45 -6
- data/lib/rigor/analysis/incremental_session.rb +156 -11
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
- data/lib/rigor/analysis/run_cache_key.rb +17 -1
- data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
- data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
- data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
- data/lib/rigor/analysis/runner.rb +78 -7
- data/lib/rigor/bleeding_edge.rb +132 -7
- data/lib/rigor/cache/descriptor.rb +6 -1
- data/lib/rigor/cache/engine_source.rb +162 -0
- data/lib/rigor/cache/incremental_snapshot.rb +87 -18
- data/lib/rigor/cli/check_command.rb +49 -28
- data/lib/rigor/cli/coverage_command.rb +15 -2
- data/lib/rigor/cli/coverage_mutation.rb +242 -11
- data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
- data/lib/rigor/cli/fused_protection_report.rb +12 -3
- data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
- data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
- data/lib/rigor/cli/mutation_protection_report.rb +26 -5
- data/lib/rigor/cli/protection_renderer.rb +13 -0
- data/lib/rigor/cli/protection_report.rb +11 -3
- data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
- data/lib/rigor/cli/sig_gen_command.rb +5 -4
- data/lib/rigor/configuration.rb +45 -6
- data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
- data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
- data/lib/rigor/environment/rbs_loader.rb +434 -75
- data/lib/rigor/environment.rb +28 -13
- data/lib/rigor/inference/expression_typer.rb +15 -0
- data/lib/rigor/inference/fork_map.rb +9 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
- data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
- data/lib/rigor/inference/mutation_widening.rb +30 -25
- data/lib/rigor/inference/narrowing.rb +3 -1
- data/lib/rigor/inference/optimistic_origin.rb +58 -0
- data/lib/rigor/inference/protection_scanner.rb +18 -2
- data/lib/rigor/inference/receiver_alias.rb +57 -0
- data/lib/rigor/inference/scope_indexer.rb +78 -27
- data/lib/rigor/inference/statement_evaluator.rb +40 -2
- data/lib/rigor/language_server/buffer_table.rb +22 -0
- data/lib/rigor/language_server/diagnostic_publisher.rb +203 -4
- data/lib/rigor/language_server/project_context.rb +45 -0
- data/lib/rigor/language_server/publish_batcher.rb +82 -0
- data/lib/rigor/language_server/server.rb +20 -1
- data/lib/rigor/language_server.rb +1 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
- data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
- data/lib/rigor/protection/dependency_closure.rb +59 -0
- data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
- data/lib/rigor/protection/discovery_seed.rb +116 -0
- data/lib/rigor/protection/kill_signature.rb +31 -0
- data/lib/rigor/protection/mutation_cache.rb +355 -0
- data/lib/rigor/protection/mutation_scanner.rb +55 -11
- data/lib/rigor/protection/mutator.rb +26 -4
- data/lib/rigor/runtime/jit.rb +63 -1
- data/lib/rigor/scope/discovery_index.rb +9 -0
- data/lib/rigor/scope.rb +52 -8
- data/lib/rigor/sig_gen/generator.rb +130 -31
- data/lib/rigor/sig_gen/layout_index.rb +6 -0
- data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
- data/lib/rigor/sig_gen/renderer.rb +20 -1
- data/lib/rigor/sig_gen/write_result.rb +6 -4
- data/lib/rigor/sig_gen/writer.rb +12 -0
- data/lib/rigor/sig_gen.rb +1 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
- data/sig/rigor/inference/void_origin.rbs +6 -3
- data/sig/rigor/scope.rbs +8 -0
- metadata +17 -1
data/lib/rigor/bleeding_edge.rb
CHANGED
|
@@ -11,29 +11,86 @@ module Rigor
|
|
|
11
11
|
#
|
|
12
12
|
# The WD2 foundation slice wired the surface end-to-end (`bleeding_edge:` config, the
|
|
13
13
|
# `rigor show-bleedingedge` command, the severity-composition hook in
|
|
14
|
-
# {Configuration::SeverityProfile.resolve}), so a discipline lands as a single
|
|
15
|
-
# entry with no engine plumbing — as the first one,
|
|
14
|
+
# {Configuration::SeverityProfile.resolve}), so a *severity* discipline lands as a single
|
|
15
|
+
# {FEATURES} entry with no engine plumbing — as the first one,
|
|
16
|
+
# `reject-unparseable-signatures`, does.
|
|
17
|
+
#
|
|
18
|
+
# A queued change whose effect is not a severity move — a measurement, an algorithm, or a
|
|
19
|
+
# default that changes while every rule keeps its severity — is a `:behaviour` feature
|
|
20
|
+
# instead. It carries no severity map; its call sites ask
|
|
21
|
+
# {Configuration#bleeding_edge_active?} whether the id is adopted for the run.
|
|
22
|
+
#
|
|
23
|
+
# A `:behaviour` feature MUST NOT change the output of `rigor check` analysis unless its
|
|
24
|
+
# feature id is folded into the analysis-cache identity. Rationale, verified 2026-08-01:
|
|
25
|
+
# severity features are safe because severity is stamped POST-cache — {Analysis::SeverityStamp}
|
|
26
|
+
# (ADR-87 WD4) stores the authored severity and applies the profile + bleeding-edge overrides
|
|
27
|
+
# identically on the miss path and the warm-hit path, so a warm HIT re-resolves under the
|
|
28
|
+
# current selector. A behaviour feature that altered analysis results themselves would poison
|
|
29
|
+
# warm caches across selector changes, because the selector is not part of the cache key. The
|
|
30
|
+
# two queued consumers (#253, #254) change what a separate command measures, not what `check`
|
|
31
|
+
# analyses, so neither is affected.
|
|
16
32
|
#
|
|
17
33
|
# Each feature carries a **stable feature id** — part of the ADR-50 WD1 contract
|
|
18
34
|
# vocabulary: the config, the `show` command, and the eventual CHANGELOG migration note all
|
|
19
|
-
# name the same id, and a feature graduates to default-on at a major
|
|
20
|
-
# {FEATURES}.
|
|
35
|
+
# name the same id, and a feature graduates to default-on at a major (ADR-50 § WD7) by
|
|
36
|
+
# moving from {FEATURES} to {GRADUATED}.
|
|
21
37
|
module BleedingEdge
|
|
38
|
+
# The two kinds a queued change can take. `:severity` composes through
|
|
39
|
+
# {Configuration::SeverityProfile.resolve}; `:behaviour` is read at a call site through
|
|
40
|
+
# {Configuration#bleeding_edge_active?}.
|
|
41
|
+
KINDS = %i[severity behaviour].freeze
|
|
42
|
+
|
|
43
|
+
# The severity map a `:behaviour` feature carries: none.
|
|
44
|
+
NO_SEVERITY_OVERRIDES = {}.freeze
|
|
45
|
+
|
|
22
46
|
# One queued change.
|
|
23
47
|
#
|
|
48
|
+
# The two kinds are exclusive by construction: a `:severity` feature MUST name at least
|
|
49
|
+
# one rule, and a `:behaviour` feature MUST name none — a behaviour switch that also
|
|
50
|
+
# moved a severity would be two changes wearing one id, and the id is what a CHANGELOG
|
|
51
|
+
# migration note and a user's `bleeding_edge:` list both key on.
|
|
52
|
+
#
|
|
24
53
|
# @!attribute id
|
|
25
54
|
# @return [String] the stable feature id (contract vocabulary).
|
|
26
55
|
# @!attribute summary
|
|
27
|
-
# @return [String] a one-line description of what it changes.
|
|
56
|
+
# @return [String] a one-line description of what it changes. For a `:behaviour`
|
|
57
|
+
# feature this is the *whole* explanation — there is no severity diff to read.
|
|
58
|
+
# @!attribute kind
|
|
59
|
+
# @return [Symbol] one of {KINDS}.
|
|
28
60
|
# @!attribute severity_overrides
|
|
29
61
|
# @return [Hash{String => Symbol}] canonical rule id → the severity this feature
|
|
30
62
|
# imposes. Composed *below* the user's own `severity_overrides:` and *above* the
|
|
31
|
-
# active `severity_profile` (see {Configuration::SeverityProfile.resolve}).
|
|
32
|
-
|
|
63
|
+
# active `severity_profile` (see {Configuration::SeverityProfile.resolve}). Empty for
|
|
64
|
+
# a `:behaviour` feature.
|
|
65
|
+
Feature = Data.define(:id, :summary, :kind, :severity_overrides) do
|
|
66
|
+
def initialize(id:, summary:, kind:, severity_overrides: NO_SEVERITY_OVERRIDES)
|
|
67
|
+
raise ArgumentError, "kind must be one of #{KINDS.inspect}, got #{kind.inspect}" unless KINDS.include?(kind)
|
|
68
|
+
|
|
69
|
+
if kind == :severity && severity_overrides.empty?
|
|
70
|
+
raise ArgumentError, "bleeding-edge feature #{id.inspect} is :severity but overrides no rule"
|
|
71
|
+
end
|
|
72
|
+
if kind == :behaviour && !severity_overrides.empty?
|
|
73
|
+
raise ArgumentError, "bleeding-edge feature #{id.inspect} is :behaviour but carries severity_overrides"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
super
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @return [Boolean]
|
|
80
|
+
def severity?
|
|
81
|
+
kind == :severity
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Boolean]
|
|
85
|
+
def behaviour?
|
|
86
|
+
kind == :behaviour
|
|
87
|
+
end
|
|
88
|
+
|
|
33
89
|
def to_h
|
|
34
90
|
{
|
|
35
91
|
"id" => id,
|
|
36
92
|
"summary" => summary,
|
|
93
|
+
"kind" => kind.to_s,
|
|
37
94
|
"severity_overrides" => severity_overrides.transform_values(&:to_s)
|
|
38
95
|
}
|
|
39
96
|
end
|
|
@@ -48,6 +105,7 @@ module Rigor
|
|
|
48
105
|
FEATURES = [
|
|
49
106
|
Feature.new(
|
|
50
107
|
id: "reject-unparseable-signatures",
|
|
108
|
+
kind: :severity,
|
|
51
109
|
summary: "A broken `signature_paths:` RBS set fails the run instead of degrading it silently. An " \
|
|
52
110
|
"unparseable `.rbs` is otherwise skipped with a warning, and a duplicate-declaration " \
|
|
53
111
|
"conflict (a file that parses fine but collides on resolve — typically against Rigor's " \
|
|
@@ -61,6 +119,7 @@ module Rigor
|
|
|
61
119
|
),
|
|
62
120
|
Feature.new(
|
|
63
121
|
id: "use-of-void-value",
|
|
122
|
+
kind: :severity,
|
|
64
123
|
summary: "Using a value recovered from an author-declared `-> void` return in value context (an " \
|
|
65
124
|
"assignment right-hand side, a call receiver, or an argument) becomes a `:warning`. An " \
|
|
66
125
|
"explicit `-> void` is the strongest possible \"do not rely on this return\" signal, so " \
|
|
@@ -70,9 +129,51 @@ module Rigor
|
|
|
70
129
|
severity_overrides: {
|
|
71
130
|
"static.value-use.void" => :warning
|
|
72
131
|
}.freeze
|
|
132
|
+
),
|
|
133
|
+
Feature.new(
|
|
134
|
+
id: "discovery-seeded-mutation-sites",
|
|
135
|
+
kind: :behaviour,
|
|
136
|
+
summary: "`rigor coverage --protection --mutation` (Tier 2) measures against the same cross-file " \
|
|
137
|
+
"project discovery Tier 1 already seeds, instead of an empty scope — both when picking the " \
|
|
138
|
+
"sites and when re-analysing each breakage to decide whether it was caught. A call whose " \
|
|
139
|
+
"receiver is a project class declared in a *sibling* file (`Post.where`, " \
|
|
140
|
+
"`Rigor::Protection::Mutator.new`) then resolves to the type it really has rather than " \
|
|
141
|
+
"`Dynamic`, so the site is measured instead of dropped — and a breakage there can actually " \
|
|
142
|
+
"be caught. This makes the two tiers judge a site " \
|
|
143
|
+
"by one standard, but it ADDS sites to the denominator, so the reported effectiveness ratio " \
|
|
144
|
+
"goes DOWN on the same code — and `--threshold=RATIO` exits 1 when that ratio falls below a " \
|
|
145
|
+
"number pinned in CI. Off by default for that reason: it is a queued change for the next " \
|
|
146
|
+
"major, not a fix you should be opted into mid-release."
|
|
147
|
+
),
|
|
148
|
+
Feature.new(
|
|
149
|
+
id: "dependent-closure-kill-oracle",
|
|
150
|
+
kind: :behaviour,
|
|
151
|
+
summary: "`rigor coverage --protection --mutation` (Tier 2) decides a breakage was caught when the " \
|
|
152
|
+
"diagnostic appears anywhere in the mutated file OR the files that depend on it, instead of " \
|
|
153
|
+
"in the mutated file alone. Changing what a method returns is caught in its *callers* — the " \
|
|
154
|
+
"cross-file reach the analyzer exists for — and that catch is scored as a miss today. The " \
|
|
155
|
+
"measurement re-analyses the dependent closure (ADR-46's dependency graph) against the " \
|
|
156
|
+
"mutated bytes, so those catches count. It can only ADD kills, never remove one, so the " \
|
|
157
|
+
"reported ratio moves up or not at all; a recorded ratio nonetheless stops being comparable " \
|
|
158
|
+
"with one measured without it. It costs roughly a third more wall time per mutant, and on " \
|
|
159
|
+
"the two corpora measured so far (Rigor's own `lib`, redmine `app/models`) it added no " \
|
|
160
|
+
"kills — every surviving breakage there is one the analyzer reports nowhere at all, not one " \
|
|
161
|
+
"it reports in a caller."
|
|
73
162
|
)
|
|
74
163
|
].freeze
|
|
75
164
|
|
|
165
|
+
# ADR-50 § WD7 — the ids that have already graduated to default-on.
|
|
166
|
+
#
|
|
167
|
+
# A feature graduates at a major by moving *here* from {FEATURES} rather than by simply
|
|
168
|
+
# disappearing: {Configuration#bleeding_edge_active?} then answers an unconditional `true`
|
|
169
|
+
# for the id, so a call site still asking about it keeps the graduated behaviour and gate
|
|
170
|
+
# cleanup can lag graduation by as many releases as it takes. The id also stays in the
|
|
171
|
+
# contract vocabulary the CHANGELOG migration note keys on. Entries are removed only once
|
|
172
|
+
# no call site names them.
|
|
173
|
+
#
|
|
174
|
+
# @return [Array<String>]
|
|
175
|
+
GRADUATED = [].freeze
|
|
176
|
+
|
|
76
177
|
module_function
|
|
77
178
|
|
|
78
179
|
# @return [Array<Feature>] the whole overlay.
|
|
@@ -91,6 +192,19 @@ module Rigor
|
|
|
91
192
|
FEATURES.find { |f| f.id == id }
|
|
92
193
|
end
|
|
93
194
|
|
|
195
|
+
# @param id [String]
|
|
196
|
+
# @return [Boolean] whether the id has graduated to default-on ({GRADUATED}).
|
|
197
|
+
def graduated?(id)
|
|
198
|
+
GRADUATED.include?(id)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# @param id [String]
|
|
202
|
+
# @return [Boolean] whether the id names a feature this gem knows at all — queued or
|
|
203
|
+
# graduated. Distinct from "adopted"; see {Configuration#bleeding_edge_active?}.
|
|
204
|
+
def known_id?(id)
|
|
205
|
+
graduated?(id) || FEATURES.any? { |f| f.id == id }
|
|
206
|
+
end
|
|
207
|
+
|
|
94
208
|
# Resolves a normalized `bleeding_edge:` selector (see {Configuration#bleeding_edge}) to
|
|
95
209
|
# the active {Feature} list. Unknown ids in a `list` / `except` selector are simply
|
|
96
210
|
# absent from the overlay and contribute nothing — symmetric with how
|
|
@@ -125,6 +239,17 @@ module Rigor
|
|
|
125
239
|
end.freeze
|
|
126
240
|
end
|
|
127
241
|
|
|
242
|
+
# The ids the active features expose to {Configuration#bleeding_edge_active?}, as a frozen
|
|
243
|
+
# `Set` so a call site on the hot path pays a hash lookup rather than an Array scan.
|
|
244
|
+
# Precomputed once per Configuration; frozen (with frozen members) so the carrier stays
|
|
245
|
+
# `Ractor.shareable?` across the worker boundary.
|
|
246
|
+
#
|
|
247
|
+
# @param selector [Hash] see {#active_features}.
|
|
248
|
+
# @return [Set<String>]
|
|
249
|
+
def active_ids_for(selector)
|
|
250
|
+
Set.new(active_features(selector).map(&:id)).freeze
|
|
251
|
+
end
|
|
252
|
+
|
|
128
253
|
# Feature ids named by a selector that are NOT in the overlay (typo / graduated / from a
|
|
129
254
|
# newer gem). Surfaced by `rigor show-bleedingedge` as a hint; never an error.
|
|
130
255
|
#
|
|
@@ -33,7 +33,12 @@ module Rigor
|
|
|
33
33
|
# content digests. Old entries must read as misses so the first writable run rebuilds them in the new
|
|
34
34
|
# format for a clean one-shot migration (the #57 marker discipline: the bump clears the root and
|
|
35
35
|
# reclaims the unreadable bytes).
|
|
36
|
-
|
|
36
|
+
# v6: #237 — `append_stub_declarations` now emits the declaration KIND each referenced-type stub needs
|
|
37
|
+
# (`interface` / `type` / `module` / `class`) and validates each declaration on its own, so a project
|
|
38
|
+
# whose RBS dangles an interface or type-alias reference gets stubs where an older Rigor discarded the
|
|
39
|
+
# whole batch and cached an env in which those signatures are inert. Same reasoning as v3: the
|
|
40
|
+
# marshalled env is the cached value, so it MUST be rebuilt for the fix to take effect.
|
|
41
|
+
SCHEMA_VERSION = 6
|
|
37
42
|
|
|
38
43
|
# Per-slot entry value objects. Constructors validate enums / required fields and freeze the resulting
|
|
39
44
|
# struct so no caller can mutate after the entry is in a Descriptor.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
require_relative "../version"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Cache
|
|
9
|
+
# Issue #285 — the identity of the ENGINE'S OWN SOURCE, as a cache-key slot.
|
|
10
|
+
#
|
|
11
|
+
# Every Rigor cache whose value is a function of what the analyzer COMPUTES (the ADR-45 run-result
|
|
12
|
+
# cache, the #134 mutation-result cache) keys on `Rigor::VERSION`. For a gem installed from RubyGems
|
|
13
|
+
# that is exact — the version pins the bytes. For anyone running an edited working tree it is not, and
|
|
14
|
+
# the failure is invisible: a warm `rigor check` replays the pre-edit diagnostics verbatim, so a
|
|
15
|
+
# before/after measurement of an engine change reports `0 new, 0 gone` no matter what the change does.
|
|
16
|
+
# That shape of false zero survived into #152's FP evaluation before it was caught.
|
|
17
|
+
#
|
|
18
|
+
# ## The two regimes
|
|
19
|
+
#
|
|
20
|
+
# {.identity} answers `nil` for a tree whose identity `Rigor::VERSION` already fixes — a RubyGems
|
|
21
|
+
# install, recognised by its `<gem_home>/gems/rigortype-<VERSION>` directory layout. The caller then
|
|
22
|
+
# adds NO slot, so a released gem's cache key is byte-identical to the one it had before this module
|
|
23
|
+
# existed and pays not one syscall for it. Everything else — a contributor's checkout, a `bundle add
|
|
24
|
+
# rigor, github:` clone (where two commits share one `Rigor::VERSION`), a `path:` gem — is treated as
|
|
25
|
+
# mutable and gets a content digest of the engine's source tree.
|
|
26
|
+
#
|
|
27
|
+
# The predicate is deliberately POSITIVE about being pinned, so every case it fails to recognise lands
|
|
28
|
+
# on the safe side: an unrecognised release pays one directory walk and one extra cache generation,
|
|
29
|
+
# while an unrecognised checkout would have served a stale answer.
|
|
30
|
+
#
|
|
31
|
+
# ## Why a content digest and not a stat tuple
|
|
32
|
+
#
|
|
33
|
+
# A `(size, mtime, ctime, inode)` walk is ~3× cheaper (measured: 5.4 ms vs 17 ms over this repo's 539
|
|
34
|
+
# engine files), and ADR-87 WD1 trusts exactly that tuple for FRESHNESS. It cannot be used here:
|
|
35
|
+
# {Descriptor::FileEntry} states the rule directly — a stat tuple carries machine-local, per-run
|
|
36
|
+
# nondeterministic data and MUST NOT enter a descriptor used as a cache KEY. On the freshness side a
|
|
37
|
+
# moved tuple falls back to the recorded digest, so a fresh checkout of unchanged content still
|
|
38
|
+
# validates; a KEY has no such fallback, so keying on stat would make every CI run and every branch
|
|
39
|
+
# switch a total miss for the `github:`-tracking users this module exists to protect.
|
|
40
|
+
#
|
|
41
|
+
# ## Never a wrong hit
|
|
42
|
+
#
|
|
43
|
+
# A mutable tree whose digest cannot be computed raises {Unavailable} rather than answering `nil`.
|
|
44
|
+
# Both callers already rescue a malformed key into "no cache for this run", which is the only sound
|
|
45
|
+
# reading: falling back to the version-only key would restore precisely the blind spot being fixed.
|
|
46
|
+
module EngineSource
|
|
47
|
+
# Raised when the tree is not version-pinned AND its source cannot be digested. Deliberately not
|
|
48
|
+
# rescued in this module — see the class doc's last paragraph.
|
|
49
|
+
class Unavailable < StandardError; end
|
|
50
|
+
|
|
51
|
+
# The RubyGems package name. `<name>-<version>` is the install directory layout {.version_pinned?}
|
|
52
|
+
# recognises; the constant is not read from the gemspec because this file loads on the boot-slimming
|
|
53
|
+
# probe path, which must not touch RubyGems' specification machinery.
|
|
54
|
+
GEM_NAME = "rigortype"
|
|
55
|
+
|
|
56
|
+
# Engine source, relative to the gem root. `plugins/` ships inside the same gem and its recognisers
|
|
57
|
+
# move diagnostics exactly as `lib/` does, so an edit there must invalidate too; `examples/`,
|
|
58
|
+
# `apps/` and `tool/` are not loaded by an analysis and stay out.
|
|
59
|
+
SOURCE_DIRECTORIES = %w[lib plugins].freeze
|
|
60
|
+
|
|
61
|
+
# The one directory whose absence means "this is not an engine tree". `plugins/` may legitimately be
|
|
62
|
+
# missing (a slimmed install contributes nothing); a missing `lib/` would silently shrink the digest
|
|
63
|
+
# to whatever else happened to be there, which is exactly the weakening this module forbids.
|
|
64
|
+
REQUIRED_DIRECTORY = "lib"
|
|
65
|
+
|
|
66
|
+
module_function
|
|
67
|
+
|
|
68
|
+
# @return [String] the gem root — the directory holding `lib/`, three levels above this file.
|
|
69
|
+
def root
|
|
70
|
+
@root ||= File.expand_path("../../..", __dir__)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @param root [String] the gem root, defaulted through {.root} so a spec can relocate the tree.
|
|
74
|
+
# @return [String, nil] a digest identifying the engine's current source, or nil when the tree is
|
|
75
|
+
# version-pinned and the caller should add no slot at all.
|
|
76
|
+
# @raise [Unavailable] when a mutable tree's source cannot be read.
|
|
77
|
+
def identity(root = self.root)
|
|
78
|
+
return nil if version_pinned?(root)
|
|
79
|
+
|
|
80
|
+
digest_tree(root)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# {.identity} for THIS process's engine, computed once. Every production caller wants this; the
|
|
84
|
+
# uncached {.identity} stays the computation, and the seam a spec relocates.
|
|
85
|
+
#
|
|
86
|
+
# A memo, not a per-call walk, on two grounds.
|
|
87
|
+
#
|
|
88
|
+
# Cost. #285 could afford the walk because the two callers it wired reached it at most twice per
|
|
89
|
+
# process. Adding {IncrementalSnapshot.fingerprint} (#289) breaks that: {Protection::MutationCache}
|
|
90
|
+
# builds a fingerprint per snapshot-root candidate, so one `rigor coverage --protection --mutation
|
|
91
|
+
# PATH` reaches this five times — 90 ms of walking for a value that cannot differ between the calls.
|
|
92
|
+
#
|
|
93
|
+
# Correctness, which is the stronger reason. The digest exists to identify the engine that COMPUTED a
|
|
94
|
+
# cached value, and that engine is fixed when the process finishes requiring — nothing an edit does to
|
|
95
|
+
# `lib/` mid-run changes the code already running. Re-reading the tree would eventually key values
|
|
96
|
+
# against source that never produced them, so the memo is the more faithful answer, not merely the
|
|
97
|
+
# cheaper one. That is also why a fork-pool worker inheriting it is right: `PoolCoordinator` builds one
|
|
98
|
+
# session on the parent and forks children that copy-on-write inherit its image, so parent and child
|
|
99
|
+
# run the same engine by construction — and the fingerprints are computed on the parent before a pool
|
|
100
|
+
# exists at all. The same reading covers the long-running `rigor lsp` process.
|
|
101
|
+
#
|
|
102
|
+
# {Unavailable} propagates and is deliberately NOT memoised: the ivar is only assigned on success.
|
|
103
|
+
def process_identity
|
|
104
|
+
return @process_identity if defined?(@process_identity)
|
|
105
|
+
|
|
106
|
+
@process_identity = identity
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Discards the {.process_identity} memo; production code MUST NOT call this — a run that recomputed
|
|
110
|
+
# mid-flight would key cached values against source that did not compute them, which is the whole
|
|
111
|
+
# argument for the memo. It exists because a spec process is many logical "processes", and a memo that
|
|
112
|
+
# outlived one example would silently ignore the next one's stub of {.root} / {.identity} and pass for
|
|
113
|
+
# the wrong reason. `spec_helper` calls it before every example so no spec has to know it is here.
|
|
114
|
+
def reset_process_identity!
|
|
115
|
+
remove_instance_variable(:@process_identity) if defined?(@process_identity)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# True when `Rigor::VERSION` already pins this tree's bytes: an immutable RubyGems install, laid out
|
|
119
|
+
# as `…/gems/rigortype-<VERSION>`. The `.git` probe is the belt to that braces — a working tree that
|
|
120
|
+
# somehow occupies a release-shaped path is still a working tree.
|
|
121
|
+
def version_pinned?(root)
|
|
122
|
+
return false unless File.basename(root) == "#{GEM_NAME}-#{Rigor::VERSION}"
|
|
123
|
+
return false unless File.basename(File.dirname(root)) == "gems"
|
|
124
|
+
|
|
125
|
+
!File.exist?(File.join(root, ".git"))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# A SHA-256 over every engine `.rb` file: its ROOT-RELATIVE path (so the digest survives moving or
|
|
129
|
+
# re-cloning the checkout) followed by its bytes, in sorted path order.
|
|
130
|
+
#
|
|
131
|
+
# The walk itself is not memoised — {.process_identity} is where a production caller gets the
|
|
132
|
+
# once-per-process value, and this stays the computation so a spec can point it at another tree.
|
|
133
|
+
def digest_tree(root)
|
|
134
|
+
unless File.directory?(File.join(root, REQUIRED_DIRECTORY))
|
|
135
|
+
raise Unavailable, "#{root} has no #{REQUIRED_DIRECTORY}/ to identify the engine by"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
digest = Digest::SHA256.new
|
|
139
|
+
prefix = "#{root}#{File::SEPARATOR}"
|
|
140
|
+
count = 0
|
|
141
|
+
source_files(root).each do |path|
|
|
142
|
+
digest << path.delete_prefix(prefix) << "\0"
|
|
143
|
+
digest.file(path)
|
|
144
|
+
count += 1
|
|
145
|
+
end
|
|
146
|
+
raise Unavailable, "no engine source found under #{root}" if count.zero?
|
|
147
|
+
|
|
148
|
+
digest.hexdigest
|
|
149
|
+
rescue SystemCallError, IOError => e
|
|
150
|
+
raise Unavailable, "engine source under #{root} could not be read: #{e.message}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Sorted absolute paths of every engine `.rb` file, across the directories that exist.
|
|
154
|
+
def source_files(root)
|
|
155
|
+
SOURCE_DIRECTORIES.flat_map do |relative|
|
|
156
|
+
directory = File.join(root, relative)
|
|
157
|
+
File.directory?(directory) ? Dir.glob(File.join(directory, "**", "*.rb")) : []
|
|
158
|
+
end.sort
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -4,6 +4,8 @@ require "fileutils"
|
|
|
4
4
|
require "digest"
|
|
5
5
|
require "zlib"
|
|
6
6
|
|
|
7
|
+
require_relative "engine_source"
|
|
8
|
+
|
|
7
9
|
module Rigor
|
|
8
10
|
module Cache
|
|
9
11
|
# ADR-46 — disk persistence for the incremental analyzer's per-file state, so a `--incremental` session
|
|
@@ -14,8 +16,16 @@ module Rigor
|
|
|
14
16
|
# this snapshot is loaded UNCONDITIONALLY when the global fingerprint matches — the per-file digests
|
|
15
17
|
# *inside* it drive the incremental re-analysis decision; they do not gate the load. The fingerprint
|
|
16
18
|
# captures the inputs whose change requires a full rebuild — the resolved configuration, the RBS
|
|
17
|
-
# environment, the engine version
|
|
18
|
-
# gem / version change) drops the snapshot and
|
|
19
|
+
# environment, the engine version and (on a checkout) the engine's own source — but NOT the analyzed
|
|
20
|
+
# source contents. A fingerprint mismatch (config / gem / version / engine change) drops the snapshot and
|
|
21
|
+
# forces a full re-analysis, the conservative direction.
|
|
22
|
+
#
|
|
23
|
+
# An engine change drops the WHOLE snapshot rather than part of it, which is a soundness point before it
|
|
24
|
+
# is a simplicity one: every section here except `digests` is a value the analyzer computed, so a changed
|
|
25
|
+
# engine can move any of it — including the dependency edges, where a new engine recording an edge the old
|
|
26
|
+
# one missed would let a recheck skip the very file that needed re-analysing. Retaining the one
|
|
27
|
+
# engine-independent section would not pay either: `digests` is 2.5% of a 2.5 MB snapshot of this repo,
|
|
28
|
+
# and re-deriving it is a file-digest walk costing ~0.2% of the full run it would be saving.
|
|
19
29
|
#
|
|
20
30
|
# Every operation is fault-tolerant: a missing, unreadable, schema-mismatched, fingerprint-mismatched, or
|
|
21
31
|
# corrupt snapshot loads as nil (→ a cold full run), and a write failure is swallowed (→ the next run is
|
|
@@ -38,8 +48,11 @@ module Rigor
|
|
|
38
48
|
# bundle (the per-def parameter-shape / visibility / ancestry surface the declaration-stability gate
|
|
39
49
|
# compares) and WD2 adds `return_summaries` (per-def observed-key return descriptors + mutation-effect
|
|
40
50
|
# sets the behavioural-stability gate compares); a pre-10 blob mismatches the SCHEMA gate and loads as
|
|
41
|
-
# nil (a clean cold rebuild — no migration).
|
|
42
|
-
|
|
51
|
+
# nil (a clean cold rebuild — no migration). 11: ADR-67 WD6c lift adds `param_table` (the inferred-param
|
|
52
|
+
# seed table the run's diagnostics were computed under, diffed on the next recheck to invalidate a
|
|
53
|
+
# callee whose seeds moved because a caller changed); a pre-11 blob mismatches the SCHEMA gate and
|
|
54
|
+
# loads as nil (a clean cold rebuild — no migration).
|
|
55
|
+
SCHEMA = 11
|
|
43
56
|
|
|
44
57
|
# The persisted per-file state.
|
|
45
58
|
# `cache` maps an analyzed file to its diagnostics.
|
|
@@ -68,20 +81,35 @@ module Rigor
|
|
|
68
81
|
# (Marshal-clean type tuples), their `describe(:short)` return descriptors, and the content-mutated
|
|
69
82
|
# parameter positions. A recheck re-evaluates a declaration-stable changed callee at these keys and,
|
|
70
83
|
# when every return + the effects are unchanged, skips its symbol dependents.
|
|
84
|
+
# ADR-67 WD6c lift:
|
|
85
|
+
# `param_table` is the `parameter_inference:` seed table (`[class, method, kind] => {param => Type}`)
|
|
86
|
+
# the run that wrote the snapshot seeded its analysis from — `{}` when the gate is off. A recheck
|
|
87
|
+
# recomputes the table fresh (the pre-pass is whole-project by design) and diffs it against this copy;
|
|
88
|
+
# a changed entry invalidates the callee's file and its symbol dependents. The types are Marshal-clean
|
|
89
|
+
# by the session's per-entry filter; a dropped entry re-checks its callee, the conservative direction.
|
|
71
90
|
Payload = Data.define(:cache, :sources, :digests, :analyzed,
|
|
72
91
|
:symbol_sources, :ancestry_sources, :symbol_fingerprints,
|
|
73
92
|
:missing, :class_decls, :seed_bundles, :plugin_fact_digest,
|
|
74
|
-
:return_summaries)
|
|
93
|
+
:return_summaries, :param_table)
|
|
75
94
|
|
|
76
95
|
# The global fingerprint that gates a snapshot load: a digest of the inputs whose change requires a full
|
|
77
|
-
# rebuild — the engine version + schema, the
|
|
78
|
-
# arguments, e.g. `["lib"]`, NOT the expanded
|
|
79
|
-
#
|
|
80
|
-
# the
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
# under-capture (it would surface as an
|
|
84
|
-
# caller falls back to a non-persisted run.
|
|
96
|
+
# rebuild — the engine version + schema, the engine's own SOURCE when the version does not pin it, the
|
|
97
|
+
# resolved configuration, the analysis **roots** (the path arguments, e.g. `["lib"]`, NOT the expanded
|
|
98
|
+
# file list — so a snapshot is keyed to an invocation's roots but adding / removing a file under them is
|
|
99
|
+
# handled incrementally by the session, not a full rebuild), the resolved gem set (`Gemfile.lock` /
|
|
100
|
+
# `rbs_collection`), and the project's own RBS (`signature_paths` file contents). Built WITHOUT
|
|
101
|
+
# constructing the RBS environment so the warm path can gate the load cheaply, before the costly env
|
|
102
|
+
# build. The `--verify-incremental` gate is the safety net for any under-capture (it would surface as an
|
|
103
|
+
# incremental-vs-full mismatch). Returns nil on any error → the caller falls back to a non-persisted run.
|
|
104
|
+
#
|
|
105
|
+
# Issue #285, wired here by #289 — every value in this snapshot is something the ANALYZER computed, so
|
|
106
|
+
# `Rigor::VERSION` alone is not enough to identify what produced it. A version pins the engine's bytes
|
|
107
|
+
# for a RubyGems install and for nothing else, so on a checkout a warm recheck served diagnostics a
|
|
108
|
+
# pre-edit analyzer had computed: editing `lib/rigor/inference/*.rb` moved no ANALYZED file, the changed
|
|
109
|
+
# set came back empty, and 357 unchanged files replayed their old answers.
|
|
110
|
+
# {EngineSource.process_identity} closes it, and answers nil for a version-pinned tree — which adds no
|
|
111
|
+
# part, so a released gem's fingerprint is byte-identical to the pre-#289 one and its warm snapshots
|
|
112
|
+
# survive the upgrade untouched.
|
|
85
113
|
def self.fingerprint(configuration:, roots:)
|
|
86
114
|
parts = [
|
|
87
115
|
"engine:#{Rigor::VERSION}:#{SCHEMA}",
|
|
@@ -91,8 +119,15 @@ module Rigor
|
|
|
91
119
|
"rbs_collection:#{digest_file_if_present('rbs_collection.lock.yaml')}",
|
|
92
120
|
"sig:#{digest_signature_paths(configuration.signature_paths)}"
|
|
93
121
|
]
|
|
122
|
+
identity = EngineSource.process_identity
|
|
123
|
+
parts << "engine-source:#{identity}" if identity
|
|
94
124
|
Digest::SHA256.hexdigest(parts.join("\x00"))
|
|
95
125
|
rescue StandardError
|
|
126
|
+
# {EngineSource::Unavailable} lands here too, and nil is the answer it requires rather than one it
|
|
127
|
+
# merely tolerates: an engine we cannot identify must DISABLE the snapshot, never fall back to the
|
|
128
|
+
# version-only key that is the blind spot above. Nil does disable it on both sides —
|
|
129
|
+
# {Analysis::IncrementalSession} guards the load AND the save on the fingerprint, so nothing stale is
|
|
130
|
+
# read and no nil-keyed blob is written for the next equally-unidentifiable run to match against.
|
|
96
131
|
nil
|
|
97
132
|
end
|
|
98
133
|
|
|
@@ -124,9 +159,42 @@ module Rigor
|
|
|
124
159
|
# The stored {Payload}, or nil when absent / unreadable / schema or fingerprint mismatch / corrupt.
|
|
125
160
|
# Never raises.
|
|
126
161
|
def load(fingerprint:)
|
|
162
|
+
data = read_data
|
|
163
|
+
return nil unless data && data[:fingerprint] == fingerprint
|
|
164
|
+
|
|
165
|
+
payload_from(data)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Issue #134 slice 2 — the same load against SEVERAL acceptable fingerprints, reading the blob once.
|
|
169
|
+
# A reader that did not itself write the snapshot cannot know which analysis ROOTS it was written under
|
|
170
|
+
# (`rigor check --incremental lib` and a bare `rigor check --incremental` produce different fingerprints
|
|
171
|
+
# for the same project), and `#load` would have to re-inflate + re-unmarshal the whole blob per candidate.
|
|
172
|
+
# The fingerprint that matched is returned alongside the payload so the caller can mix it into ITS own
|
|
173
|
+
# cache key — the snapshot's identity is exactly what "these dependency edges came from that world" means.
|
|
174
|
+
#
|
|
175
|
+
# @param fingerprints [Array<String>] candidates, most-specific first.
|
|
176
|
+
# @return [Array(String, Payload), nil] `[matched fingerprint, payload]`, or nil on any miss.
|
|
177
|
+
def load_any(fingerprints:)
|
|
178
|
+
data = read_data
|
|
179
|
+
return nil if data.nil?
|
|
180
|
+
|
|
181
|
+
matched = Array(fingerprints).compact.find { |candidate| data[:fingerprint] == candidate }
|
|
182
|
+
return nil if matched.nil?
|
|
183
|
+
|
|
184
|
+
[matched, payload_from(data)]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The raw stored Hash when it is present, readable, and schema-current; nil otherwise. Never raises —
|
|
188
|
+
# a missing, corrupt, or stale-schema snapshot is a cold run, not an error (the ADR-45 invariant).
|
|
189
|
+
def read_data
|
|
127
190
|
data = Marshal.load(Zlib::Inflate.inflate(File.binread(@path))) # rubocop:disable Security/MarshalLoad
|
|
128
|
-
|
|
191
|
+
data.is_a?(Hash) && data[:schema] == SCHEMA ? data : nil
|
|
192
|
+
rescue StandardError
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
private :read_data
|
|
129
196
|
|
|
197
|
+
def payload_from(data)
|
|
130
198
|
Payload.new(
|
|
131
199
|
cache: data[:cache], sources: data[:sources],
|
|
132
200
|
digests: data[:digests], analyzed: data[:analyzed],
|
|
@@ -137,11 +205,11 @@ module Rigor
|
|
|
137
205
|
class_decls: data[:class_decls] || {},
|
|
138
206
|
seed_bundles: data[:seed_bundles] || {},
|
|
139
207
|
plugin_fact_digest: data[:plugin_fact_digest],
|
|
140
|
-
return_summaries: data[:return_summaries] || {}
|
|
208
|
+
return_summaries: data[:return_summaries] || {},
|
|
209
|
+
param_table: data[:param_table] || {}
|
|
141
210
|
)
|
|
142
|
-
rescue StandardError
|
|
143
|
-
nil
|
|
144
211
|
end
|
|
212
|
+
private :payload_from
|
|
145
213
|
|
|
146
214
|
# Persist `payload` under `fingerprint`. Writes via a temp file + atomic rename so a concurrent reader
|
|
147
215
|
# never sees a half-written snapshot. Returns true on success, false on any failure (never raises).
|
|
@@ -158,7 +226,8 @@ module Rigor
|
|
|
158
226
|
class_decls: payload.class_decls,
|
|
159
227
|
seed_bundles: payload.seed_bundles,
|
|
160
228
|
plugin_fact_digest: payload.plugin_fact_digest,
|
|
161
|
-
return_summaries: payload.return_summaries
|
|
229
|
+
return_summaries: payload.return_summaries,
|
|
230
|
+
param_table: payload.param_table
|
|
162
231
|
)
|
|
163
232
|
blob = Zlib::Deflate.deflate(raw)
|
|
164
233
|
tmp = "#{@path}.#{Process.pid}.tmp"
|