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