From f7996128941072ca110f4c5312b7911de7c29090 Mon Sep 17 00:00:00 2001 From: apotema Date: Wed, 1 Jul 2026 14:07:18 -0300 Subject: [PATCH 1/2] =?UTF-8?q?packs:=20light=20pack=20builds=20end-to-end?= =?UTF-8?q?=20=E2=80=94=20filter=20module-less=20packs=20from=20module=20w?= =?UTF-8?q?iring=20(#481)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A light pack (module-less, carrying pack.labelle) is declared in project.labelle .plugins just like a real plugin, but ships no Zig module — only convention dirs scanned + __-namespaced into the unified registries (#439/#440). The plugin-list/build wiring still treated every .plugins entry as a decl-module plugin, emitting @import("") in main.zig and a b.dependency("labelle_", …) in build.zig/.zon that a module-less pack cannot resolve, so labelle build failed. Add root.declModulePlugins: splits the declared .plugins list into the decl-module subset (kept) vs light packs (dropped, name in pack_entries). generate() now runs the whole module-emitting phase against a cfg copy (cfg_modules) whose .plugins excludes light packs, so no pack import or build dep is emitted; the pack's contribution stays the already-scanned pack_scans registry entries. Audited + switched every module-emitting site: build.zig.zon deps, build.zig module graph, main.zig ComponentRegistryWithPlugins / SystemRegistry / plugin controllers, plugin events + flow-decl discovery. Project-description sidecars (flow catalog, feature manifest) keep the full cfg. Closes #481, Part of #651 --- src/root.zig | 101 +++++++++++++++++++++++++++-- test/pack_scan_tests.zig | 135 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 231 insertions(+), 5 deletions(-) diff --git a/src/root.zig b/src/root.zig index 9174b1b2..0e4bf9ed 100644 --- a/src/root.zig +++ b/src/root.zig @@ -579,6 +579,46 @@ fn scanPackSubdir( return scanner.copyAndScanAbs(allocator, src, dst, ext); } +/// Select the decl-module subset of `plugins` (labelle-assembler#481). +/// +/// A game's `.plugins` list mixes two kinds of dependency: +/// +/// - **decl-module plugins** — a real Zig package (`plugin.labelle` + +/// `src/root.zig` exports) that the generated build wires in as a module +/// and the generated `main.zig` reaches via `@import("")`. +/// - **light packs** — module-less, directory-scanned convention bundles +/// (they carry `pack.labelle`, so their name appears in `pack_names`). +/// A light pack has NO `build.zig` and NO importable module; its entire +/// contribution is the already-scanned + `__`-namespaced registry +/// entries (#439/#440). Emitting `@import("")` or a +/// `b.dependency("labelle_", …)` for one fails to resolve and breaks +/// `labelle build` — the gap this ticket closes. +/// +/// Returns the subset of `plugins` whose name is NOT in `pack_names`, i.e. the +/// plugins that DO ship an importable module. The order of `plugins` is +/// preserved. The returned slice is caller-owned (`allocator.free`); the +/// `PluginDep` elements are shallow copies that alias the input's strings. +pub fn declModulePlugins( + allocator: std.mem.Allocator, + plugins: []const config.PluginDep, + pack_names: []const []const u8, +) ![]config.PluginDep { + var out: std.ArrayList(config.PluginDep) = .empty; + errdefer out.deinit(allocator); + try out.ensureTotalCapacity(allocator, plugins.len); + for (plugins) |plugin| { + var is_light_pack = false; + for (pack_names) |pack_name| { + if (std.mem.eql(u8, pack_name, plugin.name)) { + is_light_pack = true; + break; + } + } + if (!is_light_pack) out.appendAssumeCapacity(plugin); + } + return out.toOwnedSlice(allocator); +} + /// Scan a pack's convention subdirs (Packs RFC §4, labelle-assembler#439). /// /// Copies `/{components,events,prefabs,hooks}/` into @@ -1272,6 +1312,42 @@ pub fn generate( pack_scans.items, ); + // ── Module-plugin filter — light packs are dir-scan-only (#481) ──── + // + // A *light pack* (module-less, carrying `pack.labelle`) contributes to + // the build ONLY via the dir-scan registry entries collected above + // (`pack_scans`): its components/events/prefabs/hooks are already + // copied, `__`-namespaced (#440), validated (#441), and wired + // into the SAME unified registries the game root feeds (#439). It ships + // NO importable Zig module and has NO `build.zig`, so it must NOT appear + // in any codegen/build site that emits `@import("")` or a + // `b.dependency("labelle_", …)` — the ComponentRegistryWithPlugins + // / SystemRegistry args, plugin controllers/events, the build.zig module + // graph, and the build.zig.zon deps. Emitting either for a module-less + // pack fails to resolve and breaks `labelle build` (the #481 gap). + // + // Only genuine decl-module plugins (a `plugin.labelle` with `src/root.zig` + // exports) get the module import + build dep. Every light pack carries a + // `pack.labelle` and therefore appears in `pack_entries` (parsed near the + // top of `generate()`), so membership there IS the light-pack predicate. + // + // Build a filtered plugin list and run the module-emitting phase against + // a `cfg` copy whose `.plugins` excludes light packs (`cfg_modules`). The + // scan/copy loops that need the FULL declared list already ran above, and + // the project-description sidecars (flow catalog, feature manifest) keep + // the original `cfg` so they still describe every declared plugin/pack. + var pack_names_for_filter: std.ArrayList([]const u8) = .empty; + defer pack_names_for_filter.deinit(allocator); + try pack_names_for_filter.ensureTotalCapacity(allocator, pack_entries.items.len); + for (pack_entries.items) |e| pack_names_for_filter.appendAssumeCapacity(e.plugin.name); + + const module_plugins = try declModulePlugins(allocator, cfg.plugins, pack_names_for_filter.items); + defer allocator.free(module_plugins); + // A `cfg` view whose `.plugins` is the decl-module subset. Passed to every + // site that emits a Zig-module import or a build-file module dependency. + var cfg_modules = cfg; + cfg_modules.plugins = module_plugins; + // ── Flow-node discovery (RFC-FLOW-VOCABULARY phase 2) ────────────── // // Discover `pub const FlowNodes` + `pub const PinStyles` + @@ -1301,7 +1377,9 @@ pub fn generate( defer allocator.free(scripts_target_for_flow); var plugin_flow_decls = try main_zig.discoverPluginFlowDecls( allocator, - cfg, + // Light packs contribute no decl-module `FlowNodes` (they have no + // `src/root.zig`); walk only the decl-module plugins (#481). + cfg_modules, game_dir, scripts_target_for_flow, script_scan.getEntries(), @@ -1327,7 +1405,9 @@ pub fn generate( defer flow_result.deinit(); // Generate build.zig.zon - const zon = try build_files.generateBuildZigZon(allocator, cfg, target_dir, output_dir, game_dir, .{ + // `cfg_modules` (not `cfg`): a light pack has no `build.zig`/module, so it + // must not become a `.labelle_ = .{ .path }` dep (#481). + const zon = try build_files.generateBuildZigZon(allocator, cfg_modules, target_dir, output_dir, game_dir, .{ // The tests target runs second — additive merge so the exe // target's deps (chosen-backend, plugins) survive. Issue #83. .recreate_deps = !is_tests_target, @@ -1348,7 +1428,10 @@ pub fn generate( defer main_zig.freePromotedScripts(allocator, promoted_scripts); // Generate build.zig - const build_zig = try build_files.generateBuildZig(allocator, cfg, .{ + // `cfg_modules` (not `cfg`): light packs get no `b.dependency` / + // `overrideImport` module wiring — their contribution is dir-scan + // registry entries, not an importable module (#481). + const build_zig = try build_files.generateBuildZig(allocator, cfg_modules, .{ .is_tests_target = is_tests_target, .promoted_scripts = promoted_scripts, // Manifest-driven backend splice (assembler#378): pass the project @@ -1379,7 +1462,11 @@ pub fn generate( // the file header on `main_zig.writePluginEventsBlock` for why the // builtin `@Union(.auto, …)` path was retired (zero-field result is // uninstantiable, breaks the plugin-controllers example). - var plugin_events = try main_zig.discoverPluginEvents(allocator, cfg, game_dir); + // `cfg_modules`: only decl-module plugins have a `src/root.zig` with an + // `Events` block; a light pack's events arrive via `pack_scans`, already + // namespaced, so it contributes nothing here and must not be resolved as a + // module (#481). + var plugin_events = try main_zig.discoverPluginEvents(allocator, cfg_modules, game_dir); defer plugin_events.deinit(); // Emit the `game.zig` shim — a tiny re-export module that surfaces @@ -1524,7 +1611,11 @@ pub fn generate( const main_zig_content = try main_zig.generateMainZigFromTemplate( allocator, engine_template, - cfg, + // `cfg_modules`: the plugin `@import` sites inside main.zig + // (ComponentRegistryWithPlugins / SystemRegistry / plugin + // controllers) must skip light packs — their items are wired in + // via `main_template.pack_scans` above, not a module import (#481). + cfg_modules, backend_tmpl, merged_entries, prefab_names, diff --git a/test/pack_scan_tests.zig b/test/pack_scan_tests.zig index 0bfa179e..df537de5 100644 --- a/test/pack_scan_tests.zig +++ b/test/pack_scan_tests.zig @@ -430,3 +430,138 @@ pub const PACK_EMISSION = struct { try std.testing.expect(!contains(main_zig, "packs/")); } }; + +// ── #481: a light pack builds end-to-end (module-less, dir-scan only) ──── +// +// A light pack is declared in `project.labelle` `.plugins` just like a real +// plugin, but it ships NO Zig module — only convention dirs. `generate()` +// therefore splits the declared `.plugins` list into decl-module plugins +// (which get `@import("")` + a `b.dependency("labelle_", …)`) and +// light packs (which contribute ONLY the already-scanned `pack_scans` registry +// entries). The split is `root.declModulePlugins`; these tests exercise that +// selection AND prove the two emission consequences: the pack is never +// imported / never a build dep, yet its items ARE registered. + +pub const LIGHT_PACK_MODULE_FILTER = struct { + test "declModulePlugins drops light packs and keeps decl-module plugins, order preserved" { + const plugins = [_]generate.PluginDep{ + .{ .name = "physics", .repo = "github:x/physics" }, + .{ .name = "citizens", .repo = "@packs/citizens" }, // light pack + .{ .name = "combat", .repo = "@libs/combat" }, + .{ .name = "biomes", .repo = "@packs/biomes" }, // light pack + }; + const pack_names = [_][]const u8{ "citizens", "biomes" }; + + const kept = try generate.declModulePlugins(std.testing.allocator, &plugins, &pack_names); + defer std.testing.allocator.free(kept); + + try std.testing.expectEqual(@as(usize, 2), kept.len); + try std.testing.expectEqualStrings("physics", kept[0].name); + try std.testing.expectEqualStrings("combat", kept[1].name); + } + + test "declModulePlugins is a no-op when there are no packs" { + const plugins = [_]generate.PluginDep{ + .{ .name = "physics", .repo = "github:x/physics" }, + }; + const kept = try generate.declModulePlugins(std.testing.allocator, &plugins, &.{}); + defer std.testing.allocator.free(kept); + try std.testing.expectEqual(@as(usize, 1), kept.len); + try std.testing.expectEqualStrings("physics", kept[0].name); + } + + test "declModulePlugins drops a game whose ONLY plugin is a light pack" { + const plugins = [_]generate.PluginDep{ + .{ .name = "citizens", .repo = "@packs/citizens" }, + }; + const pack_names = [_][]const u8{"citizens"}; + const kept = try generate.declModulePlugins(std.testing.allocator, &plugins, &pack_names); + defer std.testing.allocator.free(kept); + try std.testing.expectEqual(@as(usize, 0), kept.len); + } +}; + +pub const LIGHT_PACK_BUILDS_END_TO_END = struct { + // A game declaring a decl-module plugin (`physics`) AND a light pack + // (`citizens`). This mirrors the `project.labelle` `.plugins` list that + // reaches `generate()`. + const declared_plugins = [_]generate.PluginDep{ + .{ .name = "physics", .repo = "github:x/physics" }, + .{ .name = "citizens", .repo = "@packs/citizens" }, + }; + const pack_names = [_][]const u8{"citizens"}; + + // The pack's scanned contribution (what #439/#440 produced from its + // convention dirs). This is what a light pack adds to the build INSTEAD of + // a module. + const citizens_scan: generate.PackScan = .{ + .name = "citizens", + .import_prefix = "packs/citizens", + .component_names = &.{"Worker"}, + .event_names = &.{"worker_died"}, + .prefab_names = &.{"worker"}, + .hook_names = &.{}, + }; + + // A main.zig template that emits every site that either (a) iterates the + // plugin list to write `@import("")` — the + // ComponentRegistryWithPlugins / SystemRegistry args AND the + // plugin-controllers `_plugin_mods` tuple — or (b) registers a pack's + // scanned items (component registry field, event import, prefab embed). + const registries_tmpl = + "{{component_registry_block}}\n{{system_registry_block}}\n{{event_imports_block}}\n{{jsonc_scene_block}}\n{{lifecycle}}"; + + test "main.zig imports the decl-module plugin but NOT the light pack, yet registers the pack's items" { + // Run the SAME split `generate()` runs: declared `.plugins` → the + // decl-module subset. + const module_plugins = try generate.declModulePlugins(std.testing.allocator, &declared_plugins, &pack_names); + defer std.testing.allocator.free(module_plugins); + + const cfg: generate.ProjectConfig = .{ + .y_axis = .up, + .name = "test-game", + .backend = .raylib, + .ecs = .mock, + .plugins = module_plugins, // light pack already filtered out + }; + + const main_zig = try genWithPack(registries_tmpl, cfg, citizens_scan); + defer std.testing.allocator.free(main_zig); + + // The decl-module plugin IS imported (registry args + controllers). + try std.testing.expect(contains(main_zig, "@import(\"physics\")")); + + // The light pack is NEVER imported — a module-less pack has nothing to + // `@import`. This is the core #481 fix: emitting `@import("citizens")` + // here is exactly what broke `labelle build`. + try std.testing.expect(!contains(main_zig, "@import(\"citizens\")")); + + // …yet the pack's component/event/prefab ARE compiled in, via the + // dir-scan `pack_scans` path, under the invisible `__` namespace. + try std.testing.expect(contains(main_zig, ".citizens__Worker = @import(\"packs/citizens/components/Worker.zig\").Worker,")); + try std.testing.expect(contains(main_zig, "@import(\"packs/citizens/events/worker_died.zig\")")); + try std.testing.expect(contains(main_zig, "@embedFile(\"packs/citizens/prefabs/worker.jsonc\")")); + } + + test "build.zig wires a module dep for the decl-module plugin but NOT the light pack" { + const module_plugins = try generate.declModulePlugins(std.testing.allocator, &declared_plugins, &pack_names); + defer std.testing.allocator.free(module_plugins); + + const build_zig = try h.genSokolBuildZig(std.testing.allocator, .{ + .name = "test-game", + .backend = .sokol, + .ecs = .mock, + .plugins = module_plugins, // light pack already filtered out + }, .{}); + defer std.testing.allocator.free(build_zig); + + // The decl-module plugin gets its `b.dependency` + module decl… + try std.testing.expect(contains(build_zig, "b.dependency(\"labelle_physics\"")); + try std.testing.expect(contains(build_zig, "plugin_physics_mod")); + + // …while the light pack has NO build dep / module wiring — a + // module-less pack ships no `build.zig` for `b.dependency` to point at. + try std.testing.expect(!contains(build_zig, "labelle_citizens")); + try std.testing.expect(!contains(build_zig, "plugin_citizens_mod")); + } +}; From a4ad56f0956180358933aa420925ffbeee0491f6 Mon Sep 17 00:00:00 2001 From: apotema Date: Wed, 1 Jul 2026 14:32:06 -0300 Subject: [PATCH 2/2] packs: reject pack.labelle on a decl-module plugin (CodeRabbit #482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A light pack (pack.labelle) and a decl-module plugin (plugin.labelle + build.zig / src/root.zig) are mutually exclusive by design: a pack is module-less and contributes only its scanned convention dirs, while a decl-module plugin is wired in as an importable module. declModulePlugins() in root.zig treats "carries pack.labelle" as the sole light-pack predicate and DROPS every such plugin from the module wiring (@import / b.dependency). A plugin that erroneously carried BOTH a pack.labelle AND decl-module content would be silently stripped of its module wiring — a confusing failure the CodeRabbit review flagged. Add a generate-time guard in loadPackFromDir(): when a dir carries a pack.labelle, reject it with error.PackAndPluginManifestConflict if it ALSO ships any decl-module signal (plugin.labelle, build.zig, or src/root.zig). This keeps declModulePlugins fed a clean either/or split. Tests: pack + each of the three decl-module signals -> rejected; a clean light pack and a clean decl-module plugin both still load. --- src/plugin_manifest.zig | 201 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) diff --git a/src/plugin_manifest.zig b/src/plugin_manifest.zig index d68c56b6..5065618e 100644 --- a/src/plugin_manifest.zig +++ b/src/plugin_manifest.zig @@ -247,6 +247,41 @@ pub fn loadPackFromDir( return error.PackManifestUnknownVersion; } + // ── Mutual-exclusivity guard: pack XOR decl-module plugin (CodeRabbit, #481) ─ + // + // A `pack.labelle` marks a *light pack*: a module-less, directory-scanned + // convention bundle whose entire contribution is its scanned/namespaced + // registry entries. A *decl-module plugin* is the opposite: a real Zig + // package that the generated build wires in as a module and `main.zig` + // reaches via `@import("")`. The `declModulePlugins` filter in + // root.zig treats "carries pack.labelle" as the sole light-pack predicate + // and DROPS every such plugin from the module wiring. If a plugin + // erroneously carried BOTH a `pack.labelle` AND decl-module content, it + // would be silently stripped of its module wiring — a confusing failure. + // + // The two forms are mutually exclusive by design (a pack is module-less), + // so reject the conflict here, at load time, before `pack_entries` is even + // built — that keeps `declModulePlugins` fed a clean either/or split. + // + // Detection basis: presence of ANY decl-module signal in the same dir — + // - `plugin.labelle` — the decl-module plugin manifest (the clearest + // signal; the two manifests must never coexist), OR + // - `build.zig` — a decl-module plugin ships a build script; a light + // pack ships none, OR + // - `src/root.zig` — the importable module's exports. + // Any one is sufficient; a clean light pack has none of them. + if (try packDirHasDeclModuleContent(allocator, pack_dir)) { + std.log.warn( + "labelle: pack '{s}' at {s} carries a pack.labelle but ALSO ships decl-module content\n" ++ + " (a plugin.labelle, a build.zig, or a src/root.zig).\n" ++ + " A light pack (pack.labelle) and a decl-module plugin are mutually exclusive:\n" ++ + " a pack is module-less and contributes only its scanned convention dirs.\n" ++ + " Use ONE form — either a pack.labelle OR a decl-module plugin, not both.\n", + .{ expected_name, pack_dir }, + ); + return error.PackAndPluginManifestConflict; + } + return PackManifest{ .name = parsed.name, .manifest_version = parsed.manifest_version, @@ -269,6 +304,10 @@ pub fn loadPackFromDir( // error.PluginManifestUnsafeDirName — convention_dir name is not a safe relative segment // error.PluginManifestMissingExtension — copy_and_scan entry omitted its required extension // error.PluginManifestUnknownVersion — manifest_version is < 1 or > what we support +// +// The pack-manifest path (`loadPackFromDir`) additionally raises: +// error.PackAndPluginManifestConflict — a pack.labelle dir ALSO ships +// decl-module content (mutually exclusive) /// Read and parse `plugin.labelle` for the given plugin if it exists. /// @@ -432,6 +471,40 @@ pub fn loadFromDir( }; } +/// Returns true iff `pack_dir` contains any decl-module-plugin signal, +/// i.e. content that only a real Zig-package plugin (not a light pack) +/// would ship. Used by the pack/plugin mutual-exclusivity guard. +/// +/// Signals (any one is sufficient): +/// - `plugin.labelle` — the decl-module plugin manifest +/// - `build.zig` — a plugin's build script +/// - `src/root.zig` — the importable module's exports +/// +/// A clean light pack has none of these; it ships only convention dirs +/// (`components/ events/ prefabs/ hooks/`) and its `pack.labelle`. +pub fn packDirHasDeclModuleContent(allocator: std.mem.Allocator, pack_dir: []const u8) !bool { + const cwd = std.Io.Dir.cwd(); + const io = config.globalIo(); + const signals = [_][]const u8{ + "plugin.labelle", + "build.zig", + "src" ++ std.fs.path.sep_str ++ "root.zig", + }; + for (signals) |rel| { + const path = try std.fs.path.join(allocator, &.{ pack_dir, rel }); + defer allocator.free(path); + if (cwd.access(io, path, .{})) |_| { + return true; + } else |err| switch (err) { + error.FileNotFound => {}, + // Any other access error (permissions, etc.) is treated as + // "no reliable signal" rather than failing the whole generate. + else => {}, + } + } + return false; +} + pub fn isReservedDirName(name: []const u8) bool { for (RESERVED_DIR_NAMES) |reserved| { if (std.mem.eql(u8, name, reserved)) return true; @@ -1154,3 +1227,131 @@ test "loadPackFromDir: errors on unsupported manifest_version" { const result = loadPackFromDir(testing.allocator, tmp_path, "citizens"); try testing.expectError(error.PackManifestUnknownVersion, result); } + +// ── pack XOR decl-module plugin mutual-exclusivity guard (#481) ───── + +fn writeFileIn(tmp_dir: std.Io.Dir, name: []const u8, body: []const u8) !void { + var f = try tmp_dir.createFile(testing.io, name, .{}); + defer f.close(testing.io); + try f.writeStreamingAll(testing.io, body); +} + +test "loadPackFromDir: rejects a pack.labelle dir that also has plugin.labelle" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + try writePackManifestFile(tmp.dir, + \\.{ + \\ .name = "citizens", + \\ .manifest_version = 1, + \\ .convention_dirs = .copy_and_scan, + \\} + ); + // Decl-module signal: a plugin.labelle alongside the pack.labelle. + try writeFileIn(tmp.dir, "plugin.labelle", + \\.{ .name = "citizens", .manifest_version = 1 } + ); + + const tmp_path = try tmp.dir.realPathFileAlloc(testing.io, ".", testing.allocator); + defer testing.allocator.free(tmp_path); + + const result = loadPackFromDir(testing.allocator, tmp_path, "citizens"); + try testing.expectError(error.PackAndPluginManifestConflict, result); +} + +test "loadPackFromDir: rejects a pack.labelle dir that also has build.zig" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + try writePackManifestFile(tmp.dir, + \\.{ + \\ .name = "citizens", + \\ .manifest_version = 1, + \\ .convention_dirs = .copy_and_scan, + \\} + ); + // Decl-module signal: a build.zig means a real Zig package, not a pack. + try writeFileIn(tmp.dir, "build.zig", + \\pub fn build(b: *@import("std").Build) void { _ = b; } + ); + + const tmp_path = try tmp.dir.realPathFileAlloc(testing.io, ".", testing.allocator); + defer testing.allocator.free(tmp_path); + + const result = loadPackFromDir(testing.allocator, tmp_path, "citizens"); + try testing.expectError(error.PackAndPluginManifestConflict, result); +} + +test "loadPackFromDir: rejects a pack.labelle dir that also has src/root.zig" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + try writePackManifestFile(tmp.dir, + \\.{ + \\ .name = "citizens", + \\ .manifest_version = 1, + \\ .convention_dirs = .copy_and_scan, + \\} + ); + // Decl-module signal: an importable src/root.zig. + try tmp.dir.createDirPath(testing.io, "src"); + var src_dir = try tmp.dir.openDir(testing.io, "src", .{}); + defer src_dir.close(testing.io); + try writeFileIn(src_dir, "root.zig", + \\pub const marker = 1; + ); + + const tmp_path = try tmp.dir.realPathFileAlloc(testing.io, ".", testing.allocator); + defer testing.allocator.free(tmp_path); + + const result = loadPackFromDir(testing.allocator, tmp_path, "citizens"); + try testing.expectError(error.PackAndPluginManifestConflict, result); +} + +test "loadPackFromDir: a clean light pack (no decl-module content) still loads" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + // Only a pack.labelle + a convention dir — the genuine light-pack shape. + try writePackManifestFile(tmp.dir, + \\.{ + \\ .name = "citizens", + \\ .manifest_version = 1, + \\ .convention_dirs = .copy_and_scan, + \\} + ); + try tmp.dir.createDirPath(testing.io, "components"); + + const tmp_path = try tmp.dir.realPathFileAlloc(testing.io, ".", testing.allocator); + defer testing.allocator.free(tmp_path); + + var manifest = (try loadPackFromDir(testing.allocator, tmp_path, "citizens")).?; + defer manifest.deinit(); + + try testing.expectEqualStrings("citizens", manifest.name); +} + +test "loadFromDir: a clean decl-module plugin (no pack.labelle) still loads" { + // The mutual-exclusivity guard lives on the pack path; a decl-module + // plugin with a build.zig + src/root.zig but NO pack.labelle loads fine + // through the plugin.labelle path. + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + try writeManifestFile(tmp.dir, + \\.{ .name = "fsm", .manifest_version = 1 } + ); + try writeFileIn(tmp.dir, "build.zig", + \\pub fn build(b: *@import("std").Build) void { _ = b; } + ); + + const tmp_path = try tmp.dir.realPathFileAlloc(testing.io, ".", testing.allocator); + defer testing.allocator.free(tmp_path); + + var manifest = (try loadFromDir(testing.allocator, tmp_path, "fsm")).?; + defer manifest.deinit(); + try testing.expectEqualStrings("fsm", manifest.name); + + // And it must NOT parse as a pack (no pack.labelle present). + try testing.expect((try loadPackFromDir(testing.allocator, tmp_path, "fsm")) == null); +}