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.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  3. data/data/core_overlay/pathname.rbs +5 -0
  4. data/data/core_overlay/resolv.rbs +31 -0
  5. data/data/core_overlay/string_scanner.rbs +5 -0
  6. data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
  7. data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
  8. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
  9. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
  10. data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
  11. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
  12. data/docs/manual/02-cli-reference.md +71 -11
  13. data/docs/manual/03-configuration.md +16 -1
  14. data/docs/manual/12-caching.md +17 -5
  15. data/docs/manual/15-type-protection-coverage.md +122 -1
  16. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  18. data/lib/rigor/analysis/check_rules.rb +45 -6
  19. data/lib/rigor/analysis/incremental_session.rb +156 -11
  20. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  21. data/lib/rigor/analysis/run_cache_key.rb +17 -1
  22. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  23. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  24. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  25. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  26. data/lib/rigor/analysis/runner.rb +78 -7
  27. data/lib/rigor/bleeding_edge.rb +132 -7
  28. data/lib/rigor/cache/descriptor.rb +6 -1
  29. data/lib/rigor/cache/engine_source.rb +162 -0
  30. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  31. data/lib/rigor/cli/check_command.rb +49 -28
  32. data/lib/rigor/cli/coverage_command.rb +15 -2
  33. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  34. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  35. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  36. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  37. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  38. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  39. data/lib/rigor/cli/protection_renderer.rb +13 -0
  40. data/lib/rigor/cli/protection_report.rb +11 -3
  41. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  42. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  43. data/lib/rigor/configuration.rb +45 -6
  44. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  45. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  46. data/lib/rigor/environment/rbs_loader.rb +434 -75
  47. data/lib/rigor/environment.rb +28 -13
  48. data/lib/rigor/inference/expression_typer.rb +15 -0
  49. data/lib/rigor/inference/fork_map.rb +9 -0
  50. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  51. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  52. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  53. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
  54. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  55. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
  56. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  57. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  58. data/lib/rigor/inference/mutation_widening.rb +30 -25
  59. data/lib/rigor/inference/narrowing.rb +3 -1
  60. data/lib/rigor/inference/optimistic_origin.rb +58 -0
  61. data/lib/rigor/inference/protection_scanner.rb +18 -2
  62. data/lib/rigor/inference/receiver_alias.rb +57 -0
  63. data/lib/rigor/inference/scope_indexer.rb +78 -27
  64. data/lib/rigor/inference/statement_evaluator.rb +40 -2
  65. data/lib/rigor/language_server/buffer_table.rb +22 -0
  66. data/lib/rigor/language_server/diagnostic_publisher.rb +203 -4
  67. data/lib/rigor/language_server/project_context.rb +45 -0
  68. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  69. data/lib/rigor/language_server/server.rb +20 -1
  70. data/lib/rigor/language_server.rb +1 -0
  71. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  72. data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
  73. data/lib/rigor/protection/dependency_closure.rb +59 -0
  74. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  75. data/lib/rigor/protection/discovery_seed.rb +116 -0
  76. data/lib/rigor/protection/kill_signature.rb +31 -0
  77. data/lib/rigor/protection/mutation_cache.rb +355 -0
  78. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  79. data/lib/rigor/protection/mutator.rb +26 -4
  80. data/lib/rigor/runtime/jit.rb +63 -1
  81. data/lib/rigor/scope/discovery_index.rb +9 -0
  82. data/lib/rigor/scope.rb +52 -8
  83. data/lib/rigor/sig_gen/generator.rb +130 -31
  84. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  85. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  86. data/lib/rigor/sig_gen/renderer.rb +20 -1
  87. data/lib/rigor/sig_gen/write_result.rb +6 -4
  88. data/lib/rigor/sig_gen/writer.rb +12 -0
  89. data/lib/rigor/sig_gen.rb +1 -0
  90. data/lib/rigor/version.rb +1 -1
  91. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  92. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  93. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  94. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  95. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  96. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  97. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  98. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  99. data/sig/rigor/inference/void_origin.rbs +6 -3
  100. data/sig/rigor/scope.rbs +8 -0
  101. 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
- def initialize(configuration:, environment:, project_scan:)
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).to_set { |d| sig(d) }
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?(sig(d)) }
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