tests/robco-diagnostics.ps1 mirrored the Node test runner. The deletion was not taken on faith: an independent auditor checked out the base commit, ran both runners, and proved zero coverage was lost — every PowerShell test either shelled out to node (the same engine) or grepped the same UTF-8 bytes, so none could carry unique value. The original audit, with its per-claim verification and timing measurements, is inlined below.
Provenance: Independent Protocol-8 audit of the deletion commit, which ran BOTH runners at the base commit rather than trusting the test count.
ORIGINAL RECORD · planning/2.8.5/audits/AUDIT_U12_ps-runner-deletion.md
# AUDIT — 2.8.5 U-B3: PowerShell test-runner deletion + Protocol 15 retirement
**Commit audited:** `495180b` (on `dev`) · **Base:** `478442f`
**Auditor:** Independent (Protocol 8 stage 3) — started from the diff, verified every central claim from the code, ran both runners.
**Date:** 2026-07-12
## VERDICT: ✅ ACCEPT WITH FOLLOW-UPS
This is a sound, well-reasoned deletion. **No test coverage was lost** — proven by executing *both* runners at the base commit, not by trusting the count. The central justification holds up under direct code inspection. The gate is whole. The 4 repurposed tests are genuine, non-vacuous guards. The user-facing CHANGELOG is honest. Timing claims are accurate and I independently reconstructed the one figure the implementer could not measure.
One real (minor) doc-drift miss: **CLAUDE.md Protocol 40** still describes "both canonical runners" and names `.ps1`. Not a blocker; the commit message does not falsely claim to have swept it.
---
## Headline — was coverage actually lost? NO. (proven from the deleted file)
I did not rely on the "3002 → 3002" count. I checked out the base commit (`478442f`) in an isolated worktree and **ran both runners**:
| Runner (at base `478442f`) | Result |
| -------------------------- | ------ |
| Node `robco-diagnostics.js` | 3002 total (3001 pass + 1 env-artifact fail) |
| PowerShell `robco-diagnostics.ps1` | 3002 total (3001 pass + **the identical** env-artifact fail) |
The single "failure" in both is `pre-commit hook enforces strict monotonic rev increase` — a git-worktree/origin-comparison artifact, identical in both runners, unrelated to this commit. In the real checkout the Node runner is a clean **ALL 3002 TESTS PASSED**.
Both runners produced the *same* 3002 total and the *same* failing test → they were at **exact total parity** at the moment of deletion. Per-suite parity was additionally gate-enforced: base CI runs `npm run gate` on `ubuntu-latest` (which ships `pwsh`), so `gate.js`'s per-suite parity parser (Node title+count ≡ PS title+count for suites 49+, aggregate for 1–48) had to pass for base to be green. It was green. **The mirror's 3002 were all already in Node.**
Even setting parity aside: every PS test is structurally incapable of independent coverage (see next section), so no PS-only test could have carried unique value even if one existed.
---
## Claim verification (each confirmed from the code)
### Claim 1 — "behavioral" PS tests shelled out to `node` ✅ CONFIRMED
224 occurrences of `vm.createContext` / `vm.runInContext` inside the `.ps1`. These are JS strings built in PowerShell heredocs and piped to the node binary: e.g. line 328 `$out = ($repScript | node 2>&1 | Out-String)`, where `$repScript` reconstructs the exact `require('vm')` sandbox harness the Node runner uses. **Same execution engine — never independent.** Cannot catch a Node-engine bug; it *is* node.
### Claim 2 — static PS tests force UTF-8 and grep the same bytes ✅ CONFIRMED
`Read-Src` uses `[IO.File]::ReadAllText()`; most suites pass `[System.Text.Encoding]::UTF8` explicitly. Same content, same regex/`.Contains()` greps as Node's `fs.readFileSync(…, 'utf8')`.
### Claim 3 — Suite 90 mojibake guard is safe without PowerShell ✅ CONFIRMED (the pivotal check)
Compared the deleted PS Suite 90 (rd.ps1 ~L6145) against the surviving Node Suite 90 (`robco-diagnostics.js` L10667):
- **Both force UTF-8 decode** — PS `[System.IO.File]::ReadAllText(path, [System.Text.Encoding]::UTF8)`; Node `readFile(rel)` / `fs.readFileSync(…, 'utf8')`.
- **Both search the identical mojibake signatures** — U+FFFD, U+00E2+U+20AC (`â€`, double-encoded E2-80-xx), U+00E2+U+2013 (`â–`, E2-96-xx), plus the CHANGELOG full-sequence checks (em/en-dash, U+00C3+U+2014).
- **Both over the identical file set** — `Get-AllJsFiles` / `allJsFiles()` glob + 3 docs + CHANGELOG.
The guard inspects **already-committed bytes**. When PowerShell double-encodes a file, the corruption is written to disk; *any* UTF-8 reader decodes those bytes into the same `â€`/`â–`/U+FFFD characters. Node reads them identically — no PowerShell-specific detection capability exists, and PS 5.1 would actually be *worse* at reading its own corruption (Windows-1252 default). Even the invalid-byte edge case is parity-safe: both Node's utf8 decoder and .NET's `Encoding.UTF8` replacement-fallback emit U+FFFD, which the guard checks for. BOM differences (Node keeps a leading U+FEFF, .NET strips it) are irrelevant — U+FEFF is not in the mojibake set. **Nothing in the deleted runner was load-bearing for detecting PowerShell corruption.**
### Claim 4 — no Windows-specific guarantee / no historical unique catch ✅ CONFIRMED
No PS-only invariant found. The one protocol that *exists because of* PowerShell (Protocol 39) is guarded by content inspection that survives intact in Node.
---
## The 4 repurposed tests — genuine guards, not padding ✅
All four were inverted **in place** (not deleted/re-added), which is what keeps the total at exactly 3002 with no product coverage change. Verified each is non-vacuous:
| Suite | New assertion | Fails if… |
| ----- | ------------- | --------- |
| 28 | `!fs.existsSync('tests/robco-diagnostics.ps1')` | the mirror file returns |
| 31.2 | gate runs `node …js` **and** `!/\.ps1/` **and** ci calls `npm run gate` | gate re-references the mirror |
| 50.6 | `!\bpwsh\b` **and** `!\bpowershell\b` **and** `!/\.ps1/` in gate.js | any PS invocation returns to the gate |
| 128.5 | gate refs `…js` **and** `!/\.ps1/` | dual-runner wiring returns |
**Liveness proof:** I temporarily restored the `.ps1` into the tree and re-ran the Node runner → Suite 28's guard **failed**, runner exited 1. Removed it; tree clean. (Only 28 fired, correctly — the other three guard gate.js *references*, a distinct facet, so they'd require re-adding the wiring to trip. Each guards a different regression vector.) Non-vacuous, non-padding.
---
## Gate integrity ✅ intact
`scripts/gate.js` after the change runs: 1 ESLint, 2 Prettier, 3 boot-chain preflight, 4 **Node runner**, then (non-`--fast`) 5 Playwright availability, 6 boot-smoke, 7 render-check (360/412), 8 a11y (axe), 9 test.html headless audit. Only the PS audit + ~130-line parity parser were removed. Nothing else weakened.
- `package.json`: `test`, `gate`, `gate:fast`, `gate:iter` all unchanged and Node-only. No PS refs.
- Hooks: pre-commit → `gate:fast`; pre-push → `gate`. No PS refs.
- `ci.yml`: unchanged, calls `npm run gate`.
- `nightly-tests.yml`: now runs the single Node runner and **fails directly** on failure (removed the old `continue-on-error` + report step — actually *stronger* than before).
- `run-tests.bat` (PS-only launcher) deleted; `repomix.config.json` ignore entry + header swept (Protocol 37 satisfied).
Verified live: `npm run gate:fast` → exit 0, **ALL 3002 TESTS PASSED**, 12.7s.
---
## Protocol 15 retirement ✅ proper
Dated (`2.8.5 U-B3, 2026-07-12`), reasoned, explicitly "the number is not reused" (first deliberate retirement), cross-refs updated (36a → "the Node test runner"; 43; 45; Architecture Quick Reference → single canonical runner). No orphaned live dependency — the parity machinery is fully removed and inverted into Suites 28/31.2/50.6/128.5. Suite 220 PATH_ALLOW correctly adds `tests/robco-diagnostics.ps1` as a MUST-NOT-EXIST path (mirrors the `js/ui.js` precedent) so retirement-recording docs can name the full path without tripping the doc-integrity gate.
---
## Timing claims ✅ accurate (including the reconstructed one)
The commit presents "22.0s vs 2.0s (~13×)" and "~34s → 12s". I measured everything:
| Figure | Commit | My measurement | Verdict |
| ------ | ------ | -------------- | ------- |
| Node runner | 2.0s | 1.77–1.82s | accurate (conservative) |
| PS runner | 22.0s | 22.5s warm (39s cold) | accurate (warm) |
| ~13× | ~13× | 22.5/1.8 ≈ 12.5× | accurate |
| fast gate after | 12s | 12.7s measured | accurate |
| fast gate before | ~34s (reconstructed) | 12.7 + 22.5 ≈ 35s | reconstruction **validated** |
The "~34s before" could not be measured (PS already deleted) and the commit does not *label* it as reconstructed — but it is tilde-approximate and I independently confirmed it (fast-gate-now + measured PS runner ≈ 34.7s). No reconstructed number is being passed off as a hard measurement in a misleading way. **NOTE**, not a defect.
---
## FINDINGS
### F1 — CLAUDE.md Protocol 40 still says "both canonical runners" / names `.ps1` · SHOULD-FIX (low)
- **What:** `CLAUDE.md:499` — "the browser-side runtime mirror of the canonical static runners (`tests/robco-diagnostics.js` / `.ps1`)" and "the canonical runners statically analyse". `CLAUDE.md:518` — "**Suite 96** (both canonical runners)".
- **Why it matters:** CLAUDE.md is the canonical LIVE protocol doc. Per Protocol 45, a stale LIVE doc makes the next session confidently wrong — here it tells a future session there are *two* canonical runners to keep in sync, which is now false.
- **Why the gate didn't catch it:** Suite 220 is prose-narrow by design; `.ps1` here is bare shorthand (not the full path), so the path-integrity scanner legitimately can't see it.
- **Not overstated:** the commit's doc-sweep list does **not** claim Protocol 40 was updated (it enumerates Protocols 2a/36a/42/43/45 + Arch Quick Ref, and ARCHITECTURE/README/RULES/repomix/nightly/test.html/library — Protocol 40 is absent). Honest omission, not a false claim. The Protocol 40 region (old lines 447–540) is untouched by the diff.
- **Fix:** change L499 to "the canonical static runner (`tests/robco-diagnostics.js`)" and L518 to drop "(both canonical runners)" → "(Node runner)". No code change; no cache bump.
### F2 — Pre-existing stale gate-size counts in CLAUDE.md · NOTE (out of scope for this commit)
- `CLAUDE.md:204` "1130-test gate" and `CLAUDE.md:232` "1106-test pre-commit gate" should read 3002. **Confirmed NOT touched by this commit** (pre-existing drift, Protocol 8/10 prose). The prompt flagged pre-existing stale counts; these are two of them. Worth a separate Protocol 2a cleanup pass — not a reason to hold this commit.
### F3 — CLAUDE.md:323 "a both-runners guard" · NOTE (pre-existing, unrelated)
- This describes WU-C11's dev/prod changelog-viewer guard (asserts both prod-mode *and* dev-mode behavior) — not the Node/PS runners. Ambiguous wording, predates and is unrelated to this commit. No action required for U-B3.
---
## What I could NOT fault
- Coverage: none lost (both runners executed at base — identical 3002).
- Suite 90 / Protocol 39: fully safe without PowerShell (identical forced-UTF-8 content inspection).
- The 4 guards: genuine, liveness-proven, non-padding.
- Gate: whole; every browser step retained; fast gate green at 12.7s.
- CHANGELOG: plain-English, honest, "zero loss of coverage — all 3002 tests still run and pass" — no overstatement reached the user-facing changelog.
- Timing: accurate; reconstruction independently validated.
- No served file changed → correctly no cache bump.
**Recommendation:** ACCEPT. Fold F1 (and optionally F2) into a follow-up doc-sweep commit. The deletion itself is correct and safe; nothing here warrants restoring the runner.