diff --git a/backend/cmd/localpatchreport/main.go b/backend/cmd/localpatchreport/main.go index 0b3168e6..4849ba40 100644 --- a/backend/cmd/localpatchreport/main.go +++ b/backend/cmd/localpatchreport/main.go @@ -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 { diff --git a/backend/internal/patchreport/patchreport.go b/backend/internal/patchreport/patchreport.go index ef372159..eec0e430 100644 --- a/backend/internal/patchreport/patchreport.go +++ b/backend/internal/patchreport/patchreport.go @@ -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 == "" { diff --git a/backend/internal/patchreport/patchreport_test.go b/backend/internal/patchreport/patchreport_test.go index ab4e0b85..8c798d69 100644 --- a/backend/internal/patchreport/patchreport_test.go +++ b/backend/internal/patchreport/patchreport_test.go @@ -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) + } +}