diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a92f871..4f3c0af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,31 @@ jobs: - name: Hook tests run: bash hooks/tests/test-hooks.sh + # GIT-116: the same suite on stock macOS. The runner has Homebrew (and may + # have coreutils' timeout), so the suite runs with the system directories + # plus a directory holding only node — what a Mac without Homebrew has. + hooks-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Hook tests on a stock macOS PATH (no Homebrew, no timeout) + run: | + mkdir -p "$RUNNER_TEMP/nodeonly" + ln -s "$(command -v node)" "$RUNNER_TEMP/nodeonly/node" + STOCK="/usr/bin:/bin:/usr/sbin:/sbin:$RUNNER_TEMP/nodeonly" + for t in timeout gtimeout; do + if PATH="$STOCK" command -v "$t" >/dev/null; then echo "::error::$t is on the stock PATH"; exit 1; fi + done + # macOS 15+ ships /usr/bin/jq; older macOS does not. The jq-less path + # is covered by hooks-without-jq (Linux); report which this runner is. + PATH="$STOCK" command -v jq >/dev/null && echo "stock jq present: $(PATH="$STOCK" jq --version)" || echo "stock jq absent" + env -i HOME="$HOME" PATH="$STOCK" /bin/bash hooks/tests/test-hooks.sh + # GIT-83: the release gate. Customers never run SQL after setup (standing # rule, 2026-09-21), so a release must work on a store still on the v1.8.0 # setup.sql — the venue is put on exactly that schema first, then the @@ -164,7 +189,7 @@ jobs: retention-days: 14 publish: - needs: [build, hooks-without-jq, venue-gate] + needs: [build, hooks-without-jq, hooks-macos, venue-gate] runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/v') permissions: diff --git a/hooks/scripts/auto-retrieve-hook.sh b/hooks/scripts/auto-retrieve-hook.sh index 7bdfabb..d1befad 100755 --- a/hooks/scripts/auto-retrieve-hook.sh +++ b/hooks/scripts/auto-retrieve-hook.sh @@ -36,7 +36,20 @@ HOOK_INPUT=$(cat -) # Extract prompt from hook input # ============================================================================ -# Use node if available, fallback to basic extraction +# GIT-116: a missing tool is a visible, non-blocking hook error (exit 1 with a +# reason on stderr; exit 2 would block the prompt), never a silent "nothing +# relevant". Retrieval needs node, which the MCP server needs anyway — but the +# hook runs with the launcher's PATH, where an nvm/volta node may be absent. +gitmem_unavailable() { + echo "gitmem auto-retrieve is not running: $1" >&2 + exit 1 +} + +if ! command -v node &>/dev/null; then + gitmem_unavailable "node is not on the hook's PATH (PATH=$PATH)" +fi + +# Extract the prompt (node is required; checked above) PROMPT="" if command -v node &>/dev/null; then PROMPT=$(echo "$HOOK_INPUT" | node -e " @@ -134,10 +147,6 @@ fi # Invoke quick-retrieve (Node module) # ============================================================================ -# Require node for retrieval -if ! command -v node &>/dev/null; then - exit 0 -fi # Locate quick-retrieve.js relative to plugin root # Hook scripts are at: ${CLAUDE_PLUGIN_ROOT}/scripts/ @@ -150,13 +159,17 @@ if [ ! -f "$QUICK_RETRIEVE" ]; then QUICK_RETRIEVE="${CLAUDE_PLUGIN_ROOT}/../dist/hooks/quick-retrieve.js" fi -# If still not found, try the gitmem package location +# Hooks copied into /.gitmem/hooks (init, install-hooks) have no dist/ +# beside them, so look where the package itself is installed. GIT-116: this +# searched for a package called "gitmem"; it is "gitmem-mcp". Most installs run +# it through npx, so npx's cache is searched too (newest first). if [ ! -f "$QUICK_RETRIEVE" ]; then - # Check common install locations + REL="gitmem-mcp/dist/hooks/quick-retrieve.js" + BIN="$(command -v gitmem-mcp 2>/dev/null || true)" for CANDIDATE in \ - "/workspace/gitmem/dist/hooks/quick-retrieve.js" \ - "$(npm root -g 2>/dev/null)/gitmem/dist/hooks/quick-retrieve.js" \ - "$(dirname "$(which gitmem 2>/dev/null)")/../lib/node_modules/gitmem/dist/hooks/quick-retrieve.js"; do + ${BIN:+"$(dirname "$BIN")/../lib/node_modules/$REL"} \ + $(ls -1t "$HOME"/.npm/_npx/*/node_modules/$REL 2>/dev/null) \ + "$(npm root -g 2>/dev/null)/$REL"; do if [ -f "$CANDIDATE" ]; then QUICK_RETRIEVE="$CANDIDATE" break @@ -165,13 +178,13 @@ if [ ! -f "$QUICK_RETRIEVE" ]; then fi if [ ! -f "$QUICK_RETRIEVE" ]; then - # Can't find quick-retrieve module — fail open - exit 0 + gitmem_unavailable "quick-retrieve.js not found next to the hook, in npx's cache, or in the global node_modules" fi -# Call quick-retrieve with prompt and level -# Timeout: 2.5s (leave 500ms buffer within 3s hook timeout) -RESULT=$(timeout 2.5 node "$QUICK_RETRIEVE" "$PROMPT" "$RETRIEVAL_LEVEL" 2>/dev/null) || true +# Call quick-retrieve with prompt and level. It bounds its own run (2.5 s, +# inside the 3 s hook budget): GIT-116 dropped the `timeout` wrapper, which +# macOS does not ship. +RESULT=$(node "$QUICK_RETRIEVE" "$PROMPT" "$RETRIEVAL_LEVEL" 2>/dev/null) || true # Empty result → nothing relevant found if [ -z "$RESULT" ]; then diff --git a/hooks/scripts/credential-guard.sh b/hooks/scripts/credential-guard.sh index 83361d3..0ab006f 100755 --- a/hooks/scripts/credential-guard.sh +++ b/hooks/scripts/credential-guard.sh @@ -111,6 +111,10 @@ if [ "$TOOL_NAME" = "Read" ]; then # Block reading known credential files if echo "$BASENAME" | grep -qEi "$CREDENTIAL_FILES_PATTERN"; then + # GIT-116: escape for the JSON below. A quote or backslash in the path + # made the output invalid JSON, which Claude Code treats as "allow". + FILE_PATH=$(printf '%s' "$FILE_PATH" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g') + BASENAME=$(printf '%s' "$BASENAME" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g') cat </dev/null && return 0 + if [ -d /proc/self ]; then + [ -d "/proc/$1" ] && return 0 || return 1 + fi + if command -v ps >/dev/null 2>&1; then + ps -p "$1" >/dev/null 2>&1 && return 0 || return 1 + fi local out - out=$(kill -0 "$1" 2>&1) && return 0 + out=$(LC_ALL=C kill -0 "$1" 2>&1) case "$out" in *ermitted*) return 0 ;; esac return 1 } @@ -59,7 +68,8 @@ gitmem_pid_alive() { gitmem_live_session_ids() { [ -f "$GITMEM_ACTIVE_SESSIONS" ] || return 0 local host rows - host=$(hostname 2>/dev/null || echo "") + # bash's own HOSTNAME is gethostname(), what the server's os.hostname() is. + host="${HOSTNAME:-$(hostname 2>/dev/null || echo "")}" if command -v jq &>/dev/null; then rows=$(jq -r '(.sessions // []) | sort_by(.started_at // "") | reverse | .[] | [(.session_id // ""), ((.pid // "") | tostring), (.hostname // "")] | join("|")' \ diff --git a/hooks/scripts/session-start.sh b/hooks/scripts/session-start.sh index 8e11fbc..7a59048 100755 --- a/hooks/scripts/session-start.sh +++ b/hooks/scripts/session-start.sh @@ -7,13 +7,16 @@ set -e -# Debug logging — verify hook actually fires -PLUGIN_LOG="/tmp/gitmem-hooks-plugin-debug.log" -echo "[$(date)] PLUGIN SessionStart hook invoked" >> "$PLUGIN_LOG" +# Debug logging — verify hook actually fires. GIT-116: per user and in +# $TMPDIR (macOS gives each user its own), and never fatal: under `set -e` an +# unwritable log — e.g. /tmp/…debug.log created by another user — used to kill +# the whole SessionStart hook. +PLUGIN_LOG="${TMPDIR:-/tmp}/gitmem-hooks-plugin-debug-$(id -u).log" +echo "[$(date)] PLUGIN SessionStart hook invoked" >> "$PLUGIN_LOG" 2>/dev/null || true # Read hook input from stdin HOOK_INPUT=$(cat -) -echo "[$(date)] Input: $HOOK_INPUT" >> "$PLUGIN_LOG" +echo "[$(date)] Input: $HOOK_INPUT" >> "$PLUGIN_LOG" 2>/dev/null || true # ============================================================================ # Detect gitmem MCP server @@ -98,8 +101,7 @@ fi if [ "$GITMEM_DETECTED" = "false" ]; then for SERVER_PATH in \ "${GITMEM_SERVER_PATH:-}" \ - "$(which gitmem-mcp 2>/dev/null || echo '')" \ - "$(which gitmem 2>/dev/null || echo '')"; do + "$(command -v gitmem-mcp 2>/dev/null || echo '')"; do if [ -n "$SERVER_PATH" ] && [ -f "$SERVER_PATH" ]; then GITMEM_DETECTED=true DETECT_SOURCE="binary $SERVER_PATH" @@ -122,13 +124,13 @@ fi # MCP handshake completion. Total budget: ~8s (within 10s hook timeout). if [ "$GITMEM_DETECTED" = "true" ]; then - GATE_START=$(date +%s%N 2>/dev/null || date +%s) + GATE_START=$(date +%s) MAX_WAIT_SECS=7 POLL_INTERVAL=0.3 HANDSHAKE_BUFFER=0.5 SERVER_FOUND=false - echo "[$(date)] MCP readiness gate: waiting up to ${MAX_WAIT_SECS}s for gitmem server process..." >> "$PLUGIN_LOG" + echo "[$(date)] MCP readiness gate: waiting up to ${MAX_WAIT_SECS}s for gitmem server process..." >> "$PLUGIN_LOG" 2>/dev/null || true # Build list of patterns to match the gitmem server process # Covers: direct node invocation, npx, and symlinked binaries @@ -140,12 +142,19 @@ if [ "$GITMEM_DETECTED" = "true" ]; then # Poll every 0.3s. Max iterations = MAX_WAIT_SECS / 0.3 ≈ 23 MAX_POLLS=$(( MAX_WAIT_SECS * 10 / 3 )) POLL=0 + # GIT-116: pgrep ships with macOS and procps, but not with every slim + # image. Without it every poll failed and the gate stalled for its full + # 7 s before continuing; skip the wait instead, and say so. + if ! command -v pgrep >/dev/null 2>&1; then + echo "[$(date)] MCP readiness gate: pgrep not available — not waiting for the server process" >> "$PLUGIN_LOG" 2>/dev/null || true + MAX_POLLS=0 + fi while [ "$POLL" -lt "$MAX_POLLS" ]; do for PATTERN in "${GITMEM_PATTERNS[@]}"; do if pgrep -f "$PATTERN" > /dev/null 2>&1; then SERVER_FOUND=true WAIT_SECS=$(( POLL * 3 / 10 )) - echo "[$(date)] MCP readiness gate: gitmem process found (pattern: $PATTERN) after ~${WAIT_SECS}.$(( POLL * 3 % 10 ))s" >> "$PLUGIN_LOG" + echo "[$(date)] MCP readiness gate: gitmem process found (pattern: $PATTERN) after ~${WAIT_SECS}.$(( POLL * 3 % 10 ))s" >> "$PLUGIN_LOG" 2>/dev/null || true # Buffer for MCP protocol handshake to complete sleep "$HANDSHAKE_BUFFER" break 2 @@ -156,11 +165,11 @@ if [ "$GITMEM_DETECTED" = "true" ]; then done if [ "$SERVER_FOUND" = "false" ]; then - echo "[$(date)] MCP readiness gate: gitmem process NOT found after ${MAX_WAIT_SECS}s — proceeding anyway (config detected, server may use different process name)" >> "$PLUGIN_LOG" + echo "[$(date)] MCP readiness gate: gitmem process NOT found after ${MAX_WAIT_SECS}s — proceeding anyway (config detected, server may use different process name)" >> "$PLUGIN_LOG" 2>/dev/null || true fi - GATE_END=$(date +%s%N 2>/dev/null || date +%s) - echo "[$(date)] MCP readiness gate completed" >> "$PLUGIN_LOG" + GATE_END=$(date +%s) + echo "[$(date)] MCP readiness gate completed" >> "$PLUGIN_LOG" 2>/dev/null || true fi # ============================================================================ @@ -179,7 +188,7 @@ echo "0" > "$STATE_DIR/last_nag_time" # ============================================================================ if [ "$GITMEM_DETECTED" = "true" ]; then - echo "[$(date)] Gitmem DETECTED via ${DETECT_SOURCE} — instructing agent to call session_start via MCP" >> "$PLUGIN_LOG" + echo "[$(date)] Gitmem DETECTED via ${DETECT_SOURCE} — instructing agent to call session_start via MCP" >> "$PLUGIN_LOG" 2>/dev/null || true # Instruct agent to call session_start via MCP. # The MCP server creates sessions with its own PID, enabling multi-session @@ -197,7 +206,7 @@ if [ "$GITMEM_DETECTED" = "true" ]; then fi if [ -n "$GITMEM_PROJECT" ]; then - echo "[$(date)] Project from config: ${GITMEM_PROJECT}" >> "$PLUGIN_LOG" + echo "[$(date)] Project from config: ${GITMEM_PROJECT}" >> "$PLUGIN_LOG" 2>/dev/null || true cat <> "$PLUGIN_LOG" + echo "[$(date)] Gitmem NOT detected (checked: project .mcp.json, mcp-config, ~/.claude.json, disk)" >> "$PLUGIN_LOG" 2>/dev/null || true echo "GITMEM PLUGIN: GitMem MCP server not detected. Session lifecycle hooks are inactive. To enable, ensure gitmem is configured via --mcp-config, project .mcp.json, or set GITMEM_ENABLED=true." fi -echo "[$(date)] Hook completed, exit 0" >> "$PLUGIN_LOG" +echo "[$(date)] Hook completed, exit 0" >> "$PLUGIN_LOG" 2>/dev/null || true exit 0 diff --git a/hooks/tests/test-hooks.sh b/hooks/tests/test-hooks.sh index 95d5df8..0e24960 100755 --- a/hooks/tests/test-hooks.sh +++ b/hooks/tests/test-hooks.sh @@ -726,6 +726,92 @@ GOT=$(bash -c '. "$0/scripts/resolve-root.sh"; gitmem_pid_alive 1 && echo alive' remove_session_registry +# ============================================================================ +# TEST GROUP: portability (GIT-116) +# ============================================================================ + +echo "" +echo -e "${YELLOW}=== Portability (GIT-116) ===${NC}" + +# A PATH holding every command on the current PATH except the ones named. +path_without() { + local out="$1"; shift + mkdir -p "$out" + local d f n old_ifs="$IFS" + IFS=: + for d in $PATH; do + [ -d "$d" ] || continue + for f in "$d"/*; do + n=$(basename "$f") + [ -x "$f" ] && [ ! -e "$out/$n" ] && ln -s "$f" "$out/$n" 2>/dev/null || true + done + done + IFS="$old_ifs" + for n in "$@"; do rm -f "$out/$n"; done + echo "$out" +} + +# A fake installed package: hooks/scripts next to dist/hooks/quick-retrieve.js. +PKG="$TMPDIR/pkg" +mkdir -p "$PKG/hooks/scripts" "$PKG/dist/hooks" +cp "$SCRIPT_DIR"/scripts/*.sh "$PKG/hooks/scripts/" +cat > "$PKG/dist/hooks/quick-retrieve.js" <<'STUB' +process.stdout.write("STUB-SCAR for: " + process.argv[2]); +STUB +PROMPT_JSON='{"prompt":"add a retry to the deploy script before the migration"}' + +NO_TIMEOUT_PATH=$(path_without "$TMPDIR/bin-no-timeout" timeout gtimeout) +OUT=$(echo "$PROMPT_JSON" | PATH="$NO_TIMEOUT_PATH" bash "$PKG/hooks/scripts/auto-retrieve-hook.sh" 2>/dev/null) || true +echo "$OUT" | grep -q "STUB-SCAR" && pass "auto-retrieve works with no timeout/gtimeout on PATH (stock macOS)" \ + || fail "auto-retrieve without timeout" "STUB-SCAR in additionalContext" "$OUT" + +NO_NODE_PATH=$(path_without "$TMPDIR/bin-no-node" node) +set +e +ERR=$(echo "$PROMPT_JSON" | PATH="$NO_NODE_PATH" bash "$PKG/hooks/scripts/auto-retrieve-hook.sh" 2>&1 >/dev/null); CODE=$? +set -e +[ "$CODE" = "1" ] && echo "$ERR" | grep -q "node is not on the hook's PATH" \ + && pass "no node: visible non-blocking error (exit 1, reason on stderr)" \ + || fail "no node visible" "exit 1 + reason" "exit=$CODE err=$ERR" + +# Hooks copied into a repo (init / install-hooks): no dist beside them. +COPIED="$TMPDIR/repo/.gitmem/hooks"; mkdir -p "$COPIED"; cp "$SCRIPT_DIR"/scripts/*.sh "$COPIED/" +EMPTY_HOME="$TMPDIR/empty-home"; mkdir -p "$EMPTY_HOME" +NO_PKG_PATH=$(path_without "$TMPDIR/bin-no-pkg" gitmem-mcp npm) +set +e +ERR=$(echo "$PROMPT_JSON" | HOME="$EMPTY_HOME" PATH="$NO_PKG_PATH" bash "$COPIED/auto-retrieve-hook.sh" 2>&1 >/dev/null); CODE=$? +set -e +[ "$CODE" = "1" ] && echo "$ERR" | grep -q "quick-retrieve.js not found" \ + && pass "module not found: visible non-blocking error, not a silent 'nothing relevant'" \ + || fail "module not found visible" "exit 1 + reason" "exit=$CODE err=$ERR" + +NPX_HOME="$TMPDIR/npx-home" +mkdir -p "$NPX_HOME/.npm/_npx/abc123/node_modules/gitmem-mcp/dist/hooks" +cp "$PKG/dist/hooks/quick-retrieve.js" "$NPX_HOME/.npm/_npx/abc123/node_modules/gitmem-mcp/dist/hooks/" +OUT=$(echo "$PROMPT_JSON" | HOME="$NPX_HOME" PATH="$NO_PKG_PATH" bash "$COPIED/auto-retrieve-hook.sh" 2>/dev/null) || true +echo "$OUT" | grep -q "STUB-SCAR" && pass "copied hooks find gitmem-mcp in npx's cache" \ + || fail "npx cache lookup" "STUB-SCAR" "$OUT" + +# session-start: no pgrep must not stall the 10 s hook for the 7 s gate. +NO_PGREP_PATH=$(path_without "$TMPDIR/bin-no-pgrep" pgrep) +T0=$(date +%s) +OUT=$(echo '{}' | GITMEM_ENABLED=true PATH="$NO_PGREP_PATH" bash "$SCRIPT_DIR/scripts/session-start.sh" 2>/dev/null) || true +ELAPSED=$(( $(date +%s) - T0 )) +[ "$ELAPSED" -lt 4 ] && echo "$OUT" | grep -q "SESSION START" \ + && pass "session-start without pgrep does not stall (${ELAPSED}s)" \ + || fail "session-start without pgrep" "<4s and SESSION START" "${ELAPSED}s: $OUT" + +# session-start: an unwritable debug log must not kill the hook under set -e. +BAD_TMP="$TMPDIR/bad-tmp"; mkdir -p "$BAD_TMP/gitmem-hooks-plugin-debug-$(id -u).log" +OUT=$(echo '{}' | GITMEM_ENABLED=true TMPDIR="$BAD_TMP" PATH="$NO_PGREP_PATH" bash "$SCRIPT_DIR/scripts/session-start.sh" 2>/dev/null) || true +echo "$OUT" | grep -q "SESSION START" && pass "session-start survives an unwritable debug log" \ + || fail "unwritable debug log" "SESSION START" "$OUT" + +# credential-guard: a quote or backslash in the path must still produce JSON that blocks. +OUT=$(printf '{"tool_name":"Read","tool_input":{"file_path":"/x/we\\"ird\\\\d/.env"}}' | bash "$SCRIPT_DIR/scripts/credential-guard.sh" 2>/dev/null) || true +echo "$OUT" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{process.exit(JSON.parse(d).decision==="block"?0:1)})' 2>/dev/null \ + && pass "credential-guard block is valid JSON for a path with quotes" \ + || fail "credential-guard JSON" "valid JSON, decision block" "$OUT" + # ============================================================================ # Summary # ============================================================================ diff --git a/src/hooks/quick-retrieve.ts b/src/hooks/quick-retrieve.ts index c414b33..41cfc4b 100644 --- a/src/hooks/quick-retrieve.ts +++ b/src/hooks/quick-retrieve.ts @@ -148,12 +148,37 @@ function toFormattable(scar: RelevantScar): FormattableScar { }; } +// --- Time bound (GIT-116) --- +// The UserPromptSubmit hook has a 3 s budget. The hook used to enforce it with +// `timeout 2.5 node …`, but macOS ships no `timeout`, so on a stock Mac the +// call failed, the error was discarded and nothing was ever retrieved. The +// bound lives here now: node is the one tool the hook already requires. + +export const QUICK_RETRIEVE_DEADLINE_MS = 2500; + +/** + * Exit quietly (no context injected) once `ms` have passed. unref'd, so a + * run that finishes first is not held open by the timer. + */ +export function armDeadline( + ms: number, + onExpire: () => void = () => { + process.stderr.write(`gitmem quick-retrieve: no result within ${ms} ms; nothing injected\n`); + process.exit(0); + } +): ReturnType { + const timer = setTimeout(onExpire, ms); + timer.unref?.(); + return timer; +} + // --- CLI entry point --- // Called from hook: node dist/hooks/quick-retrieve.js [token_budget] const isMainModule = process.argv[1]?.endsWith("quick-retrieve.js"); if (isMainModule) { + armDeadline(Number(process.env.GITMEM_QUICK_RETRIEVE_DEADLINE_MS) || QUICK_RETRIEVE_DEADLINE_MS); const prompt = process.argv[2]; const level = process.argv[3] || "scars"; const budget = process.argv[4] ? parseInt(process.argv[4], 10) : undefined; diff --git a/tests/unit/hooks/quick-retrieve-deadline.test.ts b/tests/unit/hooks/quick-retrieve-deadline.test.ts new file mode 100644 index 0000000..2d34ef1 --- /dev/null +++ b/tests/unit/hooks/quick-retrieve-deadline.test.ts @@ -0,0 +1,41 @@ +/** + * GIT-116: quick-retrieve bounds its own run. The hook used to wrap it in + * `timeout 2.5`, which macOS does not ship, so on a stock Mac nothing was + * ever retrieved. + */ +import { describe, it, expect, vi, afterEach } from "vitest"; +import * as fs from "fs"; +import * as path from "path"; +import { armDeadline, QUICK_RETRIEVE_DEADLINE_MS } from "../../../src/hooks/quick-retrieve.js"; + +afterEach(() => { vi.useRealTimers(); }); + +describe("quick-retrieve deadline (GIT-116)", () => { + it("fires after the budget and not before", () => { + vi.useFakeTimers(); + const expire = vi.fn(); + armDeadline(100, expire); + vi.advanceTimersByTime(99); + expect(expire).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(expire).toHaveBeenCalledOnce(); + }); + + it("does not keep a finished run alive (unref'd)", () => { + const timer = armDeadline(60_000, () => {}); + expect(timer.hasRef()).toBe(false); + clearTimeout(timer); + }); + + it("leaves 500 ms of the hook's 3 s budget", () => { + expect(QUICK_RETRIEVE_DEADLINE_MS).toBe(2500); + }); + + it("no hook script calls timeout/gtimeout any more", () => { + const dir = path.resolve(__dirname, "../../../hooks/scripts"); + for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".sh"))) { + const code = fs.readFileSync(path.join(dir, f), "utf-8").split("\n").filter((l) => !l.trim().startsWith("#")); + expect(code.filter((l) => /(^|[\s;(|&`$])g?timeout\s+[0-9]/.test(l)), f).toEqual([]); + } + }); +});