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
183 changes: 183 additions & 0 deletions adrs/adr-011-source-layout-and-build-pipeline.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
# How `src/` is laid out and how the binary is built

* Status: accepted
* Deciders: Chemaclass
* Date: 2026-08-01

Technical Story: the layout settled across
[#924](https://github.com/TypedDevs/bashunit/issues/924),
[#925](https://github.com/TypedDevs/bashunit/issues/925),
[#931](https://github.com/TypedDevs/bashunit/issues/931),
[#940](https://github.com/TypedDevs/bashunit/issues/940),
[#948](https://github.com/TypedDevs/bashunit/issues/948) and
[#949](https://github.com/TypedDevs/bashunit/issues/949).

## Context and Problem Statement

ADR-010 decided *that* large files become module directories, and later amended *where* the
aggregator lives. What neither it nor any other document states is the whole picture: what the
modules are, in what order they load, how a single-file binary is assembled from them, and
which tests keep all of that honest.

That picture was spread across ADR-010, `build.sh` comments, `.claude/rules/architecture-map.md`
and eight issues. Someone arriving at the project β€” a contributor or an agent β€” had no single
place to read it.

This ADR is **descriptive, not a new decision.** It records the architecture as settled so it
can be understood from the outside without archaeology.

## The rule

**`src/` contains module directories and nothing else.** There are no loose `.sh` files.

A module is a directory whose entry point is `index.sh`:

```
src/<module>/index.sh <- the entry point: `source` lines and comments ONLY
src/<module>/<file>.sh <- one file per responsibility
```

`index.sh` means exactly one thing, everywhere: *a pure aggregator*. It never holds a
statement. That is load-bearing, not stylistic β€” see the build section β€” and it is why
`src/main.sh` was **not** renamed to `src/index.sh` when the question came up: a root
`index.sh` holding real code would make the name mean two things depending on depth.

## The modules

Seventeen, in the order the `bashunit` entrypoint sources them. The order is the dependency
layering: leaves first.

| # | Module | Files | Lines | Owns |
|---|---|---|---|---|
| 1 | `dev/` | 1 | 18 | debug helpers; **excluded from the build** |
| 2 | `system/` | 4 | 192 | capability probing: OS, `command -v`, small I/O |
| 3 | `util/` | 4 | 476 | computation: strings, arithmetic, time |
| 4 | `api/` | 5 | 207 | the surface a user's test file calls (except assertions) |
| 5 | `config/` | 4 | 964 | `BASHUNIT_*` defaults, scratch dirs, parallel mode, rerun cache |
| 6 | `coverage/` | 13 | 2644 | line/branch tracking and the four report formats |
| 7 | `state/` | 6 | 477 | counters, per-test context, result payload, parallel aggregation |
| 8 | `console/` | 9 | 1281 | everything printed: palette, header, per-test lines, totals |
| 9 | `helper/` | 8 | 976 | naming, discovery, data providers, tags, encoding |
| 10 | `cli/` | 5 | 447 | the `doc`/`init`/`upgrade`/`watch` subcommand implementations |
| 11 | `assert/` | 11 | 2336 | every assertion |
| 12 | `reports/` | 7 | 467 | JUnit, TAP, JSON, GHA and HTML writers |
| 13 | `runner/` | 11 | 2172 | the file loop, per-test execution, retry, result parsing |
| 14 | `benchmark/` | 4 | 221 | the bench implementation (`runner/bench.sh` is its loop) |
| 15 | `learn/` | 5 | 240 | the interactive tutorial |
| 16 | `main/` | 8 | 1475 | flag parsing per subcommand and the run lifecycle |

Namespaces track directories: `src/runner/` holds `bashunit::runner::*`. Two exceptions are
deliberate β€” `assert/` holds bare `assert_*` (the public API is unprefixed) and `console/`
holds `bashunit::console_results::*` in files named for what they do.

### Load order is not arbitrary

Most of it is ordinary dependency layering, but three points are genuinely load-bearing:

* **`api/globals.sh` runs `set -euo pipefail` at file scope.** Everything sourced after it
inherits strict mode. It must stay first inside `api/`, and `api/` must stay where it is.
* **`config/env.sh` executes at source time** β€” it loads `.bashunitrc` and `.env` and creates
the scratch dirs. It must come before `console/`, whose palette is built at file scope from
the values it resolves.
* **`main/` is sourced last**, after everything it dispatches to.

## How the binary is built

`build.sh` turns the module tree into one executable file. It is a **depth-first walk of
`source` statements, not a walk of directories** β€” so nesting depth and directory shape are
invisible to it.

```
1. build::dependencies read `^source ` lines from the `bashunit` entrypoint,
minus src/dev/ -> the embed list
2. build::process_file for each: emit `# <repo-relative path>`, then the file body
(shebang stripped), THEN recurse into its own `source` lines
dedupe on the repo-relative path
3. strip every `^source ` line from the result
4. build::embed_docs swap docs/assertions.md into a heredoc between the markers
in src/cli/doc.sh, so the binary needs no docs/ directory
5. build::assert_valid_syntax bash -n
6. build::verify (-v) run the whole suite against the built binary
```

**Step 2 is why an aggregator may hold only `source` lines.** A file's body is emitted *before*
its dependencies are recursed into, so a statement in an `index.sh` would run before the code
it depends on in the built binary, while running after it in dev mode β€” the two modes would
disagree.

The dedupe keys on the **repo-relative path**, never a basename: the top-level loop passes
relative paths and the recursion absolute ones, and two modules legitimately share a basename
(`config/parallel.sh` answers "is this run parallel"; `runner/parallel.sh` waits on job slots).

## The contracts that keep it honest

Every rule above is enforced by a test. This is the list to check before changing anything
structural.

| Contract | Test |
|---|---|
| Aggregators hold only `source` lines and comments | `build_test.sh` β€” globs `src/*/index.sh`, so it cannot drift |
| Every entrypoint-sourced file reaches the binary, and no others | `build_test.sh` β€” the #735 and bench-#0.31.0 regressions |
| Arbitrary nesting depth is embedded, in DFS order, with no `source` lines surviving | `build_test.sh` (#932) |
| Same basename in two modules does not collide | `build_test.sh` (#923) |
| `state/` never calls the renderer or `parallel` | `state_test.sh` β€” globs `src/state/*.sh` (#868, #862) |
| Shell completions match the flags `main/` actually parses | `completions_test.sh` |
| Every unprefixed env alias is declared and listed | `env_deprecated_aliases_test.sh` |
| No Bash 4+ syntax anywhere in `src/` | `bash_compatibility_test.sh` |

**These greps are the fragile part of the design.** A contract that greps a path passes
*vacuously* the moment that path moves β€” green, and checking nothing. It has happened: #946
moved `state.sh` and the layering contract kept passing while testing nothing. Prefer a glob
over a module to a path to a file, and after repointing one, **mutate the thing it guards and
watch it go red**.

## How to change things

**Add a function** β€” put it in the module file that owns the responsibility; nothing else to do.

**Add a file to a module** β€” create it, add one `source` line to that module's `index.sh`.
Its first line must be `#!/usr/bin/env bash` (`tail -n +2` strips it when embedding).

**Add a module** β€” create `src/<name>/index.sh` plus its files, and add one `source` line to
the `bashunit` entrypoint at the right point in the layering. Then check
`git check-ignore -v src/<name>/<file>.sh`: an unanchored `.gitignore` pattern once matched
`src/coverage/` and hid twelve files from git with no `??` in `git status` (#928).

**Add a subcommand** β€” implementation in `src/cli/`, flag parsing in `src/main/subcommands.sh`,
routing in the `bashunit` entrypoint, and entries in **both** completion scripts or
`completions_test.sh` fails.

**Split a file** β€” derive segment boundaries from the *next function's start*, never from a
`^}$` closing brace: a brace inside a heredoc or a `case` arm ends the segment early and
silently splits a function across files (#938). Prove the result is a relocation by comparing
the non-blank line multiset and the function count before and after, then diff the built
artifact β€” its code content should be identical.

## Consequences

**Good**

* `ls src/` names the architecture. Every entry is a concept, not a file.
* One convention for entry points at every depth.
* The build needs no per-module knowledge, so adding a module is one `source` line.
* Each file is small enough to read whole. The two largest are deliberate and were re-examined
more than once: `assert/core.sh` (970 lines) is one assertion catalogue whose size is
inherent to holding 42 assertions, and `main/test.sh` (489) is a single `case` over ~60
flags, which is the one shape that must not be cut across files. `config/env.sh` (754) is
likewise whole: 51 functions, but 33 of them are `is_*` predicates over one concern.

**Bad**

* More indirection when grepping: `bashunit::runner::*` spans eleven files.
* Return-slot globals cross file boundaries, so ShellCheck cannot see both ends. CI runs it
per file without `-x`, which surfaces cross-file symbol use a monolith hid β€” occasionally a
genuine dead local (#928), occasionally a needed `disable` comment.
* Every path-grepping contract is now one rename away from going vacuous. Mitigated by globbing
modules rather than naming files, and by mutation-testing after any repoint.

## Links

* [ADR-010](adr-010-src-module-directories.md) β€” the decision this describes the outcome of
* `.claude/rules/architecture-map.md` β€” the per-file map and the call flow of a run
* `.claude/rules/perf-fork-budget.md` β€” why per-test paths must stay fork-free
* `.claude/rules/bash-style.md` β€” the Bash 3.0 floor and the return-slot pattern
32 changes: 31 additions & 1 deletion docs/project-overview.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,13 +10,43 @@ This repository hosts the bashunit source code, its documentation and many autom

## Repository layout

- `src` – library functions used by `bashunit`.
- `src` – library functions used by `bashunit`, organised as modules (below).
- `bin` – the executable entry points.
- `adrs` – internal architecture decisions records.
- `example` – example scripts and tests demonstrating usage.
- `tests` – automated tests for bashunit itself.
- `docs` – documentation built with [VitePress](https://vitepress.dev/).

## Source modules

`src/` contains module directories and no loose files. Each module has an `index.sh` entry
point holding **only `source` lines** β€” the code lives in the sibling files beside it.

| Module | What it owns |
|---|---|
| `system` | capability probing: OS detection, `command -v`, small I/O helpers |
| `util` | computation: strings, arithmetic, time |
| `api` | the surface your test file calls β€” `temp_file`, `skip`/`todo`, custom-assert helpers |
| `config` | `BASHUNIT_*` defaults, scratch dirs, parallel mode, the rerun cache |
| `coverage` | line and branch tracking, and the coverage reports |
| `state` | counters, per-test context, the result payload |
| `console` | everything printed: palette, header, per-test lines, totals |
| `helper` | naming, test discovery, data providers, tags, encoding |
| `cli` | the `doc`, `init`, `upgrade` and `watch` subcommands |
| `assert` | every assertion |
| `reports` | JUnit, TAP, JSON, GitHub Actions and HTML writers |
| `runner` | the file loop, per-test execution, retry, result parsing |
| `benchmark` | the bench implementation |
| `learn` | the interactive tutorial |
| `main` | flag parsing per subcommand and the run lifecycle |

The released `bashunit` is a **single file**: `build.sh` walks the `source` statements from the
entrypoint, inlines every module in dependency order, and strips the `source` lines.

For the full picture β€” load order, the build pipeline, the tests that enforce all of it, and
how to add a file, a module or a subcommand β€” see
[ADR-011](https://github.com/TypedDevs/bashunit/blob/main/adrs/adr-011-source-layout-and-build-pipeline.md).

## Running tests

The project uses bashunit to test itself. To execute the full suite, run:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
183 changes: 183 additions & 0 deletions adrs/adr-011-source-layout-and-build-pipeline.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
# How `src/` is laid out and how the binary is built

* Status: accepted
* Deciders: Chemaclass
* Date: 2026-08-01

Technical Story: the layout settled across
[#924](https://github.com/TypedDevs/bashunit/issues/924),
[#925](https://github.com/TypedDevs/bashunit/issues/925),
[#931](https://github.com/TypedDevs/bashunit/issues/931),
[#940](https://github.com/TypedDevs/bashunit/issues/940),
[#948](https://github.com/TypedDevs/bashunit/issues/948) and
[#949](https://github.com/TypedDevs/bashunit/issues/949).

## Context and Problem Statement

ADR-010 decided *that* large files become module directories, and later amended *where* the
aggregator lives. What neither it nor any other document states is the whole picture: what the
modules are, in what order they load, how a single-file binary is assembled from them, and
which tests keep all of that honest.

That picture was spread across ADR-010, `build.sh` comments, `.claude/rules/architecture-map.md`
and eight issues. Someone arriving at the project β€” a contributor or an agent β€” had no single
place to read it.

This ADR is **descriptive, not a new decision.** It records the architecture as settled so it
can be understood from the outside without archaeology.

## The rule

**`src/` contains module directories and nothing else.** There are no loose `.sh` files.

A module is a directory whose entry point is `index.sh`:

```
src/<module>/index.sh <- the entry point: `source` lines and comments ONLY
src/<module>/<file>.sh <- one file per responsibility
```

`index.sh` means exactly one thing, everywhere: *a pure aggregator*. It never holds a
statement. That is load-bearing, not stylistic β€” see the build section β€” and it is why
`src/main.sh` was **not** renamed to `src/index.sh` when the question came up: a root
`index.sh` holding real code would make the name mean two things depending on depth.

## The modules

Seventeen, in the order the `bashunit` entrypoint sources them. The order is the dependency
layering: leaves first.

| # | Module | Files | Lines | Owns |
|---|---|---|---|---|
| 1 | `dev/` | 1 | 18 | debug helpers; **excluded from the build** |
| 2 | `system/` | 4 | 192 | capability probing: OS, `command -v`, small I/O |
| 3 | `util/` | 4 | 476 | computation: strings, arithmetic, time |
| 4 | `api/` | 5 | 207 | the surface a user's test file calls (except assertions) |
| 5 | `config/` | 4 | 964 | `BASHUNIT_*` defaults, scratch dirs, parallel mode, rerun cache |
| 6 | `coverage/` | 13 | 2644 | line/branch tracking and the four report formats |
| 7 | `state/` | 6 | 477 | counters, per-test context, result payload, parallel aggregation |
| 8 | `console/` | 9 | 1281 | everything printed: palette, header, per-test lines, totals |
| 9 | `helper/` | 8 | 976 | naming, discovery, data providers, tags, encoding |
| 10 | `cli/` | 5 | 447 | the `doc`/`init`/`upgrade`/`watch` subcommand implementations |
| 11 | `assert/` | 11 | 2336 | every assertion |
| 12 | `reports/` | 7 | 467 | JUnit, TAP, JSON, GHA and HTML writers |
| 13 | `runner/` | 11 | 2172 | the file loop, per-test execution, retry, result parsing |
| 14 | `benchmark/` | 4 | 221 | the bench implementation (`runner/bench.sh` is its loop) |
| 15 | `learn/` | 5 | 240 | the interactive tutorial |
| 16 | `main/` | 8 | 1475 | flag parsing per subcommand and the run lifecycle |

Namespaces track directories: `src/runner/` holds `bashunit::runner::*`. Two exceptions are
deliberate β€” `assert/` holds bare `assert_*` (the public API is unprefixed) and `console/`
holds `bashunit::console_results::*` in files named for what they do.

### Load order is not arbitrary

Most of it is ordinary dependency layering, but three points are genuinely load-bearing:

* **`api/globals.sh` runs `set -euo pipefail` at file scope.** Everything sourced after it
inherits strict mode. It must stay first inside `api/`, and `api/` must stay where it is.
* **`config/env.sh` executes at source time** β€” it loads `.bashunitrc` and `.env` and creates
the scratch dirs. It must come before `console/`, whose palette is built at file scope from
the values it resolves.
* **`main/` is sourced last**, after everything it dispatches to.

## How the binary is built

`build.sh` turns the module tree into one executable file. It is a **depth-first walk of
`source` statements, not a walk of directories** β€” so nesting depth and directory shape are
invisible to it.

```
1. build::dependencies read `^source ` lines from the `bashunit` entrypoint,
minus src/dev/ -> the embed list
2. build::process_file for each: emit `# <repo-relative path>`, then the file body
(shebang stripped), THEN recurse into its own `source` lines
dedupe on the repo-relative path
3. strip every `^source ` line from the result
4. build::embed_docs swap docs/assertions.md into a heredoc between the markers
in src/cli/doc.sh, so the binary needs no docs/ directory
5. build::assert_valid_syntax bash -n
6. build::verify (-v) run the whole suite against the built binary
```

**Step 2 is why an aggregator may hold only `source` lines.** A file's body is emitted *before*
its dependencies are recursed into, so a statement in an `index.sh` would run before the code
it depends on in the built binary, while running after it in dev mode β€” the two modes would
disagree.

The dedupe keys on the **repo-relative path**, never a basename: the top-level loop passes
relative paths and the recursion absolute ones, and two modules legitimately share a basename
(`config/parallel.sh` answers "is this run parallel"; `runner/parallel.sh` waits on job slots).

## The contracts that keep it honest

Every rule above is enforced by a test. This is the list to check before changing anything
structural.

| Contract | Test |
|---|---|
| Aggregators hold only `source` lines and comments | `build_test.sh` β€” globs `src/*/index.sh`, so it cannot drift |
| Every entrypoint-sourced file reaches the binary, and no others | `build_test.sh` β€” the #735 and bench-#0.31.0 regressions |
| Arbitrary nesting depth is embedded, in DFS order, with no `source` lines surviving | `build_test.sh` (#932) |
| Same basename in two modules does not collide | `build_test.sh` (#923) |
| `state/` never calls the renderer or `parallel` | `state_test.sh` β€” globs `src/state/*.sh` (#868, #862) |
| Shell completions match the flags `main/` actually parses | `completions_test.sh` |
| Every unprefixed env alias is declared and listed | `env_deprecated_aliases_test.sh` |
| No Bash 4+ syntax anywhere in `src/` | `bash_compatibility_test.sh` |

**These greps are the fragile part of the design.** A contract that greps a path passes
*vacuously* the moment that path moves β€” green, and checking nothing. It has happened: #946
moved `state.sh` and the layering contract kept passing while testing nothing. Prefer a glob
over a module to a path to a file, and after repointing one, **mutate the thing it guards and
watch it go red**.

## How to change things

**Add a function** β€” put it in the module file that owns the responsibility; nothing else to do.

**Add a file to a module** β€” create it, add one `source` line to that module's `index.sh`.
Its first line must be `#!/usr/bin/env bash` (`tail -n +2` strips it when embedding).

**Add a module** β€” create `src/<name>/index.sh` plus its files, and add one `source` line to
the `bashunit` entrypoint at the right point in the layering. Then check
`git check-ignore -v src/<name>/<file>.sh`: an unanchored `.gitignore` pattern once matched
`src/coverage/` and hid twelve files from git with no `??` in `git status` (#928).

**Add a subcommand** β€” implementation in `src/cli/`, flag parsing in `src/main/subcommands.sh`,
routing in the `bashunit` entrypoint, and entries in **both** completion scripts or
`completions_test.sh` fails.

**Split a file** β€” derive segment boundaries from the *next function's start*, never from a
`^}$` closing brace: a brace inside a heredoc or a `case` arm ends the segment early and
silently splits a function across files (#938). Prove the result is a relocation by comparing
the non-blank line multiset and the function count before and after, then diff the built
artifact β€” its code content should be identical.

## Consequences

**Good**

* `ls src/` names the architecture. Every entry is a concept, not a file.
* One convention for entry points at every depth.
* The build needs no per-module knowledge, so adding a module is one `source` line.
* Each file is small enough to read whole. The two largest are deliberate and were re-examined
more than once: `assert/core.sh` (970 lines) is one assertion catalogue whose size is
inherent to holding 42 assertions, and `main/test.sh` (489) is a single `case` over ~60
flags, which is the one shape that must not be cut across files. `config/env.sh` (754) is
likewise whole: 51 functions, but 33 of them are `is_*` predicates over one concern.

**Bad**

* More indirection when grepping: `bashunit::runner::*` spans eleven files.
* Return-slot globals cross file boundaries, so ShellCheck cannot see both ends. CI runs it
per file without `-x`, which surfaces cross-file symbol use a monolith hid β€” occasionally a
genuine dead local (#928), occasionally a needed `disable` comment.
* Every path-grepping contract is now one rename away from going vacuous. Mitigated by globbing
modules rather than naming files, and by mutation-testing after any repoint.

## Links

* [ADR-010](adr-010-src-module-directories.md) β€” the decision this describes the outcome of
* `.claude/rules/architecture-map.md` β€” the per-file map and the call flow of a run
* `.claude/rules/perf-fork-budget.md` β€” why per-test paths must stay fork-free
* `.claude/rules/bash-style.md` β€” the Bash 3.0 floor and the return-slot pattern
32 changes: 31 additions & 1 deletion docs/project-overview.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,13 +10,43 @@ This repository hosts the bashunit source code, its documentation and many autom

## Repository layout

- `src` – library functions used by `bashunit`.
- `src` – library functions used by `bashunit`, organised as modules (below).
- `bin` – the executable entry points.
- `adrs` – internal architecture decisions records.
- `example` – example scripts and tests demonstrating usage.
- `tests` – automated tests for bashunit itself.
- `docs` – documentation built with [VitePress](https://vitepress.dev/).

## Source modules

`src/` contains module directories and no loose files. Each module has an `index.sh` entry
point holding **only `source` lines** β€” the code lives in the sibling files beside it.

| Module | What it owns |
|---|---|
| `system` | capability probing: OS detection, `command -v`, small I/O helpers |
| `util` | computation: strings, arithmetic, time |
| `api` | the surface your test file calls β€” `temp_file`, `skip`/`todo`, custom-assert helpers |
| `config` | `BASHUNIT_*` defaults, scratch dirs, parallel mode, the rerun cache |
| `coverage` | line and branch tracking, and the coverage reports |
| `state` | counters, per-test context, the result payload |
| `console` | everything printed: palette, header, per-test lines, totals |
| `helper` | naming, test discovery, data providers, tags, encoding |
| `cli` | the `doc`, `init`, `upgrade` and `watch` subcommands |
| `assert` | every assertion |
| `reports` | JUnit, TAP, JSON, GitHub Actions and HTML writers |
| `runner` | the file loop, per-test execution, retry, result parsing |
| `benchmark` | the bench implementation |
| `learn` | the interactive tutorial |
| `main` | flag parsing per subcommand and the run lifecycle |

The released `bashunit` is a **single file**: `build.sh` walks the `source` statements from the
entrypoint, inlines every module in dependency order, and strips the `source` lines.

For the full picture β€” load order, the build pipeline, the tests that enforce all of it, and
how to add a file, a module or a subcommand β€” see
[ADR-011](https://github.com/TypedDevs/bashunit/blob/main/adrs/adr-011-source-layout-and-build-pipeline.md).

## Running tests

The project uses bashunit to test itself. To execute the full suite, run:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
183 changes: 183 additions & 0 deletions adrs/adr-011-source-layout-and-build-pipeline.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
# How `src/` is laid out and how the binary is built

* Status: accepted
* Deciders: Chemaclass
* Date: 2026-08-01

Technical Story: the layout settled across
[#924](https://github.com/TypedDevs/bashunit/issues/924),
[#925](https://github.com/TypedDevs/bashunit/issues/925),
[#931](https://github.com/TypedDevs/bashunit/issues/931),
[#940](https://github.com/TypedDevs/bashunit/issues/940),
[#948](https://github.com/TypedDevs/bashunit/issues/948) and
[#949](https://github.com/TypedDevs/bashunit/issues/949).

## Context and Problem Statement

ADR-010 decided *that* large files become module directories, and later amended *where* the
aggregator lives. What neither it nor any other document states is the whole picture: what the
modules are, in what order they load, how a single-file binary is assembled from them, and
which tests keep all of that honest.

That picture was spread across ADR-010, `build.sh` comments, `.claude/rules/architecture-map.md`
and eight issues. Someone arriving at the project β€” a contributor or an agent β€” had no single
place to read it.

This ADR is **descriptive, not a new decision.** It records the architecture as settled so it
can be understood from the outside without archaeology.

## The rule

**`src/` contains module directories and nothing else.** There are no loose `.sh` files.

A module is a directory whose entry point is `index.sh`:

```
src/<module>/index.sh <- the entry point: `source` lines and comments ONLY
src/<module>/<file>.sh <- one file per responsibility
```

`index.sh` means exactly one thing, everywhere: *a pure aggregator*. It never holds a
statement. That is load-bearing, not stylistic β€” see the build section β€” and it is why
`src/main.sh` was **not** renamed to `src/index.sh` when the question came up: a root
`index.sh` holding real code would make the name mean two things depending on depth.

## The modules

Seventeen, in the order the `bashunit` entrypoint sources them. The order is the dependency
layering: leaves first.

| # | Module | Files | Lines | Owns |
|---|---|---|---|---|
| 1 | `dev/` | 1 | 18 | debug helpers; **excluded from the build** |
| 2 | `system/` | 4 | 192 | capability probing: OS, `command -v`, small I/O |
| 3 | `util/` | 4 | 476 | computation: strings, arithmetic, time |
| 4 | `api/` | 5 | 207 | the surface a user's test file calls (except assertions) |
| 5 | `config/` | 4 | 964 | `BASHUNIT_*` defaults, scratch dirs, parallel mode, rerun cache |
| 6 | `coverage/` | 13 | 2644 | line/branch tracking and the four report formats |
| 7 | `state/` | 6 | 477 | counters, per-test context, result payload, parallel aggregation |
| 8 | `console/` | 9 | 1281 | everything printed: palette, header, per-test lines, totals |
| 9 | `helper/` | 8 | 976 | naming, discovery, data providers, tags, encoding |
| 10 | `cli/` | 5 | 447 | the `doc`/`init`/`upgrade`/`watch` subcommand implementations |
| 11 | `assert/` | 11 | 2336 | every assertion |
| 12 | `reports/` | 7 | 467 | JUnit, TAP, JSON, GHA and HTML writers |
| 13 | `runner/` | 11 | 2172 | the file loop, per-test execution, retry, result parsing |
| 14 | `benchmark/` | 4 | 221 | the bench implementation (`runner/bench.sh` is its loop) |
| 15 | `learn/` | 5 | 240 | the interactive tutorial |
| 16 | `main/` | 8 | 1475 | flag parsing per subcommand and the run lifecycle |

Namespaces track directories: `src/runner/` holds `bashunit::runner::*`. Two exceptions are
deliberate β€” `assert/` holds bare `assert_*` (the public API is unprefixed) and `console/`
holds `bashunit::console_results::*` in files named for what they do.

### Load order is not arbitrary

Most of it is ordinary dependency layering, but three points are genuinely load-bearing:

* **`api/globals.sh` runs `set -euo pipefail` at file scope.** Everything sourced after it
inherits strict mode. It must stay first inside `api/`, and `api/` must stay where it is.
* **`config/env.sh` executes at source time** β€” it loads `.bashunitrc` and `.env` and creates
the scratch dirs. It must come before `console/`, whose palette is built at file scope from
the values it resolves.
* **`main/` is sourced last**, after everything it dispatches to.

## How the binary is built

`build.sh` turns the module tree into one executable file. It is a **depth-first walk of
`source` statements, not a walk of directories** β€” so nesting depth and directory shape are
invisible to it.

```
1. build::dependencies read `^source ` lines from the `bashunit` entrypoint,
minus src/dev/ -> the embed list
2. build::process_file for each: emit `# <repo-relative path>`, then the file body
(shebang stripped), THEN recurse into its own `source` lines
dedupe on the repo-relative path
3. strip every `^source ` line from the result
4. build::embed_docs swap docs/assertions.md into a heredoc between the markers
in src/cli/doc.sh, so the binary needs no docs/ directory
5. build::assert_valid_syntax bash -n
6. build::verify (-v) run the whole suite against the built binary
```

**Step 2 is why an aggregator may hold only `source` lines.** A file's body is emitted *before*
its dependencies are recursed into, so a statement in an `index.sh` would run before the code
it depends on in the built binary, while running after it in dev mode β€” the two modes would
disagree.

The dedupe keys on the **repo-relative path**, never a basename: the top-level loop passes
relative paths and the recursion absolute ones, and two modules legitimately share a basename
(`config/parallel.sh` answers "is this run parallel"; `runner/parallel.sh` waits on job slots).

## The contracts that keep it honest

Every rule above is enforced by a test. This is the list to check before changing anything
structural.

| Contract | Test |
|---|---|
| Aggregators hold only `source` lines and comments | `build_test.sh` β€” globs `src/*/index.sh`, so it cannot drift |
| Every entrypoint-sourced file reaches the binary, and no others | `build_test.sh` β€” the #735 and bench-#0.31.0 regressions |
| Arbitrary nesting depth is embedded, in DFS order, with no `source` lines surviving | `build_test.sh` (#932) |
| Same basename in two modules does not collide | `build_test.sh` (#923) |
| `state/` never calls the renderer or `parallel` | `state_test.sh` β€” globs `src/state/*.sh` (#868, #862) |
| Shell completions match the flags `main/` actually parses | `completions_test.sh` |
| Every unprefixed env alias is declared and listed | `env_deprecated_aliases_test.sh` |
| No Bash 4+ syntax anywhere in `src/` | `bash_compatibility_test.sh` |

**These greps are the fragile part of the design.** A contract that greps a path passes
*vacuously* the moment that path moves β€” green, and checking nothing. It has happened: #946
moved `state.sh` and the layering contract kept passing while testing nothing. Prefer a glob
over a module to a path to a file, and after repointing one, **mutate the thing it guards and
watch it go red**.

## How to change things

**Add a function** β€” put it in the module file that owns the responsibility; nothing else to do.

**Add a file to a module** β€” create it, add one `source` line to that module's `index.sh`.
Its first line must be `#!/usr/bin/env bash` (`tail -n +2` strips it when embedding).

**Add a module** β€” create `src/<name>/index.sh` plus its files, and add one `source` line to
the `bashunit` entrypoint at the right point in the layering. Then check
`git check-ignore -v src/<name>/<file>.sh`: an unanchored `.gitignore` pattern once matched
`src/coverage/` and hid twelve files from git with no `??` in `git status` (#928).

**Add a subcommand** β€” implementation in `src/cli/`, flag parsing in `src/main/subcommands.sh`,
routing in the `bashunit` entrypoint, and entries in **both** completion scripts or
`completions_test.sh` fails.

**Split a file** β€” derive segment boundaries from the *next function's start*, never from a
`^}$` closing brace: a brace inside a heredoc or a `case` arm ends the segment early and
silently splits a function across files (#938). Prove the result is a relocation by comparing
the non-blank line multiset and the function count before and after, then diff the built
artifact β€” its code content should be identical.

## Consequences

**Good**

* `ls src/` names the architecture. Every entry is a concept, not a file.
* One convention for entry points at every depth.
* The build needs no per-module knowledge, so adding a module is one `source` line.
* Each file is small enough to read whole. The two largest are deliberate and were re-examined
more than once: `assert/core.sh` (970 lines) is one assertion catalogue whose size is
inherent to holding 42 assertions, and `main/test.sh` (489) is a single `case` over ~60
flags, which is the one shape that must not be cut across files. `config/env.sh` (754) is
likewise whole: 51 functions, but 33 of them are `is_*` predicates over one concern.

**Bad**

* More indirection when grepping: `bashunit::runner::*` spans eleven files.
* Return-slot globals cross file boundaries, so ShellCheck cannot see both ends. CI runs it
per file without `-x`, which surfaces cross-file symbol use a monolith hid β€” occasionally a
genuine dead local (#928), occasionally a needed `disable` comment.
* Every path-grepping contract is now one rename away from going vacuous. Mitigated by globbing
modules rather than naming files, and by mutation-testing after any repoint.

## Links

* [ADR-010](adr-010-src-module-directories.md) β€” the decision this describes the outcome of
* `.claude/rules/architecture-map.md` β€” the per-file map and the call flow of a run
* `.claude/rules/perf-fork-budget.md` β€” why per-test paths must stay fork-free
* `.claude/rules/bash-style.md` β€” the Bash 3.0 floor and the return-slot pattern
32 changes: 31 additions & 1 deletion docs/project-overview.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,13 +10,43 @@ This repository hosts the bashunit source code, its documentation and many autom

## Repository layout

- `src` – library functions used by `bashunit`.
- `src` – library functions used by `bashunit`, organised as modules (below).
- `bin` – the executable entry points.
- `adrs` – internal architecture decisions records.
- `example` – example scripts and tests demonstrating usage.
- `tests` – automated tests for bashunit itself.
- `docs` – documentation built with [VitePress](https://vitepress.dev/).

## Source modules

`src/` contains module directories and no loose files. Each module has an `index.sh` entry
point holding **only `source` lines** β€” the code lives in the sibling files beside it.

| Module | What it owns |
|---|---|
| `system` | capability probing: OS detection, `command -v`, small I/O helpers |
| `util` | computation: strings, arithmetic, time |
| `api` | the surface your test file calls β€” `temp_file`, `skip`/`todo`, custom-assert helpers |
| `config` | `BASHUNIT_*` defaults, scratch dirs, parallel mode, the rerun cache |
| `coverage` | line and branch tracking, and the coverage reports |
| `state` | counters, per-test context, the result payload |
| `console` | everything printed: palette, header, per-test lines, totals |
| `helper` | naming, test discovery, data providers, tags, encoding |
| `cli` | the `doc`, `init`, `upgrade` and `watch` subcommands |
| `assert` | every assertion |
| `reports` | JUnit, TAP, JSON, GitHub Actions and HTML writers |
| `runner` | the file loop, per-test execution, retry, result parsing |
| `benchmark` | the bench implementation |
| `learn` | the interactive tutorial |
| `main` | flag parsing per subcommand and the run lifecycle |

The released `bashunit` is a **single file**: `build.sh` walks the `source` statements from the
entrypoint, inlines every module in dependency order, and strips the `source` lines.

For the full picture β€” load order, the build pipeline, the tests that enforce all of it, and
how to add a file, a module or a subcommand β€” see
[ADR-011](https://github.com/TypedDevs/bashunit/blob/main/adrs/adr-011-source-layout-and-build-pipeline.md).

## Running tests

The project uses bashunit to test itself. To execute the full suite, run:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
183 changes: 183 additions & 0 deletions adrs/adr-011-source-layout-and-build-pipeline.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
# How `src/` is laid out and how the binary is built

* Status: accepted
* Deciders: Chemaclass
* Date: 2026-08-01

Technical Story: the layout settled across
[#924](https://github.com/TypedDevs/bashunit/issues/924),
[#925](https://github.com/TypedDevs/bashunit/issues/925),
[#931](https://github.com/TypedDevs/bashunit/issues/931),
[#940](https://github.com/TypedDevs/bashunit/issues/940),
[#948](https://github.com/TypedDevs/bashunit/issues/948) and
[#949](https://github.com/TypedDevs/bashunit/issues/949).

## Context and Problem Statement

ADR-010 decided *that* large files become module directories, and later amended *where* the
aggregator lives. What neither it nor any other document states is the whole picture: what the
modules are, in what order they load, how a single-file binary is assembled from them, and
which tests keep all of that honest.

That picture was spread across ADR-010, `build.sh` comments, `.claude/rules/architecture-map.md`
and eight issues. Someone arriving at the project β€” a contributor or an agent β€” had no single
place to read it.

This ADR is **descriptive, not a new decision.** It records the architecture as settled so it
can be understood from the outside without archaeology.

## The rule

**`src/` contains module directories and nothing else.** There are no loose `.sh` files.

A module is a directory whose entry point is `index.sh`:

```
src/<module>/index.sh <- the entry point: `source` lines and comments ONLY
src/<module>/<file>.sh <- one file per responsibility
```

`index.sh` means exactly one thing, everywhere: *a pure aggregator*. It never holds a
statement. That is load-bearing, not stylistic β€” see the build section β€” and it is why
`src/main.sh` was **not** renamed to `src/index.sh` when the question came up: a root
`index.sh` holding real code would make the name mean two things depending on depth.

## The modules

Seventeen, in the order the `bashunit` entrypoint sources them. The order is the dependency
layering: leaves first.

| # | Module | Files | Lines | Owns |
|---|---|---|---|---|
| 1 | `dev/` | 1 | 18 | debug helpers; **excluded from the build** |
| 2 | `system/` | 4 | 192 | capability probing: OS, `command -v`, small I/O |
| 3 | `util/` | 4 | 476 | computation: strings, arithmetic, time |
| 4 | `api/` | 5 | 207 | the surface a user's test file calls (except assertions) |
| 5 | `config/` | 4 | 964 | `BASHUNIT_*` defaults, scratch dirs, parallel mode, rerun cache |
| 6 | `coverage/` | 13 | 2644 | line/branch tracking and the four report formats |
| 7 | `state/` | 6 | 477 | counters, per-test context, result payload, parallel aggregation |
| 8 | `console/` | 9 | 1281 | everything printed: palette, header, per-test lines, totals |
| 9 | `helper/` | 8 | 976 | naming, discovery, data providers, tags, encoding |
| 10 | `cli/` | 5 | 447 | the `doc`/`init`/`upgrade`/`watch` subcommand implementations |
| 11 | `assert/` | 11 | 2336 | every assertion |
| 12 | `reports/` | 7 | 467 | JUnit, TAP, JSON, GHA and HTML writers |
| 13 | `runner/` | 11 | 2172 | the file loop, per-test execution, retry, result parsing |
| 14 | `benchmark/` | 4 | 221 | the bench implementation (`runner/bench.sh` is its loop) |
| 15 | `learn/` | 5 | 240 | the interactive tutorial |
| 16 | `main/` | 8 | 1475 | flag parsing per subcommand and the run lifecycle |

Namespaces track directories: `src/runner/` holds `bashunit::runner::*`. Two exceptions are
deliberate β€” `assert/` holds bare `assert_*` (the public API is unprefixed) and `console/`
holds `bashunit::console_results::*` in files named for what they do.

### Load order is not arbitrary

Most of it is ordinary dependency layering, but three points are genuinely load-bearing:

* **`api/globals.sh` runs `set -euo pipefail` at file scope.** Everything sourced after it
inherits strict mode. It must stay first inside `api/`, and `api/` must stay where it is.
* **`config/env.sh` executes at source time** β€” it loads `.bashunitrc` and `.env` and creates
the scratch dirs. It must come before `console/`, whose palette is built at file scope from
the values it resolves.
* **`main/` is sourced last**, after everything it dispatches to.

## How the binary is built

`build.sh` turns the module tree into one executable file. It is a **depth-first walk of
`source` statements, not a walk of directories** β€” so nesting depth and directory shape are
invisible to it.

```
1. build::dependencies read `^source ` lines from the `bashunit` entrypoint,
minus src/dev/ -> the embed list
2. build::process_file for each: emit `# <repo-relative path>`, then the file body
(shebang stripped), THEN recurse into its own `source` lines
dedupe on the repo-relative path
3. strip every `^source ` line from the result
4. build::embed_docs swap docs/assertions.md into a heredoc between the markers
in src/cli/doc.sh, so the binary needs no docs/ directory
5. build::assert_valid_syntax bash -n
6. build::verify (-v) run the whole suite against the built binary
```

**Step 2 is why an aggregator may hold only `source` lines.** A file's body is emitted *before*
its dependencies are recursed into, so a statement in an `index.sh` would run before the code
it depends on in the built binary, while running after it in dev mode β€” the two modes would
disagree.

The dedupe keys on the **repo-relative path**, never a basename: the top-level loop passes
relative paths and the recursion absolute ones, and two modules legitimately share a basename
(`config/parallel.sh` answers "is this run parallel"; `runner/parallel.sh` waits on job slots).

## The contracts that keep it honest

Every rule above is enforced by a test. This is the list to check before changing anything
structural.

| Contract | Test |
|---|---|
| Aggregators hold only `source` lines and comments | `build_test.sh` β€” globs `src/*/index.sh`, so it cannot drift |
| Every entrypoint-sourced file reaches the binary, and no others | `build_test.sh` β€” the #735 and bench-#0.31.0 regressions |
| Arbitrary nesting depth is embedded, in DFS order, with no `source` lines surviving | `build_test.sh` (#932) |
| Same basename in two modules does not collide | `build_test.sh` (#923) |
| `state/` never calls the renderer or `parallel` | `state_test.sh` β€” globs `src/state/*.sh` (#868, #862) |
| Shell completions match the flags `main/` actually parses | `completions_test.sh` |
| Every unprefixed env alias is declared and listed | `env_deprecated_aliases_test.sh` |
| No Bash 4+ syntax anywhere in `src/` | `bash_compatibility_test.sh` |

**These greps are the fragile part of the design.** A contract that greps a path passes
*vacuously* the moment that path moves β€” green, and checking nothing. It has happened: #946
moved `state.sh` and the layering contract kept passing while testing nothing. Prefer a glob
over a module to a path to a file, and after repointing one, **mutate the thing it guards and
watch it go red**.

## How to change things

**Add a function** β€” put it in the module file that owns the responsibility; nothing else to do.

**Add a file to a module** β€” create it, add one `source` line to that module's `index.sh`.
Its first line must be `#!/usr/bin/env bash` (`tail -n +2` strips it when embedding).

**Add a module** β€” create `src/<name>/index.sh` plus its files, and add one `source` line to
the `bashunit` entrypoint at the right point in the layering. Then check
`git check-ignore -v src/<name>/<file>.sh`: an unanchored `.gitignore` pattern once matched
`src/coverage/` and hid twelve files from git with no `??` in `git status` (#928).

**Add a subcommand** β€” implementation in `src/cli/`, flag parsing in `src/main/subcommands.sh`,
routing in the `bashunit` entrypoint, and entries in **both** completion scripts or
`completions_test.sh` fails.

**Split a file** β€” derive segment boundaries from the *next function's start*, never from a
`^}$` closing brace: a brace inside a heredoc or a `case` arm ends the segment early and
silently splits a function across files (#938). Prove the result is a relocation by comparing
the non-blank line multiset and the function count before and after, then diff the built
artifact β€” its code content should be identical.

## Consequences

**Good**

* `ls src/` names the architecture. Every entry is a concept, not a file.
* One convention for entry points at every depth.
* The build needs no per-module knowledge, so adding a module is one `source` line.
* Each file is small enough to read whole. The two largest are deliberate and were re-examined
more than once: `assert/core.sh` (970 lines) is one assertion catalogue whose size is
inherent to holding 42 assertions, and `main/test.sh` (489) is a single `case` over ~60
flags, which is the one shape that must not be cut across files. `config/env.sh` (754) is
likewise whole: 51 functions, but 33 of them are `is_*` predicates over one concern.

**Bad**

* More indirection when grepping: `bashunit::runner::*` spans eleven files.
* Return-slot globals cross file boundaries, so ShellCheck cannot see both ends. CI runs it
per file without `-x`, which surfaces cross-file symbol use a monolith hid β€” occasionally a
genuine dead local (#928), occasionally a needed `disable` comment.
* Every path-grepping contract is now one rename away from going vacuous. Mitigated by globbing
modules rather than naming files, and by mutation-testing after any repoint.

## Links

* [ADR-010](adr-010-src-module-directories.md) β€” the decision this describes the outcome of
* `.claude/rules/architecture-map.md` β€” the per-file map and the call flow of a run
* `.claude/rules/perf-fork-budget.md` β€” why per-test paths must stay fork-free
* `.claude/rules/bash-style.md` β€” the Bash 3.0 floor and the return-slot pattern
32 changes: 31 additions & 1 deletion docs/project-overview.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,13 +10,43 @@ This repository hosts the bashunit source code, its documentation and many autom

## Repository layout

- `src` – library functions used by `bashunit`.
- `src` – library functions used by `bashunit`, organised as modules (below).
- `bin` – the executable entry points.
- `adrs` – internal architecture decisions records.
- `example` – example scripts and tests demonstrating usage.
- `tests` – automated tests for bashunit itself.
- `docs` – documentation built with [VitePress](https://vitepress.dev/).

## Source modules

`src/` contains module directories and no loose files. Each module has an `index.sh` entry
point holding **only `source` lines** β€” the code lives in the sibling files beside it.

| Module | What it owns |
|---|---|
| `system` | capability probing: OS detection, `command -v`, small I/O helpers |
| `util` | computation: strings, arithmetic, time |
| `api` | the surface your test file calls β€” `temp_file`, `skip`/`todo`, custom-assert helpers |
| `config` | `BASHUNIT_*` defaults, scratch dirs, parallel mode, the rerun cache |
| `coverage` | line and branch tracking, and the coverage reports |
| `state` | counters, per-test context, the result payload |
| `console` | everything printed: palette, header, per-test lines, totals |
| `helper` | naming, test discovery, data providers, tags, encoding |
| `cli` | the `doc`, `init`, `upgrade` and `watch` subcommands |
| `assert` | every assertion |
| `reports` | JUnit, TAP, JSON, GitHub Actions and HTML writers |
| `runner` | the file loop, per-test execution, retry, result parsing |
| `benchmark` | the bench implementation |
| `learn` | the interactive tutorial |
| `main` | flag parsing per subcommand and the run lifecycle |

The released `bashunit` is a **single file**: `build.sh` walks the `source` statements from the
entrypoint, inlines every module in dependency order, and strips the `source` lines.

For the full picture β€” load order, the build pipeline, the tests that enforce all of it, and
how to add a file, a module or a subcommand β€” see
[ADR-011](https://github.com/TypedDevs/bashunit/blob/main/adrs/adr-011-source-layout-and-build-pipeline.md).

## Running tests

The project uses bashunit to test itself. To execute the full suite, run:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
183 changes: 183 additions & 0 deletions adrs/adr-011-source-layout-and-build-pipeline.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
# How `src/` is laid out and how the binary is built

* Status: accepted
* Deciders: Chemaclass
* Date: 2026-08-01

Technical Story: the layout settled across
[#924](https://github.com/TypedDevs/bashunit/issues/924),
[#925](https://github.com/TypedDevs/bashunit/issues/925),
[#931](https://github.com/TypedDevs/bashunit/issues/931),
[#940](https://github.com/TypedDevs/bashunit/issues/940),
[#948](https://github.com/TypedDevs/bashunit/issues/948) and
[#949](https://github.com/TypedDevs/bashunit/issues/949).

## Context and Problem Statement

ADR-010 decided *that* large files become module directories, and later amended *where* the
aggregator lives. What neither it nor any other document states is the whole picture: what the
modules are, in what order they load, how a single-file binary is assembled from them, and
which tests keep all of that honest.

That picture was spread across ADR-010, `build.sh` comments, `.claude/rules/architecture-map.md`
and eight issues. Someone arriving at the project β€” a contributor or an agent β€” had no single
place to read it.

This ADR is **descriptive, not a new decision.** It records the architecture as settled so it
can be understood from the outside without archaeology.

## The rule

**`src/` contains module directories and nothing else.** There are no loose `.sh` files.

A module is a directory whose entry point is `index.sh`:

```
src/<module>/index.sh <- the entry point: `source` lines and comments ONLY
src/<module>/<file>.sh <- one file per responsibility
```

`index.sh` means exactly one thing, everywhere: *a pure aggregator*. It never holds a
statement. That is load-bearing, not stylistic β€” see the build section β€” and it is why
`src/main.sh` was **not** renamed to `src/index.sh` when the question came up: a root
`index.sh` holding real code would make the name mean two things depending on depth.

## The modules

Seventeen, in the order the `bashunit` entrypoint sources them. The order is the dependency
layering: leaves first.

| # | Module | Files | Lines | Owns |
|---|---|---|---|---|
| 1 | `dev/` | 1 | 18 | debug helpers; **excluded from the build** |
| 2 | `system/` | 4 | 192 | capability probing: OS, `command -v`, small I/O |
| 3 | `util/` | 4 | 476 | computation: strings, arithmetic, time |
| 4 | `api/` | 5 | 207 | the surface a user's test file calls (except assertions) |
| 5 | `config/` | 4 | 964 | `BASHUNIT_*` defaults, scratch dirs, parallel mode, rerun cache |
| 6 | `coverage/` | 13 | 2644 | line/branch tracking and the four report formats |
| 7 | `state/` | 6 | 477 | counters, per-test context, result payload, parallel aggregation |
| 8 | `console/` | 9 | 1281 | everything printed: palette, header, per-test lines, totals |
| 9 | `helper/` | 8 | 976 | naming, discovery, data providers, tags, encoding |
| 10 | `cli/` | 5 | 447 | the `doc`/`init`/`upgrade`/`watch` subcommand implementations |
| 11 | `assert/` | 11 | 2336 | every assertion |
| 12 | `reports/` | 7 | 467 | JUnit, TAP, JSON, GHA and HTML writers |
| 13 | `runner/` | 11 | 2172 | the file loop, per-test execution, retry, result parsing |
| 14 | `benchmark/` | 4 | 221 | the bench implementation (`runner/bench.sh` is its loop) |
| 15 | `learn/` | 5 | 240 | the interactive tutorial |
| 16 | `main/` | 8 | 1475 | flag parsing per subcommand and the run lifecycle |

Namespaces track directories: `src/runner/` holds `bashunit::runner::*`. Two exceptions are
deliberate β€” `assert/` holds bare `assert_*` (the public API is unprefixed) and `console/`
holds `bashunit::console_results::*` in files named for what they do.

### Load order is not arbitrary

Most of it is ordinary dependency layering, but three points are genuinely load-bearing:

* **`api/globals.sh` runs `set -euo pipefail` at file scope.** Everything sourced after it
inherits strict mode. It must stay first inside `api/`, and `api/` must stay where it is.
* **`config/env.sh` executes at source time** β€” it loads `.bashunitrc` and `.env` and creates
the scratch dirs. It must come before `console/`, whose palette is built at file scope from
the values it resolves.
* **`main/` is sourced last**, after everything it dispatches to.

## How the binary is built

`build.sh` turns the module tree into one executable file. It is a **depth-first walk of
`source` statements, not a walk of directories** β€” so nesting depth and directory shape are
invisible to it.

```
1. build::dependencies read `^source ` lines from the `bashunit` entrypoint,
minus src/dev/ -> the embed list
2. build::process_file for each: emit `# <repo-relative path>`, then the file body
(shebang stripped), THEN recurse into its own `source` lines
dedupe on the repo-relative path
3. strip every `^source ` line from the result
4. build::embed_docs swap docs/assertions.md into a heredoc between the markers
in src/cli/doc.sh, so the binary needs no docs/ directory
5. build::assert_valid_syntax bash -n
6. build::verify (-v) run the whole suite against the built binary
```

**Step 2 is why an aggregator may hold only `source` lines.** A file's body is emitted *before*
its dependencies are recursed into, so a statement in an `index.sh` would run before the code
it depends on in the built binary, while running after it in dev mode β€” the two modes would
disagree.

The dedupe keys on the **repo-relative path**, never a basename: the top-level loop passes
relative paths and the recursion absolute ones, and two modules legitimately share a basename
(`config/parallel.sh` answers "is this run parallel"; `runner/parallel.sh` waits on job slots).

## The contracts that keep it honest

Every rule above is enforced by a test. This is the list to check before changing anything
structural.

| Contract | Test |
|---|---|
| Aggregators hold only `source` lines and comments | `build_test.sh` β€” globs `src/*/index.sh`, so it cannot drift |
| Every entrypoint-sourced file reaches the binary, and no others | `build_test.sh` β€” the #735 and bench-#0.31.0 regressions |
| Arbitrary nesting depth is embedded, in DFS order, with no `source` lines surviving | `build_test.sh` (#932) |
| Same basename in two modules does not collide | `build_test.sh` (#923) |
| `state/` never calls the renderer or `parallel` | `state_test.sh` β€” globs `src/state/*.sh` (#868, #862) |
| Shell completions match the flags `main/` actually parses | `completions_test.sh` |
| Every unprefixed env alias is declared and listed | `env_deprecated_aliases_test.sh` |
| No Bash 4+ syntax anywhere in `src/` | `bash_compatibility_test.sh` |

**These greps are the fragile part of the design.** A contract that greps a path passes
*vacuously* the moment that path moves β€” green, and checking nothing. It has happened: #946
moved `state.sh` and the layering contract kept passing while testing nothing. Prefer a glob
over a module to a path to a file, and after repointing one, **mutate the thing it guards and
watch it go red**.

## How to change things

**Add a function** β€” put it in the module file that owns the responsibility; nothing else to do.

**Add a file to a module** β€” create it, add one `source` line to that module's `index.sh`.
Its first line must be `#!/usr/bin/env bash` (`tail -n +2` strips it when embedding).

**Add a module** β€” create `src/<name>/index.sh` plus its files, and add one `source` line to
the `bashunit` entrypoint at the right point in the layering. Then check
`git check-ignore -v src/<name>/<file>.sh`: an unanchored `.gitignore` pattern once matched
`src/coverage/` and hid twelve files from git with no `??` in `git status` (#928).

**Add a subcommand** β€” implementation in `src/cli/`, flag parsing in `src/main/subcommands.sh`,
routing in the `bashunit` entrypoint, and entries in **both** completion scripts or
`completions_test.sh` fails.

**Split a file** β€” derive segment boundaries from the *next function's start*, never from a
`^}$` closing brace: a brace inside a heredoc or a `case` arm ends the segment early and
silently splits a function across files (#938). Prove the result is a relocation by comparing
the non-blank line multiset and the function count before and after, then diff the built
artifact β€” its code content should be identical.

## Consequences

**Good**

* `ls src/` names the architecture. Every entry is a concept, not a file.
* One convention for entry points at every depth.
* The build needs no per-module knowledge, so adding a module is one `source` line.
* Each file is small enough to read whole. The two largest are deliberate and were re-examined
more than once: `assert/core.sh` (970 lines) is one assertion catalogue whose size is
inherent to holding 42 assertions, and `main/test.sh` (489) is a single `case` over ~60
flags, which is the one shape that must not be cut across files. `config/env.sh` (754) is
likewise whole: 51 functions, but 33 of them are `is_*` predicates over one concern.

**Bad**

* More indirection when grepping: `bashunit::runner::*` spans eleven files.
* Return-slot globals cross file boundaries, so ShellCheck cannot see both ends. CI runs it
per file without `-x`, which surfaces cross-file symbol use a monolith hid β€” occasionally a
genuine dead local (#928), occasionally a needed `disable` comment.
* Every path-grepping contract is now one rename away from going vacuous. Mitigated by globbing
modules rather than naming files, and by mutation-testing after any repoint.

## Links

* [ADR-010](adr-010-src-module-directories.md) β€” the decision this describes the outcome of
* `.claude/rules/architecture-map.md` β€” the per-file map and the call flow of a run
* `.claude/rules/perf-fork-budget.md` β€” why per-test paths must stay fork-free
* `.claude/rules/bash-style.md` β€” the Bash 3.0 floor and the return-slot pattern
32 changes: 31 additions & 1 deletion docs/project-overview.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,13 +10,43 @@ This repository hosts the bashunit source code, its documentation and many autom

## Repository layout

- `src` – library functions used by `bashunit`.
- `src` – library functions used by `bashunit`, organised as modules (below).
- `bin` – the executable entry points.
- `adrs` – internal architecture decisions records.
- `example` – example scripts and tests demonstrating usage.
- `tests` – automated tests for bashunit itself.
- `docs` – documentation built with [VitePress](https://vitepress.dev/).

## Source modules

`src/` contains module directories and no loose files. Each module has an `index.sh` entry
point holding **only `source` lines** β€” the code lives in the sibling files beside it.

| Module | What it owns |
|---|---|
| `system` | capability probing: OS detection, `command -v`, small I/O helpers |
| `util` | computation: strings, arithmetic, time |
| `api` | the surface your test file calls β€” `temp_file`, `skip`/`todo`, custom-assert helpers |
| `config` | `BASHUNIT_*` defaults, scratch dirs, parallel mode, the rerun cache |
| `coverage` | line and branch tracking, and the coverage reports |
| `state` | counters, per-test context, the result payload |
| `console` | everything printed: palette, header, per-test lines, totals |
| `helper` | naming, test discovery, data providers, tags, encoding |
| `cli` | the `doc`, `init`, `upgrade` and `watch` subcommands |
| `assert` | every assertion |
| `reports` | JUnit, TAP, JSON, GitHub Actions and HTML writers |
| `runner` | the file loop, per-test execution, retry, result parsing |
| `benchmark` | the bench implementation |
| `learn` | the interactive tutorial |
| `main` | flag parsing per subcommand and the run lifecycle |

The released `bashunit` is a **single file**: `build.sh` walks the `source` statements from the
entrypoint, inlines every module in dependency order, and strips the `source` lines.

For the full picture β€” load order, the build pipeline, the tests that enforce all of it, and
how to add a file, a module or a subcommand β€” see
[ADR-011](https://github.com/TypedDevs/bashunit/blob/main/adrs/adr-011-source-layout-and-build-pipeline.md).

## Running tests

The project uses bashunit to test itself. To execute the full suite, run:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
183 changes: 183 additions & 0 deletions adrs/adr-011-source-layout-and-build-pipeline.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
# How `src/` is laid out and how the binary is built

* Status: accepted
* Deciders: Chemaclass
* Date: 2026-08-01

Technical Story: the layout settled across
[#924](https://github.com/TypedDevs/bashunit/issues/924),
[#925](https://github.com/TypedDevs/bashunit/issues/925),
[#931](https://github.com/TypedDevs/bashunit/issues/931),
[#940](https://github.com/TypedDevs/bashunit/issues/940),
[#948](https://github.com/TypedDevs/bashunit/issues/948) and
[#949](https://github.com/TypedDevs/bashunit/issues/949).

## Context and Problem Statement

ADR-010 decided *that* large files become module directories, and later amended *where* the
aggregator lives. What neither it nor any other document states is the whole picture: what the
modules are, in what order they load, how a single-file binary is assembled from them, and
which tests keep all of that honest.

That picture was spread across ADR-010, `build.sh` comments, `.claude/rules/architecture-map.md`
and eight issues. Someone arriving at the project β€” a contributor or an agent β€” had no single
place to read it.

This ADR is **descriptive, not a new decision.** It records the architecture as settled so it
can be understood from the outside without archaeology.

## The rule

**`src/` contains module directories and nothing else.** There are no loose `.sh` files.

A module is a directory whose entry point is `index.sh`:

```
src/<module>/index.sh <- the entry point: `source` lines and comments ONLY
src/<module>/<file>.sh <- one file per responsibility
```

`index.sh` means exactly one thing, everywhere: *a pure aggregator*. It never holds a
statement. That is load-bearing, not stylistic β€” see the build section β€” and it is why
`src/main.sh` was **not** renamed to `src/index.sh` when the question came up: a root
`index.sh` holding real code would make the name mean two things depending on depth.

## The modules

Seventeen, in the order the `bashunit` entrypoint sources them. The order is the dependency
layering: leaves first.

| # | Module | Files | Lines | Owns |
|---|---|---|---|---|
| 1 | `dev/` | 1 | 18 | debug helpers; **excluded from the build** |
| 2 | `system/` | 4 | 192 | capability probing: OS, `command -v`, small I/O |
| 3 | `util/` | 4 | 476 | computation: strings, arithmetic, time |
| 4 | `api/` | 5 | 207 | the surface a user's test file calls (except assertions) |
| 5 | `config/` | 4 | 964 | `BASHUNIT_*` defaults, scratch dirs, parallel mode, rerun cache |
| 6 | `coverage/` | 13 | 2644 | line/branch tracking and the four report formats |
| 7 | `state/` | 6 | 477 | counters, per-test context, result payload, parallel aggregation |
| 8 | `console/` | 9 | 1281 | everything printed: palette, header, per-test lines, totals |
| 9 | `helper/` | 8 | 976 | naming, discovery, data providers, tags, encoding |
| 10 | `cli/` | 5 | 447 | the `doc`/`init`/`upgrade`/`watch` subcommand implementations |
| 11 | `assert/` | 11 | 2336 | every assertion |
| 12 | `reports/` | 7 | 467 | JUnit, TAP, JSON, GHA and HTML writers |
| 13 | `runner/` | 11 | 2172 | the file loop, per-test execution, retry, result parsing |
| 14 | `benchmark/` | 4 | 221 | the bench implementation (`runner/bench.sh` is its loop) |
| 15 | `learn/` | 5 | 240 | the interactive tutorial |
| 16 | `main/` | 8 | 1475 | flag parsing per subcommand and the run lifecycle |

Namespaces track directories: `src/runner/` holds `bashunit::runner::*`. Two exceptions are
deliberate β€” `assert/` holds bare `assert_*` (the public API is unprefixed) and `console/`
holds `bashunit::console_results::*` in files named for what they do.

### Load order is not arbitrary

Most of it is ordinary dependency layering, but three points are genuinely load-bearing:

* **`api/globals.sh` runs `set -euo pipefail` at file scope.** Everything sourced after it
inherits strict mode. It must stay first inside `api/`, and `api/` must stay where it is.
* **`config/env.sh` executes at source time** β€” it loads `.bashunitrc` and `.env` and creates
the scratch dirs. It must come before `console/`, whose palette is built at file scope from
the values it resolves.
* **`main/` is sourced last**, after everything it dispatches to.

## How the binary is built

`build.sh` turns the module tree into one executable file. It is a **depth-first walk of
`source` statements, not a walk of directories** β€” so nesting depth and directory shape are
invisible to it.

```
1. build::dependencies read `^source ` lines from the `bashunit` entrypoint,
minus src/dev/ -> the embed list
2. build::process_file for each: emit `# <repo-relative path>`, then the file body
(shebang stripped), THEN recurse into its own `source` lines
dedupe on the repo-relative path
3. strip every `^source ` line from the result
4. build::embed_docs swap docs/assertions.md into a heredoc between the markers
in src/cli/doc.sh, so the binary needs no docs/ directory
5. build::assert_valid_syntax bash -n
6. build::verify (-v) run the whole suite against the built binary
```

**Step 2 is why an aggregator may hold only `source` lines.** A file's body is emitted *before*
its dependencies are recursed into, so a statement in an `index.sh` would run before the code
it depends on in the built binary, while running after it in dev mode β€” the two modes would
disagree.

The dedupe keys on the **repo-relative path**, never a basename: the top-level loop passes
relative paths and the recursion absolute ones, and two modules legitimately share a basename
(`config/parallel.sh` answers "is this run parallel"; `runner/parallel.sh` waits on job slots).

## The contracts that keep it honest

Every rule above is enforced by a test. This is the list to check before changing anything
structural.

| Contract | Test |
|---|---|
| Aggregators hold only `source` lines and comments | `build_test.sh` β€” globs `src/*/index.sh`, so it cannot drift |
| Every entrypoint-sourced file reaches the binary, and no others | `build_test.sh` β€” the #735 and bench-#0.31.0 regressions |
| Arbitrary nesting depth is embedded, in DFS order, with no `source` lines surviving | `build_test.sh` (#932) |
| Same basename in two modules does not collide | `build_test.sh` (#923) |
| `state/` never calls the renderer or `parallel` | `state_test.sh` β€” globs `src/state/*.sh` (#868, #862) |
| Shell completions match the flags `main/` actually parses | `completions_test.sh` |
| Every unprefixed env alias is declared and listed | `env_deprecated_aliases_test.sh` |
| No Bash 4+ syntax anywhere in `src/` | `bash_compatibility_test.sh` |

**These greps are the fragile part of the design.** A contract that greps a path passes
*vacuously* the moment that path moves β€” green, and checking nothing. It has happened: #946
moved `state.sh` and the layering contract kept passing while testing nothing. Prefer a glob
over a module to a path to a file, and after repointing one, **mutate the thing it guards and
watch it go red**.

## How to change things

**Add a function** β€” put it in the module file that owns the responsibility; nothing else to do.

**Add a file to a module** β€” create it, add one `source` line to that module's `index.sh`.
Its first line must be `#!/usr/bin/env bash` (`tail -n +2` strips it when embedding).

**Add a module** β€” create `src/<name>/index.sh` plus its files, and add one `source` line to
the `bashunit` entrypoint at the right point in the layering. Then check
`git check-ignore -v src/<name>/<file>.sh`: an unanchored `.gitignore` pattern once matched
`src/coverage/` and hid twelve files from git with no `??` in `git status` (#928).

**Add a subcommand** β€” implementation in `src/cli/`, flag parsing in `src/main/subcommands.sh`,
routing in the `bashunit` entrypoint, and entries in **both** completion scripts or
`completions_test.sh` fails.

**Split a file** β€” derive segment boundaries from the *next function's start*, never from a
`^}$` closing brace: a brace inside a heredoc or a `case` arm ends the segment early and
silently splits a function across files (#938). Prove the result is a relocation by comparing
the non-blank line multiset and the function count before and after, then diff the built
artifact β€” its code content should be identical.

## Consequences

**Good**

* `ls src/` names the architecture. Every entry is a concept, not a file.
* One convention for entry points at every depth.
* The build needs no per-module knowledge, so adding a module is one `source` line.
* Each file is small enough to read whole. The two largest are deliberate and were re-examined
more than once: `assert/core.sh` (970 lines) is one assertion catalogue whose size is
inherent to holding 42 assertions, and `main/test.sh` (489) is a single `case` over ~60
flags, which is the one shape that must not be cut across files. `config/env.sh` (754) is
likewise whole: 51 functions, but 33 of them are `is_*` predicates over one concern.

**Bad**

* More indirection when grepping: `bashunit::runner::*` spans eleven files.
* Return-slot globals cross file boundaries, so ShellCheck cannot see both ends. CI runs it
per file without `-x`, which surfaces cross-file symbol use a monolith hid β€” occasionally a
genuine dead local (#928), occasionally a needed `disable` comment.
* Every path-grepping contract is now one rename away from going vacuous. Mitigated by globbing
modules rather than naming files, and by mutation-testing after any repoint.

## Links

* [ADR-010](adr-010-src-module-directories.md) β€” the decision this describes the outcome of
* `.claude/rules/architecture-map.md` β€” the per-file map and the call flow of a run
* `.claude/rules/perf-fork-budget.md` β€” why per-test paths must stay fork-free
* `.claude/rules/bash-style.md` β€” the Bash 3.0 floor and the return-slot pattern
32 changes: 31 additions & 1 deletion docs/project-overview.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,13 +10,43 @@ This repository hosts the bashunit source code, its documentation and many autom

## Repository layout

- `src` – library functions used by `bashunit`.
- `src` – library functions used by `bashunit`, organised as modules (below).
- `bin` – the executable entry points.
- `adrs` – internal architecture decisions records.
- `example` – example scripts and tests demonstrating usage.
- `tests` – automated tests for bashunit itself.
- `docs` – documentation built with [VitePress](https://vitepress.dev/).

## Source modules

`src/` contains module directories and no loose files. Each module has an `index.sh` entry
point holding **only `source` lines** β€” the code lives in the sibling files beside it.

| Module | What it owns |
|---|---|
| `system` | capability probing: OS detection, `command -v`, small I/O helpers |
| `util` | computation: strings, arithmetic, time |
| `api` | the surface your test file calls β€” `temp_file`, `skip`/`todo`, custom-assert helpers |
| `config` | `BASHUNIT_*` defaults, scratch dirs, parallel mode, the rerun cache |
| `coverage` | line and branch tracking, and the coverage reports |
| `state` | counters, per-test context, the result payload |
| `console` | everything printed: palette, header, per-test lines, totals |
| `helper` | naming, test discovery, data providers, tags, encoding |
| `cli` | the `doc`, `init`, `upgrade` and `watch` subcommands |
| `assert` | every assertion |
| `reports` | JUnit, TAP, JSON, GitHub Actions and HTML writers |
| `runner` | the file loop, per-test execution, retry, result parsing |
| `benchmark` | the bench implementation |
| `learn` | the interactive tutorial |
| `main` | flag parsing per subcommand and the run lifecycle |

The released `bashunit` is a **single file**: `build.sh` walks the `source` statements from the
entrypoint, inlines every module in dependency order, and strips the `source` lines.

For the full picture β€” load order, the build pipeline, the tests that enforce all of it, and
how to add a file, a module or a subcommand β€” see
[ADR-011](https://github.com/TypedDevs/bashunit/blob/main/adrs/adr-011-source-layout-and-build-pipeline.md).

## Running tests

The project uses bashunit to test itself. To execute the full suite, run:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
183 changes: 183 additions & 0 deletions adrs/adr-011-source-layout-and-build-pipeline.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
# How `src/` is laid out and how the binary is built

* Status: accepted
* Deciders: Chemaclass
* Date: 2026-08-01

Technical Story: the layout settled across
[#924](https://github.com/TypedDevs/bashunit/issues/924),
[#925](https://github.com/TypedDevs/bashunit/issues/925),
[#931](https://github.com/TypedDevs/bashunit/issues/931),
[#940](https://github.com/TypedDevs/bashunit/issues/940),
[#948](https://github.com/TypedDevs/bashunit/issues/948) and
[#949](https://github.com/TypedDevs/bashunit/issues/949).

## Context and Problem Statement

ADR-010 decided *that* large files become module directories, and later amended *where* the
aggregator lives. What neither it nor any other document states is the whole picture: what the
modules are, in what order they load, how a single-file binary is assembled from them, and
which tests keep all of that honest.

That picture was spread across ADR-010, `build.sh` comments, `.claude/rules/architecture-map.md`
and eight issues. Someone arriving at the project β€” a contributor or an agent β€” had no single
place to read it.

This ADR is **descriptive, not a new decision.** It records the architecture as settled so it
can be understood from the outside without archaeology.

## The rule

**`src/` contains module directories and nothing else.** There are no loose `.sh` files.

A module is a directory whose entry point is `index.sh`:

```
src/<module>/index.sh <- the entry point: `source` lines and comments ONLY
src/<module>/<file>.sh <- one file per responsibility
```

`index.sh` means exactly one thing, everywhere: *a pure aggregator*. It never holds a
statement. That is load-bearing, not stylistic β€” see the build section β€” and it is why
`src/main.sh` was **not** renamed to `src/index.sh` when the question came up: a root
`index.sh` holding real code would make the name mean two things depending on depth.

## The modules

Seventeen, in the order the `bashunit` entrypoint sources them. The order is the dependency
layering: leaves first.

| # | Module | Files | Lines | Owns |
|---|---|---|---|---|
| 1 | `dev/` | 1 | 18 | debug helpers; **excluded from the build** |
| 2 | `system/` | 4 | 192 | capability probing: OS, `command -v`, small I/O |
| 3 | `util/` | 4 | 476 | computation: strings, arithmetic, time |
| 4 | `api/` | 5 | 207 | the surface a user's test file calls (except assertions) |
| 5 | `config/` | 4 | 964 | `BASHUNIT_*` defaults, scratch dirs, parallel mode, rerun cache |
| 6 | `coverage/` | 13 | 2644 | line/branch tracking and the four report formats |
| 7 | `state/` | 6 | 477 | counters, per-test context, result payload, parallel aggregation |
| 8 | `console/` | 9 | 1281 | everything printed: palette, header, per-test lines, totals |
| 9 | `helper/` | 8 | 976 | naming, discovery, data providers, tags, encoding |
| 10 | `cli/` | 5 | 447 | the `doc`/`init`/`upgrade`/`watch` subcommand implementations |
| 11 | `assert/` | 11 | 2336 | every assertion |
| 12 | `reports/` | 7 | 467 | JUnit, TAP, JSON, GHA and HTML writers |
| 13 | `runner/` | 11 | 2172 | the file loop, per-test execution, retry, result parsing |
| 14 | `benchmark/` | 4 | 221 | the bench implementation (`runner/bench.sh` is its loop) |
| 15 | `learn/` | 5 | 240 | the interactive tutorial |
| 16 | `main/` | 8 | 1475 | flag parsing per subcommand and the run lifecycle |

Namespaces track directories: `src/runner/` holds `bashunit::runner::*`. Two exceptions are
deliberate β€” `assert/` holds bare `assert_*` (the public API is unprefixed) and `console/`
holds `bashunit::console_results::*` in files named for what they do.

### Load order is not arbitrary

Most of it is ordinary dependency layering, but three points are genuinely load-bearing:

* **`api/globals.sh` runs `set -euo pipefail` at file scope.** Everything sourced after it
inherits strict mode. It must stay first inside `api/`, and `api/` must stay where it is.
* **`config/env.sh` executes at source time** β€” it loads `.bashunitrc` and `.env` and creates
the scratch dirs. It must come before `console/`, whose palette is built at file scope from
the values it resolves.
* **`main/` is sourced last**, after everything it dispatches to.

## How the binary is built

`build.sh` turns the module tree into one executable file. It is a **depth-first walk of
`source` statements, not a walk of directories** β€” so nesting depth and directory shape are
invisible to it.

```
1. build::dependencies read `^source ` lines from the `bashunit` entrypoint,
minus src/dev/ -> the embed list
2. build::process_file for each: emit `# <repo-relative path>`, then the file body
(shebang stripped), THEN recurse into its own `source` lines
dedupe on the repo-relative path
3. strip every `^source ` line from the result
4. build::embed_docs swap docs/assertions.md into a heredoc between the markers
in src/cli/doc.sh, so the binary needs no docs/ directory
5. build::assert_valid_syntax bash -n
6. build::verify (-v) run the whole suite against the built binary
```

**Step 2 is why an aggregator may hold only `source` lines.** A file's body is emitted *before*
its dependencies are recursed into, so a statement in an `index.sh` would run before the code
it depends on in the built binary, while running after it in dev mode β€” the two modes would
disagree.

The dedupe keys on the **repo-relative path**, never a basename: the top-level loop passes
relative paths and the recursion absolute ones, and two modules legitimately share a basename
(`config/parallel.sh` answers "is this run parallel"; `runner/parallel.sh` waits on job slots).

## The contracts that keep it honest

Every rule above is enforced by a test. This is the list to check before changing anything
structural.

| Contract | Test |
|---|---|
| Aggregators hold only `source` lines and comments | `build_test.sh` β€” globs `src/*/index.sh`, so it cannot drift |
| Every entrypoint-sourced file reaches the binary, and no others | `build_test.sh` β€” the #735 and bench-#0.31.0 regressions |
| Arbitrary nesting depth is embedded, in DFS order, with no `source` lines surviving | `build_test.sh` (#932) |
| Same basename in two modules does not collide | `build_test.sh` (#923) |
| `state/` never calls the renderer or `parallel` | `state_test.sh` β€” globs `src/state/*.sh` (#868, #862) |
| Shell completions match the flags `main/` actually parses | `completions_test.sh` |
| Every unprefixed env alias is declared and listed | `env_deprecated_aliases_test.sh` |
| No Bash 4+ syntax anywhere in `src/` | `bash_compatibility_test.sh` |

**These greps are the fragile part of the design.** A contract that greps a path passes
*vacuously* the moment that path moves β€” green, and checking nothing. It has happened: #946
moved `state.sh` and the layering contract kept passing while testing nothing. Prefer a glob
over a module to a path to a file, and after repointing one, **mutate the thing it guards and
watch it go red**.

## How to change things

**Add a function** β€” put it in the module file that owns the responsibility; nothing else to do.

**Add a file to a module** β€” create it, add one `source` line to that module's `index.sh`.
Its first line must be `#!/usr/bin/env bash` (`tail -n +2` strips it when embedding).

**Add a module** β€” create `src/<name>/index.sh` plus its files, and add one `source` line to
the `bashunit` entrypoint at the right point in the layering. Then check
`git check-ignore -v src/<name>/<file>.sh`: an unanchored `.gitignore` pattern once matched
`src/coverage/` and hid twelve files from git with no `??` in `git status` (#928).

**Add a subcommand** β€” implementation in `src/cli/`, flag parsing in `src/main/subcommands.sh`,
routing in the `bashunit` entrypoint, and entries in **both** completion scripts or
`completions_test.sh` fails.

**Split a file** β€” derive segment boundaries from the *next function's start*, never from a
`^}$` closing brace: a brace inside a heredoc or a `case` arm ends the segment early and
silently splits a function across files (#938). Prove the result is a relocation by comparing
the non-blank line multiset and the function count before and after, then diff the built
artifact β€” its code content should be identical.

## Consequences

**Good**

* `ls src/` names the architecture. Every entry is a concept, not a file.
* One convention for entry points at every depth.
* The build needs no per-module knowledge, so adding a module is one `source` line.
* Each file is small enough to read whole. The two largest are deliberate and were re-examined
more than once: `assert/core.sh` (970 lines) is one assertion catalogue whose size is
inherent to holding 42 assertions, and `main/test.sh` (489) is a single `case` over ~60
flags, which is the one shape that must not be cut across files. `config/env.sh` (754) is
likewise whole: 51 functions, but 33 of them are `is_*` predicates over one concern.

**Bad**

* More indirection when grepping: `bashunit::runner::*` spans eleven files.
* Return-slot globals cross file boundaries, so ShellCheck cannot see both ends. CI runs it
per file without `-x`, which surfaces cross-file symbol use a monolith hid β€” occasionally a
genuine dead local (#928), occasionally a needed `disable` comment.
* Every path-grepping contract is now one rename away from going vacuous. Mitigated by globbing
modules rather than naming files, and by mutation-testing after any repoint.

## Links

* [ADR-010](adr-010-src-module-directories.md) β€” the decision this describes the outcome of
* `.claude/rules/architecture-map.md` β€” the per-file map and the call flow of a run
* `.claude/rules/perf-fork-budget.md` β€” why per-test paths must stay fork-free
* `.claude/rules/bash-style.md` β€” the Bash 3.0 floor and the return-slot pattern
32 changes: 31 additions & 1 deletion docs/project-overview.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,13 +10,43 @@ This repository hosts the bashunit source code, its documentation and many autom

## Repository layout

- `src` – library functions used by `bashunit`.
- `src` – library functions used by `bashunit`, organised as modules (below).
- `bin` – the executable entry points.
- `adrs` – internal architecture decisions records.
- `example` – example scripts and tests demonstrating usage.
- `tests` – automated tests for bashunit itself.
- `docs` – documentation built with [VitePress](https://vitepress.dev/).

## Source modules

`src/` contains module directories and no loose files. Each module has an `index.sh` entry
point holding **only `source` lines** β€” the code lives in the sibling files beside it.

| Module | What it owns |
|---|---|
| `system` | capability probing: OS detection, `command -v`, small I/O helpers |
| `util` | computation: strings, arithmetic, time |
| `api` | the surface your test file calls β€” `temp_file`, `skip`/`todo`, custom-assert helpers |
| `config` | `BASHUNIT_*` defaults, scratch dirs, parallel mode, the rerun cache |
| `coverage` | line and branch tracking, and the coverage reports |
| `state` | counters, per-test context, the result payload |
| `console` | everything printed: palette, header, per-test lines, totals |
| `helper` | naming, test discovery, data providers, tags, encoding |
| `cli` | the `doc`, `init`, `upgrade` and `watch` subcommands |
| `assert` | every assertion |
| `reports` | JUnit, TAP, JSON, GitHub Actions and HTML writers |
| `runner` | the file loop, per-test execution, retry, result parsing |
| `benchmark` | the bench implementation |
| `learn` | the interactive tutorial |
| `main` | flag parsing per subcommand and the run lifecycle |

The released `bashunit` is a **single file**: `build.sh` walks the `source` statements from the
entrypoint, inlines every module in dependency order, and strips the `source` lines.

For the full picture β€” load order, the build pipeline, the tests that enforce all of it, and
how to add a file, a module or a subcommand β€” see
[ADR-011](https://github.com/TypedDevs/bashunit/blob/main/adrs/adr-011-source-layout-and-build-pipeline.md).

## Running tests

The project uses bashunit to test itself. To execute the full suite, run:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
183 changes: 183 additions & 0 deletions adrs/adr-011-source-layout-and-build-pipeline.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
# How `src/` is laid out and how the binary is built

* Status: accepted
* Deciders: Chemaclass
* Date: 2026-08-01

Technical Story: the layout settled across
[#924](https://github.com/TypedDevs/bashunit/issues/924),
[#925](https://github.com/TypedDevs/bashunit/issues/925),
[#931](https://github.com/TypedDevs/bashunit/issues/931),
[#940](https://github.com/TypedDevs/bashunit/issues/940),
[#948](https://github.com/TypedDevs/bashunit/issues/948) and
[#949](https://github.com/TypedDevs/bashunit/issues/949).

## Context and Problem Statement

ADR-010 decided *that* large files become module directories, and later amended *where* the
aggregator lives. What neither it nor any other document states is the whole picture: what the
modules are, in what order they load, how a single-file binary is assembled from them, and
which tests keep all of that honest.

That picture was spread across ADR-010, `build.sh` comments, `.claude/rules/architecture-map.md`
and eight issues. Someone arriving at the project β€” a contributor or an agent β€” had no single
place to read it.

This ADR is **descriptive, not a new decision.** It records the architecture as settled so it
can be understood from the outside without archaeology.

## The rule

**`src/` contains module directories and nothing else.** There are no loose `.sh` files.

A module is a directory whose entry point is `index.sh`:

```
src/<module>/index.sh <- the entry point: `source` lines and comments ONLY
src/<module>/<file>.sh <- one file per responsibility
```

`index.sh` means exactly one thing, everywhere: *a pure aggregator*. It never holds a
statement. That is load-bearing, not stylistic β€” see the build section β€” and it is why
`src/main.sh` was **not** renamed to `src/index.sh` when the question came up: a root
`index.sh` holding real code would make the name mean two things depending on depth.

## The modules

Seventeen, in the order the `bashunit` entrypoint sources them. The order is the dependency
layering: leaves first.

| # | Module | Files | Lines | Owns |
|---|---|---|---|---|
| 1 | `dev/` | 1 | 18 | debug helpers; **excluded from the build** |
| 2 | `system/` | 4 | 192 | capability probing: OS, `command -v`, small I/O |
| 3 | `util/` | 4 | 476 | computation: strings, arithmetic, time |
| 4 | `api/` | 5 | 207 | the surface a user's test file calls (except assertions) |
| 5 | `config/` | 4 | 964 | `BASHUNIT_*` defaults, scratch dirs, parallel mode, rerun cache |
| 6 | `coverage/` | 13 | 2644 | line/branch tracking and the four report formats |
| 7 | `state/` | 6 | 477 | counters, per-test context, result payload, parallel aggregation |
| 8 | `console/` | 9 | 1281 | everything printed: palette, header, per-test lines, totals |
| 9 | `helper/` | 8 | 976 | naming, discovery, data providers, tags, encoding |
| 10 | `cli/` | 5 | 447 | the `doc`/`init`/`upgrade`/`watch` subcommand implementations |
| 11 | `assert/` | 11 | 2336 | every assertion |
| 12 | `reports/` | 7 | 467 | JUnit, TAP, JSON, GHA and HTML writers |
| 13 | `runner/` | 11 | 2172 | the file loop, per-test execution, retry, result parsing |
| 14 | `benchmark/` | 4 | 221 | the bench implementation (`runner/bench.sh` is its loop) |
| 15 | `learn/` | 5 | 240 | the interactive tutorial |
| 16 | `main/` | 8 | 1475 | flag parsing per subcommand and the run lifecycle |

Namespaces track directories: `src/runner/` holds `bashunit::runner::*`. Two exceptions are
deliberate β€” `assert/` holds bare `assert_*` (the public API is unprefixed) and `console/`
holds `bashunit::console_results::*` in files named for what they do.

### Load order is not arbitrary

Most of it is ordinary dependency layering, but three points are genuinely load-bearing:

* **`api/globals.sh` runs `set -euo pipefail` at file scope.** Everything sourced after it
inherits strict mode. It must stay first inside `api/`, and `api/` must stay where it is.
* **`config/env.sh` executes at source time** β€” it loads `.bashunitrc` and `.env` and creates
the scratch dirs. It must come before `console/`, whose palette is built at file scope from
the values it resolves.
* **`main/` is sourced last**, after everything it dispatches to.

## How the binary is built

`build.sh` turns the module tree into one executable file. It is a **depth-first walk of
`source` statements, not a walk of directories** β€” so nesting depth and directory shape are
invisible to it.

```
1. build::dependencies read `^source ` lines from the `bashunit` entrypoint,
minus src/dev/ -> the embed list
2. build::process_file for each: emit `# <repo-relative path>`, then the file body
(shebang stripped), THEN recurse into its own `source` lines
dedupe on the repo-relative path
3. strip every `^source ` line from the result
4. build::embed_docs swap docs/assertions.md into a heredoc between the markers
in src/cli/doc.sh, so the binary needs no docs/ directory
5. build::assert_valid_syntax bash -n
6. build::verify (-v) run the whole suite against the built binary
```

**Step 2 is why an aggregator may hold only `source` lines.** A file's body is emitted *before*
its dependencies are recursed into, so a statement in an `index.sh` would run before the code
it depends on in the built binary, while running after it in dev mode β€” the two modes would
disagree.

The dedupe keys on the **repo-relative path**, never a basename: the top-level loop passes
relative paths and the recursion absolute ones, and two modules legitimately share a basename
(`config/parallel.sh` answers "is this run parallel"; `runner/parallel.sh` waits on job slots).

## The contracts that keep it honest

Every rule above is enforced by a test. This is the list to check before changing anything
structural.

| Contract | Test |
|---|---|
| Aggregators hold only `source` lines and comments | `build_test.sh` β€” globs `src/*/index.sh`, so it cannot drift |
| Every entrypoint-sourced file reaches the binary, and no others | `build_test.sh` β€” the #735 and bench-#0.31.0 regressions |
| Arbitrary nesting depth is embedded, in DFS order, with no `source` lines surviving | `build_test.sh` (#932) |
| Same basename in two modules does not collide | `build_test.sh` (#923) |
| `state/` never calls the renderer or `parallel` | `state_test.sh` β€” globs `src/state/*.sh` (#868, #862) |
| Shell completions match the flags `main/` actually parses | `completions_test.sh` |
| Every unprefixed env alias is declared and listed | `env_deprecated_aliases_test.sh` |
| No Bash 4+ syntax anywhere in `src/` | `bash_compatibility_test.sh` |

**These greps are the fragile part of the design.** A contract that greps a path passes
*vacuously* the moment that path moves β€” green, and checking nothing. It has happened: #946
moved `state.sh` and the layering contract kept passing while testing nothing. Prefer a glob
over a module to a path to a file, and after repointing one, **mutate the thing it guards and
watch it go red**.

## How to change things

**Add a function** β€” put it in the module file that owns the responsibility; nothing else to do.

**Add a file to a module** β€” create it, add one `source` line to that module's `index.sh`.
Its first line must be `#!/usr/bin/env bash` (`tail -n +2` strips it when embedding).

**Add a module** β€” create `src/<name>/index.sh` plus its files, and add one `source` line to
the `bashunit` entrypoint at the right point in the layering. Then check
`git check-ignore -v src/<name>/<file>.sh`: an unanchored `.gitignore` pattern once matched
`src/coverage/` and hid twelve files from git with no `??` in `git status` (#928).

**Add a subcommand** β€” implementation in `src/cli/`, flag parsing in `src/main/subcommands.sh`,
routing in the `bashunit` entrypoint, and entries in **both** completion scripts or
`completions_test.sh` fails.

**Split a file** β€” derive segment boundaries from the *next function's start*, never from a
`^}$` closing brace: a brace inside a heredoc or a `case` arm ends the segment early and
silently splits a function across files (#938). Prove the result is a relocation by comparing
the non-blank line multiset and the function count before and after, then diff the built
artifact β€” its code content should be identical.

## Consequences

**Good**

* `ls src/` names the architecture. Every entry is a concept, not a file.
* One convention for entry points at every depth.
* The build needs no per-module knowledge, so adding a module is one `source` line.
* Each file is small enough to read whole. The two largest are deliberate and were re-examined
more than once: `assert/core.sh` (970 lines) is one assertion catalogue whose size is
inherent to holding 42 assertions, and `main/test.sh` (489) is a single `case` over ~60
flags, which is the one shape that must not be cut across files. `config/env.sh` (754) is
likewise whole: 51 functions, but 33 of them are `is_*` predicates over one concern.

**Bad**

* More indirection when grepping: `bashunit::runner::*` spans eleven files.
* Return-slot globals cross file boundaries, so ShellCheck cannot see both ends. CI runs it
per file without `-x`, which surfaces cross-file symbol use a monolith hid β€” occasionally a
genuine dead local (#928), occasionally a needed `disable` comment.
* Every path-grepping contract is now one rename away from going vacuous. Mitigated by globbing
modules rather than naming files, and by mutation-testing after any repoint.

## Links

* [ADR-010](adr-010-src-module-directories.md) β€” the decision this describes the outcome of
* `.claude/rules/architecture-map.md` β€” the per-file map and the call flow of a run
* `.claude/rules/perf-fork-budget.md` β€” why per-test paths must stay fork-free
* `.claude/rules/bash-style.md` β€” the Bash 3.0 floor and the return-slot pattern
32 changes: 31 additions & 1 deletion docs/project-overview.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,13 +10,43 @@ This repository hosts the bashunit source code, its documentation and many autom

## Repository layout

- `src` – library functions used by `bashunit`.
- `src` – library functions used by `bashunit`, organised as modules (below).
- `bin` – the executable entry points.
- `adrs` – internal architecture decisions records.
- `example` – example scripts and tests demonstrating usage.
- `tests` – automated tests for bashunit itself.
- `docs` – documentation built with [VitePress](https://vitepress.dev/).

## Source modules

`src/` contains module directories and no loose files. Each module has an `index.sh` entry
point holding **only `source` lines** β€” the code lives in the sibling files beside it.

| Module | What it owns |
|---|---|
| `system` | capability probing: OS detection, `command -v`, small I/O helpers |
| `util` | computation: strings, arithmetic, time |
| `api` | the surface your test file calls β€” `temp_file`, `skip`/`todo`, custom-assert helpers |
| `config` | `BASHUNIT_*` defaults, scratch dirs, parallel mode, the rerun cache |
| `coverage` | line and branch tracking, and the coverage reports |
| `state` | counters, per-test context, the result payload |
| `console` | everything printed: palette, header, per-test lines, totals |
| `helper` | naming, test discovery, data providers, tags, encoding |
| `cli` | the `doc`, `init`, `upgrade` and `watch` subcommands |
| `assert` | every assertion |
| `reports` | JUnit, TAP, JSON, GitHub Actions and HTML writers |
| `runner` | the file loop, per-test execution, retry, result parsing |
| `benchmark` | the bench implementation |
| `learn` | the interactive tutorial |
| `main` | flag parsing per subcommand and the run lifecycle |

The released `bashunit` is a **single file**: `build.sh` walks the `source` statements from the
entrypoint, inlines every module in dependency order, and strips the `source` lines.

For the full picture β€” load order, the build pipeline, the tests that enforce all of it, and
how to add a file, a module or a subcommand β€” see
[ADR-011](https://github.com/TypedDevs/bashunit/blob/main/adrs/adr-011-source-layout-and-build-pipeline.md).

## Running tests

The project uses bashunit to test itself. To execute the full suite, run:
Expand Down
Loading