Skip to main content

zenith_core/
schema.rs

1//! Static schema metadata for the authorable node kinds and non-node surfaces.
2//!
3//! Exposes the canonical list of node kinds, one-line summaries, and the
4//! recognized attribute names for each kind. The attribute list is derived
5//! directly from the parser's own `known_props_for_kind` table so the two
6//! can never silently diverge.
7//!
8//! Also exposes `page_attributes`, `asset_attributes`, and
9//! `document_attributes` for the three non-node authorable surfaces, derived
10//! from the same parser-side `PAGE_KNOWN_PROPS`, `ASSET_KNOWN_PROPS`, and
11//! `DOCUMENT_KNOWN_PROPS` constants.
12//!
13//! Token-type schema (`token_types`, `token_type_summary`, `token_type_descriptor`)
14//! mirrors the node-kind surface and provides agent-readable value-form, child-node
15//! structure, and minimal correct examples for every authorable token type.
16
17use crate::diag_catalog::{DIAGNOSTIC_CODES, DIAGNOSTIC_VERBS, DiagnosticCodeInfo};
18use crate::parse::transform::PAGE_KNOWN_PROPS;
19use crate::parse::transform::{ASSET_KNOWN_PROPS, DOCUMENT_KNOWN_PROPS, known_props_for_kind};
20
21// ── Canonical kind list ───────────────────────────────────────────────────────
22
23/// All authorable node kinds in their canonical KDL-name form.
24///
25/// `Unknown` is excluded: it is a forward-compat placeholder, not an authorable
26/// kind. The list is sorted for deterministic output.
27pub fn node_kinds() -> &'static [&'static str] {
28    // Exhaustive correspondence is enforced by the `node_variant_count_exhaustive`
29    // helper in the `#[cfg(test)]` drift-guard below: adding a new `Node` variant
30    // without updating that match causes a compile error in the tests module.
31    &[
32        "code",
33        "connector",
34        "ellipse",
35        "field",
36        "footnote",
37        "frame",
38        "group",
39        "image",
40        "chart",
41        "instance",
42        "line",
43        "light",
44        "mesh",
45        "pattern",
46        "polygon",
47        "polyline",
48        "rect",
49        "shape",
50        "table",
51        "text",
52        "toc",
53    ]
54}
55
56// ── One-line summaries ────────────────────────────────────────────────────────
57
58/// Return a one-line description of the named node kind, or `None` if the kind
59/// is not recognised.
60///
61/// The `match` arm set here must stay exhaustive over `node_kinds()`. The
62/// drift-guard test `node_summary_covers_every_node_kind` enforces that.
63pub fn node_summary(kind: &str) -> Option<&'static str> {
64    match kind {
65        "rect" => Some("Rectangle with optional fill, stroke, and rounded corners."),
66        "ellipse" => Some("Ellipse or circle with optional fill and stroke."),
67        "line" => Some("Straight line segment between two endpoints."),
68        "text" => Some("Multi-span text block with typography and layout properties."),
69        "code" => Some("Monospace code block with syntax-theme highlighting."),
70        "frame" => Some("Container that clips and positions its children within a fixed box."),
71        "group" => Some("Transparent grouping container for related nodes."),
72        "image" => Some("Raster or SVG image positioned within a bounding box."),
73        "polygon" => Some("Closed polygon defined by an ordered vertex list."),
74        "polyline" => Some("Open polyline defined by an ordered vertex list."),
75        "instance" => Some("Reference to a master component, optionally with overrides."),
76        "field" => Some("Editable variable-data text field bound to a named slot."),
77        "footnote" => Some("Page-level footnote referenced by text span markers."),
78        "toc" => Some("Table-of-contents placeholder resolved to text by the scene compiler."),
79        "table" => Some("Structured data table with columns, rows, and cells."),
80        "shape" => Some("Preset geometric shape with an optional text label."),
81        "connector" => Some("Directed connector line between two anchor points on nodes."),
82        "pattern" => Some("Procedural grid or scatter tiling of one motif node."),
83        "chart" => Some(
84            "Data-visualization chart (bar, line, area, sparkline, pie, donut) with inline series data.",
85        ),
86        "light" => Some("Effect node that emits a soft radial ambient light."),
87        "mesh" => {
88            Some("Effect node that emits a procedural orthographic or perspective grid mesh.")
89        }
90        _ => None,
91    }
92}
93
94/// Return a minimal, syntactically correct full-node example for a node kind.
95///
96/// This is separate from [`node_content`]: leaf nodes have no child-content
97/// descriptor, but still need an authoring example in CLI schema output.
98pub fn node_example(kind: &str) -> Option<&'static str> {
99    match kind {
100        "light" => Some(
101            "light id=\"bg.glow\" kind=\"ambient\" x=(%)85 y=(%)12 \
102             radius=(token)\"size.glow\" color=(token)\"color.glow\" opacity=0.35",
103        ),
104        "mesh" => Some(
105            "mesh id=\"bg.mesh\" kind=\"perspective\" x=(px)0 y=(px)0 w=(px)1920 h=(px)1080 \
106             rows=7 columns=8 vanishing-x=(px)1260 vanishing-y=(px)-420 extend=(px)160 \
107             stroke=(token)\"color.grid\" stroke-width=(token)\"stroke.hairline\" opacity=0.34",
108        ),
109        _ => None,
110    }
111}
112
113// ── Child content descriptors ─────────────────────────────────────────────────
114
115/// Full content descriptor for a node kind that accepts authorable child content.
116///
117/// Returned by [`node_content`].
118pub struct NodeContentDescriptor {
119    /// Short prose description of what the child content represents.
120    pub description: &'static str,
121    /// A minimal, syntactically correct example of the child content written
122    /// inside the parent node's block (without the surrounding node wrapper).
123    pub example: &'static str,
124}
125
126/// Return the child-content descriptor for the given node kind, or `None` if
127/// the kind accepts no authorable child content (e.g. `rect`, `ellipse`, `line`).
128///
129/// The match here is exhaustive over all authorable node kinds so that adding a
130/// new kind forces a deliberate decision about child content at compile time.
131/// Kinds with no child content return `None`.
132pub fn node_content(kind: &str) -> Option<NodeContentDescriptor> {
133    match kind {
134        // ── Span-bearing kinds ────────────────────────────────────────────────
135        "text" => Some(NodeContentDescriptor {
136            description: "One or more `span` children carry the text runs. \
137                Each span takes a string argument and optional inline style props: \
138                fill, font-weight, italic, underline, strikethrough, highlight, code, link, \
139                vertical-align, footnote-ref. \
140                `highlight` is a per-span background color (token ref or raw color string) \
141                rendered behind the glyph run like a marker-pen highlight. \
142                `code=#true` renders the span in the bundled monospace family with a subtle \
143                background, suitable for inline code. \
144                `link=\"url\"` renders the span underlined in the default link color (unless \
145                `fill` is set); in PDF output the URL becomes a clickable `/Link` annotation \
146                over the span. \
147                `selectable` (node attribute, default `#true`) controls PDF text extraction: \
148                by default the text is emitted as real, selectable / searchable / indexable \
149                text (with a ToUnicode map, so copy and search work and links are clickable); \
150                `selectable=#false` renders the glyphs as filled outlines instead — visually \
151                identical but not selectable, searchable, or extractable. The PNG backend is \
152                unaffected. \
153                The `format` node attribute (values: `markdown` | `plain`) opts into \
154                markdown rendering of the concatenated span text. \
155                When `format=\"markdown\"`, the scene compile pass re-parses the span content \
156                AFTER data-binding substitution and renders both inline marks and block structure. \
157                Inline marks: `**bold**`, `*italic*`, `~~strike~~`, `==highlight==`, \
158                `++underline++`, `` `code` ``, `[label](url)`. \
159                Block structure (one construct per line/paragraph): \
160                `# H1` through `###### H6` (ATX headings), blank line separates paragraphs, \
161                `> text` blockquote, `- item` / `* item` / `+ item` unordered list, \
162                `1. item` ordered list, ` ``` ` fenced code block (optional lang after opening \
163                fence; ends at closing ` ``` `), `---` / `***` / `___` horizontal rule. \
164                The block roles produced (h1..h6, p, blockquote, li, code-block, hr) are the \
165                same names styled by `block role=\"…\"` declarations (see `zenith schema block`). \
166                v1 limitation: in a `chain` flow, code-block backgrounds and `---` rules are \
167                not drawn and blockquote/list indent is not applied — these render fully only \
168                in a single non-chained text box. \
169                Pairs well with a single `data-ref` span to parse external content as markdown \
170                without encoding marks in the document. `format=\"plain\"` or absent = literal \
171                (byte-identical to today's behavior). \
172                The `src` node attribute (`src=\"path/to/file.md\"`) loads the file at the \
173                given path (resolved relative to the document's project directory) and uses its \
174                UTF-8 contents as the node's text content, replacing any inline `span` children \
175                at render time. This keeps the `.zen` file lean for long-form prose. When paired \
176                with `format=\"markdown\"`, the loaded text is parsed as markdown by the \
177                scene compile pass. A missing or unreadable file emits a `text.src_missing` \
178                Error diagnostic (same gate as `asset.missing`). The `src` field is retained \
179                on the node so a future editor can write edits back to the original file. \
180                Threaded text flow (`chain` attribute): all `text` nodes that share the same \
181                `chain=\"id\"` value form one ordered chain (document source order, across pages). \
182                The FIRST member that carries spans or `src` content is the content source; \
183                subsequent members must have EMPTY spans (no `src`, no inline spans) and serve \
184                as overflow boxes. Each member needs explicit `x`/`y`/`w`/`h` geometry. Text \
185                fills box 1, the remainder flows into box 2, etc., across page boundaries. \
186                This is how you resolve a `text.overflow` warning for long-form copy: add \
187                chained continuation boxes (on the same or new pages) until nothing overflows. \
188                Only the first member's font/style drives the whole chain; per-span overrides \
189                on the source are honored. \
190                A `block role=\"…\"` declaration may appear BEFORE span children to set per-role \
191                markdown block style at this text node's scope (highest cascade precedence: \
192                text > page > document). Block decls affect only nodes with `format=\"markdown\"` \
193                and have no effect on plain-text nodes (see `zenith schema block`).",
194            example: concat!(
195                "block role=\"h1\" font-size=(token)\"size.h1\" font-weight=(token)\"weight.bold\"\n",
196                "span \"Hello \"\n",
197                "span \"world\" font-weight=(token)\"weight.bold\" italic=#true",
198            ),
199        }),
200        "shape" => Some(NodeContentDescriptor {
201            description: "Optional `span` children form a text label rendered centered inside the \
202                shape. Use h-align/v-align on the shape node to adjust alignment. \
203                Omit the block entirely for an unlabelled shape.",
204            example: "span \"Approve\"",
205        }),
206        "footnote" => Some(NodeContentDescriptor {
207            description: "One or more `span` children carry the footnote body text, \
208                using the same span model as `text`.",
209            example: "span \"See also Chapter 3.\"",
210        }),
211
212        // ── Vertex-bearing kinds ──────────────────────────────────────────────
213        "polygon" => Some(NodeContentDescriptor {
214            description: "Two or more `point` children define the closed vertex list in order. \
215                Each point carries `x` and `y` as px-literal dimensions.",
216            example: concat!(
217                "point x=(px)0 y=(px)0\n",
218                "point x=(px)100 y=(px)0\n",
219                "point x=(px)50 y=(px)86",
220            ),
221        }),
222        "polyline" => Some(NodeContentDescriptor {
223            description: "Two or more `point` children define the open vertex list in order. \
224                Each point carries `x` and `y` as px-literal dimensions.",
225            example: concat!(
226                "point x=(px)0 y=(px)0\n",
227                "point x=(px)100 y=(px)50\n",
228                "point x=(px)200 y=(px)0",
229            ),
230        }),
231
232        // ── Structured container kinds ────────────────────────────────────────
233        "table" => Some(NodeContentDescriptor {
234            description: "Optional `column` children (each with `width=(px)N`) declare column \
235                widths; then `row` children each containing `cell` children. \
236                Each cell accepts colspan, rowspan, fill, border, h-align, v-align, \
237                and arbitrary renderable child nodes for cell content. \
238                Cell text auto-places: the cell sizes and positions its text into the content box \
239                (padding-inset), wraps to the column width, and aligns via the cell/table \
240                `h-align` (start|center|end) and `v-align` (top|middle|bottom). \
241                Omit `x`/`y`/`w`/`h` on cell text; set them only to override the auto layout. \
242                The table itself requires its own `x`/`y`/`w`/`h`.",
243            example: concat!(
244                "column width=(px)120\n",
245                "column width=(px)80\n",
246                "row {\n",
247                "    cell { text { span \"Name\" } }\n",
248                "    cell { text { span \"Score\" } }\n",
249                "}",
250            ),
251        }),
252
253        // ── Generic container kinds ───────────────────────────────────────────
254        "frame" => Some(NodeContentDescriptor {
255            description: "Arbitrary renderable child nodes (any node kind). \
256                The frame clips its children to its bounding box. \
257                Use layout=\"grid\" with columns/rows attrs for grid layout.",
258            example: "rect id=\"bg\" x=(px)0 y=(px)0 w=(px)400 h=(px)300 fill=(token)\"color.bg\"",
259        }),
260        "group" => Some(NodeContentDescriptor {
261            description: "Arbitrary renderable child nodes (any node kind). \
262                May also include `protected-region id=... x=... y=... w=... h=...` \
263                and `editable-param id=...` metadata children.",
264            example: "rect id=\"box\" x=(px)0 y=(px)0 w=(px)100 h=(px)100",
265        }),
266
267        // ── Series-bearing kind ───────────────────────────────────────────────
268        "chart" => Some(NodeContentDescriptor {
269            description: "Optional `categories` child carries the X-axis category labels as \
270                positional string arguments (one per slot; absent = derive index labels at render). \
271                Optional `label-colors` child carries per-slice value-label colors as positional \
272                PropertyValue arguments (e.g. `(token)\"color.x\"`; one per category in order; \
273                absent = use the chart value-color or the white on-fill default). \
274                Optional `slice-colors` child carries per-slice FILL colors for pie/donut as \
275                positional PropertyValue arguments (e.g. `(token)\"color.x\"`; one per category \
276                in order; absent = use the palette). \
277                Zero or more `series` children carry the numeric data. \
278                Each series node takes its f64 data values as positional arguments \
279                and optional named props: label, color (token ref), label-color (token ref), data-ref. \
280                A `data-ref=\"field\"` binds the whole series to a numeric ARRAY field supplied at \
281                render via `--data` — JSON `{\"field\":[120,185,143]}` or a CSV column named `field` \
282                (one number per category). This is render-time binding, distinct from `zenith merge`, \
283                which substitutes per-row scalar text/image via `role=\"data.<column>\"` and does not \
284                vary chart series per row. \
285                Emit `categories` then `label-colors` then `slice-colors` before any `series` children.",
286            example: concat!(
287                "categories \"Q1\" \"Q2\" \"Q3\" \"Q4\"\n",
288                "label-colors (token)\"color.c1\" (token)\"color.c2\" (token)\"color.c3\" (token)\"color.c4\"\n",
289                "slice-colors (token)\"color.s1\" (token)\"color.s2\" (token)\"color.s3\" (token)\"color.s4\"\n",
290                "series label=\"Revenue\" color=(token)\"color.primary\" label-color=(token)\"color.lbl\" 120.0 200.0 150.0 310.0\n",
291                "series label=\"Costs\" color=(token)\"color.secondary\" 80.0 90.0 100.0 120.0",
292            ),
293        }),
294        "light" | "mesh" => None,
295
296        // ── Motif-bearing kind ────────────────────────────────────────────────
297        "pattern" => Some(NodeContentDescriptor {
298            description: "Exactly one required child node — the motif — which is the template \
299                node that gets tiled. Any authorable node kind is valid as the motif.",
300            example: "rect id=\"dot\" x=(px)0 y=(px)0 w=(px)8 h=(px)8 fill=(token)\"color.accent\"",
301        }),
302
303        // ── Override-bearing kind ─────────────────────────────────────────────
304        "instance" => Some(NodeContentDescriptor {
305            description: "Zero or more `override` children apply per-node property overrides \
306                to descendants of the referenced component. Each override targets a node by \
307                `ref=\"id\"` and accepts fill, visible, and optional `span` children to \
308                replace text content.",
309            example: concat!(
310                "override ref=\"headline\" fill=(token)\"color.alt\" {\n",
311                "    span \"New headline text\"\n",
312                "}",
313            ),
314        }),
315
316        // ── Verbatim-content kind ─────────────────────────────────────────────
317        "code" => Some(NodeContentDescriptor {
318            description: "A single `content` child carries the verbatim source string as its \
319                first positional argument. Newlines and tabs are expressed as \\n and \\t \
320                escape sequences in the string literal.",
321            example: "content \"fn main() {\\n    println!(\\\"hello\\\");\\n}\"",
322        }),
323
324        // ── Connector label ───────────────────────────────────────────────────
325        "connector" => Some(NodeContentDescriptor {
326            description: "Optional `span` children form a text label rendered at the \
327                connector's geometric midpoint (the mid-point of the routed polyline). \
328                Use `text-style` on the connector node to apply a style ref to the label. \
329                Omit the block entirely (or author no `span` children) for an unlabelled \
330                connector — the render output is byte-identical when no spans are present.",
331            example: "span \"Yes\"",
332        }),
333
334        // ── No authorable child content ───────────────────────────────────────
335        "rect" | "ellipse" | "line" | "image" | "field" | "toc" => None,
336
337        // Any unrecognised kind also has no content description.
338        _ => None,
339    }
340}
341
342// ── Attribute names ───────────────────────────────────────────────────────────
343
344/// Return the recognized attribute names for the given node kind.
345///
346/// Derived from the parser's own known-props table (same source of truth as
347/// the validator's "did you mean?" helper). Alias spellings (e.g. `stroke_width`
348/// alongside `stroke-width`) are de-duplicated to their canonical kebab-case
349/// form and the result is sorted for deterministic output.
350///
351/// Returns an empty slice for unrecognised kinds or kinds without a fixed
352/// prop list (e.g. "cell", "row", "column").
353pub fn node_attributes(kind: &str) -> Vec<&'static str> {
354    // The parser's known-props table carries BOTH spellings of hyphenated
355    // attributes (e.g. `stroke-width` and `stroke_width`) for lenient parsing.
356    // For the schema surface we collapse each pair to its canonical kebab-case
357    // form via `dedupe_to_kebab`, then sort + dedup for deterministic output.
358    dedupe_to_kebab(known_props_for_kind(kind))
359}
360
361// ── Non-node surface summaries ────────────────────────────────────────────────
362
363/// One-line description of the `page` surface.
364pub fn page_summary() -> &'static str {
365    "Page declaration — geometry (w/h), margins, bleed, baseline grid, and workflow metadata."
366}
367
368/// One-line description of the `asset` surface.
369pub fn asset_summary() -> &'static str {
370    "Asset declaration (image/svg/font) — provenance including sha256 and AI-generation fields."
371}
372
373/// One-line description of the `document` surface (the root `zenith` node).
374pub fn document_summary() -> &'static str {
375    "Document root — colorspace, pagination, spread gutter, and document-level default margins."
376}
377
378// ── Non-node surface attribute lists ─────────────────────────────────────────
379
380/// Return the recognized attribute names for a `page` node.
381///
382/// Derived from the parser's own `PAGE_KNOWN_PROPS` constant. Alias spellings
383/// (e.g. `margin_inner` alongside `margin-inner`) are de-duplicated to their
384/// canonical kebab-case form; the result is sorted for deterministic output.
385pub fn page_attributes() -> Vec<&'static str> {
386    dedupe_to_kebab(PAGE_KNOWN_PROPS)
387}
388
389/// Return the recognized attribute names for an `asset` declaration node.
390///
391/// Derived from the parser's own `ASSET_KNOWN_PROPS` constant, sorted and
392/// de-duplicated for deterministic output.
393pub fn asset_attributes() -> Vec<&'static str> {
394    dedupe_to_kebab(ASSET_KNOWN_PROPS)
395}
396
397/// Return the recognized attribute names for the root `zenith` document node.
398///
399/// Derived from the parser's own `DOCUMENT_KNOWN_PROPS` constant. Alias
400/// spellings (e.g. `doc_id` alongside `doc-id`) are de-duplicated to their
401/// canonical kebab-case form; the result is sorted for deterministic output.
402pub fn document_attributes() -> Vec<&'static str> {
403    dedupe_to_kebab(DOCUMENT_KNOWN_PROPS)
404}
405
406// ── Attribute type hints ──────────────────────────────────────────────────────
407
408/// Return a concise, agent-readable type hint for the named attribute on the
409/// given node kind.
410///
411/// This is the accurate, kind-aware entry point.  For attributes whose type
412/// depends on the node kind (primarily the paint/visual attributes) the hint
413/// reflects what the validator actually enforces for that specific kind.
414///
415/// Use `attribute_type` for non-node surfaces (page, asset, document) where
416/// the attribute name alone is sufficient.
417///
418/// The hint describes *what value to write*, not the Rust representation.
419/// Categories used:
420/// - `"px literal"` — bare number suffixed `px` (e.g. `x=100px`).
421/// - `"token ref: <kind>"` — a token identifier from the token block (must
422///   reference a declared token, never a raw literal; e.g. `fill="color.brand"`).
423/// - `"f64 (0.0–1.0)"` — bare floating-point ratio.
424/// - `"i64"` — integer.
425/// - `"bool"` — `true` or `false`.
426/// - `"string"` — arbitrary string.
427/// - `"node id"` — the `id` of another node in the document.
428/// - `"string (enum)"` — one of a fixed set of values (exact set confirmed in
429///   the validator; use `zenith validate` for the authoritative list).
430/// - `"enum: a|b|…"` — one of the explicitly-listed values.
431///
432/// Returns `"string"` as a safe fallback for anything not in the dictionary.
433pub fn attribute_type_for_kind(kind: &str, name: &str) -> &'static str {
434    attribute_type_for_kind_inner(kind, name, "string")
435}
436
437/// Return a concise, agent-readable type hint for the named attribute.
438///
439/// This is the kind-agnostic entry point intended for non-node surfaces (page,
440/// asset, document) where the attribute name alone determines the type.  For
441/// node attributes, prefer [`attribute_type_for_kind`] to get accurate
442/// per-kind paint/visual hints.
443///
444/// The completeness drift test (`attribute_type_covers_all_known_attrs`) uses
445/// the kind-agnostic path with the sentinel `"<unmapped>"` fallback.
446pub fn attribute_type(name: &str) -> &'static str {
447    // Non-node surfaces carry no fill/stroke, so the kind-agnostic path is
448    // accurate for them.  We route through the kind-aware inner function with
449    // an empty kind string; the paint-specific branch will fall through to the
450    // generic arms.
451    attribute_type_for_kind_inner("", name, "string")
452}
453
454/// Internal helper — kind-aware attribute type resolution.
455///
456/// `kind` is the canonical node-kind string (e.g. `"rect"`, `"text"`) or `""`
457/// for non-node surfaces.  `fallback` is `"string"` for the public APIs and
458/// `"<unmapped>"` in the completeness drift test.
459fn attribute_type_for_kind_inner(kind: &str, name: &str, fallback: &'static str) -> &'static str {
460    // ── Kind-specific overrides for paint/visual attributes ───────────────
461    //
462    // These must be checked first, before the generic arm, because the correct
463    // token type varies by node kind.  Each entry is verified against the
464    // validator's `VisualExpect` at the cited source location.
465    //
466    // Also covers enum attributes whose value set differs by node kind (e.g.
467    // `kind` means different things on `shape` vs `pattern`).
468    match (kind, name) {
469        // fill: ColorOrGradient — rect (leaf.rs check_visual_props→shared.rs:804),
470        //   ellipse (leaf.rs:218), polygon (special.rs:83), polyline (special.rs:213),
471        //   pattern (pattern.rs:101→shared.rs:804).
472        ("rect" | "ellipse" | "polygon" | "polyline" | "pattern" | "chart", "fill") => {
473            "token ref: color/gradient"
474        }
475        // fill: Color — text (text.rs:113), shape (shape.rs:108), code (leaf.rs:561).
476        // table fill is also Color (container.rs:304→312).
477        ("text" | "shape" | "code" | "table", "fill") => "token ref: color",
478        // stroke: Color on every node kind that has it — verified at:
479        //   shared.rs:813 (rect/pattern), leaf.rs:227 (ellipse), leaf.rs:409 (line),
480        //   special.rs:92 (polygon), special.rs:222 (polyline),
481        //   text.rs:122, shape.rs:117, shape.rs:248 (connector).
482        // There is no node kind where stroke accepts a gradient.
483        (_, "stroke") => "token ref: color",
484        // shadow / filter / mask: dedicated token types; NOT color/gradient.
485        // Verified at shared.rs:960 (Shadow), 969 (Filter), 978 (Mask).
486        // These are only present on kinds that go through check_visual_props
487        // (rect, pattern) or equivalent, but the type is uniform across all kinds.
488        (_, "shadow") => "token ref: shadow",
489        (_, "filter") => "token ref: filter",
490        (_, "mask") => "token ref: mask",
491        // background: page surface only; accepts Color or Gradient (driver.rs:639).
492        // The empty-kind non-node path also falls here for correctness.
493        (_, "background") => "token ref: color/gradient",
494        // Per-side border colors and stroke-outer: Color (shared.rs:887).
495        (_, "border-top" | "border-bottom" | "border-left" | "border-right" | "stroke-outer") => {
496            "token ref: color"
497        }
498        // border (table): Color (container.rs:305→312).
499        (_, "border") => "token ref: color",
500        // contrast-bg (text): Color (text.rs:139).
501        // header-fill (table): Color (container.rs:306→312).
502        (_, "contrast-bg" | "header-fill") => "token ref: color",
503        // kind: the `kind` attribute means different things on different surfaces.
504        //   shape: process/decision/terminator/ellipse (validate/check/nodes/node/shape.rs:152).
505        //   pattern: grid/scatter (validate/check/nodes/node/pattern.rs:140).
506        //   asset surface (kind=""): image/svg/font (ast/asset.rs:29-33).
507        // The attribute name alone is insufficient — each surface has a distinct enum.
508        ("shape", "kind") => "enum: process|decision|terminator|ellipse",
509        ("pattern", "kind") => "enum: grid|scatter",
510        ("chart", "kind") => "enum: bar|line|area|sparkline|pie|donut",
511        ("light", "kind") => "enum: ambient|glow|key|rim",
512        ("light", "color") => "token ref: color",
513        ("light", "angle") => "dimension: deg",
514        ("mesh", "kind") => "enum: orthographic|perspective",
515        ("mesh", "stroke-width") | ("mesh", "stroke-dash") | ("mesh", "stroke-gap") => {
516            "dimension literal or token ref: dimension"
517        }
518        ("mesh", "x")
519        | ("mesh", "y")
520        | ("mesh", "w")
521        | ("mesh", "h")
522        | ("mesh", "vanishing-x")
523        | ("mesh", "vanishing-y")
524        | ("mesh", "extend") => "dimension literal or token ref: dimension",
525        ("mesh", "rows") | ("mesh", "columns") => "u32 (>0)",
526        ("mesh", "stroke-linecap") => "enum: butt|round|square",
527        // chart axis/legend/caption/bar-mode/orientation/legend-position/legend-layout/legend-align: chart-only attributes (validate/check/nodes/node/chart.rs).
528        ("chart", "legend") => "bool",
529        ("chart", "caption") => "string",
530        ("chart", "axis-min" | "axis-max") => "f64",
531        ("chart", "axis-style") => "string",
532        ("chart", "legend-position") => "enum: right|left|top|bottom",
533        ("chart", "legend-layout") => "enum: wrapped|list",
534        ("chart", "legend-align") => "enum: center|left|right",
535        ("chart", "bar-mode") => "enum: grouped|stacked",
536        ("chart", "orientation") => "enum: vertical|horizontal",
537        ("chart", "point-placement") => "enum: edge|center",
538        ("chart", "value-labels") => "enum: auto|none|top|center",
539        ("chart", "value-color") => "token ref: color",
540        // label-color is a named prop on series children; surfaces here for type-hint purposes.
541        ("chart", "label-color") => "token ref: color",
542        // Asset surface (non-node): kind="" is used by attribute_type() / the
543        // completeness drift test for non-node attributes.
544        ("", "kind") => "enum: image|svg|font",
545        // route: connector-only; values validated at shape.rs:309 as
546        //   straight/orthogonal/avoid (avoid is validated; maps to straight today).
547        ("connector", "route") => "enum: straight|orthogonal|avoid",
548        // layout: frame-only; AST documents absolute/flow/grid (container.rs:34-39).
549        //   Validator only enforces grid semantics (advisory) but all three values are
550        //   spec'd. Other values fall through to absolute-positioning.
551        ("frame", "layout") => "enum: absolute|flow|grid",
552        // All other attributes fall through to the generic arm below.
553        _ => attribute_type_generic(name, fallback),
554    }
555}
556
557/// Generic attribute type resolution — kind-independent properties.
558///
559/// Called by `attribute_type_for_kind_inner` for every attribute that is not
560/// a paint/visual property requiring per-kind disambiguation.
561fn attribute_type_generic(name: &str, fallback: &'static str) -> &'static str {
562    match name {
563        // ── Identity / labelling ──────────────────────────────────────────
564        "id" => "string",
565        "name" => "string",
566        "role" => "string",
567        "style" => "string",
568
569        // ── Geometry (px literals) ────────────────────────────────────────
570        "x" | "y" | "w" | "h" => "px literal or token ref: dimension",
571        "x1" | "y1" | "x2" | "y2" => "px literal",
572        "rx" | "ry" => "px literal",
573        "rotate" => "px literal",
574        "spacing" => "px literal",
575        "padding-left" | "text-indent" => "px literal",
576        "bullet-gap" => "px literal",
577        "anchor-gap" => "px literal",
578        "blur" => "px literal",
579        "bleed" => "px literal",
580        "spread-gutter" => "px literal",
581        "margin-inner" | "margin-outer" | "margin-top" | "margin-bottom" => "px literal",
582
583        // ── Visual — token refs: dimension ────────────────────────────────
584        "radius" | "radius-tl" | "radius-tr" | "radius-br" | "radius-bl" => "token ref: dimension",
585        "stroke-width" | "stroke-dash" | "stroke-gap" | "stroke-outer-width" => {
586            "token ref: dimension"
587        }
588        "border-width" => "token ref: dimension",
589        "font-size" | "font-size-min" => "token ref: dimension",
590        "baseline-grid" => "token ref: dimension",
591        "gap" | "cell-padding" | "padding" => "token ref: dimension",
592        // src-* image crop coords are px literals (geometry), not token refs.
593        "src-x" | "src-y" | "src-w" | "src-h" => "px literal",
594        // object-position values are f64 ratios, not token refs.
595        "object-position-x" | "object-position-y" => "f64 (0.0–1.0)",
596        // clip-radius is a token ref (same discipline as radius).
597        "clip-radius" => "token ref: dimension",
598
599        // ── Visual — token refs: font ─────────────────────────────────────
600        "font-family" => "token ref: fontFamily",
601        "font-weight" => "token ref: fontWeight",
602
603        // ── Floating-point ratios ─────────────────────────────────────────
604        "opacity" | "jitter" | "intensity" => "f64 (0.0–1.0)",
605
606        // ── Integers ─────────────────────────────────────────────────────
607        "seed" | "count" => "i64",
608        "drop-cap-lines" | "widow-orphan" | "tab-width" | "line-numbers" => "i64",
609        "colspan" | "rowspan" | "header-rows" | "columns" | "rows" => "i64",
610        "layer-priority" => "i64",
611
612        // ── Booleans ─────────────────────────────────────────────────────
613        "visible" | "locked" | "anchor-parent" | "selectable" => "bool",
614        "hyphenate" | "suppress-first" | "border-collapse" => "bool",
615        "mirror-margins" | "facing-pages" => "bool",
616        "line-jumps" => "bool",
617
618        // ── Named enums (values confirmed in the validator) ───────────────
619        "anchor" => {
620            "enum: top-left|top-center|top-right|center-left|center|center-right|bottom-left|bottom-center|bottom-right"
621        }
622        "anchor-edge" => "enum: above|below|before|after",
623        "align" => "enum: left|center|right|justify",
624        "overflow" => "enum: clip|visible|scroll",
625        "blend-mode" => "string (enum)",
626        "stroke-alignment" => "enum: inside|center|outside",
627        "stroke-linecap" => "enum: butt|round|square",
628        "fill-rule" => "enum: nonzero|evenodd",
629        "fit" => "enum: fill|contain|cover|none",
630        "clip" => "bool",
631        "parity" => "enum: left|right",
632        "page-parity-start" => "enum: left|right",
633        "page-progression" => "enum: ltr|rtl",
634        "colorspace" => "enum: srgb|display-p3|rec2020",
635        "direction" => "enum: ltr|rtl",
636        "overflow-wrap" => "enum: normal|break-word",
637        "h-align" => "enum: left|center|right",
638        "v-align" => "enum: top|middle|bottom",
639
640        // ── Connector-specific ────────────────────────────────────────────
641        "from" | "to" => "node id",
642        "from-anchor" | "to-anchor" => "string",
643        "marker-start" | "marker-end" => "string",
644
645        // ── Text-specific strings ─────────────────────────────────────────
646        // chain: shared id string (NOT a node id — it is a user-chosen label that
647        // groups text nodes into a threaded flow). All text nodes with the same
648        // chain value form one chain; the first span-bearing member is the content
649        // source and the rest are empty continuation boxes. See `zenith schema node text`.
650        "chain" => "string (chain id)",
651        "tab-leader" | "text-exclusion" | "bullet" => "string",
652        "language" => "string",
653        "syntax-theme" => "string",
654
655        // ── Data binding (on a `span`) ────────────────────────────────────
656        "data-ref" => "string",
657        "format" => "enum: currency|percent|number",
658        "precision" => "i64",
659        "locale" => "string",
660
661        // ── Field / TOC / Footnote ────────────────────────────────────────
662        "type" => "string",
663        "recto" | "verso" | "target" => "string",
664        "folio-style" | "header-style" | "text-style" => "string",
665        "marker" => "string",
666        "match-role" | "match-style" | "leader" => "string",
667        "flows" => "string",
668
669        // ── Image ─────────────────────────────────────────────────────────
670        "asset" => "asset id",
671
672        // ── Instance / component ──────────────────────────────────────────
673        "component" => "string",
674
675        // ── Group semantic extras ─────────────────────────────────────────
676        "semantic-role" => "string",
677
678        // ── Page workflow metadata ────────────────────────────────────────
679        "master" => "string",
680
681        // ── Document root ─────────────────────────────────────────────────
682        "version" => "string",
683        "doc-id" => "string",
684        "title" => "string",
685
686        // ── Asset provenance ──────────────────────────────────────────────
687        "src" => "string",
688        "sha256" => "string",
689        "ai-prompt" => "string",
690        "ai-model" => "string",
691        "ai-provider" => "string",
692        "ai-seed" => "i64",
693        "ai-generation-date" => "string",
694        "ai-license" => "string",
695        "ai-source-rights" => "string",
696        "ai-safety-status" => "string",
697        "ai-reuse-policy" => "string",
698
699        // ── Anchor zone ───────────────────────────────────────────────────
700        "anchor-zone" | "anchor-sibling" => "string",
701
702        _ => fallback,
703    }
704}
705
706// ── Token type list ───────────────────────────────────────────────────────────
707
708/// All authorable token types in their canonical `type=` string form.
709///
710/// `Unknown` is excluded: it is a forward-compat placeholder, not an authorable
711/// type. The list is sorted for deterministic output.
712///
713/// Exhaustive correspondence is enforced by the `token_type_variant_count_exhaustive`
714/// helper in the `#[cfg(test)]` drift-guard below: adding a new `TokenType` variant
715/// without updating that match causes a compile error in the tests module.
716pub fn token_types() -> &'static [&'static str] {
717    &[
718        "color",
719        "dimension",
720        "filter",
721        "fontFamily",
722        "fontWeight",
723        "gradient",
724        "mask",
725        "number",
726        "shadow",
727    ]
728}
729
730// ── Token type summaries ──────────────────────────────────────────────────────
731
732/// Return a one-line description of the named token type, or `None` if the type
733/// is not recognised.
734///
735/// The `match` arm set here must stay exhaustive over `token_types()`. The
736/// drift-guard test `token_type_summary_covers_every_token_type` enforces that.
737pub fn token_type_summary(ty: &str) -> Option<&'static str> {
738    match ty {
739        "color" => Some("sRGB hex, alpha-hex, or CMYK color constant."),
740        "dimension" => Some("Typed measurement with unit: px, pt, pct, or deg."),
741        "filter" => Some("Ordered stack of image filter ops (grayscale, duotone, noise, …)."),
742        "fontFamily" => Some("Named font-family string used for typography."),
743        "fontWeight" => Some("Integer font weight in 100–900 (e.g. 400 = regular, 700 = bold)."),
744        "gradient" => Some("Linear or radial gradient built from ≥2 color-stop child nodes."),
745        "mask" => Some("Spatial coverage mask: a single rect, ellipse, or rounded-rect shape."),
746        "number" => Some("Unitless finite number (e.g. opacity ratio, scale factor)."),
747        "shadow" => Some("Ordered stack of drop-shadow layers, each referencing a color token."),
748        _ => None,
749    }
750}
751
752// ── Token type descriptors ────────────────────────────────────────────────────
753
754/// Full schema descriptor for one authorable token type.
755///
756/// Returned by [`token_type_descriptor`].
757pub struct TokenTypeDescriptor {
758    /// Canonical `type=` string (matches the entry in [`token_types()`]).
759    pub type_name: &'static str,
760    /// One-line summary (same text as [`token_type_summary`]).
761    pub summary: &'static str,
762    /// Human-readable description of the value form. Empty for types that carry
763    /// no inline value (gradient, shadow, filter, mask — those use child nodes).
764    pub value_form: &'static str,
765    /// Human-readable description of the expected child nodes. Empty for scalar
766    /// types (color, dimension, number, fontFamily, fontWeight).
767    pub child_nodes: &'static str,
768    /// A minimal, syntactically correct example embedded as a standalone token
769    /// node (without the surrounding `tokens { }` block wrapper).
770    pub example: &'static str,
771}
772
773/// Return the full descriptor for the named token type, or `None` if the type
774/// is not recognised.
775///
776/// The `match` arm set here must stay exhaustive over `token_types()`. The
777/// drift-guard tests enforce that and also parse every `example` string.
778pub fn token_type_descriptor(ty: &str) -> Option<TokenTypeDescriptor> {
779    match ty {
780        "color" => Some(TokenTypeDescriptor {
781            type_name: "color",
782            summary: token_type_summary("color").unwrap_or(""),
783            value_form: r##"String literal: "#rrggbb" (6-digit lowercase hex), "#rrggbbaa" (8-digit), or "cmyk(c,m,y,k)" with each channel 0–100."##,
784            child_nodes: "",
785            example: r##"token id="color.brand.primary" type="color" value="#1a73e8""##,
786        }),
787        "dimension" => Some(TokenTypeDescriptor {
788            type_name: "dimension",
789            summary: token_type_summary("dimension").unwrap_or(""),
790            value_form: "Dimension literal: (px)N, (pt)N, (pct)N, or (deg)N — annotation then bare number, no space. E.g. (px)16, (pt)12, (pct)100, (deg)45.",
791            child_nodes: "",
792            example: r#"token id="dim.radius.card" type="dimension" value=(px)8"#,
793        }),
794        "filter" => Some(TokenTypeDescriptor {
795            type_name: "filter",
796            summary: token_type_summary("filter").unwrap_or(""),
797            value_form: "No inline value. Defined entirely by op child nodes.",
798            child_nodes: "≥1 op child node. Valid op names: grayscale, invert, sepia, saturate, brightness, contrast, hue-rotate (each accept optional amount=N); duotone (requires shadow=(token)\"id\" highlight=(token)\"id\", optional amount=N); noise (accepts seed=N scale=N, optional amount=N).",
799            example: "token id=\"filter.mono\" type=\"filter\" {\n    grayscale amount=1.0\n}",
800        }),
801        "fontFamily" => Some(TokenTypeDescriptor {
802            type_name: "fontFamily",
803            summary: token_type_summary("fontFamily").unwrap_or(""),
804            value_form: r#"Non-empty string literal: the font-family name as it appears in the asset block, e.g. "Inter" or "Source Serif 4"."#,
805            child_nodes: "",
806            example: r#"token id="font.body" type="fontFamily" value="Inter""#,
807        }),
808        "fontWeight" => Some(TokenTypeDescriptor {
809            type_name: "fontWeight",
810            summary: token_type_summary("fontWeight").unwrap_or(""),
811            value_form: "Bare integer (NOT a string, NOT a dimension): an integer in 100–900 with no unit annotation. E.g. 400, 700.",
812            child_nodes: "",
813            example: r#"token id="weight.bold" type="fontWeight" value=700"#,
814        }),
815        "gradient" => Some(TokenTypeDescriptor {
816            type_name: "gradient",
817            summary: token_type_summary("gradient").unwrap_or(""),
818            value_form: "No inline value. Defined entirely by stop child nodes plus optional angle/radial props on the token node itself.",
819            child_nodes: "≥2 stop child nodes. Each stop: stop offset=0.0 color=(token)\"color-token-id\". Optional props on the token node: angle=(deg)N (linear, default 90), radial=#true, center-x=0.5 center-y=0.5 radius=1.0.",
820            example: "token id=\"gradient.brand\" type=\"gradient\" angle=(deg)90 {\n    stop offset=0.0 color=(token)\"color.brand.primary\"\n    stop offset=1.0 color=(token)\"color.brand.secondary\"\n}",
821        }),
822        "mask" => Some(TokenTypeDescriptor {
823            type_name: "mask",
824            summary: token_type_summary("mask").unwrap_or(""),
825            value_form: "No inline value. Defined by exactly one shape child node.",
826            child_nodes: "Exactly 1 shape child: rect, ellipse, or rounded. Each accepts feather=N (Gaussian sigma px, default 0) and invert=#true/#false. rounded also accepts radius=N (corner radius px).",
827            example: "token id=\"mask.card\" type=\"mask\" {\n    rounded radius=8 feather=2\n}",
828        }),
829        "number" => Some(TokenTypeDescriptor {
830            type_name: "number",
831            summary: token_type_summary("number").unwrap_or(""),
832            value_form: "Bare finite number with no unit annotation. E.g. 1.0, 0.5, 1.05. NaN and ±inf are invalid.",
833            child_nodes: "",
834            example: r#"token id="number.line-height" type="number" value=1.4"#,
835        }),
836        "shadow" => Some(TokenTypeDescriptor {
837            type_name: "shadow",
838            summary: token_type_summary("shadow").unwrap_or(""),
839            value_form: "No inline value. Defined entirely by layer child nodes.",
840            child_nodes: "≥1 layer child node. Each layer: layer color=(token)\"color-token-id\" dx=(px)N dy=(px)N blur=(px)N. dx/dy can be negative (offsets); blur is clamped to ≥0.",
841            example: "token id=\"shadow.card\" type=\"shadow\" {\n    layer color=(token)\"color.shadow\" dx=(px)0 dy=(px)2 blur=(px)8\n}",
842        }),
843        _ => None,
844    }
845}
846
847// ── Variant / override surface ────────────────────────────────────────────────
848
849/// Full schema descriptor for the `variants` / `override` surface.
850pub struct VariantDescriptor {
851    /// One-line summary of the surface.
852    pub summary: &'static str,
853    /// Description of the `variants { … }` block structure.
854    pub block_structure: &'static str,
855    /// Description of the `variant id=… source=… w=… h=… { … }` node.
856    pub variant_node: &'static str,
857    /// Description of the `override node="<id>" …` entry and its recognised keys.
858    pub override_entry: &'static str,
859    /// Recognised properties on an `override` entry, as `(name, type, required)` tuples.
860    pub override_props: &'static [(&'static str, &'static str, bool)],
861    /// A worked example of a `variants` block containing an override.
862    pub example: &'static str,
863}
864
865/// Return the descriptor for the `variants` / `override` surface.
866///
867/// This surface is not a node kind (it is not renderable on its own), so it
868/// does not appear in `node_kinds()` or `node_summary()`. It is discoverable
869/// via `zenith schema variant`.
870pub fn variant_descriptor() -> VariantDescriptor {
871    VariantDescriptor {
872        summary: "Variant system — named page-level derivatives with per-node property overrides.",
873        block_structure: "A `variants { … }` block sits at the document root, as a sibling of \
874            `document` (canonical order: after `provenance`, before `document`) — NOT inside a \
875            page. It contains one or more `variant` entries, each with its own child block of \
876            `override` entries that apply to that variant.",
877        variant_node: "variant id=<id> source=<page-id> w=(px)N h=(px)N { … }\n\
878            \n\
879            • id         — unique identifier for this variant (string, required)\n\
880            • source     — the page id to base this variant on (page id string, required)\n\
881            • w          — override canvas width in pixels, e.g. (px)1920 (dimension, required)\n\
882            • h          — override canvas height in pixels, e.g. (px)1080 (dimension, required)\n\
883            \n\
884            The child block of `variant { … }` contains `override` entries (see below).",
885        override_entry: "override node=\"<id>\" …\n\
886            \n\
887            Targets the node whose id equals the `node=` value, and applies one or more \
888            property overrides. The `node` key is the only required field; all visual/geometry \
889            keys are optional and independent (omitted keys retain the source page value).\n\
890            \n\
891            IMPORTANT: the selector key is `node` (the target node's id string), NOT `id`.\n\
892            Wrong:   override id=\"hero\" visible=#false\n\
893            Correct: override node=\"hero\" visible=#false",
894        override_props: &[
895            ("node", "string — target node id selector (required)", true),
896            ("visible", "#true or #false", false),
897            ("text", "string — replacement text content", false),
898            ("fill", "token ref or color string", false),
899            ("x", "typed dimension, e.g. (px)100", false),
900            ("y", "typed dimension, e.g. (px)50", false),
901            ("w", "typed dimension, e.g. (px)800", false),
902            ("h", "typed dimension, e.g. (px)600", false),
903        ],
904        example: concat!(
905            "variants {\n",
906            "  variant id=\"mobile\" source=\"page.main\" w=(px)390 h=(px)844 {\n",
907            "    // hide the desktop-only sidebar\n",
908            "    override node=\"sidebar\" visible=#false\n",
909            "    // shrink the hero to fit the narrower canvas\n",
910            "    override node=\"hero\" x=(px)0 y=(px)0 w=(px)390 h=(px)260\n",
911            "    // swap the headline copy\n",
912            "    override node=\"headline\" text=\"Mobile headline\"\n",
913            "  }\n",
914            "}",
915        ),
916    }
917}
918
919// ── Diagnostics surface ────────────────────────────────────────────────────────
920
921/// One-line description of the `diagnostics` surface (the root `diagnostics { … }`
922/// lint-policy block).
923pub fn diagnostics_summary() -> &'static str {
924    "In-file diagnostic policy — allow/deny/warn specific diagnostic codes \
925     (integrity Errors cannot be suppressed)."
926}
927
928/// The policy verbs accepted inside a `diagnostics { … }` block, in canonical
929/// order (`allow`, `deny`, `warn`).
930///
931/// Single source of truth: re-exposed from [`crate::diag_catalog`].
932pub fn diagnostics_verbs() -> &'static [&'static str] {
933    DIAGNOSTIC_VERBS
934}
935
936/// The full catalog of diagnostic codes the engine can emit, each with its
937/// severity and a one-line summary.
938///
939/// Single source of truth: re-exposed from [`crate::diag_catalog`]. The same
940/// table drives the diagnostic-policy validator in [`crate::validate()`], so the
941/// `zenith schema diagnostics` surface and the policy checker can never diverge.
942pub fn diagnostic_codes() -> &'static [DiagnosticCodeInfo] {
943    DIAGNOSTIC_CODES
944}
945
946// ── Internal helpers ──────────────────────────────────────────────────────────
947
948/// Collapse a raw known-props slice (which may contain both `foo-bar` and
949/// `foo_bar` spellings) to sorted, deduplicated kebab-case names.
950///
951/// For every raw name: map underscores to hyphens to get the kebab form; then
952/// find the first entry in the slice that exactly equals that kebab string.
953/// If found, use that interned static str; otherwise keep the raw entry as-is.
954/// After collecting, sort and dedup.
955fn dedupe_to_kebab(raw: &'static [&'static str]) -> Vec<&'static str> {
956    let mut out: Vec<&'static str> = raw
957        .iter()
958        .map(|&name| {
959            let kebab = name.replace('_', "-");
960            raw.iter().copied().find(|n| *n == kebab).unwrap_or(name)
961        })
962        .collect();
963    out.sort_unstable();
964    out.dedup();
965    out
966}
967
968// ── Drift-guard tests ─────────────────────────────────────────────────────────
969
970#[cfg(test)]
971mod tests {
972    use super::*;
973    use crate::ast::Node;
974    use crate::ast::token::TokenType;
975    use crate::parse::KdlSource;
976    use crate::parse::kdl_adapter::KdlAdapter;
977
978    /// Exhaustive match over every `Node` variant: the compile-time drift guard.
979    ///
980    /// When a new variant `Node::Foo(…)` is added:
981    /// 1. The `match` here becomes non-exhaustive → **compile error**.
982    /// 2. Developer adds a `Node::Foo(_) => 1` arm here.
983    /// 3. The developer also updates `TOTAL_NODE_VARIANTS`.
984    /// 4. The `assert_eq` in `node_summary_covers_every_node_kind` then fails,
985    ///    prompting the developer to add `"foo"` to `node_kinds()` and `node_summary()`.
986    ///
987    /// This function is only ever referenced via a function pointer in the test
988    /// body (never actually called); the pointer reference forces the compiler to
989    /// type-check the exhaustive match.
990    fn node_variant_count_exhaustive(node: &Node) -> usize {
991        match node {
992            Node::Rect(_) => 1,
993            Node::Ellipse(_) => 1,
994            Node::Line(_) => 1,
995            Node::Text(_) => 1,
996            Node::Code(_) => 1,
997            Node::Frame(_) => 1,
998            Node::Group(_) => 1,
999            Node::Image(_) => 1,
1000            Node::Polygon(_) => 1,
1001            Node::Polyline(_) => 1,
1002            Node::Instance(_) => 1,
1003            Node::Field(_) => 1,
1004            Node::Footnote(_) => 1,
1005            Node::Toc(_) => 1,
1006            Node::Table(_) => 1,
1007            Node::Shape(_) => 1,
1008            Node::Connector(_) => 1,
1009            Node::Pattern(_) => 1,
1010            Node::Chart(_) => 1,
1011            Node::Light(_) => 1,
1012            Node::Mesh(_) => 1,
1013            // Unknown is intentionally excluded from the authorable kind list.
1014            // This arm is required for exhaustiveness; the count still returns 1
1015            // so the total reflects all variants (authorable + Unknown).
1016            Node::Unknown(_) => 1,
1017        }
1018    }
1019
1020    /// Total number of `Node` variants as recorded in the exhaustive match above.
1021    ///
1022    /// This is the count returned by `node_variant_count_exhaustive` for any
1023    /// `Node`, summed across all variants — i.e. the total variant count.
1024    /// Updated by hand when a variant is added (compile error forces it).
1025    const TOTAL_NODE_VARIANTS: usize = 22; // 21 authorable + 1 Unknown
1026
1027    #[test]
1028    fn node_summary_covers_every_node_kind() {
1029        // Cross-check: node_kinds() must have exactly TOTAL_NODE_VARIANTS − 1
1030        // entries (all variants except Unknown).
1031        let expected_authorable = TOTAL_NODE_VARIANTS - 1; // subtract Unknown
1032        assert_eq!(
1033            node_kinds().len(),
1034            expected_authorable,
1035            "node_kinds() has {} entries but the exhaustive Node match covers {} authorable \
1036             variants (plus Unknown). Update node_kinds() and node_summary() when adding a variant.",
1037            node_kinds().len(),
1038            expected_authorable,
1039        );
1040
1041        // Suppress the "never used" lint on node_variant_count_exhaustive by
1042        // taking a function pointer — this forces the compiler to type-check the
1043        // fn's exhaustive match without calling it.
1044        let _guard: fn(&Node) -> usize = node_variant_count_exhaustive;
1045
1046        // Every listed kind must have a summary.
1047        for kind in node_kinds() {
1048            assert!(
1049                node_summary(kind).is_some(),
1050                "node_summary(\"{kind}\") returned None — add a one-liner to node_summary()",
1051            );
1052        }
1053    }
1054
1055    // ── node_content drift guard ──────────────────────────────────────────────
1056
1057    /// Every authorable node kind that is expected to have child content must
1058    /// return `Some` from `node_content`, and the example must be non-empty.
1059    ///
1060    /// Kinds confirmed to carry authorable child content (parser-verified):
1061    /// text, shape, footnote, polygon, polyline, table, frame, group, pattern, chart, instance,
1062    /// code, connector (optional span label).
1063    #[test]
1064    fn node_content_returns_some_for_content_bearing_kinds() {
1065        let content_kinds = &[
1066            "text",
1067            "shape",
1068            "footnote",
1069            "polygon",
1070            "polyline",
1071            "table",
1072            "frame",
1073            "group",
1074            "pattern",
1075            "chart",
1076            "instance",
1077            "code",
1078            "connector",
1079        ];
1080        for &kind in content_kinds {
1081            let desc = node_content(kind);
1082            assert!(
1083                desc.is_some(),
1084                "node_content(\"{kind}\") returned None — expected Some for a content-bearing kind",
1085            );
1086            let d = desc.unwrap();
1087            assert!(
1088                !d.description.is_empty(),
1089                "node_content(\"{kind}\").description is empty",
1090            );
1091            assert!(
1092                !d.example.is_empty(),
1093                "node_content(\"{kind}\").example is empty",
1094            );
1095        }
1096    }
1097
1098    /// Kinds with no authorable child content must return `None` from `node_content`.
1099    #[test]
1100    fn node_content_returns_none_for_no_content_kinds() {
1101        let no_content_kinds = &["rect", "ellipse", "line", "image", "field", "toc"];
1102        for &kind in no_content_kinds {
1103            assert!(
1104                node_content(kind).is_none(),
1105                "node_content(\"{kind}\") returned Some — expected None for a leaf-only kind",
1106            );
1107        }
1108    }
1109
1110    #[test]
1111    fn node_attributes_nonempty_for_geometry_kinds() {
1112        // rect must include "fill", "x", and "w".
1113        let rect_attrs = node_attributes("rect");
1114        assert!(!rect_attrs.is_empty(), "rect attributes must not be empty");
1115        assert!(
1116            rect_attrs.contains(&"fill"),
1117            "rect attributes must contain \"fill\"; got: {:?}",
1118            rect_attrs
1119        );
1120        assert!(
1121            rect_attrs.contains(&"x"),
1122            "rect attributes must contain \"x\"; got: {:?}",
1123            rect_attrs
1124        );
1125        assert!(
1126            rect_attrs.contains(&"w"),
1127            "rect attributes must contain \"w\"; got: {:?}",
1128            rect_attrs
1129        );
1130
1131        // text must include "x", "y", "w", "h".
1132        let text_attrs = node_attributes("text");
1133        assert!(!text_attrs.is_empty(), "text attributes must not be empty");
1134        assert!(
1135            text_attrs.contains(&"x"),
1136            "text attributes must contain \"x\"; got: {:?}",
1137            text_attrs
1138        );
1139
1140        // pattern must include "kind" and "spacing".
1141        let pattern_attrs = node_attributes("pattern");
1142        assert!(
1143            !pattern_attrs.is_empty(),
1144            "pattern attributes must not be empty"
1145        );
1146        assert!(
1147            pattern_attrs.contains(&"kind"),
1148            "pattern attributes must contain \"kind\"; got: {:?}",
1149            pattern_attrs
1150        );
1151        assert!(
1152            pattern_attrs.contains(&"spacing"),
1153            "pattern attributes must contain \"spacing\"; got: {:?}",
1154            pattern_attrs
1155        );
1156
1157        // frame must include "x", "y", "w", "h".
1158        let frame_attrs = node_attributes("frame");
1159        assert!(
1160            !frame_attrs.is_empty(),
1161            "frame attributes must not be empty"
1162        );
1163        assert!(
1164            frame_attrs.contains(&"x"),
1165            "frame attributes must contain \"x\"; got: {:?}",
1166            frame_attrs
1167        );
1168        assert!(
1169            frame_attrs.contains(&"w"),
1170            "frame attributes must contain \"w\"; got: {:?}",
1171            frame_attrs
1172        );
1173    }
1174
1175    #[test]
1176    fn node_attributes_empty_for_unknown_kind() {
1177        assert!(
1178            node_attributes("not-a-real-kind").is_empty(),
1179            "unrecognised kinds must return an empty slice"
1180        );
1181    }
1182
1183    // ── Non-node surface drift guards ─────────────────────────────────────────
1184
1185    /// Anchor check: `page_attributes()` must be non-empty and contain the
1186    /// key geometry and workflow attrs we know the parser reads. This ensures
1187    /// `PAGE_KNOWN_PROPS` is not accidentally emptied or truncated.
1188    #[test]
1189    fn page_attributes_anchor_check() {
1190        let attrs = page_attributes();
1191        assert!(!attrs.is_empty(), "page_attributes() must not be empty");
1192        for anchor in &["w", "h", "line-jumps"] {
1193            assert!(
1194                attrs.contains(anchor),
1195                "page_attributes() must contain \"{anchor}\"; got: {attrs:?}",
1196            );
1197        }
1198        // Alias spellings must be collapsed: only the kebab form should appear.
1199        assert!(
1200            !attrs.contains(&"line_jumps"),
1201            "underscore alias \"line_jumps\" must be collapsed; got: {attrs:?}",
1202        );
1203    }
1204
1205    /// Anchor check: `asset_attributes()` must be non-empty and contain the
1206    /// provenance fields the parser reads.
1207    #[test]
1208    fn asset_attributes_anchor_check() {
1209        let attrs = asset_attributes();
1210        assert!(!attrs.is_empty(), "asset_attributes() must not be empty");
1211        for anchor in &["sha256", "ai-prompt", "ai-model", "src", "kind"] {
1212            assert!(
1213                attrs.contains(anchor),
1214                "asset_attributes() must contain \"{anchor}\"; got: {attrs:?}",
1215            );
1216        }
1217    }
1218
1219    /// Anchor check: `document_attributes()` must be non-empty and contain the
1220    /// root-node fields the parser reads.
1221    #[test]
1222    fn document_attributes_anchor_check() {
1223        let attrs = document_attributes();
1224        assert!(!attrs.is_empty(), "document_attributes() must not be empty");
1225        for anchor in &["title", "colorspace", "doc-id", "spread-gutter"] {
1226            assert!(
1227                attrs.contains(anchor),
1228                "document_attributes() must contain \"{anchor}\"; got: {attrs:?}",
1229            );
1230        }
1231        // Alias spellings must be collapsed: only the kebab form should appear.
1232        assert!(
1233            !attrs.contains(&"doc_id"),
1234            "underscore alias \"doc_id\" must be collapsed; got: {attrs:?}",
1235        );
1236    }
1237
1238    // ── Attribute type completeness drift guard ───────────────────────────
1239
1240    /// Every attribute returned by any of the four public attribute-list
1241    /// functions must have an explicit entry in `attribute_type_for_kind_inner`
1242    /// or `attribute_type_generic` — not just the silent `"string"` fallback.
1243    ///
1244    /// When a new attribute is added to a KNOWN_PROPS constant, this test
1245    /// fails with a list of unmapped names, forcing the developer to add a
1246    /// corresponding arm to the appropriate function.
1247    ///
1248    /// The sentinel `"<unmapped>"` is used here instead of `"string"` so the
1249    /// test can distinguish "no entry at all" from a deliberate `"string"`
1250    /// annotation on reference/metadata fields.
1251    ///
1252    /// For node attributes the test probes with the first kind that lists the
1253    /// attribute; the completeness check just needs at least one mapped path.
1254    /// The per-kind accuracy tests below verify the per-kind correctness.
1255    #[test]
1256    fn attribute_type_covers_all_known_attrs() {
1257        use std::collections::BTreeMap;
1258        use std::collections::BTreeSet;
1259
1260        // Build a map from each attribute name to the first kind that lists it
1261        // (so we can probe with a real kind).
1262        let mut attr_to_kind: BTreeMap<&'static str, &'static str> = BTreeMap::new();
1263        for &kind in node_kinds() {
1264            for attr in node_attributes(kind) {
1265                attr_to_kind.entry(attr).or_insert(kind);
1266            }
1267        }
1268
1269        // Non-node surface attributes: probe with empty kind (kind-agnostic path).
1270        let mut surface_attrs: BTreeSet<&'static str> = BTreeSet::new();
1271        for attr in page_attributes() {
1272            surface_attrs.insert(attr);
1273        }
1274        for attr in asset_attributes() {
1275            surface_attrs.insert(attr);
1276        }
1277        for attr in document_attributes() {
1278            surface_attrs.insert(attr);
1279        }
1280
1281        // Collect any attribute whose type resolves to the unmapped sentinel.
1282        let mut unmapped: Vec<String> = Vec::new();
1283
1284        for (attr, kind) in &attr_to_kind {
1285            if attribute_type_for_kind_inner(kind, attr, "<unmapped>") == "<unmapped>" {
1286                unmapped.push(format!("{attr} (on {kind})"));
1287            }
1288        }
1289        for attr in &surface_attrs {
1290            // Only probe surface-only attrs (those not already covered via a node kind).
1291            if !attr_to_kind.contains_key(attr)
1292                && attribute_type_for_kind_inner("", attr, "<unmapped>") == "<unmapped>"
1293            {
1294                unmapped.push(format!("{attr} (surface)"));
1295            }
1296        }
1297
1298        assert!(
1299            unmapped.is_empty(),
1300            "attribute_type_for_kind_inner() has no entry for {} attribute(s): {:?}\n\
1301             Add an arm to `attribute_type_for_kind_inner` or `attribute_type_generic` \
1302             in zenith-core/src/schema.rs.",
1303            unmapped.len(),
1304            unmapped,
1305        );
1306    }
1307
1308    // ── Paint-attribute accuracy tests ────────────────────────────────────────
1309
1310    /// `fill` must report color/gradient for geometry kinds and color-only for
1311    /// text, shape, and code — matching what the validator enforces via
1312    /// `VisualExpect`.
1313    #[test]
1314    fn fill_type_hint_is_kind_accurate() {
1315        // ColorOrGradient kinds: rect (shared.rs:804), ellipse (leaf.rs:218),
1316        // polygon (special.rs:83), polyline (special.rs:213), pattern (pattern.rs:101).
1317        for kind in &["rect", "ellipse", "polygon", "polyline", "pattern"] {
1318            assert_eq!(
1319                attribute_type_for_kind(kind, "fill"),
1320                "token ref: color/gradient",
1321                "fill on {kind} should accept color/gradient (validator uses ColorOrGradient)",
1322            );
1323        }
1324        // Color-only kinds: text (text.rs:113), shape (shape.rs:108), code (leaf.rs:561),
1325        // table (container.rs:304).
1326        for kind in &["text", "shape", "code", "table"] {
1327            assert_eq!(
1328                attribute_type_for_kind(kind, "fill"),
1329                "token ref: color",
1330                "fill on {kind} should be color-only (validator uses VisualExpect::Color)",
1331            );
1332        }
1333    }
1334
1335    /// `stroke` is Color on every kind that has it — never color/gradient.
1336    /// Verified at shared.rs:813, leaf.rs:227/409, special.rs:92/222,
1337    /// text.rs:122, shape.rs:117/248.
1338    #[test]
1339    fn stroke_type_hint_is_color_only() {
1340        for kind in &[
1341            "rect",
1342            "ellipse",
1343            "line",
1344            "polygon",
1345            "polyline",
1346            "pattern",
1347            "text",
1348            "shape",
1349            "connector",
1350        ] {
1351            assert_eq!(
1352                attribute_type_for_kind(kind, "stroke"),
1353                "token ref: color",
1354                "stroke on {kind} must be color-only (validator uses VisualExpect::Color)",
1355            );
1356        }
1357    }
1358
1359    /// `shadow`, `filter`, and `mask` must each report their own dedicated token
1360    /// type — NOT color or color/gradient.
1361    /// Verified at shared.rs:960 (Shadow), 969 (Filter), 978 (Mask).
1362    #[test]
1363    fn shadow_filter_mask_report_own_token_types() {
1364        for kind in &["rect", "pattern"] {
1365            assert_eq!(
1366                attribute_type_for_kind(kind, "shadow"),
1367                "token ref: shadow",
1368                "shadow on {kind} must reference a shadow token",
1369            );
1370            assert_eq!(
1371                attribute_type_for_kind(kind, "filter"),
1372                "token ref: filter",
1373                "filter on {kind} must reference a filter token",
1374            );
1375            assert_eq!(
1376                attribute_type_for_kind(kind, "mask"),
1377                "token ref: mask",
1378                "mask on {kind} must reference a mask token",
1379            );
1380        }
1381        // Verify the kind-agnostic public API also gives the right answer.
1382        assert_eq!(attribute_type("shadow"), "token ref: shadow");
1383        assert_eq!(attribute_type("filter"), "token ref: filter");
1384        assert_eq!(attribute_type("mask"), "token ref: mask");
1385    }
1386
1387    #[test]
1388    fn container_effect_attributes_are_discoverable() {
1389        for kind in &["group", "frame"] {
1390            let attrs = node_attributes(kind);
1391            for attr in &["shadow", "filter", "mask", "blur", "blend-mode"] {
1392                assert!(
1393                    attrs.contains(attr),
1394                    "{kind} schema must include {attr}; attrs: {attrs:?}"
1395                );
1396            }
1397            assert_eq!(attribute_type_for_kind(kind, "shadow"), "token ref: shadow");
1398            assert_eq!(attribute_type_for_kind(kind, "filter"), "token ref: filter");
1399            assert_eq!(attribute_type_for_kind(kind, "mask"), "token ref: mask");
1400        }
1401    }
1402
1403    /// `background` (page surface) reports color/gradient — driver.rs:639 uses
1404    /// VisualExpect::ColorOrGradient.
1405    #[test]
1406    fn background_type_hint_is_color_or_gradient() {
1407        assert_eq!(attribute_type("background"), "token ref: color/gradient");
1408        assert_eq!(
1409            attribute_type_for_kind("", "background"),
1410            "token ref: color/gradient"
1411        );
1412    }
1413
1414    /// Border/stroke-outer type hints are color-only — shared.rs:887 uses
1415    /// VisualExpect::Color for all per-side border props.
1416    #[test]
1417    fn border_and_stroke_outer_are_color_only() {
1418        for attr in &[
1419            "border-top",
1420            "border-bottom",
1421            "border-left",
1422            "border-right",
1423            "stroke-outer",
1424        ] {
1425            assert_eq!(
1426                attribute_type_for_kind("rect", attr),
1427                "token ref: color",
1428                "{attr} on rect must be color-only (validator uses VisualExpect::Color)",
1429            );
1430        }
1431    }
1432
1433    // ── kind / route / layout node-aware accuracy tests ──────────────────────
1434
1435    /// `kind` must report node-specific enum strings per kind.
1436    ///
1437    /// shape.kind: process/decision/terminator/ellipse (shape.rs:152).
1438    /// pattern.kind: grid/scatter (pattern.rs:140).
1439    /// These must NOT cross-contaminate: the same attribute name, different enums.
1440    #[test]
1441    fn kind_type_hint_is_node_aware() {
1442        assert_eq!(
1443            attribute_type_for_kind("shape", "kind"),
1444            "enum: process|decision|terminator|ellipse",
1445            "shape.kind must enumerate the flowchart shape variants",
1446        );
1447        assert_eq!(
1448            attribute_type_for_kind("pattern", "kind"),
1449            "enum: grid|scatter",
1450            "pattern.kind must enumerate the tiling mode variants",
1451        );
1452        // The two must differ — this is the root bug being fixed.
1453        assert_ne!(
1454            attribute_type_for_kind("shape", "kind"),
1455            attribute_type_for_kind("pattern", "kind"),
1456            "shape.kind and pattern.kind must NOT return the same hint (they are different enums)",
1457        );
1458    }
1459
1460    /// `route` on connector must enumerate straight/orthogonal/avoid.
1461    ///
1462    /// Validated at validate/check/nodes/node/shape.rs:309.
1463    #[test]
1464    fn route_type_hint_is_connector_specific() {
1465        assert_eq!(
1466            attribute_type_for_kind("connector", "route"),
1467            "enum: straight|orthogonal|avoid",
1468            "connector.route must enumerate all three routing modes",
1469        );
1470    }
1471
1472    /// `layout` on frame must enumerate absolute/flow/grid.
1473    ///
1474    /// Documented in ast/node/container.rs:34-39; grid semantics validated
1475    /// at validate/check/nodes/node/container.rs:122.
1476    #[test]
1477    fn layout_type_hint_is_frame_specific() {
1478        assert_eq!(
1479            attribute_type_for_kind("frame", "layout"),
1480            "enum: absolute|flow|grid",
1481            "frame.layout must enumerate the three layout modes",
1482        );
1483    }
1484
1485    // ── Token type drift guards ───────────────────────────────────────────────
1486
1487    /// Exhaustive match over every `TokenType` variant: the compile-time drift guard.
1488    ///
1489    /// When a new variant `TokenType::Foo` is added:
1490    /// 1. The `match` here becomes non-exhaustive → **compile error**.
1491    /// 2. Developer adds a `TokenType::Foo => 1` arm here.
1492    /// 3. The developer also updates `TOTAL_TOKEN_TYPE_VARIANTS`.
1493    /// 4. The `assert_eq` in `token_type_summary_covers_every_token_type` then
1494    ///    fails, prompting the developer to add `"foo"` to `token_types()`,
1495    ///    `token_type_summary()`, and `token_type_descriptor()`.
1496    ///
1497    /// This function is only ever referenced via a function pointer in the test
1498    /// body (never actually called); the pointer reference forces the compiler to
1499    /// type-check the exhaustive match.
1500    fn token_type_variant_count_exhaustive(ty: &TokenType) -> usize {
1501        match ty {
1502            TokenType::Color => 1,
1503            TokenType::Dimension => 1,
1504            TokenType::Number => 1,
1505            TokenType::FontFamily => 1,
1506            TokenType::FontWeight => 1,
1507            TokenType::Gradient => 1,
1508            TokenType::Shadow => 1,
1509            TokenType::Filter => 1,
1510            TokenType::Mask => 1,
1511            // Unknown is intentionally excluded from the authorable type list.
1512            // This arm is required for exhaustiveness.
1513            TokenType::Unknown(_) => 1,
1514        }
1515    }
1516
1517    /// Total number of `TokenType` variants as recorded in the exhaustive match above.
1518    /// Updated by hand when a variant is added (compile error forces it).
1519    const TOTAL_TOKEN_TYPE_VARIANTS: usize = 10; // 9 authorable + 1 Unknown
1520
1521    #[test]
1522    fn token_type_summary_covers_every_token_type() {
1523        // Cross-check: token_types() must have exactly TOTAL_TOKEN_TYPE_VARIANTS − 1
1524        // entries (all variants except Unknown).
1525        let expected_authorable = TOTAL_TOKEN_TYPE_VARIANTS - 1;
1526        assert_eq!(
1527            token_types().len(),
1528            expected_authorable,
1529            "token_types() has {} entries but the exhaustive TokenType match covers {} authorable \
1530             variants (plus Unknown). Update token_types(), token_type_summary(), and \
1531             token_type_descriptor() when adding a variant.",
1532            token_types().len(),
1533            expected_authorable,
1534        );
1535
1536        // Suppress the "never used" lint on token_type_variant_count_exhaustive by
1537        // taking a function pointer — this forces the compiler to type-check the
1538        // fn's exhaustive match without calling it.
1539        let _guard: fn(&TokenType) -> usize = token_type_variant_count_exhaustive;
1540
1541        // Every listed type must have a summary.
1542        for ty in token_types() {
1543            assert!(
1544                token_type_summary(ty).is_some(),
1545                "token_type_summary(\"{ty}\") returned None — add a one-liner to token_type_summary()",
1546            );
1547        }
1548    }
1549
1550    #[test]
1551    fn token_type_descriptor_covers_every_token_type() {
1552        // Every listed type must have a descriptor.
1553        for ty in token_types() {
1554            assert!(
1555                token_type_descriptor(ty).is_some(),
1556                "token_type_descriptor(\"{ty}\") returned None — add a descriptor to token_type_descriptor()",
1557            );
1558        }
1559
1560        // Every descriptor's type_name must match its key.
1561        for ty in token_types() {
1562            let desc = token_type_descriptor(ty).unwrap();
1563            assert_eq!(
1564                desc.type_name, *ty,
1565                "token_type_descriptor(\"{ty}\").type_name is \"{}\", expected \"{ty}\"",
1566                desc.type_name,
1567            );
1568            // summary must be non-empty.
1569            assert!(
1570                !desc.summary.is_empty(),
1571                "token_type_descriptor(\"{ty}\").summary is empty",
1572            );
1573            // value_form and child_nodes may not both be empty (every type has one or the other).
1574            assert!(
1575                !desc.value_form.is_empty() || !desc.child_nodes.is_empty(),
1576                "token_type_descriptor(\"{ty}\") has both empty value_form and child_nodes",
1577            );
1578            // example must be non-empty.
1579            assert!(
1580                !desc.example.is_empty(),
1581                "token_type_descriptor(\"{ty}\").example is empty",
1582            );
1583        }
1584    }
1585
1586    /// Example-accuracy guard: each token example must parse as part of a minimal
1587    /// document without a parse error.
1588    ///
1589    /// We do NOT assert that validation is clean — compound examples reference
1590    /// other token ids that won't resolve standalone, and that is expected. We
1591    /// only assert syntax correctness: if this fails, the schema is showing agents
1592    /// syntactically wrong examples.
1593    #[test]
1594    fn token_type_examples_parse_without_syntax_errors() {
1595        for ty in token_types() {
1596            let desc = token_type_descriptor(ty).unwrap();
1597            // Wrap the token example in a minimal document.
1598            // Only `document` is required by the parser; `tokens` is optional
1599            // but must carry `format="zenith-token-v1"` when present.
1600            let doc_src = format!(
1601                "zenith version=1 {{\n\
1602                 \x20 tokens format=\"zenith-token-v1\" {{\n\
1603                 \x20   {}\n\
1604                 \x20 }}\n\
1605                 \x20 document id=\"doc\" {{\n\
1606                 \x20   page id=\"pg\" w=(px)1 h=(px)1 {{}}\n\
1607                 \x20 }}\n\
1608                 }}\n",
1609                desc.example,
1610            );
1611            let result = KdlAdapter.parse(doc_src.as_bytes());
1612            assert!(
1613                result.is_ok(),
1614                "token_type_descriptor(\"{ty}\").example failed to parse:\n\
1615                 example:\n  {}\n\
1616                 wrapped doc:\n{doc_src}\n\
1617                 parse error: {:?}",
1618                desc.example,
1619                result.err(),
1620            );
1621        }
1622    }
1623}