henitai 0.5.1 → 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 +296 -1
  3. data/README.md +109 -16
  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 +57 -15
  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 +17 -7
  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 +227 -45
  121. metadata +46 -3
  122. data/lib/henitai/survivor_test_filter.rb +0 -72
@@ -12,10 +12,18 @@ module Henitai
12
12
  class SourceParser
13
13
  DEFAULT_PATH = "(string)"
14
14
 
15
+ # Emits the modern parser-gem AST format (procarg0, kwargs, index, lambda,
16
+ # forward_arg, ...). The legacy format cannot tell `|x|` from `|x,|` or
17
+ # keyword arguments from a braced hash, so re-rendering a mutated subtree
18
+ # through the unparser would silently change its meaning.
19
+ class Builder < Prism::Translation::Parser::Builder
20
+ modernize
21
+ end
22
+
15
23
  @cache = {}
16
24
 
17
- def self.parse(source, path: DEFAULT_PATH)
18
- new.parse(source, path:)
25
+ def self.parse(source, path: DEFAULT_PATH, line: 1)
26
+ new.parse(source, path:, line:)
19
27
  end
20
28
 
21
29
  # Returns the parsed AST for +path+, re-using a cached result when the
@@ -31,20 +39,32 @@ module Henitai
31
39
  @cache.clear
32
40
  end
33
41
 
34
- def parse(source, path: DEFAULT_PATH)
35
- Prism::Translation::ParserCurrent.new.parse(source_buffer(source, path))
42
+ # Reads +path+ as UTF-8 source, honouring a magic encoding comment the
43
+ # same way parsing does. Raises EncodingError when the bytes are invalid
44
+ # in the declared (or default UTF-8) encoding.
45
+ def self.read_file(path)
46
+ buffer = Parser::Source::Buffer.new(path)
47
+ buffer.source = File.read(path, encoding: Encoding::UTF_8)
48
+ buffer.source
49
+ end
50
+
51
+ # +line+ is the number of the source's first line, as for Kernel#eval.
52
+ def parse(source, path: DEFAULT_PATH, line: 1)
53
+ Prism::Translation::ParserCurrent.new(Builder.new).parse(source_buffer(source, path, line))
36
54
  end
37
55
 
56
+ # Read as UTF-8, Ruby's own default source encoding, not the process
57
+ # locale: with LANG=C the default external encoding is US-ASCII and every
58
+ # non-ASCII source file would fail to parse. A magic encoding comment is
59
+ # still honoured by the parser buffer, which re-encodes to UTF-8.
38
60
  def parse_file(path)
39
- # Ruby's file encoding rules apply here. Projects that use explicit source
40
- # encoding comments can be handled by a future encoding-aware option.
41
- parse(File.read(path), path:)
61
+ parse(File.read(path, encoding: Encoding::UTF_8), path:)
42
62
  end
43
63
 
44
64
  private
45
65
 
46
- def source_buffer(source, path)
47
- Parser::Source::Buffer.new(path).tap do |buffer|
66
+ def source_buffer(source, path, line = 1)
67
+ Parser::Source::Buffer.new(path, line).tap do |buffer|
48
68
  buffer.source = source
49
69
  end
50
70
  end
@@ -10,9 +10,11 @@ module Henitai
10
10
  DEFAULT_PER_TEST_COVERAGE_REPORT_PATH = CoverageReportReader::DEFAULT_PER_TEST_COVERAGE_REPORT_PATH
11
11
 
12
12
  def initialize(coverage_report_reader: CoverageReportReader.new,
13
- skip_directives: MutationSkipDirectives.new)
13
+ skip_directives: MutationSkipDirectives.new,
14
+ fidelity_check: FidelityCheck.new)
14
15
  @coverage_report_reader = coverage_report_reader
15
16
  @skip_directives = skip_directives
17
+ @fidelity_check = fidelity_check
16
18
  end
17
19
 
18
20
  # This method is the gate-level filter orchestrator.
@@ -22,6 +24,7 @@ module Henitai
22
24
 
23
25
  Array(mutants).each do |mutant|
24
26
  next if ignored_mutant?(mutant, config) || skip_directive_mutant?(mutant)
27
+ next if unfaithful_mutant?(mutant)
25
28
 
26
29
  mark_equivalent_mutant(mutant)
27
30
  mark_no_coverage_mutant(
@@ -64,21 +67,22 @@ module Henitai
64
67
 
65
68
  private
66
69
 
67
- attr_reader :coverage_report_reader, :skip_directives
70
+ attr_reader :coverage_report_reader, :skip_directives, :fidelity_check
68
71
 
69
- def ignored?(mutant, config)
70
- source = source_for(mutant)
71
- return false unless source
72
+ def ignore_pattern_for(mutant, config)
73
+ patterns = compiled_ignore_patterns(config)
74
+ return if patterns.empty?
72
75
 
73
- compiled_ignore_patterns(config).any? do |pattern|
74
- pattern.match?(source)
75
- end
76
+ line = source_line_for(mutant)
77
+ patterns.find { |pattern| pattern.match?(line) }
76
78
  end
77
79
 
78
80
  def ignored_mutant?(mutant, config)
79
- return false unless ignored?(mutant, config)
81
+ pattern = ignore_pattern_for(mutant, config)
82
+ return false unless pattern
80
83
 
81
84
  mutant.status = :ignored
85
+ mutant.ignore_reason = "ignore_patterns: #{pattern.source}" if mutant.respond_to?(:ignore_reason=)
82
86
  true
83
87
  end
84
88
 
@@ -91,6 +95,17 @@ module Henitai
91
95
  true
92
96
  end
93
97
 
98
+ # A mutant whose activated code is not the reported mutation must not
99
+ # run: any test failure would be credited to a change it never made.
100
+ def unfaithful_mutant?(mutant)
101
+ reason = fidelity_check.violation(mutant)
102
+ return false unless reason
103
+
104
+ mutant.status = :compile_error
105
+ mutant.status_reason = reason
106
+ true
107
+ end
108
+
94
109
  def mark_equivalent_mutant(mutant)
95
110
  return unless mutant.pending?
96
111
 
@@ -111,11 +126,27 @@ module Henitai
111
126
  covered.any? { |line| (mutant.location[:start_line]..mutant.location[:end_line]).cover?(line) }
112
127
  end
113
128
 
114
- def source_for(mutant)
115
- original_node = mutant.original_node
116
- location = original_node&.location
117
- expression = location&.expression
118
- expression&.source
129
+ # Ignore patterns are line-scoped: they match the source line on which the
130
+ # mutant's original node starts, like a trailing `# henitai:disable`.
131
+ def source_line_for(mutant)
132
+ line_number = mutant.location[:start_line]
133
+ return unless line_number.is_a?(Integer) && line_number.positive?
134
+
135
+ lines = source_lines_for(mutant.location[:file])
136
+ lines&.fetch(line_number - 1, nil)
137
+ end
138
+
139
+ def source_lines_for(path)
140
+ @source_lines ||= {}
141
+ return @source_lines[path] if @source_lines.key?(path)
142
+
143
+ @source_lines[path] = read_source_lines(path)
144
+ end
145
+
146
+ def read_source_lines(path)
147
+ SourceParser.read_file(path).lines
148
+ rescue SystemCallError, IOError, EncodingError
149
+ nil
119
150
  end
120
151
 
121
152
  def compiled_ignore_patterns(config)
@@ -1,11 +1,81 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "prism"
3
4
  require "unparser"
4
5
 
5
6
  module Henitai
6
7
  # Suppresses mutants that do not produce syntactically valid Ruby.
8
+ #
9
+ # A mutant of a resolved method is checked in context: its activation
10
+ # source -- the whole method with the mutation spliced in -- must parse. A
11
+ # fragment such as `size: 2` (one keyword argument left) or a `next` guard
12
+ # is only valid inside its call or block, so checking it on its own would
13
+ # drop valid mutants. Without a method to splice into, the rendered
14
+ # fragment is compiled inside a bare method instead.
15
+ #
16
+ # A mutant that removes the only assignment of a local which is read later
17
+ # is suppressed as well (ACT-10): Ruby then parses the later read as a
18
+ # method call, so the mutant would fail with a NameError wherever it runs
19
+ # -- a trivial kill of a program other than the reported one.
7
20
  class StillbornFilter
21
+ # Collects the names of identifiers Ruby parses as method calls.
22
+ class VariableCalls < Prism::Visitor
23
+ def self.tally(node)
24
+ new.tap { |visitor| node.accept(visitor) }.names.tally
25
+ end
26
+
27
+ attr_reader :names
28
+
29
+ def initialize
30
+ super
31
+ @names = []
32
+ end
33
+
34
+ def visit_call_node(node)
35
+ @names << node.name if node.variable_call?
36
+ super
37
+ end
38
+ end
39
+
40
+ def initialize
41
+ @original_calls = {}.compare_by_identity
42
+ end
43
+
8
44
  def suppressed?(mutant)
45
+ activation = in_context_activation(mutant)
46
+ return fragment_invalid?(mutant) unless activation
47
+
48
+ result = Prism.parse(activation.source)
49
+ result.errors.any? || drops_local?(mutant.subject.ast_node, result.value)
50
+ end
51
+
52
+ private
53
+
54
+ def drops_local?(method_node, activated)
55
+ original = @original_calls[method_node] ||= original_calls(method_node)
56
+ VariableCalls.tally(activated).any? do |name, count|
57
+ count > original.fetch(name, 0) && local_read?(method_node, name)
58
+ end
59
+ end
60
+
61
+ def original_calls(method_node)
62
+ VariableCalls.tally(Prism.parse(method_node.location.expression.source).value)
63
+ end
64
+
65
+ def local_read?(node, name)
66
+ return false unless node.is_a?(Parser::AST::Node)
67
+ return true if node.type == :lvar && node.children.first == name
68
+
69
+ node.children.any? { |child| local_read?(child, name) }
70
+ end
71
+
72
+ def in_context_activation(mutant)
73
+ return unless mutant.subject.respond_to?(:ast_node) && mutant.subject.ast_node
74
+
75
+ Mutant::Activator.activation_for(mutant)
76
+ end
77
+
78
+ def fragment_invalid?(mutant)
9
79
  source = render(mutant)
10
80
  return true unless source
11
81
 
@@ -15,8 +85,6 @@ module Henitai
15
85
  true
16
86
  end
17
87
 
18
- private
19
-
20
88
  def render(mutant)
21
89
  Unparser.unparse(mutant.mutated_node)
22
90
  rescue StandardError
@@ -14,7 +14,7 @@ module Henitai
14
14
  # descriptions in the test suite.
15
15
  class Subject
16
16
  attr_reader :namespace, :method_name, :method_type, :source_file,
17
- :source_range, :ast_node
17
+ :source_range, :ast_node, :lexical_scope
18
18
 
19
19
  # @param expression [String] subject expression, e.g. "Foo::Bar#method"
20
20
  def self.parse(expression)
@@ -25,11 +25,15 @@ module Henitai
25
25
  # @param method_name [String] method name (nil for wildcard subjects)
26
26
  # @param method_type [Symbol] :instance or :class
27
27
  # @param source_location [Hash] file/range metadata for the subject source
28
+ # @param lexical_scope [Array<String>, nil] the openers of the modules and
29
+ # classes around the method as written ("module Outer", "class Inner",
30
+ # "class << self", "Point.class_eval do"); nil when a scope cannot be
31
+ # reopened by name
28
32
  # The explicit keyword API matches the public call sites and keeps the
29
33
  # metadata fields discoverable without a single options hash.
30
34
  # rubocop:disable Metrics/ParameterLists
31
35
  def initialize(expression: nil, namespace: nil, method_name: nil,
32
- method_type: :instance, source_location: nil, ast_node: nil)
36
+ method_type: :instance, source_location: nil, ast_node: nil, lexical_scope: nil)
33
37
  if expression
34
38
  parse_expression(expression)
35
39
  else
@@ -40,6 +44,7 @@ module Henitai
40
44
  @source_file = source_location&.fetch(:file, nil)
41
45
  @source_range = source_location&.fetch(:range, nil)
42
46
  @ast_node = ast_node
47
+ @lexical_scope = lexical_scope
43
48
  end
44
49
  # rubocop:enable Metrics/ParameterLists
45
50
 
@@ -56,7 +61,7 @@ module Henitai
56
61
  private
57
62
 
58
63
  def parse_expression(expr)
59
- if (m = expr.match(/\A(.+?)([#.])(\w+)\z/))
64
+ if (m = expr.match(/\A([\w:]+)([#.])([^#.\s]+)\z/))
60
65
  @namespace = m[1]
61
66
  @method_type = m[2] == "." ? :class : :instance
62
67
  @method_name = m[3]
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "source_parser"
4
4
  require_relative "subject"
5
+ require_relative "constant_path"
5
6
 
6
7
  module Henitai
7
8
  # Resolves AST subjects from Ruby source files.
@@ -26,6 +27,7 @@ module Henitai
26
27
  private
27
28
 
28
29
  def resolve_from_file(path)
30
+ @scope = []
29
31
  subjects = []
30
32
  walk(
31
33
  SourceParser.parse_file(path),
@@ -38,6 +40,7 @@ module Henitai
38
40
 
39
41
  def walk(node, namespace:, singleton_context:, subjects:)
40
42
  return unless node.respond_to?(:type)
43
+ return walk_constructor_body(node, namespace, subjects) if constructor_assignment?(node)
41
44
  return if anonymous_class_block?(node)
42
45
 
43
46
  namespace, singleton_context = update_context(
@@ -46,8 +49,60 @@ module Henitai
46
49
  singleton_context
47
50
  )
48
51
 
49
- collect_subject(node, namespace, singleton_context, subjects)
50
- traverse_children(node, namespace, singleton_context, subjects)
52
+ within_scope(node) do
53
+ collect_subject(node, namespace, singleton_context, subjects)
54
+ traverse_children(node, namespace, singleton_context, subjects)
55
+ end
56
+ end
57
+
58
+ # +Point = Struct.new(:x) do ... end+: the block is evaluated with
59
+ # class_eval on the new class, so its methods belong to +Point+ while
60
+ # constant lookup stays in the enclosing scope. +Point.class_eval do+
61
+ # reproduces exactly that when the method is re-evaluated.
62
+ def walk_constructor_body(node, namespace, subjects)
63
+ @scope.push("#{assigned_constant_source(node)}.class_eval do")
64
+ walk(node.children[2].children[2], namespace: ConstantPath.qualify(namespace, ConstantPath.of(node)),
65
+ singleton_context: false, subjects:)
66
+ ensure
67
+ @scope.pop
68
+ end
69
+
70
+ def constructor_assignment?(node)
71
+ node.type == :casgn && anonymous_class_block?(node.children[2])
72
+ end
73
+
74
+ def assigned_constant_source(node)
75
+ node.location.expression.begin.join(node.location.name).source
76
+ end
77
+
78
+ # Tracks the openers of the modules and classes around the walk position,
79
+ # so a method can later be re-evaluated in its original lexical scope.
80
+ def within_scope(node)
81
+ opener = scope_opener(node)
82
+ @scope.push(opener) unless opener == :none
83
+ yield
84
+ ensure
85
+ @scope.pop unless opener == :none
86
+ end
87
+
88
+ def scope_opener(node)
89
+ case node.type
90
+ when :class, :module then "#{node.type} #{node.children.first.location.expression.source}"
91
+ when :sclass then singleton_opener(node.children.first)
92
+ else :none
93
+ end
94
+ end
95
+
96
+ def singleton_opener(receiver)
97
+ case receiver.type
98
+ when :self then "class << self"
99
+ when :const then "class << #{receiver.location.expression.source}"
100
+ end
101
+ end
102
+
103
+ # nil when some enclosing scope cannot be reopened by name (class << obj).
104
+ def lexical_scope
105
+ @scope.include?(nil) ? nil : @scope.dup
51
106
  end
52
107
 
53
108
  def collect_subject(node, namespace, singleton_context, subjects)
@@ -70,7 +125,7 @@ module Henitai
70
125
 
71
126
  receiver = call_node.children.first
72
127
  method_name = call_node.children[1]
73
- receiver_name = constant_name(receiver)
128
+ receiver_name = ConstantPath.of(receiver)&.delete_prefix("::")
74
129
 
75
130
  anonymous_constructor_call?(receiver_name, method_name)
76
131
  end
@@ -86,10 +141,10 @@ module Henitai
86
141
  def update_context(node, namespace, singleton_context)
87
142
  case node.type
88
143
  when :class, :module
89
- [qualify_namespace(namespace, constant_name(node.children.first)),
144
+ [ConstantPath.qualify(namespace, ConstantPath.of(node.children.first)),
90
145
  singleton_context]
91
146
  when :sclass
92
- [namespace, true]
147
+ [ConstantPath.owner(node.children.first, namespace), true]
93
148
  else
94
149
  [namespace, singleton_context]
95
150
  end
@@ -102,7 +157,7 @@ module Henitai
102
157
  when :def
103
158
  instance_subject(node, namespace, singleton_context)
104
159
  when :defs
105
- class_subject(node, namespace)
160
+ class_subject(node, ConstantPath.owner(node.children.first, namespace))
106
161
  end
107
162
  end
108
163
 
@@ -114,7 +169,8 @@ module Henitai
114
169
  method_name: method_name(node.children.first),
115
170
  method_type: singleton_context ? :class : :instance,
116
171
  source_location: source_location_for(node),
117
- ast_node: node
172
+ ast_node: node,
173
+ lexical_scope:
118
174
  )
119
175
  end
120
176
 
@@ -126,7 +182,8 @@ module Henitai
126
182
  method_name: method_name(node.children[1]),
127
183
  method_type: :class,
128
184
  source_location: source_location_for(node),
129
- ast_node: node
185
+ ast_node: node,
186
+ lexical_scope:
130
187
  )
131
188
  end
132
189
 
@@ -147,29 +204,6 @@ module Henitai
147
204
  )
148
205
  end
149
206
 
150
- def qualify_namespace(namespace, name)
151
- return name if namespace.nil? || namespace.empty?
152
- return namespace if name.nil? || name.empty?
153
-
154
- "#{namespace}::#{name}"
155
- end
156
-
157
- def constant_name(node)
158
- return unless node.respond_to?(:type)
159
-
160
- case node.type
161
- when :const
162
- parent_name = constant_name(node.children.first)
163
- current_name = symbol_name(node.children.last)
164
-
165
- return current_name if parent_name.nil? || parent_name.empty?
166
-
167
- "#{parent_name}::#{current_name}"
168
- when :cbase
169
- ""
170
- end
171
- end
172
-
173
207
  def method_name(value)
174
208
  symbol_name(value)
175
209
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Measures how long a baseline suite process spent before its first test
5
+ # started. The parent passes its spawn wall-clock time through
6
+ # STARTED_AT_ENV; the boot time covers loading helpers and spec files and
7
+ # running before(:suite).
8
+ class SuiteBootClock
9
+ STARTED_AT_ENV = "HENITAI_SUITE_STARTED_AT"
10
+
11
+ # @param started_at [Float, nil] epoch seconds of the suite spawn
12
+ # @param clock [#call] returns the current epoch seconds
13
+ def initialize(started_at:, clock: -> { Time.now.to_f })
14
+ @started_at = started_at
15
+ @clock = clock
16
+ @boot_seconds = nil
17
+ end
18
+
19
+ # @param env [Hash] environment of the suite process
20
+ # @return [SuiteBootClock]
21
+ def self.from_env(env, clock: -> { Time.now.to_f })
22
+ new(started_at: parse_timestamp(env[STARTED_AT_ENV]), clock:)
23
+ end
24
+
25
+ def self.parse_timestamp(value)
26
+ Float(value)
27
+ rescue ArgumentError, TypeError
28
+ nil
29
+ end
30
+
31
+ # Records boot time at the first test start only; later calls are ignored.
32
+ def mark_first_test_started
33
+ return if @boot_seconds || @started_at.nil?
34
+
35
+ @boot_seconds = [@clock.call - @started_at, 0.0].max
36
+ end
37
+
38
+ # @return [Float] boot seconds, 0.0 when unknown
39
+ def boot_seconds
40
+ @boot_seconds || 0.0
41
+ end
42
+
43
+ # @return [Boolean] true once a boot time has been recorded
44
+ def measured?
45
+ !@boot_seconds.nil?
46
+ end
47
+ end
48
+ end
@@ -4,7 +4,7 @@ require "json"
4
4
  require "fileutils"
5
5
 
6
6
  module Henitai
7
- # Stores and retrieves pre-computed +define_method+ activation sources for
7
+ # Stores and retrieves pre-computed activations (see Mutant::Activation) for
8
8
  # survived mutants, enabling survivor reruns to skip the full mutant-generation
9
9
  # pipeline.
10
10
  #
@@ -14,7 +14,7 @@ module Henitai
14
14
  # objects directly and execute them without re-parsing source files.
15
15
  #
16
16
  # A recipe entry encodes everything needed to activate and re-report a mutant:
17
- # the +define_method+ source, subject coordinates, operator, description,
17
+ # the activation (source, mode, file, line), subject coordinates, operator, description,
18
18
  # location, and the coveredBy test list.
19
19
  class SurvivorActivationCache
20
20
  FILENAME = "activation-recipes.json"
@@ -26,10 +26,10 @@ module Henitai
26
26
  # @return [Hash<String, Hash>] stableId → recipe
27
27
  def self.compute(survived_mutants)
28
28
  survived_mutants.each_with_object({}) do |mutant, cache|
29
- source = Mutant::Activator.activation_source_for(mutant)
30
- next unless source
29
+ activation = Mutant::Activator.activation_for(mutant)
30
+ next unless activation
31
31
 
32
- cache[mutant.stable_id] = build_recipe(mutant, source)
32
+ cache[mutant.stable_id] = build_recipe(mutant, activation)
33
33
  end
34
34
  end
35
35
 
@@ -53,13 +53,11 @@ module Henitai
53
53
  class << self
54
54
  private
55
55
 
56
- def build_recipe(mutant, activation_source)
56
+ def build_recipe(mutant, activation)
57
57
  {
58
- "activationSource" => activation_source,
59
- "namespace" => mutant.subject.namespace,
60
- "methodName" => mutant.subject.method_name,
61
- "methodType" => mutant.subject.method_type.to_s,
62
- "sourceFile" => mutant.subject.source_file,
58
+ "activation" => activation.to_h,
59
+ "replacement" => mutant.mutated_source,
60
+ **subject_fields(mutant.subject),
63
61
  "operator" => mutant.operator,
64
62
  "description" => mutant.description,
65
63
  "location" => serialize_location(mutant.location),
@@ -67,6 +65,15 @@ module Henitai
67
65
  }
68
66
  end
69
67
 
68
+ def subject_fields(subject)
69
+ {
70
+ "namespace" => subject.namespace,
71
+ "methodName" => subject.method_name,
72
+ "methodType" => subject.method_type.to_s,
73
+ "sourceFile" => subject.source_file
74
+ }
75
+ end
76
+
70
77
  def serialize_location(location)
71
78
  {
72
79
  "file" => location[:file],