feat(plugins): Controller discovery + ship_from_plugin + two-block scripts - #73
Conversation
…etup/deinit
Plugins declaring `pub const Controller = struct { setup, deinit, ... }`
in their root module now get those lifecycle hooks invoked from the
generated game's scene setup / teardown path. Discovery is a comptime
`@hasDecl` scan of each plugin root, so plugins that don't opt in
emit no code and continue to work unchanged (backward-compatible).
Backends:
- Loop lifecycle (raylib/sdl/bgfx/wgpu): `try PluginControllers.setup`
+ `defer PluginControllers.deinit` alongside `PluginSystems`.
- Callback lifecycle (sokol, wasm): `setup` in init with `catch @panic`,
`deinit` emitted in cleanup in reverse of setup so controllers tear
down before the plugin systems they depend on.
Refs Flying-Platform/flying-platform-labelle#208
Refs Flying-Platform/flying-platform-labelle#210
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the third `ConventionDirMode.ship_from_plugin` variant so a plugin can declare a directory whose source lives in the plugin's own package rather than the consuming game's tree. Same "copy and scan" shape as `copy_and_scan`, just with the plugin as the origin. Separately — and hand in hand with the new mode — extends the script scanner to emit two ordered blocks: the game's `scripts/` first (existing behavior, unchanged), then plugin-shipped scripts per-plugin in `project.labelle` `.plugins` declaration order. Each plugin's scripts live in their own numeric-prefix namespace, so: - `05_foo.zig` in two different plugins does NOT collide (by design) - `05_foo.zig` twice in the same plugin IS a build error (unchanged rule) - game-vs-plugin prefix collisions are impossible by construction The assembler copies plugin scripts into `<target>/scripts/.plugin_<name>/…` so game + plugin trees share a single `scripts/` root without filename collisions. `scanDir` ignores the `.plugin_` subtree so plugin entries register through `scanPluginDir` only. Generator-side: plugin scripts end up in `AllScripts` with `rel_path` prefixed by `.plugin_<name>/`, and `pathToIdent` now maps `.` → `_` so the generated Zig identifiers stay valid. Backward-compatible: plugins that don't ship a `scripts/` directory (every current plugin) register zero plugin-block entries and the generated tick is unchanged. The `ship_from_plugin` mode is opt-in; existing manifests stay valid. Refs Flying-Platform/flying-platform-labelle#208 Refs Flying-Platform/flying-platform-labelle#210 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR SummaryMedium Risk Overview Extends plugin manifests with Introduces two-block script ordering: plugin-shipped Reviewed by Cursor Bugbot for commit 6a3b4e2. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Code Review
This pull request introduces a plugin controller system that allows plugins to hook into the game's lifecycle via setup and deinit methods. It also adds a new convention mode, ship_from_plugin, which enables plugins to provide their own scripts and assets that are automatically integrated into the generated build. Key changes include the generation of a PluginControllers dispatcher in main.zig, updates to the script scanner to support plugin-specific namespaces and ordering, and the addition of absolute path scanning utilities. Review feedback suggests refactoring repetitive code generation logic into a helper function, ensuring consistent error handling for plugin resolution, and simplifying complex conditional logic within the script scanner's validation process.
| try bw.writeAll(" /// Call Controller.setup(game) on every plugin that declares one.\n"); | ||
| try bw.writeAll(" /// Plugins whose root module does not export a `Controller` are silently skipped.\n"); | ||
| try bw.writeAll(" pub fn setup(game: anytype) !void {\n"); | ||
| try bw.writeAll(" inline for (_plugin_mods) |mod| {\n"); | ||
| try bw.writeAll(" if (@hasDecl(mod, \"Controller\")) {\n"); | ||
| try bw.writeAll(" const C = @field(mod, \"Controller\");\n"); | ||
| try bw.writeAll(" if (@hasDecl(C, \"setup\")) try C.setup(game);\n"); | ||
| try bw.writeAll(" }\n"); | ||
| try bw.writeAll(" }\n"); | ||
| try bw.writeAll(" }\n\n"); | ||
| try bw.writeAll(" /// Call Controller.deinit(game) on every plugin that declares one.\n"); | ||
| try bw.writeAll(" /// Mirrors setup(). Skips plugins without a Controller export or without a deinit.\n"); | ||
| try bw.writeAll(" pub fn deinit(game: anytype) void {\n"); | ||
| try bw.writeAll(" inline for (_plugin_mods) |mod| {\n"); | ||
| try bw.writeAll(" if (@hasDecl(mod, \"Controller\")) {\n"); | ||
| try bw.writeAll(" const C = @field(mod, \"Controller\");\n"); | ||
| try bw.writeAll(" if (@hasDecl(C, \"deinit\")) C.deinit(game);\n"); | ||
| try bw.writeAll(" }\n"); | ||
| try bw.writeAll(" }\n"); | ||
| try bw.writeAll(" }\n"); |
There was a problem hiding this comment.
The code generation for the setup and deinit functions is very repetitive. You could extract the common logic into a helper function to make writePluginControllersBlock more concise and easier to maintain, especially if more lifecycle methods are added in the future.
For example, you could introduce a helper like writeLifecycleFn:
fn writeLifecycleFn(bw: anytype, comptime name: []const u8, comptime ret: []const u8, comptime try_kw: []const u8) !void {
try bw.print(" /// Call Controller.{s}(game) on every plugin that declares one.\n", .{name});
// ... other comments ...
try bw.print(" pub fn {s}(game: anytype) {s} {{\n", .{name, ret});
try bw.writeAll(" inline for (_plugin_mods) |mod| {\n");
try bw.writeAll(" if (@hasDecl(mod, \"Controller\")) {\n");
try bw.writeAll(" const C = @field(mod, \"Controller\");\n");
try bw.print(" if (@hasDecl(C, \"{s}\")) {s} C.{s}(game);\n", .{name, try_kw, name});
try bw.writeAll(" }\n");
try bw.writeAll(" }\n");
try bw.writeAll(" }\n\n");
}
// Then in writePluginControllersBlock:
try writeLifecycleFn(bw, "setup", "!void", "try");
try writeLifecycleFn(bw, "deinit", "void", "");| // Plugins without a `scripts/` dir contribute nothing — backward-compat | ||
| // with every existing plugin (labelle-fsm, labelle-pathfinding today). | ||
| for (cfg.plugins) |plugin| { | ||
| const plugin_src_dir = cache.resolvePlugin(allocator, plugin, game_dir) catch continue; |
There was a problem hiding this comment.
The error handling for cache.resolvePlugin is inconsistent. Here, you use catch continue, which silently skips if a plugin can't be resolved. However, in the plugin manifest loading loop (lines 202-203), plugin_manifest.loadOptional calls resolvePlugin with try, which would fail the entire generation process.
For consistency and robustness, it would be better to use try here as well. If a plugin is declared in project.labelle, it should probably be an error if it cannot be found.
const plugin_src_dir = try cache.resolvePlugin(allocator, plugin, game_dir);
| const same_plugin = blk: { | ||
| if (a.plugin_name == null and b.plugin_name == null) break :blk true; | ||
| if (a.plugin_name) |a_p| { | ||
| if (b.plugin_name) |b_p| break :blk std.mem.eql(u8, a_p, b_p); | ||
| } | ||
| break :blk false; | ||
| }; | ||
| if (!same_plugin) continue; | ||
|
|
||
| const same_subdir = blk: { | ||
| if (a.subdir == null and b.subdir == null) break :blk true; | ||
| if (a.subdir) |a_sub| { | ||
| if (b.subdir) |b_sub| { | ||
| break :blk std.mem.eql(u8, a_sub, b_sub); | ||
| } | ||
| if (b.subdir) |b_sub| break :blk std.mem.eql(u8, a_sub, b_sub); | ||
| } | ||
| break :blk false; | ||
| }; |
There was a problem hiding this comment.
The logic to determine same_plugin and same_subdir using labeled blocks is a bit complex and can be hard to read. You can simplify this logic using if and else with optional unwrapping, which would improve readability.
const same_plugin = if (a.plugin_name) |a_p| (b.plugin_name) |b_p| and std.mem.eql(u8, a_p, b_p) else b.plugin_name == null;
if (!same_plugin) continue;
const same_subdir = if (a.subdir) |a_sub| (b.subdir) |b_sub| and std.mem.eql(u8, a_sub, b_sub) else b.subdir == null;
There was a problem hiding this comment.
Pull request overview
Implements step 1 of the Plugin Controllers RFC for labelle-assembler: adds generated PluginControllers discovery/wiring, introduces a ship_from_plugin convention-dir mode, and extends script scanning to support a second “plugin scripts” block ordered by .plugins declaration order.
Changes:
- Generate a
PluginControllersscaffold inmain.zigand wiresetup/deinitinto both loop and callback lifecycles. - Add plugin-shipped scripts support: copy plugin
scripts/intoscripts/.plugin_<name>/and scan them as a separate ordered block. - Add
ship_from_pluginconvention-dir mode and supportingcopyAndScanAbshelper; expand tests for the new behaviors.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
src/main_zig.zig |
Emits PluginControllers scaffold, wires lifecycle calls, and updates identifier sanitization for .plugin_* script paths. |
src/root.zig |
Adds ship_from_plugin handling and copies/scans plugin scripts/ into .plugin_<name> namespaces. |
src/script_scanner.zig |
Tracks plugin provenance per script entry and enforces two-block ordering + per-plugin duplicate-prefix validation. |
src/scanner.zig |
Adds copyAndScanAbs to support copying/scanning between arbitrary absolute paths. |
src/plugin_manifest.zig |
Adds ConventionDirMode.ship_from_plugin plus parsing/validation tests (extension required). |
test/tests.zig |
Adds snapshot-style assertions for generated PluginControllers output and plugin-script import identifiers. |
test/script_scanner_tests.zig |
Adds tests for two-block script ordering, per-plugin duplicate-prefix scoping, and .plugin_* subtree ignore behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| // Probe for existence — plugins without a `scripts/` dir are the | ||
| // norm and must not error. | ||
| _ = cwd.openDir(plugin_scripts_src, .{}) catch continue; |
There was a problem hiding this comment.
The existence probe opens a directory handle and immediately discards it (_ = cwd.openDir(...)) without closing. This can leak file descriptors when multiple plugins are processed. Store the opened Dir in a variable and defer close() (or use openDir just-in-time inside the copy step and handle error.FileNotFound).
| _ = cwd.openDir(plugin_scripts_src, .{}) catch continue; | |
| var plugin_scripts_dir = cwd.openDir(plugin_scripts_src, .{}) catch continue; | |
| defer plugin_scripts_dir.close(); |
| /// then loop over plugins calling `scanPluginDir`. After all calls, | ||
| /// either call `finalize()` or rely on `scanDir`'s end-of-call | ||
| /// sort+validate — but note that the final sort must see every entry, | ||
| /// so plugins added *after* `scanDir` returned will need `finalize()` | ||
| /// (or a re-sort and re-validate). |
There was a problem hiding this comment.
This doc comment references a finalize() API, but ScriptScanner doesn’t define finalize() (only sortEntries() + validateNoDuplicateOrders()). Either add the method or update the comment to describe the actual required call sequence (e.g. caller must explicitly sort/validate after all scans, or scanPluginDir always sorts/validates internally).
| /// then loop over plugins calling `scanPluginDir`. After all calls, | |
| /// either call `finalize()` or rely on `scanDir`'s end-of-call | |
| /// sort+validate — but note that the final sort must see every entry, | |
| /// so plugins added *after* `scanDir` returned will need `finalize()` | |
| /// (or a re-sort and re-validate). | |
| /// then loop over plugins calling `scanPluginDir`. `scanDir()` sorts | |
| /// and validates at the end of its own call, so if any plugins are | |
| /// scanned afterward, the caller must explicitly call `sortEntries()` | |
| /// and then `validateNoDuplicateOrders()` after all scans complete so | |
| /// the final ordering and duplicate-prefix checks see every entry. |
| // Feed the plugin's scripts into the scanner as a new block, | ||
| // isolated under the plugin's namespace so the duplicate-prefix | ||
| // validator treats it independently of the game block. | ||
| try script_scan.scanPluginDir(plugin_scripts_dst, plugin.name); | ||
| } |
There was a problem hiding this comment.
script_entries is captured via script_scan.getEntries() before this plugin-scripts loop runs (see earlier const script_entries = script_scan.getEntries();). Since getEntries() returns a slice with a fixed length, any later scanPluginDir calls will not be reflected in script_entries, so plugin-shipped scripts won’t be emitted into generated main.zig. Fix by deferring the getEntries() call until after all scanPluginDir calls (or passing script_scan.getEntries() directly into generateMainZigFromTemplate).
…, doc Three review items from the first round: - **HIGH (cursor + copilot)** — `script_entries` captured via `script_scan.getEntries()` BEFORE the plugin-shipped-scripts loop ran. Each `scanPluginDir` call appended more entries and could reallocate the ArrayList backing buffer, leaving `script_entries` as a dangling pointer. Even if no realloc, `.len` reflected only the game's block — plugin scripts never reached the codegen. Fix: defer the `getEntries()` capture until just before `generateMainZigFromTemplate`, after every plugin has been scanned. Matches the doc contract on `scanPluginDir` and makes the end-to-end pathfinder migration (#211) actually work. - **MEDIUM (cursor + copilot)** — the plugin-scripts existence probe at `root.zig` opened a `Dir` handle via `cwd.openDir` and discarded it with `_ =`, leaking one file descriptor per plugin that ships a `scripts/` directory. Fix: store the handle and close it immediately (we only needed the existence probe, not a live handle — the actual copy reopens via `copyAndScanAbs`). - **MEDIUM (gemini)** — `cache.resolvePlugin` used `catch continue` in the plugin-scripts loop while the manifest-load loop above uses `try`. The plugin was already successfully resolved during manifest-load, so a second-resolution failure is a cache corruption, not a plugin configuration error — `try` is the correct contract. Made consistent. Also cleaned up a stale `finalize()` reference in `scanPluginDir`'s doc comment (copilot). `scanPluginDir` re-sorts + re-validates internally, so the external `finalize()` step doesn't exist. Skipped two style suggestions (gemini's code-gen helper and a labeled-block simplification) — those are real but can land in a follow-up if taste agrees. Tests: `zig build test` green (60 pass across the three suites). Refs Flying-Platform/flying-platform-labelle#210 Refs Flying-Platform/flying-platform-labelle#208
…anifest
Adds the self-contained fixture plugin used by the plugin-controllers
E2E example. Ships:
- plugin.labelle: manifest_version = 1, declaring a `demo_playbooks/`
convention with `mode = .ship_from_plugin` to exercise the new mode
on a real `labelle generate` run. `scripts/` is reserved so the
plugin's per-frame script is shipped via the auto-discovery path.
- src/root.zig: minimal `pub const Controller = struct { setup, deinit }`
export. Both lifecycle hooks emit a tagged `game.log.info` line the
CI log-order assertion can key on.
- scripts/playing/01_plugin_tick.zig: state-bound per-frame script.
Lands under `<target>/scripts/.plugin_demo_plugin/playing/` after
generate — runs in the plugin's own block-2 namespace.
- demo_playbooks/README.zig: no-op smoke-test for the ship_from_plugin
copy pass.
- build.zig + build.zig.zon: exposes a `labelle_demo_plugin` module so
the generated build.zig can do
`plugin_demo_plugin_dep.module("labelle_demo_plugin")`.
Standalone commit — this plugin contributes nothing to the assembler
tests yet; the matching game project and CI test arrive in follow-up
commits. Existing PR #73 feature commits untouched.
…ugin
Adds the game-side of the plugin-controllers E2E example:
- project.labelle: raylib + mock ECS, hidden window, single `playing`
state. Declares the fixture plugin via `local:./plugin`. No atlases,
no GUI, no custom layers — the smallest project.labelle that still
exercises the full setup → tick → defer lifecycle.
- scenes/main.jsonc: empty scene (no entities, no assets) so
`g.setScene("main")` has a name to bind against.
- scripts/playing/01_game_tick.zig: state-bound per-frame script that
logs `[game] game-tick frame=N`. Runs in block-1 (game) before the
plugin's block-2 script each tick — matching `ScriptScanner`'s
game-before-plugin ordering contract (see `PluginBlockOrdering`
tests in `test/script_scanner_tests.zig`).
- README.md: explains the expected log sequence and why
`[demo-plugin] deinit` is not part of the runtime assertion (raylib's
main loop guards on `windowShouldClose`, which can't flip in a
hidden-window CI run without crashing the subsequent draw calls;
the `defer PluginControllers.deinit(&g)` wiring is covered by
snapshot tests instead).
- .gitignore: the generated `.labelle/` tree.
Generate locally:
cd examples/plugin-controllers
labelle generate
cd .labelle/raylib_desktop && zig build && ./zig-out/bin/game
Verified end-to-end on macOS: setup + 5 interleaved game/plugin tick
pairs emit in the canonical order documented in README.md. The CI
runtime assertion arrives in the follow-up commit.
Adds two steps to the `examples-integration` job:
1. `Generate + build the plugin-controllers example` — mirrors the
existing raylib / asset-streaming-smoke recipe. Shakes out
"generated code doesn't compile" bugs one consumer-repo update
before the pathfinder migration (flying-platform-labelle#211)
depends on PR #73 landing.
2. `Runtime log-order check for plugin-controllers` — actually runs
the generated binary under xvfb-run + `timeout 3`, captures
stderr, and asserts the canonical `setup` → interleaved
`game-tick` / `plugin-tick` sequence via `diff -u`.
Uses the existing `labelle generate` + `LABELLE_ASSEMBLER` env-var
pattern (not a bespoke shell-out to `labelle-assembler` directly) so
plugin resolution, cache population, and fingerprint fixups all take
the same code path as a real consumer.
xvfb dependency: raylib creates its OpenGL context at init even with
`.hidden = true`. On headless Linux runners the context needs a
display server; `xvfb-run` provides the minimal X server. The existing
build-only steps don't need xvfb because they stop before running the
binary. Tolerates `timeout` exit code 124 since raylib's main loop
polls `windowShouldClose()` which can't flip on a hidden window — the
example caps its log output at 5 frames, so extra silent iterations
between the last logged line and the timeout kill don't affect the
asserted pattern.
The `[demo-plugin] deinit` line is NOT asserted at runtime for the
reason documented in `examples/plugin-controllers/README.md` — it's
covered by the snapshot tests in `test/tests.zig::PLUGIN_CONTROLLERS`
which verify the generated `main.zig` emits
`defer PluginControllers.deinit(&g)` in the right scope.
…E_QUIT Both `01_game_tick.zig` and `01_plugin_tick.zig` now early-return once `state.frame >= FRAMES_BEFORE_QUIT` (= 5). Without the cap, raylib's main loop keeps polling — `game.quit()` flips the engine's internal `running` flag but `windowShouldClose()` doesn't honour it for a hidden window — and both scripts emit ~150 extra log lines before the CI `timeout 3` kills the process. That extra output doesn't change the asserted ordering *pattern* but makes the expected-vs-actual `diff -u` non-deterministic (exact frame count depends on CI runner scheduling) and the captured `run.log` noisy when a test does fail. Discovered by running the log-order check locally. Caught before CI would have flaked on the first PR update.
Implementation notes for reviewersA couple of deliberate trade-offs in the `examples/plugin-controllers/` addition that are worth surfacing: 1. Runtime assertion covers `setup` + `tick`, NOT `deinit`The CI test (`xvfb-run timeout 3 ./game` + `diff -u` against a canonical 11-line log) asserts: ``` `[demo-plugin] deinit` is not part of the runtime assertion because:
The `deinit` wiring itself is covered by the snapshot tests in `test/tests.zig::PLUGIN_CONTROLLERS` — we assert the generated `main.zig` contains the correct `defer` call in both loop (raylib/sdl) and callback (sokol) lifecycle paths. So the codegen is verified; just not the runtime exit path. Follow-up opportunity: a sokol-backend variant of the example would let CI exercise the deinit runtime path too (sokol's main-loop ownership makes graceful shutdown tractable). Out of scope for this PR; a single-backend example demonstrates the pattern. Tracking at Flying-Platform/flying-platform-labelle#210's follow-up list. 2. New CI dependency: `xvfb` on Linux runnerThe `examples-integration` job now needs `xvfb` to run the raylib binary headlessly on ubuntu-latest. The dependency is declared inline in the workflow step but worth calling out here so it's not a stealth infrastructure cost if this project ends up running on non-default runners (self-hosted, ARM, etc.). macOS doesn't need it — the agent verified the whole flow on macOS without xvfb. If CI flakiness emerges around the xvfb step, a fallback is to run the binary under `LABELLE_HEADLESS=1` (not wired yet) and have the generated main skip raylib's window init entirely — that's a bigger change than this PR warrants. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit e92c48e. Configure here.
| # on sokol-style ports) and 124 (timeout kill). | ||
| set +e | ||
| xvfb-run -a -s "-screen 0 320x240x24" \ | ||
| timeout 3 ./zig-out/bin/game > run.log 2>&1 |
There was a problem hiding this comment.
CI references wrong binary name for runtime check
High Severity
The CI runtime log-order check runs ./zig-out/bin/game but the project is named plugin_controllers_demo in project.labelle. The README's local-run instructions correctly reference ./zig-out/bin/plugin_controllers_demo. The generated build.zig names the executable after the project's .name field, so the binary will be plugin_controllers_demo, not game. This will cause the CI step to fail with a "file not found" error every time.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit e92c48e. Configure here.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 20 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| labelle generate | ||
| cd .labelle/raylib_desktop | ||
| zig build | ||
| timeout 3 ./zig-out/bin/plugin_controllers_demo 2>&1 | head -40 |
There was a problem hiding this comment.
The run command uses ./zig-out/bin/plugin_controllers_demo, but the generated build template hardcodes the executable name as game (build_zig.txt uses .name = "game"). Update the example's local run instructions (and any mentions of the binary name) to match the actual output path so the docs work as written.
| timeout 3 ./zig-out/bin/plugin_controllers_demo 2>&1 | head -40 | |
| timeout 3 ./zig-out/bin/game 2>&1 | head -40 |
| // Scanning is driven by the `ScriptScanner` (see addPluginBlock below), | ||
| // so the duplicate-prefix validator treats each plugin block as | ||
| // independent. Cross-plugin prefix collisions are impossible by | ||
| // construction. Game-vs-plugin collisions are also impossible — the | ||
| // game scripts live under `scripts/` while plugin scripts live under |
There was a problem hiding this comment.
This comment references addPluginBlock below, but there is no addPluginBlock in this file (or nearby). Consider updating the wording to point at the actual implementation (ScriptScanner.scanPluginDir / the plugin-script loop here) to avoid misleading future readers.
| // Scanning is driven by the `ScriptScanner` (see addPluginBlock below), | |
| // so the duplicate-prefix validator treats each plugin block as | |
| // independent. Cross-plugin prefix collisions are impossible by | |
| // construction. Game-vs-plugin collisions are also impossible — the | |
| // game scripts live under `scripts/` while plugin scripts live under | |
| // Scanning is driven by the `ScriptScanner` via the plugin-script loop | |
| // here and the `script_scan.scanPluginDir(...)` call below, so the | |
| // duplicate-prefix validator treats each plugin block as independent. | |
| // Cross-plugin prefix collisions are impossible by construction. | |
| // Game-vs-plugin collisions are also impossible — the game scripts | |
| // live under `scripts/` while plugin scripts live under |
…ild_zig.txt template Bugbot flagged a mismatch on PR #73: README says `./zig-out/bin/plugin_controllers_demo` but CI runs `./zig-out/bin/game`. The CI is correct — the build.zig template hardcodes `.name = "game"` for the executable regardless of the project's `.name` field in project.labelle. README was lying about local-run behavior. Fixed. (The template-hardcodes-'game' design is arguably a separate issue — a project.labelle should probably drive the binary name — but that's orthogonal to this PR.)
|
@gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces plugin controller machinery, allowing plugins to export lifecycle hooks and ship their own scripts via a new ship_from_plugin manifest mode. The implementation includes a comptime dispatcher and updates to the script scanner for plugin-namespaced scripts. Review feedback identifies several improvement opportunities, including the use of errdefer to prevent memory leaks during allocation failures in ScriptScanner, the propagation of directory iteration errors, and the removal of a redundant directory probe.
| var plugin_scripts_dir = cwd.openDir(plugin_scripts_src, .{}) catch continue; | ||
| plugin_scripts_dir.close(); |
There was a problem hiding this comment.
| const name_dup = try self.allocator.dupe(u8, plugin_name); | ||
| try self.shared_plugin_names.append(self.allocator, name_dup); |
There was a problem hiding this comment.
If shared_plugin_names.append fails with OutOfMemory, the name_dup allocation is leaked. Using errdefer ensures the memory is freed if the append operation fails.
const name_dup = try self.allocator.dupe(u8, plugin_name);
errdefer self.allocator.free(name_dup);
try self.shared_plugin_names.append(self.allocator, name_dup);
| defer self.allocator.free(rel_prefix); | ||
|
|
||
| var iter = dir.iterate(); | ||
| while (iter.next() catch return) |entry| { |
| const name_copy = try self.allocator.dupe(u8, entry.name); | ||
| const rel_path = try std.fmt.allocPrint(self.allocator, "{s}/{s}", .{ rel_prefix, entry.name }); | ||
| try self.addEntryWithPath(name_copy, null, &.{}, rel_path); |
There was a problem hiding this comment.
These allocations are leaked if subsequent operations fail. For example, if allocPrint fails, name_copy is leaked. If addEntryWithPath fails, both name_copy and rel_path are leaked. errdefer should be used to manage these temporary ownerships until they are successfully handed off to the scanner's internal state.
const name_copy = try self.allocator.dupe(u8, entry.name);
errdefer self.allocator.free(name_copy);
const rel_path = try std.fmt.allocPrint(self.allocator, "{s}/{s}", .{ rel_prefix, entry.name });
errdefer self.allocator.free(rel_path);
try self.addEntryWithPath(name_copy, null, &.{}, rel_path);
| const dir_states = try self.parseDirStates(entry.name); | ||
| if (dir_states.len == 0) continue; | ||
|
|
||
| const subdir_path = try std.fmt.allocPrint(self.allocator, "{s}/{s}", .{ plugin_scripts_dir, entry.name }); | ||
| defer self.allocator.free(subdir_path); | ||
| const subdir_name = try self.allocator.dupe(u8, entry.name); | ||
| try self.shared_subdirs.append(self.allocator, subdir_name); | ||
| try self.shared_states.append(self.allocator, dir_states); |
There was a problem hiding this comment.
Multiple allocations in this block are leaked if subsequent operations fail before ownership is transferred to shared_subdirs or shared_states. Specifically, dir_states is leaked if subdir_path, subdir_name, or the appends fail. subdir_name is leaked if its append fails. errdefer blocks should be used to ensure proper cleanup on failure.
…update stale doc Per gemini + copilot review on PR #73: - Removed the explicit directory-existence probe (`cwd.openDir(plugin_scripts_src, .{}) catch continue`). Both downstream calls already handle missing source dirs silently: `scanner.copyAndScanAbs` via `copyAndScanRecursive` (scanner.zig:181) and `script_scan.scanPluginDir` (script_scanner.zig:173). The probe was an extra syscall + fd open/close for no semantic gain. - Fixed the stale `see addPluginBlock below` doc reference — the function doesn't exist; we call `scanPluginDir` directly. No behavior change in the generated output. `zig build test` still green across all suites. Skipping the four OOM-errdefer comments (script_scanner.zig ~176/198/202/213) — they're correct but low-impact (leak paths only hit on OOM, which Zig treats as process-fatal anyway) and #198 would require adding error variants to `ScanError`. Better addressed as a systematic errdefer audit in a separate PR; filing a follow-up ticket.
Plugin-exported Controllers (#73).
Adds a `null` backend variant — pure-Zig no-op implementations of every
gfx/input/audio/window symbol the engine expects, plus a desktop template
whose generated `main()` runs the tick loop for a bounded number of frames
(LABELLE_NULL_FRAMES env var, default 5) at a fixed 1/60s timestep, then
exits cleanly so `defer`-bound teardown actually runs.
Wires `.null` as a valid value for `.backend` in `project.labelle`:
- `src/config.zig`: extend the `Backend` enum
- `src/build_files.zig`: route `.null` through new build/zon sections
- `src/gui_resolve.zig`: GUI plugins are incompatible with `.null`
- `src/templates/{build_zig,build_zig_zon}.txt`: backend_null + dep_null_path
- `backends/null/`: build.zig, build.zig.zon, src/{gfx,input,audio,window}.zig,
templates/desktop.txt, plus self-contained unit tests
Use case: lifecycle / determinism / integration tests that don't exercise
rendering — closes the runtime `deinit` coverage gap from PR #73 (raylib's
hidden-window loop can't exit cleanly, so `defer PluginControllers.deinit`
was only codegen-tested) and removes the xvfb dependency for headless CI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…74) Migrates the plugin-controllers E2E example off raylib (with hidden window + xvfb on Linux CI + timeout-kill exit path) onto the new `.null` backend (headless tick loop, exits cleanly after `LABELLE_NULL_FRAMES` frames). Concrete changes: - project.labelle: `.backend = .null`, drop `.hidden` - scripts/playing/01_game_tick.zig + plugin scripts: drop the `game.quit()` workaround and the raylib-specific lifecycle prose; the null backend's frame counter terminates the loop on its own - .github/workflows/ci.yml: drop the `xvfb-run` wrapper, drop `timeout 3`, drop the apt-get xvfb install. The runtime check is now a plain `./game` invocation whose exit code must be 0 - Extend the canonical log-order assertion with the trailing `[demo-plugin] deinit` line — reachable now that the loop exits cleanly and `defer PluginControllers.deinit(&g)` actually runs (the runtime coverage gap PR #73 had to leave to snapshot tests) - .gitignore: the assembler writes plugin-shipped scripts into `scripts/.plugin_<name>/` (PR #71 symlink design); ignore them Because labelle-cli's bundled generator module is pinned to v0.8.0 and doesn't know about `.null`, the CI step invokes the assembler binary directly and replicates labelle-cli's `fixFingerprint` post-pass inline. Routes back through `labelle generate` once labelle-cli bumps past this PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(backends): add null backend for headless lifecycle/CI tests (#74) Adds a `null` backend variant — pure-Zig no-op implementations of every gfx/input/audio/window symbol the engine expects, plus a desktop template whose generated `main()` runs the tick loop for a bounded number of frames (LABELLE_NULL_FRAMES env var, default 5) at a fixed 1/60s timestep, then exits cleanly so `defer`-bound teardown actually runs. Wires `.null` as a valid value for `.backend` in `project.labelle`: - `src/config.zig`: extend the `Backend` enum - `src/build_files.zig`: route `.null` through new build/zon sections - `src/gui_resolve.zig`: GUI plugins are incompatible with `.null` - `src/templates/{build_zig,build_zig_zon}.txt`: backend_null + dep_null_path - `backends/null/`: build.zig, build.zig.zon, src/{gfx,input,audio,window}.zig, templates/desktop.txt, plus self-contained unit tests Use case: lifecycle / determinism / integration tests that don't exercise rendering — closes the runtime `deinit` coverage gap from PR #73 (raylib's hidden-window loop can't exit cleanly, so `defer PluginControllers.deinit` was only codegen-tested) and removes the xvfb dependency for headless CI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(examples/plugin-controllers): switch to null backend, drop xvfb (#74) Migrates the plugin-controllers E2E example off raylib (with hidden window + xvfb on Linux CI + timeout-kill exit path) onto the new `.null` backend (headless tick loop, exits cleanly after `LABELLE_NULL_FRAMES` frames). Concrete changes: - project.labelle: `.backend = .null`, drop `.hidden` - scripts/playing/01_game_tick.zig + plugin scripts: drop the `game.quit()` workaround and the raylib-specific lifecycle prose; the null backend's frame counter terminates the loop on its own - .github/workflows/ci.yml: drop the `xvfb-run` wrapper, drop `timeout 3`, drop the apt-get xvfb install. The runtime check is now a plain `./game` invocation whose exit code must be 0 - Extend the canonical log-order assertion with the trailing `[demo-plugin] deinit` line — reachable now that the loop exits cleanly and `defer PluginControllers.deinit(&g)` actually runs (the runtime coverage gap PR #73 had to leave to snapshot tests) - .gitignore: the assembler writes plugin-shipped scripts into `scripts/.plugin_<name>/` (PR #71 symlink design); ignore them Because labelle-cli's bundled generator module is pinned to v0.8.0 and doesn't know about `.null`, the CI step invokes the assembler binary directly and replicates labelle-cli's `fixFingerprint` post-pass inline. Routes back through `labelle generate` once labelle-cli bumps past this PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(null-backend): document tick loop, env var control, replacement of xvfb (#74) - README.md: list `null` in the --backend table, link to the example and the backend implementation - examples/plugin-controllers/README.md: rewrite the "what it demonstrates" + "how this works" sections around the null-backend tick loop, document `LABELLE_NULL_FRAMES`, explain why this replaces the previous xvfb / `timeout 3` dance, and update the local-run recipe (currently bypasses labelle-cli because its bundled generator is pinned to an older release) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(null-backend/getMaxFrames): clarify error paths don't leak per gemini review on PR #76 --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Systematic audit of alloc-then-hand-off patterns where an OOM in a later step of the same function would leak an earlier allocation. script_scanner.zig - scanDir / scanPluginDir / scanZigFilesRecursive: a per-file `dupe`'d name (and `allocPrint`'d rel_path) leaked if the `addEntryWithPath` append OOM'd. Added `errdefer` on each. - scanDir / scanPluginDir state-dir branch: `subdir_name` (dupe) and `dir_states` (owned slice from parseDirStates) leaked if the shared-list append OOM'd. Reworked to reserve list capacity before the dupe so the appends are infallible; an explicit `transferred` flag scopes the errdefers to the pre-handoff window. - scanPluginDir: `name_dup` leaked if shared_plugin_names append OOM'd (gemini PR #73 finding) — reserve-then-appendAssumeCapacity. - parseDirStates: a mid-loop dupe/append OOM leaked already-duped state strings + the list buffer. Added an errdefer that frees both. - getEntriesForState: the `result` ArrayList leaked on append OOM. Added `errdefer result.deinit`. - iter.next() errors were swallowed by `catch return`, silently truncating the script list. Now propagated; ScanError folds in std.Io.Dir.Iterator.Error. main_zig.zig - generateMainZigFromTemplate: each emitted block was `toOwnedSlice`'d then `allocs.append`'d; an OOM in that append leaked the block. Reserve allocs capacity up front and use appendAssumeCapacity for all 18 sites, closing the window. root.zig audited — no genuine leaks (rgba_path_allocs already has a matching errdefer; loaded_manifests already reserves capacity). Tests: added two checkAllAllocationFailures-based regression tests exercising every OOM point in scanDir and scanPluginDir. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gins P3, #577) (#588) * feat(assets): validate studio panel.jsonc at generate time (Asset Plugins P3, #577) Validate every `studio/*.panel.jsonc` a plugin (or a pack it bundles) ships, at `labelle generate` time, alongside the Phase-1/2 asset + pack validation — so the studio never renders an invalid panel from a generated project. A malformed panel is a build error with a file (and, for parse errors, line) location. The reference is the studio POC's zod schema (labelle-studio src/services/pluginPanels.ts, #73); src/panel_validate.zig is a faithful Zig port: strict top-level keys (id/title/icon/fields/actions), id identifier + non-empty title, per-type field branches (number/slider/select/text/toggle) with strict keys, and the semantic pass (duplicate names, min>max, default out of range, select default not in options). All problems for one file are collected and rendered as `<file>: <where>: <problem>` (the studio's rules sidecar shape). - src/panel_validate.zig (new): parser (JSONC strip + std.json) + schema + semantic validation; a bounded `studio/`-gated directory walk that discovers panels at a plugin root and inside its nested packs; validatePluginPanels drives it over cfg.plugins. - src/root.zig: call validatePluginPanels in generate, before any target is written; test aggregation + pub re-export. - Tests: 18 schema/semantic accept+reject cases plus 2 filesystem-discovery tests (studio-gating + no-panel no-op). Additive: a project with no panels is unaffected; an unresolvable plugin dir is skipped (other passes report a genuinely missing plugin). Deferred (pairs with engine#729): a `"target":"preview"` command naming a plugin-declared handler can't be cross-checked here yet — the engine declares handlers by runtime subscription to engine__editor_plugin_command, so no static handler-name list exists at generate time. The schema/semantic gate is the bulk of the acceptance. `zig build test` passes (the pre-existing flow_catalog sidecar failure is unrelated and present on origin/main). Closes #577 Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw * fix(assets): scope panel validation to declared packs only (#577 review) The Phase-3 panel validator walked EVERY directory below a plugin root, so a `studio/*.panel.jsonc` in an UNDECLARED/example pack (e.g. `packs/experimental/studio/`, an `examples/` tree) was validated and could fail `labelle generate` even though that pack isn't shipped — nested packs are only part of generation when declared in `plugin.labelle`'s `.packs` list (codex review on #588). Replace the blanket recursive `walk` with a scoped discovery that mirrors the pack-discovery path (`generate_phases.discoverNestedPacks`): validate ONLY (a) the plugin-root `studio/` dir, and (b) the `studio/` dir of each pack the plugin DECLARES in `.packs`. `collectPluginPanelErrors` loads the plugin manifest (`plugin_manifest.loadOptional`) and scans exactly those units via a new non-recursive `scanStudioDir`; the undeclared/example descendants are never touched. Tests: swap the recursive-walk tests for `scanStudioDir` unit tests and add a declared-vs-undeclared scope test — a DECLARED pack's broken panel fails, an UNDECLARED pack's broken panel does NOT. `zig build test` passes (the pre-existing flow_catalog sidecar failure is unrelated). Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw


Summary
Step 1 of the Plugin Controllers RFC (flying-platform-labelle#208). Three additive, backward-compatible changes to labelle-assembler:
PluginControllersscaffold that discoverspub const Controller = struct { setup, deinit, ... }in each plugin root via comptime@hasDecl.setupfires after scene load;deinitfires on unload. Plugins without a Controller export contribute nothing (no runtime cost).ship_from_pluginConventionDirMode. New variant ofConventionDirMode. Copies<plugin>/<name>/**into the generated build tree (reverse of today'scopy_and_scan, which flows<game>/<name>/→ target). Enables plugins to ship their own scanned directories.ScriptScannernow emits the game's scripts first (unchanged), then plugin-shipped scripts per-plugin inproject.labelle.pluginsdeclaration order. Each plugin lives in its own numeric-prefix namespace —05_foo.zigin two different plugins is fine;05_foo.zigtwice in the same plugin is a build error. Game-vs-plugin and plugin-vs-plugin prefix collisions are impossible by construction.The three changes land in two commits:
feat(controllers): discover plugin Controller exports and auto-wire setup/deinitfeat(plugins): ship_from_plugin mode + two-block script orderingEvery change is opt-in. Existing plugins (labelle-fsm, labelle-pathfinding today) continue to work unchanged — no
Controllerexport, no pluginscripts/directory, noship_from_plugindeclarations.Refs Flying-Platform/flying-platform-labelle#210
Refs Flying-Platform/flying-platform-labelle#208
Test plan
zig build testpasses locally.PLUGIN_CONTROLLERSintest/tests.zig).ship_from_pluginmode parses and validates end-to-end (plugin_manifest.zigtests).copyAndScanAbscopies + scans files and preserves subdirectory structure (script_scanner_tests.zig)..pluginsdeclaration order drives inter-plugin order.scanDirignores.plugin_<name>/subdirs so plugin entries register throughscanPluginDironly.