headwater-check 0.4.0

Generates the rules from the taxonomy, runs them, computes coverage against the census, and keys each instance on what it read and on the clock it was handed
Documentation
// SPDX-License-Identifier: Apache-2.0
//! A Document-origin check: a page for an adopter that points at a file only
//! this repository holds.
//!
//! # The decision this implements
//!
//! [HW-DR-0077](../../../../docs/decisions/0077-the-consumer-surface-is-what-an-adopter-receives-runs-and-must-have-installed-and-it-is-a-closed-and-declared-list.md)
//! splits the tree into four populations and rules on the fourth: "No page for
//! an adopter instructs a file of this population. Such a page may cite one as
//! the way this repository does a thing, in a passage that says so."
//!
//! The record's Consequences say why a rule and not a review: "Nothing checks
//! this record." [#933](https://github.com/headwater-ai/headwater/issues/933)
//! refused a wide rule over a list of one entry, because such a list reports
//! zero by construction. So nothing here names a page or a root. The taxonomy
//! declares both under `surface`, and a corpus that declares no surface
//! generates no instance.
//!
//! # What counts as pointing at a file
//!
//! Three places, and none of them is prose. A path in a sentence is a mention,
//! and a mention is not an instruction.
//!
//! - an inline code span,
//! - a fenced or indented code block, which is where a command to run sits,
//! - a front-matter value that is one path and nothing else, which is how a
//!   `governs` edge names its target.
//!
//! A token counts when it opens with a declared local root, with `./` and then
//! one, or with a shell variable such as `$ROOT/` or `${ROOT}/` and then one,
//! because a command that names the root of the checkout by a variable still
//! runs the file under it. `$HOME` is the exception: a path under the home
//! directory of the user is not a path of this repository. A quoted variable,
//! as in `"$ROOT"/tools/x.sh`, is not read, and the contract says so. Code inside a block quote counts too: a quotation
//! on a page for an adopter is still something the page shows them to run.
//! Raw HTML is left alone, because an HTML comment is a note to the next editor
//! and not a page an adopter reads.
//!
//! # A passage that says so
//!
//! The record lets a page cite this repository's way of doing a thing. The
//! marker for that passage is the suppression directive of spec 4, with
//! `reason=accepted_deviation`, because it already carries an expiry and a
//! reason from a closed set and the runner already counts it. A second marker
//! would be a second hatch with neither.
//!
//! # What it does not read
//!
//! A page that the adopter list does not name has no instance of the rule.
//! The list is read at the generation step, through
//! [`DocumentCheck::selects`], and not in the evaluation.
//!
//! A document the census does not type reaches no document check, so a page on
//! the list that is not typed goes unread. `README.md` at the root is one. The
//! `commands` member of `surface`, the second half of #976, is read by
//! [`crate::command`], which holds the shell blocks of the same pages.

use crate::command::SHELLS;
use crate::finding::{Finding, Severity};
use crate::instance::Outcome;
use crate::scope::{DocumentCheck, DocumentView};
use crate::shape::Shape;
use headwater_doc::body::{BlockKind, Ownership};
use headwater_yaml::{Spanned, Value};

pub const RULE: &str = "surface.local_path.instructed";

/// The check, generated from the `surface` block of the taxonomy.
pub struct LocalPath {
    adopter_documents: Vec<String>,
    local_roots: Vec<String>,
    programs: Vec<String>,
}

impl LocalPath {
    /// The generation step. A surface that names no adopter document or no
    /// local root has nothing to hold a page against, and generates no
    /// instance.
    pub fn over(shape: &Shape) -> Self {
        LocalPath {
            adopter_documents: shape.surface.adopter_documents.clone(),
            local_roots: shape.surface.local_roots.clone(),
            programs: shape.surface.programs.clone(),
        }
    }

    fn declared(&self) -> bool {
        !self.adopter_documents.is_empty() && !self.local_roots.is_empty()
    }

    fn for_an_adopter(&self, path: &str) -> bool {
        self.adopter_documents
            .iter()
            .any(|glob| glob_matches(glob, path))
    }

    /// The local root a token opens with, if it opens with one, or the root a
    /// token is when it names the root with no trailing `/` and is not the
    /// name of a program the surface declares. `bare_counts` says whether the
    /// place the token stands in reads a bare root at all: a shell block and a
    /// code span do, and a value in any other block or in front matter does
    /// not (#1085).
    fn root_of(&self, token: &str, bare_counts: bool) -> Option<&str> {
        let token = strip_variable(token);
        let token = token.strip_prefix("./").unwrap_or(token);
        self.local_roots
            .iter()
            .find(|root| {
                token.starts_with(root.as_str()) || (bare_counts && self.bare(token, root))
            })
            .map(String::as_str)
    }

    /// Whether a token is a root with no trailing `/` and no declared program.
    fn bare(&self, token: &str, root: &str) -> bool {
        token == root.trim_end_matches('/') && !self.programs.iter().any(|name| name == token)
    }
}

impl DocumentCheck for LocalPath {
    const RULE: &'static str = self::RULE;
    /// Edition two, on 2026-09-23: a shell variable before a local root and a
    /// code span inside a block quote both count. The lock does not move
    /// with either, so a warm cache would serve edition one's pass.
    ///
    /// Edition three: `$HOME/` no longer counts as a variable before a local
    /// root, so a user-level path is not reported.
    ///
    /// Selecting the adopter list at the generation step (#1051) did not
    /// raise the edition: the verdict on every page that keeps an instance
    /// is the one edition three reached, so a cached verdict stays true.
    ///
    /// Edition four (#976): a token that is a local root with its trailing
    /// `/` removed counts, so the bare directory of a `git config
    /// core.hooksPath` line is reported. A bare token that the surface
    /// declares as a program, as `mkdocs` is, names that program and does not
    /// count.
    ///
    /// Edition five (#1085): a bare token counts only in a shell block and in
    /// a code span. In a fenced block with a different info string, and in a
    /// front-matter value, a bare word is a value of the adopter's own
    /// configuration, as `site` is in `site_dir: site`. The verdict on such a
    /// page moves from failed to passed, so a warm cache would otherwise
    /// serve edition four's failure.
    const VERSION: u32 = 5;
    const NEEDS_BODY: bool = true;

    fn instantiates(&self, _kind: &str) -> bool {
        self.declared()
    }

    fn selects(&self, path: &str) -> bool {
        self.for_an_adopter(path)
    }

    fn evaluate(&self, view: &DocumentView<'_>) -> Outcome {
        if !self.declared() || !self.for_an_adopter(view.path()) {
            return Outcome::Passed;
        }
        let mut findings = Vec::new();
        let mut report = |line: usize, column: usize, token: &str, root: &str, place: &str| {
            findings.push(Finding {
                rule: self::RULE,
                severity: Severity::Error,
                obligation: None,
                path: view.path().to_string(),
                line,
                column,
                message: format!(
                    "{place} names `{token}`, and `{root}` holds files only this repository has (HW-DR-0077, population 4)"
                ),
                remediation: format!(
                    "name the `headwater` verb or the declared prerequisite that does this, or mark the passage as how this repository does it with `<!-- headwater allow={} scope=block reason=accepted_deviation ... -->`",
                    self::RULE
                ),
                patch: None,
            });
        };

        for (key, node) in view
            .facets()
            .iter()
            .map(|entry| (&entry.key.value, &entry.value))
        {
            for item in paths_in(node) {
                let Some(text) = item.value.as_scalar().map(|scalar| scalar.text.as_str()) else {
                    continue;
                };
                if text.contains(char::is_whitespace) {
                    continue;
                }
                if let Some(root) = self.root_of(text, false) {
                    report(
                        item.span.start.line,
                        item.span.start.col,
                        text,
                        root,
                        &format!("the `{key}` front-matter value"),
                    );
                }
            }
        }

        for block in view
            .body()
            .map(|body| body.blocks.as_slice())
            .unwrap_or(&[])
        {
            if block.kind == BlockKind::Html {
                continue;
            }
            let place = match block.kind {
                BlockKind::Code => "a code block",
                _ => "a code span",
            };
            // A bare root is an argument a shell command gives. In a fence
            // that is not a shell, the same word is the adopter's own value.
            let bare_counts = match block.kind {
                BlockKind::Code => block
                    .info
                    .as_deref()
                    .is_some_and(|info| SHELLS.contains(&info)),
                _ => true,
            };
            for run in &block.runs {
                if run.ownership != Ownership::Code || run.text.trim_start().starts_with('<') {
                    continue;
                }
                for (offset, line) in run.text.split('\n').enumerate() {
                    for token in line.split(|c: char| {
                        c.is_whitespace()
                            || matches!(c, '`' | '"' | '\'' | '(' | ')' | '=' | ',' | ';')
                    }) {
                        if let Some(root) = self.root_of(token, bare_counts) {
                            let line_number = run.span.start.line + offset;
                            let column = match offset {
                                0 => run.span.start.col,
                                _ => 1,
                            };
                            report(line_number, column, token, root, place);
                        }
                    }
                }
            }
        }
        findings.sort_by_key(|finding| (finding.line, finding.column));
        Outcome::failed(findings)
    }
}

/// A token with a leading `$NAME/` or `${NAME}/` removed, and the token
/// itself when it opens with neither.
fn strip_variable(token: &str) -> &str {
    let Some(rest) = token.strip_prefix('$') else {
        return token;
    };
    let rest = match rest.strip_prefix('{') {
        Some(braced) => match braced.split_once('}') {
            Some((name, tail)) if !name.is_empty() && name != "HOME" => tail,
            _ => return token,
        },
        None => {
            let end = rest
                .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
                .unwrap_or(rest.len());
            match &rest[..end] {
                "" | "HOME" => return token,
                _ => &rest[end..],
            }
        }
    };
    rest.strip_prefix('/').unwrap_or(token)
}

/// Every scalar a front-matter value holds, through nested lists and mappings,
/// because a `governs` edge sits under `relations` and may group its targets
/// in an inner list.
fn paths_in(node: &Spanned<Value>) -> Vec<&Spanned<Value>> {
    match (&node.value.as_seq(), &node.value.as_map()) {
        (Some(items), _) => items.iter().flat_map(paths_in).collect(),
        (None, Some(map)) => map
            .iter()
            .flat_map(|entry| paths_in(&entry.value))
            .collect(),
        (None, None) => vec![node],
    }
}

/// `*` matches inside one path segment and `**` matches across any number.
pub fn glob_matches(glob: &str, path: &str) -> bool {
    let glob: Vec<&str> = glob.split('/').collect();
    let path: Vec<&str> = path.split('/').collect();
    segments(&glob, &path)
}

fn segments(glob: &[&str], path: &[&str]) -> bool {
    match glob.split_first() {
        None => path.is_empty(),
        Some((&"**", rest)) => (0..=path.len()).any(|skip| segments(rest, &path[skip..])),
        Some((first, rest)) => match path.split_first() {
            Some((segment, tail)) => segment_matches(first, segment) && segments(rest, tail),
            None => false,
        },
    }
}

fn segment_matches(glob: &str, text: &str) -> bool {
    match glob.split_once('*') {
        None => glob == text,
        Some((head, tail)) => {
            text.len() >= head.len()
                && text.starts_with(head)
                && (0..=text.len() - head.len())
                    .filter(|at| text.is_char_boundary(head.len() + at))
                    .any(|at| segment_matches(tail, &text[head.len() + at..]))
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{glob_matches, strip_variable};

    #[test]
    fn a_shell_variable_before_a_path_is_removed() {
        assert_eq!(strip_variable("$ROOT/tools/x.sh"), "tools/x.sh");
        assert_eq!(strip_variable("${ROOT}/tools/x.sh"), "tools/x.sh");
        assert_eq!(strip_variable("$ROOT"), "$ROOT");
        assert_eq!(strip_variable("$/tools"), "$/tools");
        assert_eq!(strip_variable("tools/x.sh"), "tools/x.sh");
        // The home directory of the user is not this repository.
        assert_eq!(strip_variable("$HOME/.claude/x"), "$HOME/.claude/x");
        assert_eq!(strip_variable("${HOME}/.claude/x"), "${HOME}/.claude/x");
    }

    #[test]
    fn a_glob_matches_inside_a_segment_and_across_segments() {
        assert!(glob_matches(
            "docs/interfaces/*.md",
            "docs/interfaces/headwater-check.md"
        ));
        assert!(!glob_matches(
            "docs/interfaces/*.md",
            "docs/interfaces/sub/x.md"
        ));
        assert!(glob_matches("docs/tutorials/**", "docs/tutorials/a/b.md"));
        assert!(glob_matches("README.md", "README.md"));
        assert!(!glob_matches(
            "docs/how-to/relocate-*.md",
            "docs/how-to/wire-x.md"
        ));
    }
}