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..18332597 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,67 @@ 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 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. **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`). + +**`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, 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 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 +``` + ## 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..f480428e --- /dev/null +++ b/tests/selfapps_gribapi_hook_probe.ps1 @@ -0,0 +1,361 @@ +# 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). +# +# 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() +$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 = $true + skip = $true + desc = 'gribapi hook --hidden-import probe skipped: workspace preparation failed' + details = [ordered]@{ reason = 'workspace-prep-failed'; error = $_.Exception.Message } + }) + exit 0 +} + +$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 = $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 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' + 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 0 +} + +$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" + # 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')) + + # 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 = '' + 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 { + # 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)" + } + } + $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 + + $dllWarning = $combinedLog -match [regex]::Escape("could not resolve 'eccodes.dll'") + # 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 + # 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) { + 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) + $exeLaunched = $true + $eOutTask = $eproc.StandardOutput.ReadToEndAsync() + $eErrTask = $eproc.StandardError.ReadToEndAsync() + $exeCompleted = $eproc.WaitForExit(30000) + if ($exeCompleted) { + $exeExit = $eproc.ExitCode + } 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)" + } + } + $eOut = ''; $eErr = '' + $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 { + Write-Warning "EXE launch failed for variant '$Variant': $($_.Exception.Message)" + } + } + $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 = $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 + 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 + } +} + +$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 (CodeRabbit review finding): "conclusive evidence" must require a +# 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) { + # 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 ===" +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 = $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 + hiddenImportHelped = $hiddenImportHelped + control = $control + experiment = $experiment + } +}) + +exit 0