Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
38 changes: 35 additions & 3 deletions docs/packs.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Packs: the module wall and the sanctioned surfaces

*(assembler#498, the enforcement layer of the Packs epic —
labelle-engine#650. Status: PRs 1–3 landed; `exposes`/`depends_on`
surfaces are PR 4; the lint demotion notes are PR 5.)*
labelle-engine#650. Status: PRs 1–4 landed; the lint demotion notes
are PR 5.)*

A **pack** is the light, directory-scanned form of a plugin: a
namespaced subtree (`packs/<name>/{components,events,prefabs,hooks,scripts}/`)
Expand All @@ -29,7 +29,7 @@ What a pack module can import:
| Engine substrate | `labelle-engine`, `labelle-core`, `labelle-gfx`, backend modules, `ecs_backend`/`gui_backend` | shared infrastructure |
| Decl-module plugins | `@import("<plugin>")` by project.labelle name | plugins are the sanctioned inter-domain surface (e.g. FP routes worker access through `worker_controller`) |
| Shared contracts pack | `@import("contracts")` | implicit dependency (`pack_validate.IMPLICIT_DEPS`) |
| Declared pack deps | `@import("<dep>")` → the dep's `exposes` surface | **PR 4** |
| Declared pack deps | `@import("<dep>")` → the dep's `exposes` surface (`__surface.zig`) | `depends_on` in `pack.labelle` |

What it cannot import — the wall itself:

Expand Down Expand Up @@ -58,6 +58,38 @@ Under a root module with no generated views (the tests target,
preview shells) it falls back to a registry of the pack's own
components only — no globals.

## The verb surfaces: `exposes` + `depends_on`

A pack's public API is its root-level `queries.zig` / `commands.zig`,
narrowed by the manifest:

```zig
// packs/citizens/pack.labelle
.{
.name = "citizens",
.manifest_version = 1,
.convention_dirs = .copy_and_scan,
.exposes = .{ .queries = .{"find_idle_worker"} },
}
// packs/production/pack.labelle
.{ …, .depends_on = .{"citizens"} }
// packs/production/scripts/…
const citizens = @import("citizens");
_ = citizens.queries.find_idle_worker(game);
```

`@import("<dep>")` maps to the dep's generated `__surface.zig` — a
module that re-exports **exactly** the `exposes` lists through the
dep's own pack module (its sole import). A `null`/empty `exposes`
yields a header-only surface with no imports at all — dependents can
call nothing. A non-exposed verb is "no member named …" at compile
time; an undeclared dependency is "no module named …". Exposing verbs
from a file the pack doesn't ship fails at generate time with the
manifest named. `.exposes = .all` is deliberately unsupported (an
unbounded surface defeats the wall): the manifest fails to parse and
a targeted diagnostic names the explicit-list fix. `contracts` is the
exception: implicit, full-module, no exposes-narrowing.

## What deliberately stays open

- **`game.ComponentRegistry` through the `anytype` `game` param** still
Expand Down
54 changes: 53 additions & 1 deletion src/build_files/build_zig.zig
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,9 @@ fn emitPackModules(
}
// Implicit `contracts` wiring — dependents reach the shared-vocabulary
// pack as `@import("contracts")`. `contracts` itself must not
// self-import.
// self-import. Deliberately the FULL pack module, not a surface:
// exposes-narrowing doesn't apply to the shared-vocabulary pack
// (`pack_validate.IMPLICIT_DEPS`).
const contracts: ?pack_root.PackModule = for (pack_modules) |p| {
if (std.mem.eql(u8, p.name, pack_root.CONTRACTS_PACK_NAME)) break p;
} else null;
Expand All @@ -212,6 +214,56 @@ fn emitPackModules(
try w.print(" overrideImport(pack__{s}_mod, \"contracts\", pack__{s}_mod);\n", .{ p.prefix, c.prefix });
}
}

// Surface modules (#498 PR 4): rooted at the generated
// `__surface.zig`, sole import = the pack module itself (as "pack" —
// the same self-name the pack's own code uses). Declared ON DEMAND:
// only packs some sibling actually `depends_on` get a module var —
// an unconditionally-declared one that nothing references would be
// Zig's "unused local constant" compile error in the generated
// build.zig (the `__surface.zig` FILE is still written for every
// pack, so adding a dependent later changes only this wiring).
var any_surface = false;
for (pack_modules) |p| {
const depended = blk: {
for (pack_modules) |q| {
for (q.depends_on) |dep| {
if (std.mem.eql(u8, dep, pack_root.CONTRACTS_PACK_NAME)) continue;
if (std.mem.eql(u8, dep, p.name)) break :blk true;
}
}
break :blk false;
};
if (!depended) continue;
if (!any_surface) {
try w.writeAll(" // Pack `exposes` surfaces (#498 PR 4): the only face a dependent sees.\n");
any_surface = true;
}
try w.print(" const pack_surface__{s}_mod = b.createModule(.{{\n", .{p.prefix});
try w.print(" .root_source_file = b.path(\"packs/{s}/__surface.zig\"),\n", .{p.name});
try w.writeAll(" .target = target,\n");
try w.writeAll(" .optimize = optimize,\n");
try w.print(" .imports = &.{{.{{ .name = \"pack\", .module = pack__{s}_mod }}}},\n", .{p.prefix});
try w.writeAll(" });\n");
}

// `depends_on` wiring (#498 PR 4): a dependency that names a sibling
// PACK maps the dep's plain name onto its SURFACE module — dependents
// never see `pack__<prefix>` (whose root re-exports the private
// internals). Entries naming decl-module plugins are already in every
// pack module's import table; `contracts` was wired above as the
// implicit full module.
for (pack_modules) |p| {
for (p.depends_on) |dep| {
if (std.mem.eql(u8, dep, pack_root.CONTRACTS_PACK_NAME)) continue;
const dep_pack: ?pack_root.PackModule = for (pack_modules) |q| {
if (std.mem.eql(u8, q.name, dep)) break q;
} else null;
if (dep_pack) |d| {
try w.print(" overrideImport(pack__{s}_mod, \"{s}\", pack_surface__{s}_mod);\n", .{ p.prefix, dep, d.prefix });
}
}
}
}

/// Emit `<artifact>.root_module.addImport("pack__<prefix>", pack__<prefix>_mod)`
Expand Down
75 changes: 75 additions & 0 deletions src/codegen/pack_root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ pub const PackModule = struct {
name: []const u8,
/// Sanitized `<pack>__` ident prefix (owned by the caller).
prefix: []const u8,
/// The manifest's `depends_on` (#498 PR 4): each entry that names a
/// sibling PACK gets that pack's `__surface.zig` module wired under
/// the dep's plain name; entries naming decl-module plugins are
/// already in the table; `contracts` is the implicit full-module
/// import. Aliases manifest-owned strings.
depends_on: []const []const u8 = &.{},
};

/// The implicit shared-contracts pack name (`pack_validate.IMPLICIT_DEPS`):
Expand Down Expand Up @@ -150,6 +156,12 @@ pub fn renderPackRoot(
try w.writeAll("};\n\n");
}

// Verb surfaces (RFC §6, #498 PR 4): raw re-exports for the pack's
// OWN code; dependents get the `exposes`-narrowed `__surface.zig`.
if (pack.has_queries) try w.writeAll("pub const queries = @import(\"queries.zig\");\n");
if (pack.has_commands) try w.writeAll("pub const commands = @import(\"commands.zig\");\n");
if (pack.has_queries or pack.has_commands) try w.writeAll("\n");

// ── Registry bridge (#498 PR 3) ────────────────────────────────
var prefix_buf: [128]u8 = undefined;
const prefix = scan.packNamespacePrefix(pack.name, &prefix_buf);
Expand Down Expand Up @@ -184,3 +196,66 @@ pub fn renderPackRoot(
errdefer arr_list.deinit(allocator);
return arr_list.toOwnedSlice(allocator);
}

/// The `exposes` lists as the surface renderer consumes them — a
/// decoupled mirror of `plugin_manifest.PackExposes` so this module
/// never imports the manifest parser.
pub const SurfaceExposes = struct {
queries: []const []const u8 = &.{},
commands: []const []const u8 = &.{},
};

/// Render the pack's `__surface.zig` — the ONLY thing a dependent pack
/// can import (`@import("<dep>")` maps here, #498 PR 4). Its sole
/// import is the pack module itself (`@import("pack")`), and it
/// re-exports EXACTLY the manifest's `exposes` lists: a listed-but-
/// missing verb fails compilation with an error pointing at this file;
/// a `null`/empty `exposes` yields a header-only module — dependents
/// can call nothing, the correct default.
pub fn renderSurface(
allocator: std.mem.Allocator,
pack_name: []const u8,
exposes: SurfaceExposes,
) ![]const u8 {
var alloc_writer: std.Io.Writer.Allocating = .init(allocator);
errdefer alloc_writer.deinit();
const w = &alloc_writer.writer;

try w.print(
\\//! Generated by labelle-assembler — DO NOT EDIT.
\\//! Public surface of pack '{s}' (`exposes`, RFC §6 / #498 PR 4).
\\//!
\\//! Dependent packs import THIS module under the pack's name; it
\\//! re-exports exactly the manifest's `exposes` lists. Anything
\\//! not listed here does not exist to dependents.
\\
\\
, .{pack_name});

if (exposes.queries.len > 0 or exposes.commands.len > 0) {
try w.writeAll("const pack = @import(\"pack\");\n\n");
}
if (exposes.queries.len > 0) {
try w.writeAll("pub const queries = struct {\n");
for (exposes.queries) |name| {
// @"" escaping: a manifest may expose a verb whose name is a
// Zig keyword or needs escaping (declared as `pub fn @"…"`);
// the escaped form is valid for plain identifiers too.
try w.print(" pub const @\"{s}\" = pack.queries.@\"{s}\";\n", .{ name, name });
}
Comment on lines +240 to +245

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The exposed query names are directly interpolated as Zig identifiers in the generated __surface.zig file. If a manifest defines an exposed name that is a reserved Zig keyword (like const or fn) or contains special characters, the generated code will fail to compile. Using Zig's @"" identifier escaping syntax ensures that any valid string can be safely re-exported without causing syntax errors.

        for (exposes.queries) |name| {
            try w.print("    pub const @\"{s}\" = pack.queries.@\"{s}\";\n", .{ name, name });
        }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 47e5c68 — see commit message for the specifics (escaped idents both sides / token-sequence scan / docs aligned with the header-only + targeted-diagnostic behavior).

try w.writeAll("};\n");
}
if (exposes.commands.len > 0) {
if (exposes.queries.len > 0) try w.writeAll("\n");
try w.writeAll("pub const commands = struct {\n");
for (exposes.commands) |name| {
try w.print(" pub const @\"{s}\" = pack.commands.@\"{s}\";\n", .{ name, name });
}
Comment on lines +251 to +253

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The exposed command names are directly interpolated as Zig identifiers in the generated __surface.zig file. If a manifest defines an exposed name that is a reserved Zig keyword (like const or fn) or contains special characters, the generated code will fail to compile. Using Zig's @"" identifier escaping syntax ensures that any valid string can be safely re-exported without causing syntax errors.

        for (exposes.commands) |name| {
            try w.print("    pub const @\"{s}\" = pack.commands.@\"{s}\";\n", .{ name, name });
        }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 47e5c68 — see commit message for the specifics (escaped idents both sides / token-sequence scan / docs aligned with the header-only + targeted-diagnostic behavior).

try w.writeAll("};\n");
}

var arr_list = alloc_writer.toArrayList();
// Same reset-writer rationale as `renderPackRoot`.
errdefer arr_list.deinit(allocator);
return arr_list.toOwnedSlice(allocator);
}
6 changes: 6 additions & 0 deletions src/codegen/scan/pack_refs.zig
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ pub const PackScan = struct {
/// prefix so two packs shipping `overlay.zig` don't collide on the import
/// alias / receiver-instance identifier.
hook_names: []const []const u8 = &.{},
/// True when the pack ships a root-level `queries.zig` / `commands.zig`
/// (RFC §6 verb surfaces, #498 PR 4). Copied beside the convention dirs;
/// `__pack_root.zig` re-exports them and `__surface.zig` narrows them to
/// the manifest's `exposes` lists.
has_queries: bool = false,
has_commands: bool = false,

pub fn deinit(self: *PackScan, allocator: std.mem.Allocator) void {
allocator.free(self.name);
Expand Down
28 changes: 28 additions & 0 deletions src/pack_validate.zig
Original file line number Diff line number Diff line change
Expand Up @@ -478,3 +478,31 @@ test "validate: diamond DAG (shared lower dep) is acyclic" {
const declared = [_][]const u8{ "a", "b", "c", "d" };
try validate(testing.allocator, &packs, &declared);
}

/// #498 PR 4: a manifest exposing verbs from a file the pack doesn't
/// ship fails at GENERATE time with the manifest named — otherwise the
/// dependent's eventual compile error points at generated code instead
/// of the author's mistake. Called per pack from `generate()`; split
/// out for direct unit testing.
pub fn checkExposesFiles(
pack_name: []const u8,
exposes_queries: usize,
exposes_commands: usize,
has_queries: bool,
has_commands: bool,
) error{PackExposesMissingFile}!void {
if (exposes_queries > 0 and !has_queries) {
std.log.warn(
"labelle: pack '{s}' exposes queries but ships no queries.zig — add packs/{s}/queries.zig or drop the exposes.queries list",
.{ pack_name, pack_name },
);
return error.PackExposesMissingFile;
}
if (exposes_commands > 0 and !has_commands) {
std.log.warn(
"labelle: pack '{s}' exposes commands but ships no commands.zig — add packs/{s}/commands.zig or drop the exposes.commands list",
.{ pack_name, pack_name },
);
return error.PackExposesMissingFile;
}
}
32 changes: 32 additions & 0 deletions src/plugin_manifest/pack.zig
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,19 @@ pub fn loadPackFromDir(
const parsed = std.zon.parse.fromSliceAlloc(ZonPackManifest, allocator, raw_z, null, .{
.ignore_unknown_fields = true,
}) catch |err| {
// Targeted diagnostic for the RFC's scalar shorthand (#498 PR 4):
// `.exposes = .all` is deliberately unsupported — an unbounded
// surface defeats the wall. The generic ZON error for it is
// opaque ("expected struct"), so name the fix. Token-sequence
// scan (`.exposes` ws `=` ws `.all`) rather than two independent
// substring hits, so unrelated parse failures in manifests that
// merely MENTION either token don't get the misleading hint.
if (exposesAllShorthand(raw_bytes)) {
std.log.warn(
"labelle: pack '{s}': `.exposes = .all` is not supported — list queries/commands explicitly (`.exposes = .{{ .queries = .{{ \"...\" }} }}`). The shared `contracts` pack is implicit and needs no exposes.",
.{expected_name},
);
}
std.log.warn(
"labelle: failed to parse pack.labelle for pack '{s}' at {s}\n parser error: {any}\n see docs/RFC-packs.md for the pack manifest schema\n",
.{ expected_name, manifest_path, err },
Expand Down Expand Up @@ -543,3 +556,22 @@ fn writeManifestFile(tmp_dir: std.Io.Dir, body: []const u8) !void {
defer f.close(testing.io);
try f.writeStreamingAll(testing.io, body);
}

/// True when `bytes` contains the literal token sequence
/// `.exposes = .all` (arbitrary whitespace around `=`). Deliberately
/// simple — commented-out occurrences can still match, but the hint is
/// only ever printed AFTER a real parse failure, so the worst case is a
/// redundant-but-related line above the real error.
fn exposesAllShorthand(bytes: []const u8) bool {
var search: []const u8 = bytes;
while (std.mem.indexOf(u8, search, ".exposes")) |i| {
var rest = search[i + ".exposes".len ..];
rest = std.mem.trimStart(u8, rest, " \t\r\n");
if (rest.len > 0 and rest[0] == '=') {
rest = std.mem.trimStart(u8, rest[1..], " \t\r\n");
if (std.mem.startsWith(u8, rest, ".all")) return true;
}
search = search[i + 1 ..];
}
return false;
}
27 changes: 26 additions & 1 deletion src/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,26 @@ pub fn generate(
const rel = try std.fs.path.join(allocator, &.{ "packs", pack.name, "__pack_root.zig" });
defer allocator.free(rel);
try scanner.writeFile(target_dir, rel, pack_root_src);

// `__surface.zig` (#498 PR 4): the exposes-narrowed module a
// dependent's `@import("<this pack>")` maps to. Validated here so
// a manifest exposing verbs from a file the pack doesn't ship
// fails BEFORE any build, with the manifest named — the compile
// error a dependent would eventually hit points at generated
// code instead of the author's mistake.
const exposes: pack_root_gen.SurfaceExposes = blk: {
const manifest = for (pack_entries.items) |e| {
if (std.mem.eql(u8, e.plugin.name, pack.name)) break e.manifest;
} else unreachable; // pack_scans is built FROM pack_entries
const ex = manifest.exposes orelse break :blk .{};
try pack_validate.checkExposesFiles(pack.name, ex.queries.len, ex.commands.len, pack.has_queries, pack.has_commands);
break :blk .{ .queries = ex.queries, .commands = ex.commands };
};
const surface_src = try pack_root_gen.renderSurface(allocator, pack.name, exposes);
defer allocator.free(surface_src);
const surface_rel = try std.fs.path.join(allocator, &.{ "packs", pack.name, "__surface.zig" });
defer allocator.free(surface_rel);
try scanner.writeFile(target_dir, surface_rel, surface_src);
}

// ── Module-plugin filter — light packs are dir-scan-only (#481) ────
Expand Down Expand Up @@ -968,7 +988,12 @@ pub fn generate(
for (pack_scans.items) |pack| {
var pfx_buf: [128]u8 = undefined;
const pfx = scan.packNamespacePrefix(pack.name, &pfx_buf);
pack_modules.appendAssumeCapacity(.{ .name = pack.name, .prefix = try allocator.dupe(u8, pfx) });
// depends_on aliases the manifest's strings — safe: `pack_entries`'
// cleanup defer was declared before this list's, so it runs after.
const depends_on: []const []const u8 = for (pack_entries.items) |e| {
if (std.mem.eql(u8, e.plugin.name, pack.name)) break e.manifest.depends_on;
} else &.{};
pack_modules.appendAssumeCapacity(.{ .name = pack.name, .prefix = try allocator.dupe(u8, pfx), .depends_on = depends_on });
}

// Generate build.zig
Expand Down
Loading
Loading