Skip to content

Commit 5104a5d

Browse files
committed
fix(spec,cli): todos stays whole and absentTodos names a subset; four code-door entries lose their question; tiers is a carrier
Contract-review patch round: applyMetaMigrations().todos and each hop's todos keep every semantic entry again; absentTodos names the proven-absent entries as a subset of them (same objects). os migrate meta lists todos minus absentTodos. The four CEL/RLS predicate entries whose surface also names a direct evaluator or compiler caller lose relevantWhen (batch 25). A declared tiers preset answers unknown like plugins. Docs: --all in the flags table. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848
1 parent de8ccbc commit 5104a5d

13 files changed

Lines changed: 204 additions & 141 deletions

‎.changeset/22072-migrate-meta-relevance-predicate.md‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,18 @@
88
Clause-②: yes
99

1010
- **`SemanticMigration.relevantWhen`** (`@objectstack/spec/migrations`) is a new optional field. It holds a structured question over the loaded stack, `{ kind: 'stack-declares', keys: [...] }`: does the stack declare anything under one of these top-level keys? A key's value in a `packages[].manifest` body counts the same as a top-level value. The question is closed and named. It is never free text, and it never matches against the prose of `surface`. The new types `SemanticRelevance`, `StackDeclaresRelevance` and `SemanticRelevanceKey` are exported beside `SemanticMigration`.
11-
- **`applyMetaMigrations`** asks each entry's question of the stack it is given and of every hop checkpoint. It returns the new `absentTodos`, on the chain result and on each hop, with the entries whose question answered `absent` in all of them. `todos` keeps every other entry. Together the two arrays hold every semantic entry of every hop crossed, each once and in chain order. Entries move only on a positive proof. These cases answer `unknown` and keep the entry listed:
12-
- a value the question cannot read (a function, a promise, a scalar, a getter that throws);
13-
- a stack that is not a plain object;
14-
- any `plugins` / `devPlugins` entry, since a plugin can register metadata the stack does not show.
11+
- **`applyMetaMigrations`** asks each entry's question of the stack it is given and of every hop checkpoint.
12+
- **`todos` is unchanged.** `MigrationChainResult.todos` and `MigrationHopResult.todos` still hold every semantic entry of every hop crossed, whatever the stack holds, as before.
13+
- **`absentTodos` is a new required member** of `MigrationChainResult` and `MigrationHopResult`. It names the subset of `todos` whose question answered `absent` in all of them: the same objects, in chain order. Code that only reads a chain result needs no change. Code that builds one of these two interfaces itself must now supply `absentTodos` (an empty array when nothing is proven absent).
14+
- **Only a positive proof names an entry.** These cases answer `unknown` and leave it off `absentTodos`:
15+
- a value the question cannot read (a function, a promise, a scalar, a getter that throws);
16+
- a stack that is not a plain object;
17+
- any `plugins`, `devPlugins` or `tiers` entry, since a plugin, or the platform plugins a tier preset loads, can register metadata the stack does not show.
1518

16-
An entry that judges a conversion which applied an edit in the same run also stays listed. A consumer that reads only `todos` gets the entries this stack may owe. The rest are in `absentTodos`.
17-
- **The first batch is 29 entries** (5 from protocol 17, 24 from protocol 18). Each one's surface lives only under named top-level stack keys: `analyticsCubes`, `apis`, `jobs`, `mappings`, `hooks`, `agents`, `tools`, `dashboards` (with `reports` and `pages` for the chart-config entry), `datasets`, `permissions` and `sharingRules`. Each entry was also checked to confirm that its acceptance criteria send the author to no stored row and no runtime door. Every other entry is listed exactly as before.
18-
- **`os migrate meta`** prints one line after the listed notices. It counts the entries proven absent and names `--all`. A second line says that the proof covers the stack this run loaded, and not metadata a deployment stores. `--all` prints each of those entries in full, with the keys it was proven absent under. `--json` always carries them under `absentTodos`, and under `hops[].absentTodos` with `--step`. `--step` adds a `not listed` count to each hop line. A run whose only notices are proven absent still writes `--out`.
19+
An entry that judges a conversion which applied an edit in the same run is never named either.
20+
- **The first batch is 25 entries** (5 from protocol 17, 20 from protocol 18). Each one's surface lives only under named top-level stack keys: `analyticsCubes`, `apis`, `jobs`, `mappings`, `hooks`, `agents`, `tools`, `dashboards` (with `reports` and `pages` for the chart-config entry), `datasets`, `permissions` and `sharingRules`. None of them names a code door. Each entry was also checked to confirm that its acceptance criteria send the author to no stored row and no runtime door. Every other entry is never named absent, so it is listed exactly as before.
21+
- **`os migrate meta`** lists `todos` minus `absentTodos`. After the listed notices it prints one line that counts the entries proven absent and names `--all`. A second line says that the proof covers the stack this run loaded, and not metadata a deployment stores.
22+
- `--all` prints each of those entries in full, with the keys it was proven absent under.
23+
- `--json` keeps `todos` whole and adds `absentTodos`, plus `hops[].absentTodos` with `--step`.
24+
- `--step` reports each hop's listed count and adds a `not listed` count to the hop line.
25+
- A run whose only notices are proven absent still writes `--out`.

‎content/docs/upgrading.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ Useful flags:
215215
| Flag | What it does |
216216
| :--- | :--- |
217217
| `--step` | Report each major's hop separately, so a failure bisects to the exact major |
218+
| `--all` | Also list, in full, the manual changes whose surface the command proved absent from your stack — by default they are only counted. `--json` always reports them in `todos` and names them in `absentTodos` |
218219
| `--out migrated.stack.json` | Also write the migrated stack as a JSON snapshot — the only file the command writes |
219220
| `--to 17` | Stop at an intermediate major instead of this runtime's |
220221
| `--json` | Machine-readable output, for CI or an agent |

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

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@
1111
*
1212
* ① the verdict, and every schema refusal left after the chain;
1313
* ② the applied mechanical edits;
14-
* ③ the semantic notices the stack may owe (`todos`);
15-
* ④ the notices proven absent from the stack (`absentTodos`) — counted on one
16-
* line by default, listed in full under `--all` (the ABSENT pins below).
14+
* ③ the semantic notices the stack may owe (`todos` minus `absentTodos`);
15+
* ④ the notices proven absent from the stack (`absentTodos`, a subset of
16+
* `todos`) — counted on one line by default, listed in full under `--all`
17+
* (the ABSENT pins below).
1718
*
1819
* ## Two kinds of pin, kept apart on purpose
1920
*
@@ -176,9 +177,19 @@ function blockLines(t: MigrationTodo): string[] {
176177
].join('\n').split('\n');
177178
}
178179

180+
/**
181+
* The notices ③ lists: every entry of `todos` the chain did not name in
182+
* `absentTodos` — written from the chain's data by key, not by the printer's
183+
* own filter.
184+
*/
185+
function listedOf(result: MigrationChainResult): MigrationTodo[] {
186+
const absent = new Set(result.absentTodos.map((t) => `${t.toMajor}:${t.id}`));
187+
return result.todos.filter((t) => !absent.has(`${t.toMajor}:${t.id}`));
188+
}
189+
179190
/** The lines ③ prints — every listed notice, in chain order. */
180191
function noticeLines(result: MigrationChainResult): string[] {
181-
return result.todos.flatMap(blockLines);
192+
return listedOf(result).flatMap(blockLines);
182193
}
183194

184195
/** ④ without `--all`: the line counting the proven-absent notices, and the line scoping the proof. */
@@ -258,7 +269,7 @@ describe('each group opens with one header line that counts it', () => {
258269
` Applied ${result.applied.length} mechanical change(s):`,
259270
]);
260271
expect(lines.filter((l) => SEMANTIC_HEADER_RE.test(l))).toEqual([
261-
` ${result.todos.length} manual change(s) require your judgment:`,
272+
` ${listedOf(result).length} manual change(s) require your judgment:`,
262273
]);
263274
});
264275
});
@@ -275,8 +286,8 @@ describe('no notice, edit or refusal is dropped, merged or reworded (SET)', () =
275286
const expected = noticeLines(result);
276287
// Anti-vacuity: more than one hop's catalogue, and at least one notice
277288
// whose prose spans several terminal lines.
278-
expect(new Set(result.todos.map((t) => t.toMajor)).size).toBeGreaterThan(1);
279-
expect(expected.length).toBeGreaterThan(result.todos.length * 3);
289+
expect(new Set(listedOf(result).map((t) => t.toMajor)).size).toBeGreaterThan(1);
290+
expect(expected.length).toBeGreaterThan(listedOf(result).length * 3);
280291
const header = indexOf(lines, SEMANTIC_HEADER_RE);
281292
const printedNotices = lines.slice(header + 1, header + 1 + expected.length);
282293
expect(printedNotices).toEqual(expected);
@@ -440,11 +451,11 @@ describe('an applied edit a semantic entry judges prints that entry beside it, m
440451
it('keeps every semantic entry in ③ — the judge included — with the chain\'s count and bytes', () => {
441452
const { result, lines } = run(DECISION_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS);
442453
const header = indexOf(lines, SEMANTIC_HEADER_RE);
443-
expect(lines[header]).toBe(` ${result.todos.length} manual change(s) require your judgment:`);
454+
expect(lines[header]).toBe(` ${listedOf(result).length} manual change(s) require your judgment:`);
444455
const expected = noticeLines(result);
445456
expect(lines.slice(header + 1, header + 1 + expected.length)).toEqual(expected);
446457
const entries = lines.slice(header + 1).filter((l) => /^ {4}⚠ \[protocol \d+\] /.test(l));
447-
expect(entries).toHaveLength(result.todos.length);
458+
expect(entries).toHaveLength(listedOf(result).length);
448459
const judge = todoOf(result, DECISION_JUDGE);
449460
expect(entries).toContain(` ⚠ [protocol ${judge.toMajor}] ${judge.surface} → ${judge.replacement}`);
450461
});
@@ -461,7 +472,7 @@ describe('an applied edit a semantic entry judges prints that entry beside it, m
461472
}
462473
runs.set(a.conversionId, (runs.get(a.conversionId) ?? 0) + 1);
463474
}
464-
const reviews = result.todos.flatMap((t) =>
475+
const reviews = listedOf(result).flatMap((t) =>
465476
(t.conversionIds ?? []).filter((id) => runs.has(id)).map((id) => reviewLine(t, runs.get(id)!)),
466477
);
467478
expect(reviews.length, 'anti-vacuity: the stack exercises a link').toBeGreaterThan(0);
@@ -543,7 +554,7 @@ describe('the notices proven absent leave ③ for one counting line, and --all l
543554
expect(lines.filter((l) => ABSENT_SCOPE_RE.test(l))).toHaveLength(1);
544555
expect(indexOf(lines, ABSENT_COUNT_RE)).toBeGreaterThan(indexOf(lines, SEMANTIC_HEADER_RE));
545556

546-
const headlines = new Set(result.todos.map((t) => blockLines(t)[0]));
557+
const headlines = new Set(listedOf(result).map((t) => blockLines(t)[0]));
547558
for (const t of result.absentTodos) {
548559
const headline = blockLines(t)[0]!;
549560
if (headlines.has(headline)) continue; // a listed entry sharing the headline prints it legitimately
@@ -562,26 +573,31 @@ describe('the notices proven absent leave ③ for one counting line, and --all l
562573
expect(lines.filter((l) => ABSENT_COUNT_RE.test(l) || ABSENT_SCOPE_RE.test(l))).toEqual([]);
563574
// ③ is unchanged by --all: the same header and the same notices.
564575
const semantic = indexOf(lines, SEMANTIC_HEADER_RE);
565-
expect(lines[semantic]).toBe(` ${result.todos.length} manual change(s) require your judgment:`);
576+
expect(lines[semantic]).toBe(` ${listedOf(result).length} manual change(s) require your judgment:`);
566577
expect(lines.slice(semantic + 1, semantic + 1 + noticeLines(result).length)).toEqual(noticeLines(result));
567578
});
568579

569-
it('③ and ④ together hold every semantic entry of every hop crossed, each exactly once', () => {
570-
const { result } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS, undefined, { all: true });
580+
it('③ and ④ together print every semantic entry of every hop crossed, each exactly once', () => {
581+
const { result, lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS, undefined, { all: true });
571582
const crossed = result.hops.flatMap((h) => MIGRATIONS_BY_MAJOR[h.toMajor]!.semantic.map((s) => `${h.toMajor}:${s.id}`));
572-
const reported = [...result.todos, ...result.absentTodos].map((t) => `${t.toMajor}:${t.id}`);
573-
expect(reported.slice().sort()).toEqual(crossed.slice().sort());
574-
expect(new Set(reported).size).toBe(reported.length);
583+
// The chain's `todos` is still the whole catalogue; ④ is a subset of it.
584+
expect(result.todos.map((t) => `${t.toMajor}:${t.id}`)).toEqual(crossed);
585+
for (const t of result.absentTodos) expect(result.todos).toContain(t);
586+
const printed = [...listedOf(result), ...result.absentTodos].map((t) => `${t.toMajor}:${t.id}`);
587+
expect(printed.slice().sort()).toEqual(crossed.slice().sort());
588+
expect(new Set(printed).size).toBe(printed.length);
589+
// …and the terminal shows each headline as often as the chain has entries carrying it.
590+
const headlineCount = (h: string) => lines.filter((l) => l === h).length;
591+
for (const t of result.todos) {
592+
const h = blockLines(t)[0]!;
593+
expect(headlineCount(h), t.id).toBe(result.todos.filter((u) => blockLines(u)[0] === h).length);
594+
}
575595
// Only an entry carrying a structured question can be in ④.
576596
for (const t of result.absentTodos) expect(t.relevantWhen, `${t.id} carries relevantWhen`).toBeDefined();
577597
});
578598

579599
it('prints no ④ at all when the chain proved nothing absent', () => {
580-
const { lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS, (r) => ({
581-
...r,
582-
todos: [...r.todos, ...r.absentTodos],
583-
absentTodos: [],
584-
}));
600+
const { lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS, (r) => ({ ...r, absentTodos: [] }));
585601
expect(lines.filter((l) => ABSENT_COUNT_RE.test(l) || ABSENT_SCOPE_RE.test(l) || ABSENT_HEADER_RE.test(l))).toEqual([]);
586602
});
587603

@@ -592,7 +608,10 @@ describe('the notices proven absent leave ③ for one counting line, and --all l
592608
CANONICAL_STACK,
593609
MIGRATION_SUPPORT_FLOOR,
594610
TERMINUS,
595-
(r) => ({ ...r, todos: [], absentTodos: [...r.todos, ...r.absentTodos].filter((t) => t.relevantWhen) }),
611+
(r) => {
612+
const questioned = r.todos.filter((t) => t.relevantWhen);
613+
return { ...r, todos: questioned, absentTodos: questioned };
614+
},
596615
{ out },
597616
);
598617
expect(result.applied).toEqual([]);

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

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,8 @@ function printEmptyRangeAnswer(
210210
}
211211

212212
const wider = applyMetaMigrations(stack, fromMajor, CHAIN_TERMINUS_MAJOR);
213-
if (wider.applied.length === 0 && wider.todos.length === 0) {
213+
const widerListed = listedTodos(wider.todos, wider.absentTodos);
214+
if (wider.applied.length === 0 && widerListed.length === 0) {
214215
printInfo(
215216
`The widest range this build carries (protocol ${fromMajor} → ${CHAIN_TERMINUS_MAJOR}) has `
216217
+ 'nothing for this stack either.',
@@ -220,11 +221,23 @@ function printEmptyRangeAnswer(
220221

221222
printWarning(
222223
`Protocol ${fromMajor} → ${CHAIN_TERMINUS_MAJOR} has ${wider.applied.length} mechanical and `
223-
+ `${wider.todos.length} manual change(s) for this stack — re-run with `
224+
+ `${widerListed.length} manual change(s) for this stack — re-run with `
224225
+ `\`--to ${CHAIN_TERMINUS_MAJOR}\` to list them.`,
225226
);
226227
}
227228

229+
/**
230+
* The semantic TODOs the default list prints: every entry of `todos` except
231+
* the ones `absentTodos` names (ADR-0087 D3 — an entry leaves the default list
232+
* only on the chain's structured, stack-derived proof). Matched by hop and id,
233+
* so a copy of a TODO is recognised as well as the chain's own object.
234+
*/
235+
function listedTodos(todos: readonly MigrationTodo[], absentTodos: readonly MigrationTodo[]): MigrationTodo[] {
236+
if (absentTodos.length === 0) return [...todos];
237+
const absent = new Set(absentTodos.map((t) => `${t.toMajor}:${t.id}`));
238+
return todos.filter((t) => !absent.has(`${t.toMajor}:${t.id}`));
239+
}
240+
228241
/** Print the data-migration advice — the last thing a crossing upgrade sees. */
229242
function printPendingDataMigrations(pending: readonly PendingDataMigration[]): void {
230243
if (pending.length === 0) return;
@@ -348,7 +361,7 @@ function judgesByConversion(todos: readonly MigrationTodo[]): ReadonlyMap<string
348361
* and lines do not change (ADR-0087 D3, "never silence").
349362
*/
350363
function printAppliedEdits(result: MigrationChainResult): void {
351-
const judges = judgesByConversion(result.todos);
364+
const judges = judgesByConversion(listedTodos(result.todos, result.absentTodos));
352365
console.log(chalk.bold(` Applied ${result.applied.length} mechanical change(s):`));
353366
let run = 0;
354367
for (const [i, a] of result.applied.entries()) {
@@ -429,7 +442,8 @@ function printAbsentNotices(result: MigrationChainResult, all: boolean): void {
429442
* the semantic entry that judges an edit printed beside it, marked review
430443
* (see {@link printAppliedEdits});
431444
* ③ the SEMANTIC notices — every semantic entry of every hop crossed that the
432-
* chain could not prove irrelevant to this stack (`todos`);
445+
* chain could not prove irrelevant to this stack (`todos` minus
446+
* `absentTodos`, see {@link listedTodos});
433447
* ④ the ABSENT notices — the entries whose structured relevance question
434448
* (`SemanticMigration.relevantWhen`) the chain answered `absent` over this
435449
* stack (`absentTodos`): one line counting them, or with `--all` each one
@@ -461,9 +475,7 @@ export function printMigrationReport(report: MigrationReport): void {
461475
// ① The verdict and the refusals — first, whatever else the run found.
462476
printSchemaVerdict(report);
463477

464-
// A run whose only notices are proven absent is NOT this branch: it falls
465-
// through, so ④ counts them and `--out` is still written.
466-
if (result.applied.length === 0 && result.todos.length === 0 && result.absentTodos.length === 0) {
478+
if (result.applied.length === 0 && result.todos.length === 0) {
467479
// ⚠️ Two different facts wear the same empty result, and only one of them
468480
// is good news (#17134). A range that CONTAINS steps and rewrote nothing
469481
// is a finding about the metadata. A range that contains no step at all
@@ -494,16 +506,18 @@ export function printMigrationReport(report: MigrationReport): void {
494506
for (const hop of result.hops) {
495507
console.log(chalk.bold(` ── protocol ${hop.toMajor} ──`));
496508
console.log(chalk.dim(` ${hop.rationale}`));
509+
const listed = listedTodos(hop.todos, hop.absentTodos).length;
497510
const absent = hop.absentTodos.length > 0 ? `, ${hop.absentTodos.length} not listed (surface absent)` : '';
498-
console.log(chalk.dim(` ${hop.applied.length} mechanical, ${hop.todos.length} manual${absent}`));
511+
console.log(chalk.dim(` ${hop.applied.length} mechanical, ${listed} manual${absent}`));
499512
}
500513
console.log('');
501514
}
502515

503516
// ③ The semantic TODOs (delegated to the agent — never auto-applied).
504-
if (result.todos.length > 0) {
505-
console.log(chalk.bold(chalk.yellow(` ${result.todos.length} manual change(s) require your judgment:`)));
506-
for (const t of result.todos) printNotice(t);
517+
const listed = listedTodos(result.todos, result.absentTodos);
518+
if (listed.length > 0) {
519+
console.log(chalk.bold(chalk.yellow(` ${listed.length} manual change(s) require your judgment:`)));
520+
for (const t of listed) printNotice(t);
507521
console.log('');
508522
}
509523

@@ -598,7 +612,7 @@ export default class MigrateMeta extends Command {
598612
all: Flags.boolean({
599613
description:
600614
'Also list, in full, the manual changes whose surfaces this stack provably does not declare '
601-
+ '(by default they are only counted). --json always carries them, under absentTodos.',
615+
+ '(by default they are only counted). --json always reports them in todos and names them in absentTodos.',
602616
default: false,
603617
exclusive: ['stored'],
604618
}),
@@ -732,9 +746,9 @@ export default class MigrateMeta extends Command {
732746
protocolVersion: PROTOCOL_VERSION,
733747
applied: result.applied,
734748
todos: result.todos,
735-
// The semantic entries the chain proved irrelevant to this stack
736-
// (ADR-0087 D3): reported beside `todos`, never dropped, so a
737-
// machine consumer reaches them without `--all`.
749+
// `todos` keeps every semantic entry; `absentTodos` NAMES the
750+
// subset the chain proved irrelevant to this stack (ADR-0087 D3),
751+
// so a machine consumer reads the default list as the difference.
738752
absentTodos: result.absentTodos,
739753
hops: flags.step
740754
? result.hops.map((h) => ({

0 commit comments

Comments
 (0)