simcov-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 +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE.txt +21 -0
- data/README.md +165 -0
- data/lib/simcov_ai_formatter/errors.rb +6 -0
- data/lib/simcov_ai_formatter/formatter.rb +155 -0
- data/lib/simcov_ai_formatter/renderer.rb +13 -0
- data/lib/simcov_ai_formatter/resultset_loader.rb +59 -0
- data/lib/simcov_ai_formatter/simple_cov_formatter.rb +77 -0
- data/lib/simcov_ai_formatter/source_reader.rb +49 -0
- data/lib/simcov_ai_formatter/suite_merger.rb +100 -0
- data/lib/simcov_ai_formatter/version.rb +3 -0
- data/lib/simcov_ai_formatter.rb +35 -0
- metadata +86 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 9ad0a4b51fd6e7a743e2cb7238b7797d2bad733a23bac120261028ac7ed5e8b9
|
|
4
|
+
data.tar.gz: a39467b1e1fdcf351922ceeb8718ddfbb77ecd54f689285c04fe170467998667
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4964b080d74c7d4f8e8e3f76bff4d4f668916d9adcd219e3c952295c9c27316d9a4599b43c83f52bca46b0e6c0b211397fbeef382573b3fab5b5e623b5527d16
|
|
7
|
+
data.tar.gz: 5ac52c9e2307aaee72ced97754a8daaed9a6bc0d5219466ca8c36f5059effab24c5bedce924964aea0407aa925e5dbbbd3da65a789619d63beef570a5136a672
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-05-03
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Initial release.
|
|
7
|
+
- Converts SimpleCov's `.resultset.json` into AI-friendly JSON.
|
|
8
|
+
- Per-file and project-wide summaries (line counts, coverage percentage).
|
|
9
|
+
- `uncovered_ranges` (consecutive uncovered lines collapsed into ranges) and flat `uncovered_lines`.
|
|
10
|
+
- `with_source: true, context: N` embeds source lines around uncovered ranges.
|
|
11
|
+
- Multiple suites are merged via `max(hit)`, or a single suite can be selected with `suite: "NAME"`.
|
|
12
|
+
- Public Ruby API: `SimcovAiFormatter.format(path, **opts)` returns a Hash.
|
|
13
|
+
- SimpleCov formatter plugin (`SimcovAiFormatter::SimpleCovFormatter`) — emit the AI-friendly JSON automatically during a SimpleCov run. Configure via class-level attributes (`with_source`, `context`, `pretty`, `output_path`).
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yuhi Sato
|
|
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,165 @@
|
|
|
1
|
+
# simcov-ai-formatter
|
|
2
|
+
|
|
3
|
+
[](https://deepwiki.com/Yuhi-Sato/simcov-ai-formatter)
|
|
4
|
+
|
|
5
|
+
A SimpleCov formatter that emits a JSON format optimized for AI / LLM consumption — per-file summaries, uncovered ranges, and optional source snippets.
|
|
6
|
+
|
|
7
|
+
## Why this exists
|
|
8
|
+
|
|
9
|
+
SimpleCov's `.resultset.json` records coverage as position-dependent arrays:
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{ "RSpec": { "coverage": { "/abs/path/foo.rb": { "lines": [null, 1, 0, 0, null, 5] } } } }
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`lines` is a 1-indexed hit-count array (`null` = irrelevant, `0` = uncovered, `Integer >= 1` = hit count).
|
|
16
|
+
For an LLM to figure out "which file has which uncovered lines" from this shape, it has to scan arrays, compute summaries, and normalize paths every single time.
|
|
17
|
+
|
|
18
|
+
This gem does that preprocessing **once, deterministically, and in a token-efficient way**.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
gem install simcov-ai-formatter
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or in `Gemfile`:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
group :development, :test do
|
|
30
|
+
gem "simcov-ai-formatter"
|
|
31
|
+
end
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Output schema
|
|
35
|
+
|
|
36
|
+
### Default
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"schema_version": 1,
|
|
41
|
+
"suite": "RSpec",
|
|
42
|
+
"root": "/Users/me/proj",
|
|
43
|
+
"summary": {
|
|
44
|
+
"total_files": 42,
|
|
45
|
+
"relevant_lines": 1830,
|
|
46
|
+
"covered_lines": 1644,
|
|
47
|
+
"missed_lines": 186,
|
|
48
|
+
"coverage_percentage": 89.84
|
|
49
|
+
},
|
|
50
|
+
"files": {
|
|
51
|
+
"lib/foo.rb": {
|
|
52
|
+
"relevant_lines": 50,
|
|
53
|
+
"covered_lines": 45,
|
|
54
|
+
"missed_lines": 5,
|
|
55
|
+
"coverage_percentage": 90.0,
|
|
56
|
+
"uncovered_ranges": [
|
|
57
|
+
{ "start": 12, "end": 14 },
|
|
58
|
+
{ "start": 88, "end": 88 }
|
|
59
|
+
],
|
|
60
|
+
"uncovered_lines": [12, 13, 14, 88]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
When multiple suites are merged, a top-level `"suites_merged": ["RSpec", "Cucumber"]` is emitted.
|
|
67
|
+
|
|
68
|
+
### With `with_source: true, context: 2`
|
|
69
|
+
|
|
70
|
+
Each `uncovered_ranges` entry gains a `source` array:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"start": 12, "end": 14,
|
|
75
|
+
"source": [
|
|
76
|
+
{ "line": 10, "text": "def parse(input)", "covered": true },
|
|
77
|
+
{ "line": 11, "text": " return nil if input.nil?", "covered": true },
|
|
78
|
+
{ "line": 12, "text": " raise ArgumentError", "covered": false },
|
|
79
|
+
{ "line": 13, "text": " log_error(input)", "covered": false },
|
|
80
|
+
{ "line": 14, "text": " nil", "covered": false },
|
|
81
|
+
{ "line": 15, "text": "end", "covered": true }
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
If the source file is missing, the range gets `"source": null, "source_error": "missing"` and a warning summary is emitted to stderr (processing continues).
|
|
87
|
+
|
|
88
|
+
### Branch coverage
|
|
89
|
+
|
|
90
|
+
If `branches` exists in the resultset, each file gets a `branches_raw` field containing the resultset's original key shape **unchanged**.
|
|
91
|
+
Structured form (`{ type: "if", line: ..., then_hits: ..., else_hits: ... }`) is planned for v0.2.0.
|
|
92
|
+
|
|
93
|
+
### Schema details
|
|
94
|
+
|
|
95
|
+
- `relevant_lines` excludes `null` entries (matches SimpleCov convention).
|
|
96
|
+
- Files with all `null` lines (comments / blanks only) report `relevant_lines: 0, coverage_percentage: 100.0` and are excluded from the project-level denominator.
|
|
97
|
+
- `coverage_percentage` is rounded to 2 decimal places.
|
|
98
|
+
- Files outside `root` (e.g. third-party gems) are kept under keys of the form `!abs:/abs/path`.
|
|
99
|
+
- The output contains no timestamp — the JSON is deterministic.
|
|
100
|
+
|
|
101
|
+
## As a SimpleCov formatter
|
|
102
|
+
|
|
103
|
+
Plug `simcov-ai-formatter` into SimpleCov's formatter pipeline to emit the AI-friendly JSON automatically as part of your test run.
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
# spec/spec_helper.rb (or .simplecov)
|
|
107
|
+
require "simplecov"
|
|
108
|
+
require "simcov_ai_formatter/simple_cov_formatter"
|
|
109
|
+
|
|
110
|
+
SimpleCov.start do
|
|
111
|
+
# ... your usual SimpleCov config
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Replace the default formatter
|
|
115
|
+
SimpleCov.formatter = SimcovAiFormatter::SimpleCovFormatter
|
|
116
|
+
|
|
117
|
+
# Or run alongside the HTML formatter
|
|
118
|
+
SimpleCov.formatters = SimpleCov::Formatter::MultiFormatter.create([
|
|
119
|
+
SimpleCov::Formatter::HTMLFormatter,
|
|
120
|
+
SimcovAiFormatter::SimpleCovFormatter
|
|
121
|
+
])
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
After tests finish, the AI-friendly JSON is written to `coverage/.resultset.ai.json` (or wherever `SimpleCov.coverage_path` points).
|
|
125
|
+
|
|
126
|
+
### Configuration
|
|
127
|
+
|
|
128
|
+
Set class-level attributes before `SimpleCov.start`:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
SimcovAiFormatter::SimpleCovFormatter.with_source = true
|
|
132
|
+
SimcovAiFormatter::SimpleCovFormatter.context = 3
|
|
133
|
+
SimcovAiFormatter::SimpleCovFormatter.pretty = false
|
|
134
|
+
SimcovAiFormatter::SimpleCovFormatter.output_path = "tmp/coverage.ai.json" # nil = coverage/.resultset.ai.json
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Programmatic use
|
|
138
|
+
|
|
139
|
+
The same logic is callable from Ruby:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
require "simcov_ai_formatter"
|
|
143
|
+
|
|
144
|
+
result = SimcovAiFormatter.format(
|
|
145
|
+
"coverage/.resultset.json",
|
|
146
|
+
root: Dir.pwd,
|
|
147
|
+
with_source: true,
|
|
148
|
+
context: 2
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# result is a Hash
|
|
152
|
+
puts result["summary"]["coverage_percentage"]
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Development
|
|
156
|
+
|
|
157
|
+
```sh
|
|
158
|
+
bundle install
|
|
159
|
+
bundle exec rake test # run all tests
|
|
160
|
+
UPDATE_GOLDEN=1 bundle exec rake test # regenerate golden files
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
MIT
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
require "pathname"
|
|
2
|
+
|
|
3
|
+
module SimcovAiFormatter
|
|
4
|
+
# Transforms SimpleCov's coverage hash into the AI-friendly Hash shape.
|
|
5
|
+
#
|
|
6
|
+
# Input: { "<abs_path>" => { "lines" => [null|0|N, ...], "branches" => {...}? } }
|
|
7
|
+
# Output: see README for the full schema.
|
|
8
|
+
class Formatter
|
|
9
|
+
def initialize(coverage:, suite:, root:, suites_merged: nil, with_source: false, context: 2, source_reader: nil)
|
|
10
|
+
@coverage = coverage
|
|
11
|
+
@suite = suite
|
|
12
|
+
@suites_merged = suites_merged
|
|
13
|
+
@root = Pathname.new(root).expand_path
|
|
14
|
+
@with_source = with_source
|
|
15
|
+
@context = context
|
|
16
|
+
@source_reader = source_reader
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
files = build_files
|
|
21
|
+
project_summary = aggregate_summary(files)
|
|
22
|
+
|
|
23
|
+
result = {
|
|
24
|
+
"schema_version" => 1,
|
|
25
|
+
"suite" => @suite,
|
|
26
|
+
"root" => @root.to_s,
|
|
27
|
+
"summary" => project_summary,
|
|
28
|
+
"files" => files
|
|
29
|
+
}
|
|
30
|
+
if @suites_merged && @suites_merged.size > 1
|
|
31
|
+
result["suites_merged"] = @suites_merged
|
|
32
|
+
end
|
|
33
|
+
result
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def build_files
|
|
39
|
+
sorted = @coverage.keys.sort
|
|
40
|
+
sorted.each_with_object({}) do |abs_path, acc|
|
|
41
|
+
entry = @coverage[abs_path]
|
|
42
|
+
rel = relativize(abs_path)
|
|
43
|
+
acc[rel] = build_file_entry(abs_path, entry)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_file_entry(abs_path, entry)
|
|
48
|
+
lines = entry["lines"] || []
|
|
49
|
+
relevant = lines.count { |v| !v.nil? }
|
|
50
|
+
covered = lines.count { |v| v.is_a?(Integer) && v.positive? }
|
|
51
|
+
uncovered_lines = find_uncovered_lines(lines)
|
|
52
|
+
uncovered_ranges = collapse_ranges(uncovered_lines)
|
|
53
|
+
|
|
54
|
+
file_entry = {
|
|
55
|
+
"relevant_lines" => relevant,
|
|
56
|
+
"covered_lines" => covered,
|
|
57
|
+
"missed_lines" => relevant - covered,
|
|
58
|
+
"coverage_percentage" => percentage(covered, relevant),
|
|
59
|
+
"uncovered_ranges" => uncovered_ranges,
|
|
60
|
+
"uncovered_lines" => uncovered_lines
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
with_source_attached = @with_source && @source_reader && !uncovered_ranges.empty?
|
|
64
|
+
if with_source_attached
|
|
65
|
+
file_entry["uncovered_ranges"] = uncovered_ranges.map do |range|
|
|
66
|
+
attach_source(abs_path, range, lines)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
attach_branches(file_entry, entry)
|
|
71
|
+
file_entry
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def find_uncovered_lines(lines)
|
|
75
|
+
lines.each_with_index.filter_map { |v, i| i + 1 if v.is_a?(Integer) && v.zero? }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def attach_branches(file_entry, entry)
|
|
79
|
+
branches = entry["branches"]
|
|
80
|
+
file_entry["branches_raw"] = branches if branches.is_a?(Hash) && !branches.empty?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def attach_source(abs_path, range, lines)
|
|
84
|
+
start_line = [range["start"] - @context, 1].max
|
|
85
|
+
end_line = range["end"] + @context
|
|
86
|
+
snippet = @source_reader.read(abs_path, start_line, end_line)
|
|
87
|
+
|
|
88
|
+
if snippet.nil?
|
|
89
|
+
range.merge("source" => nil, "source_error" => "missing")
|
|
90
|
+
else
|
|
91
|
+
source_array = snippet.map do |line_no, text|
|
|
92
|
+
{
|
|
93
|
+
"line" => line_no,
|
|
94
|
+
"text" => text,
|
|
95
|
+
"covered" => covered_for(lines[line_no - 1])
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
range.merge("source" => source_array)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def covered_for(value)
|
|
103
|
+
return nil if value.nil?
|
|
104
|
+
return false if value.is_a?(Integer) && value.zero?
|
|
105
|
+
true
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def collapse_ranges(line_numbers)
|
|
109
|
+
ranges = []
|
|
110
|
+
line_numbers.each do |n|
|
|
111
|
+
if !ranges.empty? && ranges.last["end"] == n - 1
|
|
112
|
+
ranges.last["end"] = n
|
|
113
|
+
else
|
|
114
|
+
ranges << { "start" => n, "end" => n }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
ranges
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def aggregate_summary(files)
|
|
121
|
+
values = files.values
|
|
122
|
+
total_relevant = values.sum { |f| f["relevant_lines"] }
|
|
123
|
+
total_covered = values.sum { |f| f["covered_lines"] }
|
|
124
|
+
total_missed = values.sum { |f| f["missed_lines"] }
|
|
125
|
+
{
|
|
126
|
+
"total_files" => files.size,
|
|
127
|
+
"relevant_lines" => total_relevant,
|
|
128
|
+
"covered_lines" => total_covered,
|
|
129
|
+
"missed_lines" => total_missed,
|
|
130
|
+
"coverage_percentage" => percentage(total_covered, total_relevant)
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def percentage(covered, relevant)
|
|
135
|
+
return 100.0 if relevant.zero?
|
|
136
|
+
(covered.to_f / relevant * 100).round(2)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def relativize(abs_path)
|
|
140
|
+
pathname = Pathname.new(abs_path)
|
|
141
|
+
return "!abs:#{abs_path}" unless pathname.absolute?
|
|
142
|
+
|
|
143
|
+
begin
|
|
144
|
+
relative = pathname.relative_path_from(@root).to_s
|
|
145
|
+
if relative.start_with?("..")
|
|
146
|
+
"!abs:#{abs_path}"
|
|
147
|
+
else
|
|
148
|
+
relative
|
|
149
|
+
end
|
|
150
|
+
rescue ArgumentError
|
|
151
|
+
"!abs:#{abs_path}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module SimcovAiFormatter
|
|
4
|
+
# Loads SimpleCov's .resultset.json, validates its shape, and returns the parsed Hash.
|
|
5
|
+
#
|
|
6
|
+
# Expected shape:
|
|
7
|
+
# {
|
|
8
|
+
# "<suite_name>" => {
|
|
9
|
+
# "coverage" => { "<abs_path>" => { "lines" => [...], "branches" => {...} } },
|
|
10
|
+
# "timestamp" => Integer
|
|
11
|
+
# },
|
|
12
|
+
# ...
|
|
13
|
+
# }
|
|
14
|
+
class ResultsetLoader
|
|
15
|
+
def initialize(path)
|
|
16
|
+
@path = path
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def load
|
|
20
|
+
raw = read_json
|
|
21
|
+
validate!(raw)
|
|
22
|
+
raw
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def read_json
|
|
28
|
+
unless File.exist?(@path)
|
|
29
|
+
raise ResultsetNotFound, "resultset.json not found: #{@path}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
JSON.parse(File.read(@path))
|
|
33
|
+
rescue JSON::ParserError => e
|
|
34
|
+
raise InvalidResultset, "invalid JSON at #{@path}: #{e.message}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def validate!(raw)
|
|
38
|
+
unless raw.is_a?(Hash) && !raw.empty?
|
|
39
|
+
raise InvalidResultset, "expected non-empty top-level hash at #{@path}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
raw.each { |suite, body| validate_suite!(suite, body) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate_suite!(suite, body)
|
|
46
|
+
unless body.is_a?(Hash) && body["coverage"].is_a?(Hash)
|
|
47
|
+
raise InvalidResultset, "suite #{suite.inspect} missing 'coverage' hash"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
body["coverage"].each { |file, entry| validate_file!(suite, file, entry) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate_file!(suite, file, entry)
|
|
54
|
+
unless entry.is_a?(Hash) && entry["lines"].is_a?(Array)
|
|
55
|
+
raise InvalidResultset, "file #{file.inspect} in suite #{suite.inspect} missing 'lines' array"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
require_relative "../simcov_ai_formatter"
|
|
2
|
+
|
|
3
|
+
module SimcovAiFormatter
|
|
4
|
+
# SimpleCov plugin formatter — emits the AI-friendly JSON during a SimpleCov
|
|
5
|
+
# run, without requiring a separate `simcov-ai-formatter` invocation.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# require "simcov_ai_formatter/simple_cov_formatter"
|
|
9
|
+
#
|
|
10
|
+
# SimpleCov.formatter = SimcovAiFormatter::SimpleCovFormatter
|
|
11
|
+
#
|
|
12
|
+
# # or alongside other formatters:
|
|
13
|
+
# SimpleCov.formatters = SimpleCov::Formatter::MultiFormatter.create([
|
|
14
|
+
# SimpleCov::Formatter::HTMLFormatter,
|
|
15
|
+
# SimcovAiFormatter::SimpleCovFormatter
|
|
16
|
+
# ])
|
|
17
|
+
#
|
|
18
|
+
# Configure (before SimpleCov.start):
|
|
19
|
+
# SimcovAiFormatter::SimpleCovFormatter.with_source = true
|
|
20
|
+
# SimcovAiFormatter::SimpleCovFormatter.context = 3
|
|
21
|
+
# SimcovAiFormatter::SimpleCovFormatter.pretty = true
|
|
22
|
+
# SimcovAiFormatter::SimpleCovFormatter.output_path = "tmp/coverage.ai.json"
|
|
23
|
+
class SimpleCovFormatter
|
|
24
|
+
DEFAULT_OUTPUT_FILENAME = ".resultset.ai.json".freeze
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
attr_accessor :with_source, :context, :pretty, :output_path
|
|
28
|
+
end
|
|
29
|
+
self.with_source = false
|
|
30
|
+
self.context = 2
|
|
31
|
+
self.pretty = false
|
|
32
|
+
self.output_path = nil
|
|
33
|
+
|
|
34
|
+
# SimpleCov calls this with a SimpleCov::Result instance.
|
|
35
|
+
def format(result)
|
|
36
|
+
with_source = self.class.with_source
|
|
37
|
+
context = self.class.context
|
|
38
|
+
pretty = self.class.pretty
|
|
39
|
+
|
|
40
|
+
raw = result.to_hash
|
|
41
|
+
selected_suite, coverage = SuiteMerger.new(raw).select
|
|
42
|
+
|
|
43
|
+
source_reader = with_source ? SourceReader.new(warnings: $stderr) : nil
|
|
44
|
+
formatted = Formatter.new(
|
|
45
|
+
coverage: coverage,
|
|
46
|
+
suite: selected_suite,
|
|
47
|
+
suites_merged: nil,
|
|
48
|
+
root: simplecov_root,
|
|
49
|
+
with_source: with_source,
|
|
50
|
+
context: context,
|
|
51
|
+
source_reader: source_reader
|
|
52
|
+
).call
|
|
53
|
+
|
|
54
|
+
json = Renderer.new(pretty: pretty).render(formatted)
|
|
55
|
+
target = resolve_output_path
|
|
56
|
+
File.write(target, json + "\n")
|
|
57
|
+
source_reader&.report_missing
|
|
58
|
+
|
|
59
|
+
puts "Coverage AI report generated to #{target}"
|
|
60
|
+
target
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def resolve_output_path
|
|
66
|
+
self.class.output_path || File.join(simplecov_coverage_path, DEFAULT_OUTPUT_FILENAME)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def simplecov_coverage_path
|
|
70
|
+
defined?(::SimpleCov) ? ::SimpleCov.coverage_path : "coverage"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def simplecov_root
|
|
74
|
+
defined?(::SimpleCov) ? ::SimpleCov.root : Dir.pwd
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module SimcovAiFormatter
|
|
2
|
+
# Reads source files by 1-indexed line numbers with random access.
|
|
3
|
+
# Files are cached as line arrays after the first read.
|
|
4
|
+
# Missing files and unknown encodings never crash the caller.
|
|
5
|
+
class SourceReader
|
|
6
|
+
def initialize(warnings: nil)
|
|
7
|
+
@cache = {}
|
|
8
|
+
@missing = []
|
|
9
|
+
@warnings = warnings
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @return [Array<[Integer, String]>, nil] line-number/text pairs, or nil if the file is missing
|
|
13
|
+
def read(path, start_line, end_line)
|
|
14
|
+
lines = lines_for(path)
|
|
15
|
+
return nil if lines.nil?
|
|
16
|
+
|
|
17
|
+
from = [start_line, 1].max
|
|
18
|
+
to = [end_line, lines.size].min
|
|
19
|
+
(from..to).map { |n| [n, lines[n - 1]] }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def report_missing
|
|
23
|
+
return if @warnings.nil? || @missing.empty?
|
|
24
|
+
@warnings.puts("simcov-ai-formatter: warning: #{@missing.size} source file(s) not found:")
|
|
25
|
+
@missing.first(5).each { |p| @warnings.puts(" - #{p}") }
|
|
26
|
+
@warnings.puts(" ...") if @missing.size > 5
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def lines_for(path)
|
|
32
|
+
return @cache[path] if @cache.key?(path)
|
|
33
|
+
|
|
34
|
+
unless File.exist?(path)
|
|
35
|
+
@missing << path
|
|
36
|
+
return (@cache[path] = nil)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
@cache[path] = read_lines_safely(path)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def read_lines_safely(path)
|
|
43
|
+
raw = File.read(path, mode: "rb")
|
|
44
|
+
text = raw.force_encoding("UTF-8")
|
|
45
|
+
text = text.scrub("?") unless text.valid_encoding?
|
|
46
|
+
text.split(/\r\n|\r|\n/, -1).tap { |arr| arr.pop if arr.last == "" }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
module SimcovAiFormatter
|
|
2
|
+
# Merges multiple suites in a resultset into a single coverage Hash,
|
|
3
|
+
# or selects one when --suite NAME is given.
|
|
4
|
+
#
|
|
5
|
+
# Line merge rules per (file, index):
|
|
6
|
+
# - one nil and one Integer → take the Integer
|
|
7
|
+
# - both Integer → take max(hit)
|
|
8
|
+
# - both nil → nil
|
|
9
|
+
# Branches: hit counts are summed for matching keys; missing keys are added.
|
|
10
|
+
class SuiteMerger
|
|
11
|
+
def initialize(resultset, suite: nil)
|
|
12
|
+
@resultset = resultset
|
|
13
|
+
@suite = suite
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [Array(String, Hash)] the selected suite label and the merged coverage hash
|
|
17
|
+
def select
|
|
18
|
+
return select_specified_suite if @suite
|
|
19
|
+
return select_sole_suite if @resultset.size == 1
|
|
20
|
+
select_merged_suites
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def select_specified_suite
|
|
26
|
+
body = @resultset[@suite]
|
|
27
|
+
unless body
|
|
28
|
+
available = @resultset.keys.join(", ")
|
|
29
|
+
raise SuiteNotFound, "suite #{@suite.inspect} not in resultset (available: #{available})"
|
|
30
|
+
end
|
|
31
|
+
[@suite, body["coverage"]]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def select_sole_suite
|
|
35
|
+
suite, body = @resultset.first
|
|
36
|
+
[suite, body["coverage"]]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def select_merged_suites
|
|
40
|
+
["merged", merge_all]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def merge_all
|
|
44
|
+
merged = {}
|
|
45
|
+
@resultset.each_value do |body|
|
|
46
|
+
body["coverage"].each do |file, entry|
|
|
47
|
+
if merged.key?(file)
|
|
48
|
+
merged[file] = merge_entries(merged[file], entry)
|
|
49
|
+
else
|
|
50
|
+
merged[file] = deep_dup(entry)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
merged
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def merge_entries(a, b)
|
|
58
|
+
lines_a = a["lines"]
|
|
59
|
+
lines_b = b["lines"]
|
|
60
|
+
length = [lines_a.size, lines_b.size].max
|
|
61
|
+
merged_lines = Array.new(length) do |i|
|
|
62
|
+
merge_hit(lines_a[i], lines_b[i])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
result = { "lines" => merged_lines }
|
|
66
|
+
branches_a = a["branches"]
|
|
67
|
+
branches_b = b["branches"]
|
|
68
|
+
if branches_a || branches_b
|
|
69
|
+
result["branches"] = merge_branches(branches_a, branches_b)
|
|
70
|
+
end
|
|
71
|
+
result
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def merge_hit(x, y)
|
|
75
|
+
return y if x.nil?
|
|
76
|
+
return x if y.nil?
|
|
77
|
+
[x, y].max
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def merge_branches(a, b)
|
|
81
|
+
a ||= {}
|
|
82
|
+
b ||= {}
|
|
83
|
+
keys = (a.keys | b.keys)
|
|
84
|
+
keys.each_with_object({}) do |outer_key, acc|
|
|
85
|
+
acc[outer_key] = sum_branch_hits(a[outer_key] || {}, b[outer_key] || {})
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def sum_branch_hits(inner_a, inner_b)
|
|
90
|
+
inner_keys = (inner_a.keys | inner_b.keys)
|
|
91
|
+
inner_keys.each_with_object({}) do |k, sub|
|
|
92
|
+
sub[k] = (inner_a[k] || 0) + (inner_b[k] || 0)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def deep_dup(entry)
|
|
97
|
+
JSON.parse(JSON.generate(entry))
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
require_relative "simcov_ai_formatter/version"
|
|
2
|
+
require_relative "simcov_ai_formatter/errors"
|
|
3
|
+
require_relative "simcov_ai_formatter/resultset_loader"
|
|
4
|
+
require_relative "simcov_ai_formatter/suite_merger"
|
|
5
|
+
require_relative "simcov_ai_formatter/formatter"
|
|
6
|
+
require_relative "simcov_ai_formatter/source_reader"
|
|
7
|
+
require_relative "simcov_ai_formatter/renderer"
|
|
8
|
+
|
|
9
|
+
module SimcovAiFormatter
|
|
10
|
+
DEFAULT_RESULTSET_PATH = "coverage/.resultset.json".freeze
|
|
11
|
+
|
|
12
|
+
# Public API: convert resultset.json into an AI-friendly Hash.
|
|
13
|
+
#
|
|
14
|
+
# @param path [String] path to resultset.json
|
|
15
|
+
# @param root [String] base directory for relative paths (default: Dir.pwd)
|
|
16
|
+
# @param suite [String, nil] pick a single suite; if nil, merge all suites via max(hit)
|
|
17
|
+
# @param with_source [Boolean] embed source lines around uncovered ranges
|
|
18
|
+
# @param context [Integer] lines of context when with_source is true
|
|
19
|
+
# @param source_warnings [IO, nil] destination for source-missing warnings
|
|
20
|
+
# @return [Hash]
|
|
21
|
+
def self.format(path, root: Dir.pwd, suite: nil, with_source: false, context: 2, source_warnings: nil)
|
|
22
|
+
raw = ResultsetLoader.new(path).load
|
|
23
|
+
selected_suite, coverage = SuiteMerger.new(raw, suite: suite).select
|
|
24
|
+
source_reader = with_source ? SourceReader.new(warnings: source_warnings) : nil
|
|
25
|
+
Formatter.new(
|
|
26
|
+
coverage: coverage,
|
|
27
|
+
suite: selected_suite,
|
|
28
|
+
suites_merged: suite.nil? ? raw.keys : nil,
|
|
29
|
+
root: root,
|
|
30
|
+
with_source: with_source,
|
|
31
|
+
context: context,
|
|
32
|
+
source_reader: source_reader
|
|
33
|
+
).call
|
|
34
|
+
end
|
|
35
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: simcov-ai-formatter
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yuhi Sato
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: minitest
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '5.0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '5.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rake
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '13.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '13.0'
|
|
40
|
+
description: Converts SimpleCov coverage data into a JSON format optimized for LLM/AI
|
|
41
|
+
consumption — per-file summaries, uncovered ranges, optional source snippets. Works
|
|
42
|
+
as a SimpleCov formatter plugin (auto-emit during test runs) or via a programmatic
|
|
43
|
+
Ruby API.
|
|
44
|
+
email:
|
|
45
|
+
- yuhi120101@gmail.com
|
|
46
|
+
executables: []
|
|
47
|
+
extensions: []
|
|
48
|
+
extra_rdoc_files: []
|
|
49
|
+
files:
|
|
50
|
+
- CHANGELOG.md
|
|
51
|
+
- LICENSE.txt
|
|
52
|
+
- README.md
|
|
53
|
+
- lib/simcov_ai_formatter.rb
|
|
54
|
+
- lib/simcov_ai_formatter/errors.rb
|
|
55
|
+
- lib/simcov_ai_formatter/formatter.rb
|
|
56
|
+
- lib/simcov_ai_formatter/renderer.rb
|
|
57
|
+
- lib/simcov_ai_formatter/resultset_loader.rb
|
|
58
|
+
- lib/simcov_ai_formatter/simple_cov_formatter.rb
|
|
59
|
+
- lib/simcov_ai_formatter/source_reader.rb
|
|
60
|
+
- lib/simcov_ai_formatter/suite_merger.rb
|
|
61
|
+
- lib/simcov_ai_formatter/version.rb
|
|
62
|
+
homepage: https://github.com/y-sato/simcov-ai-formatter
|
|
63
|
+
licenses:
|
|
64
|
+
- MIT
|
|
65
|
+
metadata:
|
|
66
|
+
homepage_uri: https://github.com/y-sato/simcov-ai-formatter
|
|
67
|
+
source_code_uri: https://github.com/y-sato/simcov-ai-formatter
|
|
68
|
+
changelog_uri: https://github.com/y-sato/simcov-ai-formatter/blob/main/CHANGELOG.md
|
|
69
|
+
rdoc_options: []
|
|
70
|
+
require_paths:
|
|
71
|
+
- lib
|
|
72
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
73
|
+
requirements:
|
|
74
|
+
- - ">="
|
|
75
|
+
- !ruby/object:Gem::Version
|
|
76
|
+
version: '3.2'
|
|
77
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0'
|
|
82
|
+
requirements: []
|
|
83
|
+
rubygems_version: 3.6.9
|
|
84
|
+
specification_version: 4
|
|
85
|
+
summary: Format SimpleCov coverage data into AI-friendly JSON
|
|
86
|
+
test_files: []
|