Skip to content

Commit 3b47a69

Browse files
objectstack-fleet[bot]hotlongclaude
authored
fix(cli): os migrate meta leads with the verdict and the refusals, then the applied edits, then the semantic notices (#20691)
Part of #20620 Clause-②: no Direction 1 of the card's triage (order). Direction 2 (pairing an applied conversion with the semantic entry that judges it) needs a structured link on `SemanticMigration` in `packages/spec`, so it stays on the card. Direction 3 (write-back) is out, per triage. ## What changed `os migrate meta --from N` (the authored-source report, human output only) now prints three groups, in the order an upgrader acts on them. Each group opens with one header line that counts it: 1. **The verdict and the refusals.** Either `✓ Migrated stack is schema-valid (Nms)`, or `⚠ Migrated stack does not yet pass schema validation — N refusals left after the chain. Resolve them, then run `os validate`:` followed by one `✗ path: message` line per issue of the migrated stack's parse. The lines are rendered by `formatZodIssue`, the same renderer behind the loader's `defineX() validation failed` block. 2. **`Applied N mechanical change(s):`**, unchanged. 3. **`N manual change(s) require your judgment:`**, unchanged. The human branch of `run()` is now one exported function, `printMigrationReport`, so the order can be pinned in process. `--step` checkpoints stay between groups 2 and 3, and the `--out` line and the data-migration advice stay after group 3. A range that holds no step also leads with the verdict, which now lists the source's refusals, and the range note follows it. **Unchanged:** - Every applied-edit line and every semantic-notice line, byte for byte and in chain order. No notice is dropped, filtered, merged or reworded (ADR-0087 D3, "never silence"). - `--json`: same keys in the same order, same values, same array order. - Exit codes. A schema-invalid run still exits 0. - The loader's stderr output. `--json`'s `applied` and `todos` arrays are in chain order, and a consumer could rely on that order. It is left as it is. ## Measurements Each "before" run used the `bin/run.js` of a worktree at `6981abfd26` (this branch's base). Each "after" run used the build of `7c484d891f`. Both runs used the same `@objectstack/spec` (`6981abfd26`). The merge of `main` since then does not touch `packages/cli`. Stacks: - `examples/app-crm` at `6981abfd26`; - **h1**, a scratch `defineStack` config: two currency fields carrying `scale` (refused, no conversion), two `dashboard.refreshInterval`, two `action.aria`, and a two-branch flow decision with no `mode`; - **dm**, a scratch config with an image and a location field and one `refreshInterval`, run `--from 16` so the data-migration advice prints. stdout line where each group starts: | run | before: applied / semantic / verdict | after: verdict / applied / semantic | |:--|:--|:--| | h1 `--from 17` (5 applied, 242 notices, 2 refusals left) | 9 / 16 / **780 (last line)** | **9** (+2 refusal lines) / 13 / 20 | | app-crm `--from 17` (0 applied, 242 notices, valid) | none / 9 / **773** | **9** / none / 11 | | dm `--from 16` (1 applied, 319 notices, valid) | 9 / 12 / **1044**, after the data-migration advice | **9** / 11 / 14, with the advice last | - **H0 (the refusals print after the catalogue): partly false.** The verdict was the last line. The printer never listed a refusal. The only refusal list was the loader's `[authored-source] defineStack()` warning on stderr, printed during load, before any chain output. That list describes the stack as authored: 6 issues on h1, 4 of which the chain goes on to convert. The 2 refusals that remain after the chain were printed nowhere. - **H1: holds, with one addition by design.** The order is verdict and refusals, then applied edits, then notices, with one header line per group. Applied-edit lines match in number and in order: 5=5 (h1), 0=0 (crm), 1=1 (dm). Notice lines match in number and in order: 744=744, 744=744, 996=996. The preamble lines are equal. The same holds for `--step` and `--out`, with the snapshot path normalised. The only lines added are the refusal list: 2 on h1, 0 on the valid stacks. That is the content direction 1 asks for, and the printer had none of it before. The only lines reworded are the two schema-invalid verdict sentences: the old one pointed at "the manual changes above", which are now below. - **H2: holds.** The exit code was 0 before and after in all 11 invocations. `--json` is identical once `duration` is removed, in the same key order (h1, h1 empty range, crm, dm). The `--out` snapshot is byte-identical. stderr is byte-identical in every run. - **H3 (ablation): holds.** The mutation restores the old order and nothing else: the body runs first, the verdict prints last. It went through `scripts/ablation-replace.mjs` (anchor 1 → 0, blob `1a002a9b` → `d80c2332`) under a trap-guarded restore. Result: exactly the 3 ORDER pins red, e.g. `the verdict is the first line the report prints: expected 1030 to be +0`. The header-count pin and the 4 SET pins stayed green. Restore proven: blob `1a002a9b` equals the `HEAD` blob, `git diff HEAD` is empty, and the marker count is 0. The subject is `./meta.js`, resolved to `src` by vitest (a relative import), so no `dist` was involved. ## Verification At `8304fb9e13` (final head: `main` merged in, then `pnpm install --frozen-lockfile` and the CLI closure rebuilt): - `pnpm --filter @objectstack/cli typecheck`: exit 0. The new test is in the `tsc --noEmit` program (`--listFiles` count 1). - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: 233 files passed, 2 failed (5 tests). The 2 are `test/published-subpath-console.pin.test.ts` and `test/published-subpath-hook-body.pin.test.ts`. They fail the same way on a clean `6981abfd26` worktree: a macOS `/var` vs `/private/var` realpath difference. See the acceptance notes. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran` over the 62 derived commands, each with its exit code: 62 run, all exit 0, 0 NOT-MEASURED. `check:dual-build-cjs-loads` and `check:i18n-coverage` first answered `PREREQUISITE NOT MET` (exit 3). They were re-run after a full `turbo run build` and exited 0. - The artifact-roster rows that could apply, all exit 0: - `check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity` (all marked ⛔); - `release-pending-publish --self-test`, `check:cli-examples-parity` and `check-platform-checklist-watchdog`; - `check-published-list-mirrors` and `check:commit-card-trailers`. - Three PR-context gates exit 2 without a PR payload and are NOT MEASURED locally: `check-single-claim-paths`, `check-partof-closing-keyword` and `check-closing-target-claim`. - `pnpm lint` (the full `eslint .`): exit 0. Both touched files are linted, with 0 errors and 0 warnings (`--format json`). - `node scripts/check-issue-citations.mjs --base origin/main`: exit 0, 3 citations resolve. At `7c484d891f` (before the merge, same `packages/cli`): - The two queue-tier spawned tests that quote this printer's text, `test/migrate-meta-default-range.test.ts` and `test/migrate-meta-engine-guidance.test.ts` (`--project integration`): 10 passed, 1 skipped (a registry-selected skip, by design). - `test/migrate-meta.e2e.test.ts` (nightly tier): 19 passed. **Declared narrowing — verification ran UNLOCKED.** `scripts/pm/os-verify-lock.sh` could not take the shared verify lock on this host: no usable `flock`. The shared verify lock is declared Linux-only (`flock` is util-linux, and a stock macOS does not ship it), so the command below was run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for this run, nor for any sibling agent in this container while it ran. pnpm exec turbo run build --filter='@objectstack/cli...' --concurrency=2 pnpm exec turbo run build --filter='!@objectstack/docs' --concurrency=2 pnpm --filter @objectstack/cli build pnpm --filter @objectstack/cli typecheck pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2 test/migrate-meta-default-range.test.ts test/migrate-meta-engine-guidance.test.ts pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2 test/migrate-meta.e2e.test.ts pnpm lint ## Acceptance notes - **The refusal group is new content, and the only content this PR adds.** The printer never had the migrated stack's refusals. The only list was the loader's, of the authored stack, on stderr. That loader warning is unchanged and still prints first during load. On h1 it names 6 issues, and the report's group names the 2 left after the chain. The two counts measure different stacks. - **A published checklist step now quotes a stale sentence.** `docs/qa/platform-checklist/areas/cli.json`, item `cli.migrate-meta-codemod` (revision 3), quotes the old invalid-verdict sentence verbatim ("resolve the manual changes above"). Its acceptance clause (the verdict agrees with `schemaValid`) still holds. The quoted step is now stale. It is not edited here: it needs a revision bump and a history entry by the checklist's author. - **The upgrade skill lists the groups in the old order.** `skills/objectstack-upgrade/SKILL.md` lists the printed groups as applied, manual, verdict. It claims no order, and every string it quotes is still printed. It is a governed surface, not edited here. - **`pnpm check:platform-checklist` is red on `main`.** The failure is `areas/identity-auth.json: ABSENT SYMBOL … auth-plugin.ts#twoFactor`, red on `6981abfd26` as well. The gate is maintainer-run and deliberately out of per-PR CI. - **Two published-subpath pins are red on macOS only.** `published-subpath-console.pin.test.ts` and `published-subpath-hook-body.pin.test.ts` compare a `/var/folders/…` path with its `/private/var/folders/…` realpath. They are red on a clean base worktree too, so the failure comes from the host, not from this diff. - **Reported for the seat to file (class a): `os migrate meta` cannot open an object built with `ObjectSchema.create` that carries a retired key.** The load-time tolerance wraps only the `define*` exports, and `ObjectSchema.create(` is used 35 times across `examples/`, as well as by HotCRM. - Repro: one object, `ObjectSchema.create({ …, tenancy: { enabled: true, organizationField: 'organization_id' } })` inside `defineStack`. - Result: `os migrate meta --from 17` exits 1 at load with the raw ZodError array. The array's own message prescribes "Run `os migrate meta --from 17` …". - Control: the same object as a plain literal loads, `object-tenancy-organization-field-removed` applies, and `schemaValid` is true. - **Exit code 0 on a schema-invalid run is unchanged**, as `test/migrate-meta-default-range.test.ts` records. --- _Generated by [Claude Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_ --------- Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0be8984 commit 3b47a69

3 files changed

Lines changed: 462 additions & 78 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)