ace-test-runner 0.28.0 → 0.28.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4626e822d15950006f1df84000e1e964163355052b9e4167bcccb963202bbd02
4
- data.tar.gz: cd10d616e61501c57556f3227ec5f635ddd34534fc31ee7a7cbe4e1a6667e869
3
+ metadata.gz: 8fc2c5132ba01f77ad703fc44426139e83ae2f930eb95a3ab26293f237bd13c7
4
+ data.tar.gz: b998e891eeecc4dd6ff6be3cdfcfb15c5d14a17333b306febecbd1db4dee4532
5
5
  SHA512:
6
- metadata.gz: 5f6bb28ba6b56c6a84cb1b4dfda1f3d6cea951d204333a31ef8ed8fc57af97494c247e81f907a23ec295fa6f5a4cb517721bbdf6ad01fcf3ba495fdd3e2f461b
7
- data.tar.gz: 15d0a4980512f6007f1468525111875826a8a57b5251bcd5fe53455552402b51cb776138c7a0426143c082e0fa0cb20052cf36947aad7d5ed72cd1dd11cbabdd
6
+ metadata.gz: d70486feda75375aaf8e5057adc1c6b9a10cf2692a7b2b7cd5d337c6375ba53a894c353de62b3178ca33e95460a208b367a9c283256b9ae8ee8127891f18e2bb
7
+ data.tar.gz: 2545709874b877010ad45bb538d05d03167b0a994a81119503a85c9f8666a13c689a2b971c738d8107d9f312785ec5a329de40718ac796baebb975f13f8421fa
@@ -26,6 +26,7 @@ patterns:
26
26
  core: "test/{fast/,}core/**/*_test.rb"
27
27
  lifecycle: "test/{fast/,}lifecycle/**/*_test.rb"
28
28
  edge: "test/{fast/,}edge/**/*_test.rb"
29
+ fast_tests: "test/fast/**/*_test.rb"
29
30
  feat_tests: "test/feat/**/*_test.rb"
30
31
 
31
32
  # Test targets that combine multiple patterns
@@ -45,6 +46,7 @@ targets:
45
46
  - contract
46
47
  - core
47
48
  - lifecycle
49
+ - fast_tests
48
50
  feat:
49
51
  - feat_tests
50
52
  - edge
data/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.28.1] - 2026-10-08
11
+
12
+ ### Fixed
13
+
14
+ - Reject missing explicit Ruby test files before loading tests or running selectors. Preserve exact unambiguous file-line selection.
15
+
16
+ ### Fixed
17
+
18
+ - Refuse invalid or ambiguous file-line selections and execute exact class/method identities in direct and subprocess modes. Reject mixed whole-file/line selections before loading tests.
19
+
20
+ - Include uncategorized fast test directories in default fast/all selection without executing overlapping matches twice or widening explicit target overrides.
21
+ - Exclude undispatched groups from sequential execution reports after target fail-fast.
22
+
10
23
  ## [0.28.0] - 2026-10-05
11
24
 
12
25
  ### Fixed
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "line_number_resolver"
4
+ require_relative "../molecules/selection_resolver"
5
+ require "shellwords"
4
6
 
5
7
  module Ace
6
8
  module TestRunner
@@ -13,6 +15,8 @@ module Ace
13
15
  end
14
16
 
15
17
  def build_test_command(files, options = {})
18
+ selection = options[:selection_plan] || Molecules::SelectionResolver.resolve(Array(files))
19
+ return exact_selection_command(selection, options) if selection.qualified?
16
20
  cmd_parts = []
17
21
 
18
22
  # Use bundler if available and requested
@@ -29,39 +33,28 @@ module Ace
29
33
 
30
34
  # Add the test files
31
35
  if files.is_a?(Array)
32
- # Check if any file has a line number (file:line format)
33
- has_line_numbers = files.any? { |f| f.match?(/:\d+$/) }
34
-
35
- if has_line_numbers
36
- # For files with line numbers, resolve to test names and filter
37
- build_line_number_command(cmd_parts, files, options)
38
- else
39
- # Build a Ruby script that requires each file and fails on LoadError
40
- requires_script = files.map do |f|
41
- # Add ./ prefix if it's a relative path without one
42
- path = f.start_with?("/", "./") ? f : "./#{f}"
43
- # Escape the path for shell safety
44
- escaped_path = path.gsub("'", "\\\\'")
45
- "begin; require '#{escaped_path}'; rescue LoadError => e; STDERR.puts \\\"Failed to load #{escaped_path}: \\\" + e.message; exit(1); end"
46
- end.join("; ")
47
-
48
- # Build the script parts
49
- script_parts = []
50
- # Inject ARGV with --verbose for Minitest if profile is requested
51
- # (Ruby's --verbose flag only sets $VERBOSE, doesn't enable Minitest verbose mode)
52
- script_parts << "ARGV.replace(['--verbose'])" if options[:profile]
53
- script_parts << requires_script
54
- script_parts << "exit_code = Minitest.autorun"
55
- script_parts << "exit(exit_code)"
56
-
57
- # Execute the requires and then run Minitest
58
- cmd_parts << "-e"
59
- # Use double quotes to wrap the entire script
60
- cmd_parts << "\"#{script_parts.join("; ")}\""
61
- end
62
- elsif files.match?(/:\d+$/)
63
- # Check if single file has line number
64
- build_line_number_command(cmd_parts, [files], options)
36
+ # Build a Ruby script that requires each file and fails on LoadError
37
+ requires_script = files.map do |f|
38
+ # Add ./ prefix if it's a relative path without one
39
+ path = f.start_with?("/", "./") ? f : "./#{f}"
40
+ # Escape the path for shell safety
41
+ escaped_path = path.gsub("'", "\\\\'")
42
+ "begin; require '#{escaped_path}'; rescue LoadError => e; STDERR.puts \\\"Failed to load #{escaped_path}: \\\" + e.message; exit(1); end"
43
+ end.join("; ")
44
+
45
+ # Build the script parts
46
+ script_parts = []
47
+ # Inject ARGV with --verbose for Minitest if profile is requested
48
+ # (Ruby's --verbose flag only sets $VERBOSE, doesn't enable Minitest verbose mode)
49
+ script_parts << "ARGV.replace(['--verbose'])" if options[:profile]
50
+ script_parts << requires_script
51
+ script_parts << "exit_code = Minitest.autorun"
52
+ script_parts << "exit(exit_code)"
53
+
54
+ # Execute the requires and then run Minitest
55
+ cmd_parts << "-e"
56
+ # Use double quotes to wrap the entire script
57
+ cmd_parts << "\"#{script_parts.join("; ")}\""
65
58
  elsif options[:profile]
66
59
  # Single file without line number
67
60
  cmd_parts << "-e"
@@ -100,60 +93,41 @@ module Ace
100
93
 
101
94
  private
102
95
 
103
- def build_line_number_command(cmd_parts, files, options = {})
104
- # For files with line numbers, we need to:
105
- # 1. Load each file
106
- # 2. Resolve line numbers to test names
107
- # 3. Filter using --name option
108
-
109
- file_requires = []
110
- test_names = []
111
-
112
- files.each do |file_with_line|
113
- parsed = LineNumberResolver.parse_file_with_line(file_with_line)
114
- file_path = parsed[:file]
115
- line_number = parsed[:line]
116
-
117
- # Add ./ prefix if it's a relative path without one
118
- path = file_path.start_with?("/", "./") ? file_path : "./#{file_path}"
119
- escaped_path = path.gsub("'", "\\\\'")
120
-
121
- # Always require the file
122
- file_requires << "require '#{escaped_path}'"
123
-
124
- # If there's a line number, resolve it to a test name
125
- if line_number
126
- test_name = LineNumberResolver.resolve_test_at_line(file_path, line_number)
127
- if test_name
128
- test_names << test_name
129
- end
96
+ def exact_selection_command(plan, options)
97
+ argv = []
98
+ argv.concat(["bundle", "exec"]) if @bundler && bundler_available?
99
+ argv.concat(Shellwords.split(@ruby_command))
100
+ argv << "-Ilib:test" unless options[:no_load_path]
101
+ model = File.expand_path("../models/test_selection_plan", __dir__)
102
+ verifier = File.expand_path("../molecules/selection_verifier", __dir__)
103
+ script = <<~RUBY
104
+ begin
105
+ require #{model.inspect}
106
+ require #{verifier.inspect}
107
+ plan = Ace::TestRunner::Models::TestSelectionPlan.new(
108
+ files: #{plan.files.inspect}, identities: #{plan.identities.inspect},
109
+ source_digests: #{plan.source_digests.inspect})
110
+ verifier = Ace::TestRunner::Molecules::SelectionVerifier
111
+ verifier.verify_sources!(plan)
112
+ require "minitest/autorun"
113
+ plan.files.each { |file| require file }
114
+ pattern = verifier.verify_loaded!(plan)
115
+ args = ["--name", "/" + pattern + "/"]
116
+ args << "--verbose" if #{!!options[:profile]}
117
+ Minitest::Reporters.use! Minitest::Reporters::DefaultReporter.new
118
+ success = verifier.verify_execution!(plan) { Minitest.run(args) }
119
+ STDOUT.flush
120
+ STDERR.flush
121
+ exit!(success ? 0 : 1)
122
+ rescue Exception => error
123
+ STDERR.puts(error.message)
124
+ STDERR.flush
125
+ exit!(1)
130
126
  end
131
- end
132
-
133
- # Build the command
134
- script_parts = []
135
-
136
- # Require all files
137
- script_parts << file_requires.join("; ")
138
-
139
- # Set up ARGV with --name filter and --verbose if needed
140
- argv_args = []
141
- argv_args << "--verbose" if options[:profile]
142
- if test_names.any?
143
- # Create a regex pattern that matches any of the test names
144
- pattern = test_names.map { |name| Regexp.escape(name) }.join("|")
145
- argv_args << "--name"
146
- argv_args << "/#{pattern}/"
147
- end
148
- script_parts << "ARGV.replace([#{argv_args.map { |a| "'#{a}'" }.join(", ")}])" if argv_args.any?
149
-
150
- # Run Minitest
151
- script_parts << "exit_code = Minitest.autorun"
152
- script_parts << "exit(exit_code)"
153
-
154
- # Add to command
155
- cmd_parts << "-e"
156
- cmd_parts << "\"#{script_parts.join("; ")}\""
127
+ RUBY
128
+ argv.concat(["-e", script])
129
+ argv.concat(Array(options[:args])) if options[:args]
130
+ argv
157
131
  end
158
132
 
159
133
  def bundler_available?
@@ -1,85 +1,92 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "prism"
4
+
3
5
  module Ace
4
6
  module TestRunner
5
7
  module Atoms
6
- # Resolves line numbers to test method names
7
8
  module LineNumberResolver
9
+ class SelectionError < ArgumentError; end
8
10
  module_function
9
11
 
10
- # Given a file and line number, find the test method name
11
- # Returns the test name or nil if not found
12
- def resolve_test_at_line(file_path, line_number)
13
- return nil unless File.exist?(file_path)
14
-
15
- content = File.read(file_path)
16
- lines = content.split("\n")
17
-
18
- # Find all test methods and their line ranges
19
- test_methods = extract_test_methods(lines)
20
-
21
- # Find the test that contains the specified line
22
- test_methods.find do |test|
23
- line_number >= test[:start_line] && line_number <= test[:end_line]
24
- end&.fetch(:name)
12
+ def parse_file_with_line(selector)
13
+ if (match = /\A(.+\.rb):(.+)\z/.match(selector))
14
+ unless match[2].match?(/\A[1-9]\d*\z/)
15
+ raise SelectionError, "Invalid test selector: #{selector}"
16
+ end
17
+ {file: match[1], line: Integer(match[2], 10)}
18
+ else
19
+ {file: selector, line: nil}
20
+ end
25
21
  end
26
22
 
27
- # Extract test method names and their line ranges from file content
28
- def extract_test_methods(lines)
29
- test_methods = []
30
- current_test = nil
31
-
32
- lines.each_with_index do |line, index|
33
- line_number = index + 1
34
-
35
- # Match test method definitions: def test_something or test "something"
36
- if line =~ /^\s*(def\s+(test_\w+)|test\s+["'](.+)["']\s+do)/
37
- test_name = $2 || $3 # Either def test_name or test "name"
38
-
39
- # Convert test "name" to test_name format for minitest --name option
40
- test_name = test_name.gsub(/\s+/, "_") if test_name && test_name.include?(" ")
41
-
42
- # Close previous test if any
43
- if current_test
44
- current_test[:end_line] = line_number - 1
45
- test_methods << current_test
46
- end
47
-
48
- current_test = {
49
- name: test_name,
50
- start_line: line_number,
51
- end_line: lines.size # Default to end of file
52
- }
53
- elsif line =~ /^\s*end\s*(#.*)?$/ && current_test
54
- # Found an end keyword - could be end of test method
55
- # Simple heuristic: if we're at the same or less indentation level, close the test
56
- current_indent = line[/^\s*/].length
23
+ def resolve_test_at_line(file_path, line_number)
24
+ resolve_identity(file_path, line_number).fetch(:name)
25
+ end
57
26
 
58
- if current_indent <= 2 # Assuming test methods are indented at most 2 spaces
59
- current_test[:end_line] = line_number
60
- test_methods << current_test
61
- current_test = nil
62
- end
63
- end
27
+ def resolve_identity(file_path, line_number, source: nil)
28
+ selector = "#{file_path}:#{line_number}"
29
+ source ||= File.read(file_path)
30
+ unless line_number.is_a?(Integer) && line_number.positive? && line_number <= source.lines.size
31
+ raise SelectionError, "Invalid test selector: #{selector}"
64
32
  end
33
+ parsed = Prism.parse(source)
34
+ raise SelectionError, "Malformed Ruby in test selector: #{selector}" unless parsed.success?
65
35
 
66
- # Close last test if still open
67
- if current_test
68
- current_test[:end_line] = lines.size
69
- test_methods << current_test
70
- end
36
+ declarations = []
37
+ scopes = Hash.new(0)
38
+ collect(parsed.value, [], false, true, declarations, scopes)
39
+ matches = declarations.select { |entry| (entry[:start_line]..entry[:end_line]).cover?(line_number) }
40
+ selected = matches.one? && matches.first
41
+ valid = selected && selected[:supported] && scopes[selected[:class_name]] == 1 &&
42
+ declarations.count { |entry| entry.values_at(:class_name, :name) == selected.values_at(:class_name, :name) } == 1
43
+ raise SelectionError, "Unmatched or ambiguous test selector: #{selector}" unless valid
71
44
 
72
- test_methods
45
+ selected.reject { |key, _| key == :supported }.merge(file: File.expand_path(file_path)).freeze
46
+ rescue Errno::ENOENT, Errno::EACCES, Errno::EISDIR => error
47
+ raise SelectionError, "Cannot read test selector #{selector}: #{error.class}"
73
48
  end
74
49
 
75
- # Given "file.rb:123", split into file and line number
76
- def parse_file_with_line(file_with_line)
77
- if file_with_line =~ /^(.+):(\d+)$/
78
- {file: $1, line: $2.to_i}
50
+ def collect(node, namespace, in_class, supported, declarations, scopes)
51
+ return unless node
52
+ case node
53
+ when Prism::ProgramNode
54
+ collect(node.statements, namespace, in_class, supported, declarations, scopes)
55
+ when Prism::StatementsNode
56
+ node.body.each { |child| collect(child, namespace, in_class, supported, declarations, scopes) }
57
+ when Prism::ClassNode, Prism::ModuleNode
58
+ text = node.constant_path.location.slice
59
+ static = text.match?(/\A(?:::)?[A-Z]\w*(?:::[A-Z]\w*)*\z/)
60
+ parts = static ? text.delete_prefix("::").split("::") : []
61
+ path = text.start_with?("::") ? parts : namespace + parts
62
+ scopes[path.join("::")] += 1
63
+ collect(node.body, path, node.is_a?(Prism::ClassNode), supported && static, declarations, scopes)
64
+ when Prism::DefNode
65
+ if node.name.to_s.start_with?("test_")
66
+ add_declaration(node, node.name.to_s, namespace, supported && in_class && node.receiver.nil?,
67
+ node.def_keyword_loc.start_line, declarations)
68
+ end
69
+ # Nested declarations cannot acquire the surrounding static class's identity.
70
+ collect(node.body, namespace, in_class, false, declarations, scopes)
71
+ when Prism::CallNode
72
+ if node.name == :test && node.block.is_a?(Prism::BlockNode)
73
+ arguments = node.arguments&.arguments || []
74
+ literal = arguments.one? && arguments.first.is_a?(Prism::StringNode)
75
+ name = literal ? "test_#{arguments.first.unescaped.gsub(/\s+/, '_')}" : nil
76
+ static = supported && in_class && node.receiver.nil? && literal && node.block.opening_loc.slice == "do"
77
+ add_declaration(node, name, namespace, static, node.block.opening_loc.start_line, declarations)
78
+ end
79
+ node.compact_child_nodes.each { |child| collect(child, namespace, in_class, false, declarations, scopes) }
79
80
  else
80
- {file: file_with_line, line: nil}
81
+ node.compact_child_nodes.each { |child| collect(child, namespace, in_class, false, declarations, scopes) }
81
82
  end
82
83
  end
84
+
85
+ def add_declaration(node, name, namespace, supported, method_line, declarations)
86
+ declarations << {name: name, class_name: namespace.join("::"), start_line: node.location.start_line,
87
+ end_line: node.location.end_line, method_line: method_line, supported: supported}
88
+ end
89
+ private_class_method :collect, :add_declaration
83
90
  end
84
91
  end
85
92
  end
@@ -15,7 +15,7 @@ module Ace
15
15
  # Pattern for inline errors - same structure as failures but with ERROR marker
16
16
  inline_error: /^\s+(test_[\w_]+).*?ERROR.*?\([\d.]+s\)\n(.*?)(?=^\s+test_[\w_]+.*?(?:PASS|FAIL|ERROR|SKIP)|^Finished in|\z)/m,
17
17
  location: /\[(.*?):(\d+)\]/,
18
- duration: /Finished in ([\d.]+)s/,
18
+ duration: /Finished(?: tests)? in ([\d.]+)s/,
19
19
  deprecation: /DEPRECATION WARNING: (.+)/,
20
20
  # Pattern to capture individual test times from verbose output
21
21
  # Matches Minitest::Reporters DefaultReporter format:
@@ -90,6 +90,7 @@ module Ace
90
90
  # Execution mode options
91
91
  option :parallel, type: :boolean, desc: "Run tests in parallel (experimental)"
92
92
  option :per_file, type: :boolean, desc: "Execute each test file separately (slower, for debugging)"
93
+ # File-line selectors resolve exact declarations; mixed whole-file/line input refuses.
93
94
  option :direct, type: :boolean, desc: "Force in-process execution (faster, less isolation)"
94
95
  option :subprocess, type: :boolean, desc: "Force subprocess execution (slower, full isolation)"
95
96
  option :run_in_sequence, type: :boolean, aliases: %w[--ris], desc: "Run test targets sequentially (default)"
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module TestRunner
5
+ module Models
6
+ class TestSelectionPlan
7
+ attr_reader :files, :identities, :source_digests
8
+
9
+ def initialize(files:, identities:, source_digests:)
10
+ @files = freeze_values(files)
11
+ @identities = freeze_values(identities)
12
+ @source_digests = freeze_values(source_digests)
13
+ freeze
14
+ end
15
+
16
+ def qualified?
17
+ !identities.empty?
18
+ end
19
+
20
+ def for_file(file)
21
+ path = File.expand_path(file.sub(/:\d+$/, ""))
22
+ self.class.new(files: [path],
23
+ identities: identities.select { |identity| identity.fetch(:file) == path },
24
+ source_digests: source_digests.select { |key, _| key == path })
25
+ end
26
+
27
+ private
28
+
29
+ def freeze_values(value)
30
+ case value
31
+ when Hash
32
+ value.to_h { |key, entry| [freeze_values(key), freeze_values(entry)] }.freeze
33
+ when Array
34
+ value.map { |entry| freeze_values(entry) }.freeze
35
+ when String
36
+ value.dup.freeze
37
+ else
38
+ value.freeze
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -38,6 +38,8 @@ module Ace
38
38
  #
39
39
  # @return [Hash] Parsed options with :package_dir, :target, :files keys
40
40
  def parse
41
+ require_relative "../atoms/line_number_resolver"
42
+ @argv.each { |argument| Atoms::LineNumberResolver.parse_file_with_line(argument) }
41
43
  parse_first_argument
42
44
  parse_remaining_arguments
43
45
 
@@ -125,6 +127,8 @@ module Ace
125
127
  @test_files << arg
126
128
  elsif package_relative_ruby_file?(arg)
127
129
  @test_files << arg
130
+ elsif literal_ruby_file?(arg)
131
+ raise ArgumentError, "File not found: #{arg}"
128
132
  elsif known_target?(arg)
129
133
  @target = arg
130
134
  elsif @target.nil? && !File.exist?(arg)
@@ -210,7 +214,7 @@ module Ace
210
214
  # Check if an argument could be a package name (not a target, file, or file:line)
211
215
  def potential_package?(arg)
212
216
  !known_target?(arg) &&
213
- !(arg.end_with?(".rb") && File.file?(arg)) &&
217
+ !literal_ruby_file?(arg) &&
214
218
  !file_with_line?(arg)
215
219
  end
216
220
 
@@ -236,6 +240,10 @@ module Ace
236
240
  @test_files << (@package_dir ? format_file_with_line(file_part, line_part) : arg)
237
241
  end
238
242
 
243
+ def literal_ruby_file?(arg)
244
+ arg.end_with?(".rb") && !arg.match?(/[\*?\[\]{}]/)
245
+ end
246
+
239
247
  def existing_ruby_file?(arg)
240
248
  File.exist?(arg) && arg.end_with?(".rb")
241
249
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "stringio"
4
+ require "timeout"
4
5
  require "ostruct"
5
6
  require "minitest"
6
7
 
@@ -62,30 +63,11 @@ module Ace
62
63
  require "minitest/autorun"
63
64
  end
64
65
 
65
- # First pass: check for line numbers and resolve test names
66
- # This must be done before loading files
67
- test_names_to_run = []
68
- files_to_load = []
69
-
70
- files.each do |file|
71
- # Check if file has line number (file:line format)
72
- if file =~ /^(.+):(\d+)$/
73
- actual_file = $1
74
- line_number = $2.to_i
75
-
76
- # Resolve line number to test name
77
- require_relative "../atoms/line_number_resolver"
78
- test_name = Ace::TestRunner::Atoms::LineNumberResolver.resolve_test_at_line(actual_file, line_number)
79
- test_names_to_run << test_name if test_name
80
-
81
- files_to_load << actual_file
82
- else
83
- files_to_load << file
84
- end
85
- end
86
-
87
- # Store test names in options to pass to run_minitest_with_args
88
- options = options.merge(test_names_filter: test_names_to_run) if test_names_to_run.any?
66
+ require_relative "selection_resolver"
67
+ require_relative "selection_verifier"
68
+ selection = options[:selection_plan] || SelectionResolver.resolve(files)
69
+ SelectionVerifier.verify_sources!(selection)
70
+ files_to_load = selection.files
89
71
 
90
72
  # Clear previously loaded test classes to avoid accumulation between groups
91
73
  # This is crucial for in-process execution where tests from previous groups
@@ -128,14 +110,23 @@ module Ace
128
110
  end
129
111
  end
130
112
 
113
+ if selection.qualified?
114
+ options = options.merge(selection_pattern: SelectionVerifier.verify_loaded!(selection))
115
+ end
116
+
131
117
  # Run Minitest with captured output
132
118
  # Suppress Minitest's own output by using null reporter
133
- exit_code = if @timeout
134
- Timeout.timeout(@timeout, ExecutionTimeout) do
119
+ execution = lambda do
120
+ if @timeout
121
+ Timeout.timeout(@timeout, ExecutionTimeout) { run_minitest_silent(options) }
122
+ else
135
123
  run_minitest_silent(options)
136
124
  end
125
+ end
126
+ exit_code = if selection.qualified?
127
+ SelectionVerifier.verify_execution!(selection, &execution)
137
128
  else
138
- run_minitest_silent(options)
129
+ execution.call
139
130
  end
140
131
 
141
132
  success = exit_code == true || exit_code == 0
@@ -242,9 +233,8 @@ module Ace
242
233
  args << "--verbose" if options[:verbose]
243
234
 
244
235
  # Add test name filter if line numbers were provided
245
- if options[:test_names_filter] && options[:test_names_filter].any?
246
- pattern = options[:test_names_filter].map { |name| Regexp.escape(name) }.join("|")
247
- args << "--name" << "/#{pattern}/"
236
+ if options[:selection_pattern]
237
+ args << "--name" << "/#{options[:selection_pattern]}/"
248
238
  end
249
239
 
250
240
  # Run Minitest
@@ -261,9 +251,8 @@ module Ace
261
251
  args << "--seed" << options[:seed].to_s if options[:seed]
262
252
 
263
253
  # Add test name filter if line numbers were provided
264
- if options[:test_names_filter] && options[:test_names_filter].any?
265
- pattern = options[:test_names_filter].map { |name| Regexp.escape(name) }.join("|")
266
- args << "--name" << "/#{pattern}/"
254
+ if options[:selection_pattern]
255
+ args << "--name" << "/#{options[:selection_pattern]}/"
267
256
  end
268
257
 
269
258
  # Minitest swallows Interrupt and returns a normal failure. Preserve
@@ -5,7 +5,8 @@ module Ace
5
5
  module Molecules
6
6
  class PatternResolver
7
7
  INTERNAL_PATTERN_LABELS = {
8
- "feat_tests" => "feat"
8
+ "feat_tests" => "feat",
9
+ "fast_tests" => "fast"
9
10
  }.freeze
10
11
 
11
12
  attr_reader :using_catch_all
@@ -42,7 +43,7 @@ module Ace
42
43
  targets.flat_map { |target| resolve_target(target) }.uniq
43
44
  end
44
45
 
45
- def resolve_target_sequential(target_name, seen = [])
46
+ def resolve_target_sequential(target_name, seen = [], selected = {})
46
47
  target_key = target_name.to_s
47
48
  if seen.include?(target_key)
48
49
  raise ArgumentError, "Cyclic test target definition detected: #{(seen + [target_key]).join(' -> ')}"
@@ -55,12 +56,14 @@ module Ace
55
56
  member_key = member.to_s
56
57
 
57
58
  if @targets.key?(member_key)
58
- resolve_target_sequential(member_key, seen + [target_key])
59
+ resolve_target_sequential(member_key, seen + [target_key], selected)
59
60
  elsif @patterns.key?(member_key)
60
- files = expand_pattern(@patterns[member_key])
61
+ files = expand_pattern(@patterns[member_key]).reject { |file| selected.key?(file) }
62
+ files.each { |file| selected[file] = true }
61
63
  files.empty? ? [] : [{name: display_name_for(member_key), files: files}]
62
64
  else
63
- files = expand_pattern(member)
65
+ files = expand_pattern(member).reject { |file| selected.key?(file) }
66
+ files.each { |file| selected[file] = true }
64
67
  files.empty? ? [] : [{name: "other", files: files}]
65
68
  end
66
69
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "../atoms/line_number_resolver"
5
+ require_relative "../models/test_selection_plan"
6
+
7
+ module Ace
8
+ module TestRunner
9
+ module Molecules
10
+ module SelectionResolver
11
+ module_function
12
+
13
+ def resolve(files)
14
+ parsed = Array(files).map { |selector| Atoms::LineNumberResolver.parse_file_with_line(selector) }
15
+ qualified = parsed.count { |entry| entry[:line] }
16
+ if qualified.positive? && qualified != parsed.size
17
+ raise Atoms::LineNumberResolver::SelectionError,
18
+ "Mixed whole-file and file:line selectors are unsupported: #{files.join(', ')}"
19
+ end
20
+ if qualified.zero?
21
+ return Models::TestSelectionPlan.new(files: Array(files), identities: [], source_digests: {})
22
+ end
23
+
24
+ sources = {}
25
+ identities = parsed.map do |entry|
26
+ path = File.expand_path(entry.fetch(:file))
27
+ source = sources[path] ||= File.read(path)
28
+ Atoms::LineNumberResolver.resolve_identity(path, entry.fetch(:line), source: source)
29
+ rescue SystemCallError => error
30
+ raise Atoms::LineNumberResolver::SelectionError,
31
+ "Cannot read test selector #{entry[:file]}:#{entry[:line]}: #{error.class}"
32
+ end
33
+ identities = identities.uniq { |entry| entry.values_at(:file, :class_name, :name) }
34
+ identities.group_by { |entry| entry.values_at(:class_name, :name) }.each_value do |matches|
35
+ next if matches.size == 1
36
+ selectors = matches.map { |entry| "#{entry[:file]}:#{entry[:start_line]}" }.join(", ")
37
+ raise Atoms::LineNumberResolver::SelectionError, "Ambiguous test identity across files: #{selectors}"
38
+ end
39
+ Models::TestSelectionPlan.new(files: sources.keys, identities: identities,
40
+ source_digests: sources.transform_values { |source| Digest::SHA256.hexdigest(source) })
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+ require "digest"
3
+ require "minitest/reporters"
4
+ require_relative "../atoms/line_number_resolver"
5
+
6
+ module Ace
7
+ module TestRunner
8
+ module Molecules
9
+ module SelectionVerifier
10
+ class ExecutionCollector < Minitest::Reporters::BaseReporter
11
+ attr_reader :identities
12
+
13
+ def initialize
14
+ super
15
+ @identities = []
16
+ @mutex = Mutex.new
17
+ end
18
+
19
+ def record(result)
20
+ @mutex.synchronize do
21
+ super
22
+ @identities << [result.class_name, result.name]
23
+ end
24
+ end
25
+ end
26
+
27
+ module_function
28
+
29
+ def verify_execution!(plan)
30
+ previous = Minitest::Reporters.reporters
31
+ collector = ExecutionCollector.new
32
+ Minitest::Reporters.reporters = Array(previous) + [collector]
33
+ result = yield
34
+ expected = plan.identities.map { |identity| identity.values_at(:class_name, :name) }.sort
35
+ unless collector.identities.sort == expected
36
+ raise Atoms::LineNumberResolver::SelectionError,
37
+ "Executed test identities do not match selected identities"
38
+ end
39
+ result
40
+ ensure
41
+ Minitest::Reporters.reporters = previous
42
+ end
43
+
44
+ def verify_sources!(plan)
45
+ plan.source_digests.each do |file, digest|
46
+ unless Digest::SHA256.file(file).hexdigest == digest
47
+ raise Atoms::LineNumberResolver::SelectionError, "Selected test source changed: #{file}"
48
+ end
49
+ end
50
+ end
51
+
52
+ def verify_loaded!(plan, runnables: Minitest::Runnable.runnables)
53
+ verify_sources!(plan)
54
+ # Minitest's default randomized enumeration requires a seed even before run.
55
+ Minitest.seed ||= Random.new_seed
56
+ plan.identities.each do |identity|
57
+ matches = runnables.select { |klass| klass.name == identity.fetch(:class_name) }
58
+ klass = matches.first
59
+ valid = matches.size == 1 && klass < Minitest::Test && klass.public_instance_methods.include?(identity.fetch(:name).to_sym)
60
+ valid &&= klass.runnable_methods.count(identity.fetch(:name)) == 1
61
+ if valid
62
+ method = klass.instance_method(identity.fetch(:name))
63
+ source = method.source_location
64
+ valid = method.owner == klass && source &&
65
+ File.expand_path(source.first) == identity.fetch(:file) && source.last == identity.fetch(:method_line)
66
+ end
67
+ unless valid
68
+ raise Atoms::LineNumberResolver::SelectionError,
69
+ "Selected test identity does not match loaded runnable: #{identity[:class_name]}##{identity[:name]}"
70
+ end
71
+ end
72
+ names = plan.identities.map { |identity| Regexp.escape("#{identity[:class_name]}##{identity[:name]}") }
73
+ "\\A(?:#{names.join('|')})\\z"
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "test_executor"
4
+ require_relative "selection_resolver"
4
5
  require_relative "in_process_runner"
5
6
  require_relative "../atoms/test_type_detector"
6
7
 
@@ -20,6 +21,7 @@ module Ace
20
21
  return empty_result if files.empty?
21
22
 
22
23
  # Determine execution mode
24
+ options = options.merge(selection_plan: options[:selection_plan] || SelectionResolver.resolve(files))
23
25
  mode = determine_execution_mode(files, options)
24
26
 
25
27
  # Execute with appropriate runner
@@ -31,6 +33,7 @@ module Ace
31
33
  end
32
34
 
33
35
  def execute_single_file(file, options = {})
36
+ options = options.merge(selection_plan: options[:selection_plan] || SelectionResolver.resolve([file]))
34
37
  mode = determine_execution_mode([file], options)
35
38
 
36
39
  if mode == :direct
@@ -46,6 +49,7 @@ module Ace
46
49
  end
47
50
 
48
51
  def execute_with_progress(files, options = {}, &block)
52
+ options = options.merge(selection_plan: options[:selection_plan] || SelectionResolver.resolve(files))
49
53
  mode = determine_execution_mode(files, options)
50
54
 
51
55
  # Add mode information to progress callback
@@ -3,6 +3,7 @@
3
3
  require "open3"
4
4
  require "timeout"
5
5
  require "ostruct"
6
+ require_relative "selection_resolver"
6
7
 
7
8
  module Ace
8
9
  module TestRunner
@@ -39,15 +40,15 @@ module Ace
39
40
  # environment (Process.spawn merges onto the parent by default).
40
41
  # MT_NO_AUTORUN is part of the fixture environment.
41
42
  env = @launch_env
42
- command = command.sub(/^MT_NO_AUTORUN=1\s+/, "")
43
+ command = command.sub(/^MT_NO_AUTORUN=1\s+/, "") if command.is_a?(String)
43
44
 
44
45
  begin
45
46
  if @timeout
46
47
  Timeout.timeout(@timeout) do
47
- stdout, stderr, status = Open3.capture3(env, command, unsetenv_others: true)
48
+ stdout, stderr, status = Open3.capture3(env, *Array(command), unsetenv_others: true)
48
49
  end
49
50
  else
50
- stdout, stderr, status = Open3.capture3(env, command, unsetenv_others: true)
51
+ stdout, stderr, status = Open3.capture3(env, *Array(command), unsetenv_others: true)
51
52
  end
52
53
  rescue Timeout::Error
53
54
  stderr = "Test execution timed out after #{@timeout} seconds"
@@ -69,6 +70,7 @@ module Ace
69
70
  end
70
71
 
71
72
  def execute_with_progress(files, options = {}, &block)
73
+ options = options.merge(selection_plan: options[:selection_plan] || SelectionResolver.resolve(files))
72
74
  # Fail-fast requires per-file execution to stop on first failure
73
75
  # For performance, execute all files together unless explicitly disabled or fail-fast enabled
74
76
  if options[:per_file] == true || options[:fail_fast]
@@ -93,12 +95,16 @@ module Ace
93
95
  end
94
96
 
95
97
  def execute_per_file_with_progress(files, options = {}, &block)
98
+ options = options.merge(selection_plan: options[:selection_plan] || SelectionResolver.resolve(files))
96
99
  results = []
100
+ plan = options.fetch(:selection_plan)
101
+ files = plan.files if plan.qualified?
97
102
 
98
103
  files.each do |file|
99
104
  yield({type: :start, file: file}) if block_given?
100
105
 
101
- result = execute_single_file(file, options)
106
+ file_options = plan.qualified? ? options.merge(selection_plan: plan.for_file(file)) : options
107
+ result = execute_single_file(file, file_options)
102
108
  results << result
103
109
 
104
110
  if block_given?
@@ -173,6 +173,7 @@ module Ace
173
173
  duration: total_duration,
174
174
  test_times: all_test_times
175
175
  },
176
+ files: all_files.uniq,
176
177
  stopped_at_target: stopped
177
178
  }
178
179
  end
@@ -265,6 +265,9 @@ module Ace
265
265
  end
266
266
  end
267
267
 
268
+ # Only dispatched targets belong in the execution report after an early stop.
269
+ all_files = execution_result.fetch(:files)
270
+
268
271
  # Use the parsed result from sequential executor
269
272
  @parsed_result = execution_result[:parsed_result]
270
273
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module TestRunner
5
- VERSION = "0.28.0"
5
+ VERSION = "0.28.1"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-test-runner
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.28.0
4
+ version: 0.28.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-05 00:00:00.000000000 Z
10
+ date: 2026-10-08 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-fs
@@ -121,6 +121,20 @@ dependencies:
121
121
  - - "~>"
122
122
  - !ruby/object:Gem::Version
123
123
  version: '1.8'
124
+ - !ruby/object:Gem::Dependency
125
+ name: prism
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: '1.9'
131
+ type: :runtime
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: '1.9'
124
138
  - !ruby/object:Gem::Dependency
125
139
  name: ostruct
126
140
  requirement: !ruby/object:Gem::Requirement
@@ -207,6 +221,7 @@ files:
207
221
  - lib/ace/test_runner/models/test_failure.rb
208
222
  - lib/ace/test_runner/models/test_report.rb
209
223
  - lib/ace/test_runner/models/test_result.rb
224
+ - lib/ace/test_runner/models/test_selection_plan.rb
210
225
  - lib/ace/test_runner/models/test_target.rb
211
226
  - lib/ace/test_runner/molecules/cli_argument_parser.rb
212
227
  - lib/ace/test_runner/molecules/config_loader.rb
@@ -220,6 +235,8 @@ files:
220
235
  - lib/ace/test_runner/molecules/pattern_resolver.rb
221
236
  - lib/ace/test_runner/molecules/rake_integration.rb
222
237
  - lib/ace/test_runner/molecules/report_storage.rb
238
+ - lib/ace/test_runner/molecules/selection_resolver.rb
239
+ - lib/ace/test_runner/molecules/selection_verifier.rb
223
240
  - lib/ace/test_runner/molecules/smart_test_executor.rb
224
241
  - lib/ace/test_runner/molecules/test_executor.rb
225
242
  - lib/ace/test_runner/organisms/agent_reporter.rb