use glob::Pattern;
use crate::globmatch::glob_matches;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Tier {
State,
PhaseLink,
Handover,
Inquisition,
Research,
MemoryCache,
}
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",
};
f.write_str(name)
}
}
#[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 }
}
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/**"),
];
#[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/*"];
#[derive(Debug)]
pub(crate) struct Allowlist {
pub(crate) patterns: Vec<Pattern>,
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum ParseError {
#[error("line {line}: negation (`!`) is unsupported in .worktreeinclude v1: `{raw}`")]
Negation { line: usize, raw: String },
#[error("line {line}: anchoring (leading `/`) is unsupported in .worktreeinclude v1: `{raw}`")]
Anchoring { line: usize, raw: String },
#[error("line {line}: invalid glob `{raw}`: {source}")]
BadGlob {
line: usize,
raw: String,
#[source]
source: glob::PatternError,
},
}
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 })
}
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)
})
}
#[derive(Debug)]
pub(crate) struct Withheld {
pub(crate) path: String,
pub(crate) tier: Tier,
}
#[derive(Debug)]
pub(crate) struct Selection {
pub(crate) copy: Vec<String>,
pub(crate) withheld: Vec<Withheld>,
}
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 }
}
#[derive(Debug)]
pub(crate) struct Violation {
pub(crate) pattern: String,
pub(crate) tier: Tier,
}
fn representative(glob: &str) -> String {
glob.replace("**", "x").replace(['*', '?'], "x")
}
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
}
#[cfg(test)]
mod tests {
use super::*;
#[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 { .. }));
}
#[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)
);
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);
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());
}
#[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() {
let allow = parse_allowlist("**").unwrap();
assert!(!allowlist_violations(&allow).is_empty());
}
}