rspec-ai-formatter 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 78649016754440db1ab8030edcf686c32a35871bb8d967d3b2423e6677c6d68c
4
+ data.tar.gz: 3d82c9e6e41523f4e914d6a66815755cbe7e59d5313feadc938fcbe6ec785904
5
+ SHA512:
6
+ metadata.gz: 1b0d8fc0ef7a083fa584db00e2f11ad5dd0bb2a5456a7addca0ab10aac6ef6bd514ca39b24f208b82d5dcfda027ab20b2b9617a8a94fbe57acd1bcc33f76874c
7
+ data.tar.gz: e03b3ac07503f063d3b3c9aa8c9ed3591dfb1fc85471aaa75b9d5c89c0c482762122d6187efe893c2e8ca5964ba90f202697d26ee032e22e5332363026f62763
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,302 @@
1
+ # RSpec AI Formatter
2
+
3
+ AI-friendly RSpec formatter optimized for minimal token usage in LLM contexts.
4
+
5
+ ## Why?
6
+
7
+ Standard RSpec formatters waste tokens on visual noise:
8
+ - **Progress formatter**: Dots and asterisks (`.F*`) with no file context
9
+ - **Documentation formatter**: Hierarchical descriptions with indentation whitespace
10
+ - **Both**: Failures dumped at end with redundant stack traces
11
+
12
+ **AI Formatter**: One line per test, structured NDJSON, references to detailed logs.
13
+
14
+ ## Installation
15
+
16
+ Add to your Gemfile:
17
+
18
+ ```ruby
19
+ group :test do
20
+ gem 'rspec-ai-formatter', require: false
21
+ end
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ### Basic
27
+
28
+ ```bash
29
+ # Via CLI helper
30
+ rspec-ai
31
+
32
+ # Via RSpec directly
33
+ rspec --format RSpec::AiFormatter::Formatter
34
+
35
+ # With custom log directory
36
+ rspec-ai --ai-logs-dir logs/test -- spec/
37
+ ```
38
+
39
+ ### Environment Variables
40
+
41
+ | Variable | Description | Default |
42
+ |----------|-------------|---------|
43
+ | `RSPEC_AI_LOGS` | Directory for failure logs | `tmp/rspec_logs` |
44
+ | `RSPEC_AI_CLEAN` | Clean logs before run | `0` |
45
+ | `RSPEC_AI_FULL` | Enable full output (default is minimal) | `0` |
46
+ | `RSPEC_AI_SIGNATURES` | Enable error signature hashing | `0` |
47
+ | `RSPEC_AI_DEDUP` | Deduplicate identical errors | `0` |
48
+
49
+ ### Output Modes
50
+
51
+ **Minimal mode (default)** — optimized for AI and CI:
52
+
53
+ ```bash
54
+ bundle exec rspec --format RSpec::AiFormatter::Formatter
55
+ ```
56
+
57
+ Output: `start`, failures, skips, `done`. Passing tests omitted.
58
+
59
+ ```json
60
+ {"t":"start","total":100}
61
+ {"t":"test","id":"spec/api_spec.rb:10","s":"fail","e":{"type":"Error","msg":"timeout","loc":"spec/api_spec.rb:12"}}
62
+ {"t":"test","id":"spec/user_spec.rb:5","s":"skip","skip":"pending"}
63
+ {"t":"done","passed":98,"failed":1,"skipped":1,"total":100,"time":1234}
64
+ ```
65
+
66
+ **Full mode** — with all details:
67
+
68
+ ```bash
69
+ RSPEC_AI_FULL=1 bundle exec rspec --format RSpec::AiFormatter::Formatter
70
+ ```
71
+
72
+ Includes: test names, timestamps, timing for every test.
73
+
74
+ | Mode | Passing tests | Failures | Skips | Events for 1000 tests |
75
+ |------|---------------|----------|-------|----------------------|
76
+ | Minimal (default) | Omitted | Full | Minimal | ~50 |
77
+ | Full | Full event | Full | Full | ~1002 |
78
+
79
+ **Use full mode when:**
80
+ - Debugging individual test performance
81
+ - You need to see all test names
82
+ - Parsing requires timing data per test
83
+ - Human-readable output is priority
84
+
85
+ **Minimal mode removes:**
86
+ - Passing tests (100% savings)
87
+ - Test names (`n`) for skips
88
+ - Timestamps (`ts`) on individual tests
89
+ - Timing (`time`) for skips
90
+ - Timestamp from `done` event
91
+ - Line end ranges in `id`
92
+ - `./` prefix in file paths
93
+
94
+ ### Combined with Other Formatters
95
+
96
+ ```bash
97
+ # AI format to file, progress to console
98
+ rspec --format progress --format RSpec::AiFormatter::Formatter --out rspec.jsonl
99
+ ```
100
+
101
+ ### GitHub Actions
102
+
103
+ The formatter auto-detects `GITHUB_ACTIONS=true` and emits workflow commands for inline PR annotations:
104
+
105
+ ```yaml
106
+ - name: Run tests
107
+ env:
108
+ GITHUB_ACTIONS: true # Usually already set by GitHub
109
+ run: bundle exec rspec --format RSpec::AiFormatter::Formatter
110
+ ```
111
+
112
+ **Annotations appear on:**
113
+ - Failed tests → `::error` with file/line
114
+ - Skipped/pending tests → `::warning`
115
+
116
+ **Example output:**
117
+ ```
118
+ ::error file=spec/models/user_spec.rb,line=42::expected false, got true
119
+ ::warning file=spec/api/client_spec.rb,line=15::Skipped: pending implementation
120
+ ```
121
+
122
+ To disable annotations but keep NDJSON:
123
+ ```bash
124
+ GITHUB_ACTIONS= bundle exec rspec --format RSpec::AiFormatter::Formatter
125
+ ```
126
+
127
+ ### Parallel Tests
128
+
129
+ When using `parallel_tests`, each process writes its own output file. Merge them with `rspec-ai-merge`:
130
+
131
+ ```bash
132
+ # Run tests in parallel, each with unique output file
133
+ parallel_rspec --format RSpec::AiFormatter::Formatter --out tmp/parallel_{%}.jsonl -- spec/
134
+
135
+ # Merge outputs
136
+ rspec-ai-merge tmp/parallel_*.jsonl > combined.jsonl
137
+
138
+ # With deduplication across all parallel runs
139
+ rspec-ai-merge --dedup tmp/parallel_*.jsonl > combined.jsonl
140
+ ```
141
+
142
+ **Merge options:**
143
+ ```bash
144
+ rspec-ai-merge --help
145
+
146
+ # Sort by timestamp (default)
147
+ rspec-ai-merge --output combined.jsonl file1.jsonl file2.jsonl
148
+
149
+ # Skip sorting, keep file order
150
+ rspec-ai-merge --no-sort file1.jsonl file2.jsonl
151
+
152
+ # Deduplicate errors across files
153
+ rspec-ai-merge --dedup file1.jsonl file2.jsonl
154
+
155
+ # Skip final summary
156
+ rspec-ai-merge --no-summary file1.jsonl file2.jsonl
157
+ ```
158
+
159
+ **Merged output includes:**
160
+ - All test events sorted by timestamp
161
+ - Combined `done` event with totals from all files
162
+ - Deduplication summaries (if enabled)
163
+
164
+ ## Output Format
165
+
166
+ ### NDJSON Stream
167
+
168
+ ```json
169
+ {"t":"start","suite":["spec/models/user_spec.rb"],"total":3,"ts":"2025-06-25T14:30:22.123Z"}
170
+ {"t":"test","id":"spec/models/user_spec.rb:15-25","n":"User>valid?>requires email","s":"pass","time":12,"ts":"2025-06-25T14:30:22.135Z"}
171
+ {"t":"test","id":"spec/models/user_spec.rb:27-40","n":"User>valid?>rejects invalid format","s":"fail","time":45,"e":{"type":"ExpectationNotMet","msg":"expected false, got true","loc":"spec/models/user_spec.rb:42","log":"tmp/rspec_logs/user_spec_27.log"},"ts":"2025-06-25T14:30:22.180Z"}
172
+ {"t":"test","id":"spec/models/user_spec.rb:42-55","n":"User>valid?>accepts valid format","s":"skip","time":0,"skip":"pending implementation","ts":"2025-06-25T14:30:22.181Z"}
173
+ {"t":"done","passed":1,"failed":1,"skipped":1,"total":3,"time":1247,"ts":"2025-06-25T14:30:23.370Z"}
174
+ ```
175
+
176
+ ### Log Files
177
+
178
+ ```
179
+ tmp/rspec_logs/
180
+ ├── user_spec_27.log # Per-failure details
181
+ ├── api_client_15.log
182
+ └── index.json # Machine-readable index
183
+ ```
184
+
185
+ Example failure log:
186
+
187
+ ```
188
+ ================================================================================
189
+ TEST: User > valid? > rejects invalid format
190
+ LOCATION: spec/models/user_spec.rb:27
191
+ STATUS: FAIL
192
+ TIME: 45ms
193
+ ================================================================================
194
+
195
+ ERROR: ExpectationNotMet
196
+ MESSAGE:
197
+ expected false, got true
198
+
199
+ EXPECTED:
200
+ false
201
+
202
+ ACTUAL:
203
+ true
204
+
205
+ BACKTRACE:
206
+ spec/models/user_spec.rb:42:in `block (3 levels) in <top (required)>'
207
+ ...
208
+ ```
209
+
210
+ ## Schema
211
+
212
+ ### Event Types
213
+
214
+ | `t` | Description | Additional Fields |
215
+ |-----|-------------|-------------------|
216
+ | `start` | Suite started | `suite`, `total` |
217
+ | `test` | Test completed | `id`, `n`, `s`, `time`, `e`/`skip` |
218
+ | `done` | Suite finished | `passed`, `failed`, `skipped`, `total`, `time` |
219
+
220
+ ### Status Values
221
+
222
+ | `s` | Meaning |
223
+ |-----|---------|
224
+ | `pass` | Test passed |
225
+ | `fail` | Test failed (see `e` for details) |
226
+ | `skip` | Test skipped/pending (see `skip` for reason) |
227
+
228
+ ### Error Object (`e`)
229
+
230
+ | Field | Description |
231
+ |-------|-------------|
232
+ | `type` | Error class (shortened) |
233
+ | `msg` | Truncated message (200 chars) |
234
+ | `loc` | File:line of failure |
235
+ | `log` | Path to full log file |
236
+ | `sig` | MD5 signature of error (if dedup/signatures enabled) |
237
+ | `diff` | Expected/actual diff (for expectations) |
238
+
239
+ ### Deduplication
240
+
241
+ When `RSPEC_AI_DEDUP=1`, identical errors are collapsed:
242
+
243
+ ```json
244
+ // First occurrence - full details
245
+ {"t":"test","id":"spec/api_spec.rb:20","n":"GET /users returns 200","s":"fail","e":{"type":"TimeoutError","msg":"execution expired","loc":"spec/api_spec.rb:25","sig":"a3f9e2d1"}}
246
+
247
+ // Duplicates - lightweight reference
248
+ {"t":"dedup","sig":"a3f9e2d1","id":"spec/api_spec.rb:40","n":"GET /users with params","first":"spec/api_spec.rb:20"}
249
+ {"t":"dedup","sig":"a3f9e2d1","id":"spec/api_spec.rb:60","n":"POST /users creates user","first":"spec/api_spec.rb:20"}
250
+
251
+ // Summary at end
252
+ {"t":"dedup_summary","sig":"a3f9e2d1","type":"TimeoutError","msg":"execution expired","count":3,"first":"spec/api_spec.rb:20","examples":["spec/api_spec.rb:20","spec/api_spec.rb:40","spec/api_spec.rb:60"]}
253
+ ```
254
+
255
+ Useful when a shared setup/teardown failure cascades through many tests.
256
+
257
+ ## Token Efficiency
258
+
259
+ | Scenario | Progress | Documentation | AI Formatter |
260
+ |----------|----------|---------------|--------------|
261
+ | 100 tests, all pass | ~200 | ~800 | ~150 |
262
+ | 10 failures with traces | ~2000 | ~2500 | ~300 + files |
263
+ | Parsing complexity | Hard | Hard | Trivial |
264
+
265
+ ## Features
266
+
267
+ - ✅ **Streaming NDJSON**: Real-time output, parse-as-you-go
268
+ - ✅ **Minimal mode**: Ultra-compact output for massive test suites (80% smaller)
269
+ - ✅ **Location tracking**: File + line start-end for each test
270
+ - ✅ **Failure isolation**: Full output to separate log files
271
+ - ✅ **Error signatures**: Group similar failures by hash
272
+ - ✅ **Error deduplication**: Collapse identical errors, show "and 4 more like this"
273
+ - ✅ **Parallel test merging**: Merge outputs from parallel_tests runs
274
+ - ✅ **Diff generation**: Smart expected/actual comparison
275
+ - ✅ **Truncation**: Prevent token explosion on large outputs
276
+ - ✅ **ANSI-free**: Clean text, no escape codes
277
+ - ✅ **GitHub Actions**: Auto-detects and emits `::error`/`::warning` annotations
278
+
279
+ ## TODO
280
+
281
+ ### CI/CD Integration
282
+ - [x] GitHub Actions workflow command integration — Auto-detect `GITHUB_ACTIONS` env and emit `::error` / `::warning` commands for inline PR annotations
283
+
284
+ ### Error Intelligence
285
+ - [x] Error pattern deduplication across test runs — Group failures by signature hash, show "and 4 more like this"
286
+ - [ ] Quick-fix hints integration with `did_you_mean` — Suggest typo corrections, similar method names
287
+ - [ ] Error context snippets — Include 3-5 lines of source code around failure in log files
288
+
289
+ ### Performance & Insights
290
+ - [ ] Performance baseline tracking — Compare to previous run, flag regressions >50%
291
+ - [ ] Test selection metadata — Add tags, flakiness score, last failure date to each test event
292
+ - [ ] Slowest tests report — Top N slowest tests in summary
293
+
294
+ ### Compatibility & Tooling
295
+ - [ ] JUnit XML compatibility mode — Dual output for systems requiring XML
296
+ - [x] Parallel test log merging — Clean merge of outputs from `parallel_tests` runs
297
+ - [ ] Screenshot capture for system tests — Auto-capture Capybara screenshot path on failure
298
+ - [ ] Custom output templates — User-defined NDJSON schema / field selection
299
+
300
+ ## License
301
+
302
+ MIT
data/bin/rspec-ai ADDED
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # CLI helper for rspec-ai-formatter
5
+ # Usage: rspec-ai [rspec-options] [--ai-logs-dir DIR]
6
+
7
+ require 'optparse'
8
+
9
+ # CLI wrapper for running RSpec with AI formatter
10
+ # Handles option parsing and environment setup
11
+ class RspecAiCli
12
+ def initialize(args)
13
+ @args = args
14
+ @log_dir = ENV['RSPEC_AI_LOGS'] || 'tmp/rspec_logs'
15
+ @rspec_args = []
16
+ @clean = false
17
+ end
18
+
19
+ def run
20
+ parse_options!
21
+ setup_environment
22
+ exec_rspec
23
+ end
24
+
25
+ private
26
+
27
+ def parse_options!
28
+ parser = OptionParser.new do |opts|
29
+ opts.banner = 'Usage: rspec-ai [options] [rspec options]'
30
+ opts.separator ''
31
+ opts.separator 'AI Formatter options:'
32
+
33
+ opts.on('--ai-logs-dir DIR', 'Directory for failure logs') do |dir|
34
+ @log_dir = dir
35
+ end
36
+
37
+ opts.on('--ai-clean', 'Clean log directory before run') do
38
+ @clean = true
39
+ end
40
+
41
+ opts.on('--ai-signatures', 'Enable error signature hashing') do
42
+ ENV['RSPEC_AI_SIGNATURES'] = '1'
43
+ end
44
+
45
+ opts.on('-h', '--help', 'Show this help') do
46
+ puts opts
47
+ puts
48
+ puts 'Environment variables:'
49
+ puts ' RSPEC_AI_LOGS - Default log directory'
50
+ puts ' RSPEC_AI_CLEAN - Clean logs before run (1/0)'
51
+ puts ' RSPEC_AI_SIGNATURES - Enable error signatures (1/0)'
52
+ exit
53
+ end
54
+
55
+ opts.separator ''
56
+ opts.separator 'All other options are passed to RSpec.'
57
+ end
58
+
59
+ # Split AI-specific options from RSpec options
60
+ ai_args = []
61
+ rspec_args = []
62
+
63
+ i = 0
64
+ while i < @args.length
65
+ arg = @args[i]
66
+
67
+ if arg.start_with?('--ai-')
68
+ ai_args << arg
69
+ if ['--ai-logs-dir'].include?(arg) && i + 1 < @args.length
70
+ ai_args << @args[i + 1]
71
+ i += 1
72
+ end
73
+ else
74
+ rspec_args << arg
75
+ end
76
+ i += 1
77
+ end
78
+
79
+ parser.parse!(ai_args)
80
+ @rspec_args = rspec_args
81
+ end
82
+
83
+ def setup_environment
84
+ ENV['RSPEC_AI_LOGS'] = @log_dir
85
+ ENV['RSPEC_AI_CLEAN'] = '1' if @clean
86
+
87
+ # Ensure the formatter is loaded
88
+ formatter_path = File.expand_path('../lib', __dir__)
89
+ ENV['RUBYOPT'] = "#{ENV.fetch('RUBYOPT', '')} -I#{formatter_path}"
90
+ end
91
+
92
+ def exec_rspec
93
+ cmd = ['rspec', '--format', 'RSpec::AiFormatter::Formatter']
94
+ cmd += @rspec_args
95
+
96
+ exec(*cmd)
97
+ end
98
+ end
99
+
100
+ RspecAiCli.new(ARGV).run
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Merge multiple RSpec AI formatter outputs from parallel test runs
5
+ # Usage: rspec-ai-merge [options] file1.jsonl file2.jsonl ... > combined.jsonl
6
+
7
+ require 'json'
8
+ require 'optparse'
9
+
10
+ # Merges multiple RSpec AI formatter JSONL outputs
11
+ # Supports deduplication, sorting, and summary generation
12
+ class RspecAiMerger
13
+ def initialize(args)
14
+ @files = []
15
+ @output = $stdout
16
+ @dedup = false
17
+ @sort = true
18
+ @summary = true
19
+ parse_options!(args)
20
+ end
21
+
22
+ def run
23
+ if @files.empty?
24
+ puts 'No input files specified. Use --help for usage.'
25
+ exit 1
26
+ end
27
+
28
+ missing = @files.reject { |f| File.exist?(f) }
29
+ unless missing.empty?
30
+ warn "Missing files: #{missing.join(', ')}"
31
+ exit 1
32
+ end
33
+
34
+ events = load_events
35
+ events = sort_events(events) if @sort
36
+ events = deduplicate(events) if @dedup
37
+
38
+ write_output(events)
39
+ write_summary(events) if @summary
40
+ end
41
+
42
+ private
43
+
44
+ def parse_options!(args)
45
+ parser = OptionParser.new do |opts|
46
+ opts.banner = 'Usage: rspec-ai-merge [options] file1.jsonl file2.jsonl ...'
47
+ opts.separator ''
48
+ opts.separator 'Merge multiple RSpec AI formatter outputs from parallel test runs'
49
+ opts.separator ''
50
+ opts.separator 'Options:'
51
+
52
+ opts.on('-o', '--output FILE', 'Output file (default: stdout)') do |f|
53
+ @output = File.open(f, 'w')
54
+ end
55
+
56
+ opts.on('-d', '--dedup', 'Deduplicate identical errors across files') do
57
+ @dedup = true
58
+ end
59
+
60
+ opts.on('--no-sort', 'Do not sort by timestamp') do
61
+ @sort = false
62
+ end
63
+
64
+ opts.on('--no-summary', 'Skip summary event') do
65
+ @summary = false
66
+ end
67
+
68
+ opts.on('-h', '--help', 'Show this help') do
69
+ puts opts
70
+ exit
71
+ end
72
+ end
73
+
74
+ parser.parse!(args)
75
+ @files = args
76
+ end
77
+
78
+ def load_events
79
+ events = []
80
+
81
+ @files.each do |file|
82
+ File.readlines(file).each do |line|
83
+ line = line.strip
84
+ next if line.empty?
85
+
86
+ begin
87
+ event = JSON.parse(line)
88
+ events << event
89
+ rescue JSON::ParserError
90
+ # Skip malformed lines
91
+ warn "Skipping malformed line in #{file}: #{line[0..50]}..."
92
+ end
93
+ end
94
+ end
95
+
96
+ events
97
+ end
98
+
99
+ def sort_events(events)
100
+ # Sort by timestamp if available, otherwise keep original order
101
+ events.sort_by do |e|
102
+ ts = e['ts']
103
+ if ts
104
+ # Parse ISO8601 timestamp
105
+ Time.parse(ts).to_f
106
+ else
107
+ 0
108
+ end
109
+ rescue StandardError
110
+ 0
111
+ end
112
+ end
113
+
114
+ def deduplicate(events)
115
+ signatures = {}
116
+ result = []
117
+
118
+ events.each do |event|
119
+ case event['t']
120
+ when 'test'
121
+ if event['s'] == 'fail' && event.dig('e', 'sig')
122
+ sig = event['e']['sig']
123
+ if signatures.key?(sig)
124
+ # Convert to dedup event
125
+ signatures[sig][:count] += 1
126
+ signatures[sig][:examples] << event['id']
127
+ result << {
128
+ 't' => 'dedup',
129
+ 'sig' => sig,
130
+ 'id' => event['id'],
131
+ 'n' => event['n'],
132
+ 'first' => signatures[sig][:first],
133
+ 'ts' => event['ts']
134
+ }
135
+ else
136
+ signatures[sig] = {
137
+ count: 1,
138
+ first: event['id'],
139
+ examples: [event['id']],
140
+ type: event.dig('e', 'type'),
141
+ msg: event.dig('e', 'msg')
142
+ }
143
+ result << event
144
+ end
145
+ else
146
+ result << event
147
+ end
148
+ when 'dedup'
149
+ # Already a dedup event, track it
150
+ sig = event['sig']
151
+ if signatures.key?(sig)
152
+ signatures[sig][:count] += 1
153
+ signatures[sig][:examples] << event['id']
154
+ end
155
+ result << event
156
+ when 'dedup_summary'
157
+ # Skip original dedup_summary, we'll generate a new one
158
+ next
159
+ else
160
+ result << event
161
+ end
162
+ end
163
+
164
+ # Add merged dedup summaries
165
+ signatures.each do |sig, data|
166
+ next if data[:count] <= 1
167
+
168
+ result << {
169
+ 't' => 'dedup_summary',
170
+ 'sig' => sig,
171
+ 'type' => data[:type],
172
+ 'msg' => data[:msg],
173
+ 'count' => data[:count],
174
+ 'first' => data[:first],
175
+ 'examples' => data[:examples]
176
+ }
177
+ end
178
+
179
+ result
180
+ end
181
+
182
+ def write_output(events)
183
+ events.each do |event|
184
+ @output.puts(event.to_json)
185
+ end
186
+ end
187
+
188
+ def write_summary(events)
189
+ # Calculate totals from all 'done' events
190
+ dones = events.select { |e| e['t'] == 'done' }
191
+
192
+ return unless dones.any?
193
+
194
+ total_passed = dones.sum { |d| d['passed'] || 0 }
195
+ total_failed = dones.sum { |d| d['failed'] || 0 }
196
+ total_skipped = dones.sum { |d| d['skipped'] || 0 }
197
+ total_tests = dones.sum { |d| d['total'] || 0 }
198
+ total_time = dones.sum { |d| d['time'] || 0 }
199
+
200
+ # Remove original done events and add merged one
201
+ @output.puts({
202
+ 't' => 'done',
203
+ 'passed' => total_passed,
204
+ 'failed' => total_failed,
205
+ 'skipped' => total_skipped,
206
+ 'total' => total_tests,
207
+ 'time' => total_time,
208
+ 'merged_from' => @files.length,
209
+ 'ts' => Time.now.utc.iso8601(3)
210
+ }.to_json)
211
+ end
212
+ end
213
+
214
+ RspecAiMerger.new(ARGV).run
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module AiFormatter
5
+ # Formats error details for output
6
+ module ErrorFormatter
7
+ def error_details(example, notification, log_path, sig = nil)
8
+ exception = notification.exception
9
+ return {} unless exception
10
+
11
+ details = {
12
+ type: error_class(exception),
13
+ msg: truncate(error_message(exception), 200),
14
+ loc: failure_location(example, exception)
15
+ }
16
+
17
+ details[:log] = log_path if log_path && @log_dir != File::NULL
18
+ details[:sig] = sig if sig
19
+
20
+ # Add diff for expectation failures
21
+ if exception.respond_to?(:expected) && exception.respond_to?(:actual)
22
+ details[:diff] = generate_diff(exception.expected, exception.actual)
23
+ end
24
+
25
+ details
26
+ end
27
+
28
+ def error_class(exception)
29
+ exception.class.name.split('::').last
30
+ rescue StandardError
31
+ 'Error'
32
+ end
33
+
34
+ def error_message(exception)
35
+ msg = exception.message.to_s
36
+ # Clean up RSpec expectation messages
37
+ msg.gsub(/\e\[\d+m/, '') # Strip ANSI codes
38
+ rescue StandardError
39
+ 'Unknown error'
40
+ end
41
+
42
+ def error_signature(exception)
43
+ # Create a hash of the error type + normalized message
44
+ # Useful for grouping similar failures
45
+ sig = "#{error_class(exception)}:#{error_message(exception)[0..50]}"
46
+ require 'digest'
47
+ Digest::MD5.hexdigest(sig)[0..7]
48
+ end
49
+
50
+ def failure_location(example, exception)
51
+ # Find the most relevant location in the test file
52
+ example_file = example.metadata[:file_path]
53
+
54
+ if exception.backtrace
55
+ # Find first line in the test file
56
+ test_line = exception.backtrace.find { |l| l.include?(example_file) }
57
+ return test_line.split(':')[0..1].join(':') if test_line
58
+ end
59
+
60
+ # Fallback to example location
61
+ "#{example_file}:#{example.metadata[:line_number]}"
62
+ end
63
+
64
+ def skip_reason(example)
65
+ result = example.execution_result
66
+ return nil unless result.respond_to?(:pending_message)
67
+
68
+ result.pending_message
69
+ end
70
+
71
+ private
72
+
73
+ def generate_diff(expected, actual)
74
+ # Simple diff generation
75
+ return nil if expected.nil? || actual.nil?
76
+
77
+ exp_str = inspect_value(expected)
78
+ act_str = inspect_value(actual)
79
+
80
+ return nil if exp_str == act_str
81
+
82
+ # Return truncated diff
83
+ {
84
+ expected: truncate(exp_str, 500),
85
+ actual: truncate(act_str, 500)
86
+ }
87
+ end
88
+
89
+ def inspect_value(value)
90
+ case value
91
+ when String
92
+ value
93
+ when Hash, Array
94
+ JSON.generate(value)
95
+ else
96
+ value.inspect
97
+ end
98
+ rescue StandardError
99
+ value.to_s
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'time'
6
+ require_relative 'log_writer'
7
+ require_relative 'error_formatter'
8
+ require_relative 'location_helper'
9
+ require_relative 'output_helper'
10
+
11
+ module RSpec
12
+ module AiFormatter
13
+ # AI-friendly formatter for RSpec
14
+ # Outputs compact NDJSON with references to detailed logs
15
+ class Formatter
16
+ include LogWriter
17
+ include ErrorFormatter
18
+ include LocationHelper
19
+ include OutputHelper
20
+
21
+ RSpec::Core::Formatters.register self,
22
+ :start,
23
+ :example_started,
24
+ :example_passed,
25
+ :example_failed,
26
+ :example_pending,
27
+ :dump_summary
28
+
29
+ def initialize(output, log_dir: nil)
30
+ @output = output
31
+ @log_dir = log_dir || ENV.fetch('RSPEC_AI_LOGS', 'tmp/rspec_logs')
32
+ @start_time = nil
33
+ @suite_start = nil
34
+ @failure_count = 0
35
+ @pending_count = 0
36
+ @passed_count = 0
37
+ @test_index = []
38
+ @deduplicate = ENV['RSPEC_AI_DEDUP'] == '1'
39
+ @minimal = ENV['RSPEC_AI_FULL'] != '1'
40
+ @error_signatures = {}
41
+ end
42
+
43
+ def start(notification)
44
+ @suite_start = Time.now
45
+
46
+ setup_log_directory
47
+
48
+ emit(
49
+ t: 'start',
50
+ total: notification.count,
51
+ ts: timestamp
52
+ )
53
+ end
54
+
55
+ def example_started(notification); end
56
+
57
+ def example_passed(notification)
58
+ @passed_count += 1
59
+ return if @minimal
60
+
61
+ ex = notification.example
62
+ emit(
63
+ t: 'test',
64
+ id: test_location(ex),
65
+ n: short_name(ex),
66
+ s: 'pass',
67
+ time: execution_time_ms(ex),
68
+ ts: timestamp
69
+ )
70
+ end
71
+
72
+ def example_failed(notification)
73
+ @failure_count += 1
74
+ ex = notification.example
75
+ exception = notification.exception
76
+ log_path = write_failure_log(ex, notification)
77
+
78
+ # Calculate signature for deduplication
79
+ sig = error_signature(exception) if @deduplicate || ENV['RSPEC_AI_SIGNATURES'] == '1'
80
+
81
+ if @deduplicate && sig && @error_signatures.key?(sig)
82
+ handle_duplicate_error(ex, sig)
83
+ else
84
+ handle_first_error(ex, notification, log_path, sig)
85
+ end
86
+ end
87
+
88
+ def example_pending(notification)
89
+ @pending_count += 1
90
+ ex = notification.example
91
+ reason = skip_reason(ex)
92
+
93
+ if @minimal
94
+ emit_minimal_test(ex, 'skip', skip: reason)
95
+ else
96
+ emit(
97
+ t: 'test',
98
+ id: test_location(ex),
99
+ n: short_name(ex),
100
+ s: 'skip',
101
+ time: execution_time_ms(ex),
102
+ skip: reason,
103
+ ts: timestamp
104
+ )
105
+ end
106
+ end
107
+
108
+ def dump_summary(notification)
109
+ total_time = ((Time.now - @suite_start) * 1000).round
110
+
111
+ emit_dedup_summaries if @deduplicate && duplicates_exist?
112
+
113
+ emit_summary(total_time, notification)
114
+ write_index_file
115
+ end
116
+
117
+ private
118
+
119
+ def handle_duplicate_error(example, sig)
120
+ @error_signatures[sig][:count] += 1
121
+ @error_signatures[sig][:examples] << test_location(example)
122
+
123
+ emit(
124
+ t: 'dedup',
125
+ sig: sig,
126
+ id: test_location(example),
127
+ n: short_name(example),
128
+ first: @error_signatures[sig][:first],
129
+ ts: timestamp
130
+ )
131
+ end
132
+
133
+ def handle_first_error(example, notification, log_path, sig)
134
+ # First occurrence of this error
135
+ if @deduplicate && sig
136
+ @error_signatures[sig] = {
137
+ count: 1,
138
+ first: test_location(example),
139
+ examples: [test_location(example)],
140
+ type: error_class(notification.exception),
141
+ msg: truncate(error_message(notification.exception), 100)
142
+ }
143
+ end
144
+
145
+ emit(
146
+ t: 'test',
147
+ id: test_location(example),
148
+ n: short_name(example),
149
+ s: 'fail',
150
+ time: execution_time_ms(example),
151
+ e: error_details(example, notification, log_path, sig),
152
+ ts: timestamp
153
+ )
154
+ end
155
+
156
+ def duplicates_exist?
157
+ @error_signatures.any? { |_, v| v[:count] > 1 }
158
+ end
159
+
160
+ def emit_dedup_summaries
161
+ @error_signatures.each do |sig, data|
162
+ next if data[:count] <= 1
163
+
164
+ emit(build_dedup_summary(sig, data))
165
+ end
166
+ end
167
+
168
+ def build_dedup_summary(sig, data)
169
+ if @minimal
170
+ { t: 'dedup_summary', sig: sig, count: data[:count], first: data[:first] }
171
+ else
172
+ {
173
+ t: 'dedup_summary',
174
+ sig: sig,
175
+ type: data[:type],
176
+ msg: data[:msg],
177
+ count: data[:count],
178
+ first: data[:first],
179
+ examples: data[:examples],
180
+ ts: timestamp
181
+ }
182
+ end
183
+ end
184
+
185
+ def emit_summary(total_time, notification)
186
+ summary = {
187
+ t: 'done',
188
+ passed: @passed_count,
189
+ failed: @failure_count,
190
+ skipped: @pending_count,
191
+ total: notification.example_count,
192
+ time: total_time
193
+ }
194
+ summary[:ts] = timestamp unless @minimal
195
+ emit(summary)
196
+ end
197
+ end
198
+ end
199
+ end
200
+
201
+ # Register with RSpec
202
+ if defined?(RSpec::Core::Formatters)
203
+ RSpec::Core::Formatters.register(
204
+ RSpec::AiFormatter::Formatter,
205
+ :start,
206
+ :example_started,
207
+ :example_passed,
208
+ :example_failed,
209
+ :example_pending,
210
+ :dump_summary
211
+ )
212
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module AiFormatter
5
+ # Handles location and naming for test examples
6
+ module LocationHelper
7
+ def test_location(example)
8
+ meta = example.metadata
9
+ file = meta[:file_path]
10
+ line = meta[:line_number]
11
+ # Try to get end line from source location
12
+ end_line = detect_end_line(example)
13
+
14
+ if end_line && end_line > line
15
+ "#{file}:#{line}-#{end_line}"
16
+ else
17
+ "#{file}:#{line}"
18
+ end
19
+ end
20
+
21
+ def minimal_location(example)
22
+ # Minimal format: no ./ prefix, no end line, just file:start_line
23
+ meta = example.metadata
24
+ file = meta[:file_path].to_s.sub(%r{^\./}, '')
25
+ line = meta[:line_number]
26
+ "#{file}:#{line}"
27
+ end
28
+
29
+ def short_name(example)
30
+ # Build hierarchical name from example groups
31
+ parts = example_groups(example).map(&:description)
32
+ parts << example.description
33
+
34
+ # Compact: skip empty descriptions, limit length
35
+ parts.reject(&:empty?).join(' > ').slice(0, 200)
36
+ end
37
+
38
+ private
39
+
40
+ def detect_end_line(example)
41
+ # Get block source location end if available
42
+ block = example.metadata[:block]
43
+ return nil unless block.respond_to?(:source_location)
44
+
45
+ loc = block.source_location
46
+ return nil unless loc.is_a?(Array) && loc.length >= 2
47
+
48
+ # Try to get end location from proc/method
49
+ if block.respond_to?(:source_location_end)
50
+ block.source_location_end&.last
51
+ else
52
+ # Fallback: estimate based on next example or simple heuristic
53
+ line = loc.last
54
+ # Rough estimate: typical test is 5-15 lines
55
+ line + 10
56
+ end
57
+ rescue StandardError
58
+ nil
59
+ end
60
+
61
+ def example_groups(example, groups = [])
62
+ group = example.example_group
63
+ while group && group != RSpec::Core::ExampleGroup
64
+ groups.unshift(group) unless group.description.empty?
65
+ group = group.superclass
66
+ break if group == RSpec::Core::ExampleGroup
67
+ end
68
+ groups
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module AiFormatter
5
+ # Handles writing failure logs to disk
6
+ module LogWriter
7
+ def write_failure_log(example, notification)
8
+ return nil if @log_dir == File::NULL
9
+
10
+ file = example.metadata[:file_path]
11
+ line = example.metadata[:line_number]
12
+ filename = "#{File.basename(file, '.rb')}_#{line}.log"
13
+ log_path = File.join(@log_dir, filename)
14
+
15
+ File.open(log_path, 'w') { |f| write_log_content(f, example, notification) }
16
+
17
+ @test_index << {
18
+ test: short_name(example),
19
+ location: "#{file}:#{line}",
20
+ log: log_path,
21
+ status: 'fail'
22
+ }
23
+
24
+ log_path
25
+ end
26
+
27
+ def write_index_file
28
+ return if @log_dir == File::NULL || @test_index.empty?
29
+
30
+ index_path = File.join(@log_dir, 'index.json')
31
+ File.write(index_path, JSON.pretty_generate(@test_index))
32
+ end
33
+
34
+ def setup_log_directory
35
+ return if @log_dir == File::NULL || @log_dir.nil?
36
+
37
+ FileUtils.mkdir_p(@log_dir)
38
+ return unless ENV['RSPEC_AI_CLEAN'] == '1'
39
+
40
+ FileUtils.rm_rf(Dir.glob(File.join(@log_dir, '*.log')))
41
+ end
42
+
43
+ private
44
+
45
+ def write_log_content(file, example, notification)
46
+ file.puts('=' * 80)
47
+ file.puts("TEST: #{short_name(example)}")
48
+ file.puts("LOCATION: #{example.metadata[:file_path]}:#{example.metadata[:line_number]}")
49
+ file.puts('STATUS: FAIL')
50
+ file.puts("TIME: #{execution_time_ms(example)}ms")
51
+ file.puts('=' * 80)
52
+ file.puts
53
+
54
+ write_exception_details(file, notification.exception)
55
+ file.puts
56
+ file.puts('=' * 80)
57
+ file.puts('FULL OUTPUT:')
58
+ file.puts('=' * 80)
59
+
60
+ return unless notification.respond_to?(:formatted_backtrace)
61
+
62
+ file.puts(notification.formatted_backtrace.join("\n"))
63
+ end
64
+
65
+ def write_exception_details(file, exception)
66
+ return unless exception
67
+
68
+ file.puts("ERROR: #{error_class(exception)}")
69
+ file.puts('MESSAGE:')
70
+ file.puts(error_message(exception))
71
+ file.puts
72
+
73
+ return unless exception.respond_to?(:expected) && exception.respond_to?(:actual)
74
+
75
+ file.puts('EXPECTED:')
76
+ file.puts(inspect_value(exception.expected))
77
+ file.puts
78
+ file.puts('ACTUAL:')
79
+ file.puts(inspect_value(exception.actual))
80
+ file.puts
81
+
82
+ file.puts('BACKTRACE:')
83
+ exception.backtrace&.first(20)&.each { |l| file.puts(l) }
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module AiFormatter
5
+ # Handles output formatting and utility methods
6
+ module OutputHelper
7
+ def emit(hash)
8
+ @output.puts(hash.to_json)
9
+ end
10
+
11
+ def emit_minimal_test(example, status, skip: nil)
12
+ hash = {
13
+ t: 'test',
14
+ id: minimal_location(example),
15
+ s: status
16
+ }
17
+ hash[:skip] = skip if skip
18
+ emit(hash)
19
+ end
20
+
21
+ def execution_time_ms(example)
22
+ result = example.execution_result
23
+ return nil unless result.respond_to?(:run_time)
24
+
25
+ (result.run_time * 1000).round
26
+ end
27
+
28
+ def timestamp
29
+ Time.now.utc.iso8601(3)
30
+ end
31
+
32
+ def truncate(str, max)
33
+ str = str.to_s
34
+ return str if str.length <= max
35
+
36
+ "#{str[0...max]}...[truncated #{str.length - max} chars]"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'ai_formatter/formatter'
4
+
5
+ module RSpec
6
+ module AiFormatter
7
+ VERSION = '0.1.0'
8
+
9
+ class Error < StandardError; end
10
+ end
11
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'ai_formatter'
metadata ADDED
@@ -0,0 +1,75 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rspec-ai-formatter
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - SK
8
+ autorequire: rspec_ai_formatter
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-02-25 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec-core
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '3.10'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '3.10'
27
+ description: |
28
+ RSpec formatter optimized for AI agents and CI systems.
29
+ Outputs compact NDJSON with file references instead of verbose text.
30
+ Supports log splitting, error deduplication, and context-efficient reporting.
31
+ email:
32
+ - konstantin.suhov@gmail.com
33
+ executables:
34
+ - rspec-ai
35
+ - rspec-ai-merge
36
+ extensions: []
37
+ extra_rdoc_files: []
38
+ files:
39
+ - LICENSE
40
+ - README.md
41
+ - bin/rspec-ai
42
+ - bin/rspec-ai-merge
43
+ - lib/rspec/ai_formatter.rb
44
+ - lib/rspec/ai_formatter/error_formatter.rb
45
+ - lib/rspec/ai_formatter/formatter.rb
46
+ - lib/rspec/ai_formatter/location_helper.rb
47
+ - lib/rspec/ai_formatter/log_writer.rb
48
+ - lib/rspec/ai_formatter/output_helper.rb
49
+ - lib/rspec_ai_formatter.rb
50
+ homepage: https://github.com/sciencejet/rspec-ai-formatter
51
+ licenses:
52
+ - MIT
53
+ metadata:
54
+ allowed_push_host: https://rubygems.org
55
+ rubygems_mfa_required: 'true'
56
+ post_install_message:
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: 3.0.0
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 3.5.22
72
+ signing_key:
73
+ specification_version: 4
74
+ summary: AI-friendly RSpec formatter with minimal token usage
75
+ test_files: []