use std::path::Path;
use crate::catalog::diagnostic::CatalogDiagnostic;
use crate::catalog::hydrate::{CatalogEdgeLabel, CatalogKey};
use crate::catalog::scan::{self, ScanMode};
use crate::finding::{Category, Finding};
pub(crate) fn raw_label_findings(root: &Path) -> Vec<Finding> {
let Ok(catalog) = crate::catalog::hydrate::scan_catalog(root, ScanMode::default()) else {
return Vec::new();
};
catalog
.edges
.iter()
.filter_map(|edge| {
if let CatalogEdgeLabel::Raw(label) = &edge.label {
Some(Finding {
category: Category::RawLabel,
entity: Some(edge.source.canonical()),
message: format!("raw label: {label}"),
})
} else {
None
}
})
.collect()
}
pub(crate) fn toml_parse_findings(root: &Path) -> Vec<Finding> {
let mut findings = Vec::new();
let mut diagnostics = Vec::new();
let _scan_result = scan::scan_entities(root, &mut diagnostics, ScanMode::default());
for d in diagnostics {
if is_facet_diagnostic(&d) {
findings.push(Finding {
category: Category::TomlParse,
entity: d.entity_key.as_ref().map(CatalogKey::canonical),
message: d.message,
});
}
}
let slice_root = root.join(".doctrine/slice");
let Ok(entries) = std::fs::read_dir(&slice_root) else {
return findings;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_symlink() {
continue;
}
let plan_path = path.join("plan.toml");
if !plan_path.is_file() {
continue;
}
let Ok(text) = std::fs::read_to_string(&plan_path) else {
continue;
};
if text.parse::<toml::Table>().is_err() {
findings.push(Finding {
category: Category::TomlParse,
entity: Some(plan_path.display().to_string()),
message: String::from("unparseable plan.toml"),
});
}
}
findings
}
fn is_facet_diagnostic(d: &CatalogDiagnostic) -> bool {
matches!(d.field.as_deref(), Some("estimate" | "value" | "facet"))
}
pub(crate) fn prose_cite_findings(root: &Path) -> Vec<Finding> {
let mut findings = Vec::new();
let pattern = root.join(".doctrine/**/*.md");
let Some(pattern_str) = pattern.to_str() else {
return findings;
};
let Ok(entries) = glob::glob(pattern_str) else {
return findings;
};
let Ok(re) = regex::Regex::new(r"[A-Z]{2,}-[0-9]+(-[A-Za-z0-9]+)*") else {
return findings;
};
let Ok(doc_local_re) = regex::Regex::new(r"^[A-Z][0-9]+$") else {
return findings;
};
for entry in entries.flatten() {
if !entry.is_file() {
continue;
}
if is_disposable_prose_d11(&entry) {
continue;
}
let Ok(text) = std::fs::read_to_string(&entry) else {
continue;
};
let mut in_fence = false;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with("```") {
in_fence = !in_fence;
continue;
}
if in_fence {
continue;
}
let segments = non_code_segments(line);
for seg in &segments {
for m in re.find_iter(seg) {
let token = m.as_str();
if token.ends_with("-SENTINEL") {
continue;
}
if doc_local_re.is_match(token) {
continue;
}
let hyphen_count = token.matches('-').count();
if hyphen_count >= 2 {
continue;
}
let Some((prefix, _num)) = token.split_once('-') else {
continue; };
if crate::integrity::kind_by_prefix(prefix).is_none() {
continue;
}
if crate::integrity::ensure_ref_resolves(root, token).is_err() {
findings.push(Finding {
category: Category::ProseCite,
entity: Some(entry.display().to_string()),
message: format!("unresolved citation: {token}"),
});
}
}
}
}
}
findings
}
fn is_disposable_prose_d11(path: &Path) -> bool {
if crate::integrity::is_disposable_prose(path) {
return true;
}
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if matches!(name, "audit.md" | "inquisition.md" | "notes.md") {
return true;
}
let path_str = path.to_string_lossy();
path_str.contains("/research/") || path_str.contains(".doctrine/review/")
}
fn non_code_segments(line: &str) -> Vec<&str> {
let parts: Vec<&str> = line.split('`').collect();
parts
.into_iter()
.enumerate()
.filter_map(|(i, s)| if i % 2 == 0 { Some(s) } else { None })
.collect()
}
const AGENT_SCAN_ROOTS: [&str; 2] = ["install/agents", ".doctrine/agents"];
const ROLE_MARKER_KEY: &str = "doctrine-role";
const ROLE_WORKER: &str = "worker";
const ROLE_ORCHESTRATOR: &str = "orchestrator";
const TOOL_ALLOWED: &str = "mcp__doctrine__worker_commit";
const MCP_TOKEN_PREFIX: &str = "mcp__";
pub(crate) fn agent_conformance_findings(root: &Path) -> Vec<Finding> {
let mut findings = Vec::new();
for rel in AGENT_SCAN_ROOTS {
collect_agent_def_findings(&root.join(rel), root, &mut findings);
}
findings
}
fn collect_agent_def_findings(dir: &Path, root: &Path, findings: &mut Vec<Finding>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return; };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_agent_def_findings(&path, root, findings);
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let Some(fm) = frontmatter(&text) else {
continue; };
let Some(name) = fm_scalar(fm, "name") else {
continue; };
let _ = name;
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.display()
.to_string();
match fm_scalar(fm, ROLE_MARKER_KEY) {
Some(role) if role == ROLE_WORKER || role == ROLE_ORCHESTRATOR => {}
Some(role) => {
findings.push(Finding {
category: Category::AgentConformance,
entity: Some(rel.clone()),
message: format!(
"invalid `{ROLE_MARKER_KEY}: {role}` (expected `{ROLE_WORKER}` or `{ROLE_ORCHESTRATOR}`)"
),
});
continue;
}
None => {
findings.push(Finding {
category: Category::AgentConformance,
entity: Some(rel.clone()),
message: format!("missing mandatory `{ROLE_MARKER_KEY}` marker"),
});
continue;
}
}
for tok in fm_tool_tokens(fm) {
if tok.starts_with(MCP_TOKEN_PREFIX) && tok != TOOL_ALLOWED {
findings.push(Finding {
category: Category::AgentConformance,
entity: Some(rel.clone()),
message: format!(
"forbidden MCP token `{tok}` — a confined agent may hold only `{TOOL_ALLOWED}`"
),
});
}
}
}
}
fn frontmatter(text: &str) -> Option<&str> {
let rest = text.strip_prefix("---")?;
let rest = rest
.strip_prefix('\n')
.or_else(|| rest.strip_prefix("\r\n"))?;
let end = rest.find("\n---")?;
Some(&rest[..end])
}
fn fm_scalar<'a>(fm: &'a str, key: &str) -> Option<&'a str> {
for line in fm.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix(key)
&& let Some(val) = rest.trim_start().strip_prefix(':')
{
let val = val.trim();
if !val.is_empty() {
return Some(val);
}
}
}
None
}
fn fm_tool_tokens(fm: &str) -> Vec<String> {
let mut lines = fm.lines();
let mut tokens = Vec::new();
while let Some(line) = lines.next() {
let trimmed = line.trim();
let Some(rest) = trimmed.strip_prefix("tools") else {
continue;
};
let Some(val) = rest.trim_start().strip_prefix(':') else {
continue;
};
let val = val.trim();
if val.is_empty() {
for l in lines.by_ref() {
let lt = l.trim();
if let Some(item) = lt.strip_prefix('-') {
let item = item.trim();
if !item.is_empty() {
tokens.push(item.to_string());
}
} else if !lt.is_empty() {
break; }
}
} else {
tokens.extend(
val.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty()),
);
}
break;
}
tokens
}
#[cfg(test)]
#[expect(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
mod tests {
use super::*;
use crate::catalog::hydrate::{
Catalog, CatalogEdge, CatalogEdgeLabel, CatalogKey, EdgeTarget, Units,
};
use crate::catalog::test_helpers::*;
use std::collections::BTreeMap;
use std::path::PathBuf;
fn seed_entity_dir(root: &Path, prefix: &str, id: u32) {
use crate::integrity::kind_by_prefix;
let Some(kref) = kind_by_prefix(prefix) else {
return;
};
let dir = root.join(kref.kind.dir).join(format!("{id:03}"));
std::fs::create_dir_all(&dir).unwrap();
}
fn scan_md(root: &Path, prose: &str) -> Vec<Finding> {
let file = root.join(".doctrine/slice/099/slice-099.md");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(&file, prose).unwrap();
prose_cite_findings(root)
}
fn root_with_sl001() -> tempfile::TempDir {
let dir = tmp();
seed_entity_dir(dir.path(), "SL", 1);
dir
}
fn test_units() -> Units {
Units {
estimation: "espresso_shots".to_string(),
value: "magic_beans".to_string(),
}
}
fn catalog_with_raw_edge() -> Catalog {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[("references(implements)", &["REQ-001"])]);
let mut catalog = Catalog::from_scanned(
root,
&[crate::catalog::scan::ScannedEntity {
key: crate::catalog::scan::EntityKey {
prefix: "SL",
id: 1,
},
kind: &crate::slice::SLICE_KIND,
status: Some("proposed".to_string()),
title: "SL-001".to_string(),
outbound: vec![crate::relation::RelationEdge::with_role(
crate::relation::RelationLabel::References,
Some(crate::relation::Role::Implements),
"REQ-001".to_string(),
)],
estimate: None,
value: None,
risk: None,
tags: vec![],
body: None,
}],
&[],
&BTreeMap::new(),
test_units(),
);
catalog.edges.push(CatalogEdge {
source: CatalogKey::Memory("mem_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string()),
label: CatalogEdgeLabel::Raw("custom-label".to_string()),
role: None,
descriptor: None,
target: EdgeTarget::UnvalidatedText {
raw: "free text target".to_string(),
},
origin: crate::catalog::hydrate::EdgeOrigin {
file: PathBuf::from("memory.toml"),
field: Some("custom-label".to_string()),
},
});
catalog
}
#[test]
fn raw_label_finds_raw_edge() {
let catalog = catalog_with_raw_edge();
let findings: Vec<Finding> = catalog
.edges
.iter()
.filter_map(|edge| {
if let CatalogEdgeLabel::Raw(label) = &edge.label {
Some(Finding {
category: Category::RawLabel,
entity: Some(edge.source.canonical()),
message: format!("raw label: {label}"),
})
} else {
None
}
})
.collect();
assert_eq!(findings.len(), 1, "one Raw edge should produce one finding");
let f = &findings[0];
assert_eq!(f.category, Category::RawLabel);
assert_eq!(f.category.severity(), crate::finding::Severity::Warning);
assert!(f.entity.as_deref().unwrap().starts_with("mem_"));
assert!(f.message.contains("custom-label"));
}
#[test]
fn raw_label_skips_validated_edge() {
let catalog = catalog_with_raw_edge();
let has_validated_finding = catalog
.edges
.iter()
.any(|edge| matches!(&edge.label, CatalogEdgeLabel::Validated(_)));
assert!(has_validated_finding, "fixture has a Validated edge");
let raw_count = catalog
.edges
.iter()
.filter(|e| matches!(e.label, CatalogEdgeLabel::Raw(_)))
.count();
assert_eq!(raw_count, 1);
}
#[test]
fn raw_label_verify_disjointness_from_illegal_rows() {
let f = Finding {
category: Category::RawLabel,
entity: Some("mem_xxx".into()),
message: "raw label: test".into(),
};
assert_eq!(f.category.severity(), crate::finding::Severity::Warning);
assert_eq!(
Category::RelationIntegrity.severity(),
crate::finding::Severity::Error
);
}
#[test]
fn toml_parse_flags_malformed_facet() {
let dir = tmp();
let root = dir.path();
write(
root,
".doctrine/adr/001/adr-001.toml",
"id = 1\nslug = \"a1\"\ntitle = \"A1\"\nstatus = \"accepted\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
estimate = 7\n",
);
write(root, ".doctrine/adr/001/adr-001.md", "body\n");
let findings = toml_parse_findings(root);
assert!(
!findings.is_empty(),
"malformed facet should produce TomlParse findings"
);
for f in &findings {
assert_eq!(f.category, Category::TomlParse);
assert_eq!(f.category.severity(), crate::finding::Severity::Warning);
assert!(
f.message.contains("must be a table") || f.message.contains("estimate"),
"message should mention estimate: {}",
f.message
);
}
}
#[test]
fn toml_parse_flags_malformed_plan_toml() {
let dir = tmp();
let root = dir.path();
let plan_dir = root.join(".doctrine/slice/001");
std::fs::create_dir_all(&plan_dir).unwrap();
std::fs::write(plan_dir.join("plan.toml"), "this is not valid toml [[[[").unwrap();
let findings = toml_parse_findings(root);
let plan_findings: Vec<&Finding> = findings
.iter()
.filter(|f| f.message.contains("unparseable plan.toml"))
.collect();
assert_eq!(
plan_findings.len(),
1,
"malformed plan.toml should produce one finding"
);
let f = plan_findings[0];
assert!(f.entity.as_deref().unwrap().contains("plan.toml"));
}
#[test]
fn toml_parse_skips_symlink_slice_dirs() {
let dir = tmp();
let root = dir.path();
let real_dir = root.join(".doctrine/slice/001");
std::fs::create_dir_all(&real_dir).unwrap();
std::fs::write(real_dir.join("plan.toml"), "not toml [[").unwrap();
let symlink = root.join(".doctrine/slice/001-my-slug");
#[cfg(unix)]
std::os::unix::fs::symlink("001", &symlink).unwrap();
#[cfg(not(unix))]
{
std::fs::write(&symlink, "not a symlink").unwrap();
}
let findings = toml_parse_findings(root);
let plan_count = findings
.iter()
.filter(|f| f.message.contains("unparseable plan.toml"))
.count();
if cfg!(unix) {
assert_eq!(plan_count, 1, "symlink slice dir must be skipped");
}
}
#[test]
fn toml_parse_excludes_entity_level_diagnostics() {
let dir = tmp();
let root = dir.path();
write(
root,
".doctrine/slice/002/slice-002.toml",
"id = notanumber\n",
);
write(root, ".doctrine/slice/002/slice-002.md", "scope\n");
let findings = toml_parse_findings(root);
for f in &findings {
assert!(
!f.message.contains("notanumber"),
"entity-level diag must not appear: {}",
f.message
);
}
}
#[test]
fn toml_parse_severity_is_warning() {
assert_eq!(
Category::TomlParse.severity(),
crate::finding::Severity::Warning
);
}
#[test]
fn prose_cite_severity_is_warning() {
assert_eq!(
Category::ProseCite.severity(),
crate::finding::Severity::Warning
);
}
#[test]
fn prose_cite_resolved_2part_produces_no_finding() {
let dir = root_with_sl001();
let root = dir.path();
let findings = scan_md(root, "see SL-001 for details");
assert!(
findings.is_empty(),
"resolved SL-001 should produce no finding: {findings:?}"
);
}
#[test]
fn prose_cite_dangling_2part_produces_finding() {
let dir = root_with_sl001();
let root = dir.path();
let findings = scan_md(root, "see SL-999 for details");
assert_eq!(findings.len(), 1, "dangling SL-999 should produce finding");
let f = &findings[0];
assert_eq!(f.category, Category::ProseCite);
assert!(f.message.contains("SL-999"), "message: {}", f.message);
assert!(f.message.contains("unresolved citation"));
}
#[test]
fn prose_cite_code_span_inline_no_finding() {
let dir = root_with_sl001();
let root = dir.path();
let findings = scan_md(root, "use `SL-001` as reference");
assert!(
findings.is_empty(),
"backtick-wrapped SL-001 should be skipped: {findings:?}"
);
}
#[test]
fn prose_cite_code_span_fenced_block_no_finding() {
let dir = root_with_sl001();
let root = dir.path();
let prose = "before\n```\nSL-999\n```\nafter";
let findings = scan_md(root, prose);
assert!(
findings.is_empty(),
"fenced block SL-999 should be skipped: {findings:?}"
);
}
#[test]
fn prose_cite_fenced_block_toggle_respects_boundaries() {
let dir = root_with_sl001();
let root = dir.path();
let prose = "SL-999 is outside\n```\nSL-888\n```\nSL-999 again outside";
let findings = scan_md(root, prose);
assert_eq!(findings.len(), 2, "both SL-999 outside fence reported");
for f in &findings {
assert!(f.message.contains("SL-999"));
}
}
#[test]
fn prose_cite_sentinel_no_finding() {
let dir = root_with_sl001();
let root = dir.path();
let findings = scan_md(root, "BOOT-SENTINEL: doctrine-governance-snapshot");
assert!(
findings.is_empty(),
"BOOT-SENTINEL should be skipped: {findings:?}"
);
}
#[test]
fn prose_cite_doc_local_no_finding() {
let dir = root_with_sl001();
let root = dir.path();
let findings = scan_md(root, "see D1 and R1 and C1 and Q1");
assert!(
findings.is_empty(),
"doc-local refs should be skipped: {findings:?}"
);
}
#[test]
fn prose_cite_3part_no_finding() {
let dir = root_with_sl001();
let root = dir.path();
let findings = scan_md(root, "see DEC-005-C and DEC-010-06");
assert!(
findings.is_empty(),
"3-part tokens should be skipped: {findings:?}"
);
}
#[test]
fn prose_cite_compound_3part_no_finding() {
let dir = root_with_sl001();
let root = dir.path();
let findings = scan_md(root, "the SL-048-style pattern");
assert!(
findings.is_empty(),
"SL-048-style (3-part) should be skipped: {findings:?}"
);
}
#[test]
fn prose_cite_unknown_prefix_no_finding() {
let dir = root_with_sl001();
let root = dir.path();
let findings = scan_md(root, "use SHA-256 for PHASE-03 hashing");
assert!(
findings.is_empty(),
"unknown prefix tokens should be skipped: {findings:?}"
);
}
#[test]
fn prose_cite_maximal_token_no_submatch() {
let dir = root_with_sl001();
let root = dir.path();
let findings = scan_md(root, "the DEC-005-C encoding");
assert!(
findings.is_empty(),
"DEC-005-C must be 3-part only, not 2-part DEC-005: {findings:?}"
);
}
#[test]
fn prose_cite_skips_audit_md() {
let dir = root_with_sl001();
let root = dir.path();
let file = root.join(".doctrine/slice/001/audit.md");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(&file, "dangling SL-999 here\n").unwrap();
let findings = prose_cite_findings(root);
assert!(
findings.is_empty(),
"audit.md should be skipped: {findings:?}"
);
}
#[test]
fn prose_cite_skips_inquisition_md() {
let dir = root_with_sl001();
let root = dir.path();
let file = root.join(".doctrine/slice/001/inquisition.md");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(&file, "dangling SL-999 here\n").unwrap();
let findings = prose_cite_findings(root);
assert!(
findings.is_empty(),
"inquisition.md should be skipped: {findings:?}"
);
}
#[test]
fn prose_cite_skips_notes_md() {
let dir = root_with_sl001();
let root = dir.path();
let file = root.join(".doctrine/slice/001/notes.md");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(&file, "dangling SL-999 here\n").unwrap();
let findings = prose_cite_findings(root);
assert!(
findings.is_empty(),
"notes.md should be skipped: {findings:?}"
);
}
#[test]
fn prose_cite_skips_research_dir() {
let dir = root_with_sl001();
let root = dir.path();
let file = root.join(".doctrine/research/notes/some.md");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(&file, "dangling SL-999 here\n").unwrap();
let findings = prose_cite_findings(root);
assert!(
findings.is_empty(),
"research/ should be skipped: {findings:?}"
);
}
#[test]
fn prose_cite_skips_doctrine_review_dir() {
let dir = root_with_sl001();
let root = dir.path();
let file = root.join(".doctrine/review/042/summary.md");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(&file, "dangling SL-999 here\n").unwrap();
let findings = prose_cite_findings(root);
assert!(
findings.is_empty(),
".doctrine/review/ should be skipped: {findings:?}"
);
}
#[test]
fn prose_cite_scans_non_skipped_file() {
let dir = root_with_sl001();
let root = dir.path();
let file = root.join(".doctrine/slice/002/slice-002.md");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(&file, "dangling SL-999 here\n").unwrap();
let findings = prose_cite_findings(root);
assert_eq!(
findings.len(),
1,
"authored prose should produce finding: {findings:?}"
);
assert!(findings[0].message.contains("SL-999"));
}
#[test]
fn prose_cite_skips_handover_md() {
let dir = root_with_sl001();
let root = dir.path();
let file = root.join(".doctrine/slice/001/handover.md");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(&file, "dangling SL-999 here\n").unwrap();
let findings = prose_cite_findings(root);
assert!(
findings.is_empty(),
"handover.md should be skipped via is_disposable_prose: {findings:?}"
);
}
#[test]
fn prose_cite_skips_doctrine_state() {
let dir = root_with_sl001();
let root = dir.path();
let file = root.join(".doctrine/state/slice/001/phase-01.md");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(&file, "dangling SL-999 here\n").unwrap();
let findings = prose_cite_findings(root);
assert!(
findings.is_empty(),
".doctrine/state should be skipped via is_disposable_prose: {findings:?}"
);
}
fn write_def(root: &Path, rel: &str, body: &str) {
let path = root.join("install/agents").join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, body).unwrap();
}
#[test]
fn agent_conformance_passes_marked_worker_with_only_worker_commit() {
let dir = tmp();
write_def(
dir.path(),
"claude/dispatch-worker.md",
"---\nname: dispatch-worker\ndoctrine-role: worker\ntools: Read, Edit, Write, Bash, mcp__doctrine__worker_commit\n---\nbody\n",
);
let findings = agent_conformance_findings(dir.path());
assert!(
findings.is_empty(),
"pinned worker should pass: {findings:?}"
);
}
#[test]
fn agent_conformance_fails_unmarked_def() {
let dir = tmp();
write_def(
dir.path(),
"claude/dispatch-worker.md",
"---\nname: dispatch-worker\ntools: Read, Edit, mcp__doctrine__worker_commit\n---\nbody\n",
);
let findings = agent_conformance_findings(dir.path());
assert_eq!(
findings.len(),
1,
"unmarked def is a failure (deny-by-default)"
);
assert_eq!(findings[0].category, Category::AgentConformance);
assert_eq!(
findings[0].category.severity(),
crate::finding::Severity::Error
);
assert!(findings[0].message.contains("doctrine-role"));
}
#[test]
fn agent_conformance_fails_marked_def_with_extra_mcp_token() {
let dir = tmp();
write_def(
dir.path(),
"claude/rogue.md",
"---\nname: rogue\ndoctrine-role: worker\ntools: Read, mcp__doctrine__worker_commit, mcp__github__create_pr\n---\nbody\n",
);
let findings = agent_conformance_findings(dir.path());
assert_eq!(findings.len(), 1, "extra writable MCP token must fail");
assert!(findings[0].message.contains("mcp__github__create_pr"));
}
#[test]
fn agent_conformance_fails_bare_server_grant() {
let dir = tmp();
write_def(
dir.path(),
"claude/bare.md",
"---\nname: bare\ndoctrine-role: worker\ntools: Read, mcp__doctrine\n---\nbody\n",
);
let findings = agent_conformance_findings(dir.path());
assert_eq!(
findings.len(),
1,
"bare mcp__doctrine server grant must fail"
);
assert!(findings[0].message.contains("mcp__doctrine"));
}
#[test]
fn agent_conformance_skips_readme_without_frontmatter() {
let dir = tmp();
write_def(
dir.path(),
"AGENTS.md",
"# Agents\n\nJust a readme, no frontmatter.\n",
);
let findings = agent_conformance_findings(dir.path());
assert!(
findings.is_empty(),
"non-frontmatter readme is not a def: {findings:?}"
);
}
#[test]
fn agent_conformance_empty_when_roots_absent() {
let dir = tmp();
let findings = agent_conformance_findings(dir.path());
assert!(
findings.is_empty(),
"absent scan roots yield no findings (downstream-safe)"
);
}
}