ci: parse-check every scheme fence in docs and examples

ober

f13c4a299d757b083b204b522fe040381ccbae54

diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..2070d82
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,35 @@
+name: Docs
+
+on:
+  push:
+    branches: [master]
+  pull_request:
+
+jobs:
+  check-doc-examples:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Install Chez Scheme
+        run: sudo apt-get update && sudo apt-get install -y chezscheme
+
+      - name: Parse-check every scheme fence in docs/ and examples/
+        run: make check-docs SCHEME=scheme
+
+  check-doc-examples-strict:
+    runs-on: ubuntu-latest
+    # Strict mode resolves imports (requires chez-sqlite/chez-httpd for some
+    # fences). Currently informational — does not block merges.
+    continue-on-error: true
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Install Chez Scheme
+        run: sudo apt-get update && sudo apt-get install -y chezscheme
+
+      - name: Build Jerboa libraries
+        run: make build SCHEME=scheme
+
+      - name: Strict-check every scheme fence (imports resolved)
+        run: make check-docs-strict SCHEME=scheme
diff --git a/Makefile b/Makefile
index e767cc8..822dc0b 100644
--- a/Makefile
+++ b/Makefile
@@ -7,7 +7,7 @@ CHEZ_EXT_LIBDIRS = $(CHEZ_EXT_DIR)/chez-https/src:$(CHEZ_EXT_DIR)/chez-ssl/src:$
 # Shared object paths for legacy FFI-based chez-* libraries
 CHEZ_EXT_LDPATH = $(CHEZ_EXT_DIR)/chez-ssl:$(CHEZ_EXT_DIR)/chez-zlib:$(CHEZ_EXT_DIR)/chez-pcre2:$(CHEZ_EXT_DIR)/chez-leveldb:$(CHEZ_EXT_DIR)/chez-epoll:$(CHEZ_EXT_DIR)/chez-inotify:$(CHEZ_EXT_DIR)/chez-crypto:$(CHEZ_EXT_DIR)/chez-sqlite:$(CHEZ_EXT_DIR)/chez-postgresql
 
-.PHONY: help build test test-reader test-core test-runtime test-stdlib test-ffi test-modules test-expanded test-features test-wrappers test-phase4a test-phase4b test-phase4c test-phase4d test-phase4e test-phase4f test-phase5 test-phase5e test-phase6 test-phase7 test-phase8 test-functional test-repl test-security test-native test-gaps native clean-native audit-native clean fuzz fuzz-smoke fuzz-deep fuzz-reader-fuzz fuzz-json-fuzz fuzz-http2-fuzz fuzz-websocket-fuzz fuzz-dns-fuzz fuzz-pregexp-fuzz fuzz-csv-fuzz fuzz-base64-fuzz fuzz-hex-fuzz fuzz-uri-fuzz fuzz-format-fuzz fuzz-router-fuzz fuzz-sandbox-fuzz test-rawstring test-regex test-rx test-peg test-regex-all docker-build docker-push
+.PHONY: help build test test-reader test-core test-runtime test-stdlib test-ffi test-modules test-expanded test-features test-wrappers test-phase4a test-phase4b test-phase4c test-phase4d test-phase4e test-phase4f test-phase5 test-phase5e test-phase6 test-phase7 test-phase8 test-functional test-repl test-security test-native test-gaps native clean-native audit-native clean fuzz fuzz-smoke fuzz-deep fuzz-reader-fuzz fuzz-json-fuzz fuzz-http2-fuzz fuzz-websocket-fuzz fuzz-dns-fuzz fuzz-pregexp-fuzz fuzz-csv-fuzz fuzz-base64-fuzz fuzz-hex-fuzz fuzz-uri-fuzz fuzz-format-fuzz fuzz-router-fuzz fuzz-sandbox-fuzz test-rawstring test-regex test-rx test-peg test-regex-all check-docs check-docs-strict docker-build docker-push
 
 help:
 	@echo "Usage: make <target>"
@@ -73,6 +73,12 @@ build:
 
 test: test-reader test-core test-runtime test-stdlib test-ffi test-modules test-expanded test-regex-all
 
+check-docs:
+	@SCHEME=$(SCHEME) tools/check-doc-examples.sh
+
+check-docs-strict:
+	@SCHEME=$(SCHEME) tools/check-doc-examples.sh --strict
+
 test-reader:
 	$(SCHEME) --libdirs $(LIBDIRS) --script tests/test-reader.ss
 
diff --git a/tools/check-doc-examples.sh b/tools/check-doc-examples.sh
new file mode 100755
index 0000000..81eb7bc
--- /dev/null
+++ b/tools/check-doc-examples.sh
@@ -0,0 +1,154 @@
+#!/usr/bin/env bash
+#
+# check-doc-examples.sh — syntax-check every code sample we ship.
+#
+# Extracts ```scheme fences from docs/*.md and compiles each, plus
+# every .ss file under examples/. Reports a PASS/FAIL line per sample
+# and exits non-zero if any failed.
+#
+# Fence language tags:
+#   ```scheme          -> checked (read-only by default)
+#   ```scheme-fragment -> skipped (incomplete snippet, e.g. function body)
+#   ```scheme-skip     -> skipped (not valid standalone for some reason)
+#
+# Modes:
+#   --parse-only   (default) Use (read) — checks sexp balance + tokens.
+#                            Does NOT require native libs to be installed.
+#   --strict                 Use (expand) — also resolves imports. Requires
+#                            the full jerboa lib tree and native shims.
+#
+# Usage:
+#   tools/check-doc-examples.sh [--verbose] [--strict]
+#
+# Runs from the jerboa repo root.
+
+set -uo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+JERBOA_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+cd "$JERBOA_ROOT"
+
+SCHEME="${SCHEME:-scheme}"
+LIBDIRS="$JERBOA_ROOT/lib"
+VERBOSE=0
+STRICT=0
+PASS=0
+FAIL=0
+SKIP=0
+
+for arg in "$@"; do
+    case "$arg" in
+        -v|--verbose) VERBOSE=1 ;;
+        --strict) STRICT=1 ;;
+        --parse-only) STRICT=0 ;;
+        -h|--help)
+            sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'
+            exit 0
+            ;;
+    esac
+done
+
+if [ -t 1 ]; then
+    GREEN='\033[0;32m'
+    RED='\033[0;31m'
+    YELLOW='\033[0;33m'
+    BOLD='\033[1m'
+    RESET='\033[0m'
+else
+    GREEN='' RED='' YELLOW='' BOLD='' RESET=''
+fi
+
+TMPDIR_="$(mktemp -d)"
+trap 'rm -rf "$TMPDIR_"' EXIT
+
+check_one() {
+    local label="$1" file="$2"
+    local log="$TMPDIR_/log.$$"
+    local op="(read)"
+    [ "$STRICT" = 1 ] && op="(expand form)"
+    # Strip leading shebang if present so (read) succeeds.
+    local src="$TMPDIR_/src.$$.ss"
+    if head -1 "$file" | grep -q '^#!'; then
+        tail -n +2 "$file" > "$src"
+    else
+        cp "$file" "$src"
+    fi
+    if "$SCHEME" --libdirs "$LIBDIRS" -q <<SCM >"$log" 2>&1
+(import (chezscheme))
+(guard (c (#t (display-condition c) (newline) (exit 1)))
+  (with-input-from-file "$src"
+    (lambda ()
+      (let loop ()
+        (let ([form (read)])
+          (unless (eof-object? form)
+            $op
+            (loop)))))))
+(exit 0)
+SCM
+    then
+        PASS=$((PASS + 1))
+        echo -e "  ${GREEN}PASS${RESET}  $label"
+    else
+        FAIL=$((FAIL + 1))
+        echo -e "  ${RED}FAIL${RESET}  $label"
+        if [ "$VERBOSE" = 1 ]; then
+            sed 's/^/        /' "$log"
+        else
+            head -5 "$log" | sed 's/^/        /'
+        fi
+    fi
+    rm -f "$log" "$src"
+}
+
+extract_fences() {
+    # Emits one "<path>#<n>\t<tmpfile>" per fence on stdout.
+    local md="$1"
+    awk -v md="$md" -v tmpdir="$TMPDIR_" '
+        BEGIN { in_fence = 0; idx = 0; skip = 0 }
+        /^```scheme[[:space:]]*$/ {
+            in_fence = 1; skip = 0; idx += 1
+            fname = tmpdir "/" gensub(/\//, "_", "g", md) "." idx ".ss"
+            next
+        }
+        /^```scheme-(fragment|skip)[[:space:]]*$/ {
+            in_fence = 1; skip = 1
+            next
+        }
+        /^```[[:space:]]*$/ && in_fence == 1 {
+            if (!skip) {
+                close(fname)
+                print md "#" idx "\t" fname
+            }
+            in_fence = 0; skip = 0
+            next
+        }
+        in_fence == 1 && skip == 0 { print > fname }
+    ' "$md"
+}
+
+echo -e "${BOLD}Checking docs/*.md scheme fences${RESET}"
+for md in docs/*.md; do
+    [ -f "$md" ] || continue
+    while IFS=$'\t' read -r label tmpfile; do
+        [ -n "$tmpfile" ] || continue
+        check_one "$label" "$tmpfile"
+    done < <(extract_fences "$md")
+done
+
+# Count explicit skips for reporting.
+SKIP=$(grep -lE '^```scheme-(fragment|skip)' docs/*.md 2>/dev/null \
+    | xargs -r grep -cE '^```scheme-(fragment|skip)' 2>/dev/null \
+    | awk -F: '{s+=$2} END {print s+0}')
+
+echo ""
+echo -e "${BOLD}Checking examples/*.ss${RESET}"
+for f in examples/*.ss; do
+    [ -f "$f" ] || continue
+    check_one "$f" "$f"
+done
+
+TOTAL=$((PASS + FAIL))
+echo ""
+echo -e "${BOLD}Results: ${GREEN}$PASS passed${RESET}${BOLD}, ${RED}$FAIL failed${RESET}${BOLD}, $TOTAL total${RESET} (${YELLOW}$SKIP fragment(s) skipped${RESET})"
+
+[ "$FAIL" -eq 0 ]