test(asm): Phase 0.0 — corpus target-gating + .build JSON config

Adds per-example build/run directives to the corpus runner via an optional
`expected/<name>.build` JSON sidecar (`BuildConfig { aot, target }`), replacing
the standalone `.aot` marker. Threads `--target` into the run/build/ir spawns
and gates the execute path on host arch+os match; a cross-target example fails
loudly ("ir-only mode not yet implemented") pending Phase 0.1.

- corpus_run.test.zig: BuildConfig + std.json parse (unknown-key => error),
  hostMatchesTarget (shorthand-expand + arch/os token match, arm64->aarch64),
  withTarget argv helper; unit tests for both.
- migrate 1226/1227 `.aot` markers -> `.build` { "aot": true }.
- lock fixture 1638-platform-target-host (`.build` { "target": "macos" }).

Test-infra only; no compiler code. zig build test green (646 corpus, 444 unit).
This commit is contained in:
agra
2026-06-15 17:37:35 +03:00
parent d6a9c4f0c4
commit c88f4fbcef
12 changed files with 282 additions and 19 deletions

View File

@@ -1,4 +1,5 @@
const std = @import("std");
const builtin = @import("builtin");
const corpus_paths = @import("corpus_paths");
// End-to-end example/issue regression runner. For every
@@ -159,6 +160,83 @@ fn readOptional(io: std.Io, gpa: std.mem.Allocator, abs_path: []const u8) ?[]u8
return std.Io.Dir.readFileAlloc(.cwd(), io, abs_path, gpa, .limited(MAX_OUTPUT)) catch null;
}
/// Per-example build/run directives, parsed from an optional `<name>.build`
/// JSON sidecar (replaces the old standalone `.aot` marker). Output snapshots
/// (.exit/.stdout/.stderr/.ir) stay separate — they are regenerated data, not
/// config. An unknown key is `error.UnknownField` (std.json default
/// `ignore_unknown_fields = false`), surfaced as a loud test failure — never a
/// silent ignore. Future directives (`cpu`, `link`, `cwd`) are just new
/// optional fields here, no new sidecar file.
const BuildConfig = struct {
aot: bool = false,
target: ?[]const u8 = null,
};
fn parseBuildConfig(a: std.mem.Allocator, text: []const u8) !BuildConfig {
return std.json.parseFromSliceLeaky(BuildConfig, a, text, .{});
}
/// Expand the `sx --target` shorthands we care about to a triple whose `arch`
/// (token 0) and OS substring are stable — versions/vendors are irrelevant to
/// host matching. Mirrors the table in `main.zig`; unknown values pass through
/// (already a triple, or LLVM rejects later).
fn expandTargetShorthand(raw: []const u8) []const u8 {
const eql = std.mem.eql;
if (eql(u8, raw, "macos") or eql(u8, raw, "macos-arm")) return "aarch64-apple-macos";
if (eql(u8, raw, "macos-x86")) return "x86_64-apple-macos";
if (eql(u8, raw, "linux") or eql(u8, raw, "linux-x86")) return "x86_64-linux-gnu";
if (eql(u8, raw, "linux-arm")) return "aarch64-linux-gnu";
if (eql(u8, raw, "windows")) return "x86_64-windows-msvc";
if (eql(u8, raw, "ios") or eql(u8, raw, "ios-arm")) return "aarch64-apple-ios";
if (eql(u8, raw, "ios-sim") or eql(u8, raw, "ios-sim-arm")) return "aarch64-apple-ios-simulator";
if (eql(u8, raw, "wasm") or eql(u8, raw, "wasm32") or eql(u8, raw, "emscripten")) return "wasm32-unknown-emscripten";
return raw;
}
/// `arm64` and `aarch64` name the same ISA; normalize so a `.build` may spell
/// either.
fn normalizeArch(arch: []const u8) []const u8 {
return if (std.mem.eql(u8, arch, "arm64")) "aarch64" else arch;
}
/// Canonical OS name detected from a triple by substring, mapped to the
/// `std.Target.Os.Tag` spelling used by `@tagName`. Order matters: `ios` is
/// checked before `macos`/`darwin` (Apple triples share the `apple` vendor).
fn tripleOsName(triple: []const u8) ?[]const u8 {
const has = std.mem.indexOf;
if (has(u8, triple, "ios") != null) return "ios";
if (has(u8, triple, "android") != null) return "linux"; // linux-android
if (has(u8, triple, "macos") != null or has(u8, triple, "darwin") != null) return "macos";
if (has(u8, triple, "linux") != null) return "linux";
if (has(u8, triple, "windows") != null) return "windows";
if (has(u8, triple, "emscripten") != null or has(u8, triple, "wasi") != null) return "emscripten";
return null;
}
/// True when `value` (a `.build` target shorthand or triple) names the host's
/// arch AND OS — i.e. an example built for it can actually execute here. A
/// mismatch routes the example to ir-only mode (Phase 0.1).
fn hostMatchesTarget(value: []const u8) bool {
const triple = expandTargetShorthand(value);
const dash = std.mem.indexOfScalar(u8, triple, '-') orelse return false;
const arch = normalizeArch(triple[0..dash]);
if (!std.mem.eql(u8, arch, @tagName(builtin.cpu.arch))) return false;
const os = tripleOsName(triple) orelse return false;
return std.mem.eql(u8, os, @tagName(builtin.os.tag));
}
/// `base` with `--target <t>` appended when `target` is set, else `base`
/// unchanged. `--target` is a global `sx` flag (main.zig), valid after any
/// subcommand.
fn withTarget(a: std.mem.Allocator, base: []const []const u8, target: ?[]const u8) ![]const []const u8 {
const t = target orelse return base;
const v = try a.alloc([]const u8, base.len + 2);
@memcpy(v[0..base.len], base);
v[base.len] = "--target";
v[base.len + 1] = t;
return v;
}
/// Run every `<root>/expected/*.exit` test. Appends a formatted diagnostic to
/// `failures` (owned by `fail_gpa`) for each mismatch. Returns the number of
/// tests actually run (markers whose `.sx` is missing are skipped).
@@ -218,14 +296,32 @@ fn sweepRoot(
const err_raw = readOptional(io, a, try std.fmt.allocPrint(a, "{s}/{s}.stderr", .{ exp_dir, name })) orelse "";
const ir_raw = readOptional(io, a, try std.fmt.allocPrint(a, "{s}/{s}.ir", .{ exp_dir, name }));
// An `<name>.aot` marker switches the example from JIT `sx run` to a
// build+execute flow: `sx build` links the sx object with any C
// `#source` companions into a native binary, which is then executed.
// This is the ONLY way to exercise a C-ABI symbol exported FROM sx
// (an `export` fn): in JIT mode the sx symbol lives in JIT memory and
// is invisible to a dlopen'd C dylib's flat-namespace lookup, so a
// C→sx-by-name call can only be linked ahead-of-time.
const is_aot = readOptional(io, a, try std.fmt.allocPrint(a, "{s}/{s}.aot", .{ exp_dir, name })) != null;
// Per-example directives live in an optional `<name>.build` JSON sidecar
// (BuildConfig). `aot` switches the JIT `sx run` to a build+execute flow:
// `sx build` links the sx object with any C `#source` companions into a
// native binary, which is then executed — the ONLY way to exercise a
// C-ABI symbol exported FROM sx (an `export` fn): in JIT mode the sx
// symbol lives in JIT memory and is invisible to a dlopen'd C dylib's
// flat-namespace lookup, so a C→sx-by-name call can only be linked
// ahead-of-time. `target` threads `--target` and gates host execution.
const cfg: BuildConfig = if (readOptional(io, a, try std.fmt.allocPrint(a, "{s}/{s}.build", .{ exp_dir, name }))) |raw|
parseBuildConfig(a, raw) catch |err| {
try failures.append(fail_gpa, try std.fmt.allocPrint(fail_gpa, "{s}: invalid .build config ({s})", .{ name, @errorName(err) }));
continue;
}
else
.{};
const is_aot = cfg.aot;
// 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
// loudly — never silently pass.
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_out: []const u8 = undefined;
@@ -239,7 +335,7 @@ fn sweepRoot(
// linker error on stderr.
const bin_path = try std.fmt.allocPrint(a, "/tmp/sx_aot_{s}", .{name});
const build_res = std.process.run(a, io, .{
.argv = &.{ corpus_paths.sx_exe, "build", rel_path, "-o", bin_path },
.argv = try withTarget(a, &.{ corpus_paths.sx_exe, "build", rel_path, "-o", bin_path }, cfg.target),
.cwd = .{ .path = repo_root },
.timeout = deadline(io),
}) catch |err| {
@@ -270,7 +366,7 @@ fn sweepRoot(
} else {
// --- sx run ---
const run_res = std.process.run(a, io, .{
.argv = &.{ corpus_paths.sx_exe, "run", rel_path },
.argv = try withTarget(a, &.{ corpus_paths.sx_exe, "run", rel_path }, cfg.target),
.cwd = .{ .path = repo_root },
.timeout = deadline(io),
}) catch |err| {
@@ -292,7 +388,7 @@ fn sweepRoot(
var act_ir: ?[]const u8 = null;
if (ir_raw != null) {
const ir_res = std.process.run(a, io, .{
.argv = &.{ corpus_paths.sx_exe, "ir", rel_path },
.argv = try withTarget(a, &.{ corpus_paths.sx_exe, "ir", rel_path }, cfg.target),
.cwd = .{ .path = repo_root },
.timeout = deadline(io),
}) catch |err| {
@@ -419,3 +515,43 @@ test "issues corpus: every pinned issues/*.sx repro runs and matches its snapsho
defer for (failures.items) |f| std.testing.allocator.free(f);
try reportFailures("issues", ran, failures.items);
}
test "parseBuildConfig: defaults, fields, unknown key" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
const empty = try parseBuildConfig(a, "{}");
try std.testing.expect(!empty.aot);
try std.testing.expect(empty.target == null);
const aot = try parseBuildConfig(a, "{ \"aot\": true }");
try std.testing.expect(aot.aot);
try std.testing.expect(aot.target == null);
const tgt = try parseBuildConfig(a, "{ \"target\": \"x86_64-linux\" }");
try std.testing.expect(!tgt.aot);
try std.testing.expectEqualStrings("x86_64-linux", tgt.target.?);
// Unknown key is a loud error, not a silent ignore.
try std.testing.expectError(error.UnknownField, parseBuildConfig(a, "{ \"bogus\": 1 }"));
}
test "hostMatchesTarget: host arch+os matches, cross-arch does not" {
const arch = @tagName(builtin.cpu.arch);
const os = @tagName(builtin.os.tag);
// A triple built from the host's own arch + os must match.
var buf: [64]u8 = undefined;
const host_triple = std.fmt.bufPrint(&buf, "{s}-unknown-{s}", .{ arch, os }) catch unreachable;
try std.testing.expect(hostMatchesTarget(host_triple));
// A different arch never matches (same os).
const other_arch = if (builtin.cpu.arch == .x86_64) "aarch64" else "x86_64";
var buf2: [64]u8 = undefined;
const cross = std.fmt.bufPrint(&buf2, "{s}-unknown-{s}", .{ other_arch, os }) catch unreachable;
try std.testing.expect(!hostMatchesTarget(cross));
// `arm64` normalizes to `aarch64`.
try std.testing.expect(normalizeArch("arm64").len == "aarch64".len);
}