Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7843724
docs(brief): add M1.0.15 milestone brief
guysenpai Jul 5, 2026
8b9240e
docs(brief): confirm specs read for M1.0.15
guysenpai Jul 5, 2026
48d11ca
docs(brief): activate M1.0.15
guysenpai Jul 5, 2026
b97366d
docs(brief): journal E1 recon findings
guysenpai Jul 5, 2026
34aeedc
feat(etch): test-name namespace, annotations, sync body type-check
guysenpai Jul 5, 2026
89e469b
docs(brief): journal E1 completion
guysenpai Jul 5, 2026
b011c60
feat(etch): test runner library + per-test failure conversion
guysenpai Jul 5, 2026
734424e
docs(brief): journal E2 completion
guysenpai Jul 5, 2026
f7eec78
feat(etch): test-world surface (test_world/spawn_with/emit/tick)
guysenpai Jul 5, 2026
bcaa921
docs(brief): journal E3 completion
guysenpai Jul 5, 2026
237fd67
fix(etch): suppress per-body store resets during a test's tick
guysenpai Jul 5, 2026
df44c6d
docs(brief): journal E3 review fix (test-body UAF)
guysenpai Jul 5, 2026
85f1afc
feat(etch): assertion family, measure, tick_until
guysenpai Jul 5, 2026
1d2d775
docs(brief): journal E4 completion + review fixes
guysenpai Jul 5, 2026
81d47d4
fix(etch): constrain assert_approx operands to float at type-check
guysenpai Jul 5, 2026
f84f1d1
feat(etch): etch_test shim + test-etch acceptance corpus
guysenpai Jul 5, 2026
1f7884c
docs(brief): journal E5 completion
guysenpai Jul 5, 2026
9c4dec9
docs(claude-md): update for M1.0.15
guysenpai Jul 5, 2026
243f8d3
docs(etch): refresh stale kw_test comment (M1.0.15 executes tests)
guysenpai Jul 5, 2026
5a0aa1b
docs(brief): close M1.0.15
guysenpai Jul 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,11 @@ jobs:
zig build test -Doptimize=${{ matrix.mode }}
echo "TEST_SECONDS=$((SECONDS - s))" >> "$GITHUB_ENV"

# M1.0.15 — Etch test-runner acceptance corpus: the Zig driver over the
# `.etch` fixtures + the `etch_test` shim run over the green fixtures.
- name: zig build test-etch
run: zig build test-etch -Doptimize=${{ matrix.mode }}

# M0.2 / E5 — bindgen-verify gate. Regenerates the Vulkan +
# Wayland bindings and asserts `git diff --quiet` on
# `bindings/generated/` + `src/core/platform/`. Any drift
Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md

Large diffs are not rendered by default.

416 changes: 416 additions & 0 deletions briefs/M1.0.15-test-runner.md

Large diffs are not rendered by default.

50 changes: 50 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1127,6 +1127,56 @@ pub fn build(b: *std.Build) void {
});
b.installArtifact(etch_cook_exe);

// M1.0.15 — `etch_test` shim (thin CLI over `weld_etch.test_runner`) + the
// `test-etch` acceptance step. The shim owns arg parsing + I/O; all logic is
// in the library (same split as `etch_cook`).
const etch_test_module = b.createModule(.{
.root_source_file = b.path("tools/etch_test/main.zig"),
.target = b.graph.host,
.optimize = optimize,
});
etch_test_module.addImport("weld_etch", etch_module);
etch_test_module.addImport("weld_core", core_module);
const etch_test_exe = b.addExecutable(.{
.name = "etch_test",
.root_module = etch_test_module,
});
b.installArtifact(etch_test_exe);

// The Zig acceptance driver (assertions over the `.etch` fixtures + the
// diagnostic cases). Wired manually — not through `test_specs` — so the
// `test-etch` step can depend on it directly AND it stays in `zig build test`.
const etch_driver_module = b.createModule(.{
.root_source_file = b.path("tests/etch/test_runner/driver_test.zig"),
.target = target,
.optimize = optimize,
});
etch_driver_module.addImport("weld_core", core_module);
etch_driver_module.addImport("weld_etch", etch_module);
const etch_driver_test = b.addTest(.{ .root_module = etch_driver_module });
const etch_driver_run = b.addRunArtifact(etch_driver_test);
test_step.dependOn(&etch_driver_run.step);

const test_etch_step = b.step("test-etch", "Run the M1.0.15 Etch test-runner acceptance corpus (driver + shim over the green fixtures)");
test_etch_step.dependOn(&etch_driver_run.step);
// Run the shim over the all-green fixtures in a SINGLE invocation (the shim
// takes many files): prints the ✓ / skipped lines + per-file and total
// aggregates (observable behavior), exits 0. One process → clean, un-
// interleaved output. `failing.etch` is exercised by the driver (which
// asserts the failure) + by hand, not here.
const etch_shim_run = b.addRunArtifact(etch_test_exe);
for ([_][]const u8{
"tests/etch/test_runner/green.etch",
"tests/etch/test_runner/only.etch",
"tests/etch/test_runner/isolation.etch",
"tests/etch/test_runner/world.etch",
"tests/etch/test_runner/timing.etch",
}) |fixture| {
etch_shim_run.addFileArg(b.path(fixture));
}
etch_shim_run.stdio = .inherit; // show the ✓ lines during the build
test_etch_step.dependOn(&etch_shim_run.step);

// Cook the 20 differential corpus programs into a single consolidated
// `corpus_codegen.zig`. The driver test imports it via the
// `corpus_codegen` module name.
Expand Down
40 changes: 31 additions & 9 deletions src/core/ecs/observers.zig
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,33 @@ pub const ObserverRegistry = struct {
try self.fireList(self.on_spawned, world, eid, null, null, null);
}

/// Spawn an entity with initial component values AND fire the exact
/// observers a deferred `.spawn` flush fires — `on_spawned`, then
/// `on_add[cid]` per component — returning the new handle. Factored out of
/// `applyWithObservers`'s `.spawn` arm so an IMMEDIATE spawn that must return
/// a handle (the Etch `world.spawn_with` test-runner surface, M1.0.15) shares
/// the one observer-firing spawn path instead of duplicating it. The handle
/// is valid on return (same tick). Observer-issued structural changes queue
/// into the shared `deferred` buffer (drained at the next flush / tick).
pub fn spawnWithObservers(
self: *ObserverRegistry,
gpa: std.mem.Allocator,
world: *World,
component_ids: []const ComponentId,
payloads: []const []const u8,
) !EntityId {
self.ensureDeferred(gpa, world);
const eid = try world.spawnDynamicWithValues(gpa, component_ids, payloads);
try self.fireList(self.on_spawned, world, eid, null, null, null);
for (component_ids) |cid| {
if (self.on_add.get(cid)) |list| {
const new_ptr: ?*const anyopaque = if (world.componentBytes(eid, cid)) |b| @ptrCast(b.ptr) else null;
try self.fireList(list, world, eid, cid, null, new_ptr);
}
}
return eid;
}

fn fireList(
self: *ObserverRegistry,
list: Listeners,
Expand Down Expand Up @@ -294,15 +321,10 @@ pub fn applyWithObservers(
) !void {
switch (c) {
.spawn => |s| {
const eid = try world.spawnDynamicWithValues(gpa, s.component_ids, s.payloads);
try reg.fireList(reg.on_spawned, world, eid, null, null, null);
for (s.component_ids) |cid| {
if (reg.on_add.get(cid)) |list| {
// Post-apply: the new component value lives in storage.
const new_ptr: ?*const anyopaque = if (world.componentBytes(eid, cid)) |b| @ptrCast(b.ptr) else null;
try reg.fireList(list, world, eid, cid, null, new_ptr);
}
}
// Shares the returning-eid primitive with the immediate
// `world.spawn_with` surface (M1.0.15) — one observer-firing spawn
// path (on_spawned + on_add per component).
_ = try reg.spawnWithObservers(gpa, world, s.component_ids, s.payloads);
},
.despawn => |d| {
// Pre-apply: fire on_remove[cid] for every component the
Expand Down
13 changes: 13 additions & 0 deletions src/core/ecs/world.zig
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,19 @@ pub const World = struct {
try self.observer_registry.dispatchOnSpawned(gpa, self, eid);
}

/// M1.0.15 — immediate spawn with initial values that fires the same
/// observers a deferred `.spawn` flush would (on_spawned + on_add), returning
/// the new handle. Backs the Etch `world.spawn_with` test-runner surface;
/// wraps `ObserverRegistry.spawnWithObservers`.
pub fn spawnWithObservers(
self: *World,
gpa: std.mem.Allocator,
component_ids: []const ComponentId,
payloads: []const []const u8,
) !EntityId {
return self.observer_registry.spawnWithObservers(gpa, self, component_ids, payloads);
}

/// M1.0.6 E6 — register the `on_attach` extension dispatch callback (the Etch
/// bridge supplies the real one; M1.0.6 tests supply a Tier-0 stand-in). One
/// hook per world (last registration wins).
Expand Down
39 changes: 37 additions & 2 deletions src/etch/ast.zig
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,11 @@ pub const ExprKind = enum {
/// `StmtKind.spawn_stmt` since M1.0.12) — the keyword is shared, the token
/// after `spawn` disambiguates; this node is only ever the `(` form.
spawn_struct,
/// `measure { block }` (M1.0.15, §17 erratum) — a wall-clock timing
/// expression yielding a `Duration`. Data indexes `measure_exprs`. Valid only
/// in a test body (E0910 elsewhere); determinism is preserved by confining
/// the sole wall-clock surface to tests.
measure_expr,
};

/// Closed enum of type-node kinds the parser can produce.
Expand Down Expand Up @@ -668,11 +673,14 @@ pub const ConstDecl = struct {
/// Side-slab entry for a top-level `test` block (M1.0.8, `etch-grammar.md`
/// §17: `test_decl = "test" STRING_LITERAL block`). `name` is the interned
/// string-literal label; `body` is a `block_expr` NodeId (the reused
/// block/statement parser). M1.0.8 delivers parse + validate + symbol
/// registration only — there is no execution surface (that is M1.0.9).
/// block/statement parser). M1.0.15 delivers execution: the body is
/// type-checked (sync context) and run by `test_runner.zig`. The annotation
/// range (`@tag`/`@skip`/`@only`) is preserved (M1.0.15 — M1.0.8 discarded it).
pub const TestDecl = struct {
name: StringId,
body: NodeId,
annotations_extra: u32 = 0,
annotations_len: u32 = 0,
};

/// Side-slab entry for a `rule` declaration: params, optional `when`
Expand Down Expand Up @@ -939,6 +947,16 @@ pub const BlockExpr = struct {
value: NodeId,
};

/// `measure { block }` expression (M1.0.15, `etch-grammar.md` §17 erratum). Same
/// storage shape as a `BlockExpr` (statement run + optional trailing value); a
/// distinct node kind so the type-checker gates it (Duration result, test-body
/// only → E0910) and the interpreter times the block on the wall clock.
pub const MeasureExpr = struct {
body_start: u32,
body_len: u32,
value: NodeId,
};

/// `if cond block {else if cond block} [else block]` if expression (M0.8
/// control flow, `etch-grammar.md` §3.2 l.500 / §4.1 l.618). The else-if chain
/// is encoded recursively: `else_branch` is `NodeId.none` (no `else`), a
Expand Down Expand Up @@ -2363,6 +2381,12 @@ pub const AnnotationKind = enum {
// `await entity_event(e, T)` (§18.10). Field-level, event-only,
// Entity-typed, at most one per event (validated in the type-checker).
entity_target,
// M1.0.15 — `test`-only annotations (§17): `@tag(.unit|.integration|.slow|
// .perf)`, `@skip(reason: "...")`, `@only`. Applicability is `.test_` only
// (annotationAppliesTo); args are validated in the test-decl check.
tag,
skip,
only,

pub fn fromName(name: []const u8) AnnotationKind {
if (std.mem.eql(u8, name, "phase")) return .phase;
Expand Down Expand Up @@ -2391,6 +2415,9 @@ pub const AnnotationKind = enum {
if (std.mem.eql(u8, name, "on_spawned")) return .on_spawned;
if (std.mem.eql(u8, name, "on_despawned")) return .on_despawned;
if (std.mem.eql(u8, name, "entity_target")) return .entity_target;
if (std.mem.eql(u8, name, "tag")) return .tag;
if (std.mem.eql(u8, name, "skip")) return .skip;
if (std.mem.eql(u8, name, "only")) return .only;
return .custom;
}

Expand Down Expand Up @@ -2607,6 +2634,7 @@ pub const AstArena = struct {
loop_exprs: std.ArrayListUnmanaged(LoopExpr) = .empty,
string_interps: std.ArrayListUnmanaged(StringInterp) = .empty,
block_exprs: std.ArrayListUnmanaged(BlockExpr) = .empty,
measure_exprs: std.ArrayListUnmanaged(MeasureExpr) = .empty,
if_exprs: std.ArrayListUnmanaged(IfExpr) = .empty,
break_stmts: std.ArrayListUnmanaged(BreakStmt) = .empty,
throw_stmts: std.ArrayListUnmanaged(ThrowStmt) = .empty,
Expand Down Expand Up @@ -2832,6 +2860,7 @@ pub const AstArena = struct {
self.loop_exprs.deinit(gpa);
self.string_interps.deinit(gpa);
self.block_exprs.deinit(gpa);
self.measure_exprs.deinit(gpa);
self.if_exprs.deinit(gpa);
self.break_stmts.deinit(gpa);
self.throw_stmts.deinit(gpa);
Expand Down Expand Up @@ -3548,6 +3577,12 @@ pub const AstArena = struct {
return try self.addExpr(gpa, .block_expr, idx, span);
}

pub fn addMeasureExpr(self: *AstArena, gpa: std.mem.Allocator, body_start: u32, body_len: u32, value: NodeId, span: SourceSpan) !NodeId {
const idx: u32 = @intCast(self.measure_exprs.items.len);
try self.measure_exprs.append(gpa, .{ .body_start = body_start, .body_len = body_len, .value = value });
return try self.addExpr(gpa, .measure_expr, idx, span);
}

pub fn addIfExpr(self: *AstArena, gpa: std.mem.Allocator, cond: NodeId, then_block: NodeId, else_branch: NodeId, let_binding: StringId, span: SourceSpan) !NodeId {
const idx: u32 = @intCast(self.if_exprs.items.len);
try self.if_exprs.append(gpa, .{ .cond = cond, .then_block = then_block, .else_branch = else_branch, .let_binding = let_binding });
Expand Down
3 changes: 3 additions & 0 deletions src/etch/diagnostics.zig
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ pub const DiagnosticCode = enum {
control_flow_escapes_task_branch, // M1.0.12 E3 — E0907 ControlFlowEscapesTaskBranch (break/continue targeting a loop outside the concurrency construct)
event_not_entity_scoped, // M1.0.14 E2 — E0908 EventNotEntityScoped (`await entity_event(e, T)` where T has no `Entity` field)
ambiguous_event_entity_target, // M1.0.14 E2 — E0909 AmbiguousEventEntityTarget (T has multiple `Entity` fields with no `@entity_target`)
measure_outside_test, // M1.0.15 E4 — E0910 MeasureOutsideTest (`measure { … }` outside a test body; wall-clock stays out of deterministic gameplay)

/// Canonical short code, e.g. `"E0001"`.
pub fn code(self: DiagnosticCode) []const u8 {
Expand Down Expand Up @@ -561,6 +562,7 @@ pub const DiagnosticCode = enum {
.control_flow_escapes_task_branch => "E0907",
.event_not_entity_scoped => "E0908",
.ambiguous_event_entity_target => "E0909",
.measure_outside_test => "E0910",
};
}

Expand Down Expand Up @@ -756,6 +758,7 @@ pub const DiagnosticCode = enum {
.control_flow_escapes_task_branch => "ControlFlowEscapesTaskBranch",
.event_not_entity_scoped => "EventNotEntityScoped",
.ambiguous_event_entity_target => "AmbiguousEventEntityTarget",
.measure_outside_test => "MeasureOutsideTest",
};
}
};
Expand Down
Loading