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
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
|
|
5
|
+
require_relative "../analysis/buffer_binding"
|
|
6
|
+
require_relative "../analysis/runner"
|
|
7
|
+
require_relative "diagnostic_oracle"
|
|
8
|
+
require_relative "discovery_seed"
|
|
9
|
+
require_relative "kill_signature"
|
|
10
|
+
|
|
11
|
+
module Rigor
|
|
12
|
+
module Protection
|
|
13
|
+
# Issue #254 — the ADR-69 Seam 1 kill oracle that judges a mutant by the WHOLE dependent closure.
|
|
14
|
+
#
|
|
15
|
+
# {DiagnosticOracle} re-analyses the mutated file alone, so the most valuable catch Rigor delivers is
|
|
16
|
+
# scored as a miss: change what a method returns and the diagnostic lands in its *callers*, which is
|
|
17
|
+
# exactly the cross-file reach the analyzer exists for. This oracle counts a kill when a new diagnostic
|
|
18
|
+
# (against the clean baseline of the same file set) appears anywhere in `{mutated} ∪ dependents[mutated]`
|
|
19
|
+
# — the ADR-46 reverse edge, supplied by {DependencyClosure}.
|
|
20
|
+
#
|
|
21
|
+
# **It is strictly additive, by construction.** The mutated file's verdict is delegated to a real
|
|
22
|
+
# {DiagnosticOracle}, built with exactly the knowledge the shipped oracle would have had (the
|
|
23
|
+
# `discovery-seeded-mutation-sites` seed when that feature is adopted, nothing when it is not), and only
|
|
24
|
+
# when that says "survived" is the closure consulted. So this feature moves `killed` in ONE direction and
|
|
25
|
+
# cannot silently re-decide a mutant the current oracle already kills. That separation is deliberate: what
|
|
26
|
+
# the oracle KNOWS is #253/#260's axis, and mixing the two here made the measurement uninterpretable —
|
|
27
|
+
# on redmine `app/models` an early build lost 11 kills to the richer knowledge while gaining none from the
|
|
28
|
+
# closure, and the two effects were indistinguishable in the total.
|
|
29
|
+
#
|
|
30
|
+
# **The mutant's bytes are never on the measured file's disk.** For the closure half they are written to a
|
|
31
|
+
# process-private temp file and bound to the measured path through {Analysis::BufferBinding} — the #146
|
|
32
|
+
# editor seam, whose whole purpose is "analyse THESE bytes at THAT logical path". The binding reaches three
|
|
33
|
+
# places that would otherwise read the file as it sits on disk:
|
|
34
|
+
#
|
|
35
|
+
# 1. the per-file parse (`Runner#parse_source` resolves through the binding);
|
|
36
|
+
# 2. the discovery tables the dependents resolve the mutated `def`s through
|
|
37
|
+
# ({DiscoverySeed.tables_for_buffer} — the change-detection half: the mutated path's digest is the
|
|
38
|
+
# mutant's, so its bundle is invalidated and re-walked);
|
|
39
|
+
# 3. diagnostic locations, which stay on the LOGICAL path, so a signature computed against a mutant is
|
|
40
|
+
# comparable with the baseline's.
|
|
41
|
+
#
|
|
42
|
+
# Miss any of them and every dependent reads the clean bytes, no diagnostic ever appears outside the
|
|
43
|
+
# mutated file, and the run reports a plausible number that measured nothing new.
|
|
44
|
+
#
|
|
45
|
+
# Cost. The closure is consulted only for the mutants the mutated file did not already kill (≈30% of them
|
|
46
|
+
# on Rigor's own `lib`), and each costs one analysis per dependent (mean 1.85 there) plus a ≈15ms seed
|
|
47
|
+
# re-fold. Every per-mutant analysis keeps `cache_store: nil`: a `--threshold` CI gate must never be
|
|
48
|
+
# handed a stale clean hit.
|
|
49
|
+
class ClosureKillOracle
|
|
50
|
+
# The clean baseline of one measured file, kept as TWO sets rather than their union. The mutated file's
|
|
51
|
+
# half must be compared against exactly what the single-file oracle would compare against — a diagnostic
|
|
52
|
+
# the closure baseline happens to carry at the mutated path (it analyses that file under cross-file
|
|
53
|
+
# knowledge the single-file run does not have) must not mask a kill the shipped oracle would report.
|
|
54
|
+
Baseline = Data.define(:own, :dependents)
|
|
55
|
+
|
|
56
|
+
# @param configuration [Rigor::Configuration]
|
|
57
|
+
# @param environment [Rigor::Environment] built once by the caller.
|
|
58
|
+
# @param project_scan [Rigor::Analysis::ProjectScan] built once by the caller; adopted per analysis
|
|
59
|
+
# through `prebuilt:`, exactly as {DiagnosticOracle} does.
|
|
60
|
+
# @param paths [Array<String>] the measured file set, in canonical order (the seed's span: a class
|
|
61
|
+
# declared outside it stays unknown, as it does for Tier 1's seed and for {DiscoverySeed}).
|
|
62
|
+
# @param dependents [Hash{String => Array<String>}] {DependencyClosure} map, restricted to `paths`.
|
|
63
|
+
# @param seed_bundles [Hash{String => Hash}] {DiscoverySeed.bundles} over the same `paths`.
|
|
64
|
+
# @param discovery_seed [Hash, nil] the `discovery-seeded-mutation-sites` seed when that feature is also
|
|
65
|
+
# adopted, nil otherwise. It goes to the delegated {DiagnosticOracle} verbatim, so the mutated file's
|
|
66
|
+
# verdict is byte-for-byte the verdict that feature combination produces without this one; its
|
|
67
|
+
# `param_inferred_types` slot additionally rides the per-mutant closure seed, so an admitted site is
|
|
68
|
+
# judged with the knowledge that admitted it (issue #260's amended decision). The table is not
|
|
69
|
+
# refreshed per mutant — the collector is a whole-project pre-pass, and one mutated method body does
|
|
70
|
+
# not justify re-running it thousands of times.
|
|
71
|
+
def initialize(configuration:, environment:, project_scan:, paths:, dependents:, seed_bundles:,
|
|
72
|
+
discovery_seed: nil)
|
|
73
|
+
@configuration = configuration
|
|
74
|
+
@environment = environment
|
|
75
|
+
@project_scan = project_scan
|
|
76
|
+
@paths = paths
|
|
77
|
+
@dependents = dependents
|
|
78
|
+
@seed_bundles = seed_bundles
|
|
79
|
+
@discovery_seed = discovery_seed
|
|
80
|
+
@param_inferred_types = discovery_seed && discovery_seed[:param_inferred_types]
|
|
81
|
+
@single = DiagnosticOracle.new(
|
|
82
|
+
configuration: configuration, environment: environment, project_scan: project_scan,
|
|
83
|
+
discovery_seed: discovery_seed
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The clean baselines a mutant must add a diagnostic to: the mutated file's (the shipped oracle's own,
|
|
88
|
+
# unchanged) and the dependents'. Computed once per measured file by the caller ({MutationScanner}),
|
|
89
|
+
# never per mutant. The dependents' half is bound through the same buffer machinery a mutant is, so the
|
|
90
|
+
# clean and mutant runs of the closure differ in exactly one input — the bytes.
|
|
91
|
+
def baseline(source:, path:)
|
|
92
|
+
Baseline.new(
|
|
93
|
+
own: @single.baseline(source: source, path: path),
|
|
94
|
+
dependents: dependents_signatures(source, path)
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Killed iff the mutant introduces a diagnostic the baseline did not carry — in the mutated file (the
|
|
99
|
+
# shipped verdict), or, failing that, in any dependent of it (what this feature adds).
|
|
100
|
+
def killed?(mutant_source:, path:, baseline:)
|
|
101
|
+
return true if @single.killed?(mutant_source: mutant_source, path: path, baseline: baseline.own)
|
|
102
|
+
|
|
103
|
+
dependents = @dependents[path] || []
|
|
104
|
+
return false if dependents.empty?
|
|
105
|
+
|
|
106
|
+
dependents_signatures(mutant_source, path).any? { |sig| !baseline.dependents.include?(sig) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The file set a kill is looked for in: the mutated file plus its measured dependents.
|
|
110
|
+
def closure_for(path)
|
|
111
|
+
[path, *(@dependents[path] || [])]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
# The diagnostic signatures the dependents of `path` report while `source` stands in for it. Empty (and
|
|
117
|
+
# analysis-free) when nothing depends on the file — 120 of Rigor's own 349 `lib` files.
|
|
118
|
+
def dependents_signatures(source, path)
|
|
119
|
+
dependents = @dependents[path] || []
|
|
120
|
+
return Set.new if dependents.empty?
|
|
121
|
+
|
|
122
|
+
with_mutant(source, path) do |buffer, seed|
|
|
123
|
+
KillSignature.signatures_of(analyse(dependents, buffer, seed))
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Binds `source` to `path` for the duration of the block, yielding the binding and the seed tables
|
|
128
|
+
# rebuilt against it. One temp file per process (never per mutant): the digest that invalidates the
|
|
129
|
+
# mutated file's bundle is taken over the file's CONTENT, and the per-run digest memo is not installed
|
|
130
|
+
# out here, so rewriting one path in place cannot serve a stale answer.
|
|
131
|
+
def with_mutant(source, path)
|
|
132
|
+
File.binwrite(mutant_file.path, source)
|
|
133
|
+
buffer = Analysis::BufferBinding.new(logical_path: path, physical_path: mutant_file.path)
|
|
134
|
+
yield(buffer, seed_for(buffer))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The process-private mutant file. Created lazily, and re-created after a fork ({CLI::MutationForkScan}
|
|
138
|
+
# workers must not share one path), which the pid guard detects.
|
|
139
|
+
def mutant_file
|
|
140
|
+
return @mutant_file if @mutant_file && @mutant_pid == Process.pid
|
|
141
|
+
|
|
142
|
+
@mutant_pid = Process.pid
|
|
143
|
+
@mutant_file = Tempfile.new(["rigor-mutant-", ".rb"])
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def seed_for(buffer)
|
|
147
|
+
tables = DiscoverySeed.tables_for_buffer(paths: @paths, bundles: @seed_bundles, buffer: buffer)
|
|
148
|
+
return tables if @param_inferred_types.nil? || @param_inferred_types.empty?
|
|
149
|
+
|
|
150
|
+
tables.merge(param_inferred_types: @param_inferred_types).freeze
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# One analysis of `paths` with the mutant bound. `prebuilt:` keeps the RBS environment + whole-project
|
|
154
|
+
# pre-pass the caller paid for once; `discovery_seed:` (issue #260's runner seam) is what carries the
|
|
155
|
+
# cross-file knowledge into a prebuilt run, and here it is the MUTANT-refreshed table set — without it a
|
|
156
|
+
# dependent resolves the `def` still on disk and no mutation could ever be visible to it.
|
|
157
|
+
#
|
|
158
|
+
# `analyze_only:` is load-bearing, not an optimisation. A `buffer:` alone selects editor-mode option A,
|
|
159
|
+
# whose analysed set IS the buffer's single logical path — every other file is read by the pre-passes
|
|
160
|
+
# and emits nothing. A closure oracle built that way would report zero cross-file kills and look
|
|
161
|
+
# entirely plausible doing it. Passing both selects option B (#146), where the closure wins and the
|
|
162
|
+
# buffer is one member of it.
|
|
163
|
+
def analyse(paths, buffer, seed)
|
|
164
|
+
Analysis::Runner.new(
|
|
165
|
+
configuration: @configuration, environment: @environment, prebuilt: @project_scan,
|
|
166
|
+
cache_store: nil, collect_stats: false, buffer: buffer, discovery_seed: seed, analyze_only: paths
|
|
167
|
+
).run(paths).diagnostics
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../analysis/runner"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Protection
|
|
7
|
+
# Issue #254 — the **dependent closure** the Tier-2 kill oracle re-analyses: for each measured file, the
|
|
8
|
+
# measured files that read a declaration or a method body from it.
|
|
9
|
+
#
|
|
10
|
+
# ADR-46 already records exactly this edge, but only under `record_dependencies: true` — an ordinary
|
|
11
|
+
# analysis (and every per-mutant analysis) records nothing. So the map is built ONCE, by one recording
|
|
12
|
+
# pass over the measured paths, and then read for every mutant. This is the one place ADR-46's REVERSE
|
|
13
|
+
# index is the right tool (the forward edge is a cache-key input; #134 needs that one).
|
|
14
|
+
#
|
|
15
|
+
# Cost, measured on Rigor's own `lib` (349 files, 8 workers, warm cache): ≈4.3s for the recording pass —
|
|
16
|
+
# roughly one extra `rigor check` next to a multi-minute mutation run, paid once. The pass CANNOT reuse
|
|
17
|
+
# the caller's `prebuilt:` {Analysis::ProjectScan}: `Runner#ensure_project_discovery` is a no-op under
|
|
18
|
+
# `prebuilt:`, so a prebuilt recording run would resolve nothing cross-file and record an empty graph —
|
|
19
|
+
# a silently empty closure, which is precisely the failure mode this feature exists to remove. It
|
|
20
|
+
# therefore runs its own pre-passes, sharing only the environment and the (read-only) cache store.
|
|
21
|
+
#
|
|
22
|
+
# Not fail-soft on purpose: an exception here aborts the run rather than degrading the measurement to
|
|
23
|
+
# "no dependents", which would look exactly like a plausible effectiveness number.
|
|
24
|
+
module DependencyClosure
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# @param paths [Array<String>] the measured file set, in caller order.
|
|
28
|
+
# @param configuration [Rigor::Configuration]
|
|
29
|
+
# @param environment [Rigor::Environment] built once by the caller.
|
|
30
|
+
# @param cache_store [Rigor::Cache::Store, nil] threaded to the recording run only (its RBS-env and
|
|
31
|
+
# plugin-producer tiers); the per-mutant analyses stay `cache_store: nil` regardless.
|
|
32
|
+
# @param workers [Integer] fork-pool workers for the recording pass (the pool records per worker and
|
|
33
|
+
# marshals the records back, so a pooled graph equals the sequential one).
|
|
34
|
+
# @return [Hash{String => Array<String>}] frozen `path => sorted dependents`, restricted to `paths`.
|
|
35
|
+
def build(paths:, configuration:, environment:, cache_store: nil, workers: 0)
|
|
36
|
+
runner = Analysis::Runner.new(
|
|
37
|
+
configuration: configuration, environment: environment, cache_store: cache_store,
|
|
38
|
+
collect_stats: false, record_dependencies: true, workers: workers
|
|
39
|
+
)
|
|
40
|
+
runner.run(paths)
|
|
41
|
+
index(runner.file_dependents, paths)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Restricts a raw {Analysis::Runner#file_dependents} map to the measured set: a dependent outside it is
|
|
45
|
+
# not being measured, so re-analysing it would report a diagnostic against a file the run never
|
|
46
|
+
# baselined. Self-edges are dropped (the mutated file is the closure's own head), and each list is
|
|
47
|
+
# sorted so a `--threshold` gate reads the same number whatever order the recording pass finished in.
|
|
48
|
+
#
|
|
49
|
+
# @return [Hash{String => Array<String>}]
|
|
50
|
+
def index(dependents, paths)
|
|
51
|
+
measured = Set.new(paths)
|
|
52
|
+
paths.to_h do |path|
|
|
53
|
+
list = (dependents[path] || []).select { |dep| measured.include?(dep) && dep != path }
|
|
54
|
+
[path, list.sort.freeze]
|
|
55
|
+
end.freeze
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../analysis/runner"
|
|
4
|
+
require_relative "kill_signature"
|
|
4
5
|
|
|
5
6
|
module Rigor
|
|
6
7
|
module Protection
|
|
@@ -15,21 +16,28 @@ module Rigor
|
|
|
15
16
|
# disk write). Passing `prebuilt:` disables the run-result cache (whose key digests the *disk* file), so a
|
|
16
17
|
# mutant is never served a stale clean hit.
|
|
17
18
|
class DiagnosticOracle
|
|
18
|
-
|
|
19
|
+
# @param discovery_seed [Hash, nil] issue #260 — the cross-file discovery tables (see {DiscoverySeed})
|
|
20
|
+
# the per-mutant analysis is seeded with, threaded through to `Runner.new(discovery_seed:)`. Without
|
|
21
|
+
# it the runner's `prebuilt:` path carries frozen-empty discovery tables, so a receiver whose class is
|
|
22
|
+
# declared in a *sibling* file reads `Dynamic` and NO mutation at that site can produce a diagnostic —
|
|
23
|
+
# a site the caller's site filter may nonetheless have admitted, and then measured as an unkillable
|
|
24
|
+
# survivor. nil (the default) keeps the shipped single-file oracle.
|
|
25
|
+
def initialize(configuration:, environment:, project_scan:, discovery_seed: nil)
|
|
19
26
|
@configuration = configuration
|
|
20
27
|
@environment = environment
|
|
21
28
|
@project_scan = project_scan
|
|
29
|
+
@discovery_seed = discovery_seed
|
|
22
30
|
end
|
|
23
31
|
|
|
24
32
|
# The clean per-file baseline: the diagnostic signatures a mutant must add to count as killed. Computed
|
|
25
33
|
# once per file by the caller.
|
|
26
34
|
def baseline(source:, path:)
|
|
27
|
-
analyse(source, path)
|
|
35
|
+
KillSignature.signatures_of(analyse(source, path))
|
|
28
36
|
end
|
|
29
37
|
|
|
30
38
|
# Killed iff the mutant introduces a diagnostic not in `baseline`.
|
|
31
39
|
def killed?(mutant_source:, path:, baseline:)
|
|
32
|
-
analyse(mutant_source, path).any? { |d| !baseline.include?(
|
|
40
|
+
analyse(mutant_source, path).any? { |d| !baseline.include?(KillSignature.of(d)) }
|
|
33
41
|
end
|
|
34
42
|
|
|
35
43
|
private
|
|
@@ -37,13 +45,9 @@ module Rigor
|
|
|
37
45
|
def analyse(source, path)
|
|
38
46
|
Rigor::Analysis::Runner.new(
|
|
39
47
|
configuration: @configuration, environment: @environment, prebuilt: @project_scan,
|
|
40
|
-
cache_store: nil, collect_stats: false
|
|
48
|
+
cache_store: nil, collect_stats: false, discovery_seed: @discovery_seed
|
|
41
49
|
).run_source(source: source, path: path).diagnostics
|
|
42
50
|
end
|
|
43
|
-
|
|
44
|
-
def sig(diagnostic)
|
|
45
|
-
[diagnostic.rule, diagnostic.path, diagnostic.line, diagnostic.column, diagnostic.message]
|
|
46
|
-
end
|
|
47
51
|
end
|
|
48
52
|
end
|
|
49
53
|
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../inference/parameter_inference_collector"
|
|
4
|
+
require_relative "../inference/scope_indexer"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module Protection
|
|
8
|
+
# Issue #260 — the cross-file knowledge Tier 2 hands to BOTH halves of a mutation measurement.
|
|
9
|
+
#
|
|
10
|
+
# Site selection and the kill oracle used to disagree by construction: the site filter judged a receiver
|
|
11
|
+
# against a scope seeded with `discovered_classes`, so `Account.find` (with `Account` declared in a sibling
|
|
12
|
+
# file) entered the denominator — while the oracle re-analysed each mutant through
|
|
13
|
+
# `Runner.new(prebuilt:)#run_source`, whose cross-file discovery tables stay frozen-empty, so the same
|
|
14
|
+
# receiver read `Dynamic` and NO mutation at that site could ever produce a diagnostic. Every admitted
|
|
15
|
+
# cross-file site was therefore a guaranteed survivor: on Rigor's own `lib`, +2,183 sites bought +2,187
|
|
16
|
+
# survivors and moved `killed` by −4.
|
|
17
|
+
#
|
|
18
|
+
# The answer (issue #260's recorded decision) is one table set, built once, threaded to both sides: the
|
|
19
|
+
# {Mutator} base scope and {DiagnosticOracle}'s runner seam. This module builds that table set — the FULL
|
|
20
|
+
# discovery bundle (`discovered_def_nodes`, `discovered_methods`, superclasses, includes, …), not just the
|
|
21
|
+
# class-identity slice site selection needed, because resolving `find` on `singleton(Account)` is what makes
|
|
22
|
+
# the site killable.
|
|
23
|
+
#
|
|
24
|
+
# The shape is a plain Hash of {Scope::DiscoveryIndex} slot names, which is exactly what both consumers take:
|
|
25
|
+
# `scope.discovery.with(**tables)` on one side, `Runner#project_scope_seed_tables` on the other.
|
|
26
|
+
#
|
|
27
|
+
# Built ONCE on the parent, before {CLI::MutationForkScan} forks, so children copy-on-write inherit it.
|
|
28
|
+
# Nothing here crosses the marshal boundary (only per-file results do) — which matters, because the def-node
|
|
29
|
+
# tables hold live `Prism::Node`s.
|
|
30
|
+
module DiscoverySeed
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# @param paths [Array<String>] the measured file set; the seed spans these files only, exactly as Tier 1's
|
|
34
|
+
# seed does. A class declared outside them stays unknown.
|
|
35
|
+
# @param environment [Rigor::Environment] the plugin-aware environment, built once by the caller.
|
|
36
|
+
# @param target_ruby [String] Prism parse version for the parameter-inference pre-pass.
|
|
37
|
+
# @param workers [Integer] worker count for that pre-pass (0 keeps it sequential).
|
|
38
|
+
# @return [Hash{Symbol => Object}] frozen seed tables; empty when the paths yield nothing.
|
|
39
|
+
def build(paths:, environment:, target_ruby:, workers: 0)
|
|
40
|
+
tables = discovery_tables(paths)
|
|
41
|
+
params = Inference::ParameterInferenceCollector.collect(
|
|
42
|
+
files: paths, environment: environment, target_ruby: target_ruby, workers: workers
|
|
43
|
+
)
|
|
44
|
+
tables[:param_inferred_types] = params unless params.empty?
|
|
45
|
+
tables.freeze
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Issue #254 — the per-file discovery *bundles* (ADR-85 WD2) the closure kill oracle re-folds once per
|
|
49
|
+
# mutant. A bundle is one file's isolated contribution to the tables above, plus the content digest that
|
|
50
|
+
# decides whether it is still valid; folding the whole set reconstructs exactly what {#discovery_tables}
|
|
51
|
+
# builds. Built ONCE on the parent (before {CLI::MutationForkScan} forks, so children copy-on-write
|
|
52
|
+
# inherit it), and cheap: ≈0.36s over Rigor's own 349-file `lib`.
|
|
53
|
+
#
|
|
54
|
+
# @param paths [Array<String>] the measured file set, in canonical (caller) order.
|
|
55
|
+
# @return [Hash{String => Hash}] per-path bundles, the input {#tables_for_buffer} re-folds.
|
|
56
|
+
def bundles(paths:)
|
|
57
|
+
Inference::ScopeIndexer.discovered_project_index_incremental(paths, seed_bundles: {}).fetch(:bundles)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Issue #254 — the seed tables for ONE mutant: every measured file's bundle folded back unchanged
|
|
61
|
+
# EXCEPT the file `buffer` binds, which is re-walked from the buffer's bytes.
|
|
62
|
+
#
|
|
63
|
+
# This is where the substitution reaches change detection rather than only the analysis. The
|
|
64
|
+
# incremental pass digests each path through the binding, so the mutated file's digest is the MUTANT's
|
|
65
|
+
# and never matches its cached bundle — it is re-walked, and every dependent then reads the mutated
|
|
66
|
+
# `def` bodies out of the seed instead of the ones still on disk. Without it a closure re-analysis
|
|
67
|
+
# would resolve the clean method, produce no new diagnostic anywhere but the mutated file itself, and
|
|
68
|
+
# report a plausible-looking effectiveness number that measured nothing new.
|
|
69
|
+
#
|
|
70
|
+
# ≈15ms over 349 files (one re-walk plus a whole-set fold), against ≈210ms for one mutant's analysis.
|
|
71
|
+
#
|
|
72
|
+
# @param paths [Array<String>] the measured file set, in the same order {#bundles} was built from.
|
|
73
|
+
# @param bundles [Hash{String => Hash}] that bundle set.
|
|
74
|
+
# @param buffer [Rigor::Analysis::BufferBinding] the mutant binding (logical path → mutant bytes).
|
|
75
|
+
# @return [Hash{Symbol => Object}] frozen seed tables.
|
|
76
|
+
def tables_for_buffer(paths:, bundles:, buffer:)
|
|
77
|
+
index = Inference::ScopeIndexer.discovered_project_index_incremental(
|
|
78
|
+
paths, seed_bundles: bundles, buffer: buffer
|
|
79
|
+
)
|
|
80
|
+
seed_tables(index).freeze
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The whole-project discovery tables, from the single-walk combined pass (one parse per file, both
|
|
84
|
+
# collectors driven over the same tree) that {Analysis::Runner::ProjectPrePasses#discover} uses. Empty
|
|
85
|
+
# tables are dropped so an empty seed stays `{}` and every consumer's "seed nothing" branch keeps working.
|
|
86
|
+
#
|
|
87
|
+
# `discovered_class_sources` is deliberately NOT carried: the runner itself seeds it only under dependency
|
|
88
|
+
# recording (it is read by the ancestry accessors solely to record cross-file edges), and Tier 2 never
|
|
89
|
+
# records.
|
|
90
|
+
def discovery_tables(paths)
|
|
91
|
+
seed_tables(Inference::ScopeIndexer.discovered_project_index_for_paths(paths))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Maps a `{ classes:, def_index: }` discovery index onto the {Scope::DiscoveryIndex} slot names both
|
|
95
|
+
# consumers take. Shared by the whole-walk ({#discovery_tables}) and bundle-fold ({#tables_for_buffer})
|
|
96
|
+
# producers so a mutant's seed and the parent's seed can never disagree about shape.
|
|
97
|
+
def seed_tables(index)
|
|
98
|
+
def_index = index.fetch(:def_index)
|
|
99
|
+
tables = { discovered_classes: index.fetch(:classes) }
|
|
100
|
+
%i[
|
|
101
|
+
def_nodes singleton_def_nodes def_sources singleton_def_sources superclasses includes
|
|
102
|
+
method_visibilities methods data_member_layouts struct_member_layouts
|
|
103
|
+
].each do |slot|
|
|
104
|
+
tables[seed_key(slot)] = def_index.fetch(slot)
|
|
105
|
+
end
|
|
106
|
+
tables.reject { |_, table| table.nil? || table.empty? }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The def-index slot names match the {Scope::DiscoveryIndex} ones under a `discovered_` prefix, except the
|
|
110
|
+
# two member-layout tables, which carry the same name on both sides.
|
|
111
|
+
def seed_key(slot)
|
|
112
|
+
slot.to_s.end_with?("member_layouts") ? slot : :"discovered_#{slot}"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Protection
|
|
5
|
+
# ADR-69 Seam 1 — the identity a *kill* is judged by, shared by every diagnostic-based oracle.
|
|
6
|
+
#
|
|
7
|
+
# A mutant is killed when re-analysis produces a diagnostic the clean baseline did not carry, so the
|
|
8
|
+
# comparison needs an identity that is stable across two runs of the same code and distinct for two
|
|
9
|
+
# genuinely different reports. `path` is part of it deliberately: the closure oracle (issue #254) pools
|
|
10
|
+
# the baseline of several files into one set, and the same rule firing at the same line of a *different*
|
|
11
|
+
# file must not be mistaken for the baseline's.
|
|
12
|
+
#
|
|
13
|
+
# Lifted out of {DiagnosticOracle} when {ClosureKillOracle} arrived: two oracles disagreeing about what
|
|
14
|
+
# "the same diagnostic" means would show up only as an unexplained kill-count delta between them.
|
|
15
|
+
module KillSignature
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# @param diagnostic [Rigor::Analysis::Diagnostic]
|
|
19
|
+
# @return [Array] the comparison key.
|
|
20
|
+
def of(diagnostic)
|
|
21
|
+
[diagnostic.rule, diagnostic.path, diagnostic.line, diagnostic.column, diagnostic.message]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param diagnostics [Enumerable<Rigor::Analysis::Diagnostic>]
|
|
25
|
+
# @return [Set<Array>] the signature set of `diagnostics`.
|
|
26
|
+
def signatures_of(diagnostics)
|
|
27
|
+
diagnostics.to_set { |diagnostic| of(diagnostic) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|