diff --git a/.agnostic-ai/AGNOSTIC_AI.md b/.agnostic-ai/AGNOSTIC_AI.md new file mode 100644 index 000000000..76e0e34f3 --- /dev/null +++ b/.agnostic-ai/AGNOSTIC_AI.md @@ -0,0 +1,46 @@ +# bashunit + +bashunit is a Bash 3.0+ testing framework with assertions, spies, mocks, data providers, and snapshots. Documentation: https://bashunit.com. + +## Required guidance + +All paths are relative to the repository root, including from generated skill and agent directories. Read the applicable source rules before editing. Directory scopes generate native instructions for that directory. Sessions started at the root must also read the relevant sources below; some clients do not load nested instructions during later edits. + +| Task | Read | +|------|------| +| Changes to `src/` or `bashunit` | `.agnostic-ai/rules/architecture-map.md` | +| Shell changes under `src/` or `tests/`, including entrypoint changes | `.agnostic-ai/rules/bash-style.md` | +| Changes to `src/`, `bashunit`, or fork census acceptance tests | `.agnostic-ai/rules/perf-fork-budget.md` | +| Runtime development or task plans | `.agnostic-ai/rules/tdd-workflow.md` | +| Test changes | `.agnostic-ai/rules/testing.md` | + +Bash 3.0 is the compatibility floor, including parse-time behavior. Use existing patterns. Study the relevant tests before adding assertions, doubles, providers, hooks, or CLI behavior. Do not invent commands or features. + +Use TDD for runtime changes: RED for the right reason, minimal GREEN, then REFACTOR with tests green. Complete the acceptance criteria. Use a task file for nontrivial work; the `tdd-cycle` skill carries the full workflow. Preserve public APIs unless the requested change includes documentation and CHANGELOG updates. Record architectural decisions in an ADR. + +## Quality gate + +Run once after the complete change set, before committing: + +```bash +./bashunit tests/ +./bashunit --parallel tests/ +make sa +make lint +``` + +`make lint` is the formatting authority. Match surrounding 2-space indentation by hand. Never run `shfmt -w`; it conflicts with EditorConfig. The `commit` skill bundles the detailed formatting constraints and optional drift comparison. + +## Delegation + +Handle small edits directly. Keep one acceptance scenario and a short plan in the main session. Delegate independent lookups to `code-locator`, bounded edits to `code-implementer`, and verified defect review to `code-reviewer` before pushing. Assign a question or file scope, a base revision, and a validation budget. Reuse findings. Keep at most two delegates active. + +Workers share the checkout: assign separate files, preserve others' edits, and run only assigned targeted checks. The coordinator runs the full gate. Return concise file and line evidence; save longer reports under ignored `.agnostic-ai/local/`. + +## Specs and delivery + +Edit `.agnostic-ai/` or `agnostic-ai.yaml`, never generated instructions, agents, skills, hooks, or settings. Install the pinned contributor dependencies with `npm ci` (use `npm ci --force` for a Windows source checkout because the published npm package restricts its OS). Run `npm run agents:sync` and `npm run agents:check`, which use the project-local CLI. + +Keep unrelated changes out of the PR. Update CHANGELOG for user-visible changes. Use the `pr` skill for pull requests. Use Conventional Commits: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, or `chore`, with an optional domain scope such as `assert`, `runner`, `cli`, or `docs`. + +Never commit secrets, skip Git hooks, amend published commits, or use destructive Git commands. Pushing requires user authorization; force-pushing main requires an explicit request. All local gates must pass before committing. diff --git a/.agnostic-ai/agents/code-implementer.md b/.agnostic-ai/agents/code-implementer.md new file mode 100644 index 000000000..390c2cae3 --- /dev/null +++ b/.agnostic-ai/agents/code-implementer.md @@ -0,0 +1,8 @@ +--- +name: code-implementer +description: Implement a bounded change in assigned files with Bash 3.0 compatibility and targeted validation. +model: balanced +can: [read, shell, edit, write] +--- + +You own only the files assigned by the main agent. Other workers share the checkout; preserve their edits and adapt to them. Read the applicable source rules listed in .agnostic-ai/AGNOSTIC_AI.md before editing. Follow the existing test patterns and the requested acceptance scenario. Use TDD for runtime changes. Keep hot paths within the fork budget. Run only the assigned targeted validation, never the full gate. Do not commit, push, publish, or install dependencies. Return changed files, the check result, and any unresolved limit. diff --git a/.agnostic-ai/agents/code-locator.md b/.agnostic-ai/agents/code-locator.md new file mode 100644 index 000000000..3e9d8f43c --- /dev/null +++ b/.agnostic-ai/agents/code-locator.md @@ -0,0 +1,8 @@ +--- +name: code-locator +description: Locate definitions, callers, and references before planning a nontrivial change. +model: fast +can: [read, shell] +--- + +Find code without editing it or proposing a redesign. Use rg, then read only enough surrounding code to verify each match. Return a file:line table and the searches used when nothing matches. Do not run tests, builds, installs, or Git mutations. diff --git a/.agnostic-ai/agents/code-reviewer.md b/.agnostic-ai/agents/code-reviewer.md new file mode 100644 index 000000000..7372fa315 --- /dev/null +++ b/.agnostic-ai/agents/code-reviewer.md @@ -0,0 +1,8 @@ +--- +name: code-reviewer +description: Review a completed diff for verified defects before a push or merge. +model: strong +can: [read, shell] +--- + +Review without editing files, committing, or posting to GitHub. Read the diff against the supplied base and the applicable source rules listed in .agnostic-ai/AGNOSTIC_AI.md. Trace changed behavior through callers and existing tests. Check Bash 3.0 parsing, dynamic scope, subshell state, parallel isolation, cleanup, and hot-path process costs when relevant. For configuration changes, check clean-clone generation, canonical references, model resolution, and preserved native behavior. Do not run the full quality gate. Return only verified findings as file:line, severity, concrete failing input, and the smallest fix. If none remain, say so and list the scope checked. diff --git a/.agnostic-ai/hooks/shellcheck-after-edit.yaml b/.agnostic-ai/hooks/shellcheck-after-edit.yaml new file mode 100644 index 000000000..45211dffa --- /dev/null +++ b/.agnostic-ai/hooks/shellcheck-after-edit.yaml @@ -0,0 +1,5 @@ +name: shellcheck-after-edit +description: Show ShellCheck diagnostics after editing shell files. +on: after-edit +command: 'bash "$(git rev-parse --show-toplevel)/.agnostic-ai/scripts/shellcheck-after-edit.sh"' +commandWindows: "bash ((git rev-parse --show-toplevel) + '/.agnostic-ai/scripts/shellcheck-after-edit.sh') codex; exit $LASTEXITCODE" diff --git a/.agnostic-ai/overlays/claude.settings.hook-events.json b/.agnostic-ai/overlays/claude.settings.hook-events.json new file mode 100644 index 000000000..5078d82b7 --- /dev/null +++ b/.agnostic-ai/overlays/claude.settings.hook-events.json @@ -0,0 +1,6 @@ +{ + "after": "includeCoAuthoredBy", + "events": [ + "PostToolUse" + ] +} diff --git a/.agnostic-ai/overlays/claude.settings.json b/.agnostic-ai/overlays/claude.settings.json new file mode 100644 index 000000000..4fa8942f2 --- /dev/null +++ b/.agnostic-ai/overlays/claude.settings.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "includeCoAuthoredBy": false +} diff --git a/.agnostic-ai/overlays/codex.config.toml b/.agnostic-ai/overlays/codex.config.toml new file mode 100644 index 000000000..b345ee4f5 --- /dev/null +++ b/.agnostic-ai/overlays/codex.config.toml @@ -0,0 +1,2 @@ +[agents] +max_concurrent_threads_per_session = 2 diff --git a/.claude/rules/architecture-map.md b/.agnostic-ai/rules/architecture-map.md similarity index 84% rename from .claude/rules/architecture-map.md rename to .agnostic-ai/rules/architecture-map.md index cb05c1290..cb6e80175 100644 --- a/.claude/rules/architecture-map.md +++ b/.agnostic-ai/rules/architecture-map.md @@ -1,12 +1,10 @@ --- -paths: - - "src/**/*.sh" - - "bashunit" +scope: src --- # Architecture Map: modules and the life of a run -Orientation for working in `src/` — which file owns what, and the exact call +Orientation for working in `src/`, which file owns what, and the exact call flow of a test run. Line numbers drift; function names are the stable anchors. ## The life of `./bashunit tests/` @@ -15,7 +13,7 @@ flow of a test run. Line numbers drift; function names are the stable anchors. bashunit (entry) sources all src/*.sh; version gate; early flag scan └─ bashunit::main::cmd_test (main.sh: flag parsing, env exports) └─ bashunit::runner::load_test_files (runner/discovery.sh: the per-file loop) - ├─ console_header::print_header "Running N tests" — captures + ├─ console_header::print_header "Running N tests", captures │ └─ helper::find_total_tests $() SUBSHELL: sources each file in a │ nested subshell just to count tests ├─ source "$test_file" in the MAIN shell (workers inherit) @@ -48,12 +46,12 @@ shell (or, in parallel, in per-test `.result` files aggregated at the end). | Module | Owns | |--------|------| | `bashunit` | entry: version gate, early flag scan, sources every module, subcommand routing | -| `main/index.sh` | aggregator only — sources the `src/main/` module below | +| `main/index.sh` | aggregator only, sources the `src/main/` module below | | `main/validate.sh` | shared parser validation: unknown options, integer/path checks, shard parsing | | `main/test.sh` / `main/bench.sh` / `main/subcommands.sh` | flag parsing per subcommand | | `main/assert.sh` | the standalone `bashunit assert` path | | `main/watch.sh` / `main/run.sh` | the `--watch` loop; the run lifecycle, exit codes and cleanup | -| `runner/index.sh` | aggregator only — sources the `src/runner/` module below | +| `runner/index.sh` | aggregator only, sources the `src/runner/` module below | | `runner/context.sh` | workdir restore, test identity/location exports, title interpolation, capability probes | | `runner/payload.sh` | the `_BASHUNIT_RUNNER_*_OUT` return slots; encode/decode of the per-test result payload | | `runner/diagnostics.sh` | runtime-error detection, kill-signal classification, profiling, verbose/file headers | @@ -65,22 +63,22 @@ shell (or, in parallel, in per-test `.result` files aggregated at the end). | `runner/list.sh` | `--list`/`--dry-run`: emits selected tests instead of running them; hooks in after all filtering, before any hook | | `runner/discovery.sh` | `load_test_files` (the per-file loop), `functions_for_script` | | `runner/bench.sh` | benchmark file loop and bench function dispatch | -| `helper/index.sh` | aggregator only — sources the `src/helper/` module below | +| `helper/index.sh` | aggregator only, sources the `src/helper/` module below | | `helper/naming.sh` | resolving and normalising test-function and variable names | | `helper/discovery.sh` | `find_files_recursive`, `find_total_tests`, fn filtering, duplicate check, line lookup | | `helper/provider.sh` / `helper/tags.sh` | `@data_provider` map; `@tag` extraction and matching | | `helper/encoding.sh` / `helper/functions.sh` / `helper/git.sh` | base64 + ids; generic fn utilities; remote tag lookup for `upgrade` | -| `state/index.sh` | aggregator only — sources the `src/state/` module below | +| `state/index.sh` | aggregator only, sources the `src/state/` module below | | `state/counters.sh` | test and assertion tallies and their get/add accessors | | `state/duplicates.sh` | duplicate test-function detection state | | `state/context.sh` | per-test output buffer, exit code, title, hook failure, and the per-test reset | | `state/payload.sh` | the encoded per-test result payload and the base64 capability probe | | `state/parallel.sh` | aggregating per-test result files after a `--parallel` run | -| `config/index.sh` | aggregator only — sources the `src/config/` module below | +| `config/index.sh` | aggregator only, sources the `src/config/` module below | | `config/env.sh` | all `BASHUNIT_*` defaults/config files, scratch dirs (`_BASHUNIT_RUN_OUTPUT_DIR` + EXIT-trap cleanup); executes at source time | | `config/rerun.sh` | the `.bashunit/last-failed` cache for `--rerun-failed` | | `config/parallel.sh` | is this run parallel; worker temp tree and stop-on-failure flag file (not `runner/parallel.sh`, which waits on job slots) | -| `console/index.sh` | aggregator only — sources the `src/console/` module below | +| `console/index.sh` | aggregator only, sources the `src/console/` module below | | `console/colors.sh` | the `_BASHUNIT_COLOR_*` palette and `bashunit::sgr` | | `console/header.sh` | the "Running N tests" header | | `console/line.sh` | `print_line`, the primitive every result line goes through, and its TAP variant | @@ -88,62 +86,62 @@ shell (or, in parallel, in per-test `.result` files aggregated at the end). | `console/test_line.sh` | the per-test result lines (passed/failed/skipped/incomplete/snapshot/risky/error) | | `console/deferred.sh` | end-of-run blocks buffered during the run (scratch files under the run dir) | | `console/summary.sh` | run totals, execution time, hook completion | -| `assert/index.sh` | aggregator only — sources the `src/assert/` module below, plus `skip_todo.sh` and `test_doubles.sh` | +| `assert/index.sh` | aggregator only, sources the `src/assert/` module below, plus `skip_todo.sh` and `test_doubles.sh` | | `assert/core.sh` | `assert::should_skip`, `assert::fail_with`, `assert::join_to_slot` and the comparison assertions the other files build on | | `assert/{arrays,assertions,dates,duration,files,folders,json,once,snapshot}.sh` | the per-topic assertions; the per-assertion path must stay fork-free | -| `util/index.sh` | aggregator only — sources the `src/util/` module below | +| `util/index.sh` | aggregator only, sources the `src/util/` module below | | `util/clock.sh` | time impl selection (EPOCHREALTIME > date > perl > …), return-slot reads | | `util/str.sh` / `util/math.sh` | pure-bash string and arithmetic helpers | -| `api/index.sh` | aggregator only — sources the `src/api/` module below | -| `api/globals.sh` | `temp_file`/`temp_dir`/`current_dir`/`data_set`; repeats `set -euo pipefail` at file scope for anything sourcing it alone — the entrypoint already set it | +| `api/index.sh` | aggregator only, sources the `src/api/` module below | +| `api/globals.sh` | `temp_file`/`temp_dir`/`current_dir`/`data_set`; repeats `set -euo pipefail` at file scope for anything sourcing it alone, the entrypoint already set it | | `api/skip_todo.sh` / `api/test_title.sh` | `skip`/`todo` markers; `set_test_title` | | `api/bashunit.sh` | the custom-assert facade (`assert_that`, `assertion_failed`/`passed`) | -| `doubles/index.sh` | aggregator only — sources the `src/doubles/` module below | +| `doubles/index.sh` | aggregator only, sources the `src/doubles/` module below | | `doubles/mock.sh` | `mock`/`unmock` and the `_BASHUNIT_MOCKED_FUNCTIONS` registry `runner/hooks.sh` unwinds per test | | `doubles/spy.sh` | `spy` and its `_BASHUNIT_SPY_*` state slots + files | | `doubles/assertions.sh` | the `assert_have_been_called*` family | -| `coverage/index.sh` | aggregator only — sources the `src/coverage/` modules below | +| `coverage/index.sh` | aggregator only, sources the `src/coverage/` modules below | | `coverage/config.sh` | data-file locations, tracked-file roots, engine selection (`init` resets state owned by several modules) | | `coverage/paths.sh` | `normalize_path`, `should_track` and the hot-path track/path caches | | `coverage/engine.sh` | DEBUG-trap and xtrace capture, buffering, `finalize`/`cleanup`, parallel merge; only active under `--coverage` | | `coverage/lines.sh` | executable-line classification (fork-free; the report phase's hot path) and hit-data reading | | `coverage/stats.sh` | percentages, the precomputed per-file stats cache, threshold gate | | `coverage/functions.sh` | function definitions and their line spans | -| `coverage/branches.sh` | branch extraction + hit computation; **one file on purpose** — the `_branch_*` helpers mutate `extract_branches`'s locals via dynamic scoping | +| `coverage/branches.sh` | branch extraction + hit computation; **one file on purpose**, the `_branch_*` helpers mutate `extract_branches`'s locals via dynamic scoping | | `coverage/report_text.sh` / `report_lcov.sh` / `report_html.sh` | the three renderers | | `coverage/html_index.sh` / `html_file.sh` | HTML page emitters; the only two files exempt from `max_line_length` | | `reports.sh` | JUnit/HTML/TAP/JSON writers | -| `system/index.sh` | aggregator only — sources the `src/system/` module below | +| `system/index.sh` | aggregator only, sources the `src/system/` module below | | `system/check_os.sh` / `system/dependencies.sh` | one-fork OS detect; `command -v` probes (builtins, not forks) | | `system/io.sh` | small I/O helpers | -| `cli/index.sh` | aggregator only — sources the `src/cli/` module below | +| `cli/index.sh` | aggregator only, sources the `src/cli/` module below | | `cli/{upgrade,watch,doc,init}.sh` | the non-`test` subcommand implementations; `main.sh` is their only caller | | `benchmark/index.sh` | the remaining non-`test` subcommand; shared with `runner/bench.sh` | ## Cross-cutting invariants -- **Bash 3.0 floor** (`.claude/rules/bash-style.md`): no `[[`, `declare -A`, - `${var,,}`, `BASHPID`, negative indices. Subshells share `$$` — you cannot +- **Bash 3.0 floor** (`.agnostic-ai/rules/bash-style.md`): no `[[`, `declare -A`, + `${var,,}`, `BASHPID`, negative indices. Subshells share `$$`, you cannot make a per-worker unique token without a fork (`mktemp`) or an ordinal assigned before the fork. `RANDOM` is no help either: whether a subshell reseeds it depends on the platform and the nesting depth (measured in perf-fork-budget.md), so it is unreliable in both directions (#1354). - **Return-slot pattern** (`_BASHUNIT___OUT` globals) instead of `$()` - captures on hot paths — bash-style.md documents it; `local` is dynamically + captures on hot paths, bash-style.md documents it; `local` is dynamically scoped, so helpers must not write caller-named variables. -- **Fork budget** (`.claude/rules/perf-fork-budget.md`): per-test paths are +- **Fork budget** (`.agnostic-ai/rules/perf-fork-budget.md`): per-test paths are fork-free (sequential) or mktemp-only (parallel); budgets are enforced by - `tests/acceptance/bashunit_*forks*_test.sh` on three platforms — a "harmless" + `tests/acceptance/bashunit_*forks*_test.sh` on three platforms, a "harmless" `echo | sed` in a per-test path will fail CI. - **`call_test_functions` is a background subshell under `--parallel`** - (`discovery.sh` backgrounds it per file). Anything it sets in shell state — - counters, flags, error records — dies with the worker. Only two things cross + (`discovery.sh` backgrounds it per file). Anything it sets in shell state, + counters, flags, error records, dies with the worker. Only two things cross back: per-test `.result` payloads, and files written to disk. This produced two silent-green bugs in one day: a data-provider error printed while the run exited 0 (#1145), and duplicate-test-function detection being entirely off in the mode CI uses (#1147). Before adding a check anywhere under that call, ask - which side of the fork it belongs on — a whole-file check almost always + which side of the fork it belongs on, a whole-file check almost always belongs in the parent loop, before dispatch. **Verify both modes.** Sequential and `--parallel` disagreeing is the signature of this bug, and it is invisible to a sequential-only test. @@ -155,7 +153,7 @@ shell (or, in parallel, in per-test `.result` files aggregated at the end). `build::process_file`): a file's body is emitted, *then* its `source` lines are recursed into. That equals dev-mode order only if a module aggregator (`src//index.sh`, ADR-010) contains **nothing but `source` - lines and comments** — any other top-level statement would run before its + lines and comments**, any other top-level statement would run before its dependencies in the built artifact but after them in dev mode. Files are deduped by repo-relative path, so `src/` may hold module dirs and two files may share a basename. Every src file's **first line must be the shebang**: diff --git a/.claude/rules/bash-style.md b/.agnostic-ai/rules/bash-style.md similarity index 91% rename from .claude/rules/bash-style.md rename to .agnostic-ai/rules/bash-style.md index ffa88305d..307f94f3c 100644 --- a/.claude/rules/bash-style.md +++ b/.agnostic-ai/rules/bash-style.md @@ -1,7 +1,7 @@ --- +scope: src paths: - - "src/**/*.sh" - - "tests/**/*.sh" + - "tests/**" --- # Bash Style & Compatibility Rules @@ -29,14 +29,14 @@ enforced mechanically by `tests/unit/bash_compatibility_test.sh`, which greps | `BASHPID` | 4.0+ | fork (`mktemp`) or an assigned ordinal | | `mapfile` / `readarray` | 4.0+ | `while IFS= read -r` | | `declare -n` / `local -n` (nameref) | 4.3+ | Return-slot pattern (below) | -| `coproc` | 4.0+ | — | -| `${var@Q}` (transformations) | 4.4+ | — | +| `coproc` | 4.0+ |, | +| `${var@Q}` (transformations) | 4.4+ |, | `[[ =~ ]]` is a subtler case: it **exists** on 3.0 (verified against a real build), but 3.2 changed whether a quoted right-hand side is a regex or a literal. At a 3.0 floor the same match therefore behaves differently across -supported versions, so regex matching goes through `grep -E` — a real -constraint, not a style preference. See `.claude/rules/perf-fork-budget.md`. +supported versions, so regex matching goes through `grep -E`, a real +constraint, not a style preference. See `.agnostic-ai/rules/perf-fork-budget.md`. ### Style, not compatibility @@ -46,7 +46,7 @@ Bash 3 requirement, and do not add it to the compatibility gate. ## Coding Conventions -- **2 spaces** indent, no tabs — enforced by `make lint` (EditorConfig). Do **not** +- **2 spaces** indent, no tabs, enforced by `make lint` (EditorConfig). Do **not** run `shfmt -w`: it wants tabs for `tests/acceptance/**` and collapses `\`-continued strings past the 120-char limit, breaking `make lint` - **120 chars** max line length (soft) @@ -82,14 +82,14 @@ Source deps relative to script: `"$(dirname "${BASH_SOURCE[0]}")/dep.sh"` Bash `local` is **dynamically scoped**, not lexically scoped. A `local` inside a callee shadows the caller's same-named variable for the duration of the call. Same trap fires with `${!name}` reads and `eval "$name=..."`/`printf -v "$name"`/`export "$name"=...` -writes — they all resolve against the dynamic scope. +writes, they all resolve against the dynamic scope. We need **all three** properties on the hot path: 1. **No subshell fork.** Returning via stdout and capturing with `$(...)` costs a fork per call, which dominates per-test cost. 2. **No collision with caller locals.** A helper that silently overwrites or reads - the wrong variable is the worst kind of bug — no error, just stale data. + the wrong variable is the worst kind of bug, no error, just stale data. 3. **Bash 3.0+ portable.** Rules out `declare -n` (4.3+) and `printf -v` (3.1+, and it has the same dynamic-scope shadowing as `eval "$name=..."` anyway). @@ -152,7 +152,7 @@ function bashunit::pkg::do_thing() { ``` - All internal locals MUST be `__bu_`-prefixed; otherwise dynamic-scope shadowing - silently breaks the caller's outvar (see PR #672 — that exact bug caused 12 parallel + silently breaks the caller's outvar (see PR #672, that exact bug caused 12 parallel test failures in #662). - Include a regression test that calls the helper with one of its own documented internal names as the outvar. @@ -161,7 +161,7 @@ function bashunit::pkg::do_thing() { The coverage branch helpers in `src/coverage/branches.sh` (`_branch_push_if` and friends) deliberately mutate caller locals (`if_decision_line`, `if_arms`, `if_depth`, `if_arm_start`). -That is documented inline above the helpers in that file and is **not** the outvar pattern — +That is documented inline above the helpers in that file and is **not** the outvar pattern, the helper has no `$1`-named outvar argument; the caller agrees to share state by convention. It is also why those helpers and `extract_branches` must stay in one file (ADR-010). Don't introduce new instances of this pattern without an inline justification comment. diff --git a/.agnostic-ai/rules/perf-fork-budget.md b/.agnostic-ai/rules/perf-fork-budget.md new file mode 100644 index 000000000..eb24bc339 --- /dev/null +++ b/.agnostic-ai/rules/perf-fork-budget.md @@ -0,0 +1,16 @@ +--- +scope: src +paths: + - "tests/acceptance/**" +--- + +# Process budgets + +Keep startup, per-file, and per-test process costs within the existing fork census budgets. Read and use {{$SKILL:fork-census}} before changing those paths, parallel workers, report serialization, or coverage classification. + +- Measure the path that runs, including flags and lifecycle hooks. A sequential fixture cannot detect per-worker costs; parallel changes need a parallel census. +- Count binary invocations with PATH shims where reliable. Pinned binaries bypass shims, and shell-function command substitutions fork without executing a binary. Raw `bash -x` totals overcount captured output. +- Benchmark before replacing an external tool with pure Bash. Large shell loops and string substitutions can be slower, and behavior varies across supported Bash versions. Keep regex assertions and HTML escaping semantics portable. +- Preserve fork-free per-test hooks, worker result naming by dispatcher ordinal, and the shared clock implementation. Do not use inherited `$$` or platform-dependent `RANDOM` behavior as unique worker identifiers. +- Never pass process substitutions as external-command arguments in per-file or per-test loops. Compare real files; redirecting process substitution into stdin is a different case. +- Extend the matching cold-start, run, or coverage fork regression when removing a fork class. Prove comparison harnesses can fail before relying on them, and repeat noisy or anomalous measurements. diff --git a/.agnostic-ai/rules/tdd-workflow.md b/.agnostic-ai/rules/tdd-workflow.md new file mode 100644 index 000000000..fe4cf71bb --- /dev/null +++ b/.agnostic-ai/rules/tdd-workflow.md @@ -0,0 +1,7 @@ +--- +alwaysApply: true +--- + +# TDD + +Use test-driven development for runtime changes: prove the smallest test fails for the right reason, make it pass with minimal code, then refactor while keeping tests green. Use {{$SKILL:tdd-cycle}} for the full workflow and task-file template. Run the complete quality gate once after the change set is complete, before committing. diff --git a/.claude/rules/testing.md b/.agnostic-ai/rules/testing.md similarity index 89% rename from .claude/rules/testing.md rename to .agnostic-ai/rules/testing.md index d8a6aeb18..31413fee2 100644 --- a/.claude/rules/testing.md +++ b/.agnostic-ai/rules/testing.md @@ -1,6 +1,5 @@ --- -paths: - - "tests/**/*_test.sh" +scope: tests --- # Testing Guidelines @@ -63,7 +62,7 @@ bashunit::mock date echo "2024-05-01" bashunit::mock uname <<< "Linux" # heredoc form ignores the call's arguments ``` -Note `_times` takes the count first while `_with` takes the spy first — a swapped pair +Note `_times` takes the count first while `_with` takes the spy first, a swapped pair fails the assertion rather than erroring, so it reads like a real defect. ## Data Providers @@ -82,10 +81,10 @@ function test_should_add() { ## Lifecycle Hooks -- `set_up_before_script()` — once before all tests in file -- `set_up()` — before each test -- `tear_down()` — after each test -- `tear_down_after_script()` — once after all tests in file +- `set_up_before_script()`, once before all tests in file +- `set_up()`, before each test +- `tear_down()`, after each test +- `tear_down_after_script()`, once after all tests in file ## Snapshot Testing @@ -114,16 +113,16 @@ function this file never defines. Give every embedded fixture function a name unique across the file. The report includes the line of each definition (`test_x (lines 30, 40)`), which is what identifies the strings. -It cannot be fixed by tracking quote state — an apostrophe in any comment +It cannot be fixed by tracking quote state, an apostrophe in any comment (`# doesn't work`) would flip it and mask real duplicates. ## Test Isolation - Use `$(bashunit::temp_file)` / `$(bashunit::temp_dir)` (auto-cleaned) for file - operations — these are functions, not variables + operations, these are functions, not variables - No shared global state between tests -- No network calls — mock external commands -- No time dependencies — mock `date` if needed +- No network calls, mock external commands +- No time dependencies, mock `date` if needed - Tests must be safe for `./bashunit --parallel tests/` ## Reference Tests diff --git a/.agnostic-ai/scripts/shellcheck-after-edit.sh b/.agnostic-ai/scripts/shellcheck-after-edit.sh new file mode 100644 index 000000000..c1e0d622d --- /dev/null +++ b/.agnostic-ai/scripts/shellcheck-after-edit.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -eu + +if [ -n "${1:-}" ]; then + export AGNOSTIC_AI_TARGET="$1" +fi + +project_root=$(git rev-parse --show-toplevel) +files=$("$project_root/node_modules/.bin/agnostic-ai" hook paths) || exit 1 +printf '%s\n' "$files" | while IFS= read -r file; do + case "$file" in + *.sh) + if [ -f "$file" ]; then + shellcheck -x "$file" 2>&1 | head -20 + fi + ;; + esac +done diff --git a/.agnostic-ai/settings/models.yaml b/.agnostic-ai/settings/models.yaml new file mode 100644 index 000000000..c0dc68256 --- /dev/null +++ b/.agnostic-ai/settings/models.yaml @@ -0,0 +1 @@ +model: balanced diff --git a/.claude/skills/add-assertion/SKILL.md b/.agnostic-ai/skills/add-assertion/SKILL.md similarity index 74% rename from .claude/skills/add-assertion/SKILL.md rename to .agnostic-ai/skills/add-assertion/SKILL.md index 810a9bf44..a48c0cc3c 100644 --- a/.claude/skills/add-assertion/SKILL.md +++ b/.agnostic-ai/skills/add-assertion/SKILL.md @@ -1,7 +1,7 @@ --- name: add-assertion description: Add new assertion function with comprehensive tests following TDD -allowed-tools: Read, Edit, Write, Bash, Grep, Glob +allowed-tools: read, edit, write, shell, Grep, Glob --- # Add Assertion Skill @@ -10,8 +10,8 @@ Add a new assertion function to bashunit following strict TDD methodology. ## Prerequisites -1. **Task file must exist** — create `.tasks/YYYY-MM-DD-add-assertion-name.md` -2. **Study patterns** — read `tests/unit/assert_test.sh` and `src/assertions.sh` +1. **Task file must exist**, create `.tasks/YYYY-MM-DD-add-assertion-name.md` +2. **Study patterns**, read `tests/unit/assert_test.sh` and `src/assertions.sh` ## Workflow @@ -32,10 +32,10 @@ Read `src/assertions.sh` and `tests/unit/assert_test.sh` to understand: For each test in inventory, follow RED -> GREEN -> REFACTOR: -1. **Basic success case** — assertion passes with valid input -2. **Failure case** — assertion fails correctly: capture its output and compare it with +1. **Basic success case**, assertion passes with valid input +2. **Failure case**, assertion fails correctly: capture its output and compare it with `bashunit::console_results::print_failed_test` (there is no `assert_fails`) -3. **Edge cases** — empty input, special characters, nested structures, malformed data +3. **Edge cases**, empty input, special characters, nested structures, malformed data ### 4. Integration diff --git a/.claude/skills/check-coverage/SKILL.md b/.agnostic-ai/skills/check-coverage/SKILL.md similarity index 85% rename from .claude/skills/check-coverage/SKILL.md rename to .agnostic-ai/skills/check-coverage/SKILL.md index f6096926a..d2be38c29 100644 --- a/.claude/skills/check-coverage/SKILL.md +++ b/.agnostic-ai/skills/check-coverage/SKILL.md @@ -1,7 +1,7 @@ --- name: check-coverage description: Analyze test coverage and identify untested code paths -allowed-tools: Read, Bash, Grep, Glob +allowed-tools: read, shell, Grep, Glob --- # Check Coverage @@ -30,9 +30,9 @@ grep -r "function_name" tests/ ### 3. Categorize Coverage -- **Well tested** — multiple tests, edge cases covered -- **Partially tested** — basic test exists, missing edge cases -- **Not tested** — no tests found +- **Well tested**, multiple tests, edge cases covered +- **Partially tested**, basic test exists, missing edge cases +- **Not tested**, no tests found ### 4. Identify Critical Gaps diff --git a/.agnostic-ai/skills/commit/SKILL.md b/.agnostic-ai/skills/commit/SKILL.md new file mode 100644 index 000000000..cbc74a322 --- /dev/null +++ b/.agnostic-ai/skills/commit/SKILL.md @@ -0,0 +1,95 @@ +--- +name: commit +description: Stage and commit changes using conventional commits format +user-invocable: true +argument-hint: "[message hint]" +disable-model-invocation: true +allowed-tools: shell, read, Grep, Glob +--- + +# Commit with Conventional Commits + +Read [the formatting constraints](references/formatting.md) before formatting or checking shell changes. Repository paths are relative to the repository root. + +Stage and commit current changes using the conventional commits format. + +> **IMPORTANT:** This skill MUST be used for ALL commits, even when the user says "commit" without `commit`. Never commit without following these steps. + +## Current State + +Run the following commands before proceeding: +- Branch: `git branch --show-current` +- Status: `git status --short` +- Staged diff: `git diff --cached --stat 2>/dev/null` +- Unstaged diff: `git diff --stat 2>/dev/null` +- Recent commits: `git log --oneline -5 2>/dev/null` + +## Arguments +- User-supplied argument: Optional hint for the commit message (e.g., `fix the snapshot comparison`) + +## Instructions + +1. **Review the state above**, understand what changed and why. + +2. **Stage files**, add only the relevant changed files by name. Never use `git add -A` or `git add .`. Never stage files that contain secrets (`.env`, credentials, etc.). + +3. **Determine the commit type** from the nature of the changes: + - `feat`, new feature or capability + - `fix`, bug fix + - `docs`, documentation only + - `style`, formatting, whitespace (no logic change) + - `refactor`, code restructuring (no behavior change) + - `test`, adding or updating tests + - `chore`, maintenance, tooling, config + - `perf`, performance improvement + +4. **Determine the scope** from the area of the codebase affected: + - `assert`, assertions (`src/assert*.sh`) + - `runner`, test runner + - `cli`, CLI entry point, flags, options + - `doubles`, mocks, spies + - `docs`, documentation site + - `ci`, CI/CD, GitHub Actions + - Use the most specific scope that fits. Omit if changes span many areas. + +5. **Write the commit message**: + - Format: `(): ` + - Description: imperative mood, lowercase, no period, under 70 chars + - Focus on **why**, not what + - Add a body (separated by blank line) only if the why isn't obvious from the description + - Never mention AI, Claude, or automation in the message + +6. **Create the commit**: + ```bash + git commit -m "$(cat <<'EOF' + (): + + + EOF + )" + ``` + +7. **Verify** the commit was created: + ```bash + git log --oneline -1 + ``` + +## Examples + +``` +feat(assert): add assert_json_contains function +fix(runner): resolve parallel execution race condition +test(doubles): add spy verification edge cases +refactor(cli): extract option parsing into helper +docs: update installation instructions +chore(ci): upgrade shellcheck to v0.10 +perf(runner): reduce subshell usage in test discovery +``` + +## Rules + +- **One logical change per commit**, don't mix unrelated changes +- **Never use `--no-verify`**, if hooks fail, fix the issue +- **Never amend** unless the user explicitly asks +- **Always create a NEW commit**, even after a hook failure +- **Author**: use the git config identity (never commit as sandbox/default user) diff --git a/.agnostic-ai/skills/commit/references/formatting.md b/.agnostic-ai/skills/commit/references/formatting.md new file mode 100644 index 000000000..ecfb557e2 --- /dev/null +++ b/.agnostic-ai/skills/commit/references/formatting.md @@ -0,0 +1,20 @@ +# Formatting + +Match the surrounding 2-space style by hand and verify with `make lint`. There is +no `shfmt` target, and **`shfmt -w .` must not be run**, it fights `.editorconfig` +two ways: + +- `.editorconfig` sets `indent_size = unset` for `tests/acceptance/**.sh` and + `src/console_header.sh`; shfmt reads `unset` as "use my default", which is tabs, + while `[*]` sets `indent_style = space`. Any new file under `tests/acceptance/` + therefore shows up shfmt-dirty, that is expected, not a defect. +- shfmt collapses `\`-continued strings onto one line, which would push + `src/state.sh`'s record writer past `max_line_length = 120`. + +To check you introduced no new drift, compare the dirty-file *list* against main +rather than requiring it to be empty: + +```bash +git ls-files -z '*.sh' bashunit | xargs -0 shfmt -d 2>/dev/null \ + | grep '^--- ' | sed 's|--- ||; s|\.orig.*||' | sort +``` diff --git a/.claude/skills/fix-test/SKILL.md b/.agnostic-ai/skills/fix-test/SKILL.md similarity index 74% rename from .claude/skills/fix-test/SKILL.md rename to .agnostic-ai/skills/fix-test/SKILL.md index 653967951..ee922a1c0 100644 --- a/.claude/skills/fix-test/SKILL.md +++ b/.agnostic-ai/skills/fix-test/SKILL.md @@ -1,7 +1,7 @@ --- name: fix-test description: Debug and fix failing tests systematically -allowed-tools: Read, Edit, Bash, Grep, Glob +allowed-tools: read, edit, shell, Grep, Glob --- # Fix Test @@ -20,11 +20,11 @@ Parse: which files, which functions, error messages. ### 2. Categorize Each Failure -- **Test bug** — test itself is wrong (wrong expected value, bad setup) -- **Implementation bug** — code doesn't match expected behavior -- **Environment issue** — missing dependency, wrong fixture path -- **Race condition** — passes sequential, fails parallel -- **Flaky test** — network/time/random dependency +- **Test bug**, test itself is wrong (wrong expected value, bad setup) +- **Implementation bug**, code doesn't match expected behavior +- **Environment issue**, missing dependency, wrong fixture path +- **Race condition**, passes sequential, fails parallel +- **Flaky test**, network/time/random dependency ### 3. Fix diff --git a/.agnostic-ai/skills/fork-census/SKILL.md b/.agnostic-ai/skills/fork-census/SKILL.md new file mode 100644 index 000000000..72e349fab --- /dev/null +++ b/.agnostic-ai/skills/fork-census/SKILL.md @@ -0,0 +1,15 @@ +--- +name: fork-census +description: Measure process costs and fork budgets when changing startup, per-file, per-test, parallel workers, reporting, or coverage paths; use for fork census and performance comparisons. +allowed-tools: read, shell +--- + +# Fork census + +Read [the fork-budget reference](references/fork-budget.md) before changing process costs. Its historical timings are evidence of earlier measurements, not current baselines. All repository paths below are relative to the repository root. + +1. Identify the actual path: cold start, sequential run, parallel worker, lifecycle hook, report, or coverage. Read the matching existing fixture in `tests/acceptance/bashunit_coldstart_forks_test.sh`, `tests/acceptance/bashunit_run_forks_test.sh`, or `tests/acceptance/bashunit_coverage_forks_test.sh`. Include the options and hooks the change exercises. +2. For a timing claim, measure current main before editing and record its commit, Bash version, OS, fixture, options, and sampling method. Land any new benchmark harness before the PR that measures a delta. Alternate baseline and candidate rounds in the same location and repeat anomalous readings. +3. Use the reference's PATH-shim census for binary invocations. Resolve real binaries before adding shims; startup-pinned binaries bypass them. Account separately for shell-function subshells. Use traces to locate sites, not raw trace-line totals to count forks. Skip unreliable PATH-shim checks on Git Bash. +4. Add a failing budget regression using the existing fixture patterns, then make the smallest change. Measure parallel costs with a parallel fixture and compare supported Bash versions. Never replace a fork with pure Bash based only on a microbenchmark. +5. Run the matching acceptance file with `./bashunit --no-parallel tests/acceptance/`. These tests select their own internal sequential or parallel census. Preserve the checked budgets; report counts, timing samples, and limits. The main session runs the full quality gate after the complete change set. diff --git a/.claude/rules/perf-fork-budget.md b/.agnostic-ai/skills/fork-census/references/fork-budget.md similarity index 88% rename from .claude/rules/perf-fork-budget.md rename to .agnostic-ai/skills/fork-census/references/fork-budget.md index 559abc961..f6c81d2b1 100644 --- a/.claude/rules/perf-fork-budget.md +++ b/.agnostic-ai/skills/fork-census/references/fork-budget.md @@ -1,9 +1,3 @@ ---- -paths: - - "src/**/*.sh" - - "tests/acceptance/bashunit_*forks*_test.sh" ---- - # Performance: Fork Budget & Census Method bashunit's dominant runtime cost on Bash 3.2 (macOS) is **process forks**, not @@ -27,18 +21,18 @@ grep -c . /tmp/count ``` Caveats: binaries pinned at startup via `command -v` (`$GREP`, `$MKTEMP`, -`$CAT`) bypass PATH shims — trace those instead; and shims are unreliable on +`$CAT`) bypass PATH shims, trace those instead; and shims are unreliable on Git Bash (skip such tests on Windows). **A shim census only sees forks that exec a binary.** `$(some_shell_function)` forks a subshell and execs nothing, so it is invisible to every shim and to the -`^\++ +/path/to/binary` trace patterns above — yet it costs the same ~0.6ms. +`^\++ +/path/to/binary` trace patterns above, yet it costs the same ~0.6ms. That blind spot hid sixteen of them in the colour palette (`$(bashunit::sgr N)` per colour, ~9ms, the largest single cost in a cold start) straight through the #801-#851 campaign that pinned everything else on this page. To count them, match the *function name* in the trace (`^\++ +bashunit::fn( |$)`), or profile by injecting `$EPOCHREALTIME` echoes at the `# src/` markers the build -emits — that attributes startup cost per source file and is how this one +emits, that attributes startup cost per source file and is how this one surfaced. **`bash -x` trace census (cheap but inflated).** `PS4='+ ' bash -x ./bashunit …` @@ -71,17 +65,17 @@ fork class; they are the RED test of the TDD cycle. | `printf \| grep -cE` per line as a classifier fallback | `case` globs translating each regex alternation | #1005 (286 lines cost 1055 forks) | `shopt -s extdebug` for `declare -F`: enable it **inside the capture subshell -only** — toggling it in the caller's shell clobbers caller state (#808). +only**, toggling it in the caller's shell clobbers caller state (#808). -## Where pure bash LOSES — measured, do not "optimize" these +## Where pure bash LOSES, measured, do not "optimize" these - **`… | awk` over ≳100 lines**: a `while read` loop was 5x slower than the awk fork (19ms vs 3.5ms for 600 `declare -F` lines). File scans (provider map, duplicate check) stay awk. -- **`${var//pattern/}` on large strings**: quadratic on Bash 3.2 — 2.7 s where +- **`${var//pattern/}` on large strings**: quadratic on Bash 3.2, 2.7 s where awk takes 3.5ms. Never string-replace over big captures. - **Regex assertions** (`assert_matches`): the `grep -E` fork is **not** because - `[[ =~ ]]` is missing — it was verified working on a real Bash 3.0 build. It is + `[[ =~ ]]` is missing, it was verified working on a real Bash 3.0 build. It is because 3.2 changed whether a quoted right-hand side is a regex or a literal, so at the 3.0 floor the same pattern would match differently across supported versions. Removing the fork means picking one semantic and breaking the other. @@ -109,7 +103,7 @@ believing a number. `>>` appends; holding the files open on persistent descriptors recovered **184 ms** of it, not 705 (#1110). - **A benchmark path must be the path that occurs.** `${file//[^a-zA-Z0-9]/_}` - costs 7.8 µs at 7 characters, 131 µs at 59 and 549 µs at 121 — so measuring + costs 7.8 µs at 7 characters, 131 µs at 59 and 549 µs at 121, so measuring it with an absolute path when the trap actually sees `./src/x.sh` inflated the estimate by 4x (#1102). @@ -128,7 +122,7 @@ diff: /dev/fd/62: Bad file descriptor `diff` then produces no comparison and writes that to stderr, which a caller collecting output reads as a real difference. Both coverage differentials did -this once per repo file — 473 iterations — and failed roughly 1 full suite run +this once per repo file, 473 iterations, and failed roughly 1 full suite run in 3, each time reporting "the classifiers disagree" over a file that agreed perfectly (#1152). Two earlier attempts blamed `awk`; the `AWK-FAILED` marker from #1143 is what finally ruled it out. @@ -139,7 +133,7 @@ shell opens the descriptor and dups it to stdin, so no external process opens So: never put `<(...)` in an external command's **argument list** inside a loop that runs per file or per test. Write real files (concurrently, so the two -sides still overlap — serialising them was measurably slower) and compare with +sides still overlap, serialising them was measurably slower) and compare with `[ "$a" != "$b" ]`, which the shell does without a fork. ## Where pure bash LOSES on some Bash versions but not others @@ -148,7 +142,7 @@ sides still overlap — serialising them was measurably slower) and compare with REPLACEMENT mean "the matched text", so `${line//` marker technique: 41.6ms of executed top-level code, against 10ms to parse the whole 600KB artifact and define all 915 functions. @@ -230,9 +224,9 @@ Lazy-loading whole modules was still rejected in #798. Measuring a change this small needs ~200 invocations per sample: the two forks are ~4ms against a ~65ms startup, and single runs vary by ±10ms. The acceptance -suite is useless for it — consecutive runs there differ by 5s. +suite is useless for it, consecutive runs there differ by 5s. `--coverage` adds ~3 forks per unique file first seen by the DEBUG trap -(decision-cache miss: grep|head + dirname) — bounded by file count, nightly +(decision-cache miss: grep|head + dirname), bounded by file count, nightly non-gating workflow, not worth chasing. **Don't assume the engine is the cost.** Profiling `--coverage` for #1005 found @@ -243,7 +237,7 @@ twice per run (`precompute_file_stats`, then `report_lcov`). Budget guarded by `tests/acceptance/bashunit_coverage_forks_test.sh`, expressed as "fork count does not grow with source-line count" rather than an absolute number. -Two traps when replacing a regex classifier with `case` globs — both silently +Two traps when replacing a regex classifier with `case` globs, both silently change coverage numbers rather than erroring: - Inside a POSIX bracket expression a backslash is a **literal member of the @@ -254,16 +248,16 @@ change coverage numbers rather than erroring: Verify such a rewrite by running both implementations over the same input (`git show HEAD:` + `declare -f | sed` to alias the old one) across every -`git ls-files '*.sh'` line, and mutation-test the harness itself — a differential +`git ls-files '*.sh'` line, and mutation-test the harness itself, a differential that cannot fail proves nothing. -**Parallel 10-test file run (CI's mode):** ~11 forks — 3 `mkdir`, 4 `rm`, +**Parallel 10-test file run (CI's mode):** ~11 forks, 3 `mkdir`, 4 `rm`, 3 `awk` (#813; was 61). That count was taken with a sequential census fixture and so missed the one cost that only exists in this mode: every worker used to re-probe the clock, because the probe resolved the implementation inside a `$( )` and the resolved value died with that subshell. On a shell without `EPOCHREALTIME` the probe forks `perl`, so it scaled one-for-one with the tests -— 502 execs for a 500-test file against 2 — and it fired even with per-test +, 502 execs for a 500-test file against 2, and it fired even with per-test timing off, since deciding that timing is off is what asks whether the clock is expensive (#1353). **Measure the parallel budget with a parallel fixture**: a per-worker cost is invisible to a sequential census by construction. The per-test result file is named by a per-suite @@ -271,7 +265,7 @@ ordinal the single-threaded dispatcher assigns just before each `&` (the fork inherits it), so it costs **no** `mktemp` + `mv` per test (#851; was 10 `mktemp` + 10 `mv`). This replaced the old sanitized-test-name scheme, whose deterministic names could collide (different provider args sanitize identically) -because Bash 3 workers can't mint a unique token — subshells inherit `$$`, and +because Bash 3 workers can't mint a unique token, subshells inherit `$$`, and `BASHPID` is 4.0+; an ordinal sidesteps that entirely. This file used to add "and the `RANDOM` state" to that list. Measured, `RANDOM` @@ -279,21 +273,21 @@ is neither reliably shared nor reliably reseeded (#1354): | Context | three consecutive `$( )` reads | |---|---| -| Plain shell — 3.00.22, 3.2.57, 4.4, 5.2, 5.3 | differ | -| `--parallel` worker — Linux 3.0, 5.2 | differ | -| `--parallel` worker — macOS 3.2.57 | **identical** | +| Plain shell, 3.00.22, 3.2.57, 4.4, 5.2, 5.3 | differ | +| `--parallel` worker, Linux 3.0, 5.2 | differ | +| `--parallel` worker, macOS 3.2.57 | **identical** | So it depends on the platform *and* on how deeply nested the subshell is. Do not build on it in either direction, and do not reuse "`RANDOM` is shared" as a -premise — it is right about the conclusion for the wrong reason. `$$` is the +premise, it is right about the conclusion for the wrong reason. `$$` is the part that genuinely is inherited. `wait_for_job_slot` already uses `wait -n` on Bash 4.3+ and an adaptive -sleep-poll fallback — don't "fix" it. The spinner forks `sleep` ~1/s on +sleep-poll fallback, don't "fix" it. The spinner forks `sleep` ~1/s on non-tty; not worth chasing. **Reports are on the per-test path too.** Any `--log-*`/`--report-*` flag makes each worker spool a result row, and that row used to base64 each of its nine -fields on its own — 14 `base64` forks per test counting the parent's decode, +fields on its own, 14 `base64` forks per test counting the parent's decode, with a `tr` on each encode. `--log-junit` therefore cost more than the run it reported on (300 tests: 1.2s → 9.2s wall, 5.8s → 27.4s CPU). Now only the four fields that can hold arbitrary text are encoded and the row is joined with diff --git a/.claude/skills/gh-issue/SKILL.md b/.agnostic-ai/skills/gh-issue/SKILL.md similarity index 81% rename from .claude/skills/gh-issue/SKILL.md rename to .agnostic-ai/skills/gh-issue/SKILL.md index 48d91e858..fd9d5beeb 100644 --- a/.claude/skills/gh-issue/SKILL.md +++ b/.agnostic-ai/skills/gh-issue/SKILL.md @@ -3,7 +3,7 @@ name: gh-issue description: Fetch GitHub issue, create branch, plan and implement with TDD, then open PR user-invocable: true argument-hint: "" -allowed-tools: Bash, Read, Edit, Write, Grep, Glob, Agent, WebFetch +allowed-tools: shell, read, edit, write, Grep, Glob, Agent, WebFetch --- # GitHub Issue Workflow @@ -11,13 +11,13 @@ allowed-tools: Bash, Read, Edit, Write, Grep, Glob, Agent, WebFetch Fetch a GitHub issue, create branch, implement following TDD, and open a PR. ## Arguments -- `$ARGUMENTS` - Issue number (e.g., `42` or `#42`) +- User-supplied argument: Issue number (e.g., `42` or `#42`) ## Instructions ### Phase 1: Setup -1. **Parse the issue number** from `$ARGUMENTS` (strip `#` if present) +1. **Parse the issue number** from the user-supplied argument (strip `#` if present) 2. **Fetch issue details**: ```bash @@ -64,24 +64,24 @@ Fetch a GitHub issue, create branch, implement following TDD, and open a PR. - Files to Change - Implementation Order (smallest first step) -9. **Use EnterPlanMode** if implementation is non-trivial +9. **Keep a short implementation plan** in the main session if implementation is non-trivial ### Phase 3: Implement -10. **Follow strict TDD workflow** (see @.claude/rules/tdd-workflow.md): +10. **Follow strict TDD workflow** (see `.agnostic-ai/rules/tdd-workflow.md`): For each test: - **RED** - Write failing test, verify it fails for the RIGHT reason - **GREEN** - Minimal code to pass - **REFACTOR** - Improve while keeping tests green -11. **Run full test suite frequently**: +11. **Run the full test suite once after the change set is complete**: ```bash ./bashunit tests/ ./bashunit --parallel tests/ ``` -12. **Quality checks** after each refactor: +12. **Quality checks** before committing: ```bash make sa && make lint ``` @@ -95,7 +95,7 @@ Fetch a GitHub issue, create branch, implement following TDD, and open a PR. 14. **Commit** using conventional commits with `Closes #` -15. **Create PR** using the `/pr` skill: +15. **Create PR** using the `pr` skill: ``` /pr # ``` diff --git a/.claude/skills/gh-issues/SKILL.md b/.agnostic-ai/skills/gh-issues/SKILL.md similarity index 81% rename from .claude/skills/gh-issues/SKILL.md rename to .agnostic-ai/skills/gh-issues/SKILL.md index db667a57f..736179e22 100644 --- a/.claude/skills/gh-issues/SKILL.md +++ b/.agnostic-ai/skills/gh-issues/SKILL.md @@ -4,22 +4,22 @@ description: Walk over all open GitHub issues that are unassigned or assigned to user-invocable: true argument-hint: "[--limit N] [--label foo] [--dry-run]" disable-model-invocation: true -allowed-tools: Read, Bash, Skill +allowed-tools: read, shell, Skill --- # GitHub Issues Watcher ## Purpose -Process every open GitHub issue that is **unassigned** or **assigned to the current user (`@me`)**, one after another, by delegating each to the `/gh-issue` skill. Stop on first hard failure so it can be inspected. +Process every open GitHub issue that is **unassigned** or **assigned to the current user (`@me`)**, one after another, by delegating each to the `gh-issue` skill. Stop on first hard failure so it can be inspected. Driven from inside the Claude session rather than a polling shell script. ## Args -- `--limit N` — process at most N issues this run (default: all). -- `--label foo` — only issues carrying label `foo`. -- `--dry-run` — list issues that would be processed; do not invoke `/gh-issue`. +- `--limit N`, process at most N issues this run (default: all). +- `--label foo`, only issues carrying label `foo`. +- `--dry-run`, list issues that would be processed; do not invoke `gh-issue`. Strip leading `#` if user passes `#123` style. @@ -47,7 +47,7 @@ Merge: - Deduplicate by `number`. - Keep only issues whose `assignees` array is empty **or** contains the current user (`gh api user -q .login`). - Drop issues assigned to anyone else (defensive). -- **Skip tracker/epic issues** — those whose body is a checklist of other issues (e.g. `- [ ] #123 …`). Process the child issues directly, not the parent. +- **Skip tracker/epic issues**, those whose body is a checklist of other issues (e.g. `- [ ] #123 …`). Process the child issues directly, not the parent. - Apply `--label` filter if given. - Apply `--limit` if given. - Sort ascending by `createdAt` (FIFO). @@ -79,16 +79,16 @@ For each issue in the queue: - Only `$me` listed → already mine, proceed (skip self-assign step). - Any other login present → skip this issue. -2. Invoke the `/gh-issue` skill with the issue number. That skill owns: +2. Invoke the `gh-issue` skill with the issue number. That skill owns: - self-assign via `gh issue edit --add-assignee @me` (no-op if already assigned) - branch from fresh `main` (prefix from labels: `fix/`, `feat/`, `docs/`) - strict TDD: RED → GREEN → REFACTOR (no production code without a failing test first) - full suite green locally (`./bashunit tests/` **and** `./bashunit --parallel tests/`) - `CHANGELOG.md` entry under `## Unreleased` for user-facing changes - commit with conventional message + `Closes #` in the body - - PR opened via `/pr #` + - PR opened via `pr #` -3. **Before pushing**, run the exact command CI's strict job uses — plain +3. **Before pushing**, run the exact command CI's strict job uses, plain sequential runs miss simple-mode and strict-mode bugs that CI rejects: ```bash ./bashunit --parallel --simple --strict tests/ @@ -99,11 +99,11 @@ For each issue in the queue: arrays with `=()` and read elements as `${arr[i]:-}`, and never enable `shopt -s extdebug` in the parent shell (isolate it inside a `$()` subshell). -4. After `/gh-issue` returns, wait for CI green on the PR: +4. After `gh-issue` returns, wait for CI green on the PR: ```bash gh pr checks --watch ``` - Fix red checks on the branch before moving on. Bash 3.0 CI fails often — + Fix red checks on the branch before moving on. Bash 3.0 CI fails often, check that job specifically. A red `docker`/registry step is usually a transient Docker Hub timeout, not your code: `gh run rerun --failed`. @@ -112,13 +112,13 @@ For each issue in the queue: gh pr merge --auto --squash --admin ``` -6. **Close the issue if the squash-merge did not.** GitHub builds the squash commit from the **PR body**, and `/pr` writes `Related #` there (never `Closes`), so the `Closes #` in the branch commit body is lost. After merge: +6. **Close the issue if the squash-merge did not.** GitHub builds the squash commit from the **PR body**, and `pr` writes `Related #` there (never `Closes`), so the `Closes #` in the branch commit body is lost. After merge: ```bash gh issue view --json state -q .state # still OPEN? gh issue close --reason completed -c "Done in # (merged)." ``` If the issue belongs to a tracker/epic, tick its checkbox in the parent - issue body. Use `sed` for the tick — bash `${body/- [ ] #N/...}` treats + issue body. Use `sed` for the tick, bash `${body/- [ ] #N/...}` treats `[ ]` as a glob (it matches a space, not literal brackets) and silently does nothing: ```bash @@ -137,8 +137,8 @@ For each issue in the queue: Halt the loop and surface the failure when: -- `/gh-issue` errors out or leaves the worktree dirty. -- `./bashunit tests/` or `./bashunit --parallel tests/` fails after implementation (for the right reason — distinguish pre-existing failures; see Notes). +- `gh-issue` errors out or leaves the worktree dirty. +- `./bashunit tests/` or `./bashunit --parallel tests/` fails after implementation (for the right reason, distinguish pre-existing failures; see Notes). - `make sa` (ShellCheck) or `make lint` (EditorConfig) fails. - CI stays red after one fix attempt. - Merge is blocked by branch protection beyond `--admin` bypass. @@ -156,12 +156,12 @@ With `--dry-run`, only execute Phase 1 and print the queue. No assignment, no br - `gh` authenticated, can read issues, open and merge PRs. - Worktree clean. - `main` exists and tracks `origin/main`. -- `/gh-issue` and `/pr` skills available in this session. +- `gh-issue` and `pr` skills available in this session. ## Notes -- **Bash 3.0+ compatibility is mandatory.** No `declare -A`, `[[ ]]`, `${var,,}`, negative array indexing, or `&>>` in `src/`. See `.claude/rules/bash-style.md`. +- **Bash 3.0+ compatibility is mandatory.** No `declare -A`, `[[ ]]`, `${var,,}`, negative array indexing, or `&>>` in `src/`. See `.agnostic-ai/rules/bash-style.md`. - **Quality gate is `make sa` + `make lint`, not bare `shfmt`.** `shfmt -w .` without project flags rewrites the whole tree (collapses line-continuations, flips binary-op style, tabs the `indent_size = unset` files). Match the surrounding 2-space style by hand and rely on `make lint` (EditorConfig) to verify. There is no `shfmt` make target. -- **Know your baseline.** `tests/unit/coverage_subshell_test.sh` fails on some macOS setups regardless of the change — confirm a failure is new (`git stash` + re-run) before treating it as a regression. +- **Know your baseline.** `tests/unit/coverage_subshell_test.sh` fails on some macOS setups regardless of the change, confirm a failure is new (`git stash` + re-run) before treating it as a regression. - Treat GitHub CI as the full quality gate; locally run focused tests during implementation, the full sequential + parallel suite once before commit. - Never split bundled changes into multiple PRs unless the issue explicitly demands it. diff --git a/.claude/skills/pr/SKILL.md b/.agnostic-ai/skills/pr/SKILL.md similarity index 66% rename from .claude/skills/pr/SKILL.md rename to .agnostic-ai/skills/pr/SKILL.md index 48b023bbb..a2e29dc70 100644 --- a/.claude/skills/pr/SKILL.md +++ b/.agnostic-ai/skills/pr/SKILL.md @@ -3,28 +3,30 @@ name: pr description: Push branch and create a GitHub PR with concise, issue-linked description user-invocable: true argument-hint: "[#issue]" -allowed-tools: Bash, Read, Grep, Glob +allowed-tools: shell, read, Grep, Glob --- # Create Pull Request Push branch and create a PR with a concise, issue-linked description. -> **IMPORTANT:** This skill MUST be used for ALL pull request creation — even when the user says "create pr" without `/pr`. Never create a PR without following these steps. +> **IMPORTANT:** This skill MUST be used for ALL pull request creation, even when the user says "create pr" without `pr`. Never create a PR without following these steps. ## Current Branch Context -- Branch: !`git branch --show-current` -- Commits: !`git log main..HEAD --oneline 2>/dev/null` -- Changed files: !`git diff main..HEAD --stat 2>/dev/null` + +Run the following commands before proceeding: +- Branch: `git branch --show-current` +- Commits: `git log main..HEAD --oneline 2>/dev/null` +- Changed files: `git diff main..HEAD --stat 2>/dev/null` ## Arguments -- `$ARGUMENTS` - Issue reference (optional, e.g., `#42` or `42`). If provided, the PR will be linked to this issue. +- User-supplied argument: Issue reference (optional, e.g., `#42` or `42`). If provided, the PR will be linked to this issue. ## Instructions -1. **Review the branch context above** — the commits and changed files are already loaded. +1. **Review the branch context above**, the commits and changed files come from the commands above. -2. **MANDATORY: Update CHANGELOG.md** — Read `CHANGELOG.md` and check the `## Unreleased` section. If the changes from this branch are NOT already listed there, you MUST update it before proceeding. **Do NOT skip this step. Do NOT proceed to push without verifying.** +2. **MANDATORY: Update CHANGELOG.md**, Read `CHANGELOG.md` and check the `## Unreleased` section. If the changes from this branch are NOT already listed there, you MUST update it before proceeding. **Do NOT skip this step. Do NOT proceed to push without verifying.** - Add entries under the appropriate subsection (`### Added`, `### Changed`, `### Fixed`, etc.) - Reference issue numbers where applicable (e.g., `(Issue #123)`) - Commit the update: @@ -40,7 +42,7 @@ Push branch and create a PR with a concise, issue-linked description. - If push fails, read the output, fix the issue, commit the fix, and retry. Do NOT use `--no-verify` to bypass. 4. **Generate PR title**: - - If `$ARGUMENTS` contains an issue number, fetch the issue title: + - If the user-supplied argument contains an issue number, fetch the issue title: ```bash gh issue view --json title -q '.title' ``` @@ -71,12 +73,12 @@ Push branch and create a PR with a concise, issue-linked description. **Assignee:** Always assign to `@me` (the PR creator). **Labels:** Add the single most relevant label based on the branch prefix and change context: - - `bug` — branch starts with `fix/` and addresses a defect - - `enhancement` — branch starts with `feat/` or adds new functionality - - `documentation` — branch starts with `docs/` or only changes docs - - `refactor` — code restructuring with no behavior change - - `ui` — visual/frontend-only changes - - `investigation` — spikes, research, or exploratory work + - `bug`, branch starts with `fix/` and addresses a defect + - `enhancement`, branch starts with `feat/` or adds new functionality + - `documentation`, branch starts with `docs/` or only changes docs + - `refactor`, code restructuring with no behavior change + - `ui`, visual/frontend-only changes + - `investigation`, spikes, research, or exploratory work **Body guidelines:** - **Background**: Link the issue with `Related #`, then 1-2 sentences of context. **NEVER use `Closes` or `Fixes`.** @@ -86,7 +88,7 @@ Push branch and create a PR with a concise, issue-linked description. 6. **Move issue to "In Review"** in GitHub Project (if issue number provided): ```bash - # Read project config from .claude/github-project.json + # Read project config from .agnostic-ai/local/github-project.json ITEM_ID=$(gh project item-list PROJECT_NUMBER --owner OWNER --format json \ | jq -r '.items[] | select(.content.number == ISSUE_NUMBER) | .id') @@ -103,8 +105,4 @@ Push branch and create a PR with a concise, issue-linked description. ## Example Usage -``` -/pr -/pr #42 -/pr 15 -``` +Use the `pr` skill alone, or pass an issue number such as `42` or `15`. diff --git a/.claude/skills/pre-release/SKILL.md b/.agnostic-ai/skills/pre-release/SKILL.md similarity index 82% rename from .claude/skills/pre-release/SKILL.md rename to .agnostic-ai/skills/pre-release/SKILL.md index a425f0720..0f7fec76e 100644 --- a/.claude/skills/pre-release/SKILL.md +++ b/.agnostic-ai/skills/pre-release/SKILL.md @@ -1,7 +1,7 @@ --- name: pre-release description: Run comprehensive pre-release validation checklist -allowed-tools: Read, Bash, Grep, Glob +allowed-tools: read, shell, Grep, Glob --- # Pre-Release Validation @@ -29,15 +29,15 @@ All must pass. Run 3-5 times to catch flaky tests. ### 3. Static Analysis & Formatting ```bash -make sa # ShellCheck — zero warnings -make lint # EditorConfig — clean -# NB: no shfmt gate — it conflicts with .editorconfig here; make lint is the authority +make sa # ShellCheck, zero warnings +make lint # EditorConfig, clean +# NB: no shfmt gate, it conflicts with .editorconfig here; make lint is the authority ``` ### 4. Documentation -- `CHANGELOG.md` — version section complete with all changes -- `README.md` — examples current, no broken links +- `CHANGELOG.md`, version section complete with all changes +- `README.md`, examples current, no broken links - No remaining TODOs/FIXMEs in docs ### 5. Bash 3.0+ Compatibility diff --git a/.claude/skills/release/SKILL.md b/.agnostic-ai/skills/release/SKILL.md similarity index 81% rename from .claude/skills/release/SKILL.md rename to .agnostic-ai/skills/release/SKILL.md index 36c526bad..f223ad582 100644 --- a/.claude/skills/release/SKILL.md +++ b/.agnostic-ai/skills/release/SKILL.md @@ -3,7 +3,7 @@ name: release description: Run pre-release validation and execute the release process user-invocable: true argument-hint: "[version]" -allowed-tools: Bash, Read, Grep, Glob +allowed-tools: shell, read, Grep, Glob --- # Release @@ -11,14 +11,16 @@ allowed-tools: Bash, Read, Grep, Glob Thin reminder around `./release.sh`. The release script owns the whole end-to-end flow (version bumps, build, checksum, CHANGELOG, commit, signed tags, push, GitHub release, `latest` branch). Don't reimplement those steps -here — fix `release.sh` if something is missing. +here, fix `release.sh` if something is missing. ## Current State -- Current version: !`grep -o 'BASHUNIT_VERSION="[^"]*"' bashunit | cut -d'"' -f2` -- Branch: !`git branch --show-current` -- Working tree: !`git status --short` -- Unreleased changes: !`awk '/^## Unreleased$/,/^## \[/' CHANGELOG.md | head -30` +Run the following commands before proceeding: + +- Current version: `grep -o 'BASHUNIT_VERSION="[^"]*"' bashunit | cut -d'"' -f2` +- Branch: `git branch --show-current` +- Working tree: `git status --short` +- Unreleased changes: `awk '/^## Unreleased$/,/^## \[/' CHANGELOG.md | head -30` ## Steps @@ -34,7 +36,7 @@ Stop and report if anything fails. Don't release on a red main. ### 2. Pick the version -`$ARGUMENTS` overrides; otherwise the script auto-increments the minor. +the user-supplied argument overrides; otherwise the script auto-increments the minor. Bump by the Unreleased section: a `### Added`/feat → minor, only `### Fixed` → patch. Confirm the version with the user before publishing. diff --git a/.claude/skills/review/SKILL.md b/.agnostic-ai/skills/review/SKILL.md similarity index 60% rename from .claude/skills/review/SKILL.md rename to .agnostic-ai/skills/review/SKILL.md index 9a77db4c3..f262969e3 100644 --- a/.claude/skills/review/SKILL.md +++ b/.agnostic-ai/skills/review/SKILL.md @@ -3,26 +3,28 @@ name: review description: Review a GitHub pull request; defaults to the current branch's PR when no number is given user-invocable: true argument-hint: "[#N]" -allowed-tools: Bash, Read, Grep, Glob +allowed-tools: shell, read, Grep, Glob --- # Review a Pull Request Review a GitHub PR. When no number is given, review the PR for the current branch. -> For reviewing your uncommitted working diff instead, use `/code-review`. +> For reviewing your uncommitted working diff instead, assign that diff to the `code-reviewer` agent. ## Current Branch Context -- Branch: !`git branch --show-current` -- PR for this branch: !`gh pr view --json number,title,state,url --jq '"#\(.number) \(.title) [\(.state)] \(.url)"' 2>/dev/null || echo "none"` + +Run the following commands before proceeding: +- Branch: `git branch --show-current` +- PR for this branch: `gh pr view --json number,title,state,url --jq '"#\(.number) \(.title) [\(.state)] \(.url)"' 2>/dev/null || echo "none"` ## Arguments -- `$ARGUMENTS` - PR number to review (optional, e.g., `#42` or `42`). +- User-supplied argument: PR number to review (optional, e.g., `#42` or `42`). ## Instructions 1. **Resolve which PR to review:** - - If `$ARGUMENTS` contains a number, review that PR. + - If the user-supplied argument contains a number, review that PR. - Otherwise, use the current branch's PR shown in the context above. - If neither yields a PR (no argument and the branch has no open PR), run `gh pr list` and ask the user which one to review. Do NOT guess. @@ -32,11 +34,11 @@ Review a GitHub PR. When no number is given, review the PR for the current branc gh pr diff ``` -3. **Review the diff** against this project's standards (see `.claude/CLAUDE.md` and `.claude/rules/`): +3. **Review the diff** against this project's standards (see `.agnostic-ai/AGNOSTIC_AI.md` and `.agnostic-ai/rules/`): - Bash 3.0+ compatibility (no `declare -A`, `[[ ]]`, `${var,,}`, negative indexing, `&>>`) - Tests exist and follow TDD; both success and failure paths covered - Naming, namespacing (`bashunit::*` / `_private`), and dynamic-scope safety - ShellCheck cleanliness and 2-space indentation (`make lint`, not `shfmt`) - CHANGELOG.md updated for user-facing changes -4. **Report findings** grouped by severity (blocker / suggestion / nit), each as `path:line — problem. fix.`. No praise, no scope creep. State plainly if the PR looks good. +4. **Report findings** grouped by severity (blocker / suggestion / nit), each as `path:line, problem. fix.`. No praise, no scope creep. State plainly if the PR looks good. diff --git a/.agnostic-ai/skills/tdd-cycle/SKILL.md b/.agnostic-ai/skills/tdd-cycle/SKILL.md new file mode 100644 index 000000000..b447bc9c9 --- /dev/null +++ b/.agnostic-ai/skills/tdd-cycle/SKILL.md @@ -0,0 +1,48 @@ +--- +name: tdd-cycle +description: Run complete TDD red-green-refactor cycle for a test +allowed-tools: read, edit, write, shell, Grep, Glob +--- + +# TDD Cycle + +Execute a complete Red -> Green -> Refactor cycle. Read [the complete workflow](references/workflow.md) for task-file conventions and the quality gate. Repository paths are relative to the repository root. + +## Workflow + +### 1. Verify Task File + +Use `.tasks/YYYY-MM-DD-slug.md` for features, refactors, or multi-test work. Skip it for small fixes or single-test changes. Choose the next test from the acceptance scenario when no task file is needed. + +### 2. RED, Write Failing Test + +1. Pick the smallest next test from the task file or acceptance scenario +2. Study patterns from existing tests (unit -> `assert_test.sh`, doubles -> `doubles_test.sh`) +3. Write the failing test following Arrange-Act-Assert +4. Run: `./bashunit path/to/test.sh`, **must fail** +5. Verify failure is for the RIGHT reason (not syntax error or wrong setup) +6. Update the task file, when present: current red bar + logbook entry + +### 3. GREEN, Make It Pass + +1. Write **minimal** code in `src/`, only enough for THIS test +2. Run: `./bashunit path/to/test.sh`, **must pass** +3. Update the task file, when present: check off test + logbook entry + +### 4. REFACTOR, Improve Code + +1. Improve readability, naming, extract duplication, no behavior changes +2. Run tests after each change +3. Run `make sa && make lint` and both full test modes once after the change set is complete +4. Update the task file, when present, with refactoring notes + +### 5. Next Test + +Continue until the acceptance scenario is complete. Update the test inventory when using a task file. + +## Critical Rules + +- **Never skip RED**, always verify test fails first +- **Minimal code in GREEN**, resist feature creep +- **All tests green during REFACTOR**, revert if broken +- **Update task file**, when present, after each phase diff --git a/.claude/rules/tdd-workflow.md b/.agnostic-ai/skills/tdd-cycle/references/workflow.md similarity index 80% rename from .claude/rules/tdd-workflow.md rename to .agnostic-ai/skills/tdd-cycle/references/workflow.md index 73c916ea4..059b6206b 100644 --- a/.claude/rules/tdd-workflow.md +++ b/.agnostic-ai/skills/tdd-cycle/references/workflow.md @@ -1,9 +1,3 @@ ---- -paths: - - "**/*.sh" - - ".tasks/**/*.md" ---- - # TDD Workflow **Test-Driven Development is mandatory** for all bashunit development. @@ -47,19 +41,19 @@ Test: (none yet) 1. Pick the **smallest next test** from inventory 2. Study patterns from existing tests (unit -> `assert_test.sh`, doubles -> `doubles_test.sh`) 3. Write test following Arrange-Act-Assert -4. Run: `./bashunit path/to/test.sh` — **must fail** +4. Run: `./bashunit path/to/test.sh`, **must fail** 5. Verify failure reason: function missing or wrong output (**not** syntax error) ## GREEN Phase -1. Write **minimal** code to pass — no extra features, no premature optimization -2. Run: `./bashunit path/to/test.sh` — **must pass** +1. Write **minimal** code to pass, no extra features, no premature optimization +2. Run: `./bashunit path/to/test.sh`, **must pass** ## REFACTOR Phase -1. Improve readability, naming, extract duplication — **no behavior changes** +1. Improve readability, naming, extract duplication, **no behavior changes** 2. Run tests after each change -3. Run quality checks: `make sa && make lint` (never `shfmt -w` — it breaks `make lint`) +3. After the change set is complete, run quality checks once: `make sa && make lint` (never `shfmt -w`, it breaks `make lint`) ## Quality Gate (Before Commit) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md deleted file mode 100644 index 14e65cd8b..000000000 --- a/.claude/CLAUDE.md +++ /dev/null @@ -1,123 +0,0 @@ -# bashunit - Bash Testing Framework - -## Project Overview - -**bashunit** is a lightweight Bash testing framework (Bash 3.0+) focused on developer experience. Provides assertions, test doubles (spies/mocks), data providers, snapshots, and more. - -**Documentation:** https://bashunit.com - -## Core Principles - -### TDD by Default -**RED → GREEN → REFACTOR** — every change starts from a failing test. No exceptions. - -### Bash 3.0+ Compatible - -Works on macOS default bash (3.2) and older. **Prohibited features:** -- `printf -v` (Bash 3.1+) -- `x+=y` string append assignment (Bash 3.1+) -- `arr+=(x)` array append (a Bash 3.0 **parse** error — it kills the file even in dead code) -- `declare -A` (associative arrays - Bash 4.0+) -- `[[ ]]` (use `[ ]` instead — house style, not a version limit) -- `[[ =~ ]]` (exists on 3.0, but 3.2 changed quoted-pattern semantics) -- `${var,,}` (case conversion - Bash 4.0+) -- `${array[-1]}` (negative indexing - Bash 4.3+) -- `&>>` redirect (Bash 4.0+) - -See `.claude/rules/bash-style.md` for complete compatibility guide (auto-loaded when editing `src/` or `tests/`). - -### Quality Standards - -Every change must pass: -```bash -make sa # ShellCheck static analysis -make lint # EditorConfig linting (the formatting authority — 2-space indent) -./bashunit tests/ # All tests passing -``` - -**Never run `shfmt -w`.** shfmt and `.editorconfig` disagree on this repo: 146 -tracked files are already shfmt-dirty on a clean `main`, and satisfying shfmt -breaks `make lint`. See "Formatting" below. - -## Formatting - -Match the surrounding 2-space style by hand and verify with `make lint`. There is -no `shfmt` target, and **`shfmt -w .` must not be run** — it fights `.editorconfig` -two ways: - -- `.editorconfig` sets `indent_size = unset` for `tests/acceptance/**.sh` and - `src/console_header.sh`; shfmt reads `unset` as "use my default", which is tabs, - while `[*]` sets `indent_style = space`. Any new file under `tests/acceptance/` - therefore shows up shfmt-dirty — that is expected, not a defect. -- shfmt collapses `\`-continued strings onto one line, which would push - `src/state.sh`'s record writer past `max_line_length = 120`. - -To check you introduced no new drift, compare the dirty-file *list* against main -rather than requiring it to be empty: - -```bash -git ls-files -z '*.sh' bashunit | xargs -0 shfmt -d 2>/dev/null \ - | grep '^--- ' | sed 's|--- ||; s|\.orig.*||' | sort -``` - -## Test Patterns - -Study existing tests before writing new ones: - -- **Assertions**: `tests/unit/assert_test.sh` -- **Test Doubles**: `tests/functional/doubles_test.sh` -- **Data Providers**: `tests/functional/provider_test.sh` -- **Lifecycle Hooks**: `tests/unit/setup_teardown_test.sh` -- **CLI Testing**: `tests/acceptance/bashunit_test.sh` - -## Path-Scoped Guidelines - -Rules auto-load based on file paths being edited (via `paths:` frontmatter in each rule file). - -## Guardrails - -### Never: -- Invent commands/features not in the codebase -- Break Bash 3.0+ compatibility -- Skip tests or quality checks -- Change public API without docs/CHANGELOG -- Commit without tests passing -- Batch unrelated changes in one PR -- Create a PR without using the `/pr` skill - -### Always: -- Write tests before implementation -- Use existing patterns from `tests/**` and `src/**` -- Minimal code in GREEN phase -- Keep tests passing during REFACTOR -- Update CHANGELOG.md for user-visible changes -- Run quality checks before committing -- Maintain Bash 3.0+ compatibility - -## Definition of Done - -- All tests green for the **right reason** -- `make sa` passes (ShellCheck) -- `make lint` passes (EditorConfig) -- Code formatted to 2-space indent by hand (verified by `make lint`, never `shfmt -w`) -- Bash 3.0+ compatible -- Parallel tests passing (`./bashunit --parallel tests/`) -- CHANGELOG.md updated (if user-facing changes) -- ADR created/updated (if architectural decision) - -## Commit Message Format - -[Conventional Commits](https://conventionalcommits.org/): `(): ` - -**Types:** `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` -**Scopes:** `assert`, `runner`, `cli`, `docs`, etc. - -## Prohibited Actions - -**Never without explicit user request:** -- Commit secrets or sensitive data -- Force push to main/master -- Skip git hooks (--no-verify) -- Amend published commits -- Use destructive git commands (reset --hard, clean -f) -- Push to remote without confirmation diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 4e4daa43a..000000000 --- a/.claude/settings.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/claude-code-settings.json", - "includeCoAuthoredBy": false, - "hooks": { - "PostToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "file=\"$CLAUDE_FILE_PATH\"; if [ -n \"$file\" ] && echo \"$file\" | grep -q '\\.sh$'; then shellcheck -x \"$file\" 2>&1 | head -20; fi" - } - ] - } - ] - } -} diff --git a/.claude/skills/commit/SKILL.md b/.claude/skills/commit/SKILL.md deleted file mode 100644 index c3cfa166b..000000000 --- a/.claude/skills/commit/SKILL.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -name: commit -description: Stage and commit changes using conventional commits format -user-invocable: true -argument-hint: "[message hint]" -disable-model-invocation: true -allowed-tools: Bash, Read, Grep, Glob ---- - -# Commit with Conventional Commits - -Stage and commit current changes using the conventional commits format. - -> **IMPORTANT:** This skill MUST be used for ALL commits — even when the user says "commit" without `/commit`. Never commit without following these steps. - -## Current State -- Branch: !`git branch --show-current` -- Status: !`git status --short` -- Staged diff: !`git diff --cached --stat 2>/dev/null` -- Unstaged diff: !`git diff --stat 2>/dev/null` -- Recent commits: !`git log --oneline -5 2>/dev/null` - -## Arguments -- `$ARGUMENTS` - Optional hint for the commit message (e.g., `fix the snapshot comparison`) - -## Instructions - -1. **Review the state above** — understand what changed and why. - -2. **Stage files** — add only the relevant changed files by name. Never use `git add -A` or `git add .`. Never stage files that contain secrets (`.env`, credentials, etc.). - -3. **Determine the commit type** from the nature of the changes: - - `feat` — new feature or capability - - `fix` — bug fix - - `docs` — documentation only - - `style` — formatting, whitespace (no logic change) - - `refactor` — code restructuring (no behavior change) - - `test` — adding or updating tests - - `chore` — maintenance, tooling, config - - `perf` — performance improvement - -4. **Determine the scope** from the area of the codebase affected: - - `assert` — assertions (`src/assert*.sh`) - - `runner` — test runner - - `cli` — CLI entry point, flags, options - - `doubles` — mocks, spies - - `docs` — documentation site - - `ci` — CI/CD, GitHub Actions - - Use the most specific scope that fits. Omit if changes span many areas. - -5. **Write the commit message**: - - Format: `(): ` - - Description: imperative mood, lowercase, no period, under 70 chars - - Focus on **why**, not what - - Add a body (separated by blank line) only if the why isn't obvious from the description - - Never mention AI, Claude, or automation in the message - -6. **Create the commit**: - ```bash - git commit -m "$(cat <<'EOF' - (): - - - EOF - )" - ``` - -7. **Verify** the commit was created: - ```bash - git log --oneline -1 - ``` - -## Examples - -``` -feat(assert): add assert_json_contains function -fix(runner): resolve parallel execution race condition -test(doubles): add spy verification edge cases -refactor(cli): extract option parsing into helper -docs: update installation instructions -chore(ci): upgrade shellcheck to v0.10 -perf(runner): reduce subshell usage in test discovery -``` - -## Rules - -- **One logical change per commit** — don't mix unrelated changes -- **Never use `--no-verify`** — if hooks fail, fix the issue -- **Never amend** unless the user explicitly asks -- **Always create a NEW commit** — even after a hook failure -- **Author**: use the git config identity (never commit as sandbox/default user) diff --git a/.claude/skills/tdd-cycle/SKILL.md b/.claude/skills/tdd-cycle/SKILL.md deleted file mode 100644 index 8f0a4b65f..000000000 --- a/.claude/skills/tdd-cycle/SKILL.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -name: tdd-cycle -description: Run complete TDD red-green-refactor cycle for a test -allowed-tools: Read, Edit, Write, Bash, Grep, Glob ---- - -# TDD Cycle - -Execute a complete Red -> Green -> Refactor cycle. - -## Workflow - -### 1. Verify Task File - -Check for `.tasks/YYYY-MM-DD-*.md`. If missing, create one before proceeding. - -### 2. RED — Write Failing Test - -1. Show test inventory from task file, ask which test to implement -2. Study patterns from existing tests (unit -> `assert_test.sh`, doubles -> `doubles_test.sh`) -3. Write the failing test following Arrange-Act-Assert -4. Run: `./bashunit path/to/test.sh` — **must fail** -5. Verify failure is for the RIGHT reason (not syntax error or wrong setup) -6. Update task file: current red bar + logbook entry - -### 3. GREEN — Make It Pass - -1. Write **minimal** code in `src/` — only enough for THIS test -2. Run: `./bashunit path/to/test.sh` — **must pass** -3. Update task file: check off test + logbook entry - -### 4. REFACTOR — Improve Code - -1. Improve readability, naming, extract duplication — no behavior changes -2. Run tests after each change -3. Quality checks: `make sa && make lint` -4. Full suite: `./bashunit tests/` -5. Update task file with refactoring notes - -### 5. Next Test - -Show updated test inventory. Ask if continuing or done. - -## Critical Rules - -- **Never skip RED** — always verify test fails first -- **Minimal code in GREEN** — resist feature creep -- **All tests green during REFACTOR** — revert if broken -- **Update task file** after each phase diff --git a/.editorconfig b/.editorconfig index a7a0edca9..cb1993de5 100644 --- a/.editorconfig +++ b/.editorconfig @@ -29,7 +29,7 @@ indent_style = tab [{tests/acceptance/**.sh,src/console/header.sh,docs/command-line.md}] indent_size = unset -[.claude/**.md] +[{.claude/**.md,.agnostic-ai/**.md}] indent_size = unset # Vendored verbatim (Cobertura's coverage-04.dtd); not ours to reformat. diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 03aff3da4..4ad7c4c23 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -34,6 +34,7 @@ Contributions are licensed under the [MIT License](https://github.com/TypedDevs/ - Bash 3.0+ - Git - Make +- Node.js (use the version in `.nvmrc`) and npm for contributor tooling - [ShellCheck](https://github.com/koalaman/shellcheck#installing) - [editorconfig-checker](https://github.com/editorconfig-checker/editorconfig-checker#installation) - [shfmt](https://github.com/mvdan/sh) and [jq](https://jqlang.org/) when building the standalone binary @@ -46,6 +47,11 @@ git clone https://github.com/YOUR_USERNAME/bashunit.git cd bashunit git remote add upstream https://github.com/TypedDevs/bashunit.git +# Install pinned contributor dependencies and generate agent configuration +npm ci +npm run agents:sync +npm run agents:check + # Install pre-commit hooks make pre_commit/install @@ -53,6 +59,18 @@ make pre_commit/install make test && make sa && make lint ``` +For a Windows source checkout in Git Bash, replace `npm ci` with `npm ci --force`: the root manifest restricts the published npm package to macOS and Linux. + +### Coding Agent Setup + +The root `package.json` pins [agnostic-ai](https://agnostic-ai.org/) 0.82.0 as a development dependency. `npm ci` installs it from the committed lockfile. The setup commands above generate Claude Code and Codex configuration using that local version; a global installation is not required. + +Edit `.agnostic-ai/` or `agnostic-ai.yaml`, then run `npm run agents:sync` and `npm run agents:check`. Generated files are ignored. Run sync after cloning and after pulling changes to the specs. Keep personal overrides in `.agnostic-ai/local/` or `agnostic-ai.local.yaml`, which are also ignored. + +Standing conventions live in `.agnostic-ai/rules/`. Directory scopes produce path-scoped Claude rules and nested Codex `AGENTS.md` files. Start a session in the relevant directory to load its native instructions; sessions started at the root must read the applicable source rules listed in the root task table. Longer procedures and reference material live in skills and their bundled files, including the fork census skill. A portable after-edit hook extracts edited paths from either tool and runs ShellCheck on shell files. Skills use explicit commands and request arguments in a shared body. The hook uses the project's installed agnostic-ai dependency and requires Node.js and ShellCheck on the client's PATH. Review and trust the generated hook through `/hooks` in Codex before relying on automatic checks. Claude's skill tool restrictions and invocation metadata have no equivalent in Codex; both tools still require the full quality gate before committing. + +The default model tier is `balanced`: Sonnet for Claude and `gpt-6.1-sol` at medium effort for Codex. `code-locator` locates code on Haiku or `gpt-6-luna` at low effort, `code-implementer` implements bounded changes on the balanced tier, and `code-reviewer` reviews on Opus or `gpt-6.1-sol` at high effort. These are task assignments, not speed or quality benchmarks. Codex permits at most two concurrent delegates. The coordinator runs the full gate once after all edits. Lookup and review agents prohibit writes in their prompts and request only read and shell capabilities. Each client applies its own capability support and session sandbox; these prompts do not impose a separate filesystem restriction. Override model tiers locally when a model is unavailable in your account; do not commit personal model or permission choices. + ### Documentation Setup (Optional) Docs live in their own workspace under `docs/` with an isolated `package.json`. diff --git a/.github/workflows/check-agent-specs.yml b/.github/workflows/check-agent-specs.yml new file mode 100644 index 000000000..6e171b2c9 --- /dev/null +++ b/.github/workflows/check-agent-specs.yml @@ -0,0 +1,71 @@ +name: Agent Specs + +on: + pull_request: + paths: + - '.agnostic-ai/**' + - 'agnostic-ai.yaml' + - 'package.json' + - 'package-lock.json' + - '.nvmrc' + - '.gitignore' + - '.worktreeinclude' + - '.github/workflows/check-agent-specs.yml' + push: + branches: [main] + paths: + - '.agnostic-ai/**' + - 'agnostic-ai.yaml' + - 'package.json' + - 'package-lock.json' + - '.nvmrc' + - '.gitignore' + - '.worktreeinclude' + - '.github/workflows/check-agent-specs.yml' + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: package-lock.json + - name: Install contributor dependencies + run: | + npm ci --ignore-scripts + echo "$GITHUB_WORKSPACE/node_modules/.bin" >> "$GITHUB_PATH" + - name: Validate and generate both targets + run: | + npm run agents:sync + npm run agents:check + git diff --exit-code + - name: Verify shared ShellCheck hook + run: | + python3 - <<'PY' + import json + import subprocess + import tempfile + from pathlib import Path + + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "edited shell.sh" + path.write_text("#!/usr/bin/env bash\necho $1\n") + result = subprocess.run( + [ + "agnostic-ai", "hook", "run", "shellcheck-after-edit", + "--edit", str(path), "--format", "json", + ], + check=True, capture_output=True, text=True, + ) + targets = json.loads(result.stdout)["targets"] + assert {target["target"] for target in targets} == {"claude", "codex"} + for target in targets: + assert target["counted"] and not target["warnings"], target + assert any("SC2086" in command["stdout"] for command in target["commands"]), target + PY diff --git a/.gitignore b/.gitignore index f8fdfb16a..e2b5499aa 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,32 @@ dev.log .claude/plans/ .claude/projects/ .claude/scheduled_tasks.lock + +# >>> agnostic-ai (managed) >>> +# Generated paths. Edit specs, not this block. Run `agnostic-ai sync` to refresh. +# Not committed: a fresh clone or `git worktree` lacks these until `agnostic-ai sync` runs (e.g. a post-checkout hook). +/.agents/skills/ +/.agnostic-ai/.command-lock +/.agnostic-ai/.sync-state +/.agnostic-ai/local/ +/.agnostic-ai/packs/ +/.claude/agent-memory-local/ +/.claude/agents/ +/.claude/hooks/ +/.claude/rules/ +/.claude/scheduled_tasks.lock +/.claude/settings.json +/.claude/settings.local.json +/.claude/skills/ +/.claude/worktrees/ +/.codex/agents/ +/.codex/config.toml +/.codex/hooks.json +/.codex/hooks/ +/AGENTS.md +/CLAUDE.md +/agnostic-ai.local.yaml +/src/AGENTS.md +/tests/AGENTS.md +/tests/acceptance/AGENTS.md +# <<< agnostic-ai (managed) <<< diff --git a/.worktreeinclude b/.worktreeinclude new file mode 100644 index 000000000..86886535f --- /dev/null +++ b/.worktreeinclude @@ -0,0 +1,27 @@ +# Generated edit hooks require the pinned contributor dependency in each worktree. +/node_modules/ + +# >>> agnostic-ai (managed) >>> +# Generated paths. Edit specs, not this block. Run `agnostic-ai sync` to refresh. +# Copied into each Claude Code worktree: the gitignored paths sync writes, and the local layer it reads. +/.agents/skills/ +/.agnostic-ai/local/ +/.agnostic-ai/packs/ +/.claude/agent-memory-local/ +/.claude/agents/ +/.claude/hooks/ +/.claude/rules/ +/.claude/settings.json +/.claude/settings.local.json +/.claude/skills/ +/.codex/agents/ +/.codex/config.toml +/.codex/hooks.json +/.codex/hooks/ +/AGENTS.md +/CLAUDE.md +/agnostic-ai.local.yaml +/src/AGENTS.md +/tests/AGENTS.md +/tests/acceptance/AGENTS.md +# <<< agnostic-ai (managed) <<< diff --git a/CHANGELOG.md b/CHANGELOG.md index e749a887a..9856aaa3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,14 @@ ## Unreleased ### Changed +- Contributor instructions use shared agnostic-ai specs for Claude Code and Codex, with a pinned npm development dependency, task-specific context, and model tiers - Sequential multi-file runs reuse provider and annotation metadata from header counting for unchanged files up to 512 characters (#1372) - Parallel reports collect worker results without per-row base64 processes, preserving values and completion order (#1371) - Data providers pass arguments without per-argument base64 processes, preserving quoting, empty values and parser isolation (#1370) - JSON reports write ordinary filenames, test names and empty messages without per-field processes, preserving existing escaping (#1369) ### Fixed +- Bash compatibility checks scan shell source files without treating generated contributor instructions as executable code - Concurrent provider rows have separate temporary-file owners, including rows with identical arguments (#1379) - Skipped macOS and Windows acceptance CI jobs have readable names, with shard labels in their test steps - Cancellation regression checks distinguish exited bodies awaiting reaping from running cleanup (#1385) diff --git a/agnostic-ai.yaml b/agnostic-ai.yaml new file mode 100644 index 000000000..2d1b7c9f4 --- /dev/null +++ b/agnostic-ai.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Chemaclass/agnostic-ai/v0.82.0/docs/schemas/config.schema.json +version: 1 +requires: "0.82.0" +builtins: [] + +targets: + - claude + - codex + +# Set to error to fail sync when a target cannot represent a spec. +on-unsupported: warn + +gitignore: + enabled: true + +models: + fast: {claude: haiku, codex: luna, effort: low} + balanced: {claude: sonnet, codex: sol, effort: medium} + strong: {claude: opus, codex: sol, effort: high} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..c8639d201 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,146 @@ +{ + "name": "bashunit", + "version": "0.51.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bashunit", + "version": "0.51.0", + "license": "MIT", + "os": [ + "darwin", + "linux" + ], + "bin": { + "bashunit": "bin/bashunit" + }, + "devDependencies": { + "agnostic-ai": "0.82.0" + } + }, + "node_modules/@agnostic-ai/darwin-arm64": { + "version": "0.82.0", + "resolved": "https://registry.npmjs.org/@agnostic-ai/darwin-arm64/-/darwin-arm64-0.82.0.tgz", + "integrity": "sha512-i2H3gz0AXZ8cAkYbEHSIBAGqvxvm79ELtZKhKpT9T8bt5cqzIruWkfbXodCdbFRl3VF/49LQqclXJz8a3OnrEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@agnostic-ai/darwin-x64": { + "version": "0.82.0", + "resolved": "https://registry.npmjs.org/@agnostic-ai/darwin-x64/-/darwin-x64-0.82.0.tgz", + "integrity": "sha512-qBn5GEOT3y2wRSR2Mzy98cpzJcDlAZTucat5EJDqPCrfqjl50tP95/+oHB8/YevfrJtcch/ZXWKXoAf4WvIbQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@agnostic-ai/linux-arm64": { + "version": "0.82.0", + "resolved": "https://registry.npmjs.org/@agnostic-ai/linux-arm64/-/linux-arm64-0.82.0.tgz", + "integrity": "sha512-SRafUWRqmcH+E+Zl4IfmbydZ8iCg7vjHvTdPZVgcz1XPMLX4mBJr1vzBPbqgRiZqR5vl+3laZCUVbgS992YUEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@agnostic-ai/linux-x64": { + "version": "0.82.0", + "resolved": "https://registry.npmjs.org/@agnostic-ai/linux-x64/-/linux-x64-0.82.0.tgz", + "integrity": "sha512-M8MfvrEZCswAivA3Gdn68cl2t7bsWVb7qZ/YNDodEpEwFgcwyM27cgZ2KcPEdJm2elRZrHgAJU1olhQT4/MyxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@agnostic-ai/win32-arm64": { + "version": "0.82.0", + "resolved": "https://registry.npmjs.org/@agnostic-ai/win32-arm64/-/win32-arm64-0.82.0.tgz", + "integrity": "sha512-nTq6+ODuRRMxHQAxTHPLYKOZ2kBxp2VP6JgdkkFqAT8c7VDVEr2JN0enNbeftzVdniKgtDGkWjpLKbulRbI8ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@agnostic-ai/win32-x64": { + "version": "0.82.0", + "resolved": "https://registry.npmjs.org/@agnostic-ai/win32-x64/-/win32-x64-0.82.0.tgz", + "integrity": "sha512-p3BcBvlxDsUga2Lw1WDTdqihz/m3MFGB2oNRLNA86jjFMm5KkObQ9uGHF/dDbjY5zFj1p5wabhgIstpfjyi7mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/agnostic-ai": { + "version": "0.82.0", + "resolved": "https://registry.npmjs.org/agnostic-ai/-/agnostic-ai-0.82.0.tgz", + "integrity": "sha512-wvsD5lhKpvv5bkEYjBBM99uNR0B27lPsgMSI15CjzZjNszV2UsV33OyR4l1LfcNrCEHmJrq2MXvJ6B2xGbnNIw==", + "dev": true, + "license": "MIT", + "bin": { + "agnostic-ai": "bin/agnostic-ai.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@agnostic-ai/darwin-arm64": "0.82.0", + "@agnostic-ai/darwin-x64": "0.82.0", + "@agnostic-ai/linux-arm64": "0.82.0", + "@agnostic-ai/linux-x64": "0.82.0", + "@agnostic-ai/win32-arm64": "0.82.0", + "@agnostic-ai/win32-x64": "0.82.0" + } + } + } +} diff --git a/package.json b/package.json index 4f7365b1e..18f213cbb 100644 --- a/package.json +++ b/package.json @@ -35,5 +35,12 @@ "bashunit", "tdd", "assertions" - ] + ], + "devDependencies": { + "agnostic-ai": "0.82.0" + }, + "scripts": { + "agents:sync": "agnostic-ai sync", + "agents:check": "agnostic-ai validate && agnostic-ai lint --strict && agnostic-ai sync --check" + } } diff --git a/tests/unit/project/bash_compatibility_test.sh b/tests/unit/project/bash_compatibility_test.sh index 315404afb..ebc05d8a5 100644 --- a/tests/unit/project/bash_compatibility_test.sh +++ b/tests/unit/project/bash_compatibility_test.sh @@ -11,11 +11,24 @@ # example preferring `[ ]` over `[[ ]]`) are not compatibility rules and are not # enforced by these tests -- `[[ ]]` works on every bash we support. -# Returns offending "file:line: text" for a pattern under a directory, skipping -# comment lines so a rule quoted in documentation or in a comment is not an -# error. +# Return offending shell source lines, excluding comments and documentation. function bashunit::compat::offenders_in() { - grep -rnE "$2" "$1" 2>/dev/null | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' || true + find "$1" -name '*.sh' -type f -print0 | + xargs -0 grep -nHE "$2" 2>/dev/null | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' || true +} + +function test_compatibility_scan_ignores_documentation_and_checks_nested_shell_files() { + local dir + dir="$(bashunit::temp_dir)" + mkdir -p "$dir/nested sources" + printf '%s\n' 'declare -A documented' >"$dir/AGENTS.md" + printf '%s\n' 'declare -A unsupported' >"$dir/nested sources/runtime.sh" + + local hits + hits="$(bashunit::compat::offenders_in "$dir" 'declare -A')" + + assert_not_contains "AGENTS.md" "$hits" + assert_contains "nested sources/runtime.sh:1:declare -A unsupported" "$hits" } function bashunit::compat::offenders() { @@ -133,7 +146,7 @@ function test_src_has_no_bare_hash_or_percent_substitution_pattern() { } # `printf -v` is Bash 3.1+. Use the return-slot pattern documented in -# .claude/rules/bash-style.md instead (which also avoids its dynamic-scope trap). +# .agnostic-ai/rules/bash-style.md instead (which also avoids its dynamic-scope trap). function test_src_has_no_printf_assignment() { assert_empty "$(bashunit::compat::ungated_offenders 'printf[[:space:]]+(-[a-zA-Z]*v)' 31)" } @@ -282,7 +295,7 @@ function test_src_has_no_mapfile_or_readarray() { } # declare -n / local -n (namerefs) are Bash 4.3+. Use the return-slot pattern -# documented in .claude/rules/bash-style.md instead. +# documented in .agnostic-ai/rules/bash-style.md instead. function test_src_has_no_namerefs() { assert_empty "$(bashunit::compat::ungated_offenders \ '(declare|local|typeset)[[:space:]]+(-[a-zA-Z]*n)[[:space:]]' 43)" @@ -461,7 +474,8 @@ function test_a_gate_does_not_carry_over_into_the_next_file() { # on every shell -- green, silently slower, and impossible to see in review. function test_every_gate_in_src_names_a_declared_flag() { local used declared missing - used="$(grep -rhoE '_BASHUNIT_BASH_GE_[0-9]+' src/ | LC_ALL=C sort -u)" + used="$(find src -name '*.sh' -type f -print0 | + xargs -0 grep -hoE '_BASHUNIT_BASH_GE_[0-9]+' | LC_ALL=C sort -u)" declared="$(grep -hoE '^_BASHUNIT_BASH_GE_[0-9]+' src/system/bash.sh | LC_ALL=C sort -u)" missing="" diff --git a/tests/unit/project/package_json_test.sh b/tests/unit/project/package_json_test.sh index 918ba2412..069bde77f 100644 --- a/tests/unit/project/package_json_test.sh +++ b/tests/unit/project/package_json_test.sh @@ -81,18 +81,20 @@ function test_package_json_excludes_docs_dependencies() { assert_not_contains '"vue"' "$pkg" } -function test_package_json_declares_no_dependencies_block() { +function test_package_json_declares_no_runtime_dependencies() { local pkg pkg=$(cat "$PKG_FILE") assert_not_matches '"dependencies"[[:space:]]*:' "$pkg" - assert_not_matches '"devDependencies"[[:space:]]*:' "$pkg" + assert_not_matches '"optionalDependencies"[[:space:]]*:' "$pkg" assert_not_matches '"peerDependencies"[[:space:]]*:' "$pkg" } -function test_package_json_declares_no_scripts_block() { +function test_package_json_declares_no_install_lifecycle_scripts() { local pkg pkg=$(cat "$PKG_FILE") - assert_not_matches '"scripts"[[:space:]]*:' "$pkg" + local lifecycle + lifecycle='"(preinstall|install|postinstall|prepublish|prepare|preprepare|postprepare)"[[:space:]]*:' + assert_not_matches "$lifecycle" "$pkg" } function test_docs_package_json_marked_private() {