rigortype 0.3.0 → 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/builtins/ruby_core/array.yml +416 -392
- data/data/builtins/ruby_core/file.yml +42 -42
- data/data/builtins/ruby_core/hash.yml +302 -302
- data/data/builtins/ruby_core/io.yml +191 -191
- data/data/builtins/ruby_core/numeric.yml +321 -366
- data/data/builtins/ruby_core/proc.yml +124 -124
- data/data/builtins/ruby_core/range.yml +21 -21
- data/data/builtins/ruby_core/rational.yml +39 -39
- data/data/builtins/ruby_core/re.yml +65 -65
- data/data/builtins/ruby_core/set.yml +106 -106
- data/data/builtins/ruby_core/struct.yml +14 -14
- data/data/core_overlay/pathname.rbs +5 -0
- data/data/core_overlay/resolv.rbs +31 -0
- data/data/core_overlay/string_scanner.rbs +11 -5
- 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/handbook/01-getting-started.md +22 -34
- data/docs/handbook/06-classes.md +1 -1
- data/docs/handbook/07-rbs-and-extended.md +76 -101
- data/docs/handbook/08-understanding-errors.md +114 -247
- data/docs/handbook/09-plugins.md +54 -144
- data/docs/handbook/README.md +5 -3
- data/docs/handbook/appendix-liskov.md +4 -2
- data/docs/handbook/appendix-phpstan.md +14 -7
- data/docs/handbook/appendix-steep.md +4 -2
- data/docs/handbook/appendix-type-theory.md +3 -1
- data/docs/manual/02-cli-reference.md +103 -11
- data/docs/manual/03-configuration.md +16 -1
- data/docs/manual/04-diagnostics.md +36 -4
- data/docs/manual/06-baseline.md +35 -1
- data/docs/manual/08-skills.md +6 -1
- data/docs/manual/09-editor-integration.md +3 -2
- data/docs/manual/12-caching.md +17 -5
- data/docs/manual/15-type-protection-coverage.md +122 -1
- data/docs/manual/plugins/rigor-actioncable.md +32 -0
- data/docs/manual/plugins/rigor-devise.md +4 -2
- 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/void_value_use_collector.rb +21 -2
- data/lib/rigor/analysis/check_rules.rb +79 -19
- 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 +27 -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 +80 -8
- 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/cache/rbs_cache_producer.rb +11 -1
- data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
- data/lib/rigor/cache/store.rb +99 -24
- data/lib/rigor/cli/check_command.rb +61 -34
- data/lib/rigor/cli/check_invocation.rb +84 -0
- data/lib/rigor/cli/coverage_command.rb +15 -2
- data/lib/rigor/cli/coverage_mutation.rb +242 -11
- data/lib/rigor/cli/doctor_command.rb +6 -8
- 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/cli/skill_command.rb +21 -1
- data/lib/rigor/cli/skill_deep_probe.rb +172 -0
- data/lib/rigor/cli/skill_describe.rb +75 -9
- data/lib/rigor/configuration.rb +45 -6
- data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
- data/lib/rigor/environment/default_libraries.rb +5 -4
- data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
- data/lib/rigor/environment/rbs_loader.rb +434 -75
- data/lib/rigor/environment.rb +38 -14
- 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/overload_selector.rb +6 -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_resolution.rb +6 -3
- data/lib/rigor/language_server/buffer_table.rb +68 -6
- data/lib/rigor/language_server/diagnostic_publisher.rb +207 -4
- data/lib/rigor/language_server/incremental_sync.rb +159 -0
- 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 +38 -9
- data/lib/rigor/language_server.rb +2 -0
- data/lib/rigor/plugin/base.rb +29 -2
- 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 +195 -47
- data/lib/rigor/sig_gen.rb +1 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -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/cache.rbs +6 -0
- data/sig/rigor/inference/void_origin.rbs +21 -0
- data/sig/rigor/plugin/base.rbs +4 -3
- data/sig/rigor/scope.rbs +8 -0
- metadata +23 -3
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "../analysis/plugin_fact_fingerprint"
|
|
7
|
+
require_relative "../cache/descriptor"
|
|
8
|
+
require_relative "../cache/engine_source"
|
|
9
|
+
require_relative "../cache/file_digest"
|
|
10
|
+
require_relative "../cache/incremental_snapshot"
|
|
11
|
+
require_relative "../cache/store"
|
|
12
|
+
require_relative "../version"
|
|
13
|
+
require_relative "mutation_scanner"
|
|
14
|
+
|
|
15
|
+
module Rigor
|
|
16
|
+
module Protection
|
|
17
|
+
# Issue #134 slice 2 — the per-file cache for an ADR-63 Tier 2 measurement.
|
|
18
|
+
#
|
|
19
|
+
# A whole-project `rigor coverage --protection --mutation` run is `Σ(1 + N_f)` single-file analyses (the
|
|
20
|
+
# investigation on #134: ≈94% of the wall time, and rising with the target set). Every one of them is
|
|
21
|
+
# recomputed on every invocation, including for files nobody has touched since the last run. This stores
|
|
22
|
+
# one {MutationScanner::FileResult} per file and serves it back when nothing it could depend on moved.
|
|
23
|
+
#
|
|
24
|
+
# ## What "nothing moved" means — the cache key
|
|
25
|
+
#
|
|
26
|
+
# The KEY carries the inputs that are known before the measurement and are not files:
|
|
27
|
+
#
|
|
28
|
+
# - `Rigor::VERSION` + this class's {SCHEMA} + the {Cache::Descriptor} schema — an engine upgrade is an ABI
|
|
29
|
+
# boundary for a Marshal'd `FileResult` and a measurement-semantics boundary for everything else. A
|
|
30
|
+
# VERSION pins the engine's bytes only for a RELEASED gem, so a checkout adds {Cache::EngineSource}'s
|
|
31
|
+
# source digest alongside it (#285) — without it, editing the analyzer and re-measuring serves the
|
|
32
|
+
# pre-edit kill counts back, which is the exact failure a mutation score exists to detect.
|
|
33
|
+
# - The {Cache::IncrementalSnapshot} fingerprint the dependency edges came from — itself a digest of the
|
|
34
|
+
# resolved configuration, the analysis roots, `Gemfile.lock`, `rbs_collection.lock.yaml`, and the
|
|
35
|
+
# project's own `sig/` contents. This is how a config or `sig/` edit invalidates the measurement. Since
|
|
36
|
+
# #289 that fingerprint carries the engine-source digest too, which makes the slot above redundant on
|
|
37
|
+
# paper — keep it anyway: this key states its own soundness rather than inheriting it from how another
|
|
38
|
+
# cache happens to compose its key today, and a memoised digest costs nothing to repeat.
|
|
39
|
+
# - `--limit` / `--seed` / the site selector: the report is already an estimate under a sample, and without
|
|
40
|
+
# them a `--limit 20` run would silently serve a `--limit 5` result.
|
|
41
|
+
# - The sorted set of ADOPTED bleeding-edge feature ids that change this measurement (#255's principle: a
|
|
42
|
+
# behaviour feature's id enters the cache identity of everything it changes). `discovery-seeded-mutation-
|
|
43
|
+
# sites` moves both the denominator and the kills; `dependent-closure-kill-oracle` moves the kills — and
|
|
44
|
+
# that one additionally BYPASSES this cache entirely (see below).
|
|
45
|
+
# - The whole-project ProjectScan tables (`synthetic_method_index`, `project_patched_methods`) by CONTENT
|
|
46
|
+
# DIGEST, plus the ADR-88 plugin fact-surface digest. Any file may contribute a row to those tables, so
|
|
47
|
+
# they go in by table diff, never by edge attribution — the same shape and the same reason as ADR-67
|
|
48
|
+
# WD6c's `param_seed_invalidation`.
|
|
49
|
+
# - When the discovery seed is active, the seed's identity (see {.seed_digest}).
|
|
50
|
+
#
|
|
51
|
+
# The per-file FILE dependencies ride the ADR-45 record-and-validate side instead: the entry stores a
|
|
52
|
+
# {Cache::Descriptor} of the measured file plus every `deps[A]` edge the ADR-46 snapshot recorded for it,
|
|
53
|
+
# and {Cache::Store#peek_validated} re-validates them against the filesystem on the next run. `deps[A]` was
|
|
54
|
+
# recorded during a full `check` run with cross-file discovery ON, while the Tier-2 oracle re-analyses the
|
|
55
|
+
# mutant with discovery OFF (or seeded — see the seed slot above), so the recorded set is a strict SUPERSET
|
|
56
|
+
# of what the oracle reads: over-invalidating, never under.
|
|
57
|
+
#
|
|
58
|
+
# ## Degradation — never a silent hit
|
|
59
|
+
#
|
|
60
|
+
# The cache disables itself, wholesale, when it cannot prove that much: no reusable snapshot (nobody has
|
|
61
|
+
# run `rigor check --incremental` for this project / these roots), a plugin with no incremental fingerprint
|
|
62
|
+
# surface (ADR-88's opaque case), tables it cannot digest, or `--no-cache`. Per file, a path the snapshot
|
|
63
|
+
# never analysed has no `deps[A]` entry, which means "depends on every project file" — a miss for that file
|
|
64
|
+
# alone. Every degradation is reported on stderr by the caller, because a measurement that quietly stopped
|
|
65
|
+
# caching and a measurement that quietly served a stale number look identical from the outside.
|
|
66
|
+
#
|
|
67
|
+
# `dependent-closure-kill-oracle` (#254) bypasses the cache rather than keying on it: under that oracle a
|
|
68
|
+
# file's verdict depends on the diagnostics of its DEPENDENTS, so validity would need the dependencies of
|
|
69
|
+
# every dependent — a strictly wider edge set than `deps[A]`. The feature is presumptively non-graduating
|
|
70
|
+
# (#254's closing comments), so a permanent bypass is the honest answer, not the key complexity.
|
|
71
|
+
class MutationCache
|
|
72
|
+
# Bumped when the stored value's shape or the key's composition changes, so entries written by an older
|
|
73
|
+
# Rigor read as misses rather than as a differently-meant number.
|
|
74
|
+
SCHEMA = 1
|
|
75
|
+
|
|
76
|
+
PRODUCER_ID = "protection.mutation-file-result"
|
|
77
|
+
|
|
78
|
+
# One live entry per (file, key) at once — the per-file producer shape, so the {Cache::Store#evict!}
|
|
79
|
+
# generation pass leaves it alone and only the size-based LRU pass can touch it.
|
|
80
|
+
GENERATION_CAP = Cache::Store::UNBOUNDED_GENERATIONS
|
|
81
|
+
|
|
82
|
+
# The sampling knobs that make one measurement incomparable with another (see the class doc).
|
|
83
|
+
Sampling = Data.define(:limit, :seed, :site_selector)
|
|
84
|
+
|
|
85
|
+
# Human-readable reasons the caller prints. Each names something the operator can act on.
|
|
86
|
+
NO_SNAPSHOT = "no reusable `rigor check --incremental` snapshot"
|
|
87
|
+
OPAQUE_PLUGIN = "a plugin contributes types with no incremental fingerprint surface"
|
|
88
|
+
UNDIGESTIBLE = "the project-scan tables could not be digested"
|
|
89
|
+
UNIDENTIFIED_ENGINE = "the engine's own source tree could not be digested"
|
|
90
|
+
|
|
91
|
+
class << self
|
|
92
|
+
# @param configuration [Rigor::Configuration]
|
|
93
|
+
# @param roots [Array<String>] the analysis roots to look for a snapshot under, most-specific first
|
|
94
|
+
# (the command's own path arguments, then the configured `paths:`).
|
|
95
|
+
# @param project_scan [Rigor::Analysis::ProjectScan] the prepared whole-project scan.
|
|
96
|
+
# @param sampling [Sampling]
|
|
97
|
+
# @param feature_ids [Array<String>] the ADOPTED bleeding-edge ids that change this measurement.
|
|
98
|
+
# @param seed_inputs [Array<String>, nil] the files the {DiscoverySeed} was built over when it is
|
|
99
|
+
# active, nil when it is not. The CLI stays the only place that knows a feature id exists.
|
|
100
|
+
# @param bypass_reason [String, nil] a caller-side reason to run uncached (`--no-cache`, the closure
|
|
101
|
+
# oracle). Reported verbatim.
|
|
102
|
+
# @return [MutationCache] enabled, or a disabled instance carrying `#reason`.
|
|
103
|
+
def build(configuration:, roots:, project_scan:, sampling:, feature_ids:, seed_inputs: nil,
|
|
104
|
+
bypass_reason: nil)
|
|
105
|
+
return disabled(bypass_reason) if bypass_reason
|
|
106
|
+
return disabled(UNIDENTIFIED_ENGINE) unless engine_identifiable?
|
|
107
|
+
|
|
108
|
+
matched = load_snapshot(configuration: configuration, roots: roots)
|
|
109
|
+
return disabled(NO_SNAPSHOT) if matched.nil?
|
|
110
|
+
|
|
111
|
+
fingerprint, payload = matched
|
|
112
|
+
configs = key_configs(fingerprint: fingerprint, project_scan: project_scan, sampling: sampling,
|
|
113
|
+
feature_ids: feature_ids, seed_inputs: seed_inputs)
|
|
114
|
+
return disabled(configs) if configs.is_a?(String)
|
|
115
|
+
|
|
116
|
+
new(store: build_store(configuration), sources: payload.sources, key_configs: configs)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def disabled(reason)
|
|
120
|
+
new(store: nil, sources: {}, key_configs: [], reason: reason)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def build_store(configuration)
|
|
124
|
+
Cache::Store.new(root: configuration.cache_path, max_bytes: configuration.cache_max_bytes)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The `[fingerprint, payload]` of the on-disk ADR-46 snapshot under any plausible root set, or nil.
|
|
128
|
+
#
|
|
129
|
+
# A snapshot is keyed to the ROOTS its writing run was invoked with, and a measurement has no way to
|
|
130
|
+
# know which form that was: `rigor check --incremental lib` records `["lib"]`, a bare `rigor check
|
|
131
|
+
# --incremental` records the configured `paths:` (absolute), and a measurement of a SUBDIRECTORY
|
|
132
|
+
# shares neither. All four renderings are offered, one blob read (see {IncrementalSnapshot#load_any}).
|
|
133
|
+
#
|
|
134
|
+
# Accepting a snapshot whose roots differ from the measured paths is sound rather than lax: the
|
|
135
|
+
# snapshot contributes per-file dependency edges only, and a file it never analysed simply has no
|
|
136
|
+
# entry — which this cache already treats as a miss for that file.
|
|
137
|
+
def load_snapshot(configuration:, roots:)
|
|
138
|
+
candidates = snapshot_root_candidates(roots, configuration.paths).map do |candidate|
|
|
139
|
+
Cache::IncrementalSnapshot.fingerprint(configuration: configuration, roots: candidate)
|
|
140
|
+
end
|
|
141
|
+
Cache::IncrementalSnapshot.new(root: configuration.cache_path).load_any(fingerprints: candidates)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def snapshot_root_candidates(roots, configured)
|
|
145
|
+
[roots, Array(roots).map { |path| File.expand_path(path) },
|
|
146
|
+
configured, Array(configured).map { |path| relative_to_pwd(path) }]
|
|
147
|
+
.compact.reject(&:empty?).uniq
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# `path` rendered relative to the working directory when it lies underneath it; unchanged otherwise.
|
|
151
|
+
# Shared by the root-candidate list and the per-file snapshot lookup, which face the same skew.
|
|
152
|
+
def relative_to_pwd(path)
|
|
153
|
+
absolute = File.expand_path(path)
|
|
154
|
+
prefix = "#{File.expand_path(Dir.pwd)}#{File::SEPARATOR}"
|
|
155
|
+
absolute.start_with?(prefix) ? absolute.delete_prefix(prefix) : path.to_s
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The shared (path-independent) key slots, or a String reason when an input cannot be digested.
|
|
159
|
+
def key_configs(fingerprint:, project_scan:, sampling:, feature_ids:, seed_inputs:)
|
|
160
|
+
facts = Analysis::PluginFactFingerprint.key_digest(project_scan.plugin_registry)
|
|
161
|
+
return OPAQUE_PLUGIN if facts.nil?
|
|
162
|
+
|
|
163
|
+
tables = table_digest(project_scan)
|
|
164
|
+
return UNDIGESTIBLE if tables.nil?
|
|
165
|
+
|
|
166
|
+
(engine_source_entries + [
|
|
167
|
+
config_entry("engine", "#{Rigor::VERSION}:#{SCHEMA}:#{Cache::Descriptor::SCHEMA_VERSION}"),
|
|
168
|
+
config_entry("snapshot", fingerprint),
|
|
169
|
+
config_entry("sampling", sampling.to_h.sort.map { |k, v| "#{k}=#{v.inspect}" }.join(" ")),
|
|
170
|
+
config_entry("bleeding-edge", feature_ids.map(&:to_s).sort.join(",")),
|
|
171
|
+
config_entry("project-tables", tables),
|
|
172
|
+
config_entry("plugin-facts", facts),
|
|
173
|
+
config_entry("discovery-seed", seed_digest(seed_inputs))
|
|
174
|
+
]).freeze
|
|
175
|
+
rescue Cache::EngineSource::Unavailable
|
|
176
|
+
UNIDENTIFIED_ENGINE
|
|
177
|
+
rescue StandardError
|
|
178
|
+
UNDIGESTIBLE
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Issue #285 — one extra slot pinning the ENGINE'S OWN SOURCE when `Rigor::VERSION` does not (a
|
|
182
|
+
# checkout rather than a released gem). Empty for a released install, so its key composition is
|
|
183
|
+
# unchanged and {SCHEMA} needs no bump; an engine that cannot be identified raises through to the
|
|
184
|
+
# `Unavailable` rescue above and disables the cache, which is the only sound reading of a key slot
|
|
185
|
+
# that could not be computed.
|
|
186
|
+
def engine_source_entries
|
|
187
|
+
identity = Cache::EngineSource.process_identity
|
|
188
|
+
identity.nil? ? [] : [config_entry("engine-source", identity)]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Probed BEFORE the snapshot load, for the reason string rather than for soundness. Since #289
|
|
192
|
+
# {Cache::IncrementalSnapshot.fingerprint} folds the same digest in and answers a bare nil when it
|
|
193
|
+
# cannot, so an unidentifiable engine already reaches the user as a disabled cache — but as
|
|
194
|
+
# {NO_SNAPSHOT}, which tells them to run `rigor check --incremental`, a fix for a different problem.
|
|
195
|
+
# The {Cache::EngineSource::Unavailable} rescue in {key_configs} stays as the backstop for a tree
|
|
196
|
+
# that becomes unreadable between here and there.
|
|
197
|
+
def engine_identifiable?
|
|
198
|
+
Cache::EngineSource.process_identity
|
|
199
|
+
true
|
|
200
|
+
rescue Cache::EngineSource::Unavailable
|
|
201
|
+
false
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def config_entry(key, payload)
|
|
205
|
+
Cache::Descriptor::ConfigEntry.new(key: key, value_hash: Digest::SHA256.hexdigest(payload))
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# A stable content digest of the two whole-project pre-pass tables, over their own plain-data
|
|
209
|
+
# renderings. `nil` when either declines to render (→ the cache disables itself rather than key on a
|
|
210
|
+
# table it cannot see).
|
|
211
|
+
def table_digest(project_scan)
|
|
212
|
+
payload = {
|
|
213
|
+
"synthetic" => project_scan.synthetic_method_index.to_h,
|
|
214
|
+
"patched" => patched_rows(project_scan.project_patched_methods)
|
|
215
|
+
}
|
|
216
|
+
Digest::SHA256.hexdigest(JSON.generate(payload))
|
|
217
|
+
rescue StandardError
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# `ProjectPatchedMethods` has no plain-data rendering of its own; each row is flattened here, with the
|
|
222
|
+
# recorded return type reduced to its short description (the value is a live `Rigor::Type`).
|
|
223
|
+
def patched_rows(patched)
|
|
224
|
+
patched.by_key.map do |(class_name, method_name, kind), entry|
|
|
225
|
+
[class_name.to_s, method_name.to_s, kind.to_s, entry.source_path.to_s, entry.source_line,
|
|
226
|
+
entry.return_type&.describe(:short).to_s]
|
|
227
|
+
end.sort
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# The identity of the cross-file {DiscoverySeed}, as the digest of its INPUTS: the sorted
|
|
231
|
+
# `(path, content digest)` rows of the files it spans. The seed is a deterministic function of those
|
|
232
|
+
# contents and of inputs already in the key (the resolved configuration and the plugin fact surface,
|
|
233
|
+
# which fix the environment the parameter-inference pre-pass runs under), so hashing the inputs is at
|
|
234
|
+
# least as strong an invalidation signal as hashing the built tables would be — and it never has to
|
|
235
|
+
# traverse `discovered_def_nodes`, whose values are live `Prism::Node`s with no stable identity.
|
|
236
|
+
#
|
|
237
|
+
# Because the seed spans the whole measured path set, ANY measured file's edit invalidates EVERY
|
|
238
|
+
# file's entry while the feature is adopted. That is the correct reading of "the seed is an input to
|
|
239
|
+
# every file's measurement", not a defect of this digest.
|
|
240
|
+
def seed_digest(seed_inputs)
|
|
241
|
+
return "off" if seed_inputs.nil?
|
|
242
|
+
|
|
243
|
+
digest = Digest::SHA256.new
|
|
244
|
+
seed_inputs.sort.each { |path| digest << path << "\0" << Cache::FileDigest.hexdigest(path) << "\0" }
|
|
245
|
+
digest.hexdigest
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def initialize(store:, sources:, key_configs:, reason: nil)
|
|
250
|
+
@store = store
|
|
251
|
+
@sources = sources
|
|
252
|
+
@key_configs = key_configs
|
|
253
|
+
@reason = reason
|
|
254
|
+
@hits = 0
|
|
255
|
+
@misses = 0
|
|
256
|
+
@snapshot_keys = {}
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
attr_reader :reason, :hits, :misses
|
|
260
|
+
|
|
261
|
+
def enabled?
|
|
262
|
+
@reason.nil?
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# The cached result for `path`, or nil on any miss (including "this cache is disabled").
|
|
266
|
+
# @return [MutationScanner::FileResult, nil]
|
|
267
|
+
def fetch(path)
|
|
268
|
+
return nil unless enabled?
|
|
269
|
+
# A path the snapshot never analysed has no recorded `deps[A]`, which means "depends on every project
|
|
270
|
+
# file". Answering that from the key alone keeps the read path free of the digesting the WRITE path
|
|
271
|
+
# does — the stored entry's own dependency descriptor is what a hit is validated against.
|
|
272
|
+
return miss if snapshot_key(path).nil?
|
|
273
|
+
|
|
274
|
+
value = @store.peek_validated(producer_id: PRODUCER_ID, key_descriptor: key_descriptor,
|
|
275
|
+
params: params_for(path))
|
|
276
|
+
return miss unless value.is_a?(MutationScanner::FileResult)
|
|
277
|
+
|
|
278
|
+
@hits += 1
|
|
279
|
+
value
|
|
280
|
+
rescue StandardError
|
|
281
|
+
miss
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Persist one measured file. Returns false when nothing was written.
|
|
285
|
+
#
|
|
286
|
+
# A result carrying `harness_errors` (#264 — mutants that blew up INSIDE the measurement harness) is
|
|
287
|
+
# never persisted: that run measured the harness rather than the code, and freezing it into a warm hit
|
|
288
|
+
# would make a transient failure permanent and invisible.
|
|
289
|
+
def store(path, result)
|
|
290
|
+
return false unless enabled?
|
|
291
|
+
return false unless result.is_a?(MutationScanner::FileResult)
|
|
292
|
+
return false if result.harness_errors.positive?
|
|
293
|
+
|
|
294
|
+
dependency = dependencies(path)
|
|
295
|
+
return false if dependency.nil?
|
|
296
|
+
|
|
297
|
+
@store.fetch_or_validate(producer_id: PRODUCER_ID, key_descriptor: key_descriptor,
|
|
298
|
+
params: params_for(path), generation_cap: GENERATION_CAP) do
|
|
299
|
+
[result, dependency]
|
|
300
|
+
end
|
|
301
|
+
true
|
|
302
|
+
rescue StandardError
|
|
303
|
+
false
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
private
|
|
307
|
+
|
|
308
|
+
def miss
|
|
309
|
+
@misses += 1
|
|
310
|
+
nil
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# The record-and-validate dependency descriptor for `path`: the file itself plus every `deps[path]` edge
|
|
314
|
+
# the snapshot recorded. nil when the snapshot never analysed the file (→ "depends on every project
|
|
315
|
+
# file", the degradation this cache refuses to guess at) or a recorded file has since vanished.
|
|
316
|
+
def dependencies(path)
|
|
317
|
+
key = snapshot_key(path)
|
|
318
|
+
sources = key && @sources[key]
|
|
319
|
+
return nil if sources.nil?
|
|
320
|
+
|
|
321
|
+
entries = ([key] + sources.to_a).uniq.map do |dependency|
|
|
322
|
+
Cache::Descriptor::FileEntry.stat(path: dependency, digest: Cache::FileDigest.hexdigest(dependency))
|
|
323
|
+
end
|
|
324
|
+
Cache::Descriptor.new(files: entries)
|
|
325
|
+
rescue StandardError
|
|
326
|
+
nil
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# The snapshot records project-relative paths; a measurement may have been invoked with an absolute one.
|
|
330
|
+
def snapshot_key(path)
|
|
331
|
+
@snapshot_keys.fetch(path) do
|
|
332
|
+
@snapshot_keys[path] =
|
|
333
|
+
if @sources.key?(path)
|
|
334
|
+
path
|
|
335
|
+
else
|
|
336
|
+
relative = relative_to_pwd(path)
|
|
337
|
+
@sources.key?(relative) ? relative : nil
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def relative_to_pwd(path)
|
|
343
|
+
self.class.relative_to_pwd(path)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def key_descriptor
|
|
347
|
+
@key_descriptor ||= Cache::Descriptor.new(configs: @key_configs)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def params_for(path)
|
|
351
|
+
{ "path" => snapshot_key(path) || path }
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
@@ -25,8 +25,17 @@ module Rigor
|
|
|
25
25
|
# A surviving mutation site — a breakage Rigor did not catch.
|
|
26
26
|
SurvivingSite = Data.define(:line, :receiver, :method_name, :operator)
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
# `harness_errors` (#264) — mutants where the harness itself failed (an exception `classify` rescued),
|
|
29
|
+
# counted separately from a parse-invalid mutant. Defaults to 0 so every existing caller that builds a
|
|
30
|
+
# `FileResult` without the new keyword — including the early-return-on-empty path below and the fork-scan
|
|
31
|
+
# `ParseError` branch — keeps constructing one exactly as before.
|
|
32
|
+
FileResult = Data.define(:path, :killed, :survived, :sites, :harness_errors) do
|
|
33
|
+
def initialize(path:, killed:, survived:, sites:, harness_errors: 0)
|
|
34
|
+
super
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Mutations actually analysed (parse-invalid mutants are not counted; neither are harness_errors — a
|
|
38
|
+
# harness-level failure measures the harness, not the code, exactly like a parse-invalid mutant).
|
|
30
39
|
def total = killed + survived
|
|
31
40
|
|
|
32
41
|
# Effectiveness ratio; a file with no type-relevant mutation is vacuously fully effective (no breakage
|
|
@@ -42,7 +51,13 @@ module Rigor
|
|
|
42
51
|
# "doubly-protected" bucket into `type_killed`: a mutant the type checker already kills never reaches the
|
|
43
52
|
# suite, because the static net already suffices and re-running the suite to learn a test *would also*
|
|
44
53
|
# catch it is wasted work. So the observed buckets are three.
|
|
45
|
-
|
|
54
|
+
# `harness_errors` (#264) — same bucket as {FileResult}, kept out of `total`/`ratio` exactly like a
|
|
55
|
+
# parse-invalid mutant. Defaults to 0 for the same backward-compatibility reason.
|
|
56
|
+
FusedFileResult = Data.define(:path, :type_killed, :test_killed, :sites, :harness_errors) do
|
|
57
|
+
def initialize(path:, type_killed:, test_killed:, sites:, harness_errors: 0)
|
|
58
|
+
super
|
|
59
|
+
end
|
|
60
|
+
|
|
46
61
|
# The unprotected sites (neither a type nor a test caught the breakage).
|
|
47
62
|
def unprotected = sites.size
|
|
48
63
|
def total = type_killed + test_killed + unprotected
|
|
@@ -63,14 +78,31 @@ module Rigor
|
|
|
63
78
|
# Seam 2). `:biteable` (default) keeps only concrete-type sites Rigor can
|
|
64
79
|
# bite; `:all` also mutates Dynamic-receiver dispatch sites — use only
|
|
65
80
|
# with a {TestSuiteOracle} (the fused overlay), never the diagnostic path.
|
|
81
|
+
# @param base_scope [Rigor::Scope, nil] the scope site selection judges
|
|
82
|
+
# anchor types against, built once by the caller (see
|
|
83
|
+
# {Mutator#anchor_base_scope}). `nil` — the default — keeps the bare
|
|
84
|
+
# single-file empty scope. A caller that seeds cross-file discovery
|
|
85
|
+
# passes it here; this class stays free of {Rigor::Configuration} and of
|
|
86
|
+
# bleeding-edge feature ids, which live one layer up in the CLI.
|
|
87
|
+
# @param discovery_seed [Hash, nil] issue #260 — the SAME cross-file table
|
|
88
|
+
# set `base_scope` was built from, threaded to the default
|
|
89
|
+
# {DiagnosticOracle} so an admitted cross-file site is one the oracle can
|
|
90
|
+
# also kill at. Pass both or neither: a `base_scope` without it measures
|
|
91
|
+
# sites no mutation can ever break. Ignored when `oracle` is supplied
|
|
92
|
+
# (that caller owns its oracle's knowledge).
|
|
93
|
+
# rubocop:disable Metrics/ParameterLists -- every one is an independently-defaulted collaborator or knob;
|
|
94
|
+
# bundling them into an options object would only move the list behind a name.
|
|
66
95
|
def initialize(configuration:, environment:, project_scan:, limit: nil, seed: 1, oracle: nil,
|
|
67
|
-
site_selector: :biteable)
|
|
96
|
+
site_selector: :biteable, base_scope: nil, discovery_seed: nil)
|
|
97
|
+
# rubocop:enable Metrics/ParameterLists
|
|
68
98
|
@environment = environment
|
|
69
99
|
@limit = limit
|
|
70
100
|
@seed = seed
|
|
71
101
|
@site_selector = site_selector
|
|
102
|
+
@base_scope = base_scope
|
|
72
103
|
@oracle = oracle || DiagnosticOracle.new(
|
|
73
|
-
configuration: configuration, environment: environment, project_scan: project_scan
|
|
104
|
+
configuration: configuration, environment: environment, project_scan: project_scan,
|
|
105
|
+
discovery_seed: discovery_seed
|
|
74
106
|
)
|
|
75
107
|
end
|
|
76
108
|
|
|
@@ -84,15 +116,17 @@ module Rigor
|
|
|
84
116
|
|
|
85
117
|
baseline = @oracle.baseline(source: source, path: path)
|
|
86
118
|
killed = 0
|
|
119
|
+
harness_errors = 0
|
|
87
120
|
sites = []
|
|
88
121
|
kept.each do |mut|
|
|
89
122
|
case classify(source, path, mut, baseline)
|
|
90
123
|
when :killed then killed += 1
|
|
91
124
|
when :survived then sites << surviving_site(mut)
|
|
125
|
+
when :harness_error then harness_errors += 1
|
|
92
126
|
# :invalid — a parse-broken mutant; not a measurement, skip it.
|
|
93
127
|
end
|
|
94
128
|
end
|
|
95
|
-
FileResult.new(path: path, killed: killed, survived: sites.size, sites: sites)
|
|
129
|
+
FileResult.new(path: path, killed: killed, survived: sites.size, sites: sites, harness_errors: harness_errors)
|
|
96
130
|
end
|
|
97
131
|
|
|
98
132
|
# ADR-70 — the fused static∪dynamic measurement. Runs the type pass (the {DiagnosticOracle}); for every
|
|
@@ -109,6 +143,7 @@ module Rigor
|
|
|
109
143
|
baseline = @oracle.baseline(source: source, path: path)
|
|
110
144
|
type_killed = 0
|
|
111
145
|
test_killed = 0
|
|
146
|
+
harness_errors = 0
|
|
112
147
|
sites = []
|
|
113
148
|
kept.each do |mut|
|
|
114
149
|
case classify(source, path, mut, baseline)
|
|
@@ -119,10 +154,12 @@ module Rigor
|
|
|
119
154
|
else
|
|
120
155
|
sites << fused_site(mut, :none)
|
|
121
156
|
end
|
|
157
|
+
when :harness_error then harness_errors += 1
|
|
122
158
|
# :invalid — a parse-broken mutant; not a measurement, skip it.
|
|
123
159
|
end
|
|
124
160
|
end
|
|
125
|
-
FusedFileResult.new(path: path, type_killed: type_killed, test_killed: test_killed, sites: sites
|
|
161
|
+
FusedFileResult.new(path: path, type_killed: type_killed, test_killed: test_killed, sites: sites,
|
|
162
|
+
harness_errors: harness_errors)
|
|
126
163
|
end
|
|
127
164
|
|
|
128
165
|
private
|
|
@@ -135,21 +172,28 @@ module Rigor
|
|
|
135
172
|
muts = mutator.mutations
|
|
136
173
|
kept =
|
|
137
174
|
if @site_selector == :all
|
|
138
|
-
mutator.dispatch_site_mutations(muts, environment: @environment, path: path)
|
|
175
|
+
mutator.dispatch_site_mutations(muts, environment: @environment, path: path, base_scope: @base_scope)
|
|
139
176
|
else
|
|
140
|
-
mutator.filter_by_type(muts, environment: @environment, path: path).first
|
|
177
|
+
mutator.filter_by_type(muts, environment: @environment, path: path, base_scope: @base_scope).first
|
|
141
178
|
end
|
|
142
179
|
sample(kept)
|
|
143
180
|
end
|
|
144
181
|
|
|
182
|
+
# #264 — a parse-invalid mutant (`:invalid`, the mutation itself does not produce parseable Ruby) and a
|
|
183
|
+
# harness-level failure (`:harness_error`, an exception raised *while measuring* an otherwise-parseable
|
|
184
|
+
# mutant — e.g. the oracle's re-analysis blowing up) are different failure modes and MUST be told apart:
|
|
185
|
+
# only the latter is a harness defect worth counting and surfacing. Both stay OUT of `killed + survived`
|
|
186
|
+
# exactly as before (containment is unchanged) — #264 is about visibility, not about admitting either
|
|
187
|
+
# bucket into the denominator.
|
|
145
188
|
def classify(source, path, mut, baseline)
|
|
146
189
|
mutant_source = mut.apply(source)
|
|
147
190
|
return :invalid unless Prism.parse(mutant_source).success?
|
|
148
191
|
|
|
149
192
|
@oracle.killed?(mutant_source: mutant_source, path: path, baseline: baseline) ? :killed : :survived
|
|
150
193
|
rescue StandardError
|
|
151
|
-
# A harness-level failure on one mutant must not abort the file
|
|
152
|
-
:
|
|
194
|
+
# A harness-level failure on one mutant must not abort the file — but it must not vanish either
|
|
195
|
+
# (#264): the caller counts this bucket separately and the CLI surfaces it.
|
|
196
|
+
:harness_error
|
|
153
197
|
end
|
|
154
198
|
|
|
155
199
|
def sample(mutations)
|
|
@@ -75,8 +75,11 @@ module Rigor
|
|
|
75
75
|
# unresolved/probe-failed type KEEPS the mutation, so the filter never hides a kill it is unsure about — it
|
|
76
76
|
# only removes provably-Dynamic sites. Returns [kept, dropped_count]. Builds the scope index from THIS
|
|
77
77
|
# mutator's parse so anchor node identity matches the keys.
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
#
|
|
79
|
+
# @param base_scope [Rigor::Scope, nil] a pre-seeded scope to judge anchors against (see
|
|
80
|
+
# {#anchor_base_scope}); nil builds the bare empty scope, which is the shipped default.
|
|
81
|
+
def filter_by_type(mutations, environment:, path:, base_scope: nil)
|
|
82
|
+
base = anchor_base_scope(environment, path, base_scope)
|
|
80
83
|
index = Rigor::Inference::ScopeIndexer.index(@parse.value, default_scope: base)
|
|
81
84
|
cache = {}
|
|
82
85
|
kept = mutations.select do |mut|
|
|
@@ -93,8 +96,8 @@ module Rigor
|
|
|
93
96
|
# {#filter_by_type} hides exactly the Dynamic sites a test-suite consumer most wants to probe: where Rigor
|
|
94
97
|
# cannot bite, a test is the only protection. Use only with a {TestSuiteOracle} — at a Dynamic site the
|
|
95
98
|
# type pass can never kill, so without the test axis these are all noise.
|
|
96
|
-
def dispatch_site_mutations(mutations, environment:, path:)
|
|
97
|
-
base =
|
|
99
|
+
def dispatch_site_mutations(mutations, environment:, path:, base_scope: nil)
|
|
100
|
+
base = anchor_base_scope(environment, path, base_scope)
|
|
98
101
|
index = Rigor::Inference::ScopeIndexer.index(@parse.value, default_scope: base)
|
|
99
102
|
cache = {}
|
|
100
103
|
mutations.select do |mut|
|
|
@@ -108,6 +111,25 @@ module Rigor
|
|
|
108
111
|
|
|
109
112
|
private
|
|
110
113
|
|
|
114
|
+
# The scope the anchor types are judged against.
|
|
115
|
+
#
|
|
116
|
+
# `base_scope` is nil on the shipped path, and the result is then the bare empty scope this file has always
|
|
117
|
+
# used: a single-file view in which a constant receiver declared in a *sibling* file reads `Dynamic`, so its
|
|
118
|
+
# dispatch site is dropped from the Tier-2 denominator. The caller may instead hand down a scope already
|
|
119
|
+
# carrying the cross-file discovery Tier 1 seeds (`discovered_classes` + `param_inferred_types`), in which
|
|
120
|
+
# case the same site resolves to the type it really has and is measured — `coverage --protection --mutation`
|
|
121
|
+
# does that behind the `discovery-seeded-mutation-sites` bleeding-edge feature (#253).
|
|
122
|
+
#
|
|
123
|
+
# Deliberately a plain parameter: this class knows nothing about {Rigor::Configuration} or feature ids. The
|
|
124
|
+
# gate lives in the CLI layer, which is the only layer that has a Configuration to ask.
|
|
125
|
+
#
|
|
126
|
+
# `source_path` is per-file, so a shared base scope is re-stamped here rather than rebuilt per path.
|
|
127
|
+
def anchor_base_scope(environment, path, base_scope)
|
|
128
|
+
return Rigor::Scope.empty(environment: environment, source_path: path) if base_scope.nil?
|
|
129
|
+
|
|
130
|
+
base_scope.with_source_path(path)
|
|
131
|
+
end
|
|
132
|
+
|
|
111
133
|
def walk(node, &blk)
|
|
112
134
|
return if node.nil?
|
|
113
135
|
|
data/lib/rigor/runtime/jit.rb
CHANGED
|
@@ -28,7 +28,14 @@ module Rigor
|
|
|
28
28
|
# Opt-out: `RIGOR_DISABLE_YJIT=1` makes every entry point a no-op (the pin
|
|
29
29
|
# the byte-identical-diagnostics gate uses). If YJIT is already enabled (the
|
|
30
30
|
# user passed `--yjit` / `RUBY_YJIT_ENABLE=1`) or is unavailable (a build
|
|
31
|
-
# without YJIT, or a non-MRI Ruby),
|
|
31
|
+
# without YJIT, or a non-MRI Ruby), every entry point is a no-op too.
|
|
32
|
+
#
|
|
33
|
+
# `fork` copies only the calling thread, so the {enable_after} sleeper dies
|
|
34
|
+
# in every child of a fork pool — a worker would run its whole slice
|
|
35
|
+
# interpreted while the parent JITs work it no longer does. {rearm_after_fork}
|
|
36
|
+
# is the child-side entry point that repairs this, and it carries the
|
|
37
|
+
# *remaining* deadline rather than restarting the window; see its own
|
|
38
|
+
# comment for why that distinction is worth the bookkeeping.
|
|
32
39
|
module Jit
|
|
33
40
|
# Opt-out switch: `RIGOR_DISABLE_YJIT=1` disables both entry points.
|
|
34
41
|
DISABLE_ENV = "RIGOR_DISABLE_YJIT"
|
|
@@ -90,6 +97,18 @@ module Rigor
|
|
|
90
97
|
return nil if disabled?
|
|
91
98
|
return nil if RubyVM::YJIT.enabled?
|
|
92
99
|
|
|
100
|
+
# Record when the window actually closes, so a child forked partway
|
|
101
|
+
# through it can re-arm with what is LEFT ({rearm_after_fork}) instead
|
|
102
|
+
# of restarting the wait. Monotonic, so a wall-clock adjustment mid-run
|
|
103
|
+
# cannot move the deadline; and monotonic time survives `fork`, so the
|
|
104
|
+
# value stays comparable in a child.
|
|
105
|
+
#
|
|
106
|
+
# Thread-safety: this is written on the arming thread before any `fork`
|
|
107
|
+
# and read only in children *after* the fork, each of which sees a
|
|
108
|
+
# private copy of the parent's memory. No reader and writer ever share a
|
|
109
|
+
# process, so the plain attribute needs no lock.
|
|
110
|
+
@deadline_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
|
|
111
|
+
|
|
93
112
|
thread = Thread.new do
|
|
94
113
|
sleep(seconds)
|
|
95
114
|
enable_now
|
|
@@ -102,6 +121,49 @@ module Rigor
|
|
|
102
121
|
thread
|
|
103
122
|
end
|
|
104
123
|
|
|
124
|
+
# Re-arms the deferred-YJIT deadline inside a freshly forked child,
|
|
125
|
+
# carrying the *remaining* window rather than restarting it.
|
|
126
|
+
#
|
|
127
|
+
# `fork` copies only the calling thread, so the parent's {enable_after}
|
|
128
|
+
# sleeper never fires in a child: without re-arming, a worker runs its
|
|
129
|
+
# entire slice interpreted however long that takes, and a fork pool can be
|
|
130
|
+
# slower than sequential (measured: `coverage --protection --mutation
|
|
131
|
+
# lib/rigor/analysis` at 37s sequential against 67s at eight workers).
|
|
132
|
+
# Re-arming with a *fresh full* deadline fixes that but overshoots the
|
|
133
|
+
# other way — the parent has normally already burned part of the window
|
|
134
|
+
# before it forks, so every child sits out the whole window again. That is
|
|
135
|
+
# pure warm-up loss, and it is paid once per worker, so it grows with the
|
|
136
|
+
# worker count exactly where a pool is supposed to pay off.
|
|
137
|
+
#
|
|
138
|
+
# The remaining window is what the amortization contract actually says:
|
|
139
|
+
# the deadline exists to keep short *runs* off the JIT, and a child is a
|
|
140
|
+
# continuation of the parent's run, not a new one. When the deadline has
|
|
141
|
+
# already passed, the run has proven itself long — enable straight away.
|
|
142
|
+
#
|
|
143
|
+
# Re-arming goes through {enable_after}, so the child records the same
|
|
144
|
+
# absolute deadline it inherited and a grandchild fork carries it too.
|
|
145
|
+
#
|
|
146
|
+
# @return [Thread, nil] the child's deadline thread; nil when YJIT was
|
|
147
|
+
# enabled immediately, and nil when the call is a no-op up front
|
|
148
|
+
# (unavailable / opted out / already enabled — a child forked after the
|
|
149
|
+
# parent enabled inherits the enabled state and has nothing to do).
|
|
150
|
+
def rearm_after_fork
|
|
151
|
+
return nil unless available?
|
|
152
|
+
return nil if disabled?
|
|
153
|
+
return nil if RubyVM::YJIT.enabled?
|
|
154
|
+
|
|
155
|
+
# No recorded deadline means nothing in this process ever armed one — a
|
|
156
|
+
# caller that forks without going through a CLI entry point, or a spec.
|
|
157
|
+
# Degrade to a fresh full window, never to no YJIT at all.
|
|
158
|
+
return enable_after(deadline_seconds) if @deadline_at.nil?
|
|
159
|
+
|
|
160
|
+
remaining = @deadline_at - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
161
|
+
return enable_after(remaining) if remaining.positive?
|
|
162
|
+
|
|
163
|
+
enable_now
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
|
|
105
167
|
# The resolved {enable_after} deadline: {DEADLINE_ENV} when it parses to a
|
|
106
168
|
# non-negative float, else {DEFAULT_DEADLINE_SECONDS}.
|
|
107
169
|
#
|
|
@@ -37,6 +37,15 @@ module Rigor
|
|
|
37
37
|
EMPTY_TABLE = {}.freeze
|
|
38
38
|
private_constant :EMPTY_NODE_TABLE, :EMPTY_TABLE
|
|
39
39
|
|
|
40
|
+
# The third value a `discovered_methods` entry can hold, beside `:instance` and `:singleton`. One name may
|
|
41
|
+
# legitimately be defined on both sides of the same class (`def helper` plus a `class << self` twin), and the
|
|
42
|
+
# table is keyed by name alone — so before this existed the second `def` overwrote the first's kind and
|
|
43
|
+
# `Scope#discovered_method?` answered false for a method that is right there in the source. That is a false
|
|
44
|
+
# `call.undefined-method` on ordinary Ruby (#239), which outranks any worst-case reading (AGENTS.md
|
|
45
|
+
# § "Implementation Guidelines"). Writers promote to this instead of clobbering; readers treat it as matching
|
|
46
|
+
# either kind.
|
|
47
|
+
METHOD_KIND_BOTH = :both
|
|
48
|
+
|
|
40
49
|
# The shared all-empty index `Scope.empty` (and every scope that never sees a seeding pass) points at — one
|
|
41
50
|
# allocation per process.
|
|
42
51
|
EMPTY = new(
|