tryouts 3.3.2 → 3.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b8d7c33ad6377a7fb1c64c83e24e643c434643aa09e5451ad42bbe80c65b9c9d
4
- data.tar.gz: 89cfe371c0fd575614a56702c904d0c5140db2a3a2af41d22777e459de1f4d8a
3
+ metadata.gz: 7b692cc3a63ac86e060b52e77c246305be0f209dbf06b31b1b08deefbc06434a
4
+ data.tar.gz: 156e86aa4bde377aa2158f3a059983a6fe5f58796f71545c9895a9b57668b126
5
5
  SHA512:
6
- metadata.gz: 174645930ab03bc8332e415e9dde67e6c261a66ec2aa4f5572a31841a339cb244d15da75f6d314e3e2ec2d987fb5439e03c34d35d244b37d8b81a7e5b4dbc55d
7
- data.tar.gz: 524ded2d4d670ed5fce810ef363faaba2459e56979fc13e61cdd7fbad6a3cffe2cadef7e7c8f1be0901ed1d26b44185f05f8be524c0ebe60a76e3bb841be5b37
6
+ metadata.gz: d289a9b5ccd6694bd63fe4ca2a0c8aaf610d702e74380bf4975ae78ffaf377d0f82a9ec481dbc51105de9f3b07681c32eaa8a1c3566a1eebd0a861796eaff1e3
7
+ data.tar.gz: a0db3ebf76b5a291e6ea9d81f1f6e268a95320d28040a10f2225171b221f04bb2ed525bae3f79785d50f4269eb7c9be1dec242012bbca0a6d4588ac9e7fdd362
data/README.md CHANGED
@@ -1,17 +1,21 @@
1
- # Tryouts v3.1
1
+ # Tryouts - A Ruby Testing Framework
2
2
 
3
3
  **Ruby tests that read like documentation.**
4
4
 
5
5
  A modern test framework for Ruby that uses comments to define expectations. Tryouts are meant to double as documentation, so the Ruby code should be plain and reminiscent of real code.
6
6
 
7
+ > [!NOTE]
8
+ > **Agent-Optimized Output**: Tryouts includes specialized output modes for LLM consumption with `--agent` flag, providing structured, token-efficient test results that are 60-80% smaller than traditional output while preserving debugging context.
9
+
7
10
  > [!WARNING]
8
- > Version 3.0+ uses Ruby's Prism parser and pattern matching, requiring Ruby 3.4+
11
+ > Version 3.0+ uses Ruby's Prism parser and pattern matching, requiring Ruby 3.2+
9
12
 
10
13
  ## Key Features
11
14
 
12
15
  - **Documentation-style tests** using comment-based expectations (`#=>`)
13
16
  - **Great expectation syntax** for more expressive assertions (`#==>` for true, `#=/=>` for false, `#=:>` for class/module)
14
17
  - **Framework integration** write with tryouts syntax, run with RSpec or Minitest
18
+ - **Agent-optimized output** structured, token-efficient output for LLM consumption
15
19
  - **Enhanced error reporting** with line numbers and context
16
20
 
17
21
  ## Installation
@@ -117,8 +121,30 @@ try -v # verbose (includes source code and return values)
117
121
  try -q # quiet mode
118
122
  try -f # show failures only
119
123
  try -D # debug mode
124
+
125
+ # Agent-optimized output for LLMs
126
+ try --agent # structured, token-efficient output
127
+ try --agent --agent-focus summary # show only counts and problem files
128
+ try --agent --agent-focus first-failure # show first failure per file
129
+ try --agent --agent-focus critical # show only errors/exceptions
130
+ try --agent --agent-limit 1000 # limit output to 1000 tokens
120
131
  ```
121
132
 
133
+ #### Why Not Pipe Test Output Directly to AI?
134
+
135
+ Raw test output creates several problems when working with AI assistants:
136
+
137
+ - **Token bloat**: Verbose formatting wastes 60-80% of your context window on styling
138
+ - **Signal vs noise**: Important failures get buried in passing test details and framework boilerplate
139
+ - **Inconsistent parsing**: AI struggles with varying output formats across different test runs
140
+ - **Context overflow**: Large test suites exceed AI token limits, truncating critical information
141
+
142
+ #### TOPA: A Better Approach
143
+
144
+ Tryouts' `--agent` mode inspired the development of **TOPA (Test Output Protocol for AI)** - a standardized format optimized for AI analysis. The [tpane](https://github.com/delano/tpane) tool implements this protocol, transforming any test framework's output into structured, token-efficient formats.
145
+
146
+ Instead of overwhelming AI with raw output, TOPA provides clean semantic data focusing on what actually needs attention - failures, errors, and actionable context.
147
+
122
148
  ### Exit Codes
123
149
 
124
150
  - `0`: All tests pass
@@ -127,14 +153,14 @@ try -D # debug mode
127
153
 
128
154
  ## Requirements
129
155
 
130
- - **Ruby >= 3.2+** (for Prism parser and pattern matching)
156
+ - **Ruby >= 3.2** (for Prism parser and pattern matching)
131
157
  - **RSpec** or **Minitest** (optional, for framework integration)
132
158
 
133
159
  ## Modern Architecture (v3+)
134
160
 
135
161
  ### Core Components
136
162
 
137
- - **Prism Parser**: Inhouse Ruby parsing with pattern matching for line classification
163
+ - **Prism Parser**: Native Ruby parsing with pattern matching for line classification
138
164
  - **Data Structures**: Immutable `Data.define` classes for test representation
139
165
  - **Framework Translators**: Convert tryouts to RSpec/Minitest format
140
166
  - **CLI**: Modern command-line interface with framework selection
@@ -151,8 +177,8 @@ For real-world usage examples, see:
151
177
 
152
178
  This version of Tryouts was developed with assistance from AI tools. The following tools provided significant help with architecture design, code generation, and documentation:
153
179
 
154
- - **Claude Sonnet 4** - Architecture design, code generation, and documentation
155
- - **Claude Desktop & Claude Code** - Interactive development sessions and debugging
180
+ - **Claude Sonnet 4, Opus 4.1** - Architecture design, code generation, and documentation
181
+ - **Claude Desktop & Claude Code (Max plan)** - Interactive development sessions and debugging
156
182
  - **GitHub Copilot** - Code completion and refactoring assistance
157
183
  - **Qodo Merge Pro** - Code review and quality improvements
158
184
 
data/exe/try CHANGED
@@ -42,15 +42,18 @@ Tryouts.update_load_path(lib_glob) if Tryouts.respond_to?(:update_load_path)
42
42
  begin
43
43
  files, options = Tryouts::CLI.parse_args(ARGV)
44
44
 
45
- # Expand files if directories are given
45
+ # Expand files if directories are given, preserving line specs
46
46
  expanded_files = []
47
47
  files.each do |file_or_dir|
48
- if File.directory?(file_or_dir)
48
+ # Parse line spec from the argument
49
+ path_part, line_spec = Tryouts::CLI::LineSpecParser.parse(file_or_dir)
50
+
51
+ if File.directory?(path_part)
49
52
  # If it's a directory, find all *_try.rb and *.try.rb files within it
50
- dir_files = Dir.glob(['**/*_try.rb', '**/*.try.rb'], base: file_or_dir)
51
- expanded_files.concat(dir_files.map { |f| File.join(file_or_dir, f) })
53
+ dir_files = Dir.glob(['**/*_try.rb', '**/*.try.rb'], base: path_part)
54
+ expanded_files.concat(dir_files.map { |f| File.join(path_part, f) })
52
55
  else
53
- # If it's a file, add it as-is
56
+ # If it's a file, add it as-is (with line spec if present)
54
57
  expanded_files << file_or_dir
55
58
  end
56
59
  end
@@ -0,0 +1,576 @@
1
+ # lib/tryouts/cli/formatters/agent.rb
2
+
3
+ require_relative 'token_budget'
4
+
5
+ class Tryouts
6
+ class CLI
7
+ # Agent-optimized formatter designed for LLM context management
8
+ # Features:
9
+ # - Token budget awareness
10
+ # - Structured YAML-like output
11
+ # - No redundant file paths
12
+ # - Smart truncation
13
+ # - Hierarchical organization
14
+ class AgentFormatter
15
+ include FormatterInterface
16
+
17
+ def initialize(options = {})
18
+ super
19
+ @budget = TokenBudget.new(options[:agent_limit] || TokenBudget::DEFAULT_LIMIT)
20
+ @focus_mode = options[:agent_focus] || :failures
21
+ @collected_files = []
22
+ @current_file_data = nil
23
+ @total_stats = { files: 0, tests: 0, failures: 0, errors: 0, elapsed: 0 }
24
+ @output_rendered = false
25
+ @options = options # Store all options for execution context display
26
+ @all_warnings = [] # Store warnings globally for execution details
27
+ @syntax_errors = [] # Store syntax errors for execution details
28
+
29
+ # No colors in agent mode for cleaner parsing
30
+ @use_colors = false
31
+ end
32
+
33
+ # Phase-level output - collect data, don't output immediately
34
+ def phase_header(message, file_count: nil)
35
+ # Store file count for later use, but only store actual file count
36
+ if file_count && message.include?("FILES")
37
+ @total_stats[:files] = file_count
38
+ end
39
+ end
40
+
41
+ # File-level operations - start collecting file data
42
+ def file_start(file_path, context_info: {})
43
+ @current_file_data = {
44
+ path: relative_path(file_path),
45
+ tests: 0,
46
+ failures: [],
47
+ errors: [],
48
+ passed: 0,
49
+ context_info: context_info # Store context info for later display
50
+ }
51
+ end
52
+
53
+ def file_end(file_path, context_info: {})
54
+ # Finalize current file data
55
+ if @current_file_data
56
+ @collected_files << @current_file_data
57
+ @current_file_data = nil
58
+ end
59
+ # REMOVED: No longer attempts to render here to avoid premature output
60
+ end
61
+
62
+ def file_parsed(_file_path, test_count:, setup_present: false, teardown_present: false)
63
+ if @current_file_data
64
+ @current_file_data[:tests] = test_count
65
+ end
66
+ @total_stats[:tests] += test_count
67
+ end
68
+
69
+ def parser_warnings(file_path, warnings:)
70
+ return if warnings.empty? || !@options.fetch(:warnings, true)
71
+
72
+ # Store warnings globally for execution details and per-file
73
+ warnings.each do |warning|
74
+ warning_data = {
75
+ type: warning.type.to_s,
76
+ message: warning.message,
77
+ line: warning.line_number,
78
+ suggestion: warning.suggestion,
79
+ file: relative_path(file_path)
80
+ }
81
+ @all_warnings << warning_data
82
+ end
83
+
84
+ # Also store in current file data for potential future use
85
+ if @current_file_data
86
+ @current_file_data[:warnings] = @all_warnings.select { |w| w[:file] == relative_path(file_path) }
87
+ end
88
+ end
89
+
90
+ def file_result(file_path, total_tests:, failed_count:, error_count:, elapsed_time: nil)
91
+ # Always update global totals
92
+ @total_stats[:failures] += failed_count
93
+ @total_stats[:errors] += error_count
94
+ @total_stats[:elapsed] += elapsed_time if elapsed_time
95
+
96
+ # Update per-file data - file_result is called AFTER file_end, so data is in @collected_files
97
+ relative_file_path = relative_path(file_path)
98
+ file_data = @collected_files.find { |f| f[:path] == relative_file_path }
99
+
100
+ if file_data
101
+ file_data[:passed] = total_tests - failed_count - error_count
102
+ # Also ensure tests count is correct if it wasn't set properly earlier
103
+ file_data[:tests] ||= total_tests
104
+ end
105
+ end
106
+
107
+
108
+ # Test-level operations - collect failure data
109
+ def test_result(result_packet)
110
+ return unless @current_file_data
111
+
112
+ # For summary mode, we still need to collect failures for counting, just don't build detailed data
113
+ if result_packet.failed? || result_packet.error?
114
+ if @focus_mode == :summary
115
+ # Just track counts for summary
116
+ if result_packet.error?
117
+ @current_file_data[:errors] << { basic: true }
118
+ else
119
+ @current_file_data[:failures] << { basic: true }
120
+ end
121
+ else
122
+ # Build detailed failure data for other modes
123
+ failure_data = build_failure_data(result_packet)
124
+
125
+ if result_packet.error?
126
+ @current_file_data[:errors] << failure_data
127
+ else
128
+ @current_file_data[:failures] << failure_data
129
+ end
130
+
131
+ # Mark truncation for first-failure mode (handle limiting in render phase)
132
+ if (@focus_mode == :first_failure || @focus_mode == :'first-failure') &&
133
+ (@current_file_data[:failures].size + @current_file_data[:errors].size) > 1
134
+ @current_file_data[:truncated] = true
135
+ end
136
+ end
137
+ end
138
+ end
139
+
140
+ # Summary operations - reliable trigger for rendering
141
+ def batch_summary(failure_collector)
142
+ # This becomes the single, reliable trigger for rendering
143
+ grand_total(
144
+ total_tests: @total_stats[:tests],
145
+ failed_count: @collected_files.sum { |f| f[:failures].size },
146
+ error_count: @collected_files.sum { |f| f[:errors].size },
147
+ successful_files: @collected_files.size - @collected_files.count { |f| f[:failures].any? || f[:errors].any? },
148
+ total_files: @collected_files.size,
149
+ elapsed_time: @total_stats[:elapsed]
150
+ ) unless @output_rendered
151
+ end
152
+
153
+ def grand_total(total_tests:, failed_count:, error_count:, successful_files:, total_files:, elapsed_time:)
154
+ return if @output_rendered # Prevent double rendering
155
+
156
+ @total_stats.merge!(
157
+ tests: total_tests,
158
+ failures: failed_count,
159
+ errors: error_count,
160
+ successful_files: successful_files,
161
+ total_files: total_files,
162
+ elapsed: elapsed_time
163
+ )
164
+
165
+ # Now render all collected data
166
+ render_agent_output
167
+ @output_rendered = true
168
+ end
169
+
170
+ def error_message(message, backtrace: nil)
171
+ # Store syntax errors for display in execution details
172
+ @syntax_errors << {
173
+ message: message,
174
+ backtrace: backtrace
175
+ }
176
+ end
177
+
178
+ # Override live status - not needed for agent mode
179
+ def live_status_capabilities
180
+ {
181
+ supports_coordination: false,
182
+ output_frequency: :none,
183
+ requires_tty: false
184
+ }
185
+ end
186
+
187
+ private
188
+
189
+ def build_failure_data(result_packet)
190
+ test_case = result_packet.test_case
191
+
192
+ failure_data = {
193
+ line: (test_case.first_expectation_line || test_case.line_range&.first || 0) + 1,
194
+ test: test_case.description.to_s.empty? ? 'unnamed test' : test_case.description.to_s
195
+ }
196
+
197
+ case result_packet.status
198
+ when :error
199
+ error = result_packet.error
200
+ failure_data[:error] = error ? "#{error.class.name}: #{error.message}" : 'unknown error'
201
+ when :failed
202
+ if result_packet.expected_results.any? && result_packet.actual_results.any?
203
+ expected = @budget.smart_truncate(result_packet.first_expected, max_tokens: 25)
204
+ actual = @budget.smart_truncate(result_packet.first_actual, max_tokens: 25)
205
+ failure_data[:expected] = expected
206
+ failure_data[:got] = actual
207
+
208
+ # Add diff for strings if budget allows
209
+ if result_packet.first_expected.is_a?(String) &&
210
+ result_packet.first_actual.is_a?(String) &&
211
+ @budget.has_budget?
212
+ failure_data[:diff] = generate_simple_diff(result_packet.first_expected, result_packet.first_actual)
213
+ end
214
+ else
215
+ failure_data[:reason] = 'test failed'
216
+ end
217
+ end
218
+
219
+ failure_data
220
+ end
221
+
222
+ def generate_simple_diff(expected, actual)
223
+ return nil unless @budget.remaining > 100 # Only if we have decent budget left
224
+
225
+ # Simple line-by-line diff
226
+ exp_lines = expected.split("\n")
227
+ act_lines = actual.split("\n")
228
+
229
+ diff_lines = []
230
+ diff_lines << "- #{act_lines.first}" if act_lines.any?
231
+ diff_lines << "+ #{exp_lines.first}" if exp_lines.any?
232
+
233
+ diff_result = diff_lines.join("\n")
234
+ return @budget.fit_text(diff_result) if @budget.would_exceed?(diff_result)
235
+ diff_result
236
+ end
237
+
238
+ def render_agent_output
239
+ case @focus_mode
240
+ when :summary
241
+ render_summary_only
242
+ when :critical
243
+ render_critical_only
244
+ else
245
+ render_full_structured
246
+ end
247
+ end
248
+
249
+ def render_summary_only
250
+ output = []
251
+
252
+ # Add execution context header for agent clarity
253
+ output << render_execution_context
254
+ output << ""
255
+
256
+ # Count failures manually from collected file data (same as other render methods)
257
+ failed_count = @collected_files.sum { |f| f[:failures].size }
258
+ error_count = @collected_files.sum { |f| f[:errors].size }
259
+ issues_count = failed_count + error_count
260
+ passed_count = [@total_stats[:tests] - issues_count, 0].max
261
+
262
+ status_parts = []
263
+ if issues_count > 0
264
+ details = []
265
+ details << "#{failed_count} failed" if failed_count > 0
266
+ details << "#{error_count} errors" if error_count > 0
267
+ status_parts << "FAIL: #{issues_count}/#{@total_stats[:tests]} tests (#{details.join(', ')}, #{passed_count} passed)"
268
+ else
269
+ # Agent doesn't need output in the positive case (i.e. for passing
270
+ # tests). It just fills out the context window.
271
+ end
272
+
273
+ status_parts << "(#{format_time(@total_stats[:elapsed])})" if @total_stats[:elapsed]
274
+
275
+ output << status_parts.join(" ")
276
+
277
+ # Always show file information for agent context
278
+ output << ""
279
+
280
+ files_with_issues = @collected_files.select { |f| f[:failures].any? || f[:errors].any? }
281
+ if files_with_issues.any?
282
+ output << "Files:"
283
+ files_with_issues.each do |file_data|
284
+ issue_count = file_data[:failures].size + file_data[:errors].size
285
+ output << " #{file_data[:path]}: #{issue_count} issue#{'s' if issue_count != 1}"
286
+ end
287
+ elsif @collected_files.any?
288
+ # Show files that were processed successfully
289
+ output << "Files:"
290
+ @collected_files.each do |file_data|
291
+ # Use the passed count from file_result if available, otherwise calculate
292
+ passed_tests = file_data[:passed] ||
293
+ ((file_data[:tests] || 0) - file_data[:failures].size - file_data[:errors].size)
294
+ output << " #{file_data[:path]}: #{passed_tests} test#{'s' if passed_tests != 1} passed"
295
+ end
296
+ end
297
+
298
+ puts output.join("\n") if output.any?
299
+ end
300
+
301
+ def render_critical_only
302
+ # Only show errors (exceptions), skip assertion failures
303
+ critical_files = @collected_files.select { |f| f[:errors].any? }
304
+
305
+ output = []
306
+
307
+ # Add execution context header for agent clarity
308
+ output << render_execution_context
309
+ output << ""
310
+
311
+ if critical_files.empty?
312
+ output << "No critical errors found"
313
+ puts output.join("\n")
314
+ return
315
+ end
316
+
317
+ output << "CRITICAL: #{critical_files.size} file#{'s' if critical_files.size != 1} with errors"
318
+ output << ""
319
+
320
+ critical_files.each do |file_data|
321
+ unless @budget.has_budget?
322
+ output << "... (truncated due to token limit)"
323
+ break
324
+ end
325
+
326
+ output << "#{file_data[:path]}:"
327
+
328
+ file_data[:errors].each do |error|
329
+ error_line = " L#{error[:line]}: #{error[:error]}"
330
+ if @budget.would_exceed?(error_line)
331
+ output << @budget.fit_text(error_line)
332
+ else
333
+ output << error_line
334
+ @budget.consume(error_line)
335
+ end
336
+ end
337
+
338
+ output << ""
339
+ end
340
+
341
+ puts output.join("\n")
342
+ end
343
+
344
+ def render_full_structured
345
+ output = []
346
+
347
+ # Add execution context header for agent clarity
348
+ output << render_execution_context
349
+ output << ""
350
+
351
+ # Count actual failures from collected data
352
+ failed_count = @collected_files.sum { |f| f[:failures].size }
353
+ error_count = @collected_files.sum { |f| f[:errors].size }
354
+ issues_count = failed_count + error_count
355
+ passed_count = [@total_stats[:tests] - issues_count, 0].max
356
+
357
+ # Show files with issues only
358
+ files_with_issues = @collected_files.select { |f| f[:failures].any? || f[:errors].any? }
359
+
360
+ if files_with_issues.any?
361
+ files_with_issues.each do |file_data|
362
+ break unless @budget.has_budget?
363
+
364
+ file_section = render_file_section(file_data)
365
+ if @budget.would_exceed?(file_section)
366
+ # Try to fit what we can
367
+ truncated = @budget.fit_text(file_section, preserve_suffix: "\n ... (truncated)")
368
+ output << truncated if truncated.length > 20 # Only if meaningful content remains
369
+ break
370
+ else
371
+ output << file_section
372
+ @budget.consume(file_section)
373
+ end
374
+ end
375
+ output << ""
376
+ end
377
+
378
+ # Final summary line
379
+ summary = "Summary: \n"
380
+ summary += "#{passed_count} testcases passed, #{failed_count} failed"
381
+ summary += ", #{error_count} errors" if error_count > 0
382
+ summary += " in #{@total_stats[:files]} files"
383
+
384
+ output << summary
385
+
386
+ puts output.join("\n")
387
+ end
388
+
389
+ def render_file_section(file_data)
390
+ lines = []
391
+
392
+ # File header
393
+ lines << "#{file_data[:path]}:"
394
+
395
+ # Check if file has any issues
396
+ has_issues = file_data[:failures].any? || file_data[:errors].any?
397
+
398
+ # If no issues, show success summary
399
+ if !has_issues
400
+ # Use the passed count from file_result if available, otherwise calculate
401
+ passed_tests = file_data[:passed] ||
402
+ ((file_data[:tests] || 0) - file_data[:failures].size - file_data[:errors].size)
403
+
404
+
405
+ lines << " ✓ #{passed_tests} test#{'s' if passed_tests != 1} passed"
406
+ return lines.join("\n")
407
+ end
408
+
409
+ # For first-failure mode, only show first error or failure
410
+ if @focus_mode == :first_failure || @focus_mode == :'first-failure'
411
+ shown_count = 0
412
+
413
+ # Show first error
414
+ if file_data[:errors].any? && shown_count == 0
415
+ error = file_data[:errors].first
416
+ lines << " L#{error[:line]}: #{error[:error]}"
417
+ lines << " Test: #{error[:test]}" if error[:test] != 'unnamed test'
418
+ shown_count += 1
419
+ end
420
+
421
+ # Show first failure if no error was shown
422
+ if file_data[:failures].any? && shown_count == 0
423
+ failure = file_data[:failures].first
424
+ line_parts = [" L#{failure[:line]}:"]
425
+
426
+ if failure[:expected] && failure[:got]
427
+ line_parts << "expected #{failure[:expected]}, got #{failure[:got]}"
428
+ elsif failure[:reason]
429
+ line_parts << failure[:reason]
430
+ end
431
+
432
+ lines << line_parts.join(' ')
433
+ lines << " Test: #{failure[:test]}" if failure[:test] != 'unnamed test'
434
+
435
+ # Add diff if available and budget allows
436
+ if failure[:diff] && @budget.remaining > 50
437
+ lines << " Diff:"
438
+ failure[:diff].split("\n").each { |diff_line| lines << " #{diff_line}" }
439
+ end
440
+ end
441
+
442
+ # Show truncation notice
443
+ total_issues = file_data[:errors].size + file_data[:failures].size
444
+ if total_issues > 1
445
+ lines << " ... (#{total_issues - 1} more failures not shown)"
446
+ end
447
+ else
448
+ # Normal mode - show all errors and failures
449
+ # Errors first (more critical)
450
+ file_data[:errors].each do |error|
451
+ next if error[:basic] # Skip basic entries from summary mode
452
+ lines << " L#{error[:line]}: #{error[:error]}"
453
+ lines << " Test: #{error[:test]}" if error[:test] != 'unnamed test'
454
+ end
455
+
456
+ # Then failures
457
+ file_data[:failures].each do |failure|
458
+ next if failure[:basic] # Skip basic entries from summary mode
459
+ line_parts = [" L#{failure[:line]}:"]
460
+
461
+ if failure[:expected] && failure[:got]
462
+ line_parts << "expected #{failure[:expected]}, got #{failure[:got]}"
463
+ elsif failure[:reason]
464
+ line_parts << failure[:reason]
465
+ end
466
+
467
+ lines << line_parts.join(' ')
468
+ lines << " Test: #{failure[:test]}" if failure[:test] != 'unnamed test'
469
+
470
+ # Add diff if available and budget allows
471
+ if failure[:diff] && @budget.remaining > 50
472
+ lines << " Diff:"
473
+ failure[:diff].split("\n").each { |diff_line| lines << " #{diff_line}" }
474
+ end
475
+ end
476
+
477
+ # Show truncation notice if applicable
478
+ if file_data[:truncated]
479
+ lines << " ... (more failures not shown)"
480
+ end
481
+ end
482
+
483
+ lines.join("\n")
484
+ end
485
+
486
+ def relative_path(file_path)
487
+ # Remove leading path components to save tokens
488
+ path = Pathname.new(file_path).relative_path_from(Pathname.pwd).to_s
489
+ # If relative path is longer, use just filename
490
+ path.include?('../') ? File.basename(file_path) : path
491
+ rescue
492
+ File.basename(file_path)
493
+ end
494
+
495
+ def format_time(seconds)
496
+ return '0ms' unless seconds
497
+
498
+ if seconds < 0.001
499
+ "#{(seconds * 1_000_000).round}μs"
500
+ elsif seconds < 1
501
+ "#{(seconds * 1000).round}ms"
502
+ else
503
+ "#{seconds.round(2)}s"
504
+ end
505
+ end
506
+
507
+ def render_execution_context
508
+ context_lines = []
509
+ context_lines << "EXECUTION DETAILS:"
510
+
511
+ # Framework and context mode
512
+ framework = @options[:framework] || :direct
513
+ shared_context = if @options.key?(:shared_context)
514
+ @options[:shared_context]
515
+ else
516
+ # Apply framework defaults
517
+ case framework
518
+ when :rspec, :minitest
519
+ false
520
+ else
521
+ true # direct/tryouts defaults to shared
522
+ end
523
+ end
524
+
525
+ context_lines << " Framework: #{framework}"
526
+ context_lines << " Context mode: #{shared_context ? 'shared (variables persist across test cases)' : 'fresh (each test case isolated)'}"
527
+
528
+ # Parser type
529
+ parser = @options[:parser] || :enhanced
530
+ context_lines << " Parser: #{parser}"
531
+
532
+ # Other relevant flags
533
+ flags = []
534
+ flags << "verbose" if @options[:verbose]
535
+ flags << "fails-only" if @options[:fails_only]
536
+ flags << "debug" if @options[:debug]
537
+ flags << "stack-traces" if @options[:stack_traces]
538
+ flags << "parallel(#{@options[:parallel_threads] || 'auto'})" if @options[:parallel]
539
+ flags << "line-spec" if @options[:line_spec]
540
+
541
+ context_lines << " Flags: #{flags.any? ? flags.join(', ') : 'none'}" if flags.any?
542
+
543
+ # Agent-specific settings
544
+ context_lines << " Agent mode: focus=#{@focus_mode}, limit=#{@budget.limit} tokens"
545
+
546
+ # Add syntax errors if any (these prevent test execution)
547
+ if @syntax_errors.any?
548
+ context_lines << ""
549
+ context_lines << "Syntax Errors:"
550
+ @syntax_errors.each do |error|
551
+ # Clean up the error message to remove redundant prefixes
552
+ clean_message = error[:message].gsub(/^ERROR:\s*/i, '').strip
553
+ context_lines << " #{clean_message}"
554
+ if error[:backtrace] && @options[:debug]
555
+ error[:backtrace].first(3).each do |trace|
556
+ context_lines << " #{trace}"
557
+ end
558
+ end
559
+ end
560
+ end
561
+
562
+ # Add warnings if any
563
+ if @all_warnings.any? && @options.fetch(:warnings, true)
564
+ context_lines << ""
565
+ context_lines << "Parser Warnings:"
566
+ @all_warnings.each do |warning|
567
+ context_lines << " #{warning[:file]}:#{warning[:line]}: #{warning[:message]}"
568
+ context_lines << " #{warning[:suggestion]}" if warning[:suggestion]
569
+ end
570
+ end
571
+
572
+ context_lines.join("\n")
573
+ end
574
+ end
575
+ end
576
+ end