An output policy for the three commands, and @cond as a true special form

A snapshot of the development tree.  The substantial changes since the last one:

COMMAND OUTPUT POLICY.  The three commands display text in exactly three cases,
and each owns a stream: LOGGING under "-v" greater than 0 and an ERROR before
termination go to STDERR; OUTPUT THE USER ASKED FOR goes to STDOUT.  For ktext
that output is a document, so "ktext doc.kt -d | ..." is now safe -- logging
used to share the stream and land inside the document.  A bare command prints
its usage and succeeds rather than failing.  Colour is emitted only to a
terminal, per stream, and NO_COLOR is honoured.

"-v 1" reports every decision whose outcome you could not have read off your own
input: the klammerset that was loaded and from which file, a font's directory, a
":files" name's file, how "-o" was expanded.  Higher levels are the trace.

The commands no longer warn and continue: an anomaly is an error, described with
its location.  Two exceptions remain, each for a stated reason -- a condition
that is expected and temporary by design, and a judgment that is a heuristic
rather than exact.

@cond IS NOW A TRUE SPECIAL FORM, resolved at APPLICATION time rather than when
the file is read.  Two consequences for a writer:

  * a state variable reaches the predicate.  "@@@state Flag :value true @@@
    @cond *Flag* | T | F @" renders "T"; it used to see the literal "*Flag*" and
    silently take the false branch.  The document now behaves like a klammer
    body, whose arguments are bound before its conditionals are decided.
  * nothing in a discarded branch happens -- it is not read, not evaluated, not
    expanded.  An @eval in the branch not taken used to run anyway.

Its predicate relation is total and strict: true, True, 1; false, False, 0, and
empty; anything else is an error at the @cond rather than silently false.

@eval REACHING OUTSIDE.  ":shell" and ":haskell" now keep the command's standard
error out of the document (it appears under "-v 1") and treat a nonzero exit as
an error naming what the command reported.  A command that exits nonzero on
purpose -- "grep" finding no match -- says so with "|| true".

KLAMMER SETS.  Several combine: "--klammersets a b c" loads all three in the
order given, sharing one namespace, with the definition modes deciding
collisions.  "none" means none and may not be combined with other symbols.  A
klammerset with symbol X is declared in a file X/X.k, which is what lets two
sets require the same third set without loading it twice.

TESTS.  Four new suites: the kdiag command's interface, the @eval primitive's
contract with the outside world, and verbosity at both tiers.  Three suites
that could not run on macOS at all now do.

Assembled from dev commit 6c8ee6c22fca.
This commit is contained in:
2026-08-16 01:37:59 +02:00
parent 59c1599bc9
commit 240cff4278
84 changed files with 2523 additions and 494 deletions

View File

@@ -9,6 +9,7 @@
.PHONY: test
test:
./cond_test.sh
./eval_test.sh
./recursion_test.sh
./check_test.sh
./deftype_test.sh
@@ -22,5 +23,7 @@ test:
./target_list_test.sh
./coverage_test.sh
./command_option_test.sh
./verbosity_test.sh
./kdesc_test.sh
./kdiag_test.sh
./editor_test.sh

View File

@@ -23,7 +23,7 @@
# would take "some words" as the value and the alone value would never be
# reached. That is a definition-time error.
#
# Engine tier: these tests run with -k none and define their own argtypes
# Engine tier: these tests run with --klammersets none and define their own argtypes
# and klammers inline, so they do not depend on the Standard Klammer Set.
#
# Usage: ./alone_test.sh (LSan suppressions come from env/runtime.env)
@@ -114,29 +114,29 @@ echo
check_eq \
" 1. name absent — the default" \
"[0]" \
-k none -s "$DEPTH $DK @d@" -d
--klammersets none -s "$DEPTH $DK @d@" -d
check_eq \
" 2. name written alone — the type's alone value" \
"[3]" \
-k none -s "$DEPTH $DK @d :n @" -d
--klammersets none -s "$DEPTH $DK @d :n @" -d
check_eq \
" 3. name written with a value — that value" \
"[7]" \
-k none -s "$DEPTH $DK @d :n 7 @" -d
--klammersets none -s "$DEPTH $DK @d :n 7 @" -d
# --- A type with an alone value but no default ---
check_eq \
" 4. no default declared — absent is empty" \
"[]" \
-k none -s "$MARK $MK @m@" -d
--klammersets none -s "$MARK $MK @m@" -d
check_eq \
" 5. no default declared — alone still applies" \
"[*]" \
-k none -s "$MARK $MK @m :c @" -d
--klammersets none -s "$MARK $MK @m :c @" -d
# --- The alone value belongs to the type, so every parameter of that
# type gets it, and a parameter default does not disturb it ---
@@ -144,73 +144,73 @@ check_eq \
check_eq \
" 6. two parameters of one type share the alone value" \
"[3][3]" \
-k none -s "$DEPTH @@d2 :a.depth :b.depth : [*a*][*b*] @@ @d2 :a :b @" -d
--klammersets none -s "$DEPTH @@d2 :a.depth :b.depth : [*a*][*b*] @@ @d2 :a :b @" -d
check_eq \
" 7. a parameter default overrides the type default, not the alone value" \
"[5]|[3]" \
-k none -s "$DEPTH @@d3 :n.depth 5 : [*n*] @@ @d3@|@d3 :n @" -d
--klammersets none -s "$DEPTH @@d3 :n.depth 5 : [*n*] @@ @d3@|@d3 :n @" -d
# --- bool: the convention that a bare boolean option means true ---
check_eq \
" 8. bool written alone is true" \
"[true]" \
-k none -s '@@b :f.bool : [*f*] @@ @b :f @' -d
--klammersets none -s '@@b :f.bool : [*f*] @@ @b :f @' -d
check_eq \
" 9. bool written with false stays false" \
"[false]" \
-k none -s '@@b :f.bool : [*f*] @@ @b :f false @' -d
--klammersets none -s '@@b :f.bool : [*f*] @@ @b :f false @' -d
check_eq \
"10. bool absent with no default is empty" \
"[]" \
-k none -s '@@b :f.bool : [*f*] @@ @b@' -d
--klammersets none -s '@@b :f.bool : [*f*] @@ @b@' -d
check_eq \
"11. bool absent with a true parameter default" \
"[true]" \
-k none -s '@@b :f.bool true : [*f*] @@ @b@' -d
--klammersets none -s '@@b :f.bool true : [*f*] @@ @b@' -d
check_eq \
"12. bool false explicitly against a true default" \
"[false]" \
-k none -s '@@b :f.bool true : [*f*] @@ @b :f false @' -d
--klammersets none -s '@@b :f.bool true : [*f*] @@ @b :f false @' -d
# --- The value reaching @eval ---
check_eq \
"13. python value of a bool written alone" \
"True" \
-k none -s '@@b :f.bool : @eval repr(K.f) @ @@ @b :f @' -d
--klammersets none -s '@@b :f.bool : @eval repr(K.f) @ @@ @b :f @' -d
check_eq \
"14. python value of an absent bool" \
"None" \
-k none -s '@@b :f.bool : @eval repr(K.f) @ @@ @b@' -d
--klammersets none -s '@@b :f.bool : @eval repr(K.f) @ @@ @b@' -d
check_eq \
"15. python value of a user type written alone" \
"'3'" \
-k none -s "$DEPTH @@d4 :n.depth : @eval repr(K.n) @ @@ @d4 :n @" -d
--klammersets none -s "$DEPTH @@d4 :n.depth : @eval repr(K.n) @ @@ @d4 :n @" -d
# --- Types that cannot delimit a bare option name ---
check_error \
"16. :alone refused on a type matching running text" \
"cannot declare an :alone value" \
-k none -s '@@@argtype loose | anything at all :alone x @@@' -d
--klammersets none -s '@@@argtype loose | anything at all :alone x @@@' -d
check_error \
"17. :alone refused on an explicit match-everything pattern" \
"cannot declare an :alone value" \
-k none -s '@@@argtype loose | anything :pattern (?:.^|\n)* :alone x @@@' -d
--klammersets none -s '@@@argtype loose | anything :pattern (?:.^|\n)* :alone x @@@' -d
check_error \
"18. an alone value must match its own type's pattern" \
"does not match its own pattern" \
-k none -s '@@@argtype depth | a depth :pattern \d+ :alone many @@@' -d
--klammersets none -s '@@@argtype depth | a depth :pattern \d+ :alone many @@@' -d
# --- Delimitation: an option value still runs to the next bar or option
# name, so text after a bare name is taken as the value and rejected
@@ -219,19 +219,19 @@ check_error \
check_error \
"19. text after a bare option name is taken as its value" \
"does not match" \
-k none -s "$DEPTH $DK @d :n some words @" -d
--klammersets none -s "$DEPTH $DK @d :n some words @" -d
check_eq \
"20. a bar separates a bare option name from following text" \
"[3]two" \
-k none -s "$DEPTH @@d5 :n.depth | t : [*n*]*t* @@ @d5 :n | two @" -d
--klammersets none -s "$DEPTH @@d5 :n.depth | t : [*n*]*t* @@ @d5 :n | two @" -d
# --- A type with no alone value is unchanged: a bare name is empty ---
check_eq \
"21. bare option of a type with no alone value is empty" \
"[]" \
-k none -s '@@s :t.word : [*t*] @@ @s :t @' -d
--klammersets none -s '@@s :t.word : [*t*] @@ @s :t @' -d
echo
echo "================================"

View File

@@ -1,6 +1,6 @@
#!/bin/bash
#
# check_test.sh — Static checking of klammer applications ("ktext --check").
# check_test.sh — Static checking of klammer applications ("kdiag --check").
#
# The engine applies klammers as it meets them, so it can only complain about
# what it reaches. Two things it therefore never reaches:
@@ -24,14 +24,17 @@
# literal parameters, which are code, filenames, and raw text -- not
# applications.
#
# Engine tier: no klammer set (-k none), every klammer defined inline.
# Engine tier: kdiag loads no klammerset, so every klammer is defined inline.
# The one case that needs the SKS reads it explicitly with "@read" -- which is
# how a klammerset reaches kdiag at all: the symbol mechanism is deliberately
# absent there, so that kdiag still works when symbol resolution is what broke.
#
# Usage: ./check_test.sh
# Exit code: 0 if all tests pass, 1 otherwise.
PASS=0
FAIL=0
KTEXT=ktext
KDIAG=kdiag
K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set}
red=$'\033[31m'
@@ -39,7 +42,7 @@ green=$'\033[32m'
bold=$'\033[1m'
reset=$'\033[0m'
# check_finds TEST_NAME PATTERN KTEXT_ARGS...
# check_finds TEST_NAME PATTERN KDIAG_ARGS...
# --check must exit nonzero and report PATTERN.
check_finds() {
local test_name="$1"
@@ -47,7 +50,7 @@ check_finds() {
shift 2
local output status
output=$("$KTEXT" --check "$@" 2>&1)
output=$("$KDIAG" --process --check "$@" 2>&1)
status=$?
if [ $status -eq 0 ]; then
@@ -65,14 +68,14 @@ check_finds() {
fi
}
# check_clean TEST_NAME KTEXT_ARGS...
# check_clean TEST_NAME KDIAG_ARGS...
# --check must exit 0 and report no diagnostics.
check_clean() {
local test_name="$1"
shift
local output status
output=$("$KTEXT" --check "$@" 2>&1)
output=$("$KDIAG" --process --check "$@" 2>&1)
status=$?
if [ $status -eq 0 ] && echo "$output" | grep -q "0 diagnostics"; then
@@ -85,7 +88,7 @@ check_clean() {
fi
}
# check_count TEST_NAME N KTEXT_ARGS...
# check_count TEST_NAME N KDIAG_ARGS...
# --check must report exactly N diagnostics.
check_count() {
local test_name="$1"
@@ -93,7 +96,7 @@ check_count() {
shift 2
local output got
output=$("$KTEXT" --check "$@" 2>&1)
output=$("$KDIAG" --process --check "$@" 2>&1)
got=$(echo "$output" | sed -nE 's/^([0-9]+) diagnostics?,.*/\1/p')
if [ "$got" = "$expected" ]; then
@@ -116,87 +119,95 @@ echo
check_finds " 1. undefined klammer in an unselected @cond branch (in a body)" \
"@nosuch is not defined" \
-k none -s "@@pick p : @cond *p* | @nosuch x @ | ok @ @@ @pick false @"
"@@pick p : @cond *p* | @nosuch x @ | ok @ @@ @pick false @"
check_finds " 2. wrong arity in an unselected @cond branch (in a body)" \
"is given 3" \
-k none -s "$GREET @@pick p : @cond *p* | @greet a | b | c @ | ok @ @@ @pick false @"
"$GREET @@pick p : @cond *p* | @greet a | b | c @ | ok @ @@ @pick false @"
# A @cond written at the top level of a DOCUMENT is resolved when the file is
# read, so its unselected branch is gone before anything can be checked. This
# test records that limitation rather than asserting the behavior is right; see
# notes/Klammertext_improvements.md, "When @cond is resolved".
check_clean " 2a. LIMITATION: a top-level @cond branch is resolved before checking" \
-k none -s "@cond false | @nosuch x @ | ok @"
# A top-level @cond branch is now checked like any other. It was the one
# documented gap in the checker: a @cond written at the top level of a DOCUMENT
# was resolved when the file was READ, so its unselected branch was gone before
# anything could look at it. Resolving @cond at APPLICATION time instead
# (notes/Klammertext_improvements.md §4.2, decided 2026-08-15) closed it -- the
# branches survive read, so both are visible to a pass that applies nothing.
check_finds " 2a. a top-level @cond branch is checked too" \
"@nosuch is not defined" \
"@cond false | @nosuch x @ | ok @"
check_finds " 2b. ... including the branch that WOULD be selected" \
"@nosuch is not defined" \
"@cond true | @nosuch x @ | ok @"
check_finds " 3. undefined klammer in a body that is never applied" \
"@nosuch is not defined" \
-k none -s "@@unused : @nosuch x @ @@ nothing applies it"
"@@unused : @nosuch x @ @@ nothing applies it"
check_finds " 4. the body it was found in is named" \
"in body of @unused" \
-k none -s "@@unused : @nosuch x @ @@ nothing applies it"
"@@unused : @nosuch x @ @@ nothing applies it"
# --- Arity ---
check_finds " 5. too few positional arguments" \
"needs 2 positional arguments but is given 1" \
-k none -s '@@pair a | b : *a**b* @@ @pair x @'
'@@pair a | b : *a**b* @@ @pair x @'
check_finds " 6. too many positional arguments" \
"takes 1 positional argument but is given 2" \
-k none -s "$GREET @greet a | b @"
"$GREET @greet a | b @"
check_finds " 7. undefined optional argument" \
'has no optional argument ":nope"' \
-k none -s "$GREET @greet a :nope 1 @"
"$GREET @greet a :nope 1 @"
check_finds " 8. the accepted optional arguments are listed" \
"It accepts: :loud" \
-k none -s '@@greet name :loud : *name* @@ @greet a :nope 1 @'
'@@greet name :loud : *name* @@ @greet a :nope 1 @'
check_clean " 9. a rest argument accepts extra positional arguments" \
-k none -s '@@many a | rest.rest : *a* @@ @many x | y | z @'
'@@many a | rest.rest : *a* @@ @many x | y | z @'
# --- Nesting. A bar or an option name belonging to a nested klammer is not
# this klammer's; the checker counts at depth 0, as @cond does. ---
check_clean "10. nested klammer's bars are not counted as the outer's" \
-k none -s '@@frac a | b : *a*/*b* @@ @@one x : [*x*] @@ @one @frac 1 | 2 @ @'
'@@frac a | b : *a*/*b* @@ @@one x : [*x*] @@ @one @frac 1 | 2 @ @'
check_clean "11. nested klammer's option name is not counted as the outer's" \
-k none -s '@@inner a :flag : *a* @@ @@outer x : [*x*] @@ @outer @inner q :flag y @ @'
'@@inner a :flag : *a* @@ @@outer x : [*x*] @@ @outer @inner q :flag y @ @'
# --- Target coverage ---
check_finds "12. klammer not defined for a target" \
'is not defined for the target "tex"' \
-k none -s '@@@target html | HTML output @@@ @@@target tex | TeX output @@@ @@only.html : H @@ @only@'
'@@@target html | HTML output @@@ @@@target tex | TeX output @@@ @@only.html : H @@ @only@'
check_clean "13. defined for every target is clean" \
-k none -s '@@@target html | HTML output @@@ @@@target tex | TeX output @@@ @@both : B @@ @both@'
'@@@target html | HTML output @@@ @@@target tex | TeX output @@@ @@both : B @@ @both@'
# --- What the checker deliberately does not see ---
# In a body, so the @eval is not evaluated at read time: what is being tested
# is that the checker does not read the eval's ARGUMENT as an application.
check_clean "14. @eval argument content is code, not applications" \
-k none -s '@@w : @eval len("@nosuch") @ @@'
'@@w : @eval len("@nosuch") @ @@'
check_clean "15. a literal parameter's content is raw text" \
-k none -s '@@lit t.literal : *t* @@ @lit @nosuch x @ lit@'
'@@lit t.literal : *t* @@ @lit @nosuch x @ lit@'
# --- Reporting ---
check_count "16. a target-independent fault is reported once, not per target" \
1 \
-k none -s '@@@target html | HTML output @@@ @@@target tex | TeX output @@@ @@g : @nosuch@ @@'
'@@@target html | HTML output @@@ @@@target tex | TeX output @@@ @@g : @nosuch@ @@'
check_clean "17. a correct document checks clean" \
-k none -s "$GREET @greet World @"
"$GREET @greet World @"
# An absolute path: "make -C tst test" runs from tst/, and @read resolves
# against the current directory.
check_clean "18. the Standard Klammer Set checks clean" \
-s 'x'
"@read $K/sks/sks.k @"
echo
echo "============================="

View File

@@ -113,6 +113,76 @@ else
echo "SKIP 13/14. two positionals (argv_test not built; run make -C tst)"
fi
echo
echo "-- the output policy: three categories, two streams --"
# The three commands display text in exactly three cases:
# 1. logging under "-v" > 0 -> STDERR
# 2. an error before termination -> STDERR
# 3. output the user asked for -> STDOUT
# For ktext, category 3 is a DOCUMENT that may be piped, so nothing else may
# share the stream. It did: K::log and msg() both wrote to stdout, so
# "ktext -d -v 1 > doc.txt" put the whole argument dump inside the document.
OUTF=$(mktemp /tmp/kout.XXXXXX); ERRF=$(mktemp /tmp/kerr.XXXXXX)
trap 'rm -f "$OUTF" "$ERRF"' EXIT
ktext --klammersets none -s 'hello' -d -v 1 > "$OUTF" 2> "$ERRF"
if [ "$(cat "$OUTF")" = "hello" ]; then
echo "${green}PASS${reset} 15. stdout under -d -v 1 is the document, nothing else"
PASS=$((PASS+1))
else
echo "${red}FAIL${reset} 15. stdout carried more than the document"
echo " got: $(head -3 "$OUTF")"; FAIL=$((FAIL+1))
fi
if [ -s "$ERRF" ]; then
echo "${green}PASS${reset} 16. ... and the -v 1 logging went to stderr"; PASS=$((PASS+1))
else
echo "${red}FAIL${reset} 16. -v 1 produced no stderr"; FAIL=$((FAIL+1))
fi
# -v 0 adds nothing anywhere: silence is the default, on both streams.
ktext --klammersets none -s 'hello' -d > "$OUTF" 2> "$ERRF"
if [ "$(cat "$OUTF")" = "hello" ] && [ ! -s "$ERRF" ]; then
echo "${green}PASS${reset} 17. -v 0 is silent on both streams"; PASS=$((PASS+1))
else
echo "${red}FAIL${reset} 17. -v 0 was not silent (stderr: $(head -2 "$ERRF"))"
FAIL=$((FAIL+1))
fi
# An error is category 2: stderr, and nothing on stdout to confuse a pipe.
ktext --klammersets none -s '@nosuch x @' -d > "$OUTF" 2> "$ERRF"
if [ ! -s "$OUTF" ] && [ -s "$ERRF" ]; then
echo "${green}PASS${reset} 18. an error goes to stderr, leaving stdout empty"; PASS=$((PASS+1))
else
echo "${red}FAIL${reset} 18. error stream discipline (stdout: $(head -2 "$OUTF"))"
FAIL=$((FAIL+1))
fi
# Colour is emitted only to a terminal, so a redirected stream never carries
# escape sequences -- into a document, a pipe, or a captured log.
for pair in "ktext:--klammersets none -s hello -d" "kdesc:-k table" "kdiag:@i-x"; do
cmd=${pair%%:*}; rest=${pair#*:}
# shellcheck disable=SC2086
$cmd $rest > "$OUTF" 2> "$ERRF"
if ! grep -q $'\033' "$OUTF" && ! grep -q $'\033' "$ERRF"; then
echo "${green}PASS${reset} 19. no escape sequences from $cmd when redirected"
PASS=$((PASS+1))
else
echo "${red}FAIL${reset} 19. $cmd emitted colour to a non-terminal"; FAIL=$((FAIL+1))
fi
done
echo
echo "-- a bare command is a request, not a failure --"
# Usage is the result being asked for: stdout, exit 0. It exited 1, so
# "kdesc && echo ok" reported failure for a successful help request.
for cmd in ktext kdesc kdiag; do
$cmd > "$OUTF" 2> "$ERRF"; status=$?
if [ $status -eq 0 ] && [ -s "$OUTF" ] && ! [ -s "$ERRF" ]; then
echo "${green}PASS${reset} 20. $cmd with no arguments: usage on stdout, exit 0"
PASS=$((PASS+1))
else
echo "${red}FAIL${reset} 20. $cmd bare: exit $status, stdout $(wc -l < "$OUTF") lines, stderr $(wc -l < "$ERRF") lines"
FAIL=$((FAIL+1))
fi
done
echo
echo "===================="
echo "Results: ${PASS} passed, ${FAIL} failed"

View File

@@ -117,6 +117,11 @@ check_error() {
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
@@ -174,6 +179,99 @@ 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

View File

@@ -17,7 +17,10 @@
# REPORTED, and a companion case checks that rendering is unaffected.
#
# Engine tier: the fixtures in tst/coverage/ declare their own targets with
# @@@target, so no klammer set is involved.
# @@@target, so no klammer set is involved -- hence "--klammersets none".
# Since the 2026-08-14 redesign "-i" ADDS a file to whatever klammersets are
# loaded (the SKS by default) rather than replacing them, so the exclusion has
# to be explicit or every count here would include the SKS.
#
# Usage: ./coverage_test.sh (needs KLAMMERTEXT_HOME set; kdesc on PATH)
# Exit code: 0 if all tests pass, 1 otherwise.
@@ -34,23 +37,50 @@ green=$'\033[32m'
bold=$'\033[1m'
reset=$'\033[0m'
# Each fixture is analysed once; the tests match against the saved report.
declare -A REPORT VREPORT
# 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
# Each fixture is analysed once and its report SAVED TO A FILE, keyed by name.
#
# It used to be a pair of associative arrays. "declare -A" is bash 4, and
# macOS ships bash 3.2 as /bin/bash -- where the declaration fails and every
# string subscript then evaluates to 0, so all five fixtures overwrote one
# slot and the whole suite compared the wrong report against the wrong test.
# Files have no such floor and read the same on both systems.
REPORTS=$(mktemp -d /tmp/coverage_reports.XXXXXX)
trap 'rm -rf "$REPORTS"' EXIT
for f in basic undecidable cycle split clean; do
REPORT[$f]=$(timeout 30 "$KDESC" -i "$DIR/$f.k" --coverage 2>&1 |
sed 's/\x1b\[[0-9;]*m//g')
limited "$KDESC" --klammersets none -i "$DIR/$f.k" --coverage 2>&1 |
sed 's/\x1b\[[0-9;]*m//g' > "$REPORTS/$f"
# "all" adds the source-file column AND the empty problem categories.
VREPORT[$f]=$(timeout 30 "$KDESC" -i "$DIR/$f.k" --coverage all 2>&1 |
sed 's/\x1b\[[0-9;]*m//g')
if [ -z "${REPORT[$f]}" ] || [ -z "${VREPORT[$f]}" ]; then
limited "$KDESC" --klammersets none -i "$DIR/$f.k" --coverage all 2>&1 |
sed 's/\x1b\[[0-9;]*m//g' > "$REPORTS/$f.all"
if [ ! -s "$REPORTS/$f" ] || [ ! -s "$REPORTS/$f.all" ]; then
echo "${red}FAIL${reset} $f.k produced no report"; FAIL=$((FAIL+1))
fi
done
# report FIXTURE [all] — the saved report, on stdout.
report() { cat "$REPORTS/$1"; }
vreport() { cat "$REPORTS/$1.all"; }
# vlacks NAME FIXTURE REGEX — the VERBOSE report does NOT match REGEX.
vlacks() {
local name="$1" fixture="$2" rgx="$3"
if printf '%s\n' "${VREPORT[$fixture]}" | grep -Eq "$rgx"; then
if vreport "$fixture" | grep -Eq "$rgx"; then
echo "${red}FAIL${reset} $name — unexpected match: $rgx"; FAIL=$((FAIL+1))
else
echo "${green}PASS${reset} $name"; PASS=$((PASS+1))
@@ -60,7 +90,7 @@ vlacks() {
# vhas NAME FIXTURE REGEX — the VERBOSE report matches REGEX.
vhas() {
local name="$1" fixture="$2" rgx="$3"
if printf '%s\n' "${VREPORT[$fixture]}" | grep -Eq "$rgx"; then
if vreport "$fixture" | grep -Eq "$rgx"; then
echo "${green}PASS${reset} $name"; PASS=$((PASS+1))
else
echo "${red}FAIL${reset} $name"
@@ -71,7 +101,7 @@ vhas() {
# has NAME FIXTURE REGEX — the report matches REGEX.
has() {
local name="$1" fixture="$2" rgx="$3"
if printf '%s\n' "${REPORT[$fixture]}" | grep -Eq "$rgx"; then
if report "$fixture" | grep -Eq "$rgx"; then
echo "${green}PASS${reset} $name"; PASS=$((PASS+1))
else
echo "${red}FAIL${reset} $name"
@@ -86,7 +116,7 @@ has() {
section_lacks() {
local name="$1" fixture="$2" header="$3" rgx="$4"
local body
body=$(printf '%s\n' "${REPORT[$fixture]}" |
body=$(report "$fixture" |
awk -v h="$header" 'index($0, h) == 1 { f = 1; next } f && /^$/ { exit } f')
if [ -z "$body" ]; then
echo "${red}FAIL${reset} $name — section [$header] not found"; FAIL=$((FAIL+1)); return
@@ -101,7 +131,7 @@ section_lacks() {
# lacks NAME FIXTURE REGEX — the report does NOT match REGEX.
lacks() {
local name="$1" fixture="$2" rgx="$3"
if printf '%s\n' "${REPORT[$fixture]}" | grep -Eq "$rgx"; then
if report "$fixture" | grep -Eq "$rgx"; then
echo "${red}FAIL${reset} $name — unexpected match: $rgx"; FAIL=$((FAIL+1))
else
echo "${green}PASS${reset} $name"; PASS=$((PASS+1))
@@ -243,7 +273,7 @@ echo
echo "-- the analysis changes nothing --"
# Coverage is a report, not a policy: a klammer whose general body cannot be
# interpreted is still offered to every target, exactly as before.
out=$("$KTEXT" -k none -s '@@@target ta | Target A @@@
out=$("$KTEXT" --klammersets none -s '@@@target ta | Target A @@@
@@e : @eval 6 * 7 @ @@ x @e@' -t ta -d 2>&1 | tr -d '\n ')
if [ "$out" = "x42" ]; then
echo "${green}PASS${reset} 22. an underivable klammer still renders"; PASS=$((PASS+1))

View File

@@ -62,7 +62,16 @@ check_eq() {
fi
}
# check_warn NAME EXPECTED KTEXT_ARGS... — exit 0, stdout==EXPECTED, AND a warning on stderr.
# check_warn NAME EXPECTED KTEXT_ARGS... — exit 0, stdout==EXPECTED, the
# override REPORTED at "-v 1" and SILENT at the default verbosity.
#
# It was a warning until 2026-08-15. ":::" exists to override, and a klammer
# set the user did not write may be overridden by design (TODO #33), so the
# notice fired on the sanctioned use of a feature: a warning nobody can act on
# is not a warning. It is still worth reporting, because the definition being
# replaced usually lives in another file in another klammerset, which the user
# cannot see from what they wrote -- so it is "-v 1" (see the output policy in
# CLAUDE.md).
check_warn() {
local name="$1" expected="$2"; shift 2
local out status err
@@ -72,8 +81,14 @@ check_warn() {
if [ $status -ne 0 ]; then
echo "${red}FAIL${reset} $name — ktext exited $status"; FAIL=$((FAIL+1)); return
fi
if ! printf '%s' "$err" | grep -qiF "warning"; then
echo "${red}FAIL${reset} $nameexpected a warning, got none"; FAIL=$((FAIL+1)); return
if [ -s "$ERR" ]; then
echo "${red}FAIL${reset} $namethe default run was not silent: $(head -1 "$ERR")"
FAIL=$((FAIL+1)); return
fi
"$KTEXT" "$@" -v 1 >/dev/null 2>"$ERR"
err=$(cat "$ERR")
if ! printf '%s' "$err" | grep -qiF "overridden"; then
echo "${red}FAIL${reset} $name — no override reported at -v 1"; FAIL=$((FAIL+1)); return
fi
if [ "$out" = "$expected" ]; then
echo "${green}PASS${reset} $name"; PASS=$((PASS+1))

View File

@@ -7,7 +7,7 @@
# mac/machine.cpp), not the SKS. A target is a MACHINE construct — declared
# with the @@@target system command, not owned by any klammer set — so the
# idiomatic engine-level test defines its own fixture target inline and loads
# no klammer set (`-k none`); it does not "avoid" the SKS so much as have no
# no klammer set (`--klammersets none`); it does not "avoid" the SKS so much as have no
# need of it. Target `t` here escapes `& -> AMP`, `_ -> UND`, `\ -> BSL`
# (arbitrary tokens, easy to assert). The SKS's own targets (tex, html) and
# the specific characters they declare are exercised by the SKS suite.
@@ -75,29 +75,29 @@ echo "======================================================================="
echo
# A self-contained fixture target, defined inline via the @@@target system
# command, escaping & _ \ to distinct tokens. -k none loads no klammer set,
# command, escaping & _ \ to distinct tokens. --klammersets none loads no klammer set,
# so nothing below depends on the SKS.
T='@@@target t | test target :escape & AMP _ UND \ BSL @@@'
check_eq " 1. top-level text: & escaped" 'A AMP B' -k none -t t -s "$T A & B"
check_eq " 2. general klammer body: & escaped" 'A AMP B' -k none -t t -s "$T @@g : A & B @@ @g@"
check_eq " 3. general klammer body: _ escaped" 'AUNDB' -k none -t t -s "$T @@g : A_B @@ @g@"
check_eq " 4. general klammer body: backslash escaped" 'aBSLb' -k none -t t -s "$T @@g : a\\b @@ @g@"
check_eq " 5. target-specific body: NOT escaped" 'A & B' -k none -t t -s "$T @@g.k : d @@ @@g.t :: A & B @@ @g@"
check_eq " 6. general body ^'...'^ literal: NOT escaped" 'a&b' -k none -t t -s "$T @@g : ^'a&b'^ @@ @g@"
check_eq " 7. nested target-native klammer survives escape" 'X AMP Y \newline Z' -k none -t t -s "$T @@n.t : \\newline @@ @@g : X & Y @n@ Z @@ @g@"
check_eq " 1. top-level text: & escaped" 'A AMP B' --klammersets none -t t -s "$T A & B"
check_eq " 2. general klammer body: & escaped" 'A AMP B' --klammersets none -t t -s "$T @@g : A & B @@ @g@"
check_eq " 3. general klammer body: _ escaped" 'AUNDB' --klammersets none -t t -s "$T @@g : A_B @@ @g@"
check_eq " 4. general klammer body: backslash escaped" 'aBSLb' --klammersets none -t t -s "$T @@g : a\\b @@ @g@"
check_eq " 5. target-specific body: NOT escaped" 'A & B' --klammersets none -t t -s "$T @@g.k : d @@ @@g.t :: A & B @@ @g@"
check_eq " 6. general body ^'...'^ literal: NOT escaped" 'a&b' --klammersets none -t t -s "$T @@g : ^'a&b'^ @@ @g@"
check_eq " 7. nested target-native klammer survives escape" 'X AMP Y \newline Z' --klammersets none -t t -s "$T @@n.t : \\newline @@ @@g : X & Y @n@ Z @@ @g@"
# 8-9: @eval inside a general body. Code (with underscores) must not be
# escaped or Python breaks; the Klammertext it returns is klammer output and
# must not be escaped either. chr(64) builds a literal '@' so the returned
# klammer call is not parsed as one in this source line.
check_eq " 8. general body @eval code NOT escaped" '3' -k none -t t -s "$T @@g : @eval (1).__add__(2) @ @@ @g@"
check_eq " 9. general body @eval klammer result NOT escaped" '\textbf{hi}' -k none -t t -s "$T @@b.k s : d @@ @@b.t :: \\textbf{*s*} @@ @@g : @eval chr(64)+'b hi '+chr(64) @ @@ @g@"
check_eq " 8. general body @eval code NOT escaped" '3' --klammersets none -t t -s "$T @@g : @eval (1).__add__(2) @ @@ @g@"
check_eq " 9. general body @eval klammer result NOT escaped" '\textbf{hi}' --klammersets none -t t -s "$T @@b.k s : d @@ @@b.t :: \\textbf{*s*} @@ @@g : @eval chr(64)+'b hi '+chr(64) @ @@ @g@"
# 10-11: an @eval result that still holds klammers is a GENERATOR (Klammertext
# with data) -- its writer text is escaped for the target before the klammers
# are applied; a result with no klammers is a RENDERER (final markup) -- left
# untouched. The signal is "does the read-back result contain a klammer".
check_eq "10. @eval generator: klammer result's data escaped" '[a AMP b]' -k none -t t -s "$T @@wrap z : [*z*] @@ @@g : @eval chr(64)+'wrap a & b '+chr(64) @ @@ @g@"
check_eq "11. @eval renderer: final markup NOT escaped" 'raw & markup' -k none -t t -s "$T @@g : @eval 'raw & markup' @ @@ @g@"
check_eq "10. @eval generator: klammer result's data escaped" '[a AMP b]' --klammersets none -t t -s "$T @@wrap z : [*z*] @@ @@g : @eval chr(64)+'wrap a & b '+chr(64) @ @@ @g@"
check_eq "11. @eval renderer: final markup NOT escaped" 'raw & markup' --klammersets none -t t -s "$T @@g : @eval 'raw & markup' @ @@ @g@"
# 12-18: quoted KLAMMERTEXT specials (^@ ^| ^# ^^ ^: ^*) and ^'...'^ literal
# regions. The katomizer strips the "^"; hide_special_katoms() and
@@ -108,21 +108,21 @@ check_eq "11. @eval renderer: final markup NOT escaped" 'raw & markup'
# leaked as a bare apply-end katom into re-read text ("A klammer ends
# without a beginning"). Exact-match expectations also guard against KTESC
# markers leaking into output.
check_eq "12. quoted @ | # resolve to the characters" 'x @ | # y' -k none -t t -s "$T x ^@ ^| ^# y"
check_eq "13. quoted ^ : * resolve to the characters" 'x ^ : * y' -k none -t t -s "$T x ^^ ^: ^* y"
check_eq "14. ^'...'^ region: specials stay literal" 'a @ | b' -k none -t t -s "$T a ^' @ | '^ b"
check_eq "15. general body: quoted @ resolves" 'x @ y' -k none -t t -s "$T @@g : x ^@ y @@ @g@"
check_eq "12. quoted @ | # resolve to the characters" 'x @ | # y' --klammersets none -t t -s "$T x ^@ ^| ^# y"
check_eq "13. quoted ^ : * resolve to the characters" 'x ^ : * y' --klammersets none -t t -s "$T x ^^ ^: ^* y"
check_eq "14. ^'...'^ region: specials stay literal" 'a @ | b' --klammersets none -t t -s "$T a ^' @ | '^ b"
check_eq "15. general body: quoted @ resolves" 'x @ y' --klammersets none -t t -s "$T @@g : x ^@ y @@ @g@"
# 16: inside an @eval argument span a quoted special reaches the CODE as the
# character (the span is skipped by hide_special_katoms, like the escape pass).
check_eq "16. @eval code: quoted : reaches shell as ':'" 'x:y' -k none -t t -s "$T @@g : @eval :shell echo x^:y @ @@ @g@"
check_eq "16. @eval code: quoted : reaches shell as ':'" 'x:y' --klammersets none -t t -s "$T @@g : @eval :shell echo x^:y @ @@ @g@"
# 17: an @eval result emitting the two characters ^ @ is re-read as a quoted
# special and survives to the output as a literal @ (the generator idiom for
# a literal @; a bare @ in a result is a parse error by design).
check_eq "17. @eval result ^@ survives read-back as @" '@' -k none -t t -s "$T @@g : @eval chr(94)+chr(64) @ @@ @g@"
check_eq "17. @eval result ^@ survives read-back as @" '@' --klammersets none -t t -s "$T @@g : @eval chr(94)+chr(64) @ @@ @g@"
# 18: a bare-Python :after_apply phase receives the RESOLVED result text
# (K_result) and its return is taken as raw target text, not re-read as
# Klammertext -- a resolved @ in the result must not be re-parsed.
check_eq "18. :after_apply phase: raw result, @ intact" 'A @ B' -k none -t u -s '@@@target u | up :after_apply string.capwords @@@ a ^@ b'
check_eq "18. :after_apply phase: raw result, @ intact" 'A @ B' --klammersets none -t u -s '@@@target u | up :after_apply string.capwords @@@ a ^@ b'
rm -f "$ERR"

226
tst/eval_test.sh Executable file
View File

@@ -0,0 +1,226 @@
#!/bin/bash
#
# eval_test.sh — the @eval primitive's contract with the outside world.
#
# @eval is the one primitive that reaches OUT of Klammertext, and until
# 2026-08-15 nothing tested what it did with what came back. ":shell" is
# covered here; the other modes (Python, :cpp, :haskell) are exercised
# incidentally by other suites and can grow into this one.
#
# The two defects this suite exists to hold shut, both of them the same shape
# as a msg() on the wrong stream -- output nobody chose to see, and a failure
# nobody was told about:
#
# * The command's STDERR went straight to the user's terminal, unattributed
# and unsuppressable. It is not the command's output in any of the three
# policy categories (CLAUDE.md, "Command output policy"): it belongs to a
# subprocess a klammer invoked, at a location the Locator can name. It is
# now captured and reported at "-v 1".
# * The EXIT STATUS was discarded, so a command that failed contributed its
# partial output (or nothing) to the document and said nothing at all.
#
# NOT to be confused with "eval_test", the C++ diagnostic program built from
# eval_test.cpp in this directory: that one constructs engine objects and prints
# what it gets, for a person to read, and asserts nothing (see the `smoke`
# target in tst/Makefile). This is the regression suite. The ".sh" is the only
# thing distinguishing them, and it is the first such collision in tst/.
#
# Usage: ./eval_test.sh (needs KLAMMERTEXT_HOME set; ktext on PATH)
# 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'
OUTF=$(mktemp /tmp/eval_out.XXXXXX)
ERRF=$(mktemp /tmp/eval_err.XXXXXX)
trap 'rm -f "$OUTF" "$ERRF"' EXIT
plain() { sed 's/\x1b\[[0-9;]*m//g'; }
# run SOURCE [EXTRA...] — sets STATUS, and fills OUTF/ERRF.
STATUS=0
run() {
local src="$1"; shift
"$KTEXT" --klammersets none -s "$src" -d "$@" >"$OUTF" 2>"$ERRF"
STATUS=$?
}
pass() { echo "${green}PASS${reset} $1"; PASS=$((PASS + 1)); }
fail() { echo "${red}FAIL${reset} $1"; [ -n "$2" ] && echo " $2"; FAIL=$((FAIL + 1)); }
echo "${bold}@eval tests${reset}"
echo "==========="
echo
echo "-- :shell, the ordinary case --"
run '@eval :shell echo hello @'
if [ $STATUS -eq 0 ] && [ "$(cat "$OUTF")" = "hello" ]; then
pass " 1. the command's stdout becomes document text"
else
fail " 1. exit $STATUS, stdout [$(cat "$OUTF")]"
fi
echo
echo "-- stderr belongs to the command, not to the terminal --"
# A signal death is not an error exit, and a crash must never read as a pass:
# every case here checks the status numerically.
run '@eval :shell echo OUT; echo NOISE >&2 @'
if [ $STATUS -eq 0 ] && [ "$(cat "$OUTF")" = "OUT" ]; then
pass " 2. stdout is the document; stderr is not in it"
else
fail " 2. exit $STATUS, stdout [$(cat "$OUTF")]"
fi
if [ ! -s "$ERRF" ]; then
pass " 3. ... and nothing leaks to the terminal at the default verbosity"
else
fail " 3. stderr leaked: $(head -1 "$ERRF")"
fi
run '@eval :shell echo OUT; echo NOISE >&2 @' -v 1
if grep -q "NOISE" "$ERRF"; then
pass " 4. ... while -v 1 reports what the command said"
else
fail " 4. -v 1 did not report the command's stderr" "$(head -2 "$ERRF")"
fi
if grep -qi "stderr" "$ERRF"; then
pass " 5. ... and says that is what it is"
else
fail " 5. the -v 1 report does not identify the stream"
fi
echo
echo "-- a failing command is an error, not silence --"
run '@eval :shell exit 3 @'
if [ $STATUS -ne 0 ] && [ $STATUS -lt 128 ]; then
pass " 6. a nonzero exit status fails the run"
else
fail " 6. exit $STATUS (128+ would be a signal death, 0 a silent pass)"
fi
for want in "exit status 3" "The shell command failed"; do
if grep -qF "$want" "$ERRF"; then
pass " 7. the error says [$want]"
else
fail " 7. the error does not say [$want]" "$(plain < "$ERRF" | head -2)"
fi
done
# What the command itself reported is the useful half of the diagnosis.
run '@eval :shell echo WHY-IT-FAILED >&2; exit 1 @'
if grep -qF "WHY-IT-FAILED" "$ERRF"; then
pass " 8. ... and includes what the command wrote to stderr"
else
fail " 8. the command's own message was dropped" "$(plain < "$ERRF" | head -3)"
fi
# An error is category 2: stderr, and nothing on stdout to confuse a pipe.
if [ ! -s "$OUTF" ]; then
pass " 9. ... and leaves stdout empty"
else
fail " 9. stdout carried [$(head -c 60 "$OUTF")]"
fi
echo
echo "-- the escape hatch, because some commands exit nonzero on purpose --"
# "grep" finding no match is the usual one. Strictness with an explicit way to
# say "I meant that" is the same shape as the @cond predicate rule.
run '@eval :shell exit 3 @'
if grep -qF "|| true" "$ERRF"; then
pass "10. the error names the way to say a nonzero status was intended"
else
fail "10. the error does not offer the remedy" "$(plain < "$ERRF" | head -3)"
fi
# NOT "exit N || true": exit terminates the shell before "||" is reached, so
# that spelling cannot work and is not what the message suggests. A command
# that merely RETURNS nonzero is the case the remedy is for.
run '@eval :shell echo kept; grep -q zzz /dev/null || true @'
if [ $STATUS -eq 0 ] && [ "$(cat "$OUTF")" = "kept" ]; then
pass "11. ... and it works"
else
fail "11. exit $STATUS, stdout [$(cat "$OUTF")]"
fi
# The same command without the remedy is an error, or case 11 proves nothing.
run '@eval :shell echo kept; grep -q zzz /dev/null @'
if [ $STATUS -ne 0 ] && [ $STATUS -lt 128 ]; then
pass "11a. ... and without it the same command fails"
else
fail "11a. exit $STATUS — expected a nonzero, non-signal exit"
fi
echo
echo "-- the command may contain its own pipeline --"
# The redirection that captures stderr must not disturb the writer's command.
run '@eval :shell echo one two three | tr " " "-" @'
if [ $STATUS -eq 0 ] && [ "$(cat "$OUTF")" = "one-two-three" ]; then
pass "12. a pipeline inside the command still works"
else
fail "12. exit $STATUS, stdout [$(cat "$OUTF")]"
fi
run '@eval :shell echo a > /dev/null; echo b @'
if [ $STATUS -eq 0 ] && [ "$(cat "$OUTF")" = "b" ]; then
pass "13. ... and so does a redirection of its own"
else
fail "13. exit $STATUS, stdout [$(cat "$OUTF")]"
fi
echo
echo "-- the other modes still work --"
run '@eval 6*7 @'
if [ $STATUS -eq 0 ] && [ "$(cat "$OUTF")" = "42" ]; then
pass "14. a Python expression"
else
fail "14. exit $STATUS, stdout [$(cat "$OUTF")]"
fi
echo
echo "-- :haskell, the same contract --"
# Skipped where GHC is absent: runghc is an optional dependency (the
# akopra/klammertext:haskell image, or a local GHCup install), and a suite that
# fails for its absence would be reporting the machine, not the code.
if ! command -v runghc >/dev/null 2>&1; then
echo "SKIP 15-18. :haskell (runghc not installed)"
else
# The defect: runghc ran with "2>&1", so on a SUCCESSFUL run everything the
# program or GHC wrote to stderr was merged into the result and became part
# of the document.
HS='@eval :haskell import System.IO
main = hPutStrLn stderr "HS-NOISE" >> putStrLn "HS-OUT" @'
run "$HS"
if [ $STATUS -eq 0 ] && [ "$(cat "$OUTF")" = "HS-OUT" ]; then
pass "15. the program's stdout is the document; its stderr is not"
else
fail "15. exit $STATUS, stdout [$(cat "$OUTF")]"
fi
run "$HS" -v 1
if grep -q "HS-NOISE" "$ERRF"; then
pass "16. ... and -v 1 reports what it wrote to stderr"
else
fail "16. -v 1 did not report it" "$(plain < "$ERRF" | head -2)"
fi
# A compile error was ALREADY reported rather than swallowed -- the exit
# status was checked -- so this pins behaviour that was right, and that the
# detail now comes from the captured stderr rather than a merged stream.
run '@eval :haskell main = putStrLn (1 + "x") @'
if [ $STATUS -ne 0 ] && [ $STATUS -lt 128 ] && grep -qi "error" "$ERRF"; then
pass "17. a compile error fails the run and shows what runghc said"
else
fail "17. exit $STATUS" "$(plain < "$ERRF" | head -3)"
fi
# A program that compiles, runs, and then exits nonzero is the other half.
run '@eval :haskell import System.Exit
main = putStrLn "partial" >> exitWith (ExitFailure 3) @'
if [ $STATUS -ne 0 ] && grep -qF "exit status 3" "$ERRF"; then
pass "18. a nonzero exit from the program itself is reported too"
else
fail "18. exit $STATUS" "$(plain < "$ERRF" | head -3)"
fi
fi
echo
echo "==========="
echo "Results: ${PASS} passed, ${FAIL} failed"
[ "$FAIL" -eq 0 ] || exit 1
exit 0

View File

@@ -14,7 +14,7 @@
# regrouping is announced on stderr.
# - A leading "~/" expands to $HOME (shells do not expand a quoted tilde).
#
# Engine tier: uses -k none; no SKS.
# Engine tier: uses --klammersets none; no SKS.
#
# Usage: ./filename_test.sh
# Exit code: 0 if all tests pass, 1 otherwise.
@@ -98,36 +98,49 @@ echo "======================="
check "1. quoted filename with a space is one file" \
"ALPHA" "" \
"my file.kt" -d -k none
"my file.kt" -d --klammersets none
check "2. unquoted spaces rescued into an existing file (announced)" \
# The regrouping is a DERIVED value -- the command completed a name the input
# left ambiguous -- so since 2026-08-15 it is reported at "-v 1" and the
# default run is silent. Silence is safe because a rescue only succeeds when
# the joined name names an existing file: a mistyped name fails instead of
# resolving to something else.
check "2. unquoted spaces are rescued into an existing file" \
"ALPHA" "" \
my file.kt -d --klammersets none
check "2a. ... silently at the default verbosity" \
"ALPHA" "" \
my file.kt -d --klammersets none
check "2b. ... and reported at -v 1" \
"ALPHA" "interpreting \"my file.kt\" as one filename" \
my file.kt -d -k none
my file.kt -d --klammersets none -v 1
check "3. standalone / separates a filename list" \
"ALPHA
BETA" "" \
my file.kt / b.kt -d -k none
my file.kt / b.kt -d --klammersets none
check "4. space in a directory component" \
"GAMMA" "" \
"my dir/c.kt" -d -k none
"my dir/c.kt" -d --klammersets none
check "5. quoted name that exists is never split (b.kt also exists)" \
"ALPHA" "" \
"my file.kt" -d -k none
"my file.kt" -d --klammersets none
HOME="$DIR" check "6. quoted ~/ expands to \$HOME inside ktext" \
"BETA" "" \
"~/b.kt" -d -k none
"~/b.kt" -d --klammersets none
check "7. @read argument keeps its internal space" \
"ALPHA" "" \
-s '@read my file.kt @' -d -k none
-s '@read my file.kt @' -d --klammersets none
check_error "8. unrescuable name is reported as written" \
"no such.kt" \
"no such.kt" -d -k none
"no such.kt" -d --klammersets none
echo
echo "======================="

View File

@@ -8,7 +8,7 @@
# * a flag a user reaches for often gets a single letter (-k klammers,
# -t targets, -c character codes, -i input); a more specialised topic gets
# a multi-letter name (--argtypes, --katoms, --rewrite, --optionsets,
# --coverage, --klammerset, --font);
# --coverage, --klammersets, --font);
# * -v says how much to show about the command's PROCESSING and never what
# its RESULT contains. So the katom regex column is "--katoms full" and
# the coverage file column is "--coverage all", not verbosity levels.
@@ -34,11 +34,25 @@ reset=$'\033[0m'
plain() { 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
# shows NAME PATTERN CMD... — exits 0 and the output matches PATTERN.
shows() {
local name="$1" pattern="$2"; shift 2
local out status
out=$(timeout 60 "$@" 2>&1 | plain); status=$?
out=$(limited "$@" 2>&1 | plain); status=$?
if [ $status -gt 128 ]; then
echo "${red}FAIL${reset} $name — died by signal $((status-128))"; FAIL=$((FAIL+1)); return
fi
@@ -53,7 +67,7 @@ shows() {
# absent NAME PATTERN CMD... — the output does NOT match PATTERN.
absent() {
local name="$1" pattern="$2"; shift 2
if timeout 60 "$@" 2>&1 | plain | grep -Eq -- "$pattern"; then
if limited "$@" 2>&1 | plain | grep -Eq -- "$pattern"; then
echo "${red}FAIL${reset} $name — unexpected match [$pattern]"; FAIL=$((FAIL+1))
else
echo "${green}PASS${reset} $name"; PASS=$((PASS+1))
@@ -93,17 +107,17 @@ absent "12. --coverage alone omits it" 'sks/table/table\.k' "$KDESC"
# reason that has nothing to do with what it is testing. (It did: adopting
# ".*" in the SKS made "All targets, declared" non-empty.)
shows "12a. --coverage all shows an empty reporting category" \
'All targets, declared \(0\)' "$KDESC" -i "$DIR/cycle.k" --coverage all
'All targets, declared \(0\)' "$KDESC" --klammersets none -i "$DIR/cycle.k" --coverage all
absent "12b. ... hidden without it" \
'All targets, declared \(0\)' "$KDESC" -i "$DIR/cycle.k" --coverage
'All targets, declared \(0\)' "$KDESC" --klammersets none -i "$DIR/cycle.k" --coverage
absent "12c. an empty problem category stays hidden" \
'Declared but never defined \(0\)' "$KDESC" -i "$DIR/cycle.k" --coverage all
'Declared but never defined \(0\)' "$KDESC" --klammersets none -i "$DIR/cycle.k" --coverage all
absent "13. -v adds no result detail" 'Regex' "$KDESC" --katoms -v 3
echo
echo "-- -v is about processing only --"
shows "14. -v names the input it read" 'input_filenames' "$KDESC" -t -v 1
absent "15. ... and is silent without it" 'input_filenames' "$KDESC" -t
shows "14. -v names the input it read" 'input_filenames' "$KDESC" -i "$DIR/clean.k" -t -v 1
absent "15. ... and is silent without it" 'input_filenames' "$KDESC" -i "$DIR/clean.k" -t
echo
echo "-- the klammer search --"
@@ -126,13 +140,46 @@ shows "25. --katoms help explains" 'Katom commands' "$KDESC" --katom
shows "26. an unknown coverage word" 'Unrecognized coverage command' "$KDESC" --coverage nonsense
shows "27. an unknown katom word" 'Unrecognized katom command' "$KDESC" --katoms nonsense
echo
echo "-- provenance: -i shows what the INPUT defines --"
# A klammerset is loaded so the input can be ANALYSED against it, but it is
# not what the user asked about. A designer wants the klammers this file
# defines; a reader of an unfamiliar document wants the custom ones it
# carries. So "-i" restricts every listing to definitions from outside the
# loaded klammersets -- while the klammersets stay loaded.
PROV=$(mktemp /tmp/kdesc_prov.XXXXXX.k)
printf '@@shout.k s : Say it loudly @@\n@@shout.html :: <b>*s*</b> @@\n' > "$PROV"
shows "32. -i lists the input's own klammer" '@shout' "$KDESC" -i "$PROV" -k
absent "33. ... and not the klammerset's" '@table' "$KDESC" -i "$PROV" -k
shows "34. without -i the klammerset is shown" '@table' "$KDESC" -k
# The klammerset is still LOADED: a coverage derived from a klammer it
# supplies proves the analysis saw it even though the report does not.
DERIVE=$(mktemp /tmp/kdesc_derive.XXXXXX.k)
printf '@@wave.k s : Wave @@\n@@wave :: @i *s* @ @@\n' > "$DERIVE"
shows "35. coverage is derived through the unlisted klammerset" \
'@wave +html pdf tex txt +from @i' "$KDESC" -i "$DERIVE" --coverage
absent "36. ... and the klammerset is not reported" '@table' "$KDESC" -i "$DERIVE" --coverage
# A klammer the input REDEFINES came from the klammerset, but the input
# changed it -- which is exactly what a reader needs to know.
REDEF=$(mktemp /tmp/kdesc_redef.XXXXXX.k)
printf '@@i.html ::: <em class="mine">*text*</em> @@\n' > "$REDEF"
shows "37. a redefined klammer is the input's too" '@i' "$KDESC" -i "$REDEF" -k
# An input that declares its own klammerset is still the input.
DECL=$(mktemp -d /tmp/kdesc_decl.XXXXXX)
mkdir -p "$DECL/own"
printf '@@@klammerset own | A designer set @@@\n@@yell.k s : Yell @@\n@@yell.html :: <b>*s*</b> @@\n' \
> "$DECL/own/own.k"
shows "38. an input declaring a klammerset is not filtered out" \
'@yell' "$KDESC" -i "$DECL/own/own.k" -k
rm -rf "$PROV" "$DERIVE" "$REDEF" "$DECL"
echo
echo "-- the usage text --"
shows "28. -k shows its optional argument" '\-k \[<text>\]' "$KDESC"
shows "29. -i shows its filename" '\-i <filename>' "$KDESC"
# Ordered by likely use: the single letters come before the long names.
# Line numbers, not a multi-line pattern -- grep is line-oriented.
usage=$(timeout 60 "$KDESC" 2>&1 | plain)
usage=$(limited "$KDESC" 2>&1 | plain)
k_line=$(printf '%s\n' "$usage" | grep -n -- '-k \[<text>\]' | head -1 | cut -d: -f1)
katoms_line=$(printf '%s\n' "$usage" | grep -n -- '--katoms' | head -1 | cut -d: -f1)
if [ -n "$k_line" ] && [ -n "$katoms_line" ] && [ "$k_line" -lt "$katoms_line" ]; then
@@ -144,7 +191,7 @@ fi
echo
echo "-- a no-result search is not an error --"
timeout 60 "$KDESC" -k zzqq >/dev/null 2>&1
limited "$KDESC" -k zzqq >/dev/null 2>&1
if [ $? -eq 0 ]; then
echo "${green}PASS${reset} 31. finding nothing exits 0"; PASS=$((PASS+1))
else

311
tst/kdiag_test.sh Executable file
View File

@@ -0,0 +1,311 @@
#!/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 '\[<input>\]')
if [ "$n_input" = "2" ]; then
pass " 5. usage lists [<input>] once as a positional (plus the Usage: line)"
else
fail " 5. usage lists [<input>] $n_input times, expected 2" \
"$(printf '%s\n' "$usage" | grep -n '\[<input>\]')"
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

View File

@@ -0,0 +1,12 @@
# Engine-tier fixture: the base of the klammerset COMBINING tests. It owns the
# target and the ".k" declaration that the two "house" sets override, so each
# of them can require it and neither has to be loaded first on the command
# line -- which is what makes command-line ORDER the only thing under test.
#
# The definitions sit in a :files entry rather than in this declaring file,
# which is NOT a style choice: the already-loaded guard skips a repeated
# declaration's :files, but the declaring file itself is read again every time
# a :requires names it. With the "@@@target fx" written here, requiring
# "base" from two sets reads it twice and the second read is a fatal "Target
# fx is already defined". See case 21c, which pins that.
@@@klammerset base | Base set for combining tests :date 2026-08-15 :files defs.k @@@

View File

@@ -0,0 +1,4 @@
# The base set's definitions. See base.k for why they are not in it.
@@@target fx | Combining fixture target @@@
@@who.k : Which klammerset supplied this definition @@
@@who.fx :: BASE @@

View File

@@ -1,11 +0,0 @@
# Engine-tier fixture: a klammerset with metadata, a dependency, an ordered
# file list (klammers.k uses the target defined in base.k), and a trailing
# definition that must load after the files (program order).
@@@klammerset kit | Engine-test klammerset
:name Klammerset Integration Test
:author Klammertext tests
:date 2026-07-30
:requires util.k
:files base.k / klammers.k
@@@
@@after : AFTER @@

View File

@@ -1 +0,0 @@
@@@klammerset kit | Duplicate declaration of kit :files dup_only.k @@@

View File

@@ -0,0 +1,9 @@
# One of two independent sets of house overrides. Both require "base" (loaded
# once, whichever asks first) and both override the same klammer with ":::", so
# the one written LAST on the command line is the one that wins. Its own
# klammer @a_only shows that the sets share one flat namespace rather than
# each keeping its klammers to itself.
@@@klammerset housea | House overrides A :requires base :date 2026-08-15 @@@
@@who.fx ::: HOUSE-A @@
@@a_only.k : A klammer only set A defines @@
@@a_only.fx :: ONLY-A @@

View File

@@ -0,0 +1,5 @@
# The mirror image of housea; see the comment there.
@@@klammerset houseb | House overrides B :requires base :date 2026-08-15 @@@
@@who.fx ::: HOUSE-B @@
@@b_only.k : A klammer only set B defines @@
@@b_only.fx :: ONLY-B @@

18
tst/klammerset/kit/kit.k Normal file
View File

@@ -0,0 +1,18 @@
# Engine-tier fixture: a klammerset with metadata, a dependency, an ordered
# file list (klammers.k uses the target defined in base.k), and a trailing
# definition that must load after the files (program order).
#
# Laid out as <symbol>/<symbol>.k, which is what a symbol names: since
# 2026-08-14 the command line takes symbols only, so a klammerset that
# cannot be named by one cannot be loaded at all. ":requires util" is a
# SYMBOL, resolved on the search path (this directory is not util's parent,
# so it is found at stage 2, KLAMMERTEXT_KLAMMERSETS); ":files" entries are
# filenames, resolved against this file's directory.
@@@klammerset kit | Engine-test klammerset
:name Klammerset Integration Test
:author Klammertext tests
:date 2026-07-30
:requires util
:files base.k / klammers.k
@@@
@@after : AFTER @@

View File

@@ -0,0 +1,2 @@
# Requires selfdef; paired with needs_b to make the diamond of case 21c.
@@@klammerset needs_a | Requires selfdef :requires selfdef :date 2026-08-15 @@@

View File

@@ -0,0 +1,2 @@
# Requires selfdef; paired with needs_a to make the diamond of case 21c.
@@@klammerset needs_b | Requires selfdef :requires selfdef :date 2026-08-15 @@@

View File

@@ -0,0 +1,8 @@
# A klammerset whose declaring file carries its own definitions (the
# documented alternative to a :text option: program order plays that role).
# Requiring it from two different sets reads this file twice, and the second
# read of the @@@target is fatal. Case 21c pins that defect; when it is
# fixed, that case fails and must be rewritten.
@@@klammerset selfdef | Definitions in the declaring file :date 2026-08-15 @@@
@@@target fy | Self-defining fixture target @@@
@@sd.fy : SELFDEF @@

6
tst/klammerset/sp/sp.k Normal file
View File

@@ -0,0 +1,6 @@
# The target and @greet come from kit, required by symbol; this set adds
# only the file whose name contains a space (standalone "/" separator).
@@@klammerset sp | Spacey filename in the list
:requires kit
:files space name.k
@@@

View File

@@ -1 +0,0 @@
@@@klammerset sp | Spacey filename in the list :files base.k / space name.k @@@

View File

@@ -0,0 +1,6 @@
# A declaration of the symbol "kit" from a file that is NOT kit/kit.k.
# Since 2026-08-15 that is an ERROR: a klammerset that can be named is
# identified by where it is, so a symbol names one file and one file declares
# one symbol. This fixture exists to prove the error; it formerly proved that
# such a duplicate was tolerated and skipped.
@@@klammerset kit | Duplicate declaration of kit :files dup_only.k @@@

View File

@@ -0,0 +1,3 @@
# Requires "kit" twice over: once by symbol, once through dup.k, which
# re-declares it. Loading "wrap" is therefore an error now -- see dup.k.
@@@klammerset wrap | Wrapper requiring kit twice :requires kit / dup.k @@@

View File

@@ -1 +0,0 @@
@@@klammerset wrap | Wrapper requiring kit twice :requires decl.k / decl2.k @@@

View File

@@ -23,6 +23,14 @@ KTEXT=ktext
K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set}
FIX=$K/tst/klammerset
# The fixtures are laid out as <symbol>/<symbol>.k under $FIX, because since
# 2026-08-14 the command line takes klammerset SYMBOLS only -- a set that
# cannot be named by one cannot be loaded. Putting $FIX on the search path is
# how the suite makes its own fixtures nameable, and it also exercises stage 2
# (KLAMMERTEXT_KLAMMERSETS) for both the command line and ":requires".
# Tests that need a different path set it themselves on the command.
export KLAMMERTEXT_KLAMMERSETS=$FIX
red=$'\033[31m'
green=$'\033[32m'
bold=$'\033[1m'
@@ -127,23 +135,23 @@ echo
# that base.k declares, so base.k must have been read first.
check_eq "1. :files load in order (target before klammer)" \
"Hello World" \
-k "$FIX/decl.k" -s '@greet World @' -t fix
--klammersets kit -s '@greet World @' -t fix
# 2. :requires loads the dependency before the set's own files.
check_eq "2. :requires loads the dependency" \
"--" \
-k "$FIX/decl.k" -s '@dash@' -t fix
--klammersets kit -s '@dash@' -t fix
# 3. Program order: a definition AFTER the declaration in the declaring file
# is available (there is no :text argument; the declaring file's own
# content plays that role).
check_eq "3. trailing definition in the declaring file" \
"AFTER" \
-k "$FIX/decl.k" -s '@after@' -t fix
--klammersets kit -s '@after@' -t fix
# 4. Relative :files names resolve against the DECLARING file's directory,
# not the cwd (run from an unrelated directory).
output=$( (cd /tmp && "$KTEXT" -k "$FIX/decl.k" -s '@greet Elsewhere @' -t fix) 2>/dev/null | trim )
output=$( (cd /tmp && "$KTEXT" --klammersets kit -s '@greet Elsewhere @' -t fix) 2>/dev/null | trim )
if [ "$output" = "Hello Elsewhere" ]; then
echo "${green}PASS${reset} 4. :files resolve against the declaring file's directory"
PASS=$((PASS + 1))
@@ -157,45 +165,83 @@ fi
# 5. A filename with a space in the :files list (standalone "/" separator).
check_eq "5. spacey filename in :files" \
"SPACEY" \
-k "$FIX/spacey.k" -s '@spacey@' -t fix
--klammersets sp -s '@spacey@' -t fix
# --- The already-loaded guard ------------------------------------------------
# --- A klammerset is identified by where it is -------------------------------
#
# Symbol X must be declared in X/X.k (Andy, 2026-08-15). The convention already
# governed symbol RESOLUTION; requiring it of the DECLARATION makes the symbol a
# function of the path, which is what lets the already-loaded guard run before a
# file is opened (cases 23-23b below) instead of after it has been re-executed.
#
# It also continues the reasoning behind symbols-only on the command line: if
# every klammerset is reachable by symbol the set of all of them is ENUMERABLE;
# if every declaration is in X/X.k each one is also IDENTIFIABLE from where it
# sits, so a symbol names one file and one file declares one symbol.
#
# "wrap" is the fixture that violates it: wrap/dup.k declares the symbol "kit".
# It formerly tested that such a duplicate was tolerated and SKIPPED; the
# duplicate is now impossible instead, which is the stronger guarantee.
check_fails "6. a declaration outside X/X.k is an error" \
'must be declared in a file named "kit/kit.k"' \
--klammersets wrap -s '@greet Again @' -t fix
check_fails "6a. ... and the error names the file it is actually in" \
'dup.k' \
--klammersets wrap -s '@greet Again @' -t fix
# 6. A second declaration of an already-registered symbol is skipped, not an
# error: wrapper.k requires decl.k (registers "kit") and then decl2.k
# (re-declares "kit"); the wrapper still loads and kit's klammers work.
check_eq "6. duplicate declaration is skipped, not an error" \
"Hello Again" \
-k "$FIX/wrapper.k" -s '@greet Again @' -t fix
# 7. ...and the skipped declaration's files are NOT loaded.
check_fails "7. skipped declaration loads none of its files" \
"only_dup" \
-k "$FIX/wrapper.k" -s '@only_dup@' -t fix
# 7. EXEMPT: a document may declare a klammerset -- that is how a designer
# writes one, and kdesc's provenance filter depends on it. Such a set is
# local to the document: nothing can ":requires" it, so it has no identity to
# protect and needs no file named after it.
check_eq "7. a klammerset declared in a document is exempt" \
"ok" \
--klammersets none -s '@@@klammerset mine | Local to this document @@@ ok'
# --- Introspection -----------------------------------------------------------
# 8. -m lists the registered klammersets.
check_contains "8. -m shows the klammerset symbol" \
"kit" \
-k "$FIX/decl.k" -s 'x' -t fix -m
# The machine state moved from "ktext -m" to "kdiag --machine" in the
# 2026-08-14 argument redesign: the Machine's internals are the programmer's
# question, and kdiag is the programmer's command. kdiag's input is optional,
# so the state can be shown without a document at all.
kdiag_contains() {
local test_name="$1" expected="$2"; shift 2
local output
output=$(kdiag "$@" 2>/dev/null)
if printf '%s' "$output" | grep -qF -- "$expected"; then
echo "${green}PASS${reset} $test_name"; PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} $test_name"
echo " expected to contain: [$expected]"
FAIL=$((FAIL + 1))
fi
}
check_contains "9. -m shows the required klammerset too" \
# 8. --machine lists the registered klammersets. kdiag has no --klammersets:
# a klammerset reaches it the way anything else does, by being read in the
# input. That is deliberate -- the symbol mechanism is a higher-level
# convenience, and kdiag must still work when symbol resolution is what
# broke. (The :requires inside kit.k still uses a symbol, resolved by the
# Machine, so this exercises both paths at once.)
kdiag_contains "8. --machine shows the klammerset symbol" \
"kit" \
"@read $FIX/kit/kit.k @" --process --machine
kdiag_contains "9. --machine shows the required klammerset too" \
"Utility klammers for engine tests" \
-k "$FIX/decl.k" -s 'x' -t fix -m
"@read $FIX/kit/kit.k @" --process --machine
# --- Errors --------------------------------------------------------------------
# 10. A listed file that does not exist is a clean klammerset error.
check_fails "10. missing file in :files" \
"missing.k" \
-k "$FIX/bad_file.k" -s 'x' -t fix
--klammersets broken -s 'x' -t fix
# 11. A symbol must be an identifier (starts with a letter; letters, digits,
# underscores).
check_fails "11. invalid symbol rejected" \
"not valid" \
-k none -s '@@@klammerset 9bad | Bad symbol @@@' -d
--klammersets none -s '@@@klammerset 9bad | Bad symbol @@@' -d
# --- The search path (symbol -> <dir>/<symbol>/<symbol>.k) -------------------
@@ -217,10 +263,10 @@ printf '@@@klammerset locset | Shadow candidate @@@\n@@@target fixL | Fixture @@
# 12. Stage 1 for a file input: the document's directory.
check_eq "12. symbol resolves in the document's directory" \
"LOCAL" \
"$DOCDIR/doc.kt" -k locset -t fixL -d
"$DOCDIR/doc.kt" --klammersets locset -t fixL -d
# 13. Stage 1 for string input: the cwd stands in for the document.
output=$( (cd "$DOCDIR" && "$KTEXT" -s '@local_k@' -k locset -t fixL) 2>/dev/null | trim )
output=$( (cd "$DOCDIR" && "$KTEXT" -s '@local_k@' --klammersets locset -t fixL) 2>/dev/null | trim )
if [ "$output" = "LOCAL" ]; then
echo "${green}PASS${reset} 13. cwd stands in for the document (string input)"
PASS=$((PASS + 1))
@@ -231,7 +277,7 @@ else
fi
# 14. Stage 2: the KLAMMERTEXT_KLAMMERSETS directories.
output=$(KLAMMERTEXT_KLAMMERSETS=$ENVDIR "$KTEXT" -s '@env_k@' -k envset -t fixE 2>/dev/null | trim)
output=$(KLAMMERTEXT_KLAMMERSETS=$ENVDIR "$KTEXT" -s '@env_k@' --klammersets envset -t fixE 2>/dev/null | trim)
if [ "$output" = "ENV" ]; then
echo "${green}PASS${reset} 14. symbol resolves in KLAMMERTEXT_KLAMMERSETS"
PASS=$((PASS + 1))
@@ -242,7 +288,7 @@ else
fi
# 15. Shadowing: the document-local set wins over the installed one.
output=$(KLAMMERTEXT_KLAMMERSETS=$ENVDIR "$KTEXT" "$DOCDIR/doc.kt" -k locset -t fixL -d 2>/dev/null | trim)
output=$(KLAMMERTEXT_KLAMMERSETS=$ENVDIR "$KTEXT" "$DOCDIR/doc.kt" --klammersets locset -t fixL -d 2>/dev/null | trim)
if [ "$output" = "LOCAL" ]; then
echo "${green}PASS${reset} 15. document-local set shadows the installed one"
PASS=$((PASS + 1))
@@ -257,26 +303,170 @@ fi
mkdir -p "$DOCDIR/kit2/locset"
cp "$DOCDIR/locset/locset.k" "$DOCDIR/kit2/locset/locset.k"
printf '@@@klammerset kit2 | Requires by symbol :requires locset @@@\n' > "$DOCDIR/kit2/kit2.k"
check_eq "16. :requires accepts a symbol (declaring-dir stage)" \
"LOCAL" \
-k "$DOCDIR/kit2/kit2.k" -s '@local_k@' -t fixL
output=$(KLAMMERTEXT_KLAMMERSETS=$DOCDIR "$KTEXT" --klammersets kit2 \
-s '@local_k@' -t fixL 2>/dev/null | trim)
if [ "$output" = "LOCAL" ]; then
echo "${green}PASS${reset} 16. :requires accepts a symbol (declaring-dir stage)"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} 16. :requires accepts a symbol (declaring-dir stage)"
echo " expected: [LOCAL] got: [$output]"
FAIL=$((FAIL + 1))
fi
# 17. Stage 3: $KLAMMERTEXT_HOME — the kdesc listing enumerates sks
# (sks/sks.k already satisfies the <symbol>/<symbol>.k convention).
output=$(kdesc --klammerset 2>/dev/null)
output=$(kdesc --klammersets 2>/dev/null)
if printf '%s' "$output" | grep -q 'sks/sks\.k'; then
echo "${green}PASS${reset} 17. kdesc --klammerset lists sks from KLAMMERTEXT_HOME"
echo "${green}PASS${reset} 17. kdesc --klammersets lists sks from KLAMMERTEXT_HOME"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} 17. kdesc --klammerset lists sks from KLAMMERTEXT_HOME"
echo "${red}FAIL${reset} 17. kdesc --klammersets lists sks from KLAMMERTEXT_HOME"
echo " got: $(echo "$output" | head -3)"
FAIL=$((FAIL + 1))
fi
# 18. An unknown symbol is a clean error naming the search directories.
check_fails "18. unknown symbol names the search path" \
# 18. An unknown symbol is a clean error, and it names both the convention and
# the directories searched -- without those a user cannot tell whether the
# name or the location is wrong. This is the failure mode the symbol-only
# rule makes common: there is no longer a pathname to fall back on.
check_fails "18. unknown symbol is a clean error" \
"was not found" \
-s 'x' -k nosuchset -d
-s 'x' --klammersets nosuchset -d
check_fails "18a. ... naming the <symbol>/<symbol>.k convention" \
"x/x.k" \
-s 'x' --klammersets nosuchset -d
check_fails "18b. ... and the directories searched" \
"$FIX" \
-s 'x' --klammersets nosuchset -d
# 19. A pathname is no longer accepted: only symbols resolve. The error must
# say so rather than reporting a missing file.
check_fails "19. a pathname is rejected as a symbol" \
"not found" \
-s 'x' --klammersets "$FIX/kit/kit.k" -d
# 20. One bad symbol among good ones fails the whole load rather than
# silently loading the rest.
check_fails "20. a bad symbol among good ones still fails" \
"was not found" \
-s 'x' --klammersets kit nosuchset -t fix -d
# --- Several klammersets combine ---------------------------------------------
#
# The headline claim of "--klammersets" (plural), stated in the ktext usage and
# in "kdesc --klammersets help": the symbols are a LIST, loaded in the order
# given, and the klammers of all of them share ONE flat namespace -- membership
# is provenance, not containment. Where two sets define the same klammer the
# definition modes decide, so a set of house overrides is loaded after the set
# it adjusts and a set of defaults before it.
#
# The fixtures: "base" declares the target and the ".k", "housea" and "houseb"
# each require base and each override @who with ":::". Both requiring base is
# what lets either be written first, so command-line order is the only variable.
# 21. One namespace: each set's own klammers are available whatever the order.
check_eq "21. combined sets share one namespace" \
"ONLY-A ONLY-B" \
--klammersets housea houseb -s '@a_only@ @b_only@' -t fx
check_eq "21a. ... in either order" \
"ONLY-A ONLY-B" \
--klammersets houseb housea -s '@a_only@ @b_only@' -t fx
# 22. Order decides: two ":::" overrides of the same klammer, so the set
# written LAST is the one whose definition survives.
check_eq "22. the last set written wins an override" \
"HOUSE-B" \
--klammersets housea houseb -s '@who@' -t fx
check_eq "22a. ... and reversing the order reverses the outcome" \
"HOUSE-A" \
--klammersets houseb housea -s '@who@' -t fx
# 23. :requires is loaded once even though both sets ask for it -- the
# already-loaded guard -- so the base definition is there exactly once.
check_eq "23. a set required by both is loaded once" \
"BASE" \
--klammersets base -s '@who@' -t fx
# 23a. A DIAMOND: two sets requiring a third. "needs_a" and "needs_b" both
# require "selfdef", whose declaring file holds its own @@@target -- which
# is legal and normal, since there is deliberately no :text option and the
# declaring file's content plays that role.
#
# This failed until 2026-08-15 with "Target fy is already defined",
# pointing at a line the author wrote once. The already-loaded guard sat
# in Klammerset_registry::add, which runs only after a file has been read
# and its declaration reached -- by which time the second read had
# re-executed the declaring file's own definitions. The guard now runs
# BEFORE the read, which is possible only because X is declared in X/X.k
# and the symbol is therefore known from the path.
check_eq "23a. a diamond :requires loads the shared set once" \
"SELFDEF" \
--klammersets needs_a needs_b -s '@sd@' -t fy
check_eq "23b. ... and requiring it once is unchanged" \
"SELFDEF" \
--klammersets needs_a -s '@sd@' -t fy
check_eq "23c. ... as is naming the same set twice on the command line" \
"SELFDEF" \
--klammersets selfdef selfdef -s '@sd@' -t fy
# 23d. The search path is FROZEN for the run. A path decides WHICH FILE a
# symbol means -- identity, not value -- so if a document could extend it
# mid-run, "which klammerset is X" would depend on evaluation order and
# "kdesc --klammersets" could not be a complete answer. It could:
# the embedded Python shares the process, so an @eval doing os.environ
# changed what a later getenv returned. The environment is now read once.
LATE=$(mktemp -d /tmp/klammerset_late.XXXXXX)
mkdir -p "$LATE/late"
printf '@@@klammerset late | Appeared mid-run @@@\n@@@target lt | T @@@\n@@l.lt : LATE @@\n' \
> "$LATE/late/late.k"
# The @eval still sets the variable -- what changed is that resolution no
# longer consults it -- so the symbol must remain unfindable.
out=$(KLAMMERTEXT_KLAMMERSETS=$FIX "$KTEXT" --klammersets none -d \
-s "@eval os.environ.__setitem__('KLAMMERTEXT_KLAMMERSETS','$LATE') or '' @" 2>&1)
if printf '%s' "$out" | grep -q "was not found" || \
! KLAMMERTEXT_KLAMMERSETS=$FIX "$KTEXT" --klammersets late -s 'x' -d >/dev/null 2>&1; then
echo "${green}PASS${reset} 23d. an @eval cannot extend the klammerset search path"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} 23d. the search path moved during the run"
FAIL=$((FAIL + 1))
fi
rm -rf "$LATE"
# --- "none" is the absence of a klammerset, not the name of one ---------------
#
# It therefore says nothing about what should be loaded alongside it. Written
# first it used to discard the rest of the list silently -- the user asked for
# a set, got none, and was told only that some target was undefined; written
# last it was looked up as a symbol and reported missing. Neither reading is
# guessed at now: "none" is accepted only alone.
# 24. "none" alone still loads nothing, which is what it is for.
check_eq "24. none alone loads no klammerset" \
"x" \
--klammersets none -s 'x' -d
check_fails "25. none before another symbol is an error" \
"cannot be combined" \
--klammersets none kit -s 'x' -t fix -d
check_fails "25a. none after another symbol is an error too" \
"cannot be combined" \
--klammersets kit none -s 'x' -t fix -d
# 26. The rule belongs to load_klammersets(), the single loader all three
# commands share -- so kdesc obeys it as well. It did not: kdesc decided
# what "none" meant a second time, and so accepted "none sks" and loaded
# neither. Deciding it twice is how the two commands came to disagree.
out=$(kdesc --klammersets none sks -k table 2>&1); status=$?
if [ $status -ne 0 ] && printf '%s' "$out" | grep -qF "cannot be combined"; then
echo "${green}PASS${reset} 26. kdesc applies the same none rule"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} 26. kdesc applies the same none rule — exit $status"
echo " got: $(printf '%s' "$out" | head -2)"
FAIL=$((FAIL + 1))
fi
echo
echo "======================="

View File

@@ -20,7 +20,7 @@
# files read earlier. A klammer DEFINED in a read file is applied later and
# always sees its own directory — the realistic case, tested below.
#
# Engine tier: -k none, fixtures created inline in a temp tree, no SKS.
# Engine tier: --klammersets none, fixtures created inline in a temp tree, no SKS.
# Usage: ./modulepath_test.sh (ktext on PATH)
PASS=0
@@ -50,7 +50,7 @@ printf '@eval nosuch_module_xyz.f() @\n' > "$T/setb/missing.kt"
check() { # check NAME EXPECTED ARGS...
local name="$1" expected="$2"; shift 2
local out
out=$(ktext "$@" -k none -d 2>/dev/null)
out=$(ktext "$@" --klammersets none -d 2>/dev/null)
if [ "$out" = "$expected" ]; then
echo "${green}PASS${reset} $name"; PASS=$((PASS + 1))
else
@@ -75,7 +75,7 @@ check " 2. the same, input given as a relative path" \
"../setb/doc.kt"
name=" 3. an unknown module still reports cleanly"
err=$(ktext "$T/setb/missing.kt" -k none -d 2>&1 >/dev/null)
err=$(ktext "$T/setb/missing.kt" --klammersets none -d 2>&1 >/dev/null)
case "$err" in
*'Cannot import module "nosuch_module_xyz"'*)
echo "${green}PASS${reset} $name"; PASS=$((PASS + 1)) ;;
@@ -111,7 +111,7 @@ check " 7. a :cwd directory containing spaces" \
name=" 8. a nonexistent :cwd is a clean argument error"
printf '@eval :cwd /nonexistent_kt_dir :shell true @\n' > "$T/setb/bad.kt"
err=$(ktext "$T/setb/bad.kt" -k none -d 2>&1 >/dev/null)
err=$(ktext "$T/setb/bad.kt" --klammersets none -d 2>&1 >/dev/null)
case "$err" in
*'The :cwd directory does not exist'*)
echo "${green}PASS${reset} $name"; PASS=$((PASS + 1)) ;;

View File

@@ -50,7 +50,7 @@ PRELUDE='@@@target fix | a fixture target @@@
check_eq() {
local name="$1" expected="$2" source="$3" target="${4:-fix}"
local output status
output=$("$KTEXT" -k none -s "$PRELUDE
output=$("$KTEXT" --klammersets none -s "$PRELUDE
$source" -t "$target" -d 2>&1)
status=$?
output=$(printf '%s' "$output" | tr -d '\n' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
@@ -76,7 +76,7 @@ $source" -t "$target" -d 2>&1)
check_fails() {
local name="$1" needle="$2" source="$3"
local output status
output=$("$KTEXT" -k none -s "$PRELUDE
output=$("$KTEXT" --klammersets none -s "$PRELUDE
$source" -t fix -d 2>&1)
status=$?
if [ $status -eq 0 ]; then
@@ -98,11 +98,23 @@ $source" -t fix -d 2>&1)
# check_warns NAME SUBSTRING SOURCE — SOURCE must be accepted, and its
# combined output must contain SUBSTRING (used where a warning is expected
# beside the result, so an exact comparison would test the warning's wording).
# Since 2026-08-15 the override notice is reported at "-v 1" rather than
# warned about: ":::" exists to override, so the notice fired on the sanctioned
# use of a feature (see tst/deftype_test.sh's check_warn for the reasoning).
# The default run must be SILENT and the notice must appear at "-v 1".
check_warns() {
local name="$1" needle="$2" source="$3"
local output status
output=$("$KTEXT" -k none -s "$PRELUDE
$source" -t fix -d 2>&1)
local output status quiet
quiet=$("$KTEXT" --klammersets none -s "$PRELUDE
$source" -t fix -d 2>&1 >/dev/null)
if [ -n "$quiet" ]; then
echo "${red}FAIL${reset} $name — the default run was not silent"
echo " got: $(printf '%s' "$quiet" | head -1)"
FAIL=$((FAIL + 1))
return
fi
output=$("$KTEXT" --klammersets none -s "$PRELUDE
$source" -t fix -d -v 1 2>&1)
status=$?
if [ $status -ne 0 ]; then
echo "${red}FAIL${reset} $name — ktext exited $status"

View File

@@ -15,7 +15,7 @@
# growing -- and exceeding its round limit is an error rather than a message
# followed by rendering a document with live klammers still in it.
#
# These are engine tests: no klammer set is loaded (-k none) and every klammer
# These are engine tests: no klammer set is loaded (--klammersets none) and every klammer
# used is defined inline as a fixture.
#
# Usage: ./recursion_test.sh
@@ -100,29 +100,29 @@ echo
check_error " 1. direct self-recursion is caught" \
"does not terminate" \
-k none -s '@@f : x @f@ @@ @f@' -d
--klammersets none -s '@@f : x @f@ @@ @f@' -d
check_error " 2. the offending klammer is named" \
'applying "f"' \
-k none -s '@@f : x @f@ @@ @f@' -d
--klammersets none -s '@@f : x @f@ @@ @f@' -d
check_error " 3. mutual recursion is caught" \
"does not terminate" \
-k none -s '@@a : ( @b@ ) @@ @@b : [ @a@ ] @@ @a@' -d
--klammersets none -s '@@a : ( @b@ ) @@ @@b : [ @a@ ] @@ @a@' -d
check_error " 4. self-recursion through an argument is caught" \
"does not terminate" \
-k none -s '@@w t : < *t* > @@ @@r : @w @r@ @ @@ @r@' -d
--klammersets none -s '@@w t : < *t* > @@ @@r : @w @r@ @ @@ @r@' -d
# --- Terminating nesting is untouched ---
check_eq " 5. deep but finite nesting still reduces" \
"<<<<<x>>>>>" \
-k none -s '@@w t : <*t*> @@ @w @w @w @w @w x @ @ @ @ @' -d
--klammersets none -s '@@w t : <*t*> @@ @w @w @w @w @w x @ @ @ @ @' -d
check_eq " 6. a chain of klammers generating klammers reduces" \
"END" \
-k none -s '@@k1 : @k2@ @@ @@k2 : @k3@ @@ @@k3 : @k4@ @@ @@k4 : @k5@ @@ @@k5 : @k6@ @@ @@k6 : @k7@ @@ @@k7 : @k8@ @@ @@k8 : END @@ @k1@' -d
--klammersets none -s '@@k1 : @k2@ @@ @@k2 : @k3@ @@ @@k3 : @k4@ @@ @@k4 : @k5@ @@ @@k5 : @k6@ @@ @@k6 : @k7@ @@ @@k7 : @k8@ @@ @@k8 : END @@ @k1@' -d
# --- The fixed point ends on "nothing was applied", not "nothing was added" ---
#
@@ -131,7 +131,7 @@ check_eq " 6. a chain of klammers generating klammers reduces" \
check_eq " 7. a klammer with an empty body reduces" \
"a b" \
-k none -s '@@nothing : @@ a @nothing@ b' -d
--klammersets none -s '@@nothing : @@ a @nothing@ b' -d
echo
echo "============================="

View File

@@ -14,7 +14,7 @@
# target a "::" instance with no parameter list of its own. That is the
# migration from repeating an argument list per target to declaring it once.
#
# These are engine tests, so they use -k none and define their own targets
# These are engine tests, so they use --klammersets none and define their own targets
# inline: a target is a Machine construct (@@@target), not owned by any
# klammer set.
#
@@ -46,7 +46,7 @@ TARGETS='@@@target ta | Target A @@@
# accepted NAME SRC — the definitions are consistent and the klammer applies.
accepted() {
local name="$1" src="$2" out
out=$("$KTEXT" -k none -s "$TARGETS$src" -t ta -d 2>&1)
out=$("$KTEXT" --klammersets none -s "$TARGETS$src" -t ta -d 2>&1)
if echo "$out" | grep -qi "error"; then
fail "$name" "no error" "$(echo "$out" | grep -i -A1 error | tail -1)"
else
@@ -57,7 +57,7 @@ accepted() {
# rejected NAME SRC PATTERN — the drift is caught, and the message says how.
rejected() {
local name="$1" src="$2" want="$3" out
out=$("$KTEXT" -k none -s "$TARGETS$src" -t ta -d 2>&1)
out=$("$KTEXT" --klammersets none -s "$TARGETS$src" -t ta -d 2>&1)
if ! echo "$out" | grep -qi "error"; then
fail "$name" "a definition error" "accepted"
elif ! echo "$out" | grep -qF "$want"; then
@@ -102,7 +102,7 @@ echo "-- the message must show HOW they differ, not just where"
# With more than two targets the designer would otherwise have to diff the
# definitions by hand; each target's own signature is listed beside its name.
name=" 6. each target's signature is shown"
out=$("$KTEXT" -k none -s "$TARGETS"'@@k6.ta s :one : [*s*] @@
out=$("$KTEXT" --klammersets none -s "$TARGETS"'@@k6.ta s :one : [*s*] @@
@@k6.tb s :two : [*s*] @@
@k6 x @' -t ta -d 2>&1)
if echo "$out" | grep -q "ta .*s :one" && echo "$out" | grep -q "tb .*s :two"; then
@@ -114,7 +114,7 @@ fi
# The error path once printed internal katom detail through msg(), which is
# debugging scaffolding and must never reach a user-facing message.
name=" 7. no internal debug output on the error path"
out=$("$KTEXT" -k none -s "$TARGETS"'@@k7.k s : a declaration @@
out=$("$KTEXT" --klammersets none -s "$TARGETS"'@@k7.k s : a declaration @@
@@k7.ta s2 :other : [*s2*] @@
@k7 x @' -t ta -d 2>&1)
if echo "$out" | grep -q "klammer-definition"; then

View File

@@ -22,7 +22,7 @@
# "*arg*" variables, and the closing delimiters
# Case 14 guards the second: a comma next to an application is writer text.
#
# These are engine tests, so they use -k none and define their own targets
# These are engine tests, so they use --klammersets none and define their own targets
# inline: a target is a Machine construct (@@@target), not owned by any
# klammer set.
#
@@ -54,7 +54,7 @@ trim() { awk '{ sub(/[ \t\r]+$/, "") } { line[NR]=$0 } END { f=1; while (f<=NR &
check_eq() {
local name="$1" target="$2" expected="$3" src="$4"
local out status err
out=$("$KTEXT" -k none -s "$TARGETS$src" -t "$target" -d 2>"$ERR"); status=$?
out=$("$KTEXT" --klammersets none -s "$TARGETS$src" -t "$target" -d 2>"$ERR"); status=$?
err=$(cat "$ERR")
out=$(printf '%s' "$out" | trim)
if [ $status -ne 0 ]; then
@@ -73,18 +73,25 @@ check_eq() {
fi
}
# check_warn NAME TARGET EXPECTED SRC — exit 0, stdout==EXPECTED, AND a warning.
# check_warn NAME TARGET EXPECTED SRC — exit 0, stdout==EXPECTED, the override
# SILENT at the default verbosity and REPORTED at "-v 1" (2026-08-15; see
# tst/deftype_test.sh's check_warn for why it is no longer a warning).
check_warn() {
local name="$1" target="$2" expected="$3" src="$4"
local out status err
out=$("$KTEXT" -k none -s "$TARGETS$src" -t "$target" -d 2>"$ERR"); status=$?
err=$(cat "$ERR")
out=$("$KTEXT" --klammersets none -s "$TARGETS$src" -t "$target" -d 2>"$ERR"); status=$?
out=$(printf '%s' "$out" | trim)
if [ $status -ne 0 ]; then
echo "${red}FAIL${reset} $name — ktext exited $status"; FAIL=$((FAIL+1)); return
fi
if ! printf '%s' "$err" | grep -qiF "warning"; then
echo "${red}FAIL${reset} $nameexpected a warning, got none"; FAIL=$((FAIL+1)); return
if [ -s "$ERR" ]; then
echo "${red}FAIL${reset} $namethe default run was not silent: $(head -1 "$ERR")"
FAIL=$((FAIL+1)); return
fi
"$KTEXT" --klammersets none -s "$TARGETS$src" -t "$target" -d -v 1 >/dev/null 2>"$ERR"
err=$(cat "$ERR")
if ! printf '%s' "$err" | grep -qiF "overridden"; then
echo "${red}FAIL${reset} $name — no override reported at -v 1"; FAIL=$((FAIL+1)); return
fi
if [ "$out" = "$expected" ]; then
echo "${green}PASS${reset} $name"; PASS=$((PASS+1))
@@ -98,7 +105,7 @@ check_warn() {
check_error() {
local name="$1" target="$2" pattern="$3" src="$4"
local out status
out=$("$KTEXT" -k none -s "$TARGETS$src" -t "$target" -d 2>&1); status=$?
out=$("$KTEXT" --klammersets none -s "$TARGETS$src" -t "$target" -d 2>&1); status=$?
if [ $status -eq 0 ]; then
echo "${red}FAIL${reset} $name — expected an error but ktext succeeded"; FAIL=$((FAIL+1)); return
fi

155
tst/verbosity_test.sh Executable file
View File

@@ -0,0 +1,155 @@
#!/bin/bash
#
# verbosity_test.sh — what "-v" says, and what it must not say.
#
# The output policy (CLAUDE.md, "Command output policy") fixes what each
# verbosity level is FOR, and a policy with no test drifts back — which is how
# the suites this session repaired got where they were. The rule:
#
# -v 0 silent. Nothing but the result.
# -v 1 every decision whose outcome the user COULD NOT HAVE READ OFF THEIR
# OWN INPUT: a klammerset symbol resolved to a file, a default applied,
# "-o" expanded into a directory and a basename, a filename regrouped,
# a klammer overridden by a definition in another klammer set.
# -v 2+ the trace: what the command DID, for someone reading the code.
#
# The boundary is the part worth testing. Andy's first formulation was "-v 1
# describes a change of state", and taken literally that swallows -v 2 — every
# definition is a change of state. So the test for -v 1 is not "did something
# happen" but "could the user have predicted the outcome from what they wrote".
# Registering a klammer the user wrote, in the file they wrote, is NOT -v 1
# material; resolving a symbol through a three-stage search path with shadowing
# is.
#
# Engine tier: no klammerset beyond what a case loads deliberately. The SKS
# half of this policy — font resolution and ":files" resolution — is in
# sks/tst/verbosity_test.sh, because those are @document's decisions.
#
# Usage: ./verbosity_test.sh (needs KLAMMERTEXT_HOME set; commands on PATH)
# 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'
OUTF=$(mktemp /tmp/verb_out.XXXXXX)
ERRF=$(mktemp /tmp/verb_err.XXXXXX)
TMPD=$(mktemp -d /tmp/verb_dir.XXXXXX)
trap 'rm -f "$OUTF" "$ERRF"; rm -rf "$TMPD"' EXIT
plain() { sed 's/\x1b\[[0-9;]*m//g'; }
pass() { echo "${green}PASS${reset} $1"; PASS=$((PASS + 1)); }
fail() { echo "${red}FAIL${reset} $1"; [ -n "$2" ] && echo " $2"; FAIL=$((FAIL + 1)); }
# run CMD ARGS... — fills OUTF/ERRF, sets STATUS.
STATUS=0
run() { "$@" >"$OUTF" 2>"$ERRF"; STATUS=$?; }
# reports NAME PATTERN — ERRF matches PATTERN (the log is on stderr, always).
reports() {
if plain < "$ERRF" | grep -Eq -- "$2"; then pass "$1"
else fail "$1" "no [$2] in: $(plain < "$ERRF" | head -3 | tr '\n' ' ')"; fi
}
absent() {
if plain < "$ERRF" | grep -Eq -- "$2"; then fail "$1" "unexpected [$2]"
else pass "$1"; fi
}
echo "${bold}Verbosity tests${reset}"
echo "==============="
echo
echo "-- -v 0 is silent --"
run "$KTEXT" --klammersets none -s 'hello' -d
if [ $STATUS -eq 0 ] && [ "$(cat "$OUTF")" = "hello" ] && [ ! -s "$ERRF" ]; then
pass " 1. a successful run says nothing but its result"
else
fail " 1. exit $STATUS, stdout [$(cat "$OUTF")], stderr [$(head -1 "$ERRF")]"
fi
echo
echo "-- -v 1 answers \"what did the command think I asked for?\" --"
run "$KTEXT" --klammersets none -s 'hello' -d -v 1
reports " 2. the resolved argument list is shown" '\-s +: hello'
reports " 3. ... including a default the user did not write" '\-v +: 1'
# The document is on stdout and nothing shares it, whatever -v says.
if [ "$(cat "$OUTF")" = "hello" ]; then
pass " 4. and the result is still the only thing on stdout"
else
fail " 4. stdout carried [$(head -c 60 "$OUTF")]"
fi
echo
echo "-- -v 1 reports DERIVED values --"
# The output path: the user gave a target and an input name; the directory, the
# basename and the final filename were all constructed.
printf 'hello\n' > "$TMPD/doc.kt"
run "$KTEXT" "$TMPD/doc.kt" -t txt --klammersets none -v 1
reports " 5. the output directory it constructed" 'Output directory: '
reports " 6. the output basename it constructed" 'Output basename: doc'
reports " 7. the output filename it constructed" 'Output filename: .*doc\.txt'
# A klammerset symbol resolves through a three-stage search path with
# shadowing, so the FILE it landed on is the derived value. Both branches are
# checked: the default (no --klammersets at all) reported nothing until
# 2026-08-15, which was the commonest case of all.
run "$KTEXT" -s 'x' -d -v 1
reports " 8. the DEFAULT klammerset names the file it loaded" 'Klammerset \(default\): .*sks/sks\.k'
mkdir -p "$TMPD/kset"
printf '@@@klammerset kset | A set @@@\n' > "$TMPD/kset/kset.k"
run env KLAMMERTEXT_KLAMMERSETS="$TMPD" "$KTEXT" -s 'x' -d --klammersets kset -v 1
reports " 9. a symbol names the file it resolved to" 'Klammerset "kset": .*kset/kset\.k'
run "$KTEXT" -s 'x' -d --klammersets none -v 1
reports "10. and \"none\" says that none was loaded" 'Klammersets: none'
# An override: the definition being replaced usually lives somewhere the user
# cannot see, which is what earns it a place here rather than a warning.
run "$KTEXT" --klammersets none -t fx -d -v 1 \
-s '@@@target fx | F @@@ @@w.k : W @@ @@w.fx :: A @@ @@w.fx ::: B @@ @w@'
reports "11. a klammer overridden by a later definition" 'overridden'
echo
echo "-- the boundary: -v 2 is the trace, -v 1 is not --"
run "$KTEXT" --klammersets none -s 'hello' -d -v 1
v1=$(wc -l < "$ERRF")
run "$KTEXT" --klammersets none -s 'hello' -d -v 2
v2=$(wc -l < "$ERRF")
if [ "$v2" -gt "$v1" ]; then
pass "12. -v 2 says more than -v 1 ($v1 lines -> $v2)"
else
fail "12. -v 2 added nothing ($v1 -> $v2)"
fi
# The trace names the code; the -v 1 report never should. A source location in
# a level-1 line means a msg() or a stray log level, not a decision.
run "$KTEXT" --klammersets none -s 'hello' -d -v 1
absent "13. -v 1 does not name source files" '\[[a-z_]+\.cpp:[0-9]+\]'
run "$KTEXT" --klammersets none -s 'hello' -d -v 2
reports "14. ... while -v 2 does" '\[[a-z_]+\.(cpp|h):[0-9]+\]'
echo
echo "-- the other two commands honour -v as well --"
run kdesc --katoms -v 1
if [ $STATUS -eq 0 ] && [ -s "$OUTF" ]; then
pass "15. kdesc -v 1 still produces its result on stdout"
else
fail "15. exit $STATUS, stdout $(wc -c < "$OUTF") bytes"
fi
run kdiag -v 1 '@i-x'
if [ $STATUS -eq 0 ] && [ -s "$OUTF" ]; then
pass "16. kdiag -v 1 still produces its result on stdout"
else
fail "16. exit $STATUS, stdout $(wc -c < "$OUTF") bytes"
fi
echo
echo "==============="
echo "Results: ${PASS} passed, ${FAIL} failed"
[ "$FAIL" -eq 0 ] || exit 1
exit 0