From 68d766ac4a6e8d53204d27b92427beb4e637c054 Mon Sep 17 00:00:00 2001 From: Daniel Murta Date: Tue, 29 Sep 2026 09:45:34 -0300 Subject: [PATCH 1/4] feat!: remove iOS from the core; the ios target belongs to labelle-ios (cli#471 I5) The ios target is owned by labelle-toolkit/labelle-ios (v0.1.0, in the provider registry): app bundling after build, simulator launch as the run replacement, and the bundle replacement. The core keeps nothing iOS-specific: - src/cli/ios.zig is deleted, along with the `labelle ios` command, its parsing and help, and Command.ios_cmd. - Deleted: the legacy own-build branch, the forced sokol backend (no silent substitution, D5), the ios Bypass arms, legacyRunBranch, and the simulator launch branch. - `ios` is no longer a reserved provider namespace. - IosConfig/Orientation are gone. project.labelle is read with ignore_unknown_fields, so an old `.ios` block still parses and is ignored. `labelle ios ...` now gives the unknown-command diagnostic with nothing generated. `run --platform=ios` without an owning provider gives the no-provider diagnostic, or NoRunReplacement when the provider has no `replace run`. The e2e scripts and docs are updated. The agnostic guard drops five entries. BREAKING CHANGE: `labelle ios` is removed; use labelle-ios with `labelle build|run|bundle --platform=ios`. Targets for ios generate into _ios, since the backend is no longer forced. --- docs/provider-contract-v1.md | 4 +- docs/provider-hooks.md | 16 +- docs/provider-local-dispatch.md | 13 +- docs/provider-targets.md | 33 +- docs/rfc-ios-platform.md | 6 + src/agnostic_guard_test.zig | 16 +- src/cli.zig | 102 +--- src/cli/args.zig | 2 +- src/cli/config.zig | 2 +- src/cli/ios.zig | 915 ------------------------------ src/cli/pipeline.zig | 54 +- src/cli/pipeline/args_resolve.zig | 10 +- src/cli/pipeline/generate.zig | 10 +- src/cli/pipeline/install.zig | 118 ++-- src/cli/pipeline/run.zig | 389 ++++++------- src/cli/project_config.zig | 75 +-- src/cli/provider_dispatch.zig | 40 +- src/cli/provider_hooks.zig | 2 +- test/provider_hooks_e2e.py | 23 +- test/provider_targets_e2e.py | 22 +- 20 files changed, 365 insertions(+), 1487 deletions(-) delete mode 100644 src/cli/ios.zig diff --git a/docs/provider-contract-v1.md b/docs/provider-contract-v1.md index 43b1e632..a9e40078 100644 --- a/docs/provider-contract-v1.md +++ b/docs/provider-contract-v1.md @@ -69,7 +69,7 @@ Every field below is required on the wires that define it, except the optional ` | `run` | **Optional**, **wire `1.2.0`+**: present on every `run`-step hook context (any phase) and absent (never null) everywhere else. An object with the required keys `env`, `args` and `timeout_ms`, plus `watch` from wire `1.3.0` and `outcome_file` from wire `1.5.0` (see below) | | `cache_dir` | **Wire `1.3.0`+, required there, never null**: the provider's persistent cache directory, on every context (commands and hooks). See [Provider cache](#provider-cache). The key doesn't exist below `1.3.0` | | `env_file` | **Wire `1.3.0`+, required there**: on a `before generate`, `after generate` or `before build` hook, an absolute path the hook may write its environment contribution to; null on every other hook and on commands. See [Environment contributions](#environment-contributions). The key doesn't exist below `1.3.0` | -| `final_step` | **Wire `1.4.0`+, required there**: on a hook, the last lifecycle step of the CLI command that runs it (see [§6](#6-hook-execution)): `"generate"` (`labelle generate`, and the legacy `labelle ios` subcommand, which only runs the generate hooks), `"build"`, `"run"` (`labelle run`, every watched rebuild included) or `"bundle"`. It is always a step the hook's own step leads to: any value on a `generate` hook, never `"generate"` on a `build` hook, and exactly its own step on a `run` or `bundle` hook; anything else is an error. Null on a command. The key doesn't exist below `1.4.0` | +| `final_step` | **Wire `1.4.0`+, required there**: on a hook, the last lifecycle step of the CLI command that runs it (see [§6](#6-hook-execution)): `"generate"` (`labelle generate`), `"build"`, `"run"` (`labelle run`, every watched rebuild included) or `"bundle"`. It is always a step the hook's own step leads to: any value on a `generate` hook, never `"generate"` on a `build` hook, and exactly its own step on a `run` or `bundle` hook; anything else is an error. Null on a command. The key doesn't exist below `1.4.0` | The `run` object holds the `labelle run` options for a hook that wraps or replaces the launch: @@ -124,7 +124,7 @@ A `before generate`, `after generate` or `before build` hook receives `env_file` **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 runtime `SDL2.dll` the core stages beside a desktop exe is looked up through `LABELLE_SDL2_LIB` as the compile saw it, so a contributed value is honoured. -**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 runs its own `zig build` after generation, with neither, so it is refused when the plan has such a hook or the target's owner declares an optimize default; `labelle build --platform=ios` carries both. +**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. ### Run outcome diff --git a/docs/provider-hooks.md b/docs/provider-hooks.md index 4f053016..1bc79a10 100644 --- a/docs/provider-hooks.md +++ b/docs/provider-hooks.md @@ -351,15 +351,13 @@ build` hook signed, stripped or patched in `zig-out/` is what runs. - No JSON progress relay or validation for hook (or command) stdout yet: the child's stdio is inherited exactly as for provider commands. -- The legacy `labelle ios …` subcommand handler is not a hook point for - `build`/`run`; it only shares the `generate` hooks. It leaves with platform - extraction (its target already goes through the resolver, so it needs the - pinned provider like `--platform=`). `labelle android …` left with - cli#405: it is the `android` provider's namespace, and `--platform=android` - runs the ordinary hook points. -- A provider target other than the legacy `ios` run branch must - replace `run` (`NoRunReplacement`, [provider - targets](provider-targets.md#labelle-run)). +- `labelle android …` left with cli#405 and `labelle ios …` with RFC cli#471 + I5: neither is a built-in, and `--platform=android` / `--platform=ios` run + the ordinary hook points of their pinned provider + ([labelle-ios](https://github.com/labelle-toolkit/labelle-ios) for `ios`). +- Every provider target must replace `run` (`NoRunReplacement`, [provider + targets](provider-targets.md#labelle-run)); the core has no launch of its + own for any of them. - `labelle bundle` for the core `desktop` target is still refused on Linux and Windows — before any install or build — because the core packager is macOS-only and no hook can replace it (nobody may own `desktop`). A diff --git a/docs/provider-local-dispatch.md b/docs/provider-local-dispatch.md index d465d3bc..41f2f3e9 100644 --- a/docs/provider-local-dispatch.md +++ b/docs/provider-local-dispatch.md @@ -12,12 +12,13 @@ Run `labelle [arguments...]` from a project directory or one of its descendants. Discovery walks to the nearest `project.labelle` and reads manifests from that project's `.plugins`; it never searches global pins. Built-ins take precedence, followed by package namespaces, then directory -shorthand. A package cannot claim a built-in namespace. The existing `ios` -and `wasm` commands remain reserved until their extraction lands; their -target already resolves like `--platform=` does, through the pinned -provider that declares it ([provider targets](provider-targets.md)). -`android` left the reserved list with its extraction (cli#405): the -`android` package declares it as its namespace. A first word that is no +shorthand. A package cannot claim a built-in namespace. No platform word +is reserved any more: `android` left the list with its extraction +(cli#405; the `android` package declares it as its namespace), `wasm` with +the legacy browser commands (RFC cli#466 PR B), and `ios` when iOS moved +into [labelle-ios](https://github.com/labelle-toolkit/labelle-ios) (RFC +cli#471 I5). Their targets resolve like any other, through the pinned +provider that declares them ([provider targets](provider-targets.md)). A first word that is no built-in, no pinned package's namespace and no directory is an unknown command; when the cached schema-2 registry names a package declaring it as a namespace, the diagnostic says so instead (`(registry: )`). diff --git a/docs/provider-targets.md b/docs/provider-targets.md index fc39c094..22214bac 100644 --- a/docs/provider-targets.md +++ b/docs/provider-targets.md @@ -12,7 +12,7 @@ and the CLI carries no Android code (cli#405). The other platform packages ## Resolution Every command that runs the project pipeline (`generate`, `build`, `run`, -`bundle`, and the legacy `ios` subcommand) resolves +`bundle`) resolves one target, in two halves, before anything is generated, locked or built: 1. The requested name is `--platform=` when given, else the project's @@ -201,9 +201,9 @@ explicit line: - A provider target whose name is a schema platform (`wasm`, `android`, `ios`) is handed to the assembler as `--platform `, exactly as - before. The legacy pipeline branch that keys on the enum (`ios`) keeps - working for it; `android` and `wasm` have none left, so their providers - replace `run` (see [`labelle run`](#labelle-run)). + before. No pipeline branch keys on the enum any more (the last, the `ios` + simulator launch, left with RFC cli#471 I5), so their providers replace + `run` (see [`labelle run`](#labelle-run)). - A provider target outside the enum can only be generated for by its provider's `replace` hook on `generate`. Without one the command stops before the assembler runs: @@ -227,10 +227,11 @@ slice, and `provider_settings.zig` is untouched. ## `labelle run` -The CLI launches only the core `desktop` target on this host, plus the -legacy run branch it still carries (the `ios` simulator). The core's own -browser serve left in 3.0: the `web` provider's `replace run` hook serves -`wasm` now. +The CLI launches only the core `desktop` target on this host. The core's +own browser serve left in 3.0 (the `web` provider's `replace run` hook +serves `wasm` now), and its `ios` simulator launch with RFC cli#471 I5 +([labelle-ios](https://github.com/labelle-toolkit/labelle-ios)'s `replace +run` hook launches it now). Every other provider target is launched by its provider, so it must have a `replace` hook on `run`, otherwise: @@ -312,8 +313,8 @@ package injection (RFC #406 "Migration", #410). The user-facing walkthrough, with Flying Platform as the worked example, is [Migrating a project to labelle CLI 2.0](migrating-to-2.0.md): -- A project that builds for `wasm`, `android` or `ios` — through `.platform`, - `--platform=`, or `labelle ios …` — must add the package that declares that target to +- A project that builds for `wasm`, `android` or `ios` — through `.platform` + or `--platform=` — must add the package that declares that target to `.plugins` (as `.repo = "github.com//"`) and pin it (`labelle providers resolve`, then `--accept`). Until it does, those commands fail with the no-provider error above. Commit @@ -323,10 +324,14 @@ with Flying Platform as the worked example, is ([pins](provider-github-pins.md#fresh-checkouts-and-ci-labelle-providers-fetch)). - The target name is unchanged: `--platform=wasm` stays `--platform=wasm`, because the web provider declares the target `wasm`. Only the pin is new. -- The legacy `labelle ios` command word stays a reserved built-in until its - extraction lands; it routes its target through the same resolver. The - legacy `labelle wasm serve|export` left the core in 3.0: `wasm` is no - reserved word any more ([migrating to 3.0](migrating-to-3.0.md)). +- The legacy `labelle wasm serve|export` left the core in 3.0: `wasm` is no + reserved word any more ([migrating to 3.0](migrating-to-3.0.md)). The + legacy `labelle ios` subcommand followed (RFC cli#471 I5): the word is an + unknown command unless a pinned package declares it as a namespace, and + iOS builds, runs and bundles through + [labelle-ios](https://github.com/labelle-toolkit/labelle-ios) with + `--platform=ios`; its settings (formerly the `.ios` block, which the CLI + now ignores) live in `providers/ios.json`. - `labelle android …` is no built-in any more (cli#405): it is the `android` provider's namespace. Without that package pinned, `labelle android` is an unknown command — or, inside a project, when the one document the target diff --git a/docs/rfc-ios-platform.md b/docs/rfc-ios-platform.md index 35552a3e..3730f038 100644 --- a/docs/rfc-ios-platform.md +++ b/docs/rfc-ios-platform.md @@ -1,5 +1,11 @@ # RFC: iOS Platform Support +> **Status: superseded.** iOS left the CLI core with RFC cli#471 I5: the +> `labelle ios` subcommand, the simulator deploy and the `.ios` block are +> gone. iOS is the [labelle-ios](https://github.com/labelle-toolkit/labelle-ios) +> provider now (`--platform=ios`, settings in `providers/ios.json`). This +> document is kept as the historical design. + ## Problem Statement labelle-cli v1.x recognizes `--platform=ios` and generates callback-based Zig code via sokol's `mobile.txt` template, but the platform is non-functional. A developer who runs `labelle build --platform=ios` gets a binary that **cannot run on iOS** because: diff --git a/src/agnostic_guard_test.zig b/src/agnostic_guard_test.zig index 7e7a0841..486784d2 100644 --- a/src/agnostic_guard_test.zig +++ b/src/agnostic_guard_test.zig @@ -104,7 +104,10 @@ const allowed_words = [_][]const u8{ "macos", "windows", "linux", "darwin", "win /// `cli/runner.zig` came clean: 18 entries. RFC cli#471 X2 moved the /// vendored stb single-headers to `vendor/stb/` (third-party code lives /// outside `src/`), taking `cli/stb_image.h`'s upstream comment with it: -/// 17 entries. +/// 17 entries. RFC cli#471 I5 removed iOS from the core (the `labelle-ios` +/// provider owns it now): `cli/ios.zig` went with its file, and +/// `cli/args.zig`, `cli/pipeline/args_resolve.zig`, `cli/pipeline/run.zig` +/// and `cli/provider_dispatch.zig` came clean: 12 entries. /// Shrink only: an entry whose file is clean fails the test until it is /// removed. Note the path scan: an entry /// under `cli/android/` or named `cli/ios.zig` stays dirty until the file is @@ -114,21 +117,16 @@ const allowed_files = [_][]const u8{ "agnostic_guard_test.zig", // Legacy platform, store, package and backend sites (RFC #406 "Migration"). "cli.zig", - "cli/args.zig", "cli/config.zig", "cli/docker.zig", "cli/doctor.zig", - "cli/ios.zig", "cli/pipeline.zig", // Split out of `cli/pipeline.zig` (moves only): the stage code they // carry names the legacy targets, backends and their toolchains. - "cli/pipeline/args_resolve.zig", "cli/pipeline/build.zig", "cli/pipeline/generate.zig", "cli/pipeline/install.zig", - "cli/pipeline/run.zig", "cli/project_config.zig", - "cli/provider_dispatch.zig", "cli/sdl_provision.zig", "cli/upgrade.zig", }; @@ -514,8 +512,8 @@ test "host OS names never flag" { test "the allowlist matches Windows-style walker paths, one file per entry" { try std.testing.expect(allowedIndex("cli/pipeline.zig") != null); try std.testing.expect(allowedIndex("cli\\pipeline.zig") != null); - try std.testing.expect(allowedIndex("cli\\pipeline\\run.zig") != null); - try std.testing.expect(allowedIndex("cli/ios.zig") != null); + try std.testing.expect(allowedIndex("cli\\pipeline\\generate.zig") != null); + try std.testing.expect(allowedIndex("cli/sdl_provision.zig") != null); // A new file under a legacy directory is NOT exempt. try std.testing.expect(allowedIndex("cli/pipeline/not_yet_written.zig") == null); try std.testing.expect(allowedIndex("cli/pipeline/") == null); @@ -597,13 +595,11 @@ test "a platform in the path is a finding, and keeps an allowlist entry dirty" { // clean contents: the entry is only stale once the file is moved or // renamed. try scan.file("cli/sdl_provision.zig", "const x = 1;\n"); - try scan.file("cli\\ios.zig", ""); try std.testing.expectEqual(@as(usize, 7), scan.offenders.items.len); var stale: std.ArrayList([]const u8) = .empty; defer stale.deinit(gpa); try scan.stale(&stale); try std.testing.expect(!containsString(stale.items, "cli/sdl_provision.zig")); - try std.testing.expect(!containsString(stale.items, "cli/ios.zig")); try std.testing.expect(containsString(stale.items, "cli/upgrade.zig")); } diff --git a/src/cli.zig b/src/cli.zig index 40c72686..e7710eb5 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -38,7 +38,6 @@ const assembler = @import("cli/assembler.zig"); const assembler_proc = @import("cli/assembler_proc.zig"); const zig_toolchain = @import("cli/zig_toolchain.zig"); const bake_mod = @import("cli/bake.zig"); -const ios = @import("cli/ios.zig"); const util = @import("cli/util.zig"); const pack = @import("cli/pack.zig"); const progress = @import("cli/progress.zig"); @@ -314,25 +313,6 @@ pub fn main(proc_init: std.process.Init) !u8 { } else if (std.mem.eql(u8, first, "doctor")) { parsed_args.command = .doctor_cmd; try collectExtraArgs(&args, &parsed_args); - } else if (std.mem.eql(u8, first, "ios")) { - parsed_args.command = .ios_cmd; - // The legacy platform subcommand requests its target by name and - // goes through the same resolver as `--platform=ios`: without a - // pinned provider declaring it, it fails with the no-provider - // error (RFC #406 "Migration", no shim; docs/provider-targets.md). - parsed_args.platform_override = "ios"; - // First non-flag arg that isn't a subcommand is the project dir - while (args.next()) |arg| { - if (std.mem.startsWith(u8, arg, "-") or - std.mem.eql(u8, arg, "build") or - std.mem.eql(u8, arg, "xcode") or - std.mem.eql(u8, arg, "run")) - { - try appendExtraArg(&parsed_args, arg); - } else { - parsed_args.project_dir = arg; - } - } } else if (std.mem.eql(u8, first, "assembler")) { parsed_args.command = .assembler_cmd; try collectExtraArgs(&args, &parsed_args); @@ -382,26 +362,6 @@ pub fn main(proc_init: std.process.Init) !u8 { const command = parsed_args.command; - // ── Help resolution — BEFORE any dispatch ──────────────────────── - // - // A usage request must never reach a handler that DOES something. - // This is the one place that decides "this invocation can only print - // usage", and it sits above EVERY dispatch below it: the standalone - // switch and `pipeline.run`. Any fast path added later lands - // underneath it by construction, so the bug cannot recur at a new - // site. - // - // That ordering is the whole point. The same blind spot was fixed at - // several sites during cli#355 and cli#361 review, each time as a - // check placed after some earlier `return` (a legacy platform - // subcommand's `--help` ran a whole build, or probed a toolchain). - // Hoisting the decision above every fast path retires the pattern - // instead of patching one more site. - if (helpOnlyPrinter(command, parsed_args.extra_args[0..parsed_args.extra_count])) |printUsage| { - printUsage(); - return 0; - } - // Standalone commands (no project.labelle needed) switch (command) { .help_cmd => return printHelpWithProviders(allocator), @@ -486,64 +446,16 @@ test "mayNameProvider: only a namespace-shaped token reaches provider dispatch" } } -/// The usage printer for an invocation that can ONLY print usage, or null -/// when it asks for real work. Pure and total: the single decision point -/// `main` consults before any dispatch, so a new fast path cannot be -/// ordered ahead of it. -/// -/// Help-only invocations of the legacy `ios` subcommand must not enter the -/// build pipeline (cli#355). Its handler prints usage for a missing -/// subcommand and for `--help`/`-h`, but it is only reached at the END of -/// `pipeline.run`, so merely asking for usage executed the project's -/// declared `.prebuild` commands plus a whole generate+build. It needs no -/// project.labelle. -/// -/// The per-command predicate lives beside the handler's own parse loop -/// (`ios.wantsHelpOnly`) so the two stay in step, and covers EVERY help -/// form, not just a token in the first extra-argument position. -/// -/// Commands whose handler is already a pure printer, or which parse their -/// own `--help` before doing anything, are absent by design: this is for -/// commands whose help would otherwise be reached only after work. -fn helpOnlyPrinter(command: args_mod.Command, extra_args: []const []const u8) ?*const fn () void { - return switch (command) { - .ios_cmd => if (ios.wantsHelpOnly(extra_args)) &ios.printIosHelp else null, - else => null, - }; -} - // --- Tests --- -/// `main` consults `helpOnlyPrinter` before EVERY dispatch, so these cases -/// pin the whole "a usage request does no work" contract in one place -/// (cli#355, cli#361 review). -pub const HelpOnlyPrinterSpec = struct { - pub const resolves_to_usage = struct { - test "ios build --help prints usage" { - try std.testing.expect(helpOnlyPrinter(.ios_cmd, &.{ "build", "--help" }) != null); - } - }; - - pub const FirstArgumentSpec = struct { - test "an existing directory keeps the implicit run shorthand" { - try std.testing.expect(isDirectoryShorthand(".")); - } - - test "a missing directory is not accepted as an implicit run command" { - try std.testing.expect(!isDirectoryShorthand("definitely-not-a-labelle-directory")); - } - }; - - pub const falls_through_to_work = struct { - test "ios run without a help token still runs" { - try std.testing.expect(helpOnlyPrinter(.ios_cmd, &.{"run"}) == null); - } +pub const FirstArgumentSpec = struct { + test "an existing directory keeps the implicit run shorthand" { + try std.testing.expect(isDirectoryShorthand(".")); + } - test "commands with no help-only form are never intercepted" { - try std.testing.expect(helpOnlyPrinter(.build, &.{"--help"}) == null); - try std.testing.expect(helpOnlyPrinter(.doctor_cmd, &.{"--help"}) == null); - } - }; + test "a missing directory is not accepted as an implicit run command" { + try std.testing.expect(!isDirectoryShorthand("definitely-not-a-labelle-directory")); + } }; const expect = @import("zspec").expect; diff --git a/src/cli/args.zig b/src/cli/args.zig index 7cb649ce..4a898f3a 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -13,7 +13,7 @@ const zig_toolchain = @import("zig_toolchain.zig"); const bundle = @import("bundle.zig"); const contract = @import("provider_contract.zig"); -pub const Command = enum { generate, build, run, init_cmd, add_cmd, install_cmd, upgrade_cmd, update_cmd, clean_cmd, ios_cmd, help_cmd, version, targets, assembler_cmd, test_cmd, pack_cmd, astc_cmd, audit_cmd, migrate_cmd, doctor_cmd, check_cmd, plugins_cmd, toolchain_cmd, status_cmd, bundle_cmd }; +pub const Command = enum { generate, build, run, init_cmd, add_cmd, install_cmd, upgrade_cmd, update_cmd, clean_cmd, help_cmd, version, targets, assembler_cmd, test_cmd, pack_cmd, astc_cmd, audit_cmd, migrate_cmd, doctor_cmd, check_cmd, plugins_cmd, toolchain_cmd, status_cmd, bundle_cmd }; const SceneResult = enum { not_scene, parsed, needs_next, err }; diff --git a/src/cli/config.zig b/src/cli/config.zig index 9cfda2e5..bac81b38 100644 --- a/src/cli/config.zig +++ b/src/cli/config.zig @@ -75,7 +75,7 @@ pub fn printNoProjectError(project_dir: []const u8) void { fn readProjectConfigImpl(allocator: std.mem.Allocator, project_dir: []const u8, verbose: bool) !project_config.ProjectConfig { // Raise branch quota for std.zon.parse.fromSlice — ProjectConfig has many - // fields (including nested IosConfig) that exceed the default 1100 limit. + // fields (including nested structs) that exceed the default 1100 limit. @setEvalBranchQuota(10000); const labelle_path = try std.fs.path.join(allocator, &.{ project_dir, "project.labelle" }); defer allocator.free(labelle_path); diff --git a/src/cli/ios.zig b/src/cli/ios.zig deleted file mode 100644 index 2c33cf58..00000000 --- a/src/cli/ios.zig +++ /dev/null @@ -1,915 +0,0 @@ -/// iOS build, deployment, and Xcode project generation for labelle-cli. -const std = @import("std"); -const project_config = @import("project_config.zig"); -const runner = @import("runner.zig"); -const config = @import("config.zig"); - -/// Deploy the built iOS binary to the iOS Simulator. -/// Expects the binary at `target_dir/zig-out/bin/game`. -pub fn deployToSimulator(allocator: std.mem.Allocator, target_dir: []const u8, cfg: project_config.ProjectConfig) !void { - const ios_cfg = cfg.ios orelse project_config.IosConfig{}; - const bundle_id = if (ios_cfg.bundle_id.len > 0) ios_cfg.bundle_id else try defaultBundleId(allocator, cfg.name); - const app_name = if (ios_cfg.app_name.len > 0) ios_cfg.app_name else cfg.title; - - // Path to simulator binary - const binary_path = try std.fs.path.join(allocator, &.{ target_dir, "zig-out", "bin", "game" }); - defer allocator.free(binary_path); - - // Check binary exists - std.Io.Dir.cwd().access(config.globalIo(), binary_path, .{}) catch { - std.debug.print("labelle: iOS binary not found at {s}\n", .{binary_path}); - return error.BinaryNotFound; - }; - - // Create .app bundle directory - const app_bundle = try std.fs.path.join(allocator, &.{ target_dir, "game.app" }); - defer allocator.free(app_bundle); - - // Remove old bundle to ensure clean state - std.Io.Dir.cwd().deleteTree(config.globalIo(), app_bundle) catch {}; - std.Io.Dir.cwd().createDirPath(config.globalIo(), app_bundle) catch {}; - - // Copy binary into .app bundle - const app_binary = try std.fs.path.join(allocator, &.{ app_bundle, "game" }); - defer allocator.free(app_binary); - - try std.Io.Dir.cwd().copyFile(binary_path, std.Io.Dir.cwd(), app_binary, config.globalIo(), .{}); - - // Make executable - _ = std.process.run(allocator, config.globalIo(), .{ - .argv = &.{ "chmod", "+x", app_binary }, - }) catch {}; - - // Generate Info.plist - const info_plist_path = try std.fs.path.join(allocator, &.{ app_bundle, "Info.plist" }); - defer allocator.free(info_plist_path); - - const info_plist = try generateInfoPlist(allocator, bundle_id, app_name, ios_cfg); - defer allocator.free(info_plist); - - try std.Io.Dir.cwd().writeFile(config.globalIo(), .{ .sub_path = info_plist_path, .data = info_plist }); - - // Generate LaunchScreen.storyboard - const launch_path = try std.fs.path.join(allocator, &.{ app_bundle, "LaunchScreen.storyboard" }); - defer allocator.free(launch_path); - - const launch_content = try generateLaunchScreen(allocator, app_name); - defer allocator.free(launch_content); - - try std.Io.Dir.cwd().writeFile(config.globalIo(), .{ .sub_path = launch_path, .data = launch_content }); - - // Copy assets into .app bundle - const assets_src = try std.fs.path.join(allocator, &.{ target_dir, "assets" }); - defer allocator.free(assets_src); - - const assets_dst = try std.fs.path.join(allocator, &.{ app_bundle, "assets" }); - defer allocator.free(assets_dst); - - copyDirectory(allocator, assets_src, assets_dst) catch { - // No assets directory is fine — not all projects have assets - }; - - // Ensure a simulator is booted - const udid = try ensureSimulatorBooted(allocator); - defer allocator.free(udid); - - // Install app on simulator - std.debug.print("labelle: installing on simulator...\n", .{}); - const install_result = std.process.run(allocator, config.globalIo(), .{ .argv = &.{ "xcrun", "simctl", "install", udid, app_bundle } }) catch |err| { - std.debug.print("labelle: failed to run simctl install: {}\n", .{err}); - return err; - }; - defer allocator.free(install_result.stdout); - defer allocator.free(install_result.stderr); - - if (install_result.term != .exited or install_result.term.exited != 0) { - std.debug.print("labelle: simulator install failed: {s}\n", .{install_result.stderr}); - return error.InstallFailed; - } - - // Launch app - std.debug.print("labelle: launching on simulator...\n", .{}); - const launch_result = std.process.run(allocator, config.globalIo(), .{ .argv = &.{ "xcrun", "simctl", "launch", udid, bundle_id } }) catch |err| { - std.debug.print("labelle: failed to run simctl launch: {}\n", .{err}); - return err; - }; - defer allocator.free(launch_result.stdout); - defer allocator.free(launch_result.stderr); - - if (launch_result.term != .exited or launch_result.term.exited != 0) { - std.debug.print("labelle: simulator launch failed: {s}\n", .{launch_result.stderr}); - return error.LaunchFailed; - } - - // Parse PID from output (format: "com.bundle.id: 12345") - if (std.mem.indexOf(u8, launch_result.stdout, ": ")) |colon_pos| { - const pid = std.mem.trim(u8, launch_result.stdout[colon_pos + 2 ..], &.{ ' ', '\n', '\r' }); - std.debug.print("labelle: app launched (PID: {s})\n", .{pid}); - } else { - std.debug.print("labelle: app launched\n", .{}); - } -} - -/// Ensure an iOS simulator is booted, boot one if needed. -/// Returns the UDID of the booted simulator. -fn ensureSimulatorBooted(allocator: std.mem.Allocator) ![]const u8 { - // Check for already-booted simulator - const list_result = std.process.run(allocator, config.globalIo(), .{ .argv = &.{ "xcrun", "simctl", "list", "devices", "booted", "-j" } }) catch |err| { - std.debug.print("labelle: failed to list simulators: {}\n", .{err}); - return err; - }; - defer allocator.free(list_result.stdout); - defer allocator.free(list_result.stderr); - - // Look for a UDID in the JSON output - const udid_key = "\"udid\" : \""; - if (std.mem.indexOf(u8, list_result.stdout, udid_key)) |start| { - const udid_start = start + udid_key.len; - if (std.mem.indexOfPos(u8, list_result.stdout, udid_start, "\"")) |end| { - const udid = list_result.stdout[udid_start..end]; - std.debug.print("labelle: using booted simulator {s}\n", .{udid}); - return allocator.dupe(u8, udid); - } - } - - // No simulator booted — find an available iPhone and boot it - std.debug.print("labelle: no simulator booted, starting one...\n", .{}); - - const avail_result = std.process.run(allocator, config.globalIo(), .{ .argv = &.{ "xcrun", "simctl", "list", "devices", "available" } }) catch { - return allocator.dupe(u8, "booted"); - }; - defer allocator.free(avail_result.stdout); - defer allocator.free(avail_result.stderr); - - // Find an iPhone with a UDID - var lines = std.mem.splitScalar(u8, avail_result.stdout, '\n'); - while (lines.next()) |line| { - if (std.mem.indexOf(u8, line, "iPhone") != null) { - if (std.mem.indexOf(u8, line, "(")) |paren_start| { - if (std.mem.indexOfPos(u8, line, paren_start, ")")) |paren_end| { - const udid = line[paren_start + 1 .. paren_end]; - if (udid.len == 36 and udid[8] == '-') { - std.debug.print("labelle: booting {s}\n", .{std.mem.trim(u8, line, " \t")}); - - _ = std.process.run(allocator, config.globalIo(), .{ .argv = &.{ "xcrun", "simctl", "boot", udid } }) catch null; - - // std.Thread.sleep removed in 0.16 — would re-add via Io.Clock.Duration.sleep; harmless to skip in dev path - - return allocator.dupe(u8, udid); - } - } - } - } - } - - return allocator.dupe(u8, "booted"); -} - -/// Copy directory recursively. Silently returns if source doesn't exist. -fn copyDirectory(allocator: std.mem.Allocator, src: []const u8, dst: []const u8) !void { - var src_dir = try std.Io.Dir.cwd().openDir(config.globalIo(), src, .{ .iterate = true }); - defer src_dir.close(config.globalIo()); - - std.Io.Dir.cwd().createDirPath(config.globalIo(), dst) catch {}; - - var iter = src_dir.iterate(); - while (try iter.next(config.globalIo())) |entry| { - const src_path = try std.fs.path.join(allocator, &.{ src, entry.name }); - defer allocator.free(src_path); - - const dst_path = try std.fs.path.join(allocator, &.{ dst, entry.name }); - defer allocator.free(dst_path); - - switch (entry.kind) { - .file => { - std.Io.Dir.cwd().copyFile(src_path, std.Io.Dir.cwd(), dst_path, config.globalIo(), .{}) catch {}; - }, - .directory => { - try copyDirectory(allocator, src_path, dst_path); - }, - else => {}, - } - } -} - -/// Generate Info.plist with full iOS metadata. -fn generateInfoPlist(allocator: std.mem.Allocator, bundle_id: []const u8, app_name: []const u8, ios_cfg: project_config.IosConfig) ![]const u8 { - const orientations = switch (ios_cfg.orientation) { - .portrait => - \\ - \\ UIInterfaceOrientationPortrait - \\ - , - // iOS `.landscape` already allowed BOTH directions, so - // `.sensor_landscape` is the same plist array here — the two values - // only diverge on Android (#341). - .landscape, .sensor_landscape => - \\ - \\ UIInterfaceOrientationLandscapeLeft - \\ UIInterfaceOrientationLandscapeRight - \\ - , - .all => - \\ - \\ UIInterfaceOrientationPortrait - \\ UIInterfaceOrientationLandscapeLeft - \\ UIInterfaceOrientationLandscapeRight - \\ - , - }; - - return std.fmt.allocPrint(allocator, - \\ - \\ - \\ - \\ - \\ CFBundleDevelopmentRegion - \\ en - \\ CFBundleDisplayName - \\ {s} - \\ CFBundleExecutable - \\ game - \\ CFBundleIdentifier - \\ {s} - \\ CFBundleInfoDictionaryVersion - \\ 6.0 - \\ CFBundleName - \\ {s} - \\ CFBundlePackageType - \\ APPL - \\ CFBundleShortVersionString - \\ 1.0 - \\ CFBundleVersion - \\ 1 - \\ LSRequiresIPhoneOS - \\ - \\ UILaunchStoryboardName - \\ LaunchScreen - \\ UIRequiredDeviceCapabilities - \\ - \\ arm64 - \\ metal - \\ - \\ UIRequiresFullScreen - \\ - \\ UIStatusBarHidden - \\ - \\ UISupportedInterfaceOrientations - \\{s} - \\ UIDeviceFamily - \\{s} - \\ MinimumOSVersion - \\ {s} - \\ - \\ - \\ - , .{ app_name, bundle_id, app_name, orientations, deviceFamilyPlist(ios_cfg.device_family), ios_cfg.minimum_ios }); -} - -// ============================================================================ -// labelle ios subcommand -// ============================================================================ - -/// True when this invocation can only ever PRINT USAGE — no subcommand at -/// all, a `help`/`--help`/`-h` subcommand, or a `--help`/`-h` anywhere in -/// the arguments (`labelle ios build --help`). -/// -/// `cli.zig` calls this BEFORE `pipeline.run` (cli#355): reaching -/// `handleIos` through the pipeline means the project's declared -/// `.prebuild` commands and a full generate+build have already run merely -/// to print usage. The first version of that interception only inspected -/// the FIRST extra argument, so `labelle ios build --help` still took the -/// long way round — and built. `handleIos` consults the same predicate so -/// the two can't drift. -/// -/// Pure, so the matrix is unit-testable without spawning anything. -pub fn wantsHelpOnly(args: []const []const u8) bool { - if (args.len == 0) return true; - // The bare word `help` is a subcommand, so it counts only in the - // subcommand slot; `--help`/`-h` count anywhere. - if (std.mem.eql(u8, args[0], "help")) return true; - for (args) |a| { - if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) return true; - } - return false; -} - -/// Handle `labelle ios ` — build, xcode, run for iOS. -pub fn handleIos(allocator: std.mem.Allocator, args: []const []const u8, cfg: project_config.ProjectConfig, target_dir: []const u8) !void { - if (wantsHelpOnly(args)) { - printIosHelp(); - return; - } - - const subcmd = args[0]; - const rest = if (args.len > 1) args[1..] else &[_][]const u8{}; - - if (std.mem.eql(u8, subcmd, "build")) { - var device = false; - var release = false; - for (rest) |arg| { - if (std.mem.eql(u8, arg, "--device") or std.mem.eql(u8, arg, "-d")) device = true; - if (std.mem.eql(u8, arg, "--release") or std.mem.eql(u8, arg, "-r")) release = true; - } - try iosBuild(allocator, target_dir, device, release); - } else if (std.mem.eql(u8, subcmd, "xcode")) { - var team_id: ?[]const u8 = null; - for (rest) |arg| { - if (std.mem.startsWith(u8, arg, "--team-id=")) team_id = arg["--team-id=".len..]; - } - try iosXcode(allocator, target_dir, cfg, team_id); - } else if (std.mem.eql(u8, subcmd, "run")) { - var device = false; - for (rest) |arg| { - if (std.mem.eql(u8, arg, "--device") or std.mem.eql(u8, arg, "-d")) device = true; - } - if (device) { - std.debug.print("labelle: device deployment requires code signing.\n", .{}); - std.debug.print(" Run 'labelle ios xcode' first, then deploy via Xcode.\n", .{}); - } else { - try iosBuild(allocator, target_dir, false, false); - try deployToSimulator(allocator, target_dir, cfg); - } - } else { - std.debug.print("labelle ios: unknown command '{s}'\n\n", .{subcmd}); - printIosHelp(); - } -} - -/// `labelle ios` usage. Public because `cli.zig` intercepts the -/// help/no-subcommand invocations BEFORE the build pipeline runs — see -/// the note there (cli#355): reaching this through `pipeline.run` meant -/// asking for usage first executed the project's declared `.prebuild` -/// commands and a full generate+build. -pub fn printIosHelp() void { - std.debug.print( - \\iOS Commands - \\ - \\Usage: labelle ios [options] - \\ - \\Commands: - \\ build Build for iOS (simulator by default) - \\ xcode Generate Xcode project for device deployment - \\ run Build and run on simulator - \\ - \\Build Options: - \\ --device Build for iOS device (arm64) - \\ --release Build release configuration - \\ - \\Xcode Options: - \\ --team-id=ID Apple Developer Team ID for signing - \\ - \\Run Options: - \\ --device Deploy to device (requires Xcode signing) - \\ - \\Examples: - \\ labelle ios build Build for iOS simulator - \\ labelle ios build --device Build for iOS device - \\ labelle ios xcode Generate Xcode project - \\ labelle ios run Run on simulator - \\ - , .{}); -} - -/// Detect the known iOS-simulator ARM NEON/SIMD compile failure (labelle-cli#4, #5). -/// -/// The `aarch64-ios-simulator` target compiles ARM SIMD (NEON) intrinsics that -/// require CPU features the simulator toolchain does not enable, producing -/// `zig cc`/clang errors like: -/// -/// always_inline function 'vdupq_n_u64' requires target feature 'altnzcv', -/// but would be inlined into function ... compiled without support for 'altnzcv' -/// -/// The durable fix lives in the plugins that compile ARM SIMD — they must select -/// their scalar fallback for the simulator triple (`os == .ios and abi == -/// .simulator`), e.g. Box2D's `BOX2D_DISABLE_SIMD` and Clay's non-NEON hash path. -/// The CLI cannot influence those C compiles directly (it only invokes `zig build` -/// on the generated project), so it recognises this failure signature and points -/// the user at the fix instead of dumping a wall of intrinsic errors. -/// -/// Returns true when `stderr` carries the feature-mismatch signature paired with a -/// NEON marker (so a generic "requires target feature" is not mistaken for this). -fn isSimdFeatureBuildError(stderr: []const u8) bool { - if (std.mem.indexOf(u8, stderr, "requires target feature") == null) return false; - const neon_markers = [_][]const u8{ - "altnzcv", // ARMv8.5 FEAT_FlagM2 — the feature the intrinsics demand - "vdupq_n_", "vld1", "vst1", "vmulq_", "vaddq_", // common NEON intrinsics - "arm_neon.h", "neon", "NEON", - }; - for (neon_markers) |marker| { - if (std.mem.indexOf(u8, stderr, marker) != null) return true; - } - return false; -} - -/// Print an actionable hint for the iOS-simulator NEON/SIMD build failure. -fn printSimdBuildHint() void { - std.debug.print( - \\ - \\labelle: this looks like the iOS-simulator ARM NEON/SIMD compile failure - \\ (labelle-cli#4 / #5). The aarch64-ios-simulator target compiles NEON - \\ intrinsics that its toolchain does not enable. - \\ - \\ A plugin that compiles ARM SIMD must fall back to scalar for the - \\ simulator triple (os == .ios and abi == .simulator). In the plugin's - \\ build.zig, gate its SIMD-disable on that target: - \\ - \\ Box2D: add `BOX2D_DISABLE_SIMD=1` (like the existing emscripten branch) - \\ Clay: compile the non-NEON hash path - \\ - \\ Building for a physical device (`labelle ios build --device`) avoids - \\ this, as does upgrading the toolchain past the 0.15.2-era clang bug. - \\ - , .{}); -} - -/// Build the iOS target (simulator or device). -fn iosBuild(allocator: std.mem.Allocator, target_dir: []const u8, device: bool, release: bool) !void { - // Simulator is the default target; `--device` switches to the physical-device - // arm64 build. The NEON/SIMD failure below only affects the simulator triple. - const simulator = !device; - const target_label: []const u8 = if (device) "iOS device (arm64)" else "iOS simulator (arm64)"; - const config_label: []const u8 = if (release) "Release" else "Debug"; - std.debug.print("labelle: building for {s} ({s})...\n", .{ target_label, config_label }); - - // Managed Zig (cli#279) — resolve against the generated target dir; it - // has no project.labelle, so this falls back to the CLI's default Zig. - const zig_exe = try runner.resolveZigExe(allocator, target_dir); - defer allocator.free(zig_exe); - - var argv_buf: [8][]const u8 = undefined; - var argc: usize = 0; - argv_buf[argc] = zig_exe; - argc += 1; - argv_buf[argc] = "build"; - argc += 1; - if (device) { - argv_buf[argc] = "-Ddevice=true"; - argc += 1; - } - if (release) { - argv_buf[argc] = "-Doptimize=ReleaseFast"; - argc += 1; - } - - const result = try runner.runZig(allocator, target_dir, argv_buf[0..argc]); - defer allocator.free(result.stdout); - defer allocator.free(result.stderr); - - switch (result.term) { - .exited => |code| if (code != 0) { - std.debug.print("labelle: build failed:\n{s}\n", .{result.stderr}); - if (simulator and isSimdFeatureBuildError(result.stderr)) printSimdBuildHint(); - return error.BuildFailed; - }, - else => { - std.debug.print("labelle: build terminated abnormally\n{s}\n", .{result.stderr}); - return error.BuildFailed; - }, - } - std.debug.print(" build ok\n", .{}); -} - -/// Generate Xcode project for device deployment and code signing. -fn iosXcode(allocator: std.mem.Allocator, target_dir: []const u8, cfg: project_config.ProjectConfig, team_id_override: ?[]const u8) !void { - const ios_cfg = cfg.ios orelse project_config.IosConfig{}; - const app_name = if (ios_cfg.app_name.len > 0) ios_cfg.app_name else cfg.title; - const bundle_id = if (ios_cfg.bundle_id.len > 0) ios_cfg.bundle_id else try defaultBundleId(allocator, cfg.name); - const minimum_ios = ios_cfg.minimum_ios; - const team_id = team_id_override orelse if (ios_cfg.team_id.len > 0) ios_cfg.team_id else null; - - // Build for device first - std.debug.print("labelle: step 1 — building for device...\n", .{}); - try iosBuild(allocator, target_dir, true, false); - - // Create xcode output directory next to .labelle/ - const xcode_dir = try std.fs.path.join(allocator, &.{ target_dir, "..", "..", "ios-xcode" }); - defer allocator.free(xcode_dir); - - const sanitized = try sanitizeName(allocator, app_name); - defer allocator.free(sanitized); - - // Create directory structure - const xcodeproj_dir = try std.fmt.allocPrint(allocator, "{s}/{s}.xcodeproj", .{ xcode_dir, sanitized }); - defer allocator.free(xcodeproj_dir); - - const app_dir = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ xcode_dir, sanitized }); - defer allocator.free(app_dir); - - const assets_xcassets = try std.fmt.allocPrint(allocator, "{s}/Assets.xcassets/AppIcon.appiconset", .{app_dir}); - defer allocator.free(assets_xcassets); - - std.Io.Dir.cwd().createDirPath(config.globalIo(), xcodeproj_dir) catch {}; - std.Io.Dir.cwd().createDirPath(config.globalIo(), assets_xcassets) catch {}; - - // Copy device binary - const binary_src = try std.fs.path.join(allocator, &.{ target_dir, "zig-out", "bin", "game" }); - defer allocator.free(binary_src); - - const binary_dst = try std.fmt.allocPrint(allocator, "{s}/game", .{app_dir}); - defer allocator.free(binary_dst); - - std.Io.Dir.cwd().copyFile(binary_src, std.Io.Dir.cwd(), binary_dst, config.globalIo(), .{}) catch |err| { - std.debug.print("labelle: could not copy binary: {}\n", .{err}); - std.debug.print(" source: {s}\n", .{binary_src}); - return err; - }; - - // Generate Info.plist - const info_content = try generateInfoPlist(allocator, bundle_id, app_name, ios_cfg); - defer allocator.free(info_content); - const info_path = try std.fmt.allocPrint(allocator, "{s}/Info.plist", .{app_dir}); - defer allocator.free(info_path); - try std.Io.Dir.cwd().writeFile(config.globalIo(), .{ .sub_path = info_path, .data = info_content }); - - // Generate LaunchScreen.storyboard - const launch_content = try generateLaunchScreen(allocator, app_name); - defer allocator.free(launch_content); - const launch_path = try std.fmt.allocPrint(allocator, "{s}/LaunchScreen.storyboard", .{app_dir}); - defer allocator.free(launch_path); - try std.Io.Dir.cwd().writeFile(config.globalIo(), .{ .sub_path = launch_path, .data = launch_content }); - - // Generate Assets.xcassets - const assets_json = try std.fmt.allocPrint(allocator, "{s}/Assets.xcassets/Contents.json", .{app_dir}); - defer allocator.free(assets_json); - try std.Io.Dir.cwd().writeFile(config.globalIo(), .{ .sub_path = assets_json, .data = "{\n \"info\" : {\n \"author\" : \"xcode\",\n \"version\" : 1\n }\n}" }); - - const icon_json = try std.fmt.allocPrint(allocator, "{s}/Contents.json", .{assets_xcassets}); - defer allocator.free(icon_json); - try std.Io.Dir.cwd().writeFile(config.globalIo(), .{ .sub_path = icon_json, .data = "{\n \"images\" : [\n {\n \"idiom\" : \"universal\",\n \"platform\" : \"ios\",\n \"size\" : \"1024x1024\"\n }\n ],\n \"info\" : {\n \"author\" : \"xcode\",\n \"version\" : 1\n }\n}" }); - - // Copy assets - const game_assets = try std.fs.path.join(allocator, &.{ target_dir, "assets" }); - defer allocator.free(game_assets); - const xcode_assets = try std.fmt.allocPrint(allocator, "{s}/assets", .{app_dir}); - defer allocator.free(xcode_assets); - copyDirectory(allocator, game_assets, xcode_assets) catch {}; - - // Generate project.pbxproj - const pbxproj_path = try std.fmt.allocPrint(allocator, "{s}/project.pbxproj", .{xcodeproj_dir}); - defer allocator.free(pbxproj_path); - - const pbxproj = try generatePbxproj(allocator, sanitized, bundle_id, minimum_ios, ios_cfg.device_family, team_id); - defer allocator.free(pbxproj); - try std.Io.Dir.cwd().writeFile(config.globalIo(), .{ .sub_path = pbxproj_path, .data = pbxproj }); - - std.debug.print("\nlabelle: Xcode project generated!\n", .{}); - std.debug.print(" location: ios-xcode/{s}.xcodeproj\n\n", .{sanitized}); - std.debug.print("Next steps:\n", .{}); - std.debug.print(" 1. open ios-xcode/{s}.xcodeproj\n", .{sanitized}); - std.debug.print(" 2. Select your development team in Signing & Capabilities\n", .{}); - std.debug.print(" 3. Build and run on device\n", .{}); -} - -/// Convert device_family string ("1,2") to plist array XML. -fn deviceFamilyPlist(device_family: []const u8) []const u8 { - if (std.mem.eql(u8, device_family, "1")) - return " \n 1\n "; - if (std.mem.eql(u8, device_family, "2")) - return " \n 2\n "; - return " \n 1\n 2\n "; -} - -/// Generate a default bundle ID from project name (e.g. "my_game" → "com.labelle.my-game"). -fn defaultBundleId(allocator: std.mem.Allocator, name: []const u8) ![]const u8 { - return std.fmt.allocPrint(allocator, "com.labelle.{s}", .{name}); -} - -/// Sanitize name for identifiers (replace non-alphanumeric with underscores). -fn sanitizeName(allocator: std.mem.Allocator, name: []const u8) ![]const u8 { - var result = try allocator.alloc(u8, name.len); - for (name, 0..) |c, i| { - result[i] = if (std.ascii.isAlphanumeric(c) or c == '_') c else '_'; - } - return result; -} - -/// Generate Xcode project.pbxproj. -fn generatePbxproj(allocator: std.mem.Allocator, app_name: []const u8, bundle_id: []const u8, minimum_ios: []const u8, device_family: []const u8, team_id: ?[]const u8) ![]const u8 { - const team_setting: []const u8 = if (team_id) |tid| tid else ""; - const team_line: []const u8 = if (team_id != null) " DEVELOPMENT_TEAM = " else " // DEVELOPMENT_TEAM not set — configure in Xcode"; - - return std.fmt.allocPrint(allocator, - \\// !$*UTF8*$! - \\{{ - \\ archiveVersion = 1; - \\ classes = {{}}; - \\ objectVersion = 56; - \\ objects = {{ - \\ /* Begin PBXBuildFile section */ - \\ A1000001 /* game in CopyFiles */ = {{isa = PBXBuildFile; fileRef = A2000001; }}; - \\ A1000002 /* LaunchScreen.storyboard in Resources */ = {{isa = PBXBuildFile; fileRef = A2000002; }}; - \\ A1000003 /* Assets.xcassets in Resources */ = {{isa = PBXBuildFile; fileRef = A2000003; }}; - \\ /* End PBXBuildFile section */ - \\ /* Begin PBXCopyFilesBuildPhase section */ - \\ A3000001 = {{ - \\ isa = PBXCopyFilesBuildPhase; - \\ buildActionMask = 2147483647; - \\ dstPath = ""; - \\ dstSubfolderSpec = 6; - \\ files = (A1000001); - \\ runOnlyForDeploymentPostprocessing = 0; - \\ }}; - \\ /* End PBXCopyFilesBuildPhase section */ - \\ /* Begin PBXFileReference section */ - \\ A4000001 /* {s}.app */ = {{isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "{s}.app"; sourceTree = BUILT_PRODUCTS_DIR; }}; - \\ A2000001 /* game */ = {{isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = game; sourceTree = ""; }}; - \\ A2000002 /* LaunchScreen.storyboard */ = {{isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }}; - \\ A2000003 /* Assets.xcassets */ = {{isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }}; - \\ A2000004 /* Info.plist */ = {{isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }}; - \\ /* End PBXFileReference section */ - \\ /* Begin PBXGroup section */ - \\ A5000001 = {{ - \\ isa = PBXGroup; - \\ children = (A5000002, A5000003); - \\ sourceTree = ""; - \\ }}; - \\ A5000002 = {{ - \\ isa = PBXGroup; - \\ children = (A2000001, A2000002, A2000003, A2000004); - \\ path = "{s}"; - \\ sourceTree = ""; - \\ }}; - \\ A5000003 = {{ - \\ isa = PBXGroup; - \\ children = (A4000001); - \\ name = Products; - \\ sourceTree = ""; - \\ }}; - \\ /* End PBXGroup section */ - \\ /* Begin PBXNativeTarget section */ - \\ A6000001 = {{ - \\ isa = PBXNativeTarget; - \\ buildConfigurationList = A7000003; - \\ buildPhases = (A3000001, A3000002); - \\ buildRules = (); - \\ dependencies = (); - \\ name = "{s}"; - \\ productName = "{s}"; - \\ productReference = A4000001; - \\ productType = "com.apple.product-type.application"; - \\ }}; - \\ /* End PBXNativeTarget section */ - \\ /* Begin PBXProject section */ - \\ A8000001 = {{ - \\ isa = PBXProject; - \\ attributes = {{BuildIndependentTargetsInParallel = 1; LastUpgradeCheck = 1500;}}; - \\ buildConfigurationList = A7000001; - \\ compatibilityVersion = "Xcode 14.0"; - \\ developmentRegion = en; - \\ hasScannedForEncodings = 0; - \\ knownRegions = (en, Base); - \\ mainGroup = A5000001; - \\ productRefGroup = A5000003; - \\ projectDirPath = ""; - \\ projectRoot = ""; - \\ targets = (A6000001); - \\ }}; - \\ /* End PBXProject section */ - \\ /* Begin PBXResourcesBuildPhase section */ - \\ A3000002 = {{ - \\ isa = PBXResourcesBuildPhase; - \\ buildActionMask = 2147483647; - \\ files = (A1000002, A1000003); - \\ runOnlyForDeploymentPostprocessing = 0; - \\ }}; - \\ /* End PBXResourcesBuildPhase section */ - \\ /* Begin XCBuildConfiguration section */ - \\ A7000002 /* Debug */ = {{ - \\ isa = XCBuildConfiguration; - \\ buildSettings = {{ - \\ ALWAYS_SEARCH_USER_PATHS = NO; - \\ IPHONEOS_DEPLOYMENT_TARGET = {s}; - \\ SDKROOT = iphoneos; - \\ }}; - \\ name = Debug; - \\ }}; - \\ A7000006 /* Release */ = {{ - \\ isa = XCBuildConfiguration; - \\ buildSettings = {{ - \\ ALWAYS_SEARCH_USER_PATHS = NO; - \\ IPHONEOS_DEPLOYMENT_TARGET = {s}; - \\ SDKROOT = iphoneos; - \\ }}; - \\ name = Release; - \\ }}; - \\ A7000004 /* Debug */ = {{ - \\ isa = XCBuildConfiguration; - \\ buildSettings = {{ - \\ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - \\ CODE_SIGN_STYLE = Automatic; - \\ CURRENT_PROJECT_VERSION = 1; - \\ INFOPLIST_FILE = Info.plist; - \\ INFOPLIST_KEY_CFBundleDisplayName = "{s}"; - \\ INFOPLIST_KEY_LSRequiresIPhoneOS = YES; - \\ INFOPLIST_KEY_MinimumOSVersion = {s}; - \\ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - \\ INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; - \\ INFOPLIST_KEY_UIRequiresFullScreen = YES; - \\ INFOPLIST_KEY_UIStatusBarHidden = YES; - \\ MARKETING_VERSION = 1.0; - \\ PRODUCT_BUNDLE_IDENTIFIER = "{s}"; - \\ PRODUCT_NAME = "$(TARGET_NAME)"; - \\ TARGETED_DEVICE_FAMILY = "{s}"; - \\{s}{s}; - \\ }}; - \\ name = Debug; - \\ }}; - \\ A7000005 /* Release */ = {{ - \\ isa = XCBuildConfiguration; - \\ buildSettings = {{ - \\ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - \\ CODE_SIGN_STYLE = Automatic; - \\ CURRENT_PROJECT_VERSION = 1; - \\ INFOPLIST_FILE = Info.plist; - \\ INFOPLIST_KEY_CFBundleDisplayName = "{s}"; - \\ INFOPLIST_KEY_LSRequiresIPhoneOS = YES; - \\ INFOPLIST_KEY_MinimumOSVersion = {s}; - \\ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - \\ INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; - \\ INFOPLIST_KEY_UIRequiresFullScreen = YES; - \\ INFOPLIST_KEY_UIStatusBarHidden = YES; - \\ MARKETING_VERSION = 1.0; - \\ PRODUCT_BUNDLE_IDENTIFIER = "{s}"; - \\ PRODUCT_NAME = "$(TARGET_NAME)"; - \\ TARGETED_DEVICE_FAMILY = "{s}"; - \\{s}{s}; - \\ }}; - \\ name = Release; - \\ }}; - \\ /* End XCBuildConfiguration section */ - \\ /* Begin XCConfigurationList section */ - \\ A7000001 = {{ - \\ isa = XCConfigurationList; - \\ buildConfigurations = (A7000002, A7000006); - \\ defaultConfigurationIsVisible = 0; - \\ defaultConfigurationName = Debug; - \\ }}; - \\ A7000003 = {{ - \\ isa = XCConfigurationList; - \\ buildConfigurations = (A7000004, A7000005); - \\ defaultConfigurationIsVisible = 0; - \\ defaultConfigurationName = Debug; - \\ }}; - \\ /* End XCConfigurationList section */ - \\ }}; - \\ rootObject = A8000001; - \\}} - \\ - , .{ - // PBXFileReference - app_name, - app_name, - // PBXGroup - app_name, - // PBXNativeTarget - app_name, - app_name, - // XCBuildConfiguration - project level (Debug + Release) - minimum_ios, - minimum_ios, - // Debug target config - app_name, - minimum_ios, - bundle_id, - device_family, - team_line, - team_setting, - // Release target config - app_name, - minimum_ios, - bundle_id, - device_family, - team_line, - team_setting, - }); -} - -/// Generate LaunchScreen.storyboard with centered app name. -fn generateLaunchScreen(allocator: std.mem.Allocator, app_name: []const u8) ![]const u8 { - return std.fmt.allocPrint(allocator, - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - , .{app_name}); -} - -// ============================================================================ -// Tests -// ============================================================================ - -test "isSimdFeatureBuildError: detects the altnzcv NEON signature" { - const stderr = - \\clay.h:1449:21: error: always_inline function 'vdupq_n_u64' requires target feature 'altnzcv', - \\but would be inlined into function 'Clay__HashData' that is compiled without support for 'altnzcv' - ; - try std.testing.expect(isSimdFeatureBuildError(stderr)); -} - -test "isSimdFeatureBuildError: detects the box2d vdupq_n_f32 signature" { - const stderr = - \\error: always_inline function 'vdupq_n_f32' requires target feature 'altnzcv', - \\but would be inlined into function that is compiled without support for 'altnzcv' - ; - try std.testing.expect(isSimdFeatureBuildError(stderr)); -} - -test "isSimdFeatureBuildError: ignores unrelated feature-mismatch errors" { - // A "requires target feature" error with no NEON marker must not trip the hint. - const stderr = - \\error: always_inline function 'foo' requires target feature 'avx512f', - \\but would be inlined into function 'bar' that is compiled without support for 'avx512f' - ; - try std.testing.expect(!isSimdFeatureBuildError(stderr)); -} - -test "isSimdFeatureBuildError: ignores ordinary build failures" { - const stderr = "error: unable to find 'game.zig'\n"; - try std.testing.expect(!isSimdFeatureBuildError(stderr)); -} - -test "wantsHelpOnly: no subcommand at all" { - try std.testing.expect(wantsHelpOnly(&.{})); -} - -test "wantsHelpOnly: explicit help forms" { - try std.testing.expect(wantsHelpOnly(&.{"help"})); - try std.testing.expect(wantsHelpOnly(&.{"--help"})); - try std.testing.expect(wantsHelpOnly(&.{"-h"})); -} - -// The positional blind spot: the first interception only looked at -// args[0], so this asked for usage and got a full generate+build instead. -test "wantsHelpOnly: a help flag AFTER a subcommand still only prints usage" { - try std.testing.expect(wantsHelpOnly(&.{ "build", "--help" })); - try std.testing.expect(wantsHelpOnly(&.{ "run", "-h" })); - try std.testing.expect(wantsHelpOnly(&.{ "xcode", "--team-id=ABC", "--help" })); -} - -test "wantsHelpOnly: real work is not intercepted" { - try std.testing.expect(!wantsHelpOnly(&.{"build"})); - try std.testing.expect(!wantsHelpOnly(&.{ "build", "--device", "--release" })); - try std.testing.expect(!wantsHelpOnly(&.{ "xcode", "--team-id=ABC" })); - try std.testing.expect(!wantsHelpOnly(&.{"run"})); -} - -test "generateInfoPlist maps every Orientation to its UISupportedInterfaceOrientations array" { - // iOS `.landscape` already emitted BOTH directions, so `.sensor_landscape` - // is byte-identical to it here — the divergence between the two values is - // Android-only (#341). Portrait/all are pinned so the shared switch can't - // quietly widen. - const allocator = std.testing.allocator; - const portrait_str = "UIInterfaceOrientationPortrait"; - const left_str = "UIInterfaceOrientationLandscapeLeft"; - const right_str = "UIInterfaceOrientationLandscapeRight"; - - inline for (.{ - .{ project_config.Orientation.portrait, true, false }, - .{ project_config.Orientation.landscape, false, true }, - .{ project_config.Orientation.sensor_landscape, false, true }, - .{ project_config.Orientation.all, true, true }, - }) |case| { - const plist = try generateInfoPlist(allocator, "com.test.game", "Test", .{ .orientation = case[0] }); - defer allocator.free(plist); - try std.testing.expectEqual(case[1], std.mem.indexOf(u8, plist, portrait_str) != null); - try std.testing.expectEqual(case[2], std.mem.indexOf(u8, plist, left_str) != null); - try std.testing.expectEqual(case[2], std.mem.indexOf(u8, plist, right_str) != null); - } - - // `.sensor_landscape` and `.landscape` are the SAME plist on iOS. - const landscape = try generateInfoPlist(allocator, "com.test.game", "Test", .{ .orientation = .landscape }); - defer allocator.free(landscape); - const sensor = try generateInfoPlist(allocator, "com.test.game", "Test", .{ .orientation = .sensor_landscape }); - defer allocator.free(sensor); - try std.testing.expectEqualStrings(landscape, sensor); -} diff --git a/src/cli/pipeline.zig b/src/cli/pipeline.zig index 9243bb8c..261acc07 100644 --- a/src/cli/pipeline.zig +++ b/src/cli/pipeline.zig @@ -1,7 +1,7 @@ //! Command-execution pipeline for the labelle CLI (#311). Extracted from //! cli.zig `main` so the dispatcher stays small: this owns the -//! generate -> build -> run flow and the docker / ios -//! branches. Behavior is identical to when this lived in `main`. +//! generate -> build -> run flow and the docker +//! branch. Behavior is identical to when this lived in `main`. //! //! Thin root: `run` walks the stages in order and owns every resource they //! borrow; each stage lives in `pipeline/`, one responsibility per file. @@ -36,7 +36,6 @@ const runner = @import("runner.zig"); const assembler_proc = @import("assembler_proc.zig"); const assembler_describe = @import("assembler_describe.zig"); const material_toolchain = @import("material_toolchain.zig"); -const ios = @import("ios.zig"); const progress = @import("progress.zig"); const args_mod = @import("args.zig"); const provider_contract = @import("provider_contract.zig"); @@ -63,10 +62,10 @@ pub const ScreenshotProbeSpec = screenshot.ScreenshotProbeSpec; pub const CollectPrebuildIgnorePathsSpec = rebuild.CollectPrebuildIgnorePathsSpec; /// Run the project-scoped pipeline: read project.labelle, then -/// generate -> build -> run (or the docker / ios +/// generate -> build -> run (or the docker /// variant selected by `parsed_args`). Dispatch of the standalone /// subcommands stays in cli.zig `main`; this is invoked only for the -/// project commands (generate / build / run / bundle / ios). +/// project commands (generate / build / run / bundle). /// Returns the process exit status the command earned: the game's own exit /// status for `run` (0 for a genuine `--timeout` expiry), 0 for everything /// that completed. `main` returns it as the CLI's exit code, so automation @@ -164,9 +163,7 @@ pub fn run(allocator: std.mem.Allocator, parsed_args: ParsedArgs) !u8 { // cli#471 D3), so a third-party `.backend_package` names the dir after // its package exactly as `generate` does (cli#471 finding 4). An older // assembler, or a `describe` that cannot answer, keeps the enum-derived - // `_`. The legacy ios subcommand forces its own - // backend, which `describe` (reading project.labelle alone) cannot - // know, so it keeps the enum name too. + // `_`. // // This is why the assembler is looked up HERE, ahead of the session lock // and the progress feed: both live under the target dir. Looking it up @@ -174,13 +171,10 @@ pub fn run(allocator: std.mem.Allocator, parsed_args: ParsedArgs) !u8 { // lookup that fails here is not fatal yet: the dir keeps the enum name, // and the lookup below fails again inside the progress feed, so a // `--progress=json` consumer still gets its `failed` record. - const early_asm: ?assembler_proc.Assembler = if (command == .ios_cmd) - null - else - assembler_proc.resolve(allocator, project_dir, "generate") catch |err| blk: { - std.log.debug("assembler lookup before naming the target dir failed ({t}); using the enum name", .{err}); - break :blk null; - }; + const early_asm: ?assembler_proc.Assembler = assembler_proc.resolve(allocator, project_dir, "generate") catch |err| blk: { + std.log.debug("assembler lookup before naming the target dir failed ({t}); using the enum name", .{err}); + break :blk null; + }; var early_asm_owned = early_asm != null; defer if (early_asm_owned) early_asm.?.deinit(allocator); const describer: assembler_describe.Describer = if (early_asm) |a| .init(a, project_dir) else .off; @@ -232,9 +226,8 @@ pub fn run(allocator: std.mem.Allocator, parsed_args: ParsedArgs) !u8 { // stderr (default human mode — a TTY-only spinner while `zig build` // runs, "still working" heartbeat lines while the assembler child // owns stderr during resolve/generate, cli#321). Enabled for the - // commands that run the shared build pipeline; `labelle generate` and - // the ios subcommand (which owns its own build flow) stay - // report-free. A + // commands that run the shared build pipeline; `labelle generate` + // stays report-free. A // reporter that fails to initialize downgrades to the pre-#284 // behavior instead of blocking the build. var reporter_storage: progress.Reporter = undefined; @@ -336,21 +329,17 @@ pub fn run(allocator: std.mem.Allocator, parsed_args: ParsedArgs) !u8 { optimize_mod.ownerDefault(providers, target.name), ).mode; - // A path that cannot carry a provider's environment contribution or its - // optimize default refuses here, before any hook, generation or build, - // rather than silently bypassing the provider (`install.providerBypass`). + // A path that cannot carry a provider's environment contribution + // refuses here, before any hook, generation or build, rather than + // silently bypassing the provider (`install.providerBypass`). + const contributor = provider_hooks.planContributor(hook_plans.generate, hook_plans.build); if (install.providerBypass( command, parsed_args.docker, - provider_hooks.planContributor(hook_plans.generate, hook_plans.build) != null, - optimize_mod.ownerDefault(providers, target.name) != null, + contributor != null, hook_plans.build.replace != null, )) |bypass| { - if (provider_hooks.planContributor(hook_plans.generate, hook_plans.build)) |hook| { - std.debug.print("labelle: hook '{s}' may contribute an environment for target '{s}'\n", .{ hook.qualified, target.name }); - } else { - std.debug.print("labelle: '{s}' declares an optimize default for target '{s}'\n", .{ target.providerName(), target.name }); - } + std.debug.print("labelle: hook '{s}' may contribute an environment for target '{s}'\n", .{ contributor.?.qualified, target.name }); std.debug.print("labelle: {s}\n", .{bypass.message()}); if (reporter) |r| r.finishFailed(1, "the build path cannot carry the provider's inputs"); return 1; @@ -424,11 +413,6 @@ pub fn run(allocator: std.mem.Allocator, parsed_args: ParsedArgs) !u8 { if (command == .generate) return 0; - // `labelle ios` subcommand — handles its own build/xcode/run - if (command == .ios_cmd) { - return ok(ios.handleIos(allocator, parsed_args.extra_args[0..parsed_args.extra_count], parsed, target_dir)); - } - // Warn if --target is used without --docker (it has no effect otherwise) if (parsed_args.docker_target != null and !parsed_args.docker) { std.debug.print("labelle: warning: --target has no effect without --docker\n", .{}); @@ -487,8 +471,7 @@ pub fn run(allocator: std.mem.Allocator, parsed_args: ParsedArgs) !u8 { } /// The last lifecycle step `command` runs its hooks for (contract §2 -/// `final_step`). The legacy `labelle ios` subcommand only shares the -/// `generate` hooks, so its hooks see `generate`. +/// `final_step`). fn finalStep(command: args_mod.Command) provider_contract.Step { return switch (command) { .build => .build, @@ -500,7 +483,6 @@ fn finalStep(command: args_mod.Command) provider_contract.Step { test "finalStep: each project command's last hook step" { try std.testing.expectEqual(provider_contract.Step.generate, finalStep(.generate)); - try std.testing.expectEqual(provider_contract.Step.generate, finalStep(.ios_cmd)); try std.testing.expectEqual(provider_contract.Step.build, finalStep(.build)); try std.testing.expectEqual(provider_contract.Step.run, finalStep(.run)); try std.testing.expectEqual(provider_contract.Step.bundle, finalStep(.bundle_cmd)); diff --git a/src/cli/pipeline/args_resolve.zig b/src/cli/pipeline/args_resolve.zig index ab2a0daa..9bfa866d 100644 --- a/src/cli/pipeline/args_resolve.zig +++ b/src/cli/pipeline/args_resolve.zig @@ -26,8 +26,8 @@ pub const Resolution = union(enum) { /// ── Target resolution, the NAME half (RFC #406 phase 3b) ────────── /// (docs/provider-targets.md "Resolution") /// The target is the core `desktop` or one a pinned provider declares; -/// nothing else, including the project's own `.platform` and the legacy -/// `ios` subcommand (no shim, RFC #406 "Migration"). +/// nothing else, including the project's own `.platform` (no shim, +/// RFC #406 "Migration"). /// Ownership needs the providers, and provider discovery runs only /// after the assembler's `install` populated the package cache (below, /// next to `gateThenInstall`; Codex P1 on #420) — while the target @@ -96,12 +96,6 @@ pub fn resolve( // legacy sites still need the schema enum. parsed.platform = provisional.legacy orelse .desktop; - // `labelle ios` always implies the sokol backend (its target came - // through the resolver like everything else). - if (command == .ios_cmd) { - parsed.backend = .sokol; - } - return .{ .proceed = .{ .project_root = project_root, .provisional = provisional } }; } diff --git a/src/cli/pipeline/generate.zig b/src/cli/pipeline/generate.zig index b4495008..278f75f4 100644 --- a/src/cli/pipeline/generate.zig +++ b/src/cli/pipeline/generate.zig @@ -23,8 +23,8 @@ const gateThenInstall = @import("install.zig").gateThenInstall; /// /// `build` / `run` are not assembler subcommands: the subsequent /// `zig build` invocation and binary launch stay CLI-side (see below). -/// The CLI owns docker orchestration, the watch supervision, the -/// iOS deploy path and `--timeout` — generation is the only +/// The CLI owns docker orchestration, the watch supervision and +/// `--timeout` — generation is the only /// step the assembler binary delegates. /// `parsed_args.scene_override` is intentionally NOT forwarded to the /// assembler. PR #243 removed the CLI's `cfg.initial_prefab` rewrite for @@ -170,9 +170,9 @@ pub fn corePrepasses(allocator: std.mem.Allocator, project_dir: []const u8, pars // #421). Its provider owns its asset pipeline; standalone `cmdAstc` // can read capabilities for any declared target name. if (opts.legacy_target and parsed.asset_compression.formatFor(parsed.platform) == .astc) { - // Pass the RESOLVED target: `--platform=wasm` and `labelle ios` - // (forces sokol) differ from what - // project.labelle declares, and the loadable blocks depend on both. + // Pass the RESOLVED target: `--platform=` differs from + // what project.labelle declares, and the loadable blocks depend on + // both. astc_cmd.cmdAstc(allocator, &.{ project_dir, "--platform", diff --git a/src/cli/pipeline/install.zig b/src/cli/pipeline/install.zig index ab695cbf..9288da80 100644 --- a/src/cli/pipeline/install.zig +++ b/src/cli/pipeline/install.zig @@ -42,8 +42,8 @@ pub fn preInstall(allocator: std.mem.Allocator, project_dir: []const u8, parsed: // Validate version compatibility compatibility.validateCompatibility(parsed); - // Pre-build hooks (#355). Runs on `generate` / `build` / `run` (and - // the ios flow, which generates too) — the first thing + // Pre-build hooks (#355). Runs on `generate` / `build` / `run` / + // `bundle` — the first thing // that touches the project after its config is validated, and ahead // of EVERY generation input reader: the ASTC pre-pass, the `--bake` // pre-pass, the assembler's cache populate and `generate`. That @@ -258,11 +258,11 @@ pub fn discoverAndPlan( } // `labelle run` of a provider target is launched by its provider, so it // needs a `replace` hook on `run`: the CLI has no launch of its own for - // any provider target except the legacy branches `legacyRunBranch` - // still names. Without one the run would fall through to the host + // any provider target (the last built-in one, the simulator deploy, left + // with cli#471 I5). Without one the run would fall through to the host // launch and try to execute a binary built for another platform, so // stop HERE, after the install and before generation or any compiler. - if (noRunReplacement(command, target.provider != null, hook_plans.run.replace != null, target.legacy)) { + if (noRunReplacement(command, target.provider != null, hook_plans.run.replace != null)) { std.debug.print("labelle: target '{s}' has no run replacement; package '{s}' must declare a `.when = .replace` hook on `run`\n", .{ target.name, target.providerName() }); return error.NoRunReplacement; } @@ -339,11 +339,6 @@ test "coreGenerateGate: describe refuses an unsupported pair; the enum check is try std.testing.expect(coreGenerateGate(true, false, d) == .unsupported); } -/// The platforms whose provider target the CLI still launches through a -/// built-in `run` branch of its own (`pipeline/run.zig`). Every other -/// provider target is launched by its provider's `replace run` hook. The -/// set only shrinks: a platform leaves it when its launch moves into a -/// provider (cli#405 removed `android`, RFC cli#466 PR B `wasm`). /// A build path that cannot carry what a provider supplies for the target. /// Such a path refuses before anything runs rather than silently bypassing /// the provider (RFC cli#466 A1): @@ -355,32 +350,24 @@ test "coreGenerateGate: describe refuses an unsupported pair; the enum check is /// one zig invocation (the host-side fingerprint pass of `tests/`) does /// receive the contributions; and when the target owner replaces `build`, /// its hook stands in for the container build and gets the -/// contributions like every hook; -/// - `labelle ios` runs its own `zig build` after generation, with neither -/// the contributions nor the effective optimize mode. +/// contributions like every hook. +/// (The legacy platform subcommand's own build, refused for both the +/// contributions and the owner's optimize default, left with cli#471 I5.) pub const Bypass = enum { docker_env, - ios_env, - ios_default, pub fn message(self: Bypass) []const u8 { return switch (self) { .docker_env => "--docker doesn't carry provider environment contributions; build without --docker", - .ios_env => "`labelle ios` runs its own build, which doesn't carry provider environment contributions; use `labelle build --platform=ios`", - .ios_default => "`labelle ios` runs its own build, which ignores the target owner's optimize default; use `labelle build --platform=ios`", }; } }; /// Pure. `contributor` is the plan's first hook that can contribute an -/// environment (`provider_hooks.planContributor`); `owner_default` the -/// target owner's `.target_defaults` optimize mode; `build_replaced` -/// whether the plan has a `replace` hook on `build`. -pub fn providerBypass(command: args_mod.Command, docker: bool, contributor: bool, owner_default: bool, build_replaced: bool) ?Bypass { - if (command == .ios_cmd) { - if (contributor) return .ios_env; - if (owner_default) return .ios_default; - } +/// environment (`provider_hooks.planContributor`); `build_replaced` +/// whether the plan has a `replace` hook on `build`. The target owner's +/// optimize default never refuses: `--docker` passes it as `-Doptimize`. +pub fn providerBypass(command: args_mod.Command, docker: bool, contributor: bool, build_replaced: bool) ?Bypass { if (docker and contributor and reachesContainerBuild(command) and !build_replaced) return .docker_env; return null; } @@ -396,79 +383,50 @@ fn reachesContainerBuild(command: args_mod.Command) bool { test "providerBypass: a path that cannot carry a provider's input refuses instead of bypassing it" { // Nothing to carry: every path keeps today's behaviour. - for ([_]args_mod.Command{ .build, .run, .generate, .bundle_cmd, .ios_cmd }) |command| { - try std.testing.expect(providerBypass(command, false, false, false, false) == null); - try std.testing.expect(providerBypass(command, true, false, false, false) == null); + for ([_]args_mod.Command{ .build, .run, .generate, .bundle_cmd }) |command| { + try std.testing.expect(providerBypass(command, false, false, false) == null); + try std.testing.expect(providerBypass(command, true, false, false) == null); } - // --docker: contributions are refused; a default is passed through. - try std.testing.expectEqual(Bypass.docker_env, providerBypass(.build, true, true, false, false).?); - try std.testing.expectEqual(Bypass.docker_env, providerBypass(.run, true, true, true, false).?); - try std.testing.expectEqual(Bypass.docker_env, providerBypass(.bundle_cmd, true, true, false, false).?); + // --docker: contributions are refused. + try std.testing.expectEqual(Bypass.docker_env, providerBypass(.build, true, true, false).?); + try std.testing.expectEqual(Bypass.docker_env, providerBypass(.run, true, true, false).?); + try std.testing.expectEqual(Bypass.docker_env, providerBypass(.bundle_cmd, true, true, false).?); // `labelle generate --docker` never reaches the container build: its // fingerprint pass runs on the host with the contributions. - try std.testing.expect(providerBypass(.generate, true, true, false, false) == null); - try std.testing.expect(providerBypass(.generate, true, true, true, false) == null); + try std.testing.expect(providerBypass(.generate, true, true, false) == null); // A `replace build` hook stands in for the container build and gets the // contributions like every hook: nothing is bypassed. for ([_]args_mod.Command{ .build, .run, .bundle_cmd }) |command| { - try std.testing.expectEqual(Bypass.docker_env, providerBypass(command, true, true, false, false).?); - try std.testing.expect(providerBypass(command, true, true, false, true) == null); + try std.testing.expectEqual(Bypass.docker_env, providerBypass(command, true, true, false).?); + try std.testing.expect(providerBypass(command, true, true, true) == null); } - // `labelle ios` runs its own build whatever the plan replaces. - try std.testing.expectEqual(Bypass.ios_env, providerBypass(.ios_cmd, false, true, false, true).?); - try std.testing.expect(providerBypass(.build, true, false, true, false) == null); - // Without --docker the shared pipeline carries both. - try std.testing.expect(providerBypass(.build, false, true, true, false) == null); - // `labelle ios`: its own build carries neither. - try std.testing.expectEqual(Bypass.ios_env, providerBypass(.ios_cmd, false, true, false, false).?); - try std.testing.expectEqual(Bypass.ios_env, providerBypass(.ios_cmd, false, true, true, false).?); - try std.testing.expectEqual(Bypass.ios_default, providerBypass(.ios_cmd, false, false, true, false).?); -} - -pub fn legacyRunBranch(platform: ?project_config.Platform) bool { - const p = platform orelse return false; - return switch (p) { - .ios => true, - .desktop, .android, .wasm => false, - }; + // Without --docker the shared pipeline carries the contributions. + try std.testing.expect(providerBypass(.build, false, true, false) == null); } /// Whether `labelle run` must be refused before the build -/// (`error.NoRunReplacement`): a provider target, no `replace run` hook in -/// the plan, and no legacy launch branch to fall back to. Pure, so the -/// decision table is unit-tested without a project. -pub fn noRunReplacement(command: args_mod.Command, has_provider: bool, has_run_replacement: bool, legacy: ?project_config.Platform) bool { - return command == .run and has_provider and !has_run_replacement and !legacyRunBranch(legacy); +/// (`error.NoRunReplacement`): a provider target and no `replace run` hook +/// in the plan. The core has no launch of its own for any provider target +/// (cli#471 I5 removed the last one), so nothing else can launch it. Pure, +/// so the decision table is unit-tested without a project. +pub fn noRunReplacement(command: args_mod.Command, has_provider: bool, has_run_replacement: bool) bool { + return command == .run and has_provider and !has_run_replacement; } -test "NoRunReplacement: a provider target needs a run replacement unless a legacy branch launches it" { - // The refused shape: `run`, a provider target, no replacement, no - // legacy branch (a schema name that left the set, or a foreign name). - try std.testing.expect(noRunReplacement(.run, true, false, .android)); - try std.testing.expect(noRunReplacement(.run, true, false, null)); +test "NoRunReplacement: a provider target needs a run replacement" { + // The refused shape: `run`, a provider target, no replacement — whatever + // its name (a former built-in, too, since cli#471 I5). + try std.testing.expect(noRunReplacement(.run, true, false)); // A replacement launches it. - try std.testing.expect(!noRunReplacement(.run, true, true, .android)); - try std.testing.expect(!noRunReplacement(.run, true, true, null)); - // The legacy branch still launches its own way; every other schema name - // needs its provider's replacement (the built-in serve left in 3.0). - try std.testing.expect(!noRunReplacement(.run, true, false, .ios)); - try std.testing.expect(noRunReplacement(.run, true, false, .wasm)); + try std.testing.expect(!noRunReplacement(.run, true, true)); // The core target is launched by the host branch. - try std.testing.expect(!noRunReplacement(.run, false, false, .desktop)); + try std.testing.expect(!noRunReplacement(.run, false, false)); // Only `run` launches: build, bundle and generate are never refused here. - for ([_]args_mod.Command{ .build, .bundle_cmd, .generate, .ios_cmd }) |command| { - try std.testing.expect(!noRunReplacement(command, true, false, .android)); + for ([_]args_mod.Command{ .build, .bundle_cmd, .generate }) |command| { + try std.testing.expect(!noRunReplacement(command, true, false)); } } -test "legacyRunBranch: only ios keeps a built-in launch" { - try std.testing.expect(!legacyRunBranch(.wasm)); - try std.testing.expect(legacyRunBranch(.ios)); - try std.testing.expect(!legacyRunBranch(.android)); - try std.testing.expect(!legacyRunBranch(.desktop)); - try std.testing.expect(!legacyRunBranch(null)); -} - test "a rejected shader override stops the cold build before any package is installed" { if (@import("builtin").os.tag == .windows) return error.SkipZigTest; const a = std.testing.allocator; diff --git a/src/cli/pipeline/run.zig b/src/cli/pipeline/run.zig index 6ce71716..5a530d99 100644 --- a/src/cli/pipeline/run.zig +++ b/src/cli/pipeline/run.zig @@ -1,11 +1,9 @@ //! The run stage: the `run` hook phases around the launch branches (a -//! provider's `replace run` hook, with the `--watch` supervision; the iOS -//! deploy; the host and docker launches) and the `RunOutcome` plumbing. +//! provider's `replace run` hook, with the `--watch` supervision; the host +//! and docker launches) and the `RunOutcome` plumbing. const std = @import("std"); const builtin = @import("builtin"); -const project_config = @import("../project_config.zig"); const runner = @import("../runner.zig"); -const ios = @import("../ios.zig"); const util = @import("../util.zig"); const progress = @import("../progress.zig"); const args_mod = @import("../args.zig"); @@ -51,7 +49,7 @@ pub fn launch( const hook_site = cx.hook_site; const hook_plans = cx.hook_plans; - if (crossTargetLaunchSkipped(parsed_args.docker, parsed_args.docker_target, parsed.platform, hook_plans.run.replace != null)) |t| { + if (crossTargetLaunchSkipped(parsed_args.docker, parsed_args.docker_target, hook_plans.run.replace != null)) |t| { std.debug.print("labelle: cannot run cross-compiled binary (target: {s})\n", .{t}); std.debug.print(" binary is at: {s}/zig-out/bin/\n", .{target_dir}); if (reporter) |r| r.finishDone(0); // build succeeded; run skipped @@ -77,190 +75,179 @@ pub fn launch( if (outcome == .exited_error) return outcome.exited_error; return provider_hooks.finishRun(hook_site, hook_plans.run.after, run_out, outcome); } - if (parsed.platform == .ios) { - // iOS: deploy to simulator - if (reporter) |r| r.beginPhaseOrStep(.run, "deploying to iOS Simulator"); - std.debug.print("labelle: deploying to iOS Simulator...\n", .{}); - try ios.deployToSimulator(allocator, target_dir, parsed); - if (parsed_args.timeout_defaulted) std.debug.print("labelle: note: the headless default timeout does not apply to a detached launch; stop the app yourself\n", .{}); - // `simctl launch` returns while the app runs on: its exit is never - // seen here, so this is not the clean exit after hooks wait for. - return provider_hooks.finishRun(hook_site, hook_plans.run.after, run_out, .launched_detached); + announceHeadlessDefault(parsed_args); + if (timeout_ns) |t| { + var dur_buf: [48]u8 = undefined; + std.debug.print("labelle: running (timeout: {s})...\n\n", .{formatDuration(&dur_buf, t)}); } else { - announceHeadlessDefault(parsed_args); - if (timeout_ns) |t| { - var dur_buf: [48]u8 = undefined; - std.debug.print("labelle: running (timeout: {s})...\n\n", .{formatDuration(&dur_buf, t)}); - } else { - std.debug.print("labelle: running...\n\n", .{}); - } - // Build a combined env map for the child when --scene (cli#229) - // and/or --screenshot (cli#227) are set. Both flags need to be - // surfaced as env vars to the spawned game: - // - LABELLE_SCENE (cli#229 runtime scene-override) - // - LABELLE_SCREENSHOT_PATH - // - LABELLE_SCREENSHOT_AFTER_SEC - // Loading-controller scripts read LABELLE_SCENE *after* - // assets.allReady succeeds and call setScene(requested), so - // asset streaming for large scenes no longer races boot. This - // is now the ONLY mechanism for `--scene=` — the legacy - // `.initial_prefab` rewrite was removed (see above). - // Default the child env to the ZIG_*_CACHE_DIR map (cli#279) so the - // rebuilt-and-run step still lands the compiler cache in user space. - var env_map_storage: ?std.process.Environ.Map = null; - defer if (env_map_storage) |*m| m.deinit(); - var env_map_ptr: ?*const std.process.Environ.Map = zig_env_ptr; - const has_scene_env = parsed_args.scene_override != null; - const has_screenshot_env = parsed_args.screenshot_path != null; - // --headless (and the flags that imply it) surface as - // LABELLE_HEADLESS=1 plus the optional uncapped/ticks knobs that - // the sokol desktop backend reads. `parsed_args.headless` is - // already set true by `--uncapped`/`--ticks`, so this one check - // covers all three. - const has_headless_env = parsed_args.headless; - // --profile surfaces as LABELLE_PROFILE=1, enabling the engine's - // built-in frame profiler. Independent of --headless. - const has_profile_env = parsed_args.profile; - // Fingerprint every path the capture could land at BEFORE the game - // runs, so the post-run report can tell a file this run wrote from one - // an earlier run left behind. The game's cwd — what a relative path - // resolves against — is the project dir under --docker and the target - // dir otherwise. - const screenshot_probe: ?ScreenshotProbe = if (parsed_args.screenshot_path) |path| - ScreenshotProbe.init(allocator, path, if (parsed_args.docker) project_dir else target_dir) - else - null; - defer if (screenshot_probe) |p| p.deinit(allocator); - if (has_scene_env or has_screenshot_env or has_headless_env or has_profile_env) { - var extras: std.ArrayList(runner.EnvKV) = .empty; - defer extras.deinit(allocator); - // --scene / --profile / --screenshot(+--after): the list shared - // with a `run`-step hook's `run.env` (`hookRunOptions`, cli#397). - var sec_buf: [32]u8 = undefined; - try runner.appendRunOptionEnv(allocator, &extras, runOptionEnv(parsed_args), &sec_buf); - var ticks_buf: [32]u8 = undefined; - if (parsed_args.headless) { - try extras.append(allocator, .{ .key = "LABELLE_HEADLESS", .value = "1" }); - if (parsed_args.headless_uncapped) { - try extras.append(allocator, .{ .key = "LABELLE_HEADLESS_UNCAPPED", .value = "1" }); - } - if (parsed_args.headless_ticks) |n| { - const ticks_str = try std.fmt.bufPrint(&ticks_buf, "{d}", .{n}); - try extras.append(allocator, .{ .key = "LABELLE_HEADLESS_TICKS", .value = ticks_str }); - } + std.debug.print("labelle: running...\n\n", .{}); + } + // Build a combined env map for the child when --scene (cli#229) + // and/or --screenshot (cli#227) are set. Both flags need to be + // surfaced as env vars to the spawned game: + // - LABELLE_SCENE (cli#229 runtime scene-override) + // - LABELLE_SCREENSHOT_PATH + // - LABELLE_SCREENSHOT_AFTER_SEC + // Loading-controller scripts read LABELLE_SCENE *after* + // assets.allReady succeeds and call setScene(requested), so + // asset streaming for large scenes no longer races boot. This + // is now the ONLY mechanism for `--scene=` — the legacy + // `.initial_prefab` rewrite was removed (see above). + // Default the child env to the ZIG_*_CACHE_DIR map (cli#279) so the + // rebuilt-and-run step still lands the compiler cache in user space. + var env_map_storage: ?std.process.Environ.Map = null; + defer if (env_map_storage) |*m| m.deinit(); + var env_map_ptr: ?*const std.process.Environ.Map = zig_env_ptr; + const has_scene_env = parsed_args.scene_override != null; + const has_screenshot_env = parsed_args.screenshot_path != null; + // --headless (and the flags that imply it) surface as + // LABELLE_HEADLESS=1 plus the optional uncapped/ticks knobs that + // a desktop backend reads. `parsed_args.headless` is + // already set true by `--uncapped`/`--ticks`, so this one check + // covers all three. + const has_headless_env = parsed_args.headless; + // --profile surfaces as LABELLE_PROFILE=1, enabling the engine's + // built-in frame profiler. Independent of --headless. + const has_profile_env = parsed_args.profile; + // Fingerprint every path the capture could land at BEFORE the game + // runs, so the post-run report can tell a file this run wrote from one + // an earlier run left behind. The game's cwd — what a relative path + // resolves against — is the project dir under --docker and the target + // dir otherwise. + const screenshot_probe: ?ScreenshotProbe = if (parsed_args.screenshot_path) |path| + ScreenshotProbe.init(allocator, path, if (parsed_args.docker) project_dir else target_dir) + else + null; + defer if (screenshot_probe) |p| p.deinit(allocator); + if (has_scene_env or has_screenshot_env or has_headless_env or has_profile_env) { + var extras: std.ArrayList(runner.EnvKV) = .empty; + defer extras.deinit(allocator); + // --scene / --profile / --screenshot(+--after): the list shared + // with a `run`-step hook's `run.env` (`hookRunOptions`, cli#397). + var sec_buf: [32]u8 = undefined; + try runner.appendRunOptionEnv(allocator, &extras, runOptionEnv(parsed_args), &sec_buf); + var ticks_buf: [32]u8 = undefined; + if (parsed_args.headless) { + try extras.append(allocator, .{ .key = "LABELLE_HEADLESS", .value = "1" }); + if (parsed_args.headless_uncapped) { + try extras.append(allocator, .{ .key = "LABELLE_HEADLESS_UNCAPPED", .value = "1" }); } - if (parsed_args.screenshot_path) |path| { - // Deliberately "requested", not "will be written to": the - // backend picks the real filename and may not honor this path - // (labelle-bgfx#57 appends its own `.tga`). The authoritative - // line is `ScreenshotProbe.report` after the run. - std.debug.print("labelle: screenshot requested: '{s}'\n", .{path}); + if (parsed_args.headless_ticks) |n| { + const ticks_str = try std.fmt.bufPrint(&ticks_buf, "{d}", .{n}); + try extras.append(allocator, .{ .key = "LABELLE_HEADLESS_TICKS", .value = ticks_str }); } - // For a non-docker run, fold the ZIG_*_CACHE_DIR vars in too so - // both the build and run children share the managed cache. For a - // docker run there is no managed toolchain, so just add extras. - env_map_storage = if (parsed_args.docker) - try runner.buildEnvironWithExtra(allocator, extras.items) - else - try runner.buildZigEnv(allocator, extras.items); - env_map_ptr = &env_map_storage.?; } + if (parsed_args.screenshot_path) |path| { + // Deliberately "requested", not "will be written to": the + // backend picks the real filename and may not honor this path + // (a backend may append its own extension). The authoritative + // line is `ScreenshotProbe.report` after the run. + std.debug.print("labelle: screenshot requested: '{s}'\n", .{path}); + } + // For a non-docker run, fold the ZIG_*_CACHE_DIR vars in too so + // both the build and run children share the managed cache. For a + // docker run there is no managed toolchain, so just add extras. + env_map_storage = if (parsed_args.docker) + try runner.buildEnvironWithExtra(allocator, extras.items) + else + try runner.buildZigEnv(allocator, extras.items); + env_map_ptr = &env_map_storage.?; + } - // When --docker was used, run the built binary directly instead of - // calling `zig build run` (local Zig may be broken). - if (parsed_args.docker) { - // (A cross-compiled binary never reaches here: the launch is - // skipped before the run hooks — `crossTargetLaunchSkipped`.) - // The assembler names the desktop binary after the project - // (sanitized) so concurrent games are distinguishable to - // `pgrep` (labelle-assembler#362). Derive the same name here so - // the docker run path execs the binary by its real on-disk name. - const exe_name = try util.sanitizeExeName(allocator, parsed.name); - defer allocator.free(exe_name); - const bin_path = try std.fs.path.join(allocator, &.{ target_dir, "zig-out", "bin", exe_name }); - defer allocator.free(bin_path); - var run_args: std.ArrayList([]const u8) = .empty; - defer run_args.deinit(allocator); - try run_args.append(allocator, bin_path); - try appendRunForwardedArgs(&run_args, allocator, parsed_args); - if (reporter) |r| r.beginPhaseOrStep(.run, exe_name); - noteRunSharesStdout(reporter); - const run_outcome = runOutcome(try runner.runInheritTerm(allocator, project_dir, run_args.items, timeout_ns, env_map_ptr)); - if (run_outcome == .exited_error) { - std.debug.print("\nlabelle: process exited with code {d}\n", .{run_outcome.status()}); - } - noteDefaultTimeoutFired(parsed_args, run_outcome); - if (screenshot_probe) |p| p.report(allocator); - // The game ran: the pipeline is `done` even on a nonzero game - // exit — the code is carried in the terminal record, and it is - // also the CLI's exit status (cli#390). After hooks run first, - // and only when the game itself exited clean — never after the - // watchdog's kill, which is also exit 0. - return provider_hooks.finishRun(hook_site, hook_plans.run.after, run_out, run_outcome); - } else { - // Run the game BINARY DIRECTLY rather than via `zig build run`. - // `zig build run` launches the game in its own child process - // group, which ESCAPES the --timeout kill: the watchdog signals - // labelle's direct child (the `zig build` process), the game - // survives in its separate group, gets reparented to init, and - // orphans. Run as labelle's own child and the game stays in the - // process group the watchdog signals, so SIGTERM→SIGKILL - // actually reaches it. Mirrors the --docker path. - // - // Keep the game's cwd at `target_dir` (a target_dir-relative - // argv[0]) so saves land exactly where `zig build run` put them. - // - // There is deliberately NO second `zig build` here. The core - // build above (`core_build`, or its `replace` hook) is the one - // and only build of this command; the warm re-build that used - // to sit here was a leftover of translating `zig build run` - // into build-then-exec (cli#265) — a warm-cache no-op that - // nonetheless re-ran the install steps, so a `zig-out/` file an - // `after build` hook had signed, stripped or patched was copied - // back to its unhooked original right before launch, and a - // `replace` hook's build was quietly followed by the core one - // (Codex P2 on #420). `run` begins when the game binary is - // about to spawn. - // Exe name: the assembler names the desktop exe after the - // sanitized project (labelle-assembler#362); older generated - // build.zig still emit `game`. Prefer the project name; fall back - // to `game` when that binary isn't on disk, so this works both - // before and after the rename ships. Run it by a target_dir- - // relative path so the game's cwd stays `target_dir` (saves land - // where `zig build run` put them). Mirrors the --docker path. - // Probe with the platform executable suffix: on Windows the - // assembler emits `.exe`, so a suffix-less probe never - // matches and would wrongly fall back to the legacy `game` name, - // then fail to launch with FileNotFound (cli#309). - const exe_suffix = if (builtin.os.tag == .windows) ".exe" else ""; - const sanitized = try util.sanitizeExeName(allocator, parsed.name); - defer allocator.free(sanitized); - const sanitized_exe = try std.fmt.allocPrint(allocator, "{s}{s}", .{ sanitized, exe_suffix }); - defer allocator.free(sanitized_exe); - const sanitized_full = try std.fs.path.join(allocator, &.{ target_dir, "zig-out", "bin", sanitized_exe }); - defer allocator.free(sanitized_full); - const exe_basename: []const u8 = if (util.fileExists(sanitized_full)) sanitized_exe else "game" ++ exe_suffix; - const rel_bin = try std.fs.path.join(allocator, &.{ "zig-out", "bin", exe_basename }); - defer allocator.free(rel_bin); - var run_args: std.ArrayList([]const u8) = .empty; - defer run_args.deinit(allocator); - try run_args.append(allocator, rel_bin); - try appendRunForwardedArgs(&run_args, allocator, parsed_args); - if (reporter) |r| r.beginPhaseOrStep(.run, exe_basename); - noteRunSharesStdout(reporter); - const run_outcome = runOutcome(try runner.runInheritTerm(allocator, target_dir, run_args.items, timeout_ns, env_map_ptr)); - if (run_outcome == .exited_error) { - std.debug.print("\nlabelle: process exited with code {d}\n", .{run_outcome.status()}); - } - noteDefaultTimeoutFired(parsed_args, run_outcome); - if (screenshot_probe) |p| p.report(allocator); - // The game ran: the pipeline is `done` even on a nonzero game - // exit — the code is carried in the terminal record, and it is - // also the CLI's exit status (cli#390). After hooks run first, - // and only when the game itself exited clean — never after the - // watchdog's kill, which is also exit 0. - return provider_hooks.finishRun(hook_site, hook_plans.run.after, run_out, run_outcome); + // When --docker was used, run the built binary directly instead of + // calling `zig build run` (local Zig may be broken). + if (parsed_args.docker) { + // (A cross-compiled binary never reaches here: the launch is + // skipped before the run hooks — `crossTargetLaunchSkipped`.) + // The assembler names the desktop binary after the project + // (sanitized) so concurrent games are distinguishable to + // `pgrep` (labelle-assembler#362). Derive the same name here so + // the docker run path execs the binary by its real on-disk name. + const exe_name = try util.sanitizeExeName(allocator, parsed.name); + defer allocator.free(exe_name); + const bin_path = try std.fs.path.join(allocator, &.{ target_dir, "zig-out", "bin", exe_name }); + defer allocator.free(bin_path); + var run_args: std.ArrayList([]const u8) = .empty; + defer run_args.deinit(allocator); + try run_args.append(allocator, bin_path); + try appendRunForwardedArgs(&run_args, allocator, parsed_args); + if (reporter) |r| r.beginPhaseOrStep(.run, exe_name); + noteRunSharesStdout(reporter); + const run_outcome = runOutcome(try runner.runInheritTerm(allocator, project_dir, run_args.items, timeout_ns, env_map_ptr)); + if (run_outcome == .exited_error) { + std.debug.print("\nlabelle: process exited with code {d}\n", .{run_outcome.status()}); } + noteDefaultTimeoutFired(parsed_args, run_outcome); + if (screenshot_probe) |p| p.report(allocator); + // The game ran: the pipeline is `done` even on a nonzero game + // exit — the code is carried in the terminal record, and it is + // also the CLI's exit status (cli#390). After hooks run first, + // and only when the game itself exited clean — never after the + // watchdog's kill, which is also exit 0. + return provider_hooks.finishRun(hook_site, hook_plans.run.after, run_out, run_outcome); + } else { + // Run the game BINARY DIRECTLY rather than via `zig build run`. + // `zig build run` launches the game in its own child process + // group, which ESCAPES the --timeout kill: the watchdog signals + // labelle's direct child (the `zig build` process), the game + // survives in its separate group, gets reparented to init, and + // orphans. Run as labelle's own child and the game stays in the + // process group the watchdog signals, so SIGTERM→SIGKILL + // actually reaches it. Mirrors the --docker path. + // + // Keep the game's cwd at `target_dir` (a target_dir-relative + // argv[0]) so saves land exactly where `zig build run` put them. + // + // There is deliberately NO second `zig build` here. The core + // build above (`core_build`, or its `replace` hook) is the one + // and only build of this command; the warm re-build that used + // to sit here was a leftover of translating `zig build run` + // into build-then-exec (cli#265) — a warm-cache no-op that + // nonetheless re-ran the install steps, so a `zig-out/` file an + // `after build` hook had signed, stripped or patched was copied + // back to its unhooked original right before launch, and a + // `replace` hook's build was quietly followed by the core one + // (Codex P2 on #420). `run` begins when the game binary is + // about to spawn. + // Exe name: the assembler names the desktop exe after the + // sanitized project (labelle-assembler#362); older generated + // build.zig still emit `game`. Prefer the project name; fall back + // to `game` when that binary isn't on disk, so this works both + // before and after the rename ships. Run it by a target_dir- + // relative path so the game's cwd stays `target_dir` (saves land + // where `zig build run` put them). Mirrors the --docker path. + // Probe with the platform executable suffix: on Windows the + // assembler emits `.exe`, so a suffix-less probe never + // matches and would wrongly fall back to the legacy `game` name, + // then fail to launch with FileNotFound (cli#309). + const exe_suffix = if (builtin.os.tag == .windows) ".exe" else ""; + const sanitized = try util.sanitizeExeName(allocator, parsed.name); + defer allocator.free(sanitized); + const sanitized_exe = try std.fmt.allocPrint(allocator, "{s}{s}", .{ sanitized, exe_suffix }); + defer allocator.free(sanitized_exe); + const sanitized_full = try std.fs.path.join(allocator, &.{ target_dir, "zig-out", "bin", sanitized_exe }); + defer allocator.free(sanitized_full); + const exe_basename: []const u8 = if (util.fileExists(sanitized_full)) sanitized_exe else "game" ++ exe_suffix; + const rel_bin = try std.fs.path.join(allocator, &.{ "zig-out", "bin", exe_basename }); + defer allocator.free(rel_bin); + var run_args: std.ArrayList([]const u8) = .empty; + defer run_args.deinit(allocator); + try run_args.append(allocator, rel_bin); + try appendRunForwardedArgs(&run_args, allocator, parsed_args); + if (reporter) |r| r.beginPhaseOrStep(.run, exe_basename); + noteRunSharesStdout(reporter); + const run_outcome = runOutcome(try runner.runInheritTerm(allocator, target_dir, run_args.items, timeout_ns, env_map_ptr)); + if (run_outcome == .exited_error) { + std.debug.print("\nlabelle: process exited with code {d}\n", .{run_outcome.status()}); + } + noteDefaultTimeoutFired(parsed_args, run_outcome); + if (screenshot_probe) |p| p.report(allocator); + // The game ran: the pipeline is `done` even on a nonzero game + // exit — the code is carried in the terminal record, and it is + // also the CLI's exit status (cli#390). After hooks run first, + // and only when the game itself exited clean — never after the + // watchdog's kill, which is also exit 0. + return provider_hooks.finishRun(hook_site, hook_plans.run.after, run_out, run_outcome); } } @@ -337,35 +324,25 @@ fn noteRunSharesStdout(reporter: ?*progress.Reporter) void { /// The `--target` of a `run --docker` whose launch is skipped: a /// cross-compiled binary cannot run on this host. Only the host launch -/// branch launches a binary here — `ios` deploys its own way, and every -/// other provider target has a `replace run` hook (the install stage -/// refuses it otherwise, `NoRunReplacement`), which stands in for the -/// launch entirely — so neither is skipped. `null` when the launch happens. -/// (A `--docker` run of a provider target is refused before this, RFC -/// cli#466 D5; the table stays total over the schema platforms.) -fn crossTargetLaunchSkipped(in_docker: bool, docker_target: ?[]const u8, platform: project_config.Platform, replaced: bool) ?[]const u8 { +/// branch launches a binary here: every provider target has a `replace +/// run` hook (the install stage refuses it otherwise, `NoRunReplacement`), +/// which stands in for the launch entirely, so it is never skipped. `null` +/// when the launch happens. (A `--docker` run of a provider target is +/// refused before this, RFC cli#466 D5; the last built-in deploy branch +/// left with cli#471 I5.) +fn crossTargetLaunchSkipped(in_docker: bool, docker_target: ?[]const u8, replaced: bool) ?[]const u8 { if (!in_docker or replaced) return null; - switch (platform) { - .ios => return null, - else => return docker_target, - } + return docker_target; } test "pipeline: a cross-target docker run is skipped before the run hooks" { // The skipped shape: docker, a cross target, the host launch branch. - try std.testing.expectEqualStrings("aarch64-linux", crossTargetLaunchSkipped(true, "aarch64-linux", .desktop, false).?); + try std.testing.expectEqualStrings("aarch64-linux", crossTargetLaunchSkipped(true, "aarch64-linux", false).?); // Each condition alone is not enough. - try std.testing.expect(crossTargetLaunchSkipped(false, "aarch64-linux", .desktop, false) == null); - try std.testing.expect(crossTargetLaunchSkipped(true, null, .desktop, false) == null); - // A replacement launches its own way; the deploy targets never reach - // the host launch branch. - try std.testing.expect(crossTargetLaunchSkipped(true, "aarch64-linux", .desktop, true) == null); - try std.testing.expect(crossTargetLaunchSkipped(true, "aarch64-linux", .ios, false) == null); - // `android` has no launch branch of its own any more: its provider's - // `replace run` hook launches it (a cross target is not skipped then), - // and without one the install stage refused the run already. - try std.testing.expect(crossTargetLaunchSkipped(true, "aarch64-linux", .android, true) == null); - try std.testing.expectEqualStrings("aarch64-linux", crossTargetLaunchSkipped(true, "aarch64-linux", .android, false).?); + try std.testing.expect(crossTargetLaunchSkipped(false, "aarch64-linux", false) == null); + try std.testing.expect(crossTargetLaunchSkipped(true, null, false) == null); + // A replacement launches its own way, so a cross target is not skipped. + try std.testing.expect(crossTargetLaunchSkipped(true, "aarch64-linux", true) == null); } /// The `labelle run` options that reach the game as `LABELLE_*` variables on diff --git a/src/cli/project_config.zig b/src/cli/project_config.zig index 35cc453b..d3193ca7 100644 --- a/src/cli/project_config.zig +++ b/src/cli/project_config.zig @@ -7,10 +7,9 @@ //! the CLI itself needs to read. //! //! Why the CLI needs *any* of the schema: a handful of CLI-retained -//! commands (`build` / `run` / `upgrade` / the legacy iOS deploy path) -//! parse `project.labelle` to drive orchestration the CLI owns — -//! target-dir naming (`_`), the compatibility-warning -//! pass, the iOS `Info.plist` emission, lockfile +//! commands (`build` / `run` / `upgrade`) parse `project.labelle` to +//! drive orchestration the CLI owns — target-dir naming +//! (`_`), the compatibility-warning pass, lockfile //! writing, docker target selection. None of that is code generation; //! it is the CLI deciding *which assembler subcommand to run and with //! what flags*, plus packaging steps the assembler never touches. @@ -121,33 +120,6 @@ pub const PluginDep = struct { } }; -// ── iOS Configuration ────────────────────────────────────────────── - -/// Screen-orientation policy of a platform config block that names one. -/// -/// MIRRORED in labelle-assembler `src/config.zig` — `project.labelle` is -/// parsed strictly there first, so both copies must learn a new value -/// together or `generate` rejects the field before the CLI sees it (#341). -pub const Orientation = enum { - portrait, - /// Landscape. Whether a 180° flip rotates the game is the packager's - /// choice: a platform may lock ONE landscape direction here and allow - /// both only for `.sensor_landscape`, or allow both for either value. - landscape, - /// Landscape only, but EITHER direction. - sensor_landscape, - all, -}; - -pub const IosConfig = struct { - app_name: []const u8 = "", - bundle_id: []const u8 = "", - team_id: []const u8 = "", - minimum_ios: []const u8 = "15.0", - orientation: Orientation = .all, - device_family: []const u8 = "1,2", -}; - pub const LayerSpace = enum { world, screen, screen_fill }; pub const LayerDef = struct { @@ -452,9 +424,6 @@ pub const ProjectConfig = struct { /// Game states for the state machine. First element is the initial state. states: []const []const u8 = &.{"running"}, - /// iOS configuration — parsed from project.labelle `.ios` section. - ios: ?IosConfig = null, - /// Pinned assembler version (RFC #122). When set, the CLI resolves the /// assembler binary from the cache instead of the paired default. assembler_version: ?[]const u8 = null, @@ -523,41 +492,21 @@ test "AssetCompression.formatFor + default png" { try @import("std").testing.expectEqual(AssetFormat.png, m.formatFor(.wasm)); } -test "Orientation: every value parses from ZON on the .ios block" { - // Mirrors the assembler's parse test (labelle-assembler src/config.zig): - // the two copies of this enum must accept the identical value set, or a - // `project.labelle` that generates fine still fails when the CLI reads it. - // Arena, not `std.zon.parse.free`: the struct has `[]const u8` fields - // defaulting to a static `""`, which the testing allocator refuses to free. - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - const alloc = arena.allocator(); - - inline for (.{ - .{ "portrait", Orientation.portrait }, - .{ "landscape", Orientation.landscape }, - .{ "sensor_landscape", Orientation.sensor_landscape }, - .{ "all", Orientation.all }, - }) |case| { - const src: [:0]const u8 = ".{ .orientation = ." ++ case[0] ++ " }"; - const ios = try std.zon.parse.fromSliceAlloc(IosConfig, alloc, src, null, .{}); - try std.testing.expectEqual(case[1], ios.orientation); - } - - try std.testing.expectEqual(Orientation.all, (IosConfig{}).orientation); -} - test "a platform block the CLI no longer models still parses (the assembler owns its strictness)" { - // `.android` left the CLI (cli#405): its keys belong to the `android` - // provider's settings and the assembler's codegen. The CLI's lenient - // read (`config.zig`, `ignore_unknown_fields`) must keep accepting a - // project that still carries the block. + // `.android` left the CLI (cli#405) and `.ios` followed (cli#471 I5): + // their keys belong to the target providers' settings + // (`providers/.json`, e.g. labelle-ios) and the assembler's + // codegen. The CLI's lenient read (`config.zig`, + // `ignore_unknown_fields`) must keep accepting a project that still + // carries either block, and simply ignores it. var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const src: [:0]const u8 = - \\.{ .name = "legacy", .android = .{ .package_name = "com.x.y", .orientation = .landscape, .immersive_mode = true } } + \\.{ .name = "legacy", .android = .{ .package_name = "com.x.y", .orientation = .landscape, .immersive_mode = true }, + \\ .ios = .{ .app_name = "Legacy", .bundle_id = "com.x.y", .team_id = "ABCDE12345", .minimum_ios = "15.0", .orientation = .sensor_landscape, .device_family = "1,2" } } ; const cfg = try std.zon.parse.fromSliceAlloc(ProjectConfig, arena.allocator(), src, null, .{ .ignore_unknown_fields = true }); try std.testing.expectEqualStrings("legacy", cfg.name); try std.testing.expect(!@hasField(ProjectConfig, "android")); + try std.testing.expect(!@hasField(ProjectConfig, "ios")); } diff --git a/src/cli/provider_dispatch.zig b/src/cli/provider_dispatch.zig index 653ddaed..0562df24 100644 --- a/src/cli/provider_dispatch.zig +++ b/src/cli/provider_dispatch.zig @@ -17,14 +17,14 @@ const provider_env = @import("provider_env.zig"); const provider_cache = @import("provider_cache.zig"); const python_provision = @import("python_provision.zig"); -// Existing platform commands remain reserved until their extraction lands; -// an extracted one leaves the list and its namespace becomes dispatchable -// (cli#405, and RFC cli#466 PR B for the legacy browser commands). +// The built-in commands are reserved; an extracted platform command leaves +// the list and its namespace becomes dispatchable (cli#405, RFC cli#466 PR B +// for the legacy browser commands, and RFC cli#471 I5 for the last one). pub const reserved = [_][]const u8{ - "generate", "build", "bundle", "run", "init", "add", "install", "update", - "upgrade", "clean", "test", "pack", "astc", "audit", "migrate", "check", - "plugins", "doctor", "assembler", "toolchain", "status", "ios", "help", "version", - "targets", "providers", + "generate", "build", "bundle", "run", "init", "add", "install", "update", + "upgrade", "clean", "test", "pack", "astc", "audit", "migrate", "check", + "plugins", "doctor", "assembler", "toolchain", "status", "help", "version", "targets", + "providers", }; pub const Provider = struct { dep: project.PluginDep, dir: []const u8, meta: manifest.Manifest, verified: bool }; @@ -830,14 +830,26 @@ fn execute(a: std.mem.Allocator, root: []const u8, cfg: project.ProjectConfig, p }); } -test "provider dispatch: an extracted platform's namespace is no longer reserved" { - // `android` moved into its provider (cli#405), and the legacy browser - // subcommands left the core (RFC cli#466 PR B): a package may declare - // either namespace. The legacy subcommand still built in stays reserved. - for ([_][]const u8{ "android", "wasm" }) |extracted| { - for (reserved) |name| try std.testing.expect(!std.mem.eql(u8, name, extracted)); +test "provider dispatch: only a built-in command's name is reserved" { + // Every extracted platform subcommand left the list with its code + // (cli#405, RFC cli#466 PR B, RFC cli#471 I5), so each reserved name is + // a command the core itself parses: an `args.Command` tag spelled + // `` or `_cmd`, or `providers` (dispatched before the + // command switch in `cli.zig`). A name without a built-in behind it + // would block a package's namespace for nothing. + const Command = @import("args.zig").Command; + for (reserved) |name| { + if (std.mem.eql(u8, name, "providers")) continue; + var found = false; + inline for (@typeInfo(Command).@"enum".fields) |field| { + const tag = field.name; + const bare = if (comptime std.mem.endsWith(u8, tag, "_cmd")) tag[0 .. tag.len - "_cmd".len] else tag; + found = found or std.mem.eql(u8, name, bare); + } + if (!found) std.debug.print("reserved namespace '{s}' has no built-in command\n", .{name}); + try std.testing.expect(found); } - for ([_][]const u8{ "ios", "run", "build", "bundle" }) |kept| { + for ([_][]const u8{ "run", "build", "bundle" }) |kept| { var found = false; for (reserved) |name| found = found or std.mem.eql(u8, name, kept); try std.testing.expect(found); diff --git a/src/cli/provider_hooks.zig b/src/cli/provider_hooks.zig index 1ba856e0..d9fd3721 100644 --- a/src/cli/provider_hooks.zig +++ b/src/cli/provider_hooks.zig @@ -518,7 +518,7 @@ pub const RunOutcome = union(enum) { /// The game was stopped at the `--timeout` deadline: by the CLI's /// watchdog, or by a run replacement that reported it. timed_out, - /// The launch returned while the app runs elsewhere (`simctl launch`, + /// The launch returned while the app runs elsewhere (a simulator, /// or any launcher that hands the app to a device): its exit is never /// observed. launched_detached, diff --git a/test/provider_hooks_e2e.py b/test/provider_hooks_e2e.py index 40344caa..860ff943 100644 --- a/test/provider_hooks_e2e.py +++ b/test/provider_hooks_e2e.py @@ -924,22 +924,23 @@ def by_id(step_dir): assert "--docker builds the `desktop` target only" in refused.stderr, (docker_args, refused.stderr) assert "FIXTURE_GENERATE" not in refused.stderr and not log(replaced_dir) and not log(replaced_dir / "zig-out"), refused.stderr a_manifest.write_text(manifest("fixture-a", ENV_HOOKS)) - # The legacy `labelle ios` runs its own zig build after generation, with - # neither the contributions nor the optimize default: refused for either, - # before any hook or generation. - ios_dir = project / ".labelle" / "sokol_ios" + # iOS left the core (RFC cli#471 I5): `labelle ios` is no built-in any + # more, so even with a pinned owner of `ios` (which declares no `ios` + # namespace) the word is an unknown command — no hook runs, nothing + # generates. And `run --platform=ios` has no core simulator launch to + # fall back to: without the owner's `replace run` it is refused like + # any provider target (`NoRunReplacement`), before any hook runs. + ios_dir = project / ".labelle" / "raylib_ios" a_manifest.write_text(manifest("fixture-a", [hook("tc", "generate", "before", target="ios")], targets=["ios"])) reset() refused = run("ios", "build", code=1) - assert "hook 'fixture-a/tc' may contribute an environment for target 'ios'" in refused.stderr, refused.stderr - assert "`labelle ios` runs its own build, which doesn't carry provider environment contributions" in refused.stderr, refused.stderr + assert "labelle: unknown command 'ios'" in refused.stderr, refused.stderr assert "FIXTURE_GENERATE" not in refused.stderr and not log(ios_dir), refused.stderr - a_manifest.write_text(manifest("fixture-a", [], targets=["ios"], defaults=(("ios", "ReleaseSafe"),))) reset() - refused = run("ios", "build", code=1) - assert "'fixture-a' declares an optimize default for target 'ios'" in refused.stderr, refused.stderr - assert "`labelle ios` runs its own build, which ignores the target owner's optimize default" in refused.stderr, refused.stderr - assert "FIXTURE_GENERATE" not in refused.stderr, refused.stderr + refused = run("run", "--platform=ios", code=1) + assert "target 'ios' has no run replacement; package 'fixture-a' must declare a `.when = .replace` hook on `run`" in refused.stderr, refused.stderr + assert "NoRunReplacement" in refused.stderr, refused.stderr + assert "FIXTURE_GENERATE" not in refused.stderr and not log(ios_dir), refused.stderr a_manifest.write_text(manifest("fixture-a", ENV_HOOKS)) # ── contract 1.3.0: the target owner's optimize default ─────────────── diff --git a/test/provider_targets_e2e.py b/test/provider_targets_e2e.py index 2082208d..6bf364c6 100644 --- a/test/provider_targets_e2e.py +++ b/test/provider_targets_e2e.py @@ -173,17 +173,19 @@ def installed_only(result, target): assert NO_PROVIDER.format(t="wasm") in refused.stderr, refused.stderr assert "(registry:" not in refused.stderr, "a registry owner was invented" untouched(refused, "wasm") - # The legacy subcommand requests the same target and fails the same way. - refused = run("ios", "build", code=1) - assert NO_PROVIDER.format(t="ios") in refused.stderr, refused.stderr - untouched(refused, "ios") - # `android` (cli#405) and the legacy `wasm serve|export` (RFC cli#466 PR - # B) are no built-ins any more: each is a provider namespace like any - # other, so with no package declaring it the word is an unknown command - # and nothing is generated (test/provider_android_like_e2e.py covers the - # registry hint). + # `ios` has no core fallback either since RFC cli#471 I5: no simulator + # launch, no forced backend — the plain no-provider verdict. + for args in (("build", "--platform=ios"), ("run", "--platform=ios")): + refused = run(*args, code=1) + assert NO_PROVIDER.format(t="ios") in refused.stderr, (args, refused.stderr) + untouched(refused, "ios") + # `android` (cli#405), the legacy `wasm serve|export` (RFC cli#466 PR B) + # and `ios` (RFC cli#471 I5) are no built-ins any more: each is a + # provider namespace like any other, so with no package declaring it the + # word is an unknown command and nothing is generated + # (test/provider_android_like_e2e.py covers the registry hint). for args in (("android", "build"), ("wasm", "serve", "--no-open"), ("wasm", "export"), - ("wasm", "serve", "--no-build")): + ("wasm", "serve", "--no-build"), ("ios", "build"), ("ios", "run")): refused = run(*args, code=1) assert f"labelle: unknown command '{args[0]}'" in refused.stderr, (args, refused.stderr) assert NO_PROVIDER.format(t=args[0]) not in refused.stderr, (args, refused.stderr) From 55c9d228d4686a4c2ed054e2a591f69116d1f014 Mon Sep 17 00:00:00 2001 From: Daniel Murta Date: Tue, 29 Sep 2026 12:44:32 -0300 Subject: [PATCH 2/4] feat!: targets are names; drop the CLI's Platform and AssetCompression mirrors (cli#471 P3) The CLI no longer mirrors the project schema's platform enum or its asset-compression table. A target is a name end to end: `desktop` is core, every other name is declared by a pinned provider, and the assembler says what it can do with it. - project_config: `Platform`, `AssetCompression` (+ `formatFor`) and the `platform`/`asset_compression` fields are gone. `.platform` is read as a name (`declaredTarget`, any enum literal) into `declared_target`, which `config.readProjectConfig` fills after the tolerant parse; it is still the default target when no `--platform` is given. `.asset_compression` is skipped by `ignore_unknown_fields`; the assembler reads both. - provider_targets: `Provisional.legacy` / `Resolved.legacy` are gone. - The labelle-assembler#378 schema-target gate is replaced by the assembler's own verdict: unless a provider replaces `generate`, the pipeline (and a watched rebuild) asks `describe --target ` after the install and refuses `supported: false` with describe's reason. An unknown target reads "backend 'X' cannot build target 'T': backend 'X' has no target 'T': this assembler generates for ...", plus a line telling a provider target's owner it declares no `replace generate`. - The ASTC prepass keys on `describe`'s `asset_format` for the resolved target name (passed to `labelle astc --platform `), asked on every generation so a watched rebuild follows an edited `.asset_compression`. - `parsed.platform` is gone from the pipeline; args_resolve no longer derives it, and the session key, provider command context and `labelle astc` default read `declared_target`. - Agnosticism guard: cli.zig, cli/pipeline.zig, cli/pipeline/build.zig and cli/project_config.zig came clean; 2 entries left. - docs/provider-targets.md: "What the assembler generates" replaces the #378 boundary section; the targets e2e fake assembler answers like the real `describe` (unsupported with a reason for unknown names, `asset_format` from `.asset_compression`). BREAKING CHANGE: the CLI no longer knows the schema platforms. A provider target whose provider does not replace `generate` is refused with the assembler's `describe` reason instead of the #378 message, and whether the ASTC prepass runs comes from the assembler's `asset_format` for the target (so it needs an assembler that answers `describe`, protocol >= 7, which CLI 4.0 already requires). `.platform` accepts any target name as an enum literal; a non-enum value is a parse error. --- docs/provider-targets.md | 76 +++++----- src/agnostic_guard_test.zig | 45 +++--- src/astc/cmd.zig | 2 +- src/cli.zig | 6 +- src/cli/config.zig | 9 +- src/cli/pipeline.zig | 18 +-- src/cli/pipeline/args_resolve.zig | 16 +- src/cli/pipeline/build.zig | 2 +- src/cli/pipeline/generate.zig | 37 +++-- src/cli/pipeline/install.zig | 80 +++++----- src/cli/pipeline/rebuild_commit_tests.zig | 16 +- src/cli/pipeline/rebuild_lock_tests.zig | 5 +- src/cli/pipeline/rebuild_replan.zig | 34 ++--- src/cli/pipeline/rebuild_replan_tests.zig | 47 +++--- .../pipeline/rebuild_transaction_tests.zig | 14 +- src/cli/pipeline/session_key.zig | 2 +- src/cli/pipeline/watch_session.zig | 2 +- src/cli/project_config.zig | 141 ++++++++++-------- src/cli/provider_dispatch.zig | 2 +- src/cli/provider_targets.zig | 54 +++---- test/provider_targets_e2e.py | 42 ++++-- 21 files changed, 334 insertions(+), 316 deletions(-) diff --git a/docs/provider-targets.md b/docs/provider-targets.md index 22214bac..ac6391c0 100644 --- a/docs/provider-targets.md +++ b/docs/provider-targets.md @@ -16,7 +16,7 @@ Every command that runs the project pipeline (`generate`, `build`, `run`, one target, in two halves, before anything is generated, locked or built: 1. The requested name is `--platform=` when given, else the project's - declared `.platform`. The parsers check only that it is identifier-shaped + declared `.platform` (read as a name: `.platform = .`). The parsers check only that it is identifier-shaped (`[a-z][a-z0-9_-]*`); anything else is `invalid target '' (targets are lowercase identifiers; run 'labelle targets')`. A Windows reserved device name (`con`, `nul`, `prn`, `aux`, `com1`-`com9`, `lpt1`-`lpt9`) is @@ -138,8 +138,8 @@ one target, in two halves, before anything is generated, locked or built: The two halves are the **name** and the **ownership**. The name is settled from the string alone, first thing: `desktop` is core, any other name is *provisionally* a provider target. That is enough for everything the -pipeline needs before a provider can be read — the target directory, the -progress feed, the schema platform the pre-install steps key off — and for +pipeline needs before a provider can be read — the target directory and the +progress feed the pre-install steps key off — and for two verdicts that need no provider: a project with no `.plugins` cannot own a provider target and is refused before anything is read, written or built, and `labelle bundle` of the core target is refused off macOS before any @@ -164,8 +164,9 @@ package that does not declare the requested name is refused there — after the install, before the lock, generation or any compiler, with a `failed` progress record naming the refusal — `no provider for target`, or `unpinned provider for target` when the declaring package is remote and -unpinned — and nothing else in the target directory. The `labelle-assembler#378` gate and the bundle-replacement check -below need the hook plans, so they land at the post-install point. +unpinned — and nothing else in the target directory. The +[generation gate](#what-the-assembler-generates) and the bundle-replacement +check below need the hook plans, so they land at the post-install point. `--docker` is decided before any of this (CLI 3.0, RFC cli#466 D5): the container build is the core `desktop` target's only, so a provider target @@ -192,38 +193,35 @@ wasm provided by labelle-web It is metadata only (like `labelle help`): a broken provider manifest is a warning on that listing, not a failure. -## The labelle-assembler#378 boundary - -`project.labelle`'s `.platform` keeps its strict schema type -(`project_config.Platform`, mirrored by the assembler), and the assembler -still parses `--platform` against that enum. The CLI therefore draws one -explicit line: - -- A provider target whose name is a schema platform (`wasm`, `android`, - `ios`) is handed to the assembler as `--platform `, exactly as - before. No pipeline branch keys on the enum any more (the last, the `ios` - simulator launch, left with RFC cli#471 I5), so their providers replace - `run` (see [`labelle run`](#labelle-run)). -- A provider target outside the enum can only be generated for by its - provider's `replace` hook on `generate`. Without one the command stops - before the assembler runs: - - ``` - labelle: target 'probe-target' is declared by 'fixture' but the pinned assembler cannot generate for it yet (labelle-assembler#378) - ``` - - With one, the provider generates; the core steps it does not replace - (`build`, `run`) treat the target as the generic host baseline. The ASTC - prepass is not among them: it keys on the requested target, and a target - outside the enum has no `asset_compression` setting (standalone `labelle astc` - can read that target's backend manifest declaration), - so the prepass is skipped rather than run with the derived `desktop` — a - provider owns its target's asset pipeline. - -What waits for assembler#378: string-resolved platforms in `generate`, the -removal of the `Capability.{wasm,android,ios}` derivation, and the enum -leaving both schema mirrors. Nothing in `project.labelle` changes in this -slice, and `provider_settings.zig` is untouched. +## What the assembler generates + +The CLI keeps no list of targets and no mirror of the project schema's +platform or asset-compression types (CLI 4.0, RFC cli#471 P3). Whether the +core generation can produce a target is the assembler's answer: after the +install, unless the target's provider replaces `generate`, the pipeline asks +`labelle-assembler describe --target ` and stops — before the lock, the +assembler's `generate` and any compiler — when it answers `supported: false`. +That covers a backend that does not support the target and a name the +assembler does not generate for at all; the reason is the assembler's own: + +``` +labelle: backend 'bgfx' cannot build target 'probe-target': backend 'bgfx' has no target 'probe-target': this assembler generates for desktop ios android wasm + package 'fixture' declares target 'probe-target' but no `.when = .replace` hook on `generate`, so the assembler must generate it +``` + +A watched rebuild applies the same gate to the project as it now is. With a +`replace` hook on `generate` the provider generates, whatever the assembler +knows; the core steps it does not replace (`build`, `run`) treat the target +as the generic host baseline. + +The ASTC prepass keys on the resolved target the same way: `describe`'s +`asset_format` (the assembler reads `.asset_compression` by the target +name, `web` a warned alias for `wasm`) decides whether `labelle astc +--platform ` runs before generation. It is never asked for a target whose +generation a provider replaces: that provider owns its asset pipeline. + +`project.labelle`'s `.platform` and `.asset_compression` still parse +unchanged; the assembler owns their strictness. ## `labelle run` @@ -391,8 +389,8 @@ early verdict for a local provider that does not own the name, including a typo that leaves a `.prebuild` marker step unrun (while a resolving target runs it); a remote package on a cold cache deferring the verdict to after the install (the `failed` record) and, unpinned, refused as an owner both -after the install and — warm — before it; the #378 message before the -assembler runs; `NoBundleReplacement`; a provider replacing +after the install and — warm — before it; the generation gate's refusal +(`describe`'s reason) before the assembler generates; `NoBundleReplacement`; a provider replacing `generate`/`build`/`bundle` for `probe-target`, including `labelle bundle --platform=probe-target` running the replacement on every host with the contract's `output_dir`; the ASTC prepass running for `desktop` and not for diff --git a/src/agnostic_guard_test.zig b/src/agnostic_guard_test.zig index 80685f6f..4173805b 100644 --- a/src/agnostic_guard_test.zig +++ b/src/agnostic_guard_test.zig @@ -119,7 +119,12 @@ const allowed_words = [_][]const u8{ "macos", "windows", "linux", "darwin", "win /// `cli/args.zig`, `cli/pipeline/args_resolve.zig`, `cli/pipeline/run.zig` /// and `cli/provider_dispatch.zig` came clean; merged onto D4/S4, /// `cli/config.zig` and `cli/pipeline/install.zig` came clean too: -/// 6 entries. +/// 6 entries. RFC cli#471 P3 deleted the CLI's platform and +/// asset-compression mirrors (a target is a name; the assembler's `describe` +/// says whether it can generate it and whether it ships ASTC), so +/// `cli/project_config.zig` came clean, and `cli.zig`, `cli/pipeline.zig` +/// and `cli/pipeline/build.zig` once their comments and fixtures stopped +/// citing platform names: 2 entries. /// Shrink only: an entry whose file is clean fails the test until it is /// removed. Note the path scan: an entry /// under `cli/android/` or named `cli/ios.zig` stays dirty until the file is @@ -128,13 +133,7 @@ const allowed_files = [_][]const u8{ // This file: it spells the forbidden table out. "agnostic_guard_test.zig", // Legacy platform, store, package and backend sites (RFC #406 "Migration"). - "cli.zig", "cli/doctor.zig", - "cli/pipeline.zig", - // Split out of `cli/pipeline.zig` (moves only): the stage code they - // carry names the legacy targets, backends and their toolchains. - "cli/pipeline/build.zig", - "cli/project_config.zig", }; const finding_note = "(platform/store/package names belong in providers; see docs/rfc-package-commands.md#enforcement)"; @@ -516,16 +515,16 @@ test "host OS names never flag" { } test "the allowlist matches Windows-style walker paths, one file per entry" { - try std.testing.expect(allowedIndex("cli/pipeline.zig") != null); - try std.testing.expect(allowedIndex("cli\\pipeline.zig") != null); - try std.testing.expect(allowedIndex("cli\\pipeline\\build.zig") != null); try std.testing.expect(allowedIndex("cli/doctor.zig") != null); - // A new file under a legacy directory is NOT exempt. - try std.testing.expect(allowedIndex("cli/pipeline/not_yet_written.zig") == null); - try std.testing.expect(allowedIndex("cli/pipeline/") == null); - try std.testing.expect(allowedIndex("cli/pipelinex/run.zig") == null); - // A removed file's entry left with it. + try std.testing.expect(allowedIndex("cli\\doctor.zig") != null); + // A new file beside or under an entry is NOT exempt. + try std.testing.expect(allowedIndex("cli/doctor/not_yet_written.zig") == null); + try std.testing.expect(allowedIndex("cli/doctor.zig/") == null); + try std.testing.expect(allowedIndex("cli/doctorx.zig") == null); + // A removed or cleaned file's entry left with it. try std.testing.expect(allowedIndex("cli/serve/http.zig") == null); + try std.testing.expect(allowedIndex("cli\\pipeline.zig") == null); + try std.testing.expect(allowedIndex("cli/project_config.zig") == null); try std.testing.expect(allowedIndex("cli/ios.zig") == null); try std.testing.expect(allowedIndex("cli/sdl_provision.zig") == null); // An extracted platform's files left the allowlist with the platform. @@ -558,15 +557,21 @@ test "a finding is reported per line and a clean allowlisted file goes stale" { try std.testing.expectEqualStrings("src/cli/provider_manifest.zig:1: 'ios' " ++ finding_note, scan.offenders.items[2]); try std.testing.expectEqualStrings("src/cli/provider_manifest.zig:2: 'web' " ++ finding_note, scan.offenders.items[3]); // Allowlisted and dirty: no finding, entry kept. Clean name and body: stale. - try scan.file("cli/pipeline.zig", "// wasm\n"); - try scan.file("cli/project_config.zig", "const x = 1;\n"); + try scan.file("cli/doctor.zig", "// wasm\n"); + try scan.file("agnostic_guard_test.zig", "const x = 1;\n"); try std.testing.expectEqual(@as(usize, 4), scan.offenders.items.len); var stale: std.ArrayList([]const u8) = .empty; defer stale.deinit(gpa); try scan.stale(&stale); - try std.testing.expect(!containsString(stale.items, "cli/pipeline.zig")); - try std.testing.expect(containsString(stale.items, "cli/project_config.zig")); - try std.testing.expect(containsString(stale.items, "cli/doctor.zig")); + try std.testing.expect(!containsString(stale.items, "cli/doctor.zig")); + try std.testing.expect(containsString(stale.items, "agnostic_guard_test.zig")); + // An entry no scanned file exercised is stale too. + var unscanned: Scan = .{ .gpa = gpa }; + defer unscanned.deinit(); + var unexercised: std.ArrayList([]const u8) = .empty; + defer unexercised.deinit(gpa); + try unscanned.stale(&unexercised); + try std.testing.expect(containsString(unexercised.items, "cli/doctor.zig")); // The sentinel is only set by the CLI root itself. try std.testing.expect(!scan.saw_cli_root); try scan.file(cli_root, "// android\n"); diff --git a/src/astc/cmd.zig b/src/astc/cmd.zig index 20285516..034f74d1 100644 --- a/src/astc/cmd.zig +++ b/src/astc/cmd.zig @@ -177,7 +177,7 @@ pub fn cmdAstc(gpa: std.mem.Allocator, cmd_args: []const []const u8) !void { return error.InvalidArgs; }; - const platform = platform_override orelse @tagName(cfg.platform); + const platform = platform_override orelse cfg.declared_target; const package = cfg.backend_package; // The `.backend` shorthand names no package to read, so ask the // assembler where the project's backend package is (cli#471 D3a): diff --git a/src/cli.zig b/src/cli.zig index cc8e3198..9e05a410 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -329,8 +329,8 @@ pub fn main(proc_init: std.process.Init) !u8 { // ../game` from a broken project reports `provider command // failed` instead of running that directory (#460 review). A // namespace-shaped token still dispatches before the directory - // shorthand, so `labelle web …` keeps meaning the provider in - // a project that also has a `web/` folder. + // shorthand, so `labelle probe …` keeps meaning the provider in + // a project that also has a `probe/` folder. if (mayNameProvider(first)) { if (provider_dispatch.dispatch(allocator, first, &args) catch |err| { std.debug.print("labelle: provider command failed: {s}\n", .{@errorName(err)}); @@ -437,7 +437,7 @@ fn mayNameProvider(token: []const u8) bool { } test "mayNameProvider: only a namespace-shaped token reaches provider dispatch" { - for ([_][]const u8{ "android", "web", "probe", "my-provider", "ns_2" }) |token| { + for ([_][]const u8{ "acme", "tools", "probe", "my-provider", "ns_2" }) |token| { try std.testing.expect(mayNameProvider(token)); } for ([_][]const u8{ "../game", "./game", "..", ".", "/abs/game", "game/", "C:\\game", "Game", "" }) |token| { diff --git a/src/cli/config.zig b/src/cli/config.zig index e85ca848..220726a9 100644 --- a/src/cli/config.zig +++ b/src/cli/config.zig @@ -95,12 +95,19 @@ fn readProjectConfigImpl(allocator: std.mem.Allocator, project_dir: []const u8, // mirror — without this, a newer project.labelle would fail to parse // and break the CLI for no good reason. try @import("provider_settings.zig").validateProject(allocator, source, if (verbose) labelle_path else null); - const cfg = std.zon.parse.fromSliceAlloc(project_config.ProjectConfig, allocator, source, null, .{ + var cfg = std.zon.parse.fromSliceAlloc(project_config.ProjectConfig, allocator, source, null, .{ .ignore_unknown_fields = true, }) catch |err| { if (verbose) std.debug.print("labelle: could not parse '{s}': {any}\n", .{ labelle_path, err }); return error.ParseError; }; + // `.platform` is a target NAME to the CLI (RFC cli#471 P3), which the + // typed parse above cannot read (an enum literal is not a string), so + // it is read on its own; the tolerant parse skipped it. + if (project_config.declaredTarget(allocator, source) catch |err| { + if (verbose) std.debug.print("labelle: could not parse '{s}': `.platform` must name a target, e.g. `.platform = .desktop` ({s})\n", .{ labelle_path, @errorName(err) }); + return error.ParseError; + }) |name| cfg.declared_target = name; // `labelle install plugin local:` overrides (assembler#772) // live outside the project; fold them in once, here, so every consumer // sees the plugin where the assembler builds it from. diff --git a/src/cli/pipeline.zig b/src/cli/pipeline.zig index a97290e9..cefa9a11 100644 --- a/src/cli/pipeline.zig +++ b/src/cli/pipeline.zig @@ -122,12 +122,11 @@ pub fn run(allocator: std.mem.Allocator, parsed_args: ParsedArgs) !u8 { parsed.normalizeInitialPrefab(); // The requested target (RFC #406 phase 3b, docs/provider-targets.md): - // `--platform=` — the legacy platform subcommands set the same - // override — else the project's declared platform. It is resolved below - // against the core target and the pinned providers' declarations; - // `parsed.platform` is derived from the RESULT only where the pinned - // assembler and the legacy sites still need the schema enum. - const requested_target: []const u8 = parsed_args.platform_override orelse @tagName(parsed.platform); + // `--platform=`, else the target the project declares (`.platform`). + // It is a name, resolved below against the core target and the pinned + // providers' declarations; the assembler says whether it can generate + // it (cli#471 P3). + const requested_target: []const u8 = parsed_args.platform_override orelse parsed.declared_target; // Upgrade modifies project.labelle in the project directory if (command == .upgrade_cmd) { @@ -140,7 +139,7 @@ pub fn run(allocator: std.mem.Allocator, parsed_args: ParsedArgs) !u8 { // `--docker` builds the core target only (RFC cli#466 D5): refused for // a provider target before anything is read, written or built. if (args_resolve.dockerRefused(parsed_args.docker, requested_target)) return 1; - const resolved = switch (try args_resolve.resolve(allocator, hook_arena, project_dir, command, &parsed, requested_target)) { + const resolved = switch (try args_resolve.resolve(allocator, hook_arena, project_dir, command, parsed, requested_target)) { .proceed => |proceed| proceed, .exit => |code| return code, }; @@ -461,9 +460,8 @@ pub fn run(allocator: std.mem.Allocator, parsed_args: ParsedArgs) !u8 { if (command == .bundle_cmd) return build.bundleStep(&cx); if (command == .build) { - // (The build's finalization — the Linux `.desktop` entry and the - // APK packaging — ran inside the core build above, ahead of the - // `after build` hooks.) + // (The build's finalization — the Linux `.desktop` entry — ran + // inside the core build above, ahead of the `after build` hooks.) if (reporter) |r| r.finishDone(0); return 0; } diff --git a/src/cli/pipeline/args_resolve.zig b/src/cli/pipeline/args_resolve.zig index 9bfa866d..529db6fa 100644 --- a/src/cli/pipeline/args_resolve.zig +++ b/src/cli/pipeline/args_resolve.zig @@ -31,8 +31,8 @@ pub const Resolution = union(enum) { /// Ownership needs the providers, and provider discovery runs only /// after the assembler's `install` populated the package cache (below, /// next to `gateThenInstall`; Codex P1 on #420) — while the target -/// directory, the progress feed and the schema platform every -/// pre-install step keys off need the name now. So the name is settled +/// directory and the progress feed every pre-install step keys off need +/// the name now. So the name is settled /// here from the string alone: `desktop` is core; any other name is /// PROVISIONALLY a provider target, confirmed against the discovered /// providers right after the install and refused there when nobody @@ -42,7 +42,7 @@ pub fn resolve( hook_arena: std.mem.Allocator, project_dir: []const u8, command: args_mod.Command, - parsed: *project_config.ProjectConfig, + parsed: project_config.ProjectConfig, requested_target: []const u8, ) !Resolution { const project_root = try std.Io.Dir.cwd().realPathFileAlloc(config.globalIo(), project_dir, hook_arena); @@ -83,19 +83,11 @@ pub fn resolve( // so the pinned archives it reads are not held again beside the // authoritative discovery's copies (Codex P2 on #421). if (!provisional.is_core) { - switch (earlyTargetCheck(allocator, project_root, parsed.*, requested_target) catch return .{ .exit = 1 }) { + switch (earlyTargetCheck(allocator, project_root, parsed, requested_target) catch return .{ .exit = 1 }) { .confirmed, .deferred => {}, .refused => return .{ .exit = 1 }, } } - // The legacy sites below (`parsed.platform == .X`; the guard's migration - // allowlist) keep working for the schema-named provider targets. A - // target outside the enum reaches only steps its provider does not - // replace, which treat it as the generic host baseline. `parsed.platform` - // is derived from the NAME only where the pinned assembler and the - // legacy sites still need the schema enum. - parsed.platform = provisional.legacy orelse .desktop; - return .{ .proceed = .{ .project_root = project_root, .provisional = provisional } }; } diff --git a/src/cli/pipeline/build.zig b/src/cli/pipeline/build.zig index 21854036..bfdf0d1d 100644 --- a/src/cli/pipeline/build.zig +++ b/src/cli/pipeline/build.zig @@ -131,7 +131,7 @@ pub fn run( /// `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, as `wasm export` does) and only mark `done` once +/// (a `run` phase) and only mark `done` once /// the `.app` is on disk — a `--progress=json` consumer must not see /// `done` before the artifact exists. pub fn bundleStep(cx: *const Context) !u8 { diff --git a/src/cli/pipeline/generate.zig b/src/cli/pipeline/generate.zig index 50329997..a3a810d1 100644 --- a/src/cli/pipeline/generate.zig +++ b/src/cli/pipeline/generate.zig @@ -6,6 +6,7 @@ const std = @import("std"); const config = @import("../config.zig"); const runner = @import("../runner.zig"); const assembler_proc = @import("../assembler_proc.zig"); +const assembler_describe = @import("../assembler_describe.zig"); const material_toolchain = @import("../material_toolchain.zig"); const bake_mod = @import("../bake.zig"); const progress = @import("../progress.zig"); @@ -77,13 +78,14 @@ pub fn run(cx: *const Context, generate_out: []const u8) !?u8 { } try corePrepasses(allocator, project_dir, parsed, .{ - .legacy_target = target.legacy != null, + .target = target.name, + .describer = .init(cx.asm_bin, project_dir), .bake = parsed_args.bake, .fatal = true, }); - // The assembler receives the resolved target NAME; the #378 gate above - // guarantees it is one the pinned assembler can take. + // 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); // (`target_name`/`target_dir` — .labelle/_/, named by `describe` — are @@ -126,8 +128,12 @@ pub fn run(cx: *const Context, generate_out: []const u8) !?u8 { /// How the core generation's pre-passes run (`corePrepasses`). pub const Prepass = struct { - /// The target is one the `asset_compression` schema knows. - legacy_target: bool, + /// The resolved target name: the ASTC conversion runs for it. + target: []const u8, + /// Asks the assembler whether the target ships ASTC atlases + /// (`describe`'s `asset_format`, from `.asset_compression` keyed by the + /// target name). `off`, or no answer, means PNG: no conversion. + describer: assembler_describe.Describer, /// `--bake`. bake: bool, /// A misconfiguration ends the command (the cold pipeline) rather than @@ -142,7 +148,7 @@ pub const Prepass = struct { /// source PNG reaches a rebuild's `.astc`/`.rgba` siblings exactly as it /// reaches a cold build's. pub fn corePrepasses(allocator: std.mem.Allocator, project_dir: []const u8, parsed: @import("../project_config.zig").ProjectConfig, opts: Prepass) anyerror!void { - // ASTC build-time conversion (#340): when this platform ships ASTC atlases + // ASTC build-time conversion (#340): when the target ships ASTC atlases // (`asset_compression`), run `labelle astc` first so the `.astc` // siblings exist for the assembler's catalog `.png → .astc` swap. Runs // before the assembler's `generate` (it only needs project.labelle + the @@ -156,21 +162,22 @@ pub fn corePrepasses(allocator: std.mem.Allocator, project_dir: []const u8, pars // like it worked. So a config error stops the build, while a conversion // failure still degrades to PNG. // - // Only for a target the asset_compression schema knows (`target.legacy`: - // `desktop` or a schema-named provider target). A provider target - // outside the enum has `parsed.platform` derived as `.desktop` for the - // legacy sites, but it is NOT the desktop target: running the desktop - // prepass for it would use the wrong compression setting (Codex on - // #421). Its provider owns its asset pipeline; standalone `cmdAstc` - // can read capabilities for any declared target name. - if (opts.legacy_target and parsed.asset_compression.formatFor(parsed.platform) == .astc) { + // Whether the target ships ASTC is the assembler's answer (cli#471 P3): + // `describe` reads `.asset_compression` by the target NAME, so a provider + // target never borrows another target's setting (Codex on #421), and the + // CLI keeps no mirror of the schema. Asked on every generation, so a + // watched rebuild follows an edited `.asset_compression`. + var scratch = std.heap.ArenaAllocator.init(allocator); + defer scratch.deinit(); + const described = opts.describer.query(scratch.allocator(), opts.target); + if (described != null and std.mem.eql(u8, described.?.asset_format, "astc")) { // Pass the RESOLVED target: `--platform=` may differ from what // project.labelle declares, and the loadable blocks depend on it. // No backend: `labelle astc` reads the project's own (cli#471 D4). astc_cmd.cmdAstc(allocator, &.{ project_dir, "--platform", - @tagName(parsed.platform), + opts.target, }) catch |err| switch (err) { error.InvalidTextureCapabilities => { if (!opts.fatal) return err; diff --git a/src/cli/pipeline/install.zig b/src/cli/pipeline/install.zig index bbf54263..02c9b2d3 100644 --- a/src/cli/pipeline/install.zig +++ b/src/cli/pipeline/install.zig @@ -195,35 +195,26 @@ pub fn discoverAndPlan( .bundle = try provider_hooks.plan(hook_arena, providers, .bundle, target.name), .run = try provider_hooks.plan(hook_arena, providers, .run, target.name), }; - // The labelle-assembler#378 boundary: the assembler generates only for - // the schema platforms, so a provider target outside that enum can be - // generated for only by its provider's `replace` hook on `generate`. - // Without one, stop HERE — before the lock, the assembler's `generate` - // and any compiler — rather than hand the assembler a name it cannot - // take. - // - // A protocol-7 assembler also judges the backend × target pair itself - // (`describe`'s `supported`, cli#471 D3): asked HERE, after the install, - // so an installed backend is judged from its own manifest — the check - // `generate` would fail on, reported before the lock and any compiler. - // Only asked when the core generation will run at all. - const described: ?assembler_describe.Description = if (target.legacy != null and hook_plans.generate.replace == null) + // Whether the assembler can generate the target at all is ITS answer + // (`describe`'s `supported`, cli#471 D3/P3): a name its codegen does not + // know, or a backend × target pair the backend does not support, comes + // back unsupported with a reason. Asked HERE, after the install, so an + // installed backend is judged from its own manifest — before the lock, + // the assembler's `generate` and any compiler. Only asked when the core + // generation will run at all: a `replace` hook on `generate` stands in + // for it, whatever the assembler knows. + const described: ?assembler_describe.Description = if (hook_plans.generate.replace == null) describer.require(hook_arena, target.name) catch { if (reporter) |r| r.finishFailed(1, "assembler describe failed"); return .{ .exit = 1 }; } else null; - switch (coreGenerateGate(target.legacy != null, hook_plans.generate.replace != null, described)) { + switch (coreGenerateGate(hook_plans.generate.replace != null, described)) { .proceed => {}, - .no_schema_target => { - std.debug.print("labelle: target '{s}' is declared by '{s}' but the pinned assembler cannot generate for it yet (labelle-assembler#378)\n", .{ target.name, target.providerName() }); - if (reporter) |r| r.finishFailed(1, "the pinned assembler cannot generate for this target"); - return .{ .exit = 1 }; - }, .unsupported => |reason| { - std.debug.print("labelle: backend '{s}' cannot build target '{s}': {s}\n", .{ described.?.backend.name, target.name, reason }); - if (reporter) |r| r.finishFailed(1, "the backend does not support this target"); + reportUnsupported(target, described.?.backend.name, reason); + if (reporter) |r| r.finishFailed(1, "the assembler cannot generate this target"); return .{ .exit = 1 }; }, } @@ -282,43 +273,46 @@ pub fn discoverAndPlan( /// The verdict on handing the target to the assembler's core `generate`. pub const GenerateGate = union(enum) { proceed, - /// labelle-assembler#378: a target outside the schema enum. - no_schema_target, - /// `describe` said the resolved backend does not support the target; - /// carries its reason. + /// `describe` said the assembler cannot generate the target with the + /// resolved backend (an unknown target name included); carries its + /// reason. unsupported: []const u8, }; -/// Pure. `schema_target`: the target is one the pinned assembler's schema -/// knows (`target.legacy != null`); `replaced`: a `replace` hook on -/// `generate` stands in for the core generation; `described`: the -/// assembler's `describe` answer, null below protocol 7 or when it could -/// not answer (then the enum check alone decides, as before cli#471 D3). -/// `described` is null only when it was not asked: the generation is -/// replaced, or the target is outside the assembler's schema. -pub fn coreGenerateGate(schema_target: bool, replaced: bool, described: ?assembler_describe.Description) GenerateGate { +/// Pure. `replaced`: a `replace` hook on `generate` stands in for the core +/// generation; `described`: the assembler's `describe` answer, null only +/// when it was not asked (the generation is replaced). +pub fn coreGenerateGate(replaced: bool, described: ?assembler_describe.Description) GenerateGate { if (replaced) return .proceed; - if (!schema_target) return .no_schema_target; if (described) |d| if (!d.supported) return .{ .unsupported = d.reason orelse "unsupported (no reason given)" }; return .proceed; } +/// The refusal of a target the assembler cannot generate: its reason, and +/// for a provider target, what the provider could do about it. The CLI +/// keeps no list of targets, so an unknown name reads the same way — the +/// assembler's reason names what it does generate for. +pub fn reportUnsupported(target: provider_targets.Resolved, backend: []const u8, reason: []const u8) void { + std.debug.print("labelle: backend '{s}' cannot build target '{s}': {s}\n", .{ backend, target.name, reason }); + if (target.provider) |provider| + std.debug.print(" package '{s}' declares target '{s}' but no `.when = .replace` hook on `generate`, so the assembler must generate it\n", .{ provider.meta.name, target.name }); +} + test "coreGenerateGate: describe refuses an unsupported pair" { var d = std.mem.zeroInit(assembler_describe.Description, .{ .supported = true }); // A replacement generates: nothing else is asked. - try std.testing.expect(coreGenerateGate(false, true, null) == .proceed); + try std.testing.expect(coreGenerateGate(true, null) == .proceed); d.supported = false; - try std.testing.expect(coreGenerateGate(true, true, d) == .proceed); - // labelle-assembler#378, with or without an answer. - try std.testing.expect(coreGenerateGate(false, false, null) == .no_schema_target); - // Supported: proceed. Unsupported: refused with describe's reason. + try std.testing.expect(coreGenerateGate(true, d) == .proceed); + // Supported: proceed. Unsupported — an unknown target name included — + // refused with describe's reason. d.supported = true; - try std.testing.expect(coreGenerateGate(true, false, d) == .proceed); + try std.testing.expect(coreGenerateGate(false, d) == .proceed); d.supported = false; - d.reason = "provider 'acme.gfx' does not support capability 'probe'"; - try std.testing.expectEqualStrings(d.reason.?, coreGenerateGate(true, false, d).unsupported); + d.reason = "backend 'acme' has no target 'probe-target': this assembler generates for desktop"; + try std.testing.expectEqualStrings(d.reason.?, coreGenerateGate(false, d).unsupported); d.reason = null; - try std.testing.expect(coreGenerateGate(true, false, d) == .unsupported); + try std.testing.expect(coreGenerateGate(false, d) == .unsupported); } /// A build path that cannot carry what a provider supplies for the target. diff --git a/src/cli/pipeline/rebuild_commit_tests.zig b/src/cli/pipeline/rebuild_commit_tests.zig index fe68226d..a0b1f9c4 100644 --- a/src/cli/pipeline/rebuild_commit_tests.zig +++ b/src/cli/pipeline/rebuild_commit_tests.zig @@ -112,7 +112,7 @@ test "rebuild commit: labelle.lock is committed before the generation advances, try fx.startup(); var site = fx.site(); defer site.env.deinit(); - var replan = Replanner{ .backing = a, .project_dir = fx.project }; + var replan = Replanner{ .backing = a, .project_dir = fx.project, .describer = testing.fakeDescriber(fx.project) }; defer replan.deinit(&site, fx.providers, fx.cfg); replan.baseline(); var dummy: u8 = 0; @@ -180,7 +180,7 @@ test "rebuild commit: a staged lock that cannot be committed fails the rebuild, try fx.startup(); var site = fx.site(); defer site.env.deinit(); - var replan = Replanner{ .backing = a, .project_dir = fx.project }; + var replan = Replanner{ .backing = a, .project_dir = fx.project, .describer = testing.fakeDescriber(fx.project) }; defer replan.deinit(&site, fx.providers, fx.cfg); replan.baseline(); var dummy: u8 = 0; @@ -237,7 +237,7 @@ test "rebuild commit: a cancel during the publication is honoured before the swi try fx.startup(); var site = fx.site(); defer site.env.deinit(); - var replan = Replanner{ .backing = a, .project_dir = fx.project }; + var replan = Replanner{ .backing = a, .project_dir = fx.project, .describer = testing.fakeDescriber(fx.project) }; defer replan.deinit(&site, fx.providers, fx.cfg); replan.baseline(); var dummy: u8 = 0; @@ -355,7 +355,7 @@ test "rebuild commit: the local providers outside the project are watched with i try fx.startup(); var site = fx.site(); defer site.env.deinit(); - var replan = Replanner{ .backing = a, .project_dir = fx.project }; + var replan = Replanner{ .backing = a, .project_dir = fx.project, .describer = testing.fakeDescriber(fx.project) }; defer replan.deinit(&site, fx.providers, fx.cfg); var dummy: u8 = 0; var ctx = tx.rebuildCtx(&fx, &site, &replan, &dummy); @@ -503,7 +503,7 @@ test "rebuild commit: a rollback restores an absent labelle.lock as absent (cli# try fx.tmp.dir.deleteFile(io, "project/labelle.lock"); var site = fx.site(); defer site.env.deinit(); - var replan = Replanner{ .backing = a, .project_dir = fx.project }; + var replan = Replanner{ .backing = a, .project_dir = fx.project, .describer = testing.fakeDescriber(fx.project) }; defer replan.deinit(&site, fx.providers, fx.cfg); replan.baseline(); var dummy: u8 = 0; @@ -604,7 +604,7 @@ test "rebuild commit: a newly declared provider whose manifest fails the replan try fx.startup(); var site = fx.site(); defer site.env.deinit(); - var replan = Replanner{ .backing = a, .project_dir = fx.project }; + var replan = Replanner{ .backing = a, .project_dir = fx.project, .describer = testing.fakeDescriber(fx.project) }; defer replan.deinit(&site, fx.providers, fx.cfg); replan.baseline(); var dummy: u8 = 0; @@ -652,7 +652,7 @@ test "rebuild commit: a rollback leaves a labelle.lock another command rewrote m try fx.startup(); var site = fx.site(); defer site.env.deinit(); - var replan = Replanner{ .backing = a, .project_dir = fx.project }; + var replan = Replanner{ .backing = a, .project_dir = fx.project, .describer = testing.fakeDescriber(fx.project) }; defer replan.deinit(&site, fx.providers, fx.cfg); replan.baseline(); var dummy: u8 = 0; @@ -717,7 +717,7 @@ test "rebuild commit: a local provider whose manifest is deleted stays watched, try fx.startup(); var site = fx.site(); defer site.env.deinit(); - var replan = Replanner{ .backing = a, .project_dir = fx.project }; + var replan = Replanner{ .backing = a, .project_dir = fx.project, .describer = testing.fakeDescriber(fx.project) }; defer replan.deinit(&site, fx.providers, fx.cfg); replan.baseline(); var dummy: u8 = 0; diff --git a/src/cli/pipeline/rebuild_lock_tests.zig b/src/cli/pipeline/rebuild_lock_tests.zig index 65f2eeea..797d2c3c 100644 --- a/src/cli/pipeline/rebuild_lock_tests.zig +++ b/src/cli/pipeline/rebuild_lock_tests.zig @@ -15,6 +15,7 @@ const provider_hooks = @import("../provider_hooks.zig"); const lockfile = @import("../lockfile.zig"); const project_lock = @import("../project_lock.zig"); const Replanner = @import("rebuild_replan.zig").Replanner; +const testing = @import("testing.zig"); const tx = @import("rebuild_transaction_tests.zig"); const commit_tests = @import("rebuild_commit_tests.zig"); const Fixture = tx.Fixture; @@ -33,8 +34,8 @@ const Sessions = struct { try s.fx.write(.{}); try s.fx.startup(); s.site = s.fx.site(); - s.one = .{ .backing = a, .project_dir = s.fx.project }; - s.two = .{ .backing = a, .project_dir = s.fx.project }; + s.one = .{ .backing = a, .project_dir = s.fx.project, .describer = testing.fakeDescriber(s.fx.project) }; + s.two = .{ .backing = a, .project_dir = s.fx.project, .describer = testing.fakeDescriber(s.fx.project) }; } fn deinit(s: *Sessions) void { diff --git a/src/cli/pipeline/rebuild_replan.zig b/src/cli/pipeline/rebuild_replan.zig index fb46082d..ade0e875 100644 --- a/src/cli/pipeline/rebuild_replan.zig +++ b/src/cli/pipeline/rebuild_replan.zig @@ -16,6 +16,7 @@ const SessionKey = @import("session_key.zig").SessionKey; const confirmTarget = @import("args_resolve.zig").confirmTarget; const AssemblerInstaller = @import("install.zig").AssemblerInstaller; const coreGenerateGate = @import("install.zig").coreGenerateGate; +const reportUnsupported = @import("install.zig").reportUnsupported; const assembler_describe = @import("../assembler_describe.zig"); const provider_contract = @import("../provider_contract.zig"); const optimize_mod = @import("optimize.zig"); @@ -100,7 +101,8 @@ pub const Replanner = struct { /// (`describe`, cli#471 D3), after this rebuild's install: a backend /// bump that drops the pair fails the rebuild with describe's reason, /// as the cold pipeline's gate does (`install.coreGenerateGate`). - /// `off` (the plumbing tests, a pre-7 assembler) keeps the enum check. + /// `off` (the plumbing tests, a pre-7 assembler) cannot answer, so the + /// rebuild fails (`Describer.require`). describer: assembler_describe.Describer = .off, pub const LockBefore = union(enum) { absent, bytes: []u8 }; @@ -423,11 +425,8 @@ pub const Replanner = struct { self.pruneExtractions(); }; const a = next.arena.allocator(); - var cfg = try next.read(self.project_dir); + const cfg = try next.read(self.project_dir); const digest = next.digest; - // The target's platform is the pipeline's resolved one (a command - // may override what the file declares). - cfg.platform = ctx.hooks.cfg.platform; // An edited `project.labelle` may declare a package the startup // install never fetched and the startup lock never pinned: install // first, as the cold pipeline does ahead of discovery (Codex P2 on @@ -464,7 +463,7 @@ pub const Replanner = struct { // The core generation's gate, as the cold pipeline applies it // (`install.discoverAndPlan`): before the lock and any hook, against // the project and packages as they now are. - try generateGate(self.describer, a, ctx.hooks.target, resolved, generate_plan.replace != null); + try generateGate(self.describer, a, resolved, generate_plan.replace != null); // The lock follows the re-read project once the target is confirmed // and the plans are good — the cold pipeline's order — and before // any hook runs: STAGED, so this rebuild's hooks verify their pins @@ -498,19 +497,17 @@ pub const Replanner = struct { self.pruneExtractions(); } - /// Fail the rebuild when `describe` says the backend no longer supports - /// `target` and the core generation would run for it. A target outside - /// the pinned assembler's schema is left alone here: the cold pipeline - /// already refused it (labelle-assembler#378) unless a `replace` hook - /// generated it, and a replan never judged that before. Public for the + /// Fail the rebuild when `describe` says the assembler cannot generate + /// `resolved` (a backend that no longer supports it, or a name it does + /// not know) and the core generation would run for it. Public for the /// tests. - pub fn generateGate(describer: assembler_describe.Describer, a: std.mem.Allocator, target: []const u8, resolved: provider_targets.Resolved, replaced: bool) !void { - if (resolved.legacy == null or replaced) return; - const described = try describer.require(a, target); - switch (coreGenerateGate(true, false, described)) { - .proceed, .no_schema_target => {}, + pub fn generateGate(describer: assembler_describe.Describer, a: std.mem.Allocator, resolved: provider_targets.Resolved, replaced: bool) !void { + if (replaced) return; + const described = try describer.require(a, resolved.name); + switch (coreGenerateGate(false, described)) { + .proceed => {}, .unsupported => |reason| { - std.debug.print("labelle: backend '{s}' cannot build target '{s}': {s}\n", .{ described.backend.name, target, reason }); + reportUnsupported(resolved, described.backend.name, reason); return error.BackendUnsupportedTarget; }, } @@ -560,7 +557,7 @@ pub const Replanner = struct { // stale: read afresh. if (!self.installed) self.dropStaged(); const next = try self.stage(); - var cfg = next.read(self.project_dir) catch |err| { + const cfg = next.read(self.project_dir) catch |err| { self.dropStaged(); return err; }; @@ -573,7 +570,6 @@ pub const Replanner = struct { var scratch = std.heap.ArenaAllocator.init(self.backing); defer scratch.deinit(); const a = scratch.allocator(); - cfg.platform = ctx.hooks.cfg.platform; // Lookup only: a pinned remote is read from the extraction an // earlier generation (or the cold pipeline) verified and unpacked, // never from its archive (cli#429). diff --git a/src/cli/pipeline/rebuild_replan_tests.zig b/src/cli/pipeline/rebuild_replan_tests.zig index 1384dc6a..f67cdd4a 100644 --- a/src/cli/pipeline/rebuild_replan_tests.zig +++ b/src/cli/pipeline/rebuild_replan_tests.zig @@ -74,7 +74,7 @@ test "watch replan re-reads the project and provider manifests on every call" { .hooks = &site, }; defer ctx.deinit(); - var replan = Replanner{ .backing = a, .project_dir = project }; + var replan = Replanner{ .backing = a, .project_dir = project, .describer = testing.fakeDescriber(project) }; const startup_cfg = site.cfg; defer replan.deinit(&site, &.{}, startup_cfg); @@ -206,7 +206,7 @@ test "watched rebuild refuses a target whose owner disappeared before the prebui const asm_path = try testing.okTool(a, tmp.dir); defer a.free(asm_path); var site = testing.testSite(a, project); - var replan = Replanner{ .backing = a, .project_dir = project, .write_lock = TwoStage.Spy.lock }; + var replan = Replanner{ .backing = a, .project_dir = project, .write_lock = TwoStage.Spy.lock, .describer = testing.fakeDescriber(project) }; const startup_cfg = site.cfg; defer replan.deinit(&site, &.{}, startup_cfg); var ctx = RebuildCtx{ @@ -319,7 +319,7 @@ test "watched rebuild lets a prebuild refresh an existing provider manifest" { const asm_path = try testing.okTool(a, tmp.dir); defer a.free(asm_path); var site = testing.testSite(a, project); - var replan = Replanner{ .backing = a, .project_dir = project, .write_lock = TwoStage.Spy.lock }; + var replan = Replanner{ .backing = a, .project_dir = project, .write_lock = TwoStage.Spy.lock, .describer = testing.fakeDescriber(project) }; const startup_cfg = site.cfg; defer replan.deinit(&site, &.{}, startup_cfg); var ctx = RebuildCtx{ @@ -382,7 +382,7 @@ test "watched rebuild replans after a prebuild step generates provider metadata" const asm_path = try testing.okTool(a, tmp.dir); defer a.free(asm_path); var site = testing.testSite(a, project); - var replan = Replanner{ .backing = a, .project_dir = project, .write_lock = TwoStage.Spy.lock }; + var replan = Replanner{ .backing = a, .project_dir = project, .write_lock = TwoStage.Spy.lock, .describer = testing.fakeDescriber(project) }; const startup_cfg = site.cfg; defer replan.deinit(&site, &.{}, startup_cfg); var ctx = RebuildCtx{ @@ -503,7 +503,7 @@ test "watch replan extracts a pinned provider once per changed pin" { .hooks = &site, }; defer ctx.deinit(); - var replan = Replanner{ .backing = a, .project_dir = project, .write_lock = TwoStage.Spy.lock }; + var replan = Replanner{ .backing = a, .project_dir = project, .write_lock = TwoStage.Spy.lock, .describer = testing.fakeDescriber(project) }; const startup_cfg = site.cfg; defer replan.deinit(&site, &.{}, startup_cfg); replan.seed(&startup_sources); @@ -618,6 +618,7 @@ test "watch replan installs and relocks when project.labelle changes" { .project_dir = project, .installer = .{ .ctx = &Spy.count, .run = Spy.install }, .write_lock = Spy.lock, + .describer = testing.fakeDescriber(project), }; const startup_cfg = site.cfg; defer replan.deinit(&site, &.{}, startup_cfg); @@ -648,7 +649,7 @@ test "watch replan installs and relocks when project.labelle changes" { // Without the install the same edit is the reported failure: the // cache never learns about the package. - var bare = Replanner{ .backing = a, .project_dir = project, .write_lock = Spy.lock }; + var bare = Replanner{ .backing = a, .project_dir = project, .write_lock = Spy.lock, .describer = testing.fakeDescriber(project) }; defer bare.deinit(&site, &.{}, startup_cfg); try std.Io.Dir.cwd().deleteTree(io, Spy.cache_dir); try std.testing.expectError(error.ProviderPackageMissing, Replanner.run(&bare, &ctx)); @@ -736,7 +737,7 @@ test "watched serve shutdown runs the replanned after-run hooks" { try lockfile.writeLockFileTo(la, dir, cfg, path); } }; - var replan = Replanner{ .backing = a, .project_dir = project, .write_lock = InPlace.lock }; + var replan = Replanner{ .backing = a, .project_dir = project, .write_lock = InPlace.lock, .describer = testing.fakeDescriber(project) }; defer replan.deinit(&site, startup_providers, startup_cfg); replan.baseline(); // No rebuild has replanned yet: the startup plan is the shutdown's. @@ -817,7 +818,7 @@ test "watch replan release restores the site's stable storage" { .hooks = &site, }; defer ctx.deinit(); - var replan = Replanner{ .backing = a, .project_dir = project, .write_lock = Lock.none }; + var replan = Replanner{ .backing = a, .project_dir = project, .write_lock = Lock.none, .describer = testing.fakeDescriber(project) }; try runCommitted(&replan, &ctx); // The site now reads the replan's generation, not the startup storage. try std.testing.expect(site.providers.ptr != &stable_providers); @@ -869,7 +870,7 @@ test "watch replan installs the re-read prebuild steps" { .hooks = &site, }; defer ctx.deinit(); - var replan = Replanner{ .backing = a, .project_dir = project, .write_lock = Lock.none }; + var replan = Replanner{ .backing = a, .project_dir = project, .write_lock = Lock.none, .describer = testing.fakeDescriber(project) }; const startup_cfg = site.cfg; defer replan.deinit(&site, &.{}, startup_cfg); @@ -944,7 +945,7 @@ test "watch replan recomputes the effective optimize mode from the owner's defau .hooks = &site, }; defer ctx.deinit(); - var replan = Replanner{ .backing = a, .project_dir = project }; + var replan = Replanner{ .backing = a, .project_dir = project, .describer = testing.fakeDescriber(project) }; const startup_cfg = site.cfg; defer replan.deinit(&site, &.{}, startup_cfg); @@ -986,8 +987,9 @@ test "watch replan: withOptimize replaces or drops the flag, keeping every other // cli#471 D3 (CodeRabbit on #504): a watched rebuild applies the cold // pipeline's support gate. A backend bump that makes `describe` refuse the // pair fails THAT rebuild (the session lives on), and so does no answer -// (D4: there is no enum to fall back to); a supported pair, a replaced -// generation or a target outside the schema pass. +// (D4: there is no enum to fall back to); a supported pair or a replaced +// generation pass. Since P3 a provider target is judged the same way: the +// CLI knows no schema of targets to exempt it by. test "watch replan: describe's unsupported verdict fails the rebuild before generation" { const Fake = struct { var calls: usize = 0; @@ -1006,23 +1008,28 @@ test "watch replan: describe's unsupported verdict fails the rebuild before gene defer arena.deinit(); const a = arena.allocator(); const describer: @import("../assembler_describe.zig").Describer = .{ .bin_path = "/asm", .protocol = 7, .project_dir = "/proj", .spawn = Fake.spawn }; - const core: @import("../provider_targets.zig").Resolved = .{ .name = "desktop", .provider = null, .legacy = .desktop }; + const core: @import("../provider_targets.zig").Resolved = .{ .name = "desktop", .provider = null }; Fake.reply = Fake.doc("false", ",\"reason\":\"provider 'acme.gfx' does not support capability 'probe'\""); - try std.testing.expectError(error.BackendUnsupportedTarget, Replanner.generateGate(describer, a, "desktop", core, false)); + try std.testing.expectError(error.BackendUnsupportedTarget, Replanner.generateGate(describer, a, core, false)); Fake.reply = Fake.doc("true", ""); - try Replanner.generateGate(describer, a, "desktop", core, false); + try Replanner.generateGate(describer, a, core, false); // No answer (describe failed, or no describe at all): the rebuild fails. Fake.reply = null; - try std.testing.expectError(error.DescribeFailed, Replanner.generateGate(describer, a, "desktop", core, false)); - try std.testing.expectError(error.DescribeFailed, Replanner.generateGate(.off, a, "desktop", core, false)); + try std.testing.expectError(error.DescribeFailed, Replanner.generateGate(describer, a, core, false)); + try std.testing.expectError(error.DescribeFailed, Replanner.generateGate(.off, a, core, false)); // Never asked when the core generation does not run for the target. Fake.reply = Fake.doc("false", ",\"reason\":\"x\""); Fake.calls = 0; - try Replanner.generateGate(describer, a, "desktop", core, true); - const foreign: @import("../provider_targets.zig").Resolved = .{ .name = "probe-target", .provider = null, .legacy = null }; - try Replanner.generateGate(describer, a, "probe-target", foreign, false); + try Replanner.generateGate(describer, a, core, true); try std.testing.expectEqual(@as(usize, 0), Fake.calls); + // A target the assembler does not know is refused with its reason. + Fake.reply = "{\"schema\":\"labelle.describe/v1\",\"target\":\"probe-target\",\"target_dir\":\".labelle/acme_probe-target\"," ++ + "\"backend\":{\"name\":\"acme\",\"id\":null,\"repo\":null,\"version\":\"2.0.0\",\"local_path\":null}," ++ + "\"asset_format\":\"png\",\"supported\":false,\"reason\":\"backend 'acme' has no target 'probe-target': this assembler generates for desktop\",\"capabilities_source\":\"manifest\"}"; + const foreign: @import("../provider_targets.zig").Resolved = .{ .name = "probe-target", .provider = null }; + try std.testing.expectError(error.BackendUnsupportedTarget, Replanner.generateGate(describer, a, foreign, false)); + try std.testing.expectEqual(@as(usize, 1), Fake.calls); } diff --git a/src/cli/pipeline/rebuild_transaction_tests.zig b/src/cli/pipeline/rebuild_transaction_tests.zig index 23de05a6..465c52b6 100644 --- a/src/cli/pipeline/rebuild_transaction_tests.zig +++ b/src/cli/pipeline/rebuild_transaction_tests.zig @@ -208,7 +208,7 @@ test "rebuild transaction: a failure after the replan restores every replanned p try fx.startup(); var site = fx.site(); defer site.env.deinit(); - var replan = Replanner{ .backing = a, .project_dir = fx.project }; + var replan = Replanner{ .backing = a, .project_dir = fx.project, .describer = testing.fakeDescriber(fx.project) }; defer replan.deinit(&site, fx.providers, fx.cfg); replan.baseline(); var dummy: u8 = 0; @@ -275,7 +275,7 @@ test "rebuild transaction: publication happens only after the after-build hooks, try fx.startup(); var site = fx.site(); defer site.env.deinit(); - var replan = Replanner{ .backing = a, .project_dir = fx.project }; + var replan = Replanner{ .backing = a, .project_dir = fx.project, .describer = testing.fakeDescriber(fx.project) }; defer replan.deinit(&site, fx.providers, fx.cfg); replan.baseline(); var dummy: u8 = 0; @@ -315,7 +315,7 @@ test "rebuild transaction: an edited prebuild step runs in the rebuild that sees try fx.startup(); var site = fx.site(); defer site.env.deinit(); - var replan = Replanner{ .backing = a, .project_dir = fx.project }; + var replan = Replanner{ .backing = a, .project_dir = fx.project, .describer = testing.fakeDescriber(fx.project) }; defer replan.deinit(&site, fx.providers, fx.cfg); replan.baseline(); var dummy: u8 = 0; @@ -453,7 +453,7 @@ test "rebuild transaction: cancelling reaps the in-flight child — provisioning try fx.startup(); var site = fx.site(); defer site.env.deinit(); - var replan = Replanner{ .backing = a, .project_dir = fx.project }; + var replan = Replanner{ .backing = a, .project_dir = fx.project, .describer = testing.fakeDescriber(fx.project) }; defer replan.deinit(&site, fx.providers, fx.cfg); replan.baseline(); var dummy: u8 = 0; @@ -496,7 +496,7 @@ test "rebuild transaction: an edited project's lock is staged privately and comm try fx.startup(); var site = fx.site(); defer site.env.deinit(); - var replan = Replanner{ .backing = a, .project_dir = fx.project }; + var replan = Replanner{ .backing = a, .project_dir = fx.project, .describer = testing.fakeDescriber(fx.project) }; defer replan.deinit(&site, fx.providers, fx.cfg); replan.baseline(); var dummy: u8 = 0; @@ -575,7 +575,7 @@ test "rebuild transaction: a rebuild runs the cold pipeline's pre-passes and pre try fx.startup(); var site = fx.site(); defer site.env.deinit(); - var replan = Replanner{ .backing = a, .project_dir = fx.project }; + var replan = Replanner{ .backing = a, .project_dir = fx.project, .describer = testing.fakeDescriber(fx.project) }; defer replan.deinit(&site, fx.providers, fx.cfg); replan.baseline(); var dummy: u8 = 0; @@ -623,7 +623,7 @@ test "rebuild transaction: a rebuild runs the cold pipeline's pre-passes and pre } }; Spy.log_path = log; - ctx.prepass = .{ .legacy_target = true, .bake = true, .fatal = false }; + ctx.prepass = .{ .target = "desktop", .describer = .off, .bake = true, .fatal = false }; ctx.run_prepasses = Spy.prepass; ctx.wire_prebuild_env = Spy.wire; ctx.run_prebuild = Spy.runPrebuild; diff --git a/src/cli/pipeline/session_key.zig b/src/cli/pipeline/session_key.zig index 9cb6ed92..3122e18a 100644 --- a/src/cli/pipeline/session_key.zig +++ b/src/cli/pipeline/session_key.zig @@ -195,7 +195,7 @@ pub const SessionKey = struct { pub fn configChanged(self: SessionKey, cfg: project_config.ProjectConfig, backend: []const u8) ?[]const u8 { if (!eql(self.backend, backend)) return "the backend"; if (!eql(self.backend_package, backendPackageName(cfg))) return "the backend package"; - if (self.target_follows_file and !eql(self.target, @tagName(cfg.platform))) return "the target"; + if (self.target_follows_file and !eql(self.target, cfg.declared_target)) return "the target"; return null; } diff --git a/src/cli/pipeline/watch_session.zig b/src/cli/pipeline/watch_session.zig index d9f37dfe..feb35b07 100644 --- a/src/cli/pipeline/watch_session.zig +++ b/src/cli/pipeline/watch_session.zig @@ -223,7 +223,7 @@ pub fn run( .launch_env = &site.env, // The cold pipeline's generation pre-passes, failing the rebuild // (not the session) on a misconfiguration. - .prepass = .{ .legacy_target = cx.target.legacy != null, .bake = cx.parsed_args.bake, .fatal = false }, + .prepass = .{ .target = cx.target.name, .describer = .init(cx.asm_bin, cx.project_dir), .bake = cx.parsed_args.bake, .fatal = false }, }; ctx.initIgnore(); defer ctx.deinit(); diff --git a/src/cli/project_config.zig b/src/cli/project_config.zig index 20c48205..acdb7d0f 100644 --- a/src/cli/project_config.zig +++ b/src/cli/project_config.zig @@ -8,9 +8,8 @@ //! //! Why the CLI needs *any* of the schema: a handful of CLI-retained //! commands (`build` / `run` / `upgrade`) parse `project.labelle` to -//! drive orchestration the CLI owns — target-dir naming -//! (`_`), the compatibility-warning pass, lockfile -//! writing, docker target selection. None of that is code generation; +//! drive orchestration the CLI owns — the compatibility-warning pass, +//! lockfile writing, the declared target. None of that is code generation; //! it is the CLI deciding *which assembler subcommand to run and with //! what flags*, plus packaging steps the assembler never touches. //! @@ -33,38 +32,17 @@ const astc = @import("../astc/convert.zig"); // and every backend fact the CLI needs (the generated target dir, the // backend package's name, whether it supports the target) comes from // `labelle-assembler describe` (`assembler_describe.zig`). -/// The legacy codegen platform: the strict schema type of `project.labelle`'s -/// `.platform` (mirrored by the assembler's `config.zig`; `provider_settings.zig` -/// is untouched). The RESOLVED target is a string — see `provider_targets.zig` -/// (RFC #406 phase 3b): `desktop` is core, every other name must be declared -/// by a pinned provider, and this enum is derived from the name only where -/// the pinned assembler still needs it. Enum values other than `desktop`, -/// and every `parsed.platform == .X` site, are on the agnosticism guard's -/// migration allowlist until extraction / labelle-assembler#378. -pub const Platform = enum { desktop, ios, android, wasm }; - -/// Texture container a platform ships atlases in — `.png` (CPU-decoded) or -/// `.astc` (GPU-native, zero decode). Mirrors the assembler's `AssetFormat` -/// (the assembler does the catalog swap; the CLI reads this to know whether to -/// run `labelle astc` before generating). See labelle-gfx#269 / #340. -pub const AssetFormat = enum { png, astc }; - -/// Per-platform `AssetFormat` selection; default `.png` everywhere (opt-in). -pub const AssetCompression = struct { - desktop: AssetFormat = .png, - android: AssetFormat = .png, - ios: AssetFormat = .png, - web: AssetFormat = .png, - - pub fn formatFor(self: AssetCompression, platform: Platform) AssetFormat { - return switch (platform) { - .desktop => self.desktop, - .android => self.android, - .ios => self.ios, - .wasm => self.web, - }; - } -}; +// No platform enum or asset-compression mirror either (RFC cli#471 P3): +// a target is a NAME (`provider_targets.zig`: `desktop` is core, every +// other name is declared by a pinned provider), and whether a target ships +// ASTC atlases is the assembler's answer (`describe`'s `asset_format`, which +// reads `.asset_compression` keyed by the target name). Both keys still +// parse: `ignore_unknown_fields` skips `.asset_compression`, and `.platform` +// is read as a name into `declared_target` (`declaredTarget`). + +/// The name the project's `.platform` gives when it names none. +pub const default_target = "desktop"; + pub const EcsChoice = enum { mock, zig_ecs, zflecs, mr_ecs }; /// CLI version — injected from build.zig via build options. @@ -176,7 +154,7 @@ pub const ResourceDef = struct { lazy: ?bool = null, /// Per-atlas ASTC block size, overriding the backend default for THIS - /// atlas only. Ignored unless the target platform actually compresses + /// atlas only. Ignored unless the target actually compresses /// (`asset_compression`), and ignored on sound/font resources. /// /// Block size trades fidelity against memory at a fixed 128 bits per @@ -340,11 +318,17 @@ pub const ProjectConfig = struct { target_fps: u32 = 60, // `.backend` is deliberately absent (RFC cli#471 D4): see the note // at the top of this file. - platform: Platform = .desktop, + /// NOT a `project.labelle` key: the target the project declares with + /// `.platform = .`, as a name (`declaredTarget`). The assembler + /// owns `.platform`'s strictness; the CLI only needs the name, for the + /// target a command resolves when no `--platform` overrides it. + /// `config.readProjectConfig` fills it in after the tolerant parse. + declared_target: []const u8 = default_target, ecs: EcsChoice = .mock, /// Gamepad input mode (assembler#274 opt-out flag): `.auto` (default) - /// pulls the shared SDL gamepad source into raylib/sokol desktop - /// builds; `.none` disables it (and drops the SDL2 link dependency). + /// pulls the shared gamepad source into the desktop builds of the + /// backends that use it; `.none` disables it (and drops its link + /// dependency). gamepad: GamepadMode = .auto, /// GUI plugin reference — parsed from project.labelle. gui: ?GuiPlugin = null, @@ -360,7 +344,7 @@ pub const ProjectConfig = struct { gfx_version: []const u8 = GFX_VERSION, labelle_version: []const u8 = CLI_VERSION, - /// The graphics backend package (e.g. bgfx), pinned like a plugin. + /// The graphics backend package, pinned like a plugin. /// The assembler is the primary consumer; the CLI parses it so /// `upgrade --check` can REPORT the pin instead of silently omitting /// it from the version report (cli#336). Shape matches `PluginDep` @@ -387,10 +371,6 @@ pub const ProjectConfig = struct { /// See `PrebuildStep` for the shape and the assembler-side ownership /// caveat. prebuild: []const PrebuildStep = &.{}, - /// Per-platform texture-compression selection (read by `build`/`run` to - /// auto-run `labelle astc` before generating; the assembler does the - /// catalog `.png → .astc` swap). Default `.png` everywhere. - asset_compression: AssetCompression = .{}, /// App icon — a PNG path relative to the PROJECT ROOT. The assembler /// owns this field (`labelle-assembler` `src/config.zig` / /// `src/app_icon.zig`); the CLI mirrors it because the packaging the @@ -481,30 +461,67 @@ pub const ProjectConfig = struct { } }; -test "AssetCompression.formatFor + default png" { - const def = AssetCompression{}; - inline for (.{ Platform.desktop, .android, .ios, .wasm }) |p| - try @import("std").testing.expectEqual(AssetFormat.png, def.formatFor(p)); - const m = AssetCompression{ .android = .astc, .desktop = .astc }; - try @import("std").testing.expectEqual(AssetFormat.astc, m.formatFor(.android)); - try @import("std").testing.expectEqual(AssetFormat.png, m.formatFor(.wasm)); +/// The target `project.labelle` declares with `.platform = .`, as a +/// name owned by `a`; null when the key is absent. Any enum literal is a +/// name here: the CLI keeps no list of targets (the resolution judges it, +/// `provider_targets.provisional`, and the assembler judges whether it can +/// generate it). `error.InvalidDeclaredTarget` when `.platform` is not an +/// enum literal. Pure: parses `source`, reads nothing else. +pub fn declaredTarget(a: std.mem.Allocator, source: [:0]const u8) !?[]const u8 { + var ast = try std.zig.Ast.parse(a, source, .zon); + defer ast.deinit(a); + if (ast.errors.len != 0) return null; // The owning project parser diagnoses it. + var zoir = try std.zig.ZonGen.generate(a, ast, .{ .parse_str_lits = false }); + defer zoir.deinit(a); + if (zoir.hasCompileErrors()) return null; + const fields = switch (std.zig.Zoir.Node.Index.root.get(zoir)) { + .struct_literal => |fields| fields, + else => return null, + }; + for (fields.names, 0..) |name, i| { + if (!std.mem.eql(u8, name.get(zoir), "platform")) continue; + return switch (fields.vals.at(@intCast(i)).get(zoir)) { + .enum_literal => |literal| try a.dupe(u8, literal.get(zoir)), + else => error.InvalidDeclaredTarget, + }; + } + return null; +} + +test "declaredTarget: `.platform` is read as a name, any name" { + const a = std.testing.allocator; + try std.testing.expect((try declaredTarget(a, ".{ .name = \"x\" }")) == null); + const cases = [_][2][:0]const u8{ + .{ ".{ .name = \"x\", .platform = .desktop }", "desktop" }, + .{ ".{ .name = \"x\", .platform = .other_target }", "other_target" }, + .{ ".{ .platform = .@\"probe-target\", .name = \"x\" }", "probe-target" }, + }; + for (cases) |case| { + const got = (try declaredTarget(a, case[0])).?; + defer a.free(got); + try std.testing.expectEqualStrings(case[1], got); + } + try std.testing.expectError(error.InvalidDeclaredTarget, declaredTarget(a, ".{ .platform = \"desktop\" }")); + // A key the CLI never parses stays unread; the project parser skips it too. + var arena = std.heap.ArenaAllocator.init(a); + defer arena.deinit(); + const src: [:0]const u8 = ".{ .name = \"x\", .platform = .probe, .asset_compression = .{ .probe = .astc, .desktop = .png } }"; + const cfg = try std.zon.parse.fromSliceAlloc(ProjectConfig, arena.allocator(), src, null, .{ .ignore_unknown_fields = true }); + try std.testing.expectEqualStrings(default_target, cfg.declared_target); } -test "a platform block the CLI no longer models still parses (the assembler owns its strictness)" { - // `.android` left the CLI (cli#405) and `.ios` followed (cli#471 I5): - // their keys belong to the target providers' settings - // (`providers/.json`, e.g. labelle-ios) and the assembler's - // codegen. The CLI's lenient read (`config.zig`, - // `ignore_unknown_fields`) must keep accepting a project that still - // carries either block, and simply ignores it. +test "a target block the CLI does not model still parses (the assembler owns its strictness)" { + // Target settings blocks left the CLI with their targets (cli#405, + // cli#471 I5): their keys belong to the target providers' settings + // (`providers/.json`) and the assembler's codegen. The CLI's + // lenient read (`config.zig`, `ignore_unknown_fields`) must keep + // accepting a project that still carries one, and simply ignores it. var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const src: [:0]const u8 = - \\.{ .name = "legacy", .android = .{ .package_name = "com.x.y", .orientation = .landscape, .immersive_mode = true }, - \\ .ios = .{ .app_name = "Legacy", .bundle_id = "com.x.y", .team_id = "ABCDE12345", .minimum_ios = "15.0", .orientation = .sensor_landscape, .device_family = "1,2" } } + \\.{ .name = "legacy", .probe_target = .{ .package_name = "com.x.y", .orientation = .landscape, .immersive_mode = true }, + \\ .other_target = .{ .app_name = "Legacy", .bundle_id = "com.x.y", .minimum = "15.0", .orientation = .sensor_landscape } } ; const cfg = try std.zon.parse.fromSliceAlloc(ProjectConfig, arena.allocator(), src, null, .{ .ignore_unknown_fields = true }); try std.testing.expectEqualStrings("legacy", cfg.name); - try std.testing.expect(!@hasField(ProjectConfig, "android")); - try std.testing.expect(!@hasField(ProjectConfig, "ios")); } diff --git a/src/cli/provider_dispatch.zig b/src/cli/provider_dispatch.zig index 0562df24..f200f1a3 100644 --- a/src/cli/provider_dispatch.zig +++ b/src/cli/provider_dispatch.zig @@ -818,7 +818,7 @@ fn execute(a: std.mem.Allocator, root: []const u8, cfg: project.ProjectConfig, p return runTool(a, host, root, provider, cmd.tool(), .{ .invocation = .{ .kind = .command, .id = cmd.name, .step = null, .phase = null }, .needs_project = cmd.needs_project, - .target = @tagName(cfg.platform), + .target = cfg.declared_target, .lock_file = lock_path, .output_dir = output, .optimize = .Debug, diff --git a/src/cli/provider_targets.zig b/src/cli/provider_targets.zig index e85e1171..98f08c40 100644 --- a/src/cli/provider_targets.zig +++ b/src/cli/provider_targets.zig @@ -3,16 +3,14 @@ //! `desktop` is the core target. Every other target name is accepted only //! because a pinned provider declares it in `.targets`; the CLI keeps no //! list of its own (`docs/provider-targets.md`). The resolved target is a -//! string. `project_config.Platform` stays the strict schema type of -//! `.platform` and is derived from the name only where the pinned assembler -//! still needs it — the labelle-assembler#378 boundary the pipeline enforces. +//! string, and only a string: whether the assembler can generate for it is +//! the assembler's answer (`describe`'s `supported`, RFC cli#471 P3). //! //! Nothing here knows a platform, store or package name: the CLI is agnostic //! (`docs/rfc-package-commands.md`). const std = @import("std"); const contract = @import("provider_contract.zig"); const dispatch = @import("provider_dispatch.zig"); -const project = @import("project_config.zig"); const config = @import("config.zig"); const github = @import("provider_github.zig"); const registry = @import("provider_registry.zig"); @@ -24,9 +22,6 @@ pub const Resolved = struct { name: []const u8, /// The owning provider, or null for the core target. provider: ?*const dispatch.Provider, - /// The schema platform of the same name, when the pinned assembler can - /// generate for it; null for every other provider target. - legacy: ?project.Platform, pub fn providerName(self: Resolved) []const u8 { return if (self.provider) |p| p.meta.name else "core"; @@ -36,25 +31,22 @@ pub const Resolved = struct { /// The name-only half of resolution: everything the requested STRING alone /// decides. The pipeline discovers providers only after the assembler's /// `install` populated the package cache (docs/provider-hooks.md), but the -/// target directory, the progress feed and the schema platform of the -/// pre-install steps need a name before that. `desktop` is the core target; +/// target directory and the progress feed of the pre-install steps need a +/// name before that. `desktop` is the core target; /// any other well-formed name is provisionally a provider target — never a /// resolved one — until `resolve` confirms a discovered provider owns it. pub const Provisional = struct { name: []const u8, /// The core target: resolved by name alone, no provider can own it. is_core: bool, - /// The schema platform of the same name, when the pinned assembler can - /// generate for it; null for every other name. - legacy: ?project.Platform, }; /// Pure. `InvalidTarget` for a non-identifier or a Windows reserved device /// name (the parsers reject those first, with their own message). pub fn provisional(requested: []const u8) !Provisional { if (!contract.targetName(requested)) return error.InvalidTarget; - if (std.mem.eql(u8, requested, core_target)) return .{ .name = core_target, .is_core = true, .legacy = .desktop }; - return .{ .name = requested, .is_core = false, .legacy = std.meta.stringToEnum(project.Platform, requested) }; + if (std.mem.eql(u8, requested, core_target)) return .{ .name = core_target, .is_core = true }; + return .{ .name = requested, .is_core = false }; } /// The discovered provider declaring `target`, if any (ownership is unique: @@ -78,10 +70,10 @@ pub fn ownerOf(providers: []const dispatch.Provider, target: []const u8) ?*const /// This is the only place ownership of a requested target is decided. pub fn resolve(providers: []const dispatch.Provider, requested: []const u8) !Resolved { const candidate = try provisional(requested); - if (candidate.is_core) return .{ .name = core_target, .provider = null, .legacy = .desktop }; + if (candidate.is_core) return .{ .name = core_target, .provider = null }; const provider = ownerOf(providers, candidate.name) orelse return error.NoProviderForTarget; if (!provider.verified) return error.UnverifiedTargetOwner; - return .{ .name = candidate.name, .provider = provider, .legacy = candidate.legacy }; + return .{ .name = candidate.name, .provider = provider }; } /// The diagnostic for `UnverifiedTargetOwner`: names the package, and the @@ -279,7 +271,6 @@ test "provider targets: the core target resolves with no providers and never to const core = try resolve(&.{}, core_target); try std.testing.expectEqualStrings(core_target, core.name); try std.testing.expect(core.provider == null); - try std.testing.expectEqual(project.Platform.desktop, core.legacy.?); try std.testing.expectEqualStrings("core", core.providerName()); const owner = fixtureProvider("fixture", &.{"probe-target"}); const with = try resolve(&.{owner}, core_target); @@ -290,18 +281,14 @@ test "provider targets: the provisional target is decided by the name alone and const core = try provisional(core_target); try std.testing.expect(core.is_core); try std.testing.expectEqualStrings(core_target, core.name); - try std.testing.expectEqual(project.Platform.desktop, core.legacy.?); - // A schema name is provisionally a provider target with its legacy - // platform; a name outside the enum has none. Neither is resolved. - for (std.enums.values(project.Platform)) |platform| { - if (platform == .desktop) continue; - const schema = try provisional(@tagName(platform)); - try std.testing.expect(!schema.is_core); - try std.testing.expectEqual(platform, schema.legacy.?); + // Every other name is provisionally a provider target, never resolved; + // no name is special (the CLI keeps no list of targets). + for ([_][]const u8{ "other-target", "probe-target" }) |name| { + const other = try provisional(name); + try std.testing.expect(!other.is_core); + try std.testing.expectEqualStrings(name, other.name); } const foreign = try provisional("probe-target"); - try std.testing.expect(!foreign.is_core); - try std.testing.expect(foreign.legacy == null); try std.testing.expectError(error.InvalidTarget, provisional("Probe")); try std.testing.expectError(error.InvalidTarget, provisional("nul")); // `resolve` is the provisional step plus ownership: the same names @@ -309,7 +296,6 @@ test "provider targets: the provisional target is decided by the name alone and const owner = fixtureProvider("fixture", &.{"probe-target"}); const resolved = try resolve(&.{owner}, "probe-target"); try std.testing.expectEqualStrings(foreign.name, resolved.name); - try std.testing.expectEqual(foreign.legacy, resolved.legacy); try std.testing.expectError(error.NoProviderForTarget, resolve(&.{}, "probe-target")); } @@ -321,7 +307,6 @@ test "provider targets: a declared target resolves to its provider; an undeclare try std.testing.expectEqualStrings("probe-target", resolved.name); try std.testing.expectEqualStrings("fixture", resolved.providerName()); try std.testing.expect(resolved.provider.? == &providers[1]); - try std.testing.expect(resolved.legacy == null); try std.testing.expectError(error.NoProviderForTarget, resolve(&.{other}, "probe-target")); try std.testing.expectError(error.NoProviderForTarget, resolve(&.{}, "probe-target")); // Shape is checked before ownership: a non-identifier never resolves. @@ -329,19 +314,14 @@ test "provider targets: a declared target resolves to its provider; an undeclare try std.testing.expectError(error.InvalidTarget, resolve(&providers, "")); } -test "provider targets: schema platforms map by name only through a provider; other targets never map" { - for (std.enums.values(project.Platform)) |platform| { - if (platform == .desktop) continue; - const name = @tagName(platform); - // The schema name alone resolves nothing: the provider is required. +test "provider targets: no name but the core one resolves without a provider" { + for ([_][]const u8{ "other-target", "third-target", "probe-target" }) |name| { try std.testing.expectError(error.NoProviderForTarget, resolve(&.{}, name)); const owner = fixtureProvider("owner", &.{name}); const resolved = try resolve(&.{owner}, name); try std.testing.expectEqualStrings("owner", resolved.providerName()); - try std.testing.expectEqual(platform, resolved.legacy.?); + try std.testing.expectEqualStrings(name, resolved.name); } - const owner = fixtureProvider("owner", &.{"probe-target"}); - try std.testing.expect((try resolve(&.{owner}, "probe-target")).legacy == null); } test "provider targets: the no-provider diagnostic names a candidate on a hit and stays generic on a miss" { diff --git a/test/provider_targets_e2e.py b/test/provider_targets_e2e.py index 20079091..d1f37dc2 100644 --- a/test/provider_targets_e2e.py +++ b/test/provider_targets_e2e.py @@ -46,10 +46,22 @@ def backend_of(root): elif argv and argv[0] == "describe": import json target_name = argv[argv.index("--target") + 1] - backend = backend_of(Path(argv[argv.index("--project-root") + 1])) - print(json.dumps({"schema": "labelle.describe/v1", "target": target_name, - "target_dir": ".labelle/" + backend + "_" + target_name, - "backend": {"name": backend}, "asset_format": "png", "supported": True})) + root = Path(argv[argv.index("--project-root") + 1]) + backend = backend_of(root) + # Like the real assembler: it generates for its own four targets and + # answers `supported: false` with a reason for any other name (exit 0), + # and reads `.asset_compression` by the target name. + known = target_name in ("desktop", "ios", "android", "wasm") + text = (root / "project.labelle").read_text() + at = text.find(".asset_compression = .{") + compression = text[at:text.find("}", at)] if at >= 0 else "" + astc = f".{target_name} = .astc" in compression + doc = {"schema": "labelle.describe/v1", "target": target_name, + "target_dir": ".labelle/" + backend + "_" + target_name, + "backend": {"name": backend}, "asset_format": "astc" if astc else "png", "supported": known} + if not known: + doc["reason"] = f"backend '{backend}' has no target '{target_name}': this assembler generates for desktop ios android wasm" + print(json.dumps(doc)) elif argv and argv[0] == "install": if os.environ.get("FAKE_INSTALL_PLUGIN"): src, dest = os.environ["FAKE_INSTALL_PLUGIN"].split("|") @@ -78,6 +90,10 @@ def backend_of(root): TOOL = '.build_step = "probe-tool", .executable = "bin/provider-probe"' NO_PROVIDER = ("labelle: no provider for target '{t}' in this project; " "add and pin the package that declares target '{t}'") +# The generation gate's refusal: `describe`'s reason for a name the +# assembler does not generate for (the fake's backend is raylib). +UNGENERATABLE = ("labelle: backend 'raylib' cannot build target 'probe-target': " + "backend 'raylib' has no target 'probe-target'") def hook(id, step, when, target): @@ -318,8 +334,8 @@ def lookup(target, extra, requested): assert "(registry not consulted: LABELLE_OFFLINE is set)" in err and generic in err and "(registry:" not in err, err # ── A provider that owns the target but replaces nothing on generate ── - # The pinned assembler cannot generate for a name outside its enum, so - # the build stops with the #378 message before the assembler's + # The assembler's `describe` says it cannot generate a name it does not + # know, so the build stops with its reason before the assembler's # `generate`. With a package declared, every verdict that needs its # manifest — this one, ownership, the bundle replacement — is reached # only after the install populated the cache; the target directory is @@ -329,7 +345,8 @@ def lookup(target, extra, requested): for args in (("build", "--platform=probe-target"), ("generate", "--platform=probe-target"), ("bundle", "--platform=probe-target")): reset() stopped = run(*args, code=1) - assert "target 'probe-target' is declared by 'fixture' but the pinned assembler cannot generate for it yet (labelle-assembler#378)" in stopped.stderr, stopped.stderr + assert UNGENERATABLE in stopped.stderr, stopped.stderr + assert "package 'fixture' declares target 'probe-target' but no `.when = .replace` hook on `generate`" in stopped.stderr, stopped.stderr installed_only(stopped, "probe-target") # Ownership is per target: this provider does not make `wasm` resolvable. # Its manifest is local, so every declared package is readable before @@ -356,7 +373,7 @@ def lookup(target, extra, requested): untouched(typo, "waasm") reset() control = run("build", "--platform=probe-target", code=1, extra_env={"LABELLE_NO_PREBUILD": "0"}) - assert "labelle-assembler#378" in control.stderr, control.stderr + assert UNGENERATABLE in control.stderr, control.stderr assert marker.exists(), "the prebuild marker step did not run for a declared target" marker.unlink() declare(dep) @@ -504,11 +521,10 @@ def lookup(target, extra, requested): assert entries and Path(entries[0]["output_dir"]) == (project / "dist").resolve(), entries assert not bundle_dir.exists(), "the default bundle directory was created despite --output" - # ── The ASTC prepass keys on the target, not on the derived enum ─────── - # A provider target outside the schema enum derives `.desktop` for the - # legacy sites, but it is not the desktop target: with desktop atlases - # set to ASTC the prepass runs for `desktop` (the control: it reports - # its tally even with no atlas) and never for `probe-target`. + # ── The ASTC prepass keys on the target's own describe answer ───────── + # With desktop atlases set to ASTC the prepass runs for `desktop` (the + # control: it reports its tally even with no atlas) and never for + # `probe-target`, whose generation its provider replaces. astc = ', .asset_compression = .{ .desktop = .astc }' declare(dep, extra=astc) reset() From 66811ef5e123908f39056bab9bfe48d2bbd855c7 Mon Sep 17 00:00:00 2001 From: Daniel Murta Date: Tue, 29 Sep 2026 13:02:16 -0300 Subject: [PATCH 3/4] docs: the 4.0 migration guide (cli#471) docs/migrating-to-4.0.md covers what a project needs for CLI 4.0: - assembler 0.118.0+ (protocol 7); - labelle-ios for the ios target; - the sdl2 provider (or LABELLE_SDL2_LIB, or .gamepad = .none) for SDL2 on Windows; - smaller changes: the describe-named target dir, unknown targets, astc --backend, the .platform enum literal, doctor --fix, labelle test, and --docker on a Mac. `labelle help` points at it. --- docs/migrating-to-4.0.md | 113 +++++++++++++++++++++++++++++++++++++++ src/cli/help.zig | 2 + 2 files changed, 115 insertions(+) create mode 100644 docs/migrating-to-4.0.md diff --git a/docs/migrating-to-4.0.md b/docs/migrating-to-4.0.md new file mode 100644 index 00000000..35672ab4 --- /dev/null +++ b/docs/migrating-to-4.0.md @@ -0,0 +1,113 @@ +# Migrating a project to labelle CLI 4.0 + +CLI 4.0 finishes [RFC #406](rfc-package-commands.md) with its last phase, +[RFC cli#471](https://github.com/labelle-toolkit/labelle-cli/issues/471): the +CLI core no longer knows any platform, backend or store. What it used to +hard-code now comes from two places: + +- **The assembler** answers every backend and target question through + `labelle-assembler describe`: which backend package the project resolves + to, the generated target directory, the asset format, and whether the pair + is supported. +- **Providers** own everything platform-specific: iOS comes from + [labelle-ios](https://github.com/labelle-toolkit/labelle-ios), and SDL2 on + Windows from the `sdl2` provider in + [labelle-sdl](https://github.com/labelle-toolkit/labelle-sdl). + +A `desktop` project on bgfx, raylib or sokol with `.gamepad = .none`, pinned +to assembler 0.118.0 or newer, needs no edit. + +## Checklist + +1. [Pin assembler 0.118.0 or newer](#1-pin-assembler-01180-or-newer). +2. [iOS: add labelle-ios](#2-ios-add-labelle-ios) if you build for `ios`. +3. [SDL2 on Windows: add the `sdl2` provider](#3-sdl2-add-the-sdl2-provider) + if a Windows build links SDL2. +4. [Check the smaller changes](#4-also-changed). + +## 1. Pin assembler 0.118.0 or newer + +`generate`, `describe` and `upgrade backend` need assembler protocol 7, which +shipped in assembler 0.118.0. An older `.assembler_version` fails with a +one-line error that names `labelle upgrade assembler`. + +```zig +.assembler_version = "0.120.0", +``` + +`labelle upgrade all` bumps the assembler first, then asks it to bump the +backend (`labelle-assembler upgrade backend`). If the assembler refuses, +`project.labelle` is restored, unless you pass `--force`. + +## 2. iOS: add labelle-ios + +The core `labelle ios` command, the simulator launch and the forced sokol +backend are gone. The `ios` target belongs to the labelle-ios provider: + +```zig +.plugins = .{ + .{ .name = "ios", .repo = "github.com/labelle-toolkit/labelle-ios", .version = "0.1.0" }, +}, +``` + +Then use the normal commands: `labelle build|run|bundle --platform=ios`. + +- `labelle ios …` is now an unknown command. +- The backend is no longer switched to sokol behind your back. Set + `.backend = .sokol` (or a `.backend_package` whose manifest supports iOS) + yourself; `describe` refuses an unsupported pair with its reason. +- iOS targets generate into `.labelle/_ios`. +- A leftover `.ios = .{ … }` block in `project.labelle` still parses and is + ignored. labelle-ios reads its settings from `providers/ios.json`. + +## 3. SDL2: add the `sdl2` provider + +The CLI no longer downloads SDL2 on Windows, sets `LABELLE_SDL2_LIB`, or +copies `SDL2.dll` beside the game. Any of these replaces it: + +- Add the provider. It provisions SDL2 on Windows and stages the DLLs, and + does nothing on macOS or Linux: + + ```zig + .{ .name = "sdl2", .repo = "github.com/labelle-toolkit/labelle-sdl", .version = "0.4.0" }, + ``` + + `labelle sdl2 doctor` checks the install; `labelle sdl2 install` fetches it. +- Point `LABELLE_SDL2_LIB` at your own SDL2 MinGW `lib` directory. +- Turn the gamepad off with `.gamepad = .none`, if you don't use one. + +Without any of them, a Windows build that needs SDL2 stops with one line: +`SDL2 not found: set LABELLE_SDL2_LIB or use .gamepad = .none`. +`labelle doctor` prints the same advice when the project links SDL2 and +doesn't list `sdl2`. + +## 4. Also changed + +- **Target directory.** The generated directory is the one the assembler + reports. For a third-party `.backend_package` without a matching + `.backend`, that is `.labelle/_`, where 3.x wrongly used + `.labelle/bgfx_`. +- **Unknown targets.** The assembler judges them. A name no provider declares + and the assembler can't generate for stops with describe's reason, for + example: `backend 'bgfx' has no target 'probe-target': this assembler + generates for desktop ios android wasm`. +- **`labelle astc --backend`** is accepted and ignored, with a deprecation + line. Texture capabilities come from the project's backend package, and for + the `.backend` shorthand `describe` locates it, so the declared default + block is used instead of the conservative one. +- **`.platform`** must be an enum literal (`.platform = .desktop`); the CLI + reads it as a target name. +- **`labelle doctor --fix`** has nothing left to fix in the core. Use each + provider's `labelle doctor --fix`. +- **`labelle test`** always runs the generated tests in `.labelle/tests/`. +- **`--docker`** refuses macOS targets, including a bare `--docker` on a Mac + (since 3.1). Linux and Windows cross builds are unchanged. + +## Troubleshooting + +| Message | Fix | +|---|---| +| `assembler … protocol … labelle upgrade assembler` | [Step 1](#1-pin-assembler-01180-or-newer) | +| `unknown command 'ios'` | [Step 2](#2-ios-add-labelle-ios) | +| `SDL2 not found: set LABELLE_SDL2_LIB …` | [Step 3](#3-sdl2-add-the-sdl2-provider) | +| `backend '…' cannot build target '…'` | Pick a backend that supports the target, or add the provider that owns it | diff --git a/src/cli/help.zig b/src/cli/help.zig index c5324fab..ed581515 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -47,6 +47,8 @@ pub fn printHelp() void { \\ `--target` cross-compiles for Linux or Windows; a macOS target is refused. \\ Upgrading a 1.x project: docs/migrating-to-2.0.md \\ (https://github.com/labelle-toolkit/labelle-cli/blob/main/docs/migrating-to-2.0.md) + \\ Upgrading from 3.x: docs/migrating-to-4.0.md + \\ (https://github.com/labelle-toolkit/labelle-cli/blob/main/docs/migrating-to-4.0.md) \\ Upgrading from 2.x: docs/migrating-to-3.0.md \\ (https://github.com/labelle-toolkit/labelle-cli/blob/main/docs/migrating-to-3.0.md) \\ From 87a3ab073f004952628ba31c7d3fcd0c857e586e Mon Sep 17 00:00:00 2001 From: Daniel Murta Date: Tue, 29 Sep 2026 13:13:17 -0300 Subject: [PATCH 4/4] fix(generate): no describe answer fails the ASTC prepass instead of skipping it A null describe answer used to read as PNG, so a target configured for ASTC could generate against a stale .astc sibling without refreshing it. The prepass now requires the answer: the cold pipeline stops with a clear message, and a watched rebuild fails only itself (the session lives on). --- src/cli/pipeline/generate.zig | 43 ++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/src/cli/pipeline/generate.zig b/src/cli/pipeline/generate.zig index a3a810d1..b70045ab 100644 --- a/src/cli/pipeline/generate.zig +++ b/src/cli/pipeline/generate.zig @@ -132,7 +132,7 @@ pub const Prepass = struct { target: []const u8, /// Asks the assembler whether the target ships ASTC atlases /// (`describe`'s `asset_format`, from `.asset_compression` keyed by the - /// target name). `off`, or no answer, means PNG: no conversion. + /// target name). No answer (`off` included) fails the generation. describer: assembler_describe.Describer, /// `--bake`. bake: bool, @@ -167,10 +167,20 @@ pub fn corePrepasses(allocator: std.mem.Allocator, project_dir: []const u8, pars // target never borrows another target's setting (Codex on #421), and the // CLI keeps no mirror of the schema. Asked on every generation, so a // watched rebuild follows an edited `.asset_compression`. + // + // No answer is a failure, not PNG: for a target that ships ASTC, + // skipping the conversion would let the assembler swap in a STALE + // `.astc` sibling from an earlier build (the same hazard as + // `StaleAstcSiblingUndeletable`). The cold pipeline stops; a watched + // rebuild fails only itself, and the session lives on. var scratch = std.heap.ArenaAllocator.init(allocator); defer scratch.deinit(); - const described = opts.describer.query(scratch.allocator(), opts.target); - if (described != null and std.mem.eql(u8, described.?.asset_format, "astc")) { + const described = opts.describer.require(scratch.allocator(), opts.target) catch |err| { + if (opts.fatal) progress.fatalExit(1, "cannot tell whether the target ships ASTC atlases (assembler describe failed) — see the error above"); + std.debug.print("labelle: cannot tell whether target '{s}' ships ASTC atlases; not generating with possibly stale .astc siblings\n", .{opts.target}); + return err; + }; + if (std.mem.eql(u8, described.asset_format, "astc")) { // Pass the RESOLVED target: `--platform=` may differ from what // project.labelle declares, and the loadable blocks depend on it. // No backend: `labelle astc` reads the project's own (cli#471 D4). @@ -237,6 +247,33 @@ pub fn beforeGenerate( return 0; } +test "pipeline: no describe answer fails the prepass instead of skipping the ASTC conversion" { + const Fake = struct { + var calls: usize = 0; + fn spawn(_: std.mem.Allocator, _: []const []const u8) ?[]const u8 { + calls += 1; + return null; // describe failed (a mid-edit project file, say) + } + }; + const describer: assembler_describe.Describer = .{ .bin_path = "/asm", .protocol = assembler_describe.min_protocol, .project_dir = "/proj", .spawn = Fake.spawn }; + const cfg: @import("../project_config.zig").ProjectConfig = .{ .name = "game" }; + // A watched rebuild (`fatal = false`): the error fails that rebuild only. + try std.testing.expectError(error.DescribeFailed, corePrepasses(std.testing.allocator, "/proj", cfg, .{ + .target = "desktop", + .describer = describer, + .bake = false, + .fatal = false, + })); + try std.testing.expectEqual(@as(usize, 1), Fake.calls); + // No describer at all is no answer either. + try std.testing.expectError(error.DescribeFailed, corePrepasses(std.testing.allocator, "/proj", cfg, .{ + .target = "desktop", + .describer = .off, + .bake = false, + .fatal = false, + })); +} + test "pipeline: the shader override is re-gated after the before-generate hooks" { if (@import("builtin").os.tag == .windows) return error.SkipZigTest; var arena = std.heap.ArenaAllocator.init(std.testing.allocator);