diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml index 9b1544d04eb..aefda0477c0 100644 --- a/.github/workflows/cli-ci.yml +++ b/.github/workflows/cli-ci.yml @@ -6,6 +6,7 @@ on: - "cli/**" - "!cli/azd/extensions/**" - ".github/workflows/cli-ci.yml" + - "eng/scripts/Get-CoverageDiff*.ps1" branches: [main] # If two events are triggered within a short time in the same PR, cancel the run of the oldest event @@ -44,3 +45,34 @@ jobs: bicep-lint: uses: ./.github/workflows/lint-bicep.yml + + coverage-script-tests: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - name: Run Pester tests for coverage scripts + shell: pwsh + run: | + # Pin Pester to a specific signed release for deterministic, supply-chain-safe installs. + Install-Module -Name Pester -RequiredVersion 5.7.1 -Force -Scope CurrentUser + Import-Module Pester -RequiredVersion 5.7.1 + $config = New-PesterConfiguration + $config.Run.Path = './eng/scripts/Get-CoverageDiff.Tests.ps1' + $config.Run.Exit = $true + $config.Output.Verbosity = 'Detailed' + Invoke-Pester -Configuration $config + + magefile-tests: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: cli/azd/go.mod + - name: Run mage helper tests (resolveCoverageFile, resolveBaselineFile, resolveChangedFilesForDiff) + working-directory: cli/azd + run: go test -tags mage -run '^TestResolve' -v . diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 857fae2a52e..84f7518d5fe 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -401,6 +401,16 @@ overrides: words: - covdata - GOWORK + - textfmt + - logissue + - runbook + - AMRD + - filename: magefile.go + words: + - textfmt + - covcounters + - covmeta + - AMRD - filename: test/eval/README.md words: - Waza diff --git a/cli/azd/AGENTS.md b/cli/azd/AGENTS.md index b319ca4708d..a91abe105ab 100644 --- a/cli/azd/AGENTS.md +++ b/cli/azd/AGENTS.md @@ -78,6 +78,7 @@ When writing tests, prefer table-driven tests. Use testify/mock for mocking. Additional mage targets: - `mage record` — re-record functional test cassettes against a live Azure subscription. Accepts an optional `-filter=TestName` flag to re-record specific tests. Typically only core maintainers need to run this; external contributors can rely on playback mode (the default) which requires no Azure access. Requires `azd auth login` and a configured test subscription (see `docs/recording-functional-tests-guide.md`). +- `mage coverage:pr` — preview the CI PR coverage gate locally before pushing. Resolves PR-touched `.go` files via `git merge-base origin/main HEAD` for the per-package summary, runs the diff against the latest `main` baseline, and fails (exit 2) on **either** breach type: any PR-touched package drops more than 0.5 pp, or overall coverage falls below 69% (defaults match CI; override via `COVERAGE_MAX_PACKAGE_DECREASE`, `COVERAGE_MIN_OVERALL`). See `docs/code-coverage-guide.md` for details. ```bash gofmt -s -w . diff --git a/cli/azd/docs/code-coverage-guide.md b/cli/azd/docs/code-coverage-guide.md index 0f274da10ba..e84ad20bbec 100644 --- a/cli/azd/docs/code-coverage-guide.md +++ b/cli/azd/docs/code-coverage-guide.md @@ -39,8 +39,10 @@ The CI pipeline collects coverage in several stages: 6. **Filter**: `Filter-GeneratedCoverage.ps1` removes auto-generated files (e.g., `*.pb.go`) so coverage reflects only hand-written code. -7. **Threshold**: `Test-CodeCoverageThreshold.ps1` enforces the minimum - coverage gate, failing the build if coverage drops below the threshold. +7. **PR gate** (PR builds only): `Get-CoverageDiff.ps1` compares the merged + profile against the latest successful `main` baseline and fails the build + on either a per-package decrease > 0.5 pp or overall coverage below the + floor. Release/main builds skip this step and only publish artifacts. ## Developer Modes @@ -159,14 +161,22 @@ additional code generators are introduced. ## CI Gate -The CI pipeline enforces a minimum coverage threshold using -`Test-CodeCoverageThreshold.ps1` in the `release-cli.yml` pipeline. - -- **Current threshold**: Check the pipeline definition for the latest value. -- **Ratchet policy**: The threshold is periodically raised as coverage improves. - PRs that reduce coverage below the threshold will fail the coverage gate. -- **Enforcement**: The threshold script parses `go tool cover -func` output and - exits non-zero if the total statement coverage is below the minimum. +The CI pipeline enforces a two-gate coverage check on **PR builds only** +using `Get-CoverageDiff.ps1` (invoked from +`eng/pipelines/templates/stages/code-coverage-upload.yml`): + +- **Per-package gate**: any PR-touched package that drops more than + `MaxPackageDecrease` percentage points (default **0.5 pp**) versus the + latest successful `main` baseline fails the build. +- **Overall floor gate**: if the PR's overall coverage falls below + `MinOverallCoverage` (default **69%**), the build fails. +- **Failure mode**: the script emits `##vso[task.logissue type=error]` + for each breached gate and exits with code `2`, which fails the ADO job. +- **Scope**: release, scheduled, and `main` builds **do not** enforce + these gates — they only publish coverage artifacts. A coverage dip on + `main` will surface on the next PR rather than block a release. +- **Ratchet policy**: see the *Adjusting the absolute floor* runbook + below. ## Scripts Reference @@ -175,7 +185,8 @@ The CI pipeline enforces a minimum coverage threshold using | `Get-LocalCoverageReport.ps1` | `eng/scripts/` | Developer-facing: runs coverage locally in any of the 4 modes | | `Get-CICoverageReport.ps1` | `eng/scripts/` | Downloads combined coverage from Azure DevOps CI builds | | `Filter-GeneratedCoverage.ps1` | `eng/scripts/` | Strips auto-generated files (`.pb.go`) from coverage profiles | -| `Test-CodeCoverageThreshold.ps1` | `eng/scripts/` | Enforces minimum coverage gate; used by CI and `-MinCoverage` | +| `Get-CoverageDiff.ps1` | `eng/scripts/` | PR coverage gate: two-gate check (per-package decrease + overall floor) used by CI and `mage coverage:pr` | +| `Test-CodeCoverageThreshold.ps1` | `eng/scripts/` | Local minimum-coverage helper used by `Get-LocalCoverageReport.ps1 -MinCoverage` (no longer wired into CI) | | `Convert-GoCoverageToCobertura.ps1` | `eng/scripts/` | Converts Go coverage to Cobertura XML for ADO reporting (CI only) | | `ci-build.ps1` | `eng/scripts/` | CI: builds azd binary with `-cover` instrumentation | | `ci-test.ps1` | `eng/scripts/` | CI: runs unit and integration tests with coverage collection | @@ -192,8 +203,9 @@ All modes are also available as `mage` targets (from `cli/azd/`): | `mage coverage:ci` | CI baseline report | `az login` | | `mage coverage:html` | HTML report (unit only by default) | Go 1.26 | | `mage coverage:check` | Enforce 50% threshold (unit only; CI gate is 55% combined) | Go 1.26 | -| `mage coverage:diff` | Compare current branch coverage vs main baseline | Go 1.26 | -| `mage coverage:pr` | Diff + post results as a PR comment | Go 1.26, `gh` CLI | +| `mage coverage:diff` | Compare current branch coverage vs main baseline (advisory; honors `COVERAGE_MAX_PACKAGE_DECREASE` / `COVERAGE_MIN_OVERALL` / `COVERAGE_FAIL_ON_DECREASE`) | Go 1.26 | +| `mage coverage:pr` | Preview the CI PR coverage gate locally (fail-loud on either: per-package regression > 0.5 pp, or overall < 69%) | Go 1.26 | +| `mage coverage:report` | Merge raw covdata input directories into a single `cover.out` (used by CI; honors `COVERAGE_REPORT_*` env vars) | Go 1.26 | Environment variables for optional overrides: @@ -203,6 +215,175 @@ Environment variables for optional overrides: | `COVERAGE_BUILD_ID` | `hybrid`, `ci` | Target a specific ADO build ID | | `COVERAGE_MODE` | `html` | Set to `full` or `hybrid` (default: `unit`) | | `COVERAGE_MIN` | `check` | Override threshold (default: `55`) | +| `COVERAGE_MAX_PACKAGE_DECREASE` | `diff`, `pr` | Maximum tolerated per-package coverage drop in percentage points (defaults come from `Get-CoverageDiff.ps1`, currently `0.5`; PR-touched packages only when changed-files can be resolved). Set to `-1` to disable the per-package gate (the floor gate stays active unless `COVERAGE_MIN_OVERALL` is also set to `-1`). | +| `COVERAGE_MIN_OVERALL` | `diff`, `pr` | Absolute floor for overall coverage in percent (defaults come from `Get-CoverageDiff.ps1`, currently `69`). Set to `-1` to disable the floor gate. | +| `COVERAGE_FAIL_ON_DECREASE` | `diff` | Set to `1` / `true` to exit `2` when EITHER gate is breached (`pr` always fails loud). Any other non-zero exit indicates a script/infra error, not a gate breach. **Note:** setting `COVERAGE_MAX_PACKAGE_DECREASE` alone does NOT enable fail-loud mode for `mage coverage:diff` — you must also set `COVERAGE_FAIL_ON_DECREASE=1` (or use `mage coverage:pr`, which always fails loud). | +| `COVERAGE_BASELINE` | `diff`, `pr` | Path to baseline coverage profile (default: `cover-ci-combined.out` or download from CI) | +| `COVERAGE_CURRENT` | `diff`, `pr` | Path to current coverage profile (default: `cover-local.out`) | +| `COVERAGE_REPORT_UNIT_INPUTS` | `report` | Comma-separated list of unit-test covdata input directories. | +| `COVERAGE_REPORT_INT_INPUTS` | `report` | Comma-separated list of integration-test covdata input directories (optional). | +| `COVERAGE_REPORT_OUTPUT` | `report` | Output `cover.out` path (textfmt). | +| `COVERAGE_REPORT_MERGED_DIR` | `report` | Optional intermediate merged covdata directory. Created if absent. | + +## PR Coverage Check (Fail-Loud) + +PRs run a **two-gate** coverage check as part of the +`code-coverage-upload.yml` Azure DevOps stage. After unit + integration +coverage is merged via `mage coverage:report`, the pipeline: + +1. Resolves the list of `.go` files touched by the PR via + `git diff --name-only --no-renames --diff-filter=AMRD origin/...HEAD`, + so per-package results are scoped to the packages this PR touches. +2. Runs `eng/scripts/Get-CoverageDiff.ps1` against the merged baseline + from the latest successful build of the PR target branch and the PR's `cover.out`. +3. Prints a per-package report (regressions first), the overall delta, + and the configured tolerances. +4. **Fails the build (`exit 2`) when EITHER of the following is true:** + - **Per-package decrease**: any single PR-touched package drops by + more than `MaxPackageDecrease` pp (default **0.5 pp**). + - **Absolute floor**: overall coverage falls below + `MinOverallCoverage` percent (default **69%**). +5. Surfaces every breach via `##vso[task.logissue type=error]` so each + one shows up in the PR check summary. + +Per-package results outside the PR-touched set are advisory; they appear +in the report but do not gate the build. There is intentionally **no PR +comment**; the build log is the source of truth. + +### Reproducing the gate locally + +```powershell +# 1. Build the unit-only profile for your branch +mage coverage:unit + +# 2. Run the same gate CI runs +mage coverage:pr +``` + +`mage coverage:pr` runs `git fetch --no-tags --depth=200 origin main` (best-effort), +resolves changed files via `git merge-base origin/main HEAD` for the +per-package report, applies the default 0.5 pp per-package tolerance and +69% absolute floor, and exits with code `2` when either is breached (any +other non-zero exit indicates a script/infra error). On `main`, +in detached-HEAD state, or when git resolution fails, the target returns +an error rather than silently passing (the "preview" guarantee depends on +running against the same inputs CI uses). For an advisory run on `main`, +use `mage coverage:diff` instead. + +### Configuring the tolerance + +Override per run: + +```powershell +$env:COVERAGE_MAX_PACKAGE_DECREASE = "1.0"; mage coverage:pr +``` + +Or use the advisory `coverage:diff` target with explicit opt-in: + +```powershell +$env:COVERAGE_MAX_PACKAGE_DECREASE = "0.5" +$env:COVERAGE_MIN_OVERALL = "69" +$env:COVERAGE_FAIL_ON_DECREASE = "1" +mage coverage:diff +``` + +### Adjusting the absolute floor (`MinOverallCoverage`) + +The PR pipeline fails when overall coverage falls below +`MinOverallCoverage` (default **69%**). The floor is calibrated just below +the observed main overall coverage so it ratchets quality up while leaving +a small safety margin for normal churn. The release / `main` / scheduled +pipelines do not enforce the floor — `CodeCoverage_Upload` runs there only +to publish coverage artifacts. When a wave of refactors or generated-code +changes shifts overall coverage below the floor, follow this runbook so +PRs don't get jammed: + +1. **Confirm the dip is real** — pull the latest combined profile and read + the overall number: + + ```powershell + mage coverage:ci + go tool cover "-func=cover-ci-combined.out" | Select-String "^total:" + ``` + + If the dip is genuine (not a flaky platform leg producing artifact gaps), + continue. + +2. **Lower the floor temporarily** in + `eng/pipelines/templates/stages/code-coverage-upload.yml` — find the + `MinOverallCoverage` parameter and set `default:` to **1pp below** the new + observed overall (e.g. observed 62.5% → set 61). This unblocks `main`. + +3. **File a coverage-debt issue** capturing: the package(s) responsible for + the regression, the floor delta you applied, and a target date to ratchet + back. Without this step the floor silently stays loose forever. + +4. **Ratchet the floor back up** once the responsible package(s) regain + coverage. Bump `default:` to **1pp below the new observed overall**. + Avoid moving the floor in increments larger than 2pp at a time so that a + single flaky monthly measurement can't lock in an artificially-high + floor. + +> ⚠️ **Branch-protection note (one-time setup, repo admin):** the gate's +> exit code is only enforced on merges if the `CodeCoverage_Upload` stage +> is configured as a **required status check** on `main` in the repo's +> branch-protection rules. Without this, a PR can merge while the coverage +> check is red. +> +> A repo admin can confirm or add the rule via the GitHub UI +> (`Settings → Branches → Branch protection rules → main → Require status +> checks to pass before merging`), selecting the +> `azure-dev - ci - CodeCoverage_Upload` check. +> +> Equivalent `gh` CLI command (run by an admin once after the first +> successful build of this PR): +> +> ```bash +> gh api -X PATCH repos/Azure/azure-dev/branches/main/protection \ +> -f 'required_status_checks[strict]=true' \ +> -f 'required_status_checks[contexts][]=azure-dev - ci - CodeCoverage_Upload' +> ``` +> +> The exact context name comes from the GitHub Checks tab on a PR build — +> verify the string before applying. The check appears only after the +> stage has run on at least one PR, so seed it by opening a draft PR +> first. + +### Worked example + +Suppose this PR touches `pkg/auth` and drops its coverage from 72.0% → 48.0% (a +24.0 pp drop, well past the 0.5 pp tolerance). Overall coverage stays at 70.0% +(comfortably above the 69% floor — passes). The CI step prints: + +``` +============================================================ +Coverage Report +============================================================ +Baseline: baseline +Overall: 70.4% -> 70.0% (-0.4%) + Tolerance: -0.5 pp per package before failing the gate + Floor: overall coverage must stay >= 69.0% +PR-touched packages (2 packages): + pkg/auth 72.0% -> 48.0% ( -24.0%) regress (1 file touched) + pkg/project 81.0% -> 82.0% ( +1.0%) improved (2 files touched) +============================================================ +RESULT: FAIL +============================================================ +Breached gate(s): + - 1 package(s) dropped more than 0.5 pp: + pkg/auth: 72.0% -> 48.0% (-24.0 pp) +============================================================ +``` + +…then emits: + +``` +##vso[task.logissue type=error]Package pkg/auth dropped 24.0 pp (max allowed: -0.5 pp). +``` + +…and exits 2. The PR check summary shows the error, and the build fails. If +overall coverage had also fallen below 69.0%, a second `##vso[task.logissue]` +line would name the floor breach (both gates report independently). ## Troubleshooting diff --git a/cli/azd/go.mod b/cli/azd/go.mod index a2caceeb076..b44d3bc599a 100644 --- a/cli/azd/go.mod +++ b/cli/azd/go.mod @@ -53,7 +53,7 @@ require ( github.com/invopop/jsonschema v0.13.0 github.com/jmespath-community/go-jmespath v1.1.1 github.com/joho/godotenv v1.5.1 - github.com/magefile/mage v1.16.0 + github.com/magefile/mage v1.17.2 github.com/mark3labs/mcp-go v0.41.1 github.com/mattn/go-colorable v0.1.14 github.com/mattn/go-isatty v0.0.20 @@ -81,6 +81,7 @@ require ( go.uber.org/atomic v1.11.0 go.uber.org/multierr v1.11.0 go.yaml.in/yaml/v3 v3.0.4 + golang.org/x/sync v0.20.0 golang.org/x/sys v0.42.0 golang.org/x/term v0.41.0 golang.org/x/time v0.9.0 @@ -148,7 +149,6 @@ require ( golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect golang.org/x/net v0.52.0 // indirect - golang.org/x/sync v0.20.0 // indirect golang.org/x/text v0.35.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect ) diff --git a/cli/azd/go.sum b/cli/azd/go.sum index 77ff368a5a0..1ac7e26989e 100644 --- a/cli/azd/go.sum +++ b/cli/azd/go.sum @@ -211,8 +211,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/magefile/mage v1.16.0 h1:2naaPmNwrMicCdLBCRDw288hcyClO9lmnm6FMpXyJ5I= -github.com/magefile/mage v1.16.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= +github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40= +github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA= github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA= diff --git a/cli/azd/magefile.go b/cli/azd/magefile.go index f56d0f5048a..242c2921791 100644 --- a/cli/azd/magefile.go +++ b/cli/azd/magefile.go @@ -986,7 +986,7 @@ func addToPathUnix(dir string) error { // mage coverage:html — generate and open an HTML report // mage coverage:check — enforce minimum coverage threshold // mage coverage:diff — compare current branch vs main baseline -// mage coverage:pr — diff + post as PR comment +// mage coverage:pr — preview the CI PR coverage gate (fail-loud) // // See cli/azd/docs/code-coverage-guide.md for prerequisites and details. type Coverage mg.Namespace @@ -1090,6 +1090,18 @@ func (Coverage) Check() error { // Uses cover-local.out as the current profile (run coverage:unit first) and downloads // the main baseline from CI when needed. // +// By default this is purely advisory: it prints a per-package report without enforcing +// any gates, so local invocations like `mage coverage:diff` don't show noisy +// "RESULT: FAIL" lines just because a single package drifted past CI's 0.5 pp tolerance +// during exploration. To preview the CI gate locally, either set +// COVERAGE_FAIL_ON_DECREASE=1 (which activates CI defaults: 0.5 pp per package + +// 69% floor) or use `mage coverage:pr`. +// +// On a feature branch (not main / detached HEAD), Diff resolves PR-touched .go files +// via `git fetch origin main` + `git diff origin/main...HEAD` and passes them to the +// underlying script so the per-package report is scoped to packages containing +// touched files — matching the CI gate run by code-coverage-upload.yml. +// // To avoid the CI download (which requires 'az login'), run coverage on main first // and point to it: // @@ -1097,8 +1109,14 @@ func (Coverage) Check() error { // // Environment variables (optional): // -// COVERAGE_BASELINE — path to baseline coverage profile (default: cover-ci-combined.out or download from CI) -// COVERAGE_CURRENT — path to current coverage profile (default: cover-local.out) +// COVERAGE_BASELINE — path to baseline coverage profile (default: cover-ci-combined.out or download from CI) +// COVERAGE_CURRENT — path to current coverage profile (default: cover-local.out) +// COVERAGE_MAX_PACKAGE_DECREASE — per-package coverage decrease tolerance in percentage points +// (defaults: 0.5 when COVERAGE_FAIL_ON_DECREASE=1; gate disabled otherwise) +// COVERAGE_MIN_OVERALL — absolute floor for overall coverage in percent +// (defaults: 69.0 when COVERAGE_FAIL_ON_DECREASE=1; gate disabled otherwise) +// COVERAGE_FAIL_ON_DECREASE — "1" or "true" to exit non-zero when EITHER gate is breached +// (per-package decrease or absolute floor); also activates default thresholds // // Usage: mage coverage:diff func (Coverage) Diff() error { @@ -1121,22 +1139,74 @@ func (Coverage) Diff() error { return err } - diffScript := filepath.Join(repoRoot, "eng", "scripts", "Get-CoverageDiff.ps1") - return runPwshScript(azdDir, diffScript, + args := []string{ "-BaselineFile", baselineFile, "-CurrentFile", currentFile, - ) + } + + failOnDecrease := false + if fail := os.Getenv("COVERAGE_FAIL_ON_DECREASE"); fail == "1" || strings.EqualFold(fail, "true") { + failOnDecrease = true + } + + changedFiles, err := resolveChangedFilesForDiff(azdDir, failOnDecrease) + if err != nil { + return err + } + if changedFiles != "" { + args = append(args, "-ChangedFilesFromFile", changedFiles) + } + + // Pass user-supplied thresholds explicitly. When the user opts into + // fail-loud mode (COVERAGE_FAIL_ON_DECREASE=1) and hasn't set a + // threshold, omit the flag entirely so the script's own defaults rule — + // keeps a single source of truth (Get-CoverageDiff.ps1) and prevents + // drift between mage and CI. + // In advisory mode (default), neutralize the gates (max=100 / min=0) so + // local exploration runs don't print "RESULT: FAIL" noise just because + // a single package drifted past CI's defaults during exploration. + if maxPkg := os.Getenv("COVERAGE_MAX_PACKAGE_DECREASE"); maxPkg != "" { + args = append(args, "-MaxPackageDecrease", maxPkg) + } else if !failOnDecrease { + args = append(args, "-MaxPackageDecrease", "100") + } + + if minOverall := os.Getenv("COVERAGE_MIN_OVERALL"); minOverall != "" { + args = append(args, "-MinOverallCoverage", minOverall) + } else if !failOnDecrease { + args = append(args, "-MinOverallCoverage", "0") + } + + if failOnDecrease { + args = append(args, "-FailOnGate") + } + + diffScript := filepath.Join(repoRoot, "eng", "scripts", "Get-CoverageDiff.ps1") + return runPwshScript(azdDir, diffScript, args...) } -// PR generates a coverage diff and posts it as a comment on the current pull request. -// Requires: gh CLI authenticated, current branch must have an open PR. +// PR previews the CI PR coverage gate locally: same fail-loud per-package +// decrease and absolute-floor gates that code-coverage-upload.yml runs +// against the latest successful main build. No PR comment is posted (the CI +// gate surfaces breaches in the build log; this target lets contributors +// repro the gate locally before pushing). // -// Re-running replaces the previous coverage comment (uses a tag for replacement). +// Resolves changed files via `git fetch origin main` + `git merge-base origin/main HEAD` +// + `git diff` so the per-package report is scoped to packages containing PR-touched +// files. Requires a feature branch with origin/main reachable; on main or detached +// HEAD, or when git resolution fails (e.g. no remote / shallow clone), the target +// returns an actionable error rather than silently passing — the "preview" +// guarantee depends on the same package set CI displays. // // Environment variables (optional): // -// COVERAGE_BASELINE — path to baseline coverage profile (default: cover-ci-combined.out or download from CI) -// COVERAGE_CURRENT — path to current coverage profile (default: cover-local.out) +// COVERAGE_BASELINE — path to baseline coverage profile (default: cover-ci-combined.out or download from CI) +// COVERAGE_CURRENT — path to current coverage profile (default: cover-local.out) +// COVERAGE_MAX_PACKAGE_DECREASE — per-package coverage decrease tolerance in pp (default: from Get-CoverageDiff.ps1, currently 0.5) +// COVERAGE_MIN_OVERALL — absolute floor for overall coverage in percent (default: from Get-CoverageDiff.ps1, currently 69) +// +// Defaults intentionally come from the underlying script so both `mage coverage:pr` +// and the CI pipeline read from a single source of truth. // // Usage: mage coverage:pr func (Coverage) PR() error { @@ -1159,40 +1229,208 @@ func (Coverage) PR() error { return err } - // Generate diff markdown to a file - diffFile := filepath.Join(azdDir, "coverage-diff.md") - diffScript := filepath.Join(repoRoot, "eng", "scripts", "Get-CoverageDiff.ps1") - if err := runPwshScript(azdDir, diffScript, + changedFiles, err := resolveChangedFilesForDiff(azdDir, true) + if err != nil { + return err + } + if changedFiles == "" { + return fmt.Errorf( + "coverage:pr requires a feature branch with origin/main reachable; " + + "run on a feature branch and ensure 'git fetch origin main' succeeds", + ) + } + + args := []string{ "-BaselineFile", baselineFile, "-CurrentFile", currentFile, - "-OutputFile", diffFile, - ); err != nil { + "-FailOnGate", + "-ChangedFilesFromFile", changedFiles, + } + + // Only forward thresholds when the user explicitly set them. Otherwise + // let Get-CoverageDiff.ps1's own defaults rule so there's a single + // source of truth shared between mage and CI. + if v := os.Getenv("COVERAGE_MAX_PACKAGE_DECREASE"); v != "" { + args = append(args, "-MaxPackageDecrease", v) + } + if v := os.Getenv("COVERAGE_MIN_OVERALL"); v != "" { + args = append(args, "-MinOverallCoverage", v) + } + + diffScript := filepath.Join(repoRoot, "eng", "scripts", "Get-CoverageDiff.ps1") + return runPwshScript(azdDir, diffScript, args...) +} + +// Report merges Go cover-data inputs and writes a textfmt cover.out, mirroring +// the same `go tool covdata merge` + `go tool covdata textfmt` plumbing that +// `mage coverage:ci` uses internally. CI invokes this target so the upload +// stage and the local `mage coverage:*` targets share one coverage-reporting +// path — no second source of truth. +// +// Environment variables: +// +// COVERAGE_REPORT_UNIT_INPUTS — comma-separated paths to per-platform unit covdata directories (required) +// COVERAGE_REPORT_INT_INPUTS — comma-separated paths to per-platform integration covdata directories (optional) +// COVERAGE_REPORT_OUTPUT — path to the textfmt output file (required, e.g. cover.out) +// COVERAGE_REPORT_MERGED_DIR — directory to write the merged covdata into (default: /cover-merged) +// +// Usage: mage coverage:report +func (Coverage) Report() error { + unitInputs := os.Getenv("COVERAGE_REPORT_UNIT_INPUTS") + if unitInputs == "" { + return fmt.Errorf("COVERAGE_REPORT_UNIT_INPUTS is required (comma-separated covdata directories)") + } + output := os.Getenv("COVERAGE_REPORT_OUTPUT") + if output == "" { + return fmt.Errorf("COVERAGE_REPORT_OUTPUT is required (path to textfmt cover.out)") + } + + repoRoot, err := findRepoRoot() + if err != nil { return err } + azdDir := filepath.Join(repoRoot, "cli", "azd") + + mergedDir := os.Getenv("COVERAGE_REPORT_MERGED_DIR") + if mergedDir == "" { + mergedDir = filepath.Join(azdDir, "cover-merged") + } + + if err := os.MkdirAll(mergedDir, 0o755); err != nil { + return fmt.Errorf("creating merged dir %q: %w", mergedDir, err) + } + + intInputs := os.Getenv("COVERAGE_REPORT_INT_INPUTS") + + // Per-stream merge: when integration inputs are provided we merge each + // stream individually first so the final merge can combine both streams. + // Place intermediate dirs INSIDE mergedDir so concurrent invocations + // with different mergedDir values (e.g. PR pipeline merging current and + // baseline coverage in the same job) don't share intermediate paths and + // contaminate each other's covdata. + tmpUnit := filepath.Join(mergedDir, "unit-tmp") + tmpInt := filepath.Join(mergedDir, "int-tmp") + + if intInputs != "" { + // Clean any stale intermediate dir from a prior run before merging + // to avoid mixing previous covcounters/covmeta into this run. + if err := os.RemoveAll(tmpUnit); err != nil { + return fmt.Errorf("cleaning stale unit merge dir %q: %w", tmpUnit, err) + } + if err := os.RemoveAll(tmpInt); err != nil { + return fmt.Errorf("cleaning stale int merge dir %q: %w", tmpInt, err) + } + if err := os.MkdirAll(tmpUnit, 0o755); err != nil { + return fmt.Errorf("creating unit merge dir: %w", err) + } + if err := os.MkdirAll(tmpInt, 0o755); err != nil { + return fmt.Errorf("creating int merge dir: %w", err) + } + if err := runGoTool(azdDir, "covdata", "merge", "-i="+unitInputs, "-o="+tmpUnit); err != nil { + return fmt.Errorf("merging unit covdata: %w", err) + } + if err := runGoTool(azdDir, "covdata", "merge", "-i="+intInputs, "-o="+tmpInt); err != nil { + return fmt.Errorf("merging integration covdata: %w", err) + } + combined := tmpUnit + "," + tmpInt + if err := runGoTool(azdDir, "covdata", "merge", "-i="+combined, "-o="+mergedDir); err != nil { + return fmt.Errorf("merging combined covdata: %w", err) + } + } else { + if err := runGoTool(azdDir, "covdata", "merge", "-i="+unitInputs, "-o="+mergedDir); err != nil { + return fmt.Errorf("merging unit covdata: %w", err) + } + } + + if err := runGoTool(azdDir, "covdata", "textfmt", "-i="+mergedDir, "-o="+output); err != nil { + return fmt.Errorf("converting covdata to textfmt: %w", err) + } + + fmt.Printf("Wrote merged textfmt coverage profile to %s\n", output) + return nil +} + +// runGoTool runs `go tool ` inside the given directory and streams +// stdout/stderr through the parent process. Used by Coverage.Report to wrap +// `go tool covdata` invocations so the same pipeline plumbing is exercised +// in CI and locally. +func runGoTool(dir string, args ...string) error { + full := append([]string{"tool"}, args...) + cmd := exec.Command("go", full...) + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// resolveChangedFilesForDiff returns the path to a file listing PR-touched +// files used to scope the per-package coverage gate, or an +// empty string when changed-file mode should be skipped (on main, detached +// HEAD, or when git resolution fails). +// +// When strict is false, git resolution failures degrade silently to advisory +// mode. When strict is true, failures return an actionable error so the caller +// (e.g. `mage coverage:pr`, which always enforces the floor) doesn't pass with +// a green-when-CI-is-red false positive. +// +// Mirrors the resolution done by code-coverage-upload.yml so local +// `mage coverage:diff` / `coverage:pr` runs against the same file set CI checks. +func resolveChangedFilesForDiff(azdDir string, strict bool) (string, error) { + skipOrFail := func(reason string) (string, error) { + if strict { + return "", fmt.Errorf("cannot resolve changed files for coverage gate: %s", reason) + } + return "", nil + } - // Determine PR number from current branch - prNumRaw, err := runCapture(azdDir, "gh", "pr", "view", "--json", "number", "--jq", ".number") + branch, err := runCapture(azdDir, "git", "rev-parse", "--abbrev-ref", "HEAD") if err != nil { - return fmt.Errorf("no open PR for current branch (is 'gh' authenticated?): %w", err) + return skipOrFail(fmt.Sprintf("git rev-parse failed: %v", err)) } - prNum := strings.TrimSpace(prNumRaw) + branch = strings.TrimSpace(branch) + if branch == "" || branch == "HEAD" { + return skipOrFail("detached HEAD or empty branch name") + } + if branch == "main" { + return skipOrFail("on main branch (no PR diff to compute)") + } + + // Best-effort fetch so origin/main is fresh; mirrors code-coverage-upload.yml. + // Any error here is non-fatal — the next step will surface a usable error if + // origin/main truly isn't reachable. + _, _ = runCapture(azdDir, "git", "fetch", "--no-tags", "--depth=200", "origin", "main") - // Determine repository slug (owner/repo) - repoRaw, err := runCapture(azdDir, "gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner") + base, err := runCapture(azdDir, "git", "merge-base", "origin/main", "HEAD") if err != nil { - return fmt.Errorf("cannot determine repository: %w", err) - } - repo := strings.TrimSpace(repoRaw) - - // Post coverage diff as a PR comment (replaces previous tagged comment) - fmt.Printf("Posting coverage diff to %s#%s...\n", repo, prNum) - updateScript := filepath.Join(repoRoot, "eng", "scripts", "Update-PRComment.ps1") - return runPwshScript(azdDir, updateScript, - "-Repo", repo, - "-PRNumber", prNum, - "-BodyFile", diffFile, - "-Tag", "", - ) + return skipOrFail(fmt.Sprintf("git merge-base origin/main HEAD failed: %v", err)) + } + base = strings.TrimSpace(base) + if base == "" { + return skipOrFail("empty merge-base result") + } + + diff, err := runCapture(azdDir, "git", "diff", "--name-only", "--no-renames", "--diff-filter=AMRD", base+"...HEAD") + if err != nil { + return skipOrFail(fmt.Sprintf("git diff failed: %v", err)) + } + diff = strings.TrimSpace(diff) + if diff == "" { + return skipOrFail("no files changed vs origin/main") + } + + // Use os.CreateTemp (not a fixed name in TempDir) so two concurrent + // `mage coverage:diff` / `coverage:pr` runs on the same machine can't + // clobber each other's file list and silently produce wrong gate results. + f, err := os.CreateTemp("", "azd-coverage-changed-files-*.txt") + if err != nil { + return "", fmt.Errorf("creating changed-files temp file: %w", err) + } + out := f.Name() + f.Close() + if err := os.WriteFile(out, []byte(diff+"\n"), 0o644); err != nil { + return "", fmt.Errorf("writing changed files list: %w", err) + } + return out, nil } // resolveCoverageFile returns envOverride if non-empty and existing, diff --git a/cli/azd/magefile_test.go b/cli/azd/magefile_test.go new file mode 100644 index 00000000000..d4ea71cc8c7 --- /dev/null +++ b/cli/azd/magefile_test.go @@ -0,0 +1,302 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +//go:build mage + +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// ---------------------------------------------------------------------------- +// resolveCoverageFile — pure filesystem; covers the env-override + default +// fallback contract used by Coverage.Diff / Coverage.PR. +// ---------------------------------------------------------------------------- + +func TestResolveCoverageFile_EnvOverride_Exists(t *testing.T) { + dir := t.TempDir() + envFile := filepath.Join(dir, "override.out") + if err := os.WriteFile(envFile, []byte("mode: set\n"), 0o644); err != nil { + t.Fatalf("seed env file: %v", err) + } + defaultFile := filepath.Join(dir, "default.out") // intentionally not created + + got, err := resolveCoverageFile(envFile, defaultFile) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != envFile { + t.Fatalf("want %q, got %q", envFile, got) + } +} + +func TestResolveCoverageFile_EnvOverride_Missing(t *testing.T) { + dir := t.TempDir() + envFile := filepath.Join(dir, "missing.out") // not created + defaultFile := filepath.Join(dir, "default.out") + if err := os.WriteFile(defaultFile, []byte("mode: set\n"), 0o644); err != nil { + t.Fatalf("seed default file: %v", err) + } + + // Env override takes precedence even when missing — must error rather + // than silently falling back to the default (callers explicitly opted + // in to a specific file). + _, err := resolveCoverageFile(envFile, defaultFile) + if err == nil { + t.Fatal("expected error for missing env-override file, got nil") + } + if !strings.Contains(err.Error(), "file not found") { + t.Fatalf("expected 'file not found' error, got: %v", err) + } +} + +func TestResolveCoverageFile_DefaultExists(t *testing.T) { + dir := t.TempDir() + defaultFile := filepath.Join(dir, "default.out") + if err := os.WriteFile(defaultFile, []byte("mode: set\n"), 0o644); err != nil { + t.Fatalf("seed default file: %v", err) + } + + got, err := resolveCoverageFile("", defaultFile) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != defaultFile { + t.Fatalf("want %q, got %q", defaultFile, got) + } +} + +func TestResolveCoverageFile_DefaultMissing(t *testing.T) { + dir := t.TempDir() + defaultFile := filepath.Join(dir, "missing.out") // not created + + _, err := resolveCoverageFile("", defaultFile) + if err == nil { + t.Fatal("expected error for missing default file, got nil") + } + if !strings.Contains(err.Error(), "file not found") { + t.Fatalf("expected 'file not found' error, got: %v", err) + } +} + +// ---------------------------------------------------------------------------- +// resolveBaselineFile — env override + default; the CI-download fallback +// is intentionally not exercised here (requires az login). +// ---------------------------------------------------------------------------- + +func TestResolveBaselineFile_EnvOverride_Exists(t *testing.T) { + t.Setenv("COVERAGE_BASELINE", "") + dir := t.TempDir() + envFile := filepath.Join(dir, "baseline.out") + if err := os.WriteFile(envFile, []byte("mode: set\n"), 0o644); err != nil { + t.Fatalf("seed env baseline: %v", err) + } + t.Setenv("COVERAGE_BASELINE", envFile) + + got, err := resolveBaselineFile(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != envFile { + t.Fatalf("want %q, got %q", envFile, got) + } +} + +func TestResolveBaselineFile_EnvOverride_Missing(t *testing.T) { + dir := t.TempDir() + t.Setenv("COVERAGE_BASELINE", filepath.Join(dir, "missing.out")) + + _, err := resolveBaselineFile(dir) + if err == nil { + t.Fatal("expected error for missing baseline override, got nil") + } + if !strings.Contains(err.Error(), "baseline file not found") { + t.Fatalf("expected 'baseline file not found' error, got: %v", err) + } +} + +func TestResolveBaselineFile_DefaultExists(t *testing.T) { + t.Setenv("COVERAGE_BASELINE", "") + dir := t.TempDir() + defaultBaseline := filepath.Join(dir, "cover-ci-combined.out") + if err := os.WriteFile(defaultBaseline, []byte("mode: set\n"), 0o644); err != nil { + t.Fatalf("seed default baseline: %v", err) + } + + got, err := resolveBaselineFile(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != defaultBaseline { + t.Fatalf("want %q, got %q", defaultBaseline, got) + } +} + +// ---------------------------------------------------------------------------- +// resolveChangedFilesForDiff — uses real git in temp repos so we exercise +// the actual git invocations the function relies on. +// ---------------------------------------------------------------------------- + +// initTempRepo seeds a fresh git repo in t.TempDir() with one commit on main. +// Returns the repo dir and the initial commit SHA. +func initTempRepo(t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available on PATH; skipping git-backed test") + } + dir := t.TempDir() + mustGit := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + // Repo-local identity so CI runners without global config still work. + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", + "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=test", + "GIT_COMMITTER_EMAIL=test@example.com", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, out) + } + } + mustGit("init", "-b", "main") + mustGit("config", "user.email", "test@example.com") + mustGit("config", "user.name", "test") + if err := os.WriteFile(filepath.Join(dir, "seed.txt"), []byte("seed\n"), 0o644); err != nil { + t.Fatalf("seed file: %v", err) + } + mustGit("add", ".") + mustGit("commit", "-m", "seed") + return dir +} + +func TestResolveChangedFilesForDiff_OnMain_NonStrict(t *testing.T) { + dir := initTempRepo(t) + + got, err := resolveChangedFilesForDiff(dir, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "" { + t.Fatalf("expected empty path on main, got %q", got) + } +} + +func TestResolveChangedFilesForDiff_OnMain_Strict(t *testing.T) { + dir := initTempRepo(t) + + _, err := resolveChangedFilesForDiff(dir, true) + if err == nil { + t.Fatal("expected error in strict mode on main, got nil") + } + if !strings.Contains(err.Error(), "on main branch") { + t.Fatalf("expected 'on main branch' error, got: %v", err) + } +} + +func TestResolveChangedFilesForDiff_DetachedHEAD_NonStrict(t *testing.T) { + dir := initTempRepo(t) + + // Detach HEAD by checking out the commit SHA directly. + cmd := exec.Command("git", "rev-parse", "HEAD") + cmd.Dir = dir + shaOut, err := cmd.Output() + if err != nil { + t.Fatalf("git rev-parse HEAD: %v", err) + } + sha := strings.TrimSpace(string(shaOut)) + cmd = exec.Command("git", "checkout", "--detach", sha) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git checkout --detach: %v\n%s", err, out) + } + + got, err := resolveChangedFilesForDiff(dir, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "" { + t.Fatalf("expected empty path on detached HEAD, got %q", got) + } +} + +// Validates the happy path: a feature branch with diffs vs origin/main +// returns a file containing the changed-file list. +// +// We simulate `origin/main` by creating a bare upstream repo, pushing main to +// it, then branching locally and diffing against origin/main. +func TestResolveChangedFilesForDiff_FeatureBranch_HappyPath(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available on PATH") + } + + // Upstream "remote" — bare repo to push to. + upstream := t.TempDir() + cmd := exec.Command("git", "init", "--bare", "-b", "main", upstream) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git init --bare: %v\n%s", err, out) + } + + // Working repo with origin pointing at upstream. + work := initTempRepo(t) + mustGit := func(args ...string) { + t.Helper() + c := exec.Command("git", args...) + c.Dir = work + c.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", + "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=test", + "GIT_COMMITTER_EMAIL=test@example.com", + ) + if out, err := c.CombinedOutput(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } + } + mustGit("remote", "add", "origin", upstream) + mustGit("push", "-u", "origin", "main") + + // Branch off, add a Go file (per-package gate cares about Go files). + mustGit("checkout", "-b", "feature/test") + pkgDir := filepath.Join(work, "pkg", "foo") + if err := os.MkdirAll(pkgDir, 0o755); err != nil { + t.Fatalf("mkdir pkg: %v", err) + } + if err := os.WriteFile(filepath.Join(pkgDir, "foo.go"), []byte("package foo\n"), 0o644); err != nil { + t.Fatalf("write foo.go: %v", err) + } + mustGit("add", ".") + mustGit("commit", "-m", "add foo") + + got, err := resolveChangedFilesForDiff(work, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == "" { + t.Fatal("expected non-empty path on feature branch with diff") + } + defer os.Remove(got) + + body, err := os.ReadFile(got) + if err != nil { + t.Fatalf("read changed-files output: %v", err) + } + if !strings.Contains(string(body), filepath.ToSlash(filepath.Join("pkg", "foo", "foo.go"))) { + t.Fatalf("expected changed-files output to include pkg/foo/foo.go, got:\n%s", body) + } + + got2, err := resolveChangedFilesForDiff(work, false) + if err != nil { + t.Fatalf("second resolve unexpected error: %v", err) + } + defer os.Remove(got2) + if got == got2 { + t.Fatalf("expected two resolves to produce distinct paths, both got %q", got) + } +} diff --git a/eng/pipelines/release-cli.yml b/eng/pipelines/release-cli.yml index bbe82369692..2657a4a1027 100644 --- a/eng/pipelines/release-cli.yml +++ b/eng/pipelines/release-cli.yml @@ -128,7 +128,6 @@ extends: - template: /eng/pipelines/templates/stages/code-coverage-upload.yml parameters: - MinimumCoveragePercent: 65 DownloadArtifacts: - cover-win - cover-lin diff --git a/eng/pipelines/templates/stages/code-coverage-upload.yml b/eng/pipelines/templates/stages/code-coverage-upload.yml index 3019b6dcccb..979f6bf6ac2 100644 --- a/eng/pipelines/templates/stages/code-coverage-upload.yml +++ b/eng/pipelines/templates/stages/code-coverage-upload.yml @@ -1,13 +1,35 @@ parameters: - name: DownloadArtifacts + # Coverage artifacts to download and merge. Each entry must match a + # `cover-` artifact published by the BuildAndTest matrix + # in eng/pipelines/templates/jobs/build-cli.yml (search for + # `artifact: cover-$(AZURE_DEV_CI_OS)`). Adding a new test-leg OS to the + # matrix without adding it here will silently exclude that leg's coverage + # from the merged total *and* from the missing-platform assertion in this + # template — keep these two lists in sync. type: object default: - cover-win - cover-lin - cover-mac - - name: MinimumCoveragePercent + - name: MaxPackageDecrease + # Maximum tolerated per-package coverage decrease (percentage points) on PRs. + # The PR fails when any single package's coverage drops by more than this + # many pp from the baseline. Scoped to PR-touched packages when changed + # files can be resolved; otherwise applies to all packages. Set to -1 to + # disable both this gate and the absolute-floor gate (advisory output only). + # Name matches the underlying Get-CoverageDiff.ps1 -MaxPackageDecrease flag. type: number - default: 0 + default: 0.5 + - name: MinOverallCoverage + # Absolute floor for overall coverage on PRs. The PR fails when current + # overall coverage is below this percentage, regardless of baseline. + # Calibrated just below the observed main overall coverage to ratchet + # quality up while keeping a small safety margin for normal churn; raise + # deliberately when ratcheting further. + # Name matches the underlying Get-CoverageDiff.ps1 -MinOverallCoverage flag. + type: number + default: 69 - name: MaxPackageTestSeconds type: number default: 0 @@ -31,33 +53,74 @@ stages: os: linux steps: - template: /eng/pipelines/templates/steps/setup-go.yml + - pwsh: | + # Install mage so `mage coverage:report` (used both for the merged + # current profile below and for the baseline merge in the PR-diff + # step) is available on the build agent. + # Pinned for deterministic, supply-chain-safe installs (avoid + # `@latest` drift between builds). Keep this in sync with the + # `github.com/magefile/mage` version in `cli/azd/go.mod`. + go install github.com/magefile/mage@v1.17.2 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # Ensure $(go env GOPATH)/bin is on PATH for subsequent steps. + $goBin = (& go env GOPATH) + "/bin" + Write-Host "##vso[task.prependpath]$goBin" + displayName: Install mage - template: /eng/pipelines/templates/steps/download-artifacts.yml parameters: Artifacts: ${{ parameters.DownloadArtifacts }} - pwsh: | New-Item -ItemType Directory -Force -Path cover - New-Item -ItemType Directory -Force -Path cover-int - New-Item -ItemType Directory -Force -Path cover-unit + + # Expected platform artifacts (populated from the DownloadArtifacts + # template parameter — itself mirroring the BuildAndTest matrix in + # eng/pipelines/templates/jobs/build-cli.yml). Assert presence + # before merging so a flaky runner that silently dropped its + # coverage artifact (e.g. the Mac leg fell over) fails the gate as + # an infra issue here, instead of masquerading downstream as a + # coverage regression after the merge silently proceeds with fewer + # platforms. + $expectedPlatforms = @' + ${{ convertToJson(parameters.DownloadArtifacts) }} + '@ | ConvertFrom-Json + + $missing = @() + foreach ($p in $expectedPlatforms) { + # Test-Path returns true for empty dirs; require at least one file + # under each unit/ + int/ subtree so a partial-publish leg (empty + # dir) fails fast here instead of silently shrinking the merged + # coverage total downstream. + $unitOk = (Test-Path "$p/unit") -and ` + @(Get-ChildItem "$p/unit" -File -Recurse -ErrorAction SilentlyContinue).Count -gt 0 + $intOk = (Test-Path "$p/int") -and ` + @(Get-ChildItem "$p/int" -File -Recurse -ErrorAction SilentlyContinue).Count -gt 0 + if (-not $unitOk -or -not $intOk) { + $missing += $p + } + } + if ($missing.Count -gt 0) { + Write-Host "##vso[task.logissue type=error]Missing or empty coverage artifacts for platform(s): $($missing -join ', '). Expected $($expectedPlatforms.Count) platform(s) ($($expectedPlatforms -join ', ')). This is an infra/build issue (a platform leg likely failed, skipped publish, or published an empty dir) and would silently shrink the merged coverage total — failing fast to avoid a false-positive coverage regression." + exit 1 + } $unitCoverage = (Get-ChildItem cover-*/unit).FullName -join "," $integrationCoverage = (Get-ChildItem cover-*/int).FullName -join "," - # Merge unit test coverage across platforms - go tool covdata merge -i="$unitCoverage" -o cover-unit - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - # Merge integration test coverage across platforms - go tool covdata merge -i="$integrationCoverage" -o cover-int - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - # Merge unit and integration code coverage - go tool covdata merge -i="cover-unit,cover-int" -o cover - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - # Convert to text format - go tool covdata textfmt -i=cover -o cover.out - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # Merge platform coverage and emit cover.out via the shared mage + # target so CI uses the same coverage reporting plumbing as + # `mage coverage:ci` does locally — avoids a second source of truth. + Push-Location $(Build.SourcesDirectory)/cli/azd + try { + $env:COVERAGE_REPORT_UNIT_INPUTS = $unitCoverage + $env:COVERAGE_REPORT_INT_INPUTS = $integrationCoverage + $env:COVERAGE_REPORT_OUTPUT = "$(Build.SourcesDirectory)/cover.out" + $env:COVERAGE_REPORT_MERGED_DIR = "$(Build.SourcesDirectory)/cover" + mage coverage:report + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } finally { + Pop-Location + } # Filter generated code (e.g. protobuf *.pb.go) from coverage profile # so that coverage percentages reflect only hand-written source code. @@ -74,16 +137,6 @@ stages: } displayName: Merge code coverage files - - ${{ if gt(parameters.MinimumCoveragePercent, 0) }}: - - task: PowerShell@2 - inputs: - pwsh: true - targetType: filePath - filePath: $(Build.SourcesDirectory)/eng/scripts/Test-CodeCoverageThreshold.ps1 - arguments: -CoverageFile $(Build.SourcesDirectory)/cover.out -MinimumCoveragePercent ${{ parameters.MinimumCoveragePercent }} - workingDirectory: $(Build.SourcesDirectory)/cli/azd - displayName: Check code coverage threshold - - task: PublishCodeCoverageResults@2 condition: succeededOrFailed() inputs: @@ -128,7 +181,45 @@ stages: $ErrorActionPreference = 'Stop' # --------------------------------------------------------------- - # Download main branch baseline coverage for comparison + # Helper: retry a scriptblock with exponential backoff. + # Used for transient ADO REST / artifact download failures so + # network blips don't silently skip the gate. + # --------------------------------------------------------------- + function Invoke-WithRetry { + param( + [Parameter(Mandatory)][scriptblock]$ScriptBlock, + [string]$Operation = 'operation', + [int]$MaxAttempts = 3, + [int]$InitialDelaySeconds = 2 + ) + $delay = $InitialDelaySeconds + for ($i = 1; $i -le $MaxAttempts; $i++) { + try { + return & $ScriptBlock + } catch { + if ($i -eq $MaxAttempts) { + Write-Warning "$Operation failed after $MaxAttempts attempts: $($_.Exception.Message)" + throw + } + Write-Warning "$Operation attempt $i/$MaxAttempts failed: $($_.Exception.Message). Retrying in ${delay}s..." + Start-Sleep -Seconds $delay + $delay *= 2 + } + } + } + + # --------------------------------------------------------------- + # Resolve the PR target branch up-front. Both the baseline build + # query AND the changed-files diff must use the SAME branch, + # otherwise PRs against release branches compare current coverage + # to a main baseline (false positives + negatives). + # --------------------------------------------------------------- + $targetRef = $env:SYSTEM_PULLREQUEST_TARGETBRANCH + $targetBranch = if ($targetRef) { $targetRef -replace '^refs/heads/', '' } else { 'main' } + Write-Host "PR target branch: $targetBranch" + + # --------------------------------------------------------------- + # Download target-branch baseline coverage for comparison # --------------------------------------------------------------- $adoOrg = '$(System.CollectionUri)'.TrimEnd('/') $adoProject = '$(System.TeamProjectId)' @@ -141,62 +232,138 @@ stages: $headers = @{ Authorization = "Bearer $env:SYSTEM_ACCESSTOKEN" } - Write-Host "Finding latest successful main build for baseline..." + Write-Host "Finding latest successful $targetBranch build for baseline..." $buildsUrl = "$adoOrg/$adoProject/_apis/build/builds?" + - "definitions=$pipelineId&branchName=refs/heads/main" + + "definitions=$pipelineId&branchName=refs/heads/$targetBranch" + "&resultFilter=succeeded&`$top=1&api-version=7.1" try { - $buildsResp = Invoke-RestMethod -Uri $buildsUrl -Headers $headers -Method Get + $buildsResp = Invoke-WithRetry -Operation "Query $targetBranch builds" -ScriptBlock { + Invoke-RestMethod -Uri $buildsUrl -Headers $headers -Method Get + } } catch { - Write-Warning "Failed to query main build baseline. Skipping coverage diff. $($_.Exception.Message)" + # Final-attempt failure: emit a loud annotation so the gate + # skip is visible in the PR (vs. the previous silent exit 0). + Write-Host "##vso[task.logissue type=warning]Failed to query $targetBranch baseline build after retries; coverage diff skipped. $($_.Exception.Message)" exit 0 } if ($buildsResp.count -eq 0) { - Write-Warning "No successful main builds found. Skipping coverage diff." + # No successful baseline build for this target branch (e.g. a + # newly-cut release branch). Skip diff but advertise the skip. + Write-Host "##vso[task.logissue type=warning]No successful $targetBranch builds found; coverage diff skipped (advisory)." exit 0 } $mainBuildId = $buildsResp.value[0].id $mainBuildNum = $buildsResp.value[0].buildNumber - Write-Host "Using main build $mainBuildId ($mainBuildNum) for baseline" - - # Download and merge main build coverage artifacts + $mainCommit = $buildsResp.value[0].sourceVersion + $shortCommit = if ($mainCommit) { $mainCommit.Substring(0, [math]::Min(8, $mainCommit.Length)) } else { 'unknown' } + Write-Host "Using $targetBranch build $mainBuildId ($mainBuildNum) commit $shortCommit for baseline" + + # Download and merge target-branch coverage artifacts. The baseline + # uses the SAME per-platform `cover-` artifact list as the + # current-build merge above (driven by the DownloadArtifacts + # template parameter). Each platform artifact contains `unit/` and + # `int/` subdirs (see eng/pipelines/templates/jobs/build-cli.yml + # `path: cli/azd/cover-$(AZURE_DEV_CI_OS)`); merging the same shape + # ensures the baseline profile is comparable with the current one. New-Item -ItemType Directory -Force -Path baseline-tmp | Out-Null - foreach ($artifactName in @('cover-unit', 'cover-int')) { + $baselinePlatforms = @' + ${{ convertToJson(parameters.DownloadArtifacts) }} + '@ | ConvertFrom-Json + + foreach ($artifactName in $baselinePlatforms) { $url = "$adoOrg/$adoProject/_apis/build/builds/$mainBuildId/artifacts?artifactName=$artifactName&api-version=7.1" try { - $resp = Invoke-RestMethod -Uri $url -Headers $headers -Method Get + $resp = Invoke-WithRetry -Operation "Get $artifactName metadata" -ScriptBlock { + Invoke-RestMethod -Uri $url -Headers $headers -Method Get + } } catch { - Write-Warning "Failed to download $artifactName from main build: $_" - exit 0 + # Distinguish "no baseline available" (404 — artifact never + # existed for this build, e.g. main built before the gate + # was wired up, or coverage was skipped on that build) from + # transient network/auth failures that should hard-fail. + # Without this distinction, the very-first PR after the + # gate lands would block on a CI-infra reason rather than + # a code reason. + $statusCode = $null + if ($_.Exception -and $_.Exception.Response) { + $statusCode = [int]$_.Exception.Response.StatusCode + } + if ($statusCode -eq 404) { + Write-Host "##vso[task.logissue type=warning]Baseline build $mainBuildId has no $artifactName artifact (HTTP 404); coverage diff skipped (advisory). This is expected on the first PR after the gate lands or when main builds were run with coverage disabled." + exit 0 + } + Write-Host "##vso[task.logissue type=error]Failed to fetch $artifactName metadata after retries; coverage diff cannot run. $($_.Exception.Message)" + exit 1 } $dlUrl = $resp.resource.downloadUrl - Invoke-WebRequest -Uri $dlUrl -Headers $headers -OutFile "baseline-tmp/$artifactName.zip" + try { + Invoke-WithRetry -Operation "Download $artifactName" -ScriptBlock { + Invoke-WebRequest -Uri $dlUrl -Headers $headers -OutFile "baseline-tmp/$artifactName.zip" + } | Out-Null + } catch { + Write-Host "##vso[task.logissue type=error]Failed to download $artifactName after retries; coverage diff cannot run. $($_.Exception.Message)" + exit 1 + } Expand-Archive -Path "baseline-tmp/$artifactName.zip" -DestinationPath "baseline-tmp/$artifactName" -Force Remove-Item "baseline-tmp/$artifactName.zip" } - # Resolve nested artifact paths (pipeline artifacts nest under artifact name) - $unitPath = "baseline-tmp/cover-unit/cover-unit" - $intPath = "baseline-tmp/cover-int/cover-int" - if (-not (Test-Path $unitPath)) { $unitPath = "baseline-tmp/cover-unit" } - if (-not (Test-Path $intPath)) { $intPath = "baseline-tmp/cover-int" } - - New-Item -ItemType Directory -Force -Path baseline-merged | Out-Null - go tool covdata merge -i="$unitPath,$intPath" -o baseline-merged - if ($LASTEXITCODE -ne 0) { - Write-Warning "Failed to merge baseline coverage. Skipping diff." + # Collect unit/int subdirs from every downloaded platform artifact. + # Pipeline artifacts can land flat (`/unit`) or nested under + # the artifact name (`//unit`) depending on publish + # settings; handle both. The resulting comma-joined paths feed + # `mage coverage:report` exactly like the current-build merge, + # producing a baseline profile with matching package coverage. + $unitDirs = @() + $intDirs = @() + # Helper: returns the resolved path only if the dir exists AND + # contains at least one file. Resolve-Path alone passes empty + # dirs, which would silently shrink the baseline merge. + function Test-NonEmptyCoverDir($candidate) { + $rp = Resolve-Path $candidate -ErrorAction SilentlyContinue + if (-not $rp) { return $null } + $hasFiles = @(Get-ChildItem $rp.Path -File -Recurse -ErrorAction SilentlyContinue).Count -gt 0 + if (-not $hasFiles) { return $null } + return $rp + } + foreach ($p in $baselinePlatforms) { + $unit = (Test-NonEmptyCoverDir "baseline-tmp/$p/$p/unit") ` + ?? (Test-NonEmptyCoverDir "baseline-tmp/$p/unit") + $int = (Test-NonEmptyCoverDir "baseline-tmp/$p/$p/int") ` + ?? (Test-NonEmptyCoverDir "baseline-tmp/$p/int") + if (-not $unit -or -not $int) { + Write-Host "##vso[task.logissue type=warning]Baseline artifact $p missing or empty unit/int subdirs (unit=$unit int=$int); excluding this platform from baseline merge. The PR-side merge would also have flagged this as a missing-platform error, so this likely indicates the baseline build's matrix differs from the current build's matrix, or that baseline platform published an empty dir." + continue + } + $unitDirs += $unit.Path + $intDirs += $int.Path + } + if ($unitDirs.Count -eq 0 -or $intDirs.Count -eq 0) { + Write-Host "##vso[task.logissue type=warning]Baseline build $mainBuildId contained no usable coverage data after layout resolution; coverage diff skipped (advisory)." exit 0 } + $unitPath = $unitDirs -join ',' + $intPath = $intDirs -join ',' - go tool covdata textfmt -i=baseline-merged -o cover-baseline.out - if ($LASTEXITCODE -ne 0) { - Write-Warning "Failed to convert baseline coverage. Skipping diff." - exit 0 + New-Item -ItemType Directory -Force -Path baseline-merged | Out-Null + Push-Location $(Build.SourcesDirectory)/cli/azd + try { + $env:COVERAGE_REPORT_UNIT_INPUTS = $unitPath + $env:COVERAGE_REPORT_INT_INPUTS = $intPath + $env:COVERAGE_REPORT_OUTPUT = "$(Build.SourcesDirectory)/cover-baseline.out" + $env:COVERAGE_REPORT_MERGED_DIR = "$(Build.SourcesDirectory)/baseline-merged" + mage coverage:report + if ($LASTEXITCODE -ne 0) { + Write-Host "##vso[task.logissue type=error]Failed to merge baseline coverage (mage coverage:report exit $LASTEXITCODE). Cannot run PR coverage diff." + exit 1 + } + } finally { + Pop-Location } # Filter generated code from baseline (same as current) @@ -206,64 +373,104 @@ stages: } # --------------------------------------------------------------- - # Generate coverage diff report + # Resolve PR-changed files via `git diff` against the PR target + # branch. The triple-dot syntax `base...HEAD` implicitly uses the + # merge-base, so we don't need a separate `git merge-base` call. + # The list scopes the per-package report and per-package gate to + # packages that contain a touched file. If diff resolution fails + # (shallow clone, missing remote ref, etc.) we fall back to + # package mode (full scan of every package) — fail closed, never + # silently disable the per-package gate. # --------------------------------------------------------------- - pwsh -NoProfile -NonInteractive -File "$(Build.SourcesDirectory)/eng/scripts/Get-CoverageDiff.ps1" ` - -BaselineFile cover-baseline.out ` - -CurrentFile cover.out ` - -OutputFile coverage-diff.md - - if (-not (Test-Path coverage-diff.md)) { - Write-Warning "Coverage diff report was not generated." - exit 0 + $changedFilesPath = "changed-files.txt" + $changedFilesResolved = $false + + $packageTolerance = ${{ parameters.MaxPackageDecrease }} + $minOverall = ${{ parameters.MinOverallCoverage }} + + # NOTE: $targetBranch was resolved at the top of this script; the + # baseline build query above used the same value, so the diff and + # the baseline always reference the same branch. + + Write-Host "Fetching target branch for changed-file resolution: $targetBranch" + git fetch --no-tags origin "+refs/heads/${targetBranch}:refs/remotes/origin/${targetBranch}" --depth=200 2>&1 | Write-Host + + # `git diff base...HEAD` does its own merge-base resolution; if the + # remote ref or merge-base isn't reachable, this fails non-zero. + # --diff-filter=AMRD also captures Deletions, so a PR that only + # removes files (e.g. removes a fully-covered file → package + # coverage drops) still triggers the per-package gate. + # --no-renames is explicit so behavior is deterministic regardless + # of the agent's diff.renames config: a rename surfaces as a + # D-old + A-new pair (both packages correctly scoped) rather than + # an opaque R-line whose old path the script cannot recover. + $diffOutput = git diff --name-only --no-renames --diff-filter=AMRD "origin/${targetBranch}...HEAD" 2>&1 + if ($LASTEXITCODE -eq 0) { + # Empty output is a valid result (docs-only PR, no Go changes); + # mark as resolved so the per-package gate scopes to "none". + Set-Content -Path $changedFilesPath -Value ($diffOutput -join "`n") -Encoding UTF8 + $changedFilesResolved = $true + } else { + Write-Warning "git diff against origin/$targetBranch failed: $diffOutput — falling back to package mode (full per-package scan)." } + # Reuse the in-memory diff output instead of re-reading the file we + # just wrote — saves an unnecessary disk round-trip. + $changedCount = if ($changedFilesResolved) { + @($diffOutput | Where-Object { $_ -match '\S' }).Count + } else { 0 } + Write-Host "Changed files for coverage report scope: $changedCount (resolved=$changedFilesResolved)" + # --------------------------------------------------------------- - # Post as PR comment (requires GH_TOKEN) + # Generate plain-text coverage report (logged inline) and fail + # the stage on either gate breach: overall coverage below the + # absolute floor, or any PR-touched package dropping beyond the + # per-package tolerance. # --------------------------------------------------------------- - if (-not $env:GH_TOKEN) { - Write-Host "GH_TOKEN not configured - printing diff to log instead:" - Get-Content coverage-diff.md - exit 0 + $diffArgs = @( + '-NoProfile', '-NonInteractive', + '-File', "$(Build.SourcesDirectory)/eng/scripts/Get-CoverageDiff.ps1", + '-BaselineFile', 'cover-baseline.out', + '-CurrentFile', 'cover.out', + '-BaselineLabel', "$targetBranch build $mainBuildNum / commit $shortCommit" + ) + + # Pass the changed-files list ONLY when git diff resolution + # succeeded. On failure we omit -ChangedFilesFromFile so the + # script falls into package mode (full per-package scan) instead + # of receiving an empty list (which would silently scope the + # per-package gate to zero packages → false negative). + if ($changedFilesResolved) { + $diffArgs += @('-ChangedFilesFromFile', $changedFilesPath) } - $prNumber = '$(System.PullRequest.PullRequestNumber)' - if (-not $prNumber -or $prNumber -eq '$(System.PullRequest.PullRequestNumber)') { - Write-Warning "Cannot determine PR number. Printing diff to log:" - Get-Content coverage-diff.md - exit 0 + if ($packageTolerance -ge 0) { + $diffArgs += @( + '-MaxPackageDecrease', $packageTolerance, + '-MinOverallCoverage', $minOverall, + '-FailOnGate' + ) } - - # Derive owner/repo from git remote - $repoSlug = git config --get remote.origin.url 2>$null - if ($repoSlug -match 'github\.com[:/](.+?)(?:\.git)?$') { - $repoSlug = $Matches[1] - } else { - Write-Warning "Cannot determine GitHub repo slug. Printing diff to log:" - Get-Content coverage-diff.md - exit 0 + # MaxPackageDecrease < 0 disables BOTH gates (advisory output only). + # See cli/azd/docs/code-coverage-guide.md for the supported way to + # opt out (set the YAML param to -1 in your stage extension). + + pwsh @diffArgs + $diffExit = $LASTEXITCODE + Write-Host "" + Write-Host "Get-CoverageDiff.ps1 exit code: $diffExit" + if ($diffExit -eq 2) { + # Gate breach (per-package decrease and/or absolute-floor): + # Get-CoverageDiff.ps1 already emitted ##vso[task.logissue] + # entries for each breached gate. Just propagate the exit code + # without duplicating the annotation. + exit 2 } - - Write-Host "Posting coverage diff to $repoSlug#$prNumber..." - pwsh -NoProfile -NonInteractive -File "$(Build.SourcesDirectory)/eng/scripts/Update-PRComment.ps1" ` - -Repo $repoSlug ` - -PRNumber $prNumber ` - -BodyFile coverage-diff.md ` - -Tag '' - displayName: Post coverage diff to PR - condition: succeededOrFailed() + if ($diffExit -ne 0) { + Write-Host "##vso[task.logissue type=error]Coverage diff failed with exit code $diffExit. See log above." + exit $diffExit + } + displayName: PR coverage diff (fail on per-package decrease or floor breach) + condition: succeeded() env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) - GH_TOKEN: $(GitHubToken) - - templateContext: - outputs: - - output: pipelineArtifact - path: cover-unit - artifact: cover-unit - displayName: Upload unit test code coverage - - - output: pipelineArtifact - path: cover-int - artifact: cover-int - displayName: Upload integration test code coverage diff --git a/eng/scripts/Get-CoverageDiff.Tests.ps1 b/eng/scripts/Get-CoverageDiff.Tests.ps1 new file mode 100644 index 00000000000..a7b4160e3b2 --- /dev/null +++ b/eng/scripts/Get-CoverageDiff.Tests.ps1 @@ -0,0 +1,857 @@ +Set-StrictMode -Version 4 + +# Pester tests for Get-CoverageDiff.ps1. Each test builds a small synthetic +# Go coverprofile, invokes the script in a child pwsh, and asserts the exit +# code and report contents. Subprocess invocation is required because the +# script uses `exit 2` to signal a gate breach, which would terminate the +# Pester runner if invoked in-process. + +BeforeAll { + $script:scriptPath = Join-Path $PSScriptRoot 'Get-CoverageDiff.ps1' + $script:modPrefix = 'github.com/azure/azure-dev/cli/azd/' + + # Each entry: @{ File='pkg/sample/foo.go'; Stmts=10; Hits=1 }. Hits>0 means covered. + function New-Profile { + param([string]$Path, [object[]]$Entries) + $sb = [System.Text.StringBuilder]::new() + [void]$sb.AppendLine('mode: set') + $line = 1 + foreach ($e in $Entries) { + $f = "$script:modPrefix$($e.File)" + $next = $line + 1 + [void]$sb.AppendLine("${f}:${line}.0,${next}.0 $($e.Stmts) $($e.Hits)") + $line = $next + } + Set-Content -Path $Path -Value $sb.ToString() -Encoding ASCII + } + + function Invoke-Script { + param( + [string]$BaselineFile, + [string]$CurrentFile, + [string[]]$ChangedFiles, + [string]$ChangedFilesFromFile, + [switch]$FailOnGate, + [Nullable[double]]$MaxPackageDecrease = $null, + [Nullable[double]]$MinOverallCoverage = $null + ) + $pwshArgs = @( + '-NoProfile', '-NonInteractive', '-File', $script:scriptPath, + '-BaselineFile', $BaselineFile, + '-CurrentFile', $CurrentFile, + '-ModulePrefix', $script:modPrefix + ) + if ($null -ne $MaxPackageDecrease) { + $pwshArgs += @('-MaxPackageDecrease', $MaxPackageDecrease) + } + if ($null -ne $MinOverallCoverage) { + $pwshArgs += @('-MinOverallCoverage', $MinOverallCoverage) + } + if ($ChangedFiles) { $pwshArgs += @('-ChangedFiles', ($ChangedFiles -join ',')) } + if ($ChangedFilesFromFile) { $pwshArgs += @('-ChangedFilesFromFile', $ChangedFilesFromFile) } + if ($FailOnGate) { $pwshArgs += '-FailOnGate' } + + $stdout = & pwsh @pwshArgs 2>&1 + return @{ ExitCode = $LASTEXITCODE; Output = ($stdout -join "`n") } + } + + function New-TempDir { + $dir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $dir | Out-Null + return $dir + } + + # Invoke the script with a forced thread culture (e.g. 'de-DE' or 'fr-FR'). + # Used to verify F1 number formatting stays invariant ('.' decimal) on + # locales where '{0:F1}' would otherwise emit ',' (the locale-bug guard). + # Writes a wrapper .ps1 that sets the culture and dot-sources the script, + # which avoids fragile -Command quoting across platforms. + function Invoke-ScriptInCulture { + param( + [Parameter(Mandatory)][string]$Culture, + [Parameter(Mandatory)][string]$BaselineFile, + [Parameter(Mandatory)][string]$CurrentFile, + [Nullable[double]]$MaxPackageDecrease = $null, + [Nullable[double]]$MinOverallCoverage = $null, + [switch]$FailOnGate + ) + $wrapperDir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $wrapperDir | Out-Null + $wrapper = Join-Path $wrapperDir 'invoke.ps1' + + $extra = '' + if ($null -ne $MaxPackageDecrease) { $extra += " -MaxPackageDecrease $MaxPackageDecrease" } + if ($null -ne $MinOverallCoverage) { $extra += " -MinOverallCoverage $MinOverallCoverage" } + if ($FailOnGate) { $extra += ' -FailOnGate' } + + $body = @" +[System.Threading.Thread]::CurrentThread.CurrentCulture = [System.Globalization.CultureInfo]::new('$Culture') +[System.Threading.Thread]::CurrentThread.CurrentUICulture = [System.Globalization.CultureInfo]::new('$Culture') +& '$script:scriptPath' -BaselineFile '$BaselineFile' -CurrentFile '$CurrentFile' -ModulePrefix '$script:modPrefix'$extra +exit `$LASTEXITCODE +"@ + Set-Content -Path $wrapper -Value $body -Encoding UTF8 + + $stdout = & pwsh -NoProfile -NonInteractive -File $wrapper 2>&1 + return @{ ExitCode = $LASTEXITCODE; Output = ($stdout -join "`n") } + } +} + +Describe 'Get-CoverageDiff: per-package report scoping' { + BeforeAll { + $script:tmp = New-TempDir + + # Baseline: pkg/a 60%, pkg/b 50%, pkg/c 80% + New-Profile -Path "$script:tmp/base.out" -Entries @( + @{ File = 'pkg/a/x.go'; Stmts = 60; Hits = 1 } + @{ File = 'pkg/a/x.go'; Stmts = 40; Hits = 0 } + @{ File = 'pkg/b/y.go'; Stmts = 50; Hits = 1 } + @{ File = 'pkg/b/y.go'; Stmts = 50; Hits = 0 } + @{ File = 'pkg/c/z.go'; Stmts = 80; Hits = 1 } + @{ File = 'pkg/c/z.go'; Stmts = 20; Hits = 0 } + ) + # Current: pkg/a 70% (improved), pkg/b 50% (unchanged), pkg/c 80% (unchanged) + New-Profile -Path "$script:tmp/curr.out" -Entries @( + @{ File = 'pkg/a/x.go'; Stmts = 70; Hits = 1 } + @{ File = 'pkg/a/x.go'; Stmts = 30; Hits = 0 } + @{ File = 'pkg/b/y.go'; Stmts = 50; Hits = 1 } + @{ File = 'pkg/b/y.go'; Stmts = 50; Hits = 0 } + @{ File = 'pkg/c/z.go'; Stmts = 80; Hits = 1 } + @{ File = 'pkg/c/z.go'; Stmts = 20; Hits = 0 } + ) + } + + It 'shows only touched packages when -ChangedFiles is supplied' { + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/curr.out" ` + -ChangedFiles 'cli/azd/pkg/a/x.go' + + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match 'PR-touched packages \(1 package\):' + $r.Output | Should -Match 'pkg/a' + $r.Output | Should -Not -Match '\bpkg/b\b' + $r.Output | Should -Not -Match '\bpkg/c\b' + } + + It 'reports "PR-touched packages: none" when changed files match no coverage entries' { + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/curr.out" ` + -ChangedFiles 'cli/azd/pkg/nope/missing.go' + + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match 'PR-touched packages: none with coverage data\.' + } + + It 'includes a touched file with no coverage entry when its package has coverage (G4)' { + # pkg/a/constants.go has no coverage entry (constants-only file or + # build-tagged out), but pkg/a is otherwise tracked via x.go. The + # package must still surface in the per-package report so the gate + # can evaluate it. + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/curr.out" ` + -ChangedFiles 'cli/azd/pkg/a/constants.go' + + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match 'PR-touched packages \(1 package\):' + $r.Output | Should -Match 'pkg/a' + } + + It 'falls back to top-N changed packages when no changed files supplied' { + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/curr.out" + + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match 'Top \d+ changed packages' + $r.Output | Should -Match 'pkg/a' + } + + It 'shows "1 file touched" annotation for single-file packages' { + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/curr.out" ` + -ChangedFiles 'cli/azd/pkg/a/x.go' + + $r.Output | Should -Match '1 file touched' + } + + It 'aggregates multiple files in same package as "N files touched"' { + $script:tmp2 = New-TempDir + New-Profile -Path "$script:tmp2/base.out" -Entries @( + @{ File = 'pkg/multi/a.go'; Stmts = 10; Hits = 1 } + @{ File = 'pkg/multi/b.go'; Stmts = 10; Hits = 1 } + @{ File = 'pkg/multi/c.go'; Stmts = 10; Hits = 1 } + ) + New-Profile -Path "$script:tmp2/curr.out" -Entries @( + @{ File = 'pkg/multi/a.go'; Stmts = 10; Hits = 1 } + @{ File = 'pkg/multi/b.go'; Stmts = 10; Hits = 1 } + @{ File = 'pkg/multi/c.go'; Stmts = 10; Hits = 1 } + ) + + $r = Invoke-Script ` + -BaselineFile "$script:tmp2/base.out" ` + -CurrentFile "$script:tmp2/curr.out" ` + -ChangedFiles 'cli/azd/pkg/multi/a.go,cli/azd/pkg/multi/b.go,cli/azd/pkg/multi/c.go' + + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match '3 files touched' + } +} + +Describe 'Get-CoverageDiff: changed-files input handling' { + BeforeAll { + $script:tmp = New-TempDir + + New-Profile -Path "$script:tmp/base.out" -Entries @( + @{ File = 'pkg/a/x.go'; Stmts = 50; Hits = 1 } + @{ File = 'pkg/a/x.go'; Stmts = 50; Hits = 0 } + ) + New-Profile -Path "$script:tmp/curr.out" -Entries @( + @{ File = 'pkg/a/x.go'; Stmts = 50; Hits = 1 } + @{ File = 'pkg/a/x.go'; Stmts = 50; Hits = 0 } + ) + } + + It 'ignores non-Go, _test.go, and .pb.go from changed-file input' { + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/curr.out" ` + -ChangedFiles 'README.md','cli/azd/pkg/a/x_test.go','cli/azd/pkg/a/generated.pb.go' + + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match 'PR-touched packages: none with coverage data\.' + } + + It 'normalizes repo-relative cli/azd/ prefix to module-relative' { + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/curr.out" ` + -ChangedFiles 'cli/azd/pkg/a/x.go' + + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match 'PR-touched packages \(1 package\)' + } + + It 'accepts both -ChangedFiles and -ChangedFilesFromFile and dedupes them' { + $listFile = "$script:tmp/changed.txt" + Set-Content -Path $listFile -Value @( + 'cli/azd/pkg/a/x.go' + 'cli/azd/pkg/a/x.go' # duplicate + ) + + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/curr.out" ` + -ChangedFiles 'cli/azd/pkg/a/x.go' ` + -ChangedFilesFromFile $listFile + + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match 'PR-touched packages \(1 package\)' + } + + It 'reports none when -ChangedFilesFromFile is empty' { + $listFile = "$script:tmp/empty.txt" + Set-Content -Path $listFile -Value '' + + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/curr.out" ` + -ChangedFilesFromFile $listFile + + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match 'PR-touched packages: none with coverage data\.' + } +} + +Describe 'Get-CoverageDiff: profile parsing edge cases' { + BeforeAll { + $script:tmp = New-TempDir + } + + It 'throws when current file is empty' { + Set-Content -Path "$script:tmp/empty.out" -Value '' + New-Profile -Path "$script:tmp/base.out" -Entries @( + @{ File = 'pkg/a/x.go'; Stmts = 10; Hits = 1 } + ) + + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/empty.out" ` + -FailOnGate + + $r.ExitCode | Should -Not -Be 0 + $r.ExitCode | Should -Not -Be 2 + $r.Output | Should -Match 'does not start with a mode line' + } + + It 'throws when profile has mode line but only malformed entries' { + $f = "$script:tmp/malformed.out" + Set-Content -Path $f -Value @('mode: set', 'this-is-not-a-valid-coverline', 'another-bad-line') + New-Profile -Path "$script:tmp/base2.out" -Entries @( + @{ File = 'pkg/a/x.go'; Stmts = 10; Hits = 1 } + ) + + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base2.out" ` + -CurrentFile $f + + $r.ExitCode | Should -Not -Be 0 + $r.Output | Should -Match 'valid coverage entries' + } + + It 'tolerates valid mix of well-formed and malformed lines (warns, does not throw)' { + $f = "$script:tmp/mixed.out" + Set-Content -Path $f -Value @( + 'mode: set' + 'github.com/azure/azure-dev/cli/azd/pkg/a/x.go:1.0,2.0 50 1' + 'github.com/azure/azure-dev/cli/azd/pkg/a/x.go:3.0,4.0 50 0' + 'completely-bogus-line' + ) + New-Profile -Path "$script:tmp/base3.out" -Entries @( + @{ File = 'pkg/a/x.go'; Stmts = 50; Hits = 1 } + @{ File = 'pkg/a/x.go'; Stmts = 50; Hits = 0 } + ) + + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base3.out" ` + -CurrentFile $f ` + -MinOverallCoverage 0 ` + -FailOnGate + + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match 'RESULT: PASS' + } +} + +Describe 'Get-CoverageDiff: absolute floor gate (-MinOverallCoverage)' { + BeforeAll { + $script:tmp = New-TempDir + # Baseline 80% (fits comfortably above any reasonable floor) + New-Profile -Path "$script:tmp/base.out" -Entries @( + @{ File = 'pkg/a/x.go'; Stmts = 80; Hits = 1 } + @{ File = 'pkg/a/x.go'; Stmts = 20; Hits = 0 } + ) + # Current 60% (below default 65 floor; only -20pp from baseline) + New-Profile -Path "$script:tmp/below-floor.out" -Entries @( + @{ File = 'pkg/a/x.go'; Stmts = 60; Hits = 1 } + @{ File = 'pkg/a/x.go'; Stmts = 40; Hits = 0 } + ) + # Add an at-floor profile (exactly 65%) to lock in the boundary contract: + # currTotal == MinOverallCoverage MUST pass (gate uses strict less-than). + New-Profile -Path "$script:tmp/at-floor.out" -Entries @( + @{ File = 'pkg/a/x.go'; Stmts = 65; Hits = 1 } + @{ File = 'pkg/a/x.go'; Stmts = 35; Hits = 0 } + ) + # Current 70% (above default 65 floor) + New-Profile -Path "$script:tmp/above-floor.out" -Entries @( + @{ File = 'pkg/a/x.go'; Stmts = 70; Hits = 1 } + @{ File = 'pkg/a/x.go'; Stmts = 30; Hits = 0 } + ) + } + + It 'FAILs when overall coverage drops below the -MinOverallCoverage floor' { + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/below-floor.out" ` + -MinOverallCoverage 65 ` + -MaxPackageDecrease 100 ` + -FailOnGate + + $r.ExitCode | Should -Be 2 + $r.Output | Should -Match 'RESULT: FAIL' + $r.Output | Should -Match 'Overall coverage 60\.0% is below floor of 65\.0%' + $r.Output | Should -Match '##vso\[task\.logissue type=error\].*below floor' + } + + It 'PASSes when overall coverage stays above the floor' { + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/above-floor.out" ` + -MinOverallCoverage 65 ` + -MaxPackageDecrease 100 ` + -FailOnGate + + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match 'RESULT: PASS' + } + + It 'PASSes when overall coverage exactly equals the -MinOverallCoverage floor' { + # Boundary contract: currTotal >= MinOverallCoverage passes. Use raw + # comparison (strict less-than) so 65.0% at a 65 floor does NOT fail. + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/at-floor.out" ` + -MinOverallCoverage 65 ` + -MaxPackageDecrease 100 ` + -FailOnGate + + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match 'RESULT: PASS' + } + + It 'shows the floor in the report header' { + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/above-floor.out" ` + -MinOverallCoverage 65 ` + -MaxPackageDecrease 100 + + $r.Output | Should -Match 'Floor: overall coverage must stay >= 65\.0%' + } + + It 'is advisory (exit 0) without -FailOnGate even when below floor' { + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/below-floor.out" ` + -MinOverallCoverage 65 ` + -MaxPackageDecrease 100 + + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match 'RESULT: FAIL' + $r.Output | Should -Match 'below floor of 65\.0%' + } +} + +Describe 'Get-CoverageDiff: per-package decrease gate (-MaxPackageDecrease)' { + BeforeAll { + $script:tmp = New-TempDir + # Baseline: pkg/a 80%, pkg/b 80% (overall 80%) + New-Profile -Path "$script:tmp/base.out" -Entries @( + @{ File = 'pkg/a/x.go'; Stmts = 80; Hits = 1 } + @{ File = 'pkg/a/x.go'; Stmts = 20; Hits = 0 } + @{ File = 'pkg/b/y.go'; Stmts = 80; Hits = 1 } + @{ File = 'pkg/b/y.go'; Stmts = 20; Hits = 0 } + ) + # Current: pkg/a 78% (-2pp), pkg/b 80% (overall 79%, -1pp) + New-Profile -Path "$script:tmp/pkg-regress.out" -Entries @( + @{ File = 'pkg/a/x.go'; Stmts = 78; Hits = 1 } + @{ File = 'pkg/a/x.go'; Stmts = 22; Hits = 0 } + @{ File = 'pkg/b/y.go'; Stmts = 80; Hits = 1 } + @{ File = 'pkg/b/y.go'; Stmts = 20; Hits = 0 } + ) + # Current: pkg/a 79.5% (-0.5pp, exactly at boundary), pkg/b 80% + New-Profile -Path "$script:tmp/pkg-tiny.out" -Entries @( + @{ File = 'pkg/a/x.go'; Stmts = 159; Hits = 1 } + @{ File = 'pkg/a/x.go'; Stmts = 41; Hits = 0 } + @{ File = 'pkg/b/y.go'; Stmts = 80; Hits = 1 } + @{ File = 'pkg/b/y.go'; Stmts = 20; Hits = 0 } + ) + } + + It 'FAILs when any package drops more than -MaxPackageDecrease' { + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/pkg-regress.out" ` + -MaxPackageDecrease 0.5 ` + -MinOverallCoverage 0 ` + -FailOnGate + + $r.ExitCode | Should -Be 2 + $r.Output | Should -Match 'RESULT: FAIL' + $r.Output | Should -Match '1 package\(s\) dropped more than 0\.5 pp' + $r.Output | Should -Match 'pkg/a: 80\.0% -> 78\.0% \(-2\.0 pp\)' + $r.Output | Should -Match '##vso\[task\.logissue type=error\].*Package pkg/a dropped 2\.0 pp' + } + + It 'PASSes when no package drops beyond tolerance' { + # Baseline → baseline = no change at all. + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/base.out" ` + -MaxPackageDecrease 0.5 ` + -MinOverallCoverage 0 ` + -FailOnGate + + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match 'RESULT: PASS' + } + + It 'PASSes when a package decrease is exactly at the tolerance boundary' { + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/pkg-tiny.out" ` + -MaxPackageDecrease 0.5 ` + -MinOverallCoverage 0 ` + -FailOnGate + + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match 'RESULT: PASS' + } + + It 'with custom -MaxPackageDecrease 5.0 tolerates a 2.0pp package drop' { + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/pkg-regress.out" ` + -MaxPackageDecrease 5.0 ` + -MinOverallCoverage 0 ` + -FailOnGate + + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match 'RESULT: PASS' + $r.Output | Should -Match 'Tolerance: -5\.0 pp per package' + } + + It 'in changed-file mode, only checks PR-touched packages' { + # pkg/a regresses but is NOT in changed files → should NOT trigger gate. + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/pkg-regress.out" ` + -ChangedFiles 'cli/azd/pkg/b/y.go' ` + -MaxPackageDecrease 0.5 ` + -MinOverallCoverage 0 ` + -FailOnGate + + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match 'RESULT: PASS' + } + + It 'in changed-file mode, FAILs when a touched package regresses' { + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/pkg-regress.out" ` + -ChangedFiles 'cli/azd/pkg/a/x.go' ` + -MaxPackageDecrease 0.5 ` + -MinOverallCoverage 0 ` + -FailOnGate + + $r.ExitCode | Should -Be 2 + $r.Output | Should -Match 'Package pkg/a dropped 2\.0 pp' + } +} + +Describe 'Get-CoverageDiff: combined multi-gate behavior' { + BeforeAll { + $script:tmp = New-TempDir + # Baseline 80% + New-Profile -Path "$script:tmp/base.out" -Entries @( + @{ File = 'pkg/a/x.go'; Stmts = 80; Hits = 1 } + @{ File = 'pkg/a/x.go'; Stmts = 20; Hits = 0 } + @{ File = 'pkg/b/y.go'; Stmts = 80; Hits = 1 } + @{ File = 'pkg/b/y.go'; Stmts = 20; Hits = 0 } + ) + # Current trips ALL THREE gates: overall 50% (below 65 floor + drop 30pp), + # pkg/a -50pp, pkg/b -10pp. + New-Profile -Path "$script:tmp/all-bad.out" -Entries @( + @{ File = 'pkg/a/x.go'; Stmts = 30; Hits = 1 } + @{ File = 'pkg/a/x.go'; Stmts = 70; Hits = 0 } + @{ File = 'pkg/b/y.go'; Stmts = 70; Hits = 1 } + @{ File = 'pkg/b/y.go'; Stmts = 30; Hits = 0 } + ) + } + + It 'reports both breached gates in a single FAIL block' { + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/all-bad.out" ` + -MaxPackageDecrease 0.5 ` + -MinOverallCoverage 65 ` + -FailOnGate + + $r.ExitCode | Should -Be 2 + $r.Output | Should -Match 'RESULT: FAIL' + $r.Output | Should -Match 'Overall coverage 50\.0% is below floor of 65\.0%' + $r.Output | Should -Match '2 package\(s\) dropped more than 0\.5 pp' + } +} + +Describe 'Get-CoverageDiff: locale-invariant number formatting' { + BeforeAll { + $script:tmp = New-TempDir + # Baseline 80%, current 50% — guarantees overall floor breach + per-pkg + # decrease, so both gate-breach annotations format numbers. + New-Profile -Path "$script:tmp/base.out" -Entries @( + @{ File = 'pkg/a/x.go'; Stmts = 80; Hits = 1 } + @{ File = 'pkg/a/x.go'; Stmts = 20; Hits = 0 } + ) + New-Profile -Path "$script:tmp/curr.out" -Entries @( + @{ File = 'pkg/a/x.go'; Stmts = 50; Hits = 1 } + @{ File = 'pkg/a/x.go'; Stmts = 50; Hits = 0 } + ) + } + + # de-DE / fr-FR use ',' as the decimal separator. If the script formatted + # numbers with '{0:F1}' (which honors CurrentCulture), the report would + # contain '50,0%' on these machines — breaking the regex assertions used + # by tests AND breaking downstream tools that parse the output. + foreach ($culture in @('de-DE', 'fr-FR')) { + It "uses '.' decimal separator on $culture (no comma decimals leak through)" -TestCases @(@{ Culture = $culture }) { + param($Culture) + $r = Invoke-ScriptInCulture ` + -Culture $Culture ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/curr.out" ` + -MaxPackageDecrease 0.5 ` + -MinOverallCoverage 65 ` + -FailOnGate + + $r.ExitCode | Should -Be 2 + # All percentage values must use '.' decimal — never ','. + $r.Output | Should -Match 'Overall: 80\.0% -> 50\.0%' + $r.Output | Should -Match 'Overall coverage 50\.0% is below floor of 65\.0%' + $r.Output | Should -Match 'Package pkg/a dropped 30\.0 pp' + # Specifically NOT a German-formatted decimal anywhere in numeric output. + $r.Output | Should -Not -Match '\d+,\d+%' + } + } +} + +Describe 'Get-CoverageDiff: per-package gate boundary (raw delta)' { + BeforeAll { + $script:tmp = New-TempDir + # Baseline pkg/a: 80% (200 stmts, 160 covered). + New-Profile -Path "$script:tmp/base.out" -Entries @( + @{ File = 'pkg/a/x.go'; Stmts = 160; Hits = 1 } + @{ File = 'pkg/a/x.go'; Stmts = 40; Hits = 0 } + ) + # Current pkg/a: 79.49% (200 stmts, 158.98 ~ 159 covered) → -0.51 pp. + # 159/200 = 0.795 → 79.5% rounded; we need raw 79.49 to exercise the + # raw-delta-no-rounding code path. Use 1000 stmts: 794/1000 = 79.4% + # → still 0.6 pp drop. Easier: 158/200 = 79.0% (1.0 pp drop). Use 1000: + # baseline 800/1000=80, current 794/1000=79.4 → -0.6 pp. + New-Profile -Path "$script:tmp/curr-051.out" -Entries @( + @{ File = 'pkg/a/x.go'; Stmts = 794; Hits = 1 } + @{ File = 'pkg/a/x.go'; Stmts = 206; Hits = 0 } + ) + # Baseline 800/1000 = 80% + New-Profile -Path "$script:tmp/base-1000.out" -Entries @( + @{ File = 'pkg/a/x.go'; Stmts = 800; Hits = 1 } + @{ File = 'pkg/a/x.go'; Stmts = 200; Hits = 0 } + ) + } + + It 'FAILs when package drops 0.6pp against 0.5pp tolerance (raw delta, no rounding)' { + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base-1000.out" ` + -CurrentFile "$script:tmp/curr-051.out" ` + -MaxPackageDecrease 0.5 ` + -MinOverallCoverage 0 ` + -FailOnGate + + $r.ExitCode | Should -Be 2 + $r.Output | Should -Match 'RESULT: FAIL' + # 80.0 -> 79.4 = -0.6 pp drop, must exceed 0.5 tolerance. + $r.Output | Should -Match 'Package pkg/a dropped 0\.6 pp' + } +} + +Describe 'Get-CoverageDiff: -1 sentinel disables both gates' { + BeforeAll { + $script:tmp = New-TempDir + # Catastrophic regression: 80% -> 10% (would fail ALL gates). + New-Profile -Path "$script:tmp/base.out" -Entries @( + @{ File = 'pkg/a/x.go'; Stmts = 80; Hits = 1 } + @{ File = 'pkg/a/x.go'; Stmts = 20; Hits = 0 } + ) + New-Profile -Path "$script:tmp/curr.out" -Entries @( + @{ File = 'pkg/a/x.go'; Stmts = 10; Hits = 1 } + @{ File = 'pkg/a/x.go'; Stmts = 90; Hits = 0 } + ) + } + + It 'PASSes when -MaxPackageDecrease -1 disables the per-package gate even with -FailOnGate' { + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/curr.out" ` + -MaxPackageDecrease -1 ` + -MinOverallCoverage 0 ` + -FailOnGate + + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match 'RESULT: PASS' + } + + It 'PASSes when -MinOverallCoverage -1 disables the floor gate even with -FailOnGate' { + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/curr.out" ` + -MaxPackageDecrease 100 ` + -MinOverallCoverage -1 ` + -FailOnGate + + $r.ExitCode | Should -Be 0 + $r.Output | Should -Match 'RESULT: PASS' + } + + It 'rejects values like -0.5 with a clear error (only -1 is the disable sentinel)' { + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/curr.out" ` + -MaxPackageDecrease -0.5 ` + -MinOverallCoverage 0 + # ValidateScript rejects -0.5 → script exits non-zero with a + # parameter-validation error from PowerShell. + $r.ExitCode | Should -Not -Be 0 + } +} + +Describe 'Get-CoverageDiff: deletion-only PR (AMRD scope)' { + BeforeAll { + $script:tmp = New-TempDir + # Baseline: pkg/a has two files (one heavily covered, one lightly). + New-Profile -Path "$script:tmp/base.out" -Entries @( + @{ File = 'pkg/a/keep.go'; Stmts = 50; Hits = 1 } + @{ File = 'pkg/a/keep.go'; Stmts = 50; Hits = 0 } + @{ File = 'pkg/a/delete.go'; Stmts = 100; Hits = 1 } + ) + # Current: delete.go removed → pkg/a now only has keep.go (50% cov). + # Package coverage drops from 100/200=50%? Wait, baseline: + # 50 covered + 100 covered = 150 / 200 = 75% + # Current: 50 covered / 100 = 50% → -25pp drop. + New-Profile -Path "$script:tmp/curr.out" -Entries @( + @{ File = 'pkg/a/keep.go'; Stmts = 50; Hits = 1 } + @{ File = 'pkg/a/keep.go'; Stmts = 50; Hits = 0 } + ) + } + + # The YAML/magefile use --diff-filter=AMRD so deletion-only PRs still + # produce a changed-files list. The script's job is to scope the + # per-package gate to the package containing the deleted file. This + # asserts the script uses the inferred package even though the file + # itself is absent from current profile. + It 'still triggers per-package gate when a deleted file regresses its package coverage' { + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/curr.out" ` + -ChangedFiles 'cli/azd/pkg/a/delete.go' ` + -MaxPackageDecrease 0.5 ` + -MinOverallCoverage 0 ` + -FailOnGate + + $r.ExitCode | Should -Be 2 + $r.Output | Should -Match 'PR-touched packages \(1 package\)' + $r.Output | Should -Match 'Package pkg/a dropped 25\.0 pp' + } +} + +Describe 'Get-CoverageDiff: TopN and MinDelta in package mode' { + BeforeAll { + $script:tmp = New-TempDir + # 5 packages with varying deltas. Use 100-stmt buckets so percentages + # are easy to reason about. + New-Profile -Path "$script:tmp/base.out" -Entries @( + @{ File = 'pkg/big/a.go'; Stmts = 80; Hits = 1 }; @{ File = 'pkg/big/a.go'; Stmts = 20; Hits = 0 } + @{ File = 'pkg/medium/b.go'; Stmts = 60; Hits = 1 }; @{ File = 'pkg/medium/b.go'; Stmts = 40; Hits = 0 } + @{ File = 'pkg/small/c.go'; Stmts = 50; Hits = 1 }; @{ File = 'pkg/small/c.go'; Stmts = 50; Hits = 0 } + @{ File = 'pkg/tiny/d.go'; Stmts = 70; Hits = 1 }; @{ File = 'pkg/tiny/d.go'; Stmts = 30; Hits = 0 } + @{ File = 'pkg/none/e.go'; Stmts = 90; Hits = 1 }; @{ File = 'pkg/none/e.go'; Stmts = 10; Hits = 0 } + ) + # Current produces these deltas: + # pkg/big: 80 -> 30 = -50pp + # pkg/medium: 60 -> 50 = -10pp + # pkg/small: 50 -> 49 = -1pp + # pkg/tiny: 70 -> 69.95 = -0.05pp (below default MinDelta=0.1) + # pkg/none: 90 -> 90 (no change) + New-Profile -Path "$script:tmp/curr.out" -Entries @( + @{ File = 'pkg/big/a.go'; Stmts = 30; Hits = 1 }; @{ File = 'pkg/big/a.go'; Stmts = 70; Hits = 0 } + @{ File = 'pkg/medium/b.go'; Stmts = 50; Hits = 1 }; @{ File = 'pkg/medium/b.go'; Stmts = 50; Hits = 0 } + @{ File = 'pkg/small/c.go'; Stmts = 49; Hits = 1 }; @{ File = 'pkg/small/c.go'; Stmts = 51; Hits = 0 } + @{ File = 'pkg/tiny/d.go'; Stmts = 1399; Hits = 1 }; @{ File = 'pkg/tiny/d.go'; Stmts = 601; Hits = 0 } + @{ File = 'pkg/none/e.go'; Stmts = 90; Hits = 1 }; @{ File = 'pkg/none/e.go'; Stmts = 10; Hits = 0 } + ) + } + + It '-TopN limits the number of packages shown in the report' { + # Disable gates (-1 sentinel) — this test asserts display behavior only. + # Without disabling, pkg/small (-1pp) appears in the gate-breach listing + # regardless of -TopN, which limits the changed-packages *table* only. + $r = & pwsh -NoProfile -NonInteractive -File $script:scriptPath ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/curr.out" ` + -ModulePrefix $script:modPrefix ` + -MaxPackageDecrease -1 ` + -MinOverallCoverage -1 ` + -TopN 2 2>&1 + $output = ($r -join "`n") + + $output | Should -Match 'Top 2 changed packages' + # Top 2 by absolute delta = pkg/big (-50pp) and pkg/medium (-10pp). + $output | Should -Match '\bpkg/big\b' + $output | Should -Match '\bpkg/medium\b' + # pkg/small (-1pp) is in the changed list but past TopN=2 → not shown. + $output | Should -Not -Match '\bpkg/small\b' + $output | Should -Match '\.\.\. and \d+ more packages' + } + + It '-MinDelta filters packages with sub-threshold changes' { + $r = & pwsh -NoProfile -NonInteractive -File $script:scriptPath ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/curr.out" ` + -ModulePrefix $script:modPrefix ` + -MaxPackageDecrease -1 ` + -MinOverallCoverage -1 ` + -MinDelta 5 2>&1 + $output = ($r -join "`n") + + # Only pkg/big (50pp) and pkg/medium (10pp) exceed 5pp. pkg/small (1pp) + # falls below the threshold and must NOT appear in the changed list. + $output | Should -Match '\bpkg/big\b' + $output | Should -Match '\bpkg/medium\b' + $output | Should -Not -Match '\bpkg/small\b' + $output | Should -Not -Match '\bpkg/tiny\b' + } +} + +Describe 'Get-CoverageDiff: input validation' { + BeforeAll { + $script:tmp = New-TempDir + New-Profile -Path "$script:tmp/base.out" -Entries @( + @{ File = 'pkg/a/x.go'; Stmts = 50; Hits = 1 } + @{ File = 'pkg/a/x.go'; Stmts = 50; Hits = 0 } + ) + } + + It 'throws a clear error when -BaselineFile does not exist' { + $r = Invoke-Script ` + -BaselineFile "$script:tmp/does-not-exist.out" ` + -CurrentFile "$script:tmp/base.out" + + $r.ExitCode | Should -Not -Be 0 + $r.Output | Should -Match 'Baseline coverage file not found' + } + + It 'throws a clear error when -CurrentFile does not exist' { + $r = Invoke-Script ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/does-not-exist.out" + + $r.ExitCode | Should -Not -Be 0 + $r.Output | Should -Match 'Current coverage file not found' + } +} + +Describe 'Get-CoverageDiff: -ModulePrefix override' { + BeforeAll { + $script:tmp = New-TempDir + # Build profiles with a non-default module prefix. + $custom = 'github.com/example/my-module/' + $sb = [System.Text.StringBuilder]::new() + [void]$sb.AppendLine('mode: set') + [void]$sb.AppendLine("${custom}internal/foo.go:1.0,2.0 50 1") + [void]$sb.AppendLine("${custom}internal/foo.go:2.0,3.0 50 0") + Set-Content -Path "$script:tmp/base.out" -Value $sb.ToString() -Encoding ASCII + + $sb2 = [System.Text.StringBuilder]::new() + [void]$sb2.AppendLine('mode: set') + [void]$sb2.AppendLine("${custom}internal/foo.go:1.0,2.0 70 1") + [void]$sb2.AppendLine("${custom}internal/foo.go:2.0,3.0 30 0") + Set-Content -Path "$script:tmp/curr.out" -Value $sb2.ToString() -Encoding ASCII + + $script:customPrefix = $custom + } + + It 'strips a custom -ModulePrefix so package names appear module-relative' { + $r = & pwsh -NoProfile -NonInteractive -File $script:scriptPath ` + -BaselineFile "$script:tmp/base.out" ` + -CurrentFile "$script:tmp/curr.out" ` + -ModulePrefix $script:customPrefix 2>&1 + $output = ($r -join "`n") + + # With prefix stripped, the package shows as 'internal' (not the + # full 'github.com/example/my-module/internal'). + $output | Should -Match '\binternal\b' + $output | Should -Not -Match 'github\.com/example/my-module/internal' + } +} diff --git a/eng/scripts/Get-CoverageDiff.ps1 b/eng/scripts/Get-CoverageDiff.ps1 index e1a0d3bef00..d3348f3b9b3 100644 --- a/eng/scripts/Get-CoverageDiff.ps1 +++ b/eng/scripts/Get-CoverageDiff.ps1 @@ -5,20 +5,52 @@ <# .SYNOPSIS - Generates a markdown coverage diff between two Go coverage profiles. + Computes a Go coverage diff between two coverage profiles and emits a + plain-text report intended for CI build logs. .DESCRIPTION - Compares a baseline coverage profile (typically from main) against a - current branch profile and produces a markdown table showing per-package - coverage changes, overall delta, and impact summary. - - The output includes a HTML comment tag so that - Update-PRComment.ps1 can replace previous diff comments on re-runs. - - Coverage is computed by parsing Go text coverage profiles directly: - each line represents a code block with statement count and hit count. - Statements are aggregated per package (directory) and coverage is - calculated as covered_statements / total_statements * 100. + Compares a baseline Go coverage profile (typically from a recent main + build) against the current branch's profile and writes a plain-text + report. The report is designed to be read directly in pipeline logs; + no Markdown or HTML is emitted and no PR comment is posted. + + Two gates run when -FailOnGate is set: + 1. Per-package decrease beyond -MaxPackageDecrease percentage + points (scoped to PR-touched packages in changed-file mode, + all packages otherwise). + 2. Absolute floor: overall coverage must stay at or above + -MinOverallCoverage. + Either breach exits the script with code 2 so the CI stage fails + and merge is blocked. The gate intentionally does NOT enforce a + per-file floor or an overall-decrease tolerance — small, isolated + drops in untouched packages are ignored, and a PR that only + rebalances coverage between packages is fine. + + Two reporting modes are supported: + + * Changed-file mode (recommended for CI) + Provide -ChangedFiles or -ChangedFilesFromFile. The script reports + per-package deltas only for packages that contain a file touched + by the PR (Go files, *_test.go and *.pb.go excluded). The + per-package gate is scoped to those PR-touched packages. + + * Package mode (fallback / local exploration) + Without -ChangedFiles, the script lists the most-changed packages + across the whole module and the per-package gate considers every + package. + + In both modes, the absolute floor gate runs identically. + + Status values used in package output: + improved — package coverage went up + regress — package coverage went down + new — package has no baseline entry + ok — package coverage unchanged (or below MinDelta) + + Exit codes: + 0 — success / no breach + 2 — at least one gate breached and -FailOnGate was set + (any other non-zero exit indicates a script error) See cli/azd/docs/code-coverage-guide.md for context on coverage modes. @@ -28,32 +60,66 @@ .PARAMETER CurrentFile Path to the current branch's coverage profile. -.PARAMETER OutputFile - Write markdown to this file. If omitted, writes to stdout. +.PARAMETER ChangedFiles + Array of file paths touched by the PR (newline- or comma-delimited + when passed as a single string). Paths may be repo-relative + (e.g. "cli/azd/pkg/foo/bar.go") or module-relative + (e.g. "pkg/foo/bar.go"); the script tries both. Non-Go files, + test files (*_test.go) and generated *.pb.go files are ignored. + +.PARAMETER ChangedFilesFromFile + Path to a plain newline-delimited file listing changed files. + Equivalent to -ChangedFiles, but sourced from disk so callers + (e.g. CI pipelines emitting a long file list from `gh api`) can + avoid command-line length limits. Mutually compatible with + -ChangedFiles — both may be supplied and the union is used. + +.PARAMETER MaxPackageDecrease + Maximum tolerated per-package coverage decrease in percentage points. + For example, with the default 0.5, a package at 80.0% may drop to + 79.5% without failing; 79.4% (-0.6 pp) trips the gate. In + changed-file mode this only considers PR-touched packages. + Default: 0.5. + +.PARAMETER MinOverallCoverage + Absolute floor (in percent) for overall coverage. CI fails if the + current overall is below this value. Default: 69.0. + +.PARAMETER FailOnGate + Exit with code 2 if any gate (per-package decrease or absolute + floor) is breached. CI sets this explicitly so a regression blocks + merge; local runs leave it off by default for advisory output. + +.PARAMETER BaselineLabel + Free-form label describing the baseline (e.g. "main build 123456"). + Printed in the report header. Default: "baseline". .PARAMETER TopN - Maximum number of changed packages to show in the diff table. - Packages are sorted by absolute delta descending. Default: 20. + In package mode, maximum number of changed packages to display. + Default: 20. .PARAMETER MinDelta - Minimum absolute percentage-point change to include a package in - the diff table. Default: 0.1. + In package mode, minimum absolute percentage-point change to include + a package in the table. Default: 0.1. .PARAMETER ModulePrefix - Go module import path prefix to strip from package names for - readability. Auto-detected from go.mod when possible. - -.EXAMPLE - # Compare two profiles and print to terminal - ./Get-CoverageDiff.ps1 -BaselineFile cover-main.out -CurrentFile cover-local.out + Go module import path prefix to strip from package and file names + for readability. Auto-detected from go.mod when possible. .EXAMPLE - # Write diff to a file for PR comment - ./Get-CoverageDiff.ps1 -BaselineFile cover-main.out -CurrentFile cover-local.out -OutputFile diff.md + # Local: package-level diff to terminal (advisory, no gate) + ./Get-CoverageDiff.ps1 -BaselineFile cover-main.out -CurrentFile cover.out .EXAMPLE - # Show top 10 changes with at least 1pp delta - ./Get-CoverageDiff.ps1 -BaselineFile cover-main.out -CurrentFile cover-local.out -TopN 10 -MinDelta 1.0 + # CI: per-package report scoped to changed files, fail on per-package regression or floor + ./Get-CoverageDiff.ps1 ` + -BaselineFile cover-baseline.out ` + -CurrentFile cover.out ` + -ChangedFilesFromFile changed-files.txt ` + -BaselineLabel "main build 123456 / commit abcdef" ` + -MaxPackageDecrease 0.5 ` + -MinOverallCoverage 69 ` + -FailOnGate #> param( @@ -63,24 +129,41 @@ param( [Parameter(Mandatory = $true)] [string]$CurrentFile, - [string]$OutputFile, + [string[]]$ChangedFiles, + + [string]$ChangedFilesFromFile, + + [ValidateScript({ $_ -ge 0 -and $_ -le 100 -or $_ -eq -1 }, ErrorMessage = + '-MaxPackageDecrease must be between 0 and 100, or -1 to disable the gate.')] + [double]$MaxPackageDecrease = 0.5, + + [ValidateScript({ $_ -ge 0 -and $_ -le 100 -or $_ -eq -1 }, ErrorMessage = + '-MinOverallCoverage must be between 0 and 100, or -1 to disable the floor gate.')] + [double]$MinOverallCoverage = 69.0, + + [switch]$FailOnGate, + [string]$BaselineLabel = 'baseline', + + [ValidateRange(1, [int]::MaxValue)] [int]$TopN = 20, + [ValidateRange(0, 100)] [double]$MinDelta = 0.1, [string]$ModulePrefix = '' ) $ErrorActionPreference = 'Stop' +Set-StrictMode -Version 4 # --------------------------------------------------------------------------- # Validate inputs # --------------------------------------------------------------------------- -if (-not (Test-Path $BaselineFile)) { +if (-not (Test-Path -LiteralPath $BaselineFile)) { throw "Baseline coverage file not found: $BaselineFile" } -if (-not (Test-Path $CurrentFile)) { +if (-not (Test-Path -LiteralPath $CurrentFile)) { throw "Current coverage file not found: $CurrentFile" } @@ -93,37 +176,56 @@ if (-not $ModulePrefix) { (Join-Path $PSScriptRoot '../../cli/azd/go.mod') ) foreach ($candidate in $goModCandidates) { - if (Test-Path $candidate) { - $modLine = Get-Content $candidate | + if (Test-Path -LiteralPath $candidate) { + $modLine = @(Get-Content -LiteralPath $candidate -Encoding UTF8) | Where-Object { $_ -match '^module\s+' } | Select-Object -First 1 if ($modLine -match '^module\s+(.+)$') { - $ModulePrefix = $Matches[1].Trim() + '/' + $ModulePrefix = $Matches[1].Trim() break } } } if (-not $ModulePrefix) { - $ModulePrefix = 'github.com/azure/azure-dev/cli/azd/' + $ModulePrefix = 'github.com/azure/azure-dev/cli/azd' } } +# Normalize module prefix: forward slashes, single trailing slash. Applies to +# both auto-detected and user-supplied values so downstream StartsWith() +# comparisons strip cleanly without producing leading-slash module-relative +# keys. +$ModulePrefix = $ModulePrefix.Trim().Replace('\', '/') +if ($ModulePrefix -and -not $ModulePrefix.EndsWith('/')) { + $ModulePrefix += '/' +} + +# Repo-relative prefix for the Go module. When the user passes repo-relative +# paths (as `gh api .../files` returns), we strip this to recover the +# module-relative path used as the file key. +$repoRelativeModulePrefix = 'cli/azd/' + +$inv = [System.Globalization.CultureInfo]::InvariantCulture + # --------------------------------------------------------------------------- -# Parse a Go text coverage profile into per-package stats. +# Parse a Go text coverage profile. # # Each non-header line has the format: # file:startLine.startCol,endLine.endCol numStatements hitCount # # Returns a hashtable with: -# Packages — hashtable: packageName -> @{ Statements; Covered } -# TotalStatements — int: sum of all statement counts -# TotalCovered — int: sum of statements in blocks with hitCount > 0 +# Packages — packageName -> @{ Statements; Covered } +# Files — moduleRelativeFilePath -> @{ Statements; Covered } +# TotalStatements — int +# TotalCovered — int # --------------------------------------------------------------------------- function Read-CoverageProfile { param([string]$FilePath) - $lines = Get-Content $FilePath - if ($lines.Count -eq 0) { + # ReadAllLines is ~2-5x faster than Get-Content for large profiles + # (45k+ lines) because it bypasses PowerShell's per-line pipeline overhead. + $lines = [System.IO.File]::ReadAllLines($FilePath, [System.Text.Encoding]::UTF8) + if ($lines.Length -eq 0) { throw "Coverage file is empty: $FilePath" } @@ -132,15 +234,15 @@ function Read-CoverageProfile { } $packages = @{} + $files = @{} $totalStatements = 0 $totalCovered = 0 $skippedLines = 0 - for ($i = 1; $i -lt $lines.Count; $i++) { + for ($i = 1; $i -lt $lines.Length; $i++) { $line = $lines[$i] if ([string]::IsNullOrWhiteSpace($line)) { continue } - # Match: filepath:startLine.startCol,endLine.endCol numStatements hitCount if ($line -notmatch '^(.+?):(\d+\.\d+),(\d+\.\d+)\s+(\d+)\s+(\d+)$') { $skippedLines++ continue @@ -150,46 +252,107 @@ function Read-CoverageProfile { $stmts = [int]$Matches[4] $hits = [int]$Matches[5] - # Derive package name by stripping module prefix and filename - $pkg = '' - if ($filePath.StartsWith($ModulePrefix)) { - $relPath = $filePath.Substring($ModulePrefix.Length) - $lastSlash = $relPath.LastIndexOf('/') - $pkg = if ($lastSlash -ge 0) { $relPath.Substring(0, $lastSlash) } else { '.' } + # Module-relative path: strip module prefix when present (case-insensitive + # — go.mod casing may differ from the casing in user-supplied paths). + $rel = if ($filePath.StartsWith($ModulePrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + $filePath.Substring($ModulePrefix.Length) } else { - $lastSlash = $filePath.LastIndexOf('/') - $pkg = if ($lastSlash -ge 0) { $filePath.Substring(0, $lastSlash) } else { $filePath } + $filePath } + $lastSlash = $rel.LastIndexOf('/') + $pkg = if ($lastSlash -ge 0) { $rel.Substring(0, $lastSlash) } else { '.' } + if (-not $packages.ContainsKey($pkg)) { $packages[$pkg] = @{ Statements = 0; Covered = 0 } } - $packages[$pkg].Statements += $stmts - if ($hits -gt 0) { - $packages[$pkg].Covered += $stmts + if ($hits -gt 0) { $packages[$pkg].Covered += $stmts } + + if (-not $files.ContainsKey($rel)) { + $files[$rel] = @{ Statements = 0; Covered = 0 } } + $files[$rel].Statements += $stmts + if ($hits -gt 0) { $files[$rel].Covered += $stmts } $totalStatements += $stmts - if ($hits -gt 0) { - $totalCovered += $stmts - } + if ($hits -gt 0) { $totalCovered += $stmts } } - if ($skippedLines -gt 0) { Write-Warning "${FilePath}: skipped $skippedLines line(s) that did not match the expected coverprofile format." } + # Defend against silent passes: a profile with a valid mode line but every + # entry malformed would otherwise produce zero statements and slip through + # the gate as "no coverage data". + if ($totalStatements -eq 0 -and $skippedLines -gt 0) { + throw "Coverage file '$FilePath' contained no valid coverage entries (skipped $skippedLines malformed line(s))." + } + return @{ Packages = $packages + Files = $files TotalStatements = $totalStatements TotalCovered = $totalCovered } } # --------------------------------------------------------------------------- -# Parse both profiles +# Normalize a changed-file path to module-relative form. +# Returns $null for non-Go files, test files, and generated *.pb.go files. +# --------------------------------------------------------------------------- +function ConvertTo-ModuleRelative { + param([string]$Path) + + if ([string]::IsNullOrWhiteSpace($Path)) { return $null } + $p = $Path.Trim().Replace('\', '/') + if (-not $p.EndsWith('.go', [System.StringComparison]::OrdinalIgnoreCase)) { return $null } + if ($p.EndsWith('_test.go', [System.StringComparison]::OrdinalIgnoreCase)) { return $null } + if ($p.EndsWith('.pb.go', [System.StringComparison]::OrdinalIgnoreCase)) { return $null } + + if ($p.StartsWith($repoRelativeModulePrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + return $p.Substring($repoRelativeModulePrefix.Length) + } + return $p +} + +# --------------------------------------------------------------------------- +# Compute coverage percentage as a raw double. Display sites format with +# {0:F1} for one-decimal output; gate comparisons use the raw value to +# avoid threshold false negatives at boundaries (e.g. 68.96% rounding up +# to 69.0% and passing a 69 floor, or a 0.54 pp drop rounding to 0.5 pp +# and passing a 0.5 tolerance). +# --------------------------------------------------------------------------- +function Get-Percent { + param([int]$Covered, [int]$Statements) + if ($Statements -le 0) { return 0.0 } + return ($Covered / $Statements) * 100 +} + +# --------------------------------------------------------------------------- +# Format a fixed-width status table row. +# --------------------------------------------------------------------------- +function Format-FileRow { + param( + [string]$Path, + [double]$Before, + [double]$After, + [double]$Delta, + [string]$Status, + [string]$Note + ) + $sign = if ($Delta -ge 0) { '+' } else { '' } + $beforeStr = if ($Before -lt 0) { ' -' } else { ('{0,5}%' -f $Before.ToString('F1', $script:inv)) } + $afterStr = ('{0,5}%' -f $After.ToString('F1', $script:inv)) + $deltaStr = ('{0}{1} pp' -f $sign, $Delta.ToString('F1', $script:inv)) + $line = (' {0,-60} {1} -> {2} ({3,9}) {4,-8}' -f $Path, $beforeStr, $afterStr, $deltaStr, $Status) + if ($Note) { $line += " $Note" } + return $line +} + +# --------------------------------------------------------------------------- +# Parse profiles # --------------------------------------------------------------------------- Write-Host "Parsing baseline: $BaselineFile" $baseline = Read-CoverageProfile -FilePath $BaselineFile @@ -197,156 +360,330 @@ $baseline = Read-CoverageProfile -FilePath $BaselineFile Write-Host "Parsing current: $CurrentFile" $current = Read-CoverageProfile -FilePath $CurrentFile +$baseTotal = Get-Percent $baseline.TotalCovered $baseline.TotalStatements +$currTotal = Get-Percent $current.TotalCovered $current.TotalStatements +$overallDelta = [math]::Round($currTotal - $baseTotal, 1) + +Write-Host (" Baseline: {0}% ({1}/{2} stmts)" -f $baseTotal.ToString('F1', $inv), $baseline.TotalCovered, $baseline.TotalStatements) +Write-Host (" Current: {0}% ({1}/{2} stmts)" -f $currTotal.ToString('F1', $inv), $current.TotalCovered, $current.TotalStatements) +Write-Host (" Delta: {0} pp" -f $overallDelta.ToString('F1', $inv)) + # --------------------------------------------------------------------------- -# Compute overall totals +# Collect changed files (union of -ChangedFiles and -ChangedFilesFromFile) # --------------------------------------------------------------------------- -$inv = [System.Globalization.CultureInfo]::InvariantCulture +$changedRaw = @() +if ($ChangedFiles) { + foreach ($entry in $ChangedFiles) { + if ($null -eq $entry) { continue } + $changedRaw += ($entry -split "[`r`n,]") + } +} +if ($ChangedFilesFromFile) { + if (-not (Test-Path -LiteralPath $ChangedFilesFromFile)) { + throw "Changed-files file not found: $ChangedFilesFromFile" + } + $changedRaw += @([System.IO.File]::ReadAllLines($ChangedFilesFromFile, [System.Text.Encoding]::UTF8)) +} -$baseTotal = if ($baseline.TotalStatements -gt 0) { - [math]::Round(($baseline.TotalCovered / $baseline.TotalStatements) * 100, 1) -} else { 0.0 } +$changedSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +foreach ($raw in $changedRaw) { + $rel = ConvertTo-ModuleRelative -Path $raw + if ($rel) { [void]$changedSet.Add($rel) } +} -$currTotal = if ($current.TotalStatements -gt 0) { - [math]::Round(($current.TotalCovered / $current.TotalStatements) * 100, 1) -} else { 0.0 } +# If the user explicitly supplied a changed-files input we enter +# "changed-file mode": the per-package report AND the per-package gate +# (-MaxPackageDecrease) are both scoped to packages that contain at least +# one touched file. The absolute floor gate (-MinOverallCoverage) is +# always computed against the full repository total — it is not affected +# by changed-file scope. +$changedFilesSupplied = ($null -ne $ChangedFiles -and $ChangedFiles.Count -gt 0) -or ` + (-not [string]::IsNullOrWhiteSpace($ChangedFilesFromFile)) +$useChangedFileMode = $changedFilesSupplied -$overallDelta = [math]::Round($currTotal - $baseTotal, 1) +# --------------------------------------------------------------------------- +# Build report +# --------------------------------------------------------------------------- +$sb = [System.Text.StringBuilder]::new() +$bar = '=' * 60 -Write-Host " Baseline: $baseTotal% ($($baseline.TotalCovered)/$($baseline.TotalStatements) stmts)" -Write-Host " Current: $currTotal% ($($current.TotalCovered)/$($current.TotalStatements) stmts)" -Write-Host " Delta: $overallDelta pp" +[void]$sb.AppendLine($bar) +[void]$sb.AppendLine('Coverage Report') +[void]$sb.AppendLine($bar) +[void]$sb.AppendLine("Baseline: $BaselineLabel") +[void]$sb.AppendLine() + +$deltaSign = if ($overallDelta -ge 0) { '+' } else { '' } +[void]$sb.AppendLine( + ('Overall: {0}% -> {1}% ({2}{3} pp)' -f ` + $baseTotal.ToString('F1', $inv), ` + $currTotal.ToString('F1', $inv), ` + $deltaSign, ` + $overallDelta.ToString('F1', $inv)) +) +[void]$sb.AppendLine( + (' Tolerance: -{0} pp per package before failing the gate' -f $MaxPackageDecrease.ToString('F1', $inv)) +) +[void]$sb.AppendLine( + (' Floor: overall coverage must stay >= {0}%' -f $MinOverallCoverage.ToString('F1', $inv)) +) +[void]$sb.AppendLine() # --------------------------------------------------------------------------- -# Compute per-package deltas +# Determine which packages to report on. # --------------------------------------------------------------------------- -$allPackages = @(@($baseline.Packages.Keys) + @($current.Packages.Keys)) | - Sort-Object -Unique - -$allDiffs = [System.Collections.Generic.List[PSCustomObject]]::new() +function Get-PackageRow { + param( + [string]$Pkg, + [hashtable]$Baseline, + [hashtable]$Current, + [int]$TouchedFileCount + ) -foreach ($pkg in $allPackages) { - $bPkg = $baseline.Packages[$pkg] - $cPkg = $current.Packages[$pkg] + $bPkg = $Baseline.Packages[$Pkg] + $cPkg = $Current.Packages[$Pkg] $bStmts = if ($bPkg) { $bPkg.Statements } else { 0 } $bCov = if ($bPkg) { $bPkg.Covered } else { 0 } $cStmts = if ($cPkg) { $cPkg.Statements } else { 0 } $cCov = if ($cPkg) { $cPkg.Covered } else { 0 } - $bPct = if ($bStmts -gt 0) { - [math]::Round(($bCov / $bStmts) * 100, 1) - } else { 0.0 } + $bPct = if ($bPkg) { Get-Percent $bCov $bStmts } else { -1.0 } + $cPct = Get-Percent $cCov $cStmts + $delta = if ($bPkg) { $cPct - $bPct } else { 0.0 } + + $status = 'ok' + $note = if ($TouchedFileCount -gt 0) { + "$TouchedFileCount file$(if ($TouchedFileCount -ne 1) { 's' }) touched" + } else { '' } + + if (-not $bPkg) { $status = 'new' } + elseif (-not $cPkg) { $status = 'deleted' } + elseif ($delta -gt 0) { $status = 'improved' } + elseif ($delta -lt 0) { $status = 'regress' } + + return [PSCustomObject]@{ + Package = $Pkg + Before = $bPct + After = $cPct + Delta = $delta + Status = $status + Note = $note + Stmts = [math]::Max($bStmts, $cStmts) + AbsDelta = [math]::Abs($delta) + StatusOrder = switch ($status) { 'regress' { 0 } 'improved' { 1 } 'new' { 2 } default { 3 } } + } +} - $cPct = if ($cStmts -gt 0) { - [math]::Round(($cCov / $cStmts) * 100, 1) - } else { 0.0 } +$script:packageRowMap = @{} +if ($useChangedFileMode) { + # ----------------------------------------------------------------------- + # Per-package mode scoped to packages containing PR-touched files. + # ----------------------------------------------------------------------- + $touchedByPackage = @{} + foreach ($rel in $changedSet) { + $lastSlash = $rel.LastIndexOf('/') + $pkg = if ($lastSlash -ge 0) { $rel.Substring(0, $lastSlash) } else { '.' } + # Include the package if EITHER (a) this file appears in coverage data + # directly, OR (b) the file's inferred package has any coverage entries + # in either profile. (b) ensures touched-but-uncovered files (constants, + # build-tagged, generated stubs) still count their package toward the + # per-package gate when the package itself is tracked. Truly orphan + # paths (no coverage anywhere in the package) are still skipped. + if (-not $baseline.Files.ContainsKey($rel) -and ` + -not $current.Files.ContainsKey($rel) -and ` + -not $baseline.Packages.ContainsKey($pkg) -and ` + -not $current.Packages.ContainsKey($pkg)) { + continue + } + if (-not $touchedByPackage.ContainsKey($pkg)) { + $touchedByPackage[$pkg] = 0 + } + $touchedByPackage[$pkg] += 1 + } - $delta = [math]::Round($cPct - $bPct, 1) + if ($touchedByPackage.Count -eq 0) { + [void]$sb.AppendLine('PR-touched packages: none with coverage data.') + [void]$sb.AppendLine() + } else { + # Build rows once and cache by package — reused by the gate loop below. + $rows = foreach ($pkg in ($touchedByPackage.Keys | Sort-Object)) { + $row = Get-PackageRow -Pkg $pkg -Baseline $baseline -Current $current ` + -TouchedFileCount $touchedByPackage[$pkg] + $script:packageRowMap[$pkg] = $row + $row + } - if ([math]::Abs($delta) -ge $MinDelta -and $delta -ne 0) { - $allDiffs.Add([PSCustomObject]@{ - Package = $pkg - BaselinePercent = $bPct - CurrentPercent = $cPct - Delta = $delta - ImpactStmts = [math]::Max($bStmts, $cStmts) - }) + [void]$sb.AppendLine( + "PR-touched packages ($($rows.Count) package$(if ($rows.Count -ne 1) { 's' })):" + ) + # Sort: regressions first by absolute delta, then improvements, then ok/new. + # Uses precomputed StatusOrder/AbsDelta properties so each row is sorted by + # property lookup rather than re-evaluating a scriptblock per comparison. + $sorted = $rows | Sort-Object -Property StatusOrder, @{Expression='AbsDelta'; Descending=$true}, Package + foreach ($row in $sorted) { + [void]$sb.AppendLine((Format-FileRow ` + -Path $row.Package ` + -Before $row.Before ` + -After $row.After ` + -Delta $row.Delta ` + -Status $row.Status ` + -Note $row.Note)) + } + [void]$sb.AppendLine() + } +} else { + # ----------------------------------------------------------------------- + # Package mode (no changed-file list): show top changed packages. + # ----------------------------------------------------------------------- + $allPackages = @(@($baseline.Packages.Keys) + @($current.Packages.Keys)) | + Sort-Object -Unique + + # Build rows once and cache by package — reused by the gate loop below. + $allDiffs = [System.Collections.Generic.List[PSCustomObject]]::new() + + foreach ($pkg in $allPackages) { + $row = Get-PackageRow -Pkg $pkg -Baseline $baseline -Current $current -TouchedFileCount 0 + $script:packageRowMap[$pkg] = $row + if ($row.AbsDelta -ge $MinDelta -and $row.Delta -ne 0) { + $allDiffs.Add($row) + } } -} -# Impact summary uses all changed packages; table shows top N -$changedPackageCount = $allDiffs.Count -$changedStmts = ($allDiffs | Measure-Object -Property ImpactStmts -Sum).Sum -if ($null -eq $changedStmts) { $changedStmts = 0 } -$totalStmts = [math]::Max($baseline.TotalStatements, $current.TotalStatements) -$changedPct = if ($totalStmts -gt 0) { - [math]::Round(($changedStmts / $totalStmts) * 100, 1) -} else { 0.0 } - -# Sort by absolute delta descending, take top N for display -$tableDiffs = @( - $allDiffs | - Sort-Object { [math]::Abs($_.Delta) } -Descending | - Select-Object -First $TopN -) + $changedPackageCount = $allDiffs.Count + $tableDiffs = @($allDiffs | + Sort-Object -Property @{Expression='AbsDelta'; Descending=$true} | + Select-Object -First $TopN) -# --------------------------------------------------------------------------- -# Get branch name for header -# --------------------------------------------------------------------------- -$branchName = 'current' -try { - $gitBranch = git rev-parse --abbrev-ref HEAD 2>$null - if ($gitBranch) { $branchName = $gitBranch.Trim() } -} catch { - # Ignore -- use default + if ($tableDiffs.Count -eq 0) { + [void]$sb.AppendLine("No packages changed by >= $($MinDelta.ToString('F1', $inv)) percentage points.") + [void]$sb.AppendLine() + } else { + [void]$sb.AppendLine("Top $([math]::Min($TopN, $changedPackageCount)) changed packages (of ${changedPackageCount}):") + foreach ($d in $tableDiffs) { + [void]$sb.AppendLine((Format-FileRow ` + -Path $d.Package ` + -Before $d.Before ` + -After $d.After ` + -Delta $d.Delta ` + -Status $d.Status ` + -Note '')) + } + if ($changedPackageCount -gt $TopN) { + [void]$sb.AppendLine(" ... and $($changedPackageCount - $TopN) more packages with smaller changes.") + } + [void]$sb.AppendLine() + } } # --------------------------------------------------------------------------- -# Generate markdown +# Multi-gate evaluation # --------------------------------------------------------------------------- -$sb = [System.Text.StringBuilder]::new() +# Two independent breach types — any one breach fails the build when +# -FailOnGate is set: +# 1. Overall coverage below -MinOverallCoverage absolute floor +# 2. Any package decrease beyond -MaxPackageDecrease +# +# Per-package gate scope: in changed-file mode we only consider PR-touched +# packages (developers shouldn't be blamed for unrelated package movement +# from baseline drift); in package mode we consider every package present +# in either profile (full scan). +$overallFloorBreached = ($MinOverallCoverage -ge 0) -and ($currTotal -lt $MinOverallCoverage) + +# Determine per-package breach set. -MaxPackageDecrease < 0 disables the +# per-package gate (advisory output only); skip the loop entirely so a +# disabled gate can never report breaches. +$pkgGateScope = if ($MaxPackageDecrease -lt 0) { + @() +} elseif ($useChangedFileMode) { + @($touchedByPackage.Keys) +} else { + @(@($baseline.Packages.Keys) + @($current.Packages.Keys)) | Sort-Object -Unique +} -[void]$sb.AppendLine('') -[void]$sb.AppendLine("## Coverage Diff: ``main`` <- ``$branchName``") -[void]$sb.AppendLine() +$packageBreaches = [System.Collections.Generic.List[PSCustomObject]]::new() +foreach ($pkg in $pkgGateScope) { + # Reuse the row computed during display rather than recomputing — avoids + # the duplicate Get-PackageRow call per package that the original + # implementation made (one for display, one for the gate). + if ($script:packageRowMap.ContainsKey($pkg)) { + $row = $script:packageRowMap[$pkg] + } else { + $row = Get-PackageRow -Pkg $pkg -Baseline $baseline -Current $current -TouchedFileCount 0 + } + # Only existing packages with a real regression count toward the gate; + # 'new' packages (no baseline) and packages absent from current with + # 0 statements aren't comparable. + if ($row.Status -ne 'regress') { continue } + # Use raw delta (no rounding) to avoid boundary false negatives: + # a 0.54 pp drop must NOT round down to 0.5 pp and pass a 0.5 tolerance. + $pkgDecrease = -$row.Delta + if ($pkgDecrease -gt $MaxPackageDecrease) { + $packageBreaches.Add([PSCustomObject]@{ + Package = $pkg + Before = $row.Before + After = $row.After + Decrease = $pkgDecrease + }) + } +} +$packageGateBreached = $packageBreaches.Count -gt 0 -# Overall line -$deltaSign = if ($overallDelta -ge 0) { '+' } else { '' } -[void]$sb.AppendLine( - "**Overall**: $($baseTotal.ToString('F1', $inv))% -> $($currTotal.ToString('F1', $inv))% " + - "($deltaSign$($overallDelta.ToString('F1', $inv)) pp)" -) -[void]$sb.AppendLine() +$gateBreached = $overallFloorBreached -or $packageGateBreached -# Changed packages table -if ($tableDiffs.Count -gt 0) { - [void]$sb.AppendLine('### Changed Packages') - [void]$sb.AppendLine() - [void]$sb.AppendLine('| Package | Before | After | Delta |') - [void]$sb.AppendLine('|---------|--------|-------|-------|') +[void]$sb.AppendLine($bar) - foreach ($d in $tableDiffs) { - $sign = if ($d.Delta -ge 0) { '+' } else { '' } - $bold = if ([math]::Abs($d.Delta) -ge 1.0) { '**' } else { '' } +if ($gateBreached) { + [void]$sb.AppendLine('RESULT: FAIL') + [void]$sb.AppendLine($bar) + [void]$sb.AppendLine('Breached gate(s):') + if ($overallFloorBreached) { [void]$sb.AppendLine( - "| ``$($d.Package)`` " + - "| $($d.BaselinePercent.ToString('F1', $inv))% " + - "| $($d.CurrentPercent.ToString('F1', $inv))% " + - "| $bold$sign$($d.Delta.ToString('F1', $inv))$bold |" + (' - Overall coverage {0}% is below floor of {1}%' -f $currTotal.ToString('F1', $inv), $MinOverallCoverage.ToString('F1', $inv)) ) } - - if ($changedPackageCount -gt $TopN) { - [void]$sb.AppendLine() - $remaining = $changedPackageCount - $TopN - [void]$sb.AppendLine("*... and $remaining more packages with smaller changes.*") + if ($packageGateBreached) { + [void]$sb.AppendLine( + (' - {0} package(s) dropped more than {1} pp:' -f $packageBreaches.Count, $MaxPackageDecrease.ToString('F1', $inv)) + ) + $sortedBreaches = $packageBreaches | Sort-Object -Property Decrease -Descending + foreach ($pb in $sortedBreaches) { + [void]$sb.AppendLine( + (' {0}: {1}% -> {2}% (-{3} pp)' -f $pb.Package, $pb.Before.ToString('F1', $inv), $pb.After.ToString('F1', $inv), $pb.Decrease.ToString('F1', $inv)) + ) + } } - - [void]$sb.AppendLine() + [void]$sb.AppendLine($bar) + [void]$sb.AppendLine('How to fix:') + [void]$sb.AppendLine(' 1. Add tests for the regressing packages listed above.') + [void]$sb.AppendLine(' 2. Re-run locally: mage coverage:unit && mage coverage:diff') + [void]$sb.AppendLine( + (' 3. CI fails when overall falls below {0}% or any package drops more than {1} pp.' -f $MinOverallCoverage.ToString('F1', $inv), $MaxPackageDecrease.ToString('F1', $inv)) + ) } else { - [void]$sb.AppendLine("No packages changed by >= $($MinDelta.ToString('F1', $inv)) percentage points.") - [void]$sb.AppendLine() + [void]$sb.AppendLine('RESULT: PASS') + [void]$sb.AppendLine($bar) } -# Impact summary -$changedStmtsFmt = $changedStmts.ToString('N0', $inv) -$totalStmtsFmt = $totalStmts.ToString('N0', $inv) - -[void]$sb.AppendLine('### Impact Summary') -[void]$sb.AppendLine("- **Packages changed**: $changedPackageCount of $($allPackages.Count)") -[void]$sb.AppendLine("- **Statements in changed packages**: $changedStmtsFmt of $totalStmtsFmt ($($changedPct.ToString('F1', $inv))%)") -[void]$sb.AppendLine("- **Weighted impact**: $deltaSign$($overallDelta.ToString('F1', $inv)) pp overall") +$report = $sb.ToString() -$markdown = $sb.ToString() +Write-Host "" +Write-Output $report # --------------------------------------------------------------------------- -# Output +# Exit code # --------------------------------------------------------------------------- -if ($OutputFile) { - Set-Content -Path $OutputFile -Value $markdown -Encoding UTF8 - Write-Host "" - Write-Host "Coverage diff written to: $OutputFile" -} else { - Write-Host "" - Write-Output $markdown +if ($gateBreached -and $FailOnGate) { + if ($overallFloorBreached) { + Write-Host ('##vso[task.logissue type=error]Overall coverage {0}% is below floor of {1}%.' -f $currTotal.ToString('F1', $inv), $MinOverallCoverage.ToString('F1', $inv)) + } + if ($packageGateBreached) { + $sortedBreaches = $packageBreaches | Sort-Object -Property Decrease -Descending + foreach ($pb in $sortedBreaches) { + Write-Host ('##vso[task.logissue type=error]Package {0} dropped {1} pp (max allowed: -{2} pp).' -f $pb.Package, $pb.Decrease.ToString('F1', $inv), $MaxPackageDecrease.ToString('F1', $inv)) + } + } + exit 2 } + +exit 0 diff --git a/eng/scripts/Test-CodeCoverageThreshold.ps1 b/eng/scripts/Test-CodeCoverageThreshold.ps1 index 8c5258b5f0a..ccb76fd5718 100644 --- a/eng/scripts/Test-CodeCoverageThreshold.ps1 +++ b/eng/scripts/Test-CodeCoverageThreshold.ps1 @@ -10,12 +10,13 @@ specified minimum. This script is called automatically by Get-LocalCoverageReport.ps1 - (when -MinCoverage is set) and by the CI pipeline (release-cli.yml) to - enforce the coverage gate. It can also be run standalone on any Go - coverage profile. + (when -MinCoverage is set) for local coverage checks. It can also be + run standalone on any Go coverage profile. - See cli/azd/docs/code-coverage-guide.md for details on the CI gate - and ratchet policy. + Note: this script is no longer wired into the CI pipeline. PR coverage + enforcement is handled by Get-CoverageDiff.ps1, which runs a two-gate + check (per-package decrease + overall floor) — see + cli/azd/docs/code-coverage-guide.md for details. .PARAMETER CoverageFile Path to the Go coverage profile (typically cover.out).