test(asm): Phase 0.1 — corpus ir-only branch for cross-target examples

When a `.build` target doesn't match the host, the runner can't execute the
example here, so it verifies via `sx ir --target` only: asserts exit + the `.ir`
snapshot (stdout) + diagnostics (stderr), never `.stdout`. An `.ir` snapshot is
REQUIRED in ir-only mode — its absence is a loud failure, never a silent pass.

- corpus_run.test.zig: ir_only flag (target set & !hostMatchesTarget); first
  dispatch arm runs `sx ir`, sets act_exit/act_err/act_ir; skip stdout in both
  update and verify modes; require ir_raw.
- lock fixture 1639-platform-target-cross (asm-free main, target x86_64-linux,
  checked-in .ir). Verified: corrupt .ir => IR mismatch; delete .ir => require
  failure.

Test-infra only; no compiler code. zig build test green (647 corpus, 444 unit).
This commit is contained in:
agra
2026-06-15 18:19:17 +03:00
parent c88f4fbcef
commit 0095584105
7 changed files with 74 additions and 44 deletions

View File

@@ -6,39 +6,34 @@ commit, one step at a time per the cadence rule (no commit may both add a test
and make it pass). and make it pass).
## Last completed step ## Last completed step
**0.0** — corpus runner target-gating + `<name>.build` JSON config. Added **0.1** — corpus runner **ir-only branch** for cross-target examples. Replaced
`BuildConfig` (`std.json.parseFromSliceLeaky``struct { aot, target }`, 0.0's loud placeholder bail: when `cfg.target` doesn't match the host (`ir_only`),
unknown-key ⇒ `error.UnknownField`) replacing the standalone `.aot` marker; `sweepRoot` skips run/build/exec and verifies via `sx ir --target` only —
migrated the 2 existing `.aot` markers (1226/1227) to `{ "aot": true }`; threaded asserting `.exit` (ir cmd) + `.ir` (normalized stdout) + `.stderr`, never
`--target` into the `run`/`build`/`ir` spawns via `withTarget`; added `.stdout` (write skipped in update mode, assertion skipped in verify mode). An
`hostMatchesTarget` (shorthand-expand + arch/os token match, `arm64``aarch64`) `.ir` snapshot is **required** in ir-only mode — its absence is a loud failure
gating the execute path. Cross-target mismatch fails **loudly** (placeholder until ("needs an .ir snapshot for ir-only mode"). Locked with
0.1's ir-only branch) — verified the bail fires (`target=linux, `examples/1639-platform-target-cross.sx` (asm-free `main :: () -> i64 { return 0;
host=aarch64-macos`). Locked with `examples/1638-platform-target-host.sx` }`), `.build` `{ "target": "x86_64-linux" }`, + checked-in `.ir`. Verified both
(`.build` `{ "target": "macos" }`, runs natively + asserts stdout) + unit tests guards fire: corrupting the `.ir` → IR mismatch; deleting it → the require-failure.
for the JSON parse and `hostMatchesTarget`. `zig build test` green (646 corpus, 0 `zig build test` green (647 corpus, 0 failed; 444 unit). Files:
failed; 444 unit, 0 failed). Files: `src/corpus_run.test.zig`, `src/corpus_run.test.zig`, `examples/1639-*`.
`examples/1638-*`, `examples/expected/{1226,1227,1638}-*`.
## Current state ## Current state
Phase 0 step 0.0 landed (test-infra only — no compiler code touched). The corpus Phase 0 steps 0.0 + 0.1 landed (test-infra only — no compiler code). The corpus
runner now reads `expected/<name>.build` and threads/gates `--target`; an runner reads `expected/<name>.build` (JSON `{ aot, target }`), threads `--target`,
arch-pinned example whose target matches the host **executes**, a mismatch and gates on host arch+os: a matching target **executes** (full exit/stdout/stderr
currently **bails loudly** ("cross-target ir-only mode not yet implemented"). Phase + optional `.ir`); a mismatch runs **ir-only** (`sx ir --target`, asserting
AE feasibility already confirmed against the live tree (`LLVMGetInlineAsm` / exit+ir+stderr, `.ir` required). Phase AE feasibility already confirmed against
`LLVMBuildCall2` / `LLVMAppendModuleInlineAsm` in LLVM@19 `Core.h`; ERR-stream the live tree (`LLVMGetInlineAsm` / `LLVMBuildCall2` / `LLVMAppendModuleInlineAsm`
`extractvalue`→tuple machinery in `emit_llvm.zig:726-927`; lib-less `extern`, 60 in LLVM@19 `Core.h`; ERR-stream `extractvalue`→tuple in `emit_llvm.zig:726-927`;
sites; `--target` a global CLI flag). lib-less `extern`, 60 sites; `--target` a global CLI flag).
## Next step ## Next step
**0.1** — implement the **mismatch ⇒ ir-only** branch in `sweepRoot` (replace the **0.2** — docs: update CLAUDE.md §"Testing"/§"Test layout" to document the
loud placeholder bail): when `cfg.target` doesn't match the host, skip `<name>.build` JSON config (`aot` + `target` keys, ir-only gating) replacing the
run/build/exec and assert only `.exit`+`.ir`+`.stderr` from `sx ir --target`; standalone `.aot` marker prose (~lines 435, 492). Then Phase A (`kw_asm` keyword +
require an `.ir` snapshot (loud failure if absent). Lock with lex test). See `PLAN-ASM.md` Phase 0 / Phase A.
`examples/16xx-platform-target-cross.sx` (asm-free `() -> i64 { return 0; }`),
`.build` `{ "target": "x86_64-linux" }`, + a checked-in `.ir` snapshot. Then 0.2
(CLAUDE.md §Testing/§Test-layout docs for `.build`), then Phase A (`kw_asm`). See
`PLAN-ASM.md` Phase 0.
## Log ## Log
- (init) Plan + design doc written; ASM stream opened. - (init) Plan + design doc written; ASM stream opened.
@@ -46,6 +41,9 @@ require an `.ir` snapshot (loud failure if absent). Lock with
marker), `--target` threading, `hostMatchesTarget` execute-gate, loud marker), `--target` threading, `hostMatchesTarget` execute-gate, loud
cross-target placeholder bail. Migrated 1226/1227 `.aot``.build`; locked with cross-target placeholder bail. Migrated 1226/1227 `.aot``.build`; locked with
1638 fixture + unit tests. `zig build test` green. 1638 fixture + unit tests. `zig build test` green.
- (0.1) ir-only branch: cross-target examples verify via `sx ir --target` only
(exit+ir+stderr, no stdout; `.ir` required). Locked with 1639 fixture; verified
corrupt-.ir → mismatch and missing-.ir → loud failure. `zig build test` green.
## Known issues ## Known issues
None yet. None yet.

View File

@@ -0,0 +1,7 @@
// Phase 0 (ASM stream) test-infra lock: exercises the corpus runner's
// CROSS-TARGET ir-only path. The `.build` pins `x86_64-linux`, which does NOT
// match this aarch64 host, so the runner skips run/build/exec and verifies via
// `sx ir --target x86_64-linux` only — asserting exit + the `.ir` snapshot +
// stderr (no `.stdout`). Asm-free on purpose: it locks the harness gating, not
// any inline-asm lowering (that arrives in Phase A+).
main :: () -> i64 { return 0; }

View File

@@ -0,0 +1 @@
{ "target": "x86_64-linux" }

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,6 @@
; Function Attrs: nounwind
define i32 @main() #0 {
entry:
ret i32 0
}

View File

@@ -0,0 +1 @@

View File

@@ -314,20 +314,36 @@ fn sweepRoot(
const is_aot = cfg.aot; const is_aot = cfg.aot;
// An example pinned to a non-host target cannot execute here; it routes // An example pinned to a non-host target cannot execute here; it routes
// to ir-only mode (Phase 0.1). Until that lands, a mismatch must fail // to ir-only mode (verify via `sx ir` only — see the first arm below).
// loudly — never silently pass. const ir_only = if (cfg.target) |t| !hostMatchesTarget(t) else false;
if (cfg.target) |t| {
if (!hostMatchesTarget(t)) {
try failures.append(fail_gpa, try std.fmt.allocPrint(fail_gpa, "{s}: cross-target ir-only mode not yet implemented (target={s}, host={s}-{s})", .{ name, t, @tagName(builtin.cpu.arch), @tagName(builtin.os.tag) }));
continue;
}
}
var act_exit: u32 = undefined; var act_exit: u32 = undefined;
var act_out: []const u8 = undefined; var act_out: []const u8 = undefined;
var act_err: []const u8 = undefined; var act_err: []const u8 = undefined;
var act_ir: ?[]const u8 = null;
if (is_aot) { if (ir_only) {
// Cross-target: cannot run on this host. Verify via `sx ir` only —
// exit code, the IR snapshot (stdout), and diagnostics (stderr). An
// .ir snapshot is REQUIRED: without it an arch-pinned example would
// assert nothing. Its absence is a loud failure, never a silent pass.
if (ir_raw == null) {
try failures.append(fail_gpa, try std.fmt.allocPrint(fail_gpa, "{s}: cross-target example (target={s}) needs an .ir snapshot for ir-only mode", .{ name, cfg.target.? }));
continue;
}
const ir_res = std.process.run(a, io, .{
.argv = try withTarget(a, &.{ corpus_paths.sx_exe, "ir", rel_path }, cfg.target),
.cwd = .{ .path = repo_root },
.timeout = deadline(io),
}) catch |err| {
try failures.append(fail_gpa, try std.fmt.allocPrint(fail_gpa, "{s}: `sx ir` {s}{s}", .{ name, @errorName(err), if (err == error.Timeout) " (>10s)" else "" }));
continue;
};
act_exit = termCode(ir_res.term);
act_out = ""; // stdout carries IR (asserted via .ir), not a separate stream
act_err = trimNl(try normalizeStd(a, ir_res.stderr));
act_ir = trimNl(try normalizeIr(a, ir_res.stdout));
} else if (is_aot) {
// Build a native executable, then run it. The build's own stderr // Build a native executable, then run it. The build's own stderr
// ("compiled: <path>") is intentionally discarded — only the built // ("compiled: <path>") is intentionally discarded — only the built
// program's streams are snapshotted. A build failure (e.g. an // program's streams are snapshotted. A build failure (e.g. an
@@ -383,10 +399,10 @@ fn sweepRoot(
act_err = trimNl(try normalizeStd(a, run_res.stderr)); act_err = trimNl(try normalizeStd(a, run_res.stderr));
} }
// --- sx ir (only when a snapshot already exists; mirrors the shell's // --- sx ir (execute-mode only; ir-only produced act_ir above). Runs
// `$has_ir` gate — update mode never CREATES new .ir files) --- // when a snapshot already exists; mirrors the shell's `$has_ir` gate —
var act_ir: ?[]const u8 = null; // update mode never CREATES new .ir files. ---
if (ir_raw != null) { if (!ir_only and ir_raw != null) {
const ir_res = std.process.run(a, io, .{ const ir_res = std.process.run(a, io, .{
.argv = try withTarget(a, &.{ corpus_paths.sx_exe, "ir", rel_path }, cfg.target), .argv = try withTarget(a, &.{ corpus_paths.sx_exe, "ir", rel_path }, cfg.target),
.cwd = .{ .path = repo_root }, .cwd = .{ .path = repo_root },
@@ -404,7 +420,7 @@ fn sweepRoot(
// --- update mode: overwrite snapshots with freshly-normalized output --- // --- update mode: overwrite snapshots with freshly-normalized output ---
if (corpus_paths.update_goldens) { if (corpus_paths.update_goldens) {
try writeGolden(io, a, exp_dir, name, "exit", try std.fmt.allocPrint(a, "{d}", .{act_exit})); try writeGolden(io, a, exp_dir, name, "exit", try std.fmt.allocPrint(a, "{d}", .{act_exit}));
try writeGolden(io, a, exp_dir, name, "stdout", act_out); if (!ir_only) try writeGolden(io, a, exp_dir, name, "stdout", act_out);
try writeGolden(io, a, exp_dir, name, "stderr", act_err); try writeGolden(io, a, exp_dir, name, "stderr", act_err);
if (act_ir) |ir| try writeGolden(io, a, exp_dir, name, "ir", ir); if (act_ir) |ir| try writeGolden(io, a, exp_dir, name, "ir", ir);
updated += 1; updated += 1;
@@ -422,7 +438,7 @@ fn sweepRoot(
var diag: std.ArrayList(u8) = .empty; var diag: std.ArrayList(u8) = .empty;
if (act_exit != exp_exit) if (act_exit != exp_exit)
try diag.appendSlice(a, try std.fmt.allocPrint(a, " exit: expected={d} actual={d}\n", .{ exp_exit, act_exit })); try diag.appendSlice(a, try std.fmt.allocPrint(a, " exit: expected={d} actual={d}\n", .{ exp_exit, act_exit }));
if (!std.mem.eql(u8, act_out, exp_out)) if (!ir_only and !std.mem.eql(u8, act_out, exp_out))
try appendDiff(a, &diag, "stdout", exp_out, act_out); try appendDiff(a, &diag, "stdout", exp_out, act_out);
if (!std.mem.eql(u8, act_err, exp_err)) if (!std.mem.eql(u8, act_err, exp_err))
try appendDiff(a, &diag, "stderr", exp_err, act_err); try appendDiff(a, &diag, "stderr", exp_err, act_err);