Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions docs/provider-contract-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Implementation progress: [project-local dispatch](provider-local-dispatch.md)
implements the first executable slice of phase 2. Its explicit limitations
do not weaken the normative contract below; full phase-2 acceptance is pending.

This document supplies normative v1 details for [the architecture RFC](rfc-package-commands.md). Where the illustrative RFC conflicts, this contract takes precedence. Migration is breaking: no legacy forwarding or implicit provider injection. Contract negotiation checks a provider's declared semver range against the wire versions the CLI speaks; it never warns and proceeds. See [Wire versions and negotiation](#wire-versions-and-negotiation) below: the CLI implements `1.5.0` and still speaks `1.4.0`, `1.3.0`, `1.2.0`, `1.1.0` and `1.0.0`, and every context carries the negotiated version.
This document supplies normative v1 details for [the architecture RFC](rfc-package-commands.md). Where the illustrative RFC conflicts, this contract takes precedence. Migration is breaking: no legacy forwarding or implicit provider injection. Contract negotiation checks a provider's declared semver range against the wire versions the CLI speaks; it never warns and proceeds. See [Wire versions and negotiation](#wire-versions-and-negotiation) below: the CLI implements `1.6.0` and still speaks `1.5.0`, `1.4.0`, `1.3.0`, `1.2.0`, `1.1.0` and `1.0.0`, and every context carries the negotiated version.

## 1. Package declarations and installed tools

Expand Down Expand Up @@ -50,7 +50,7 @@ Every field below is required on the wires that define it, except the optional `

| Field | Type / rule |
| --- | --- |
| `contract_version` | The negotiated wire version: `"1.0.0"`, `"1.1.0"`, `"1.2.0"`, `"1.3.0"`, `"1.4.0"` or `"1.5.0"` for this decoder |
| `contract_version` | The negotiated wire version: `"1.0.0"`, `"1.1.0"`, `"1.2.0"`, `"1.3.0"`, `"1.4.0"`, `"1.5.0"` or `"1.6.0"` for this decoder |
| `invocation` | Object containing `kind`, `id`, `step`, `phase` |
| `invocation.kind` | `"command"` or `"hook"` |
| `invocation.id` | Command name or hook ID |
Expand Down Expand Up @@ -111,7 +111,22 @@ A `before generate`, `after generate` or `before build` hook receives `env_file`
- **Format.** Strict JSON; both keys are optional; unknown keys, duplicate keys and wrong types are errors. Each `set` entry has exactly `name` and `value`.
- **Names** match `[A-Za-z_][A-Za-z0-9_]*`, appear once per file, and are not CLI-owned. The CLI-owned names are a fixed table (`config.reserved_env` in `src/cli/config.zig`), not a `LABELLE_*` prefix ban: `PATH` (extend it with `path_prepend`), `ZIG_GLOBAL_CACHE_DIR`, `ZIG_LOCAL_CACHE_DIR`, every `LABELLE_*` variable the CLI reads (`LABELLE_HOME`, `LABELLE_CONTEXT`, `LABELLE_OFFLINE`, `LABELLE_ZIG`, `LABELLE_ASSEMBLER`, …) and the `labelle run` options it sets for the game. Any other name may be set, `LABELLE_*` or not; a toolchain library directory such as `LABELLE_SDL2_LIB` stays settable. A test fails when the CLI source spells a `LABELLE_*` name the table does not classify.
- **`path_prepend`** entries are absolute paths for the host (drive-qualified or UNC on Windows) and contain no PATH separator.
- **File states.** Missing: no contribution, which is normal. Empty, malformed (including a reserved name, a bad name or a relative PATH entry) or larger than 1 MiB: the command fails right after that hook, before any later zig invocation, with `labelle: hook '<package>/<id>' wrote an invalid env_file: <reason>`. Written by a hook that then failed: ignored; the hook's failure is the outcome.
- **`build_options`** (wire `1.6.0`+, cli#471 D3): `[{"name": ..., "value": ...}]`, each appended as `-D<name>=<value>` to the core `zig build`. See [Build options](#build-options).
- **File states.** Missing: no contribution, which is normal. Empty, malformed (including a reserved name, a bad name, a relative PATH entry or a refused build option) or larger than 1 MiB: the command fails right after that hook, before any later zig invocation, with `labelle: hook '<package>/<id>' wrote an invalid env_file: <reason>`. Written by a hook that then failed: ignored; the hook's failure is the outcome.

#### Build options

From wire `1.6.0` the file may also carry `build_options`, the Zig build options the target needs (a device build rather than a simulator one, say):

```json
{ "set": [ { "name": "SDK_ROOT", "value": "/abs/sdk" } ], "build_options": [ { "name": "device", "value": "true" } ] }
```

- **Who.** Only the **target owner**'s `before generate` and `before build` hooks, on a negotiated wire of `1.6.0` or newer. The key from any other provider, from an `after generate` hook or from an owner capped below `1.6.0` makes the file invalid, even as an empty list, with `labelle: hook '<package>/<id>' wrote an invalid env_file: build_options may only come from the owner of target '<t>', not package '<p>'` (or `... from a 'before generate' or 'before build' hook, not '<phase> <step>'`, or `build_options need provider contract >= 1.6.0; package '<p>' speaks <wire>`).
- **Shape.** Each entry has exactly `name` and `value`. Names match `[A-Za-z_][A-Za-z0-9_-]*` and appear once per file (case-sensitive, like Zig's own options); values are strings without a NUL byte or a line break.
- **CLI-owned options.** `optimize` (the CLI passes `-Doptimize` from `--optimize` or the owner's `target_defaults`) and `target` (`--docker --target`) are refused in the file: `build option 'optimize' is owned by the CLI (it passes -Doptimize itself)`. When the argv is assembled, a contributed option the CLI's own arguments already set fails the command before the zig invocation, naming both: `labelle: build option '-D<name>=<value>' from hook '<package>/<id>' conflicts with the CLI's '-D<name>=...'`.
- **Merge.** As for `set`: hook execution order, then list order; two hooks giving one option different values is `labelle: hook environment conflict: build option '-D<name>' is set to different values by hooks '<a>' and '<b>'`; the same value twice is fine.
- **Scope.** The options follow the CLI's own arguments, in that order, on every later `zig build` of the same build: the generation-time fingerprint pass (`zig build --list-steps -D...`, which a `before generate` contribution reaches and a `before build` one does not) and the core compile (`zig build -Doptimize=ReleaseFast -Ddevice=true`), watched rebuilds included (each starts over, like the environment). They never reach a hook's process, a provider's own tool build or the game. A `replace build` hook stands in for the compile, so there the options reach only the fingerprint pass. A watched rebuild whose options changed publishes normally: the running replacement's environment is what a session compares, not the options.

**Merge.** Contributions apply in hook execution order (phases, `after_hooks` edges, then qualified ID) and accumulate across the phases of one build:

Expand Down Expand Up @@ -169,10 +184,11 @@ The cold build is published as generation `0` before the replacement starts. A f

### Wire versions and negotiation

The CLI implements contract `1.5.0` and speaks every wire version listed here, newest first:
The CLI implements contract `1.6.0` and speaks every wire version listed here, newest first:

| Wire | Adds |
| --- | --- |
| `1.6.0` | `build_options` in env_file (additive minor, cli#471 D3). The context itself is unchanged; only the target owner's `before generate` / `before build` `env_file` may carry the key. |
| `1.5.0` | `outcome_file` in the `run` object (additive minor, cli#473). |
| `1.4.0` | `final_step` on every context (additive minor, cli#443). |
| `1.3.0` | `cache_dir` and `env_file` on every context, and `watch` in the `run` object (additive minor, CLI 2.1.0). |
Expand All @@ -182,7 +198,8 @@ The CLI implements contract `1.5.0` and speaks every wire version listed here, n

For each invocation the CLI negotiates the **newest** wire version the provider's `command_contract` range admits and writes it as `contract_version`; a range that admits none of them is `UnsupportedContract` at discovery. Keys a wire version does not define are never emitted in it, so a provider decoding strictly (unknown fields are errors, as above) keeps working:

- `>=1.0.0 <2.0.0` admits every additive v1 minor, so it receives `1.5.0` and must accept the keys `1.1.0`, `1.2.0`, `1.3.0`, `1.4.0` and `1.5.0` add. A provider declaring such a range promises exactly that.
- `>=1.0.0 <2.0.0` admits every additive v1 minor, so it receives `1.6.0` and must accept the keys `1.1.0`, `1.2.0`, `1.3.0`, `1.4.0` and `1.5.0` add (`1.6.0` adds none to the context, only what an owner may write). A provider declaring such a range promises exactly that.
- `<1.6.0` (for example `>=1.3.0 <1.6.0`) receives the exact `1.5.0` wire: its `env_file` may not carry `build_options`.
- `<1.5.0` (for example `>=1.3.0 <1.5.0`) receives the exact `1.4.0` wire, without `run.outcome_file`: its run replacement cannot report a timeout, so a status-0 exit is always a clean one.
- `<1.4.0` (for example `>=1.0.0 <1.4.0`) receives the exact `1.3.0` wire, without `final_step`: its hooks run exactly as on `1.4.0` but cannot tell which command runs them.
- `<1.3.0` (for example `>=1.0.0 <1.3.0`) receives the exact `1.2.0` wire, without `cache_dir`, `env_file` or `run.watch`: its hooks cannot contribute an environment and its run replacement cannot run a watch session.
Expand Down
10 changes: 9 additions & 1 deletion docs/provider-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,8 +275,16 @@ pass, which already configures the generated build; an `after generate` hook
- The environment is rebuilt for every build, including every watched
rebuild, so a hook that stops running leaves nothing behind.

- On wire `1.6.0` the **target owner**'s `before generate` and `before
build` hooks may also write `"build_options": [{"name": "device",
"value": "true"}]`: each becomes `-D<name>=<value>` after the CLI's own
arguments on the fingerprint pass and the core compile. Any other hook,
or an owner capped below `1.6.0`, may not send the key; `optimize` and
`target` are the CLI's.

The full rules are in the contract:
[environment contributions](provider-contract-v1.md#environment-contributions).
[environment contributions](provider-contract-v1.md#environment-contributions)
and [build options](provider-contract-v1.md#build-options).
A provider capped below `1.3.0` gets neither key, so its hooks can't
contribute.

Expand Down
25 changes: 23 additions & 2 deletions src/cli/pipeline/build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const runner = @import("../runner.zig");
const bundle = @import("../bundle.zig");
const linux_desktop = @import("../linux_desktop.zig");
const provider_hooks = @import("../provider_hooks.zig");
const provider_env = @import("../provider_env.zig");
const Context = @import("context.zig").Context;

/// Provider hooks on `build` (contract §6) wrap the whole core build —
Expand Down Expand Up @@ -46,6 +47,11 @@ pub fn run(
null;
defer if (composed_env) |*m| m.deinit();
const compile_env: ?*const std.process.Environ.Map = if (composed_env) |*m| m else zig_env_ptr;
// The target owner's `build_options` (wire 1.6.0+) follow the CLI's own
// arguments; one the CLI already passes is a conflict naming both.
var args_arena = std.heap.ArenaAllocator.init(allocator);
defer args_arena.deinit();
const compile_args = try compileArgs(args_arena.allocator(), hook_site, zig_args, reporter);
core_build: {
if (hook_plans.build.replace) |replacement| {
const code = try provider_hooks.runPhase(hook_site, &.{replacement}, .build, .replace, build_out);
Expand All @@ -72,7 +78,7 @@ pub fn run(
// terminal unaltered (nothing is captured or eaten).
std.debug.print("labelle: building...\n", .{});
r.beginPhaseOrStep(.compile, "zig build");
const build_code = try runner.runZigInheritProgress(allocator, target_dir, zig_args, compile_env, r);
const build_code = try runner.runZigInheritProgress(allocator, target_dir, compile_args, compile_env, r);
// Wipe the spinner line before anything else prints on it.
r.clearSpinner();
if (build_code != 0) {
Expand All @@ -82,7 +88,7 @@ pub fn run(
}
} else {
std.debug.print("labelle: building...\n", .{});
const build_result = try runner.runZigWithEnv(allocator, target_dir, zig_args, compile_env);
const build_result = try runner.runZigWithEnv(allocator, target_dir, compile_args, compile_env);
defer allocator.free(build_result.stdout);
defer allocator.free(build_result.stderr);

Expand Down Expand Up @@ -129,6 +135,21 @@ pub fn run(
return null;
}

/// `zig_args` plus the build options the hooks contributed so far (contract
/// §2 `build_options`), or the conflict with a CLI-owned argument reported
/// and `error.BuildOptionConflict`.
pub fn compileArgs(a: std.mem.Allocator, hook_site: *const provider_hooks.Site, zig_args: []const []const u8, reporter: anytype) ![]const []const u8 {
var diag: provider_env.Diagnostic = .{};
return hook_site.env.zigArgs(a, zig_args, &diag) catch |err| switch (err) {
error.BuildOptionConflict => {
std.debug.print("labelle: {s}\n", .{diag.message});
if (reporter) |r| r.finishFailed(1, "build option conflict");
return err;
},
else => return err,
};
}

/// `labelle bundle` (cli#359): the exe is built; wrap it. Packaging
/// runs AFTER the compile, so keep the progress feed open across it
/// (a `run` phase) and only mark `done` once
Expand Down
9 changes: 8 additions & 1 deletion src/cli/pipeline/rebuild.zig
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,14 @@ pub const RebuildCtx = struct {
null;
defer if (composed) |*m| m.deinit();
const env: ?*const std.process.Environ.Map = if (composed) |*m| m else self.zig_env;
const res = runner.runZigWithEnv(a, self.target_dir, self.zig_args, env) catch |err| {
var args_arena = std.heap.ArenaAllocator.init(a);
defer args_arena.deinit();
var diag: @import("../provider_env.zig").Diagnostic = .{};
const args = self.hooks.env.zigArgs(args_arena.allocator(), self.zig_args, &diag) catch |err| {
std.debug.print("labelle: rebuild: {s}\n", .{if (err == error.BuildOptionConflict) diag.message else @errorName(err)});
return error.BuildFailed;
};
const res = runner.runZigWithEnv(a, self.target_dir, args, env) catch |err| {
if (self.canceled()) return error.Canceled;
std.debug.print("labelle: rebuild could not spawn zig ({s})\n", .{@errorName(err)});
return error.ZigSpawnFailed;
Expand Down
52 changes: 48 additions & 4 deletions src/cli/provider_contract.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@
const std = @import("std");

/// The contract version this CLI implements: the newest wire it speaks.
pub const version = "1.5.0";
pub const version = "1.6.0";

/// Every wire version this CLI can speak, newest first. A minor is additive:
/// `1.1.0` is `1.0.0` plus the optional `build_number` key, `1.2.0` is
/// `1.1.0` plus `target_dir` and the `run` options, `1.3.0` is `1.2.0`
/// plus `cache_dir` and `env_file`, `1.4.0` is `1.3.0` plus
/// `final_step`, and `1.5.0` is `1.4.0` plus `run.outcome_file` (§2).
/// `final_step`, `1.5.0` is `1.4.0` plus `run.outcome_file` (§2), and
/// `1.6.0` is `1.5.0` plus `build_options` in the target owner's
/// `env_file` (§2 "Environment contributions"; the context is unchanged).
/// The version a provider receives is negotiated from its `command_contract` range
/// (`provider_manifest.negotiate`), so a provider pinned to `<1.1.0` keeps
/// receiving the exact `1.0.0` wire and never sees a key it would reject as
/// unknown.
pub const supported_versions = [_][]const u8{ version, "1.4.0", "1.3.0", "1.2.0", "1.1.0", "1.0.0" };
pub const supported_versions = [_][]const u8{ version, "1.5.0", "1.4.0", "1.3.0", "1.2.0", "1.1.0", "1.0.0" };

/// The first wire version that carries `build_number`.
pub const build_number_since = "1.1.0";
Expand All @@ -33,6 +35,9 @@ pub const final_step_since = "1.4.0";
/// The first wire version whose `run` context carries `outcome_file`.
pub const outcome_context_since = "1.5.0";

/// The first wire version whose `env_file` may carry `build_options`.
pub const build_options_since = "1.6.0";

fn atLeast(wire_version: []const u8, since: []const u8) bool {
const wire = std.SemanticVersion.parse(wire_version) catch return false;
const floor = std.SemanticVersion.parse(since) catch unreachable;
Expand Down Expand Up @@ -74,6 +79,22 @@ pub fn carriesOutcomeContext(wire_version: []const u8) bool {
return atLeast(wire_version, outcome_context_since);
}

/// True when a provider on the wire `contract_version` may write
/// `build_options` to its `env_file` (the target owner's `before generate`
/// and `before build` hooks only, `buildOptionsSlot`).
pub fn carriesBuildOptions(wire_version: []const u8) bool {
return atLeast(wire_version, build_options_since);
}

/// Whether the hook `invocation` may contribute `build_options` through its
/// `env_file`, ownership and wire aside: `before generate` and `before build`
/// only (an `after generate` hook's `env_file` may carry the environment but
/// not build options).
pub fn buildOptionsSlot(invocation: Invocation) bool {
if (!envFileSlot(invocation)) return false;
return invocation.phase.? == .before;
}

/// Whether a command whose last lifecycle step is `final` runs the hooks of
/// `step` (contract §6): every command runs `generate`; `build`, `run` and
/// `bundle` run `build` first; and `run` and `bundle` are alternatives, so
Expand Down Expand Up @@ -767,7 +788,7 @@ test "build_number is optional on the wire and only for bundle hooks" {
}

test "a 1.0.0 context never carries build_number; every wire otherwise validates" {
try std.testing.expectEqualStrings("1.5.0", version);
try std.testing.expectEqualStrings("1.6.0", version);
try std.testing.expect(carriesBuildNumber("1.1.0"));
try std.testing.expect(carriesBuildNumber("1.2.0"));
try std.testing.expect(carriesBuildNumber("1.3.0"));
Expand Down Expand Up @@ -809,10 +830,33 @@ test "a 1.0.0 context never carries build_number; every wire otherwise validates
try value.validate(true);
value.contract_version = "1.5.0";
try value.validate(true);
// `1.6.0` adds no context key (its `build_options` live in the env_file).
value.contract_version = "1.6.0";
try value.validate(true);
value.contract_version = "1.7.0";
try std.testing.expectError(error.UnsupportedContract, value.validate(true));
}

test "1.6.0: build_options are wire-gated and slot-gated to before generate / before build" {
try std.testing.expect(carriesBuildOptions("1.6.0"));
try std.testing.expect(!carriesBuildOptions("1.5.0"));
try std.testing.expect(!carriesBuildOptions("1.0.0"));
const slot = struct {
fn of(step: Step, phase: Phase) bool {
return buildOptionsSlot(.{ .kind = .hook, .id = "h", .step = step, .phase = phase });
}
}.of;
try std.testing.expect(slot(.generate, .before));
try std.testing.expect(slot(.build, .before));
// `after generate` contributes an environment, never build options.
try std.testing.expect(!slot(.generate, .after));
try std.testing.expect(!slot(.build, .after));
try std.testing.expect(!slot(.build, .replace));
try std.testing.expect(!slot(.run, .before));
try std.testing.expect(!slot(.bundle, .before));
try std.testing.expect(!buildOptionsSlot(.{ .kind = .command, .id = "c", .step = null, .phase = null }));
}

/// A project hook context on `wire` for `step`, from the projectless fixture.
pub fn hookContext(base: Context, wire: []const u8, step: Step) Context {
var value = base;
Expand Down
2 changes: 1 addition & 1 deletion src/cli/provider_dispatch_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ test "provider dispatch: build_number reaches only a provider whose range admits
try std.testing.expectEqualStrings("42", open.build_number.?);
const open_wire = try std.json.Stringify.valueAlloc(a, open, .{});
try std.testing.expect(std.mem.indexOf(u8, open_wire, "\"build_number\":\"42\"") != null);
try std.testing.expect(std.mem.indexOf(u8, open_wire, "\"contract_version\":\"1.5.0\"") != null);
try std.testing.expect(std.mem.indexOf(u8, open_wire, "\"contract_version\":\"1.6.0\"") != null);
// A range capped at the 1.1 wire still gets the key, and nothing newer.
provider.meta.command_contract = ">=1.0.0 <1.2.0";
const mid = try wireContext(provider, host, abs, run, cache);
Expand Down
Loading
Loading