From e8777954913bef79e1e3ab26178aa5b392f16bb9 Mon Sep 17 00:00:00 2001 From: youfun <3973573+youfun@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:53:28 +0530 Subject: [PATCH 1/9] Pin Zig 0.17.0 and detect stale iOS build_root. Doctor and native preflight now require zig 0.17.0 exactly, rejecting the previous 0.17.0-dev.269 nightly. Existing ios/build.zig files that still call b.build_root fail doctor; line comments naming the old field do not. Companion to mob and mob_new. --- .tool-versions | 2 +- CHANGELOG.md | 20 ++++++++++++++++++++ lib/mix/tasks/mob.doctor.ex | 27 +++++++++++++++++++++++++++ lib/mob_dev/toolchain.ex | 2 +- test/mix/tasks/mob_doctor_test.exs | 27 +++++++++++++++++++++++++++ test/mob_dev/toolchain_test.exs | 5 +++++ 6 files changed, 81 insertions(+), 2 deletions(-) diff --git a/.tool-versions b/.tool-versions index b3d9d31b..e6da9a5c 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ elixir 1.20.0-otp-29 erlang 29.1.2 -zig 0.17.0-dev.269+ebff43698 +zig 0.17.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4029c948..edf63aa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,20 @@ ## [Unreleased] +### Changed + +- **Zig pin is 0.17.0.** Replaces `0.17.0-dev.269+ebff43698` now that 0.17.0 + is the stable with the macOS 26 SDK fix the nightly was taken for. + `mix mob.doctor` / `mix mob.deploy --native` still require an exact + `zig version` match (`mise install zig@0.17.0`). Companion to mob and + mob_new. + ### Fixed +- **`mix mob.doctor` fails iOS `build.zig` files that still use `b.build_root`.** + Zig 0.17.0 removed `Build.build_root`; a file generated against the + nightly panics at configure with `no field named 'build_root'`. The + check names the Swift-glob replacement (`std.Io.Dir.openDirAbsolute`) + from mob_new's templates. - **The Hex package ships `guides/` (MOB-501).** It shipped only the default files, so an app had no `deps/mob_dev/guides/` and the publishing and security-scan guides were only on hexdocs. @@ -10,6 +23,13 @@ one. The task docs and `guides/publishing_to_google_play.md` now list the file's four keys and name `mix mob.setup.google_play`, which writes it. +### Upgrading + +- **Zig 0.17.0.** Install that exact version. Existing iOS apps must port + `ios/build.zig` and `ios/build_device.zig` off `b.build_root` (doctor + fails until they do). Android `build.zig` from current templates already + configures on 0.17.0. + ## [0.7.30] - 2026-10-23 ### Security diff --git a/lib/mix/tasks/mob.doctor.ex b/lib/mix/tasks/mob.doctor.ex index 3aa23729..0d105ac7 100644 --- a/lib/mix/tasks/mob.doctor.ex +++ b/lib/mix/tasks/mob.doctor.ex @@ -739,6 +739,7 @@ defmodule Mix.Tasks.Mob.Doctor do check_compiled(), check_driver_tab(), check_plugin_build_options(), + check_ios_zig_build_root(), check_inactive_nif_plugins(), check_component_event_jni(), check_sheet_dismiss_wire_shape(), @@ -1043,6 +1044,32 @@ defmodule Mix.Tasks.Mob.Doctor do Enum.reject(required, &String.contains?(content, "\"#{&1}\"")) end + # Zig 0.17.0 removed `Build.build_root`. iOS templates generated against + # 0.17.0-dev.269 still call `b.build_root.handle.openDir` to glob mob Swift + # sources; configure then exits with `no field named 'build_root'`. + defp check_ios_zig_build_root do + for path <- ["ios/build.zig", "ios/build_device.zig"], + {:ok, content} <- [File.read(path)], + __ios_build_root_stale__(content) do + {:fail, "zig 0.17.0 (#{path})", + "uses b.build_root, which Zig 0.17.0 removed", + "In the Swift glob, replace `b.build_root.handle.openDir(...)` with " <> + "`std.Io.Dir.openDirAbsolute(...)` — see a freshly generated app " <> + "or mob_new's ios/build.zig.eex"} + end + end + + @doc false + @spec __ios_build_root_stale__(String.t()) :: boolean() + def __ios_build_root_stale__(content) do + content + |> String.split("\n") + |> Enum.any?(fn line -> + code = line |> String.split("//") |> hd() |> String.trim() + String.contains?(code, "b.build_root") + end) + end + # ── Installed-but-inactive NIF plugins (MOB-281) ───────────────────────────── # # Same check `mix mob.deploy --native` prints: a dep shipping a NIF that isn't diff --git a/lib/mob_dev/toolchain.ex b/lib/mob_dev/toolchain.ex index edb353ec..65dfd0e0 100644 --- a/lib/mob_dev/toolchain.ex +++ b/lib/mob_dev/toolchain.ex @@ -3,7 +3,7 @@ defmodule MobDev.Toolchain do # This value must live in packaged source; the root toolchain file is not in # Hex archives. The source test keeps both release authorities in lockstep. - @required_zig_version "0.17.0-dev.269+ebff43698" + @required_zig_version "0.17.0" # iPhone Duo needs the iOS 27.1 SDK, which ships with Xcode 27.1 (MOB-201). @duo_min_version {27, 1, 0} diff --git a/test/mix/tasks/mob_doctor_test.exs b/test/mix/tasks/mob_doctor_test.exs index 04b1c971..f1dd150d 100644 --- a/test/mix/tasks/mob_doctor_test.exs +++ b/test/mix/tasks/mob_doctor_test.exs @@ -106,6 +106,33 @@ defmodule Mix.Tasks.Mob.DoctorTest do end end + describe "__ios_build_root_stale__/1" do + test "flags the 0.17-dev glob that calls b.build_root" do + stale = """ + var mob_ios = b.build_root.handle.openDir(glob_io, b.fmt("{s}/ios", .{mob_dir}), .{ .iterate = true }) catch @panic("mob ios dir not found"); + """ + + assert Mix.Tasks.Mob.Doctor.__ios_build_root_stale__(stale) + end + + test "does not flag the 0.17.0 glob" do + fixed = """ + var mob_ios = std.Io.Dir.openDirAbsolute(glob_io, b.fmt("{s}/ios", .{mob_dir}), .{ .iterate = true }) catch @panic("mob ios dir not found"); + """ + + refute Mix.Tasks.Mob.Doctor.__ios_build_root_stale__(fixed) + end + + test "does not flag a migration comment that names the old field" do + migrated = """ + // Replaced b.build_root.handle.openDir with std.Io.Dir.openDirAbsolute. + var mob_ios = std.Io.Dir.openDirAbsolute(glob_io, b.fmt("{s}/ios", .{mob_dir}), .{ .iterate = true }) catch @panic("mob ios dir not found"); + """ + + refute Mix.Tasks.Mob.Doctor.__ios_build_root_stale__(migrated) + end + end + describe "__component_event_jni_mismatched__/1 (MOB-98 JNI owner check)" do test "flags the pre-fix declaration (bare external fun, no @JvmStatic)" do pre_fix = """ diff --git a/test/mob_dev/toolchain_test.exs b/test/mob_dev/toolchain_test.exs index 3ebed9c7..f01fa23a 100644 --- a/test/mob_dev/toolchain_test.exs +++ b/test/mob_dev/toolchain_test.exs @@ -34,6 +34,11 @@ defmodule MobDev.ToolchainTest do {:version_mismatch, "0.17.0-dev.270+different"} end + test "rejects the previous 0.17.0-dev.269 nightly" do + assert Toolchain.zig_status_from_result({"0.17.0-dev.269+ebff43698\n", 0}) == + {:version_mismatch, "0.17.0-dev.269+ebff43698"} + end + test "rejects a failed version command" do assert Toolchain.zig_status_from_result({"dyld failure\n", 127}) == {:version_command_failed, "dyld failure", 127} From 3c13c396ced712cd1444c6b2649f8cd7ee37e45e Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 23 Oct 2026 18:50:02 -0600 Subject: [PATCH 2/9] mix format --- lib/mix/tasks/mob.doctor.ex | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/mix/tasks/mob.doctor.ex b/lib/mix/tasks/mob.doctor.ex index 0d105ac7..c7921739 100644 --- a/lib/mix/tasks/mob.doctor.ex +++ b/lib/mix/tasks/mob.doctor.ex @@ -1051,8 +1051,7 @@ defmodule Mix.Tasks.Mob.Doctor do for path <- ["ios/build.zig", "ios/build_device.zig"], {:ok, content} <- [File.read(path)], __ios_build_root_stale__(content) do - {:fail, "zig 0.17.0 (#{path})", - "uses b.build_root, which Zig 0.17.0 removed", + {:fail, "zig 0.17.0 (#{path})", "uses b.build_root, which Zig 0.17.0 removed", "In the Swift glob, replace `b.build_root.handle.openDir(...)` with " <> "`std.Io.Dir.openDirAbsolute(...)` — see a freshly generated app " <> "or mob_new's ios/build.zig.eex"} From c13e05f16f0a92b01c073d8c03c82d73a861966b Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 23 Oct 2026 19:10:10 -0600 Subject: [PATCH 3/9] build_root check ignores comments/strings and spans lines; CI installs the pinned Zig and runs :requires_zig; upgrade steps --- .github/workflows/test.yml | 19 +++++++++++----- CHANGELOG.md | 19 +++++++++------- lib/mix/tasks/mob.doctor.ex | 36 +++++++++++++++++++++++++----- test/mix/tasks/mob_doctor_test.exs | 20 +++++++++++++++++ 4 files changed, 75 insertions(+), 19 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b887ddb9..62c28f63 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,6 +40,17 @@ jobs: elixir-version: ${{ matrix.elixir }} otp-version: ${{ matrix.otp }} + # `:requires_zig` tests (StaticNifs ast-check, generated driver tables, + # zig_build) run against the pinned Zig, read from .tool-versions so CI + # can't drift from it. + - name: Zig version from .tool-versions + id: zig + run: echo "version=$(awk '$1 == "zig" {print $2}' .tool-versions)" >> "$GITHUB_OUTPUT" + + - uses: mlugg/setup-zig@v2 + with: + version: ${{ steps.zig.outputs.version }} + - name: Cache deps + _build uses: actions/cache@v4 with: @@ -73,11 +84,9 @@ jobs: # mob.adopt native tests resolve mob_new's templates from this checkout. MOB_NEW_DIR: ${{ github.workspace }}/mob_new # `:macos_only` — tests that shell out to /usr/libexec/PlistBuddy - # and similar macOS-bound tools. - # `:requires_zig` — the StaticNifs zig-ast-check round-trip; we - # don't install Zig in CI yet (it's a 30 MB download for one test). - # Both run by default on a maintainer's laptop; CI explicitly skips. - run: mix test --exclude macos_only --exclude requires_zig + # and similar macOS-bound tools. Runs by default on a maintainer's + # laptop; CI explicitly skips it. + run: mix test --exclude macos_only # MOB-201: `mix mob.doctor`'s Xcode / iOS SDK / iPhone Duo rows, run against # real Xcodes selected through DEVELOPER_DIR (the same resolution diff --git a/CHANGELOG.md b/CHANGELOG.md index edf63aa6..6144c49b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,16 +5,17 @@ - **Zig pin is 0.17.0.** Replaces `0.17.0-dev.269+ebff43698` now that 0.17.0 is the stable with the macOS 26 SDK fix the nightly was taken for. `mix mob.doctor` / `mix mob.deploy --native` still require an exact - `zig version` match (`mise install zig@0.17.0`). Companion to mob and - mob_new. + `zig version` match. Companion to mob and mob_new. CI now installs the + pinned Zig (read from `.tool-versions`) and runs the `:requires_zig` + tests. ### Fixed - **`mix mob.doctor` fails iOS `build.zig` files that still use `b.build_root`.** Zig 0.17.0 removed `Build.build_root`; a file generated against the nightly panics at configure with `no field named 'build_root'`. The - check names the Swift-glob replacement (`std.Io.Dir.openDirAbsolute`) - from mob_new's templates. + check ignores comments and strings and names the Swift-glob replacement + (`std.Io.Dir.openDirAbsolute`) from mob_new's templates. - **The Hex package ships `guides/` (MOB-501).** It shipped only the default files, so an app had no `deps/mob_dev/guides/` and the publishing and security-scan guides were only on hexdocs. @@ -25,10 +26,12 @@ ### Upgrading -- **Zig 0.17.0.** Install that exact version. Existing iOS apps must port - `ios/build.zig` and `ios/build_device.zig` off `b.build_root` (doctor - fails until they do). Android `build.zig` from current templates already - configures on 0.17.0. +- **Zig 0.17.0.** In each app, set `zig 0.17.0` in `.tool-versions` + (`mise use zig@0.17.0` installs it and edits the file; `mise install + zig@0.17.0` alone leaves the app on the nightly, and the native build + rejects it). Existing iOS apps must port `ios/build.zig` and + `ios/build_device.zig` off `b.build_root` (doctor fails until they do). + Android `build.zig` from current templates already configures on 0.17.0. ## [0.7.30] - 2026-10-23 diff --git a/lib/mix/tasks/mob.doctor.ex b/lib/mix/tasks/mob.doctor.ex index c7921739..f3c45907 100644 --- a/lib/mix/tasks/mob.doctor.ex +++ b/lib/mix/tasks/mob.doctor.ex @@ -1059,16 +1059,40 @@ defmodule Mix.Tasks.Mob.Doctor do end @doc false + # True when Zig code (not a comment or string) reads `build_root` off a + # `b` value, allowing whitespace and line breaks around the dot. @spec __ios_build_root_stale__(String.t()) :: boolean() def __ios_build_root_stale__(content) do - content - |> String.split("\n") - |> Enum.any?(fn line -> - code = line |> String.split("//") |> hd() |> String.trim() - String.contains?(code, "b.build_root") - end) + Regex.match?( + Regex.compile!("\\bb\\s*\\.\\s*build_root\\b"), + zig_code_only(content, []) + ) end + # Blanks out `//` comments, `\\` multiline-string lines and quoted + # string/char literals, keeping newlines. + defp zig_code_only(<<>>, acc), do: acc |> Enum.reverse() |> IO.iodata_to_binary() + defp zig_code_only(<<"//", rest::binary>>, acc), do: zig_code_only(skip_line(rest), acc) + defp zig_code_only(<<"\\\\", rest::binary>>, acc), do: zig_code_only(skip_line(rest), acc) + + defp zig_code_only(<>, acc) when q in [?", ?'], + do: zig_code_only(skip_literal(rest, q), [?\s | acc]) + + defp zig_code_only(<>, acc), do: zig_code_only(rest, [c | acc]) + + defp skip_line(rest) do + case :binary.split(rest, "\n") do + [_, after_newline] -> "\n" <> after_newline + [_] -> "" + end + end + + defp skip_literal(<<"\\", _escaped, rest::binary>>, q), do: skip_literal(rest, q) + defp skip_literal(<>, q), do: rest + defp skip_literal(<<"\n", _::binary>> = rest, _q), do: rest + defp skip_literal(<<_, rest::binary>>, q), do: skip_literal(rest, q) + defp skip_literal(<<>>, _q), do: "" + # ── Installed-but-inactive NIF plugins (MOB-281) ───────────────────────────── # # Same check `mix mob.deploy --native` prints: a dep shipping a NIF that isn't diff --git a/test/mix/tasks/mob_doctor_test.exs b/test/mix/tasks/mob_doctor_test.exs index f1dd150d..022f4bb2 100644 --- a/test/mix/tasks/mob_doctor_test.exs +++ b/test/mix/tasks/mob_doctor_test.exs @@ -131,6 +131,26 @@ defmodule Mix.Tasks.Mob.DoctorTest do refute Mix.Tasks.Mob.Doctor.__ios_build_root_stale__(migrated) end + + test "flags a use after a string holding //, and one split across lines" do + assert Mix.Tasks.Mob.Doctor.__ios_build_root_stale__(""" + const url = "https://example"; _ = b.build_root; + """) + + assert Mix.Tasks.Mob.Doctor.__ios_build_root_stale__(""" + var dir = b + .build_root.handle; + """) + end + + test "does not flag the name inside a string or a multiline string" do + refute Mix.Tasks.Mob.Doctor.__ios_build_root_stale__(""" + const note = "b.build_root was removed"; + const text = + \\\\see b.build_root + ; + """) + end end describe "__component_event_jni_mismatched__/1 (MOB-98 JNI owner check)" do From 376d85d9fd4754cfbc29c49d4c7efd8998b268bc Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 23 Oct 2026 19:16:59 -0600 Subject: [PATCH 4/9] CI: keep :requires_zig out of the Linux job (Mach-O nm, cold zig builds past 60 s) --- .github/workflows/test.yml | 19 +++++-------------- CHANGELOG.md | 4 +--- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 62c28f63..b887ddb9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,17 +40,6 @@ jobs: elixir-version: ${{ matrix.elixir }} otp-version: ${{ matrix.otp }} - # `:requires_zig` tests (StaticNifs ast-check, generated driver tables, - # zig_build) run against the pinned Zig, read from .tool-versions so CI - # can't drift from it. - - name: Zig version from .tool-versions - id: zig - run: echo "version=$(awk '$1 == "zig" {print $2}' .tool-versions)" >> "$GITHUB_OUTPUT" - - - uses: mlugg/setup-zig@v2 - with: - version: ${{ steps.zig.outputs.version }} - - name: Cache deps + _build uses: actions/cache@v4 with: @@ -84,9 +73,11 @@ jobs: # mob.adopt native tests resolve mob_new's templates from this checkout. MOB_NEW_DIR: ${{ github.workspace }}/mob_new # `:macos_only` — tests that shell out to /usr/libexec/PlistBuddy - # and similar macOS-bound tools. Runs by default on a maintainer's - # laptop; CI explicitly skips it. - run: mix test --exclude macos_only + # and similar macOS-bound tools. + # `:requires_zig` — the StaticNifs zig-ast-check round-trip; we + # don't install Zig in CI yet (it's a 30 MB download for one test). + # Both run by default on a maintainer's laptop; CI explicitly skips. + run: mix test --exclude macos_only --exclude requires_zig # MOB-201: `mix mob.doctor`'s Xcode / iOS SDK / iPhone Duo rows, run against # real Xcodes selected through DEVELOPER_DIR (the same resolution diff --git a/CHANGELOG.md b/CHANGELOG.md index 6144c49b..137b2f10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,7 @@ - **Zig pin is 0.17.0.** Replaces `0.17.0-dev.269+ebff43698` now that 0.17.0 is the stable with the macOS 26 SDK fix the nightly was taken for. `mix mob.doctor` / `mix mob.deploy --native` still require an exact - `zig version` match. Companion to mob and mob_new. CI now installs the - pinned Zig (read from `.tool-versions`) and runs the `:requires_zig` - tests. + `zig version` match. Companion to mob and mob_new. ### Fixed From 37dde407d003654d3839750f9c7cfc8bf47a5510 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 23 Oct 2026 19:22:49 -0600 Subject: [PATCH 5/9] Track the mob/ios Swift glob as a configure dependency; doctor and upgrade notes --- CHANGELOG.md | 20 ++++++----- lib/mix/tasks/mob.doctor.ex | 54 ++++++++++++++++++++-------- test/mix/tasks/mob_doctor_test.exs | 58 ++++++++++++++++-------------- 3 files changed, 84 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 137b2f10..803e1b3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,13 @@ ### Fixed -- **`mix mob.doctor` fails iOS `build.zig` files that still use `b.build_root`.** - Zig 0.17.0 removed `Build.build_root`; a file generated against the - nightly panics at configure with `no field named 'build_root'`. The - check ignores comments and strings and names the Swift-glob replacement - (`std.Io.Dir.openDirAbsolute`) from mob_new's templates. +- **`mix mob.doctor` checks the iOS Swift glob for Zig 0.17.0.** It fails + `ios/build.zig` / `ios/build_device.zig` that still use `b.build_root`, + which 0.17.0 removed (configure panics with `no field named + 'build_root'`). It warns when the glob opens `mob/ios` without + `b.dependOnDirectoryContents`: 0.17.0 caches the configure phase, so a + Swift file mob adds is missed until the zig cache is cleared. Comments and + strings are ignored. The fix names mob_new's template block. - **The Hex package ships `guides/` (MOB-501).** It shipped only the default files, so an app had no `deps/mob_dev/guides/` and the publishing and security-scan guides were only on hexdocs. @@ -27,9 +29,11 @@ - **Zig 0.17.0.** In each app, set `zig 0.17.0` in `.tool-versions` (`mise use zig@0.17.0` installs it and edits the file; `mise install zig@0.17.0` alone leaves the app on the nightly, and the native build - rejects it). Existing iOS apps must port `ios/build.zig` and - `ios/build_device.zig` off `b.build_root` (doctor fails until they do). - Android `build.zig` from current templates already configures on 0.17.0. + rejects it). Existing iOS apps must port the Swift glob in + `ios/build.zig` and `ios/build_device.zig`: `std.Io.Dir.openDirAbsolute` + instead of `b.build_root`, with `b.dependOnDirectoryContents` on the + directory first (doctor says how). Android `build.zig` from current + templates already configures on 0.17.0. ## [0.7.30] - 2026-10-23 diff --git a/lib/mix/tasks/mob.doctor.ex b/lib/mix/tasks/mob.doctor.ex index f3c45907..12241223 100644 --- a/lib/mix/tasks/mob.doctor.ex +++ b/lib/mix/tasks/mob.doctor.ex @@ -1046,27 +1046,53 @@ defmodule Mix.Tasks.Mob.Doctor do # Zig 0.17.0 removed `Build.build_root`. iOS templates generated against # 0.17.0-dev.269 still call `b.build_root.handle.openDir` to glob mob Swift - # sources; configure then exits with `no field named 'build_root'`. + # sources; configure then exits with `no field named 'build_root'`. 0.17.0 + # also caches the configure phase, so a glob without + # `b.dependOnDirectoryContents` misses a Swift file mob adds until the zig + # cache is cleared. + @swift_glob_fix "Before the Swift glob, add `b.dependOnDirectoryContents(.{ " <> + ".cwd_relative = b.fmt(\"{s}/ios\", .{mob_dir}) });`, and open " <> + "the directory with `std.Io.Dir.openDirAbsolute(...)`, not " <> + "`b.build_root.handle.openDir(...)`. Or copy the block from a " <> + "freshly generated app (mob_new's ios/build.zig.eex)." + defp check_ios_zig_build_root do for path <- ["ios/build.zig", "ios/build_device.zig"], {:ok, content} <- [File.read(path)], - __ios_build_root_stale__(content) do - {:fail, "zig 0.17.0 (#{path})", "uses b.build_root, which Zig 0.17.0 removed", - "In the Swift glob, replace `b.build_root.handle.openDir(...)` with " <> - "`std.Io.Dir.openDirAbsolute(...)` — see a freshly generated app " <> - "or mob_new's ios/build.zig.eex"} + status = __ios_swift_glob_status__(content), + status != :ok do + case status do + :build_root -> + {:fail, "zig 0.17.0 (#{path})", "uses b.build_root, which Zig 0.17.0 removed", + @swift_glob_fix} + + :untracked -> + {:warn, "zig 0.17.0 (#{path})", + "the mob/ios Swift glob isn't a configure dependency, so a Swift file " <> + "mob adds is missed until the zig cache is cleared", @swift_glob_fix} + end end end @doc false - # True when Zig code (not a comment or string) reads `build_root` off a - # `b` value, allowing whitespace and line breaks around the dot. - @spec __ios_build_root_stale__(String.t()) :: boolean() - def __ios_build_root_stale__(content) do - Regex.match?( - Regex.compile!("\\bb\\s*\\.\\s*build_root\\b"), - zig_code_only(content, []) - ) + # :build_root when Zig code (not a comment or string) reads `build_root` off + # `b`, whitespace and line breaks allowed around the dot; :untracked for a + # glob opened with openDirAbsolute but no dependOnDirectoryContents; else :ok. + @spec __ios_swift_glob_status__(String.t()) :: :ok | :build_root | :untracked + def __ios_swift_glob_status__(content) do + code = zig_code_only(content, []) + + cond do + Regex.match?(Regex.compile!("\\bb\\s*\\.\\s*build_root\\b"), code) -> + :build_root + + String.contains?(code, "openDirAbsolute") and + not String.contains?(code, "dependOnDirectoryContents") -> + :untracked + + true -> + :ok + end end # Blanks out `//` comments, `\\` multiline-string lines and quoted diff --git a/test/mix/tasks/mob_doctor_test.exs b/test/mix/tasks/mob_doctor_test.exs index 022f4bb2..52ffe729 100644 --- a/test/mix/tasks/mob_doctor_test.exs +++ b/test/mix/tasks/mob_doctor_test.exs @@ -106,50 +106,56 @@ defmodule Mix.Tasks.Mob.DoctorTest do end end - describe "__ios_build_root_stale__/1" do - test "flags the 0.17-dev glob that calls b.build_root" do - stale = """ - var mob_ios = b.build_root.handle.openDir(glob_io, b.fmt("{s}/ios", .{mob_dir}), .{ .iterate = true }) catch @panic("mob ios dir not found"); - """ + describe "__ios_swift_glob_status__/1" do + @tracked """ + b.dependOnDirectoryContents(.{ .cwd_relative = b.fmt("{s}/ios", .{mob_dir}) }); + var mob_ios = std.Io.Dir.openDirAbsolute(glob_io, b.fmt("{s}/ios", .{mob_dir}), .{ .iterate = true }) catch @panic("mob ios dir not found"); + """ - assert Mix.Tasks.Mob.Doctor.__ios_build_root_stale__(stale) + defp status(content), do: Mix.Tasks.Mob.Doctor.__ios_swift_glob_status__(content) + + test "the 0.17-dev glob that calls b.build_root fails" do + assert status(""" + var mob_ios = b.build_root.handle.openDir(glob_io, b.fmt("{s}/ios", .{mob_dir}), .{ .iterate = true }) catch @panic("mob ios dir not found"); + """) == :build_root end - test "does not flag the 0.17.0 glob" do - fixed = """ - var mob_ios = std.Io.Dir.openDirAbsolute(glob_io, b.fmt("{s}/ios", .{mob_dir}), .{ .iterate = true }) catch @panic("mob ios dir not found"); - """ + test "the 0.17.0 glob passes only when it declares the directory a configure dependency" do + assert status(@tracked) == :ok - refute Mix.Tasks.Mob.Doctor.__ios_build_root_stale__(fixed) - end + assert status(""" + var mob_ios = std.Io.Dir.openDirAbsolute(glob_io, b.fmt("{s}/ios", .{mob_dir}), .{ .iterate = true }) catch @panic("mob ios dir not found"); + """) == :untracked - test "does not flag a migration comment that names the old field" do - migrated = """ - // Replaced b.build_root.handle.openDir with std.Io.Dir.openDirAbsolute. - var mob_ios = std.Io.Dir.openDirAbsolute(glob_io, b.fmt("{s}/ios", .{mob_dir}), .{ .iterate = true }) catch @panic("mob ios dir not found"); - """ + assert status(""" + // b.dependOnDirectoryContents(.{ .cwd_relative = mob_ios_dir }); + var mob_ios = std.Io.Dir.openDirAbsolute(glob_io, mob_ios_dir, .{ .iterate = true }) catch @panic("x"); + """) == :untracked + end - refute Mix.Tasks.Mob.Doctor.__ios_build_root_stale__(migrated) + test "a migration comment that names the old field passes" do + assert status("// Replaced b.build_root.handle.openDir with openDirAbsolute.\n" <> @tracked) == + :ok end - test "flags a use after a string holding //, and one split across lines" do - assert Mix.Tasks.Mob.Doctor.__ios_build_root_stale__(""" + test "a use after a string holding //, and one split across lines, fail" do + assert status(""" const url = "https://example"; _ = b.build_root; - """) + """) == :build_root - assert Mix.Tasks.Mob.Doctor.__ios_build_root_stale__(""" + assert status(""" var dir = b .build_root.handle; - """) + """) == :build_root end - test "does not flag the name inside a string or a multiline string" do - refute Mix.Tasks.Mob.Doctor.__ios_build_root_stale__(""" + test "the name inside a string or a multiline string passes" do + assert status(""" const note = "b.build_root was removed"; const text = \\\\see b.build_root ; - """) + """) == :ok end end From 754a1e664689561c3560e735acfd7527a286725c Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 23 Oct 2026 19:47:23 -0600 Subject: [PATCH 6/9] Review round 3: dupeZ, doctor associates the dependency with mob/ios, CI lanes for the pinned Zig, test path without apostrophe --- .github/workflows/test.yml | 38 ++++++++++++++++++++++++++ lib/mix/tasks/mob.doctor.ex | 44 +++++++++++++++++++----------- test/mix/tasks/mob_doctor_test.exs | 5 ++++ test/mob_dev/zig_build_test.exs | 2 ++ 4 files changed, 73 insertions(+), 16 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b887ddb9..835af8d3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -135,6 +135,44 @@ jobs: - run: mix deps.get - run: mix test --only xcode_live test/mob_dev/xcode_live_test.exs + # `:requires_zig` tests compile generated driver tables and plugin NIFs with + # the pinned Zig (read from .tool-versions so CI can't drift from it). macOS, + # because the iOS objects are Mach-O and the test reads them with `nm`. + zig: + name: Zig (requires_zig) + runs-on: macos-26 + env: + MIX_ENV: test + HOMEBREW_NO_AUTO_UPDATE: '1' + steps: + - uses: actions/checkout@v4 + + - name: Install Elixir + run: | + brew install elixir + mix local.hex --force + mix local.rebar --force + + - name: Zig version from .tool-versions + id: zig + run: echo "version=$(awk '$1 == "zig" {print $2}' .tool-versions)" >> "$GITHUB_OUTPUT" + + - uses: mlugg/setup-zig@v2 + with: + version: ${{ steps.zig.outputs.version }} + + - name: Cache deps + _build + uses: actions/cache@v4 + with: + path: | + deps + _build + key: mix-zig-macos-26-${{ hashFiles('**/mix.lock') }} + restore-keys: mix-zig-macos-26- + + - run: mix deps.get + - run: mix test --only requires_zig + security_scan: name: Security scan needs: test diff --git a/lib/mix/tasks/mob.doctor.ex b/lib/mix/tasks/mob.doctor.ex index 12241223..41bf0b0d 100644 --- a/lib/mix/tasks/mob.doctor.ex +++ b/lib/mix/tasks/mob.doctor.ex @@ -1077,17 +1077,25 @@ defmodule Mix.Tasks.Mob.Doctor do @doc false # :build_root when Zig code (not a comment or string) reads `build_root` off # `b`, whitespace and line breaks allowed around the dot; :untracked for a - # glob opened with openDirAbsolute but no dependOnDirectoryContents; else :ok. + # glob opened with openDirAbsolute without the generated template's + # `b.dependOnDirectoryContents` on `"{s}/ios"` (a call on some other + # directory doesn't count); else :ok. @spec __ios_swift_glob_status__(String.t()) :: :ok | :build_root | :untracked def __ios_swift_glob_status__(content) do - code = zig_code_only(content, []) + code = zig_strip(content, false, []) + + tracked = + Regex.compile!( + "\\bb\\s*\\.\\s*dependOnDirectoryContents\\s*\\(\\s*\\.\\{\\s*\\.cwd_relative\\s*=" <> + "\\s*b\\s*\\.\\s*fmt\\s*\\(\\s*\"\\{s\\}/ios\"" + ) cond do Regex.match?(Regex.compile!("\\bb\\s*\\.\\s*build_root\\b"), code) -> :build_root String.contains?(code, "openDirAbsolute") and - not String.contains?(code, "dependOnDirectoryContents") -> + not Regex.match?(tracked, zig_strip(content, true, [])) -> :untracked true -> @@ -1095,16 +1103,19 @@ defmodule Mix.Tasks.Mob.Doctor do end end - # Blanks out `//` comments, `\\` multiline-string lines and quoted - # string/char literals, keeping newlines. - defp zig_code_only(<<>>, acc), do: acc |> Enum.reverse() |> IO.iodata_to_binary() - defp zig_code_only(<<"//", rest::binary>>, acc), do: zig_code_only(skip_line(rest), acc) - defp zig_code_only(<<"\\\\", rest::binary>>, acc), do: zig_code_only(skip_line(rest), acc) + # Blanks out `//` comments and `\\` multiline-string lines, keeping + # newlines. Quoted string/char literals are kept when `keep_strings`, else + # blanked. + defp zig_strip(<<>>, _keep, acc), do: acc |> Enum.reverse() |> IO.iodata_to_binary() + defp zig_strip(<<"//", rest::binary>>, keep, acc), do: zig_strip(skip_line(rest), keep, acc) + defp zig_strip(<<"\\\\", rest::binary>>, keep, acc), do: zig_strip(skip_line(rest), keep, acc) - defp zig_code_only(<>, acc) when q in [?", ?'], - do: zig_code_only(skip_literal(rest, q), [?\s | acc]) + defp zig_strip(<>, keep, acc) when q in [?", ?'] do + {literal, rest} = take_literal(rest, q, [q]) + zig_strip(rest, keep, [if(keep, do: literal, else: " ") | acc]) + end - defp zig_code_only(<>, acc), do: zig_code_only(rest, [c | acc]) + defp zig_strip(<>, keep, acc), do: zig_strip(rest, keep, [c | acc]) defp skip_line(rest) do case :binary.split(rest, "\n") do @@ -1113,11 +1124,12 @@ defmodule Mix.Tasks.Mob.Doctor do end end - defp skip_literal(<<"\\", _escaped, rest::binary>>, q), do: skip_literal(rest, q) - defp skip_literal(<>, q), do: rest - defp skip_literal(<<"\n", _::binary>> = rest, _q), do: rest - defp skip_literal(<<_, rest::binary>>, q), do: skip_literal(rest, q) - defp skip_literal(<<>>, _q), do: "" + # Consumes a literal after its opening quote; returns its text and the rest. + defp take_literal(<<"\\", e, rest::binary>>, q, acc), do: take_literal(rest, q, [e, ?\\ | acc]) + defp take_literal(<>, q, acc), do: {Enum.reverse([q | acc]), rest} + defp take_literal(<<"\n", _::binary>> = rest, _q, acc), do: {Enum.reverse(acc), rest} + defp take_literal(<>, q, acc), do: take_literal(rest, q, [c | acc]) + defp take_literal(<<>>, _q, acc), do: {Enum.reverse(acc), ""} # ── Installed-but-inactive NIF plugins (MOB-281) ───────────────────────────── # diff --git a/test/mix/tasks/mob_doctor_test.exs b/test/mix/tasks/mob_doctor_test.exs index 52ffe729..e04e8963 100644 --- a/test/mix/tasks/mob_doctor_test.exs +++ b/test/mix/tasks/mob_doctor_test.exs @@ -131,6 +131,11 @@ defmodule Mix.Tasks.Mob.DoctorTest do // b.dependOnDirectoryContents(.{ .cwd_relative = mob_ios_dir }); var mob_ios = std.Io.Dir.openDirAbsolute(glob_io, mob_ios_dir, .{ .iterate = true }) catch @panic("x"); """) == :untracked + + assert status(""" + b.dependOnDirectoryContents(.{ .cwd_relative = "some/other/directory" }); + var mob_ios = std.Io.Dir.openDirAbsolute(glob_io, b.fmt("{s}/ios", .{mob_dir}), .{ .iterate = true }) catch @panic("x"); + """) == :untracked end test "a migration comment that names the old field passes" do diff --git a/test/mob_dev/zig_build_test.exs b/test/mob_dev/zig_build_test.exs index 7a400dfa..a2e63e5e 100644 --- a/test/mob_dev/zig_build_test.exs +++ b/test/mob_dev/zig_build_test.exs @@ -82,6 +82,8 @@ defmodule MobDev.ZigBuildTest do describe "run/4 against a real zig build" do @describetag :requires_zig + # A cold zig cache (CI) compiles the target's libc and compiler_rt first. + @describetag timeout: :timer.minutes(5) @describetag :tmp_dir setup %{tmp_dir: dir} do From fd115805af98d2ded94f41eb9f557f9c3c4a892f Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 23 Oct 2026 19:55:28 -0600 Subject: [PATCH 7/9] CI zig job: restore the Xcode 26.6 lane's deps cache (cold vix download fails on macOS runners) --- .github/workflows/test.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 835af8d3..caf535f6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -161,14 +161,18 @@ jobs: with: version: ${{ steps.zig.outputs.version }} + # The Xcode 26.6 lane's cache (same runner, same MIX_ENV): a cold build + # fetches vix's precompiled NIF, and that download fails on macOS + # runners (httpc header_too_long), falling back to a source build that + # needs glib. - name: Cache deps + _build - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: | deps _build - key: mix-zig-macos-26-${{ hashFiles('**/mix.lock') }} - restore-keys: mix-zig-macos-26- + key: mix-macos-26-${{ hashFiles('**/mix.lock') }} + restore-keys: mix-macos-26- - run: mix deps.get - run: mix test --only requires_zig From 9e49663b599eb0458925b88bf49506abf225ce30 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 23 Oct 2026 20:00:13 -0600 Subject: [PATCH 8/9] Review nits: CI/Zig comments; zig test loop fails when it finds no files --- .github/workflows/test.yml | 12 ++++++------ test/mob_dev/static_nifs_test.exs | 5 ++--- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index caf535f6..70809ddc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -74,8 +74,8 @@ jobs: MOB_NEW_DIR: ${{ github.workspace }}/mob_new # `:macos_only` — tests that shell out to /usr/libexec/PlistBuddy # and similar macOS-bound tools. - # `:requires_zig` — the StaticNifs zig-ast-check round-trip; we - # don't install Zig in CI yet (it's a 30 MB download for one test). + # `:requires_zig` — run by the macOS `zig` job below, which has the + # pinned Zig and reads Mach-O objects with `nm`. # Both run by default on a maintainer's laptop; CI explicitly skips. run: mix test --exclude macos_only --exclude requires_zig @@ -161,10 +161,10 @@ jobs: with: version: ${{ steps.zig.outputs.version }} - # The Xcode 26.6 lane's cache (same runner, same MIX_ENV): a cold build - # fetches vix's precompiled NIF, and that download fails on macOS - # runners (httpc header_too_long), falling back to a source build that - # needs glib. + # The Xcode 26.6 lane's cache (same runner, same MIX_ENV). On a cold + # cache vix's precompiled downloads failed on this runner (NIF :enoent, + # then header_too_long fetching libvips), and the source fallback needs + # glib. This job depends on that lane keeping the cache warm. - name: Cache deps + _build uses: actions/cache/restore@v4 with: diff --git a/test/mob_dev/static_nifs_test.exs b/test/mob_dev/static_nifs_test.exs index 5c64788e..8dfb3185 100644 --- a/test/mob_dev/static_nifs_test.exs +++ b/test/mob_dev/static_nifs_test.exs @@ -359,9 +359,8 @@ defmodule MobDev.StaticNifsTest do assert out =~ "build_options" end - # The `zig` binary isn't installed in CI (we don't compile Zig in the - # Elixir test suite). Skip there via `mix test --exclude requires_zig`; - # local dev runs with Zig on PATH still execute this. + # Needs the pinned `zig` on PATH. CI's Linux job excludes :requires_zig; + # its macOS `zig` job runs these. @tag :requires_zig test "produces parseable Zig source (round-trip via zig ast-check)" do for platform <- [:ios, :android] do From b0c0448c4996f5fedefe0147ab70553db583e32c Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 23 Oct 2026 20:03:47 -0600 Subject: [PATCH 9/9] CI: setup-zig without its .zig-cache cache (cached configure results name the old zig path) --- .github/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 70809ddc..539b978f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -160,6 +160,9 @@ jobs: - uses: mlugg/setup-zig@v2 with: version: ${{ steps.zig.outputs.version }} + # A cached .zig-cache holds configure results that name the zig + # binary by its absolute path, which changes every run. + use-cache: false # The Xcode 26.6 lane's cache (same runner, same MIX_ENV). On a cold # cache vix's precompiled downloads failed on this runner (NIF :enoent,