Skip to content

Commit c749198

Browse files
fix(cli): os migrate meta --json says when no migration step ran (#29045)
domain:server#3 · os-project-manager · session_01FEkQ5pLAfce3rbrPKLECmj <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XL7p7YBQtLSHX1r2qftEhKw)_ Fixes #28947 Clause-②: yes (widening) Metadata-protocol: none Clause-② measured: the `--json` answer's members are a documented contract in a published surface. `skills/objectstack-upgrade/SKILL.md` (shipped by `@objectstack/skills`) tells an agent to read `.applied` ("`applied` must be []"), `.todos[].surface` and `.specChanges` off `os migrate meta --json`, and `content/docs/upgrading.mdx` documents `todos` / `absentTodos` under `--all`. This PR adds one member to that answer, so it is a widening. A `Clause-②: yes (widening)` declaration on its own is reviewed on its declaration face at `TIER_DEFAULT`. Metadata-protocol is `none`: nothing a metadata author writes is accepted, refused or read differently. ## 维护者速读 - 改了什么:`os migrate meta --json` 在一步迁移都没跑的时候(空区间,如 `--from 18`;或倒置区间,如 `--from 19`),JSON 多一个成员 `replayedNothing: { reason, widestRange }`,说明「本次什么都没重放」以及更宽的区间里有没有活。人读输出与 JSON 读的是同一个函数;倒置区间的人读文案也改为直说「区间倒置」。 - 为什么改:以前 JSON 对空区间的回答和一次干净的重放一模一样(`applied: []`、`schemaValid: true`、退出码 0),而人读输出会警告「这不代表元数据已是规范形」。脚本或 CI 读 JSON 时,`--from` 写错就会被告知迁移已完成。 - 风险与回滚:纯加法。真正跑了迁移步骤的 JSON 逐字节不变(实测 `--from 17` 输出 2,624,990 字节,掩掉 `duration` 后与 main 同 md5),所有退出码不变,倒置区间不新增拒绝。回滚即 revert。 - 先看:`packages/cli/src/commands/migrate/meta.ts` 的 `readReplayedNothing`(唯一判定)与 `printReplayedNothing`(人读渲染);同文件 `emitJson` 调用里新增的那一行展开。 - 两分钟验证:`pnpm --filter @objectstack/cli exec vitest run --project unit test/migrate-meta-json-replayed-nothing.test.ts`,预期 `Tests 8 passed (8)`。 - 需要你做的:无,席位落地。 ## What changes - **`packages/cli/src/commands/migrate/meta.ts`**: `printEmptyRangeAnswer` is split into one reading and one renderer. - `readReplayedNothing(stack, result)` returns `null` when the run replayed at least one step (`result.hops.length > 0`), and otherwise `{ reason, widestRange }`. `reason` is `inverted-range` when `--from` is above `--to` and `empty-range` otherwise. `widestRange` is `null` when no wider range exists (`--to` already reaches the chain terminus, or `--from` is at or above it, so any range from that `--from` is itself empty or inverted), and otherwise `{ from, to, applied, manual }` from the same widest-chain probe the human answer always ran (`applyMetaMigrationsToPackages` over the same normalized stack, manual counted through `listedTodos`). - `printReplayedNothing` renders that answer. The empty-range outcomes print the same text as before, except that an inverted range from a `--from` at or above the terminus (`--from T --to T-1`) now prints the no-wider-range line rather than `The widest range this build carries (protocol T → T) has nothing for this stack either`. An inverted range now prints `--from N is above --to M, so the range is inverted and this run replayed nothing — that is not a finding that the metadata is canonical.` in place of `No migration step exists for protocol N → M, …`. - The `--json` payload spreads `replayedNothing` in after `hops` only when the reading is non-null, so a run that replayed a step keeps its old payload byte for byte. Exit codes are untouched everywhere, and `--from` above the terminus is answered, not refused. - **`packages/cli/test/normalized-call-sites.test.ts`**: a `packages` row for `migrate/meta.ts :: readReplayedNothing(normalized`. The census caught the new call site in the unit tier. The function's only stack read is the widest-range probe, which goes through `applyMetaMigrationsToPackages` (the row above it), so package bodies are counted. - **`.changeset/28947-migrate-meta-json-replayed-nothing.md`**: `@objectstack/cli` patch with both declaration lines. ## Tests - New `packages/cli/test/migrate-meta-json-replayed-nothing.test.ts` (unit tier by behaviour: in-process `MigrateMeta.run`, nothing spawned, no kernel). Every range is derived from `MIGRATION_MAJORS` / `PROTOCOL_MAJOR`, and no major is hard-coded. 8 cases: - guard: the registry has a step at the terminus `T`, and `T-1` is at or above the support floor; - `--from T --json`: `replayedNothing` is `{ reason: 'empty-range', widestRange: null }`, exit 0; - `--from T --to T --json`: the same; - `--from T+1 --json`: `{ reason: 'inverted-range', widestRange: null }`, exit 0 (not refused); - `--from T --to T-1` and `--from T+1 --to T-1`: `{ reason: 'inverted-range', widestRange: null }` (seat review: a widest range from a `--from` at or above the terminus is itself empty or inverted); - `--from T-1 --to T-1`: `widestRange` names `T-1 → T`, and the human run prints the same counts (face parity); - human `--from T+1` names the inverted range; - control `--from T-1 --json` (a real replay): no marker, and `Object.keys` equals the ten members the payload carried before. - **Red first** on `main`'s code (`135054eb`): 5 failed / 2 passed. The guard and the real-replay control were green. The `--from T --to T-1` case added on review was red first at `4066e3ac` (1 failed / 7 passed: `widestRange` came back `{ from: T, to: T, … }`). - **Ablations** (`scripts/ablation-replace.mjs`, anchor hit once each, restored to the HEAD blob `155f6cd4` with `git diff HEAD` empty). The test imports `src/` directly, so no build sits on the path. - The JSON spread replaced with an empty spread: 4 failed / 3 passed (the four JSON marker cases). - `reason` pinned to `'empty-range'`: 2 failed / 5 passed (the inverted JSON case and the inverted human case). - The `fromMajor >= CHAIN_TERMINUS_MAJOR` arm of the no-wider-range guard removed: 1 failed / 7 passed (the `--from T --to T-1` case); restored to the HEAD blob `6da11573`, `git diff HEAD` empty. - **Byte identity of a real replay**, measured with the built CLI on a canonical stack: `--from 17 --json` on `main` and on this branch are 2,624,990 bytes each, and both hash to md5 `6edb407e…` with `duration` masked. ## Acceptance notes - **Measured on `main` first** (canonical scratch stack, built CLI at `135054eb`): `--from 18 --json`, `--from 19 --json` and `--from 18 --to 18 --json` all exited 0 with `applied: []`, `todos: []`, `schemaValid: true` and no member saying no step ran. The human runs printed the `replayed nothing` warning. Control `--from 17 --json` exited 0 with 448 todos (21 absent). On this branch the three empty runs carry `replayedNothing`, and `--from 17 --to 17 --json` answers `{ reason: 'empty-range', widestRange: { from: 17, to: 18, applied: 0, manual: 427 } }`. The human run prints the same `0 mechanical and 427 manual`. - **The human face changed for the inverted range only.** It follows from the one reading: the JSON reason says the range is inverted, so the human line says so too. No existing pin reads that line, and the empty-range lines are byte-identical. ## Verification All at head `ec0c6421` (base `135054eb`, `origin/main` when the branch was cut). `main` has moved since; no conflict is reported, so the branch is not re-merged (AGENTS.md multi-agent §10). <details><summary>Package suites, typecheck and lint</summary> - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` (verify lock, VERDICT command-exit 0): Test Files 369 passed (369), Tests 5204 passed (5204). The first full run at `8eae1397` had 1 failed / 5202 passed: `normalized-call-sites.test.ts` named the new `readReplayedNothing(normalized` call site, which `4066e3ac` classifies. - `pnpm --filter @objectstack/cli typecheck` (tsc plus `check:test-typecheck` over `tsconfig.test.json`, which covers the new test): exit 0. - `pnpm exec eslint --no-inline-config packages/cli`: exit 0. - Integration tier: declared to CI. The diff touches no integration-tier file and no spawn entry. </details> <details><summary>Derived gates (dispatch-gates --commands, 61 families)</summary> - 60 run with exit 0, 1 NOT MEASURED. `dispatch-gates.mjs --ran`: "61 derived famil(ies) accounted for — 60 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)". - NOT MEASURED: `pnpm check:dual-build-cjs-loads`, exit 3 (PREREQUISITE NOT MET: packages outside this closure, e.g. `client-react`, `studio`, have no `dist` in this worktree). CI runs it. (At `4066e3ac`, in a worktree that had those builds, it exited 0.) - `check:i18n-coverage` exit 0 after the example closure build it names: "14 config(s), 426 baselined untranslated string(s), none new". - Gate logs are captured to file before any pipe. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5pLAfce3rbrPKLECmj)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0d6d406 commit c749198

4 files changed

Lines changed: 298 additions & 18 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os migrate meta --json` says when no migration step ran
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
An empty range (`--from N` where `N` is already the highest major this build carries a step for, or `--from N --to N`) and an inverted one (`--from` above `--to`) replay no step. The human run has always warned that such a run "replayed nothing — that is not a finding that the metadata is canonical", but the `--json` answer looked exactly like a clean replay: `applied: []`, `todos: []`, `schemaValid: true`, exit 0. A script or CI step with a wrong `--from` was told its metadata had been migrated.
11+
12+
The `--json` answer now carries `replayedNothing` whenever no step ran:
13+
14+
- `reason`: `empty-range` when the range is well-formed but holds no step, `inverted-range` when `--from` is above `--to`.
15+
- `widestRange`: the widest range this build carries from the same `--from` (`from`, `to`) and how many mechanical (`applied`) and manual (`manual`) changes it holds for this stack, or `null` when no wider range exists: `--to` already reaches the highest major, or `--from` is at or above it.
16+
17+
Both faces read the same answer, so the human run now also names an inverted range as inverted. A run that replays at least one step carries no `replayedNothing` member and its payload is unchanged, and the exit code is unchanged in every case: an inverted range is answered, not refused.

‎packages/cli/src/commands/migrate/meta.ts‎

Lines changed: 75 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,31 @@ function pendingDataMigrations(stack: any, fromMajor: number, toMajor: number):
289289
}
290290

291291
/**
292-
* The answer a range holding NO migration step owes the author (#17134).
292+
* What a run that replayed NO migration step owes its reader (#17134), on both
293+
* faces: the human run renders it ({@link printReplayedNothing}) and `--json`
294+
* carries it as `replayedNothing` (#28947). One reading, so the two faces
295+
* cannot disagree about whether a step ran or what a wider range holds.
296+
*/
297+
interface ReplayedNothing {
298+
/**
299+
* Why no step ran: `inverted-range` when `--from` is above `--to`, so no
300+
* major can lie between them; `empty-range` when the range is well-formed
301+
* but this build carries no step inside it.
302+
*/
303+
reason: 'empty-range' | 'inverted-range';
304+
/**
305+
* The widest range this build carries from the same `--from`, and how many
306+
* mechanical and manual changes it holds for THIS stack (zero and zero when
307+
* it has nothing either). `null` when no wider range is available: `--to`
308+
* already reaches the terminus, or `--from` is at or above it, so any range
309+
* from that `--from` to the terminus is itself empty or inverted.
310+
*/
311+
widestRange: { from: number; to: number; applied: number; manual: number } | null;
312+
}
313+
314+
/**
315+
* The answer a range holding NO migration step owes the author (#17134), or
316+
* `null` when the run replayed at least one step.
293317
*
294318
* `composeMigrationChain` keeps the majors `m > fromMajor && m <= toMajor`, so
295319
* `--from 17 --to 17` composes zero steps and every stack — canonical or not —
@@ -300,25 +324,54 @@ function pendingDataMigrations(stack: any, fromMajor: number, toMajor: number):
300324
* (the first was the tombstone that named this command). So an empty range is
301325
* answered as an empty range, and — when a wider one would rewrite this very
302326
* stack — with the range that lists them, because "which `--to` do I need" is
303-
* the question the author is left holding.
327+
* the question the author is left holding. An inverted range (`--from` above
328+
* `--to`) composes zero steps the same way and is answered the same way, its
329+
* reason naming the inversion; it is answered, never refused.
304330
*
305331
* The probe is exact rather than advisory: it replays the widest chain this
306332
* build carries against the SAME normalized stack, so it can only speak up
307333
* when there is real work for THIS source. A genuinely canonical stack in an
308334
* empty range is still told its range was empty — that much is a fact about
309335
* the invocation — but is offered no phantom conversions.
336+
*
337+
* ⛔ `--json` reads this function too. Its payload used to answer an empty
338+
* range exactly like a clean replay (`applied: []`, `schemaValid: true`, exit
339+
* 0), so a script with a wrong `--from` was told its metadata was migrated
340+
* while the human run warned (#28947).
310341
*/
311-
function printEmptyRangeAnswer(
342+
function readReplayedNothing(
312343
stack: Record<string, unknown>,
313-
fromMajor: number,
314-
toMajor: number,
315-
): void {
344+
result: Pick<MigrationChainResult, 'hops' | 'fromMajor' | 'toMajor'>,
345+
): ReplayedNothing | null {
346+
if (result.hops.length > 0) return null;
347+
const { fromMajor, toMajor } = result;
348+
const reason = fromMajor > toMajor ? 'inverted-range' : 'empty-range';
349+
if (toMajor >= CHAIN_TERMINUS_MAJOR || fromMajor >= CHAIN_TERMINUS_MAJOR) {
350+
return { reason, widestRange: null };
351+
}
352+
const wider = applyMetaMigrationsToPackages(stack, fromMajor, CHAIN_TERMINUS_MAJOR);
353+
return {
354+
reason,
355+
widestRange: {
356+
from: fromMajor,
357+
to: CHAIN_TERMINUS_MAJOR,
358+
applied: wider.applied.length,
359+
manual: listedTodos(wider.todos, wider.absentTodos).length,
360+
},
361+
};
362+
}
363+
364+
/** The human face of {@link readReplayedNothing}. */
365+
function printReplayedNothing(answer: ReplayedNothing, fromMajor: number, toMajor: number): void {
366+
const notCanonical = 'this run replayed nothing — that is not a finding that the metadata is canonical.';
316367
printWarning(
317-
`No migration step exists for protocol ${fromMajor} → ${toMajor}, so this run replayed nothing `
318-
+ '— that is not a finding that the metadata is canonical.',
368+
answer.reason === 'inverted-range'
369+
? `--from ${fromMajor} is above --to ${toMajor}, so the range is inverted and ${notCanonical}`
370+
: `No migration step exists for protocol ${fromMajor} → ${toMajor}, so ${notCanonical}`,
319371
);
320372

321-
if (toMajor >= CHAIN_TERMINUS_MAJOR) {
373+
const { widestRange } = answer;
374+
if (widestRange === null) {
322375
printInfo(
323376
`Protocol ${CHAIN_TERMINUS_MAJOR} is the highest major this build carries a step for, so no `
324377
+ 'wider range is available; check `--from` against the major the metadata was authored '
@@ -327,20 +380,18 @@ function printEmptyRangeAnswer(
327380
return;
328381
}
329382

330-
const wider = applyMetaMigrationsToPackages(stack, fromMajor, CHAIN_TERMINUS_MAJOR);
331-
const widerListed = listedTodos(wider.todos, wider.absentTodos);
332-
if (wider.applied.length === 0 && widerListed.length === 0) {
383+
if (widestRange.applied === 0 && widestRange.manual === 0) {
333384
printInfo(
334-
`The widest range this build carries (protocol ${fromMajor} → ${CHAIN_TERMINUS_MAJOR}) has `
385+
`The widest range this build carries (protocol ${widestRange.from} → ${widestRange.to}) has `
335386
+ 'nothing for this stack either.',
336387
);
337388
return;
338389
}
339390

340391
printWarning(
341-
`Protocol ${fromMajor} → ${CHAIN_TERMINUS_MAJOR} has ${wider.applied.length} mechanical and `
342-
+ `${widerListed.length} manual change(s) for this stack — re-run with `
343-
+ `\`--to ${CHAIN_TERMINUS_MAJOR}\` to list them.`,
392+
`Protocol ${widestRange.from} → ${widestRange.to} has ${widestRange.applied} mechanical and `
393+
+ `${widestRange.manual} manual change(s) for this stack — re-run with `
394+
+ `\`--to ${widestRange.to}\` to list them.`,
344395
);
345396
}
346397

@@ -901,8 +952,9 @@ export function printMigrationReport(report: MigrationReport): void {
901952
// is a finding about the metadata. A range that contains no step at all
902953
// replayed nothing and therefore found nothing — saying "already
903954
// canonical" over it is a claim about a check that never ran.
904-
if (result.hops.length === 0) {
905-
printEmptyRangeAnswer(report.normalized, result.fromMajor, result.toMajor);
955+
const replayedNothing = readReplayedNothing(report.normalized, result);
956+
if (replayedNothing) {
957+
printReplayedNothing(replayedNothing, result.fromMajor, result.toMajor);
906958
} else {
907959
printSuccess('Nothing to migrate — the metadata is already canonical for this range.');
908960
}
@@ -1207,6 +1259,7 @@ export default class MigrateMeta extends Command {
12071259
: undefined;
12081260

12091261
if (flags.json) {
1262+
const replayedNothing = readReplayedNothing(normalized, result);
12101263
await emitJson({
12111264
from: result.fromMajor,
12121265
to: result.toMajor,
@@ -1236,6 +1289,10 @@ export default class MigrateMeta extends Command {
12361289
absentTodos: h.absentTodos,
12371290
}))
12381291
: undefined,
1292+
// Only when no step ran (an empty or inverted range): the answer the
1293+
// human run prints, from the same reading. A replay's payload is
1294+
// what it always was (#28947).
1295+
...(replayedNothing ? { replayedNothing } : {}),
12391296
specChanges,
12401297
schemaValid: parsed.success,
12411298
// Per-deployment data migrations this chain leaves to the
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os migrate meta --json`: a run that replayed no step says so, in the same
5+
* words the human run does.
6+
*
7+
* ## The defect these pin
8+
*
9+
* `composeMigrationChain` keeps the majors `m > fromMajor && m <= toMajor`, so
10+
* an empty range (`--from T` with `T` the terminus, or `--from N --to N`) and
11+
* an inverted one (`--from T+1`, which defaults `--to` to `T`) both compose zero
12+
* steps. The human run answers that as an empty range: "this run replayed
13+
* nothing — that is not a finding that the metadata is canonical". The JSON run
14+
* answered it exactly like a clean replay — `applied: []`, `todos: []`,
15+
* `schemaValid: true`, exit 0 — so an upgrade script reading the JSON with a
16+
* wrong `--from` was told its metadata had been migrated.
17+
*
18+
* So the JSON answer now carries `replayedNothing` whenever no step ran, read
19+
* by the one function the human run renders. A run that replayed a step carries
20+
* no such member, and its payload keeps the shape it always had.
21+
*
22+
* ⛔ No expectation here hard-codes a major. Every range is derived from
23+
* `MIGRATION_MAJORS` and `PROTOCOL_MAJOR`, so the file stays true one major later.
24+
*
25+
* In-process over `MigrateMeta.run`: no process is spawned and no kernel is
26+
* booted, so this file sits in the `unit` tier by behaviour.
27+
*/
28+
29+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
30+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
31+
import { tmpdir } from 'node:os';
32+
import { join, resolve } from 'node:path';
33+
import { fileURLToPath } from 'node:url';
34+
import { stripVTControlCharacters } from 'node:util';
35+
import { MIGRATION_MAJORS, MIGRATION_SUPPORT_FLOOR } from '@objectstack/spec/migrations';
36+
import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
37+
import MigrateMeta from '../src/commands/migrate/meta.js';
38+
39+
const CLI_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..');
40+
41+
/** Where the chain ends when `--to` is not given, computed as the command computes it. */
42+
const TERMINUS = Math.max(PROTOCOL_MAJOR, ...MIGRATION_MAJORS);
43+
44+
/** One below the terminus: a range that holds the terminus's own step. */
45+
const BELOW = TERMINUS - 1;
46+
47+
/** A canonical stack: nothing in it is a retired spelling. */
48+
const CANONICAL_CONFIG = `
49+
export default {
50+
manifest: { id: 'com.example.replayed-nothing', name: 'Replayed Nothing', version: '1.0.0', type: 'app' },
51+
objects: [{ name: 'rn_ticket', label: 'Ticket', fields: { title: { type: 'text', label: 'Title' } } }],
52+
};
53+
`;
54+
55+
/** Every member a replay's payload carried before this change, in order (`hops` only under `--step`). */
56+
const REPLAY_KEYS = [
57+
'from', 'to', 'protocolVersion', 'applied', 'todos', 'absentTodos',
58+
'specChanges', 'schemaValid', 'dataMigrations', 'duration',
59+
];
60+
61+
interface Run {
62+
stdout: string;
63+
exitCode: number | undefined;
64+
}
65+
66+
let dir: string;
67+
const runs = new Map<string, Promise<Run>>();
68+
69+
/** Run the real command in-process once per distinct invocation, capturing stdout and any exit. */
70+
function runMeta(flags: string[]): Promise<Run> {
71+
const key = flags.join(' ');
72+
const hit = runs.get(key);
73+
if (hit) return hit;
74+
const started = (async (): Promise<Run> => {
75+
const out: string[] = [];
76+
const priorExitCode = process.exitCode;
77+
const write = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => {
78+
out.push(String(chunk));
79+
const done = rest.find((r) => typeof r === 'function') as (() => void) | undefined;
80+
done?.();
81+
return true;
82+
}) as never);
83+
const log = vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => { out.push(a.join(' ')); });
84+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
85+
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
86+
let exitCode: number | undefined;
87+
try {
88+
await MigrateMeta.run([join(dir, 'objectstack.config.ts'), ...flags], { root: CLI_ROOT });
89+
} catch (e: any) {
90+
if (typeof e?.oclif?.exit !== 'number') throw e;
91+
exitCode = e.oclif.exit;
92+
} finally {
93+
write.mockRestore();
94+
log.mockRestore();
95+
warn.mockRestore();
96+
error.mockRestore();
97+
if (exitCode === undefined && typeof process.exitCode === 'number' && process.exitCode !== 0) {
98+
exitCode = process.exitCode;
99+
}
100+
process.exitCode = priorExitCode;
101+
}
102+
return { stdout: stripVTControlCharacters(out.join('\n')), exitCode };
103+
})();
104+
runs.set(key, started);
105+
return started;
106+
}
107+
108+
const json = async (flags: string[]) => {
109+
const run = await runMeta([...flags, '--json']);
110+
return { exitCode: run.exitCode, payload: JSON.parse(run.stdout) };
111+
};
112+
113+
beforeAll(() => {
114+
dir = mkdtempSync(join(tmpdir(), 'os-meta-replayed-nothing-'));
115+
writeFileSync(join(dir, 'objectstack.config.ts'), CANONICAL_CONFIG);
116+
});
117+
118+
afterAll(() => {
119+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
120+
});
121+
122+
describe('os migrate meta --json: a run that replayed no step says so', () => {
123+
it('derives its ranges from a registry that has a step at the terminus', () => {
124+
// Anti-vacuity: without a step at the terminus `--from BELOW` is no replay,
125+
// and the control case below would read an empty range as a real one.
126+
expect(MIGRATION_MAJORS).toContain(TERMINUS);
127+
expect(BELOW).toBeGreaterThanOrEqual(MIGRATION_SUPPORT_FLOOR);
128+
});
129+
130+
it('an empty range at the terminus (`--from T`) carries the marker, with no wider range', async () => {
131+
const { exitCode, payload } = await json(['--from', String(TERMINUS)]);
132+
expect(exitCode).toBeUndefined();
133+
expect(payload.from).toBe(TERMINUS);
134+
expect(payload.to).toBe(TERMINUS);
135+
expect(payload.applied).toEqual([]);
136+
expect(payload.todos).toEqual([]);
137+
expect(payload.replayedNothing).toEqual({ reason: 'empty-range', widestRange: null });
138+
}, 120_000);
139+
140+
it('an explicit empty range (`--from T --to T`) carries the same marker', async () => {
141+
const { exitCode, payload } = await json(['--from', String(TERMINUS), '--to', String(TERMINUS)]);
142+
expect(exitCode).toBeUndefined();
143+
expect(payload.replayedNothing).toEqual({ reason: 'empty-range', widestRange: null });
144+
}, 120_000);
145+
146+
it('an inverted range (`--from T+1`) carries the marker, and its reason says the range is inverted', async () => {
147+
const { exitCode, payload } = await json(['--from', String(TERMINUS + 1)]);
148+
// Not refused: an inverted range is answered, never gated.
149+
expect(exitCode).toBeUndefined();
150+
expect(payload.from).toBe(TERMINUS + 1);
151+
expect(payload.to).toBe(TERMINUS);
152+
expect(payload.replayedNothing).toEqual({ reason: 'inverted-range', widestRange: null });
153+
}, 120_000);
154+
155+
it('an inverted range whose --to is below the terminus offers no wider range either', async () => {
156+
// `--from T --to T-1` and `--from T+1 --to T-1`: a "widest range" from a
157+
// `--from` at or above the terminus is itself empty or inverted, so there
158+
// is no wider range to name, whatever `--to` says.
159+
for (const from of [TERMINUS, TERMINUS + 1]) {
160+
const { exitCode, payload } = await json(['--from', String(from), '--to', String(BELOW)]);
161+
expect(exitCode).toBeUndefined();
162+
expect(payload.from).toBe(from);
163+
expect(payload.to).toBe(BELOW);
164+
expect(payload.replayedNothing).toEqual({ reason: 'inverted-range', widestRange: null });
165+
}
166+
}, 120_000);
167+
168+
it('an empty range below the terminus names what the widest range holds, as the human run does', async () => {
169+
const flags = ['--from', String(BELOW), '--to', String(BELOW)];
170+
const { exitCode, payload } = await json(flags);
171+
expect(exitCode).toBeUndefined();
172+
expect(payload.replayedNothing?.reason).toBe('empty-range');
173+
const widest = payload.replayedNothing?.widestRange;
174+
expect(widest).toMatchObject({ from: BELOW, to: TERMINUS });
175+
expect(typeof widest.applied).toBe('number');
176+
expect(typeof widest.manual).toBe('number');
177+
// One reading, two faces: the counts the human run prints are the JSON's.
178+
const human = (await runMeta(flags)).stdout;
179+
if (widest.applied === 0 && widest.manual === 0) {
180+
expect(human).toContain(`(protocol ${BELOW} → ${TERMINUS}) has nothing for this stack either`);
181+
} else {
182+
expect(human).toContain(
183+
`Protocol ${BELOW} → ${TERMINUS} has ${widest.applied} mechanical and ${widest.manual} manual change(s)`,
184+
);
185+
}
186+
}, 120_000);
187+
188+
it('the human run names the inverted range too', async () => {
189+
const { stdout, exitCode } = await runMeta(['--from', String(TERMINUS + 1)]);
190+
expect(exitCode).toBeUndefined();
191+
expect(stdout).toContain(`--from ${TERMINUS + 1} is above --to ${TERMINUS}`);
192+
expect(stdout).toContain('this run replayed nothing');
193+
}, 120_000);
194+
195+
it('a real replay (`--from T-1`) carries no marker and keeps the payload it always had', async () => {
196+
const { exitCode, payload } = await json(['--from', String(BELOW)]);
197+
expect(exitCode).toBeUndefined();
198+
expect(payload.from).toBe(BELOW);
199+
expect(payload.to).toBe(TERMINUS);
200+
expect(Object.keys(payload)).toEqual(REPLAY_KEYS);
201+
}, 120_000);
202+
});

‎packages/cli/test/normalized-call-sites.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,10 @@ const SITES: Readonly<Record<string, { reads: Reads; why: string }>> = {
140140
reads: 'packages',
141141
why: 'replays the conversion chain over the AUTHORED stack and over each `packages[i].manifest` body as a stack, reporting a body\'s edits under that body\'s own path, which `--write` traces to the `composeStacks` input that authored it. A folded union would report paths no source file holds. Measured on a two-package `preserve` app in `migrate-meta-composed.test.ts`.',
142142
},
143+
'migrate/meta.ts :: readReplayedNothing(normalized': {
144+
reads: 'packages',
145+
why: 'the `--json` face of the empty-range answer (#28947). Its only stack read is the widest-range probe, which replays the chain through `applyMetaMigrationsToPackages` (row above), so a package body\'s edits are counted. The human run reads the same function off `report.normalized`.',
146+
},
143147
'migrate/meta.ts :: planProtocolRange(normalized': {
144148
reads: 'top-level',
145149
why: 'reads the handshake range off `manifest`, where the load seam reads it (`AppPlugin`: `bundle.manifest || bundle`); a `preserve` artifact keeps `manifest` at the top level.',

0 commit comments

Comments
 (0)