Skip to content

Commit 9c837b6

Browse files
feat: force comment on coverage-ignore annotation (#222)
1 parent 842601f commit 9c837b6

File tree

12 files changed

+345
-52
lines changed

12 files changed

+345
-52
lines changed

.testcoverage.example.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ exclude:
4141
- \.pb\.go$ # excludes all protobuf generated files
4242
- ^pkg/bar # exclude package `pkg/bar`
4343

44+
# (optional; default false)
45+
# When true, requires all coverage-ignore annotations to include explanatory comments
46+
force-annotation-comment: false
47+
4448
# If specified, saves the current test coverage breakdown to this file.
4549
#
4650
# Typically, this breakdown is generated only for main (base) branches and

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,10 @@ exclude:
125125
- \.pb\.go$ # excludes all protobuf generated files
126126
- ^pkg/bar # exclude package `pkg/bar`
127127

128+
# (optional; default false)
129+
# When true, requires all coverage-ignore annotations to include explanatory comments
130+
force-annotation-comment: false
131+
128132
# If specified, saves the current test coverage breakdown to this file.
129133
#
130134
# Typically, this breakdown is generated only for main (base) branches and

pkg/testcoverage/check.go

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,10 @@ func reportForHuman(w io.Writer, result AnalyzeResult) string {
9292

9393
func GenerateCoverageStats(cfg Config) ([]coverage.Stats, error) {
9494
return coverage.GenerateCoverageStats(coverage.Config{ //nolint:wrapcheck // err wrapped above
95-
Profiles: strings.Split(cfg.Profile, ","),
96-
ExcludePaths: cfg.Exclude.Paths,
97-
SourceDir: cfg.SourceDir,
95+
Profiles: strings.Split(cfg.Profile, ","),
96+
ExcludePaths: cfg.Exclude.Paths,
97+
SourceDir: cfg.SourceDir,
98+
ForceAnnotationComment: cfg.ForceAnnotationComment,
9899
})
99100
}
100101

@@ -103,6 +104,11 @@ func Analyze(cfg Config, current, base []coverage.Stats) AnalyzeResult {
103104
overrideRules := compileOverridePathRules(cfg)
104105
hasFileOverrides, hasPackageOverrides := detectOverrides(cfg.Override)
105106

107+
var filesWithMissingExplanations []coverage.Stats
108+
if cfg.ForceAnnotationComment {
109+
filesWithMissingExplanations = coverage.StatsFilterWithMissingExplanations(current)
110+
}
111+
106112
return AnalyzeResult{
107113
Threshold: thr,
108114
DiffThreshold: cfg.Diff.Threshold,
@@ -112,11 +118,12 @@ func Analyze(cfg Config, current, base []coverage.Stats) AnalyzeResult {
112118
PackagesBelowThreshold: checkCoverageStatsBelowThreshold(
113119
makePackageStats(current), thr.Package, overrideRules,
114120
),
115-
FilesWithUncoveredLines: coverage.StatsFilterWithUncoveredLines(current),
116-
TotalStats: coverage.StatsCalcTotal(current),
117-
HasBaseBreakdown: len(base) > 0,
118-
Diff: calculateStatsDiff(current, base),
119-
DiffPercentage: TotalPercentageDiff(current, base),
121+
FilesWithUncoveredLines: coverage.StatsFilterWithUncoveredLines(current),
122+
FilesWithMissingExplanations: filesWithMissingExplanations,
123+
TotalStats: coverage.StatsCalcTotal(current),
124+
HasBaseBreakdown: len(base) > 0,
125+
Diff: calculateStatsDiff(current, base),
126+
DiffPercentage: TotalPercentageDiff(current, base),
120127
}
121128
}
122129

pkg/testcoverage/check_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,40 @@ func TestCheck(t *testing.T) {
265265
assert.Error(t, err)
266266
assert.Contains(t, err.Error(), "failed to load base coverage breakdown")
267267
})
268+
269+
t.Run("valid profile - fail when missing explanations", func(t *testing.T) {
270+
t.Parallel()
271+
272+
buf := &bytes.Buffer{}
273+
cfg := Config{
274+
Profile: profileOK,
275+
SourceDir: sourceDir,
276+
ForceAnnotationComment: true,
277+
}
278+
pass, err := Check(buf, cfg)
279+
assert.False(t, pass)
280+
assert.NoError(t, err)
281+
282+
// Should report missing explanations
283+
assert.Contains(t, buf.String(), "Files with missing explanations for coverage-ignore")
284+
})
285+
286+
t.Run("valid profile - pass when not checking for explanations", func(t *testing.T) {
287+
t.Parallel()
288+
289+
buf := &bytes.Buffer{}
290+
cfg := Config{
291+
Profile: profileOK,
292+
SourceDir: sourceDir,
293+
ForceAnnotationComment: false,
294+
}
295+
pass, err := Check(buf, cfg)
296+
assert.True(t, pass)
297+
assert.NoError(t, err)
298+
299+
// Should not report missing explanations
300+
assert.NotContains(t, buf.String(), "Files with missing explanations for coverage-ignore")
301+
})
268302
}
269303

270304
func TestCheckDiff(t *testing.T) {
@@ -612,6 +646,36 @@ func Test_Analyze(t *testing.T) {
612646
assert.True(t, result.MeetsDiffThreshold())
613647
assert.Equal(t, 0.01, result.DiffPercentage) //nolint:testifylint //relax
614648
})
649+
650+
t.Run("missing explanations for coverage-ignore", func(t *testing.T) {
651+
t.Parallel()
652+
653+
// Create a stat with missing explanations
654+
stats := []coverage.Stats{
655+
{
656+
Name: prefix + "/foo.go",
657+
Total: 100,
658+
Covered: 100,
659+
AnnotationsWithoutComments: []int{10},
660+
},
661+
}
662+
663+
// When explanations are not required, the check should pass
664+
cfg := Config{
665+
ForceAnnotationComment: false,
666+
}
667+
result := Analyze(cfg, stats, nil)
668+
assert.True(t, result.Pass())
669+
assert.Empty(t, result.FilesWithMissingExplanations)
670+
671+
// When explanations are required, the check should fail
672+
cfg = Config{
673+
ForceAnnotationComment: true,
674+
}
675+
result = Analyze(cfg, stats, nil)
676+
assert.False(t, result.Pass())
677+
assert.NotEmpty(t, result.FilesWithMissingExplanations)
678+
})
615679
}
616680

617681
func TestLoadBaseCoverageBreakdown(t *testing.T) {

pkg/testcoverage/config.go

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,18 @@ var (
2424
)
2525

2626
type Config struct {
27-
Profile string `yaml:"profile"`
28-
Debug bool `yaml:"-"`
29-
LocalPrefixDeprecated string `yaml:"-"`
30-
SourceDir string `yaml:"-"`
31-
Threshold Threshold `yaml:"threshold"`
32-
Override []Override `yaml:"override,omitempty"`
33-
Exclude Exclude `yaml:"exclude"`
34-
BreakdownFileName string `yaml:"breakdown-file-name"`
35-
GithubActionOutput bool `yaml:"github-action-output"`
36-
Diff Diff `yaml:"diff"`
37-
Badge Badge `yaml:"-"`
27+
Profile string `yaml:"profile"`
28+
Debug bool `yaml:"-"`
29+
LocalPrefixDeprecated string `yaml:"-"`
30+
SourceDir string `yaml:"-"`
31+
Threshold Threshold `yaml:"threshold"`
32+
Override []Override `yaml:"override,omitempty"`
33+
Exclude Exclude `yaml:"exclude"`
34+
BreakdownFileName string `yaml:"breakdown-file-name"`
35+
GithubActionOutput bool `yaml:"github-action-output"`
36+
Diff Diff `yaml:"diff"`
37+
Badge Badge `yaml:"-"`
38+
ForceAnnotationComment bool `yaml:"force-annotation-comment"`
3839
}
3940

4041
type Threshold struct {

pkg/testcoverage/config_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,8 @@ func nonZeroConfig() Config {
251251
BaseBreakdownFileName: "breakdown.testcoverage",
252252
Threshold: ptr(-1.01),
253253
},
254-
GithubActionOutput: true,
254+
GithubActionOutput: true,
255+
ForceAnnotationComment: false,
255256
}
256257
}
257258

@@ -265,6 +266,7 @@ threshold:
265266
override:
266267
- threshold: 99
267268
path: pathToFile
269+
force-annotation-comment: false
268270
exclude:
269271
paths:
270272
- path1

pkg/testcoverage/coverage/cover.go

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,10 @@ import (
2121
const IgnoreText = "coverage-ignore"
2222

2323
type Config struct {
24-
Profiles []string
25-
ExcludePaths []string
26-
SourceDir string
24+
Profiles []string
25+
ExcludePaths []string
26+
SourceDir string
27+
ForceAnnotationComment bool
2728
}
2829

2930
func GenerateCoverageStats(cfg Config) ([]Stats, error) {
@@ -52,7 +53,7 @@ func GenerateCoverageStats(cfg Config) ([]Stats, error) {
5253
continue // this file is excluded
5354
}
5455

55-
s, err := coverageForFile(profile, fi)
56+
s, err := coverageForFile(profile, fi, cfg.ForceAnnotationComment)
5657
if err != nil {
5758
return nil, err
5859
}
@@ -78,7 +79,7 @@ func GenerateCoverageStats(cfg Config) ([]Stats, error) {
7879
return fileStats, nil
7980
}
8081

81-
func coverageForFile(profile *cover.Profile, fi fileInfo) (Stats, error) {
82+
func coverageForFile(profile *cover.Profile, fi fileInfo, forceComment bool) (Stats, error) {
8283
source, err := os.ReadFile(fi.path)
8384
if err != nil { // coverage-ignore
8485
return Stats{}, fmt.Errorf("failed reading file source [%s]: %w", fi.path, err)
@@ -89,14 +90,19 @@ func coverageForFile(profile *cover.Profile, fi fileInfo) (Stats, error) {
8990
return Stats{}, err
9091
}
9192

92-
annotations, err := findAnnotations(source)
93+
annotations, withoutComment, err := findAnnotations(source, forceComment)
9394
if err != nil { // coverage-ignore
9495
return Stats{}, err
9596
}
9697

9798
s := sumCoverage(profile, funcs, blocks, annotations)
9899
s.Name = fi.name
99100

101+
s.AnnotationsWithoutComments = make([]int, len(withoutComment))
102+
for i, extent := range withoutComment {
103+
s.AnnotationsWithoutComments[i] = extent.StartLine
104+
}
105+
100106
return s, nil
101107
}
102108

@@ -251,23 +257,43 @@ func findFilePathMatchingSearch(files *[]fileInfo, search string) string {
251257
return path
252258
}
253259

254-
func findAnnotations(source []byte) ([]extent, error) {
260+
// findAnnotations finds coverage-ignore annotations and checks for explanations
261+
func findAnnotations(source []byte, forceComment bool) ([]extent, []extent, error) {
255262
fset := token.NewFileSet()
256263

257264
node, err := parser.ParseFile(fset, "", source, parser.ParseComments)
258265
if err != nil {
259-
return nil, fmt.Errorf("can't parse comments: %w", err)
266+
return nil, nil, fmt.Errorf("can't parse comments: %w", err)
260267
}
261268

262-
var res []extent
269+
var validAnnotations []extent
270+
271+
var annotationsWithoutComment []extent
263272

264273
for _, c := range node.Comments {
265-
if strings.Contains(c.Text(), IgnoreText) {
266-
res = append(res, newExtent(fset, c))
274+
if !strings.Contains(c.Text(), IgnoreText) {
275+
continue // does not have annotation continue to next comment
276+
}
277+
278+
if forceComment {
279+
if hasComment(c.Text()) {
280+
validAnnotations = append(validAnnotations, newExtent(fset, c))
281+
} else {
282+
annotationsWithoutComment = append(annotationsWithoutComment, newExtent(fset, c))
283+
}
284+
} else {
285+
validAnnotations = append(validAnnotations, newExtent(fset, c))
267286
}
268287
}
269288

270-
return res, nil
289+
return validAnnotations, annotationsWithoutComment, nil
290+
}
291+
292+
// hasComment checks if the coverage-ignore annotation has an explanation comment
293+
func hasComment(text string) bool {
294+
// coverage-ignore should be followed by additional text to be considered an explanation
295+
trimmedComment := strings.TrimSpace(text)
296+
return len(trimmedComment) > len(IgnoreText)
271297
}
272298

273299
func findFuncsAndBlocks(source []byte) ([]extent, []extent, error) {

pkg/testcoverage/coverage/cover_test.go

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,10 @@ func Test_findFile(t *testing.T) {
121121
func Test_findAnnotations(t *testing.T) {
122122
t.Parallel()
123123

124-
_, err := FindAnnotations(nil)
124+
_, _, err := FindAnnotations(nil, false)
125125
assert.Error(t, err)
126126

127-
_, err = FindAnnotations([]byte{})
127+
_, _, err = FindAnnotations([]byte{}, false)
128128
assert.Error(t, err)
129129

130130
const source = `
@@ -138,11 +138,46 @@ func Test_findAnnotations(t *testing.T) {
138138
}
139139
`
140140

141-
comments, err := FindAnnotations([]byte(source))
141+
comments, _, err := FindAnnotations([]byte(source), false)
142142
assert.NoError(t, err)
143143
assert.Equal(t, []int{3, 5}, pluckStartLine(comments))
144144
}
145145

146+
func Test_findAnnotationsWithComment(t *testing.T) {
147+
t.Parallel()
148+
149+
_, _, err := FindAnnotations(nil, true)
150+
assert.Error(t, err)
151+
152+
_, _, err = FindAnnotations([]byte{}, true)
153+
assert.Error(t, err)
154+
155+
const source = `
156+
package foo
157+
func foo() int { // coverage-ignore
158+
a := 0
159+
// This is a regular comment
160+
for i := range 10 { // coverage-ignore - this has an explanation
161+
a += i
162+
}
163+
if a > 5 { // coverage-ignore
164+
a = 5
165+
}
166+
return a
167+
}
168+
`
169+
170+
validAnnotations, withoutComment, err := FindAnnotations([]byte(source), true)
171+
assert.NoError(t, err)
172+
assert.Equal(t, []int{6}, pluckStartLine(validAnnotations))
173+
assert.Equal(t, []int{3, 9}, pluckStartLine(withoutComment))
174+
175+
validAnnotations, withoutComment, err = FindAnnotations([]byte(source), false)
176+
assert.NoError(t, err)
177+
assert.Equal(t, []int{3, 6, 9}, pluckStartLine(validAnnotations))
178+
assert.Empty(t, withoutComment)
179+
}
180+
146181
func Test_findFuncs(t *testing.T) {
147182
t.Parallel()
148183

pkg/testcoverage/coverage/types.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@ import (
1111
)
1212

1313
type Stats struct {
14-
Name string
15-
Total int64
16-
Covered int64
17-
Threshold int
18-
UncoveredLines []int
14+
Name string
15+
Total int64
16+
Covered int64
17+
Threshold int
18+
UncoveredLines []int
19+
AnnotationsWithoutComments []int
1920
}
2021

2122
func (s Stats) UncoveredLinesCount() int {
@@ -149,6 +150,13 @@ func StatsFilterWithCoveredLines(stats []Stats) []Stats {
149150
})
150151
}
151152

153+
// StatsFilterWithMissingExplanations returns stats that have missing explanations
154+
func StatsFilterWithMissingExplanations(stats []Stats) []Stats {
155+
return filter(stats, func(s Stats) bool {
156+
return len(s.AnnotationsWithoutComments) > 0
157+
})
158+
}
159+
152160
func filter[T any](slice []T, predicate func(T) bool) []T {
153161
var result []T
154162

0 commit comments

Comments
 (0)