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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user