Recursion guard and static klammer checking

A klammer that reaches itself, directly or through a cycle, expanded
until the C++ stack was exhausted: the process died from SIGSEGV with no
message and no location. The former limit guarded only the top-level
fixed-point iteration, never the descent through klammer application. A
depth guard now raises a recursion error naming the klammer and where it
was applied. The same loop's termination test moves from "the katom list
stopped growing" to "a pass applied no klammer", since a klammer whose
body expands to nothing is a reduction that adds no katoms; exceeding the
round limit is now an error rather than a message followed by rendering a
document with live klammers still in it.

ktext --check locates every klammer application written in a document or
in a klammer body and checks name existence, argument count, option
names, and target coverage without applying anything, reporting all
problems at once. This is possible because Klammertext has no catcodes:
katom structure is fixed when a file is read, so a klammer body has a
determinate shape before it is expanded. The check therefore reaches what
the engine cannot -- the branch of a @cond that is not selected, and
bodies a given render never enters.

@cond's set of truth values is an open language question, so its meaning
is unchanged here; an unrecognized predicate now warns, giving its value
and location.

tst/ gains recursion_test.sh (7 cases) and check_test.sh (19 cases), and
this snapshot's test Makefile is generated from the shipped suite list so
the two cannot drift apart.

(from dev c27e63802406)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 15:41:43 +02:00
parent 55a99c7eeb
commit 4306dcd490
11 changed files with 849 additions and 29 deletions

204
tst/check_test.sh Executable file
View File

@@ -0,0 +1,204 @@
#!/bin/bash
#
# check_test.sh — Static checking of klammer applications ("ktext --check").
#
# The engine applies klammers as it meets them, so it can only complain about
# what it reaches. Two things it therefore never reaches:
#
# * the branch of a @cond that is not selected. @cond is a non-strict
# special form, so an undefined klammer or a wrong argument count sitting
# in the unselected branch is invisible -- and stays invisible until the
# day the predicate flips.
# * a klammer body that this render does not use, including a body defined
# for a target other than the one being built.
#
# Klammertext can check both without running anything, because katom structure
# is fixed when a file is read: there are no catcodes, so nothing later can
# change how text already read divides into katoms. Every application written
# literally in a document or a klammer body can be located and checked against
# the registry. That is what check_machine() (mac/check.cpp) does and what
# these tests pin down.
#
# What the checker deliberately does not see is also tested: klammers produced
# by @eval at run time, and the interiors of @eval/@read argument spans and of
# literal parameters, which are code, filenames, and raw text -- not
# applications.
#
# Engine tier: no klammer set (-k none), every klammer defined inline.
#
# Usage: ./check_test.sh
# 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'
# check_finds TEST_NAME PATTERN KTEXT_ARGS...
# --check must exit nonzero and report PATTERN.
check_finds() {
local test_name="$1"
local pattern="$2"
shift 2
local output status
output=$("$KTEXT" --check "$@" 2>&1)
status=$?
if [ $status -eq 0 ]; then
echo "${red}FAIL${reset} $test_name — expected a diagnostic, got none"
FAIL=$((FAIL + 1))
return
fi
if echo "$output" | grep -qF "$pattern"; then
echo "${green}PASS${reset} $test_name"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} $test_name — expected report to contain [$pattern]"
echo " output: $(echo "$output" | head -4)"
FAIL=$((FAIL + 1))
fi
}
# check_clean TEST_NAME KTEXT_ARGS...
# --check must exit 0 and report no diagnostics.
check_clean() {
local test_name="$1"
shift
local output status
output=$("$KTEXT" --check "$@" 2>&1)
status=$?
if [ $status -eq 0 ] && echo "$output" | grep -q "0 diagnostics"; then
echo "${green}PASS${reset} $test_name"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} $test_name — expected a clean check"
echo " output: $(echo "$output" | head -4)"
FAIL=$((FAIL + 1))
fi
}
# check_count TEST_NAME N KTEXT_ARGS...
# --check must report exactly N diagnostics.
check_count() {
local test_name="$1"
local expected="$2"
shift 2
local output got
output=$("$KTEXT" --check "$@" 2>&1)
got=$(echo "$output" | sed -nE 's/^([0-9]+) diagnostics?,.*/\1/p')
if [ "$got" = "$expected" ]; then
echo "${green}PASS${reset} $test_name"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} $test_name — expected $expected diagnostics, got ${got:-none}"
echo " output: $(echo "$output" | head -4)"
FAIL=$((FAIL + 1))
fi
}
GREET='@@greet name : Hello, *name*. @@'
echo "${bold}Static klammer checking tests${reset}"
echo "============================="
echo
# --- What the renderer cannot reach ---
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 @"
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 @"
# 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 @"
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"
check_finds " 4. the body it was found in is named" \
"in body of @unused" \
-k none -s "@@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 @'
check_finds " 6. too many positional arguments" \
"takes 1 positional argument but is given 2" \
-k none -s "$GREET @greet a | b @"
check_finds " 7. undefined optional argument" \
'has no optional argument ":nope"' \
-k none -s "$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 @'
check_clean " 9. a rest argument accepts extra positional arguments" \
-k none -s '@@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 @ @'
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 @ @'
# --- 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@'
check_clean "13. defined for every target is clean" \
-k none -s '@@@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") @ @@'
check_clean "15. a literal parameter's content is raw text" \
-k none -s '@@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@ @@'
check_clean "17. a correct document checks clean" \
-k none -s "$GREET @greet World @"
check_clean "18. the Standard Klammer Set checks clean" \
-s 'x'
echo
echo "============================="
echo "Results: ${green}$PASS passed${reset}, ${red}$FAIL failed${reset}"
[ $FAIL -eq 0 ]