Skip to main content

rto_spec/
check.rs

1//! Drift checking: validate the authored layer (ADR wiki-links and `@rto:`
2//! annotations) against the derived code graph, and weave the valid links in as
3//! `authored` edges.
4//!
5//! [`run`] expects `store` to already hold the derived graph (symbols, files).
6//! It applies each ADR's structural nodes, then for every authored link checks
7//! that its target exists — reporting a [`Violation`] when it does not — and
8//! adds an `authored` edge when it does.
9//!
10//! It also guards the authored layer's own integrity: ADR ids are node keys, so
11//! two ADRs claiming one id silently discard a decision. See
12//! [`duplicate_adr_ids`].
13
14use std::collections::{BTreeMap, BTreeSet};
15
16use rto_graph::{Edge, EdgeKind, Store, StoreError};
17use serde::Serialize;
18
19use crate::adr::{AdrDoc, AdrStatus};
20use crate::annotate::Annotation;
21use crate::blueprint::BlueprintDoc;
22use crate::layer::AuthoredDocs;
23use crate::site::SitePage;
24
25/// The category of an authored-layer drift.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "kebab-case")]
28pub enum ViolationKind {
29    /// An ADR under `docs/adr` could not be parsed.
30    MalformedAdr,
31    /// An ADR `[[…]]` link points at a symbol or file not in the graph.
32    BrokenLink,
33    /// A `@rto:` annotation references an ADR that does not exist.
34    UnknownAdr,
35    /// A `@rto:` annotation references a rejected or superseded ADR.
36    InactiveAdr,
37    /// Two or more ADR files declare the same `adr-id`.
38    DuplicateAdrId,
39    /// An ADR's version metadata disagrees with itself.
40    AdrVersionDrift,
41    /// A document declared itself a published site page but could not be parsed
42    /// as one.
43    MalformedSitePage,
44    /// Two or more documents declare the same `site-page` slug.
45    DuplicateSiteSlug,
46}
47
48impl ViolationKind {
49    /// A short stable label for this kind.
50    #[must_use]
51    pub fn label(self) -> &'static str {
52        match self {
53            Self::MalformedAdr => "malformed-adr",
54            Self::BrokenLink => "broken-link",
55            Self::UnknownAdr => "unknown-adr",
56            Self::InactiveAdr => "inactive-adr",
57            Self::DuplicateAdrId => "duplicate-adr-id",
58            Self::AdrVersionDrift => "adr-version-drift",
59            Self::MalformedSitePage => "malformed-site-page",
60            Self::DuplicateSiteSlug => "duplicate-site-slug",
61        }
62    }
63}
64
65/// A single authored-layer drift finding.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
67pub struct Violation {
68    /// What kind of drift this is.
69    pub kind: ViolationKind,
70    /// A human-readable, location-prefixed message.
71    pub message: String,
72}
73
74/// The outcome of a [`run`]: how much authored content was checked and any
75/// drift found.
76#[derive(Debug, Clone, Default, Serialize)]
77pub struct CheckReport {
78    /// Number of ADRs parsed and applied.
79    pub adrs: usize,
80    /// Number of blueprints parsed and applied.
81    pub blueprints: usize,
82    /// Number of published site pages parsed and applied.
83    pub site_pages: usize,
84    /// Authored `[[…]]` links that resolved and became edges.
85    pub links_ok: usize,
86    /// `@rto:` annotations that resolved to an active ADR.
87    pub annotations_ok: usize,
88    /// Drift findings; the check fails if this is non-empty.
89    pub violations: Vec<Violation>,
90}
91
92impl CheckReport {
93    /// Whether any drift was found.
94    #[must_use]
95    pub fn has_violations(&self) -> bool {
96        !self.violations.is_empty()
97    }
98}
99
100/// Find `adr-id` values claimed by more than one ADR file.
101///
102/// An ADR's node key is `adr:<id>` ([`AdrDoc::key`]), so this collision is not
103/// cosmetic — it is *lossy*. Two files sharing an id produce one node key, the
104/// later [`Store::apply_factset`] overwrites the earlier, and from then on
105/// `query adr:NNNN` answers for one decision while the other is invisible, every
106/// `@rto:NNNN` annotation binds to whichever won, and the published artifact
107/// carries the survivor alone. Nothing else in the pipeline notices: the two
108/// files merge cleanly in git (they touch no common line) and every other check
109/// passes. That is exactly how ADR-0016 came to be authored twice on two
110/// parallel branches in this repository.
111///
112/// The message names **both** paths and the id: an id alone leaves the reader to
113/// hunt for the partner file, which is the work this check exists to save.
114///
115/// The same collision class does *not* exist for the other keyed documents,
116/// because their ids are their paths, and a tree cannot hold two files at one
117/// path: blueprints are `blueprint:<path>` ([`BlueprintDoc::key`]), `lat.md`
118/// nodes are `lat:<path>`, files are `file:<path>` and symbols are
119/// `sym:<lang>:<path>#<symbol>`. Imported Graphify nodes (`graphify:<id>`) do
120/// carry an author-chosen id, but importing is an explicit, single-document act
121/// whose merge semantics are deliberate rather than accidental, and hyperedges
122/// are already namespaced away from nodes to prevent exactly this clobber.
123/// Multi-repo workspaces hold one [`Store`] per project, so ids collide only
124/// within a repository, never across one.
125fn duplicate_adr_ids(docs: &[AdrDoc]) -> Vec<Violation> {
126    let mut by_id: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
127    for doc in docs {
128        by_id
129            .entry(doc.meta.id.as_str())
130            .or_default()
131            .push(doc.path.as_str());
132    }
133    by_id
134        .into_iter()
135        .filter(|(_, paths)| paths.len() > 1)
136        .map(|(id, mut paths)| {
137            // Sort so the message is stable whatever order the tree walk yielded.
138            paths.sort_unstable();
139            Violation {
140                kind: ViolationKind::DuplicateAdrId,
141                message: format!(
142                    "adr-id {id} is declared by {} files: {} — all of them collapse \
143                     into the single node `adr:{id}`, so only one decision survives \
144                     and every @rto:{id} annotation binds to it",
145                    paths.len(),
146                    paths.join(", "),
147                ),
148            }
149        })
150        .collect()
151}
152
153/// Find `site-page` slugs claimed by more than one document.
154///
155/// Exactly [`duplicate_adr_ids`]'s failure, in the one other place this
156/// repository lets an author choose a key. A site page's node key is
157/// `site:<slug>` and its published filename is `<slug>.html`, so two documents
158/// claiming one slug collapse twice over: the later `apply_factset` overwrites
159/// the earlier node, and the later write to `<slug>.html` overwrites the earlier
160/// page. The site then serves one document at an address the other one also
161/// claims, and nothing else notices — the two files merge cleanly in git, and
162/// every other check passes. That is the ADR-0016 story with a public URL
163/// attached.
164///
165/// The message names **both** paths and the slug, for the reason
166/// [`duplicate_adr_ids`] does: a slug alone leaves the reader to hunt for the
167/// partner file, which is the work this check exists to save.
168fn duplicate_site_slugs(pages: &[SitePage]) -> Vec<Violation> {
169    let mut by_slug: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
170    for page in pages {
171        by_slug
172            .entry(page.slug.as_str())
173            .or_default()
174            .push(page.path.as_str());
175    }
176    by_slug
177        .into_iter()
178        .filter(|(_, paths)| paths.len() > 1)
179        .map(|(slug, mut paths)| {
180            // Sort so the message is stable whatever order the tree walk yielded.
181            paths.sort_unstable();
182            Violation {
183                kind: ViolationKind::DuplicateSiteSlug,
184                message: format!(
185                    "site-page slug `{slug}` is declared by {} files: {} — all of \
186                     them collapse into the single node `site:{slug}` and the single \
187                     published page `{slug}.html`, so only one document survives",
188                    paths.len(),
189                    paths.join(", "),
190                ),
191            }
192        })
193        .collect()
194}
195
196/// Find ADRs whose version metadata contradicts itself.
197///
198/// An ADR states its version in three places, and nothing until now compared
199/// them. Three real defects were found by hand in this repository on
200/// 2026-08-18, all of this shape, while `check` reported 0 violations: ADR-0001
201/// carried frontmatter `1.2` over a summary row reading `1.0` (#406); ADR-0006
202/// listed 1.3 above 1.2 in its history and carried an inline note citing
203/// `(Update, v1.5)`, a version it has never had (#413). The third is the worst
204/// of them — `git log -S` put the change that note describes at 2026-08-14,
205/// when the document was at 1.1, and it never got a history row at all. A
206/// version claim nobody checks is a claim that quietly stops being true.
207///
208/// Five contradictions, reported under one kind because a caller does the same
209/// thing with all of them — fail the gate and print the message — and because
210/// the message, not the label, is what tells the reader which one fired. This
211/// follows [`ViolationKind::MalformedAdr`], which likewise covers every
212/// [`crate::adr::ParseError`] behind one label and puts the specifics in prose.
213/// It is also why rules 4 and 5 (issue #432) needed no new variant, and so
214/// reopened no part of the semver question this enum's exhaustiveness raises.
215///
216/// Each message names the file and **both** conflicting values, for the reason
217/// [`duplicate_adr_ids`] names both paths: a message that reports only what it
218/// found leaves the reader to hunt for what it was compared against.
219///
220/// # Why rules 4 and 5 are separate rules rather than widenings
221///
222/// Rule 1 compares the frontmatter to the **summary row**, and rule 4 compares
223/// it to the **history**. That looks redundant until you see how the defect
224/// actually happens: the row and the frontmatter are usually forgotten in the
225/// *same* edit, so they agree with each other and both lag the history. Rule 1
226/// is silent on exactly the documents rule 4 catches — ADR-0009 sat at 1.10
227/// over a history reaching 1.11, and ADR-0014 at 1.4 over 1.5. Both were found
228/// by hand while rule 1 was being written, by the rule's own author, with rule 1
229/// passing.
230///
231/// Rule 5 is **one-directional on purpose**, and getting that backwards would be
232/// worse than not having it. `last-modified` running *ahead* of the newest
233/// history row is legitimate — a typo fix or a link repair need not earn a row —
234/// so an equality rule would fire on every small edit and be switched off within
235/// a week. Only the impossible direction is checked: a document cannot have been
236/// last modified before a change it itself lists. When it was measured, eight of
237/// this repository's twenty ADRs lagged, three of them put there by people
238/// actively repairing this same family.
239///
240/// Still deliberately *not* checked, because widening a rule that returns one
241/// hit or none is how it becomes a rule nobody reads: that a `version:`, a
242/// summary row or a history table exists at all. The contradiction is the
243/// finding, and ADR-0011 legitimately has no history.
244fn adr_version_drift(docs: &[AdrDoc]) -> Vec<Violation> {
245    let mut out = Vec::new();
246    for doc in docs {
247        let path = &doc.path;
248        let facts = &doc.versions;
249
250        // 1. The two places a *current* version is written must agree. This is
251        //    the pair ADR-0001 got wrong; a reader trusting frontmatter and a
252        //    reader trusting the rendered table came away with different answers.
253        if let (Some(front), Some(row)) = (doc.meta.version, facts.summary_row)
254            && front != row
255        {
256            out.push(Violation {
257                kind: ViolationKind::AdrVersionDrift,
258                message: format!(
259                    "{path}: frontmatter says version {front} but the summary \
260                     table's **Document version** row says {row}"
261                ),
262            });
263        }
264
265        // 2. The history is a sequence, so it has to read as one. Compared
266        //    component-wise: 1.10 follows 1.9, and any ordering that puts it
267        //    first would report this repository's longest-running ADR as broken.
268        for pair in facts.history.windows(2) {
269            let (prev, next) = (pair[0].version, pair[1].version);
270            if next > prev {
271                continue;
272            }
273            let why = if next == prev {
274                "twice"
275            } else {
276                "out of order"
277            };
278            out.push(Violation {
279                kind: ViolationKind::AdrVersionDrift,
280                message: format!(
281                    "{path}: version history lists {next} after {prev} — {why}; the \
282                     rows must ascend so the document reads as its own changelog"
283                ),
284            });
285        }
286
287        // 3. A note citing a version the history never recorded describes a
288        //    change the document cannot account for. Skipped when there is no
289        //    history table: an absent table contradicts nothing, and requiring
290        //    one is the fourth rule this deliberately is not.
291        if facts.history.is_empty() {
292            continue;
293        }
294        for reference in &facts.inline_refs {
295            if facts.history.iter().any(|r| r.version == reference.version) {
296                continue;
297            }
298            let known = facts
299                .history
300                .iter()
301                .map(|r| r.version.to_string())
302                .collect::<Vec<_>>()
303                .join(", ");
304            out.push(Violation {
305                kind: ViolationKind::AdrVersionDrift,
306                message: format!(
307                    "{path}:{}: an inline note cites (Update, v{}), a version this \
308                     document has never had — its history records {known}",
309                    reference.line, reference.version
310                ),
311            });
312        }
313
314        // 4. The frontmatter must have kept up with the history. A row is added
315        //    by the person making the change; `version:` is a second place the
316        //    same fact is written, and it is the one that gets forgotten —
317        //    ADR-0009 sat at 1.10 over a history reaching 1.11, ADR-0014 at 1.4
318        //    over 1.5, both found while rule 1 was being written and neither
319        //    caught by it, because rule 1 compares frontmatter to the *summary
320        //    row* and the summary row was forgotten in the same edit.
321        //
322        //    Compared against the highest row rather than the last one so this
323        //    still reports honestly on a document rule 2 has already failed:
324        //    with the rows out of order, "the last row" is not the version the
325        //    document has reached.
326        if let (Some(front), Some(highest)) = (
327            doc.meta.version,
328            facts.history.iter().map(|r| r.version).max(),
329        ) && front != highest
330        {
331            out.push(Violation {
332                kind: ViolationKind::AdrVersionDrift,
333                message: format!(
334                    "{path}: frontmatter says version {front} but the version \
335                     history reaches {highest} — the frontmatter was not bumped \
336                     with the row that records the change"
337                ),
338            });
339        }
340
341        // 5. `last-modified` must not predate a change the document itself
342        //    records. **One-directional on purpose.** Running *ahead* of the
343        //    newest row is legitimate — a typo fix or a link repair need not
344        //    earn a history row — so an equality rule would fire on every small
345        //    edit and be switched off within a week. Only the impossible
346        //    direction is a violation: the document cannot have last been
347        //    modified before a change it lists.
348        //
349        //    Rows without a parseable date are skipped rather than guessed at;
350        //    a row reading `TBD` makes no claim to contradict.
351        if let (Some(modified), Some(newest)) = (
352            doc.meta.last_modified,
353            facts.history.iter().filter_map(|r| r.date).max(),
354        ) && modified < newest
355        {
356            out.push(Violation {
357                kind: ViolationKind::AdrVersionDrift,
358                message: format!(
359                    "{path}: frontmatter last-modified is {modified} but the \
360                     version history records a change on {newest} — a document \
361                     cannot have been last modified before a change it lists"
362                ),
363            });
364        }
365    }
366    out
367}
368
369/// The outcome of a read-only [`validate`]: the report, plus the `authored`
370/// edges the valid links and annotations *would* weave into the graph.
371///
372/// Splitting the edges out of the report is what lets one violation definition
373/// serve both a gate that writes ([`run`]) and a tool surface that must not
374/// ([`crate::tool_check`]). Nothing decides what counts as drift twice.
375#[derive(Debug, Clone, Default)]
376pub struct Validation {
377    /// What was checked and what drifted.
378    pub report: CheckReport,
379    /// The `authored` `references` edges the resolved links and annotations
380    /// imply. [`run`] inserts these; a read-only caller discards them.
381    pub edges: Vec<Edge>,
382}
383
384/// The nodes the authored layer *would* contribute, and each authored ADR's
385/// parsed `status`.
386///
387/// [`run`] applies these to the store before validating, so its `get_node`
388/// lookups see them. [`validate`] must reach the same verdict without writing,
389/// so it consults this overlay first and the store second — the keys come from
390/// the very same [`AdrDoc::facts`]/[`BlueprintDoc::facts`]/[`SitePage::facts`]
391/// sets `run` applies, so the two cannot disagree about what the authored layer
392/// contributes.
393///
394/// Later docs overwrite earlier ones, matching `apply_factset`'s
395/// last-writer-wins — which is exactly the lossiness [`duplicate_adr_ids`]
396/// reports separately.
397#[derive(Debug, Default)]
398struct AuthoredOverlay {
399    /// Every node key the authored layer contributes (ADRs, ADR sections,
400    /// blueprints, site pages, and all of their sections).
401    keys: BTreeSet<String>,
402    /// Parsed status per ADR node key.
403    adr_status: BTreeMap<String, AdrStatus>,
404}
405
406fn authored_overlay(
407    docs: &[AdrDoc],
408    blueprints: &[BlueprintDoc],
409    site: &[SitePage],
410) -> AuthoredOverlay {
411    let mut overlay = AuthoredOverlay::default();
412    for doc in docs {
413        overlay
414            .keys
415            .extend(doc.facts().nodes.into_iter().map(|n| n.key));
416        overlay.adr_status.insert(doc.key(), doc.meta.status);
417    }
418    for bp in blueprints {
419        overlay
420            .keys
421            .extend(bp.facts().nodes.into_iter().map(|n| n.key));
422    }
423    for page in site {
424        overlay
425            .keys
426            .extend(page.facts().nodes.into_iter().map(|n| n.key));
427    }
428    overlay
429}
430
431/// Validate the authored layer against the derived graph **without writing
432/// anything**, returning the report and the edges a writing caller should weave.
433///
434/// This is the whole of the drift rule. [`run`] is this function plus the two
435/// writes it deliberately leaves out (applying the ADR/blueprint structure, and
436/// inserting the returned edges), so the CLI gate and the read-only tool surfaces
437/// cannot drift apart in what they call a violation.
438///
439/// # Errors
440/// Returns [`StoreError`] if querying the store fails.
441pub fn validate(
442    store: &Store,
443    docs: &[AdrDoc],
444    blueprints: &[BlueprintDoc],
445    annotations: &[Annotation],
446) -> Result<Validation, StoreError> {
447    validate_all(store, docs, blueprints, &[], annotations)
448}
449
450/// [`validate`] over a whole [`AuthoredDocs`] — the same verdict, plus the
451/// **site pages** the three-slice form has no parameter for.
452///
453/// Two entry points rather than one, because the classification that produces
454/// site pages ([`crate::authored_layer_from`]) and the CLI gate that consumes
455/// them land in separate changes: a caller still passing three slices keeps
456/// compiling and keeps getting exactly today's verdict, and moves to this
457/// function when it is ready to check the website too. The shared body below is
458/// the only copy of the rule, so the two cannot drift into disagreeing about
459/// what a violation is.
460///
461/// # Errors
462/// Returns [`StoreError`] if querying the store fails.
463pub fn validate_layer(store: &Store, docs: &AuthoredDocs) -> Result<Validation, StoreError> {
464    validate_all(
465        store,
466        &docs.layer.docs,
467        &docs.layer.blueprints,
468        &docs.site,
469        &docs.layer.annotations,
470    )
471}
472
473/// The whole drift rule, over every authored document class. [`validate`] and
474/// [`validate_layer`] are this function with and without site pages.
475fn validate_all(
476    store: &Store,
477    docs: &[AdrDoc],
478    blueprints: &[BlueprintDoc],
479    site: &[SitePage],
480    annotations: &[Annotation],
481) -> Result<Validation, StoreError> {
482    // 1. Detect colliding ADR ids *before* anything is applied, so the report
483    //    describes the authored file set rather than what survived the merge.
484    let mut report = CheckReport {
485        adrs: docs.len(),
486        blueprints: blueprints.len(),
487        site_pages: site.len(),
488        violations: duplicate_adr_ids(docs),
489        ..CheckReport::default()
490    };
491    // The same collision in the one other place an author picks a key.
492    report.violations.extend(duplicate_site_slugs(site));
493    // Self-contradiction inside one ADR, checked alongside the collision
494    // *between* ADRs above: neither needs the graph, both are read off the
495    // authored files exactly as they were parsed.
496    report.violations.extend(adr_version_drift(docs));
497    let overlay = authored_overlay(docs, blueprints, site);
498    let mut edges = Vec::new();
499
500    // 2. Validate ADR, blueprint and site-page `[[…]]` links against the code
501    //    graph. All three author `references` edges into real symbols/files and
502    //    drift the same way — which is the point of making the website a
503    //    document class rather than a pile of hand-written HTML: a page that
504    //    describes `security run`'s isolation posture can cite the code that
505    //    implements it, and the citation fails the gate when the code moves.
506    let links = docs
507        .iter()
508        .flat_map(|d| &d.links)
509        .chain(blueprints.iter().flat_map(|b| &b.links))
510        .chain(site.iter().flat_map(|p| &p.links));
511    for link in links {
512        // A link resolves against the derived graph, or against an ADR the
513        // authored layer is contributing in this same pass.
514        if store.get_node(&link.target_key)?.is_some() || overlay.keys.contains(&link.target_key) {
515            edges.push(Edge::authored(
516                link.from.clone(),
517                link.target_key.clone(),
518                EdgeKind::References,
519            ));
520            report.links_ok += 1;
521        } else {
522            report.violations.push(Violation {
523                kind: ViolationKind::BrokenLink,
524                message: format!(
525                    "{}: authored link [[{}]] does not resolve ({} not found in graph)",
526                    link.from, link.raw, link.target_key
527                ),
528            });
529        }
530    }
531
532    // 3. Validate `@rto:` annotations against ADR state. The overlay is consulted
533    //    first: an ADR authored in this pass is the one the annotation means, and
534    //    its parsed status is what `run` would have written to the node.
535    for ann in annotations {
536        let key = ann.target_key();
537        let status = match overlay.adr_status.get(&key) {
538            Some(status) => Some(*status),
539            None => match store.get_node(&key)? {
540                Some(adr) => Some(
541                    adr.meta
542                        .get("status")
543                        .and_then(|s| s.as_str())
544                        .and_then(|s| s.parse::<AdrStatus>().ok())
545                        // A node with an unparseable status still *exists*, so it
546                        // is not `unknown-adr`; treat it as active, exactly as the
547                        // pre-split code did by leaving `status` at `None`.
548                        .unwrap_or(AdrStatus::Accepted),
549                ),
550                None => None,
551            },
552        };
553        let Some(status) = status else {
554            report.violations.push(Violation {
555                kind: ViolationKind::UnknownAdr,
556                message: format!(
557                    "{}:{}: @rto:{} references unknown ADR",
558                    ann.path, ann.line, ann.adr_id
559                ),
560            });
561            continue;
562        };
563        if !status.is_active() {
564            report.violations.push(Violation {
565                kind: ViolationKind::InactiveAdr,
566                message: format!(
567                    "{}:{}: @rto:{} references non-active ADR ({})",
568                    ann.path,
569                    ann.line,
570                    ann.adr_id,
571                    status.as_str()
572                ),
573            });
574            continue;
575        }
576        // Link the annotated file to the ADR when the file is in the graph.
577        let file_key = format!("file:{}", ann.path);
578        if store.get_node(&file_key)?.is_some() {
579            edges.push(Edge::authored(file_key, key, EdgeKind::References));
580        }
581        report.annotations_ok += 1;
582    }
583
584    Ok(Validation { report, edges })
585}
586
587/// Apply the authored layer to `store` and validate it against the derived
588/// graph, returning a [`CheckReport`].
589///
590/// The verdict itself comes from [`validate`]; this function is the writing half
591/// around it — materialising ADR/blueprint structure so links can reference it,
592/// and weaving the resolved links in as `authored` edges.
593///
594/// # Errors
595/// Returns [`StoreError`] if applying ADR facts or edges, or querying the
596/// store, fails.
597pub fn run(
598    store: &mut Store,
599    docs: &[AdrDoc],
600    blueprints: &[BlueprintDoc],
601    annotations: &[Annotation],
602) -> Result<CheckReport, StoreError> {
603    run_all(store, docs, blueprints, &[], annotations)
604}
605
606/// [`run`] over a whole [`AuthoredDocs`], including its **site pages**. See
607/// [`validate_layer`] for why both entry points exist.
608///
609/// # Errors
610/// Returns [`StoreError`] if applying authored facts or edges, or querying the
611/// store, fails.
612pub fn run_layer(store: &mut Store, docs: &AuthoredDocs) -> Result<CheckReport, StoreError> {
613    run_all(
614        store,
615        &docs.layer.docs,
616        &docs.layer.blueprints,
617        &docs.site,
618        &docs.layer.annotations,
619    )
620}
621
622/// The writing half, over every authored document class.
623fn run_all(
624    store: &mut Store,
625    docs: &[AdrDoc],
626    blueprints: &[BlueprintDoc],
627    site: &[SitePage],
628    annotations: &[Annotation],
629) -> Result<CheckReport, StoreError> {
630    // Materialise ADR/blueprint/site-page section nodes so links and annotations
631    // can reference them (and so `@rto:` targets can be looked up by key).
632    for doc in docs {
633        store.apply_factset(&doc.facts())?;
634    }
635    for bp in blueprints {
636        store.apply_factset(&bp.facts())?;
637    }
638    for page in site {
639        store.apply_factset(&page.facts())?;
640    }
641
642    let validation = validate_all(store, docs, blueprints, site, annotations)?;
643    for edge in &validation.edges {
644        store.insert_edge(edge)?;
645    }
646    Ok(validation.report)
647}
648
649#[cfg(test)]
650mod tests {
651    use super::{ViolationKind, run, run_layer};
652    use crate::adr::parse_adr;
653    use crate::annotate::scan_annotations;
654    use crate::layer::{AuthoredDocs, AuthoredLayer};
655    use crate::site::parse_site_page;
656    use rto_graph::{Node, NodeKind, Store};
657
658    /// An [`AuthoredDocs`] holding only site pages — the rest of the authored
659    /// layer is exercised by the tests above.
660    fn site_layer(pages: Vec<crate::site::SitePage>) -> AuthoredDocs {
661        AuthoredDocs {
662            site: pages,
663            ..AuthoredDocs::default()
664        }
665    }
666
667    fn seed_graph(store: &Store) {
668        // A tiny derived graph: one file and one symbol.
669        store
670            .upsert_node(&Node::new("file:src/store.rs", NodeKind::File, "store.rs"))
671            .expect("file");
672        store
673            .upsert_node(&Node::new(
674                "sym:rust:src/store.rs#Store",
675                NodeKind::Struct,
676                "Store",
677            ))
678            .expect("sym");
679    }
680
681    #[test]
682    fn resolvable_links_and_annotations_pass() {
683        let mut store = Store::open_in_memory().expect("store");
684        seed_graph(&store);
685
686        let adr = "---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n# ADR-0001\n\n## Design\n\nUses [[src/store.rs#Store]].\n";
687        let doc = parse_adr("docs/adr/0001.md", adr).expect("parse");
688        let anns = scan_annotations("src/store.rs", "//! @rto:0001\n");
689
690        let report = run(&mut store, &[doc], &[], &anns).expect("run");
691        assert!(!report.has_violations(), "{:?}", report.violations);
692        assert_eq!(report.links_ok, 1);
693        assert_eq!(report.annotations_ok, 1);
694        // The authored edge is now in the graph.
695        let edges = store.edges_from("adr:0001#design").expect("edges");
696        assert!(edges.iter().any(|e| e.dst == "sym:rust:src/store.rs#Store"));
697    }
698
699    #[test]
700    fn a_site_page_s_links_are_drift_checked_like_an_adr_s() {
701        // The whole point of the document class: the public website's claims are
702        // held against the graph, so a page that cites the code it describes
703        // fails the gate when that code moves.
704        let mut store = Store::open_in_memory().expect("store");
705        seed_graph(&store);
706        let ok = parse_site_page(
707            "docs/site/modes.md",
708            "---\nsite-page: modes\n---\n\n# Modes\n\n## Offline\n\nSee [[src/store.rs#Store]].\n",
709        )
710        .expect("parse");
711        let report = run_layer(&mut store, &site_layer(vec![ok])).expect("run");
712        assert!(!report.has_violations(), "{:?}", report.violations);
713        assert_eq!(report.site_pages, 1);
714        assert_eq!(report.links_ok, 1);
715        // The authored edge is in the graph, attributed to the page's section.
716        let edges = store.edges_from("site:modes#offline").expect("edges");
717        assert!(edges.iter().any(|e| e.dst == "sym:rust:src/store.rs#Store"));
718
719        // The failing half — this is what would have caught the stale
720        // `--allow-unsandboxed` claim the hand-written page carried.
721        let mut store = Store::open_in_memory().expect("store");
722        seed_graph(&store);
723        let stale = parse_site_page(
724            "docs/site/modes.md",
725            "---\nsite-page: modes\n---\n\n# Modes\n\nSee [[src/store.rs#Ghost]].\n",
726        )
727        .expect("parse");
728        let report = run_layer(&mut store, &site_layer(vec![stale])).expect("run");
729        assert_eq!(report.violations.len(), 1);
730        assert_eq!(report.violations[0].kind, ViolationKind::BrokenLink);
731    }
732
733    #[test]
734    fn two_pages_sharing_a_slug_are_a_violation_naming_both_files() {
735        // `duplicate_adr_ids` with a public URL attached: one node key and one
736        // published filename, so the later document silently replaces the first.
737        let mut store = Store::open_in_memory().expect("store");
738        seed_graph(&store);
739        let one = parse_site_page(
740            "docs/site/config.md",
741            "---\nsite-page: config\n---\n\n# Configuration\n",
742        )
743        .expect("one");
744        let two = parse_site_page(
745            "docs/OFFLINE_SETUP.md",
746            "---\nsite-page: config\n---\n\n# Offline setup\n",
747        )
748        .expect("two");
749        let report = run_layer(&mut store, &site_layer(vec![one, two])).expect("run");
750        let dupes: Vec<_> = report
751            .violations
752            .iter()
753            .filter(|v| v.kind == ViolationKind::DuplicateSiteSlug)
754            .collect();
755        assert_eq!(dupes.len(), 1, "one finding for the one colliding slug");
756        let msg = &dupes[0].message;
757        assert!(msg.contains("config"), "names the slug: {msg}");
758        assert!(
759            msg.contains("docs/site/config.md"),
760            "names the first: {msg}"
761        );
762        assert!(
763            msg.contains("docs/OFFLINE_SETUP.md"),
764            "names the second: {msg}"
765        );
766    }
767
768    #[test]
769    fn the_three_slice_entry_point_still_reaches_the_same_verdict_today() {
770        // `run` is `run_layer` with no site pages. A caller that has not moved
771        // over must see exactly the report it sees today — that is the whole
772        // reason both entry points exist.
773        let mut a = Store::open_in_memory().expect("store");
774        seed_graph(&a);
775        let mut b = Store::open_in_memory().expect("store");
776        seed_graph(&b);
777        let adr = "---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n# ADR-0001\n\n## Design\n\nUses [[src/store.rs#Store]].\n";
778        let doc = parse_adr("docs/adr/0001.md", adr).expect("parse");
779
780        let old = run(&mut a, std::slice::from_ref(&doc), &[], &[]).expect("run");
781        let new = run_layer(
782            &mut b,
783            &AuthoredDocs {
784                layer: AuthoredLayer {
785                    docs: vec![doc],
786                    ..AuthoredLayer::default()
787                },
788                ..AuthoredDocs::default()
789            },
790        )
791        .expect("run_layer");
792        assert_eq!(old.adrs, new.adrs);
793        assert_eq!(old.links_ok, new.links_ok);
794        assert_eq!(old.violations.len(), new.violations.len());
795        assert_eq!(old.site_pages, 0, "no site pages via the three-slice form");
796        assert_eq!(new.site_pages, 0);
797    }
798
799    #[test]
800    fn broken_link_is_a_violation() {
801        let mut store = Store::open_in_memory().expect("store");
802        seed_graph(&store);
803        let adr =
804            "---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n## Design\n\n[[src/store.rs#Ghost]]\n";
805        let doc = parse_adr("docs/adr/0001.md", adr).expect("parse");
806
807        let report = run(&mut store, &[doc], &[], &[]).expect("run");
808        assert_eq!(report.violations.len(), 1);
809        assert_eq!(report.violations[0].kind, ViolationKind::BrokenLink);
810    }
811
812    #[test]
813    fn two_adrs_sharing_an_id_are_a_violation_naming_both_files() {
814        // The regression from issue #324: two branches each author ADR-0016.
815        // Both files merge cleanly, both parse, and both apply to the *same*
816        // node key — so without this check the report is 0 violations.
817        let mut store = Store::open_in_memory().expect("store");
818        seed_graph(&store);
819        let one = parse_adr(
820            "docs/adr/0016-audio-metadata.md",
821            "---\nadr-id: \"0016\"\nstatus: Accepted\n---\n\n# Audio metadata\n\n## Decision\n\nbody\n",
822        )
823        .expect("parse one");
824        let two = parse_adr(
825            "docs/adr/0016-speculative-decoding.md",
826            "---\nadr-id: \"0016\"\nstatus: Accepted\n---\n\n# Speculative decoding\n\n## Decision\n\nbody\n",
827        )
828        .expect("parse two");
829
830        let report = run(&mut store, &[one, two], &[], &[]).expect("run");
831        let dupes: Vec<_> = report
832            .violations
833            .iter()
834            .filter(|v| v.kind == ViolationKind::DuplicateAdrId)
835            .collect();
836        assert_eq!(dupes.len(), 1, "one finding for the one colliding id");
837        // Both paths and the id must be named — an id alone makes the reader hunt.
838        let msg = &dupes[0].message;
839        assert!(msg.contains("0016"), "names the shared id: {msg}");
840        assert!(
841            msg.contains("docs/adr/0016-audio-metadata.md"),
842            "names the first file: {msg}"
843        );
844        assert!(
845            msg.contains("docs/adr/0016-speculative-decoding.md"),
846            "names the second file: {msg}"
847        );
848        assert!(report.has_violations(), "the gate must fail");
849    }
850
851    #[test]
852    fn distinct_adr_ids_are_not_a_duplicate_violation() {
853        let mut store = Store::open_in_memory().expect("store");
854        seed_graph(&store);
855        let one = parse_adr(
856            "docs/adr/0001-a.md",
857            "---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n# A\n\n## Decision\n\nbody\n",
858        )
859        .expect("parse one");
860        let two = parse_adr(
861            "docs/adr/0002-b.md",
862            "---\nadr-id: \"0002\"\nstatus: Accepted\n---\n\n# B\n\n## Decision\n\nbody\n",
863        )
864        .expect("parse two");
865
866        let report = run(&mut store, &[one, two], &[], &[]).expect("run");
867        assert!(!report.has_violations(), "{:?}", report.violations);
868    }
869
870    #[test]
871    fn three_files_on_one_id_report_once_and_name_all_three() {
872        let mut store = Store::open_in_memory().expect("store");
873        seed_graph(&store);
874        let docs: Vec<_> = ["c.md", "a.md", "b.md"]
875            .iter()
876            .map(|name| {
877                parse_adr(
878                    &format!("docs/adr/{name}"),
879                    "---\nadr-id: \"0007\"\nstatus: Accepted\n---\n\n# X\n\n## Decision\n\nbody\n",
880                )
881                .expect("parse")
882            })
883            .collect();
884
885        let report = run(&mut store, &docs, &[], &[]).expect("run");
886        assert_eq!(report.violations.len(), 1, "one finding, not one per file");
887        let msg = &report.violations[0].message;
888        // Paths are sorted, so the message does not depend on tree-walk order.
889        assert!(
890            msg.contains("docs/adr/a.md, docs/adr/b.md, docs/adr/c.md"),
891            "names all three in a stable order: {msg}"
892        );
893    }
894
895    #[test]
896    fn annotation_to_unknown_and_superseded_adrs() {
897        let mut store = Store::open_in_memory().expect("store");
898        seed_graph(&store);
899        let superseded =
900            "---\nadr-id: \"0002\"\nstatus: Superseded\n---\n\n# Old\n\n## X\n\nbody\n";
901        let doc = parse_adr("docs/adr/0002.md", superseded).expect("parse");
902        let anns = scan_annotations("src/store.rs", "// @rto:0002\n// @rto:9999\n");
903
904        let report = run(&mut store, &[doc], &[], &anns).expect("run");
905        let kinds: Vec<_> = report.violations.iter().map(|v| v.kind).collect();
906        assert!(kinds.contains(&ViolationKind::InactiveAdr));
907        assert!(kinds.contains(&ViolationKind::UnknownAdr));
908        assert_eq!(report.annotations_ok, 0);
909    }
910
911    /// A clean ADR carrying all three version claims in agreement, used as the
912    /// base each test below injects exactly one defect into.
913    const VERSIONED: &str = "\
914---
915adr-id: \"0006\"
916status: Accepted
917version: \"1.4\"
918---
919
920# ADR-0006
921
922| Field | Value |
923|---|---|
924| **Document version** | 1.4 |
925
926## Consequences
927
928The server moved. *(Update, v1.2: it moved again.)*
929
930Taken with `axum` v1.13.0, and boxlite v0.9.7 alongside it.
931
932## Document version history
933
934| Version | Date | Notes |
935|---------|------|-------|
936| 1.0 | 2026-08-09 | Accepted. |
937| 1.1 | 2026-08-09 | Revised. |
938| 1.2 | 2026-08-15 | Consequence added. |
939| 1.4 | 2026-08-18 | HTTP/2 is a non-goal. |
940";
941
942    fn drift(adr: &str) -> Vec<String> {
943        let mut store = Store::open_in_memory().expect("store");
944        seed_graph(&store);
945        let doc = parse_adr("docs/adr/0006-local-model-serving.md", adr).expect("parse");
946        let report = run(&mut store, &[doc], &[], &[]).expect("run");
947        report
948            .violations
949            .into_iter()
950            .inspect(|v| assert_eq!(v.kind, ViolationKind::AdrVersionDrift, "{}", v.message))
951            .map(|v| v.message)
952            .collect()
953    }
954
955    #[test]
956    fn a_self_consistent_adr_reports_nothing() {
957        assert!(drift(VERSIONED).is_empty());
958    }
959
960    #[test]
961    fn frontmatter_disagreeing_with_the_summary_row_is_a_violation() {
962        // ADR-0001's defect, fixed by #406: frontmatter said 1.2 over a summary
963        // row still reading 1.0, so the answer depended on which one you read.
964        let msg = &drift(&VERSIONED.replace("version: \"1.4\"", "version: \"1.2\""))[0];
965        assert!(msg.contains("0006-local-model-serving.md"), "{msg}");
966        assert!(msg.contains("frontmatter says version 1.2"), "{msg}");
967        assert!(msg.contains("row says 1.4"), "{msg}");
968    }
969
970    #[test]
971    fn history_rows_out_of_order_are_a_violation() {
972        // ADR-0006's defect, fixed by #413: the table listed 1.3 above 1.2.
973        let swapped = VERSIONED.replace(
974            "| 1.1 | 2026-08-09 | Revised. |",
975            "| 1.3 | 2026-08-09 | Revised. |",
976        );
977        let msg = &drift(&swapped)[0];
978        assert!(msg.contains("lists 1.2 after 1.3"), "{msg}");
979        assert!(msg.contains("out of order"), "{msg}");
980    }
981
982    #[test]
983    fn a_version_listed_twice_is_a_violation() {
984        // ADR-0017 carried two different rows both labelled 1.2. Sorting cannot
985        // fix that, so it is reported as its own thing rather than as disorder.
986        let dup = VERSIONED.replace(
987            "| 1.1 | 2026-08-09 | Revised. |",
988            "| 1.0 | 2026-08-09 | Revised. |",
989        );
990        let msg = &drift(&dup)[0];
991        assert!(msg.contains("lists 1.0 after 1.0"), "{msg}");
992        assert!(msg.contains("twice"), "{msg}");
993    }
994
995    #[test]
996    fn an_inline_note_citing_an_unrecorded_version_is_a_violation() {
997        // ADR-0006's third defect, and the nastiest: a note citing (Update,
998        // v1.5) for a change that landed while the document was at 1.1 and was
999        // never given a history row at all. ADR-0002 carried the same note.
1000        let msg = &drift(&VERSIONED.replace("(Update, v1.2:", "(Update, v1.5:"))[0];
1001        assert!(msg.contains("0006-local-model-serving.md:15"), "{msg}");
1002        assert!(msg.contains("(Update, v1.5)"), "{msg}");
1003        assert!(msg.contains("never had"), "{msg}");
1004        assert!(msg.contains("1.0, 1.1, 1.2, 1.4"), "{msg}");
1005    }
1006
1007    #[test]
1008    fn a_date_that_is_not_exactly_iso_8601_is_not_a_date() {
1009        use crate::adr::DocDate;
1010
1011        // Exactly four-two-two, and nothing else.
1012        assert_eq!(
1013            DocDate::parse("2026-08-18"),
1014            Some(DocDate {
1015                year: 2026,
1016                month: 8,
1017                day: 18
1018            })
1019        );
1020        // A table cell arrives padded, so surrounding whitespace is trimmed
1021        // before the width is counted — that is not leniency about the format.
1022        assert!(DocDate::parse("  2026-08-18  ").is_some());
1023
1024        for bad in [
1025            "2026-8-18",   // month not padded
1026            "2026-08-1",   // day not padded
1027            "26-08-18",    // two-digit year
1028            "20260-08-18", // five-digit year
1029            "2026-08-188", // three-digit day
1030            "2026/08/18",  // wrong separator
1031            "2026-08",     // no day
1032            "TBD",
1033            "",
1034        ] {
1035            assert_eq!(
1036                DocDate::parse(bad),
1037                None,
1038                "`{bad}` must not parse as a date"
1039            );
1040        }
1041
1042        // Display round-trips exactly what a conforming file contains — which
1043        // is why the width is fixed. A lenient parser would accept `2026-8-1`
1044        // and then quote it back as `2026-08-01`, so a violation message would
1045        // name a date the document does not contain.
1046        assert_eq!(
1047            DocDate::parse("2026-08-18").unwrap().to_string(),
1048            "2026-08-18"
1049        );
1050    }
1051
1052    #[test]
1053    fn frontmatter_lagging_the_history_is_a_violation() {
1054        // ADR-0009 (1.10 over a history reaching 1.11) and ADR-0014 (1.4 over
1055        // 1.5). The summary row is moved down **with** the frontmatter, because
1056        // that is how the defect really occurs — both are forgotten in one edit,
1057        // so they agree with each other and rule 1 stays silent. If rule 1 could
1058        // catch this, rule 4 would not be worth having.
1059        let lagged = VERSIONED
1060            .replace("version: \"1.4\"", "version: \"1.2\"")
1061            .replace(
1062                "| **Document version** | 1.4 |",
1063                "| **Document version** | 1.2 |",
1064            );
1065        let msgs = drift(&lagged);
1066        assert_eq!(msgs.len(), 1, "rule 1 must stay silent here: {msgs:?}");
1067        assert!(
1068            msgs[0].contains("frontmatter says version 1.2"),
1069            "{}",
1070            msgs[0]
1071        );
1072        assert!(msgs[0].contains("history reaches 1.4"), "{}", msgs[0]);
1073    }
1074
1075    #[test]
1076    fn rule_four_compares_against_the_highest_row_not_the_last_one() {
1077        // On a document rule 2 has already failed, "the last row" is not the
1078        // version the document has reached — so comparing against it would
1079        // report a second, false contradiction on top of the real one.
1080        let out_of_order = VERSIONED.replace(
1081            "| 1.4 | 2026-08-18 | HTTP/2 is a non-goal. |",
1082            "| 1.4 | 2026-08-18 | HTTP/2 is a non-goal. |\n| 1.3 | 2026-08-19 | Later, lower. |",
1083        );
1084        let msgs = drift(&out_of_order);
1085        assert!(
1086            msgs.iter().any(|m| m.contains("out of order")),
1087            "rule 2 still fires: {msgs:?}"
1088        );
1089        assert!(
1090            !msgs.iter().any(|m| m.contains("history reaches")),
1091            "frontmatter 1.4 *is* the highest row, so rule 4 must not fire: {msgs:?}"
1092        );
1093    }
1094
1095    #[test]
1096    fn last_modified_older_than_the_newest_history_row_is_a_violation() {
1097        let stale = VERSIONED.replace(
1098            "version: \"1.4\"",
1099            "version: \"1.4\"\nlast-modified: 2026-08-15",
1100        );
1101        let msgs = drift(&stale);
1102        assert_eq!(msgs.len(), 1, "{msgs:?}");
1103        assert!(
1104            msgs[0].contains("last-modified is 2026-08-15"),
1105            "{}",
1106            msgs[0]
1107        );
1108        assert!(msgs[0].contains("change on 2026-08-18"), "{}", msgs[0]);
1109    }
1110
1111    #[test]
1112    fn last_modified_ahead_of_the_history_is_not_a_violation() {
1113        // The direction that makes this rule survivable. A typo fix or a link
1114        // repair moves `last-modified` and earns no history row; an equality
1115        // rule would fire on every one of them and be switched off within a
1116        // week. Only the impossible direction is a finding.
1117        let ahead = VERSIONED.replace(
1118            "version: \"1.4\"",
1119            "version: \"1.4\"\nlast-modified: 2026-09-30",
1120        );
1121        assert!(drift(&ahead).is_empty(), "{:?}", drift(&ahead));
1122    }
1123
1124    #[test]
1125    fn a_history_row_without_a_parseable_date_is_skipped_not_guessed() {
1126        // The Date column is prose in practice — `TBD`, a range, an empty cell.
1127        // Such a row still counts for the ordering rules (it has a version), but
1128        // it makes no date claim, so rule 5 has nothing to contradict. Guessing
1129        // one would invent the finding.
1130        let tbd = VERSIONED
1131            .replace(
1132                "| 1.4 | 2026-08-18 | HTTP/2 is a non-goal. |",
1133                "| 1.4 | TBD | HTTP/2 is a non-goal. |",
1134            )
1135            .replace(
1136                "version: \"1.4\"",
1137                "version: \"1.4\"\nlast-modified: 2026-08-16",
1138            );
1139        // 2026-08-16 is older than the *undated* 1.4 row but newer than 1.2's
1140        // 2026-08-15, which is the newest row that actually states a date.
1141        assert!(drift(&tbd).is_empty(), "{:?}", drift(&tbd));
1142    }
1143
1144    #[test]
1145    fn an_adr_with_no_last_modified_field_reports_nothing_for_rule_five() {
1146        // Rule 5 compares two claims. A document that makes only one of them
1147        // cannot contradict itself, and requiring the field is the rule this
1148        // deliberately is not.
1149        assert!(!VERSIONED.contains("last-modified"));
1150        assert!(drift(VERSIONED).is_empty());
1151    }
1152
1153    #[test]
1154    fn software_versions_in_prose_are_not_document_versions() {
1155        // `v1.13.0` is a crate release and `v0.9.7` is boxlite's; a scan for a
1156        // bare `vX.Y` reads both as document versions this ADR has never had.
1157        // Over the 20 ADRs in this repository that scan matches 40+ times and
1158        // the `(Update, v` marker matches 4 — this is the whole precision gap.
1159        assert!(drift(VERSIONED).is_empty());
1160        let extra = VERSIONED.replace(
1161            "Taken with",
1162            "Released in v1.11.0 and v1.12.0, superseding v0.9. Taken with",
1163        );
1164        assert!(drift(&extra).is_empty(), "{:?}", drift(&extra));
1165    }
1166
1167    #[test]
1168    fn a_history_row_quoting_a_bad_note_is_not_itself_one() {
1169        // The false positive this rule had to be built around. #413 recorded
1170        // its own fix by *quoting* the note it removed, so ADR-0006's history
1171        // contains the literal `(Update, v1.5)` — inside the history section,
1172        // which the scan therefore excludes.
1173        let quoting = VERSIONED.replace(
1174            "| 1.4 | 2026-08-18 | HTTP/2 is a non-goal. |",
1175            "| 1.4 | 2026-08-18 | An inline note cited *(Update, v1.5)*, now removed. |",
1176        );
1177        assert!(drift(&quoting).is_empty(), "{:?}", drift(&quoting));
1178    }
1179
1180    #[test]
1181    fn ten_is_a_later_revision_than_nine() {
1182        // ADR-0009 reached 1.11 one row at a time. Lexical or decimal ordering
1183        // sorts 1.10 below 1.9 and reports the whole table as out of order.
1184        let long = VERSIONED.replace(
1185            "| 1.4 | 2026-08-18 | HTTP/2 is a non-goal. |",
1186            "| 1.9 | 2026-08-12 | Step 8b. |\n| 1.10 | 2026-08-12 | Step 8c. |\n| 1.11 | 2026-08-13 | Config keys. |",
1187        );
1188        let long = long.replace("version: \"1.4\"", "version: \"1.11\"");
1189        let long = long.replace(
1190            "| **Document version** | 1.4 |",
1191            "| **Document version** | 1.11 |",
1192        );
1193        assert!(drift(&long).is_empty(), "{:?}", drift(&long));
1194    }
1195
1196    #[test]
1197    fn an_adr_with_no_history_table_is_not_a_violation() {
1198        // ADR-0011 has none. An absent table contradicts nothing.
1199        let none = VERSIONED
1200            .split("## Document version history")
1201            .next()
1202            .expect("body")
1203            .to_owned();
1204        assert!(drift(&none).is_empty(), "{:?}", drift(&none));
1205    }
1206}