diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8530c5f9b2a..4233ed76c78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -305,7 +305,7 @@ jobs: if-no-files-found: warn macos-stock-bash: - name: Stock macOS Bash snapshot compatibility + name: Stock macOS Bash compatibility runs-on: macos-latest timeout-minutes: 10 steps: @@ -323,19 +323,36 @@ jobs: /bin/bash --version | head -1 command -v jq >/dev/null || { echo "::error::jq is required"; exit 1; } - shell_inventory="$RUNNER_TEMP/fm-shell-inventory" - bin/fm-lint.sh --list-files > "$shell_inventory" - parse_fail=0 - while IFS= read -r f; do - /bin/bash -n "$f" || { echo "::error::stock macOS Bash 3.2 failed to parse $f"; parse_fail=1; } - done < "$shell_inventory" - [ "$parse_fail" -eq 0 ] || { echo "::error::stock macOS Bash 3.2 parse sweep failed"; exit 1; } + # bin/fm-lint.sh is the single owner of firstmate's shell-script file + # set, so this sweep consumes its --list output rather than spelling + # its own, and must parse every entry that list publishes. + canonical_scripts=$(bin/fm-lint.sh --list) + expected_scripts=$(printf '%s\n' "$canonical_scripts" | grep -c '[^[:space:]]' || true) + [ "$expected_scripts" -gt 0 ] || { + echo "::error::bin/fm-lint.sh --list published no shell scripts" + exit 1 + } + parsed_scripts=0 + while IFS= read -r script; do + [ -n "$script" ] || continue + [ -f "$script" ] || { + echo "::error::canonical shell script $script does not exist" + exit 1 + } + /bin/bash -n "$script" + parsed_scripts=$((parsed_scripts + 1)) + done <<< "$canonical_scripts" + [ "$parsed_scripts" -eq "$expected_scripts" ] || { + echo "::error::Bash 3.2 parsed $parsed_scripts of $expected_scripts canonical shell scripts" + exit 1 + } + printf 'Bash 3.2 parsed %s canonical shell scripts\n' "$parsed_scripts" snapshot_output=$(/bin/bash tests/fm-fleet-snapshot-view.test.sh) printf '%s\n' "$snapshot_output" snapshot_count=$(printf '%s\n' "$snapshot_output" | grep -c '^ok - ') - [ "$snapshot_count" -eq 15 ] || { - echo "::error::expected 15 snapshot/fleet-view tests, got $snapshot_count" + [ "$snapshot_count" -eq 16 ] || { + echo "::error::expected 16 snapshot/fleet-view tests, got $snapshot_count" exit 1 } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index effd31a8912..406155d9db9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,8 +45,11 @@ See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/star - Helper scripts in `bin/` are plain bash. Each starts with a usage header comment; keep it accurate when you change behavior. Test scripts and helpers in `tests/` are plain bash too. + Stock macOS `/bin/bash` 3.2 is the supported floor for both, so avoid Bash 4+ constructs such as associative arrays and `${var,,}`/`${var^^}`, keep `$BASHPID` in its guarded `${BASHPID:-$$}` form, and never build a here-document inside a command substitution (`VAR=$(cat <.test.sh # one script (primary local focus path, timed) bin/fm-test-run.sh --family pure-contract-unit # ordinary family-scoped local path (serial, timed) @@ -94,9 +97,10 @@ Portable shard balance evidence lives in `docs/fm-test-portable-shards.md`. Local no-mistakes Test stays intent-targeted and must not wire `commands.test` to `--all` or a `tests/*.test.sh` walk. Family selection is the ordinary local path; `--all` is deliberate full regression only. CI owns broad regression across required portable parallel shards, the portable serial lane, the Herdr lane, lint, invariants, the coverage guard, and stock macOS Bash compatibility in [`.github/workflows/ci.yml`](.github/workflows/ci.yml). +That macOS lane parses every path `bin/fm-lint.sh --list` publishes through the real `/bin/bash` 3.2, requires an exact parsed-file count, and then runs the fleet snapshot/view and Bearings suites under the same interpreter. Use `bin/fm-test-run.sh --help` for lane names, `--jobs` rules, and required gate-skip flags when reproducing a lane locally. Discover tests by listing `tests/*.test.sh`: each is a self-contained bash script named `.test.sh`, and its header comment describes what it covers, so pass one to `bin/fm-test-run.sh` to focus on a subject with canonical timing output. -Tests that need a real optional backend or an explicit opt-in (real herdr/zellij/cmux smoke tests, the live Pi regression) skip themselves and print the tool or environment gate needed to enable them, so the portable suite remains safe on machines without those tools. +Tests that need a real optional backend, an external dependency the product itself requires, or an explicit opt-in (real herdr/zellij/cmux smoke tests, the Kimi turn-end harness's `python3` with `tomllib`, the live Pi regression) skip themselves and print the tool or environment gate needed to enable them, so the portable suite remains safe on machines without those tools. The [Herdr backend guide](docs/herdr-backend.md#destructive-lab-safety) owns the lane's isolation boundary, while [runtime backend verification](docs/verification/runtime-backends.md#herdr) owns active empirical evidence; live harness credential tests remain opt-in. ## Questions diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 0ac06b162f5..ea25e54f859 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -708,14 +708,7 @@ x_mode_setup() { fmx_poll_shim_valid "$shim" "$shim_home" "$FM_ROOT" \ || { fmx_arm_failed; return 0; } - cadence_body=$(cat <<'EOF' -# Auto-generated by fm-bootstrap.sh - X mode watcher cadence. -# Source this before the active harness protocol starts a watcher process so -# fm-watch.sh polls the X check every 30s. Non-X instances have no such file and -# keep the default 300s cadence. -export FM_CHECK_INTERVAL=30 -EOF -) + cadence_body=$(fmx_cadence_content) x_mode_write_if_changed "$cadence" "$cadence_body" 600 || { fmx_arm_failed; return 0; } echo "FMX: X mode on - relay poll armed via state/x-watch.check.sh; 30s watcher cadence in config/x-mode.env" diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 1dee81bb84a..7c7bc96fe7f 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -770,21 +770,14 @@ else file_mode_octal() { stat -c '%a' "$1" 2>/dev/null || true; } fi -registry_secondmates_json() { - local reg="$DATA/secondmates.md" out rc reason mode script parse_filter output_filter - if [ ! -f "$reg" ]; then - jq -n --arg path "$reg" --arg observed "$SNAPSHOT_NOW" \ - '{present:false,available:true,complete:true,reason:null,provenance:"registered-table",path:$path,freshness:{status:"fresh",observed_at:$observed},records:[],input_truncated:false,records_truncated:false,reasons:[],lines_in_window:0,records_in_window:0}' - return 0 - fi - mode=$(file_mode_octal "$reg") - if [ -z "$mode" ] || [ $((8#$mode & 0444)) -eq 0 ]; then - jq -n --arg path "$reg" --arg observed "$SNAPSHOT_NOW" \ - --arg reason "registered secondmate table is unreadable" \ - '{present:true,available:false,complete:false,reason:$reason,provenance:"registered-table",path:$path,freshness:{status:"unavailable",observed_at:$observed},records:[],input_truncated:false,records_truncated:false,reasons:[$reason],lines_in_window:0,records_in_window:0}' - return 0 - fi - script=$(cat <<'BASH' +# Each bounded reader body and jq filter is emitted by a function instead of +# an inline `VAR=$(cat <<'EOF' ...)`. Stock macOS Bash 3.2 keeps tracking +# quote state through a here-document while it scans for the closing `)` of a +# command substitution, so one apostrophe in any body below would break +# parsing of this whole file (issue #166); a function body is outside that +# scan. +registry_reader_script() { + cat <<'BASH' f=$1 max_lines=$2 max_bytes=$3 @@ -833,8 +826,10 @@ registry_secondmates_json() { --argjson records_in_window "$records_in_window" \ --argjson max_records "$max_records" "$output_filter" BASH - ) - parse_filter=$(cat <<'JQ' +} + +registry_parse_filter() { + cat <<'JQ' [ inputs | select(startswith("- ")) | (capture("^- (?[^[:space:]]+)")?) as $id @@ -845,8 +840,10 @@ BASH | group_by(.id) | map(if length > 1 then .[0] + {registry_error:"duplicate secondmate id in registry"} else .[0] end) JQ - ) - output_filter=$(cat <<'JQ' +} + +registry_output_filter() { + cat <<'JQ' {present:true,available:true,reason:null,provenance:"registered-table",path:$path, freshness:{status:"fresh",observed_at:$observed}, records:(if length > $max_records then .[:$max_records] else . end), @@ -858,7 +855,26 @@ JQ (if $records_truncated then "record_limit" else empty end) ],lines_in_window:$lines_in_window,records_in_window:$records_in_window} JQ - ) +} + +registry_secondmates_json() { + local reg="$DATA/secondmates.md" out rc reason mode script parse_filter output_filter + if [ ! -f "$reg" ]; then + jq -n --arg path "$reg" --arg observed "$SNAPSHOT_NOW" \ + '{present:false,available:true,complete:true,reason:null,provenance:"registered-table",path:$path,freshness:{status:"fresh",observed_at:$observed},records:[],input_truncated:false,records_truncated:false,reasons:[],lines_in_window:0,records_in_window:0}' + return 0 + fi + mode=$(file_mode_octal "$reg") + if [ -z "$mode" ] || [ $((8#$mode & 0444)) -eq 0 ]; then + jq -n --arg path "$reg" --arg observed "$SNAPSHOT_NOW" \ + --arg reason "registered secondmate table is unreadable" \ + '{present:true,available:false,complete:false,reason:$reason,provenance:"registered-table",path:$path,freshness:{status:"unavailable",observed_at:$observed},records:[],input_truncated:false,records_truncated:false,reasons:[$reason],lines_in_window:0,records_in_window:0}' + return 0 + fi + script=$(registry_reader_script) + parse_filter=$(registry_parse_filter) + output_filter=$(registry_output_filter) + out=$(run_timed "$FM_SNAPSHOT_REGISTRY_TIMEOUT" bash -c "$script" \ fm-secondmate-registry "$reg" "$FM_SNAPSHOT_REGISTRY_LINES" \ "$FM_SNAPSHOT_REGISTRY_BYTES" "$FM_SNAPSHOT_REGISTRY_RECORDS" "$reg" "$SNAPSHOT_NOW" \ @@ -876,13 +892,9 @@ JQ '{present:true,available:false,complete:false,reason:$reason,provenance:"registered-table",path:$path,freshness:{status:"unavailable",observed_at:$observed},records:[],input_truncated:false,records_truncated:false,reasons:[$reason],lines_in_window:0,records_in_window:0}' } -bounded_parent_activities_json() { # - local f=$1 out rc reason script - if [ ! -f "$f" ]; then - jq -n '{records:[],available:true,input_truncated:false,retained_truncated:false,reasons:[],lines_in_window:0,records_in_window:0}' - return 0 - fi - script=$(cat <<'BASH' +# Function-wrapped for the same Bash 3.2 reason as the registry reader above. +parent_activities_script() { + cat <<'BASH' classify=$1 f=$2 max_lines=$3 @@ -945,7 +957,16 @@ bounded_parent_activities_json() { # lines_in_window:$lines_in_window, records_in_window:$records_in_window}' BASH - ) +} + +bounded_parent_activities_json() { # + local f=$1 out rc reason script + if [ ! -f "$f" ]; then + jq -n '{records:[],available:true,input_truncated:false,retained_truncated:false,reasons:[],lines_in_window:0,records_in_window:0}' + return 0 + fi + script=$(parent_activities_script) + out=$(run_timed "$FM_SNAPSHOT_PARENT_ACTIVITY_TIMEOUT" bash -c "$script" \ fm-parent-activities "$SCRIPT_DIR/fm-classify-lib.sh" "$f" \ "$FM_SNAPSHOT_PARENT_ACTIVITY_LINES" "$FM_SNAPSHOT_PARENT_ACTIVITY_BYTES" \ diff --git a/bin/fm-lint.sh b/bin/fm-lint.sh index d1d761dd271..6fe2ab0333c 100755 --- a/bin/fm-lint.sh +++ b/bin/fm-lint.sh @@ -22,7 +22,7 @@ # fm-lint.sh --jobs <1|2> [path]... override bounded worker count # fm-lint.sh --telemetry ... write a quiet metrics snapshot # fm-lint.sh --required-version print the ShellCheck pin -# fm-lint.sh --list-files print the canonical file set +# fm-lint.sh --list print the canonical file set, one per line # fm-lint.sh --help print this usage set -u @@ -32,6 +32,12 @@ SELF="$SELF_DIR/fm-lint.sh" ROOT="$(cd "$SELF_DIR/.." && pwd)" cd "$ROOT" || exit 1 +# The canonical file set, spelled exactly once. Both consumers below - `--list` +# for every other gate, and the lint run's own roots - expand this array, so the +# published set and the linted set cannot drift by construction. Every adapter +# and test shell stays an independent root. +CANONICAL_ROOTS=(bin/*.sh bin/backends/*.sh tests/*.sh) + FM_LINT_WORKER_SHELLCHECK_PID= # shellcheck disable=SC2329 # Registered by the private worker's signal traps. fm_lint_worker_stop() { @@ -83,13 +89,21 @@ if [ "${1:-}" = "--required-version" ]; then exit 0 fi +# Publish the canonical file set without needing ShellCheck installed, so every +# other gate that must iterate firstmate's shell scripts consumes this owner's +# definition instead of spelling its own. tests/fm-lint.test.sh asserts this +# output matches the set the lint run below executes. +if [ "${1:-}" = "--list" ]; then + printf '%s\n' "${CANONICAL_ROOTS[@]}" + exit 0 +fi + fm_lint_usage() { sed -n '2,26{s/^# \{0,1\}//;p;}' "$SELF" } JOBS=${FM_LINT_JOBS:-2} TELEMETRY=${FM_LINT_TELEMETRY:-} -LIST_FILES=0 while [ "$#" -gt 0 ]; do case "$1" in --jobs) @@ -110,10 +124,6 @@ while [ "$#" -gt 0 ]; do TELEMETRY=${1#*=} shift ;; - --list-files) - LIST_FILES=1 - shift - ;; --help|-h) fm_lint_usage exit 0 @@ -131,22 +141,7 @@ case "$JOBS" in *) printf 'fm-lint.sh: jobs must be 1 or 2, got %s.\n' "$JOBS" >&2; exit 2 ;; esac -if [ "$#" -gt 0 ]; then - ROOTS=("$@") -else - ROOTS=(bin/*.sh bin/backends/*.sh tests/*.sh) -fi -ROOT_COUNT=${#ROOTS[@]} - -if [ "$LIST_FILES" -eq 1 ]; then - [ "$#" -eq 0 ] || { - printf 'fm-lint.sh: --list-files does not accept explicit paths.\n' >&2 - exit 2 - } - printf '%s\n' "${ROOTS[@]}" - exit 0 -fi - +# Enforce the pin so local and CI resolve the identical rule set. if ! command -v shellcheck >/dev/null 2>&1; then printf 'fm-lint.sh: ShellCheck not found; install ShellCheck %s for CI parity.\n' \ "$REQUIRED_SHELLCHECK" >&2 @@ -166,6 +161,13 @@ if [ "$resolved" != "$REQUIRED_SHELLCHECK" ]; then exit 1 fi +if [ "$#" -gt 0 ]; then + ROOTS=("$@") +else + ROOTS=("${CANONICAL_ROOTS[@]}") +fi +ROOT_COUNT=${#ROOTS[@]} + if [ -n "$TELEMETRY" ]; then telemetry_parent=$(dirname "$TELEMETRY") [ -d "$telemetry_parent" ] || { diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index ec485fd2415..d5d72045955 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1605,7 +1605,7 @@ fi META_WINDOW=$T [ "$BACKEND" = orca ] && META_WINDOW=$W -{ +write_spawn_metadata() { echo "window=$META_WINDOW" echo "endpoint_task_id=$ID" echo "worktree=$WT" @@ -1645,7 +1645,16 @@ META_WINDOW=$T echo "home=$PROJ_ABS" echo "projects=$SECONDMATE_PROJECTS" fi -} > "$STATE/$ID.meta" +} +# Bash 3.2 does not reliably apply `set -e` when a brace-group redirection +# fails. Render the metadata first, then publish it with a single write whose +# status covers both a failed open and a failed (short) write, so metadata +# publication failure always aborts the spawn and preserves the cleanup path. +META_BODY=$(write_spawn_metadata) +printf '%s\n' "$META_BODY" > "$STATE/$ID.meta" || { + echo "error: could not publish task metadata at $STATE/$ID.meta" >&2 + exit 1 +} [ "$BACKEND" = orca ] && ORCA_ABORT_CLEANUP=0 sq_brief=$(shell_quote "$BRIEF") diff --git a/bin/fm-x-lib.sh b/bin/fm-x-lib.sh index a8ea57991cd..e88c020776d 100644 --- a/bin/fm-x-lib.sh +++ b/bin/fm-x-lib.sh @@ -77,6 +77,20 @@ fmx_poll_shim_content() { "exec $(printf '%q' "$root/bin/fm-x-poll.sh")" } +# The generated config/x-mode.env body. It lives in a function rather than an +# inline `VAR=$(cat <&1); rc=$? expect_code 0 "$rc" "bash -n bin/fm-brief.sh must parse cleanly (got: $out)" [ -z "$out" ] || fail "bash -n bin/fm-brief.sh emitted unexpected output: $out" - pass "fm-brief.sh: bash -n succeeds" + + stock_version="" + if [ -x /bin/bash ]; then + stock_version=$(/bin/bash -c 'printf "%s" "$BASH_VERSION"' 2>/dev/null || true) + fi + case "$stock_version" in + 3.*) + out=$(/bin/bash -n "$ROOT/bin/fm-brief.sh" 2>&1); rc=$? + expect_code 0 "$rc" "/bin/bash $stock_version -n bin/fm-brief.sh must parse cleanly (got: $out)" + [ -z "$out" ] || fail "/bin/bash $stock_version -n bin/fm-brief.sh emitted unexpected output: $out" + checked="$checked, stock /bin/bash $stock_version" + ;; + esac + pass "fm-brief.sh: bash -n succeeds ($checked)" } # Structural class guard (issues #166, #958, #1069): never build a variable by diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index f47c70f2fa8..da4b15ddba4 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -440,6 +440,51 @@ EOF pass "snapshot includes durable scout reports after teardown" } +test_registry_byte_truncation_discards_partial_line() { + local home first second partial limit out + home=$(make_home registry-byte-truncation) + first="- first (home: $home/first; scope: alpha)" + second="- second (home: $home/second; scope: beta)" + # The byte cut has to land *after* the second entry's home field. A shorter + # prefix carries no `(home: ...;` segment, so the registry parser drops it on + # its own and the assertions below would still hold with the partial-line + # discard deleted from fm-fleet-snapshot.sh - a vacuous guard. + partial="- second (home: $home/second;" + limit=$((${#first} + 1 + ${#partial})) + + # Control: the prefix the byte budget leaves behind is a parseable record on + # its own, so requiring its absence below is a real requirement. + printf '%s\n' "$partial" > "$home/data/secondmates.md" + out=$(FM_HOME="$home" "$SNAPSHOT" --json) + printf '%s' "$out" | jq -e ' + (.secondmate_current.registry.records | map(.id)) == ["second"] + ' >/dev/null || fail "control: the truncated prefix must parse as a record on its own: $out" + + printf '%s\n%s\n' "$first" "$second" > "$home/data/secondmates.md" + out=$(FM_HOME="$home" FM_SNAPSHOT_REGISTRY_BYTES="$limit" "$SNAPSHOT" --json) + printf '%s' "$out" | jq -e ' + .secondmate_current.registry.input_truncated == true + and .secondmate_current.registry.complete == false + and .secondmate_current.registry.reasons == ["byte_limit"] + and .secondmate_current.registry.lines_in_window == 1 + and (.secondmate_current.registry.records | map(.id)) == ["first"] + ' >/dev/null || fail "registry byte truncation must keep only complete lines: $out" + + # No newline anywhere in the window: every retained byte belongs to a partial + # line, so the window must empty rather than publish a half-read record. + printf '%s' "$second" > "$home/data/secondmates.md" + out=$(FM_HOME="$home" FM_SNAPSHOT_REGISTRY_BYTES="${#partial}" "$SNAPSHOT" --json) + printf '%s' "$out" | jq -e ' + .secondmate_current.registry.input_truncated == true + and .secondmate_current.registry.complete == false + and .secondmate_current.registry.reasons == ["byte_limit"] + and .secondmate_current.registry.lines_in_window == 0 + and (.secondmate_current.registry.records | length) == 0 + ' >/dev/null || fail "registry truncation before the first newline must discard all content: $out" + + pass "registry byte truncation discards the partial final line and content without a newline" +} + test_backlog_tasks_axi_forms_and_overrides() { local home data projects fakebin out view home=$(make_home overrides) @@ -791,6 +836,7 @@ test_open_decision_clears_on_keyed_resolution test_completed_scout_report_is_pointer_not_pending test_parked_scout_decision_stays_pending test_scout_reports_include_teardown_reports +test_registry_byte_truncation_discards_partial_line test_backlog_tasks_axi_forms_and_overrides test_view_renders_snapshot test_view_renders_dead_secondmate_agent_status diff --git a/tests/fm-kimi-harness.test.sh b/tests/fm-kimi-harness.test.sh index b9f84803459..7736c0a2915 100755 --- a/tests/fm-kimi-harness.test.sh +++ b/tests/fm-kimi-harness.test.sh @@ -11,6 +11,11 @@ KIMI_HOOK="$ROOT/bin/fm-kimi-turnend-hook.sh" TMP_ROOT=$(fm_test_tmproot fm-kimi-harness) KIMI_RUNTIME_TASK_TMP= PYTHON_BIN=$(command -v python3) || fail "test needs python3" +# The Kimi turn-end hook validates config.toml with tomllib, so it fails closed +# on a python3 older than 3.11. That refusal is the product's contract, not a +# regression: gate the suite the way the other optional-dependency tests do. +"$PYTHON_BIN" -c 'import tomllib' >/dev/null 2>&1 \ + || { echo "skip: python3 lacks tomllib (required by fm-kimi-turnend-hook.sh; needs Python 3.11+)"; exit 0; } PYTHON_BIN_DIR=$(dirname "$PYTHON_BIN") JQ_BIN=$(command -v jq) || fail "test needs jq" BASE_PATH=${FM_TEST_BASE_PATH:-$PYTHON_BIN_DIR:/usr/bin:/bin:/usr/sbin:/sbin} diff --git a/tests/fm-lint.test.sh b/tests/fm-lint.test.sh index 17fb097f758..8a147ee90b0 100755 --- a/tests/fm-lint.test.sh +++ b/tests/fm-lint.test.sh @@ -18,6 +18,7 @@ set -u . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" LINT="$ROOT/bin/fm-lint.sh" +CI="$ROOT/.github/workflows/ci.yml" INSTALLER="$ROOT/bin/fm-install-shellcheck.sh" # The pinned version, read from the single source (the one owner itself). REQUIRED=$("$LINT" --required-version) @@ -29,13 +30,69 @@ pinned_ready() { [ "$(shellcheck --version | awk '/^version:/ {print $2; exit}')" = "$REQUIRED" ] } -test_list_files_reports_the_shell_inventory() { - local listed expected - listed=$("$LINT" --list-files) - expected=$(find bin bin/backends tests -maxdepth 1 -type f -name '*.sh' -print | LC_ALL=C sort) - [ "$(printf '%s\n' "$listed" | LC_ALL=C sort)" = "$expected" ] \ - || fail "fm-lint.sh --list-files did not return the complete shell inventory" - pass "fm-lint.sh --list-files reports the complete shell inventory" +test_list_mode_publishes_the_canonical_set() { + # --list is what other gates consume, so it must expand to exactly the set the + # lint run itself executes. That equality is structural: one CANONICAL_ROOTS + # array spelled exactly once, with both consumers expanding it. + local actual path inventory + [ "$(grep -Fc -- 'bin/*.sh bin/backends/*.sh tests/*.sh' "$LINT")" -eq 1 ] \ + || fail "the canonical globs must be spelled exactly once in fm-lint.sh" + assert_grep 'printf '\''%s\n'\'' "${CANONICAL_ROOTS[@]}"' "$LINT" "--list must publish the canonical array, not its own glob spelling" + assert_grep 'ROOTS=("${CANONICAL_ROOTS[@]}")' "$LINT" "the lint run must consume the canonical array, not its own glob spelling" + actual=$("$LINT" --list) + [ -n "$actual" ] || fail "fm-lint.sh --list published nothing" + printf '%s\n' "$actual" | grep -q '^bin/' || fail "fm-lint.sh --list published no bin shell scripts" + printf '%s\n' "$actual" | grep -q '^bin/backends/' || fail "fm-lint.sh --list published no backend adapters" + printf '%s\n' "$actual" | grep -q '^tests/' || fail "fm-lint.sh --list published no test shells" + while IFS= read -r path; do + [ -n "$path" ] || continue + [ -f "$ROOT/$path" ] || fail "fm-lint.sh --list published a path that does not exist: $path" + case "$path" in + *.sh) ;; + *) fail "fm-lint.sh --list published a non-shell path: $path" ;; + esac + done < "$winners" cat > "$fakebin/ps" <<'SH' #!/usr/bin/env bash @@ -719,32 +720,60 @@ esac SH chmod +x "$fakebin/ps" + # Every contender barrier below is bounded. An unwritable handoff directory, a + # parent that dies before publishing a pid, or a peer that exits early would + # otherwise leave all 40 contenders spinning until the CI job timeout, which + # presents as a hang instead of a diagnosed failure. + lock_await() { # + local what=$1 condition=$2 deadline + deadline=$((SECONDS + 60)) + until eval "$condition"; do + if [ "$SECONDS" -ge "$deadline" ]; then + printf 'lock-concurrency: timed out after 60s waiting for %s\n' "$what" >&2 + return 1 + fi + sleep 0.01 + done + } + pids= i=1 while [ "$i" -le 40 ]; do ( - harness_pid=$BASHPID + # Stock macOS Bash 3.2 has no $BASHPID, so the parent hands each subshell + # its own pid instead: for `( ... ) &` the parent's $! is exactly the pid + # $BASHPID would report inside. Each contender still needs a distinct, + # genuinely live pid because fm_harness_pid_alive runs a real `kill -0`. + lock_await "contender $i's pid handoff" '[ -s "$handoff/$i" ]' || exit 1 + harness_pid=$(cat "$handoff/$i") : > "$home/state/harness-$harness_pid" : > "$ready/$i" - while [ "$(find "$ready" -type f | wc -l | tr -d ' ')" -lt 40 ]; do - sleep 0.01 - done + lock_await "all 40 contenders to arm" \ + '[ "$(find "$ready" -type f | wc -l | tr -d " ")" -ge 40 ]' || exit 1 if FM_HOME="$home" FM_FAKE_LOCK_STATE="$home/state" \ FM_FAKE_HARNESS_PID="$harness_pid" PATH="$fakebin:$BASE_PATH" \ "$ROOT/bin/fm-lock.sh" >/dev/null 2>&1; then printf '%s\n' "$harness_pid" >> "$winners" fi : > "$completed/$i" - while [ "$(find "$completed" -type f | wc -l | tr -d ' ')" -lt 40 ]; do - sleep 0.01 - done + lock_await "all 40 contenders to finish" \ + '[ "$(find "$completed" -type f | wc -l | tr -d " ")" -ge 40 ]' || exit 1 ) & - pids="$pids $!" + child=$! + # Publish the pid atomically so the subshell never reads a half-written file. + printf '%s\n' "$child" > "$handoff/$i.tmp" + mv "$handoff/$i.tmp" "$handoff/$i" + pids="$pids $child" i=$((i + 1)) done + stalled=0 for pid in $pids; do - wait "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || stalled=$((stalled + 1)) done + # A contender only exits non-zero when one of its bounded barriers timed out, + # so report that harness failure instead of the misleading winner count it + # would otherwise produce. + [ "$stalled" -eq 0 ] || fail "$stalled of 40 lock contenders stalled at a barrier (see the timeout diagnostics above)" count=$(awk 'NF { count++ } END { print count + 0 }' "$winners") [ "$count" -eq 1 ] || fail "concurrent session-lock acquisition produced $count winners" diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 242407c1a32..d52bde74c0b 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -10,6 +10,10 @@ # All hermetic over temp dirs; no real agent session is invoked. set -u +# Keep every guard invocation inside its explicit fixture home even when the +# surrounding firstmate session exports operational path overrides. +unset FM_HOME FM_STATE_OVERRIDE FM_ROOT_OVERRIDE FM_CONFIG_OVERRIDE + # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" @@ -18,6 +22,10 @@ set -u TMP_ROOT=$(fm_test_tmproot fm-turnend-guard) fm_git_identity fmtest fmtest@example.invalid +# These tests dynamically import the tracked Pi TypeScript extension under +# plain Node, outside Pi's own loader. +export NODE_NO_WARNINGS=1 +fm_test_enable_node_typescript_imports REQUIRED_REASON='repair missing watcher supervision with bin/fm-watch-arm.sh as its own Claude Code background task' diff --git a/tests/lib.sh b/tests/lib.sh index ee3b1d1476c..a3e5d3e884e 100644 --- a/tests/lib.sh +++ b/tests/lib.sh @@ -50,6 +50,18 @@ pass() { printf 'ok - %s\n' "$1" } +# Enable direct imports of tracked TypeScript fixtures on Node releases that +# expose type stripping behind a flag. Newer releases strip types by default. +fm_test_enable_node_typescript_imports() { + if node --help 2>&1 | grep -q -- '--experimental-strip-types'; then + case " ${NODE_OPTIONS:-} " in + *" --experimental-strip-types "*) ;; + *) NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--experimental-strip-types" ;; + esac + export NODE_OPTIONS + fi +} + # --- self-cleaning temp root ------------------------------------------------ # # fm_test_tmproot echoes a fresh temp dir and registers it for removal