MigrationPlan
The actionable migration plan the {@see MigrationAdvisor} derives from a frozen {@see \Milpa\Resolver\Report\ResolutionReport} — spec §14's `milpa migrate --plan`: it changes nothing, it only proposes. A pure value object: the report's `status` verbatim plus one entry per package with actionable work — what was `detected`, the `recommended` targets, the numbered `steps` (1..n, the LAST always re-running `coa:inspect architecture`), the honest `compatibility` line, and the live bilingual `academy` links the report's learnable errors already carry. {@see toArray()} serializes to the FROZEN plan shape (drift-locked by `tests/Advisor/MigrationPlanShapeContractsTest` and the README "Migration plan shape" tables): `{status, packages: [...], summary: {packages, actions}}`, in that exact key order. The `summary` is DERIVED here on every serialization — `packages` is the package count and `actions` the total step count — so it can never disagree with the packages it summarizes. A report with nothing actionable serializes as the VISIBLE empty plan (`packages: []`, summary `0/0`), never a null: nothing to migrate is a stated fact.
MigrationPlan::__construct()
public function __construct(string $status, array $packages = []):Parameters
| Name | Type | Description |
|---|---|---|
| $status | string | The originating report's status, verbatim — the plan never invents a fifth verdict. |
| $packages | list<array<string, mixed>> | One frozen entry per package with actionable work, sorted by package name; a package with nothing actionable never appears. |
MigrationPlan::toArray()
public function toArray(): arraySerialize to the frozen plan shape with a fixed, deterministic key order; the `summary` counts are re-derived from the packages on every call, so they can never drift.