From 4aec69696f2079aa8498c9e0029257fe555eab89 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 13:16:24 +0000 Subject: [PATCH 1/3] Item 24 experiment: does --hidden-import=gribapi bundle eccodes.dll for pygrib? Standalone, non-gating probe (does not invoke run_setup.bat) testing PRD Requirement 1's cheapest-option-first question: create a scratch conda env with pygrib+eccodes+python-eccodes+pyinstaller, build a trivial pygrib stub twice (control vs --hidden-import=gribapi), and record whether the existing pyinstaller-hooks-contrib hook-gribapi.py bundles eccodes.dll for free. Research (docs/prd-conda-native-dll-bundling.md Finding 1) suggests this is likely negative since pygrib and gribapi are independent bindings to the same C library, but the owner asked for real CI evidence before ruling it out. Wired as its own step in the conda-full lane, gated on the existing conda-availability check, continue-on-error. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01A4iE1BRSkUETwz237XeuTW --- .github/workflows/batch-check.yml | 13 ++ docs/agent-ndjson.md | 43 +++++ tests/selfapps_gribapi_hook_probe.ps1 | 267 ++++++++++++++++++++++++++ 3 files changed, 323 insertions(+) create mode 100644 tests/selfapps_gribapi_hook_probe.ps1 diff --git a/.github/workflows/batch-check.yml b/.github/workflows/batch-check.yml index 3ba4a984..ce074891 100644 --- a/.github/workflows/batch-check.yml +++ b/.github/workflows/batch-check.yml @@ -988,6 +988,19 @@ jobs: run: | & tests\selfapps_pipgap.ps1 + # CLAUDE.md Active Backlog Item 24 / docs/prd-conda-native-dll-bundling.md Requirement 1: + # empirical, standalone experiment (does NOT invoke run_setup.bat) testing whether forcing + # --hidden-import=gribapi on a pygrib PyInstaller build makes the existing + # pyinstaller-hooks-contrib hook-gribapi.py bundle eccodes.dll for free. Non-gating: + # exploratory by design, and the conda-forge solve for pygrib+eccodes+python-eccodes+ + # pyinstaller together in one env is unproven. + - name: "Probe: does --hidden-import=gribapi bundle eccodes.dll for pygrib (conda-full only, non-gating)" + if: ${{ !cancelled() && matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true' }} + continue-on-error: true + shell: pwsh + run: | + & tests\selfapps_gribapi_hook_probe.ps1 + - name: "Self-test: parse_warn TRANSLATIONS table coverage" # derived requirement (item 7 scoping pass): this step only decodes the embedded # HP_PARSE_WARN base64 payload out of run_setup.bat as static text -- it never executes diff --git a/docs/agent-ndjson.md b/docs/agent-ndjson.md index 57d95b35..b954133c 100644 --- a/docs/agent-ndjson.md +++ b/docs/agent-ndjson.md @@ -50,6 +50,7 @@ self.cascade.detect, self.cascade.consent, self.cascade.timed, self.cascade.exec (uv lane only -- selfapps_cascade.ps1; non-gating), self.cascade.conda_create_fail (uv lane only -- selfapps_cascade_conda_create_fail.ps1; non-gating), self.layered_e2e.chain (cache lane only -- selfapps_layered_e2e.ps1; non-gating), +self.gribapi_hook_probe.hidden_import (conda-full lane only -- selfapps_gribapi_hook_probe.ps1; non-gating), self.conda.bothfail (uv lane only -- selfapps_conda_bothfail.ps1; non-gating), self.exe.build.tiera (uv lane only -- selfapps_nuitka_tiera.ps1; non-gating), self.exe.tiera.hidden_skip (uv lane only -- selfapps_nuitka_tiera_hidden_skip.ps1; non-gating), @@ -443,6 +444,48 @@ grouped, no ordering dependency between them -- different lanes). self.layered_e2e.chain ``` +## selfapps-gribapi-hook-probe NDJSON rows (selfapps_gribapi_hook_probe.ps1, conda-full lane only, non-gating) + +CLAUDE.md Active Backlog Item 24 / `docs/prd-conda-native-dll-bundling.md` Requirement 1: a +one-off empirical experiment testing whether forcing `--hidden-import=gribapi` on a `pygrib` +PyInstaller build makes `pyinstaller-hooks-contrib`'s existing `hook-gribapi.py` bundle +`eccodes.dll` for free, without building any new bootstrapper mechanism. See the PRD's Finding 1 +for the full research trail on why this is now expected to come back negative (pygrib and gribapi +are architecturally independent bindings to the same C library) but is still worth the real CI +evidence per explicit owner instruction. + +**Deliberately standalone -- does NOT invoke `run_setup.bat` or the REQ-009 provider cascade at +all.** Creates its own scratch conda env directly (`gribapi_probe_env`, removed both before and +after) with `python pygrib eccodes python-eccodes pyinstaller pip --override-channels -c +conda-forge`, then runs PyInstaller twice from that env's own interpreter against a trivial +`import pygrib` stub: once as a control (no extra flags), once with `--hidden-import gribapi` +added. For each build, checks whether the `could not resolve 'eccodes.dll'` build-time warning +still appears, whether any `eccodes*.dll` landed anywhere under the dist directory (recursive -- +`hook-gribapi.py`'s own directory-preservation logic nests it under an `eccodes` subfolder on +Windows, not the dist root), and whether the resulting EXE actually runs clean (exits 0 and prints +its own success token, as opposed to failing with `DLL load failed`). + +**`pass` reflects whether the experiment ran to completion and produced conclusive evidence, NOT +whether the hidden-import "worked" -- there is no `pass`/`fail` on the actual research question +itself, only a recorded finding.** `details.hiddenImportHelped` (`true`/`false`/`null`) is the +actual answer: `null` when the experiment was inconclusive (either build never produced an EXE to +test, most likely a conda-forge solve failure for the three grib-related packages together, which +is itself a valid, useful finding), otherwise `true` only if the control build failed to run clean +AND the experiment build did. `pass=false` covers a genuine infra failure (conda env create +failed, or python.exe missing from the created env) that prevented the probe from running at all +-- distinct from `pass=true, hiddenImportHelped=null`, which means the probe ran but the builds +themselves were inconclusive. + +Lane: `conda-full` only, gated on `steps.conda_avail.outputs.available == 'true'` (same gate the +27 other conda-full-only self-tests already use) -- guarantees real Miniconda is present without +any step-ordering placement constraint. Non-gating (`continue-on-error: true` at the step level): +exploratory by design, and the conda-forge solve for `pygrib`+`eccodes`+`python-eccodes`+ +`pyinstaller` together in one env is unproven as of this test's first landing. + +``` +self.gribapi_hook_probe.hidden_import +``` + ## selfapps-conda-bothfail NDJSON rows (selfapps_conda_bothfail.ps1, uv lane only, non-gating) Closes CLAUDE.md Active Backlog item 10: `:tci_both_failed` (both Miniconda AllUsers and JustMe diff --git a/tests/selfapps_gribapi_hook_probe.ps1 b/tests/selfapps_gribapi_hook_probe.ps1 new file mode 100644 index 00000000..671acc3f --- /dev/null +++ b/tests/selfapps_gribapi_hook_probe.ps1 @@ -0,0 +1,267 @@ +# ASCII only +# selfapps_gribapi_hook_probe.ps1 - one-off empirical experiment for CLAUDE.md Active Backlog +# Item 24 / docs/prd-conda-native-dll-bundling.md's Requirement 1: does forcing +# --hidden-import=gribapi onto a pygrib PyInstaller build make pyinstaller-hooks-contrib's +# existing hook-gribapi.py bundle eccodes.dll for free, without any new bootstrapper mechanism? +# +# Background (see the PRD's Finding 1 for the full research trail): pygrib's own compiled +# extension links against eccodes.dll directly; PyInstaller never discovers/bundles it, so a +# frozen EXE importing pygrib fails at runtime with "ImportError: DLL load failed while +# importing _pygrib". pyinstaller-hooks-contrib ships hook-gribapi.py, which at build time +# queries gribapi.bindings.library_path to locate and bundle the eccodes shared library -- but +# pygrib and gribapi (the conda-forge package python-eccodes) are architecturally INDEPENDENT +# bindings to the same C library; pygrib never imports gribapi, so the hook has no reason to +# fire for a pygrib-only build. This test proves that empirically rather than relying on +# analysis alone, per explicit owner instruction to verify via real CI. +# +# Deliberately standalone -- does NOT invoke run_setup.bat or its REQ-009 provider cascade at +# all. The question here is narrow (does one PyInstaller flag change one DLL's bundling +# outcome), so a direct conda-env-plus-PyInstaller harness is more isolated and faster than +# reusing the full bootstrap+cascade machinery selfapps_layered_e2e.ps1 already proves works. +# +# Lane: conda-full only. Miniconda is always installed for real in that lane regardless of step +# ordering (CLAUDE.md's lane table), so this avoids the CI-ordering placement constraints other +# conda-dependent tests need (see selfapps_conda_bothfail.ps1's own placement note for contrast). +# Non-gating (continue-on-error at the step level) -- exploratory by nature, and the conda-forge +# solve for pygrib+eccodes+python-eccodes+pyinstaller together is untested and may need a real +# CI run to even confirm it resolves cleanly. +# +# This test is deliberately NOT tied to a specific pass/fail expectation on the hidden-import +# question itself -- "pass" means the experiment ran to completion and produced conclusive +# evidence for both the control and experiment builds; the actual finding (did the hidden-import +# help) is recorded in details.hiddenImportHelped for a human to read, matching the established +# "informational, pass=true records an observed fact" convention used elsewhere (e.g. +# diag.conda.available). +# +# Emits: self.gribapi_hook_probe.hidden_import +param() +$ErrorActionPreference = 'Continue' +$here = $PSScriptRoot +$repo = Split-Path -Path $here -Parent +$nd = Join-Path $here '~test-results.ndjson' +$ciNd = Join-Path $repo 'ci_test_results.ndjson' +if (-not (Test-Path -LiteralPath $nd)) { New-Item -ItemType File -Path $nd -Force | Out-Null } +if (-not (Test-Path -LiteralPath $ciNd)) { New-Item -ItemType File -Path $ciNd -Force | Out-Null } + +function Write-NdjsonRow { + param([hashtable]$Row) + $lane = [Environment]::GetEnvironmentVariable('HP_CI_LANE') + if ($lane -and -not $Row.ContainsKey('lane')) { $Row['lane'] = $lane } + $json = $Row | ConvertTo-Json -Compress -Depth 8 + Add-Content -LiteralPath $nd -Value $json -Encoding Ascii + Add-Content -LiteralPath $ciNd -Value $json -Encoding Ascii +} + +if (-not $IsWindows) { + Write-NdjsonRow ([ordered]@{ + id = 'self.gribapi_hook_probe.hidden_import' + req = 'REQ-009' + pass = $true + skip = $true + desc = 'gribapi hook --hidden-import probe skipped on non-Windows' + details = [ordered]@{ reason = 'non-windows-host' } + }) + exit 0 +} + +function Get-CondaBatPath { + $publicRoot = [Environment]::GetEnvironmentVariable('PUBLIC') + $publicRootClean = if ($publicRoot) { $publicRoot.Trim().Trim('"') } else { '' } + $candidates = @() + if ($publicRootClean) { + $candidates += Join-Path $publicRootClean 'Documents\Miniconda3\condabin\conda.bat' + } + $candidates += 'C:\Miniconda3\condabin\conda.bat' + $candidates += 'C:\ProgramData\Miniconda3\condabin\conda.bat' + $candidates += 'C:\Users\Public\Documents\Miniconda3\condabin\conda.bat' + return ($candidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1) +} + +$condaBat = Get-CondaBatPath +if (-not $condaBat) { + Write-NdjsonRow ([ordered]@{ + id = 'self.gribapi_hook_probe.hidden_import' + req = 'REQ-009' + pass = $true + skip = $true + desc = 'gribapi hook --hidden-import probe skipped: conda not found' + details = [ordered]@{ reason = 'conda-not-installed' } + }) + exit 0 +} + +$workDir = Join-Path $here '~selftest_gribapi_probe' +try { + if (Test-Path -LiteralPath $workDir) { Remove-Item -LiteralPath $workDir -Recurse -Force -ErrorAction Stop } + New-Item -ItemType Directory -Force -Path $workDir -ErrorAction Stop | Out-Null +} catch { + Write-NdjsonRow ([ordered]@{ + id = 'self.gribapi_hook_probe.hidden_import' + req = 'REQ-009' + pass = $false + desc = 'probe workspace preparation failed' + details = [ordered]@{ error = $_.Exception.Message } + }) + exit 1 +} + +$envName = 'gribapi_probe_env' +$envCreateLog = Join-Path $workDir 'env_create.log' +& cmd /c "call `"$condaBat`" env remove -y -n $envName >nul 2>&1" +& cmd /c "call `"$condaBat`" create -y -n $envName python pip pyinstaller pygrib eccodes python-eccodes --override-channels -c conda-forge > `"$envCreateLog`" 2>&1" +$envCreateExit = $LASTEXITCODE +$envCreateText = if (Test-Path -LiteralPath $envCreateLog) { Get-Content -LiteralPath $envCreateLog -Raw -Encoding ASCII } else { '' } + +if ($envCreateExit -ne 0) { + Write-Host "=== self.gribapi_hook_probe.hidden_import: conda env create failed ===" + Write-Host $envCreateText + Write-NdjsonRow ([ordered]@{ + id = 'self.gribapi_hook_probe.hidden_import' + req = 'REQ-009' + pass = $false + desc = 'conda env create for pygrib+eccodes+python-eccodes+pyinstaller failed -- cannot run the probe' + details = [ordered]@{ envCreateExit = $envCreateExit; envCreateTail = ($envCreateText -split "`n" | Select-Object -Last 40) -join "`n" } + }) + exit 1 +} + +$condaRoot = Split-Path -Path (Split-Path -Path $condaBat -Parent) -Parent +$envPython = Join-Path $condaRoot "envs\$envName\python.exe" +if (-not (Test-Path -LiteralPath $envPython)) { + Write-NdjsonRow ([ordered]@{ + id = 'self.gribapi_hook_probe.hidden_import' + req = 'REQ-009' + pass = $false + desc = 'conda env created but python.exe missing -- cannot run the probe' + details = [ordered]@{ envPython = $envPython } + }) + exit 1 +} + +$appCode = @' +import pygrib +print("pygrib-imported-ok") +'@ +Set-Content -Path (Join-Path $workDir 'app.py') -Value $appCode -Encoding ASCII + +function Invoke-ProbeBuild { + param( + [string]$Variant, + [string[]]$ExtraArgs + ) + $distDir = Join-Path $workDir "dist_$Variant" + $buildDir = Join-Path $workDir "build_$Variant" + $buildLog = Join-Path $workDir "build_$Variant.log" + $specName = "probe_$Variant" + $pyiArgs = @('-m', 'PyInstaller', '-y', '--onefile', '--name', $specName, + '--distpath', $distDir, '--workpath', $buildDir, + '--specpath', $workDir) + $ExtraArgs + @((Join-Path $workDir 'app.py')) + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $envPython + $psi.Arguments = ($pyiArgs | ForEach-Object { '"' + $_ + '"' }) -join ' ' + $psi.UseShellExecute = $false + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.WorkingDirectory = $workDir + $proc = [System.Diagnostics.Process]::Start($psi) + $stdout = $proc.StandardOutput.ReadToEndAsync() + $stderr = $proc.StandardError.ReadToEndAsync() + $completed = $proc.WaitForExit(600000) + $buildExit = if ($completed) { $proc.ExitCode } else { -1 } + if (-not $completed) { + try { & taskkill.exe /F /T /PID $proc.Id 2>$null 1>$null } catch {} + } + $stdoutText = '' + $stderrText = '' + if ($stdout.Wait(10000)) { try { $stdoutText = $stdout.Result } catch {} } + if ($stderr.Wait(10000)) { try { $stderrText = $stderr.Result } catch {} } + $combinedLog = $stdoutText + "`n" + $stderrText + Set-Content -LiteralPath $buildLog -Value $combinedLog -Encoding Ascii + + $dllWarning = $combinedLog -match [regex]::Escape("could not resolve 'eccodes.dll'") + $exePath = Join-Path $distDir "$specName.exe" + $exeExists = Test-Path -LiteralPath $exePath + # derived requirement: check for the DLL landing ANYWHERE under distDir, not just a flat + # eccodes.dll -- hook-gribapi.py's own directory-preservation logic (Finding 1) nests it + # under an "eccodes" subfolder on Windows, not the dist root. + $bundledDll = @(Get-ChildItem -Path $distDir -Recurse -Filter 'eccodes*.dll' -ErrorAction SilentlyContinue).Count -gt 0 + + $exeExit = -1 + $exeOut = '' + if ($exeExists) { + try { + $epsi = New-Object System.Diagnostics.ProcessStartInfo + $epsi.FileName = $exePath + $epsi.UseShellExecute = $false + $epsi.RedirectStandardOutput = $true + $epsi.RedirectStandardError = $true + $eproc = [System.Diagnostics.Process]::Start($epsi) + $eOutTask = $eproc.StandardOutput.ReadToEndAsync() + $eErrTask = $eproc.StandardError.ReadToEndAsync() + if ($eproc.WaitForExit(30000)) { + $exeExit = $eproc.ExitCode + } else { + try { & taskkill.exe /F /T /PID $eproc.Id 2>$null 1>$null } catch {} + $exeExit = -1 + } + $eOut = ''; $eErr = '' + if ($eOutTask.Wait(5000)) { try { $eOut = $eOutTask.Result } catch {} } + if ($eErrTask.Wait(5000)) { try { $eErr = $eErrTask.Result } catch {} } + $exeOut = $eOut + $eErr + } catch { + $exeOut = $_.Exception.Message + } + } + $dllLoadFailed = $exeOut -match 'DLL load failed' + $ranClean = ($exeExit -eq 0) -and ($exeOut -match 'pygrib-imported-ok') + + return [ordered]@{ + variant = $Variant + buildExit = $buildExit + dllWarningSeen = [bool]$dllWarning + exeExists = [bool]$exeExists + bundledDll = [bool]$bundledDll + exeExit = $exeExit + dllLoadFailed = [bool]$dllLoadFailed + ranClean = [bool]$ranClean + } +} + +$control = Invoke-ProbeBuild -Variant 'control' -ExtraArgs @() +$experiment = Invoke-ProbeBuild -Variant 'experiment' -ExtraArgs @('--hidden-import', 'gribapi') + +& cmd /c "call `"$condaBat`" env remove -y -n $envName >nul 2>&1" + +# derived requirement: "conclusive evidence" means both builds actually produced an EXE we could +# launch and observe -- if either build failed to even produce dist_*\probe_*.exe, the experiment +# itself is inconclusive (an infra/solve problem, not a finding about the hidden-import), so pass +# reflects "did this experiment run cleanly," not "did hidden-import fix the DLL." +$conclusive = $control.exeExists -and $experiment.exeExists +$hiddenImportHelped = $null +if ($conclusive) { + $hiddenImportHelped = (-not $control.ranClean) -and $experiment.ranClean +} + +Write-Host "=== self.gribapi_hook_probe.hidden_import evidence ===" +Write-Host ("control: buildExit={0} exeExists={1} bundledDll={2} exeExit={3} dllLoadFailed={4} ranClean={5}" -f ` + $control.buildExit, $control.exeExists, $control.bundledDll, $control.exeExit, $control.dllLoadFailed, $control.ranClean) +Write-Host ("experiment: buildExit={0} exeExists={1} bundledDll={2} exeExit={3} dllLoadFailed={4} ranClean={5}" -f ` + $experiment.buildExit, $experiment.exeExists, $experiment.bundledDll, $experiment.exeExit, $experiment.dllLoadFailed, $experiment.ranClean) +Write-Host ("conclusive={0} hiddenImportHelped={1}" -f $conclusive, $hiddenImportHelped) +Write-Host "=== end self.gribapi_hook_probe.hidden_import evidence ===" + +Write-NdjsonRow ([ordered]@{ + id = 'self.gribapi_hook_probe.hidden_import' + req = 'REQ-009' + pass = [bool]$conclusive + desc = 'empirical probe: does --hidden-import=gribapi make hook-gribapi.py bundle eccodes.dll for a pygrib build (CLAUDE.md Active Backlog Item 24 / PRD Requirement 1)' + details = [ordered]@{ + conclusive = $conclusive + hiddenImportHelped = $hiddenImportHelped + control = $control + experiment = $experiment + } +}) + +if (-not $conclusive) { exit 1 } +exit 0 From 8c7463eec41bba6b6c03833cb7e706baeea72109 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 13:25:46 +0000 Subject: [PATCH 2/3] Fix CodeRabbit review findings: real gating bug, robustness, naming - Real bug: continue-on-error on the CI step does NOT make this probe non-gating -- batch-check.yml's separate "Verdict from NDJSON" + "Enforce NDJSON failures" steps scan raw pass values across ALL rows and fail the conda-full lane job on any pass=false, independent of which step emitted it. This diagnostic/exploratory probe must never gate, so every exit path now emits pass=true (skip=true or details.conclusive carries the real outcome) and exits 0 unconditionally. - Tighten "conclusive evidence": track buildLaunched/buildCompleted/ buildOutputCaptured and exeLaunched/exeCompleted/exeOutputCaptured explicitly per variant, so a timed-out process, launch exception, or unreadable output can no longer masquerade as a real (negative) finding via the old -1/empty-string defaults. observationComplete now requires all three EXE-side conditions before ranClean is trusted. - Tilde-prefix the temp log files (~env_create.log, ~build_$Variant.log) per repo convention. - Add Write-Warning to the two taskkill catch blocks (matching selfapps_layered_e2e.ps1's own established precedent); the remaining empty catches are the same "best-effort .Result read after a bounded Wait()" pattern already accepted in that file. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01A4iE1BRSkUETwz237XeuTW --- tests/selfapps_gribapi_hook_probe.ps1 | 186 +++++++++++++++++--------- 1 file changed, 120 insertions(+), 66 deletions(-) diff --git a/tests/selfapps_gribapi_hook_probe.ps1 b/tests/selfapps_gribapi_hook_probe.ps1 index 671acc3f..c2217fb2 100644 --- a/tests/selfapps_gribapi_hook_probe.ps1 +++ b/tests/selfapps_gribapi_hook_probe.ps1 @@ -22,16 +22,16 @@ # Lane: conda-full only. Miniconda is always installed for real in that lane regardless of step # ordering (CLAUDE.md's lane table), so this avoids the CI-ordering placement constraints other # conda-dependent tests need (see selfapps_conda_bothfail.ps1's own placement note for contrast). -# Non-gating (continue-on-error at the step level) -- exploratory by nature, and the conda-forge -# solve for pygrib+eccodes+python-eccodes+pyinstaller together is untested and may need a real -# CI run to even confirm it resolves cleanly. # -# This test is deliberately NOT tied to a specific pass/fail expectation on the hidden-import -# question itself -- "pass" means the experiment ran to completion and produced conclusive -# evidence for both the control and experiment builds; the actual finding (did the hidden-import -# help) is recorded in details.hiddenImportHelped for a human to read, matching the established -# "informational, pass=true records an observed fact" convention used elsewhere (e.g. -# diag.conda.available). +# derived requirement: this row is genuinely diagnostic/exploratory, not a correctness +# assertion about the bootstrapper -- it must NEVER emit pass=false. A CodeRabbit review on this +# PR's first commit correctly found that `continue-on-error: true` on the CI step does NOT make +# a row non-gating on its own: batch-check.yml's separate "Verdict from NDJSON" + "Enforce +# NDJSON failures for gated lanes" steps scan raw NDJSON pass values across ALL rows and fail +# the conda-full lane job outright on any pass=false, independent of which step emitted it. +# Every exit path below (including genuine infra failures such as a failed conda env create) +# therefore emits pass=true and exits 0 -- skip=true or details.conclusive=false carries the +# real outcome instead of a gating failure. # # Emits: self.gribapi_hook_probe.hidden_import param() @@ -98,15 +98,16 @@ try { Write-NdjsonRow ([ordered]@{ id = 'self.gribapi_hook_probe.hidden_import' req = 'REQ-009' - pass = $false - desc = 'probe workspace preparation failed' - details = [ordered]@{ error = $_.Exception.Message } + pass = $true + skip = $true + desc = 'gribapi hook --hidden-import probe skipped: workspace preparation failed' + details = [ordered]@{ reason = 'workspace-prep-failed'; error = $_.Exception.Message } }) - exit 1 + exit 0 } $envName = 'gribapi_probe_env' -$envCreateLog = Join-Path $workDir 'env_create.log' +$envCreateLog = Join-Path $workDir '~env_create.log' & cmd /c "call `"$condaBat`" env remove -y -n $envName >nul 2>&1" & cmd /c "call `"$condaBat`" create -y -n $envName python pip pyinstaller pygrib eccodes python-eccodes --override-channels -c conda-forge > `"$envCreateLog`" 2>&1" $envCreateExit = $LASTEXITCODE @@ -118,11 +119,12 @@ if ($envCreateExit -ne 0) { Write-NdjsonRow ([ordered]@{ id = 'self.gribapi_hook_probe.hidden_import' req = 'REQ-009' - pass = $false - desc = 'conda env create for pygrib+eccodes+python-eccodes+pyinstaller failed -- cannot run the probe' - details = [ordered]@{ envCreateExit = $envCreateExit; envCreateTail = ($envCreateText -split "`n" | Select-Object -Last 40) -join "`n" } + pass = $true + skip = $true + desc = 'gribapi hook --hidden-import probe skipped: conda env create for pygrib+eccodes+python-eccodes+pyinstaller failed' + details = [ordered]@{ reason = 'env-create-failed'; envCreateExit = $envCreateExit; envCreateTail = ($envCreateText -split "`n" | Select-Object -Last 40) -join "`n" } }) - exit 1 + exit 0 } $condaRoot = Split-Path -Path (Split-Path -Path $condaBat -Parent) -Parent @@ -131,11 +133,12 @@ if (-not (Test-Path -LiteralPath $envPython)) { Write-NdjsonRow ([ordered]@{ id = 'self.gribapi_hook_probe.hidden_import' req = 'REQ-009' - pass = $false - desc = 'conda env created but python.exe missing -- cannot run the probe' - details = [ordered]@{ envPython = $envPython } + pass = $true + skip = $true + desc = 'gribapi hook --hidden-import probe skipped: conda env created but python.exe missing' + details = [ordered]@{ reason = 'env-python-missing'; envPython = $envPython } }) - exit 1 + exit 0 } $appCode = @' @@ -151,30 +154,55 @@ function Invoke-ProbeBuild { ) $distDir = Join-Path $workDir "dist_$Variant" $buildDir = Join-Path $workDir "build_$Variant" - $buildLog = Join-Path $workDir "build_$Variant.log" + $buildLog = Join-Path $workDir "~build_$Variant.log" $specName = "probe_$Variant" $pyiArgs = @('-m', 'PyInstaller', '-y', '--onefile', '--name', $specName, '--distpath', $distDir, '--workpath', $buildDir, '--specpath', $workDir) + $ExtraArgs + @((Join-Path $workDir 'app.py')) - $psi = New-Object System.Diagnostics.ProcessStartInfo - $psi.FileName = $envPython - $psi.Arguments = ($pyiArgs | ForEach-Object { '"' + $_ + '"' }) -join ' ' - $psi.UseShellExecute = $false - $psi.RedirectStandardOutput = $true - $psi.RedirectStandardError = $true - $psi.WorkingDirectory = $workDir - $proc = [System.Diagnostics.Process]::Start($psi) - $stdout = $proc.StandardOutput.ReadToEndAsync() - $stderr = $proc.StandardError.ReadToEndAsync() - $completed = $proc.WaitForExit(600000) - $buildExit = if ($completed) { $proc.ExitCode } else { -1 } - if (-not $completed) { - try { & taskkill.exe /F /T /PID $proc.Id 2>$null 1>$null } catch {} - } + + # derived requirement (CodeRabbit review finding): a launch exception, a timeout, or a + # failed output read must each be tracked explicitly and distinctly from a genuine build/run + # result -- collapsing them all into the same -1/empty-string defaults let an incomplete + # observation masquerade as a real (negative) finding. buildLaunched/buildCompleted/ + # buildOutputCaptured (and the EXE-side equivalents below) make "did we actually observe + # this variant's real behavior" a first-class, checkable fact instead of an implicit + # assumption baked into $conclusive. + $buildLaunched = $false + $buildCompleted = $false + $buildOutputCaptured = $false + $buildExit = -1 $stdoutText = '' $stderrText = '' - if ($stdout.Wait(10000)) { try { $stdoutText = $stdout.Result } catch {} } - if ($stderr.Wait(10000)) { try { $stderrText = $stderr.Result } catch {} } + try { + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $envPython + $psi.Arguments = ($pyiArgs | ForEach-Object { '"' + $_ + '"' }) -join ' ' + $psi.UseShellExecute = $false + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.WorkingDirectory = $workDir + $proc = [System.Diagnostics.Process]::Start($psi) + $buildLaunched = $true + $stdoutTask = $proc.StandardOutput.ReadToEndAsync() + $stderrTask = $proc.StandardError.ReadToEndAsync() + $buildCompleted = $proc.WaitForExit(600000) + if ($buildCompleted) { + $buildExit = $proc.ExitCode + } else { + try { + & taskkill.exe /F /T /PID $proc.Id 2>$null 1>$null + } catch { + Write-Warning "taskkill failed for build PID $($proc.Id): $($_.Exception.Message)" + } + } + $stdoutOk = $stdoutTask.Wait(10000) + $stderrOk = $stderrTask.Wait(10000) + if ($stdoutOk) { try { $stdoutText = $stdoutTask.Result } catch {} } + if ($stderrOk) { try { $stderrText = $stderrTask.Result } catch {} } + $buildOutputCaptured = $stdoutOk -and $stderrOk + } catch { + Write-Warning "PyInstaller launch failed for variant '$Variant': $($_.Exception.Message)" + } $combinedLog = $stdoutText + "`n" + $stderrText Set-Content -LiteralPath $buildLog -Value $combinedLog -Encoding Ascii @@ -186,6 +214,9 @@ function Invoke-ProbeBuild { # under an "eccodes" subfolder on Windows, not the dist root. $bundledDll = @(Get-ChildItem -Path $distDir -Recurse -Filter 'eccodes*.dll' -ErrorAction SilentlyContinue).Count -gt 0 + $exeLaunched = $false + $exeCompleted = $false + $exeOutputCaptured = $false $exeExit = -1 $exeOut = '' if ($exeExists) { @@ -196,34 +227,55 @@ function Invoke-ProbeBuild { $epsi.RedirectStandardOutput = $true $epsi.RedirectStandardError = $true $eproc = [System.Diagnostics.Process]::Start($epsi) + $exeLaunched = $true $eOutTask = $eproc.StandardOutput.ReadToEndAsync() $eErrTask = $eproc.StandardError.ReadToEndAsync() - if ($eproc.WaitForExit(30000)) { + $exeCompleted = $eproc.WaitForExit(30000) + if ($exeCompleted) { $exeExit = $eproc.ExitCode } else { - try { & taskkill.exe /F /T /PID $eproc.Id 2>$null 1>$null } catch {} - $exeExit = -1 + try { + & taskkill.exe /F /T /PID $eproc.Id 2>$null 1>$null + } catch { + Write-Warning "taskkill failed for EXE PID $($eproc.Id): $($_.Exception.Message)" + } } $eOut = ''; $eErr = '' - if ($eOutTask.Wait(5000)) { try { $eOut = $eOutTask.Result } catch {} } - if ($eErrTask.Wait(5000)) { try { $eErr = $eErrTask.Result } catch {} } + $eOutOk = $eOutTask.Wait(5000) + $eErrOk = $eErrTask.Wait(5000) + if ($eOutOk) { try { $eOut = $eOutTask.Result } catch {} } + if ($eErrOk) { try { $eErr = $eErrTask.Result } catch {} } + $exeOutputCaptured = $eOutOk -and $eErrOk $exeOut = $eOut + $eErr } catch { - $exeOut = $_.Exception.Message + Write-Warning "EXE launch failed for variant '$Variant': $($_.Exception.Message)" } } $dllLoadFailed = $exeOut -match 'DLL load failed' - $ranClean = ($exeExit -eq 0) -and ($exeOut -match 'pygrib-imported-ok') + + # derived requirement: an "observation" only counts as complete when the EXE genuinely + # launched, ran to completion within the timeout (not killed), and both output streams were + # fully captured -- only then can ranClean's absence be trusted as a real negative result + # rather than an artifact of a hung process or a truncated read. + $observationComplete = $exeExists -and $exeLaunched -and $exeCompleted -and $exeOutputCaptured + $ranClean = $observationComplete -and ($exeExit -eq 0) -and ($exeOut -match 'pygrib-imported-ok') return [ordered]@{ - variant = $Variant - buildExit = $buildExit - dllWarningSeen = [bool]$dllWarning - exeExists = [bool]$exeExists - bundledDll = [bool]$bundledDll - exeExit = $exeExit - dllLoadFailed = [bool]$dllLoadFailed - ranClean = [bool]$ranClean + variant = $Variant + buildLaunched = [bool]$buildLaunched + buildCompleted = [bool]$buildCompleted + buildOutputCaptured = [bool]$buildOutputCaptured + buildExit = $buildExit + dllWarningSeen = [bool]$dllWarning + exeExists = [bool]$exeExists + bundledDll = [bool]$bundledDll + exeLaunched = [bool]$exeLaunched + exeCompleted = [bool]$exeCompleted + exeOutputCaptured = [bool]$exeOutputCaptured + observationComplete = [bool]$observationComplete + exeExit = $exeExit + dllLoadFailed = [bool]$dllLoadFailed + ranClean = [bool]$ranClean } } @@ -232,28 +284,31 @@ $experiment = Invoke-ProbeBuild -Variant 'experiment' -ExtraArgs @('--hidden-imp & cmd /c "call `"$condaBat`" env remove -y -n $envName >nul 2>&1" -# derived requirement: "conclusive evidence" means both builds actually produced an EXE we could -# launch and observe -- if either build failed to even produce dist_*\probe_*.exe, the experiment -# itself is inconclusive (an infra/solve problem, not a finding about the hidden-import), so pass -# reflects "did this experiment run cleanly," not "did hidden-import fix the DLL." -$conclusive = $control.exeExists -and $experiment.exeExists +# derived requirement (CodeRabbit review finding): "conclusive evidence" must require a +# COMPLETE runtime observation for both variants, not merely that a dist\*.exe file exists on +# disk -- a build that produced an EXE which then hung or whose output read timed out is NOT +# evidence about whether the hidden-import helped, and must not be treated as if it were. +$conclusive = $control.observationComplete -and $experiment.observationComplete $hiddenImportHelped = $null if ($conclusive) { $hiddenImportHelped = (-not $control.ranClean) -and $experiment.ranClean } Write-Host "=== self.gribapi_hook_probe.hidden_import evidence ===" -Write-Host ("control: buildExit={0} exeExists={1} bundledDll={2} exeExit={3} dllLoadFailed={4} ranClean={5}" -f ` - $control.buildExit, $control.exeExists, $control.bundledDll, $control.exeExit, $control.dllLoadFailed, $control.ranClean) -Write-Host ("experiment: buildExit={0} exeExists={1} bundledDll={2} exeExit={3} dllLoadFailed={4} ranClean={5}" -f ` - $experiment.buildExit, $experiment.exeExists, $experiment.bundledDll, $experiment.exeExit, $experiment.dllLoadFailed, $experiment.ranClean) +Write-Host ("control: buildExit={0} exeExists={1} bundledDll={2} observationComplete={3} exeExit={4} dllLoadFailed={5} ranClean={6}" -f ` + $control.buildExit, $control.exeExists, $control.bundledDll, $control.observationComplete, $control.exeExit, $control.dllLoadFailed, $control.ranClean) +Write-Host ("experiment: buildExit={0} exeExists={1} bundledDll={2} observationComplete={3} exeExit={4} dllLoadFailed={5} ranClean={6}" -f ` + $experiment.buildExit, $experiment.exeExists, $experiment.bundledDll, $experiment.observationComplete, $experiment.exeExit, $experiment.dllLoadFailed, $experiment.ranClean) Write-Host ("conclusive={0} hiddenImportHelped={1}" -f $conclusive, $hiddenImportHelped) Write-Host "=== end self.gribapi_hook_probe.hidden_import evidence ===" +# derived requirement: pass is ALWAYS true -- this row is diagnostic/exploratory (see the file +# header comment), never a bootstrapper-correctness assertion, so it must never fail the +# conda-full lane's gated NDJSON verdict regardless of what this probe observed. Write-NdjsonRow ([ordered]@{ id = 'self.gribapi_hook_probe.hidden_import' req = 'REQ-009' - pass = [bool]$conclusive + pass = $true desc = 'empirical probe: does --hidden-import=gribapi make hook-gribapi.py bundle eccodes.dll for a pygrib build (CLAUDE.md Active Backlog Item 24 / PRD Requirement 1)' details = [ordered]@{ conclusive = $conclusive @@ -263,5 +318,4 @@ Write-NdjsonRow ([ordered]@{ } }) -if (-not $conclusive) { exit 1 } exit 0 From 87a678a759c008c7c349dae3196186a131e4bd66 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 13:42:03 +0000 Subject: [PATCH 3/3] Fix critical --onefile bug + 3 more CodeRabbit review findings - Critical: switch --onefile to --onedir. --onefile embeds all bundled support files (DLLs included) inside the compressed EXE, extracting them only to a runtime-only _MEIxxxxxx temp directory that never touches disk under the dist path -- the bundledDll filesystem scan could therefore NEVER detect a positive result under --onefile, regardless of whether the hidden-import actually worked. This was a structural flaw in the experiment itself, not a finding about the research question. --onedir writes bundled files under dist_$Variant\probe_$Variant\, where the scan can see them; $exePath updated to point into that subdirectory. - hiddenImportHelped now derived from bundledDll (a structural, build-output fact) instead of ranClean (a runtime-behavior fact that conflates "did it bundle" with "did it happen to run cleanly for any reason"); ranClean stays recorded per-variant as supplementary evidence. - Add buildObservationComplete (build launched, completed within budget, output captured, exited 0) as a prerequisite for observationComplete, so a build that timed out or left a partial EXE on disk before being killed can no longer be treated as conclusive evidence. - Clean up the scratch conda env before the python.exe-missing early exit, matching every other post-create exit path. - Verify taskkill.exe actually terminated the process (check $LASTEXITCODE + a bounded WaitForExit) instead of assuming success whenever the call itself didn't throw. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01A4iE1BRSkUETwz237XeuTW --- docs/agent-ndjson.md | 47 ++++++++++----- tests/selfapps_gribapi_hook_probe.ps1 | 84 ++++++++++++++++++++------- 2 files changed, 95 insertions(+), 36 deletions(-) diff --git a/docs/agent-ndjson.md b/docs/agent-ndjson.md index b954133c..18332597 100644 --- a/docs/agent-ndjson.md +++ b/docs/agent-ndjson.md @@ -455,12 +455,19 @@ are architecturally independent bindings to the same C library) but is still wor evidence per explicit owner instruction. **Deliberately standalone -- does NOT invoke `run_setup.bat` or the REQ-009 provider cascade at -all.** Creates its own scratch conda env directly (`gribapi_probe_env`, removed both before and -after) with `python pygrib eccodes python-eccodes pyinstaller pip --override-channels -c +all.** Creates its own scratch conda env directly (`gribapi_probe_env`, removed after every exit +path including early ones, per a CodeRabbit review finding that the original version leaked it on +one guard) with `python pygrib eccodes python-eccodes pyinstaller pip --override-channels -c conda-forge`, then runs PyInstaller twice from that env's own interpreter against a trivial `import pygrib` stub: once as a control (no extra flags), once with `--hidden-import gribapi` -added. For each build, checks whether the `could not resolve 'eccodes.dll'` build-time warning -still appears, whether any `eccodes*.dll` landed anywhere under the dist directory (recursive -- +added. **Builds use `--onedir`, not `--onefile`** (fixed from the first version, another +CodeRabbit finding) -- `--onefile` embeds bundled support files, DLLs included, inside the +compressed EXE itself, extracting them only to a runtime-only `_MEIxxxxxx` temp directory that +never touches disk under the dist path, which would have made the DLL-bundling scan structurally +unable to ever detect a positive result regardless of whether the hidden-import worked. `--onedir` +writes bundled files under `dist_$Variant\probe_$Variant\`, where the scan can actually see them. +For each build, checks whether the `could not resolve 'eccodes.dll'` build-time warning still +appears, whether any `eccodes*.dll` landed anywhere under that directory (recursive -- `hook-gribapi.py`'s own directory-preservation logic nests it under an `eccodes` subfolder on Windows, not the dist root), and whether the resulting EXE actually runs clean (exits 0 and prints its own success token, as opposed to failing with `DLL load failed`). @@ -468,19 +475,31 @@ its own success token, as opposed to failing with `DLL load failed`). **`pass` reflects whether the experiment ran to completion and produced conclusive evidence, NOT whether the hidden-import "worked" -- there is no `pass`/`fail` on the actual research question itself, only a recorded finding.** `details.hiddenImportHelped` (`true`/`false`/`null`) is the -actual answer: `null` when the experiment was inconclusive (either build never produced an EXE to -test, most likely a conda-forge solve failure for the three grib-related packages together, which -is itself a valid, useful finding), otherwise `true` only if the control build failed to run clean -AND the experiment build did. `pass=false` covers a genuine infra failure (conda env create -failed, or python.exe missing from the created env) that prevented the probe from running at all --- distinct from `pass=true, hiddenImportHelped=null`, which means the probe ran but the builds -themselves were inconclusive. +actual answer, determined from `bundledDll` (a structural, build-output fact -- did the DLL +actually land on disk), NOT from `ranClean` (a runtime-behavior fact kept only as supplementary +per-variant evidence) -- a review finding correctly pointed out these are two distinct questions +that a runtime-only signal would have conflated. `null` when the experiment was inconclusive +(either variant's observation was incomplete), otherwise `true` only if the control build did NOT +bundle the DLL AND the experiment build did. An "observation" requires BOTH the build itself +(launched, completed within its 600s budget, output fully captured, exited 0) AND the resulting +EXE (launched, completed within its 30s budget, output fully captured) to be complete -- +`buildObservationComplete`/`observationComplete` are both explicit, checkable per-variant facts +(not inferred from `dist\*.exe` merely existing on disk) precisely so a timed-out or partially- +written build/run can never masquerade as real evidence. `pass=false` covers a genuine infra +failure (conda env create failed, or python.exe missing from the created env) that prevented the +probe from running at all -- distinct from `pass=true, hiddenImportHelped=null`, which means the +probe ran but the builds themselves were inconclusive. Lane: `conda-full` only, gated on `steps.conda_avail.outputs.available == 'true'` (same gate the 27 other conda-full-only self-tests already use) -- guarantees real Miniconda is present without -any step-ordering placement constraint. Non-gating (`continue-on-error: true` at the step level): -exploratory by design, and the conda-forge solve for `pygrib`+`eccodes`+`python-eccodes`+ -`pyinstaller` together in one env is unproven as of this test's first landing. +any step-ordering placement constraint. Non-gating in practice, though NOT via `continue-on-error` +alone (a review finding on this PR's first commit correctly caught that this does not, by itself, +exempt a row from `batch-check.yml`'s separate NDJSON-verdict enforcement step, which fails the +`conda-full` lane job on any raw `pass=false` regardless of which step emitted it) -- every exit +path in the test script emits `pass=true` unconditionally, with `skip`/`details.conclusive` +carrying the real outcome instead. Exploratory by design: the conda-forge solve for +`pygrib`+`eccodes`+`python-eccodes`+`pyinstaller` together in one env is unproven as of this +test's first landing. ``` self.gribapi_hook_probe.hidden_import diff --git a/tests/selfapps_gribapi_hook_probe.ps1 b/tests/selfapps_gribapi_hook_probe.ps1 index c2217fb2..f480428e 100644 --- a/tests/selfapps_gribapi_hook_probe.ps1 +++ b/tests/selfapps_gribapi_hook_probe.ps1 @@ -130,6 +130,10 @@ if ($envCreateExit -ne 0) { $condaRoot = Split-Path -Path (Split-Path -Path $condaBat -Parent) -Parent $envPython = Join-Path $condaRoot "envs\$envName\python.exe" if (-not (Test-Path -LiteralPath $envPython)) { + # derived requirement (CodeRabbit review finding): the env genuinely got created by the + # successful conda create above -- this guard must clean it up too, matching every other + # post-create exit path, or a python.exe-missing run leaks gribapi_probe_env on the runner. + & cmd /c "call `"$condaBat`" env remove -y -n $envName >nul 2>&1" Write-NdjsonRow ([ordered]@{ id = 'self.gribapi_hook_probe.hidden_import' req = 'REQ-009' @@ -156,7 +160,14 @@ function Invoke-ProbeBuild { $buildDir = Join-Path $workDir "build_$Variant" $buildLog = Join-Path $workDir "~build_$Variant.log" $specName = "probe_$Variant" - $pyiArgs = @('-m', 'PyInstaller', '-y', '--onefile', '--name', $specName, + # derived requirement (CodeRabbit review finding): --onefile embeds every bundled support + # file (including any DLL the hook would add) INSIDE the compressed EXE -- PyInstaller only + # extracts them to a runtime-only _MEIxxxxxx temp directory, never to $distDir on disk. The + # $bundledDll filesystem scan below could therefore never find anything under --onefile, + # regardless of whether hook-gribapi.py actually bundled the DLL -- a structural blind spot + # in the experiment itself, not a finding about the hidden-import. --onedir writes bundled + # files (DLLs included) directly under $distDir\$specName\, where the scan can see them. + $pyiArgs = @('-m', 'PyInstaller', '-y', '--onedir', '--name', $specName, '--distpath', $distDir, '--workpath', $buildDir, '--specpath', $workDir) + $ExtraArgs + @((Join-Path $workDir 'app.py')) @@ -189,8 +200,16 @@ function Invoke-ProbeBuild { if ($buildCompleted) { $buildExit = $proc.ExitCode } else { + # derived requirement (CodeRabbit review finding): taskkill.exe is a native command -- + # a nonzero exit lands in $LASTEXITCODE, not a PowerShell exception, so the catch block + # alone never detects a failed kill. Check $LASTEXITCODE AND confirm the process + # actually exited via a bounded WaitForExit before trusting the kill succeeded. try { & taskkill.exe /F /T /PID $proc.Id 2>$null 1>$null + $taskkillExit = $LASTEXITCODE + if (-not $proc.WaitForExit(5000)) { + Write-Warning "taskkill did not stop build PID $($proc.Id) within 5s (exit code $taskkillExit)" + } } catch { Write-Warning "taskkill failed for build PID $($proc.Id): $($_.Exception.Message)" } @@ -207,7 +226,9 @@ function Invoke-ProbeBuild { Set-Content -LiteralPath $buildLog -Value $combinedLog -Encoding Ascii $dllWarning = $combinedLog -match [regex]::Escape("could not resolve 'eccodes.dll'") - $exePath = Join-Path $distDir "$specName.exe" + # derived requirement: --onedir places the EXE inside its own $specName subdirectory under + # $distDir (dist_$Variant\probe_$Variant\probe_$Variant.exe), not directly in $distDir. + $exePath = Join-Path (Join-Path $distDir $specName) "$specName.exe" $exeExists = Test-Path -LiteralPath $exePath # derived requirement: check for the DLL landing ANYWHERE under distDir, not just a flat # eccodes.dll -- hook-gribapi.py's own directory-preservation logic (Finding 1) nests it @@ -236,6 +257,10 @@ function Invoke-ProbeBuild { } else { try { & taskkill.exe /F /T /PID $eproc.Id 2>$null 1>$null + $taskkillExit = $LASTEXITCODE + if (-not $eproc.WaitForExit(5000)) { + Write-Warning "taskkill did not stop EXE PID $($eproc.Id) within 5s (exit code $taskkillExit)" + } } catch { Write-Warning "taskkill failed for EXE PID $($eproc.Id): $($_.Exception.Message)" } @@ -253,29 +278,37 @@ function Invoke-ProbeBuild { } $dllLoadFailed = $exeOut -match 'DLL load failed' + # derived requirement (CodeRabbit review finding): a build that timed out or whose output + # went uncaptured must not be treated as a valid observation just because a (possibly + # partial) EXE happened to land on disk before the kill -- buildObservationComplete gates on + # the BUILD's own completeness (launched, finished within budget, output captured, exited 0), + # independent of whatever the EXE-side observation below finds. + $buildObservationComplete = $buildLaunched -and $buildCompleted -and $buildOutputCaptured -and ($buildExit -eq 0) + # derived requirement: an "observation" only counts as complete when the EXE genuinely # launched, ran to completion within the timeout (not killed), and both output streams were # fully captured -- only then can ranClean's absence be trusted as a real negative result # rather than an artifact of a hung process or a truncated read. - $observationComplete = $exeExists -and $exeLaunched -and $exeCompleted -and $exeOutputCaptured + $observationComplete = $buildObservationComplete -and $exeExists -and $exeLaunched -and $exeCompleted -and $exeOutputCaptured $ranClean = $observationComplete -and ($exeExit -eq 0) -and ($exeOut -match 'pygrib-imported-ok') return [ordered]@{ - variant = $Variant - buildLaunched = [bool]$buildLaunched - buildCompleted = [bool]$buildCompleted - buildOutputCaptured = [bool]$buildOutputCaptured - buildExit = $buildExit - dllWarningSeen = [bool]$dllWarning - exeExists = [bool]$exeExists - bundledDll = [bool]$bundledDll - exeLaunched = [bool]$exeLaunched - exeCompleted = [bool]$exeCompleted - exeOutputCaptured = [bool]$exeOutputCaptured - observationComplete = [bool]$observationComplete - exeExit = $exeExit - dllLoadFailed = [bool]$dllLoadFailed - ranClean = [bool]$ranClean + variant = $Variant + buildLaunched = [bool]$buildLaunched + buildCompleted = [bool]$buildCompleted + buildOutputCaptured = [bool]$buildOutputCaptured + buildObservationComplete = [bool]$buildObservationComplete + buildExit = $buildExit + dllWarningSeen = [bool]$dllWarning + exeExists = [bool]$exeExists + bundledDll = [bool]$bundledDll + exeLaunched = [bool]$exeLaunched + exeCompleted = [bool]$exeCompleted + exeOutputCaptured = [bool]$exeOutputCaptured + observationComplete = [bool]$observationComplete + exeExit = $exeExit + dllLoadFailed = [bool]$dllLoadFailed + ranClean = [bool]$ranClean } } @@ -285,13 +318,20 @@ $experiment = Invoke-ProbeBuild -Variant 'experiment' -ExtraArgs @('--hidden-imp & cmd /c "call `"$condaBat`" env remove -y -n $envName >nul 2>&1" # derived requirement (CodeRabbit review finding): "conclusive evidence" must require a -# COMPLETE runtime observation for both variants, not merely that a dist\*.exe file exists on -# disk -- a build that produced an EXE which then hung or whose output read timed out is NOT -# evidence about whether the hidden-import helped, and must not be treated as if it were. +# COMPLETE observation for both variants -- observationComplete (per variant, above) now +# requires buildObservationComplete as a prerequisite, so a build that timed out, whose output +# went uncaptured, or that merely left a partial EXE on disk before being killed can never be +# treated as conclusive just because dist\*.exe happened to exist. $conclusive = $control.observationComplete -and $experiment.observationComplete $hiddenImportHelped = $null if ($conclusive) { - $hiddenImportHelped = (-not $control.ranClean) -and $experiment.ranClean + # derived requirement (CodeRabbit review finding): the actual research question is whether + # the hidden-import causes hook-gribapi.py to BUNDLE eccodes.dll -- bundledDll (a structural, + # build-output fact) is the direct signal for that; ranClean (a runtime-behavior fact) is + # supplementary evidence recorded separately in details.control/details.experiment, not the + # primary "did it help" determination, since a clean run could in principle happen for a + # reason unrelated to DLL bundling (or vice versa). + $hiddenImportHelped = (-not $control.bundledDll) -and $experiment.bundledDll } Write-Host "=== self.gribapi_hook_probe.hidden_import evidence ==="