diff --git a/.changeset/9591-migrate-meta-write.md b/.changeset/9591-migrate-meta-write.md
new file mode 100644
index 00000000000..a6bb673ec1d
--- /dev/null
+++ b/.changeset/9591-migrate-meta-write.md
@@ -0,0 +1,15 @@
+---
+"@objectstack/cli": minor
+---
+
+`os migrate meta --write` writes the chain's mechanical changes into the authored source files, in place, at every site it can prove
+
+Clause-②: yes (widening)
+
+- A new flag on the authored-source mode: `os migrate meta --from N --write`. Without it nothing changes: the dry run, its report and its `--json` payload are what they were, and `--out` still writes its snapshot.
+- What it writes: each mechanical change the chain applied (`applied`), at a site it traces to one object or array literal in one project file — through `define*` calls and the `.create(…)` factories `@objectstack/spec` exports, module-level `const` bindings, relative imports and re-exports, and `Object.values()` over a namespace import — when the loaded value matches that literal and nothing else references the bindings on the way. Only that site's bytes change: a renamed key keeps its value and its comments, a removed key takes its own line(s), and every other byte (comments, formatting, key order) stays as it was.
+- What it refuses, each change listed with the reason (`--json`: `write.manual[].kind`): `computed`, `helper`, `spread`, `shared`, `outside-project`, `mismatch`, `injected`, `unspellable`, `layout` and `unattributed`; and `entangled`, because a conversion's edits are written whole or not at all.
+- What it never writes: the semantic changes (`todos`), which stay listed exactly as before, and a site a conversion declines, for which no mechanical change exists.
+- After writing it re-runs the chain over the written sources. Unless the re-run applies exactly the changes it left, it restores every file it wrote and exits 1.
+- `--json` gains a `write` key, only with `--write`: `status`, `files`, `written`, `manual`, `unexplained` and `verification`.
+- `--write` is exclusive with `--stored`; `--stored --apply` is unchanged.
diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx
index 385558ab309..5a12f54dc83 100644
--- a/content/docs/deployment/cli.mdx
+++ b/content/docs/deployment/cli.mdx
@@ -1430,7 +1430,8 @@ an old dialect this pass can convert exits `1`. So "my metadata is on protocol N
becomes a check rather than a belief.
Note the division of labour with the default mode: `os migrate meta --from N`
-lists the edits **an author's source** needs and reads no database; `--stored`
+lists the edits **an author's source** needs — with `--write`, it also writes the
+ones it can trace to one literal into the source files — and reads no database; `--stored`
rewrites **one deployment's rows** and reads no config. Same chain, opposite
ends of the contract — which is why the two modes are mutually exclusive.
diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx
index e760feaa53c..5a7f15c68b5 100644
--- a/content/docs/upgrading.mdx
+++ b/content/docs/upgrading.mdx
@@ -215,7 +215,8 @@ Useful flags:
| Flag | What it does |
| :--- | :--- |
| `--step` | Report each major's hop separately, so a failure bisects to the exact major |
-| `--out migrated.stack.json` | Also write the migrated stack as a JSON snapshot — the only file the command writes |
+| `--out migrated.stack.json` | Also write the migrated stack as a JSON snapshot |
+| `--write` | Write the mechanical changes into your source files, where each can be traced to one literal; list the rest with the reason (see below) |
| `--to 17` | Stop at an intermediate major instead of this runtime's |
| `--json` | Machine-readable output, for CI or an agent |
@@ -226,11 +227,22 @@ stored metadata rows and reads no config. The two are mutually exclusive.)
### What it does not do — read the output
-**`os migrate meta` does not rewrite your source files.** It replays the chain
-over the loaded stack *in memory* and reports the diff; the only file it writes
-is `--out`, a JSON snapshot. Porting the listed edits into your own `.ts`
-sources is your work — use `--out` as the oracle you diff against, never as the
-file you ship.
+**Without `--write`, `os migrate meta` does not rewrite your source files.** It
+replays the chain over the loaded stack *in memory* and reports the diff; the
+only file it writes is `--out`, a JSON snapshot — use it as the oracle you diff
+against, never as the file you ship.
+
+**`--write` writes the mechanical changes into your `.ts` sources — only where
+it can prove the site.** A change is written when it traces to one object or
+array literal in one project file: through `define*` calls and `.create`
+factories, `const` bindings, relative imports and re-exports, and
+`Object.values()` over a namespace import. Only that site's bytes change; a
+renamed key keeps its value and its comments. Every other change — a value
+built by a function call or an expression, a key a spread supplies, a binding
+something else also uses, a file in a package — is listed with the reason it was
+not written, for you to port by hand. The command then re-runs the chain over
+the written files and restores them all unless it comes back with exactly the
+changes it left. Commit first, and review the diff like any other change.
@@ -274,7 +286,8 @@ has already done.
```bash
os migrate meta --from 16 # 1. read the mechanical change list and the to-dos
- # 2. apply the edits by hand; resolve the to-dos
+ # 2. apply the edits (--write writes the ones it can
+ # trace and lists the rest); resolve the to-dos
# and the checklist items below
os validate # 3. the gate — schema, CEL predicates, widget bindings
os build # 4. compile to dist/objectstack.json
diff --git a/packages/cli/src/commands/migrate/meta.ts b/packages/cli/src/commands/migrate/meta.ts
index d762ae032c4..1a67b183f6d 100644
--- a/packages/cli/src/commands/migrate/meta.ts
+++ b/packages/cli/src/commands/migrate/meta.ts
@@ -29,6 +29,7 @@ import {
createTimer,
emitJson,
errorCodeFields,
+ isExitSignal,
isReportedError,
} from '../../utils/format.js';
import { bootSchemaStack } from '../../utils/schema-migrate.js';
@@ -37,6 +38,14 @@ import { absentTableReads } from '../../utils/absent-table-reads.js';
import type { StoredMigrationReport } from '@objectstack/metadata-protocol';
import { OCCUPANCY_HINT, probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js';
import { describeOccupancy } from '../../utils/sqlite-occupancy.js';
+import {
+ planAuthoredSourceWrite,
+ restoreAuthoredSources,
+ verifyAuthoredSourceWrite,
+ writeAuthoredSources,
+ type AuthoredSourceWritePlan,
+ type WriteVerification,
+} from '../../utils/authored-source-codemod.js';
async function confirm(question: string): Promise {
if (!process.stdin.isTTY) return false; // non-interactive → require --yes
@@ -245,6 +254,110 @@ function printPendingDataMigrations(pending: readonly PendingDataMigration[]): v
/** One schema refusal of the migrated stack, in the shape `formatZodIssue` renders. */
export type MigrationRefusal = Parameters[0];
+/** What `--write` did with the authored sources (#9591). */
+export interface WriteOutcome {
+ plan: AuthoredSourceWritePlan;
+ /**
+ * `written` — the plan's files were written and the re-run agreed with it;
+ * `restored` — they were written, the re-run disagreed, and every one was
+ * put back; `unwritten` — writing was refused before any file changed.
+ * A plan with no file to write is `written` with an empty `rewrites`.
+ */
+ status: 'written' | 'restored' | 'unwritten';
+ /** The re-run's verdict, when files were written. */
+ verification?: WriteVerification;
+ /** Why the write was refused or undone. */
+ error?: string;
+}
+
+/** The `--json` face of a {@link WriteOutcome}. */
+export function writeOutcomeJson(outcome: WriteOutcome) {
+ const { plan } = outcome;
+ return {
+ status: outcome.status,
+ files: plan.rewrites.map((r) => ({
+ file: r.file,
+ sites: plan.written.filter((w) => w.file === r.file).length,
+ })),
+ written: plan.written.map((w) => ({
+ conversionId: w.application.conversionId,
+ path: w.application.path,
+ file: w.file,
+ line: w.line,
+ })),
+ manual: plan.manual.map((m) => ({
+ conversionId: m.application.conversionId,
+ path: m.application.path,
+ kind: m.refusal.kind,
+ reason: m.refusal.reason,
+ })),
+ unexplained: plan.unexplained,
+ ...(outcome.verification ? { verification: outcome.verification } : {}),
+ ...(outcome.error ? { error: outcome.error } : {}),
+ };
+}
+
+/**
+ * The `--write` group: what was written where, what was left and why, and
+ * whether the re-run over the written sources agreed. Printed after the
+ * semantic notices and before the data-migration advice, which stays last.
+ */
+function printWriteOutcome(outcome: WriteOutcome, appliedCount: number): void {
+ const { plan } = outcome;
+ if (appliedCount === 0) {
+ printInfo('--write: the chain made no mechanical change here, so no file was written.');
+ console.log('');
+ return;
+ }
+ if (outcome.status === 'unwritten') {
+ printError(`--write wrote nothing: ${outcome.error}`);
+ console.log('');
+ return;
+ }
+ const files = plan.rewrites.length;
+ if (outcome.status === 'restored') {
+ printError(
+ `--write wrote ${files} file(s), but re-running the chain over them did not match this report, so `
+ + `every one was restored to its previous bytes: ${outcome.error}`,
+ );
+ console.log('');
+ return;
+ }
+ console.log(chalk.bold(
+ ` Wrote ${plan.written.length} of ${appliedCount} mechanical change(s) into ${files} file(s):`,
+ ));
+ for (const r of plan.rewrites) {
+ console.log(` ${chalk.white(r.file)}`);
+ for (const w of plan.written.filter((x) => x.file === r.file)) {
+ console.log(` ${chalk.dim(`:${w.line}`)} ${w.application.path} ${chalk.dim(`(${w.application.conversionId})`)}`);
+ }
+ }
+ console.log('');
+ if (plan.manual.length > 0) {
+ console.log(chalk.bold(chalk.yellow(` ${plan.manual.length} mechanical change(s) left for you to apply by hand:`)));
+ for (const m of plan.manual) {
+ const a = m.application;
+ console.log(` ${chalk.yellow('•')} ${a.path}: ${a.from} → ${a.to} ${chalk.dim(`(${a.conversionId})`)}`);
+ console.log(chalk.dim(` not written [${m.refusal.kind}]: ${m.refusal.reason}`));
+ }
+ console.log('');
+ }
+ if (plan.unexplained.length > 0) {
+ printWarning(
+ `${plan.unexplained.length} change(s) in the migrated stack are named by no applied entry and were not `
+ + `written: ${plan.unexplained.join(', ')}`,
+ );
+ }
+ if (files > 0) {
+ printSuccess(
+ `Re-ran the chain over the written sources: ${plan.manual.length === 0
+ ? 'no mechanical change remains.'
+ : `only the ${plan.manual.length} change(s) left above remain.`}`,
+ );
+ console.log('');
+ }
+}
+
/** Everything the human report prints after the `Config:` / `Chain:` preamble. */
export interface MigrationReport {
/** The chain's result. Every group prints in chain order — never re-sorted, filtered or merged. */
@@ -260,6 +373,8 @@ export interface MigrationReport {
step: boolean;
/** `--out`, resolved — the snapshot is written here so its line keeps its place. */
out?: string;
+ /** `--write`: what was written into the authored sources (absent without the flag). */
+ write?: WriteOutcome;
/** Printed beside a schema-valid verdict. */
elapsed: string;
}
@@ -413,6 +528,7 @@ export function printMigrationReport(report: MigrationReport): void {
// Still advertise: metadata needing no rewrite says nothing about whether
// this deployment's DATA has been migrated.
console.log('');
+ if (report.write) printWriteOutcome(report.write, 0);
printPendingDataMigrations(report.dataMigrations);
// Returning is safe only because ① has already printed: the schema verdict
// is the one line that can contradict a "nothing to do" answer, and
@@ -451,6 +567,9 @@ export function printMigrationReport(report: MigrationReport): void {
printInfo(`Wrote migrated stack snapshot → ${chalk.white(report.out)}`);
}
+ // ④ `--write`: the mechanical changes written into the sources, and the rest.
+ if (report.write) printWriteOutcome(report.write, result.applied.length);
+
printPendingDataMigrations(report.dataMigrations);
}
@@ -466,10 +585,14 @@ export function printMigrationReport(report: MigrationReport): void {
* TODOs for the semantic changes the chain cannot apply, so the consumer agent
* reviews a provably-valid change instead of hand-porting from prose.
*
- * The command does not silently rewrite TS config source (that AST rewrite is
- * unsafe and lossy); `--out` writes the canonicalized stack as a JSON snapshot
- * the agent can diff and adopt. `--step` prints a per-hop checkpoint so a failure
- * can be bisected to the exact major.
+ * By default it writes no source file: `--out` writes the canonicalized stack as
+ * a JSON snapshot the agent can diff and adopt. `--write` (#9591) writes the
+ * mechanical changes into the authored sources in place — only at sites it can
+ * trace to one literal in one project file, every other byte left as it was —
+ * and lists each change it could not trace, with the reason; it never writes a
+ * semantic TODO. See `utils/authored-source-codemod.ts` for what it proves
+ * before writing and the closed set of reasons it refuses. `--step` prints a
+ * per-hop checkpoint so a failure can be bisected to the exact major.
*
* ## `--stored`: the same chain, over data at rest (#4327)
*
@@ -500,6 +623,7 @@ export default class MigrateMeta extends Command {
`$ os migrate meta --from ${MIGRATION_SUPPORT_FLOOR} --step`,
`$ os migrate meta --from ${MIGRATION_SUPPORT_FLOOR} --to ${MIGRATION_SUPPORT_FLOOR + 1} --json`,
`$ os migrate meta --from ${MIGRATION_SUPPORT_FLOOR} --out migrated.stack.json`,
+ `$ os migrate meta --from ${MIGRATION_SUPPORT_FLOOR} --write`,
'$ os migrate meta --stored',
'$ os migrate meta --stored --apply',
'$ os migrate meta --stored --apply --yes --json',
@@ -530,6 +654,14 @@ export default class MigrateMeta extends Command {
description: 'Write the migrated stack as a JSON snapshot to this path.',
exclusive: ['stored'],
}),
+ write: Flags.boolean({
+ description:
+ 'Rewrite the authored source files in place for each mechanical change traced to one literal in one '
+ + 'project file; every other change is listed with the reason it was not written. Never writes the '
+ + 'manual (semantic) changes.',
+ default: false,
+ exclusive: ['stored'],
+ }),
stored: Flags.boolean({
description:
"Canonicalize this deployment's sys_metadata rows in place instead of an authored config "
@@ -577,7 +709,8 @@ export default class MigrateMeta extends Command {
const message =
`${typed.map((f) => `--${f}`).join(', ')} only appl${typed.length > 1 ? 'y' : 'ies'} to `
+ '`os migrate meta --stored` (the pass over a deployment\'s sys_metadata rows). '
- + 'The authored-source chain reads a config file and writes nothing but --out.';
+ + 'The authored-source chain reads a config file and writes only the --out snapshot and, '
+ + 'with --write, the authored sources.';
if (flags.json) {
await emitJson({ error: 'stored_only_flag', flags: typed, message }, 0, { compact: true });
this.exit(1);
@@ -626,7 +759,7 @@ export default class MigrateMeta extends Command {
// has no validation step of its own to move: the load is tolerant, and
// the schema verdict is taken below on the MIGRATED stack instead
// (`schemaValid`), which is the stack the author is being asked to adopt.
- const { config, absolutePath } = await loadConfig(args.config, { authoredSource: true });
+ const { config, absolutePath, namedExports } = await loadConfig(args.config, { authoredSource: true });
// Map→array normalization ONLY (convert:false): the chain must replay the
// conversions itself against the raw authored source so each rewrite is
@@ -643,6 +776,20 @@ export default class MigrateMeta extends Command {
const specChanges = composeSpecChanges(fromMajor, toMajor);
const dataMigrations = pendingDataMigrations(result.stack, result.fromMajor, result.toMajor);
+ // `--write` (#9591): the mechanical changes go into the authored sources
+ // where they can be proved, and the write is held to a re-run of the chain.
+ const write = flags.write
+ ? await this.writeSources({
+ configArg: args.config,
+ configPath: absolutePath,
+ config: config as Record,
+ namedExports,
+ normalized,
+ result,
+ json: Boolean(flags.json),
+ })
+ : undefined;
+
if (flags.json) {
await emitJson({
from: result.fromMajor,
@@ -673,9 +820,12 @@ export default class MigrateMeta extends Command {
// Per-deployment data migrations this chain leaves to the
// operator — the metadata is only half of a crossing upgrade.
dataMigrations,
+ // Only with `--write`: without it the payload is what it always was.
+ ...(write ? { write: writeOutcomeJson(write) } : {}),
duration: timer.elapsed(),
});
if (flags.out) writeFileSync(resolve(flags.out), JSON.stringify(result.stack, null, 2));
+ if (write && write.status !== 'written') this.exit(1);
return;
}
@@ -702,9 +852,13 @@ export default class MigrateMeta extends Command {
dataMigrations,
step: flags.step,
...(flags.out ? { out: resolve(flags.out) } : {}),
+ ...(write ? { write } : {}),
elapsed: timer.display(),
});
+ // A write refused or undone is a failed run, reported above.
+ if (write && write.status !== 'written') this.exit(1);
} catch (error: any) {
+ if (isExitSignal(error)) throw error;
if (error instanceof MigrationFloorError) {
if (flags.json) {
await emitJson({ error: 'unsupported_from_major', message: error.message }, 0, { compact: true });
@@ -726,6 +880,69 @@ export default class MigrateMeta extends Command {
}
}
+ /**
+ * `--write`: plan the source edits, write them, re-run the chain over the
+ * written sources, and restore every file when the re-run disagrees with
+ * the plan (#9591). Writes nothing when the chain applied nothing.
+ */
+ private async writeSources(input: {
+ configArg: string | undefined;
+ configPath: string;
+ config: Record;
+ namedExports: readonly string[];
+ normalized: Record;
+ result: MigrationChainResult;
+ json: boolean;
+ }): Promise {
+ const { result } = input;
+ if (!input.json && result.applied.length > 0) printStep('Writing the mechanical changes into the authored sources…');
+ const plan = await planAuthoredSourceWrite({
+ configPath: input.configPath,
+ config: input.config,
+ namedExports: input.namedExports,
+ normalized: input.normalized,
+ migrated: result.stack,
+ applied: result.applied,
+ });
+ if (plan.rewrites.length === 0) return { plan, status: 'written' };
+ try {
+ writeAuthoredSources(plan);
+ } catch (error: any) {
+ return { plan, status: 'unwritten', error: error.message || String(error) };
+ }
+
+ let verification: WriteVerification;
+ // The re-load hands the remaining refused artifacts through the
+ // authored-source shim again, and it would announce each of them a second
+ // time; the first load already said so, and the verdict below is the one
+ // thing this load is for.
+ const warn = console.warn;
+ console.warn = () => {};
+ try {
+ const reloaded = await loadConfig(input.configArg, { authoredSource: true });
+ const rerun = applyMetaMigrations(
+ normalizeStackInput(reloaded.config as Record, { convert: false }),
+ result.fromMajor,
+ result.toMajor,
+ );
+ verification = verifyAuthoredSourceWrite(plan, rerun.applied);
+ } catch (error: any) {
+ restoreAuthoredSources(plan);
+ return { plan, status: 'restored', error: `the re-run over the written sources failed: ${error.message || String(error)}` };
+ } finally {
+ console.warn = warn;
+ }
+ if (!verification.ok) {
+ restoreAuthoredSources(plan);
+ const parts = [
+ ...(verification.stillApplied.length > 0 ? [`still converted: ${verification.stillApplied.join(', ')}`] : []),
+ ...(verification.vanished.length > 0 ? [`no longer converted: ${verification.vanished.join(', ')}`] : []),
+ ];
+ return { plan, status: 'restored', verification, error: parts.join('; ') };
+ }
+ return { plan, status: 'written', verification };
+ }
+
/**
* `os migrate meta --stored` — canonicalize this deployment's `sys_metadata`
* rows so the read-path conversion chain has a finish line (#4327).
diff --git a/packages/cli/src/utils/authored-source-codemod.ts b/packages/cli/src/utils/authored-source-codemod.ts
new file mode 100644
index 00000000000..af6865b2eb2
--- /dev/null
+++ b/packages/cli/src/utils/authored-source-codemod.ts
@@ -0,0 +1,1758 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * `os migrate meta --write` — write the chain's MECHANICAL edits back into the
+ * authored sources, at the sites it can prove, and list every other one (#9591).
+ *
+ * ## What the chain hands over, and why it is not an edit
+ *
+ * `applyMetaMigrations` replays the conversions over the LOADED, normalised
+ * stack in memory. Each `applied` entry names a conversion and a `path` into
+ * that stack, plus `from` / `to` DISPLAY strings: a token (`mongo` →
+ * `mongodb`), a removal (`striped` → `(removed)`) or a rendered shape
+ * (`'previousPeriod'` → `{ kind: 'previousPeriod' }`). The path's anchor also
+ * differs per conversion — the renamed-to key, the removed key, or the
+ * container a key was removed from. So an entry alone says WHERE a conversion
+ * acted, never WHAT bytes to write, and reading `from` / `to` as edits would
+ * mean a parser per conversion.
+ *
+ * The edit is therefore taken from the chain's own two stacks instead: a
+ * structural diff of the stack the chain started from against the stack it
+ * produced, which is exactly — and only — what the conversions changed. Every
+ * diff change is tied back to the `applied` entries whose paths explain it
+ * (the site itself, a key inside it, the container of it, or a sibling key in
+ * the same container), and nothing here knows any conversion by name: a
+ * conversion added to the chain is covered the day it lands.
+ *
+ * ## The one thing this may write: a literal it can PROVE is the source
+ *
+ * A diff change is a path into a runtime value. It is written only when that
+ * path leads, through the authored modules' syntax, to ONE object or array
+ * literal in ONE project file — and the loaded value agrees with that literal:
+ *
+ * - the walk starts at the config module's default export and follows the
+ * path through object and array literals, module-level `const` bindings,
+ * relative imports and re-exports, `Object.values()` (the
+ * barrel shape the example apps use, in the module namespace's sorted
+ * order), and the `@objectstack/spec` `define*` helpers and `.create`
+ * factories, which the authored-source load hands their argument through;
+ * - the literal's own statically known values must match the loaded value at
+ * that site (a helper that parsed, defaulted or rebuilt it fails the match);
+ * - every binding the walk crossed must have no reference other than the
+ * walk's own, so the edit cannot change a second use of the same literal.
+ *
+ * Anything else — a computed value, a spread, a call to any other helper, a
+ * shared binding, a file under `node_modules` or outside the project, a key
+ * the loader injected, a new value with no literal spelling, a layout an edit
+ * would have to disturb — is REFUSED, by a named {@link CodemodRefusalKind},
+ * and the site stays on the list for the author. Nothing is guessed.
+ *
+ * ## Exactness
+ *
+ * Edits are text splices at node positions, so every byte outside an edited
+ * site — comments, formatting, key order — is unchanged. A key rename keeps
+ * its value's text (and its comments); a removed property takes its own
+ * line(s) with it, including a comment on those same lines, and nothing else.
+ * A conversion's edits are written whole or not at all: when one of its sites
+ * is refused, its other sites are left too (`entangled`), so no source is ever
+ * left half-converted. The command re-runs the chain over the written sources
+ * and restores every file if the written sites do not come back clean.
+ *
+ * ## What it never writes
+ *
+ * The chain's semantic TODOs (`todos`) are judgment calls; nothing here reads
+ * them. A site a conversion declines (the `compareTo: { offset: '7d' }` case)
+ * produces no diff, so it is never written either — it stays exactly where the
+ * schema verdict and the semantic notice already put it.
+ */
+
+import { existsSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
+import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
+import type { ts as TS } from 'ts-morph';
+import type { MigrationApplication } from '@objectstack/spec/migrations';
+import { syntacticDiagnostics } from './emitted-source-parses.js';
+
+type Ts = typeof TS;
+
+/** One step of a path into a stack: an object key or an array index. */
+export type Segment = string | number;
+
+/**
+ * Why a site was not written. A closed set: the command prints the kind beside
+ * the reason, and the pins hold each kind to a fixture that produces it.
+ */
+export type CodemodRefusalKind =
+ /** The value or its container is produced by an expression, not written as a literal. */
+ | 'computed'
+ /** Built by a call that is not an `@objectstack/spec` `define*` helper or `.create` factory. */
+ | 'helper'
+ /** A spread supplies the key (or the array element), or may override it. */
+ | 'spread'
+ /** The literal is reachable through a binding something else also references. */
+ | 'shared'
+ /** The site lives under `node_modules`, in a package, or outside the project. */
+ | 'outside-project'
+ /** The loaded value does not match the literal, so the literal is not provably its source. */
+ | 'mismatch'
+ /** The key is in the loaded value but not in the literal: the loader or a helper supplied it. */
+ | 'injected'
+ /** The converted value has no literal spelling (a function, `undefined`, a class instance). */
+ | 'unspellable'
+ /** The text around the site cannot be edited without touching bytes outside it. */
+ | 'layout'
+ /** No edit in the migrated stack could be tied to this entry. */
+ | 'unattributed'
+ /** Another site of the same conversion (or of one sharing an edit with it) was refused. */
+ | 'entangled';
+
+export interface CodemodRefusal {
+ kind: CodemodRefusalKind;
+ /** One sentence naming what blocked the write, in the author's terms. */
+ reason: string;
+}
+
+/** A mechanical change written into a source file. */
+export interface WrittenSite {
+ application: MigrationApplication;
+ /** Project-relative path of the file written. */
+ file: string;
+ /** 1-based line of the site in the file as it was before the write. */
+ line: number;
+}
+
+/** A mechanical change left for the author, with the reason it was not written. */
+export interface ManualSite {
+ application: MigrationApplication;
+ refusal: CodemodRefusal;
+}
+
+/** One file the plan rewrites: its bytes before and after. */
+export interface FileRewrite {
+ /** Absolute path. */
+ path: string;
+ /** Project-relative path, for display. */
+ file: string;
+ before: string;
+ after: string;
+}
+
+export interface AuthoredSourceWritePlan {
+ /** The project directory every written file lies under (the config's directory). */
+ projectRoot: string;
+ rewrites: FileRewrite[];
+ written: WrittenSite[];
+ manual: ManualSite[];
+ /**
+ * Paths the migrated stack changed that no `applied` entry explains. Never
+ * written. Expected to be empty: a non-empty list means a conversion rewrote
+ * a site without reporting it.
+ */
+ unexplained: string[];
+}
+
+export interface AuthoredSourceWriteInput {
+ /** Absolute path of the loaded config module. */
+ configPath: string;
+ /** The loaded config (`loadConfig(…, { authoredSource: true }).config`). */
+ config: Record;
+ /** The named exports `loadConfig` merged in as top-level keys. */
+ namedExports: readonly string[];
+ /** The stack the chain started from (`normalizeStackInput(config, { convert: false })`). */
+ normalized: Record;
+ /** The stack the chain produced (`result.stack`). */
+ migrated: Record;
+ /** The chain's mechanical applications (`result.applied`), in order. */
+ applied: readonly MigrationApplication[];
+}
+
+/** Thrown inside a walk; caught at the change it was resolving. */
+class Refusal extends Error {
+ constructor(readonly refusal: CodemodRefusal) {
+ super(refusal.reason);
+ }
+}
+
+function refuse(kind: CodemodRefusalKind, reason: string): never {
+ throw new Refusal({ kind, reason });
+}
+
+// ── value helpers ────────────────────────────────────────────────────────────
+
+function isPlainObject(value: unknown): value is Record {
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
+ const proto: unknown = Object.getPrototypeOf(value);
+ return proto === null || Object.getPrototypeOf(proto) === null;
+}
+
+/** The keys that carry a value: an own key holding `undefined` reads as absent. */
+function presentKeys(value: Record): string[] {
+ return Object.keys(value).filter((k) => value[k] !== undefined);
+}
+
+function deepEqual(a: unknown, b: unknown): boolean {
+ if (Object.is(a, b)) return true;
+ if (Array.isArray(a) && Array.isArray(b)) {
+ return a.length === b.length && a.every((v, i) => deepEqual(v, b[i]));
+ }
+ if (isPlainObject(a) && isPlainObject(b)) {
+ const ka = presentKeys(a);
+ const kb = presentKeys(b);
+ return ka.length === kb.length && ka.every((k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]));
+ }
+ return false;
+}
+
+/** Render a path the way the chain's `applied[].path` spells one: `a.b[0].c`. */
+export function formatPath(path: readonly Segment[]): string {
+ let out = '';
+ for (const seg of path) {
+ if (typeof seg === 'number') out += `[${seg}]`;
+ else out += out === '' ? seg : `.${seg}`;
+ }
+ return out;
+}
+
+/** Parse an `applied[].path` (`a.b[0].c`) into segments. */
+export function parsePath(path: string): Segment[] {
+ const out: Segment[] = [];
+ for (const part of path.split('.')) {
+ const m = /^([^[\]]*)((?:\[\d+\])*)$/.exec(part);
+ if (!m) {
+ out.push(part);
+ continue;
+ }
+ if (m[1] !== '') out.push(m[1]!);
+ for (const idx of m[2]!.matchAll(/\[(\d+)\]/g)) out.push(Number(idx[1]));
+ }
+ return out;
+}
+
+function isPrefix(prefix: readonly Segment[], path: readonly Segment[]): boolean {
+ return prefix.length <= path.length && prefix.every((s, i) => s === path[i]);
+}
+
+function sameParent(a: readonly Segment[], b: readonly Segment[]): boolean {
+ return a.length === b.length && a.length > 0 && isPrefix(a.slice(0, -1), b);
+}
+
+// ── the structural diff ──────────────────────────────────────────────────────
+
+/** One difference between the stack the chain started from and the one it produced. */
+export type StackChange =
+ | { op: 'set'; path: Segment[]; before: unknown; after: unknown }
+ | { op: 'delete'; path: Segment[]; before: unknown }
+ | { op: 'add'; path: Segment[]; after: unknown }
+ /** Elements dropped from an array; `path` is the array, `indices` are into `before`. */
+ | { op: 'remove'; path: Segment[]; indices: number[]; before: unknown[] };
+
+/** `small` is `big` with some elements taken out, in order — the dropped indices, or null. */
+function droppedIndices(big: readonly unknown[], small: readonly unknown[]): number[] | null {
+ const dropped: number[] = [];
+ let j = 0;
+ for (let i = 0; i < big.length; i++) {
+ if (j < small.length && deepEqual(big[i], small[j])) j++;
+ else dropped.push(i);
+ }
+ return j === small.length ? dropped : null;
+}
+
+/**
+ * The minimal structural difference between two stacks, as the conversions
+ * made it. Copy-on-write conversions keep every untouched subtree's identity,
+ * so the walk only descends where something changed.
+ */
+export function diffStacks(before: unknown, after: unknown, path: Segment[] = [], out: StackChange[] = []): StackChange[] {
+ if (Object.is(before, after)) return out;
+ if (isPlainObject(before) && isPlainObject(after)) {
+ const kb = presentKeys(before);
+ const ka = presentKeys(after);
+ for (const k of kb) {
+ if (!ka.includes(k)) out.push({ op: 'delete', path: [...path, k], before: before[k] });
+ else diffStacks(before[k], after[k], [...path, k], out);
+ }
+ for (const k of ka) {
+ if (!kb.includes(k)) out.push({ op: 'add', path: [...path, k], after: after[k] });
+ }
+ return out;
+ }
+ if (Array.isArray(before) && Array.isArray(after)) {
+ if (before.length === after.length) {
+ for (let i = 0; i < before.length; i++) diffStacks(before[i], after[i], [...path, i], out);
+ return out;
+ }
+ const dropped = after.length < before.length ? droppedIndices(before, after) : null;
+ if (dropped) out.push({ op: 'remove', path, indices: dropped, before });
+ else out.push({ op: 'set', path, before, after });
+ return out;
+ }
+ if (deepEqual(before, after)) return out;
+ out.push({ op: 'set', path, before, after });
+ return out;
+}
+
+// ── attribution: which applied entries explain which changes ────────────────
+
+/**
+ * The rules that tie a change to the applied entries explaining it, tried in
+ * order; a change takes the entries of the FIRST rule that finds any, so a
+ * looser rule never widens an entry that already has its own edits:
+ *
+ * 1. the change is the site, inside it, or a container replaced around it;
+ * 2. the change is a sibling key of the site — a rename's old key, where the
+ * entry names the new one;
+ * 3. a MOVE between two containers of one subject (`node.config.x` →
+ * `node.waitEventConfig.y`): the entry names the destination, the change is
+ * the source, and both sit under the subject two steps above the entry.
+ */
+const ATTRIBUTION_RULES: ReadonlyArray<(applied: readonly Segment[], change: readonly Segment[]) => boolean> = [
+ (applied, change) => isPrefix(applied, change) || isPrefix(change, applied),
+ (applied, change) => sameParent(applied, change),
+ (applied, change) => applied.length >= 3 && isPrefix(applied.slice(0, -2), change),
+];
+
+/** The applied entries (by index) that explain a change at `change`, by {@link ATTRIBUTION_RULES}. */
+function explainingEntries(appliedPaths: readonly (readonly Segment[])[], change: readonly Segment[]): number[] {
+ for (const rule of ATTRIBUTION_RULES) {
+ const hits = appliedPaths.flatMap((p, i) => (rule(p, change) ? [i] : []));
+ if (hits.length > 0) return hits;
+ }
+ return [];
+}
+
+// ── the authored module graph, read statically ──────────────────────────────
+
+interface SourceFileRec {
+ readonly path: string;
+ readonly text: string;
+ readonly sf: TS.SourceFile;
+}
+
+/** What the walk knows about a value, read from the source rather than run. */
+type SNode =
+ | { k: 'object'; file: SourceFileRec; node: TS.ObjectLiteralExpression }
+ | { k: 'array'; file: SourceFileRec; node: TS.ArrayLiteralExpression }
+ /** A sequence the authored code assembles (`Object.values(ns)`, an array with spreads). */
+ | { k: 'list'; items: Array<(trail: Trail) => SNode>; describe: string }
+ | { k: 'namespace'; file: SourceFileRec }
+ | { k: 'scalar'; file: SourceFileRec; node: TS.Expression }
+ /** A binding imported from `@objectstack/spec` (`name` is the imported name, `*` a namespace). */
+ | { k: 'spec'; name: string }
+ | { k: 'specMember'; owner: string; member: string };
+
+/** A module-level binding the walk crossed, and how to tell its references apart. */
+interface BindingRef {
+ readonly file: SourceFileRec;
+ readonly name: string;
+}
+
+/**
+ * What one walk crossed: every binding it resolved, and every identifier it
+ * resolved them THROUGH. A binding whose references are not all in `uses` is
+ * referenced by something the walk did not take, so its literal is shared.
+ */
+class Trail {
+ readonly bindings = new Map();
+ readonly uses = new Set();
+}
+
+/**
+ * The two patterns the authored-source shim (`loadConfig`'s `authoredSource`,
+ * `utils/config.ts`) wraps by: an `@objectstack/spec` entrypoint, and a
+ * `define*` export of it. A call the shim makes tolerant hands its argument
+ * through when the current schema refuses it, so the walk may follow the
+ * argument; when the schema accepts it, the subset check below catches any
+ * default or transform the real helper applied.
+ *
+ * `.create(…)` is followed on the same terms: every `create` an
+ * `@objectstack/spec` entrypoint exports either validates (and is wrapped by
+ * the shim's `STRICT_AUTHORING_FACTORIES`) or returns its argument untouched —
+ * `test/migrate-meta-strict-factories.test.ts` holds that list to the spec
+ * surface in both directions.
+ */
+const SPEC_SPECIFIER_RE = /^@objectstack\/spec(?:\/[\w./-]+)?$/;
+const DEFINE_HELPER_RE = /^define[A-Z]/;
+const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/;
+const TS_EXT_FOR_JS: Readonly> = {
+ '.js': ['.ts', '.tsx', '.js'],
+ '.jsx': ['.tsx', '.jsx'],
+ '.mjs': ['.mts', '.mjs'],
+ '.cjs': ['.cts', '.cjs'],
+};
+const PROBE_EXTS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'];
+
+const UNKNOWN = Symbol('unknown');
+/** A value read from source, or {@link UNKNOWN} where the source does not say. */
+type Readable = unknown;
+
+class SourceGraph {
+ private readonly files = new Map();
+ private readonly exportNameCache = new Map>();
+
+ constructor(
+ private readonly ts: Ts,
+ readonly projectRoot: string,
+ ) {}
+
+ rel(file: SourceFileRec | string): string {
+ const p = typeof file === 'string' ? file : file.path;
+ const r = relative(this.projectRoot, p);
+ return r === '' ? p : r.split(sep).join('/');
+ }
+
+ file(path: string): SourceFileRec {
+ const hit = this.files.get(path);
+ if (hit) return hit;
+ const text = readFileSync(path, 'utf8');
+ const kind = /\.[cm]?tsx?$/.test(path)
+ ? (path.endsWith('x') ? this.ts.ScriptKind.TSX : this.ts.ScriptKind.TS)
+ : (path.endsWith('x') ? this.ts.ScriptKind.JSX : this.ts.ScriptKind.JS);
+ const sf = this.ts.createSourceFile(path, text, this.ts.ScriptTarget.Latest, true, kind);
+ const rec = { path, text, sf };
+ this.files.set(path, rec);
+ return rec;
+ }
+
+ /** Every file this graph has read — the population a reference count looks across. */
+ loaded(): SourceFileRec[] {
+ return [...this.files.values()];
+ }
+
+ /** A RELATIVE specifier resolved the way the bundler does; `null` for a package specifier. */
+ resolveModule(from: SourceFileRec, specifier: string): string | null {
+ if (!specifier.startsWith('.') && !isAbsolute(specifier)) return null;
+ const base = resolve(dirname(from.path), specifier);
+ const ext = /\.[cm]?jsx?$/.exec(base)?.[0];
+ const candidates: string[] = [];
+ if (ext && TS_EXT_FOR_JS[ext]) {
+ const stem = base.slice(0, -ext.length);
+ for (const e of TS_EXT_FOR_JS[ext]!) candidates.push(stem + e);
+ } else {
+ candidates.push(base);
+ for (const e of PROBE_EXTS) candidates.push(base + e);
+ for (const e of PROBE_EXTS) candidates.push(resolve(base, `index${e}`));
+ }
+ for (const c of candidates) {
+ if (existsSync(c) && statSync(c).isFile()) return c;
+ }
+ return null;
+ }
+
+ /** Read every file reachable from `entry` through relative imports and re-exports. */
+ crawl(entry: string): void {
+ const queue = [entry];
+ const seen = new Set();
+ while (queue.length > 0) {
+ const path = queue.shift()!;
+ if (seen.has(path)) continue;
+ seen.add(path);
+ const rec = this.file(path);
+ for (const stmt of rec.sf.statements) {
+ const spec = (this.ts.isImportDeclaration(stmt) || this.ts.isExportDeclaration(stmt))
+ && stmt.moduleSpecifier && this.ts.isStringLiteral(stmt.moduleSpecifier)
+ ? stmt.moduleSpecifier.text
+ : undefined;
+ if (spec === undefined) continue;
+ const target = this.resolveModule(rec, spec);
+ if (target) queue.push(target);
+ }
+ }
+ }
+
+ // ── reading one expression ──
+
+ private unwrap(expr: TS.Expression): TS.Expression {
+ const ts = this.ts;
+ let e = expr;
+ for (;;) {
+ if (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isSatisfiesExpression(e)
+ || ts.isTypeAssertionExpression(e) || ts.isNonNullExpression(e)) {
+ e = e.expression;
+ continue;
+ }
+ return e;
+ }
+ }
+
+ /** The literal node an initializer is, under `as const` / parentheses — or undefined. */
+ literalNode(expr: TS.Expression): TS.Expression | undefined {
+ const e = this.unwrap(expr);
+ return this.isScalar(e) || this.ts.isObjectLiteralExpression(e) || this.ts.isArrayLiteralExpression(e) ? e : undefined;
+ }
+
+ private isScalar(e: TS.Expression): boolean {
+ const ts = this.ts;
+ return ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e) || ts.isNumericLiteral(e)
+ || e.kind === ts.SyntaxKind.TrueKeyword || e.kind === ts.SyntaxKind.FalseKeyword
+ || e.kind === ts.SyntaxKind.NullKeyword
+ || (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(e.operand));
+ }
+
+ private scalarValue(e: TS.Expression): unknown {
+ const ts = this.ts;
+ if (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) return e.text;
+ if (ts.isNumericLiteral(e)) return Number(e.text.replace(/_/g, ''));
+ if (e.kind === ts.SyntaxKind.TrueKeyword) return true;
+ if (e.kind === ts.SyntaxKind.FalseKeyword) return false;
+ if (e.kind === ts.SyntaxKind.NullKeyword) return null;
+ if (ts.isPrefixUnaryExpression(e) && ts.isNumericLiteral(e.operand)) return -Number(e.operand.text.replace(/_/g, ''));
+ return UNKNOWN;
+ }
+
+ /** A property's key as the runtime spells it, or undefined when it is computed. */
+ propName(name: TS.PropertyName): string | undefined {
+ const ts = this.ts;
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) return name.text;
+ if (ts.isNumericLiteral(name)) return String(Number(name.text.replace(/_/g, '')));
+ if (ts.isComputedPropertyName(name)) {
+ const e = this.unwrap(name.expression);
+ if (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) return e.text;
+ }
+ return undefined;
+ }
+
+ /**
+ * The value a literal spells, with whatever it cannot know left out: an
+ * object keeps only the keys written after its last spread with a plain
+ * name, an array marks an unreadable element UNKNOWN.
+ */
+ partialValue(expr: TS.Expression): Readable {
+ const ts = this.ts;
+ const e = this.unwrap(expr);
+ if (this.isScalar(e)) return this.scalarValue(e);
+ if (ts.isArrayLiteralExpression(e)) {
+ if (e.elements.some((el) => ts.isSpreadElement(el) || ts.isOmittedExpression(el))) return UNKNOWN;
+ return e.elements.map((el) => this.partialValue(el));
+ }
+ if (ts.isObjectLiteralExpression(e)) {
+ const out: Record = {};
+ const lastSpread = lastSpreadIndex(ts, e);
+ e.properties.forEach((p, i) => {
+ if (i < lastSpread || !ts.isPropertyAssignment(p)) return;
+ const name = this.propName(p.name);
+ if (name === undefined) return;
+ const v = this.partialValue(p.initializer);
+ if (v !== UNKNOWN) out[name] = v;
+ });
+ return out;
+ }
+ return UNKNOWN;
+ }
+
+ /** The value a literal spells when every part of it is known, else UNKNOWN. */
+ exactValue(expr: TS.Expression): Readable {
+ const ts = this.ts;
+ const e = this.unwrap(expr);
+ if (this.isScalar(e)) return this.scalarValue(e);
+ if (ts.isArrayLiteralExpression(e)) {
+ const out: unknown[] = [];
+ for (const el of e.elements) {
+ if (ts.isSpreadElement(el) || ts.isOmittedExpression(el)) return UNKNOWN;
+ const v = this.exactValue(el);
+ if (v === UNKNOWN) return UNKNOWN;
+ out.push(v);
+ }
+ return out;
+ }
+ if (ts.isObjectLiteralExpression(e)) {
+ const out: Record = {};
+ for (const p of e.properties) {
+ if (!ts.isPropertyAssignment(p)) return UNKNOWN;
+ const name = this.propName(p.name);
+ if (name === undefined) return UNKNOWN;
+ const v = this.exactValue(p.initializer);
+ if (v === UNKNOWN) return UNKNOWN;
+ out[name] = v;
+ }
+ return out;
+ }
+ return UNKNOWN;
+ }
+
+ // ── following a value through the authored modules ──
+
+ private identifierId(file: SourceFileRec, id: TS.Node): string {
+ return `${file.path}#${id.getStart(file.sf)}`;
+ }
+
+ /** Resolve an expression to what the walk can know about its value; throws a {@link Refusal}. */
+ resolveExpr(file: SourceFileRec, expr: TS.Expression, trail: Trail): SNode {
+ const ts = this.ts;
+ const e = this.unwrap(expr);
+ if (ts.isObjectLiteralExpression(e)) return { k: 'object', file, node: e };
+ if (ts.isArrayLiteralExpression(e)) {
+ if (!e.elements.some((el) => ts.isSpreadElement(el) || ts.isOmittedExpression(el))) {
+ return { k: 'array', file, node: e };
+ }
+ return this.expandArray(file, e, trail);
+ }
+ if (this.isScalar(e)) return { k: 'scalar', file, node: e };
+ if (ts.isIdentifier(e)) {
+ trail.uses.add(this.identifierId(file, e));
+ return this.resolveIdentifier(file, e.text, trail);
+ }
+ if (ts.isPropertyAccessExpression(e)) {
+ const base = this.resolveExpr(file, e.expression, trail);
+ const member = e.name.text;
+ if (base.k === 'namespace') return this.resolveExport(base.file, member, trail, new Set());
+ if (base.k === 'spec') return { k: 'specMember', owner: base.name, member };
+ if (base.k === 'object') {
+ const prop = this.findProp(base, member);
+ return this.resolveExpr(base.file, this.valueOf(prop), trail);
+ }
+ refuse('computed', `\`${this.snippet(file, e)}\` reads a member of a value that is not written as a literal`);
+ }
+ if (ts.isCallExpression(e)) return this.resolveCall(file, e, trail);
+ refuse('computed', `the value is the expression \`${this.snippet(file, e)}\`, not a literal`);
+ }
+
+ private resolveCall(file: SourceFileRec, call: TS.CallExpression, trail: Trail): SNode {
+ const ts = this.ts;
+ const callee = this.unwrap(call.expression);
+ const args = call.arguments;
+ if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression)
+ && callee.expression.text === 'Object' && callee.name.text === 'values'
+ && args.length === 1 && !ts.isSpreadElement(args[0]!)) {
+ return this.objectValues(file, args[0]!, trail);
+ }
+ let target: SNode | undefined;
+ try {
+ target = this.resolveExpr(file, callee, trail);
+ } catch (error) {
+ if (!(error instanceof Refusal)) throw error;
+ }
+ const transparent = target !== undefined
+ && ((target.k === 'spec' && DEFINE_HELPER_RE.test(target.name))
+ || (target.k === 'specMember' && target.member === 'create'));
+ if (!transparent) {
+ refuse('helper', `the value is built by \`${this.snippet(file, callee)}(…)\`, not written as a literal`);
+ }
+ if (args.length < 1 || ts.isSpreadElement(args[0]!)) {
+ refuse('helper', `\`${this.snippet(file, callee)}(…)\` is not called with a literal argument`);
+ }
+ return this.resolveExpr(file, args[0]!, trail);
+ }
+
+ private objectValues(file: SourceFileRec, arg: TS.Expression, trail: Trail): SNode {
+ const base = this.resolveExpr(file, arg, trail);
+ if (base.k === 'namespace') {
+ // A module namespace enumerates its exports in sorted (code unit) order —
+ // the order the bundler's namespace object, and the spec, both use.
+ const names = [...this.exportNames(base.file)].sort();
+ return {
+ k: 'list',
+ describe: `Object.values(${this.snippet(file, arg)})`,
+ items: names.map((n) => (t: Trail) => this.resolveExport(base.file, n, t, new Set())),
+ };
+ }
+ if (base.k === 'object') {
+ const ts = this.ts;
+ const props = base.node.properties;
+ if (props.some((p) => !ts.isPropertyAssignment(p) && !ts.isShorthandPropertyAssignment(p))) {
+ refuse('spread', `\`Object.values(${this.snippet(file, arg)})\` reads an object with a spread or a method`);
+ }
+ const named = props.map((p) => ({ p, name: this.propName((p as TS.PropertyAssignment).name) }));
+ if (named.some((n) => n.name === undefined) || new Set(named.map((n) => n.name)).size !== named.length) {
+ refuse('computed', `\`Object.values(${this.snippet(file, arg)})\` reads an object with a computed or repeated key`);
+ }
+ // Object.keys order: integer-like keys ascending, then the rest as written.
+ const isIndex = (k: string) => /^(0|[1-9]\d*)$/.test(k) && Number(k) < 2 ** 32 - 1;
+ const ordered = [
+ ...named.filter((n) => isIndex(n.name!)).sort((a, b) => Number(a.name) - Number(b.name)),
+ ...named.filter((n) => !isIndex(n.name!)),
+ ];
+ return {
+ k: 'list',
+ describe: `Object.values(${this.snippet(file, arg)})`,
+ items: ordered.map(({ p }) => (t: Trail) => this.resolveExpr(base.file, this.valueOf(p as TS.ObjectLiteralElementLike), t)),
+ };
+ }
+ refuse('computed', `\`Object.values(${this.snippet(file, arg)})\` reads a value that is not a module namespace or an object literal`);
+ }
+
+ private expandArray(file: SourceFileRec, node: TS.ArrayLiteralExpression, trail: Trail): SNode {
+ const ts = this.ts;
+ const items: Array<(t: Trail) => SNode> = [];
+ for (const el of node.elements) {
+ if (ts.isOmittedExpression(el)) {
+ items.push(() => refuse('computed', 'the array has a hole at this index'));
+ } else if (ts.isSpreadElement(el)) {
+ const source = this.resolveExpr(file, el.expression, trail);
+ if (source.k === 'array') {
+ for (const inner of source.node.elements) items.push((t) => this.resolveExpr(source.file, inner, t));
+ } else if (source.k === 'list') {
+ items.push(...source.items);
+ } else {
+ refuse('spread', `\`...${this.snippet(file, el.expression)}\` spreads a value that is not a literal array`);
+ }
+ } else {
+ items.push((t) => this.resolveExpr(file, el, t));
+ }
+ }
+ return { k: 'list', items, describe: 'an array literal with spreads' };
+ }
+
+ private resolveIdentifier(file: SourceFileRec, name: string, trail: Trail): SNode {
+ const ts = this.ts;
+ for (const stmt of file.sf.statements) {
+ if (ts.isVariableStatement(stmt)) {
+ for (const decl of stmt.declarationList.declarations) {
+ if (!ts.isIdentifier(decl.name) || decl.name.text !== name) continue;
+ if (!(stmt.declarationList.flags & ts.NodeFlags.Const)) {
+ refuse('computed', `\`${name}\` is a \`let\`/\`var\` binding, which can be reassigned`);
+ }
+ if (!decl.initializer) refuse('computed', `\`${name}\` has no initializer`);
+ trail.bindings.set(`${file.path}#${name}`, { file, name });
+ return this.resolveExpr(file, decl.initializer, trail);
+ }
+ } else if (ts.isImportDeclaration(stmt) && stmt.importClause && ts.isStringLiteral(stmt.moduleSpecifier)) {
+ const clause = stmt.importClause;
+ const specifier = stmt.moduleSpecifier.text;
+ let imported: string | undefined;
+ if (clause.name?.text === name) imported = 'default';
+ const bindings = clause.namedBindings;
+ if (bindings && ts.isNamespaceImport(bindings) && bindings.name.text === name) imported = '*';
+ if (bindings && ts.isNamedImports(bindings)) {
+ for (const el of bindings.elements) {
+ if (el.name.text === name) {
+ if (el.isTypeOnly) refuse('computed', `\`${name}\` is a type-only import`);
+ imported = (el.propertyName ?? el.name).text;
+ }
+ }
+ }
+ if (imported === undefined) continue;
+ if (clause.isTypeOnly) refuse('computed', `\`${name}\` is a type-only import`);
+ if (SPEC_SPECIFIER_RE.test(specifier)) return { k: 'spec', name: imported };
+ const target = this.resolveModule(file, specifier);
+ if (!target) refuse('outside-project', `\`${name}\` is imported through ${unresolved(specifier)}`);
+ const mod = this.file(target);
+ if (imported === '*') return { k: 'namespace', file: mod };
+ return this.resolveExport(mod, imported, trail, new Set());
+ } else if ((ts.isFunctionDeclaration(stmt) || ts.isClassDeclaration(stmt) || ts.isEnumDeclaration(stmt))
+ && stmt.name?.text === name) {
+ refuse('computed', `\`${name}\` is a ${ts.isFunctionDeclaration(stmt) ? 'function' : ts.isClassDeclaration(stmt) ? 'class' : 'enum'}, not a literal`);
+ }
+ }
+ refuse('computed', `\`${name}\` is not a module-level binding of ${this.rel(file)}`);
+ }
+
+ /** Resolve export `name` of `mod` to the value it carries; throws a {@link Refusal}. */
+ resolveExport(mod: SourceFileRec, name: string, trail: Trail, visiting: Set): SNode {
+ const ts = this.ts;
+ const key = `${mod.path}#${name}`;
+ if (visiting.has(key)) refuse('computed', `\`${name}\` is re-exported in a cycle`);
+ visiting.add(key);
+ const stars: SourceFileRec[] = [];
+ for (const stmt of mod.sf.statements) {
+ if (ts.isVariableStatement(stmt) && hasModifier(ts, stmt, ts.SyntaxKind.ExportKeyword)) {
+ for (const decl of stmt.declarationList.declarations) {
+ if (ts.isIdentifier(decl.name) && decl.name.text === name) return this.resolveIdentifier(mod, name, trail);
+ }
+ } else if (ts.isExportAssignment(stmt) && !stmt.isExportEquals && name === 'default') {
+ trail.bindings.set(`${mod.path}#default`, { file: mod, name: 'default' });
+ return this.resolveExpr(mod, stmt.expression, trail);
+ } else if ((ts.isFunctionDeclaration(stmt) || ts.isClassDeclaration(stmt))
+ && hasModifier(ts, stmt, ts.SyntaxKind.ExportKeyword)
+ && (hasModifier(ts, stmt, ts.SyntaxKind.DefaultKeyword) ? 'default' : stmt.name?.text) === name) {
+ refuse('computed', `\`${name}\` exported by ${this.rel(mod)} is a ${ts.isFunctionDeclaration(stmt) ? 'function' : 'class'}, not a literal`);
+ } else if (ts.isExportDeclaration(stmt) && !stmt.isTypeOnly) {
+ const specifier = stmt.moduleSpecifier && ts.isStringLiteral(stmt.moduleSpecifier) ? stmt.moduleSpecifier.text : undefined;
+ const clause = stmt.exportClause;
+ if (clause && ts.isNamedExports(clause)) {
+ for (const el of clause.elements) {
+ if (el.isTypeOnly || el.name.text !== name) continue;
+ const local = (el.propertyName ?? el.name).text;
+ if (specifier === undefined) return this.resolveIdentifier(mod, local, trail);
+ if (SPEC_SPECIFIER_RE.test(specifier)) return { k: 'spec', name: local };
+ const target = this.resolveModule(mod, specifier);
+ if (!target) refuse('outside-project', `\`${name}\` is re-exported from ${unresolved(specifier)}`);
+ return this.resolveExport(this.file(target), local, trail, visiting);
+ }
+ } else if (clause && ts.isNamespaceExport(clause) && clause.name.text === name && specifier !== undefined) {
+ const target = this.resolveModule(mod, specifier);
+ if (!target) refuse('outside-project', `\`${name}\` re-exports ${unresolved(specifier)}`);
+ return { k: 'namespace', file: this.file(target) };
+ } else if (!clause && specifier !== undefined && name !== 'default') {
+ const target = this.resolveModule(mod, specifier);
+ if (target) stars.push(this.file(target));
+ }
+ }
+ }
+ const hits = stars.filter((s) => this.exportNames(s).has(name));
+ if (hits.length === 1) return this.resolveExport(hits[0]!, name, trail, visiting);
+ if (hits.length > 1) refuse('computed', `\`${name}\` is exported by more than one \`export *\` of ${this.rel(mod)}`);
+ refuse('computed', `\`${name}\` is not a runtime export of ${this.rel(mod)}`);
+ }
+
+ /** The runtime export names of a module — types excluded, star re-exports followed. */
+ exportNames(mod: SourceFileRec, visiting: Set = new Set()): Set {
+ const cached = this.exportNameCache.get(mod.path);
+ if (cached) return cached;
+ if (visiting.has(mod.path)) return new Set();
+ visiting.add(mod.path);
+ const ts = this.ts;
+ const names = new Set();
+ const localTypes = new Set();
+ for (const stmt of mod.sf.statements) {
+ if ((ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)) && stmt.name) localTypes.add(stmt.name.text);
+ }
+ for (const stmt of mod.sf.statements) {
+ const exported = hasModifier(ts, stmt, ts.SyntaxKind.ExportKeyword) && !hasModifier(ts, stmt, ts.SyntaxKind.DeclareKeyword);
+ if (ts.isVariableStatement(stmt) && exported) {
+ for (const decl of stmt.declarationList.declarations) if (ts.isIdentifier(decl.name)) names.add(decl.name.text);
+ } else if ((ts.isFunctionDeclaration(stmt) || ts.isClassDeclaration(stmt) || ts.isEnumDeclaration(stmt)) && exported) {
+ if (hasModifier(ts, stmt, ts.SyntaxKind.DefaultKeyword)) names.add('default');
+ else if (stmt.name) names.add(stmt.name.text);
+ } else if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) {
+ names.add('default');
+ } else if (ts.isExportDeclaration(stmt) && !stmt.isTypeOnly) {
+ const specifier = stmt.moduleSpecifier && ts.isStringLiteral(stmt.moduleSpecifier) ? stmt.moduleSpecifier.text : undefined;
+ const target = specifier !== undefined ? this.resolveModule(mod, specifier) : null;
+ const clause = stmt.exportClause;
+ if (clause && ts.isNamedExports(clause)) {
+ for (const el of clause.elements) {
+ if (el.isTypeOnly) continue;
+ const local = (el.propertyName ?? el.name).text;
+ if (specifier === undefined && localTypes.has(local)) continue;
+ if (target && !this.exportNames(this.file(target), visiting).has(local)) continue;
+ names.add(el.name.text);
+ }
+ } else if (clause && ts.isNamespaceExport(clause)) {
+ names.add(clause.name.text);
+ } else if (!clause && target) {
+ for (const n of this.exportNames(this.file(target), visiting)) if (n !== 'default') names.add(n);
+ }
+ }
+ }
+ this.exportNameCache.set(mod.path, names);
+ return names;
+ }
+
+ /** The value expression of an object-literal member the walk may follow. */
+ valueOf(prop: TS.ObjectLiteralElementLike): TS.Expression {
+ const ts = this.ts;
+ if (ts.isPropertyAssignment(prop)) return prop.initializer;
+ if (ts.isShorthandPropertyAssignment(prop)) return prop.name;
+ refuse('computed', 'the key is a method or an accessor, not a value');
+ }
+
+ /**
+ * The member of an object literal that supplies `key` at runtime. Refused
+ * when a spread supplies it or could override it, or when it is not written
+ * there at all.
+ */
+ findProp(obj: { file: SourceFileRec; node: TS.ObjectLiteralExpression }, key: string): TS.ObjectLiteralElementLike {
+ const ts = this.ts;
+ const props = obj.node.properties;
+ const lastSpread = lastSpreadIndex(ts, obj.node);
+ let found: number | undefined;
+ let computed = false;
+ props.forEach((p, i) => {
+ if (ts.isSpreadAssignment(p)) return;
+ const name = p.name ? this.propName(p.name) : undefined;
+ if (name === undefined) computed = true;
+ else if (name === key) {
+ if (found !== undefined) refuse('mismatch', `\`${key}\` is written twice in this literal`);
+ found = i;
+ }
+ });
+ if (found === undefined) {
+ if (lastSpread >= 0) refuse('spread', `\`${key}\` comes from a spread (\`...\`) in ${this.rel(obj.file)}, not from a key written there`);
+ if (computed) refuse('computed', `\`${key}\` may come from a computed key in ${this.rel(obj.file)}`);
+ refuse('injected', `\`${key}\` is not written in the literal at ${this.at(obj.file, obj.node)} — the loader or a helper supplied it`);
+ }
+ if (found < lastSpread) refuse('spread', `a spread written after \`${key}\` in ${this.rel(obj.file)} may override it`);
+ return props[found]!;
+ }
+
+ snippet(file: SourceFileRec, node: TS.Node): string {
+ const text = node.getText(file.sf).replace(/\s+/g, ' ');
+ return text.length > 60 ? `${text.slice(0, 57)}...` : text;
+ }
+
+ at(file: SourceFileRec, node: TS.Node): string {
+ return `${this.rel(file)}:${file.sf.getLineAndCharacterOfPosition(node.getStart(file.sf)).line + 1}`;
+ }
+
+ // ── who else references a binding ──
+
+ /** The binding an export resolves to (`file#local`), or null when it is not one this walk tracks. */
+ private bindingOfExport(mod: SourceFileRec, name: string, visiting: Set = new Set()): string | null {
+ const ts = this.ts;
+ const key = `${mod.path}#${name}`;
+ if (visiting.has(key)) return null;
+ visiting.add(key);
+ const stars: SourceFileRec[] = [];
+ for (const stmt of mod.sf.statements) {
+ if (ts.isVariableStatement(stmt) && hasModifier(ts, stmt, ts.SyntaxKind.ExportKeyword)) {
+ for (const decl of stmt.declarationList.declarations) {
+ if (ts.isIdentifier(decl.name) && decl.name.text === name) return key;
+ }
+ } else if (ts.isExportAssignment(stmt) && !stmt.isExportEquals && name === 'default') {
+ return key;
+ } else if (ts.isExportDeclaration(stmt) && !stmt.isTypeOnly) {
+ const specifier = stmt.moduleSpecifier && ts.isStringLiteral(stmt.moduleSpecifier) ? stmt.moduleSpecifier.text : undefined;
+ const clause = stmt.exportClause;
+ if (clause && ts.isNamedExports(clause)) {
+ for (const el of clause.elements) {
+ if (el.isTypeOnly || el.name.text !== name) continue;
+ const local = (el.propertyName ?? el.name).text;
+ if (specifier === undefined) return `${mod.path}#${local}`;
+ const target = this.resolveModule(mod, specifier);
+ return target ? this.bindingOfExport(this.file(target), local, visiting) : null;
+ }
+ } else if (!clause && specifier !== undefined && name !== 'default') {
+ const target = this.resolveModule(mod, specifier);
+ if (target) stars.push(this.file(target));
+ }
+ }
+ }
+ const hits = stars.filter((s) => this.exportNames(s).has(name));
+ return hits.length === 1 ? this.bindingOfExport(hits[0]!, name, visiting) : null;
+ }
+
+ /** Whether an identifier occurrence READS a binding (not a declaration, key, type or specifier). */
+ private isReference(id: TS.Identifier): boolean {
+ const ts = this.ts;
+ const p = id.parent;
+ if (!p) return false;
+ if ((ts.isVariableDeclaration(p) || ts.isFunctionDeclaration(p) || ts.isClassDeclaration(p)
+ || ts.isEnumDeclaration(p) || ts.isInterfaceDeclaration(p) || ts.isTypeAliasDeclaration(p)
+ || ts.isParameter(p) || ts.isPropertyAssignment(p) || ts.isMethodDeclaration(p)
+ || ts.isPropertyDeclaration(p) || ts.isGetAccessorDeclaration(p) || ts.isSetAccessorDeclaration(p)
+ || ts.isPropertySignature(p) || ts.isMethodSignature(p) || ts.isEnumMember(p)) && p.name === id) return false;
+ if (ts.isBindingElement(p) && (p.name === id || p.propertyName === id)) return false;
+ if (ts.isPropertyAccessExpression(p) && p.name === id) return false;
+ if (ts.isImportSpecifier(p) || ts.isImportClause(p) || ts.isNamespaceImport(p)
+ || ts.isExportSpecifier(p) || ts.isNamespaceExport(p) || ts.isQualifiedName(p)
+ || ts.isLabeledStatement(p) || ts.isBreakOrContinueStatement(p)) return false;
+ for (let n: TS.Node | undefined = p; n && !ts.isSourceFile(n); n = n.parent) {
+ if (ts.isTypeNode(n)) return false;
+ if (ts.isStatement(n)) break;
+ }
+ return true;
+ }
+
+ private referenceIds(file: SourceFileRec, names: ReadonlySet, keep?: (id: TS.Identifier) => boolean): string[] {
+ const ts = this.ts;
+ const out: string[] = [];
+ const visit = (node: TS.Node): void => {
+ if (ts.isIdentifier(node) && names.has(node.text) && this.isReference(node) && (!keep || keep(node))) {
+ out.push(this.identifierId(file, node));
+ }
+ node.forEachChild(visit);
+ };
+ visit(file.sf);
+ return out;
+ }
+
+ /**
+ * Every identifier, across the files this graph read, that reads the binding
+ * `file#name`: by its own name in its own module, through a default or named
+ * import (under any alias), or through a namespace import of any module that
+ * exports it — `ns.Name` or the namespace used whole.
+ */
+ references(binding: BindingRef): string[] {
+ const ts = this.ts;
+ const key = `${binding.file.path}#${binding.name}`;
+ const out = binding.name === 'default' ? [] : this.referenceIds(binding.file, new Set([binding.name]));
+ for (const g of this.loaded()) {
+ for (const stmt of g.sf.statements) {
+ if (!ts.isImportDeclaration(stmt) || !stmt.importClause || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
+ const target = this.resolveModule(g, stmt.moduleSpecifier.text);
+ if (!target) continue;
+ const mod = this.file(target);
+ const clause = stmt.importClause;
+ const aliases = new Set();
+ if (clause.name && this.bindingOfExport(mod, 'default') === key) aliases.add(clause.name.text);
+ const nb = clause.namedBindings;
+ if (nb && ts.isNamedImports(nb)) {
+ for (const el of nb.elements) {
+ if (this.bindingOfExport(mod, (el.propertyName ?? el.name).text) === key) aliases.add(el.name.text);
+ }
+ }
+ if (aliases.size > 0) out.push(...this.referenceIds(g, aliases));
+ if (nb && ts.isNamespaceImport(nb)) {
+ const via = [...this.exportNames(mod)].filter((n) => this.bindingOfExport(mod, n) === key);
+ if (via.length > 0) {
+ out.push(...this.referenceIds(g, new Set([nb.name.text]), (id) => {
+ const p = id.parent;
+ // `ns.Other` reads a different export; anything else reaches this one.
+ return !(p && ts.isPropertyAccessExpression(p) && p.expression === id && !via.includes(p.name.text));
+ }));
+ }
+ }
+ }
+ }
+ return out;
+ }
+}
+
+/** Why a specifier leads nowhere the walk reads. */
+function unresolved(specifier: string): string {
+ return specifier.startsWith('.') || isAbsolute(specifier)
+ ? `\`${specifier}\`, which resolves to no project file`
+ : `\`${specifier}\` — a package or a path alias, not a project file this walk reads`;
+}
+
+function hasModifier(ts: Ts, node: TS.Node, kind: TS.SyntaxKind): boolean {
+ const mods = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined;
+ return !!mods?.some((m) => m.kind === kind);
+}
+
+function lastSpreadIndex(ts: Ts, node: TS.ObjectLiteralExpression): number {
+ let last = -1;
+ node.properties.forEach((p, i) => { if (ts.isSpreadAssignment(p)) last = i; });
+ return last;
+}
+
+// ── locating a change's container in the source ─────────────────────────────
+
+interface Located {
+ readonly key: string;
+ readonly node: Extract;
+ /** The loaded value at the container's path. */
+ readonly rt: unknown;
+}
+
+function subsetOf(partial: unknown, rt: unknown): boolean {
+ if (partial === UNKNOWN) return true;
+ if (Array.isArray(partial)) {
+ return Array.isArray(rt) && rt.length === partial.length && partial.every((v, i) => subsetOf(v, rt[i]));
+ }
+ if (isPlainObject(partial)) {
+ return isPlainObject(rt) && Object.keys(partial).every((k) => Object.prototype.hasOwnProperty.call(rt, k) && subsetOf(partial[k], rt[k]));
+ }
+ return Object.is(partial, rt);
+}
+
+class Locator {
+ private readonly cache = new Map();
+ private readonly configFile: SourceFileRec;
+
+ constructor(
+ private readonly ts: Ts,
+ private readonly graph: SourceGraph,
+ private readonly config: Record,
+ private readonly namedExports: readonly string[],
+ configPath: string,
+ ) {
+ this.configFile = graph.file(configPath);
+ }
+
+ private root(trail: Trail): SNode {
+ const ts = this.ts;
+ for (const stmt of this.configFile.sf.statements) {
+ if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) return this.graph.resolveExpr(this.configFile, stmt.expression, trail);
+ }
+ if (this.graph.exportNames(this.configFile).has('default')) {
+ return this.graph.resolveExport(this.configFile, 'default', trail, new Set());
+ }
+ return { k: 'namespace', file: this.configFile };
+ }
+
+ private step(node: SNode, seg: Segment, trail: Trail, rtParent: unknown, atRoot: boolean): SNode {
+ const g = this.graph;
+ switch (node.k) {
+ case 'object': {
+ if (typeof seg !== 'string') refuse('mismatch', `the literal at ${g.at(node.file, node.node)} is an object where the loaded value is an array`);
+ try {
+ return g.resolveExpr(node.file, g.valueOf(g.findProp(node, seg)), trail);
+ } catch (error) {
+ // A config's named export is merged in as a top-level key when the
+ // default export does not carry it (`loadConfig`).
+ if (atRoot && error instanceof Refusal && error.refusal.kind === 'injected' && this.namedExports.includes(seg)) {
+ return g.resolveExport(this.configFile, seg, trail, new Set());
+ }
+ throw error;
+ }
+ }
+ case 'array': {
+ if (typeof seg !== 'number') refuse('mismatch', `the literal at ${g.at(node.file, node.node)} is an array where the loaded value is an object`);
+ const elements = node.node.elements;
+ if (!Array.isArray(rtParent) || rtParent.length !== elements.length || seg >= elements.length) {
+ refuse('mismatch', `the array at ${g.at(node.file, node.node)} has ${elements.length} element(s) where the loaded value has ${Array.isArray(rtParent) ? rtParent.length : 'none'}`);
+ }
+ return g.resolveExpr(node.file, elements[seg]!, trail);
+ }
+ case 'list': {
+ if (typeof seg !== 'number') refuse('mismatch', `${node.describe} is a list where the loaded value is an object`);
+ if (!Array.isArray(rtParent) || rtParent.length !== node.items.length || seg >= node.items.length) {
+ refuse('mismatch', `${node.describe} reads as ${node.items.length} element(s) where the loaded value has ${Array.isArray(rtParent) ? rtParent.length : 'none'}`);
+ }
+ return node.items[seg]!(trail);
+ }
+ case 'namespace':
+ if (typeof seg !== 'string') refuse('mismatch', `the module namespace of ${g.rel(node.file)} has no index ${seg}`);
+ return g.resolveExport(node.file, seg, trail, new Set());
+ case 'scalar':
+ refuse('mismatch', `the literal at ${g.at(node.file, node.node)} is a scalar where the loaded value has members`);
+ default:
+ refuse('computed', 'the value comes from `@objectstack/spec`, not from the project');
+ }
+ }
+
+ /** The literal that holds the site at `path` (a normalised-stack path); throws a {@link Refusal}. */
+ container(path: readonly Segment[]): Located {
+ const key = JSON.stringify(path);
+ const hit = this.cache.get(key);
+ if (hit instanceof Refusal) throw hit;
+ if (hit) return hit;
+ try {
+ const located = this.locate(path, key);
+ this.cache.set(key, located);
+ return located;
+ } catch (error) {
+ if (error instanceof Refusal) this.cache.set(key, error);
+ throw error;
+ }
+ }
+
+ private locate(path: readonly Segment[], key: string): Located {
+ const g = this.graph;
+ const trail = new Trail();
+ let node = this.root(trail);
+ let rt: unknown = this.config;
+ path.forEach((raw, depth) => {
+ let seg = raw;
+ // A map-form collection (`objects: { account: {…} }`) is normalised to an
+ // array in `Object.entries` order before the chain runs.
+ if (typeof seg === 'number' && isPlainObject(rt)) {
+ const keys = Object.keys(rt);
+ if (seg >= keys.length) refuse('mismatch', `the loaded map has no entry ${seg}`);
+ seg = keys[seg]!;
+ }
+ node = this.step(node, seg, trail, rt, depth === 0);
+ rt = rt !== null && typeof rt === 'object' ? (rt as Record)[seg as string] : undefined;
+ });
+
+ if (node.k === 'list') refuse('spread', `the container is assembled by ${node.describe}, not written as one literal`);
+ if (node.k === 'namespace') refuse('computed', 'the container is a module namespace, not a literal');
+ if (node.k === 'scalar') refuse('mismatch', `the literal at ${g.at(node.file, node.node)} is a scalar where the loaded value is a container`);
+ if (node.k !== 'object' && node.k !== 'array') refuse('computed', 'the value comes from `@objectstack/spec`, not from the project');
+
+ const real = realpathSafe(node.file.path);
+ const inside = relative(g.projectRoot, real);
+ if (inside.startsWith('..') || isAbsolute(inside) || real.split(sep).includes('node_modules')) {
+ refuse('outside-project', `the literal is in ${real}, outside the project at ${g.projectRoot}`);
+ }
+
+ if (!subsetOf(g.partialValue(node.node), rt)) {
+ refuse('mismatch', `the loaded value does not match the literal at ${g.at(node.file, node.node)} — a helper parsed or rebuilt it, or code changed it after it was written`);
+ }
+
+ for (const binding of trail.bindings.values()) {
+ const others = g.references(binding).filter((id) => !trail.uses.has(id));
+ if (others.length > 0) {
+ refuse('shared', `\`${binding.name}\` (${g.rel(binding.file)}) is referenced ${others.length} more time(s) than this site, so editing its literal would change those uses too`);
+ }
+ }
+ return { key, node, rt };
+ }
+}
+
+function realpathSafe(path: string): string {
+ try {
+ return realpathSync(path);
+ } catch {
+ return resolve(path);
+ }
+}
+
+// ── text: spelling values and splicing them in ──────────────────────────────
+
+/** How far an inline rendering may run before it breaks across lines. */
+const INLINE_WIDTH = 100;
+
+function quoteString(value: string, quote: string): string {
+ let out = quote;
+ for (const ch of value) {
+ const code = ch.codePointAt(0)!;
+ if (ch === '\\') out += '\\\\';
+ else if (ch === quote) out += `\\${quote}`;
+ else if (ch === '\n') out += '\\n';
+ else if (ch === '\r') out += '\\r';
+ else if (ch === '\t') out += '\\t';
+ else if (code < 0x20 || code === 0x7f || code === 0x2028 || code === 0x2029) out += `\\u${code.toString(16).padStart(4, '0')}`;
+ else out += ch;
+ }
+ return out + quote;
+}
+
+function keyText(key: string, quote: string): string {
+ return IDENTIFIER_RE.test(key) ? key : quoteString(key, quote);
+}
+
+/** A converted value as TypeScript source; throws `unspellable` for a value no literal can spell. */
+export function spellValue(value: unknown, indent: string, quote = "'"): string {
+ if (typeof value === 'string') return quoteString(value, quote);
+ if (typeof value === 'number') {
+ if (!Number.isFinite(value)) refuse('unspellable', `the converted value ${String(value)} has no literal spelling`);
+ return Object.is(value, -0) ? '0' : String(value);
+ }
+ if (typeof value === 'boolean') return String(value);
+ if (value === null) return 'null';
+ const inner = `${indent} `;
+ if (Array.isArray(value)) {
+ if (value.length === 0) return '[]';
+ const parts = value.map((v) => spellValue(v, inner, quote));
+ const inline = `[${parts.join(', ')}]`;
+ if (!inline.includes('\n') && indent.length + inline.length <= INLINE_WIDTH) return inline;
+ return `[\n${parts.map((p) => inner + p).join(',\n')},\n${indent}]`;
+ }
+ if (isPlainObject(value)) {
+ const keys = presentKeys(value);
+ if (keys.length === 0) return '{}';
+ const parts = keys.map((k) => `${keyText(k, quote)}: ${spellValue(value[k], inner, quote)}`);
+ const inline = `{ ${parts.join(', ')} }`;
+ if (!inline.includes('\n') && indent.length + inline.length <= INLINE_WIDTH) return inline;
+ return `{\n${parts.map((p) => inner + p).join(',\n')},\n${indent}}`;
+ }
+ refuse('unspellable', `the converted value is ${value === undefined ? '`undefined`' : `a ${typeof value === 'function' ? 'function' : 'non-plain object'}`}, which has no literal spelling`);
+}
+
+function lineStart(text: string, pos: number): number {
+ return text.lastIndexOf('\n', pos - 1) + 1;
+}
+
+function indentAt(text: string, pos: number): string {
+ const ls = lineStart(text, pos);
+ return /^[ \t]*/.exec(text.slice(ls))![0];
+}
+
+/**
+ * The range a member occupies when it sits on lines of its own: from the start
+ * of its first line through the line break after it, taking its separator and
+ * a comment on that same line with it. Null when other code shares the lines.
+ */
+function ownLines(text: string, start: number, end: number): [number, number] | null {
+ const ls = lineStart(text, start);
+ if (!/^[ \t]*$/.test(text.slice(ls, start))) return null;
+ let p = end;
+ const skip = () => { while (text[p] === ' ' || text[p] === '\t') p++; };
+ skip();
+ if (text[p] === ',') p++;
+ skip();
+ if (text.startsWith('//', p)) {
+ const nl = text.indexOf('\n', p);
+ p = nl < 0 ? text.length : nl;
+ if (text[p - 1] === '\r') p--;
+ } else if (text.startsWith('/*', p)) {
+ const close = text.indexOf('*/', p);
+ if (close < 0 || text.slice(p, close).includes('\n')) return null;
+ p = close + 2;
+ skip();
+ }
+ if (p === text.length) return [ls, p];
+ if (text[p] === '\r' && text[p + 1] === '\n') return [ls, p + 2];
+ if (text[p] === '\n') return [ls, p + 1];
+ return null;
+}
+
+/** Where a member's line ends: the offset of its line break (or the end of the text). */
+function lineBreakAfter(range: [number, number], text: string): number {
+ const [, end] = range;
+ if (end === text.length && text[end - 1] !== '\n') return end;
+ return text[end - 2] === '\r' ? end - 2 : end - 1;
+}
+
+interface TextEdit {
+ start: number;
+ end: number;
+ text: string;
+ component: number;
+}
+
+/** A change resolved to the literal it edits. */
+interface Resolved {
+ readonly change: StackChange;
+ readonly component: number;
+ readonly container: Located;
+ /** The changed key (object container) or element index (array `set`). */
+ readonly member?: Segment;
+ /** 1-based line of the site in the file as read. */
+ readonly line: number;
+}
+
+// ── planning ────────────────────────────────────────────────────────────────
+
+class Planner {
+ constructor(
+ private readonly ts: Ts,
+ private readonly graph: SourceGraph,
+ private readonly locator: Locator,
+ ) {}
+
+ private lineOf(file: SourceFileRec, pos: number): number {
+ return file.sf.getLineAndCharacterOfPosition(pos).line + 1;
+ }
+
+ /** Phase 1: tie one change to the literal it would edit, proving what can be proved. */
+ resolve(change: StackChange, component: number): Resolved {
+ const ts = this.ts;
+ const g = this.graph;
+ const containerPath = change.op === 'remove' ? change.path : change.path.slice(0, -1);
+ const container = this.locator.container(containerPath);
+ const { node, rt } = container;
+ const file = node.file;
+
+ if (change.op === 'remove') {
+ if (node.k !== 'array') refuse('mismatch', `the literal at ${g.at(file, node.node)} is not an array`);
+ const first = node.node.elements[change.indices[0]!];
+ if (!first) refuse('mismatch', `the array at ${g.at(file, node.node)} has no element ${change.indices[0]}`);
+ return { change, component, container, line: this.lineOf(file, first.getStart(file.sf)) };
+ }
+
+ const member = change.path[change.path.length - 1]!;
+ if (node.k === 'array') {
+ if (change.op !== 'set' || typeof member !== 'number') refuse('mismatch', `the literal at ${g.at(file, node.node)} is an array`);
+ const el = node.node.elements[member];
+ if (!el) refuse('mismatch', `the array at ${g.at(file, node.node)} has no element ${member}`);
+ this.provenLiteral(file, el, (rt as unknown[])[member]);
+ spellValue(change.after, '');
+ return { change, component, container, member, line: this.lineOf(file, el.getStart(file.sf)) };
+ }
+
+ if (typeof member !== 'string') refuse('mismatch', `the literal at ${g.at(file, node.node)} is an object`);
+ const rtObject = rt as Record;
+ if (change.op === 'add') {
+ const written = node.node.properties.some((p) => !ts.isSpreadAssignment(p) && p.name && g.propName(p.name) === member);
+ if (written) refuse('mismatch', `\`${member}\` is already written in the literal at ${g.at(file, node.node)}`);
+ spellValue(change.after, '');
+ return { change, component, container, member, line: this.lineOf(file, node.node.getStart(file.sf)) };
+ }
+ const prop = g.findProp(node, member);
+ if (change.op === 'set') {
+ if (!ts.isPropertyAssignment(prop)) refuse('computed', `\`${member}\` is written as the binding \`${g.snippet(file, prop)}\`, not a literal`);
+ this.provenLiteral(file, prop.initializer, rtObject[member]);
+ spellValue(change.after, '');
+ } else if (ts.isPropertyAssignment(prop)) {
+ // A delete: a literal value, when there is one, must be the loaded one.
+ const lit = g.literalNode(prop.initializer);
+ if (lit) {
+ const exact = g.exactValue(lit);
+ if (exact !== UNKNOWN && !deepEqual(exact, rtObject[member])) {
+ refuse('mismatch', `\`${member}\` at ${g.at(file, prop)} is not the value that was loaded`);
+ }
+ }
+ }
+ return { change, component, container, member, line: this.lineOf(file, prop.getStart(file.sf)) };
+ }
+
+ /** The node is a literal whose value is exactly the loaded one; throws otherwise. */
+ private provenLiteral(file: SourceFileRec, expr: TS.Expression, loaded: unknown): TS.Expression {
+ const g = this.graph;
+ const lit = g.literalNode(expr);
+ if (!lit) refuse('computed', `the value is the expression \`${g.snippet(file, expr)}\`, not a literal`);
+ const exact = g.exactValue(lit);
+ if (exact === UNKNOWN) refuse('computed', `the literal \`${g.snippet(file, lit)}\` holds a value that is not itself a literal`);
+ if (!deepEqual(exact, loaded)) refuse('mismatch', `the literal at ${g.at(file, lit)} is not the value that was loaded`);
+ return lit;
+ }
+
+ private quoteOf(file: SourceFileRec, node: TS.Node | undefined): string {
+ if (node && (this.ts.isStringLiteral(node) || this.ts.isNoSubstitutionTemplateLiteral(node))) {
+ const q = file.text[node.getStart(file.sf)]!;
+ if (q === '"' || q === "'") return q;
+ }
+ return "'";
+ }
+
+ /** Phase 3: the edits one literal needs for every change written into it. */
+ edits(items: readonly Resolved[]): TextEdit[] {
+ const ts = this.ts;
+ const g = this.graph;
+ const { node } = items[0]!.container;
+ const file = node.file;
+ const text = file.text;
+ const open = node.node.getStart(file.sf);
+ const close = node.node.end - 1;
+ const out: TextEdit[] = [];
+ const members: readonly TS.Node[] = node.k === 'object' ? node.node.properties : node.node.elements;
+ const deleted = new Map(); // member index → component
+ const adds: Array<{ key: string; value: unknown; component: number }> = [];
+
+ const indexOfKey = (key: Segment): number => {
+ if (node.k !== 'object') return -1;
+ return node.node.properties.findIndex((p) => !ts.isSpreadAssignment(p) && p.name && g.propName(p.name) === key);
+ };
+
+ // Group the object changes by component so a rename pairs only within its own conversion.
+ const byComponent = new Map();
+ for (const r of items) {
+ const list = byComponent.get(r.component) ?? [];
+ list.push(r);
+ byComponent.set(r.component, list);
+ }
+
+ for (const [component, list] of byComponent) {
+ const dels = list.filter((r) => r.change.op === 'delete');
+ const addsHere = list.filter((r) => r.change.op === 'add');
+ for (const r of list) {
+ const c = r.change;
+ if (c.op === 'remove') {
+ for (const i of c.indices) deleted.set(i, component);
+ } else if (c.op === 'set') {
+ const target = node.k === 'array'
+ ? node.node.elements[r.member as number]!
+ : (members[indexOfKey(r.member!)] as TS.PropertyAssignment).initializer;
+ const lit = g.literalNode(target)!;
+ const start = lit.getStart(file.sf);
+ out.push({ start, end: lit.end, text: spellValue(c.after, indentAt(text, start), this.quoteOf(file, lit)), component });
+ }
+ }
+ // Renames: a removed key and an added key holding the same value.
+ const pairedAdds = new Set();
+ const unpairedDels: Resolved[] = [];
+ for (const d of dels) {
+ const before = (d.change as { before: unknown }).before;
+ const a = addsHere.find((x) => !pairedAdds.has(x) && deepEqual((x.change as { after: unknown }).after, before));
+ if (!a) { unpairedDels.push(d); continue; }
+ pairedAdds.add(a);
+ const prop = members[indexOfKey(d.member!)] as TS.ObjectLiteralElementLike;
+ const newKey = a.member as string;
+ if (ts.isShorthandPropertyAssignment(prop)) {
+ out.push({ start: prop.getStart(file.sf), end: prop.end, text: `${keyText(newKey, "'")}: ${prop.name.text}`, component });
+ } else {
+ const name = prop.name!;
+ out.push({ start: name.getStart(file.sf), end: name.end, text: keyText(newKey, this.quoteOf(file, name)), component });
+ }
+ }
+ const unpairedAdds = addsHere.filter((a) => !pairedAdds.has(a));
+ if (unpairedDels.length === 1 && unpairedAdds.length === 1) {
+ // One key out, one key in, values differ: the member is rewritten in place.
+ const d = unpairedDels[0]!;
+ const a = unpairedAdds[0]!;
+ const prop = members[indexOfKey(d.member!)] as TS.ObjectLiteralElementLike;
+ if (!ts.isPropertyAssignment(prop) || !g.literalNode(prop.initializer)) {
+ refuse('computed', `\`${String(d.member)}\` at ${g.at(file, prop)} becomes \`${String(a.member)}\` with a new value, and its old value is not a literal`);
+ }
+ const start = prop.getStart(file.sf);
+ const quote = this.quoteOf(file, g.literalNode(prop.initializer));
+ out.push({
+ start,
+ end: prop.end,
+ text: `${keyText(a.member as string, quote)}: ${spellValue((a.change as { after: unknown }).after, indentAt(text, start), quote)}`,
+ component,
+ });
+ } else {
+ for (const d of unpairedDels) deleted.set(indexOfKey(d.member!), component);
+ for (const a of unpairedAdds) adds.push({ key: a.member as string, value: (a.change as { after: unknown }).after, component });
+ }
+ }
+
+ out.push(...this.deletions(file, open, close, members, deleted));
+ out.push(...this.insertions(file, open, close, members, deleted, adds));
+ return out;
+ }
+
+ private deletions(file: SourceFileRec, open: number, close: number, members: readonly TS.Node[], deleted: ReadonlyMap): TextEdit[] {
+ if (deleted.size === 0) return [];
+ const text = file.text;
+ const span = (m: TS.Node): [number, number] => [m.getStart(file.sf), m.end];
+ const out: TextEdit[] = [];
+ if (text.slice(open, close).includes('\n')) {
+ for (const [i, component] of deleted) {
+ const [s, e] = span(members[i]!);
+ const range = ownLines(text, s, e);
+ if (!range) refuse('layout', `the member at ${this.graph.at(file, members[i]!)} shares its line with other code`);
+ out.push({ start: range[0], end: range[1], text: '', component });
+ }
+ return out;
+ }
+ // One line: delete each run of neighbours together with the separators around it.
+ const sepOk = (from: number, to: number) => /^\s*,\s*$/.test(text.slice(from, to));
+ const indices = [...deleted.keys()].sort((a, b) => a - b);
+ let k = 0;
+ while (k < indices.length) {
+ let j = k;
+ while (j + 1 < indices.length && indices[j + 1] === indices[j]! + 1) j++;
+ const first = indices[k]!;
+ const last = indices[j]!;
+ const component = deleted.get(first)!;
+ for (let t = first; t < last; t++) {
+ if (!sepOk(span(members[t]!)[1], span(members[t + 1]!)[0])) refuse('layout', `a comment sits between the members at ${this.graph.at(file, members[t]!)}`);
+ }
+ if (last + 1 < members.length) {
+ if (!sepOk(span(members[last]!)[1], span(members[last + 1]!)[0])) refuse('layout', `a comment sits beside the member at ${this.graph.at(file, members[last]!)}`);
+ out.push({ start: span(members[first]!)[0], end: span(members[last + 1]!)[0], text: '', component });
+ } else if (first > 0) {
+ if (!sepOk(span(members[first - 1]!)[1], span(members[first]!)[0])) refuse('layout', `a comment sits beside the member at ${this.graph.at(file, members[first]!)}`);
+ out.push({ start: span(members[first - 1]!)[1], end: span(members[last]!)[1], text: '', component });
+ } else {
+ const after = text.slice(span(members[last]!)[1], close);
+ if (!/^\s*$/.test(text.slice(open + 1, span(members[0]!)[0])) || !/^\s*,?\s*$/.test(after)) {
+ refuse('layout', `a comment sits inside the literal at ${this.graph.at(file, members[0]!)}`);
+ }
+ out.push({ start: open + 1, end: close, text: '', component });
+ }
+ k = j + 1;
+ }
+ return out;
+ }
+
+ private insertions(
+ file: SourceFileRec,
+ open: number,
+ close: number,
+ members: readonly TS.Node[],
+ deleted: ReadonlyMap,
+ adds: ReadonlyArray<{ key: string; value: unknown; component: number }>,
+ ): TextEdit[] {
+ if (adds.length === 0) return [];
+ const text = file.text;
+ const component = adds[0]!.component;
+ const quote = "'";
+ if (members.length === 0) {
+ if (!/^\s*$/.test(text.slice(open + 1, close))) refuse('layout', `a comment sits inside the empty literal at ${this.graph.at(file, members[0] ?? file.sf)}`);
+ const indent = indentAt(text, open);
+ return [{ start: open + 1, end: close, text: ` ${adds.map((a) => `${keyText(a.key, quote)}: ${spellValue(a.value, indent, quote)}`).join(', ')} `, component }];
+ }
+ const kept = members.map((_, i) => i).filter((i) => !deleted.has(i));
+ if (kept.length === 0) refuse('layout', 'every key of this literal is replaced; rewrite it by hand');
+ const anchor = members[kept[kept.length - 1]!]!;
+ const s = anchor.getStart(file.sf);
+ let p = anchor.end;
+ while (text[p] === ' ' || text[p] === '\t') p++;
+ const hasComma = text[p] === ',';
+ if (!text.slice(open, close).includes('\n')) {
+ const entries = adds.map((a) => `${keyText(a.key, quote)}: ${spellValue(a.value, indentAt(text, s), quote)}`);
+ // A trailing comma is kept trailing; a separator before deleted members
+ // is theirs, and goes with them.
+ const trailing = anchor === members[members.length - 1] && hasComma;
+ return trailing
+ ? [{ start: p + 1, end: p + 1, text: ` ${entries.map((x) => `${x},`).join(' ')}`, component }]
+ : [{ start: anchor.end, end: anchor.end, text: `, ${entries.join(', ')}`, component }];
+ }
+ const range = ownLines(text, s, anchor.end);
+ if (!range) refuse('layout', `the member at ${this.graph.at(file, anchor)} shares its line with other code`);
+ const indent = indentAt(text, s);
+ const entries = adds.map((a) => `${keyText(a.key, quote)}: ${spellValue(a.value, indent, quote)}`);
+ const out: TextEdit[] = [];
+ if (!hasComma) out.push({ start: anchor.end, end: anchor.end, text: ',', component });
+ const at = lineBreakAfter(range, text);
+ out.push({
+ start: at,
+ end: at,
+ text: entries.map((x, i) => `\n${indent}${x}${hasComma || i < entries.length - 1 ? ',' : ''}`).join(''),
+ component,
+ });
+ return out;
+ }
+}
+
+// ── the plan ────────────────────────────────────────────────────────────────
+
+function applyEdits(text: string, edits: readonly TextEdit[]): string {
+ // Last first, so every offset still points into the original text; at one
+ // offset a removal goes before the insertion that lands where it starts.
+ const ordered = [...edits].sort((a, b) => b.start - a.start || b.end - a.end);
+ let out = text;
+ for (const e of ordered) out = out.slice(0, e.start) + e.text + out.slice(e.end);
+ return out;
+}
+
+/**
+ * The first edit that collides with the one before it — a range that starts
+ * inside another, or two insertions at one offset (whose order would be a
+ * guess). An insertion where a removal starts, or where a replacement ends, is
+ * not a collision.
+ */
+function overlapping(edits: readonly TextEdit[]): TextEdit | undefined {
+ const ordered = [...edits].sort((a, b) => a.start - b.start || a.end - b.end);
+ for (let i = 1; i < ordered.length; i++) {
+ const prev = ordered[i - 1]!;
+ const cur = ordered[i]!;
+ const twoInsertions = prev.start === prev.end && cur.start === cur.end && cur.start === prev.start;
+ if (cur.start < prev.end || twoInsertions) return cur;
+ }
+ return undefined;
+}
+
+/**
+ * Plan `os migrate meta --write`: which of the chain's mechanical changes can be
+ * written into which authored files, the bytes each file would hold, and the
+ * reason for every change left to the author. Reads the sources; writes nothing.
+ */
+export async function planAuthoredSourceWrite(input: AuthoredSourceWriteInput): Promise {
+ const { ts } = await import('ts-morph');
+ const configPath = realpathSafe(input.configPath);
+ const projectRoot = dirname(configPath);
+ const graph = new SourceGraph(ts, projectRoot);
+ graph.crawl(configPath);
+ const locator = new Locator(ts, graph, input.config, input.namedExports, configPath);
+ const planner = new Planner(ts, graph, locator);
+
+ // Attribution: each change to the entries that explain it.
+ const changes = diffStacks(input.normalized, input.migrated);
+ const appliedPaths = input.applied.map((a) => parsePath(a.path));
+ const entriesOf = changes.map((c) => explainingEntries(appliedPaths, c.path));
+
+ // Components: entries that share a change are written together or not at all.
+ const parent = input.applied.map((_, i) => i);
+ const find = (i: number): number => (parent[i] === i ? i : (parent[i] = find(parent[i]!)));
+ for (const entries of entriesOf) for (const e of entries.slice(1)) parent[find(e)] = find(entries[0]!);
+ const componentOf = (entry: number) => find(entry);
+
+ const unexplained: string[] = [];
+ const changesOf = new Map(); // component → change indices
+ changes.forEach((c, ci) => {
+ const entries = entriesOf[ci]!;
+ if (entries.length === 0) { unexplained.push(formatPath(c.path)); return; }
+ const comp = componentOf(entries[0]!);
+ changesOf.set(comp, [...(changesOf.get(comp) ?? []), ci]);
+ });
+
+ // Phase 1 — resolve every change; a refusal refuses its component.
+ const refusedChange = new Map(); // change index → refusal
+ const refusedComponent = new Map();
+ const resolved = new Map();
+ for (const [comp, cis] of changesOf) {
+ for (const ci of cis) {
+ try {
+ resolved.set(ci, planner.resolve(changes[ci]!, comp));
+ } catch (error) {
+ if (!(error instanceof Refusal)) throw error;
+ refusedChange.set(ci, error.refusal);
+ if (!refusedComponent.has(comp)) refusedComponent.set(comp, error.refusal);
+ }
+ }
+ }
+
+ // Phases 2–4 — build the edits of the components still standing; a layout
+ // refusal, an overlap or an edit that would not parse refuses its
+ // component(s) and the build starts over without them.
+ let rewrites: FileRewrite[] = [];
+ for (;;) {
+ const byContainer = new Map();
+ for (const [ci, r] of resolved) {
+ if (refusedComponent.has(r.component)) continue;
+ const k = `${r.container.node.file.path}#${r.container.node.node.getStart(r.container.node.file.sf)}`;
+ byContainer.set(k, [...(byContainer.get(k) ?? []), r]);
+ void ci;
+ }
+ let retry = false;
+ const editsByFile = new Map();
+ for (const items of byContainer.values()) {
+ try {
+ const file = items[0]!.container.node.file;
+ const slot = editsByFile.get(file.path) ?? { file, edits: [] };
+ slot.edits.push(...planner.edits(items));
+ editsByFile.set(file.path, slot);
+ } catch (error) {
+ if (!(error instanceof Refusal)) throw error;
+ for (const r of items) {
+ if (!refusedComponent.has(r.component)) refusedComponent.set(r.component, error.refusal);
+ for (const [ci, x] of resolved) if (x === r) refusedChange.set(ci, error.refusal);
+ }
+ retry = true;
+ }
+ }
+ if (retry) continue;
+
+ const next: FileRewrite[] = [];
+ for (const { file, edits } of editsByFile.values()) {
+ if (edits.length === 0) continue;
+ const clash = overlapping(edits);
+ if (clash) {
+ refusedComponent.set(clash.component, {
+ kind: 'layout',
+ reason: `its edit in ${graph.rel(file)} overlaps another site's edit at line ${file.sf.getLineAndCharacterOfPosition(clash.start).line + 1}`,
+ });
+ retry = true;
+ break;
+ }
+ const after = applyEdits(file.text, edits);
+ if (syntacticDiagnostics(ts, after).length > syntacticDiagnostics(ts, file.text).length) {
+ for (const e of edits) {
+ if (!refusedComponent.has(e.component)) {
+ refusedComponent.set(e.component, { kind: 'layout', reason: `the edited ${graph.rel(file)} would not parse` });
+ }
+ }
+ retry = true;
+ break;
+ }
+ next.push({ path: file.path, file: graph.rel(file), before: file.text, after });
+ }
+ if (retry) continue;
+ rewrites = next.sort((a, b) => a.file.localeCompare(b.file));
+ break;
+ }
+
+ // Report by applied entry, in chain order.
+ const written: WrittenSite[] = [];
+ const manual: ManualSite[] = [];
+ input.applied.forEach((application, i) => {
+ const comp = componentOf(i);
+ const mine = (changesOf.get(comp) ?? []).filter((ci) => entriesOf[ci]!.includes(i));
+ if (mine.length === 0) {
+ manual.push({ application, refusal: { kind: 'unattributed', reason: 'no edit in the migrated stack could be tied to this site' } });
+ return;
+ }
+ const compRefusal = refusedComponent.get(comp);
+ if (compRefusal) {
+ const own = mine.map((ci) => refusedChange.get(ci)).find((r) => r !== undefined);
+ manual.push({
+ application,
+ refusal: own ?? { kind: 'entangled', reason: `a conversion's edits are written whole or not at all, and a linked site was refused: ${compRefusal.reason}` },
+ });
+ return;
+ }
+ // The line of a member that was there (a rename's old key, a removed or
+ // rewritten value) over the line of the literal a new key went into.
+ const site = mine.find((ci) => changes[ci]!.op !== 'add') ?? mine[0]!;
+ const r = resolved.get(site)!;
+ written.push({ application, file: graph.rel(r.container.node.file), line: r.line });
+ });
+
+ return { projectRoot, rewrites, written, manual, unexplained };
+}
+
+/**
+ * Write a plan's files. Refuses — before writing any of them — when a file no
+ * longer holds the bytes the plan was made from.
+ */
+export function writeAuthoredSources(plan: AuthoredSourceWritePlan): void {
+ for (const r of plan.rewrites) {
+ if (readFileSync(r.path, 'utf8') !== r.before) {
+ throw new Error(`${r.file} changed on disk while the migration was planned; nothing was written. Re-run the command.`);
+ }
+ }
+ for (const r of plan.rewrites) writeFileSync(r.path, r.after, 'utf8');
+}
+
+/** Put every file a plan wrote back to the bytes it held before. */
+export function restoreAuthoredSources(plan: AuthoredSourceWritePlan): void {
+ for (const r of plan.rewrites) writeFileSync(r.path, r.before, 'utf8');
+}
+
+/** What a re-run of the chain over the written sources says about the write. */
+export interface WriteVerification {
+ ok: boolean;
+ /** Mechanical changes the re-run still applies that the plan reported written. */
+ stillApplied: string[];
+ /** Changes the plan left manual that the re-run no longer applies. */
+ vanished: string[];
+}
+
+/**
+ * Hold a write to its own report: re-run over the written sources, the chain
+ * must apply exactly the changes the plan left manual — every written site
+ * gone, every manual one still there.
+ */
+export function verifyAuthoredSourceWrite(
+ plan: AuthoredSourceWritePlan,
+ rerun: readonly MigrationApplication[],
+): WriteVerification {
+ const key = (a: MigrationApplication) => `${a.path} (${a.conversionId})`;
+ const expected = new Map();
+ for (const m of plan.manual) expected.set(key(m.application), (expected.get(key(m.application)) ?? 0) + 1);
+ const stillApplied: string[] = [];
+ for (const a of rerun) {
+ const k = key(a);
+ const n = expected.get(k) ?? 0;
+ if (n > 0) expected.set(k, n - 1);
+ else stillApplied.push(k);
+ }
+ const vanished = [...expected].flatMap(([k, n]) => Array.from({ length: n }, () => k));
+ return { ok: stillApplied.length === 0 && vanished.length === 0, stillApplied, vanished };
+}
diff --git a/packages/cli/test/migrate-meta-write.test.ts b/packages/cli/test/migrate-meta-write.test.ts
new file mode 100644
index 00000000000..14bbf710e9f
--- /dev/null
+++ b/packages/cli/test/migrate-meta-write.test.ts
@@ -0,0 +1,746 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * `os migrate meta --write` — the codemod that writes the chain's MECHANICAL
+ * edits into the authored sources (#9591).
+ *
+ * ## What is pinned
+ *
+ * 1. Exactness: over a project whose artifacts live in per-module `define*`
+ * calls, a `.create` factory, a barrel read through `Object.values(ns)` and
+ * an imported array, `--write` rewrites exactly the attributed sites —
+ * each written file equals its old bytes with only those sites edited, and
+ * every file it did not touch keeps its bytes. Both conversion shapes are
+ * covered: a key STRIPPED, a value REWRITTEN (and a key RENAMED).
+ * 2. Idempotence: a second run over the written sources applies nothing and
+ * writes nothing.
+ * 3. The semantic TODOs are never written, and stay listed exactly as the dry
+ * run lists them; the partial `compareTo` case writes its covered arm and
+ * leaves `{ offset: '7d' }` — which the conversion declines — untouched.
+ * 4. Controls: without `--write` no source changes and the `--json` payload
+ * carries no `write` key; `--out` still writes its snapshot.
+ * 5. Every refusal kind: the real-project kinds through the command, the
+ * kinds no live conversion can produce through the planner itself; each
+ * refused site keeps its bytes while its neighbours are written.
+ * 6. The surface: `--write` is exclusive with `--stored` and refused there;
+ * the module names no conversion, so it stays generic over the chain.
+ *
+ * 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, realpathSync, 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 { MIGRATIONS_BY_MAJOR, MIGRATION_MAJORS, type MigrationApplication } from '@objectstack/spec/migrations';
+import MigrateMeta from '../src/commands/migrate/meta.js';
+import {
+ planAuthoredSourceWrite,
+ verifyAuthoredSourceWrite,
+ type AuthoredSourceWritePlan,
+ type CodemodRefusalKind,
+} from '../src/utils/authored-source-codemod.js';
+
+/**
+ * A seam between planning and writing, for the two failure exits: the command
+ * runs the real planner, and a test may act on the plan before the write.
+ */
+const hooks = vi.hoisted(() => ({ afterPlan: undefined as undefined | ((plan: AuthoredSourceWritePlan) => void) }));
+vi.mock('../src/utils/authored-source-codemod.js', async (importActual) => {
+ const actual = await importActual();
+ return {
+ ...actual,
+ planAuthoredSourceWrite: async (input: Parameters[0]) => {
+ const plan = await actual.planAuthoredSourceWrite(input);
+ hooks.afterPlan?.(plan);
+ return plan;
+ },
+ };
+});
+
+const CLI_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..');
+const CODEMOD_SOURCE = resolve(fileURLToPath(import.meta.url), '..', '..', 'src', 'utils', 'authored-source-codemod.ts');
+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;
+
+/** Write a fresh project (relative path → text) under the temp root; returns its directory. */
+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 (node_modules excluded), relative path → bytes. */
+function snapshot(dir: string): Record {
+ const out: Record = {};
+ const walk = (d: string) => {
+ for (const name of readdirSync(d)) {
+ if (name === 'node_modules') continue;
+ 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` — and `from` must occur exactly once, so an edit can never be a no-op. */
+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'), '--from', '16', ...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,
+ };
+}
+
+function json(run: Run): any {
+ return JSON.parse(run.stdout);
+}
+
+const sites = (list: Array<{ conversionId: string; path: string }>) =>
+ list.map((a) => `${a.path} (${a.conversionId})`).sort();
+
+beforeAll(() => {
+ root = mkdtempSync(join(tmpdir(), 'os-migrate-meta-write-'));
+ mkdirSync(join(root, 'node_modules', '@objectstack'), { recursive: true });
+ specLink = join(root, 'node_modules', '@objectstack', 'spec');
+ symlinkSync(SPEC_PACKAGE_ROOT, specLink, 'dir');
+ // A package the refusal fixture imports a view from: `node_modules` is never written.
+ mkdirSync(join(root, 'node_modules', 'fake-kit'), { recursive: true });
+ writeFileSync(join(root, 'node_modules', 'fake-kit', 'package.json'), JSON.stringify({ name: 'fake-kit', main: 'index.js' }));
+ writeFileSync(
+ join(root, 'node_modules', 'fake-kit', 'index.js'),
+ "exports.KitView = { object: 'rf_ticket', list: { type: 'grid', columns: ['title'], striped: true } };\n",
+ );
+});
+
+afterAll(() => {
+ // Unlinked BEFORE the recursive remove, and named explicitly: this 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 */ }
+});
+
+// ── 1–4: the per-artifact project ───────────────────────────────────────────
+
+const PROJECT: Record = {
+ 'objectstack.config.ts': `import { defineStack } from '@objectstack/spec';
+import * as dashboards from './src/dashboards/index.js';
+import * as views from './src/views/index.js';
+import { TicketObject } from './src/objects/ticket.object.js';
+import { SupportAgent } from './src/agents/support.agent.js';
+import { allFlows } from './src/flows/index.js';
+
+export default defineStack({
+ manifest: { id: 'com.example.write', name: 'Write fixture', version: '1.0.0', type: 'app' },
+ objects: [TicketObject],
+ // The document store, spelled the old way.
+ datasources: [{ name: 'docs', label: 'Docs', driver: 'mongo', config: {} }],
+ dashboards: Object.values(dashboards),
+ views: Object.values(views),
+ agents: [SupportAgent],
+ flows: allFlows,
+});
+`,
+ 'src/dashboards/index.ts': `export * from './ops.dashboard.js';
+export * from './kpi.dashboard.js';
+`,
+ 'src/dashboards/ops.dashboard.ts': `import { Dashboard } from '@objectstack/spec/ui';
+
+export const OpsDashboard = Dashboard.create({
+ name: 'ops',
+ label: 'Ops',
+ widgets: [],
+ refreshInterval: 300, // seconds
+});
+`,
+ 'src/dashboards/kpi.dashboard.ts': `import { Dashboard } from '@objectstack/spec/ui';
+
+export const KpiDashboard = Dashboard.create({
+ name: 'kpi',
+ label: 'KPI',
+ widgets: [
+ { id: 'w1', type: 'kpi', dataset: 'orders', values: ['total'], compareTo: 'previousPeriod' },
+ { id: 'w2', type: 'kpi', dataset: 'orders', values: ['total'], compareTo: { offset: '1y' } },
+ { id: 'w3', type: 'kpi', dataset: 'orders', values: ['total'], compareTo: { offset: '7d' } },
+ ],
+});
+`,
+ 'src/views/index.ts': `export * from './ticket.view.js';
+`,
+ 'src/views/ticket.view.ts': `import { defineView } from '@objectstack/spec';
+
+export const TicketView = defineView({
+ object: 'wr_ticket',
+ list: {
+ type: 'grid',
+ columns: ['title'],
+ // Zebra rows.
+ striped: true,
+ bordered: false, // no borders
+ },
+});
+`,
+ 'src/agents/support.agent.ts': `import { defineAgent } from '@objectstack/spec';
+
+export const SupportAgent = defineAgent({
+ name: 'support_agent',
+ label: 'Support',
+ role: 'Support assistant',
+ instructions: 'Help customers.',
+ knowledge: {
+ sources: ['faq'],
+ indexes: ['docs'],
+ },
+});
+`,
+ 'src/flows/index.ts': `import { EscalateFlow } from './escalate.flow.js';
+
+export const allFlows = [EscalateFlow];
+`,
+ 'src/flows/escalate.flow.ts': `import { defineFlow } from '@objectstack/spec';
+
+export const EscalateFlow = defineFlow({
+ name: 'escalate',
+ label: 'Escalate',
+ type: 'autolaunched',
+ active: false,
+ nodes: [
+ { id: 'start', type: 'start', label: 'Start' },
+ { id: 'done', type: 'end', label: 'Done' },
+ ],
+ edges: [{ id: 'e1', source: 'start', target: 'done' }],
+});
+`,
+ 'src/objects/ticket.object.ts': `import { ObjectSchema } from '@objectstack/spec/data';
+
+export const TicketObject = ObjectSchema.create({
+ name: 'wr_ticket',
+ label: 'Ticket',
+ fields: { title: { type: 'text', label: 'Title' } },
+ tenancy: { enabled: true, organizationField: 'organization_id' },
+});
+`,
+};
+
+/** PROJECT after `--write`: each written file is its old bytes with ONLY the attributed sites edited. */
+function expectedAfterWrite(): Record {
+ const p = PROJECT;
+ return {
+ ...p,
+ 'objectstack.config.ts': edit(p['objectstack.config.ts']!, "driver: 'mongo'", "driver: 'mongodb'"),
+ 'src/dashboards/ops.dashboard.ts': edit(p['src/dashboards/ops.dashboard.ts']!, 'refreshInterval: 300,', 'refreshIntervalSeconds: 300,'),
+ 'src/dashboards/kpi.dashboard.ts': edit(
+ edit(p['src/dashboards/kpi.dashboard.ts']!, "compareTo: 'previousPeriod'", "compareTo: { kind: 'previousPeriod' }"),
+ "compareTo: { offset: '1y' }",
+ "compareTo: { kind: 'previousYear' }",
+ ),
+ 'src/views/ticket.view.ts': edit(
+ edit(p['src/views/ticket.view.ts']!, ' striped: true,\n', ''),
+ ' bordered: false, // no borders\n',
+ '',
+ ),
+ 'src/agents/support.agent.ts': edit(
+ p['src/agents/support.agent.ts']!,
+ " knowledge: {\n sources: ['faq'],\n indexes: ['docs'],\n },\n",
+ '',
+ ),
+ 'src/flows/escalate.flow.ts': edit(p['src/flows/escalate.flow.ts']!, ' active: false,\n', ''),
+ 'src/objects/ticket.object.ts': edit(
+ p['src/objects/ticket.object.ts']!,
+ "tenancy: { enabled: true, organizationField: 'organization_id' }",
+ 'tenancy: { enabled: true }',
+ ),
+ };
+}
+
+const EXPECTED_SITES = [
+ 'agents[0].knowledge (agent-knowledge-removed)',
+ 'dashboards[0].widgets[0].compareTo (dashboard-widget-compareto-converged)',
+ 'dashboards[0].widgets[1].compareTo (dashboard-widget-compareto-converged)',
+ 'dashboards[1].refreshIntervalSeconds (dashboard-refresh-interval-to-refresh-interval-seconds)',
+ 'datasources[0].driver (datasource-driver-mongo-to-mongodb)',
+ 'flows[0].active (flow-inert-keys-removed)',
+ 'objects[0].tenancy.organizationField (object-tenancy-organization-field-removed)',
+ 'views[0].list.bordered (view-list-passthrough-keys-removed)',
+ 'views[0].list.striped (view-list-passthrough-keys-removed)',
+];
+
+describe('os migrate meta --write over per-artifact modules', () => {
+ let dir: string;
+ let dry: any;
+ let first: Run;
+ let firstJson: any;
+
+ beforeAll(async () => {
+ dir = writeProject(PROJECT);
+ dry = json(await runMeta(dir, ['--json']));
+ first = await runMeta(dir, ['--write', '--json']);
+ firstJson = json(first);
+ }, RUN_TIMEOUT * 2);
+
+ it('the dry run lists the sites and writes no source (control)', () => {
+ expect(sites(dry.applied)).toEqual(EXPECTED_SITES);
+ expect(dry).not.toHaveProperty('write');
+ });
+
+ it('writes every attributed site, and only those bytes change', () => {
+ expect(first.exitCode, first.stderr).toBeUndefined();
+ expect(firstJson.write.status).toBe('written');
+ expect(sites(firstJson.write.written)).toEqual(EXPECTED_SITES);
+ expect(firstJson.write.manual).toEqual([]);
+ expect(firstJson.write.unexplained).toEqual([]);
+ expect(firstJson.write.verification).toEqual({ ok: true, stillApplied: [], vanished: [] });
+ // Byte-for-byte, every file: the barrels and `index.ts` files included.
+ expect(snapshot(dir)).toEqual(expectedAfterWrite());
+ expect(firstJson.write.files.map((f: any) => f.file).sort()).toEqual([
+ 'objectstack.config.ts',
+ 'src/agents/support.agent.ts',
+ 'src/dashboards/kpi.dashboard.ts',
+ 'src/dashboards/ops.dashboard.ts',
+ 'src/flows/escalate.flow.ts',
+ 'src/objects/ticket.object.ts',
+ 'src/views/ticket.view.ts',
+ ]);
+ });
+
+ it('reports each written site at its file and line as read', () => {
+ const at = Object.fromEntries(firstJson.write.written.map((w: any) => [w.path, `${w.file}:${w.line}`]));
+ expect(at['datasources[0].driver']).toBe('objectstack.config.ts:12');
+ expect(at['dashboards[1].refreshIntervalSeconds']).toBe('src/dashboards/ops.dashboard.ts:7');
+ expect(at['views[0].list.striped']).toBe('src/views/ticket.view.ts:9');
+ expect(at['views[0].list.bordered']).toBe('src/views/ticket.view.ts:10');
+ });
+
+ it('writes the applied set and nothing else: every site it reports is an applied entry', () => {
+ // What `--write` writes or leaves is exactly the chain's `applied` set —
+ // never a semantic TODO, which the planner is not even handed.
+ expect(sites([...firstJson.write.written, ...firstJson.write.manual])).toEqual(sites(firstJson.applied));
+ });
+
+ it('never writes a semantic TODO, and lists them as the dry run does', () => {
+ // Relative to the dry run of the same build, not a pinned listing: which
+ // notices the default list carries is the chain's business, not --write's.
+ expect(firstJson.todos.map((t: any) => t.id)).toEqual(dry.todos.map((t: any) => t.id));
+ // The `compareTo` arm the conversion declines has no mechanical change, so
+ // its bytes stay — and the schema still refuses it, as before the write.
+ expect(readFileSync(join(dir, 'src/dashboards/kpi.dashboard.ts'), 'utf8')).toContain("compareTo: { offset: '7d' }");
+ expect(firstJson.schemaValid).toBe(false);
+ });
+
+ it('is idempotent: a second run applies nothing and writes nothing', async () => {
+ const before = snapshot(dir);
+ const second = json(await runMeta(dir, ['--write', '--json']));
+ expect(second.applied).toEqual([]);
+ expect(second.write.status).toBe('written');
+ expect(second.write.files).toEqual([]);
+ expect(second.write.written).toEqual([]);
+ expect(snapshot(dir)).toEqual(before);
+ }, RUN_TIMEOUT);
+});
+
+describe('os migrate meta without --write (controls)', () => {
+ it('writes no source file, and --out still writes its snapshot', async () => {
+ const dir = writeProject(PROJECT);
+ const before = snapshot(dir);
+ const out = join(root, `snapshot-${caseSeq}.json`);
+ const run = await runMeta(dir, ['--json', '--out', out]);
+ expect(run.exitCode, run.stderr).toBeUndefined();
+ expect(snapshot(dir)).toEqual(before);
+ expect(JSON.parse(readFileSync(out, 'utf8')).datasources[0].driver).toBe('mongodb');
+ }, RUN_TIMEOUT);
+
+ it('prints the human report with --write, naming no capability it lacks', async () => {
+ const dir = writeProject(PROJECT);
+ const run = await runMeta(dir, ['--write']);
+ expect(run.exitCode, run.stderr).toBeUndefined();
+ const group = run.stdout.slice(run.stdout.indexOf('Wrote '));
+ expect(group).toMatch(/^Wrote 9 of 9 mechanical change\(s\) into 7 file\(s\):/);
+ expect(group).toContain('Re-ran the chain over the written sources: no mechanical change remains.');
+ // The group's own words; the spec's notices above it are the spec's.
+ expect(group).not.toMatch(/automatic/i);
+ }, RUN_TIMEOUT);
+});
+
+// ── 1 (cont.): the third shape — a conversion that ADDS a key ───────────────
+
+const ADD_PROJECT: Record = {
+ 'objectstack.config.ts': `import { defineStack } from '@objectstack/spec';
+import { VerdictFlow } from './src/verdict.flow.js';
+
+export default defineStack({
+ manifest: { id: 'com.example.add', name: 'Add fixture', version: '1.0.0', type: 'app' },
+ flows: [VerdictFlow],
+});
+`,
+ 'src/verdict.flow.ts': `import { defineFlow } from '@objectstack/spec';
+
+export const VerdictFlow = defineFlow({
+ name: 'lead_verdict',
+ label: 'Lead verdict',
+ type: 'autolaunched',
+ nodes: [
+ { id: 'start', type: 'start', label: 'Start' },
+ {
+ id: 'verdict',
+ type: 'decision',
+ label: 'Verdict?' // the last key, with no comma after it
+ },
+ { id: 'gate', type: 'decision', label: 'Gate' },
+ { id: 'refuse', type: 'end', label: 'Refuse' },
+ { id: 'convert', type: 'end', label: 'Convert' },
+ ],
+ edges: [
+ { id: 'e1', source: 'start', target: 'verdict' },
+ { id: 'e2', source: 'verdict', target: 'refuse', condition: "lead.status != 'suspected'" },
+ { id: 'e3', source: 'verdict', target: 'convert', condition: "lead.status == 'confirmed'" },
+ { id: 'e4', source: 'gate', target: 'refuse', condition: 'x > 1' },
+ { id: 'e5', source: 'gate', target: 'convert', condition: 'x > 2' },
+ ],
+});
+`,
+};
+
+describe('os migrate meta --write adds a key where the conversion adds one', () => {
+ it('appends it after the last key — on its own line, or inline — and touches nothing else', async () => {
+ const dir = writeProject(ADD_PROJECT);
+ const run = await runMeta(dir, ['--write', '--json']);
+ expect(run.exitCode, run.stderr).toBeUndefined();
+ const payload = json(run);
+ expect(payload.write.manual).toEqual([]);
+ expect(payload.write.written.map((w: any) => w.conversionId)).toEqual([
+ 'flow-decision-mode-inclusive-explicit',
+ 'flow-decision-mode-inclusive-explicit',
+ ]);
+ const flow = ADD_PROJECT['src/verdict.flow.ts']!;
+ expect(snapshot(dir)).toEqual({
+ ...ADD_PROJECT,
+ 'src/verdict.flow.ts': edit(
+ edit(
+ flow,
+ " label: 'Verdict?' // the last key, with no comma after it\n",
+ " label: 'Verdict?', // the last key, with no comma after it\n config: { mode: 'inclusive' }\n",
+ ),
+ "{ id: 'gate', type: 'decision', label: 'Gate' }",
+ "{ id: 'gate', type: 'decision', label: 'Gate', config: { mode: 'inclusive' } }",
+ ),
+ });
+ expect(payload.write.verification.ok).toBe(true);
+ }, RUN_TIMEOUT);
+});
+
+// ── the two failure exits: nothing is left half-written ─────────────────────
+
+describe('os migrate meta --write fails closed', () => {
+ it('writes nothing, and exits 1, when a file changed after it was read', async () => {
+ const dir = writeProject(PROJECT);
+ let touched = '';
+ hooks.afterPlan = (plan) => {
+ touched = plan.rewrites[0]!.path;
+ writeFileSync(touched, `${readFileSync(touched, 'utf8')}// edited meanwhile\n`);
+ };
+ let run: Run;
+ try {
+ run = await runMeta(dir, ['--write', '--json']);
+ } finally {
+ hooks.afterPlan = undefined;
+ }
+ expect(run.exitCode).toBe(1);
+ const payload = json(run);
+ expect(payload.write.status).toBe('unwritten');
+ expect(payload.write.error).toMatch(/changed on disk/);
+ const rel = relative(realpathSync(dir), touched).split('\\').join('/');
+ expect(snapshot(dir)).toEqual({ ...PROJECT, [rel]: `${PROJECT[rel]}// edited meanwhile\n` });
+ }, RUN_TIMEOUT);
+
+ it('restores every file, and exits 1, when the re-run disagrees with its report', async () => {
+ const dir = writeProject(PROJECT);
+ // A report claiming one more site left by hand than the chain will find.
+ hooks.afterPlan = (plan) => {
+ plan.manual.push({ application: applied('nowhere.at.all', 'probe-phantom'), refusal: { kind: 'computed', reason: 'r' } });
+ };
+ let run: Run;
+ try {
+ run = await runMeta(dir, ['--write']);
+ } finally {
+ hooks.afterPlan = undefined;
+ }
+ expect(run.exitCode).toBe(1);
+ expect(run.stdout).toContain('every one was restored to its previous bytes');
+ expect(run.stdout).toContain('no longer converted: nowhere.at.all (probe-phantom)');
+ expect(snapshot(dir)).toEqual(PROJECT);
+ }, RUN_TIMEOUT);
+});
+
+// ── 5: the refusals a real project produces ─────────────────────────────────
+
+const REFUSALS: Record = {
+ 'objectstack.config.ts': `import { defineStack } from '@objectstack/spec';
+import { KitView } from 'fake-kit';
+
+const MONGO = 'mongo';
+const sharedList = { type: 'grid', columns: ['title'], striped: true };
+const listBase = { bordered: true };
+function makeAgent(name: string) {
+ return { name, label: name, role: 'r', instructions: 'i', knowledge: { sources: ['faq'] } };
+}
+
+export default defineStack({
+ manifest: { id: 'com.example.refusals', name: 'Refusals', version: '1.0.0', type: 'app' },
+ objects: [{ name: 'rf_ticket', label: 'Ticket', fields: { title: { type: 'text', label: 'Title' } } }],
+ datasources: [{ name: 'docs', label: 'Docs', driver: MONGO, config: {} }],
+ views: [
+ { object: 'rf_ticket', list: sharedList },
+ { object: 'rf_ticket', list: sharedList },
+ { object: 'rf_ticket', list: { ...listBase, type: 'grid', columns: ['title'] } },
+ {
+ object: 'rf_ticket',
+ list: {
+ type: 'grid', virtualScroll: true,
+ columns: ['title'],
+ },
+ },
+ KitView,
+ ],
+ agents: [makeAgent('helper_agent')],
+ flows: [{ name: 'plain', label: 'Plain', type: 'autolaunched', active: true, nodes: [], edges: [] }],
+});
+`,
+};
+
+describe('os migrate meta --write refuses what it cannot prove, and says why', () => {
+ let dir: string;
+ let payload: any;
+
+ beforeAll(async () => {
+ dir = writeProject(REFUSALS);
+ const run = await runMeta(dir, ['--write', '--json']);
+ expect(run.exitCode, run.stderr).toBeUndefined();
+ payload = json(run);
+ }, RUN_TIMEOUT * 2);
+
+ const kindOf = (path: string): CodemodRefusalKind | undefined =>
+ payload.write.manual.find((m: any) => m.path === path)?.kind;
+
+ it('names the refusal kind of every site it leaves', () => {
+ expect(kindOf('datasources[0].driver')).toBe('computed');
+ expect(kindOf('views[0].list.striped')).toBe('shared');
+ expect(kindOf('views[1].list.striped')).toBe('shared');
+ expect(kindOf('views[2].list.bordered')).toBe('spread');
+ expect(kindOf('views[3].list.virtualScroll')).toBe('layout');
+ expect(kindOf('views[4].list.striped')).toBe('outside-project');
+ expect(kindOf('agents[0].knowledge')).toBe('helper');
+ expect(payload.write.manual).toHaveLength(7);
+ for (const m of payload.write.manual) expect(m.reason.length).toBeGreaterThan(0);
+ });
+
+ it('still writes the site it can prove, beside them — and nothing else', () => {
+ expect(sites(payload.write.written)).toEqual(['flows[0].active (flow-inert-keys-removed)']);
+ expect(snapshot(dir)).toEqual({
+ 'objectstack.config.ts': edit(REFUSALS['objectstack.config.ts']!, ' active: true,', ''),
+ });
+ expect(payload.write.verification.ok).toBe(true);
+ });
+
+ it('lists each refused site for the author, with its kind, in the human report', async () => {
+ const run = await runMeta(writeProject(REFUSALS), ['--write']);
+ expect(run.exitCode, run.stderr).toBeUndefined();
+ expect(run.stdout).toContain('Wrote 1 of 8 mechanical change(s) into 1 file(s):');
+ expect(run.stdout).toContain('7 mechanical change(s) left for you to apply by hand:');
+ for (const kind of ['computed', 'shared', 'spread', 'layout', 'outside-project', 'helper']) {
+ expect(run.stdout).toContain(`not written [${kind}]:`);
+ }
+ expect(run.stdout).toContain('Re-ran the chain over the written sources: only the 7 change(s) left above remain.');
+ }, RUN_TIMEOUT);
+});
+
+// ── 5 (cont.): the planner's own refusals, which no live conversion reaches ──
+
+/** A single-file project for the planner. */
+function plannerProject(source: string): string {
+ return join(writeProject({ 'objectstack.config.ts': source }), 'objectstack.config.ts');
+}
+
+function applied(path: string, conversionId = 'probe'): MigrationApplication {
+ return { toMajor: 18, conversionId, surface: 'probe', from: 'a', to: 'b', path };
+}
+
+describe('planAuthoredSourceWrite — the refusal kinds the planner owns', () => {
+ it('mismatch: the loaded value disagrees with the literal', async () => {
+ const configPath = plannerProject("export default { datasources: [{ name: 'docs', label: 'A', driver: 'mongo' }] };\n");
+ const loaded = { datasources: [{ name: 'docs', label: 'B', driver: 'mongo' }] };
+ const plan = await planAuthoredSourceWrite({
+ configPath,
+ config: loaded,
+ namedExports: [],
+ normalized: loaded,
+ migrated: { datasources: [{ name: 'docs', label: 'B', driver: 'mongodb' }] },
+ applied: [applied('datasources[0].driver')],
+ });
+ expect(plan.manual.map((m) => m.refusal.kind)).toEqual(['mismatch']);
+ expect(plan.rewrites).toEqual([]);
+ });
+
+ it('injected: a map-form collection\'s `name` is the loader\'s; the key beside it is written', async () => {
+ const source = "export default { objects: { t: { label: 'T' } } };\n";
+ const configPath = plannerProject(source);
+ const loaded = { objects: { t: { label: 'T' } } };
+ const plan = await planAuthoredSourceWrite({
+ configPath,
+ config: loaded,
+ namedExports: [],
+ normalized: { objects: [{ label: 'T', name: 't' }] },
+ migrated: { objects: [{ label: 'Ticket', name: 'u' }] },
+ applied: [applied('objects[0].name', 'probe-name'), applied('objects[0].label', 'probe-label')],
+ });
+ expect(plan.manual.map((m) => [m.application.conversionId, m.refusal.kind])).toEqual([['probe-name', 'injected']]);
+ expect(plan.written.map((w) => w.application.conversionId)).toEqual(['probe-label']);
+ expect(plan.rewrites[0]!.after).toBe(edit(source, "label: 'T'", "label: 'Ticket'"));
+ });
+
+ it('unspellable: a converted value no literal can spell', async () => {
+ const configPath = plannerProject("export default { datasources: [{ name: 'docs', driver: 'mongo' }] };\n");
+ const loaded = { datasources: [{ name: 'docs', driver: 'mongo' }] };
+ const plan = await planAuthoredSourceWrite({
+ configPath,
+ config: loaded,
+ namedExports: [],
+ normalized: loaded,
+ migrated: { datasources: [{ name: 'docs', driver: () => 'mongodb' }] },
+ applied: [applied('datasources[0].driver')],
+ });
+ expect(plan.manual.map((m) => m.refusal.kind)).toEqual(['unspellable']);
+ });
+
+ it('entangled: an entry is never half-written beside a refused edit it shares', async () => {
+ const source = 'const N = 1;\nexport default { obj: { k: N, k2: 2 } };\n';
+ const configPath = plannerProject(source);
+ const loaded = { obj: { k: 1, k2: 2 } };
+ const plan = await planAuthoredSourceWrite({
+ configPath,
+ config: loaded,
+ namedExports: [],
+ normalized: loaded,
+ // `obj` explains both edits, `obj.k2` only its own: one component.
+ migrated: { obj: { k: 3 } },
+ applied: [applied('obj', 'probe-whole'), applied('obj.k2', 'probe-k2')],
+ });
+ expect(Object.fromEntries(plan.manual.map((m) => [m.application.conversionId, m.refusal.kind])))
+ .toEqual({ 'probe-whole': 'computed', 'probe-k2': 'entangled' });
+ expect(plan.rewrites).toEqual([]);
+ });
+
+ it('unattributed: an entry no edit can be tied to; an edit no entry names is never written', async () => {
+ const configPath = plannerProject("export default { a: { x: 1 }, b: { y: 1 } };\n");
+ const loaded = { a: { x: 1 }, b: { y: 1 } };
+ const plan = await planAuthoredSourceWrite({
+ configPath,
+ config: loaded,
+ namedExports: [],
+ normalized: loaded,
+ migrated: { a: { x: 1 }, b: { y: 2 } },
+ applied: [applied('a.x')],
+ });
+ expect(plan.manual.map((m) => m.refusal.kind)).toEqual(['unattributed']);
+ expect(plan.unexplained).toEqual(['b.y']);
+ expect(plan.rewrites).toEqual([]);
+ });
+});
+
+describe('verifyAuthoredSourceWrite — the re-run must match the report', () => {
+ it('fails on a written site the re-run still converts, and on a manual one it no longer does', () => {
+ const plan = {
+ projectRoot: '/p',
+ rewrites: [],
+ written: [{ application: applied('a.x', 'w'), file: 'c.ts', line: 1 }],
+ manual: [{ application: applied('b.y', 'm'), refusal: { kind: 'computed' as const, reason: 'r' } }],
+ unexplained: [],
+ };
+ expect(verifyAuthoredSourceWrite(plan, [applied('b.y', 'm')]).ok).toBe(true);
+ expect(verifyAuthoredSourceWrite(plan, [applied('b.y', 'm'), applied('a.x', 'w')]))
+ .toEqual({ ok: false, stillApplied: ['a.x (w)'], vanished: [] });
+ expect(verifyAuthoredSourceWrite(plan, [])).toEqual({ ok: false, stillApplied: [], vanished: ['b.y (m)'] });
+ });
+});
+
+// ── 6: the surface ──────────────────────────────────────────────────────────
+
+describe('the --write surface', () => {
+ it('is exclusive with --stored, and refused there', async () => {
+ const flags = MigrateMeta.flags as Record;
+ expect(flags.write.exclusive).toContain('stored');
+ await expect(MigrateMeta.run(['--stored', '--write'], { root: CLI_ROOT })).rejects.toThrow(/--write.*--stored|--stored.*--write/);
+ });
+
+ it('says what it writes and what it leaves, and claims no "automatic" rewrite', () => {
+ const description = (MigrateMeta.flags as Record).write.description as string;
+ expect(description).toMatch(/in place/);
+ expect(description).toMatch(/Never writes the manual/);
+ expect(description).not.toMatch(/automatic/i);
+ });
+
+ it('names no conversion: it is generic over the chain', () => {
+ const source = readFileSync(CODEMOD_SOURCE, 'utf8');
+ const ids = MIGRATION_MAJORS.flatMap((m) => MIGRATIONS_BY_MAJOR[m]!.conversionIds);
+ expect(ids.length).toBeGreaterThan(0);
+ expect(ids.filter((id) => source.includes(`'${id}'`) || source.includes(`"${id}"`))).toEqual([]);
+ });
+});