diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 8c2e7982f..4238ad723 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -61,3 +61,86 @@ jobs:
echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY
echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Duration | ${DURATION}s |" >> $GITHUB_STEP_SUMMARY
+
+ # ---------------------------------------------------------------------------
+ # ci-full-suite-gate (Phase 1+2): run the full 65-suite run-all.sh in CI.
+ # Phase 1 measured the ground truth; Phase 2 made it green (service/tool-absent
+ # suites clean-skip via rc 77). Still NON-BLOCKING (continue-on-error) for a
+ # dev soak โ Phase 3 promotes it to a required check (dev โ main protection).
+ # Do NOT add to required checks while this comment is here.
+ # ---------------------------------------------------------------------------
+ full-suite:
+ name: Full Test Suite (non-blocking)
+ runs-on: ubuntu-latest
+ continue-on-error: true
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Ensure zsh is installed
+ run: |
+ if ! command -v zsh >/dev/null 2>&1; then
+ sudo apt-get update && sudo apt-get install -y zsh
+ fi
+
+ - name: Configure git identity
+ run: |
+ # Fresh runners have no git identity; suites that exercise the deploy
+ # workflow (teach-deploy) run `git commit` and fail with "empty ident"
+ # without it. This is CI environment provisioning, not a test change.
+ git config --global user.email "ci@flow-cli.test"
+ git config --global user.name "flow-cli CI"
+ git config --global init.defaultBranch main
+
+ - name: Record start time
+ id: start
+ run: echo "time=$(date +%s)" >> $GITHUB_OUTPUT
+
+ - name: Create mock project structure
+ run: |
+ mkdir -p ~/projects/dev-tools/flow-cli/.git
+ mkdir -p ~/projects/r-packages/active/mediationverse/.git
+ mkdir -p ~/projects/r-packages/stable/rmediation/.git
+ mkdir -p ~/projects/teaching/stat-440/.git
+ mkdir -p ~/projects/research/mediation-planning/.git
+ mkdir -p ~/projects/quarto/manuscripts/paper1/.git
+ mkdir -p ~/projects/apps/examify/.git
+ cp -r . ~/projects/dev-tools/flow-cli/
+
+ - name: Run full suite (non-blocking)
+ id: fullsuite
+ run: |
+ cd ~/projects/dev-tools/flow-cli
+ set +e
+ # Capture run-all.sh output; tee returns 0, so grab run-all's real
+ # exit via PIPESTATUS (1=FAIL, 2=TIMEOUT, 0=clean) and re-exit with it
+ # so the job color reflects reality. continue-on-error keeps it from
+ # blocking the PR.
+ ./tests/run-all.sh 2>&1 | tee /tmp/full-suite.log
+ rc=${PIPESTATUS[0]}
+ echo "rc=$rc" >> "$GITHUB_OUTPUT"
+ echo "Full suite exit code: $rc"
+ exit "$rc"
+
+ - name: Full Suite Summary
+ if: always()
+ run: |
+ END_TIME=$(date +%s)
+ DURATION=$((END_TIME - ${{ steps.start.outputs.time }}))
+ {
+ echo "## ๐งช Full Suite (run-all.sh) โ non-blocking measurement"
+ echo ""
+ echo "| Metric | Value |"
+ echo "|--------|-------|"
+ echo "| Duration | ${DURATION}s |"
+ echo "| Exit code | \`${{ steps.fullsuite.outputs.rc }}\` (0=clean, 1=FAIL, 2=TIMEOUT) |"
+ echo ""
+ echo "Full run-all.sh output
"
+ echo ""
+ echo '```'
+ cat /tmp/full-suite.log 2>/dev/null || echo "(no log captured)"
+ echo '```'
+ echo ""
+ echo " "
+ } >> "$GITHUB_STEP_SUMMARY"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 065814fbc..c5580b457 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Fixed
+
+- **Cache locking errored on Linux** (`lib/doctor-cache.zsh`,
+ `lib/analysis-cache.zsh`): the `flock` path used bash-only high-fd
+ redirection (`exec 201>`/`exec 200>`), which zsh parses as a command and
+ fails with "command not found" on Linux (where `flock` exists). Switched to
+ zsh's dynamic `exec {var}>` allocation. macOS was unaffected (no `flock` โ
+ mkdir fallback), so this only ever broke on Linux/CI.
+- **Email cache never worked on Linux** (`lib/em-cache.zsh`): used macOS-only
+ `stat -f %m`, so every entry's mtime read as 0 and looked expired. Added a
+ portable `_em_cache_mtime` (GNU `stat -c %Y` first โ it fails cleanly on
+ macOS โ then BSD `stat -f %m`).
+- **`teaching_week` computed 0 on Linux** (`lib/teaching-utils.zsh`,
+ `lib/dispatchers/teach-deploy-enhanced.zsh`): used macOS-only `date -j -f`.
+ Added portable date helpers (BSD then GNU `date -d`).
+- **`flow doctor --help-check` false-flagged `tm`** on machines without aiterm
+ (`lib/help-compliance.zsh`): the `tm` dispatcher only loads its help when the
+ `ait` CLI is present, so it's now checked only when `ait` is installed.
+
+### Changed
+
+- **CI now runs the full test suite on every PR.** Added a `full-suite` job to
+ `.github/workflows/test.yml` running `./tests/run-all.sh` (the full 65-suite
+ suite), parallel to the fast smoke job. It starts non-blocking
+ (`continue-on-error`) and is promoted to a required check after soaking green.
+- **`run-all.sh` skip semantics:** exit code **77** now counts a suite as
+ *skipped* (not failed) โ used by suites that require an external tool/service
+ (`atlas`, `ait`, `himalaya`, R, quarto, `claude`) absent on a hosted runner.
+ Service-dependent suites skip/degrade cleanly; standalone-behavior suites pin
+ `FLOW_ATLAS_ENABLED=no` so results are identical with or without atlas.
+
## [7.10.0] โ 2026-06-13 โ forward-looking schedule layer (`agenda` + dash UPCOMING)
### Added
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 44422dacb..bb99e19f0 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -8,6 +8,37 @@ The format follows [Keep a Changelog](https://keepachangelog.com/), and this pro
## [Unreleased]
+### Fixed
+
+- **Cache locking errored on Linux** (`lib/doctor-cache.zsh`,
+ `lib/analysis-cache.zsh`): the `flock` path used bash-only high-fd
+ redirection (`exec 201>`/`exec 200>`), which zsh parses as a command and
+ fails with "command not found" on Linux (where `flock` exists). Switched to
+ zsh's dynamic `exec {var}>` allocation. macOS was unaffected (no `flock` โ
+ mkdir fallback), so this only ever broke on Linux/CI.
+- **Email cache never worked on Linux** (`lib/em-cache.zsh`): used macOS-only
+ `stat -f %m`, so every entry's mtime read as 0 and looked expired. Added a
+ portable `_em_cache_mtime` (GNU `stat -c %Y` first โ it fails cleanly on
+ macOS โ then BSD `stat -f %m`).
+- **`teaching_week` computed 0 on Linux** (`lib/teaching-utils.zsh`,
+ `lib/dispatchers/teach-deploy-enhanced.zsh`): used macOS-only `date -j -f`.
+ Added portable date helpers (BSD then GNU `date -d`).
+- **`flow doctor --help-check` false-flagged `tm`** on machines without aiterm
+ (`lib/help-compliance.zsh`): the `tm` dispatcher only loads its help when the
+ `ait` CLI is present, so it's now checked only when `ait` is installed.
+
+### Changed
+
+- **CI now runs the full test suite on every PR.** Added a `full-suite` job to
+ `.github/workflows/test.yml` running `./tests/run-all.sh` (the full 65-suite
+ suite), parallel to the fast smoke job. It starts non-blocking
+ (`continue-on-error`) and is promoted to a required check after soaking green.
+- **`run-all.sh` skip semantics:** exit code **77** now counts a suite as
+ *skipped* (not failed) โ used by suites that require an external tool/service
+ (`atlas`, `ait`, `himalaya`, R, quarto, `claude`) absent on a hosted runner.
+ Service-dependent suites skip/degrade cleanly; standalone-behavior suites pin
+ `FLOW_ATLAS_ENABLED=no` so results are identical with or without atlas.
+
## [7.10.0] โ 2026-06-13 โ forward-looking schedule layer (`agenda` + dash UPCOMING)
### Added
diff --git a/docs/guides/TESTING.md b/docs/guides/TESTING.md
index 8eca12b21..c831769ca 100644
--- a/docs/guides/TESTING.md
+++ b/docs/guides/TESTING.md
@@ -23,10 +23,11 @@ flow-cli uses a **shared test framework** (`tests/test-framework.zsh`) with comp
| Metric | Count |
|--------|-------|
-| Test files | 210 |
-| Test suites (run-all.sh) | 58/58 passing |
+| Test files | 213 |
+| Test suites (run-all.sh) | 65 total โ 64 passed, 1 skipped, 0 failed |
| Test functions | 12,000+ |
-| Expected timeouts | 1 (IMAP connectivity) |
+| Expected skips | 1 (`e2e-em-dispatcher` โ needs configured IMAP account) |
+| CI | runs the full suite on every PR (green on the Ubuntu runner) |
---
@@ -263,7 +264,36 @@ zsh tests/test-work.zsh
./tests/run-all.sh
```
-65 suites, ~12000 assertions. Expected: 64/64 pass, 1 timeout (IMAP connectivity).
+65 suites, ~12000 assertions. Expected: **64 passed, 0 failed, 0 timeout, 1 skipped**.
+The 1 skip is `e2e-em-dispatcher` (needs a configured IMAP account; skips cleanly
+otherwise). `run-all.sh` exits **0** when there are no failures or timeouts.
+
+#### Skip semantics (exit code 77)
+
+A suite that requires an external tool/service which is absent must **skip
+cleanly** rather than fail. Exit **77** (the automake "skip" convention) tells
+`run-all.sh` to count the suite as โญ๏ธ skipped, not โ failed:
+
+```zsh
+# Whole-suite guard โ put after sourcing, before the tests:
+command -v yq >/dev/null 2>&1 || { echo "SKIP: yq not installed"; exit 77; }
+```
+
+For a **mixed** suite (most cases are tool-independent), gate only the
+tool-dependent cases instead of skipping the whole file โ e.g. include the `tm`
+dispatcher in dispatcher-enumeration checks only `if command -v ait`, so the
+other assertions still run. This keeps full coverage on a dev machine that has
+the tool while staying green on a hosted runner that doesn't.
+
+Tools whose absence triggers a skip on CI: `atlas`, `ait` (aiterm),
+`himalaya` (IMAP), `R`/`renv`, `quarto`, `claude`. Skips are printed in the
+suite output and summarised in the `run-all.sh` results line, so a skip is
+always visible (never a silently-missing pass).
+
+> **Determinism:** suites that assert flow-cli's *standalone* behavior pin
+> `FLOW_ATLAS_ENABLED=no` in setup so the result can't flip based on whether
+> `atlas` happens to be installed. The suite is green locally **with or without**
+> atlas, and on the runner (which has neither atlas nor the other tools above).
### Dogfood Quality Check
@@ -302,21 +332,40 @@ test_something() {
## Continuous Integration
-### GitHub Actions (`test.yml`)
+### GitHub Actions (`.github/workflows/test.yml`)
+
+Tests run automatically on push and PR to `main`/`dev`, in **two parallel jobs**:
+
+| Job | Runs | Purpose |
+|-----|------|---------|
+| **ZSH Plugin Tests** (`zsh-tests`) | smoke tests (`test-flow.zsh`, `test-install.sh`) + man-page version-sync guard | fast signal; the long-standing required check |
+| **Full Test Suite** (`full-suite`) | the whole `./tests/run-all.sh` (~4 min) | comprehensive gate โ runs every PR |
+
+The runner has no `atlas`, `ait`, `himalaya`, `R`, or `quarto`, so service-
+dependent suites **skip** there (see "Skip semantics" above); everything else
+must pass. A git identity is provisioned in the job so deploy suites that
+`git commit` work. The `full-suite` job captures the real exit code via
+`PIPESTATUS` (so its colour reflects reality) and emits the full `run-all.sh`
+output to the job summary.
-Tests run automatically on push and PR:
+> **Phasing:** `full-suite` starts as a **non-blocking** measurement job
+> (`continue-on-error: true`) so it can never create a perpetually-red gate
+> while the suite is being made deterministic. Once it has soaked green it is
+> promoted to a **required** status check on `dev`, then `main`.
```yaml
-name: ZSH Plugin Tests
-on: [push, pull_request]
-jobs:
- test:
+ full-suite:
+ name: Full Test Suite (non-blocking)
runs-on: ubuntu-latest
+ continue-on-error: true # measurement phase; drop when promoting to required
steps:
- - uses: actions/checkout@v4
- - name: Install ZSH
- run: sudo apt-get install -y zsh
- - name: Run Tests
+ - uses: actions/checkout@v6
+ - name: Configure git identity
+ run: |
+ git config --global user.email "ci@flow-cli.test"
+ git config --global user.name "flow-cli CI"
+ # ... mock project structure ...
+ - name: Run full suite (non-blocking)
run: ./tests/run-all.sh
```
diff --git a/lib/analysis-cache.zsh b/lib/analysis-cache.zsh
index 39714f10f..3b8ae9af6 100644
--- a/lib/analysis-cache.zsh
+++ b/lib/analysis-cache.zsh
@@ -44,6 +44,12 @@ if ! typeset -f _flow_log_debug >/dev/null 2>&1; then
source "${0:A:h}/core.zsh" 2>/dev/null || true
fi
+# Mutable module state: the flock file descriptor allocated by `exec {var}>` in
+# the acquire path and closed in the release path (a different function). Declare
+# it `-g` explicitly so the cross-function reference is unambiguous rather than
+# relying on zsh's implicit-global-on-assignment behaviour.
+typeset -g _ANALYSIS_CACHE_LOCK_FD=""
+
# =============================================================================
# CONSTANTS
# =============================================================================
@@ -182,9 +188,12 @@ _cache_acquire_lock() {
# Create lock file if it doesn't exist
touch "$lock_path" 2>/dev/null
- # Use flock with timeout
- exec 200>"$lock_path"
- if ! flock -w "$ANALYSIS_CACHE_LOCK_TIMEOUT" 200 2>/dev/null; then
+ # Use flock with timeout. zsh requires the dynamic `{var}` form for
+ # file descriptors >= 10; the literal `exec 200>` is bash-only and
+ # errors in zsh ("command not found: 200") on Linux โ where this flock
+ # branch runs. macOS lacks flock and uses the mkdir fallback below.
+ exec {_ANALYSIS_CACHE_LOCK_FD}>"$lock_path"
+ if ! flock -w "$ANALYSIS_CACHE_LOCK_TIMEOUT" "$_ANALYSIS_CACHE_LOCK_FD" 2>/dev/null; then
_flow_log_debug "Failed to acquire cache lock (timeout)" 2>/dev/null
return 1
fi
@@ -238,9 +247,10 @@ _cache_release_lock() {
local lock_path
lock_path=$(_cache_get_lock_path "$course_dir")
- # Release flock (if using flock)
- if command -v flock >/dev/null 2>&1; then
- exec 200>&- 2>/dev/null || true
+ # Release flock (if using flock). Close the dynamically-allocated fd from
+ # the acquire path (zsh {var} form; see the note there).
+ if command -v flock >/dev/null 2>&1 && [[ -n "$_ANALYSIS_CACHE_LOCK_FD" ]]; then
+ exec {_ANALYSIS_CACHE_LOCK_FD}>&- 2>/dev/null || true
fi
# Remove mkdir-based lock
diff --git a/lib/dispatchers/teach-deploy-enhanced.zsh b/lib/dispatchers/teach-deploy-enhanced.zsh
index e65ab7e56..b7c462b90 100644
--- a/lib/dispatchers/teach-deploy-enhanced.zsh
+++ b/lib/dispatchers/teach-deploy-enhanced.zsh
@@ -495,7 +495,8 @@ _deploy_update_status_file() {
start_date=$(yq '.semester_info.start_date // ""' .flow/teach-config.yml 2>/dev/null)
if [[ -n "$start_date" && "$start_date" != "null" ]]; then
local start_epoch today_epoch week_num
- start_epoch=$(date -j -f "%Y-%m-%d" "$start_date" "+%s" 2>/dev/null)
+ # Portable: BSD `date -j -f` (macOS) then GNU `date -d` (Linux/CI).
+ start_epoch=$(date -j -f "%Y-%m-%d" "$start_date" "+%s" 2>/dev/null || date -d "$start_date" "+%s" 2>/dev/null)
today_epoch=$(date "+%s")
if [[ -n "$start_epoch" ]]; then
week_num=$(( (today_epoch - start_epoch) / 604800 + 1 ))
diff --git a/lib/dispatchers/teach-dispatcher.zsh b/lib/dispatchers/teach-dispatcher.zsh
index 6c7dc1f36..b7b8c009b 100644
--- a/lib/dispatchers/teach-dispatcher.zsh
+++ b/lib/dispatchers/teach-dispatcher.zsh
@@ -4880,7 +4880,7 @@ _teach_show_status_full() {
# Find most recent backup
local recent=$(_teach_list_backups "$content_dir" | head -1)
if [[ -n "$recent" ]]; then
- local backup_time=$(stat -f %m "$recent" 2>/dev/null || stat -c %Y "$recent" 2>/dev/null)
+ local backup_time=$(stat -c %Y "$recent" 2>/dev/null || stat -f %m "$recent" 2>/dev/null)
if [[ "$backup_time" -gt "$latest_backup_time" ]]; then
latest_backup_time=$backup_time
latest_backup=$(basename "$recent")
diff --git a/lib/dispatchers/teach-doctor-impl.zsh b/lib/dispatchers/teach-doctor-impl.zsh
index 31cb7023f..0b64ba1d2 100644
--- a/lib/dispatchers/teach-doctor-impl.zsh
+++ b/lib/dispatchers/teach-doctor-impl.zsh
@@ -346,7 +346,7 @@ _teach_doctor_check_r_quick() {
# renv.lock freshness
if [[ "$verbose" == "true" && "$quiet" == "false" && "$json" == "false" ]]; then
local lock_mtime
- lock_mtime=$(stat -f %m renv.lock 2>/dev/null || stat -c %Y renv.lock 2>/dev/null)
+ lock_mtime=$(stat -c %Y renv.lock 2>/dev/null || stat -f %m renv.lock 2>/dev/null)
if [[ -n "$lock_mtime" ]]; then
local age_days=$(( (EPOCHSECONDS - lock_mtime) / 86400 ))
if [[ $age_days -eq 0 ]]; then
@@ -751,7 +751,7 @@ _teach_doctor_check_r_packages() {
# Lock file freshness
local lock_mtime
- lock_mtime=$(stat -f %m renv.lock 2>/dev/null || stat -c %Y renv.lock 2>/dev/null)
+ lock_mtime=$(stat -c %Y renv.lock 2>/dev/null || stat -f %m renv.lock 2>/dev/null)
if [[ -n "$lock_mtime" ]]; then
local age_days=$(( (EPOCHSECONDS - lock_mtime) / 86400 ))
if [[ $age_days -eq 0 ]]; then
@@ -1076,13 +1076,13 @@ _teach_doctor_check_macros() {
if [[ "$macros_configured" == "true" ]]; then
if [[ -f "$cache_file" ]]; then
local cache_mtime=0
- cache_mtime=$(stat -f %m "$cache_file" 2>/dev/null || stat -c %Y "$cache_file" 2>/dev/null || echo 0)
+ cache_mtime=$(stat -c %Y "$cache_file" 2>/dev/null || stat -f %m "$cache_file" 2>/dev/null || echo 0)
local stale=0
for src in "${sources[@]}"; do
if [[ -f "$src" ]]; then
local src_mtime
- src_mtime=$(stat -f %m "$src" 2>/dev/null || stat -c %Y "$src" 2>/dev/null || echo 0)
+ src_mtime=$(stat -c %Y "$src" 2>/dev/null || stat -f %m "$src" 2>/dev/null || echo 0)
if (( src_mtime > cache_mtime )); then
stale=1
break
diff --git a/lib/doctor-cache.zsh b/lib/doctor-cache.zsh
index 6ad9c1006..a4ee220f8 100644
--- a/lib/doctor-cache.zsh
+++ b/lib/doctor-cache.zsh
@@ -59,6 +59,11 @@ if ! typeset -f _flow_log_debug >/dev/null 2>&1; then
source "${0:A:h}/core.zsh" 2>/dev/null || true
fi
+# Mutable module state: flock fd allocated by `exec {var}>` in the acquire path
+# and closed in the release path (a different function). Declared `-g` so the
+# cross-function reference is explicit, not reliant on implicit globals.
+typeset -g _DOCTOR_CACHE_LOCK_FD=""
+
# =============================================================================
# CONSTANTS
# =============================================================================
@@ -157,10 +162,13 @@ _doctor_cache_acquire_lock() {
# Create lock file if it doesn't exist
touch "$lock_path" 2>/dev/null
- # Use flock with timeout
- # Use file descriptor 201 for doctor cache locks
- exec 201>"$lock_path"
- if ! flock -w "$DOCTOR_CACHE_LOCK_TIMEOUT" 201 2>/dev/null; then
+ # Use flock with timeout. zsh requires the dynamic `{var}` form for
+ # file descriptors >= 10; the literal `exec 201>` is bash-only and
+ # errors in zsh ("command not found: 201") on Linux โ where this flock
+ # branch actually runs. macOS has no flock and uses the mkdir fallback
+ # below, which is why this only ever broke on hosted CI runners.
+ exec {_DOCTOR_CACHE_LOCK_FD}>"$lock_path"
+ if ! flock -w "$DOCTOR_CACHE_LOCK_TIMEOUT" "$_DOCTOR_CACHE_LOCK_FD" 2>/dev/null; then
_flow_log_debug "Failed to acquire cache lock for $key (timeout)" 2>/dev/null
return 1
fi
@@ -214,9 +222,10 @@ _doctor_cache_release_lock() {
local lock_path
lock_path=$(_doctor_cache_get_lock_path "$key")
- # Release flock (if using flock)
- if command -v flock >/dev/null 2>&1; then
- exec 201>&- 2>/dev/null || true
+ # Release flock (if using flock). Close the dynamically-allocated fd from
+ # _doctor_cache_acquire_lock (zsh {var} form; see the note there).
+ if command -v flock >/dev/null 2>&1 && [[ -n "$_DOCTOR_CACHE_LOCK_FD" ]]; then
+ exec {_DOCTOR_CACHE_LOCK_FD}>&- 2>/dev/null || true
fi
# Remove mkdir-based lock
diff --git a/lib/em-cache.zsh b/lib/em-cache.zsh
index 5ecb6e3cc..a3c593440 100644
--- a/lib/em-cache.zsh
+++ b/lib/em-cache.zsh
@@ -56,6 +56,18 @@ _em_cache_key() {
echo "$msg_id" | md5 -q 2>/dev/null || echo "$msg_id" | md5sum 2>/dev/null | cut -d' ' -f1
}
+_em_cache_mtime() {
+ # Portable file mtime in epoch seconds. GNU `stat -c %Y` (Linux) is tried
+ # FIRST, BSD `stat -f %m` (macOS) second โ order matters: on Linux
+ # `stat -f %m FILE` treats `-f` as --file-system and prints a filesystem
+ # block for FILE to stdout while erroring on `%m`, so a BSD-first chain
+ # captures BOTH outputs and corrupts the mtime (cache then looks expired โ
+ # the email cache silently never worked on Linux). `stat -c %Y` fails
+ # cleanly on macOS (illegal option, empty stdout), so GNU-first is safe
+ # on both platforms.
+ stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0
+}
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# CACHE READ/WRITE
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -74,7 +86,7 @@ _em_cache_get() {
# Check TTL
local ttl="${_EM_CACHE_TTL[$operation]:-3600}"
local file_mod
- file_mod=$(stat -f %m "$cache_file" 2>/dev/null || echo 0)
+ file_mod=$(_em_cache_mtime "$cache_file")
local now=$(date +%s)
local file_age=$(( now - file_mod ))
@@ -153,7 +165,7 @@ _em_cache_prune() {
ttl="${_EM_CACHE_TTL[$op_name]:-3600}"
for cache_file in "$op_dir"/*.txt(N); do
- file_mod=$(stat -f %m "$cache_file" 2>/dev/null || echo 0)
+ file_mod=$(_em_cache_mtime "$cache_file")
if (( (now - file_mod) > ttl )); then
rm -f "$cache_file"
(( pruned++ ))
@@ -185,7 +197,13 @@ _em_cache_enforce_cap() {
# Evict oldest files first (LRU)
local evicted=0
local files_by_age
- files_by_age=("${(@f)$(find "$cache_base" -name '*.txt' -print0 2>/dev/null | xargs -0 stat -f '%m %N' 2>/dev/null | sort -n | awk '{print $2}')}")
+ # Null-delimited find/read + tab-separated mtimepath + `cut -f2-` so
+ # paths containing spaces survive the sort (the prior `awk '{print $2}'`
+ # truncated them). Cache files are hash-named .txt, so this is defensive.
+ files_by_age=("${(@f)$(
+ find "$cache_base" -name '*.txt' -print0 2>/dev/null | while IFS= read -r -d '' _f; do
+ print -r -- "$(_em_cache_mtime "$_f")"$'\t'"$_f"
+ done | sort -n | cut -f2-)}")
for old_file in "${files_by_age[@]}"; do
[[ -z "$old_file" ]] && continue
@@ -229,7 +247,7 @@ _em_cache_stats() {
for cache_file in "$op_dir"/*.txt(N); do
(( count++ ))
- file_mod=$(stat -f %m "$cache_file" 2>/dev/null || echo 0)
+ file_mod=$(_em_cache_mtime "$cache_file")
(( (now - file_mod) > ttl )) && (( expired++ ))
done
diff --git a/lib/help-compliance.zsh b/lib/help-compliance.zsh
index f63241dca..65cb93399 100644
--- a/lib/help-compliance.zsh
+++ b/lib/help-compliance.zsh
@@ -17,8 +17,17 @@
# 8. Color codes (_C_ or \033[)
# 9. Help function naming (__help)
-# All 14 dispatchers to check
-typeset -ga _FLOW_HELP_DISPATCHERS=(g r mcp qu wt v cc tm teach dots sec tok prompt em)
+# Dispatchers to check for help compliance. `tm` (aiterm terminal-manager)
+# only defines its help function (`_tm_help`) when the `ait` CLI is installed;
+# without it the dispatcher degrades to a "not installed" alias and early-
+# returns (lib/dispatchers/tm-dispatcher.zsh). Including tm unconditionally
+# would make `flow doctor --help-check` (and the help-compliance tests) report
+# a false "non-compliant" on any machine without aiterm โ e.g. CI runners โ so
+# tm is only checked when ait is present.
+typeset -ga _FLOW_HELP_DISPATCHERS=(g r mcp qu wt v cc teach dots sec tok prompt em)
+if command -v ait >/dev/null 2>&1; then
+ _FLOW_HELP_DISPATCHERS+=(tm)
+fi
# Map dispatcher names to their help function names
typeset -gA _FLOW_HELP_FUNCTIONS=(
diff --git a/lib/teaching-utils.zsh b/lib/teaching-utils.zsh
index 37a7f94f1..bcd113576 100644
--- a/lib/teaching-utils.zsh
+++ b/lib/teaching-utils.zsh
@@ -2,6 +2,18 @@
# Teaching workflow utility functions
# Part of Increment 2: Course Context
+# Portable date helpers. `date -j -f` is macOS (BSD) only and FAILS on Linux/CI
+# runners, where it would silently yield an empty epoch and make every
+# teaching-week / semester calculation return 0. Try BSD first, then GNU `date`.
+_teach_date_to_epoch() {
+ # YYYY-MM-DD -> epoch seconds
+ date -j -f "%Y-%m-%d" "$1" "+%s" 2>/dev/null || date -d "$1" "+%s" 2>/dev/null
+}
+_teach_epoch_to_date() {
+ # epoch seconds -> YYYY-MM-DD
+ date -j -f "%s" "$1" "+%Y-%m-%d" 2>/dev/null || date -d "@$1" "+%Y-%m-%d" 2>/dev/null
+}
+
# =============================================================================
# Function: _calculate_current_week
# Purpose: Calculate current week number from semester start date
@@ -40,7 +52,7 @@ _calculate_current_week() {
fi
# Calculate weeks since start (macOS date compatible)
- local start_epoch=$(date -j -f "%Y-%m-%d" "$start_date" "+%s" 2>/dev/null)
+ local start_epoch=$(_teach_date_to_epoch "$start_date")
local now_epoch=$(date "+%s")
if [[ -z "$start_epoch" ]]; then
@@ -174,8 +186,8 @@ _date_to_week() {
return 0
fi
- local start_epoch=$(date -j -f "%Y-%m-%d" "$start_date" "+%s" 2>/dev/null)
- local target_epoch=$(date -j -f "%Y-%m-%d" "$target_date" "+%s" 2>/dev/null)
+ local start_epoch=$(_teach_date_to_epoch "$start_date")
+ local target_epoch=$(_teach_date_to_epoch "$target_date")
if [[ -z "$start_epoch" || -z "$target_epoch" ]]; then
return 0
@@ -218,7 +230,7 @@ _validate_date_format() {
fi
# Verify it's a real date
- if ! date -j -f "%Y-%m-%d" "$date_str" "+%s" &>/dev/null; then
+ if ! _teach_date_to_epoch "$date_str" >/dev/null 2>&1 || [[ -z "$(_teach_date_to_epoch "$date_str")" ]]; then
return 1
fi
@@ -250,7 +262,7 @@ _validate_date_format() {
_calculate_semester_end() {
local start_date="$1"
- local start_epoch=$(date -j -f "%Y-%m-%d" "$start_date" "+%s" 2>/dev/null)
+ local start_epoch=$(_teach_date_to_epoch "$start_date")
if [[ -z "$start_epoch" ]]; then
return 1
@@ -258,7 +270,7 @@ _calculate_semester_end() {
# Add 16 weeks (112 days)
local end_epoch=$((start_epoch + (16 * 7 * 86400)))
- local end_date=$(date -j -f "%s" "$end_epoch" "+%Y-%m-%d" 2>/dev/null)
+ local end_date=$(_teach_epoch_to_date "$end_epoch")
echo "$end_date"
}
diff --git a/tests/automated-plugin-dogfood.zsh b/tests/automated-plugin-dogfood.zsh
index 2f9290753..2f4c2c4ee 100644
--- a/tests/automated-plugin-dogfood.zsh
+++ b/tests/automated-plugin-dogfood.zsh
@@ -101,7 +101,17 @@ echo ""
echo "${CYAN}--- Section 2: Dispatcher Functions ---${RESET}"
-dispatchers=(g mcp qu r cc tm wt dots sec tok teach prompt v em)
+# `tm` (aiterm terminal-manager) only defines its dispatcher function when the
+# `ait` CLI is installed; without it the dispatcher intentionally degrades to a
+# "not installed" alias and early-returns (lib/dispatchers/tm-dispatcher.zsh).
+# Include tm only when ait is present so this suite is deterministic on hosted
+# CI runners (where aiterm is absent) without losing coverage on dev machines.
+dispatchers=(g mcp qu r cc wt dots sec tok teach prompt v em)
+if command -v ait >/dev/null 2>&1; then
+ dispatchers+=(tm)
+else
+ echo "${YELLOW} (skipping tm dispatcher check โ aiterm 'ait' not installed)${RESET}"
+fi
for disp in "${dispatchers[@]}"; do
run_test "Dispatcher '$disp' is a function" "
@@ -145,7 +155,6 @@ help_fns=(
qu _qu_help
r _r_help
cc _cc_help
- tm _tm_help
wt _wt_help
dots _dots_help
sec _sec_help
@@ -155,6 +164,10 @@ help_fns=(
v _v_help
em _em_help
)
+# tm's _tm_help only exists when aiterm ('ait') is installed (see note above).
+if command -v ait >/dev/null 2>&1; then
+ help_fns[tm]=_tm_help
+fi
for disp fn in "${(@kv)help_fns}"; do
run_test "'$disp help' produces non-empty output" "
diff --git a/tests/dogfood-atlas-bridge.zsh b/tests/dogfood-atlas-bridge.zsh
index 5fffbca4b..d09fee80e 100644
--- a/tests/dogfood-atlas-bridge.zsh
+++ b/tests/dogfood-atlas-bridge.zsh
@@ -250,6 +250,9 @@ run_test "Plugin loads without stderr when Atlas disabled" '
run_test "at() coexists with all 14 dispatchers" '
local all_ok=true
for d in g mcp qu r cc tm wt dots sec tok teach prompt v em; do
+ # tm degrades to an alias when aiterm (ait) is absent (CI runners) โ
+ # only assert it is a function when ait is installed.
+ [[ "$d" == tm ]] && ! command -v ait >/dev/null 2>&1 && continue
typeset -f "$d" >/dev/null 2>&1 || { echo "Missing: $d"; all_ok=false; }
done
typeset -f at >/dev/null 2>&1 || { echo "Missing: at"; all_ok=false; }
diff --git a/tests/dogfood-teach-deploy-v2.zsh b/tests/dogfood-teach-deploy-v2.zsh
index ed52e51ba..c340ba122 100755
--- a/tests/dogfood-teach-deploy-v2.zsh
+++ b/tests/dogfood-teach-deploy-v2.zsh
@@ -494,6 +494,7 @@ echo ""
echo "${CYAN}--- Section 5: Deploy Rollback Helpers ---${RESET}"
run_test "Rollback in CI mode without index returns error" '
+ [[ "$_YQ_AVAILABLE" == "true" ]] || return 77
local tmpdir=$(mktemp -d)
_DOGFOOD_TEMP_DIRS+=("$tmpdir")
(
diff --git a/tests/dogfood-teach-doctor-v2.zsh b/tests/dogfood-teach-doctor-v2.zsh
index 08172abdf..4a54381ce 100644
--- a/tests/dogfood-teach-doctor-v2.zsh
+++ b/tests/dogfood-teach-doctor-v2.zsh
@@ -367,6 +367,10 @@ run_test "--verbose shows full check header" '
'
run_test "--verbose shows renv.lock age detail (if renv present)" '
+ # The renv.lock age detail is only emitted after the R-availability check
+ # passes (teach-doctor-impl.zsh returns early when R is absent), so skip
+ # cleanly on CI runners where R is not installed.
+ command -v R >/dev/null 2>&1 || return 77
# Create minimal renv setup
echo "{\"R\":{\"Version\":\"4.4.2\"},\"Packages\":{}}" > renv.lock
mkdir -p renv
diff --git a/tests/e2e-core-commands.zsh b/tests/e2e-core-commands.zsh
index b6126d931..796fb8d61 100644
--- a/tests/e2e-core-commands.zsh
+++ b/tests/e2e-core-commands.zsh
@@ -57,6 +57,13 @@ echo ""
# Load plugin
FLOW_QUIET=1
FLOW_PLUGIN_DIR="$PROJECT_ROOT"
+# Pin standalone mode: this suite asserts flow-cli's built-in fallback
+# behavior for `status` and `catch`. With atlas installed those commands
+# delegate to the atlas binary instead, flipping the result based on whether
+# atlas happens to be on PATH (passes on CI where atlas is absent, fails on a
+# dev box where it isn't). Forcing atlas off makes the suite deterministic
+# everywhere โ independent of whether atlas is installed.
+export FLOW_ATLAS_ENABLED=no
source "$PROJECT_ROOT/flow.plugin.zsh" 2>/dev/null || {
echo "${RED}Failed to load plugin${RESET}"
exit 1
diff --git a/tests/e2e-em-dispatcher.zsh b/tests/e2e-em-dispatcher.zsh
index dbf65590e..59a4d5ab7 100644
--- a/tests/e2e-em-dispatcher.zsh
+++ b/tests/e2e-em-dispatcher.zsh
@@ -91,18 +91,27 @@ test_himalaya_binary() {
}
run_test "himalaya binary exists" "test_himalaya_binary"
+# _em_hml_check reaches the configured IMAP account over the network.
+# On CI (or any host without a reachable account) this can block forever,
+# so bound it. A timeout (rc 124) or any failure => himalaya is not usable
+# for the live suite; skip everything below rather than hang.
+# (run_test runs the func in a command-substitution subshell, so we can't
+# set a flag from inside it โ track skips via TESTS_SKIPPED instead.)
+_skipped_before_cfg=$TESTS_SKIPPED
test_himalaya_configured() {
- if _em_hml_check >/dev/null 2>&1; then
+ if timeout 10 zsh -c "FLOW_QUIET=1 FLOW_ATLAS_ENABLED=no source '$PROJECT_ROOT/flow.plugin.zsh' 2>/dev/null; _em_hml_check >/dev/null 2>&1"; then
return 0
else
- echo "himalaya not configured"
+ echo "himalaya not configured (or account unreachable)"
exit 77
fi
}
run_test "himalaya configured" "test_himalaya_configured"
-# If prerequisites failed, exit now
-if [[ $TESTS_FAILED -gt 0 || $TESTS_SKIPPED -eq $TESTS_RUN ]]; then
+# If prerequisites failed, or the configured-check skipped (timeout / no
+# reachable account), exit now. Every test below makes live IMAP calls, so
+# continuing without a usable account would hang the runner.
+if [[ $TESTS_FAILED -gt 0 || $TESTS_SKIPPED -gt $_skipped_before_cfg ]]; then
echo ""
echo "${YELLOW}Prerequisites not met, skipping remaining tests${RESET}"
exit 77
@@ -204,7 +213,7 @@ echo "${CYAN}Section 4: Email Reading${RESET}"
FIRST_EMAIL_ID=""
TESTS_RUN=$((TESTS_RUN + 1))
echo -n " ${CYAN}[$TESTS_RUN] get first email ID...${RESET} "
-_e2e_email_data=$(_em_hml_list INBOX 1 2>/dev/null)
+_e2e_email_data=$(timeout 15 zsh -c "FLOW_QUIET=1 FLOW_ATLAS_ENABLED=no source '$PROJECT_ROOT/flow.plugin.zsh' 2>/dev/null; _em_hml_list INBOX 1 2>/dev/null")
if [[ -n "$_e2e_email_data" ]]; then
FIRST_EMAIL_ID=$(echo "$_e2e_email_data" | jq -r '.[0].id // empty' 2>/dev/null)
fi
diff --git a/tests/e2e-teach-deploy-v2.zsh b/tests/e2e-teach-deploy-v2.zsh
index f7e83730b..2abb12bc6 100755
--- a/tests/e2e-teach-deploy-v2.zsh
+++ b/tests/e2e-teach-deploy-v2.zsh
@@ -45,6 +45,10 @@ source "$PROJECT_ROOT/lib/deploy-history-helpers.zsh" 2>/dev/null || true
source "$PROJECT_ROOT/lib/deploy-rollback-helpers.zsh" 2>/dev/null || true
source "$PROJECT_ROOT/lib/dispatchers/teach-deploy-enhanced.zsh" 2>/dev/null || true
+# Deploy history/rollback/preflight helpers require yq to read/write YAML.
+# On CI runners where yq is absent, skip the whole suite cleanly (exit 77).
+command -v yq >/dev/null 2>&1 || { echo "SKIP: yq not installed"; exit 77; }
+
# Stub missing functions
if ! typeset -f _teach_error >/dev/null 2>&1; then
_teach_error() { echo "ERROR: $1" >&2; }
diff --git a/tests/run-all.sh b/tests/run-all.sh
index 4edc5b719..acb7a8656 100755
--- a/tests/run-all.sh
+++ b/tests/run-all.sh
@@ -12,6 +12,15 @@ echo ""
PASS=0
FAIL=0
TIMEOUT=0
+SKIP=0
+
+# Exit code 77 = the suite (or its only meaningful cases) cleanly skipped
+# because a required external tool/service is absent (atlas, ait/aiterm,
+# himalaya, R, quarto, โฆ). This is the standard automake "skip" code. A
+# skipped suite is NOT a failure โ it must never redden the gate โ but it is
+# surfaced distinctly so a skip is visible (and never silently masks a real
+# pass that should have happened on a fully-provisioned runner).
+readonly SKIP_RC=77
run_test() {
local test_file="$1"
@@ -32,6 +41,10 @@ run_test() {
# 124 = timeout
echo "โฑ๏ธ (timeout after ${timeout_seconds}s)"
((TIMEOUT++))
+ elif [[ $exit_code -eq $SKIP_RC ]]; then
+ # 77 = clean skip (required tool/service absent)
+ echo "โญ๏ธ (skipped โ required tool absent)"
+ ((SKIP++))
elif [[ $exit_code -eq 0 ]]; then
echo "โ
"
((PASS++))
@@ -43,6 +56,9 @@ run_test() {
if [[ $exit_code -eq 124 ]]; then
echo "โฑ๏ธ (timeout after ${timeout_seconds}s)"
((TIMEOUT++))
+ elif [[ $exit_code -eq $SKIP_RC ]]; then
+ echo "โญ๏ธ (skipped โ required tool absent)"
+ ((SKIP++))
elif [[ $exit_code -eq 0 ]]; then
echo "โ
"
((PASS++))
@@ -154,9 +170,16 @@ run_test ./tests/test-scholar-config-sync.zsh
echo ""
echo "========================================="
-echo " Results: $PASS passed, $FAIL failed, $TIMEOUT timeout"
+echo " Results: $PASS passed, $FAIL failed, $TIMEOUT timeout, $SKIP skipped"
echo "========================================="
+if [[ $SKIP -gt 0 ]]; then
+ echo ""
+ echo "Note: $SKIP suite(s) skipped โ a required external tool/service was"
+ echo "absent (atlas, ait/aiterm, himalaya, R, quarto). Expected on a hosted"
+ echo "CI runner; locally they run when the tool is installed."
+fi
+
if [[ $FAIL -gt 0 ]]; then
exit 1
fi
diff --git a/tests/test-atlas-contract.zsh b/tests/test-atlas-contract.zsh
index 7887f234e..33192e6a9 100755
--- a/tests/test-atlas-contract.zsh
+++ b/tests/test-atlas-contract.zsh
@@ -28,6 +28,21 @@ skip_without_atlas() {
return 1
}
+# Helper: skip warm-path/exit-code contract tests unless atlas actually
+# implements flow-cli's expected subcommands. A same-named `atlas` binary may
+# be on PATH (e.g. a different/older atlas) whose `stats`/`parked`/`trail`/`-v`
+# return 127; those tests would then fail not because the contract is broken
+# but because this isn't a flow-compatible atlas. Probe `atlas stats` once.
+# Returns 0 (true) when skipped, 1 (false) when a functional atlas is present.
+skip_without_warm_atlas() {
+ skip_without_atlas && return 0
+ if ! atlas stats >/dev/null 2>&1; then
+ test_skip "atlas present but warm-path subcommands unimplemented"
+ return 0
+ fi
+ return 1
+}
+
# ============================================================================
# BRIDGE FUNCTION TESTS (always run โ these test flow-cli code)
# ============================================================================
@@ -175,7 +190,7 @@ if ! skip_without_atlas; then
fi
test_case "atlas exit codes: success = 0"
-if ! skip_without_atlas; then
+if ! skip_without_warm_atlas; then
atlas -v >/dev/null 2>&1
assert_exit_code $? 0
test_pass
@@ -194,7 +209,7 @@ if ! skip_without_atlas; then
fi
test_case "Warm-path: atlas stats responds"
-if ! skip_without_atlas; then
+if ! skip_without_warm_atlas; then
atlas stats >/dev/null 2>&1
local ec=$?
assert_exit_code $ec 0
@@ -202,7 +217,7 @@ if ! skip_without_atlas; then
fi
test_case "Warm-path: atlas parked responds"
-if ! skip_without_atlas; then
+if ! skip_without_warm_atlas; then
atlas parked >/dev/null 2>&1
local ec=$?
assert_exit_code $ec 0
@@ -210,7 +225,7 @@ if ! skip_without_atlas; then
fi
test_case "Warm-path: atlas trail responds"
-if ! skip_without_atlas; then
+if ! skip_without_warm_atlas; then
atlas trail >/dev/null 2>&1
local ec=$?
assert_exit_code $ec 0
diff --git a/tests/test-cc-dispatcher.zsh b/tests/test-cc-dispatcher.zsh
index 0664a990c..3272ad6b1 100755
--- a/tests/test-cc-dispatcher.zsh
+++ b/tests/test-cc-dispatcher.zsh
@@ -504,6 +504,13 @@ test_shortcut_h_expands_to_haiku() {
test_explicit_here_dot() {
test_case "cc . recognized as explicit HERE"
+ # Requires the claude binary: HERE target execs `claude` directly.
+ # When absent (CI runner), zsh prints "command not found" -> skip cleanly.
+ if ! command -v claude >/dev/null 2>&1; then
+ test_skip "claude not installed"
+ return
+ fi
+
# The . should be recognized as HERE target
local output=$(cc . --help 2>&1 || echo "error")
@@ -518,6 +525,13 @@ test_explicit_here_dot() {
test_explicit_here_word() {
test_case "cc here recognized as explicit HERE"
+ # Requires the claude binary: HERE target execs `claude` directly.
+ # When absent (CI runner), zsh prints "command not found" -> skip cleanly.
+ if ! command -v claude >/dev/null 2>&1; then
+ test_skip "claude not installed"
+ return
+ fi
+
local output=$(cc here --help 2>&1 || echo "error")
if [[ "$output" != "error" ]]; then
diff --git a/tests/test-help-compliance-dogfood.zsh b/tests/test-help-compliance-dogfood.zsh
index c55b48dfb..e97a6fbfa 100755
--- a/tests/test-help-compliance-dogfood.zsh
+++ b/tests/test-help-compliance-dogfood.zsh
@@ -108,6 +108,17 @@ source "$FLOW_DIR/lib/help-compliance.zsh" 2>/dev/null || {
source "$FLOW_DIR/commands/doctor.zsh" 2>/dev/null
+# `tm` (aiterm terminal-manager) only defines its dispatcher/help when the
+# `ait` CLI is installed; otherwise it degrades to a "not installed" alias and
+# early-returns (lib/dispatchers/tm-dispatcher.zsh). On machines/CI runners
+# without aiterm, tm has no compliant help โ skip tm-specific cases and adjust
+# the expected dispatcher count so this suite is deterministic everywhere.
+_HAS_AIT=0
+command -v ait >/dev/null 2>&1 && _HAS_AIT=1
+_EXPECTED_DISPATCHERS=14
+(( _HAS_AIT )) || _EXPECTED_DISPATCHERS=13
+(( _HAS_AIT )) || echo -e "${YELLOW}Note: aiterm 'ait' not installed โ skipping tm help-compliance cases (expecting ${_EXPECTED_DISPATCHERS} dispatchers).${NC}"
+
echo "โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ"
echo " Help Compliance Dogfooding Tests"
echo "โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ"
@@ -160,8 +171,9 @@ _test_individual_rules() {
echo ""
}
-# Test all 14 dispatchers individually
+# Test all 14 dispatchers individually (tm only when aiterm is installed)
for d in g r mcp qu wt v cc tm teach dots sec tok prompt em; do
+ [[ "$d" == tm ]] && (( ! _HAS_AIT )) && continue
_test_individual_rules "$d"
done
@@ -189,6 +201,7 @@ _test_help_invocation() {
# Test all three invocation forms for each dispatcher
for cmd in g r mcp qu wt v cc tm prompt; do
+ [[ "$cmd" == tm ]] && (( ! _HAS_AIT )) && continue
for form in help --help -h; do
_test_help_invocation "$cmd" "$form"
done
@@ -272,6 +285,7 @@ _test_content_quality() {
}
for d in g r mcp qu wt v cc tm teach dots sec tok prompt; do
+ [[ "$d" == tm ]] && (( ! _HAS_AIT )) && continue
_test_content_quality "$d"
done
@@ -307,6 +321,7 @@ _test_color_fallback() {
# Only test the 7 dispatchers we fixed (they all define their own fallbacks)
for d in prompt dots sec tok cc tm teach v; do
+ [[ "$d" == tm ]] && (( ! _HAS_AIT )) && continue
_test_color_fallback "$d"
done
echo ""
@@ -353,6 +368,7 @@ _test_box_format() {
}
for d in g r mcp qu wt v cc tm teach dots sec tok prompt; do
+ [[ "$d" == tm ]] && (( ! _HAS_AIT )) && continue
_test_box_format "$d"
done
echo ""
@@ -368,6 +384,7 @@ echo ""
_test_function_naming() {
# Standard pattern: __help
for d in g r mcp qu wt v cc tm dots sec tok prompt; do
+ [[ "$d" == tm ]] && (( ! _HAS_AIT )) && continue
local expected="_${d}_help"
if typeset -f "$expected" > /dev/null 2>&1; then
assert_pass "$d: function $expected() exists"
@@ -406,12 +423,13 @@ _test_doctor_integration() {
assert_contains "$output" "Help Function Compliance Check" \
"doctor --help-check shows compliance header"
- # Output should report all 14 dispatchers
- assert_contains "$output" "All 14 dispatchers compliant" \
- "doctor --help-check reports all 14 compliant"
+ # Output should report all dispatchers compliant (13 without aiterm's tm)
+ assert_contains "$output" "All ${_EXPECTED_DISPATCHERS} dispatchers compliant" \
+ "doctor --help-check reports all ${_EXPECTED_DISPATCHERS} compliant"
# Each dispatcher should appear in output
for d in g r mcp qu wt v cc tm teach dots sec tok prompt em; do
+ [[ "$d" == tm ]] && (( ! _HAS_AIT )) && continue
assert_grep "$output" "โ
$d:" "doctor output includes $d result"
done
}
@@ -428,12 +446,12 @@ echo -e "${BLUE}โโ Section 8: Compliance Library API โโ${NC}"
echo ""
_test_compliance_api() {
- # Dispatcher list has exactly 14 entries
+ # Dispatcher list has the expected entries (14 with aiterm, 13 without tm)
local count=${#_FLOW_HELP_DISPATCHERS[@]}
- if [[ $count -eq 14 ]]; then
- assert_pass "dispatcher list has exactly 14 entries"
+ if [[ $count -eq $_EXPECTED_DISPATCHERS ]]; then
+ assert_pass "dispatcher list has exactly $_EXPECTED_DISPATCHERS entries"
else
- assert_fail "dispatcher list has exactly 14 entries" "found $count"
+ assert_fail "dispatcher list has exactly $_EXPECTED_DISPATCHERS entries" "found $count"
fi
# Function map has entry for every dispatcher
@@ -511,6 +529,7 @@ _test_consistency() {
# All dispatchers should have the same section order:
# box โ MOST COMMON โ QUICK EXAMPLES โ ๐ sections โ TIP โ See also
for d in g r mcp qu wt v cc tm teach dots sec tok prompt; do
+ [[ "$d" == tm ]] && (( ! _HAS_AIT )) && continue
local help_fn="${_FLOW_HELP_FUNCTIONS[$d]}"
local output
output="$($help_fn 2>&1)"
@@ -576,6 +595,7 @@ _test_edge_cases() {
# Help output contains no raw FLOW_COLORS references (all converted)
for d in prompt dots sec tok cc tm teach; do
+ [[ "$d" == tm ]] && (( ! _HAS_AIT )) && continue
local help_fn="${_FLOW_HELP_FUNCTIONS[$d]}"
local output
output="$($help_fn 2>&1)"
@@ -584,6 +604,7 @@ _test_edge_cases() {
# Help output contains no literal \033[ (should be rendered as actual ESC)
for d in prompt dots sec tok cc tm teach; do
+ [[ "$d" == tm ]] && (( ! _HAS_AIT )) && continue
local help_fn="${_FLOW_HELP_FUNCTIONS[$d]}"
local output
output="$($help_fn 2>&1)"
diff --git a/tests/test-teach-deploy-v2-integration.zsh b/tests/test-teach-deploy-v2-integration.zsh
index cbc35243d..72d7523a6 100755
--- a/tests/test-teach-deploy-v2-integration.zsh
+++ b/tests/test-teach-deploy-v2-integration.zsh
@@ -45,6 +45,10 @@ source "$PROJECT_ROOT/lib/deploy-history-helpers.zsh" 2>/dev/null || true
source "$PROJECT_ROOT/lib/deploy-rollback-helpers.zsh" 2>/dev/null || true
source "$PROJECT_ROOT/lib/dispatchers/teach-deploy-enhanced.zsh" 2>/dev/null || true
+# Deploy history/rollback/preflight helpers require yq to read/write YAML.
+# On CI runners where yq is absent, skip the whole suite cleanly (exit 77).
+command -v yq >/dev/null 2>&1 || { echo "SKIP: yq not installed"; exit 77; }
+
# Stub/override functions for test isolation
# These MUST be set AFTER sourcing libs to override real implementations
_teach_error() { echo "ERROR: $1" >&2; }
diff --git a/tests/test-teach-deploy-v2-unit.zsh b/tests/test-teach-deploy-v2-unit.zsh
index a2ffea322..9872a344d 100755
--- a/tests/test-teach-deploy-v2-unit.zsh
+++ b/tests/test-teach-deploy-v2-unit.zsh
@@ -45,6 +45,10 @@ source "$PROJECT_ROOT/lib/deploy-history-helpers.zsh" 2>/dev/null || true
source "$PROJECT_ROOT/lib/deploy-rollback-helpers.zsh" 2>/dev/null || true
source "$PROJECT_ROOT/lib/dispatchers/teach-deploy-enhanced.zsh" 2>/dev/null || true
+# Deploy history/rollback/preflight helpers require yq to read/write YAML.
+# On CI runners where yq is absent, skip the whole suite cleanly (exit 77).
+command -v yq >/dev/null 2>&1 || { echo "SKIP: yq not installed"; exit 77; }
+
# Stub functions that may not be available
if ! typeset -f _teach_error >/dev/null 2>&1; then
_teach_error() { echo "ERROR: $1" >&2; }