rigortype 0.3.1 → 0.3.3
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/04-diagnostics.md +8 -0
- 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/always_truthy_condition_collector.rb +8 -0
- data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
- data/lib/rigor/analysis/check_rules.rb +107 -28
- data/lib/rigor/analysis/incremental_session.rb +156 -11
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
- data/lib/rigor/analysis/rule_catalog.rb +5 -1
- 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/analysis/severity_stamp.rb +2 -1
- 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/anonymous_meta_class.rb +81 -0
- data/lib/rigor/inference/expression_typer.rb +62 -4
- data/lib/rigor/inference/fork_map.rb +47 -4
- 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 +158 -12
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +234 -11
- 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/method_dispatcher.rb +41 -10
- data/lib/rigor/inference/mutation_widening.rb +30 -25
- data/lib/rigor/inference/narrowing.rb +3 -1
- data/lib/rigor/inference/optimistic_origin.rb +123 -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 +197 -55
- data/lib/rigor/inference/singleton_object_constant.rb +63 -0
- data/lib/rigor/inference/statement_evaluator.rb +55 -4
- 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/node_rule_walk.rb +6 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
- data/lib/rigor/protection/closure_kill_oracle.rb +190 -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 +114 -10
- 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/source/node_walker.rb +12 -0
- data/lib/rigor/type/anonymous_class_name.rb +40 -0
- data/lib/rigor/type/nominal.rb +5 -2
- data/lib/rigor/type/singleton.rb +4 -1
- 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 +12 -0
- data/sig/rigor.rbs +4 -0
- metadata +21 -1
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
require "English"
|
|
4
4
|
require "prism"
|
|
5
5
|
|
|
6
|
+
require_relative "../cache/file_digest"
|
|
7
|
+
require_relative "../protection/closure_kill_oracle"
|
|
8
|
+
require_relative "../protection/dependency_closure"
|
|
9
|
+
require_relative "../protection/discovery_seed"
|
|
10
|
+
require_relative "../protection/mutation_cache"
|
|
11
|
+
|
|
6
12
|
module Rigor
|
|
7
13
|
class CLI
|
|
8
14
|
# ADR-63 Tier 2 + ADR-70 — the mutation-effectiveness and fused static∪dynamic protection paths, factored out of
|
|
@@ -10,8 +16,118 @@ module Rigor
|
|
|
10
16
|
# (using `@out` / `@err` / `@argv` / `collect_paths` / `determine_protection_exit` and the Protection +
|
|
11
17
|
# LanguageServer collaborators the command requires).
|
|
12
18
|
module CoverageMutation
|
|
19
|
+
# ADR-50 § WD2 — the bleeding-edge feature id gating the Tier-2 discovery seed (#253). Named here rather
|
|
20
|
+
# than inlined at the call site: {Configuration#bleeding_edge_active?} raises on an id absent from the
|
|
21
|
+
# registry, so the constant is the single place a rename has to reach.
|
|
22
|
+
DISCOVERY_SEEDED_MUTATION_SITES = "discovery-seeded-mutation-sites"
|
|
23
|
+
|
|
24
|
+
# ADR-50 § WD2 — the bleeding-edge feature id gating the Tier-2 dependent-closure kill oracle (#254).
|
|
25
|
+
# Same reason as above for naming it here rather than inlining the string.
|
|
26
|
+
DEPENDENT_CLOSURE_KILL_ORACLE = "dependent-closure-kill-oracle"
|
|
27
|
+
|
|
28
|
+
# #264 — the "loud" threshold for a rescued-harness-failure count. Below it, a rescued mutant reads as
|
|
29
|
+
# the occasional transient this issue's `harness_errors` bucket exists to make VISIBLE, not to eliminate
|
|
30
|
+
# (see {Protection::MutationScanner#classify}); at or above it, the pattern looks less like noise and
|
|
31
|
+
# more like a harness defect worth stopping to investigate before trusting the ratio. Deliberately NOT
|
|
32
|
+
# the `determine_protection_exit` gate: a `--threshold` build is pinned to the killed/survived ratio
|
|
33
|
+
# today, and turning a harness-side symptom into a new way for that same command to exit non-zero would
|
|
34
|
+
# silently change semantics CI already depends on. A loud stderr warning (plus the unconditional JSON
|
|
35
|
+
# field) is the visibility this issue asks for without redefining what "the build is red" means.
|
|
36
|
+
HARNESS_ERROR_WARN_FLOOR = 3
|
|
37
|
+
|
|
38
|
+
# Issue #134 slice 2 — the bleeding-edge ids whose adoption changes what a Tier-2 measurement REPORTS,
|
|
39
|
+
# and which therefore enter the identity of anything cached about it (#255: a behaviour feature's id is
|
|
40
|
+
# part of the cache identity of everything it changes). Both are named above; this is the ordered set the
|
|
41
|
+
# cache key reads, so a feature added to Tier 2 later has exactly one place to register.
|
|
42
|
+
MUTATION_BEHAVIOUR_FEATURES = [DISCOVERY_SEEDED_MUTATION_SITES, DEPENDENT_CLOSURE_KILL_ORACLE].freeze
|
|
43
|
+
|
|
13
44
|
private
|
|
14
45
|
|
|
46
|
+
# @param report [MutationProtectionReport, FusedProtectionReport] — both expose `total_harness_errors`.
|
|
47
|
+
def warn_harness_errors(report)
|
|
48
|
+
count = report.total_harness_errors
|
|
49
|
+
return if count < HARNESS_ERROR_WARN_FLOOR
|
|
50
|
+
|
|
51
|
+
@err.puts(
|
|
52
|
+
"coverage: #{count} mutants failed inside the measurement harness (\"harness_errors\", " \
|
|
53
|
+
"at/above the #{HARNESS_ERROR_WARN_FLOOR}-mutant floor) — excluded from the ratio like a " \
|
|
54
|
+
"parse-invalid mutant, but this many suggests a harness defect rather than one-off noise. " \
|
|
55
|
+
"Investigate before trusting --threshold on this run."
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The cross-file knowledge Tier 2 measures with — the #253 gate, and the ONLY place in this feature that
|
|
60
|
+
# knows a feature id exists.
|
|
61
|
+
#
|
|
62
|
+
# Returns nil (today's behaviour: an unseeded per-file view on both halves of the measurement) unless the
|
|
63
|
+
# project has adopted `discovery-seeded-mutation-sites`. With it adopted, returns the table set
|
|
64
|
+
# {Protection::DiscoverySeed} builds over the scanned `paths` — class identity so a receiver whose class
|
|
65
|
+
# is declared in a *sibling* file resolves instead of reading `Dynamic`, the def / ancestry index so the
|
|
66
|
+
# method on it resolves too, and `param_inferred_types` (ADR-67 WD3) for an inferred-parameter receiver.
|
|
67
|
+
#
|
|
68
|
+
# One table set, two consumers, deliberately (issue #260): site selection admits a site and the kill
|
|
69
|
+
# oracle can act on it. Seeding only the first admitted sites no mutation could ever break.
|
|
70
|
+
#
|
|
71
|
+
# Why the feature is off by default: an unseeded Tier 2 drops those sites from the denominator entirely,
|
|
72
|
+
# so seeding ADDS sites and the effectiveness ratio moves on unchanged code — and `--threshold=RATIO`
|
|
73
|
+
# exits 1 below a ratio users pin in CI. Turning it on by default would turn their build red with no
|
|
74
|
+
# change on their side.
|
|
75
|
+
#
|
|
76
|
+
# Built ONCE here, on the parent, so {MutationForkScan}'s children copy-on-write inherit it inside the
|
|
77
|
+
# scanner. Nothing crosses the marshal boundary: only the per-file results are marshaled back, and
|
|
78
|
+
# neither a Scope nor a `Prism::Node` is among them.
|
|
79
|
+
def mutation_discovery_seed(paths, configuration, environment, workers)
|
|
80
|
+
return nil unless configuration.bleeding_edge_active?(DISCOVERY_SEEDED_MUTATION_SITES)
|
|
81
|
+
|
|
82
|
+
seed = Protection::DiscoverySeed.build(
|
|
83
|
+
paths: paths, environment: environment, target_ruby: configuration.target_ruby, workers: workers
|
|
84
|
+
)
|
|
85
|
+
seed.empty? ? nil : seed
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The kill oracle Tier 2 measures with — the #254 gate, and the only place that feature id is read.
|
|
89
|
+
#
|
|
90
|
+
# Returns nil (today's behaviour: {Protection::DiagnosticOracle}, which re-analyses the mutated file
|
|
91
|
+
# alone) unless the project has adopted `dependent-closure-kill-oracle`. With it adopted, returns the
|
|
92
|
+
# {Protection::ClosureKillOracle}, which counts a kill when a new diagnostic appears anywhere in the
|
|
93
|
+
# mutated file's dependent closure — so a mutation whose damage lands in a CALLER is scored as the
|
|
94
|
+
# catch it is.
|
|
95
|
+
#
|
|
96
|
+
# Why the feature is off by default: it moves the reported effectiveness ratio UP on unchanged code
|
|
97
|
+
# (kills rise, the denominator does not), so a number recorded under it is not comparable with one
|
|
98
|
+
# recorded without it — the ADR-50 WD2/WD7 reason for the overlay, even though the direction cannot
|
|
99
|
+
# turn a `--threshold` build red.
|
|
100
|
+
#
|
|
101
|
+
# Both of its inputs are built HERE, once, on the parent — the ADR-46 dependents map (one recording
|
|
102
|
+
# pass) and the per-file discovery bundles — so {MutationForkScan}'s children copy-on-write inherit
|
|
103
|
+
# them and no per-mutant work is repeated per worker. `seed` composes the two features: it is handed
|
|
104
|
+
# to the closure oracle's delegated {Protection::DiagnosticOracle} verbatim, so the mutated file's
|
|
105
|
+
# verdict stays exactly the verdict the other feature's state produces, and this feature only ever
|
|
106
|
+
# ADDS the kills that land in a dependent.
|
|
107
|
+
def mutation_kill_oracle(paths, configuration, context, seed, workers)
|
|
108
|
+
return nil unless configuration.bleeding_edge_active?(DEPENDENT_CLOSURE_KILL_ORACLE)
|
|
109
|
+
|
|
110
|
+
Protection::ClosureKillOracle.new(
|
|
111
|
+
configuration: configuration, environment: context.environment, project_scan: context.project_scan,
|
|
112
|
+
paths: paths,
|
|
113
|
+
dependents: Protection::DependencyClosure.build(
|
|
114
|
+
paths: paths, configuration: configuration, environment: context.environment,
|
|
115
|
+
cache_store: context.cache_store, workers: workers
|
|
116
|
+
),
|
|
117
|
+
seed_bundles: Protection::DiscoverySeed.bundles(paths: paths),
|
|
118
|
+
discovery_seed: seed
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The scope {Protection::Mutator} judges a mutation site's receiver against, derived from the same seed
|
|
123
|
+
# the oracle gets. nil (the gate off, or an empty seed) keeps the bare `Scope.empty` per file.
|
|
124
|
+
def mutation_base_scope(seed, environment)
|
|
125
|
+
return nil if seed.nil?
|
|
126
|
+
|
|
127
|
+
base = Scope.empty(environment: environment)
|
|
128
|
+
base.with_discovery(base.discovery.with(**seed))
|
|
129
|
+
end
|
|
130
|
+
|
|
15
131
|
# ADR-63 Tier 2 — the mutation-effectiveness deep dive. Builds the RBS environment + project pre-pass once (the
|
|
16
132
|
# warm loop), then re-analyses each target file's mutants against its clean baseline. Defaults to the git-changed
|
|
17
133
|
# `.rb` files; explicit paths override (and enable the whole-project opt-in, which is minutes).
|
|
@@ -26,9 +142,12 @@ module Rigor
|
|
|
26
142
|
end
|
|
27
143
|
|
|
28
144
|
note_sampling(options)
|
|
145
|
+
# `--with-tests` deliberately does NOT take the fork path below: {Protection::TestSuiteOracle} shells
|
|
146
|
+
# out to the project's test runner, and concurrent suite invocations would race over one working tree.
|
|
29
147
|
return run_fused_protection(target_files, options) if options[:with_tests]
|
|
30
148
|
|
|
31
149
|
report = scan_mutation_protection(target_files, options)
|
|
150
|
+
warn_harness_errors(report)
|
|
32
151
|
MutationProtectionRenderer.new(out: @out).render(report, format: options.fetch(:format))
|
|
33
152
|
determine_protection_exit(report, options)
|
|
34
153
|
end
|
|
@@ -49,22 +168,47 @@ module Rigor
|
|
|
49
168
|
# mutant survived" is meaningless — abort with a clear message if not.
|
|
50
169
|
def run_fused_protection(paths, options)
|
|
51
170
|
configuration = Configuration.load(options.fetch(:config))
|
|
171
|
+
warn_workers_ignored_under_tests(options)
|
|
52
172
|
test_oracle = Protection::TestSuiteOracle.new(command: options.fetch(:test_command))
|
|
53
173
|
return suite_not_green_error(options) unless test_oracle.green?
|
|
54
174
|
|
|
55
175
|
context = LanguageServer::ProjectContext.new(configuration: configuration)
|
|
176
|
+
# The fused path stays sequential end to end (`--workers` is warned about above), so the seed's own
|
|
177
|
+
# parameter-inference pre-pass runs sequentially too rather than quietly re-enabling the forking the
|
|
178
|
+
# warning just said was ignored. It is otherwise the same seed, threaded to the same two consumers, so
|
|
179
|
+
# `--with-tests` measures the same site set as the plain path.
|
|
180
|
+
seed = mutation_discovery_seed(paths, configuration, context.environment, 0)
|
|
56
181
|
scanner = Protection::MutationScanner.new(
|
|
57
182
|
configuration: configuration, environment: context.environment, project_scan: context.project_scan,
|
|
58
183
|
limit: options[:limit], seed: options[:seed],
|
|
59
|
-
site_selector: options[:include_dynamic] ? :all : :biteable
|
|
184
|
+
site_selector: options[:include_dynamic] ? :all : :biteable,
|
|
185
|
+
base_scope: mutation_base_scope(seed, context.environment), discovery_seed: seed,
|
|
186
|
+
# #254 — the type half of the fused measurement is the same measurement, so the closure oracle
|
|
187
|
+
# applies here too when adopted: a `--with-tests` run must not disagree with the plain run about
|
|
188
|
+
# which mutants the TYPE axis caught, or the "add a type OR a test" verdict would depend on which
|
|
189
|
+
# command you ran. Sequential like the rest of this path (`--workers` is warned about above).
|
|
190
|
+
oracle: mutation_kill_oracle(paths, configuration, context, seed, 0)
|
|
60
191
|
)
|
|
61
192
|
accumulator = FusedProtectionAccumulator.new
|
|
62
193
|
paths.each { |path| scan_fused_one(path, scanner, accumulator, test_oracle, configuration) }
|
|
63
194
|
report = accumulator.to_report
|
|
195
|
+
warn_harness_errors(report)
|
|
64
196
|
FusedProtectionRenderer.new(out: @out).render(report, format: options.fetch(:format))
|
|
65
197
|
determine_protection_exit(report, options)
|
|
66
198
|
end
|
|
67
199
|
|
|
200
|
+
# An explicit `--workers=N` on the fused path cannot be honoured, so say so rather than repeat the bug
|
|
201
|
+
# this slice fixed (a flag accepted and silently dropped). Only the explicit flag warns — a project-wide
|
|
202
|
+
# `parallel.workers:` or `RIGOR_RACTOR_WORKERS` default is not a request about *this* run.
|
|
203
|
+
def warn_workers_ignored_under_tests(options)
|
|
204
|
+
return unless options[:workers].to_i > 1
|
|
205
|
+
|
|
206
|
+
@err.puts(
|
|
207
|
+
"coverage: --workers is ignored with --with-tests — the test-suite oracle shells out to " \
|
|
208
|
+
"#{options.fetch(:test_command).join(' ')}, and parallel runs would race."
|
|
209
|
+
)
|
|
210
|
+
end
|
|
211
|
+
|
|
68
212
|
def scan_fused_one(path, scanner, accumulator, test_oracle, configuration)
|
|
69
213
|
source = File.read(path)
|
|
70
214
|
parse_result = Prism.parse(source, filepath: path, version: configuration.target_ruby)
|
|
@@ -89,28 +233,115 @@ module Rigor
|
|
|
89
233
|
1
|
|
90
234
|
end
|
|
91
235
|
|
|
236
|
+
# Builds the RBS environment + whole-project pre-pass ONCE (≈6% of a 45-file run), then fork-maps the
|
|
237
|
+
# per-file measurement — the ≈94% that is `Σ(1 + N_f)` single-file analyses — across the resolved worker
|
|
238
|
+
# count (#134 slice 1). {MutationForkScan} returns `{path => result}` and the parent absorbs in `paths`
|
|
239
|
+
# order, so the report is byte-identical to a sequential run whatever order the workers finished in.
|
|
240
|
+
#
|
|
241
|
+
# #134 slice 2 — the files whose measurement is still valid are served from {Protection::MutationCache}
|
|
242
|
+
# and never reach a worker at all; only the rest are forked over, and their fresh results are written
|
|
243
|
+
# back. The cache read + write both happen on the parent, so the workers stay the pure-read, store-free
|
|
244
|
+
# processes {MutationForkScan} documents.
|
|
92
245
|
def scan_mutation_protection(paths, options)
|
|
93
246
|
configuration = Configuration.load(options.fetch(:config))
|
|
94
247
|
context = LanguageServer::ProjectContext.new(configuration: configuration)
|
|
248
|
+
workers = CheckRunnerFactory.resolve_workers(options, configuration)
|
|
249
|
+
seed = mutation_discovery_seed(paths, configuration, context.environment, workers)
|
|
95
250
|
scanner = Protection::MutationScanner.new(
|
|
96
251
|
configuration: configuration, environment: context.environment, project_scan: context.project_scan,
|
|
97
|
-
limit: options[:limit], seed: options[:seed]
|
|
252
|
+
limit: options[:limit], seed: options[:seed],
|
|
253
|
+
base_scope: mutation_base_scope(seed, context.environment), discovery_seed: seed,
|
|
254
|
+
oracle: mutation_kill_oracle(paths, configuration, context, seed, workers)
|
|
98
255
|
)
|
|
99
|
-
|
|
256
|
+
cache = mutation_result_cache(paths, options, configuration, context, seed)
|
|
257
|
+
measure_mutation_files(paths, cache: cache, scanner: scanner, context: context,
|
|
258
|
+
configuration: configuration, workers: workers)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# The cached / freshly-measured split, absorbed in `paths` order so the report is byte-identical however
|
|
262
|
+
# the two sets were assembled.
|
|
263
|
+
#
|
|
264
|
+
# The two cache phases each get their OWN {Cache::FileDigest.with_run} scope and the MEASUREMENT sits
|
|
265
|
+
# between them, deliberately: that scope installs a per-path digest memo, and {Protection::ClosureKillOracle}
|
|
266
|
+
# rewrites one process-private temp file per mutant and digests it through a buffer binding. A memo
|
|
267
|
+
# spanning the measurement would hand every mutant after the first the FIRST one's digest, the mutated
|
|
268
|
+
# file's discovery bundle would never be re-walked, and the run would report zero cross-file kills — a
|
|
269
|
+
# plausible-looking number rather than an error. The oracle's own comment states the same invariant from
|
|
270
|
+
# the other side. The cost of two scopes is one extra SHA-256 per cached file, against a measurement
|
|
271
|
+
# that is hundreds of analyses.
|
|
272
|
+
def measure_mutation_files(paths, cache:, scanner:, context:, configuration:, workers:)
|
|
273
|
+
cached = with_digest_run(configuration) { paths.to_h { |path| [path, cache.fetch(path)] }.compact }
|
|
274
|
+
pending = paths - cached.keys
|
|
275
|
+
fresh = if pending.empty?
|
|
276
|
+
{}
|
|
277
|
+
else
|
|
278
|
+
MutationForkScan.run(paths: pending, scanner: scanner, environment: context.environment,
|
|
279
|
+
configuration: configuration, workers: workers)
|
|
280
|
+
end
|
|
281
|
+
# `fetch`, never `[]`: a worker that died mid-slice must abort the run rather than quietly drop files
|
|
282
|
+
# out of a ratio that `--threshold` gates CI on.
|
|
283
|
+
with_digest_run(configuration) { pending.each { |path| cache.store(path, fresh.fetch(path)) } }
|
|
284
|
+
report_mutation_cache(cache, measured: pending.size, served: cached.size)
|
|
285
|
+
absorb_measured_files(paths, cached, fresh)
|
|
286
|
+
end
|
|
100
287
|
|
|
101
|
-
|
|
288
|
+
def absorb_measured_files(paths, cached, fresh)
|
|
289
|
+
accumulator = MutationProtectionAccumulator.new
|
|
290
|
+
paths.each do |path|
|
|
291
|
+
absorb_mutation_result(accumulator, path, cached.fetch(path) { fresh.fetch(path) })
|
|
292
|
+
end
|
|
102
293
|
accumulator.to_report
|
|
103
294
|
end
|
|
104
295
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
296
|
+
# ADR-87 WD1's per-run digest scope, so the cache's own freshness checks honour `cache.validation:`
|
|
297
|
+
# (and `RIGOR_STRICT_VALIDATION`) exactly as every other record-and-validate cache does.
|
|
298
|
+
def with_digest_run(configuration, &)
|
|
299
|
+
Cache::FileDigest.with_run(strict: configuration.cache_validation_strict?, &)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# The per-file result cache (#134 slice 2), or a disabled one. Two callers-side bypasses are decided
|
|
303
|
+
# here rather than inside the cache: `--no-cache`, and the `dependent-closure-kill-oracle` overlay —
|
|
304
|
+
# under that oracle a file's verdict depends on its DEPENDENTS' diagnostics, so validity would need the
|
|
305
|
+
# dependencies of every dependent rather than `deps[A]`, and the feature is presumptively non-graduating
|
|
306
|
+
# (#254). A bypass is sound and honest; a key that pretended otherwise would not be.
|
|
307
|
+
def mutation_result_cache(paths, options, configuration, context, seed)
|
|
308
|
+
Protection::MutationCache.build(
|
|
309
|
+
configuration: configuration, roots: @argv, project_scan: context.project_scan,
|
|
310
|
+
sampling: Protection::MutationCache::Sampling.new(
|
|
311
|
+
limit: options[:limit], seed: options[:seed],
|
|
312
|
+
site_selector: options[:include_dynamic] ? :all : :biteable
|
|
313
|
+
),
|
|
314
|
+
feature_ids: MUTATION_BEHAVIOUR_FEATURES.select { |id| configuration.bleeding_edge_active?(id) },
|
|
315
|
+
seed_inputs: seed.nil? ? nil : paths,
|
|
316
|
+
bypass_reason: mutation_cache_bypass(options, configuration)
|
|
317
|
+
)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def mutation_cache_bypass(options, configuration)
|
|
321
|
+
return "--no-cache" if options[:no_cache]
|
|
322
|
+
return DEPENDENT_CLOSURE_KILL_ORACLE if configuration.bleeding_edge_active?(DEPENDENT_CLOSURE_KILL_ORACLE)
|
|
323
|
+
|
|
324
|
+
nil
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# One stderr line saying what the cache did — stdout stays clean for JSON. Always printed, because "the
|
|
328
|
+
# cache quietly stopped working" and "the cache quietly served a stale number" are indistinguishable
|
|
329
|
+
# from the outside otherwise; the slice-3 gate reads this line to prove itself non-vacuous.
|
|
330
|
+
def report_mutation_cache(cache, measured:, served:)
|
|
331
|
+
if cache.enabled?
|
|
332
|
+
@err.puts("coverage: mutation cache — re-measured #{measured} file(s), #{served} served from cache.")
|
|
333
|
+
else
|
|
334
|
+
@err.puts("coverage: mutation cache disabled (#{cache.reason}) — " \
|
|
335
|
+
"re-measured #{measured} file(s).")
|
|
111
336
|
end
|
|
337
|
+
end
|
|
112
338
|
|
|
113
|
-
|
|
339
|
+
def absorb_mutation_result(accumulator, path, result)
|
|
340
|
+
if result.is_a?(MutationForkScan::ParseError)
|
|
341
|
+
accumulator.record_parse_error_count(path, result.count)
|
|
342
|
+
else
|
|
343
|
+
accumulator.absorb(result)
|
|
344
|
+
end
|
|
114
345
|
end
|
|
115
346
|
|
|
116
347
|
# The git-changed (modified / added / untracked) `.rb` files that exist on disk — the default Tier 2 scope.
|
|
@@ -33,10 +33,20 @@ module Rigor
|
|
|
33
33
|
@out.puts " by type: #{report.total_type_killed}"
|
|
34
34
|
@out.puts " by test: #{report.total_test_killed} (type-survivors a test caught)"
|
|
35
35
|
@out.puts " unprotected: #{report.total_unprotected} (neither — add a type or a test)"
|
|
36
|
+
render_harness_errors(report)
|
|
36
37
|
render_unprotected(report)
|
|
37
38
|
render_files(report)
|
|
38
39
|
end
|
|
39
40
|
|
|
41
|
+
# #264 — see {MutationProtectionRenderer#render_harness_errors}; surfaced only when non-zero.
|
|
42
|
+
def render_harness_errors(report)
|
|
43
|
+
count = report.total_harness_errors
|
|
44
|
+
return if count.zero?
|
|
45
|
+
|
|
46
|
+
@out.puts " harness errors: #{count} mutant(s) failed inside the measurement harness " \
|
|
47
|
+
"(excluded from the ratio — see --format=json's \"harness_errors\")"
|
|
48
|
+
end
|
|
49
|
+
|
|
40
50
|
def render_unprotected(report)
|
|
41
51
|
unprotected = report.unprotected
|
|
42
52
|
return if unprotected.empty?
|
|
@@ -9,7 +9,12 @@ module Rigor
|
|
|
9
9
|
#
|
|
10
10
|
# Framing (ADR-63 / ADR-62 Criterion A, extended): the payload is the **attribution** — which protection axis is
|
|
11
11
|
# missing — never raw survival. An unprotected site is "add protection here", never "your code is broken".
|
|
12
|
-
|
|
12
|
+
# `harness_errors` (#264) — see {Rigor::CLI::FileEffectiveness}; defaults to 0 for the same reason.
|
|
13
|
+
FusedFileProtection = Data.define(:path, :type_killed, :test_killed, :unprotected, :ratio, :harness_errors) do
|
|
14
|
+
def initialize(path:, type_killed:, test_killed:, unprotected:, ratio:, harness_errors: 0)
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
end
|
|
13
18
|
UnprotectedBreakage = Data.define(:method_name, :count, :examples)
|
|
14
19
|
|
|
15
20
|
FusedProtectionReport = Data.define(:files, :unprotected, :parse_errors) do
|
|
@@ -20,6 +25,9 @@ module Rigor
|
|
|
20
25
|
def protected_total = total_type_killed + total_test_killed
|
|
21
26
|
def ratio = grand_total.zero? ? 1.0 : protected_total.to_f / grand_total
|
|
22
27
|
|
|
28
|
+
# #264 — stays OUT of `grand_total`/`ratio`, exactly like the plain mutation report.
|
|
29
|
+
def total_harness_errors = files.sum(&:harness_errors)
|
|
30
|
+
|
|
23
31
|
def to_h
|
|
24
32
|
{
|
|
25
33
|
"mode" => "protection-fused",
|
|
@@ -27,9 +35,10 @@ module Rigor
|
|
|
27
35
|
"test_killed" => total_test_killed,
|
|
28
36
|
"unprotected" => total_unprotected,
|
|
29
37
|
"protected_ratio" => ratio.round(4),
|
|
38
|
+
"harness_errors" => total_harness_errors,
|
|
30
39
|
"files" => files.map do |f|
|
|
31
40
|
{ "path" => f.path, "type_killed" => f.type_killed, "test_killed" => f.test_killed,
|
|
32
|
-
"unprotected" => f.unprotected, "ratio" => f.ratio.round(4) }
|
|
41
|
+
"unprotected" => f.unprotected, "ratio" => f.ratio.round(4), "harness_errors" => f.harness_errors }
|
|
33
42
|
end,
|
|
34
43
|
"add_protection_here" => unprotected.map do |m|
|
|
35
44
|
{ "method" => m.method_name, "count" => m.count, "examples" => m.examples }
|
|
@@ -50,7 +59,7 @@ module Rigor
|
|
|
50
59
|
@files << FusedFileProtection.new(
|
|
51
60
|
path: file_result.path, type_killed: file_result.type_killed,
|
|
52
61
|
test_killed: file_result.test_killed, unprotected: file_result.unprotected,
|
|
53
|
-
ratio: file_result.ratio
|
|
62
|
+
ratio: file_result.ratio, harness_errors: file_result.harness_errors
|
|
54
63
|
)
|
|
55
64
|
file_result.sites.each do |site|
|
|
56
65
|
bucket = @unprotected[site.method_name]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../inference/fork_map"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
class CLI
|
|
9
|
+
# Fork-pool for the `rigor coverage --protection --mutation` scan (#134 slice 1) — the Tier-2 analogue of
|
|
10
|
+
# {ProtectionForkScan}. The expensive shared state is built ONCE on the parent (the RBS environment, the
|
|
11
|
+
# plugin registry, and the whole-project pre-pass {Analysis::ProjectScan}, all held by the
|
|
12
|
+
# {Protection::MutationScanner}'s oracle) and children copy-on-write inherit it via {Inference::ForkMap},
|
|
13
|
+
# each measuring a contiguous slice of paths.
|
|
14
|
+
#
|
|
15
|
+
# Why this is safe to parallelise where Tier 1 already is: a Tier-2 per-file measurement is pure-read
|
|
16
|
+
# against that shared state — every mutant is analysed through `Runner.new(prebuilt:)#run_source`, an
|
|
17
|
+
# in-memory overlay that writes nothing to disk and, because `prebuilt:` disables the run-result cache, has
|
|
18
|
+
# no cache store to race on. The returned {Protection::MutationScanner::FileResult} is a Data of primitives
|
|
19
|
+
# (and `SurvivingSite` likewise), so it marshals back verbatim.
|
|
20
|
+
#
|
|
21
|
+
# Determinism: {MutationProtectionAccumulator#absorb} is order-dependent (the per-file list and the
|
|
22
|
+
# "add a type here" example paths follow absorption order), so the parent MUST iterate the original `paths`
|
|
23
|
+
# and `fetch` each result rather than consume completion order. A missing key then raises instead of
|
|
24
|
+
# silently under-reporting an effectiveness ratio a `--threshold` gate reads.
|
|
25
|
+
#
|
|
26
|
+
# NOT used by the fused `--with-tests` path: {Protection::TestSuiteOracle} shells out to the project's test
|
|
27
|
+
# runner, and concurrent suite invocations would race over the same working tree.
|
|
28
|
+
module MutationForkScan
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# A parse failure for one file, carrying only the marshalable error count (Prism error objects are not
|
|
32
|
+
# reliably marshalable, and the accumulator only needs the count).
|
|
33
|
+
ParseError = Data.define(:count)
|
|
34
|
+
|
|
35
|
+
# @param paths [Array<String>] the files to measure, in caller order.
|
|
36
|
+
# @param scanner [Protection::MutationScanner] built on the parent; COW-inherited by workers.
|
|
37
|
+
# @param environment [Rigor::Environment] the scanner's environment, prewarmed here before forking.
|
|
38
|
+
# @param configuration [Rigor::Configuration] for the Prism `target_ruby` version.
|
|
39
|
+
# @param workers [Integer] resolved worker count (≤1 → sequential).
|
|
40
|
+
# @return [Hash{String => Protection::MutationScanner::FileResult, ParseError}] one entry per path.
|
|
41
|
+
def run(paths:, scanner:, environment:, configuration:, workers:)
|
|
42
|
+
# Force the full RBS load on the parent so children copy-on-write inherit a warm environment rather
|
|
43
|
+
# than each rebuilding it after the fork. A no-op on the sequential path but cheap.
|
|
44
|
+
environment.rbs_loader&.prewarm if Inference::ForkMap.parallel?([workers, paths.size].min)
|
|
45
|
+
|
|
46
|
+
payloads = Inference::ForkMap.call(items: paths, workers: workers) do |slice|
|
|
47
|
+
slice.to_h { |path| [path, scan_path(path, scanner, configuration)] }
|
|
48
|
+
end
|
|
49
|
+
payloads.each_with_object({}) { |slice_results, merged| merged.merge!(slice_results) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Measures one file and returns the scanner's {Protection::MutationScanner::FileResult}, or a
|
|
53
|
+
# {ParseError} when the source does not parse — the same read → parse → guard → scan order the sequential
|
|
54
|
+
# loop used, so a broken file is reported identically either side of the fork.
|
|
55
|
+
def scan_path(path, scanner, configuration)
|
|
56
|
+
source = File.read(path)
|
|
57
|
+
parse_result = Prism.parse(source, filepath: path, version: configuration.target_ruby)
|
|
58
|
+
return ParseError.new(count: parse_result.errors.size) if parse_result.errors.any?
|
|
59
|
+
|
|
60
|
+
scanner.scan_file(path, source: source)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -30,10 +30,22 @@ module Rigor
|
|
|
30
30
|
@out.puts "Type-protection effectiveness (Tier 2 — mutation kill rate)"
|
|
31
31
|
@out.puts " caught breakages: #{report.total_killed} / #{report.grand_total} (#{pct}%)"
|
|
32
32
|
@out.puts " (effectiveness = when a type-visible bug was introduced, Rigor caught it)"
|
|
33
|
+
render_harness_errors(report)
|
|
33
34
|
render_missed(report)
|
|
34
35
|
render_files(report)
|
|
35
36
|
end
|
|
36
37
|
|
|
38
|
+
# #264 — surfaced only when non-zero: a harness-level failure is a defect in the measurement itself, not
|
|
39
|
+
# in the code being measured, and a clean run should not carry a permanent line about a bucket that is
|
|
40
|
+
# always empty.
|
|
41
|
+
def render_harness_errors(report)
|
|
42
|
+
count = report.total_harness_errors
|
|
43
|
+
return if count.zero?
|
|
44
|
+
|
|
45
|
+
@out.puts " harness errors: #{count} mutant(s) failed inside the measurement harness " \
|
|
46
|
+
"(excluded from the ratio — see --format=json's \"harness_errors\")"
|
|
47
|
+
end
|
|
48
|
+
|
|
37
49
|
def render_missed(report)
|
|
38
50
|
missed = report.missed
|
|
39
51
|
return if missed.empty?
|
|
@@ -9,7 +9,14 @@ module Rigor
|
|
|
9
9
|
#
|
|
10
10
|
# The framing is load-bearing (ADR-63 Criterion A / ADR-62 Criterion A): the number is *effectiveness*, the
|
|
11
11
|
# survivors are *missed breakages / where to add a type*, never "your code is broken".
|
|
12
|
-
|
|
12
|
+
# `harness_errors` (#264) — mutants where the harness itself raised (a rescued failure), distinguished from
|
|
13
|
+
# a parse-invalid mutant. Defaults to 0 so existing call sites keep constructing a `FileEffectiveness`
|
|
14
|
+
# without the new keyword.
|
|
15
|
+
FileEffectiveness = Data.define(:path, :killed, :survived, :ratio, :harness_errors) do
|
|
16
|
+
def initialize(path:, killed:, survived:, ratio:, harness_errors: 0)
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
end
|
|
13
20
|
MissedBreakage = Data.define(:method_name, :count, :examples)
|
|
14
21
|
|
|
15
22
|
MutationProtectionReport = Data.define(:files, :missed, :parse_errors) do
|
|
@@ -18,15 +25,22 @@ module Rigor
|
|
|
18
25
|
def grand_total = total_killed + total_survived
|
|
19
26
|
def ratio = grand_total.zero? ? 1.0 : total_killed.to_f / grand_total
|
|
20
27
|
|
|
28
|
+
# #264 — a harness-level failure count, summed across files. Stays OUT of `grand_total`/`ratio` exactly
|
|
29
|
+
# like a parse error: it is not a measurement of the code.
|
|
30
|
+
def total_harness_errors = files.sum(&:harness_errors)
|
|
31
|
+
|
|
21
32
|
def to_h
|
|
22
33
|
{
|
|
23
34
|
"mode" => "mutation",
|
|
24
35
|
"killed" => total_killed,
|
|
25
36
|
"survived" => total_survived,
|
|
26
37
|
"effectiveness_ratio" => ratio.round(4),
|
|
38
|
+
# #264 — unconditional: a JSON consumer (e.g. a CI gate) must be able to check this every run, not
|
|
39
|
+
# only when a text renderer decided it was worth a line.
|
|
40
|
+
"harness_errors" => total_harness_errors,
|
|
27
41
|
"files" => files.map do |f|
|
|
28
|
-
{ "path" => f.path, "killed" => f.killed,
|
|
29
|
-
"
|
|
42
|
+
{ "path" => f.path, "killed" => f.killed, "survived" => f.survived,
|
|
43
|
+
"ratio" => f.ratio.round(4), "harness_errors" => f.harness_errors }
|
|
30
44
|
end,
|
|
31
45
|
"add_a_type_here" => missed.map do |m|
|
|
32
46
|
{ "method" => m.method_name, "count" => m.count, "examples" => m.examples }
|
|
@@ -46,7 +60,8 @@ module Rigor
|
|
|
46
60
|
def absorb(file_result)
|
|
47
61
|
@files << FileEffectiveness.new(
|
|
48
62
|
path: file_result.path, killed: file_result.killed,
|
|
49
|
-
survived: file_result.survived, ratio: file_result.ratio
|
|
63
|
+
survived: file_result.survived, ratio: file_result.ratio,
|
|
64
|
+
harness_errors: file_result.harness_errors
|
|
50
65
|
)
|
|
51
66
|
file_result.sites.each do |site|
|
|
52
67
|
bucket = @missed[site.method_name]
|
|
@@ -56,7 +71,13 @@ module Rigor
|
|
|
56
71
|
end
|
|
57
72
|
|
|
58
73
|
def record_parse_error(path, errors)
|
|
59
|
-
|
|
74
|
+
record_parse_error_count(path, errors.size)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Count-based variant for the fork-pool path, where a worker carries only the marshalable error count
|
|
78
|
+
# (see {MutationForkScan::ParseError}), not the Prism error objects.
|
|
79
|
+
def record_parse_error_count(path, count)
|
|
80
|
+
@parse_errors << { "path" => path, "errors" => count }
|
|
60
81
|
end
|
|
61
82
|
|
|
62
83
|
def to_report
|
|
@@ -40,10 +40,23 @@ module Rigor
|
|
|
40
40
|
@out.puts "Type-protection coverage (Tier 1 — dispatch-site receiver concreteness)"
|
|
41
41
|
@out.puts " protected dispatch sites: #{report.total_protected} / #{report.grand_total} (#{pct}%)"
|
|
42
42
|
@out.puts " (protected = Rigor can catch a wrong call here; an upper bound on real protection)"
|
|
43
|
+
render_lower_bound_typed(report)
|
|
43
44
|
render_untyped_calls(report)
|
|
44
45
|
render_files(report)
|
|
45
46
|
end
|
|
46
47
|
|
|
48
|
+
# ADR-67 WD6b (issue #263) — a sub-bucket WITHIN `protected`, never a move out of it: these sites'
|
|
49
|
+
# receiver types are call-site lower bounds (an undeclared parameter seeded from its callers), so the
|
|
50
|
+
# negative in-body rules decline on them — no diagnostic actually fires here yet. Silent when zero, the
|
|
51
|
+
# normal `check` / unseeded-scope case.
|
|
52
|
+
def render_lower_bound_typed(report)
|
|
53
|
+
m = report.total_lower_bound_typed
|
|
54
|
+
return if m.zero?
|
|
55
|
+
|
|
56
|
+
@out.puts " protected #{report.total_protected} (of which #{m} lower-bound-typed — " \
|
|
57
|
+
"no negative-rule teeth, ADR-67 WD6b)"
|
|
58
|
+
end
|
|
59
|
+
|
|
47
60
|
def render_untyped_calls(report)
|
|
48
61
|
calls = report.untyped_calls
|
|
49
62
|
return if calls.empty?
|
|
@@ -8,7 +8,7 @@ module Rigor
|
|
|
8
8
|
# the protected ratio, the per-file breakdown, and a ranked "add a type here" list keyed by the method called on an
|
|
9
9
|
# unprotected (`Dynamic`) receiver — the highest-traffic untyped dispatches, where a receiver annotation buys the
|
|
10
10
|
# most catching power.
|
|
11
|
-
FileProtection = Data.define(:path, :protected_count, :unprotected_count, :ratio)
|
|
11
|
+
FileProtection = Data.define(:path, :protected_count, :unprotected_count, :ratio, :lower_bound_typed)
|
|
12
12
|
UntypedCall = Data.define(:method_name, :count, :examples, :dynamic_origin)
|
|
13
13
|
|
|
14
14
|
ProtectionReport = Data.define(:files, :untyped_calls, :parse_errors, :cause_site_counts) do
|
|
@@ -17,6 +17,11 @@ module Rigor
|
|
|
17
17
|
def grand_total = total_protected + total_unprotected
|
|
18
18
|
def ratio = grand_total.zero? ? 1.0 : total_protected.to_f / grand_total
|
|
19
19
|
|
|
20
|
+
# ADR-67 WD6b (issue #263) — total count of `protected` sites that are additionally lower-bound-typed
|
|
21
|
+
# (a call-site-inferred, undeclared parameter): a sub-bucket WITHIN `total_protected`, never a move out
|
|
22
|
+
# of it. `total_protected` / `ratio` are unaffected by this count.
|
|
23
|
+
def total_lower_bound_typed = files.sum(&:lower_bound_typed)
|
|
24
|
+
|
|
20
25
|
# ADR-73 P6 / ADR-82 — total dispatch-site count per tractability axis across the classified holes (those with a
|
|
21
26
|
# recorded `dynamic_origin`), so a user sees at a glance how much of the untyped surface a type can actually close
|
|
22
27
|
# (`add_rbs`) vs. needs a plugin (`enable_plugin`) vs. is an engine gap. Keys are omitted when zero; an
|
|
@@ -43,11 +48,13 @@ module Rigor
|
|
|
43
48
|
"protected" => total_protected,
|
|
44
49
|
"unprotected" => total_unprotected,
|
|
45
50
|
"protection_ratio" => ratio.round(4),
|
|
51
|
+
"lower_bound_typed" => total_lower_bound_typed,
|
|
46
52
|
"tractability_summary" => tractability_summary.transform_keys(&:to_s),
|
|
47
53
|
"cause_site_counts" => cause_site_totals,
|
|
48
54
|
"files" => files.map do |f|
|
|
49
55
|
{ "path" => f.path, "protected" => f.protected_count,
|
|
50
|
-
"unprotected" => f.unprotected_count, "ratio" => f.ratio.round(4)
|
|
56
|
+
"unprotected" => f.unprotected_count, "ratio" => f.ratio.round(4),
|
|
57
|
+
"lower_bound_typed" => f.lower_bound_typed }
|
|
51
58
|
end,
|
|
52
59
|
"add_a_type_here" => untyped_calls.map do |c|
|
|
53
60
|
entry = { "method" => c.method_name, "count" => c.count, "examples" => c.examples }
|
|
@@ -74,7 +81,8 @@ module Rigor
|
|
|
74
81
|
def absorb(path, file_result)
|
|
75
82
|
@files << FileProtection.new(
|
|
76
83
|
path: path, protected_count: file_result.protected_count,
|
|
77
|
-
unprotected_count: file_result.unprotected_count, ratio: file_result.ratio
|
|
84
|
+
unprotected_count: file_result.unprotected_count, ratio: file_result.ratio,
|
|
85
|
+
lower_bound_typed: file_result.lower_bound_typed
|
|
78
86
|
)
|
|
79
87
|
file_result.sites.each do |site|
|
|
80
88
|
bucket = @calls[site.method_name]
|