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("ing).is_empty(), "{:?}", drift("ing));
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}