henitai 0.5.3 → 0.6.0

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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +222 -1
  3. data/README.md +100 -13
  4. data/assets/schema/henitai.schema.json +20 -0
  5. data/lib/henitai/arid_node_filter.rb +4 -19
  6. data/lib/henitai/bootstrap_error_message.rb +20 -0
  7. data/lib/henitai/canonical_report_merger.rb +23 -2
  8. data/lib/henitai/cli/run_command.rb +25 -7
  9. data/lib/henitai/cli/run_options.rb +1 -1
  10. data/lib/henitai/cli.rb +4 -1
  11. data/lib/henitai/configuration.rb +4 -1
  12. data/lib/henitai/configuration_validator/rules.rb +8 -0
  13. data/lib/henitai/configuration_validator/scalars.rb +9 -0
  14. data/lib/henitai/configuration_validator.rb +2 -1
  15. data/lib/henitai/constant_path.rb +48 -0
  16. data/lib/henitai/control_run.rb +73 -0
  17. data/lib/henitai/coverage_bootstrapper.rb +36 -10
  18. data/lib/henitai/coverage_formatter.rb +26 -3
  19. data/lib/henitai/coverage_report_reader.rb +21 -0
  20. data/lib/henitai/equivalence_detector/operand_predicates.rb +16 -0
  21. data/lib/henitai/equivalence_detector.rb +46 -38
  22. data/lib/henitai/example_summary.rb +24 -0
  23. data/lib/henitai/execution_engine/fork_server_scope.rb +57 -0
  24. data/lib/henitai/execution_engine.rb +75 -60
  25. data/lib/henitai/fidelity_check.rb +173 -0
  26. data/lib/henitai/flaky_detection_baseline.rb +61 -0
  27. data/lib/henitai/flaky_mutant_retrier.rb +43 -0
  28. data/lib/henitai/flaky_retry_decision.rb +23 -0
  29. data/lib/henitai/flaky_test_registry.rb +63 -0
  30. data/lib/henitai/fork_server/client.rb +231 -0
  31. data/lib/henitai/fork_server/group_killer.rb +48 -0
  32. data/lib/henitai/fork_server/group_terminator.rb +47 -0
  33. data/lib/henitai/fork_server/launcher.rb +38 -0
  34. data/lib/henitai/fork_server/mutant_runner.rb +141 -0
  35. data/lib/henitai/fork_server/protocol.rb +73 -0
  36. data/lib/henitai/fork_server/reaper.rb +24 -0
  37. data/lib/henitai/fork_server/runtime.rb +109 -0
  38. data/lib/henitai/fork_server/server.rb +108 -0
  39. data/lib/henitai/fork_server/server_process.rb +73 -0
  40. data/lib/henitai/fork_server/status.rb +52 -0
  41. data/lib/henitai/fork_server.rb +43 -0
  42. data/lib/henitai/generated_artifacts.rb +3 -0
  43. data/lib/henitai/git_diff_analyzer.rb +28 -11
  44. data/lib/henitai/incremental_filter.rb +20 -10
  45. data/lib/henitai/integration/base.rb +72 -5
  46. data/lib/henitai/integration/capped_log_sink.rb +34 -0
  47. data/lib/henitai/integration/child_bootstrap.rb +7 -0
  48. data/lib/henitai/integration/child_report_store.rb +36 -0
  49. data/lib/henitai/integration/minitest.rb +7 -5
  50. data/lib/henitai/integration/minitest_autorun.rb +20 -0
  51. data/lib/henitai/integration/minitest_outcome_reporter.rb +28 -0
  52. data/lib/henitai/integration/minitest_preload.rb +30 -0
  53. data/lib/henitai/integration/minitest_runnable_filter.rb +61 -0
  54. data/lib/henitai/integration/minitest_test_runner.rb +32 -15
  55. data/lib/henitai/integration/mutant_run_support.rb +73 -13
  56. data/lib/henitai/integration/rspec_child_runner.rb +44 -9
  57. data/lib/henitai/integration/rspec_preload.rb +35 -0
  58. data/lib/henitai/integration/rspec_process_runner.rb +4 -1
  59. data/lib/henitai/integration/scenario_log_support.rb +31 -18
  60. data/lib/henitai/integration.rb +16 -3
  61. data/lib/henitai/minitest_coverage_reporter.rb +22 -6
  62. data/lib/henitai/minitest_result_file.rb +62 -0
  63. data/lib/henitai/mutant/activation.rb +32 -0
  64. data/lib/henitai/mutant/activator.rb +75 -55
  65. data/lib/henitai/mutant/def_injection.rb +136 -0
  66. data/lib/henitai/mutant/lexical_scope_check.rb +120 -0
  67. data/lib/henitai/mutant/lexical_target.rb +105 -0
  68. data/lib/henitai/mutant/replacement.rb +52 -0
  69. data/lib/henitai/mutant.rb +27 -8
  70. data/lib/henitai/mutant_generator.rb +62 -18
  71. data/lib/henitai/mutant_history_store/verdict_cache.rb +12 -11
  72. data/lib/henitai/mutant_history_store.rb +14 -4
  73. data/lib/henitai/mutant_identity.rb +5 -1
  74. data/lib/henitai/mutant_verdict.rb +71 -0
  75. data/lib/henitai/operators/array_declaration.rb +4 -0
  76. data/lib/henitai/operators/assignment_expression.rb +2 -0
  77. data/lib/henitai/operators/conditional_expression.rb +21 -1
  78. data/lib/henitai/operators/hash_literal.rb +7 -4
  79. data/lib/henitai/operators/method_expression.rb +14 -3
  80. data/lib/henitai/operators/regex_metacharacters.rb +89 -0
  81. data/lib/henitai/operators/regex_mutator.rb +23 -28
  82. data/lib/henitai/operators/return_value.rb +1 -1
  83. data/lib/henitai/operators.rb +1 -0
  84. data/lib/henitai/per_test_coverage.rb +11 -7
  85. data/lib/henitai/per_test_coverage_collector.rb +30 -31
  86. data/lib/henitai/per_test_coverage_delta.rb +60 -0
  87. data/lib/henitai/per_test_coverage_selector.rb +26 -13
  88. data/lib/henitai/process_wakeup.rb +8 -3
  89. data/lib/henitai/process_worker_runner.rb +19 -5
  90. data/lib/henitai/reporter.rb +15 -8
  91. data/lib/henitai/result.rb +32 -16
  92. data/lib/henitai/reuse_context.rb +50 -0
  93. data/lib/henitai/rspec_coverage_formatter.rb +4 -0
  94. data/lib/henitai/rspec_outcomes.rb +72 -0
  95. data/lib/henitai/runner.rb +58 -19
  96. data/lib/henitai/runner_dependencies.rb +22 -1
  97. data/lib/henitai/sampling_strategy.rb +36 -6
  98. data/lib/henitai/scenario_execution_result.rb +28 -5
  99. data/lib/henitai/signature_line.rb +21 -0
  100. data/lib/henitai/slot_scheduler/draining.rb +12 -2
  101. data/lib/henitai/slot_scheduler/process_control.rb +33 -0
  102. data/lib/henitai/slot_scheduler/retry_policy.rb +8 -6
  103. data/lib/henitai/slot_scheduler.rb +47 -8
  104. data/lib/henitai/source_file_selection.rb +1 -1
  105. data/lib/henitai/source_parser.rb +29 -9
  106. data/lib/henitai/static_filter.rb +45 -14
  107. data/lib/henitai/stillborn_filter.rb +70 -2
  108. data/lib/henitai/subject.rb +8 -3
  109. data/lib/henitai/subject_resolver.rb +65 -31
  110. data/lib/henitai/suite_boot_clock.rb +48 -0
  111. data/lib/henitai/survivor_activation_cache.rb +18 -11
  112. data/lib/henitai/survivor_rerun_strategy.rb +47 -43
  113. data/lib/henitai/test_file_outcomes.rb +79 -0
  114. data/lib/henitai/timeout_calibrator.rb +31 -9
  115. data/lib/henitai/timeout_ceiling.rb +37 -0
  116. data/lib/henitai/verdict_fingerprint.rb +29 -30
  117. data/lib/henitai/version.rb +1 -1
  118. data/lib/henitai.rb +17 -1
  119. data/sig/configuration_validator.rbs +1 -0
  120. data/sig/henitai.rbs +225 -45
  121. metadata +46 -3
  122. data/lib/henitai/survivor_test_filter.rb +0 -72
@@ -43,7 +43,8 @@ module Henitai
43
43
  survivors_from: resolved_survivors_from,
44
44
  mode: {
45
45
  dry_run: options.fetch(:dry_run, false),
46
- incremental: options.fetch(:incremental, false) && !options.fetch(:force, false)
46
+ incremental: options.fetch(:incremental, false) && !options.fetch(:force, false),
47
+ force: options.fetch(:force, false)
47
48
  }
48
49
  )
49
50
  runner.run
@@ -64,7 +65,7 @@ module Henitai
64
65
  # being a snapshot path; this keeps activation-recipes lookup correct.
65
66
  return survivors_from if File.basename(parent_dir) == "sessions"
66
67
 
67
- session_id = session_id_from_report(survivors_from)
68
+ session_id = single_session_id_from_report(survivors_from)
68
69
  return survivors_from if session_id.nil?
69
70
 
70
71
  snapshot_path = survivors_snapshot_path(report_dir, session_id)
@@ -85,9 +86,16 @@ module Henitai
85
86
  File.join(report_dir, "sessions", session_id, "mutation-report.json")
86
87
  end
87
88
 
88
- def session_id_from_report(path)
89
+ # The report's sessionId only names a snapshot holding every survivor when
90
+ # each file entry was produced by that very session. A merged canonical
91
+ # report (or a legacy one without per-file provenance) is used as is.
92
+ def single_session_id_from_report(path)
89
93
  parsed = JSON.parse(File.read(path))
90
- parsed["sessionId"]
94
+ session_id = parsed["sessionId"]
95
+ files = parsed["files"]
96
+ return nil unless files.is_a?(Hash) && files.each_value.all? { |f| f["sessionId"] == session_id }
97
+
98
+ session_id
91
99
  rescue JSON::ParserError, Errno::ENOENT
92
100
  nil
93
101
  end
@@ -104,6 +112,18 @@ module Henitai
104
112
  strict_status || threshold_status_for(result, config)
105
113
  end
106
114
 
115
+ # An empty score denominator passes only when there was nothing to score
116
+ # (no changed code, everything ignored). When harness errors emptied it,
117
+ # nothing was tested, and the run must not pass its threshold.
118
+ def empty_score_status(result)
119
+ harness_errors = result.mutants.count { |mutant| mutant.status == :compile_error }
120
+ return 0 if harness_errors.zero?
121
+
122
+ warn "henitai: no mutant could be scored: #{harness_errors} harness error(s) " \
123
+ "(CompileError, see statusReason in the report)"
124
+ 1
125
+ end
126
+
107
127
  # Expanded, opt-in exit codes (precedence: timeout > runtime/compile
108
128
  # error > threshold miss). The timeout code is informational and
109
129
  # independent of coverage_criteria.timeout: a run can pass its threshold
@@ -118,9 +138,7 @@ module Henitai
118
138
 
119
139
  def threshold_status_for(result, config)
120
140
  score = result.mutation_score
121
- # No valid mutants to evaluate (e.g. an incremental run with no changed
122
- # code) cannot fail a threshold — treat it as success.
123
- return 0 if score.nil?
141
+ return empty_score_status(result) if score.nil?
124
142
 
125
143
  score.to_i >= config.thresholds.fetch(:low, 60) ? 0 : 1
126
144
  end
@@ -79,7 +79,7 @@ module Henitai
79
79
  def add_force_option(opts, options)
80
80
  opts.on(
81
81
  "--force",
82
- "Bypass verdict reuse and execute every mutant (only meaningful with --incremental)"
82
+ "Bypass verdict reuse (--incremental, --survivors-from) and execute every mutant"
83
83
  ) do
84
84
  options[:force] = true
85
85
  end
data/lib/henitai/cli.rb CHANGED
@@ -37,7 +37,10 @@ module Henitai
37
37
  %w[coverage .resultset.json],
38
38
  %w[coverage .last_run.json],
39
39
  ["henitai_per_test.json"],
40
- [CoverageBootstrapper::DEPENDENCY_MANIFEST_FILE]
40
+ [PerTestCoverageCollector::BOOT_FILE_NAME],
41
+ [CoverageBootstrapper::DEPENDENCY_MANIFEST_FILE],
42
+ [FlakyTestRegistry::FILE_NAME],
43
+ [TestFileOutcomes::REPORT_FILE_NAME]
41
44
  ].freeze
42
45
 
43
46
  # Whole directories of per-mutant scratch artifacts, removed recursively.
@@ -43,7 +43,7 @@ module Henitai
43
43
 
44
44
  attr_reader :integration, :includes, :excludes, :test_excludes, :operators, :timeout,
45
45
  :timeout_multiplier, :ignore_patterns, :sampling, :jobs,
46
- :max_flaky_retries, :max_log_bytes, :max_timeout,
46
+ :max_flaky_retries, :max_log_bytes, :max_timeout, :control_runs, :flaky_detection, :fork_server,
47
47
  :coverage_criteria, :thresholds,
48
48
  :reporters, :reports_dir,
49
49
  :checkpoint_enabled, :checkpoint_every, :checkpoint_interval,
@@ -124,6 +124,9 @@ module Henitai
124
124
  @max_flaky_retries = mutation.fetch(:max_flaky_retries, DEFAULT_MAX_FLAKY_RETRIES)
125
125
  @max_log_bytes = mutation[:max_log_bytes] || DEFAULT_MAX_LOG_BYTES
126
126
  @max_timeout = mutation[:max_timeout] || DEFAULT_MAX_TIMEOUT
127
+ @control_runs = mutation.fetch(:control_runs, true)
128
+ @flaky_detection = mutation.fetch(:flaky_detection, false)
129
+ @fork_server = mutation.fetch(:fork_server, false)
127
130
  end
128
131
 
129
132
  def apply_reports_defaults(raw)
@@ -100,6 +100,13 @@ module Henitai
100
100
  Scalars.validate_max_flaky_retries(value[:max_flaky_retries])
101
101
  Scalars.validate_max_log_bytes(value[:max_log_bytes])
102
102
  Scalars.validate_max_timeout(value[:max_timeout])
103
+ validate_mutation_flags(value)
104
+ end
105
+
106
+ def validate_mutation_flags(value)
107
+ %i[control_runs flaky_detection fork_server].each do |key|
108
+ Scalars.validate_boolean(value[key], "mutation.#{key}") unless value[key].nil?
109
+ end
103
110
  end
104
111
 
105
112
  def validate_mutation_filters(value)
@@ -133,6 +140,7 @@ module Henitai
133
140
  Scalars.validate_sampling_completeness(value)
134
141
  Scalars.validate_sampling_ratio(value[:ratio])
135
142
  Scalars.validate_sampling_strategy(value[:strategy])
143
+ Scalars.validate_sampling_seed(value[:seed])
136
144
  end
137
145
 
138
146
  def warn_unknown_keys(raw, allowed_keys, path = nil)
@@ -150,6 +150,15 @@ module Henitai
150
150
  )
151
151
  end
152
152
 
153
+ def validate_sampling_seed(value)
154
+ return if value.nil?
155
+ return if value.is_a?(Integer) && value >= 0
156
+
157
+ Rules.configuration_error(
158
+ "Invalid configuration value for mutation.sampling.seed: expected Integer >= 0, got #{value.inspect}"
159
+ )
160
+ end
161
+
153
162
  def validate_sampling_completeness(value)
154
163
  return if value.key?(:ratio) && value.key?(:strategy)
155
164
 
@@ -26,9 +26,10 @@ module Henitai
26
26
  ].freeze
27
27
  VALID_MUTATION_KEYS = %i[
28
28
  operators timeout timeout_multiplier ignore_patterns max_flaky_retries max_log_bytes max_timeout sampling
29
+ control_runs flaky_detection fork_server
29
30
  ].freeze
30
31
  VALID_REPORTS_KEYS = %i[checkpoint checkpoint_every checkpoint_interval].freeze
31
- VALID_SAMPLING_KEYS = %i[ratio strategy].freeze
32
+ VALID_SAMPLING_KEYS = %i[ratio strategy seed].freeze
32
33
  VALID_COVERAGE_CRITERIA_KEYS = %i[test_result timeout process_abort].freeze
33
34
  VALID_THRESHOLDS_KEYS = %i[high low].freeze
34
35
  VALID_DASHBOARD_KEYS = %i[project base_url].freeze
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Names constants as the source writes them and attributes them to the
5
+ # namespace they belong to, as far as the source alone can tell.
6
+ module ConstantPath
7
+ module_function
8
+
9
+ # The constant path of a +const+ or +casgn+ node as written;
10
+ # root-qualified paths keep their leading +::+. nil for other nodes.
11
+ def of(node)
12
+ return unless node.respond_to?(:type)
13
+
14
+ case node.type
15
+ when :const, :casgn
16
+ parent = of(node.children[0])
17
+ own = node.children[1].to_s
18
+ parent.nil? ? own : "#{parent}::#{own}"
19
+ when :cbase
20
+ ""
21
+ end
22
+ end
23
+
24
+ # +name+ inside +namespace+. A root-qualified name (+::Top+) ignores the
25
+ # enclosing namespace.
26
+ def qualify(namespace, name)
27
+ return name.delete_prefix("::") if name&.start_with?("::")
28
+ return name if namespace.nil? || namespace.empty?
29
+ return namespace if name.nil? || name.empty?
30
+
31
+ "#{namespace}::#{name}"
32
+ end
33
+
34
+ # The object a singleton method or singleton class is defined on: the
35
+ # enclosing namespace for +self+, the named constant otherwise, nil for
36
+ # any other object. A relative constant is attributed to the innermost
37
+ # lexical candidate, which Ruby also tries first. The source alone cannot
38
+ # tell whether +Other+ inside +module Outer+ is +Outer::Other+ or
39
+ # +::Other+, so this name can be off; activation resolves the real object
40
+ # at runtime (Mutant::Activator).
41
+ def owner(receiver, namespace)
42
+ case receiver.type
43
+ when :self then namespace
44
+ when :const then qualify(namespace, of(receiver))
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Verifies, after execution, that the activation path itself does not break
5
+ # a subject's tests.
6
+ #
7
+ # For every subject with a detected verdict (Killed, Timeout, RuntimeError)
8
+ # the unmutated method is injected through the same activation and fork
9
+ # path, against the same test files. The tests must pass. If they do not,
10
+ # injecting the method is what made them fail -- a lost constant scope,
11
+ # `super` or `yield` inside `define_method`, a rebuilt parameter list -- so
12
+ # none of that subject's detections can be credited to its mutations. They
13
+ # are reclassified as CompileError with the control's outcome as reason.
14
+ #
15
+ # Verdicts reused from history are left alone: they were not produced by
16
+ # this run's activation.
17
+ class ControlRun
18
+ DETECTED = %i[killed timeout runtime_error].freeze
19
+
20
+ # @param timeout_for [#call] test files -> timeout in seconds
21
+ def initialize(integration:, timeout_for:)
22
+ @integration = integration
23
+ @timeout_for = timeout_for
24
+ end
25
+
26
+ # @return [Hash] verified:, failed: subjects; reclassified: mutants
27
+ def verify(mutants)
28
+ stats = { verified: 0, failed: 0, reclassified: 0 }
29
+ detected_groups(mutants).each_value do |detected|
30
+ stats[:verified] += 1
31
+ result = run_control(detected.first)
32
+ next if result.status == :survived
33
+
34
+ stats[:failed] += 1
35
+ stats[:reclassified] += reclassify(detected, result)
36
+ end
37
+ stats
38
+ end
39
+
40
+ private
41
+
42
+ def detected_groups(mutants)
43
+ Array(mutants)
44
+ .select { |mutant| DETECTED.include?(mutant.status) && !mutant.from_cache? && mutant.original_node }
45
+ .group_by { |mutant| [mutant.subject, test_files(mutant)] }
46
+ end
47
+
48
+ def run_control(mutant)
49
+ files = test_files(mutant)
50
+ @integration.run_mutant(mutant: control_for(mutant), test_files: files, timeout: @timeout_for.call(files))
51
+ end
52
+
53
+ def control_for(mutant)
54
+ node = mutant.original_node
55
+ Mutant.new(subject: mutant.subject, operator: "Control", nodes: { original: node, mutated: node },
56
+ description: "unmutated control", location: mutant.location)
57
+ end
58
+
59
+ def reclassify(detected, result)
60
+ reason = "control run failed: the unmutated method fails its tests when injected " \
61
+ "(#{[result.status, result.status_reason].compact.join(': ')})"
62
+ detected.each do |mutant|
63
+ mutant.status = :compile_error
64
+ mutant.status_reason = reason
65
+ end
66
+ detected.size
67
+ end
68
+
69
+ def test_files(mutant)
70
+ Array(mutant.covered_by).sort
71
+ end
72
+ end
73
+ end
@@ -45,7 +45,9 @@ module Henitai
45
45
  # but irrelevant report (e.g. from a different working directory) must
46
46
  # still trigger a re-bootstrap rather than silently proceeding with no
47
47
  # usable coverage.
48
- unless coverage_ready?(source_files, config, integration, resolved_test_files)
48
+ if flaky_detection?(config)
49
+ run_flaky_baseline(integration, config, resolved_test_files)
50
+ elsif !coverage_ready?(source_files, config, integration, resolved_test_files)
49
51
  bootstrap_coverage(integration, config, resolved_test_files)
50
52
  end
51
53
 
@@ -69,7 +71,25 @@ module Henitai
69
71
  def coverage_ready?(source_files, config, integration, test_files)
70
72
  coverage_fresh?(source_files, config, test_files) &&
71
73
  coverage_available?(source_files, config) &&
72
- per_test_coverage_ready?(source_files, config, integration, test_files)
74
+ per_test_coverage_ready?(source_files, config, integration, test_files) &&
75
+ boot_seconds_ready?(config, integration)
76
+ end
77
+
78
+ # Timeout calibration reads the boot time the per-test collector writes
79
+ # next to the coverage artifacts, so a missing boot file means the
80
+ # baseline must run again to measure it.
81
+ def boot_seconds_ready?(config, integration)
82
+ return true unless calibration_enabled?(config) && per_test_coverage_supported?(integration)
83
+
84
+ File.exist?(boot_seconds_report_path(config))
85
+ end
86
+
87
+ def calibration_enabled?(config)
88
+ config.respond_to?(:timeout_configured?) && !config.timeout_configured?
89
+ end
90
+
91
+ def boot_seconds_report_path(config)
92
+ File.join(reports_dir(config), PerTestCoverageCollector::BOOT_FILE_NAME)
73
93
  end
74
94
 
75
95
  def covered_source_files(source_files, coverage_lines)
@@ -135,19 +155,25 @@ module Henitai
135
155
  return
136
156
  end
137
157
 
138
- raise CoverageError, build_bootstrap_error(result)
158
+ raise CoverageError, BootstrapErrorMessage.build(result)
139
159
  end
140
160
  end
141
161
  end
142
162
 
143
- def build_bootstrap_error(result)
144
- return "Configured test suite failed while generating coverage" unless result.respond_to?(:log_path)
163
+ # Flaky detection needs two complete baseline runs, so the existing
164
+ # coverage is never reused in this mode.
165
+ def flaky_detection?(config)
166
+ config.respond_to?(:flaky_detection) && config.flaky_detection
167
+ end
145
168
 
146
- tail = result.tail(12).strip
147
- message = +"Configured test suite failed while generating coverage"
148
- message << " (see #{result.log_path})"
149
- message << "\n#{tail}" unless tail.empty?
150
- message
169
+ def run_flaky_baseline(integration, config, test_files)
170
+ with_reports_dir(config) do
171
+ with_coverage_dir(config) do
172
+ FlakyDetectionBaseline.new(reports_dir: reports_dir(config))
173
+ .run(integration:, test_files:)
174
+ record_dependency_manifest(config)
175
+ end
176
+ end
151
177
  end
152
178
 
153
179
  def with_coverage_dir(config)
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "henitai/per_test_coverage_collector"
4
+ require "henitai/test_file_outcomes"
5
+ require "henitai/rspec_outcomes"
4
6
 
5
7
  module Henitai
6
8
  # Collects per-test coverage data for static filtering heuristics.
@@ -8,8 +10,14 @@ module Henitai
8
10
  REPORT_DIR_ENV = PerTestCoverageCollector::REPORT_DIR_ENV
9
11
  REPORT_FILE_NAME = PerTestCoverageCollector::REPORT_FILE_NAME
10
12
 
11
- def initialize(_output)
12
- @collector = PerTestCoverageCollector.new
13
+ def initialize(_output, boot_clock: SuiteBootClock.from_env(ENV))
14
+ @collector = PerTestCoverageCollector.new(boot_clock:)
15
+ @outcomes = TestFileOutcomes.new
16
+ @outcome_listener = RspecOutcomes::Listener.new(@outcomes)
17
+ end
18
+
19
+ def example_started(_notification)
20
+ @collector.test_started
13
21
  end
14
22
 
15
23
  def example_finished(notification)
@@ -18,10 +26,25 @@ module Henitai
18
26
  example.metadata[:file_path],
19
27
  duration: example.execution_result.run_time
20
28
  )
29
+ @outcome_listener.example_finished(notification)
30
+ end
31
+
32
+ def example_group_started(notification)
33
+ @outcome_listener.example_group_started(notification)
34
+ end
35
+
36
+ def example_group_finished(notification)
37
+ @outcome_listener.example_group_finished(notification)
38
+ end
39
+
40
+ def message(notification)
41
+ @outcome_listener.message(notification)
21
42
  end
22
43
 
23
- def dump_summary(_summary)
44
+ def dump_summary(summary)
45
+ @outcome_listener.dump_summary(summary)
24
46
  @collector.write_report
47
+ @outcomes.write(TestFileOutcomes.default_report_path)
25
48
  end
26
49
  end
27
50
  end
@@ -7,6 +7,7 @@ module Henitai
7
7
  class CoverageReportReader
8
8
  DEFAULT_COVERAGE_REPORT_PATH = "coverage/.resultset.json"
9
9
  DEFAULT_PER_TEST_COVERAGE_REPORT_PATH = "coverage/henitai_per_test.json"
10
+ DEFAULT_SUITE_BOOT_REPORT_PATH = "coverage/henitai_suite_boot.json"
10
11
 
11
12
  def coverage_lines_by_file(path = DEFAULT_COVERAGE_REPORT_PATH)
12
13
  return {} unless File.exist?(path)
@@ -37,6 +38,26 @@ module Henitai
37
38
  end.to_h
38
39
  end
39
40
 
41
+ # Suite boot seconds (loading helpers, spec files and before(:suite)).
42
+ # Missing or malformed reports yield 0.0, the value for "unknown".
43
+ def boot_seconds(path = DEFAULT_SUITE_BOOT_REPORT_PATH)
44
+ return 0.0 unless File.exist?(path)
45
+
46
+ parsed = JSON.parse(File.read(path))
47
+ parsed.is_a?(Hash) ? usable_boot_seconds(parsed["boot_seconds"]) : 0.0
48
+ rescue JSON::ParserError, ArgumentError, TypeError
49
+ 0.0
50
+ end
51
+
52
+ # Only a finite, non-negative number is a usable measurement; anything
53
+ # else is treated as unknown.
54
+ def usable_boot_seconds(value)
55
+ return 0.0 unless value.is_a?(Numeric)
56
+
57
+ seconds = Float(value)
58
+ seconds.finite? && seconds >= 0 ? seconds : 0.0
59
+ end
60
+
40
61
  private
41
62
 
42
63
  def per_test_report(path)
@@ -27,8 +27,24 @@ module Henitai
27
27
  # Multiplicative identity: `x * 1`, `x / 1` and `x ** 1` all reduce to `x`.
28
28
  def one_operand?(node) = numeric_operand?(node, 1)
29
29
 
30
+ # The identities above only hold when the receiver is a number. For an
31
+ # unknown receiver `"ab" * 1` is `"ab"` while `"ab" / 1` raises, and a
32
+ # custom object may define its own operators. Negative zero is excluded
33
+ # because `-0.0 + 0` is `0.0` while `-0.0 - 0` is `-0.0`.
34
+ def numeric_receiver?(node)
35
+ return false unless node.is_a?(Parser::AST::Node)
36
+
37
+ case node.type
38
+ when :int then true
39
+ when :float then !negative_zero?(node.children.first)
40
+ else false
41
+ end
42
+ end
43
+
30
44
  private
31
45
 
46
+ def negative_zero?(value) = value.zero? && (1.0 / value).negative?
47
+
32
48
  # Reads the right-hand operand of a binary send. Guards against a
33
49
  # malformed node whose operand is a bare Ruby value rather than an AST
34
50
  # node -- that shape does not occur in parsed source, but the detector
@@ -6,9 +6,25 @@ require_relative "equivalence_detector/operand_predicates"
6
6
  module Henitai
7
7
  # Detects obvious equivalent mutants before execution.
8
8
  #
9
- # The detector is intentionally conservative: it only marks mutations as
10
- # equivalent when the AST shape and the operand literals make the equivalence
11
- # obvious enough to be useful.
9
+ # The detector is intentionally conservative: it only marks a mutation as
10
+ # equivalent when the AST shape and the literal operands prove the
11
+ # equivalence for every runtime value. Each rule below states the argument
12
+ # that makes it sound; a rule that cannot state one is not implemented.
13
+ #
14
+ # Rules:
15
+ # * Additive identity, `n + 0` <-> `n - 0`, when `n` is an Integer literal
16
+ # or a non-negative-zero Float literal. Both return `n`.
17
+ # * Multiplicative identity, `n * 1` <-> `n / 1` <-> `n ** 1`, under the
18
+ # same receiver condition. Both return `n`.
19
+ # * Left-literal logical identity, `false || x` -> `x` and `true && x` -> `x`.
20
+ # `false || x` is `x` for every `x`, and `true && x` is `x` for every `x`.
21
+ # The right-literal forms are unsound: `nil || false` is `false` and
22
+ # `5 && true` is `true`.
23
+ # * Singleton equality, `s == v` <-> `s.equal?(v)` when `s` and `v` are
24
+ # Symbol, nil, true or false literals. These values are unique instances,
25
+ # so `==` reduces to identity.
26
+ # * String equality, `"a" == "b"` <-> `"a".eql?("b")` when both operands are
27
+ # String literals. Both compare type and value for two Strings.
12
28
  class EquivalenceDetector
13
29
  def analyze(mutant)
14
30
  return mutant unless equivalent_mutation?(mutant)
@@ -48,14 +64,16 @@ module Henitai
48
64
  additive_operator?(original.children[1]) &&
49
65
  additive_operator?(mutated.children[1]) &&
50
66
  zero_operand?(original) &&
51
- zero_operand?(mutated)
67
+ zero_operand?(mutated) &&
68
+ numeric_receiver?(original)
52
69
  end
53
70
 
54
71
  def multiplicative_equivalent?(original, mutated)
55
72
  multiplicative_operator?(original.children[1]) &&
56
73
  multiplicative_operator?(mutated.children[1]) &&
57
74
  one_operand?(original) &&
58
- one_operand?(mutated)
75
+ one_operand?(mutated) &&
76
+ numeric_receiver?(original)
59
77
  end
60
78
 
61
79
  def equivalent_logical_mutation?(mutant)
@@ -71,28 +89,23 @@ module Henitai
71
89
  end
72
90
 
73
91
  def logical_identity_equivalent?(original, mutated)
74
- case original.type
75
- when :or
92
+ if original.type == :or
76
93
  false_identity_equivalent?(original, mutated)
77
- when :and
78
- true_identity_equivalent?(original, mutated)
79
94
  else
80
- false
95
+ true_identity_equivalent?(original, mutated)
81
96
  end
82
97
  end
83
98
 
99
+ # `false || x` is `x` for every `x`. The right-literal form `x || false` is
100
+ # not: `nil || false` is `false`, not `nil`.
84
101
  def false_identity_equivalent?(original, mutated)
85
- return true if false_operand?(original.children[0]) && same_node?(mutated, original.children[1])
86
- return true if false_operand?(original.children[1]) && same_node?(mutated, original.children[0])
87
-
88
- false
102
+ false_operand?(original.children[0]) && same_node?(mutated, original.children[1])
89
103
  end
90
104
 
105
+ # `true && x` is `x` for every `x`. The right-literal form `x && true` is
106
+ # not: `5 && true` is `true`, not `5`.
91
107
  def true_identity_equivalent?(original, mutated)
92
- return true if true_operand?(original.children[0]) && same_node?(mutated, original.children[1])
93
- return true if true_operand?(original.children[1]) && same_node?(mutated, original.children[0])
94
-
95
- false
108
+ true_operand?(original.children[0]) && same_node?(mutated, original.children[1])
96
109
  end
97
110
 
98
111
  def false_operand?(node)
@@ -121,6 +134,8 @@ module Henitai
121
134
 
122
135
  def one_operand?(node) = operand_predicates.one_operand?(node)
123
136
 
137
+ def numeric_receiver?(node) = operand_predicates.numeric_receiver?(node.children[0])
138
+
124
139
  def operand_predicates = @operand_predicates ||= OperandPredicates.new
125
140
 
126
141
  # Detects `lhs == <singleton>` mutated to `lhs.equal?(<singleton>)` (or the
@@ -146,28 +161,27 @@ module Henitai
146
161
  equality_operators?(original.children[1], mutated.children[1])
147
162
  end
148
163
 
149
- # Detects `lhs == rhs` mutated to `lhs.eql?(rhs)` (or the reverse) when at
150
- # least one operand is a string literal.
164
+ # Detects `lhs == rhs` mutated to `lhs.eql?(rhs)` (or the reverse) when both
165
+ # operands are string literals.
151
166
  #
152
- # String#eql? is documented to compare both type and value. Since String#==
153
- # also compares type and value (it returns false for any non-String argument
154
- # without invoking the other object's #==), the two methods are equivalent
155
- # for all possible inputs whenever at least one operand is statically known
156
- # to be a String — proven here by the presence of a :str literal on the
157
- # receiver or the argument side.
158
- #
159
- # When no operand is a string literal we conservatively leave the mutant
160
- # pending: the receiver could be any object whose custom #== diverges from
161
- # its #eql?.
167
+ # With two String literals, neither `==` nor `eql?` can reach a custom
168
+ # method, so both compare type and value and return the same result. A
169
+ # single string literal is not enough: an unknown receiver can override
170
+ # `==`, and `"a" == obj` calls `obj == "a"` when obj responds to `to_str`.
162
171
  def equivalent_string_eql_mutation?(mutant)
163
172
  original = mutant.original_node
164
173
  mutated = mutant.mutated_node
165
174
 
175
+ string_eql_swap?(original, mutated) &&
176
+ string_literal?(original.children[0]) &&
177
+ string_literal?(original.children[2])
178
+ end
179
+
180
+ def string_eql_swap?(original, mutated)
166
181
  string_eql_send?(original) && string_eql_send?(mutated) &&
167
182
  same_receiver?(original, mutated) &&
168
183
  string_eql_operators?(original.children[1], mutated.children[1]) &&
169
- same_rhs?(original, mutated) &&
170
- string_operand?(original)
184
+ same_rhs?(original, mutated)
171
185
  end
172
186
 
173
187
  def string_eql_send?(node)
@@ -189,12 +203,6 @@ module Henitai
189
203
  same_node?(original.children[2], mutated.children[2])
190
204
  end
191
205
 
192
- # Returns true when at least one operand is a string literal, giving static
193
- # proof that the comparison is string-typed on at least one side.
194
- def string_operand?(node)
195
- string_literal?(node.children[0]) || string_literal?(node.children[2])
196
- end
197
-
198
206
  def string_literal?(node)
199
207
  node.is_a?(Parser::AST::Node) && node.type == :str
200
208
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Reads whether a test run's output says that no example ran.
5
+ #
6
+ # Both texts are matched at the start of a line only, and only the last
7
+ # summary line counts: failure output may quote either text, indented, from
8
+ # the test source, and the tests themselves may print summaries of nested
9
+ # runs before the run's own.
10
+ module ExampleSummary
11
+ NO_EXAMPLES_FOUND = /^No examples found\.$/
12
+ SUMMARY = /^(\d+) (?:examples?, \d+ failures?|runs?, \d+ assertions?)/
13
+
14
+ module_function
15
+
16
+ def none_ran?(output)
17
+ text = output.to_s
18
+ summaries = text.scan(SUMMARY)
19
+ return NO_EXAMPLES_FOUND.match?(text) if summaries.empty?
20
+
21
+ summaries.last == ["0"]
22
+ end
23
+ end
24
+ end