From e43e66e9af9c1be060e84588193f7c52266b5fb7 Mon Sep 17 00:00:00 2001 From: Aaron Burtle Date: Thu, 9 Jul 2026 12:08:09 -0700 Subject: [PATCH 01/13] Add combined coverage merge script + draft aggregator pipeline --- .pipelines/combined-coverage-pipelines.yml | 91 +++++++++++++ scripts/merge-coverage.ps1 | 147 +++++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 .pipelines/combined-coverage-pipelines.yml create mode 100644 scripts/merge-coverage.ps1 diff --git a/.pipelines/combined-coverage-pipelines.yml b/.pipelines/combined-coverage-pipelines.yml new file mode 100644 index 0000000000..d34d76574b --- /dev/null +++ b/.pipelines/combined-coverage-pipelines.yml @@ -0,0 +1,91 @@ +# ============================================================================= +# DRAFT — Combined Code Coverage (union of all test pipelines) +# ============================================================================= +# Purpose: +# The unit + per-database (MsSql, PostgreSql, MySql, DwSql, CosmosDb) pipelines +# each publish their OWN coverage tab. No single view shows the union, so each +# pipeline looks low (~30-43% branch) even though the merged coverage is ~77%. +# +# This aggregator runs AFTER the six test pipelines complete, downloads each +# one's raw cobertura artifact, merges them (scripts/merge-coverage.ps1), and +# republishes a single combined coverage report. THIS pipeline's build is where +# the true combined "Code Coverage" tab lives. +# +# PREREQUISITE (do first, in each of the six *-pipelines.yml): +# Add a step that publishes the raw cobertura as a downloadable artifact, e.g. +# - task: PublishPipelineArtifact@1 +# displayName: 'Publish raw coverage' +# inputs: +# targetPath: '$(Agent.TempDirectory)' +# artifact: 'coverage-' # coverage-unit, coverage-mssql, ... +# +# TODO before enabling: +# 1. Replace the pipeline resource 'source' values with the real ADO pipeline +# definition names (Pipelines > each pipeline > ... > Settings). +# 2. Confirm the artifact names match those published above. +# 3. Register this file as a new pipeline in Azure DevOps and add it as a +# required GitHub status check named "Combined Coverage". +# ============================================================================= + +trigger: none # runs via pipeline-completion resource triggers, not on push + +resources: + pipelines: + - pipeline: unit + source: 'TODO-unit-pipeline-name' + trigger: true + - pipeline: mssql + source: 'TODO-mssql-pipeline-name' + trigger: true + - pipeline: pg + source: 'TODO-pg-pipeline-name' + trigger: true + - pipeline: mysql + source: 'TODO-mysql-pipeline-name' + trigger: true + - pipeline: dwsql + source: 'TODO-dwsql-pipeline-name' + trigger: true + - pipeline: cosmos + source: 'TODO-cosmos-pipeline-name' + trigger: true + +pool: + vmImage: 'windows-latest' + +steps: + - download: unit + artifact: coverage-unit + - download: mssql + artifact: coverage-mssql + - download: pg + artifact: coverage-pg + - download: mysql + artifact: coverage-mysql + - download: dwsql + artifact: coverage-dwsql + - download: cosmos + artifact: coverage-cosmos + + - task: PowerShell@2 + displayName: 'Merge cobertura reports (union)' + inputs: + filePath: 'scripts/merge-coverage.ps1' + arguments: '-Path "$(Pipeline.Workspace)" ' + pwsh: false + + # merge-coverage.ps1 prints the combined %. For a published tab, also emit a + # merged cobertura via ReportGenerator and publish it: + - task: reportgenerator@5 + displayName: 'Generate merged cobertura' + inputs: + reports: '$(Pipeline.Workspace)/**/coverage.cobertura.xml' + targetdir: '$(Build.ArtifactStagingDirectory)/merged' + reporttypes: 'Cobertura;HtmlInline_AzurePipelines;TextSummary' + # Exclude the test projects so the number reflects product code only. + assemblyfilters: '-*.Tests' + + - task: PublishCodeCoverageResults@2 + displayName: 'Publish combined code coverage' + inputs: + summaryFileLocation: '$(Build.ArtifactStagingDirectory)/merged/Cobertura.xml' diff --git a/scripts/merge-coverage.ps1 b/scripts/merge-coverage.ps1 new file mode 100644 index 0000000000..01430c47bb --- /dev/null +++ b/scripts/merge-coverage.ps1 @@ -0,0 +1,147 @@ +<# +.SYNOPSIS + Merges multiple Cobertura coverage reports (e.g. one per DAB pipeline: unit, mssql, + postgres, mysql, cosmos, dwsql) into a single combined line/branch coverage number. + +.DESCRIPTION + Azure DevOps' PublishCodeCoverageResults@1 renders a per-pipeline coverage tab but does + not merge across pipelines. Download each pipeline run's raw *.cobertura.xml (from the + "Code Coverage Report_" artifact) into one folder, then run this script. + + Merge semantics (union): a source line is covered if it is hit in ANY report; a line's + covered branch-conditions are the MAX seen across reports (denominator is fixed per line). + This matches how ReportGenerator unions reports when per-condition detail is absent. + + Zero external dependencies (no reportgenerator / no az CLI required). + +.PARAMETER Path + Folder to search recursively for *.cobertura.xml files. + +.PARAMETER Files + Explicit list of cobertura files to merge (overrides -Path). + +.EXAMPLE + ./merge-coverage.ps1 -Path C:\coverage-downloads + +.EXAMPLE + ./merge-coverage.ps1 -Files unit.cobertura.xml,mssql.cobertura.xml,pg.cobertura.xml +#> +[CmdletBinding()] +param( + [string]$Path, + [string[]]$Files, + # Package (assembly) names matching this regex are excluded (test projects by default). + [string]$ExcludePattern = '\.Tests$' +) + +if ($Files) { + $reportFiles = $Files +} +elseif ($Path) { + $reportFiles = Get-ChildItem -Path $Path -Recurse -Filter *.cobertura.xml -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty FullName +} +else { + Write-Error "Provide -Path or -Files a.xml,b.xml,..." + exit 1 +} + +$reportFiles = @($reportFiles | Where-Object { $_ -and (Test-Path $_) }) +if ($reportFiles.Count -eq 0) { + Write-Error "No cobertura files found." + exit 1 +} + +# key: "package|filename|lineNumber" -> merged coverage point +$points = @{} + +foreach ($rf in $reportFiles) { + try { + [xml]$xml = Get-Content -LiteralPath $rf -Raw + } + catch { + Write-Warning "Skipping unreadable file: $rf" + continue + } + + foreach ($pkg in $xml.coverage.packages.package) { + $pkgName = [string]$pkg.name + if ($ExcludePattern -and $pkgName -match $ExcludePattern) { continue } + foreach ($cls in $pkg.classes.class) { + $clsName = [string]$cls.name + if (-not $cls.lines) { continue } + foreach ($ln in $cls.lines.line) { + if (-not $ln) { continue } + $num = [int]$ln.number + $hits = [int]$ln.hits + $isBranch = ([string]$ln.branch -eq 'true') + $condCov = 0 + $condTot = 0 + if ($isBranch -and ([string]$ln.'condition-coverage') -match '\((\d+)/(\d+)\)') { + $condCov = [int]$Matches[1] + $condTot = [int]$Matches[2] + } + + # Key by FQ class name (agent-path independent) + line number so the same + # source point unions across reports built on different agents (Windows vs Linux). + $key = "$pkgName|$clsName|$num" + if ($points.ContainsKey($key)) { + $e = $points[$key] + if ($hits -gt $e.Hits) { $e.Hits = $hits } + if ($isBranch) { + $e.IsBranch = $true + if ($condCov -gt $e.CondCov) { $e.CondCov = $condCov } + if ($condTot -gt $e.CondTot) { $e.CondTot = $condTot } + } + } + else { + $points[$key] = [pscustomobject]@{ + Package = $pkgName + Hits = $hits + IsBranch = $isBranch + CondCov = $condCov + CondTot = $condTot + } + } + } + } + } +} + +# Aggregate overall and per-assembly. +$byPkg = @{} +$totLines = 0; $covLines = 0; $totBr = 0; $covBr = 0 + +foreach ($e in $points.Values) { + $totLines++ + if ($e.Hits -gt 0) { $covLines++ } + if ($e.IsBranch) { $totBr += $e.CondTot; $covBr += $e.CondCov } + + if (-not $byPkg.ContainsKey($e.Package)) { + $byPkg[$e.Package] = [pscustomobject]@{ TL = 0; CL = 0; TB = 0; CB = 0 } + } + $p = $byPkg[$e.Package] + $p.TL++ + if ($e.Hits -gt 0) { $p.CL++ } + if ($e.IsBranch) { $p.TB += $e.CondTot; $p.CB += $e.CondCov } +} + +Write-Host "" +Write-Host "Merged $($reportFiles.Count) coverage file(s):" +$reportFiles | ForEach-Object { Write-Host " - $_" } +Write-Host "" +Write-Host ("{0,-45} {1,12} {2,14}" -f 'Assembly', 'Line %', 'Branch %') +Write-Host ("{0,-45} {1,12} {2,14}" -f ('-' * 40), '------', '--------') +foreach ($k in ($byPkg.Keys | Sort-Object)) { + $p = $byPkg[$k] + $lr = if ($p.TL) { [math]::Round($p.CL / $p.TL * 100, 1) } else { 0 } + $br = if ($p.TB) { [math]::Round($p.CB / $p.TB * 100, 1) } else { 0 } + Write-Host ("{0,-45} {1,9}% ({2}/{3}) {4,7}% ({5}/{6})" -f $k, $lr, $p.CL, $p.TL, $br, $p.CB, $p.TB) +} +Write-Host "" +$LR = if ($totLines) { [math]::Round($covLines / $totLines * 100, 2) } else { 0 } +$BR = if ($totBr) { [math]::Round($covBr / $totBr * 100, 2) } else { 0 } +Write-Host "==================== COMBINED (all pipelines) ====================" +Write-Host " LINE : $covLines / $totLines = $LR%" +Write-Host " BRANCH : $covBr / $totBr = $BR%" +Write-Host "=================================================================" From 4633c65f39b152bc040b9ed67f6a082f8e0bdf02 Mon Sep 17 00:00:00 2001 From: Aaron Burtle Date: Thu, 9 Jul 2026 12:24:28 -0700 Subject: [PATCH 02/13] Aggregator reuses existing coverage artifacts (no pipeline changes) --- .pipelines/combined-coverage-pipelines.yml | 63 ++++++++++++---------- 1 file changed, 35 insertions(+), 28 deletions(-) diff --git a/.pipelines/combined-coverage-pipelines.yml b/.pipelines/combined-coverage-pipelines.yml index d34d76574b..a22e680608 100644 --- a/.pipelines/combined-coverage-pipelines.yml +++ b/.pipelines/combined-coverage-pipelines.yml @@ -1,30 +1,35 @@ # ============================================================================= -# DRAFT — Combined Code Coverage (union of all test pipelines) +# Combined Code Coverage (union of all test pipelines) # ============================================================================= # Purpose: # The unit + per-database (MsSql, PostgreSql, MySql, DwSql, CosmosDb) pipelines # each publish their OWN coverage tab. No single view shows the union, so each # pipeline looks low (~30-43% branch) even though the merged coverage is ~77%. # -# This aggregator runs AFTER the six test pipelines complete, downloads each -# one's raw cobertura artifact, merges them (scripts/merge-coverage.ps1), and -# republishes a single combined coverage report. THIS pipeline's build is where -# the true combined "Code Coverage" tab lives. +# This aggregator runs AFTER the six test pipelines complete, downloads the +# coverage each already publishes, merges them (scripts/merge-coverage.ps1), +# and republishes ONE combined report. THIS pipeline's build is where the true +# combined "Code Coverage" tab lives. # -# PREREQUISITE (do first, in each of the six *-pipelines.yml): -# Add a step that publishes the raw cobertura as a downloadable artifact, e.g. -# - task: PublishPipelineArtifact@1 -# displayName: 'Publish raw coverage' -# inputs: -# targetPath: '$(Agent.TempDirectory)' -# artifact: 'coverage-' # coverage-unit, coverage-mssql, ... +# NOTE: This REUSES the existing "Code Coverage Report_" artifact that +# every pipeline's PublishCodeCoverageResults@1 step already produces (the same +# zip downloadable from each build). No changes to the six pipelines required. +# The `patterns` filter grabs only the small cobertura xml and skips the ~22MB +# bundled HTML report, and avoids needing the dynamic artifact name. # # TODO before enabling: -# 1. Replace the pipeline resource 'source' values with the real ADO pipeline -# definition names (Pipelines > each pipeline > ... > Settings). -# 2. Confirm the artifact names match those published above. -# 3. Register this file as a new pipeline in Azure DevOps and add it as a +# 1. Replace each pipeline resource `source:` with the real Azure DevOps +# pipeline DEFINITION name (Pipelines list, not the yml filename). +# 2. Register this file as a new pipeline in Azure DevOps and add it as a # required GitHub status check named "Combined Coverage". +# 3. Fallback only if `- download` cannot see the report (it is a build/ +# container artifact): add a deterministic pipeline artifact to each +# *-pipelines.yml -- +# - task: PublishPipelineArtifact@1 +# inputs: +# targetPath: '$(Agent.TempDirectory)' +# artifact: 'coverage-' +# -- and change the downloads below to `- download: `. # ============================================================================= trigger: none # runs via pipeline-completion resource triggers, not on push @@ -54,35 +59,37 @@ pool: vmImage: 'windows-latest' steps: + # Reuse the coverage each pipeline already publishes; `patterns` keeps only the + # cobertura xml (skips the bundled HTML) and avoids needing the dynamic + # "Code Coverage Report_" artifact name. - download: unit - artifact: coverage-unit + patterns: '**/*.cobertura.xml' - download: mssql - artifact: coverage-mssql + patterns: '**/*.cobertura.xml' - download: pg - artifact: coverage-pg + patterns: '**/*.cobertura.xml' - download: mysql - artifact: coverage-mysql + patterns: '**/*.cobertura.xml' - download: dwsql - artifact: coverage-dwsql + patterns: '**/*.cobertura.xml' - download: cosmos - artifact: coverage-cosmos + patterns: '**/*.cobertura.xml' - task: PowerShell@2 - displayName: 'Merge cobertura reports (union)' + displayName: 'Merge cobertura reports (union) and print combined %' inputs: filePath: 'scripts/merge-coverage.ps1' - arguments: '-Path "$(Pipeline.Workspace)" ' + arguments: '-Path "$(Pipeline.Workspace)"' pwsh: false - # merge-coverage.ps1 prints the combined %. For a published tab, also emit a - # merged cobertura via ReportGenerator and publish it: + # Also emit a merged cobertura via ReportGenerator so the combined number shows + # in the Code Coverage tab (excludes test projects so it reflects product code). - task: reportgenerator@5 displayName: 'Generate merged cobertura' inputs: - reports: '$(Pipeline.Workspace)/**/coverage.cobertura.xml' + reports: '$(Pipeline.Workspace)/**/*.cobertura.xml' targetdir: '$(Build.ArtifactStagingDirectory)/merged' reporttypes: 'Cobertura;HtmlInline_AzurePipelines;TextSummary' - # Exclude the test projects so the number reflects product code only. assemblyfilters: '-*.Tests' - task: PublishCodeCoverageResults@2 From abe2d34684cf559523e1ab99c8bbaa32b663fd1d Mon Sep 17 00:00:00 2001 From: Aaron Burtle Date: Thu, 9 Jul 2026 12:44:28 -0700 Subject: [PATCH 03/13] Name run, fill best-guess pipeline sources for Total Code Coverage --- .pipelines/combined-coverage-pipelines.yml | 31 ++++++++++++++-------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/.pipelines/combined-coverage-pipelines.yml b/.pipelines/combined-coverage-pipelines.yml index a22e680608..c03a0bf5ac 100644 --- a/.pipelines/combined-coverage-pipelines.yml +++ b/.pipelines/combined-coverage-pipelines.yml @@ -17,11 +17,13 @@ # The `patterns` filter grabs only the small cobertura xml and skips the ~22MB # bundled HTML report, and avoids needing the dynamic artifact name. # -# TODO before enabling: -# 1. Replace each pipeline resource `source:` with the real Azure DevOps -# pipeline DEFINITION name (Pipelines list, not the yml filename). -# 2. Register this file as a new pipeline in Azure DevOps and add it as a -# required GitHub status check named "Combined Coverage". +# BEFORE ENABLING: +# 1. Confirm each pipeline resource `source:` matches that pipeline's name in +# the Azure DevOps Pipelines list. The definition name is NOT stored in the +# repo, so the values below are best-guess and may need adjusting. +# 2. Create this as a new pipeline in Azure DevOps named "Total Code Coverage" +# (that name becomes the GitHub status check and the Pipelines-list entry; +# its build's Code Coverage tab is where the combined number is shown). # 3. Fallback only if `- download` cannot see the report (it is a build/ # container artifact): add a deterministic pipeline artifact to each # *-pipelines.yml -- @@ -32,27 +34,34 @@ # -- and change the downloads below to `- download: `. # ============================================================================= +# Build-number format for each run (the human-facing pipeline name is set in +# Azure DevOps as "Total Code Coverage"). +name: 'TotalCodeCoverage_$(Date:yyyyMMdd)$(Rev:.r)' + trigger: none # runs via pipeline-completion resource triggers, not on push resources: pipelines: + # `source` must match each pipeline's name in the Azure DevOps Pipelines list. + # These are best-guess names derived from the existing *-pipelines.yml; adjust + # if the ADO definition names differ. - pipeline: unit - source: 'TODO-unit-pipeline-name' + source: 'DataApiBuilder-UnitTests' trigger: true - pipeline: mssql - source: 'TODO-mssql-pipeline-name' + source: 'DataApiBuilder-MsSql' trigger: true - pipeline: pg - source: 'TODO-pg-pipeline-name' + source: 'DataApiBuilder-PostgreSql' trigger: true - pipeline: mysql - source: 'TODO-mysql-pipeline-name' + source: 'DataApiBuilder-MySql' trigger: true - pipeline: dwsql - source: 'TODO-dwsql-pipeline-name' + source: 'DataApiBuilder-DwSql' trigger: true - pipeline: cosmos - source: 'TODO-cosmos-pipeline-name' + source: 'DataApiBuilder-CosmosDb' trigger: true pool: From 4f2c0304c3911bf032fe8011815e173225aef19d Mon Sep 17 00:00:00 2001 From: Aaron Burtle Date: Thu, 9 Jul 2026 12:50:32 -0700 Subject: [PATCH 04/13] Document one-time ADO registration + run-once trigger arming --- .pipelines/combined-coverage-pipelines.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.pipelines/combined-coverage-pipelines.yml b/.pipelines/combined-coverage-pipelines.yml index c03a0bf5ac..1dd4d902c5 100644 --- a/.pipelines/combined-coverage-pipelines.yml +++ b/.pipelines/combined-coverage-pipelines.yml @@ -17,14 +17,19 @@ # The `patterns` filter grabs only the small cobertura xml and skips the ~22MB # bundled HTML report, and avoids needing the dynamic artifact name. # -# BEFORE ENABLING: +# BEFORE ENABLING (one-time, manual — this yaml does NOTHING until registered): # 1. Confirm each pipeline resource `source:` matches that pipeline's name in # the Azure DevOps Pipelines list. The definition name is NOT stored in the # repo, so the values below are best-guess and may need adjusting. -# 2. Create this as a new pipeline in Azure DevOps named "Total Code Coverage" -# (that name becomes the GitHub status check and the Pipelines-list entry; -# its build's Code Coverage tab is where the combined number is shown). -# 3. Fallback only if `- download` cannot see the report (it is a build/ +# 2. Create the pipeline in Azure DevOps: Pipelines > New pipeline > "Existing +# Azure Pipelines YAML file" > point at this file. Name it "Total Code +# Coverage" (that name becomes the GitHub status check and the Pipelines- +# list entry; its build's Code Coverage tab shows the combined number). +# NOTE: merging this file alone does not create or run anything. +# 3. Run the new pipeline once manually. Azure DevOps only arms +# `resources.pipelines` completion triggers AFTER the pipeline has run once, +# so the "run after the six test pipelines" trigger won't fire until then. +# 4. Fallback only if `- download` cannot see the report (it is a build/ # container artifact): add a deterministic pipeline artifact to each # *-pipelines.yml -- # - task: PublishPipelineArtifact@1 From 6edd503b7fe2a780bfdd95bd424b954576162c77 Mon Sep 17 00:00:00 2001 From: Aaron Burtle Date: Thu, 9 Jul 2026 12:57:28 -0700 Subject: [PATCH 05/13] Integrate Total Code Coverage aggregation into unit pipeline; remove standalone --- .pipelines/combined-coverage-pipelines.yml | 112 ------------------- .pipelines/templates/build-pipelines.yml | 15 +++ .pipelines/unittest-pipelines.yml | 123 ++++++++++++++++++++- 3 files changed, 133 insertions(+), 117 deletions(-) delete mode 100644 .pipelines/combined-coverage-pipelines.yml diff --git a/.pipelines/combined-coverage-pipelines.yml b/.pipelines/combined-coverage-pipelines.yml deleted file mode 100644 index 1dd4d902c5..0000000000 --- a/.pipelines/combined-coverage-pipelines.yml +++ /dev/null @@ -1,112 +0,0 @@ -# ============================================================================= -# Combined Code Coverage (union of all test pipelines) -# ============================================================================= -# Purpose: -# The unit + per-database (MsSql, PostgreSql, MySql, DwSql, CosmosDb) pipelines -# each publish their OWN coverage tab. No single view shows the union, so each -# pipeline looks low (~30-43% branch) even though the merged coverage is ~77%. -# -# This aggregator runs AFTER the six test pipelines complete, downloads the -# coverage each already publishes, merges them (scripts/merge-coverage.ps1), -# and republishes ONE combined report. THIS pipeline's build is where the true -# combined "Code Coverage" tab lives. -# -# NOTE: This REUSES the existing "Code Coverage Report_" artifact that -# every pipeline's PublishCodeCoverageResults@1 step already produces (the same -# zip downloadable from each build). No changes to the six pipelines required. -# The `patterns` filter grabs only the small cobertura xml and skips the ~22MB -# bundled HTML report, and avoids needing the dynamic artifact name. -# -# BEFORE ENABLING (one-time, manual — this yaml does NOTHING until registered): -# 1. Confirm each pipeline resource `source:` matches that pipeline's name in -# the Azure DevOps Pipelines list. The definition name is NOT stored in the -# repo, so the values below are best-guess and may need adjusting. -# 2. Create the pipeline in Azure DevOps: Pipelines > New pipeline > "Existing -# Azure Pipelines YAML file" > point at this file. Name it "Total Code -# Coverage" (that name becomes the GitHub status check and the Pipelines- -# list entry; its build's Code Coverage tab shows the combined number). -# NOTE: merging this file alone does not create or run anything. -# 3. Run the new pipeline once manually. Azure DevOps only arms -# `resources.pipelines` completion triggers AFTER the pipeline has run once, -# so the "run after the six test pipelines" trigger won't fire until then. -# 4. Fallback only if `- download` cannot see the report (it is a build/ -# container artifact): add a deterministic pipeline artifact to each -# *-pipelines.yml -- -# - task: PublishPipelineArtifact@1 -# inputs: -# targetPath: '$(Agent.TempDirectory)' -# artifact: 'coverage-' -# -- and change the downloads below to `- download: `. -# ============================================================================= - -# Build-number format for each run (the human-facing pipeline name is set in -# Azure DevOps as "Total Code Coverage"). -name: 'TotalCodeCoverage_$(Date:yyyyMMdd)$(Rev:.r)' - -trigger: none # runs via pipeline-completion resource triggers, not on push - -resources: - pipelines: - # `source` must match each pipeline's name in the Azure DevOps Pipelines list. - # These are best-guess names derived from the existing *-pipelines.yml; adjust - # if the ADO definition names differ. - - pipeline: unit - source: 'DataApiBuilder-UnitTests' - trigger: true - - pipeline: mssql - source: 'DataApiBuilder-MsSql' - trigger: true - - pipeline: pg - source: 'DataApiBuilder-PostgreSql' - trigger: true - - pipeline: mysql - source: 'DataApiBuilder-MySql' - trigger: true - - pipeline: dwsql - source: 'DataApiBuilder-DwSql' - trigger: true - - pipeline: cosmos - source: 'DataApiBuilder-CosmosDb' - trigger: true - -pool: - vmImage: 'windows-latest' - -steps: - # Reuse the coverage each pipeline already publishes; `patterns` keeps only the - # cobertura xml (skips the bundled HTML) and avoids needing the dynamic - # "Code Coverage Report_" artifact name. - - download: unit - patterns: '**/*.cobertura.xml' - - download: mssql - patterns: '**/*.cobertura.xml' - - download: pg - patterns: '**/*.cobertura.xml' - - download: mysql - patterns: '**/*.cobertura.xml' - - download: dwsql - patterns: '**/*.cobertura.xml' - - download: cosmos - patterns: '**/*.cobertura.xml' - - - task: PowerShell@2 - displayName: 'Merge cobertura reports (union) and print combined %' - inputs: - filePath: 'scripts/merge-coverage.ps1' - arguments: '-Path "$(Pipeline.Workspace)"' - pwsh: false - - # Also emit a merged cobertura via ReportGenerator so the combined number shows - # in the Code Coverage tab (excludes test projects so it reflects product code). - - task: reportgenerator@5 - displayName: 'Generate merged cobertura' - inputs: - reports: '$(Pipeline.Workspace)/**/*.cobertura.xml' - targetdir: '$(Build.ArtifactStagingDirectory)/merged' - reporttypes: 'Cobertura;HtmlInline_AzurePipelines;TextSummary' - assemblyfilters: '-*.Tests' - - - task: PublishCodeCoverageResults@2 - displayName: 'Publish combined code coverage' - inputs: - summaryFileLocation: '$(Build.ArtifactStagingDirectory)/merged/Cobertura.xml' diff --git a/.pipelines/templates/build-pipelines.yml b/.pipelines/templates/build-pipelines.yml index e6e7ca3f13..40cb22ec2e 100644 --- a/.pipelines/templates/build-pipelines.yml +++ b/.pipelines/templates/build-pipelines.yml @@ -125,3 +125,18 @@ steps: inputs: codeCoverageTool: Cobertura summaryFileLocation: '$(Agent.TempDirectory)/**/*cobertura.xml' + +# Publish this run's unit cobertura as a pipeline artifact so the +# "Total Code Coverage" job (unittest-pipelines.yml) can merge it with the +# database integration pipelines' coverage into one combined report. +- task: CopyFiles@2 + displayName: 'Stage unit coverage for aggregation' + inputs: + sourceFolder: '$(Agent.TempDirectory)' + contents: '**/*.cobertura.xml' + targetFolder: '$(Build.ArtifactStagingDirectory)/coverage' +- task: PublishPipelineArtifact@1 + displayName: 'Publish unit cobertura (coverage-unit)' + inputs: + targetPath: '$(Build.ArtifactStagingDirectory)/coverage' + artifact: 'coverage-unit' diff --git a/.pipelines/unittest-pipelines.yml b/.pipelines/unittest-pipelines.yml index 56357a181c..1fc69c9a09 100644 --- a/.pipelines/unittest-pipelines.yml +++ b/.pipelines/unittest-pipelines.yml @@ -21,11 +21,124 @@ pr: - '*.md' - templates/** -pool: - vmImage: 'ubuntu-latest' # examples of other options: 'macOS-10.15', 'windows-2019' - variables: - template: templates/variables.yml -steps: -- template: templates/build-pipelines.yml +jobs: +- job: build + displayName: 'Build and Unit Tests' + pool: + vmImage: 'ubuntu-latest' + steps: + - template: templates/build-pipelines.yml + +# --------------------------------------------------------------------------- +# Total Code Coverage +# --------------------------------------------------------------------------- +# Merges THIS run's unit coverage with the latest coverage from the database +# integration pipelines (MsSql, PostgreSql, MySql, DwSql, CosmosDb) and publishes +# a single combined report. Each pipeline in isolation looks low (~30-43% branch) +# because they are complementary; the union is ~77%. +# +# This job is BEST-EFFORT (continueOnError on the job and every step) so it can +# never fail or block unit CI. The DB coverage is the latest available run, so +# the combined number is a dashboard figure, not a per-PR-exact gate. +# +# TODO before it produces DB coverage: replace each 'TODO--definition-id' +# with the pipeline's numeric definition ID (open the pipeline in Azure DevOps; +# the URL contains ...?definitionId=NN). Until then the job still runs and +# publishes unit-only coverage. Requires the ReportGenerator ADO extension. +- job: total_code_coverage + displayName: 'Total Code Coverage' + dependsOn: build + condition: succeededOrFailed() + continueOnError: true + pool: + vmImage: 'ubuntu-latest' + steps: + - checkout: self + + # This run's unit coverage (published by the build job above). + - task: DownloadPipelineArtifact@2 + displayName: 'Download unit coverage (this run)' + continueOnError: true + inputs: + source: 'current' + artifact: 'coverage-unit' + patterns: '**/*.cobertura.xml' + path: '$(Pipeline.Workspace)/unit' + + - task: DownloadPipelineArtifact@2 + displayName: 'Download MsSql coverage (latest)' + continueOnError: true + inputs: + source: 'specific' + project: '$(System.TeamProject)' + pipeline: 'TODO-mssql-definition-id' + runVersion: 'latest' + patterns: '**/*.cobertura.xml' + path: '$(Pipeline.Workspace)/db/mssql' + - task: DownloadPipelineArtifact@2 + displayName: 'Download PostgreSql coverage (latest)' + continueOnError: true + inputs: + source: 'specific' + project: '$(System.TeamProject)' + pipeline: 'TODO-pg-definition-id' + runVersion: 'latest' + patterns: '**/*.cobertura.xml' + path: '$(Pipeline.Workspace)/db/pg' + - task: DownloadPipelineArtifact@2 + displayName: 'Download MySql coverage (latest)' + continueOnError: true + inputs: + source: 'specific' + project: '$(System.TeamProject)' + pipeline: 'TODO-mysql-definition-id' + runVersion: 'latest' + patterns: '**/*.cobertura.xml' + path: '$(Pipeline.Workspace)/db/mysql' + - task: DownloadPipelineArtifact@2 + displayName: 'Download DwSql coverage (latest)' + continueOnError: true + inputs: + source: 'specific' + project: '$(System.TeamProject)' + pipeline: 'TODO-dwsql-definition-id' + runVersion: 'latest' + patterns: '**/*.cobertura.xml' + path: '$(Pipeline.Workspace)/db/dwsql' + - task: DownloadPipelineArtifact@2 + displayName: 'Download CosmosDb coverage (latest)' + continueOnError: true + inputs: + source: 'specific' + project: '$(System.TeamProject)' + pipeline: 'TODO-cosmos-definition-id' + runVersion: 'latest' + patterns: '**/*.cobertura.xml' + path: '$(Pipeline.Workspace)/db/cosmos' + + - task: PowerShell@2 + displayName: 'Merge cobertura (union) and print combined %' + continueOnError: true + inputs: + filePath: 'scripts/merge-coverage.ps1' + arguments: '-Path "$(Pipeline.Workspace)"' + pwsh: true + + # Merge into one cobertura for the Code Coverage tab (test projects excluded). + - task: reportgenerator@5 + displayName: 'Generate merged cobertura' + continueOnError: true + inputs: + reports: '$(Pipeline.Workspace)/**/*.cobertura.xml' + targetdir: '$(Build.ArtifactStagingDirectory)/merged' + reporttypes: 'Cobertura;HtmlInline_AzurePipelines;TextSummary' + assemblyfilters: '-*.Tests' + + - task: PublishCodeCoverageResults@2 + displayName: 'Publish combined code coverage' + continueOnError: true + inputs: + summaryFileLocation: '$(Build.ArtifactStagingDirectory)/merged/Cobertura.xml' From 742b3282fe32d506600a86023f799d35cc7ab666 Mon Sep 17 00:00:00 2001 From: Aaron Burtle Date: Thu, 9 Jul 2026 13:25:08 -0700 Subject: [PATCH 06/13] Clarify combined coverage is a rolling dashboard, not a synchronized gate --- .pipelines/unittest-pipelines.yml | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/.pipelines/unittest-pipelines.yml b/.pipelines/unittest-pipelines.yml index 1fc69c9a09..d054e38087 100644 --- a/.pipelines/unittest-pipelines.yml +++ b/.pipelines/unittest-pipelines.yml @@ -33,23 +33,33 @@ jobs: - template: templates/build-pipelines.yml # --------------------------------------------------------------------------- -# Total Code Coverage +# Total Code Coverage (rolling dashboard - NOT a synchronized per-PR gate) # --------------------------------------------------------------------------- -# Merges THIS run's unit coverage with the latest coverage from the database -# integration pipelines (MsSql, PostgreSql, MySql, DwSql, CosmosDb) and publishes -# a single combined report. Each pipeline in isolation looks low (~30-43% branch) -# because they are complementary; the union is ~77%. +# Merges THIS run's unit coverage with the LATEST AVAILABLE coverage from the +# database integration pipelines (MsSql, PostgreSql, MySql, DwSql, CosmosDb) and +# publishes a single combined report. Each pipeline in isolation looks low +# (~30-43% branch) because they are complementary; the union is ~77%. # -# This job is BEST-EFFORT (continueOnError on the job and every step) so it can -# never fail or block unit CI. The DB coverage is the latest available run, so -# the combined number is a dashboard figure, not a per-PR-exact gate. +# IMPORTANT - there is intentionally NO cross-pipeline synchronization: +# * This job only waits for THIS pipeline's unit tests (dependsOn: build). +# * DB coverage is pulled with runVersion 'latest', i.e. whatever the most +# recent completed DB run produced - which may be an OLDER commit or a +# different PR. ADO offers no primitive to block until all six sibling runs +# finish for the same commit from inside one of them. +# * Therefore the combined number is a ROLLING DASHBOARD figure for +# visibility only. Do NOT treat it as a per-PR-exact gate. (A true per-PR +# guarantee would require consolidating all six suites into one pipeline +# with a dependsOn-all aggregation job.) +# +# This job is also BEST-EFFORT (continueOnError on the job and every step) so it +# can never fail or block unit CI. # # TODO before it produces DB coverage: replace each 'TODO--definition-id' # with the pipeline's numeric definition ID (open the pipeline in Azure DevOps; # the URL contains ...?definitionId=NN). Until then the job still runs and # publishes unit-only coverage. Requires the ReportGenerator ADO extension. - job: total_code_coverage - displayName: 'Total Code Coverage' + displayName: 'Total Code Coverage (rolling dashboard)' dependsOn: build condition: succeededOrFailed() continueOnError: true From 954bfb4caaa45c07bfe49113a3645d1c22522ccc Mon Sep 17 00:00:00 2001 From: Aaron Burtle Date: Thu, 9 Jul 2026 13:31:31 -0700 Subject: [PATCH 07/13] Drop ReportGenerator; merge script emits combined Cobertura; fill DB pipeline IDs --- .pipelines/unittest-pipelines.yml | 37 ++++++------- scripts/merge-coverage.ps1 | 92 ++++++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 23 deletions(-) diff --git a/.pipelines/unittest-pipelines.yml b/.pipelines/unittest-pipelines.yml index d054e38087..63e7b81324 100644 --- a/.pipelines/unittest-pipelines.yml +++ b/.pipelines/unittest-pipelines.yml @@ -54,10 +54,13 @@ jobs: # This job is also BEST-EFFORT (continueOnError on the job and every step) so it # can never fail or block unit CI. # -# TODO before it produces DB coverage: replace each 'TODO--definition-id' -# with the pipeline's numeric definition ID (open the pipeline in Azure DevOps; -# the URL contains ...?definitionId=NN). Until then the job still runs and -# publishes unit-only coverage. Requires the ReportGenerator ADO extension. +# The merge + report generation is done entirely by scripts/merge-coverage.ps1 +# (a dependency-free PowerShell union merger that emits a Cobertura file) - no +# ReportGenerator or other Marketplace extension is required. +# +# The DB 'pipeline' IDs below are the numeric definition IDs from Azure DevOps +# (pipeline URL ...?definitionId=NN). The db-name labels are cosmetic; only the +# numeric IDs matter for the download, and the merge is order-independent. - job: total_code_coverage displayName: 'Total Code Coverage (rolling dashboard)' dependsOn: build @@ -84,7 +87,7 @@ jobs: inputs: source: 'specific' project: '$(System.TeamProject)' - pipeline: 'TODO-mssql-definition-id' + pipeline: '1' runVersion: 'latest' patterns: '**/*.cobertura.xml' path: '$(Pipeline.Workspace)/db/mssql' @@ -94,7 +97,7 @@ jobs: inputs: source: 'specific' project: '$(System.TeamProject)' - pipeline: 'TODO-pg-definition-id' + pipeline: '2' runVersion: 'latest' patterns: '**/*.cobertura.xml' path: '$(Pipeline.Workspace)/db/pg' @@ -104,7 +107,7 @@ jobs: inputs: source: 'specific' project: '$(System.TeamProject)' - pipeline: 'TODO-mysql-definition-id' + pipeline: '3' runVersion: 'latest' patterns: '**/*.cobertura.xml' path: '$(Pipeline.Workspace)/db/mysql' @@ -114,7 +117,7 @@ jobs: inputs: source: 'specific' project: '$(System.TeamProject)' - pipeline: 'TODO-dwsql-definition-id' + pipeline: '4' runVersion: 'latest' patterns: '**/*.cobertura.xml' path: '$(Pipeline.Workspace)/db/dwsql' @@ -124,29 +127,21 @@ jobs: inputs: source: 'specific' project: '$(System.TeamProject)' - pipeline: 'TODO-cosmos-definition-id' + pipeline: '6' runVersion: 'latest' patterns: '**/*.cobertura.xml' path: '$(Pipeline.Workspace)/db/cosmos' + # Union-merge every downloaded cobertura and emit ONE combined Cobertura.xml + # (test projects excluded). Dependency-free - no ReportGenerator extension. - task: PowerShell@2 - displayName: 'Merge cobertura (union) and print combined %' + displayName: 'Merge cobertura (union) and emit combined report' continueOnError: true inputs: filePath: 'scripts/merge-coverage.ps1' - arguments: '-Path "$(Pipeline.Workspace)"' + arguments: '-Path "$(Pipeline.Workspace)" -OutFile "$(Build.ArtifactStagingDirectory)/merged/Cobertura.xml"' pwsh: true - # Merge into one cobertura for the Code Coverage tab (test projects excluded). - - task: reportgenerator@5 - displayName: 'Generate merged cobertura' - continueOnError: true - inputs: - reports: '$(Pipeline.Workspace)/**/*.cobertura.xml' - targetdir: '$(Build.ArtifactStagingDirectory)/merged' - reporttypes: 'Cobertura;HtmlInline_AzurePipelines;TextSummary' - assemblyfilters: '-*.Tests' - - task: PublishCodeCoverageResults@2 displayName: 'Publish combined code coverage' continueOnError: true diff --git a/scripts/merge-coverage.ps1 b/scripts/merge-coverage.ps1 index 01430c47bb..e92bf3c80a 100644 --- a/scripts/merge-coverage.ps1 +++ b/scripts/merge-coverage.ps1 @@ -31,7 +31,9 @@ param( [string]$Path, [string[]]$Files, # Package (assembly) names matching this regex are excluded (test projects by default). - [string]$ExcludePattern = '\.Tests$' + [string]$ExcludePattern = '\.Tests$', + # When set, writes a merged Cobertura XML to this path (for PublishCodeCoverageResults). + [string]$OutFile ) if ($Files) { @@ -52,8 +54,10 @@ if ($reportFiles.Count -eq 0) { exit 1 } -# key: "package|filename|lineNumber" -> merged coverage point +# key: "package|class|lineNumber" -> merged coverage point $points = @{} +# "package|class" -> representative filename (first non-empty seen) +$classFile = @{} foreach ($rf in $reportFiles) { try { @@ -69,7 +73,10 @@ foreach ($rf in $reportFiles) { if ($ExcludePattern -and $pkgName -match $ExcludePattern) { continue } foreach ($cls in $pkg.classes.class) { $clsName = [string]$cls.name + $clsFile = [string]$cls.filename if (-not $cls.lines) { continue } + $ckey = "$pkgName|$clsName" + if ($clsFile -and -not $classFile.ContainsKey($ckey)) { $classFile[$ckey] = $clsFile } foreach ($ln in $cls.lines.line) { if (-not $ln) { continue } $num = [int]$ln.number @@ -97,6 +104,8 @@ foreach ($rf in $reportFiles) { else { $points[$key] = [pscustomobject]@{ Package = $pkgName + Class = $clsName + Line = $num Hits = $hits IsBranch = $isBranch CondCov = $condCov @@ -145,3 +154,82 @@ Write-Host "==================== COMBINED (all pipelines) ====================" Write-Host " LINE : $covLines / $totLines = $LR%" Write-Host " BRANCH : $covBr / $totBr = $BR%" Write-Host "=================================================================" + +# --------------------------------------------------------------------------- +# Optionally emit a merged Cobertura XML (so PublishCodeCoverageResults can show +# the combined number in the Code Coverage tab - no ReportGenerator required). +# --------------------------------------------------------------------------- +if ($OutFile) { + function ConvertTo-XmlAttr([string]$s) { + if ($null -eq $s) { return '' } + return $s.Replace('&', '&').Replace('<', '<').Replace('>', '>').Replace('"', '"').Replace("'", ''') + } + + # Group merged points into package -> class -> lines. + $pkgTree = @{} + foreach ($e in $points.Values) { + if (-not $pkgTree.ContainsKey($e.Package)) { $pkgTree[$e.Package] = @{} } + $classes = $pkgTree[$e.Package] + if (-not $classes.ContainsKey($e.Class)) { $classes[$e.Class] = New-Object System.Collections.ArrayList } + [void]$classes[$e.Class].Add($e) + } + + $sb = New-Object System.Text.StringBuilder + [void]$sb.AppendLine('') + $lineRate = if ($totLines) { [math]::Round($covLines / $totLines, 4) } else { 0 } + $branchRate = if ($totBr) { [math]::Round($covBr / $totBr, 4) } else { 0 } + $ts = [int64]([datetime]::UtcNow - [datetime]'1970-01-01').TotalSeconds + [void]$sb.AppendLine("") + [void]$sb.AppendLine(' .') + [void]$sb.AppendLine(' ') + foreach ($pk in ($pkgTree.Keys | Sort-Object)) { + $classes = $pkgTree[$pk] + $pTL = 0; $pCL = 0; $pTB = 0; $pCB = 0 + foreach ($clist in $classes.Values) { + foreach ($e in $clist) { + $pTL++; if ($e.Hits -gt 0) { $pCL++ } + if ($e.IsBranch) { $pTB += $e.CondTot; $pCB += $e.CondCov } + } + } + $pLR = if ($pTL) { [math]::Round($pCL / $pTL, 4) } else { 0 } + $pBR = if ($pTB) { [math]::Round($pCB / $pTB, 4) } else { 0 } + [void]$sb.AppendLine((" " -f (ConvertTo-XmlAttr $pk), $pLR, $pBR)) + [void]$sb.AppendLine(' ') + foreach ($cn in ($classes.Keys | Sort-Object)) { + $clist = $classes[$cn] + $cTL = 0; $cCL = 0; $cTB = 0; $cCB = 0 + foreach ($e in $clist) { + $cTL++; if ($e.Hits -gt 0) { $cCL++ } + if ($e.IsBranch) { $cTB += $e.CondTot; $cCB += $e.CondCov } + } + $cLR = if ($cTL) { [math]::Round($cCL / $cTL, 4) } else { 0 } + $cBR = if ($cTB) { [math]::Round($cCB / $cTB, 4) } else { 0 } + $fn = '' + if ($classFile.ContainsKey("$pk|$cn")) { $fn = $classFile["$pk|$cn"] } + [void]$sb.AppendLine((" " -f (ConvertTo-XmlAttr $cn), (ConvertTo-XmlAttr $fn), $cLR, $cBR)) + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + foreach ($e in ($clist | Sort-Object Line)) { + if ($e.IsBranch) { + $pct = if ($e.CondTot) { [math]::Round($e.CondCov / $e.CondTot * 100, 0) } else { 0 } + [void]$sb.AppendLine((" " -f $e.Line, $e.Hits, $pct, $e.CondCov, $e.CondTot)) + } + else { + [void]$sb.AppendLine((" " -f $e.Line, $e.Hits)) + } + } + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + } + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + } + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine('') + + $dir = Split-Path -Parent $OutFile + if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + [System.IO.File]::WriteAllText($OutFile, $sb.ToString()) + Write-Host "" + Write-Host "Wrote merged Cobertura report: $OutFile" +} From f1986f1c6257e6e56f90376d22403e4dd3f6b39e Mon Sep 17 00:00:00 2001 From: Aaron Burtle Date: Thu, 9 Jul 2026 13:35:20 -0700 Subject: [PATCH 08/13] Correct DB pipeline ID mapping (DwSql=1, MsSql=2, MySql=3, PgSql=4, Cosmos=6) --- .pipelines/unittest-pipelines.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pipelines/unittest-pipelines.yml b/.pipelines/unittest-pipelines.yml index 63e7b81324..966d324e8b 100644 --- a/.pipelines/unittest-pipelines.yml +++ b/.pipelines/unittest-pipelines.yml @@ -87,7 +87,7 @@ jobs: inputs: source: 'specific' project: '$(System.TeamProject)' - pipeline: '1' + pipeline: '2' runVersion: 'latest' patterns: '**/*.cobertura.xml' path: '$(Pipeline.Workspace)/db/mssql' @@ -97,7 +97,7 @@ jobs: inputs: source: 'specific' project: '$(System.TeamProject)' - pipeline: '2' + pipeline: '4' runVersion: 'latest' patterns: '**/*.cobertura.xml' path: '$(Pipeline.Workspace)/db/pg' @@ -117,7 +117,7 @@ jobs: inputs: source: 'specific' project: '$(System.TeamProject)' - pipeline: '4' + pipeline: '1' runVersion: 'latest' patterns: '**/*.cobertura.xml' path: '$(Pipeline.Workspace)/db/dwsql' From 07b9f4b796b387a52b53a3c89bbbcb8e7a56cd26 Mon Sep 17 00:00:00 2001 From: Aaron Burtle Date: Thu, 9 Jul 2026 15:38:27 -0700 Subject: [PATCH 09/13] Publish raw cobertura as named artifact from each DB pipeline; download by name for aggregation --- .pipelines/cosmos-pipelines.yml | 15 +++++++++++++++ .pipelines/dwsql-pipelines.yml | 15 +++++++++++++++ .pipelines/mssql-pipelines.yml | 16 ++++++++++++++-- .pipelines/mysql-pipelines.yml | 15 +++++++++++++++ .pipelines/pg-pipelines.yml | 15 +++++++++++++++ .pipelines/unittest-pipelines.yml | 17 ++++++++++------- 6 files changed, 84 insertions(+), 9 deletions(-) diff --git a/.pipelines/cosmos-pipelines.yml b/.pipelines/cosmos-pipelines.yml index 0f5a7eb427..88addc4a88 100644 --- a/.pipelines/cosmos-pipelines.yml +++ b/.pipelines/cosmos-pipelines.yml @@ -142,3 +142,18 @@ steps: inputs: codeCoverageTool: Cobertura summaryFileLocation: '$(Agent.TempDirectory)/**/*cobertura.xml' + +# Publish the RAW cobertura as a named pipeline artifact so the unit pipeline's +# 'Total Code Coverage' job can download and union it. (PublishCodeCoverageResults@1 +# only exposes an HTML report artifact, not the raw XML, so aggregation needs this.) +- task: CopyFiles@2 + displayName: 'Stage raw coverage for aggregation' + inputs: + sourceFolder: '$(Agent.TempDirectory)' + contents: '**/*.cobertura.xml' + targetFolder: '$(Build.ArtifactStagingDirectory)/coverage' +- task: PublishPipelineArtifact@1 + displayName: 'Publish coverage artifact for aggregation' + inputs: + targetPath: '$(Build.ArtifactStagingDirectory)/coverage' + artifact: 'coverage-cosmos' diff --git a/.pipelines/dwsql-pipelines.yml b/.pipelines/dwsql-pipelines.yml index d8fe8d0455..b7f8a52fae 100644 --- a/.pipelines/dwsql-pipelines.yml +++ b/.pipelines/dwsql-pipelines.yml @@ -146,6 +146,21 @@ jobs: codeCoverageTool: Cobertura summaryFileLocation: '$(Agent.TempDirectory)/**/*cobertura.xml' + # Publish the RAW cobertura as a named pipeline artifact so the unit pipeline's + # 'Total Code Coverage' job can download and union it. (PublishCodeCoverageResults@1 + # only exposes an HTML report artifact, not the raw XML, so aggregation needs this.) + - task: CopyFiles@2 + displayName: 'Stage raw coverage for aggregation' + inputs: + sourceFolder: '$(Agent.TempDirectory)' + contents: '**/*.cobertura.xml' + targetFolder: '$(Build.ArtifactStagingDirectory)/coverage' + - task: PublishPipelineArtifact@1 + displayName: 'Publish coverage artifact for aggregation' + inputs: + targetPath: '$(Build.ArtifactStagingDirectory)/coverage' + artifact: 'coverage-dwsql' + - job: windows pool: diff --git a/.pipelines/mssql-pipelines.yml b/.pipelines/mssql-pipelines.yml index bdfb5e37e6..0086fed4bb 100644 --- a/.pipelines/mssql-pipelines.yml +++ b/.pipelines/mssql-pipelines.yml @@ -151,8 +151,20 @@ jobs: codeCoverageTool: Cobertura summaryFileLocation: '$(Agent.TempDirectory)/**/*cobertura.xml' - -# MsSql Integration Testing is split into two parallel jobs (~20 min each): + # Publish the RAW cobertura as a named pipeline artifact so the unit pipeline's + # 'Total Code Coverage' job can download and union it. (PublishCodeCoverageResults@1 + # only exposes an HTML report artifact, not the raw XML, so aggregation needs this.) + - task: CopyFiles@2 + displayName: 'Stage raw coverage for aggregation' + inputs: + sourceFolder: '$(Agent.TempDirectory)' + contents: '**/*.cobertura.xml' + targetFolder: '$(Build.ArtifactStagingDirectory)/coverage' + - task: PublishPipelineArtifact@1 + displayName: 'Publish coverage artifact for aggregation' + inputs: + targetPath: '$(Build.ArtifactStagingDirectory)/coverage' + artifact: 'coverage-mssql' # # 1) windows_combined -> GraphQL, HotReload, REST, Unit, OpenApi, Auth, # Telemetry, and Caching tests. diff --git a/.pipelines/mysql-pipelines.yml b/.pipelines/mysql-pipelines.yml index d25fbabe7c..838a59b5ca 100644 --- a/.pipelines/mysql-pipelines.yml +++ b/.pipelines/mysql-pipelines.yml @@ -145,3 +145,18 @@ jobs: inputs: codeCoverageTool: Cobertura summaryFileLocation: '$(Agent.TempDirectory)/**/*cobertura.xml' + + # Publish the RAW cobertura as a named pipeline artifact so the unit pipeline's + # 'Total Code Coverage' job can download and union it. (PublishCodeCoverageResults@1 + # only exposes an HTML report artifact, not the raw XML, so aggregation needs this.) + - task: CopyFiles@2 + displayName: 'Stage raw coverage for aggregation' + inputs: + sourceFolder: '$(Agent.TempDirectory)' + contents: '**/*.cobertura.xml' + targetFolder: '$(Build.ArtifactStagingDirectory)/coverage' + - task: PublishPipelineArtifact@1 + displayName: 'Publish coverage artifact for aggregation' + inputs: + targetPath: '$(Build.ArtifactStagingDirectory)/coverage' + artifact: 'coverage-mysql' diff --git a/.pipelines/pg-pipelines.yml b/.pipelines/pg-pipelines.yml index 14f290ecbc..7d686fd90a 100644 --- a/.pipelines/pg-pipelines.yml +++ b/.pipelines/pg-pipelines.yml @@ -140,3 +140,18 @@ jobs: inputs: codeCoverageTool: Cobertura summaryFileLocation: '$(Agent.TempDirectory)/**/*cobertura.xml' + + # Publish the RAW cobertura as a named pipeline artifact so the unit pipeline's + # 'Total Code Coverage' job can download and union it. (PublishCodeCoverageResults@1 + # only exposes an HTML report artifact, not the raw XML, so aggregation needs this.) + - task: CopyFiles@2 + displayName: 'Stage raw coverage for aggregation' + inputs: + sourceFolder: '$(Agent.TempDirectory)' + contents: '**/*.cobertura.xml' + targetFolder: '$(Build.ArtifactStagingDirectory)/coverage' + - task: PublishPipelineArtifact@1 + displayName: 'Publish coverage artifact for aggregation' + inputs: + targetPath: '$(Build.ArtifactStagingDirectory)/coverage' + artifact: 'coverage-pg' diff --git a/.pipelines/unittest-pipelines.yml b/.pipelines/unittest-pipelines.yml index 966d324e8b..45dd00a823 100644 --- a/.pipelines/unittest-pipelines.yml +++ b/.pipelines/unittest-pipelines.yml @@ -59,8 +59,11 @@ jobs: # ReportGenerator or other Marketplace extension is required. # # The DB 'pipeline' IDs below are the numeric definition IDs from Azure DevOps -# (pipeline URL ...?definitionId=NN). The db-name labels are cosmetic; only the -# numeric IDs matter for the download, and the merge is order-independent. +# (pipeline URL ...?definitionId=NN). Each DB pipeline publishes its RAW cobertura +# as a named 'coverage-' pipeline artifact (added in its own YAML); this job +# downloads those by name and unions them. NOTE: each DB pipeline must run once +# after that change merges before its 'coverage-' artifact exists - until then +# its download soft-fails (continueOnError) and it's simply omitted from the union. - job: total_code_coverage displayName: 'Total Code Coverage (rolling dashboard)' dependsOn: build @@ -89,7 +92,7 @@ jobs: project: '$(System.TeamProject)' pipeline: '2' runVersion: 'latest' - patterns: '**/*.cobertura.xml' + artifact: 'coverage-mssql' path: '$(Pipeline.Workspace)/db/mssql' - task: DownloadPipelineArtifact@2 displayName: 'Download PostgreSql coverage (latest)' @@ -99,7 +102,7 @@ jobs: project: '$(System.TeamProject)' pipeline: '4' runVersion: 'latest' - patterns: '**/*.cobertura.xml' + artifact: 'coverage-pg' path: '$(Pipeline.Workspace)/db/pg' - task: DownloadPipelineArtifact@2 displayName: 'Download MySql coverage (latest)' @@ -109,7 +112,7 @@ jobs: project: '$(System.TeamProject)' pipeline: '3' runVersion: 'latest' - patterns: '**/*.cobertura.xml' + artifact: 'coverage-mysql' path: '$(Pipeline.Workspace)/db/mysql' - task: DownloadPipelineArtifact@2 displayName: 'Download DwSql coverage (latest)' @@ -119,7 +122,7 @@ jobs: project: '$(System.TeamProject)' pipeline: '1' runVersion: 'latest' - patterns: '**/*.cobertura.xml' + artifact: 'coverage-dwsql' path: '$(Pipeline.Workspace)/db/dwsql' - task: DownloadPipelineArtifact@2 displayName: 'Download CosmosDb coverage (latest)' @@ -129,7 +132,7 @@ jobs: project: '$(System.TeamProject)' pipeline: '6' runVersion: 'latest' - patterns: '**/*.cobertura.xml' + artifact: 'coverage-cosmos' path: '$(Pipeline.Workspace)/db/cosmos' # Union-merge every downloaded cobertura and emit ONE combined Cobertura.xml From f4e07bb1c04efbc0e31b3c2cf0c8248fd263ec0b Mon Sep 17 00:00:00 2001 From: Aaron Burtle Date: Thu, 9 Jul 2026 16:57:11 -0700 Subject: [PATCH 10/13] Trim Total Code Coverage comment --- .pipelines/unittest-pipelines.yml | 33 +++---------------------------- 1 file changed, 3 insertions(+), 30 deletions(-) diff --git a/.pipelines/unittest-pipelines.yml b/.pipelines/unittest-pipelines.yml index 45dd00a823..51dcc09dbb 100644 --- a/.pipelines/unittest-pipelines.yml +++ b/.pipelines/unittest-pipelines.yml @@ -33,37 +33,10 @@ jobs: - template: templates/build-pipelines.yml # --------------------------------------------------------------------------- -# Total Code Coverage (rolling dashboard - NOT a synchronized per-PR gate) +# Total Code Coverage (rolling dashboard, not a per-PR gate) # --------------------------------------------------------------------------- -# Merges THIS run's unit coverage with the LATEST AVAILABLE coverage from the -# database integration pipelines (MsSql, PostgreSql, MySql, DwSql, CosmosDb) and -# publishes a single combined report. Each pipeline in isolation looks low -# (~30-43% branch) because they are complementary; the union is ~77%. -# -# IMPORTANT - there is intentionally NO cross-pipeline synchronization: -# * This job only waits for THIS pipeline's unit tests (dependsOn: build). -# * DB coverage is pulled with runVersion 'latest', i.e. whatever the most -# recent completed DB run produced - which may be an OLDER commit or a -# different PR. ADO offers no primitive to block until all six sibling runs -# finish for the same commit from inside one of them. -# * Therefore the combined number is a ROLLING DASHBOARD figure for -# visibility only. Do NOT treat it as a per-PR-exact gate. (A true per-PR -# guarantee would require consolidating all six suites into one pipeline -# with a dependsOn-all aggregation job.) -# -# This job is also BEST-EFFORT (continueOnError on the job and every step) so it -# can never fail or block unit CI. -# -# The merge + report generation is done entirely by scripts/merge-coverage.ps1 -# (a dependency-free PowerShell union merger that emits a Cobertura file) - no -# ReportGenerator or other Marketplace extension is required. -# -# The DB 'pipeline' IDs below are the numeric definition IDs from Azure DevOps -# (pipeline URL ...?definitionId=NN). Each DB pipeline publishes its RAW cobertura -# as a named 'coverage-' pipeline artifact (added in its own YAML); this job -# downloads those by name and unions them. NOTE: each DB pipeline must run once -# after that change merges before its 'coverage-' artifact exists - until then -# its download soft-fails (continueOnError) and it's simply omitted from the union. +# Unions this run's unit coverage with the latest DB coverage and publishes one +# combined report. Best-effort (continueOnError) so it never blocks unit CI. - job: total_code_coverage displayName: 'Total Code Coverage (rolling dashboard)' dependsOn: build From da9dd7793f3faac5b41244ac8c3c778cf3102b96 Mon Sep 17 00:00:00 2001 From: Aaron Burtle Date: Thu, 9 Jul 2026 17:19:21 -0700 Subject: [PATCH 11/13] Address review: capture Windows-job coverage, document branch% as floor, warn on absent sources, map pipeline IDs --- .pipelines/dwsql-pipelines.yml | 14 ++++++++ .pipelines/templates/mssql-test-steps.yml | 16 ++++++--- .pipelines/unittest-pipelines.yml | 20 +++++++---- scripts/merge-coverage.ps1 | 42 ++++++++++++++++------- 4 files changed, 70 insertions(+), 22 deletions(-) diff --git a/.pipelines/dwsql-pipelines.yml b/.pipelines/dwsql-pipelines.yml index b7f8a52fae..acafe817b8 100644 --- a/.pipelines/dwsql-pipelines.yml +++ b/.pipelines/dwsql-pipelines.yml @@ -266,6 +266,20 @@ jobs: codeCoverageTool: Cobertura summaryFileLocation: '$(Agent.TempDirectory)/**/*cobertura.xml' + # Publish the RAW cobertura as a uniquely-named pipeline artifact so the unit + # pipeline's 'Total Code Coverage' job can union the Windows job's coverage too. + - task: CopyFiles@2 + displayName: 'Stage raw coverage for aggregation' + inputs: + sourceFolder: '$(Agent.TempDirectory)' + contents: '**/*.cobertura.xml' + targetFolder: '$(Build.ArtifactStagingDirectory)/coverage' + - task: PublishPipelineArtifact@1 + displayName: 'Publish coverage artifact for aggregation' + inputs: + targetPath: '$(Build.ArtifactStagingDirectory)/coverage' + artifact: 'coverage-dwsql-windows' + - task: CopyFiles@2 condition: eq(variables['publishverify'], 'Yes') displayName: 'Copy received files to Artifact Staging' diff --git a/.pipelines/templates/mssql-test-steps.yml b/.pipelines/templates/mssql-test-steps.yml index 565273f27c..b6bb24aa18 100644 --- a/.pipelines/templates/mssql-test-steps.yml +++ b/.pipelines/templates/mssql-test-steps.yml @@ -142,12 +142,20 @@ steps: codeCoverageTool: Cobertura summaryFileLocation: '$(Agent.TempDirectory)/**/*cobertura.xml' +# Publish the RAW cobertura as a uniquely-named pipeline artifact (suffixed per job) +# so the unit pipeline's 'Total Code Coverage' job can union THIS job's coverage too. - task: CopyFiles@2 - condition: eq(variables['publishverify'], 'Yes') - displayName: 'Copy received files to Artifact Staging' + displayName: 'Stage raw coverage for aggregation' + inputs: + sourceFolder: '$(Agent.TempDirectory)' + contents: '**/*.cobertura.xml' + targetFolder: '$(Build.ArtifactStagingDirectory)/coverage' +- task: PublishPipelineArtifact@1 + displayName: 'Publish coverage artifact for aggregation' inputs: - contents: '**\*.received.*' - targetFolder: '$(Build.ArtifactStagingDirectory)\Verify' + targetPath: '$(Build.ArtifactStagingDirectory)/coverage' + artifact: 'coverage-mssql${{ parameters.artifactSuffix }}' + cleanTargetFolder: true overWrite: true diff --git a/.pipelines/unittest-pipelines.yml b/.pipelines/unittest-pipelines.yml index 51dcc09dbb..31a09436b8 100644 --- a/.pipelines/unittest-pipelines.yml +++ b/.pipelines/unittest-pipelines.yml @@ -57,6 +57,14 @@ jobs: patterns: '**/*.cobertura.xml' path: '$(Pipeline.Workspace)/unit' + # DB coverage: each pipeline's numeric definition ID (Azure DevOps pipeline URL + # ...?definitionId=NN). Mapping: DwSql=1, MsSql=2, MySql=3, PgSql=4, Cosmos=6. + # (5 is THIS unit pipeline, so it is not downloaded here.) If a pipeline is + # recreated its ID changes and that DB silently drops from the union - the merge + # step's -ExpectedSources warning flags any source that produced no files. + # No 'artifact:' name is set so ALL of a run's coverage artifacts are pulled + # (some DBs publish one per job, e.g. MsSql linux + windows_combined + + # windows_configuration); the pattern keeps only raw cobertura for the merger. - task: DownloadPipelineArtifact@2 displayName: 'Download MsSql coverage (latest)' continueOnError: true @@ -65,7 +73,7 @@ jobs: project: '$(System.TeamProject)' pipeline: '2' runVersion: 'latest' - artifact: 'coverage-mssql' + patterns: '**/*.cobertura.xml' path: '$(Pipeline.Workspace)/db/mssql' - task: DownloadPipelineArtifact@2 displayName: 'Download PostgreSql coverage (latest)' @@ -75,7 +83,7 @@ jobs: project: '$(System.TeamProject)' pipeline: '4' runVersion: 'latest' - artifact: 'coverage-pg' + patterns: '**/*.cobertura.xml' path: '$(Pipeline.Workspace)/db/pg' - task: DownloadPipelineArtifact@2 displayName: 'Download MySql coverage (latest)' @@ -85,7 +93,7 @@ jobs: project: '$(System.TeamProject)' pipeline: '3' runVersion: 'latest' - artifact: 'coverage-mysql' + patterns: '**/*.cobertura.xml' path: '$(Pipeline.Workspace)/db/mysql' - task: DownloadPipelineArtifact@2 displayName: 'Download DwSql coverage (latest)' @@ -95,7 +103,7 @@ jobs: project: '$(System.TeamProject)' pipeline: '1' runVersion: 'latest' - artifact: 'coverage-dwsql' + patterns: '**/*.cobertura.xml' path: '$(Pipeline.Workspace)/db/dwsql' - task: DownloadPipelineArtifact@2 displayName: 'Download CosmosDb coverage (latest)' @@ -105,7 +113,7 @@ jobs: project: '$(System.TeamProject)' pipeline: '6' runVersion: 'latest' - artifact: 'coverage-cosmos' + patterns: '**/*.cobertura.xml' path: '$(Pipeline.Workspace)/db/cosmos' # Union-merge every downloaded cobertura and emit ONE combined Cobertura.xml @@ -115,7 +123,7 @@ jobs: continueOnError: true inputs: filePath: 'scripts/merge-coverage.ps1' - arguments: '-Path "$(Pipeline.Workspace)" -OutFile "$(Build.ArtifactStagingDirectory)/merged/Cobertura.xml"' + arguments: '-Path "$(Pipeline.Workspace)" -OutFile "$(Build.ArtifactStagingDirectory)/merged/Cobertura.xml" -ExpectedSources unit,mssql,pg,mysql,dwsql,cosmos' pwsh: true - task: PublishCodeCoverageResults@2 diff --git a/scripts/merge-coverage.ps1 b/scripts/merge-coverage.ps1 index e92bf3c80a..3e4549e1dc 100644 --- a/scripts/merge-coverage.ps1 +++ b/scripts/merge-coverage.ps1 @@ -4,13 +4,18 @@ postgres, mysql, cosmos, dwsql) into a single combined line/branch coverage number. .DESCRIPTION - Azure DevOps' PublishCodeCoverageResults@1 renders a per-pipeline coverage tab but does - not merge across pipelines. Download each pipeline run's raw *.cobertura.xml (from the - "Code Coverage Report_" artifact) into one folder, then run this script. - - Merge semantics (union): a source line is covered if it is hit in ANY report; a line's - covered branch-conditions are the MAX seen across reports (denominator is fixed per line). - This matches how ReportGenerator unions reports when per-condition detail is absent. + Azure DevOps' PublishCodeCoverageResults renders a per-pipeline coverage tab but does not + merge across pipelines. Download each pipeline run's raw *.cobertura.xml into one folder + (or pass -Files) and run this script to produce a single combined report. + + Merge semantics: + * LINE coverage is an EXACT union - a source line is covered if it is hit in ANY report. + * BRANCH coverage is a conservative FLOOR (lower bound), NOT an exact union. Coverlet's + Cobertura reports only an aggregate "(covered/total)" per line, not which specific + branch was taken, so when two reports each cover a DIFFERENT branch of the same line we + cannot prove the union (e.g. (1/2) + (1/2) may really be 2/2 but we report 1/2). We take + the MAX (covered/total) seen across reports: this never over-counts but can under-count. + Treat the combined branch % as "at least this". Line % is the accurate headline metric. Zero external dependencies (no reportgenerator / no az CLI required). @@ -33,7 +38,10 @@ param( # Package (assembly) names matching this regex are excluded (test projects by default). [string]$ExcludePattern = '\.Tests$', # When set, writes a merged Cobertura XML to this path (for PublishCodeCoverageResults). - [string]$OutFile + [string]$OutFile, + # Optional path substrings (e.g. 'unit','mssql','cosmos'). A warning is printed for each + # that matches none of the discovered files, so a silently-missing source is noticeable. + [string[]]$ExpectedSources ) if ($Files) { @@ -51,7 +59,17 @@ else { $reportFiles = @($reportFiles | Where-Object { $_ -and (Test-Path $_) }) if ($reportFiles.Count -eq 0) { Write-Error "No cobertura files found." - exit 1 + exit 1 # intentional: fail this task when there is nothing to merge (CI job is continueOnError) +} + +# Surface silently-missing sources: a DB whose artifact failed to download simply +# vanishes from the union, so warn loudly for each expected source with no files. +if ($ExpectedSources) { + foreach ($src in $ExpectedSources) { + if (-not ($reportFiles | Where-Object { $_ -like "*$src*" })) { + Write-Warning "Expected coverage source '$src' not found - it is ABSENT from the combined union." + } + } } # key: "package|class|lineNumber" -> merged coverage point @@ -80,7 +98,7 @@ foreach ($rf in $reportFiles) { foreach ($ln in $cls.lines.line) { if (-not $ln) { continue } $num = [int]$ln.number - $hits = [int]$ln.hits + $hits = [long]$ln.hits $isBranch = ([string]$ln.branch -eq 'true') $condCov = 0 $condTot = 0 @@ -151,8 +169,8 @@ Write-Host "" $LR = if ($totLines) { [math]::Round($covLines / $totLines * 100, 2) } else { 0 } $BR = if ($totBr) { [math]::Round($covBr / $totBr * 100, 2) } else { 0 } Write-Host "==================== COMBINED (all pipelines) ====================" -Write-Host " LINE : $covLines / $totLines = $LR%" -Write-Host " BRANCH : $covBr / $totBr = $BR%" +Write-Host " LINE : $covLines / $totLines = $LR% (exact union)" +Write-Host " BRANCH : $covBr / $totBr = $BR% (floor / lower bound - see script header)" Write-Host "=================================================================" # --------------------------------------------------------------------------- From 147a0d03df7991442bfb3726fa6cce2fb97accec Mon Sep 17 00:00:00 2001 From: Aaron Burtle Date: Thu, 9 Jul 2026 19:04:35 -0700 Subject: [PATCH 12/13] Fix mssql template regression (restore received-files copy); exact per-edge branch union with floor fallback; guard 0/0 and de-dup inputs --- .pipelines/templates/mssql-test-steps.yml | 6 ++ scripts/merge-coverage.ps1 | 74 ++++++++++++++++++----- 2 files changed, 65 insertions(+), 15 deletions(-) diff --git a/.pipelines/templates/mssql-test-steps.yml b/.pipelines/templates/mssql-test-steps.yml index b6bb24aa18..9a3efa436f 100644 --- a/.pipelines/templates/mssql-test-steps.yml +++ b/.pipelines/templates/mssql-test-steps.yml @@ -156,6 +156,12 @@ steps: targetPath: '$(Build.ArtifactStagingDirectory)/coverage' artifact: 'coverage-mssql${{ parameters.artifactSuffix }}' +- task: CopyFiles@2 + condition: eq(variables['publishverify'], 'Yes') + displayName: 'Copy received files to Artifact Staging' + inputs: + contents: '**\*.received.*' + targetFolder: '$(Build.ArtifactStagingDirectory)\Verify' cleanTargetFolder: true overWrite: true diff --git a/scripts/merge-coverage.ps1 b/scripts/merge-coverage.ps1 index 3e4549e1dc..51a622b529 100644 --- a/scripts/merge-coverage.ps1 +++ b/scripts/merge-coverage.ps1 @@ -10,12 +10,13 @@ Merge semantics: * LINE coverage is an EXACT union - a source line is covered if it is hit in ANY report. - * BRANCH coverage is a conservative FLOOR (lower bound), NOT an exact union. Coverlet's - Cobertura reports only an aggregate "(covered/total)" per line, not which specific - branch was taken, so when two reports each cover a DIFFERENT branch of the same line we - cannot prove the union (e.g. (1/2) + (1/2) may really be 2/2 but we report 1/2). We take - the MAX (covered/total) seen across reports: this never over-counts but can under-count. - Treat the combined branch % as "at least this". Line % is the accurate headline metric. + * BRANCH coverage is an EXACT union WHEN per-edge detail is available. Coverlet may emit + children - one per + edge. When present (their count matches the aggregate denominator) we OR each edge's + covered flag across reports for a true union. When only the aggregate "(covered/total)" + is available we fall back to MAX(covered/total): a conservative FLOOR that never + over-counts but can under-count (e.g. (1/2)+(1/2) covering different edges is really 2/2 + but reports 1/2). Line % is always exact and is the headline metric. Zero external dependencies (no reportgenerator / no az CLI required). @@ -56,7 +57,8 @@ else { exit 1 } -$reportFiles = @($reportFiles | Where-Object { $_ -and (Test-Path $_) }) +$reportFiles = @($reportFiles | Where-Object { $_ -and (Test-Path $_) } | + ForEach-Object { (Resolve-Path -LiteralPath $_).Path } | Select-Object -Unique) if ($reportFiles.Count -eq 0) { Write-Error "No cobertura files found." exit 1 # intentional: fail this task when there is nothing to merge (CI job is continueOnError) @@ -107,6 +109,23 @@ foreach ($rf in $reportFiles) { $condTot = [int]$Matches[2] } + # Per-edge branch detail: coverlet may emit one + # per edge. When their count equals the aggregate denominator we can OR each edge's + # covered flag across reports for a TRUE union (instead of MAX-ing aggregate counts, + # which under-counts when two reports cover different edges of the same line). + $edges = $null + if ($isBranch -and $ln.conditions -and $ln.conditions.condition) { + $condEls = @($ln.conditions.condition) + if ($condTot -gt 0 -and $condEls.Count -eq $condTot) { + $edges = @{} + foreach ($c in $condEls) { + $covPct = 0 + if (([string]$c.coverage) -match '(\d+)') { $covPct = [int]$Matches[1] } + $edges[[int]$c.number] = ($covPct -ge 100) + } + } + } + # Key by FQ class name (agent-path independent) + line number so the same # source point unions across reports built on different agents (Windows vs Linux). $key = "$pkgName|$clsName|$num" @@ -117,6 +136,12 @@ foreach ($rf in $reportFiles) { $e.IsBranch = $true if ($condCov -gt $e.CondCov) { $e.CondCov = $condCov } if ($condTot -gt $e.CondTot) { $e.CondTot = $condTot } + if ($edges) { + if (-not $e.Edges) { $e.Edges = @{} } + foreach ($n in $edges.Keys) { + $e.Edges[$n] = ([bool]$e.Edges[$n]) -or $edges[$n] + } + } } } else { @@ -128,6 +153,7 @@ foreach ($rf in $reportFiles) { IsBranch = $isBranch CondCov = $condCov CondTot = $condTot + Edges = $edges } } } @@ -135,6 +161,24 @@ foreach ($rf in $reportFiles) { } } +# Effective branch cov/tot per point: exact union from per-edge detail when available, +# else the aggregate MAX (a conservative floor). +foreach ($e in $points.Values) { + if ($e.IsBranch -and $e.Edges -and $e.Edges.Count -gt 0) { + $cov = 0; foreach ($v in $e.Edges.Values) { if ($v) { $cov++ } } + $e | Add-Member -NotePropertyName EffTot -NotePropertyValue $e.Edges.Count -Force + $e | Add-Member -NotePropertyName EffCov -NotePropertyValue $cov -Force + } + elseif ($e.IsBranch) { + $e | Add-Member -NotePropertyName EffTot -NotePropertyValue $e.CondTot -Force + $e | Add-Member -NotePropertyName EffCov -NotePropertyValue $e.CondCov -Force + } + else { + $e | Add-Member -NotePropertyName EffTot -NotePropertyValue 0 -Force + $e | Add-Member -NotePropertyName EffCov -NotePropertyValue 0 -Force + } +} + # Aggregate overall and per-assembly. $byPkg = @{} $totLines = 0; $covLines = 0; $totBr = 0; $covBr = 0 @@ -142,7 +186,7 @@ $totLines = 0; $covLines = 0; $totBr = 0; $covBr = 0 foreach ($e in $points.Values) { $totLines++ if ($e.Hits -gt 0) { $covLines++ } - if ($e.IsBranch) { $totBr += $e.CondTot; $covBr += $e.CondCov } + if ($e.IsBranch) { $totBr += $e.EffTot; $covBr += $e.EffCov } if (-not $byPkg.ContainsKey($e.Package)) { $byPkg[$e.Package] = [pscustomobject]@{ TL = 0; CL = 0; TB = 0; CB = 0 } @@ -150,7 +194,7 @@ foreach ($e in $points.Values) { $p = $byPkg[$e.Package] $p.TL++ if ($e.Hits -gt 0) { $p.CL++ } - if ($e.IsBranch) { $p.TB += $e.CondTot; $p.CB += $e.CondCov } + if ($e.IsBranch) { $p.TB += $e.EffTot; $p.CB += $e.EffCov } } Write-Host "" @@ -170,7 +214,7 @@ $LR = if ($totLines) { [math]::Round($covLines / $totLines * 100, 2) } else { 0 $BR = if ($totBr) { [math]::Round($covBr / $totBr * 100, 2) } else { 0 } Write-Host "==================== COMBINED (all pipelines) ====================" Write-Host " LINE : $covLines / $totLines = $LR% (exact union)" -Write-Host " BRANCH : $covBr / $totBr = $BR% (floor / lower bound - see script header)" +Write-Host " BRANCH : $covBr / $totBr = $BR% (exact where per-edge detail present, else floor)" Write-Host "=================================================================" # --------------------------------------------------------------------------- @@ -206,7 +250,7 @@ if ($OutFile) { foreach ($clist in $classes.Values) { foreach ($e in $clist) { $pTL++; if ($e.Hits -gt 0) { $pCL++ } - if ($e.IsBranch) { $pTB += $e.CondTot; $pCB += $e.CondCov } + if ($e.IsBranch) { $pTB += $e.EffTot; $pCB += $e.EffCov } } } $pLR = if ($pTL) { [math]::Round($pCL / $pTL, 4) } else { 0 } @@ -218,7 +262,7 @@ if ($OutFile) { $cTL = 0; $cCL = 0; $cTB = 0; $cCB = 0 foreach ($e in $clist) { $cTL++; if ($e.Hits -gt 0) { $cCL++ } - if ($e.IsBranch) { $cTB += $e.CondTot; $cCB += $e.CondCov } + if ($e.IsBranch) { $cTB += $e.EffTot; $cCB += $e.EffCov } } $cLR = if ($cTL) { [math]::Round($cCL / $cTL, 4) } else { 0 } $cBR = if ($cTB) { [math]::Round($cCB / $cTB, 4) } else { 0 } @@ -228,9 +272,9 @@ if ($OutFile) { [void]$sb.AppendLine(' ') [void]$sb.AppendLine(' ') foreach ($e in ($clist | Sort-Object Line)) { - if ($e.IsBranch) { - $pct = if ($e.CondTot) { [math]::Round($e.CondCov / $e.CondTot * 100, 0) } else { 0 } - [void]$sb.AppendLine((" " -f $e.Line, $e.Hits, $pct, $e.CondCov, $e.CondTot)) + if ($e.IsBranch -and $e.EffTot -gt 0) { + $pct = [math]::Round($e.EffCov / $e.EffTot * 100, 0) + [void]$sb.AppendLine((" " -f $e.Line, $e.Hits, $pct, $e.EffCov, $e.EffTot)) } else { [void]$sb.AppendLine((" " -f $e.Line, $e.Hits)) From 1e662f6efb2142ede6da865ab4307edd823840ae Mon Sep 17 00:00:00 2001 From: Aaron Burtle Date: Thu, 9 Jul 2026 19:14:59 -0700 Subject: [PATCH 13/13] Take max of per-edge union and aggregate max so mixed sources never report below either bound --- scripts/merge-coverage.ps1 | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/merge-coverage.ps1 b/scripts/merge-coverage.ps1 index 51a622b529..ac6c93379b 100644 --- a/scripts/merge-coverage.ps1 +++ b/scripts/merge-coverage.ps1 @@ -161,13 +161,14 @@ foreach ($rf in $reportFiles) { } } -# Effective branch cov/tot per point: exact union from per-edge detail when available, -# else the aggregate MAX (a conservative floor). +# Effective branch cov/tot per point. The per-edge union and the aggregate MAX are two +# INDEPENDENT lower bounds of the true union; take the max of each (still never over-counts, +# since each is <= the true union) so an aggregate-only report proving more is not discarded. foreach ($e in $points.Values) { if ($e.IsBranch -and $e.Edges -and $e.Edges.Count -gt 0) { $cov = 0; foreach ($v in $e.Edges.Values) { if ($v) { $cov++ } } - $e | Add-Member -NotePropertyName EffTot -NotePropertyValue $e.Edges.Count -Force - $e | Add-Member -NotePropertyName EffCov -NotePropertyValue $cov -Force + $e | Add-Member -NotePropertyName EffTot -NotePropertyValue ([math]::Max($e.Edges.Count, $e.CondTot)) -Force + $e | Add-Member -NotePropertyName EffCov -NotePropertyValue ([math]::Max($cov, $e.CondCov)) -Force } elseif ($e.IsBranch) { $e | Add-Member -NotePropertyName EffTot -NotePropertyValue $e.CondTot -Force