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.
- checksums.yaml +4 -4
- data/README.md +41 -6
- data/data/core_overlay/numeric.rbs +33 -0
- data/data/core_overlay/pathname.rbs +25 -0
- data/data/core_overlay/string_scanner.rbs +28 -0
- data/data/gem_overlay/activesupport/core_ext.rbs +473 -0
- data/data/vendored_gem_sigs/ast/ast.rbs +130 -0
- data/data/vendored_gem_sigs/bcrypt/bcrypt.rbs +47 -0
- data/data/vendored_gem_sigs/bundler/bundler.rbs +238 -0
- data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +34 -0
- data/data/vendored_gem_sigs/did_you_mean/did_you_mean_extras.rbs +34 -0
- data/data/vendored_gem_sigs/idn-ruby/idn.rbs +54 -0
- data/data/vendored_gem_sigs/mysql2/client.rbs +55 -0
- data/data/vendored_gem_sigs/mysql2/error.rbs +5 -0
- data/data/vendored_gem_sigs/mysql2/result.rbs +31 -0
- data/data/vendored_gem_sigs/mysql2/statement.rbs +5 -0
- data/data/vendored_gem_sigs/nokogiri/nokogiri.rbs +2332 -0
- data/data/vendored_gem_sigs/nokogiri/nokogiri_html5.rbs +47 -0
- data/data/vendored_gem_sigs/pg/pg.rbs +212 -0
- data/data/vendored_gem_sigs/prism/prism_supplement.rbs +44 -0
- data/data/vendored_gem_sigs/redis/errors.rbs +50 -0
- data/data/vendored_gem_sigs/redis/future.rbs +5 -0
- data/data/vendored_gem_sigs/redis/redis.rbs +348 -0
- data/data/vendored_gem_sigs/redis/redis_extras.rbs +130 -0
- data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +226 -0
- data/lib/rigor/cli/check_command.rb +25 -2
- data/lib/rigor/cli/coverage_command.rb +67 -92
- data/lib/rigor/cli/coverage_mutation.rb +149 -0
- data/lib/rigor/cli/fused_protection_renderer.rb +67 -0
- data/lib/rigor/cli/fused_protection_report.rb +76 -0
- data/lib/rigor/config_audit.rb +152 -0
- data/lib/rigor/configuration.rb +12 -0
- data/lib/rigor/environment/rbs_loader.rb +27 -0
- data/lib/rigor/environment.rb +49 -1
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +17 -7
- data/lib/rigor/inference/statement_evaluator.rb +27 -0
- data/lib/rigor/protection/diagnostic_oracle.rb +51 -0
- data/lib/rigor/protection/mutation_scanner.rb +98 -38
- data/lib/rigor/protection/mutator.rb +21 -0
- data/lib/rigor/protection/test_suite_oracle.rb +68 -0
- data/lib/rigor/signature_path_audit.rb +92 -0
- data/lib/rigor/version.rb +1 -1
- 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
|
data/lib/rigor/configuration.rb
CHANGED
|
@@ -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
|
|
data/lib/rigor/environment.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|