#!/bin/bash # # kdiag_test.sh — the kdiag command's interface. # # kdiag is the PROGRAMMER's command in the 2026-08-14 three-command redesign: # ktext renders a document for an author, kdesc describes a klammer set for a # designer, kdiag dissects katoms and the Machine. It had no suite of its own, # which is how four defects survived in it -- including a "--check" that # reported "0 diagnostics, 0 errors" for every input there is, while every # other suite stayed green. An absent test is what let that read as a pass, so # each of the four is pinned here by OUTCOME. # # Its peculiarities, and why each is what it is: # # * Its input positional is OPTIONAL, so "kdiag --machine" answers a question # about the Machine rather than about a document. # * --klammer registers the @@ tier and --system the @@@ tier; --process does # both. A registered definition's katoms are CONSUMED, so they show up # only under --replaced -- their disappearance is the evidence. # * Extraction is TOLERANT here and nowhere else. The tiers are ordered (a # klammer names a target), so running one without the other leaves # definitions that cannot be registered. In kdiag that is not an error: # the definition is skipped and its katoms stay visible, which IS the # report. ktext must keep throwing on the same input, and the contrast is # asserted, not assumed. # * It deliberately has NO --klammersets. A klammerset arrives the way # anything else does, "@read sks/sks.k @": a debugger must not depend on # the machinery it debugs, so kdiag still runs when symbol resolution is # what broke. # # Engine tier: no klammerset is loaded, so every fixture defines what it needs # inline. The one case that wants the SKS reads it by path. # # Usage: ./kdiag_test.sh (needs KLAMMERTEXT_HOME set; kdiag and ktext on PATH) # Exit code: 0 if all tests pass, 1 otherwise. PASS=0 FAIL=0 KDIAG=kdiag KTEXT=ktext K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set} red=$'\033[31m' green=$'\033[32m' bold=$'\033[1m' reset=$'\033[0m' plain() { printf '%s' "$1" | sed 's/\x1b\[[0-9;]*m//g'; } # GNU coreutils' "timeout" is NOT present on macOS, and Homebrew's is named # "gtimeout", so a bare "timeout" made this suite fail WHOLESALE there -- every # case, because the command never ran at all (found on Olion, 2026-08-16). The # guard is a safety net against a hung command, not part of what is being # tested, so it is optional: bound the command where the tool exists, run it # directly where it does not. if command -v timeout >/dev/null 2>&1; then limited() { timeout 60 "$@"; } elif command -v gtimeout >/dev/null 2>&1; then limited() { gtimeout 60 "$@"; } else limited() { "$@"; } fi # run KDIAG_ARGS... — sets OUT (colour codes stripped) and STATUS. The status # must be taken from the command itself: a pipeline inside a command # substitution reports the SED's status, so "kdiag | plain" would call every # failure a success. Strip afterwards instead. OUT='' STATUS=0 run() { OUT=$(limited "$@" 2>&1); STATUS=$? OUT=$(plain "$OUT") } # A signal death is not an error exit: 128+n. Report it distinctly, so a # crash can never be read as a reported error. died_by_signal() { [ "$1" -gt 128 ]; } pass() { echo "${green}PASS${reset} $1"; PASS=$((PASS + 1)); } fail() { echo "${red}FAIL${reset} $1"; [ -n "$2" ] && echo " $2"; FAIL=$((FAIL + 1)); } # shows NAME PATTERN KDIAG_ARGS... — exits 0 and the output matches PATTERN. shows() { local name="$1" pattern="$2"; shift 2 run "$KDIAG" "$@" if died_by_signal "$STATUS"; then fail "$name" "died by signal $((STATUS - 128))"; return fi if [ "$STATUS" -ne 0 ]; then fail "$name" "exit $STATUS: $(printf '%s' "$OUT" | head -2)"; return fi if printf '%s' "$OUT" | grep -Eq -- "$pattern"; then pass "$name" else fail "$name" "no match for [$pattern]; got: $(printf '%s' "$OUT" | head -2)" fi } # absent NAME PATTERN KDIAG_ARGS... — exits 0 and the output does NOT match. absent() { local name="$1" pattern="$2"; shift 2 run "$KDIAG" "$@" if died_by_signal "$STATUS"; then fail "$name" "died by signal $((STATUS - 128))"; return fi if [ "$STATUS" -ne 0 ]; then fail "$name" "exit $STATUS: $(printf '%s' "$OUT" | head -2)"; return fi if printf '%s' "$OUT" | grep -Eq -- "$pattern"; then fail "$name" "unexpected match [$pattern]" else pass "$name" fi } # reports NAME PATTERN KDIAG_ARGS... — NONZERO exit, no signal, PATTERN shown. reports() { local name="$1" pattern="$2"; shift 2 run "$KDIAG" "$@" if died_by_signal "$STATUS"; then fail "$name" "died by signal $((STATUS - 128))"; return fi if [ "$STATUS" -eq 0 ]; then fail "$name" "expected a nonzero exit, got 0"; return fi if printf '%s' "$OUT" | grep -Eq -- "$pattern"; then pass "$name" else fail "$name" "no match for [$pattern]; got: $(printf '%s' "$OUT" | head -2)" fi } # A klammer definition and an application of it, with no target named: the # general target needs no @@@target, so the @@ tier can be exercised alone. GREET='@@greet name : Hello, *name*. @@ @greet World @' # Input that declares its own target AND a klammer for it. The two tiers are # ordered, so this is what tells --klammer and --system apart. OWNTARGET='@@@target foo | Foo output @@@ @@bar.foo : B @@ text' # A file for --read to pull in. READFIXTURE=$(mktemp /tmp/kdiag_read.XXXXXX) printf 'READFILE\n' > "$READFIXTURE" trap 'rm -f "$READFIXTURE"' EXIT echo "${bold}kdiag interface tests${reset}" echo "=====================" echo echo "-- the input positional is optional --" # The Machine's initial state is a question about the Machine, and the one # thing a programmer wants before feeding it anything. shows " 1. --machine with no input at all" 'Machine' --machine shows " 2. ... and shows the built-in argtypes" 'Argtypes \([0-9]+\)' --machine shows " 3. ... and the pseudo-targets" 'Targets \([0-9]+\)' --machine shows " 4. an input is still accepted" '⟨@i' --type '@i x @' # Listed under "Arguments:", so it must not be repeated under "Options:". usage=$(plain "$(limited "$KDIAG" 2>&1)") n_input=$(printf '%s\n' "$usage" | grep -c '\[\]') if [ "$n_input" = "2" ]; then pass " 5. usage lists [] once as a positional (plus the Usage: line)" else fail " 5. usage lists [] $n_input times, expected 2" \ "$(printf '%s\n' "$usage" | grep -n '\[\]')" fi echo echo "-- registration: one flag per definition tier --" # A registered definition is CONSUMED. Its disappearance from the katom # display is the evidence that it registered, and --replaced brings it back. absent " 6. --klammer consumes the @@ definition" '⟨@@greet⟩' --klammer "$GREET" shows " 7. ... and --replaced shows it again" '⟨@@greet⟩' --klammer --replaced "$GREET" shows " 8. ... and the klammer is in the registry" 'Klammers \(1\)' --klammer --machine "$GREET" absent " 9. --system consumes the @@@ definition" '⟨@@@target⟩' --system "$OWNTARGET" shows "10. ... and the target is in the registry" 'foo → ' --system --machine "$OWNTARGET" shows "11. --process registers both tiers" 'Klammers \(1\)' --process --machine "$OWNTARGET" absent "12. ... and consumes both" '⟨@@' --process "$OWNTARGET" echo echo "-- tolerant extraction: kdiag only --" # --klammer alone cannot register @@bar.foo, because the target "foo" it names # is declared by the @@@ tier this run did not process. Skipping it is the # report, not a failure -- the definition's katoms stay on screen. shows "13. --klammer alone does not fail on an unregisterable klammer" \ '⟨@@bar.foo⟩' --klammer "$OWNTARGET" absent "14. ... and does not register it either" \ 'Klammers \(1\)' --klammer --machine "$OWNTARGET" shows "15. --system alone leaves the @@ katoms visible" \ '⟨@@bar.foo⟩' --system "$OWNTARGET" # The contrast. In ktext the same input is a genuine error: a document naming # an undefined target would otherwise render wrongly and silently. # The pattern must sit on ONE line of the message: an error is wrapped for the # terminal, so "is not defined" can arrive with a newline inside it and a # line-oriented grep will never see it. run "$KTEXT" --klammersets none -s '@@bar.nosuchtarget : B @@' -d if [ "$STATUS" -ne 0 ] && printf '%s' "$OUT" | grep -q 'target "nosuchtarget"'; then pass "16. ktext still THROWS on a klammer naming an undefined target" else fail "16. ktext accepted an undefined target (exit $STATUS)" \ "$(printf '%s' "$OUT" | head -2)" fi echo echo "-- --check --" # The regression: check_machine() read machine.m_katoms, which read() fills and # process() -- how kdiag builds its katoms -- does not. So it had nothing to # check and said so, for every input there is. These two are the guard: a # clean check must be able to fail. shows "17. a correct input checks clean" '0 diagnostics' --process --check "$GREET" reports "18. an undefined klammer is found" '@nosuch is not defined' \ --process --check '@@g : @nosuch x @ @@' reports "19. ... and the body it sits in is named" 'in body of @g' \ --process --check '@@g : @nosuch x @ @@' reports "20. a wrong argument count is found" 'is given 2' \ --process --check "@@greet name : Hello, *name*. @@ @greet a | b @" # Without --klammer/--system/--process nothing is registered, so every # application is undefined. Reporting 80 spurious errors without saying why # would be worse than the old silence; the hint is part of the report. reports "21. --check alone explains why nothing is registered" \ 'No klammers are registered' --check "$GREET" reports "22. ... and names the way to fix it" '--process' --check "$GREET" # --machine must print even when the check failed: the state shown last has to # reflect everything that happened, so the nonzero exit waits for it. reports "23. --machine still prints after a failed check" \ 'Machine' --process --check --machine '@nosuch x @' echo echo "-- a klammerset reaches kdiag only as input --" # No --klammersets, deliberately: a debugger must not depend on the machinery # it debugs. The flag must be REJECTED, not silently ignored. reports "24. --klammersets is not a kdiag argument" 'argument error' \ --klammersets sks '@i x @' # ...so the advice on an undefined-klammer error must not offer it. It did # until 2026-08-15: the one remedy the error suggested was a flag this command # rejects. A wrong hint is worse than none, because it is followed. # # The trigger is a GENERATOR: an @eval whose result holds a klammer. A klammer # written literally in the input is only displayed, never applied, so nothing # reaches the catch block -- which is also why this advice went years unread. UNDEF='@eval chr(64)+"nosuch 7 "+chr(64) @' reports "24a. the error advice names a way kdiag has" '@read sks/sks\.k @' \ --process "$UNDEF" # absent_in_error NAME PATTERN ARGS... -- the pattern is missing whatever the # exit status; these run on a path that exits 1 by design. absent_in_error() { local name="$1" pattern="$2"; shift 2 run "$KDIAG" "$@" if printf '%s' "$OUT" | grep -Eq -- "$pattern"; then fail "$name" "unexpected match [$pattern]" else pass "$name" fi } absent_in_error "24b. ... and not the flag it rejects" '\-\-klammersets' \ --process "$UNDEF" # The advice is appended to the description, which is then justified to 80 # columns -- without a blank line between them the two ran together into one # word ("...unspecified targetkdiag loads no klammerset..."). absent_in_error "24c. ... and does not run onto the message" 'target[a-z]' \ --process "$UNDEF" shows "25. @read loads the SKS instead" 'Klammers \([0-9][0-9]+\)' \ "@read $K/sks/sks.k @" --process --machine shows "26. ... and the SKS then checks clean" '0 diagnostics' \ "@read $K/sks/sks.k @" --process --check # ...and the clean result above is a real one: the same input with a fault # added must still be caught, or case 26 says nothing. reports "27. ... while a fault added to it is still caught" '@nosuchklammer is not defined' \ "@read $K/sks/sks.k @ @@bad : @nosuchklammer x @ @@" --process --check echo echo "-- the katom display flags --" shows "28. --type subscripts the katom type" '⟨@i.⟩' --type '@i x @' shows "29. --index subscripts the list index" '⟨@i.' --index '@i x @' shows "30. --spans shows span endpoints" '⟨@i.⟩.*⟨@.⟩' --spans '@i x @' shows "31. --args parses positional arguments" 'required' --args --pos 2 'a | b | c' shows "32. --pos sets how many are positional" 'rest: c' --args --pos 2 'a | b | c' shows "33. --ignore removes removed text" '^a b' --ignore 'a #[gone]# b' shows "34. --all shows what was removed" '⟨gone⟩' --ignore --all 'a #[gone]# b' shows "35. --ws applies whitespace operators" 'ab' --ws 'a#- b' # A DECOMPOSED sequence: the base letter followed by U+0308 COMBINING # DIAERESIS, not the precomposed U+00FC. The diacritic FOLLOWS the base # letter -- that is the design of the ^ codes -- so the two spellings look # identical in an editor and only one of them matches. shows "36. --nonascii decodes to a combining sequence" 'ü' --nonascii '^u"' shows "37. --literal marks a literal span" 'x' --literal "^'x'^" # Evaluating needs a target: Eval::eval re-reads its result under K_target, and # kdiag has no target argument, so with the variable unset EVERY @eval died # with an argument error naming something the user never wrote. A command that # specifies no target evaluates under the GENERAL target (Andy, 2026-08-15), # which is also what kdiag means -- it loads no klammerset, so nothing # target-specific is in scope. All three eval modes are checked, because the # failure was in the shared read-back and not in any one of them. shows "38. --eval evaluates a Python expression" '^4$' --eval '@eval 2 + 2 @' shows "38a. ... and --process does too" '^4$' --process '@eval 2 + 2 @' shows "38b. ... in :shell mode" '^hi$' --process '@eval :shell echo hi @' shows "38c. ... and a module.function auto-imports" '^3$' --process '@eval len("abc") @' shows "38d. --read reads a file" 'READFILE' --read "@read $READFIXTURE @" shows "39. --cond selects a branch" 'yes' --cond '@cond true | yes | no @' shows "40. --rewrite reports a rewrite" '.' --rewrite '@i x @' shows "41. --text shows text katoms" 'x' --text '@i x @' shows "42. --ignored shows ignored katoms" '.' --ignore --ignored 'a #[gone]# b' echo echo "-- verbosity is about processing --" shows "43. -v takes a value" '.' -v 1 '@i x @' echo echo "=====================" echo "Results: ${PASS} passed, ${FAIL} failed" [ "$FAIL" -eq 0 ] || exit 1 exit 0