diff --git a/AGENTS.md b/AGENTS.md index bfe86e6..493f999 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -525,6 +525,12 @@ Real fix: `sim_runtime_dir/0` should detect `ios/build.zig` and use shipping needs the user to wire the archive into ios/build.zig + android/jni/ themselves. Reach for `--type c` if static linking matters more than the source language. +- **Generated files that print terms with `inspect` must pass + `custom_options: [sort_maps: true]`.** On OTP 26+ a map prints its keys in + atom-creation order, which differs between VMs, so a committed file that a + `--check` re-renders and compares byte-for-byte reports drift right after a + regen (MOB-383: `RuntimeManifest.render/1`, `priv/generated/mob_plugins.exs`). + Text built by iterating a map needs an explicit sort for the same reason. - **`mob.regen_driver_tab` reads `:static_nifs` from `mob.exs`** via `Config.Reader`, NOT from `Application.get_env`. mob.exs is not auto-imported into Mix application env (this matches every other diff --git a/CHANGELOG.md b/CHANGELOG.md index a7f97ab..a82a73e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## [Unreleased] + +### Fixed + +- **`mix mob.regen_plugin_manifest --check` no longer reports drift right + after a regen (MOB-383).** `priv/generated/mob_plugins.exs` printed each + map's keys in atom-creation order, which on OTP 26+ differs between VMs, so + the file a deploy wrote and the one `--check` (and `mix mob.doctor`) + rendered could differ in key order alone. Every map in the file, nested + ones included, is now printed with sorted keys, so the same plugins always + produce the same bytes. **Upgrading:** the first regen after updating + rewrites a committed `mob_plugins.exs` with its keys reordered (no content + change); run `mix mob.regen_plugin_manifest` once (or let the next deploy do + it) and commit the result, or `--check` reports drift until you do. + ## [0.7.25] - 2026-10-13 ### Added diff --git a/lib/mob_dev/plugin/runtime_manifest.ex b/lib/mob_dev/plugin/runtime_manifest.ex index 93e5a6a..2beb5f2 100644 --- a/lib/mob_dev/plugin/runtime_manifest.ex +++ b/lib/mob_dev/plugin/runtime_manifest.ex @@ -164,15 +164,29 @@ defmodule MobDev.Plugin.RuntimeManifest do @doc """ Renders the manifest map to the contents of `priv/generated/mob_plugins.exs` — a self-describing `.exs` that evaluates back to the map. + + Every map, nested ones included, is printed with its keys sorted, so the same + manifest renders to the same bytes in any VM. Without that, a map's printed key + order follows atom-creation order (OTP 26+), which differs between runs, and + `mix mob.regen_plugin_manifest --check` reported drift right after a regen + (MOB-383). """ @spec render(map()) :: String.t() def render(manifest) do + terms = + inspect(manifest, + limit: :infinity, + printable_limit: :infinity, + pretty: true, + custom_options: [sort_maps: true] + ) + """ # Generated by `mix mob.regen_plugin_manifest` — do not edit by hand. # # The activated plugins' tier-3/4 contributions, read at boot by Mob.Plugins. # Regenerated whenever `config :mob, :plugins` changes (the deploy/regen hook). - #{inspect(manifest, limit: :infinity, printable_limit: :infinity, pretty: true)} + #{terms} """ end diff --git a/test/mob_dev/plugin/runtime_manifest_test.exs b/test/mob_dev/plugin/runtime_manifest_test.exs index 3ed9202..9e62c33 100644 --- a/test/mob_dev/plugin/runtime_manifest_test.exs +++ b/test/mob_dev/plugin/runtime_manifest_test.exs @@ -32,6 +32,54 @@ defmodule MobDev.Plugin.RuntimeManifestTest do defp base(extra), do: Map.merge(%{name: :p, mob_version: "~> 0.6", plugin_spec_version: 1}, extra) + # A manifest with every map-bearing section, each map keyed partly by `a` and + # `z` (atoms the caller creates). Keys are written in sorted order here; + # @reverse_order_writer builds the same manifest with every key reversed. + defp every_section_manifest(a, z) do + %{ + composites: [%{:atom => :card, :expand => {P.Card, :expand}, :plugin => :p, a => 1, z => 2}], + default_font: %{:android => "inter_regular", :ios => "Inter", a => 1, z => 2}, + default_style: :brand, + lifecycle: [%{:on_start => {P, :start, []}, :plugin => :p, a => 1, z => 2}], + nifs: [P.Nif], + notification_handlers: [ + %{:handler => {P, :h, 1}, :match => %{:type => "t", a => 1, z => 2}, :plugin => :p} + ], + plugins: [:p], + screens: [%{:default_route => "/p", :module => P.Home, :plugin => :p, a => 1, z => 2}], + settings: [ + %{:plugin => :p, :schema => [%{:default => 3, :key => :x, a => 1, z => 2}]} + ], + styles: [%{:name => :brand, :theme => %{:colors => %{a => 1, z => 2}, a => 1, z => 2}}] + } + end + + # Run in a fresh VM: creates `z` before `a` and writes every map literal in + # reverse key order, so each atom's creation order is the reverse of this + # VM's. + @reverse_order_writer """ + [root, a_name, z_name] = System.argv() + z = String.to_atom(z_name) + a = String.to_atom(a_name) + + manifest = %{ + styles: [%{:theme => %{z => 2, a => 1, :colors => %{z => 2, a => 1}}, :name => :brand}], + settings: [%{:schema => [%{z => 2, a => 1, :key => :x, :default => 3}], :plugin => :p}], + screens: [%{z => 2, a => 1, :plugin => :p, :module => P.Home, :default_route => "/p"}], + plugins: [:p], + notification_handlers: [ + %{:plugin => :p, :match => %{z => 2, a => 1, :type => "t"}, :handler => {P, :h, 1}} + ], + nifs: [P.Nif], + lifecycle: [%{z => 2, a => 1, :plugin => :p, :on_start => {P, :start, []}}], + default_style: :brand, + default_font: %{z => 2, a => 1, :ios => "Inter", :android => "inter_regular"}, + composites: [%{z => 2, a => 1, :plugin => :p, :expand => {P.Card, :expand}, :atom => :card}] + } + + MobDev.Plugin.RuntimeManifest.write(root, manifest) + """ + describe "build/1" do test "combines static and generated screens, each tagged with its plugin" do plugins = [ @@ -236,6 +284,46 @@ defmodule MobDev.Plugin.RuntimeManifestTest do {evaluated, _} = Code.eval_string(RuntimeManifest.render(manifest)) assert evaluated == manifest end + + # MOB-383: a map prints its keys in atom-creation order (OTP 26+), so a VM + # that created the atoms in another order rendered other bytes and + # `mix mob.regen_plugin_manifest --check` reported drift right after a regen. + # A fresh VM writes the manifest after creating every atom in reverse; this + # VM, which created them in forward order, runs the check's comparison. + test "a manifest written by a VM with another atom order passes the --check comparison" do + id = System.unique_integer([:positive]) + {a_name, z_name} = {"mob383_alpha_#{id}", "mob383_zeta_#{id}"} + a = String.to_atom(a_name) + z = String.to_atom(z_name) + manifest = every_section_manifest(a, z) + + root = Path.join(System.tmp_dir!(), "mob_rtm_order_#{id}") + on_exit(fn -> File.rm_rf!(root) end) + + elixir = System.find_executable("elixir") || flunk("elixir not on PATH") + + {out, status} = + System.cmd( + elixir, + [ + "-pa", + Application.app_dir(:mob_dev, "ebin"), + "-e", + @reverse_order_writer, + root, + a_name, + z_name + ], + stderr_to_stdout: true + ) + + assert status == 0, out + path = Path.join([root, "priv", "generated", "mob_plugins.exs"]) + + {written, _} = Code.eval_file(path) + assert written == manifest + assert File.read!(path) == RuntimeManifest.render(manifest) + end end describe "write/1" do