doctrine 0.12.0

Project tooling CLI
// SPDX-License-Identifier: GPL-3.0-only
//! Pure leaf (ADR-001 D4): the exclusion core — tier classification, allowlist parsing,
//! copy selection, and the static smell test. No disk, git, clock, or rng.

use glob::Pattern;

use crate::globmatch::glob_matches;

// ---------------------------------------------------------------------------
// Tier & withheld globs — the structured authority
// ---------------------------------------------------------------------------

/// The coordination/runtime tier a fork must never receive, categorised.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Tier {
    /// `.doctrine/state/**` — phase sheets, the boot snapshot.
    State,
    /// `.doctrine/slice/*/phases` — per-slice symlink into the state tree.
    PhaseLink,
    /// `**/handover.md` — disposable agent context.
    Handover,
    /// `.doctrine/slice/*/inquisition.md` — disposable adversarial-review scratch.
    Inquisition,
    /// `.doctrine/slice/*/research/**` — disposable per-slice research scratch.
    Research,
    /// `.doctrine/memory/{index,embeddings,state,shipped}` — regenerable caches.
    MemoryCache,
    /// `.doctrine/dispatch/**` — orchestrator dispatch coordination scratch
    /// (candidates, boundaries, handover). Committed ledger rows (`journal.toml`)
    /// are force-added by dispatch itself; the rest is fork-withheld runtime.
    Dispatch,
}

impl std::fmt::Display for Tier {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let name = match self {
            Tier::State => "state",
            Tier::PhaseLink => "phase-link",
            Tier::Handover => "handover",
            Tier::Inquisition => "inquisition",
            Tier::Research => "research",
            Tier::MemoryCache => "memory-cache",
            Tier::Dispatch => "dispatch",
        };
        f.write_str(name)
    }
}

/// One categorised withhold glob.
#[derive(Debug)]
pub(crate) struct Withhold {
    pub(crate) tier: Tier,
    pub(crate) glob: &'static str,
}

const fn w(tier: Tier, glob: &'static str) -> Withhold {
    Withhold { tier, glob }
}

/// The single structured authority (design §3 F4): every glob here is pinned to
/// a runtime-tier line in `.gitignore` (24, 31–38). The parity test
/// (`every_runtime_gitignore_glob_is_classified`) fails CI if a new runtime glob
/// lands in `.gitignore` without a home here or in [`DERIVED_RUNTIME`].
pub(crate) const WITHHELD: &[Withhold] = &[
    w(Tier::State, ".doctrine/state/**"),
    w(Tier::PhaseLink, ".doctrine/slice/*/phases"),
    w(Tier::Handover, "**/handover.md"),
    w(Tier::Inquisition, ".doctrine/slice/*/inquisition.md"),
    w(Tier::Research, ".doctrine/slice/*/research/**"),
    w(Tier::MemoryCache, ".doctrine/memory/index/**"),
    w(Tier::MemoryCache, ".doctrine/memory/embeddings/**"),
    w(Tier::MemoryCache, ".doctrine/memory/state/**"),
    w(Tier::MemoryCache, ".doctrine/memory/shipped/**"),
    w(Tier::Dispatch, ".doctrine/dispatch/**"),
];

/// Gitignored-but-*derived* trees: regenerated by `doctrine install` in the fork,
/// never copied and not a hazard — documented, deliberately out of [`WITHHELD`]
/// (design §3). Classified so the parity test does not flag them unclassified.
/// Only the parity test consumes it today (the `select_copies` guarantee needs no
/// derived list — derived paths simply fall through as unallowlisted/uncopied);
/// the expectation self-clears the moment a non-test consumer appears.
#[cfg_attr(
    not(test),
    expect(
        dead_code,
        reason = "classification authority; only the .gitignore parity test reads it so far (SL-029)"
    )
)]
pub(crate) const DERIVED_RUNTIME: &[&str] = &[".doctrine/skills/*", ".doctrine/agents/*"];

// ---------------------------------------------------------------------------
// Allowlist (the documented `glob` subset, design §3 M6)
// ---------------------------------------------------------------------------

/// A parsed `.worktreeinclude`: the documented subset — blank/`#`-comment lines,
/// literal repo-relative paths, and `* ** ?` patterns. No `!` negation, no
/// anchoring (rejected at parse).
#[derive(Debug)]
pub(crate) struct Allowlist {
    pub(crate) patterns: Vec<Pattern>,
}

/// Why a `.worktreeinclude` line is unsupported in v1.
#[derive(Debug, thiserror::Error)]
pub(crate) enum ParseError {
    /// `!`-negation — unsupported (a project must not rely on un-implemented semantics).
    #[error("line {line}: negation (`!`) is unsupported in .worktreeinclude v1: `{raw}`")]
    Negation { line: usize, raw: String },
    /// Leading-`/` anchoring — unsupported.
    #[error("line {line}: anchoring (leading `/`) is unsupported in .worktreeinclude v1: `{raw}`")]
    Anchoring { line: usize, raw: String },
    /// Not a valid `glob` pattern.
    #[error("line {line}: invalid glob `{raw}`: {source}")]
    BadGlob {
        line: usize,
        raw: String,
        #[source]
        source: glob::PatternError,
    },
}

/// Parse `.worktreeinclude` text into an [`Allowlist`], rejecting `!`/anchoring
/// with a clear error so a project cannot silently rely on unsupported semantics.
pub(crate) fn parse_allowlist(text: &str) -> Result<Allowlist, ParseError> {
    let mut patterns = Vec::new();
    for (i, raw_line) in text.lines().enumerate() {
        let line = raw_line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        let n = i + 1;
        if line.starts_with('!') {
            return Err(ParseError::Negation {
                line: n,
                raw: line.to_string(),
            });
        }
        if line.starts_with('/') {
            return Err(ParseError::Anchoring {
                line: n,
                raw: line.to_string(),
            });
        }
        let pat = Pattern::new(line).map_err(|source| ParseError::BadGlob {
            line: n,
            raw: line.to_string(),
            source,
        })?;
        patterns.push(pat);
    }
    Ok(Allowlist { patterns })
}

// ---------------------------------------------------------------------------
// The exclusion core (pure)
// ---------------------------------------------------------------------------

/// The tier a repo-relative path belongs to, if it is withheld. Non-fallible:
/// the static [`WITHHELD`] globs are proven to compile by `withheld_globs_all_compile`.
pub(crate) fn is_withheld(rel: &str) -> Option<Tier> {
    WITHHELD.iter().find_map(|item| {
        Pattern::new(item.glob)
            .ok()
            .filter(|p| glob_matches(p, rel))
            .map(|_p| item.tier)
    })
}

/// A withheld candidate: the path that matched the allowlist but is skipped.
#[derive(Debug)]
pub(crate) struct Withheld {
    pub(crate) path: String,
    pub(crate) tier: Tier,
}

/// The partition of allowlisted candidates into those to copy and those withheld.
#[derive(Debug)]
pub(crate) struct Selection {
    pub(crate) copy: Vec<String>,
    pub(crate) withheld: Vec<Withheld>,
}

/// Partition gitignored `candidates`: a path is copied iff it matches the
/// allowlist AND is not withheld; a withheld match is dropped (skip+warn) **even
/// under a broad `*`/`**`** — this is the copy-time guarantee (design §3).
pub(crate) fn select_copies(allow: &Allowlist, candidates: &[String]) -> Selection {
    let mut copy = Vec::new();
    let mut withheld = Vec::new();
    for cand in candidates {
        if !allow.patterns.iter().any(|p| glob_matches(p, cand)) {
            continue;
        }
        match is_withheld(cand) {
            Some(tier) => withheld.push(Withheld {
                path: cand.clone(),
                tier,
            }),
            None => copy.push(cand.clone()),
        }
    }
    Selection { copy, withheld }
}

/// A static smell-test hit: an allowlist pattern that *names* a withheld tier.
#[derive(Debug)]
pub(crate) struct Violation {
    pub(crate) pattern: String,
    pub(crate) tier: Tier,
}

/// A concrete representative path for a glob: replace each wildcard with a literal
/// segment so a pattern that "would pull" the tier matches it. `**`→`x`, `*`→`x`,
/// `?`→`x`. e.g. `.doctrine/state/**` → `.doctrine/state/x`.
fn representative(glob: &str) -> String {
    glob.replace("**", "x").replace(['*', '?'], "x")
}

/// Patterns that *name* a withheld glob (a [`WITHHELD`] representative matches the
/// pattern). The static smell test behind `check-allowlist` and `provision`'s
/// fail-closed gate. **Green is not completeness (F7)** — [`select_copies`] is the
/// guarantee; this only proves no pattern *names* the tier.
pub(crate) fn allowlist_violations(allow: &Allowlist) -> Vec<Violation> {
    let mut out = Vec::new();
    for item in WITHHELD {
        let rep = representative(item.glob);
        for pat in &allow.patterns {
            if glob_matches(pat, &rep) {
                out.push(Violation {
                    pattern: pat.as_str().to_string(),
                    tier: item.tier,
                });
            }
        }
    }
    out
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    // --- T1: parse_allowlist (SL-029 PHASE-03, VT-1) ---

    #[test]
    fn parse_allowlist_accepts_each_supported_class() {
        let text = "# a comment\n\nsrc/main.rs\nconfig/*.toml\n**/*.md\nfile?.txt\n";
        let allow = parse_allowlist(text).unwrap();
        assert_eq!(allow.patterns.len(), 4);
    }

    #[test]
    fn parse_allowlist_rejects_negation() {
        let err = parse_allowlist("src/*\n!secret").unwrap_err();
        assert!(matches!(err, ParseError::Negation { .. }));
    }

    #[test]
    fn parse_allowlist_rejects_anchoring() {
        let err = parse_allowlist("/anchored").unwrap_err();
        assert!(matches!(err, ParseError::Anchoring { .. }));
    }

    #[test]
    fn parse_allowlist_rejects_bad_glob() {
        let err = parse_allowlist("a[b").unwrap_err();
        assert!(matches!(err, ParseError::BadGlob { .. }));
    }

    // --- T3: is_withheld + select_copies (VT-3) ---

    #[test]
    fn is_withheld_classifies_each_tier() {
        assert_eq!(is_withheld(".doctrine/state/boot.md"), Some(Tier::State));
        assert_eq!(
            is_withheld(".doctrine/state/slice/029/phases/phase-01.md"),
            Some(Tier::State)
        );
        // SL-152 PHASE-03 VT-2: the arm-spawn arming dir is withheld State tier, so
        // the provision copier never copies it into a worker fork.
        assert_eq!(
            is_withheld(".doctrine/state/dispatch/spawn/base"),
            Some(Tier::State)
        );
        assert_eq!(
            is_withheld(".doctrine/slice/029/phases"),
            Some(Tier::PhaseLink)
        );
        assert_eq!(
            is_withheld(".doctrine/slice/029/handover.md"),
            Some(Tier::Handover)
        );
        assert_eq!(
            is_withheld(".doctrine/memory/index/foo"),
            Some(Tier::MemoryCache)
        );
        assert_eq!(is_withheld("src/main.rs"), None);
        // derived, not withheld
        assert_eq!(is_withheld(".doctrine/skills/code-review/SKILL.md"), None);
    }

    #[test]
    fn select_copies_withholds_tier_files_under_a_broad_glob() {
        let allow = parse_allowlist("**").unwrap();
        let candidates = vec![
            "src/main.rs".to_string(),
            ".doctrine/state/boot.md".to_string(),
            ".doctrine/slice/029/handover.md".to_string(),
        ];
        let sel = select_copies(&allow, &candidates);
        assert_eq!(sel.copy, ["src/main.rs"]);
        let held: Vec<&str> = sel.withheld.iter().map(|h| h.path.as_str()).collect();
        assert!(held.contains(&".doctrine/state/boot.md"));
        assert!(held.contains(&".doctrine/slice/029/handover.md"));
    }

    #[test]
    fn select_copies_skips_unallowlisted_candidates() {
        let allow = parse_allowlist("docs/**").unwrap();
        let candidates = vec!["src/main.rs".to_string(), "docs/guide.md".to_string()];
        let sel = select_copies(&allow, &candidates);
        assert_eq!(sel.copy, ["docs/guide.md"]);
        assert!(sel.withheld.is_empty());
    }

    // --- T4: allowlist_violations (VT-2) ---

    #[test]
    fn allowlist_violations_flags_a_tier_naming_pattern() {
        let allow = parse_allowlist(".doctrine/state/*").unwrap();
        let v = allowlist_violations(&allow);
        assert!(!v.is_empty());
        assert_eq!(v[0].tier, Tier::State);
    }

    #[test]
    fn allowlist_violations_passes_benign_patterns() {
        let allow = parse_allowlist("src/**\nconfig/app.toml").unwrap();
        assert!(allowlist_violations(&allow).is_empty());
    }

    #[test]
    fn allowlist_violations_flags_a_broad_wildcard() {
        // `**` names every tier — the static gate fails closed even though
        // select_copies would still protect at copy time.
        let allow = parse_allowlist("**").unwrap();
        assert!(!allowlist_violations(&allow).is_empty());
    }
}