Skip to main content

planarian_gym/
site.rs

1//! `planarian-gym site`: the guarantees site (SPEC §17c). Every page is
2//! computed from the repository at one commit: the invariant table (§7),
3//! docs/verification-ratio.toml, docs/assumptions.md, docs/bypasses.md, the
4//! gym catalog and a run's results, docs/spec-history.md and the source
5//! tree. It writes an mdBook (`book.toml`, `src/`); `mdbook build` turns it
6//! into the site. The build is the check: any reference it cannot resolve
7//! fails it.
8
9use 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;
19/// The crates Verus checks: a function there is a verified fn.
20const VERIFIED: &[&str] = &["policy", "schema", "txn"];
21
22/// What a page needs to know about where it is published.
23pub struct Opts {
24    /// The commit the sources are at, and the repository (`owner/name`).
25    pub commit: String,
26    pub repo: String,
27    /// This snapshot's name (`main`, a tag) and the other published ones.
28    pub snapshot: String,
29    pub others: Vec<String>,
30}
31
32/// Every file the site is built from, read once.
33#[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    /// `PASS id`-style lines from `planarian-gym run`, if a run was given.
42    pub results: Option<String>,
43    /// Per crate directory under `crates/`: the name rustdoc files it under
44    /// (its package, or its binary if it has no library) and its `src/`
45    /// files (path relative to the repository root, text).
46    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            // The name rustdoc files the crate under: the package's for a
57            // library, else its first binary's.
58            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
95// ---------- References in text ----------
96
97/// The invariant numbers a text names: `I18`, and ranges `I1–I14`.
98pub 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            // A range: "I1–I14" or "I1-I14".
121            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
144/// The spec sections a text names: `§12c` → "12c", `§12 step 4` → "12".
145fn 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
162/// Bypass ids a text names: `B-8`, `X-12`.
163fn 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
186/// Markdown with §, invariant and bypass references as links, outside code
187/// spans. `up` leads from the page to the snapshot root.
188fn 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
246/// A table row's cells, the outer pipes dropped.
247fn 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('<', "&lt;").replace('>', "&gt;")
253}
254
255// ---------- The parsed repository ----------
256
257struct 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    /// The Invariants column: ids, or `all`, or empty for `—`.
286    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    /// The rustdoc page of its module, if rustdoc renders it.
306    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
325/// assumptions.md: each bold id with its text and the sites column.
326fn 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                    // `| **V1** text | sites |` or `| **U1** | code | proved in |`.
342                    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                    // A paragraph: `**G1** text. **G2** …`.
350                    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
361/// The proof-validity ids every proof rests on: "proof-validity trust (V7–V11)".
362fn 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
422/// A heading's slug: "12c" for `### 12c. …`, "22-27" for `## 22–27. …`,
423/// else the title in lower case with dashes.
424fn 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
443/// `##` and `###` sections, in order, with their text.
444fn 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
461/// The changes sections, each with the version it produced: "Changes from
462/// v4.4" produced the next one's from-version, the last the current spec.
463fn 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
473/// The part of a changelog bullet about invariant `n`: its clauses that
474/// name it.
475fn 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
482/// Invariant ids in numeric order (I6 before I12).
483fn 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
493/// A short, computed account of what an entry requires afterwards.
494fn 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
525/// `fn name` in a crate's sources: file, line, kind, doc comment, module.
526fn 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
582/// A gym reference in a `tested` list: "gym journal-record-edited", "gym
583/// posture-*"; the entries it names, or None if it is not a gym reference.
584fn 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    // Prose ("gym (every entry …)") names no entry.
588    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
598// ---------- The build ----------
599
600pub struct Site {
601    /// Paths under the book directory, with their contents.
602    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    // §7: the invariants.
611    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    // The ratio rows and baseline.
630    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    // Bypass rows that have no Invariants column are skipped by the parser;
672    // name them here.
673    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    // The catalog.
692    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    // Obligations, located in the source.
738    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    // The spec, with the history's sections, which keep their numbers.
766    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    // Bypass ids the invariants and bypass rows cite must have rows.
779    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(&sections, &version);
787
788    if !errors.is_empty() {
789        errors.sort();
790        errors.dedup();
791        return Err(errors);
792    }
793
794    // ---------- Pages ----------
795    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    // Closed rows no longer limit anything; they stay on their own pages.
824    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    // Reverse index: assumptions.
1057    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    // Reverse index: bypasses.
1083    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    // Reverse index: gym entries.
1110    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    // Reverse index: source files.
1147    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    // The spec, one page per section.
1177    for s in &sections {
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    // API docs.
1185    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    // The index and the gym index.
1195    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    // The book's table of contents.
1226    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 &sections {
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    // The build is the check (§17c): every relative link resolves to a page.
1299    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
1343/// Write the book: `book.toml` and `src/`.
1344pub 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
1365/// `planarian-gym site [--out DIR] [--results FILE] [--commit SHA]
1366/// [--repo OWNER/NAME] [--snapshot NAME] [--others a,b]`: exit 1 with every
1367/// unresolved reference, 0 with the book written.
1368pub 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    /// A small repository that resolves completely.
1425    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        // The prototype's sections, in its order.
1455        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    /// The real repository resolves (where the source tree is present: the
1529    /// Nix package build copies only the crates).
1530    #[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}