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