Skip to content

Commit 9f0de32

Browse files
fix(cli): os migrate meta --write rewrites the declared protocol range the load still refuses (#22252)
Fixes #22219 Clause-②: no ## What changed `os migrate meta --from N --write` now rewrites the manifest's declared protocol range when the load would still refuse the migrated source under it. Before, it wrote the chain's mechanical edits and left `engines: { protocol: '^16' }` as it was, so the load refusal that names this command as its remedy came straight back. - **Which range:** `--to`, capped at the protocol this runtime implements, in the scaffold's spelling (`'^17'` on this build). See H5 for why the cap is needed. - **Where:** in place, under the key the handshake read the range from (`engines.protocol`, else `engines.platform`, else the legacy `engine.objectstack`). See H2. - **How:** the edit rides the chain's own plan, write and re-check. The planner treats it as one more applied entry with one more change. A re-check that disagrees restores it with every other file, and a range edit that does not land is caught by that same re-check. See H3. - **Nothing to convert:** the range edit is written on its own. See H4. - **Left alone:** a range the load admits (`'>=16'`), an absent or unparsable range, and a range at or above the target (a range is never lowered). A range that `--from` starts above is also left, with a line naming the `--from` that would move it. - **Output:** the human report names the file, the line and the from → to text. A dry run names the edit `--write` would make. `--json` reports the edit as `write.range`, a NEW key (see H6). ## Measured on `main` `7b926f76` (H1, premise holds) The CLI was built from that tree and each load went through `os serve`, with `--from 16`: - **Fixture A:** `^16` plus a tombstoned `views[0].list.striped`. - Before: the schema refuses it. `✗ views.0.list.striped: … Run os migrate meta --from 16 …` - `--write` reported `Wrote 1 of 1 mechanical change(s) into 1 file(s)`. The reload was then refused: `✗ package 'com.example.h1' targets protocol ^16 (engines.protocol) but this runtime is protocol 17.0.0. This is a major-version break. Run: objectstack migrate meta --from 16` - **Fixture B:** `^16` with nothing to convert. - Before: the same refusal at the handshake. - `--write` reported `--write: the chain made no mechanical change here, so no file was written.` The reload returned the same refusal. ## The PM's readings, measured - **H2: the three homes.** - No chain step reads or writes any of the three keys (no conversion or migration entry names `engines` or `engine`). - The schema accepts all three (`engines.platform: '^16'` and `engine.objectstack: '^16.0.0'` both parse). - The handshake still reads each one when `engines.protocol` is absent. Both are refused with `migrateCommand: objectstack migrate meta --from 16`. - Moving a range between keys would be a conversion the chain has not declared. The key the handshake read is the key it reads again on reload. So the edit is **in place**, and both legacy homes are pinned. - One measured wrinkle: `engine.objectstack: '^17'` is refused at parse (`invalid_format`, because the schema wants a full version). That home is therefore written `'^17.0.0'`. - **H3: the plan.** The range is a `RangeRewrite` input to `planAuthoredSourceWrite`. It joins as the last entry and is reported apart (`plan.range`). `verifyAuthoredSourceWrite` takes the range the written sources still owe: - when the range was written, it must be absent from the re-run; - when the range was left, it must still be there. The restore case is pinned. Its error reads `still converted: manifest.engines.protocol (declared protocol range)`. - **H4: no conversions.** Measured above (`no file was written`). The range edit is now written alone, and the report says so. - **H5: which range.** - `os init`, the `create-objectstack` blank template and `os lint`'s fix all write `'^' + PROTOCOL_MAJOR`, and that form is used here. A range that already admits the target (`'>=16'`) is pinned as a no-op. - **`--to`:** on this build `--to` defaults to 18 (the chain's terminus) while the runtime implements 17. - A literal `'^18'` is refused by this runtime. Measured through `os serve`: `targets protocol ^18 … Run: objectstack migrate meta --from 18`. So the written major is `min(--to, PROTOCOL_MAJOR)`: `--to` above the runtime writes the runtime's major, and `--to` below it writes `--to`. - **H6: `--json`.** The edit does not fit the existing `write.written` / `write.manual` lists. Those list exactly the chain's `applied` entries, an invariant already pinned (`writes the applied set and nothing else`), and the range is not one of them. It is reported as a **new key**, `write.range`: `status`, `path`, `from`, `to`, plus `file` / `line` when written or `kind` / `reason` when left. Per the claim, the seat amends the Clause-② line if this key needs it; line 2 above is `no` as dispatched. A dry run's `--json` carries no range, because that would be a new top-level key. ## Pins (`packages/cli/test/migrate-meta-protocol-range.test.ts`, unit tier) The pins run in-process over `MigrateMeta.run`, and each reload goes through the load's own door: the CLI's `loadConfig` (whose `defineStack` runs the schema), then `AppPlugin.init` (whose handshake runs before the manifest registers). - `--from 16` with a converted key: - Control: the load refuses it (`STACK_SCHEMA_INVALID`, naming `migrate meta --from 16`). - The dry run names the edit and writes nothing. - `--write` rewrites the range and the key, and only those bytes change. - The reload is **not refused**. - `--from 16` with nothing to convert: - Control: the load refuses it at the handshake (`OS_PROTOCOL_INCOMPATIBLE`, status 422, `migrateCommand: objectstack migrate meta --from 16`). - `--write` writes the range alone and says so. - The reload is not refused. - `'>=16'` is left byte for byte, and `write.range` is absent. - Legacy homes: `engines.platform` and `engine.objectstack` are rewritten in place, and each reload is not refused. - `--to` is capped at the runtime's major. A `--from` above the range is left, and the report names `--from 16`. The planner never lowers a range, and never touches an absent, unparsable, admitted or non-string range. - Codemod: - a forced re-check mismatch restores the range with the chain's edit; - a range edit that does not land is caught and everything is restored; - a shared range literal is refused (`shared`) while the chain's edit beside it is written. - The 17 → 18 shape is NOT added here (it waits for #22215). It is one more `SHAPES` row: ```ts { from: 17, member: "dashboards: [{ name: 'ops', label: 'Ops', widgets: [], refreshInterval: 300 }]", converted: ['dashboard-refresh-interval-to-refresh-interval-seconds'], before: 'refreshInterval: 300', after: 'refreshIntervalSeconds: 300' }, ``` ## Ablation (one-off, `meta.ts` restored to a blob equal to HEAD `ecbcd924`, `git diff HEAD` empty) The test imports `meta.ts` relatively, so it resolves to source and no rebuild applies. Each mutation went through `scripts/ablation-replace.mjs`, which reported `anchor 1 → 0` and a changed blob. Both legs ran with a trap that restores from HEAD. - **A: the range taken out of the plan.** The `range` argument to `planAuthoredSourceWrite` was deleted. - Result: **8 failed, 6 passed.** - With a converted key, the re-check caught the missing range and restored every file. The reload is then red with the schema refusal, because the write was undone. This is a different direction from B, and it is the H3 guarantee doing its job. - With nothing to convert, nothing is written. - **B: no range owed at all**, which reproduces the world before this fix. - Result: **11 failed, 3 passed.** - The reload pin turns red with exactly the measured refusal: `ProtocolIncompatibleError … targets protocol ^16 (engines.protocol) but this runtime is protocol 17.0.0 … Run: objectstack migrate meta --from 16`. ## Verification (at `9578a2af`, `main` `f4bed583` merged in) - New pin file: 14/14 passed. It is a member of the unit project (`vitest list --project unit`). - `@objectstack/cli` unit project: **264 files, 3888 tests passed**. - The `migrate meta` and codemod files (7 files): 75 passed, plus 1 pre-existing `skipIf` skip. - `pnpm --filter @objectstack/cli typecheck`: exit 0. The `tsc --noEmit` and `check:test-typecheck` steps both pass, and the new test is in `tsconfig.test.json`'s program. - `pnpm lint` (full `eslint . --no-inline-config`): exit 0. - `dispatch-gates --ran`: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN. The list is identical to the dispatched one. - `check:dual-build-cjs-loads` and `check:i18n-coverage` first exited 3 (PREREQUISITE NOT MET: dist absent for packages outside the CLI closure). Re-run once those dist trees existed, both exit 0. - Integration tier: declared to CI. The diff touches no integration-tier file and no spawn entry. ## Acceptance notes - **Docs.** `content/docs/upgrading.mdx` (the `--write` row and callout) and `skills/objectstack-data` (`--write applies the proven sites`, landed in #22199) do not mention the range rewrite. Both are outside this card's file surface (`content/docs`) or a governed surface (`skills/**`). - **`--out` snapshot.** The `--out` snapshot (`result.stack`) keeps the authored range. The docs already call it an oracle to diff against, not a file to ship. - **Duplicate narrowing.** `handshakeSlice` in `meta.ts` repeats the type narrowing that `toHandshakeManifest` in `utils/protocol-version-gap.ts` does. That file is not on this card's surface, so it was not exported from there. - **Load-path conversions are never written.** A key that a load-path conversion converts (`retiredFromLoadPath: false`, e.g. `driver: 'mongo'`) in a stack the schema otherwise accepts is converted by `defineStack` during the authored-source load. The chain therefore never sees it, and `--write` never writes it. Measured: `--from 16 --write --json` gives `applied: []` while the source keeps `'mongo'` and the load warns. Reported to the PM; not changed here. --- _Generated by [Claude Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3513ac7 commit 9f0de32

4 files changed

Lines changed: 895 additions & 45 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `os migrate meta --from N --write` rewrites the declared protocol range the load refuses (#22219)
6+
7+
Clause-②: no
8+
9+
The load refuses a manifest whose `engines.protocol` excludes the runtime's protocol major, and the refusal names `objectstack migrate meta --from N` as the command that resolves it. `--write` wrote the chain's mechanical changes and left `engines: { protocol: '^N' }` as it was, so an author who followed the refusal, wrote, and reloaded got the same refusal back. A manifest with nothing to convert was not written at all.
10+
11+
- `--write` now also rewrites the declared range when the load would still refuse the migrated source under it, in the scaffold's spelling: `'^16'` → `'^17'` on a protocol-17 runtime. It rides the same plan, write and re-check as the chain's changes: a range literal the command cannot trace to one site is left with the reason, and a re-check that disagrees restores it with every other file.
12+
- A manifest with nothing to convert gets the range edit alone.
13+
- The major is `--to`, capped at the protocol this runtime implements. `--to` defaults to the highest major this build carries a step for, which can run ahead of the runtime: on a protocol-17 build, `--from 16` replays 16 → 18 and writes `'^17'`.
14+
- The range is rewritten under the key the load read it from: `engines.protocol`, else `engines.platform`, else the legacy `engine.objectstack` (written as `'^17.0.0'`, the only form that key accepts).
15+
- Left alone: a range the load already admits, an absent or unrecognised range, a range at or above the target major, and a range below `--from` (the report names the `--from` that moves it).
16+
- The dry run (no `--write`) names the range edit `--write` would make. With `--json`, `write.range` reports the edit: `status`, `path`, `from`, `to`, and `file` / `line` when written or `kind` / `reason` when left.

0 commit comments

Comments
 (0)