Skip to content

Commit 1cb0edb

Browse files
fix(cli): os migrate meta lists and writes a conversion the load already applies, on a stack the schema accepts (#22351)
Part of #22256 Clause-②: no Release: the composed half stays open on #22256. That half is a conversion the load still applies inside a `composeStacks` package body. Its carrier is the `domain:cli` seat, which takes it to the `domain:spec` seat with the H4 measurement below. It needs a spec seam or a second copy of composition's rule. `os migrate meta` now lists and writes a conversion the load still applies, such as `datasources[].driver: 'mongo'`, on a one-package stack the current schema ACCEPTS. Every change is in `packages/cli`. There is no `packages/spec` change, and nothing here reads or writes the producer's provenance key. ## What changed 1. **The shim keeps the argument of an accepted `defineStack` call** (`src/utils/config.ts`). `__tolerant` gains a `keep` hook. It runs after a successful call, outside the `try`. The `defineStack` wrap passes `__keepAuthored`, which defines one non-enumerable property on the returned stack, holding the argument. Its key is a CLI-owned `Symbol.for` key, `@objectstack/cli:authored-source/accepted-argument`. A refused call is unchanged: it hands its argument through raw, and `__recordStack` records it for `composeStacks`. A call takes exactly one of the two arms. 2. **`loadConfig({ authoredSource: true })` starts the default export from that argument** (`authoredArgumentOf`). It reads the argument off `mod.default` BEFORE the named-export merge, which is where the provenance mark and the conversion record are already read: the merge's spread drops non-enumerable properties. It follows the record to the end, so `defineStack(defineStack({ … }))` answers the inner literal. `stackProvenance` and `stackConversions` are still read off the built stack. A load without `authoredSource`, which is every other command, is untouched. 3. **`meta.ts`: no code change, one comment.** Both chain call sites (`:1159`–`:1165`, and the `--write` re-run at `:1343`–`:1344`) already run `normalizeStackInput(config, { convert: false })` over `loadConfig(…, { authoredSource: true }).config`, and that value is now the stack as written. The comment says where it comes from, and that the composed path is not covered. 4. **Changeset**: `@objectstack/cli` `patch`, `Clause-②: no`. It keeps PR #22326's "Known limit" true, and restates it for both kinds of composed input. ### The carrier (H3): a property on the stack, not a WeakMap in the shim's record module The record has to cross from the bundled shim to `loadConfig`. The only values that cross are the config module's exports, and the default export IS the stack the call returned. A property on it crosses with it, with no global state and no lifetime beyond the value's. The hand-through WeakMap (PR #22326) lives in a module bundled inside the load, and nothing outside the load can reach it; reaching it would take a process-global. The property is non-enumerable, like the producer's own mark, so a spread, `JSON.stringify` and a schema parse all leave it behind. ### Why `loadConfig` substitutes, and `meta.ts` does not re-merge In the order's route, `loadConfig` exposes the record and `meta.ts` starts from it at its two call sites. `meta.ts` would then have to redo `loadConfig`'s named-export merge over the authored argument, which is a second copy of that rule. Substituting before the merge keeps one merge. The `--write` planner also gets the authored value, so its literal match (`subsetOf(literal, loaded)`) compares the literal with what the author wrote. ## PM readings, measured All on `origin/main` `3599fef1` with the built CLI (`node packages/cli/bin/run.js`), against temp projects that link this worktree's built `@objectstack/spec`. - **H1, reproduced.** The stack is a one-package stack the schema accepts, with `datasources: [{ name: 'docs', label: 'Docs', driver: 'mongo', config: { url: 'mongodb://mongo.internal:27017/docs' } }]`. `migrate meta objectstack.config.ts --from 16 --write --json` gave exit 0, `"applied": []` and `"write": {"status": "written", "files": [], "written": [], "manual": [], "unexplained": []}`. The source kept `driver: 'mongo'`. A later load (`os validate`) printed: `defineStack: datasources[0].driver: 'mongo' → 'mongodb' (converted at load; conversion 'datasource-driver-mongo-to-mongodb', retires in protocol 18). Update the source to the canonical shape — the conversion stops running then.` - A first probe with `config: {}` was REFUSED, because a mongo datasource needs a connection target. It took the raw hand-back, and the conversion was listed and written. Only an accepted stack shows the defect. - **H2, confirmed by instrument.** `loadConfig(…, { authoredSource: true }).config.datasources[0].driver` was `'mongodb'`, and so was the chain input after `normalizeStackInput(…, { convert: false })`. `stackConversions` named `datasource-driver-mongo-to-mongodb`. On success the shim's `__tolerant` returned the real result. Where `driver` is converted: `buildDefinedStack` (`packages/spec/src/stack.zod.ts`) calls `normalizeStackInput` with conversions on, in both modes. That calls `applyConversions`, which runs `datasourceDriverMongoToMongodb` (`packages/spec/src/conversions/registry.ts`). No other `define*` helper runs the pass. - **H3, fixed as above.** After the change, the same H1 run gave `applied` = `[datasource-driver-mongo-to-mongodb @ datasources[0].driver, mongo → mongodb]`, `write.files` = `[{ objectstack.config.ts, sites: 1 }]`, `verification.ok: true` and `schemaValid: true`. The source line now reads `driver: 'mongodb'`. A later `os validate` printed no `converted at load` line, and a re-run applied nothing. - **Nothing to convert is unchanged, measured on the corpus.** All four example apps are ACCEPTED by `defineStack` and carry no load-path conversion, so the fix changes each one's chain input. I ran `migrate meta --from 16 --json` and `--from 17 --json` on `app-crm`, `app-multi-package`, `app-showcase` and `app-todo`. The eight `--json` documents, minus `duration`, are byte-identical by md5 before and after. I also ran `--write --from 16` on a throwaway copy of each app. The `--json` outcome and the written bytes are identical by md5: `app-showcase` writes 3 sites in `src/automation/flows/index.ts` and `app-todo` 1 site in `src/flows/task.flow.ts`, both before and after. - **The `--out` snapshot changes for an accepted stack.** It is now the authored stack plus the chain's edits, as it already was for a refused stack. Measured on the three one-package apps, every difference is one of two kinds. Either a key is present only BEFORE: parse defaults such as `manifest.scope`, `manifest.defaultDatasource`, `flows[].version` and `hooks[].runAs`, and `objects[].actions` from `defineStack`'s action merge. Or the parse transformed a value: `hooks[].condition` and `flows[].edges[].condition`. Nothing appears that the author did not write. The composed app's snapshot is unchanged. The changeset says so. - **H4, measured. NOT fixed here.** The project is `composeStacks([ServiceStack, AppStack], { manifest: 'preserve' })`, with the service body carrying the accepted mongo datasource. Before AND after this change, `--from 16 --write --json` gives exit 0, `applied: []`, `write.files: []` and `schemaValid: true`. The sources keep their md5, and a later load prints the notice once. With a refused spelling added to the same body (`time` default `'10:00Z'`), `applied` lists only `time-default-utc-suffix-dropped @ packages[0].manifest.objects[0].fields.starts_at.defaultValue`, because the `strict: false` re-production converts the driver. - **Why it stops.** The chain's per-body run reads `packages[i].manifest`. `composeStacks` assembles that body from the stack the input's `defineStack` call RETURNED (`assemblePackageBody`: `{ ...stack.manifest }`, then each package-owned key). To start that run from the authored input, `packages/cli` needs two things it does not have as values: 1. The mapping from entry to input (`preservePackageEntries`: an input with `packages` contributes its own entries, and an input without `manifest` contributes none). 2. The body assembly itself. The codemod's `packageBodyKeys` gives the key SET, and its `composedBody` step states the assembly over SYNTAX nodes. Neither produces a value. Writing either as a value is a second copy of composition's rule. The real `composeStacks` refuses the authored inputs at its step 0 (`STACK_PROVENANCE_MISSING`). Marking them would take the spec's provenance key, or `defineStack`, which converts. So this half needs a `packages/spec` seam, for example a `defineStack` mode that skips the D2 pass, or an exported body assembler. It goes back to the seat. - **H5, holds.** An accepted call is kept (`__keepAuthored`), and a refused call is handed through (`__recordStack`). They are the two arms of one `try`. The composed re-production calls the REAL `defineStack` (`__specRoot.defineStack`), not the wrap, so it keeps nothing. A composed artifact carries no record, so `loadConfig` uses it as before. The pin is a stack carrying one refused spelling and one load-path spelling: each conversion is applied once, and both are written. ## Pins — `test/migrate-meta-load-conversions.test.ts`, `unit` tier The tests run in-process over `MigrateMeta.run`, against temp projects that link the real spec. No process is spawned and no kernel is booted. `vitest list --project unit` lists the file, and `--project integration` does not. 5 tests: - **The fix.** The accepted mongo stack. The dry run lists `datasource-driver-mongo-to-mongodb @ datasources[0].driver`. `--write` writes it: `write.files` = `[{ objectstack.config.ts, sites: 1 }]`, `manual: []`, `unexplained: []`, and the bytes are the source with `'mongo'` → `'mongodb'` and nothing else. The re-run applies nothing. The strict load's `stackConversions` goes from naming the conversion to `[]`. The reload is read through `stackConversions`, not the stderr notice, because `defineStack` prints that notice once per process. - **Where the argument is read.** With `export const onEnable` beside the default, the authored-source load gives `driver: 'mongo'`, `namedExports: ['onEnable']`, `onEnable` merged, `stackProvenance: true` and `stackConversions` naming the conversion. The strict load still gives `'mongodb'`. - **Defined twice.** `defineStack(defineStack({ … }))` lists the conversion exactly once. - **Control.** An already-canonical source gives `applied: []` and `write` = `{ status: 'written', files: [], written: [], manual: [], unexplained: [] }`, and every byte is unchanged. Its `applied`, `todos`, `absentTodos` and `schemaValid` equal those of the chain over the stack `defineStack` built, which is where the chain started before this change. - **Mixed (H5).** One refused spelling (`time` default `'10:00Z'`) and one load-path spelling (the valid mongo datasource). `applied` = `[mongo, time]`, each once. `write.files` = `[{ objectstack.config.ts, sites: 2 }]`, and both edits are written. The re-run applies nothing, and the strict load accepts and converts nothing. PR #22326's pins (`test/migrate-meta-composed.test.ts`, 8 tests, including both one-package controls) stay green. ## Ablations Each ablation went through `scripts/ablation-replace.mjs`. The anchor hit once, the mutation was proven on disk, and the restore was proven: blob `0488d7e55d38` = HEAD, and `git diff HEAD` empty. The suite imports `src` directly, so no `dist/` is involved. The direction was stated before each run. 1. **The converted hand-through restored in the loader** (`authoredBase` = `baseConfig`). Predicted 3 red, 2 green. Got 3 red (the fix, where the argument is read, defined twice) and 2 green (control, mixed). The fix pin's first red: `expected [] to deeply equal [ { …(4) } ]`. 2. **The shim no longer keeps the argument** (the `defineStack` wrap passes only `__recordStack`). The same 3 red, 2 green. 3. **The record followed one level only.** Predicted 1 red. Got 1 red (defined twice) and 4 green. ## Local verification at `038ef735d` - **CLI unit tier**: `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` gave `Test Files 269 passed (269)` and `Tests 3956 passed (3956)`. - **Typecheck**: `pnpm --filter @objectstack/cli typecheck` (`tsc --noEmit`, then `check:test-typecheck`) exited 0, ending `check:test-typecheck: OK … 3 file(s) / 28 error(s) / 6 pinned signature(s) held`, the pre-existing ledger. It ran at `93f524998`. The only later commit is the changeset, so the TypeScript tree is the same. - **Integration tier**: declared to CI, because this diff touches no integration-tier file and no spawn entry. As a targeted check, I ran the two integration files that drive `migrate meta` over an authored source, `test/migrate-meta-default-range.test.ts` and `test/migrate-meta-engine-guidance.test.ts`, with `--project integration`: `Test Files 2 passed (2)`, `Tests 10 passed | 1 skipped (11)`. - **Gates**: `node scripts/pm/dispatch-gates.mjs --commands`, re-derived over this diff, gives the same 65 commands the order listed. All 65 exited 0. `check:dual-build-cjs-loads` first exited 3 (`PREREQUISITE NOT MET`: 8 packages outside the closures I had built had no `dist/`). After building those 8 it passed (`106/66/712/1` entries/packages/cjsFiles/probes). `dispatch-gates --ran` reports `65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN`. - **Lint**: `pnpm lint` (`eslint . --no-inline-config`, the whole repo) exited 0 with no findings. - **Base**: the branch is on `3599fef1`. `main` has since moved 6 commits, and none touches `packages/cli`, the spec's stack or provenance modules, or PR #22326's changeset. I did not merge it; CI's merge ref covers the join. ## Acceptance notes - **PR #22326's "Known limit" sentence** ends "…, the same as for an input the current schema accepts." Inside a composed project, which is its context, it stays true. Read as the one-package path, this PR makes the comparison stale. This PR's changeset restates the limit for both kinds of composed input. I did not edit the other changeset: it is outside the claimed file surface. - **`content/docs/upgrading.mdx`**: I read the `--write` paragraph. Nothing in it goes stale, because it makes no claim about load-path conversions. No docs edit. - **Named exports, in this one load.** `migrate meta`'s merge now judges whether a named export is shadowed against the authored argument, not the built stack. A top-level key that the parse defaults and that the author also exports would now merge instead of being reported as shadowed. Measured on the corpus, no TOP-LEVEL key differs between the two; every difference is nested. - **A stack spread from a built stack** (`defineStack({ ...base, … })`) still reaches the chain with `base`'s converted values. The spread drops the record, as it drops the provenance mark, and `--write` refuses that site as `spread` anyway. - **A one-input `composeStacks([x])`** returns `x` itself. Measured: it now lists `datasource-driver-mongo-to-mongodb @ datasources[0].driver`, and `--write` lists that site as `helper` under the codemod's existing composed-key rule. ## Round 2 (REWORK `6068295985`), written by the `domain:cli` seat - **Merged `main`** with a merge commit, `daa27b7c7` (parents `038ef735d` and `35afb1587`). There was no rebase and no force-push, and no path conflicted. - **PR #22326's pending changeset, corrected** (`2f8e479a3`). In `.changeset/22289-migrate-meta-composed-project.md`, the "Known limit" bullet's closing clause ", the same as for an input the current schema accepts" is removed. Nothing else in that file moves. - This PR makes that clause false, and both changesets ship in one `@objectstack/cli` release. - It is a deliberate correction of another PR's pending note, so `check-empty-changeset`'s foreign-changeset rule is red by design. The seat confirms that in a comment on this PR. - The first Acceptance note above (PR #22326's "Known limit" sentence) no longer applies. - **The open question (the `--out` snapshot) is ruled A.** It is kept as shipped and declared in this PR's changeset. - **Re-verified by the dev at `2f8e479a3`,** after a full `turbo run build`: - CLI unit tier: 269 files, 3956 tests passed. Typecheck exit 0. `pnpm lint` exit 0. - The same 65 gates re-derived over the final diff: 64 exit 0, and `check-empty-changeset` exits 1 on the foreign-changeset rule only. `dispatch-gates --ran`: `65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN`. - **The docs-drift advisory (`6068211309`), re-derived at `2f8e479a3`** with `node scripts/docs-audit/affected-docs.mjs --json 35afb15`. It lists 16 hand-written pages and 9 release-owned ones (read-only). - The two pages that describe `os migrate meta` (`content/docs/upgrading.mdx` and `content/docs/deployment/cli.mdx`) make no claim this PR changes. The `--out` row says "the migrated stack as a JSON snapshot", which still holds. `cli.mdx:1354` is about `--stored`, which this PR does not touch. - The other 14 name `os migrate meta` as a remedy, which stays true. - So no docs edit is owed. --- _Generated by [Claude Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 19bb55e commit 1cb0edb

5 files changed

Lines changed: 456 additions & 10 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os migrate meta` lists and writes a conversion the load already applies, on a stack the current schema accepts (#22256)
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** When the current schema accepts a stack, `defineStack` runs its load-time ADR-0087 conversions while the config loads. `os migrate meta` then replayed its chain over a stack that was already converted. So a spelling the load still converts, such as `datasources[].driver: 'mongo'`, was never listed in `applied`, and `--write` never wrote it. Every later load kept printing `converted at load … Update the source to the canonical shape`, the notice that sends the author to this command, and the run exited 0.
10+
- **What changed.** The chain now starts from the argument the stack's `defineStack` call was given. It already did this for a stack the schema refuses. The conversion is listed, `--write` writes it into the source, and the next load converts nothing.
11+
- **What did not change.** A stack with nothing to convert gives the same `--json` summary and the same `--write` result as before. This was measured on the four example apps at `--from 16` and `--from 17`. Every other command still reads the stack `defineStack` builds.
12+
- **The `--out` snapshot.** It is now the stack as written, with the chain's changes applied. That is what it already was for a stack the schema refuses. It no longer carries values the schema's parse fills in and the source never wrote, such as default keys and actions merged into their objects.
13+
- **Known limit, unchanged.** Inside a `composeStacks([…])` project, each package body is assembled from the stack its input's `defineStack` call returned, whether that call accepted its input or was produced again in `strict: false` mode. So a conversion the load still applies inside a package body is still not listed, and `--write` still does not write it.

‎.changeset/22289-migrate-meta-composed-project.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@ Clause-②: no
99
- **What changed.** On a project whose config exports `composeStacks([defineStack({ … }), …], { manifest: 'preserve' })`, `os migrate meta --from N` used to exit 1 with `STACK_PROVENANCE_MISSING`, telling the author to wrap each input in `defineStack`, as soon as any input carried a spelling the current schema refuses. Every input already was wrapped. The command now loads such a project: an input whose `defineStack` call the current schema refuses is handed to `composeStacks` as `defineStack(input, { strict: false })` returns it, and composition runs as it does at build time.
1010
- **The package bodies are migrated.** A composed artifact keeps each definition under the package that owns it (`packages[i].manifest`), and the migration chain used to reach only the stack's own top level. It now also runs over each package body, and lists each change under the body's path, such as `packages[0].manifest.objects[0].fields.starts_at.defaultValue`. `--write` writes a change in package i into the file that authored input i only when input i and every input before it in the `composeStacks([…])` list is a stack literal (a `defineStack({ … })` call on an object literal, written in place or reached through a `const` or an import) that writes its own `manifest` and no `packages`. Only then is package i that one input's body. Otherwise `--write` lists the change with the reason, as it does for any site it cannot trace.
1111
- **What did not change.** A one-package project loads, migrates and writes exactly as before. An input that was never built by `defineStack` (a plain object, or a spread of a built stack) is still refused with `STACK_PROVENANCE_MISSING`.
12-
- **Known limit.** Inside a composed project, a conversion the load still applies (for example `datasources[].driver: 'mongo'`) is applied while the input is composed. So the chain does not list it and `--write` does not write it, the same as for an input the current schema accepts.
12+
- **Known limit.** Inside a composed project, a conversion the load still applies (for example `datasources[].driver: 'mongo'`) is applied while the input is composed. So the chain does not list it and `--write` does not write it.

‎packages/cli/src/commands/migrate/meta.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1162,6 +1162,13 @@ export default class MigrateMeta extends Command {
11621162
// conversions itself against the raw authored source so each rewrite is
11631163
// attributed to a chain hop, not silently pre-applied by the load-time
11641164
// D2 pass. Running the D2 pass here would leave the chain's diff empty.
1165+
//
1166+
// `convert: false` reaches only THIS call. The stack's own `defineStack`
1167+
// call runs the D2 pass inside the config module whenever the schema
1168+
// accepts its input, so `config` is the raw source only because the
1169+
// load starts it from the argument that call was given (`loadConfig`,
1170+
// `authoredSource`). That holds for a one-package stack; a composed
1171+
// project's package bodies are assembled from converted stacks.
11651172
const normalized = normalizeStackInput(config as Record<string, unknown>, { convert: false });
11661173

11671174
if (!flags.json) printStep(`Replaying chain: protocol ${fromMajor} → ${toMajor}…`);

‎packages/cli/src/utils/config.ts‎

Lines changed: 113 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,33 @@ const HANDED_THROUGH_MODULE = 'objectstack:authored-source/handed-through';
297297
const HANDED_THROUGH_FILTER = /^objectstack:authored-source\/handed-through$/;
298298
const HANDED_THROUGH_NAMESPACE = 'objectstack-authored-source-record';
299299

300+
/**
301+
* The global-registry symbol name under which the shim keeps, on the stack an
302+
* ACCEPTED {@link STACK_PRODUCER} call returned, the argument that call was
303+
* given — and the one name {@link authoredArgumentOf} reads it back by.
304+
*
305+
* Owned by this CLI: ⛔ never the producer's own provenance key. The record
306+
* is not a claim about who built the stack (that mark is the producer's to
307+
* write); it is this loader's note of what the author wrote, read once, off
308+
* the default export, by {@link loadConfig}.
309+
*
310+
* ## Why a property on the stack, and not the load's hand-through record
311+
*
312+
* The record has to cross from the bundled shim to {@link loadConfig}. The
313+
* only values that cross are the config module's exports, and the default
314+
* export IS the stack the call returned, so a property on it crosses with it
315+
* — with no state outside the value, and no lifetime but the value's. The
316+
* hand-through record ({@link HANDED_THROUGH_MODULE}) is bundled inside the
317+
* load and is reachable from nothing outside it. A `Symbol.for` key because
318+
* the shim's code and this module are two module graphs in one process, and
319+
* the global registry is what both resolve the same symbol from.
320+
*
321+
* Non-enumerable, like the producer's own mark: a spread, `JSON.stringify`
322+
* and a schema parse all leave it behind, and {@link loadConfig} reads it
323+
* BEFORE its named-export merge spreads the default export into a new object.
324+
*/
325+
const AUTHORED_ARGUMENT_KEY = '@objectstack/cli:authored-source/accepted-argument';
326+
300327
/**
301328
* One strict authoring factory that is not a `define*` helper: the function
302329
* `member` of the exported value `owner`, which validates its argument AT THE
@@ -425,16 +452,23 @@ async function authoredSourceHelpersOf(
425452
* `composeStacks` wrap maps its inputs through: a recorded stack is produced
426453
* again by the real `defineStack` in its `strict: false` mode, and every other
427454
* input reaches the real `composeStacks` as it was passed.
455+
*
456+
* `__keepAuthored` is the other `defineStack`-only hook, for the call that
457+
* SUCCEEDS: it keeps the argument beside the stack the real call returned,
458+
* under {@link AUTHORED_ARGUMENT_KEY}. It runs outside the `try`, so it can
459+
* never turn an accepted call into a refused one. A call takes exactly one of
460+
* the two arms, so no argument is both handed through and kept.
428461
*/
429462
const AUTHORED_SOURCE_PRELUDE: readonly string[] = [
430463
`const __refusal = (label, error) =>`,
431464
` error && error.name === 'ZodError' && Array.isArray(error.issues)`,
432465
` && typeof __specRoot.formatZodError === 'function'`,
433466
` ? __specRoot.formatZodError(error, label + ' validation failed')`,
434467
` : (error && error.message) || String(error);`,
435-
`const __tolerant = (label, call, handOver) => (...authored) => {`,
468+
`const __tolerant = (label, call, handOver, keep) => (...authored) => {`,
469+
` let built;`,
436470
` try {`,
437-
` return call(...authored);`,
471+
` built = call(...authored);`,
438472
` } catch (error) {`,
439473
` console.warn(`,
440474
` '[authored-source] ' + label + '(): the current schema refuses this '`,
@@ -444,10 +478,20 @@ const AUTHORED_SOURCE_PRELUDE: readonly string[] = [
444478
` if (handOver) handOver(authored);`,
445479
` return authored[0];`,
446480
` }`,
481+
` if (keep) keep(built, authored);`,
482+
` return built;`,
447483
`};`,
448484
`const __recordStack = (authored) => {`,
449485
` if (authored[0] !== null && typeof authored[0] === 'object') __handedThrough.set(authored[0], authored[1]);`,
450486
`};`,
487+
`const __authoredKey = Symbol.for(${JSON.stringify(AUTHORED_ARGUMENT_KEY)});`,
488+
`const __keepAuthored = (built, authored) => {`,
489+
` const source = authored[0];`,
490+
` if (built === null || typeof built !== 'object' || source === null || typeof source !== 'object') return;`,
491+
` if (built === source || !Object.isExtensible(built)) return;`,
492+
` if (Object.prototype.hasOwnProperty.call(built, __authoredKey)) return;`,
493+
` Object.defineProperty(built, __authoredKey, { value: source, enumerable: false, writable: false, configurable: false });`,
494+
`};`,
451495
`const __composable = (stack) => (__handedThrough.has(stack)`,
452496
` ? __specRoot.${STACK_PRODUCER}(stack, { ...__handedThrough.get(stack), strict: false })`,
453497
` : stack);`,
@@ -506,10 +550,12 @@ const AUTHORED_SOURCE_PRELUDE: readonly string[] = [
506550
* The narrowness is the point, and it is what keeps this a restoration rather
507551
* than a widening of what the command accepts:
508552
*
509-
* - **A source that loads today loads identically.** The real helper runs, so
510-
* its defaults and transforms still apply (`defineForm` moves `schemaId`
511-
* into `data`, `defineStack` merges actions into objects, …). Nothing about
512-
* the existing happy path is re-decided.
553+
* - **A source that loads today evaluates identically.** The real helper
554+
* runs, so every value the module builds is the value it builds today, with
555+
* each helper's defaults and transforms (`defineForm` moves `schemaId` into
556+
* `data`, …). The one difference is which value the chain starts from: an
557+
* accepted `defineStack` call's ARGUMENT, not its result (see "An accepted
558+
* `defineStack` call" below).
513559
* - **A source the current schema refuses reaches the chain as authored** —
514560
* which is precisely the codemod's input. `defineX(config: z.input<typeof
515561
* XSchema>)` means the authored argument is by construction a shape
@@ -525,6 +571,31 @@ const AUTHORED_SOURCE_PRELUDE: readonly string[] = [
525571
* author deserves to know an artifact bypassed the parse, and stderr keeps a
526572
* `--json` run's stdout a single parseable document.
527573
*
574+
* ## An accepted `defineStack` call: the chain starts from its argument
575+
*
576+
* A call the current schema ACCEPTS has already run the producer's load-time
577+
* ADR-0087 D2 conversion pass when it returns. That pass runs in both of its
578+
* modes, and no option skips it. So its result is canonical already. Handed
579+
* to the chain as it was, a conversion the load still applies
580+
* (`driver: 'mongo'`) had already happened: `applied` came back empty,
581+
* `--write` wrote nothing, and every later load still printed the notice
582+
* that sends the author to this command.
583+
*
584+
* So the shim keeps the argument beside the stack the call returned
585+
* ({@link AUTHORED_ARGUMENT_KEY}), and {@link loadConfig} starts the default
586+
* export from it. That argument is what `defineStack(config: z.input<…>)`
587+
* accepted, so it is a well-formed authoring tree. The command still parses
588+
* the MIGRATED stack and reports `schemaValid`. A refused call already hands
589+
* its argument through as it is, so the two arms leave the chain one input
590+
* shape: the stack the author wrote.
591+
*
592+
* ⚠️ A composed input does not get this. `composeStacks` builds each package
593+
* body from the stack the input's `defineStack` call RETURNED, so the body is
594+
* converted before the chain sees it. Recovering the authored body would mean
595+
* assembling it again outside the producer: a second copy of composition's
596+
* rule. Inside a composed project, a conversion the load still applies is
597+
* still applied before the chain runs.
598+
*
528599
* ## A composed project: the handed-through stack is produced again
529600
*
530601
* `composeStacks` is not a `define*` helper, so it runs for real, and its
@@ -594,7 +665,7 @@ function authoredSourcePlugin(configPath: string): Plugin {
594665
...AUTHORED_SOURCE_PRELUDE,
595666
];
596667
for (const name of defineHelpers) {
597-
const handOver = name === STACK_PRODUCER ? ', __recordStack' : '';
668+
const handOver = name === STACK_PRODUCER ? ', __recordStack, __keepAuthored' : '';
598669
lines.push(
599670
`export const ${name} = __tolerant(${JSON.stringify(name)}, (...authored) => __real.${name}(...authored)${handOver});`,
600671
);
@@ -621,11 +692,39 @@ export interface LoadConfigOptions {
621692
* Read the config as AUTHORED rather than as the current schema would have
622693
* it — see {@link authoredSourcePlugin}. Set by `os migrate meta` only.
623694
*
695+
* Two things follow. A refused helper call hands its argument through. And a
696+
* default export that an ACCEPTED `defineStack` call built is replaced by the
697+
* argument that call was given ({@link authoredArgumentOf}), before the
698+
* named exports are merged onto it, so `config` is the stack as written. The
699+
* producer's mark and conversion record are still read off the built stack.
700+
*
624701
* @default false
625702
*/
626703
authoredSource?: boolean;
627704
}
628705

706+
/**
707+
* The argument an accepted `defineStack` call was given, read off the stack
708+
* it returned — or `value` itself when the authored-source shim kept none
709+
* there (a refused call's hand-through, a composed stack, a plain object, a
710+
* load without the shim).
711+
*
712+
* Followed to the end, so `defineStack(defineStack({ … }))` answers the inner
713+
* literal: each accepted call's result carries its own argument, and only the
714+
* innermost one is what the author wrote.
715+
*/
716+
function authoredArgumentOf(value: unknown): unknown {
717+
const key = Symbol.for(AUTHORED_ARGUMENT_KEY);
718+
let current = value;
719+
const seen = new Set<unknown>();
720+
while (current !== null && typeof current === 'object' && !seen.has(current)) {
721+
seen.add(current);
722+
if (!Object.prototype.hasOwnProperty.call(current, key)) break;
723+
current = (current as Record<symbol, unknown>)[key];
724+
}
725+
return current;
726+
}
727+
629728
/**
630729
* Load and bundle a config file using bundle-require.
631730
* Returns the resolved config object, its load time, and the provenance of
@@ -693,6 +792,11 @@ export async function loadConfig(source?: string, options?: LoadConfigOptions):
693792
// The producer's conversion record rides beside the mark and is dropped by
694793
// the same spread, so it is read here too, off the same value.
695794
const stackConversions = stackConversionsOf(baseConfig);
795+
// `authoredSource`: the stack as WRITTEN. An accepted `defineStack` call has
796+
// already converted its result at load, so the chain starts from the
797+
// argument the shim kept beside it. Read off the same value for the same
798+
// reason: the spread below drops the non-enumerable record too.
799+
const authoredBase = options?.authoredSource ? authoredArgumentOf(baseConfig) : baseConfig;
696800

697801
// Preserve named exports (e.g. the `onEnable` runtime hook and `functions`)
698802
// alongside the default-exported stack. Module-namespace named exports are
@@ -707,9 +811,9 @@ export async function loadConfig(source?: string, options?: LoadConfigOptions):
707811
const namedExports: string[] = [];
708812
const shadowedNamedExports: string[] = [];
709813
const config = (baseConfig === mod || mod.default == null)
710-
? baseConfig
814+
? authoredBase
711815
: (() => {
712-
const merged: any = { ...baseConfig };
816+
const merged: any = { ...(authoredBase as Record<string, unknown>) };
713817
for (const key of Object.keys(mod)) {
714818
if (key === 'default') continue;
715819
// ⛔ `hasOwnProperty`, never `key in merged` (#18419). `in` walks the

0 commit comments

Comments
 (0)