
The Piped Exit: Why TSC_EXIT=0 Sat Next to Real Type Errors
bunx tsc --noEmit piped through tail; echo TSC_EXIT=$? reports tail's status, not the compiler's. A forge functional audit declared typechecks clean while the instrument was lying.
View companion repoTSC_EXIT=0 sat next to real type errors
I ran a command that looks like a typecheck. It is not.
cd apps/dashboard && bunx tsc --noEmit 2>&1 | tail -20
echo TSC_EXIT=$?
An agent pastes that into a forge functional audit because the compiler is loud and context is scarce. tail -20 keeps the last twenty lines. echo TSC_EXIT=$? looks like a receipt. The receipt prints TSC_EXIT=0. The twenty lines above it can still be TypeScript errors. The gate records clean. The tree is not.
That is not a TypeScript bug. It is not a Bun bug. It is POSIX. $? holds the exit status of the last command in the pipeline. The last command is tail. tail exits 0 if it printed. The compiler's status was discarded at the pipe.
The 2026-08-13 forge verification-trap note in the 30-day mine named this exact shape: cmd 2>&1 | tail; echo EXIT=$? reports tail's status. Green typecheck beside real errors. Post 50 already caught a cousin of this on an invoice verifier, as a side observation while hunting a vacuous regex. This post is the load-bearing case. CI and agent gates. The measurement instrument itself.
What $? actually measures in a pipeline
A pipeline is a sequence of processes, not a single process with a filter bolted on. The shell runs tsc, connects its stdout and stderr to tail, and then, unless pipefail is on, forgets every status except the last.
I have a skill for this now, verify-exit-codes-unpiped, because I kept shipping the same false finding. The skill's first example is the invoice form of the trap:
my_validator.py 2>&1 | tail -18; echo "EXIT:$?" # reports tail's exit, ALWAYS 0
Swap the binary for bunx tsc --noEmit and the label for TSC_EXIT and you have the forge audit. Same instrument. Higher blast radius. A PDF verifier that swallows its own exit is one invoice. A typecheck gate that swallows tsc is every subsequent merge that believed the dashboard was well-typed.
POSIX offers three honest repairs, and only one of them belongs in a claim about the compiler:
- Do not pipe. Redirect to a file. Read
$?fromtsc. - Index the pipeline: bash
${PIPESTATUS[0]}, zsh${pipestatus[1]}. set -o pipefailso a failing stage fails the pipeline.
Prefer form 1 when the claim is about the exit code. Capture output and status as two different facts. Mixing them in one pipeline is how the facts trade places.
The rereview that refused the first green
On 2026-08-06 an independent reviewer ran the residual CMS polish pass on withagents-forge. Session 38801af1-1c6e-4d67-a3ae-85cd95f05657. The producing agent had already claimed clean static checks. The reviewer did not accept the claim. Every PASS had to cite a captured artifact on disk.
The lint capture is the confession. docs/validation/audit/evidence/rereview-2026-08-06/lint-output.txt lines 9 and 10:
# NOTE: 'npx next lint' fails here (next not on PATH); a piped exit code
# would report tail's status, not lint's. ESLint invoked directly.
A first attempt produced a misleading NEXTLINT=0 that was actually tail's exit after next was not on PATH. The reviewer discarded it rather than report it. Then invoked ESLint directly, proved a config resolved (CONFIG_RESOLVES=0), proved three files were actually linted (FILES_LINTED=3), and only then wrote LINT_EXIT=0.
The typecheck capture had a sibling honesty check. The first tsc returned in about 1.8 seconds, implausibly fast for that project, so the reviewer did not accept it. A re-run with --listFiles showed 2,889 files in the program. TSC_EXIT=0 in tsc-output.txt is a real pass over a real program, not a cache no-op and not a piped tail.
That is the standard the 2026-08-13 trap note is pointing at. A green number is not a typecheck. A green number plus a file count plus an unpiped status is a typecheck. Anything less is a story the agent told itself.
The day before, the residual CMS-gates close-out had already learned the same lesson in shorter form. docs/validation/audit/vg-residual-cms-gates.txt opens with a heading, "Verification (unpiped exits)", and a line that reads bunx tsc --noEmit --incremental false → EXIT 0 (re-run, no pipe). The parenthetical is the whole method. Re-run. No pipe. The flag --incremental false is there because apps/dashboard/tsconfig.json sets "incremental": true, so a bare tsc --noEmit can return off cache in a little over a second and look clean without walking the tree.
Two traps, stacked. The pipe lies about whose status you measured. The incremental cache lies about how much work you did. Either one is enough to print TSC_EXIT=0 next to a broken program. Together they are a forge audit that will rubber-stamp a dirty working tree and call it done.
The harness that still pipes
Knowing the trap does not delete the copies of it that already live in runbooks.
docs/validation/audit/vg-1-harness.md step 3 is still:
cd apps/dashboard && bun run lint 2>&1 | tail -20
Must be clean, the harness says. Warnings tolerable only if pre-existing. There is no set -o pipefail. There is no PIPESTATUS. There is no redirect to a file. The VAL agent that follows this runbook by the letter will see twenty lines of lint, echo a status, and record whatever tail returned. If lint printed more than twenty errors, the VAL agent will not even see the top of the list. The receipt will still be 0.
The same idiom is baked into older Claude prompt-stack hooks. npx tsc --noEmit 2>&1 | tail -20 is the TypeScript build helper. xcodebuild ... | tail -20 is the Swift helper. cargo check 2>&1 | tail -20 is the Rust helper. Every one of those helpers was written to save tokens. Every one of them donates the process status to tail. An auto-build hook that "passes" after a compile failure is not a hook. It is a mute.
I keep this next to the unpiped-exit skill because the skill is a diagnosis and the harness is the patient that has not taken the medicine. A note in a rereview does not patch a markdown runbook. Until VG-1 step 3 redirects to a file and reads the linter's own exit, the documented gate and the true gate are different programs.
The sibling lesson in post 50
Post 50, "The Negative Control," is the other half of this family, not a retelling of it.
In that session I ran an invoice verifier through 2>&1 | tail -18 and printed EXIT=0 next to a FAIL verdict. That was a real piped-exit bug. It was not the A-plot. The A-plot was a regex that could not fail: after a destructive whitespace strip, \b\d{9,12}\b matched nothing, found_nums was empty, and "no stale account numbers" became a vacuous truth. The negative control, a Chase-era PDF that still carried the old digits, is what made the green stale-check look suspicious. The piped EXIT=0 was the second bug in the same output block, noted and then set aside so the regex could be fixed.
This post flips the emphasis. The matcher in a typecheck gate is tsc itself. tsc is not vacuous. It reports errors. It exits non-zero. The lie is one layer out, in the shell that asked tail how the compiler felt. Post 50 is a detector that cannot discriminate. Post 55 is a meter that reports the wrong instrument. You can have a working detector and still ship a green gate if the meter is taped to the wrong pipe.
The durable pairing is: after the suite is green, feed it a known-bad input and require a fail (post 50); and when you read that fail, do not pipe the process whose status you are about to cite (post 55). Miss either one and the audit is theatre.
How to capture a compiler's own exit
The form I now require in forge evidence looks like this:
cd apps/dashboard
bunx tsc --noEmit --incremental false --pretty false \
> /tmp/tsc-dashboard.txt 2>&1
echo TSC_EXIT=$? | tee -a /tmp/tsc-dashboard.txt
wc -l /tmp/tsc-dashboard.txt
Redirect, then status, then a size check so an empty file cannot masquerade as a clean program. If I need the last twenty lines in the transcript I read them from the file:
tail -20 /tmp/tsc-dashboard.txt
tail is now a viewer. It is not in the status path.
The anti-no-op proof from the 2026-08-06 rereview stays. A tsc that returns in under two seconds on this dashboard is a cache hit until proven otherwise. --incremental false defeats the tsconfig cache. --listFiles piped to wc -l proves the program was the whole program. 2,889 files was the number that made TSC_EXIT=0 admissible. Without that number, TSC_EXIT=0 is a label on an unexamined run.
Lint gets the same treatment. Do not wrap next lint in tail. If next is not on PATH, the failure must survive as a failure. Invoke the binary you mean, write its stdout to a file, record its exit, and count the files it actually linted. FILES_LINTED=3 in lint-output.txt is the reason that LINT_EXIT=0 is allowed to mean anything.
For CI YAML the same rule, stricter. GitHub Actions will fail a step on a non-zero exit, but only the exit of the step's last command unless pipefail is set. bash --noprofile --norc -eo pipefail is the default in bash shells on GitHub-hosted runners for a reason. A step that is run: bunx tsc --noEmit 2>&1 | tail -20 without pipefail is a step that cannot go red. The Actions UI will show the errors in the log and a green check on the job. That is the forge audit, promoted to the default branch.
Where this generalizes past tsc
Any status you cite from a pipeline is a status you have to name by process.
pytest -q 2>&1 | tail -20; echo PYTEST_EXIT=$? reports whether tail printed. A failing test in line 1 of a 200-line run is invisible to both the transcript slice and the exit code.
eslint . 2>&1 | grep error; echo ESLINT_EXIT=$? is worse. grep exits 1 when it finds no matches, so a clean lint becomes a red gate, and a lint that printed errors becomes a green gate if you invert the reading. You have now built a detector whose polarity depends on whether the last stage is tail or grep.
xcodebuild ... | tail -5 in an iOS validation skill will print BUILD SUCCEEDED when that string landed in the last five lines, and will print TAIL_EXIT=0 even when the build failed twelve hundred lines earlier. Success strings in a truncated tail are not a build.
The rule I took out of the forge rereview is small enough to keep next to every evidence block I write: if the claim is "this compiler / linter / test runner exited 0," the command that produced $? must be that compiler, that linter, that test runner. Not tail. Not tee. Not head. Redirect to a file. Read the file. Read the status from the process named in the claim.
The 2026-08-13 verification-trap note exists because agents will keep typing the piped form. It is shorter. It fits in a session. It produces a number that looks like proof. The number is proof of tail. Until the gate measures tsc, TSC_EXIT=0 is a caption, not a typecheck.
Continue the series
- 54SeriesDrive Don't Sweep: A Green HTTP Status Is Not Proof a Screen WorksA cookie-less HTTP sweep called every Forge route clean. Every response was the same login page. Status OK measured the server. It never measured the screen.
- 53SeriesThe Unwired Specialist: When Delegation Fails Because the Model Was Never ThereFive recon specialists died in under a second with the same missing-route error. No active credentials for provider: anthropic. That is a missing model role, not a rate limit.
- 52SeriesThe Wrong Posts Directory: Why the Hub Tree Never Reaches withagents.devI wrote the next field-journal entry into the hub posts/ tree and the live Next.js site never saw it. The build reads only site/posts/. The parent tree is not a build input.
- 51SeriesZero Behavior Change: A Structure-Only Refactor Is a Claim Until Someone Can Falsify ItIndependent reviewers on yt-transition-shorts-detector were told ZERO behavior change for deepen phases. Iron rule: cite evidence or FAIL. A refactor is not proven by the author saying it is structure-only.