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..acafe817b8 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:
@@ -251,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/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/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/templates/mssql-test-steps.yml b/.pipelines/templates/mssql-test-steps.yml
index 565273f27c..9a3efa436f 100644
--- a/.pipelines/templates/mssql-test-steps.yml
+++ b/.pipelines/templates/mssql-test-steps.yml
@@ -142,6 +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
+ 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${{ parameters.artifactSuffix }}'
+
- task: CopyFiles@2
condition: eq(variables['publishverify'], 'Yes')
displayName: 'Copy received files to Artifact Staging'
diff --git a/.pipelines/unittest-pipelines.yml b/.pipelines/unittest-pipelines.yml
index 56357a181c..31a09436b8 100644
--- a/.pipelines/unittest-pipelines.yml
+++ b/.pipelines/unittest-pipelines.yml
@@ -21,11 +21,113 @@ 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 (rolling dashboard, not a per-PR gate)
+# ---------------------------------------------------------------------------
+# 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
+ 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'
+
+ # 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
+ inputs:
+ source: 'specific'
+ project: '$(System.TeamProject)'
+ pipeline: '2'
+ 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: '4'
+ 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: '3'
+ 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: '1'
+ 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: '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 emit combined report'
+ continueOnError: true
+ inputs:
+ filePath: 'scripts/merge-coverage.ps1'
+ arguments: '-Path "$(Pipeline.Workspace)" -OutFile "$(Build.ArtifactStagingDirectory)/merged/Cobertura.xml" -ExpectedSources unit,mssql,pg,mysql,dwsql,cosmos'
+ pwsh: true
+
+ - task: PublishCodeCoverageResults@2
+ displayName: 'Publish combined code coverage'
+ continueOnError: true
+ 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..ac6c93379b
--- /dev/null
+++ b/scripts/merge-coverage.ps1
@@ -0,0 +1,298 @@
+<#
+.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 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 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).
+
+.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$',
+ # When set, writes a merged Cobertura XML to this path (for PublishCodeCoverageResults).
+ [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) {
+ $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 $_) } |
+ 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)
+}
+
+# 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
+$points = @{}
+# "package|class" -> representative filename (first non-empty seen)
+$classFile = @{}
+
+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
+ $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
+ $hits = [long]$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]
+ }
+
+ # 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"
+ 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 }
+ if ($edges) {
+ if (-not $e.Edges) { $e.Edges = @{} }
+ foreach ($n in $edges.Keys) {
+ $e.Edges[$n] = ([bool]$e.Edges[$n]) -or $edges[$n]
+ }
+ }
+ }
+ }
+ else {
+ $points[$key] = [pscustomobject]@{
+ Package = $pkgName
+ Class = $clsName
+ Line = $num
+ Hits = $hits
+ IsBranch = $isBranch
+ CondCov = $condCov
+ CondTot = $condTot
+ Edges = $edges
+ }
+ }
+ }
+ }
+ }
+}
+
+# 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 ([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
+ $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
+
+foreach ($e in $points.Values) {
+ $totLines++
+ if ($e.Hits -gt 0) { $covLines++ }
+ 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 }
+ }
+ $p = $byPkg[$e.Package]
+ $p.TL++
+ if ($e.Hits -gt 0) { $p.CL++ }
+ if ($e.IsBranch) { $p.TB += $e.EffTot; $p.CB += $e.EffCov }
+}
+
+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% (exact union)"
+Write-Host " BRANCH : $covBr / $totBr = $BR% (exact where per-edge detail present, else floor)"
+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.EffTot; $pCB += $e.EffCov }
+ }
+ }
+ $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.EffTot; $cCB += $e.EffCov }
+ }
+ $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 -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))
+ }
+ }
+ [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"
+}