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.
Files changed (145) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  3. data/data/builtins/ruby_core/array.yml +416 -392
  4. data/data/builtins/ruby_core/file.yml +42 -42
  5. data/data/builtins/ruby_core/hash.yml +302 -302
  6. data/data/builtins/ruby_core/io.yml +191 -191
  7. data/data/builtins/ruby_core/numeric.yml +321 -366
  8. data/data/builtins/ruby_core/proc.yml +124 -124
  9. data/data/builtins/ruby_core/range.yml +21 -21
  10. data/data/builtins/ruby_core/rational.yml +39 -39
  11. data/data/builtins/ruby_core/re.yml +65 -65
  12. data/data/builtins/ruby_core/set.yml +106 -106
  13. data/data/builtins/ruby_core/struct.yml +14 -14
  14. data/data/core_overlay/pathname.rbs +5 -0
  15. data/data/core_overlay/resolv.rbs +31 -0
  16. data/data/core_overlay/string_scanner.rbs +11 -5
  17. data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
  18. data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
  19. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
  20. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
  21. data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
  22. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
  23. data/docs/handbook/01-getting-started.md +22 -34
  24. data/docs/handbook/06-classes.md +1 -1
  25. data/docs/handbook/07-rbs-and-extended.md +76 -101
  26. data/docs/handbook/08-understanding-errors.md +114 -247
  27. data/docs/handbook/09-plugins.md +54 -144
  28. data/docs/handbook/README.md +5 -3
  29. data/docs/handbook/appendix-liskov.md +4 -2
  30. data/docs/handbook/appendix-phpstan.md +14 -7
  31. data/docs/handbook/appendix-steep.md +4 -2
  32. data/docs/handbook/appendix-type-theory.md +3 -1
  33. data/docs/manual/02-cli-reference.md +103 -11
  34. data/docs/manual/03-configuration.md +16 -1
  35. data/docs/manual/04-diagnostics.md +36 -4
  36. data/docs/manual/06-baseline.md +35 -1
  37. data/docs/manual/08-skills.md +6 -1
  38. data/docs/manual/09-editor-integration.md +3 -2
  39. data/docs/manual/12-caching.md +17 -5
  40. data/docs/manual/15-type-protection-coverage.md +122 -1
  41. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  42. data/docs/manual/plugins/rigor-devise.md +4 -2
  43. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  44. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  45. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +21 -2
  46. data/lib/rigor/analysis/check_rules.rb +79 -19
  47. data/lib/rigor/analysis/incremental_session.rb +156 -11
  48. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  49. data/lib/rigor/analysis/run_cache_key.rb +27 -1
  50. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  51. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  52. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  53. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  54. data/lib/rigor/analysis/runner.rb +80 -8
  55. data/lib/rigor/bleeding_edge.rb +132 -7
  56. data/lib/rigor/cache/descriptor.rb +6 -1
  57. data/lib/rigor/cache/engine_source.rb +162 -0
  58. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  59. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  60. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  61. data/lib/rigor/cache/store.rb +99 -24
  62. data/lib/rigor/cli/check_command.rb +61 -34
  63. data/lib/rigor/cli/check_invocation.rb +84 -0
  64. data/lib/rigor/cli/coverage_command.rb +15 -2
  65. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  66. data/lib/rigor/cli/doctor_command.rb +6 -8
  67. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  68. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  69. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  70. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  71. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  72. data/lib/rigor/cli/protection_renderer.rb +13 -0
  73. data/lib/rigor/cli/protection_report.rb +11 -3
  74. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  75. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  76. data/lib/rigor/cli/skill_command.rb +21 -1
  77. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  78. data/lib/rigor/cli/skill_describe.rb +75 -9
  79. data/lib/rigor/configuration.rb +45 -6
  80. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  81. data/lib/rigor/environment/default_libraries.rb +5 -4
  82. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  83. data/lib/rigor/environment/rbs_loader.rb +434 -75
  84. data/lib/rigor/environment.rb +38 -14
  85. data/lib/rigor/inference/expression_typer.rb +15 -0
  86. data/lib/rigor/inference/fork_map.rb +9 -0
  87. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  88. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  89. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  90. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -1
  91. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
  92. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  93. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
  94. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  95. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  96. data/lib/rigor/inference/mutation_widening.rb +30 -25
  97. data/lib/rigor/inference/narrowing.rb +3 -1
  98. data/lib/rigor/inference/optimistic_origin.rb +58 -0
  99. data/lib/rigor/inference/protection_scanner.rb +18 -2
  100. data/lib/rigor/inference/receiver_alias.rb +57 -0
  101. data/lib/rigor/inference/scope_indexer.rb +78 -27
  102. data/lib/rigor/inference/statement_evaluator.rb +40 -2
  103. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  104. data/lib/rigor/language_server/buffer_table.rb +68 -6
  105. data/lib/rigor/language_server/diagnostic_publisher.rb +207 -4
  106. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  107. data/lib/rigor/language_server/project_context.rb +45 -0
  108. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  109. data/lib/rigor/language_server/server.rb +38 -9
  110. data/lib/rigor/language_server.rb +2 -0
  111. data/lib/rigor/plugin/base.rb +29 -2
  112. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  113. data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
  114. data/lib/rigor/protection/dependency_closure.rb +59 -0
  115. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  116. data/lib/rigor/protection/discovery_seed.rb +116 -0
  117. data/lib/rigor/protection/kill_signature.rb +31 -0
  118. data/lib/rigor/protection/mutation_cache.rb +355 -0
  119. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  120. data/lib/rigor/protection/mutator.rb +26 -4
  121. data/lib/rigor/runtime/jit.rb +63 -1
  122. data/lib/rigor/scope/discovery_index.rb +9 -0
  123. data/lib/rigor/scope.rb +52 -8
  124. data/lib/rigor/sig_gen/generator.rb +130 -31
  125. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  126. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  127. data/lib/rigor/sig_gen/renderer.rb +20 -1
  128. data/lib/rigor/sig_gen/write_result.rb +6 -4
  129. data/lib/rigor/sig_gen/writer.rb +195 -47
  130. data/lib/rigor/sig_gen.rb +1 -0
  131. data/lib/rigor/version.rb +1 -1
  132. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  133. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  134. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  135. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  136. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  137. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  138. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  139. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  140. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  141. data/sig/rigor/cache.rbs +6 -0
  142. data/sig/rigor/inference/void_origin.rbs +21 -0
  143. data/sig/rigor/plugin/base.rbs +4 -3
  144. data/sig/rigor/scope.rbs +8 -0
  145. 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
- FileResult = Data.define(:path, :killed, :survived, :sites) do
29
- # Mutations actually analysed (parse-invalid mutants are not counted).
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
- FusedFileResult = Data.define(:path, :type_killed, :test_killed, :sites) do
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
- :invalid
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
- def filter_by_type(mutations, environment:, path:)
79
- base = Rigor::Scope.empty(environment: environment, source_path: path)
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 = Rigor::Scope.empty(environment: environment, source_path: path)
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
 
@@ -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), both entry points are no-ops too.
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(