diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f5b53ba0..2f748f89 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -125,11 +125,105 @@ jobs: name: L0 静态门禁 (pre-commit) runs-on: windows steps: - - uses: actions/checkout@v4 - with: - # 钩子要对比基线与历史(doc-links 等读工作区即可,但 gitlint/cubemx 需完整历史) - fetch-depth: 0 - + - name: 检出源码(原生 git;不依赖 github.com 上的 action) + shell: powershell + env: + DTU_REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git + DTU_REPO_REF: ${{ github.ref }} + DTU_REPO_SHA: ${{ github.sha }} + DTU_REPO_TOKEN: ${{ github.token }} + run: | + # Pure ASCII only (see file header). + # + # WHY NOT `uses: actions/checkout`: the runner resolves every `uses:` by + # contacting the actions URL during "Prepare all required actions", BEFORE + # the first step runs. On this intranet runner that call intermittently dies + # with a connect timeout -- runs 36 (10-21) / 41 (10-25) / 42 (10-26) each + # burned ~45 s and produced ZERO hook output, while a manual curl to the same + # URL minutes later returned 200. Manual reachability is not CI health: the + # resolver runs as SYSTEM and the runner caches nothing across jobs (each job + # re-resolves into its own .cache/act/). Gitea 1.25.4 also removed the + # custom DEFAULT_ACTIONS_URL knob, so the only fix this repo owns is to stop + # depending on the public resolver at all. + # + # KEEP IN SYNC with the same step in the other job. This logic deliberately + # does NOT live in scripts/*.ps1 (unlike the Python / MSVC probes): the file + # is not checked out yet at the moment it would be needed -- chicken and egg. + # + # NOTE on $ErrorActionPreference: left at Continue on purpose. Under Windows + # PowerShell 5.1 a native command that writes to stderr while EAP is Stop + # raises NativeCommandError -- git legitimately writes progress to stderr. + if (-not $env:DTU_REPO_SHA) { Write-Host '::error::DTU_REPO_SHA is empty'; exit 1 } + if (-not $env:DTU_REPO_URL) { Write-Host '::error::DTU_REPO_URL is empty'; exit 1 } + $ws = (Get-Location).Path + Write-Host "workspace: $ws" + if (-not (Test-Path -LiteralPath (Join-Path $ws '.git'))) { + git init -q . + if ($LASTEXITCODE -ne 0) { Write-Host '::error::git init failed'; exit 1 } + } + $rem = git remote get-url origin 2>$null + if ($LASTEXITCODE -eq 0 -and $rem) { + git remote set-url origin $env:DTU_REPO_URL + } else { + git remote add origin $env:DTU_REPO_URL + } + if ($LASTEXITCODE -ne 0) { Write-Host '::error::configuring remote origin failed'; exit 1 } + # Full refspec, NOT --depth 1: the commit-message gate resolves base..head + # ranges and several gates diff against history, so a shallow clone turns + # them into "unknown revision" failures. Tags are skipped on purpose: this + # repository has zero tags and no script uses `git describe --tags` + # (measured 2026-10-27). + git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*' + # Never let a fetch wait for a human: the runner has no console and a + # credential prompt would burn the whole 30 m job timeout while looking like + # a hung CI. Terminal prompts off + no credential helper means an auth + # problem fails immediately with a message instead of blocking. Anonymous + # fetch works here (the repository is public; measured HTTP 200 on + # /info/refs?service=git-upload-pack), so CI must not start depending on a + # credential store it cannot answer. + $env:GIT_TERMINAL_PROMPT = '0' + # The token, when the instance provides one, travels only as a per-invocation + # -c override: it never lands in .git/config and is never echoed in logs. + # Anonymous fetch works too (the repository is public), so CI does not start + # depending on token availability. + $auth = @('-c', 'credential.helper=', '-c', 'credential.interactive=false') + if ($env:DTU_REPO_TOKEN) { + # Same scheme actions/checkout uses against Gitea git-http: Basic with a + # placeholder user and the token as the password. (Bearer is the GitHub + # *API* style, not the git-http style.) Honest caveat: this branch CANNOT + # be verified here -- the repository is public, so an anonymous fetch + # succeeds and any token we fabricate locally only proves a bad one is + # rejected. If the repository ever goes private this is the first line to + # check; the failure mode is a loud rc=1, never a silent fallback. + $pair = 'x-access-token:' + $env:DTU_REPO_TOKEN + $b64 = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($pair)) + $auth += @('-c', ('http.extraheader=AUTHORIZATION: Basic ' + $b64)) + } + # PS 5.1 wraps each native stderr line into an ErrorRecord, turning two real + # log lines into eight of CategoryInfo/FullyQualifiedErrorId noise. Flattening + # with "$_" keeps the log readable; $LASTEXITCODE is unaffected. + $fetchArgs = $auth + @('fetch', '--prune', '--no-tags', 'origin') + & git @fetchArgs 2>&1 | ForEach-Object { "$_" } + if ($LASTEXITCODE -ne 0) { Write-Host "::error::git fetch origin failed (exit $LASTEXITCODE)"; exit 1 } + # Some refs (PR merge commits, refs outside refs/heads) are not covered by the + # branch refspec -- fall back to an explicit ref fetch, then INSIST the wanted + # SHA is really present, rather than silently building the wrong tree. + git rev-parse --verify -q ($env:DTU_REPO_SHA + '^{commit}') 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Host 'sha not covered by the branch refspec -- fetching the event ref explicitly' + $refArgs = $auth + @('fetch', '--prune', '--no-tags', 'origin', $env:DTU_REPO_REF) + & git @refArgs 2>&1 | ForEach-Object { "$_" } + } + git rev-parse --verify -q ($env:DTU_REPO_SHA + '^{commit}') 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Host ('::error::' + $env:DTU_REPO_SHA + ' is not present in the fetched history'); exit 1 } + & git checkout -q --detach $env:DTU_REPO_SHA 2>&1 | ForEach-Object { "$_" } + if ($LASTEXITCODE -ne 0) { Write-Host '::error::detached checkout failed'; exit 1 } + git reset --hard -q $env:DTU_REPO_SHA + if ($LASTEXITCODE -ne 0) { Write-Host '::error::reset --hard failed'; exit 1 } + # -ffd, deliberately not -ffdx: -x would delete IGNORED files, and the runner + # reuses those across runs (pip / pre-commit caches live in the workspace). + git clean -ffd -q + Write-Host ('checkout ok: ' + (git rev-parse --short HEAD) + ' | commits=' + (git rev-list --count HEAD) + ' | dirty_files=' + ((git status --porcelain | Measure-Object).Count)) - name: 环境自检(Python / cppcheck / pre-commit) shell: powershell run: | @@ -399,8 +493,105 @@ jobs: name: L0.5 主机侧单测 (MSVC) runs-on: windows steps: - - uses: actions/checkout@v4 - + - name: 检出源码(原生 git;与 l0-gates 同一实现口径) + shell: powershell + env: + DTU_REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git + DTU_REPO_REF: ${{ github.ref }} + DTU_REPO_SHA: ${{ github.sha }} + DTU_REPO_TOKEN: ${{ github.token }} + run: | + # Pure ASCII only (see file header). + # + # WHY NOT `uses: actions/checkout`: the runner resolves every `uses:` by + # contacting the actions URL during "Prepare all required actions", BEFORE + # the first step runs. On this intranet runner that call intermittently dies + # with a connect timeout -- runs 36 (10-21) / 41 (10-25) / 42 (10-26) each + # burned ~45 s and produced ZERO hook output, while a manual curl to the same + # URL minutes later returned 200. Manual reachability is not CI health: the + # resolver runs as SYSTEM and the runner caches nothing across jobs (each job + # re-resolves into its own .cache/act/). Gitea 1.25.4 also removed the + # custom DEFAULT_ACTIONS_URL knob, so the only fix this repo owns is to stop + # depending on the public resolver at all. + # + # KEEP IN SYNC with the same step in the other job. This logic deliberately + # does NOT live in scripts/*.ps1 (unlike the Python / MSVC probes): the file + # is not checked out yet at the moment it would be needed -- chicken and egg. + # + # NOTE on $ErrorActionPreference: left at Continue on purpose. Under Windows + # PowerShell 5.1 a native command that writes to stderr while EAP is Stop + # raises NativeCommandError -- git legitimately writes progress to stderr. + if (-not $env:DTU_REPO_SHA) { Write-Host '::error::DTU_REPO_SHA is empty'; exit 1 } + if (-not $env:DTU_REPO_URL) { Write-Host '::error::DTU_REPO_URL is empty'; exit 1 } + $ws = (Get-Location).Path + Write-Host "workspace: $ws" + if (-not (Test-Path -LiteralPath (Join-Path $ws '.git'))) { + git init -q . + if ($LASTEXITCODE -ne 0) { Write-Host '::error::git init failed'; exit 1 } + } + $rem = git remote get-url origin 2>$null + if ($LASTEXITCODE -eq 0 -and $rem) { + git remote set-url origin $env:DTU_REPO_URL + } else { + git remote add origin $env:DTU_REPO_URL + } + if ($LASTEXITCODE -ne 0) { Write-Host '::error::configuring remote origin failed'; exit 1 } + # Full refspec, NOT --depth 1: the commit-message gate resolves base..head + # ranges and several gates diff against history, so a shallow clone turns + # them into "unknown revision" failures. Tags are skipped on purpose: this + # repository has zero tags and no script uses `git describe --tags` + # (measured 2026-10-27). + git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*' + # Never let a fetch wait for a human: the runner has no console and a + # credential prompt would burn the whole 30 m job timeout while looking like + # a hung CI. Terminal prompts off + no credential helper means an auth + # problem fails immediately with a message instead of blocking. Anonymous + # fetch works here (the repository is public; measured HTTP 200 on + # /info/refs?service=git-upload-pack), so CI must not start depending on a + # credential store it cannot answer. + $env:GIT_TERMINAL_PROMPT = '0' + # The token, when the instance provides one, travels only as a per-invocation + # -c override: it never lands in .git/config and is never echoed in logs. + # Anonymous fetch works too (the repository is public), so CI does not start + # depending on token availability. + $auth = @('-c', 'credential.helper=', '-c', 'credential.interactive=false') + if ($env:DTU_REPO_TOKEN) { + # Same scheme actions/checkout uses against Gitea git-http: Basic with a + # placeholder user and the token as the password. (Bearer is the GitHub + # *API* style, not the git-http style.) Honest caveat: this branch CANNOT + # be verified here -- the repository is public, so an anonymous fetch + # succeeds and any token we fabricate locally only proves a bad one is + # rejected. If the repository ever goes private this is the first line to + # check; the failure mode is a loud rc=1, never a silent fallback. + $pair = 'x-access-token:' + $env:DTU_REPO_TOKEN + $b64 = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($pair)) + $auth += @('-c', ('http.extraheader=AUTHORIZATION: Basic ' + $b64)) + } + # PS 5.1 wraps each native stderr line into an ErrorRecord, turning two real + # log lines into eight of CategoryInfo/FullyQualifiedErrorId noise. Flattening + # with "$_" keeps the log readable; $LASTEXITCODE is unaffected. + $fetchArgs = $auth + @('fetch', '--prune', '--no-tags', 'origin') + & git @fetchArgs 2>&1 | ForEach-Object { "$_" } + if ($LASTEXITCODE -ne 0) { Write-Host "::error::git fetch origin failed (exit $LASTEXITCODE)"; exit 1 } + # Some refs (PR merge commits, refs outside refs/heads) are not covered by the + # branch refspec -- fall back to an explicit ref fetch, then INSIST the wanted + # SHA is really present, rather than silently building the wrong tree. + git rev-parse --verify -q ($env:DTU_REPO_SHA + '^{commit}') 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Host 'sha not covered by the branch refspec -- fetching the event ref explicitly' + $refArgs = $auth + @('fetch', '--prune', '--no-tags', 'origin', $env:DTU_REPO_REF) + & git @refArgs 2>&1 | ForEach-Object { "$_" } + } + git rev-parse --verify -q ($env:DTU_REPO_SHA + '^{commit}') 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Host ('::error::' + $env:DTU_REPO_SHA + ' is not present in the fetched history'); exit 1 } + & git checkout -q --detach $env:DTU_REPO_SHA 2>&1 | ForEach-Object { "$_" } + if ($LASTEXITCODE -ne 0) { Write-Host '::error::detached checkout failed'; exit 1 } + git reset --hard -q $env:DTU_REPO_SHA + if ($LASTEXITCODE -ne 0) { Write-Host '::error::reset --hard failed'; exit 1 } + # -ffd, deliberately not -ffdx: -x would delete IGNORED files, and the runner + # reuses those across runs (pip / pre-commit caches live in the workspace). + git clean -ffd -q + Write-Host ('checkout ok: ' + (git rev-parse --short HEAD) + ' | commits=' + (git rev-list --count HEAD) + ' | dirty_files=' + ((git status --porcelain | Measure-Object).Count)) - name: 探测 MSVC(VS BuildTools / Community 等) id: msvc shell: powershell diff --git a/scripts/ci_runner_bootstrap.ps1 b/scripts/ci_runner_bootstrap.ps1 index 253f20eb..9e378a15 100644 --- a/scripts/ci_runner_bootstrap.ps1 +++ b/scripts/ci_runner_bootstrap.ps1 @@ -49,7 +49,11 @@ .PARAMETER SetEnv Discover and write MACHINE-level DTU_PRECOMMIT_PYTHON / DTU_CPPCHECK / - DTU_MSVC_CL. Existing values are preserved unless -Force. + DTU_MSVC_CL / DTU_MS_COVERAGE. Existing values are preserved unless + -Force. MSVC discovery goes through vswhere (so VS on a non-default + drive is found) and pins the toolset with -MsvcToolset, because the + coverage baseline was recorded with one specific MSVC version -- see + the remote-CI guide (docs/CI/) section 4.4. .PARAMETER StartNow Start the task after registering it. @@ -65,6 +69,16 @@ .PARAMETER Force With -SetEnv, overwrite existing machine-level values. +.PARAMETER MsvcToolset + Toolset version PREFIX to prefer when writing DTU_MSVC_CL (default + '14.51.'). 'newest available' is deliberately NOT the rule: the host + unit-test coverage baseline was measured with MSVC 14.51 and the + denominator changes with the compiler generation. When no toolset + matches the prefix the script warns and falls back to the newest. + +.PARAMETER MsvcLatest + Ignore -MsvcToolset and take the newest MSVC toolset found. + .OUTPUTS Progress lines. Exit codes: 0 = ok, 1 = precondition failed, 2 = not elevated. @@ -80,7 +94,9 @@ param( [switch]$StartNow, [switch]$Status, [switch]$DryRun, - [switch]$Force + [switch]$Force, + [string]$MsvcToolset = '14.51.', + [switch]$MsvcLatest ) $ErrorActionPreference = 'Stop' @@ -138,24 +154,115 @@ function Find-Cppcheck { return $null } -function Find-MsvcCl { - $pf = $env:ProgramFiles - $pf86 = ${env:ProgramFiles(x86)} - if ([string]::IsNullOrWhiteSpace($pf)) { $pf = 'C:\Program Files' } - if ([string]::IsNullOrWhiteSpace($pf86)) { $pf86 = 'C:\Program Files (x86)' } - $roots = @() - foreach ($year in @('2019', '2022', '2026', '18')) { - foreach ($edition in @('BuildTools', 'Community', 'Professional', 'Enterprise')) { - $roots += (Join-Path $pf86 "Microsoft Visual Studio\$year\$edition") - $roots += (Join-Path $pf "Microsoft Visual Studio\$year\$edition") +function Find-VsInstances { + # VS install roots, newest first, via vswhere. This matters because + # vswhere knows about NON-DEFAULT INSTALL DRIVES: this machine keeps + # VS 18 BuildTools under E:, which a %ProgramFiles%-derived probe can + # never see. Returns an empty array when vswhere is unusable so the + # caller can fall back to the classic (incomplete) root scan. + $vswhere = $env:DTU_VSWHERE + if ([string]::IsNullOrWhiteSpace($vswhere)) { + $pf86 = [Environment]::GetFolderPath('ProgramFilesX86') + if ([string]::IsNullOrWhiteSpace($pf86)) { $pf86 = 'C:\Program Files (x86)' } + $vswhere = Join-Path $pf86 'Microsoft Visual Studio\Installer\vswhere.exe' + } + if (-not (Test-Path -LiteralPath $vswhere)) { return @() } + $raw = & $vswhere -all -products * -format json 2>$null + if ($LASTEXITCODE -ne 0 -or -not $raw) { return @() } + $list = @() + foreach ($i in @($raw | ConvertFrom-Json)) { + if (-not $i -or -not $i.installationPath) { continue } + if (-not (Test-Path -LiteralPath $i.installationPath)) { continue } + $v = [version]'0.0' + if ($i.installationVersion) { [void][version]::TryParse([string]$i.installationVersion, [ref]$v) } + $list += [pscustomobject]@{ Root = [string]$i.installationPath; Version = $v } + } + return @($list | Sort-Object -Property Version -Descending) +} + +function Find-MsvcClCandidates { + # Every x64-host cl.exe on this machine, tagged with its toolset version. + # Comparison operators in PowerShell are case-insensitive by default, so + # -match / -notmatch cover HostX64 / Hostx64 spelling differences. + $cands = @() + $roots = @(Find-VsInstances | ForEach-Object { $_.Root }) + if ($roots.Count -eq 0) { + Warn 'vswhere unusable -- falling back to %ProgramFiles% roots; VS installs on other drives are INVISIBLE to that probe' + $pf = [Environment]::GetFolderPath('ProgramFiles') + $pf86 = [Environment]::GetFolderPath('ProgramFilesX86') + if ([string]::IsNullOrWhiteSpace($pf)) { $pf = 'C:\Program Files' } + if ([string]::IsNullOrWhiteSpace($pf86)) { $pf86 = 'C:\Program Files (x86)' } + foreach ($year in @('18', '2026', '2022', '2019')) { + foreach ($edition in @('BuildTools', 'Community', 'Professional', 'Enterprise')) { + foreach ($base in @($pf, $pf86)) { + $r = Join-Path $base ('Microsoft Visual Studio\' + $year + '\' + $edition) + if (Test-Path -LiteralPath $r) { $roots += $r } + } + } } } foreach ($r in $roots) { - if (-not (Test-Path -LiteralPath $r)) { continue } - $cl = Get-ChildItem -LiteralPath $r -Recurse -Filter 'cl.exe' -ErrorAction SilentlyContinue | - Where-Object { $_.FullName -like '*Hostx64\x64*' } | - Select-Object -First 1 - if ($cl) { return $cl.FullName } + $msvc = Join-Path $r 'VC\Tools\MSVC' + if (-not (Test-Path -LiteralPath $msvc)) { continue } + foreach ($f in (Get-ChildItem -LiteralPath $msvc -Recurse -Filter 'cl.exe' -ErrorAction SilentlyContinue)) { + $full = $f.FullName + if ($full -notmatch '\\bin\\Hostx64\\x64\\cl\.exe$') { continue } + $toolset = '' + if ($full -match '\\VC\\Tools\\MSVC\\([^\\]+)\\bin\\') { $toolset = $Matches[1] } + $ver = [version]'0.0' + [void][version]::TryParse($toolset, [ref]$ver) + $cands += [pscustomobject]@{ Cl = $full; Toolset = $toolset; Version = $ver; VsRoot = $r } + } + } + return @($cands) +} + +function Find-MsvcCl { + # Rewritten 2026-10-27. The previous version walked a hard-coded root list + # derived from %ProgramFiles% and returned the FIRST hit: with VS2019 + # (MSVC 14.29) and a newer VS both present it handed back 14.29, and it + # could not see the E: install at all. "First hit" is never right for a + # compiler: the coverage baseline (scripts/test_coverage_baseline.json) + # was recorded with MSVC 14.51 and the instrumented denominator moves when + # the compiler generation moves (measured: App/util/sha256.c is 105 lines + # under gcc vs 114 under MSVC -- see the host coverage policy doc, 2.1). + # Hence the default is a PINNED toolset prefix, not "newest". + param([string]$ToolsetPrefix = '', [switch]$Latest) + $cands = @(Find-MsvcClCandidates) + if ($cands.Count -eq 0) { return $null } + $sorted = @($cands | Sort-Object -Property Version -Descending) + $seen = (@($sorted | ForEach-Object { $_.Toolset } | Select-Object -Unique)) -join ', ' + if (-not $Latest -and $ToolsetPrefix) { + $hit = @($sorted | Where-Object { $_.Toolset -like ($ToolsetPrefix + '*') }) + if ($hit.Count -gt 0) { + Info ('MSVC pinned to toolset prefix ' + $ToolsetPrefix + '* -> ' + $hit[0].Toolset + ' (' + $hit.Count + ' of ' + $cands.Count + ' candidate(s); toolsets found: ' + $seen + ')') + return $hit[0].Cl + } + Warn ('no MSVC toolset matching prefix ' + $ToolsetPrefix + ' on this machine (found: ' + $seen + ')') + Warn 'falling back to the NEWEST toolset -- that changes the coverage denominator; read the remote-CI guide (docs/CI/) section 4.4 before accepting it' + } + Info ('using newest MSVC toolset ' + $sorted[0].Toolset + ' of ' + $cands.Count + ' candidate(s); toolsets found: ' + $seen) + return $sorted[0].Cl +} + +function Find-CoverageConsole { + # Microsoft.CodeCoverage.Console.exe ships ONLY inside Visual Studio + # (measured 2026-10-27: NuGet candidates contain no such .exe, or only the + # legacy Team Tools CodeCoverage.exe whose CLI differs). Its location + # inside a VS install is a fixed relative path: + param([string]$ClPath = '') + $rel = 'Common7\IDE\Extensions\Microsoft\CodeCoverage.Console\Microsoft.CodeCoverage.Console.exe' + $roots = @() + if ($ClPath) { + $vsRoot = '' + # cl.exe = \VC\Tools\MSVC\\bin\Hostx64\x64\cl.exe + if ($ClPath -match '^(.*)\\VC\\Tools\\MSVC\\') { $vsRoot = $Matches[1] } + if ($vsRoot) { $roots += $vsRoot } + } + foreach ($i in @(Find-VsInstances)) { $roots += $i.Root } + foreach ($r in ($roots | Where-Object { $_ } | Select-Object -Unique)) { + $cand = Join-Path $r $rel + if (Test-Path -LiteralPath $cand) { return $cand } } return $null } @@ -273,7 +380,7 @@ if ($Status) { if (Test-Gitea $GiteaUrl) { Ok "Gitea reachable at $GiteaUrl" } else { Fail "Gitea NOT reachable at $GiteaUrl" } - foreach ($n in @('DTU_PRECOMMIT_PYTHON', 'DTU_CPPCHECK', 'DTU_MSVC_CL')) { + foreach ($n in @('DTU_PRECOMMIT_PYTHON', 'DTU_CPPCHECK', 'DTU_MSVC_CL', 'DTU_MS_COVERAGE')) { $v = Get-MachineEnv $n if ($v) { Ok ("MACHINE {0} = {1}" -f $n, $v) } else { Warn "MACHINE $n not set (CI auto-discovery still applies; see scripts/ci_find_python.ps1)" } @@ -359,8 +466,21 @@ if ($SetEnv) { if ($cpp) { Set-MachineEnv 'DTU_CPPCHECK' $cpp 'DTU_CPPCHECK' } else { Warn 'cppcheck not found (install Cppcheck.Cppcheck, or set DTU_CPPCHECK manually)' } - $cl = Find-MsvcCl - if ($cl) { Set-MachineEnv 'DTU_MSVC_CL' $cl 'DTU_MSVC_CL' } + $cl = Find-MsvcCl -ToolsetPrefix $MsvcToolset -Latest:$MsvcLatest + if ($cl) { + Set-MachineEnv 'DTU_MSVC_CL' $cl 'DTU_MSVC_CL' + # Pin the coverage collector too, and pin it INSIDE the same VS as the + # chosen cl.exe: the gate derives it from the compiler ancestor anyway, + # but an explicit machine value also covers the case where somebody + # later points DTU_MSVC_CL at another install. Both vars must be + # changed together (docs/CI/ guide, section 4.4). + $cov = Find-CoverageConsole -ClPath $cl + if ($cov) { Set-MachineEnv 'DTU_MS_COVERAGE' $cov 'DTU_MS_COVERAGE' } + else { + Warn 'Microsoft.CodeCoverage.Console.exe NOT found -- the test-coverage gate FAILS with [ENV] exit 2 (by the 2026-10-24 ruling it does not skip).' + Warn 'install VS BuildTools 17.9+/18.x including Microsoft.VisualStudio.PackageGroup.TestTools.CodeCoverage, then re-run -SetEnv' + } + } else { Warn 'MSVC cl.exe (Hostx64\x64) not found -- L0.5 will degrade to "skipped" without it' } Info 'machine env vars are read when a PROCESS STARTS: the daemon must be restarted below'