commit feaa52041dcd6ed0dfcb6bf1853df0da25d76d23
parent 94ddbef56c292a076d693d710eb4bdd618c96c3d
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Sat, 30 May 2026 16:54:00 -0700
test+asm: cross-compile + cross-exec host-assembler lane (test-hostas-cross)
Extend the host-assembler-by-execution idea (test-hostas-toy) from the native
target to ELF Linux cross targets. test/asm/hostas_cross.sh emits one
`cc -S -target <triple>` per toy case, assembles it with BOTH cfree-as and clang,
links each into a static non-PIE ELF with `cfree ld -static` + the freestanding
start.c crt (compiled -Dtest_main=main so _start calls the toy's main and exits
with its return), and runs under podman/qemu via test/lib/exec_target.sh (one
batched container per target). Exit must match the toy oracle; the assembler is
the only variable between lanes.
Each target self-skips (never fails) unless the host has a clang cross target, a
runner, a working `cc -S | cfree as` for that arch, and a passing *bounded* exec
smoke (so a wedged emulator downgrades to SKIP rather than hanging). Status:
- aarch64-linux: green end-to-end (cfree-as 312/0, clang-as 312/0) — podman runs
arm64 natively in its VM. This is the primary verified target.
- x86_64-linux: SKIPS on the pre-existing x64 `cc -S` symbolizer gap (numeric
branch targets the x64 `as` can't reassemble; branch-label synthesis +
reloc-operand syntax are aarch64-only).
- riscv64-linux: `cc -S | as | ld` works; SKIPS where rv64 user-mode emulation
is unavailable/too slow to pass the exec smoke.
Overridable via CFREE_HOSTAS_CROSS_TARGETS / CFREE_HOSTAS_EXEC_TIMEOUT /
RUN_{AARCH64,X64,RV64}_IMAGE. Opt-in `make test-hostas-cross`; added to
TEST_TARGETS. Also refreshed the now-stale test-hostas-toy comment (clang lane
gates by default since the format-aware cc -S landed) and documented the cross
lane in doc/ASM_ROUNDTRIP_TESTING.md.
Diffstat:
3 files changed, 343 insertions(+), 8 deletions(-)
diff --git a/doc/ASM_ROUNDTRIP_TESTING.md b/doc/ASM_ROUNDTRIP_TESTING.md
@@ -228,10 +228,45 @@ ELF-triple `roundtrip`/`diff-llvm` lanes are unaffected. The same
yet — x64/rv64 add their own `ArchAsmOps.reloc_operand`, COFF adds an `AsmSyntax`
impl, then this lane extends to them).
-Opt-in (`make test-hostas-toy`); skips cleanly when `clang` is absent. ELF
-cross-targets (`aarch64/x86_64/riscv64-linux-gnu`) already assemble cleanly with
-clang/llvm-mc and can extend this lane to podman/qemu cross-execution (à la
-`test/toy/run.sh` path X).
+Opt-in (`make test-hostas-toy`); skips cleanly when `clang` is absent.
+
+### Cross-compile + cross-exec lane (`test-hostas-cross`)
+
+`test/asm/hostas_cross.sh` is the cross extension of the host-assembler lane:
+the same two-assembler-by-execution test, but for ELF **Linux** targets
+(`aarch64`/`x86_64`/`riscv64-linux`) emitted with `cc -S -target <triple>`,
+assembled by BOTH cfree-as and clang, linked into a **static, non-PIE** ELF with
+`cfree ld -static`, and run under **podman/qemu** via the shared
+`test/lib/exec_target.sh` helper (one batched container per target). The
+executable is made runnable without a libc/loader by linking the freestanding
+crt `test/link/harness/start.c` compiled `-Dtest_main=main`: its `_start` runs
+ctors then calls the toy's `main` and exits with its return (the oracle) via a
+raw `exit_group` syscall.
+
+Each target **self-skips** (never fails) unless the host has (1) a clang cross
+target, (2) a runner (podman/qemu), (3) a working `cc -S | cfree as` round-trip
+for that arch, and (4) a passing **bounded** exec smoke (so a wedged emulator
+downgrades to SKIP instead of hanging). Status:
+
+- **aarch64-linux**: green end-to-end (cfree-as 312/0, clang-as 312/0) — podman
+ runs arm64 natively in its VM, so it's fast and the primary verified target.
+- **x86_64-linux**: SKIPS on the x64 `cc -S` symbolizer gap — x64 emits numeric
+ branch targets (`jmp 0x77`) the x64 `as` can't reassemble. The aarch64
+ symbolizer (intra-section branch-target label synthesis in
+ `src/api/asm_emit.c`, and the relocation-operand syntax via
+ `ArchAsmOps.reloc_operand`) needs x64 implementations — `is_local_branch`-style
+ recognition for `jmp`/`jcc`, plus an x64 `reloc_operand` table
+ (`sym(%rip)`/`@PLT`/`@GOTPCREL`). Tracked.
+- **riscv64-linux**: `cc -S | cfree as | cfree ld -static` works; SKIPS where
+ riscv64 user-mode emulation is unavailable or too slow/wedged to pass the exec
+ smoke (e.g. the macOS/arm64 dev host's podman riscv64 path hangs on the
+ cfree-built static ELF even though it runs a clang-built one — likely a
+ cfree-rv64-ELF-under-qemu-user issue to chase separately).
+
+Override the matrix with `CFREE_HOSTAS_CROSS_TARGETS="tag:triple ..."`, the
+exec-smoke cap with `CFREE_HOSTAS_EXEC_TIMEOUT=<secs>`, and per-arch images with
+`RUN_{AARCH64,X64,RV64}_IMAGE`. Opt-in (`make test-hostas-cross`); skips cleanly
+without clang/podman.
## Background — what cfree can do today (verified)
diff --git a/test/asm/hostas_cross.sh b/test/asm/hostas_cross.sh
@@ -0,0 +1,288 @@
+#!/usr/bin/env bash
+# test/asm/hostas_cross.sh — cross-compile + cross-exec extension of the
+# host-assembler lane (test/asm/hostas_toy.sh) to ELF Linux targets.
+#
+# Where hostas_toy.sh proves `cc -S` on the *native* target, this proves it
+# CROSS: for each ELF target (aarch64/x86_64/riscv64-linux) it emits ONE
+# `cc -S`, feeds it to BOTH cfree-as and a host assembler (clang), links each
+# into a static ELF with `cfree ld`, and runs it under podman/qemu — exit must
+# match the toy oracle. The assembler is the only variable between the two
+# lanes, judged by EXECUTION (cfree and clang emit different code, so a
+# byte/text match would be meaningless), exactly like hostas_toy.sh.
+#
+# Per toy case (each target; both O0 and O1):
+# cfree cc -S -target <triple> -> s.s (shared by both)
+# A /cfree-as: cfree as -target | cfree ld -static -> run exit == oracle
+# B /clang-as: clang --target -c | cfree ld -static -> run exit == oracle
+#
+# Executable shape: a STATIC, non-PIE ELF (`cfree ld -static`) linked with the
+# freestanding crt test/link/harness/start.c compiled `-Dtest_main=main`, so
+# `_start` runs ctors then calls the toy's `main` and exits with its return
+# (the oracle) via a raw syscall — no libc/loader needed, so any same-arch
+# Linux image runs it. Execution uses the shared test/lib/exec_target.sh helper
+# (one batched `podman run` per target) — the same path test/{link,smoke}
+# already use.
+#
+# Self-probing: each target is SKIPPED (not failed) unless the host has (1) a
+# clang cross-compiler for it, (2) a runner (podman/qemu) per exec_target, (3) a
+# working `cfree cc -S | cfree as` round-trip for that arch, and (4) a bounded
+# exec smoke that returns the oracle. So the harness runs green on whatever the
+# host supports and self-extends as gaps close. Status at time of writing:
+# - aarch64-linux: works end-to-end (podman runs arm64 natively in its VM).
+# - x86_64-linux: SKIPS on the x64 `cc -S` symbolizer gap — x64 emits numeric
+# branch targets (`jmp 0x77`) the x64 `as` can't reassemble
+# (branch-target label synthesis + reloc-operand symbolization
+# are aarch64-only today; see doc/ASM_ROUNDTRIP_TESTING.md).
+# - riscv64-linux: `cc -S | as | ld` works; SKIPS where riscv64 user-mode
+# emulation is unavailable/too slow to pass the exec smoke.
+#
+# Override the matrix with CFREE_HOSTAS_CROSS_TARGETS="tag:triple ..." and the
+# clang-as gate with CFREE_HOSTAS_ENFORCE_CLANG=0 (demote lane B to XFAIL).
+
+set -u
+
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+CFREE="$ROOT/build/cfree"
+CASES="$ROOT/test/toy/cases"
+WORK="$ROOT/build/test/asm/hostas_cross"
+START_SRC="$ROOT/test/link/harness/start.c"
+OPTS="${CFREE_TEST_OPTS:-O0 O1}"
+FILTER="${1:-}"
+ENFORCE_CLANG="${CFREE_HOSTAS_ENFORCE_CLANG:-1}"
+EXEC_SMOKE_TIMEOUT="${CFREE_HOSTAS_EXEC_TIMEOUT:-45}"
+
+# "tag:triple" — tag is exec_target.sh's <arch>-<os> spelling.
+TARGETS="${CFREE_HOSTAS_CROSS_TARGETS:-aarch64-linux:aarch64-linux-gnu x64-linux:x86_64-linux-gnu rv64-linux:riscv64-linux-gnu}"
+
+# Same TLS-symbolization skip as the sibling lanes.
+SKIP="141_threadlocal_mutate"
+
+CLANG="${CLANG:-$(command -v clang 2>/dev/null || true)}"
+
+color_red() { printf '\033[31m%s\033[0m' "$1"; }
+color_grn() { printf '\033[32m%s\033[0m' "$1"; }
+color_yel() { printf '\033[33m%s\033[0m' "$1"; }
+
+if [ ! -x "$CFREE" ]; then
+ printf 'hostas-cross: %s cfree missing — run "make bin"\n' "$(color_red FATAL)" >&2
+ exit 1
+fi
+if [ -z "$CLANG" ] || [ ! -x "$CLANG" ]; then
+ printf 'hostas-cross: %s no clang (host assembler); skipping\n' "$(color_yel SKIP)"
+ exit 0
+fi
+mkdir -p "$WORK"
+
+# ---- exec_target.sh wiring (mirrors test/link/run.sh detection) ------------
+have_qemu=0
+QEMU_BIN="$(command -v qemu-aarch64-static 2>/dev/null || command -v qemu-aarch64 2>/dev/null || true)"
+[ -n "$QEMU_BIN" ] && have_qemu=1
+QEMU_RV64_BIN="$(command -v qemu-riscv64-static 2>/dev/null || command -v qemu-riscv64 2>/dev/null || true)"
+have_podman=0
+command -v podman >/dev/null 2>&1 && have_podman=1
+arch_raw="$(uname -m 2>/dev/null || true)"
+is_aarch64=0
+{ [ "$arch_raw" = "aarch64" ] || [ "$arch_raw" = "arm64" ]; } && is_aarch64=1
+export have_qemu QEMU_BIN QEMU_RV64_BIN have_podman is_aarch64
+# Pin arch-explicit images so podman doesn't resolve an ambiguous multi-arch
+# tag to the wrong variant (the bare `alpine:latest` in local storage can map
+# to a non-host arch). Overridable; the binaries are static/freestanding so any
+# same-arch Linux image with /bin/sh works. Absent images fail --pull=never,
+# which the per-target exec smoke turns into a clean SKIP.
+: "${RUN_AARCH64_IMAGE:=docker.io/arm64v8/alpine:latest}"
+: "${RUN_X64_IMAGE:=docker.io/amd64/alpine:latest}"
+: "${RUN_RV64_IMAGE:=docker.io/riscv64/alpine:edge}"
+export RUN_AARCH64_IMAGE RUN_X64_IMAGE RUN_RV64_IMAGE
+EXEC_TARGET_MOUNT_ROOT="$WORK"
+# shellcheck source=../lib/exec_target.sh
+source "$ROOT/test/lib/exec_target.sh"
+
+is_skip() { case " $SKIP " in *" $1 "*) return 0;; *) return 1;; esac; }
+
+oracle() {
+ local name="$1" exp=0
+ [ -f "$CASES/$name.expected" ] && exp=$(head -n1 "$CASES/$name.expected")
+ echo $((exp & 255))
+}
+
+# First real `error:` line from an assembler's stderr (clang prints a harmless
+# -Wmissing-sysroot warning first — we never link with clang, so the SDK is
+# irrelevant).
+err_reason() {
+ local f="$1" line=""
+ line=$(grep -m1 -E 'error:|fatal:' "$f" 2>/dev/null | sed 's|.*\(error\|fatal\): *||')
+ [ -z "$line" ] && line=$(head -1 "$f" 2>/dev/null | sed 's|.*: ||')
+ printf '%s' "$line"
+}
+
+# Bounded exec: run exe via exec_target under a wall-clock cap so a wedged
+# emulator (e.g. a riscv64 qemu-user that never returns) downgrades a target to
+# SKIP instead of hanging the whole harness. Sets SMOKE_RC (124 == timed out).
+bounded_exec() {
+ local to="$1" tag="$2" exe="$3" out="$4" err="$5"
+ local rc0="$exe.rc0"
+ rm -f "$rc0"
+ ( exec_target_run "$tag" "$exe" "$out" "$err"; echo "$RUN_RC" >"$rc0" ) &
+ local pid=$! waited=0
+ while kill -0 "$pid" 2>/dev/null; do
+ sleep 1; waited=$((waited+1))
+ if [ "$waited" -ge "$to" ]; then
+ kill -9 "$pid" 2>/dev/null; wait "$pid" 2>/dev/null
+ SMOKE_RC=124; return
+ fi
+ done
+ wait "$pid" 2>/dev/null
+ SMOKE_RC=124
+ [ -f "$rc0" ] && SMOKE_RC="$(cat "$rc0")"
+}
+
+# Link a relocatable object + the target crt into a static ELF. Echoes nothing;
+# returns nonzero (and leaves stderr in $3) on failure or a non-empty linker
+# diagnostic (warnings are treated as failures, as in hostas_toy.sh).
+cfree_ld_static() {
+ local obj="$1" out="$2" lderr="$3" crt="$4"
+ "$CFREE" ld -static "$obj" "$crt" -o "$out" 2>"$lderr" || return 1
+ [ -s "$lderr" ] && return 1
+ return 0
+}
+
+printf 'hostas-cross: cfree=%s\n' "$CFREE"
+printf 'hostas-cross: clang=%s opts="%s" enforce_clang=%s podman=%s\n' \
+ "$CLANG" "$OPTS" "$ENFORCE_CLANG" "$have_podman"
+printf 'hostas-cross: targets="%s"\n' "$TARGETS"
+
+a_pass=0; a_fail=0
+b_pass=0; b_xfail=0; b_xpass=0; b_efail=0
+skip_cases=0
+tgt_run=0; tgt_skip=0
+a_failnames=()
+
+shopt -s nullglob
+for entry in $TARGETS; do
+ tag="${entry%%:*}"; triple="${entry##*:}"
+ tdir="$WORK/$tag"; rm -rf "$tdir"; mkdir -p "$tdir"
+
+ # --- per-target capability gates (SKIP, never FAIL) ---
+ if ! "$CLANG" --target="$triple" -c -x c - -o /dev/null </dev/null 2>/dev/null; then
+ tgt_skip=$((tgt_skip+1)); printf ' %s %s — no clang cross target\n' "$(color_yel SKIP-TGT)" "$tag"; continue
+ fi
+ if ! exec_target_supported "$tag"; then
+ tgt_skip=$((tgt_skip+1)); printf ' %s %s — no runner (podman/qemu)\n' "$(color_yel SKIP-TGT)" "$tag"; continue
+ fi
+ crt="$tdir/start.o"
+ if ! "$CLANG" --target="$triple" -O1 -ffreestanding -fno-stack-protector \
+ -fno-PIC -fno-pie -Dtest_main=main -c "$START_SRC" -o "$crt" 2>"$tdir/crt.err"; then
+ tgt_skip=$((tgt_skip+1)); printf ' %s %s — crt build failed: %s\n' "$(color_yel SKIP-TGT)" "$tag" "$(err_reason "$tdir/crt.err")"; continue
+ fi
+
+ # Pick a representative non-skip case for the smokes.
+ smoke=""; for s in "$CASES"/*.toy; do n="$(basename "$s" .toy)"; is_skip "$n" && continue; smoke="$n"; break; done
+ sd="$tdir/_smoke"; mkdir -p "$sd"
+ if ! "$CFREE" cc -S -O0 -target "$triple" "$CASES/$smoke.toy" -o "$sd/s.s" 2>"$sd/ccs.err"; then
+ tgt_skip=$((tgt_skip+1)); printf ' %s %s — cc -S failed: %s\n' "$(color_yel SKIP-TGT)" "$tag" "$(err_reason "$sd/ccs.err")"; continue
+ fi
+ if ! "$CFREE" as -target "$triple" "$sd/s.s" -o "$sd/a.o" 2>"$sd/as.err"; then
+ tgt_skip=$((tgt_skip+1)); printf ' %s %s — cc -S|as gap: %s\n' "$(color_yel SKIP-TGT)" "$tag" "$(err_reason "$sd/as.err")"; continue
+ fi
+ if ! cfree_ld_static "$sd/a.o" "$sd/a.out" "$sd/ld.err" "$crt"; then
+ tgt_skip=$((tgt_skip+1)); printf ' %s %s — cfree ld -static failed: %s\n' "$(color_yel SKIP-TGT)" "$tag" "$(err_reason "$sd/ld.err")"; continue
+ fi
+ bounded_exec "$EXEC_SMOKE_TIMEOUT" "$tag" "$sd/a.out" "$sd/run.out" "$sd/run.err"
+ sexp=$(oracle "$smoke")
+ if [ "$SMOKE_RC" != "$sexp" ]; then
+ reason="exit $SMOKE_RC != $sexp"; [ "$SMOKE_RC" = "124" ] && reason="exec timed out (>${EXEC_SMOKE_TIMEOUT}s)"
+ tgt_skip=$((tgt_skip+1)); printf ' %s %s — exec smoke: %s\n' "$(color_yel SKIP-TGT)" "$tag" "$reason"; continue
+ fi
+
+ # --- full corpus for this target: build everything, then one batched run ---
+ tgt_run=$((tgt_run+1))
+ printf ' %s %s (%s) — running corpus\n' "$(color_grn TGT)" "$tag" "$triple"
+ EXEC_TARGET_TAGS=(); EXEC_TARGET_NAMES=(); EXEC_TARGET_EXES=()
+ EXEC_TARGET_OUTS=(); EXEC_TARGET_ERRS=(); EXEC_TARGET_RCS=()
+ # exec_target_flush() clears its arrays on return, so keep our own
+ # reconciliation bookkeeping (lane / case / oracle / rc-file path).
+ Q_LANE=(); Q_NAME=(); Q_EXP=(); Q_RCF=()
+
+ for src in "$CASES"/*.toy; do
+ name="$(basename "$src" .toy)"
+ [ -n "$FILTER" ] && [[ "$name" != *"$FILTER"* ]] && continue
+ if is_skip "$name"; then skip_cases=$((skip_cases+1)); continue; fi
+ exp=$(oracle "$name")
+ for opt in $OPTS; do
+ w="$tdir/$name/$opt"; rm -rf "$w"; mkdir -p "$w"
+ if ! "$CFREE" cc -S "-$opt" -target "$triple" "$src" -o "$w/s.s" 2>"$w/ccs.err"; then
+ a_fail=$((a_fail+1)); a_failnames+=("$tag/$name[-$opt] cc-S: $(err_reason "$w/ccs.err")")
+ printf ' %s %s/%s[-%s] cc -S: %s\n' "$(color_red FAIL)" "$tag" "$name" "$opt" "$(err_reason "$w/ccs.err")"
+ continue
+ fi
+ # Lane A: cfree as -> cfree ld -static.
+ if ! "$CFREE" as -target "$triple" "$w/s.s" -o "$w/a.o" 2>"$w/a.as.err"; then
+ a_fail=$((a_fail+1)); a_failnames+=("$tag/$name[-$opt]/cfree-as: $(err_reason "$w/a.as.err")")
+ printf ' %s %s/%s[-%s]/cfree-as: %s\n' "$(color_red FAIL)" "$tag" "$name" "$opt" "$(err_reason "$w/a.as.err")"
+ elif ! cfree_ld_static "$w/a.o" "$w/a.out" "$w/a.ld.err" "$crt"; then
+ a_fail=$((a_fail+1)); a_failnames+=("$tag/$name[-$opt]/cfree-as ld: $(err_reason "$w/a.ld.err")")
+ printf ' %s %s/%s[-%s]/cfree-as ld: %s\n' "$(color_red FAIL)" "$tag" "$name" "$opt" "$(err_reason "$w/a.ld.err")"
+ else
+ exec_target_queue "$tag" "A:$name[-$opt]" "$w/a.out" "$w/a.run.out" "$w/a.run.err" "$w/a.rc"
+ Q_LANE+=("A"); Q_NAME+=("$name[-$opt]"); Q_EXP+=("$exp"); Q_RCF+=("$w/a.rc")
+ fi
+ # Lane B: clang -c (third-party assembler) -> cfree ld -static.
+ if ! "$CLANG" --target="$triple" -c "$w/s.s" -o "$w/b.o" 2>"$w/b.as.err"; then
+ if [ "$ENFORCE_CLANG" = "1" ]; then
+ b_efail=$((b_efail+1)); printf ' %s %s/%s[-%s]/clang-as: %s\n' "$(color_red FAIL)" "$tag" "$name" "$opt" "$(err_reason "$w/b.as.err")"
+ else
+ b_xfail=$((b_xfail+1)); printf ' %s %s/%s[-%s]/clang-as: %s\n' "$(color_yel XFAIL)" "$tag" "$name" "$opt" "$(err_reason "$w/b.as.err")"
+ fi
+ elif ! cfree_ld_static "$w/b.o" "$w/b.out" "$w/b.ld.err" "$crt"; then
+ if [ "$ENFORCE_CLANG" = "1" ]; then
+ b_efail=$((b_efail+1)); printf ' %s %s/%s[-%s]/clang-as ld: %s\n' "$(color_red FAIL)" "$tag" "$name" "$opt" "$(err_reason "$w/b.ld.err")"
+ else
+ b_xfail=$((b_xfail+1))
+ fi
+ else
+ exec_target_queue "$tag" "B:$name[-$opt]" "$w/b.out" "$w/b.run.out" "$w/b.run.err" "$w/b.rc"
+ Q_LANE+=("B"); Q_NAME+=("$name[-$opt]"); Q_EXP+=("$exp"); Q_RCF+=("$w/b.rc")
+ fi
+ done
+ done
+
+ # Drain this target's queue in one batched container run, then reconcile.
+ exec_target_flush
+ qn="${#Q_LANE[@]}"; qi=0
+ while [ "$qi" -lt "$qn" ]; do
+ lane="${Q_LANE[$qi]}"; nm="${Q_NAME[$qi]}"; exp="${Q_EXP[$qi]}"
+ rcf="${Q_RCF[$qi]}"
+ rc=127; [ -f "$rcf" ] && rc="$(cat "$rcf")"
+ if [ "$lane" = "A" ]; then
+ if [ "$rc" = "$exp" ]; then a_pass=$((a_pass+1)); else
+ a_fail=$((a_fail+1)); a_failnames+=("$tag/$nm/cfree-as exit $rc != $exp")
+ printf ' %s %s/%s/cfree-as: exit %s != %s\n' "$(color_red FAIL)" "$tag" "$nm" "$rc" "$exp"
+ fi
+ else
+ if [ "$rc" = "$exp" ]; then
+ if [ "$ENFORCE_CLANG" = "1" ]; then b_pass=$((b_pass+1)); else b_xpass=$((b_xpass+1)); fi
+ else
+ if [ "$ENFORCE_CLANG" = "1" ]; then
+ b_efail=$((b_efail+1)); printf ' %s %s/%s/clang-as: exit %s != %s\n' "$(color_red FAIL)" "$tag" "$nm" "$rc" "$exp"
+ else
+ b_xfail=$((b_xfail+1))
+ fi
+ fi
+ fi
+ qi=$((qi+1))
+ done
+done
+shopt -u nullglob
+
+printf '\n'
+[ "${#a_failnames[@]}" -gt 0 ] && { printf 'cfree-as failures:\n'; for f in "${a_failnames[@]}"; do printf ' %s\n' "$f"; done; }
+printf 'hostas-cross: targets %d run, %d skip | cfree-as %d pass, %d fail | clang-as %d pass, %d xfail, %d xpass, %d efail | %d case-skip\n' \
+ "$tgt_run" "$tgt_skip" "$a_pass" "$a_fail" "$b_pass" "$b_xfail" "$b_xpass" "$b_efail" "$skip_cases"
+if [ "$tgt_run" -eq 0 ]; then
+ printf 'hostas-cross: no target ran (all SKIP-TGT) — needs a clang cross target + podman/qemu + a working cc -S|as for some ELF arch.\n'
+fi
+
+rc=0
+[ "$a_fail" -gt 0 ] && rc=1
+[ "$ENFORCE_CLANG" = "1" ] && [ "$b_efail" -gt 0 ] && rc=1
+exit $rc
diff --git a/test/test.mk b/test/test.mk
@@ -43,6 +43,7 @@ TEST_TARGETS = \
test-asm-symmetry \
test-asm-roundtrip-toy \
test-hostas-toy \
+ test-hostas-cross \
test-diff-llvm \
test-bounce \
test-cbackend \
@@ -739,13 +740,24 @@ test-asm-roundtrip-toy: bin
# oracle. Only the assembler differs between the two lanes, so the clang lane is
# the real test: a standard assembler can't paper over a private-dialect quirk
# the way cfree's own `as` can (cf. test/asm/diff_llvm.sh, but by execution).
-# The clang lane is currently XFAIL (native Mach-O cc -S emits ELF-only
-# .type/.size and an @progbits token inside Mach-O .sections, which clang
-# rejects) and does not gate by default; run with CFREE_HOSTAS_ENFORCE_CLANG=1
-# to gate it while fixing. Opt-in; skips cleanly if clang is absent.
+# cc -S is now object-format-aware, so the native Mach-O clang lane GATES by
+# default (both lanes 312/0); CFREE_HOSTAS_ENFORCE_CLANG=0 demotes it to XFAIL.
+# Opt-in; skips cleanly if clang is absent.
test-hostas-toy: bin
@bash test/asm/hostas_toy.sh
+# test-hostas-cross: the same two-assembler-by-execution idea as test-hostas-toy,
+# but CROSS — `cc -S -target <triple>` for ELF Linux arches (aarch64/x86_64/
+# riscv64), assembled by cfree-as AND clang, linked static (+ the start.c crt)
+# with cfree ld, and run under podman/qemu via test/lib/exec_target.sh. Each
+# target self-skips unless the host has a clang cross target, a runner, a
+# working `cc -S | cfree as` for that arch, and a passing bounded exec smoke —
+# so it runs green on whatever the host supports (aarch64-linux today; x86_64
+# pends the x64 cc -S symbolizer, riscv64 pends a working rv64 user-mode
+# emulator). Opt-in; skips cleanly if clang/podman are absent.
+test-hostas-cross: bin
+ @bash test/asm/hostas_cross.sh
+
test-wasm: test-wasm-front test-wasm-target test-wasm-toy
test-wasm-front: bin $(WASM_TOOL) $(LINK_EXE_RUNNER) $(JIT_RUNNER)