Repository navigation
Commit a959493
feat(cli): os migrate meta --write — write the chain's mechanical edits into the authored sources (#22108)
Refs #9591 (os migrate meta --write; the retirement-sentence restoration
goes to the spec lane)
Clause-②: yes
## What this adds
`os migrate meta --from N --write` writes the chain's **mechanical**
changes into the authored source files in place, at every site it can
prove is one literal in one project file. It lists every other
mechanical change with the reason it was not written. It never writes a
semantic TODO. Without `--write` nothing changes: the dry run, its
report and its `--json` payload are what they were, and `--out` still
writes its snapshot.
New module: `packages/cli/src/utils/authored-source-codemod.ts` (it uses
the `ts` compiler namespace that `ts-morph` re-exports, the same way
`emitted-source-parses.ts` does). The command change is in
`packages/cli/src/commands/migrate/meta.ts`.
## The widened surface, stated for the contract review
- **The flag.** `--write` is a boolean that defaults to false, on the
authored-source mode only. It declares `exclusive: ['stored']`, so `os
migrate meta --stored --write` is refused by oclif's flag validation
before any work, with exit 2 and `--stored=true cannot also be provided
when using --write`. This is the same mechanism as `--out` / `--from` /
`--to` / `--step`. `--stored` / `--apply` are untouched.
- **What it writes.** It writes each entry of the chain's `applied` set
at a site it traces to one object or array literal in one project file.
The trace goes through `define*` calls and the `.create(…)` factories of
`@objectstack/spec` exports, module-level `const` bindings, relative
imports and re-exports (named and `export *`), and `Object.values()`
over a namespace import, in the module namespace's sorted order. A site
is written only when three things hold:
1. the loaded value matches the literal's statically known values;
2. every binding crossed on the way has no reference other than the
walk's own;
3. the file is under the config's directory and not under
`node_modules`.
- **How it writes.** Edits are text splices at node positions, so every
byte outside an edited site stays as it was: comments, formatting and
key order. A renamed key keeps its value text and its comments. A
removed key takes its own line(s), including a comment on those same
lines; comment lines of their own are kept. A new key is appended after
the last key, on its own line or inline, matching the literal.
- **What it refuses.** Each refused change is listed under `--json`
`write.manual[]` as `{ conversionId, path, kind, reason }`, and in the
human report as `not written [kind]: reason`. The kinds form a closed
set:
- `computed`: the value is an expression;
- `helper`: the value is built by a call that is not a `define*` helper
or a spec `.create`;
- `spread`: a spread supplies the key or may override it;
- `shared`: a binding on the way is referenced elsewhere;
- `outside-project`: a package, a path alias, or a file outside the
project;
- `mismatch`: the loaded value disagrees with the literal;
- `injected`: the loader supplied the key, such as a map-form
collection's `name`;
- `unspellable`: no literal spelling exists for the new value;
- `layout`: a comment or other code shares the site's line, or the edit
would not parse;
- `unattributed`: no edit could be tied to the entry;
- `entangled`: a conversion's edits are written whole or not at all.
- **What it never writes.** It never writes a semantic TODO (`todos`),
because the planner is not even handed them. It never writes a site a
conversion declines, because a declined site produces no mechanical
change.
- **Fail-closed.** It writes nothing (`status: 'unwritten'`, exit 1)
when a file changed on disk after it was read. After writing, it
re-loads the config and re-runs the chain. Unless the re-run applies
exactly the changes it left (matched by `path (conversionId)`), it
restores every written file to its previous bytes (`status: 'restored'`,
exit 1).
- **Output.** The human report gains one group after the semantic
notices and before the data-migration advice, which stays last: `Wrote N
of M mechanical change(s) into K file(s):`, per file and line, then the
list left to apply by hand, then the re-run verdict. `--json` gains a
`write` key only with `--write`: `status`, `files`, `written`, `manual`,
`unexplained`, `verification`, and `error` when there is one.
- **Text (H8).** The help text, the printed group and the docs say "in
place" and "traced to one literal". None of them says "automatically",
and the pins hold that.
## PM readings, measured (on `3d918850`; re-run on the merged tree
`b1799bf8`)
- **H1, confirmed.** On a fixture project that carries 9 retired sites
across 7 per-artifact modules, `os migrate meta --from 16 --out
snap.json` printed `Applied 9 mechanical change(s):` with one line per
site. The md5 of every source file was identical before and after the
run. The only file written was the snapshot.
- **H2, confirmed, and it is the design.** An `applied` entry carries `{
toMajor, conversionId, surface, from, to, path }`. Its `from` / `to` are
display strings, not edits (`striped` → `(removed)`, `'previousPeriod'`
→ `{ kind: 'previousPeriod' }`). Its `path` points into the normalised
stack. Its anchor differs per conversion: the renamed-to key, the
removed key, or a container (`path: 'api'` for `api.requireAuth`). So
the edit is taken from a structural diff of the stack the chain started
from against the stack it produced. Each change is tied back to the
entries whose paths explain it, under three ordered rules: (1) the site,
a key inside it, or a container around it; (2) a sibling key, which is a
rename's old key; (3) a move between two containers of one subject.
Writing then requires the proof above, and every refusal class met is in
the list above.
- **H3, confirmed on fixtures.** Every written file equals its old bytes
with only the attributed sites edited, asserted byte for byte for every
file in the project, barrels included. A second run applies 0 mechanical
changes and writes nothing.
- **H4, confirmed.** Semantic TODOs are never written. With `--write`,
`todos` lists exactly what the dry run of the same build lists. Per the
seat's heads-up about #22072, this is pinned relative to the dry run and
not as a fixed listing.
- **H5.** The convention followed is the house convention for
**source-file writers**, not the `--stored --apply` prompt. `os i18n
extract --out`, `os datasource introspect` and `os generate` take their
flag as the opt-in, with no prompt. The `confirm()` + `--yes` prompt
belongs to row rewrites, where a write mints a checksum and a history
entry. It is also unreachable here: `--yes` is a `--stored`-only flag
that `storedOnlyFlagsIn` refuses outside `--stored`, and that code is
off limits to this card. The fail-closed re-run is the safety net
instead. `--write --stored` is refused, as the PM leaned.
- **H6, confirmed.** A key is stripped (`view.list.striped`,
`agent.knowledge`, `flow.active`, `object.tenancy.organizationField`). A
value is rewritten: `datasource.driver` is a scalar to a scalar, and
`compareTo: 'previousPeriod'` becomes `{ kind: 'previousPeriod' }`, a
scalar to an object. A key is renamed (`refreshInterval` →
`refreshIntervalSeconds`, value and trailing comment kept). A key is
added (`config: { mode: 'inclusive' }` on decision nodes, on its own
line and inline). The partial case: `compareTo: { offset: '1y' }` is
written as `{ kind: 'previousYear' }`. The uncovered `{ offset: '7d' }`
produces no mechanical change, so `--write` leaves its bytes as they
are. The schema verdict still refuses it and the semantic notice still
names it, exactly as before.
- **H7, confirmed.** The module names no conversion. A pin reads the
module source and asserts that none of the chain's conversion ids
appears in it. #5082 and #13458 conversions are covered when they land.
- **H8, confirmed** (see "Text" above). One more sentence changed for
H8: the stored-only refusal said the authored-source mode "writes
nothing but --out". It now says it writes "only the --out snapshot and,
with --write, the authored sources".
## Evidence
- **Real-app round trip.** A copy of `examples/app-crm` at `b1799bf8`
was back-dated in 3 sites over 2 files: a view `striped: true` reached
through a named-re-export barrel and `Object.values(views)`, and two
dashboard `compareTo` values in a typed plain literal. `--from 16
--write` wrote all 3, and the re-run verdict was `ok`. All 32 source
files then compared byte-identical to `examples/app-crm` at `b1799bf8`.
The unmodified copy reports `applied: 0` and writes nothing.
- **Pins** (`packages/cli/test/migrate-meta-write.test.ts`, unit tier,
in-process, temp projects that link the real `@objectstack/spec`). They
cover:
- exactness over every file;
- idempotence;
- the applied set as the written-or-left set;
- todos as listed by the dry run, and the declined `7d` arm untouched;
- the add shape;
- the dry-run and `--out` controls;
- both failure exits (unwritten, and restored to identical bytes);
- every refusal kind: 6 kinds over 7 sites of a real project through the
command, and 5 more through the planner;
- `--stored --write` refused;
- the help text;
- the no-conversion-id pin.
- **Ablations**, run from the committed state through
`scripts/ablation-replace.mjs`, each restored with the blob equal to
HEAD and `git diff HEAD` empty:
1. Disabling the shared-binding check turned 3 of the refusal pins red:
the shared literal was written.
2. Deleting only a member's node text, not its own lines, turned 3
exactness and idempotence pins red. The parse guard refused the broken
edits, so the written set shrank.
## Semver
`@objectstack/cli: minor`. A new flag is additive, and `Clause-②: yes`
requires at least `minor`. `main` is not in Changesets pre mode:
`origin/main` at `bafb58bb` has no `.changeset/pre.json`.
`check-changeset-no-major` is green.
## Acceptance notes
- **The retirement sentence** in
`packages/spec/src/shared/retired-key.ts` is not touched. It belongs to
the spec lane, per the claim. Its current wording, "list the mechanical
edits for existing sources; apply them by hand", stays true and only
undersells `--write`.
- **`skills/objectstack-upgrade/SKILL.md`** still says the command
"writes nothing but `--out`" in its troubleshooting row. That stays true
for the default invocation and undersells `--write`. It is a Tier H
governed surface, so it is not touched here. Carrier: the spec lane's
sentence-restoration PR, or the skills owner.
- **The authored mode has no row in `content/docs/deployment/cli.mdx`.**
Only `--stored` does. The authored mode's flag table lives in
`content/docs/upgrading.mdx`, so `--write` was added there, and the
`cli.mdx` paragraph on the two modes now names it.
- **Measured limits of the walk, all in the refusing direction:**
- a tsconfig `paths` alias is refused as `outside-project`;
- a value reached only through a dynamic `import()` or a `require()` is
not read;
- the shared-binding count reads the files in the static import graph.
- The post-write re-run is the backstop for convergence.
## Deviation, stated rather than taken silently
`content/docs/upgrading.mdx` is outside the claim's file surface. It
said "`os migrate meta` does not rewrite your source files" and called
`--out` "the only file the command writes". This change makes both
sentences false. `os-dev.md` requires fixing a published sentence the
change makes false, and it wins over the dispatch on conflict. So the
callout, the flags table and the loop comment were corrected, and the
conflict is reported to the seat.
## Local verification (head `be29d66f` unless noted)
- `pnpm --filter @objectstack/cli exec vitest run --project unit` at
`b1799bf8`, the merged tree: 260 files, 3815 tests passed.
- At `be29d66f`: the 4 meta test files passed, 52 tests
(`migrate-meta-write` 23, `meta.stored-flags`, `meta.report-order`,
`migrate-meta-strict-factories`).
- `pnpm --filter @objectstack/cli typecheck`: exit 0, including
`check:test-typecheck`.
- The integration tier is declared to CI. The diff touches no
integration-tier file and no spawn entry.
- The derived gate union (`node scripts/pm/dispatch-gates.mjs
--commands`) at `be29d66f`: 96 commands, all exit 0. `--ran`
reconciliation: `96 derived, 96 run, 0 NOT-MEASURED, 0 UNRUN`. The PM's
list plus `pnpm check:docs-image-tag` was added by derivation.
- `pnpm lint` (the full `eslint . --no-inline-config`) at `be29d66f`:
exit 0.
- One earlier gate pass was discarded because it ran while a build was
rewriting `packages/spec/dist`. Its three exit-3 results and one exit-1
result were build-in-flight artefacts, NOT MEASURED. Every number above
is from the clean re-run.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent a543e24 commit a959493
6 files changed
Lines changed: 2764 additions & 14 deletions
File tree
- .changeset
- content/docs
- deployment
- packages/cli
- src
- commands/migrate
- utils
- test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1430 | 1430 | | |
1431 | 1431 | | |
1432 | 1432 | | |
1433 | | - | |
| 1433 | + | |
| 1434 | + | |
1434 | 1435 | | |
1435 | 1436 | | |
1436 | 1437 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
215 | 215 | | |
216 | 216 | | |
217 | 217 | | |
218 | | - | |
| 218 | + | |
| 219 | + | |
219 | 220 | | |
220 | 221 | | |
221 | 222 | | |
| |||
226 | 227 | | |
227 | 228 | | |
228 | 229 | | |
229 | | - | |
230 | | - | |
231 | | - | |
232 | | - | |
233 | | - | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
234 | 246 | | |
235 | 247 | | |
236 | 248 | | |
| |||
274 | 286 | | |
275 | 287 | | |
276 | 288 | | |
277 | | - | |
| 289 | + | |
| 290 | + | |
278 | 291 | | |
279 | 292 | | |
280 | 293 | | |
| |||
0 commit comments