---
name: wiring-bats-tests
description: Use when adding bats integration tests to a Go/Rust/CLI repo, migrating Go subprocess tests to bats, or extending an existing zz-tests_bats setup with new tags, fixtures, or lanes
---

# Wiring bats Tests with Nix Lanes

## Overview

Stand up integration tests under bats inside the nix sandbox: one
derivation per `# bats file_tags=` directive, plus a `bats-default` that
runs everything. Tests exercise real binaries via env-var injection from
the flake, isolate per-test under `BATS_TEST_TMPDIR`, and use
bats-island/bats-emo/bats-assert as the standard helper vocabulary.

**Reference repos** (all under `~/eng/repos/`):

- `tap` — fully on `amarbel-llc/bats`; cleanest separation (bats.nix
  as its own file). The **target shape** for new wirings.
- `clown` — simplest stable shape: HTTP fixture with handshake-on-stdout
- `madder` — most mature lane wiring (race + coverage + sftp/webdav
  lanes, version burnin), fully on `amarbel-llc/bats`. The worked
  example for the `extraBinaries` per-tag binary-fixture overlay

## When to Use

**Bootstrap flow** — repo has no integration tests yet, or only ad-hoc
shell scripts under `tests/`. Add `zz-tests_bats/`, wire `bats.nix`,
expose `bats-default` as a flake check.

**Migration flow** — repo has Go-level `*_test.go` exercising whole
binaries via subprocess. Lift those into bats; keep narrow unit tests in
Go. Tap is mid-migration; use it as the worked example.

**Skip when:**
- Pure library code with no CLI surface — Go's `testing` package is fine
- One-off scripts in `playgrounds/` or `zz-explore/`
- Tests must run on macOS without nix — bats works, but the lane builder assumes `amarbel-llc/nixpkgs`

## Migrating from amarbel-llc/bob

Historically `batman`, the bats helper libs, and the `sandcastle`
sandbox all lived in `amarbel-llc/bob`. They were extracted into
`amarbel-llc/bats` in 2026. **New wirings MUST use the `bats` flake
input, not `bob`.** If your repo's `flake.nix` still reaches into
`bob` for any bats-related output, switch.

What moved:

| Old (bob) | New (bats) |
|---|---|
| `bob.packages.${system}.bats-libs` | `bats.packages.${system}.bats-libs` |
| `bob.packages.${system}.batman` | `bats.packages.${system}.batman` |
| `bob`'s `sandcastle` Node.js wrapper | **removed entirely** — fence is the only sandbox backend now |
| `bob`'s bats wrapper accepting a `sandcastle` derivation | `bats.lib.${system}.mkBats { tap-dancer-go }` (fence-only) |
| `pkgs.testers.batsLane` (amarbel-llc/nixpkgs overlay) | `bats.lib.${system}.batsLane`, threaded into `bats.nix` as the `batsLane` arg |

The last row is a separate migration axis from bob: the `batsLane` lane
builder moved *out of the amarbel-llc/nixpkgs overlay* into
`amarbel-llc/bats` (amarbel-llc/nixpkgs#16). Consume it from the `bats`
flake input — `bats.lib.${system}.batsLane` — and pass it into `bats.nix`
rather than reaching `pkgs.testers.batsLane`.

Concretely, in your `flake.nix`:

```nix
# Before:
inputs.bob.url = "github:amarbel-llc/bob";
# …
batsLibPath = [ bob.packages.${system}.bats-libs.batsLibPath ];

# After:
inputs.bats.url = "github:amarbel-llc/bats";
inputs.bats.inputs.nixpkgs.follows = "nixpkgs";
# …
batsLibPath = [ bats.packages.${system}.bats-libs.batsLibPath ];
```

Drop the `bob` flake input entirely if it was only there for bats
wiring. Repos that still depend on `bob` for other things (e.g.
`tap-dancer` Go module) keep the input but reach bats helpers via
`bats`. (Note: `tap-dancer` itself lives in `amarbel-llc/tap` now;
the `bob/packages/tap-dancer/go` import path is the legacy form.)

Templates and the worked `flake.nix` example in this skill are already
on `amarbel-llc/bats` — copy them as-is and don't backslide to `bob`.

**`bats` vs `batman` in the devshell.** The bats flake's
`packages.${system}.default` bundles *both* binaries under one store
path: `bin/bats` (plain bats-core) and `bin/batman` (the
fence-orchestrating wrapper). A devShell that lists
`bats.packages.${system}.default` (as madder's does) gets both on
`PATH`. Recipes that invoke `bats` directly hit the plain binary and
need no `fence.jsonc` plumbing; reach for `batman` only when you want
fence sandboxing. This catches out migration agents who assume the
devshell `bats` is already the wrapper.

The templates in `templates/` use placeholder names: `MY_BIN`,
`my-cli`, `mkXxxBatsLane`, `run_my_cli`. Rename **all** of these to
match your project before committing — the templates won't work as-is.

For a project building binary `widget`:

| Template token | Renamed to |
|---|---|
| `MY_BIN` (env var) | `WIDGET_BIN` |
| `my-cli` (binary name in `require_bin` and the `binaries` map) | `widget` |
| `mkXxxBatsLane` (function name in `bats.nix`) | `mkWidgetBatsLane` |
| `run_my_cli` (helper in `common.bash`) | `run_widget` |
| `myBin` (parameter passed to `bats.nix`) | `widgetBin` (or keep `myBin` — internal to the wiring) |

Search-and-replace `MY_BIN` and `my-cli` first; the others fall out
from the search.

## Timeout layering

There are two distinct timeouts in the templates and they don't replace
each other:

- **`BATS_TEST_TIMEOUT`** (set in `bats.nix` `extraEnv`, default 10s) —
  per-test wall-clock cap. bats kills the entire `@test` block if it
  exceeds this. Bump for tests that legitimately run long.
- **`timeout 2s` inside `run_my_cli`** — per-CLI-invocation cap. The
  binary under test gets killed after 2s if it hangs. A test can call
  `run_my_cli` multiple times and stay well under `BATS_TEST_TIMEOUT`.

Tune them independently. If a CLI invocation legitimately needs >2s,
bump the per-invocation timeout in your `run_widget` helper, not
`BATS_TEST_TIMEOUT`.

## The Pattern at a Glance

```
repo/
  flake.nix              # imports bats.nix, exposes bats-* outputs
  bats.nix               # mkXxxBatsLane wrapping the bats flake's batsLane
  justfile               # test-bats, test-bats-tags recipes
  zz-tests_bats/
    common.bash          # setup_test_home, require_bin, library loads
    setup_suite.bash     # bats_require_minimum_version
    *.bats               # tests, each carrying `# bats file_tags=foo`
```

The `zz-` prefix sorts the directory last in `ls` and most file pickers,
keeping it out of the way during normal navigation. Use it even for the
first test file — naming consistency across madder/clown/tap matters.

## Library Vocabulary

Load all four in `common.bash`:

```bash
bats_load_library bats-support
bats_load_library bats-assert
bats_load_library bats-emo
bats_load_library bats-island
```

| Library | Purpose | Key functions |
|---|---|---|
| bats-support | Internal infra for assert/island | (loaded transitively) |
| bats-assert | Standard assertion vocabulary | `assert_success`, `assert_failure`, `assert_output [--partial\|--regexp]`, `assert_line`, `assert_equal`, `refute_*` |
| bats-emo | Binary discovery and verification | `require_bin <ENV_VAR> <name>` — fail fast if binary not in scope |
| bats-island | Per-test sandbox in `BATS_TEST_TMPDIR` | `setup_test_home` (fresh `$HOME`, XDG dirs, `GIT_CONFIG_GLOBAL`), `setup_test_repo`, `teardown_test_home` |

Source: `~/eng/repos/bats/packages/batman/lib/`. Use these — don't
hand-roll equivalents. (Tap's `common.bash` defines a stand-in
`setup_test_home` because it doesn't load bats-island yet; that's a
migration artifact, not the target shape.)

See `references/library-vocabulary.md` for full signatures and examples.

## Output design comes first

Tests assert on **whole CLI output** wherever possible. This is a hard
ask of the binary under test, not of the test framework: the binary must
emit output that's stable and assertable without per-call massaging.

**Design the binary's output for asserting from day one:**
- Pick formats with deterministic ordering — TAP, JSON with sorted keys, fixed-column text
- No timestamps, version strings, hostnames, or PIDs in the body — push those to a header tests can ignore or strip
- No randomized iteration order in maps — sort before printing
- Errors go to stderr with stable prefixes; stdout is the asserting surface
- Expose a `-format` flag (json/tap/plain) so tests can pick the most stable form

**In tests, prefer:**
```bash
run_madder write -format tap a.txt
assert_success
assert_output - <<EOF
TAP version 14
1..1
ok 1 - a.txt sha256:abc123...
EOF
```

over substring matching — `assert_output --partial` hides regressions in
adjacent output. When a full-output check is too brittle for a specific
case, extract a single field with `jq`/`awk` and `assert_equal` on that.
Never fall back to `--partial` on free-form prose.

If you can't assert on full output, that's a binary-design smell. Stop
and fix the output before writing more tests.

## Sandboxing: nix first

The default and preferred sandbox is the **nix build sandbox** that
the bats flake's `batsLane` already provides. Tests run with read-only store,
ephemeral `BATS_TEST_TMPDIR`, fresh network namespace (loopback enabled).
For 95% of tests this is enough.

**sandcastle** was the older capability-aware wrapper, originally
shipped from `amarbel-llc/bob` and consumed by batman in its bob-era
form. It's a Node.js CLI driven by inline allow-lists. **It's gone** —
`amarbel-llc/bats` removed the sandcastle integration entirely when
batman moved over. Don't reach for it in new code, and rip it out of
any existing wiring that still references it.

**fence** is the only remaining sandbox backend: a bubblewrap-based
sandbox driven by per-directory `fence.jsonc` config files. The
`mkBats` wrapper exposed by `amarbel-llc/bats` always invokes
`fence --settings <cfg> -- bats <args>`. Reach for fence directly when:
- The nix sandbox forbids something you genuinely need (rare)
- A specific test needs network egress to a known domain
- You're integrating with `mkBats` from `amarbel-llc/bats` directly

Tag tests that need elevated capability with `# bats file_tags=net_cap`
(or similar) so the default lane filters them out and a separate lane
runs them under fence. Madder does this with a `!net_cap` filter on its
default lane.

If a test binds 127.0.0.1 to a port (e.g. an HTTP fixture), you do
**not** need fence — every modern Linux sandbox creates a fresh network
namespace with loopback up. Clown's ADR-0007
(`docs/adrs/0007-drop-net-cap-bats-file-tag.md`) documents the empirical
analysis.

## Bootstrap Flow

For a repo with no bats setup yet:

### 1. Create the test directory

```bash
mkdir -p zz-tests_bats
```

Add `zz-tests_bats/setup_suite.bash`:

```bash
setup_suite() {
  bats_require_minimum_version 1.5.0
}
```

Copy `templates/common.bash` to `zz-tests_bats/common.bash` and edit:
- The `MY_BIN` env-var name to match your project's binary
- Any project-specific helpers (output parsers, format extractors)

### 2. Add `bats.nix`

Copy `templates/bats.nix` to the repo root. Edit:
- The `mkXxxBatsLane` function name
- The `binaries` map (env var → binary derivation)
- The `extraEnv` (project-specific overrides)
- The default `BATS_TEST_TIMEOUT` if 10s isn't right

### 3. Wire `bats.nix` into `flake.nix`

Two changes: add the `bats` input, then import `./bats.nix` in the
per-system block. A complete worked flake.nix is at
`references/flake-nix-example.md`; the diffs below show the additions.

**Inputs section:**

```nix
{
  inputs = {
    nixpkgs.url = "github:amarbel-llc/nixpkgs/master";
    flake-utils.url = "github:numtide/flake-utils";
    # add this:
    bats.url = "github:amarbel-llc/bats";
    bats.inputs.nixpkgs.follows = "nixpkgs";
  };
  # ...
}
```

**Per-system block** (e.g. inside `flake-utils.lib.eachDefaultSystem`).
Replace `widget` with your project's binary name and adjust the build
function (`buildGoModule`, `buildRustPackage`, `callPackage ./pkg`,
etc.) to match how your binary is built:

```nix
let
  pkgs = import nixpkgs { inherit system; };

  widget = pkgs.buildGoModule {
    pname = "widget";
    version = "0.1.0";
    src = ./.;
    vendorHash = null;            # use a real hash once go.mod has deps
    subPackages = [ "cmd/widget" ];
  };

  batsLib = import ./bats.nix {
    inherit pkgs;
    myBin = widget;
    batsLane = inputs.bats.lib.${system}.batsLane;
    bats-libs = inputs.bats.packages.${system}.bats-libs;
    batsSrc = pkgs.lib.cleanSourceWith {
      src = ./zz-tests_bats;
      filter = path: type:
        type == "directory"
        || pkgs.lib.hasSuffix ".bats" path
        || baseNameOf path == "common.bash"
        || baseNameOf path == "setup_suite.bash";
    };
  };
in
{
  packages = batsLib.batsLaneOutputs // {
    default = widget;
  };

  checks = {
    bats-default = batsLib.batsLaneOutputs.bats-default;
  };
}
```

The `cleanSourceWith` filter matters: it keeps the bats source store
path stable so unrelated repo edits don't invalidate the test build
cache. If your binary needs files outside `zz-tests_bats/` at runtime
(version files, config fixtures, helper scripts), stage them via
`extraStagedFiles` in `bats.nix` (see commented-out template entry) — do
**not** widen `cleanSourceWith` to include them, since that ties the
test cache to unrelated repo state.

### 4. Add justfile recipes

```just
# Authoritative — nix sandbox, every lane.
test-bats:
  nix build .#bats-default --no-link --print-build-logs

test-bats-tags *tags:
  nix build .#bats-{{tags}} --no-link --print-build-logs

# Fast iteration — runs against the locally-built binary in ./result.
# Build first with `just build` (or `nix build`), then this picks up
# ./result/bin/<bin> via the WIDGET_BIN env var.
test-bats-local *targets="*.bats":
  WIDGET_BIN=$(realpath ./result/bin/widget) \
    BATS_TEST_TIMEOUT=10 \
    bats --jobs $(nproc) zz-tests_bats/{{targets}}
```

Rename `WIDGET_BIN`/`widget` to match your project. The `test-bats-local`
form is for fast iteration only; the nix lanes are authoritative — CI
should always run `test-bats`.

### 5. Write the first test

Pick something that doesn't need any external file staged into the lane.
A help/usage check is the easiest first test:

```bash
# zz-tests_bats/help.bats
# bats file_tags=help

setup() {
  load "$(dirname "$BATS_TEST_FILE")/common.bash"
  setup_test_home
}

@test "help prints synopsis" {
  run "$MY_BIN" --help
  assert_success
  assert_line --partial "Usage: my-cli"
  assert_line --partial "Commands:"
}
```

Run `nix build .#bats-default` and confirm green. Once that's working,
extend with feature tests.

**For tests that need external fixtures** (a `version.txt`, a config
template, a sample input file), stage them via `extraStagedFiles` in
`bats.nix`:

```nix
extraStagedFiles = [
  { src = ./version.txt; dest = "zz-tests_bats/version.txt"; }
];
```

Then read at the staged path: `cat "$BATS_TEST_DIRNAME/version.txt"`.
Don't reach outside `zz-tests_bats/` with `..` paths — those resolve in
the worktree during local runs but not in the nix sandbox.

## Migration Flow

For a repo where Go tests are exercising whole binaries via subprocess:

### Sort existing tests

For each `*_test.go`:

| Test pattern | Move to |
|---|---|
| Calls `exec.Command("./mybin", ...)` and asserts on stdout | bats |
| Uses `httptest.NewServer` against the binary's HTTP server | bats (with binary-spawn fixture) |
| Tests an exported Go function with table-driven inputs | keep in Go |
| Tests an internal helper or pure function | keep in Go |

### Lift one binary-exercise test at a time

1. Identify the test's invariants — what does success look like?
2. Make the binary's output stable (see "Output design comes first")
3. Write the bats equivalent under `zz-tests_bats/<feature>.bats`
4. Run the new bats test in nix; confirm green
5. Delete the Go test
6. Commit

Don't lift multiple tests in one commit — keeping them small lets you
spot binary-output instabilities one at a time.

### Coverage strategy during migration

Run both lanes in CI until the migration is done:
- `go test -short ./...` for unit-level confidence
- `nix build .#bats-default` for integration confidence

Once `*_test.go` files no longer exercise binaries, drop the integration
half of `go test` (or guard those with `//go:build integration`).

## file_tags auto-discovery

`bats.nix` scans every `.bats` file at flake-eval time for
`# bats file_tags=...` directives. For each unique tag, it generates a
`bats-<tag>` derivation that runs only files carrying that tag.
`bats-default` runs everything (or everything except a skip-list — see
madder's `!net_cap` example).

Practical implications:
- Tags are file-level only, not per-test. To run a single test, use
  `test-bats-local <file>` and `--filter` locally.
- Adding/removing a tag changes the flake's output set, so it
  invalidates eval cache. Acceptable cost.
- Common tag scheme: `<feature>` for grouping (e.g. `init`, `version`,
  `sftp`); `net_cap` for capability escalation; `slow` for tests you
  want to skip on every push.

**Conventions worth knowing** (these match tap's and clown's `bats.nix`):

- Put **all tags on one line**, comma-separated: `# bats file_tags=init,sandbox`.
  The scanner reads the first matching directive line per file; multiple
  `# bats file_tags=` lines in the same file are ignored after the first.
- The scan is **non-recursive** — only `.bats` files at the top of
  `batsSrc` (i.e. directly under `zz-tests_bats/`) are considered. If
  you need to subdirectory tests, either keep them flat or extend
  `bats.nix` with a recursive walk (none of madder/clown/tap do).

## Per-tag binary fixtures

A lane sometimes needs extra binaries in scope that the default lanes
shouldn't pay for — e.g. an SFTP/WebDAV test server that only the
`net_cap` tag exercises. The pattern (from madder's `mkBatsLane`) is an
`extraBinaries` overlay onto the `batsLane` `binaries` map, attached
only to the lane that needs it:

```nix
# `extraBinaries` is a param of your local mkBatsLane wrapper, not of
# batsLane itself — it's merged into the binaries map you pass through.
mkBatsLane =
  { filter ? "!net_cap", base ? madder, extraBinaries ? { } }:
  bats.lib.${system}.batsLane {
    inherit base filter batsSrc;
    binaries = {
      MADDER_BIN = { inherit base; name = "madder"; };
    } // extraBinaries;          # ← overlay
    # ...
  };

# In the file_tags auto-discovery map, attach fixtures to one tag only:
extraBinaries = if tag == "net_cap" then netCapExtraBinaries else { };
```

`netCapExtraBinaries` exports the fixture binaries under the env vars
the bats helpers read (`MADDER_TEST_SFTP_SERVER`,
`MADDER_TEST_WEBDAV_SERVER`, …), so `nix build .#bats-net_cap` is
self-sufficient — no devshell handoff. Keeping the overlay tag-scoped
means non-`net_cap` lanes don't cache-invalidate when a fixture
binary's source changes.

## NDJSON output (experimental)

`batsLane` can emit structured per-test results as NDJSON alongside the
TAP stream — useful when a lane is consumed by an agent/workloop rather
than purely gating CI. It is **opt-in and experimental**: see FDR-0003
(`packages/batman/docs/features/0003-bats-lane-ndjson-output.md` in
`amarbel-llc/bats`) for design, promotion criteria, and known
limitations; the record schema (`amarbel-llc/tap` RFC 0001) is still
`proposed`. Default behavior is unchanged — existing lanes stay
stamp-file lanes.

**Turning it on.** Pass `emitNdjson = true` (default `false`). It
requires `tap-dancer-go`, which the bats flake's `flake.nix` already
plumbs; override per-call with `tapDancerGo` if needed. The builder
throws if `emitNdjson = true` and neither is set.

**`$out` becomes a directory.** With `emitNdjson = true`, the
derivation's `$out` is no longer a stamp file but a directory:

| File | Contents |
|---|---|
| `run.raw.tap` | bats's raw TAP-13 (with YAML diagnostics) |
| `run.tap` | TAP-14 (version header prepended) |
| `run.failures.ndjson` | failure + bail-out records (split mode, the default) |
| `run.passes.ndjson` | passing records (split mode, the default) |
| `run.ndjson` | combined records (only when `splitNdjson = false`) |
| `exit_code` | bats's exit status, one decimal line |

Consumers that `stat result` as a regular file (e.g. a stamp check)
break if you flip this on — opt in deliberately, lane by lane. The
derivation still gates on bats: a failing suite fails the build, and the
directory is only kept on disk via `nix build --keep-failed`.

### Agent / LLM consumption

The records are primarily meant for agents. By default `splitNdjson =
true`, so the inline echo carries **only failures** — keeping the
context window small and the failure signal precise. The build echoes
them to stderr between sentinel markers, so nix's default failure-log
tail (and `nix log`) surface them without `--keep-failed`:

```
>>> BATSLANE NDJSON BEGIN <<<
{"type":"test","n":2,"description":"…","ok":false,"diagnostic":{…}}
passes: 7 record(s) at /nix/store/…-bats-foo/run.passes.ndjson
>>> BATSLANE NDJSON END <<<
```

Extract just the failure records from a build log:

```sh
nix log .#bats-<lane> \
  | sed -n '/BATSLANE NDJSON BEGIN/,/BATSLANE NDJSON END/p' \
  | sed '1d;$d' \
  | grep '^{' \
  | jq 'select(.ok == false)'
```

The `grep '^{'` matters in split mode: the block's last line is a
human-readable `passes: N record(s) …` summary, not JSON, so piping the
raw block straight to `jq` errors on that line. Record shape is
`amarbel-llc/tap` RFC 0001 — `ok:false` is the actionable signal, and
`diagnostic.message` carries the file + line + failure text.

Pass `splitNdjson = false` for a single combined `run.ndjson` and an
inline echo of every record (passes included) — useful when you want the
full run, not just the failures.

## Common Mistakes

| Mistake | Why it bites | Fix |
|---|---|---|
| Hard-coding the binary path | Test passes locally, fails in nix sandbox | Use `require_bin MY_BIN my-cli` and inject via `binaries` map |
| Letting the binary print timestamps/PIDs | Tests pass once, fail forever after | Strip them, or move to a header tests can ignore |
| `assert_output --partial` on long output | Adjacent regressions slip through silently | Assert on full output (heredoc) or extract specific fields |
| Forgetting `setup_test_home` | Test pollutes real `$HOME` (writes `~/.config`) | Always call it in `setup()`. No exceptions — even tests that "only check help output" can shell out to git or read `$HOME` indirectly |
| Asserting `assert_failure 124` to detect a hang | `timeout --preserve-status` exits with the killed process's signal-derived status (128+SIGTERM), not 124 | If you need to detect timeout-on-hang, drop `--preserve-status` for that one helper and assert exit 124 explicitly |
| Setting `WIDGET_BIN` for `test-bats-local` from thin air | The local lane reads the env var but never builds the binary | First run `nix build`, then `WIDGET_BIN=$PWD/result/bin/widget bats ...`; or wire the dev shell to expose it |
| Missing `bats_require_minimum_version` | Old bats silently skips features | Pin to 1.5.0+ in `setup_suite.bash` |
| Sourcing `common.bash` outside `setup()` | Fails in some bats configurations | Always `load` inside `setup()` (per madder/clown) |
| Reaching for sandcastle in new tests | Being deprecated | Use nix sandbox; escalate to fence only if truly needed |
| Lifting all Go tests at once during migration | Output instabilities hide each other | One test per commit; fix binary output as you go |
| Hand-rolling `setup_test_home` | Easy to miss `GIT_CONFIG_GLOBAL` (see auto-memory) | Use bats-island instead |

## Red Flags — stop and reconsider

- Writing bash `if`/`grep` chains instead of `assert_*`
- Running tests outside nix to "make them pass"
- Adding a `sleep N` instead of polling on a condition
- Calling `cleanup_pids` from `setup()` instead of `teardown()`
- Setting `--allow-local-binding` flags on the default lane
- Asserting only on `--partial` substrings for >2 tests in a row

## References

- `templates/bats.nix` — annotated lane builder ready to copy (modeled after tap's)
- `templates/common.bash` — annotated test helper ready to copy
- `references/flake-nix-example.md` — complete minimal `flake.nix` for a Go CLI with bats wired up
- `references/library-vocabulary.md` — full bats-island/bats-emo/bats-assert function reference

## Real-world examples

- **tap**: `~/eng/repos/tap/zz-tests_bats/` + `~/eng/repos/tap/bats.nix`. **Target shape** for new wirings: fully on `amarbel-llc/bats`, bats.nix as its own file, `bats-libs` consumed via `bats.packages.${system}.bats-libs`.
- **clown**: `~/eng/repos/clown/tests/bats/` + `~/eng/repos/clown/bats.nix`. Simplest stable shape; HTTP fixture with handshake-on-stdout. Doesn't consume `bats-libs` from any flake input — devShell-only setup.
- **madder**: `~/eng/repos/madder/zz-tests_bats/` + `~/eng/repos/madder/go/default.nix` (lane builder inline). Most mature lane wiring (race + coverage + sftp/webdav lanes, version burnin), fully migrated to `amarbel-llc/bats`: `mkBatsLane` wraps `bats.lib.${system}.batsLane`, `batsLibPath` comes from `bats.packages.${system}.bats-libs`, and the devShell ships `bats.packages.${system}.default`. The worked example for the `extraBinaries` overlay (see "Per-tag binary fixtures").
