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 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
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](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,6 @@
1
+ module SimcovAiFormatter
2
+ class Error < StandardError; end
3
+ class ResultsetNotFound < Error; end
4
+ class InvalidResultset < Error; end
5
+ class SuiteNotFound < Error; end
6
+ end
@@ -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,13 @@
1
+ require "json"
2
+
3
+ module SimcovAiFormatter
4
+ class Renderer
5
+ def initialize(pretty: false)
6
+ @pretty = pretty
7
+ end
8
+
9
+ def render(hash)
10
+ @pretty ? JSON.pretty_generate(hash) : JSON.generate(hash)
11
+ end
12
+ end
13
+ 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,3 @@
1
+ module SimcovAiFormatter
2
+ VERSION = "0.1.0"
3
+ 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: []