a73x

2dadb5e0

scenario: reject size=0, out-of-bounds asserts, empty bytes-hex

a73x   2026-04-19 09:19

Three parse-time validations the parser was silently accepting.
All three surface a line-numbered Diagnostic and error.ParseFailed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

diff --git a/src/scenario.zig b/src/scenario.zig
index 2d22461..2dd3f50 100644
--- a/src/scenario.zig
+++ b/src/scenario.zig
@@ -231,6 +231,10 @@ pub fn parse(
                diag.message = "size rows must be a positive integer";
                return error.ParseFailed;
            };
            if (cols == 0 or rows == 0) {
                diag.* = .{ .line = line_num, .message = "size dimensions must be non-zero" };
                return error.ParseFailed;
            }
            has_size = true;
        } else if (std.mem.eql(u8, kw, "timeout")) {
            if (!has_size) {
@@ -311,6 +315,10 @@ pub fn parse(
                    };
                    try hex_buf.append(alloc, byte);
                }
                if (hex_buf.items.len == 0) {
                    diag.* = .{ .line = line_num, .message = "bytes-hex requires at least one token" };
                    return error.ParseFailed;
                }
                try directives.append(alloc, .{ .bytes = try hex_buf.toOwnedSlice(alloc) });
            } else if (std.mem.eql(u8, kw, "capture")) {
                const lbl_res = nextToken(line, pos) orelse {
@@ -367,6 +375,10 @@ pub fn parse(
                    diag.message = "unknown predicate";
                    return error.ParseFailed;
                };
                if (row >= rows or col >= cols) {
                    diag.* = .{ .line = line_num, .message = "assert-cell row/col out of bounds for scenario size" };
                    return error.ParseFailed;
                }
                try directives.append(alloc, .{ .assert_cell = .{ .row = row, .col = col, .pred = pred } });
            } else if (std.mem.eql(u8, kw, "assert-cell-at")) {
                const lbl_res = nextToken(line, pos) orelse {
@@ -416,6 +428,10 @@ pub fn parse(
                    diag.message = "unknown predicate";
                    return error.ParseFailed;
                };
                if (row >= rows or col >= cols) {
                    diag.* = .{ .line = line_num, .message = "assert-cell row/col out of bounds for scenario size" };
                    return error.ParseFailed;
                }
                try directives.append(alloc, .{ .assert_cell_at = .{
                    .label = label_copy,
                    .row = row,
@@ -697,3 +713,42 @@ test "parse: unknown predicate fails at line" {
    );
    try std.testing.expectEqual(@as(usize, 3), diag.line);
}

test "parse: size 0 0 rejected" {
    const diag = try parseErr(
        \\size 0 0
        \\timeout 500ms
    );
    try std.testing.expectEqual(@as(usize, 1), diag.line);
    try std.testing.expect(std.mem.indexOf(u8, diag.message, "non-zero") != null);
}

test "parse: assert-cell row out of bounds" {
    const diag = try parseErr(
        \\size 80 24
        \\timeout 500ms
        \\assert-cell 24 0 cell-empty
    );
    try std.testing.expectEqual(@as(usize, 3), diag.line);
    try std.testing.expect(std.mem.indexOf(u8, diag.message, "out of bounds") != null);
}

test "parse: assert-cell-at col out of bounds" {
    const diag = try parseErr(
        \\size 80 24
        \\timeout 500ms
        \\assert-cell-at foo 0 80 cell-empty
    );
    try std.testing.expectEqual(@as(usize, 3), diag.line);
    try std.testing.expect(std.mem.indexOf(u8, diag.message, "out of bounds") != null);
}

test "parse: bytes-hex rejects empty token list" {
    const diag = try parseErr(
        \\size 80 24
        \\timeout 500ms
        \\bytes-hex
    );
    try std.testing.expectEqual(@as(usize, 3), diag.line);
    try std.testing.expect(std.mem.indexOf(u8, diag.message, "at least one token") != null);
}