diff --git a/.changeset/22072-migrate-meta-relevance-predicate.md b/.changeset/22072-migrate-meta-relevance-predicate.md new file mode 100644 index 00000000000..e7b53ff9432 --- /dev/null +++ b/.changeset/22072-migrate-meta-relevance-predicate.md @@ -0,0 +1,25 @@ +--- +"@objectstack/spec": minor +"@objectstack/cli": minor +--- + +`os migrate meta` stops listing semantic notices whose surface the stack provably does not declare. It counts them instead, and `--all` lists them in full. + +Clause-②: yes + +- **`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`. +- **`applyMetaMigrations`** asks each entry's question of the stack it is given and of every hop checkpoint. + - **`todos` is unchanged.** `MigrationChainResult.todos` and `MigrationHopResult.todos` still hold every semantic entry of every hop crossed, whatever the stack holds, as before. + - **`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). + - **Only a positive proof names an entry.** These cases answer `unknown` and leave it off `absentTodos`: + - a value the question cannot read (a function, a promise, a scalar, a getter that throws); + - a stack that is not a plain object; + - 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. + + An entry that judges a conversion which applied an edit in the same run is never named either. +- **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. +- **`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. + - `--all` prints each of those entries in full, with the keys it was proven absent under. + - `--json` keeps `todos` whole and adds `absentTodos`, plus `hops[].absentTodos` with `--step`. + - `--step` reports each hop's listed count, and adds a `not listed` count to the hop line when that count is not zero. + - A run whose only notices are proven absent still writes `--out`. diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx index 5a7f15c68b5..56282af922c 100644 --- a/content/docs/upgrading.mdx +++ b/content/docs/upgrading.mdx @@ -215,6 +215,7 @@ Useful flags: | Flag | What it does | | :--- | :--- | | `--step` | Report each major's hop separately, so a failure bisects to the exact major | +| `--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` | | `--out migrated.stack.json` | Also write the migrated stack as a JSON snapshot | | `--write` | Write the mechanical changes into your source files, where each can be traced to one literal; list the rest with the reason (see below) | | `--to 17` | Stop at an intermediate major instead of this runtime's | diff --git a/packages/cli/src/commands/migrate/meta.report-order.test.ts b/packages/cli/src/commands/migrate/meta.report-order.test.ts index 86a2e855510..407fda198dc 100644 --- a/packages/cli/src/commands/migrate/meta.report-order.test.ts +++ b/packages/cli/src/commands/migrate/meta.report-order.test.ts @@ -3,15 +3,18 @@ /** * `os migrate meta` — the human report leads with what blocks the stack. * - * The chain hands the printer every semantic entry of every hop it crosses, - * whatever the stack holds, so the semantic group is the whole catalogue of - * each major crossed — hundreds of notices. The report therefore prints three - * groups in the order an upgrader acts on them, each under one header line that - * counts it: + * The chain hands the printer every semantic entry of every hop it crosses — + * hundreds of notices — and proves only the entries carrying a structured + * relevance question (`relevantWhen`) irrelevant to the stack. The report + * therefore prints its groups in the order an upgrader acts on them, each + * under one header line that counts it: * * ① the verdict, and every schema refusal left after the chain; * ② the applied mechanical edits; - * ③ the semantic notices. + * ③ the semantic notices the stack may owe (`todos` minus `absentTodos`); + * ④ the notices proven absent from the stack (`absentTodos`, a subset of + * `todos`) — counted on one line by default, listed in full under `--all` + * (the ABSENT pins below). * * ## Two kinds of pin, kept apart on purpose * @@ -45,6 +48,9 @@ * EARLIER step replays, and none is authored yet. */ +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; import { stripVTControlCharacters } from 'node:util'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ALL_CONVERSIONS, ObjectStackDefinitionSchema, formatZodIssue, normalizeStackInput } from '@objectstack/spec'; @@ -126,6 +132,7 @@ function run( fromMajor: number, toMajor: number, amend: (result: MigrationChainResult) => MigrationChainResult = (r) => r, + options: { all?: boolean; out?: string } = {}, ): Run { const normalized = normalizeStackInput(stack, { convert: false }); const result = amend(applyMetaMigrations(normalized, fromMajor, toMajor)); @@ -137,6 +144,8 @@ function run( refusals: parsed.success ? [] : parsed.error.issues, dataMigrations: [], step: false, + all: options.all ?? false, + ...(options.out ? { out: options.out } : {}), elapsed: '1ms', }; printMigrationReport(report); @@ -159,15 +168,42 @@ function appliedLines(result: MigrationChainResult): string[] { return result.applied.map((a) => ` • ${a.path}: ${a.from} → ${a.to} (${a.conversionId})`); } -/** The lines a semantic notice prints — every field, split the way a terminal splits it. */ +/** The lines one semantic notice prints — every field, split the way a terminal splits it. */ +function blockLines(t: MigrationTodo): string[] { + return [ + ` ⚠ [protocol ${t.toMajor}] ${t.surface} → ${t.replacement}`, + ` why: ${t.reason}`, + ` verify: ${t.acceptanceCriteria}`, + ].join('\n').split('\n'); +} + +/** + * The notices ③ lists: every entry of `todos` the chain did not name in + * `absentTodos` — written from the chain's data by key, not by the printer's + * own filter. + */ +function listedOf(result: MigrationChainResult): MigrationTodo[] { + const absent = new Set(result.absentTodos.map((t) => `${t.toMajor}:${t.id}`)); + return result.todos.filter((t) => !absent.has(`${t.toMajor}:${t.id}`)); +} + +/** The lines ③ prints — every listed notice, in chain order. */ function noticeLines(result: MigrationChainResult): string[] { - return result.todos.flatMap((t) => - [ - ` ⚠ [protocol ${t.toMajor}] ${t.surface} → ${t.replacement}`, - ` why: ${t.reason}`, - ` verify: ${t.acceptanceCriteria}`, - ].join('\n').split('\n'), - ); + return listedOf(result).flatMap(blockLines); +} + +/** ④ without `--all`: the line counting the proven-absent notices, and the line scoping the proof. */ +const ABSENT_COUNT_RE = /^ {2}(\d+) more manual change\(s\) not listed: their surfaces are absent from this stack \(run with --all to list them\)\.$/; +const ABSENT_SCOPE_RE = /^ {4}Absent is proven over the stack this run loaded; /; +/** ④ under `--all`: the header counting the group. */ +const ABSENT_HEADER_RE = /^ {2}(\d+) manual change\(s\) whose surfaces are absent from this stack \(listed by --all\):$/; + +/** The lines ④ prints under `--all` — each absent notice in full, then the keys it was proven absent under. */ +function absentListLines(result: MigrationChainResult): string[] { + return result.absentTodos.flatMap((t) => [ + ...blockLines(t), + ` absent: nothing is declared under ${(t.relevantWhen?.keys ?? []).map((k) => `\`${k}\``).join(' / ')}`, + ]); } /** The lines the refusal group prints — one `formatZodIssue` render per refusal. */ @@ -233,7 +269,7 @@ describe('each group opens with one header line that counts it', () => { ` Applied ${result.applied.length} mechanical change(s):`, ]); expect(lines.filter((l) => SEMANTIC_HEADER_RE.test(l))).toEqual([ - ` ${result.todos.length} manual change(s) require your judgment:`, + ` ${listedOf(result).length} manual change(s) require your judgment:`, ]); }); }); @@ -250,8 +286,8 @@ describe('no notice, edit or refusal is dropped, merged or reworded (SET)', () = const expected = noticeLines(result); // Anti-vacuity: more than one hop's catalogue, and at least one notice // whose prose spans several terminal lines. - expect(new Set(result.todos.map((t) => t.toMajor)).size).toBeGreaterThan(1); - expect(expected.length).toBeGreaterThan(result.todos.length * 3); + expect(new Set(listedOf(result).map((t) => t.toMajor)).size).toBeGreaterThan(1); + expect(expected.length).toBeGreaterThan(listedOf(result).length * 3); const header = indexOf(lines, SEMANTIC_HEADER_RE); const printedNotices = lines.slice(header + 1, header + 1 + expected.length); expect(printedNotices).toEqual(expected); @@ -273,6 +309,7 @@ describe('no notice, edit or refusal is dropped, merged or reworded (SET)', () = const { report, result, lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS); const accounted = [ ...lines.filter((l) => VERDICT_RE.test(l) || APPLIED_HEADER_RE.test(l) || SEMANTIC_HEADER_RE.test(l)), + ...lines.filter((l) => ABSENT_COUNT_RE.test(l) || ABSENT_SCOPE_RE.test(l)), ...refusalLines(report), ...appliedLines(result), ...noticeLines(result), @@ -414,11 +451,11 @@ describe('an applied edit a semantic entry judges prints that entry beside it, m it('keeps every semantic entry in ③ — the judge included — with the chain\'s count and bytes', () => { const { result, lines } = run(DECISION_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS); const header = indexOf(lines, SEMANTIC_HEADER_RE); - expect(lines[header]).toBe(` ${result.todos.length} manual change(s) require your judgment:`); + expect(lines[header]).toBe(` ${listedOf(result).length} manual change(s) require your judgment:`); const expected = noticeLines(result); expect(lines.slice(header + 1, header + 1 + expected.length)).toEqual(expected); const entries = lines.slice(header + 1).filter((l) => /^ {4}⚠ \[protocol \d+\] /.test(l)); - expect(entries).toHaveLength(result.todos.length); + expect(entries).toHaveLength(listedOf(result).length); const judge = todoOf(result, DECISION_JUDGE); expect(entries).toContain(` ⚠ [protocol ${judge.toMajor}] ${judge.surface} → ${judge.replacement}`); }); @@ -435,12 +472,13 @@ describe('an applied edit a semantic entry judges prints that entry beside it, m } runs.set(a.conversionId, (runs.get(a.conversionId) ?? 0) + 1); } - const reviews = result.todos.flatMap((t) => + const reviews = listedOf(result).flatMap((t) => (t.conversionIds ?? []).filter((id) => runs.has(id)).map((id) => reviewLine(t, runs.get(id)!)), ); expect(reviews.length, 'anti-vacuity: the stack exercises a link').toBeGreaterThan(0); const accounted = [ ...lines.filter((l) => VERDICT_RE.test(l) || APPLIED_HEADER_RE.test(l) || SEMANTIC_HEADER_RE.test(l)), + ...lines.filter((l) => ABSENT_COUNT_RE.test(l) || ABSENT_SCOPE_RE.test(l)), ...refusalLines(report), ...appliedLines(result), ...reviews, @@ -493,3 +531,96 @@ describe('an applied edit a semantic entry judges prints that entry beside it, m expect(reviewsUnder(lines, last)).toEqual([reviewLine(judge, length)]); }); }); + +/** + * ④ — the notices the chain PROVED irrelevant to the stack (`absentTodos`: an + * entry's structured `relevantWhen` question answered `absent` over the stack). + * ADR-0087 D3 lets such an entry leave ③, and only such an entry, so the pins + * hold both halves: by default ④ is one line that counts them and names + * `--all`, and under `--all` every one of them is printed in full — nothing the + * chain reported becomes unreachable from the terminal. + */ +describe('the notices proven absent leave ③ for one counting line, and --all lists them (ABSENT)', () => { + it('counts them on one line naming --all, after ③, and lists none of them by default', () => { + const { result, lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS); + // Anti-vacuity: the stack declares no analytics cube, so the chain proved + // some entries absent — and still left others listed. + expect(result.absentTodos.length).toBeGreaterThan(0); + expect(result.todos.length).toBeGreaterThan(0); + + const counts = lines.filter((l) => ABSENT_COUNT_RE.test(l)); + expect(counts).toHaveLength(1); + expect(Number(ABSENT_COUNT_RE.exec(counts[0]!)![1])).toBe(result.absentTodos.length); + expect(lines.filter((l) => ABSENT_SCOPE_RE.test(l))).toHaveLength(1); + expect(indexOf(lines, ABSENT_COUNT_RE)).toBeGreaterThan(indexOf(lines, SEMANTIC_HEADER_RE)); + + const headlines = new Set(listedOf(result).map((t) => blockLines(t)[0])); + for (const t of result.absentTodos) { + const headline = blockLines(t)[0]!; + if (headlines.has(headline)) continue; // a listed entry sharing the headline prints it legitimately + expect(lines, `${t.id} is counted, not listed`).not.toContain(headline); + } + }); + + it('lists every one of them under --all, in full and in chain order, after ③', () => { + const { result, lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS, undefined, { all: true }); + const header = indexOf(lines, ABSENT_HEADER_RE); + expect(header).toBeGreaterThan(indexOf(lines, SEMANTIC_HEADER_RE)); + expect(Number(ABSENT_HEADER_RE.exec(lines[header]!)![1])).toBe(result.absentTodos.length); + const expected = absentListLines(result); + expect(lines.slice(header + 1, header + 1 + expected.length)).toEqual(expected); + // The counting line belongs to the default only. + expect(lines.filter((l) => ABSENT_COUNT_RE.test(l) || ABSENT_SCOPE_RE.test(l))).toEqual([]); + // ③ is unchanged by --all: the same header and the same notices. + const semantic = indexOf(lines, SEMANTIC_HEADER_RE); + expect(lines[semantic]).toBe(` ${listedOf(result).length} manual change(s) require your judgment:`); + expect(lines.slice(semantic + 1, semantic + 1 + noticeLines(result).length)).toEqual(noticeLines(result)); + }); + + it('③ and ④ together print every semantic entry of every hop crossed, each exactly once', () => { + const { result, lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS, undefined, { all: true }); + const crossed = result.hops.flatMap((h) => MIGRATIONS_BY_MAJOR[h.toMajor]!.semantic.map((s) => `${h.toMajor}:${s.id}`)); + // The chain's `todos` is still the whole catalogue; ④ is a subset of it. + expect(result.todos.map((t) => `${t.toMajor}:${t.id}`)).toEqual(crossed); + for (const t of result.absentTodos) expect(result.todos).toContain(t); + const printed = [...listedOf(result), ...result.absentTodos].map((t) => `${t.toMajor}:${t.id}`); + expect(printed.slice().sort()).toEqual(crossed.slice().sort()); + expect(new Set(printed).size).toBe(printed.length); + // …and the terminal shows each headline as often as the chain has entries carrying it. + const headlineCount = (h: string) => lines.filter((l) => l === h).length; + for (const t of result.todos) { + const h = blockLines(t)[0]!; + expect(headlineCount(h), t.id).toBe(result.todos.filter((u) => blockLines(u)[0] === h).length); + } + // Only an entry carrying a structured question can be in ④. + for (const t of result.absentTodos) expect(t.relevantWhen, `${t.id} carries relevantWhen`).toBeDefined(); + }); + + it('prints no ④ at all when the chain proved nothing absent', () => { + const { lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS, (r) => ({ ...r, absentTodos: [] })); + expect(lines.filter((l) => ABSENT_COUNT_RE.test(l) || ABSENT_SCOPE_RE.test(l) || ABSENT_HEADER_RE.test(l))).toEqual([]); + }); + + it('a run whose only notices are proven absent still counts them and still writes --out', () => { + const out = join(mkdtempSync(join(tmpdir(), 'os-migrate-meta-absent-only-')), 'migrated.stack.json'); + try { + const { result, lines } = run( + CANONICAL_STACK, + MIGRATION_SUPPORT_FLOOR, + TERMINUS, + (r) => { + const questioned = r.todos.filter((t) => t.relevantWhen); + return { ...r, todos: questioned, absentTodos: questioned }; + }, + { out }, + ); + expect(result.applied).toEqual([]); + expect(result.absentTodos.length).toBeGreaterThan(0); + expect(lines.filter((l) => ABSENT_COUNT_RE.test(l))).toHaveLength(1); + expect(lines.some((l) => l.includes('Nothing to migrate'))).toBe(false); + expect(existsSync(out), 'the snapshot --out asked for is written').toBe(true); + } finally { + rmSync(dirname(out), { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/migrate/meta.ts b/packages/cli/src/commands/migrate/meta.ts index 20cadb91c14..c036012c63c 100644 --- a/packages/cli/src/commands/migrate/meta.ts +++ b/packages/cli/src/commands/migrate/meta.ts @@ -219,7 +219,8 @@ function printEmptyRangeAnswer( } const wider = applyMetaMigrations(stack, fromMajor, CHAIN_TERMINUS_MAJOR); - if (wider.applied.length === 0 && wider.todos.length === 0) { + const widerListed = listedTodos(wider.todos, wider.absentTodos); + if (wider.applied.length === 0 && widerListed.length === 0) { printInfo( `The widest range this build carries (protocol ${fromMajor} → ${CHAIN_TERMINUS_MAJOR}) has ` + 'nothing for this stack either.', @@ -229,11 +230,23 @@ function printEmptyRangeAnswer( printWarning( `Protocol ${fromMajor} → ${CHAIN_TERMINUS_MAJOR} has ${wider.applied.length} mechanical and ` - + `${wider.todos.length} manual change(s) for this stack — re-run with ` + + `${widerListed.length} manual change(s) for this stack — re-run with ` + `\`--to ${CHAIN_TERMINUS_MAJOR}\` to list them.`, ); } +/** + * The semantic TODOs the default list prints: every entry of `todos` except + * the ones `absentTodos` names (ADR-0087 D3 — an entry leaves the default list + * only on the chain's structured, stack-derived proof). Matched by hop and id, + * so a copy of a TODO is recognised as well as the chain's own object. + */ +function listedTodos(todos: readonly MigrationTodo[], absentTodos: readonly MigrationTodo[]): MigrationTodo[] { + if (absentTodos.length === 0) return [...todos]; + const absent = new Set(absentTodos.map((t) => `${t.toMajor}:${t.id}`)); + return todos.filter((t) => !absent.has(`${t.toMajor}:${t.id}`)); +} + /** Print the data-migration advice — the last thing a crossing upgrade sees. */ function printPendingDataMigrations(pending: readonly PendingDataMigration[]): void { if (pending.length === 0) return; @@ -384,6 +397,11 @@ export interface MigrationReport { dataMigrations: readonly PendingDataMigration[]; /** `--step`: a checkpoint per hop, between the applied edits and the semantic notices. */ step: boolean; + /** + * `--all`: list the notices the chain proved irrelevant (`absentTodos`) + * after the listed ones, instead of only counting them. + */ + all: boolean; /** `--out`, resolved — the snapshot is written here so its line keeps its place. */ out?: string; /** `--write`: what was written into the authored sources (absent without the flag). */ @@ -471,7 +489,7 @@ function judgesByConversion(todos: readonly MigrationTodo[]): ReadonlyMap `\`${k}\``).join(' / '); +} + +/** + * Group ④ — the semantic entries the chain PROVED irrelevant to this stack: + * each carries a structured relevance question (`relevantWhen`) that the chain + * answered `absent` over the stack it loaded and every checkpoint it made of it + * (`absentTodos`). + * + * By default one line counts them and names `--all`, and a second says what + * the proof covers: the definition this run loaded, not the rows a deployment + * stores. With `--all` each is printed in full, exactly as ③ prints a notice, + * followed by the keys it was proven absent under. Nothing reaches this group + * by matching prose, and nothing in it is unreachable (ADR-0087 D3). + */ +function printAbsentNotices(result: MigrationChainResult, all: boolean): void { + const count = result.absentTodos.length; + if (count === 0) return; + if (!all) { + console.log( + chalk.dim( + ` ${count} more manual change(s) not listed: their surfaces are absent from this stack ` + + '(run with --all to list them).', + ), + ); + console.log( + chalk.dim( + ' Absent is proven over the stack this run loaded; metadata a deployment stores ' + + '(Studio, the metadata API) is not read here.', + ), + ); + console.log(''); + return; + } + console.log(chalk.bold(` ${count} manual change(s) whose surfaces are absent from this stack (listed by --all):`)); + for (const t of result.absentTodos) { + printNotice(t); + console.log(chalk.dim(` absent: nothing is declared under ${absentKeysText(t)}`)); + } + console.log(''); +} + /** * The human report of an authored-source run, in the order an upgrader acts on * it, each group under one header line that counts it (ADR-0087 D3): @@ -499,27 +569,36 @@ function printAppliedEdits(result: MigrationChainResult): void { * ② the APPLIED mechanical edits — the diff the chain has already made, with * the semantic entry that judges an edit printed beside it, marked review * (see {@link printAppliedEdits}); - * ③ the SEMANTIC notices — every semantic entry of every hop crossed. + * ③ the SEMANTIC notices — every semantic entry of every hop crossed that the + * chain could not prove irrelevant to this stack (`todos` minus + * `absentTodos`, see {@link listedTodos}); + * ④ the ABSENT notices — the entries whose structured relevance question + * (`SemanticMigration.relevantWhen`) the chain answered `absent` over this + * stack (`absentTodos`): one line counting them, or with `--all` each one + * in full (see {@link printAbsentNotices}); + * ⑤ with `--write`, what was written into the authored sources and what was + * left (see {@link printWriteOutcome}) — mechanical changes only, so it + * reads `applied` and never ③ or ④. * * ## Why this order * - * The chain hands the printer every semantic entry of every hop it crosses, - * whatever the stack holds — `SemanticMigration` carries no predicate over the - * stack — so ③ is the whole catalogue of each major crossed, 242 notices for - * protocol 18. It used to be printed first and the verdict last, where it told - * the author to "resolve the manual changes above"; and the refusals that block - * the stack were printed nowhere. Measured on a real upgrade: 874 lines, whose - * 41 refusals were buried under 240 notices about surfaces the stack never used. + * The chain hands the printer every semantic entry of every hop it crosses — + * the whole catalogue of each major crossed, 300-odd notices for protocol 18 — + * and only a structured question proves one irrelevant. ③ used to be printed + * first and the verdict last, where it told the author to "resolve the manual + * changes above"; and the refusals that block the stack were printed nowhere. + * Measured on a real upgrade: 874 lines, whose 41 refusals were buried under + * 240 notices about surfaces the stack never used. * * ## What it must not do * * ⛔ Drop, filter, collapse or summarise a notice. ADR-0087 D3 is "never - * silence": an entry may leave ③ only on a structured, stack-derived proof that - * its surface is absent, and matching the prose of `surface` against the stack - * is not one. So the groups MOVE and nothing else does: every line ② and ③ - * printed before is printed after, byte-identical and in chain order. The one - * addition is ②'s review lines, each a copy of an entry ③ still prints. - * `--json` is untouched — its keys, its values and the order of its arrays. + * silence": an entry leaves ③ only on a structured, stack-derived proof that + * its surface is absent — the chain's `absentTodos`, which ④ always counts and + * `--all` always lists — and matching the prose of `surface` against the stack + * is never one. So every line ② and ③ printed is the chain's, byte-identical + * and in chain order. The one addition to them is ②'s review lines, each a + * copy of an entry ③ still prints. */ export function printMigrationReport(report: MigrationReport): void { const { result } = report; @@ -563,27 +642,29 @@ export function printMigrationReport(report: MigrationReport): void { for (const hop of result.hops) { console.log(chalk.bold(` ── protocol ${hop.toMajor} ──`)); console.log(chalk.dim(` ${hop.rationale}`)); - console.log(chalk.dim(` ${hop.applied.length} mechanical, ${hop.todos.length} manual`)); + const listed = listedTodos(hop.todos, hop.absentTodos).length; + const absent = hop.absentTodos.length > 0 ? `, ${hop.absentTodos.length} not listed (surface absent)` : ''; + console.log(chalk.dim(` ${hop.applied.length} mechanical, ${listed} manual${absent}`)); } console.log(''); } // ③ The semantic TODOs (delegated to the agent — never auto-applied). - if (result.todos.length > 0) { - console.log(chalk.bold(chalk.yellow(` ${result.todos.length} manual change(s) require your judgment:`))); - for (const t of result.todos) { - console.log(` ${chalk.yellow('⚠')} [protocol ${t.toMajor}] ${t.surface} → ${t.replacement}`); - console.log(chalk.dim(` why: ${t.reason}`)); - console.log(chalk.dim(` verify: ${t.acceptanceCriteria}`)); - } + const listed = listedTodos(result.todos, result.absentTodos); + if (listed.length > 0) { + console.log(chalk.bold(chalk.yellow(` ${listed.length} manual change(s) require your judgment:`))); + for (const t of listed) printNotice(t); console.log(''); } + // ④ The notices the chain proved irrelevant to this stack — counted, or listed under --all. + printAbsentNotices(result, report.all); + if (report.out) { writeStackSnapshot(report.out, result.stack); } - // ④ `--write`: the mechanical changes written into the sources, and the rest. + // ⑤ `--write`: the mechanical changes written into the sources, and the rest. if (report.write) printWriteOutcome(report.write, result.applied.length); printPendingDataMigrations(report.dataMigrations); @@ -637,6 +718,7 @@ export default class MigrateMeta extends Command { static override examples = [ `$ os migrate meta --from ${MIGRATION_SUPPORT_FLOOR}`, `$ os migrate meta --from ${MIGRATION_SUPPORT_FLOOR} --step`, + `$ os migrate meta --from ${MIGRATION_SUPPORT_FLOOR} --all`, `$ os migrate meta --from ${MIGRATION_SUPPORT_FLOOR} --to ${MIGRATION_SUPPORT_FLOOR + 1} --json`, `$ os migrate meta --from ${MIGRATION_SUPPORT_FLOOR} --out migrated.stack.json`, `$ os migrate meta --from ${MIGRATION_SUPPORT_FLOOR} --write`, @@ -670,6 +752,13 @@ export default class MigrateMeta extends Command { description: 'Write the migrated stack as a JSON snapshot to this path.', exclusive: ['stored'], }), + all: Flags.boolean({ + description: + 'Also list, in full, the manual changes whose surfaces this stack provably does not declare ' + + '(by default they are only counted). --json always reports them in todos and names them in absentTodos.', + default: false, + exclusive: ['stored'], + }), write: Flags.boolean({ description: 'Rewrite the authored source files in place for each mechanical change traced to one literal in one ' @@ -823,12 +912,17 @@ export default class MigrateMeta extends Command { protocolVersion: PROTOCOL_VERSION, applied: result.applied, todos: result.todos, + // `todos` keeps every semantic entry; `absentTodos` NAMES the + // subset the chain proved irrelevant to this stack (ADR-0087 D3), + // so a machine consumer reads the default list as the difference. + absentTodos: result.absentTodos, hops: flags.step ? result.hops.map((h) => ({ toMajor: h.toMajor, rationale: h.rationale, applied: h.applied, todos: h.todos, + absentTodos: h.absentTodos, })) : undefined, specChanges, @@ -867,6 +961,7 @@ export default class MigrateMeta extends Command { refusals: parsed.success ? [] : parsed.error.issues, dataMigrations, step: flags.step, + all: flags.all, ...(flags.out ? { out: resolve(flags.out) } : {}), ...(write ? { write } : {}), elapsed: timer.display(), diff --git a/packages/cli/test/migrate-meta-engine-guidance.test.ts b/packages/cli/test/migrate-meta-engine-guidance.test.ts index f6b04eb565b..ddc6aea620a 100644 --- a/packages/cli/test/migrate-meta-engine-guidance.test.ts +++ b/packages/cli/test/migrate-meta-engine-guidance.test.ts @@ -17,11 +17,13 @@ * stay, because an ADR lives in this repository. The whole printed * block is held, so `surface` is held as well as the three prose fields. * - * The chain reports every semantic entry of every hop it crosses, whatever the - * stack authors, so the fixture only has to be a real stack the command loads; - * it keeps the lookup and the virtual `formula` field the `engine-*` entries - * are about. The CLI replays the chain from the support floor to the highest - * major carrying a semantic entry. Each block is then located VERBATIM in what + * The chain reports every semantic entry of every hop it crosses, and the run + * passes `--all`, so the entries the chain proves absent from this stack (a + * `relevantWhen` question answered `absent`) are printed in full too — the + * same block, in group ④. So the fixture only has to be a real stack the + * command loads; it keeps the lookup and the virtual `formula` field the + * `engine-*` entries are about. The CLI replays the chain from the support + * floor to the highest major carrying a semantic entry. Each block is then located VERBATIM in what * the terminal printed, and that printed block must hold no `#` followed by * four or five digits. The file keeps the name it was given when `engine-*` * was the only covered family; the staged rewrites have since reached every @@ -352,8 +354,8 @@ function printedBlock(e: FamilyEntry): string { * A real stack for the command to load. It keeps the shapes the `engine-*` * entries are about — a relation a dotted path would follow, and a virtual * `formula` field no driver materialises a column for — though which blocks - * print does not depend on it: every semantic entry of a crossed hop is - * reported. + * print does not depend on it: under `--all` every semantic entry of a crossed + * hop is printed, listed or proven absent. */ const FAMILY_FIXTURE = ` export default { @@ -383,7 +385,7 @@ beforeAll(async () => { const toMajor = Math.max(...FAMILY.map((e) => e.toMajor)); const run = await execFileP( TSX, - [CLI, 'migrate', 'meta', '--from', String(MIGRATION_SUPPORT_FLOOR), '--to', String(toMajor)], + [CLI, 'migrate', 'meta', '--from', String(MIGRATION_SUPPORT_FLOOR), '--to', String(toMajor), '--all'], { cwd: dir, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, ); stdout = run.stdout; diff --git a/packages/cli/test/migrate-meta-out-snapshot.test.ts b/packages/cli/test/migrate-meta-out-snapshot.test.ts index 27ff9bdf102..16c431c9dcc 100644 --- a/packages/cli/test/migrate-meta-out-snapshot.test.ts +++ b/packages/cli/test/migrate-meta-out-snapshot.test.ts @@ -278,6 +278,7 @@ describe('printMigrationReport on a run with nothing to migrate', () => { refusals: parsed.success ? [] : parsed.error.issues, dataMigrations: [PROBE_DATA_MIGRATION], step: false, + all: false, out, write: EMPTY_WRITE, elapsed: '1ms', diff --git a/packages/spec/api-surface/migrations.json b/packages/spec/api-surface/migrations.json index 024adfcdcd5..f5f3ba59f1e 100644 --- a/packages/spec/api-surface/migrations.json +++ b/packages/spec/api-surface/migrations.json @@ -16,6 +16,8 @@ "RETIRED_KEYS_BY_MAJOR (const)", "ReleaseSurfaceDiff (interface)", "SemanticMigration (interface)", + "SemanticRelevance (type)", + "SemanticRelevanceKey (type)", "SpecChanges (type)", "SpecChangesSchema (const)", "SpecConverted (type)", @@ -30,6 +32,7 @@ "SpecSurfaceAddSchema (const)", "SpecSurfaceRemove (type)", "SpecSurfaceRemoveSchema (const)", + "StackDeclaresRelevance (interface)", "SurfaceDiff (interface)", "applyMetaMigrations (function)", "composeMigrationChain (function)", diff --git a/packages/spec/export-origins/migrations.json b/packages/spec/export-origins/migrations.json index 2e457314845..77adacde943 100644 --- a/packages/spec/export-origins/migrations.json +++ b/packages/spec/export-origins/migrations.json @@ -16,6 +16,8 @@ "RETIRED_KEYS_BY_MAJOR": "src/migrations/registry.ts#RETIRED_KEYS_BY_MAJOR (const)", "ReleaseSurfaceDiff": "src/migrations/spec-changes.ts#ReleaseSurfaceDiff (interface)", "SemanticMigration": "src/migrations/types.ts#SemanticMigration (interface)", + "SemanticRelevance": "src/migrations/types.ts#SemanticRelevance (type)", + "SemanticRelevanceKey": "src/migrations/types.ts#SemanticRelevanceKey (type)", "SpecChanges": "src/migrations/spec-changes.ts#SpecChanges (type)", "SpecChangesSchema": "src/migrations/spec-changes.ts#SpecChangesSchema (const)", "SpecConverted": "src/migrations/spec-changes.ts#SpecConverted (type)", @@ -30,6 +32,7 @@ "SpecSurfaceAddSchema": "src/migrations/spec-changes.ts#SpecSurfaceAddSchema (const)", "SpecSurfaceRemove": "src/migrations/spec-changes.ts#SpecSurfaceRemove (type)", "SpecSurfaceRemoveSchema": "src/migrations/spec-changes.ts#SpecSurfaceRemoveSchema (const)", + "StackDeclaresRelevance": "src/migrations/types.ts#StackDeclaresRelevance (interface)", "SurfaceDiff": "src/migrations/spec-changes.ts#SurfaceDiff (interface)", "applyMetaMigrations": "src/migrations/chain.ts#applyMetaMigrations (function)", "composeMigrationChain": "src/migrations/chain.ts#composeMigrationChain (function)", diff --git a/packages/spec/src/migrations/chain.ts b/packages/spec/src/migrations/chain.ts index bb7890c84dd..4776c104d69 100644 --- a/packages/spec/src/migrations/chain.ts +++ b/packages/spec/src/migrations/chain.ts @@ -22,6 +22,7 @@ import type { MigrationHopResult, MigrationStep, MigrationTodo, + SemanticRelevance, } from './types.js'; const CONVERSION_BY_ID: ReadonlyMap = new Map( @@ -42,6 +43,130 @@ export function composeMigrationChain( .map((m) => MIGRATIONS_BY_MAJOR[m]!); } +/** + * The answer to a {@link SemanticRelevance} question over a stack. Only + * `absent` names an entry in `absentTodos`; `unknown` keeps it off that list, + * so a question the evaluation cannot answer never reads as a proof. + */ +export type SemanticRelevanceVerdict = 'present' | 'absent' | 'unknown'; + +function isPlainDict(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** One collection value, as authored: an array, or the map form `defineStack` also accepts. */ +function collectionVerdict(value: unknown): SemanticRelevanceVerdict { + if (value === undefined) return 'absent'; + if (Array.isArray(value)) return value.length > 0 ? 'present' : 'absent'; + if (isPlainDict(value)) return Object.keys(value).length > 0 ? 'present' : 'absent'; + // A function, a promise, a class instance, a scalar or null: not a value the + // question can read, so it proves nothing either way. + return 'unknown'; +} + +/** Fold verdicts: any `present` wins, then any `unknown`; `absent` only when every one is. */ +function foldVerdicts(verdicts: Iterable): SemanticRelevanceVerdict { + let unknown = false; + for (const v of verdicts) { + if (v === 'present') return 'present'; + if (v === 'unknown') unknown = true; + } + return unknown ? 'unknown' : 'absent'; +} + +/** + * The `stack-declares` question over ONE stack: does it declare anything under + * any of `keys`, at the top level or inside an assembled `packages[].manifest` + * body? + * + * Conservative by construction (ADR-0087 D3, "never silence"): `absent` is a + * positive proof — the stack is a plain object, no `plugins` / `devPlugins` + * entry and no `tiers` preset could contribute what it does not show, every + * carrier of the keys was read, and none held anything. Whatever the evaluation cannot read answers + * `unknown`, and a read that throws (a getter, a proxy) answers `unknown` too. + */ +function stackDeclaresVerdict(stack: unknown, keys: readonly string[]): SemanticRelevanceVerdict { + if (!isPlainDict(stack)) return 'unknown'; + try { + const verdicts: SemanticRelevanceVerdict[] = keys.map((key) => collectionVerdict(stack[key])); + + // An assembled multi-package stack (`composeStacks(…, { manifest: 'preserve' })`) + // carries its collections in each package body rather than at the top level. + const packages = stack.packages; + if (packages !== undefined) { + if (!Array.isArray(packages)) { + verdicts.push('unknown'); + } else { + for (const entry of packages) { + if (!isPlainDict(entry) || !isPlainDict(entry.manifest)) { + verdicts.push('unknown'); + continue; + } + const body = entry.manifest; + for (const key of keys) verdicts.push(collectionVerdict(body[key])); + } + } + } + + // A plugin is handed to the kernel, not read by this chain, and it can + // register metadata of any type at boot; a `tiers` preset names platform + // plugins the host loads the same way. So while one is listed, no key can + // be proven absent from what this stack deploys (a key the stack visibly + // declares is still present). + for (const carrier of ['plugins', 'devPlugins', 'tiers']) { + if (collectionVerdict(stack[carrier]) !== 'absent') verdicts.push('unknown'); + } + + return foldVerdicts(verdicts); + } catch { + return 'unknown'; + } +} + +/** + * Evaluate a {@link SemanticRelevance} question over every stack the chain + * saw — the stack it was handed and each hop's checkpoint — and fold the + * answers: present in any of them is present, and the surface is `absent` only + * when it is absent from all of them. Reading every checkpoint keeps a key a + * conversion renames or removes from reading as absent on either side of it. + * + * Exported for the chain's own tests; not part of the published entry. + */ +export function semanticRelevanceVerdict( + relevance: SemanticRelevance, + stacks: readonly unknown[], +): SemanticRelevanceVerdict { + switch (relevance.kind) { + case 'stack-declares': + return stacks.length === 0 + ? 'unknown' + : foldVerdicts(stacks.map((s) => stackDeclaresVerdict(s, relevance.keys))); + default: + // A question this build does not know how to answer proves nothing. + return 'unknown'; + } +} + +/** + * Whether one semantic TODO is named in `absentTodos`: it carries a relevance + * question, it judges no conversion that applied an edit in this run (an + * applied edit is itself a proof its surface is there), and the question + * answers `absent` over every checkpoint. + * + * Exported for the chain's own tests; not part of the published entry. + */ +export function semanticTodoAbsent( + todo: MigrationTodo, + checkpoints: readonly unknown[], + appliedConversionIds: ReadonlySet, +): boolean { + if (!todo.relevantWhen) return false; + if (todo.conversionIds?.some((id) => appliedConversionIds.has(id))) return false; + return semanticRelevanceVerdict(todo.relevantWhen, checkpoints) === 'absent'; +} + /** Thrown when `--from N` is below the documented support floor. */ export class MigrationFloorError extends Error { constructor( @@ -64,6 +189,15 @@ export class MigrationFloorError extends Error { * are reported as {@link MigrationTodo}s, never auto-applied. Never throws on * stack content — only {@link MigrationFloorError} when `fromMajor` is * unsupported. + * + * Every semantic entry of every hop crossed is reported in `todos`, whatever + * the stack holds. `absentTodos` additionally names — as the same objects, a + * subset of `todos` in chain order — the entries whose `relevantWhen` question + * proves their surface absent from the stack and from every checkpoint the + * chain made of it (ADR-0087 D3: an entry may leave a printer's default list + * only on a structured, stack-derived proof). An entry that judges a + * conversion which applied an edit in this run is never named there, whatever + * its question answers. */ export function applyMetaMigrations( stack: Record, @@ -76,8 +210,7 @@ export function applyMetaMigrations( const steps = composeMigrationChain(fromMajor, toMajor); const applied: MigrationApplication[] = []; - const todos: MigrationTodo[] = []; - const hops: MigrationHopResult[] = []; + const replayed: Array<{ step: MigrationStep; stack: Record; applied: MigrationApplication[] }> = []; let current = stack; for (const step of steps) { @@ -99,18 +232,32 @@ export function applyMetaMigrations( }); }); } - const hopTodos: MigrationTodo[] = step.semantic.map((s) => ({ ...s, toMajor: step.toMajor })); - applied.push(...hopApplied); + replayed.push({ step, stack: current, applied: hopApplied }); + } + + // The semantic entries are judged once every hop has run, so a question is + // asked of the whole sequence of stacks the chain produced. + const checkpoints: readonly unknown[] = [stack, ...replayed.map((r) => r.stack)]; + const appliedConversionIds: ReadonlySet = new Set(applied.map((a) => a.conversionId)); + + const todos: MigrationTodo[] = []; + const absentTodos: MigrationTodo[] = []; + const hops: MigrationHopResult[] = replayed.map(({ step, stack: hopStack, applied: hopApplied }) => { + const hopTodos: MigrationTodo[] = step.semantic.map((s) => ({ ...s, toMajor: step.toMajor })); + // The same objects, never copies: `absentTodos` is a subset of `todos`. + const hopAbsent = hopTodos.filter((todo) => semanticTodoAbsent(todo, checkpoints, appliedConversionIds)); todos.push(...hopTodos); - hops.push({ + absentTodos.push(...hopAbsent); + return { toMajor: step.toMajor, rationale: step.rationale, - stack: current, + stack: hopStack, applied: hopApplied, todos: hopTodos, - }); - } + absentTodos: hopAbsent, + }; + }); - return { fromMajor, toMajor, stack: current, applied, todos, hops }; + return { fromMajor, toMajor, stack: current, applied, todos, absentTodos, hops }; } diff --git a/packages/spec/src/migrations/entries/semantic/17.dashboard-widget-compareto-offset.ts b/packages/spec/src/migrations/entries/semantic/17.dashboard-widget-compareto-offset.ts index cf10039e96b..e9b0b20c30d 100644 --- a/packages/spec/src/migrations/entries/semantic/17.dashboard-widget-compareto-offset.ts +++ b/packages/spec/src/migrations/entries/semantic/17.dashboard-widget-compareto-offset.ts @@ -27,4 +27,5 @@ export const entry: SemanticMigration = { + "(or `'previousYear'`), and `dimension` is named wherever the selection dates more than one " + 'time dimension. `objectstack validate` passes, and each affected widget renders a ' + '`__compare` column over the window its author intended.', + relevantWhen: { kind: 'stack-declares', keys: ['dashboards'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/17.declarative-apis-endpoints-live.ts b/packages/spec/src/migrations/entries/semantic/17.declarative-apis-endpoints-live.ts index 856a74deb02..f35a4e0842a 100644 --- a/packages/spec/src/migrations/entries/semantic/17.declarative-apis-endpoints-live.ts +++ b/packages/spec/src/migrations/entries/semantic/17.declarative-apis-endpoints-live.ts @@ -60,4 +60,5 @@ export const entry: SemanticMigration = { + '`inputMapping` on find/get/delete, or two endpoints claiming one METHOD + path); and ' + '(4) after publishing, each endpoint answers as you expect — an anonymous request to ' + 'a session-only endpoint returns 401 rather than data.', + relevantWhen: { kind: 'stack-declares', keys: ['apis'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/17.job-retry-policy-constraints-tightened.ts b/packages/spec/src/migrations/entries/semantic/17.job-retry-policy-constraints-tightened.ts index baeb4b686ee..2a4f0fa325b 100644 --- a/packages/spec/src/migrations/entries/semantic/17.job-retry-policy-constraints-tightened.ts +++ b/packages/spec/src/migrations/entries/semantic/17.job-retry-policy-constraints-tightened.ts @@ -21,4 +21,5 @@ export const entry: SemanticMigration = { + '`backoffMultiplier` below 1 remain, and each adjusted value was re-chosen knowing a ' + 'retry re-runs the handler with its writes and callouts. No job fails to register ' + 'with the retry-policy bound prescription.', + relevantWhen: { kind: 'stack-declares', keys: ['jobs'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts b/packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts index 9256726b679..0c04bdde69e 100644 --- a/packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts +++ b/packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts @@ -50,4 +50,5 @@ export const entry: SemanticMigration = { + 'rule needs a `criteria` predicate that names the same population, checked against a ' + 'representative record. Where a single business unit was meant, use `business_unit`; ' + '`unit_and_subordinates` is the subtree and grants strictly more.', + relevantWhen: { kind: 'stack-declares', keys: ['sharingRules'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts b/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts index 30db587880a..33440edd382 100644 --- a/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts @@ -65,4 +65,5 @@ export const entry: SemanticMigration = { + 'Deleting it without that decision leaves exactly the state the retirement exists to ' + 'end: a destructive tool nobody is approving, now without even the false flag to show ' + 'that somebody once meant to.', + relevantWhen: { kind: 'stack-declares', keys: ['tools'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.agent-memory-store-retired-and-limits-required.ts b/packages/spec/src/migrations/entries/semantic/18.agent-memory-store-retired-and-limits-required.ts index 24276c9a9bc..8a5437a769d 100644 --- a/packages/spec/src/migrations/entries/semantic/18.agent-memory-store-retired-and-limits-required.ts +++ b/packages/spec/src/migrations/entries/semantic/18.agent-memory-store-retired-and-limits-required.ts @@ -45,4 +45,5 @@ export const entry: SemanticMigration = { + 'declares `reflectionInterval` without an enabled `longTerm`. Every agent parses under the new ' + 'schema.', conversionIds: ['agent-memory-long-term-store-removed'], + relevantWhen: { kind: 'stack-declares', keys: ['agents'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.agent-structured-output-refused-members-retired.ts b/packages/spec/src/migrations/entries/semantic/18.agent-structured-output-refused-members-retired.ts index 7ae237f7187..fe2e35ad703 100644 --- a/packages/spec/src/migrations/entries/semantic/18.agent-structured-output-refused-members-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.agent-structured-output-refused-members-retired.ts @@ -45,4 +45,5 @@ export const entry: SemanticMigration = { + 'agent that relied on coercion declares the exact types in `schema` and a test turn returns an ' + 'answer that validates without conversion.', conversionIds: ['agent-structured-output-refused-members-removed'], + relevantWhen: { kind: 'stack-declares', keys: ['agents'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.analytics-cube-public-default-visible-enforced.ts b/packages/spec/src/migrations/entries/semantic/18.analytics-cube-public-default-visible-enforced.ts index 74391a43f22..dd5bec7c2b1 100644 --- a/packages/spec/src/migrations/entries/semantic/18.analytics-cube-public-default-visible-enforced.ts +++ b/packages/spec/src/migrations/entries/semantic/18.analytics-cube-public-default-visible-enforced.ts @@ -44,4 +44,5 @@ export const entry: SemanticMigration = { + 'to be queried) or keep it and confirm that `/analytics/meta` omits the cube and that a query ' + 'naming it answers 404 `CUBE_NOT_FOUND`. Every compiled artifact in use was built by ' + '`os compile` from this release or later.', + relevantWhen: { kind: 'stack-declares', keys: ['analyticsCubes'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.analytics-cube-single-granularity-default-enforced.ts b/packages/spec/src/migrations/entries/semantic/18.analytics-cube-single-granularity-default-enforced.ts index 462ff56c689..9264b21b75f 100644 --- a/packages/spec/src/migrations/entries/semantic/18.analytics-cube-single-granularity-default-enforced.ts +++ b/packages/spec/src/migrations/entries/semantic/18.analytics-cube-single-granularity-default-enforced.ts @@ -63,4 +63,5 @@ export const entry: SemanticMigration = { + 'beside a dimension over a joined object — or each one that did now groups by a dimension ' + 'without a single interval. A host that overrides `queryCapabilities` to raw SQL only either ' + 'adds an engine aggregate bridge or groups by no one-interval dimension.', + relevantWhen: { kind: 'stack-declares', keys: ['analyticsCubes'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.api-endpoint-cache-ttl-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.api-endpoint-cache-ttl-unit-in-key.ts index ce577a58cac..a9a9c81f834 100644 --- a/packages/spec/src/migrations/entries/semantic/18.api-endpoint-cache-ttl-unit-in-key.ts +++ b/packages/spec/src/migrations/entries/semantic/18.api-endpoint-cache-ttl-unit-in-key.ts @@ -28,4 +28,5 @@ export const entry: SemanticMigration = { + 'meant to cache for one minute reads `cacheTtlSeconds: 60`. A GET to the endpoint repeated ' + 'inside that window is answered from the cache, and one repeated after it reflects a record ' + 'changed in between.', + relevantWhen: { kind: 'stack-declares', keys: ['apis'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.chart-config-aria-retired.ts b/packages/spec/src/migrations/entries/semantic/18.chart-config-aria-retired.ts index 3092f381e07..8220a1d051f 100644 --- a/packages/spec/src/migrations/entries/semantic/18.chart-config-aria-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.chart-config-aria-retired.ts @@ -29,4 +29,5 @@ export const entry: SemanticMigration = { + '`description` conveying what that label was meant to announce, or the author has confirmed ' + 'the existing description does. With a screen reader, focusing the chart graphic announces ' + 'the description as its name.', + relevantWhen: { kind: 'stack-declares', keys: ['dashboards', 'reports', 'pages'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.cube-join-sql-and-relationship-retired.ts b/packages/spec/src/migrations/entries/semantic/18.cube-join-sql-and-relationship-retired.ts index c605af2612b..e54c82f928c 100644 --- a/packages/spec/src/migrations/entries/semantic/18.cube-join-sql-and-relationship-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.cube-join-sql-and-relationship-retired.ts @@ -58,4 +58,5 @@ export const entry: SemanticMigration = { + 'derived ON clause reads, so a join keyed after the object it REACHES never resolved at all. ' + 'Nothing else regresses: `joins..name` is unchanged, and it is what both the joined ' + 'table and the per-object RLS/tenant read scope are resolved from.', + relevantWhen: { kind: 'stack-declares', keys: ['analyticsCubes'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.cube-member-inner-name-retired.ts b/packages/spec/src/migrations/entries/semantic/18.cube-member-inner-name-retired.ts index 856fe218d69..c8878411656 100644 --- a/packages/spec/src/migrations/entries/semantic/18.cube-member-inner-name-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.cube-member-inner-name-retired.ts @@ -33,4 +33,5 @@ export const entry: SemanticMigration = { + 'author has either kept the key (nothing else changes) or re-keyed the member to the intended ' + 'name and updated every query, dashboard and report that names `.`. ' + '`GET /api/v1/analytics/meta` lists each member as `.` exactly as before the upgrade.', + relevantWhen: { kind: 'stack-declares', keys: ['analyticsCubes'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.cube-member-sql-expression-retired.ts b/packages/spec/src/migrations/entries/semantic/18.cube-member-sql-expression-retired.ts index 7016e12c8f1..28b940b8576 100644 --- a/packages/spec/src/migrations/entries/semantic/18.cube-member-sql-expression-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.cube-member-sql-expression-retired.ts @@ -53,4 +53,5 @@ export const entry: SemanticMigration = { + 'ratio: the same value divided by 100 when the expression returned percentage points). ' + 'Every dashboard, report or saved query that named the cube member now names the dataset ' + 'measure. A cube member that aggregates a column parses byte-identically to before.', + relevantWhen: { kind: 'stack-declares', keys: ['analyticsCubes'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.cube-metric-expression-types-retired.ts b/packages/spec/src/migrations/entries/semantic/18.cube-metric-expression-types-retired.ts index 90f459e620f..4a5ffcf6c4d 100644 --- a/packages/spec/src/migrations/entries/semantic/18.cube-metric-expression-types-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.cube-metric-expression-types-retired.ts @@ -46,4 +46,5 @@ export const entry: SemanticMigration = { + 'returns the aggregate the author chose, and every dashboard, report or saved query that read ' + 'the measure is checked against the number it now returns. A measure typed with one of the six ' + 'aggregates parses byte-identically to before.', + relevantWhen: { kind: 'stack-declares', keys: ['analyticsCubes'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.cube-metric-filters-retired.ts b/packages/spec/src/migrations/entries/semantic/18.cube-metric-filters-retired.ts index 1e45952ad7b..0d00628011c 100644 --- a/packages/spec/src/migrations/entries/semantic/18.cube-metric-filters-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.cube-metric-filters-retired.ts @@ -32,4 +32,5 @@ export const entry: SemanticMigration = { + 'renamed the metric if its name promised the filter. With the condition re-expressed, a query ' + 'over a fixture where the condition excludes rows returns the filtered aggregate (strictly ' + 'smaller for a positive sum over excluded rows), not the unfiltered one.', + relevantWhen: { kind: 'stack-declares', keys: ['analyticsCubes'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.cube-refresh-key-retired.ts b/packages/spec/src/migrations/entries/semantic/18.cube-refresh-key-retired.ts index 00f49052f33..d4b9d7d1042 100644 --- a/packages/spec/src/migrations/entries/semantic/18.cube-refresh-key-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.cube-refresh-key-retired.ts @@ -25,4 +25,5 @@ export const entry: SemanticMigration = { 'No cube carries `refreshKey`, and the parse refuses one with the prescription. Every analytics ' + 'query answers as it did before the upgrade. Nothing the author maintains relies on cube results ' + 'being cached or refreshed on a schedule.', + relevantWhen: { kind: 'stack-declares', keys: ['analyticsCubes'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.dashboard-header-modal-target-page-only.ts b/packages/spec/src/migrations/entries/semantic/18.dashboard-header-modal-target-page-only.ts index f2f4824a251..8179b7276ea 100644 --- a/packages/spec/src/migrations/entries/semantic/18.dashboard-header-modal-target-page-only.ts +++ b/packages/spec/src/migrations/entries/semantic/18.dashboard-header-modal-target-page-only.ts @@ -34,4 +34,5 @@ export const entry: SemanticMigration = { + 'buttons meant to open an object\'s form declare `actionType: \'form\'` with an ' + '`.` target instead. Clicking each converted button opens the intended ' + 'page or form rather than a refusal dialog.', + relevantWhen: { kind: 'stack-declares', keys: ['dashboards'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.dashboard-refresh-interval-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.dashboard-refresh-interval-unit-in-key.ts index 7a7383a9391..5f9b9f94f76 100644 --- a/packages/spec/src/migrations/entries/semantic/18.dashboard-refresh-interval-unit-in-key.ts +++ b/packages/spec/src/migrations/entries/semantic/18.dashboard-refresh-interval-unit-in-key.ts @@ -31,4 +31,5 @@ export const entry: SemanticMigration = { + 'console the deployment runs, an open dashboard re-queries its widgets at that cadence; ' + 'where it does not, the console build predates the renderer\'s move to the new key, and the ' + 'author has recorded that until the console is upgraded.', + relevantWhen: { kind: 'stack-declares', keys: ['dashboards'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.dataset-measure-aggregate-field-type-refused.ts b/packages/spec/src/migrations/entries/semantic/18.dataset-measure-aggregate-field-type-refused.ts index 835eee15a7d..9383106f51a 100644 --- a/packages/spec/src/migrations/entries/semantic/18.dataset-measure-aggregate-field-type-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.dataset-measure-aggregate-field-type-refused.ts @@ -73,4 +73,5 @@ export const entry: SemanticMigration = { + 'stands down rather than guessing wherever the type cannot be resolved: no ' + '`sourceFieldMeta` wired, an unknown field, or a `relationship.field` path whose ' + 'column lives on a joined object.', + relevantWhen: { kind: 'stack-declares', keys: ['datasets'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.dataset-measure-selecting-aggregate-field-type-refused.ts b/packages/spec/src/migrations/entries/semantic/18.dataset-measure-selecting-aggregate-field-type-refused.ts index 9a06ef83826..dcdcfc09508 100644 --- a/packages/spec/src/migrations/entries/semantic/18.dataset-measure-selecting-aggregate-field-type-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.dataset-measure-selecting-aggregate-field-type-refused.ts @@ -77,4 +77,5 @@ export const entry: SemanticMigration = { + 'wired, an unknown field, or a `relationship.field` path whose column lives on a ' + 'joined object. A measure column over such a pair also stops carrying a corrected ' + '`fields[].type`, because the pair no longer produces a column at all.', + relevantWhen: { kind: 'stack-declares', keys: ['datasets'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.hook-timeout-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.hook-timeout-unit-in-key.ts index d086802d179..7800868d9dc 100644 --- a/packages/spec/src/migrations/entries/semantic/18.hook-timeout-unit-in-key.ts +++ b/packages/spec/src/migrations/entries/semantic/18.hook-timeout-unit-in-key.ts @@ -26,4 +26,5 @@ export const entry: SemanticMigration = { + 'to be allowed thirty seconds reads `timeoutMs: 30000`. A hook that runs longer than its ' + '`timeoutMs` fails with a timeout at that limit, and one that finishes inside it completes as ' + 'it did before the upgrade. No code reads or writes `timeout` on a hook definition.', + relevantWhen: { kind: 'stack-declares', keys: ['hooks'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.job-timeout-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.job-timeout-unit-in-key.ts index f074f2cc192..0c3fdb2ef92 100644 --- a/packages/spec/src/migrations/entries/semantic/18.job-timeout-unit-in-key.ts +++ b/packages/spec/src/migrations/entries/semantic/18.job-timeout-unit-in-key.ts @@ -27,4 +27,5 @@ export const entry: SemanticMigration = { + '`timeoutMs` fails with a timeout and is retried under `retryPolicy`, and an attempt that ' + 'finishes inside it succeeds as before. No code reads or writes `timeout` on a job ' + 'definition.', + relevantWhen: { kind: 'stack-declares', keys: ['jobs'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.mapping-lookup-params-retired.ts b/packages/spec/src/migrations/entries/semantic/18.mapping-lookup-params-retired.ts index 6d6a1d19332..053e22d5eaa 100644 --- a/packages/spec/src/migrations/entries/semantic/18.mapping-lookup-params-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.mapping-lookup-params-retired.ts @@ -34,4 +34,5 @@ export const entry: SemanticMigration = { + '(no `import_reference_not_found` row) — or the missing referenced records are created by a ' + 'step that runs before the import, since the import itself never creates them. Row counts ' + 'and links match the pre-upgrade import of the same file.', + relevantWhen: { kind: 'stack-declares', keys: ['mappings'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.permission-restore-purge-bits-retired.ts b/packages/spec/src/migrations/entries/semantic/18.permission-restore-purge-bits-retired.ts index 8d986c0a7e1..dbc9a6780d1 100644 --- a/packages/spec/src/migrations/entries/semantic/18.permission-restore-purge-bits-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.permission-restore-purge-bits-retired.ts @@ -36,4 +36,5 @@ export const entry: SemanticMigration = { + 'upgrade, and `allowTransfer` behaves as before. Every documented process that assumed a ' + 'restore or purge grant — an erasure-request runbook, an access review, an audit control — ' + 'names the mechanism it actually uses instead.', + relevantWhen: { kind: 'stack-declares', keys: ['permissions'] }, }; diff --git a/packages/spec/src/migrations/index.ts b/packages/spec/src/migrations/index.ts index 53a463595a5..5f65f9305fb 100644 --- a/packages/spec/src/migrations/index.ts +++ b/packages/spec/src/migrations/index.ts @@ -19,6 +19,9 @@ export type { MigrationStep, MigrationTodo, SemanticMigration, + SemanticRelevance, + SemanticRelevanceKey, + StackDeclaresRelevance, } from './types.js'; export { MIGRATIONS_BY_MAJOR, diff --git a/packages/spec/src/migrations/migrations.test.ts b/packages/spec/src/migrations/migrations.test.ts index fae59edd3c4..634bc3cb4f1 100644 --- a/packages/spec/src/migrations/migrations.test.ts +++ b/packages/spec/src/migrations/migrations.test.ts @@ -609,6 +609,10 @@ describe('migration chain (ADR-0087 D3)', () => { MIGRATIONS_BY_MAJOR[OLDEST_HOP]!.semantic.map((s) => s.id).sort(), ); expect(result.todos.length).toBeGreaterThan(0); + // `absentTodos` only NAMES a subset of them — the same objects, never a + // removal (`semantic-relevance.test.ts` pins which entries can be named). + for (const t of result.absentTodos) expect(result.todos).toContain(t); + expect(result.absentTodos.filter((t) => !t.relevantWhen)).toEqual([]); }); it('is immutable — the input stack is not mutated', () => { diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index fe36dead7c8..b135be10c98 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -2044,6 +2044,7 @@ const step17: MigrationStep = { + "(or `'previousYear'`), and `dimension` is named wherever the selection dates more than one " + 'time dimension. `objectstack validate` passes, and each affected widget renders a ' + '`__compare` column over the window its author intended.', + relevantWhen: { kind: 'stack-declares', keys: ['dashboards'] }, }, { id: 'data-driver-find-stream-retired', @@ -2356,6 +2357,7 @@ const step17: MigrationStep = { + '`inputMapping` on find/get/delete, or two endpoints claiming one METHOD + path); and ' + '(4) after publishing, each endpoint answers as you expect — an anonymous request to ' + 'a session-only endpoint returns 401 rather than data.', + relevantWhen: { kind: 'stack-declares', keys: ['apis'] }, }, { id: 'delete-by-id-before-hook-repoint-retired', @@ -3549,6 +3551,7 @@ const step17: MigrationStep = { + '`backoffMultiplier` below 1 remain, and each adjusted value was re-chosen knowing a ' + 'retry re-runs the handler with its writes and callouts. No job fails to register ' + 'with the retry-policy bound prescription.', + relevantWhen: { kind: 'stack-declares', keys: ['jobs'] }, }, { id: 'notification-list-cursor-retired', @@ -4275,6 +4278,7 @@ const step17: MigrationStep = { + 'rule needs a `criteria` predicate that names the same population, checked against a ' + 'representative record. Where a single business unit was meant, use `business_unit`; ' + '`unit_and_subordinates` is the subtree and grants strictly more.', + relevantWhen: { kind: 'stack-declares', keys: ['sharingRules'] }, }, { id: 'sort-node-direction-rejected', @@ -4514,6 +4518,7 @@ const step17: MigrationStep = { + 'Deleting it without that decision leaves exactly the state the retirement exists to ' + 'end: a destructive tool nobody is approving, now without even the false flag to show ' + 'that somebody once meant to.', + relevantWhen: { kind: 'stack-declares', keys: ['tools'] }, }, { id: 'ui-interaction-config-family-retired', @@ -7166,6 +7171,7 @@ const step18: MigrationStep = { + 'declares `reflectionInterval` without an enabled `longTerm`. Every agent parses under the new ' + 'schema.', conversionIds: ['agent-memory-long-term-store-removed'], + relevantWhen: { kind: 'stack-declares', keys: ['agents'] }, }, // #21277 — ADR-0049 enforce-or-remove (ruling record 5945617233, letter A) — // the D3 entry of the `agent-structured-output-refused-members-removed` @@ -7210,6 +7216,7 @@ const step18: MigrationStep = { + 'agent that relied on coercion declares the exact types in `schema` and a test turn returns an ' + 'answer that validates without conversion.', conversionIds: ['agent-structured-output-refused-members-removed'], + relevantWhen: { kind: 'stack-declares', keys: ['agents'] }, }, { id: 'ai-conversation-analytics-duration-unit-in-key', @@ -7392,6 +7399,7 @@ const step18: MigrationStep = { + 'to be queried) or keep it and confirm that `/analytics/meta` omits the cube and that a query ' + 'naming it answers 404 `CUBE_NOT_FOUND`. Every compiled artifact in use was built by ' + '`os compile` from this release or later.', + relevantWhen: { kind: 'stack-declares', keys: ['analyticsCubes'] }, }, // An inert key made real, and the request class that goes from answered to // refused because of it: the engine aggregate path's whole refusal set, which a @@ -7454,6 +7462,7 @@ const step18: MigrationStep = { + 'beside a dimension over a joined object — or each one that did now groups by a dimension ' + 'without a single interval. A host that overrides `queryCapabilities` to raw SQL only either ' + 'adds an engine aggregate bridge or groups by no one-interval dimension.', + relevantWhen: { kind: 'stack-declares', keys: ['analyticsCubes'] }, }, { id: 'analytics-date-range-array-two-bounds-required', @@ -7743,6 +7752,7 @@ const step18: MigrationStep = { + 'meant to cache for one minute reads `cacheTtlSeconds: 60`. A GET to the endpoint repeated ' + 'inside that window is answered from the cache, and one repeated after it reflects a record ' + 'changed in between.', + relevantWhen: { kind: 'stack-declares', keys: ['apis'] }, }, { id: 'api-error-retry-after-unit-in-key', @@ -8647,6 +8657,7 @@ const step18: MigrationStep = { + '`description` conveying what that label was meant to announce, or the author has confirmed ' + 'the existing description does. With a screen reader, focusing the chart graphic announces ' + 'the description as its name.', + relevantWhen: { kind: 'stack-declares', keys: ['dashboards', 'reports', 'pages'] }, }, { id: 'cli-command-contribution-retired', @@ -9357,6 +9368,7 @@ const step18: MigrationStep = { + 'derived ON clause reads, so a join keyed after the object it REACHES never resolved at all. ' + 'Nothing else regresses: `joins..name` is unchanged, and it is what both the joined ' + 'table and the per-object RLS/tenant read scope are resolved from.', + relevantWhen: { kind: 'stack-declares', keys: ['analyticsCubes'] }, }, // #20300 — ADR-0049 enforce-or-remove (triage verdict RETIRE) — the D3 entry of // the `cube-member-inner-name-removed` family (one D3 entry per retirement @@ -9389,6 +9401,7 @@ const step18: MigrationStep = { + 'author has either kept the key (nothing else changes) or re-keyed the member to the intended ' + 'name and updated every query, dashboard and report that names `.`. ' + '`GET /api/v1/analytics/meta` lists each member as `.` exactly as before the upgrade.', + relevantWhen: { kind: 'stack-declares', keys: ['analyticsCubes'] }, }, // #20943, maintainer ruling D — a cube member's `sql` is a column reference; // the expression half is retired at the contract (ADR-0021's zero raw @@ -9441,6 +9454,7 @@ const step18: MigrationStep = { + 'ratio: the same value divided by 100 when the expression returned percentage points). ' + 'Every dashboard, report or saved query that named the cube member now names the dataset ' + 'measure. A cube member that aggregates a column parses byte-identically to before.', + relevantWhen: { kind: 'stack-declares', keys: ['analyticsCubes'] }, }, // #21000 (ADR-0049 enforce-or-remove) — `AggregationMetricType`'s `number`, // `string` and `boolean` declared a custom SQL expression returning that type, @@ -9486,6 +9500,7 @@ const step18: MigrationStep = { + 'returns the aggregate the author chose, and every dashboard, report or saved query that read ' + 'the measure is checked against the number it now returns. A measure typed with one of the six ' + 'aggregates parses byte-identically to before.', + relevantWhen: { kind: 'stack-declares', keys: ['analyticsCubes'] }, }, // #10414 (ADR-0049 enforce-or-remove) — the D3 entry of the // `metric-filters-removed` family (ruling B on #17152: one D3 entry per @@ -9517,6 +9532,7 @@ const step18: MigrationStep = { + 'renamed the metric if its name promised the filter. With the condition re-expressed, a query ' + 'over a fixture where the condition excludes rows returns the filtered aggregate (strictly ' + 'smaller for a positive sum over excluded rows), not the unfiltered one.', + relevantWhen: { kind: 'stack-declares', keys: ['analyticsCubes'] }, }, // #20637 — ADR-0049 enforce-or-remove (maintainer ruling, letter C) — the D3 // entry of the `cube-refresh-key-removed` family (one D3 entry per retirement @@ -9541,6 +9557,7 @@ const step18: MigrationStep = { 'No cube carries `refreshKey`, and the parse refuses one with the prescription. Every analytics ' + 'query answers as it did before the upgrade. Nothing the author maintains relies on cube results ' + 'being cached or refreshed on a schedule.', + relevantWhen: { kind: 'stack-declares', keys: ['analyticsCubes'] }, }, // #19992 (ADR-0049 enforce-or-remove; triage direction REMOVE under ruling 乙 // on #19910: 「a currency's decimal places are the currency's, not a @@ -9616,6 +9633,7 @@ const step18: MigrationStep = { + 'buttons meant to open an object\'s form declare `actionType: \'form\'` with an ' + '`.` target instead. Clicking each converted button opens the intended ' + 'page or form rather than a refusal dialog.', + relevantWhen: { kind: 'stack-declares', keys: ['dashboards'] }, }, // #15680 (stack card of #14478, maintainer ruling B: a duration key carries its // unit in its NAME) — the D3 entry of the @@ -9646,6 +9664,7 @@ const step18: MigrationStep = { + 'console the deployment runs, an open dashboard re-queries its widgets at that cadence; ' + 'where it does not, the console build predates the renderer\'s move to the new key, and the ' + 'author has recorded that until the console is upgraded.', + relevantWhen: { kind: 'stack-declares', keys: ['dashboards'] }, }, // The judgement half of `dashboard-widget-chart-config-structure-removed`. The // D2 conversion strips the four keys mechanically; what they CARRIED cannot be @@ -10273,6 +10292,7 @@ const step18: MigrationStep = { + 'stands down rather than guessing wherever the type cannot be resolved: no ' + '`sourceFieldMeta` wired, an unknown field, or a `relationship.field` path whose ' + 'column lives on a joined object.', + relevantWhen: { kind: 'stack-declares', keys: ['datasets'] }, }, { id: 'dataset-measure-selecting-aggregate-field-type-refused', @@ -10349,6 +10369,7 @@ const step18: MigrationStep = { + 'wired, an unknown field, or a `relationship.field` path whose column lives on a ' + 'joined object. A measure column over such a pair also stops carrying a corrected ' + '`fields[].type`, because the pair no longer produces a column at all.', + relevantWhen: { kind: 'stack-declares', keys: ['datasets'] }, }, // #21220 (ADR-0049 enforce-or-remove) — an ADR-0021 dataset dimension's and // measure's `field` is a column reference, the accept set the cube members it @@ -14054,6 +14075,7 @@ const step18: MigrationStep = { + 'to be allowed thirty seconds reads `timeoutMs: 30000`. A hook that runs longer than its ' + '`timeoutMs` fails with a timeout at that limit, and one that finishes inside it completes as ' + 'it did before the upgrade. No code reads or writes `timeout` on a hook definition.', + relevantWhen: { kind: 'stack-declares', keys: ['hooks'] }, }, { id: 'hot-reload-inert-state-strategies-retired', @@ -14479,6 +14501,7 @@ const step18: MigrationStep = { + '`timeoutMs` fails with a timeout and is retried under `retryPolicy`, and an attempt that ' + 'finishes inside it succeeds as before. No code reads or writes `timeout` on a job ' + 'definition.', + relevantWhen: { kind: 'stack-declares', keys: ['jobs'] }, }, { id: 'kernel-compatibility-matrix-estimated-migration-time-unit-in-key', @@ -15278,6 +15301,7 @@ const step18: MigrationStep = { + '(no `import_reference_not_found` row) — or the missing referenced records are created by a ' + 'step that runs before the import, since the import itself never creates them. Row counts ' + 'and links match the pre-upgrade import of the same file.', + relevantWhen: { kind: 'stack-declares', keys: ['mappings'] }, }, // #15680 (stack card of #14478, maintainer ruling B: a duration key carries its // unit in its NAME) — the D3 entry of the @@ -16604,6 +16628,7 @@ const step18: MigrationStep = { + 'upgrade, and `allowTransfer` behaves as before. Every documented process that assumed a ' + 'restore or purge grant — an erasure-request runbook, an access review, an audit control — ' + 'names the mechanism it actually uses instead.', + relevantWhen: { kind: 'stack-declares', keys: ['permissions'] }, }, // #20321 (ADR-0049 enforce-or-remove) — the D3 entry of the // `permission-rls-tags-removed` family (ruling B on #17152: one D3 entry per diff --git a/packages/spec/src/migrations/semantic-relevance.test.ts b/packages/spec/src/migrations/semantic-relevance.test.ts new file mode 100644 index 00000000000..c5e83960647 --- /dev/null +++ b/packages/spec/src/migrations/semantic-relevance.test.ts @@ -0,0 +1,314 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `SemanticMigration.relevantWhen` — the structured, stack-derived question + * that is the ONE way a semantic entry leaves `os migrate meta`'s default list + * (ADR-0087 D3, "never silence"; ⛔ never prose-matching `surface`). + * + * Four kinds of pin, kept apart: + * + * - ENUMERATION: exactly which entries carry a question, and which keys each + * names. Adding, widening or narrowing one is therefore a reviewed edit of + * the table below, never a side effect of an entry file. + * - SHAPE: every question is a closed, structured member of + * `SemanticRelevance` naming real top-level stack keys. + * - EVALUATION: the three-valued answer, and that only `absent` — a positive + * proof — names anything; whatever the evaluation cannot read is `unknown`. + * - CHAIN: `applyMetaMigrations` reports every entry of every hop crossed in + * `todos`, whatever the stack holds, and `absentTodos` is a SUBSET of it — + * the same objects, in chain order — holding only proven-absent entries. + */ + +import { describe, expect, it } from 'vitest'; + +import { ALL_CONVERSIONS } from '../conversions/registry.js'; +import { STACK_DEFINITION_KEYS } from '../stack.zod.js'; +import { applyMetaMigrations, semanticRelevanceVerdict, semanticTodoAbsent } from './chain.js'; +import { MIGRATIONS_BY_MAJOR, MIGRATION_MAJORS, MIGRATION_SUPPORT_FLOOR } from './registry.js'; +import type { MigrationTodo, SemanticRelevance } from './types.js'; + +/** + * The first batch: the entries whose surface lives ONLY under the named + * top-level stack keys — no runtime request body, no code door (a direct + * caller of an exported evaluator or compiler) — and whose acceptance criteria + * send the author to no stored row and no runtime door. `major:id` → the keys + * its question names. + */ +const EXPECTED_RELEVANCE: Readonly> = { + '17:dashboard-widget-compareto-offset': ['dashboards'], + '17:declarative-apis-endpoints-live': ['apis'], + '17:job-retry-policy-constraints-tightened': ['jobs'], + '17:sharing-rule-recipient-reconcile': ['sharingRules'], + '17:tool-requires-confirmation-retired': ['tools'], + '18:agent-memory-store-retired-and-limits-required': ['agents'], + '18:agent-structured-output-refused-members-retired': ['agents'], + '18:analytics-cube-public-default-visible-enforced': ['analyticsCubes'], + '18:analytics-cube-single-granularity-default-enforced': ['analyticsCubes'], + '18:api-endpoint-cache-ttl-unit-in-key': ['apis'], + '18:chart-config-aria-retired': ['dashboards', 'reports', 'pages'], + '18:cube-join-sql-and-relationship-retired': ['analyticsCubes'], + '18:cube-member-inner-name-retired': ['analyticsCubes'], + '18:cube-member-sql-expression-retired': ['analyticsCubes'], + '18:cube-metric-expression-types-retired': ['analyticsCubes'], + '18:cube-metric-filters-retired': ['analyticsCubes'], + '18:cube-refresh-key-retired': ['analyticsCubes'], + '18:dashboard-header-modal-target-page-only': ['dashboards'], + '18:dashboard-refresh-interval-unit-in-key': ['dashboards'], + '18:dataset-measure-aggregate-field-type-refused': ['datasets'], + '18:dataset-measure-selecting-aggregate-field-type-refused': ['datasets'], + '18:hook-timeout-unit-in-key': ['hooks'], + '18:job-timeout-unit-in-key': ['jobs'], + '18:mapping-lookup-params-retired': ['mappings'], + '18:permission-restore-purge-bits-retired': ['permissions'], +}; + +/** The keys the evaluation reads as carriers of other definitions, never as a family. */ +const CARRIER_KEYS = ['manifest', 'packages', 'plugins', 'devPlugins', 'tiers']; + +/** Every registered entry, with the major whose step carries it. */ +const ENTRIES = MIGRATION_MAJORS.flatMap((major) => + MIGRATIONS_BY_MAJOR[major]!.semantic.map((entry) => ({ major, entry })), +); + +const cubes = (keys: readonly string[]): SemanticRelevance => + ({ kind: 'stack-declares', keys }) as unknown as SemanticRelevance; + +describe('which entries carry a relevance question (ENUMERATION)', () => { + it('is exactly the reviewed table — no entry gains, loses or changes one silently', () => { + const actual: Record = {}; + for (const { major, entry } of ENTRIES) { + if (entry.relevantWhen) actual[`${major}:${entry.id}`] = entry.relevantWhen.keys; + } + expect(actual).toEqual(EXPECTED_RELEVANCE); + }); + + it('is 25 entries: 5 of protocol 17 and 20 of protocol 18', () => { + const keys = Object.keys(EXPECTED_RELEVANCE); + expect(keys).toHaveLength(25); + expect(keys.filter((k) => k.startsWith('17:'))).toHaveLength(5); + expect(keys.filter((k) => k.startsWith('18:'))).toHaveLength(20); + }); + + it('the table names only registered entries (anti-vacuity for the comparison above)', () => { + const registered = new Set(ENTRIES.map(({ major, entry }) => `${major}:${entry.id}`)); + expect(Object.keys(EXPECTED_RELEVANCE).filter((k) => !registered.has(k))).toEqual([]); + expect(Object.keys(EXPECTED_RELEVANCE).length).toBeGreaterThan(0); + }); +}); + +describe('every relevance question is a closed, structured question over top-level stack keys (SHAPE)', () => { + const declared = new Set(STACK_DEFINITION_KEYS); + const questions = ENTRIES.filter(({ entry }) => entry.relevantWhen); + + it('names one or more distinct keys the stack definition declares, and no carrier key', () => { + expect(declared.size).toBeGreaterThan(CARRIER_KEYS.length); + for (const { major, entry } of questions) { + const q = entry.relevantWhen!; + const label = `protocol ${major}: ${entry.id}`; + expect(q.kind, label).toBe('stack-declares'); + expect(q.keys.length, label).toBeGreaterThan(0); + expect(new Set(q.keys).size, `${label}: duplicate key`).toBe(q.keys.length); + for (const key of q.keys) { + expect(declared.has(key), `${label}: \`${key}\` is not a stack key`).toBe(true); + expect(CARRIER_KEYS.includes(key), `${label}: \`${key}\` is a carrier, not a family`).toBe(false); + } + } + }); + + it('carries no free text — the question is its two structured fields and nothing else', () => { + for (const { entry } of questions) { + expect(Object.keys(entry.relevantWhen!).sort()).toEqual(['keys', 'kind']); + } + }); + + it('an entry that judges a conversion is listed on that conversion\'s own fixture', () => { + // The applied edit is a proof the surface is there; the question must not + // contradict it on the fixture the conversion is tested with. + let checked = 0; + for (const { major, entry } of questions) { + for (const id of entry.conversionIds ?? []) { + const conversion = ALL_CONVERSIONS.find((c) => c.id === id)!; + const result = applyMetaMigrations( + structuredClone(conversion.fixture.before) as Record, + MIGRATION_SUPPORT_FLOOR, + major, + ); + expect(result.todos.some((t) => t.id === entry.id), `${entry.id} reported on ${id}'s fixture`).toBe(true); + expect(result.absentTodos.some((t) => t.id === entry.id), `${entry.id} not named absent on ${id}'s fixture`) + .toBe(false); + checked++; + } + } + expect(checked).toBeGreaterThan(0); + }); +}); + +describe('the answer is three-valued, and only a positive proof is `absent` (EVALUATION)', () => { + const q = cubes(['analyticsCubes']); + + it('a key that is missing, or an empty array or map, is absent', () => { + expect(semanticRelevanceVerdict(q, [{}])).toBe('absent'); + expect(semanticRelevanceVerdict(q, [{ objects: [{ name: 'a' }] }])).toBe('absent'); + expect(semanticRelevanceVerdict(q, [{ analyticsCubes: [] }])).toBe('absent'); + expect(semanticRelevanceVerdict(q, [{ analyticsCubes: {} }])).toBe('absent'); + }); + + it('a non-empty array or map — the authored map form included — is present', () => { + expect(semanticRelevanceVerdict(q, [{ analyticsCubes: [{ name: 'c' }] }])).toBe('present'); + expect(semanticRelevanceVerdict(q, [{ analyticsCubes: { c: { sql: 't' } } }])).toBe('present'); + }); + + it('any one of several keys being present is present', () => { + const two = cubes(['permissions', 'sharingRules']); + expect(semanticRelevanceVerdict(two, [{ sharingRules: [{ name: 'r' }] }])).toBe('present'); + expect(semanticRelevanceVerdict(two, [{ permissions: [], sharingRules: [] }])).toBe('absent'); + }); + + it('a value the question cannot read is unknown, never absent', () => { + expect(semanticRelevanceVerdict(q, [{ analyticsCubes: () => [] }])).toBe('unknown'); + expect(semanticRelevanceVerdict(q, [{ analyticsCubes: null }])).toBe('unknown'); + expect(semanticRelevanceVerdict(q, [{ analyticsCubes: 'cubes' }])).toBe('unknown'); + expect(semanticRelevanceVerdict(q, [{ analyticsCubes: Promise.resolve([]) }])).toBe('unknown'); + const throwing = Object.defineProperty({}, 'analyticsCubes', { + enumerable: true, + get() { + throw new Error('computed key'); + }, + }); + expect(semanticRelevanceVerdict(q, [throwing])).toBe('unknown'); + }); + + it('a stack that is not a plain object, or no stack at all, is unknown', () => { + class Bundle {} + expect(semanticRelevanceVerdict(q, [new Bundle()])).toBe('unknown'); + expect(semanticRelevanceVerdict(q, [undefined])).toBe('unknown'); + expect(semanticRelevanceVerdict(q, [[]])).toBe('unknown'); + expect(semanticRelevanceVerdict(q, [])).toBe('unknown'); + }); + + it('a listed plugin makes every key unknown, since it can register metadata the stack does not show', () => { + class SomePlugin {} + expect(semanticRelevanceVerdict(q, [{ plugins: [new SomePlugin()] }])).toBe('unknown'); + expect(semanticRelevanceVerdict(q, [{ plugins: ['@acme/crm'] }])).toBe('unknown'); + expect(semanticRelevanceVerdict(q, [{ devPlugins: ['@acme/dev'] }])).toBe('unknown'); + // …but a key the stack visibly declares is still present. + expect(semanticRelevanceVerdict(q, [{ plugins: [new SomePlugin()], analyticsCubes: [{}] }])).toBe('present'); + // An empty plugin list proves nothing is contributed. + expect(semanticRelevanceVerdict(q, [{ plugins: [], devPlugins: [] }])).toBe('absent'); + }); + + it('a declared `tiers` preset makes every key unknown too, since a tier loads platform plugins', () => { + expect(semanticRelevanceVerdict(q, [{ tiers: ['ai'] }])).toBe('unknown'); + expect(semanticRelevanceVerdict(q, [{ tiers: ['core', 'ui'], objects: [{ name: 'a' }] }])).toBe('unknown'); + expect(semanticRelevanceVerdict(q, [{ tiers: () => ['ai'] }])).toBe('unknown'); + // …a key the stack visibly declares is still present, and an empty list loads nothing. + expect(semanticRelevanceVerdict(q, [{ tiers: ['ai'], analyticsCubes: [{}] }])).toBe('present'); + expect(semanticRelevanceVerdict(q, [{ tiers: [] }])).toBe('absent'); + }); + + it('an assembled package body counts like the top level, and an unreadable one is unknown', () => { + const body = (manifest: unknown) => ({ packages: [{ manifest: { id: 'p', objects: [{}] } }, { manifest }] }); + expect(semanticRelevanceVerdict(q, [body({ id: 'q', analyticsCubes: [{ name: 'c' }] })])).toBe('present'); + expect(semanticRelevanceVerdict(q, [body({ id: 'q' })])).toBe('absent'); + expect(semanticRelevanceVerdict(q, [{ packages: [{ manifest: 'com.example.q' }] }])).toBe('unknown'); + expect(semanticRelevanceVerdict(q, [{ packages: ['com.example.q'] }])).toBe('unknown'); + expect(semanticRelevanceVerdict(q, [{ packages: { q: {} } }])).toBe('unknown'); + }); + + it('over several checkpoints: present anywhere is present, and absent needs every one', () => { + const withCubes = { analyticsCubes: [{ name: 'c' }] }; + expect(semanticRelevanceVerdict(q, [withCubes, {}])).toBe('present'); + expect(semanticRelevanceVerdict(q, [{}, withCubes])).toBe('present'); + expect(semanticRelevanceVerdict(q, [{}, { analyticsCubes: () => [] }])).toBe('unknown'); + expect(semanticRelevanceVerdict(q, [{}, {}])).toBe('absent'); + }); + + it('an entry that judges an applied conversion stays listed whatever its question answers', () => { + const todo: MigrationTodo = { + id: 'synthetic-judge', + surface: 'analyticsCubes[].x', + replacement: 'y', + reason: 'r', + acceptanceCriteria: 'a', + conversionIds: ['some-conversion'], + relevantWhen: cubes(['analyticsCubes']), + toMajor: 18, + }; + expect(semanticTodoAbsent(todo, [{}], new Set())).toBe(true); + expect(semanticTodoAbsent(todo, [{}], new Set(['some-conversion']))).toBe(false); + expect(semanticTodoAbsent({ ...todo, relevantWhen: undefined }, [{}], new Set())).toBe(false); + }); +}); + +describe('todos reports every entry; absentTodos names the proven-absent subset (CHAIN)', () => { + const TO = Math.max(...MIGRATION_MAJORS); + /** A stack that declares objects only — none of the first batch's keys. */ + const OBJECTS_ONLY = { + manifest: { id: 'com.example.relevance', name: 'relevance', version: '1.0.0', type: 'app' }, + objects: [{ name: 'rel_thing', label: 'Thing', fields: { title: { type: 'text', label: 'Title' } } }], + }; + + function crossed(from: number, to: number): string[] { + return MIGRATION_MAJORS.filter((m) => m > from && m <= to).flatMap((m) => + MIGRATIONS_BY_MAJOR[m]!.semantic.map((s) => `${m}:${s.id}`), + ); + } + const ids = (todos: readonly MigrationTodo[]) => todos.map((t) => `${t.toMajor}:${t.id}`); + + it('todos holds every crossed entry, in chain order, whatever the stack holds', () => { + for (const stack of [structuredClone(OBJECTS_ONLY), {}]) { + const result = applyMetaMigrations(stack, MIGRATION_SUPPORT_FLOOR, TO); + expect(ids(result.todos)).toEqual(crossed(MIGRATION_SUPPORT_FLOOR, TO)); + expect(result.hops.flatMap((h) => ids(h.todos))).toEqual(ids(result.todos)); + } + }); + + it('on a stack declaring none of the batch\'s keys, absentTodos names exactly the questioned entries', () => { + const result = applyMetaMigrations(structuredClone(OBJECTS_ONLY), MIGRATION_SUPPORT_FLOOR, TO); + const expectedAbsent = crossed(MIGRATION_SUPPORT_FLOOR, TO).filter((k) => k in EXPECTED_RELEVANCE); + expect(expectedAbsent.length).toBeGreaterThan(0); + expect(ids(result.absentTodos)).toEqual(expectedAbsent); + expect(result.hops.flatMap((h) => ids(h.absentTodos))).toEqual(ids(result.absentTodos)); + }); + + it('absentTodos is a subset of todos — the same objects, in chain order — at chain and hop level', () => { + const result = applyMetaMigrations(structuredClone(OBJECTS_ONLY), MIGRATION_SUPPORT_FLOOR, TO); + expect(result.absentTodos.length).toBeGreaterThan(0); + for (const t of result.absentTodos) expect(result.todos.includes(t), `${t.id} is the todos object`).toBe(true); + const position = new Map(result.todos.map((t, i) => [t, i])); + const at = result.absentTodos.map((t) => position.get(t)!); + expect(at).toEqual(at.slice().sort((a, b) => a - b)); + for (const h of result.hops) { + for (const t of h.absentTodos) expect(h.todos.includes(t), `hop ${h.toMajor}: ${t.id}`).toBe(true); + expect(h.todos).toHaveLength(MIGRATIONS_BY_MAJOR[h.toMajor]!.semantic.length); + } + }); + + it('a stack that declares the key names none of its entries absent', () => { + const stack = { ...structuredClone(OBJECTS_ONLY), analyticsCubes: [{ name: 'rel_cube', title: 'Cube', sql: 'rel_thing' }] }; + const result = applyMetaMigrations(stack, MIGRATION_SUPPORT_FLOOR, TO); + const cubeEntries = Object.entries(EXPECTED_RELEVANCE) + .filter(([, keys]) => keys.includes('analyticsCubes')) + .map(([k]) => k); + expect(cubeEntries.length).toBeGreaterThan(0); + for (const k of cubeEntries) expect(ids(result.todos), k).toContain(k); + expect(ids(result.absentTodos).filter((k) => cubeEntries.includes(k))).toEqual([]); + }); + + it('a stack listing a plugin, or declaring a tier preset, proves nothing absent', () => { + class LocalPlugin {} + for (const extra of [{ plugins: [new LocalPlugin()] }, { tiers: ['core'] }]) { + const result = applyMetaMigrations({ ...structuredClone(OBJECTS_ONLY), ...extra }, MIGRATION_SUPPORT_FLOOR, TO); + expect(result.absentTodos).toEqual([]); + expect(ids(result.todos)).toEqual(crossed(MIGRATION_SUPPORT_FLOOR, TO)); + } + }); + + it('an entry with no question is never named absent, even on an empty stack', () => { + const result = applyMetaMigrations({}, MIGRATION_SUPPORT_FLOOR, TO); + const unquestioned = crossed(MIGRATION_SUPPORT_FLOOR, TO).filter((k) => !(k in EXPECTED_RELEVANCE)); + expect(unquestioned.length).toBeGreaterThan(0); + expect(ids(result.absentTodos).filter((k) => unquestioned.includes(k))).toEqual([]); + expect(result.absentTodos.every((t) => t.relevantWhen)).toBe(true); + }); +}); diff --git a/packages/spec/src/migrations/types.ts b/packages/spec/src/migrations/types.ts index 1d7b1e30b15..cb79169a31f 100644 --- a/packages/spec/src/migrations/types.ts +++ b/packages/spec/src/migrations/types.ts @@ -30,6 +30,67 @@ * delegated to it, rather than silence. */ +import type { StackDefinitionKey } from '../stack.zod.js'; + +/** + * A top-level stack key a {@link SemanticRelevance} question may name: any key + * `ObjectStackDefinitionSchema` declares, except the five the evaluation reads + * as CARRIERS of other definitions rather than as a family of its own — + * `manifest` (always present), `packages` (whose bodies are searched for the + * named keys), and `plugins` / `devPlugins` / `tiers` (whose entries, or the + * platform plugins a tier preset loads, can contribute metadata no reader of + * the stack can see, so their presence makes every answer unknown). + */ +export type SemanticRelevanceKey = Exclude< + StackDefinitionKey, + 'manifest' | 'packages' | 'plugins' | 'devPlugins' | 'tiers' +>; + +/** + * "Does the stack declare anything under one of these top-level keys?" — the + * one relevance question protocol 18 ships. + * + * Answered over the stack as loaded, map form included: a key whose value is a + * non-empty array or map is PRESENT, an absent key or an empty array or map is + * ABSENT, and any other value (a function, a promise, a scalar) is UNKNOWN. The + * same key inside each `packages[].manifest` body counts too, since an + * assembled multi-package stack carries its collections there and not at the + * top level. + */ +export interface StackDeclaresRelevance { + /** The discriminant: a question about which top-level keys the stack declares. */ + readonly kind: 'stack-declares'; + /** + * Every top-level key under which the entry's surface can be authored in a + * source written against the step's PREVIOUS major. The surface is absent + * only when every one of them is: list a second key wherever the governed + * shape can also be written (a report a page component carries inline, a + * predicate a sharing rule carries as well as a permission set). + */ + readonly keys: readonly [SemanticRelevanceKey, ...SemanticRelevanceKey[]]; +} + +/** + * A structured, stack-derived relevance question for a {@link SemanticMigration} + * — the one exit ADR-0087 D3 ("never silence") leaves a notice: an entry + * leaves `os migrate meta`'s default list only when this question, evaluated + * over the stack being migrated, PROVES the entry's surface absent. ⛔ Never + * free text, and never a match against the prose of `surface`. + * + * A closed union of named questions rather than a callback: a callback could do + * arbitrary work and could not be enumerated, reviewed or pinned. A new kind of + * question is a new member here, evaluated in `chain.ts`. + * + * The answer is three-valued, and only one value takes an entry off a + * printer's default list. `absent` (the stack provably carries no such + * surface) names the entry in {@link MigrationChainResult.absentTodos} — it + * stays in `todos` too; `present` and `unknown` (a value the question cannot + * read, a stack that is not a plain object, or a `plugins` / `devPlugins` / + * `tiers` entry that can contribute metadata the stack does not show) do not. The proof is about the definition the chain reads: metadata + * a deployment stores at runtime (Studio, the metadata API) is not part of it. + */ +export type SemanticRelevance = StackDeclaresRelevance; + /** * One retirement family's D3 entry — owed even when a lossless D2 conversion * carries the data repair — surfaced as a structured TODO the consumer agent @@ -71,6 +132,23 @@ export interface SemanticMigration { * names incidental analogues. */ conversionIds?: readonly string[]; + /** + * The structured question that can prove this entry irrelevant to a stack: + * when the chain evaluates it as `absent` over the stack it migrates, the + * entry — still reported in `todos` — is also named in + * {@link MigrationChainResult.absentTodos}, and `os migrate meta` counts it + * rather than listing it (`--all` lists it). See {@link SemanticRelevance} + * for the evaluation. + * + * Omit it — the entry is then always listed — unless the surface lives ONLY + * under top-level stack keys the question names. Two cases keep it listed by + * construction: an unreadable stack answers `unknown`, and an entry whose + * `conversionIds` names a conversion that applied an edit in the same run is + * listed whatever the question answers, since the edit is itself a proof the + * surface is there. Every entry carrying this field is enumerated by + * `semantic-relevance.test.ts`, so adding one is a reviewed edit. + */ + relevantWhen?: SemanticRelevance; } /** @@ -119,7 +197,14 @@ export interface MigrationHopResult { rationale: string; stack: Record; applied: MigrationApplication[]; + /** Every semantic TODO of this hop, whatever the stack holds. */ todos: MigrationTodo[]; + /** + * The subset of this hop's `todos` — the same objects, in the same order — + * whose surface the stack provably lacks (see + * {@link MigrationChainResult.absentTodos}). + */ + absentTodos: MigrationTodo[]; } /** The full result of composing + applying a chain from `fromMajor` to `toMajor`. */ @@ -130,8 +215,22 @@ export interface MigrationChainResult { stack: Record; /** Every mechanical rewrite across all hops, in application order. */ applied: MigrationApplication[]; - /** Every semantic TODO across all hops — the judgment delegated to the consumer. */ + /** + * Every semantic TODO across all hops — the judgment delegated to the + * consumer: every entry of every hop crossed, in chain order, whatever the + * stack holds. {@link absentTodos} never removes anything from it. + */ todos: MigrationTodo[]; + /** + * The subset of {@link todos} — the same objects, in the same order — whose + * {@link SemanticMigration.relevantWhen} question PROVED their surface absent + * from the stack: absent from the stack the chain was handed and from every + * hop's checkpoint after it. A printer may leave these off its default list + * only because they are named here (ADR-0087 D3, "never silence"): it counts + * them, and lists them on request. Empty when no entry carries a question + * the stack answers `absent`. + */ + absentTodos: MigrationTodo[]; /** Per-hop checkpoints, in order (for `--step` bisection). */ hops: MigrationHopResult[]; }