From f370acd1a782d63907d988ecaad80e5bae548222 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 16:38:28 +0000 Subject: [PATCH 1/3] test(ci): pin own-check.ps1's exit-code tiers on Windows (#313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the evidence gap recorded in the #312 merge commit. The stage-1 normalisation — a broken extractor must land in the >=2 tier instead of borrowing exit 1 from "analysed, findings present" — was mirrored into the PowerShell wrapper and proven nowhere. No other job touches that file: the composite action drives the .sh, and the .ps1 builds its paths with backslashes, so it cannot run on Linux at all. A new windows-latest job exercises the tiers the wrapper actually has: broken stage 1 -> >=2, clean -> 0, findings -> 1 with -FailOnFinding and 0 without. The findings pair also asserts the two runs produce IDENTICAL output, so the flag can never quietly become a second, differently-behaving analysis. The "broken config -> 2" tier from the issue is deliberately absent: the PowerShell wrapper has no -Config parameter, so that tier does not exist on this surface. Asserting it would have meant inventing the surface to fit the test. One platform detail is load-bearing enough to be a comment rather than folk knowledge: the assertions read $LASTEXITCODE, never the wrapper process's exit code. Inside a PowerShell session — which is what `shell: pwsh` is — a script's `exit N` sets $LASTEXITCODE to N, but `pwsh -Command "& ./x.ps1"` collapses it to 1 at the process boundary. Measured the wrong way, every tier here reads as pass/fail and the contract looks broken when it is not; that is exactly what happened once while writing this. Verified locally with PowerShell 7.4.6: the stage-1 tier runs on Linux (the failure is the point) and reports $LASTEXITCODE = 2; every step body was parse-checked with the PowerShell parser. The remaining tiers need a working extractor and therefore first run on the Windows runner. Refs #313. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015de4MezSeUnZBoWq1fFU5M --- .github/workflows/ci.yml | 74 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe6d604b..8b01538c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2110,6 +2110,80 @@ jobs: || { echo "FAIL: an operational failure (exit >=2) must fail the step regardless of fail-on-finding (outcome=${{ steps.operational_failure.outcome }})"; exit 1; } echo "OK: 'could not look' did not become 'looked and found nothing'" + # The PowerShell wrapper's exit-code tiers, on the platform it exists for + # (#313). own-check.ps1 is the Windows twin of own-check.sh and is never + # exercised by any other job: the composite action drives the .sh, and the + # .ps1's paths are built with backslashes, so it cannot run on Linux at all. + # That left the stage-1 normalisation — a broken extractor must land in the + # >=2 tier, not borrow exit 1 from "findings present" — mirrored in code and + # proven nowhere. Symmetry of source is an argument, not evidence. + # + # Read $LASTEXITCODE, never the wrapper process's code: inside a PowerShell + # session (which is what `shell: pwsh` is) a script's `exit N` sets + # $LASTEXITCODE to N, but `pwsh -Command "& ./script.ps1"` collapses that to + # 1 at the process boundary — measuring the wrapper instead of the script. + own-check-ps1-surface: + name: own-check.ps1 exit-code tiers (Windows) + runs-on: windows-latest + defaults: + run: + shell: pwsh + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.13" + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: "8.0.x" + - name: A clean tree to check against + run: | + $clean = Join-Path $env:RUNNER_TEMP "ps1-clean" + New-Item -ItemType Directory -Force -Path $clean | Out-Null + 'public class Clean { public int M() { return 1; } }' | + Set-Content -Path (Join-Path $clean "Clean.cs") + - name: "Tier >=2 — a broken stage 1 is a hard error, never the findings tier" + run: | + # -Root at a checkout that has no extractor project: `dotnet run` + # fails with 1, and the wrapper must NOT pass that through, or a + # caller that does not gate on findings reads a run that analysed + # nothing as clean. + & ./scripts/own-check.ps1 -Root (Join-Path $env:RUNNER_TEMP "no-such-root") ` + -Format github -- (Join-Path $env:RUNNER_TEMP "ps1-clean") 2>$null 1>$null + if ($LASTEXITCODE -lt 2) { + Write-Host "FAIL: a broken stage 1 must exit >=2 (the tool did not look), got $LASTEXITCODE" + exit 1 + } + Write-Host "OK: stage-1 failure landed in the hard-error tier ($LASTEXITCODE)" + - name: "Tier 0 — a clean tree exits 0" + run: | + & ./scripts/own-check.ps1 -Format github -- (Join-Path $env:RUNNER_TEMP "ps1-clean") 1>$null + if ($LASTEXITCODE -ne 0) { + Write-Host "FAIL: a clean tree must exit 0, got $LASTEXITCODE"; exit 1 + } + Write-Host "OK: clean tree exits 0" + - name: "Tier 1 — findings exit 1 only with -FailOnFinding" + run: | + # The same tree, twice: the flag is the ONLY difference, and it must + # move nothing but the exit code. + $withFlag = & ./scripts/own-check.ps1 -Format github -FailOnFinding -- frontend/roslyn/samples + $rcFlag = $LASTEXITCODE + $noFlag = & ./scripts/own-check.ps1 -Format github -- frontend/roslyn/samples + $rcNoFlag = $LASTEXITCODE + if ($rcFlag -ne 1) { + Write-Host "FAIL: findings with -FailOnFinding must exit 1, got $rcFlag"; exit 1 + } + if ($rcNoFlag -ne 0) { + Write-Host "FAIL: findings without the flag must exit 0, got $rcNoFlag"; exit 1 + } + if (-not ($withFlag -match "OWN001")) { + Write-Host "FAIL: expected OWN001 in the annotated output"; exit 1 + } + if (($withFlag -join "`n") -ne ($noFlag -join "`n")) { + Write-Host "FAIL: the flag changed the OUTPUT, not just the exit code"; exit 1 + } + Write-Host "OK: findings -> 1 with the flag, 0 without, identical output" + # Dog-food the code-scanning surface end-to-end (P-013): run the composite action # with format: sarif over the sample tree, then upload the log to GitHub code # scanning. The repo is public, so code scanning is free — this is the live proof From 2ea190d0c42555952e09ffa5321ec9609ed7e8ed Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 16:53:24 +0000 Subject: [PATCH 2/3] fix(ci): end the PowerShell tier steps explicitly, so a deliberate failure does not fail the step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job's first run printed exactly what it was written to prove — "OK: stage-1 failure landed in the hard-error tier (2)" — and then reported the step as failed. The assertion was right; the step's status was not. GitHub's `shell: pwsh` wrapper finishes with `exit $LASTEXITCODE`. These steps deliberately run commands that FAIL, so the code left behind by the last one decides the step's fate no matter what the assertion concluded. Each step now ends with an explicit `exit 0`, and the reason sits in a comment above the job rather than in anyone's memory. Worth noting what this was NOT: the tier contract held on Windows on the first attempt — a broken stage 1 really did land on 2. The harness around it was wrong, not the thing it measures. Refs #313. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015de4MezSeUnZBoWq1fFU5M --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b01538c..7a30652b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2122,6 +2122,13 @@ jobs: # session (which is what `shell: pwsh` is) a script's `exit N` sets # $LASTEXITCODE to N, but `pwsh -Command "& ./script.ps1"` collapses that to # 1 at the process boundary — measuring the wrapper instead of the script. + # + # And every step here ENDS WITH `exit 0`. These steps run commands that are + # SUPPOSED to fail, and GitHub's pwsh wrapper finishes with + # `exit $LASTEXITCODE` — so a leftover non-zero code fails the step even when + # the assertion above it passed. The first run of this job did exactly that: + # it printed "OK: stage-1 failure landed in the hard-error tier (2)" and then + # reported the step as failed. own-check-ps1-surface: name: own-check.ps1 exit-code tiers (Windows) runs-on: windows-latest @@ -2155,6 +2162,7 @@ jobs: exit 1 } Write-Host "OK: stage-1 failure landed in the hard-error tier ($LASTEXITCODE)" + exit 0 - name: "Tier 0 — a clean tree exits 0" run: | & ./scripts/own-check.ps1 -Format github -- (Join-Path $env:RUNNER_TEMP "ps1-clean") 1>$null @@ -2162,6 +2170,7 @@ jobs: Write-Host "FAIL: a clean tree must exit 0, got $LASTEXITCODE"; exit 1 } Write-Host "OK: clean tree exits 0" + exit 0 - name: "Tier 1 — findings exit 1 only with -FailOnFinding" run: | # The same tree, twice: the flag is the ONLY difference, and it must @@ -2183,6 +2192,7 @@ jobs: Write-Host "FAIL: the flag changed the OUTPUT, not just the exit code"; exit 1 } Write-Host "OK: findings -> 1 with the flag, 0 without, identical output" + exit 0 # Dog-food the code-scanning surface end-to-end (P-013): run the composite action # with format: sarif over the sample tree, then upload the log to GitHub code From dd1f05f18a913cd9e50958e7d9ecbd2b05c786d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 17:07:51 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(ci):=20bind=20the=20PowerShell=20paths?= =?UTF-8?q?=20explicitly=20=E2=80=94=20`--`=20sends=20them=20to=20-Root,?= =?UTF-8?q?=20not=20-Paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows run failed with the extractor being looked for under the tree being scanned: The provided file path does not exist: D:\a\_temp\ps1-clean\frontend\roslyn\OwnSharp.Extractor The path had bound to -Root. In PowerShell `--` ends parameter parsing and everything after it binds POSITIONALLY, and own-check.ps1 declares $Root first, so it takes position 0. Verified against a reduced param block: both `-Format github -- ` and `-Format github ` land in $Root, and only `-Paths ` reaches $Paths. This job now binds -Paths explicitly. It does NOT change the wrapper: that the script's own .EXAMPLE lines (`own-check.ps1 -Format msbuild -- src\MyApp`) therefore scan "." instead of src\MyApp is a real user-facing defect, but it is a different one from the exit-code tiers this job exists to pin, and it is being reported rather than folded in here. Also worth recording honestly: the tier>=2 assertion passed in the previous run while malformed — -Root and -Format were bound by name, so the stray positional argument went to some other parameter entirely and the tier was proven for the right verdict by an accident of binding. Re-verified locally with the corrected invocation: LASTEXITCODE = 2. Refs #313. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015de4MezSeUnZBoWq1fFU5M --- .github/workflows/ci.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a30652b..8de6e2a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2129,6 +2129,14 @@ jobs: # the assertion above it passed. The first run of this job did exactly that: # it printed "OK: stage-1 failure landed in the hard-error tier (2)" and then # reported the step as failed. + # + # Paths are passed with an explicit -Paths, never positionally and never + # after a `--` separator. In PowerShell `--` ends parameter parsing and what + # follows binds POSITIONALLY, and this script declares $Root first — so + # `own-check.ps1 -Format github -- src\App` silently binds src\App to -Root + # and scans "." instead. That is a defect in the wrapper's own documented + # examples, tracked separately; these assertions must exercise the tiers, not + # inherit the bug. own-check-ps1-surface: name: own-check.ps1 exit-code tiers (Windows) runs-on: windows-latest @@ -2156,7 +2164,7 @@ jobs: # caller that does not gate on findings reads a run that analysed # nothing as clean. & ./scripts/own-check.ps1 -Root (Join-Path $env:RUNNER_TEMP "no-such-root") ` - -Format github -- (Join-Path $env:RUNNER_TEMP "ps1-clean") 2>$null 1>$null + -Format github -Paths (Join-Path $env:RUNNER_TEMP "ps1-clean") 2>$null 1>$null if ($LASTEXITCODE -lt 2) { Write-Host "FAIL: a broken stage 1 must exit >=2 (the tool did not look), got $LASTEXITCODE" exit 1 @@ -2165,7 +2173,7 @@ jobs: exit 0 - name: "Tier 0 — a clean tree exits 0" run: | - & ./scripts/own-check.ps1 -Format github -- (Join-Path $env:RUNNER_TEMP "ps1-clean") 1>$null + & ./scripts/own-check.ps1 -Format github -Paths (Join-Path $env:RUNNER_TEMP "ps1-clean") 1>$null if ($LASTEXITCODE -ne 0) { Write-Host "FAIL: a clean tree must exit 0, got $LASTEXITCODE"; exit 1 } @@ -2175,9 +2183,9 @@ jobs: run: | # The same tree, twice: the flag is the ONLY difference, and it must # move nothing but the exit code. - $withFlag = & ./scripts/own-check.ps1 -Format github -FailOnFinding -- frontend/roslyn/samples + $withFlag = & ./scripts/own-check.ps1 -Format github -FailOnFinding -Paths frontend/roslyn/samples $rcFlag = $LASTEXITCODE - $noFlag = & ./scripts/own-check.ps1 -Format github -- frontend/roslyn/samples + $noFlag = & ./scripts/own-check.ps1 -Format github -Paths frontend/roslyn/samples $rcNoFlag = $LASTEXITCODE if ($rcFlag -ne 1) { Write-Host "FAIL: findings with -FailOnFinding must exit 1, got $rcFlag"; exit 1