Skip to content

Commit 6073bb9

Browse files
fix(cli): os migrate meta prints the manual change that judges an applied edit beside that edit, marked review (#21025)
Fixes #20620 Clause-②: no Direction 2 of triage's first grade on #20620 (comment `5888087153`): 「Where an applied conversion has a semantic sibling that judges it, print the sibling beside the applied edit, marked **review**.」 It pairs through the declared `SemanticMigration.conversionIds` only, never through text mentions (release `5916560364`). Direction 1 (order) landed in PR #20691. Direction 3 (write-back) is not taken by triage, so this PR closes the card. ## What changes `printMigrationReport` (`packages/cli/src/commands/migrate/meta.ts`), the human face of `os migrate meta --from N`, now prints group 2 through `printAppliedEdits`. Under each run of applied edits by one conversion, it prints one review line for every semantic entry that names that conversion in `conversionIds`: ```text Applied 3 mechanical change(s): • dashboards[0].refreshIntervalSeconds: refreshInterval → refreshIntervalSeconds (dashboard-refresh-interval-to-refresh-interval-seconds) • flows[0].nodes[1].config.mode: mode unset (2 conditioned out-edges; every one whose condition held was taken) → inclusive (flow-decision-mode-inclusive-explicit) • flows[1].nodes[1].config.mode: mode unset (2 conditioned out-edges; every one whose condition held was taken) → inclusive (flow-decision-mode-inclusive-explicit) ↳ review the 2 edits above against the manual change [protocol 18] flow.nodes[].config.mode (decision) — an OMITTED mode on a decision that branches on its out-edges and carries two or more conditioned ones → nothing, where the out-edge conditions partition (exactly one can hold for any record): […] ``` (The review line is cut at `[…]` here. It is printed in full.) - **It is a copy, not a move.** The review line repeats the entry's `[protocol N] surface → replacement` headline, which is the line group 3 prints for that entry. Group 3 still prints every entry, byte for byte, under the same `N manual change(s) require your judgment:` count, so ADR-0087 D3's "never silence" holds. The `why` and `verify` lines are found in group 3 under the same headline. - **Declared links only.** `judgesByConversion` reads `todo.conversionIds`, which the chain copies from the entry. Nothing matches prose. - **Nothing else moves.** Every edit line is byte-identical and in chain order, and the group 2 header count is unchanged. An edit that no entry judges prints with nothing under it. `--json`, `--out`, the exit code and the loader's stderr are untouched. `--stored` does not use this printer. ## The dispatch's mechanism assumptions, measured - **H1 holds.** `applyMetaMigrations` builds each todo as `{ ...s, toMajor }` (`packages/spec/src/migrations/chain.ts`), so every todo carries `conversionIds`. On `main` the registry declares exactly two links, and both point into the same hop: `flow-decision-edge-branching-first-match` (18) → `flow-decision-mode-inclusive-explicit` (18), and `time-default-zone-refused` (18) → `time-default-utc-suffix-dropped` (18). No authored link crosses a hop yet. The join runs over the flat `applied` / `todos` arrays, so a cross-hop link pairs by construction. A pin proves it by adding a link from a protocol-18 entry to the protocol-17 conversion `action-execute-to-target` on a real chain result. Across steps, the 108 step conversion ids are unique (0 duplicates). - **H2 holds, as a copy.** Group 3 keeps every entry, byte-identical, with its header count unchanged. The review line sits in group 2 beside the edits it judges. I did not change it to a move. A move would take the entry out of group 3, and its count would then stop matching the chain's `todos`. - **H3: one review line per run, not one per edit.** A conversion's edits are contiguous by construction, because the chain replays one conversion at a time and its `apply` emits every edit before the next conversion starts. So a run is "this conversion's edits in this hop". HotCRM had 13 edits by `flow-decision-mode-inclusive-explicit`. One review line per edit would repeat a paragraph-long `replacement` 13 times, which is the noise this card is about. No information is lost: every edit line still names its `conversionId`, and the review line counts the edits above it ("the 2 edits above", or "the edit above" for a run of one). If a conversion's edits were ever split into two runs, each run would get its own review line, so a review line never sits far from its edits. - **H4: `--stored` does not use `printMigrationReport`.** `runStored` prints `formatStoredMigrationReport` from `@objectstack/metadata-protocol`, so this PR does not touch it. ## Evidence: real stacks through `bin/run.js` Two small authored stacks (`export default { … }` configs) were run with `os migrate meta --from 17`, once on `origin/main` at `315888d660` and once at this PR's head, `c6622657f2`: - a decision stack: two flows, each with an edge-branched decision with two conditioned out-edges and no `mode`, plus a dashboard `refreshInterval`; - a time stack: two `time` field defaults, `09:00Z` and `17:30:00+00:00`, plus a dashboard `refreshInterval`. | | decision stack | time stack | |:---|:---|:---| | stdout lines, before → after | 813 → 814 | 813 → 814 | | stdout diff (timing and `--out` path normalised) | one added line, the review line under the 2 `flow-decision-mode-inclusive-explicit` edits | one added line, the review line under the 2 `time-default-utc-suffix-dropped` edits | | group 3 entries / header count | 253 = 253 | 253 = 253 | | `--json` apart from `duration` | identical | identical | | `--out` snapshot | byte-identical | byte-identical | | stderr | 0 bytes both runs | 0 bytes both runs | | exit code (human / `--json`) | 0 / 0 both runs | 0 / 0 both runs | Before this change, the decision edits printed on lines 13 and 14, and the entry that judges them printed at line 351. ## Tests The pins extend `packages/cli/src/commands/migrate/meta.report-order.test.ts` rather than a new file. That file already holds the printer's helpers (`run`, `appliedLines`, `noticeLines`, `refusalLines`) and the SET pins the pairing must not break, so the pairing pins reuse them, and every pin on this printer stays in one file. The new `PAIR` block adds: 1. the judge prints once, directly under the run of edits it judges, counting them, inside group 2; 2. a run of one says "the edit above"; 3. an edit no entry judges prints exactly as before, with no review line under it, and group 2's edit lines equal the chain's, in order; 4. a stack whose applied conversions have no link prints no review line at all; 5. group 3 keeps every entry, the judge included, with the chain's count and bytes; 6. every non-blank line is a header, a refusal, an edit, a notice, or a review line that the declared links call for. The expected review lines are computed without the printer's run logic; 7. every link the registry declares is paired, replayed over the linked conversion's own `fixture.before`, so a link the spec lane adds later is covered when it lands. Anti-vacuity: the two links named in the dispatch are asserted present; 8. a link to a conversion that an earlier step replays pairs across hops. Measured at `c6622657f2`: - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 src/commands/migrate/meta.report-order.test.ts`: 16 passed (8 existing, 8 new). - The unit-tier neighbours (`doctor-deprecation-hint-commands`, `meta.stored-flags`, `migrate-meta-strict-factories`, and this file): 4 files, 41 passed. - `test/migrate-meta-default-range.test.ts`, `test/migrate-meta-engine-guidance.test.ts` and `test/migrate-meta.e2e.test.ts` are integration tier, so they are declared to CI. Their only group 2 assertions are `toContain` checks, which an added line cannot break. - `pnpm --filter @objectstack/cli typecheck`: exit 0. The test file is in the `tsc --noEmit` program (`--listFiles`, 1 hit). **Ablation.** The fix was committed first (`19563f6c4e`). `node scripts/ablation-replace.mjs` replaced `const judges = judgesByConversion(result.todos);` with an empty map (anchor x1 → x0, blob `868eef7e8983` → `542c67cdfca2`). The subject is imported relatively from source, so no build was involved. Result: **5 red** (pins 1, 2, 6, 7 and 8) and **11 green**. The 11 are the 8 order, count and set pins and the 3 control pins: unjudged edit, no-link stack, and group 3 intact. The restore was proven by blob `868eef7e8983` equal to HEAD and an empty `git diff HEAD`. ## Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`, with no paths, at `c6622657f2` (merge base `9b0de7de73`), derived 62 commands. Each was run and its exit code recorded before any pipe. All 62 exited 0. `--ran`: `62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN`. On the first round, `check:dual-build-cjs-loads` and `check:i18n-coverage` answered PREREQUISITE NOT MET (exit 3, no dist). They were re-run after `pnpm turbo run build --filter=!@objectstack/docs`, and both are green. At reconciliation time, `origin/main` was already 3 commits ahead, and one family file (`scripts/check-platform-checklist.mjs`) had changed there. That family reads `docs/qa/platform-checklist/`, which this diff does not touch. **Lint, as a proven narrowing.** `pnpm lint` is a repo-wide scan owned by CI. Run at `c6622657f2`: - **Population.** ESLint's own config was asked through its API (`isPathIgnored` and `calculateConfigForFile` against `eslint.config.mjs`, inline config off). `meta.ts` is LINTED with 6 rules and `meta.report-order.test.ts` is LINTED with 5. The changeset is in no config's population. - **Count.** `--format json` over those 2 files: 2 results, 0 errors, 0 warnings. - **Invariance.** `eslint.config.mjs` enables no type-aware linting: none of its 7 `parserOptions` blocks sets `project`. Its only disk reads at load are two baseline JSON files this diff does not touch. So no untouched file's verdict can move. ## Acceptance notes - `skills/objectstack-upgrade/SKILL.md:139-147` describes `Applied N mechanical change(s)` as "one line per rewritten site". With this PR, a judged run also carries a review line. The surface is Tier H governed, and a one-line mention can ride with the group reorder that the previous ACCEPT on this card already left for that surface's next queue entry. - The `printMigrationReport` docblock cites "242 notices for protocol 18" as the measurement behind direction 1. Today's chain prints 253. It is a dated measurement, not a claim this PR depends on, so it is left as written. - `docs/qa/platform-checklist/areas/cli.json` item `cli.migrate-meta-codemod` locates planted spellings in the `Applied` block by their edit line. The added review lines do not change that. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5d5e679 commit 6073bb9

3 files changed

Lines changed: 344 additions & 15 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os migrate meta --from N` prints the manual change that judges an applied edit beside that edit, marked review
6+
7+
Clause-②: no
8+
9+
Some applied mechanical edits are not the end of the job. A default flip such as
10+
`flow-decision-mode-inclusive-explicit` writes `mode: 'inclusive'` onto a decision so
11+
the flow keeps its old behaviour, and the manual change that judges it says the right
12+
edit is usually none: delete the key where the branch conditions partition. That
13+
judgment used to print hundreds of lines below the edit, among every other manual
14+
change of the major.
15+
16+
A manual change can now declare which conversions' edits it judges. In the
17+
`Applied N mechanical change(s):` group, each run of edits by such a conversion is
18+
followed by one line:
19+
20+
↳ review the N edits above against the manual change [protocol M] surface → replacement
21+
22+
The line copies the headline the manual change prints in its own group, so its `why`
23+
and `verify` lines are found there under the same text. Two pairs are declared today:
24+
`flow-decision-mode-inclusive-explicit` with the decision-mode entry, and
25+
`time-default-utc-suffix-dropped` with the time-default entry. Only declared links
26+
pair; a manual change that merely mentions a conversion in its prose does not.
27+
28+
Nothing else moves. The `N manual change(s) require your judgment:` group still lists
29+
every manual change, byte for byte, with the same count. An edit no manual change
30+
judges prints exactly as before. `--json`, `--out`, the exit code and the loader's
31+
stderr are unchanged, and `--stored` is not affected.

‎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)