From 4f18dc02eb8e2b3192e8ad8ae7163daac1862655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Mondaini=20Calv=C3=A3o?= Date: Thu, 1 Oct 2026 20:07:14 -0300 Subject: [PATCH] feat(hooks): before-generate env reaches the assembler's generate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A provider's env_file contribution (contract §2) used to reach the fingerprint pass, the compile and later hooks, but not the labelle-assembler generate process, so a before-generate hook could steer the compile but not what gets generated. assembler_proc.generate now takes the build's accumulated hook environment and runs the assembler with it merged on top of ours (normal builds and watched rebuilds); with nothing contributed it inherits our environment exactly as before. First consumer: labelle-web#24, whose before-generate hook switches on the threaded web build (LABELLE_WASM_THREADS, labelle-assembler#818) from the provider's own config. Docs: provider-contract-v1 §2 scope and provider-hooks. Claude-Session: https://claude.ai/code/session_01LpszrcSrLLijQcxxvgWyUQ --- docs/provider-contract-v1.md | 2 +- docs/provider-hooks.md | 5 ++- src/cli/assembler_proc.zig | 74 ++++++++++++++++++++++++++++++++++- src/cli/pipeline/generate.zig | 4 +- src/cli/pipeline/rebuild.zig | 2 +- src/cli/provider_hooks.zig | 3 +- 6 files changed, 82 insertions(+), 8 deletions(-) diff --git a/docs/provider-contract-v1.md b/docs/provider-contract-v1.md index 5d1238e..44acc33 100644 --- a/docs/provider-contract-v1.md +++ b/docs/provider-contract-v1.md @@ -137,7 +137,7 @@ From wire `1.6.0` the file may also carry `build_options`, the Zig build options **Freshness.** The environment is built for each build from the inherited environment plus the contributions of the hooks that ran in that build; a watched rebuild starts over. A hook a replan removed leaves nothing behind. A watched rebuild that fails, at any stage, keeps the environment of the last successful build (the one being served, and the one the session's `after run` hooks get); only a rebuild that succeeds replaces it. -**Scope.** The merged environment reaches every later zig invocation and hook process of the same command: the generation-time fingerprint pass (`zig build --list-steps`, which configures the generated build and so is the first toolchain consumer: a `before generate` contribution reaches it, an `after generate` one does not), the core `zig build` compile, and every later hook and replacement (`after generate`, `before`/`replace`/`after build`, `bundle` and `run` hooks). It never reaches the build of a provider's own tool (`zig build ` in the provider's package), which runs on the plain inherited environment plus the CLI's cache variables, so a provider cannot change how other providers' tools are built. The game the core `labelle run` launches keeps its own environment (the inherited one plus the run options); a provider that must reach the game uses a `run` replacement. The core stages no runtime library beside a built exe: it only merges the contribution into the environment above. Staging one (a DLL next to a desktop exe, say) is a provider's job, in an `after build` hook, which sees the merged environment as the compile did; the `sdl2` provider's `stage` hook copies `SDL2.dll` this way (cli#471 S4). +**Scope.** The merged environment reaches every later zig invocation and hook process of the same command: the core generation itself (the `labelle-assembler generate` process, so a `before generate` contribution can steer what is generated; labelle-web's `LABELLE_WASM_THREADS`, labelle-web#24), the generation-time fingerprint pass (`zig build --list-steps`, which configures the generated build and so is the first toolchain consumer: a `before generate` contribution reaches it, an `after generate` one does not), the core `zig build` compile, and every later hook and replacement (`after generate`, `before`/`replace`/`after build`, `bundle` and `run` hooks). It never reaches the build of a provider's own tool (`zig build ` in the provider's package), which runs on the plain inherited environment plus the CLI's cache variables, so a provider cannot change how other providers' tools are built. The game the core `labelle run` launches keeps its own environment (the inherited one plus the run options); a provider that must reach the game uses a `run` replacement. The core stages no runtime library beside a built exe: it only merges the contribution into the environment above. Staging one (a DLL next to a desktop exe, say) is a provider's job, in an `after build` hook, which sees the merged environment as the compile did; the `sdl2` provider's `stage` hook copies `SDL2.dll` this way (cli#471 S4). **No silent bypass.** A build path that cannot carry a provider's contribution or its optimize default refuses before any hook, generation or build, naming the hook or package. `--docker` builds in a container the contributions never reach, so a `build`, `run` or `bundle` (any command that reaches the container build) whose plan has a hook that may contribute (one in a contributing slot whose provider negotiates `1.3.0` or newer) is refused; `labelle generate --docker` is not, since it stops after generation and its host-side fingerprint pass gets the contributions, and neither is a build the target owner replaces, since its `replace build` hook gets them like every hook: `--docker doesn't carry provider environment contributions; build without --docker`. (`--docker` does pass the effective optimize mode as `-Doptimize`.) The legacy `labelle ios` subcommand, which ran its own `zig build` carrying neither and was refused for both, left the core with RFC cli#471 I5; `labelle build --platform=ios` carries both. diff --git a/docs/provider-hooks.md b/docs/provider-hooks.md index ffca42d..8878ebe 100644 --- a/docs/provider-hooks.md +++ b/docs/provider-hooks.md @@ -252,8 +252,9 @@ in a fresh per-invocation directory, where it may write ``` to contribute to the environment of everything that runs after it in the -same command: the generation-time fingerprint pass (`zig build ---list-steps`), the core compile, and every later hook and replacement. The +same command: the core generation itself (the `labelle-assembler generate` +process, so a `before generate` hook can steer what is generated), the +generation-time fingerprint pass (`zig build --list-steps`), the core compile, and every later hook and replacement. The build of a provider's own tool never sees it. This is how a provider that provisions a toolchain hands it to the build without the CLI knowing the toolchain: a `before generate` hook is early enough for the fingerprint diff --git a/src/cli/assembler_proc.zig b/src/cli/assembler_proc.zig index d3362be..68b8c82 100644 --- a/src/cli/assembler_proc.zig +++ b/src/cli/assembler_proc.zig @@ -29,6 +29,8 @@ const config = @import("config.zig"); const supervise = @import("supervise.zig"); const assembler = @import("assembler.zig"); const progress = @import("progress.zig"); +const provider_env = @import("provider_env.zig"); +const runner = @import("runner.zig"); /// Global floor: every subcommand the CLI delegates needs at least this /// protocol (`install`/`clean`/`upgrade` arrived at protocol 2, `init` at @@ -104,7 +106,18 @@ pub const Assembler = struct { subcommand: []const u8, args: []const []const u8, ) !void { - return spawnAndWait(allocator, self.path, subcommand, args, self.fatal_on_failure); + return spawnAndWait(allocator, self.path, subcommand, args, self.fatal_on_failure, null); + } + + /// `run` with an explicit child environment (null inherits ours). + pub fn runWithEnv( + self: Assembler, + allocator: std.mem.Allocator, + subcommand: []const u8, + args: []const []const u8, + env: ?*const std.process.Environ.Map, + ) !void { + return spawnAndWait(allocator, self.path, subcommand, args, self.fatal_on_failure, env); } }; @@ -207,6 +220,12 @@ pub fn runSubcommand( /// user-facing `--scene` flag; the assembler always compiles a stable /// artifact with `initial_prefab` taken straight from `project.labelle`. /// +/// `contributed` is the environment this build's provider hooks have +/// contributed so far (contract §2 `env_file`): the `before generate` hooks +/// run first, so a provider can steer generation itself, not only the +/// compile (a provider-owned generation switch, say). +/// Null or empty: the assembler inherits the CLI's environment, as before. +/// /// On a non-zero exit code, returns `error.AssemblerFailed`; the binary's /// inherited stderr already explains the failure. pub fn generate( @@ -214,13 +233,31 @@ pub fn generate( allocator: std.mem.Allocator, project_dir: []const u8, target: []const u8, + contributed: ?*const provider_env.Accumulator, ) !void { var args: std.ArrayList([]const u8) = .empty; defer args.deinit(allocator); try buildGenerateArgs(allocator, &args, project_dir, target); - try asm_bin.run(allocator, "generate", args.items); + var base = try runner.buildEnvironWithExtra(allocator, &.{}); + defer base.deinit(); + var env_storage = try generateEnv(allocator, &base, contributed); + defer if (env_storage) |*m| m.deinit(); + try asm_bin.runWithEnv(allocator, "generate", args.items, if (env_storage) |*m| m else null); +} + +/// The environment `generate` hands the assembler: `base` (our own +/// environment) with the hooks' contribution merged on top, or null (inherit +/// unchanged) when nothing was contributed. Caller deinits a returned map. +fn generateEnv( + allocator: std.mem.Allocator, + base: *const std.process.Environ.Map, + contributed: ?*const provider_env.Accumulator, +) !?std.process.Environ.Map { + const env = contributed orelse return null; + if (env.isEmpty()) return null; + return try env.compose(allocator, base); } /// Build the argv slice for the assembler's `generate` subcommand. @@ -256,6 +293,37 @@ test { /// argv this module sends to `labelle-assembler generate` must NOT /// contain `--scene`. The override travels at runtime via the /// `LABELLE_SCENE` env var the CLI injects when it spawns the game. +/// The `before generate` hooks' environment reaches the assembler process +/// (contract §2): a provider can steer generation, not only the compile. +pub const GenerateEnvSpec = struct { + test "a contributed variable reaches the assembler's environment" { + const a = std.testing.allocator; + var acc: provider_env.Accumulator = .{}; + defer acc.deinit(); + var diag: provider_env.Diagnostic = .{}; + var arena = std.heap.ArenaAllocator.init(a); + defer arena.deinit(); + try acc.add(a, arena.allocator(), "p/toolchain", .{ .set = &.{.{ .name = "PROBE_GENERATE_SWITCH", .value = "1" }} }, &diag); + var base = std.process.Environ.Map.init(a); + defer base.deinit(); + try base.put("KEEP_ME", "yes"); + var env = (try generateEnv(a, &base, &acc)).?; + defer env.deinit(); + try std.testing.expectEqualStrings("1", env.get("PROBE_GENERATE_SWITCH").?); + try std.testing.expectEqualStrings("yes", env.get("KEEP_ME").?); + } + + test "no contribution: the assembler inherits our environment unchanged" { + const a = std.testing.allocator; + var base = std.process.Environ.Map.init(a); + defer base.deinit(); + try std.testing.expect((try generateEnv(a, &base, null)) == null); + var empty: provider_env.Accumulator = .{}; + defer empty.deinit(); + try std.testing.expect((try generateEnv(a, &base, &empty)) == null); + } +}; + pub const BuildGenerateArgsSpec = struct { pub const no_scene_forwarding = struct { test "argv to assembler never contains --scene" { @@ -352,6 +420,7 @@ fn spawnAndWait( subcommand: []const u8, args: []const []const u8, fatal_on_failure: bool, + env: ?*const std.process.Environ.Map, ) !void { const io = config.globalIo(); @@ -366,6 +435,7 @@ fn spawnAndWait( .stdin = .inherit, .stdout = .inherit, .stderr = .inherit, + .environ_map = env, }) catch |err| { std.debug.print("labelle: could not launch assembler '{s}': {any}\n", .{ exe_path, err }); return error.AssemblerFailed; diff --git a/src/cli/pipeline/generate.zig b/src/cli/pipeline/generate.zig index b70045a..6bf2cb3 100644 --- a/src/cli/pipeline/generate.zig +++ b/src/cli/pipeline/generate.zig @@ -86,7 +86,9 @@ pub fn run(cx: *const Context, generate_out: []const u8) !?u8 { // The assembler receives the resolved target NAME; the gate after the // install (`describe`'s `supported`) guarantees it can take it. - try assembler_proc.generate(cx.asm_bin, allocator, project_dir, target.name); + // The `before generate` hooks' environment reaches the assembler too + // (contract §2): a provider can steer generation, not only the compile. + try assembler_proc.generate(cx.asm_bin, allocator, project_dir, target.name, &hook_site.env); // (`target_name`/`target_dir` — .labelle/_/, named by `describe` — are // computed up front, before the progress reporter init; see cli#284.) diff --git a/src/cli/pipeline/rebuild.zig b/src/cli/pipeline/rebuild.zig index 19aede5..51a47c2 100644 --- a/src/cli/pipeline/rebuild.zig +++ b/src/cli/pipeline/rebuild.zig @@ -379,7 +379,7 @@ pub const RebuildCtx = struct { var asm_bin = self.asm_bin; // A failed regeneration fails this rebuild, never the session. asm_bin.fatal_on_failure = false; - assembler_proc.generate(asm_bin, a, self.project_dir, self.platform_tag) catch |err| { + assembler_proc.generate(asm_bin, a, self.project_dir, self.platform_tag, &self.hooks.env) catch |err| { if (self.canceled()) return error.Canceled; std.debug.print("labelle: rebuild generate failed ({s})\n", .{@errorName(err)}); return error.GenerateFailed; diff --git a/src/cli/provider_hooks.zig b/src/cli/provider_hooks.zig index 7a979a3..3fde2ae 100644 --- a/src/cli/provider_hooks.zig +++ b/src/cli/provider_hooks.zig @@ -313,7 +313,8 @@ pub const Site = struct { /// The environment the hooks of the CURRENT build contributed through /// their `env_file` (contract §2, wire `1.3.0`+), merged in hook /// execution order. Every later hook and replacement runs with it, and - /// the pipeline applies it to the fingerprint pass and the compile. A + /// the pipeline applies it to the assembler's generate, the fingerprint + /// pass and the compile. A /// rebuild `reset`s it first, so a hook that no longer runs leaves /// nothing behind. The pipeline deinits it. env: provider_env.Accumulator = .{},