specfold-core 0.3.0

Library behind specfold: typed Markdown documents, owners, a derived graph and checks.
Documentation
//! L6: documents cited from outside the spec corpus (code comments, READMEs, agent instruction
//! files) still resolve. Old paths keep spreading through text agents read, so a moved or
//! retired target is reported where it is cited, not only inside documents.

use crate::check::context::Ctx;
use crate::check::finding::Finding;
use crate::check::rule::{Rule, RuleInfo};
use crate::check::scope::Range;
use crate::graph::Resolution;
use crate::markdown::body::{path_cite_re, wiki_re};
use crate::workspace::owners::{PathMatch, matcher};
use crate::workspace::vcs;
use ignore::WalkBuilder;
use regex::Regex;
use std::collections::{BTreeSet, HashMap};
use std::sync::OnceLock;

pub struct CitesOutside;

/// Statuses whose documents should no longer be cited as current guidance.
const PAST: &[&str] = &["superseded", "archived", "rejected"];

fn path_token_re() -> &'static Regex {
    static R: OnceLock<Regex> = OnceLock::new();
    R.get_or_init(|| Regex::new(r"(?:[A-Za-z0-9_.@-]+/)+[A-Za-z0-9_.@-]+").unwrap())
}

/// `type:owner/key`: outside documents, `[[…]]` is also TOML, shell and array syntax, so only
/// this shape (or a known alias) is read as a reference that must resolve.
fn id_shaped(target: &str) -> bool {
    static R: OnceLock<Regex> = OnceLock::new();
    R.get_or_init(|| Regex::new(r"^[a-z][a-z0-9-]*:[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(#\S+)?$").unwrap()).is_match(target)
}

struct Files {
    include: Vec<PathMatch>,
    exclude: Vec<PathMatch>,
}

impl Files {
    fn wants(&self, ctx: &Ctx, p: &str) -> bool {
        self.include.iter().any(|m| m.is_match(p))
            && !self.exclude.iter().any(|m| m.is_match(p))
            && !ctx.graph.by_path.contains_key(p)
    }
}

/// The files to read and, for staged or ranged runs, the lines each change added.
fn targets(ctx: &Ctx, files: &Files) -> Vec<(String, Option<BTreeSet<usize>>)> {
    let root = &ctx.corpus.cfg.root;
    let added = match &ctx.scope.range {
        Range::All => {
            let walk = WalkBuilder::new(root).hidden(false).build().flatten();
            return walk
                .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
                .filter_map(|e| e.path().strip_prefix(root).ok().map(|p| p.to_string_lossy().replace('\\', "/")))
                .filter(|p| files.wants(ctx, p))
                .map(|p| (p, None))
                .collect();
        }
        Range::Staged => vcs::added_lines(root, None),
        Range::Changed(base) => vcs::added_lines(root, Some(base)),
    };
    let added: HashMap<String, Vec<usize>> = added.unwrap_or_default();
    added
        .into_iter()
        .filter(|(p, _)| files.wants(ctx, p))
        .map(|(p, lines)| (p, Some(lines.into_iter().collect())))
        .collect()
}

fn judge(ctx: &Ctx, path: &str, line: usize, written: &str, target: &str, by_path: bool) -> Option<Finding> {
    let id_of = |idx: usize| ctx.corpus.docs[idx].id.to_string();
    if !by_path {
        use crate::remotes::Remote;
        let problem = match crate::remotes::resolve(&ctx.corpus.cfg, target) {
            Remote::Local => None,
            Remote::Absent { .. } | Remote::Found { .. } => return None,
            Remote::Missing { name, id } => Some(format!("{written} names no document in the remote {name} ({id})")),
            Remote::MissingAnchor { name, id, anchor } => Some(format!("{name}:{id} has no anchor #{anchor}")),
            Remote::Ambiguous { name, ids } => {
                Some(format!("{written} is ambiguous in the remote {name}: {}", ids.join(", ")))
            }
        };
        if let Some(m) = problem {
            return Some(Finding::error("L6", path, line, m));
        }
    }
    match ctx.graph.resolve(target) {
        Resolution::Doc { idx, anchor, via_alias } => {
            let id = match anchor {
                Some(a) => format!("{}#{a}", id_of(idx)),
                None => id_of(idx),
            };
            let status = ctx.corpus.docs[idx].status().unwrap_or_default();
            if via_alias || by_path {
                let how = if via_alias { "an old name" } else { "a path" };
                return Some(
                    Finding::warn("L6", path, line, format!("{written} is {how} for {id}"))
                        .fix_replace(written.to_string(), format!("[[{id}]]")),
                );
            }
            PAST.contains(&status.as_str())
                .then(|| Finding::warn("L6", path, line, format!("{written} cites {id}, which is {status}")))
        }
        Resolution::Ambiguous(list) => {
            let c: Vec<String> = list.into_iter().map(id_of).collect();
            Some(Finding::error("L6", path, line, format!("{written} is ambiguous")).with_candidates(c))
        }
        Resolution::MissingAnchor { idx, anchor } => {
            Some(Finding::error("L6", path, line, format!("{} has no anchor #{anchor}", id_of(idx))))
        }
        Resolution::Missing if by_path || !id_shaped(target) => None,
        Resolution::Missing => Some(Finding::error("L6", path, line, format!("{written} names no document"))),
    }
}

impl Rule for CitesOutside {
    fn info(&self) -> &RuleInfo {
        &RuleInfo {
            id: "L6",
            title: "citations outside documents still resolve",
            explain: "Files listed in [cites] paths (code, READMEs, agent instructions) are read for [[id]] references, paths into spec directories and old paths from the alias table. A reference that names nothing is an error, and so is `[[<remote>:<id>]]` that names nothing in a configured remote whose directory is present (an absent remote directory is skipped); an old name or a path is reported with the id to write; a citation of a superseded, archived or rejected document is reported. Staged and ranged runs read only the lines the change adds, and lines matching [cites] history are left alone.",
            on_commit: true,
        }
    }

    fn run(&self, ctx: &Ctx, out: &mut Vec<Finding>) {
        let cfg = &ctx.corpus.cfg.raw.cites;
        if cfg.paths.is_empty() {
            return;
        }
        let files = Files {
            include: cfg.paths.iter().filter_map(|p| matcher(p)).collect(),
            exclude: cfg.exclude.iter().filter_map(|p| matcher(p)).collect(),
        };
        let history: Vec<Regex> = cfg.history.iter().filter_map(|r| Regex::new(r).ok()).collect();
        let spec_paths = path_cite_re(ctx.corpus.cfg.spec_dir());
        for (path, only) in targets(ctx, &files) {
            let Some(text) = ctx.current(&path) else { continue };
            for (n, line) in text.lines().enumerate() {
                let no = n + 1;
                if only.as_ref().is_some_and(|set| !set.contains(&no)) || history.iter().any(|h| h.is_match(line)) {
                    continue;
                }
                let mut seen = BTreeSet::new();
                for c in wiki_re().captures_iter(line) {
                    let target = c[1].trim().to_string();
                    seen.insert(target.clone());
                    out.extend(judge(ctx, &path, no, &c[0], &target, false));
                }
                let tokens = spec_paths.find_iter(line).chain(path_token_re().find_iter(line));
                for m in tokens {
                    let t = m.as_str().trim_end_matches(['.', ',', ':', ';']);
                    if !seen.insert(t.to_string()) {
                        continue;
                    }
                    let known = ctx.graph.by_path.contains_key(t) || ctx.graph.by_alias.contains_key(t);
                    if known {
                        out.extend(judge(ctx, &path, no, t, t, true));
                    }
                }
            }
        }
    }
}