ace-test-runner 0.18.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 (60) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/test/runner.yml +35 -0
  3. data/.ace-defaults/test/suite.yml +31 -0
  4. data/.ace-defaults/test-runner/config.yml +61 -0
  5. data/CHANGELOG.md +626 -0
  6. data/LICENSE +21 -0
  7. data/README.md +42 -0
  8. data/Rakefile +14 -0
  9. data/exe/ace-test +26 -0
  10. data/exe/ace-test-suite +149 -0
  11. data/lib/ace/test_runner/atoms/command_builder.rb +165 -0
  12. data/lib/ace/test_runner/atoms/lazy_loader.rb +62 -0
  13. data/lib/ace/test_runner/atoms/line_number_resolver.rb +86 -0
  14. data/lib/ace/test_runner/atoms/report_directory_resolver.rb +48 -0
  15. data/lib/ace/test_runner/atoms/report_path_resolver.rb +67 -0
  16. data/lib/ace/test_runner/atoms/result_parser.rb +254 -0
  17. data/lib/ace/test_runner/atoms/test_detector.rb +114 -0
  18. data/lib/ace/test_runner/atoms/test_folder_detector.rb +53 -0
  19. data/lib/ace/test_runner/atoms/test_type_detector.rb +83 -0
  20. data/lib/ace/test_runner/atoms/timestamp_generator.rb +103 -0
  21. data/lib/ace/test_runner/cli/commands/test.rb +326 -0
  22. data/lib/ace/test_runner/cli.rb +16 -0
  23. data/lib/ace/test_runner/formatters/base_formatter.rb +102 -0
  24. data/lib/ace/test_runner/formatters/json_formatter.rb +90 -0
  25. data/lib/ace/test_runner/formatters/markdown_formatter.rb +91 -0
  26. data/lib/ace/test_runner/formatters/progress_file_formatter.rb +164 -0
  27. data/lib/ace/test_runner/formatters/progress_formatter.rb +328 -0
  28. data/lib/ace/test_runner/models/test_configuration.rb +165 -0
  29. data/lib/ace/test_runner/models/test_failure.rb +95 -0
  30. data/lib/ace/test_runner/models/test_group.rb +105 -0
  31. data/lib/ace/test_runner/models/test_report.rb +145 -0
  32. data/lib/ace/test_runner/models/test_result.rb +86 -0
  33. data/lib/ace/test_runner/molecules/cli_argument_parser.rb +263 -0
  34. data/lib/ace/test_runner/molecules/config_loader.rb +162 -0
  35. data/lib/ace/test_runner/molecules/deprecation_fixer.rb +204 -0
  36. data/lib/ace/test_runner/molecules/failed_package_reporter.rb +100 -0
  37. data/lib/ace/test_runner/molecules/failure_analyzer.rb +249 -0
  38. data/lib/ace/test_runner/molecules/in_process_runner.rb +249 -0
  39. data/lib/ace/test_runner/molecules/package_resolver.rb +106 -0
  40. data/lib/ace/test_runner/molecules/pattern_resolver.rb +146 -0
  41. data/lib/ace/test_runner/molecules/rake_integration.rb +218 -0
  42. data/lib/ace/test_runner/molecules/report_storage.rb +303 -0
  43. data/lib/ace/test_runner/molecules/smart_test_executor.rb +107 -0
  44. data/lib/ace/test_runner/molecules/test_executor.rb +162 -0
  45. data/lib/ace/test_runner/organisms/agent_reporter.rb +384 -0
  46. data/lib/ace/test_runner/organisms/report_generator.rb +151 -0
  47. data/lib/ace/test_runner/organisms/sequential_group_executor.rb +185 -0
  48. data/lib/ace/test_runner/organisms/test_orchestrator.rb +648 -0
  49. data/lib/ace/test_runner/rake_task.rb +90 -0
  50. data/lib/ace/test_runner/suite/display_helpers.rb +117 -0
  51. data/lib/ace/test_runner/suite/display_manager.rb +204 -0
  52. data/lib/ace/test_runner/suite/duration_estimator.rb +50 -0
  53. data/lib/ace/test_runner/suite/orchestrator.rb +120 -0
  54. data/lib/ace/test_runner/suite/process_monitor.rb +268 -0
  55. data/lib/ace/test_runner/suite/result_aggregator.rb +176 -0
  56. data/lib/ace/test_runner/suite/simple_display_manager.rb +122 -0
  57. data/lib/ace/test_runner/suite.rb +22 -0
  58. data/lib/ace/test_runner/version.rb +7 -0
  59. data/lib/ace/test_runner.rb +69 -0
  60. metadata +246 -0
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module TestRunner
5
+ module Models
6
+ # Represents a single test failure or error
7
+ class TestFailure
8
+ attr_accessor :type, :test_name, :test_class, :message, :file_path,
9
+ :line_number, :backtrace, :fix_suggestion, :code_context,
10
+ :stderr_warnings
11
+
12
+ def initialize(attributes = {})
13
+ @type = attributes[:type] || :failure # :failure or :error
14
+ @test_name = attributes[:test_name]
15
+ @test_class = attributes[:test_class]
16
+ @message = attributes[:message]
17
+ @file_path = attributes[:file_path]
18
+ @line_number = attributes[:line_number]
19
+ @backtrace = attributes[:backtrace] || []
20
+ @fix_suggestion = attributes[:fix_suggestion]
21
+ @code_context = attributes[:code_context]
22
+ @stderr_warnings = attributes[:stderr_warnings]
23
+ end
24
+
25
+ def location
26
+ return nil unless file_path
27
+
28
+ if line_number
29
+ "#{file_path}:#{line_number}"
30
+ else
31
+ file_path
32
+ end
33
+ end
34
+
35
+ def short_location
36
+ return nil unless file_path
37
+
38
+ base_name = File.basename(file_path)
39
+ line_number ? "#{base_name}:#{line_number}" : base_name
40
+ end
41
+
42
+ def full_test_name
43
+ test_class ? "#{test_class}##{test_name}" : test_name.to_s
44
+ end
45
+
46
+ def error?
47
+ type == :error
48
+ end
49
+
50
+ def failure?
51
+ type == :failure
52
+ end
53
+
54
+ def summary_line
55
+ "#{type_icon} #{full_test_name} - #{short_location}"
56
+ end
57
+
58
+ def detailed_description
59
+ lines = []
60
+ lines << "#{type_icon} #{type.to_s.capitalize}: #{full_test_name}"
61
+ lines << " Location: #{location}" if location
62
+ lines << " Message: #{message}" if message
63
+ lines << " Fix: #{fix_suggestion}" if fix_suggestion
64
+ lines.join("\n")
65
+ end
66
+
67
+ def to_h
68
+ {
69
+ type: type,
70
+ test_name: test_name,
71
+ test_class: test_class,
72
+ message: message,
73
+ location: location,
74
+ file_path: file_path,
75
+ line_number: line_number,
76
+ backtrace: backtrace,
77
+ fix_suggestion: fix_suggestion,
78
+ code_context: code_context,
79
+ stderr_warnings: stderr_warnings
80
+ }
81
+ end
82
+
83
+ def to_json(*args)
84
+ to_h.to_json(*args)
85
+ end
86
+
87
+ private
88
+
89
+ def type_icon
90
+ error? ? "💥" : "❌"
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module TestRunner
5
+ module Models
6
+ # Represents a group of tests to be executed together
7
+ class TestGroup
8
+ attr_reader :name, :patterns, :files, :options
9
+
10
+ def initialize(name:, patterns: [], files: [], options: {})
11
+ @name = name
12
+ @patterns = Array(patterns)
13
+ @files = Array(files)
14
+ @options = options
15
+ end
16
+
17
+ # Find all test files matching this group's patterns
18
+ def find_files
19
+ return @files unless @files.empty?
20
+
21
+ detector = Atoms::TestDetector.new(patterns: @patterns)
22
+ detector.find_test_files
23
+ end
24
+
25
+ # Execute this group's tests
26
+ def execute(executor, formatter_options = {})
27
+ test_files = find_files
28
+ return empty_result if test_files.empty?
29
+
30
+ options = @options.merge(formatter_options)
31
+ executor.execute_tests(test_files, options)
32
+ end
33
+
34
+ # Check if a file belongs to this group
35
+ def includes_file?(file_path)
36
+ return true if @files.include?(file_path)
37
+
38
+ @patterns.any? do |pattern|
39
+ File.fnmatch(pattern, file_path, File::FNM_PATHNAME)
40
+ end
41
+ end
42
+
43
+ def to_h
44
+ {
45
+ name: @name,
46
+ patterns: @patterns,
47
+ files: @files,
48
+ options: @options
49
+ }
50
+ end
51
+
52
+ # Load groups from configuration
53
+ def self.from_config(config)
54
+ groups = config.fetch("groups", default_groups)
55
+
56
+ groups.map do |name, definition|
57
+ new(
58
+ name: name,
59
+ patterns: definition["patterns"] || [],
60
+ files: definition["files"] || [],
61
+ options: definition.fetch("options", {}).transform_keys(&:to_sym)
62
+ )
63
+ end
64
+ end
65
+
66
+ # Default test groups for common Ruby project structures
67
+ def self.default_groups
68
+ {
69
+ "unit" => {
70
+ "patterns" => ["test/unit/**/*_test.rb", "test/*_test.rb"],
71
+ "options" => {}
72
+ },
73
+ "integration" => {
74
+ "patterns" => ["test/integration/**/*_test.rb"],
75
+ "options" => {}
76
+ },
77
+ "system" => {
78
+ "patterns" => ["test/system/**/*_test.rb"],
79
+ "options" => {"timeout" => 60}
80
+ },
81
+ "all" => {
82
+ "patterns" => ["test/**/*_test.rb"],
83
+ "options" => {}
84
+ }
85
+ }
86
+ end
87
+
88
+ private
89
+
90
+ def empty_result
91
+ {
92
+ stdout: "",
93
+ stderr: "No test files found for group '#{@name}'",
94
+ status: OpenStruct.new(success?: true, exitstatus: 0),
95
+ command: "",
96
+ start_time: Time.now,
97
+ end_time: Time.now,
98
+ duration: 0.0,
99
+ success: true
100
+ }
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Ace
6
+ module TestRunner
7
+ module Models
8
+ # Represents a complete test report
9
+ class TestReport
10
+ attr_accessor :result, :configuration, :timestamp, :report_path,
11
+ :files_tested, :environment, :metadata
12
+
13
+ def initialize(attributes = {})
14
+ @result = attributes[:result] || TestResult.new
15
+ @configuration = attributes[:configuration] || TestConfiguration.new
16
+ @timestamp = attributes[:timestamp] || Time.now
17
+ @report_path = attributes[:report_path]
18
+ @files_tested = attributes[:files_tested] || []
19
+ @environment = attributes[:environment] || capture_environment
20
+ @metadata = attributes[:metadata] || {}
21
+ end
22
+
23
+ def success?
24
+ result.success?
25
+ end
26
+
27
+ def summary
28
+ {
29
+ status: success? ? "success" : "failure",
30
+ passed: result.passed,
31
+ failed: result.failed,
32
+ errors: result.errors,
33
+ skipped: result.skipped,
34
+ total: result.total_tests,
35
+ pass_rate: result.pass_rate,
36
+ duration: result.duration,
37
+ timestamp: timestamp.iso8601
38
+ }
39
+ end
40
+
41
+ def failure_summary
42
+ return [] unless result.has_failures?
43
+
44
+ result.failures_detail.map do |failure|
45
+ {
46
+ type: failure.type,
47
+ test: failure.full_test_name,
48
+ location: failure.location,
49
+ message: failure.message
50
+ }
51
+ end
52
+ end
53
+
54
+ def to_h
55
+ {
56
+ summary: summary,
57
+ result: result.to_h,
58
+ configuration: configuration.to_h,
59
+ timestamp: timestamp.iso8601,
60
+ report_path: report_path,
61
+ files_tested: files_tested,
62
+ environment: environment,
63
+ failures: failure_summary,
64
+ deprecations: result.deprecations,
65
+ metadata: metadata
66
+ }
67
+ end
68
+
69
+ def to_json(*args)
70
+ to_h.to_json(*args)
71
+ end
72
+
73
+ def to_markdown
74
+ lines = []
75
+ lines << "# Test Report"
76
+ lines << ""
77
+ lines << "**Generated:** #{timestamp.strftime("%Y-%m-%d %H:%M:%S")}"
78
+ lines << "**Status:** #{success? ? "✅ Success" : "❌ Failed"}"
79
+ lines << ""
80
+
81
+ lines << "## Summary"
82
+ lines << ""
83
+ lines << "| Metric | Value |"
84
+ lines << "|--------|-------|"
85
+ lines << "| Total Tests | #{result.total_tests} |"
86
+ lines << "| Passed | #{result.passed} |"
87
+ lines << "| Failed | #{result.failed} |"
88
+ lines << "| Errors | #{result.errors} |"
89
+ lines << "| Skipped | #{result.skipped} |"
90
+ lines << "| Pass Rate | #{result.pass_rate}% |"
91
+ lines << "| Duration | #{result.duration}s |"
92
+ lines << ""
93
+
94
+ if result.has_failures?
95
+ lines << "## Failures"
96
+ lines << ""
97
+ result.failures_detail.each_with_index do |failure, idx|
98
+ lines << "### #{idx + 1}. #{failure.full_test_name}"
99
+ lines << ""
100
+ lines << "- **Type:** #{failure.type}"
101
+ lines << "- **Location:** `#{failure.location}`"
102
+ lines << "- **Message:** #{failure.message}"
103
+ lines << "- **Fix:** #{failure.fix_suggestion}" if failure.fix_suggestion
104
+ lines << ""
105
+ end
106
+ end
107
+
108
+ if result.has_deprecations?
109
+ lines << "## Deprecations"
110
+ lines << ""
111
+ result.deprecations.each do |deprecation|
112
+ lines << "- #{deprecation}"
113
+ end
114
+ lines << ""
115
+ end
116
+
117
+ if files_tested.any?
118
+ lines << "## Files Tested"
119
+ lines << ""
120
+ files_tested.each do |file|
121
+ lines << "- #{file}"
122
+ end
123
+ lines << ""
124
+ end
125
+
126
+ lines.join("\n")
127
+ end
128
+
129
+ private
130
+
131
+ def capture_environment
132
+ {
133
+ ruby_version: RUBY_VERSION,
134
+ ruby_platform: RUBY_PLATFORM,
135
+ minitest_version: defined?(Minitest::VERSION) ? Minitest::VERSION : "unknown",
136
+ ace_test_runner_version: VERSION,
137
+ working_directory: Dir.pwd,
138
+ user: ENV["USER"],
139
+ hostname: ENV["HOSTNAME"] || Socket.gethostname
140
+ }
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module TestRunner
5
+ module Models
6
+ # Represents the result of a test run
7
+ class TestResult
8
+ attr_accessor :passed, :failed, :skipped, :errors, :assertions,
9
+ :duration, :start_time, :end_time, :failures_detail,
10
+ :deprecations, :raw_output, :stderr
11
+
12
+ def initialize(attributes = {})
13
+ @passed = attributes[:passed] || 0
14
+ @failed = attributes[:failed] || 0
15
+ @skipped = attributes[:skipped] || 0
16
+ @errors = attributes[:errors] || 0
17
+ @assertions = attributes[:assertions] || 0
18
+ @duration = attributes[:duration] || 0.0
19
+ @start_time = attributes[:start_time]
20
+ @end_time = attributes[:end_time]
21
+ @failures_detail = attributes[:failures_detail] || []
22
+ @deprecations = attributes[:deprecations] || []
23
+ @raw_output = attributes[:raw_output] || ""
24
+ @stderr = attributes[:stderr] || ""
25
+ end
26
+
27
+ def total_tests
28
+ passed + failed + skipped + errors
29
+ end
30
+
31
+ def success?
32
+ failed == 0 && errors == 0
33
+ end
34
+
35
+ def has_failures?
36
+ failed > 0 || errors > 0
37
+ end
38
+
39
+ def has_skips?
40
+ skipped > 0
41
+ end
42
+
43
+ def has_deprecations?
44
+ deprecations.any?
45
+ end
46
+
47
+ def pass_rate
48
+ return 0.0 if total_tests == 0
49
+ (passed.to_f / total_tests * 100).round(2)
50
+ end
51
+
52
+ def summary_line
53
+ parts = []
54
+ parts << "✅ #{passed} passed" if passed > 0
55
+ parts << "❌ #{failed} failed" if failed > 0
56
+ parts << "💥 #{errors} errors" if errors > 0
57
+ parts << "⚠️ #{skipped} skipped" if skipped > 0
58
+
59
+ parts.empty? ? "No tests executed" : parts.join(", ")
60
+ end
61
+
62
+ def to_h
63
+ {
64
+ passed: passed,
65
+ failed: failed,
66
+ skipped: skipped,
67
+ errors: errors,
68
+ assertions: assertions,
69
+ total_tests: total_tests,
70
+ duration: duration,
71
+ pass_rate: pass_rate,
72
+ success: success?,
73
+ start_time: start_time&.iso8601,
74
+ end_time: end_time&.iso8601,
75
+ failures: failures_detail.map(&:to_h),
76
+ deprecations: deprecations
77
+ }
78
+ end
79
+
80
+ def to_json(*args)
81
+ to_h.to_json(*args)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "package_resolver"
4
+
5
+ module Ace
6
+ module TestRunner
7
+ module Molecules
8
+ # Parses CLI arguments to identify package, target, and test files.
9
+ # Handles the complexity of distinguishing between:
10
+ # - Direct file paths (./path/file.rb, ../path/file.rb, /abs/path/file.rb)
11
+ # - Package-prefixed file paths (ace-bundle/test/file.rb)
12
+ # - Package names (ace-bundle)
13
+ # - Test targets (atoms, molecules, unit, etc.)
14
+ class CliArgumentParser
15
+ KNOWN_TARGETS = %w[atoms molecules organisms models unit integration system all quick].freeze
16
+
17
+ attr_reader :package_dir, :target, :test_files
18
+
19
+ # @param argv [Array<String>] Command line arguments (will be modified)
20
+ # @param package_resolver [PackageResolver] Optional resolver for testing
21
+ def initialize(argv, package_resolver: nil)
22
+ @argv = argv
23
+ @package_resolver = package_resolver || PackageResolver.new
24
+ @package_dir = nil
25
+ @target = nil
26
+ @test_files = []
27
+ end
28
+
29
+ # Parse arguments and populate package_dir, target, test_files.
30
+ #
31
+ # Parsing precedence (order matters for correct classification):
32
+ # 1. Existing files (./path/file.rb, ../path/file.rb) - direct file paths
33
+ # 2. Package-prefixed file paths (ace-bundle/test/file.rb) - sets package + adds file
34
+ # 3. Package names (ace-bundle) - sets package directory
35
+ # 4. Known targets (atoms, molecules, etc.) - sets test target
36
+ # 5. Relative file paths within package (test/file.rb when package is set)
37
+ # 6. Unrecognized args - treated as custom targets for PatternResolver
38
+ #
39
+ # @return [Hash] Parsed options with :package_dir, :target, :files keys
40
+ def parse
41
+ parse_first_argument
42
+ parse_remaining_arguments
43
+
44
+ result = {}
45
+ result[:package_dir] = @package_dir if @package_dir
46
+ result[:target] = @target if @target
47
+ result[:files] = @test_files unless @test_files.empty?
48
+ result
49
+ end
50
+
51
+ # Check if an argument is a known test target
52
+ # @param arg [String] The argument to check
53
+ # @return [Boolean]
54
+ def known_target?(arg)
55
+ KNOWN_TARGETS.include?(arg)
56
+ end
57
+
58
+ private
59
+
60
+ # Extract file path and optional line number from a path string.
61
+ # Handles both "file.rb" and "file.rb:42" formats.
62
+ # @param path [String] File path, optionally with :line suffix
63
+ # @return [Array(String, String|nil)] [file_path, line_number]
64
+ def extract_file_and_line(path)
65
+ if path =~ /^(.+\.rb):(\d+)$/
66
+ [$1, $2]
67
+ else
68
+ [path, nil]
69
+ end
70
+ end
71
+
72
+ # Split a package-prefixed path into package name and file path.
73
+ # Example: "ace-bundle/test/file.rb" -> ["ace-bundle", "test/file.rb"]
74
+ # @param arg [String] Package-prefixed path
75
+ # @return [Array(String, String)] [package_name, file_path]
76
+ def split_package_prefix(arg)
77
+ arg.split("/", 2)
78
+ end
79
+
80
+ # Format a file path with optional line number.
81
+ # @param file_path [String] The file path
82
+ # @param line_number [String, nil] Optional line number
83
+ # @return [String] Formatted path (e.g., "file.rb" or "file.rb:42")
84
+ def format_file_with_line(file_path, line_number)
85
+ line_number ? "#{file_path}:#{line_number}" : file_path
86
+ end
87
+
88
+ def parse_first_argument
89
+ first_arg_index = @argv.find_index { |arg| !arg.start_with?("-") }
90
+ return unless first_arg_index
91
+
92
+ first_arg = @argv[first_arg_index]
93
+
94
+ # First, check if the argument is an existing file (handles ./path/file.rb, ../path/file.rb)
95
+ # This must be checked BEFORE package detection to avoid misclassification
96
+ if existing_file?(first_arg)
97
+ @test_files << first_arg
98
+ @argv.delete_at(first_arg_index)
99
+ return
100
+ end
101
+
102
+ # Check for package-prefixed file path (e.g., ace-bundle/test/foo_test.rb)
103
+ if package_prefixed_file_path?(first_arg)
104
+ handle_package_prefixed_path(first_arg, first_arg_index)
105
+ return
106
+ end
107
+
108
+ # Check if first arg is a package (not a known target, not a ruby file, not file:line)
109
+ return if @package_dir || !@test_files.empty?
110
+
111
+ handle_potential_package(first_arg, first_arg_index)
112
+ end
113
+
114
+ def parse_remaining_arguments
115
+ @argv.each do |arg|
116
+ next if arg.start_with?("-")
117
+
118
+ # Check package-prefixed paths first (before file_with_line?) to handle
119
+ # cases like "ace-bundle/test/file.rb:42" correctly when package is resolved
120
+ if @package_dir && handle_remaining_package_prefixed_path(arg)
121
+ next
122
+ elsif file_with_line?(arg)
123
+ handle_file_with_line(arg)
124
+ elsif existing_ruby_file?(arg)
125
+ @test_files << arg
126
+ elsif package_relative_ruby_file?(arg)
127
+ @test_files << arg
128
+ elsif known_target?(arg)
129
+ @target = arg
130
+ elsif @target.nil? && !File.exist?(arg)
131
+ # Unrecognized target - will be handled by PatternResolver
132
+ @target = arg
133
+ end
134
+ end
135
+ end
136
+
137
+ def existing_file?(arg)
138
+ file_arg = arg.sub(/:\d+$/, "") # Strip line number if present
139
+ File.file?(file_arg) && file_arg.end_with?(".rb")
140
+ end
141
+
142
+ def package_prefixed_file_path?(arg)
143
+ arg.include?("/") && (arg.end_with?(".rb") || arg =~ /\.rb:\d+$/)
144
+ end
145
+
146
+ # Handle package-prefixed file paths in remaining args when package already resolved.
147
+ # Example: "ace-bundle ace-bundle/test/foo.rb" - second arg should be recognized
148
+ # as a file within the already-resolved package.
149
+ # @return [Boolean] true if arg was handled as a package-prefixed file
150
+ def handle_remaining_package_prefixed_path(arg)
151
+ return false unless package_prefixed_file_path?(arg)
152
+
153
+ potential_package, file_path = split_package_prefix(arg)
154
+
155
+ # Check if the prefix matches our resolved package
156
+ resolved_package = @package_resolver.resolve(potential_package)
157
+ return false unless resolved_package == @package_dir
158
+
159
+ file_path_only, line_number = extract_file_and_line(file_path)
160
+
161
+ full_file_path = File.join(@package_dir, file_path_only)
162
+ return false unless File.exist?(full_file_path)
163
+
164
+ @test_files << format_file_with_line(file_path_only, line_number)
165
+ true
166
+ end
167
+
168
+ def handle_package_prefixed_path(arg, index)
169
+ # Split into package name and file path using first "/" as delimiter.
170
+ # This assumes package names don't contain "/" (true for all ace-* packages).
171
+ # Note: If a package were ever named with "/" (e.g., "ace/context"), this
172
+ # would misparse it. Current ace-* naming convention prevents this issue.
173
+ potential_package, file_path = split_package_prefix(arg)
174
+ file_path_only, line_number = extract_file_and_line(file_path)
175
+
176
+ # Try to resolve the package
177
+ resolved_package = @package_resolver.resolve(potential_package)
178
+ return unless resolved_package
179
+
180
+ full_file_path = File.join(resolved_package, file_path_only)
181
+ return unless File.exist?(full_file_path)
182
+
183
+ @package_dir = resolved_package
184
+ @test_files << format_file_with_line(file_path_only, line_number)
185
+ @argv.delete_at(index)
186
+ end
187
+
188
+ def handle_potential_package(arg, index)
189
+ return unless potential_package?(arg)
190
+
191
+ # Try to resolve as package (works for package names and explicit paths)
192
+ resolved_path = @package_resolver.resolve(arg)
193
+ if resolved_path
194
+ @package_dir = resolved_path
195
+ @argv.delete_at(index)
196
+ elsif explicit_path?(arg)
197
+ raise_package_not_found_error(arg)
198
+ elsif looks_like_package_name?(arg)
199
+ # Package-like name (e.g., ace-foo) that didn't resolve - give helpful error
200
+ raise_package_not_found_error(arg)
201
+ end
202
+ # Otherwise, fall through and let it be handled as target/file
203
+ end
204
+
205
+ # Check if argument looks like a package name (ace-* pattern)
206
+ def looks_like_package_name?(arg)
207
+ arg.start_with?("ace-") && !arg.include?("/")
208
+ end
209
+
210
+ # Check if an argument could be a package name (not a target, file, or file:line)
211
+ def potential_package?(arg)
212
+ !known_target?(arg) &&
213
+ !(arg.end_with?(".rb") && File.file?(arg)) &&
214
+ !file_with_line?(arg)
215
+ end
216
+
217
+ def explicit_path?(arg)
218
+ arg.start_with?("./", "../", "/")
219
+ end
220
+
221
+ def file_with_line?(arg)
222
+ arg =~ /^(.+):(\d+)$/
223
+ end
224
+
225
+ def handle_file_with_line(arg)
226
+ file_part, line_part = extract_file_and_line(arg)
227
+
228
+ # If we have a package_dir, make the path relative to it
229
+ check_path = @package_dir ? File.join(@package_dir, file_part) : file_part
230
+
231
+ unless File.exist?(check_path)
232
+ raise ArgumentError, "File not found: #{check_path}"
233
+ end
234
+
235
+ # Store the path that will work from the package directory
236
+ @test_files << (@package_dir ? format_file_with_line(file_part, line_part) : arg)
237
+ end
238
+
239
+ def existing_ruby_file?(arg)
240
+ File.exist?(arg) && arg.end_with?(".rb")
241
+ end
242
+
243
+ def package_relative_ruby_file?(arg)
244
+ @package_dir && File.exist?(File.join(@package_dir, arg)) && arg.end_with?(".rb")
245
+ end
246
+
247
+ def raise_package_not_found_error(arg)
248
+ message = "Package not found: #{arg}\n"
249
+ message += if Dir.exist?(arg)
250
+ "Directory exists but has no test/ subdirectory.\n"
251
+ else
252
+ "Directory does not exist.\n"
253
+ end
254
+
255
+ available = @package_resolver.available_packages
256
+ message += "Available packages: #{available.join(", ")}" if available.any?
257
+
258
+ raise ArgumentError, message
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end