Skip to content

Commit 7c484d8

Browse files
hotlongclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 9d89a9c commit 7c484d8

2 files changed

Lines changed: 294 additions & 0 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os migrate meta --from N` prints the schema verdict and the refusals first, then the applied mechanical edits, then the manual changes
6+
7+
Clause-②: no
8+
9+
`os migrate meta --from N` prints every semantic entry of every protocol major the
10+
chain crosses, whatever the stack uses: a `--from 17` run prints 242 manual changes.
11+
Those used to come before the schema verdict, which was the last line of the report
12+
and said "resolve the manual changes above". The refusals that keep the migrated
13+
stack from parsing were not listed at all. The only refusal list was the one printed
14+
while the config loaded, and that list names the stack as authored, including the
15+
keys the chain goes on to convert.
16+
17+
The human-readable report now prints three groups, each opened by one header line
18+
that counts it:
19+
20+
1. the verdict: `Migrated stack is schema-valid`, or
21+
`Migrated stack does not yet pass schema validation — N refusals left after the chain`
22+
followed by one `✗ path: message` line per refusal of the migrated stack;
23+
2. `Applied N mechanical change(s):`;
24+
3. `N manual change(s) require your judgment:`.
25+
26+
No manual change is dropped, merged or reworded. Every applied edit and every
27+
manual change prints byte for byte as before, in the same order within its group.
28+
The data-migration advice is still the last thing printed. A range that holds no
29+
migration step also leads with the verdict, which now lists the source's refusals,
30+
and the note naming the range to use follows it.
31+
32+
`--json` is unchanged: same keys, same values, same array order. The exit code is
33+
unchanged too: a run whose migrated stack does not parse still exits 0.
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os migrate meta` — the human report leads with what blocks the stack.
5+
*
6+
* The chain hands the printer every semantic entry of every hop it crosses,
7+
* whatever the stack holds, so the semantic group is the whole catalogue of
8+
* each major crossed — hundreds of notices. The report therefore prints three
9+
* groups in the order an upgrader acts on them, each under one header line that
10+
* counts it:
11+
*
12+
* ① the verdict, and every schema refusal left after the chain;
13+
* ② the applied mechanical edits;
14+
* ③ the semantic notices.
15+
*
16+
* ## Two kinds of pin, kept apart on purpose
17+
*
18+
* - ORDER: ① is the first thing the report prints, then ②, then ③. These are
19+
* the pins that go red when the verdict is moved back behind the catalogue.
20+
* - SET: every applied edit and every semantic notice is printed exactly once,
21+
* byte-identical to what the chain produced and in chain order, and every
22+
* refusal the parse produced is listed. ADR-0087 D3 is "never silence": the
23+
* groups may move, a notice may never be dropped, merged or reworded. These
24+
* pins do not read positions across groups, so a reorder alone cannot turn
25+
* them red — and a dropped notice cannot hide behind a correct order.
26+
*
27+
* ## Why in-process, over a real chain run
28+
*
29+
* The inputs are not fabricated: the stack below is replayed through the real
30+
* `applyMetaMigrations` and parsed by the real `ObjectStackDefinitionSchema`,
31+
* exactly as the command does, and `printMigrationReport` is the function the
32+
* command's text face calls with them. Spawning the CLI would add a process
33+
* and a config load and pin nothing more about the order.
34+
*/
35+
36+
import { stripVTControlCharacters } from 'node:util';
37+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
38+
import {
39+
ObjectStackDefinitionSchema,
40+
applyMetaMigrations,
41+
formatZodIssue,
42+
normalizeStackInput,
43+
MIGRATION_MAJORS,
44+
MIGRATION_SUPPORT_FLOOR,
45+
type MigrationChainResult,
46+
} from '@objectstack/spec';
47+
import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
48+
import { printMigrationReport, type MigrationReport } from './meta.js';
49+
50+
/** Where the chain ends by default — derived, so the file survives the next major. */
51+
const TERMINUS = Math.max(PROTOCOL_MAJOR, ...MIGRATION_MAJORS);
52+
53+
/**
54+
* A stack with all three groups populated: two refusals no conversion repairs
55+
* (an unknown field type, a `scale` on a currency field), and retired spellings
56+
* the chain rewrites (`dashboard.refreshInterval`, `action.aria`).
57+
*/
58+
const FINDINGS_STACK = {
59+
manifest: { id: 'com.example.report-order', name: 'Report Order', version: '1.0.0', type: 'app' },
60+
objects: [{
61+
name: 'ro_deal',
62+
label: 'Deal',
63+
fields: {
64+
title: { type: 'text', label: 'Title' },
65+
stage: { type: 'dropdown', label: 'Stage' },
66+
amount: { type: 'currency', label: 'Amount', scale: 2 },
67+
},
68+
}],
69+
actions: [{
70+
name: 'ro_close',
71+
label: 'Close',
72+
type: 'script',
73+
target: 'closeHandler',
74+
aria: { ariaLabel: 'Close the deal' },
75+
}],
76+
dashboards: [
77+
{ name: 'kpi_a', label: 'KPI A', widgets: [], refreshInterval: 300 },
78+
{ name: 'kpi_b', label: 'KPI B', widgets: [], refreshInterval: 60 },
79+
],
80+
};
81+
82+
/** The same app, already canonical: the verdict leads here too. */
83+
const CANONICAL_STACK = {
84+
manifest: { id: 'com.example.report-order-canon', name: 'Report Order Canon', version: '1.0.0', type: 'app' },
85+
objects: [{ name: 'ro_thing', label: 'Thing', fields: { title: { type: 'text', label: 'Title' } } }],
86+
};
87+
88+
interface Run {
89+
report: MigrationReport;
90+
result: MigrationChainResult;
91+
lines: string[];
92+
}
93+
94+
let printed: string[] = [];
95+
96+
beforeEach(() => {
97+
printed = [];
98+
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
99+
printed.push(args.map(String).join(' '));
100+
});
101+
});
102+
103+
afterEach(() => {
104+
vi.restoreAllMocks();
105+
});
106+
107+
/** Replay the chain the way the command does, print the report, return its lines. */
108+
function run(stack: Record<string, unknown>, fromMajor: number, toMajor: number): Run {
109+
const normalized = normalizeStackInput(stack, { convert: false });
110+
const result = applyMetaMigrations(normalized, fromMajor, toMajor);
111+
const parsed = ObjectStackDefinitionSchema.safeParse(result.stack);
112+
const report: MigrationReport = {
113+
result,
114+
normalized,
115+
schemaValid: parsed.success,
116+
refusals: parsed.success ? [] : parsed.error.issues,
117+
dataMigrations: [],
118+
step: false,
119+
elapsed: '1ms',
120+
};
121+
printMigrationReport(report);
122+
// One console.log call may carry embedded newlines (a multi-paragraph
123+
// `reason`), so the lines are the terminal's, not the calls'.
124+
const lines = stripVTControlCharacters(printed.join('\n')).split('\n');
125+
return { report, result, lines };
126+
}
127+
128+
const VERDICT_RE = /^ {2}[✓⚠] (?:Migrated stack|Stack does not)/;
129+
const APPLIED_HEADER_RE = /^ {2}Applied (\d+) mechanical change\(s\):$/;
130+
const SEMANTIC_HEADER_RE = /^ {2}(\d+) manual change\(s\) require your judgment:$/;
131+
132+
function indexOf(lines: string[], re: RegExp): number {
133+
return lines.findIndex((l) => re.test(l));
134+
}
135+
136+
/** The lines an applied edit prints — written from the chain's data, not from the printer. */
137+
function appliedLines(result: MigrationChainResult): string[] {
138+
return result.applied.map((a) => ` • ${a.path}: ${a.from} → ${a.to} (${a.conversionId})`);
139+
}
140+
141+
/** The lines a semantic notice prints — every field, split the way a terminal splits it. */
142+
function noticeLines(result: MigrationChainResult): string[] {
143+
return result.todos.flatMap((t) =>
144+
[
145+
` ⚠ [protocol ${t.toMajor}] ${t.surface} → ${t.replacement}`,
146+
` why: ${t.reason}`,
147+
` verify: ${t.acceptanceCriteria}`,
148+
].join('\n').split('\n'),
149+
);
150+
}
151+
152+
/** The lines the refusal group prints — one `formatZodIssue` render per refusal. */
153+
function refusalLines(report: MigrationReport): string[] {
154+
return report.refusals.flatMap((r) => formatZodIssue(r).split('\n').map((l) => ` ${l}`));
155+
}
156+
157+
/** The lines strictly between two indices that are not blank. */
158+
function between(lines: string[], from: number, to: number): string[] {
159+
return lines.slice(from + 1, to).filter((l) => l.trim() !== '');
160+
}
161+
162+
describe('the report leads with the verdict and the refusals (ORDER)', () => {
163+
it('prints ① the verdict and refusals, then ② the applied edits, then ③ the notices', () => {
164+
const { report, lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS);
165+
// Anti-vacuity: all three groups have members, so no index below can be
166+
// satisfied by a group that is simply absent.
167+
expect(report.schemaValid).toBe(false);
168+
expect(report.refusals.length).toBeGreaterThan(0);
169+
expect(report.result.applied.length).toBeGreaterThan(0);
170+
expect(report.result.todos.length).toBeGreaterThan(0);
171+
172+
const verdict = indexOf(lines, VERDICT_RE);
173+
const applied = indexOf(lines, APPLIED_HEADER_RE);
174+
const semantic = indexOf(lines, SEMANTIC_HEADER_RE);
175+
expect(verdict, 'the verdict is the first line the report prints').toBe(0);
176+
expect(applied).toBeGreaterThan(verdict);
177+
expect(semantic).toBeGreaterThan(applied);
178+
// The refusals sit under the verdict and nowhere else: nothing but them
179+
// between the verdict line and the applied-edit header.
180+
expect(between(lines, verdict, applied)).toEqual(refusalLines(report));
181+
});
182+
183+
it('leads with the verdict on a schema-valid stack too', () => {
184+
const { report, lines } = run(CANONICAL_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS);
185+
expect(report.schemaValid).toBe(true);
186+
expect(report.result.todos.length).toBeGreaterThan(0);
187+
expect(lines[0]).toBe(' ✓ Migrated stack is schema-valid (1ms)');
188+
expect(indexOf(lines, SEMANTIC_HEADER_RE)).toBeGreaterThan(0);
189+
});
190+
191+
it('leads with the verdict on a range that holds no step, above the range answer', () => {
192+
const { report, lines } = run(FINDINGS_STACK, TERMINUS, TERMINUS);
193+
expect(report.result.hops).toHaveLength(0);
194+
expect(report.schemaValid).toBe(false);
195+
const verdict = indexOf(lines, VERDICT_RE);
196+
const rangeAnswer = indexOf(lines, /No migration step exists for protocol/);
197+
expect(verdict).toBe(0);
198+
expect(lines[verdict]).toContain('this run replayed no conversion');
199+
expect(rangeAnswer).toBeGreaterThan(verdict);
200+
});
201+
});
202+
203+
describe('each group opens with one header line that counts it', () => {
204+
it('counts the refusals, the applied edits and the notices', () => {
205+
const { report, result, lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS);
206+
const n = report.refusals.length;
207+
expect(lines.filter((l) => VERDICT_RE.test(l))).toEqual([
208+
` ⚠ Migrated stack does not yet pass schema validation — ${n} refusal${n === 1 ? '' : 's'} `
209+
+ 'left after the chain. Resolve them, then run `os validate`:',
210+
]);
211+
expect(lines.filter((l) => APPLIED_HEADER_RE.test(l))).toEqual([
212+
` Applied ${result.applied.length} mechanical change(s):`,
213+
]);
214+
expect(lines.filter((l) => SEMANTIC_HEADER_RE.test(l))).toEqual([
215+
` ${result.todos.length} manual change(s) require your judgment:`,
216+
]);
217+
});
218+
});
219+
220+
describe('no notice, edit or refusal is dropped, merged or reworded (SET)', () => {
221+
it('prints every applied edit exactly once, in chain order', () => {
222+
const { result, lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS);
223+
const expected = appliedLines(result);
224+
expect(lines.filter((l) => l.startsWith(' • '))).toEqual(expected);
225+
});
226+
227+
it('prints every semantic notice of every hop crossed, byte-identical and in chain order', () => {
228+
const { result, lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS);
229+
const expected = noticeLines(result);
230+
// Anti-vacuity: more than one hop's catalogue, and at least one notice
231+
// whose prose spans several terminal lines.
232+
expect(new Set(result.todos.map((t) => t.toMajor)).size).toBeGreaterThan(1);
233+
expect(expected.length).toBeGreaterThan(result.todos.length * 3);
234+
const header = indexOf(lines, SEMANTIC_HEADER_RE);
235+
const printedNotices = lines.slice(header + 1, header + 1 + expected.length);
236+
expect(printedNotices).toEqual(expected);
237+
// …and not a second time anywhere else.
238+
const firstNotice = expected[0]!;
239+
expect(lines.filter((l) => l === firstNotice)).toHaveLength(
240+
expected.filter((l) => l === firstNotice).length,
241+
);
242+
});
243+
244+
it('lists every refusal the parse of the migrated stack produced', () => {
245+
const { report, lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS);
246+
const expected = refusalLines(report);
247+
for (const line of expected) expect(lines, `refusal missing: ${line}`).toContain(line);
248+
expect(lines.filter((l) => l.startsWith(' ✗ '))).toEqual(expected.filter((l) => l.startsWith(' ✗ ')));
249+
});
250+
251+
it('prints nothing but the groups: every non-blank line is a header, an item or a refusal', () => {
252+
const { report, result, lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS);
253+
const accounted = [
254+
...lines.filter((l) => VERDICT_RE.test(l) || APPLIED_HEADER_RE.test(l) || SEMANTIC_HEADER_RE.test(l)),
255+
...refusalLines(report),
256+
...appliedLines(result),
257+
...noticeLines(result),
258+
].filter((l) => l.trim() !== ''); // a multi-paragraph `reason` carries blank lines of its own
259+
expect(lines.filter((l) => l.trim() !== '').sort()).toEqual(accounted.sort());
260+
});
261+
});

0 commit comments

Comments
 (0)