From 8c9e806bdef612d30720d997690c7753e36212a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 16:32:54 +0000 Subject: [PATCH 1/7] feat(spec): a structured relevance question on SemanticMigration, evaluated by the chain `SemanticMigration.relevantWhen` is an optional, closed question over the loaded stack (`{ kind: 'stack-declares', keys: [...] }`). `applyMetaMigrations` evaluates it over the stack it is handed and every hop checkpoint, and an entry whose surface it proves absent moves from `todos` to the new `absentTodos` (chain and per hop). Unreadable values, opaque plugins and an applied edit the entry judges all keep the entry listed. A first batch of 29 entries whose surface lives only under named top-level stack keys carries the question. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 --- packages/spec/src/migrations/chain.ts | 146 ++++++++++++++++-- .../17.dashboard-widget-compareto-offset.ts | 1 + .../17.declarative-apis-endpoints-live.ts | 1 + ....job-retry-policy-constraints-tightened.ts | 1 + .../17.sharing-rule-recipient-reconcile.ts | 1 + .../17.tool-requires-confirmation-retired.ts | 1 + ...emory-store-retired-and-limits-required.ts | 1 + ...ructured-output-refused-members-retired.ts | 1 + ...cs-cube-public-default-visible-enforced.ts | 1 + ...ube-single-granularity-default-enforced.ts | 1 + .../18.api-endpoint-cache-ttl-unit-in-key.ts | 1 + ...l-predicate-one-value-comparand-refused.ts | 1 + ...edicate-variable-root-comparand-refused.ts | 1 + .../semantic/18.chart-config-aria-retired.ts | 1 + ....cube-join-sql-and-relationship-retired.ts | 1 + .../18.cube-member-inner-name-retired.ts | 1 + .../18.cube-member-sql-expression-retired.ts | 1 + ...18.cube-metric-expression-types-retired.ts | 1 + .../18.cube-metric-filters-retired.ts | 1 + .../semantic/18.cube-refresh-key-retired.ts | 1 + ...dashboard-header-modal-target-page-only.ts | 1 + ....dashboard-refresh-interval-unit-in-key.ts | 1 + ...et-measure-aggregate-field-type-refused.ts | 1 + ...-selecting-aggregate-field-type-refused.ts | 1 + .../semantic/18.hook-timeout-unit-in-key.ts | 1 + .../semantic/18.job-timeout-unit-in-key.ts | 1 + .../18.mapping-lookup-params-retired.ts | 1 + ...8.permission-restore-purge-bits-retired.ts | 1 + ...8.rls-predicate-array-comparand-refused.ts | 1 + ...-predicate-stored-list-ordering-refused.ts | 1 + packages/spec/src/migrations/index.ts | 3 + packages/spec/src/migrations/registry.ts | 29 ++++ packages/spec/src/migrations/types.ts | 92 ++++++++++- 33 files changed, 289 insertions(+), 10 deletions(-) diff --git a/packages/spec/src/migrations/chain.ts b/packages/spec/src/migrations/chain.ts index bb7890c84dd..51263abdb14 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,105 @@ export function composeMigrationChain( .map((m) => MIGRATIONS_BY_MAJOR[m]!); } +/** + * The answer to a {@link SemanticRelevance} question over a stack. Only + * `absent` moves an entry out of the listed TODOs; `unknown` keeps it listed, + * 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 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 { + // A plugin is handed to the kernel, not read by this chain, and it can + // register metadata of any type at boot — so while one is listed, no key + // can be proven absent from what this stack deploys. + for (const carrier of ['plugins', 'devPlugins']) { + const verdict = collectionVerdict(stack[carrier]); + if (verdict !== 'absent') return 'unknown'; + } + + 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)) return 'unknown'; + for (const entry of packages) { + if (!isPlainDict(entry) || !isPlainDict(entry.manifest)) return 'unknown'; + const body = entry.manifest; + for (const key of keys) verdicts.push(collectionVerdict(body[key])); + } + } + + 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'; + } +} + /** Thrown when `--from N` is below the documented support floor. */ export class MigrationFloorError extends Error { constructor( @@ -64,6 +164,13 @@ 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 exactly once: in + * `todos`, or — when its `relevantWhen` question proves its surface absent from + * the stack and from every checkpoint the chain made of it — in `absentTodos` + * (ADR-0087 D3: an entry leaves the list only on a structured, stack-derived + * proof). An entry that judges a conversion which applied an edit in this run + * stays in `todos` whatever its question answers. */ export function applyMetaMigrations( stack: Record, @@ -76,8 +183,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 +205,40 @@ 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 = new Set(applied.map((a) => a.conversionId)); + const isAbsent = (todo: MigrationTodo): boolean => { + if (!todo.relevantWhen) return false; + if (todo.conversionIds?.some((id) => appliedConversionIds.has(id))) return false; + return semanticRelevanceVerdict(todo.relevantWhen, checkpoints) === 'absent'; + }; + + const todos: MigrationTodo[] = []; + const absentTodos: MigrationTodo[] = []; + const hops: MigrationHopResult[] = replayed.map(({ step, stack: hopStack, applied: hopApplied }) => { + const hopTodos: MigrationTodo[] = []; + const hopAbsent: MigrationTodo[] = []; + for (const s of step.semantic) { + const todo: MigrationTodo = { ...s, toMajor: step.toMajor }; + (isAbsent(todo) ? hopAbsent : hopTodos).push(todo); + } 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.cel-predicate-one-value-comparand-refused.ts b/packages/spec/src/migrations/entries/semantic/18.cel-predicate-one-value-comparand-refused.ts index a9ab9d36a29..6ef7239746a 100644 --- a/packages/spec/src/migrations/entries/semantic/18.cel-predicate-one-value-comparand-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.cel-predicate-one-value-comparand-refused.ts @@ -64,4 +64,5 @@ export const entry: SemanticMigration = { + 'multiple field; rewrite each as the replacement says. Then re-check what each policy is ' + 'supposed to admit rather than assuming what it admitted before was right: several of these ' + 'admitted every write, and two folded to no restriction at all.', + relevantWhen: { kind: 'stack-declares', keys: ['permissions', 'sharingRules'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.cel-predicate-variable-root-comparand-refused.ts b/packages/spec/src/migrations/entries/semantic/18.cel-predicate-variable-root-comparand-refused.ts index 36b399a4247..b73890fcaaf 100644 --- a/packages/spec/src/migrations/entries/semantic/18.cel-predicate-variable-root-comparand-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.cel-predicate-variable-root-comparand-refused.ts @@ -47,4 +47,5 @@ export const entry: SemanticMigration = { + 'condition of your sharing rules, for != or == whose other side is current_user with no key ' + 'after it, then rewrite each against the key it means (current_user.id, ' + 'current_user.organization_id or current_user.email), or with in against a membership set.', + relevantWhen: { kind: 'stack-declares', keys: ['permissions', 'sharingRules'] }, }; 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/entries/semantic/18.rls-predicate-array-comparand-refused.ts b/packages/spec/src/migrations/entries/semantic/18.rls-predicate-array-comparand-refused.ts index 32054144a20..48f206e27f9 100644 --- a/packages/spec/src/migrations/entries/semantic/18.rls-predicate-array-comparand-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.rls-predicate-array-comparand-refused.ts @@ -47,4 +47,5 @@ export const entry: SemanticMigration = { + 'Then re-check what each policy is supposed to refuse rather than assuming the writes it ' + 'admitted before were right: before this change a != or a negated == against a list ' + 'admitted every write.', + relevantWhen: { kind: 'stack-declares', keys: ['permissions'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.rls-predicate-stored-list-ordering-refused.ts b/packages/spec/src/migrations/entries/semantic/18.rls-predicate-stored-list-ordering-refused.ts index 36837d94652..9fd7ff540fd 100644 --- a/packages/spec/src/migrations/entries/semantic/18.rls-predicate-stored-list-ordering-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.rls-predicate-stored-list-ordering-refused.ts @@ -53,4 +53,5 @@ export const entry: SemanticMigration = { + 'multiple lookup, and rewrite each as the replacement says. Then write a record through each ' + 'such policy: a write whose compared field holds a list now answers 400 rather than being ' + 'admitted by string comparison, so re-check what the policy is supposed to admit.', + 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/registry.ts b/packages/spec/src/migrations/registry.ts index 7fbbfba7c48..4a1a748e159 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', @@ -7115,6 +7120,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` @@ -7159,6 +7165,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', @@ -7341,6 +7348,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 @@ -7403,6 +7411,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', @@ -7692,6 +7701,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', @@ -8382,6 +8392,7 @@ const step18: MigrationStep = { + 'multiple field; rewrite each as the replacement says. Then re-check what each policy is ' + 'supposed to admit rather than assuming what it admitted before was right: several of these ' + 'admitted every write, and two folded to no restriction at all.', + relevantWhen: { kind: 'stack-declares', keys: ['permissions', 'sharingRules'] }, }, // The variable-ROOT sibling of cel-predicate-list-comparand-refused, one // comparand kind over: the same pushdown compiler, the same consumers, the same @@ -8428,6 +8439,7 @@ const step18: MigrationStep = { + 'condition of your sharing rules, for != or == whose other side is current_user with no key ' + 'after it, then rewrite each against the key it means (current_user.id, ' + 'current_user.organization_id or current_user.email), or with in against a membership set.', + relevantWhen: { kind: 'stack-declares', keys: ['permissions', 'sharingRules'] }, }, { id: 'change-management-duration-keys-retired', @@ -8551,6 +8563,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', @@ -9261,6 +9274,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 @@ -9293,6 +9307,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 @@ -9345,6 +9360,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, @@ -9390,6 +9406,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 @@ -9421,6 +9438,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 @@ -9445,6 +9463,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 @@ -9520,6 +9539,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 @@ -9550,6 +9570,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 @@ -10177,6 +10198,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', @@ -10253,6 +10275,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 @@ -13809,6 +13832,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', @@ -14234,6 +14258,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', @@ -15033,6 +15058,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 @@ -16359,6 +16385,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 @@ -17520,6 +17547,7 @@ const step18: MigrationStep = { + 'Then re-check what each policy is supposed to refuse rather than assuming the writes it ' + 'admitted before were right: before this change a != or a negated == against a list ' + 'admitted every write.', + relevantWhen: { kind: 'stack-declares', keys: ['permissions'] }, }, // The cross-field comparison-class family, both arms in one entry: #20347 // refuses the comparison where it is authored (the lint rules behind @@ -17646,6 +17674,7 @@ const step18: MigrationStep = { + 'multiple lookup, and rewrite each as the replacement says. Then write a record through each ' + 'such policy: a write whose compared field holds a list now answers 400 rather than being ' + 'admitted by string comparison, so re-check what the policy is supposed to admit.', + relevantWhen: { kind: 'stack-declares', keys: ['permissions'] }, }, // The saved-report stack and the `report` metadata kind shared a word and // nothing else; this retires the stack and leaves the kind untouched. diff --git a/packages/spec/src/migrations/types.ts b/packages/spec/src/migrations/types.ts index 1d7b1e30b15..63ede96f2ee 100644 --- a/packages/spec/src/migrations/types.ts +++ b/packages/spec/src/migrations/types.ts @@ -30,6 +30,64 @@ * 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 four 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` (whose entries can contribute + * metadata no reader of the stack can see, so their presence makes every + * answer unknown). + */ +export type SemanticRelevanceKey = Exclude; + +/** + * "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 removes anything. `absent` + * (the stack provably carries no such surface) moves the entry to + * {@link MigrationChainResult.absentTodos}; `present` and `unknown` (a value + * the question cannot read, a stack that is not a plain object, or a `plugins` + * / `devPlugins` entry that can contribute metadata the stack does not show) + * keep it listed. 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 +129,22 @@ 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 is reported in {@link MigrationChainResult.absentTodos} instead of + * `todos`, 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 + * `migrations.test.ts`, so adding one is a reviewed edit. + */ + relevantWhen?: SemanticRelevance; } /** @@ -119,7 +193,10 @@ export interface MigrationHopResult { rationale: string; stack: Record; applied: MigrationApplication[]; + /** This hop's semantic TODOs the stack may owe — every entry `absentTodos` does not hold. */ todos: MigrationTodo[]; + /** This hop's semantic TODOs 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 +207,21 @@ 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 that this stack may owe — the judgment + * delegated to the consumer. That is every entry of every hop crossed except + * the ones in {@link absentTodos}; together the two hold each entry once, in + * chain order. + */ todos: MigrationTodo[]; + /** + * The semantic TODOs 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. They are + * reported here, never dropped (ADR-0087 D3, "never silence"): a printer + * counts them, and lists them on request. + */ + absentTodos: MigrationTodo[]; /** Per-hop checkpoints, in order (for `--step` bisection). */ hops: MigrationHopResult[]; } From 866c31ca8b088984977bdb1ca091a1d397b15f00 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 16:36:45 +0000 Subject: [PATCH 2/7] wip(cli): migrate meta counts the proven-absent notices; --all lists them Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 --- .../migrate/meta.report-order.test.ts | 140 ++++++++- packages/cli/src/commands/migrate/meta.ts | 120 +++++-- .../test/migrate-meta-engine-guidance.test.ts | 18 +- packages/spec/src/migrations/chain.ts | 59 ++-- .../src/migrations/semantic-relevance.test.ts | 294 ++++++++++++++++++ packages/spec/src/migrations/types.ts | 2 +- 6 files changed, 569 insertions(+), 64 deletions(-) create mode 100644 packages/spec/src/migrations/semantic-relevance.test.ts 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..97de9a70a18 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,17 @@ /** * `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`); + * ④ the notices proven absent from the stack (`absentTodos`) — 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 +47,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 +131,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 +143,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 +167,32 @@ 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 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 result.todos.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. */ @@ -273,6 +298,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), @@ -441,6 +467,7 @@ describe('an applied edit a semantic entry judges prints that entry beside it, m 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 +520,88 @@ 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(result.todos.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(` ${result.todos.length} manual change(s) require your judgment:`); + expect(lines.slice(semantic + 1, semantic + 1 + noticeLines(result).length)).toEqual(noticeLines(result)); + }); + + it('③ and ④ together hold every semantic entry of every hop crossed, each exactly once', () => { + const { result } = 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}`)); + const reported = [...result.todos, ...result.absentTodos].map((t) => `${t.toMajor}:${t.id}`); + expect(reported.slice().sort()).toEqual(crossed.slice().sort()); + expect(new Set(reported).size).toBe(reported.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, + todos: [...r.todos, ...r.absentTodos], + 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) => ({ ...r, todos: [], absentTodos: [...r.todos, ...r.absentTodos].filter((t) => t.relevantWhen) }), + { 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 d762ae032c4..3248b4fa1de 100644 --- a/packages/cli/src/commands/migrate/meta.ts +++ b/packages/cli/src/commands/migrate/meta.ts @@ -258,6 +258,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; /** Printed beside a schema-valid verdict. */ @@ -362,6 +367,58 @@ function printAppliedEdits(result: MigrationChainResult): void { console.log(''); } +/** One semantic notice, as ③ prints it: the headline, then `why:` and `verify:`. */ +function printNotice(t: MigrationTodo): void { + 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}`)); +} + +/** The keys an absent notice's relevance question found empty, each in backticks, joined by ` / `. */ +function absentKeysText(t: MigrationTodo): string { + const keys = t.relevantWhen?.kind === 'stack-declares' ? t.relevantWhen.keys : []; + return keys.map((k) => `\`${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): @@ -371,27 +428,32 @@ 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`); + * ④ 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}). * * ## 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; @@ -399,7 +461,9 @@ export function printMigrationReport(report: MigrationReport): void { // ① The verdict and the refusals — first, whatever else the run found. printSchemaVerdict(report); - if (result.applied.length === 0 && result.todos.length === 0) { + // A run whose only notices are proven absent is NOT this branch: it falls + // through, so ④ counts them and `--out` is still written. + if (result.applied.length === 0 && result.todos.length === 0 && result.absentTodos.length === 0) { // ⚠️ Two different facts wear the same empty result, and only one of them // is good news (#17134). A range that CONTAINS steps and rewrote nothing // is a finding about the metadata. A range that contains no step at all @@ -430,7 +494,8 @@ 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 absent = hop.absentTodos.length > 0 ? `, ${hop.absentTodos.length} not listed (surface absent)` : ''; + console.log(chalk.dim(` ${hop.applied.length} mechanical, ${hop.todos.length} manual${absent}`)); } console.log(''); } @@ -438,14 +503,13 @@ export function printMigrationReport(report: MigrationReport): void { // ③ 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}`)); - } + for (const t of result.todos) 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) { writeFileSync(report.out, JSON.stringify(result.stack, null, 2)); printInfo(`Wrote migrated stack snapshot → ${chalk.white(report.out)}`); @@ -498,6 +562,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 --stored', @@ -530,6 +595,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 carries them, under absentTodos.', + default: false, + exclusive: ['stored'], + }), stored: Flags.boolean({ description: "Canonicalize this deployment's sys_metadata rows in place instead of an authored config " @@ -660,12 +732,17 @@ export default class MigrateMeta extends Command { protocolVersion: PROTOCOL_VERSION, applied: result.applied, todos: result.todos, + // The semantic entries the chain proved irrelevant to this stack + // (ADR-0087 D3): reported beside `todos`, never dropped, so a + // machine consumer reaches them without `--all`. + 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, @@ -701,6 +778,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) } : {}), 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/spec/src/migrations/chain.ts b/packages/spec/src/migrations/chain.ts index 51263abdb14..f854b18fa39 100644 --- a/packages/spec/src/migrations/chain.ts +++ b/packages/spec/src/migrations/chain.ts @@ -90,28 +90,34 @@ function foldVerdicts(verdicts: Iterable): SemanticRel function stackDeclaresVerdict(stack: unknown, keys: readonly string[]): SemanticRelevanceVerdict { if (!isPlainDict(stack)) return 'unknown'; try { - // A plugin is handed to the kernel, not read by this chain, and it can - // register metadata of any type at boot — so while one is listed, no key - // can be proven absent from what this stack deploys. - for (const carrier of ['plugins', 'devPlugins']) { - const verdict = collectionVerdict(stack[carrier]); - if (verdict !== 'absent') return 'unknown'; - } - 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)) return 'unknown'; - for (const entry of packages) { - if (!isPlainDict(entry) || !isPlainDict(entry.manifest)) return 'unknown'; - const body = entry.manifest; - for (const key of keys) verdicts.push(collectionVerdict(body[key])); + 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 — 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']) { + if (collectionVerdict(stack[carrier]) !== 'absent') verdicts.push('unknown'); + } + return foldVerdicts(verdicts); } catch { return 'unknown'; @@ -142,6 +148,24 @@ export function semanticRelevanceVerdict( } } +/** + * Whether one semantic TODO leaves the listed TODOs: 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( @@ -212,12 +236,7 @@ export function applyMetaMigrations( // 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 = new Set(applied.map((a) => a.conversionId)); - const isAbsent = (todo: MigrationTodo): boolean => { - if (!todo.relevantWhen) return false; - if (todo.conversionIds?.some((id) => appliedConversionIds.has(id))) return false; - return semanticRelevanceVerdict(todo.relevantWhen, checkpoints) === 'absent'; - }; + const appliedConversionIds: ReadonlySet = new Set(applied.map((a) => a.conversionId)); const todos: MigrationTodo[] = []; const absentTodos: MigrationTodo[] = []; @@ -226,7 +245,7 @@ export function applyMetaMigrations( const hopAbsent: MigrationTodo[] = []; for (const s of step.semantic) { const todo: MigrationTodo = { ...s, toMajor: step.toMajor }; - (isAbsent(todo) ? hopAbsent : hopTodos).push(todo); + (semanticTodoAbsent(todo, checkpoints, appliedConversionIds) ? hopAbsent : hopTodos).push(todo); } todos.push(...hopTodos); absentTodos.push(...hopAbsent); 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..4522f7b82b7 --- /dev/null +++ b/packages/spec/src/migrations/semantic-relevance.test.ts @@ -0,0 +1,294 @@ +// 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 — moves anything; whatever the evaluation cannot read is `unknown`. + * - CHAIN: `applyMetaMigrations` reports every entry of every hop crossed + * exactly once, in `todos` or in `absentTodos`, and only a proven-absent + * entry is in the second. + */ + +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, 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:cel-predicate-one-value-comparand-refused': ['permissions', 'sharingRules'], + '18:cel-predicate-variable-root-comparand-refused': ['permissions', 'sharingRules'], + '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'], + '18:rls-predicate-array-comparand-refused': ['permissions'], + '18:rls-predicate-stored-list-ordering-refused': ['permissions'], +}; + +/** The keys the evaluation reads as carriers of other definitions, never as a family. */ +const CARRIER_KEYS = ['manifest', 'packages', 'plugins', 'devPlugins']; + +/** 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('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} listed on ${id}'s fixture`).toBe(true); + 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('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('the chain reports every entry exactly once, and only a proven-absent one in absentTodos (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('on a stack declaring none of the batch\'s keys, every questioned entry is absent and every other one listed', () => { + 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(ids(result.todos)).toEqual(crossed(MIGRATION_SUPPORT_FLOOR, TO).filter((k) => !(k in EXPECTED_RELEVANCE))); + }); + + it('todos and absentTodos partition the crossed entries, in chain order, hop by hop too', () => { + const result = applyMetaMigrations(structuredClone(OBJECTS_ONLY), MIGRATION_SUPPORT_FLOOR, TO); + const all = crossed(MIGRATION_SUPPORT_FLOOR, TO); + const merged = [...ids(result.todos), ...ids(result.absentTodos)]; + expect(merged.slice().sort()).toEqual(all.slice().sort()); + expect(new Set(merged).size).toBe(merged.length); + // Each array keeps chain order. + const position = new Map(all.map((k, i) => [k, i])); + for (const list of [ids(result.todos), ids(result.absentTodos)]) { + const at = list.map((k) => position.get(k)!); + expect(at).toEqual(at.slice().sort((a, b) => a - b)); + } + expect(result.hops.flatMap((h) => ids(h.todos))).toEqual(ids(result.todos)); + expect(result.hops.flatMap((h) => ids(h.absentTodos))).toEqual(ids(result.absentTodos)); + for (const h of result.hops) { + expect([...ids(h.todos), ...ids(h.absentTodos)].sort()).toEqual(crossed(h.toMajor - 1, h.toMajor).sort()); + } + }); + + it('a stack that declares the key keeps the entries listed', () => { + 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 proves nothing absent', () => { + class LocalPlugin {} + const stack = { ...structuredClone(OBJECTS_ONLY), plugins: [new LocalPlugin()] }; + const result = applyMetaMigrations(stack, 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 listed 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.todos)).toEqual(unquestioned); + }); +}); diff --git a/packages/spec/src/migrations/types.ts b/packages/spec/src/migrations/types.ts index 63ede96f2ee..35583241724 100644 --- a/packages/spec/src/migrations/types.ts +++ b/packages/spec/src/migrations/types.ts @@ -142,7 +142,7 @@ export interface SemanticMigration { * `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 - * `migrations.test.ts`, so adding one is a reviewed edit. + * `semantic-relevance.test.ts`, so adding one is a reviewed edit. */ relevantWhen?: SemanticRelevance; } From 671038431f8c5c96fcddd1f5d787cfcc39525e41 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 17:17:33 +0000 Subject: [PATCH 3/7] test(spec): the replay pin reads the partitioned todos; changeset Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 --- .../22072-migrate-meta-relevance-predicate.md | 18 ++++++++++++++++++ .../spec/src/migrations/migrations.test.ts | 9 ++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 .changeset/22072-migrate-meta-relevance-predicate.md diff --git a/.changeset/22072-migrate-meta-relevance-predicate.md b/.changeset/22072-migrate-meta-relevance-predicate.md new file mode 100644 index 00000000000..d2e7aa7090f --- /dev/null +++ b/.changeset/22072-migrate-meta-relevance-predicate.md @@ -0,0 +1,18 @@ +--- +"@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. It returns the new `absentTodos`, on the chain result and on each hop, with the entries whose question answered `absent` in all of them. `todos` keeps every other entry. Together the two arrays hold every semantic entry of every hop crossed, each once and in chain order. Entries move only on a positive proof. These cases answer `unknown` and keep the entry listed: + - 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` entry, since a plugin can register metadata the stack does not show. + + An entry that judges a conversion which applied an edit in the same run also stays listed. A consumer that reads only `todos` gets the entries this stack may owe. The rest are in `absentTodos`. +- **The first batch is 29 entries** (5 from protocol 17, 24 from protocol 18). Each one's surface lives only under named top-level stack keys: `analyticsCubes`, `apis`, `jobs`, `mappings`, `hooks`, `agents`, `tools`, `dashboards` (with `reports` and `pages` for the chart-config entry), `datasets`, `permissions` and `sharingRules`. Each entry was also checked to confirm that its acceptance criteria send the author to no stored row and no runtime door. Every other entry is listed exactly as before. +- **`os migrate meta`** prints one line after the listed notices. It counts the entries proven absent and names `--all`. A second line says that the proof covers the stack this run loaded, and not metadata a deployment stores. `--all` prints each of those entries in full, with the keys it was proven absent under. `--json` always carries them under `absentTodos`, and under `hops[].absentTodos` with `--step`. `--step` adds a `not listed` count to each hop line. A run whose only notices are proven absent still writes `--out`. diff --git a/packages/spec/src/migrations/migrations.test.ts b/packages/spec/src/migrations/migrations.test.ts index fae59edd3c4..e5c4cfc69da 100644 --- a/packages/spec/src/migrations/migrations.test.ts +++ b/packages/spec/src/migrations/migrations.test.ts @@ -603,11 +603,14 @@ describe('migration chain (ADR-0087 D3)', () => { 'action-execute-to-target', 'field-conditionalRequired-to-requiredWhen', ]); - // Semantic TODOs are advisory per-major and always surfaced for the hop, - // whatever the stack contains. - expect(result.todos.map((t) => t.id).sort()).toEqual( + // Semantic TODOs are advisory per-major and every entry of the hop is + // reported once: listed in `todos` or — only when its structured + // `relevantWhen` question proves its surface absent from this stack — in + // `absentTodos` (`semantic-relevance.test.ts` pins which entries can be). + expect([...result.todos, ...result.absentTodos].map((t) => t.id).sort()).toEqual( MIGRATIONS_BY_MAJOR[OLDEST_HOP]!.semantic.map((s) => s.id).sort(), ); + expect(result.absentTodos.filter((t) => !t.relevantWhen)).toEqual([]); expect(result.todos.length).toBeGreaterThan(0); }); From 1904d535078bd530b4c32af812a01bf909ce7aac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 17:31:21 +0000 Subject: [PATCH 4/7] chore(spec): regenerate api-surface and export-origins for the relevance types Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 --- packages/spec/api-surface/migrations.json | 3 +++ packages/spec/export-origins/migrations.json | 3 +++ 2 files changed, 6 insertions(+) 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)", From 5104a5d7458e93d770cf1efaf41913a6fa4c64c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 18:55:40 +0000 Subject: [PATCH 5/7] fix(spec,cli): todos stays whole and absentTodos names a subset; four code-door entries lose their question; tiers is a carrier Contract-review patch round: applyMetaMigrations().todos and each hop's todos keep every semantic entry again; absentTodos names the proven-absent entries as a subset of them (same objects). os migrate meta lists todos minus absentTodos. The four CEL/RLS predicate entries whose surface also names a direct evaluator or compiler caller lose relevantWhen (batch 25). A declared tiers preset answers unknown like plugins. Docs: --all in the flags table. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 --- .../22072-migrate-meta-relevance-predicate.md | 21 +++-- content/docs/upgrading.mdx | 1 + .../migrate/meta.report-order.test.ts | 65 ++++++++----- packages/cli/src/commands/migrate/meta.ts | 44 ++++++--- packages/spec/src/migrations/chain.ts | 40 ++++---- ...l-predicate-one-value-comparand-refused.ts | 1 - ...edicate-variable-root-comparand-refused.ts | 1 - ...8.rls-predicate-array-comparand-refused.ts | 1 - ...-predicate-stored-list-ordering-refused.ts | 1 - .../spec/src/migrations/migrations.test.ts | 13 +-- packages/spec/src/migrations/registry.ts | 4 - .../src/migrations/semantic-relevance.test.ts | 94 +++++++++++-------- packages/spec/src/migrations/types.ts | 59 +++++++----- 13 files changed, 204 insertions(+), 141 deletions(-) diff --git a/.changeset/22072-migrate-meta-relevance-predicate.md b/.changeset/22072-migrate-meta-relevance-predicate.md index d2e7aa7090f..4296ebc2f7e 100644 --- a/.changeset/22072-migrate-meta-relevance-predicate.md +++ b/.changeset/22072-migrate-meta-relevance-predicate.md @@ -8,11 +8,18 @@ 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. It returns the new `absentTodos`, on the chain result and on each hop, with the entries whose question answered `absent` in all of them. `todos` keeps every other entry. Together the two arrays hold every semantic entry of every hop crossed, each once and in chain order. Entries move only on a positive proof. These cases answer `unknown` and keep the entry listed: - - 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` entry, since a plugin can register metadata the stack does not show. +- **`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 also stays listed. A consumer that reads only `todos` gets the entries this stack may owe. The rest are in `absentTodos`. -- **The first batch is 29 entries** (5 from protocol 17, 24 from protocol 18). Each one's surface lives only under named top-level stack keys: `analyticsCubes`, `apis`, `jobs`, `mappings`, `hooks`, `agents`, `tools`, `dashboards` (with `reports` and `pages` for the chart-config entry), `datasets`, `permissions` and `sharingRules`. Each entry was also checked to confirm that its acceptance criteria send the author to no stored row and no runtime door. Every other entry is listed exactly as before. -- **`os migrate meta`** prints one line after the listed notices. It counts the entries proven absent and names `--all`. A second line says that the proof covers the stack this run loaded, and not metadata a deployment stores. `--all` prints each of those entries in full, with the keys it was proven absent under. `--json` always carries them under `absentTodos`, and under `hops[].absentTodos` with `--step`. `--step` adds a `not listed` count to each hop line. A run whose only notices are proven absent still writes `--out`. + 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. + - A run whose only notices are proven absent still writes `--out`. diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx index e760feaa53c..ae54752b673 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 — the only file the command writes | | `--to 17` | Stop at an intermediate major instead of this runtime's | | `--json` | Machine-readable output, for CI or an agent | 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 97de9a70a18..407fda198dc 100644 --- a/packages/cli/src/commands/migrate/meta.report-order.test.ts +++ b/packages/cli/src/commands/migrate/meta.report-order.test.ts @@ -11,9 +11,10 @@ * * ① the verdict, and every schema refusal left after the chain; * ② the applied mechanical edits; - * ③ the semantic notices the stack may owe (`todos`); - * ④ the notices proven absent from the stack (`absentTodos`) — counted on one - * line by default, listed in full under `--all` (the ABSENT pins below). + * ③ 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 * @@ -176,9 +177,19 @@ function blockLines(t: MigrationTodo): string[] { ].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(blockLines); + return listedOf(result).flatMap(blockLines); } /** ④ without `--all`: the line counting the proven-absent notices, and the line scoping the proof. */ @@ -258,7 +269,7 @@ describe('each group opens with one header line that counts it', () => { ` 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:`, ]); }); }); @@ -275,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); @@ -440,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}`); }); @@ -461,7 +472,7 @@ 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); @@ -543,7 +554,7 @@ describe('the notices proven absent leave ③ for one counting line, and --all l 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(result.todos.map((t) => blockLines(t)[0])); + 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 @@ -562,26 +573,31 @@ describe('the notices proven absent leave ③ for one counting line, and --all l 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(` ${result.todos.length} manual change(s) require your judgment:`); + 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 hold every semantic entry of every hop crossed, each exactly once', () => { - const { result } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS, undefined, { all: true }); + 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}`)); - const reported = [...result.todos, ...result.absentTodos].map((t) => `${t.toMajor}:${t.id}`); - expect(reported.slice().sort()).toEqual(crossed.slice().sort()); - expect(new Set(reported).size).toBe(reported.length); + // 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, - todos: [...r.todos, ...r.absentTodos], - absentTodos: [], - })); + 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([]); }); @@ -592,7 +608,10 @@ describe('the notices proven absent leave ③ for one counting line, and --all l CANONICAL_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS, - (r) => ({ ...r, todos: [], absentTodos: [...r.todos, ...r.absentTodos].filter((t) => t.relevantWhen) }), + (r) => { + const questioned = r.todos.filter((t) => t.relevantWhen); + return { ...r, todos: questioned, absentTodos: questioned }; + }, { out }, ); expect(result.applied).toEqual([]); diff --git a/packages/cli/src/commands/migrate/meta.ts b/packages/cli/src/commands/migrate/meta.ts index 3248b4fa1de..501d447a91c 100644 --- a/packages/cli/src/commands/migrate/meta.ts +++ b/packages/cli/src/commands/migrate/meta.ts @@ -210,7 +210,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.', @@ -220,11 +221,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; @@ -348,7 +361,7 @@ function judgesByConversion(todos: readonly MigrationTodo[]): ReadonlyMap 0 ? `, ${hop.absentTodos.length} not listed (surface absent)` : ''; - console.log(chalk.dim(` ${hop.applied.length} mechanical, ${hop.todos.length} manual${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) printNotice(t); + 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(''); } @@ -598,7 +612,7 @@ export default class MigrateMeta extends Command { 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 carries them, under absentTodos.', + + '(by default they are only counted). --json always reports them in todos and names them in absentTodos.', default: false, exclusive: ['stored'], }), @@ -732,9 +746,9 @@ export default class MigrateMeta extends Command { protocolVersion: PROTOCOL_VERSION, applied: result.applied, todos: result.todos, - // The semantic entries the chain proved irrelevant to this stack - // (ADR-0087 D3): reported beside `todos`, never dropped, so a - // machine consumer reaches them without `--all`. + // `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) => ({ diff --git a/packages/spec/src/migrations/chain.ts b/packages/spec/src/migrations/chain.ts index f854b18fa39..4776c104d69 100644 --- a/packages/spec/src/migrations/chain.ts +++ b/packages/spec/src/migrations/chain.ts @@ -45,7 +45,7 @@ export function composeMigrationChain( /** * The answer to a {@link SemanticRelevance} question over a stack. Only - * `absent` moves an entry out of the listed TODOs; `unknown` keeps it listed, + * `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'; @@ -83,8 +83,8 @@ function foldVerdicts(verdicts: Iterable): SemanticRel * * Conservative by construction (ADR-0087 D3, "never silence"): `absent` is a * positive proof — the stack is a plain object, no `plugins` / `devPlugins` - * entry could contribute what it does not show, every carrier of the keys was - * read, and none held anything. Whatever the evaluation cannot read answers + * 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 { @@ -111,10 +111,11 @@ function stackDeclaresVerdict(stack: unknown, keys: readonly string[]): Semantic } // A plugin is handed to the kernel, not read by this chain, and it can - // register metadata of any type at boot — 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']) { + // 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'); } @@ -149,7 +150,7 @@ export function semanticRelevanceVerdict( } /** - * Whether one semantic TODO leaves the listed TODOs: it carries a relevance + * 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. @@ -189,12 +190,14 @@ export class MigrationFloorError extends Error { * stack content — only {@link MigrationFloorError} when `fromMajor` is * unsupported. * - * Every semantic entry of every hop crossed is reported exactly once: in - * `todos`, or — when its `relevantWhen` question proves its surface absent from - * the stack and from every checkpoint the chain made of it — in `absentTodos` - * (ADR-0087 D3: an entry leaves the list only on a structured, stack-derived - * proof). An entry that judges a conversion which applied an edit in this run - * stays in `todos` whatever its question answers. + * 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, @@ -241,12 +244,9 @@ export function applyMetaMigrations( const todos: MigrationTodo[] = []; const absentTodos: MigrationTodo[] = []; const hops: MigrationHopResult[] = replayed.map(({ step, stack: hopStack, applied: hopApplied }) => { - const hopTodos: MigrationTodo[] = []; - const hopAbsent: MigrationTodo[] = []; - for (const s of step.semantic) { - const todo: MigrationTodo = { ...s, toMajor: step.toMajor }; - (semanticTodoAbsent(todo, checkpoints, appliedConversionIds) ? hopAbsent : hopTodos).push(todo); - } + 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); absentTodos.push(...hopAbsent); return { diff --git a/packages/spec/src/migrations/entries/semantic/18.cel-predicate-one-value-comparand-refused.ts b/packages/spec/src/migrations/entries/semantic/18.cel-predicate-one-value-comparand-refused.ts index 6ef7239746a..a9ab9d36a29 100644 --- a/packages/spec/src/migrations/entries/semantic/18.cel-predicate-one-value-comparand-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.cel-predicate-one-value-comparand-refused.ts @@ -64,5 +64,4 @@ export const entry: SemanticMigration = { + 'multiple field; rewrite each as the replacement says. Then re-check what each policy is ' + 'supposed to admit rather than assuming what it admitted before was right: several of these ' + 'admitted every write, and two folded to no restriction at all.', - relevantWhen: { kind: 'stack-declares', keys: ['permissions', 'sharingRules'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.cel-predicate-variable-root-comparand-refused.ts b/packages/spec/src/migrations/entries/semantic/18.cel-predicate-variable-root-comparand-refused.ts index b73890fcaaf..36b399a4247 100644 --- a/packages/spec/src/migrations/entries/semantic/18.cel-predicate-variable-root-comparand-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.cel-predicate-variable-root-comparand-refused.ts @@ -47,5 +47,4 @@ export const entry: SemanticMigration = { + 'condition of your sharing rules, for != or == whose other side is current_user with no key ' + 'after it, then rewrite each against the key it means (current_user.id, ' + 'current_user.organization_id or current_user.email), or with in against a membership set.', - relevantWhen: { kind: 'stack-declares', keys: ['permissions', 'sharingRules'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.rls-predicate-array-comparand-refused.ts b/packages/spec/src/migrations/entries/semantic/18.rls-predicate-array-comparand-refused.ts index 48f206e27f9..32054144a20 100644 --- a/packages/spec/src/migrations/entries/semantic/18.rls-predicate-array-comparand-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.rls-predicate-array-comparand-refused.ts @@ -47,5 +47,4 @@ export const entry: SemanticMigration = { + 'Then re-check what each policy is supposed to refuse rather than assuming the writes it ' + 'admitted before were right: before this change a != or a negated == against a list ' + 'admitted every write.', - relevantWhen: { kind: 'stack-declares', keys: ['permissions'] }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.rls-predicate-stored-list-ordering-refused.ts b/packages/spec/src/migrations/entries/semantic/18.rls-predicate-stored-list-ordering-refused.ts index 9fd7ff540fd..36837d94652 100644 --- a/packages/spec/src/migrations/entries/semantic/18.rls-predicate-stored-list-ordering-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.rls-predicate-stored-list-ordering-refused.ts @@ -53,5 +53,4 @@ export const entry: SemanticMigration = { + 'multiple lookup, and rewrite each as the replacement says. Then write a record through each ' + 'such policy: a write whose compared field holds a list now answers 400 rather than being ' + 'admitted by string comparison, so re-check what the policy is supposed to admit.', - relevantWhen: { kind: 'stack-declares', keys: ['permissions'] }, }; diff --git a/packages/spec/src/migrations/migrations.test.ts b/packages/spec/src/migrations/migrations.test.ts index e5c4cfc69da..634bc3cb4f1 100644 --- a/packages/spec/src/migrations/migrations.test.ts +++ b/packages/spec/src/migrations/migrations.test.ts @@ -603,15 +603,16 @@ describe('migration chain (ADR-0087 D3)', () => { 'action-execute-to-target', 'field-conditionalRequired-to-requiredWhen', ]); - // Semantic TODOs are advisory per-major and every entry of the hop is - // reported once: listed in `todos` or — only when its structured - // `relevantWhen` question proves its surface absent from this stack — in - // `absentTodos` (`semantic-relevance.test.ts` pins which entries can be). - expect([...result.todos, ...result.absentTodos].map((t) => t.id).sort()).toEqual( + // Semantic TODOs are advisory per-major and always surfaced for the hop, + // whatever the stack contains. + expect(result.todos.map((t) => t.id).sort()).toEqual( MIGRATIONS_BY_MAJOR[OLDEST_HOP]!.semantic.map((s) => s.id).sort(), ); - expect(result.absentTodos.filter((t) => !t.relevantWhen)).toEqual([]); 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 cc9281ce9af..18e9a37241b 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -8456,7 +8456,6 @@ const step18: MigrationStep = { + 'multiple field; rewrite each as the replacement says. Then re-check what each policy is ' + 'supposed to admit rather than assuming what it admitted before was right: several of these ' + 'admitted every write, and two folded to no restriction at all.', - relevantWhen: { kind: 'stack-declares', keys: ['permissions', 'sharingRules'] }, }, // The variable-ROOT sibling of cel-predicate-list-comparand-refused, one // comparand kind over: the same pushdown compiler, the same consumers, the same @@ -8503,7 +8502,6 @@ const step18: MigrationStep = { + 'condition of your sharing rules, for != or == whose other side is current_user with no key ' + 'after it, then rewrite each against the key it means (current_user.id, ' + 'current_user.organization_id or current_user.email), or with in against a membership set.', - relevantWhen: { kind: 'stack-declares', keys: ['permissions', 'sharingRules'] }, }, { id: 'change-management-duration-keys-retired', @@ -17688,7 +17686,6 @@ const step18: MigrationStep = { + 'Then re-check what each policy is supposed to refuse rather than assuming the writes it ' + 'admitted before were right: before this change a != or a negated == against a list ' + 'admitted every write.', - relevantWhen: { kind: 'stack-declares', keys: ['permissions'] }, }, // The cross-field comparison-class family, both arms in one entry: #20347 // refuses the comparison where it is authored (the lint rules behind @@ -17815,7 +17812,6 @@ const step18: MigrationStep = { + 'multiple lookup, and rewrite each as the replacement says. Then write a record through each ' + 'such policy: a write whose compared field holds a list now answers 400 rather than being ' + 'admitted by string comparison, so re-check what the policy is supposed to admit.', - relevantWhen: { kind: 'stack-declares', keys: ['permissions'] }, }, // The saved-report stack and the `report` metadata kind shared a word and // nothing else; this retires the stack and leaves the kind untouched. diff --git a/packages/spec/src/migrations/semantic-relevance.test.ts b/packages/spec/src/migrations/semantic-relevance.test.ts index 4522f7b82b7..c5e83960647 100644 --- a/packages/spec/src/migrations/semantic-relevance.test.ts +++ b/packages/spec/src/migrations/semantic-relevance.test.ts @@ -13,10 +13,10 @@ * - 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 — moves anything; whatever the evaluation cannot read is `unknown`. - * - CHAIN: `applyMetaMigrations` reports every entry of every hop crossed - * exactly once, in `todos` or in `absentTodos`, and only a proven-absent - * entry is in the second. + * 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'; @@ -29,8 +29,10 @@ import type { MigrationTodo, SemanticRelevance } from './types.js'; /** * The first batch: the entries whose surface lives ONLY under the named - * top-level stack keys, and whose acceptance criteria send the author to no - * stored row and no runtime door. `major:id` → the keys its question names. + * 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'], @@ -43,8 +45,6 @@ const EXPECTED_RELEVANCE: Readonly> = { '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:cel-predicate-one-value-comparand-refused': ['permissions', 'sharingRules'], - '18:cel-predicate-variable-root-comparand-refused': ['permissions', 'sharingRules'], '18:chart-config-aria-retired': ['dashboards', 'reports', 'pages'], '18:cube-join-sql-and-relationship-retired': ['analyticsCubes'], '18:cube-member-inner-name-retired': ['analyticsCubes'], @@ -60,12 +60,10 @@ const EXPECTED_RELEVANCE: Readonly> = { '18:job-timeout-unit-in-key': ['jobs'], '18:mapping-lookup-params-retired': ['mappings'], '18:permission-restore-purge-bits-retired': ['permissions'], - '18:rls-predicate-array-comparand-refused': ['permissions'], - '18:rls-predicate-stored-list-ordering-refused': ['permissions'], }; /** The keys the evaluation reads as carriers of other definitions, never as a family. */ -const CARRIER_KEYS = ['manifest', 'packages', 'plugins', 'devPlugins']; +const CARRIER_KEYS = ['manifest', 'packages', 'plugins', 'devPlugins', 'tiers']; /** Every registered entry, with the major whose step carries it. */ const ENTRIES = MIGRATION_MAJORS.flatMap((major) => @@ -84,6 +82,13 @@ describe('which entries carry a relevance question (ENUMERATION)', () => { 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([]); @@ -128,7 +133,9 @@ describe('every relevance question is a closed, structured question over top-lev MIGRATION_SUPPORT_FLOOR, major, ); - expect(result.todos.some((t) => t.id === entry.id), `${entry.id} listed on ${id}'s fixture`).toBe(true); + 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++; } } @@ -190,6 +197,15 @@ describe('the answer is three-valued, and only a positive proof is `absent` (EVA 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'); @@ -224,7 +240,7 @@ describe('the answer is three-valued, and only a positive proof is `absent` (EVA }); }); -describe('the chain reports every entry exactly once, and only a proven-absent one in absentTodos (CHAIN)', () => { +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 = { @@ -239,34 +255,36 @@ describe('the chain reports every entry exactly once, and only a proven-absent o } const ids = (todos: readonly MigrationTodo[]) => todos.map((t) => `${t.toMajor}:${t.id}`); - it('on a stack declaring none of the batch\'s keys, every questioned entry is absent and every other one listed', () => { + 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(ids(result.todos)).toEqual(crossed(MIGRATION_SUPPORT_FLOOR, TO).filter((k) => !(k in EXPECTED_RELEVANCE))); + expect(result.hops.flatMap((h) => ids(h.absentTodos))).toEqual(ids(result.absentTodos)); }); - it('todos and absentTodos partition the crossed entries, in chain order, hop by hop too', () => { + 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); - const all = crossed(MIGRATION_SUPPORT_FLOOR, TO); - const merged = [...ids(result.todos), ...ids(result.absentTodos)]; - expect(merged.slice().sort()).toEqual(all.slice().sort()); - expect(new Set(merged).size).toBe(merged.length); - // Each array keeps chain order. - const position = new Map(all.map((k, i) => [k, i])); - for (const list of [ids(result.todos), ids(result.absentTodos)]) { - const at = list.map((k) => position.get(k)!); - expect(at).toEqual(at.slice().sort((a, b) => a - b)); - } - expect(result.hops.flatMap((h) => ids(h.todos))).toEqual(ids(result.todos)); - expect(result.hops.flatMap((h) => ids(h.absentTodos))).toEqual(ids(result.absentTodos)); + 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) { - expect([...ids(h.todos), ...ids(h.absentTodos)].sort()).toEqual(crossed(h.toMajor - 1, h.toMajor).sort()); + 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 keeps the entries listed', () => { + 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) @@ -277,18 +295,20 @@ describe('the chain reports every entry exactly once, and only a proven-absent o expect(ids(result.absentTodos).filter((k) => cubeEntries.includes(k))).toEqual([]); }); - it('a stack listing a plugin proves nothing absent', () => { + it('a stack listing a plugin, or declaring a tier preset, proves nothing absent', () => { class LocalPlugin {} - const stack = { ...structuredClone(OBJECTS_ONLY), plugins: [new LocalPlugin()] }; - const result = applyMetaMigrations(stack, MIGRATION_SUPPORT_FLOOR, TO); - expect(result.absentTodos).toEqual([]); - expect(ids(result.todos)).toEqual(crossed(MIGRATION_SUPPORT_FLOOR, TO)); + 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 listed even on an empty stack', () => { + 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.todos)).toEqual(unquestioned); + 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 35583241724..cb79169a31f 100644 --- a/packages/spec/src/migrations/types.ts +++ b/packages/spec/src/migrations/types.ts @@ -34,14 +34,17 @@ import type { StackDefinitionKey } from '../stack.zod.js'; /** * A top-level stack key a {@link SemanticRelevance} question may name: any key - * `ObjectStackDefinitionSchema` declares, except the four the evaluation reads + * `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` (whose entries can contribute - * metadata no reader of the stack can see, so their presence makes every - * answer unknown). + * 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; +export type SemanticRelevanceKey = Exclude< + StackDefinitionKey, + 'manifest' | 'packages' | 'plugins' | 'devPlugins' | 'tiers' +>; /** * "Does the stack declare anything under one of these top-level keys?" — the @@ -78,12 +81,12 @@ export interface StackDeclaresRelevance { * 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 removes anything. `absent` - * (the stack provably carries no such surface) moves the entry to - * {@link MigrationChainResult.absentTodos}; `present` and `unknown` (a value - * the question cannot read, a stack that is not a plain object, or a `plugins` - * / `devPlugins` entry that can contribute metadata the stack does not show) - * keep it listed. The proof is about the definition the chain reads: metadata + * 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; @@ -132,9 +135,10 @@ export interface SemanticMigration { /** * 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 is reported in {@link MigrationChainResult.absentTodos} instead of - * `todos`, and `os migrate meta` counts it rather than listing it (`--all` - * lists it). See {@link SemanticRelevance} for the evaluation. + * 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 @@ -193,9 +197,13 @@ export interface MigrationHopResult { rationale: string; stack: Record; applied: MigrationApplication[]; - /** This hop's semantic TODOs the stack may owe — every entry `absentTodos` does not hold. */ + /** Every semantic TODO of this hop, whatever the stack holds. */ todos: MigrationTodo[]; - /** This hop's semantic TODOs whose surface the stack provably lacks (see {@link MigrationChainResult.absentTodos}). */ + /** + * 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[]; } @@ -208,18 +216,19 @@ export interface MigrationChainResult { /** Every mechanical rewrite across all hops, in application order. */ applied: MigrationApplication[]; /** - * Every semantic TODO across all hops that this stack may owe — the judgment - * delegated to the consumer. That is every entry of every hop crossed except - * the ones in {@link absentTodos}; together the two hold each entry once, in - * chain order. + * 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 semantic TODOs 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. They are - * reported here, never dropped (ADR-0087 D3, "never silence"): a printer - * counts them, and lists them on request. + * 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). */ From 1d6efd22928ed25479d6a4ffafeba739e36779b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 20:47:12 +0000 Subject: [PATCH 6/7] docs(changeset): --step adds the not-listed count only when it is not zero Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 --- .changeset/22072-migrate-meta-relevance-predicate.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/22072-migrate-meta-relevance-predicate.md b/.changeset/22072-migrate-meta-relevance-predicate.md index 4296ebc2f7e..e7b53ff9432 100644 --- a/.changeset/22072-migrate-meta-relevance-predicate.md +++ b/.changeset/22072-migrate-meta-relevance-predicate.md @@ -21,5 +21,5 @@ Clause-②: yes - **`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. + - `--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`. From 16cb55b8d0acda74b4e740e56bf1b5cbe3871bbd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 21:42:12 +0000 Subject: [PATCH 7/7] test(cli): the --out snapshot test's MigrationReport names `all: false` `MigrationReport.all` (the --all flag) is a required member of the report; the literal the early-return pin builds omitted it (TS2741 under tsconfig.test.json). No assertion changes. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 --- packages/cli/test/migrate-meta-out-snapshot.test.ts | 1 + 1 file changed, 1 insertion(+) 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',