1use std::collections::{BTreeMap, BTreeSet};
10use std::path::{Path, PathBuf};
11use toml::Value as T;
12
13pub const SPEC: &str = "docs/SPEC.md";
14pub const HISTORY: &str = "docs/spec-history.md";
15pub const RATIO: &str = "docs/verification-ratio.toml";
16pub const ASSUMPTIONS: &str = "docs/assumptions.md";
17pub const BYPASSES: &str = "docs/bypasses.md";
18pub const CATALOG: &str = crate::catalog::DIR;
19const VERIFIED: &[&str] = &["policy", "schema", "txn"];
21
22pub struct Opts {
24 pub commit: String,
26 pub repo: String,
27 pub snapshot: String,
29 pub others: Vec<String>,
30}
31
32#[derive(Default)]
34pub struct Sources {
35 pub spec: String,
36 pub history: String,
37 pub ratio: String,
38 pub assumptions: String,
39 pub bypasses: String,
40 pub catalog: String,
41 pub results: Option<String>,
43 pub crates: BTreeMap<String, (String, Vec<(String, String)>)>,
47}
48
49impl Sources {
50 pub fn load(root: &Path, results: Option<&Path>) -> Result<Sources, String> {
51 let read = |p: &str| std::fs::read_to_string(root.join(p)).map_err(|e| format!("{p}: {e}"));
52 let mut crates = BTreeMap::new();
53 for e in std::fs::read_dir(root.join("crates")).map_err(|e| e.to_string())?.flatten() {
54 let dir = e.path();
55 let Ok(manifest) = std::fs::read_to_string(dir.join("Cargo.toml")) else { continue };
56 let field = |l: &str| l.trim().strip_prefix("name = ").map(|v| v.trim_matches('"').to_string());
59 let package = manifest.lines().find_map(field).unwrap_or_default();
60 let bin = manifest.split("[[bin]]").nth(1).and_then(|b| b.lines().find_map(field));
61 let name = if dir.join("src/lib.rs").exists() { package } else { bin.unwrap_or(package) };
62 let mut files = vec![];
63 rust_files(root, &dir.join("src"), &mut files);
64 files.sort();
65 crates.insert(e.file_name().to_string_lossy().to_string(), (name, files));
66 }
67 Ok(Sources {
68 spec: read(SPEC)?,
69 history: read(HISTORY)?,
70 ratio: read(RATIO)?,
71 assumptions: read(ASSUMPTIONS)?,
72 bypasses: read(BYPASSES)?,
73 catalog: crate::catalog::read_text(&root.join(CATALOG))?,
74 results: match results {
75 Some(p) => Some(std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()))?),
76 None => None,
77 },
78 crates,
79 })
80 }
81}
82
83fn rust_files(root: &Path, dir: &Path, out: &mut Vec<(String, String)>) {
84 for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
85 let p = e.path();
86 if p.is_dir() {
87 rust_files(root, &p, out);
88 } else if p.extension().is_some_and(|x| x == "rs") {
89 let rel = p.strip_prefix(root).unwrap_or(&p).to_string_lossy().to_string();
90 out.push((rel, std::fs::read_to_string(&p).unwrap_or_default()));
91 }
92 }
93}
94
95pub fn invariant_refs(text: &str) -> Vec<u32> {
99 let b = text.as_bytes();
100 let mut out = vec![];
101 let mut i = 0;
102 while i < b.len() {
103 if !text.is_char_boundary(i) {
104 i += 1;
105 continue;
106 }
107 let prev = text[..i].chars().last();
108 let starts = b[i] == b'I' && !prev.is_some_and(|p| p.is_alphanumeric() || p == '_');
109 if starts && i + 1 < b.len() && b[i + 1].is_ascii_digit() {
110 let mut j = i + 1;
111 while j < b.len() && b[j].is_ascii_digit() {
112 j += 1;
113 }
114 if text[j..].chars().next().is_some_and(|c| c.is_alphanumeric() || c == '_') {
115 i = j;
116 continue;
117 }
118 let n: u32 = text[i + 1..j].parse().unwrap_or(0);
119 out.push(n);
120 let rest = &text[j..];
122 let after = rest.strip_prefix('–').or_else(|| rest.strip_prefix('-'));
123 if let Some(after) = after
124 && let Some(num) = after.strip_prefix('I')
125 {
126 let digits: String = num.chars().take_while(char::is_ascii_digit).collect();
127 if let Ok(m) = digits.parse::<u32>()
128 && m > n
129 && m - n < 40
130 {
131 out.extend(n + 1..=m);
132 }
133 }
134 i = j;
135 } else {
136 i += 1;
137 }
138 }
139 let mut seen = BTreeSet::new();
140 out.retain(|n| seen.insert(*n));
141 out
142}
143
144fn section_refs(text: &str) -> Vec<String> {
146 let mut out = vec![];
147 for (i, _) in text.match_indices('§') {
148 let rest = &text[i + '§'.len_utf8()..];
149 let num: String = rest.chars().take_while(char::is_ascii_digit).collect();
150 if num.is_empty() {
151 continue;
152 }
153 let letter = rest[num.len()..].chars().next().filter(|c| c.is_ascii_lowercase() && *c != 's');
154 out.push(match letter {
155 Some(l) if !rest[num.len() + 1..].starts_with(|c: char| c.is_alphabetic()) => format!("{num}{l}"),
156 _ => num,
157 });
158 }
159 out
160}
161
162fn bypass_refs(text: &str) -> Vec<String> {
164 let b = text.as_bytes();
165 let mut out = vec![];
166 for i in 0..b.len() {
167 if !text.is_char_boundary(i) {
168 continue;
169 }
170 if (b[i] == b'B' || b[i] == b'X')
171 && i + 2 < b.len()
172 && b[i + 1] == b'-'
173 && b[i + 2].is_ascii_digit()
174 && !text[..i].chars().last().is_some_and(char::is_alphanumeric)
175 {
176 let digits: String = text[i + 2..].chars().take_while(char::is_ascii_digit).collect();
177 let end = i + 2 + digits.len();
178 if !text[end..].chars().next().is_some_and(char::is_alphanumeric) {
179 out.push(format!("{}-{digits}", b[i] as char));
180 }
181 }
182 }
183 out
184}
185
186fn linkify(text: &str, up: &str, sections: &BTreeSet<String>, bypasses: &BTreeSet<String>) -> String {
189 let mut out = String::new();
190 for (k, part) in text.split('`').enumerate() {
191 if k % 2 == 1 {
192 out.push('`');
193 out.push_str(part);
194 out.push('`');
195 continue;
196 }
197 let mut s = String::new();
198 let mut rest = part;
199 while !rest.is_empty() {
200 let c = rest.chars().next().unwrap_or(' ');
201 if c == '§' {
202 let r = &rest['§'.len_utf8()..];
203 let num: String = r.chars().take_while(char::is_ascii_digit).collect();
204 let letter = r[num.len()..].chars().next().filter(|c| c.is_ascii_lowercase());
205 let with = letter.map(|l| format!("{num}{l}"));
206 let slug = match &with {
207 Some(w) if sections.contains(w) && !r[num.len() + 1..].starts_with(|c: char| c.is_alphabetic()) => {
208 Some(w.clone())
209 }
210 _ if !num.is_empty() && sections.contains(&num) => Some(num.clone()),
211 _ => None,
212 };
213 if let Some(slug) = slug {
214 s.push_str(&format!("[§{slug}]({up}spec/{slug}.md)"));
215 rest = &r[slug.len()..];
216 continue;
217 }
218 }
219 let prev_alnum = s.chars().last().is_some_and(|p| p.is_alphanumeric() || p == '_');
220 if (c == 'I') && !prev_alnum {
221 let digits: String = rest[1..].chars().take_while(char::is_ascii_digit).collect();
222 let next = rest[1 + digits.len()..].chars().next();
223 if !digits.is_empty() && !next.is_some_and(|n| n.is_alphanumeric() || n == '_') {
224 s.push_str(&format!("[I{digits}]({up}invariants/I{digits}.md)"));
225 rest = &rest[1 + digits.len()..];
226 continue;
227 }
228 }
229 if (c == 'B' || c == 'X') && !prev_alnum && rest[1..].starts_with('-') {
230 let digits: String = rest[2..].chars().take_while(char::is_ascii_digit).collect();
231 let id = format!("{c}-{digits}");
232 if !digits.is_empty() && bypasses.contains(&id) {
233 s.push_str(&format!("[{id}]({up}bypasses/{id}.md)"));
234 rest = &rest[2 + digits.len()..];
235 continue;
236 }
237 }
238 s.push(c);
239 rest = &rest[c.len_utf8()..];
240 }
241 out.push_str(&s);
242 }
243 out
244}
245
246fn cells(line: &str) -> Vec<String> {
248 line.trim().trim_matches('|').split('|').map(|c| c.trim().to_string()).collect()
249}
250
251fn esc(s: &str) -> String {
252 s.replace('|', "\\|").replace('\n', " ").replace('<', "<").replace('>', ">")
253}
254
255struct Invariant {
258 id: String,
259 statement: String,
260 phase: String,
261}
262
263#[derive(Default)]
264struct Row {
265 discharged: Vec<String>,
266 assumes: Vec<String>,
267 tested: Vec<String>,
268}
269
270struct Assumption {
271 id: String,
272 text: String,
273 sites: String,
274 kind: &'static str,
275}
276
277struct Bypass {
278 id: String,
279 route: String,
280 severity: String,
281 status: String,
282 closes: String,
283 mitigation: String,
284 breaks: String,
285 limits: Vec<String>,
287 all: bool,
288}
289
290struct Entry {
291 id: String,
292 summary: String,
293 harness: Vec<String>,
294 scoring: String,
295 expects: String,
296 invariants: BTreeSet<String>,
297}
298
299struct Obligation {
300 name: String,
301 kind: &'static str,
302 doc: String,
303 file: String,
304 line: usize,
305 rustdoc: Option<String>,
307}
308
309struct Section {
310 slug: String,
311 title: String,
312 body: String,
313}
314
315fn kind_of(id: &str) -> &'static str {
316 match id.chars().next() {
317 Some('P') => "Platform",
318 Some('G') => "Platform (progress)",
319 Some('V') => "Proof-validity",
320 Some('U') => "Internal unproven",
321 _ => "?",
322 }
323}
324
325fn parse_assumptions(text: &str) -> BTreeMap<String, Assumption> {
327 let mut out = BTreeMap::new();
328 for line in text.lines() {
329 let mut rest = line;
330 while let Some(i) = rest.find("**") {
331 let after = &rest[i + 2..];
332 let Some(j) = after.find("**") else { break };
333 let id = &after[..j];
334 let valid = id.len() >= 2
335 && matches!(id.chars().next(), Some('V' | 'P' | 'G' | 'U'))
336 && id[1..].chars().all(|c| c.is_ascii_digit());
337 let tail = &after[j + 2..];
338 if valid && !out.contains_key(id) {
339 let row = cells(line);
340 let (text, sites) = if line.trim_start().starts_with('|') {
341 let first = row.first().map(String::as_str).unwrap_or("");
343 let own = first.split_once("**").map(|(_, r)| r).and_then(|r| r.split_once("**")).map(|(_, t)| t);
344 match own.map(str::trim) {
345 Some(t) if !t.is_empty() => (t.to_string(), row.get(1).cloned().unwrap_or_default()),
346 _ => (row.get(1).cloned().unwrap_or_default(), row.get(2).cloned().unwrap_or_default()),
347 }
348 } else {
349 let t = tail.split("**").next().unwrap_or("").trim().to_string();
351 (t, String::new())
352 };
353 out.insert(id.to_string(), Assumption { id: id.to_string(), text, sites, kind: kind_of(id) });
354 }
355 rest = tail;
356 }
357 }
358 out
359}
360
361fn global_ids(text: &str) -> Vec<String> {
363 let Some(i) = text.find("proof-validity trust (") else { return vec![] };
364 let inner = &text[i + "proof-validity trust (".len()..];
365 let inner = &inner[..inner.find(')').unwrap_or(0)];
366 let nums: Vec<u32> =
367 inner.split(['–', '-']).filter_map(|p| p.trim().trim_start_matches('V').parse().ok()).collect();
368 match nums.as_slice() {
369 [a, b] if a <= b => (*a..=*b).map(|n| format!("V{n}")).collect(),
370 [a] => vec![format!("V{a}")],
371 _ => vec![],
372 }
373}
374
375fn parse_bypasses(text: &str) -> BTreeMap<String, Bypass> {
376 let mut out = BTreeMap::new();
377 for line in text.lines() {
378 let c = cells(line);
379 let id = c.first().cloned().unwrap_or_default();
380 let open_or_closed = id.starts_with("B-") && c.len() == 8;
381 let accepted = id.starts_with("X-") && c.len() == 6;
382 if !open_or_closed && !accepted {
383 continue;
384 }
385 let inv = c.last().cloned().unwrap_or_default();
386 let all = inv == "all";
387 let limits = if all || inv == "—" {
388 vec![]
389 } else {
390 inv.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
391 };
392 let b = if open_or_closed {
393 Bypass {
394 id: id.clone(),
395 route: c[1].clone(),
396 breaks: c[2].clone(),
397 severity: c[3].clone(),
398 status: c[4].clone(),
399 mitigation: c[5].clone(),
400 closes: c[6].clone(),
401 limits,
402 all,
403 }
404 } else {
405 Bypass {
406 id: id.clone(),
407 route: c[1].clone(),
408 breaks: c[2].clone(),
409 severity: "Accepted".into(),
410 status: "Accepted".into(),
411 mitigation: c[3].clone(),
412 closes: c[4].clone(),
413 limits,
414 all,
415 }
416 };
417 out.insert(id, b);
418 }
419 out
420}
421
422fn slug_of(title: &str) -> String {
425 let num: String = title.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '–').collect();
426 if let Some(rest) = title.strip_prefix(&num)
427 && rest.starts_with(". ")
428 && num.starts_with(|c: char| c.is_ascii_digit())
429 {
430 return num.replace('–', "-");
431 }
432 let mut s = String::new();
433 for c in title.chars() {
434 if c.is_alphanumeric() {
435 s.extend(c.to_lowercase());
436 } else if !s.ends_with('-') {
437 s.push('-');
438 }
439 }
440 s.trim_matches('-').to_string()
441}
442
443fn split_sections(text: &str) -> Vec<Section> {
445 let mut out: Vec<Section> = vec![];
446 for line in text.lines() {
447 let heading = line.strip_prefix("### ").or_else(|| line.strip_prefix("## "));
448 match heading {
449 Some(t) => out.push(Section { slug: slug_of(t), title: t.to_string(), body: String::new() }),
450 None => {
451 if let Some(last) = out.last_mut() {
452 last.body.push_str(line);
453 last.body.push('\n');
454 }
455 }
456 }
457 }
458 out
459}
460
461fn change_sections<'a>(sections: &'a [Section], current: &str) -> Vec<(String, &'a Section)> {
464 let changes: Vec<&Section> = sections.iter().filter(|s| s.title.contains("Changes from v")).collect();
465 let from = |s: &Section| s.title.split("Changes from ").nth(1).unwrap_or("").trim().to_string();
466 changes
467 .iter()
468 .enumerate()
469 .map(|(i, s)| (changes.get(i + 1).map(|n| from(n)).unwrap_or_else(|| current.to_string()), *s))
470 .collect()
471}
472
473fn about(bullet: &str, n: u32) -> String {
476 let clauses: Vec<&str> = bullet.split("; ").flat_map(|c| c.split(". ")).collect();
477 let hit: Vec<&str> = clauses.iter().copied().filter(|c| invariant_refs(c).contains(&n)).collect();
478 let s = if hit.is_empty() { bullet.to_string() } else { hit.join("; ") };
479 s.trim().trim_end_matches('.').to_string()
480}
481
482fn by_number(ids: &mut [String]) {
484 ids.sort_by_key(|i| i.trim_start_matches('I').parse::<u32>().unwrap_or(0));
485}
486
487fn short_title(statement: &str) -> String {
488 let cut = statement.find(['.', ';']).unwrap_or(statement.len());
489 let t = statement[..cut].trim().trim_end_matches(',');
490 if t.chars().count() > 90 { format!("{}…", t.chars().take(88).collect::<String>()) } else { t.to_string() }
491}
492
493fn expectation(post: Option<&T>) -> String {
495 let Some(t) = post.and_then(T::as_table) else { return String::new() };
496 let show = |v: &T| -> String {
497 match v {
498 T::String(s) => s.clone(),
499 T::Array(a) => a
500 .iter()
501 .map(|x| match x {
502 T::String(s) => s.clone(),
503 T::Table(t) => t
504 .iter()
505 .filter(|(k, _)| matches!(k.as_str(), "target" | "terminal" | "transition" | "unit"))
506 .map(|(_, v)| v.as_str().unwrap_or("").to_string())
507 .collect::<Vec<_>>()
508 .join(" "),
509 other => other.to_string(),
510 })
511 .collect::<Vec<_>>()
512 .join(", "),
513 T::Table(t) => t
514 .iter()
515 .map(|(k, v)| format!("{k} {}", v.as_str().unwrap_or(&v.to_string())))
516 .collect::<Vec<_>>()
517 .join(", "),
518 other => other.to_string(),
519 }
520 };
521 let s = t.iter().map(|(k, v)| format!("{k}: {}", show(v))).collect::<Vec<_>>().join("; ");
522 if s.chars().count() > 220 { format!("{}…", s.chars().take(218).collect::<String>()) } else { s }
523}
524
525fn locate(krate: &str, func: &str, src: &Sources) -> Option<Obligation> {
527 let (pkg, files) = src.crates.get(krate)?;
528 let pat = format!("fn {func}");
529 for (path, text) in files {
530 let lines: Vec<&str> = text.lines().collect();
531 for (n, l) in lines.iter().enumerate() {
532 let Some(i) = l.find(&pat) else { continue };
533 let next = l[i + pat.len()..].chars().next();
534 if !next.is_some_and(|c| c == '(' || c == '<' || c.is_whitespace()) {
535 continue;
536 }
537 let before: Vec<&str> = lines[n.saturating_sub(8)..n].to_vec();
538 let kani = before.iter().rev().take_while(|b| !b.trim().is_empty()).any(|b| b.contains("kani::proof"));
539 let kind = if kani {
540 "Kani harness"
541 } else if l.contains("proof fn") {
542 "proof fn"
543 } else if VERIFIED.contains(&krate) {
544 "verified fn"
545 } else {
546 "function"
547 };
548 let mut doc = vec![];
549 let mut k = n;
550 while k > 0 {
551 k -= 1;
552 let t = lines[k].trim();
553 if let Some(d) = t.strip_prefix("///") {
554 doc.push(d.trim().to_string());
555 } else if t.starts_with("#[") || t.starts_with("#![") {
556 continue;
557 } else {
558 break;
559 }
560 }
561 doc.reverse();
562 let module = path.split("/src/").nth(1).map(|m| m.trim_end_matches(".rs").trim_end_matches("/mod"));
563 let lib = pkg.replace('-', "_");
564 let rustdoc = match module {
565 Some("lib") => Some(format!("{lib}/index.html")),
566 Some(m) if !m.contains("kani") && !m.contains("tests") => Some(format!("{lib}/{m}/index.html")),
567 _ => None,
568 };
569 return Some(Obligation {
570 name: format!("{krate}::{func}"),
571 kind,
572 doc: doc.join(" ").trim().to_string(),
573 file: path.clone(),
574 line: n + 1,
575 rustdoc,
576 });
577 }
578 }
579 None
580}
581
582fn gym_ref<'a>(t: &str, entries: &'a BTreeMap<String, Entry>) -> Option<(String, Vec<&'a str>)> {
585 let rest = t.strip_prefix("gym ")?;
586 let name = rest.split_whitespace().next().unwrap_or("").to_string();
587 if !name.starts_with(|c: char| c.is_ascii_alphanumeric()) {
589 return None;
590 }
591 let hits: Vec<&str> = match name.strip_suffix('*') {
592 Some(prefix) => entries.keys().filter(|k| k.starts_with(prefix)).map(String::as_str).collect(),
593 None => entries.keys().filter(|k| **k == name).map(String::as_str).collect(),
594 };
595 Some((name, hits))
596}
597
598pub struct Site {
601 pub files: Vec<(PathBuf, String)>,
603 pub summary: String,
604}
605
606pub fn build(src: &Sources, o: &Opts) -> Result<Site, Vec<String>> {
607 let mut errors: Vec<String> = vec![];
608 let version = src.spec.lines().next().and_then(|l| l.rsplit("Spec ").next()).unwrap_or("?").trim().to_string();
609
610 let invariants: Vec<Invariant> = src
612 .spec
613 .lines()
614 .filter(|l| l.starts_with("| I") && l[3..].starts_with(|c: char| c.is_ascii_digit()))
615 .filter_map(|l| {
616 let c = cells(l);
617 (c.len() >= 3 && c[0].starts_with('I')).then(|| Invariant {
618 id: c[0].clone(),
619 statement: c[1].clone(),
620 phase: c[2].clone(),
621 })
622 })
623 .collect();
624 let inv_ids: BTreeSet<String> = invariants.iter().map(|i| i.id.clone()).collect();
625 if invariants.is_empty() {
626 errors.push(format!("{SPEC}: no invariant table (§7)"));
627 }
628
629 let ratio: T = toml::from_str(&src.ratio).unwrap_or_else(|e| {
631 errors.push(format!("{RATIO}: {e}"));
632 T::Table(Default::default())
633 });
634 let list = |v: &T, k: &str| -> Vec<String> {
635 v.get(k).and_then(T::as_array).into_iter().flatten().filter_map(T::as_str).map(String::from).collect()
636 };
637 let mut rows: BTreeMap<String, Row> = BTreeMap::new();
638 for r in ratio.get("invariant").and_then(T::as_array).into_iter().flatten() {
639 let id = r.get("id").and_then(T::as_str).unwrap_or("?").to_string();
640 if !inv_ids.contains(&id) {
641 errors.push(format!("{RATIO}: {id} is not an invariant in §7 (it would have no page)"));
642 }
643 rows.insert(
644 id,
645 Row { discharged: list(r, "discharged"), assumes: list(r, "assumes"), tested: list(r, "tested") },
646 );
647 }
648 let baseline: BTreeMap<String, (i64, i64)> = ratio
649 .get("baseline")
650 .and_then(|b| b.get("per_invariant"))
651 .and_then(T::as_table)
652 .map(|t| {
653 t.iter()
654 .filter_map(|(k, v)| {
655 let a = v.as_array()?;
656 Some((k.clone(), (a.first()?.as_integer()?, a.get(1)?.as_integer()?)))
657 })
658 .collect()
659 })
660 .unwrap_or_default();
661
662 let assumptions = parse_assumptions(&src.assumptions);
663 let global = global_ids(&src.assumptions);
664 for g in &global {
665 if !assumptions.contains_key(g) {
666 errors.push(format!("{ASSUMPTIONS}: proof-validity id {g} has no entry"));
667 }
668 }
669 let bypasses = parse_bypasses(&src.bypasses);
670 let bypass_ids: BTreeSet<String> = bypasses.keys().cloned().collect();
671 for line in src.bypasses.lines() {
674 let c = cells(line);
675 let id = c.first().cloned().unwrap_or_default();
676 if (id.starts_with("B-") || id.starts_with("X-"))
677 && id[2..].parse::<u32>().is_ok()
678 && !bypasses.contains_key(&id)
679 {
680 errors.push(format!("{BYPASSES}: {id} has no Invariants column"));
681 }
682 }
683 for b in bypasses.values() {
684 for i in &b.limits {
685 if !inv_ids.contains(i) {
686 errors.push(format!("{BYPASSES}: {} names {i}, which is not an invariant", b.id));
687 }
688 }
689 }
690
691 let catalog: T = toml::from_str(&src.catalog).unwrap_or_else(|e| {
693 errors.push(format!("{CATALOG}: {e}"));
694 T::Table(Default::default())
695 });
696 let mut entries: BTreeMap<String, Entry> = BTreeMap::new();
697 for e in catalog.get("fault").and_then(T::as_array).into_iter().flatten() {
698 let id = e.get("id").and_then(T::as_str).unwrap_or("?").to_string();
699 let post = e.get("required_post");
700 let mut named: BTreeSet<String> = BTreeSet::new();
701 for s in list(e, "spec") {
702 named.extend(invariant_refs(&s).into_iter().map(|n| format!("I{n}")));
703 }
704 if let Some(p) = post {
705 named.extend(list(p, "invariants"));
706 }
707 for n in &named {
708 if !inv_ids.contains(n) {
709 errors.push(format!("{CATALOG}: {id} names {n}, which is not an invariant"));
710 }
711 }
712 entries.insert(
713 id.clone(),
714 Entry {
715 id,
716 summary: e.get("summary").and_then(T::as_str).unwrap_or("").to_string(),
717 harness: list(e, "harness"),
718 scoring: e.get("scoring").and_then(T::as_str).unwrap_or("").to_string(),
719 expects: expectation(post),
720 invariants: named,
721 },
722 );
723 }
724 let verdicts: BTreeMap<String, String> = src
725 .results
726 .iter()
727 .flat_map(|r| r.lines())
728 .filter_map(|l| {
729 let mut w = l.split_whitespace();
730 let v = w.next()?;
731 let id = w.next()?;
732 (matches!(v, "PASS" | "FAIL" | "PARTIAL" | "UNSUPPORTED" | "NOTFAKE" | "ERROR") && w.next().is_none())
733 .then(|| (id.to_string(), v.to_string()))
734 })
735 .collect();
736
737 let mut obligations: BTreeMap<String, Obligation> = BTreeMap::new();
739 for (id, r) in &rows {
740 for a in &r.assumes {
741 if !assumptions.contains_key(a) {
742 errors.push(format!("{RATIO}: {id} rests on {a}, which has no entry in {ASSUMPTIONS}"));
743 }
744 }
745 for n in &r.discharged {
746 if obligations.contains_key(n) {
747 continue;
748 }
749 match n.split_once("::").and_then(|(k, f)| locate(k, f, src)) {
750 Some(ob) => {
751 obligations.insert(n.clone(), ob);
752 }
753 None => errors.push(format!("{RATIO}: {id}'s obligation {n} has no matching function")),
754 }
755 }
756 for t in &r.tested {
757 if let Some((name, hits)) = gym_ref(t, &entries)
758 && hits.is_empty()
759 {
760 errors.push(format!("{RATIO}: {id} is tested by gym {name}, which matches no catalog entry"));
761 }
762 }
763 }
764
765 let mut sections = split_sections(&src.spec);
767 let spec_slugs: BTreeSet<String> = sections.iter().map(|s| s.slug.clone()).collect();
768 for h in split_sections(&src.history) {
769 if !spec_slugs.contains(&h.slug) {
770 sections.push(h);
771 }
772 }
773 let slugs: BTreeSet<String> = sections.iter().map(|s| s.slug.clone()).collect();
774 sections.sort_by_key(|s| {
775 let n: u32 = s.slug.chars().take_while(char::is_ascii_digit).collect::<String>().parse().unwrap_or(999);
776 (n, s.slug.clone())
777 });
778 for i in &invariants {
780 for b in bypass_refs(&i.statement) {
781 if !bypass_ids.contains(&b) {
782 errors.push(format!("{SPEC}: {} cites {b}, which has no row in {BYPASSES}", i.id));
783 }
784 }
785 }
786 let changes = change_sections(§ions, &version);
787
788 if !errors.is_empty() {
789 errors.sort();
790 errors.dedup();
791 return Err(errors);
792 }
793
794 let tree = format!("https://github.com/{}/blob/{}", o.repo, o.commit);
796 let short = &o.commit[..o.commit.len().min(7)];
797 let header = |path: &str| -> String {
798 let up = "../".repeat(path.matches('/').count());
799 let others: Vec<String> =
800 o.others.iter().filter(|s| **s != o.snapshot).map(|s| format!("[{s}]({up}../{s}/{path}.html)")).collect();
801 format!(
802 "`planarian` @ [`{short}`](https://github.com/{}/tree/{}) · Spec {version} · snapshot `{}` · [all versions]({up}../index.html){}\n\n",
803 o.repo,
804 o.commit,
805 o.snapshot,
806 if others.is_empty() { String::new() } else { format!(" · also in {}", others.join(", ")) }
807 )
808 };
809 let link = |text: &str, up: &str| linkify(text, up, &slugs, &bypass_ids);
810 let mut files: Vec<(PathBuf, String)> = vec![];
811 let entries_for = |inv: &str| -> Vec<&Entry> {
812 let mut ids: BTreeSet<&str> =
813 entries.values().filter(|e| e.invariants.contains(inv)).map(|e| e.id.as_str()).collect();
814 if let Some(r) = rows.get(inv) {
815 for t in &r.tested {
816 if let Some((_, hits)) = gym_ref(t, &entries) {
817 ids.extend(hits);
818 }
819 }
820 }
821 ids.iter().filter_map(|i| entries.get(*i)).collect()
822 };
823 let limits_of = |inv: &str| -> Vec<&Bypass> {
825 let mut v: Vec<&Bypass> = bypasses
826 .values()
827 .filter(|b| !b.status.starts_with("Closed"))
828 .filter(|b| b.all || b.limits.iter().any(|l| l == inv))
829 .collect();
830 v.sort_by_key(|b| (b.id.starts_with('X'), b.id[2..].parse::<u32>().unwrap_or(0)));
831 v
832 };
833 let verdict = |e: &Entry| -> String {
834 let fake = if e.harness.iter().any(|h| h == "fake") {
835 match verdicts.get(&e.id) {
836 Some(v) => format!("fake: {v}"),
837 None => "fake: not run in this build".into(),
838 }
839 } else {
840 String::new()
841 };
842 let vm =
843 if e.harness.iter().any(|h| h == "vm") { "VM: not run in this build".to_string() } else { String::new() };
844 [fake, vm].into_iter().filter(|s| !s.is_empty()).collect::<Vec<_>>().join("; ")
845 };
846 let n_of = |id: &str| -> u32 { id.trim_start_matches('I').parse().unwrap_or(0) };
847
848 let mut index_rows = vec![];
849 for inv in &invariants {
850 let path = format!("invariants/{}", inv.id);
851 let up = "../";
852 let row = rows.get(&inv.id);
853 let (d, a) = row.map(|r| (r.discharged.len(), r.assumes.len())).unwrap_or((0, 0));
854 let gym = entries_for(&inv.id);
855 let must = gym.iter().filter(|e| e.scoring == "must-pass").count();
856 let status = match row {
857 None => "**Not accounted** (no row in `docs/verification-ratio.toml`)".to_string(),
858 Some(_) if d > 0 => "**Proved**".to_string(),
859 Some(_) if !gym.is_empty() || row.is_some_and(|r| !r.tested.is_empty()) => {
860 "**Tested, not proved**".to_string()
861 }
862 Some(_) => "**Neither proved nor tested**".to_string(),
863 };
864 let da = match (row, baseline.get(&inv.id)) {
865 (None, _) => "—".to_string(),
866 (Some(_), Some((bd, ba))) => {
867 let now = if a == 0 { 0.0 } else { d as f64 / a as f64 };
868 let then = if *ba == 0 { 0.0 } else { *bd as f64 / *ba as f64 };
869 format!(
870 "**{d} : {a}** (baseline {bd} : {ba}, {})",
871 if now + 1e-9 < then { "degraded" } else { "not degraded" }
872 )
873 }
874 (Some(_), None) => format!("**{d} : {a}** (no baseline yet)"),
875 };
876 let title = short_title(&inv.statement);
877 let mut p = format!("# {} · {}\n\n", inv.id, esc(&title));
878 p.push_str(&header(&path));
879 p.push_str(&format!("> {}\n>\n> — [SPEC §7]({up}spec/7.md)\n\n", link(&inv.statement, up)));
880 p.push_str("| Status | Proof phase | d:a | Gym |\n| --- | --- | --- | --- |\n");
881 p.push_str(&format!(
882 "| {status} | {} | {da} | {} {}, {must} must-pass |\n\n",
883 esc(&inv.phase),
884 gym.len(),
885 if gym.len() == 1 { "entry" } else { "entries" }
886 ));
887
888 p.push_str("## How it is proved\n\n");
889 let obs: Vec<&Obligation> =
890 row.into_iter().flat_map(|r| &r.discharged).filter_map(|n| obligations.get(n)).collect();
891 if obs.is_empty() {
892 p.push_str("No obligation discharges it: nothing about it is proved.\n\n");
893 } else {
894 let modules: BTreeSet<String> = obs.iter().map(|o| o.file.clone()).collect();
895 p.push_str(&format!(
896 "{} {}, in {}; every one is checked on every CI run.\n\n| Obligation | Kind | What it guarantees | Source |\n| --- | --- | --- | --- |\n",
897 obs.len(),
898 if obs.len() == 1 { "obligation" } else { "obligations" },
899 modules.iter().map(|m| format!("`{m}`")).collect::<Vec<_>>().join(", ")
900 ));
901 for ob in &obs {
902 let file = ob.file.rsplit('/').next().unwrap_or(&ob.file);
903 p.push_str(&format!(
904 "| `{}` | {} | {} | [{file}:{}]({tree}/{}#L{}) |\n",
905 ob.name.split("::").nth(1).unwrap_or(&ob.name),
906 ob.kind,
907 if ob.doc.is_empty() { "*(no doc comment)*".to_string() } else { esc(&link(&ob.doc, up)) },
908 ob.line,
909 ob.file,
910 ob.line
911 ));
912 }
913 p.push('\n');
914 }
915
916 p.push_str(
917 "## What it rests on\n\nIf any of these is false, the proof no longer describes the real machine.\n\n",
918 );
919 let assumed = row.map(|r| r.assumes.clone()).unwrap_or_default();
920 if assumed.is_empty() {
921 p.push_str("Nothing beyond the global assumptions below.\n\n");
922 } else {
923 p.push_str("| Id | Assumption | Kind | Also relied on by |\n| --- | --- | --- | --- |\n");
924 for a in &assumed {
925 let Some(asm) = assumptions.get(a) else { continue };
926 let mut also: Vec<String> = rows
927 .iter()
928 .filter(|(k, r)| **k != inv.id && r.assumes.contains(a))
929 .map(|(k, _)| k.clone())
930 .collect();
931 by_number(&mut also);
932 let also: Vec<String> = also.iter().map(|k| format!("[{k}](./{k}.md)")).collect();
933 p.push_str(&format!(
934 "| [{a}]({up}assumptions/{a}.md) | {} | {} | {} |\n",
935 esc(&link(&asm.text, up)),
936 asm.kind,
937 if also.is_empty() { "—".into() } else { also.join(", ") }
938 ));
939 }
940 p.push('\n');
941 }
942 if !obs.is_empty() {
943 let kani = obs.iter().any(|o| o.kind == "Kani harness");
944 let g: Vec<String> = global
945 .iter()
946 .filter(|g| kani || !assumptions.get(*g).is_some_and(|a| a.sites.to_lowercase().contains("kani")))
947 .map(|g| {
948 let t = assumptions
949 .get(g)
950 .map(|a| a.text.split(". ").next().unwrap_or("").to_string())
951 .unwrap_or_default();
952 format!("[{g}]({up}assumptions/{g}.md) ({})", esc(t.trim_end_matches('.')))
953 })
954 .collect();
955 p.push_str(&format!("Every proof also rests on {}.\n\n", g.join(", ")));
956 }
957
958 p.push_str("## How it is tested\n\n");
959 if gym.is_empty() {
960 p.push_str("No gym entry exercises it.\n\n");
961 } else {
962 p.push_str(
963 "| Entry | Harness | What it does | Expects | Latest verdict |\n| --- | --- | --- | --- | --- |\n",
964 );
965 for e in &gym {
966 p.push_str(&format!(
967 "| [`{0}`]({up}gym/{0}.md) | {1} | {2} | {3} | {4} |\n",
968 e.id,
969 e.harness.join(", "),
970 esc(&e.summary),
971 esc(&e.expects),
972 verdict(e)
973 ));
974 }
975 p.push('\n');
976 }
977 let other_tests: Vec<String> = row
978 .into_iter()
979 .flat_map(|r| &r.tested)
980 .filter(|t| !t.starts_with("gym "))
981 .map(|t| format!("`{t}`"))
982 .collect();
983 if !other_tests.is_empty() {
984 p.push_str(&format!("Also tested by {}.\n\n", other_tests.join(", ")));
985 }
986
987 p.push_str("## Known limits\n\n");
988 let lim = limits_of(&inv.id);
989 if lim.is_empty() {
990 p.push_str("No row of the bypass register limits it.\n\n");
991 } else {
992 p.push_str("| Bypass | Route | Severity | Status | Closes in |\n| --- | --- | --- | --- | --- |\n");
993 for b in &lim {
994 p.push_str(&format!(
995 "| [{0}]({up}bypasses/{0}.md) | {1} | {2} | {3} | {4} |\n",
996 b.id,
997 esc(&link(&b.route, up)),
998 b.severity,
999 esc(&b.status),
1000 esc(&b.closes)
1001 ));
1002 }
1003 p.push('\n');
1004 }
1005
1006 p.push_str("## History\n\n");
1007 let n = n_of(&inv.id);
1008 let mut hist = vec![];
1009 for (v, s) in &changes {
1010 for bullet in s.body.lines().filter(|l| l.starts_with("- ")) {
1011 if invariant_refs(bullet).contains(&n) {
1012 hist.push(format!("| {v} | {} |", esc(&link(&about(&bullet[2..], n), up))));
1013 }
1014 }
1015 }
1016 if hist.is_empty() {
1017 p.push_str("No changes section names it.\n\n");
1018 } else {
1019 p.push_str(&format!("| Spec | Change |\n| --- | --- |\n{}\n\n", hist.join("\n")));
1020 }
1021
1022 p.push_str("## See also\n\n");
1023 let mut see: Vec<String> = vec![];
1024 let mut seen = BTreeSet::new();
1025 for sref in section_refs(&inv.statement) {
1026 if slugs.contains(&sref) && seen.insert(sref.clone()) {
1027 see.push(format!("[§{sref}]({up}spec/{sref}.md)"));
1028 }
1029 }
1030 for m in invariant_refs(&inv.statement) {
1031 let other = format!("I{m}");
1032 if other != inv.id && inv_ids.contains(&other) && seen.insert(other.clone()) {
1033 see.push(format!("[{other}](./{other}.md)"));
1034 }
1035 }
1036 let docs: BTreeSet<&String> = obs.iter().filter_map(|o| o.rustdoc.as_ref()).collect();
1037 for d in docs {
1038 see.push(format!(
1039 "[`{}` in rustdoc]({up}rustdoc/{d})",
1040 d.trim_end_matches("/index.html").replace('/', "::")
1041 ));
1042 }
1043 p.push_str(&if see.is_empty() { "—\n".to_string() } else { format!("{}\n", see.join(" · ")) });
1044 files.push((PathBuf::from(format!("{path}.md")), p));
1045 index_rows.push(format!(
1046 "| [{0}](invariants/{0}.md) | {1} | {2} | {3} | {4} | {5} |",
1047 inv.id,
1048 esc(&title),
1049 status.replace("**", ""),
1050 if row.is_some() { format!("{d} : {a}") } else { "—".into() },
1051 gym.len(),
1052 limits_of(&inv.id).len()
1053 ));
1054 }
1055
1056 for a in assumptions.values() {
1058 let path = format!("assumptions/{}", a.id);
1059 let up = "../";
1060 let mut on: Vec<String> =
1061 rows.iter().filter(|(_, r)| r.assumes.contains(&a.id)).map(|(k, _)| k.clone()).collect();
1062 by_number(&mut on);
1063 let on: Vec<String> = on.iter().map(|k| format!("[{k}]({up}invariants/{k}.md)")).collect();
1064 let every = global.contains(&a.id);
1065 let mut p = format!("# {} · {}\n\n{}", a.id, a.kind, header(&path));
1066 p.push_str(&format!("> {}\n\n", link(&a.text, up)));
1067 if !a.sites.is_empty() && a.sites != "—" {
1068 p.push_str(&format!("Sites: {}\n\n", link(&a.sites, up)));
1069 }
1070 p.push_str("## If it is false\n\n");
1071 if every {
1072 p.push_str("Every proof stops describing the real machine: it is proof-validity trust.\n\n");
1073 }
1074 p.push_str(&if on.is_empty() {
1075 "No invariant names it in `docs/verification-ratio.toml`.\n".to_string()
1076 } else {
1077 format!("These invariants fall: {}.\n", on.join(", "))
1078 });
1079 files.push((PathBuf::from(format!("{path}.md")), p));
1080 }
1081
1082 for b in bypasses.values() {
1084 let path = format!("bypasses/{}", b.id);
1085 let up = "../";
1086 let mut p = format!("# {} · {}\n\n{}", b.id, b.severity, header(&path));
1087 p.push_str(&format!("> {}\n\n", link(&b.route, up)));
1088 p.push_str(&format!(
1089 "| Breaks | Status | Mitigation | Closes in |\n| --- | --- | --- | --- |\n| {} | {} | {} | {} |\n\n",
1090 esc(&b.breaks),
1091 esc(&b.status),
1092 esc(&link(&b.mitigation, up)),
1093 esc(&b.closes)
1094 ));
1095 p.push_str("## The guarantees it narrows\n\n");
1096 p.push_str(&if b.all {
1097 "Every invariant.\n".to_string()
1098 } else if b.limits.is_empty() {
1099 "None: a process or availability row.\n".to_string()
1100 } else {
1101 format!(
1102 "{}.\n",
1103 b.limits.iter().map(|i| format!("[{i}]({up}invariants/{i}.md)")).collect::<Vec<_>>().join(", ")
1104 )
1105 });
1106 files.push((PathBuf::from(format!("{path}.md")), p));
1107 }
1108
1109 let mut gym_index = vec![];
1111 for e in entries.values() {
1112 let path = format!("gym/{}", e.id);
1113 let up = "../";
1114 let on: Vec<String> = invariants
1115 .iter()
1116 .filter(|i| entries_for(&i.id).iter().any(|x| x.id == e.id))
1117 .map(|i| i.id.clone())
1118 .collect();
1119 let mut p = format!("# `{}`\n\n{}", e.id, header(&path));
1120 p.push_str(&format!("> {}\n\n", link(&esc(&e.summary), up)));
1121 p.push_str(&format!(
1122 "| Harness | Scoring | Expects | Latest verdict |\n| --- | --- | --- | --- |\n| {} | {} | {} | {} |\n\n",
1123 e.harness.join(", "),
1124 e.scoring,
1125 esc(&e.expects),
1126 verdict(e)
1127 ));
1128 p.push_str(&format!(
1129 "## What it exercises\n\n{}\n",
1130 if on.is_empty() {
1131 "No invariant by name.".to_string()
1132 } else {
1133 on.iter().map(|i| format!("[{i}]({up}invariants/{i}.md)")).collect::<Vec<_>>().join(", ")
1134 }
1135 ));
1136 files.push((PathBuf::from(format!("{path}.md")), p));
1137 gym_index.push(format!(
1138 "| [`{0}`](gym/{0}.md) | {1} | {2} | {3} |",
1139 e.id,
1140 e.harness.join(", "),
1141 on.join(", "),
1142 verdict(e)
1143 ));
1144 }
1145
1146 let mut by_file: BTreeMap<String, Vec<(&Obligation, Vec<String>)>> = BTreeMap::new();
1148 for ob in obligations.values() {
1149 let mut invs: Vec<String> =
1150 rows.iter().filter(|(_, r)| r.discharged.contains(&ob.name)).map(|(k, _)| k.clone()).collect();
1151 by_number(&mut invs);
1152 by_file.entry(ob.file.clone()).or_default().push((ob, invs));
1153 }
1154 let mut src_page =
1155 format!("# Source files\n\n{}Which invariants and obligations live in each file.\n\n", header("files"));
1156 for (file, obs) in &by_file {
1157 src_page.push_str(&format!(
1158 "## [`{file}`]({tree}/{file})\n\n| Obligation | Kind | Line | Invariants |\n| --- | --- | --- | --- |\n"
1159 ));
1160 let mut obs = obs.clone();
1161 obs.sort_by_key(|(o, _)| o.line);
1162 for (ob, invs) in obs {
1163 src_page.push_str(&format!(
1164 "| `{}` | {} | [{}]({tree}/{file}#L{}) | {} |\n",
1165 ob.name,
1166 ob.kind,
1167 ob.line,
1168 ob.line,
1169 invs.iter().map(|i| format!("[{i}](invariants/{i}.md)")).collect::<Vec<_>>().join(", ")
1170 ));
1171 }
1172 src_page.push('\n');
1173 }
1174 files.push((PathBuf::from("files.md"), src_page));
1175
1176 for s in §ions {
1178 let path = format!("spec/{}", s.slug);
1179 let mut p = format!("# §{}\n\n{}", esc(&s.title), header(&path));
1180 p.push_str(&link(&s.body, "../"));
1181 files.push((PathBuf::from(format!("{path}.md")), p));
1182 }
1183
1184 let mut api = format!(
1186 "# API docs\n\n{}`cargo doc` for the workspace. Proof obligations link to source lines instead: rustdoc renders `verus!` blocks poorly.\n\n",
1187 header("api")
1188 );
1189 for (dir, (pkg, _)) in &src.crates {
1190 api.push_str(&format!("- [`{pkg}`](rustdoc/{}/index.html) (`crates/{dir}`)\n", pkg.replace('-', "_")));
1191 }
1192 files.push((PathBuf::from("api.md"), api));
1193
1194 let (dsum, asum): (usize, usize) =
1196 rows.values().fold((0, 0), |(d, a), r| (d + r.discharged.len(), a + r.assumes.len()));
1197 let mut index = format!(
1198 "# Planarian's guarantees\n\n{}What Planarian promises, and the code, proofs, tests and limits that carry each promise, computed from the repository at this commit (SPEC {}).\n\n{} invariants · {} obligations discharged against {} assumptions (d:a {dsum} : {asum}) · {} assumptions · {} bypass rows · {} gym entries\n\n| Invariant | Statement | Status | d:a | Gym | Limits |\n| --- | --- | --- | --- | --- | --- |\n{}\n",
1199 header("index"),
1200 if slugs.contains("17c") { "[§17c](spec/17c.md)" } else { "§17c" },
1201 invariants.len(),
1202 dsum,
1203 asum,
1204 assumptions.len(),
1205 bypasses.len(),
1206 entries.len(),
1207 index_rows.join("\n")
1208 );
1209 let unaccounted: Vec<&str> =
1210 invariants.iter().filter(|i| !rows.contains_key(&i.id)).map(|i| i.id.as_str()).collect();
1211 if !unaccounted.is_empty() {
1212 index
1213 .push_str(&format!("\nNot yet accounted in `docs/verification-ratio.toml`: {}.\n", unaccounted.join(", ")));
1214 }
1215 files.push((PathBuf::from("index.md"), index));
1216 files.push((
1217 PathBuf::from("gym.md"),
1218 format!(
1219 "# Gym entries\n\n{}| Entry | Harness | Invariants | Latest verdict |\n| --- | --- | --- | --- |\n{}\n",
1220 header("gym"),
1221 gym_index.join("\n")
1222 ),
1223 ));
1224
1225 let mut summary = String::from("# Summary\n\n[Guarantees](index.md)\n\n# Invariants\n\n");
1227 for i in &invariants {
1228 summary.push_str(&format!(
1229 "- [{} · {}](invariants/{}.md)\n",
1230 i.id,
1231 short_title(&i.statement).replace(['[', ']'], ""),
1232 i.id
1233 ));
1234 }
1235 summary.push_str("\n# Indexes\n\n- [Assumptions](assumptions/index.md)\n");
1236 for a in assumptions.keys() {
1237 summary.push_str(&format!(" - [{a}](assumptions/{a}.md)\n"));
1238 }
1239 summary.push_str("- [Bypasses](bypasses/index.md)\n");
1240 for b in bypasses.keys() {
1241 summary.push_str(&format!(" - [{b}](bypasses/{b}.md)\n"));
1242 }
1243 summary.push_str("- [Gym entries](gym.md)\n");
1244 for e in entries.keys() {
1245 summary.push_str(&format!(" - [{e}](gym/{e}.md)\n"));
1246 }
1247 summary.push_str("- [Source files](files.md)\n- [API docs](api.md)\n\n# Specification\n\n");
1248 for s in §ions {
1249 summary.push_str(&format!("- [§{}](spec/{}.md)\n", s.title.replace(['[', ']'], ""), s.slug));
1250 }
1251 let asm_index = format!(
1252 "# Assumptions\n\n{}| Id | Kind | Assumption | Invariants |\n| --- | --- | --- | --- |\n{}\n",
1253 header("assumptions/index"),
1254 assumptions
1255 .values()
1256 .map(|a| {
1257 let mut on: Vec<String> =
1258 rows.iter().filter(|(_, r)| r.assumes.contains(&a.id)).map(|(k, _)| k.clone()).collect();
1259 by_number(&mut on);
1260 format!(
1261 "| [{0}]({0}.md) | {1} | {2} | {3} |",
1262 a.id,
1263 a.kind,
1264 esc(&a.text),
1265 if global.contains(&a.id) { "every proof".into() } else { on.join(", ") }
1266 )
1267 })
1268 .collect::<Vec<_>>()
1269 .join("\n")
1270 );
1271 files.push((PathBuf::from("assumptions/index.md"), asm_index));
1272 let byp_index = format!(
1273 "# Bypasses\n\n{}| Id | Severity | Status | Invariants |\n| --- | --- | --- | --- |\n{}\n",
1274 header("bypasses/index"),
1275 bypasses
1276 .values()
1277 .map(|b| {
1278 format!(
1279 "| [{0}]({0}.md) | {1} | {2} | {3} |",
1280 b.id,
1281 b.severity,
1282 esc(&b.status),
1283 if b.all {
1284 "all".into()
1285 } else if b.limits.is_empty() {
1286 "—".into()
1287 } else {
1288 b.limits.join(", ")
1289 }
1290 )
1291 })
1292 .collect::<Vec<_>>()
1293 .join("\n")
1294 );
1295 files.push((PathBuf::from("bypasses/index.md"), byp_index));
1296 files.push((PathBuf::from("SUMMARY.md"), summary));
1297
1298 let pages: BTreeSet<PathBuf> = files.iter().map(|(p, _)| p.clone()).collect();
1300 let mut broken = vec![];
1301 for (page, text) in &files {
1302 let dir = page.parent().map(Path::to_path_buf).unwrap_or_default();
1303 for (i, _) in text.match_indices("](") {
1304 let target = text[i + 2..].split(')').next().unwrap_or("");
1305 let path = target.split('#').next().unwrap_or("");
1306 if path.is_empty() || path.contains("://") || !path.ends_with(".md") {
1307 continue;
1308 }
1309 let mut resolved = dir.clone();
1310 for part in path.split('/') {
1311 match part {
1312 "." => {}
1313 ".." => {
1314 resolved.pop();
1315 }
1316 p => resolved.push(p),
1317 }
1318 }
1319 if !pages.contains(&resolved) {
1320 broken.push(format!("{}: links to {path}, which is not a page", page.display()));
1321 }
1322 }
1323 }
1324 if !broken.is_empty() {
1325 broken.sort();
1326 broken.dedup();
1327 return Err(broken);
1328 }
1329
1330 let summary = format!(
1331 "{} pages: {} invariants, {} assumptions, {} bypass rows, {} gym entries, {} spec sections, {} obligations located",
1332 files.len(),
1333 invariants.len(),
1334 assumptions.len(),
1335 bypasses.len(),
1336 entries.len(),
1337 sections.len(),
1338 obligations.len()
1339 );
1340 Ok(Site { files, summary })
1341}
1342
1343pub fn write(site: &Site, out: &Path, o: &Opts) -> std::io::Result<()> {
1345 let src = out.join("src");
1346 if src.exists() {
1347 std::fs::remove_dir_all(&src)?;
1348 }
1349 for (path, text) in &site.files {
1350 let p = src.join(path);
1351 if let Some(d) = p.parent() {
1352 std::fs::create_dir_all(d)?;
1353 }
1354 std::fs::write(p, text)?;
1355 }
1356 std::fs::write(
1357 out.join("book.toml"),
1358 format!(
1359 "[book]\ntitle = \"Planarian's guarantees ({})\"\nsrc = \"src\"\n\n[output.html]\ngit-repository-url = \"https://github.com/{}\"\nno-section-label = true\n",
1360 o.snapshot, o.repo
1361 ),
1362 )
1363}
1364
1365pub fn main(args: &[String]) -> i32 {
1369 let get = |k: &str| args.iter().position(|a| a == k).and_then(|i| args.get(i + 1)).cloned();
1370 let out = PathBuf::from(get("--out").unwrap_or_else(|| "target/site".into()));
1371 let commit = get("--commit").or_else(|| {
1372 std::process::Command::new("git")
1373 .args(["rev-parse", "HEAD"])
1374 .output()
1375 .ok()
1376 .filter(|o| o.status.success())
1377 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
1378 });
1379 let o = Opts {
1380 commit: commit.unwrap_or_else(|| "HEAD".into()),
1381 repo: get("--repo").unwrap_or_else(|| "aubrika/planarian".into()),
1382 snapshot: get("--snapshot").unwrap_or_else(|| "local".into()),
1383 others: get("--others")
1384 .map(|s| s.split(',').filter(|x| !x.is_empty()).map(String::from).collect())
1385 .unwrap_or_default(),
1386 };
1387 let results = get("--results").map(PathBuf::from);
1388 let src = match Sources::load(Path::new("."), results.as_deref()) {
1389 Ok(s) => s,
1390 Err(e) => {
1391 eprintln!("site: {e}");
1392 return 1;
1393 }
1394 };
1395 match build(&src, &o) {
1396 Ok(site) => match write(&site, &out, &o) {
1397 Ok(()) => {
1398 println!("guarantees site (SPEC §17c): {} in {}", site.summary, out.display());
1399 0
1400 }
1401 Err(e) => {
1402 eprintln!("site: {e}");
1403 1
1404 }
1405 },
1406 Err(errors) => {
1407 eprintln!("guarantees site (SPEC §17c): {} unresolved reference(s)", errors.len());
1408 for e in errors {
1409 eprintln!(" ERROR: {e}");
1410 }
1411 1
1412 }
1413 }
1414}
1415
1416#[cfg(test)]
1417mod tests {
1418 use super::*;
1419
1420 fn opts() -> Opts {
1421 Opts { commit: "0123456789abcdef".into(), repo: "o/r".into(), snapshot: "main".into(), others: vec![] }
1422 }
1423
1424 fn sources() -> Sources {
1426 let mut crates = BTreeMap::new();
1427 crates.insert(
1428 "txn".to_string(),
1429 (
1430 "planarian-txn".to_string(),
1431 vec![(
1432 "crates/txn/src/chain.rs".to_string(),
1433 "/// Every record follows its predecessor.\npub fn follows(x: u8) -> bool { true }\n".to_string(),
1434 )],
1435 ),
1436 );
1437 Sources {
1438 spec: "# Planarian — Spec v9.9\n\n## 7. Principles and invariants\n\n| I1 | The journal is chained (§12c); X-1 limits it. | A (proof) |\n| I2 | Another one. | A |\n\n## 12. Executor\n\n### 12c. Journal\n\ntext\n\n## 28. Changes from v9.8\n\n- I1: chained now.\n".into(),
1439 history: "# History\n\n## 27. Changes from v9.7\n\n- Invariants I1–I2 added.\n".into(),
1440 ratio: "[[invariant]]\nid = \"I1\"\ndischarged = [\"txn::follows\"]\nassumes = [\"P5\"]\ntested = [\"gym chain-edited\"]\n".into(),
1441 assumptions: "| **P5** SHA-256 is collision-resistant | — |\n\nproof-validity trust (V8–V8) underlies every proof\n\n| **V8** Verus is sound. | every proof |\n".into(),
1442 bypasses: "| X-1 | Root edits the journal | All | Detection | E | all |\n".into(),
1443 catalog: "[[fault]]\nid = \"chain-edited\"\nsummary = \"Edit a record\"\nharness = [\"fake\"]\nscoring = \"must-pass\"\nspec = [\"I1\"]\n[fault.required_post]\nreports = [\"JournalChainBroken\"]\n".into(),
1444 results: Some("PASS chain-edited\n".into()),
1445 crates,
1446 }
1447 }
1448
1449 #[test]
1450 fn a_resolving_repository_builds_the_invariant_page() {
1451 let site = build(&sources(), &opts()).unwrap_or_else(|e| panic!("{e:?}"));
1452 let page = |p: &str| site.files.iter().find(|(f, _)| f == Path::new(p)).map(|(_, t)| t.clone()).unwrap();
1453 let i1 = page("invariants/I1.md");
1454 let order = [
1456 "## How it is proved",
1457 "## What it rests on",
1458 "## How it is tested",
1459 "## Known limits",
1460 "## History",
1461 "## See also",
1462 ];
1463 let at: Vec<usize> = order.iter().map(|h| i1.find(h).unwrap_or_else(|| panic!("{h} missing"))).collect();
1464 assert!(at.windows(2).all(|w| w[0] < w[1]), "sections out of order");
1465 assert!(i1.starts_with("# I1 · The journal is chained"));
1466 assert!(i1.contains("`planarian` @ [`0123456`]"), "names its commit");
1467 assert!(i1.contains("Spec v9.9"));
1468 assert!(i1.contains("[chain.rs:2](https://github.com/o/r/blob/0123456789abcdef/crates/txn/src/chain.rs#L2)"));
1469 assert!(i1.contains("Every record follows its predecessor."), "the doc comment is what it guarantees");
1470 assert!(i1.contains("[P5](../assumptions/P5.md)"));
1471 assert!(i1.contains("Every proof also rests on [V8]"));
1472 assert!(i1.contains("fake: PASS"), "the run's verdict");
1473 assert!(i1.contains("[X-1](../bypasses/X-1.md)"), "an `all` row limits it");
1474 assert!(
1475 i1.contains("| v9.9 | [I1](../invariants/I1.md): chained now |"),
1476 "history from §28, which produced the current spec:\n{i1}"
1477 );
1478 assert!(i1.contains("| v9.8 | Invariants [I1]"), "the changes from v9.7 produced v9.8");
1479 assert!(i1.contains("[§12c](../spec/12c.md)"));
1480 assert!(i1.contains("rustdoc/planarian_txn/chain/index.html"));
1481 assert!(page("assumptions/P5.md").contains("[I1](../invariants/I1.md)"));
1482 assert!(page("index.md").contains("Not yet accounted in `docs/verification-ratio.toml`: I2."));
1483 assert!(site.files.iter().any(|(f, _)| f == Path::new("spec/27.md")), "the history's sections are spec pages");
1484 }
1485
1486 #[test]
1487 fn every_unresolved_reference_fails_the_build() {
1488 type Edit = Box<dyn Fn(&mut Sources)>;
1489 let cases: Vec<(&str, Edit)> = vec![
1490 ("obligation", Box::new(|s: &mut Sources| s.ratio = s.ratio.replace("txn::follows", "txn::gone"))),
1491 ("assumption", Box::new(|s: &mut Sources| s.ratio = s.ratio.replace("P5", "P99"))),
1492 ("bypass cited", Box::new(|s: &mut Sources| s.spec = s.spec.replace("X-1 limits", "X-7 limits"))),
1493 ("no page", Box::new(|s: &mut Sources| s.ratio.push_str("[[invariant]]\nid = \"I40\"\n"))),
1494 (
1495 "gym invariant",
1496 Box::new(|s: &mut Sources| s.catalog = s.catalog.replace("[\"I1\"]", "[\"I1\", \"I77\"]")),
1497 ),
1498 ("gym tested", Box::new(|s: &mut Sources| s.ratio = s.ratio.replace("gym chain-edited", "gym gone-*"))),
1499 (
1500 "bypass column",
1501 Box::new(|s: &mut Sources| {
1502 s.bypasses = "| X-1 | Root edits the journal | All | Detection | E |\n".into()
1503 }),
1504 ),
1505 ("bypass invariant", Box::new(|s: &mut Sources| s.bypasses = s.bypasses.replace("| all |", "| I55 |"))),
1506 (
1507 "dangling link",
1508 Box::new(|s: &mut Sources| s.spec = s.spec.replace("Another one.", "Another one, unlike I30.")),
1509 ),
1510 ];
1511 for (what, edit) in cases {
1512 let mut s = sources();
1513 edit(&mut s);
1514 assert!(build(&s, &opts()).is_err(), "{what}: an unresolved reference was accepted");
1515 }
1516 }
1517
1518 #[test]
1519 fn references_are_read_as_the_spec_writes_them() {
1520 assert_eq!(invariant_refs("invariants (I1–I3) and I10; CI and I/O"), vec![1, 2, 3, 10]);
1521 assert_eq!(section_refs("§12c and §12 step 4, §17a."), vec!["12c", "12", "17a"]);
1522 assert_eq!(bypass_refs("X-12 for writers; B-8, not AB-3"), vec!["X-12", "B-8"]);
1523 assert_eq!(slug_of("22–27. Earlier changes"), "22-27");
1524 assert_eq!(slug_of("12c. Journal headroom"), "12c");
1525 assert_eq!(slug_of("Sources"), "sources");
1526 }
1527
1528 #[test]
1531 fn the_repository_resolves() {
1532 let root = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../.."));
1533 if !root.join(SPEC).exists() {
1534 return;
1535 }
1536 let src = Sources::load(root, None).expect("sources");
1537 if let Err(e) = build(&src, &opts()) {
1538 panic!("the guarantees site does not resolve:\n{}", e.join("\n"));
1539 }
1540 }
1541}