henitai 0.2.1 → 0.3.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/CHANGELOG.md +106 -1
- data/README.md +125 -3
- data/assets/schema/henitai.schema.json +35 -0
- data/lib/henitai/available_cpu_count.rb +16 -23
- data/lib/henitai/canonical_report_merger.rb +107 -0
- data/lib/henitai/canonical_report_writer.rb +22 -0
- data/lib/henitai/checkpoint_reporter.rb +79 -0
- data/lib/henitai/cli/clean_command.rb +14 -8
- data/lib/henitai/cli/command_support.rb +2 -1
- data/lib/henitai/cli/operator_command.rb +2 -1
- data/lib/henitai/cli/options.rb +21 -57
- data/lib/henitai/cli/run_command.rb +36 -3
- data/lib/henitai/cli/run_options.rb +108 -0
- data/lib/henitai/cli.rb +12 -4
- data/lib/henitai/composite_progress_reporter.rb +42 -0
- data/lib/henitai/configuration.rb +41 -9
- data/lib/henitai/configuration_validator/rules.rb +18 -0
- data/lib/henitai/configuration_validator/scalars.rb +46 -0
- data/lib/henitai/configuration_validator.rb +8 -1
- data/lib/henitai/coverage_bootstrapper.rb +54 -5
- data/lib/henitai/coverage_formatter.rb +5 -1
- data/lib/henitai/coverage_report_reader.rb +28 -5
- data/lib/henitai/execution_engine/env_scope.rb +67 -0
- data/lib/henitai/execution_engine.rb +111 -50
- data/lib/henitai/generated_artifacts.rb +58 -0
- data/lib/henitai/incremental_filter.rb +100 -0
- data/lib/henitai/integration/child_debug_support.rb +6 -2
- data/lib/henitai/integration/coverage_suppression.rb +9 -0
- data/lib/henitai/integration/minitest.rb +11 -43
- data/lib/henitai/integration/minitest_load_path.rb +14 -0
- data/lib/henitai/integration/minitest_suite_command.rb +17 -0
- data/lib/henitai/integration/minitest_test_runner.rb +39 -0
- data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
- data/lib/henitai/integration/rspec_child_runner.rb +3 -3
- data/lib/henitai/integration/rspec_test_selection.rb +3 -0
- data/lib/henitai/integration/scenario_log_support.rb +67 -20
- data/lib/henitai/minitest_coverage_reporter.rb +4 -1
- data/lib/henitai/mutant/activator.rb +14 -0
- data/lib/henitai/mutant.rb +13 -6
- data/lib/henitai/mutant_history_store/sql.rb +21 -3
- data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
- data/lib/henitai/mutant_history_store.rb +62 -12
- data/lib/henitai/mutant_identity.rb +33 -2
- data/lib/henitai/mutation_skip_directives.rb +227 -0
- data/lib/henitai/operator.rb +3 -1
- data/lib/henitai/operators/equality_identity_operator.rb +51 -0
- data/lib/henitai/operators/equality_operator.rb +8 -3
- data/lib/henitai/operators.rb +1 -0
- data/lib/henitai/per_test_coverage.rb +81 -0
- data/lib/henitai/per_test_coverage_collector.rb +20 -5
- data/lib/henitai/per_test_coverage_selector.rb +11 -33
- data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
- data/lib/henitai/reporter.rb +248 -117
- data/lib/henitai/reports_directory_lock.rb +76 -0
- data/lib/henitai/result.rb +75 -10
- data/lib/henitai/runner.rb +102 -43
- data/lib/henitai/scenario_execution_result.rb +12 -0
- data/lib/henitai/slot_scheduler/draining.rb +21 -15
- data/lib/henitai/slot_scheduler.rb +73 -15
- data/lib/henitai/static_filter.rb +16 -6
- data/lib/henitai/survivor_rerun_strategy.rb +1 -1
- data/lib/henitai/survivor_test_filter.rb +1 -1
- data/lib/henitai/test_prioritizer.rb +28 -2
- data/lib/henitai/timeout_calibrator.rb +44 -0
- data/lib/henitai/verdict_fingerprint.rb +155 -0
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +14 -1
- data/sig/henitai.rbs +235 -25
- metadata +22 -3
- data/lib/henitai/parallel_execution_runner.rb +0 -153
|
@@ -7,12 +7,19 @@ require "sqlite3"
|
|
|
7
7
|
require "time"
|
|
8
8
|
require_relative "mutant_identity"
|
|
9
9
|
require_relative "mutant_history_store/sql"
|
|
10
|
+
require_relative "mutant_history_store/verdict_cache"
|
|
10
11
|
|
|
11
12
|
module Henitai
|
|
12
13
|
# Persists mutant outcomes across runs in a lightweight SQLite database.
|
|
13
14
|
class MutantHistoryStore
|
|
14
|
-
|
|
15
|
+
include VerdictCache
|
|
16
|
+
|
|
17
|
+
# @param per_test_coverage [PerTestCoverage, nil] live per-test coverage
|
|
18
|
+
# view used to record the full-map intersection set for survived
|
|
19
|
+
# verdicts; without it survived rows stay NULL (never reusable).
|
|
20
|
+
def initialize(path:, per_test_coverage: nil)
|
|
15
21
|
@path = path
|
|
22
|
+
@per_test_coverage = per_test_coverage
|
|
16
23
|
end
|
|
17
24
|
|
|
18
25
|
attr_reader :path
|
|
@@ -31,6 +38,27 @@ module Henitai
|
|
|
31
38
|
end
|
|
32
39
|
end
|
|
33
40
|
|
|
41
|
+
# Verdict-cache lookup for `--incremental`: returns the stored hashes for
|
|
42
|
+
# the mutant's LATEST verdict when it is Killed or Survived — the upsert
|
|
43
|
+
# keeps exactly one row per stable id, so current_status is always the
|
|
44
|
+
# most recent outcome. nil for every other status, unknown ids and legacy
|
|
45
|
+
# rows without hashes (recorded before the cache columns existed).
|
|
46
|
+
def verdict_for(stable_id)
|
|
47
|
+
return nil unless File.exist?(path)
|
|
48
|
+
|
|
49
|
+
with_database do |db|
|
|
50
|
+
ensure_schema(db)
|
|
51
|
+
verdict_from_row(db.get_first_row(Sql::VERDICT_LOOKUP, stable_id))
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Killed-only view of {#verdict_for}, kept for the original killed reuse
|
|
56
|
+
# path.
|
|
57
|
+
def killed_verdict_for(stable_id)
|
|
58
|
+
verdict = verdict_for(stable_id)
|
|
59
|
+
verdict if verdict && verdict[:status] == :killed
|
|
60
|
+
end
|
|
61
|
+
|
|
34
62
|
def trend_report
|
|
35
63
|
with_database do |db|
|
|
36
64
|
ensure_schema(db)
|
|
@@ -44,6 +72,19 @@ module Henitai
|
|
|
44
72
|
|
|
45
73
|
private
|
|
46
74
|
|
|
75
|
+
attr_reader :per_test_coverage
|
|
76
|
+
|
|
77
|
+
def verdict_from_row(row)
|
|
78
|
+
return nil unless row && VerdictCache::CACHEABLE_STATUSES.include?(row["current_status"])
|
|
79
|
+
return nil if row["subject_source_hash"].nil? || row["covered_tests_fingerprint"].nil?
|
|
80
|
+
|
|
81
|
+
{
|
|
82
|
+
status: row["current_status"].to_sym,
|
|
83
|
+
subject_source_hash: row["subject_source_hash"],
|
|
84
|
+
covered_tests_fingerprint: row["covered_tests_fingerprint"]
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
47
88
|
def partial_rerun?(result)
|
|
48
89
|
result.respond_to?(:partial_rerun?) && result.partial_rerun?
|
|
49
90
|
end
|
|
@@ -59,6 +100,19 @@ module Henitai
|
|
|
59
100
|
def ensure_schema(db)
|
|
60
101
|
db.execute_batch(Sql::RUNS_TABLE)
|
|
61
102
|
db.execute_batch(Sql::MUTANTS_TABLE)
|
|
103
|
+
migrate_mutants_table(db)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Additive, idempotent in-place migration: databases created before the
|
|
107
|
+
# verdict-cache columns existed gain them on first contact, with existing
|
|
108
|
+
# rows left intact (NULL hashes — valid but never reusable).
|
|
109
|
+
def migrate_mutants_table(db)
|
|
110
|
+
existing = db.execute("PRAGMA table_info(mutants)").map { |row| row["name"] }
|
|
111
|
+
Sql::MIGRATION_COLUMNS.each do |column, type|
|
|
112
|
+
next if existing.include?(column)
|
|
113
|
+
|
|
114
|
+
db.execute("ALTER TABLE mutants ADD COLUMN #{column} #{type}")
|
|
115
|
+
end
|
|
62
116
|
end
|
|
63
117
|
|
|
64
118
|
def insert_run(db, result, version, recorded_at)
|
|
@@ -94,18 +148,12 @@ module Henitai
|
|
|
94
148
|
def mutant_history_data(db, mutant, version, recorded_at)
|
|
95
149
|
mutant_id = stable_mutant_id(mutant)
|
|
96
150
|
existing = existing_mutant_row(db, mutant_id)
|
|
97
|
-
history = existing_status_history(existing)
|
|
98
|
-
history << mutation_history_entry(mutant, version, recorded_at)
|
|
151
|
+
history = existing_status_history(existing) << mutation_history_entry(mutant, version, recorded_at)
|
|
99
152
|
first_seen = first_seen_metadata(existing, version, recorded_at)
|
|
100
153
|
|
|
101
154
|
{
|
|
102
|
-
mutant_id:
|
|
103
|
-
first_seen_version: first_seen[:version],
|
|
104
|
-
first_seen_at: first_seen[:at],
|
|
105
|
-
version: version,
|
|
106
|
-
recorded_at: recorded_at,
|
|
107
|
-
mutant: mutant,
|
|
108
|
-
history: history,
|
|
155
|
+
mutant_id:, version:, recorded_at:, mutant:, history:, existing_row: existing,
|
|
156
|
+
first_seen_version: first_seen[:version], first_seen_at: first_seen[:at],
|
|
109
157
|
days_alive: days_alive_since(first_seen[:at], recorded_at)
|
|
110
158
|
}
|
|
111
159
|
end
|
|
@@ -185,15 +233,17 @@ module Henitai
|
|
|
185
233
|
end
|
|
186
234
|
|
|
187
235
|
def upsert_mutant_bindings(data)
|
|
236
|
+
mutant = data.fetch(:mutant)
|
|
188
237
|
[
|
|
189
238
|
data.fetch(:mutant_id),
|
|
190
239
|
data.fetch(:first_seen_version),
|
|
191
240
|
data.fetch(:first_seen_at),
|
|
192
241
|
data.fetch(:version),
|
|
193
242
|
data.fetch(:recorded_at).iso8601,
|
|
194
|
-
|
|
243
|
+
mutant.status.to_s,
|
|
195
244
|
JSON.generate(data.fetch(:history)),
|
|
196
|
-
data.fetch(:days_alive)
|
|
245
|
+
data.fetch(:days_alive),
|
|
246
|
+
*verdict_cache_bindings(mutant, data[:existing_row])
|
|
197
247
|
]
|
|
198
248
|
end
|
|
199
249
|
end
|
|
@@ -10,10 +10,21 @@ module Henitai
|
|
|
10
10
|
# session UUID or source coordinates, so it survives ordinary line shifts.
|
|
11
11
|
module MutantIdentity
|
|
12
12
|
def self.stable_id(mutant)
|
|
13
|
-
|
|
13
|
+
digest(identity_components(mutant))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Stable id formula used before site offsets were introduced. Emitted as a
|
|
17
|
+
# temporary report alias so scoped runs can replace pre-migration entries.
|
|
18
|
+
def self.legacy_stable_id(mutant)
|
|
19
|
+
digest(legacy_identity_components(mutant))
|
|
14
20
|
end
|
|
15
21
|
|
|
16
22
|
def self.identity_components(mutant)
|
|
23
|
+
legacy_identity_components(mutant) + [site_offset(mutant)]
|
|
24
|
+
end
|
|
25
|
+
private_class_method :identity_components
|
|
26
|
+
|
|
27
|
+
def self.legacy_identity_components(mutant)
|
|
17
28
|
[
|
|
18
29
|
mutant.subject.expression,
|
|
19
30
|
mutant.operator,
|
|
@@ -22,7 +33,27 @@ module Henitai
|
|
|
22
33
|
mutation_signature(mutant)
|
|
23
34
|
]
|
|
24
35
|
end
|
|
25
|
-
private_class_method :
|
|
36
|
+
private_class_method :legacy_identity_components
|
|
37
|
+
|
|
38
|
+
def self.digest(components)
|
|
39
|
+
Digest::SHA256.hexdigest(components.join("\0"))
|
|
40
|
+
end
|
|
41
|
+
private_class_method :digest
|
|
42
|
+
|
|
43
|
+
# Position of the mutation site relative to its subject's own start,
|
|
44
|
+
# not the file's absolute line/column — this still disambiguates two
|
|
45
|
+
# call sites that produce the same operator/description/signature
|
|
46
|
+
# within one subject (e.g. two `MethodExpression — replaced with nil`
|
|
47
|
+
# mutants on different lines of a method), while surviving line drift
|
|
48
|
+
# elsewhere in the file, which would shift absolute coordinates but
|
|
49
|
+
# not this offset.
|
|
50
|
+
def self.site_offset(mutant)
|
|
51
|
+
subject_start = mutant.subject.source_range&.begin
|
|
52
|
+
return mutant.location[:start_col].to_s unless subject_start
|
|
53
|
+
|
|
54
|
+
"#{mutant.location[:start_line] - subject_start}:#{mutant.location[:start_col]}"
|
|
55
|
+
end
|
|
56
|
+
private_class_method :site_offset
|
|
26
57
|
|
|
27
58
|
def self.mutation_signature(mutant)
|
|
28
59
|
Unparser.unparse(mutant.mutated_node)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
# Reads `# henitai:disable` magic comments from subject source files and
|
|
7
|
+
# decides whether a mutant is excluded from the run.
|
|
8
|
+
#
|
|
9
|
+
# Grammar (backward compatible — a bare directive means "all operators"):
|
|
10
|
+
#
|
|
11
|
+
# # henitai:disable all operators, current scope
|
|
12
|
+
# # henitai:disable -- prose same; `--` introduces prose
|
|
13
|
+
# # henitai:disable OpA, OpB only the named operators
|
|
14
|
+
# # henitai:disable OpA: reason reason lands in the report
|
|
15
|
+
# # henitai:disable: reason all operators, with reason
|
|
16
|
+
# # henitai:disable-start [ops][: reason] region begin
|
|
17
|
+
# # henitai:disable-end region end
|
|
18
|
+
#
|
|
19
|
+
# Scopes: trailing comment (line), standalone comment directly above a
|
|
20
|
+
# `def` (method), and disable-start/disable-end pairs (region, no nesting).
|
|
21
|
+
# Operator names must exactly match the canonical registry names
|
|
22
|
+
# (`henitai operator list`); unknown names, unmatched or nested region
|
|
23
|
+
# directives raise Henitai::ConfigurationError with file:line.
|
|
24
|
+
#
|
|
25
|
+
# Matching mutants are reported as ignored by {StaticFilter}, not dropped.
|
|
26
|
+
class MutationSkipDirectives
|
|
27
|
+
DIRECTIVE = /\A#\s*henitai:disable(?<kind>-start|-end)?(?<rest>[:\s].*)?\z/
|
|
28
|
+
VALID_OPERATOR_NAMES = Operator::FULL_SET
|
|
29
|
+
|
|
30
|
+
# A parsed directive: +operators+ is nil (all) or a Set of canonical
|
|
31
|
+
# operator names; +reason+ is optional free text shown in reports.
|
|
32
|
+
Directive = Data.define(:operators, :reason) do
|
|
33
|
+
def match?(operator)
|
|
34
|
+
operators.nil? || operators.include?(operator.to_s)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Per-file directives, classified by scope.
|
|
39
|
+
Index = Data.define(:trailing, :standalone, :regions, :standalone_comment_lines)
|
|
40
|
+
EMPTY_INDEX = Index.new(
|
|
41
|
+
trailing: {}.freeze,
|
|
42
|
+
standalone: {}.freeze,
|
|
43
|
+
regions: [].freeze,
|
|
44
|
+
standalone_comment_lines: Set.new.freeze
|
|
45
|
+
).freeze
|
|
46
|
+
|
|
47
|
+
def initialize
|
|
48
|
+
@index_cache = {}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def skip?(mutant)
|
|
52
|
+
!directive_for(mutant).nil?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Directive, nil] the directive excluding this mutant, if any.
|
|
56
|
+
def directive_for(mutant)
|
|
57
|
+
line_directive_for(mutant) || region_directive_for(mutant) || method_directive_for(mutant)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def line_directive_for(mutant)
|
|
63
|
+
directive = index_for(mutant.location[:file]).trailing[mutant.location[:start_line]]
|
|
64
|
+
directive&.match?(mutant.operator) ? directive : nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def region_directive_for(mutant)
|
|
68
|
+
index = index_for(mutant.location[:file])
|
|
69
|
+
line = mutant.location[:start_line]
|
|
70
|
+
_range, directive = index.regions.find do |range, region_directive|
|
|
71
|
+
range.cover?(line) && region_directive.match?(mutant.operator)
|
|
72
|
+
end
|
|
73
|
+
directive
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def method_directive_for(mutant)
|
|
77
|
+
range = mutant.subject&.source_range
|
|
78
|
+
return nil unless range
|
|
79
|
+
|
|
80
|
+
index = index_for(mutant.subject.source_file)
|
|
81
|
+
line = range.begin - 1
|
|
82
|
+
while index.standalone_comment_lines.include?(line)
|
|
83
|
+
directive = index.standalone[line]
|
|
84
|
+
return directive if directive&.match?(mutant.operator)
|
|
85
|
+
|
|
86
|
+
line -= 1
|
|
87
|
+
end
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def index_for(path)
|
|
92
|
+
mtime = File.mtime(path)
|
|
93
|
+
cached_mtime, cached_index = @index_cache[path]
|
|
94
|
+
return cached_index if cached_mtime == mtime
|
|
95
|
+
|
|
96
|
+
index = build_index(path)
|
|
97
|
+
@index_cache[path] = [mtime, index]
|
|
98
|
+
index
|
|
99
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
100
|
+
EMPTY_INDEX
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def build_index(path)
|
|
104
|
+
source = File.read(path)
|
|
105
|
+
lines = source.lines
|
|
106
|
+
builder = IndexBuilder.new(path)
|
|
107
|
+
|
|
108
|
+
Prism.parse(source).comments.sort_by { |comment| comment.location.start_line }.each do |comment|
|
|
109
|
+
builder.record(comment, standalone: standalone?(comment, lines))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
builder.index
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def standalone?(comment, lines)
|
|
116
|
+
prefix = lines[comment.location.start_line - 1].to_s[0...comment.location.start_column]
|
|
117
|
+
prefix.strip.empty?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Accumulates classified directives for one file, tracking open
|
|
121
|
+
# disable-start regions and raising on malformed directive usage.
|
|
122
|
+
class IndexBuilder
|
|
123
|
+
def initialize(path)
|
|
124
|
+
@path = path
|
|
125
|
+
@trailing = {}
|
|
126
|
+
@standalone = {}
|
|
127
|
+
@regions = []
|
|
128
|
+
@standalone_comment_lines = Set.new
|
|
129
|
+
@open_region = nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def record(comment, standalone:)
|
|
133
|
+
line = comment.location.start_line
|
|
134
|
+
@standalone_comment_lines << line if standalone
|
|
135
|
+
match = DIRECTIVE.match(comment.slice)
|
|
136
|
+
return unless match
|
|
137
|
+
|
|
138
|
+
dispatch(match, line, standalone)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def index
|
|
142
|
+
ensure_all_regions_closed!
|
|
143
|
+
|
|
144
|
+
Index.new(
|
|
145
|
+
trailing: @trailing.freeze,
|
|
146
|
+
standalone: @standalone.freeze,
|
|
147
|
+
regions: @regions.freeze,
|
|
148
|
+
standalone_comment_lines: @standalone_comment_lines.freeze
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def ensure_all_regions_closed!
|
|
155
|
+
return unless @open_region
|
|
156
|
+
|
|
157
|
+
error("unclosed `henitai:disable-start` (opened at line #{@open_region.fetch(:line)})")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def dispatch(match, line, standalone)
|
|
161
|
+
case match[:kind]
|
|
162
|
+
when "-start" then open_region(parse_payload(match[:rest], line), line)
|
|
163
|
+
when "-end" then close_region(line)
|
|
164
|
+
else
|
|
165
|
+
bucket = standalone ? @standalone : @trailing
|
|
166
|
+
bucket[line] = parse_payload(match[:rest], line)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def open_region(directive, line)
|
|
171
|
+
error("nested `henitai:disable-start` at line #{line}") if @open_region
|
|
172
|
+
|
|
173
|
+
@open_region = { line:, directive: }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def close_region(line)
|
|
177
|
+
error("`henitai:disable-end` without a matching start at line #{line}") unless @open_region
|
|
178
|
+
|
|
179
|
+
@regions << [(@open_region.fetch(:line)..line), @open_region.fetch(:directive)]
|
|
180
|
+
@open_region = nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def parse_payload(rest, line)
|
|
184
|
+
text = rest.to_s.strip
|
|
185
|
+
return Directive.new(operators: nil, reason: nil) if prose?(text)
|
|
186
|
+
return Directive.new(operators: nil, reason: presence(text[1..])) if text.start_with?(":")
|
|
187
|
+
|
|
188
|
+
operators_part, reason = text.split(":", 2)
|
|
189
|
+
Directive.new(
|
|
190
|
+
operators: parse_operator_names(operators_part, line),
|
|
191
|
+
reason: presence(reason)
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Free-form rationale after a bare directive stays valid (pre-grammar
|
|
196
|
+
# behavior): empty, `-- prose`, or anything whose first token cannot be
|
|
197
|
+
# an operator name (operators are CamelCase). Only CamelCase-looking
|
|
198
|
+
# tokens are validated against the registry and can raise.
|
|
199
|
+
def prose?(text)
|
|
200
|
+
text.empty? || text.start_with?("--") ||
|
|
201
|
+
(!text.start_with?(":") && !text.match?(/\A[A-Z]/))
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def parse_operator_names(operators_part, line)
|
|
205
|
+
names = operators_part.split(",", -1).map(&:strip)
|
|
206
|
+
names.each do |name|
|
|
207
|
+
next if VALID_OPERATOR_NAMES.include?(name)
|
|
208
|
+
|
|
209
|
+
error(
|
|
210
|
+
"unknown operator #{name.inspect} in `henitai:disable` directive at line #{line} " \
|
|
211
|
+
"(valid names: `henitai operator list`)"
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
names.to_set
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def presence(text)
|
|
218
|
+
stripped = text.to_s.strip
|
|
219
|
+
stripped.empty? ? nil : stripped
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def error(message)
|
|
223
|
+
raise Henitai::ConfigurationError, "#{@path}: #{message}"
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
data/lib/henitai/operator.rb
CHANGED
|
@@ -14,7 +14,8 @@ module Henitai
|
|
|
14
14
|
# Additional operators (full set):
|
|
15
15
|
# ArrayDeclaration, HashLiteral, RangeLiteral, SafeNavigation,
|
|
16
16
|
# PatternMatch, BlockStatement, MethodExpression, AssignmentExpression,
|
|
17
|
-
# UnaryOperator, UpdateOperator, RegexMutator, MethodChainUnwrap
|
|
17
|
+
# UnaryOperator, UpdateOperator, RegexMutator, MethodChainUnwrap,
|
|
18
|
+
# EqualityIdentityOperator
|
|
18
19
|
#
|
|
19
20
|
# Each operator subclass must implement:
|
|
20
21
|
# - .node_types → Array<Symbol> AST node types this operator handles
|
|
@@ -43,6 +44,7 @@ module Henitai
|
|
|
43
44
|
AssignmentExpression
|
|
44
45
|
UnaryOperator
|
|
45
46
|
UpdateOperator
|
|
47
|
+
EqualityIdentityOperator
|
|
46
48
|
]).freeze
|
|
47
49
|
|
|
48
50
|
# @param set [Symbol] :light or :full
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../parser_current"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
module Operators
|
|
7
|
+
# Replaces relational operators with identity methods (`eql?`, `equal?`)
|
|
8
|
+
# and vice versa.
|
|
9
|
+
#
|
|
10
|
+
# This is the noisy half of the equality/identity pairing split out of
|
|
11
|
+
# EqualityOperator: most Ruby objects don't observably distinguish `==`
|
|
12
|
+
# from `eql?`/`equal?`, so these mutations are frequently unkillable by
|
|
13
|
+
# ordinary tests. They stay available in the full operator set rather
|
|
14
|
+
# than the default light set.
|
|
15
|
+
class EqualityIdentityOperator < Henitai::Operator
|
|
16
|
+
NODE_TYPES = [:send].freeze
|
|
17
|
+
RELATIONAL = %i[== != < > <= >= <=>].freeze
|
|
18
|
+
IDENTITY = %i[eql? equal?].freeze
|
|
19
|
+
OPERATORS = (RELATIONAL + IDENTITY).freeze
|
|
20
|
+
|
|
21
|
+
def self.node_types
|
|
22
|
+
NODE_TYPES
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def mutate(node, subject:)
|
|
26
|
+
method_name = node.children[1]
|
|
27
|
+
return [] unless OPERATORS.include?(method_name)
|
|
28
|
+
|
|
29
|
+
OPERATORS.each_with_object([]) do |replacement, mutants|
|
|
30
|
+
next if replacement == method_name
|
|
31
|
+
next if RELATIONAL.include?(method_name) && RELATIONAL.include?(replacement)
|
|
32
|
+
|
|
33
|
+
mutants << build_mutant(
|
|
34
|
+
subject:,
|
|
35
|
+
original_node: node,
|
|
36
|
+
mutated_node: mutated_node(node, replacement),
|
|
37
|
+
description: "replaced #{method_name} with #{replacement}"
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def mutated_node(node, replacement)
|
|
45
|
+
receiver = node.children[0]
|
|
46
|
+
arguments = node.children[2..]
|
|
47
|
+
Parser::AST::Node.new(:send, [receiver, replacement, *arguments])
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -4,10 +4,15 @@ require_relative "../parser_current"
|
|
|
4
4
|
|
|
5
5
|
module Henitai
|
|
6
6
|
module Operators
|
|
7
|
-
# Replaces
|
|
7
|
+
# Replaces relational/equality operators with the other relational operators.
|
|
8
|
+
#
|
|
9
|
+
# Identity-method comparisons (`eql?`, `equal?`) are handled separately by
|
|
10
|
+
# EqualityIdentityOperator: that pairing is the hardest to kill in practice
|
|
11
|
+
# (most objects don't observably distinguish `==` from `eql?`/`equal?`), so
|
|
12
|
+
# it is kept out of the default light set.
|
|
8
13
|
class EqualityOperator < Henitai::Operator
|
|
9
14
|
NODE_TYPES = [:send].freeze
|
|
10
|
-
OPERATORS = %i[== != < > <= >= <=>
|
|
15
|
+
OPERATORS = %i[== != < > <= >= <=>].freeze
|
|
11
16
|
|
|
12
17
|
def self.node_types
|
|
13
18
|
NODE_TYPES
|
|
@@ -33,7 +38,7 @@ module Henitai
|
|
|
33
38
|
|
|
34
39
|
def mutated_node(node, replacement)
|
|
35
40
|
receiver = node.children[0]
|
|
36
|
-
arguments = node.children[2..]
|
|
41
|
+
arguments = node.children[2..]
|
|
37
42
|
Parser::AST::Node.new(:send, [receiver, replacement, *arguments])
|
|
38
43
|
end
|
|
39
44
|
end
|
data/lib/henitai/operators.rb
CHANGED
|
@@ -8,6 +8,7 @@ module Henitai
|
|
|
8
8
|
module Operators
|
|
9
9
|
autoload :ArithmeticOperator, "henitai/operators/arithmetic_operator"
|
|
10
10
|
autoload :EqualityOperator, "henitai/operators/equality_operator"
|
|
11
|
+
autoload :EqualityIdentityOperator, "henitai/operators/equality_identity_operator"
|
|
11
12
|
autoload :LogicalOperator, "henitai/operators/logical_operator"
|
|
12
13
|
autoload :BooleanLiteral, "henitai/operators/boolean_literal"
|
|
13
14
|
autoload :ConditionalExpression, "henitai/operators/conditional_expression"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Read-side view of the per-test coverage map (henitai_per_test.json):
|
|
5
|
+
# which test files reach which source lines. The single implementation of
|
|
6
|
+
# the line-intersection check, shared by test selection
|
|
7
|
+
# (PerTestCoverageSelector) and survivor verdict reuse (IncrementalFilter),
|
|
8
|
+
# so both callers always agree on what "covers" means.
|
|
9
|
+
class PerTestCoverage
|
|
10
|
+
def initialize(reports_dir:, coverage_report_reader: CoverageReportReader.new)
|
|
11
|
+
@reports_dir = reports_dir
|
|
12
|
+
@coverage_report_reader = coverage_report_reader
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def available?
|
|
16
|
+
!map.empty?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The full-map intersection set: every test file whose covered lines
|
|
20
|
+
# intersect the mutant's current line range.
|
|
21
|
+
#
|
|
22
|
+
# @return [Array<String>] sorted test file paths; empty when the map or
|
|
23
|
+
# the mutant's location is unavailable — callers must treat that as
|
|
24
|
+
# "unknown", never as "not covered".
|
|
25
|
+
def tests_covering(mutant)
|
|
26
|
+
return [] unless location_available?(mutant)
|
|
27
|
+
|
|
28
|
+
map.keys.select { |test| covers?(test, mutant) }.sort
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The source files the given test file's recorded coverage reaches.
|
|
32
|
+
# Test keys are matched path-insensitively (relative or absolute form);
|
|
33
|
+
# returned source paths are absolute, as recorded.
|
|
34
|
+
#
|
|
35
|
+
# @return [Array<String>] sorted; empty when the test is unknown or the
|
|
36
|
+
# map is unavailable.
|
|
37
|
+
def source_files_covered_by(test_file)
|
|
38
|
+
wanted = File.expand_path(test_file.to_s)
|
|
39
|
+
map.select { |test, _| File.expand_path(test) == wanted }
|
|
40
|
+
.values
|
|
41
|
+
.flat_map(&:keys)
|
|
42
|
+
.uniq
|
|
43
|
+
.sort
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# True when the given test file's recorded coverage intersects the
|
|
47
|
+
# mutant's current line range.
|
|
48
|
+
def covers?(test, mutant)
|
|
49
|
+
return false unless location_available?(mutant)
|
|
50
|
+
|
|
51
|
+
coverage_lines_for(test, mutant).intersect?(mutant_lines(mutant))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
attr_reader :reports_dir, :coverage_report_reader
|
|
57
|
+
|
|
58
|
+
def location_available?(mutant)
|
|
59
|
+
mutant.respond_to?(:location) &&
|
|
60
|
+
mutant.location.is_a?(Hash) &&
|
|
61
|
+
mutant.location[:file] &&
|
|
62
|
+
mutant.location[:start_line] &&
|
|
63
|
+
mutant.location[:end_line]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def coverage_lines_for(test, mutant)
|
|
67
|
+
source_map = map[test.to_s] || {}
|
|
68
|
+
Array(source_map[File.expand_path(mutant.location[:file])]).uniq
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def mutant_lines(mutant)
|
|
72
|
+
(mutant.location[:start_line]..mutant.location[:end_line]).to_a
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def map
|
|
76
|
+
@map ||= coverage_report_reader.test_lines_by_file(
|
|
77
|
+
File.join(reports_dir, PerTestCoverageCollector::REPORT_FILE_NAME)
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -18,11 +18,12 @@ module Henitai
|
|
|
18
18
|
@coverage_by_test = Hash.new do |hash, test_file|
|
|
19
19
|
hash[test_file] = Hash.new { |nested, source_file| nested[source_file] = [] }
|
|
20
20
|
end
|
|
21
|
+
@duration_by_test = Hash.new(0.0)
|
|
21
22
|
@previous_snapshot = {}
|
|
22
23
|
@warned_missing_coverage = false
|
|
23
24
|
end
|
|
24
25
|
|
|
25
|
-
def record_test(test_file)
|
|
26
|
+
def record_test(test_file, duration: nil)
|
|
26
27
|
snapshot = current_snapshot
|
|
27
28
|
return warn_missing_coverage unless snapshot
|
|
28
29
|
|
|
@@ -31,6 +32,7 @@ module Henitai
|
|
|
31
32
|
@coverage_by_test[test_file][source_file].uniq!
|
|
32
33
|
@coverage_by_test[test_file][source_file].sort!
|
|
33
34
|
end
|
|
35
|
+
@duration_by_test[test_file] += duration.to_f
|
|
34
36
|
@previous_snapshot = snapshot
|
|
35
37
|
end
|
|
36
38
|
|
|
@@ -61,11 +63,20 @@ module Henitai
|
|
|
61
63
|
|
|
62
64
|
def warn_missing_coverage
|
|
63
65
|
return if @warned_missing_coverage
|
|
66
|
+
return if coverage_suppressed?
|
|
64
67
|
|
|
65
68
|
warn "Per-test coverage unavailable; skipping coverage formatter output"
|
|
66
69
|
@warned_missing_coverage = true
|
|
67
70
|
end
|
|
68
71
|
|
|
72
|
+
# Mutant children deliberately suppress coverage startup
|
|
73
|
+
# (CoverageRuntimeSuppressors), so missing coverage is expected there and
|
|
74
|
+
# warning on every child run is pure noise.
|
|
75
|
+
def coverage_suppressed?
|
|
76
|
+
defined?(Henitai::Integration::CoverageRuntimeSuppressors) &&
|
|
77
|
+
Henitai::Integration::CoverageRuntimeSuppressors.active?
|
|
78
|
+
end
|
|
79
|
+
|
|
69
80
|
def new_lines(snapshot)
|
|
70
81
|
snapshot.each_with_object({}) do |(source_file, file_coverage), result|
|
|
71
82
|
next unless source_file?(source_file)
|
|
@@ -78,10 +89,13 @@ module Henitai
|
|
|
78
89
|
end
|
|
79
90
|
end
|
|
80
91
|
|
|
92
|
+
# Count-delta attribution: a line belongs to every test whose run
|
|
93
|
+
# incremented its cumulative count, not just the first toucher. This keeps
|
|
94
|
+
# the per-test map a complete over-approximation of reachability — the
|
|
95
|
+
# soundness precondition for survivor verdict reuse (ADR-11).
|
|
81
96
|
def new_line_numbers(file_coverage, previous_counts)
|
|
82
97
|
line_counts_for(file_coverage).each_with_index.filter_map do |count, index|
|
|
83
|
-
next unless count.to_i.
|
|
84
|
-
next if previous_counts.fetch(index, 0).to_i.positive?
|
|
98
|
+
next unless count.to_i > previous_counts.fetch(index, 0).to_i
|
|
85
99
|
|
|
86
100
|
index + 1
|
|
87
101
|
end
|
|
@@ -111,10 +125,11 @@ module Henitai
|
|
|
111
125
|
end
|
|
112
126
|
|
|
113
127
|
def serializable_report
|
|
114
|
-
@coverage_by_test.
|
|
115
|
-
source_map.to_h do |source_file, lines|
|
|
128
|
+
@coverage_by_test.to_h do |test_file, source_map|
|
|
129
|
+
coverage = source_map.to_h do |source_file, lines|
|
|
116
130
|
[File.expand_path(source_file), lines.uniq.sort]
|
|
117
131
|
end
|
|
132
|
+
[test_file, { "coverage" => coverage, "duration" => @duration_by_test[test_file] }]
|
|
118
133
|
end
|
|
119
134
|
end
|
|
120
135
|
end
|