diff --git a/.changeset/22256-composed-load-path-conversions.md b/.changeset/22256-composed-load-path-conversions.md new file mode 100644 index 00000000000..3abac6c0be6 --- /dev/null +++ b/.changeset/22256-composed-load-path-conversions.md @@ -0,0 +1,14 @@ +--- +'@objectstack/cli': patch +'@objectstack/spec': patch +--- + +fix(cli): `os migrate meta` lists and writes a conversion the load already applies inside a composed project's package body (#22256) + +Clause-②: no + +- **What was wrong.** In a project whose config exports `composeStacks([defineStack({ … }), …], { manifest: 'preserve' })`, each package body is assembled from the stack its input's `defineStack` call returns. That call runs the load-time ADR-0087 conversions, both when the schema accepts the input and when `os migrate meta` produces a refused input again in `strict: false` mode. So a spelling the load still converts inside a package body, 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`, and the run exited 0. +- **What changed.** `os migrate meta` now produces every input of the composition again from what its author wrote, with the load-time conversions skipped, before the inputs are composed. Composition then assembles each package body from the authored source. The conversion is listed under the body's path (for example `packages[0].manifest.datasources[0].driver`), `--write` writes it into the file that authored that input, and the next load converts nothing. This holds whether the input's `defineStack` call accepted its argument or was refused. +- **`@objectstack/spec`.** `defineStack` reads one internal parameter for this, which only the CLI passes: an undeclared, symbol-keyed entry on its options, honoured only together with `strict: false`, that skips the conversion pass. It is not part of `DefineStackOptions` and nothing new is exported. A strict call ignores it, and every call without it behaves exactly as before. +- **What did not change.** A project with nothing to convert gives the same `--json` summary and the same `--write` result and written bytes as before. This was measured on the four example apps, one of them composed, at `--from 16` and `--from 17`. Every other command still reads the stacks `defineStack` builds and the composition they make. +- **The `--out` snapshot of a composed project.** Each package body in it is now the body as written, with the chain's changes applied. That is what the snapshot of a one-package stack already is. It no longer carries values the schema's parse fills in and the source never wrote, such as default keys. diff --git a/.changeset/22256-migrate-meta-load-path-conversions.md b/.changeset/22256-migrate-meta-load-path-conversions.md index a6119093069..bd796637161 100644 --- a/.changeset/22256-migrate-meta-load-path-conversions.md +++ b/.changeset/22256-migrate-meta-load-path-conversions.md @@ -10,4 +10,3 @@ Clause-②: no - **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. - **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. - **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. -- **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. diff --git a/.changeset/22289-migrate-meta-composed-project.md b/.changeset/22289-migrate-meta-composed-project.md index 632bfbf7ec8..528ae138149 100644 --- a/.changeset/22289-migrate-meta-composed-project.md +++ b/.changeset/22289-migrate-meta-composed-project.md @@ -6,7 +6,6 @@ fix(cli): `os migrate meta` runs on a project built with `composeStacks`, and co Clause-②: no -- **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. +- **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 produced again by `defineStack(input, { strict: false })` with the load-time conversions skipped (#22256) before it is handed to `composeStacks`, and composition runs as it does at build time. - **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. - **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`. -- **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. diff --git a/packages/cli/src/commands/migrate/meta.ts b/packages/cli/src/commands/migrate/meta.ts index e6a151d8715..f026976c08c 100644 --- a/packages/cli/src/commands/migrate/meta.ts +++ b/packages/cli/src/commands/migrate/meta.ts @@ -1167,8 +1167,9 @@ export default class MigrateMeta extends Command { // call runs the D2 pass inside the config module whenever the schema // accepts its input, so `config` is the raw source only because the // load starts it from the argument that call was given (`loadConfig`, - // `authoredSource`). That holds for a one-package stack; a composed - // project's package bodies are assembled from converted stacks. + // `authoredSource`). A composed project's inputs are produced again from + // their authored arguments with that pass skipped before they are + // composed, so its package bodies are raw source too (#22256). const normalized = normalizeStackInput(config as Record, { convert: false }); if (!flags.json) printStep(`Replaying chain: protocol ${fromMajor} → ${toMajor}…`); diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts index 0488d7e55d3..281e043f30e 100644 --- a/packages/cli/src/utils/config.ts +++ b/packages/cli/src/utils/config.ts @@ -324,6 +324,25 @@ const HANDED_THROUGH_NAMESPACE = 'objectstack-authored-source-record'; */ const AUTHORED_ARGUMENT_KEY = '@objectstack/cli:authored-source/accepted-argument'; +/** + * The global-registry symbol name under which a non-strict {@link STACK_PRODUCER} + * call's options ask the producer for its input AS AUTHORED: normalised and + * marked as every `strict: false` call is, with its load-time ADR-0087 D2 + * conversion pass skipped (#22256). + * + * Owned by `@objectstack/spec`, ⛔ not by this CLI: it is the producer's one + * internal parameter (`stack.zod.ts`, beside `defineStack`), deliberately + * neither declared on `DefineStackOptions` nor exported, so this module spells + * its `Symbol.for` name the way the producer's provenance mark is read across + * two copies of the package. A drifted spelling is not silent: the producer + * then converts as before, and the composed pins in + * `test/migrate-meta-composed-load-conversions.test.ts` go red on the very + * conversion this key exists to keep visible. + * + * Read by the `composeStacks` wrap alone ({@link authoredSourcePlugin}). + */ +const SPEC_AUTHORED_INPUT_OPTION = 'objectstack.stack.authoredInput'; + /** * One strict authoring factory that is not a `define*` helper: the function * `member` of the exported value `owner`, which validates its argument AT THE @@ -448,16 +467,23 @@ async function authoredSourceHelpersOf( * * `__recordStack` is the hand-over hook the `defineStack` wrap alone passes: * it records the stack it hands through, with the options it was called - * with, in the load's one hand-through record. `__composable` is what the - * `composeStacks` wrap maps its inputs through: a recorded stack is produced - * again by the real `defineStack` in its `strict: false` mode, and every other - * input reaches the real `composeStacks` as it was passed. + * with, in the load's one hand-through record. * * `__keepAuthored` is the other `defineStack`-only hook, for the call that * SUCCEEDS: it keeps the argument beside the stack the real call returned, * under {@link AUTHORED_ARGUMENT_KEY}. It runs outside the `try`, so it can * never turn an accepted call into a refused one. A call takes exactly one of * the two arms, so no argument is both handed through and kept. + * + * `__composable` is what the `composeStacks` wrap maps its inputs through, and + * it gives both arms one shape: what the author wrote, produced again by the + * real `defineStack` in its `strict: false` mode with its conversion pass + * skipped ({@link SPEC_AUTHORED_INPUT_OPTION}). A recorded stack is that + * argument already; a kept one is followed to the end, as + * {@link authoredArgumentOf} follows it, so a nested `defineStack(defineStack( + * …))` starts from the inner literal. Every other input — a nested + * composition, a plain object the author never wrapped — reaches the real + * `composeStacks` as it was passed. */ const AUTHORED_SOURCE_PRELUDE: readonly string[] = [ `const __refusal = (label, error) =>`, @@ -492,9 +518,24 @@ const AUTHORED_SOURCE_PRELUDE: readonly string[] = [ ` if (Object.prototype.hasOwnProperty.call(built, __authoredKey)) return;`, ` Object.defineProperty(built, __authoredKey, { value: source, enumerable: false, writable: false, configurable: false });`, `};`, - `const __composable = (stack) => (__handedThrough.has(stack)`, - ` ? __specRoot.${STACK_PRODUCER}(stack, { ...__handedThrough.get(stack), strict: false })`, - ` : stack);`, + `const __authoredInput = Symbol.for(${JSON.stringify(SPEC_AUTHORED_INPUT_OPTION)});`, + `const __keptArgumentOf = (stack) => {`, + ` const seen = new Set();`, + ` let current = stack;`, + ` while (current !== null && typeof current === 'object' && !seen.has(current)`, + ` && Object.prototype.hasOwnProperty.call(current, __authoredKey)) {`, + ` seen.add(current);`, + ` current = current[__authoredKey];`, + ` }`, + ` return current;`, + `};`, + `const __composable = (stack) => {`, + ` if (__handedThrough.has(stack)) {`, + ` return __specRoot.${STACK_PRODUCER}(stack, { ...__handedThrough.get(stack), strict: false, [__authoredInput]: true });`, + ` }`, + ` const authored = __keptArgumentOf(stack);`, + ` return authored === stack ? stack : __specRoot.${STACK_PRODUCER}(authored, { strict: false, [__authoredInput]: true });`, + `};`, `const __tolerantMembers = (owner, ownerName, members) => {`, ` const wrapped = new Map(members.map((member) => [`, ` member,`, @@ -589,14 +630,12 @@ const AUTHORED_SOURCE_PRELUDE: readonly string[] = [ * its argument through as it is, so the two arms leave the chain one input * shape: the stack the author wrote. * - * ⚠️ A composed input does not get this. `composeStacks` builds each package - * body from the stack the input's `defineStack` call RETURNED, so the body is - * converted before the chain sees it. Recovering the authored body would mean - * assembling it again outside the producer: a second copy of composition's - * rule. Inside a composed project, a conversion the load still applies is - * still applied before the chain runs. + * A composed input cannot be substituted this way: `composeStacks` builds each + * package body from the stack its input's producer RETURNED, and rebuilding the + * body from the argument here would be a second copy of composition's rule. + * The next section is how a composed project reaches the same shape. * - * ## A composed project: the handed-through stack is produced again + * ## A composed project: every wrapped input is produced again, as authored * * `composeStacks` is not a `define*` helper, so it runs for real, and its * first step refuses every input no producer built (`STACK_PROVENANCE_MISSING`, @@ -606,22 +645,25 @@ const AUTHORED_SOURCE_PRELUDE: readonly string[] = [ * retired spelling in ANY input was refused with a prescription the author had * already followed. * - * So the shim records each stack it hands through for `defineStack`, and wraps - * `composeStacks` to hand each RECORDED input to the real `defineStack` again - * in its own `strict: false` mode before the real `composeStacks` runs. The - * producer marks its own output (`strict: false` is a choice made inside it), - * so nothing here writes the mark; and an input the shim did not hand through - * — a plain object the author never wrapped, a spread of a built stack — - * reaches the real `composeStacks` untouched and is refused there exactly as - * before. - * - * ⚠️ The one-package path keeps handing the authored argument through RAW, - * on purpose. `strict: false` still runs the load-time ADR-0087 D2 conversion - * pass, so a conversion the load still applies (`driver: 'mongo'`) would be - * applied before the chain runs — unlisted in `applied`, unwritten by - * `--write`. Measured on a refused one-package stack: the raw hand-back lists - * and writes it, a `strict: false` fallback does neither. A composed input - * pays that cost because the composer cannot take it any other way. + * So the shim wraps `composeStacks` to hand each input whose `defineStack` call + * it saw — refused and recorded, or accepted and kept — to the real + * `defineStack` again, before the real `composeStacks` runs, starting from + * what the author wrote. It asks in the producer's `strict: false` mode, with + * the producer's internal parameter that skips the load-time ADR-0087 D2 + * conversion pass ({@link SPEC_AUTHORED_INPUT_OPTION}, #22256). Without that + * parameter a conversion the load still applies (`driver: 'mongo'`) was + * applied while the input was produced again, so the chain never listed it + * and `--write` never wrote it — the one-package defect, inside every package + * body. With it, composition runs its own rule over the authored inputs, and + * each `packages[i].manifest` body the chain reads is what input i's author + * wrote. The producer marks its own output (`strict: false` is a choice made + * inside it), so nothing here writes the mark; and an input the shim never + * saw a `defineStack` call build — a plain object the author never wrapped, a + * spread of a built stack — reaches the real `composeStacks` untouched and is + * refused there exactly as before. + * + * The one-package path keeps handing the authored argument through RAW: it + * needs no producer at all, since nothing composes it. * * ⚠️ Deliberately NOT the default for `loadConfig()`. Every other command — * `os build`, `os validate`, `os serve` — must keep hearing the rejection: the diff --git a/packages/cli/test/migrate-meta-composed-load-conversions.test.ts b/packages/cli/test/migrate-meta-composed-load-conversions.test.ts new file mode 100644 index 00000000000..23f2d23a874 --- /dev/null +++ b/packages/cli/test/migrate-meta-composed-load-conversions.test.ts @@ -0,0 +1,358 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os migrate meta` on a COMPOSED project — + * `composeStacks([defineStack(…), …], { manifest: 'preserve' })` — whose + * package body carries a spelling the load still converts + * (`datasources[].driver: 'mongo'`) (#22256, the composed half). + * + * ## The defect + * + * `composeStacks` assembles each `packages[i].manifest` body from the stack its + * input's `defineStack` call RETURNED, and that call runs the producer's + * load-time ADR-0087 D2 conversion pass — when the schema accepts the input, + * and again when the authored-source shim produces a refused input once more + * in `strict: false` mode. So the chain started from a body that was already + * converted: `applied` did not list the conversion, `--write` did not write + * it, and every later load still printed the notice that sends the author to + * this command. The fix produces every wrapped input again from what the + * author wrote, through the producer's internal parameter that skips the pass, + * so composition's own rule assembles the bodies from the authored source. + * + * ## What is pinned + * + * 1. An accepted input: the conversion is listed under the body's path, + * `--write` writes it into the input's own file and nothing else, the + * re-run applies nothing, and a strict load converts nothing any more. + * 2. Where the authored body comes from: the authored-source load's body + * carries the authored spelling, and every other command's load still + * carries the converted one. + * 3. A refused input (re-produced by the shim) carrying the same spelling + * beside a refused one: each conversion is listed once and both are + * written. + * 4. An input defined twice starts from the innermost argument, once. + * 5. The control: an already-canonical composed project writes nothing, and + * its `--json` summary equals the one the built composition gives. + * + * The strict reload is read through `stackConversions`, the producers' own + * record of what they converted, and not through the stderr notice: that + * notice is printed once per process. + * + * In-process over the real command (`MigrateMeta.run`), against temp projects + * that link the real `@objectstack/spec`: no process is spawned and no kernel + * is booted, so this file sits in the `unit` tier. + */ + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve } from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { stripVTControlCharacters } from 'node:util'; +import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec'; +import MigrateMeta, { applyMetaMigrationsToPackages } from '../src/commands/migrate/meta.js'; +import { loadConfig } from '../src/utils/config.js'; + +const CLI_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..'); +const RUN_TIMEOUT = 120_000; + +/** `packages/cli` depends on `@objectstack/spec`; resolved as a package, not a source path. */ +const requireFromCli = createRequire(import.meta.url); +const SPEC_PACKAGE_ROOT = dirname(requireFromCli.resolve('@objectstack/spec/package.json')); + +let root: string; +let specLink: string; +let caseSeq = 0; + +function writeProject(files: Record): string { + const dir = join(root, `case-${++caseSeq}`); + for (const [rel, text] of Object.entries(files)) { + const path = join(dir, rel); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, text); + } + return dir; +} + +/** Every file under a project, relative path → bytes. */ +function snapshot(dir: string): Record { + const out: Record = {}; + const walk = (d: string) => { + for (const name of readdirSync(d)) { + const p = join(d, name); + if (statSync(p).isDirectory()) walk(p); + else out[relative(dir, p).split('\\').join('/')] = readFileSync(p, 'utf8'); + } + }; + walk(dir); + return out; +} + +/** `text` with `from` replaced by `to` — `from` must occur exactly once. */ +function edit(text: string, from: string, to: string): string { + const parts = text.split(from); + expect(parts.length, `expected exactly one occurrence of ${JSON.stringify(from)}`).toBe(2); + return parts.join(to); +} + +interface Run { + stdout: string; + stderr: string; + exitCode: number | undefined; +} + +/** Run the real command in-process, capturing both streams and any exit. */ +async function runMeta(dir: string, flags: string[]): Promise { + const out: string[] = []; + const err: string[] = []; + const priorExitCode = process.exitCode; + const write = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => { + out.push(String(chunk)); + const done = rest.find((r) => typeof r === 'function') as (() => void) | undefined; + done?.(); + return true; + }) as never); + const log = vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => { out.push(a.join(' ')); }); + const warn = vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => { err.push(a.join(' ')); }); + const error = vi.spyOn(console, 'error').mockImplementation((...a: unknown[]) => { err.push(a.join(' ')); }); + let exitCode: number | undefined; + try { + await MigrateMeta.run([join(dir, 'objectstack.config.ts'), ...flags], { root: CLI_ROOT }); + } catch (e: any) { + if (typeof e?.oclif?.exit !== 'number') throw e; + exitCode = e.oclif.exit; + } finally { + write.mockRestore(); + log.mockRestore(); + warn.mockRestore(); + error.mockRestore(); + if (exitCode === undefined && typeof process.exitCode === 'number' && process.exitCode !== 0) { + exitCode = process.exitCode; + } + process.exitCode = priorExitCode; + } + return { + stdout: stripVTControlCharacters(out.join('\n')), + stderr: stripVTControlCharacters(err.join('\n')), + exitCode, + }; +} + +/** A load with its stderr swallowed: the producers' notices are not what these pins read. */ +async function quietLoad(dir: string, authoredSource = false) { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + return await loadConfig(join(dir, 'objectstack.config.ts'), authoredSource ? { authoredSource: true } : undefined); + } finally { + warn.mockRestore(); + } +} + +const json = (run: Run): any => JSON.parse(run.stdout); +const sites = (list: Array<{ conversionId: string; path: string; from?: string; to?: string }>) => + list.map(({ conversionId, path, from, to }) => ({ conversionId, path, from, to })); +const conversionIds = (list: readonly { conversionId: string }[]) => list.map((c) => c.conversionId); +const bodyDriver = (config: any): string => config.packages[0].manifest.datasources[0].driver; + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'os-migrate-meta-composed-load-conversions-')); + mkdirSync(join(root, 'node_modules', '@objectstack'), { recursive: true }); + specLink = join(root, 'node_modules', '@objectstack', 'spec'); + symlinkSync(SPEC_PACKAGE_ROOT, specLink, 'dir'); +}); + +afterAll(() => { + // Unlinked BEFORE the recursive remove: the symlink points at the real + // `packages/spec`, and a cleanup must never follow it. + try { unlinkSync(specLink); } catch { /* already gone */ } + try { rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ } +}); + +/** + * The service package: a stack the current schema accepts, whose one + * non-canonical spelling is a datasource driver id the load still converts + * (`retiredFromLoadPath: false`); the `url` is what makes a mongo datasource + * valid, so the call is accepted. + */ +const SERVICE_STACK = `import { defineStack } from '@objectstack/spec'; + +export const ServiceStack = defineStack({ + manifest: { id: 'com.probe.svc', name: 'Probe Service', namespace: 'probe', version: '1.0.0', type: 'module' }, + objects: [ + { + name: 'probe_slot', + label: 'Slot', + fields: { + name: { type: 'text', label: 'Name' }, + }, + }, + ], + datasources: [{ name: 'docs', label: 'Docs', driver: 'mongo', config: { url: 'mongodb://mongo.internal:27017/docs' } }], +}); +`; + +const APP_STACK = `import { defineStack } from '@objectstack/spec'; + +export const AppStack = defineStack({ + manifest: { id: 'com.probe.app', name: 'Probe App', namespace: 'probe', version: '1.0.0', type: 'app' }, + objects: [{ name: 'probe_room', label: 'Room', fields: { name: { type: 'text', label: 'Name' } } }], +}); +`; + +const CONFIG = `import { composeStacks } from '@objectstack/spec'; +import { ServiceStack } from './src/service.stack.js'; +import { AppStack } from './src/app.stack.js'; + +export default composeStacks([ServiceStack, AppStack], { manifest: 'preserve' }); +`; + +const composed = (service: string): Record => ({ + 'objectstack.config.ts': CONFIG, + 'src/service.stack.ts': service, + 'src/app.stack.ts': APP_STACK, +}); + +const MONGO_CONVERSION = 'datasource-driver-mongo-to-mongodb'; +const MONGO_BODY_SITE = { + conversionId: MONGO_CONVERSION, + path: 'packages[0].manifest.datasources[0].driver', + from: 'mongo', + to: 'mongodb', +}; +const TIME_BODY_SITE = { + conversionId: 'time-default-utc-suffix-dropped', + path: 'packages[0].manifest.objects[0].fields.starts_at.defaultValue', + from: '"10:00Z"', + to: '"10:00"', +}; + +describe('an accepted input: the chain reads the package body as the author wrote it', () => { + it('lists a conversion the load already applied; --write writes it into the input’s file, and the load stops converting', async () => { + const dir = writeProject(composed(SERVICE_STACK)); + const before = snapshot(dir); + + // The control for the last reading below: today the strict load converts it. + const strictBefore = await quietLoad(dir); + expect(strictBefore.stackProvenance).toBe(true); + expect(conversionIds(strictBefore.stackConversions)).toEqual([MONGO_CONVERSION]); + + const dry = json(await runMeta(dir, ['--from', '16', '--json'])); + expect(sites(dry.applied)).toEqual([MONGO_BODY_SITE]); + expect(dry.schemaValid).toBe(true); + + const run = await runMeta(dir, ['--from', '16', '--write', '--json']); + expect(run.exitCode, run.stdout + run.stderr).toBeUndefined(); + const out = json(run); + expect(sites(out.applied)).toEqual([MONGO_BODY_SITE]); + expect(out.write.status).toBe('written'); + expect(out.write.files).toEqual([{ file: 'src/service.stack.ts', sites: 1 }]); + expect(out.write.manual).toEqual([]); + expect(out.write.unexplained).toEqual([]); + + // The site and nothing else: every other file keeps its bytes. + expect(snapshot(dir)).toEqual({ + ...before, + 'src/service.stack.ts': edit(before['src/service.stack.ts']!, "driver: 'mongo'", "driver: 'mongodb'"), + }); + + const again = json(await runMeta(dir, ['--from', '16', '--json'])); + expect(again.applied).toEqual([]); + expect(again.schemaValid).toBe(true); + + // The load every other command uses — strict, no shim — converts nothing now. + const strictAfter = await quietLoad(dir); + expect(strictAfter.stackProvenance).toBe(true); + expect(strictAfter.stackConversions).toEqual([]); + }, RUN_TIMEOUT); + + it('the authored-source load’s package body carries the authored spelling; every other load’s, the converted one', async () => { + const dir = writeProject(composed(SERVICE_STACK)); + + const authored = await quietLoad(dir, true); + expect(bodyDriver(authored.config)).toBe('mongo'); + expect(authored.stackProvenance).toBe(true); + + const strict = await quietLoad(dir); + expect(bodyDriver(strict.config)).toBe('mongodb'); + }, RUN_TIMEOUT); + + it('an input defined twice starts from the innermost argument, and is listed once', async () => { + const dir = writeProject(composed(edit( + edit(SERVICE_STACK, 'export const ServiceStack = defineStack({', 'export const ServiceStack = defineStack(defineStack({'), + '\n});\n', + '\n}));\n', + ))); + const out = json(await runMeta(dir, ['--from', '16', '--json'])); + expect(sites(out.applied)).toEqual([MONGO_BODY_SITE]); + expect(out.schemaValid).toBe(true); + }, RUN_TIMEOUT); +}); + +describe('a refused input the shim produces again, carrying a load-path spelling too', () => { + // The `time` default with the UTC suffix is refused by the current schema, + // so the shim hands the call's argument through and produces it again in + // `strict: false` mode for the composer; the driver id is the input's one + // load-path conversion. + const MIXED = edit( + SERVICE_STACK, + " name: { type: 'text', label: 'Name' },\n", + " name: { type: 'text', label: 'Name' },\n starts_at: { type: 'time', label: 'Starts', defaultValue: '10:00Z' },\n", + ); + + it('lists each conversion once, and --write writes both into the input’s file', async () => { + const dir = writeProject(composed(MIXED)); + const before = snapshot(dir); + + const dry = json(await runMeta(dir, ['--from', '16', '--json'])); + expect(sites(dry.applied)).toEqual([MONGO_BODY_SITE, TIME_BODY_SITE]); + + const run = await runMeta(dir, ['--from', '16', '--write', '--json']); + expect(run.exitCode, run.stdout + run.stderr).toBeUndefined(); + const out = json(run); + expect(sites(out.applied)).toEqual([MONGO_BODY_SITE, TIME_BODY_SITE]); + expect(out.write.status).toBe('written'); + expect(out.write.files).toEqual([{ file: 'src/service.stack.ts', sites: 2 }]); + expect(snapshot(dir)).toEqual({ + ...before, + 'src/service.stack.ts': edit( + edit(MIXED, "defaultValue: '10:00Z'", "defaultValue: '10:00'"), + "driver: 'mongo'", + "driver: 'mongodb'", + ), + }); + + const again = json(await runMeta(dir, ['--from', '16', '--json'])); + expect(again.applied).toEqual([]); + + const strict = await quietLoad(dir); + expect(strict.stackProvenance).toBe(true); + expect(strict.stackConversions).toEqual([]); + }, RUN_TIMEOUT); +}); + +describe('control: an already-canonical composed project', () => { + const CANONICAL = edit(SERVICE_STACK, "driver: 'mongo'", "driver: 'mongodb'"); + + it('writes nothing, and its --json summary is the one the built composition gives', async () => { + const dir = writeProject(composed(CANONICAL)); + const before = snapshot(dir); + + const run = await runMeta(dir, ['--from', '16', '--write', '--json']); + expect(run.exitCode, run.stdout + run.stderr).toBeUndefined(); + const out = json(run); + expect(out.applied).toEqual([]); + expect(out.write).toEqual({ status: 'written', files: [], written: [], manual: [], unexplained: [] }); + expect(snapshot(dir)).toEqual(before); + + // The chain over the composition the producers built — where it started + // before this change — reaches the same summary. + const built = (await quietLoad(dir)).config as Record; + const old = applyMetaMigrationsToPackages(normalizeStackInput(built, { convert: false }), out.from, out.to); + const plain = (value: unknown) => JSON.parse(JSON.stringify(value)); + expect(out.applied).toEqual(plain(old.applied)); + expect(out.todos).toEqual(plain(old.todos)); + expect(out.absentTodos).toEqual(plain(old.absentTodos)); + expect(out.schemaValid).toBe(ObjectStackDefinitionSchema.safeParse(old.stack).success); + }, RUN_TIMEOUT); +}); diff --git a/packages/spec/src/stack-authored-input.test.ts b/packages/spec/src/stack-authored-input.test.ts new file mode 100644 index 00000000000..4cd9215a847 --- /dev/null +++ b/packages/spec/src/stack-authored-input.test.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `defineStack`'s one internal parameter: a non-strict call whose options + * carry `Symbol.for('objectstack.stack.authoredInput')` returns its input AS + * AUTHORED — normalised and marked as every non-strict output is, with the + * load-time ADR-0087 D2 conversion pass skipped (#22256). + * + * Its one caller is the CLI's authored-source load for `os migrate meta`, + * which produces each `composeStacks` input again this way so that + * composition assembles each package body from what the author wrote, and + * the migration chain can list and write a conversion the load would + * otherwise have applied out of its sight. What is pinned here: + * + * - the non-strict call with the key keeps the authored spelling, is marked, + * and records no conversion; the same call without it (the control) + * converts and records it; + * - a strict call ignores the key, so it can never let an old spelling reach + * the strict parse unconverted; + * - `composeStacks` over such inputs assembles each `packages[i].manifest` + * body from the authored spelling — composition's own rule, run over the + * authored inputs — and over plain non-strict inputs (the control) from the + * converted one. + * + * The record (`stackConversionsOf`), not the stderr notice, is what is read: + * the notice is printed once per process, so its absence proves nothing in a + * file that converts the same spelling in another case. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { composeStacks, defineStack, hasStackProvenance, stackConversionsOf } from './stack.zod'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +/** Spelled here, by its registry name, exactly as the CLI spells it: the key is not exported. */ +const AUTHORED_INPUT = Symbol.for('objectstack.stack.authoredInput'); + +const MONGO = 'datasource-driver-mongo-to-mongodb'; + +/** + * A stack the strict parse accepts, whose one non-canonical spelling is a + * datasource driver id the load still converts. + */ +const service = () => ({ + manifest: { id: 'com.probe.svc', name: 'Probe Service', namespace: 'probe', version: '1.0.0', type: 'module' as const }, + objects: [{ name: 'probe_slot', label: 'Slot', fields: { name: { type: 'text' as const, label: 'Name' } } }], + datasources: [ + { name: 'docs', label: 'Docs', driver: 'mongo', config: { url: 'mongodb://mongo.internal:27017/docs' } }, + ], +}); + +const app = () => ({ + manifest: { id: 'com.probe.app', name: 'Probe App', namespace: 'probe', version: '1.0.0', type: 'app' as const }, + objects: [{ name: 'probe_room', label: 'Room', fields: { name: { type: 'text' as const, label: 'Name' } } }], +}); + +const quiet = () => vi.spyOn(console, 'warn').mockImplementation(() => {}); +const ids = (value: unknown) => stackConversionsOf(value).map((n) => n.conversionId); +const driverOf = (stack: unknown) => (stack as { datasources: Array<{ driver: string }> }).datasources[0]!.driver; + +describe('a non-strict call carrying the key returns its input as authored', () => { + it('keeps the authored spelling, is marked, and records nothing; without the key it converts', () => { + quiet(); + const authored = defineStack(service() as never, { strict: false, [AUTHORED_INPUT]: true } as never); + expect(driverOf(authored)).toBe('mongo'); + expect(hasStackProvenance(authored)).toBe(true); + expect(stackConversionsOf(authored)).toEqual([]); + + const converted = defineStack(service() as never, { strict: false }); + expect(driverOf(converted)).toBe('mongodb'); + expect(ids(converted)).toEqual([MONGO]); + }); + + it('a strict call ignores the key: the pass runs and is recorded', () => { + quiet(); + const strict = defineStack(service() as never, { [AUTHORED_INPUT]: true } as never); + expect(driverOf(strict)).toBe('mongodb'); + expect(ids(strict)).toEqual([MONGO]); + }); +}); + +describe('composeStacks over inputs produced as authored', () => { + const body = (artifact: unknown, i: number) => + (artifact as { packages: Array<{ manifest: unknown }> }).packages[i]!.manifest; + + it('assembles each package body from the authored spelling; over plain non-strict inputs, from the converted one', () => { + quiet(); + const asAuthored = { strict: false, [AUTHORED_INPUT]: true } as never; + const authored = composeStacks( + [defineStack(service() as never, asAuthored), defineStack(app() as never, asAuthored)], + { manifest: 'preserve' }, + ); + expect(hasStackProvenance(authored)).toBe(true); + expect(driverOf(body(authored, 0))).toBe('mongo'); + expect(stackConversionsOf(authored)).toEqual([]); + + const converted = composeStacks( + [defineStack(service() as never, { strict: false }), defineStack(app() as never, { strict: false })], + { manifest: 'preserve' }, + ); + expect(driverOf(body(converted, 0))).toBe('mongodb'); + expect(ids(converted)).toEqual([MONGO]); + }); +}); diff --git a/packages/spec/src/stack-provenance.ts b/packages/spec/src/stack-provenance.ts index 89278c468c9..c88cb4827c5 100644 --- a/packages/spec/src/stack-provenance.ts +++ b/packages/spec/src/stack-provenance.ts @@ -61,7 +61,11 @@ import type { ConversionNotice } from './conversions/types.js'; * ## The record beside the mark: the ADR-0087 conversions the producer applied * * `defineStack` runs the ADR-0087 D2 conversion layer at load, in both modes, - * so an old spelling is already canonical in what it returns. The doors that + * so an old spelling is already canonical in what it returns. The one + * exception is a non-strict call carrying the producer's internal + * authored-input key (`AUTHORED_INPUT_OPTION` in `stack.zod.ts`), which only + * `os migrate meta` makes because it replays the conversions itself: that + * call converts nothing, so its record is its input's own. The doors that * report conversions (`os validate` / `os build`, the `--json` envelope's * `conversions` and `os validate --strict`) receive that returned value — and * a door re-running the conversion pass on it finds nothing left to convert. diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 8ee45603473..dc661e98bd8 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -3823,6 +3823,61 @@ function warnEmailTemplateLocaleFloor(data: ObjectStackDefinition): void { } } +/** + * [ADR-0087 D3 · #22256] The producer's one INTERNAL parameter: the + * global-registry symbol under which a `defineStack` call's `options` asks for + * the stack AS AUTHORED — map→array normalised and its bound actions merged, + * as every non-strict call is, but with the load-time ADR-0087 D2 conversion + * pass skipped. Honoured only together with `strict: false`. + * + * ## Who asks, and why the producer has to be the one that answers + * + * `os migrate meta` replays the conversions itself, as chain steps, against + * the source the author wrote — that is how each rewrite is listed in + * `applied` and traced back to a literal `--write` can edit. A conversion the + * load already applied is invisible to it. On a one-package project the CLI + * starts the chain from the argument the stack's `defineStack` call was given. + * A composed project has no such argument to start from: `composeStacks` + * assembles each `packages[i].manifest` body from the stack its input's + * producer RETURNED, and that assembly is composition's rule, which lives here + * and nowhere else. So the CLI's authored-source load (and only it) hands + * `composeStacks` inputs produced again with this key: the composition then + * runs its own rule over what the author wrote, and nothing outside this + * package copies it. + * + * ## Why a symbol, and why it is not an option + * + * It is not authoring surface. An author never needs a stack whose old + * spellings were left in place — the load converts them so the author does not + * have to — and a declared `DefineStackOptions` key would advertise one. So it + * is neither declared on {@link DefineStackOptions} nor exported: the key + * crosses from the CLI to this producer the way the provenance mark crosses + * back (`stack-provenance.ts`), by its `Symbol.for` name, which two copies of + * this package resolve alike. + * + * ## What it cannot do + * + * - **Relax a strict call.** A strict call ignores it, so it can never let a + * spelling the conversion pass would have repaired reach the strict parse, + * and never skips a refusal: `strict: false` is what skips the judgement, + * exactly as before. + * - **Hide what it skipped.** Nothing is converted, so the stack's conversion + * record is its input's, and no `converted at load` notice is printed for a + * conversion that did not happen. + * - **Mint a stack a non-strict call could not already mint.** The output is + * marked, as every non-strict output is, and judged by nothing, as every + * non-strict output is. What differs is only that an old spelling is left + * where the author wrote it, which a door that reports conversions finds + * with its own pass over what it loads (`stackConversionsOf`'s caveat). + */ +const AUTHORED_INPUT_OPTION: symbol = Symbol.for('objectstack.stack.authoredInput'); + +/** Does this non-strict call ask for its input as authored ({@link AUTHORED_INPUT_OPTION})? */ +function asksForAuthoredInput(options: DefineStackOptions | undefined): boolean { + if (options === null || typeof options !== 'object') return false; + return (options as Record)[AUTHORED_INPUT_OPTION] === true; +} + export function defineStack( config: ObjectStackDefinitionInput, options?: DefineStackOptions, @@ -3889,8 +3944,11 @@ function buildDefinedStack( // warning below this runs in BOTH modes: a conversion happens whether or not // we go on to parse, so `strict: false` does not make the old shape any less // retiring. Each notice is pushed to the caller's record (see - // {@link defineStack}) as well as printed, warn-once, on stderr. + // {@link defineStack}) as well as printed, warn-once, on stderr. The one + // exception is a non-strict call carrying {@link AUTHORED_INPUT_OPTION}: the + // migration chain's own load, which replays the conversions itself. const normalized = normalizeStackInput(config as Record, { + convert: strict || !asksForAuthoredInput(options), onConversionNotice: (notice) => { appliedConversions.push(notice); warnConversionNotice(notice);