Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-docgen/src/claims.rs

//! `nornir:claim:` — falsifiable claims a doc source carries and the render
//! path must evaluate. THE GATE REFUSES; it does not warn.
//!
//! # Why this lives in the ENGINE and not in a caller (2026-08-27)
//!
//! It was written in `nornir/src/docs/` (nornir 6ab4bf1) and wired into six
//! wrapper functions there. Those wrappers gate `nornir`'s road. They cannot
//! gate anyone else's, and the forge is someone else: `dwarves` depends on THIS
//! crate directly and calls [`super::layout::render_all`],
//! [`super::layout::render_sources_in_place`], [`super::book::build_book`],
//! [`super::book::build_books`] and [`super::book::build_book_svg_pages`] raw
//! (`dwarves/crates/dwarves/src/docs/mod.rs`), so every book the forge burns
//! took the ungated road. `gate_doc_claims` had ZERO occurrences anywhere in the
//! dwarves tree.
//!
//! MEASURED 2026-08-27, before the move, with a real `dwarves docs render` on a
//! fixture whose one claim was false against its own tree:
//!
//! ```text
//! wrote: …/claimprobe/README.md (211 bytes, 0 sections)
//! <!-- ⚠ GENERATED by nornir from .nornir/README.md — DO NOT EDIT this file -->
//! ```
//!
//! Exit 0, artifact written, DO-NOT-EDIT banner stamped over a sentence the tree
//! contradicts — which is the whole defect the gate was built to stop, still
//! happening, through a second door. A law nobody can execute reads exactly like
//! a law being obeyed.
//!
//! So the gate moved to the ONE place both roads pass through: the entry points
//! of this crate. nornir keeps `docs::claims` / `docs::gate_doc_claims` as
//! re-exports and DROPPED its six wrappers, because a wrapper that adds nothing
//! is a second copy of the rule (L5).
//!
//! # The defect this closes (sweep-25-22)
//!
//! The renderer was a FORMATTER: `.nornir/*.md` prose was copied through with
//! no check of any kind, and the consumer contract even asserted that
//! (gunnar's `readme_version.rs` — generated README equals the source
//! byte-for-byte below the banner). Every existing check (`verify_pdf`,
//! `smudge_check`, the freshness gate) asks whether a document was *produced*;
//! none asked whether a sentence in it is *true*. The live consequence:
//! `.nornir/design.md` called the `NORNIR_MONITOR`/`NORNIR_POLL` removal "a
//! pending code change" for two months after `0c240f9`/`9ea97c4` removed it,
//! while a green unit test in this very repo asserted the removal — a re-render
//! on 2026-08-21 re-blessed the false paragraph with a fresh date and a
//! DO-NOT-EDIT banner. The render is the moment a stale sentence gets
//! re-authorized, so the check lives ON the render path and fails it, exactly
//! the way `gunnar/xtask/src/vendor_patch.rs` fails `cargo xtask ci` when the
//! vendored fork's claims stop matching reality.
//!
//! # Syntax
//!
//! A claim is an HTML comment in any `.nornir/*.md` source (invisible in
//! rendered markdown), one claim per line:
//!
//! ```text
//! <!-- nornir:claim: absent "Environment=NORNIR_MONITOR" in src/bin/nornir.rs -->
//! <!-- nornir:claim: present "fn render_server_unit" in src/bin/nornir.rs -->
//! ```
//!
//! Grammar: `nornir:claim:` VERB `"`NEEDLE`"` `in` PATH — where VERB is
//! `absent` or `present`, NEEDLE is a non-empty literal (no escapes; a needle
//! containing `"` is unsupported), and PATH is repo-relative (never absolute,
//! never `..`), naming a file or a directory (directories are searched
//! recursively, skipping `.git` and `target`). The claim is placed next to the
//! prose it backs; the human binds sentence to claim by adjacency, the gate
//! binds claim to tree by evaluation.
//!
//! # The rules, in order of the constraints that shaped them
//!
//! 1. **Refuse, not warn.** A false claim is an `Err` from every render entry
//!    point in this crate ([`super::layout::render_doc`] / `render_all` /
//!    `render_sources_in_place` and the [`super::book`] builders), so it is an
//!    `Err` for every caller of them — nornir, dwarves and anything published
//!    against this crate. A warning in a render log is how the defect survived
//!    two months.
//! 2. **Falsifiable against the tree at render time.** A claim is about the
//!    existence/absence of bytes in the working tree, nothing else. No
//!    network, no warehouse, no git history — one file read per target, the
//!    `vendor_patch` cost model.
//! 3. **Never blank committed content** (SK190). The gate runs BEFORE
//!    assembly; on refusal nothing has been read for writing and no artifact
//!    is touched.
//! 4. **A claim that cannot fail is worthless.** Every occurrence of the
//!    `nornir:claim:` token is parsed; anything unparseable — unknown verb,
//!    missing quotes, empty needle, absolute or escaping path, a path the tree
//!    does not have — is an ERROR, never a silent pass. (Corollary: prose that
//!    wants to *show* the syntax without asserting it must break the token,
//!    e.g. backtick-split `nornir:claim` + `:`.)
//! 5. **Incremental adoption.** A source with no `nornir:claim:` token
//!    contributes no constraints; a repo with none renders exactly as before.
//!
//! # Known limits (deliberate)
//!
//! Claims are literal-level: `absent "Environment=NORNIR_MONITOR"` cannot see
//! an emission rebuilt via `format!("Environment={key}")`. That is the same
//! limit `vendor_patch`'s source-byte assertions accept, and the same answer
//! applies: the claim buys a tripwire at one-file-read cost, not a proof.
//! A claim whose PATH covers the doc that carries it will match its own marker
//! text; point claims at code, not at `.nornir/`.

use std::path::{Path, PathBuf};

use anyhow::{Result, bail};

/// The token that starts a claim. Every occurrence in a source is parsed.
pub const CLAIM_MARKER: &str = "nornir:claim:";

/// What a claim asserts about its target path.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClaimKind {
    /// The needle must NOT occur anywhere under the path.
    Absent,
    /// The needle MUST occur somewhere under the path.
    Present,
}

impl ClaimKind {
    fn as_str(self) -> &'static str {
        match self {
            ClaimKind::Absent => "absent",
            ClaimKind::Present => "present",
        }
    }
}

/// One parsed claim, with enough provenance to name it in a refusal.
#[derive(Debug, Clone)]
pub struct Claim {
    pub kind: ClaimKind,
    /// The literal to search for. Non-empty.
    pub needle: String,
    /// Repo-relative target, as written in the doc.
    pub path: String,
    /// 1-based line in the source doc where the marker starts.
    pub line: usize,
}

impl std::fmt::Display for Claim {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "{} \"{}\" in {}",
            self.kind.as_str(),
            self.needle,
            self.path
        )
    }
}

/// Parse the text after `nornir:claim:` (already cut at end-of-line / `-->`).
/// `Err` carries the human reason — the caller attaches doc + line.
fn parse_body(body: &str) -> std::result::Result<(ClaimKind, String, String), String> {
    let body = body.trim();
    if body.is_empty() {
        return Err("empty claim — expected `absent|present \"NEEDLE\" in PATH`".into());
    }
    let (verb, rest) = body.split_once(char::is_whitespace).unwrap_or((body, ""));
    let kind = match verb {
        "absent" => ClaimKind::Absent,
        "present" => ClaimKind::Present,
        other => {
            return Err(format!(
                "unknown verb `{other}` — a marker the gate does not recognise is an error, \
                 never a silent pass (known verbs: absent, present)"
            ));
        }
    };
    let rest = rest.trim_start();
    let Some(after_open) = rest.strip_prefix('"') else {
        return Err(format!(
            "needle must be double-quoted: `{verb} \"NEEDLE\" in PATH`"
        ));
    };
    let Some(close) = after_open.find('"') else {
        return Err("unterminated needle quote".into());
    };
    let needle = &after_open[..close];
    if needle.is_empty() {
        return Err("empty needle — a claim that cannot fail is worthless".into());
    }
    let tail = after_open[close + 1..].trim_start();
    let path = match tail.strip_prefix("in") {
        // `in` must be a whole token — `index`/`inside` must not parse.
        Some(after_in) if after_in.starts_with(char::is_whitespace) => after_in.trim(),
        _ => return Err("expected `in PATH` after the needle".into()),
    };
    if path.is_empty() {
        return Err("empty path after `in`".into());
    }
    if Path::new(path).is_absolute() {
        return Err(format!(
            "path `{path}` is absolute — claims are evaluated against the repo root"
        ));
    }
    if Path::new(path).components().any(|c| {
        matches!(
            c,
            std::path::Component::ParentDir | std::path::Component::Prefix(_)
        )
    }) {
        return Err(format!("path `{path}` escapes the repo root"));
    }
    Ok((kind, needle.to_string(), path.to_string()))
}

/// Scan one source text for claims. Malformed markers are an `Err` naming the
/// doc and line; a source with no marker returns an empty set.
pub fn scan(doc: &Path, text: &str) -> Result<Vec<Claim>> {
    let mut claims = Vec::new();
    let mut from = 0usize;
    while let Some(rel) = text[from..].find(CLAIM_MARKER) {
        let at = from + rel;
        let line = text[..at].bytes().filter(|&b| b == b'\n').count() + 1;
        let after = &text[at + CLAIM_MARKER.len()..];
        // A claim is a one-liner; cut at the comment close if it comes first.
        let eol = after.find('\n').unwrap_or(after.len());
        let mut body = &after[..eol];
        if let Some(close) = body.find("-->") {
            body = &body[..close];
        }
        match parse_body(body) {
            Ok((kind, needle, path)) => claims.push(Claim {
                kind,
                needle,
                path,
                line,
            }),
            Err(reason) => bail!(
                "{}:{line}: MALFORMED nornir:claim — {reason} (claim text: `{}`)",
                doc.display(),
                body.trim()
            ),
        }
        from = at + CLAIM_MARKER.len();
    }
    Ok(claims)
}

/// Search one file (lossy UTF-8) for the needle.
fn file_contains(path: &Path, needle: &str) -> std::io::Result<bool> {
    let bytes = std::fs::read(path)?;
    Ok(String::from_utf8_lossy(&bytes).contains(needle))
}

/// Recursively search a directory for the needle; returns the first file that
/// contains it. Skips `.git` and `target` (build noise, not the tree the
/// claim speaks about) and never follows symlinks out of the repo.
fn dir_find(dir: &Path, needle: &str) -> std::io::Result<Option<PathBuf>> {
    let mut stack = vec![dir.to_path_buf()];
    while let Some(d) = stack.pop() {
        for entry in std::fs::read_dir(&d)? {
            let entry = entry?;
            let p = entry.path();
            let ft = entry.file_type()?;
            if ft.is_symlink() {
                continue;
            }
            if ft.is_dir() {
                let name = entry.file_name();
                if name == ".git" || name == "target" {
                    continue;
                }
                stack.push(p);
            } else if ft.is_file() && file_contains(&p, needle)? {
                return Ok(Some(p));
            }
        }
    }
    Ok(None)
}

/// Evaluate one claim against the working tree. `Err` is the human reason the
/// claim no longer holds (or cannot be evaluated — which is equally fatal:
/// a claim naming a path the tree does not have proves nothing).
fn eval(claim: &Claim, repo_root: &Path) -> std::result::Result<(), String> {
    let target = repo_root.join(&claim.path);
    if !target.exists() {
        return Err(format!(
            "target `{}` does not exist in the tree — an unevaluable claim is a failure, \
             not a pass",
            claim.path
        ));
    }
    let found: Option<PathBuf> = if target.is_dir() {
        dir_find(&target, &claim.needle).map_err(|e| format!("reading `{}`: {e}", claim.path))?
    } else {
        match file_contains(&target, &claim.needle) {
            Ok(true) => Some(target.clone()),
            Ok(false) => None,
            Err(e) => return Err(format!("reading `{}`: {e}", claim.path)),
        }
    };
    match (claim.kind, found) {
        (ClaimKind::Absent, Some(hit)) => Err(format!(
            "claimed ABSENT but `{}` contains the needle",
            hit.strip_prefix(repo_root).unwrap_or(&hit).display()
        )),
        (ClaimKind::Present, None) => Err(format!(
            "claimed PRESENT but nothing under `{}` contains the needle",
            claim.path
        )),
        _ => Ok(()),
    }
}

/// ★ THE GATE — evaluate every claim in every top-level `.nornir/*.md` source
/// and REFUSE (Err) if any is false or malformed. Called by every render entry
/// point in [`super::layout`] and [`super::book`] BEFORE any assembly, so a
/// refusal leaves every artifact byte-for-byte untouched (SK190). A repo with no
/// `.nornir/`, or no claims, passes — adoption is per-sentence, not per-repo.
///
/// The whole doc set gates every render (not just the doc being rendered):
/// the book binds the sources into one publication, and a tree that
/// contradicts any claimed sentence must not have anything re-blessed with a
/// fresh render date until the two are reconciled.
pub fn gate_repo(repo_root: &Path) -> Result<()> {
    let dir = repo_root.join(".nornir");
    let Ok(rd) = std::fs::read_dir(&dir) else {
        return Ok(()); // no sources, nothing claimed
    };
    let mut sources: Vec<PathBuf> = rd
        .flatten()
        .map(|e| e.path())
        .filter(|p| {
            p.is_file()
                && p.extension()
                    .and_then(|e| e.to_str())
                    .is_some_and(|e| e.eq_ignore_ascii_case("md"))
        })
        .collect();
    sources.sort();

    let mut failures: Vec<String> = Vec::new();
    for src in &sources {
        let text = std::fs::read_to_string(src)
            .map_err(|e| anyhow::anyhow!("read {}: {e}", src.display()))?;
        let rel = src.strip_prefix(repo_root).unwrap_or(src);
        // Malformed markers refuse immediately — an unrecognised marker must
        // never be treated as satisfied.
        let claims = scan(rel, &text)?;
        for claim in &claims {
            if let Err(reason) = eval(claim, repo_root) {
                failures.push(format!(
                    "  {}:{} — claim `{claim}`: {reason}",
                    rel.display(),
                    claim.line
                ));
            }
        }
    }
    if failures.is_empty() {
        return Ok(());
    }
    bail!(
        "docs claims REFUSED — {} claim{} no longer hold{} against the tree; \
         nothing was rendered and no artifact was touched:\n{}\n\
         fix the prose AND its claim, or fix the tree — a false sentence must not be \
         re-blessed with a fresh render date and a DO-NOT-EDIT banner",
        failures.len(),
        if failures.len() == 1 { "" } else { "s" },
        if failures.len() == 1 { "s" } else { "" },
        failures.join("\n")
    )
}

// Tests live in `tests/claims_gate.rs` (integration target). They drive the
// engine's OWN entry points — the road dwarves takes — rather than a caller's
// wrapper, which is the whole reason this module moved here. nornir's
// `tests/docs_claims.rs` still drives the same code through its re-exports, so
// both doors are asserted.