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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/lib/simplecov-ai/ast_resolver/semantic_node.rb +47 -0
- data/lib/simplecov-ai/ast_resolver.rb +61 -80
- data/lib/simplecov-ai/configuration.rb +19 -6
- data/lib/simplecov-ai/constants.rb +20 -0
- data/lib/simplecov-ai/markdown_builder/branch_enricher.rb +70 -0
- data/lib/simplecov-ai/markdown_builder/bypass_compiler.rb +27 -14
- data/lib/simplecov-ai/markdown_builder/deficit_compiler.rb +55 -76
- data/lib/simplecov-ai/markdown_builder/deficit_formatter.rb +142 -0
- data/lib/simplecov-ai/markdown_builder/deficit_grouper.rb +35 -9
- data/lib/simplecov-ai/markdown_builder/snippet_formatter.rb +23 -12
- data/lib/simplecov-ai/markdown_builder.rb +60 -23
- data/lib/simplecov-ai/version.rb +1 -1
- data/lib/simplecov-ai.rb +9 -5
- data.tar.gz.sig +0 -0
- metadata +7 -3
- metadata.gz.sig +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 72c1e34acc5609236e3551bf3759099948f9dd98ae12b04ee249ea0e03682e79
|
|
4
|
+
data.tar.gz: 84d74a91bc0ac99d9841242aa095a5876316f8493e8a1d9ac8be1aca1146003c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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,
|
|
53
|
+
params(node: T.nilable(Parser::AST::Node),
|
|
75
54
|
context: String).returns(T::Array[SemanticNode])
|
|
76
55
|
end
|
|
77
|
-
def traverse(node,
|
|
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,
|
|
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,
|
|
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,
|
|
90
|
+
params(node: Parser::AST::Node, context: String)
|
|
95
91
|
.returns([String, T.nilable(SemanticNode)])
|
|
96
92
|
end
|
|
97
|
-
def extract_node_metadata(node,
|
|
93
|
+
def extract_node_metadata(node, context)
|
|
98
94
|
case node.type
|
|
99
95
|
when :class, :module
|
|
100
|
-
extract_class_metadata(node,
|
|
96
|
+
extract_class_metadata(node, context)
|
|
101
97
|
when :def
|
|
102
|
-
extract_instance_method_metadata(node,
|
|
98
|
+
extract_instance_method_metadata(node, context)
|
|
103
99
|
when :defs
|
|
104
|
-
extract_singleton_method_metadata(node,
|
|
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,
|
|
107
|
+
params(node: Parser::AST::Node, context: String)
|
|
112
108
|
.returns([String, T.nilable(SemanticNode)])
|
|
113
109
|
end
|
|
114
|
-
def extract_class_metadata(node,
|
|
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
|
-
|
|
119
|
-
[
|
|
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,
|
|
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,
|
|
122
|
+
def extract_instance_method_metadata(node, context)
|
|
127
123
|
name = T.cast(node.children.first, Symbol).to_s
|
|
128
|
-
|
|
129
|
-
[
|
|
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,
|
|
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,
|
|
132
|
+
def extract_singleton_method_metadata(node, context)
|
|
137
133
|
name = T.cast(node.children[1], Symbol).to_s
|
|
138
|
-
|
|
139
|
-
[
|
|
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,
|
|
139
|
+
params(node: Parser::AST::Node, name: String,
|
|
144
140
|
type: String).returns(SemanticNode)
|
|
145
141
|
end
|
|
146
|
-
def build_node(node,
|
|
142
|
+
def build_node(node, name, type)
|
|
147
143
|
loc = T.cast(node.loc, Parser::Source::Map)
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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(
|
|
48
|
-
@max_file_size_kb = T.let(
|
|
49
|
-
@max_snippet_lines = T.let(
|
|
50
|
-
@output_to_console = T.let(
|
|
51
|
-
@granularity = T.let(
|
|
52
|
-
@include_bypasses = T.let(
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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(@
|
|
35
|
-
|
|
36
|
-
next if
|
|
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,
|
|
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 { |
|
|
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,
|
|
63
|
+
params(buffer: StringIO, file: SimpleCov::SourceFile, bypassed_nodes: T::Array[ASTResolver::SemanticNode]).void
|
|
52
64
|
end
|
|
53
|
-
def write_file_bypasses(buffer, file,
|
|
54
|
-
buffer.puts
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 =
|
|
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
|
|
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 {
|
|
39
|
-
def
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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(
|
|
46
|
-
def
|
|
47
|
-
|
|
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
|
|
57
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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,
|
|
74
|
-
def
|
|
75
|
-
|
|
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
|
|
78
|
-
|
|
91
|
+
if nodes
|
|
92
|
+
formatter.process_deficits(file, nodes, -> { safe_readlines(file.filename) })
|
|
79
93
|
else
|
|
80
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 { |
|
|
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
|
|
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(
|
|
28
|
-
def truncate_snippet(
|
|
29
|
-
max_chars = max_snippet_lines *
|
|
30
|
-
|
|
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 ?
|
|
62
|
+
occurrences > 1 ? Kernel.format(OCCURRENCE_TEMPLATE, current, occurrences) : ''
|
|
52
63
|
end
|
|
53
64
|
|
|
54
65
|
sig do
|
|
55
|
-
params(snippet: 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,
|
|
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 |
|
|
63
|
-
line_content = source_lines[
|
|
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
|
|
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
|
|
65
|
+
# @param coverage_metrics [SimpleCov::Result] Application-wide coverage aggregation metrics
|
|
33
66
|
# @param config [Configuration] Pre-registered runtime toggles
|
|
34
|
-
sig { params(
|
|
35
|
-
def initialize(
|
|
36
|
-
@
|
|
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(@
|
|
52
|
-
BypassCompiler.new(@
|
|
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 /
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
@buffer.puts
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
123
|
+
unless @coverage_metrics.respond_to?(:covered_branches) &&
|
|
124
|
+
@coverage_metrics.respond_to?(:total_branches)
|
|
125
|
+
return 0.0
|
|
126
|
+
end
|
|
89
127
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
99
|
-
|
|
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
|
data/lib/simplecov-ai/version.rb
CHANGED
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
|
|
53
|
+
# @param coverage_metrics [SimpleCov::Result] The test coverage outcome generated by SimpleCov.
|
|
50
54
|
# @return [void]
|
|
51
|
-
sig { params(
|
|
52
|
-
def format(
|
|
55
|
+
sig { params(coverage_metrics: SimpleCov::Result).void }
|
|
56
|
+
def format(coverage_metrics)
|
|
53
57
|
config = self.class.configuration
|
|
54
|
-
builder = MarkdownBuilder.new(
|
|
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 "
|
|
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.
|
|
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:
|
|
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.
|
|
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
|