simplecov-ai 0.10.1 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 576c5e4cf35ca947577427b1da22a95eb70ec3318cae00c73d555e7322688ad4
4
- data.tar.gz: 92db8df633735a42827d3e62f69bd7dbc9c03de20758d12b0e7dec32dbd87165
3
+ metadata.gz: 3b180d86c54ffab95578a779a8765ec546495b6bc3bd1e353674cf81697b9afe
4
+ data.tar.gz: f86ba3e7b0b6e5b3ded6c40799424fded61d2f2d980cb92e784685d340ac31f6
5
5
  SHA512:
6
- metadata.gz: 01fd803f6a14fb042221d2aa2964c257506c3244ebd1810af962d97ea7dc9e8271abfe080276bbd34d1739b651e556fdd9d65abd53dfaab30e63410dbd1bdc6f
7
- data.tar.gz: 95217a0e33e2dcc13e7a155bc5f4a8f889922279279173552d61783ece44410acc76134d5e0729ef0fdf4843a21c886b81d33173719abaf16adc07a78101e21a
6
+ metadata.gz: 4cd0db7e047ccb21fdfc6ccf1c4e2855542c209051b6a0f663ec8037deddf6a4b8481865c492343ccee81f9ed42e5b937d0286b5d5f18dec0e07897aaf6cd902
7
+ data.tar.gz: 92f1698f205c2ff941287b45c53290e52306e8e9b1ae3916e84477917c8045d3ed17c5a2653c33a4ea842e837b9c96521a3bb85bc44799e8135962ae65346f1f
checksums.yaml.gz.sig CHANGED
Binary file
data/README.md CHANGED
@@ -1 +1,89 @@
1
1
  # simplecov-ai
2
+
3
+ A custom `SimpleCov::Formatter` designed explicitly for consumption by Large Language Models (LLMs) and autonomous engineering agents.
4
+
5
+ Standard coverage reporters generate massive HTML files or exhaustive JSON/console outputs detailing every line number. This overwhelms strict LLM token constraints and relies on highly volatile line numbers. `simplecov-ai` solves this by generating a concise, structurally optimized **Markdown document** containing only the exact missing semantic coverage blocks.
6
+
7
+ ## Why use simplecov-ai?
8
+
9
+ - **Semantic Resolution:** Instead of volatile line numbers, missing coverage is resolved via Abstract Syntax Tree (AST) mapping into immutable semantic groupings (e.g., Class, Module, Instance Method).
10
+ - **Maximum Token Conservation:** Fully covered files are completely omitted. If the report exceeds size limits, it safely truncates the output prioritizing the lowest-coverage files.
11
+ - **Actionable Delta Directives:** Missing branches and lines are mapped directly to method names, letting the AI instantly search the code and write targeted specs.
12
+ - **Directive Auditing:** Explicitly reports `:nocov:` bypasses, ensuring artificial metric inflation is completely transparent to the reviewing AI.
13
+
14
+ ## Installation
15
+
16
+ Add this line to your application's `Gemfile` strictly in the `test` group:
17
+
18
+ ```ruby
19
+ group :test do
20
+ gem 'simplecov'
21
+ gem 'simplecov-ai', require: false
22
+ end
23
+ ```
24
+
25
+ ## Usage & Configuration
26
+
27
+ Require and configure the formatter in your test helper (`spec_helper.rb` or `test_helper.rb`) after requiring `simplecov`:
28
+
29
+ ```ruby
30
+ require 'simplecov'
31
+ require 'simplecov-ai'
32
+
33
+ # Optional Configuration (defaults shown below):
34
+ SimpleCov::Formatter::AIFormatter.configure do |config|
35
+ config.report_path = 'coverage/ai_report.md' # Output location
36
+ config.max_file_size_kb = 50 # Maximum size (Token Ceiling)
37
+ config.max_snippet_lines = 5 # AST context truncation limit
38
+ config.output_to_console = false # Echo digest to STDOUT
39
+ config.granularity = :fine # :fine (statements) or :coarse (methods)
40
+ config.include_bypasses = true # Audit `:nocov:` ignores
41
+ end
42
+
43
+ SimpleCov.start do
44
+ # Combine with your existing formatters
45
+ SimpleCov.formatters = SimpleCov::Formatter::MultiFormatter.new([
46
+ SimpleCov::Formatter::HTMLFormatter,
47
+ SimpleCov::Formatter::AIFormatter
48
+ ])
49
+ end
50
+ ```
51
+
52
+ ## Example Output
53
+
54
+ The output is written to `coverage/ai_report.md` (or your configured path), perfect for providing directly as context to an LLM:
55
+
56
+ ```md
57
+ # AI Coverage Digest
58
+ **Status:** FAILED
59
+ **Global Line Coverage:** 92.5%
60
+ **Global Branch Coverage:** 88.0%
61
+ **Generated At:** 2026-04-21T23:40:44+09:00 (Local Timezone)
62
+ **Report File Size:** 1.2 kB
63
+
64
+ ## Coverage Deficits
65
+
66
+ ### `lib/my_gem/client.rb`
67
+ - `MyGem::Client#authenticate!`
68
+ - **Branch Deficit:** Missing coverage for conditional evaluation handling `ExpiredTokenError`.
69
+ - `MyGem::Client#initialize`
70
+ - **Line Deficit:** Variable initialization state uncovered.
71
+
72
+ ### `lib/my_gem/parser/processor.rb`
73
+ - `MyGem::Parser::Processor.parse_stream`
74
+ - **Branch Deficit:** Missing coverage for early-exit condition `break if stream.closed?` (Occurrence 1 of 2).
75
+
76
+ ## Ignored Coverage Bypasses
77
+
78
+ ### `lib/my_gem/legacy_handler.rb`
79
+ - `MyGem::LegacyHandler#obsolete_action`
80
+ - **Bypass Present:** Contains `:nocov:` directive artificially ignoring coverage (Occurrence 1 of 1).
81
+ ```
82
+
83
+ ## Error Handling
84
+
85
+ Adhering to fail-fast principles, if the AST parser encounters structurally unparseable Ruby code or corrupt telemetry, it will gracefully degrade or explicitly fail. It will not silently ignore failures or emit corrupted artifacts.
86
+
87
+ ## License
88
+
89
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -78,61 +78,91 @@ module SimpleCov
78
78
  return [] unless node.is_a?(Parser::AST::Node)
79
79
 
80
80
  nodes = T.let([], T::Array[SemanticNode])
81
- current_context = context
81
+ current_context, semantic_node = extract_node_metadata(node, comments, context)
82
+ nodes << semantic_node if semantic_node
82
83
 
84
+ node.children.grep(Parser::AST::Node).each do |child|
85
+ nodes.concat(traverse(child, comments, current_context))
86
+ end
87
+
88
+ nodes
89
+ end
90
+
91
+ private
92
+
93
+ sig do
94
+ params(node: Parser::AST::Node, comments: T::Array[Parser::Source::Comment], context: String)
95
+ .returns([String, T.nilable(SemanticNode)])
96
+ end
97
+ def extract_node_metadata(node, comments, context)
83
98
  case node.type
84
99
  when :class, :module
85
- const_node = T.cast(node.children[0], Parser::AST::Node)
86
- const_node_loc = T.cast(const_node.loc, Parser::Source::Map::Constant)
87
- const_node_name = T.cast(const_node_loc.name, Parser::Source::Range)
88
- name = T.cast(const_node_name.source, String)
89
- current_context = context.empty? ? name : "#{context}::#{name}"
90
- nodes << build_node(node, comments, current_context, node.type.to_s.capitalize)
100
+ extract_class_metadata(node, comments, context)
91
101
  when :def
92
- name = T.cast(node.children.first, Symbol).to_s
93
- current_context = context.empty? ? "##{name}" : "#{context}##{name}"
94
- nodes << build_node(node, comments, current_context, 'Instance Method')
102
+ extract_instance_method_metadata(node, comments, context)
95
103
  when :defs
96
- name = T.cast(node.children[1], Symbol).to_s
97
- current_context = context.empty? ? ".#{name}" : "#{context}.#{name}"
98
- nodes << build_node(node, comments, current_context, 'Singleton Method')
104
+ extract_singleton_method_metadata(node, comments, context)
105
+ else
106
+ [context, nil]
99
107
  end
108
+ end
100
109
 
101
- node.children.each do |child|
102
- case child
103
- when Parser::AST::Node
104
- nodes.concat(traverse(child, comments, current_context))
105
- end
106
- end
110
+ sig do
111
+ params(node: Parser::AST::Node, comments: T::Array[Parser::Source::Comment], context: String)
112
+ .returns([String, T.nilable(SemanticNode)])
113
+ end
114
+ def extract_class_metadata(node, comments, context)
115
+ const_node = T.cast(node.children[0], Parser::AST::Node)
116
+ const_node_name = T.cast(T.cast(const_node.loc, Parser::Source::Map::Constant).name, Parser::Source::Range)
117
+ name = T.cast(const_node_name.source, String)
118
+ ctx = context.empty? ? name : "#{context}::#{name}"
119
+ [ctx, build_node(node, comments, ctx, node.type.to_s.capitalize)]
120
+ end
107
121
 
108
- nodes
122
+ sig do
123
+ params(node: Parser::AST::Node, comments: T::Array[Parser::Source::Comment], context: String)
124
+ .returns([String, T.nilable(SemanticNode)])
125
+ end
126
+ def extract_instance_method_metadata(node, comments, context)
127
+ name = T.cast(node.children.first, Symbol).to_s
128
+ ctx = context.empty? ? "##{name}" : "#{context}##{name}"
129
+ [ctx, build_node(node, comments, ctx, 'Instance Method')]
109
130
  end
110
131
 
111
- private
132
+ sig do
133
+ params(node: Parser::AST::Node, comments: T::Array[Parser::Source::Comment], context: String)
134
+ .returns([String, T.nilable(SemanticNode)])
135
+ end
136
+ def extract_singleton_method_metadata(node, comments, context)
137
+ name = T.cast(node.children[1], Symbol).to_s
138
+ ctx = context.empty? ? ".#{name}" : "#{context}.#{name}"
139
+ [ctx, build_node(node, comments, ctx, 'Singleton Method')]
140
+ end
112
141
 
113
142
  sig do
114
143
  params(node: Parser::AST::Node, comments: T::Array[Parser::Source::Comment], name: String,
115
144
  type: String).returns(SemanticNode)
116
145
  end
117
146
  def build_node(node, comments, name, type)
118
- node_loc = T.cast(node.loc, Parser::Source::Map)
119
- node_line = T.cast(node_loc.line, Integer)
120
- node_last_line = T.cast(node_loc.last_line, Integer)
147
+ loc = T.cast(node.loc, Parser::Source::Map)
148
+ start_ln = T.cast(loc.line, Integer)
149
+ end_ln = T.cast(loc.last_line, Integer)
150
+ bypasses = extract_bypasses(comments, start_ln, end_ln)
151
+ SemanticNode.new(name: name, type: type, start_line: start_ln, end_line: end_ln, bypasses: bypasses)
152
+ end
121
153
 
122
- bypasses = comments.select do |c|
154
+ sig do
155
+ params(comments: T::Array[Parser::Source::Comment], start_line: Integer, end_line: Integer)
156
+ .returns(T::Array[String])
157
+ end
158
+ def extract_bypasses(comments, start_line, end_line)
159
+ matched = comments.select do |c|
123
160
  c_loc = T.cast(c.loc, Parser::Source::Map)
124
161
  c_line = T.cast(c_loc.line, Integer)
125
162
  c_text = T.cast(c.text, String)
126
- c_line >= node_line - 1 && c_line <= node_last_line + 1 && c_text.include?(':nocov:')
127
- end.map { |c| T.cast(c.text, String).strip }
128
-
129
- SemanticNode.new(
130
- name: name,
131
- type: type,
132
- start_line: node_line,
133
- end_line: node_last_line,
134
- bypasses: bypasses
135
- )
163
+ c_line.between?(start_line - 1, end_line + 1) && c_text.include?(':nocov:')
164
+ end
165
+ matched.map { |c| T.cast(c.text, String).strip }
136
166
  end
137
167
  end
138
168
  end
@@ -0,0 +1,64 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module SimpleCov
5
+ module Formatter
6
+ class AIFormatter
7
+ class MarkdownBuilder
8
+ # Scans resolved AST blocks to report explicitly defined coverage ignores (e.g., :nocov:).
9
+ class BypassCompiler
10
+ extend T::Sig
11
+
12
+ sig { params(result: SimpleCov::Result, builder: MarkdownBuilder).void }
13
+ def initialize(result, builder)
14
+ @result = result
15
+ @builder = builder
16
+ end
17
+
18
+ sig { params(buffer: StringIO).void }
19
+ def write_bypasses(buffer)
20
+ bypass_buffer = T.let(StringIO.new, StringIO)
21
+ has_bypasses = compile_all_bypasses(bypass_buffer)
22
+
23
+ return unless has_bypasses
24
+
25
+ buffer.puts "## Ignored Coverage Bypasses\n\n"
26
+ buffer.puts bypass_buffer.string
27
+ end
28
+
29
+ private
30
+
31
+ sig { params(buffer: StringIO).returns(T::Boolean) }
32
+ def compile_all_bypasses(buffer)
33
+ has_bypasses = T.let(false, T::Boolean)
34
+ T.let(@result.files.to_a, T::Array[SimpleCov::SourceFile]).each do |file|
35
+ bypassed = fetch_bypassed_nodes(file.filename)
36
+ next if bypassed.empty?
37
+
38
+ has_bypasses = true
39
+ write_file_bypasses(buffer, file, bypassed)
40
+ end
41
+ has_bypasses
42
+ end
43
+
44
+ sig { params(filename: String).returns(T::Array[ASTResolver::SemanticNode]) }
45
+ def fetch_bypassed_nodes(filename)
46
+ nodes = @builder.try_resolve_ast(filename)
47
+ nodes ? nodes.select { |n| n.bypasses.any? } : []
48
+ end
49
+
50
+ sig do
51
+ params(buffer: StringIO, file: SimpleCov::SourceFile, bypasses: T::Array[ASTResolver::SemanticNode]).void
52
+ end
53
+ def write_file_bypasses(buffer, file, bypasses)
54
+ buffer.puts "### `#{file.project_filename}`"
55
+ bypasses.each do |node|
56
+ buffer.puts "- `#{node.name}`\n - **Bypass Present:** Contains `:nocov:` directive."
57
+ end
58
+ buffer.puts ''
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,129 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module SimpleCov
5
+ module Formatter
6
+ class AIFormatter
7
+ class MarkdownBuilder
8
+ # Iterates through files with coverage deficits and coordinates their AST parsing and snippet generation.
9
+ class DeficitCompiler
10
+ extend T::Sig
11
+ include SnippetFormatter
12
+
13
+ sig { params(result: SimpleCov::Result, config: Configuration, builder: MarkdownBuilder).void }
14
+ def initialize(result, config, builder)
15
+ @result = result
16
+ @config = config
17
+ @builder = builder
18
+ end
19
+
20
+ sig { params(buffer: StringIO).void }
21
+ def write_deficits(buffer)
22
+ files = T.let(
23
+ @result.files.reject { |f| f.covered_percent >= 100.0 }.sort_by { |f| [f.covered_percent, f.filename] },
24
+ T::Array[SimpleCov::SourceFile]
25
+ )
26
+ return if files.empty?
27
+
28
+ buffer.puts "## Coverage Deficits\n\n"
29
+ files.each do |file|
30
+ break if @builder.truncate_if_needed?
31
+
32
+ process_file(buffer, file)
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ sig { params(buffer: StringIO, file: SimpleCov::SourceFile).void }
39
+ def process_file(buffer, file)
40
+ buffer.puts "### `#{file.project_filename}`"
41
+ nodes = @builder.try_resolve_ast(file.filename)
42
+ nodes ? process_deficits(buffer, file, nodes) : format_raw_deficits(buffer, file)
43
+ end
44
+
45
+ sig { params(buffer: StringIO, file: SimpleCov::SourceFile).void }
46
+ def format_raw_deficits(buffer, file)
47
+ buffer.puts " - **ERROR:** AST Parsing Failed. Showing raw line numbers instead.\n"
48
+ group = MarkdownBuilder::DeficitGroup.new(
49
+ lines: file.missed_lines,
50
+ branches: file.missed_branches
51
+ )
52
+ format_deficit_group(buffer, group, fetch_source_lines(file.filename))
53
+ buffer.puts ''
54
+ end
55
+
56
+ sig do
57
+ params(buffer: StringIO, file: SimpleCov::SourceFile, nodes: T::Array[ASTResolver::SemanticNode]).void
58
+ end
59
+ def process_deficits(buffer, file, nodes)
60
+ node_deficits = DeficitGrouper.build(file, nodes)
61
+ source_lines = T.let(nil, T.nilable(T::Array[String]))
62
+
63
+ node_deficits.each do |node_name, group|
64
+ break if @builder.truncate_if_needed?
65
+
66
+ source_lines ||= fetch_source_lines(file.filename)
67
+ format_node_deficit(buffer, node_name, group, source_lines)
68
+ end
69
+
70
+ buffer.puts ''
71
+ end
72
+
73
+ sig { params(buffer: StringIO, node_name: String, group: DeficitGroup, source_lines: T::Array[String]).void }
74
+ def format_node_deficit(buffer, node_name, group, source_lines)
75
+ buffer.puts "- `#{node_name}`"
76
+
77
+ if @config.granularity == :coarse
78
+ buffer.puts ' - **Deficit:** Contains unexecuted lines or branches.'
79
+ else
80
+ format_deficit_group(buffer, group, source_lines)
81
+ end
82
+ end
83
+
84
+ sig { params(filename: String).returns(T::Array[String]) }
85
+ def fetch_source_lines(filename)
86
+ File.readlines(filename)
87
+ rescue StandardError
88
+ []
89
+ end
90
+
91
+ sig { params(buffer: StringIO, group: DeficitGroup, source_lines: T::Array[String]).void }
92
+ def format_deficit_group(buffer, group, source_lines)
93
+ group.lines.each do |line|
94
+ write_line_snippet(buffer, line, source_lines, group.semantic_node)
95
+ end
96
+
97
+ group.branches.each do |branch|
98
+ write_branch_snippet(buffer, branch, source_lines, group.semantic_node)
99
+ end
100
+ end
101
+
102
+ sig do
103
+ params(buffer: StringIO, line: SimpleCov::SourceFile::Line, source_lines: T::Array[String],
104
+ node: T.nilable(ASTResolver::SemanticNode)).void
105
+ end
106
+ def write_line_snippet(buffer, line, source_lines, node)
107
+ line_num = line.line_number
108
+ text = truncate_snippet(fetch_snippet_text([line_num], source_lines), @config.max_snippet_lines)
109
+ occurrence_str = calculate_occurrence(line_num, source_lines, node)
110
+ buffer.puts " - **Line Deficit:** #{occurrence_str}`#{text}`"
111
+ end
112
+
113
+ sig do
114
+ params(buffer: StringIO, branch: SimpleCov::SourceFile::Branch, source_lines: T::Array[String],
115
+ node: T.nilable(ASTResolver::SemanticNode)).void
116
+ end
117
+ def write_branch_snippet(buffer, branch, source_lines, node)
118
+ start_line = branch.start_line
119
+ end_line = branch.end_line
120
+ lines_range = T.cast((start_line..end_line).to_a, T::Array[Integer])
121
+ text = truncate_snippet(fetch_snippet_text(lines_range, source_lines), @config.max_snippet_lines)
122
+ occurrence_str = calculate_occurrence(start_line, source_lines, node)
123
+ buffer.puts " - **Branch Deficit:** Missing coverage for conditional `#{text}` #{occurrence_str}".rstrip
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,65 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module SimpleCov
5
+ module Formatter
6
+ class AIFormatter
7
+ class MarkdownBuilder
8
+ # Groups missed lines and branches into DeficitGroup objects based on AST semantic boundaries.
9
+ class DeficitGrouper
10
+ extend T::Sig
11
+
12
+ sig { returns(T::Hash[String, DeficitGroup]) }
13
+ attr_reader :node_deficits
14
+
15
+ sig { params(nodes: T::Array[ASTResolver::SemanticNode]).void }
16
+ def initialize(nodes)
17
+ @nodes = nodes
18
+ @node_deficits = T.let({}, T::Hash[String, DeficitGroup])
19
+ end
20
+
21
+ sig do
22
+ params(file: SimpleCov::SourceFile, nodes: T::Array[ASTResolver::SemanticNode])
23
+ .returns(T::Hash[String, DeficitGroup])
24
+ end
25
+ def self.build(file, nodes)
26
+ grouper = new(nodes)
27
+ grouper.group_missed_lines(file)
28
+ grouper.group_missed_branches(file)
29
+ grouper.node_deficits
30
+ end
31
+
32
+ sig { params(file: SimpleCov::SourceFile).void }
33
+ def group_missed_lines(file)
34
+ file.missed_lines.each do |line|
35
+ line_num = line.line_number
36
+ node = @nodes.find { |n| line_num.between?(n.start_line, n.end_line) }
37
+ node_name = node ? node.name : "Line #{line_num}"
38
+ @node_deficits[node_name] ||= DeficitGroup.new(semantic_node: node)
39
+ T.must(@node_deficits[node_name]).lines << line
40
+ end
41
+ end
42
+
43
+ sig { params(file: SimpleCov::SourceFile).void }
44
+ def group_missed_branches(file)
45
+ return unless file.respond_to?(:branches) && file.branches.any?
46
+
47
+ file.missed_branches.each do |branch|
48
+ add_missed_branch(branch)
49
+ end
50
+ end
51
+
52
+ sig { params(branch: SimpleCov::SourceFile::Branch).void }
53
+ def add_missed_branch(branch)
54
+ start_line = branch.start_line
55
+ end_line = branch.end_line
56
+ node = @nodes.find { |n| start_line >= n.start_line && end_line <= n.end_line }
57
+ node_name = node ? node.name : "Lines #{start_line}-#{end_line}"
58
+ @node_deficits[node_name] ||= DeficitGroup.new(semantic_node: node)
59
+ T.must(@node_deficits[node_name]).branches << branch
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,76 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module SimpleCov
5
+ module Formatter
6
+ class AIFormatter
7
+ class MarkdownBuilder
8
+ # Handles extraction and formatting of source code snippets for the markdown digest.
9
+ module SnippetFormatter
10
+ extend T::Sig
11
+
12
+ # Extracts and normalizes exact string literals from the source file arrays.
13
+ #
14
+ # @param line_nums [Array<Integer>] Target line coordinates.
15
+ # @param source_lines [Array<String>] The raw text lines of the file.
16
+ # @return [String] Joined snippet text.
17
+ sig { params(line_nums: T::Array[Integer], source_lines: T::Array[String]).returns(String) }
18
+ def fetch_snippet_text(line_nums, source_lines)
19
+ line_nums.filter_map { |ln| source_lines[ln - 1]&.strip }.reject(&:empty?).join(' ')
20
+ end
21
+
22
+ # Safely limits the character length of a code snippet according to global configurations.
23
+ #
24
+ # @param text [String] The snippet to potentially truncate.
25
+ # @param max_snippet_lines [Integer] The configured max lines.
26
+ # @return [String] Truncated string with trailing ellipses if limit exceeded.
27
+ sig { params(text: String, max_snippet_lines: Integer).returns(String) }
28
+ def truncate_snippet(text, max_snippet_lines)
29
+ max_chars = max_snippet_lines * 80
30
+ text.length > max_chars ? "#{text[0...max_chars]}..." : text
31
+ end
32
+
33
+ # Disambiguates identical code snippets within the same semantic block (e.g., "(Occurrence 2 of 3)").
34
+ #
35
+ # @param line_num [Integer] The target coordinate of the deficit.
36
+ # @param source_lines [Array<String>] Raw file contents.
37
+ # @param node [ASTResolver::SemanticNode, nil] The semantic node boundary to search within.
38
+ # @return [String] Occurrence label or empty string if unique.
39
+ sig do
40
+ params(line_num: Integer, source_lines: T::Array[String],
41
+ node: T.nilable(ASTResolver::SemanticNode)).returns(String)
42
+ end
43
+ def calculate_occurrence(line_num, source_lines, node)
44
+ return '' if node.nil?
45
+
46
+ first_line_of_snippet = source_lines[line_num - 1]&.strip
47
+ return '' if first_line_of_snippet.nil? || first_line_of_snippet.empty?
48
+
49
+ occurrences, current = count_snippet_occurrences(first_line_of_snippet, line_num, source_lines, node)
50
+
51
+ occurrences > 1 ? "(Occurrence #{current} of #{occurrences}) " : ''
52
+ end
53
+
54
+ sig do
55
+ params(snippet: String, target_ln: Integer, source_lines: T::Array[String],
56
+ node: ASTResolver::SemanticNode).returns([Integer, Integer])
57
+ end
58
+ def count_snippet_occurrences(snippet, target_ln, source_lines, node)
59
+ occurrences = 0
60
+ current_occurrence = 1
61
+
62
+ (node.start_line..node.end_line).each do |ln|
63
+ line_content = source_lines[ln - 1]&.strip
64
+ next unless line_content == snippet
65
+
66
+ occurrences += 1
67
+ current_occurrence = occurrences if ln == target_ln
68
+ end
69
+
70
+ [occurrences, current_occurrence]
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -1,6 +1,12 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative 'ast_resolver'
5
+ require_relative 'markdown_builder/snippet_formatter'
6
+ require_relative 'markdown_builder/bypass_compiler'
7
+ require_relative 'markdown_builder/deficit_grouper'
8
+ require_relative 'markdown_builder/deficit_compiler'
9
+
4
10
  module SimpleCov
5
11
  module Formatter
6
12
  class AIFormatter
@@ -9,6 +15,17 @@ module SimpleCov
9
15
  # Serves as the primary mutation boundary to format AI consumption targets.
10
16
  class MarkdownBuilder
11
17
  extend T::Sig
18
+ include SnippetFormatter
19
+
20
+ # Groups unexecuted lines and branches under their common semantic node.
21
+ class DeficitGroup < T::Struct
22
+ # @return [ASTResolver::SemanticNode, nil] The corresponding structural boundary
23
+ prop :semantic_node, T.nilable(ASTResolver::SemanticNode), default: nil
24
+ # @return [Array<SimpleCov::SourceFile::Line>] The missed source lines
25
+ prop :lines, T::Array[SimpleCov::SourceFile::Line], default: []
26
+ # @return [Array<SimpleCov::SourceFile::Branch>] The missed conditional branches
27
+ prop :branches, T::Array[SimpleCov::SourceFile::Branch], default: []
28
+ end
12
29
 
13
30
  # Initializes the Markdown sequence compilation.
14
31
  #
@@ -21,6 +38,7 @@ module SimpleCov
21
38
  @buffer = T.let(StringIO.new, StringIO)
22
39
  @file_count = T.let(0, Integer)
23
40
  @truncated = T.let(false, T::Boolean)
41
+ @ast_cache = T.let({}, T::Hash[String, T::Array[ASTResolver::SemanticNode]])
24
42
  end
25
43
 
26
44
  # Executes the primary buffer composition logic yielding a monolithic compiled output.
@@ -30,126 +48,58 @@ module SimpleCov
30
48
  sig { returns(String) }
31
49
  def build
32
50
  write_header
33
- write_deficits
34
- write_bypasses if @config.include_bypasses
51
+ DeficitCompiler.new(@result, @config, self).write_deficits(@buffer)
52
+ BypassCompiler.new(@result, self).write_bypasses(@buffer) if @config.include_bypasses
35
53
  write_truncation_warning if @truncated
36
54
  @buffer.string
37
55
  end
38
56
 
39
- private
40
-
41
- sig { void }
42
- def write_header
43
- status = T.cast(@result.covered_percent, Float) >= 100.0 ? 'PASSED' : 'FAILED'
44
- time_str = Time.now.to_s # UI timezone requirement
45
-
46
- @buffer.puts '# AI Coverage Digest'
47
- @buffer.puts "**Status:** #{status}"
48
- @buffer.puts "**Global Line Coverage:** #{T.cast(@result.covered_percent, Float).round(1)}%"
49
-
50
- branch_pct = begin
51
- T.cast(@result.covered_branches, Float) / T.cast(@result.total_branches, Numeric) * 100
52
- rescue StandardError
53
- 0.0
54
- end
55
- @buffer.puts "**Global Branch Coverage:** #{T.cast(branch_pct, Float).round(1)}%"
56
- @buffer.puts "**Generated At:** #{time_str}"
57
- @buffer.puts ''
57
+ sig { params(filename: String).returns(T.nilable(T::Array[ASTResolver::SemanticNode])) }
58
+ def try_resolve_ast(filename)
59
+ @ast_cache[filename] ||= ASTResolver.resolve(filename)
60
+ rescue StandardError
61
+ nil
58
62
  end
59
63
 
60
- sig { void }
61
- def write_deficits
62
- # SCMD-REQ-014: Sort by coverage percent ASC, then by filename
63
- files = T.let(
64
- T.cast(@result.files.to_a, T::Array[SimpleCov::SourceFile]).reject { |f| T.cast(f.covered_percent, Float) >= 100.0 }
65
- .sort_by { |f| [T.cast(f.covered_percent, Float), T.cast(f.filename, String)] },
66
- T::Array[SimpleCov::SourceFile]
67
- )
68
-
69
- return if files.empty?
70
-
71
- @buffer.puts "## Coverage Deficits\n\n"
72
-
73
- files.each do |file|
74
- # Check size limits SCMD-REQ-012
75
- if @buffer.size / 1024.0 > @config.max_file_size_kb
76
- @truncated = true
77
- break
78
- end
64
+ sig { returns(T::Boolean) }
65
+ def truncate_if_needed?
66
+ return false unless @buffer.size / 1024.0 > @config.max_file_size_kb
79
67
 
80
- @buffer.puts "### `#{T.cast(file.project_filename, String)}`"
81
-
82
- begin
83
- nodes = ASTResolver.resolve(T.cast(file.filename, String))
84
- rescue StandardError => e
85
- @buffer.puts "- **ERROR:** AST Parsing Failed (`#{e.class}`)"
86
- next
87
- end
88
-
89
- process_deficits(file, nodes)
90
- end
68
+ @truncated = true
69
+ true
91
70
  end
92
71
 
93
- sig { params(file: SimpleCov::SourceFile, nodes: T::Array[ASTResolver::SemanticNode]).void }
94
- def process_deficits(file, nodes)
95
- T.cast(file.missed_lines, T::Array[SimpleCov::SourceFile::Line]).each do |line|
96
- line_num = T.cast(line.line_number, Integer)
97
- node = nodes.find { |n| line_num >= n.start_line && line_num <= n.end_line }
98
- node_name = node ? node.name : "Line #{line_num}"
99
- @buffer.puts "- `#{node_name}`\n - **Line Deficit:** Unexecuted code."
100
- end
101
-
102
- if file.respond_to?(:branches) && file.branches.is_a?(Array) && file.branches.any?
103
- T.cast(file.missed_branches, T::Array[SimpleCov::SourceFile::Branch]).each do |branch|
104
- start_line = T.cast(branch.start_line, Integer)
105
- end_line = T.cast(branch.end_line, Integer)
106
- node = nodes.find { |n| start_line >= n.start_line && end_line <= n.end_line }
107
- node_name = node ? node.name : "Lines #{start_line}-#{end_line}"
108
- @buffer.puts "- `#{node_name}`\n - **Branch Deficit:** Missing coverage for conditional."
109
- end
110
- end
72
+ private
111
73
 
74
+ # Writes the summary header containing global coverage percentages and generation metadata.
75
+ sig { void }
76
+ def write_header
77
+ status = @result.covered_percent >= 100.0 ? 'PASSED' : 'FAILED'
78
+ @buffer.puts '# AI Coverage Digest'
79
+ @buffer.puts "**Status:** #{status}"
80
+ @buffer.puts "**Global Line Coverage:** #{@result.covered_percent.round(1)}%"
81
+ @buffer.puts "**Global Branch Coverage:** #{calculate_branch_pct.round(1)}%"
82
+ @buffer.puts "**Generated At:** #{Time.now}"
112
83
  @buffer.puts ''
113
84
  end
114
85
 
115
- sig { void }
116
- def write_bypasses
117
- has_bypasses = T.let(false, T::Boolean)
118
- bypass_buffer = T.let(StringIO.new, StringIO)
119
-
120
- T.cast(@result.files.to_a, T::Array[SimpleCov::SourceFile]).each do |file|
121
- begin
122
- nodes = ASTResolver.resolve(T.cast(file.filename, String))
123
- rescue StandardError
124
- next
125
- end
126
-
127
- nodes_with_bypasses = nodes.select { |n| n.bypasses.any? }
128
- next if nodes_with_bypasses.empty?
129
-
130
- has_bypasses = true
131
- write_file_bypasses(bypass_buffer, file, nodes_with_bypasses)
132
- end
133
-
134
- return unless has_bypasses
135
-
136
- @buffer.puts "## Ignored Coverage Bypasses\n\n"
137
- @buffer.puts bypass_buffer.string
138
- end
86
+ sig { returns(Float) }
87
+ def calculate_branch_pct
88
+ return 0.0 unless @result.respond_to?(:covered_branches) && @result.respond_to?(:total_branches)
139
89
 
140
- sig { params(buffer: StringIO, file: SimpleCov::SourceFile, bypasses: T::Array[ASTResolver::SemanticNode]).void }
141
- def write_file_bypasses(buffer, file, bypasses)
142
- buffer.puts "### `#{file.project_filename}`"
143
- bypasses.each do |node|
144
- buffer.puts "- `#{node.name}`\n - **Bypass Present:** Contains `# :nocov:` directive artificially ignoring coverage."
145
- end
146
- buffer.puts ''
90
+ T.cast(@result.covered_branches, Float) / @result.total_branches * 100
91
+ rescue StandardError
92
+ 0.0
147
93
  end
148
94
 
95
+ # Appends a critical alert if the output hit the token-ceiling constraint and was forcibly terminated.
149
96
  sig { void }
150
97
  def write_truncation_warning
151
98
  @buffer.puts '> **[WARNING] TRUNCATION NOTIFICATION:**'
152
- @buffer.puts "> The total coverage deficit report exceeded the maximum token constraint (#{@config.max_file_size_kb} kB). The report was truncated. The deficits detailed above represent the lowest-coverage (most critical) files. Please resolve these deficits to reveal the remaining uncovered files in subsequent test runs."
99
+ msg = "> The total coverage deficit report exceeded the maximum token constraint (#{@config.max_file_size_kb} kB). " \
100
+ 'The report was truncated. The deficits detailed above represent the lowest-coverage files. ' \
101
+ 'Please resolve these deficits to reveal the remaining uncovered files.'
102
+ @buffer.puts msg
153
103
  end
154
104
  end
155
105
  end
@@ -8,7 +8,7 @@ module SimpleCov
8
8
  class AIFormatter
9
9
  # The semantic version identifier for the gem, used for dependency resolution
10
10
  # and enforcing compatibility across upgrades.
11
- VERSION = T.let('0.10.1', String)
11
+ VERSION = T.let('0.10.3', String)
12
12
  end
13
13
  end
14
14
  end
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: simplecov-ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.1
4
+ version: 0.10.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vitalii Lazebnyi
@@ -151,44 +151,58 @@ dependencies:
151
151
  name: rubocop
152
152
  requirement: !ruby/object:Gem::Requirement
153
153
  requirements:
154
- - - "~>"
154
+ - - ">="
155
155
  - !ruby/object:Gem::Version
156
156
  version: '1.28'
157
157
  type: :development
158
158
  prerelease: false
159
159
  version_requirements: !ruby/object:Gem::Requirement
160
160
  requirements:
161
- - - "~>"
161
+ - - ">="
162
162
  - !ruby/object:Gem::Version
163
163
  version: '1.28'
164
164
  - !ruby/object:Gem::Dependency
165
165
  name: rubocop-performance
166
166
  requirement: !ruby/object:Gem::Requirement
167
167
  requirements:
168
- - - "~>"
168
+ - - ">="
169
169
  - !ruby/object:Gem::Version
170
170
  version: '1.14'
171
171
  type: :development
172
172
  prerelease: false
173
173
  version_requirements: !ruby/object:Gem::Requirement
174
174
  requirements:
175
- - - "~>"
175
+ - - ">="
176
176
  - !ruby/object:Gem::Version
177
177
  version: '1.14'
178
178
  - !ruby/object:Gem::Dependency
179
179
  name: rubocop-rspec
180
180
  requirement: !ruby/object:Gem::Requirement
181
181
  requirements:
182
- - - "~>"
182
+ - - ">="
183
183
  - !ruby/object:Gem::Version
184
184
  version: '2.11'
185
185
  type: :development
186
186
  prerelease: false
187
187
  version_requirements: !ruby/object:Gem::Requirement
188
188
  requirements:
189
- - - "~>"
189
+ - - ">="
190
190
  - !ruby/object:Gem::Version
191
191
  version: '2.11'
192
+ - !ruby/object:Gem::Dependency
193
+ name: rubocop-sorbet
194
+ requirement: !ruby/object:Gem::Requirement
195
+ requirements:
196
+ - - ">="
197
+ - !ruby/object:Gem::Version
198
+ version: '0'
199
+ type: :development
200
+ prerelease: false
201
+ version_requirements: !ruby/object:Gem::Requirement
202
+ requirements:
203
+ - - ">="
204
+ - !ruby/object:Gem::Version
205
+ version: '0'
192
206
  - !ruby/object:Gem::Dependency
193
207
  name: rubocop-thread_safety
194
208
  requirement: !ruby/object:Gem::Requirement
@@ -218,7 +232,7 @@ dependencies:
218
232
  - !ruby/object:Gem::Version
219
233
  version: '0.5'
220
234
  - !ruby/object:Gem::Dependency
221
- name: tsort
235
+ name: standard-sorbet
222
236
  requirement: !ruby/object:Gem::Requirement
223
237
  requirements:
224
238
  - - ">="
@@ -232,7 +246,7 @@ dependencies:
232
246
  - !ruby/object:Gem::Version
233
247
  version: '0'
234
248
  - !ruby/object:Gem::Dependency
235
- name: yard
249
+ name: tapioca
236
250
  requirement: !ruby/object:Gem::Requirement
237
251
  requirements:
238
252
  - - ">="
@@ -246,7 +260,7 @@ dependencies:
246
260
  - !ruby/object:Gem::Version
247
261
  version: '0'
248
262
  - !ruby/object:Gem::Dependency
249
- name: yard-sorbet
263
+ name: tsort
250
264
  requirement: !ruby/object:Gem::Requirement
251
265
  requirements:
252
266
  - - ">="
@@ -260,19 +274,33 @@ dependencies:
260
274
  - !ruby/object:Gem::Version
261
275
  version: '0'
262
276
  - !ruby/object:Gem::Dependency
263
- name: simplecov-ai
277
+ name: yard
264
278
  requirement: !ruby/object:Gem::Requirement
265
279
  requirements:
266
- - - "~>"
280
+ - - ">="
267
281
  - !ruby/object:Gem::Version
268
- version: '0.10'
282
+ version: '0'
269
283
  type: :development
270
284
  prerelease: false
271
285
  version_requirements: !ruby/object:Gem::Requirement
272
286
  requirements:
273
- - - "~>"
287
+ - - ">="
274
288
  - !ruby/object:Gem::Version
275
- version: '0.10'
289
+ version: '0'
290
+ - !ruby/object:Gem::Dependency
291
+ name: yard-sorbet
292
+ requirement: !ruby/object:Gem::Requirement
293
+ requirements:
294
+ - - ">="
295
+ - !ruby/object:Gem::Version
296
+ version: '0'
297
+ type: :development
298
+ prerelease: false
299
+ version_requirements: !ruby/object:Gem::Requirement
300
+ requirements:
301
+ - - ">="
302
+ - !ruby/object:Gem::Version
303
+ version: '0'
276
304
  description: Generates highly concise, deterministic Markdown coverage digests tailored
277
305
  for LLMs and autonomous agents by matching coverage deficits to their AST semantic
278
306
  boundaries rather than line numbers.
@@ -289,6 +317,10 @@ files:
289
317
  - lib/simplecov-ai/ast_resolver.rb
290
318
  - lib/simplecov-ai/configuration.rb
291
319
  - lib/simplecov-ai/markdown_builder.rb
320
+ - lib/simplecov-ai/markdown_builder/bypass_compiler.rb
321
+ - lib/simplecov-ai/markdown_builder/deficit_compiler.rb
322
+ - lib/simplecov-ai/markdown_builder/deficit_grouper.rb
323
+ - lib/simplecov-ai/markdown_builder/snippet_formatter.rb
292
324
  - lib/simplecov-ai/version.rb
293
325
  homepage: https://github.com/VitaliiLazebnyi/simplecov-ai
294
326
  licenses:
metadata.gz.sig CHANGED
Binary file