specfold-core 0.4.0

Library behind specfold: typed Markdown documents, owners, a derived graph and checks.
Documentation
//! H1: no invisible characters in documents or agent assets. A zero-width space or a
//! bidirectional control hides text from a reviewer while an agent still reads it.

use crate::assets;
use crate::check::context::Ctx;
use crate::check::finding::Finding;
use crate::check::rule::{Rule, RuleInfo};

pub struct HiddenChars;

/// The name of a character that should not appear in Markdown a person reviews.
fn hidden(c: char) -> Option<&'static str> {
    Some(match c {
        '\u{200B}' => "zero-width space",
        '\u{2060}' => "word joiner",
        '\u{FEFF}' => "zero-width no-break space (BOM)",
        '\u{202A}'..='\u{202E}' => "bidirectional embedding or override",
        '\u{2066}'..='\u{2069}' => "bidirectional isolate",
        _ => return None,
    })
}

fn scan(path: &str, text: &str, out: &mut Vec<Finding>) {
    for (i, line) in text.lines().enumerate() {
        for (col, c) in line.chars().enumerate() {
            let Some(name) = hidden(c) else { continue };
            // A byte-order mark on the first line is left alone: removing "the first" one there
            // could take the real one at the start of the file.
            if i == 0 && c == '\u{FEFF}' {
                continue;
            }
            let msg = format!("hidden character U+{:04X} ({name}) at column {}", c as u32, col + 1);
            out.push(Finding::warn("H1", path, i + 1, msg).fix_replace(c.to_string(), ""));
        }
    }
}

impl Rule for HiddenChars {
    fn info(&self) -> &RuleInfo {
        &RuleInfo {
            id: "H1",
            title: "no invisible characters in documents or agent assets",
            explain: "Agent files are scanned whether declared in spec.toml or not: AGENTS.md, CLAUDE.md, GEMINI.md, .github/copilot-instructions.md, .agents/skills/*/SKILL.md, .claude/skills/*/SKILL.md, .cursor/rules/*.mdc, .claude/agents/*.md and .claude/commands/*.md. Zero-width spaces, word joiners, a byte-order mark after the start of the file, and bidirectional embedding, override or isolate controls do not show in an editor or a diff, but agents and tools read them: a link or a command can look right and not be. Each one is reported per line with its code point; `spec fix --rule H1 --write` removes it. Joiners used inside emoji and scripts (U+200C, U+200D) are not reported. Reported as warnings.",
            on_commit: true,
        }
    }

    fn run(&self, ctx: &Ctx, out: &mut Vec<Finding>) {
        for (i, d) in ctx.corpus.docs.iter().enumerate() {
            if !ctx.scope.covers(i) {
                continue;
            }
            for p in std::iter::once(&d.path).chain(&d.chapters) {
                if let Some(t) = ctx.current(p) {
                    scan(p, &t, out);
                }
            }
        }
        for (p, t) in agent_files(ctx) {
            scan(&p, &t, out);
        }
    }
}

/// Declared agent assets and the well-known agent files, each once, within scope. Agent files
/// are read whether or not spec.toml declares them, so the usual ones are included too.
pub(super) fn agent_files(ctx: &Ctx) -> Vec<(String, String)> {
    let mut seen = std::collections::HashSet::new();
    let mut out = vec![];
    for a in assets::load(&ctx.corpus.cfg) {
        seen.insert(a.path.clone());
        if ctx.scope.covers_path(&a.path) {
            out.push((a.path, a.text));
        }
    }
    for p in well_known(&ctx.corpus.cfg.root) {
        if seen.insert(p.clone())
            && ctx.scope.covers_path(&p)
            && let Some(t) = ctx.current(&p)
        {
            out.push((p, t.to_string()));
        }
    }
    out
}

/// Agent instruction and skill files at their usual places, relative to the root.
fn well_known(root: &std::path::Path) -> Vec<String> {
    let mut out: Vec<String> = ["AGENTS.md", "CLAUDE.md", "GEMINI.md", ".github/copilot-instructions.md"]
        .iter()
        .filter(|p| root.join(p).is_file())
        .map(|p| p.to_string())
        .collect();
    let mut listed = |dir: &str, each: &dyn Fn(&std::path::Path) -> Option<String>| {
        let Ok(rd) = std::fs::read_dir(root.join(dir)) else { return };
        let mut found: Vec<String> = rd.flatten().filter_map(|e| each(&e.path())).collect();
        found.sort();
        out.extend(found);
    };
    for skills in [".agents/skills", ".claude/skills"] {
        listed(skills, &|p| {
            let name = p.file_name()?.to_string_lossy().to_string();
            p.join("SKILL.md").is_file().then(|| format!("{skills}/{name}/SKILL.md"))
        });
    }
    for (dir, ext) in [(".cursor/rules", "mdc"), (".claude/agents", "md"), (".claude/commands", "md")] {
        listed(dir, &|p| {
            let name = p.file_name()?.to_string_lossy().to_string();
            (p.is_file() && p.extension()? == ext).then(|| format!("{dir}/{name}"))
        });
    }
    out
}