From 9aafd2b501fefb790c0406c83fb2b8607fe98b3f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 16:37:31 +0000 Subject: [PATCH] fix(cli): every `os explain` catalog example parses against its own schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog in `packages/cli/src/commands/explain.ts` is hand-maintained and does not derive from the spec, so its examples drifted behind the schemas they claim to demonstrate. The sweep landed for #14811 parses every entry's `example` against its real schema and pinned each failure as an `it.fails` xfail. This corrects the six entries that carried one, disposes of a seventh that had no type to check against at all, and promotes every xfail to a plain assertion — the ledger is now empty. - object select `options` are objects (`{ label, value }`), not strings - field the prose row that was the source of that error: `string[]` -> `SelectOption[]`, and the example demonstrates the real shape - view `ViewSchema` is the per-object CONTAINER; a single view's keys live inside a `list` / `form` / `listViews` / `formViews` slot - agent `tools` was removed in spec 17 (ADR-0064) with no key its value moves to — teach `skills`, `model` as an object, and the required `label` / `instructions` - app nav items need `id` plus the discriminant's own target key (`objectName` / `dashboardName`); `logo` / `defaultRoute` are not AppSchema keys - dashboard widgets bind a `dataset` and select `dimensions` / `values` (ADR-0021); `'chart'` is not a widget type; there is no dashboard-level `layout` - action `objectName`, `confirmText`, and `target` for a flow action; `"button"` is not in the type enum - trigger ADR-0088 retired the kind and its `TriggerSchema` never existed — the entry becomes an explicit redirect to `hook` and a `record_change` flow, the shape `workflow` already uses Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --- ...cli-explain-catalog-parses-against-spec.md | 16 ++ packages/cli/src/commands/explain.ts | 219 ++++++++++++------ packages/cli/test/commands.test.ts | 46 ++-- 3 files changed, 193 insertions(+), 88 deletions(-) create mode 100644 .changeset/cli-explain-catalog-parses-against-spec.md diff --git a/.changeset/cli-explain-catalog-parses-against-spec.md b/.changeset/cli-explain-catalog-parses-against-spec.md new file mode 100644 index 0000000000..238ce06fb9 --- /dev/null +++ b/.changeset/cli-explain-catalog-parses-against-spec.md @@ -0,0 +1,16 @@ +--- +"@objectstack/cli": patch +--- + +`os explain` stops teaching shapes the spec rejects: all seven remaining broken catalog entries are corrected, and the sweep's xfail ledger is now empty. + +The catalog in `packages/cli/src/commands/explain.ts` is hand-maintained and does not derive from the spec, so its examples drifted behind the schemas they claim to demonstrate. The sweep landed for #14811 parses every entry's `example` against its real schema and pinned each failure as an `it.fails` xfail naming a card. This corrects all six that carried one, disposes of the seventh (an entry with no type at all), and promotes every xfail to a plain assertion — leaving the ledger empty, so the identical error cannot return silently. + +- **`object`** — select `options` are sampled as objects. A bare `['open', 'closed']` was rejected twice over (`expected object, received string`); each option is `{ label, value }`, where `value` is the stored lowercase machine identifier. +- **`field`** — the prose half of the same defect, which the sweep structurally cannot see: the optional-property table documented `options` as `type: 'string[]'`, and that row is where the `object` example's error came from. It now names `SelectOption[]` and spells the member shape out, and the entry's example demonstrates the real option list instead of a plain text field. +- **`view`** — the entry taught the wrong LEVEL, not a drifted key name. `ViewSchema` is the per-object view CONTAINER (`list` / `form` / `listViews` / `formViews`); a flat list-view literal is rejected wholesale. The tables now document the container, and the example shows a single view's own keys inside a slot. +- **`agent`** — `tools` was removed in `@objectstack/spec` 17 with no key its value moves to (ADR-0064: an agent reaches exactly the tools its surface-compatible skills declare). The entry now teaches `skills`, `model` as the configuration OBJECT it is, and the required `label` / `instructions` it had listed as optional or omitted. Two rows naming keys the schema does not have (`objects`, a top-level `temperature`) are gone. +- **`app`** — navigation items carry `id` plus the discriminant's own target key (`objectName` / `dashboardName`), not a bare `object` / `dashboard`. The table's `logo` and `defaultRoute` rows were the same class of error — neither is an `AppSchema` key, and `logo` is now rejected by name in favour of `branding`. +- **`dashboard`** — widgets bind a `dataset` and select `dimensions` / `values` by name (ADR-0021); the pre-ADR-0021 inline analytics shape (`object` + `groupBy` + `aggregate`) was removed, and `'chart'` is not a widget type at all — the enum names the concrete mark. `label` and `widgets` move to required, where the schema has them, and the non-existent dashboard-level `layout` row is dropped for the per-widget `layout: { x, y, w, h }`. +- **`trigger`** — not a wrong example but an entry with no type to check against. ADR-0088 §1 retired the `trigger` metadata kind and records that its enum comment referenced a `TriggerSchema` that never existed. The entry becomes an explicit redirect, the same shape the `workflow` entry already uses, pointing at the two delivered mechanisms the ADR names: a `hook` for synchronous in-transaction data-layer logic, a `record_change` flow for asynchronous automation. Its `docsPath` moves off a page that does not exist. +- **`action`** — `object` is `objectName`, `confirmation` is `confirmText`, and a `type: 'flow'` action names its flow in `target` (there is no `flow` key, and none was suggested, which is why the entry could not simply be renamed). The required table also advertised a `"button"` action type that is not in the enum, and a `url` key the schema does not have. diff --git a/packages/cli/src/commands/explain.ts b/packages/cli/src/commands/explain.ts index 59ea4b52eb..dde84bcebf 100644 --- a/packages/cli/src/commands/explain.ts +++ b/packages/cli/src/commands/explain.ts @@ -44,7 +44,12 @@ export const SCHEMAS: Record = { label: 'Project Task', fields: { title: { type: 'text', label: 'Title', required: true }, - status: { type: 'select', label: 'Status', options: ['open', 'closed'] }, + // Select options are OBJECTS, not bare strings: each is { label, value }, + // where value is the stored lowercase machine identifier. + status: { type: 'select', label: 'Status', options: [ + { label: 'Open', value: 'open' }, + { label: 'Closed', value: 'closed' }, + ] }, assigned_to: { type: 'lookup', label: 'Assigned To', reference: 'user' }, }, enable: { trackHistory: true, apiEnabled: true }, @@ -66,40 +71,58 @@ export const SCHEMAS: Record = { { name: 'defaultValue', type: 'any', description: 'Default value for new records' }, { name: 'maxLength', type: 'number', description: 'Maximum character length (text fields)' }, { name: 'reference', type: 'string', description: 'Target object name (lookup fields)' }, - { name: 'options', type: 'string[]', description: 'Available choices (select fields)' }, + { name: 'options', type: 'SelectOption[]', description: 'Available choices (select/multiselect fields). Each option is an OBJECT — { label, value } plus optional description / color / default / visibleWhen — never a bare string. `value` is the stored lowercase machine identifier; `label` is what the user sees.' }, ], example: `{ - type: 'text', - label: 'Email Address', + type: 'select', + label: 'Priority', required: true, - maxLength: 255, + // The option shape the spec actually validates. A bare string list + // (['high', 'low']) is rejected: expected object, received string. + options: [ + { label: 'High', value: 'high', color: '#dc2626' }, + { label: 'Normal', value: 'normal', default: true }, + { label: 'Low', value: 'low' }, + ], }`, related: ['object', 'view', 'query'], docsPath: 'data/field', }, + // `ViewSchema` is the per-object view CONTAINER, not a single view. A flat + // list-view literal written at this level is rejected wholesale: `type`, + // `columns`, `data`, `viewKind`, `filters` and `sort` all belong to a single + // VIEW, one level down. The tables below document the container; a single + // view's own keys are shown inside the example's slots. view: { - name: 'View', - description: 'Defines how data is displayed and interacted with. Views can be list views (grid, kanban, calendar) or form views (simple, tabbed, wizard).', + name: 'View (container)', + description: 'Per-object container holding that object\'s views. It is NOT a single view: the container\'s own keys are `list`, `form`, `listViews` and `formViews`, and a single view\'s keys (type, columns, filter, sort, …) go INSIDE one of those slots. List views render as grid / kanban / gallery / calendar / timeline / gantt / map / chart / tree / page; form views as simple / tabbed / wizard.', required: [ - { name: 'name', type: 'string (snake_case)', description: 'Machine name identifier' }, - { name: 'object', type: 'string', description: 'Target object to display' }, - { name: 'type', type: '"list" | "form"', description: 'View category' }, + { name: 'list | form | listViews | formViews', type: 'at least one slot', description: 'A container must register at least one view — defineView() refuses one that registers none, and a flat view literal parses to an empty container. `list` / `form` are the object\'s default views; `listViews` / `formViews` are Record maps of additional NAMED views.' }, ], optional: [ - { name: 'label', type: 'string', description: 'Display name' }, - { name: 'layout', type: '"grid" | "kanban" | "calendar" | "gantt" | "simple" | "tabbed" | "wizard"', description: 'Layout style' }, - { name: 'columns', type: 'string[]', description: 'Visible fields for list views' }, - { name: 'filters', type: 'Filter[]', description: 'Default query filters' }, - { name: 'sort', type: 'SortConfig', description: 'Default sort configuration' }, + { name: 'name', type: 'string', description: 'Item name — supplied by the metadata door; for an object-scoped container it is the object name' }, + { name: 'label', type: 'string (i18n)', description: 'Human-readable label shown in metadata lists' }, + { name: 'object', type: 'string', description: 'Object this container binds to — how a stack-level `views: [...]` entry says which object its views belong to' }, ], example: `{ - name: 'task_board', + // ── the CONTAINER's own keys ── + name: 'project_task', object: 'project_task', - type: 'list', - label: 'Task Board', - layout: 'kanban', - columns: ['title', 'status', 'assigned_to'], + label: 'Project Task Views', + // ── a single VIEW lives inside a slot, never at the level above ── + list: { + type: 'grid', + columns: ['title', 'status', 'assigned_to'], + }, + listViews: { + task_board: { + label: 'Task Board', + type: 'kanban', + columns: ['title', 'status', 'assigned_to'], + kanban: { groupByField: 'status', columns: ['title', 'assigned_to'] }, + }, + }, }`, related: ['object', 'app', 'action', 'dashboard'], docsPath: 'ui/view', @@ -152,24 +175,33 @@ export const SCHEMAS: Record = { agent: { name: 'Agent', - description: 'Autonomous AI actor that can perform tasks using tools, instructions, and context from the ObjectStack data model.', + description: 'Autonomous AI actor that performs tasks using its attached skills, instructions, and context from the ObjectStack data model. An agent reaches exactly the tools its surface-compatible SKILLS declare (ADR-0064) — there is no agent-level tool list.', required: [ { name: 'name', type: 'string (snake_case)', description: 'Machine name identifier' }, - { name: 'role', type: 'string', description: 'Agent purpose description' }, + { name: 'label', type: 'string', description: 'Agent display name' }, + { name: 'role', type: 'string', description: 'The persona/role (e.g. "Senior Support Engineer")' }, + { name: 'instructions', type: 'string', description: 'System prompt / prime directives' }, ], optional: [ - { name: 'instructions', type: 'string', description: 'System prompt / behavioral instructions' }, - { name: 'tools', type: 'ToolReference[]', description: 'Tools the agent can invoke' }, - { name: 'model', type: 'string', description: 'LLM model to use' }, - { name: 'objects', type: 'string[]', description: 'Objects the agent can access' }, - { name: 'temperature', type: 'number', description: 'Creativity parameter (0-1)' }, + { name: 'skills', type: 'string[]', description: 'Skill names to attach (Agent -> Skill -> Tool architecture, ADR-0064). This is where tool access comes from: a platform tool by its registered name, or `action_` for one of your own AI-exposed Actions — declared inside the skill, not here.' }, + { name: 'model', type: 'AIModelConfig', description: 'LLM configuration OBJECT — { provider, model, temperature, maxTokens, topP }. A bare model string is rejected, and model settings such as `temperature` live in HERE, never at the agent top level.' }, + { name: 'surface', type: '"ask" | "build"', description: 'Product surface this agent is for (default "ask")' }, + { name: 'avatar', type: 'string', description: 'Avatar image reference' }, + { name: 'access', type: 'string[]', description: 'Who can chat with this agent — enforced at the chat route' }, + { name: 'permissions', type: 'string[]', description: 'Required permission-set capabilities' }, + { name: 'active', type: 'boolean', description: 'Whether the agent is enabled (default true)' }, ], example: `{ name: 'support_agent', + label: 'Support Assistant', role: 'Customer Support Assistant', instructions: 'Help users resolve issues by searching the knowledge base.', - tools: [{ name: 'query', object: 'knowledge_article' }], - model: 'gpt-4o', + // ADR-0064: tools are reached THROUGH skills. \`agent.tools\` was removed in + // @objectstack/spec 17 and there is no key its value moves to — declare each + // tool inside a skill and attach the skill by name here. + skills: ['knowledge_lookup'], + // \`model\` is an object, and temperature belongs inside it. + model: { provider: 'openai', model: 'gpt-4o', temperature: 0.2 }, }`, related: ['object', 'flow', 'query'], docsPath: 'ai/agent', @@ -180,24 +212,26 @@ export const SCHEMAS: Record = { description: 'Application shell that groups navigation, branding, and views into a cohesive user experience.', required: [ { name: 'name', type: 'string (snake_case)', description: 'Machine name identifier' }, + { name: 'label', type: 'string', description: 'Display name' }, ], optional: [ - { name: 'label', type: 'string', description: 'Display name' }, { name: 'description', type: 'string', description: 'App description' }, - { name: 'navigation', type: 'NavItem[]', description: 'Menu tree structure' }, - { name: 'logo', type: 'string', description: 'Logo URL or asset path' }, + { name: 'navigation', type: 'NavItem[]', description: 'Menu tree. Every item needs `id` (snake_case) + `label` + a discriminant `type`, plus that type\'s own target key: object -> `objectName`, dashboard -> `dashboardName`, page -> `pageName`, url -> `url`, report -> `reportName`.' }, // `theme` here described a key that is only an alias of `branding`; the // `themes` metadata surface itself was retired at #10485 (ADR-0049) — - // `app.branding` is the one colour surface. - { name: 'branding', type: 'AppBranding', description: 'Brand colors and logo (primaryColor, accentColor, logo)' }, - { name: 'defaultRoute', type: 'string', description: 'Landing page route' }, + // `app.branding` is the one colour surface. `logo` and `defaultRoute` + // were the same class of row: neither is an AppSchema key, and both are + // now rejected by name (`logo` -> `branding`). + { name: 'branding', type: 'AppBranding', description: 'Brand colors and logo (primaryColor, accentColor, logo) — the one colour/logo surface' }, ], example: `{ name: 'project_manager', label: 'Project Manager', navigation: [ - { type: 'object', object: 'project_task', label: 'Tasks' }, - { type: 'dashboard', dashboard: 'project_overview', label: 'Overview' }, + // A nav item is discriminated on \`type\`, and each arm names its target + // with its OWN key — never a bare \`object\` / \`dashboard\`. + { id: 'nav_tasks', type: 'object', objectName: 'project_task', label: 'Tasks' }, + { id: 'nav_overview', type: 'dashboard', dashboardName: 'project_overview', label: 'Overview' }, ], }`, related: ['view', 'dashboard', 'action', 'object'], @@ -233,19 +267,45 @@ export const SCHEMAS: Record = { description: 'Grid-layout container for widgets that display aggregated data, charts, and key metrics.', required: [ { name: 'name', type: 'string (snake_case)', description: 'Machine name identifier' }, + { name: 'label', type: 'string', description: 'Display name' }, + { name: 'widgets', type: 'Widget[]', description: 'Widgets to display. Each needs `id`, a `dataset` to bind (ADR-0021) and at least one measure in `values`; `dimensions` selects the X/group axis and `type` names the concrete mark.' }, ], optional: [ - { name: 'label', type: 'string', description: 'Display name' }, - { name: 'widgets', type: 'Widget[]', description: 'Dashboard widget definitions' }, - { name: 'layout', type: 'GridLayout', description: 'Widget positioning' }, + { name: 'description', type: 'string', description: 'Dashboard description' }, + { name: 'columns', type: 'number (1-24)', description: 'Number of grid columns (default 12)' }, + { name: 'gap', type: 'number', description: 'Space between widgets, in steps of 0.25rem (4 = 1rem)' }, + // There is no dashboard-level `layout` template — the key is rejected by + // name. Positioning is per-widget: `layout: { x, y, w, h }`, and a widget + // with none is auto-flowed into the grid. { name: 'refreshIntervalSeconds', type: 'number', description: 'Auto-refresh interval in seconds' }, + { name: 'globalFilters', type: 'GlobalFilter[]', description: 'Filters broadcast into every widget\'s analytics query' }, ], example: `{ name: 'project_overview', label: 'Project Overview', + columns: 12, widgets: [ - { type: 'chart', object: 'project_task', groupBy: 'status' }, - { type: 'metric', object: 'project_task', aggregate: 'count' }, + // ADR-0021: a widget BINDS A DATASET and selects measures/dimensions by + // name. The pre-ADR-0021 inline analytics shape (object + groupBy + + // aggregate) was removed, and \`type\` names the concrete mark — there is + // no \`'chart'\` widget type. + { + id: 'tasks_by_status', + type: 'column', + title: 'Tasks by Status', + dataset: 'project_task_metrics', + dimensions: ['status'], + values: ['task_count'], + layout: { x: 0, y: 0, w: 6, h: 4 }, + }, + { + id: 'open_tasks', + type: 'metric', + title: 'Open Tasks', + dataset: 'project_task_metrics', + values: ['task_count'], + layout: { x: 6, y: 0, w: 3, h: 2 }, + }, ], }`, related: ['app', 'view', 'object'], @@ -257,23 +317,29 @@ export const SCHEMAS: Record = { description: 'User-triggered operation such as a button click, URL redirect, or screen flow launch.', required: [ { name: 'name', type: 'string (snake_case)', description: 'Machine name identifier' }, - { name: 'type', type: '"button" | "url" | "flow" | "api"', description: 'Action type' }, + { name: 'label', type: 'string (i18n)', description: 'Display label' }, + { name: 'type', type: '"script" | "url" | "modal" | "flow" | "api" | "form"', description: 'Action type. There is no "button" type — a button is a LOCATION, not a kind.' }, + { name: 'target', type: 'string', description: 'Required for every type except `script`: the URL, flow id, modal name or API endpoint this action points at. This — not a per-type `flow` / `url` key — is how a flow action names its flow.' }, ], optional: [ - { name: 'label', type: 'string', description: 'Display text' }, - { name: 'icon', type: 'string', description: 'Button icon' }, - { name: 'object', type: 'string', description: 'Target object context' }, - { name: 'flow', type: 'string', description: 'Flow to launch (for flow actions)' }, - { name: 'url', type: 'string', description: 'Target URL (for url actions)' }, - { name: 'confirmation', type: 'string', description: 'Confirmation dialog message' }, + { name: 'icon', type: 'string', description: 'Icon name' }, + { name: 'objectName', type: 'string (snake_case)', description: 'Target object this action belongs to; when set, defineStack() merges it into that object\'s actions' }, + { name: 'confirmText', type: 'string (i18n)', description: 'Confirmation message before execution. Correct on a param-LESS action; pairing it with a non-empty `params` is refused — put the question on `description` instead.' }, + { name: 'locations', type: 'ActionLocation[]', description: 'Where the action is offered (record header, list toolbar, row, …)' }, + { name: 'params', type: 'ActionParam[]', description: 'User-input collection for the action dialog' }, + { name: 'variant', type: '"primary" | "secondary" | "danger" | "ghost" | "link"', description: 'Button visual variant' }, + { name: 'visible', type: 'Expression', description: 'Visibility predicate (CEL); the action is offered when it evaluates TRUE' }, + { name: 'requiredPermissions', type: 'string[]', description: 'Capabilities required to invoke this action — enforced with 403 on the platform action route' }, ], example: `{ name: 'close_task', type: 'flow', label: 'Close Task', - object: 'project_task', - flow: 'close_task_flow', - confirmation: 'Are you sure you want to close this task?', + // \`objectName\`, not \`object\`; \`target\` carries the flow id (there is no + // \`flow\` key); \`confirmText\`, not \`confirmation\`. + objectName: 'project_task', + target: 'close_task_flow', + confirmText: 'Are you sure you want to close this task?', }`, related: ['flow', 'view', 'app'], docsPath: 'ui/action', @@ -298,30 +364,35 @@ export const SCHEMAS: Record = { docsPath: 'automation/workflows', }, + // Kept as a redirect topic, the same shape as `workflow` above: there is NO + // standalone Trigger authoring type. ADR-0088 §1 retired the `trigger` + // metadata kind — it had no stack collection, no `defineTrigger`, no FS + // loader, no executor, and "its enum comment referenced a `TriggerSchema` + // that never existed". Two delivered mechanisms cover "data change -> + // reaction" with a clean seam, and the ADR states the prescription outright: + // *Authors: use `hook` for sync data-layer logic, a `record_change` flow for + // async automation.* trigger: { - name: 'Trigger', - description: 'Event-driven automation hook that fires when specific data events occur on an object.', - required: [ - { name: 'name', type: 'string (snake_case)', description: 'Machine name identifier' }, - { name: 'object', type: 'string', description: 'Target object to monitor' }, - { name: 'event', type: '"beforeInsert" | "afterInsert" | "beforeUpdate" | "afterUpdate" | "beforeDelete" | "afterDelete"', description: 'Event type' }, - ], - optional: [ - { name: 'label', type: 'string', description: 'Display name' }, - { name: 'condition', type: 'FilterExpression', description: 'Conditional guard' }, - { name: 'flow', type: 'string', description: 'Flow to execute' }, - { name: 'async', type: 'boolean', description: 'Whether to execute asynchronously' }, - ], - example: `{ - name: 'task_created_notify', - object: 'project_task', - event: 'afterInsert', - label: 'Notify on Task Creation', - flow: 'send_task_notification', - async: true, -}`, + name: 'Trigger (no standalone type)', + description: 'ObjectStack has no standalone Trigger authoring type — ADR-0088 retired the kind, and the `TriggerSchema` its registry comment once referenced never existed. Use a `hook` for synchronous, in-transaction data-layer logic (24 lifecycle events), and a Flow of `type: \'record_change\'` for asynchronous, observable/pausable automation.', + required: [], + optional: [], + example: `// No trigger metadata exists (ADR-0088). Use the delivered mechanisms: +// +// - hook — sync, data-layer, in-transaction (HookSchema): +// { name: 'notify_on_task_create', +// object: 'project_task', +// events: ['afterInsert'], // \`events\` is an ARRAY; \`event\` is an alias, not a key +// body: { ... } } // a hook's code slot is \`body\` — there is no \`flow\` key +// +// - Flow (type: 'record_change') — async, business-layer, observable/pausable. +// It binds its object on the START node's config, not at the top level. +// +// The \`triggers\` capability token in a package's \`requires:\` is a DIFFERENT +// namespace and is unaffected by the retirement. +// See: os explain flow`, related: ['object', 'flow', 'workflow'], - docsPath: 'automation/trigger', + docsPath: 'automation/hooks', }, }; diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index 31d478239f..57af6b8bfd 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -220,16 +220,24 @@ describe('os explain — every catalog entry swept against its spec schema (#148 // Entries with one schema to parse against. `card` marks a known-broken one // and names where its errors are recorded; its absence means "must parse". + // + // ⭐ The xfail ledger is EMPTY: #15170–#15175 corrected the six entries that + // carried a card, and each xfail was promoted to the plain assertion below in + // that same change — which is the whole point of the sweep. Leaving a + // corrected entry as an `it.fails` would let the identical error return + // silently, because `it.fails` is green on ANY failure. The machinery stays + // for the next entry that arrives broken: give it a `card` and it becomes an + // xfail again, with a filed number attached rather than a quiet skip. const BOUND: Record = { - object: { schema: 'ObjectSchema', card: 15170 }, + object: { schema: 'ObjectSchema' }, field: { schema: 'FieldSchema' }, - view: { schema: 'ViewSchema', card: 15171 }, + view: { schema: 'ViewSchema' }, flow: { schema: 'FlowSchema' }, - agent: { schema: 'AgentSchema', card: 15172 }, - app: { schema: 'AppSchema', card: 15173 }, + agent: { schema: 'AgentSchema' }, + app: { schema: 'AppSchema' }, query: { schema: 'QuerySchema' }, - dashboard: { schema: 'DashboardSchema', card: 15174 }, - action: { schema: 'ActionSchema', card: 15175 }, + dashboard: { schema: 'DashboardSchema' }, + action: { schema: 'ActionSchema' }, }; // Entries with NO single schema to parse against, and the reason each of the @@ -239,7 +247,9 @@ describe('os explain — every catalog entry swept against its spec schema (#148 'there is no standalone Workflow authoring type (ADR-0019) — the entry is a ' + 'redirect and its example is commentary, not a literal', trigger: - 'no `TriggerSchema` exists in the spec, and the sample is not a Hook either (#15176)', + 'ADR-0088 retired the `trigger` metadata kind — no `TriggerSchema` exists and ' + + 'none ever did, so the entry is a redirect to `hook` / a `record_change` flow ' + + 'and its example is commentary, not a literal (#15176)', }; it('classifies every entry in SCHEMAS — none is silently unswept', () => { @@ -296,13 +306,21 @@ describe('os explain — every catalog entry swept against its spec schema (#148 it(`os explain trigger — ${UNBOUND.trigger}`, () => { expect('TriggerSchema' in specSurface).toBe(false); - // …and it is not `HookSchema` under another name. Ruling the one real - // candidate out is what makes the unbound classification a measurement - // instead of an assumption: `event` is a strict-object ALIAS of `events` - // (the same alias-as-a-documented-key failure the `flow` entry had), and a - // hook's code slot is `handler`, so the entry's `flow` key is unrecognised. + expect(catalog.trigger.name).toContain('no standalone type'); + // The redirect points at a mechanism that EXISTS — asserted, not assumed. + // Before #15176 this line ruled `HookSchema` out as a candidate for the old + // entry's literal (`event` is a strict-object ALIAS of `events`, and a + // hook's code slot is `body`/`handler`, so its `flow` key was unrecognised). + // The entry no longer offers a literal to rule out; what the assertion has + // to defend now is the other half — that the reader is being sent somewhere + // real. A redirect naming a schema the spec does not have would be the same + // defect one level up. const hook = specSurface.HookSchema as ZodLike | undefined; - expect(typeof hook?.safeParse, 'HookSchema — the candidate this rules out').toBe('function'); - expect(hook!.safeParse(evaluate('trigger')).success).toBe(false); + expect(typeof hook?.safeParse, 'HookSchema — the mechanism this entry redirects to').toBe('function'); + // Its example is commentary about the live mechanisms, not a literal — + // asserted for the same reason the `workflow` entry above asserts it, so + // "nothing was parsed here" is a property of this file rather than an + // omission a reader has to notice. + expect(() => evaluate('trigger')).toThrow(); }); });