simplecov-ai 0.10.2 → 0.10.4

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: b7ae5d956cdb6b5fd6529cbf793507984563b331ccaffeb710b7760f7e81a9da
4
- data.tar.gz: eecad8ca18885972bd1b494f3706d1182c6fa0a3c42a1799ba4af27199e9f83f
3
+ metadata.gz: 21fb51a293aef52967848c9b4fcc176b83aa274c347fb29abeaf76a34f040754
4
+ data.tar.gz: b43d99c3b4064c5e2914be0df4251774f78f0f6fa41cc51ae85c4e4547a586e9
5
5
  SHA512:
6
- metadata.gz: 7e1a9a39532116181c40dc234f98364462fefd3f30a77ba88e7eebabeb4a222550e260b3d0571539ab6b3895221c56d37f38f8391173867ecd4f06672582d7ef
7
- data.tar.gz: 44a0c37ed5fe8ef1e0df5b0ecbfc88ce3315b91a6dc33767751c042cf2ad8863756d46163f5fe25741a4a7a39d7802d7b2c60926f89ab5e57d1b7ab872d90be2
6
+ metadata.gz: 55f0042ff2503ffc573c39a1393a407e66988e2de5f3e79d5d35eb539f70645bb1bcd6374870d84682dba430a87b7b0f9601baa446e9beb99f1e9e7aa3c45cf1
7
+ data.tar.gz: 2a94566df43e8bdd3c8e68ffcbe95bbda0683be0cd661313ede1af5a421d0972ad03d3b0363cf72259d631e80a490529075adc2b13df58a02d7ffa4c777ef95c
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).
@@ -43,6 +43,11 @@ module SimpleCov
43
43
  @end_line = end_line
44
44
  @bypasses = bypasses
45
45
  end
46
+
47
+ sig { params(bypass: String).void }
48
+ def add_bypass(bypass)
49
+ @bypasses << bypass
50
+ end
46
51
  end
47
52
 
48
53
  # Orchestrates the initial mapping algorithm on a target file to extract structural
@@ -54,85 +59,118 @@ module SimpleCov
54
59
  def self.resolve(file_path)
55
60
  return [] unless File.exist?(file_path)
56
61
 
57
- begin
58
- source = File.read(file_path)
59
- ast, comments = Parser::CurrentRuby.parse_with_comments(source)
60
- new.traverse(ast, comments)
61
- rescue Parser::SyntaxError
62
- []
63
- end
62
+ source = File.read(file_path)
63
+ ast, comments = Parser::CurrentRuby.parse_with_comments(source)
64
+
65
+ resolver = new
66
+ nodes = resolver.traverse(ast)
67
+ resolver.assign_bypasses(nodes, comments)
68
+ nodes
64
69
  end
65
70
 
66
71
  # Recursively navigates an abstract node hierarchy, building SemanticNodes mappings
67
72
  # around modules, classes, singleton, and instance methods while aggregating parent paths.
68
73
  #
69
74
  # @param node [Parser::AST::Node] The root AST node from which traversal executes.
70
- # @param comments [Array<Parser::Source::Comment>] Lexical comments corresponding to nodes.
71
75
  # @param context [String] An accumulated identifier linking namespaces to inner entities.
72
76
  # @return [Array<SemanticNode>] Accumulation of all sub-tree defined endpoints.
73
77
  sig do
74
- params(node: Parser::AST::Node, comments: T::Array[Parser::Source::Comment],
78
+ params(node: T.nilable(Parser::AST::Node),
75
79
  context: String).returns(T::Array[SemanticNode])
76
80
  end
77
- def traverse(node, comments, context = '')
81
+ def traverse(node, context = '')
78
82
  return [] unless node.is_a?(Parser::AST::Node)
79
83
 
80
84
  nodes = T.let([], T::Array[SemanticNode])
81
- current_context = context
85
+ current_context, semantic_node = extract_node_metadata(node, context)
86
+ nodes << semantic_node if semantic_node
87
+
88
+ node.children.grep(Parser::AST::Node).each do |child|
89
+ nodes.concat(traverse(child, current_context))
90
+ end
91
+
92
+ nodes
93
+ end
94
+
95
+ sig { params(nodes: T::Array[SemanticNode], comments: T::Array[Parser::Source::Comment]).void }
96
+ def assign_bypasses(nodes, comments)
97
+ comments.each do |c|
98
+ c_text = T.cast(c.text, String)
99
+ next unless c_text.include?(':nocov:')
100
+
101
+ assign_bypass(nodes, c, c_text.strip)
102
+ end
103
+ end
104
+
105
+ private
106
+
107
+ sig { params(nodes: T::Array[SemanticNode], comment: Parser::Source::Comment, text: String).void }
108
+ def assign_bypass(nodes, comment, text)
109
+ c_loc = T.cast(comment.loc, Parser::Source::Map)
110
+ c_line = T.cast(c_loc.line, Integer)
82
111
 
112
+ innermost_node = nodes.reverse.find { |n| c_line.between?(n.start_line - 1, n.end_line + 1) }
113
+ innermost_node&.add_bypass(text)
114
+ end
115
+
116
+ sig do
117
+ params(node: Parser::AST::Node, context: String)
118
+ .returns([String, T.nilable(SemanticNode)])
119
+ end
120
+ def extract_node_metadata(node, context)
83
121
  case node.type
84
122
  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)
123
+ extract_class_metadata(node, context)
91
124
  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')
125
+ extract_instance_method_metadata(node, context)
95
126
  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')
127
+ extract_singleton_method_metadata(node, context)
128
+ else
129
+ [context, nil]
99
130
  end
131
+ end
100
132
 
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
133
+ sig do
134
+ params(node: Parser::AST::Node, context: String)
135
+ .returns([String, T.nilable(SemanticNode)])
136
+ end
137
+ def extract_class_metadata(node, context)
138
+ const_node = T.cast(node.children[0], Parser::AST::Node)
139
+ const_node_name = T.cast(T.cast(const_node.loc, Parser::Source::Map::Constant).name, Parser::Source::Range)
140
+ name = T.cast(const_node_name.source, String)
141
+ ctx = context.empty? ? name : "#{context}::#{name}"
142
+ [ctx, build_node(node, ctx, node.type.to_s.capitalize)]
143
+ end
107
144
 
108
- nodes
145
+ sig do
146
+ params(node: Parser::AST::Node, context: String)
147
+ .returns([String, T.nilable(SemanticNode)])
148
+ end
149
+ def extract_instance_method_metadata(node, context)
150
+ name = T.cast(node.children.first, Symbol).to_s
151
+ ctx = context.empty? ? "##{name}" : "#{context}##{name}"
152
+ [ctx, build_node(node, ctx, 'Instance Method')]
109
153
  end
110
154
 
111
- private
155
+ sig do
156
+ params(node: Parser::AST::Node, context: String)
157
+ .returns([String, T.nilable(SemanticNode)])
158
+ end
159
+ def extract_singleton_method_metadata(node, context)
160
+ name = T.cast(node.children[1], Symbol).to_s
161
+ ctx = context.empty? ? ".#{name}" : "#{context}.#{name}"
162
+ [ctx, build_node(node, ctx, 'Singleton Method')]
163
+ end
112
164
 
113
165
  sig do
114
- params(node: Parser::AST::Node, comments: T::Array[Parser::Source::Comment], name: String,
166
+ params(node: Parser::AST::Node, name: String,
115
167
  type: String).returns(SemanticNode)
116
168
  end
117
- 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)
121
-
122
- bypasses = comments.select do |c|
123
- c_loc = T.cast(c.loc, Parser::Source::Map)
124
- c_line = T.cast(c_loc.line, Integer)
125
- 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
- )
169
+ def build_node(node, name, type)
170
+ loc = T.cast(node.loc, Parser::Source::Map)
171
+ start_ln = T.cast(loc.line, Integer)
172
+ end_ln = T.cast(loc.last_line, Integer)
173
+ SemanticNode.new(name: name, type: type, start_line: start_ln, end_line: end_ln, bypasses: [])
136
174
  end
137
175
  end
138
176
  end
@@ -0,0 +1,67 @@
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
+ total = bypasses.size
56
+ bypasses.each_with_index do |node, idx|
57
+ buffer.puts "- `#{node.name}`\n " \
58
+ '- **Bypass Present:** Contains `:nocov:` directive artificially ' \
59
+ "ignoring coverage (Occurrence #{idx + 1} of #{total})."
60
+ end
61
+ buffer.puts ''
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ 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:** `#{text}` #{occurrence_str}".rstrip
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,84 @@
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.sort_deficits
30
+ end
31
+
32
+ sig { returns(T::Hash[String, DeficitGroup]) }
33
+ def sort_deficits
34
+ T.let(
35
+ @node_deficits.sort_by do |name, group|
36
+ if (node = group.semantic_node)
37
+ node.start_line
38
+ else
39
+ name.match(/\d+/)&.to_s&.to_i || Float::INFINITY
40
+ end
41
+ end.to_h,
42
+ T::Hash[String, DeficitGroup]
43
+ )
44
+ end
45
+
46
+ sig { params(file: SimpleCov::SourceFile).void }
47
+ def group_missed_lines(file)
48
+ file.missed_lines.each do |line|
49
+ add_missed_line(line)
50
+ end
51
+ end
52
+
53
+ sig { params(line: SimpleCov::SourceFile::Line).void }
54
+ def add_missed_line(line)
55
+ line_num = line.line_number
56
+ node = @nodes.reverse.find { |n| line_num.between?(n.start_line, n.end_line) }
57
+ node_name = node ? node.name : "Line #{line_num}"
58
+ @node_deficits[node_name] ||= DeficitGroup.new(semantic_node: node)
59
+ T.must(@node_deficits[node_name]).lines << line
60
+ end
61
+
62
+ sig { params(file: SimpleCov::SourceFile).void }
63
+ def group_missed_branches(file)
64
+ return unless file.respond_to?(:branches) && file.branches.any?
65
+
66
+ file.missed_branches.each do |branch|
67
+ add_missed_branch(branch)
68
+ end
69
+ end
70
+
71
+ sig { params(branch: SimpleCov::SourceFile::Branch).void }
72
+ def add_missed_branch(branch)
73
+ start_line = branch.start_line
74
+ end_line = branch.end_line
75
+ node = @nodes.reverse.find { |n| start_line >= n.start_line && end_line <= n.end_line }
76
+ node_name = node ? node.name : "Lines #{start_line}-#{end_line}"
77
+ @node_deficits[node_name] ||= DeficitGroup.new(semantic_node: node)
78
+ T.must(@node_deficits[node_name]).branches << branch
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ 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,13 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative 'ast_resolver'
5
+ require 'time'
6
+ require_relative 'markdown_builder/snippet_formatter'
7
+ require_relative 'markdown_builder/bypass_compiler'
8
+ require_relative 'markdown_builder/deficit_grouper'
9
+ require_relative 'markdown_builder/deficit_compiler'
10
+
4
11
  module SimpleCov
5
12
  module Formatter
6
13
  class AIFormatter
@@ -9,6 +16,17 @@ module SimpleCov
9
16
  # Serves as the primary mutation boundary to format AI consumption targets.
10
17
  class MarkdownBuilder
11
18
  extend T::Sig
19
+ include SnippetFormatter
20
+
21
+ # Groups unexecuted lines and branches under their common semantic node.
22
+ class DeficitGroup < T::Struct
23
+ # @return [ASTResolver::SemanticNode, nil] The corresponding structural boundary
24
+ prop :semantic_node, T.nilable(ASTResolver::SemanticNode), default: nil
25
+ # @return [Array<SimpleCov::SourceFile::Line>] The missed source lines
26
+ prop :lines, T::Array[SimpleCov::SourceFile::Line], default: []
27
+ # @return [Array<SimpleCov::SourceFile::Branch>] The missed conditional branches
28
+ prop :branches, T::Array[SimpleCov::SourceFile::Branch], default: []
29
+ end
12
30
 
13
31
  # Initializes the Markdown sequence compilation.
14
32
  #
@@ -21,6 +39,7 @@ module SimpleCov
21
39
  @buffer = T.let(StringIO.new, StringIO)
22
40
  @file_count = T.let(0, Integer)
23
41
  @truncated = T.let(false, T::Boolean)
42
+ @ast_cache = T.let({}, T::Hash[String, T::Array[ASTResolver::SemanticNode]])
24
43
  end
25
44
 
26
45
  # Executes the primary buffer composition logic yielding a monolithic compiled output.
@@ -30,142 +49,62 @@ module SimpleCov
30
49
  sig { returns(String) }
31
50
  def build
32
51
  write_header
33
- write_deficits
34
- write_bypasses if @config.include_bypasses
52
+ DeficitCompiler.new(@result, @config, self).write_deficits(@buffer)
53
+ BypassCompiler.new(@result, self).write_bypasses(@buffer) if @config.include_bypasses
35
54
  write_truncation_warning if @truncated
36
55
  @buffer.string
37
56
  end
38
57
 
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 ''
58
+ sig { params(filename: String).returns(T.nilable(T::Array[ASTResolver::SemanticNode])) }
59
+ def try_resolve_ast(filename)
60
+ @ast_cache[filename] ||= ASTResolver.resolve(filename)
61
+ rescue StandardError
62
+ nil
58
63
  end
59
64
 
60
- sig { void }
61
- def write_deficits
62
- files_enum = T.cast(@result.files, T::Enumerable[T.untyped])
63
- files_array = T.let(files_enum.to_a, T::Array[SimpleCov::SourceFile])
64
- # SCMD-REQ-014: Sort by coverage percent ASC, then by filename
65
- files = T.let(
66
- files_array.reject { |f| T.cast(f.covered_percent, Float) >= 100.0 }
67
- .sort_by { |f| [T.cast(f.covered_percent, Float), T.cast(f.filename, String)] },
68
- T::Array[SimpleCov::SourceFile]
69
- )
70
-
71
- return if files.empty?
72
-
73
- @buffer.puts "## Coverage Deficits\n\n"
65
+ sig { returns(T::Boolean) }
66
+ def truncate_if_needed?
67
+ return false unless @buffer.size / 1024.0 > @config.max_file_size_kb
74
68
 
75
- files.each do |file|
76
- # Check size limits SCMD-REQ-012
77
- if @buffer.size / 1024.0 > @config.max_file_size_kb
78
- @truncated = true
79
- break
80
- end
81
-
82
- @buffer.puts "### `#{T.cast(file.project_filename, String)}`"
83
-
84
- begin
85
- nodes = ASTResolver.resolve(T.cast(file.filename, String))
86
- rescue StandardError => e
87
- @buffer.puts "- **ERROR:** AST Parsing Failed (`#{e.class}`)"
88
- next
89
- end
90
-
91
- process_deficits(file, nodes)
92
- end
69
+ @truncated = true
70
+ true
93
71
  end
94
72
 
95
- sig { params(file: SimpleCov::SourceFile, nodes: T::Array[ASTResolver::SemanticNode]).void }
96
- def process_deficits(file, nodes)
97
- T.cast(file.missed_lines, T::Array[SimpleCov::SourceFile::Line]).each do |line|
98
- line_num = T.cast(line.line_number, Integer)
99
- node = nodes.find { |n| line_num >= n.start_line && line_num <= n.end_line }
100
- node_name = node ? node.name : "Line #{line_num}"
101
- @buffer.puts "- `#{node_name}`\n - **Line Deficit:** Unexecuted code."
102
- end
103
-
104
- process_branch_deficits(file, nodes) if file.respond_to?(:branches)
105
-
106
- @buffer.puts ''
107
- end
108
-
109
- sig { params(file: SimpleCov::SourceFile, nodes: T::Array[ASTResolver::SemanticNode]).void }
110
- def process_branch_deficits(file, nodes)
111
- branches = file.branches
112
- case branches
113
- when Array
114
- return unless branches.any?
115
-
116
- T.cast(file.missed_branches, T::Array[SimpleCov::SourceFile::Branch]).each do |branch|
117
- start_line = T.cast(branch.start_line, Integer)
118
- end_line = T.cast(branch.end_line, Integer)
119
- node = nodes.find { |n| start_line >= n.start_line && end_line <= n.end_line }
120
- node_name = node ? node.name : "Lines #{start_line}-#{end_line}"
121
- @buffer.puts "- `#{node_name}`\n - **Branch Deficit:** Missing coverage for conditional."
122
- end
123
- end
73
+ private
124
74
 
75
+ # Writes the summary header containing global coverage percentages and generation metadata.
76
+ sig { void }
77
+ def write_header
78
+ status = @result.covered_percent >= 100.0 ? 'PASSED' : 'FAILED'
79
+ @buffer.puts '# AI Coverage Digest'
80
+ @buffer.puts "**Status:** #{status}"
81
+ @buffer.puts "**Global Line Coverage:** #{@result.covered_percent.round(1)}%"
82
+ @buffer.puts "**Global Branch Coverage:** #{calculate_branch_pct.round(1)}%"
83
+ @buffer.puts "**Generated At:** #{Time.now.iso8601} (Local Timezone)"
125
84
  @buffer.puts ''
126
85
  end
127
86
 
128
- sig { void }
129
- def write_bypasses
130
- has_bypasses = T.let(false, T::Boolean)
131
- bypass_buffer = T.let(StringIO.new, StringIO)
132
-
133
- files_enum = T.cast(@result.files, T::Enumerable[T.untyped])
134
- files_array = T.let(files_enum.to_a, T::Array[SimpleCov::SourceFile])
135
-
136
- files_array.each do |file|
137
- begin
138
- nodes = ASTResolver.resolve(T.cast(file.filename, String))
139
- rescue StandardError
140
- next
141
- end
87
+ sig { returns(Float) }
88
+ def calculate_branch_pct
89
+ return 0.0 unless @result.respond_to?(:covered_branches) && @result.respond_to?(:total_branches)
142
90
 
143
- nodes_with_bypasses = nodes.select { |n| n.bypasses.any? }
144
- next if nodes_with_bypasses.empty?
145
-
146
- has_bypasses = true
147
- write_file_bypasses(bypass_buffer, file, nodes_with_bypasses)
148
- end
149
-
150
- return unless has_bypasses
151
-
152
- @buffer.puts "## Ignored Coverage Bypasses\n\n"
153
- @buffer.puts bypass_buffer.string
154
- end
91
+ total = @result.total_branches
92
+ return 0.0 if total.to_i.zero?
155
93
 
156
- sig { params(buffer: StringIO, file: SimpleCov::SourceFile, bypasses: T::Array[ASTResolver::SemanticNode]).void }
157
- def write_file_bypasses(buffer, file, bypasses)
158
- buffer.puts "### `#{file.project_filename}`"
159
- bypasses.each do |node|
160
- buffer.puts "- `#{node.name}`\n - **Bypass Present:** Contains `# :nocov:` directive artificially ignoring coverage."
161
- end
162
- buffer.puts ''
94
+ covered = @result.covered_branches
95
+ covered.to_f / total * 100.0
163
96
  end
164
97
 
98
+ # Appends a critical alert if the output hit the token-ceiling constraint and was forcibly terminated.
165
99
  sig { void }
166
100
  def write_truncation_warning
167
101
  @buffer.puts '> **[WARNING] TRUNCATION NOTIFICATION:**'
168
- @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."
102
+ msg = '> The total coverage deficit report exceeded the maximum token ' \
103
+ "constraint (#{@config.max_file_size_kb} kB). " \
104
+ 'The report was truncated. The deficits detailed above represent ' \
105
+ 'the lowest-coverage (most critical) files. ' \
106
+ 'Please resolve these deficits to reveal the remaining uncovered files in subsequent test runs.'
107
+ @buffer.puts msg
169
108
  end
170
109
  end
171
110
  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.2', String)
11
+ VERSION = T.let('0.10.4', 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.2
4
+ version: 0.10.4
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:
@@ -304,7 +336,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
304
336
  requirements:
305
337
  - - ">="
306
338
  - !ruby/object:Gem::Version
307
- version: 3.0.0
339
+ version: 2.7.0
308
340
  required_rubygems_version: !ruby/object:Gem::Requirement
309
341
  requirements:
310
342
  - - ">="
metadata.gz.sig CHANGED
Binary file