Skip to main content

omni_dev/atlassian/adf_schema/
mod.rs

1//! ADF content-model schema and structural validator.
2//!
3//! Encodes the per-parent content expressions from the upstream
4//! `@atlaskit/adf-schema` npm package as a static lookup table, and exposes a
5//! [`validate_document`] walker that reports nesting **and** arity violations
6//! against that model.
7//!
8//! # Source of truth
9//!
10//! The lookup table is a manual transcription of the per-node `content:`
11//! expressions defined in the upstream schema. The pinned version is recorded
12//! in [`SCHEMA_VERSION`] and the upstream tarball SHA-256 in
13//! [`UPSTREAM_TARBALL_SHA256`]. When refreshing the snapshot, bump both
14//! constants and re-verify each entry against the upstream source files
15//! (`packages/adf-schema/src/schema/nodes/<node>.ts`).
16//!
17//! # Forward compatibility
18//!
19//! The walker is **permissive on unknown parents**: a node whose `node_type` is
20//! not in the table is treated as opaque and its children are not validated.
21//! This preserves the round-trip guarantee of ADR-0020's `adf-unsupported`
22//! escape hatch — the validator never rejects a document just because it
23//! contains a node type the snapshot doesn't know about.
24//!
25//! `unsupportedBlock` and `unsupportedInline` are accepted under any known
26//! parent and **count toward arity** for the parent's current content term, so
27//! a round-tripped document carrying a preservation wrapper still satisfies
28//! the parent's `+` / `Exactly(n)` requirements.
29//!
30//! # Coverage in this slice
31//!
32//! - Allowed-children sets for every container node type (PR #717 / ADR-0023).
33//! - Per-term quantifiers (`?`, `*`, `+`, exact, range) and per-parent term
34//!   sequences (PR #733). Empty `bulletList`, two-`media` `mediaSingle`,
35//!   `layoutSection` with one column, etc. are all flagged via
36//!   [`AdfSchemaViolation::Arity`].
37//!
38//! Mark whitelists and attribute schemas are still out of scope; they are
39//! addressed in the follow-up sub-PRs of #733.
40
41use std::collections::{BTreeMap, BTreeSet, HashMap};
42use std::sync::LazyLock;
43
44use crate::atlassian::adf::{AdfDocument, AdfNode};
45
46pub mod drift;
47pub mod generated;
48
49/// Crate-internal view of the schema as a `BTreeMap`, used by the drift
50/// detector to diff against an upstream-derived map of the same shape.
51///
52/// Built by flattening every parent's [`CONTENT_ENTRIES`] terms into the
53/// union of their atoms. Quantifier and order information is intentionally
54/// stripped because the drift detector compares against the upstream JSON
55/// schema's `anyOf` of `$ref` items, which has the same flat-set shape.
56#[must_use]
57pub(crate) fn local_schema_map() -> BTreeMap<&'static str, BTreeSet<&'static str>> {
58    let mut m = BTreeMap::new();
59    for (parent, terms) in CONTENT_ENTRIES {
60        let children: BTreeSet<&'static str> =
61            terms.iter().flat_map(|t| t.atoms.iter().copied()).collect();
62        m.insert(*parent, children);
63    }
64    m
65}
66
67/// Pinned upstream schema version.
68///
69/// Format: `<npm-package-version>-<transcription-date>`. Bumped manually when
70/// the lookup table is refreshed against a new upstream release.
71pub const SCHEMA_VERSION: &str = "56.0.9-2026-06-30";
72
73/// SHA-256 of the upstream `@atlaskit/adf-schema` tarball used as the source
74/// for the current transcription.
75///
76/// Recorded for reproducibility. Kept here (not in the ADR) so the binary
77/// itself carries the provenance and so refreshing the snapshot is a single
78/// file change.
79///
80/// To verify locally:
81/// ```text
82/// curl -sL https://registry.npmjs.org/@atlaskit/adf-schema/-/adf-schema-56.0.9.tgz \
83///   | shasum -a 256
84/// ```
85pub const UPSTREAM_TARBALL_SHA256: &str =
86    "a8345cc658048efd681c927814498d2cb83f1c75094ae2528f788b4aaa6a5c1f";
87
88// -----------------------------------------------------------------------------
89// Quantifier and content-term types
90// -----------------------------------------------------------------------------
91
92/// A quantifier applied to a single term in a parent's content expression.
93///
94/// Mirrors the ProseMirror content-expression grammar used by
95/// `@atlaskit/adf-schema`. `Range` is used for the only known range case
96/// (`layoutSection` permits 2–3 `layoutColumn` children); `Exactly` is used
97/// for `mediaSingle`'s required `media` child.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum Quantifier {
100    /// `?` — zero or one (optional).
101    ZeroOrOne,
102    /// `*` — zero or more.
103    ZeroOrMore,
104    /// `+` — one or more.
105    OneOrMore,
106    /// `{n}` — exactly `n`.
107    Exactly(usize),
108    /// `{min,max}` — between `min` and `max` inclusive.
109    Range(usize, usize),
110}
111
112impl Quantifier {
113    /// True when a count of `n` is acceptable for this quantifier.
114    #[must_use]
115    pub fn satisfied_by(&self, n: usize) -> bool {
116        match *self {
117            Self::ZeroOrOne => n <= 1,
118            Self::ZeroOrMore => true,
119            Self::OneOrMore => n >= 1,
120            Self::Exactly(k) => n == k,
121            Self::Range(lo, hi) => n >= lo && n <= hi,
122        }
123    }
124
125    /// Human-readable phrasing used in [`AdfSchemaViolation`] messages.
126    fn phrasing(&self) -> String {
127        match *self {
128            Self::ZeroOrOne => "at most one".to_string(),
129            Self::ZeroOrMore => "any number of".to_string(),
130            Self::OneOrMore => "at least one".to_string(),
131            Self::Exactly(1) => "exactly one".to_string(),
132            Self::Exactly(n) => format!("exactly {n}"),
133            Self::Range(lo, hi) => format!("between {lo} and {hi}"),
134        }
135    }
136}
137
138/// One term in a parent's content expression: an atom (or alternation of
139/// atoms), with a quantifier.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct ContentTerm {
142    /// One or more allowed node types. A list of length 1 is a single atom; a
143    /// list of length >1 is an alternation.
144    pub atoms: &'static [&'static str],
145    /// Quantifier applied to this term.
146    pub quant: Quantifier,
147}
148
149// -----------------------------------------------------------------------------
150// Violation enum
151// -----------------------------------------------------------------------------
152
153/// A structural violation reported by the validator.
154///
155/// Each variant corresponds to a distinct class of issue so callers can opt in
156/// to strictness (e.g. surface only [`Self::DisallowedChild`] today, then layer
157/// in arity checks once their pipeline is ready). New variants are added in
158/// later sub-PRs of #733 (marks, attributes); pattern matches should remain
159/// non-exhaustive-aware.
160#[derive(Debug, Clone, PartialEq)]
161pub enum AdfSchemaViolation {
162    /// A child node type appears under a parent that does not permit it.
163    DisallowedChild {
164        /// The `node_type` of the offending child.
165        child_type: String,
166        /// The `node_type` of the parent that does not permit the child.
167        parent_type: String,
168        /// Index path from the document root to the offending child.
169        ///
170        /// Each element is the position of the node in its parent's `content`
171        /// array. The last element identifies the child within its parent.
172        path: Vec<usize>,
173    },
174
175    /// A parent has the wrong number of children matching one of its content
176    /// terms.
177    ///
178    /// Examples:
179    /// - `mediaSingle` with two `media` children: `expected = Exactly(1)`,
180    ///   `actual = 2`, `atoms = ["media"]`.
181    /// - Empty `bulletList`: `expected = OneOrMore`, `actual = 0`,
182    ///   `atoms = ["listItem"]`.
183    /// - `layoutSection` with one column: `expected = Range(2, 3)`,
184    ///   `actual = 1`, `atoms = ["layoutColumn"]`.
185    Arity {
186        /// The `node_type` of the parent whose content count is wrong.
187        parent_type: String,
188        /// The term's atoms (alternation list). Length 1 for a single atom,
189        /// >1 for an alternation like `["tableCell", "tableHeader"]`.
190        atoms: Vec<&'static str>,
191        /// The quantifier the term expects.
192        expected: Quantifier,
193        /// The actual number of children matching the term's atoms.
194        actual: usize,
195        /// Index path from the document root to the **parent** node.
196        path: Vec<usize>,
197    },
198
199    /// A node's `attrs` value is missing a required field.
200    ///
201    /// Example: `panel` without `panelType`, `heading` without `level`.
202    MissingAttr {
203        /// The `node_type` whose attrs are incomplete.
204        node_type: String,
205        /// The name of the missing attribute.
206        attr_name: String,
207        /// Index path from the document root to the offending node.
208        path: Vec<usize>,
209    },
210
211    /// A node's `attrs` value has the wrong shape for a declared field.
212    ///
213    /// Examples: `panel.panelType: "purple"` (not in the enum),
214    /// `heading.level: 7` (out of range), `heading.level: "two"` (wrong
215    /// type), `embedCard.url: "not a url"` (bad format).
216    InvalidAttr {
217        /// The `node_type` whose attrs are malformed.
218        node_type: String,
219        /// The name of the offending attribute.
220        attr_name: String,
221        /// What is wrong with the value (enum / range / type / format).
222        problem: crate::atlassian::adf_attr_schema::AttrProblem,
223        /// Index path from the document root to the offending node.
224        path: Vec<usize>,
225    },
226
227    /// A mark appears in a context that does not permit it.
228    ///
229    /// Examples: `code` mark on text inside a `heading`, `border` mark on a
230    /// `paragraph` (block marks like `border` are tableCell-only).
231    DisallowedMark {
232        /// The `mark_type` of the offending mark.
233        mark_type: String,
234        /// The context that rejects this mark — for inline marks, the
235        /// inline-content parent (e.g. `"heading"`); for block marks, the
236        /// node whose own `marks` array contains the mark (e.g.
237        /// `"paragraph"`).
238        parent_type: String,
239        /// For inline-mark violations, the position of the inline node
240        /// within its parent. `None` for block-mark violations.
241        inline_index: Option<usize>,
242        /// Index path from the document root to the node whose marks were
243        /// being validated.
244        path: Vec<usize>,
245    },
246
247    /// A mark's `attrs` value has the wrong shape for a declared field, or
248    /// is missing a required field.
249    ///
250    /// Examples: `link.href: "not a url"` (bad format),
251    /// `subsup.type: "side"` (not in enum), `border.size: 5` (out of range
252    /// 1..=3), `link` without `href` (required field absent).
253    InvalidMarkAttr {
254        /// The `mark_type` whose attrs are malformed.
255        mark_type: String,
256        /// The name of the offending attribute.
257        attr_name: String,
258        /// What is wrong with the value.
259        problem: crate::atlassian::adf_attr_schema::AttrProblem,
260        /// The position of the mark within the node's `marks` array (for
261        /// disambiguation when a node carries multiple marks).
262        inline_index: Option<usize>,
263        /// Index path from the document root to the node whose mark is
264        /// malformed.
265        path: Vec<usize>,
266    },
267
268    /// Two marks on one inline text node that ADF does not allow together.
269    ///
270    /// The upstream JSON schema models text marks as two mutually-exclusive
271    /// node variants (`code_inline_node` vs `formatted_text_inline_node`):
272    /// a text node's marks must all fit within a single variant's mark group.
273    /// The `code` mark lives only in the code group (`{annotation, code,
274    /// link}`), so pairing it with a styling mark such as `strong` or
275    /// `textColor` is rejected by the API as an opaque `INVALID_INPUT`
276    /// (issue #1047).
277    ForbiddenMarkCombination {
278        /// One of the two conflicting marks (the earlier one in `marks`).
279        mark_type: String,
280        /// The other conflicting mark (the later one in `marks`).
281        conflicts_with: String,
282        /// The inline-content parent of the text node (e.g. `"paragraph"`).
283        parent_type: String,
284        /// The position of the offending inline node within its parent.
285        inline_index: Option<usize>,
286        /// Index path from the document root to the node whose marks conflict.
287        path: Vec<usize>,
288    },
289}
290
291impl AdfSchemaViolation {
292    /// Path from the document root to the violation site.
293    ///
294    /// For [`Self::DisallowedChild`] this is the child; for [`Self::Arity`]
295    /// this is the parent whose count is wrong; for [`Self::MissingAttr`]
296    /// and [`Self::InvalidAttr`] this is the node whose attrs are wrong.
297    #[must_use]
298    pub fn path(&self) -> &[usize] {
299        match self {
300            Self::DisallowedChild { path, .. }
301            | Self::Arity { path, .. }
302            | Self::MissingAttr { path, .. }
303            | Self::InvalidAttr { path, .. }
304            | Self::DisallowedMark { path, .. }
305            | Self::InvalidMarkAttr { path, .. }
306            | Self::ForbiddenMarkCombination { path, .. } => path,
307        }
308    }
309}
310
311impl std::fmt::Display for AdfSchemaViolation {
312    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
313        let path_str = self
314            .path()
315            .iter()
316            .map(usize::to_string)
317            .collect::<Vec<_>>()
318            .join("/");
319        match self {
320            Self::DisallowedChild {
321                child_type,
322                parent_type,
323                ..
324            } => write!(
325                f,
326                "ADF schema violation at /{path_str}: '{child_type}' is not permitted inside '{parent_type}'",
327            ),
328            Self::Arity {
329                parent_type,
330                atoms,
331                expected,
332                actual,
333                ..
334            } => write!(
335                f,
336                "ADF schema violation at /{path_str}: '{parent_type}' must contain {phrasing} {atoms_str} (found {actual})",
337                phrasing = expected.phrasing(),
338                atoms_str = format_atoms(atoms),
339            ),
340            Self::MissingAttr {
341                node_type,
342                attr_name,
343                ..
344            } => write!(
345                f,
346                "ADF schema violation at /{path_str}: '{node_type}' is missing required attribute '{attr_name}'",
347            ),
348            Self::InvalidAttr {
349                node_type,
350                attr_name,
351                problem,
352                ..
353            } => write!(
354                f,
355                "ADF schema violation at /{path_str}: '{node_type}.{attr_name}' is invalid — {problem}",
356            ),
357            Self::DisallowedMark {
358                mark_type,
359                parent_type,
360                ..
361            } => write!(
362                f,
363                "ADF schema violation at /{path_str}: '{mark_type}' mark is not permitted on '{parent_type}'",
364            ),
365            Self::InvalidMarkAttr {
366                mark_type,
367                attr_name,
368                problem,
369                ..
370            } => write!(
371                f,
372                "ADF schema violation at /{path_str}: '{mark_type}' mark's '{attr_name}' is invalid — {problem}",
373            ),
374            Self::ForbiddenMarkCombination {
375                mark_type,
376                conflicts_with,
377                ..
378            } => write!(
379                f,
380                "ADF schema violation at /{path_str}: '{mark_type}' mark cannot be combined with '{conflicts_with}' mark on the same text",
381            ),
382        }
383    }
384}
385
386fn format_atoms(atoms: &[&str]) -> String {
387    if atoms.len() == 1 {
388        format!("'{}'", atoms[0])
389    } else {
390        let inner = atoms
391            .iter()
392            .map(|a| format!("'{a}'"))
393            .collect::<Vec<_>>()
394            .join(", ");
395        format!("{{{inner}}}")
396    }
397}
398
399// -----------------------------------------------------------------------------
400// Common atom slices
401// -----------------------------------------------------------------------------
402
403/// Inline content shared by `paragraph`, `heading`, `taskItem`, `decisionItem`.
404///
405/// `caption`'s inline list is a strict subset (no `inlineExtension`, no
406/// `mediaInline`) and is inlined below to keep the per-parent diff against
407/// upstream exact.
408const FULL_INLINE_ATOMS: &[&str] = &[
409    "date",
410    "emoji",
411    "hardBreak",
412    "inlineCard",
413    "inlineExtension",
414    "mediaInline",
415    "mention",
416    "placeholder",
417    "status",
418    "text",
419];
420
421const CAPTION_INLINE_ATOMS: &[&str] = &[
422    "date",
423    "emoji",
424    "hardBreak",
425    "inlineCard",
426    "mention",
427    "placeholder",
428    "status",
429    "text",
430];
431
432const LISTITEM_BLOCK_ATOMS: &[&str] = &[
433    "bulletList",
434    "codeBlock",
435    "extension",
436    "mediaSingle",
437    "orderedList",
438    "paragraph",
439    "taskList",
440];
441
442const PANEL_BLOCK_ATOMS: &[&str] = &[
443    "blockCard",
444    "bulletList",
445    "codeBlock",
446    "decisionList",
447    "extension",
448    "heading",
449    "mediaGroup",
450    "mediaSingle",
451    "orderedList",
452    "paragraph",
453    "rule",
454    "taskList",
455];
456
457const NESTED_EXPAND_BLOCK_ATOMS: &[&str] = &[
458    "blockquote",
459    "bulletList",
460    "codeBlock",
461    "decisionList",
462    "extension",
463    "heading",
464    "mediaGroup",
465    "mediaSingle",
466    "orderedList",
467    "panel",
468    "paragraph",
469    "rule",
470    "taskList",
471];
472
473const EXPAND_BLOCK_ATOMS: &[&str] = &[
474    "blockCard",
475    "blockquote",
476    "bulletList",
477    "codeBlock",
478    "decisionList",
479    "embedCard",
480    "extension",
481    "heading",
482    "mediaGroup",
483    "mediaSingle",
484    "nestedExpand",
485    "orderedList",
486    "panel",
487    "paragraph",
488    "rule",
489    "table",
490    "taskList",
491];
492
493const BODIED_EXTENSION_BLOCK_ATOMS: &[&str] = &[
494    "blockCard",
495    "blockquote",
496    "bulletList",
497    "codeBlock",
498    "decisionList",
499    "embedCard",
500    "extension",
501    "heading",
502    "mediaGroup",
503    "mediaSingle",
504    "orderedList",
505    "panel",
506    "paragraph",
507    "rule",
508    "table",
509    "taskList",
510];
511
512const BODIED_SYNC_BLOCK_ATOMS: &[&str] = &[
513    "blockCard",
514    "blockquote",
515    "bulletList",
516    "codeBlock",
517    "decisionList",
518    "embedCard",
519    "expand",
520    "heading",
521    "layoutSection",
522    "mediaGroup",
523    "mediaSingle",
524    "orderedList",
525    "panel",
526    "paragraph",
527    "rule",
528    "table",
529    "taskList",
530];
531
532const LAYOUT_COLUMN_BLOCK_ATOMS: &[&str] = &[
533    "blockCard",
534    "blockquote",
535    "bodiedExtension",
536    "bulletList",
537    "codeBlock",
538    "decisionList",
539    "embedCard",
540    "expand",
541    "extension",
542    "heading",
543    "mediaGroup",
544    "mediaSingle",
545    "orderedList",
546    "panel",
547    "paragraph",
548    "rule",
549    "table",
550    "taskList",
551];
552
553const TABLE_CELL_BLOCK_ATOMS: &[&str] = &[
554    "blockCard",
555    "blockquote",
556    "bulletList",
557    "codeBlock",
558    "decisionList",
559    "embedCard",
560    "extension",
561    "heading",
562    "mediaGroup",
563    "mediaSingle",
564    "nestedExpand",
565    "orderedList",
566    "panel",
567    "paragraph",
568    "rule",
569    "taskList",
570];
571
572const DOC_BLOCK_ATOMS: &[&str] = &[
573    "blockCard",
574    "blockquote",
575    "bodiedExtension",
576    "bodiedSyncBlock",
577    "bulletList",
578    "codeBlock",
579    "decisionList",
580    "embedCard",
581    "expand",
582    "extension",
583    "heading",
584    "layoutSection",
585    "mediaGroup",
586    "mediaSingle",
587    "orderedList",
588    "panel",
589    "paragraph",
590    "rule",
591    "syncBlock",
592    "table",
593    "taskList",
594];
595
596// -----------------------------------------------------------------------------
597// Schema entries
598// -----------------------------------------------------------------------------
599//
600// One entry per container node, transcribed faithfully from the upstream JSON
601// schema (`json-schema/v1/full.json` in the package tarball pinned by
602// `SCHEMA_VERSION` / `UPSTREAM_TARBALL_SHA256`). Leaf nodes (text, hardBreak,
603// mention, emoji, date, status, inlineCard, mediaInline, placeholder,
604// inlineExtension, media, rule, blockCard, embedCard, extension, syncBlock,
605// unsupportedBlock, unsupportedInline) have no `content` upstream and are
606// intentionally absent.
607//
608// `unsupportedBlock` and `unsupportedInline` are NOT listed in any parent's
609// allowed-children atoms — they are runtime-only preservation wrappers. The
610// walker accepts them under any known parent and counts them toward the
611// parent's current term arity; see [`is_unsupported`] and [`walk_children`].
612//
613// Lenient deviations from upstream (where strict would break common
614// real-world inputs) are commented inline:
615//
616// - `doc`: upstream is `block+`; we use `block*` so `AdfDocument::new()` (the
617//   canonical empty document, returned for missing JIRA descriptions) does
618//   not produce an arity violation.
619// - `tableCell` / `tableHeader`: upstream is `block+`; we use `block*` so
620//   visibly-empty cells in real Confluence tables do not produce arity
621//   violations.
622
623/// Allowed-children entries, sorted alphabetically by parent. Crate-visible
624/// so the drift detector ([`drift`]) can flatten them into a `BTreeMap`
625/// without re-deriving from the runtime `ALLOWED_CHILDREN` cache.
626pub(crate) type ModelEntry = (&'static str, &'static [ContentTerm]);
627
628pub(crate) const CONTENT_ENTRIES: &[ModelEntry] = &[
629    // blockTaskItem — definitions/blockTaskItem_node
630    // upstream: (extension | paragraph)+
631    (
632        "blockTaskItem",
633        &[ContentTerm {
634            atoms: &["extension", "paragraph"],
635            quant: Quantifier::OneOrMore,
636        }],
637    ),
638    // blockquote — definitions/blockquote_node
639    // upstream: (paragraph | bulletList | orderedList | mediaGroup |
640    //            mediaSingle | codeBlock | extension)+
641    (
642        "blockquote",
643        &[ContentTerm {
644            atoms: &[
645                "bulletList",
646                "codeBlock",
647                "extension",
648                "mediaGroup",
649                "mediaSingle",
650                "orderedList",
651                "paragraph",
652            ],
653            quant: Quantifier::OneOrMore,
654        }],
655    ),
656    // bodiedExtension — definitions/bodiedExtension_node
657    (
658        "bodiedExtension",
659        &[ContentTerm {
660            atoms: BODIED_EXTENSION_BLOCK_ATOMS,
661            quant: Quantifier::OneOrMore,
662        }],
663    ),
664    // bodiedSyncBlock — definitions/bodiedSyncBlock_node
665    (
666        "bodiedSyncBlock",
667        &[ContentTerm {
668            atoms: BODIED_SYNC_BLOCK_ATOMS,
669            quant: Quantifier::OneOrMore,
670        }],
671    ),
672    // bulletList — definitions/bulletList_node
673    // upstream: listItem+
674    (
675        "bulletList",
676        &[ContentTerm {
677            atoms: &["listItem"],
678            quant: Quantifier::OneOrMore,
679        }],
680    ),
681    // caption — definitions/caption_node
682    // upstream: inline* (subset of FULL_INLINE: no inlineExtension, no
683    // mediaInline)
684    (
685        "caption",
686        &[ContentTerm {
687            atoms: CAPTION_INLINE_ATOMS,
688            quant: Quantifier::ZeroOrMore,
689        }],
690    ),
691    // codeBlock — definitions/codeBlock_node
692    // upstream: text* (hardBreak NOT permitted by the JSON schema even
693    // though some renderers handle it)
694    (
695        "codeBlock",
696        &[ContentTerm {
697            atoms: &["text"],
698            quant: Quantifier::ZeroOrMore,
699        }],
700    ),
701    // decisionItem — definitions/decisionItem_node
702    // upstream: inline*
703    (
704        "decisionItem",
705        &[ContentTerm {
706            atoms: FULL_INLINE_ATOMS,
707            quant: Quantifier::ZeroOrMore,
708        }],
709    ),
710    // decisionList — definitions/decisionList_node
711    // upstream: decisionItem+
712    (
713        "decisionList",
714        &[ContentTerm {
715            atoms: &["decisionItem"],
716            quant: Quantifier::OneOrMore,
717        }],
718    ),
719    // doc — definitions/doc_node
720    // upstream: block+; LENIENT: block* — empty docs are the canonical
721    // value for missing JIRA descriptions (`AdfDocument::new()`).
722    (
723        "doc",
724        &[ContentTerm {
725            atoms: DOC_BLOCK_ATOMS,
726            quant: Quantifier::ZeroOrMore,
727        }],
728    ),
729    // expand — definitions/expand_node
730    // upstream: block+ (DOES permit nestedExpand, NOT another expand)
731    (
732        "expand",
733        &[ContentTerm {
734            atoms: EXPAND_BLOCK_ATOMS,
735            quant: Quantifier::OneOrMore,
736        }],
737    ),
738    // heading — definitions/heading_node
739    // upstream: inline*
740    (
741        "heading",
742        &[ContentTerm {
743            atoms: FULL_INLINE_ATOMS,
744            quant: Quantifier::ZeroOrMore,
745        }],
746    ),
747    // layoutColumn — definitions/layoutColumn_node
748    // upstream: block+ (permits expand and bodiedExtension, NOT
749    // nestedExpand)
750    (
751        "layoutColumn",
752        &[ContentTerm {
753            atoms: LAYOUT_COLUMN_BLOCK_ATOMS,
754            quant: Quantifier::OneOrMore,
755        }],
756    ),
757    // layoutSection — definitions/layoutSection_node
758    // upstream: layoutColumn{2,3}
759    (
760        "layoutSection",
761        &[ContentTerm {
762            atoms: &["layoutColumn"],
763            quant: Quantifier::Range(2, 3),
764        }],
765    ),
766    // listItem — definitions/listItem_node
767    // upstream: paragraph (paragraph | bulletList | orderedList |
768    //                       mediaSingle | codeBlock | taskList)*
769    // LENIENT: simplified to (one-or-more of the union) — most listItems
770    // start with a paragraph in practice; flagging pure-list-of-list items
771    // would be noisy.
772    (
773        "listItem",
774        &[ContentTerm {
775            atoms: LISTITEM_BLOCK_ATOMS,
776            quant: Quantifier::OneOrMore,
777        }],
778    ),
779    // mediaGroup — definitions/mediaGroup_node
780    // upstream: media+
781    (
782        "mediaGroup",
783        &[ContentTerm {
784            atoms: &["media"],
785            quant: Quantifier::OneOrMore,
786        }],
787    ),
788    // mediaSingle — definitions/mediaSingle_caption_node /
789    //               mediaSingle_full_node
790    // upstream: media (caption)?  (in this order)
791    (
792        "mediaSingle",
793        &[
794            ContentTerm {
795                atoms: &["media"],
796                quant: Quantifier::Exactly(1),
797            },
798            ContentTerm {
799                atoms: &["caption"],
800                quant: Quantifier::ZeroOrOne,
801            },
802        ],
803    ),
804    // nestedExpand — definitions/nestedExpand_node
805    // upstream: block+ (permits panel and blockquote; NOT table, blockCard,
806    // embedCard, expand, or nestedExpand itself)
807    (
808        "nestedExpand",
809        &[ContentTerm {
810            atoms: NESTED_EXPAND_BLOCK_ATOMS,
811            quant: Quantifier::OneOrMore,
812        }],
813    ),
814    // orderedList — definitions/orderedList_node
815    // upstream: listItem+
816    (
817        "orderedList",
818        &[ContentTerm {
819            atoms: &["listItem"],
820            quant: Quantifier::OneOrMore,
821        }],
822    ),
823    // panel — definitions/panel_node
824    // upstream: block+ (subset)
825    (
826        "panel",
827        &[ContentTerm {
828            atoms: PANEL_BLOCK_ATOMS,
829            quant: Quantifier::OneOrMore,
830        }],
831    ),
832    // paragraph — definitions/paragraph_node
833    // upstream: inline*
834    (
835        "paragraph",
836        &[ContentTerm {
837            atoms: FULL_INLINE_ATOMS,
838            quant: Quantifier::ZeroOrMore,
839        }],
840    ),
841    // table — definitions/table_node
842    // upstream: tableRow+
843    (
844        "table",
845        &[ContentTerm {
846            atoms: &["tableRow"],
847            quant: Quantifier::OneOrMore,
848        }],
849    ),
850    // tableCell — definitions/table_cell_content
851    // upstream: block+; LENIENT: block* — visibly-empty cells in real
852    // Confluence tables are common and accepted by the renderer.
853    (
854        "tableCell",
855        &[ContentTerm {
856            atoms: TABLE_CELL_BLOCK_ATOMS,
857            quant: Quantifier::ZeroOrMore,
858        }],
859    ),
860    // tableHeader — definitions/table_header_node (uses table_cell_content)
861    // upstream: block+; LENIENT: block* — same reason as tableCell.
862    (
863        "tableHeader",
864        &[ContentTerm {
865            atoms: TABLE_CELL_BLOCK_ATOMS,
866            quant: Quantifier::ZeroOrMore,
867        }],
868    ),
869    // tableRow — definitions/table_row_node
870    // upstream: (tableCell | tableHeader)+
871    (
872        "tableRow",
873        &[ContentTerm {
874            atoms: &["tableCell", "tableHeader"],
875            quant: Quantifier::OneOrMore,
876        }],
877    ),
878    // taskItem — definitions/taskItem_node
879    // upstream: inline*
880    (
881        "taskItem",
882        &[ContentTerm {
883            atoms: FULL_INLINE_ATOMS,
884            quant: Quantifier::ZeroOrMore,
885        }],
886    ),
887    // taskList — definitions/taskList_node
888    // upstream: (taskItem | taskList | blockTaskItem)+
889    (
890        "taskList",
891        &[ContentTerm {
892            atoms: &["blockTaskItem", "taskItem", "taskList"],
893            quant: Quantifier::OneOrMore,
894        }],
895    ),
896];
897
898/// Forward-compat preservation wrappers. Accepted under any known parent by
899/// the walker, regardless of whether the parent's allowed-children set lists
900/// them. Atlassian's renderer uses these to wrap content the schema doesn't
901/// know how to validate; flagging them as violations would be noisy and would
902/// break the round-trip guarantee of [ADR-0020]'s `adf-unsupported` fenced
903/// block.
904const UNSUPPORTED_NODES: &[&str] = &["unsupportedBlock", "unsupportedInline"];
905
906fn is_unsupported(node_type: &str) -> bool {
907    UNSUPPORTED_NODES.contains(&node_type)
908}
909
910static CONTENT_MODELS: LazyLock<HashMap<&'static str, &'static [ContentTerm]>> =
911    LazyLock::new(|| CONTENT_ENTRIES.iter().copied().collect());
912
913/// Per-parent flattened allowed-children atoms, computed once from
914/// [`CONTENT_ENTRIES`] and used by the back-compat [`allowed_children`] /
915/// [`permits_child`] helpers. Sorted and deduplicated within each entry.
916static ALLOWED_CHILDREN: LazyLock<HashMap<&'static str, Vec<&'static str>>> = LazyLock::new(|| {
917    CONTENT_ENTRIES
918        .iter()
919        .map(|(parent, terms)| {
920            let mut atoms: Vec<&'static str> =
921                terms.iter().flat_map(|t| t.atoms.iter().copied()).collect();
922            atoms.sort_unstable();
923            atoms.dedup();
924            (*parent, atoms)
925        })
926        .collect()
927});
928
929/// Returns the allowed direct children for a parent node type.
930///
931/// `None` means the node has no entry in the schema (either a leaf type or a
932/// type unknown to this snapshot). Unknown parents are treated permissively
933/// by [`permits_child`] and the walker.
934///
935/// The returned slice is the union of all atoms across the parent's content
936/// terms (sorted, deduplicated). Quantifier and order information is not
937/// surfaced through this helper — use [`content_model`] for that.
938#[must_use]
939pub fn allowed_children(parent: &str) -> Option<&'static [&'static str]> {
940    ALLOWED_CHILDREN.get(parent).map(Vec::as_slice)
941}
942
943/// Returns the full content model (sequence of quantified terms) for a parent
944/// node type, or `None` if the parent has no entry.
945#[must_use]
946pub fn content_model(parent: &str) -> Option<&'static [ContentTerm]> {
947    CONTENT_MODELS.get(parent).copied()
948}
949
950/// Returns `true` if `child` is permitted as a direct child of `parent`.
951///
952/// Returns `true` (permissive) when `parent` has no schema entry — see the
953/// module-level docs for rationale. Also returns `true` when `child` is
954/// `unsupportedBlock` or `unsupportedInline`, regardless of `parent`, because
955/// those are forward-compat preservation wrappers, not first-class content.
956#[must_use]
957pub fn permits_child(parent: &str, child: &str) -> bool {
958    if is_unsupported(child) {
959        return true;
960    }
961    match allowed_children(parent) {
962        Some(children) => children.contains(&child),
963        None => true,
964    }
965}
966
967/// Validates an entire ADF document and returns all violations found.
968///
969/// An empty `Vec` means the document is structurally valid against the
970/// snapshot. The walker is depth-first: violations under a child are reported
971/// after the child's own violations, so the overall ordering is "each parent's
972/// own checks, then descend into each child in turn." Arity violations on a
973/// parent appear at the position the parent is visited, before any of its
974/// descendants' violations.
975#[must_use]
976pub fn validate_document(doc: &AdfDocument) -> Vec<AdfSchemaViolation> {
977    let mut violations = Vec::new();
978    let mut path = Vec::new();
979    if let Some(model) = content_model(&doc.doc_type) {
980        walk_children(
981            &doc.content,
982            &doc.doc_type,
983            model,
984            &mut path,
985            &mut violations,
986        );
987    }
988    violations
989}
990
991/// Walks `children` against `model`, reporting `DisallowedChild` and `Arity`
992/// violations into `out`. Recurses into each child's subtree if its
993/// `node_type` has a schema entry.
994///
995/// `path` is the index path from the document root to the **parent** of
996/// `children` (i.e. the index of the current child is pushed/popped inside the
997/// loop). On entry, `path` identifies the parent; on exit it is unchanged.
998fn walk_children(
999    children: &[AdfNode],
1000    parent_type: &str,
1001    model: &[ContentTerm],
1002    path: &mut Vec<usize>,
1003    out: &mut Vec<AdfSchemaViolation>,
1004) {
1005    // Per-term match counts. Index aligned with `model`.
1006    let mut term_counts: Vec<usize> = vec![0; model.len()];
1007    // Index of the term we are currently consuming children into. Advances
1008    // monotonically — children that don't match the current term try later
1009    // terms, but we never go backwards (this matches ProseMirror's greedy
1010    // sequence-matching semantics).
1011    let mut current_term: usize = 0;
1012
1013    for (idx, child) in children.iter().enumerate() {
1014        path.push(idx);
1015
1016        let child_type = child.node_type.as_str();
1017
1018        // Validate this child's attrs (per PR #733-attrs slice). Permissive
1019        // on unknown node types; emits `MissingAttr` / `InvalidAttr` for
1020        // declared fields. Always runs — independent of disallowed-child
1021        // / arity bookkeeping.
1022        crate::atlassian::adf_attr_schema::validate_attrs(
1023            child_type,
1024            child.attrs.as_ref(),
1025            path,
1026            out,
1027        );
1028
1029        // Validate this child's marks (per PR #733-marks slice). The
1030        // `parent_type` is the node enclosing `child` — it determines the
1031        // inline-mark allow-list when `child` is an inline node like text.
1032        // Permissive on unknown contexts.
1033        crate::atlassian::adf_mark_schema::validate_marks(parent_type, child, path, out);
1034
1035        if is_unsupported(child_type) {
1036            // Round-trip escape hatch: count toward the current term's arity
1037            // (so a panel containing only an `unsupportedBlock` still
1038            // satisfies panel's `+`). Never emits a DisallowedChild.
1039            if current_term < model.len() {
1040                term_counts[current_term] += 1;
1041            }
1042        } else {
1043            // Find a term (at or after current_term) whose atoms accept this
1044            // child. Greedy: first match wins; subsequent children continue
1045            // from the matched term.
1046            let mut matched: Option<usize> = None;
1047            let mut try_idx = current_term;
1048            while try_idx < model.len() {
1049                if model[try_idx].atoms.contains(&child_type) {
1050                    matched = Some(try_idx);
1051                    break;
1052                }
1053                try_idx += 1;
1054            }
1055
1056            match matched {
1057                Some(t) => {
1058                    term_counts[t] += 1;
1059                    current_term = t;
1060                }
1061                None => {
1062                    out.push(AdfSchemaViolation::DisallowedChild {
1063                        child_type: child_type.to_string(),
1064                        parent_type: parent_type.to_string(),
1065                        path: path.clone(),
1066                    });
1067                    // Don't count toward any term — see the doc on
1068                    // `Arity` for why disallowed children should not satisfy
1069                    // arity for the parent (the user clearly tried to put a
1070                    // child here, but it's the wrong type — the right thing
1071                    // is to flag both DisallowedChild and any missing Arity).
1072                }
1073            }
1074        }
1075
1076        // Recurse into the child's content if it has a known schema. Treat
1077        // a missing `content` field as an empty content array so that arity
1078        // checks still fire for empty containers (`AdfNode::content: None`
1079        // is how the converter encodes "no children").
1080        if let Some(grand_model) = content_model(child_type) {
1081            let grand = child.content.as_deref().unwrap_or(&[]);
1082            walk_children(grand, child_type, grand_model, path, out);
1083        }
1084
1085        path.pop();
1086    }
1087
1088    // After consuming children, emit one arity violation per term whose count
1089    // doesn't satisfy its quantifier. Path here points at the parent (one
1090    // level up from the children we just walked).
1091    for (i, term) in model.iter().enumerate() {
1092        let count = term_counts[i];
1093        if !term.quant.satisfied_by(count) {
1094            out.push(AdfSchemaViolation::Arity {
1095                parent_type: parent_type.to_string(),
1096                atoms: term.atoms.to_vec(),
1097                expected: term.quant.clone(),
1098                actual: count,
1099                path: path.clone(),
1100            });
1101        }
1102    }
1103}
1104
1105#[cfg(test)]
1106#[allow(clippy::unwrap_used, clippy::expect_used)]
1107mod tests {
1108    use super::*;
1109    use crate::atlassian::adf::{AdfDocument, AdfNode};
1110
1111    fn node(node_type: &str, content: Vec<AdfNode>) -> AdfNode {
1112        AdfNode {
1113            node_type: node_type.to_string(),
1114            attrs: None,
1115            content: if content.is_empty() {
1116                None
1117            } else {
1118                Some(content)
1119            },
1120            text: None,
1121            marks: None,
1122            local_id: None,
1123            parameters: None,
1124        }
1125    }
1126
1127    fn leaf(node_type: &str) -> AdfNode {
1128        node(node_type, vec![])
1129    }
1130
1131    fn with_attrs(mut n: AdfNode, attrs: serde_json::Value) -> AdfNode {
1132        n.attrs = Some(attrs);
1133        n
1134    }
1135
1136    /// `panel` with a valid `panelType` so attribute validation does not
1137    /// add noise to tests focused on content-model behaviour.
1138    fn panel(content: Vec<AdfNode>) -> AdfNode {
1139        with_attrs(
1140            node("panel", content),
1141            serde_json::json!({"panelType": "info"}),
1142        )
1143    }
1144
1145    /// `media` with a valid `type`.
1146    fn media() -> AdfNode {
1147        with_attrs(
1148            leaf("media"),
1149            serde_json::json!({"type": "file", "id": "x"}),
1150        )
1151    }
1152
1153    /// `layoutColumn` with a valid `width`.
1154    fn layout_column(content: Vec<AdfNode>) -> AdfNode {
1155        with_attrs(
1156            node("layoutColumn", content),
1157            serde_json::json!({"width": 33.3}),
1158        )
1159    }
1160
1161    fn doc(content: Vec<AdfNode>) -> AdfDocument {
1162        AdfDocument {
1163            version: 1,
1164            doc_type: "doc".to_string(),
1165            content,
1166        }
1167    }
1168
1169    fn unwrap_disallowed(v: &AdfSchemaViolation) -> (&str, &str, &[usize]) {
1170        match v {
1171            AdfSchemaViolation::DisallowedChild {
1172                child_type,
1173                parent_type,
1174                path,
1175            } => (child_type.as_str(), parent_type.as_str(), path.as_slice()),
1176            other => panic!("expected DisallowedChild, got {other:?}"),
1177        }
1178    }
1179
1180    fn unwrap_arity(
1181        v: &AdfSchemaViolation,
1182    ) -> (&str, &[&'static str], &Quantifier, usize, &[usize]) {
1183        match v {
1184            AdfSchemaViolation::Arity {
1185                parent_type,
1186                atoms,
1187                expected,
1188                actual,
1189                path,
1190            } => (
1191                parent_type.as_str(),
1192                atoms.as_slice(),
1193                expected,
1194                *actual,
1195                path.as_slice(),
1196            ),
1197            other => panic!("expected Arity, got {other:?}"),
1198        }
1199    }
1200
1201    #[test]
1202    fn schema_has_entry_for_every_advertised_container() {
1203        let known_leaves = [
1204            "blockCard",
1205            "date",
1206            "embedCard",
1207            "emoji",
1208            "extension",
1209            "hardBreak",
1210            "inlineCard",
1211            "inlineExtension",
1212            "media",
1213            "mediaInline",
1214            "mention",
1215            "placeholder",
1216            "rule",
1217            "status",
1218            "syncBlock",
1219            "text",
1220            "unsupportedBlock",
1221            "unsupportedInline",
1222        ];
1223        for (_parent, terms) in CONTENT_ENTRIES {
1224            for term in *terms {
1225                for child in term.atoms {
1226                    let known = CONTENT_MODELS.contains_key(child) || known_leaves.contains(child);
1227                    assert!(
1228                        known,
1229                        "child '{child}' has no schema entry and is not in the leaf list"
1230                    );
1231                }
1232            }
1233        }
1234    }
1235
1236    #[test]
1237    fn child_lists_are_sorted_for_diffability() {
1238        for (parent, terms) in CONTENT_ENTRIES {
1239            for term in *terms {
1240                let mut sorted = term.atoms.to_vec();
1241                sorted.sort_unstable();
1242                assert_eq!(
1243                    term.atoms.to_vec(),
1244                    sorted,
1245                    "atom list for '{parent}' is not sorted"
1246                );
1247            }
1248        }
1249    }
1250
1251    // ---- Issue #717 examples ---------------------------------------------
1252
1253    #[test]
1254    fn panel_allows_examples_from_issue_717() {
1255        for child in [
1256            "paragraph",
1257            "heading",
1258            "bulletList",
1259            "orderedList",
1260            "blockCard",
1261            "mediaGroup",
1262            "mediaSingle",
1263            "codeBlock",
1264            "taskList",
1265            "rule",
1266            "decisionList",
1267            "unsupportedBlock",
1268            "extension",
1269        ] {
1270            assert!(
1271                permits_child("panel", child),
1272                "panel should permit '{child}'"
1273            );
1274        }
1275    }
1276
1277    #[test]
1278    fn panel_rejects_expand_and_nested_expand() {
1279        assert!(!permits_child("panel", "expand"));
1280        assert!(!permits_child("panel", "nestedExpand"));
1281    }
1282
1283    #[test]
1284    fn expand_allows_nested_block_types_and_nested_expand_but_not_self() {
1285        assert!(permits_child("expand", "panel"));
1286        assert!(permits_child("expand", "table"));
1287        assert!(permits_child("expand", "nestedExpand"));
1288        assert!(!permits_child("expand", "expand"));
1289    }
1290
1291    #[test]
1292    fn table_cell_allows_nested_expand_but_not_expand() {
1293        assert!(permits_child("tableCell", "nestedExpand"));
1294        assert!(!permits_child("tableCell", "expand"));
1295    }
1296
1297    #[test]
1298    fn blockquote_allowed_children_match_upstream_json_schema() {
1299        let expected = [
1300            "bulletList",
1301            "codeBlock",
1302            "extension",
1303            "mediaGroup",
1304            "mediaSingle",
1305            "orderedList",
1306            "paragraph",
1307        ];
1308        let got: Vec<&str> = allowed_children("blockquote")
1309            .expect("blockquote has an entry")
1310            .to_vec();
1311        assert_eq!(got, expected);
1312    }
1313
1314    // ---- Permissiveness invariants ---------------------------------------
1315
1316    #[test]
1317    fn unknown_parent_is_permissive() {
1318        assert!(permits_child("madeUpNode", "anything"));
1319        assert!(permits_child("madeUpNode", "alsoFake"));
1320    }
1321
1322    #[test]
1323    fn unknown_child_inside_known_parent_is_a_violation() {
1324        assert!(!permits_child("paragraph", "madeUpInline"));
1325    }
1326
1327    #[test]
1328    fn nested_expand_distinguished_from_expand() {
1329        assert!(permits_child("nestedExpand", "panel"));
1330        assert!(permits_child("nestedExpand", "blockquote"));
1331        assert!(!permits_child("nestedExpand", "table"));
1332        assert!(!permits_child("nestedExpand", "blockCard"));
1333        assert!(!permits_child("nestedExpand", "embedCard"));
1334        assert!(!permits_child("nestedExpand", "nestedExpand"));
1335        assert!(!permits_child("nestedExpand", "expand"));
1336    }
1337
1338    // ---- Walker behaviour: existing v1 cases -----------------------------
1339
1340    #[test]
1341    fn validate_succeeds_on_known_good_doc() {
1342        let document = doc(vec![
1343            AdfNode::paragraph(vec![AdfNode::text("hello")]),
1344            AdfNode::heading(2, vec![AdfNode::text("world")]),
1345        ]);
1346        assert_eq!(validate_document(&document), vec![]);
1347    }
1348
1349    #[test]
1350    fn validate_finds_expand_inside_panel() {
1351        // panel with [expand]: emits DisallowedChild for the expand AND an
1352        // Arity violation for the panel (panel needs 1+ valid children;
1353        // disallowed children do not satisfy arity).
1354        let bad_panel = panel(vec![with_attrs(
1355            node("expand", vec![AdfNode::paragraph(vec![])]),
1356            serde_json::json!({"title": "x"}),
1357        )]);
1358        let document = doc(vec![bad_panel]);
1359
1360        let violations = validate_document(&document);
1361        let disallowed: Vec<_> = violations
1362            .iter()
1363            .filter(|v| matches!(v, AdfSchemaViolation::DisallowedChild { .. }))
1364            .collect();
1365        let arity: Vec<_> = violations
1366            .iter()
1367            .filter(|v| matches!(v, AdfSchemaViolation::Arity { .. }))
1368            .collect();
1369
1370        assert_eq!(disallowed.len(), 1, "got: {violations:?}");
1371        let (child, parent, path) = unwrap_disallowed(disallowed[0]);
1372        assert_eq!(child, "expand");
1373        assert_eq!(parent, "panel");
1374        assert_eq!(path, [0, 0]);
1375
1376        assert_eq!(arity.len(), 1, "got: {violations:?}");
1377        let (parent, _, _, actual, path) = unwrap_arity(arity[0]);
1378        assert_eq!(parent, "panel");
1379        assert_eq!(actual, 0);
1380        assert_eq!(path, [0]);
1381    }
1382
1383    #[test]
1384    fn validate_finds_expand_inside_table_cell() {
1385        let bad_cell = node(
1386            "tableCell",
1387            vec![with_attrs(
1388                node("expand", vec![AdfNode::paragraph(vec![])]),
1389                serde_json::json!({"title": "x"}),
1390            )],
1391        );
1392        let row = node("tableRow", vec![bad_cell]);
1393        let table = node("table", vec![row]);
1394        let document = doc(vec![table]);
1395
1396        let violations = validate_document(&document);
1397        let disallowed: Vec<_> = violations
1398            .iter()
1399            .filter(|v| matches!(v, AdfSchemaViolation::DisallowedChild { .. }))
1400            .collect();
1401        assert_eq!(disallowed.len(), 1, "got: {violations:?}");
1402        let (child, parent, path) = unwrap_disallowed(disallowed[0]);
1403        assert_eq!(child, "expand");
1404        assert_eq!(parent, "tableCell");
1405        assert_eq!(path, [0, 0, 0, 0]);
1406    }
1407
1408    #[test]
1409    fn validate_walks_into_nested_violations_in_document_order() {
1410        let document = doc(vec![
1411            AdfNode::paragraph(vec![leaf("rule")]),
1412            panel(vec![with_attrs(
1413                node("expand", vec![AdfNode::paragraph(vec![])]),
1414                serde_json::json!({"title": "x"}),
1415            )]),
1416        ]);
1417
1418        let violations = validate_document(&document);
1419        // First violation: rule inside paragraph (DisallowedChild).
1420        // Then: panel's DisallowedChild for expand, panel's Arity (0 valid).
1421        // (Inline-content of paragraph #0 has no further descent because rule
1422        // is a leaf.)
1423        let first = violations.first().expect("at least one");
1424        let (child, parent, _) = unwrap_disallowed(first);
1425        assert_eq!(child, "rule");
1426        assert_eq!(parent, "paragraph");
1427    }
1428
1429    #[test]
1430    fn validate_is_permissive_under_unknown_parents() {
1431        let document = doc(vec![node("futureBlock", vec![node("expand", vec![])])]);
1432        let violations = validate_document(&document);
1433        // futureBlock is not in `doc`'s allowed atoms → DisallowedChild.
1434        // Since `doc` is `*` (lenient), no Arity violation.
1435        // futureBlock's subtree is not walked (unknown parent).
1436        assert_eq!(violations.len(), 1);
1437        let (child, parent, _) = unwrap_disallowed(&violations[0]);
1438        assert_eq!(child, "futureBlock");
1439        assert_eq!(parent, "doc");
1440    }
1441
1442    #[test]
1443    fn unsupported_block_is_universally_accepted_via_walker_escape_hatch() {
1444        for parent in [
1445            "doc",
1446            "panel",
1447            "expand",
1448            "tableCell",
1449            "blockquote",
1450            "listItem",
1451        ] {
1452            assert!(
1453                permits_child(parent, "unsupportedBlock"),
1454                "{parent} should permit unsupportedBlock via the escape hatch"
1455            );
1456            assert!(
1457                !allowed_children(parent).is_some_and(|c| c.contains(&"unsupportedBlock")),
1458                "{parent}'s allowed-children list must not list unsupportedBlock — \
1459                 acceptance comes from the walker escape hatch only"
1460            );
1461        }
1462    }
1463
1464    #[test]
1465    fn unsupported_inline_is_universally_accepted_via_walker_escape_hatch() {
1466        for parent in [
1467            "paragraph",
1468            "heading",
1469            "taskItem",
1470            "decisionItem",
1471            "caption",
1472        ] {
1473            assert!(
1474                permits_child(parent, "unsupportedInline"),
1475                "{parent} should permit unsupportedInline via the escape hatch"
1476            );
1477            assert!(
1478                !allowed_children(parent).is_some_and(|c| c.contains(&"unsupportedInline")),
1479                "{parent}'s allowed-children list must not list unsupportedInline"
1480            );
1481        }
1482    }
1483
1484    #[test]
1485    fn validate_returns_empty_when_doc_type_is_unknown() {
1486        let document = AdfDocument {
1487            version: 1,
1488            doc_type: "futureRoot".to_string(),
1489            content: vec![node("expand", vec![])],
1490        };
1491        assert_eq!(validate_document(&document), vec![]);
1492    }
1493
1494    #[test]
1495    fn walker_does_not_flag_unsupported_block_inside_panel() {
1496        // Panel contains only an unsupportedBlock: counts toward panel's
1497        // arity (so no Arity violation), and the wrapper is universally
1498        // accepted. Should validate cleanly.
1499        let document = doc(vec![panel(vec![leaf("unsupportedBlock")])]);
1500        assert_eq!(validate_document(&document), vec![]);
1501    }
1502
1503    // ---- Walker behaviour: arity (PR #733) -------------------------------
1504
1505    #[test]
1506    fn empty_bullet_list_flagged_as_arity_violation() {
1507        let document = doc(vec![node("bulletList", vec![])]);
1508        let violations = validate_document(&document);
1509        assert_eq!(violations.len(), 1, "got: {violations:?}");
1510        let (parent, atoms, expected, actual, path) = unwrap_arity(&violations[0]);
1511        assert_eq!(parent, "bulletList");
1512        assert_eq!(atoms, &["listItem"]);
1513        assert_eq!(expected, &Quantifier::OneOrMore);
1514        assert_eq!(actual, 0);
1515        assert_eq!(path, [0]);
1516    }
1517
1518    #[test]
1519    fn media_single_with_two_media_flagged_as_arity_violation() {
1520        // mediaSingle requires exactly one media; two media → Arity (too many).
1521        let media_single = node("mediaSingle", vec![media(), media()]);
1522        let document = doc(vec![media_single]);
1523        let violations = validate_document(&document);
1524
1525        let arity: Vec<_> = violations
1526            .iter()
1527            .filter(|v| matches!(v, AdfSchemaViolation::Arity { .. }))
1528            .collect();
1529        assert_eq!(arity.len(), 1, "got: {violations:?}");
1530        let (parent, atoms, expected, actual, _) = unwrap_arity(arity[0]);
1531        assert_eq!(parent, "mediaSingle");
1532        assert_eq!(atoms, &["media"]);
1533        assert_eq!(expected, &Quantifier::Exactly(1));
1534        assert_eq!(actual, 2);
1535    }
1536
1537    #[test]
1538    fn media_single_with_only_caption_flagged_missing_media() {
1539        // mediaSingle: media (caption)? — with [caption] alone, media is
1540        // missing AND caption is out-of-position. We currently emit only the
1541        // missing-media Arity (caption matches term 1 successfully).
1542        let document = doc(vec![node("mediaSingle", vec![leaf("caption")])]);
1543        let violations = validate_document(&document);
1544        let arity: Vec<_> = violations
1545            .iter()
1546            .filter(|v| matches!(v, AdfSchemaViolation::Arity { .. }))
1547            .collect();
1548        assert_eq!(arity.len(), 1, "got: {violations:?}");
1549        let (parent, atoms, expected, actual, _) = unwrap_arity(arity[0]);
1550        assert_eq!(parent, "mediaSingle");
1551        assert_eq!(atoms, &["media"]);
1552        assert_eq!(expected, &Quantifier::Exactly(1));
1553        assert_eq!(actual, 0);
1554    }
1555
1556    #[test]
1557    fn media_single_with_media_then_caption_validates() {
1558        let document = doc(vec![node(
1559            "mediaSingle",
1560            vec![media(), node("caption", vec![AdfNode::text("c")])],
1561        )]);
1562        assert_eq!(validate_document(&document), vec![]);
1563    }
1564
1565    #[test]
1566    fn media_single_with_just_one_media_validates() {
1567        let document = doc(vec![node("mediaSingle", vec![media()])]);
1568        assert_eq!(validate_document(&document), vec![]);
1569    }
1570
1571    #[test]
1572    fn empty_table_row_flagged_arity() {
1573        let document = doc(vec![node("table", vec![node("tableRow", vec![])])]);
1574        let violations = validate_document(&document);
1575        let arity: Vec<_> = violations
1576            .iter()
1577            .filter(|v| matches!(v, AdfSchemaViolation::Arity { .. }))
1578            .collect();
1579        assert_eq!(arity.len(), 1, "got: {violations:?}");
1580        let (parent, atoms, expected, actual, _) = unwrap_arity(arity[0]);
1581        assert_eq!(parent, "tableRow");
1582        assert_eq!(atoms, &["tableCell", "tableHeader"]);
1583        assert_eq!(expected, &Quantifier::OneOrMore);
1584        assert_eq!(actual, 0);
1585    }
1586
1587    #[test]
1588    fn empty_media_group_flagged_arity() {
1589        let document = doc(vec![node("mediaGroup", vec![])]);
1590        let violations = validate_document(&document);
1591        assert_eq!(violations.len(), 1);
1592        let (parent, atoms, expected, actual, _) = unwrap_arity(&violations[0]);
1593        assert_eq!(parent, "mediaGroup");
1594        assert_eq!(atoms, &["media"]);
1595        assert_eq!(expected, &Quantifier::OneOrMore);
1596        assert_eq!(actual, 0);
1597    }
1598
1599    #[test]
1600    fn layout_section_with_one_column_flagged_arity_range() {
1601        let document = doc(vec![node(
1602            "layoutSection",
1603            vec![node(
1604                "layoutColumn",
1605                vec![AdfNode::paragraph(vec![AdfNode::text("a")])],
1606            )],
1607        )]);
1608        let violations = validate_document(&document);
1609        let arity: Vec<_> = violations
1610            .iter()
1611            .filter(|v| matches!(v, AdfSchemaViolation::Arity { .. }))
1612            .collect();
1613        assert_eq!(arity.len(), 1, "got: {violations:?}");
1614        let (parent, atoms, expected, actual, _) = unwrap_arity(arity[0]);
1615        assert_eq!(parent, "layoutSection");
1616        assert_eq!(atoms, &["layoutColumn"]);
1617        assert_eq!(expected, &Quantifier::Range(2, 3));
1618        assert_eq!(actual, 1);
1619    }
1620
1621    #[test]
1622    fn layout_section_with_three_columns_validates() {
1623        let column = || layout_column(vec![AdfNode::paragraph(vec![AdfNode::text("x")])]);
1624        let document = doc(vec![node(
1625            "layoutSection",
1626            vec![column(), column(), column()],
1627        )]);
1628        assert_eq!(validate_document(&document), vec![]);
1629    }
1630
1631    #[test]
1632    fn layout_section_with_four_columns_flagged_too_many() {
1633        let column = || layout_column(vec![AdfNode::paragraph(vec![AdfNode::text("x")])]);
1634        let document = doc(vec![node(
1635            "layoutSection",
1636            vec![column(), column(), column(), column()],
1637        )]);
1638        let violations = validate_document(&document);
1639        let arity: Vec<_> = violations
1640            .iter()
1641            .filter(|v| matches!(v, AdfSchemaViolation::Arity { .. }))
1642            .collect();
1643        assert_eq!(arity.len(), 1, "got: {violations:?}");
1644        let (_, _, expected, actual, _) = unwrap_arity(arity[0]);
1645        assert_eq!(expected, &Quantifier::Range(2, 3));
1646        assert_eq!(actual, 4);
1647    }
1648
1649    #[test]
1650    fn empty_paragraph_validates_under_lenient_inline_star() {
1651        let document = doc(vec![AdfNode::paragraph(vec![])]);
1652        assert_eq!(validate_document(&document), vec![]);
1653    }
1654
1655    #[test]
1656    fn empty_doc_validates_under_lenient_block_star() {
1657        let document = doc(vec![]);
1658        assert_eq!(validate_document(&document), vec![]);
1659    }
1660
1661    #[test]
1662    fn empty_table_cell_validates_under_lenient_block_star() {
1663        let document = doc(vec![node(
1664            "table",
1665            vec![node("tableRow", vec![node("tableCell", vec![])])],
1666        )]);
1667        assert_eq!(validate_document(&document), vec![]);
1668    }
1669
1670    #[test]
1671    fn empty_panel_flagged_arity() {
1672        let document = doc(vec![panel(vec![])]);
1673        let violations = validate_document(&document);
1674        assert_eq!(violations.len(), 1, "got: {violations:?}");
1675        let (parent, _, expected, actual, _) = unwrap_arity(&violations[0]);
1676        assert_eq!(parent, "panel");
1677        assert_eq!(expected, &Quantifier::OneOrMore);
1678        assert_eq!(actual, 0);
1679    }
1680
1681    #[test]
1682    fn unsupported_block_satisfies_parent_arity() {
1683        // panel + with [unsupportedBlock] → no violation (round-trip
1684        // preservation: the wrapper counts toward panel's arity).
1685        let document = doc(vec![panel(vec![leaf("unsupportedBlock")])]);
1686        assert_eq!(validate_document(&document), vec![]);
1687    }
1688
1689    #[test]
1690    fn unsupported_inline_satisfies_inline_parent_arity() {
1691        // taskItem is `inline*` (lenient), so this is trivially OK; the
1692        // assertion is that we don't reject the unsupportedInline. Both
1693        // taskList and taskItem need a localId; taskItem also needs a
1694        // state.
1695        let task_item = with_attrs(
1696            node("taskItem", vec![leaf("unsupportedInline")]),
1697            serde_json::json!({"localId": "ti1", "state": "TODO"}),
1698        );
1699        let task_list = with_attrs(
1700            node("taskList", vec![task_item]),
1701            serde_json::json!({"localId": "tl1"}),
1702        );
1703        let document = doc(vec![task_list]);
1704        assert_eq!(validate_document(&document), vec![]);
1705    }
1706
1707    // ---- Display formatting ----------------------------------------------
1708
1709    #[test]
1710    fn display_format_for_disallowed_child_is_back_compat() {
1711        let v = AdfSchemaViolation::DisallowedChild {
1712            child_type: "expand".into(),
1713            parent_type: "panel".into(),
1714            path: vec![0, 1, 0],
1715        };
1716        assert_eq!(
1717            v.to_string(),
1718            "ADF schema violation at /0/1/0: 'expand' is not permitted inside 'panel'"
1719        );
1720    }
1721
1722    #[test]
1723    fn display_format_for_arity_one_or_more() {
1724        let v = AdfSchemaViolation::Arity {
1725            parent_type: "bulletList".into(),
1726            atoms: vec!["listItem"],
1727            expected: Quantifier::OneOrMore,
1728            actual: 0,
1729            path: vec![1],
1730        };
1731        assert_eq!(
1732            v.to_string(),
1733            "ADF schema violation at /1: 'bulletList' must contain at least one 'listItem' (found 0)"
1734        );
1735    }
1736
1737    #[test]
1738    fn display_format_for_arity_exactly_one() {
1739        let v = AdfSchemaViolation::Arity {
1740            parent_type: "mediaSingle".into(),
1741            atoms: vec!["media"],
1742            expected: Quantifier::Exactly(1),
1743            actual: 2,
1744            path: vec![0],
1745        };
1746        assert_eq!(
1747            v.to_string(),
1748            "ADF schema violation at /0: 'mediaSingle' must contain exactly one 'media' (found 2)"
1749        );
1750    }
1751
1752    #[test]
1753    fn display_format_for_arity_range() {
1754        let v = AdfSchemaViolation::Arity {
1755            parent_type: "layoutSection".into(),
1756            atoms: vec!["layoutColumn"],
1757            expected: Quantifier::Range(2, 3),
1758            actual: 1,
1759            path: vec![0],
1760        };
1761        assert_eq!(
1762            v.to_string(),
1763            "ADF schema violation at /0: 'layoutSection' must contain between 2 and 3 'layoutColumn' (found 1)"
1764        );
1765    }
1766
1767    #[test]
1768    fn display_format_for_arity_alternation() {
1769        let v = AdfSchemaViolation::Arity {
1770            parent_type: "tableRow".into(),
1771            atoms: vec!["tableCell", "tableHeader"],
1772            expected: Quantifier::OneOrMore,
1773            actual: 0,
1774            path: vec![0, 0],
1775        };
1776        assert_eq!(
1777            v.to_string(),
1778            "ADF schema violation at /0/0: 'tableRow' must contain at least one {'tableCell', 'tableHeader'} (found 0)"
1779        );
1780    }
1781
1782    #[test]
1783    fn display_format_for_missing_attr() {
1784        let v = AdfSchemaViolation::MissingAttr {
1785            node_type: "panel".into(),
1786            attr_name: "panelType".into(),
1787            path: vec![0],
1788        };
1789        assert_eq!(
1790            v.to_string(),
1791            "ADF schema violation at /0: 'panel' is missing required attribute 'panelType'"
1792        );
1793    }
1794
1795    #[test]
1796    fn display_format_for_invalid_attr() {
1797        let v = AdfSchemaViolation::InvalidAttr {
1798            node_type: "heading".into(),
1799            attr_name: "level".into(),
1800            problem: crate::atlassian::adf_attr_schema::AttrProblem::OutOfRange {
1801                lo: 1,
1802                hi: 6,
1803                actual: 7,
1804            },
1805            path: vec![0],
1806        };
1807        let s = v.to_string();
1808        assert!(s.contains("'heading.level'"), "got: {s}");
1809        assert!(s.contains("invalid"), "got: {s}");
1810        assert!(s.contains("[1, 6]"), "got: {s}");
1811    }
1812
1813    #[test]
1814    fn display_format_for_disallowed_mark() {
1815        let v = AdfSchemaViolation::DisallowedMark {
1816            mark_type: "code".into(),
1817            parent_type: "heading".into(),
1818            inline_index: Some(0),
1819            path: vec![0, 1],
1820        };
1821        assert_eq!(
1822            v.to_string(),
1823            "ADF schema violation at /0/1: 'code' mark is not permitted on 'heading'"
1824        );
1825    }
1826
1827    #[test]
1828    fn display_format_for_invalid_mark_attr() {
1829        let v = AdfSchemaViolation::InvalidMarkAttr {
1830            mark_type: "link".into(),
1831            attr_name: "href".into(),
1832            problem: crate::atlassian::adf_attr_schema::AttrProblem::BadFormat {
1833                reason: "not a valid URL",
1834            },
1835            inline_index: Some(0),
1836            path: vec![0, 1],
1837        };
1838        let s = v.to_string();
1839        assert!(s.contains("'link' mark"), "got: {s}");
1840        assert!(s.contains("'href'"), "got: {s}");
1841        assert!(s.contains("not a valid URL"), "got: {s}");
1842    }
1843
1844    // ---- Quantifier behaviour --------------------------------------------
1845
1846    #[test]
1847    fn quantifier_satisfied_by() {
1848        assert!(Quantifier::ZeroOrOne.satisfied_by(0));
1849        assert!(Quantifier::ZeroOrOne.satisfied_by(1));
1850        assert!(!Quantifier::ZeroOrOne.satisfied_by(2));
1851
1852        assert!(Quantifier::ZeroOrMore.satisfied_by(0));
1853        assert!(Quantifier::ZeroOrMore.satisfied_by(99));
1854
1855        assert!(!Quantifier::OneOrMore.satisfied_by(0));
1856        assert!(Quantifier::OneOrMore.satisfied_by(1));
1857
1858        assert!(!Quantifier::Exactly(2).satisfied_by(1));
1859        assert!(Quantifier::Exactly(2).satisfied_by(2));
1860        assert!(!Quantifier::Exactly(2).satisfied_by(3));
1861
1862        assert!(!Quantifier::Range(2, 3).satisfied_by(1));
1863        assert!(Quantifier::Range(2, 3).satisfied_by(2));
1864        assert!(Quantifier::Range(2, 3).satisfied_by(3));
1865        assert!(!Quantifier::Range(2, 3).satisfied_by(4));
1866    }
1867
1868    // ── Quantifier::phrasing arm coverage ─────────────────────────────
1869    //
1870    // Each variant has its own phrasing fragment used in
1871    // `AdfSchemaViolation::Arity`'s Display. The fixture-driven Display
1872    // tests above only exercise OneOrMore, Exactly(1), and Range; cover
1873    // the remaining arms (ZeroOrOne, ZeroOrMore, Exactly(n>1)) here so
1874    // future renumbering of the Display wording is caught.
1875
1876    #[test]
1877    fn display_format_for_arity_zero_or_one() {
1878        let v = AdfSchemaViolation::Arity {
1879            parent_type: "mediaSingle".into(),
1880            atoms: vec!["caption"],
1881            expected: Quantifier::ZeroOrOne,
1882            actual: 2,
1883            path: vec![0],
1884        };
1885        assert_eq!(
1886            v.to_string(),
1887            "ADF schema violation at /0: 'mediaSingle' must contain at most one 'caption' (found 2)"
1888        );
1889    }
1890
1891    #[test]
1892    fn display_format_for_arity_zero_or_more() {
1893        // ZeroOrMore is never violated (any count is OK), so the Arity
1894        // variant with ZeroOrMore is not produced by the walker. Construct
1895        // directly to exercise the Display arm.
1896        let v = AdfSchemaViolation::Arity {
1897            parent_type: "paragraph".into(),
1898            atoms: vec!["text"],
1899            expected: Quantifier::ZeroOrMore,
1900            actual: 0,
1901            path: vec![0],
1902        };
1903        assert_eq!(
1904            v.to_string(),
1905            "ADF schema violation at /0: 'paragraph' must contain any number of 'text' (found 0)"
1906        );
1907    }
1908
1909    #[test]
1910    fn display_format_for_arity_exactly_n_greater_than_one() {
1911        let v = AdfSchemaViolation::Arity {
1912            parent_type: "futureNode".into(),
1913            atoms: vec!["child"],
1914            expected: Quantifier::Exactly(3),
1915            actual: 2,
1916            path: vec![0],
1917        };
1918        assert_eq!(
1919            v.to_string(),
1920            "ADF schema violation at /0: 'futureNode' must contain exactly 3 'child' (found 2)"
1921        );
1922    }
1923
1924    // ── path() accessor: every variant returns its path ─────────────────
1925    //
1926    // The match in `path()` uses an or-pattern `A | B | C => path`, so
1927    // each arm needs to be exercised separately to count as covered.
1928
1929    #[test]
1930    fn path_accessor_returns_path_for_each_variant() {
1931        let v1 = AdfSchemaViolation::DisallowedChild {
1932            child_type: "x".into(),
1933            parent_type: "y".into(),
1934            path: vec![1],
1935        };
1936        assert_eq!(v1.path(), &[1]);
1937
1938        let v2 = AdfSchemaViolation::Arity {
1939            parent_type: "y".into(),
1940            atoms: vec!["x"],
1941            expected: Quantifier::OneOrMore,
1942            actual: 0,
1943            path: vec![2],
1944        };
1945        assert_eq!(v2.path(), &[2]);
1946
1947        let v3 = AdfSchemaViolation::MissingAttr {
1948            node_type: "y".into(),
1949            attr_name: "a".into(),
1950            path: vec![3],
1951        };
1952        assert_eq!(v3.path(), &[3]);
1953
1954        let v4 = AdfSchemaViolation::InvalidAttr {
1955            node_type: "y".into(),
1956            attr_name: "a".into(),
1957            problem: crate::atlassian::adf_attr_schema::AttrProblem::WrongType {
1958                expected: "string",
1959            },
1960            path: vec![4],
1961        };
1962        assert_eq!(v4.path(), &[4]);
1963
1964        let v5 = AdfSchemaViolation::DisallowedMark {
1965            mark_type: "code".into(),
1966            parent_type: "heading".into(),
1967            inline_index: Some(0),
1968            path: vec![5],
1969        };
1970        assert_eq!(v5.path(), &[5]);
1971
1972        let v6 = AdfSchemaViolation::InvalidMarkAttr {
1973            mark_type: "link".into(),
1974            attr_name: "href".into(),
1975            problem: crate::atlassian::adf_attr_schema::AttrProblem::BadFormat {
1976                reason: "not a valid URL",
1977            },
1978            inline_index: Some(0),
1979            path: vec![6],
1980        };
1981        assert_eq!(v6.path(), &[6]);
1982    }
1983
1984    /// Allowlist entry: `(parent, upstream_extra_atoms, local_extra_atoms,
1985    /// justification)`. See [`LENIENCY_ALLOWLIST`] below.
1986    type LenientEntry = (
1987        &'static str,
1988        &'static [&'static str],
1989        &'static [&'static str],
1990        &'static str,
1991    );
1992
1993    /// Result of comparing the local and upstream atom maps.
1994    #[derive(Debug, Default)]
1995    struct SchemaAtomDiff {
1996        /// Parents in `CONTENT_ENTRIES` but not in `UPSTREAM_ENTRIES`.
1997        local_only_parents: Vec<&'static str>,
1998        /// Parents in `UPSTREAM_ENTRIES` but not in `CONTENT_ENTRIES`.
1999        upstream_only_parents: Vec<&'static str>,
2000        /// Human-readable per-parent atom-set mismatches that are not
2001        /// covered by the leniency allowlist.
2002        per_parent_unexpected: Vec<String>,
2003    }
2004
2005    impl SchemaAtomDiff {
2006        fn is_clean(&self) -> bool {
2007            self.local_only_parents.is_empty()
2008                && self.upstream_only_parents.is_empty()
2009                && self.per_parent_unexpected.is_empty()
2010        }
2011    }
2012
2013    /// Pure helper: diff two `BTreeMap<&str, BTreeSet<&str>>` views of the
2014    /// schema, accounting for an allowlist of intentional leniencies.
2015    ///
2016    /// Extracted from `generated_upstream_atoms_match_local_snapshot` so the
2017    /// failure-detection branches can be exercised by tests with synthetic
2018    /// inputs (the production maps are intentionally in sync).
2019    fn diff_atom_sets(
2020        local: &std::collections::BTreeMap<&'static str, std::collections::BTreeSet<&'static str>>,
2021        upstream: &std::collections::BTreeMap<
2022            &'static str,
2023            std::collections::BTreeSet<&'static str>,
2024        >,
2025        leniency: &[LenientEntry],
2026    ) -> SchemaAtomDiff {
2027        let local_parents: std::collections::BTreeSet<&'static str> =
2028            local.keys().copied().collect();
2029        let upstream_parents: std::collections::BTreeSet<&'static str> =
2030            upstream.keys().copied().collect();
2031
2032        let mut diff = SchemaAtomDiff {
2033            local_only_parents: local_parents
2034                .difference(&upstream_parents)
2035                .copied()
2036                .collect(),
2037            upstream_only_parents: upstream_parents
2038                .difference(&local_parents)
2039                .copied()
2040                .collect(),
2041            per_parent_unexpected: Vec::new(),
2042        };
2043
2044        for parent in local_parents.intersection(&upstream_parents) {
2045            let l = &local[parent];
2046            let u = &upstream[parent];
2047
2048            let allowed_upstream_extra: std::collections::BTreeSet<&str> = leniency
2049                .iter()
2050                .filter(|(p, _, _, _)| p == parent)
2051                .flat_map(|(_, ue, _, _)| ue.iter().copied())
2052                .collect();
2053            let allowed_local_extra: std::collections::BTreeSet<&str> = leniency
2054                .iter()
2055                .filter(|(p, _, _, _)| p == parent)
2056                .flat_map(|(_, _, le, _)| le.iter().copied())
2057                .collect();
2058
2059            let upstream_extra: Vec<&str> = u
2060                .iter()
2061                .filter(|c| !l.contains(**c) && !allowed_upstream_extra.contains(**c))
2062                .copied()
2063                .collect();
2064            let local_extra: Vec<&str> = l
2065                .iter()
2066                .filter(|c| !u.contains(**c) && !allowed_local_extra.contains(**c))
2067                .copied()
2068                .collect();
2069
2070            if !upstream_extra.is_empty() || !local_extra.is_empty() {
2071                diff.per_parent_unexpected.push(format!(
2072                    "{parent}: upstream_only={upstream_extra:?}, local_only={local_extra:?}"
2073                ));
2074            }
2075        }
2076
2077        diff
2078    }
2079
2080    /// Intentional atom-set leniencies. Each entry is `(parent,
2081    /// upstream_extra_atoms, local_extra_atoms, justification)`. Keep
2082    /// synchronised with the "LENIENT" comments in [`CONTENT_ENTRIES`].
2083    ///
2084    /// All currently-documented leniencies are *quantifier-only* (e.g.
2085    /// `block+` → `block*`), so the atom sets remain identical and this
2086    /// table is empty. If a future leniency adds or drops atoms (e.g.
2087    /// narrowing `listItem` to forbid `taskList`), record it here.
2088    const LENIENCY_ALLOWLIST: &[LenientEntry] = &[];
2089
2090    /// Build the upstream `BTreeMap` view from `generated::UPSTREAM_ENTRIES`.
2091    fn upstream_atom_map(
2092    ) -> std::collections::BTreeMap<&'static str, std::collections::BTreeSet<&'static str>> {
2093        generated::UPSTREAM_ENTRIES
2094            .iter()
2095            .map(|(p, children)| (*p, children.iter().copied().collect()))
2096            .collect()
2097    }
2098
2099    /// Issue #732 — the code-generated upstream-atom snapshot must agree with
2100    /// the hand-maintained [`CONTENT_ENTRIES`] table (modulo a small allowlist
2101    /// of intentional leniency deviations that are quantifier-only and
2102    /// therefore preserve atom-set equality).
2103    ///
2104    /// If this test fails, either:
2105    ///
2106    /// - Upstream `@atlaskit/adf-schema` shipped a content-model change.
2107    ///   Refresh `assets/adf-schema/full.json`, re-run
2108    ///   `cargo run --bin adf-schema-codegen`, update [`CONTENT_ENTRIES`] to
2109    ///   match, and bump [`SCHEMA_VERSION`] / [`UPSTREAM_TARBALL_SHA256`].
2110    /// - You edited [`CONTENT_ENTRIES`] in a way that desynchronises it from
2111    ///   the upstream atoms. Fix the entry, or document a new entry in
2112    ///   `LENIENCY_ALLOWLIST` if the deviation is intentional.
2113    #[test]
2114    fn generated_upstream_atoms_match_local_snapshot() {
2115        let local = local_schema_map();
2116        let upstream = upstream_atom_map();
2117        let diff = diff_atom_sets(&local, &upstream, LENIENCY_ALLOWLIST);
2118        assert!(
2119            diff.is_clean(),
2120            "atom-set drift between CONTENT_ENTRIES and generated::UPSTREAM_ENTRIES:\n\
2121             local_only_parents={:?}\n\
2122             upstream_only_parents={:?}\n\
2123             per_parent_unexpected={:?}",
2124            diff.local_only_parents,
2125            diff.upstream_only_parents,
2126            diff.per_parent_unexpected,
2127        );
2128    }
2129
2130    #[test]
2131    fn diff_atom_sets_reports_clean_when_maps_agree() {
2132        let mut m: std::collections::BTreeMap<
2133            &'static str,
2134            std::collections::BTreeSet<&'static str>,
2135        > = std::collections::BTreeMap::new();
2136        m.insert("panel", ["paragraph", "heading"].into_iter().collect());
2137        let diff = diff_atom_sets(&m, &m.clone(), &[]);
2138        assert!(diff.is_clean());
2139        assert!(diff.local_only_parents.is_empty());
2140        assert!(diff.upstream_only_parents.is_empty());
2141        assert!(diff.per_parent_unexpected.is_empty());
2142    }
2143
2144    #[test]
2145    fn diff_atom_sets_reports_local_only_parents() {
2146        let mut local: std::collections::BTreeMap<
2147            &'static str,
2148            std::collections::BTreeSet<&'static str>,
2149        > = std::collections::BTreeMap::new();
2150        local.insert("legacyNode", std::iter::once("paragraph").collect());
2151        let upstream: std::collections::BTreeMap<
2152            &'static str,
2153            std::collections::BTreeSet<&'static str>,
2154        > = std::collections::BTreeMap::new();
2155        let diff = diff_atom_sets(&local, &upstream, &[]);
2156        assert!(!diff.is_clean());
2157        assert_eq!(diff.local_only_parents, vec!["legacyNode"]);
2158        assert!(diff.upstream_only_parents.is_empty());
2159    }
2160
2161    #[test]
2162    fn diff_atom_sets_reports_upstream_only_parents() {
2163        let local: std::collections::BTreeMap<
2164            &'static str,
2165            std::collections::BTreeSet<&'static str>,
2166        > = std::collections::BTreeMap::new();
2167        let mut upstream: std::collections::BTreeMap<
2168            &'static str,
2169            std::collections::BTreeSet<&'static str>,
2170        > = std::collections::BTreeMap::new();
2171        upstream.insert("newNode", std::iter::once("paragraph").collect());
2172        let diff = diff_atom_sets(&local, &upstream, &[]);
2173        assert!(!diff.is_clean());
2174        assert_eq!(diff.upstream_only_parents, vec!["newNode"]);
2175        assert!(diff.local_only_parents.is_empty());
2176    }
2177
2178    #[test]
2179    fn diff_atom_sets_reports_unexpected_per_parent_diffs() {
2180        let mut local: std::collections::BTreeMap<
2181            &'static str,
2182            std::collections::BTreeSet<&'static str>,
2183        > = std::collections::BTreeMap::new();
2184        local.insert(
2185            "panel",
2186            ["paragraph", "heading"]
2187                .into_iter()
2188                .collect::<std::collections::BTreeSet<_>>(),
2189        );
2190        let mut upstream = local.clone();
2191        upstream.insert("panel", ["paragraph", "blockCard"].into_iter().collect());
2192        let diff = diff_atom_sets(&local, &upstream, &[]);
2193        assert!(!diff.is_clean());
2194        let msg = diff.per_parent_unexpected.join("\n");
2195        assert!(msg.contains("panel"));
2196        assert!(
2197            msg.contains("blockCard"),
2198            "upstream_only should mention blockCard: {msg}"
2199        );
2200        assert!(
2201            msg.contains("heading"),
2202            "local_only should mention heading: {msg}"
2203        );
2204    }
2205
2206    #[test]
2207    fn diff_atom_sets_honours_leniency_allowlist() {
2208        let mut local: std::collections::BTreeMap<
2209            &'static str,
2210            std::collections::BTreeSet<&'static str>,
2211        > = std::collections::BTreeMap::new();
2212        local.insert("panel", ["paragraph", "heading"].into_iter().collect());
2213        let mut upstream: std::collections::BTreeMap<
2214            &'static str,
2215            std::collections::BTreeSet<&'static str>,
2216        > = std::collections::BTreeMap::new();
2217        upstream.insert("panel", ["paragraph", "blockCard"].into_iter().collect());
2218        // Allowlist the exact deviation we just constructed.
2219        let lenient: &[LenientEntry] = &[(
2220            "panel",
2221            &["blockCard"], // upstream-only
2222            &["heading"],   // local-only
2223            "synthetic test deviation",
2224        )];
2225        let diff = diff_atom_sets(&local, &upstream, lenient);
2226        assert!(diff.is_clean(), "allowlist should mask the diff: {diff:?}");
2227    }
2228
2229    #[test]
2230    fn generated_provenance_matches_local_constants() {
2231        assert_eq!(
2232            generated::UPSTREAM_TARBALL_SHA256,
2233            UPSTREAM_TARBALL_SHA256,
2234            "the vendored JSON's provenance SHA must match the runtime constant; \
2235             both are bumped together when the snapshot is refreshed",
2236        );
2237        // SCHEMA_VERSION is `<npm-version>-YYYY-MM-DD`. Strip the trailing
2238        // 11-char date suffix to recover the npm version, which must match
2239        // the version baked into the generated file.
2240        let date_len = "-YYYY-MM-DD".len();
2241        let local_npm_prefix = SCHEMA_VERSION
2242            .get(..SCHEMA_VERSION.len().saturating_sub(date_len))
2243            .unwrap_or(SCHEMA_VERSION);
2244        assert_eq!(
2245            generated::UPSTREAM_VERSION,
2246            local_npm_prefix,
2247            "generated UPSTREAM_VERSION must match the npm-version prefix of SCHEMA_VERSION",
2248        );
2249    }
2250}