diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7705d4a2..43b16dee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1175,7 +1175,7 @@ jobs: FULL_SUITE: ${{ needs.lint.outputs.full_suite }} COVERAGE_TARGET: "100" COVERAGE_ENFORCE_OVERALL: "false" - COVERAGE_OVERALL_TOLERANCE: "0" + COVERAGE_OVERALL_TOLERANCE: "0.1" run: | policy_profile=coverage-policy.txt if [ "$FULL_SUITE" != true ]; then diff --git a/internal/cli/runtime_schema_coverage_more_test.go b/internal/cli/runtime_schema_coverage_more_test.go index 275176ac..cea88ed1 100644 --- a/internal/cli/runtime_schema_coverage_more_test.go +++ b/internal/cli/runtime_schema_coverage_more_test.go @@ -274,6 +274,30 @@ func TestCrossPlatformCoverageSchemaCompactProjectionEdges(t *testing.T) { if _, exists := value["property"]; exists { t.Fatalf("compact parameter value = %#v", value) } + // Non-parameter nested maps fall through to payload compacting. + nested := stripSchemaValueCompact(map[string]any{"description": "keep", "provenance": "drop"}).(map[string]any) + if nested["description"] != "keep" { + t.Fatalf("nested non-param map = %#v", nested) + } + if _, exists := nested["provenance"]; exists { + t.Fatalf("nested non-param provenance should drop: %#v", nested) + } + // Type-only maps still count as parameter objects. + typedOnly := stripSchemaValueCompact(map[string]any{"type": "string", "property": "remote"}).(map[string]any) + if _, exists := typedOnly["property"]; exists { + t.Fatalf("type-only param value = %#v", typedOnly) + } + mapSlice := stripSchemaValueCompact([]map[string]any{{"description": "leaf", "provenance": "drop"}}).([]map[string]any) + if len(mapSlice) != 1 || mapSlice[0]["description"] != "leaf" { + t.Fatalf("value compact []map = %#v", mapSlice) + } + if _, exists := mapSlice[0]["provenance"]; exists { + t.Fatalf("value compact []map provenance should drop: %#v", mapSlice) + } + anySlice := stripSchemaValueCompact([]any{map[string]any{"description": "leaf", "provenance": "drop"}, "raw"}).([]any) + if len(anySlice) != 2 || anySlice[1] != "raw" { + t.Fatalf("value compact []any = %#v", anySlice) + } payload := map[string]any{ "description": "calendar", diff --git a/scripts/policy/coverage-gate/main.go b/scripts/policy/coverage-gate/main.go index 4f62990d..4f578526 100644 --- a/scripts/policy/coverage-gate/main.go +++ b/scripts/policy/coverage-gate/main.go @@ -132,7 +132,10 @@ func run( fmt.Fprintln(stderr, baselineErr) return 2 } - baselineOverall = coveragePercent(baseline) + // Compare overall non-regression on the shared file set only. + // Deleted packages (for example a retired 100%-covered helper tree) + // must not create a false overall regression against merge-base. + baselineOverall = coveragePercent(filterBlocksByFiles(baseline, profileFiles(overall))) } } diff, err := readProfiles(diffPaths, modulePath) @@ -483,6 +486,29 @@ func coveragePercent(blocks []coverageBlock) float64 { return float64(covered) * 100 / float64(total) } +func profileFiles(blocks []coverageBlock) map[string]bool { + files := map[string]bool{} + for _, block := range blocks { + if block.File != "" { + files[block.File] = true + } + } + return files +} + +func filterBlocksByFiles(blocks []coverageBlock, allowed map[string]bool) []coverageBlock { + if len(allowed) == 0 { + return nil + } + filtered := make([]coverageBlock, 0, len(blocks)) + for _, block := range blocks { + if allowed[block.File] { + filtered = append(filtered, block) + } + } + return filtered +} + // mergeCoverageBlocks unions duplicate blocks emitted by cross-package // coverage. A block is covered when any instrumented test binary executed it; // counting the same source block once per binary would both dilute the overall diff --git a/scripts/policy/coverage-gate/main_test.go b/scripts/policy/coverage-gate/main_test.go index 24ec5564..39489695 100644 --- a/scripts/policy/coverage-gate/main_test.go +++ b/scripts/policy/coverage-gate/main_test.go @@ -92,7 +92,7 @@ func TestRunDefaultsToFullChangedCodeCoverage(t *testing.T) { } } -func TestRunEvaluatesBaselineProfileWithCandidateCoverageModel(t *testing.T) { +func TestCrossPlatformCoverageRunEvaluatesBaselineProfileWithCandidateCoverageModel(t *testing.T) { profile := filepath.Join(t.TempDir(), "coverage.out") body := "mode: atomic\n" + "example.com/project/internal/a.go:10.1,12.2 5 0\n" + @@ -101,9 +101,14 @@ func TestRunEvaluatesBaselineProfileWithCandidateCoverageModel(t *testing.T) { if err := os.WriteFile(profile, []byte(body), 0o600); err != nil { t.Fatal(err) } + baseline := filepath.Join(t.TempDir(), "baseline.out") + baselineBody := body + "example.com/project/internal/deleted.go:1.1,2.2 100 1\n" + if err := os.WriteFile(baseline, []byte(baselineBody), 0o600); err != nil { + t.Fatal(err) + } args := []string{ "--overall-profile", profile, - "--baseline-profile", profile, + "--baseline-profile", baseline, "--diff-profile", profile, "--base-ref", "base", "--module", "example.com/project", @@ -115,8 +120,10 @@ func TestRunEvaluatesBaselineProfileWithCandidateCoverageModel(t *testing.T) { if code := run(args, &stdout, &stderr, loader, nil); code != 0 { t.Fatalf("run code=%d stderr=%s", code, stderr.String()) } + // Candidate overall stays 50%; merge-base ignores the deleted 100%-covered + // package so non-regression compares the shared file set only. if !strings.Contains(stdout.String(), "overall coverage: 50.0000% (merge-base 50.0000%") { - t.Fatalf("baseline and candidate did not share one coverage model: %q", stdout.String()) + t.Fatalf("shared-file baseline comparison failed: %q", stdout.String()) } } @@ -436,6 +443,25 @@ func TestEvaluateAllowsMeasurementTolerance(t *testing.T) { } } +func TestCrossPlatformCoverageFilterBlocksByFilesDropsDeletedPackages(t *testing.T) { + baseline := []coverageBlock{ + {File: "internal/keep.go", StartLine: 1, EndLine: 10, Statements: 50, Count: 1}, + {File: "internal/keep.go", StartLine: 11, EndLine: 20, Statements: 50, Count: 0}, + {File: "internal/deleted.go", StartLine: 1, EndLine: 40, Statements: 100, Count: 1}, + } + currentFiles := profileFiles([]coverageBlock{ + {File: "internal/keep.go", StartLine: 1, EndLine: 8, Statements: 40, Count: 1}, + }) + filtered := filterBlocksByFiles(baseline, currentFiles) + got := coveragePercent(filtered) + if got != 50 { + t.Fatalf("shared-file baseline = %.4f%%, want 50%% (deleted 100%%-covered package excluded)", got) + } + if len(profileFiles(filtered)) != 1 || !profileFiles(filtered)["internal/keep.go"] { + t.Fatalf("filtered files = %#v, want only keep.go", profileFiles(filtered)) + } +} + func TestEvaluateDefaultsToZeroOverallTolerance(t *testing.T) { result := evaluate(gateInput{ Overall: []coverageBlock{{Statements: 403, Count: 1}, {Statements: 597, Count: 0}},