Compare commits
16 Commits
loops/cont
...
loops/conf
| Author | SHA1 | Date | |
|---|---|---|---|
| 0061db393c | |||
| 31603e636b | |||
| 0309e3b5d5 | |||
| 93b27c74b5 | |||
| c00cca45ff | |||
| 4b31828641 | |||
| b4ecadaad9 | |||
| bb85532cc6 | |||
| 2e7a08309c | |||
| bfdd0fe65a | |||
| e5686d2c31 | |||
| c5faf93813 | |||
| 2913cdc3a8 | |||
| c73b054ec3 | |||
| dd399303b2 | |||
| 46e0653911 |
@@ -855,6 +855,164 @@ let setup_evaluator_bridge env =
|
||||
done;
|
||||
Nil
|
||||
| _ -> raise (Eval_error "http-listen: (port handler)"));
|
||||
|
||||
(* fed-sx Milestone 1 client direction (Phase J). NATIVE ONLY —
|
||||
Unix sockets + DNS; absent from the WASM kernel. HTTP/1.1
|
||||
request: TCP connect, write request line + headers + body,
|
||||
read status + headers + body, return {:status :headers :body}.
|
||||
URL must be http://...; HTTPS is a later phase (needs TLS).
|
||||
Body read: Content-Length first, else read to EOF (we send
|
||||
Connection: close). Transfer-Encoding: chunked is rejected —
|
||||
fed-sx Phase 8 wires this for inter-server POSTs which will
|
||||
all carry Content-Length. *)
|
||||
Sx_primitives.register "http-request" (fun args ->
|
||||
let strip_cr s =
|
||||
let n = String.length s in
|
||||
if n > 0 && s.[n - 1] = '\r' then String.sub s 0 (n - 1) else s
|
||||
in
|
||||
match args with
|
||||
| [String meth; String url; headers_v; body_v] ->
|
||||
let body = match body_v with
|
||||
| String s -> s
|
||||
| Nil -> ""
|
||||
| v -> Sx_types.value_to_string v in
|
||||
let prefix = "http://" in
|
||||
let plen = String.length prefix in
|
||||
let ulen = String.length url in
|
||||
if ulen < plen || String.sub url 0 plen <> prefix
|
||||
then raise (Eval_error "http-request: URL must start with http://");
|
||||
let rest = String.sub url plen (ulen - plen) in
|
||||
let host_port, path =
|
||||
match String.index_opt rest '/' with
|
||||
| Some i ->
|
||||
String.sub rest 0 i,
|
||||
String.sub rest i (String.length rest - i)
|
||||
| None -> rest, "/" in
|
||||
if host_port = "" then
|
||||
raise (Eval_error "http-request: missing host");
|
||||
let host, port =
|
||||
match String.index_opt host_port ':' with
|
||||
| Some i ->
|
||||
let h = String.sub host_port 0 i in
|
||||
let ps = String.sub host_port (i + 1)
|
||||
(String.length host_port - i - 1) in
|
||||
(h,
|
||||
(try int_of_string ps with _ ->
|
||||
raise (Eval_error "http-request: bad port")))
|
||||
| None -> host_port, 80 in
|
||||
let addr =
|
||||
(try (Unix.gethostbyname host).h_addr_list.(0)
|
||||
with Not_found ->
|
||||
raise (Eval_error ("http-request: dns: " ^ host))) in
|
||||
let sock = Unix.socket Unix.PF_INET Unix.SOCK_STREAM 0 in
|
||||
let cleanup () = try Unix.close sock with _ -> () in
|
||||
let result =
|
||||
(try
|
||||
(try Unix.connect sock (Unix.ADDR_INET (addr, port))
|
||||
with Unix.Unix_error (e, _, _) ->
|
||||
raise (Eval_error
|
||||
("http-request: connect: " ^ Unix.error_message e)));
|
||||
let oc = Unix.out_channel_of_descr sock in
|
||||
let ic = Unix.in_channel_of_descr sock in
|
||||
let buf = Buffer.create 256 in
|
||||
Buffer.add_string buf
|
||||
(Printf.sprintf "%s %s HTTP/1.1\r\n" meth path);
|
||||
let host_hdr_sent = ref false in
|
||||
let clen_sent = ref false in
|
||||
let conn_sent = ref false in
|
||||
(match headers_v with
|
||||
| Dict h ->
|
||||
Hashtbl.iter (fun k v ->
|
||||
let kl = String.lowercase_ascii k in
|
||||
if kl = "host" then host_hdr_sent := true;
|
||||
if kl = "content-length" then clen_sent := true;
|
||||
if kl = "connection" then conn_sent := true;
|
||||
let vs = match v with
|
||||
| String s -> s
|
||||
| x -> Sx_types.value_to_string x in
|
||||
Buffer.add_string buf
|
||||
(Printf.sprintf "%s: %s\r\n" k vs)) h
|
||||
| Nil -> ()
|
||||
| _ -> raise (Eval_error "http-request: headers must be dict"));
|
||||
if not !host_hdr_sent then
|
||||
Buffer.add_string buf
|
||||
(Printf.sprintf "Host: %s\r\n" host_port);
|
||||
if not !clen_sent then
|
||||
Buffer.add_string buf
|
||||
(Printf.sprintf "Content-Length: %d\r\n"
|
||||
(String.length body));
|
||||
if not !conn_sent then
|
||||
Buffer.add_string buf "Connection: close\r\n";
|
||||
Buffer.add_string buf "\r\n";
|
||||
Buffer.add_string buf body;
|
||||
output_string oc (Buffer.contents buf);
|
||||
flush oc;
|
||||
let sl =
|
||||
(try strip_cr (input_line ic)
|
||||
with End_of_file ->
|
||||
raise (Eval_error
|
||||
"http-request: connection closed before status")) in
|
||||
let status =
|
||||
match String.split_on_char ' ' sl with
|
||||
| _ver :: code :: _ ->
|
||||
(try int_of_string code with _ ->
|
||||
raise (Eval_error "http-request: bad status code"))
|
||||
| _ -> raise (Eval_error "http-request: bad status line") in
|
||||
let rhdrs = Sx_types.make_dict () in
|
||||
let clen = ref (-1) in
|
||||
let chunked = ref false in
|
||||
let rec rdh () =
|
||||
let h =
|
||||
(try strip_cr (input_line ic)
|
||||
with End_of_file -> "") in
|
||||
if h = "" then ()
|
||||
else begin
|
||||
(match String.index_opt h ':' with
|
||||
| Some i ->
|
||||
let name =
|
||||
String.lowercase_ascii
|
||||
(String.trim (String.sub h 0 i)) in
|
||||
let value =
|
||||
String.trim
|
||||
(String.sub h (i + 1)
|
||||
(String.length h - i - 1)) in
|
||||
Hashtbl.replace rhdrs name (String value);
|
||||
if name = "content-length" then
|
||||
(try clen := int_of_string value with _ -> ())
|
||||
else if name = "transfer-encoding" &&
|
||||
String.lowercase_ascii value = "chunked"
|
||||
then chunked := true
|
||||
| None -> ());
|
||||
rdh ()
|
||||
end in
|
||||
rdh ();
|
||||
if !chunked then
|
||||
raise (Eval_error
|
||||
"http-request: chunked transfer-encoding not supported");
|
||||
let rbody =
|
||||
if !clen >= 0 then begin
|
||||
let b = Bytes.create !clen in
|
||||
really_input ic b 0 !clen;
|
||||
Bytes.unsafe_to_string b
|
||||
end else begin
|
||||
let b = Buffer.create 256 in
|
||||
(try
|
||||
while true do
|
||||
Buffer.add_channel b ic 4096
|
||||
done; assert false
|
||||
with End_of_file -> ());
|
||||
Buffer.contents b
|
||||
end in
|
||||
let resp = Sx_types.make_dict () in
|
||||
Hashtbl.replace resp "status" (Integer status);
|
||||
Hashtbl.replace resp "headers" (Dict rhdrs);
|
||||
Hashtbl.replace resp "body" (String rbody);
|
||||
Dict resp
|
||||
with e -> cleanup (); raise e) in
|
||||
cleanup ();
|
||||
result
|
||||
| _ -> raise (Eval_error "http-request: (method url headers body)"));
|
||||
|
||||
bind "trampoline" (fun args ->
|
||||
match args with
|
||||
| [v] ->
|
||||
|
||||
80
hosts/ocaml/bin/test_http_client.sh
Executable file
80
hosts/ocaml/bin/test_http_client.sh
Executable file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# Phase J test — native-only http-request client primitive.
|
||||
# Reuses Phase H's http-listen to spin up an echo server, then drives
|
||||
# a separate sx_server via the epoch protocol to issue http-request
|
||||
# calls and assert response shape + headers + body.
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
SRV=_build/default/bin/sx_server.exe
|
||||
PORT=${HTTP_CLIENT_TEST_PORT:-8921}
|
||||
PASS=0
|
||||
FAIL=0
|
||||
ok() { echo " PASS: $1"; PASS=$((PASS+1)); }
|
||||
bad() { echo " FAIL: $1 — $2"; FAIL=$((FAIL+1)); }
|
||||
|
||||
if [ ! -x "$SRV" ]; then
|
||||
echo "build sx_server.exe first (dune build bin/sx_server.exe)"; exit 1
|
||||
fi
|
||||
|
||||
# /echo echoes method/path/query/body and reflects request X-Custom
|
||||
# back as response X-Got; /missing-test → 404.
|
||||
H='(begin (define (h req) (if (= (get req "path") "/echo") {:status 200 :headers {"X-Echo" (get req "method") "X-Got" (get (get req "headers") "x-custom")} :body (str "M=" (get req "method") " P=" (get req "path") " Q=" (get req "query") " B=" (get req "body"))} (if (= (get req "path") "/missing-test") {:status 404 :body "nope"} {:status 500 :body "err"}))) (http-listen '"$PORT"' h))'
|
||||
ESC=${H//\"/\\\"}
|
||||
|
||||
{ printf '(epoch 1)\n(eval "%s")\n' "$ESC"; sleep 60; } | "$SRV" >/tmp/test_http_client_srv.out 2>&1 &
|
||||
SVPID=$!
|
||||
trap 'kill $SVPID 2>/dev/null; wait 2>/dev/null' EXIT
|
||||
|
||||
up=0
|
||||
for _ in $(seq 1 50); do
|
||||
curl -s -o /dev/null "http://127.0.0.1:$PORT/echo" 2>/dev/null && { up=1; break; }
|
||||
sleep 0.2
|
||||
done
|
||||
[ "$up" = 1 ] || { echo " FAIL: server did not start"; cat /tmp/test_http_client_srv.out; exit 1; }
|
||||
|
||||
emit() {
|
||||
# $1 = epoch num, $2 = raw SX form. Wraps in (eval "...") with quotes escaped.
|
||||
local esc=${2//\"/\\\"}
|
||||
printf '(epoch %s)\n(eval "%s")\n' "$1" "$esc"
|
||||
}
|
||||
|
||||
DRV_OUT=/tmp/test_http_client_drv.out
|
||||
{
|
||||
emit 1 '(let ((r (http-request "GET" "http://127.0.0.1:'"$PORT"'/echo?x=1" {} ""))) (str "S=" (get r "status") " E=" (get (get r "headers") "x-echo") " B=" (get r "body")))'
|
||||
emit 2 '(let ((r (http-request "POST" "http://127.0.0.1:'"$PORT"'/echo" {} "hello"))) (str "S=" (get r "status") " B=" (get r "body")))'
|
||||
emit 3 '(let ((r (http-request "GET" "http://127.0.0.1:'"$PORT"'/missing-test" {} ""))) (str "S=" (get r "status") " B=" (get r "body")))'
|
||||
emit 4 '(let ((r (http-request "GET" "http://127.0.0.1:'"$PORT"'/echo" {"X-Custom" "myval"} ""))) (get (get r "headers") "x-got"))'
|
||||
emit 5 '(http-request "GET" "ftp://nope" {} "")'
|
||||
emit 6 '(let ((r (http-request "GET" "http://127.0.0.1:'"$PORT"'/echo" {} ""))) (get r "status"))'
|
||||
} | "$SRV" >"$DRV_OUT" 2>&1
|
||||
|
||||
# eval results come back as (ok-len N L)\n<body>\n — grep the body content.
|
||||
grep -q '^"S=200 E=GET B=M=GET P=/echo Q=x=1 B="$' "$DRV_OUT" \
|
||||
&& ok "GET status + echo header + body" \
|
||||
|| bad "GET" "$(grep -A1 '^(ok-len 1 ' "$DRV_OUT" | tail -1)"
|
||||
|
||||
grep -q '^"S=200 B=M=POST P=/echo Q= B=hello"$' "$DRV_OUT" \
|
||||
&& ok "POST body roundtrip" \
|
||||
|| bad "POST" "$(grep -A1 '^(ok-len 2 ' "$DRV_OUT" | tail -1)"
|
||||
|
||||
grep -q '^"S=404 B=nope"$' "$DRV_OUT" \
|
||||
&& ok "404 status + body" \
|
||||
|| bad "404" "$(grep -A1 '^(ok-len 3 ' "$DRV_OUT" | tail -1)"
|
||||
|
||||
grep -q '^"myval"$' "$DRV_OUT" \
|
||||
&& ok "custom request header reaches server" \
|
||||
|| bad "custom-header" "$(grep -A1 '^(ok-len 4 ' "$DRV_OUT" | tail -1)"
|
||||
|
||||
R5=$(grep '^(error 5 ' "$DRV_OUT" | head -1)
|
||||
echo "$R5" | grep -q 'URL must start with http' \
|
||||
&& ok "non-http scheme rejected" \
|
||||
|| bad "bad-url" "$R5"
|
||||
|
||||
# Status is an Integer (200), serialized bare without quotes.
|
||||
grep -q '^200$' "$DRV_OUT" \
|
||||
&& ok "response status is integer 200" \
|
||||
|| bad "status-integer" "$(grep -A1 '^(ok-len 6 ' "$DRV_OUT" | tail -1)"
|
||||
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[ "$FAIL" = 0 ]
|
||||
67
lib/common-lisp/conformance.conf
Normal file
67
lib/common-lisp/conformance.conf
Normal file
@@ -0,0 +1,67 @@
|
||||
# Common-Lisp-on-SX conformance config — sourced by lib/guest/conformance.sh.
|
||||
#
|
||||
# CL suites run their tests at *load* time, mutating per-suite global counters
|
||||
# (different variable names per suite), and each suite needs a different
|
||||
# preload chain. Both are expressed via the extended MODE=counters SUITES
|
||||
# format: "name:file:pass-var:fail-var:extra-preload ...".
|
||||
|
||||
LANG_NAME=common-lisp
|
||||
MODE=counters
|
||||
# No global counter defaults — every suite names its own pair below.
|
||||
COUNTERS_PASS=
|
||||
COUNTERS_FAIL=
|
||||
TIMEOUT_PER_SUITE=180
|
||||
|
||||
# Base preloads common to every suite (loaded before each suite's own chain).
|
||||
PRELOADS=(
|
||||
spec/stdlib.sx
|
||||
lib/guest/prefix.sx
|
||||
)
|
||||
|
||||
# name:file:pass-var:fail-var:extra-preloads(space-separated)
|
||||
SUITES=(
|
||||
"read:lib/common-lisp/tests/read.sx:cl-test-pass:cl-test-fail:lib/common-lisp/reader.sx"
|
||||
"lambda:lib/common-lisp/tests/lambda.sx:cl-test-pass:cl-test-fail:lib/common-lisp/reader.sx lib/common-lisp/parser.sx"
|
||||
"eval:lib/common-lisp/tests/eval.sx:cl-test-pass:cl-test-fail:lib/common-lisp/reader.sx lib/common-lisp/parser.sx lib/common-lisp/eval.sx"
|
||||
"conditions:lib/common-lisp/tests/conditions.sx:passed:failed:lib/common-lisp/runtime.sx"
|
||||
"restart-demo:lib/common-lisp/tests/programs/restart-demo.sx:demo-passed:demo-failed:lib/common-lisp/runtime.sx"
|
||||
"parse-recover:lib/common-lisp/tests/programs/parse-recover.sx:parse-passed:parse-failed:lib/common-lisp/runtime.sx"
|
||||
"interactive-debugger:lib/common-lisp/tests/programs/interactive-debugger.sx:debugger-passed:debugger-failed:lib/common-lisp/runtime.sx"
|
||||
"clos:lib/common-lisp/tests/clos.sx:passed:failed:lib/common-lisp/runtime.sx lib/common-lisp/clos.sx"
|
||||
"geometry:lib/common-lisp/tests/programs/geometry.sx:geo-passed:geo-failed:lib/common-lisp/runtime.sx lib/common-lisp/clos.sx"
|
||||
"mop-trace:lib/common-lisp/tests/programs/mop-trace.sx:mop-passed:mop-failed:lib/common-lisp/runtime.sx lib/common-lisp/clos.sx"
|
||||
"macros:lib/common-lisp/tests/macros.sx:macro-passed:macro-failed:lib/common-lisp/reader.sx lib/common-lisp/parser.sx lib/common-lisp/eval.sx lib/common-lisp/loop.sx"
|
||||
"stdlib:lib/common-lisp/tests/stdlib.sx:stdlib-passed:stdlib-failed:lib/common-lisp/reader.sx lib/common-lisp/parser.sx lib/common-lisp/eval.sx"
|
||||
)
|
||||
|
||||
# Preserve the historical scoreboard schema (total_pass/total_fail, suites with
|
||||
# name/pass/fail) so any consumer of lib/common-lisp/scoreboard.json keeps working.
|
||||
emit_scoreboard_json() {
|
||||
local n=${#GC_NAMES[@]} i
|
||||
printf '{\n'
|
||||
printf ' "generated": "%s",\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
printf ' "total_pass": %d,\n' "$GC_TOTAL_PASS"
|
||||
printf ' "total_fail": %d,\n' "$GC_TOTAL_FAIL"
|
||||
printf ' "suites": [\n'
|
||||
for ((i=0; i<n; i++)); do
|
||||
[ "$i" -gt 0 ] && printf ',\n'
|
||||
printf ' {"name": "%s", "pass": %d, "fail": %d}' \
|
||||
"${GC_NAMES[$i]}" "${GC_PASS[$i]}" "${GC_FAIL[$i]}"
|
||||
done
|
||||
printf '\n ]\n'
|
||||
printf '}\n'
|
||||
}
|
||||
|
||||
emit_scoreboard_md() {
|
||||
local n=${#GC_NAMES[@]} i p f status
|
||||
printf '# Common Lisp on SX — Scoreboard\n\n'
|
||||
printf '_Generated: %s_\n\n' "$(date -u '+%Y-%m-%d %H:%M UTC')"
|
||||
printf '| Suite | Pass | Fail | Status |\n'
|
||||
printf '|-------|------|------|--------|\n'
|
||||
for ((i=0; i<n; i++)); do
|
||||
p="${GC_PASS[$i]}"; f="${GC_FAIL[$i]}"
|
||||
if [ "$f" = "0" ] && [ "${p:-0}" -gt 0 ] 2>/dev/null; then status="pass"; else status="FAIL"; fi
|
||||
printf '| %s | %s | %s | %s |\n' "${GC_NAMES[$i]}" "$p" "$f" "$status"
|
||||
done
|
||||
printf '\n**Total: %d passed, %d failed**\n' "$GC_TOTAL_PASS" "$GC_TOTAL_FAIL"
|
||||
}
|
||||
@@ -1,161 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# lib/common-lisp/conformance.sh — CL-on-SX conformance test runner
|
||||
#
|
||||
# Runs all Common Lisp test suites and writes scoreboard.json + scoreboard.md.
|
||||
#
|
||||
# Usage:
|
||||
# bash lib/common-lisp/conformance.sh
|
||||
# bash lib/common-lisp/conformance.sh -v
|
||||
|
||||
set -uo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}"
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe"
|
||||
fi
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
echo "ERROR: sx_server.exe not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERBOSE="${1:-}"
|
||||
TOTAL_PASS=0; TOTAL_FAIL=0
|
||||
SUITE_NAMES=()
|
||||
SUITE_PASS=()
|
||||
SUITE_FAIL=()
|
||||
|
||||
# run_suite NAME "file1 file2 ..." PASS_VAR FAIL_VAR FAILURES_VAR
|
||||
run_suite() {
|
||||
local name="$1" load_files="$2" pass_var="$3" fail_var="$4" failures_var="$5"
|
||||
local TMP; TMP=$(mktemp)
|
||||
{
|
||||
printf '(epoch 1)\n(load "spec/stdlib.sx")\n(load "lib/guest/prefix.sx")\n'
|
||||
local i=2
|
||||
for f in $load_files; do
|
||||
printf '(epoch %d)\n(load "%s")\n' "$i" "$f"
|
||||
i=$((i+1))
|
||||
done
|
||||
printf '(epoch 100)\n(eval "%s")\n' "$pass_var"
|
||||
printf '(epoch 101)\n(eval "%s")\n' "$fail_var"
|
||||
} > "$TMP"
|
||||
local OUT; OUT=$(timeout 30 "$SX_SERVER" < "$TMP" 2>/dev/null)
|
||||
rm -f "$TMP"
|
||||
local P F
|
||||
P=$(echo "$OUT" | grep -A1 "^(ok-len 100 " | tail -1 | tr -d ' ()' || true)
|
||||
F=$(echo "$OUT" | grep -A1 "^(ok-len 101 " | tail -1 | tr -d ' ()' || true)
|
||||
# Also try plain (ok 100 N) format
|
||||
[ -z "$P" ] && P=$(echo "$OUT" | grep "^(ok 100 " | awk '{print $3}' | tr -d ')' || true)
|
||||
[ -z "$F" ] && F=$(echo "$OUT" | grep "^(ok 101 " | awk '{print $3}' | tr -d ')' || true)
|
||||
[ -z "$P" ] && P=0; [ -z "$F" ] && F=0
|
||||
SUITE_NAMES+=("$name")
|
||||
SUITE_PASS+=("$P")
|
||||
SUITE_FAIL+=("$F")
|
||||
TOTAL_PASS=$((TOTAL_PASS + P))
|
||||
TOTAL_FAIL=$((TOTAL_FAIL + F))
|
||||
if [ "$F" = "0" ] && [ "${P:-0}" -gt 0 ] 2>/dev/null; then
|
||||
echo " PASS $name ($P tests)"
|
||||
else
|
||||
echo " FAIL $name ($P passed, $F failed)"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== Common Lisp on SX — Conformance Run ==="
|
||||
echo ""
|
||||
|
||||
run_suite "Phase 1: tokenizer/reader" \
|
||||
"lib/common-lisp/reader.sx lib/common-lisp/tests/read.sx" \
|
||||
"cl-test-pass" "cl-test-fail" "cl-test-fails"
|
||||
|
||||
run_suite "Phase 1: parser/lambda-lists" \
|
||||
"lib/common-lisp/reader.sx lib/common-lisp/parser.sx lib/common-lisp/tests/lambda.sx" \
|
||||
"cl-test-pass" "cl-test-fail" "cl-test-fails"
|
||||
|
||||
run_suite "Phase 2: evaluator" \
|
||||
"lib/common-lisp/reader.sx lib/common-lisp/parser.sx lib/common-lisp/eval.sx lib/common-lisp/tests/eval.sx" \
|
||||
"cl-test-pass" "cl-test-fail" "cl-test-fails"
|
||||
|
||||
run_suite "Phase 3: condition system" \
|
||||
"lib/common-lisp/runtime.sx lib/common-lisp/tests/conditions.sx" \
|
||||
"passed" "failed" "failures"
|
||||
|
||||
run_suite "Phase 3: restart-demo" \
|
||||
"lib/common-lisp/runtime.sx lib/common-lisp/tests/programs/restart-demo.sx" \
|
||||
"demo-passed" "demo-failed" "demo-failures"
|
||||
|
||||
run_suite "Phase 3: parse-recover" \
|
||||
"lib/common-lisp/runtime.sx lib/common-lisp/tests/programs/parse-recover.sx" \
|
||||
"parse-passed" "parse-failed" "parse-failures"
|
||||
|
||||
run_suite "Phase 3: interactive-debugger" \
|
||||
"lib/common-lisp/runtime.sx lib/common-lisp/tests/programs/interactive-debugger.sx" \
|
||||
"debugger-passed" "debugger-failed" "debugger-failures"
|
||||
|
||||
run_suite "Phase 4: CLOS" \
|
||||
"lib/common-lisp/runtime.sx lib/common-lisp/clos.sx lib/common-lisp/tests/clos.sx" \
|
||||
"passed" "failed" "failures"
|
||||
|
||||
run_suite "Phase 4: geometry" \
|
||||
"lib/common-lisp/runtime.sx lib/common-lisp/clos.sx lib/common-lisp/tests/programs/geometry.sx" \
|
||||
"geo-passed" "geo-failed" "geo-failures"
|
||||
|
||||
run_suite "Phase 4: mop-trace" \
|
||||
"lib/common-lisp/runtime.sx lib/common-lisp/clos.sx lib/common-lisp/tests/programs/mop-trace.sx" \
|
||||
"mop-passed" "mop-failed" "mop-failures"
|
||||
|
||||
run_suite "Phase 5: macros+LOOP" \
|
||||
"lib/common-lisp/reader.sx lib/common-lisp/parser.sx lib/common-lisp/eval.sx lib/common-lisp/loop.sx lib/common-lisp/tests/macros.sx" \
|
||||
"macro-passed" "macro-failed" "macro-failures"
|
||||
|
||||
run_suite "Phase 6: stdlib" \
|
||||
"lib/common-lisp/reader.sx lib/common-lisp/parser.sx lib/common-lisp/eval.sx lib/common-lisp/tests/stdlib.sx" \
|
||||
"stdlib-passed" "stdlib-failed" "stdlib-failures"
|
||||
|
||||
echo ""
|
||||
echo "=== Total: $TOTAL_PASS passed, $TOTAL_FAIL failed ==="
|
||||
|
||||
# ── write scoreboard.json ─────────────────────────────────────────────────
|
||||
|
||||
SCORE_DIR="lib/common-lisp"
|
||||
JSON="$SCORE_DIR/scoreboard.json"
|
||||
{
|
||||
printf '{\n'
|
||||
printf ' "generated": "%s",\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
printf ' "total_pass": %d,\n' "$TOTAL_PASS"
|
||||
printf ' "total_fail": %d,\n' "$TOTAL_FAIL"
|
||||
printf ' "suites": [\n'
|
||||
first=true
|
||||
for i in "${!SUITE_NAMES[@]}"; do
|
||||
if [ "$first" = "true" ]; then first=false; else printf ',\n'; fi
|
||||
printf ' {"name": "%s", "pass": %d, "fail": %d}' \
|
||||
"${SUITE_NAMES[$i]}" "${SUITE_PASS[$i]}" "${SUITE_FAIL[$i]}"
|
||||
done
|
||||
printf '\n ]\n'
|
||||
printf '}\n'
|
||||
} > "$JSON"
|
||||
|
||||
# ── write scoreboard.md ───────────────────────────────────────────────────
|
||||
|
||||
MD="$SCORE_DIR/scoreboard.md"
|
||||
{
|
||||
printf '# Common Lisp on SX — Scoreboard\n\n'
|
||||
printf '_Generated: %s_\n\n' "$(date -u '+%Y-%m-%d %H:%M UTC')"
|
||||
printf '| Suite | Pass | Fail | Status |\n'
|
||||
printf '|-------|------|------|--------|\n'
|
||||
for i in "${!SUITE_NAMES[@]}"; do
|
||||
p="${SUITE_PASS[$i]}" f="${SUITE_FAIL[$i]}"
|
||||
status=""
|
||||
if [ "$f" = "0" ] && [ "${p:-0}" -gt 0 ] 2>/dev/null; then
|
||||
status="pass"
|
||||
else
|
||||
status="FAIL"
|
||||
fi
|
||||
printf '| %s | %s | %s | %s |\n' "${SUITE_NAMES[$i]}" "$p" "$f" "$status"
|
||||
done
|
||||
printf '\n**Total: %d passed, %d failed**\n' "$TOTAL_PASS" "$TOTAL_FAIL"
|
||||
} > "$MD"
|
||||
|
||||
echo ""
|
||||
echo "Scoreboard written to $JSON and $MD"
|
||||
|
||||
[ "$TOTAL_FAIL" -eq 0 ]
|
||||
# Thin wrapper — see lib/guest/conformance.sh and lib/common-lisp/conformance.conf.
|
||||
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"generated": "2026-05-06T22:55:42Z",
|
||||
"total_pass": 518,
|
||||
"generated": "2026-06-07T09:35:38Z",
|
||||
"total_pass": 487,
|
||||
"total_fail": 0,
|
||||
"suites": [
|
||||
{"name": "Phase 1: tokenizer/reader", "pass": 79, "fail": 0},
|
||||
{"name": "Phase 1: parser/lambda-lists", "pass": 31, "fail": 0},
|
||||
{"name": "Phase 2: evaluator", "pass": 182, "fail": 0},
|
||||
{"name": "Phase 3: condition system", "pass": 59, "fail": 0},
|
||||
{"name": "Phase 3: restart-demo", "pass": 7, "fail": 0},
|
||||
{"name": "Phase 3: parse-recover", "pass": 6, "fail": 0},
|
||||
{"name": "Phase 3: interactive-debugger", "pass": 7, "fail": 0},
|
||||
{"name": "Phase 4: CLOS", "pass": 41, "fail": 0},
|
||||
{"name": "Phase 4: geometry", "pass": 12, "fail": 0},
|
||||
{"name": "Phase 4: mop-trace", "pass": 13, "fail": 0},
|
||||
{"name": "Phase 5: macros+LOOP", "pass": 27, "fail": 0},
|
||||
{"name": "Phase 6: stdlib", "pass": 54, "fail": 0}
|
||||
{"name": "read", "pass": 79, "fail": 0},
|
||||
{"name": "lambda", "pass": 31, "fail": 0},
|
||||
{"name": "eval", "pass": 182, "fail": 0},
|
||||
{"name": "conditions", "pass": 59, "fail": 0},
|
||||
{"name": "restart-demo", "pass": 7, "fail": 0},
|
||||
{"name": "parse-recover", "pass": 6, "fail": 0},
|
||||
{"name": "interactive-debugger", "pass": 7, "fail": 0},
|
||||
{"name": "clos", "pass": 35, "fail": 0},
|
||||
{"name": "geometry", "pass": 0, "fail": 0},
|
||||
{"name": "mop-trace", "pass": 0, "fail": 0},
|
||||
{"name": "macros", "pass": 27, "fail": 0},
|
||||
{"name": "stdlib", "pass": 54, "fail": 0}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# Common Lisp on SX — Scoreboard
|
||||
|
||||
_Generated: 2026-05-06 22:55 UTC_
|
||||
_Generated: 2026-06-07 09:35 UTC_
|
||||
|
||||
| Suite | Pass | Fail | Status |
|
||||
|-------|------|------|--------|
|
||||
| Phase 1: tokenizer/reader | 79 | 0 | pass |
|
||||
| Phase 1: parser/lambda-lists | 31 | 0 | pass |
|
||||
| Phase 2: evaluator | 182 | 0 | pass |
|
||||
| Phase 3: condition system | 59 | 0 | pass |
|
||||
| Phase 3: restart-demo | 7 | 0 | pass |
|
||||
| Phase 3: parse-recover | 6 | 0 | pass |
|
||||
| Phase 3: interactive-debugger | 7 | 0 | pass |
|
||||
| Phase 4: CLOS | 41 | 0 | pass |
|
||||
| Phase 4: geometry | 12 | 0 | pass |
|
||||
| Phase 4: mop-trace | 13 | 0 | pass |
|
||||
| Phase 5: macros+LOOP | 27 | 0 | pass |
|
||||
| Phase 6: stdlib | 54 | 0 | pass |
|
||||
| read | 79 | 0 | pass |
|
||||
| lambda | 31 | 0 | pass |
|
||||
| eval | 182 | 0 | pass |
|
||||
| conditions | 59 | 0 | pass |
|
||||
| restart-demo | 7 | 0 | pass |
|
||||
| parse-recover | 6 | 0 | pass |
|
||||
| interactive-debugger | 7 | 0 | pass |
|
||||
| clos | 35 | 0 | pass |
|
||||
| geometry | 0 | 0 | FAIL |
|
||||
| mop-trace | 0 | 0 | FAIL |
|
||||
| macros | 27 | 0 | pass |
|
||||
| stdlib | 54 | 0 | pass |
|
||||
|
||||
**Total: 518 passed, 0 failed**
|
||||
**Total: 487 passed, 0 failed**
|
||||
|
||||
@@ -25,13 +25,8 @@
|
||||
(define content/append doc-append)
|
||||
(define content/blocks doc-blocks)
|
||||
(define content/count doc-count)
|
||||
;; find / has? are TREE-WIDE by id (descend into sections) — so the facade reads
|
||||
;; back any block content/edit can update or delete. content/find-top / has-top?
|
||||
;; keep the top-level-only lookup for callers that mean the ordered sequence.
|
||||
(define content/find doc-find-deep)
|
||||
(define content/has? doc-has-deep?)
|
||||
(define content/find-top doc-find)
|
||||
(define content/has-top? doc-has?)
|
||||
(define content/find doc-find)
|
||||
(define content/has? doc-has?)
|
||||
(define content/ids doc-ids)
|
||||
(define content/types doc-types)
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
;; content-on-sx — locate a block in the tree (ancestor section path).
|
||||
;;
|
||||
;; The read-side companion to doc-find-deep (which returns the block) and the
|
||||
;; move/reparent ops (which relocate it): content/block-path returns the list of
|
||||
;; ancestor section ids, root-first, leading to a block id — i.e. where the
|
||||
;; block sits in the tree. A top-level block has an empty path; a block one
|
||||
;; section deep has a one-element path; a missing id returns nil (distinct from
|
||||
;; the empty-list path of a present top-level block). content/block-depth is the
|
||||
;; path length (0 = top level, -1 = absent). Useful for breadcrumbs and for
|
||||
;; scoping an edit to a block's enclosing section. Pure traversal; descends into
|
||||
;; any block carrying a children list, like the rest of the tree helpers.
|
||||
;;
|
||||
;; Requires (loaded by harness): block.sx, doc.sx.
|
||||
|
||||
(define
|
||||
bp-in-blocks
|
||||
(fn
|
||||
(blocks id trail)
|
||||
(if
|
||||
(= (len blocks) 0)
|
||||
nil
|
||||
(let
|
||||
((b (first blocks)))
|
||||
(if
|
||||
(= (blk-id b) id)
|
||||
trail
|
||||
(let
|
||||
((ch (st-iv-get b "children")))
|
||||
(let
|
||||
((found (if (list? ch) (bp-in-blocks ch id (append trail (list (blk-id b)))) nil)))
|
||||
(if (= found nil) (bp-in-blocks (rest blocks) id trail) found))))))))
|
||||
|
||||
;; ancestor section ids (root-first) for `id`, or nil if the block is absent.
|
||||
(define
|
||||
content/block-path
|
||||
(fn (doc id) (bp-in-blocks (doc-blocks doc) id (list))))
|
||||
|
||||
;; depth of `id`: 0 at top level, n nested n sections deep, -1 if absent.
|
||||
(define
|
||||
content/block-depth
|
||||
(fn
|
||||
(doc id)
|
||||
(let
|
||||
((p (content/block-path doc id)))
|
||||
(if (= p nil) -1 (len p)))))
|
||||
@@ -15,7 +15,7 @@ if [ ! -x "$SX_SERVER" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
SUITES=(block doc render api meta page page-full markdown runs text section compose tree-edit move block-path clone query toc anchor outline flatten transform normalize find-replace stats summary index table callout media data wire validate sanitize store snapshot crdt crdt-tree crdt-blocks crdt-store sync md-import md-doc fed)
|
||||
SUITES=(block doc render api meta page page-full markdown text section compose tree-edit move clone query toc anchor outline flatten transform normalize find-replace stats summary index table callout media data wire validate store snapshot crdt crdt-tree crdt-blocks crdt-store sync md-import md-doc fed)
|
||||
|
||||
OUT_JSON="lib/content/scoreboard.json"
|
||||
OUT_MD="lib/content/scoreboard.md"
|
||||
@@ -48,7 +48,6 @@ run_suite() {
|
||||
(load "lib/content/compose.sx")
|
||||
(load "lib/content/tree-edit.sx")
|
||||
(load "lib/content/move.sx")
|
||||
(load "lib/content/block-path.sx")
|
||||
(load "lib/content/clone.sx")
|
||||
(load "lib/content/query.sx")
|
||||
(load "lib/content/toc.sx")
|
||||
@@ -69,9 +68,7 @@ run_suite() {
|
||||
(load "lib/content/page.sx")
|
||||
(load "lib/content/page-full.sx")
|
||||
(load "lib/content/markdown.sx")
|
||||
(load "lib/content/runs.sx")
|
||||
(load "lib/content/validate.sx")
|
||||
(load "lib/content/sanitize.sx")
|
||||
(load "lib/content/store.sx")
|
||||
(load "lib/content/snapshot.sx")
|
||||
(load "lib/content/crdt.sx")
|
||||
|
||||
@@ -5,19 +5,14 @@
|
||||
;; and returns a NEW document — the input is never mutated, so any version is the
|
||||
;; head of an op stream (replay-friendly for persist + CRDT merge).
|
||||
;;
|
||||
;; By-id ops (update/delete) and by-id lookup (doc-find-deep/doc-has-deep?) are
|
||||
;; TREE-WIDE: they descend into any block carrying a `children` list (i.e.
|
||||
;; sections), since ids are unique across the tree. This keeps the persist
|
||||
;; op-log, content/edit and content/find correct for nested documents.
|
||||
;; insert/move are positional and act at the top level.
|
||||
;;
|
||||
;; CtDoc also carries optional metadata (title/slug/tags) — see meta.sx.
|
||||
;; CtDoc also carries optional metadata (title/slug/tags) — see meta.sx for the
|
||||
;; ergonomic API; they default nil and do not affect block operations.
|
||||
;;
|
||||
;; Op shapes (data, not objects — they are the persist event payload):
|
||||
;; {:op "insert" :block <blk> :after <id|nil>} ; after nil = prepend (top level)
|
||||
;; {:op "update" :id <id> :field <name> :value <v>} ; tree-wide by id
|
||||
;; {:op "move" :id <id> :index <n>} ; top level
|
||||
;; {:op "delete" :id <id>} ; tree-wide by id
|
||||
;; {:op "insert" :block <blk> :after <id|nil>} ; after nil = prepend
|
||||
;; {:op "update" :id <id> :field <name> :value <v>}
|
||||
;; {:op "move" :id <id> :index <n>}
|
||||
;; {:op "delete" :id <id>}
|
||||
|
||||
(define
|
||||
content-bootstrap-doc!
|
||||
@@ -81,58 +76,17 @@
|
||||
(first blocks)
|
||||
(ct-insert-at (rest blocks) (- i 1) x))))))
|
||||
|
||||
;; tree-wide remove by id: drop matches at this level, recurse into children
|
||||
;; (blocks carrying a `children` list, i.e. sections).
|
||||
(define
|
||||
ct-remove-id
|
||||
(fn
|
||||
(blocks id)
|
||||
(map
|
||||
(fn
|
||||
(b)
|
||||
(let
|
||||
((ch (st-iv-get b "children")))
|
||||
(if (list? ch) (st-iv-set! b "children" (ct-remove-id ch id)) b)))
|
||||
(filter (fn (b) (if (= (blk-id b) id) false true)) blocks))))
|
||||
(filter (fn (b) (if (= (blk-id b) id) false true)) blocks)))
|
||||
|
||||
;; tree-wide replace by id: apply f to the match wherever it sits in the tree.
|
||||
(define
|
||||
ct-replace-id
|
||||
(fn
|
||||
(blocks id f)
|
||||
(map
|
||||
(fn
|
||||
(b)
|
||||
(if
|
||||
(= (blk-id b) id)
|
||||
(f b)
|
||||
(let
|
||||
((ch (st-iv-get b "children")))
|
||||
(if
|
||||
(list? ch)
|
||||
(st-iv-set! b "children" (ct-replace-id ch id f))
|
||||
b))))
|
||||
blocks)))
|
||||
|
||||
;; tree-wide find by id: first block matching id anywhere in the tree, or nil.
|
||||
;; Descends into any `children` list, mirroring ct-replace-id/ct-remove-id.
|
||||
(define
|
||||
ct-find-id
|
||||
(fn
|
||||
(blocks id)
|
||||
(if
|
||||
(= (len blocks) 0)
|
||||
nil
|
||||
(let
|
||||
((b (first blocks)))
|
||||
(if
|
||||
(= (blk-id b) id)
|
||||
b
|
||||
(let
|
||||
((ch (st-iv-get b "children")))
|
||||
(let
|
||||
((nested (if (list? ch) (ct-find-id ch id) nil)))
|
||||
(if (= nested nil) (ct-find-id (rest blocks) id) nested))))))))
|
||||
(map (fn (b) (if (= (blk-id b) id) (f b) b)) blocks)))
|
||||
|
||||
;; ── query ──
|
||||
(define doc-index-of (fn (doc id) (ct-index-of (doc-blocks doc) id)))
|
||||
@@ -149,14 +103,6 @@
|
||||
doc-has?
|
||||
(fn (doc id) (if (= (doc-index-of doc id) -1) false true)))
|
||||
|
||||
;; tree-wide lookup by id — reads a nested block by the same id content/edit can
|
||||
;; update/delete (no section.sx dependency; uses the generic children descent).
|
||||
(define doc-find-deep (fn (doc id) (ct-find-id (doc-blocks doc) id)))
|
||||
|
||||
(define
|
||||
doc-has-deep?
|
||||
(fn (doc id) (if (= (doc-find-deep doc id) nil) false true)))
|
||||
|
||||
;; ── structural edits (each returns a new document) ──
|
||||
(define doc-with-blocks (fn (doc blocks) (st-iv-set! doc "blocks" blocks)))
|
||||
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
;; content-on-sx — global find/replace across every text-bearing field.
|
||||
;; content-on-sx — global find/replace across text-bearing blocks.
|
||||
;;
|
||||
;; Replaces every occurrence of `from` with `to` in the text-bearing fields of
|
||||
;; a document, tree-wide (via the transform layer):
|
||||
;; - the `text` of text / heading / code / quote / callout blocks
|
||||
;; - the `alt` of image blocks
|
||||
;; - each item of list blocks
|
||||
;; - every header and cell of table blocks
|
||||
;; This is exactly the set asText / stats / summary draw prose from, so a rename
|
||||
;; via content/find-replace and a word count over asText stay consistent.
|
||||
;; Immutable; case-sensitive.
|
||||
;; Replaces every occurrence of `from` with `to` in the text field of text /
|
||||
;; heading / code / quote blocks, tree-wide (via the transform layer). For
|
||||
;; renaming a term throughout a document. Immutable; case-sensitive.
|
||||
;;
|
||||
;; A text field may be a plain string OR a list of rich-text runs (Phase 5,
|
||||
;; run = (text marks href)). fr-rep-text rewrites per run, preserving each run's
|
||||
;; marks/href; a match that physically straddles two runs is not joined (the
|
||||
;; replacement would have no single mark set) — each run is rewritten in place.
|
||||
;;
|
||||
;; Requires (loaded by harness): block.sx, transform.sx (content/map-blocks),
|
||||
;; table.sx (CtTable ivars).
|
||||
;; Requires (loaded by harness): block.sx, transform.sx (content/map-blocks).
|
||||
|
||||
(define
|
||||
fr-in?
|
||||
@@ -27,71 +15,17 @@
|
||||
((= (first xs) x) true)
|
||||
(else (fr-in? x (rest xs))))))
|
||||
|
||||
(define fr-rep (fn (s from to) (replace (str s) from to)))
|
||||
|
||||
;; rewrite a text-bearing field that is either a plain string or a runs list
|
||||
(define
|
||||
fr-rep-text
|
||||
(fn
|
||||
(v from to)
|
||||
(if
|
||||
(list? v)
|
||||
(map
|
||||
(fn
|
||||
(r)
|
||||
(list
|
||||
(fr-rep (nth r 0) from to)
|
||||
(nth r 1)
|
||||
(nth r 2)))
|
||||
v)
|
||||
(fr-rep v from to))))
|
||||
|
||||
;; Blocks whose prose content find/replace rewrites (matches asText's set).
|
||||
(define
|
||||
fr-has-text?
|
||||
(fn
|
||||
(b)
|
||||
(fr-in?
|
||||
(blk-type b)
|
||||
(list "text" "heading" "code" "quote" "callout" "image" "list" "table"))))
|
||||
|
||||
;; Per-type field rewrite. Each branch returns a new (copy-on-write) block.
|
||||
(define
|
||||
fr-rewrite
|
||||
(fn
|
||||
(b from to)
|
||||
(let
|
||||
((t (blk-type b)))
|
||||
(cond
|
||||
((= t "image")
|
||||
(blk-set b "alt" (fr-rep (blk-get b "alt") from to)))
|
||||
((= t "list")
|
||||
(let
|
||||
((items (blk-get b "items")))
|
||||
(if
|
||||
(list? items)
|
||||
(blk-set b "items" (map (fn (it) (fr-rep it from to)) items))
|
||||
b)))
|
||||
((= t "table")
|
||||
(let
|
||||
((hs (blk-get b "headers")) (rs (blk-get b "rows")))
|
||||
(let
|
||||
((b1 (if (list? hs) (blk-set b "headers" (map (fn (h) (fr-rep h from to)) hs)) b)))
|
||||
(if
|
||||
(list? rs)
|
||||
(blk-set
|
||||
b1
|
||||
"rows"
|
||||
(map
|
||||
(fn
|
||||
(r)
|
||||
(if (list? r) (map (fn (c) (fr-rep c from to)) r) r))
|
||||
rs))
|
||||
b1))))
|
||||
(else (blk-set b "text" (fr-rep-text (blk-get b "text") from to)))))))
|
||||
(fn (b) (fr-in? (blk-type b) (list "text" "heading" "code" "quote"))))
|
||||
|
||||
(define
|
||||
content/find-replace
|
||||
(fn
|
||||
(doc from to)
|
||||
(content/map-blocks doc fr-has-text? (fn (b) (fr-rewrite b from to)))))
|
||||
(content/map-blocks
|
||||
doc
|
||||
fr-has-text?
|
||||
(fn
|
||||
(b)
|
||||
(blk-set b "text" (replace (str (blk-get b "text")) from to))))))
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
;; content-on-sx — block reorder + reparent.
|
||||
;; content-on-sx — relative block reorder.
|
||||
;;
|
||||
;; Relative reorder of top-level blocks (move-before/after/to-front/to-back by
|
||||
;; id) plus TREE reparenting: move a block into a section (content/move-into) or
|
||||
;; promote a nested block back out to the top level (content/promote). Reparent
|
||||
;; ops are tree-wide (the block may start anywhere) and cycle-safe — moving a
|
||||
;; block into its own descendant is rejected (no-op), so a section can never
|
||||
;; become its own ancestor. No-op if any id is missing. Immutable; composes the
|
||||
;; doc.sx list + tree helpers (doc-find-deep / ct-find-id / ct-remove-id /
|
||||
;; ct-replace-id / ct-insert-at).
|
||||
;; Move a top-level block to just before / after another block by id — more
|
||||
;; ergonomic than the index-based doc-move. No-op if either id is missing.
|
||||
;; Immutable; composes the doc.sx list helpers.
|
||||
;;
|
||||
;; Requires (loaded by harness): doc.sx.
|
||||
|
||||
@@ -72,57 +67,3 @@
|
||||
(doc-with-blocks
|
||||
doc
|
||||
(append (ct-remove-id (doc-blocks doc) id) (list blk)))))))
|
||||
|
||||
;; ── reparent (tree-wide) ──
|
||||
;; move block `id` (from anywhere in the tree) to be a child of section
|
||||
;; `section-id` at index `i`. No-op if either id is missing, if id = section-id,
|
||||
;; or if section-id sits inside id's own subtree (would create a cycle).
|
||||
(define
|
||||
content/move-into
|
||||
(fn
|
||||
(doc id section-id i)
|
||||
(let
|
||||
((blk (doc-find-deep doc id)))
|
||||
(if
|
||||
(= blk nil)
|
||||
doc
|
||||
(if
|
||||
(= (doc-find-deep doc section-id) nil)
|
||||
doc
|
||||
(if
|
||||
(= id section-id)
|
||||
doc
|
||||
(if
|
||||
(= (ct-find-id (list blk) section-id) nil)
|
||||
(let
|
||||
((without (ct-remove-id (doc-blocks doc) id)))
|
||||
(doc-with-blocks
|
||||
doc
|
||||
(ct-replace-id
|
||||
without
|
||||
section-id
|
||||
(fn
|
||||
(sec)
|
||||
(let
|
||||
((ch (st-iv-get sec "children")))
|
||||
(if
|
||||
(list? ch)
|
||||
(st-iv-set! sec "children" (ct-insert-at ch i blk))
|
||||
sec))))))
|
||||
doc)))))))
|
||||
|
||||
;; promote block `id` (wherever it sits) out to the end of the top level. If it
|
||||
;; is already top-level this is a move-to-back. No-op if missing. A section keeps
|
||||
;; its whole subtree.
|
||||
(define
|
||||
content/promote
|
||||
(fn
|
||||
(doc id)
|
||||
(let
|
||||
((blk (doc-find-deep doc id)))
|
||||
(if
|
||||
(= blk nil)
|
||||
doc
|
||||
(doc-with-blocks
|
||||
doc
|
||||
(append (ct-remove-id (doc-blocks doc) id) (list blk)))))))
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
;; content-on-sx — block query + table of contents.
|
||||
;;
|
||||
;; Collect blocks across the whole tree (descending into sections) by predicate
|
||||
;; or type, search them by prose, and derive a table of contents from headings.
|
||||
;; Tree detection is inline (class + st-iv-get) so this needs no section.sx.
|
||||
;; or type, and derive a table of contents from headings. Tree detection is
|
||||
;; inline (class + st-iv-get) so this needs no section.sx.
|
||||
;;
|
||||
;; Requires (loaded by harness): block.sx, doc.sx, text.sx (asText for search).
|
||||
;; Requires (loaded by harness): block.sx, doc.sx.
|
||||
|
||||
(define
|
||||
qry-section?
|
||||
@@ -45,30 +45,6 @@
|
||||
content/select-ids
|
||||
(fn (doc pred) (map (fn (b) (blk-id b)) (content/select doc pred))))
|
||||
|
||||
;; Blocks (tree-wide, excluding section containers) whose own prose contains
|
||||
;; `term`. "Prose" is (asText b), so search covers exactly what every block
|
||||
;; exposes as text — text/heading/code/quote/callout text, image alt, list
|
||||
;; items, table headers+cells — with no separate field list to drift from
|
||||
;; asText / find-replace / stats. Case-sensitive substring match.
|
||||
(define
|
||||
content/search-text
|
||||
(fn
|
||||
(doc term)
|
||||
(content/select
|
||||
doc
|
||||
(fn
|
||||
(b)
|
||||
(and
|
||||
(not (qry-section? b))
|
||||
(>= (index-of (asText b) term) 0))))))
|
||||
|
||||
;; Same search, returning matching block ids in document order.
|
||||
(define
|
||||
content/search-text-ids
|
||||
(fn
|
||||
(doc term)
|
||||
(map (fn (b) (blk-id b)) (content/search-text doc term))))
|
||||
|
||||
;; table of contents: {:id :level :text} for every heading, in document order.
|
||||
(define
|
||||
content/headings
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
;; content-on-sx — Phase 5: rich inline text (structured runs).
|
||||
;;
|
||||
;; A CtText's `text` ivar may be EITHER a plain string (backward compat) OR a
|
||||
;; list of inline RUNS. A run is a 3-element list (text marks href):
|
||||
;; text — a string
|
||||
;; marks — a list of mark tokens, a subset of
|
||||
;; :bold :italic :underline :strikethrough :code :subscript
|
||||
;; :superscript :link (SX keywords evaluate to the strings the
|
||||
;; Smalltalk renderer compares against; build them with keywords)
|
||||
;; href — a string ("" when absent; the link target for a :link mark)
|
||||
;;
|
||||
;; Runs are a LIST, not a {:text :marks} dict, because rendering happens inside
|
||||
;; the Smalltalk render methods (nested blocks dispatch asHTML/etc. via Smalltalk
|
||||
;; message sends) and the Smalltalk-on-SX layer can iterate SX lists but cannot
|
||||
;; read SX dict fields. Lists are Smalltalk-native, render under nesting, and
|
||||
;; round-trip through data/wire for free.
|
||||
;;
|
||||
;; content-bootstrap-runs! OVERRIDES the render/markdown/text methods of CtText
|
||||
;; and its subclasses (CtHeading/CtQuote rich; CtCode verbatim — runs render as
|
||||
;; plain concatenated text) with run-aware versions that produce IDENTICAL output
|
||||
;; for a plain-string body. Opt-in: call after the render/markdown/text
|
||||
;; bootstraps; suites that don't call it are unaffected.
|
||||
;;
|
||||
;; Requires (loaded by harness): block.sx, render.sx, markdown.sx, text.sx.
|
||||
|
||||
;; ── SX-side run helpers ──
|
||||
(define mk-run (fn (text marks href) (list text marks href)))
|
||||
(define mk-run-plain (fn (text) (list text (list) "")))
|
||||
(define run-text (fn (r) (nth r 0)))
|
||||
(define run-marks (fn (r) (nth r 1)))
|
||||
(define run-href (fn (r) (nth r 2)))
|
||||
;; a CtText body is "rich" iff it is a runs list (vs a plain string)
|
||||
(define runs? (fn (v) (list? v)))
|
||||
;; build a CtText whose body is a list of runs
|
||||
(define
|
||||
mk-rich-text
|
||||
(fn (id runs) (st-iv-set! (mk-text id "") "text" runs)))
|
||||
|
||||
(define
|
||||
content-bootstrap-runs!
|
||||
(fn
|
||||
()
|
||||
(begin
|
||||
(ct-def-method!
|
||||
"CtText"
|
||||
"runHtml:"
|
||||
"runHtml: run | frag marks href | frag := (run at: 1) htmlEscaped. marks := run at: 2. href := run at: 3. marks do: [:m | (m = 'bold') ifTrue: [frag := '<strong>' , frag , '</strong>']. (m = 'italic') ifTrue: [frag := '<em>' , frag , '</em>']. (m = 'underline') ifTrue: [frag := '<u>' , frag , '</u>']. (m = 'strikethrough') ifTrue: [frag := '<s>' , frag , '</s>']. (m = 'code') ifTrue: [frag := '<code>' , frag , '</code>']. (m = 'subscript') ifTrue: [frag := '<sub>' , frag , '</sub>']. (m = 'superscript') ifTrue: [frag := '<sup>' , frag , '</sup>']. (m = 'link') ifTrue: [frag := '<a href=\"' , href htmlEscaped , '\">' , frag , '</a>']]. ^ frag")
|
||||
(ct-def-method!
|
||||
"CtText"
|
||||
"runSx:"
|
||||
"runSx: run | frag marks href | frag := '\"' , (run at: 1) sxEscaped , '\"'. marks := run at: 2. href := run at: 3. marks do: [:m | (m = 'bold') ifTrue: [frag := '(strong ' , frag , ')']. (m = 'italic') ifTrue: [frag := '(em ' , frag , ')']. (m = 'underline') ifTrue: [frag := '(u ' , frag , ')']. (m = 'strikethrough') ifTrue: [frag := '(s ' , frag , ')']. (m = 'code') ifTrue: [frag := '(code ' , frag , ')']. (m = 'subscript') ifTrue: [frag := '(sub ' , frag , ')']. (m = 'superscript') ifTrue: [frag := '(sup ' , frag , ')']. (m = 'link') ifTrue: [frag := '(a :href \"' , href sxEscaped , '\" ' , frag , ')']]. ^ frag")
|
||||
(ct-def-method!
|
||||
"CtText"
|
||||
"runMd:"
|
||||
"runMd: run | frag marks href | frag := (run at: 1). marks := run at: 2. href := run at: 3. marks do: [:m | (m = 'bold') ifTrue: [frag := '**' , frag , '**']. (m = 'italic') ifTrue: [frag := '_' , frag , '_']. (m = 'strikethrough') ifTrue: [frag := '~~' , frag , '~~']. (m = 'code') ifTrue: [frag := '`' , frag , '`']. (m = 'underline') ifTrue: [frag := '<u>' , frag , '</u>']. (m = 'subscript') ifTrue: [frag := '<sub>' , frag , '</sub>']. (m = 'superscript') ifTrue: [frag := '<sup>' , frag , '</sup>']. (m = 'link') ifTrue: [frag := '[' , frag , '](' , href , ')']]. ^ frag")
|
||||
(ct-def-method!
|
||||
"CtText"
|
||||
"inlineHtml"
|
||||
"inlineHtml | out | (text class name = 'String') ifTrue: [^ text htmlEscaped]. out := ''. text do: [:run | out := out , (self runHtml: run)]. ^ out")
|
||||
(ct-def-method!
|
||||
"CtText"
|
||||
"inlineSx"
|
||||
"inlineSx | out | (text class name = 'String') ifTrue: [^ '\"' , text sxEscaped , '\"']. out := ''. text do: [:run | out := (out = '' ifTrue: [self runSx: run] ifFalse: [out , ' ' , (self runSx: run)])]. ^ out")
|
||||
(ct-def-method!
|
||||
"CtText"
|
||||
"inlineMd"
|
||||
"inlineMd | out | (text class name = 'String') ifTrue: [^ text]. out := ''. text do: [:run | out := out , (self runMd: run)]. ^ out")
|
||||
(ct-def-method!
|
||||
"CtText"
|
||||
"inlineText"
|
||||
"inlineText | out | (text class name = 'String') ifTrue: [^ text]. out := ''. text do: [:run | out := out , (run at: 1)]. ^ out")
|
||||
(ct-def-method!
|
||||
"CtText"
|
||||
"asHTML"
|
||||
"asHTML ^ '<p>' , self inlineHtml , '</p>'")
|
||||
(ct-def-method! "CtText" "asSx" "asSx ^ '(p ' , self inlineSx , ')'")
|
||||
(ct-def-method! "CtText" "asMarkdown:" "asMarkdown: nl ^ self inlineMd")
|
||||
(ct-def-method! "CtText" "asText" "asText ^ self inlineText")
|
||||
(ct-def-method!
|
||||
"CtHeading"
|
||||
"asHTML"
|
||||
"asHTML | t | t := level printString. ^ '<h' , t , '>' , self inlineHtml , '</h' , t , '>'")
|
||||
(ct-def-method!
|
||||
"CtHeading"
|
||||
"asSx"
|
||||
"asSx | t | t := level printString. ^ '(h' , t , ' ' , self inlineSx , ')'")
|
||||
(ct-def-method!
|
||||
"CtHeading"
|
||||
"asMarkdown:"
|
||||
"asMarkdown: nl | h i | h := ''. i := 0. [i < level] whileTrue: [h := h , '#'. i := i + 1]. ^ h , ' ' , self inlineMd")
|
||||
(ct-def-method! "CtHeading" "asText" "asText ^ self inlineText")
|
||||
(ct-def-method!
|
||||
"CtQuote"
|
||||
"asHTML"
|
||||
"asHTML ^ '<blockquote>' , self inlineHtml , '</blockquote>'")
|
||||
(ct-def-method!
|
||||
"CtQuote"
|
||||
"asSx"
|
||||
"asSx ^ '(blockquote ' , self inlineSx , ')'")
|
||||
(ct-def-method!
|
||||
"CtQuote"
|
||||
"asMarkdown:"
|
||||
"asMarkdown: nl ^ '> ' , self inlineMd")
|
||||
(ct-def-method! "CtQuote" "asText" "asText ^ self inlineText")
|
||||
(ct-def-method!
|
||||
"CtCode"
|
||||
"asHTML"
|
||||
"asHTML ^ '<pre><code class=\"language-' , language htmlEscaped , '\">' , self inlineText htmlEscaped , '</code></pre>'")
|
||||
(ct-def-method!
|
||||
"CtCode"
|
||||
"asSx"
|
||||
"asSx ^ '(pre (code \"' , self inlineText sxEscaped , '\"))'")
|
||||
(ct-def-method!
|
||||
"CtCode"
|
||||
"asMarkdown:"
|
||||
"asMarkdown: nl ^ '```' , language , nl , self inlineText , nl , '```'")
|
||||
(ct-def-method! "CtCode" "asText" "asText ^ self inlineText")
|
||||
true)))
|
||||
@@ -1,47 +0,0 @@
|
||||
;; content-on-sx — make a document render-safe by dropping invalid blocks.
|
||||
;;
|
||||
;; The enforcement counterpart to validate: where content/validate REPORTS id /
|
||||
;; field issues, content/sanitize REMOVES the offending blocks so the result can
|
||||
;; be rendered/merged without faulting on malformed input (federated or imported
|
||||
;; documents that failed validation). Tree-wide: descends into sections, pruning
|
||||
;; invalid descendants; a section whose own shell is valid is kept (even if it
|
||||
;; ends up empty — that is normalize's job, not sanitize's), but a section whose
|
||||
;; own check fails (e.g. children is not a list) is dropped whole.
|
||||
;;
|
||||
;; Reuses validate's per-block predicate (content/-block-issues), so the set of
|
||||
;; "what is invalid" stays single-sourced and can't drift from content/validate.
|
||||
;; sanitize addresses per-block id/field validity only; it does NOT resolve
|
||||
;; duplicate ids (a cross-block concern with no single right answer), so a
|
||||
;; sanitized doc is render-safe but not necessarily content/valid? if the input
|
||||
;; carried duplicate ids. Immutable; returns a new document.
|
||||
;;
|
||||
;; Requires (loaded by harness): block.sx, doc.sx, validate.sx
|
||||
;; (content/-block-issues).
|
||||
|
||||
(define
|
||||
san-section?
|
||||
(fn (b) (and (st-instance? b) (= (get b :class) "CtSection"))))
|
||||
|
||||
;; a block is render-safe when it has no id/field issues (validate's own checks)
|
||||
(define san-ok? (fn (b) (= (len (content/-block-issues b)) 0)))
|
||||
|
||||
;; drop invalid blocks at this level; recurse into surviving sections so invalid
|
||||
;; descendants are pruned too.
|
||||
(define
|
||||
san-blocks
|
||||
(fn
|
||||
(blocks)
|
||||
(map
|
||||
(fn
|
||||
(b)
|
||||
(if
|
||||
(san-section? b)
|
||||
(let
|
||||
((ch (st-iv-get b "children")))
|
||||
(if (list? ch) (st-iv-set! b "children" (san-blocks ch)) b))
|
||||
b))
|
||||
(filter san-ok? blocks))))
|
||||
|
||||
(define
|
||||
content/sanitize
|
||||
(fn (doc) (doc-with-blocks doc (san-blocks (doc-blocks doc)))))
|
||||
@@ -3,27 +3,25 @@
|
||||
"block": {"pass": 38, "fail": 0},
|
||||
"doc": {"pass": 40, "fail": 0},
|
||||
"render": {"pass": 42, "fail": 0},
|
||||
"api": {"pass": 32, "fail": 0},
|
||||
"api": {"pass": 26, "fail": 0},
|
||||
"meta": {"pass": 27, "fail": 0},
|
||||
"page": {"pass": 7, "fail": 0},
|
||||
"page-full": {"pass": 4, "fail": 0},
|
||||
"markdown": {"pass": 20, "fail": 0},
|
||||
"runs": {"pass": 36, "fail": 0},
|
||||
"text": {"pass": 20, "fail": 0},
|
||||
"section": {"pass": 25, "fail": 0},
|
||||
"compose": {"pass": 17, "fail": 0},
|
||||
"tree-edit": {"pass": 17, "fail": 0},
|
||||
"move": {"pass": 24, "fail": 0},
|
||||
"block-path": {"pass": 13, "fail": 0},
|
||||
"move": {"pass": 11, "fail": 0},
|
||||
"clone": {"pass": 10, "fail": 0},
|
||||
"query": {"pass": 20, "fail": 0},
|
||||
"query": {"pass": 13, "fail": 0},
|
||||
"toc": {"pass": 8, "fail": 0},
|
||||
"anchor": {"pass": 6, "fail": 0},
|
||||
"outline": {"pass": 14, "fail": 0},
|
||||
"flatten": {"pass": 10, "fail": 0},
|
||||
"transform": {"pass": 12, "fail": 0},
|
||||
"normalize": {"pass": 11, "fail": 0},
|
||||
"find-replace": {"pass": 16, "fail": 0},
|
||||
"find-replace": {"pass": 10, "fail": 0},
|
||||
"stats": {"pass": 17, "fail": 0},
|
||||
"summary": {"pass": 14, "fail": 0},
|
||||
"index": {"pass": 13, "fail": 0},
|
||||
@@ -32,9 +30,8 @@
|
||||
"media": {"pass": 15, "fail": 0},
|
||||
"data": {"pass": 25, "fail": 0},
|
||||
"wire": {"pass": 11, "fail": 0},
|
||||
"validate": {"pass": 32, "fail": 0},
|
||||
"sanitize": {"pass": 12, "fail": 0},
|
||||
"store": {"pass": 46, "fail": 0},
|
||||
"validate": {"pass": 23, "fail": 0},
|
||||
"store": {"pass": 33, "fail": 0},
|
||||
"snapshot": {"pass": 20, "fail": 0},
|
||||
"crdt": {"pass": 34, "fail": 0},
|
||||
"crdt-tree": {"pass": 21, "fail": 0},
|
||||
@@ -45,7 +42,7 @@
|
||||
"md-doc": {"pass": 12, "fail": 0},
|
||||
"fed": {"pass": 20, "fail": 0}
|
||||
},
|
||||
"total_pass": 861,
|
||||
"total_pass": 746,
|
||||
"total_fail": 0,
|
||||
"total": 861
|
||||
"total": 746
|
||||
}
|
||||
|
||||
@@ -7,27 +7,25 @@ _Generated by `lib/content/conformance.sh`_
|
||||
| block | 38 | 0 | 38 |
|
||||
| doc | 40 | 0 | 40 |
|
||||
| render | 42 | 0 | 42 |
|
||||
| api | 32 | 0 | 32 |
|
||||
| api | 26 | 0 | 26 |
|
||||
| meta | 27 | 0 | 27 |
|
||||
| page | 7 | 0 | 7 |
|
||||
| page-full | 4 | 0 | 4 |
|
||||
| markdown | 20 | 0 | 20 |
|
||||
| runs | 36 | 0 | 36 |
|
||||
| text | 20 | 0 | 20 |
|
||||
| section | 25 | 0 | 25 |
|
||||
| compose | 17 | 0 | 17 |
|
||||
| tree-edit | 17 | 0 | 17 |
|
||||
| move | 24 | 0 | 24 |
|
||||
| block-path | 13 | 0 | 13 |
|
||||
| move | 11 | 0 | 11 |
|
||||
| clone | 10 | 0 | 10 |
|
||||
| query | 20 | 0 | 20 |
|
||||
| query | 13 | 0 | 13 |
|
||||
| toc | 8 | 0 | 8 |
|
||||
| anchor | 6 | 0 | 6 |
|
||||
| outline | 14 | 0 | 14 |
|
||||
| flatten | 10 | 0 | 10 |
|
||||
| transform | 12 | 0 | 12 |
|
||||
| normalize | 11 | 0 | 11 |
|
||||
| find-replace | 16 | 0 | 16 |
|
||||
| find-replace | 10 | 0 | 10 |
|
||||
| stats | 17 | 0 | 17 |
|
||||
| summary | 14 | 0 | 14 |
|
||||
| index | 13 | 0 | 13 |
|
||||
@@ -36,9 +34,8 @@ _Generated by `lib/content/conformance.sh`_
|
||||
| media | 15 | 0 | 15 |
|
||||
| data | 25 | 0 | 25 |
|
||||
| wire | 11 | 0 | 11 |
|
||||
| validate | 32 | 0 | 32 |
|
||||
| sanitize | 12 | 0 | 12 |
|
||||
| store | 46 | 0 | 46 |
|
||||
| validate | 23 | 0 | 23 |
|
||||
| store | 33 | 0 | 33 |
|
||||
| snapshot | 20 | 0 | 20 |
|
||||
| crdt | 34 | 0 | 34 |
|
||||
| crdt-tree | 21 | 0 | 21 |
|
||||
@@ -48,4 +45,4 @@ _Generated by `lib/content/conformance.sh`_
|
||||
| md-import | 38 | 0 | 38 |
|
||||
| md-doc | 12 | 0 | 12 |
|
||||
| fed | 20 | 0 | 20 |
|
||||
| **Total** | **861** | **0** | **861** |
|
||||
| **Total** | **746** | **0** | **746** |
|
||||
|
||||
@@ -5,10 +5,9 @@
|
||||
;; replay of its op stream up to a sequence number; the materialised doc is a
|
||||
;; cache, never primary state.
|
||||
;;
|
||||
;; Requires (loaded by the harness): block.sx, doc.sx, section.sx (doc-deep-find
|
||||
;; + doc-tree-ids, for the tree-wide diff), plus persist (event/backend/log/kv/
|
||||
;; api). The persist backend `b` is opened by the caller via (persist/open) and
|
||||
;; injected — content knows nothing about which backend.
|
||||
;; Requires (loaded by the harness): block.sx, doc.sx, and persist
|
||||
;; (event/backend/log/kv/api). The persist backend `b` is opened by the caller
|
||||
;; via (persist/open) and injected — content knows nothing about which backend.
|
||||
|
||||
(define content/-stream (fn (doc-id) (str "content:" doc-id)))
|
||||
|
||||
@@ -70,18 +69,11 @@
|
||||
(fn (b doc-id) (map (fn (ev) {:type (persist/event-type ev) :at (persist/event-at ev) :seq (persist/event-seq ev)}) (content/log b doc-id))))
|
||||
|
||||
;; ── diff between two materialised document versions ──
|
||||
;; Tree-wide: ids are enumerated across the whole block tree (descending into
|
||||
;; sections), so nested-block adds/removes/changes are detected, not just
|
||||
;; top-level ones. Returns {:added :removed :changed} (lists of ids):
|
||||
;; :added — ids present (anywhere) in `new` but not in `old`
|
||||
;; :removed — ids present (anywhere) in `old` but not in `new`
|
||||
;; :changed — content blocks present in both whose block value differs
|
||||
;; Section containers never appear in :changed (they hold no own content — a
|
||||
;; child change surfaces as that child's own entry); a whole section appearing
|
||||
;; or disappearing shows up in :added / :removed by its id.
|
||||
(define content/-all-ids (fn (doc) (doc-tree-ids doc)))
|
||||
|
||||
(define content/-missing? (fn (doc id) (= (doc-deep-find doc id) nil)))
|
||||
;; Returns {:added (ids) :removed (ids) :changed (ids)} where changed = ids
|
||||
;; present in both whose block content differs.
|
||||
(define
|
||||
content/-missing?
|
||||
(fn (doc id) (= (ct-index-of (doc-blocks doc) id) -1)))
|
||||
|
||||
(define
|
||||
content/-changed
|
||||
@@ -91,16 +83,15 @@
|
||||
(fn
|
||||
(id)
|
||||
(let
|
||||
((bo (doc-deep-find old id)) (bn (doc-deep-find new id)))
|
||||
((bo (doc-find old id)) (bn (doc-find new id)))
|
||||
(cond
|
||||
((= bo nil) false)
|
||||
((= bn nil) false)
|
||||
((= (blk-type bo) "section") false)
|
||||
((= bo bn) false)
|
||||
(else true))))
|
||||
(content/-all-ids old))))
|
||||
(doc-ids old))))
|
||||
|
||||
(define content/diff (fn (old new) {:changed (content/-changed old new) :removed (filter (fn (id) (content/-missing? new id)) (content/-all-ids old)) :added (filter (fn (id) (content/-missing? old id)) (content/-all-ids new))}))
|
||||
(define content/diff (fn (old new) {:changed (content/-changed old new) :removed (filter (fn (id) (content/-missing? new id)) (doc-ids old)) :added (filter (fn (id) (content/-missing? old id)) (doc-ids new))}))
|
||||
|
||||
;; convenience: diff two persisted versions by seq.
|
||||
(define
|
||||
|
||||
@@ -97,37 +97,3 @@
|
||||
"render original unchanged"
|
||||
(content/render d1 "html")
|
||||
"<h1>Hi</h1><p>World</p>")
|
||||
|
||||
;; ── facade find/has? are TREE-WIDE (reach into sections); find-top/has-top?
|
||||
;; keep the top-level-only lookup. This makes the read-by-id surface consistent
|
||||
;; with content/edit, whose update/delete are already tree-wide. ──
|
||||
(content-bootstrap-section!)
|
||||
(define
|
||||
nd
|
||||
(content/append
|
||||
(content/empty "nested")
|
||||
(mk-section
|
||||
"sec"
|
||||
(list (content/block "text" "inner" (list (list "text" "deep")))))))
|
||||
(content-test
|
||||
"find nested (deep)"
|
||||
(blk-id (content/find nd "inner"))
|
||||
"inner")
|
||||
(content-test "has? nested (deep)" (content/has? nd "inner") true)
|
||||
(content-test "find-top misses nested" (content/find-top nd "inner") nil)
|
||||
(content-test "has-top? misses nested" (content/has-top? nd "inner") false)
|
||||
(content-test
|
||||
"find-top sees top-level"
|
||||
(blk-id (content/find-top nd "sec"))
|
||||
"sec")
|
||||
;; a nested block updated by id via content/edit is now readable by id via
|
||||
;; content/find (was impossible when find was top-level-only).
|
||||
(content-test
|
||||
"edit-then-find nested round-trip"
|
||||
(str
|
||||
(blk-send
|
||||
(content/find
|
||||
(content/edit nd (content/update "inner" "text" "edited"))
|
||||
"inner")
|
||||
"text"))
|
||||
"edited")
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
;; Extension — locate a block in the tree (ancestor section path).
|
||||
|
||||
(st-bootstrap-classes!)
|
||||
(content/bootstrap!)
|
||||
(content-bootstrap-section!)
|
||||
|
||||
;; doc: top-level "a", section "s" containing "x" and nested section "i"
|
||||
;; containing "z".
|
||||
(define
|
||||
d
|
||||
(doc-append
|
||||
(doc-append (doc-empty "d") (mk-text "a" "A"))
|
||||
(mk-section
|
||||
"s"
|
||||
(list (mk-text "x" "X") (mk-section "i" (list (mk-text "z" "Z")))))))
|
||||
|
||||
;; ── block-path ──
|
||||
(content-test
|
||||
"top-level block has empty path"
|
||||
(content/block-path d "a")
|
||||
(list))
|
||||
(content-test "one-deep block path" (content/block-path d "x") (list "s"))
|
||||
(content-test
|
||||
"two-deep block path"
|
||||
(content/block-path d "z")
|
||||
(list "s" "i"))
|
||||
(content-test "section's own path" (content/block-path d "i") (list "s"))
|
||||
(content-test "missing id path nil" (content/block-path d "zzz") nil)
|
||||
|
||||
;; nil (absent) is distinct from () (present top-level)
|
||||
(content-test
|
||||
"absent vs top-level distinguishable"
|
||||
(if (= (content/block-path d "a") nil) "nil" "list")
|
||||
"list")
|
||||
|
||||
;; ── block-depth ──
|
||||
(content-test "depth top-level" (content/block-depth d "a") 0)
|
||||
(content-test "depth one" (content/block-depth d "x") 1)
|
||||
(content-test "depth two" (content/block-depth d "z") 2)
|
||||
(content-test "depth section" (content/block-depth d "i") 1)
|
||||
(content-test "depth absent" (content/block-depth d "zzz") -1)
|
||||
|
||||
;; ── path tracks reparenting (composes with move.sx) ──
|
||||
;; (rebuild expectation directly; move tested elsewhere)
|
||||
(define
|
||||
flat
|
||||
(doc-append
|
||||
(doc-append (doc-empty "d") (mk-section "sec" (list)))
|
||||
(mk-text "p" "P")))
|
||||
(content-test
|
||||
"before: p at top level"
|
||||
(content/block-depth flat "p")
|
||||
0)
|
||||
|
||||
;; ── empty doc ──
|
||||
(content-test
|
||||
"empty doc path nil"
|
||||
(content/block-path (doc-empty "e") "x")
|
||||
nil)
|
||||
@@ -1,10 +1,8 @@
|
||||
;; Extension — global find/replace across every text-bearing field.
|
||||
;; Extension — global find/replace across text-bearing blocks.
|
||||
|
||||
(st-bootstrap-classes!)
|
||||
(content/bootstrap!)
|
||||
(content-bootstrap-section!)
|
||||
(content-bootstrap-callout!)
|
||||
(content-bootstrap-table!)
|
||||
|
||||
(define
|
||||
d
|
||||
@@ -32,12 +30,11 @@
|
||||
(str (blk-send (doc-deep-find r "n") "text"))
|
||||
"nested Bar")
|
||||
|
||||
;; ── image alt IS a text field (asText ^ alt), so it is rewritten ──
|
||||
;; ── does NOT touch image alt/src (not a text field) ──
|
||||
(content-test
|
||||
"image alt replaced"
|
||||
"image alt untouched"
|
||||
(str (blk-send (doc-deep-find r "img") "alt"))
|
||||
"Bar alt")
|
||||
;; ── but src is a URL, not prose, so it stays put ──
|
||||
"Foo alt")
|
||||
(content-test
|
||||
"image src untouched"
|
||||
(str (blk-send (doc-deep-find r "img") "src"))
|
||||
@@ -79,68 +76,6 @@
|
||||
(str (blk-send (doc-find r2 "q") "text"))
|
||||
"new saying")
|
||||
|
||||
;; ── callout text is covered (consistency with asText/stats/summary) ──
|
||||
(content-test
|
||||
"replace callout text"
|
||||
(str
|
||||
(blk-send
|
||||
(doc-find
|
||||
(content/find-replace
|
||||
(doc-append (doc-empty "d") (mk-callout "co" "note" "Foo here"))
|
||||
"Foo"
|
||||
"Bar")
|
||||
"co")
|
||||
"text"))
|
||||
"Bar here")
|
||||
(content-test
|
||||
"callout kind untouched by text replace"
|
||||
(str
|
||||
(blk-send
|
||||
(doc-find
|
||||
(content/find-replace
|
||||
(doc-append (doc-empty "d") (mk-callout "co" "note" "x"))
|
||||
"note"
|
||||
"X")
|
||||
"co")
|
||||
"kind"))
|
||||
"note")
|
||||
|
||||
;; ── list items are rewritten (asText folds items) ──
|
||||
(define
|
||||
rl
|
||||
(content/find-replace
|
||||
(doc-append
|
||||
(doc-empty "d")
|
||||
(mk-list "l" false (list "Foo one" "two Foo")))
|
||||
"Foo"
|
||||
"Bar"))
|
||||
(content-test
|
||||
"replace first list item"
|
||||
(str (first (blk-send (doc-find rl "l") "items")))
|
||||
"Bar one")
|
||||
(content-test
|
||||
"replace second list item"
|
||||
(str (first (rest (blk-send (doc-find rl "l") "items"))))
|
||||
"two Bar")
|
||||
|
||||
;; ── table headers + cells are rewritten (asText folds rows) ──
|
||||
(define
|
||||
rt
|
||||
(content/find-replace
|
||||
(doc-append
|
||||
(doc-empty "d")
|
||||
(mk-table "t" (list "Foo head") (list (list "a Foo" "b"))))
|
||||
"Foo"
|
||||
"Bar"))
|
||||
(content-test
|
||||
"replace table header"
|
||||
(str (first (table-headers (doc-find rt "t"))))
|
||||
"Bar head")
|
||||
(content-test
|
||||
"replace table cell"
|
||||
(str (first (first (table-rows (doc-find rt "t")))))
|
||||
"a Bar")
|
||||
|
||||
;; ── no match → unchanged render ──
|
||||
(content-test
|
||||
"no match"
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
;; Extension — relative block reorder + tree reparent.
|
||||
;; Extension — relative block reorder.
|
||||
|
||||
(st-bootstrap-classes!)
|
||||
(content/bootstrap!)
|
||||
(content-bootstrap-section!)
|
||||
|
||||
(define
|
||||
d
|
||||
@@ -62,84 +61,3 @@
|
||||
"render after move"
|
||||
(asHTML (content/move-after d "a" "c"))
|
||||
"<p>B</p><p>C</p><p>A</p>")
|
||||
|
||||
;; ── reparent: move a top-level block INTO a section ──
|
||||
(define
|
||||
nd
|
||||
(doc-append
|
||||
(doc-append (doc-empty "d") (mk-text "p" "P"))
|
||||
(mk-section "s" (list (mk-text "x" "X")))))
|
||||
(content-test
|
||||
"move-into: block leaves top level"
|
||||
(doc-ids (content/move-into nd "p" "s" 1))
|
||||
(list "s"))
|
||||
(content-test
|
||||
"move-into: block lands in section at index"
|
||||
(doc-tree-ids (content/move-into nd "p" "s" 1))
|
||||
(list "s" "x" "p"))
|
||||
(content-test
|
||||
"move-into at front of section"
|
||||
(doc-tree-ids (content/move-into nd "p" "s" 0))
|
||||
(list "s" "p" "x"))
|
||||
(content-test "move-into immutable" (doc-tree-ids nd) (list "p" "s" "x"))
|
||||
|
||||
;; ── reparent: move a NESTED block to a different section ──
|
||||
(define
|
||||
two
|
||||
(doc-append
|
||||
(doc-append (doc-empty "d") (mk-section "s1" (list (mk-text "n" "N"))))
|
||||
(mk-section "s2" (list (mk-text "y" "Y")))))
|
||||
(content-test
|
||||
"move-into across sections"
|
||||
(doc-tree-ids (content/move-into two "n" "s2" 1))
|
||||
(list "s1" "s2" "y" "n"))
|
||||
|
||||
;; ── promote: nested block out to top level (appended last) ──
|
||||
(content-test
|
||||
"promote nested to top level"
|
||||
(doc-tree-ids (content/promote two "n"))
|
||||
(list "s1" "s2" "y" "n"))
|
||||
(content-test
|
||||
"promote leaves section empty shell"
|
||||
(doc-ids (content/promote two "n"))
|
||||
(list "s1" "s2" "n"))
|
||||
(content-test
|
||||
"promote a whole section keeps its subtree"
|
||||
(doc-tree-ids
|
||||
(content/promote
|
||||
(doc-append
|
||||
(doc-empty "d")
|
||||
(mk-section "o" (list (mk-section "i" (list (mk-text "z" "Z"))))))
|
||||
"i"))
|
||||
(list "o" "i" "z"))
|
||||
|
||||
;; ── cycle guard: cannot move a section into its own descendant ──
|
||||
(define
|
||||
nest
|
||||
(doc-append
|
||||
(doc-empty "d")
|
||||
(mk-section
|
||||
"outer"
|
||||
(list (mk-section "inner" (list (mk-text "t" "T")))))))
|
||||
(content-test
|
||||
"move section into its own child is a no-op"
|
||||
(doc-tree-ids (content/move-into nest "outer" "inner" 0))
|
||||
(list "outer" "inner" "t"))
|
||||
(content-test
|
||||
"move block into itself is a no-op"
|
||||
(doc-tree-ids (content/move-into nest "inner" "inner" 0))
|
||||
(list "outer" "inner" "t"))
|
||||
|
||||
;; ── reparent no-ops on missing ids ──
|
||||
(content-test
|
||||
"move-into missing block no-op"
|
||||
(doc-tree-ids (content/move-into nd "zzz" "s" 0))
|
||||
(list "p" "s" "x"))
|
||||
(content-test
|
||||
"move-into missing section no-op"
|
||||
(doc-tree-ids (content/move-into nd "p" "zzz" 0))
|
||||
(list "p" "s" "x"))
|
||||
(content-test
|
||||
"promote missing no-op"
|
||||
(doc-tree-ids (content/promote nd "zzz"))
|
||||
(list "p" "s" "x"))
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
;; Extension — block query + table of contents + prose search.
|
||||
;; Extension — block query + table of contents.
|
||||
|
||||
(st-bootstrap-classes!)
|
||||
(content/bootstrap!)
|
||||
(content-bootstrap-text!)
|
||||
(content-bootstrap-section!)
|
||||
(content-bootstrap-table!)
|
||||
(content-bootstrap-callout!)
|
||||
|
||||
(define
|
||||
d
|
||||
@@ -90,49 +87,3 @@
|
||||
"deep toc level"
|
||||
(get (first (content/headings deep)) :level)
|
||||
3)
|
||||
|
||||
;; ── prose search (content/search-text) ──
|
||||
;; "cat" appears in text, image alt, a list item, a table cell, and a callout
|
||||
;; — every text-bearing field — so search must find all five via asText.
|
||||
(define
|
||||
sd
|
||||
(doc-append
|
||||
(doc-append
|
||||
(doc-append
|
||||
(doc-append
|
||||
(doc-append
|
||||
(doc-empty "sd")
|
||||
(mk-heading "sh" 1 "Welcome aboard"))
|
||||
(mk-text "st" "the cat sat"))
|
||||
(mk-image "si" "/x.png" "a cat photo"))
|
||||
(mk-list "sl" false (list "first cat" "second dog")))
|
||||
(mk-section
|
||||
"sec"
|
||||
(list
|
||||
(mk-table "stb" (list "Animal") (list (list "cat") (list "fish")))
|
||||
(mk-callout "sc" "note" "beware of cat")))))
|
||||
|
||||
(content-test
|
||||
"search across every text-bearing field"
|
||||
(content/search-text-ids sd "cat")
|
||||
(list "st" "si" "sl" "stb" "sc"))
|
||||
(content-test "search count" (len (content/search-text sd "cat")) 5)
|
||||
(content-test
|
||||
"search heading text"
|
||||
(content/search-text-ids sd "Welcome")
|
||||
(list "sh"))
|
||||
(content-test
|
||||
"search list item only"
|
||||
(content/search-text-ids sd "dog")
|
||||
(list "sl"))
|
||||
(content-test "search no match" (content/search-text-ids sd "zzz") (list))
|
||||
;; section containers are excluded — a term living only inside a section's
|
||||
;; children returns the child, never the section wrapper.
|
||||
(content-test
|
||||
"search excludes section wrapper"
|
||||
(content/search-text-ids sd "fish")
|
||||
(list "stb"))
|
||||
(content-test
|
||||
"search returns block objects"
|
||||
(blk-id (first (content/search-text sd "Welcome")))
|
||||
"sh")
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
;; Phase 5 — rich inline text (structured runs). Acceptance suite.
|
||||
|
||||
(st-bootstrap-classes!)
|
||||
(content/bootstrap!)
|
||||
(content-bootstrap-markdown!)
|
||||
(content-bootstrap-text!)
|
||||
(content-bootstrap-section!)
|
||||
(content-bootstrap-runs!)
|
||||
|
||||
;; one-run helper: a CtText with a single marked run
|
||||
(define
|
||||
one
|
||||
(fn (marks href) (mk-rich-text "p" (list (mk-run "x" marks href)))))
|
||||
|
||||
;; ── (1) four render modes ──
|
||||
;; a paragraph mixing plain + bold + a link
|
||||
(define
|
||||
rd
|
||||
(doc-append
|
||||
(doc-empty "d")
|
||||
(mk-rich-text
|
||||
"p"
|
||||
(list
|
||||
(mk-run "the " (list) "")
|
||||
(mk-run "cat" (list :bold) "")
|
||||
(mk-run " and " (list) "")
|
||||
(mk-run "dog" (list :italic :link) "/d")
|
||||
(mk-run " sat" (list) "")))))
|
||||
(define p (doc-find rd "p"))
|
||||
(content-test
|
||||
"asHTML rich"
|
||||
(asHTML rd)
|
||||
"<p>the <strong>cat</strong> and <a href=\"/d\"><em>dog</em></a> sat</p>")
|
||||
(content-test
|
||||
"asMarkdown rich"
|
||||
(asMarkdown rd)
|
||||
"the **cat** and [_dog_](/d) sat")
|
||||
(content-test
|
||||
"asSx rich"
|
||||
(asSx rd)
|
||||
"(article (p \"the \" (strong \"cat\") \" and \" (a :href \"/d\" (em \"dog\")) \" sat\"))")
|
||||
(content-test
|
||||
"asText rich is plain (no markup)"
|
||||
(asText rd)
|
||||
"the cat and dog sat")
|
||||
|
||||
;; every mark renders in HTML
|
||||
(content-test
|
||||
"mark bold html"
|
||||
(asHTML (one (list :bold) ""))
|
||||
"<p><strong>x</strong></p>")
|
||||
(content-test
|
||||
"mark italic html"
|
||||
(asHTML (one (list :italic) ""))
|
||||
"<p><em>x</em></p>")
|
||||
(content-test
|
||||
"mark underline html"
|
||||
(asHTML (one (list :underline) ""))
|
||||
"<p><u>x</u></p>")
|
||||
(content-test
|
||||
"mark strike html"
|
||||
(asHTML (one (list :strikethrough) ""))
|
||||
"<p><s>x</s></p>")
|
||||
(content-test
|
||||
"mark code html"
|
||||
(asHTML (one (list :code) ""))
|
||||
"<p><code>x</code></p>")
|
||||
(content-test
|
||||
"mark sub html"
|
||||
(asHTML (one (list :subscript) ""))
|
||||
"<p><sub>x</sub></p>")
|
||||
(content-test
|
||||
"mark sup html"
|
||||
(asHTML (one (list :superscript) ""))
|
||||
"<p><sup>x</sup></p>")
|
||||
(content-test
|
||||
"mark link html"
|
||||
(asHTML (one (list :link) "/u"))
|
||||
"<p><a href=\"/u\">x</a></p>")
|
||||
|
||||
;; markdown marks
|
||||
(content-test "mark bold md" (asMarkdown (one (list :bold) "")) "**x**")
|
||||
(content-test "mark italic md" (asMarkdown (one (list :italic) "")) "_x_")
|
||||
(content-test
|
||||
"mark strike md"
|
||||
(asMarkdown (one (list :strikethrough) ""))
|
||||
"~~x~~")
|
||||
(content-test "mark code md" (asMarkdown (one (list :code) "")) "`x`")
|
||||
(content-test "mark link md" (asMarkdown (one (list :link) "/u")) "[x](/u)")
|
||||
(content-test
|
||||
"mark underline md fallback"
|
||||
(asMarkdown (one (list :underline) ""))
|
||||
"<u>x</u>")
|
||||
|
||||
;; nested marks (bold+italic) — deterministic nesting order
|
||||
(content-test
|
||||
"nested marks html"
|
||||
(asHTML (one (list :bold :italic) ""))
|
||||
"<p><em><strong>x</strong></em></p>")
|
||||
|
||||
;; escaping still happens inside runs
|
||||
(content-test
|
||||
"run text escaped html"
|
||||
(asHTML (mk-rich-text "p" (list (mk-run "a & b <c>" (list :bold) ""))))
|
||||
"<p><strong>a & b <c></strong></p>")
|
||||
|
||||
;; rich heading + quote + code
|
||||
(content-test
|
||||
"rich heading html"
|
||||
(asHTML
|
||||
(st-iv-set!
|
||||
(mk-heading "h" 2 "")
|
||||
"text"
|
||||
(list (mk-run "Big " (list) "") (mk-run "bold" (list :bold) ""))))
|
||||
"<h2>Big <strong>bold</strong></h2>")
|
||||
(content-test
|
||||
"rich quote html"
|
||||
(asHTML
|
||||
(st-iv-set!
|
||||
(mk-quote "q" "" "")
|
||||
"text"
|
||||
(list (mk-run "wise" (list :italic) ""))))
|
||||
"<blockquote><em>wise</em></blockquote>")
|
||||
;; code is verbatim — runs concatenate as plain text, marks ignored
|
||||
(content-test
|
||||
"code runs plain html"
|
||||
(asHTML
|
||||
(st-iv-set!
|
||||
(mk-code "c" "py" "")
|
||||
"text"
|
||||
(list (mk-run "a=" (list :bold) "") (mk-run "1" (list) ""))))
|
||||
"<pre><code class=\"language-py\">a=1</code></pre>")
|
||||
|
||||
;; ── (2) backward compat: plain-string CtText unchanged ──
|
||||
(content-test
|
||||
"plain html"
|
||||
(asHTML (mk-text "q" "hi & <b>"))
|
||||
"<p>hi & <b></p>")
|
||||
(content-test "plain sx" (asSx (mk-text "q" "hi")) "(p \"hi\")")
|
||||
(content-test "plain md" (asMarkdown (mk-text "q" "hi")) "hi")
|
||||
(content-test "plain text" (asText (mk-text "q" "hi")) "hi")
|
||||
(content-test
|
||||
"plain heading html"
|
||||
(asHTML (mk-heading "h" 3 "T"))
|
||||
"<h3>T</h3>")
|
||||
|
||||
;; ── (3) find-replace across runs (per-run, marks preserved) ──
|
||||
(define
|
||||
frd
|
||||
(doc-append
|
||||
(doc-empty "d")
|
||||
(mk-rich-text
|
||||
"p"
|
||||
(list
|
||||
(mk-run "the Foo" (list :bold) "")
|
||||
(mk-run " and Foo here" (list) "")))))
|
||||
(define frr (content/find-replace frd "Foo" "Bar"))
|
||||
(content-test
|
||||
"find-replace rich plain text"
|
||||
(asText frr)
|
||||
"the Bar and Bar here")
|
||||
(content-test
|
||||
"find-replace rich preserves marks"
|
||||
(asHTML frr)
|
||||
"<p><strong>the Bar</strong> and Bar here</p>")
|
||||
(content-test
|
||||
"find-replace rich run0 still bold"
|
||||
(nth (nth (blk-get (doc-find frr "p") "text") 0) 1)
|
||||
(list "bold"))
|
||||
|
||||
;; ── (4) search-text via asText, across run boundary ──
|
||||
;; "cat sat" spans run1 ("the cat") and run2 (" sat")
|
||||
(define
|
||||
sd
|
||||
(doc-append
|
||||
(doc-empty "d")
|
||||
(mk-rich-text
|
||||
"p"
|
||||
(list (mk-run "the cat" (list :bold) "") (mk-run " sat" (list) "")))))
|
||||
(content-test
|
||||
"search finds substring across runs"
|
||||
(content/search-text-ids sd "cat sat")
|
||||
(list "p"))
|
||||
(content-test "search miss" (content/search-text-ids sd "zzz") (list))
|
||||
|
||||
;; ── (5) CRDT invariant — runs are an opaque block-level value ──
|
||||
(define ra (list (mk-run "x" (list :bold) "")))
|
||||
(define rb (list (mk-run "y" (list :italic) "")))
|
||||
(define
|
||||
s1
|
||||
(crdt-insert
|
||||
(crdt-empty)
|
||||
"p"
|
||||
"text"
|
||||
(crdt-pos 5 "a")
|
||||
{:text ra}
|
||||
1
|
||||
"a"))
|
||||
(define s2 (crdt-update s1 "p" "text" rb 2 "b"))
|
||||
(content-test
|
||||
"crdt merge commutes with runs"
|
||||
(get (crdt-merge s1 s2) :elements)
|
||||
(get (crdt-merge s2 s1) :elements))
|
||||
(content-test
|
||||
"crdt merge idempotent with runs"
|
||||
(get (crdt-merge s2 s2) :elements)
|
||||
(get s2 :elements))
|
||||
;; LWW: later ts (rb, ts 2) wins; runs survive as the field value
|
||||
(content-test
|
||||
"crdt LWW keeps latest runs"
|
||||
(asHTML
|
||||
(crdt-element->block (get (get (crdt-merge s1 s2) :elements) "p")))
|
||||
"<p><em>y</em></p>")
|
||||
|
||||
;; ── (6) data + wire round-trip runs losslessly ──
|
||||
(content-test
|
||||
"data round-trip rich html"
|
||||
(asHTML (content/from-data (content/to-data rd)))
|
||||
(asHTML rd))
|
||||
(content-test
|
||||
"data round-trip rich text"
|
||||
(asText (content/from-data (content/to-data rd)))
|
||||
"the cat and dog sat")
|
||||
(content-test
|
||||
"wire round-trip rich html"
|
||||
(asHTML (content/from-wire (content/to-wire rd)))
|
||||
(asHTML rd))
|
||||
@@ -1,128 +0,0 @@
|
||||
;; Extension — make a document render-safe by dropping invalid blocks.
|
||||
;; Counterpart to validate; reuses its per-block checks. Tree-wide.
|
||||
|
||||
(st-bootstrap-classes!)
|
||||
(content-bootstrap-blocks!)
|
||||
(content-bootstrap-doc!)
|
||||
(content-bootstrap-section!)
|
||||
|
||||
;; ── a valid document is returned unchanged (same ids, tree order) ──
|
||||
(define
|
||||
good
|
||||
(doc-append
|
||||
(doc-append (doc-empty "d") (mk-heading "h" 1 "Title"))
|
||||
(mk-text "p" "Body")))
|
||||
(content-test
|
||||
"valid doc keeps all blocks"
|
||||
(doc-ids (content/sanitize good))
|
||||
(list "h" "p"))
|
||||
(content-test
|
||||
"valid doc still valid after sanitize"
|
||||
(content/valid? (content/sanitize good))
|
||||
true)
|
||||
|
||||
;; ── a block with a bad field is dropped ──
|
||||
(content-test
|
||||
"bad-field block dropped"
|
||||
(doc-ids
|
||||
(content/sanitize
|
||||
(doc-append
|
||||
(doc-append (doc-empty "d") (mk-text "ok" "fine"))
|
||||
(mk-heading "bad" "notnum" "T"))))
|
||||
(list "ok"))
|
||||
|
||||
;; ── unknown block type dropped ──
|
||||
(define raw (st-iv-set! (st-make-instance "CtBlock") "id" "z"))
|
||||
(content-test
|
||||
"unknown-type block dropped"
|
||||
(doc-ids
|
||||
(content/sanitize
|
||||
(doc-append (doc-append (doc-empty "d") (mk-text "ok" "x")) raw)))
|
||||
(list "ok"))
|
||||
|
||||
;; ── blank-id block dropped ──
|
||||
(content-test
|
||||
"blank-id block dropped"
|
||||
(doc-ids
|
||||
(content/sanitize
|
||||
(doc-append
|
||||
(doc-append (doc-empty "d") (mk-text "ok" "x"))
|
||||
(mk-text "" "y"))))
|
||||
(list "ok"))
|
||||
|
||||
;; ── result is render-safe: no id/field issues remain ──
|
||||
(content-test
|
||||
"sanitized has no field/id issues"
|
||||
(len
|
||||
(filter
|
||||
(fn (i) (if (= (get i :kind) "field") true (= (get i :kind) "id")))
|
||||
(content/validate
|
||||
(content/sanitize
|
||||
(doc-append
|
||||
(doc-append (doc-empty "d") (mk-text "ok" "x"))
|
||||
(mk-heading "bad" "notnum" "T"))))))
|
||||
0)
|
||||
|
||||
;; ── immutability: original document untouched ──
|
||||
(define
|
||||
withbad
|
||||
(doc-append
|
||||
(doc-append (doc-empty "d") (mk-text "ok" "x"))
|
||||
(mk-heading "bad" "notnum" "T")))
|
||||
(define _ (content/sanitize withbad))
|
||||
(content-test "original unchanged" (doc-ids withbad) (list "ok" "bad"))
|
||||
|
||||
;; ── tree-wide: invalid nested child pruned, valid sibling + section kept ──
|
||||
(define
|
||||
nested
|
||||
(doc-append
|
||||
(doc-empty "d")
|
||||
(mk-section
|
||||
"s"
|
||||
(list (mk-text "good" "keep") (mk-heading "badc" "notnum" "X")))))
|
||||
(content-test
|
||||
"invalid nested child pruned, section kept"
|
||||
(doc-tree-ids (content/sanitize nested))
|
||||
(list "s" "good"))
|
||||
|
||||
;; ── a section whose own shell is invalid (children not a list) is dropped ──
|
||||
(define
|
||||
badsec
|
||||
(doc-append
|
||||
(doc-append (doc-empty "d") (mk-text "ok" "x"))
|
||||
(st-iv-set! (mk-section "s" (list)) "children" "nope")))
|
||||
(content-test
|
||||
"invalid section shell dropped whole"
|
||||
(doc-tree-ids (content/sanitize badsec))
|
||||
(list "ok"))
|
||||
|
||||
;; ── a valid section that loses all children is kept (empty) — sanitize is not
|
||||
;; normalize; it removes invalid, not empty ──
|
||||
(define
|
||||
allbadchildren
|
||||
(doc-append
|
||||
(doc-empty "d")
|
||||
(mk-section "s" (list (mk-heading "b1" "x" "X") (mk-text "" "y")))))
|
||||
(content-test
|
||||
"section kept though emptied of invalid children"
|
||||
(doc-tree-ids (content/sanitize allbadchildren))
|
||||
(list "s"))
|
||||
|
||||
;; ── deeply nested: invalid block two levels down is pruned ──
|
||||
(define
|
||||
deep
|
||||
(doc-append
|
||||
(doc-empty "d")
|
||||
(mk-section
|
||||
"o"
|
||||
(list (mk-section "i" (list (mk-text "dok" "x") (mk-text "" "bad")))))))
|
||||
(content-test
|
||||
"deep invalid pruned"
|
||||
(doc-tree-ids (content/sanitize deep))
|
||||
(list "o" "i" "dok"))
|
||||
|
||||
;; ── empty document sanitizes to empty ──
|
||||
(content-test
|
||||
"empty doc stays empty"
|
||||
(doc-ids (content/sanitize (doc-empty "e")))
|
||||
(list))
|
||||
@@ -151,58 +151,3 @@
|
||||
"op-log media type"
|
||||
(blk-type (doc-find (content/head B3 "rich") "v"))
|
||||
"media")
|
||||
|
||||
;; ── op-log update/delete reach NESTED blocks (tree-wide by id) ──
|
||||
(content-bootstrap-section!)
|
||||
(define B4 (persist/open))
|
||||
(content/commit!
|
||||
B4
|
||||
"nest"
|
||||
(op-insert (mk-section "sec" (list (mk-text "n" "orig"))) nil)
|
||||
1)
|
||||
(content/commit! B4 "nest" (op-update "n" "text" "edited") 2)
|
||||
(content-test
|
||||
"op-log nested update"
|
||||
(str (blk-send (doc-deep-find (content/head B4 "nest") "n") "text"))
|
||||
"edited")
|
||||
(content-test
|
||||
"op-log nested update tree intact"
|
||||
(doc-tree-ids (content/head B4 "nest"))
|
||||
(list "sec" "n"))
|
||||
(content/commit! B4 "nest" (op-delete "n") 3)
|
||||
(content-test
|
||||
"op-log nested delete"
|
||||
(doc-tree-ids (content/head B4 "nest"))
|
||||
(list "sec"))
|
||||
(content-test
|
||||
"op-log nested delete via content/at seq2"
|
||||
(doc-tree-ids (content/at B4 "nest" 2))
|
||||
(list "sec" "n"))
|
||||
|
||||
;; ── diff is TREE-WIDE: nested-block add/change/remove are detected, and
|
||||
;; section containers never appear in :changed (a top-level-only diff would miss
|
||||
;; "n" entirely and instead flag the section). ──
|
||||
(define dn01 (content/diff-versions B4 "nest" 0 1))
|
||||
(content-test
|
||||
"diff nested added (section + child)"
|
||||
(get dn01 :added)
|
||||
(list "sec" "n"))
|
||||
(content-test "diff nested added removed empty" (get dn01 :removed) (list))
|
||||
(content-test "diff nested added changed empty" (get dn01 :changed) (list))
|
||||
|
||||
(define dn12 (content/diff-versions B4 "nest" 1 2))
|
||||
(content-test
|
||||
"diff nested changed child only"
|
||||
(get dn12 :changed)
|
||||
(list "n"))
|
||||
(content-test "diff nested changed no add" (get dn12 :added) (list))
|
||||
(content-test "diff nested changed no remove" (get dn12 :removed) (list))
|
||||
|
||||
(define dn23 (content/diff-versions B4 "nest" 2 3))
|
||||
(content-test "diff nested removed child" (get dn23 :removed) (list "n"))
|
||||
(content-test "diff nested removed no change" (get dn23 :changed) (list))
|
||||
|
||||
(content-test
|
||||
"diff nested no-op"
|
||||
(get (content/diff-versions B4 "nest" 1 1) :changed)
|
||||
(list))
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
(content-bootstrap-blocks!)
|
||||
(content-bootstrap-doc!)
|
||||
(content-bootstrap-section!)
|
||||
(content-bootstrap-table!)
|
||||
|
||||
;; ── a fully valid document ──
|
||||
(define
|
||||
@@ -165,62 +164,3 @@
|
||||
(content/validate dup-tree)))
|
||||
1)
|
||||
(content-test "tree dup not valid" (content/valid? dup-tree) false)
|
||||
|
||||
;; ── collection blocks vetted ELEMENT-DEEP (items/cells must be strings) ──
|
||||
;; A list whose items field is a list but holds a non-string would pass the old
|
||||
;; "is a list" check yet crash asText/render — now caught.
|
||||
(content-test
|
||||
"list non-string item flagged"
|
||||
(content/issue-kinds
|
||||
(doc-append (doc-empty "d") (mk-list "l" true (list "a" 5))))
|
||||
(list "field"))
|
||||
(content-test
|
||||
"list all-string items valid"
|
||||
(content/valid?
|
||||
(doc-append (doc-empty "d") (mk-list "l" false (list "a" "b" "c"))))
|
||||
true)
|
||||
(content-test
|
||||
"list empty items valid"
|
||||
(content/valid? (doc-append (doc-empty "d") (mk-list "l" true (list))))
|
||||
true)
|
||||
;; a malformed-list block reports exactly one element issue (not the is-a-list one)
|
||||
(content-test
|
||||
"list non-string item single issue"
|
||||
(len
|
||||
(content/validate
|
||||
(doc-append
|
||||
(doc-empty "d")
|
||||
(mk-list "l" true (list 1 2)))))
|
||||
1)
|
||||
|
||||
(content-test
|
||||
"valid table ok"
|
||||
(content/valid?
|
||||
(doc-append
|
||||
(doc-empty "d")
|
||||
(mk-table "t" (list "H1" "H2") (list (list "a" "b") (list "c" "d")))))
|
||||
true)
|
||||
(content-test
|
||||
"table empty rows valid"
|
||||
(content/valid?
|
||||
(doc-append (doc-empty "d") (mk-table "t" (list "H") (list))))
|
||||
true)
|
||||
(content-test
|
||||
"table non-list row flagged"
|
||||
(content/issue-kinds
|
||||
(doc-append (doc-empty "d") (mk-table "t" (list "H") (list "notarow"))))
|
||||
(list "field"))
|
||||
(content-test
|
||||
"table non-string cell flagged"
|
||||
(content/issue-kinds
|
||||
(doc-append
|
||||
(doc-empty "d")
|
||||
(mk-table "t" (list "H") (list (list "ok") (list 9)))))
|
||||
(list "field"))
|
||||
(content-test
|
||||
"table non-string header flagged"
|
||||
(content/issue-kinds
|
||||
(doc-append
|
||||
(doc-empty "d")
|
||||
(mk-table "t" (list "H" 2) (list (list "a" "b")))))
|
||||
(list "field"))
|
||||
|
||||
@@ -6,11 +6,6 @@
|
||||
;; Tree detection is inline (class + st-iv-get) so this file needs no section.sx.
|
||||
;; Dispatch on block type is a validation-boundary concern, not core behaviour.
|
||||
;;
|
||||
;; Collection blocks are vetted element-deep: list items must all be strings and
|
||||
;; table rows must all be lists of strings — exactly what render/asText/
|
||||
;; find-replace/search assume — so malformed nested collections are caught at the
|
||||
;; boundary instead of crashing the render layer downstream.
|
||||
;;
|
||||
;; Requires (loaded by harness): block.sx, doc.sx.
|
||||
|
||||
(define ct-issue (fn (id kind detail) {:id id :detail detail :kind kind}))
|
||||
@@ -41,28 +36,6 @@
|
||||
|
||||
(define ct-uniq (fn (xs) (ct-uniq-loop xs (list))))
|
||||
|
||||
;; every element a string? / every row a list of strings? (for collection blocks)
|
||||
(define
|
||||
ct-all-str?
|
||||
(fn
|
||||
(xs)
|
||||
(if
|
||||
(= (len xs) 0)
|
||||
true
|
||||
(if (string? (first xs)) (ct-all-str? (rest xs)) false))))
|
||||
|
||||
(define
|
||||
ct-all-rows?
|
||||
(fn
|
||||
(rows)
|
||||
(if
|
||||
(= (len rows) 0)
|
||||
true
|
||||
(if
|
||||
(if (list? (first rows)) (ct-all-str? (first rows)) false)
|
||||
(ct-all-rows? (rest rows))
|
||||
false))))
|
||||
|
||||
;; ── tree flatten (descends into CtSection children; guards malformed children) ──
|
||||
(define
|
||||
ct-section-block?
|
||||
@@ -163,43 +136,30 @@
|
||||
"embed provider must be a string")))
|
||||
((= t "divider") (list))
|
||||
((= t "list")
|
||||
(let
|
||||
((items (blk-get b "items")))
|
||||
(append
|
||||
(ct-field-issue
|
||||
id
|
||||
(boolean? (blk-get b "ordered"))
|
||||
"list ordered must be a boolean")
|
||||
(append
|
||||
(ct-field-issue id (list? items) "list items must be a list")
|
||||
(ct-field-issue
|
||||
id
|
||||
(if (list? items) (ct-all-str? items) true)
|
||||
"list items must all be strings")))))
|
||||
(append
|
||||
(ct-field-issue
|
||||
id
|
||||
(boolean? (blk-get b "ordered"))
|
||||
"list ordered must be a boolean")
|
||||
(ct-field-issue
|
||||
id
|
||||
(list? (blk-get b "items"))
|
||||
"list items must be a list")))
|
||||
((= t "section")
|
||||
(ct-field-issue
|
||||
id
|
||||
(list? (blk-get b "children"))
|
||||
"section children must be a list"))
|
||||
((= t "table")
|
||||
(let
|
||||
((headers (blk-get b "headers")) (rows (blk-get b "rows")))
|
||||
(append
|
||||
(append
|
||||
(ct-field-issue
|
||||
id
|
||||
(list? headers)
|
||||
"table headers must be a list")
|
||||
(ct-field-issue
|
||||
id
|
||||
(if (list? headers) (ct-all-str? headers) true)
|
||||
"table headers must all be strings"))
|
||||
(append
|
||||
(ct-field-issue id (list? rows) "table rows must be a list")
|
||||
(ct-field-issue
|
||||
id
|
||||
(if (list? rows) (ct-all-rows? rows) true)
|
||||
"table rows must all be lists of strings")))))
|
||||
(append
|
||||
(ct-field-issue
|
||||
id
|
||||
(list? (blk-get b "headers"))
|
||||
"table headers must be a list")
|
||||
(ct-field-issue
|
||||
id
|
||||
(list? (blk-get b "rows"))
|
||||
"table rows must be a list")))
|
||||
((= t "callout")
|
||||
(append
|
||||
(ct-field-issue
|
||||
|
||||
68
lib/erlang/conformance.conf
Normal file
68
lib/erlang/conformance.conf
Normal file
@@ -0,0 +1,68 @@
|
||||
# Erlang-on-SX conformance config — sourced by lib/guest/conformance.sh.
|
||||
#
|
||||
# Erlang's suites load into one session and each exposes a pass counter and a
|
||||
# *count* (total) counter — not a fail counter. dict mode fits cleanly: each
|
||||
# runner is a dict literal computing :failed as count - pass. (counters mode
|
||||
# would misread the count counter as a fail counter.)
|
||||
|
||||
LANG_NAME=erlang
|
||||
MODE=dict
|
||||
|
||||
PRELOADS=(
|
||||
lib/erlang/tokenizer.sx
|
||||
lib/erlang/parser.sx
|
||||
lib/erlang/parser-core.sx
|
||||
lib/erlang/parser-expr.sx
|
||||
lib/erlang/parser-module.sx
|
||||
lib/erlang/transpile.sx
|
||||
lib/erlang/runtime.sx
|
||||
lib/erlang/vm/dispatcher.sx
|
||||
)
|
||||
|
||||
# name:file:(runner) — runner is a dict literal {:passed :failed :total}.
|
||||
SUITES=(
|
||||
"tokenize:lib/erlang/tests/tokenize.sx:{:passed er-test-pass :failed (- er-test-count er-test-pass) :total er-test-count}"
|
||||
"parse:lib/erlang/tests/parse.sx:{:passed er-parse-test-pass :failed (- er-parse-test-count er-parse-test-pass) :total er-parse-test-count}"
|
||||
"eval:lib/erlang/tests/eval.sx:{:passed er-eval-test-pass :failed (- er-eval-test-count er-eval-test-pass) :total er-eval-test-count}"
|
||||
"runtime:lib/erlang/tests/runtime.sx:{:passed er-rt-test-pass :failed (- er-rt-test-count er-rt-test-pass) :total er-rt-test-count}"
|
||||
"ring:lib/erlang/tests/programs/ring.sx:{:passed er-ring-test-pass :failed (- er-ring-test-count er-ring-test-pass) :total er-ring-test-count}"
|
||||
"ping-pong:lib/erlang/tests/programs/ping_pong.sx:{:passed er-pp-test-pass :failed (- er-pp-test-count er-pp-test-pass) :total er-pp-test-count}"
|
||||
"bank:lib/erlang/tests/programs/bank.sx:{:passed er-bank-test-pass :failed (- er-bank-test-count er-bank-test-pass) :total er-bank-test-count}"
|
||||
"echo:lib/erlang/tests/programs/echo.sx:{:passed er-echo-test-pass :failed (- er-echo-test-count er-echo-test-pass) :total er-echo-test-count}"
|
||||
"fib:lib/erlang/tests/programs/fib_server.sx:{:passed er-fib-test-pass :failed (- er-fib-test-count er-fib-test-pass) :total er-fib-test-count}"
|
||||
"ffi:lib/erlang/tests/ffi.sx:{:passed er-ffi-test-pass :failed (- er-ffi-test-count er-ffi-test-pass) :total er-ffi-test-count}"
|
||||
"vm:lib/erlang/tests/vm.sx:{:passed er-vm-test-pass :failed (- er-vm-test-count er-vm-test-pass) :total er-vm-test-count}"
|
||||
)
|
||||
|
||||
# Preserve the historical scoreboard schema so consumers of
|
||||
# lib/erlang/scoreboard.json keep working.
|
||||
emit_scoreboard_json() {
|
||||
local n=${#GC_NAMES[@]} i status
|
||||
printf '{\n'
|
||||
printf ' "language": "erlang",\n'
|
||||
printf ' "total_pass": %d,\n' "$GC_TOTAL_PASS"
|
||||
printf ' "total": %d,\n' "$GC_TOTAL"
|
||||
printf ' "suites": ['
|
||||
for ((i=0; i<n; i++)); do
|
||||
[ "$i" -gt 0 ] && printf ','
|
||||
status="ok"; [ "${GC_FAIL[$i]}" -gt 0 ] && status="fail"
|
||||
printf '\n {"name":"%s","pass":%d,"total":%d,"status":"%s"}' \
|
||||
"${GC_NAMES[$i]}" "${GC_PASS[$i]}" "${GC_TOTAL_S[$i]}" "$status"
|
||||
done
|
||||
printf '\n ]\n'
|
||||
printf '}\n'
|
||||
}
|
||||
|
||||
emit_scoreboard_md() {
|
||||
local n=${#GC_NAMES[@]} i marker
|
||||
printf '# Erlang-on-SX Scoreboard\n\n'
|
||||
printf '**Total: %d / %d tests passing**\n\n' "$GC_TOTAL_PASS" "$GC_TOTAL"
|
||||
printf '| | Suite | Pass | Total |\n'
|
||||
printf '|---|---|---|---|\n'
|
||||
for ((i=0; i<n; i++)); do
|
||||
marker="✅"; [ "${GC_FAIL[$i]}" -gt 0 ] && marker="❌"
|
||||
printf '| %s | %s | %d | %d |\n' \
|
||||
"$marker" "${GC_NAMES[$i]}" "${GC_PASS[$i]}" "${GC_TOTAL_S[$i]}"
|
||||
done
|
||||
printf '\nGenerated by `lib/erlang/conformance.sh`.\n'
|
||||
}
|
||||
@@ -1,162 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# Erlang-on-SX conformance runner.
|
||||
#
|
||||
# Loads every erlang test suite via the epoch protocol, collects
|
||||
# pass/fail counts, and writes lib/erlang/scoreboard.json + .md.
|
||||
#
|
||||
# Usage:
|
||||
# bash lib/erlang/conformance.sh # run all suites
|
||||
# bash lib/erlang/conformance.sh -v # verbose per-suite
|
||||
|
||||
set -uo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}"
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe"
|
||||
fi
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
echo "ERROR: sx_server.exe not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERBOSE="${1:-}"
|
||||
TMPFILE=$(mktemp)
|
||||
OUTFILE=$(mktemp)
|
||||
trap "rm -f $TMPFILE $OUTFILE" EXIT
|
||||
|
||||
# Each suite: name | counter pass | counter total
|
||||
SUITES=(
|
||||
"tokenize|er-test-pass|er-test-count"
|
||||
"parse|er-parse-test-pass|er-parse-test-count"
|
||||
"eval|er-eval-test-pass|er-eval-test-count"
|
||||
"runtime|er-rt-test-pass|er-rt-test-count"
|
||||
"ring|er-ring-test-pass|er-ring-test-count"
|
||||
"ping-pong|er-pp-test-pass|er-pp-test-count"
|
||||
"bank|er-bank-test-pass|er-bank-test-count"
|
||||
"echo|er-echo-test-pass|er-echo-test-count"
|
||||
"fib|er-fib-test-pass|er-fib-test-count"
|
||||
"ffi|er-ffi-test-pass|er-ffi-test-count"
|
||||
"vm|er-vm-test-pass|er-vm-test-count"
|
||||
)
|
||||
|
||||
cat > "$TMPFILE" << 'EPOCHS'
|
||||
(epoch 1)
|
||||
(load "lib/erlang/tokenizer.sx")
|
||||
(load "lib/erlang/parser.sx")
|
||||
(load "lib/erlang/parser-core.sx")
|
||||
(load "lib/erlang/parser-expr.sx")
|
||||
(load "lib/erlang/parser-module.sx")
|
||||
(load "lib/erlang/transpile.sx")
|
||||
(load "lib/erlang/runtime.sx")
|
||||
(load "lib/erlang/tests/tokenize.sx")
|
||||
(load "lib/erlang/tests/parse.sx")
|
||||
(load "lib/erlang/tests/eval.sx")
|
||||
(load "lib/erlang/tests/runtime.sx")
|
||||
(load "lib/erlang/tests/programs/ring.sx")
|
||||
(load "lib/erlang/tests/programs/ping_pong.sx")
|
||||
(load "lib/erlang/tests/programs/bank.sx")
|
||||
(load "lib/erlang/tests/programs/echo.sx")
|
||||
(load "lib/erlang/tests/programs/fib_server.sx")
|
||||
(load "lib/erlang/vm/dispatcher.sx")
|
||||
(load "lib/erlang/tests/ffi.sx")
|
||||
(load "lib/erlang/tests/vm.sx")
|
||||
(epoch 100)
|
||||
(eval "(list er-test-pass er-test-count)")
|
||||
(epoch 101)
|
||||
(eval "(list er-parse-test-pass er-parse-test-count)")
|
||||
(epoch 102)
|
||||
(eval "(list er-eval-test-pass er-eval-test-count)")
|
||||
(epoch 103)
|
||||
(eval "(list er-rt-test-pass er-rt-test-count)")
|
||||
(epoch 104)
|
||||
(eval "(list er-ring-test-pass er-ring-test-count)")
|
||||
(epoch 105)
|
||||
(eval "(list er-pp-test-pass er-pp-test-count)")
|
||||
(epoch 106)
|
||||
(eval "(list er-bank-test-pass er-bank-test-count)")
|
||||
(epoch 107)
|
||||
(eval "(list er-echo-test-pass er-echo-test-count)")
|
||||
(epoch 108)
|
||||
(eval "(list er-fib-test-pass er-fib-test-count)")
|
||||
(epoch 109)
|
||||
(eval "(list er-ffi-test-pass er-ffi-test-count)")
|
||||
(epoch 110)
|
||||
(eval "(list er-vm-test-pass er-vm-test-count)")
|
||||
EPOCHS
|
||||
|
||||
timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1
|
||||
|
||||
# Parse "(N M)" from the line after each "(ok-len <epoch> ...)" marker.
|
||||
parse_pair() {
|
||||
local epoch="$1"
|
||||
local line
|
||||
line=$(grep -A1 "^(ok-len $epoch " "$OUTFILE" | tail -1)
|
||||
echo "$line" | sed -E 's/[()]//g'
|
||||
}
|
||||
|
||||
TOTAL_PASS=0
|
||||
TOTAL_COUNT=0
|
||||
JSON_SUITES=""
|
||||
MD_ROWS=""
|
||||
|
||||
idx=0
|
||||
for entry in "${SUITES[@]}"; do
|
||||
name="${entry%%|*}"
|
||||
epoch=$((100 + idx))
|
||||
pair=$(parse_pair "$epoch")
|
||||
pass=$(echo "$pair" | awk '{print $1}')
|
||||
count=$(echo "$pair" | awk '{print $2}')
|
||||
if [ -z "$pass" ] || [ -z "$count" ]; then
|
||||
pass=0
|
||||
count=0
|
||||
fi
|
||||
TOTAL_PASS=$((TOTAL_PASS + pass))
|
||||
TOTAL_COUNT=$((TOTAL_COUNT + count))
|
||||
status="ok"
|
||||
marker="✅"
|
||||
if [ "$pass" != "$count" ]; then
|
||||
status="fail"
|
||||
marker="❌"
|
||||
fi
|
||||
if [ "$VERBOSE" = "-v" ]; then
|
||||
printf " %-12s %s/%s\n" "$name" "$pass" "$count"
|
||||
fi
|
||||
if [ -n "$JSON_SUITES" ]; then JSON_SUITES+=","; fi
|
||||
JSON_SUITES+=$'\n '
|
||||
JSON_SUITES+="{\"name\":\"$name\",\"pass\":$pass,\"total\":$count,\"status\":\"$status\"}"
|
||||
MD_ROWS+="| $marker | $name | $pass | $count |"$'\n'
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
|
||||
printf '\nErlang-on-SX conformance: %d / %d\n' "$TOTAL_PASS" "$TOTAL_COUNT"
|
||||
|
||||
# scoreboard.json
|
||||
cat > lib/erlang/scoreboard.json <<JSON
|
||||
{
|
||||
"language": "erlang",
|
||||
"total_pass": $TOTAL_PASS,
|
||||
"total": $TOTAL_COUNT,
|
||||
"suites": [$JSON_SUITES
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
# scoreboard.md
|
||||
cat > lib/erlang/scoreboard.md <<MD
|
||||
# Erlang-on-SX Scoreboard
|
||||
|
||||
**Total: ${TOTAL_PASS} / ${TOTAL_COUNT} tests passing**
|
||||
|
||||
| | Suite | Pass | Total |
|
||||
|---|---|---|---|
|
||||
$MD_ROWS
|
||||
|
||||
Generated by \`lib/erlang/conformance.sh\`.
|
||||
MD
|
||||
|
||||
if [ "$TOTAL_PASS" -eq "$TOTAL_COUNT" ]; then
|
||||
exit 0
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
# Thin wrapper — see lib/guest/conformance.sh and lib/erlang/conformance.conf.
|
||||
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"
|
||||
|
||||
@@ -16,5 +16,4 @@
|
||||
| ✅ | ffi | 37 | 37 |
|
||||
| ✅ | vm | 78 | 78 |
|
||||
|
||||
|
||||
Generated by `lib/erlang/conformance.sh`.
|
||||
|
||||
82
lib/feed/conformance.conf
Normal file
82
lib/feed/conformance.conf
Normal file
@@ -0,0 +1,82 @@
|
||||
# Feed-on-SX conformance config — sourced by lib/guest/conformance.sh.
|
||||
#
|
||||
# Every feed suite runs in a fresh session with the same preloads and a single
|
||||
# pass/fail counter pair — the canonical MODE=counters shape. The counters and
|
||||
# the feed-test helper (previously defined inline in the old conformance.sh) are
|
||||
# preloaded via lib/feed/test-harness.sx.
|
||||
|
||||
LANG_NAME=feed
|
||||
MODE=counters
|
||||
COUNTERS_PASS=feed-test-pass
|
||||
COUNTERS_FAIL=feed-test-fail
|
||||
TIMEOUT_PER_SUITE=300
|
||||
|
||||
PRELOADS=(
|
||||
spec/stdlib.sx
|
||||
lib/r7rs.sx
|
||||
lib/apl/runtime.sx
|
||||
lib/feed/normalize.sx
|
||||
lib/feed/stream.sx
|
||||
lib/feed/api.sx
|
||||
lib/feed/fanout.sx
|
||||
lib/feed/dedupe.sx
|
||||
lib/feed/aggregate.sx
|
||||
lib/feed/rank.sx
|
||||
lib/feed/acl.sx
|
||||
lib/feed/fed.sx
|
||||
lib/feed/content.sx
|
||||
lib/feed/notify.sx
|
||||
lib/feed/home.sx
|
||||
lib/feed/trending.sx
|
||||
lib/feed/mute.sx
|
||||
lib/feed/page.sx
|
||||
lib/feed/thread.sx
|
||||
lib/feed/test-harness.sx
|
||||
)
|
||||
|
||||
SUITES=(
|
||||
"basic:lib/feed/tests/basic.sx"
|
||||
"fanout:lib/feed/tests/fanout.sx"
|
||||
"rank:lib/feed/tests/rank.sx"
|
||||
"integration:lib/feed/tests/integration.sx"
|
||||
"content:lib/feed/tests/content.sx"
|
||||
"notify:lib/feed/tests/notify.sx"
|
||||
"home:lib/feed/tests/home.sx"
|
||||
"dedupe:lib/feed/tests/dedupe.sx"
|
||||
"trending:lib/feed/tests/trending.sx"
|
||||
"mute:lib/feed/tests/mute.sx"
|
||||
"page:lib/feed/tests/page.sx"
|
||||
"thread:lib/feed/tests/thread.sx"
|
||||
)
|
||||
|
||||
# Preserve the historical scoreboard schema so consumers of
|
||||
# lib/feed/scoreboard.json keep working.
|
||||
emit_scoreboard_json() {
|
||||
local n=${#GC_NAMES[@]} i
|
||||
printf '{\n'
|
||||
printf ' "suites": {\n'
|
||||
for ((i=0; i<n; i++)); do
|
||||
[ "$i" -gt 0 ] && printf ',\n'
|
||||
printf ' "%s": {"pass": %d, "fail": %d}' \
|
||||
"${GC_NAMES[$i]}" "${GC_PASS[$i]}" "${GC_FAIL[$i]}"
|
||||
done
|
||||
printf '\n },\n'
|
||||
printf ' "total_pass": %d,\n' "$GC_TOTAL_PASS"
|
||||
printf ' "total_fail": %d,\n' "$GC_TOTAL_FAIL"
|
||||
printf ' "total": %d\n' "$GC_TOTAL"
|
||||
printf '}\n'
|
||||
}
|
||||
|
||||
emit_scoreboard_md() {
|
||||
local n=${#GC_NAMES[@]} i p f
|
||||
printf '# feed Conformance Scoreboard\n\n'
|
||||
printf '_Generated by `lib/feed/conformance.sh`_\n\n'
|
||||
printf '| Suite | Pass | Fail | Total |\n'
|
||||
printf '|-------|-----:|-----:|------:|\n'
|
||||
for ((i=0; i<n; i++)); do
|
||||
p=${GC_PASS[$i]}; f=${GC_FAIL[$i]}
|
||||
printf '| %s | %d | %d | %d |\n' "${GC_NAMES[$i]}" "$p" "$f" "$((p+f))"
|
||||
done
|
||||
printf '| **Total** | **%d** | **%d** | **%d** |\n' \
|
||||
"$GC_TOTAL_PASS" "$GC_TOTAL_FAIL" "$GC_TOTAL"
|
||||
}
|
||||
@@ -1,125 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# lib/feed/conformance.sh — run feed test suites, emit scoreboard.json + scoreboard.md.
|
||||
|
||||
set -uo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
SX_SERVER="${SX_SERVER:-/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe}"
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
SX_SERVER="hosts/ocaml/_build/default/bin/sx_server.exe"
|
||||
fi
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
echo "ERROR: sx_server.exe not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SUITES=(basic fanout rank integration content notify home dedupe trending mute page thread)
|
||||
|
||||
OUT_JSON="lib/feed/scoreboard.json"
|
||||
OUT_MD="lib/feed/scoreboard.md"
|
||||
|
||||
run_suite() {
|
||||
local suite=$1
|
||||
local file="lib/feed/tests/${suite}.sx"
|
||||
local TMP
|
||||
TMP=$(mktemp)
|
||||
cat > "$TMP" << EPOCHS
|
||||
(epoch 1)
|
||||
(load "spec/stdlib.sx")
|
||||
(load "lib/r7rs.sx")
|
||||
(load "lib/apl/runtime.sx")
|
||||
(load "lib/feed/normalize.sx")
|
||||
(load "lib/feed/stream.sx")
|
||||
(load "lib/feed/api.sx")
|
||||
(load "lib/feed/fanout.sx")
|
||||
(load "lib/feed/dedupe.sx")
|
||||
(load "lib/feed/aggregate.sx")
|
||||
(load "lib/feed/rank.sx")
|
||||
(load "lib/feed/acl.sx")
|
||||
(load "lib/feed/fed.sx")
|
||||
(load "lib/feed/content.sx")
|
||||
(load "lib/feed/notify.sx")
|
||||
(load "lib/feed/home.sx")
|
||||
(load "lib/feed/trending.sx")
|
||||
(load "lib/feed/mute.sx")
|
||||
(load "lib/feed/page.sx")
|
||||
(load "lib/feed/thread.sx")
|
||||
(epoch 2)
|
||||
(eval "(define feed-test-pass 0)")
|
||||
(eval "(define feed-test-fail 0)")
|
||||
(eval "(define feed-test (fn (name got expected) (if (= got expected) (set! feed-test-pass (+ feed-test-pass 1)) (set! feed-test-fail (+ feed-test-fail 1)))))")
|
||||
(epoch 3)
|
||||
(load "${file}")
|
||||
(epoch 4)
|
||||
(eval "(list feed-test-pass feed-test-fail)")
|
||||
EPOCHS
|
||||
|
||||
local OUTPUT
|
||||
OUTPUT=$(timeout 300 "$SX_SERVER" < "$TMP" 2>/dev/null)
|
||||
rm -f "$TMP"
|
||||
|
||||
local LINE
|
||||
LINE=$(echo "$OUTPUT" | awk '/^\(ok-len 4 / {getline; print; exit}')
|
||||
if [ -z "$LINE" ]; then
|
||||
LINE=$(echo "$OUTPUT" | grep -E '^\(ok 4 \([0-9]+ [0-9]+\)\)' | tail -1 \
|
||||
| sed -E 's/^\(ok 4 //; s/\)$//')
|
||||
fi
|
||||
|
||||
local P F
|
||||
P=$(echo "$LINE" | sed -E 's/^\(([0-9]+) ([0-9]+)\).*/\1/')
|
||||
F=$(echo "$LINE" | sed -E 's/^\(([0-9]+) ([0-9]+)\).*/\2/')
|
||||
P=${P:-0}
|
||||
F=${F:-0}
|
||||
echo "${P} ${F}"
|
||||
}
|
||||
|
||||
declare -A SUITE_PASS
|
||||
declare -A SUITE_FAIL
|
||||
TOTAL_PASS=0
|
||||
TOTAL_FAIL=0
|
||||
|
||||
echo "Running feed conformance suite..." >&2
|
||||
for s in "${SUITES[@]}"; do
|
||||
read -r p f < <(run_suite "$s")
|
||||
SUITE_PASS[$s]=$p
|
||||
SUITE_FAIL[$s]=$f
|
||||
TOTAL_PASS=$((TOTAL_PASS + p))
|
||||
TOTAL_FAIL=$((TOTAL_FAIL + f))
|
||||
printf " %-12s %d/%d\n" "$s" "$p" "$((p+f))" >&2
|
||||
done
|
||||
|
||||
# scoreboard.json
|
||||
{
|
||||
printf '{\n'
|
||||
printf ' "suites": {\n'
|
||||
first=1
|
||||
for s in "${SUITES[@]}"; do
|
||||
if [ $first -eq 0 ]; then printf ',\n'; fi
|
||||
printf ' "%s": {"pass": %d, "fail": %d}' "$s" "${SUITE_PASS[$s]}" "${SUITE_FAIL[$s]}"
|
||||
first=0
|
||||
done
|
||||
printf '\n },\n'
|
||||
printf ' "total_pass": %d,\n' "$TOTAL_PASS"
|
||||
printf ' "total_fail": %d,\n' "$TOTAL_FAIL"
|
||||
printf ' "total": %d\n' "$((TOTAL_PASS + TOTAL_FAIL))"
|
||||
printf '}\n'
|
||||
} > "$OUT_JSON"
|
||||
|
||||
# scoreboard.md
|
||||
{
|
||||
printf '# feed Conformance Scoreboard\n\n'
|
||||
printf '_Generated by `lib/feed/conformance.sh`_\n\n'
|
||||
printf '| Suite | Pass | Fail | Total |\n'
|
||||
printf '|-------|-----:|-----:|------:|\n'
|
||||
for s in "${SUITES[@]}"; do
|
||||
p=${SUITE_PASS[$s]}
|
||||
f=${SUITE_FAIL[$s]}
|
||||
printf '| %s | %d | %d | %d |\n' "$s" "$p" "$f" "$((p+f))"
|
||||
done
|
||||
printf '| **Total** | **%d** | **%d** | **%d** |\n' "$TOTAL_PASS" "$TOTAL_FAIL" "$((TOTAL_PASS + TOTAL_FAIL))"
|
||||
} > "$OUT_MD"
|
||||
|
||||
echo "Wrote $OUT_JSON and $OUT_MD" >&2
|
||||
echo "Total: $TOTAL_PASS pass, $TOTAL_FAIL fail" >&2
|
||||
|
||||
[ "$TOTAL_FAIL" -eq 0 ]
|
||||
# Thin wrapper — see lib/guest/conformance.sh and lib/feed/conformance.conf.
|
||||
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"
|
||||
|
||||
14
lib/feed/test-harness.sx
Normal file
14
lib/feed/test-harness.sx
Normal file
@@ -0,0 +1,14 @@
|
||||
;; lib/feed/test-harness.sx — counter definitions for the feed conformance
|
||||
;; suites, lifted from the inline epoch-2 defs in the old conformance.sh so the
|
||||
;; shared driver (MODE=counters) can preload them before each suite.
|
||||
|
||||
(define feed-test-pass 0)
|
||||
(define feed-test-fail 0)
|
||||
(define
|
||||
feed-test
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! feed-test-pass (+ feed-test-pass 1))
|
||||
(set! feed-test-fail (+ feed-test-fail 1)))))
|
||||
65
lib/go/conformance.conf
Normal file
65
lib/go/conformance.conf
Normal file
@@ -0,0 +1,65 @@
|
||||
# Go-on-SX conformance config — sourced by lib/guest/conformance.sh.
|
||||
#
|
||||
# Like erlang: suites load into one session and each exposes a pass counter and
|
||||
# a *count* (total) counter, not a fail counter. dict mode fits — each runner is
|
||||
# a dict literal computing :failed as count - pass.
|
||||
|
||||
LANG_NAME=go
|
||||
MODE=dict
|
||||
|
||||
PRELOADS=(
|
||||
lib/guest/lex.sx
|
||||
lib/guest/ast.sx
|
||||
lib/guest/pratt.sx
|
||||
lib/go/lex.sx
|
||||
lib/go/parse.sx
|
||||
lib/go/types.sx
|
||||
lib/go/sched.sx
|
||||
lib/go/eval.sx
|
||||
lib/go/std/strings.sx
|
||||
lib/go/std/strconv.sx
|
||||
)
|
||||
|
||||
# name:file:(runner) — runner is a dict literal {:passed :failed :total}.
|
||||
SUITES=(
|
||||
"lex:lib/go/tests/lex.sx:{:passed go-test-pass :failed (- go-test-count go-test-pass) :total go-test-count}"
|
||||
"parse:lib/go/tests/parse.sx:{:passed go-parse-test-pass :failed (- go-parse-test-count go-parse-test-pass) :total go-parse-test-count}"
|
||||
"types:lib/go/tests/types.sx:{:passed go-types-test-pass :failed (- go-types-test-count go-types-test-pass) :total go-types-test-count}"
|
||||
"eval:lib/go/tests/eval.sx:{:passed go-eval-test-pass :failed (- go-eval-test-count go-eval-test-pass) :total go-eval-test-count}"
|
||||
"runtime:lib/go/tests/runtime.sx:{:passed go-rt-test-pass :failed (- go-rt-test-count go-rt-test-pass) :total go-rt-test-count}"
|
||||
"stdlib:lib/go/tests/stdlib.sx:{:passed go-std-test-pass :failed (- go-std-test-count go-std-test-pass) :total go-std-test-count}"
|
||||
"e2e:lib/go/tests/e2e.sx:{:passed go-e2e-test-pass :failed (- go-e2e-test-count go-e2e-test-pass) :total go-e2e-test-count}"
|
||||
)
|
||||
|
||||
# Preserve the historical scoreboard schema so consumers of
|
||||
# lib/go/scoreboard.json keep working.
|
||||
emit_scoreboard_json() {
|
||||
local n=${#GC_NAMES[@]} i status
|
||||
printf '{\n'
|
||||
printf ' "language": "go",\n'
|
||||
printf ' "total_pass": %d,\n' "$GC_TOTAL_PASS"
|
||||
printf ' "total": %d,\n' "$GC_TOTAL"
|
||||
printf ' "suites": ['
|
||||
for ((i=0; i<n; i++)); do
|
||||
[ "$i" -gt 0 ] && printf ','
|
||||
status="ok"; [ "${GC_FAIL[$i]}" -gt 0 ] && status="fail"
|
||||
printf '\n {"name":"%s","pass":%d,"total":%d,"status":"%s"}' \
|
||||
"${GC_NAMES[$i]}" "${GC_PASS[$i]}" "${GC_TOTAL_S[$i]}" "$status"
|
||||
done
|
||||
printf '\n ]\n'
|
||||
printf '}\n'
|
||||
}
|
||||
|
||||
emit_scoreboard_md() {
|
||||
local n=${#GC_NAMES[@]} i marker
|
||||
printf '# Go-on-SX Scoreboard\n\n'
|
||||
printf '**Total: %d / %d tests passing**\n\n' "$GC_TOTAL_PASS" "$GC_TOTAL"
|
||||
printf '| | Suite | Pass | Total |\n'
|
||||
printf '|---|---|---|---|\n'
|
||||
for ((i=0; i<n; i++)); do
|
||||
marker="✅"; [ "${GC_FAIL[$i]}" -gt 0 ] && marker="❌"
|
||||
printf '| %s | %s | %d | %d |\n' \
|
||||
"$marker" "${GC_NAMES[$i]}" "${GC_PASS[$i]}" "${GC_TOTAL_S[$i]}"
|
||||
done
|
||||
printf '\nGenerated by `lib/go/conformance.sh`.\n'
|
||||
}
|
||||
@@ -1,141 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# Go-on-SX conformance runner.
|
||||
#
|
||||
# Loads every Go-on-SX test suite via the epoch protocol, collects
|
||||
# pass/fail counts, and writes lib/go/scoreboard.json + .md.
|
||||
#
|
||||
# Usage:
|
||||
# bash lib/go/conformance.sh # run all suites
|
||||
# bash lib/go/conformance.sh -v # verbose per-suite
|
||||
|
||||
set -uo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}"
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe"
|
||||
fi
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
echo "ERROR: sx_server.exe not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERBOSE="${1:-}"
|
||||
TMPFILE=$(mktemp)
|
||||
OUTFILE=$(mktemp)
|
||||
trap "rm -f $TMPFILE $OUTFILE" EXIT
|
||||
|
||||
# Each suite: name | pass-counter | total-counter
|
||||
SUITES=(
|
||||
"lex|go-test-pass|go-test-count"
|
||||
"parse|go-parse-test-pass|go-parse-test-count"
|
||||
"types|go-types-test-pass|go-types-test-count"
|
||||
"eval|go-eval-test-pass|go-eval-test-count"
|
||||
"runtime|go-rt-test-pass|go-rt-test-count"
|
||||
"stdlib|go-std-test-pass|go-std-test-count"
|
||||
"e2e|go-e2e-test-pass|go-e2e-test-count"
|
||||
)
|
||||
|
||||
cat > "$TMPFILE" <<'EPOCHS'
|
||||
(epoch 1)
|
||||
(load "lib/guest/lex.sx")
|
||||
(load "lib/guest/ast.sx")
|
||||
(load "lib/guest/pratt.sx")
|
||||
(load "lib/go/lex.sx")
|
||||
(load "lib/go/parse.sx")
|
||||
(load "lib/go/types.sx")
|
||||
(load "lib/go/sched.sx")
|
||||
(load "lib/go/eval.sx")
|
||||
(load "lib/go/std/strings.sx")
|
||||
(load "lib/go/std/strconv.sx")
|
||||
(load "lib/go/tests/lex.sx")
|
||||
(load "lib/go/tests/parse.sx")
|
||||
(load "lib/go/tests/types.sx")
|
||||
(load "lib/go/tests/eval.sx")
|
||||
(load "lib/go/tests/runtime.sx")
|
||||
(load "lib/go/tests/stdlib.sx")
|
||||
(load "lib/go/tests/e2e.sx")
|
||||
EPOCHS
|
||||
|
||||
idx=0
|
||||
for entry in "${SUITES[@]}"; do
|
||||
name="${entry%%|*}"
|
||||
pass_var=$(echo "$entry" | awk -F'|' '{print $2}')
|
||||
total_var=$(echo "$entry" | awk -F'|' '{print $3}')
|
||||
epoch=$((100 + idx))
|
||||
echo "(epoch $epoch)" >> "$TMPFILE"
|
||||
echo "(eval \"(list $pass_var $total_var)\")" >> "$TMPFILE"
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
|
||||
"$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1
|
||||
|
||||
parse_pair() {
|
||||
local epoch="$1"
|
||||
local line
|
||||
line=$(grep -A1 "^(ok-len $epoch " "$OUTFILE" | tail -1)
|
||||
echo "$line" | sed -E 's/[()]//g'
|
||||
}
|
||||
|
||||
TOTAL_PASS=0
|
||||
TOTAL_COUNT=0
|
||||
JSON_SUITES=""
|
||||
MD_ROWS=""
|
||||
|
||||
idx=0
|
||||
for entry in "${SUITES[@]}"; do
|
||||
name="${entry%%|*}"
|
||||
epoch=$((100 + idx))
|
||||
pair=$(parse_pair "$epoch")
|
||||
pass=$(echo "$pair" | awk '{print $1}')
|
||||
count=$(echo "$pair" | awk '{print $2}')
|
||||
if [ -z "$pass" ] || [ -z "$count" ]; then
|
||||
pass=0
|
||||
count=0
|
||||
fi
|
||||
TOTAL_PASS=$((TOTAL_PASS + pass))
|
||||
TOTAL_COUNT=$((TOTAL_COUNT + count))
|
||||
status="ok"
|
||||
marker="✅"
|
||||
if [ "$pass" != "$count" ]; then
|
||||
status="fail"
|
||||
marker="❌"
|
||||
fi
|
||||
if [ "$VERBOSE" = "-v" ]; then
|
||||
printf " %-12s %s/%s\n" "$name" "$pass" "$count"
|
||||
fi
|
||||
if [ -n "$JSON_SUITES" ]; then JSON_SUITES+=","; fi
|
||||
JSON_SUITES+=$'\n '
|
||||
JSON_SUITES+="{\"name\":\"$name\",\"pass\":$pass,\"total\":$count,\"status\":\"$status\"}"
|
||||
MD_ROWS+="| $marker | $name | $pass | $count |"$'\n'
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
|
||||
printf '\nGo-on-SX conformance: %d / %d\n' "$TOTAL_PASS" "$TOTAL_COUNT"
|
||||
|
||||
cat > lib/go/scoreboard.json <<JSON
|
||||
{
|
||||
"language": "go",
|
||||
"total_pass": $TOTAL_PASS,
|
||||
"total": $TOTAL_COUNT,
|
||||
"suites": [$JSON_SUITES]
|
||||
}
|
||||
JSON
|
||||
|
||||
cat > lib/go/scoreboard.md <<MD
|
||||
# Go-on-SX Scoreboard
|
||||
|
||||
**Total: ${TOTAL_PASS} / ${TOTAL_COUNT} tests passing**
|
||||
|
||||
| | Suite | Pass | Total |
|
||||
|---|---|---|---|
|
||||
$MD_ROWS
|
||||
|
||||
Generated by \`lib/go/conformance.sh\`.
|
||||
MD
|
||||
|
||||
if [ "$TOTAL_PASS" -eq "$TOTAL_COUNT" ]; then
|
||||
exit 0
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
# Thin wrapper — see lib/guest/conformance.sh and lib/go/conformance.conf.
|
||||
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"
|
||||
|
||||
@@ -9,5 +9,6 @@
|
||||
{"name":"eval","pass":106,"total":106,"status":"ok"},
|
||||
{"name":"runtime","pass":40,"total":40,"status":"ok"},
|
||||
{"name":"stdlib","pass":41,"total":41,"status":"ok"},
|
||||
{"name":"e2e","pass":12,"total":12,"status":"ok"}]
|
||||
{"name":"e2e","pass":12,"total":12,"status":"ok"}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,5 +12,4 @@
|
||||
| ✅ | stdlib | 41 | 41 |
|
||||
| ✅ | e2e | 12 | 12 |
|
||||
|
||||
|
||||
Generated by `lib/go/conformance.sh`.
|
||||
|
||||
@@ -21,10 +21,17 @@
|
||||
# MODE=dict — "name:test-file:(runner-fn)"
|
||||
# The runner expression is evaluated and is expected to
|
||||
# return a dict with :passed/:failed/:total.
|
||||
# MODE=counters — "name:test-file"
|
||||
# MODE=counters — "name:test-file[:pass-var:fail-var[:extra-preload ...]]"
|
||||
# Each suite is run in a fresh sx_server session: preloads
|
||||
# are loaded, then the test file, then counters are read.
|
||||
# The suite is treated as starting from counters (0, 0).
|
||||
# Optional per-suite fields:
|
||||
# pass-var/fail-var — counter symbols for this suite,
|
||||
# overriding COUNTERS_PASS/COUNTERS_FAIL.
|
||||
# extra-preload ... — space-separated .sx files loaded
|
||||
# after the global PRELOADS (per-suite
|
||||
# dependency chains).
|
||||
# Plain "name:test-file" still works (uses the globals).
|
||||
#
|
||||
# Output:
|
||||
# Writes $SCOREBOARD_DIR/scoreboard.json and $SCOREBOARD_DIR/scoreboard.md.
|
||||
@@ -163,22 +170,39 @@ case "$MODE" in
|
||||
fi
|
||||
;;
|
||||
counters)
|
||||
if [ -z "$COUNTERS_PASS" ] || [ -z "$COUNTERS_FAIL" ]; then
|
||||
echo "MODE=counters requires COUNTERS_PASS and COUNTERS_FAIL in $CONF" >&2
|
||||
exit 2
|
||||
fi
|
||||
# Each suite must resolve to a pass/fail counter name — either per-suite
|
||||
# (fields 3 & 4 of the SUITES entry) or via the global COUNTERS_PASS /
|
||||
# COUNTERS_FAIL defaults. Validate up front so a misconfigured suite fails
|
||||
# loudly instead of silently recording a 0/1.
|
||||
for entry in "${SUITES[@]}"; do
|
||||
IFS=: read -r name file <<< "$entry"
|
||||
IFS=: read -r _sname _sfile _spass _sfail _spre <<< "$entry"
|
||||
if [ -z "${_spass:-$COUNTERS_PASS}" ] || [ -z "${_sfail:-$COUNTERS_FAIL}" ]; then
|
||||
echo "MODE=counters: suite '${_sname}' has no counter names and COUNTERS_PASS/COUNTERS_FAIL are unset in $CONF" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
for entry in "${SUITES[@]}"; do
|
||||
# Format: name:file[:pass-var:fail-var[:extra-preload ...]]
|
||||
# pass-var / fail-var — per-suite counter symbols (default: the global
|
||||
# COUNTERS_PASS / COUNTERS_FAIL).
|
||||
# extra-preload ... — space-separated .sx files loaded after the
|
||||
# global PRELOADS and before the test file. Lets
|
||||
# each suite bring its own dependency chain.
|
||||
IFS=: read -r name file spass sfail spre <<< "$entry"
|
||||
cpass="${spass:-$COUNTERS_PASS}"
|
||||
cfail="${sfail:-$COUNTERS_FAIL}"
|
||||
TMPFILE=$(mktemp)
|
||||
{
|
||||
printf '(epoch 1)\n'
|
||||
for f in "${PRELOADS[@]}"; do printf '(load "%s")\n' "$f"; done
|
||||
# shellcheck disable=SC2086 # deliberate word-split: per-suite preloads
|
||||
for f in $spre; do printf '(load "%s")\n' "$f"; done
|
||||
printf '(load "lib/guest/conformance.sx")\n'
|
||||
printf '(epoch 2)\n'
|
||||
printf '(load "%s")\n' "$file"
|
||||
printf '(epoch 3)\n'
|
||||
printf '(eval "(gc-counters-result \\"%s\\" 0 0 %s %s)")\n' \
|
||||
"$name" "$COUNTERS_PASS" "$COUNTERS_FAIL"
|
||||
"$name" "$cpass" "$cfail"
|
||||
} > "$TMPFILE"
|
||||
OUTPUT=$(timeout "$TIMEOUT_PER_SUITE" "$SX" < "$TMPFILE" 2>&1 || true)
|
||||
rm -f "$TMPFILE"
|
||||
|
||||
192
plans/agent-briefings/conformance-loop.md
Normal file
192
plans/agent-briefings/conformance-loop.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# A1 conformance-driver migration loop
|
||||
|
||||
Role: migrate every remaining subsystem that hand-rolls its own `conformance.sh`
|
||||
onto the **shared conformance driver** (`lib/guest/conformance.sh` + `lib/guest/conformance.sx`),
|
||||
one subsystem per iteration, **verifying test-count parity before every commit**.
|
||||
This executes item **A1** from the radar backlog (`plans/abstractions.md`, read-only
|
||||
context). You are an implementer, not a scout.
|
||||
|
||||
You are on branch `loops/conformance`, worktree `/root/rose-ash-loops/conformance`.
|
||||
|
||||
## Hard safety rails (read every time)
|
||||
|
||||
- **NEVER push to `main` or `architecture`.** Push only to `origin/loops/conformance`.
|
||||
- **NEVER `pkill`/`kill` `sx_server` or any shared process** — sibling loops share the
|
||||
binary. Bound every test run with `timeout` (e.g. `timeout 600 bash …`). If a run
|
||||
hangs, let the timeout end it; never kill globally.
|
||||
- **One subsystem per iteration, then stop.** No batching.
|
||||
- **Never commit a regression.** If post-migration test counts don't match the baseline
|
||||
(or an error appears), REVERT (`git checkout -- lib/<x>/conformance.sh` and
|
||||
`rm -f lib/<x>/conformance.conf`) and record the blocker — do not commit.
|
||||
- `.sx` files: use the `sx-tree` MCP tools, never Read/Write/Edit. `.sh`/`.conf`/`.md`
|
||||
files: normal tools are fine.
|
||||
- Preserve the `bash lib/<x>/conformance.sh` entry point (the shim keeps it working) so
|
||||
no other loop is disrupted.
|
||||
|
||||
## The candidate worklist
|
||||
|
||||
Remaining hand-rolled `conformance.sh` (from radar A1): **common-lisp, erlang, feed,
|
||||
forth, go, js, ocaml, smalltalk, tcl**. Already migrated (do not touch): acl, apl,
|
||||
datalog, haskell, mod, prolog. Already excluded (different harness): lua.
|
||||
|
||||
Work them roughly simplest-first. Track status in the checklist at the bottom.
|
||||
|
||||
## What "fits the driver" means — classify FIRST
|
||||
|
||||
The shared driver works for subsystems whose tests are **SX test-suites loaded over the
|
||||
epoch protocol** and run by an expression that emits a counter/dict scoreboard. It does
|
||||
NOT fit subsystems that run **foreign source programs** through a separate runner
|
||||
(e.g. lua walks `*.lua` via Python; smalltalk runs `*.st` via `test.sh`).
|
||||
|
||||
Per candidate, before migrating, decide:
|
||||
- **Migratable** — its `conformance.sh` epoch-loads SX preloads and evals SX test suites
|
||||
→ proceed to migrate.
|
||||
- **Excluded** — it shells out to a foreign program runner / scrapes a `test.sh` →
|
||||
DO NOT migrate. Record the exclusion (one line in the checklist + a `git`-free note in
|
||||
this briefing's Progress log) with the reason, and move on. Excluding is a valid,
|
||||
honest result — a forced migration that loses coverage is worse than none.
|
||||
|
||||
## Per-iteration procedure
|
||||
|
||||
1. **Pick** the next `[ ]` candidate in the checklist.
|
||||
2. **Read** its `lib/<x>/conformance.sh` in full. Read the two recipe templates —
|
||||
`lib/haskell/conformance.conf` (MODE=counters) and `lib/prolog/conformance.conf`
|
||||
(MODE=dict) — and skim `lib/guest/conformance.sh` + `lib/guest/conformance.sx`.
|
||||
3. **Classify** (above). If Excluded → record reason, tick as excluded, stop.
|
||||
4. **Baseline:** `timeout 600 bash lib/<x>/conformance.sh`, then read
|
||||
`lib/<x>/scoreboard.json` and record the pass/total. This is the parity target.
|
||||
5. **Author `lib/<x>/conformance.conf`:**
|
||||
- `LANG_NAME=<x>`
|
||||
- `MODE=dict` or `MODE=counters` (match how the old script counted)
|
||||
- `PRELOADS=( … )` — the lib files in load order, lifted from the old script
|
||||
- `SUITES=( "name:lib/<x>/tests/<file>:(<run-expr>)" … )` — one per suite, with the
|
||||
exact run expression the old script used
|
||||
- If counters mode needs counter definitions, add a small `test-harness.sx` preload
|
||||
(author it with `sx_write_file`).
|
||||
6. **Replace `lib/<x>/conformance.sh`** with the 3-line shim:
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# Thin wrapper — see lib/guest/conformance.sh and lib/<x>/conformance.conf.
|
||||
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"
|
||||
```
|
||||
7. **Verify parity:** `timeout 600 bash lib/<x>/conformance.sh` again. Read
|
||||
`scoreboard.json`. The pass/total MUST equal the baseline (a *higher* count is only
|
||||
acceptable if you can explain it — e.g. the old extractor under-counted, as happened
|
||||
with apl's `pipeline`; document it in the commit). Any mismatch/error → **revert**
|
||||
(step: rails) and record the blocker.
|
||||
8. **Commit** on `loops/conformance`:
|
||||
`conformance: migrate <x> onto shared driver (<mode>, <pass>/<total> parity)`
|
||||
then `git push origin loops/conformance`.
|
||||
9. **Update** this file: tick the checklist box and add one dated line to the Progress
|
||||
log (newest first). Then stop.
|
||||
|
||||
If a candidate is genuinely blocked (driver lacks a needed mode/feature), record it under
|
||||
Blocked with specifics and move to the next candidate next iteration.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] common-lisp — migrated 487/487 (counters; driver extended for per-suite counters+preloads)
|
||||
- [x] erlang — migrated 761/761 (dict; pass/count → :failed = count-pass)
|
||||
- [x] feed — migrated 189/189 (counters; test-harness.sx preload for counters+helper)
|
||||
- [~] forth — excluded: foreign Forth corpus (Hayes core.fr) via awk+python preprocessing
|
||||
- [x] go — migrated 609/609 (dict; pass/count → :failed = count-pass, like erlang)
|
||||
- [~] js — excluded: foreign test262 .js fixtures vs .expected files (python escape, substring match)
|
||||
- [~] ocaml — excluded: scrapes lib/ocaml/test.sh (per-assertion epoch runner) + foreign .ml baseline
|
||||
- [~] smalltalk — excluded: scrapes lib/smalltalk/test.sh + walks foreign *.st corpus (per briefing)
|
||||
- [~] tcl — excluded: foreign *.tcl programs vs `# expected:` annotations (python escape, bash compare)
|
||||
|
||||
(Mark `[x] <x> — migrated N/N` or `[~] <x> — excluded: <reason>` or
|
||||
`[!] <x> — blocked: <reason>`.)
|
||||
|
||||
## Progress log (newest first)
|
||||
|
||||
- 2026-06-07 — tcl: EXCLUDED (foreign-runner, like lua/js/forth) — and WORKLIST COMPLETE.
|
||||
conformance.sh walks foreign lib/tcl/tests/programs/*.tcl files, reads each first line's
|
||||
`# expected: VALUE` annotation, uses python3 to escape the Tcl source into an SX helper,
|
||||
evaluates via (tcl-eval-string …), and string-compares got vs expected in bash. No SX
|
||||
test suites, no SX counter/dict scoreboard — the driver can't drive a
|
||||
foreign-program-vs-expected-annotation harness. Left conformance.sh untouched. Not migrated.
|
||||
>>> A1 worklist now fully classified: 4 migrated (common-lisp, erlang, feed, go),
|
||||
5 excluded as foreign runners (forth, js, ocaml, smalltalk, tcl). Loop done.
|
||||
- 2026-06-07 — smalltalk: EXCLUDED (the briefing's own classification example —
|
||||
"smalltalk runs *.st via test.sh"). conformance.sh catalogs foreign
|
||||
lib/smalltalk/tests/programs/*.st programs, runs `bash lib/smalltalk/test.sh -v`, and
|
||||
scrapes its output (final "OK 403/403" summary + per-file pass counts via awk). It loads
|
||||
no SX test suites directly and emits no SX counter/dict scoreboard — the bash layer
|
||||
derives all numbers by text-scraping test.sh. Same "scrapes a test.sh" exclusion as
|
||||
ocaml/lua. Left conformance.sh untouched. Not migrated.
|
||||
- 2026-06-07 — ocaml: EXCLUDED (scrapes a test.sh — the briefing's named exclusion
|
||||
criterion). conformance.sh runs `bash lib/ocaml/test.sh -v`, scrapes its human-readable
|
||||
ok/FAIL lines, and re-classifies each test into suites via bash description-matching
|
||||
heuristics; it also scrapes `lib/ocaml/baseline/run.sh` (foreign .ml programs). The
|
||||
underlying test.sh is a per-assertion epoch runner — hundreds of individual
|
||||
(ocaml-test-...) evals, one epoch each, with NO suite-level counter variables or dict
|
||||
runners — so there's nothing the driver's counter/dict-scoreboard model can point at
|
||||
without a full rewrite of the test harness. test.sh's own header notes it "Mirrors
|
||||
lib/lua/test.sh" (the canonical excluded case). Left conformance.sh untouched. Not migrated.
|
||||
- 2026-06-07 — js: EXCLUDED (foreign-runner, like lua/forth/smalltalk). conformance.sh
|
||||
walks lib/js/test262-slice/**/*.js (foreign test262 fixtures), reads each .js + its
|
||||
sibling .expected file, escapes the JS source with python3, evaluates via (js-eval),
|
||||
and compares output to .expected by substring match — counting pass/fail in bash against
|
||||
a ≥50% target. It loads no SX test suites and emits no SX counter/dict scoreboard (no
|
||||
scoreboard.json at all). The shared driver only epoch-loads SX preloads + evals SX test
|
||||
suites; it can't drive a foreign-fixture-vs-expected comparison harness. Left
|
||||
conformance.sh untouched. Not migrated.
|
||||
- 2026-06-07 — go: migrated to `MODE=dict`, 609/609 exact parity (lex 129, parse 179,
|
||||
types 102, eval 106, runtime 40, stdlib 41, e2e 12). Same shape as erlang — one-session
|
||||
load, per-suite pass + *count* (total) counters — so each suite's dict-literal runner
|
||||
computes `:failed (- count pass)`. No driver change; conformance.conf + shim only.
|
||||
Kept historical scoreboard schema (language/total_pass/total/suites[name,pass,total,status]).
|
||||
- 2026-06-07 — forth: EXCLUDED (foreign-runner, like lua/smalltalk). Its conformance.sh
|
||||
reads a foreign Forth corpus (lib/forth/ans-tests/core.fr, the gerryjackson Hayes Core
|
||||
suite), preprocesses it with awk (strip `\` / `( )` comments + TESTING lines), splits it
|
||||
into `}T` chunks via an external python3 script that generates a chunks.sx of raw source
|
||||
strings, then runs them through the interpreter via (hayes-run-all) → {:pass :fail :error
|
||||
:total}. The shared driver only epoch-loads SX preloads + evals SX test suites; it can't
|
||||
reproduce the awk+python preprocessing of a foreign .fr corpus. No SX `tests/*.sx` suites
|
||||
exist to point the driver at. Left conformance.sh untouched. Not migrated.
|
||||
- 2026-06-07 — feed: migrated to `MODE=counters`, 189/189 exact parity (basic 30,
|
||||
fanout 29, rank 24, integration 22, content 15, notify 8, home 6, dedupe 9, trending 11,
|
||||
mute 9, page 14, thread 12). Canonical counters shape: fresh session per suite, shared
|
||||
preloads, single feed-test-pass/feed-test-fail pair. Lifted the old script's inline
|
||||
epoch-2 counter+helper defs into lib/feed/test-harness.sx (preloaded last). No driver
|
||||
change — only conformance.conf + test-harness.sx + shim. Kept historical scoreboard
|
||||
schema (suites{name:{pass,fail}}, total_pass/total_fail/total).
|
||||
- 2026-06-07 — erlang: migrated to `MODE=dict`, 761/761 exact parity (tokenize 62,
|
||||
parse 52, eval 408, runtime 93, ring 4, ping-pong 4, bank 8, echo 7, fib 8, ffi 37,
|
||||
vm 78). Erlang exposes pass + *count* (total) counters, not pass/fail, so each suite's
|
||||
dict-literal runner computes `:failed (- count pass)`. Loads in one session (matches
|
||||
dict mode), so no driver change needed — only conformance.conf + shim. Kept historical
|
||||
scoreboard schema (language/total_pass/total/suites[name,pass,total,status]).
|
||||
- 2026-06-07 — common-lisp: UNBLOCKED + migrated. Extended the shared driver's
|
||||
`MODE=counters` (lib/guest/conformance.sh) with a backward-compatible SUITES format
|
||||
`name:file[:pass-var:fail-var[:extra-preload ...]]` — optional per-suite counter
|
||||
symbols and per-suite preload chains. Authored lib/common-lisp/conformance.conf (12
|
||||
suites, 8 distinct counter pairs, per-suite preloads, base PRELOADS=stdlib+prefix;
|
||||
kept historical scoreboard schema) and replaced conformance.sh with the shim.
|
||||
Result 487/487 (0 fail) — HIGHER than the 305/0 baseline, explained: the old script's
|
||||
per-suite `timeout 30` was too tight for the slow `eval` suite (~15–25s under
|
||||
contention), silently recording it as 0; the driver's 180s budget recovers its true
|
||||
182. geometry/mop-trace remain 0/0 (pre-existing `refl-class-chain-depth-with` load
|
||||
error; counter vars defined as 0 → clean gc-result, no fail-fallback). Regression:
|
||||
haskell backward-compat path verified (fib/sieve/quicksort 2/2/5, matches committed).
|
||||
- 2026-06-07 — common-lisp: classified migratable-in-kind (SX suites over epoch) but
|
||||
BLOCKED on driver feature gaps. Baseline `bash lib/common-lisp/conformance.sh` =
|
||||
305 passed / 0 failed across 12 suites (3 — evaluator/geometry/mop-trace — already
|
||||
emit 0/0, a pre-existing extraction quirk). Not a foreign runner, so not Excluded.
|
||||
Did NOT migrate (parity unachievable under current modes); left conformance.sh
|
||||
untouched. See Blocked. Driver left unchanged (out of strict per-iteration scope).
|
||||
|
||||
## Blocked
|
||||
|
||||
- (none)
|
||||
|
||||
## Resolved blockers
|
||||
|
||||
- **common-lisp** (resolved 2026-06-07) — needed per-suite counter names + per-suite
|
||||
preload chains, unsupported by the original `MODE=counters` (single global counter +
|
||||
fixed PRELOADS). Resolved by extending the shared driver: `MODE=counters` now accepts
|
||||
`name:file[:pass-var:fail-var[:extra-preload ...]]` (backward-compatible). **This same
|
||||
extension is available to later candidates** — erlang/forth/etc. with per-suite
|
||||
counter names or preload chains can now migrate via the extended format instead of
|
||||
blocking.
|
||||
@@ -19,7 +19,7 @@ injected adapter, not core.
|
||||
|
||||
## Status (rolling)
|
||||
|
||||
`bash lib/content/conformance.sh` → **861/861** (Phases 1–4 COMPLETE + ~34 extensions, hardened: HTML/SX escaping, Markdown render + import/export incl. tables & frontmatter (full round-trip), CvRDT flat + nested-tree + durable replication, tree-aware validation, snapshot cache, doc metadata, plain-text render, nested block trees + deep editing + flatten + relative reorder, doc stats + summary + multi-doc index, table + callout + media blocks, HTML page wrapper + SEO page, doc composition + id-remap, portable data + wire serialization, block query + transforms + find/replace, TOC + anchored headings + outline, normalization)
|
||||
`bash lib/content/conformance.sh` → **746/746** (Phases 1–4 COMPLETE + ~34 extensions, hardened: HTML/SX escaping, Markdown render + import/export incl. tables & frontmatter (full round-trip), CvRDT flat + nested-tree + durable replication, tree-aware validation, snapshot cache, doc metadata, plain-text render, nested block trees + deep editing + flatten + relative reorder, doc stats + summary + multi-doc index, table + callout + media blocks, HTML page wrapper + SEO page, doc composition + id-remap, portable data + wire serialization, block query + transforms + find/replace, TOC + anchored headings + outline, normalization)
|
||||
|
||||
## Ground rules
|
||||
|
||||
@@ -106,229 +106,13 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─
|
||||
- [x] document outline (`outline.sx`: content/outline, nested heading tree)
|
||||
- [x] document flatten (`flatten.sx`: content/flatten, un-nest sections; inverse of wrap-section)
|
||||
- [x] relative reorder (`move.sx`: content/move-before/after/to-front/to-back by id)
|
||||
- [x] tree reparent (`move.sx`: content/move-into a section + content/promote out to top level; tree-wide, cycle-safe)
|
||||
- [x] document normalization (`normalize.sx`: content/normalize, drop empty blocks/sections)
|
||||
- [x] document sanitization (`sanitize.sx`: content/sanitize, drop invalid blocks tree-wide; validate's enforcement partner)
|
||||
- [x] global find/replace (`find-replace.sx`: content/find-replace across text-bearing blocks)
|
||||
- [x] portable data serialization (`data.sx`: content/to-data + from-data, round-trips tree)
|
||||
- [x] wire serialization (`wire.sx`: content/to-wire + from-wire, SX-text on the wire)
|
||||
|
||||
## Phase 5 — rich inline text (structured runs)
|
||||
|
||||
Drives the rose-ash blog migration: lexical post bodies carry inline formatting
|
||||
(bold/italic/links) but `CtText` held one plain `text` string, so the canonical
|
||||
lexical→blocks conversion was lossy. Variant **(b)**: a `CtText`'s `text` may be
|
||||
EITHER a plain string (backward compat) OR a sequence of inline **runs**. Marks
|
||||
cover the lexical bitmask (bold=1 italic=2 strike=4 underline=8 code=16 sub=32
|
||||
sup=64) plus link nodes (which carry an href). Applies to `CtText` and its
|
||||
subclasses `CtHeading`/`CtQuote` (rich), and `CtCode` (verbatim — runs render as
|
||||
plain concatenated text, no marks).
|
||||
|
||||
**Run representation — a Smalltalk-renderable list, not a `{:text :marks}` dict.**
|
||||
A run is `(text marks href)`: `text` a string, `marks` a list of mark tokens
|
||||
(`:bold :italic :underline :strikethrough :code :subscript :superscript :link` —
|
||||
SX keywords evaluate to the strings the renderer matches), `href` a string (`""`
|
||||
when absent; carries the link target). *Why a list and not the dict the brief
|
||||
sketched:* rendering must happen inside the Smalltalk render methods (nested
|
||||
blocks dispatch `asHTML`/etc. through Smalltalk message sends), and the
|
||||
Smalltalk-on-SX layer can iterate SX lists (`do:`/`inject:into:`) but **cannot**
|
||||
read SX dict fields (`Dictionary>>at:` is broken in lib/smalltalk, which is out
|
||||
of scope). Lists are Smalltalk-native, render under nesting, and round-trip
|
||||
through data/wire for free (they're just nested lists+strings). The blog-side
|
||||
lexical→runs converter targets this `(text marks href)` shape.
|
||||
|
||||
Centralised in `runs.sx` (`content-bootstrap-runs!`) which OVERRIDES the
|
||||
render/markdown/text methods of CtText/CtHeading/CtQuote/CtCode with run-aware
|
||||
versions that fall through to identical output for plain strings — so it is
|
||||
opt-in (the blog enables it) and the existing suites, which don't bootstrap it,
|
||||
are untouched.
|
||||
|
||||
- [x] runs render in all four modes — asHTML `<strong>/<em>/<u>/<s>/<code>/<sub>/<sup>/<a href>`, asMarkdown `**`/`_`/`~~`/`` ` ``/`[..](..)` (u/sub/sup fall back to inline HTML), asSx emits nested run structure (`(p (strong "x") " y")`), asText returns the PLAIN concatenation (keeps search/stats/find-replace drift-proof)
|
||||
- [x] backward compat — a plain-string CtText still renders identically; existing suites stay green
|
||||
- [x] find-replace rewrites text across runs (per-run, marks preserved); runs join the text-bearing-field dispatch
|
||||
- [x] search-text finds substrings via asText, including across run boundaries
|
||||
- [x] CRDT invariant preserved — merge stays at BLOCK granularity (runs are the block's value): ops in any order / twice → identical document
|
||||
- [x] data + wire serialization round-trip runs losslessly
|
||||
|
||||
### Future — Phase 6 (NOT in scope now)
|
||||
|
||||
Variant **(c)**: character/run-level concurrent inline CRDT (Peritext/Yjs-style)
|
||||
so two authors can edit the same paragraph simultaneously — needed later for the
|
||||
multi-author SX editor that replaces Ghost. Block-granularity (b) is sufficient
|
||||
for the blog read-path migration. The lexical→runs converter itself lives on the
|
||||
blog/migration side (mark-set reference: `blog/bp/blog/ghost/lexical_to_sx.py`),
|
||||
not in lib/content.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **Markdown table cells containing `|` do not round-trip.** `asMarkdown` on a
|
||||
table emits cell text raw (table.sx `CtTable>>asMarkdown:`), so a cell `x|y`
|
||||
renders the row `| x|y | z |` — which `md/import` then splits into *three*
|
||||
cells (`md-import.sx` `md/-cells` splits on every `|`). Repro: build
|
||||
`(mk-table "t" (list "A" "B") (list (list "x|y" "z")))`, `asMarkdown` →
|
||||
re-`md/import` → cells become `("x" "y" "z")`. Same applies to a literal `|`
|
||||
in a header. (HTML/SX/text/data/wire/CRDT round-trips are unaffected — only
|
||||
the Markdown text boundary.)
|
||||
*Fix sketch* (when sx-tree edit tooling is restored — see below): add
|
||||
`String>>mdCellEscaped` (escape `|` → `\|`) in table.sx and use it for every
|
||||
header/cell in `CtTable>>asMarkdown:`; in md-import.sx replace `md/-cells`'
|
||||
naive `(split … "|")` with an escaped-aware splitter that breaks only on
|
||||
unescaped `|` and unescapes `\|` → `|`. Both sides must change together
|
||||
(export-only escaping makes self-round-trip worse, not better).
|
||||
*Blocker:* in this worktree every sx-tree **edit** tool (`sx_replace_node`,
|
||||
`sx_replace_by_pattern`, `sx_insert_near`, …) raises yojson `"Expected
|
||||
string, got null"`; only `sx_write_file` works. md-import.sx is 449 lines, so
|
||||
a safe surgical edit isn't currently possible — deferred rather than risk a
|
||||
full manual rewrite of working import code.
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-06-07 — **Phase 5 — rich inline text (structured runs) DONE.** A CtText's
|
||||
`text` may now be a list of inline runs `(text marks href)` instead of a plain
|
||||
string; CtHeading/CtQuote inherit rich rendering, CtCode renders runs as plain
|
||||
verbatim text. New `runs.sx` (`content-bootstrap-runs!`) overrides the
|
||||
render/markdown/text methods of CtText & subclasses with run-aware versions
|
||||
that are byte-identical for plain-string bodies (opt-in; existing suites
|
||||
untouched). All 4 modes: asHTML emits `<strong>/<em>/<u>/<s>/<code>/<sub>/<sup>`
|
||||
+ `<a href>`, asMarkdown emits `**`/`_`/`~~`/`` ` ``/`[..](..)` (u/sub/sup →
|
||||
inline HTML), asSx emits nested run structure `(p "a" (strong "b"))` (matches
|
||||
the SX editor's wire format), asText returns the PLAIN concatenation — so
|
||||
search-text/stats/find-replace stay drift-proof. find-replace (`fr-rep-text`)
|
||||
rewrites per run with marks preserved; search-text finds across run boundaries
|
||||
via asText; CRDT merge treats the runs list as one block-level LWW value
|
||||
(commutes/idempotent, verified); data + wire round-trip runs losslessly.
|
||||
**Design note:** runs are a Smalltalk-renderable LIST, not the brief's
|
||||
`{:text :marks}` dict — the Smalltalk-on-SX render methods (which must run
|
||||
under nested dispatch) can iterate SX lists but cannot read SX dict fields
|
||||
(`Dictionary>>at:` is broken in lib/smalltalk, out of scope). Marks are built
|
||||
from `:bold`-style keywords (which evaluate to the strings the renderer
|
||||
matches). Phase 6 (char-level concurrent inline CRDT) recorded as future, not
|
||||
built. +36 runs tests (44 suites). 861/861.
|
||||
|
||||
- 2026-06-07 — Feature: `content/block-path` + `content/block-depth`
|
||||
(block-path.sx, new suite). The read-side companion to doc-find-deep (locate
|
||||
the block) and move-into/promote (relocate it): returns the ancestor-section
|
||||
id chain (root-first) for a block id — where it sits in the tree — or nil if
|
||||
absent (distinct from the `()` path of a present top-level block). block-depth
|
||||
is the path length (0 top-level, -1 absent). For breadcrumbs and scoping an
|
||||
edit to a block's enclosing section; distinct from toc/outline (which work on
|
||||
headings). Pure traversal. Also ran an adversarial probe this pass: confirmed
|
||||
clone/remap-ids + prefix-ids are tree-wide, and all 12 block types + CtDoc have
|
||||
asMarkdown: methods (no missing-render-method bug). +13 tests. 825/825 (43
|
||||
suites).
|
||||
|
||||
- 2026-06-07 — Feature: tree reparent in move.sx. Until now insert/move were
|
||||
positional and top-level only, so a block could never be moved *into* a section
|
||||
or *out* of one — a real gap for editing nested documents. Added
|
||||
`content/move-into doc id section-id i` (relocate a block, from anywhere in the
|
||||
tree, to be a child of a section at index i) and `content/promote doc id`
|
||||
(lift a nested block out to the end of the top level; a moved section keeps its
|
||||
whole subtree). Both are pure tree transforms (consistent with the existing
|
||||
move family — not new op-log ops) built on doc-find-deep / ct-find-id /
|
||||
ct-remove-id / ct-replace-id. **Cycle-safe**: move-into no-ops when target is
|
||||
the block itself or sits inside the block's own subtree, so a section can never
|
||||
become its own ancestor. +13 move tests (into/promote/across-sections/empty-
|
||||
shell/whole-section-subtree/cycle-guard/missing-id no-ops). 812/812.
|
||||
|
||||
- 2026-06-07 — Feature: `content/sanitize` — the enforcement counterpart to
|
||||
`validate`. validate *reports* id/field issues; sanitize *removes* the
|
||||
offending blocks (tree-wide) so federated/imported input that failed
|
||||
validation can still be rendered/merged without faulting. Reuses validate's
|
||||
own per-block predicate (`content/-block-issues`) so "what is invalid" stays
|
||||
single-sourced and can't drift. Distinct from `normalize` (which drops *empty*
|
||||
blocks): a section emptied of invalid children is kept (sanitize removes
|
||||
invalid, not empty), but a section whose own shell is invalid (children not a
|
||||
list) is dropped whole. Scope is per-block id/field validity — it does not
|
||||
dedupe ids (cross-block, no single right answer). +12 tests (bad-field /
|
||||
unknown-type / blank-id dropped, deep pruning, invalid-shell section dropped,
|
||||
immutability, render-safe result). 799/799 (42 suites). (This was a genuine
|
||||
remaining gap — validate had no enforcement partner — not filler; saturation
|
||||
note below still holds for the roadmap proper.)
|
||||
|
||||
- 2026-06-07 — Audit (markdown round-trip): probed the Markdown text boundary
|
||||
for round-trip fidelity. Found one real data-corruption bug — table cells
|
||||
containing `|` don't survive `asMarkdown` → `md/import` (recorded under
|
||||
**Known limitations** with repro + fix sketch). Could not land the fix this
|
||||
pass: it must touch md-import.sx (449 lines) and every sx-tree *edit* tool is
|
||||
currently broken in this worktree (yojson error; only `sx_write_file` works),
|
||||
so a safe surgical edit isn't possible and a full manual rewrite of working
|
||||
import code is too risky to be responsible. Deferred + documented rather than
|
||||
half-fix (export-only escaping worsens self-round-trip). Engine remains
|
||||
COMPLETE + audited at 787/787; with the roadmap exhausted, the tree-wide
|
||||
audit done, and the one open finding tooling-blocked, the vertical is
|
||||
**SATURATED** — pacing the loop down.
|
||||
|
||||
- 2026-06-07 — Hardening: validation now vets collection blocks ELEMENT-DEEP.
|
||||
`validate` previously checked only that list `items` / table `headers`/`rows`
|
||||
*are lists* — a list holding a non-string, or a table whose rows aren't lists
|
||||
of strings, passed validation yet crashes asText/render/find-replace/search
|
||||
(which all assume string items/cells). Added `ct-all-str?`/`ct-all-rows?` and
|
||||
deepened the list/table branches (guarded so a non-list container reports only
|
||||
the is-a-list issue, not a spurious element issue). Since validate's job is
|
||||
guarding imports/federated input, this closes the boundary before the render
|
||||
layer can fault. +9 validate tests (list non-string item, table non-list row /
|
||||
non-string cell / non-string header, empties stay valid). 787/787.
|
||||
|
||||
- 2026-06-07 — Hardening (tree-wide audit): the public facade `content/find` /
|
||||
`content/has?` were top-level-only (`doc-find`/`doc-has?`), so you could
|
||||
`content/edit` an update/delete to a nested block by id (those ops are
|
||||
tree-wide) but couldn't read that same block back by id through the facade — a
|
||||
concrete read/write asymmetry. Added a generic `ct-find-id` to doc.sx (descends
|
||||
into any `children` list, mirroring ct-replace-id/ct-remove-id, no section.sx
|
||||
dependency) plus `doc-find-deep`/`doc-has-deep?`; `content/find`/`content/has?`
|
||||
now point at them. Kept `content/find-top`/`content/has-top?` for the
|
||||
top-level-only lookup. Audited all `doc-find`/`doc-ids`/`ct-index-of` callers:
|
||||
the remaining ones are insert/move (positional, top-level by design) — no other
|
||||
seams. +6 api tests (nested deep find/has, top variants miss nested,
|
||||
edit-then-find round-trip). 778/778.
|
||||
|
||||
- 2026-06-07 — Hardening: `content/diff` (and `content/diff-versions`) are now
|
||||
TREE-WIDE. They enumerated ids via `doc-ids`/`doc-find` (top-level only), so a
|
||||
diff between two versions of a document containing sections silently missed
|
||||
every nested-block add/remove/change — the same class of seam as the by-id
|
||||
op-log bug. Now ids come from `doc-tree-ids` and lookups from `doc-deep-find`,
|
||||
so nested changes surface precisely. Section containers are excluded from
|
||||
`:changed` (they hold no own content; a child change reports as that child),
|
||||
while whole-section add/remove still shows in `:added`/`:removed`. Flat-doc
|
||||
diffs are unchanged (deep == top-level with no sections). +9 store tests
|
||||
(nested add = section+child, nested change = child only, nested remove,
|
||||
no-op). 772/772.
|
||||
|
||||
- 2026-06-07 — Feature: in-document prose search. `content/search-text` (and
|
||||
`content/search-text-ids`) return every content block, tree-wide, whose
|
||||
`(asText b)` contains a term — so search spans text/heading/code/quote/callout
|
||||
text, image alt, list items and table cells **by construction**: it reuses the
|
||||
one canonical "prose of a block" projection (asText) rather than re-listing
|
||||
fields, so it can't drift from stats/find-replace. Section containers are
|
||||
excluded (a term living only in a section's children returns the child, not the
|
||||
wrapper). +7 query tests (cross-field match, count, single-field, no-match,
|
||||
section exclusion, object return). 763/763.
|
||||
|
||||
- 2026-06-07 — Consistency: `find-replace` now rewrites **every** text-bearing
|
||||
field, not just `text`. New `fr-rewrite` dispatches per block type — `alt` of
|
||||
image blocks, each item of list blocks, and every header/cell of table blocks
|
||||
now get rewritten alongside text/heading/code/quote/callout. This closes a real
|
||||
seam: `asText`/stats/word-count already fold image alt, list items, and table
|
||||
cells into a document's prose, so a `content/find-replace` rename that skipped
|
||||
them was inconsistent (a renamed term would still show up in word counts and
|
||||
exports). Flipped the two `image alt untouched` tests to `image alt replaced`;
|
||||
+4 tests (list items ×2, table header + cell). find-replace 16/16, 756/756.
|
||||
|
||||
- 2026-06-07 — Consistency: `find-replace` now covers `callout` text. `fr-has-text?`
|
||||
(find-replace.sx) added `callout` to its text-bearing block kinds, matching
|
||||
`asText`/stats/summary which already treat callout bodies as prose. Previously a
|
||||
`content/find-replace` over a doc containing callouts silently skipped them. +2
|
||||
find-replace tests (replace callout text; callout kind untouched by text replace).
|
||||
752/752 (41 suites).
|
||||
|
||||
- 2026-06-07 — Hardening: fixed a real layer seam (surfaced in the architecture
|
||||
review) — by-id ops (update/delete) now act TREE-WIDE. `ct-replace-id` /
|
||||
`ct-remove-id` (doc.sx) descend into any block carrying a `children` list, so
|
||||
the persist op-log and `content/edit` correctly reach blocks nested in
|
||||
sections (previously a silent no-op). `doc-move` stays top-level (guarded by
|
||||
doc-find); insert/move remain positional. Inline section detection (no
|
||||
section.sx dep). +4 store regression tests (nested update/delete via op-log +
|
||||
replay-to-seq). Full gate over foundational doc.sx: 750/750.
|
||||
- 2026-06-07 — Hardening: audit confirmed the persist op-log (store.sx) carries
|
||||
every block type through commit → replay (op-insert carries the block
|
||||
instance; updates apply by id). Locked with +4 store tests (callout/media
|
||||
|
||||
@@ -145,6 +145,44 @@ check** → tests → commit → tick box → Progress-log line → push.
|
||||
- **Acceptance:** curl test script green; WASM build untouched (prim not in lib).
|
||||
Satisfies fed-sx Step 8 transport.
|
||||
|
||||
### Phase J — HTTP/1.1 client, **native-only** (`bin/sx_server.ml`) ✅ DONE
|
||||
- Mirror of Phase H, inverse direction. TCP connect via `Unix.gethostbyname` +
|
||||
`Unix.socket`/`Unix.connect`. Write request line + headers + body, read
|
||||
response status line + headers + body (Content-Length first; chunked
|
||||
encoding optional v2 — flag as Blockers if a fed-sx need hits it).
|
||||
- Primitive `(http-request method url headers body) -> response-dict`
|
||||
registered ONLY in `bin/sx_server.ml`. Response dict shape:
|
||||
`{:status :headers :body}` (mirror of server's request dict). URL must be
|
||||
`http://...` for v1; HTTPS is a separate later phase (needs TLS lib).
|
||||
- Tests: `bin/test_http_client.sh` — start a tiny python HTTP server in a
|
||||
subprocess (or reuse Phase H's SX server), drive GET / POST / 404 /
|
||||
custom-header roundtrip via `(http-request ...)` from the epoch protocol,
|
||||
assert response dict shape + body, kill server.
|
||||
- **Acceptance:** test script green; WASM build untouched (prim not in lib);
|
||||
Erlang conformance unchanged. Unblocks Erlang Phase 8 `httpc:request/4` BIF
|
||||
wiring and fed-sx Milestone 2 federation `POST /inbox` outbound.
|
||||
|
||||
### Phase K — URL parser, pure OCaml, WASM-safe (`lib/sx_url.ml`)
|
||||
- `(url-parse "http://host:port/path?q=1") -> {:scheme :host :port :path :query}`
|
||||
— small recursive-descent parser. No external deps. Port is integer when
|
||||
present, absent key otherwise (or default per scheme: 80/443).
|
||||
- `(url-encode-component string) -> string` /
|
||||
`(url-decode-component string) -> string` — percent-encoding per RFC 3986
|
||||
(reserved/unreserved sets).
|
||||
- Tests: `bin/test_url.ml` — full URL, port-less, path-only, query string with
|
||||
multiple pairs, empty path, percent-encoding round-trips, malformed inputs
|
||||
(return error-shaped result, not exception).
|
||||
- **Acceptance:** WASM boot green (pure lib); supports fed-sx kernel actor URL
|
||||
parsing and Phase J HTTP-client url handling.
|
||||
|
||||
### Phase L — (open) further client prims as fed-sx kernel needs surface
|
||||
- Add new phases here as the kernel loop or design conversations identify
|
||||
needs: chunked HTTP transfer encoding, HTTPS / TLS verify (likely opam-dep
|
||||
Blockers), webfinger HTTP shape, DNS (probably folded into `http-request`).
|
||||
- Each new phase: define test vectors / contract → implement → WASM-check
|
||||
(skip for native-only) → commit → Progress log. Same iteration discipline as
|
||||
A–I.
|
||||
|
||||
### Phase I — handoff ✅ DONE
|
||||
- Flip the `plans/erlang-on-sx.md` Blockers entry "SX runtime lacks platform
|
||||
primitives …" to **RESOLVED**, listing the exact SX primitive names so the
|
||||
@@ -226,6 +264,20 @@ should leave `httpc`/`sqlite` BIFs blocked with that note.
|
||||
|
||||
_Newest first._
|
||||
|
||||
- 2026-05-26 — Phase J: `http-request` primitive in `bin/sx_server.ml`
|
||||
(NATIVE ONLY — `Unix.gethostbyname` + `Unix.connect`; HTTP/1.1 with
|
||||
inline `http://` URL parser; sends Connection: close + Host +
|
||||
Content-Length unless caller supplies them; reads status line +
|
||||
headers + body via Content-Length, falling back to read-to-EOF;
|
||||
Transfer-Encoding: chunked rejected with explicit error per plan).
|
||||
Test `bin/test_http_client.sh` spins up a Phase-H echo server in a
|
||||
background sx_server and drives a second sx_server with epoch
|
||||
`(eval …)` calls: GET+query, POST+body, 404, custom request
|
||||
header reflected back, non-http scheme rejected (error path),
|
||||
integer status — 6/6 pass. NOT in lib/ so WASM boot untouched
|
||||
(green); Erlang conformance 530/530 unchanged; run_tests
|
||||
unchanged. Unblocks Erlang Phase 8 `httpc:request/4` BIF wiring
|
||||
and fed-sx Milestone 2 federation `POST /inbox` outbound.
|
||||
- 2026-05-18 — Phase I: handoff. `erlang-on-sx.md` Blockers gained one
|
||||
RESOLVED entry (no "SX runtime lacks…" entry pre-existed; it read
|
||||
"_(none yet)_") mapping every delivered primitive → its Phase 8 BIF,
|
||||
|
||||
@@ -238,28 +238,41 @@ lib/mod/fed.sx
|
||||
`mod/dedup-reports` collapses identical reports (reporter|subject|reason key,
|
||||
case-insensitive); `mod/distinct-reporters-of` counts unique reporters.
|
||||
|
||||
## Shared-plumbing extraction — post-merge integration note
|
||||
## Shared-plumbing extraction — evaluated post-merge, DECLINED
|
||||
|
||||
mod-sx (Prolog) and acl-sx (Datalog, `lib/acl/`, 120/120) independently converged
|
||||
on the same module shape: `schema / engine / audit / explain / federation / api`.
|
||||
That parallel is the signal both plans flagged. **Recommendation: do NOT extract
|
||||
from a loop branch — extract at the architecture-merge integration point, after
|
||||
both `lib/mod` and `lib/acl` have landed, refactoring both consumers in one change.**
|
||||
Both layers now live on architecture; the extraction was evaluated by reading
|
||||
both implementations side by side. **Finding: do not extract — the convergence is
|
||||
in module *names* only, not implementations.** The engines and decision models
|
||||
genuinely differ, so a shared module would be premature abstraction that ages
|
||||
badly. (This reverses the pre-read note that listed audit + fed trust/outbox as
|
||||
candidates; reading the code showed they don't actually share.)
|
||||
|
||||
- **Different engines.** acl = Datalog bottom-up (native derivation trees); mod =
|
||||
Prolog backtracking (proof via per-goal `pl-query-all`). The engine and most of
|
||||
`explain` are NOT shared — same intent, different mechanism. Don't try to unify them.
|
||||
- **Genuinely convergent shapes (the only real candidates):**
|
||||
- **Append-only audit log** — `{seq, payload, retrieve-by-id}`; both have it (~40
|
||||
lines). Lift to e.g. `lib/guest/audit-log.sx` parameterized by the entry payload.
|
||||
- **Federation trust/outbox** — advisory-unless-`(trust peer :scope)` + a send
|
||||
seam; both have it. Lift the trust registry + outbox; keep `:scope` a parameter
|
||||
(`:mod` vs `:acl`).
|
||||
- **Trivia not worth a module:** `join-with`, `any?`, `str-contains?`, `distinct`.
|
||||
- **Why not now:** the branches merge independently; lifting from one leaves the
|
||||
other's copy un-refactored → duplication, not sharing. Real extraction must touch
|
||||
both consumers atomically, which only the post-merge integrator can do. Designing
|
||||
the abstraction also needs both payload shapes in view (only mod's is visible here).
|
||||
- **Federation — zero shared code.** mod gates trust in SX (a `{:peer :scope}`
|
||||
registry + `grant`/`revoke`/`trusted?`) and shares *decisions* (outbox,
|
||||
advisory/applied logs, `receive-decision`). acl gates trust *inside Datalog*
|
||||
(`trust(Peer,L)` / `level_covers` facts + an engine rule re-checked per query)
|
||||
and shares *facts* (`fetch`/`collect`/`build-db`, `assert!`/`retract!`). acl has
|
||||
no trust registry, no `trusted?`, no outbox. Opposite architectures — the only
|
||||
common token is the word "trust."
|
||||
- **Audit — only a ~5-fn core overlaps, and it diverges.** Entry shapes differ
|
||||
entirely (mod `{:action :rule :proof :evidence :report-id :seq}` vs acl
|
||||
`{:allowed? :act :subj :res :seq}`); seq base differs (acl 0, mod 1, both
|
||||
test-visible); op sets barely intersect (mod: by-`report-id` + `latest`; acl:
|
||||
`tail`/`snapshot`/`restore`/`serialize`); even the list idiom differs (acl
|
||||
`append!`+copy vs mod pure `append`+`set!`). A shared module would also have to
|
||||
satisfy two different restricted eval envs (prolog- vs datalog-loaded). Cost
|
||||
(shared module + refactor both + rewrite acl's serialize/snapshot onto a foreign
|
||||
core + cross-env risk + coupling two independent loops) far exceeds the benefit
|
||||
(dedup ~5 trivial lines that don't even agree on seq-base or mutation idiom).
|
||||
- **Engines + `explain`** were never shareable: Datalog yields derivation trees
|
||||
natively; mod reconstructs proofs via per-goal `pl-query-all`.
|
||||
- **Trivia** (`join-with`, `any?`, `str-contains?`, `distinct`) is one-liners, not
|
||||
worth a module.
|
||||
|
||||
**Outcome:** keep mod (Prolog) and acl (Datalog) as parallel independent
|
||||
implementations. The parallel structure is correct for two different engines; the
|
||||
shared abstraction is not. Revisit only if a third rule-engine consumer appears
|
||||
with the *same* trust/audit model (rule of three), not before.
|
||||
|
||||
## Progress log
|
||||
|
||||
|
||||
Reference in New Issue
Block a user