simplecov-ai 0.10.3 → 0.10.5

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: 3b180d86c54ffab95578a779a8765ec546495b6bc3bd1e353674cf81697b9afe
4
- data.tar.gz: f86ba3e7b0b6e5b3ded6c40799424fded61d2f2d980cb92e784685d340ac31f6
3
+ metadata.gz: 72c1e34acc5609236e3551bf3759099948f9dd98ae12b04ee249ea0e03682e79
4
+ data.tar.gz: 84d74a91bc0ac99d9841242aa095a5876316f8493e8a1d9ac8be1aca1146003c
5
5
  SHA512:
6
- metadata.gz: 4cd0db7e047ccb21fdfc6ccf1c4e2855542c209051b6a0f663ec8037deddf6a4b8481865c492343ccee81f9ed42e5b937d0286b5d5f18dec0e07897aaf6cd902
7
- data.tar.gz: 92f1698f205c2ff941287b45c53290e52306e8e9b1ae3916e84477917c8045d3ed17c5a2653c33a4ea842e837b9c96521a3bb85bc44799e8135962ae65346f1f
6
+ metadata.gz: b4339ac621867c733eebbc80bcb6b8453839e210f3ce6e9017a276d4342b48c1aa2e9e068f7068b3780e33934edffdcda5afec4ffb670b9a2dee214ea68f5ebd
7
+ data.tar.gz: 1cc89347ca1d591d0f207d81dde82864062743b8cc8b981b71c5f7f2529ae0f06898d4949c9a1f7ed5916b943cb510a4a4528235acb15135d1f8fd116bb25ae8
checksums.yaml.gz.sig CHANGED
Binary file
@@ -0,0 +1,47 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module SimpleCov
5
+ module Formatter
6
+ class AIFormatter
7
+ class ASTResolver
8
+ # An immutable struct housing bounds, identification metrics, and static bypassing
9
+ # definitions derived from traversing the AST nodes.
10
+ class SemanticNode
11
+ extend T::Sig
12
+
13
+ sig { returns(String) }
14
+ attr_reader :name, :type
15
+
16
+ sig { returns(Integer) }
17
+ attr_reader :start_line, :end_line
18
+
19
+ sig { returns(T::Array[String]) }
20
+ attr_reader :bypass_reasons
21
+
22
+ sig do
23
+ params(
24
+ name: String,
25
+ type: String,
26
+ start_line: Integer,
27
+ end_line: Integer,
28
+ bypass_reasons: T::Array[String]
29
+ ).void
30
+ end
31
+ def initialize(name:, type:, start_line:, end_line:, bypass_reasons: [])
32
+ @name = name
33
+ @type = type
34
+ @start_line = start_line
35
+ @end_line = end_line
36
+ @bypass_reasons = bypass_reasons
37
+ end
38
+
39
+ sig { params(bypass_reason: String).void }
40
+ def add_bypass(bypass_reason)
41
+ @bypass_reasons << bypass_reason
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require 'parser/current'
5
+ require_relative 'ast_resolver/semantic_node'
5
6
 
6
7
  module SimpleCov
7
8
  module Formatter
@@ -13,37 +14,16 @@ module SimpleCov
13
14
  class ASTResolver
14
15
  extend T::Sig
15
16
 
16
- # An immutable struct housing bounds, identification metrics, and static bypassing
17
- # definitions derived from traversing the AST nodes.
18
- class SemanticNode
19
- extend T::Sig
20
-
21
- sig { returns(String) }
22
- attr_reader :name, :type
23
-
24
- sig { returns(Integer) }
25
- attr_reader :start_line, :end_line
26
-
27
- sig { returns(T::Array[String]) }
28
- attr_reader :bypasses
29
-
30
- sig do
31
- params(
32
- name: String,
33
- type: String,
34
- start_line: Integer,
35
- end_line: Integer,
36
- bypasses: T::Array[String]
37
- ).void
38
- end
39
- def initialize(name:, type:, start_line:, end_line:, bypasses: [])
40
- @name = name
41
- @type = type
42
- @start_line = start_line
43
- @end_line = end_line
44
- @bypasses = bypasses
45
- end
46
- end
17
+ # Separator used to denote namespace nesting (e.g., Module::Class)
18
+ NAMESPACE_SEPARATOR = T.let('::', String)
19
+ # Separator used to denote instance methods (e.g., Class#method)
20
+ INSTANCE_SEPARATOR = T.let('#', String)
21
+ # Separator used to denote singleton/class methods (e.g., Class.method)
22
+ SINGLETON_SEPARATOR = T.let('.', String)
23
+ # Label applied to nodes representing instance methods
24
+ TYPE_INSTANCE_METHOD = T.let('Instance Method', String)
25
+ # Label applied to nodes representing singleton methods
26
+ TYPE_SINGLETON_METHOD = T.let('Singleton Method', String)
47
27
 
48
28
  # Orchestrates the initial mapping algorithm on a target file to extract structural
49
29
  # metadata, circumventing potential syntax violations explicitly.
@@ -54,115 +34,116 @@ module SimpleCov
54
34
  def self.resolve(file_path)
55
35
  return [] unless File.exist?(file_path)
56
36
 
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
37
+ source = File.read(file_path)
38
+ ast, comments = Parser::CurrentRuby.parse_with_comments(source)
39
+
40
+ resolver = new
41
+ nodes = resolver.traverse(ast)
42
+ resolver.assign_bypasses(nodes, comments)
43
+ nodes
64
44
  end
65
45
 
66
46
  # Recursively navigates an abstract node hierarchy, building SemanticNodes mappings
67
47
  # around modules, classes, singleton, and instance methods while aggregating parent paths.
68
48
  #
69
49
  # @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
50
  # @param context [String] An accumulated identifier linking namespaces to inner entities.
72
51
  # @return [Array<SemanticNode>] Accumulation of all sub-tree defined endpoints.
73
52
  sig do
74
- params(node: Parser::AST::Node, comments: T::Array[Parser::Source::Comment],
53
+ params(node: T.nilable(Parser::AST::Node),
75
54
  context: String).returns(T::Array[SemanticNode])
76
55
  end
77
- def traverse(node, comments, context = '')
56
+ def traverse(node, context = '')
78
57
  return [] unless node.is_a?(Parser::AST::Node)
79
58
 
80
59
  nodes = T.let([], T::Array[SemanticNode])
81
- current_context, semantic_node = extract_node_metadata(node, comments, context)
60
+ current_context, semantic_node = extract_node_metadata(node, context)
82
61
  nodes << semantic_node if semantic_node
83
62
 
84
63
  node.children.grep(Parser::AST::Node).each do |child|
85
- nodes.concat(traverse(child, comments, current_context))
64
+ nodes.concat(traverse(child, current_context))
86
65
  end
87
66
 
88
67
  nodes
89
68
  end
90
69
 
70
+ sig { params(nodes: T::Array[SemanticNode], comments: T::Array[Parser::Source::Comment]).void }
71
+ def assign_bypasses(nodes, comments)
72
+ comments.each do |comment|
73
+ comment_text = T.cast(comment.text, String)
74
+ assign_bypass(nodes, comment, comment_text.strip) if comment_text.include?(Constants::NOCOV_DIRECTIVE)
75
+ end
76
+ end
77
+
91
78
  private
92
79
 
80
+ sig { params(nodes: T::Array[SemanticNode], comment: Parser::Source::Comment, bypass_reason: String).void }
81
+ def assign_bypass(nodes, comment, bypass_reason)
82
+ comment_loc = T.cast(comment.loc, Parser::Source::Map)
83
+ comment_line = T.cast(comment_loc.line, Integer)
84
+
85
+ innermost_node = nodes.reverse.find { |node| comment_line.between?(node.start_line - 1, node.end_line + 1) }
86
+ innermost_node&.add_bypass(bypass_reason)
87
+ end
88
+
93
89
  sig do
94
- params(node: Parser::AST::Node, comments: T::Array[Parser::Source::Comment], context: String)
90
+ params(node: Parser::AST::Node, context: String)
95
91
  .returns([String, T.nilable(SemanticNode)])
96
92
  end
97
- def extract_node_metadata(node, comments, context)
93
+ def extract_node_metadata(node, context)
98
94
  case node.type
99
95
  when :class, :module
100
- extract_class_metadata(node, comments, context)
96
+ extract_class_metadata(node, context)
101
97
  when :def
102
- extract_instance_method_metadata(node, comments, context)
98
+ extract_instance_method_metadata(node, context)
103
99
  when :defs
104
- extract_singleton_method_metadata(node, comments, context)
100
+ extract_singleton_method_metadata(node, context)
105
101
  else
106
102
  [context, nil]
107
103
  end
108
104
  end
109
105
 
110
106
  sig do
111
- params(node: Parser::AST::Node, comments: T::Array[Parser::Source::Comment], context: String)
107
+ params(node: Parser::AST::Node, context: String)
112
108
  .returns([String, T.nilable(SemanticNode)])
113
109
  end
114
- def extract_class_metadata(node, comments, context)
110
+ def extract_class_metadata(node, context)
115
111
  const_node = T.cast(node.children[0], Parser::AST::Node)
116
112
  const_node_name = T.cast(T.cast(const_node.loc, Parser::Source::Map::Constant).name, Parser::Source::Range)
117
113
  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)]
114
+ new_context = context.empty? ? name : "#{context}#{NAMESPACE_SEPARATOR}#{name}"
115
+ [new_context, build_node(node, new_context, node.type.to_s.capitalize)]
120
116
  end
121
117
 
122
118
  sig do
123
- params(node: Parser::AST::Node, comments: T::Array[Parser::Source::Comment], context: String)
119
+ params(node: Parser::AST::Node, context: String)
124
120
  .returns([String, T.nilable(SemanticNode)])
125
121
  end
126
- def extract_instance_method_metadata(node, comments, context)
122
+ def extract_instance_method_metadata(node, context)
127
123
  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')]
124
+ new_context = context.empty? ? "#{INSTANCE_SEPARATOR}#{name}" : "#{context}#{INSTANCE_SEPARATOR}#{name}"
125
+ [new_context, build_node(node, new_context, TYPE_INSTANCE_METHOD)]
130
126
  end
131
127
 
132
128
  sig do
133
- params(node: Parser::AST::Node, comments: T::Array[Parser::Source::Comment], context: String)
129
+ params(node: Parser::AST::Node, context: String)
134
130
  .returns([String, T.nilable(SemanticNode)])
135
131
  end
136
- def extract_singleton_method_metadata(node, comments, context)
132
+ def extract_singleton_method_metadata(node, context)
137
133
  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')]
134
+ new_context = context.empty? ? "#{SINGLETON_SEPARATOR}#{name}" : "#{context}#{SINGLETON_SEPARATOR}#{name}"
135
+ [new_context, build_node(node, new_context, TYPE_SINGLETON_METHOD)]
140
136
  end
141
137
 
142
138
  sig do
143
- params(node: Parser::AST::Node, comments: T::Array[Parser::Source::Comment], name: String,
139
+ params(node: Parser::AST::Node, name: String,
144
140
  type: String).returns(SemanticNode)
145
141
  end
146
- def build_node(node, comments, name, type)
142
+ def build_node(node, name, type)
147
143
  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
153
-
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|
160
- c_loc = T.cast(c.loc, Parser::Source::Map)
161
- c_line = T.cast(c_loc.line, Integer)
162
- c_text = T.cast(c.text, String)
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 }
144
+ start_line = T.cast(loc.line, Integer)
145
+ end_line = T.cast(loc.last_line, Integer)
146
+ SemanticNode.new(name: name, type: type, start_line: start_line, end_line: end_line, bypass_reasons: [])
166
147
  end
167
148
  end
168
149
  end
@@ -10,6 +10,19 @@ module SimpleCov
10
10
  class Configuration
11
11
  extend T::Sig
12
12
 
13
+ # Default output path for the generated markdown report
14
+ DEFAULT_REPORT_PATH = T.let('coverage/ai_report.md', String)
15
+ # Default maximum size of the output file in kilobytes
16
+ DEFAULT_MAX_FILE_SIZE_KB = T.let(50, Integer)
17
+ # Default maximum number of lines for a single snippet
18
+ DEFAULT_MAX_SNIPPET_LINES = T.let(5, Integer)
19
+ # Default flag for outputting to console
20
+ DEFAULT_OUTPUT_TO_CONSOLE = T.let(false, T::Boolean)
21
+ # Default granularity level of the report
22
+ DEFAULT_GRANULARITY = T.let(:fine, Symbol)
23
+ # Default flag for including bypassed regions in the report
24
+ DEFAULT_INCLUDE_BYPASSES = T.let(true, T::Boolean)
25
+
13
26
  # The absolute or relative system path where the final token-efficient markdown
14
27
  # document acts as an artifact.
15
28
  # @return [String]
@@ -44,12 +57,12 @@ module SimpleCov
44
57
 
45
58
  sig { void }
46
59
  def initialize
47
- @report_path = T.let('coverage/ai_report.md', String)
48
- @max_file_size_kb = T.let(50, Integer)
49
- @max_snippet_lines = T.let(5, Integer)
50
- @output_to_console = T.let(false, T::Boolean)
51
- @granularity = T.let(:fine, Symbol)
52
- @include_bypasses = T.let(true, T::Boolean)
60
+ @report_path = T.let(DEFAULT_REPORT_PATH, String)
61
+ @max_file_size_kb = T.let(DEFAULT_MAX_FILE_SIZE_KB, Integer)
62
+ @max_snippet_lines = T.let(DEFAULT_MAX_SNIPPET_LINES, Integer)
63
+ @output_to_console = T.let(DEFAULT_OUTPUT_TO_CONSOLE, T::Boolean)
64
+ @granularity = T.let(DEFAULT_GRANULARITY, Symbol)
65
+ @include_bypasses = T.let(DEFAULT_INCLUDE_BYPASSES, T::Boolean)
53
66
  end
54
67
  end
55
68
  end
@@ -0,0 +1,20 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module SimpleCov
5
+ module Formatter
6
+ class AIFormatter
7
+ # Houses globally shared constant values utilized across the AI formatter suite
8
+ # to prevent magic string duplication and establish a single source of truth.
9
+ module Constants
10
+ extend T::Sig
11
+
12
+ # The explicit value used to designate a file or coverage block as perfectly covered.
13
+ PERFECT_COVERAGE_PERCENT = T.let(100.0, Float)
14
+
15
+ # The directive typically employed within comments to force coverage engines to bypass execution tracking.
16
+ NOCOV_DIRECTIVE = T.let(':nocov:', String)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,70 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module SimpleCov
5
+ module Formatter
6
+ class AIFormatter
7
+ class MarkdownBuilder
8
+ # Enriches SimpleCov branch data with column information from raw coverage data.
9
+ class BranchEnricher
10
+ extend T::Sig
11
+
12
+ sig { params(file: SimpleCov::SourceFile).void }
13
+ def self.enrich(file)
14
+ return unless file.respond_to?(:coverage_data)
15
+
16
+ case (cov = file.coverage_data)
17
+ when Hash
18
+ case (branches = cov['branches'])
19
+ when Hash
20
+ process_branches(file, branches)
21
+ end
22
+ end
23
+ rescue StandardError
24
+ nil
25
+ end
26
+
27
+ class << self
28
+ extend T::Sig
29
+
30
+ private
31
+
32
+ sig { params(file: SimpleCov::SourceFile, branches: T::Hash[BasicObject, BasicObject]).void }
33
+ def process_branches(file, branches)
34
+ raw = extract_raw_branches(file, branches)
35
+ apply_column_data(file.branches, raw)
36
+ end
37
+
38
+ sig { params(file: SimpleCov::SourceFile, branches: T::Hash[BasicObject, BasicObject]).returns(T::Array[BasicObject]) }
39
+ def extract_raw_branches(file, branches)
40
+ branches.flat_map do |_condition, branch_hash|
41
+ case branch_hash
42
+ when Hash
43
+ branch_hash.map do |branch_data, _hit_count|
44
+ T.cast(file.send(:restore_ruby_data_structure, branch_data), BasicObject)
45
+ end
46
+ else
47
+ []
48
+ end
49
+ end
50
+ end
51
+
52
+ sig { params(branches: T::Array[SimpleCov::SourceFile::Branch], raw_branches: T::Array[BasicObject]).void }
53
+ def apply_column_data(branches, raw_branches)
54
+ branches.zip(raw_branches).each do |branch, raw|
55
+ case raw
56
+ when Array
57
+ next unless raw.size >= 6
58
+
59
+ branch.instance_variable_set(:@start_col, T.cast(raw[3], Integer))
60
+ branch.instance_variable_set(:@end_col, T.cast(raw[5], Integer))
61
+ branch.class.send(:attr_reader, :start_col, :end_col) unless branch.respond_to?(:start_col)
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -9,9 +9,21 @@ module SimpleCov
9
9
  class BypassCompiler
10
10
  extend T::Sig
11
11
 
12
- sig { params(result: SimpleCov::Result, builder: MarkdownBuilder).void }
13
- def initialize(result, builder)
14
- @result = result
12
+ # Section heading for bypassed coverage
13
+ HEADING = T.let("## Ignored Coverage Bypasses\n\n", String)
14
+ # Template for formatting file headers in the bypass section
15
+ FILE_HEADING_TEMPLATE = T.let('### `%s`', String)
16
+ # Template for detailing an individual bypass directive
17
+ BYPASS_TEMPLATE = T.let(
18
+ "- `%s`\n " \
19
+ '- **Bypass Present:** Contains `%s` directive artificially ' \
20
+ 'ignoring coverage (Occurrence %d of %d).',
21
+ String
22
+ )
23
+
24
+ sig { params(coverage_metrics: SimpleCov::Result, builder: MarkdownBuilder).void }
25
+ def initialize(coverage_metrics, builder)
26
+ @coverage_metrics = coverage_metrics
15
27
  @builder = builder
16
28
  end
17
29
 
@@ -22,7 +34,7 @@ module SimpleCov
22
34
 
23
35
  return unless has_bypasses
24
36
 
25
- buffer.puts "## Ignored Coverage Bypasses\n\n"
37
+ buffer.puts HEADING
26
38
  buffer.puts bypass_buffer.string
27
39
  end
28
40
 
@@ -31,12 +43,12 @@ module SimpleCov
31
43
  sig { params(buffer: StringIO).returns(T::Boolean) }
32
44
  def compile_all_bypasses(buffer)
33
45
  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?
46
+ T.let(@coverage_metrics.files.to_a, T::Array[SimpleCov::SourceFile]).each do |file|
47
+ bypassed_nodes = fetch_bypassed_nodes(file.filename)
48
+ next if bypassed_nodes.empty?
37
49
 
38
50
  has_bypasses = true
39
- write_file_bypasses(buffer, file, bypassed)
51
+ write_file_bypasses(buffer, file, bypassed_nodes)
40
52
  end
41
53
  has_bypasses
42
54
  end
@@ -44,16 +56,17 @@ module SimpleCov
44
56
  sig { params(filename: String).returns(T::Array[ASTResolver::SemanticNode]) }
45
57
  def fetch_bypassed_nodes(filename)
46
58
  nodes = @builder.try_resolve_ast(filename)
47
- nodes ? nodes.select { |n| n.bypasses.any? } : []
59
+ nodes ? nodes.select { |node| node.bypass_reasons.any? } : []
48
60
  end
49
61
 
50
62
  sig do
51
- params(buffer: StringIO, file: SimpleCov::SourceFile, bypasses: T::Array[ASTResolver::SemanticNode]).void
63
+ params(buffer: StringIO, file: SimpleCov::SourceFile, bypassed_nodes: T::Array[ASTResolver::SemanticNode]).void
52
64
  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."
65
+ def write_file_bypasses(buffer, file, bypassed_nodes)
66
+ buffer.puts format(FILE_HEADING_TEMPLATE, file.project_filename)
67
+ total = bypassed_nodes.size
68
+ bypassed_nodes.each_with_index do |node, idx|
69
+ buffer.puts format(BYPASS_TEMPLATE, node.name, Constants::NOCOV_DIRECTIVE, idx + 1, total)
57
70
  end
58
71
  buffer.puts ''
59
72
  end
@@ -10,22 +10,35 @@ module SimpleCov
10
10
  extend T::Sig
11
11
  include SnippetFormatter
12
12
 
13
- sig { params(result: SimpleCov::Result, config: Configuration, builder: MarkdownBuilder).void }
14
- def initialize(result, config, builder)
15
- @result = result
13
+ # Header for the coverage deficits section
14
+ HEADING = T.let("## Coverage Deficits\n\n", String)
15
+ # Template for file-level deficit headings
16
+ FILE_HEADING_TEMPLATE = T.let('### `%s`', String)
17
+ # Error message for AST parsing failures
18
+ ERROR_AST_FAILED = T.let(" - **ERROR:** AST Parsing Failed. Showing raw line numbers instead.\n", String)
19
+ # Template for node-level deficit headings
20
+ NODE_HEADING_TEMPLATE = T.let('- `%s`', String)
21
+ # Coarse-grained deficit summary message
22
+ DEFICIT_COARSE = T.let(' - **Deficit:** Contains unexecuted lines or branches.', String)
23
+ # Template for a specific line deficit
24
+ LINE_DEFICIT_TEMPLATE = T.let(' - **Line Deficit:** [L%d] `%s` %s', String)
25
+ # Template for a specific branch deficit
26
+ BRANCH_DEFICIT_TEMPLATE = T.let(' - **Branch Deficit:** [L%s] Missing coverage for `%s` branch: `%s` %s',
27
+ String)
28
+
29
+ sig { params(coverage_metrics: SimpleCov::Result, config: Configuration, builder: MarkdownBuilder).void }
30
+ def initialize(coverage_metrics, config, builder)
31
+ @coverage_metrics = coverage_metrics
16
32
  @config = config
17
33
  @builder = builder
18
34
  end
19
35
 
20
36
  sig { params(buffer: StringIO).void }
21
37
  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
- )
38
+ files = find_deficit_files
26
39
  return if files.empty?
27
40
 
28
- buffer.puts "## Coverage Deficits\n\n"
41
+ buffer.puts HEADING
29
42
  files.each do |file|
30
43
  break if @builder.truncate_if_needed?
31
44
 
@@ -35,93 +48,59 @@ module SimpleCov
35
48
 
36
49
  private
37
50
 
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)
51
+ sig { returns(T::Array[SimpleCov::SourceFile]) }
52
+ def find_deficit_files
53
+ files_with_deficits = @coverage_metrics.files.reject do |f|
54
+ line_perfect?(f) && branch_perfect?(f)
55
+ end
56
+ T.let(files_with_deficits.sort_by { |file| [file.covered_percent, file.filename] }, T::Array[SimpleCov::SourceFile])
43
57
  end
44
58
 
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 ''
59
+ sig { params(file: SimpleCov::SourceFile).returns(T::Boolean) }
60
+ def line_perfect?(file)
61
+ file.covered_percent >= Constants::PERFECT_COVERAGE_PERCENT
54
62
  end
55
63
 
56
- sig do
57
- params(buffer: StringIO, file: SimpleCov::SourceFile, nodes: T::Array[ASTResolver::SemanticNode]).void
64
+ sig { params(file: SimpleCov::SourceFile).returns(T::Boolean) }
65
+ def branch_perfect?(file)
66
+ cov = file.respond_to?(:branches_coverage_percent) ? file.branches_coverage_percent : nil
67
+ branch_coverage_perfect?(cov)
58
68
  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
69
 
66
- source_lines ||= fetch_source_lines(file.filename)
67
- format_node_deficit(buffer, node_name, group, source_lines)
70
+ sig { params(coverage: T.nilable(BasicObject)).returns(T::Boolean) }
71
+ def branch_coverage_perfect?(coverage)
72
+ case coverage
73
+ when Float, Integer
74
+ coverage >= Constants::PERFECT_COVERAGE_PERCENT
75
+ else
76
+ case coverage
77
+ when nil then true
78
+ else false
79
+ end
68
80
  end
69
-
70
- buffer.puts ''
71
81
  end
72
82
 
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}`"
83
+ sig { params(buffer: StringIO, file: SimpleCov::SourceFile).void }
84
+ def process_file(buffer, file)
85
+ BranchEnricher.enrich(file)
86
+ buffer.puts format(FILE_HEADING_TEMPLATE, file.project_filename)
87
+
88
+ formatter = DeficitFormatter.new(buffer, @config)
89
+ nodes = @builder.try_resolve_ast(file.filename)
76
90
 
77
- if @config.granularity == :coarse
78
- buffer.puts ' - **Deficit:** Contains unexecuted lines or branches.'
91
+ if nodes
92
+ formatter.process_deficits(file, nodes, -> { safe_readlines(file.filename) })
79
93
  else
80
- format_deficit_group(buffer, group, source_lines)
94
+ formatter.format_raw_deficits(file, safe_readlines(file.filename))
81
95
  end
82
96
  end
83
97
 
84
98
  sig { params(filename: String).returns(T::Array[String]) }
85
- def fetch_source_lines(filename)
99
+ def safe_readlines(filename)
86
100
  File.readlines(filename)
87
101
  rescue StandardError
88
102
  []
89
103
  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
104
  end
126
105
  end
127
106
  end
@@ -0,0 +1,142 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module SimpleCov
5
+ module Formatter
6
+ class AIFormatter
7
+ class MarkdownBuilder
8
+ # Handles the formatting of line and branch deficits into markdown.
9
+ class DeficitFormatter
10
+ extend T::Sig
11
+ include SnippetFormatter
12
+
13
+ # Error message for AST parsing failures
14
+ ERROR_AST_FAILED = T.let(" - **ERROR:** AST Parsing Failed. Showing raw line numbers instead.\n", String)
15
+ # Template for node-level deficit headings
16
+ NODE_HEADING_TEMPLATE = T.let('- `%s`', String)
17
+ # Coarse-grained deficit summary message
18
+ DEFICIT_COARSE = T.let(' - **Deficit:** Contains unexecuted lines or branches.', String)
19
+ # Template for a specific line deficit
20
+ LINE_DEFICIT_TMPL = T.let(' - **Line Deficit:** [L%d] `%s` %s', String)
21
+ # Template for a specific branch deficit
22
+ BRANCH_DEFICIT_TMPL = T.let(' - **Branch Deficit:** [L%s] Missing coverage for `%s` branch: `%s` %s', String)
23
+
24
+ sig { params(buffer: StringIO, config: Configuration).void }
25
+ def initialize(buffer, config)
26
+ @buffer = buffer
27
+ @config = config
28
+ end
29
+
30
+ sig { params(file: SimpleCov::SourceFile, source_lines: T::Array[String]).void }
31
+ def format_raw_deficits(file, source_lines)
32
+ @buffer.puts ERROR_AST_FAILED
33
+ deficit_group = MarkdownBuilder::DeficitGroup.new(lines: file.missed_lines, branches: file.missed_branches)
34
+ format_deficit_group(deficit_group, source_lines)
35
+ @buffer.puts ''
36
+ end
37
+
38
+ sig do
39
+ params(file: SimpleCov::SourceFile, nodes: T::Array[ASTResolver::SemanticNode],
40
+ safe_readlines_proc: T.proc.returns(T::Array[String])).void
41
+ end
42
+ def process_deficits(file, nodes, safe_readlines_proc)
43
+ node_deficits = DeficitGrouper.build(file, nodes)
44
+ source_lines = T.let(nil, T.nilable(T::Array[String]))
45
+
46
+ node_deficits.each do |node_name, deficit_group|
47
+ source_lines ||= safe_readlines_proc.call
48
+ format_node_deficit(node_name, deficit_group, source_lines)
49
+ end
50
+
51
+ @buffer.puts ''
52
+ end
53
+
54
+ private
55
+
56
+ sig { params(node_name: String, deficit_group: DeficitGroup, source_lines: T::Array[String]).void }
57
+ def format_node_deficit(node_name, deficit_group, source_lines)
58
+ @buffer.puts format(NODE_HEADING_TEMPLATE, node_name)
59
+
60
+ if @config.granularity == :coarse
61
+ @buffer.puts DEFICIT_COARSE
62
+ else
63
+ format_deficit_group(deficit_group, source_lines)
64
+ end
65
+ end
66
+
67
+ sig { params(deficit_group: DeficitGroup, source_lines: T::Array[String]).void }
68
+ def format_deficit_group(deficit_group, source_lines)
69
+ deficit_group.lines.each do |line|
70
+ write_line_snippet(line, source_lines, deficit_group.semantic_node)
71
+ end
72
+
73
+ deficit_group.branches.each do |branch|
74
+ write_branch_snippet(branch, source_lines, deficit_group.semantic_node)
75
+ end
76
+ end
77
+
78
+ sig do
79
+ params(line: SimpleCov::SourceFile::Line, source_lines: T::Array[String],
80
+ node: T.nilable(ASTResolver::SemanticNode)).void
81
+ end
82
+ def write_line_snippet(line, source_lines, node)
83
+ text = truncate_snippet(fetch_snippet_text([line.line_number], source_lines), @config.max_snippet_lines)
84
+ occurrence_str = calculate_occurrence(line.line_number, source_lines, node)
85
+ @buffer.puts format(LINE_DEFICIT_TMPL, line.line_number, text, occurrence_str).rstrip
86
+ end
87
+
88
+ sig do
89
+ params(branch: SimpleCov::SourceFile::Branch, source_lines: T::Array[String],
90
+ node: T.nilable(ASTResolver::SemanticNode)).void
91
+ end
92
+ def write_branch_snippet(branch, source_lines, node)
93
+ text = truncate_snippet(extract_branch_text(branch, source_lines), @config.max_snippet_lines)
94
+ occurrence = calculate_occurrence(branch.start_line, source_lines, node)
95
+ label = format_branch_label(branch)
96
+
97
+ type_val = branch.respond_to?(:type) ? branch.type : nil
98
+
99
+ type_label = type_val ? type_val.to_s : 'conditional'
100
+ @buffer.puts format(BRANCH_DEFICIT_TMPL, label, type_label, text, occurrence).rstrip
101
+ end
102
+
103
+ sig { params(branch: SimpleCov::SourceFile::Branch).returns(String) }
104
+ def format_branch_label(branch)
105
+ branch.start_line == branch.end_line ? branch.start_line.to_s : "#{branch.start_line}-#{branch.end_line}"
106
+ end
107
+
108
+ sig { params(branch: SimpleCov::SourceFile::Branch, source_lines: T::Array[String]).returns(String) }
109
+ def extract_branch_text(branch, source_lines)
110
+ start_col = fetch_column(branch, :start_col)
111
+ end_col = fetch_column(branch, :end_col)
112
+
113
+ inline_text = extract_inline_branch(branch, start_col, end_col, source_lines)
114
+ return inline_text if inline_text
115
+
116
+ lines_range = T.cast((branch.start_line..branch.end_line).to_a, T::Array[Integer])
117
+ fetch_snippet_text(lines_range, source_lines)
118
+ end
119
+
120
+ sig { params(branch: SimpleCov::SourceFile::Branch, col: Symbol).returns(T.nilable(Integer)) }
121
+ def fetch_column(branch, col)
122
+ val = branch.respond_to?(col) ? branch.public_send(col) : branch.instance_variable_get(:"@#{col}")
123
+ T.cast(val, T.nilable(Integer))
124
+ end
125
+
126
+ sig do
127
+ params(branch: SimpleCov::SourceFile::Branch, start_col: T.nilable(Integer),
128
+ end_col: T.nilable(Integer), source_lines: T::Array[String]).returns(T.nilable(String))
129
+ end
130
+ def extract_inline_branch(branch, start_col, end_col, source_lines)
131
+ return nil unless branch.start_line == branch.end_line && start_col && end_col
132
+
133
+ line_text = source_lines[branch.start_line - 1]
134
+ return nil unless line_text && line_text.length >= end_col
135
+
136
+ line_text[start_col...end_col].to_s.strip
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -9,6 +9,11 @@ module SimpleCov
9
9
  class DeficitGrouper
10
10
  extend T::Sig
11
11
 
12
+ # Fallback identifier format for unassociated lines
13
+ FALLBACK_LINE_NAME = T.let('Line %d', String)
14
+ # Fallback identifier format for unassociated branches
15
+ FALLBACK_BRANCH_NAME = T.let('Lines %d-%d', String)
16
+
12
17
  sig { returns(T::Hash[String, DeficitGroup]) }
13
18
  attr_reader :node_deficits
14
19
 
@@ -26,20 +31,39 @@ module SimpleCov
26
31
  grouper = new(nodes)
27
32
  grouper.group_missed_lines(file)
28
33
  grouper.group_missed_branches(file)
29
- grouper.node_deficits
34
+ grouper.sort_deficits
35
+ end
36
+
37
+ sig { returns(T::Hash[String, DeficitGroup]) }
38
+ def sort_deficits
39
+ T.let(
40
+ @node_deficits.sort_by do |node_name, deficit_group|
41
+ if (semantic_node = deficit_group.semantic_node)
42
+ semantic_node.start_line
43
+ else
44
+ node_name.match(/\d+/)&.to_s&.to_i || Float::INFINITY
45
+ end
46
+ end.to_h,
47
+ T::Hash[String, DeficitGroup]
48
+ )
30
49
  end
31
50
 
32
51
  sig { params(file: SimpleCov::SourceFile).void }
33
52
  def group_missed_lines(file)
34
53
  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
54
+ add_missed_line(line)
40
55
  end
41
56
  end
42
57
 
58
+ sig { params(line: SimpleCov::SourceFile::Line).void }
59
+ def add_missed_line(line)
60
+ line_num = line.line_number
61
+ matched_node = @nodes.reverse.find { |node| line_num.between?(node.start_line, node.end_line) }
62
+ node_name = matched_node ? matched_node.name : format(FALLBACK_LINE_NAME, line_num)
63
+ @node_deficits[node_name] ||= DeficitGroup.new(semantic_node: matched_node)
64
+ T.must(@node_deficits[node_name]).lines << line
65
+ end
66
+
43
67
  sig { params(file: SimpleCov::SourceFile).void }
44
68
  def group_missed_branches(file)
45
69
  return unless file.respond_to?(:branches) && file.branches.any?
@@ -53,9 +77,11 @@ module SimpleCov
53
77
  def add_missed_branch(branch)
54
78
  start_line = branch.start_line
55
79
  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)
80
+ matched_node = @nodes.reverse.find do |node|
81
+ start_line >= node.start_line && end_line <= node.end_line
82
+ end
83
+ node_name = matched_node ? matched_node.name : format(FALLBACK_BRANCH_NAME, start_line, end_line)
84
+ @node_deficits[node_name] ||= DeficitGroup.new(semantic_node: matched_node)
59
85
  T.must(@node_deficits[node_name]).branches << branch
60
86
  end
61
87
  end
@@ -9,6 +9,13 @@ module SimpleCov
9
9
  module SnippetFormatter
10
10
  extend T::Sig
11
11
 
12
+ # Approximate maximum characters per line for truncation calculation
13
+ ESTIMATED_CHARS_PER_LINE = T.let(80, Integer)
14
+ # Suffix added to truncated snippets
15
+ TRUNCATION_ELLIPSIS = T.let('...', String)
16
+ # Template for identical snippet occurrences indicator
17
+ OCCURRENCE_TEMPLATE = T.let('(Occurrence %d of %d).', String)
18
+
12
19
  # Extracts and normalizes exact string literals from the source file arrays.
13
20
  #
14
21
  # @param line_nums [Array<Integer>] Target line coordinates.
@@ -16,18 +23,22 @@ module SimpleCov
16
23
  # @return [String] Joined snippet text.
17
24
  sig { params(line_nums: T::Array[Integer], source_lines: T::Array[String]).returns(String) }
18
25
  def fetch_snippet_text(line_nums, source_lines)
19
- line_nums.filter_map { |ln| source_lines[ln - 1]&.strip }.reject(&:empty?).join(' ')
26
+ line_nums.filter_map { |line_number| source_lines[line_number - 1]&.strip }.reject(&:empty?).join(' ')
20
27
  end
21
28
 
22
29
  # Safely limits the character length of a code snippet according to global configurations.
23
30
  #
24
- # @param text [String] The snippet to potentially truncate.
31
+ # @param snippet_text [String] The snippet to potentially truncate.
25
32
  # @param max_snippet_lines [Integer] The configured max lines.
26
33
  # @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
34
+ sig { params(snippet_text: String, max_snippet_lines: Integer).returns(String) }
35
+ def truncate_snippet(snippet_text, max_snippet_lines)
36
+ max_chars = max_snippet_lines * ESTIMATED_CHARS_PER_LINE
37
+ if snippet_text.length > max_chars
38
+ "#{snippet_text[0...max_chars]}#{TRUNCATION_ELLIPSIS}"
39
+ else
40
+ snippet_text
41
+ end
31
42
  end
32
43
 
33
44
  # Disambiguates identical code snippets within the same semantic block (e.g., "(Occurrence 2 of 3)").
@@ -48,23 +59,23 @@ module SimpleCov
48
59
 
49
60
  occurrences, current = count_snippet_occurrences(first_line_of_snippet, line_num, source_lines, node)
50
61
 
51
- occurrences > 1 ? "(Occurrence #{current} of #{occurrences}) " : ''
62
+ occurrences > 1 ? Kernel.format(OCCURRENCE_TEMPLATE, current, occurrences) : ''
52
63
  end
53
64
 
54
65
  sig do
55
- params(snippet: String, target_ln: Integer, source_lines: T::Array[String],
66
+ params(snippet: String, target_line_number: Integer, source_lines: T::Array[String],
56
67
  node: ASTResolver::SemanticNode).returns([Integer, Integer])
57
68
  end
58
- def count_snippet_occurrences(snippet, target_ln, source_lines, node)
69
+ def count_snippet_occurrences(snippet, target_line_number, source_lines, node)
59
70
  occurrences = 0
60
71
  current_occurrence = 1
61
72
 
62
- (node.start_line..node.end_line).each do |ln|
63
- line_content = source_lines[ln - 1]&.strip
73
+ (node.start_line..node.end_line).each do |line_number|
74
+ line_content = source_lines[line_number - 1]&.strip
64
75
  next unless line_content == snippet
65
76
 
66
77
  occurrences += 1
67
- current_occurrence = occurrences if ln == target_ln
78
+ current_occurrence = occurrences if line_number == target_line_number
68
79
  end
69
80
 
70
81
  [occurrences, current_occurrence]
@@ -2,9 +2,13 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require_relative 'ast_resolver'
5
+ require 'time'
6
+ require 'stringio'
5
7
  require_relative 'markdown_builder/snippet_formatter'
6
8
  require_relative 'markdown_builder/bypass_compiler'
7
9
  require_relative 'markdown_builder/deficit_grouper'
10
+ require_relative 'markdown_builder/branch_enricher'
11
+ require_relative 'markdown_builder/deficit_formatter'
8
12
  require_relative 'markdown_builder/deficit_compiler'
9
13
 
10
14
  module SimpleCov
@@ -17,6 +21,35 @@ module SimpleCov
17
21
  extend T::Sig
18
22
  include SnippetFormatter
19
23
 
24
+ # The number of bytes in a kilobyte
25
+ BYTES_PER_KB = T.let(1024.0, Float)
26
+ # Text representation for a passed coverage check
27
+ STATUS_PASSED = T.let('PASSED', String)
28
+ # Text representation for a failed coverage check
29
+ STATUS_FAILED = T.let('FAILED', String)
30
+
31
+ # Template for the report header
32
+ HEADER_TEMPLATE = T.let(
33
+ "# AI Coverage Digest\n" \
34
+ "**Status:** %<status>s\n" \
35
+ "**Global Line Coverage:** %<line_pct>s%%\n" \
36
+ "**Global Branch Coverage:** %<branch_pct>s%%\n" \
37
+ "**Generated At:** %<time>s (Local Timezone)\n",
38
+ String
39
+ )
40
+
41
+ # Alert heading for truncated reports
42
+ TRUNCATION_ALERT_HEADING = T.let('> **[WARNING] TRUNCATION NOTIFICATION:**', String)
43
+ # Alert body for truncated reports
44
+ TRUNCATION_ALERT_BODY = T.let(
45
+ '> The total coverage deficit report exceeded the maximum token ' \
46
+ 'constraint (%<limit>d kB). ' \
47
+ 'The report was truncated. The deficits detailed above represent ' \
48
+ 'the lowest-coverage (most critical) files. ' \
49
+ 'Please resolve these deficits to reveal the remaining uncovered files in subsequent test runs.',
50
+ String
51
+ )
52
+
20
53
  # Groups unexecuted lines and branches under their common semantic node.
21
54
  class DeficitGroup < T::Struct
22
55
  # @return [ASTResolver::SemanticNode, nil] The corresponding structural boundary
@@ -29,11 +62,11 @@ module SimpleCov
29
62
 
30
63
  # Initializes the Markdown sequence compilation.
31
64
  #
32
- # @param result [SimpleCov::Result] Application-wide coverage aggregation metrics
65
+ # @param coverage_metrics [SimpleCov::Result] Application-wide coverage aggregation metrics
33
66
  # @param config [Configuration] Pre-registered runtime toggles
34
- sig { params(result: SimpleCov::Result, config: Configuration).void }
35
- def initialize(result, config)
36
- @result = T.let(result, SimpleCov::Result)
67
+ sig { params(coverage_metrics: SimpleCov::Result, config: Configuration).void }
68
+ def initialize(coverage_metrics, config)
69
+ @coverage_metrics = T.let(coverage_metrics, SimpleCov::Result)
37
70
  @config = T.let(config, Configuration)
38
71
  @buffer = T.let(StringIO.new, StringIO)
39
72
  @file_count = T.let(0, Integer)
@@ -48,8 +81,8 @@ module SimpleCov
48
81
  sig { returns(String) }
49
82
  def build
50
83
  write_header
51
- DeficitCompiler.new(@result, @config, self).write_deficits(@buffer)
52
- BypassCompiler.new(@result, self).write_bypasses(@buffer) if @config.include_bypasses
84
+ DeficitCompiler.new(@coverage_metrics, @config, self).write_deficits(@buffer)
85
+ BypassCompiler.new(@coverage_metrics, self).write_bypasses(@buffer) if @config.include_bypasses
53
86
  write_truncation_warning if @truncated
54
87
  @buffer.string
55
88
  end
@@ -63,7 +96,7 @@ module SimpleCov
63
96
 
64
97
  sig { returns(T::Boolean) }
65
98
  def truncate_if_needed?
66
- return false unless @buffer.size / 1024.0 > @config.max_file_size_kb
99
+ return false unless @buffer.size / BYTES_PER_KB > @config.max_file_size_kb
67
100
 
68
101
  @truncated = true
69
102
  true
@@ -74,32 +107,36 @@ module SimpleCov
74
107
  # Writes the summary header containing global coverage percentages and generation metadata.
75
108
  sig { void }
76
109
  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}"
83
- @buffer.puts ''
110
+ covered_pct = @coverage_metrics.covered_percent
111
+ status = covered_pct >= Constants::PERFECT_COVERAGE_PERCENT ? STATUS_PASSED : STATUS_FAILED
112
+ @buffer.puts format(
113
+ HEADER_TEMPLATE,
114
+ status: status,
115
+ line_pct: covered_pct.round(1),
116
+ branch_pct: calculate_branch_pct.round(1),
117
+ time: Time.now.iso8601
118
+ )
84
119
  end
85
120
 
86
121
  sig { returns(Float) }
87
122
  def calculate_branch_pct
88
- return 0.0 unless @result.respond_to?(:covered_branches) && @result.respond_to?(:total_branches)
123
+ unless @coverage_metrics.respond_to?(:covered_branches) &&
124
+ @coverage_metrics.respond_to?(:total_branches)
125
+ return 0.0
126
+ end
89
127
 
90
- T.cast(@result.covered_branches, Float) / @result.total_branches * 100
91
- rescue StandardError
92
- 0.0
128
+ total = @coverage_metrics.total_branches
129
+ return 0.0 if total.to_i.zero?
130
+
131
+ covered = @coverage_metrics.covered_branches
132
+ covered.to_f / total * Constants::PERFECT_COVERAGE_PERCENT
93
133
  end
94
134
 
95
135
  # Appends a critical alert if the output hit the token-ceiling constraint and was forcibly terminated.
96
136
  sig { void }
97
137
  def write_truncation_warning
98
- @buffer.puts '> **[WARNING] TRUNCATION NOTIFICATION:**'
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
138
+ @buffer.puts TRUNCATION_ALERT_HEADING
139
+ @buffer.puts format(TRUNCATION_ALERT_BODY, limit: @config.max_file_size_kb)
103
140
  end
104
141
  end
105
142
  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.3', String)
11
+ VERSION = T.let('0.10.5', String)
12
12
  end
13
13
  end
14
14
  end
data/lib/simplecov-ai.rb CHANGED
@@ -5,6 +5,7 @@ require 'sorbet-runtime'
5
5
  require 'simplecov'
6
6
  require 'parser/current'
7
7
 
8
+ require_relative 'simplecov-ai/constants'
8
9
  require_relative 'simplecov-ai/version'
9
10
  require_relative 'simplecov-ai/configuration'
10
11
  require_relative 'simplecov-ai/ast_resolver'
@@ -21,6 +22,9 @@ module SimpleCov
21
22
  class AIFormatter
22
23
  extend T::Sig
23
24
 
25
+ # The standard output prefix used when reporting generation success.
26
+ SUCCESS_LOG_PREFIX = T.let("\n[SimpleCov AI Formatter] Digest written to ", String)
27
+
24
28
  # Retrieves the global configuration for the AI formatter.
25
29
  # The instantiation pattern ensures that defaults are securely lazily loaded
26
30
  # before any coverage processing begins.
@@ -46,18 +50,18 @@ module SimpleCov
46
50
  # directory structures, and securely persists the result to disk to guarantee
47
51
  # an idempotent and strictly defined output location.
48
52
  #
49
- # @param result [SimpleCov::Result] The test coverage outcome generated by SimpleCov.
53
+ # @param coverage_metrics [SimpleCov::Result] The test coverage outcome generated by SimpleCov.
50
54
  # @return [void]
51
- sig { params(result: SimpleCov::Result).void }
52
- def format(result)
55
+ sig { params(coverage_metrics: SimpleCov::Result).void }
56
+ def format(coverage_metrics)
53
57
  config = self.class.configuration
54
- builder = MarkdownBuilder.new(result, config)
58
+ builder = MarkdownBuilder.new(coverage_metrics, config)
55
59
  digest = builder.build
56
60
 
57
61
  FileUtils.mkdir_p(File.dirname(config.report_path))
58
62
  File.write(config.report_path, digest)
59
63
 
60
- puts "\n[SimpleCov AI Formatter] Digest written to #{config.report_path}" if config.output_to_console
64
+ puts "#{SUCCESS_LOG_PREFIX}#{config.report_path}" if config.output_to_console
61
65
  end
62
66
  end
63
67
  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.3
4
+ version: 0.10.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vitalii Lazebnyi
@@ -315,10 +315,14 @@ files:
315
315
  - certs/simplecov-ai-public_cert.pem
316
316
  - lib/simplecov-ai.rb
317
317
  - lib/simplecov-ai/ast_resolver.rb
318
+ - lib/simplecov-ai/ast_resolver/semantic_node.rb
318
319
  - lib/simplecov-ai/configuration.rb
320
+ - lib/simplecov-ai/constants.rb
319
321
  - lib/simplecov-ai/markdown_builder.rb
322
+ - lib/simplecov-ai/markdown_builder/branch_enricher.rb
320
323
  - lib/simplecov-ai/markdown_builder/bypass_compiler.rb
321
324
  - lib/simplecov-ai/markdown_builder/deficit_compiler.rb
325
+ - lib/simplecov-ai/markdown_builder/deficit_formatter.rb
322
326
  - lib/simplecov-ai/markdown_builder/deficit_grouper.rb
323
327
  - lib/simplecov-ai/markdown_builder/snippet_formatter.rb
324
328
  - lib/simplecov-ai/version.rb
@@ -336,14 +340,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
336
340
  requirements:
337
341
  - - ">="
338
342
  - !ruby/object:Gem::Version
339
- version: 3.0.0
343
+ version: 2.7.0
340
344
  required_rubygems_version: !ruby/object:Gem::Requirement
341
345
  requirements:
342
346
  - - ">="
343
347
  - !ruby/object:Gem::Version
344
348
  version: '0'
345
349
  requirements: []
346
- rubygems_version: 4.0.6
350
+ rubygems_version: 4.0.10
347
351
  specification_version: 4
348
352
  summary: An AI-optimized Markdown formatter for SimpleCov utilizing AST mapping.
349
353
  test_files: []
metadata.gz.sig CHANGED
Binary file