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/// Three contradictions, reported under one kind because a caller does the same
209/// thing with all three — fail the gate and print the message — and because the
210/// 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///
214/// Each message names the file and **both** conflicting values, for the reason
215/// [`duplicate_adr_ids`] names both paths: a message that reports only what it
216/// found leaves the reader to hunt for what it was compared against.
217///
218/// Deliberately *not* checked, because widening a rule that returns one hit or
219/// none is how it becomes a rule nobody reads:
220/// - that a `version:`, a summary row or a history table exists at all — the
221///   contradiction is the finding, and ADR-0011 legitimately has no history;
222/// - that the frontmatter version equals the highest history row. Real: found
223///   twice while this rule was written (ADR-0009 had reached 1.11 and ADR-0014
224///   1.5, neither bumped);
225/// - that `last-modified` is no older than the newest history date. Also real,
226///   and worse — eight of the twenty lagged, and three of those were introduced
227///   by people actively repairing this same family, ADR-0006's by #413 itself.
228///   One-directional: running *ahead* is legitimate, because a typo fix or a
229///   link repair need not earn a history row.
230///
231/// Those are a fourth and a fifth rule rather than widenings of these three,
232/// and both land under this one kind — the variant was shaped so the message,
233/// not the label, says which check fired. Neither therefore reopens the semver
234/// question this enum's exhaustiveness raises. The fourth is free: `meta.version`
235/// and [`crate::adr::VersionFacts::history`] already hold everything it compares.
236/// The fifth is not quite free — nothing yet parses `last-modified`, and history
237/// rows are kept without their dates, so it wants a field on
238/// [`crate::adr::AdrMeta`] and dates alongside the history versions. That is the
239/// same already-taken API decision, not a new one.
240fn adr_version_drift(docs: &[AdrDoc]) -> Vec<Violation> {
241    let mut out = Vec::new();
242    for doc in docs {
243        let path = &doc.path;
244        let facts = &doc.versions;
245
246        // 1. The two places a *current* version is written must agree. This is
247        //    the pair ADR-0001 got wrong; a reader trusting frontmatter and a
248        //    reader trusting the rendered table came away with different answers.
249        if let (Some(front), Some(row)) = (doc.meta.version, facts.summary_row)
250            && front != row
251        {
252            out.push(Violation {
253                kind: ViolationKind::AdrVersionDrift,
254                message: format!(
255                    "{path}: frontmatter says version {front} but the summary \
256                     table's **Document version** row says {row}"
257                ),
258            });
259        }
260
261        // 2. The history is a sequence, so it has to read as one. Compared
262        //    component-wise: 1.10 follows 1.9, and any ordering that puts it
263        //    first would report this repository's longest-running ADR as broken.
264        for pair in facts.history.windows(2) {
265            let (prev, next) = (pair[0], pair[1]);
266            if next > prev {
267                continue;
268            }
269            let why = if next == prev {
270                "twice"
271            } else {
272                "out of order"
273            };
274            out.push(Violation {
275                kind: ViolationKind::AdrVersionDrift,
276                message: format!(
277                    "{path}: version history lists {next} after {prev} — {why}; the \
278                     rows must ascend so the document reads as its own changelog"
279                ),
280            });
281        }
282
283        // 3. A note citing a version the history never recorded describes a
284        //    change the document cannot account for. Skipped when there is no
285        //    history table: an absent table contradicts nothing, and requiring
286        //    one is the fourth rule this deliberately is not.
287        if facts.history.is_empty() {
288            continue;
289        }
290        for reference in &facts.inline_refs {
291            if facts.history.contains(&reference.version) {
292                continue;
293            }
294            let known = facts
295                .history
296                .iter()
297                .map(ToString::to_string)
298                .collect::<Vec<_>>()
299                .join(", ");
300            out.push(Violation {
301                kind: ViolationKind::AdrVersionDrift,
302                message: format!(
303                    "{path}:{}: an inline note cites (Update, v{}), a version this \
304                     document has never had — its history records {known}",
305                    reference.line, reference.version
306                ),
307            });
308        }
309    }
310    out
311}
312
313/// The outcome of a read-only [`validate`]: the report, plus the `authored`
314/// edges the valid links and annotations *would* weave into the graph.
315///
316/// Splitting the edges out of the report is what lets one violation definition
317/// serve both a gate that writes ([`run`]) and a tool surface that must not
318/// ([`crate::tool_check`]). Nothing decides what counts as drift twice.
319#[derive(Debug, Clone, Default)]
320pub struct Validation {
321    /// What was checked and what drifted.
322    pub report: CheckReport,
323    /// The `authored` `references` edges the resolved links and annotations
324    /// imply. [`run`] inserts these; a read-only caller discards them.
325    pub edges: Vec<Edge>,
326}
327
328/// The nodes the authored layer *would* contribute, and each authored ADR's
329/// parsed `status`.
330///
331/// [`run`] applies these to the store before validating, so its `get_node`
332/// lookups see them. [`validate`] must reach the same verdict without writing,
333/// so it consults this overlay first and the store second — the keys come from
334/// the very same [`AdrDoc::facts`]/[`BlueprintDoc::facts`]/[`SitePage::facts`]
335/// sets `run` applies, so the two cannot disagree about what the authored layer
336/// contributes.
337///
338/// Later docs overwrite earlier ones, matching `apply_factset`'s
339/// last-writer-wins — which is exactly the lossiness [`duplicate_adr_ids`]
340/// reports separately.
341#[derive(Debug, Default)]
342struct AuthoredOverlay {
343    /// Every node key the authored layer contributes (ADRs, ADR sections,
344    /// blueprints, site pages, and all of their sections).
345    keys: BTreeSet<String>,
346    /// Parsed status per ADR node key.
347    adr_status: BTreeMap<String, AdrStatus>,
348}
349
350fn authored_overlay(
351    docs: &[AdrDoc],
352    blueprints: &[BlueprintDoc],
353    site: &[SitePage],
354) -> AuthoredOverlay {
355    let mut overlay = AuthoredOverlay::default();
356    for doc in docs {
357        overlay
358            .keys
359            .extend(doc.facts().nodes.into_iter().map(|n| n.key));
360        overlay.adr_status.insert(doc.key(), doc.meta.status);
361    }
362    for bp in blueprints {
363        overlay
364            .keys
365            .extend(bp.facts().nodes.into_iter().map(|n| n.key));
366    }
367    for page in site {
368        overlay
369            .keys
370            .extend(page.facts().nodes.into_iter().map(|n| n.key));
371    }
372    overlay
373}
374
375/// Validate the authored layer against the derived graph **without writing
376/// anything**, returning the report and the edges a writing caller should weave.
377///
378/// This is the whole of the drift rule. [`run`] is this function plus the two
379/// writes it deliberately leaves out (applying the ADR/blueprint structure, and
380/// inserting the returned edges), so the CLI gate and the read-only tool surfaces
381/// cannot drift apart in what they call a violation.
382///
383/// # Errors
384/// Returns [`StoreError`] if querying the store fails.
385pub fn validate(
386    store: &Store,
387    docs: &[AdrDoc],
388    blueprints: &[BlueprintDoc],
389    annotations: &[Annotation],
390) -> Result<Validation, StoreError> {
391    validate_all(store, docs, blueprints, &[], annotations)
392}
393
394/// [`validate`] over a whole [`AuthoredDocs`] — the same verdict, plus the
395/// **site pages** the three-slice form has no parameter for.
396///
397/// Two entry points rather than one, because the classification that produces
398/// site pages ([`crate::authored_layer_from`]) and the CLI gate that consumes
399/// them land in separate changes: a caller still passing three slices keeps
400/// compiling and keeps getting exactly today's verdict, and moves to this
401/// function when it is ready to check the website too. The shared body below is
402/// the only copy of the rule, so the two cannot drift into disagreeing about
403/// what a violation is.
404///
405/// # Errors
406/// Returns [`StoreError`] if querying the store fails.
407pub fn validate_layer(store: &Store, docs: &AuthoredDocs) -> Result<Validation, StoreError> {
408    validate_all(
409        store,
410        &docs.layer.docs,
411        &docs.layer.blueprints,
412        &docs.site,
413        &docs.layer.annotations,
414    )
415}
416
417/// The whole drift rule, over every authored document class. [`validate`] and
418/// [`validate_layer`] are this function with and without site pages.
419fn validate_all(
420    store: &Store,
421    docs: &[AdrDoc],
422    blueprints: &[BlueprintDoc],
423    site: &[SitePage],
424    annotations: &[Annotation],
425) -> Result<Validation, StoreError> {
426    // 1. Detect colliding ADR ids *before* anything is applied, so the report
427    //    describes the authored file set rather than what survived the merge.
428    let mut report = CheckReport {
429        adrs: docs.len(),
430        blueprints: blueprints.len(),
431        site_pages: site.len(),
432        violations: duplicate_adr_ids(docs),
433        ..CheckReport::default()
434    };
435    // The same collision in the one other place an author picks a key.
436    report.violations.extend(duplicate_site_slugs(site));
437    // Self-contradiction inside one ADR, checked alongside the collision
438    // *between* ADRs above: neither needs the graph, both are read off the
439    // authored files exactly as they were parsed.
440    report.violations.extend(adr_version_drift(docs));
441    let overlay = authored_overlay(docs, blueprints, site);
442    let mut edges = Vec::new();
443
444    // 2. Validate ADR, blueprint and site-page `[[…]]` links against the code
445    //    graph. All three author `references` edges into real symbols/files and
446    //    drift the same way — which is the point of making the website a
447    //    document class rather than a pile of hand-written HTML: a page that
448    //    describes `security run`'s isolation posture can cite the code that
449    //    implements it, and the citation fails the gate when the code moves.
450    let links = docs
451        .iter()
452        .flat_map(|d| &d.links)
453        .chain(blueprints.iter().flat_map(|b| &b.links))
454        .chain(site.iter().flat_map(|p| &p.links));
455    for link in links {
456        // A link resolves against the derived graph, or against an ADR the
457        // authored layer is contributing in this same pass.
458        if store.get_node(&link.target_key)?.is_some() || overlay.keys.contains(&link.target_key) {
459            edges.push(Edge::authored(
460                link.from.clone(),
461                link.target_key.clone(),
462                EdgeKind::References,
463            ));
464            report.links_ok += 1;
465        } else {
466            report.violations.push(Violation {
467                kind: ViolationKind::BrokenLink,
468                message: format!(
469                    "{}: authored link [[{}]] does not resolve ({} not found in graph)",
470                    link.from, link.raw, link.target_key
471                ),
472            });
473        }
474    }
475
476    // 3. Validate `@rto:` annotations against ADR state. The overlay is consulted
477    //    first: an ADR authored in this pass is the one the annotation means, and
478    //    its parsed status is what `run` would have written to the node.
479    for ann in annotations {
480        let key = ann.target_key();
481        let status = match overlay.adr_status.get(&key) {
482            Some(status) => Some(*status),
483            None => match store.get_node(&key)? {
484                Some(adr) => Some(
485                    adr.meta
486                        .get("status")
487                        .and_then(|s| s.as_str())
488                        .and_then(|s| s.parse::<AdrStatus>().ok())
489                        // A node with an unparseable status still *exists*, so it
490                        // is not `unknown-adr`; treat it as active, exactly as the
491                        // pre-split code did by leaving `status` at `None`.
492                        .unwrap_or(AdrStatus::Accepted),
493                ),
494                None => None,
495            },
496        };
497        let Some(status) = status else {
498            report.violations.push(Violation {
499                kind: ViolationKind::UnknownAdr,
500                message: format!(
501                    "{}:{}: @rto:{} references unknown ADR",
502                    ann.path, ann.line, ann.adr_id
503                ),
504            });
505            continue;
506        };
507        if !status.is_active() {
508            report.violations.push(Violation {
509                kind: ViolationKind::InactiveAdr,
510                message: format!(
511                    "{}:{}: @rto:{} references non-active ADR ({})",
512                    ann.path,
513                    ann.line,
514                    ann.adr_id,
515                    status.as_str()
516                ),
517            });
518            continue;
519        }
520        // Link the annotated file to the ADR when the file is in the graph.
521        let file_key = format!("file:{}", ann.path);
522        if store.get_node(&file_key)?.is_some() {
523            edges.push(Edge::authored(file_key, key, EdgeKind::References));
524        }
525        report.annotations_ok += 1;
526    }
527
528    Ok(Validation { report, edges })
529}
530
531/// Apply the authored layer to `store` and validate it against the derived
532/// graph, returning a [`CheckReport`].
533///
534/// The verdict itself comes from [`validate`]; this function is the writing half
535/// around it — materialising ADR/blueprint structure so links can reference it,
536/// and weaving the resolved links in as `authored` edges.
537///
538/// # Errors
539/// Returns [`StoreError`] if applying ADR facts or edges, or querying the
540/// store, fails.
541pub fn run(
542    store: &mut Store,
543    docs: &[AdrDoc],
544    blueprints: &[BlueprintDoc],
545    annotations: &[Annotation],
546) -> Result<CheckReport, StoreError> {
547    run_all(store, docs, blueprints, &[], annotations)
548}
549
550/// [`run`] over a whole [`AuthoredDocs`], including its **site pages**. See
551/// [`validate_layer`] for why both entry points exist.
552///
553/// # Errors
554/// Returns [`StoreError`] if applying authored facts or edges, or querying the
555/// store, fails.
556pub fn run_layer(store: &mut Store, docs: &AuthoredDocs) -> Result<CheckReport, StoreError> {
557    run_all(
558        store,
559        &docs.layer.docs,
560        &docs.layer.blueprints,
561        &docs.site,
562        &docs.layer.annotations,
563    )
564}
565
566/// The writing half, over every authored document class.
567fn run_all(
568    store: &mut Store,
569    docs: &[AdrDoc],
570    blueprints: &[BlueprintDoc],
571    site: &[SitePage],
572    annotations: &[Annotation],
573) -> Result<CheckReport, StoreError> {
574    // Materialise ADR/blueprint/site-page section nodes so links and annotations
575    // can reference them (and so `@rto:` targets can be looked up by key).
576    for doc in docs {
577        store.apply_factset(&doc.facts())?;
578    }
579    for bp in blueprints {
580        store.apply_factset(&bp.facts())?;
581    }
582    for page in site {
583        store.apply_factset(&page.facts())?;
584    }
585
586    let validation = validate_all(store, docs, blueprints, site, annotations)?;
587    for edge in &validation.edges {
588        store.insert_edge(edge)?;
589    }
590    Ok(validation.report)
591}
592
593#[cfg(test)]
594mod tests {
595    use super::{ViolationKind, run, run_layer};
596    use crate::adr::parse_adr;
597    use crate::annotate::scan_annotations;
598    use crate::layer::{AuthoredDocs, AuthoredLayer};
599    use crate::site::parse_site_page;
600    use rto_graph::{Node, NodeKind, Store};
601
602    /// An [`AuthoredDocs`] holding only site pages — the rest of the authored
603    /// layer is exercised by the tests above.
604    fn site_layer(pages: Vec<crate::site::SitePage>) -> AuthoredDocs {
605        AuthoredDocs {
606            site: pages,
607            ..AuthoredDocs::default()
608        }
609    }
610
611    fn seed_graph(store: &Store) {
612        // A tiny derived graph: one file and one symbol.
613        store
614            .upsert_node(&Node::new("file:src/store.rs", NodeKind::File, "store.rs"))
615            .expect("file");
616        store
617            .upsert_node(&Node::new(
618                "sym:rust:src/store.rs#Store",
619                NodeKind::Struct,
620                "Store",
621            ))
622            .expect("sym");
623    }
624
625    #[test]
626    fn resolvable_links_and_annotations_pass() {
627        let mut store = Store::open_in_memory().expect("store");
628        seed_graph(&store);
629
630        let adr = "---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n# ADR-0001\n\n## Design\n\nUses [[src/store.rs#Store]].\n";
631        let doc = parse_adr("docs/adr/0001.md", adr).expect("parse");
632        let anns = scan_annotations("src/store.rs", "//! @rto:0001\n");
633
634        let report = run(&mut store, &[doc], &[], &anns).expect("run");
635        assert!(!report.has_violations(), "{:?}", report.violations);
636        assert_eq!(report.links_ok, 1);
637        assert_eq!(report.annotations_ok, 1);
638        // The authored edge is now in the graph.
639        let edges = store.edges_from("adr:0001#design").expect("edges");
640        assert!(edges.iter().any(|e| e.dst == "sym:rust:src/store.rs#Store"));
641    }
642
643    #[test]
644    fn a_site_page_s_links_are_drift_checked_like_an_adr_s() {
645        // The whole point of the document class: the public website's claims are
646        // held against the graph, so a page that cites the code it describes
647        // fails the gate when that code moves.
648        let mut store = Store::open_in_memory().expect("store");
649        seed_graph(&store);
650        let ok = parse_site_page(
651            "docs/site/modes.md",
652            "---\nsite-page: modes\n---\n\n# Modes\n\n## Offline\n\nSee [[src/store.rs#Store]].\n",
653        )
654        .expect("parse");
655        let report = run_layer(&mut store, &site_layer(vec![ok])).expect("run");
656        assert!(!report.has_violations(), "{:?}", report.violations);
657        assert_eq!(report.site_pages, 1);
658        assert_eq!(report.links_ok, 1);
659        // The authored edge is in the graph, attributed to the page's section.
660        let edges = store.edges_from("site:modes#offline").expect("edges");
661        assert!(edges.iter().any(|e| e.dst == "sym:rust:src/store.rs#Store"));
662
663        // The failing half — this is what would have caught the stale
664        // `--allow-unsandboxed` claim the hand-written page carried.
665        let mut store = Store::open_in_memory().expect("store");
666        seed_graph(&store);
667        let stale = parse_site_page(
668            "docs/site/modes.md",
669            "---\nsite-page: modes\n---\n\n# Modes\n\nSee [[src/store.rs#Ghost]].\n",
670        )
671        .expect("parse");
672        let report = run_layer(&mut store, &site_layer(vec![stale])).expect("run");
673        assert_eq!(report.violations.len(), 1);
674        assert_eq!(report.violations[0].kind, ViolationKind::BrokenLink);
675    }
676
677    #[test]
678    fn two_pages_sharing_a_slug_are_a_violation_naming_both_files() {
679        // `duplicate_adr_ids` with a public URL attached: one node key and one
680        // published filename, so the later document silently replaces the first.
681        let mut store = Store::open_in_memory().expect("store");
682        seed_graph(&store);
683        let one = parse_site_page(
684            "docs/site/config.md",
685            "---\nsite-page: config\n---\n\n# Configuration\n",
686        )
687        .expect("one");
688        let two = parse_site_page(
689            "docs/OFFLINE_SETUP.md",
690            "---\nsite-page: config\n---\n\n# Offline setup\n",
691        )
692        .expect("two");
693        let report = run_layer(&mut store, &site_layer(vec![one, two])).expect("run");
694        let dupes: Vec<_> = report
695            .violations
696            .iter()
697            .filter(|v| v.kind == ViolationKind::DuplicateSiteSlug)
698            .collect();
699        assert_eq!(dupes.len(), 1, "one finding for the one colliding slug");
700        let msg = &dupes[0].message;
701        assert!(msg.contains("config"), "names the slug: {msg}");
702        assert!(
703            msg.contains("docs/site/config.md"),
704            "names the first: {msg}"
705        );
706        assert!(
707            msg.contains("docs/OFFLINE_SETUP.md"),
708            "names the second: {msg}"
709        );
710    }
711
712    #[test]
713    fn the_three_slice_entry_point_still_reaches_the_same_verdict_today() {
714        // `run` is `run_layer` with no site pages. A caller that has not moved
715        // over must see exactly the report it sees today — that is the whole
716        // reason both entry points exist.
717        let mut a = Store::open_in_memory().expect("store");
718        seed_graph(&a);
719        let mut b = Store::open_in_memory().expect("store");
720        seed_graph(&b);
721        let adr = "---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n# ADR-0001\n\n## Design\n\nUses [[src/store.rs#Store]].\n";
722        let doc = parse_adr("docs/adr/0001.md", adr).expect("parse");
723
724        let old = run(&mut a, std::slice::from_ref(&doc), &[], &[]).expect("run");
725        let new = run_layer(
726            &mut b,
727            &AuthoredDocs {
728                layer: AuthoredLayer {
729                    docs: vec![doc],
730                    ..AuthoredLayer::default()
731                },
732                ..AuthoredDocs::default()
733            },
734        )
735        .expect("run_layer");
736        assert_eq!(old.adrs, new.adrs);
737        assert_eq!(old.links_ok, new.links_ok);
738        assert_eq!(old.violations.len(), new.violations.len());
739        assert_eq!(old.site_pages, 0, "no site pages via the three-slice form");
740        assert_eq!(new.site_pages, 0);
741    }
742
743    #[test]
744    fn broken_link_is_a_violation() {
745        let mut store = Store::open_in_memory().expect("store");
746        seed_graph(&store);
747        let adr =
748            "---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n## Design\n\n[[src/store.rs#Ghost]]\n";
749        let doc = parse_adr("docs/adr/0001.md", adr).expect("parse");
750
751        let report = run(&mut store, &[doc], &[], &[]).expect("run");
752        assert_eq!(report.violations.len(), 1);
753        assert_eq!(report.violations[0].kind, ViolationKind::BrokenLink);
754    }
755
756    #[test]
757    fn two_adrs_sharing_an_id_are_a_violation_naming_both_files() {
758        // The regression from issue #324: two branches each author ADR-0016.
759        // Both files merge cleanly, both parse, and both apply to the *same*
760        // node key — so without this check the report is 0 violations.
761        let mut store = Store::open_in_memory().expect("store");
762        seed_graph(&store);
763        let one = parse_adr(
764            "docs/adr/0016-audio-metadata.md",
765            "---\nadr-id: \"0016\"\nstatus: Accepted\n---\n\n# Audio metadata\n\n## Decision\n\nbody\n",
766        )
767        .expect("parse one");
768        let two = parse_adr(
769            "docs/adr/0016-speculative-decoding.md",
770            "---\nadr-id: \"0016\"\nstatus: Accepted\n---\n\n# Speculative decoding\n\n## Decision\n\nbody\n",
771        )
772        .expect("parse two");
773
774        let report = run(&mut store, &[one, two], &[], &[]).expect("run");
775        let dupes: Vec<_> = report
776            .violations
777            .iter()
778            .filter(|v| v.kind == ViolationKind::DuplicateAdrId)
779            .collect();
780        assert_eq!(dupes.len(), 1, "one finding for the one colliding id");
781        // Both paths and the id must be named — an id alone makes the reader hunt.
782        let msg = &dupes[0].message;
783        assert!(msg.contains("0016"), "names the shared id: {msg}");
784        assert!(
785            msg.contains("docs/adr/0016-audio-metadata.md"),
786            "names the first file: {msg}"
787        );
788        assert!(
789            msg.contains("docs/adr/0016-speculative-decoding.md"),
790            "names the second file: {msg}"
791        );
792        assert!(report.has_violations(), "the gate must fail");
793    }
794
795    #[test]
796    fn distinct_adr_ids_are_not_a_duplicate_violation() {
797        let mut store = Store::open_in_memory().expect("store");
798        seed_graph(&store);
799        let one = parse_adr(
800            "docs/adr/0001-a.md",
801            "---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n# A\n\n## Decision\n\nbody\n",
802        )
803        .expect("parse one");
804        let two = parse_adr(
805            "docs/adr/0002-b.md",
806            "---\nadr-id: \"0002\"\nstatus: Accepted\n---\n\n# B\n\n## Decision\n\nbody\n",
807        )
808        .expect("parse two");
809
810        let report = run(&mut store, &[one, two], &[], &[]).expect("run");
811        assert!(!report.has_violations(), "{:?}", report.violations);
812    }
813
814    #[test]
815    fn three_files_on_one_id_report_once_and_name_all_three() {
816        let mut store = Store::open_in_memory().expect("store");
817        seed_graph(&store);
818        let docs: Vec<_> = ["c.md", "a.md", "b.md"]
819            .iter()
820            .map(|name| {
821                parse_adr(
822                    &format!("docs/adr/{name}"),
823                    "---\nadr-id: \"0007\"\nstatus: Accepted\n---\n\n# X\n\n## Decision\n\nbody\n",
824                )
825                .expect("parse")
826            })
827            .collect();
828
829        let report = run(&mut store, &docs, &[], &[]).expect("run");
830        assert_eq!(report.violations.len(), 1, "one finding, not one per file");
831        let msg = &report.violations[0].message;
832        // Paths are sorted, so the message does not depend on tree-walk order.
833        assert!(
834            msg.contains("docs/adr/a.md, docs/adr/b.md, docs/adr/c.md"),
835            "names all three in a stable order: {msg}"
836        );
837    }
838
839    #[test]
840    fn annotation_to_unknown_and_superseded_adrs() {
841        let mut store = Store::open_in_memory().expect("store");
842        seed_graph(&store);
843        let superseded =
844            "---\nadr-id: \"0002\"\nstatus: Superseded\n---\n\n# Old\n\n## X\n\nbody\n";
845        let doc = parse_adr("docs/adr/0002.md", superseded).expect("parse");
846        let anns = scan_annotations("src/store.rs", "// @rto:0002\n// @rto:9999\n");
847
848        let report = run(&mut store, &[doc], &[], &anns).expect("run");
849        let kinds: Vec<_> = report.violations.iter().map(|v| v.kind).collect();
850        assert!(kinds.contains(&ViolationKind::InactiveAdr));
851        assert!(kinds.contains(&ViolationKind::UnknownAdr));
852        assert_eq!(report.annotations_ok, 0);
853    }
854
855    /// A clean ADR carrying all three version claims in agreement, used as the
856    /// base each test below injects exactly one defect into.
857    const VERSIONED: &str = "\
858---
859adr-id: \"0006\"
860status: Accepted
861version: \"1.4\"
862---
863
864# ADR-0006
865
866| Field | Value |
867|---|---|
868| **Document version** | 1.4 |
869
870## Consequences
871
872The server moved. *(Update, v1.2: it moved again.)*
873
874Taken with `axum` v1.13.0, and boxlite v0.9.7 alongside it.
875
876## Document version history
877
878| Version | Date | Notes |
879|---------|------|-------|
880| 1.0 | 2026-08-09 | Accepted. |
881| 1.1 | 2026-08-09 | Revised. |
882| 1.2 | 2026-08-15 | Consequence added. |
883| 1.4 | 2026-08-18 | HTTP/2 is a non-goal. |
884";
885
886    fn drift(adr: &str) -> Vec<String> {
887        let mut store = Store::open_in_memory().expect("store");
888        seed_graph(&store);
889        let doc = parse_adr("docs/adr/0006-local-model-serving.md", adr).expect("parse");
890        let report = run(&mut store, &[doc], &[], &[]).expect("run");
891        report
892            .violations
893            .into_iter()
894            .inspect(|v| assert_eq!(v.kind, ViolationKind::AdrVersionDrift, "{}", v.message))
895            .map(|v| v.message)
896            .collect()
897    }
898
899    #[test]
900    fn a_self_consistent_adr_reports_nothing() {
901        assert!(drift(VERSIONED).is_empty());
902    }
903
904    #[test]
905    fn frontmatter_disagreeing_with_the_summary_row_is_a_violation() {
906        // ADR-0001's defect, fixed by #406: frontmatter said 1.2 over a summary
907        // row still reading 1.0, so the answer depended on which one you read.
908        let msg = &drift(&VERSIONED.replace("version: \"1.4\"", "version: \"1.2\""))[0];
909        assert!(msg.contains("0006-local-model-serving.md"), "{msg}");
910        assert!(msg.contains("frontmatter says version 1.2"), "{msg}");
911        assert!(msg.contains("row says 1.4"), "{msg}");
912    }
913
914    #[test]
915    fn history_rows_out_of_order_are_a_violation() {
916        // ADR-0006's defect, fixed by #413: the table listed 1.3 above 1.2.
917        let swapped = VERSIONED.replace(
918            "| 1.1 | 2026-08-09 | Revised. |",
919            "| 1.3 | 2026-08-09 | Revised. |",
920        );
921        let msg = &drift(&swapped)[0];
922        assert!(msg.contains("lists 1.2 after 1.3"), "{msg}");
923        assert!(msg.contains("out of order"), "{msg}");
924    }
925
926    #[test]
927    fn a_version_listed_twice_is_a_violation() {
928        // ADR-0017 carried two different rows both labelled 1.2. Sorting cannot
929        // fix that, so it is reported as its own thing rather than as disorder.
930        let dup = VERSIONED.replace(
931            "| 1.1 | 2026-08-09 | Revised. |",
932            "| 1.0 | 2026-08-09 | Revised. |",
933        );
934        let msg = &drift(&dup)[0];
935        assert!(msg.contains("lists 1.0 after 1.0"), "{msg}");
936        assert!(msg.contains("twice"), "{msg}");
937    }
938
939    #[test]
940    fn an_inline_note_citing_an_unrecorded_version_is_a_violation() {
941        // ADR-0006's third defect, and the nastiest: a note citing (Update,
942        // v1.5) for a change that landed while the document was at 1.1 and was
943        // never given a history row at all. ADR-0002 carried the same note.
944        let msg = &drift(&VERSIONED.replace("(Update, v1.2:", "(Update, v1.5:"))[0];
945        assert!(msg.contains("0006-local-model-serving.md:15"), "{msg}");
946        assert!(msg.contains("(Update, v1.5)"), "{msg}");
947        assert!(msg.contains("never had"), "{msg}");
948        assert!(msg.contains("1.0, 1.1, 1.2, 1.4"), "{msg}");
949    }
950
951    #[test]
952    fn software_versions_in_prose_are_not_document_versions() {
953        // `v1.13.0` is a crate release and `v0.9.7` is boxlite's; a scan for a
954        // bare `vX.Y` reads both as document versions this ADR has never had.
955        // Over the 20 ADRs in this repository that scan matches 40+ times and
956        // the `(Update, v` marker matches 4 — this is the whole precision gap.
957        assert!(drift(VERSIONED).is_empty());
958        let extra = VERSIONED.replace(
959            "Taken with",
960            "Released in v1.11.0 and v1.12.0, superseding v0.9. Taken with",
961        );
962        assert!(drift(&extra).is_empty(), "{:?}", drift(&extra));
963    }
964
965    #[test]
966    fn a_history_row_quoting_a_bad_note_is_not_itself_one() {
967        // The false positive this rule had to be built around. #413 recorded
968        // its own fix by *quoting* the note it removed, so ADR-0006's history
969        // contains the literal `(Update, v1.5)` — inside the history section,
970        // which the scan therefore excludes.
971        let quoting = VERSIONED.replace(
972            "| 1.4 | 2026-08-18 | HTTP/2 is a non-goal. |",
973            "| 1.4 | 2026-08-18 | An inline note cited *(Update, v1.5)*, now removed. |",
974        );
975        assert!(drift(&quoting).is_empty(), "{:?}", drift(&quoting));
976    }
977
978    #[test]
979    fn ten_is_a_later_revision_than_nine() {
980        // ADR-0009 reached 1.11 one row at a time. Lexical or decimal ordering
981        // sorts 1.10 below 1.9 and reports the whole table as out of order.
982        let long = VERSIONED.replace(
983            "| 1.4 | 2026-08-18 | HTTP/2 is a non-goal. |",
984            "| 1.9 | 2026-08-12 | Step 8b. |\n| 1.10 | 2026-08-12 | Step 8c. |\n| 1.11 | 2026-08-13 | Config keys. |",
985        );
986        let long = long.replace("version: \"1.4\"", "version: \"1.11\"");
987        let long = long.replace(
988            "| **Document version** | 1.4 |",
989            "| **Document version** | 1.11 |",
990        );
991        assert!(drift(&long).is_empty(), "{:?}", drift(&long));
992    }
993
994    #[test]
995    fn an_adr_with_no_history_table_is_not_a_violation() {
996        // ADR-0011 has none. An absent table contradicts nothing.
997        let none = VERSIONED
998            .split("## Document version history")
999            .next()
1000            .expect("body")
1001            .to_owned();
1002        assert!(drift(&none).is_empty(), "{:?}", drift(&none));
1003    }
1004}