Skip to content

Commit 3edaae1

Browse files
committed
test(cli): pin the review pairing in os migrate meta's human report
Adds the PAIR pins beside the report-order pins: the judging entry prints once under the run of edits it judges, counting them; an unjudged edit prints as before; group 3 keeps every entry with the chain's count and bytes; every link the registry declares is paired over its conversion's own fixture; and a link to an earlier step's conversion pairs across hops. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
1 parent 19563f6 commit 3edaae1

1 file changed

Lines changed: 241 additions & 5 deletions

File tree

‎packages/cli/src/commands/migrate/meta.report-order.test.ts‎

Lines changed: 241 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,23 +24,37 @@
2424
* pins do not read positions across groups, so a reorder alone cannot turn
2525
* them red — and a dropped notice cannot hide behind a correct order.
2626
*
27+
* ## The pairing pins (PAIR)
28+
*
29+
* Where a semantic entry declares in `conversionIds` that it judges a
30+
* conversion's applied edits, ② prints that entry's headline beside those
31+
* edits, marked review: once under each run of the conversion's edits, counting
32+
* them. It is a copy, never a move: ③ still prints the entry, and ③'s lines and
33+
* count stay the chain's. Declared links only, never prose: the pins enumerate
34+
* the links off the registry and replay each linked conversion's own fixture,
35+
* so a link the spec lane authors later is covered on the day it lands.
36+
*
2737
* ## Why in-process, over a real chain run
2838
*
2939
* The inputs are not fabricated: the stack below is replayed through the real
3040
* `applyMetaMigrations` and parsed by the real `ObjectStackDefinitionSchema`,
3141
* exactly as the command does, and `printMigrationReport` is the function the
3242
* 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.
43+
* and a config load and pin nothing more about the order. One pin adds a link
44+
* to a real result, and says so: the spec allows a link to a conversion an
45+
* EARLIER step replays, and none is authored yet.
3446
*/
3547

3648
import { stripVTControlCharacters } from 'node:util';
3749
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
38-
import { ObjectStackDefinitionSchema, formatZodIssue, normalizeStackInput } from '@objectstack/spec';
50+
import { ALL_CONVERSIONS, ObjectStackDefinitionSchema, formatZodIssue, normalizeStackInput } from '@objectstack/spec';
3951
import {
4052
applyMetaMigrations,
53+
MIGRATIONS_BY_MAJOR,
4154
MIGRATION_MAJORS,
4255
MIGRATION_SUPPORT_FLOOR,
4356
type MigrationChainResult,
57+
type MigrationTodo,
4458
} from '@objectstack/spec/migrations';
4559
import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
4660
import { printMigrationReport, type MigrationReport } from './meta.js';
@@ -102,10 +116,19 @@ afterEach(() => {
102116
vi.restoreAllMocks();
103117
});
104118

105-
/** Replay the chain the way the command does, print the report, return its lines. */
106-
function run(stack: Record<string, unknown>, fromMajor: number, toMajor: number): Run {
119+
/**
120+
* Replay the chain the way the command does, print the report, return its lines.
121+
* `amend` edits the chain's result before it is printed — one pin uses it, to
122+
* add a link no registry entry declares yet.
123+
*/
124+
function run(
125+
stack: Record<string, unknown>,
126+
fromMajor: number,
127+
toMajor: number,
128+
amend: (result: MigrationChainResult) => MigrationChainResult = (r) => r,
129+
): Run {
107130
const normalized = normalizeStackInput(stack, { convert: false });
108-
const result = applyMetaMigrations(normalized, fromMajor, toMajor);
131+
const result = amend(applyMetaMigrations(normalized, fromMajor, toMajor));
109132
const parsed = ObjectStackDefinitionSchema.safeParse(result.stack);
110133
const report: MigrationReport = {
111134
result,
@@ -257,3 +280,216 @@ describe('no notice, edit or refusal is dropped, merged or reworded (SET)', () =
257280
expect(lines.filter((l) => l.trim() !== '').sort()).toEqual(accounted.sort());
258281
});
259282
});
283+
284+
/**
285+
* Two flows, each with an edge-branched decision carrying two conditioned
286+
* out-edges and no `mode` — the shape `flow-decision-mode-inclusive-explicit`
287+
* rewrites, twice — and a dashboard `refreshInterval`, whose conversion no
288+
* semantic entry names.
289+
*/
290+
const decisionFlow = (name: string) => ({
291+
name,
292+
label: name,
293+
type: 'autolaunched',
294+
status: 'active',
295+
nodes: [
296+
{ id: 'start', type: 'start', label: 'Start' },
297+
{ id: 'verdict', type: 'decision', label: 'Verdict?' },
298+
{ id: 'refuse', type: 'end', label: 'Refuse' },
299+
{ id: 'convert', type: 'end', label: 'Convert' },
300+
],
301+
edges: [
302+
{ id: 'e1', source: 'start', target: 'verdict' },
303+
{ id: 'e2', source: 'verdict', target: 'refuse', condition: "lead.status != 'confirmed'" },
304+
{ id: 'e3', source: 'verdict', target: 'convert', condition: "lead.status == 'confirmed'" },
305+
],
306+
});
307+
const DECISION_STACK = {
308+
manifest: { id: 'com.example.report-pairing', name: 'Report Pairing', version: '1.0.0', type: 'app' },
309+
objects: [{ name: 'rp_lead', label: 'Lead', fields: { status: { type: 'text', label: 'Status' } } }],
310+
flows: [decisionFlow('rp_lead_verdict'), decisionFlow('rp_lead_recheck')],
311+
dashboards: [{ name: 'rp_kpi', label: 'KPI', widgets: [], refreshInterval: 300 }],
312+
};
313+
314+
/** One `time` default with a `Z` — a single edit by `time-default-utc-suffix-dropped`. */
315+
const TIME_STACK = {
316+
manifest: { id: 'com.example.report-pairing-time', name: 'Report Pairing Time', version: '1.0.0', type: 'app' },
317+
objects: [{
318+
name: 'rp_shift',
319+
label: 'Shift',
320+
fields: { starts_at: { type: 'time', label: 'Starts at', defaultValue: '09:00Z' } },
321+
}],
322+
};
323+
324+
const DECISION_CONVERSION = 'flow-decision-mode-inclusive-explicit';
325+
const DECISION_JUDGE = 'flow-decision-edge-branching-first-match';
326+
const TIME_CONVERSION = 'time-default-utc-suffix-dropped';
327+
const TIME_JUDGE = 'time-default-zone-refused';
328+
329+
const REVIEW_RE = /^ {6}↳ review /;
330+
331+
/** The review line a judge prints under `edits` edits — written from the entry's data, not from the printer. */
332+
function reviewLine(judge: MigrationTodo, edits: number): string {
333+
const subject = edits === 1 ? 'the edit above' : `the ${edits} edits above`;
334+
return ` ↳ review ${subject} against the manual change [protocol ${judge.toMajor}] ${judge.surface} → ${judge.replacement}`;
335+
}
336+
337+
/** Every link the registry declares: a semantic entry, and a conversion id it names. */
338+
function declaredLinks(): Array<{ toMajor: number; entry: string; conversionId: string }> {
339+
return MIGRATION_MAJORS.flatMap((m) =>
340+
MIGRATIONS_BY_MAJOR[m]!.semantic.flatMap((s) =>
341+
(s.conversionIds ?? []).map((conversionId) => ({ toMajor: m, entry: s.id, conversionId })),
342+
),
343+
);
344+
}
345+
346+
function todoOf(result: MigrationChainResult, id: string): MigrationTodo {
347+
const todo = result.todos.find((t) => t.id === id);
348+
expect(todo, `the chain reports the semantic entry ${id}`).toBeDefined();
349+
return todo!;
350+
}
351+
352+
/** The index of the last line of the first run of edits by `conversionId`, and the run's length. */
353+
function runOf(lines: string[], conversionId: string): { last: number; length: number } {
354+
const isEdit = (l: string | undefined) => l !== undefined && l.startsWith(' • ') && l.endsWith(` (${conversionId})`);
355+
const first = lines.findIndex((l) => isEdit(l));
356+
expect(first, `an applied edit by ${conversionId} is printed`).toBeGreaterThan(-1);
357+
let last = first;
358+
while (isEdit(lines[last + 1])) last += 1;
359+
return { last, length: last - first + 1 };
360+
}
361+
362+
/** The review lines printed directly under line `i`. */
363+
function reviewsUnder(lines: string[], i: number): string[] {
364+
const out: string[] = [];
365+
for (let j = i + 1; j < lines.length && REVIEW_RE.test(lines[j]!); j += 1) out.push(lines[j]!);
366+
return out;
367+
}
368+
369+
describe('an applied edit a semantic entry judges prints that entry beside it, marked review (PAIR)', () => {
370+
it('prints the judge once under the run of edits it judges, counting them', () => {
371+
const { result, lines } = run(DECISION_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS);
372+
const edits = result.applied.filter((a) => a.conversionId === DECISION_CONVERSION);
373+
expect(edits, 'anti-vacuity: both decisions were rewritten').toHaveLength(2);
374+
const { last, length } = runOf(lines, DECISION_CONVERSION);
375+
expect(length).toBe(2);
376+
expect(reviewsUnder(lines, last)).toEqual([reviewLine(todoOf(result, DECISION_JUDGE), 2)]);
377+
// Beside the edit, i.e. inside ②: after its header, before ③'s.
378+
expect(last).toBeGreaterThan(indexOf(lines, APPLIED_HEADER_RE));
379+
expect(last + 1).toBeLessThan(indexOf(lines, SEMANTIC_HEADER_RE));
380+
});
381+
382+
it('says "the edit above" under a run of one', () => {
383+
const { result, lines } = run(TIME_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS);
384+
const { last, length } = runOf(lines, TIME_CONVERSION);
385+
expect(length).toBe(1);
386+
expect(reviewsUnder(lines, last)).toEqual([reviewLine(todoOf(result, TIME_JUDGE), 1)]);
387+
});
388+
389+
it('prints an edit no entry judges exactly as before, with no review line under it', () => {
390+
const { result, lines } = run(DECISION_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS);
391+
const linked = new Set(result.todos.flatMap((t) => t.conversionIds ?? []));
392+
const unjudged = result.applied.filter((a) => !linked.has(a.conversionId));
393+
expect(unjudged.length, 'anti-vacuity: the dashboard edit has no judge').toBeGreaterThan(0);
394+
for (const a of unjudged) {
395+
const i = lines.indexOf(` • ${a.path}: ${a.from} → ${a.to} (${a.conversionId})`);
396+
expect(i, `unjudged edit printed: ${a.path}`).toBeGreaterThan(-1);
397+
expect(reviewsUnder(lines, i), `no review line under ${a.path}`).toEqual([]);
398+
}
399+
// ②'s own lines are the chain's, byte-identical and in order, review lines aside.
400+
expect(lines.filter((l) => l.startsWith(' • '))).toEqual(appliedLines(result));
401+
expect(lines.filter((l) => APPLIED_HEADER_RE.test(l))).toEqual([
402+
` Applied ${result.applied.length} mechanical change(s):`,
403+
]);
404+
});
405+
406+
it('prints no review line at all where no applied conversion is linked', () => {
407+
const { result, lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS);
408+
const linked = new Set(result.todos.flatMap((t) => t.conversionIds ?? []));
409+
expect(linked.size, 'anti-vacuity: the chain does carry links').toBeGreaterThan(0);
410+
expect(result.applied.some((a) => linked.has(a.conversionId))).toBe(false);
411+
expect(lines.filter((l) => REVIEW_RE.test(l))).toEqual([]);
412+
});
413+
414+
it('keeps every semantic entry in ③ — the judge included — with the chain\'s count and bytes', () => {
415+
const { result, lines } = run(DECISION_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS);
416+
const header = indexOf(lines, SEMANTIC_HEADER_RE);
417+
expect(lines[header]).toBe(` ${result.todos.length} manual change(s) require your judgment:`);
418+
const expected = noticeLines(result);
419+
expect(lines.slice(header + 1, header + 1 + expected.length)).toEqual(expected);
420+
const entries = lines.slice(header + 1).filter((l) => /^ {4}⚠ \[protocol \d+\] /.test(l));
421+
expect(entries).toHaveLength(result.todos.length);
422+
const judge = todoOf(result, DECISION_JUDGE);
423+
expect(entries).toContain(` ⚠ [protocol ${judge.toMajor}] ${judge.surface} → ${judge.replacement}`);
424+
});
425+
426+
it('prints nothing but the groups and the review lines the declared links call for', () => {
427+
const { report, result, lines } = run(DECISION_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS);
428+
// Written without the printer's run logic: a real chain replays one
429+
// conversion at a time, so a conversion's edits are one run and its count
430+
// is the run's length.
431+
const runs = new Map<string, number>();
432+
for (const [i, a] of result.applied.entries()) {
433+
if (i > 0 && result.applied[i - 1]!.conversionId !== a.conversionId) {
434+
expect(runs.has(a.conversionId), `${a.conversionId} edits are contiguous`).toBe(false);
435+
}
436+
runs.set(a.conversionId, (runs.get(a.conversionId) ?? 0) + 1);
437+
}
438+
const reviews = result.todos.flatMap((t) =>
439+
(t.conversionIds ?? []).filter((id) => runs.has(id)).map((id) => reviewLine(t, runs.get(id)!)),
440+
);
441+
expect(reviews.length, 'anti-vacuity: the stack exercises a link').toBeGreaterThan(0);
442+
const accounted = [
443+
...lines.filter((l) => VERDICT_RE.test(l) || APPLIED_HEADER_RE.test(l) || SEMANTIC_HEADER_RE.test(l)),
444+
...refusalLines(report),
445+
...appliedLines(result),
446+
...reviews,
447+
...noticeLines(result),
448+
].filter((l) => l.trim() !== '');
449+
expect(lines.filter((l) => l.trim() !== '').sort()).toEqual(accounted.sort());
450+
});
451+
452+
it('pairs every link the registry declares, replayed over the linked conversion\'s own fixture', () => {
453+
const links = declaredLinks();
454+
// Anti-vacuity, and the two links authored when the printer learned to pair.
455+
expect(links).toEqual(expect.arrayContaining([
456+
expect.objectContaining({ entry: DECISION_JUDGE, conversionId: DECISION_CONVERSION }),
457+
expect.objectContaining({ entry: TIME_JUDGE, conversionId: TIME_CONVERSION }),
458+
]));
459+
for (const link of links) {
460+
const conversion = ALL_CONVERSIONS.find((c) => c.id === link.conversionId);
461+
expect(conversion, `${link.entry} names a registered conversion`).toBeDefined();
462+
printed = [];
463+
const { result, lines } = run(
464+
conversion!.fixture.before as Record<string, unknown>,
465+
MIGRATION_SUPPORT_FLOOR,
466+
TERMINUS,
467+
);
468+
const { last, length } = runOf(lines, link.conversionId);
469+
expect(
470+
reviewsUnder(lines, last),
471+
`${link.entry} is printed beside the ${link.conversionId} edits`,
472+
).toContain(reviewLine(todoOf(result, link.entry), length));
473+
}
474+
});
475+
476+
it('pairs across hops: a link to a conversion an earlier step replays', () => {
477+
// No authored link crosses a hop yet, though `SemanticMigration.conversionIds`
478+
// allows one, so this pin adds it: a protocol-18 entry made to judge a
479+
// protocol-17 conversion. Everything else is the chain's own result.
480+
const earlier = 'action-execute-to-target';
481+
const conversion = ALL_CONVERSIONS.find((c) => c.id === earlier)!;
482+
const judgeId = DECISION_JUDGE;
483+
const { result, lines } = run(
484+
conversion.fixture.before as Record<string, unknown>,
485+
MIGRATION_SUPPORT_FLOOR,
486+
TERMINUS,
487+
(r) => ({ ...r, todos: r.todos.map((t) => (t.id === judgeId ? { ...t, conversionIds: [earlier] } : t)) }),
488+
);
489+
const judge = todoOf(result, judgeId);
490+
const edit = result.applied.find((a) => a.conversionId === earlier);
491+
expect(edit?.toMajor, 'anti-vacuity: the edit comes from an earlier hop').toBeLessThan(judge.toMajor);
492+
const { last, length } = runOf(lines, earlier);
493+
expect(reviewsUnder(lines, last)).toEqual([reviewLine(judge, length)]);
494+
});
495+
});

0 commit comments

Comments
 (0)