rigortype 0.2.0 → 0.2.1

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -6
  3. data/data/core_overlay/numeric.rbs +33 -0
  4. data/data/core_overlay/pathname.rbs +25 -0
  5. data/data/core_overlay/string_scanner.rbs +28 -0
  6. data/data/gem_overlay/activesupport/core_ext.rbs +473 -0
  7. data/data/vendored_gem_sigs/ast/ast.rbs +130 -0
  8. data/data/vendored_gem_sigs/bcrypt/bcrypt.rbs +47 -0
  9. data/data/vendored_gem_sigs/bundler/bundler.rbs +238 -0
  10. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +34 -0
  11. data/data/vendored_gem_sigs/did_you_mean/did_you_mean_extras.rbs +34 -0
  12. data/data/vendored_gem_sigs/idn-ruby/idn.rbs +54 -0
  13. data/data/vendored_gem_sigs/mysql2/client.rbs +55 -0
  14. data/data/vendored_gem_sigs/mysql2/error.rbs +5 -0
  15. data/data/vendored_gem_sigs/mysql2/result.rbs +31 -0
  16. data/data/vendored_gem_sigs/mysql2/statement.rbs +5 -0
  17. data/data/vendored_gem_sigs/nokogiri/nokogiri.rbs +2332 -0
  18. data/data/vendored_gem_sigs/nokogiri/nokogiri_html5.rbs +47 -0
  19. data/data/vendored_gem_sigs/pg/pg.rbs +212 -0
  20. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +44 -0
  21. data/data/vendored_gem_sigs/redis/errors.rbs +50 -0
  22. data/data/vendored_gem_sigs/redis/future.rbs +5 -0
  23. data/data/vendored_gem_sigs/redis/redis.rbs +348 -0
  24. data/data/vendored_gem_sigs/redis/redis_extras.rbs +130 -0
  25. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +226 -0
  26. data/lib/rigor/cli/check_command.rb +25 -2
  27. data/lib/rigor/cli/coverage_command.rb +67 -92
  28. data/lib/rigor/cli/coverage_mutation.rb +149 -0
  29. data/lib/rigor/cli/fused_protection_renderer.rb +67 -0
  30. data/lib/rigor/cli/fused_protection_report.rb +76 -0
  31. data/lib/rigor/config_audit.rb +152 -0
  32. data/lib/rigor/configuration.rb +12 -0
  33. data/lib/rigor/environment/rbs_loader.rb +27 -0
  34. data/lib/rigor/environment.rb +49 -1
  35. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +17 -7
  36. data/lib/rigor/inference/statement_evaluator.rb +27 -0
  37. data/lib/rigor/protection/diagnostic_oracle.rb +51 -0
  38. data/lib/rigor/protection/mutation_scanner.rb +98 -38
  39. data/lib/rigor/protection/mutator.rb +21 -0
  40. data/lib/rigor/protection/test_suite_oracle.rb +68 -0
  41. data/lib/rigor/signature_path_audit.rb +92 -0
  42. data/lib/rigor/version.rb +1 -1
  43. metadata +31 -1
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rigor
6
+ class CLI
7
+ # Renders a {FusedProtectionReport} (ADR-70) as text or JSON. The text form
8
+ # leads with the fused protected ratio (caught by *either* a type or a test),
9
+ # splits it into the two axes, then lists the unprotected breakages ("add a
10
+ # type or a test here") and the least-protected files. The framing is always
11
+ # *where to add protection*, never "your code is broken".
12
+ class FusedProtectionRenderer
13
+ TOP_CALLS = 15
14
+ TOP_FILES = 10
15
+
16
+ def initialize(out:)
17
+ @out = out
18
+ end
19
+
20
+ def render(report, format:)
21
+ format == "json" ? render_json(report) : render_text(report)
22
+ end
23
+
24
+ private
25
+
26
+ def render_json(report)
27
+ @out.puts(JSON.pretty_generate(report.to_h))
28
+ end
29
+
30
+ def render_text(report)
31
+ pct = (report.ratio * 100).round(1)
32
+ @out.puts "Fused protection (static type ∪ dynamic test)"
33
+ @out.puts " protected: #{report.protected_total} / #{report.grand_total} (#{pct}%)"
34
+ @out.puts " by type: #{report.total_type_killed}"
35
+ @out.puts " by test: #{report.total_test_killed} (type-survivors a test caught)"
36
+ @out.puts " unprotected: #{report.total_unprotected} (neither — add a type or a test)"
37
+ render_unprotected(report)
38
+ render_files(report)
39
+ end
40
+
41
+ def render_unprotected(report)
42
+ unprotected = report.unprotected
43
+ return if unprotected.empty?
44
+
45
+ @out.puts "\nAdd protection here — breakages neither a type nor a test caught:"
46
+ unprotected.first(TOP_CALLS).each do |call|
47
+ @out.puts format(" %<count>-5d #%<method>s e.g. %<sites>s",
48
+ count: call.count, method: call.method_name, sites: call.examples.join(" "))
49
+ end
50
+ @out.puts " (#{unprotected.size - TOP_CALLS} more)" if unprotected.size > TOP_CALLS
51
+ end
52
+
53
+ def render_files(report)
54
+ worst = report.files.reject { |f| f.unprotected.zero? }.sort_by(&:ratio).first(TOP_FILES)
55
+ return if worst.empty?
56
+
57
+ @out.puts "\nLeast-protected files:"
58
+ worst.each do |file|
59
+ total = file.type_killed + file.test_killed + file.unprotected
60
+ protected_n = file.type_killed + file.test_killed
61
+ @out.puts format(" %<pct>5.1f%% %<path>s (%<n>d/%<total>d protected)",
62
+ pct: file.ratio * 100, path: file.path, n: protected_n, total: total)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class CLI
5
+ # ADR-70 — aggregates per-file {Protection::MutationScanner::FusedFileResult}
6
+ # into a project-level **fused** protection report: how many type-visible
7
+ # breakages were caught by the type checker, how many of the *type-survivors*
8
+ # were caught by the test suite, and which sites neither axis caught — the
9
+ # ranked "add a type OR a test here" list.
10
+ #
11
+ # Framing (ADR-63 / ADR-62 Criterion A, extended): the payload is the
12
+ # **attribution** — which protection axis is missing — never raw survival.
13
+ # An unprotected site is "add protection here", never "your code is broken".
14
+ FusedFileProtection = Data.define(:path, :type_killed, :test_killed, :unprotected, :ratio)
15
+ UnprotectedBreakage = Data.define(:method_name, :count, :examples)
16
+
17
+ FusedProtectionReport = Data.define(:files, :unprotected, :parse_errors) do
18
+ def total_type_killed = files.sum(&:type_killed)
19
+ def total_test_killed = files.sum(&:test_killed)
20
+ def total_unprotected = files.sum(&:unprotected)
21
+ def grand_total = total_type_killed + total_test_killed + total_unprotected
22
+ def protected_total = total_type_killed + total_test_killed
23
+ def ratio = grand_total.zero? ? 1.0 : protected_total.to_f / grand_total
24
+
25
+ def to_h
26
+ {
27
+ "mode" => "protection-fused",
28
+ "type_killed" => total_type_killed,
29
+ "test_killed" => total_test_killed,
30
+ "unprotected" => total_unprotected,
31
+ "protected_ratio" => ratio.round(4),
32
+ "files" => files.map do |f|
33
+ { "path" => f.path, "type_killed" => f.type_killed, "test_killed" => f.test_killed,
34
+ "unprotected" => f.unprotected, "ratio" => f.ratio.round(4) }
35
+ end,
36
+ "add_protection_here" => unprotected.map do |m|
37
+ { "method" => m.method_name, "count" => m.count, "examples" => m.examples }
38
+ end,
39
+ "parse_errors" => parse_errors
40
+ }
41
+ end
42
+ end
43
+
44
+ class FusedProtectionAccumulator
45
+ def initialize
46
+ @files = []
47
+ @unprotected = Hash.new { |h, k| h[k] = { count: 0, examples: [] } }
48
+ @parse_errors = []
49
+ end
50
+
51
+ def absorb(file_result)
52
+ @files << FusedFileProtection.new(
53
+ path: file_result.path, type_killed: file_result.type_killed,
54
+ test_killed: file_result.test_killed, unprotected: file_result.unprotected,
55
+ ratio: file_result.ratio
56
+ )
57
+ file_result.sites.each do |site|
58
+ bucket = @unprotected[site.method_name]
59
+ bucket[:count] += 1
60
+ bucket[:examples] << "#{file_result.path}:#{site.line}" if bucket[:examples].size < 3
61
+ end
62
+ end
63
+
64
+ def record_parse_error(path, errors)
65
+ @parse_errors << { "path" => path, "errors" => errors.size }
66
+ end
67
+
68
+ def to_report
69
+ unprotected = @unprotected
70
+ .map { |m, v| UnprotectedBreakage.new(method_name: m, count: v[:count], examples: v[:examples]) }
71
+ .sort_by { |m| [-m.count, m.method_name] }
72
+ FusedProtectionReport.new(files: @files, unprotected: unprotected, parse_errors: @parse_errors)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "signature_path_audit"
4
+ require_relative "analysis/check_rules"
5
+
6
+ module Rigor
7
+ # Audits a loaded {Configuration} for the class of mistake where a
8
+ # configured value silently resolves to nothing — a typo'd or moved
9
+ # path, an unknown library name, an inert rule id. The shared failure
10
+ # mode is that the loader filters the bad entry without a word, so the
11
+ # only symptom is downstream and confusing: missing signatures turn
12
+ # every call into the types they were meant to cover into a
13
+ # high-confidence `call.undefined-method`, and an unrecognised
14
+ # suppression token leaves the rule firing as if the `disable:` line
15
+ # were never written. This module surfaces each such entry up front so
16
+ # the cause is visible instead of inferred.
17
+ #
18
+ # Every check is held to the same bar that {SignaturePathAudit} set:
19
+ # it mirrors the loader's own acceptance test, so a warning means the
20
+ # loader really did load nothing, and it never fires on a setup that
21
+ # works. In particular the rule-token check only flags a token under a
22
+ # built-in family (`call`, `flow`, …) — a plugin- or `rbs_extended.*`
23
+ # rule id (whose family Rigor cannot statically enumerate, since a
24
+ # `node_rule` block emits any `rule:` string it likes) is left alone, so
25
+ # disabling a plugin rule is never mistaken for a typo.
26
+ module ConfigAudit
27
+ # One config-level finding. `kind` discriminates the source key
28
+ # (`:signature_path`, `:library`, `:disabled_rule`,
29
+ # `:severity_override`, `:bundler_bundle_path`, `:bundler_lockfile`,
30
+ # `:rbs_collection_lockfile`); `fields` carries the kind-specific
31
+ # structured data merged into {#to_h} for JSON consumers.
32
+ Warning = Data.define(:kind, :message, :fields) do
33
+ def to_h
34
+ { "kind" => kind.to_s, "message" => message }.merge(fields)
35
+ end
36
+ end
37
+
38
+ # @param configuration [Rigor::Configuration]
39
+ # @param project_root [String] the directory the run's relative
40
+ # bundler / collection paths resolve against (the CLI's CWD), used
41
+ # only by the explicit-path checks.
42
+ # @return [Array<Warning>]
43
+ def self.warnings(configuration, project_root: Dir.pwd)
44
+ signature_path_warnings(configuration) +
45
+ library_warnings(configuration) +
46
+ rule_token_warnings(configuration) +
47
+ explicit_path_warnings(configuration, project_root)
48
+ end
49
+
50
+ # `signature_paths:` entries that resolve to nothing — delegated to
51
+ # {SignaturePathAudit}, which mirrors the loader's `directory?` +
52
+ # recursive `**/*.rbs` acceptance test.
53
+ def self.signature_path_warnings(configuration)
54
+ SignaturePathAudit.warnings(configuration.signature_paths).map do |entry|
55
+ Warning.new(
56
+ kind: :signature_path,
57
+ message: entry.message,
58
+ fields: { "path" => entry.path, "status" => entry.status.to_s, "rbs_file_count" => entry.rbs_file_count }
59
+ )
60
+ end
61
+ end
62
+
63
+ # `libraries:` entries RBS does not recognise. Uses the same
64
+ # `RBS::EnvironmentLoader#has_library?` guard the loader filters
65
+ # through ({Environment::RbsLoader} `build_env_for`), so a flagged
66
+ # name is exactly one the loader skipped. Fails soft: if RBS itself
67
+ # raises, no warning rather than a crash.
68
+ def self.library_warnings(configuration)
69
+ configured = Array(configuration.libraries)
70
+ return [] if configured.empty?
71
+
72
+ loader = ::RBS::EnvironmentLoader.new
73
+ configured.reject { |lib| loader.has_library?(library: lib.to_s, version: nil) }.map do |lib|
74
+ Warning.new(
75
+ kind: :library,
76
+ message: "libraries: #{lib.to_s.inspect} is not an available RBS library (no signatures loaded from it)",
77
+ fields: { "name" => lib.to_s }
78
+ )
79
+ end
80
+ rescue StandardError
81
+ []
82
+ end
83
+
84
+ # `disable:` tokens and `severity_overrides:` keys that name no rule.
85
+ # Restricted to tokens under a built-in family so a plugin rule id is
86
+ # never mis-flagged (see the module docstring).
87
+ def self.rule_token_warnings(configuration)
88
+ disable = Array(configuration.disabled_rules).filter_map do |token|
89
+ next unless inert_builtin_token?(token.to_s)
90
+
91
+ Warning.new(
92
+ kind: :disabled_rule,
93
+ message: "disable: #{token.to_s.inspect} is not a recognized rule id; the suppression has no effect",
94
+ fields: { "token" => token.to_s }
95
+ )
96
+ end
97
+ overrides = configuration.severity_overrides.keys.filter_map do |key|
98
+ next unless inert_builtin_token?(key.to_s)
99
+
100
+ Warning.new(
101
+ kind: :severity_override,
102
+ message: "severity_overrides: #{key.to_s.inspect} is not a recognized rule id; the override has no effect",
103
+ fields: { "token" => key.to_s }
104
+ )
105
+ end
106
+ disable + overrides
107
+ end
108
+
109
+ # True when `token` looks like a built-in rule id but matches none —
110
+ # its first segment is a built-in family yet it is neither a bare
111
+ # family wildcard nor a known canonical id. A token whose family is
112
+ # not built-in (a plugin / `rbs_extended.*` rule, or a bare legacy
113
+ # alias) is deliberately NOT flagged: it may resolve at run time, so
114
+ # under-warning is the FP-safe choice.
115
+ def self.inert_builtin_token?(token)
116
+ family = token.split(".").first
117
+ return false unless Analysis::CheckRules::RULE_FAMILIES.include?(family)
118
+ return false if token == family
119
+ return false if Analysis::CheckRules::ALL_RULES.include?(token)
120
+
121
+ true
122
+ end
123
+
124
+ # Explicitly-configured bundler / rbs-collection paths that do not
125
+ # exist. Only the explicit form is audited (a nil value means
126
+ # auto-detection, which finding nothing is normal); messages stay
127
+ # factual about the path rather than guessing the fallback.
128
+ def self.explicit_path_warnings(configuration, project_root)
129
+ warnings = []
130
+ add_missing_dir(warnings, configuration.bundler_bundle_path, project_root,
131
+ :bundler_bundle_path, "bundler.bundle_path")
132
+ add_missing_file(warnings, configuration.bundler_lockfile, project_root,
133
+ :bundler_lockfile, "bundler.lockfile")
134
+ add_missing_file(warnings, configuration.rbs_collection_lockfile, project_root,
135
+ :rbs_collection_lockfile, "rbs_collection.lockfile")
136
+ warnings
137
+ end
138
+
139
+ def self.add_missing_dir(warnings, path, project_root, kind, key)
140
+ return if path.nil? || File.directory?(File.expand_path(path, project_root))
141
+
142
+ warnings << Warning.new(kind: kind, message: "#{key}: #{path.inspect} is not a directory",
143
+ fields: { "path" => path })
144
+ end
145
+
146
+ def self.add_missing_file(warnings, path, project_root, kind, key)
147
+ return if path.nil? || File.file?(File.expand_path(path, project_root))
148
+
149
+ warnings << Warning.new(kind: kind, message: "#{key}: #{path.inspect} does not exist", fields: { "path" => path })
150
+ end
151
+ end
152
+ end
@@ -600,6 +600,18 @@ module Rigor
600
600
  raise ArgumentError, "severity_overrides must be a Hash, got #{value.inspect}" unless value.is_a?(Hash)
601
601
 
602
602
  value.to_h do |k, v|
603
+ # YAML 1.1 parses bare `off`/`on`/`no`/`yes`/`true`/`false`
604
+ # as booleans, so a user who wrote `off` (a valid severity)
605
+ # without quotes hands us `false`. Catch the non-Symbol /
606
+ # non-String case before `to_sym` blows up with a backtrace.
607
+ unless v.is_a?(String) || v.is_a?(Symbol)
608
+ hint = v == false ? %( — did you mean the string "off"?) : ""
609
+ raise ArgumentError,
610
+ "severity_overrides[#{k.inspect}] is #{v.inspect}, a YAML boolean#{hint} " \
611
+ "Bare off/on/no/yes/true/false are parsed as booleans; quote the severity " \
612
+ "(e.g. \"off\")."
613
+ end
614
+
603
615
  sym = v.to_sym
604
616
  unless SeverityProfile::VALID_SEVERITIES.include?(sym)
605
617
  raise ArgumentError,
@@ -323,6 +323,33 @@ module Rigor
323
323
  [Pathname(CORE_OVERLAY_SIGS_ROOT)]
324
324
  end
325
325
 
326
+ # Rigor-owned per-gem RBS overlays (`data/gem_overlay/<gem>/`),
327
+ # ADR-72. Unlike the unconditional `core_overlay`, each gem's
328
+ # overlay is loaded ONLY when that gem is locked in the
329
+ # project's Gemfile.lock but ships no RBS of its own —
330
+ # {Environment.for_project} decides eligibility and passes the
331
+ # already-filtered gem-name set here. One directory per gem name
332
+ # keeps the membership check a cheap `File.directory?`.
333
+ GEM_OVERLAY_SIGS_ROOT = File.expand_path(
334
+ "../../../data/gem_overlay",
335
+ __dir__
336
+ ).freeze
337
+
338
+ # @param gem_names [Enumerable<String>] overlay-eligible
339
+ # Gemfile.lock gem names (the caller filters to the
340
+ # `:missing`-coverage, no-conflicting-plugin set).
341
+ # @return [Array<Pathname>] the bundled overlay directory for
342
+ # each gem that ships one; empty when none match or the
343
+ # overlay root is absent.
344
+ def gem_overlay_sig_paths(gem_names)
345
+ return [] unless File.directory?(GEM_OVERLAY_SIGS_ROOT)
346
+
347
+ gem_names.filter_map do |name|
348
+ dir = File.join(GEM_OVERLAY_SIGS_ROOT, name.to_s)
349
+ Pathname(dir) if File.directory?(dir)
350
+ end
351
+ end
352
+
326
353
  def vendored_gem_sig_paths
327
354
  return [] unless File.directory?(VENDORED_GEM_SIGS_ROOT)
328
355
 
@@ -65,6 +65,13 @@ module Rigor
65
65
  prism rbs
66
66
  ].freeze
67
67
 
68
+ # ADR-72 — a Gemfile.lock gem name mapped to the opt-in plugin id
69
+ # that ships the SAME core-ext RBS. When that plugin is loaded the
70
+ # auto-overlay for the gem stands down, so the two never both
71
+ # declare the methods (which would raise a duplicate-declaration
72
+ # error). Keyed on the gem name `RbsCoverageReport` reports.
73
+ GEM_OVERLAY_PLUGIN_IDS = { "activesupport" => "activesupport-core-ext" }.freeze
74
+
68
75
  attr_reader :class_registry, :rbs_loader, :plugin_registry, :dependency_source_index,
69
76
  :reporters, :name_scope,
70
77
  :synthetic_method_index, :project_patched_methods
@@ -291,7 +298,24 @@ module Rigor
291
298
  # collection discovery. A duplicate-declaration conflict
292
299
  # degrades through the same O7 failure-memo path.
293
300
  plugin_sig_paths = plugin_registry ? plugin_registry.signature_paths.map(&:to_s) : []
294
- loader_signature_paths = resolved_paths + plugin_sig_paths + gem_sig_paths + collection_paths
301
+ # ADR-72 Gemfile.lock-gated bundled RBS overlays. For each
302
+ # locked gem that ships no RBS through any resolution path
303
+ # (`:missing`) and has a Rigor-bundled overlay, load that
304
+ # overlay so its core-class extensions resolve (e.g.
305
+ # `Integer#minutes` on a Rails project) — turning a systematic
306
+ # false `call.undefined-method` into a no-op while a project
307
+ # WITHOUT the gem still sees the genuine diagnostic. Appended
308
+ # last so any RBS the project already supplies wins, and skipped
309
+ # for a gem whose opt-in plugin twin is loaded (no duplicate
310
+ # declaration). The paths ride in `loader_signature_paths`, so
311
+ # the env cache descriptor digests them for free.
312
+ overlay_paths = gem_overlay_paths(
313
+ locked: locked, default_libraries: merged_libraries,
314
+ bundle_sig_paths: gem_sig_paths, rbs_collection_paths: collection_paths,
315
+ plugin_registry: plugin_registry
316
+ )
317
+ loader_signature_paths = resolved_paths + plugin_sig_paths + gem_sig_paths +
318
+ collection_paths + overlay_paths
295
319
  # ADR-32 WD4 + WD5 — invoke each loaded plugin's
296
320
  # `source_rbs_synthesizer` once per project source file
297
321
  # and collect non-nil `[filename, rbs_source]` pairs.
@@ -346,6 +370,30 @@ module Rigor
346
370
  sig.directory? ? [sig] : []
347
371
  end
348
372
 
373
+ # ADR-72 — resolve the bundled RBS overlay directories to load for
374
+ # this project. A gem is eligible when it is locked in the
375
+ # Gemfile.lock, classified `:missing` by {RbsCoverageReport}
376
+ # (no RBS through default-library / vendored / bundle-`sig/` /
377
+ # rbs-collection), and its conflicting opt-in plugin (if any) is
378
+ # not loaded. Returns `[Pathname]`, deterministically ordered, or
379
+ # `[]` when no lockfile / no eligible gem.
380
+ def gem_overlay_paths(locked:, default_libraries:, bundle_sig_paths:,
381
+ rbs_collection_paths:, plugin_registry:)
382
+ return [] if locked.empty?
383
+
384
+ missing = RbsCoverageReport.classify(
385
+ locked_gems: locked, default_libraries: default_libraries,
386
+ bundle_sig_paths: bundle_sig_paths, rbs_collection_paths: rbs_collection_paths
387
+ ).select { |row| row.source == :missing }.map(&:gem_name)
388
+
389
+ loaded_ids = plugin_registry ? plugin_registry.ids.to_set : Set.new
390
+ eligible = missing.reject do |gem_name|
391
+ plugin_id = GEM_OVERLAY_PLUGIN_IDS[gem_name]
392
+ plugin_id && loaded_ids.include?(plugin_id)
393
+ end.sort
394
+ RbsLoader.gem_overlay_sig_paths(eligible)
395
+ end
396
+
349
397
  # ADR-32 WD4 + WD5 — for each project source file, invoke
350
398
  # every plugin-registered synthesizer once and collect
351
399
  # non-nil returns. The returned array is `[[virtual_filename,
@@ -114,14 +114,19 @@ module Rigor
114
114
  # siblings `:inspect` / `:to_s` remain folded.
115
115
  INTEGER_UNARY = Set[
116
116
  :odd?, :even?, :zero?, :positive?, :negative?,
117
+ # `finite?` / `infinite?` are total on Integer (`true` / `nil`
118
+ # always) and round out the numeric predicate family — the Float
119
+ # sibling already folds them. `nonzero?` returns `self` (non-zero)
120
+ # or `nil`, both foldable Constants.
121
+ :finite?, :infinite?, :nonzero?,
117
122
  :succ, :pred, :next, :abs, :magnitude,
118
123
  :bit_length, :to_s, :to_i, :to_int, :to_f,
119
124
  :floor, :ceil, :round, :truncate, :chr,
120
125
  :inspect, :-@, :+@, :~, :to_r, :to_c
121
126
  ].freeze
122
127
  FLOAT_UNARY = Set[
123
- :zero?, :positive?, :negative?,
124
- :nan?, :finite?, :infinite?,
128
+ :zero?, :positive?, :negative?, :nonzero?,
129
+ :nan?, :finite?, :infinite?, :integer?,
125
130
  :abs, :magnitude, :floor, :ceil, :round, :truncate,
126
131
  :next_float, :prev_float,
127
132
  :to_s, :to_i, :to_int, :to_f, :to_r, :rationalize,
@@ -138,7 +143,11 @@ module Rigor
138
143
  SYMBOL_UNARY = Set[
139
144
  :to_s, :to_sym, :to_proc, :length, :size,
140
145
  :empty?, :upcase, :downcase, :capitalize,
141
- :swapcase, :succ, :next, :inspect
146
+ :swapcase, :succ, :next, :inspect,
147
+ # `name` (the frozen-string accessor), `id2name` (alias of
148
+ # `to_s`), and `intern` (alias of `to_sym`) are pure reads of the
149
+ # symbol's text — siblings of the already-folded `to_s` / `to_sym`.
150
+ :name, :id2name, :intern
142
151
  ].freeze
143
152
  BOOL_UNARY = Set[:!, :to_s, :inspect, :&, :|, :^].freeze
144
153
  NIL_UNARY = Set[:nil?, :!, :to_s, :to_a, :to_h, :inspect].freeze
@@ -542,14 +551,15 @@ module Rigor
542
551
  build_constant_type(results, source: receiver_values + arg_values)
543
552
  end
544
553
  # v0.0.7 — `Constant<String>#chars` / `bytes` / `codepoints` /
545
- # `lines` / `split` (no-arg) return a Ruby Array of foldable
546
- # scalars; `foldable_constant_value?` rejects Array
554
+ # `grapheme_clusters` / `lines` / `split` (no-arg) return a Ruby
555
+ # Array of foldable scalars; `foldable_constant_value?` rejects Array
547
556
  # results, so the standard unary path declines. Lift the
548
557
  # Array to a per-position `Tuple[Constant…]` directly,
549
558
  # capped at `STRING_ARRAY_LIFT_LIMIT` to keep the result
550
559
  # bounded for long strings. (`codepoints` yields per-character
551
- # Integer codepoints, the sibling of the byte-valued `bytes`.)
552
- STRING_ARRAY_UNARY_METHODS = Set[:chars, :bytes, :codepoints, :lines, :split].freeze
560
+ # Integer codepoints, the sibling of the byte-valued `bytes`;
561
+ # `grapheme_clusters` is the extended-grapheme sibling of `chars`.)
562
+ STRING_ARRAY_UNARY_METHODS = Set[:chars, :bytes, :codepoints, :grapheme_clusters, :lines, :split].freeze
553
563
  # `partition` / `rpartition` always return a fixed 3-element
554
564
  # `[head, separator, tail]` Array whose members are substrings of
555
565
  # the receiver (bounded by the input), so they lift to a precise
@@ -2274,6 +2274,11 @@ module Rigor
2274
2274
  body = block_node.body
2275
2275
  return post_scope if body.nil?
2276
2276
 
2277
+ # Transitive case first: the body may content-mutate a captured local
2278
+ # through a self-call rather than a direct `local[k] = v` write, which
2279
+ # `collect_content_mutations` cannot see (see below).
2280
+ post_scope = floor_block_body_callee_escaped_args(body, post_scope)
2281
+
2277
2282
  mutations = collect_content_mutations(body)
2278
2283
  return post_scope if mutations.empty?
2279
2284
 
@@ -2283,6 +2288,28 @@ module Rigor
2283
2288
  end
2284
2289
  end
2285
2290
 
2291
+ # Inside an ESCAPING block body, a captured outer local can be content-
2292
+ # mutated transitively: the body is (or contains) a self-call that
2293
+ # escape-mutates one of its arguments. The canonical shape is the CLI's
2294
+ # own `OptionParser.new { |opts| define_options(opts, options) }` — the
2295
+ # block body is a bare `define_options(opts, options)` whose `options`
2296
+ # parameter is escape-mutated inside ITS nested `opts.on { options[:k] =
2297
+ # v }` blocks. `collect_content_mutations` only sees direct `local[k] =
2298
+ # v` writes in THIS body, so it misses the transitive write and the
2299
+ # captured Hash keeps its literal-false seed (folding the caller's
2300
+ # `options[:mutation]` guard to an always-falsey constant). Reuse the
2301
+ # cross-method-boundary callee-escaped-argument floor (the same gate the
2302
+ # receiver-chain path uses at the call site) on every self-call in the
2303
+ # body. Sound — only ever floors a captured local passed as an argument
2304
+ # to a callee that demonstrably escape-mutates the matching parameter.
2305
+ def floor_block_body_callee_escaped_args(body, post_scope)
2306
+ acc = post_scope
2307
+ Source::NodeWalker.each(body) do |descendant|
2308
+ acc = floor_callee_escaped_args_for_call(descendant, acc) if descendant.is_a?(Prism::CallNode)
2309
+ end
2310
+ acc
2311
+ end
2312
+
2286
2313
  # The Dynamic-floor carrier for a content-mutated escaping capture, or
2287
2314
  # nil when the pre-state is not a recognised mutable collection (leave
2288
2315
  # it alone — e.g. an already-`Dynamic` binding or an unknown shape).
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../analysis/runner"
4
+
5
+ module Rigor
6
+ module Protection
7
+ # ADR-69 Seam 1 — the **kill oracle** Rigor's analyzer-teeth measurement uses:
8
+ # a mutant is killed iff re-analysing it introduces a diagnostic absent from
9
+ # the clean baseline. This is exactly the behaviour ADR-62/63 shipped, lifted
10
+ # out of {MutationScanner} so a {TestSuiteOracle} (ADR-70) — which kills by
11
+ # *running tests* rather than by re-analysis — can sit beside it without the
12
+ # scanner baking in either assumption.
13
+ #
14
+ # The expensive builds (RBS environment + the whole-project pre-pass scan) are
15
+ # paid once by the caller and threaded in; each mutant reuses them through
16
+ # `Runner.new(prebuilt:)#run_source` (in-memory overlay, no disk write).
17
+ # Passing `prebuilt:` disables the run-result cache (whose key digests the
18
+ # *disk* file), so a mutant is never served a stale clean hit.
19
+ class DiagnosticOracle
20
+ def initialize(configuration:, environment:, project_scan:)
21
+ @configuration = configuration
22
+ @environment = environment
23
+ @project_scan = project_scan
24
+ end
25
+
26
+ # The clean per-file baseline: the diagnostic signatures a mutant must add
27
+ # to count as killed. Computed once per file by the caller.
28
+ def baseline(source:, path:)
29
+ analyse(source, path).to_set { |d| sig(d) }
30
+ end
31
+
32
+ # Killed iff the mutant introduces a diagnostic not in `baseline`.
33
+ def killed?(mutant_source:, path:, baseline:)
34
+ analyse(mutant_source, path).any? { |d| !baseline.include?(sig(d)) }
35
+ end
36
+
37
+ private
38
+
39
+ def analyse(source, path)
40
+ Rigor::Analysis::Runner.new(
41
+ configuration: @configuration, environment: @environment, prebuilt: @project_scan,
42
+ cache_store: nil, collect_stats: false
43
+ ).run_source(source: source, path: path).diagnostics
44
+ end
45
+
46
+ def sig(diagnostic)
47
+ [diagnostic.rule, diagnostic.path, diagnostic.line, diagnostic.column, diagnostic.message]
48
+ end
49
+ end
50
+ end
51
+ end