From 66008b2c10bbcf5dd05bd612d345af041ec088d7 Mon Sep 17 00:00:00 2001 From: apotema Date: Sun, 5 Jul 2026 12:26:42 -0300 Subject: [PATCH] feat(packs): exposes surface modules + depends_on wiring (#498 PR 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilt onto the six parallel split-refactors (#539-#549): identical semantics, new homes — scanPack verb-copy in root/pack_scan.zig, surface+depends_on emission in build_files/build_zig.zig, the .all diagnostic in plugin_manifest/pack.zig. pack_root/pack_validate/ pack_refs/tests/docs carried verbatim (untouched by the splits). Includes the review fixes from the first head: @"…" escaping on exposed verb idents both sides, token-sequence .exposes = .all detection (exposesAllShorthand), docs aligned with header-only + targeted-diagnostic behavior. Claude-Session: https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j --- docs/packs.md | 38 ++++++++++++-- src/build_files/build_zig.zig | 54 ++++++++++++++++++- src/codegen/pack_root.zig | 75 +++++++++++++++++++++++++++ src/codegen/scan/pack_refs.zig | 6 +++ src/pack_validate.zig | 28 ++++++++++ src/plugin_manifest/pack.zig | 32 ++++++++++++ src/root.zig | 27 +++++++++- src/root/pack_scan.zig | 43 +++++++++++++++ test/pack_scan_tests.zig | 95 ++++++++++++++++++++++++++++++++++ 9 files changed, 393 insertions(+), 5 deletions(-) diff --git a/docs/packs.md b/docs/packs.md index 0d71f0cb..f55fb90b 100644 --- a/docs/packs.md +++ b/docs/packs.md @@ -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//{components,events,prefabs,hooks,scripts}/`) @@ -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("")` 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("")` → the dep's `exposes` surface | **PR 4** | +| Declared pack deps | `@import("")` → the dep's `exposes` surface (`__surface.zig`) | `depends_on` in `pack.labelle` | What it cannot import — the wall itself: @@ -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("")` 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 diff --git a/src/build_files/build_zig.zig b/src/build_files/build_zig.zig index 90ee6506..c31a8290 100644 --- a/src/build_files/build_zig.zig +++ b/src/build_files/build_zig.zig @@ -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; @@ -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__` (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 `.root_module.addImport("pack__", pack___mod)` diff --git a/src/codegen/pack_root.zig b/src/codegen/pack_root.zig index 5b4f907b..771780a3 100644 --- a/src/codegen/pack_root.zig +++ b/src/codegen/pack_root.zig @@ -45,6 +45,12 @@ pub const PackModule = struct { name: []const u8, /// Sanitized `__` 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`): @@ -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); @@ -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("")` 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 }); + } + 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 }); + } + 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); +} diff --git a/src/codegen/scan/pack_refs.zig b/src/codegen/scan/pack_refs.zig index 14533351..29ebe726 100644 --- a/src/codegen/scan/pack_refs.zig +++ b/src/codegen/scan/pack_refs.zig @@ -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); diff --git a/src/pack_validate.zig b/src/pack_validate.zig index 7b378028..698e27b2 100644 --- a/src/pack_validate.zig +++ b/src/pack_validate.zig @@ -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; + } +} diff --git a/src/plugin_manifest/pack.zig b/src/plugin_manifest/pack.zig index 6f8e6d6a..93c7d044 100644 --- a/src/plugin_manifest/pack.zig +++ b/src/plugin_manifest/pack.zig @@ -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 }, @@ -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; +} diff --git a/src/root.zig b/src/root.zig index 26bdccf9..c4ea5520 100644 --- a/src/root.zig +++ b/src/root.zig @@ -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("")` 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) ──── @@ -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 diff --git a/src/root/pack_scan.zig b/src/root/pack_scan.zig index 6addf4cf..94c6bdff 100644 --- a/src/root/pack_scan.zig +++ b/src/root/pack_scan.zig @@ -183,6 +183,14 @@ pub fn scanPack( // (`"citizens__Worker"` / `"citizens__worker"`). Done against the copied // (destination) files so the source pack tree is never mutated. try rewritePackPrefabRefs(allocator, dst_base, pack_name, component_names, prefab_names); + + // Verb-surface files (RFC §6, #498 PR 4): a pack's root-level + // `queries.zig` / `commands.zig` are copied beside the convention + // dirs so `__pack_root.zig` can re-export them and `__surface.zig` + // can narrow them to the manifest's `exposes` lists. Absent source + // prunes a stale copy (same #496 discipline as pack scripts). + const has_queries = try copyPackRootFile(allocator, pack_src_dir, dst_base, "queries.zig"); + const has_commands = try copyPackRootFile(allocator, pack_src_dir, dst_base, "commands.zig"); // …and rewrite the copied hook sources so a handler written with the pack's // bare local event name receives its `__`-prefixed event (chatgpt-codex // #3). Same "mutate the copy, never the source" discipline. @@ -195,7 +203,42 @@ pub fn scanPack( .event_names = event_names, .prefab_names = prefab_names, .hook_names = hook_names, + .has_queries = has_queries, + .has_commands = has_commands, + }; +} + +/// Copy one root-level pack file (`queries.zig`/`commands.zig`) into the +/// generated pack dir, returning whether it exists. A missing source +/// deletes any stale destination a prior generate copied — without the +/// prune, a removed verb surface would keep compiling from the leftover +/// (labelle-assembler#496's discipline, applied to single files). +fn copyPackRootFile( + allocator: std.mem.Allocator, + pack_src_dir: []const u8, + dst_base: []const u8, + filename: []const u8, +) !bool { + const io = config.globalIo(); + const cwd = std.Io.Dir.cwd(); + const src = try std.fs.path.join(allocator, &.{ pack_src_dir, filename }); + defer allocator.free(src); + const dst = try std.fs.path.join(allocator, &.{ dst_base, filename }); + defer allocator.free(dst); + + const bytes = cwd.readFileAlloc(io, src, allocator, .limited(1024 * 1024)) catch |err| switch (err) { + error.FileNotFound => { + cwd.deleteFile(io, dst) catch {}; + return false; + }, + else => return err, }; + defer allocator.free(bytes); + // A pack may ship ONLY a verb surface (no convention dirs), in which + // case nothing above created the destination dir yet. + try cwd.createDirPath(io, dst_base); + try scanner.writeFile(dst_base, filename, bytes); + return true; } /// Rewrite every copied pack prefab JSONC in place so its local references diff --git a/test/pack_scan_tests.zig b/test/pack_scan_tests.zig index a5288cfd..e7fc81bd 100644 --- a/test/pack_scan_tests.zig +++ b/test/pack_scan_tests.zig @@ -1242,3 +1242,98 @@ pub const FLOW_HANDLER_ROUTING = struct { try std.testing.expect(!contains(main_zig, "script__counter")); } }; + +// ── #498 PR 4: exposes surfaces + depends_on wiring ────────────────── + +pub const PACK_SURFACE = struct { + test "renderSurface re-exports exactly the exposes lists through @import(\"pack\")" { + const src = try generate.pack_root.renderSurface(std.testing.allocator, "citizens", .{ + .queries = &.{ "find_idle_worker", "worker_count" }, + .commands = &.{"assign_job"}, + }); + defer std.testing.allocator.free(src); + + try std.testing.expect(contains(src, "const pack = @import(\"pack\");")); + try std.testing.expect(contains(src, "pub const @\"find_idle_worker\" = pack.queries.@\"find_idle_worker\";")); + try std.testing.expect(contains(src, "pub const @\"worker_count\" = pack.queries.@\"worker_count\";")); + try std.testing.expect(contains(src, "pub const @\"assign_job\" = pack.commands.@\"assign_job\";")); + } + + test "renderSurface with no exposes is a header-only module — dependents can call nothing" { + const src = try generate.pack_root.renderSurface(std.testing.allocator, "props", .{}); + defer std.testing.allocator.free(src); + + try std.testing.expect(contains(src, "Public surface of pack 'props'")); + try std.testing.expect(!contains(src, "@import(\"pack\")")); + try std.testing.expect(!contains(src, "pub const queries")); + try std.testing.expect(!contains(src, "pub const commands")); + } + + test "checkExposesFiles: exposing verbs without the file is a generate-time error" { + try generate.pack_validate.checkExposesFiles("citizens", 2, 0, true, false); + try std.testing.expectError( + error.PackExposesMissingFile, + generate.pack_validate.checkExposesFiles("citizens", 2, 0, false, false), + ); + try std.testing.expectError( + error.PackExposesMissingFile, + generate.pack_validate.checkExposesFiles("citizens", 0, 1, true, false), + ); + } + + test "scanPack copies queries.zig/commands.zig and prunes stale copies" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.createDirPath(io, "src/citizens"); + var pack_src = try tmp.dir.openDir(io, "src/citizens", .{}); + defer pack_src.close(io); + try writeFileIn(pack_src, "queries.zig", "pub fn find_idle(game: anytype) void { _ = game; }\n"); + + const pack_src_path = try tmp.dir.realPathFileAlloc(io, "src/citizens", allocator); + defer allocator.free(pack_src_path); + const target_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(target_path); + + var scan1 = try generate.scanPack(allocator, pack_src_path, target_path, "citizens"); + defer scan1.deinit(allocator); + try std.testing.expect(scan1.has_queries); + try std.testing.expect(!scan1.has_commands); + try tmp.dir.access(io, "packs/citizens/queries.zig", .{}); + + // Source removed → the stale copy is pruned on the next scan. + try pack_src.deleteFile(io, "queries.zig"); + var scan2 = try generate.scanPack(allocator, pack_src_path, target_path, "citizens"); + defer scan2.deinit(allocator); + try std.testing.expect(!scan2.has_queries); + try std.testing.expectError(error.FileNotFound, tmp.dir.access(io, "packs/citizens/queries.zig", .{})); + } + + test "build wiring: every pack gets a surface module importing ONLY its pack; depends_on maps the dep name onto the dep's SURFACE" { + const pack_modules = [_]generate.pack_root.PackModule{ + .{ .name = "citizens", .prefix = "citizens" }, + .{ .name = "production", .prefix = "production", .depends_on = &.{"citizens"} }, + }; + const build_zig = try h.genSokolBuildZigV2(std.testing.allocator, .{ + .name = "test-game", + .backend = .sokol, + .ecs = .mock, + }, .{ .pack_modules = &pack_modules }); + defer std.testing.allocator.free(build_zig); + + // Surface module: rooted at __surface.zig, sole import = the pack. + try std.testing.expect(contains(build_zig, "const pack_surface__citizens_mod = b.createModule(.{")); + try std.testing.expect(contains(build_zig, "b.path(\"packs/citizens/__surface.zig\")")); + try std.testing.expect(contains(build_zig, ".imports = &.{.{ .name = \"pack\", .module = pack__citizens_mod }},")); + // Surface modules are DEMAND-driven: nothing depends_on + // production, so declaring its surface would be an unused-const + // compile error in the generated build.zig. + try std.testing.expect(!contains(build_zig, "pack_surface__production_mod")); + // depends_on: production reaches citizens ONLY through the surface… + try std.testing.expect(contains(build_zig, "overrideImport(pack__production_mod, \"citizens\", pack_surface__citizens_mod);")); + // … never the pack module itself, and never the reverse direction. + try std.testing.expect(!contains(build_zig, "overrideImport(pack__production_mod, \"citizens\", pack__citizens_mod);")); + try std.testing.expect(!contains(build_zig, "overrideImport(pack__citizens_mod, \"production\"")); + } +};