#!/bin/bash # # cond_test.sh — Regression tests for @cond argument delimitation. # # These tests pin down the fix for the bug witnessed by tst/cond_test.kt: # a defined klammer that contains its own bar separators (e.g. @frac a | b @) # nested inside a @cond branch caused @cond to miscount bars and reject the # input with "There should only be one or two bar characters". # # Root cause: @cond delimited its arguments by counting EVERY bar in its flat # katom range, conflating the inner klammer's bars (which belong to the inner # klammer's arity) with @cond's own separators. The fix counts only the bars # at nesting depth 0 within the @cond span (cond_separator_bars() in # mac/machine.cpp), so argument boundaries follow the span tree. # # See doc/cond_evaluation_order.md for the full description and the # theoretical basis (operadic arity, the precedence-order proposition, and # @cond as a non-strict special form). # # Usage: ./cond_test.sh (LSan suppressions come from env/runtime.env.*) # Exit code: 0 if all tests pass, 1 otherwise. PASS=0 FAIL=0 KTEXT=ktext K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set} red=$'\033[31m' green=$'\033[32m' bold=$'\033[1m' reset=$'\033[0m' # strip leading/trailing blank lines and surrounding whitespace trim() { awk '{ sub(/[ \t\r]+$/, "") } { line[NR]=$0 } END { f=1; while (f<=NR && line[f]=="") f++; l=NR; while (l>=1 && line[l]=="") l--; for (i=f;i<=l;i++) print line[i] }'; } # check_eq TEST_NAME EXPECTED KTEXT_ARGS... # Runs ktext, expects exit status 0, and compares trimmed stdout to EXPECTED. check_eq() { local test_name="$1" local expected="$2" shift 2 local output status output=$("$KTEXT" "$@" 2>/tmp/cond_test_err.$$) status=$? output=$(printf '%s' "$output" | trim) if [ $status -ne 0 ]; then echo "${red}FAIL${reset} $test_name — ktext exited $status" echo " stderr: $(head -3 /tmp/cond_test_err.$$)" FAIL=$((FAIL + 1)) return fi if [ "$output" = "$expected" ]; then echo "${green}PASS${reset} $test_name" PASS=$((PASS + 1)) else echo "${red}FAIL${reset} $test_name" echo " expected: [$expected]" echo " got: [$output]" FAIL=$((FAIL + 1)) fi } # check_contains TEST_NAME SUBSTRING KTEXT_ARGS... # Runs ktext, expects exit status 0, and checks that stdout contains SUBSTRING. check_contains() { local test_name="$1" local needle="$2" shift 2 local output status output=$("$KTEXT" "$@" 2>&1) status=$? if [ $status -ne 0 ]; then echo "${red}FAIL${reset} $test_name — ktext exited $status" echo " output: $(echo "$output" | head -3)" FAIL=$((FAIL + 1)) return fi if echo "$output" | grep -qF "$needle"; then echo "${green}PASS${reset} $test_name" PASS=$((PASS + 1)) else echo "${red}FAIL${reset} $test_name — expected to contain [$needle]" echo " output: $(echo "$output" | head -3)" FAIL=$((FAIL + 1)) fi } # check_error TEST_NAME PATTERN KTEXT_ARGS... # Runs ktext, expects a NONZERO exit status and PATTERN in the message. check_error() { local test_name="$1" local pattern="$2" shift 2 local output status output=$("$KTEXT" "$@" 2>&1) status=$? if [ $status -eq 0 ]; then echo "${red}FAIL${reset} $test_name — expected an error but ktext succeeded" FAIL=$((FAIL + 1)) return fi if echo "$output" | grep -qF "$pattern"; then echo "${green}PASS${reset} $test_name" PASS=$((PASS + 1)) else echo "${red}FAIL${reset} $test_name — expected error to contain [$pattern]" echo " output: $(echo "$output" | head -3)" FAIL=$((FAIL + 1)) fi } FRAC='@@frac a | b : *a*/*b* @@' # A file for the @read non-strictness cases (21-21b). READ_FIXTURE=$(mktemp /tmp/cond_read.XXXXXX) printf 'READ-FIXTURE\n' > "$READ_FIXTURE" trap 'rm -f "$READ_FIXTURE"' EXIT echo "${bold}@cond argument delimitation tests${reset}" echo "=================================" echo # --- The reported regression (defined klammer with bars inside a branch) --- check_eq \ " 1. inner klammer with bars, true branch" \ "1/2" \ -s "$FRAC @cond true | @frac 1 | 2 @ | @frac 2 | 1 @ @" -d check_eq \ " 2. inner klammer with bars, false branch" \ "2/1" \ -s "$FRAC @cond false | @frac 1 | 2 @ | @frac 2 | 1 @ @" -d # --- The exact witness file from the bug report --- check_contains \ " 3. tst/cond_test.kt @cond_klammer_test true" \ "1/2" \ "$K/tst/cond_test.kt" -d -s '@cond_klammer_test true @' check_contains \ " 4. tst/cond_test.kt @cond_klammer_test false" \ "2/1" \ "$K/tst/cond_test.kt" -d -s '@cond_klammer_test false @' # --- Plain @cond unaffected by the change --- check_eq " 5. plain two-bar, true" "yes" -s '@cond true | yes | no @' -d check_eq " 6. plain two-bar, false" "no" -s '@cond false | yes | no @' -d check_eq " 7. one-bar, true" "shown" -s '@cond true | shown @' -d check_eq " 8. one-bar, false (empty)" "" -s '@cond false | shown @' -d # --- Primitives and nesting inside @cond --- check_eq " 9. @eval in a branch" "42" -s '@cond true | @eval 6*7 @ | no @' -d check_eq "10. @eval as the predicate" "yes" -s '@cond @eval 1==1 @ | yes | no @' -d check_eq "11. nested @cond in a branch" "B" -s '@cond true | @cond false | A | B @ | C @' -d # --- A klammer with its own bars (double-bar / cells) inside a branch --- check_eq "12. klammer with internal bar in a branch" \ "x+y" \ -s '@@two a | b : *a*+*b* @@ @cond true | @two x | y @ | z @' -d # --- Genuine arity errors must still be rejected --- check_error "13. three top-level bars is still an error" \ "one or two bar characters" \ -s '@cond true | a | b | c @' -d check_error "14. zero bars is still an error" \ "one or two bar characters" \ -s '@cond true @' -d # --- The predicate relation: TOTAL AND STRICT (Andy, 2026-08-15) --- # # Deciding notes/Klammertext_improvements.md §4.1. There is a defined true # set, a defined false set, and anything else is an error AT THE @cond. It was # partial until then: is_true() recognized three strings and everything else # took the false branch, so a misspelled variable, a "TRUE", a "yes", or a # Python traceback all silently selected a branch. A warning had made that # visible while the policy was open; it never fired on the SKS, which is the # evidence that the blast radius is small. echo echo "-- the predicate relation --" for p in true True 1; do check_eq "15. \"$p\" is true" "T" -s "@cond $p | T | F @" -d done for p in false False 0; do check_eq "16. \"$p\" is false" "F" -s "@cond $p | T | F @" -d done # Empty stays FALSE, and load-bearing: an optional argument that was not # written substitutes as empty, which is what carries the "@cond *opt*" idiom. # The entangled sub-question in §4.1 -- empty means false, or means "not # supplied"? -- is answered "false" by that use. check_eq "17. an absent optional argument is false" "F" \ -s '@@g :opt : @cond *opt* | T | F @ @@ @g@' -d check_eq "17a. ... and the same argument written true is true" "T" \ -s '@@g :opt : @cond *opt* | T | F @ @@ @g :opt true @' -d check_error "18. an unrecognized predicate is an error, not false" \ "is not a truth value" \ -s '@cond yes | T | F @' -d check_error "18a. ... including a near miss of a true value" \ "is not a truth value" \ -s '@cond TRUE | T | F @' -d # A state variable in a klammer BODY works: a body is processed at application # time, after substitution, so @cond sees the value. check_eq "19. a state variable in a body reaches the @cond" "T" \ -s '@@@state Flag :value true @@@ @@g : @cond *Flag* | T | F @ @@ @g@' -d # A top-level state variable reaches the predicate too, since @cond is resolved # at APPLICATION time (notes/Klammertext_improvements.md §4.2, decided # 2026-08-15). It did not until then: a top-level @cond was resolved when the # file was READ, which is before state substitution, so it saw the literal # "*Flag*" and silently took the false branch -- the WRONG answer for a flag # whose value was true. The document now behaves like a klammer body: its # state variables are bound before its conditionals are decided. check_eq "19a. a top-level state variable reaches the @cond" "T" \ -s '@@@state Flag :value true @@@ @cond *Flag* | T | F @' -d check_eq "19b. ... and selects the false branch when it is false" "F" \ -s '@@@state Flag :value false @@@ @cond *Flag* | T | F @' -d # --- Non-strictness: nothing in a discarded branch runs --- # # doc/cond_evaluation_order.md states this ("with side-effecting @read/@eval, # wrong ... must not read the missing file"). @read honoured it; @eval did not, # because the eval pass swept the list before the cond pass did. Both honour it # now: mark_cond_content() makes a branch inert BEFORE either pass runs. check_eq "21. a @read in a discarded branch is not performed" "ok" \ -s "@cond false | @read $READ_FIXTURE @ | ok @" -d check_eq "21a. ... and IS performed when the branch is selected" "READ-FIXTURE" \ -s "@cond true | @read $READ_FIXTURE @ | ok @" -d # The case the design document names: the file need not even exist. check_eq "21b. ... so a missing file in a discarded branch is not an error" "ok" \ -s '@cond false | @read /nonexistent/no-such-file.txt @ | ok @' -d # An @eval side effect is the observable test: the branch either touched the # file or it did not. SIDE=$(mktemp -u /tmp/cond_side.XXXXXX) "$KTEXT" --klammersets none -s "@cond false | @eval :shell touch $SIDE @ | ok @" -d >/dev/null 2>&1 if [ -f "$SIDE" ]; then echo "${red}FAIL${reset} 22. an @eval in a discarded branch ran"; FAIL=$((FAIL+1)); rm -f "$SIDE" else echo "${green}PASS${reset} 22. an @eval in a discarded branch does not run"; PASS=$((PASS+1)) fi "$KTEXT" --klammersets none -s "@cond true | @eval :shell touch $SIDE @ | ok @" -d >/dev/null 2>&1 if [ -f "$SIDE" ]; then echo "${green}PASS${reset} 22a. ... and does run when the branch is selected"; PASS=$((PASS+1)); rm -f "$SIDE" else echo "${red}FAIL${reset} 22a. an @eval in the selected branch did not run"; FAIL=$((FAIL+1)) fi # The PREDICATE is always evaluated -- a conditional that could not compute its # own predicate would be useless. Only the branches are non-strict. check_eq "23. the predicate is evaluated even though the branches are not" "yes" \ -s '@cond @eval 1==1 @ | yes | no @' -d # The recognized sets are named in the message, since the whole point is that # the writer has to know what they are. check_error "20. the message names the recognized values" \ "true, True, 1" \ -s '@cond yes | T | F @' -d rm -f /tmp/cond_test_err.$$ echo echo "=================================" echo "Results: ${green}$PASS passed${reset}, ${red}$FAIL failed${reset}" [ $FAIL -eq 0 ]