diff --git a/.changeset/22219-migrate-meta-write-range.md b/.changeset/22219-migrate-meta-write-range.md new file mode 100644 index 00000000000..44b45b19c1d --- /dev/null +++ b/.changeset/22219-migrate-meta-write-range.md @@ -0,0 +1,16 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): `os migrate meta --from N --write` rewrites the declared protocol range the load refuses (#22219) + +Clause-②: no + +The load refuses a manifest whose `engines.protocol` excludes the runtime's protocol major, and the refusal names `objectstack migrate meta --from N` as the command that resolves it. `--write` wrote the chain's mechanical changes and left `engines: { protocol: '^N' }` as it was, so an author who followed the refusal, wrote, and reloaded got the same refusal back. A manifest with nothing to convert was not written at all. + +- `--write` now also rewrites the declared range when the load would still refuse the migrated source under it, in the scaffold's spelling: `'^16'` → `'^17'` on a protocol-17 runtime. It rides the same plan, write and re-check as the chain's changes: a range literal the command cannot trace to one site is left with the reason, and a re-check that disagrees restores it with every other file. +- A manifest with nothing to convert gets the range edit alone. +- The major is `--to`, capped at the protocol this runtime implements. `--to` defaults to the highest major this build carries a step for, which can run ahead of the runtime: on a protocol-17 build, `--from 16` replays 16 → 18 and writes `'^17'`. +- The range is rewritten under the key the load read it from: `engines.protocol`, else `engines.platform`, else the legacy `engine.objectstack` (written as `'^17.0.0'`, the only form that key accepts). +- Left alone: a range the load already admits, an absent or unrecognised range, a range at or above the target major, and a range below `--from` (the report names the `--from` that moves it). +- The dry run (no `--write`) names the range edit `--write` would make. With `--json`, `write.range` reports the edit: `status`, `path`, `from`, `to`, and `file` / `line` when written or `kind` / `reason` when left. diff --git a/packages/cli/src/commands/migrate/meta.ts b/packages/cli/src/commands/migrate/meta.ts index c036012c63c..ecbcd92468c 100644 --- a/packages/cli/src/commands/migrate/meta.ts +++ b/packages/cli/src/commands/migrate/meta.ts @@ -38,12 +38,15 @@ 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 { checkProtocolCompat, type ProtocolHandshakeManifest } from '@objectstack/metadata-core'; import { planAuthoredSourceWrite, restoreAuthoredSources, verifyAuthoredSourceWrite, writeAuthoredSources, type AuthoredSourceWritePlan, + type RangeOutcome, + type RangeRewrite, type WriteVerification, } from '../../utils/authored-source-codemod.js'; @@ -277,6 +280,147 @@ function writeStackSnapshot(out: string, stack: Record): void { printInfo(`Wrote migrated stack snapshot → ${chalk.white(out)}`); } +/** + * What the run owes the manifest's declared protocol range (#22219). + * + * `rewrite`: the load refuses the range under the major this run migrated the + * source to, so `--write` rewrites it (and a dry run names the edit). + * `behind-from`: the load refuses it, but the chain started ABOVE the major the + * range declares, so it never replayed the majors in between; the range is + * left as written, and the report says which `--from` would move it. + */ +export type ProtocolRangePlan = + | { kind: 'rewrite'; rewrite: RangeRewrite } + | { kind: 'behind-from'; path: string; range: string; declaredMajor: number }; + +/** A manifest value narrowed to the strings the handshake reads; anything else reads as absent. */ +function handshakeSlice(manifest: Record): ProtocolHandshakeManifest { + const str = (v: unknown) => (typeof v === 'string' ? v : undefined); + const obj = (v: unknown) => (v && typeof v === 'object' ? (v as Record) : undefined); + const engines = obj(manifest.engines); + const engine = obj(manifest.engine); + return { + ...(engines ? { engines: { protocol: str(engines.protocol), platform: str(engines.platform) } } : {}), + ...(engine ? { engine: { objectstack: str(engine.objectstack) } } : {}), + }; +} + +/** + * The declared protocol range a migrated source owes, judged by the load's + * own handshake (#22219). + * + * ## Why this exists + * + * The load refuses a manifest whose range excludes the runtime's major, and the + * refusal names `objectstack migrate meta --from N` as the command that resolves + * it (`ProtocolIncompatibleDiagnostic.migrateCommand`, ADR-0087 P2). No + * conversion touches the range, so `--write` used to write the chain's edits, + * leave `'^N'` as it was, and hand the author back the same refusal. + * + * ## Which major the range is moved to + * + * `--to`, capped at the protocol this runtime implements. `--to` defaults to + * {@link CHAIN_TERMINUS_MAJOR}, which runs AHEAD of {@link PROTOCOL_MAJOR} while + * this build carries the next major's conversions — measured on a protocol-17 + * build, `--from 16` replays 16 → 18. Those conversions map shapes the + * installed schemas refuse onto ones they accept, so the migrated source is + * what THIS runtime loads, and `'^18'` would be refused by the same handshake + * (measured: "targets protocol ^18 … Run: objectstack migrate meta --from 18"). + * A `--to` below this runtime's major stops there, and so does the range: the + * source was not migrated past it. + * + * ## Where, and in what spelling + * + * At the key the handshake READ (`resolveDeclaredRange`: `engines.protocol`, + * else `engines.platform`, else the legacy `engine.objectstack`), in place. No + * chain step moves a range between those keys, and the schema accepts all three, + * so moving one would be a conversion the chain has not declared; the key the + * handshake read is the key it reads again. The spelling is the scaffold's and + * `os lint`'s, `'^N'` — except under `engine.objectstack`, whose schema refuses + * anything short of a full version, so `'^N.0.0'` there. + * + * ## What it never does + * + * It never lowers a range: one declaring a major at or above the target is + * left for the handshake to refuse, since moving it down would silence a real + * mismatch. It never touches a range the load admits, an absent one, or one + * the handshake cannot parse (the load admits those too). And it moves a range + * only across majors the chain replayed: when `--from` starts above the major + * the range declares (and above the chain's floor), the range is left and the + * report names the `--from` that would move it. + */ +export function planProtocolRange( + stack: Record, + fromMajor: number, + toMajor: number, +): ProtocolRangePlan | undefined { + // The slice the load's handshake reads: the bundle's `manifest`, else the + // bundle itself (`AppPlugin`: `bundle.manifest || bundle`). + const nested = stack.manifest && typeof stack.manifest === 'object'; + const manifest = (nested ? stack.manifest : stack) as Record; + const slice = handshakeSlice(manifest); + const major = Math.min(toMajor, PROTOCOL_MAJOR); + const compat = checkProtocolCompat(slice, `${major}.0.0`); + if (compat.status !== 'incompatible') return undefined; + const declaredMajor = compat.diagnostic.targetMajor; + if (declaredMajor === null || declaredMajor >= major) return undefined; + + const path = `${nested ? 'manifest.' : ''}${compat.source}`; + const authored = compat.source === 'engines.protocol' + ? slice.engines?.protocol + : compat.source === 'engines.platform' ? slice.engines?.platform : slice.engine?.objectstack; + const from = authored ?? compat.requiredRange; + if (fromMajor > Math.max(declaredMajor, MIGRATION_SUPPORT_FLOOR)) { + return { kind: 'behind-from', path, range: from, declaredMajor }; + } + const to = compat.source === 'engine.objectstack' ? `^${major}.0.0` : `^${major}`; + return { kind: 'rewrite', rewrite: { path, from, to, major } }; +} + +/** A range string as the report quotes it. */ +function quotedRange(range: string): string { + return `'${range}'`; +} + +/** A dry run's range line (#22219): the edit `--write` would make, and why it is owed. */ +function printRangeDryRun(rewrite: RangeRewrite): void { + printWarning( + `${rewrite.path} ${quotedRange(rewrite.from)} does not admit protocol ${rewrite.major}, so the load ` + + `refuses this stack even after migrating; --write rewrites it to ${quotedRange(rewrite.to)}.`, + ); + console.log(''); +} + +/** The range a run left because it started above the major the range declares (#22219). */ +function printRangeLeft(plan: Extract, fromMajor: number): void { + // The lowest `--from` that replays every major the range missed — never under the chain's floor. + const start = Math.max(plan.declaredMajor, MIGRATION_SUPPORT_FLOOR); + printWarning( + `${plan.path} ${quotedRange(plan.range)} declares protocol ${plan.declaredMajor}, below --from ${fromMajor}: ` + + `this run did not replay protocol ${start} → ${fromMajor}, so the range is left as written and ` + + `the load still refuses it. Run with --from ${start}.`, + ); + console.log(''); +} + +/** What `--write` did with the declared range (#22219): written at its site, or left with the reason. */ +function printRangeOutcome(range: RangeOutcome): void { + const { rewrite } = range; + const edit = `${rewrite.path}: ${quotedRange(rewrite.from)} → ${quotedRange(rewrite.to)}`; + if (range.status === 'written') { + console.log(chalk.bold(` Rewrote the declared protocol range, so the load admits protocol ${rewrite.major}:`)); + console.log(` ${chalk.white(range.file)}${chalk.dim(`:${range.line}`)} ${edit}`); + console.log(''); + return; + } + console.log(chalk.bold(chalk.yellow( + ` The declared protocol range was left for you to change by hand; until then the load refuses it under protocol ${rewrite.major}:`, + ))); + console.log(` ${chalk.yellow('•')} ${edit}`); + console.log(chalk.dim(` not written [${range.refusal.kind}]: ${range.refusal.reason}`)); + console.log(''); +} + /** One schema refusal of the migrated stack, in the shape `formatZodIssue` renders. */ export type MigrationRefusal = Parameters[0]; @@ -318,11 +462,23 @@ export function writeOutcomeJson(outcome: WriteOutcome) { reason: m.refusal.reason, })), unexplained: plan.unexplained, + // The declared protocol range (#22219), only when the run owed it an edit. + // Not one of `written` / `manual`: those list the chain's `applied` + // entries, and the range is not one. + ...(plan.range ? { range: rangeOutcomeJson(plan.range) } : {}), ...(outcome.verification ? { verification: outcome.verification } : {}), ...(outcome.error ? { error: outcome.error } : {}), }; } +/** The `--json` face of a {@link RangeOutcome}. */ +function rangeOutcomeJson(range: RangeOutcome) { + const { path, from, to } = range.rewrite; + return range.status === 'written' + ? { status: range.status, path, from, to, file: range.file, line: range.line } + : { status: range.status, path, from, to, kind: range.refusal.kind, reason: range.refusal.reason }; +} + /** * 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 @@ -330,7 +486,7 @@ export function writeOutcomeJson(outcome: WriteOutcome) { */ function printWriteOutcome(outcome: WriteOutcome, appliedCount: number): void { const { plan } = outcome; - if (appliedCount === 0) { + if (appliedCount === 0 && !plan.range) { printInfo('--write: the chain made no mechanical change here, so no file was written.'); console.log(''); return; @@ -349,16 +505,25 @@ function printWriteOutcome(outcome: WriteOutcome, appliedCount: number): void { 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})`)}`); + if (appliedCount === 0) { + // #22219: a stack with nothing to convert still owes the range its refusal names. + printInfo('--write: the chain made no mechanical change here; the declared protocol range is the one edit it owes.'); + console.log(''); + } else { + // The files holding the chain's edits; a file holding only the range is the range group's. + const chainFiles = plan.rewrites.filter((r) => plan.written.some((w) => w.file === r.file)); + console.log(chalk.bold( + ` Wrote ${plan.written.length} of ${appliedCount} mechanical change(s) into ${chainFiles.length} file(s):`, + )); + for (const r of chainFiles) { + 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(''); } - console.log(''); + if (plan.range) printRangeOutcome(plan.range); 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) { @@ -375,15 +540,29 @@ function printWriteOutcome(outcome: WriteOutcome, appliedCount: number): void { ); } if (files > 0) { + // The re-check holds the range as it holds the chain's edits (#22219). + const range = plan.range?.status === 'written' + ? ` The declared protocol range now admits protocol ${plan.range.rewrite.major}.` + : ''; 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.`}`, + : `only the ${plan.manual.length} change(s) left above remain.`}${range}`, ); console.log(''); } } +/** + * Group ⑤: with `--write`, what it wrote; without it, the range edit it would + * make (#22219). A range the run left alone says so either way. + */ +function printWriteGroup(report: MigrationReport, appliedCount: number): void { + if (report.range?.kind === 'behind-from') printRangeLeft(report.range, report.result.fromMajor); + if (report.write) printWriteOutcome(report.write, appliedCount); + else if (report.range?.kind === 'rewrite') printRangeDryRun(report.range.rewrite); +} + /** 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. */ @@ -406,6 +585,8 @@ export interface MigrationReport { out?: string; /** `--write`: what was written into the authored sources (absent without the flag). */ write?: WriteOutcome; + /** What the run owes the declared protocol range, when anything (#22219). */ + range?: ProtocolRangePlan; /** Printed beside a schema-valid verdict. */ elapsed: string; } @@ -578,7 +759,9 @@ function printAbsentNotices(result: MigrationChainResult, all: boolean): void { * in full (see {@link printAbsentNotices}); * ⑤ with `--write`, what was written into the authored sources and what was * left (see {@link printWriteOutcome}) — mechanical changes only, so it - * reads `applied` and never ③ or ④. + * reads `applied` and never ③ or ④ — plus the declared protocol range the + * load would otherwise still refuse; without `--write`, that range edit + * named as the one `--write` would make (see {@link printWriteGroup}). * * ## Why this order * @@ -624,7 +807,7 @@ export function printMigrationReport(report: MigrationReport): void { // `--json` writes it regardless (#22116). console.log(''); if (report.out) writeStackSnapshot(report.out, result.stack); - if (report.write) printWriteOutcome(report.write, 0); + printWriteGroup(report, 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 @@ -664,8 +847,9 @@ export function printMigrationReport(report: MigrationReport): void { writeStackSnapshot(report.out, result.stack); } - // ⑤ `--write`: the mechanical changes written into the sources, and the rest. - if (report.write) printWriteOutcome(report.write, result.applied.length); + // ⑤ `--write`: the mechanical changes written into the sources, and the rest; + // or, on a dry run, the declared-range edit `--write` would make (#22219). + printWriteGroup(report, result.applied.length); printPendingDataMigrations(report.dataMigrations); } @@ -687,7 +871,9 @@ export function printMigrationReport(report: MigrationReport): void { * 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 + * semantic TODO. It also rewrites the manifest's declared protocol range when + * the load would still refuse the migrated source under it (#22219, see + * {@link planProtocolRange}). 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. * @@ -762,8 +948,8 @@ export default class MigrateMeta extends Command { 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.', + + 'project file, and the manifest\'s declared protocol range when the load would still refuse it; every ' + + 'other change is listed with the reason it was not written. Never writes the manual (semantic) changes.', default: false, exclusive: ['stored'], }), @@ -881,6 +1067,10 @@ export default class MigrateMeta extends Command { const specChanges = composeSpecChanges(fromMajor, toMajor); const dataMigrations = pendingDataMigrations(result.stack, result.fromMajor, result.toMajor); + // The declared protocol range the load would still refuse after this + // run (#22219): `--write` rewrites it, a dry run names the edit. + const range = planProtocolRange(normalized, 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 @@ -891,6 +1081,7 @@ export default class MigrateMeta extends Command { namedExports, normalized, result, + ...(range?.kind === 'rewrite' ? { range: range.rewrite } : {}), json: Boolean(flags.json), }) : undefined; @@ -964,6 +1155,7 @@ export default class MigrateMeta extends Command { all: flags.all, ...(flags.out ? { out: resolve(flags.out) } : {}), ...(write ? { write } : {}), + ...(range ? { range } : {}), elapsed: timer.display(), }); // A write refused or undone is a failed run, reported above. @@ -994,7 +1186,9 @@ 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. + * the plan (#9591). The declared protocol range rides the same plan, write + * and re-check (#22219). Writes nothing when neither the chain nor the range + * owes an edit. */ private async writeSources(input: { configArg: string | undefined; @@ -1003,10 +1197,13 @@ export default class MigrateMeta extends Command { namedExports: readonly string[]; normalized: Record; result: MigrationChainResult; + range?: RangeRewrite; json: boolean; }): Promise { const { result } = input; - if (!input.json && result.applied.length > 0) printStep('Writing the mechanical changes into the authored sources…'); + if (!input.json && (result.applied.length > 0 || input.range)) { + printStep('Writing the mechanical changes into the authored sources…'); + } const plan = await planAuthoredSourceWrite({ configPath: input.configPath, config: input.config, @@ -1014,6 +1211,7 @@ export default class MigrateMeta extends Command { normalized: input.normalized, migrated: result.stack, applied: result.applied, + ...(input.range ? { range: input.range } : {}), }); if (plan.rewrites.length === 0) return { plan, status: 'written' }; try { @@ -1031,12 +1229,15 @@ export default class MigrateMeta extends Command { 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, + const rerunInput = normalizeStackInput(reloaded.config as Record, { convert: false }); + const rerun = applyMetaMigrations(rerunInput, result.fromMajor, result.toMajor); + // The range the written sources still owe, judged as it was before the write. + const rerunRange = planProtocolRange(rerunInput, result.fromMajor, result.toMajor); + verification = verifyAuthoredSourceWrite( + plan, + rerun.applied, + rerunRange?.kind === 'rewrite' ? rerunRange.rewrite : undefined, ); - 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)}` }; diff --git a/packages/cli/src/utils/authored-source-codemod.ts b/packages/cli/src/utils/authored-source-codemod.ts index af6865b2eb2..16f69e433c7 100644 --- a/packages/cli/src/utils/authored-source-codemod.ts +++ b/packages/cli/src/utils/authored-source-codemod.ts @@ -58,6 +58,19 @@ * 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. * + * ## The one edit no conversion makes: the declared protocol range (#22219) + * + * The load refuses a manifest whose declared protocol range excludes the + * runtime's major, and its refusal names `migrate meta --from N` as the remedy. + * No conversion moves that range, so a write of the chain's edits alone left + * the refusal standing. The command therefore hands the planner one more edit, + * a {@link RangeRewrite}, and the planner treats it as one more applied entry + * with one more change: it is traced, proved, refused, written, re-checked and + * restored exactly like the chain's own edits. It is reported apart + * (`plan.range`), because it is not one of the chain's `applied` entries. + * Which range is owed, and at which key, is the command's question, not this + * module's. + * * ## What it never writes * * The chain's semantic TODOs (`todos`) are judgment calls; nothing here reads @@ -136,6 +149,27 @@ export interface FileRewrite { after: string; } +/** + * The manifest's declared protocol range, moved to the major the migrated + * source targets (#22219). The command decides that it is owed and at which + * key; the planner writes it like any other edit. + */ +export interface RangeRewrite { + /** Where the range sits in the stack, spelled like `applied[].path` (`manifest.engines.protocol`). */ + path: string; + /** The range as authored. */ + from: string; + /** The range written in its place. */ + to: string; + /** The protocol major `to` admits. */ + major: number; +} + +/** What became of a {@link RangeRewrite}: written at a site, or left for the author with the reason. */ +export type RangeOutcome = + | { rewrite: RangeRewrite; status: 'written'; file: string; line: number } + | { rewrite: RangeRewrite; status: 'manual'; refusal: CodemodRefusal }; + export interface AuthoredSourceWritePlan { /** The project directory every written file lies under (the config's directory). */ projectRoot: string; @@ -148,6 +182,8 @@ export interface AuthoredSourceWritePlan { * a site without reporting it. */ unexplained: string[]; + /** The declared protocol range, when the input carried a {@link RangeRewrite}. */ + range?: RangeOutcome; } export interface AuthoredSourceWriteInput { @@ -163,6 +199,8 @@ export interface AuthoredSourceWriteInput { migrated: Record; /** The chain's mechanical applications (`result.applied`), in order. */ applied: readonly MigrationApplication[]; + /** The declared protocol range the load refuses, when the command owes it an edit (#22219). */ + range?: RangeRewrite; } /** Thrown inside a walk; caught at the change it was resolving. */ @@ -1568,10 +1606,47 @@ function overlapping(edits: readonly TextEdit[]): TextEdit | undefined { return undefined; } +/** + * The entry a {@link RangeRewrite} stands as among the chain's: its label sits + * where a conversion id would, so a re-check names it the way it names theirs. + */ +function rangeApplication(range: RangeRewrite): MigrationApplication { + return { + toMajor: range.major, + conversionId: 'declared protocol range', + surface: range.path, + from: range.from, + to: range.to, + path: range.path, + }; +} + +/** + * `root` with `value` at `path`, copying only the containers on the way. A path + * that leads nowhere changes nothing, so the edit is then attributed to nothing. + */ +function withValueAt(root: unknown, path: readonly Segment[], value: unknown): Record { + const set = (node: unknown, depth: number): unknown => { + if (depth === path.length) return value; + const seg = path[depth]!; + if (Array.isArray(node) && typeof seg === 'number' && seg < node.length) { + const copy = [...node]; + copy[seg] = set(node[seg], depth + 1); + return copy; + } + if (isPlainObject(node) && typeof seg === 'string' && node[seg] !== undefined) { + return { ...node, [seg]: set(node[seg], depth + 1) }; + } + return node; + }; + return set(root, 0) as Record; +} + /** * 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. + * A {@link RangeRewrite} rides the same plan as one more entry (#22219). */ export async function planAuthoredSourceWrite(input: AuthoredSourceWriteInput): Promise { const { ts } = await import('ts-morph'); @@ -1582,13 +1657,23 @@ export async function planAuthoredSourceWrite(input: AuthoredSourceWriteInput): const locator = new Locator(ts, graph, input.config, input.namedExports, configPath); const planner = new Planner(ts, graph, locator); + // The declared range joins as the last entry, and its new value joins the + // migrated stack, so every phase below plans it as it plans a conversion's. + const rangeIndex = input.range ? input.applied.length : -1; + const applied: readonly MigrationApplication[] = input.range + ? [...input.applied, rangeApplication(input.range)] + : input.applied; + const migrated = input.range + ? withValueAt(input.migrated, parsePath(input.range.path), input.range.to) + : input.migrated; + // 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 changes = diffStacks(input.normalized, migrated); + const appliedPaths = 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 parent = 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); @@ -1678,33 +1763,45 @@ export async function planAuthoredSourceWrite(input: AuthoredSourceWriteInput): break; } - // Report by applied entry, in chain order. - const written: WrittenSite[] = []; - const manual: ManualSite[] = []; - input.applied.forEach((application, i) => { + /** Where entry `i` was written, or why it was not. */ + const entrySite = (i: number): { file: string; line: number } | { refusal: CodemodRefusal } => { 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; + return { refusal: { kind: 'unattributed', reason: 'no edit in the migrated stack could be tied to this site' } }; } const compRefusal = refusedComponent.get(comp); if (compRefusal) { const own = mine.map((ci) => refusedChange.get(ci)).find((r) => r !== undefined); - manual.push({ - application, + return { 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 }); - }); + const at = mine.find((ci) => changes[ci]!.op !== 'add') ?? mine[0]!; + const r = resolved.get(at)!; + return { file: graph.rel(r.container.node.file), line: r.line }; + }; + + // Report by applied entry, in chain order; the declared range apart. + const written: WrittenSite[] = []; + const manual: ManualSite[] = []; + let range: RangeOutcome | undefined; + for (const [i, application] of applied.entries()) { + const site = entrySite(i); + if (i === rangeIndex && input.range) { + range = 'refusal' in site + ? { rewrite: input.range, status: 'manual', refusal: site.refusal } + : { rewrite: input.range, status: 'written', file: site.file, line: site.line }; + } else if ('refusal' in site) { + manual.push({ application, refusal: site.refusal }); + } else { + written.push({ application, file: site.file, line: site.line }); + } + } - return { projectRoot, rewrites, written, manual, unexplained }; + return { projectRoot, rewrites, written, manual, unexplained, ...(range ? { range } : {}) }; } /** @@ -1737,17 +1834,23 @@ export interface WriteVerification { /** * 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. + * gone, every manual one still there. The declared range is held the same + * way (#22219): `rerunRange` is the range edit the written sources still owe, + * which must be absent when the plan wrote it and present when it left it. */ export function verifyAuthoredSourceWrite( plan: AuthoredSourceWritePlan, rerun: readonly MigrationApplication[], + rerunRange?: RangeRewrite, ): 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 leave = (a: MigrationApplication) => expected.set(key(a), (expected.get(key(a)) ?? 0) + 1); + for (const m of plan.manual) leave(m.application); + if (plan.range?.status === 'manual') leave(rangeApplication(plan.range.rewrite)); + const owed = rerunRange ? [...rerun, rangeApplication(rerunRange)] : rerun; const stillApplied: string[] = []; - for (const a of rerun) { + for (const a of owed) { const k = key(a); const n = expected.get(k) ?? 0; if (n > 0) expected.set(k, n - 1); diff --git a/packages/cli/test/migrate-meta-protocol-range.test.ts b/packages/cli/test/migrate-meta-protocol-range.test.ts new file mode 100644 index 00000000000..d3f7dab3a63 --- /dev/null +++ b/packages/cli/test/migrate-meta-protocol-range.test.ts @@ -0,0 +1,530 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os migrate meta --from N --write` rewrites the manifest's declared protocol + * range when the load would still refuse it (#22219) — pinned at the load's + * own door. + * + * ## The defect + * + * The load refuses a manifest whose `engines.protocol` excludes the runtime's + * major, and the refusal names `objectstack migrate meta --from N` as the + * command that resolves it (`ProtocolIncompatibleDiagnostic.migrateCommand`, + * ADR-0087 P2). `--write` wrote the chain's edits and left `'^N'` as it was, so + * an author who followed the prescription, wrote, and reloaded got the same + * refusal back. Measured on `main` `7b926f76` through `os serve` with the + * `--from 16` shape, before and after `--write`: "package 'com.example.h1' + * targets protocol ^16 (engines.protocol) but this runtime is protocol 17.0.0. + * This is a major-version break. Run: objectstack migrate meta --from 16". + * + * ## What is pinned + * + * 1. At the door: the control (the manifest before migrating is refused, and + * the refusal names this command), then `--write`, then a reload that is + * NOT refused. The door is `AppPlugin.init`, the code-defined-stack load + * seam `os serve` boots through, fed by the CLI's own config loader. With + * a key the chain converts, the load refuses at the schema first (the + * key's tombstone names the command); the range alone is refused at the + * handshake (`OS_PROTOCOL_INCOMPATIBLE`, whose `migrateCommand` names it). + * 2. A manifest with nothing to convert still owes the range: `--write` + * writes the range alone, and says so. + * 3. A range the load already admits is left alone, byte for byte. + * 4. The legacy homes the handshake still reads (`engines.platform`, + * `engine.objectstack`) are rewritten where the handshake read them, in + * the spelling each one's schema accepts. + * 5. Which major: `--to`, capped at the protocol this runtime implements. + * 6. A range the chain did not migrate past (`--from` above the major the + * range declares) is left, and the report names the `--from` that moves it. + * 7. The range rides the chain's plan, write and re-check: a forced re-check + * mismatch restores it with the other edits, and a range edit that does not + * land is caught by the re-check and restored; the planner refuses a range + * literal it cannot prove, by the same refusal kinds. + * + * The 17 → 18 shape is a second row of {@link SHAPES}, once the runtime + * implements protocol 18. + * + * In-process over the real command (`MigrateMeta.run`) and the real load seam + * (`AppPlugin.init` with a context double), against temp projects that link + * the real `@objectstack/spec`: no process is spawned and no kernel is booted, + * so this file sits in the `unit` tier. + */ + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve } from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { stripVTControlCharacters } from 'node:util'; +import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; +import { AppPlugin } from '@objectstack/runtime'; +import MigrateMeta, { planProtocolRange } from '../src/commands/migrate/meta.js'; +import { loadConfig } from '../src/utils/config.js'; +import { + planAuthoredSourceWrite, + verifyAuthoredSourceWrite, + type AuthoredSourceWritePlan, +} from '../src/utils/authored-source-codemod.js'; + +/** A seam between planning and writing: the command runs the real planner, and a test may act on the plan. */ +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 RUN_TIMEOUT = 120_000; + +/** + * The shapes the load refuses on this runtime, one row per major migrated + * FROM: the major, and one authored member the chain converts out of it — a + * key the current schema tombstones, so the authored-source load hands it to + * the chain as written. The 17 → 18 row joins once this runtime implements + * protocol 18, with a member the protocol-18 step converts. + */ +const SHAPES = [ + { + from: 16, + member: "views: [{ object: 'rg_item', list: { type: 'grid', columns: ['title'], striped: true } }]", + converted: ['view-list-passthrough-keys-removed'], + before: ', striped: true', + after: '', + }, +]; + +/** The range `--write` writes on this build: the scaffold's spelling, at this runtime's major. */ +const TARGET = `^${PROTOCOL_MAJOR}`; + +/** `packages/cli` depends on `@objectstack/spec`; resolved as a package, not a source path. */ +const requireFromCli = createRequire(import.meta.url); +const SPEC_PACKAGE_ROOT = dirname(requireFromCli.resolve('@objectstack/spec/package.json')); + +let root: string; +let specLink: string; +let caseSeq = 0; + +function writeProject(files: Record): string { + const dir = join(root, `case-${++caseSeq}`); + for (const [rel, text] of Object.entries(files)) { + const path = join(dir, rel); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, text); + } + return dir; +} + +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` — `from` must occur exactly once, so an edit is never 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, from: number, 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', String(from), ...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, + }; +} + +/** What the load did: `refused` with the thrown value, or `loaded` with the manifest it registered. */ +type LoadVerdict = { refused: any } | { loaded: unknown }; + +/** + * Load a project through the door `os serve` boots through: the CLI's config + * loader (whose `define*` calls run the schema), then `AppPlugin.init`, whose + * protocol handshake runs before the manifest is registered. Either one may + * refuse. The context double carries the one service registration needs. + */ +async function load(dir: string): Promise { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + let config: Awaited>['config']; + try { + ({ config } = await loadConfig(join(dir, 'objectstack.config.ts'))); + } catch (refused) { + return { refused }; + } + const registered: unknown[] = []; + const ctx = { + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: vi.fn((name: string) => { + if (name === 'manifest') return { register: (m: unknown) => registered.push(m) }; + throw new Error(`service '${name}' not found`); + }), + getServices: vi.fn(() => new Map()), + hook: vi.fn(), + trigger: vi.fn(), + }; + try { + await new AppPlugin(config).init(ctx as never); + } catch (refused) { + return { refused }; + } + expect(registered, 'a load that is not refused registers the manifest').toHaveLength(1); + return { loaded: registered[0] }; + } finally { + warn.mockRestore(); + log.mockRestore(); + } +} + +/** The refusal the load answers a manifest declaring `range` at `source`, from major `from`. */ +function refusal(from: number, range: string, source = 'engines.protocol') { + return { + code: 'OS_PROTOCOL_INCOMPATIBLE', + status: 422, + diagnostic: expect.objectContaining({ + rangeSource: source, + requiredRange: range, + migrateCommand: `objectstack migrate meta --from ${from}`, + }), + }; +} + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'os-migrate-meta-range-')); + mkdirSync(join(root, 'node_modules', '@objectstack'), { recursive: true }); + specLink = join(root, 'node_modules', '@objectstack', 'spec'); + symlinkSync(SPEC_PACKAGE_ROOT, specLink, 'dir'); +}); + +afterAll(() => { + // Unlinked BEFORE the recursive remove: 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 */ } +}); + +/** A config declaring `engines` as given, with one more stack member (`member`), or none. */ +function config(engines: string, member?: string): string { + return `import { defineStack } from '@objectstack/spec'; + +export default defineStack({ + manifest: { + id: 'com.example.range', + name: 'Range fixture', + version: '1.0.0', + type: 'app', + ${engines}, + }, + objects: [{ name: 'rg_item', label: 'Item', fields: { title: { type: 'text', label: 'Title' } } }], +${member ? ` ${member},\n` : ''}}); +`; +} + +/** The first row's convertible member, for the cases that need one and are not about the major. */ +const MEMBER = SHAPES[0]!; + +/** The line the `engines` member sits on in {@link config}'s output. */ +const ENGINES_LINE = 9; + +// ── 1: the door, before and after ────────────────────────────────────────── + +describe.each(SHAPES)('--from $from: the load refuses the manifest, and --write leaves one it admits', (shape) => { + const { from } = shape; + const RANGE = `^${from}`; + const SOURCE = config(`engines: { protocol: '${RANGE}' }`, shape.member); + let dir: string; + let before: LoadVerdict; + let dry: Run; + let dryBytes: Record; + let written: Run; + let after: LoadVerdict; + + beforeAll(async () => { + dir = writeProject({ 'objectstack.config.ts': SOURCE }); + before = await load(dir); + dry = await runMeta(dir, from, []); + dryBytes = snapshot(dir); + written = await runMeta(dir, from, ['--write', '--json']); + after = await load(dir); + }, RUN_TIMEOUT * 4); + + it('control: before migrating, the load refuses it, and the refusal names this command', () => { + // The tombstoned key is refused at the schema, before the handshake runs. + expect(before).toEqual({ + refused: expect.objectContaining({ + code: 'STACK_SCHEMA_INVALID', + message: expect.stringContaining(`migrate meta --from ${from}`), + }), + }); + }); + + it('a dry run names the range edit --write would make, and writes nothing', () => { + expect(dry.exitCode, dry.stderr).toBeUndefined(); + expect(dry.stdout).toContain( + `manifest.engines.protocol '${RANGE}' does not admit protocol ${PROTOCOL_MAJOR}, so the load refuses this ` + + `stack even after migrating; --write rewrites it to '${TARGET}'.`, + ); + expect(dryBytes).toEqual({ 'objectstack.config.ts': SOURCE }); + }); + + it('--write rewrites the range in place beside the chain\'s edit, and only those bytes change', () => { + expect(written.exitCode, written.stderr).toBeUndefined(); + const payload = JSON.parse(written.stdout); + expect(payload.write.status).toBe('written'); + expect(payload.write.range).toEqual({ + status: 'written', + path: 'manifest.engines.protocol', + from: RANGE, + to: TARGET, + file: 'objectstack.config.ts', + line: ENGINES_LINE, + }); + // The range is not one of the chain's entries: `written` lists exactly `applied`. + expect(payload.write.written.map((w: any) => w.conversionId)).toEqual(shape.converted); + expect(payload.applied.map((a: any) => a.conversionId)).toEqual(shape.converted); + expect(payload.write.verification).toEqual({ ok: true, stillApplied: [], vanished: [] }); + expect(snapshot(dir)).toEqual({ + 'objectstack.config.ts': edit( + edit(SOURCE, `protocol: '${RANGE}'`, `protocol: '${TARGET}'`), + shape.before, + shape.after, + ), + }); + }); + + it('the reload is not refused', () => { + expect(after).toEqual({ loaded: expect.anything() }); + }); +}); + +// ── 2: nothing to convert, still the range ───────────────────────────────── + +describe.each(SHAPES)('--from $from: a manifest with nothing to convert still owes the range', ({ from }) => { + it('control: the load refuses it at the handshake, naming this command; --write writes the range alone, says so, and the reload is not refused', async () => { + const source = config(`engines: { protocol: '^${from}' }`); + const dir = writeProject({ 'objectstack.config.ts': source }); + expect(await load(dir)).toEqual({ refused: expect.objectContaining(refusal(from, `^${from}`)) }); + + const run = await runMeta(dir, from, ['--write']); + expect(run.exitCode, run.stderr).toBeUndefined(); + expect(run.stdout).toContain( + '--write: the chain made no mechanical change here; the declared protocol range is the one edit it owes.', + ); + expect(run.stdout).toContain(`Rewrote the declared protocol range, so the load admits protocol ${PROTOCOL_MAJOR}:`); + expect(run.stdout).toContain(`objectstack.config.ts:${ENGINES_LINE} manifest.engines.protocol: '^${from}' → '${TARGET}'`); + expect(run.stdout).toContain( + 'Re-ran the chain over the written sources: no mechanical change remains. ' + + `The declared protocol range now admits protocol ${PROTOCOL_MAJOR}.`, + ); + expect(run.stdout).not.toContain('no file was written'); + expect(snapshot(dir)).toEqual({ 'objectstack.config.ts': edit(source, `protocol: '^${from}'`, `protocol: '${TARGET}'`) }); + expect(await load(dir)).toEqual({ loaded: expect.anything() }); + }, RUN_TIMEOUT * 2); +}); + +// ── 3: a range the load admits ───────────────────────────────────────────── + +describe('a range the load already admits is left alone', () => { + it('--write writes the chain\'s edit only, reports no range, and the reload is not refused', async () => { + const source = config("engines: { protocol: '>=16' }", MEMBER.member); + const dir = writeProject({ 'objectstack.config.ts': source }); + const dry = await runMeta(dir, MEMBER.from, []); + expect(dry.stdout).not.toContain('--write rewrites it'); + const run = await runMeta(dir, MEMBER.from, ['--write', '--json']); + expect(run.exitCode, run.stderr).toBeUndefined(); + const payload = JSON.parse(run.stdout); + expect(payload.write.written.map((w: any) => w.conversionId)).toEqual(MEMBER.converted); + expect(payload.write).not.toHaveProperty('range'); + expect(snapshot(dir)).toEqual({ 'objectstack.config.ts': edit(source, MEMBER.before, MEMBER.after) }); + expect(await load(dir)).toEqual({ loaded: expect.anything() }); + }, RUN_TIMEOUT * 2); +}); + +// ── 4: the legacy homes, where the handshake read them ───────────────────── + +describe('a range in a legacy home is rewritten where the handshake read it', () => { + it.each([ + ['engines.platform', "engines: { platform: '^16' }", '^16', `engines: { platform: '${TARGET}' }`], + // Its schema refuses a range short of a full version, so the full version is written. + ['engine.objectstack', "engine: { objectstack: '^16.0.0' }", '^16.0.0', `engine: { objectstack: '^${PROTOCOL_MAJOR}.0.0' }`], + ])('%s', async (source, engines, range, rewritten) => { + const text = config(engines); + const dir = writeProject({ 'objectstack.config.ts': text }); + expect(await load(dir)).toEqual({ refused: expect.objectContaining(refusal(16, range, source)) }); + + const run = await runMeta(dir, 16, ['--write', '--json']); + expect(run.exitCode, run.stderr).toBeUndefined(); + expect(JSON.parse(run.stdout).write.range).toMatchObject({ status: 'written', path: `manifest.${source}`, from: range }); + expect(snapshot(dir)).toEqual({ 'objectstack.config.ts': edit(text, engines, rewritten) }); + expect(await load(dir)).toEqual({ loaded: expect.anything() }); + }, RUN_TIMEOUT * 2); +}); + +// ── 5 and 6: which major, and when the range is left ─────────────────────── + +describe('which range is written', () => { + it('the major is --to, capped at the protocol this runtime implements', async () => { + const dir = writeProject({ 'objectstack.config.ts': config("engines: { protocol: '^16' }", MEMBER.member) }); + const payload = JSON.parse((await runMeta(dir, MEMBER.from, ['--write', '--json'])).stdout); + // `--to` defaults to the chain's terminus, which may run ahead of this runtime. + expect(payload.write.range.to).toBe(`^${Math.min(payload.to, PROTOCOL_MAJOR)}`); + expect(payload.write.range.to).toBe(TARGET); + }, RUN_TIMEOUT); + + it('a range the chain did not migrate past is left, and the report names the --from that moves it', async () => { + const source = config("engines: { protocol: '^16' }"); + const dir = writeProject({ 'objectstack.config.ts': source }); + const run = await runMeta(dir, PROTOCOL_MAJOR, ['--write']); + expect(run.exitCode, run.stderr).toBeUndefined(); + expect(run.stdout).toContain( + `manifest.engines.protocol '^16' declares protocol 16, below --from ${PROTOCOL_MAJOR}: this run did not ` + + `replay protocol 16 → ${PROTOCOL_MAJOR}, so the range is left as written and the load still refuses it. ` + + 'Run with --from 16.', + ); + expect(snapshot(dir)).toEqual({ 'objectstack.config.ts': source }); + }, RUN_TIMEOUT); + + it('never lowers a range, nor touches one the handshake admits, cannot parse, or that is absent', () => { + const at = (engines: Record) => planProtocolRange({ manifest: { engines } }, 16, PROTOCOL_MAJOR); + expect(at({ protocol: `^${PROTOCOL_MAJOR + 1}` })).toBeUndefined(); + expect(at({ protocol: TARGET })).toBeUndefined(); + expect(at({ protocol: 'banana' })).toBeUndefined(); + expect(at({ protocol: 17 as unknown as string })).toBeUndefined(); + expect(planProtocolRange({ manifest: { id: 'x' } }, 16, PROTOCOL_MAJOR)).toBeUndefined(); + expect(at({ protocol: '^16' })).toEqual({ + kind: 'rewrite', + rewrite: { path: 'manifest.engines.protocol', from: '^16', to: TARGET, major: PROTOCOL_MAJOR }, + }); + }); +}); + +// ── 7: one plan, one write, one re-check ─────────────────────────────────── + +describe('the range rides the plan, the write and the re-check', () => { + const SOURCE = config("engines: { protocol: '^16' }", MEMBER.member); + + it('a re-check that disagrees restores the range with every other edit', async () => { + const dir = writeProject({ 'objectstack.config.ts': SOURCE }); + let planned: AuthoredSourceWritePlan['range']; + hooks.afterPlan = (plan) => { + planned = plan.range; + // A report claiming one more site left by hand than the chain will find. + plan.manual.push({ + application: { toMajor: 18, conversionId: 'probe-phantom', surface: 'probe', from: 'a', to: 'b', path: 'nowhere.at.all' }, + refusal: { kind: 'computed', reason: 'r' }, + }); + }; + let run: Run; + try { + run = await runMeta(dir, MEMBER.from, ['--write']); + } finally { + hooks.afterPlan = undefined; + } + // The plan wrote the range beside the chain's edit, and the restore took both back. + expect(planned).toMatchObject({ status: 'written', rewrite: { from: '^16', to: TARGET } }); + expect(run.exitCode).toBe(1); + expect(run.stdout).toContain('every one was restored to its previous bytes'); + expect(snapshot(dir)).toEqual({ 'objectstack.config.ts': SOURCE }); + }, RUN_TIMEOUT); + + it('a range edit that does not land is caught by the re-check, and everything is restored', async () => { + const dir = writeProject({ 'objectstack.config.ts': SOURCE }); + hooks.afterPlan = (plan) => { + // The file the plan writes, with the range put back as it was — and only the range. + const rewrite = plan.rewrites[0]!; + rewrite.after = edit(rewrite.after, `protocol: '${TARGET}'`, "protocol: '^16'"); + }; + let run: Run; + try { + run = await runMeta(dir, MEMBER.from, ['--write']); + } finally { + hooks.afterPlan = undefined; + } + expect(run.exitCode).toBe(1); + expect(run.stdout).toContain('still converted: manifest.engines.protocol (declared protocol range)'); + expect(snapshot(dir)).toEqual({ 'objectstack.config.ts': SOURCE }); + }, RUN_TIMEOUT); + + it('the planner refuses a range literal it cannot prove, and writes the chain\'s edits beside it', async () => { + const source = "const ENGINES = { protocol: '^16' };\n" + + 'export const other = ENGINES;\n' + + "export default { manifest: { id: 'x', engines: ENGINES }, datasources: [{ name: 'd', driver: 'mongo' }] };\n"; + const configPath = join(writeProject({ 'objectstack.config.ts': source }), 'objectstack.config.ts'); + const loaded = { manifest: { id: 'x', engines: { protocol: '^16' } }, datasources: [{ name: 'd', driver: 'mongo' }] }; + const plan = await planAuthoredSourceWrite({ + configPath, + config: loaded, + namedExports: [], + normalized: loaded, + migrated: { ...loaded, datasources: [{ name: 'd', driver: 'mongodb' }] }, + applied: [{ toMajor: 17, conversionId: 'probe-driver', surface: 'probe', from: 'mongo', to: 'mongodb', path: 'datasources[0].driver' }], + range: { path: 'manifest.engines.protocol', from: '^16', to: '^17', major: 17 }, + }); + expect(plan.range).toMatchObject({ status: 'manual', refusal: { kind: 'shared' } }); + expect(plan.written.map((w) => w.application.conversionId)).toEqual(['probe-driver']); + expect(plan.manual).toEqual([]); + expect(plan.rewrites[0]!.after).toBe(edit(source, "driver: 'mongo'", "driver: 'mongodb'")); + + // The re-check expects a range left by hand to still be owed, and nothing else. + const owed = { path: 'manifest.engines.protocol', from: '^16', to: '^17', major: 17 }; + expect(verifyAuthoredSourceWrite(plan, [], owed).ok).toBe(true); + expect(verifyAuthoredSourceWrite(plan, [])).toEqual({ + ok: false, + stillApplied: [], + vanished: ['manifest.engines.protocol (declared protocol range)'], + }); + }); +});