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,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module TestRunner
5
+ module Molecules
6
+ # Analyzes test failures and provides insights
7
+ class FailureAnalyzer
8
+ COMMON_PATTERNS = {
9
+ assertion: {
10
+ pattern: /Expected: (.+)\n\s+Actual: (.+)/m,
11
+ suggestion: "Check the assertion values. Expected and actual don't match."
12
+ },
13
+ nil_error: {
14
+ pattern: /undefined method .+ for nil:NilClass/,
15
+ suggestion: "Object is nil. Add nil check or ensure object is initialized."
16
+ },
17
+ missing_constant: {
18
+ pattern: /uninitialized constant (\S+)/,
19
+ suggestion: "Class or module not found. Check requires/imports or class name spelling."
20
+ },
21
+ argument_error: {
22
+ pattern: /wrong number of arguments \(given (\d+), expected (\d+)\)/,
23
+ suggestion: "Method called with wrong number of arguments. Check method signature."
24
+ },
25
+ file_not_found: {
26
+ pattern: /No such file or directory/,
27
+ suggestion: "File doesn't exist. Check file path or create the file."
28
+ },
29
+ syntax_error: {
30
+ pattern: /syntax error/,
31
+ suggestion: "Ruby syntax error. Check for missing brackets, quotes, or keywords."
32
+ },
33
+ timeout: {
34
+ pattern: /execution expired|timeout/i,
35
+ suggestion: "Operation timed out. Consider increasing timeout or optimizing slow code."
36
+ }
37
+ }.freeze
38
+
39
+ def analyze_failure(failure_data)
40
+ return failure_data unless failure_data.is_a?(Hash)
41
+
42
+ message = failure_data[:message] || failure_data[:full_content] || ""
43
+
44
+ # Try to extract test class and method
45
+ if failure_data[:test_name] && failure_data[:test_name].include?("#")
46
+ parts = failure_data[:test_name].split("#")
47
+ failure_data[:test_class] = parts[0]
48
+ failure_data[:test_name] = parts[1]
49
+ end
50
+
51
+ # Analyze the error message for common patterns
52
+ suggestion = find_suggestion(message)
53
+ failure_data[:fix_suggestion] = suggestion if suggestion
54
+
55
+ # Extract file and line from location if available
56
+ if failure_data[:location] && failure_data[:location].is_a?(Hash)
57
+ failure_data[:file_path] = failure_data[:location][:file]
58
+ failure_data[:line_number] = failure_data[:location][:line]
59
+ end
60
+
61
+ # Extract code context if file and line are available
62
+ if failure_data[:file_path] && failure_data[:line_number] && File.exist?(failure_data[:file_path])
63
+ failure_data[:code_context] = extract_code_context(
64
+ failure_data[:file_path],
65
+ failure_data[:line_number]
66
+ )
67
+ end
68
+
69
+ # Clean and format backtrace
70
+ if failure_data[:backtrace]
71
+ failure_data[:formatted_backtrace] = format_backtrace(failure_data[:backtrace])
72
+ end
73
+
74
+ Models::TestFailure.new(failure_data)
75
+ end
76
+
77
+ def analyze_all(failures, stderr: nil)
78
+ return [] unless failures.is_a?(Array)
79
+
80
+ failures.map do |failure|
81
+ analyzed = analyze_failure(failure)
82
+ # Associate stderr with all failures if present
83
+ analyzed.stderr_warnings = stderr if stderr && !stderr.empty?
84
+ analyzed
85
+ end
86
+ end
87
+
88
+ def group_by_type(failures)
89
+ failures.group_by(&:type)
90
+ end
91
+
92
+ def group_by_file(failures)
93
+ failures.group_by(&:file_path).compact
94
+ end
95
+
96
+ def find_common_issues(failures)
97
+ issues = {}
98
+
99
+ # Count occurrences of each type of error
100
+ failures.each do |failure|
101
+ COMMON_PATTERNS.each do |issue_type, config|
102
+ if failure.message&.match?(config[:pattern])
103
+ issues[issue_type] ||= {count: 0, failures: [], suggestion: config[:suggestion]}
104
+ issues[issue_type][:count] += 1
105
+ issues[issue_type][:failures] << failure
106
+ end
107
+ end
108
+ end
109
+
110
+ issues.sort_by { |_, v| -v[:count] }.to_h
111
+ end
112
+
113
+ def generate_fix_script(failures)
114
+ fixes = []
115
+
116
+ failures.each do |failure|
117
+ if failure.message&.match?(/DEPRECATION WARNING/)
118
+ fixes << generate_deprecation_fix(failure)
119
+ elsif failure.message&.match?(/undefined method/)
120
+ fixes << generate_method_fix(failure)
121
+ end
122
+ end
123
+
124
+ fixes.compact
125
+ end
126
+
127
+ def extract_code_context(file_path, line_number, radius = 5)
128
+ return nil unless File.exist?(file_path)
129
+
130
+ lines = File.readlines(file_path)
131
+ total_lines = lines.size
132
+ center_line = line_number.to_i
133
+
134
+ # Calculate the range of lines to include
135
+ start_line = [center_line - radius, 1].max
136
+ end_line = [center_line + radius, total_lines].min
137
+
138
+ context = {
139
+ file: file_path,
140
+ center_line: center_line,
141
+ lines: {}
142
+ }
143
+
144
+ (start_line..end_line).each do |line_num|
145
+ line_content = lines[line_num - 1]
146
+ context[:lines][line_num] = {
147
+ content: line_content.chomp,
148
+ highlighted: line_num == center_line
149
+ }
150
+ end
151
+
152
+ context
153
+ end
154
+
155
+ def format_backtrace(backtrace)
156
+ return [] unless backtrace
157
+
158
+ # Convert to array if it's a string
159
+ trace_lines = backtrace.is_a?(String) ? backtrace.split("\n") : backtrace
160
+
161
+ formatted = []
162
+ project_root = Dir.pwd
163
+
164
+ trace_lines.each do |line|
165
+ # Clean up the line
166
+ clean_line = line.strip
167
+
168
+ # Skip minitest internal frames unless in verbose mode
169
+ next if clean_line.include?("/minitest/") && !@verbose
170
+ next if clean_line.include?("/bundler/") && !@verbose
171
+
172
+ # Parse the backtrace line
173
+ if clean_line =~ /^(.+):(\d+):in `(.+)'$/
174
+ file = $1
175
+ line_num = $2
176
+ method = $3
177
+
178
+ # Make paths relative to project root
179
+ relative_file = file.start_with?(project_root) ?
180
+ file.sub(project_root + "/", "") : file
181
+
182
+ formatted << {
183
+ file: relative_file,
184
+ line: line_num.to_i,
185
+ method: method,
186
+ in_project: file.start_with?(project_root)
187
+ }
188
+ else
189
+ # Keep unparseable lines as-is
190
+ formatted << {raw: clean_line}
191
+ end
192
+ end
193
+
194
+ formatted
195
+ end
196
+
197
+ private
198
+
199
+ def find_suggestion(message)
200
+ COMMON_PATTERNS.each do |_, config|
201
+ return config[:suggestion] if message.match?(config[:pattern])
202
+ end
203
+
204
+ # Generic suggestions based on keywords
205
+ case message
206
+ when /permission denied/i
207
+ "Check file/directory permissions"
208
+ when /connection refused/i
209
+ "Service not running or wrong connection settings"
210
+ when /invalid/i
211
+ "Check input validation and data format"
212
+ when /not found/i
213
+ "Resource doesn't exist. Check paths and names"
214
+ end
215
+ end
216
+
217
+ def generate_deprecation_fix(failure)
218
+ {
219
+ file: failure.file_path,
220
+ line: failure.line_number,
221
+ type: :deprecation,
222
+ fix: extract_deprecation_fix(failure.message)
223
+ }
224
+ end
225
+
226
+ def generate_method_fix(failure)
227
+ if failure.message =~ /undefined method `(.+)' for/
228
+ method = $1
229
+ {
230
+ file: failure.file_path,
231
+ line: failure.line_number,
232
+ type: :missing_method,
233
+ fix: "Define method '#{method}' or check spelling"
234
+ }
235
+ end
236
+ end
237
+
238
+ def extract_deprecation_fix(message)
239
+ # Look for "use X instead" patterns
240
+ if message =~ /use (.+) instead/i
241
+ "Replace with: #{$1}"
242
+ else
243
+ "Update deprecated code"
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require "ostruct"
5
+ require "minitest"
6
+
7
+ module Ace
8
+ module TestRunner
9
+ module Molecules
10
+ # Runs tests directly in the current Ruby process without spawning subprocesses
11
+ # This provides significantly faster execution for unit tests that don't need isolation
12
+ class InProcessRunner
13
+ def initialize(timeout: nil)
14
+ @timeout = timeout
15
+ end
16
+
17
+ def execute_tests(files, options = {})
18
+ return empty_result if files.empty?
19
+
20
+ start_time = Time.now
21
+
22
+ # Capture stdout/stderr
23
+ original_stdout = $stdout
24
+ original_stderr = $stderr
25
+ stdout_io = StringIO.new
26
+ stderr_io = StringIO.new
27
+
28
+ # Store original verbose setting
29
+ original_verbose = $VERBOSE
30
+ original_mt_no_autorun = ENV["MT_NO_AUTORUN"]
31
+
32
+ begin
33
+ $stdout = stdout_io
34
+ $stderr = stderr_io
35
+ $VERBOSE = nil if options[:suppress_warnings]
36
+
37
+ # Prevent Minitest from auto-running
38
+ ENV["MT_NO_AUTORUN"] = "1"
39
+
40
+ # Add test directory to load path if not already there
41
+ test_dir = File.expand_path("test")
42
+ lib_dir = File.expand_path("lib")
43
+ $LOAD_PATH.unshift(test_dir) unless $LOAD_PATH.include?(test_dir)
44
+ $LOAD_PATH.unshift(lib_dir) unless $LOAD_PATH.include?(lib_dir)
45
+
46
+ # Only require minitest/autorun if not already loaded
47
+ # This prevents double runs when ace/test_support is loaded
48
+ unless defined?(Minitest.autorun)
49
+ require "minitest/autorun"
50
+ end
51
+
52
+ # First pass: check for line numbers and resolve test names
53
+ # This must be done before loading files
54
+ test_names_to_run = []
55
+ files_to_load = []
56
+
57
+ files.each do |file|
58
+ # Check if file has line number (file:line format)
59
+ if file =~ /^(.+):(\d+)$/
60
+ actual_file = $1
61
+ line_number = $2.to_i
62
+
63
+ # Resolve line number to test name
64
+ require_relative "../atoms/line_number_resolver"
65
+ test_name = Ace::TestRunner::Atoms::LineNumberResolver.resolve_test_at_line(actual_file, line_number)
66
+ test_names_to_run << test_name if test_name
67
+
68
+ files_to_load << actual_file
69
+ else
70
+ files_to_load << file
71
+ end
72
+ end
73
+
74
+ # Store test names in options to pass to run_minitest_with_args
75
+ options = options.merge(test_names_filter: test_names_to_run) if test_names_to_run.any?
76
+
77
+ # Clear previously loaded test classes to avoid accumulation between groups
78
+ # This is crucial for in-process execution where tests from previous groups
79
+ # would otherwise be re-run in subsequent groups
80
+ Minitest::Runnable.runnables.clear
81
+
82
+ # Setup Minitest::Reporters BEFORE loading test files
83
+ # For in-process mode, we need to handle reporter state carefully
84
+ # because Minitest::Reporters.use! only works properly on first call
85
+ require "minitest/reporters"
86
+
87
+ # Create a fresh reporter for this group
88
+ reporter = Minitest::Reporters::DefaultReporter.new(io: $stdout)
89
+
90
+ # If this isn't the first group, we need to replace the existing reporter
91
+ if Minitest.reporter && Minitest.reporter.reporters
92
+ $stdout.flush
93
+ Minitest.reporter.reporters.clear
94
+ Minitest.reporter.reporters << reporter
95
+ # Reset reporter state for the new group
96
+ reporter.start_time = nil
97
+ # NOTE: Known limitation - progress dots don't show for subsequent groups
98
+ # in in-process mode. This appears to be a Minitest::Reporters limitation
99
+ # where some internal state prevents proper output after the first run.
100
+ # Test counts and results are still accurate.
101
+ else
102
+ # First group - use the standard setup
103
+ Minitest::Reporters.use! reporter
104
+ end
105
+
106
+ # Load the test files
107
+ files_to_load.uniq.each do |file|
108
+ file_path = File.expand_path(file)
109
+ begin
110
+ load file_path
111
+ rescue LoadError => e
112
+ stderr_io.puts "Failed to load #{file}: #{e.message}"
113
+ # Re-raise to fail the entire test run
114
+ raise
115
+ end
116
+ end
117
+
118
+ # Run Minitest with captured output
119
+ # Suppress Minitest's own output by using null reporter
120
+ exit_code = if @timeout
121
+ Timeout.timeout(@timeout) do
122
+ run_minitest_silent(options)
123
+ end
124
+ else
125
+ run_minitest_silent(options)
126
+ end
127
+
128
+ success = exit_code == true || exit_code == 0
129
+ rescue Timeout::Error
130
+ stderr_io.puts "Test execution timed out after #{@timeout} seconds"
131
+ success = false
132
+ exit_code = 124
133
+ rescue LoadError
134
+ # LoadError already logged in the loop above
135
+ stderr_io.puts "Test run aborted due to load error"
136
+ success = false
137
+ exit_code = 1
138
+ rescue => e
139
+ stderr_io.puts "Error running tests: #{e.message}"
140
+ stderr_io.puts e.backtrace.join("\n") if options[:verbose]
141
+ success = false
142
+ exit_code = 1
143
+ ensure
144
+ $stdout = original_stdout
145
+ $stderr = original_stderr
146
+ $VERBOSE = original_verbose
147
+
148
+ # Restore original MT_NO_AUTORUN value
149
+ if original_mt_no_autorun
150
+ ENV["MT_NO_AUTORUN"] = original_mt_no_autorun
151
+ else
152
+ ENV.delete("MT_NO_AUTORUN")
153
+ end
154
+ end
155
+
156
+ end_time = Time.now
157
+
158
+ {
159
+ stdout: stdout_io.string,
160
+ stderr: stderr_io.string,
161
+ status: OpenStruct.new(success?: success, exitstatus: if exit_code.is_a?(Integer)
162
+ exit_code
163
+ else
164
+ (success ? 0 : 1)
165
+ end),
166
+ command: "in-process:#{files.join(",")}",
167
+ start_time: start_time,
168
+ end_time: end_time,
169
+ duration: end_time - start_time,
170
+ success: success
171
+ }
172
+ end
173
+
174
+ def execute_single_file(file, options = {})
175
+ execute_tests([file], options)
176
+ end
177
+
178
+ def execute_with_progress(files, options = {}, &block)
179
+ # For in-process execution, we run all tests together for best performance
180
+ result = execute_tests(files, options)
181
+
182
+ # Send stdout event for per-test progress parsing
183
+ if block_given? && result[:stdout]
184
+ yield({type: :stdout, content: result[:stdout]})
185
+ end
186
+
187
+ # Simulate progress callbacks for compatibility
188
+ if block_given?
189
+ files.each { |file| yield({type: :start, file: file}) }
190
+ files.each { |file| yield({type: :complete, file: file, success: result[:success], duration: result[:duration] / files.size}) }
191
+ end
192
+
193
+ result
194
+ end
195
+
196
+ private
197
+
198
+ def empty_result
199
+ {
200
+ stdout: "",
201
+ stderr: "No test files found",
202
+ status: OpenStruct.new(success?: true, exitstatus: 0),
203
+ command: "",
204
+ start_time: Time.now,
205
+ end_time: Time.now,
206
+ duration: 0.0,
207
+ success: true
208
+ }
209
+ end
210
+
211
+ def run_minitest_with_args(options)
212
+ # Build Minitest arguments
213
+ args = []
214
+ args << "--seed" << options[:seed].to_s if options[:seed]
215
+ args << "--verbose" if options[:verbose]
216
+
217
+ # Add test name filter if line numbers were provided
218
+ if options[:test_names_filter] && options[:test_names_filter].any?
219
+ pattern = options[:test_names_filter].map { |name| Regexp.escape(name) }.join("|")
220
+ args << "--name" << "/#{pattern}/"
221
+ end
222
+
223
+ # Run Minitest
224
+ # Returns true on success, false on failure
225
+ Minitest.run(args)
226
+ end
227
+
228
+ def run_minitest_silent(options)
229
+ # Minitest uses reporters that can bypass $stdout redirection
230
+ # We need to suppress Minitest's own output completely
231
+
232
+ # Build Minitest arguments
233
+ args = []
234
+ args << "--seed" << options[:seed].to_s if options[:seed]
235
+
236
+ # Add test name filter if line numbers were provided
237
+ if options[:test_names_filter] && options[:test_names_filter].any?
238
+ pattern = options[:test_names_filter].map { |name| Regexp.escape(name) }.join("|")
239
+ args << "--name" << "/#{pattern}/"
240
+ end
241
+
242
+ # Reporter already set up before loading test files
243
+ # Run Minitest
244
+ Minitest.run(args)
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "ace/support/fs"
5
+
6
+ module Ace
7
+ module TestRunner
8
+ module Molecules
9
+ # Resolves package names or paths to absolute package directories
10
+ # Supports: package name (ace-bundle), relative path (./ace-bundle), absolute path
11
+ #
12
+ # Note: This class depends on ace-support-fs which provides ProjectRootFinder.
13
+ class PackageResolver
14
+ # Initialize resolver
15
+ # @param project_root [String, nil] Override project root (for testing)
16
+ def initialize(project_root: nil)
17
+ @project_root = project_root || Ace::Support::Fs::Molecules::ProjectRootFinder.find
18
+ end
19
+
20
+ # Resolve a package name or path to an absolute directory path
21
+ # @param name_or_path [String] Package name, relative path, or absolute path
22
+ # @return [String, nil] Absolute path to package directory, or nil if not found
23
+ def resolve(name_or_path)
24
+ return nil if name_or_path.nil? || name_or_path.empty?
25
+
26
+ path = if absolute_path?(name_or_path)
27
+ resolve_absolute(name_or_path)
28
+ elsif relative_path?(name_or_path)
29
+ resolve_relative(name_or_path)
30
+ else
31
+ resolve_by_name(name_or_path)
32
+ end
33
+
34
+ # Validate the resolved path has a test directory
35
+ return nil unless path && valid_package?(path)
36
+
37
+ path
38
+ end
39
+
40
+ # List all available packages in the mono-repo.
41
+ # Results are memoized since filesystem glob operations are relatively expensive.
42
+ # @return [Array<String>] List of package names
43
+ def available_packages
44
+ return [] unless @project_root
45
+
46
+ @available_packages ||= Dir.glob(File.join(@project_root, "ace-*"))
47
+ .select { |path| File.directory?(path) && has_test_directory?(path) }
48
+ .map { |path| File.basename(path) }
49
+ .sort
50
+ end
51
+
52
+ # Get the project root
53
+ # @return [String, nil] Project root path
54
+ attr_reader :project_root
55
+
56
+ private
57
+
58
+ def absolute_path?(path)
59
+ path.start_with?("/")
60
+ end
61
+
62
+ def relative_path?(path)
63
+ path == "." || path == ".." || path.start_with?("./") || path.start_with?("../")
64
+ end
65
+
66
+ def resolve_absolute(path)
67
+ return nil unless Dir.exist?(path)
68
+
69
+ File.realpath(path)
70
+ end
71
+
72
+ def resolve_relative(path)
73
+ expanded = File.expand_path(path, Dir.pwd)
74
+ return nil unless Dir.exist?(expanded)
75
+
76
+ File.realpath(expanded)
77
+ end
78
+
79
+ def resolve_by_name(name)
80
+ return nil unless @project_root
81
+
82
+ # Try exact match first (ace-bundle)
83
+ exact_path = File.join(@project_root, name)
84
+ return File.realpath(exact_path) if Dir.exist?(exact_path)
85
+
86
+ # Try with ace- prefix (bundle -> ace-bundle)
87
+ prefixed_path = File.join(@project_root, "ace-#{name}")
88
+ return File.realpath(prefixed_path) if Dir.exist?(prefixed_path)
89
+
90
+ nil
91
+ end
92
+
93
+ def valid_package?(path)
94
+ return false unless Dir.exist?(path)
95
+
96
+ has_test_directory?(path)
97
+ end
98
+
99
+ def has_test_directory?(path)
100
+ test_dir = File.join(path, "test")
101
+ Dir.exist?(test_dir)
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end