Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .agnostic-ai/AGNOSTIC_AI.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# bashunit

bashunit is a Bash 3.0+ testing framework with assertions, spies, mocks, data providers, and snapshots. Documentation: https://bashunit.com.

## Required guidance

All paths are relative to the repository root, including from generated skill and agent directories. Read the applicable source rules before editing. Directory scopes generate native instructions for that directory. Sessions started at the root must also read the relevant sources below; some clients do not load nested instructions during later edits.

| Task | Read |
|------|------|
| Changes to `src/` or `bashunit` | `.agnostic-ai/rules/architecture-map.md` |
| Shell changes under `src/` or `tests/`, including entrypoint changes | `.agnostic-ai/rules/bash-style.md` |
| Changes to `src/`, `bashunit`, or fork census acceptance tests | `.agnostic-ai/rules/perf-fork-budget.md` |
| Runtime development or task plans | `.agnostic-ai/rules/tdd-workflow.md` |
| Test changes | `.agnostic-ai/rules/testing.md` |

Bash 3.0 is the compatibility floor, including parse-time behavior. Use existing patterns. Study the relevant tests before adding assertions, doubles, providers, hooks, or CLI behavior. Do not invent commands or features.

Use TDD for runtime changes: RED for the right reason, minimal GREEN, then REFACTOR with tests green. Complete the acceptance criteria. Use a task file for nontrivial work; the `tdd-cycle` skill carries the full workflow. Preserve public APIs unless the requested change includes documentation and CHANGELOG updates. Record architectural decisions in an ADR.

## Quality gate

Run once after the complete change set, before committing:

```bash
./bashunit tests/
./bashunit --parallel tests/
make sa
make lint
```

`make lint` is the formatting authority. Match surrounding 2-space indentation by hand. Never run `shfmt -w`; it conflicts with EditorConfig. The `commit` skill bundles the detailed formatting constraints and optional drift comparison.

## Delegation

Handle small edits directly. Keep one acceptance scenario and a short plan in the main session. Delegate independent lookups to `code-locator`, bounded edits to `code-implementer`, and verified defect review to `code-reviewer` before pushing. Assign a question or file scope, a base revision, and a validation budget. Reuse findings. Keep at most two delegates active.

Workers share the checkout: assign separate files, preserve others' edits, and run only assigned targeted checks. The coordinator runs the full gate. Return concise file and line evidence; save longer reports under ignored `.agnostic-ai/local/`.

## Specs and delivery

Edit `.agnostic-ai/` or `agnostic-ai.yaml`, never generated instructions, agents, skills, hooks, or settings. Install the pinned contributor dependencies with `npm ci` (use `npm ci --force` for a Windows source checkout because the published npm package restricts its OS). Run `npm run agents:sync` and `npm run agents:check`, which use the project-local CLI.

Keep unrelated changes out of the PR. Update CHANGELOG for user-visible changes. Use the `pr` skill for pull requests. Use Conventional Commits: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, or `chore`, with an optional domain scope such as `assert`, `runner`, `cli`, or `docs`.

Never commit secrets, skip Git hooks, amend published commits, or use destructive Git commands. Pushing requires user authorization; force-pushing main requires an explicit request. All local gates must pass before committing.
8 changes: 8 additions & 0 deletions .agnostic-ai/agents/code-implementer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
name: code-implementer
description: Implement a bounded change in assigned files with Bash 3.0 compatibility and targeted validation.
model: balanced
can: [read, shell, edit, write]
---

You own only the files assigned by the main agent. Other workers share the checkout; preserve their edits and adapt to them. Read the applicable source rules listed in .agnostic-ai/AGNOSTIC_AI.md before editing. Follow the existing test patterns and the requested acceptance scenario. Use TDD for runtime changes. Keep hot paths within the fork budget. Run only the assigned targeted validation, never the full gate. Do not commit, push, publish, or install dependencies. Return changed files, the check result, and any unresolved limit.
8 changes: 8 additions & 0 deletions .agnostic-ai/agents/code-locator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
name: code-locator
description: Locate definitions, callers, and references before planning a nontrivial change.
model: fast
can: [read, shell]
---

Find code without editing it or proposing a redesign. Use rg, then read only enough surrounding code to verify each match. Return a file:line table and the searches used when nothing matches. Do not run tests, builds, installs, or Git mutations.
8 changes: 8 additions & 0 deletions .agnostic-ai/agents/code-reviewer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
name: code-reviewer
description: Review a completed diff for verified defects before a push or merge.
model: strong
can: [read, shell]
---

Review without editing files, committing, or posting to GitHub. Read the diff against the supplied base and the applicable source rules listed in .agnostic-ai/AGNOSTIC_AI.md. Trace changed behavior through callers and existing tests. Check Bash 3.0 parsing, dynamic scope, subshell state, parallel isolation, cleanup, and hot-path process costs when relevant. For configuration changes, check clean-clone generation, canonical references, model resolution, and preserved native behavior. Do not run the full quality gate. Return only verified findings as file:line, severity, concrete failing input, and the smallest fix. If none remain, say so and list the scope checked.
5 changes: 5 additions & 0 deletions .agnostic-ai/hooks/shellcheck-after-edit.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
name: shellcheck-after-edit
description: Show ShellCheck diagnostics after editing shell files.
on: after-edit
command: 'bash "$(git rev-parse --show-toplevel)/.agnostic-ai/scripts/shellcheck-after-edit.sh"'
commandWindows: "bash ((git rev-parse --show-toplevel) + '/.agnostic-ai/scripts/shellcheck-after-edit.sh') codex; exit $LASTEXITCODE"
6 changes: 6 additions & 0 deletions .agnostic-ai/overlays/claude.settings.hook-events.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"after": "includeCoAuthoredBy",
"events": [
"PostToolUse"
]
}
4 changes: 4 additions & 0 deletions .agnostic-ai/overlays/claude.settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"includeCoAuthoredBy": false
}
2 changes: 2 additions & 0 deletions .agnostic-ai/overlays/codex.config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[agents]
max_concurrent_threads_per_session = 2
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
---
paths:
- "src/**/*.sh"
- "bashunit"
scope: src
---

# Architecture Map: modules and the life of a run

Orientation for working in `src/` — which file owns what, and the exact call
Orientation for working in `src/`, which file owns what, and the exact call
flow of a test run. Line numbers drift; function names are the stable anchors.

## The life of `./bashunit tests/`
Expand All @@ -15,7 +13,7 @@ flow of a test run. Line numbers drift; function names are the stable anchors.
bashunit (entry) sources all src/*.sh; version gate; early flag scan
└─ bashunit::main::cmd_test (main.sh: flag parsing, env exports)
└─ bashunit::runner::load_test_files (runner/discovery.sh: the per-file loop)
├─ console_header::print_header "Running N tests" — captures
├─ console_header::print_header "Running N tests", captures
│ └─ helper::find_total_tests $() SUBSHELL: sources each file in a
│ nested subshell just to count tests
├─ source "$test_file" in the MAIN shell (workers inherit)
Expand Down Expand Up @@ -48,12 +46,12 @@ shell (or, in parallel, in per-test `.result` files aggregated at the end).
| Module | Owns |
|--------|------|
| `bashunit` | entry: version gate, early flag scan, sources every module, subcommand routing |
| `main/index.sh` | aggregator only — sources the `src/main/` module below |
| `main/index.sh` | aggregator only, sources the `src/main/` module below |
| `main/validate.sh` | shared parser validation: unknown options, integer/path checks, shard parsing |
| `main/test.sh` / `main/bench.sh` / `main/subcommands.sh` | flag parsing per subcommand |
| `main/assert.sh` | the standalone `bashunit assert` path |
| `main/watch.sh` / `main/run.sh` | the `--watch` loop; the run lifecycle, exit codes and cleanup |
| `runner/index.sh` | aggregator only — sources the `src/runner/` module below |
| `runner/index.sh` | aggregator only, sources the `src/runner/` module below |
| `runner/context.sh` | workdir restore, test identity/location exports, title interpolation, capability probes |
| `runner/payload.sh` | the `_BASHUNIT_RUNNER_*_OUT` return slots; encode/decode of the per-test result payload |
| `runner/diagnostics.sh` | runtime-error detection, kill-signal classification, profiling, verbose/file headers |
Expand All @@ -65,85 +63,85 @@ shell (or, in parallel, in per-test `.result` files aggregated at the end).
| `runner/list.sh` | `--list`/`--dry-run`: emits selected tests instead of running them; hooks in after all filtering, before any hook |
| `runner/discovery.sh` | `load_test_files` (the per-file loop), `functions_for_script` |
| `runner/bench.sh` | benchmark file loop and bench function dispatch |
| `helper/index.sh` | aggregator only — sources the `src/helper/` module below |
| `helper/index.sh` | aggregator only, sources the `src/helper/` module below |
| `helper/naming.sh` | resolving and normalising test-function and variable names |
| `helper/discovery.sh` | `find_files_recursive`, `find_total_tests`, fn filtering, duplicate check, line lookup |
| `helper/provider.sh` / `helper/tags.sh` | `@data_provider` map; `@tag` extraction and matching |
| `helper/encoding.sh` / `helper/functions.sh` / `helper/git.sh` | base64 + ids; generic fn utilities; remote tag lookup for `upgrade` |
| `state/index.sh` | aggregator only — sources the `src/state/` module below |
| `state/index.sh` | aggregator only, sources the `src/state/` module below |
| `state/counters.sh` | test and assertion tallies and their get/add accessors |
| `state/duplicates.sh` | duplicate test-function detection state |
| `state/context.sh` | per-test output buffer, exit code, title, hook failure, and the per-test reset |
| `state/payload.sh` | the encoded per-test result payload and the base64 capability probe |
| `state/parallel.sh` | aggregating per-test result files after a `--parallel` run |
| `config/index.sh` | aggregator only — sources the `src/config/` module below |
| `config/index.sh` | aggregator only, sources the `src/config/` module below |
| `config/env.sh` | all `BASHUNIT_*` defaults/config files, scratch dirs (`_BASHUNIT_RUN_OUTPUT_DIR` + EXIT-trap cleanup); executes at source time |
| `config/rerun.sh` | the `.bashunit/last-failed` cache for `--rerun-failed` |
| `config/parallel.sh` | is this run parallel; worker temp tree and stop-on-failure flag file (not `runner/parallel.sh`, which waits on job slots) |
| `console/index.sh` | aggregator only — sources the `src/console/` module below |
| `console/index.sh` | aggregator only, sources the `src/console/` module below |
| `console/colors.sh` | the `_BASHUNIT_COLOR_*` palette and `bashunit::sgr` |
| `console/header.sh` | the "Running N tests" header |
| `console/line.sh` | `print_line`, the primitive every result line goes through, and its TAP variant |
| `console/duration.sh` / `console/diff.sh` | duration formatting; unified and line-by-line diffs under a failure |
| `console/test_line.sh` | the per-test result lines (passed/failed/skipped/incomplete/snapshot/risky/error) |
| `console/deferred.sh` | end-of-run blocks buffered during the run (scratch files under the run dir) |
| `console/summary.sh` | run totals, execution time, hook completion |
| `assert/index.sh` | aggregator only — sources the `src/assert/` module below, plus `skip_todo.sh` and `test_doubles.sh` |
| `assert/index.sh` | aggregator only, sources the `src/assert/` module below, plus `skip_todo.sh` and `test_doubles.sh` |
| `assert/core.sh` | `assert::should_skip`, `assert::fail_with`, `assert::join_to_slot` and the comparison assertions the other files build on |
| `assert/{arrays,assertions,dates,duration,files,folders,json,once,snapshot}.sh` | the per-topic assertions; the per-assertion path must stay fork-free |
| `util/index.sh` | aggregator only — sources the `src/util/` module below |
| `util/index.sh` | aggregator only, sources the `src/util/` module below |
| `util/clock.sh` | time impl selection (EPOCHREALTIME > date > perl > …), return-slot reads |
| `util/str.sh` / `util/math.sh` | pure-bash string and arithmetic helpers |
| `api/index.sh` | aggregator only — sources the `src/api/` module below |
| `api/globals.sh` | `temp_file`/`temp_dir`/`current_dir`/`data_set`; repeats `set -euo pipefail` at file scope for anything sourcing it alone — the entrypoint already set it |
| `api/index.sh` | aggregator only, sources the `src/api/` module below |
| `api/globals.sh` | `temp_file`/`temp_dir`/`current_dir`/`data_set`; repeats `set -euo pipefail` at file scope for anything sourcing it alone, the entrypoint already set it |
| `api/skip_todo.sh` / `api/test_title.sh` | `skip`/`todo` markers; `set_test_title` |
| `api/bashunit.sh` | the custom-assert facade (`assert_that`, `assertion_failed`/`passed`) |
| `doubles/index.sh` | aggregator only — sources the `src/doubles/` module below |
| `doubles/index.sh` | aggregator only, sources the `src/doubles/` module below |
| `doubles/mock.sh` | `mock`/`unmock` and the `_BASHUNIT_MOCKED_FUNCTIONS` registry `runner/hooks.sh` unwinds per test |
| `doubles/spy.sh` | `spy` and its `_BASHUNIT_SPY_*` state slots + files |
| `doubles/assertions.sh` | the `assert_have_been_called*` family |
| `coverage/index.sh` | aggregator only — sources the `src/coverage/` modules below |
| `coverage/index.sh` | aggregator only, sources the `src/coverage/` modules below |
| `coverage/config.sh` | data-file locations, tracked-file roots, engine selection (`init` resets state owned by several modules) |
| `coverage/paths.sh` | `normalize_path`, `should_track` and the hot-path track/path caches |
| `coverage/engine.sh` | DEBUG-trap and xtrace capture, buffering, `finalize`/`cleanup`, parallel merge; only active under `--coverage` |
| `coverage/lines.sh` | executable-line classification (fork-free; the report phase's hot path) and hit-data reading |
| `coverage/stats.sh` | percentages, the precomputed per-file stats cache, threshold gate |
| `coverage/functions.sh` | function definitions and their line spans |
| `coverage/branches.sh` | branch extraction + hit computation; **one file on purpose** — the `_branch_*` helpers mutate `extract_branches`'s locals via dynamic scoping |
| `coverage/branches.sh` | branch extraction + hit computation; **one file on purpose**, the `_branch_*` helpers mutate `extract_branches`'s locals via dynamic scoping |
| `coverage/report_text.sh` / `report_lcov.sh` / `report_html.sh` | the three renderers |
| `coverage/html_index.sh` / `html_file.sh` | HTML page emitters; the only two files exempt from `max_line_length` |

| `reports.sh` | JUnit/HTML/TAP/JSON writers |
| `system/index.sh` | aggregator only — sources the `src/system/` module below |
| `system/index.sh` | aggregator only, sources the `src/system/` module below |
| `system/check_os.sh` / `system/dependencies.sh` | one-fork OS detect; `command -v` probes (builtins, not forks) |
| `system/io.sh` | small I/O helpers |
| `cli/index.sh` | aggregator only — sources the `src/cli/` module below |
| `cli/index.sh` | aggregator only, sources the `src/cli/` module below |
| `cli/{upgrade,watch,doc,init}.sh` | the non-`test` subcommand implementations; `main.sh` is their only caller |
| `benchmark/index.sh` | the remaining non-`test` subcommand; shared with `runner/bench.sh` |

## Cross-cutting invariants

- **Bash 3.0 floor** (`.claude/rules/bash-style.md`): no `[[`, `declare -A`,
`${var,,}`, `BASHPID`, negative indices. Subshells share `$$` — you cannot
- **Bash 3.0 floor** (`.agnostic-ai/rules/bash-style.md`): no `[[`, `declare -A`,
`${var,,}`, `BASHPID`, negative indices. Subshells share `$$`, you cannot
make a per-worker unique token without a fork (`mktemp`) or an ordinal
assigned before the fork. `RANDOM` is no help either: whether a subshell
reseeds it depends on the platform and the nesting depth (measured in
perf-fork-budget.md), so it is unreliable in both directions (#1354).
- **Return-slot pattern** (`_BASHUNIT_<PKG>_<FN>_OUT` globals) instead of `$()`
captures on hot paths — bash-style.md documents it; `local` is dynamically
captures on hot paths, bash-style.md documents it; `local` is dynamically
scoped, so helpers must not write caller-named variables.
- **Fork budget** (`.claude/rules/perf-fork-budget.md`): per-test paths are
- **Fork budget** (`.agnostic-ai/rules/perf-fork-budget.md`): per-test paths are
fork-free (sequential) or mktemp-only (parallel); budgets are enforced by
`tests/acceptance/bashunit_*forks*_test.sh` on three platforms — a "harmless"
`tests/acceptance/bashunit_*forks*_test.sh` on three platforms, a "harmless"
`echo | sed` in a per-test path will fail CI.
- **`call_test_functions` is a background subshell under `--parallel`**
(`discovery.sh` backgrounds it per file). Anything it sets in shell state —
counters, flags, error records — dies with the worker. Only two things cross
(`discovery.sh` backgrounds it per file). Anything it sets in shell state,
counters, flags, error records, dies with the worker. Only two things cross
back: per-test `.result` payloads, and files written to disk. This produced
two silent-green bugs in one day: a data-provider error printed while the run
exited 0 (#1145), and duplicate-test-function detection being entirely off in
the mode CI uses (#1147). Before adding a check anywhere under that call, ask
which side of the fork it belongs on — a whole-file check almost always
which side of the fork it belongs on, a whole-file check almost always
belongs in the parent loop, before dispatch.
**Verify both modes.** Sequential and `--parallel` disagreeing is the
signature of this bug, and it is invisible to a sequential-only test.
Expand All @@ -155,7 +153,7 @@ shell (or, in parallel, in per-test `.result` files aggregated at the end).
`build::process_file`): a file's body is emitted, *then* its `source` lines are
recursed into. That equals dev-mode order only if a module aggregator
(`src/<module>/index.sh`, ADR-010) contains **nothing but `source`
lines and comments** — any other top-level statement would run before its
lines and comments**, any other top-level statement would run before its
dependencies in the built artifact but after them in dev mode. Files are
deduped by repo-relative path, so `src/` may hold module dirs and two files may
share a basename. Every src file's **first line must be the shebang**:
Expand Down
Loading
Loading