From bfcab5e314d6ec3e285f9595d81a8a414b780094 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 17:54:03 +0000 Subject: [PATCH] fix(powershell): bind positional scan paths to -Paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrapper's own documented invocation scanned the wrong tree. With own-check.ps1 -Format msbuild -- src\MyApp `--` ends PowerShell's parameter parsing, what follows binds POSITIONALLY, and $Root was declared first — so src\MyApp became the Own.NET checkout root and the scan fell through to ".". Bare positional paths did the same. Only an explicit -Paths reached $Paths. The failure mode is what makes this more than a papercut: it is SILENT. Ask for directory A, get directory B analysed, and if B is clean you are handed a green result and no error. That is the same lie A1 and A4 spent two arcs removing — "did not look" wearing the face of "looked and found nothing" — except here the tool did look, just not where it was told to. Fixed by declaring the contract instead of inheriting it from declaration order: PositionalBinding = $false turns off automatic positional binding for every parameter, and $Paths claims Position = 0 explicitly. Reordering the param block would have worked too, and would have left the meaning resting on the same implicit machinery that already produced this twice. Root -> named -Root only Paths -> explicit -Paths, or position 0 Verified against the real script, every documented form: -Format github -- /t/a -> Paths=[/t/a] (was Root) -Format github /t/a -> Paths=[/t/a] (was Root) -Format github -Paths /t/a -> Paths=[/t/a] -Root /checkout -Format github -Paths /t/a -> Root=[/checkout] Paths=[/t/a] -Format github -> Paths=[.] -Format github -- /t/a /t/b -> Paths=[/t/a|/t/b] CI gains the end-to-end half, which is the part that actually catches a wrong-tree scan: standing in a CLEAN working directory, ask for a target that contains a guaranteed OWN001, and require the finding to come back and the cwd's own file never to appear. The `--` and -Paths forms must produce IDENTICAL output — equivalence of the public forms, not merely "both said something" — and two positional paths must both arrive, since [string[]] is the declared type and a one-element proof would not establish it. The stale claim above the job is corrected while here: the PowerShell wrapper does execute on Linux far enough to prove its stage-1 failure tier; it is the successful extraction path that needs Windows. Closes #315. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015de4MezSeUnZBoWq1fFU5M --- .github/workflows/ci.yml | 66 ++++++++++++++++++++++++++++++++++++++-- scripts/own-check.ps1 | 15 +++++++-- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8de6e2a9..d26ee060 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2112,8 +2112,10 @@ jobs: # 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. + # exercised by any other job. The composite action drives own-check.sh. The + # PowerShell wrapper can execute far enough on Linux to prove its stage-1 + # failure tier, but its successful extraction path constructs Windows-style + # paths and is therefore exercised end to end on Windows. # 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. @@ -2157,6 +2159,21 @@ jobs: 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: Fixtures for the invocation contract (a leaky target, a clean cwd) + run: | + # The target the user will ASK for, and a working directory that has + # nothing to report — so "scanned the wrong tree" cannot hide behind + # a finding that happens to exist in both. + foreach ($n in @("ps1-target-a", "ps1-target-b")) { + $d = Join-Path $env:RUNNER_TEMP $n + New-Item -ItemType Directory -Force -Path $d | Out-Null + "using System.IO;`npublic class Leaky_$($n -replace '-','_') { public void Run() { var s = new MemoryStream(); s.WriteByte(1); } }" | + Set-Content -Path (Join-Path $d "Leaky.cs") + } + $cwd = Join-Path $env:RUNNER_TEMP "ps1-cwd-clean" + New-Item -ItemType Directory -Force -Path $cwd | Out-Null + 'public class NothingHere { public int M() { return 1; } }' | + Set-Content -Path (Join-Path $cwd "NothingHere.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` @@ -2201,6 +2218,51 @@ jobs: } Write-Host "OK: findings -> 1 with the flag, 0 without, identical output" exit 0 + - name: "Invocation contract — the requested target is what gets scanned (#315)" + run: | + # The defect this pins: `own-check.ps1 -Format github -- src\App` used + # to bind src\App to -Root and scan "." instead — the tool looked, but + # not where it was asked to. Standing in a CLEAN directory is what + # makes that visible: a wrong-tree scan comes back with nothing and + # reads as good news. + $script = Join-Path $env:GITHUB_WORKSPACE "scripts/own-check.ps1" + $targetA = Join-Path $env:RUNNER_TEMP "ps1-target-a" + $targetB = Join-Path $env:RUNNER_TEMP "ps1-target-b" + Push-Location (Join-Path $env:RUNNER_TEMP "ps1-cwd-clean") + try { + $sep = & $script -Format github -- $targetA + $expl = & $script -Format github -Paths $targetA + $pos = & $script -Format github $targetA + $many = & $script -Format github -- $targetA $targetB + $none = & $script -Format github + } finally { Pop-Location } + + foreach ($case in @(@{n="-- "; o=$sep}, @{n="-Paths "; o=$expl}, @{n="bare positional"; o=$pos})) { + if (-not (($case.o -join "`n") -match "OWN001")) { + Write-Host "FAIL: $($case.n) reported no finding — the target was not scanned"; exit 1 + } + if (($case.o -join "`n") -match "NothingHere") { + Write-Host "FAIL: $($case.n) scanned the working directory instead of the target"; exit 1 + } + } + # The two public forms must be EQUIVALENT, not merely both non-empty. + if (($sep -join "`n").Trim() -ne ($expl -join "`n").Trim()) { + Write-Host "FAIL: '-- ' and '-Paths ' disagree" + Write-Host "--- -- form ---"; $sep | Write-Host + Write-Host "--- -Paths form ---"; $expl | Write-Host + exit 1 + } + # [string[]] is the declared type, so more than one path must work. + $manyText = $many -join "`n" + if (-not ($manyText -match "ps1-target-a") -or -not ($manyText -match "ps1-target-b")) { + Write-Host "FAIL: multiple positional paths did not both reach -Paths"; $many | Write-Host; exit 1 + } + # No target at all still means the working directory, which is clean. + if (($none -join "`n") -match "OWN001") { + Write-Host "FAIL: with no target the clean cwd should report nothing"; $none | Write-Host; exit 1 + } + Write-Host "OK: every documented form scans the requested target; the two public forms agree" + 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 diff --git a/scripts/own-check.ps1 b/scripts/own-check.ps1 index 8ecd0996..9140a611 100644 --- a/scripts/own-check.ps1 +++ b/scripts/own-check.ps1 @@ -44,7 +44,7 @@ .EXAMPLE scripts\own-check.ps1 -Format github -Severity warning -FailOnFinding -- . #> -[CmdletBinding()] +[CmdletBinding(PositionalBinding = $false)] param( [string]$Root, [string]$Format = "human", @@ -53,7 +53,13 @@ param( [string]$Verbosity = "normal", [switch]$Legacy, [switch]$FailOnFinding, - [Parameter(ValueFromRemainingArguments = $true)] + # Position 0 is claimed EXPLICITLY, and automatic positional binding is off + # for everything else (PositionalBinding = $false). Without both halves the + # scan target lands in $Root — the first declared parameter took position 0 + # — and own-check then hunts for the extractor inside the tree it was asked + # to scan, or silently scans "." instead. Declaring the contract beats + # relying on declaration order to keep meaning it. + [Parameter(Position = 0, ValueFromRemainingArguments = $true)] [string[]]$Paths ) @@ -64,7 +70,10 @@ $ErrorActionPreference = "Stop" if ([string]::IsNullOrEmpty($Root)) { $Root = Split-Path -Parent $PSScriptRoot } -# A bare "--" separator (shell habit) is harmless; drop it. +# A bare "--" separator (shell habit) is harmless; drop it. In an interactive +# session PowerShell eats the token itself, so this is a no-op there — it earns +# its keep when the arguments are splatted (`& own-check.ps1 @args`), where a +# literal "--" does arrive as a value. if ($Paths) { $Paths = @($Paths | Where-Object { $_ -ne "--" }) } if (-not $Paths -or $Paths.Count -eq 0) { $Paths = @(".") }