From 9d89a9c0720992507f768832bc1ae4f1aba08d9c Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:21:26 +0800 Subject: [PATCH 1/2] fix(cli): os migrate meta leads with the verdict and the refusals, then the applied edits, then the semantic notices The authored-source report printed the whole semantic catalogue first and the schema verdict last, and never listed the refusals that keep the migrated stack from parsing. The human report is now one function that prints three groups, each under one header line counting it: the verdict with every refusal left after the chain, the applied mechanical edits, then every semantic notice. No notice is dropped or reworded, and the --json payload is unchanged. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude --- packages/cli/src/commands/migrate/meta.ts | 246 +++++++++++++++------- 1 file changed, 168 insertions(+), 78 deletions(-) diff --git a/packages/cli/src/commands/migrate/meta.ts b/packages/cli/src/commands/migrate/meta.ts index e80aee74e3e..1a002a9b489 100644 --- a/packages/cli/src/commands/migrate/meta.ts +++ b/packages/cli/src/commands/migrate/meta.ts @@ -9,10 +9,12 @@ import { ObjectStackDefinitionSchema, applyMetaMigrations, composeSpecChanges, + formatZodIssue, normalizeStackInput, MigrationFloorError, MIGRATION_MAJORS, MIGRATION_SUPPORT_FLOOR, + type MigrationChainResult, } from '@objectstack/spec'; import { PROTOCOL_MAJOR, PROTOCOL_VERSION } from '@objectstack/spec/kernel'; import { FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, STRUCTURED_JSON_TYPES } from '@objectstack/spec/data'; @@ -107,7 +109,7 @@ export function storedOnlyFlagsIn(argv: readonly string[]): string[] { return typed; } -interface PendingDataMigration { +export interface PendingDataMigration { /** `sys_migration` row id the run records. */ id: string; command: string; @@ -223,7 +225,7 @@ function printEmptyRangeAnswer( } /** Print the data-migration advice — the last thing a crossing upgrade sees. */ -function printPendingDataMigrations(pending: PendingDataMigration[]): void { +function printPendingDataMigrations(pending: readonly PendingDataMigration[]): void { if (pending.length === 0) return; console.log(chalk.bold(' Then, against each deployment\'s database:')); for (const m of pending) { @@ -239,6 +241,157 @@ function printPendingDataMigrations(pending: PendingDataMigration[]): void { console.log(''); } +/** One schema refusal of the migrated stack, in the shape `formatZodIssue` renders. */ +export type MigrationRefusal = Parameters[0]; + +/** 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. */ + result: MigrationChainResult; + /** The stack the chain started from — the empty-range answer replays a wider chain over it. */ + normalized: Record; + /** Whether the migrated stack parses under the installed schema (`--json`'s `schemaValid`). */ + schemaValid: boolean; + /** The migrated stack's schema refusals, in parse order; empty when it parses. */ + refusals: readonly MigrationRefusal[]; + dataMigrations: readonly PendingDataMigration[]; + /** `--step`: a checkpoint per hop, between the applied edits and the semantic notices. */ + step: boolean; + /** `--out`, resolved — the snapshot is written here so its line keeps its place. */ + out?: string; + /** Printed beside a schema-valid verdict. */ + elapsed: string; +} + +/** + * Group ① — the verdict, and every refusal that blocks the migrated stack. + * + * One header line carries the verdict and counts the group. The refusals are + * the MIGRATED stack's, read off the same parse `--json` reports as + * `schemaValid`, and rendered by `formatZodIssue` — the renderer behind the + * loader's own `defineX() validation failed` block, so a refusal reads the same + * here as everywhere else. They are not the loader's list: that one is printed + * while the config is evaluated, of the stack as AUTHORED, and still names every + * key the chain goes on to convert. + */ +function printSchemaVerdict(report: MigrationReport): void { + if (report.schemaValid) { + printSuccess(`Migrated stack is schema-valid ${chalk.dim(`(${report.elapsed})`)}`); + console.log(''); + return; + } + const count = report.refusals.length; + const counted = `${count} refusal${count === 1 ? '' : 's'}`; + printWarning( + report.result.hops.length === 0 + // The range held no step, so this run rewrote nothing: the refusals are + // exactly the ones the source had before it (#17134). + ? 'Stack does not pass schema validation, and this run replayed no conversion — nothing here ' + + `has been fixed; ${counted}, exactly as the source has them:` + : `Migrated stack does not yet pass schema validation — ${counted} left after the chain. ` + + 'Resolve them, then run `os validate`:', + ); + for (const refusal of report.refusals) { + for (const line of formatZodIssue(refusal).split('\n')) console.log(chalk.red(` ${line}`)); + } + console.log(''); +} + +/** + * The human report of an authored-source run, in the order an upgrader acts on + * it, each group under one header line that counts it (ADR-0087 D3): + * + * ① the VERDICT and the REFUSALS — whether the migrated stack parses and, when + * it does not, every refusal left after the chain: what still blocks it; + * ② the APPLIED mechanical edits — the diff the chain has already made; + * ③ the SEMANTIC notices — every semantic entry of every hop crossed. + * + * ## Why this order + * + * The chain hands the printer every semantic entry of every hop it crosses, + * whatever the stack holds — `SemanticMigration` carries no predicate over the + * stack — so ③ is the whole catalogue of each major crossed, 242 notices for + * protocol 18. It used to be printed first and the verdict last, where it told + * the author to "resolve the manual changes above"; and the refusals that block + * the stack were printed nowhere. Measured on a real upgrade: 874 lines, whose + * 41 refusals were buried under 240 notices about surfaces the stack never used. + * + * ## What it must not do + * + * ⛔ Drop, filter, collapse or summarise a notice. ADR-0087 D3 is "never + * silence": an entry may leave ③ only on a structured, stack-derived proof that + * its surface is absent, and matching the prose of `surface` against the stack + * is not one. So the groups MOVE and nothing else does: every line ② and ③ + * printed before is printed after, byte-identical and in chain order. + * `--json` is untouched — its keys, its values and the order of its arrays. + */ +export function printMigrationReport(report: MigrationReport): void { + const { result } = report; + + // ① The verdict and the refusals — first, whatever else the run found. + printSchemaVerdict(report); + + if (result.applied.length === 0 && result.todos.length === 0) { + // ⚠️ Two different facts wear the same empty result, and only one of them + // is good news (#17134). A range that CONTAINS steps and rewrote nothing + // is a finding about the metadata. A range that contains no step at all + // replayed nothing and therefore found nothing — saying "already + // canonical" over it is a claim about a check that never ran. + if (result.hops.length === 0) { + printEmptyRangeAnswer(report.normalized, result.fromMajor, result.toMajor); + } else { + printSuccess('Nothing to migrate — the metadata is already canonical for this range.'); + } + // Still advertise: metadata needing no rewrite says nothing about whether + // this deployment's DATA has been migrated. + console.log(''); + 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 + // returning past it was the second half of #17134 — on a stack authoring a + // tombstoned key, `--json` reported `schemaValid: false` while the human + // output said the metadata was canonical and stopped. + return; + } + + // ② The mechanical rewrites (auto-applied). + if (result.applied.length > 0) { + console.log(chalk.bold(` Applied ${result.applied.length} mechanical change(s):`)); + for (const a of result.applied) { + console.log(` • ${a.path}: ${chalk.red(a.from)} → ${chalk.green(a.to)} ${chalk.dim(`(${a.conversionId})`)}`); + } + console.log(''); + } + + // Per-hop checkpoints. + if (report.step) { + for (const hop of result.hops) { + console.log(chalk.bold(` ── protocol ${hop.toMajor} ──`)); + console.log(chalk.dim(` ${hop.rationale}`)); + console.log(chalk.dim(` ${hop.applied.length} mechanical, ${hop.todos.length} manual`)); + } + console.log(''); + } + + // ③ The semantic TODOs (delegated to the agent — never auto-applied). + if (result.todos.length > 0) { + console.log(chalk.bold(chalk.yellow(` ${result.todos.length} manual change(s) require your judgment:`))); + for (const t of result.todos) { + console.log(` ${chalk.yellow('⚠')} [protocol ${t.toMajor}] ${t.surface} → ${t.replacement}`); + console.log(chalk.dim(` why: ${t.reason}`)); + console.log(chalk.dim(` verify: ${t.acceptanceCriteria}`)); + } + console.log(''); + } + + if (report.out) { + writeFileSync(report.out, JSON.stringify(result.stack, null, 2)); + printInfo(`Wrote migrated stack snapshot → ${chalk.white(report.out)}`); + } + + printPendingDataMigrations(report.dataMigrations); +} + /** * `os migrate meta --from N` — replay the ADR-0087 D3 migration chain. * @@ -476,82 +629,19 @@ export default class MigrateMeta extends Command { printInfo(`Chain: protocol ${fromMajor} → ${toMajor} (this runtime implements protocol ${PROTOCOL_MAJOR})`); console.log(''); - if (result.applied.length === 0 && result.todos.length === 0) { - // ⚠️ Two different facts wear the same empty result, and only one of - // them is good news (#17134). A range that CONTAINS steps and rewrote - // nothing is a finding about the metadata. A range that contains no - // step at all replayed nothing and therefore found nothing — saying - // "already canonical" over it is a claim about a check that never ran. - if (result.hops.length === 0) { - printEmptyRangeAnswer(normalized, fromMajor, toMajor); - } else { - printSuccess('Nothing to migrate — the metadata is already canonical for this range.'); - } - // Still advertise: metadata needing no rewrite says nothing about - // whether this deployment's DATA has been migrated. - console.log(''); - printPendingDataMigrations(dataMigrations); - // ⛔ NOT a `return`. The schema verdict at the end of this block is the - // only line that can contradict a "nothing to do" answer, and returning - // past it was the second half of #17134: on a stack authoring a - // tombstoned key the same run reported `schemaValid: false` in `--json` - // while the human output said the metadata was canonical and stopped. - } else { - // Mechanical rewrites (auto-applied). - if (result.applied.length > 0) { - console.log(chalk.bold(` Applied ${result.applied.length} mechanical change(s):`)); - for (const a of result.applied) { - console.log(` • ${a.path}: ${chalk.red(a.from)} → ${chalk.green(a.to)} ${chalk.dim(`(${a.conversionId})`)}`); - } - console.log(''); - } - - // Per-hop checkpoints. - if (flags.step) { - for (const hop of result.hops) { - console.log(chalk.bold(` ── protocol ${hop.toMajor} ──`)); - console.log(chalk.dim(` ${hop.rationale}`)); - console.log(chalk.dim(` ${hop.applied.length} mechanical, ${hop.todos.length} manual`)); - } - console.log(''); - } - - // Semantic TODOs (delegated to the agent — never auto-applied). - if (result.todos.length > 0) { - console.log(chalk.bold(chalk.yellow(` ${result.todos.length} manual change(s) require your judgment:`))); - for (const t of result.todos) { - console.log(` ${chalk.yellow('⚠')} [protocol ${t.toMajor}] ${t.surface} → ${t.replacement}`); - console.log(chalk.dim(` why: ${t.reason}`)); - console.log(chalk.dim(` verify: ${t.acceptanceCriteria}`)); - } - console.log(''); - } - - if (flags.out) { - writeFileSync(resolve(flags.out), JSON.stringify(result.stack, null, 2)); - printInfo(`Wrote migrated stack snapshot → ${chalk.white(resolve(flags.out))}`); - } - - printPendingDataMigrations(dataMigrations); - } - - if (parsed.success) { - printSuccess(`Migrated stack is schema-valid ${chalk.dim(`(${timer.display()})`)}`); - } else if (result.hops.length === 0) { - // "Resolve the changes above" has nothing to point at when the range - // held no step: this run rewrote nothing, so the refusals are exactly - // the ones the source had before it (#17134). - printWarning( - 'Stack does not pass schema validation, and this run replayed no conversion — nothing ' - + 'here has been fixed. Widen the range above, or run `os validate` for the refusals.', - ); - } else { - printWarning( - 'Migrated stack does not yet pass schema validation — resolve the manual changes above, ' + - 'then run `os validate`.', - ); - } - console.log(''); + // The verdict and the refusals lead, then the applied edits, then the + // semantic notices — see printMigrationReport for why, and for what it + // must never do to a notice. + printMigrationReport({ + result, + normalized, + schemaValid: parsed.success, + refusals: parsed.success ? [] : parsed.error.issues, + dataMigrations, + step: flags.step, + ...(flags.out ? { out: resolve(flags.out) } : {}), + elapsed: timer.display(), + }); } catch (error: any) { if (error instanceof MigrationFloorError) { if (flags.json) { From 7c484d891fbcc4740b64f7e821a96e8633b6293d Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:26:53 +0800 Subject: [PATCH 2/2] test(cli): pin the migrate meta report order apart from its completeness, and add the changeset Order pins: the verdict and its refusals are the first lines, then the applied-edit header, then the manual-change header, and nothing but the refusals sits between the verdict and the applied edits. Completeness pins: every applied edit and every semantic notice of a real two-hop chain run prints exactly once, byte-identical and in chain order; every refusal of the migrated stack is listed; nothing else is printed. Neither kind reads the other's positions, so a reorder alone cannot redden the completeness pins. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude --- .../20620-migrate-meta-verdict-first.md | 33 +++ .../migrate/meta.report-order.test.ts | 261 ++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 .changeset/20620-migrate-meta-verdict-first.md create mode 100644 packages/cli/src/commands/migrate/meta.report-order.test.ts diff --git a/.changeset/20620-migrate-meta-verdict-first.md b/.changeset/20620-migrate-meta-verdict-first.md new file mode 100644 index 00000000000..72cd5412d80 --- /dev/null +++ b/.changeset/20620-migrate-meta-verdict-first.md @@ -0,0 +1,33 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): `os migrate meta --from N` prints the schema verdict and the refusals first, then the applied mechanical edits, then the manual changes + +Clause-②: no + +`os migrate meta --from N` prints every semantic entry of every protocol major the +chain crosses, whatever the stack uses: a `--from 17` run prints 242 manual changes. +Those used to come before the schema verdict, which was the last line of the report +and said "resolve the manual changes above". The refusals that keep the migrated +stack from parsing were not listed at all. The only refusal list was the one printed +while the config loaded, and that list names the stack as authored, including the +keys the chain goes on to convert. + +The human-readable report now prints three groups, each opened by one header line +that counts it: + +1. the verdict: `Migrated stack is schema-valid`, or + `Migrated stack does not yet pass schema validation — N refusals left after the chain` + followed by one `✗ path: message` line per refusal of the migrated stack; +2. `Applied N mechanical change(s):`; +3. `N manual change(s) require your judgment:`. + +No manual change is dropped, merged or reworded. Every applied edit and every +manual change prints byte for byte as before, in the same order within its group. +The data-migration advice is still the last thing printed. A range that holds no +migration step also leads with the verdict, which now lists the source's refusals, +and the note naming the range to use follows it. + +`--json` is unchanged: same keys, same values, same array order. The exit code is +unchanged too: a run whose migrated stack does not parse still exits 0. diff --git a/packages/cli/src/commands/migrate/meta.report-order.test.ts b/packages/cli/src/commands/migrate/meta.report-order.test.ts new file mode 100644 index 00000000000..ded4942ff55 --- /dev/null +++ b/packages/cli/src/commands/migrate/meta.report-order.test.ts @@ -0,0 +1,261 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os migrate meta` — the human report leads with what blocks the stack. + * + * The chain hands the printer every semantic entry of every hop it crosses, + * whatever the stack holds, so the semantic group is the whole catalogue of + * each major crossed — hundreds of notices. The report therefore prints three + * groups in the order an upgrader acts on them, each under one header line that + * counts it: + * + * ① the verdict, and every schema refusal left after the chain; + * ② the applied mechanical edits; + * ③ the semantic notices. + * + * ## Two kinds of pin, kept apart on purpose + * + * - ORDER: ① is the first thing the report prints, then ②, then ③. These are + * the pins that go red when the verdict is moved back behind the catalogue. + * - SET: every applied edit and every semantic notice is printed exactly once, + * byte-identical to what the chain produced and in chain order, and every + * refusal the parse produced is listed. ADR-0087 D3 is "never silence": the + * groups may move, a notice may never be dropped, merged or reworded. These + * pins do not read positions across groups, so a reorder alone cannot turn + * them red — and a dropped notice cannot hide behind a correct order. + * + * ## Why in-process, over a real chain run + * + * The inputs are not fabricated: the stack below is replayed through the real + * `applyMetaMigrations` and parsed by the real `ObjectStackDefinitionSchema`, + * exactly as the command does, and `printMigrationReport` is the function the + * command's text face calls with them. Spawning the CLI would add a process + * and a config load and pin nothing more about the order. + */ + +import { stripVTControlCharacters } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ObjectStackDefinitionSchema, + applyMetaMigrations, + formatZodIssue, + normalizeStackInput, + MIGRATION_MAJORS, + MIGRATION_SUPPORT_FLOOR, + type MigrationChainResult, +} from '@objectstack/spec'; +import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; +import { printMigrationReport, type MigrationReport } from './meta.js'; + +/** Where the chain ends by default — derived, so the file survives the next major. */ +const TERMINUS = Math.max(PROTOCOL_MAJOR, ...MIGRATION_MAJORS); + +/** + * A stack with all three groups populated: two refusals no conversion repairs + * (an unknown field type, a `scale` on a currency field), and retired spellings + * the chain rewrites (`dashboard.refreshInterval`, `action.aria`). + */ +const FINDINGS_STACK = { + manifest: { id: 'com.example.report-order', name: 'Report Order', version: '1.0.0', type: 'app' }, + objects: [{ + name: 'ro_deal', + label: 'Deal', + fields: { + title: { type: 'text', label: 'Title' }, + stage: { type: 'dropdown', label: 'Stage' }, + amount: { type: 'currency', label: 'Amount', scale: 2 }, + }, + }], + actions: [{ + name: 'ro_close', + label: 'Close', + type: 'script', + target: 'closeHandler', + aria: { ariaLabel: 'Close the deal' }, + }], + dashboards: [ + { name: 'kpi_a', label: 'KPI A', widgets: [], refreshInterval: 300 }, + { name: 'kpi_b', label: 'KPI B', widgets: [], refreshInterval: 60 }, + ], +}; + +/** The same app, already canonical: the verdict leads here too. */ +const CANONICAL_STACK = { + manifest: { id: 'com.example.report-order-canon', name: 'Report Order Canon', version: '1.0.0', type: 'app' }, + objects: [{ name: 'ro_thing', label: 'Thing', fields: { title: { type: 'text', label: 'Title' } } }], +}; + +interface Run { + report: MigrationReport; + result: MigrationChainResult; + lines: string[]; +} + +let printed: string[] = []; + +beforeEach(() => { + printed = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + printed.push(args.map(String).join(' ')); + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +/** Replay the chain the way the command does, print the report, return its lines. */ +function run(stack: Record, fromMajor: number, toMajor: number): Run { + const normalized = normalizeStackInput(stack, { convert: false }); + const result = applyMetaMigrations(normalized, fromMajor, toMajor); + const parsed = ObjectStackDefinitionSchema.safeParse(result.stack); + const report: MigrationReport = { + result, + normalized, + schemaValid: parsed.success, + refusals: parsed.success ? [] : parsed.error.issues, + dataMigrations: [], + step: false, + elapsed: '1ms', + }; + printMigrationReport(report); + // One console.log call may carry embedded newlines (a multi-paragraph + // `reason`), so the lines are the terminal's, not the calls'. + const lines = stripVTControlCharacters(printed.join('\n')).split('\n'); + return { report, result, lines }; +} + +const VERDICT_RE = /^ {2}[✓⚠] (?:Migrated stack|Stack does not)/; +const APPLIED_HEADER_RE = /^ {2}Applied (\d+) mechanical change\(s\):$/; +const SEMANTIC_HEADER_RE = /^ {2}(\d+) manual change\(s\) require your judgment:$/; + +function indexOf(lines: string[], re: RegExp): number { + return lines.findIndex((l) => re.test(l)); +} + +/** The lines an applied edit prints — written from the chain's data, not from the printer. */ +function appliedLines(result: MigrationChainResult): string[] { + return result.applied.map((a) => ` • ${a.path}: ${a.from} → ${a.to} (${a.conversionId})`); +} + +/** The lines a semantic notice prints — every field, split the way a terminal splits it. */ +function noticeLines(result: MigrationChainResult): string[] { + return result.todos.flatMap((t) => + [ + ` ⚠ [protocol ${t.toMajor}] ${t.surface} → ${t.replacement}`, + ` why: ${t.reason}`, + ` verify: ${t.acceptanceCriteria}`, + ].join('\n').split('\n'), + ); +} + +/** The lines the refusal group prints — one `formatZodIssue` render per refusal. */ +function refusalLines(report: MigrationReport): string[] { + return report.refusals.flatMap((r) => formatZodIssue(r).split('\n').map((l) => ` ${l}`)); +} + +/** The lines strictly between two indices that are not blank. */ +function between(lines: string[], from: number, to: number): string[] { + return lines.slice(from + 1, to).filter((l) => l.trim() !== ''); +} + +describe('the report leads with the verdict and the refusals (ORDER)', () => { + it('prints ① the verdict and refusals, then ② the applied edits, then ③ the notices', () => { + const { report, lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS); + // Anti-vacuity: all three groups have members, so no index below can be + // satisfied by a group that is simply absent. + expect(report.schemaValid).toBe(false); + expect(report.refusals.length).toBeGreaterThan(0); + expect(report.result.applied.length).toBeGreaterThan(0); + expect(report.result.todos.length).toBeGreaterThan(0); + + const verdict = indexOf(lines, VERDICT_RE); + const applied = indexOf(lines, APPLIED_HEADER_RE); + const semantic = indexOf(lines, SEMANTIC_HEADER_RE); + expect(verdict, 'the verdict is the first line the report prints').toBe(0); + expect(applied).toBeGreaterThan(verdict); + expect(semantic).toBeGreaterThan(applied); + // The refusals sit under the verdict and nowhere else: nothing but them + // between the verdict line and the applied-edit header. + expect(between(lines, verdict, applied)).toEqual(refusalLines(report)); + }); + + it('leads with the verdict on a schema-valid stack too', () => { + const { report, lines } = run(CANONICAL_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS); + expect(report.schemaValid).toBe(true); + expect(report.result.todos.length).toBeGreaterThan(0); + expect(lines[0]).toBe(' ✓ Migrated stack is schema-valid (1ms)'); + expect(indexOf(lines, SEMANTIC_HEADER_RE)).toBeGreaterThan(0); + }); + + it('leads with the verdict on a range that holds no step, above the range answer', () => { + const { report, lines } = run(FINDINGS_STACK, TERMINUS, TERMINUS); + expect(report.result.hops).toHaveLength(0); + expect(report.schemaValid).toBe(false); + const verdict = indexOf(lines, VERDICT_RE); + const rangeAnswer = indexOf(lines, /No migration step exists for protocol/); + expect(verdict).toBe(0); + expect(lines[verdict]).toContain('this run replayed no conversion'); + expect(rangeAnswer).toBeGreaterThan(verdict); + }); +}); + +describe('each group opens with one header line that counts it', () => { + it('counts the refusals, the applied edits and the notices', () => { + const { report, result, lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS); + const n = report.refusals.length; + expect(lines.filter((l) => VERDICT_RE.test(l))).toEqual([ + ` ⚠ Migrated stack does not yet pass schema validation — ${n} refusal${n === 1 ? '' : 's'} ` + + 'left after the chain. Resolve them, then run `os validate`:', + ]); + expect(lines.filter((l) => APPLIED_HEADER_RE.test(l))).toEqual([ + ` Applied ${result.applied.length} mechanical change(s):`, + ]); + expect(lines.filter((l) => SEMANTIC_HEADER_RE.test(l))).toEqual([ + ` ${result.todos.length} manual change(s) require your judgment:`, + ]); + }); +}); + +describe('no notice, edit or refusal is dropped, merged or reworded (SET)', () => { + it('prints every applied edit exactly once, in chain order', () => { + const { result, lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS); + const expected = appliedLines(result); + expect(lines.filter((l) => l.startsWith(' • '))).toEqual(expected); + }); + + it('prints every semantic notice of every hop crossed, byte-identical and in chain order', () => { + const { result, lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS); + const expected = noticeLines(result); + // Anti-vacuity: more than one hop's catalogue, and at least one notice + // whose prose spans several terminal lines. + expect(new Set(result.todos.map((t) => t.toMajor)).size).toBeGreaterThan(1); + expect(expected.length).toBeGreaterThan(result.todos.length * 3); + const header = indexOf(lines, SEMANTIC_HEADER_RE); + const printedNotices = lines.slice(header + 1, header + 1 + expected.length); + expect(printedNotices).toEqual(expected); + // …and not a second time anywhere else. + const firstNotice = expected[0]!; + expect(lines.filter((l) => l === firstNotice)).toHaveLength( + expected.filter((l) => l === firstNotice).length, + ); + }); + + it('lists every refusal the parse of the migrated stack produced', () => { + const { report, lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS); + const expected = refusalLines(report); + for (const line of expected) expect(lines, `refusal missing: ${line}`).toContain(line); + expect(lines.filter((l) => l.startsWith(' ✗ '))).toEqual(expected.filter((l) => l.startsWith(' ✗ '))); + }); + + it('prints nothing but the groups: every non-blank line is a header, an item or a refusal', () => { + const { report, result, lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS); + const accounted = [ + ...lines.filter((l) => VERDICT_RE.test(l) || APPLIED_HEADER_RE.test(l) || SEMANTIC_HEADER_RE.test(l)), + ...refusalLines(report), + ...appliedLines(result), + ...noticeLines(result), + ].filter((l) => l.trim() !== ''); // a multi-paragraph `reason` carries blank lines of its own + expect(lines.filter((l) => l.trim() !== '').sort()).toEqual(accounted.sort()); + }); +});