Bash AI coding Testing Workflow

Bash and tee: Keep Command Failure Separate from Log Failure

The Prompt Shelf ·

When an AI coding assistant runs a test command through tee, a saved log and a successful command are two different outcomes. The default pipeline status can hide a failed producer. pipefail helps, but a single number still does not tell you which stage failed.

This guide is for developers reviewing an assistant’s shell wrapper before relying on its “tests passed” report. It covers a foreground pipeline with one external producer process and one tee process, not background jobs or a same-shell function. You can solve this with Bash you already have; purchasing a tool is not necessary for the checks below.

Decide what success means first

For this example, success requires both the producer and the logger to return zero. If both fail, we preserve the producer’s status as the wrapper’s exit status and print both statuses. That priority is a deliberate policy, not Bash’s default behavior.

Bash’s official pipeline documentation explains that the usual pipeline result comes from the last command. With pipefail, the result instead comes from the rightmost failing command. Thus a producer returning 7 and a logger returning 1 produce pipeline status 1 under pipefail.

The Bash reference manual documents PIPESTATUS, which retains individual foreground pipeline statuses. Save it immediately: an intervening simple command can replace the values you meant to inspect. The manual also describes exceptions to errexit (set -e), including commands evaluated in conditional contexts.

A narrowly scoped wrapper

In a disposable directory, create a synthetic producer.sh:

printf 'Starting synthetic check\n'
false
printf 'UNREACHED\n'

Save this as capture.sh in that same directory:

set -uo pipefail
if /bin/bash -e producer.sh 2>&1 | tee "$1"; then
  statuses=("${PIPESTATUS[@]}")
else
  statuses=("${PIPESTATUS[@]}")
fi
printf 'producer=%s logger=%s\n' "${statuses[0]}" "${statuses[1]}"
if (( statuses[0] != 0 )); then
  exit "${statuses[0]}"
fi
exit "${statuses[1]}"

Run it explicitly with Bash and supply a log path:

/bin/bash capture.sh ./synthetic.log

The example returns 1 and reports producer=1 logger=0. The child Bash stops at the standalone false; the marker is not printed. There is intentionally no parent set -e: the wrapper handles the pipeline result explicitly. The first command in each branch is the status-array assignment.

Before adapting this, confirm the interpreter path and the command’s documented exit-code meaning. For a normal external test executable, replace /bin/bash -e producer.sh with that executable and its arguments; do not wrap an arbitrary user-supplied command string in eval. A command that reports failure only in its text while exiting zero needs a different acceptance check.

Four actual local results

We ran the downloadable fixture on November 7, 2026 JST using macOS Bash 3.2.57(1)-release. Each case used a fresh shell, a temporary directory, synthetic input, and a five-second timeout. The fixture made no network calls.

CaseProducerLoggerWrapper result
Child Bash encounters standalone false101; later marker absent
Producer succeeds, log parent directory missing011
Producer exits 7, log parent directory missing717; both failures reported
Producer and logger succeed000

Download the synthetic Python lab. Read the file before running it with an existing Python 3 installation:

python3 bash-tee-external-process-lab.py

It accepts no input paths, creates and removes its own temporary files, and prints a JSON result. It requires /bin/bash and tee; it does not install anything. These are local shell results, not tests of a GitHub runner, an AI service, or their job-status interfaces.

Do not substitute a shell function without reassessing it

An earlier local check found that a same-shell function containing false followed by a successful printf, when invoked in an if pipeline, continued past false despite set -e and finished successfully. The new child-process test above addresses that specific applicability boundary; it does not make all shell scripts fail-fast.

A separately invoked /bin/bash -e starts a new interpreter with that option. Exceptions inside the child script still apply: conditional tests, explicitly handled failures, and other documented contexts can legitimately continue. Audit the actual script rather than treating -e as a proof that every failed operation aborts.

Likewise, this recipe does not cover process substitution, asynchronous producers, multiple producer stages, signal handling, or a closed output consumer. Adding stages changes the array positions and requires a new policy. A zero status does not prove that an artifact contains the intended data or that log bytes are durably stored.

Make the assistant’s report auditable

Before accepting a generated wrapper, ask it to identify the producer, logger, interpreter, expected exit codes, and artifact checks. Require it to preserve both statuses and explain which failure controls the final exit. Then test a producer failure and a log failure separately using disposable inputs before applying it to real work.

2>&1 deliberately sends stderr into the log as well as stdout. Real command output may contain credentials or customer information. Keep logs local until reviewed; exit-status handling is not redaction. Do not paste production logs into the synthetic fixture.

You can adapt these acceptance criteria into a project rule using the rules gallery. The value is an explicit failure policy and a reproducible check, not a claim that a particular paid assistant prevents shell errors. Documentation visits and lab downloads are not purchase events or evidence of revenue.

Related Articles

Explore the collection

Browse all AI coding rules — CLAUDE.md, .cursorrules, AGENTS.md, and more.

Browse Rules