chore: add detailed file coverage reporting and sorting functionality
This commit is contained in:
@@ -31,16 +31,17 @@ type artifactsJSON struct {
|
||||
}
|
||||
|
||||
type reportJSON struct {
|
||||
Baseline string `json:"baseline"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
Mode string `json:"mode"`
|
||||
Thresholds thresholdJSON `json:"thresholds"`
|
||||
ThresholdSources thresholdSourcesJSON `json:"threshold_sources"`
|
||||
Overall patchreport.ScopeCoverage `json:"overall"`
|
||||
Backend patchreport.ScopeCoverage `json:"backend"`
|
||||
Frontend patchreport.ScopeCoverage `json:"frontend"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Artifacts artifactsJSON `json:"artifacts"`
|
||||
Baseline string `json:"baseline"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
Mode string `json:"mode"`
|
||||
Thresholds thresholdJSON `json:"thresholds"`
|
||||
ThresholdSources thresholdSourcesJSON `json:"threshold_sources"`
|
||||
Overall patchreport.ScopeCoverage `json:"overall"`
|
||||
Backend patchreport.ScopeCoverage `json:"backend"`
|
||||
Frontend patchreport.ScopeCoverage `json:"frontend"`
|
||||
FilesNeedingCoverage []patchreport.FileCoverageDetail `json:"files_needing_coverage,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Artifacts artifactsJSON `json:"artifacts"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -102,6 +103,9 @@ func main() {
|
||||
backendScope := patchreport.ComputeScopeCoverage(backendChanged, backendCoverage)
|
||||
frontendScope := patchreport.ComputeScopeCoverage(frontendChanged, frontendCoverage)
|
||||
overallScope := patchreport.MergeScopeCoverage(backendScope, frontendScope)
|
||||
backendFilesNeedingCoverage := patchreport.ComputeFilesNeedingCoverage(backendChanged, backendCoverage, backendThreshold.Value)
|
||||
frontendFilesNeedingCoverage := patchreport.ComputeFilesNeedingCoverage(frontendChanged, frontendCoverage, frontendThreshold.Value)
|
||||
filesNeedingCoverage := patchreport.MergeFileCoverageDetails(backendFilesNeedingCoverage, frontendFilesNeedingCoverage)
|
||||
|
||||
backendScope = patchreport.ApplyStatus(backendScope, backendThreshold.Value)
|
||||
frontendScope = patchreport.ApplyStatus(frontendScope, frontendThreshold.Value)
|
||||
@@ -136,10 +140,11 @@ func main() {
|
||||
Backend: backendThreshold.Source,
|
||||
Frontend: frontendThreshold.Source,
|
||||
},
|
||||
Overall: overallScope,
|
||||
Backend: backendScope,
|
||||
Frontend: frontendScope,
|
||||
Warnings: warnings,
|
||||
Overall: overallScope,
|
||||
Backend: backendScope,
|
||||
Frontend: frontendScope,
|
||||
FilesNeedingCoverage: filesNeedingCoverage,
|
||||
Warnings: warnings,
|
||||
Artifacts: artifactsJSON{
|
||||
Markdown: relOrAbs(repoRoot, mdOutPath),
|
||||
JSON: relOrAbs(repoRoot, jsonOutPath),
|
||||
@@ -246,6 +251,20 @@ func writeMarkdown(path string, report reportJSON, backendCoveragePath, frontend
|
||||
builder.WriteString(scopeRow("Frontend", report.Frontend))
|
||||
builder.WriteString("\n")
|
||||
|
||||
if len(report.FilesNeedingCoverage) > 0 {
|
||||
builder.WriteString("## Files Needing Coverage\n\n")
|
||||
builder.WriteString("| Path | Patch Coverage (%) | Uncovered Changed Lines | Uncovered Changed Line Ranges |\n")
|
||||
builder.WriteString("|---|---:|---:|---|\n")
|
||||
for _, fileCoverage := range report.FilesNeedingCoverage {
|
||||
ranges := "-"
|
||||
if len(fileCoverage.UncoveredChangedLineRange) > 0 {
|
||||
ranges = strings.Join(fileCoverage.UncoveredChangedLineRange, ", ")
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf("| `%s` | %.1f | %d | %s |\n", fileCoverage.Path, fileCoverage.PatchCoveragePct, fileCoverage.UncoveredChangedLines, ranges))
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(report.Warnings) > 0 {
|
||||
builder.WriteString("## Warnings\n\n")
|
||||
for _, warning := range report.Warnings {
|
||||
|
||||
@@ -27,6 +27,13 @@ type ScopeCoverage struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type FileCoverageDetail struct {
|
||||
Path string `json:"path"`
|
||||
PatchCoveragePct float64 `json:"patch_coverage_pct"`
|
||||
UncoveredChangedLines int `json:"uncovered_changed_lines"`
|
||||
UncoveredChangedLineRange []string `json:"uncovered_changed_line_ranges,omitempty"`
|
||||
}
|
||||
|
||||
type ThresholdResolution struct {
|
||||
Value float64
|
||||
Source string
|
||||
@@ -344,6 +351,70 @@ func ApplyStatus(scope ScopeCoverage, minThreshold float64) ScopeCoverage {
|
||||
return scope
|
||||
}
|
||||
|
||||
func ComputeFilesNeedingCoverage(changedLines FileLineSet, coverage CoverageData, minThreshold float64) []FileCoverageDetail {
|
||||
details := make([]FileCoverageDetail, 0, len(changedLines))
|
||||
|
||||
for filePath, lines := range changedLines {
|
||||
executable, ok := coverage.Executable[filePath]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
coveredLines := coverage.Covered[filePath]
|
||||
executableChanged := 0
|
||||
coveredChanged := 0
|
||||
uncoveredLines := make([]int, 0, len(lines))
|
||||
|
||||
for lineNo := range lines {
|
||||
if _, executableLine := executable[lineNo]; !executableLine {
|
||||
continue
|
||||
}
|
||||
executableChanged++
|
||||
if _, isCovered := coveredLines[lineNo]; isCovered {
|
||||
coveredChanged++
|
||||
} else {
|
||||
uncoveredLines = append(uncoveredLines, lineNo)
|
||||
}
|
||||
}
|
||||
|
||||
if executableChanged == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
patchCoveragePct := roundToOneDecimal(float64(coveredChanged) * 100 / float64(executableChanged))
|
||||
uncoveredCount := executableChanged - coveredChanged
|
||||
if uncoveredCount == 0 && patchCoveragePct >= minThreshold {
|
||||
continue
|
||||
}
|
||||
|
||||
sort.Ints(uncoveredLines)
|
||||
details = append(details, FileCoverageDetail{
|
||||
Path: filePath,
|
||||
PatchCoveragePct: patchCoveragePct,
|
||||
UncoveredChangedLines: uncoveredCount,
|
||||
UncoveredChangedLineRange: formatLineRanges(uncoveredLines),
|
||||
})
|
||||
}
|
||||
|
||||
sortFileCoverageDetails(details)
|
||||
return details
|
||||
}
|
||||
|
||||
func MergeFileCoverageDetails(groups ...[]FileCoverageDetail) []FileCoverageDetail {
|
||||
count := 0
|
||||
for _, group := range groups {
|
||||
count += len(group)
|
||||
}
|
||||
|
||||
merged := make([]FileCoverageDetail, 0, count)
|
||||
for _, group := range groups {
|
||||
merged = append(merged, group...)
|
||||
}
|
||||
|
||||
sortFileCoverageDetails(merged)
|
||||
return merged
|
||||
}
|
||||
|
||||
func SortedWarnings(warnings []string) []string {
|
||||
filtered := make([]string, 0, len(warnings))
|
||||
for _, warning := range warnings {
|
||||
@@ -466,6 +537,47 @@ func roundToOneDecimal(value float64) float64 {
|
||||
return float64(int(value*10+0.5)) / 10
|
||||
}
|
||||
|
||||
func formatLineRanges(lines []int) []string {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ranges := make([]string, 0, len(lines))
|
||||
start := lines[0]
|
||||
end := lines[0]
|
||||
|
||||
for index := 1; index < len(lines); index++ {
|
||||
lineNo := lines[index]
|
||||
if lineNo == end+1 {
|
||||
end = lineNo
|
||||
continue
|
||||
}
|
||||
|
||||
ranges = append(ranges, formatLineRange(start, end))
|
||||
start = lineNo
|
||||
end = lineNo
|
||||
}
|
||||
|
||||
ranges = append(ranges, formatLineRange(start, end))
|
||||
return ranges
|
||||
}
|
||||
|
||||
func formatLineRange(start, end int) string {
|
||||
if start == end {
|
||||
return strconv.Itoa(start)
|
||||
}
|
||||
return fmt.Sprintf("%d-%d", start, end)
|
||||
}
|
||||
|
||||
func sortFileCoverageDetails(details []FileCoverageDetail) {
|
||||
sort.Slice(details, func(left, right int) bool {
|
||||
if details[left].PatchCoveragePct != details[right].PatchCoveragePct {
|
||||
return details[left].PatchCoveragePct < details[right].PatchCoveragePct
|
||||
}
|
||||
return details[left].Path < details[right].Path
|
||||
})
|
||||
}
|
||||
|
||||
func validateReadablePath(rawPath string) (string, error) {
|
||||
trimmedPath := strings.TrimSpace(rawPath)
|
||||
if trimmedPath == "" {
|
||||
|
||||
@@ -293,3 +293,81 @@ func TestValidateReadablePath(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestComputeFilesNeedingCoverage_IncludesUncoveredAndSortsDeterministically(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
changed := FileLineSet{
|
||||
"backend/internal/b.go": {1: {}, 2: {}},
|
||||
"backend/internal/a.go": {1: {}, 2: {}},
|
||||
"backend/internal/c.go": {1: {}, 2: {}},
|
||||
}
|
||||
|
||||
coverage := CoverageData{
|
||||
Executable: FileLineSet{
|
||||
"backend/internal/a.go": {1: {}, 2: {}},
|
||||
"backend/internal/b.go": {1: {}, 2: {}},
|
||||
"backend/internal/c.go": {1: {}, 2: {}},
|
||||
},
|
||||
Covered: FileLineSet{
|
||||
"backend/internal/a.go": {1: {}},
|
||||
"backend/internal/c.go": {1: {}, 2: {}},
|
||||
},
|
||||
}
|
||||
|
||||
details := ComputeFilesNeedingCoverage(changed, coverage, 40)
|
||||
if len(details) != 2 {
|
||||
t.Fatalf("expected 2 files needing coverage, got %d", len(details))
|
||||
}
|
||||
|
||||
if details[0].Path != "backend/internal/b.go" {
|
||||
t.Fatalf("expected first file to be backend/internal/b.go, got %s", details[0].Path)
|
||||
}
|
||||
if details[0].PatchCoveragePct != 0.0 {
|
||||
t.Fatalf("expected first file coverage 0.0, got %.1f", details[0].PatchCoveragePct)
|
||||
}
|
||||
if details[0].UncoveredChangedLines != 2 {
|
||||
t.Fatalf("expected first file uncovered lines 2, got %d", details[0].UncoveredChangedLines)
|
||||
}
|
||||
if strings.Join(details[0].UncoveredChangedLineRange, ",") != "1-2" {
|
||||
t.Fatalf("expected first file uncovered ranges 1-2, got %v", details[0].UncoveredChangedLineRange)
|
||||
}
|
||||
|
||||
if details[1].Path != "backend/internal/a.go" {
|
||||
t.Fatalf("expected second file to be backend/internal/a.go, got %s", details[1].Path)
|
||||
}
|
||||
if details[1].PatchCoveragePct != 50.0 {
|
||||
t.Fatalf("expected second file coverage 50.0, got %.1f", details[1].PatchCoveragePct)
|
||||
}
|
||||
if details[1].UncoveredChangedLines != 1 {
|
||||
t.Fatalf("expected second file uncovered lines 1, got %d", details[1].UncoveredChangedLines)
|
||||
}
|
||||
if strings.Join(details[1].UncoveredChangedLineRange, ",") != "2" {
|
||||
t.Fatalf("expected second file uncovered range 2, got %v", details[1].UncoveredChangedLineRange)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeFileCoverageDetails_SortsWorstCoverageThenPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
merged := MergeFileCoverageDetails(
|
||||
[]FileCoverageDetail{
|
||||
{Path: "frontend/src/z.ts", PatchCoveragePct: 50.0},
|
||||
{Path: "frontend/src/a.ts", PatchCoveragePct: 50.0},
|
||||
},
|
||||
[]FileCoverageDetail{
|
||||
{Path: "backend/internal/w.go", PatchCoveragePct: 0.0},
|
||||
},
|
||||
)
|
||||
|
||||
if len(merged) != 3 {
|
||||
t.Fatalf("expected 3 merged items, got %d", len(merged))
|
||||
}
|
||||
|
||||
orderedPaths := []string{merged[0].Path, merged[1].Path, merged[2].Path}
|
||||
got := strings.Join(orderedPaths, ",")
|
||||
want := "backend/internal/w.go,frontend/src/a.ts,frontend/src/z.ts"
|
||||
if got != want {
|
||||
t.Fatalf("unexpected merged order: got %s want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user