AtlatestRepositoryfolio
1
#!/usr/bin/env bash2
# CLI contract regression test.3
#4
# Two defects, both measured against folio 0.2.10 before the fix:5
#6
# 1. `folio <anything>` silently ignored the unknown argument and started the7
# MCP server anyway, EXITING 0. So `folio migrate-buried` -- the first8
# thing anyone types after reading about buried-id recovery -- appeared to9
# run and do nothing, and a typo in a supervisor config was invisible.10
# A silent success for an operation that did not happen.11
#12
# 2. `folio --help` required FOLIO_ROOT and exited 1 without printing help.13
# Help is what you reach for when you do not yet know how to configure the14
# thing, so requiring configuration to read it is backwards.15
#16
# The third and fourth arms are the ones that keep this fix from being worse17
# than the bug: `serve` and the real flags MUST keep working. `serve` was never18
# a subcommand -- the old loop ignored it -- but every MCP config in the estate19
# passes it, so refusing it would take the fleet's folio down.20
#21
# Exit 0 on pass, 1 on any failure, 2 if the binary could not be found.22
set -u24
FOLIO=${FOLIO:-$(dirname "$0")/../build/release/bin/folio}25
if [ ! -x "$FOLIO" ]; then26
FOLIO=$(dirname "$0")/../build/dev/bin/folio27
fi28
if [ ! -x "$FOLIO" ]; then29
echo "ERROR: no folio binary at build/release/bin/folio or build/dev/bin/folio" >&230
exit 231
fi33
ROOT=$(mktemp -d -t folio-cli.XXXXXX)34
# Unconditional, not reached-if-we-get-there: an early exit must still clean up.35
trap 'rm -rf "$ROOT"' EXIT36
printf '# Inbox\n\n- [ ] a task {id: t-cli1}\n' > "$ROOT/inbox.md"37
mkdir -p "$ROOT/projects"39
FAILED=040
pass() { echo " PASS $1"; }41
fail() { echo " FAIL $1"; FAILED=1; }43
# --- 1. an unknown argument must be REFUSED, not ignored -------------------44
# Guard with `timeout`: the defect's failure mode is that the server STARTS,45
# which would otherwise hang here forever rather than failing.46
OUT=$(timeout 10 env FOLIO_ROOT="$ROOT" "$FOLIO" migrate-buried < /dev/null 2>&1)47
RC=$?48
if [ "$RC" -eq 0 ]; then49
fail "unknown argument accepted (exit 0) -- server started instead of refusing"50
elif [ "$RC" -eq 124 ]; then51
fail "unknown argument accepted -- server started and had to be timed out"52
elif ! printf '%s' "$OUT" | grep -q 'no subcommands'; then53
fail "unknown argument refused (exit $RC) but the message does not explain why"54
else55
pass "unknown argument refused with a non-zero exit and an explanation"56
fi58
# The message must name what IS valid, or the refusal just moves the problem.59
if printf '%s' "$OUT" | grep -q -- '--web-port' && printf '%s' "$OUT" | grep -q 'USAGE'; then60
pass "refusal prints usage naming the valid arguments"61
else62
fail "refusal does not name the valid arguments"63
fi65
# Discoverability: `folio migrate-buried` is what people will type, so the66
# error has to say where recovery actually lives.67
if printf '%s' "$OUT" | grep -q 'folio/migrate-buried'; then68
pass "refusal points at the MCP tool for recovery"69
else70
fail "refusal does not mention where migrate-buried actually lives"71
fi73
# --- 2. --help must work with NO environment ------------------------------74
OUT=$(env -u FOLIO_ROOT "$FOLIO" --help 2>&1)75
RC=$?76
if [ "$RC" -ne 0 ]; then77
fail "--help exited $RC with no FOLIO_ROOT (must not need configuration)"78
elif ! printf '%s' "$OUT" | grep -q 'USAGE'; then79
fail "--help exited 0 but printed no usage"80
else81
pass "--help works with no environment"82
fi84
OUT=$(env -u FOLIO_ROOT "$FOLIO" -h 2>&1)85
if [ $? -ne 0 ] || ! printf '%s' "$OUT" | grep -q 'USAGE'; then86
fail "-h does not behave like --help"87
else88
pass "-h works with no environment"89
fi91
# --- 3. --version keeps working with no environment (positive control) -----92
# If this ever fails, the harness is broken rather than the feature: --version93
# worked before the fix and is not what the fix touched.94
OUT=$(env -u FOLIO_ROOT "$FOLIO" --version 2>&1)95
if [ $? -ne 0 ] || ! printf '%s' "$OUT" | grep -q '^folio '; then96
fail "--version regressed (positive control -- suspect the harness)"97
else98
pass "--version still works with no environment"99
fi101
# --- 4. FLEET-VECTOR ARMS. DO NOT PRUNE THESE AS REDUNDANT. ---------------102
#103
# The arms in this section and in section 8 exercise REAL invocations taken104
# from live configs, not synthetic defect cases. They look redundant next to105
# the defect arms above. They are the opposite of redundant, and here is the106
# measured reason:107
#108
# While hardening this CLI, the port validator was written with `exact?`,109
# which is UNBOUND in sigil. It COMPILED CLEAN and failed at RUNTIME on110
# every --web-port invocation -- i.e. on launch-server.sh's live vector.111
# Every defect-case arm still passed. Only running a real fleet vector112
# caught it.113
#114
# A build that succeeds is not a code path that has run. These arms are the115
# only thing here that executes the paths the estate actually uses.116
#117
# --- THE FLEET MUST NOT BREAK ---------------------------------------------118
# `serve` plus the logging flags are what every MCP config passes. Measured119
# 2026-08-11: 15 live processes use `serve --log FILE --log-level LEVEL`.120
# With stdin closed the server reads EOF and shuts down cleanly, so ACCEPTED121
# looks like exit 0 with no usage text; REFUSED looks like exit 1 with usage.122
OUT=$(timeout 10 env FOLIO_ROOT="$ROOT" "$FOLIO" serve --log "$ROOT/f.log" --log-level trace \123
< /dev/null 2>&1)124
RC=$?125
if [ "$RC" -eq 0 ] && ! printf '%s' "$OUT" | grep -q 'USAGE'; then126
pass "serve + --log + --log-level accepted (clean startup and shutdown)"127
else128
fail "serve + logging flags rejected (exit $RC) -- this would break every MCP config"129
fi131
# NOTE ON THE ASSERTION ABOVE. The first version of this arm expected exit 124132
# (timeout), reasoning that a server which starts never exits. That was wrong and133
# it failed on BOTH the pre-fix and post-fix binaries: with stdin closed the MCP134
# server reads EOF and shuts down cleanly, exiting 0. Caught by running the arm135
# against the pre-fix binary and reading the failure rather than assuming a red136
# there meant the arm was working. This arm is a REGRESSION GUARD, not a defect137
# detector -- it must pass on both binaries, because `serve` worked before the138
# fix and must keep working after it.140
# A value-flag with its value missing must be refused, not silently dropped.141
OUT=$(timeout 10 env FOLIO_ROOT="$ROOT" "$FOLIO" --web-port < /dev/null 2>&1)142
RC=$?143
if [ "$RC" -ne 0 ] && [ "$RC" -ne 124 ]; then144
pass "--web-port with no value refused"145
else146
fail "--web-port with no value not refused (exit $RC)"147
fi149
# --- 5. --version / --help MUST NOT BYPASS VALIDATION ----------------------150
# Both are `member` scans over the whole command line. When they ran BEFORE151
# validation, any unknown argument was waved through if one of them appeared152
# anywhere: `folio migrate-buried --version` printed the version and exited 0 --153
# the exact silent success this validation exists to kill, reintroduced by the154
# ordering of the fix for it. Found by adversarial review, uncovered until now.155
for COMBO in "migrate-buried --version" "migrate-buried -h" "--version migrate-buried"; do156
# shellcheck disable=SC2086157
OUT=$(timeout 10 env FOLIO_ROOT="$ROOT" "$FOLIO" $COMBO < /dev/null 2>&1)158
RC=$?159
if [ "$RC" -eq 0 ]; then160
fail "'folio $COMBO' exited 0 -- unknown argument bypassed validation"161
else162
pass "'folio $COMBO' refused"163
fi164
done166
# --- 6. VALIDATION MUST AGREE WITH THE PARSERS THAT DRIVE BEHAVIOUR --------167
# `folio --log --web` was accepted as "--log with value --web"; find-web-port168
# then saw --web and started an HTTP server nobody asked for, and the logger169
# wrote to a file named `--web`. Exit 0, no diagnostic. A validator that blesses170
# an invocation the program then reads differently is worse than none.171
# Assert a REFUSAL, not merely a non-zero exit: without the fix this invocation172
# starts a web server and is killed by `timeout` (124), which a bare non-zero173
# check would score as a pass for entirely the wrong reason.174
OUT=$(timeout 10 env FOLIO_ROOT="$ROOT" "$FOLIO" --log --web < /dev/null 2>&1)175
RC=$?176
if [ "$RC" -ne 0 ] && [ "$RC" -ne 124 ] && printf '%s' "$OUT" | grep -q 'requires a value'; then177
pass "a value-flag swallowing an option is refused"178
elif [ "$RC" -eq 124 ]; then179
fail "'--log --web' started a server (timed out) -- validator disagrees with find-web-port"180
else181
fail "'--log --web' not refused with an explanation (exit $RC)"182
fi184
# Run this probe with cwd INSIDE the throwaway root, so a file created by the185
# defect lands there and is cleaned up with it. The first version checked `./`186
# and reported a stale `--web` left in the repo by an earlier run against an187
# older binary -- a true finding about the wrong build, which is its own kind of188
# false green. Scope a side-effect check to the run that could cause it.189
( cd "$ROOT" && timeout 10 env FOLIO_ROOT="$ROOT" "$FOLIO" --log --web < /dev/null > /dev/null 2>&1 )190
if [ -e "$ROOT/--web" ]; then191
fail "a file named '--web' was created -- the option was consumed as a value"192
else193
pass "no file created from an option mistaken for a value"194
fi196
# An unknown log level used to be accepted and silently demoted to info.197
OUT=$(timeout 10 env FOLIO_ROOT="$ROOT" "$FOLIO" --log-level tarce < /dev/null 2>&1)198
if [ $? -eq 0 ]; then199
fail "unknown --log-level accepted (silently demoted rather than refused)"200
else201
pass "unknown --log-level refused"202
fi204
# A non-numeric port used to pass validation and fail only AFTER the index was205
# opened -- refusing after acting is not refusing.206
OUT=$(timeout 10 env FOLIO_ROOT="$ROOT" "$FOLIO" --web-port abc < /dev/null 2>&1)207
if [ $? -eq 0 ]; then208
fail "--web-port abc accepted"209
else210
pass "--web-port with a non-numeric value refused"211
fi213
# --- 7. STREAMS. Redirected INDEPENDENTLY, because 2>&1 cannot see this ----214
# Every other arm captures with 2>&1 and is therefore blind to which stream215
# anything went to. A stub printing help to stderr and the refusal to stdout216
# passed all of them. Usage on stdout would pollute the MCP stdio channel.217
if [ -n "$(env -u FOLIO_ROOT "$FOLIO" --help 2>/dev/null)" ]; then218
pass "--help prints to stdout"219
else220
fail "--help prints nothing to stdout"221
fi223
if [ -z "$(env -u FOLIO_ROOT "$FOLIO" --help 2>&1 1>/dev/null)" ]; then224
pass "--help prints nothing to stderr"225
else226
fail "--help writes to stderr (would pollute the MCP stdio channel)"227
fi229
if [ -n "$(timeout 10 env FOLIO_ROOT="$ROOT" "$FOLIO" bogus < /dev/null 2>&1 1>/dev/null)" ]; then230
pass "refusal prints to stderr"231
else232
fail "refusal prints nothing to stderr"233
fi235
if [ -z "$(timeout 10 env FOLIO_ROOT="$ROOT" "$FOLIO" bogus < /dev/null 2>/dev/null)" ]; then236
pass "refusal prints nothing to stdout"237
else238
fail "refusal writes usage to stdout"239
fi241
# --- 8. THE OTHER LIVE FLEET VECTOR: --web-port WITH a value --------------242
# `serve --web-port 3737 --log F --log-level trace` is what launch-server.sh243
# passes. The earlier --web-port arm only covers the MISSING-value case.244
OUT=$(timeout 10 env FOLIO_ROOT="$ROOT" "$FOLIO" serve --web-port 3737 \245
--log "$ROOT/w.log" --log-level trace < /dev/null 2>&1)246
RC=$?247
if [ "$RC" -eq 0 ] && ! printf '%s' "$OUT" | grep -q 'USAGE'; then248
pass "serve + --web-port VALUE + logging flags accepted (live fleet vector)"249
else250
fail "serve + --web-port 3737 rejected (exit $RC) -- breaks launch-server.sh"251
fi253
# --- 9. --stdio is a compatibility no-op, and must stay one ---------------254
# Many MCP clients pass it. folio speaks stdio anyway, so it must be accepted255
# and ignored: refusing it breaks such a client for no safety gain.256
OUT=$(timeout 10 env FOLIO_ROOT="$ROOT" "$FOLIO" serve --stdio < /dev/null 2>&1)257
RC=$?258
if [ "$RC" -eq 0 ] && ! printf '%s' "$OUT" | grep -q 'USAGE'; then259
pass "--stdio accepted and ignored"260
else261
fail "--stdio rejected (exit $RC) -- breaks MCP clients that pass it"262
fi264
# --- 10. --flag=value must be REFUSED WITH THE CORRECTION -----------------265
# folio cannot honour this spelling: log-configure-from-args! matches with266
# (equal? ...) and find-web-port with (string=? ...), neither splitting on '='.267
# Accepting it would mean the flag is silently ignored by the code that268
# consumes it -- a validator/consumer divergence, the defect this file exists269
# to prevent. So it must refuse, and the message must say how to write it.270
for PAIR in "--log-level=trace|--log-level trace" "--web-port=8080|--web-port 8080"; do271
ARG=${PAIR%%|*}; WANT=${PAIR##*|}272
OUT=$(timeout 10 env FOLIO_ROOT="$ROOT" "$FOLIO" "$ARG" < /dev/null 2>&1)273
RC=$?274
if [ "$RC" -eq 0 ]; then275
fail "'$ARG' accepted -- it would be silently ignored downstream"276
elif printf '%s' "$OUT" | grep -qF -- "$WANT"; then277
pass "'$ARG' refused, and the message gives the correct spelling"278
else279
fail "'$ARG' refused but the message does not show '$WANT'"280
fi281
done283
if [ "$FAILED" -eq 0 ]; then284
echo "CLI-CONTRACT-OK"285
exit 0286
fi287
echo "CLI-CONTRACT-FAILED"288
exit 1