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([]); + }); +});